diff --git a/.gitignore b/.gitignore index 5770d5a..7cae4a4 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ kostenschaetzung/ schadendokumentation/ zeitwert/ .claude/ +__pycache__/ diff --git a/schadenprotokoll/api/app.py b/schadenprotokoll/api/app.py new file mode 100644 index 0000000..b1bbf84 --- /dev/null +++ b/schadenprotokoll/api/app.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +""" +Schadenprotokoll API - Flask Backend +Endpunkte: /generate, /analyze, /vorbericht +""" +from flask import Flask, request, jsonify, send_file +from flask_cors import CORS +import tempfile +import os +from processors import parse_laufzettel, fill_pdf, analyze_pdf, generate_vorbericht + +app = Flask(__name__) +CORS(app) + +TEMPLATE_PDF = "/opt/stacks/spa-hosting/html/schadenprotokoll/templates/protokoll.pdf" +TEMPLATE_DOCX = "/opt/stacks/spa-hosting/html/schadenprotokoll/templates/vorbericht.docx" + + +@app.route("/health", methods=["GET"]) +def health(): + return jsonify({"status": "ok"}) + + +@app.route("/generate", methods=["POST"]) +def generate(): + """Laufzettel.docx -> vorausgefuelltes Protokoll.pdf""" + if "file" not in request.files: + return jsonify({"error": "Keine Datei hochgeladen"}), 400 + + file = request.files["file"] + if not file.filename.endswith(".docx"): + return jsonify({"error": "Nur .docx Dateien erlaubt"}), 400 + + with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as tmp_in: + file.save(tmp_in.name) + + try: + data = parse_laufzettel(tmp_in.name) + + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp_out: + fill_pdf(TEMPLATE_PDF, tmp_out.name, data) + + os.unlink(tmp_in.name) + return send_file( + tmp_out.name, + mimetype="application/pdf", + as_attachment=True, + download_name="Schadenprotokoll_vorbefuellt.pdf" + ) + except Exception as e: + os.unlink(tmp_in.name) + return jsonify({"error": str(e)}), 500 + + +@app.route("/analyze", methods=["POST"]) +def analyze(): + """Ausgefuelltes PDF -> JSON mit allen Feldern""" + if "file" not in request.files: + return jsonify({"error": "Keine Datei hochgeladen"}), 400 + + file = request.files["file"] + if not file.filename.endswith(".pdf"): + return jsonify({"error": "Nur .pdf Dateien erlaubt"}), 400 + + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp: + file.save(tmp.name) + + try: + result = analyze_pdf(tmp.name) + os.unlink(tmp.name) + return jsonify({"success": True, "data": result}) + except Exception as e: + os.unlink(tmp.name) + return jsonify({"error": str(e)}), 500 + + +@app.route("/vorbericht", methods=["POST"]) +def vorbericht(): + """Ausgefuelltes PDF -> Vorbericht.docx""" + if "file" not in request.files: + return jsonify({"error": "Keine Datei hochgeladen"}), 400 + + file = request.files["file"] + if not file.filename.endswith(".pdf"): + return jsonify({"error": "Nur .pdf Dateien erlaubt"}), 400 + + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp_in: + file.save(tmp_in.name) + + try: + pdf_data = analyze_pdf(tmp_in.name) + + with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as tmp_out: + generate_vorbericht(pdf_data, TEMPLATE_DOCX, tmp_out.name) + + os.unlink(tmp_in.name) + return send_file( + tmp_out.name, + mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + as_attachment=True, + download_name="Vorbericht.docx" + ) + except Exception as e: + os.unlink(tmp_in.name) + return jsonify({"error": str(e)}), 500 + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5050, debug=False) diff --git a/schadenprotokoll/api/processors.py b/schadenprotokoll/api/processors.py new file mode 100644 index 0000000..8eabd18 --- /dev/null +++ b/schadenprotokoll/api/processors.py @@ -0,0 +1,136 @@ +""" +Schadenprotokoll Processors - PDF/DOCX Verarbeitung +""" +from docx import Document +from docx.oxml.ns import qn +from pypdf import PdfReader, PdfWriter +import zipfile +import xml.etree.ElementTree as ET + + +def parse_laufzettel(docx_path: str) -> dict: + """Extrahiert Schadensdaten aus dem Laufzettel.docx""" + data = {} + + with zipfile.ZipFile(docx_path, "r") as z: + xml_content = z.read("word/document.xml") + root = ET.fromstring(xml_content) + + ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"} + + for sdt in root.findall(".//w:sdt", ns): + alias = sdt.find(".//w:alias", ns) + if alias is not None: + alias_val = alias.get(qn("w:val")) + texts = sdt.findall(".//w:t", ns) + content = " ".join([t.text for t in texts if t.text]).strip() + + field_map = { + "Schadennummer": "Schadennummer", + "Versicherungsnehmer": "Versicherungsnehmer:", + "Versicherer": "Versicherer:", + "Datum": "Datum:", + "Schadenort": "Schadenort:", + "Schadenart": "Schadenart:", + "e-Mail": "e-Mail VN:", + } + + if alias_val in field_map: + data[field_map[alias_val]] = content + elif alias_val == "Straße": + data["Adresse:"] = content + elif alias_val == "Ort": + if "Adresse:" in data: + data["Adresse:"] += ", " + content + else: + data["Adresse:"] = content + + return data + + +def fill_pdf(template_path: str, output_path: str, data: dict) -> str: + """Befuellt das PDF-Template mit den Schadensdaten""" + reader = PdfReader(template_path) + writer = PdfWriter() + + for page in reader.pages: + writer.add_page(page) + if "/Annots" in page: + writer.update_page_form_field_values( + writer.pages[-1], data, auto_regenerate=False + ) + + with open(output_path, "wb") as f: + writer.write(f) + + return output_path + + +def analyze_pdf(pdf_path: str) -> dict: + """Liest alle Formularfelder aus dem ausgefuellten PDF""" + reader = PdfReader(pdf_path) + result = {"textfields": {}, "dropdowns": {}, "checkboxes": {}} + + for page_num, page in enumerate(reader.pages): + if "/Annots" not in page: + continue + + for annot in page["/Annots"]: + obj = annot.get_object() + field_type = obj.get("/FT", "") + field_name = obj.get("/T", f"Feld_{page_num}") + + if field_type == "/Tx": + value = obj.get("/V", "") + result["textfields"][field_name] = str(value) if value else "" + + elif field_type == "/Ch": + value = obj.get("/V", "") + options = [] + if "/Opt" in obj: + for opt in obj["/Opt"]: + if isinstance(opt, list): + options.append(str(opt[1]) if len(opt) > 1 else str(opt[0])) + else: + options.append(str(opt)) + result["dropdowns"][field_name] = { + "selected": str(value) if value else "", + "options": options + } + + elif field_type == "/Btn": + value = obj.get("/V", "/Off") + result["checkboxes"][field_name] = value != "/Off" + + return result + + +def generate_vorbericht(pdf_data: dict, template_path: str, output_path: str) -> str: + """Generiert Vorbericht.docx aus PDF-Daten""" + doc = Document(template_path) + + tf = pdf_data.get("textfields", {}) + replacements = { + "Schadennummer": tf.get("Schadennummer", "xx"), + "Versicherer": tf.get("Versicherer:", "xx"), + "Versicherungsnehmer": tf.get("Versicherungsnehmer:", "xx"), + "Adresse": tf.get("Adresse:", "xx"), + } + + # Dropdown-Antworten fuer Sachverhalt sammeln + dropdowns = pdf_data.get("dropdowns", {}) + sachverhalt = [] + for key in sorted(dropdowns.keys()): + val = dropdowns[key].get("selected", "") + if val and val not in ["Was ist beschädigt?", "Schadenursache?", ""]: + sachverhalt.append(val) + + for para in doc.paragraphs: + text = para.text + for key, value in replacements.items(): + if key.lower() in text.lower() and "xx" in text: + para.text = text.replace("xx", value, 1) + break + + doc.save(output_path) + return output_path diff --git a/schadenprotokoll/app.js b/schadenprotokoll/app.js new file mode 100644 index 0000000..6a99b30 --- /dev/null +++ b/schadenprotokoll/app.js @@ -0,0 +1,174 @@ +// Tab Navigation +document.querySelectorAll(".tab").forEach(tab => { + tab.addEventListener("click", () => { + document.querySelectorAll(".tab").forEach(t => t.classList.remove("active")); + document.querySelectorAll(".tab-content").forEach(c => c.classList.remove("active")); + tab.classList.add("active"); + document.getElementById(tab.dataset.tab).classList.add("active"); + }); +}); + +// Upload Zone Setup +function setupUploadZone(zoneId, inputId, infoId, btnId) { + const zone = document.getElementById(zoneId); + const input = document.getElementById(inputId); + const info = document.getElementById(infoId); + const btn = document.getElementById(btnId); + + zone.addEventListener("click", () => input.click()); + zone.addEventListener("dragover", (e) => { e.preventDefault(); zone.classList.add("dragover"); }); + zone.addEventListener("dragleave", () => zone.classList.remove("dragover")); + zone.addEventListener("drop", (e) => { + e.preventDefault(); + zone.classList.remove("dragover"); + if (e.dataTransfer.files.length) { + input.files = e.dataTransfer.files; + updateFileInfo(input, info, btn); + } + }); + input.addEventListener("change", () => updateFileInfo(input, info, btn)); +} + +function updateFileInfo(input, info, btn) { + if (input.files.length) { + const file = input.files[0]; + info.innerHTML = `✓ ${file.name} (${(file.size / 1024).toFixed(1)} KB)`; + info.classList.add("visible"); + btn.disabled = false; + } +} + +function showStatus(id, type, message) { + const status = document.getElementById(id); + status.className = `status visible ${type}`; + status.textContent = message; +} + +// Setup all upload zones +setupUploadZone("laufzettel-zone", "laufzettel-input", "laufzettel-info", "generate-btn"); +setupUploadZone("pdf-zone", "pdf-input", "pdf-info", "analyze-btn"); +setupUploadZone("vorbericht-zone", "vorbericht-input", "vorbericht-info", "vorbericht-btn"); + +// API Base URL +const API_BASE = "/schadenprotokoll/api"; + +// Generate Button +document.getElementById("generate-btn").addEventListener("click", async () => { + showStatus("generate-status", "loading", "⏳ Verarbeite Laufzettel..."); + + const file = document.getElementById("laufzettel-input").files[0]; + const formData = new FormData(); + formData.append("file", file); + + try { + const response = await fetch(`${API_BASE}/generate`, { + method: "POST", + body: formData + }); + + if (\!response.ok) { + const err = await response.json(); + throw new Error(err.error || "Unbekannter Fehler"); + } + + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "Schadenprotokoll_vorbefuellt.pdf"; + a.click(); + URL.revokeObjectURL(url); + + showStatus("generate-status", "success", "✓ PDF erfolgreich generiert und heruntergeladen"); + } catch (err) { + showStatus("generate-status", "error", `❌ Fehler: ${err.message}`); + } +}); + +// Analyze Button +document.getElementById("analyze-btn").addEventListener("click", async () => { + showStatus("analyze-status", "loading", "⏳ Analysiere PDF..."); + + const file = document.getElementById("pdf-input").files[0]; + const formData = new FormData(); + formData.append("file", file); + + try { + const response = await fetch(`${API_BASE}/analyze`, { + method: "POST", + body: formData + }); + + if (\!response.ok) { + const err = await response.json(); + throw new Error(err.error || "Unbekannter Fehler"); + } + + const result = await response.json(); + displayAnalysisResult(result.data); + showStatus("analyze-status", "success", "✓ Analyse abgeschlossen"); + } catch (err) { + showStatus("analyze-status", "error", `❌ Fehler: ${err.message}`); + } +}); + +function displayAnalysisResult(data) { + const container = document.getElementById("analyze-data"); + let html = ""; + + // Textfelder + if (Object.keys(data.textfields).length > 0) { + html += `
Textfelder
`; + for (const [key, value] of Object.entries(data.textfields)) { + if (value) html += ``; + } + html += `
`; + } + + // Dropdowns + if (Object.keys(data.dropdowns).length > 0) { + html += `
Dropdown-Auswahlen (Sachverhalt)
`; + for (const [key, val] of Object.entries(data.dropdowns)) { + if (val.selected) { + html += ``; + } + } + html += `
`; + } + + container.innerHTML = html || "

Keine ausgefüllten Felder gefunden.

"; + document.getElementById("analyze-result").classList.add("visible"); +} + +// Vorbericht Button +document.getElementById("vorbericht-btn").addEventListener("click", async () => { + showStatus("vorbericht-status", "loading", "⏳ Erstelle Vorbericht..."); + + const file = document.getElementById("vorbericht-input").files[0]; + const formData = new FormData(); + formData.append("file", file); + + try { + const response = await fetch(`${API_BASE}/vorbericht`, { + method: "POST", + body: formData + }); + + if (\!response.ok) { + const err = await response.json(); + throw new Error(err.error || "Unbekannter Fehler"); + } + + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "Vorbericht.docx"; + a.click(); + URL.revokeObjectURL(url); + + showStatus("vorbericht-status", "success", "✓ Vorbericht erfolgreich erstellt und heruntergeladen"); + } catch (err) { + showStatus("vorbericht-status", "error", `❌ Fehler: ${err.message}`); + } +}); diff --git a/schadenprotokoll/index.html b/schadenprotokoll/index.html new file mode 100644 index 0000000..a211a93 --- /dev/null +++ b/schadenprotokoll/index.html @@ -0,0 +1,99 @@ + + + + + + Schadenprotokoll Tool | ass-ass + + + +
+
+

Schadenprotokoll Tool

+

Automatisierte Verarbeitung von Schadenprotokollen

+
+ +
+ + + +
+ + +
+
+

Schadenlaufzettel → Protokoll PDF

+

Lädt Daten aus dem Schadenlaufzettel.docx und füllt das Schadenprotokoll-PDF vor.

+
+ +
+ +

📄 Schadenlaufzettel.docx hochladen

+

Datei hierher ziehen oder klicken zum Auswählen

+
+
+ +
+ +
+
+
+ + +
+
+

Ausgefülltes PDF analysieren

+

Liest alle Formularfelder und Dropdown-Auswahlen aus dem Schadenprotokoll.

+
+ +
+ +

📋 Schadenprotokoll.pdf hochladen

+

Das ausgefüllte PDF-Formular

+
+
+ +
+ +
+
+
+

Analyseergebnis:

+
+
+
+ + +
+
+

Protokoll → Vorbericht Word

+

Generiert einen strukturierten Vorbericht aus dem ausgefüllten Schadenprotokoll.

+
+ +
+ +

📋 Ausgefülltes Schadenprotokoll.pdf

+

Das vollständig ausgefüllte PDF-Formular

+
+
+ +
+ +
+
+
+ + +
+ + + + diff --git a/schadenprotokoll/styles.css b/schadenprotokoll/styles.css new file mode 100644 index 0000000..30e7d59 --- /dev/null +++ b/schadenprotokoll/styles.css @@ -0,0 +1,107 @@ +* { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --primary: #2563eb; + --primary-dark: #1d4ed8; + --success: #16a34a; + --warning: #ca8a04; + --error: #dc2626; + --bg: #f8fafc; + --card: #ffffff; + --border: #e2e8f0; + --text: #1e293b; + --text-muted: #64748b; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.6; + min-height: 100vh; +} + +.container { max-width: 900px; margin: 0 auto; padding: 2rem; } + +header { text-align: center; margin-bottom: 2rem; } +header h1 { font-size: 1.75rem; font-weight: 600; margin-bottom: 0.5rem; } +header p { color: var(--text-muted); } + +.tabs { + display: flex; gap: 0.5rem; margin-bottom: 1.5rem; + border-bottom: 2px solid var(--border); padding-bottom: 0.5rem; +} + +.tab { + padding: 0.75rem 1.5rem; border: none; background: none; + font-size: 1rem; cursor: pointer; border-radius: 0.5rem 0.5rem 0 0; + color: var(--text-muted); transition: all 0.2s; +} + +.tab:hover { background: var(--border); } +.tab.active { background: var(--primary); color: white; } + +.tab-content { + display: none; background: var(--card); border-radius: 0.75rem; + padding: 2rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} +.tab-content.active { display: block; } + +.upload-zone { + border: 2px dashed var(--border); border-radius: 0.75rem; + padding: 3rem; text-align: center; transition: all 0.2s; cursor: pointer; +} +.upload-zone:hover, .upload-zone.dragover { + border-color: var(--primary); background: #eff6ff; +} +.upload-zone input { display: none; } +.upload-zone h3 { margin-bottom: 0.5rem; } +.upload-zone p { color: var(--text-muted); font-size: 0.875rem; } + +.file-info { + margin-top: 1rem; padding: 1rem; background: #f0fdf4; + border-radius: 0.5rem; display: none; +} +.file-info.visible { display: block; } + +.btn { + display: inline-flex; align-items: center; gap: 0.5rem; + padding: 0.75rem 1.5rem; border: none; border-radius: 0.5rem; + font-size: 1rem; cursor: pointer; transition: all 0.2s; +} +.btn-primary { background: var(--primary); color: white; } +.btn-primary:hover { background: var(--primary-dark); } +.btn-primary:disabled { background: var(--border); cursor: not-allowed; } + +.actions { margin-top: 1.5rem; display: flex; gap: 1rem; } + +.status { + padding: 1rem; border-radius: 0.5rem; margin-top: 1rem; display: none; +} +.status.visible { display: block; } +.status.success { background: #f0fdf4; color: var(--success); } +.status.error { background: #fef2f2; color: var(--error); } +.status.loading { background: #eff6ff; color: var(--primary); } + +.info-box { + background: #eff6ff; border-left: 4px solid var(--primary); + padding: 1rem; margin-bottom: 1.5rem; border-radius: 0 0.5rem 0.5rem 0; +} +.info-box h4 { margin-bottom: 0.25rem; } +.info-box p { color: var(--text-muted); font-size: 0.875rem; } + +.result { margin-top: 1.5rem; padding: 1rem; background: var(--bg); border-radius: 0.5rem; display: none; } +.result.visible { display: block; } +.result h4 { margin-bottom: 0.75rem; } + +.dropdown-item { + padding: 0.75rem; background: white; border: 1px solid var(--border); + border-radius: 0.5rem; margin-bottom: 0.5rem; +} +.dropdown-item strong { color: var(--primary); } + +.field-group { margin-bottom: 1rem; } +.field-group h5 { margin-bottom: 0.5rem; color: var(--text-muted); } +.field-list { display: grid; gap: 0.5rem; } + +footer { text-align: center; margin-top: 3rem; color: var(--text-muted); font-size: 0.875rem; } diff --git a/schadenprotokoll/templates/protokoll.pdf b/schadenprotokoll/templates/protokoll.pdf new file mode 100644 index 0000000..af1c2e6 Binary files /dev/null and b/schadenprotokoll/templates/protokoll.pdf differ diff --git a/schadenprotokoll/templates/vorbericht.docx b/schadenprotokoll/templates/vorbericht.docx new file mode 100644 index 0000000..0c2de4e Binary files /dev/null and b/schadenprotokoll/templates/vorbericht.docx differ