Files
vns-skills/kim-angebot/scripts/generate_angebot.py
Kirsten-vunds cee643e7ab 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>
2026-04-09 15:33:42 +02:00

500 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)