feat: KIM-Angebot Skill hinzugefügt
Erstellt professionelle V&S/KIM-Angebote als Word-Dokumente (.docx) im exakten V&S-Briefformat mit Logo, Header, Footer und Unterschrift. - Drei Angebotstypen: Jahresprogramm, modularer Einstieg, projektbasiert - Automatische Kundenrecherche (CLAUDE.md, CRM, E-Mail, Web) - Preise aus referenzierter Preisliste (flexibel aktualisierbar) - Feste Bestandteile: Qualitätsversprechen, Schlussbestimmungen, Signatur Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
499
kim-angebot/scripts/generate_angebot.py
Normal file
499
kim-angebot/scripts/generate_angebot.py
Normal file
@@ -0,0 +1,499 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
KIM Angebot Generator v2
|
||||
Erzeugt V&S/KIM-Angebote basierend auf der Original-Vorlage.
|
||||
|
||||
Ansatz: Die Original-.docx wird kopiert und nur der variable Inhalt wird
|
||||
ersetzt. So bleiben Briefkopf-Positionierung, Header/Footer, Textboxen
|
||||
und alle Styles exakt erhalten.
|
||||
|
||||
Usage:
|
||||
python generate_angebot.py input.json [output.docx]
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
import copy
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from docx import Document
|
||||
from docx.shared import Pt, Emu, RGBColor, Cm, Inches
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.oxml.ns import qn
|
||||
from docx.oxml import OxmlElement
|
||||
|
||||
# Pfade
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
ASSETS_DIR = SCRIPT_DIR.parent / "assets"
|
||||
TEMPLATE_PATH = ASSETS_DIR / "template.docx" # Original V&S Vorlage
|
||||
SIGNATURE_PATH = ASSETS_DIR / "unterschrift_benno.jpg"
|
||||
|
||||
# Konstanten
|
||||
BLUE_HEADING = RGBColor(0x44, 0x72, 0xC4)
|
||||
BLACK = RGBColor(0x00, 0x00, 0x00)
|
||||
FONT_NAME = "Arial"
|
||||
BODY_SIZE = Pt(10)
|
||||
HEADING_SIZE = Pt(12)
|
||||
SMALL_SIZE = Pt(8)
|
||||
|
||||
# Feste Kontaktdaten (Kirsten)
|
||||
KONTAKT = {
|
||||
"name": "Kirsten Meyer",
|
||||
"mobil": "+49 1511 4481468",
|
||||
"email": "meyer@v-und-s.de",
|
||||
}
|
||||
|
||||
# Feste Rechtstexte
|
||||
SCHLUSSBESTIMMUNGEN = [
|
||||
"Der Klient erhält alle Rechte an den im Rahmen dieses Auftrages erstellten "
|
||||
"Analysen und Lösungen. Dazu zählen nicht die von V&S im Zuge der "
|
||||
"Leistungserbringung eingesetzten eigenen Erfahrungen, Erfindungen, "
|
||||
"EDV-Programme, Urheberrechte und Know-how.",
|
||||
|
||||
"V&S verpflichtet sich über unternehmensbezogene Daten und Vorgänge, die ihr "
|
||||
"während der Bearbeitung des Projektes zur Kenntnis gelangt sind, auch über die "
|
||||
"Projektlaufzeit hinaus Stillschweigen zu wahren und Dritten auch nicht "
|
||||
"auszugsweise zugänglich zu machen.",
|
||||
|
||||
"V&S schließt die vertragliche und außervertragliche Haftung außer im Falle von "
|
||||
"grober Fahrlässigkeit und Vorsatz aus und leistet keinen Schadensersatz.",
|
||||
|
||||
"V&S gewährleistet eine termingerechte und professionelle Arbeit, nach aktuellem "
|
||||
"Stand der Technik. Gegenstand des Auftrages ist dabei stets die vereinbarte "
|
||||
"Leistung, nicht ein bestimmter wirtschaftlicher Erfolg.",
|
||||
]
|
||||
|
||||
|
||||
def set_run_format(run, font_name=FONT_NAME, font_size=BODY_SIZE,
|
||||
color=BLACK, bold=False, italic=False):
|
||||
"""Formatiert einen Run."""
|
||||
run.font.name = font_name
|
||||
run.font.size = font_size
|
||||
run.font.color.rgb = color
|
||||
run.font.bold = bold
|
||||
run.font.italic = italic
|
||||
|
||||
|
||||
def make_paragraph(text, style_name="_Fließtext__standard_V+S",
|
||||
font_size=BODY_SIZE, color=BLACK, bold=False, italic=False,
|
||||
space_before=None, space_after=None, line_spacing=None):
|
||||
"""Erzeugt ein neues Paragraph-Element (ohne es einzufügen)."""
|
||||
p = OxmlElement('w:p')
|
||||
# Style + Spacing
|
||||
pPr = OxmlElement('w:pPr')
|
||||
pStyle = OxmlElement('w:pStyle')
|
||||
pStyle.set(qn('w:val'), style_name)
|
||||
pPr.append(pStyle)
|
||||
# Paragraph Spacing
|
||||
if space_before is not None or space_after is not None or line_spacing is not None:
|
||||
spacing = OxmlElement('w:spacing')
|
||||
if space_before is not None:
|
||||
spacing.set(qn('w:before'), str(space_before))
|
||||
if space_after is not None:
|
||||
spacing.set(qn('w:after'), str(space_after))
|
||||
if line_spacing is not None:
|
||||
spacing.set(qn('w:line'), str(line_spacing))
|
||||
spacing.set(qn('w:lineRule'), 'auto')
|
||||
pPr.append(spacing)
|
||||
p.append(pPr)
|
||||
# Run mit Text
|
||||
if text:
|
||||
r = OxmlElement('w:r')
|
||||
rPr = OxmlElement('w:rPr')
|
||||
# Font
|
||||
rFonts = OxmlElement('w:rFonts')
|
||||
rFonts.set(qn('w:ascii'), FONT_NAME)
|
||||
rFonts.set(qn('w:hAnsi'), FONT_NAME)
|
||||
rPr.append(rFonts)
|
||||
# Size
|
||||
sz = OxmlElement('w:sz')
|
||||
sz.set(qn('w:val'), str(int(font_size.pt * 2)))
|
||||
rPr.append(sz)
|
||||
szCs = OxmlElement('w:szCs')
|
||||
szCs.set(qn('w:val'), str(int(font_size.pt * 2)))
|
||||
rPr.append(szCs)
|
||||
# Color
|
||||
c = OxmlElement('w:color')
|
||||
c.set(qn('w:val'), f'{color}')
|
||||
rPr.append(c)
|
||||
# Bold
|
||||
if bold:
|
||||
b = OxmlElement('w:b')
|
||||
rPr.append(b)
|
||||
# Italic
|
||||
if italic:
|
||||
i = OxmlElement('w:i')
|
||||
rPr.append(i)
|
||||
r.append(rPr)
|
||||
# Text
|
||||
t = OxmlElement('w:t')
|
||||
t.set(qn('xml:space'), 'preserve')
|
||||
t.text = text
|
||||
r.append(t)
|
||||
p.append(r)
|
||||
return p
|
||||
|
||||
# Spacing-Werte (in Twips: 1pt = 20 twips)
|
||||
# 120 twips = 6pt Abstand, 80 = 4pt, 200 = 10pt, 280 = 14pt
|
||||
SP_BODY_BEFORE = 80 # 4pt vor Body-Text
|
||||
SP_BODY_AFTER = 80 # 4pt nach Body-Text
|
||||
SP_HEADING_BEFORE = 280 # 14pt vor Überschrift
|
||||
SP_HEADING_AFTER = 120 # 6pt nach Überschrift
|
||||
SP_SUB_BEFORE = 200 # 10pt vor Sub-Überschrift
|
||||
SP_SUB_AFTER = 40 # 2pt nach Sub-Überschrift
|
||||
LINE_SPACING = 276 # 1.15x Zeilenabstand (240 = single, 276 ≈ 1.15)
|
||||
|
||||
|
||||
def make_heading(text):
|
||||
"""Blaue Sektionsüberschrift mit Abstand."""
|
||||
return make_paragraph(text, style_name="Heading 2",
|
||||
font_size=HEADING_SIZE, color=BLUE_HEADING,
|
||||
space_before=SP_HEADING_BEFORE, space_after=SP_HEADING_AFTER)
|
||||
|
||||
|
||||
def make_body(text, bold=False, italic=False):
|
||||
"""Standard-Fließtext mit Zeilenabstand."""
|
||||
return make_paragraph(text, bold=bold, italic=italic,
|
||||
space_before=SP_BODY_BEFORE, space_after=SP_BODY_AFTER,
|
||||
line_spacing=LINE_SPACING)
|
||||
|
||||
|
||||
def make_empty():
|
||||
"""Leerer Absatz (halbe Höhe)."""
|
||||
return make_paragraph("", space_before=0, space_after=0)
|
||||
|
||||
|
||||
def make_subheading(text):
|
||||
"""Heading 4 Subheading mit Abstand davor."""
|
||||
return make_paragraph(text, style_name="Heading 4",
|
||||
font_size=BODY_SIZE, color=BLACK, bold=True,
|
||||
space_before=SP_SUB_BEFORE, space_after=SP_SUB_AFTER)
|
||||
|
||||
|
||||
def update_textbox_content(p_element, old_text_fragment, new_lines):
|
||||
"""
|
||||
Findet eine Textbox im Paragraphen die old_text_fragment enthält
|
||||
und ersetzt den kompletten Inhalt mit new_lines.
|
||||
"""
|
||||
ns_mc = 'http://schemas.openxmlformats.org/markup-compatibility/2006'
|
||||
|
||||
for txbxContent in p_element.iter(qn('w:txbxContent')):
|
||||
# Check if this textbox contains the old text
|
||||
all_text = ""
|
||||
for t in txbxContent.iter(qn('w:t')):
|
||||
all_text += (t.text or "")
|
||||
|
||||
if old_text_fragment.lower() in all_text.lower():
|
||||
# Clear existing paragraphs in the textbox
|
||||
for old_p in list(txbxContent.findall(qn('w:p'))):
|
||||
txbxContent.remove(old_p)
|
||||
|
||||
# Add new paragraphs
|
||||
for line in new_lines:
|
||||
p = OxmlElement('w:p')
|
||||
pPr = OxmlElement('w:pPr')
|
||||
# Right align for contact info
|
||||
if old_text_fragment.lower() in ["löffler", "meyer", "kirsten"]:
|
||||
jc = OxmlElement('w:jc')
|
||||
jc.set(qn('w:val'), 'right')
|
||||
pPr.append(jc)
|
||||
p.append(pPr)
|
||||
|
||||
r = OxmlElement('w:r')
|
||||
rPr = OxmlElement('w:rPr')
|
||||
rFonts = OxmlElement('w:rFonts')
|
||||
rFonts.set(qn('w:ascii'), FONT_NAME)
|
||||
rFonts.set(qn('w:hAnsi'), FONT_NAME)
|
||||
rPr.append(rFonts)
|
||||
sz = OxmlElement('w:sz')
|
||||
sz.set(qn('w:val'), '20') # 10pt
|
||||
rPr.append(sz)
|
||||
r.append(rPr)
|
||||
|
||||
t = OxmlElement('w:t')
|
||||
t.set(qn('xml:space'), 'preserve')
|
||||
t.text = line
|
||||
r.append(t)
|
||||
p.append(r)
|
||||
|
||||
txbxContent.append(p)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def update_paragraph_text(para, new_text):
|
||||
"""Ersetzt den Text eines Paragraphen, behält die Formatierung des ersten Runs."""
|
||||
if para.runs:
|
||||
# Keep first run's formatting, set its text
|
||||
para.runs[0].text = new_text
|
||||
# Remove remaining runs (but not non-run elements like drawings)
|
||||
for run in para.runs[1:]:
|
||||
# Only remove if it's a text-only run (no images)
|
||||
has_drawing = run._element.findall('.//' + qn('w:drawing'))
|
||||
has_pict = run._element.findall('.//' + qn('w:pict'))
|
||||
if not has_drawing and not has_pict:
|
||||
run._element.getparent().remove(run._element)
|
||||
else:
|
||||
run = para.add_run(new_text)
|
||||
set_run_format(run)
|
||||
|
||||
|
||||
def generate_angebot(data: dict, output_path: str):
|
||||
"""Generiert ein KIM-Angebot."""
|
||||
doc = Document(str(TEMPLATE_PATH))
|
||||
body = doc.element.body
|
||||
|
||||
kunde = data["kunde"]
|
||||
|
||||
# === 1. TEXTBOXEN AKTUALISIEREN (Adresse + Kontakt) ===
|
||||
p0 = doc.paragraphs[0]
|
||||
|
||||
# Empfänger-Adresse aktualisieren
|
||||
addr_lines = [
|
||||
kunde["firma"],
|
||||
kunde["ansprechpartner"],
|
||||
kunde["strasse"],
|
||||
kunde["plz_ort"],
|
||||
]
|
||||
# Try multiple fragments to find the address textbox
|
||||
for fragment in ["Prälatur", "Prälat", "Albrecht", kunde.get("firma", "XXX")[:10]]:
|
||||
if update_textbox_content(p0._element, fragment, addr_lines):
|
||||
break
|
||||
|
||||
# Kontaktdaten aktualisieren (Kirsten statt Benno)
|
||||
contact_lines = [
|
||||
KONTAKT["name"],
|
||||
f"mobil: {KONTAKT['mobil']}",
|
||||
KONTAKT["email"],
|
||||
]
|
||||
for fragment in ["Löffler", "loeffler", "löffler", "Benno"]:
|
||||
if update_textbox_content(p0._element, fragment, contact_lines):
|
||||
break
|
||||
|
||||
# === 2. DATUM AKTUALISIEREN ===
|
||||
# Paragraph 0 enthält den Datum-Text
|
||||
datum = data.get("datum")
|
||||
if not datum:
|
||||
now = datetime.now()
|
||||
monatsnamen = {
|
||||
1: "Januar", 2: "Februar", 3: "März", 4: "April",
|
||||
5: "Mai", 6: "Juni", 7: "Juli", 8: "August",
|
||||
9: "September", 10: "Oktober", 11: "November", 12: "Dezember"
|
||||
}
|
||||
datum = f"{now.day}. {monatsnamen[now.month]} {now.year}"
|
||||
|
||||
# Find the text run with "Hannover" in paragraph 0
|
||||
for run in p0.runs:
|
||||
if "Hannover" in (run.text or ""):
|
||||
run.text = f" Hannover, {datum}"
|
||||
break
|
||||
|
||||
# === 3. AKTENZEICHEN AKTUALISIEREN ===
|
||||
angebotsnummer = data.get("angebotsnummer")
|
||||
if not angebotsnummer:
|
||||
now = datetime.now()
|
||||
angebotsnummer = f"{now.strftime('%Y%m%d')}KIM01"
|
||||
|
||||
p2 = doc.paragraphs[2] # Aktenzeichen
|
||||
update_paragraph_text(p2, angebotsnummer)
|
||||
|
||||
# === 4. ANREDE AKTUALISIEREN ===
|
||||
p5 = doc.paragraphs[5] # Anrede
|
||||
update_paragraph_text(p5, kunde["anrede"])
|
||||
|
||||
# === 5. EINLEITUNG AKTUALISIEREN ===
|
||||
p7 = doc.paragraphs[7] # Einleitung
|
||||
einleitung = data.get("einleitung",
|
||||
"auf Basis unseres Vorgesprächs erhalten Sie hier unser Angebot:")
|
||||
update_paragraph_text(p7, einleitung)
|
||||
|
||||
# === 6. VARIABLEN INHALT ERSETZEN ===
|
||||
# Paragraphen 9 (Ausgangslage-Heading) bis VOR die Schlussbestimmungen entfernen
|
||||
# und durch neuen Inhalt ersetzen.
|
||||
|
||||
# Finde den Startpunkt (Paragraph 9 = "Ausgangslage")
|
||||
# und den Endpunkt (wo Schlussbestimmungen beginnen)
|
||||
start_idx = 9
|
||||
end_idx = None
|
||||
|
||||
for i, p in enumerate(doc.paragraphs):
|
||||
if "Schlussbestimmungen" in p.text and i > 20:
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if end_idx is None:
|
||||
# Fallback: Finde "Mit freundlichen Grüßen"
|
||||
for i, p in enumerate(doc.paragraphs):
|
||||
if "freundlichen Grüßen" in p.text:
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
# Alle Paragraphen zwischen start und end aus dem Body entfernen
|
||||
paras_to_remove = []
|
||||
body_paras = [p for p in doc.paragraphs]
|
||||
for i in range(start_idx, end_idx):
|
||||
paras_to_remove.append(body_paras[i]._element)
|
||||
|
||||
for p_elem in paras_to_remove:
|
||||
if p_elem.getparent() == body:
|
||||
body.remove(p_elem)
|
||||
|
||||
# Neuen Inhalt einfügen an der Stelle wo start_idx war
|
||||
# Finde den Referenz-Paragraphen (der jetzt an Position start_idx-1 ist, also die Einleitung)
|
||||
remaining_paras = body.findall(qn('w:p'))
|
||||
# Der Einleitungs-Paragraph ist jetzt der letzte vor dem neuen Inhalt
|
||||
insert_after = remaining_paras[start_idx - 1] # Paragraph 8 (leer nach Einleitung)
|
||||
|
||||
new_elements = []
|
||||
|
||||
# Sektionen
|
||||
for sektion in data.get("sektionen", []):
|
||||
new_elements.append(make_empty())
|
||||
new_elements.append(make_heading(sektion["ueberschrift"]))
|
||||
new_elements.append(make_empty()) # Leerzeile nach Überschrift
|
||||
|
||||
for block in sektion.get("inhalt", []):
|
||||
typ = block["typ"]
|
||||
if typ == "text":
|
||||
new_elements.append(make_body(block["text"]))
|
||||
elif typ == "text_bold":
|
||||
new_elements.append(make_body(block["text"], bold=True))
|
||||
elif typ == "text_italic":
|
||||
new_elements.append(make_body(block["text"], italic=True))
|
||||
elif typ == "text_small":
|
||||
new_elements.append(make_paragraph(
|
||||
block["text"], font_size=SMALL_SIZE, italic=True,
|
||||
space_before=SP_BODY_BEFORE, space_after=SP_BODY_AFTER))
|
||||
elif typ == "subheading":
|
||||
new_elements.append(make_subheading(block["text"]))
|
||||
elif typ == "bullet":
|
||||
for item in block["items"]:
|
||||
new_elements.append(make_body(f"• {item}"))
|
||||
|
||||
# Optionale Module
|
||||
if data.get("optionale_module"):
|
||||
new_elements.append(make_empty())
|
||||
new_elements.append(make_heading("Optionale Module:"))
|
||||
new_elements.append(make_empty()) # Leerzeile nach Überschrift
|
||||
for modul in data["optionale_module"]:
|
||||
new_elements.append(make_body(f"• {modul}"))
|
||||
|
||||
# Honorarvereinbarung
|
||||
if data.get("honorar"):
|
||||
honorar = data["honorar"]
|
||||
new_elements.append(make_empty())
|
||||
new_elements.append(make_heading(
|
||||
honorar.get("ueberschrift", "Honorarvereinbarung")))
|
||||
new_elements.append(make_empty())
|
||||
|
||||
for block in honorar.get("inhalt", []):
|
||||
typ = block["typ"]
|
||||
if typ == "text":
|
||||
new_elements.append(make_body(block["text"]))
|
||||
elif typ == "text_bold":
|
||||
new_elements.append(make_body(block["text"], bold=True))
|
||||
elif typ == "text_italic" or typ == "text_small":
|
||||
new_elements.append(make_body(block["text"], italic=True))
|
||||
|
||||
new_elements.append(make_empty())
|
||||
for line in honorar.get("zahlungsbedingungen", [
|
||||
"Alle Preise verstehen sich zzgl. gesetzlicher Mehrwertsteuer.",
|
||||
"Reisekosten und Spesen sind in der Monatspauschale enthalten.",
|
||||
"Das Zahlungsziel für alle Rechnungen beträgt 30 Tage rein netto.",
|
||||
]):
|
||||
new_elements.append(make_body(line))
|
||||
|
||||
# Durchführung
|
||||
if data.get("berater"):
|
||||
new_elements.append(make_empty())
|
||||
new_elements.append(make_heading("Durchführung"))
|
||||
new_elements.append(make_empty())
|
||||
new_elements.append(make_body(data["berater"]))
|
||||
|
||||
# V&S Qualitätsversprechen
|
||||
kunde_formal = kunde.get("firma_formal", kunde["firma"])
|
||||
laufzeit = data.get("laufzeit", "12 Monate")
|
||||
|
||||
new_elements.append(make_empty())
|
||||
new_elements.append(make_heading("V&S Qualitätsversprechen"))
|
||||
new_elements.append(make_empty())
|
||||
new_elements.append(make_body(
|
||||
f"Sollte {kunde_formal} mit den Ergebnissen des Projektes nicht zufrieden sein, "
|
||||
f"so entscheidet {kunde_formal} einen Monat rückwirkend über das Monats-Honorar "
|
||||
f"– bis hin zu einem Honorar von 0 EUR."
|
||||
))
|
||||
new_elements.append(make_body(
|
||||
f"Geplant ist eine Begleitung über {laufzeit}. Allerdings kann {kunde_formal} "
|
||||
f"das gemeinsame Vorhaben jederzeit beenden."
|
||||
))
|
||||
|
||||
# Leere Zeilen vor Schlussbestimmungen
|
||||
new_elements.append(make_empty())
|
||||
|
||||
# Neuen Inhalt nach insert_after einfügen
|
||||
insert_point = insert_after
|
||||
for elem in new_elements:
|
||||
insert_point.addnext(elem)
|
||||
insert_point = elem
|
||||
|
||||
# === 7. SCHLUSSBESTIMMUNGEN AKTUALISIEREN ===
|
||||
# Die existierenden Schlussbestimmungen sind schon im Dokument (wurden nicht gelöscht)
|
||||
# Wir müssen nur "Der Klient" durch den Kundennamen ersetzen
|
||||
for p in doc.paragraphs:
|
||||
if p.text.startswith("Der Klient"):
|
||||
firma_ref = kunde_formal
|
||||
if firma_ref.startswith("die ") or firma_ref.startswith("der "):
|
||||
firma_ref = firma_ref[0].upper() + firma_ref[1:]
|
||||
new_text = p.text.replace("Der Klient", firma_ref, 1)
|
||||
update_paragraph_text(p, new_text)
|
||||
|
||||
# === 8. ANGEBOTSDATUM IM ANNAHMEBLOCK AKTUALISIEREN ===
|
||||
angebotsdatum = data.get("angebotsdatum_kurz")
|
||||
if not angebotsdatum:
|
||||
now = datetime.now()
|
||||
angebotsdatum = now.strftime("%d.%m.%Y")
|
||||
|
||||
for p in doc.paragraphs:
|
||||
if "Angebot vom" in p.text and "angenommen" in p.text:
|
||||
update_paragraph_text(p, f"Angebot vom {angebotsdatum} angenommen:")
|
||||
|
||||
# === 9. UNTERSCHRIFT-BILD PRÜFEN ===
|
||||
# Die Unterschrift ist bereits im Template als eingebettetes Bild vorhanden.
|
||||
# Falls sie fehlt (z.B. wenn Template geändert wurde), hier einfügen:
|
||||
has_signature = False
|
||||
for p in doc.paragraphs:
|
||||
for r in p.runs:
|
||||
if r._element.findall('.//' + qn('a:blip')):
|
||||
has_signature = True
|
||||
break
|
||||
|
||||
if not has_signature and SIGNATURE_PATH.exists():
|
||||
# Finde "Mit freundlichen Grüßen" und füge Bild danach ein
|
||||
for i, p in enumerate(doc.paragraphs):
|
||||
if "freundlichen Grüßen" in p.text:
|
||||
# Nächster leerer Paragraph -> Bild einfügen
|
||||
if i + 1 < len(doc.paragraphs):
|
||||
next_p = doc.paragraphs[i + 1]
|
||||
run = next_p.add_run()
|
||||
run.add_picture(str(SIGNATURE_PATH), width=Inches(2.5))
|
||||
break
|
||||
|
||||
# Speichern
|
||||
doc.save(output_path)
|
||||
print(f"Angebot gespeichert: {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python generate_angebot.py input.json [output.docx]")
|
||||
sys.exit(1)
|
||||
|
||||
input_path = sys.argv[1]
|
||||
with open(input_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
output = sys.argv[2] if len(sys.argv) > 2 else "Angebot.docx"
|
||||
generate_angebot(data, output)
|
||||
Reference in New Issue
Block a user