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:
2026-04-09 15:33:42 +02:00
parent 662e2efaad
commit cee643e7ab
6 changed files with 760 additions and 2 deletions

172
kim-angebot/SKILL.md Normal file
View File

@@ -0,0 +1,172 @@
---
name: kim-angebot
description: "Erstellt KIM/V&S-Angebote als professionelle Word-Dokumente (.docx) im exakten V&S-Briefformat. Nutze diesen Skill IMMER wenn Kirsten ein Angebot, eine Offerte, ein Proposal oder einen Kostenvoranschlag für einen KIM-Kunden oder Interessenten erstellen will — auch wenn sie nur sagt 'mach mal ein Angebot für XY', 'schreib dem ein Angebot', 'Angebot erstellen', 'Offerte für...', oder den Kundennamen nennt und etwas anbieten will. Gilt für alle Angebotsarten: KIM-Jahresprogramm, modularer Einstieg, Projektbegleitung, individuelle Angebote."
---
# KIM Angebot erstellen
Dieser Skill erzeugt professionelle KIM/V&S-Angebote als Word-Dokumente (.docx) im exakten V&S-Briefformat mit Logo, Header, Footer und allen Corporate Styles.
## Ablauf
### 1. Kontext sammeln
Bevor du den Angebotsinhalt schreibst, sammle alle nötigen Informationen. Frage Kirsten nur nach dem, was du nicht selbst herausfinden kannst.
**Automatisch recherchieren:**
- **Kunden-CLAUDE.md:** Prüfe ob unter `~/VundS Dropbox/VundS/A_Leistungsteams/A_02_Leistungserstellung/A_0205_Produkte/93_KIM - KI im Mittelstand/KIM Kunden/<Kundenname>/CLAUDE.md` eine Datei existiert. Wenn ja: lies sie — dort steht Kundenkontext, Ansprechpartner, bisherige Module, Use Cases etc.
- **CRM (Monday.com):** Suche den Kunden im Leads-Board (5075849337) oder Kunden-Board (5075849340). Prüfe: Status, letzte Interaktion, Ansprechpartner, Updates.
- **Preisliste:** Lies `references/preisliste.md` (im Skill-Verzeichnis) für aktuelle Preise. Die Preise dort sind die verbindliche Quelle — keine Preise aus dem Gedächtnis verwenden.
**Kundenadresse automatisch recherchieren:**
- Zuerst in der Kunden-CLAUDE.md nachschauen (Adresse steht oft dort)
- Dann im CRM (Monday.com) prüfen — Website-Feld enthält oft die Firmenhomepage
- E-Mail-Signaturen durchsuchen (ms365 MCP: letzte E-Mails vom Ansprechpartner lesen)
- Falls immer noch nicht gefunden: Firmennamen + "Adresse" im Web suchen
- Kirsten nur fragen wenn die automatische Recherche nichts ergibt
**Von Kirsten erfragen (nur was fehlt):**
- Kundenname und Ansprechpartner (falls nicht aus CLAUDE.md/CRM/E-Mails bekannt)
- Was soll angeboten werden? (Jahresprogramm, Einzelmodule, Projektbegleitung, individuell)
- Besondere Konditionen oder Rabatte?
- Geplanter Starttermin?
- Wer führt durch? (Default: "Uwe Kreyenborg und Kirsten Meyer")
### 2. Angebotsinhalt komponieren
Basierend auf dem gesammelten Kontext schreibst du den Inhalt. Du bist dabei kein Formular-Ausfüller, sondern denkst mit: Was ergibt für diesen konkreten Kunden Sinn? Was passt zur Ausgangslage?
**Schreibstil:**
- Professionell, aber nicht steif — wie ein erfahrener Berater, der dem Kunden auf Augenhöhe begegnet
- Konkret statt generisch — beziehe dich auf die tatsächliche Situation des Kunden
- Kundenname wird im Text verwendet (nicht "der Kunde")
- Sachlich-warm, keine Marketing-Superlative
**Drei Angebotstypen:**
#### Typ A: KIM-Jahresprogramm
Für Unternehmen die die ganzheitliche KI-Transformation wollen.
- Ausgangslage & Zielsetzung (kundenspezifisch)
- KIM-Programminhalte & Leistungen (Phasen 1+2 mit beispielhaften Maßnahmen)
- Laufende Leistungen (Steuerungsgespräche, Reviews, Community)
- Optionale Module
- Honorar: Monatspauschale (7.5008.500 EUR/Monat)
- Laufzeit: 12 Monate, jederzeit kündbar
#### Typ B: Modularer Einstieg
Für Unternehmen die erstmal ausprobieren wollen.
- Ausgangslage & Zielsetzung
- Baustein 1: KI-Labor (2 Tage, 4.900 EUR)
- Baustein 2: KI-Projektbegleitung (monatlich, 4.5004.900 EUR)
- Optionale Module
- Honorar pro Baustein
#### Typ C: Projektbasiert / Individuell
Für Unternehmen mit konkreten Use Cases.
- Ausgangslage (identifizierte Projekte, bisherige Zusammenarbeit)
- Zielsetzung
- Leistungsübersicht (Phasen mit konkreten Projekten, Personentagen, Preisen)
- Preismodell (Tagessatz 1.300 EUR/PT oder Monatskontingent)
- Ggf. Hosting & Betrieb
**Kreative Freiheit:** Wenn Kirsten etwas Spezielles beschreibt, das in keinen Standard-Typ passt, kombiniere und passe an. Die Struktur ist ein Gerüst, kein Käfig.
### 3. JSON erzeugen und Word generieren
Sobald der Inhalt steht, erzeuge eine JSON-Datei mit der Angebotsstruktur und rufe das Generierungsskript auf.
**JSON-Struktur:**
```json
{
"kunde": {
"firma": "Firmenname GmbH",
"firma_formal": "die Firmenname GmbH",
"ansprechpartner": "Herr Max Mustermann",
"strasse": "Musterstraße 1",
"plz_ort": "30175 Hannover",
"anrede": "Sehr geehrter Herr Mustermann,"
},
"datum": "9. April 2026",
"angebotsnummer": "20260409KIM01",
"einleitung": "auf Basis unseres Vorgesprächs erhalten Sie hier unser Angebot:",
"berater": "Uwe Kreyenborg und Kirsten Meyer",
"laufzeit": "12 Monate",
"sektionen": [
{
"ueberschrift": "Ausgangslage & Zielsetzung:",
"inhalt": [
{"typ": "text", "text": "Fließtext..."},
{"typ": "text_bold", "text": "Fette Zwischenüberschrift"},
{"typ": "bullet", "items": ["Punkt 1", "Punkt 2"]},
{"typ": "subheading", "text": "Fette Sub-Überschrift (Heading 4)"},
{"typ": "text_italic", "text": "Kursiver Hinweistext"},
{"typ": "text_small", "text": "Kleingedrucktes (8pt, kursiv)"}
]
}
],
"optionale_module": [
"Deep Dive Automation Owner Ausbildung (optional, gegen Aufpreis)",
"KI-Strategieentwicklung (optional, gegen Aufpreis)"
],
"honorar": {
"ueberschrift": "Honorarvereinbarung",
"inhalt": [
{"typ": "text_bold", "text": "Baustein 1 KI-Labor (2 Tage):"},
{"typ": "text", "text": "4.900 EUR (einmalig)"}
],
"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."
]
},
"angebotsdatum_kurz": "09.04.2026"
}
```
**Inhalt-Typen für `inhalt`-Arrays:**
- `text` — Normaler Fließtext (Arial 10pt, schwarz)
- `text_bold` — Fette Zwischenüberschrift (gleiche Größe wie Body, fett)
- `text_italic` — Kursiver Text
- `text_small` — Kleingedrucktes (8pt, kursiv)
- `subheading` — Heading 4 (fett, schwarz)
- `bullet` — Aufzählung mit `items`-Array
**Generierung:**
```bash
python3 ~/.claude/skills/kim-angebot/scripts/generate_angebot.py /tmp/angebot_data.json ~/Downloads/YYYY_M_DD_KIM-Angebot_Kundenname.docx
```
Dateiname-Konvention: `YYYY_M_DD_KIM-Angebot_Kundenname.docx`
### 4. Kirsten das Ergebnis zeigen
Nach der Generierung:
1. Sage Kirsten wo die Datei liegt
2. Fasse kurz zusammen was drinsteht (Typ, Bausteine, Preise)
3. Frage ob sie es öffnen und prüfen möchte
4. Biete an, Änderungen vorzunehmen
## Feste Bestandteile (werden automatisch eingefügt)
Diese Teile kommen immer rein und müssen nicht in den Sektionen stehen:
- **Kontaktblock:** Kirsten Meyer, mobil: +49 1511 4481468, meyer@v-und-s.de
- **V&S Qualitätsversprechen:** Null-Risiko-Garantie (Kundenname wird eingesetzt)
- **Schlussbestimmungen:** Nutzungsrechte, Geheimhaltung, Haftung, AGB
- **Signatur:** Mit freundlichen Grüßen, Benno Löffler
- **Annahmeblock:** "Angebot vom DD.MM.YYYY angenommen: / Datum, Name / Zur Beauftragung bitte senden an: meyer@v-und-s.de"
## Angebotsnummer-Format
`YYYYMMDDKIM01` — Datum + "KIM01". Kirsten passt die Nummer ggf. manuell an, also einfach das aktuelle Datum nehmen und KIM01 dranhängen.
## Referenzen
- **Preisliste:** `references/preisliste.md` — Aktuelle Preise (IMMER lesen vor Angebotserstellung)
- **Word-Template:** `assets/template.docx` — V&S-Vorlage mit Logo, Header, Footer
- **Bestehende Angebote (Referenz für Stil):**
- `~/VundS Dropbox/VundS/.../KIM Kunden/Guidant/Angebot/Angebot_Guidant_KI-Projekte.md`
- `~/VundS Dropbox/VundS/.../KIM Marketing/Claude_Marketing/Angebot_und_Positionierung.md`

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -0,0 +1,55 @@
# KIM Preisliste & Module
> Quelle: Angebot_und_Positionierung.md — bei Preisänderungen diese Datei aktualisieren.
## Enablement-Module (Einzelbuchung)
| Modul | Stufe | Dauer | Preis |
|-------|-------|-------|-------|
| KI-Practitioner Training | 1 | 1 Tag, max. 15 TN | 2.500 EUR |
| KI-Labor #1: KI-Verständnis | 1 | 2 Tage | 4.900 EUR |
| KI-Labor #2: Automatisierung | 2 | 1 Tag | 2.500 EUR |
| Automation Owner Training | 2 | 4 Wochen, max. 10 TN | 1.950 EUR (ab 5 TN: 1.500 EUR) |
| KI-Labor #3: Agentic AI | 3 | 1 Tag | 2.500 EUR |
| Training Agentic AI Claude Code | 3 | 1 Tag, max. 5 TN | 2.500 EUR |
## Programm-Pakete
| Paket | Preis | Details |
|-------|-------|---------|
| KIM-Jahresprogramm (12 Monate) | 7.5008.500 EUR/Monat | Ganzheitliche KI-Integration, modularer Baukasten |
| KI-Projektbegleitung (monatlich) | 4.5004.900 EUR/Monat | 2 Tage vor Ort + wöchentl. Teams, monatl. kündbar |
| Impuls-Workshop KI & Führung | auf Anfrage | 1 Tag |
| KI-Impuls Workshop | auf Anfrage | 24 Stunden |
## Projektbasierte Abrechnung
| Modell | Preis |
|--------|-------|
| Tagessatz (Personentag) | 1.300 EUR |
| Use Case Begleitung | auf Anfrage |
## Hosting & Betrieb (optional, für laufende Lösungen)
| Leistung | Preis/Monat |
|----------|-------------|
| Hosting & Infrastruktur | 150500 EUR |
| Wartung & Monitoring | 5001.000 EUR |
| Support | nach Aufwand (Tagessatz) |
## Typische Angebotskombinationen
### Typ A: KIM-Jahresprogramm
- Monatspauschale (7.5008.500 EUR/Monat, 12 Monate)
- Enthält: Alle Module, Begleitung, Steuerungsgespräche, Community
- Ideal für: Unternehmen die ganzheitliche KI-Transformation wollen
### Typ B: Modularer Einstieg
- Baustein 1: KI-Labor (4.900 EUR einmalig)
- Baustein 2: KI-Projektbegleitung (4.5004.900 EUR/Monat)
- Ideal für: Unternehmen die erstmal ausprobieren wollen
### Typ C: Projektbasiert
- Einzelne Projekte/Use Cases mit Personentagen (1.300 EUR/PT)
- Phasenweise Abrechnung (Pauschale oder Kontingent)
- Ideal für: Unternehmen mit konkreten, identifizierten Use Cases

View 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)