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>
500 lines
18 KiB
Python
500 lines
18 KiB
Python
#!/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)
|