diff --git a/testgen/__pycache__/corrupt.cpython-312.pyc b/testgen/__pycache__/corrupt.cpython-312.pyc new file mode 100644 index 0000000..1e3bb5a Binary files /dev/null and b/testgen/__pycache__/corrupt.cpython-312.pyc differ diff --git a/testgen/__pycache__/generate.cpython-312.pyc b/testgen/__pycache__/generate.cpython-312.pyc new file mode 100644 index 0000000..ca85862 Binary files /dev/null and b/testgen/__pycache__/generate.cpython-312.pyc differ diff --git a/testgen/__pycache__/pools.cpython-312.pyc b/testgen/__pycache__/pools.cpython-312.pyc new file mode 100644 index 0000000..2e27939 Binary files /dev/null and b/testgen/__pycache__/pools.cpython-312.pyc differ diff --git a/testgen/__pycache__/templates.cpython-312.pyc b/testgen/__pycache__/templates.cpython-312.pyc new file mode 100644 index 0000000..c699fa1 Binary files /dev/null and b/testgen/__pycache__/templates.cpython-312.pyc differ diff --git a/testgen/corrupt.py b/testgen/corrupt.py new file mode 100644 index 0000000..1b1e5de --- /dev/null +++ b/testgen/corrupt.py @@ -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 diff --git a/testgen/generate.py b/testgen/generate.py new file mode 100644 index 0000000..8c3ea19 --- /dev/null +++ b/testgen/generate.py @@ -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() diff --git a/testgen/out/duplicates/copy_of_спецификация-XXX006-06800_v2.docx b/testgen/out/duplicates/copy_of_спецификация-XXX006-06800_v2.docx new file mode 100644 index 0000000..0bd1721 Binary files /dev/null and b/testgen/out/duplicates/copy_of_спецификация-XXX006-06800_v2.docx differ diff --git a/testgen/out/duplicates/спецификация-XXX006-06800_v2.docx b/testgen/out/duplicates/спецификация-XXX006-06800_v2.docx new file mode 100644 index 0000000..b45a5ca Binary files /dev/null and b/testgen/out/duplicates/спецификация-XXX006-06800_v2.docx differ diff --git a/testgen/out/errors/_tmp_valid.docx b/testgen/out/errors/_tmp_valid.docx new file mode 100644 index 0000000..1c1cc4e Binary files /dev/null and b/testgen/out/errors/_tmp_valid.docx differ diff --git a/testgen/out/errors/corrupt_xml.docx b/testgen/out/errors/corrupt_xml.docx new file mode 100644 index 0000000..569e0e3 Binary files /dev/null and b/testgen/out/errors/corrupt_xml.docx differ diff --git a/testgen/out/errors/empty.docx b/testgen/out/errors/empty.docx new file mode 100644 index 0000000..12b3e60 Binary files /dev/null and b/testgen/out/errors/empty.docx differ diff --git a/testgen/out/errors/empty_zero.docx b/testgen/out/errors/empty_zero.docx new file mode 100644 index 0000000..e69de29 diff --git a/testgen/out/errors/giant_500.docx b/testgen/out/errors/giant_500.docx new file mode 100644 index 0000000..7b7a9c5 Binary files /dev/null and b/testgen/out/errors/giant_500.docx differ diff --git a/testgen/out/errors/no_number.docx b/testgen/out/errors/no_number.docx new file mode 100644 index 0000000..c04b442 Binary files /dev/null and b/testgen/out/errors/no_number.docx differ diff --git a/testgen/out/errors/nonstandard_title.docx b/testgen/out/errors/nonstandard_title.docx new file mode 100644 index 0000000..9c1b05e Binary files /dev/null and b/testgen/out/errors/nonstandard_title.docx differ diff --git a/testgen/out/errors/orphan_spec.docx b/testgen/out/errors/orphan_spec.docx new file mode 100644 index 0000000..39951a3 Binary files /dev/null and b/testgen/out/errors/orphan_spec.docx differ diff --git a/testgen/out/errors/table_only.docx b/testgen/out/errors/table_only.docx new file mode 100644 index 0000000..76cc1a3 Binary files /dev/null and b/testgen/out/errors/table_only.docx differ diff --git a/testgen/out/errors/text_only.docx b/testgen/out/errors/text_only.docx new file mode 100644 index 0000000..2965f43 Binary files /dev/null and b/testgen/out/errors/text_only.docx differ diff --git a/testgen/out/errors/trash_1.docx b/testgen/out/errors/trash_1.docx new file mode 100644 index 0000000..46f2311 Binary files /dev/null and b/testgen/out/errors/trash_1.docx differ diff --git a/testgen/out/errors/trash_2.docx b/testgen/out/errors/trash_2.docx new file mode 100644 index 0000000..11f50b1 Binary files /dev/null and b/testgen/out/errors/trash_2.docx differ diff --git a/testgen/out/errors/trash_3.docx b/testgen/out/errors/trash_3.docx new file mode 100644 index 0000000..901238b Binary files /dev/null and b/testgen/out/errors/trash_3.docx differ diff --git a/testgen/out/formats/_base.docx b/testgen/out/formats/_base.docx new file mode 100644 index 0000000..1a72f89 Binary files /dev/null and b/testgen/out/formats/_base.docx differ diff --git a/testgen/out/manifest.json b/testgen/out/manifest.json new file mode 100644 index 0000000..6ed6f82 --- /dev/null +++ b/testgen/out/manifest.json @@ -0,0 +1,1394 @@ +{ + "договор-XXX001-03700.docx": { + "type": "contract", + "company": "XXX001", + "number": "03700", + "date": "23.12.2025", + "status": "valid", + "groups": [ + { + "contract_number": "03700", + "counterparty": "XXX001" + } + ] + }, + "спецификация-XXX001-03700.docx": { + "type": "spec", + "company": "XXX001", + "number": "03700", + "version": 1, + "date": "23.12.2025", + "status": "valid", + "services": [ + { + "name": "Аренда IPv4 подсети /30", + "price": 681.16, + "qty": 4, + "sum": 2724.64 + }, + { + "name": "Next Generation Cloud (Intel 3.0 ГГц), в составе:\n Количество vCPU: 8, RAM: 32 ГБ", + "price": 85723.58, + "qty": 2, + "sum": 171447.16 + }, + { + "name": "Организация cross-connect: UTP", + "price": 870.63, + "qty": 2, + "sum": 1741.26 + }, + { + "name": "Аренда порта без резерва, в составе:\n Скорость порта: 1 Гбит/с", + "price": 1404.36, + "qty": 3, + "sum": 4213.08 + }, + { + "name": "Организация cross-connect: ВОЛС, 2×SMF", + "price": 3843.89, + "qty": 4, + "sum": 15375.56 + }, + { + "name": "Аренда публичной подсети (Длина префикса: /30 — 1 IPv4 адрес)", + "price": 558.25, + "qty": 2, + "sum": 1116.5 + }, + { + "name": "Аренда PDU: 3Ф 32А вертикальный", + "price": 2043.73, + "qty": 5, + "sum": 10218.65 + }, + { + "name": "Организация L2 канала без резерва, в составе:\n Скорость порта: 1 Гбит/с", + "price": 11334.32, + "qty": 3, + "sum": 34002.96 + }, + { + "name": "Аренда IPv4 адреса /32", + "price": 127.33, + "qty": 11, + "sum": 1400.63 + } + ] + }, + "спецификация-XXX001-03700_v2.docx": { + "type": "spec", + "company": "XXX001", + "number": "03700", + "version": 2, + "date": "23.12.2025", + "status": "valid", + "services": [ + { + "name": "Аренда IPv4 подсети /30", + "price": 681.16, + "qty": 4, + "sum": 2724.64 + }, + { + "name": "Next Generation Cloud (Intel 3.0 ГГц), в составе:\n Количество vCPU: 8, RAM: 32 ГБ", + "price": 85723.58, + "qty": 2, + "sum": 171447.16 + }, + { + "name": "Организация cross-connect: UTP", + "price": 870.63, + "qty": 2, + "sum": 1741.26 + }, + { + "name": "Аренда порта без резерва, в составе:\n Скорость порта: 1 Гбит/с", + "price": 1404.36, + "qty": 3, + "sum": 4213.08 + }, + { + "name": "Аренда публичной подсети (Длина префикса: /30 — 1 IPv4 адрес)", + "price": 558.25, + "qty": 2, + "sum": 1116.5 + }, + { + "name": "Аренда PDU: 3Ф 32А вертикальный", + "price": 1752.53, + "qty": 5, + "sum": 8762.65 + }, + { + "name": "Организация L2 канала без резерва, в составе:\n Скорость порта: 1 Гбит/с", + "price": 11334.32, + "qty": 3, + "sum": 34002.96 + }, + { + "name": "Аренда IPv4 адреса /32", + "price": 127.33, + "qty": 11, + "sum": 1400.63 + }, + { + "name": "Интернет без защиты от DDoS, в составе:\n Полоса Интернет: 100 Мбит/с", + "price": 7936.82, + "qty": 1, + "sum": 7936.82 + } + ], + "diff": { + "added": [ + "Интернет без защиты от DDoS, в составе:\n Полоса Интер" + ], + "removed": [ + "Организация cross-connect: ВОЛС, 2×SMF" + ], + "changed": [ + "цена Аренда PDU: 3Ф 32А вертикальный: 2043.73→1752.53" + ], + "description": "цена Аренда PDU: 3Ф 32А вертикальный 2043.73→1752.53, объём Организация cross-connect: ВОЛС, 2×SMF 4→3, удалена: Организация cross-connect: ВОЛС, 2×SMF, добавлена: Интернет без защиты от DDoS, в составе:\n" + } + }, + "допник-1-XXX001-03700.docx": { + "type": "addendum", + "company": "XXX001", + "number": "03700", + "addendum_num": 1, + "date": "02.02.2026", + "status": "valid" + }, + "договор-XXX002-01200.docx": { + "type": "contract", + "company": "XXX002", + "number": "01200", + "date": "25.06.2026", + "status": "valid", + "groups": [ + { + "contract_number": "01200", + "counterparty": "XXX002" + } + ] + }, + "спецификация-XXX002-01200.docx": { + "type": "spec", + "company": "XXX002", + "number": "01200", + "version": 1, + "date": "25.06.2026", + "status": "valid", + "services": [ + { + "name": "Next Generation Cloud (Intel 3.0 ГГц), в составе:\n Количество vCPU: 8, RAM: 32 ГБ", + "price": 84714.59, + "qty": 2, + "sum": 169429.18 + }, + { + "name": "Аренда PDU: 3Ф 32А вертикальный", + "price": 2620.72, + "qty": 2, + "sum": 5241.44 + }, + { + "name": "Аренда IPv4 адреса /32", + "price": 128.29, + "qty": 13, + "sum": 1667.77 + }, + { + "name": "Next Generation Cloud (AMD 4.0 ГГц), в составе:\n Количество vCPU: 4, RAM: 16 ГБ", + "price": 125836.76, + "qty": 2, + "sum": 251673.52 + }, + { + "name": "Аренда порта без резерва, в составе:\n Скорость порта: 1 Гбит/с", + "price": 1811.72, + "qty": 1, + "sum": 1811.72 + } + ] + }, + "спецификация-XXX002-01200_v2.docx": { + "type": "spec", + "company": "XXX002", + "number": "01200", + "version": 2, + "date": "25.06.2026", + "status": "valid", + "services": [ + { + "name": "Next Generation Cloud (Intel 3.0 ГГц), в составе:\n Количество vCPU: 8, RAM: 32 ГБ", + "price": 84714.59, + "qty": 2, + "sum": 169429.18 + }, + { + "name": "Аренда PDU: 3Ф 32А вертикальный", + "price": 2620.72, + "qty": 2, + "sum": 5241.44 + }, + { + "name": "Next Generation Cloud (AMD 4.0 ГГц), в составе:\n Количество vCPU: 4, RAM: 16 ГБ", + "price": 125836.76, + "qty": 2, + "sum": 251673.52 + }, + { + "name": "Аренда порта без резерва, в составе:\n Скорость порта: 1 Гбит/с", + "price": 2092.5, + "qty": 1, + "sum": 2092.5 + }, + { + "name": "Аренда IPv4 подсети /30", + "price": 589.52, + "qty": 2, + "sum": 1179.04 + } + ], + "diff": { + "added": [ + "Аренда IPv4 подсети /30" + ], + "removed": [ + "Аренда IPv4 адреса /32" + ], + "changed": [ + "цена Аренда порта без резерва, в составе:\n : 1811.72→2092.5" + ], + "description": "цена Аренда порта без резерва, в составе:\n 1811.72→2092.5, удалена: Аренда IPv4 адреса /32, добавлена: Аренда IPv4 подсети /30" + } + }, + "допник-1-XXX002-01200.docx": { + "type": "addendum", + "company": "XXX002", + "number": "01200", + "addendum_num": 1, + "date": "06.05.2026", + "status": "valid" + }, + "договор-XXX003-01300.docx": { + "type": "contract", + "company": "XXX003", + "number": "01300", + "date": "12.03.2026", + "status": "valid", + "groups": [ + { + "contract_number": "01300", + "counterparty": "XXX003" + } + ] + }, + "спецификация-XXX003-01300.docx": { + "type": "spec", + "company": "XXX003", + "number": "01300", + "version": 1, + "date": "12.03.2026", + "status": "valid", + "services": [ + { + "name": "Next Generation Cloud (AMD 4.0 ГГц), в составе:\n Количество vCPU: 4, RAM: 16 ГБ", + "price": 133119.69, + "qty": 1, + "sum": 133119.69 + }, + { + "name": "Интернет без защиты от DDoS, в составе:\n Полоса Интернет: 100 Мбит/с", + "price": 5639.68, + "qty": 2, + "sum": 11279.36 + }, + { + "name": "Аренда IPv4 адреса /32", + "price": 147.51, + "qty": 12, + "sum": 1770.12 + }, + { + "name": "Организация cross-connect: ВОЛС, 2×SMF", + "price": 3517.39, + "qty": 4, + "sum": 14069.56 + }, + { + "name": "Аренда стойко-места", + "price": 193315.23, + "qty": 3, + "sum": 579945.69 + }, + { + "name": "Аренда PDU: 3Ф 32А вертикальный", + "price": 2110.33, + "qty": 4, + "sum": 8441.32 + }, + { + "name": "Организация L2 канала без резерва, в составе:\n Скорость порта: 10 Гбит/с", + "price": 23503.95, + "qty": 2, + "sum": 47007.9 + } + ] + }, + "спецификация-XXX003-01300_v2.docx": { + "type": "spec", + "company": "XXX003", + "number": "01300", + "version": 2, + "date": "12.03.2026", + "status": "valid", + "services": [ + { + "name": "Next Generation Cloud (AMD 4.0 ГГц), в составе:\n Количество vCPU: 4, RAM: 16 ГБ", + "price": 133119.69, + "qty": 1, + "sum": 133119.69 + }, + { + "name": "Интернет без защиты от DDoS, в составе:\n Полоса Интернет: 100 Мбит/с", + "price": 5639.68, + "qty": 2, + "sum": 11279.36 + }, + { + "name": "Аренда IPv4 адреса /32", + "price": 134.25, + "qty": 12, + "sum": 1611.0 + }, + { + "name": "Организация cross-connect: ВОЛС, 2×SMF", + "price": 3517.39, + "qty": 4, + "sum": 14069.56 + }, + { + "name": "Аренда PDU: 3Ф 32А вертикальный", + "price": 2110.33, + "qty": 4, + "sum": 8441.32 + }, + { + "name": "Организация L2 канала без резерва, в составе:\n Скорость порта: 10 Гбит/с", + "price": 23503.95, + "qty": 2, + "sum": 47007.9 + }, + { + "name": "Организация L2 канала без резерва, в составе:\n Скорость порта: 1 Гбит/с", + "price": 9597.54, + "qty": 2, + "sum": 19195.08 + } + ], + "diff": { + "added": [ + "Организация L2 канала без резерва, в составе:\n Скорос" + ], + "removed": [ + "Аренда стойко-места" + ], + "changed": [ + "цена Аренда IPv4 адреса /32: 147.51→134.25" + ], + "description": "цена Аренда IPv4 адреса /32 147.51→134.25, удалена: Аренда стойко-места, добавлена: Организация L2 канала без резерва, в сос" + } + }, + "допник-1-XXX003-01300.docx": { + "type": "addendum", + "company": "XXX003", + "number": "01300", + "addendum_num": 1, + "date": "13.05.2026", + "status": "valid" + }, + "допник-2-XXX003-01300.docx": { + "type": "addendum", + "company": "XXX003", + "number": "01300", + "addendum_num": 2, + "date": "25.03.2026", + "status": "valid" + }, + "допник-3-XXX003-01300.docx": { + "type": "addendum", + "company": "XXX003", + "number": "01300", + "addendum_num": 3, + "date": "15.02.2026", + "status": "valid" + }, + "договор-XXX004-04100.docx": { + "type": "contract", + "company": "XXX004", + "number": "04100", + "date": "22.02.2026", + "status": "valid", + "groups": [ + { + "contract_number": "04100", + "counterparty": "XXX004" + } + ] + }, + "спецификация-XXX004-04100.docx": { + "type": "spec", + "company": "XXX004", + "number": "04100", + "version": 1, + "date": "22.02.2026", + "status": "valid", + "services": [ + { + "name": "Организация L2 канала без резерва, в составе:\n Скорость порта: 1 Гбит/с", + "price": 12747.48, + "qty": 3, + "sum": 38242.44 + }, + { + "name": "Аренда публичной подсети (Длина префикса: /30 — 1 IPv4 адрес)", + "price": 507.72, + "qty": 2, + "sum": 1015.44 + }, + { + "name": "Организация cross-connect: UTP", + "price": 831.7, + "qty": 4, + "sum": 3326.8 + }, + { + "name": "Организация L2 канала без резерва, в составе:\n Скорость порта: 10 Гбит/с", + "price": 25005.67, + "qty": 1, + "sum": 25005.67 + }, + { + "name": "Next Generation Cloud (Intel 3.0 ГГц), в составе:\n Количество vCPU: 8, RAM: 32 ГБ", + "price": 66085.95, + "qty": 3, + "sum": 198257.85 + } + ] + }, + "спецификация-XXX004-04100_v2.docx": { + "type": "spec", + "company": "XXX004", + "number": "04100", + "version": 2, + "date": "22.02.2026", + "status": "valid", + "services": [ + { + "name": "Аренда публичной подсети (Длина префикса: /30 — 1 IPv4 адрес)", + "price": 507.72, + "qty": 2, + "sum": 1015.44 + }, + { + "name": "Организация cross-connect: UTP", + "price": 903.09, + "qty": 4, + "sum": 3612.36 + }, + { + "name": "Организация L2 канала без резерва, в составе:\n Скорость порта: 10 Гбит/с", + "price": 25005.67, + "qty": 1, + "sum": 25005.67 + }, + { + "name": "Next Generation Cloud (Intel 3.0 ГГц), в составе:\n Количество vCPU: 8, RAM: 32 ГБ", + "price": 66085.95, + "qty": 3, + "sum": 198257.85 + }, + { + "name": "Аренда порта без резерва, в составе:\n Скорость порта: 1 Гбит/с", + "price": 1255.6, + "qty": 3, + "sum": 3766.8 + } + ], + "diff": { + "added": [ + "Аренда порта без резерва, в составе:\n Скорость порта:" + ], + "removed": [ + "Организация L2 канала без резерва, в составе:\n Скорос" + ], + "changed": [ + "цена Организация cross-connect: UTP: 831.7→903.09" + ], + "description": "цена Организация cross-connect: UTP 831.7→903.09, удалена: Организация L2 канала без резерва, в сос, добавлена: Аренда порта без резерва, в составе:\n " + } + }, + "допник-1-XXX004-04100.docx": { + "type": "addendum", + "company": "XXX004", + "number": "04100", + "addendum_num": 1, + "date": "28.06.2026", + "status": "valid" + }, + "договор-XXX005-05200.docx": { + "type": "contract", + "company": "XXX005", + "number": "05200", + "date": "27.05.2026", + "status": "valid", + "groups": [ + { + "contract_number": "05200", + "counterparty": "XXX005" + } + ] + }, + "спецификация-XXX005-05200.docx": { + "type": "spec", + "company": "XXX005", + "number": "05200", + "version": 1, + "date": "27.05.2026", + "status": "valid", + "services": [ + { + "name": "Организация L2 канала без резерва, в составе:\n Скорость порта: 10 Гбит/с", + "price": 31429.0, + "qty": 1, + "sum": 31429.0 + }, + { + "name": "Организация L2 канала без резерва, в составе:\n Скорость порта: 1 Гбит/с", + "price": 9816.9, + "qty": 2, + "sum": 19633.8 + }, + { + "name": "Организация cross-connect: UTP", + "price": 728.8, + "qty": 1, + "sum": 728.8 + }, + { + "name": "Аренда порта без резерва, в составе:\n Скорость порта: 1 Гбит/с", + "price": 1374.2, + "qty": 1, + "sum": 1374.2 + }, + { + "name": "Аренда публичной подсети (Длина префикса: /30 — 1 IPv4 адрес)", + "price": 662.27, + "qty": 1, + "sum": 662.27 + }, + { + "name": "Next Generation Cloud (AMD 4.0 ГГц), в составе:\n Количество vCPU: 4, RAM: 16 ГБ", + "price": 114539.86, + "qty": 1, + "sum": 114539.86 + } + ] + }, + "спецификация-XXX005-05200_v2.docx": { + "type": "spec", + "company": "XXX005", + "number": "05200", + "version": 2, + "date": "27.05.2026", + "status": "valid", + "services": [ + { + "name": "Организация L2 канала без резерва, в составе:\n Скорость порта: 10 Гбит/с", + "price": 31429.0, + "qty": 1, + "sum": 31429.0 + }, + { + "name": "Организация cross-connect: UTP", + "price": 776.04, + "qty": 1, + "sum": 776.04 + }, + { + "name": "Аренда порта без резерва, в составе:\n Скорость порта: 1 Гбит/с", + "price": 1374.2, + "qty": 2, + "sum": 2748.4 + }, + { + "name": "Аренда публичной подсети (Длина префикса: /30 — 1 IPv4 адрес)", + "price": 662.27, + "qty": 1, + "sum": 662.27 + }, + { + "name": "Next Generation Cloud (AMD 4.0 ГГц), в составе:\n Количество vCPU: 4, RAM: 16 ГБ", + "price": 114539.86, + "qty": 1, + "sum": 114539.86 + }, + { + "name": "Аренда IPv4 адреса /32", + "price": 163.68, + "qty": 8, + "sum": 1309.44 + } + ], + "diff": { + "added": [ + "Аренда IPv4 адреса /32" + ], + "removed": [ + "Организация L2 канала без резерва, в составе:\n Скорос" + ], + "changed": [ + "цена Организация cross-connect: UTP: 728.8→776.04", + "объём Аренда порта без резерва, в составе:\n : 1→2" + ], + "description": "цена Организация cross-connect: UTP 728.8→776.04, объём Аренда порта без резерва, в составе:\n 1→2, удалена: Организация L2 канала без резерва, в сос, добавлена: Аренда IPv4 адреса /32" + } + }, + "допник-1-XXX005-05200.docx": { + "type": "addendum", + "company": "XXX005", + "number": "05200", + "addendum_num": 1, + "date": "15.02.2026", + "status": "valid" + }, + "договор-XXX006-06800.docx": { + "type": "contract", + "company": "XXX006", + "number": "06800", + "date": "23.01.2026", + "status": "valid", + "groups": [ + { + "contract_number": "06800", + "counterparty": "XXX006" + } + ] + }, + "спецификация-XXX006-06800.docx": { + "type": "spec", + "company": "XXX006", + "number": "06800", + "version": 1, + "date": "23.01.2026", + "status": "valid", + "services": [ + { + "name": "Аренда стойко-места", + "price": 199537.79, + "qty": 2, + "sum": 399075.58 + }, + { + "name": "Организация L2 канала без резерва, в составе:\n Скорость порта: 1 Гбит/с", + "price": 9312.65, + "qty": 1, + "sum": 9312.65 + }, + { + "name": "Аренда порта без резерва, в составе:\n Скорость порта: 1 Гбит/с", + "price": 1287.91, + "qty": 1, + "sum": 1287.91 + }, + { + "name": "Next Generation Cloud (Intel 3.0 ГГц), в составе:\n Количество vCPU: 8, RAM: 32 ГБ", + "price": 63372.11, + "qty": 3, + "sum": 190116.33 + }, + { + "name": "Next Generation Cloud (AMD 4.0 ГГц), в составе:\n Количество vCPU: 4, RAM: 16 ГБ", + "price": 129244.86, + "qty": 1, + "sum": 129244.86 + }, + { + "name": "Аренда публичной подсети (Длина префикса: /30 — 1 IPv4 адрес)", + "price": 496.42, + "qty": 4, + "sum": 1985.68 + }, + { + "name": "Интернет без защиты от DDoS, в составе:\n Полоса Интернет: 100 Мбит/с", + "price": 6854.77, + "qty": 3, + "sum": 20564.31 + } + ] + }, + "спецификация-XXX006-06800_v2.docx": { + "type": "duplicate", + "original": "спецификация-XXX006-06800_v2.docx", + "kind": "name_collision" + }, + "допник-1-XXX006-06800.docx": { + "type": "addendum", + "company": "XXX006", + "number": "06800", + "addendum_num": 1, + "date": "02.05.2026", + "status": "valid" + }, + "договор-XXX007-07400.docx": { + "type": "contract", + "company": "XXX007", + "number": "07400", + "date": "29.04.2026", + "status": "valid", + "groups": [ + { + "contract_number": "07400", + "counterparty": "XXX007" + } + ] + }, + "спецификация-XXX007-07400.docx": { + "type": "spec", + "company": "XXX007", + "number": "07400", + "version": 1, + "date": "29.04.2026", + "status": "valid", + "services": [ + { + "name": "Аренда IPv4 подсети /30", + "price": 599.49, + "qty": 2, + "sum": 1198.98 + }, + { + "name": "Аренда стойко-места", + "price": 238926.14, + "qty": 5, + "sum": 1194630.7 + }, + { + "name": "Организация L2 канала без резерва, в составе:\n Скорость порта: 1 Гбит/с", + "price": 11316.89, + "qty": 1, + "sum": 11316.89 + }, + { + "name": "Аренда публичной подсети (Длина префикса: /30 — 1 IPv4 адрес)", + "price": 568.23, + "qty": 3, + "sum": 1704.69 + }, + { + "name": "Аренда порта без резерва, в составе:\n Скорость порта: 1 Гбит/с", + "price": 1792.4, + "qty": 1, + "sum": 1792.4 + } + ] + }, + "спецификация-XXX007-07400_v2.docx": { + "type": "spec", + "company": "XXX007", + "number": "07400", + "version": 2, + "date": "29.04.2026", + "status": "valid", + "services": [ + { + "name": "Аренда IPv4 подсети /30", + "price": 599.49, + "qty": 2, + "sum": 1198.98 + }, + { + "name": "Аренда стойко-места", + "price": 238926.14, + "qty": 5, + "sum": 1194630.7 + }, + { + "name": "Аренда публичной подсети (Длина префикса: /30 — 1 IPv4 адрес)", + "price": 563.68, + "qty": 4, + "sum": 2254.72 + }, + { + "name": "Аренда порта без резерва, в составе:\n Скорость порта: 1 Гбит/с", + "price": 1792.4, + "qty": 1, + "sum": 1792.4 + }, + { + "name": "Организация cross-connect: ВОЛС, 2×SMF", + "price": 4113.48, + "qty": 4, + "sum": 16453.92 + } + ], + "diff": { + "added": [ + "Организация cross-connect: ВОЛС, 2×SMF" + ], + "removed": [ + "Организация L2 канала без резерва, в составе:\n Скорос" + ], + "changed": [ + "цена Аренда публичной подсети (Длина префикса: 568.23→563.68", + "объём Аренда публичной подсети (Длина префикса: 3→4" + ], + "description": "цена Аренда публичной подсети (Длина префикса 568.23→563.68, объём Аренда публичной подсети (Длина префикса 3→4, удалена: Организация L2 канала без резерва, в сос, добавлена: Организация cross-connect: ВОЛС, 2×SMF" + } + }, + "допник-1-XXX007-07400.docx": { + "type": "addendum", + "company": "XXX007", + "number": "07400", + "addendum_num": 1, + "date": "20.02.2026", + "status": "valid" + }, + "допник-2-XXX007-07400.docx": { + "type": "addendum", + "company": "XXX007", + "number": "07400", + "addendum_num": 2, + "date": "03.05.2026", + "status": "valid" + }, + "договор-XXX008-08100.docx": { + "type": "contract", + "company": "XXX008", + "number": "08100", + "date": "18.12.2025", + "status": "valid", + "groups": [ + { + "contract_number": "08100", + "counterparty": "XXX008" + } + ] + }, + "спецификация-XXX008-08100.docx": { + "type": "spec", + "company": "XXX008", + "number": "08100", + "version": 1, + "date": "18.12.2025", + "status": "valid", + "services": [ + { + "name": "Аренда порта без резерва, в составе:\n Скорость порта: 1 Гбит/с", + "price": 1624.97, + "qty": 1, + "sum": 1624.97 + }, + { + "name": "Аренда PDU: 3Ф 32А вертикальный", + "price": 2193.02, + "qty": 5, + "sum": 10965.1 + }, + { + "name": "Организация cross-connect: ВОЛС, 2×SMF", + "price": 4673.15, + "qty": 3, + "sum": 14019.45 + }, + { + "name": "Аренда публичной подсети (Длина префикса: /30 — 1 IPv4 адрес)", + "price": 687.87, + "qty": 3, + "sum": 2063.61 + }, + { + "name": "Организация L2 канала без резерва, в составе:\n Скорость порта: 10 Гбит/с", + "price": 33041.15, + "qty": 1, + "sum": 33041.15 + }, + { + "name": "Интернет без защиты от DDoS, в составе:\n Полоса Интернет: 100 Мбит/с", + "price": 8057.54, + "qty": 3, + "sum": 24172.62 + }, + { + "name": "Организация L2 канала без резерва, в составе:\n Скорость порта: 1 Гбит/с", + "price": 10412.58, + "qty": 3, + "sum": 31237.74 + }, + { + "name": "Next Generation Cloud (AMD 4.0 ГГц), в составе:\n Количество vCPU: 4, RAM: 16 ГБ", + "price": 118845.24, + "qty": 1, + "sum": 118845.24 + } + ] + }, + "спецификация-XXX008-08100_v2.docx": { + "type": "spec", + "company": "XXX008", + "number": "08100", + "version": 2, + "date": "18.12.2025", + "status": "valid", + "services": [ + { + "name": "Аренда порта без резерва, в составе:\n Скорость порта: 1 Гбит/с", + "price": 1624.97, + "qty": 1, + "sum": 1624.97 + }, + { + "name": "Аренда PDU: 3Ф 32А вертикальный", + "price": 2193.02, + "qty": 5, + "sum": 10965.1 + }, + { + "name": "Организация cross-connect: ВОЛС, 2×SMF", + "price": 4673.15, + "qty": 3, + "sum": 14019.45 + }, + { + "name": "Аренда публичной подсети (Длина префикса: /30 — 1 IPv4 адрес)", + "price": 687.87, + "qty": 5, + "sum": 3439.35 + }, + { + "name": "Организация L2 канала без резерва, в составе:\n Скорость порта: 10 Гбит/с", + "price": 33041.15, + "qty": 1, + "sum": 33041.15 + }, + { + "name": "Организация L2 канала без резерва, в составе:\n Скорость порта: 1 Гбит/с", + "price": 10412.58, + "qty": 3, + "sum": 31237.74 + }, + { + "name": "Next Generation Cloud (AMD 4.0 ГГц), в составе:\n Количество vCPU: 4, RAM: 16 ГБ", + "price": 108782.99, + "qty": 1, + "sum": 108782.99 + }, + { + "name": "Аренда стойко-места", + "price": 222974.49, + "qty": 5, + "sum": 1114872.45 + } + ], + "diff": { + "added": [ + "Аренда стойко-места" + ], + "removed": [ + "Интернет без защиты от DDoS, в составе:\n Полоса Интер" + ], + "changed": [ + "объём Аренда публичной подсети (Длина префикса: 3→5", + "цена Next Generation Cloud (AMD 4.0 ГГц), в с: 118845.24→108782.99" + ], + "description": "цена Next Generation Cloud (AMD 4.0 ГГц), в с 118845.24→108782.99, объём Аренда публичной подсети (Длина префикса 3→5, удалена: Интернет без защиты от DDoS, в составе:\n, добавлена: Аренда стойко-места" + } + }, + "допник-1-XXX008-08100.docx": { + "type": "addendum", + "company": "XXX008", + "number": "08100", + "addendum_num": 1, + "date": "02.04.2026", + "status": "valid" + }, + "допник-2-XXX008-08100.docx": { + "type": "addendum", + "company": "XXX008", + "number": "08100", + "addendum_num": 2, + "date": "23.02.2026", + "status": "valid" + }, + "договор-XXX009-09900.docx": { + "type": "contract", + "company": "XXX009", + "number": "09900", + "date": "31.05.2026", + "status": "valid", + "groups": [ + { + "contract_number": "09900", + "counterparty": "XXX009" + } + ] + }, + "спецификация-XXX009-09900.docx": { + "type": "spec", + "company": "XXX009", + "number": "09900", + "version": 1, + "date": "31.05.2026", + "status": "valid", + "services": [ + { + "name": "Организация cross-connect: UTP", + "price": 791.59, + "qty": 1, + "sum": 791.59 + }, + { + "name": "Аренда IPv4 адреса /32", + "price": 178.59, + "qty": 12, + "sum": 2143.08 + }, + { + "name": "Организация L2 канала без резерва, в составе:\n Скорость порта: 10 Гбит/с", + "price": 24858.21, + "qty": 1, + "sum": 24858.21 + }, + { + "name": "Аренда порта без резерва, в составе:\n Скорость порта: 1 Гбит/с", + "price": 1445.72, + "qty": 3, + "sum": 4337.16 + }, + { + "name": "Next Generation Cloud (AMD 4.0 ГГц), в составе:\n Количество vCPU: 4, RAM: 16 ГБ", + "price": 117508.09, + "qty": 2, + "sum": 235016.18 + }, + { + "name": "Организация cross-connect: ВОЛС, 2×SMF", + "price": 4057.13, + "qty": 4, + "sum": 16228.52 + }, + { + "name": "Организация L2 канала без резерва, в составе:\n Скорость порта: 1 Гбит/с", + "price": 11189.62, + "qty": 1, + "sum": 11189.62 + } + ] + }, + "спецификация-XXX009-09900_v2.docx": { + "type": "spec", + "company": "XXX009", + "number": "09900", + "version": 2, + "date": "31.05.2026", + "status": "valid", + "services": [ + { + "name": "Организация cross-connect: UTP", + "price": 791.59, + "qty": 1, + "sum": 791.59 + }, + { + "name": "Аренда IPv4 адреса /32", + "price": 225.35, + "qty": 12, + "sum": 2704.2 + }, + { + "name": "Организация L2 канала без резерва, в составе:\n Скорость порта: 10 Гбит/с", + "price": 24858.21, + "qty": 1, + "sum": 24858.21 + }, + { + "name": "Аренда порта без резерва, в составе:\n Скорость порта: 1 Гбит/с", + "price": 1445.72, + "qty": 3, + "sum": 4337.16 + }, + { + "name": "Next Generation Cloud (AMD 4.0 ГГц), в составе:\n Количество vCPU: 4, RAM: 16 ГБ", + "price": 117508.09, + "qty": 2, + "sum": 235016.18 + }, + { + "name": "Организация cross-connect: ВОЛС, 2×SMF", + "price": 4057.13, + "qty": 4, + "sum": 16228.52 + }, + { + "name": "Next Generation Cloud (Intel 3.0 ГГц), в составе:\n Количество vCPU: 8, RAM: 32 ГБ", + "price": 71506.68, + "qty": 2, + "sum": 143013.36 + } + ], + "diff": { + "added": [ + "Next Generation Cloud (Intel 3.0 ГГц), в составе:\n Ко" + ], + "removed": [ + "Организация L2 канала без резерва, в составе:\n Скорос" + ], + "changed": [ + "цена Аренда IPv4 адреса /32: 178.59→225.35" + ], + "description": "цена Аренда IPv4 адреса /32 178.59→225.35, объём Организация L2 канала без резерва, в сос 1→4, удалена: Организация L2 канала без резерва, в сос, добавлена: Next Generation Cloud (Intel 3.0 ГГц), в" + } + }, + "допник-1-XXX009-09900.docx": { + "type": "addendum", + "company": "XXX009", + "number": "09900", + "addendum_num": 1, + "date": "23.03.2026", + "status": "valid" + }, + "допник-2-XXX009-09900.docx": { + "type": "addendum", + "company": "XXX009", + "number": "09900", + "addendum_num": 2, + "date": "26.03.2026", + "status": "valid" + }, + "договор-XXX010-10500.docx": { + "type": "contract", + "company": "XXX010", + "number": "10500", + "date": "09.03.2026", + "status": "valid", + "groups": [ + { + "contract_number": "10500", + "counterparty": "XXX010" + } + ] + }, + "спецификация-XXX010-10500.docx": { + "type": "spec", + "company": "XXX010", + "number": "10500", + "version": 1, + "date": "09.03.2026", + "status": "valid", + "services": [ + { + "name": "Аренда публичной подсети (Длина префикса: /30 — 1 IPv4 адрес)", + "price": 698.92, + "qty": 1, + "sum": 698.92 + }, + { + "name": "Аренда IPv4 адреса /32", + "price": 188.52, + "qty": 8, + "sum": 1508.16 + }, + { + "name": "Аренда PDU: 3Ф 32А вертикальный", + "price": 2776.63, + "qty": 3, + "sum": 8329.89 + }, + { + "name": "Организация cross-connect: ВОЛС, 2×SMF", + "price": 4972.17, + "qty": 3, + "sum": 14916.51 + }, + { + "name": "Аренда IPv4 подсети /30", + "price": 518.35, + "qty": 3, + "sum": 1555.05 + }, + { + "name": "Next Generation Cloud (Intel 3.0 ГГц), в составе:\n Количество vCPU: 8, RAM: 32 ГБ", + "price": 79601.94, + "qty": 3, + "sum": 238805.82 + }, + { + "name": "Организация cross-connect: UTP", + "price": 752.0, + "qty": 4, + "sum": 3008.0 + } + ] + }, + "спецификация-XXX010-10500_v2.docx": { + "type": "spec", + "company": "XXX010", + "number": "10500", + "version": 2, + "date": "09.03.2026", + "status": "valid", + "services": [ + { + "name": "Аренда публичной подсети (Длина префикса: /30 — 1 IPv4 адрес)", + "price": 698.92, + "qty": 1, + "sum": 698.92 + }, + { + "name": "Аренда IPv4 адреса /32", + "price": 188.52, + "qty": 8, + "sum": 1508.16 + }, + { + "name": "Аренда PDU: 3Ф 32А вертикальный", + "price": 2776.63, + "qty": 3, + "sum": 8329.89 + }, + { + "name": "Организация cross-connect: ВОЛС, 2×SMF", + "price": 4972.17, + "qty": 3, + "sum": 14916.51 + }, + { + "name": "Аренда IPv4 подсети /30", + "price": 435.57, + "qty": 3, + "sum": 1306.71 + }, + { + "name": "Организация cross-connect: UTP", + "price": 752.0, + "qty": 2, + "sum": 1504.0 + }, + { + "name": "Организация L2 канала без резерва, в составе:\n Скорость порта: 10 Гбит/с", + "price": 25615.28, + "qty": 1, + "sum": 25615.28 + } + ], + "diff": { + "added": [ + "Организация L2 канала без резерва, в составе:\n Скорос" + ], + "removed": [ + "Next Generation Cloud (Intel 3.0 ГГц), в составе:\n Ко" + ], + "changed": [ + "цена Аренда IPv4 подсети /30: 518.35→435.57", + "объём Организация cross-connect: UTP: 4→2" + ], + "description": "цена Аренда IPv4 подсети /30 518.35→435.57, объём Организация cross-connect: UTP 4→2, удалена: Next Generation Cloud (Intel 3.0 ГГц), в, добавлена: Организация L2 канала без резерва, в сос" + } + }, + "допник-1-XXX010-10500.docx": { + "type": "addendum", + "company": "XXX010", + "number": "10500", + "addendum_num": 1, + "date": "12.06.2026", + "status": "valid" + }, + "допник-2-XXX010-10500.docx": { + "type": "addendum", + "company": "XXX010", + "number": "10500", + "addendum_num": 2, + "date": "30.01.2026", + "status": "valid" + }, + "corrupt_xml.docx": { + "type": "error", + "error": "corrupt_xml", + "status": "expected_fail" + }, + "empty.docx": { + "type": "error", + "error": "empty_docx", + "status": "expected_fail" + }, + "empty_zero.docx": { + "type": "error", + "error": "zero_bytes", + "status": "expected_fail" + }, + "text_only.docx": { + "type": "error", + "error": "text_only", + "status": "expected_parse_ok_no_tables" + }, + "table_only.docx": { + "type": "error", + "error": "table_only", + "status": "expected_parse_ok" + }, + "no_number.docx": { + "type": "error", + "error": "no_number", + "status": "expected_classify_fail" + }, + "nonstandard_title.docx": { + "type": "error", + "error": "nonstandard_title", + "status": "expected_classify_ok" + }, + "giant_500.docx": { + "type": "error", + "error": "giant", + "status": "expected_slow" + }, + "trash_1.docx": { + "type": "trash", + "label": "Счёт-фактура", + "status": "expected_classify_other" + }, + "trash_2.docx": { + "type": "trash", + "label": "Письмо-запрос", + "status": "expected_classify_other" + }, + "trash_3.docx": { + "type": "trash", + "label": "Коммерческое предложение", + "status": "expected_classify_other" + }, + "orphan_spec.docx": { + "type": "error", + "error": "orphan", + "status": "expected_group_unresolved" + }, + "contract_XXX001.zip": { + "type": "zip", + "files": [ + "договор-XXX001-03700.docx" + ], + "status": "valid" + }, + "mixed_with_error.zip": { + "type": "zip", + "has_error": true, + "status": "valid" + }, + "copy_of_спецификация-XXX006-06800_v2.docx": { + "type": "duplicate", + "original": "спецификация-XXX006-06800_v2.docx", + "kind": "content_copy" + } +} \ No newline at end of file diff --git a/testgen/out/valid/договор-XXX001-03700.docx b/testgen/out/valid/договор-XXX001-03700.docx new file mode 100644 index 0000000..15e0e60 Binary files /dev/null and b/testgen/out/valid/договор-XXX001-03700.docx differ diff --git a/testgen/out/valid/договор-XXX002-01200.docx b/testgen/out/valid/договор-XXX002-01200.docx new file mode 100644 index 0000000..d297d8a Binary files /dev/null and b/testgen/out/valid/договор-XXX002-01200.docx differ diff --git a/testgen/out/valid/договор-XXX003-01300.docx b/testgen/out/valid/договор-XXX003-01300.docx new file mode 100644 index 0000000..a9f9b0b Binary files /dev/null and b/testgen/out/valid/договор-XXX003-01300.docx differ diff --git a/testgen/out/valid/договор-XXX004-04100.docx b/testgen/out/valid/договор-XXX004-04100.docx new file mode 100644 index 0000000..1783cd9 Binary files /dev/null and b/testgen/out/valid/договор-XXX004-04100.docx differ diff --git a/testgen/out/valid/договор-XXX005-05200.docx b/testgen/out/valid/договор-XXX005-05200.docx new file mode 100644 index 0000000..7c32a07 Binary files /dev/null and b/testgen/out/valid/договор-XXX005-05200.docx differ diff --git a/testgen/out/valid/договор-XXX006-06800.docx b/testgen/out/valid/договор-XXX006-06800.docx new file mode 100644 index 0000000..bb7cf6e Binary files /dev/null and b/testgen/out/valid/договор-XXX006-06800.docx differ diff --git a/testgen/out/valid/договор-XXX007-07400.docx b/testgen/out/valid/договор-XXX007-07400.docx new file mode 100644 index 0000000..de93da0 Binary files /dev/null and b/testgen/out/valid/договор-XXX007-07400.docx differ diff --git a/testgen/out/valid/договор-XXX008-08100.docx b/testgen/out/valid/договор-XXX008-08100.docx new file mode 100644 index 0000000..4684eac Binary files /dev/null and b/testgen/out/valid/договор-XXX008-08100.docx differ diff --git a/testgen/out/valid/договор-XXX009-09900.docx b/testgen/out/valid/договор-XXX009-09900.docx new file mode 100644 index 0000000..389b62f Binary files /dev/null and b/testgen/out/valid/договор-XXX009-09900.docx differ diff --git a/testgen/out/valid/договор-XXX010-10500.docx b/testgen/out/valid/договор-XXX010-10500.docx new file mode 100644 index 0000000..f52d8b6 Binary files /dev/null and b/testgen/out/valid/договор-XXX010-10500.docx differ diff --git a/testgen/out/valid/допник-1-XXX001-03700.docx b/testgen/out/valid/допник-1-XXX001-03700.docx new file mode 100644 index 0000000..071da9c Binary files /dev/null and b/testgen/out/valid/допник-1-XXX001-03700.docx differ diff --git a/testgen/out/valid/допник-1-XXX002-01200.docx b/testgen/out/valid/допник-1-XXX002-01200.docx new file mode 100644 index 0000000..0e159b0 Binary files /dev/null and b/testgen/out/valid/допник-1-XXX002-01200.docx differ diff --git a/testgen/out/valid/допник-1-XXX003-01300.docx b/testgen/out/valid/допник-1-XXX003-01300.docx new file mode 100644 index 0000000..ec896a0 Binary files /dev/null and b/testgen/out/valid/допник-1-XXX003-01300.docx differ diff --git a/testgen/out/valid/допник-1-XXX004-04100.docx b/testgen/out/valid/допник-1-XXX004-04100.docx new file mode 100644 index 0000000..ad2e74d Binary files /dev/null and b/testgen/out/valid/допник-1-XXX004-04100.docx differ diff --git a/testgen/out/valid/допник-1-XXX005-05200.docx b/testgen/out/valid/допник-1-XXX005-05200.docx new file mode 100644 index 0000000..8b3a676 Binary files /dev/null and b/testgen/out/valid/допник-1-XXX005-05200.docx differ diff --git a/testgen/out/valid/допник-1-XXX006-06800.docx b/testgen/out/valid/допник-1-XXX006-06800.docx new file mode 100644 index 0000000..ebeeb0b Binary files /dev/null and b/testgen/out/valid/допник-1-XXX006-06800.docx differ diff --git a/testgen/out/valid/допник-1-XXX007-07400.docx b/testgen/out/valid/допник-1-XXX007-07400.docx new file mode 100644 index 0000000..b45a5ca Binary files /dev/null and b/testgen/out/valid/допник-1-XXX007-07400.docx differ diff --git a/testgen/out/valid/допник-1-XXX008-08100.docx b/testgen/out/valid/допник-1-XXX008-08100.docx new file mode 100644 index 0000000..8dc5e9c Binary files /dev/null and b/testgen/out/valid/допник-1-XXX008-08100.docx differ diff --git a/testgen/out/valid/допник-1-XXX009-09900.docx b/testgen/out/valid/допник-1-XXX009-09900.docx new file mode 100644 index 0000000..8b06cbf Binary files /dev/null and b/testgen/out/valid/допник-1-XXX009-09900.docx differ diff --git a/testgen/out/valid/допник-1-XXX010-10500.docx b/testgen/out/valid/допник-1-XXX010-10500.docx new file mode 100644 index 0000000..639278c Binary files /dev/null and b/testgen/out/valid/допник-1-XXX010-10500.docx differ diff --git a/testgen/out/valid/допник-2-XXX002-01200.docx b/testgen/out/valid/допник-2-XXX002-01200.docx new file mode 100644 index 0000000..5326154 Binary files /dev/null and b/testgen/out/valid/допник-2-XXX002-01200.docx differ diff --git a/testgen/out/valid/допник-2-XXX003-01300.docx b/testgen/out/valid/допник-2-XXX003-01300.docx new file mode 100644 index 0000000..5c64c8d Binary files /dev/null and b/testgen/out/valid/допник-2-XXX003-01300.docx differ diff --git a/testgen/out/valid/допник-2-XXX004-04100.docx b/testgen/out/valid/допник-2-XXX004-04100.docx new file mode 100644 index 0000000..10b8dfc Binary files /dev/null and b/testgen/out/valid/допник-2-XXX004-04100.docx differ diff --git a/testgen/out/valid/допник-2-XXX006-06800.docx b/testgen/out/valid/допник-2-XXX006-06800.docx new file mode 100644 index 0000000..4f47df2 Binary files /dev/null and b/testgen/out/valid/допник-2-XXX006-06800.docx differ diff --git a/testgen/out/valid/допник-2-XXX007-07400.docx b/testgen/out/valid/допник-2-XXX007-07400.docx new file mode 100644 index 0000000..d0be3d3 Binary files /dev/null and b/testgen/out/valid/допник-2-XXX007-07400.docx differ diff --git a/testgen/out/valid/допник-2-XXX008-08100.docx b/testgen/out/valid/допник-2-XXX008-08100.docx new file mode 100644 index 0000000..4326567 Binary files /dev/null and b/testgen/out/valid/допник-2-XXX008-08100.docx differ diff --git a/testgen/out/valid/допник-2-XXX009-09900.docx b/testgen/out/valid/допник-2-XXX009-09900.docx new file mode 100644 index 0000000..bd25493 Binary files /dev/null and b/testgen/out/valid/допник-2-XXX009-09900.docx differ diff --git a/testgen/out/valid/допник-2-XXX010-10500.docx b/testgen/out/valid/допник-2-XXX010-10500.docx new file mode 100644 index 0000000..f867e28 Binary files /dev/null and b/testgen/out/valid/допник-2-XXX010-10500.docx differ diff --git a/testgen/out/valid/допник-3-XXX002-01200.docx b/testgen/out/valid/допник-3-XXX002-01200.docx new file mode 100644 index 0000000..e3282f0 Binary files /dev/null and b/testgen/out/valid/допник-3-XXX002-01200.docx differ diff --git a/testgen/out/valid/допник-3-XXX003-01300.docx b/testgen/out/valid/допник-3-XXX003-01300.docx new file mode 100644 index 0000000..0a11fc8 Binary files /dev/null and b/testgen/out/valid/допник-3-XXX003-01300.docx differ diff --git a/testgen/out/valid/допник-3-XXX006-06800.docx b/testgen/out/valid/допник-3-XXX006-06800.docx new file mode 100644 index 0000000..4dad5bb Binary files /dev/null and b/testgen/out/valid/допник-3-XXX006-06800.docx differ diff --git a/testgen/out/valid/спецификация-XXX001-03700.docx b/testgen/out/valid/спецификация-XXX001-03700.docx new file mode 100644 index 0000000..ede6664 Binary files /dev/null and b/testgen/out/valid/спецификация-XXX001-03700.docx differ diff --git a/testgen/out/valid/спецификация-XXX001-03700_v2.docx b/testgen/out/valid/спецификация-XXX001-03700_v2.docx new file mode 100644 index 0000000..87dc3e6 Binary files /dev/null and b/testgen/out/valid/спецификация-XXX001-03700_v2.docx differ diff --git a/testgen/out/valid/спецификация-XXX002-01200.docx b/testgen/out/valid/спецификация-XXX002-01200.docx new file mode 100644 index 0000000..d4fea5f Binary files /dev/null and b/testgen/out/valid/спецификация-XXX002-01200.docx differ diff --git a/testgen/out/valid/спецификация-XXX002-01200_v2.docx b/testgen/out/valid/спецификация-XXX002-01200_v2.docx new file mode 100644 index 0000000..de9a479 Binary files /dev/null and b/testgen/out/valid/спецификация-XXX002-01200_v2.docx differ diff --git a/testgen/out/valid/спецификация-XXX003-01300.docx b/testgen/out/valid/спецификация-XXX003-01300.docx new file mode 100644 index 0000000..81d88a6 Binary files /dev/null and b/testgen/out/valid/спецификация-XXX003-01300.docx differ diff --git a/testgen/out/valid/спецификация-XXX003-01300_v2.docx b/testgen/out/valid/спецификация-XXX003-01300_v2.docx new file mode 100644 index 0000000..e65dbb5 Binary files /dev/null and b/testgen/out/valid/спецификация-XXX003-01300_v2.docx differ diff --git a/testgen/out/valid/спецификация-XXX004-04100.docx b/testgen/out/valid/спецификация-XXX004-04100.docx new file mode 100644 index 0000000..6a90142 Binary files /dev/null and b/testgen/out/valid/спецификация-XXX004-04100.docx differ diff --git a/testgen/out/valid/спецификация-XXX004-04100_v2.docx b/testgen/out/valid/спецификация-XXX004-04100_v2.docx new file mode 100644 index 0000000..88f7dbf Binary files /dev/null and b/testgen/out/valid/спецификация-XXX004-04100_v2.docx differ diff --git a/testgen/out/valid/спецификация-XXX005-05200.docx b/testgen/out/valid/спецификация-XXX005-05200.docx new file mode 100644 index 0000000..116d41d Binary files /dev/null and b/testgen/out/valid/спецификация-XXX005-05200.docx differ diff --git a/testgen/out/valid/спецификация-XXX005-05200_v2.docx b/testgen/out/valid/спецификация-XXX005-05200_v2.docx new file mode 100644 index 0000000..47c74dd Binary files /dev/null and b/testgen/out/valid/спецификация-XXX005-05200_v2.docx differ diff --git a/testgen/out/valid/спецификация-XXX006-06800.docx b/testgen/out/valid/спецификация-XXX006-06800.docx new file mode 100644 index 0000000..cce61c0 Binary files /dev/null and b/testgen/out/valid/спецификация-XXX006-06800.docx differ diff --git a/testgen/out/valid/спецификация-XXX006-06800_v2.docx b/testgen/out/valid/спецификация-XXX006-06800_v2.docx new file mode 100644 index 0000000..0bd1721 Binary files /dev/null and b/testgen/out/valid/спецификация-XXX006-06800_v2.docx differ diff --git a/testgen/out/valid/спецификация-XXX007-07400.docx b/testgen/out/valid/спецификация-XXX007-07400.docx new file mode 100644 index 0000000..29704d2 Binary files /dev/null and b/testgen/out/valid/спецификация-XXX007-07400.docx differ diff --git a/testgen/out/valid/спецификация-XXX007-07400_v2.docx b/testgen/out/valid/спецификация-XXX007-07400_v2.docx new file mode 100644 index 0000000..9f642d5 Binary files /dev/null and b/testgen/out/valid/спецификация-XXX007-07400_v2.docx differ diff --git a/testgen/out/valid/спецификация-XXX008-08100.docx b/testgen/out/valid/спецификация-XXX008-08100.docx new file mode 100644 index 0000000..cf5538e Binary files /dev/null and b/testgen/out/valid/спецификация-XXX008-08100.docx differ diff --git a/testgen/out/valid/спецификация-XXX008-08100_v2.docx b/testgen/out/valid/спецификация-XXX008-08100_v2.docx new file mode 100644 index 0000000..9699124 Binary files /dev/null and b/testgen/out/valid/спецификация-XXX008-08100_v2.docx differ diff --git a/testgen/out/valid/спецификация-XXX009-09900.docx b/testgen/out/valid/спецификация-XXX009-09900.docx new file mode 100644 index 0000000..3f71fd2 Binary files /dev/null and b/testgen/out/valid/спецификация-XXX009-09900.docx differ diff --git a/testgen/out/valid/спецификация-XXX009-09900_v2.docx b/testgen/out/valid/спецификация-XXX009-09900_v2.docx new file mode 100644 index 0000000..557d3aa Binary files /dev/null and b/testgen/out/valid/спецификация-XXX009-09900_v2.docx differ diff --git a/testgen/out/valid/спецификация-XXX010-10500.docx b/testgen/out/valid/спецификация-XXX010-10500.docx new file mode 100644 index 0000000..b0f6ae0 Binary files /dev/null and b/testgen/out/valid/спецификация-XXX010-10500.docx differ diff --git a/testgen/out/valid/спецификация-XXX010-10500_v2.docx b/testgen/out/valid/спецификация-XXX010-10500_v2.docx new file mode 100644 index 0000000..0550329 Binary files /dev/null and b/testgen/out/valid/спецификация-XXX010-10500_v2.docx differ diff --git a/testgen/out/zips/contract_XXX001.zip b/testgen/out/zips/contract_XXX001.zip new file mode 100644 index 0000000..2442501 Binary files /dev/null and b/testgen/out/zips/contract_XXX001.zip differ diff --git a/testgen/out/zips/mixed_with_error.zip b/testgen/out/zips/mixed_with_error.zip new file mode 100644 index 0000000..94b9c68 Binary files /dev/null and b/testgen/out/zips/mixed_with_error.zip differ diff --git a/testgen/pools.py b/testgen/pools.py new file mode 100644 index 0000000..df972f4 --- /dev/null +++ b/testgen/pools.py @@ -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)) diff --git a/testgen/templates.py b/testgen/templates.py new file mode 100644 index 0000000..8e5872d --- /dev/null +++ b/testgen/templates.py @@ -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)