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:
36
README.md
36
README.md
@@ -1,3 +1,35 @@
|
||||
# vns-skills
|
||||
# V&S Claude Code Skills
|
||||
|
||||
V&S Claude Code Skills — gemeinsam genutzte Skills für das KIM-Team
|
||||
Gemeinsam genutzte Skills für das V&S / KIM-Team.
|
||||
|
||||
## Verfügbare Skills
|
||||
|
||||
| Skill | Beschreibung |
|
||||
|-------|-------------|
|
||||
| [kim-angebot](kim-angebot/) | Erstellt KIM/V&S-Angebote als .docx im V&S-Briefformat |
|
||||
|
||||
## Installation
|
||||
|
||||
Skill-Ordner nach `~/.claude/skills/` kopieren:
|
||||
|
||||
```bash
|
||||
cp -r kim-angebot ~/.claude/skills/
|
||||
```
|
||||
|
||||
Oder direkt das ganze Repo klonen:
|
||||
|
||||
```bash
|
||||
git clone https://git.v-und-s.de/meyer/vns-skills.git
|
||||
# Dann Skills verlinken oder kopieren
|
||||
ln -s $(pwd)/vns-skills/kim-angebot ~/.claude/skills/kim-angebot
|
||||
```
|
||||
|
||||
## Neuen Skill hinzufügen
|
||||
|
||||
1. Ordner mit `SKILL.md` erstellen (siehe bestehende Skills als Vorlage)
|
||||
2. README.md aktualisieren
|
||||
3. Commit + Push
|
||||
|
||||
## Kontakt
|
||||
|
||||
Kirsten Meyer — meyer@v-und-s.de
|
||||
|
||||
172
kim-angebot/SKILL.md
Normal file
172
kim-angebot/SKILL.md
Normal 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.500–8.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.500–4.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`
|
||||
BIN
kim-angebot/assets/template.docx
Normal file
BIN
kim-angebot/assets/template.docx
Normal file
Binary file not shown.
BIN
kim-angebot/assets/unterschrift_benno.jpg
Normal file
BIN
kim-angebot/assets/unterschrift_benno.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
55
kim-angebot/references/preisliste.md
Normal file
55
kim-angebot/references/preisliste.md
Normal 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.500–8.500 EUR/Monat | Ganzheitliche KI-Integration, modularer Baukasten |
|
||||
| KI-Projektbegleitung (monatlich) | 4.500–4.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 | 2–4 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 | 150–500 EUR |
|
||||
| Wartung & Monitoring | 500–1.000 EUR |
|
||||
| Support | nach Aufwand (Tagessatz) |
|
||||
|
||||
## Typische Angebotskombinationen
|
||||
|
||||
### Typ A: KIM-Jahresprogramm
|
||||
- Monatspauschale (7.500–8.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.500–4.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
|
||||
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