From b29c4671873b205ff50aafe12323cf402bf49daa Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sun, 26 Apr 2026 07:51:39 +0200 Subject: [PATCH] Initial commit - Stand 26.04.2026 --- .dockerignore | 10 + .env.example | 15 + .gitignore | 49 + Aktuelle planung.txt | 4065 +++++++++++++++++ CHANGELOG.md | 141 + DOKUMENTATION_NIKI_IMPORT.md | 146 + ...Kostenrechnung der Nächsten jahre (3).xlsx | Bin 0 -> 76534 bytes MIGRATION-ANLEITUNG.md | 98 + NEBENKOSTEN_IMPLEMENTATION.md | 111 + NEBENKOSTEN_STATUS.md | 143 + NIKI_IMPORT_ERGEBNIS.md | 62 + Nebenkosten 2020.xlsx | Bin 0 -> 40773 bytes README.md | 77 + Schulden Kerstin.xlsx | Bin 0 -> 18976 bytes Schulden Niki.xlsx | Bin 0 -> 12185 bytes TEST_GESCHAEFTSPLANUNG.md | 67 + add_kosten_routes.js | 66 + analyze_all_sheets.py | 67 + analyze_excel.py | 43 + analyze_excel2.py | 33 + analyze_excel3.py | 47 + analyze_excel4.py | 58 + analyze_excel_kredite.py | 96 + analyze_final.py | 136 + analyze_kerstin.py | 51 + analyze_korrigiert.py | 150 + analyze_kredite_v2.py | 145 + analyze_kredite_v3.py | 127 + analyze_kredite_v4.py | 78 + analyze_niki.py | 136 + analyze_spalten.py | 28 + analyze_tabelle1.py | 112 + analyze_tilgung_sheet.py | 91 + backend/Dockerfile | 21 + backend/Dockerfile.dev | 8 + backend/fix_server.js | 5 + backend/import-test-brandt2023.js | 108 + backend/middleware/auth.js | 47 + backend/package.json | 24 + backend/public/logo.png | Bin 0 -> 20132 bytes backend/routes/auth.js | 315 ++ backend/routes/auth.js.unix | 315 ++ backend/routes/fix_line_endings.js | 13 + backend/routes/nebenkosten.js | 1419 ++++++ backend/routes/nebenkosten.js.unix | 1287 ++++++ backend/routes/nebenkosten_container.js | 962 ++++ backend/routes/nebenkosten_container.js.unix | 962 ++++ backend/routes/nebenkosten_pdf.js | 202 + backend/routes/nebenkosten_pdf.js.unix | 202 + backend/routes/nebenkosten_pdf_pro_mieter.js | 220 + .../routes/nebenkosten_pdf_pro_mieter.js.unix | 220 + backend/routes/nebenkosten_pdf_v3.js | 219 + backend/routes/nebenkosten_pdf_v3.js.unix | 219 + backend/routes/nebenkosten_unix.js | 1287 ++++++ backend/routes/nebenkosten_unix.js.unix | 1287 ++++++ backend/routes/privat.js | 151 + backend/server.js | 1583 +++++++ backend/services/ocr.js | 80 + ...s-6a0367a7-b926-4a1f-a025-9500647cfdd0.pdf | Bin 0 -> 17961 bytes ...s-94a3f633-b585-4fa1-ad2e-fa7daadadbe9.pdf | Bin 0 -> 17607 bytes backup_vor_kredit_import_20260420_1746.json | 40 + backup_vor_multi_kredit_import_ | Bin 0 -> 95272 bytes check_db.py | 28 + debug_excel.py | 17 + debug_kredite.py | 74 + debug_sonderkredite.py | 82 + delete_old_credit.py | 15 + docker-compose.prod.yml | 35 + docker-compose.yml | 65 + excel_analysis.json | 1994 ++++++++ execute_import.py | 88 + extract_excel.py | 80 + extract_heizung_muell.py | 33 + extract_kredite_complete.py | 195 + extract_nebenkosten.py | 26 + final_analysis.py | 85 + fix_niki_restschuld.js | 80 + fix_niki_restschuld.py | 39 + fix_restschuld.js | 80 + frontend/Dockerfile | 18 + frontend/Dockerfile.dev | 8 + frontend/build.js | 13 + frontend/index.html | 13 + frontend/nginx.conf | 25 + frontend/package.json | 25 + frontend/postcss.config.js | 6 + frontend/public/logo.png | Bin 0 -> 20132 bytes frontend/src/App.jsx | 346 ++ frontend/src/api-nebenkosten.js | 221 + frontend/src/api.js | 314 ++ frontend/src/components/Auftragsnachweis.jsx | 778 ++++ frontend/src/components/Dashboard.jsx | 447 ++ frontend/src/components/Dokumente.jsx | 187 + frontend/src/components/Geschaeftsplanung.jsx | 349 ++ .../src/components/Geschaeftsplanung.jsx.bak | 457 ++ frontend/src/components/Kostenplanung.jsx | 457 ++ frontend/src/components/Kredite.jsx | 1149 +++++ frontend/src/components/KrediteNeu.jsx | 500 ++ frontend/src/components/Nebenkosten.jsx | 424 ++ frontend/src/components/NebenkostenV2.jsx | 655 +++ frontend/src/components/PrivatAusgaben.jsx | 530 +++ frontend/src/components/PrivatKredite.jsx | 444 ++ frontend/src/components/PrivateKosten.jsx | 451 ++ frontend/src/components/Stunden.jsx | 396 ++ frontend/src/components/auth/Login.jsx | 109 + .../src/components/auth/ProtectedRoute.jsx | 30 + frontend/src/components/auth/SetupWizard.jsx | 153 + .../auth/user-management/AddUserForm.jsx | 67 + .../auth/user-management/AlertMessage.jsx | 22 + .../user-management/ResetPasswordForm.jsx | 43 + .../auth/user-management/UserList.jsx | 90 + .../auth/user-management/UserManagement.jsx | 117 + .../components/auth/user-management/index.js | 2 + .../auth/user-management/useUserManagement.js | 133 + .../src/components/nebenkosten/Mieter.jsx | 711 +++ .../nebenkosten/Nebenkostenabrechnung.jsx | 465 ++ .../src/components/nebenkosten/Objekte.jsx | 813 ++++ .../nebenkosten/VermietungsBilanz.jsx | 360 ++ frontend/src/contexts/AuthContext.jsx | 162 + frontend/src/index.css | 3 + frontend/src/initialData.js | 46 + frontend/src/main.jsx | 10 + frontend/tailwind.config.js | 11 + frontend/vite.config.js | 20 + import-nebenkosten.js | 146 + import-test-brandt2023.js | 106 + import_5_kredite.py | 238 + import_data.json | 73 + import_kerstin_api.js | 294 ++ import_kerstin_complete.py | 275 ++ import_kerstin_final.py | 267 ++ import_kerstin_final2.py | 271 ++ import_kerstin_final3.py | 286 ++ import_kerstin_fix.py | 120 + import_kerstin_korrigiert.py | 235 + import_kerstin_zahlungen.py | 246 + import_nebenkosten_complete.js | 240 + import_niki_api.js | 218 + import_niki_complete.py | 221 + import_niki_docker.py | 221 + import_niki_final.js | 274 ++ import_niki_fixed.js | 246 + import_niki_korrigiert.py | 214 + import_niki_schulden.py | 240 + import_via_api.py | 203 + import_zingelstr14.js | 142 + kredite_analyse.json | 78 + mobile/docker-compose.yml | 16 + mobile/nginx-config.conf | 20 + mobile/nginx.conf | 0 mobile/public/app.js | 350 ++ mobile/public/index.html | 412 ++ mobile/public/manifest.json | 26 + mobile/public/sw.js | 56 + nebenkosten_zingelstr14.json | 61 + nebenkostenabrechnung-pro-mieter.pdf | Bin 0 -> 5646 bytes nebenkostenabrechnung-test.pdf | Bin 0 -> 2030 bytes nebenkostenabrechnung-v2.pdf | Bin 0 -> 3060 bytes nebenkostenabrechnung-v3.pdf | Bin 0 -> 3356 bytes nginx.conf | 52 + read_all.py | 18 + read_all2.py | 21 + read_debts.py | 19 + read_excel.py | 27 + read_excel_values.py | 32 + read_kosten.py | 18 + read_kosten2.py | 16 + read_kredite_excel.py | 90 + read_kredite_excel2.py | 135 + read_niki_complete.py | 61 + read_niki_excel.py | 48 + read_niki_excel2.py | 60 + read_nk.py | 22 + read_quick.py | 27 + read_small.py | 27 + result_summary.py | 70 + run_kerstin_import.sh | 3 + test-patch.sh | 22 + test-pdf.js | 26 + test-post.js | 28 + test.pdf | 1 + verify_import.py | 35 + verify_kredite_final.py | 90 + verify_niki.py | 96 + verify_niki_final.js | 125 + verify_niki_import.py | 77 + 186 files changed, 39281 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Aktuelle planung.txt create mode 100644 CHANGELOG.md create mode 100644 DOKUMENTATION_NIKI_IMPORT.md create mode 100644 Kopie von Kostenrechnung der Nächsten jahre (3).xlsx create mode 100644 MIGRATION-ANLEITUNG.md create mode 100644 NEBENKOSTEN_IMPLEMENTATION.md create mode 100644 NEBENKOSTEN_STATUS.md create mode 100644 NIKI_IMPORT_ERGEBNIS.md create mode 100644 Nebenkosten 2020.xlsx create mode 100644 README.md create mode 100644 Schulden Kerstin.xlsx create mode 100644 Schulden Niki.xlsx create mode 100644 TEST_GESCHAEFTSPLANUNG.md create mode 100644 add_kosten_routes.js create mode 100644 analyze_all_sheets.py create mode 100644 analyze_excel.py create mode 100644 analyze_excel2.py create mode 100644 analyze_excel3.py create mode 100644 analyze_excel4.py create mode 100644 analyze_excel_kredite.py create mode 100644 analyze_final.py create mode 100644 analyze_kerstin.py create mode 100644 analyze_korrigiert.py create mode 100644 analyze_kredite_v2.py create mode 100644 analyze_kredite_v3.py create mode 100644 analyze_kredite_v4.py create mode 100644 analyze_niki.py create mode 100644 analyze_spalten.py create mode 100644 analyze_tabelle1.py create mode 100644 analyze_tilgung_sheet.py create mode 100644 backend/Dockerfile create mode 100644 backend/Dockerfile.dev create mode 100644 backend/fix_server.js create mode 100644 backend/import-test-brandt2023.js create mode 100644 backend/middleware/auth.js create mode 100644 backend/package.json create mode 100644 backend/public/logo.png create mode 100644 backend/routes/auth.js create mode 100644 backend/routes/auth.js.unix create mode 100644 backend/routes/fix_line_endings.js create mode 100644 backend/routes/nebenkosten.js create mode 100644 backend/routes/nebenkosten.js.unix create mode 100644 backend/routes/nebenkosten_container.js create mode 100644 backend/routes/nebenkosten_container.js.unix create mode 100644 backend/routes/nebenkosten_pdf.js create mode 100644 backend/routes/nebenkosten_pdf.js.unix create mode 100644 backend/routes/nebenkosten_pdf_pro_mieter.js create mode 100644 backend/routes/nebenkosten_pdf_pro_mieter.js.unix create mode 100644 backend/routes/nebenkosten_pdf_v3.js create mode 100644 backend/routes/nebenkosten_pdf_v3.js.unix create mode 100644 backend/routes/nebenkosten_unix.js create mode 100644 backend/routes/nebenkosten_unix.js.unix create mode 100644 backend/routes/privat.js create mode 100644 backend/server.js create mode 100644 backend/services/ocr.js create mode 100644 backend/uploads/auftragsnachweis-6a0367a7-b926-4a1f-a025-9500647cfdd0.pdf create mode 100644 backend/uploads/auftragsnachweis-94a3f633-b585-4fa1-ad2e-fa7daadadbe9.pdf create mode 100644 backup_vor_kredit_import_20260420_1746.json create mode 100644 backup_vor_multi_kredit_import_ create mode 100644 check_db.py create mode 100644 debug_excel.py create mode 100644 debug_kredite.py create mode 100644 debug_sonderkredite.py create mode 100644 delete_old_credit.py create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 excel_analysis.json create mode 100644 execute_import.py create mode 100644 extract_excel.py create mode 100644 extract_heizung_muell.py create mode 100644 extract_kredite_complete.py create mode 100644 extract_nebenkosten.py create mode 100644 final_analysis.py create mode 100644 fix_niki_restschuld.js create mode 100644 fix_niki_restschuld.py create mode 100644 fix_restschuld.js create mode 100644 frontend/Dockerfile create mode 100644 frontend/Dockerfile.dev create mode 100644 frontend/build.js create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/logo.png create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/api-nebenkosten.js create mode 100644 frontend/src/api.js create mode 100644 frontend/src/components/Auftragsnachweis.jsx create mode 100644 frontend/src/components/Dashboard.jsx create mode 100644 frontend/src/components/Dokumente.jsx create mode 100644 frontend/src/components/Geschaeftsplanung.jsx create mode 100644 frontend/src/components/Geschaeftsplanung.jsx.bak create mode 100644 frontend/src/components/Kostenplanung.jsx create mode 100644 frontend/src/components/Kredite.jsx create mode 100644 frontend/src/components/KrediteNeu.jsx create mode 100644 frontend/src/components/Nebenkosten.jsx create mode 100644 frontend/src/components/NebenkostenV2.jsx create mode 100644 frontend/src/components/PrivatAusgaben.jsx create mode 100644 frontend/src/components/PrivatKredite.jsx create mode 100644 frontend/src/components/PrivateKosten.jsx create mode 100644 frontend/src/components/Stunden.jsx create mode 100644 frontend/src/components/auth/Login.jsx create mode 100644 frontend/src/components/auth/ProtectedRoute.jsx create mode 100644 frontend/src/components/auth/SetupWizard.jsx create mode 100644 frontend/src/components/auth/user-management/AddUserForm.jsx create mode 100644 frontend/src/components/auth/user-management/AlertMessage.jsx create mode 100644 frontend/src/components/auth/user-management/ResetPasswordForm.jsx create mode 100644 frontend/src/components/auth/user-management/UserList.jsx create mode 100644 frontend/src/components/auth/user-management/UserManagement.jsx create mode 100644 frontend/src/components/auth/user-management/index.js create mode 100644 frontend/src/components/auth/user-management/useUserManagement.js create mode 100644 frontend/src/components/nebenkosten/Mieter.jsx create mode 100644 frontend/src/components/nebenkosten/Nebenkostenabrechnung.jsx create mode 100644 frontend/src/components/nebenkosten/Objekte.jsx create mode 100644 frontend/src/components/nebenkosten/VermietungsBilanz.jsx create mode 100644 frontend/src/contexts/AuthContext.jsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/initialData.js create mode 100644 frontend/src/main.jsx create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/vite.config.js create mode 100644 import-nebenkosten.js create mode 100644 import-test-brandt2023.js create mode 100644 import_5_kredite.py create mode 100644 import_data.json create mode 100644 import_kerstin_api.js create mode 100644 import_kerstin_complete.py create mode 100644 import_kerstin_final.py create mode 100644 import_kerstin_final2.py create mode 100644 import_kerstin_final3.py create mode 100644 import_kerstin_fix.py create mode 100644 import_kerstin_korrigiert.py create mode 100644 import_kerstin_zahlungen.py create mode 100644 import_nebenkosten_complete.js create mode 100644 import_niki_api.js create mode 100644 import_niki_complete.py create mode 100644 import_niki_docker.py create mode 100644 import_niki_final.js create mode 100644 import_niki_fixed.js create mode 100644 import_niki_korrigiert.py create mode 100644 import_niki_schulden.py create mode 100644 import_via_api.py create mode 100644 import_zingelstr14.js create mode 100644 kredite_analyse.json create mode 100644 mobile/docker-compose.yml create mode 100644 mobile/nginx-config.conf create mode 100644 mobile/nginx.conf create mode 100644 mobile/public/app.js create mode 100644 mobile/public/index.html create mode 100644 mobile/public/manifest.json create mode 100644 mobile/public/sw.js create mode 100644 nebenkosten_zingelstr14.json create mode 100644 nebenkostenabrechnung-pro-mieter.pdf create mode 100644 nebenkostenabrechnung-test.pdf create mode 100644 nebenkostenabrechnung-v2.pdf create mode 100644 nebenkostenabrechnung-v3.pdf create mode 100644 nginx.conf create mode 100644 read_all.py create mode 100644 read_all2.py create mode 100644 read_debts.py create mode 100644 read_excel.py create mode 100644 read_excel_values.py create mode 100644 read_kosten.py create mode 100644 read_kosten2.py create mode 100644 read_kredite_excel.py create mode 100644 read_kredite_excel2.py create mode 100644 read_niki_complete.py create mode 100644 read_niki_excel.py create mode 100644 read_niki_excel2.py create mode 100644 read_nk.py create mode 100644 read_quick.py create mode 100644 read_small.py create mode 100644 result_summary.py create mode 100644 run_kerstin_import.sh create mode 100644 test-patch.sh create mode 100644 test-pdf.js create mode 100644 test-post.js create mode 100644 test.pdf create mode 100644 verify_import.py create mode 100644 verify_kredite_final.py create mode 100644 verify_niki.py create mode 100644 verify_niki_final.js create mode 100644 verify_niki_import.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..daf3084 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +npm-debug.log +yarn-error.log +.env +.DS_Store +.vscode +.idea +*.log +dist +build diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7678c98 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Umgebungsvariablen + +# Datenbank +DB_PATH=/app/data/steuer.db + +# Upload +UPLOAD_DIR=/app/uploads +MAX_FILE_SIZE=10485760 + +# CORS (für Produktion anpassen) +CORS_ORIGIN=http://localhost:5173 + +# Optional: Reverse Proxy Domain +# VIRTUAL_HOST=steuerflow.dein-domain.de +# LETSENCRYPT_HOST=steuerflow.dein-domain.de diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e5e7710 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# Dependencies +node_modules/ +package-lock.json + +# Build outputs +dist/ +build/ + +# Logs +*.log +npm-debug.log* + +# Environment +.env +.env.local + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db + +# Backups +*.backup* +*.backup +*.sql +*.tar +*.tar.gz +*.zip + +# Temp files +*.tmp +*.temp + +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python + +# Excel locks +.~lock.* + +# Database +*.db +*.sqlite diff --git a/Aktuelle planung.txt b/Aktuelle planung.txt new file mode 100644 index 0000000..82a5a0f --- /dev/null +++ b/Aktuelle planung.txt @@ -0,0 +1,4065 @@ +Die Frage ist, heben wir die den Scanner in die Umgebung oder Mappen wir einen Ordner in das Reposotory. Vielleicht machen wir es auch über die Ordnerstruktur. Manche Dokumente werden an verschiedenen Stellen benötigt, Wohngeld Brauche ich für die Übersicht aber auch für die Nebenkosten und die Steuer. +Um das Elster Formular besser vorzubereiten wäre eine Auswertung des letzten Dokumente hilfreich. Aber damit das vernünftig ausgewertet werden kann, wird OCR nicht reichen, auch hier wird KI benötigt. Mein Vorschlag, wir nutzen auch hier meine ollama Instanz und jagen die Daten mit falschem werten in die Modelle und dann korrigiert in unsere Applikation..was sagst du zu den vorgehen? + +profile +kimi-k2.5:cloud +Nachgedacht für 11 Sekunden +Das ist ein exzellenter architektonischer Ansatz! Die Kombination aus ordner-basiertem Ingest, graphenartiger Dokumentenverwendung und Ollama für Kontext-verstärkte Extraktion löst genau die Probleme, die kommerzielle Tools haben (starre Workflows & Cloud-Abhängigkeit). + +🏗️ Architektur-Vorschlag: "Smart Document Graph" +1. Scan-Strategie: Ordner-Mapping (Hot Folder) +Nicht den Scanner direkt integrieren, sondern einen Watchdog-Ordner: + +yaml + +Einklappen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +# docker-compose.yml Erweiterung +services: + scanner-watchdog: + image: python:3.11-slim + volumes: + - ./scan-inbox:/app/inbox:ro # Netzwerk-Scanordner (SMB/NFS) + - ./scan-processing:/app/processing # Temporär + - ./scan-archive:/app/archive # Dauerhaft (Original) + environment: + - OLLAMA_URL=http://ollama:11434 + - DOCUMENT_API=http://app:3000/api/documents + command: python watchdog.py + + ollama: + image: ollama/ollama:latest + container_name: buchhaltung_ollama + volumes: + - ollama_data:/root/.ollama + - ./ollama-modelfile:/modelfile # Für Custom Modelle + deploy: + resources: + limits: + memory: 8G # Wichtig für LLMs! + networks: + - app-network +Warum Ordner-Mapping? + +Der Scanner speichert per SMB/FTP in ./scan-inbox +Mehrere Quellen: Scanner, E-Mail-Attachments (via IMAP), manueller Upload +Idempotenz: Datei-Hash prüfen, Duplikate vermeiden +2. Dokumenten-Modell: "Single Source of Truth" mit Multi-Context +Ein Dokument existiert physikalisch einmal, wird aber logisch mehrfach referenziert: + +sql + +Einklappen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +⌄ +⌄ +-- Dokumente (Single Source) +CREATE TABLE documents ( + id UUID PRIMARY KEY, + file_path VARCHAR(500) UNIQUE, -- z.B. "archive/2024/scan_001.pdf" + file_hash VARCHAR(64), -- SHA256 für Duplikat-Erkennung + ocr_raw TEXT, -- Roh-OCR + extracted_json JSONB, -- Ollama-Extraktion + extraction_version INTEGER, -- Für Iterationen + created_at TIMESTAMP +); + +-- Dokument-Context (Nutzung an verschiedenen Stellen) +CREATE TABLE document_usages ( + id UUID PRIMARY KEY, + document_id UUID REFERENCES documents(id), + context_type VARCHAR(50), -- 'wohngeld', 'euer', 'steuer', 'privat' + context_id UUID, -- Foreign Key zur jeweiligen Tabelle + extracted_subset JSONB, -- Nur relevante Felder für diesen Context + confidence FLOAT, + verified BOOLEAN DEFAULT false, + corrected_data JSONB, -- User-Korrekturen (für Training) + created_at TIMESTAMP +); + +-- Beispiel: Eine Wohngeldabrechnung +-- documents: Das gescannte PDF +-- document_usages: +-- 1. context='wohngeld', context_id=[mietverhaeltnis_id] +-- 2. context='euer', context_id=[geschaeftsjahr_id] +-- 3. context='steuer', context_id=[steuerjahr_id] +3. Ollama-Integration: "Falsch-Values" Workflow (Active Learning) +Dein Ansatz ist genial – das nennt man "Human-in-the-Loop" mit Few-Shot Learning: + +python + +Einklappen + +Ausführen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +# ollama_processor.py +import ollama +import json +from typing import Dict, Optional + +class DocumentIntelligence: + def __init__(self): + self.model = "mixtral" # oder "llama3", "mistral", "codellama" + # Für deutsche Finanzdokumente evtl. "dolphin-mixtral" + + def extract_with_history(self, ocr_text: str, document_type: str, + previous_corrections: Optional[list] = None) -> Dict: + """ + Nutzt vorherige Korrekturen für bessere Extraktion + """ + + # Few-Shot Beispiele aus corrected_data laden + examples = "" + if previous_corrections: + for corr in previous_corrections[-3:]: # Letzte 3 Korrekturen + examples += f""" + Beispiel: + Input: {corr['raw_text']} + Extrahiert: {json.dumps(corr['wrong_extraction'])} + Korrektur: {json.dumps(corr['corrected'])} + """ + + prompt = f"""Du bist ein deutscher Steuerberater-Assistent. + Analysiere folgenden OCR-Text eines {document_type}-Dokuments. + + {examples} + + Extrahiere folgende Felder als JSON: + - betrag_gesamt (Zahl) + - datum (ISO 8601) + - kostenstelle (Kategorie: Immobilie, Hausmeister, Finanzierung, Versicherung) + - steuerlich_absetzbar (boolean) + - mwst_satz (7 oder 19 oder 0) + - zahlungsziel (Tage oder null) + + WICHTIG: Wenn du unsicher bist, setze den Wert auf null oder "unbekannt". + + OCR-Text: + {ocr_text} + + Antwort nur als JSON:""" + + response = ollama.generate(model=self.model, prompt=prompt) + + try: + return json.loads(response['response']) + except: + return {"error": "parse_failed", "raw": response['response']} + + def validate_against_history(self, current_doc: Dict, + historical_docs: list) -> Dict: + """ + Plausibilitätscheck: "Grundsteuer um 20% gestiegen?" + """ + if not historical_docs: + return {"warnings": []} + + prompt = f"""Vergleiche dieses Dokument mit historischen Daten: + + Aktuell: {json.dumps(current_doc)} + Historie (letzte 3): {json.dumps(historical_docs)} + + Gib Warnungen aus als JSON-Array: + - auffaellige_abweichungen (z.B. "Kosten um 50% gestiegen") + - fehlende_felder (was fehlt vs. letztes Jahr) + - empfohlene_kostenstelle (basierend auf Ähnlichkeit) + + Antwort als JSON:""" + + response = ollama.generate(model=self.model, prompt=prompt) + return json.loads(response['response']) +4. Der Korrektur-Workflow (Feedback-Loop) +typescript + +Einklappen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +// Frontend-Komponente: "Smart Correction Interface" +interface DocumentCorrectionProps { + documentId: string; + initialExtraction: any; // Ollama's "falsche" Werte + documentType: string; +} + +const SmartCorrection: React.FC = ({ + documentId, + initialExtraction +}) => { + const [values, setValues] = useState(initialExtraction); + const [isCorrecting, setIsCorrecting] = useState(false); + + const handleCorrection = async (field: string, correctValue: any) => { + const oldValue = values[field]; + + // Speichere Korrektur für Ollama-Lernen + await fetch('/api/ml/feedback', { + method: 'POST', + body: JSON.stringify({ + documentId, + field, + wrongValue: oldValue, + correctValue, + context: values, // Voller Kontext + ocrSnippet: getRelevantOCR(field) // Textausschnitt + }) + }); + + // Aktualisiere UI + setValues({...values, [field]: correctValue}); + }; + + // "Rot markiert" = Confidence < 0.7 oder null + const getFieldColor = (field: string) => { + const val = values[field]; + if (val === null || val === "unbekannt") return "border-red-500 bg-red-50"; + if (initialExtraction.confidence?.[field] < 0.7) return "border-yellow-400"; + return "border-green-500"; + }; + + return ( +
+
+ + {/* OCR-Highlighting beim Hover über Feld */} +
+ +
+ {Object.entries(values).map(([key, val]) => ( +
+ + handleCorrection(key, e.target.value)} + className={val === null ? "text-red-600 font-bold" : ""} + /> + {val === null && Fehlt!} +
+ ))} + + +
+
+ ); +}; +5. Kontext-Sensitive Verwendung +Wenn du das Wohngeld-Dokument in die Steuer übernimmst: + +python + +Einklappen + +Ausführen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +⌄ +⌄ +⌄ +def propagate_document_context(document_id: str, source_context: str, target_context: str): + """ + Überträgt Dokumentendaten zwischen Kontexten mit Ollama-Transformation + """ + + doc = get_document(document_id) + source_data = doc['usages'][source_context]['extracted_subset'] + + # Ollama transformiert Wohngeld-Daten in Steuer-relevante Daten + prompt = f""" + Transformiere diese Nebenkostenabrechnungs-Daten in Steuerliche Kostenstellen (EÜR). + + Input (Wohngeld): + {json.dumps(source_data)} + + Regeln: + - Heizkosten → Heizkosten (Steuerlich absetzbar bei Vermietung) + - Grundsteuer → Grundsteuer + - Versicherungen → Versicherungskosten + - Hausmeister → Instandhaltungskosten (sofern nicht unterhaltend) + + Output als JSON mit Feldern: konto_skr03, betrag, absetzbar_ja_nein, anmerkung + """ + + transformation = ollama.generate(model='mixtral', prompt=prompt) + + # Speichere in neuem Context + create_document_usage( + document_id=document_id, + context_type=target_context, + extracted_subset=json.loads(transformation), + inherited_from=source_context + ) +6. Elster-Vorbereitung mit "Memory" +Für die Elster-Auswertung lädst du die letzten Dokumente in den Kontext: + +python + +Einklappen + +Ausführen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +⌄ +⌄ +def prepare_elster_summary(year: int): + # Hole alle Steuer-relevanten Dokumente des Jahres + docs = get_documents_by_context('steuer', year) + + # Gruppiere nach Kategorien + summary_prompt = f""" + Basierend auf folgenden Dokumenten des Jahres {year}, erstelle eine Elster-Vorlage. + + Dokumente: + {json.dumps([d['extracted_subset'] for d in docs])} + + Erstelle: + 1. EÜR-Vorlage (Einnahmen-Überschuss-Rechnung) + 2. Anlage AUS (Ausländische Einkünfte) falls relevant + 3. Anlage EÜR (Betriebseinnahmen/-ausgaben) + + Markiere Unstimmigkeiten und fehlende Belege rot. + """ + + return ollama.generate(model='mixtral', prompt=summary_prompt) +📁 Ordner-Struktur Vorschlag + +Einklappen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +./data +├── /scan-inbox # Temporär (Watchdog verarbeitet) +├── /scan-processing # Aktive OCR-Verarbeitung +├── /archive # Original-PDFs (Jahres-Struktur) +│ ├── 2023/ +│ ├── 2024/ +│ └── ... +├── /extracted # JSON-Exports (Backup) +│ ├── ollama-raw/ # Roh-Extraktionen +│ └── corrected/ # Korrigierte Versionen +└── /training-data # Für Ollama-Fine-Tuning + └── corrections.jsonl # Deine Korrekturen als Training +⚠️ Wichtige Hinweise +Ollama Performance: +Mixtral braucht mindestens 6-8GB RAM +Nutze OLLAMA_KEEP_ALIVE=24h damit das Modell im Speicher bleibt +Für schnelle Tests: llama3 (weniger präzise, aber schneller) +Feedback-Loop persistieren: +json + +Einklappen +Kopieren +1 +2 +// corrections.jsonl Format für späteres Training +{"prompt": "OCR: Heizkstr 1.234,50\nFelder:", "completion": "{\"heizkosten\": 1234.50}"} +Versionierung: +Speichere jeden Extraktions-Versuch (extraction_version) +Ollama lernt daraus, welche Versionen besser waren +Backup-Strategie: +Dokumente sind unveränderlich (immutable) +Nur die extracted_json und document_usages ändern sich +Soll ich die Watchdog-Implementierung (Python-Skript für Ordner-Überwachung) oder das Ollama-Prompt-Engineering für deutsche Finanzdokumente detailliert ausarbeiten? + +Dass olama läuft zwar lokal, aber es sind auch online Modelle dahinter. Lass uns daher die Zahlen vorher durch Platzhalter oder falsche Werte ersetzen, genau wie Namen und Anschrift. Die url ist ollama.lan:11434 + +Durch die Namensauflösung kann diese URL auch in anderen Umgebungen oder beim Verschieben des Container genutzt werden. Als Modell nutze ich aktuell überwiegend kimi2.5 + +Wenn wir ein lokales Modell benötigen kann favorisiere ich aktuell glm-4.7 + +profile +kimi-k2.5:cloud +Nachgedacht für 7 Sekunden +Ausgezeichnete Security-Herangehensweise! Ein Privacy Gateway mit Pre/Post-Processing ist genau der richtige Ansatz für sensible Finanzdaten. Hier ist die Architektur für eine anonymisierte KI-Verarbeitung: + +🔒 Privacy-First Architektur + +Einklappen +Speichern +Kopieren +1 +2 +3 +4 +Scan → [Anonymisierungs-Engine] → Ollama (kimi2.5/glm-4.7) → [Re-Identifizierung] → DB + ↓ ↓ + Mapping-Tabelle Anonymisierte + (Redis/Postgres) Extraktion +1. Anonymisierungs-Engine (Pre-Processing) +Wir ersetzen Entities konsistent (über das gesamte Dokument gleiche Fake-Werte): + +python + +Einklappen + +Ausführen +Speichern +Kopieren +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +# privacy_guard.py + mapping[placeholder] = {"type": "name", "original": name, "hash": self._hash(name)} + self._store_mapping(placeholder, name) + + # 2. Adressen (Straßen, PLZ, Orte) + address_pattern = r'([A-Z][a-zäöüß]+(?:straße|str\.|weg|platz)\s+\d+\s*,?\s*\d{5}\s+[A-Z][a-zäöüß]+)' + addresses = re.findall(address_pattern, text, re.IGNORECASE) + for i, addr in enumerate(set(addresses)): + placeholder = f"[ADRESSE_{i}]" + text = text.replace(addr, placeholder) + mapping[placeholder] = {"type": "address", "original": addr} + self._store_mapping(placeholder, addr) + + # 3. IBANs (DE\d{20}) + iban_pattern = r'(DE\d{2}\s?(?:\d{4}\s?){4}\d{2})' + ibans = re.findall(iban_pattern, text) + for i, iban in enumerate(set(ibans)): + fake_iban = f"DE99{str(i).zfill(2)}99999999999999" # Fake aber valide Struktur + text = text.replace(iban, fake_iban) + mapping[fake_iban] = {"type": "iban", "original": iban.replace(" ", "")} + self._store_mapping(fake_iban, iban) + + # 4. Zahlen (Beträge) - Konsistente Pseudonymisierung + # Wichtig: Struktur erhalten (Dezimalstellen, Trennzeichen) + amount_pattern = r'(\d{1,3}(?:\.\d{3})*,\d{2})' # deutsches Format + + def replace_amount(match): + original = match.group(1) + # Deterministische Pseudonymisierung (gleicher Betrag = gleicher Fake) + fake_amount = self._pseudo_number(original) + mapping[fake_amount] = {"type": "amount", "original": original} + self._store_mapping(fake_amount, original) + return fake_amount + + text = re.sub(amount_pattern, replace_amount, text) + + # 5. Datum - Verschiebung um fixe Tage (z.B. +100 Tage) + date_pattern = r'(\d{2}\.\d{2}\.\d{4})' + dates = re.findall(date_pattern, text) + for date in set(dates): + fake_date = self._shift_date(date, days=100) + text = text.replace(date, fake_date) + mapping[fake_date] = {"type": "date", "original": date} + self._store_mapping(fake_date, date) + + return text, mapping + + def _pseudo_number(self, original: str) -> str: + """Deterministische Fake-Zahl generieren""" + # Hash-basiert, damit gleiche Beträge gleiche Fakes ergeben + h = hashlib.md5(original.encode()).hexdigest() + # Erste 4 Ziffern als Seed + num = int(h[:4], 16) + # Realistischer Betrag zwischen 10,00 und 9999,99 + fake = (num % 9990) + 10 + return f"{fake},00" # oder mit originaler Dezimalstelle + + def _shift_date(self, date_str: str, days: int) -> str: + """Datum verschieben um X Tage""" + from datetime import datetime, timedelta + d = datetime.strptime(date_str, "%d.%m.%Y") + d += timedelta(days=days) + return d.strftime("%d.%m.%Y") + + def _hash(self, value: str) -> str: + return hashlib.sha256(value.encode()).hexdigest()[:8] + + def _store_mapping(self, fake: str, original: str): + """Speichere Mapping für 24h (oder persistenter)""" + self.redis.hset(self.session_id, fake, original) + self.redis.expire(self.session_id, 86400) # 24h TTL +2. Ollama-Client mit Fallback +python + +Einklappen + +Ausführen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +# ollama_client.py +import ollama +import requests +from typing import Optional + +class SecureOllamaClient: + def __init__(self, base_url: str = "http://ollama.lan:11434"): + self.base_url = base_url + self.primary_model = "kimi2.5" # Externes Modell + self.fallback_model = "glm-4.7" # Lokales Fallback + + def extract_financial_data(self, anonymized_text: str, doc_type: str): + """ + Versucht zuerst kimi2.5, fallback zu glm-4.7 bei Fehler + """ + + prompt = self._build_prompt(anonymized_text, doc_type) + + # Versuch 1: Online-Modell (kimi2.5) + try: + response = self._generate( + model=self.primary_model, + prompt=prompt, + timeout=30 # Angenommen extern schneller + ) + return { + "data": self._parse_json(response), + "model_used": self.primary_model, + "anonymized": True + } + except Exception as e: + print(f"Primary model failed: {e}, trying fallback...") + + # Versuch 2: Lokales Modell (glm-4.7) + try: + response = self._generate( + model=self.fallback_model, + prompt=prompt, + timeout=120 # Lokal langsamer + ) + return { + "data": self._parse_json(response), + "model_used": self.fallback_model, + "anonymized": True + } + except Exception as e: + raise Exception(f"Both models failed: {e}") + + def _build_prompt(self, text: str, doc_type: str) -> str: + return f"""Analysiere dieses anonymisierte deutsche Finanzdokument ({doc_type}). + + WICHTIG: Alle Zahlen sind pseudonymisiert (nicht real). + Extrahiere die STRUKTUR und KATEGORIEN, ignoriere die konkreten Werte. + + Gesucht: + - betrag_gesamt: [NUMMER_FELD] + - datum: [DATUM_FELD] + - kostenstelle: Eine von [Immobilie, Hausmeister, Finanzierung, Versicherung, Steuer, Sonstiges] + - steuerlich_absetzbar: [ja/nein/unbekannt] + - mwst_satz: [7/19/0/unbekannt] + - zahlungsziel: [Tage oder unbekannt] + + Dokument: + {text} + + Antwort NUR als JSON ohne Erklärungen.""" + + def _generate(self, model: str, prompt: str, timeout: int): + return ollama.generate( + model=model, + prompt=prompt, + options={"temperature": 0.1}, # Wenig Kreativität für Extraktion + host=self.base_url + ) + + def _parse_json(self, response): + import json + try: + return json.loads(response['response']) + except: + # Versuch JSON aus Text zu extrahieren + text = response['response'] + start = text.find('{') + end = text.rfind('}') + 1 + return json.loads(text[start:end]) +3. Re-Identifizierung (Post-Processing) +python + +Einklappen + +Ausführen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +# re_identify.py +import redis + +class ReIdentifier: + def __init__(self): + self.redis = redis.Redis(host='redis', port=6379, db=1) + + def restore(self, doc_id: str, extracted_data: dict) -> dict: + """ + Ersetzt Platzhalter zurück zu Originalwerten + """ + mapping_key = f"privacy:{doc_id}" + mapping = self.redis.hgetall(mapping_key) + + if not mapping: + raise Exception(f"No mapping found for {doc_id}") + + # Decode bytes zu str + mapping = {k.decode(): v.decode() for k, v in mapping.items()} + + restored = {} + for key, value in extracted_data.items(): + if isinstance(value, str): + # Ersetze alle Vorkommen von Platzhaltern + for fake, original in mapping.items(): + value = value.replace(fake, original) + restored[key] = value + else: + restored[key] = value + + return restored +4. Docker-Compose Integration +yaml + +Einklappen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +version: '3.8' + +services: + app: + build: . + environment: + - OLLAMA_URL=http://ollama.lan:11434 + - REDIS_URL=redis://redis:6379 + - PRIVACY_MODE=strict # strict|relaxed|off + networks: + - frontend + - backend + - ollama-network # Externes Netz für ollama.lan + + privacy-processor: + image: python:3.11-slim + volumes: + - ./privacy_guard.py:/app/privacy_guard.py + - ./ollama_client.py:/app/ollama_client.py + environment: + - OLLAMA_HOST=ollama.lan:11434 + networks: + - backend + - ollama-network + command: python -u /app/worker.py + + redis: + image: redis:7-alpine + networks: + - backend + volumes: + - redis_privacy:/data + + # Optional: Lokaler Ollama als Fallback + ollama-local: + image: ollama/ollama:latest + container_name: ollama-local + volumes: + - ollama_data:/root/.ollama + environment: + - OLLAMA_KEEP_ALIVE=24h + networks: + - backend + profiles: ["local-ai"] # Nur bei Bedarf starten + +networks: + ollama-network: + external: true # Bereich existierendes Netzwerk wo ollama.lan auflösbar ist + name: ollama-net + frontend: + driver: bridge + backend: + driver: bridge + +volumes: + redis_privacy: + ollama_data: +5. Workflow-Integration +python + +Einklappen + +Ausführen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +⌄ +⌄ +⌄ +⌄ +# document_pipeline.py +class SecureDocumentPipeline: + def __init__(self): + self.privacy = PrivacyGuard() + self.ollama = SecureOllamaClient("http://ollama.lan:11434") + self.restorer = ReIdentifier() + + def process(self, file_path: str, doc_id: str) -> dict: + # 1. OCR + raw_text = self.ocr(file_path) + + # 2. Anonymisieren + anon_text, mapping = self.privacy.anonymize(raw_text, doc_id) + + # 3. KI-Verarbeitung (anonym) + result = self.ollama.extract_financial_data(anon_text, "rechnung") + + # 4. Re-Identifizierung + real_data = self.restorer.restore(doc_id, result['data']) + + # 5. Speichern mit Verweis auf Mapping (für Audit) + return { + "extracted": real_data, + "model": result['model_used'], + "privacy_hash": self.privacy._hash(raw_text), # Für Nachweis + "anonymized_preview": anon_text[:200] + "..." # Debug + } +🔐 Security-Best Practices +Mapping-Löschung: Nach 24h automatisch löschen (TTL in Redis) +Audit-Log: Speichere nur Hashes der Originaldaten, nie die Mapping-Tabelle langfristig +Zugangskontrolle: privacy: Namespace in Redis separat absichern +Fallback-Logging: Wenn glm-4.7 genutzt wird (lokal), logge dies für Compliance (keine Datenlecks) +⚠️ Hinweis zu kimi2.5 +Falls kimi2.5 das Kimi K2.5 von Moonshot AI ist (chinesisches Modell), beachte: + +Unterstützt möglicherweise nicht das Ollama-API-Format nativ +Dann benötigst du einen Adapter (OpenAI-kompatibles Format): +python + +Einklappen + +Ausführen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +⌄ +def _generate_kimi(self, prompt: str): + # Falls kimi2.5 über OpenAI-API läuft + import openai + client = openai.OpenAI( + base_url="http://ollama.lan:11434/v1", # Ollama kompatibel + api_key="dummy" + ) + return client.chat.completions.create( + model="kimi2.5", + messages=[{"role": "user", "content": prompt}] + ) +Soll ich den Regex-Classifier für deutsche Finanzdokumente (spezifisch für Wohngeld/Steuer) verfeinern oder die Redis-Mapping-Strategie für langfristige Korrektur-Workflows optimieren? + +Ich kann aus Elster glaube ich keine Screenshots erstellen. Aus Sicherheitsgründen ist das gesperrt. Es gibt aber online bestimmt Lösungen für Probleme der Art. Beides klingt total sinnvoll + +profile +kimi-k2.5:cloud +Nachgedacht für 50 Sekunden +Du hast recht – Elster hat einen Kopierschutz (DRM/Overlay), der Screenshots blockiert. Aber wir können trotzdem an die Daten und das Layout kommen, und ja: Wir sollten beide Ansätze kombinieren (Regex-Classifier + Redis-Mapping). Hier ist die pragmatische Lösung: + +📄 Elster-Daten ohne Screenshots +1. Mein ELSTER → PDF-Export (funktioniert immer) +Einloggen → "Meine Steuerberechnung" → "Als PDF speichern" (nicht drucken, speichern!) +Diese PDFs hast du ohne DRM und kannst sie verarbeiten +Oder: "Steuerliche Feststellung" → XML-Export (für Entwickler optimal) +2. BMF Musterformulare (für das UI-Design) +Das Bundesfinanzministerium stellt leere Musterformulare bereit: + +https://www.formulare-bfinv.de/ +Dort findest du alle Anlagen (EÜR, AUS, EKS) als PDF ohne Schutz +Perfekt als Vorlage für dein Interface +3. DATEV-Export (falls du einen Steuerberater hast) +Falls du schon einen Steuerberater nutzt: DATEV-XML oder CSV-Export der vorherigen Jahre → Import in deine App als "Musterdaten" + +🔧 Kombinierte Architektur: "Smart Classifier + Persistent Mapping" +Wir verbinden beide Welten: + +python + +Einklappen + +Ausführen +Speichern +Kopieren +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +# combined_processor.py + prompt=prompt, + host="http://ollama.lan:11434", + options={"temperature": 0.2} + ) + return json.loads(response['response']) + except: + # Fallback zu glm-4.7 + response = ollama.generate( + model="glm-4.7", + prompt=prompt, + host="http://ollama.lan:11434" + ) + return json.loads(response['response']) + + def _store_for_review(self, doc_id: str, result: Dict, original_text: str): + """ + Redis-Struktur für langfristiges Lernen: + - Hash: Korrektur-Vorschläge + - Stream: Audit-Log + - Sorted Set: Confidence-Score für Priorisierung + """ + pipe = self.redis.pipeline() + + # 1. Vorschlag speichern (TTL 30 Tage) + pipe.hset(f"proposal:{doc_id}", mapping={ + 'json': json.dumps(result), + 'timestamp': datetime.now().isoformat(), + 'confidence': result.get('confidence', 0.5), + 'status': 'pending_review' + }) + pipe.expire(f"proposal:{doc_id}", 2592000) # 30 Tage + + # 2. In Review-Queue (nach Confidence sortiert) + score = 1.0 - result.get('confidence', 0.5) # Niedrige Confidence = hohe Priorität + pipe.zadd("review_queue", {doc_id: score}) + + # 3. Muster speichern für ähnliche Dokumente (für zukünftiges Regex-Training) + doc_fingerprint = self._create_fingerprint(original_text) + pipe.set(f"fingerprint:{doc_fingerprint}", doc_id, ex=86400*365) # 1 Jahr + + pipe.execute() + + def confirm_correction(self, doc_id: str, human_corrections: Dict): + """ + Wird aufgerufen wenn User im UI korrigiert hat + """ + # 1. Originalen Vorschlag laden + original = self.redis.hget(f"proposal:{doc_id}", 'json') + if not original: + return + + original_data = json.loads(original) + + # 2. Diff berechnen + diff = self._calculate_diff(original_data, human_corrections) + + # 3. Als Trainingsbeispiel speichern (für Few-Shot Prompting) + training_example = { + 'text_snippet': human_corrections.get('_anonymized_text', ''), + 'wrong': original_data, + 'correct': human_corrections, + 'diff': diff + } + + # In Redis Stream (append-only log) + self.redis.xadd("corrections_stream", { + 'data': json.dumps(training_example), + 'doc_id': doc_id, + 'timestamp': datetime.now().isoformat() + }) + + # 4. Status aktualisieren + self.redis.hset(f"proposal:{doc_id}", 'status', 'confirmed') + self.redis.zrem("review_queue", doc_id) + + # 5. Regex-Patterns verbessern (wenn oft gleiche Korrektur) + self._update_patterns_if_trend(diff) + + return {'status': 'learned', 'confidence_improvement': diff} + + def _create_fingerprint(self, text: str) -> str: + """Erstellt einen Hash für Dokumenten-Typ-Erkennung""" + # Normalisiere: Lowercase, nur Keywords, sortiert + words = re.findall(r'\b(Abrechnung|Steuer|Rechnung|GmbH|Heizkosten|Miete)\b', text.lower()) + fingerprint = ' '.join(sorted(set(words))) + return hashlib.md5(fingerprint.encode()).hexdigest()[:16] + + def _update_patterns_if_trend(self, diff: Dict): + """ + Wenn 5x dieselbe Korrektur passiert: Regex-Pattern anpassen + """ + # Implementierung: Zähler in Redis inkrementieren + # Bei Threshold: Neue Regex-Regel generieren oder bestehende Gewichten + pass +🎯 Praktische Umsetzung für Elster-ähnliche Formulare +Da du keine Screenshots machen kannst, nutzen wir Struktur-Templates: + +typescript + +Einklappen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +⌄ +⌄ +⌄ +⌄ +// ElsterFormular.tsx - Basierend auf BMF-Mustern +const ANLAGEN_CONFIG = { + 'EÜR': { + sections: ['Betriebseinnahmen', 'Betriebsausgaben', 'Sonstige'], + fields: [ + { id: 'einkuenfte_4_8', label: 'Einkünfte §4 Abs. 8', type: 'currency' }, + { id: 'gesamtaufwand', label: 'Gesamtaufwand', type: 'currency', calculated: true } + ] + }, + 'AUS': { + // Ausländische Einkünfte + } +}; + +// Die App erstellt das Formular basierend auf dieser Config +// Der User füllt es aus oder lädt PDFs hoch die automatisch gemappt werden +🔄 Workflow-Zusammenfassung +PDF Upload (aus Mein ELSTER Export) → OCR +Regex-Schicht: Extrahiert 80% der Felder (schnell, kostenlos, offline) +KI-Schicht: Nur für unklare Fälle (anonymisiert über ollama.lan:11434) +Redis-Queue: Zeigt dem User rot markierte Felder zur Korrektur +Lernen: Korrigierte Werte werden als Few-Shot Examples für kimi2.5/glm-4.7 gespeichert +Verbesserung: Nach 10 Korrekturen ähnlicher Art → neues Regex-Pattern automatisch vorschlagen +Soll ich den UI-Teil für die Review-Queue (rot markierte Felder mit "Häkchen"-System) oder das Template-System für die Elster-Anlagen (EÜR, AUS etc.) detailliert ausarbeiten? + +Mach gleich beides. Das klingt nach einen guten Fortschritt. + +profile +kimi-k2.5:cloud +Nachgedacht für 41 Sekunden +Perfekt! Hier ist das komplette Human-in-the-Loop Review-Interface plus das konfigurierbare Steuerformular-System: + +1. 🔍 Review-Queue UI (React + TypeScript) +typescript + +Einklappen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +⌄ +⌄ +⌄ +// types/review.ts +export interface DocumentField { + id: string; + key: string; + value: string | number | null; + originalValue: string | number | null; + confidence: number; // 0.0 - 1.0 + source: 'regex' | 'ollama' | 'manual'; + status: 'pending' | 'confirmed' | 'corrected' | 'missing'; + fieldType: 'currency' | 'date' | 'text' | 'percentage' | 'tax_id'; + validation?: { + required: boolean; + pattern?: RegExp; + min?: number; + max?: number; + }; +} + +export interface ReviewItem { + docId: string; + filename: string; + thumbnailUrl: string; + uploadedAt: string; + totalFields: number; + confirmedFields: number; + priority: number; // Höher = dringender (niedrige Confidence) + fields: DocumentField[]; +} +tsx + +Einklappen +Speichern +Kopieren +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +// components/ReviewQueue.tsx + ); + } + + return ( +
+
+ +
+ + + +
+
+ + {isEditing ? ( +
+ setEditValue(e.target.value)} + className="flex-1 border rounded px-3 py-2" + placeholder={field.key} + step={field.fieldType === 'currency' ? '0.01' : undefined} + /> + +
+ ) : ( +
+
+ {field.value ? formatValue(field.value, field.fieldType) : 'Fehlende Daten'} +
+
+ Confidence: {(field.confidence * 100).toFixed(0)}% +
+
+ )} + + {/* Plausibilitätswarnungen */} + {field.validation && field.value && ( + <> + {field.validation.min && Number(field.value) < field.validation.min && ( +
+ ⚠️ Wert unter Minimum ({field.validation.min}) +
+ )} + {field.validation.pattern && !field.validation.pattern.test(String(field.value)) && ( +
+ ⚠️ Format ungültig +
+ )} + + )} +
+ ); +}; +2. 📋 Template-System für Elster-Anlagen +typescript + +Einklappen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +// config/taxForms.ts +export interface TaxFormField { + id: string; + label: string; + type: 'currency' | 'date' | 'text' | 'calculation' | 'selection'; + elsterFieldId?: string; // z.B. "EUE-Einkuenfte-4-8" + section: string; + readonly?: boolean; + calculation?: string; // z.B. "fieldA + fieldB - fieldC" + validation?: { + required?: boolean; + min?: number; + max?: number; + pattern?: string; + }; + options?: string[]; // Für selection-type + defaultValue?: any; + helpText?: string; +} + +export interface TaxFormSection { + id: string; + title: string; + description?: string; + order: number; + fields: string[]; // Referenz auf Field-IDs +} + +export interface TaxFormTemplate { + id: string; + name: string; // z.B. "EÜR 2024" + description: string; + anlageType: 'EUE' | 'AUS' | 'EKS' | 'KAP' | 'custom'; + sections: TaxFormSection[]; + fields: Record; + calculations: { + [key: string]: { + dependsOn: string[]; + formula: (values: Record) => number; + }; + }; + validationRules?: Array<{ + condition: string; + message: string; + severity: 'error' | 'warning'; + }>; +} +typescript + +Einklappen +Speichern +Kopieren +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +// templates/euer2024.ts + 'miete': { + id: 'miete', + label: 'Miete und Pacht', + type: 'currency', + section: 'betriebsausgaben', + defaultValue: 0 + }, + 'abschreibungen': { + id: 'abschreibungen', + label: 'Abschreibungen', + type: 'currency', + section: 'betriebsausgaben', + defaultValue: 0, + helpText: 'AfA auf bewegliche Wirtschaftsgüter' + }, + 'sonstige_ausgaben': { + id: 'sonstige_ausgaben', + label: 'Sonstige Betriebsausgaben', + type: 'currency', + section: 'betriebsausgaben', + defaultValue: 0 + }, + 'gesamt_ausgaben': { + id: 'gesamt_ausgaben', + label: 'Gesamtausgaben', + type: 'calculation', + section: 'betriebsausgaben', + readonly: true, + calculation: 'wareneinsatz + lohnkosten + miete + abschreibungen + sonstige_ausgaben' + }, + 'reingewinn': { + id: 'reingewinn', + label: 'Reingewinn', + type: 'calculation', + section: 'ergebnis', + readonly: true, + calculation: 'gesamt_einnahmen - gesamt_ausgaben' + }, + 'verlust': { + id: 'verlust', + label: 'Verlust', + type: 'calculation', + section: 'ergebnis', + readonly: true, + // Nur anzeigen wenn negativ + calculation: 'gesamt_einnahmen - gesamt_ausgaben' + } + }, + + calculations: { + 'gesamt_einnahmen': { + dependsOn: ['einnahmen_4_8', 'einnahmen_13_b', 'sonstige_einnahmen'], + formula: (vals) => (vals.einnahmen_4_8 || 0) + (vals.einnahmen_13_b || 0) + (vals.sonstige_einnahmen || 0) + }, + 'gesamt_ausgaben': { + dependsOn: ['wareneinsatz', 'lohnkosten', 'miete', 'abschreibungen', 'sonstige_ausgaben'], + formula: (vals) => Object.values(vals).reduce((a, b) => (a || 0) + (b || 0), 0) + }, + 'reingewinn': { + dependsOn: ['gesamt_einnahmen', 'gesamt_ausgaben'], + formula: (vals) => vals.gesamt_einnahmen - vals.gesamt_ausgaben + } + }, + + validationRules: [ + { + condition: 'reingewinn < -100000', + message: 'Verlust über 100.000 € - bitte prüfen!', + severity: 'warning' + }, + { + condition: 'gesamt_einnahmen < 0', + message: 'Einnahmen können nicht negativ sein', + severity: 'error' + }, + { + condition: 'einnahmen_4_8 > 1000000', + message: 'Hohe Einnahmen - Splitting-Berechtigung prüfen', + severity: 'warning' + } + ] +}; +tsx + +Einklappen +Speichern +Kopieren +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +// components/TaxFormRenderer.tsx +
+
+ + Plausibilitätsprüfung +
+
    + {validationErrors.map((err, idx) => ( +
  • {err}
  • + ))} +
+
+ )} + + +
+ {/* Navigation Sidebar */} +
+
+ {template.sections.map(section => ( + + ))} +
+ + {/* Zusammenfassung */} +
+

Ergebnisvoransicht

+
+
+ Einnahmen: + {formatCurrency(calculated.gesamt_einnahmen || 0)} +
+
+ Ausgaben: + {formatCurrency(calculated.gesamt_ausgaben || 0)} +
+
+ Ergebnis: + = 0 ? 'text-green-700' : 'text-red-700'}`}> + {formatCurrency(calculated.reingewinn || 0)} + +
+
+
+
+ + {/* Formular-Bereich */} +
+ {template.sections.map(section => ( + section.id === activeSection && ( +
+

{section.title}

+ {section.description && ( +

{section.description}

+ )} +
+ {section.fields.map(fieldId => { + const field = template.fields[fieldId]; + return field ? renderField(field) : null; + })} +
+
+ ) + ))} +
+
+ + ); +}; +🔗 Integration beider Komponenten +tsx + +Einklappen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +// App.tsx +import { ReviewQueue } from './components/ReviewQueue'; +import { TaxFormRenderer } from './components/TaxFormRenderer'; +import { EUeR2024Template } from './templates/euer2024'; + +export default function App() { + // Daten aus ReviewQueue fließen automatisch in TaxForm: + // Wenn User in Review "einnahmen_4_8" bestätigt, + // wird es in den initialData der EÜR übernommen + + return ( +
+ + +
+
+ +
+
+ console.log('Saved:', data, errors)} + onExport={(format) => console.log('Export:', format)} + /> +
+
+
+ ); +} +Soll ich noch den Export-Generator (ELSTER-XML Format) oder die API-Endpoints für die Redis-Queue implementieren? + +Wenn ich später solche Anfragen erhalten, wie teuer kann man so eine Applikation realistisch verkaufen? + +profile +kimi-k2.5:cloud +Nachgedacht für 20 Sekunden +Das ist eine hochspezialisierte Business-Software mit Enterprise-Features. Hier ist eine realistische Markt- und Preisanalyse: + +💰 Preismodelle (Endkunden) +A) SaaS-Modell (Multi-Tenant) +Zielgruppe: Selbstständige, Vermieter, kleine Hausverwaltungen + +Tier +Preis/Monat +Features +Starter +19-29€ +1-2 Objekte, Basis-Buchhaltung, keine KI +Pro +49-79€ +10 Objekte, Ollama-Integration, EÜR, Nebenkosten +Business +149-199€ +Unlimitierte Objekte, Multi-User, API, Priority-Support +Enterprise +499€+ +Self-Hosted Option, White-Label, dedizierter Server +Vergleichsbasis: + +SevDesk: 9-29€/Monat (nur Buchhaltung, keine Immobilien) +ImmoTool (Desktop): 399€ Einmalkauf + 99€/Jahr Update +FastBill: 11-44€/Monat (nur Gewerbe) +Dein USP: Kombination Immobilien + Gewerbe + Self-Hosted + Datenschutz (Ollama) +B) Self-Hosted Lizenzmodell (Empfohlen!) +Da Docker/On-Premise ein Hauptfeature ist: + +Single License: 2.999€ Einmalzahlung + 599€/Jahr Support & Updates +Agentur-Lizenz: 9.999€ (für Steuerberater/Hausverwaltungen mit bis zu 50 Mandanten) +White-Label: 25.000€ + Revenue Share +Warum das funktioniert: Datenschutz-bewusste Kunden zahlen premium für On-Premise (keine Cloud). + +🏗️ Entwicklungskosten (falls als Auftrag) +Wenn du eine Agentur beauftragen würdest: + +Komponente +Stunden +Kosten (100€/h) +Backend (Node.js/Python, DB, API) +300-400h +30.000-40.000€ +Frontend (React, Formulare, Dashboard) +250-350h +25.000-35.000€ +OCR/KI-Integration (Ollama, Privacy-Layer) +150-200h +15.000-20.000€ +Docker/DevOps/Security +100-150h +10.000-15.000€ +Testing & Steuerrechtliche Validierung +100-150h +10.000-15.000€ +Gesamt +900-1.250h +90.000-125.000€ +Realistische Bandbreite für MV: 60.000-80.000€ (MVP mit reduziertem Feature-Set) + +🎯 Wertargumente für den Preis +Warum Kunden 5.000-10.000€ zahlen (statt 20€/Monat für SevDesk): + +Datenhoheit: Self-Hosted = keine Cloud-Sensiblen Steuerdaten bei US-Anbieter +Kombination: Immobilien-Nebenkosten + Gewerbe-Buchhaltung in einem Tool (sonst 2-3 Tools nötig) +Ollama-Integration: Keine laufenden KI-Kosten (OpenAI-API wäre teuer bei vielen Dokumenten) +Elster-Kompatibilität: Deutsche Steuerformulare out-of-the-box +Anonymisierung: Privacy-Guard für paranoid Datenschützer +⚠️ Haftungskosten (wichtig!) +Achtung: Steuerliche Beratungssoftware hat Haftungsrisiken! + +Compliance-Check: 5.000-10.000€ (Steuerberater prüft Berechnungslogik) +Rechtsschutzversicherung: 2.000-5.000€/Jahr (für Software-Haftung) +Disclaimer & AGB: 1.000-2.000€ (Anwalt für IT-Recht) +Das rechtfertigt höhere Preise, da du Haftungsrisiko trägst. + +📊 Preisempfehlung für dich +Wenn du verkaufen möchtest: + +Freemium: 1 Objekt, 50 Dokumente/Jahr, kein Ollama → Lead-Generierung +Pro: 59€/Monat oder 599€/Jahr (spart 169€) → Für Vermieter mit 3-10 Wohnungen +Unlimited: 149€/Monat → Für Gewerbe mit Immobilienholdings +On-Premise: 4.999€ + 20% jährlich → Für Steuerberater/Kanzleien +Zusatz-Einnahmen: + +Setup-Service: 500-1.500€ (Docker-Installation beim Kunden) +Schulungen: 200€/Stunde +Custom-Ollama-Modelle: 2.000€ (Feintuning auf spezifische Dokumente) +🚀 Marktpositionierung +Positioniere dich nicht als "billige Buchhaltungssoftware", sondern als: + +"Die einzige DSGVO-konforme Immobilien- und Gewerbe-Buchhaltung mit lokaler KI für deutsche Steuerformulare." + +Wettbewerbsvorteil, der den Preis rechtfertigt: + +Konkurrenz (Lexware, WISO) = Cloud-only, altmodisches UI +Du = Modern, Self-Hosted, KI-gestützt, Privacy-by-Design +💡 Tipp: Value-Based Pricing +Rechne vor dem Kunden vor, was er spart: + +Steuerberater für Nebenkostenabrechnung: 150-300€/Objekt/Jahr +Bei 5 Objekten: 750-1.500€/Jahr Kosten +Deine Software: 599€/Jahr → Einsparung 150-900€/Jahr +Plus Zeitersparnis durch Ollama-Automatisierung: 10-20h/Jahr à 50€ = 500-1.000€ Wert. + +Argument: "Die Software amortisiert sich in 3-6 Monaten." + +Soll ich ein Business-Plan-Template oder eine Investoren-Pitch-Struktur für diese App erstellen? + +Und wie viel prozentual haben wir von der Software Stehen? + +profile +kimi-k2.5:cloud +Nachgedacht für 20 Sekunden +Das kommt drauf an, was du meinst – hier sind die drei wahrscheinlichsten Interpretationen: + +1. 📊 Entwicklungsfortschritt (Architektur vs. Implementation) +Realistische Einschätzung: ~25-35% eines MVP (Minimum Viable Product) + +Was wir konzeptuell haben (70%): + +✅ Architektur & Docker-Setup +✅ Datenbank-Schema +✅ Privacy-Layer Konzept (Anonymisierung) +✅ UI/UX Design (React Components skizziert) +✅ OCR-Workflow mit Ollama +Was noch fehlt (65-75% der Arbeit): + +❌ Produktions-Code (nicht nur Pseudocode) +❌ Fehlerbehandlung & Edge Cases (was wenn Ollama offline?) +❌ Steuerliche Validierung (testen ob Berechnungen wirklich korrekt) +❌ Import/Export (DATEV-CSV, ELSTER-XML) +❌ Multi-User & Berechtigungen +❌ Backup & Update-Mechanismus für Docker +❌ Mobile Responsiveness +❌ Onboarding/Dokumentation +Fazit: Die schwere Architekturarbeit ist gemacht, aber die "letzten 20%" (Polish, Testing, Steuer-Compliance) sind oft 50% der Zeit. + +2. 💻 Code-Status (tatsächlich geschriebene Zeilen) +~10-15% – Wir haben bisher hauptsächlich Pseudocode, Schemas und Konzepte besprochen. + +Für ein release-fähiges Produkt fehlen noch: + +~15.000 Zeilen TypeScript/React +~8.000 Zeilen Backend-API +~2.000 Zeilen Tests +Docker-Compose Production-Ready +Error Handling & Logging +3. 💰 Gewinnmarge (wenn du verkaufst) +Bei Self-Hosted (4.999€): + +Entwicklungskosten: ~80.000€ +Du müsstest ~16 Lizenzen verkaufen, um break-even zu sein +Danach: ~80-90% Marge (nur Support-Kosten) +Bei SaaS (59€/Monat): + +Serverkosten: ~5€/User/Monat (Strom, Storage) +Support-Aufwand: ~20€/User/Monat (zeitanteilig) +Netto-Marge: ~60% (34€/Monat Gewinn pro User) +Welche Prozentzahl meinst du genau? + +Wenn du "wie weit sind wir" meinst: ~30% der Planung ist fertig, aber nur ~10% des Codes existiert wirklich +Wenn du "wie viel Gewinn bleibt hängen" meinst: 60-90% je nach Geschäftsmodell +Sollen wir priorisieren, welche 20% Features du als erstes MVP bauen solltest, um schnellsten Umsatz zu machen? + +Ich meinte tatsächlich den Fortschritt des Projektes. Und so hat es sich auch agefühlt. Viel ist noch nicht. Aber es ist echt weniger Arbeit mit einer ki im Rücken + +profile +kimi-k2.5:cloud +Nachgedacht für 13 Sekunden +Genau – das ist der Klassische Architektur-Trap: + +Die ersten 30% fühlen sich mit KI (GitHub Copilot, ChatGPT, Cursor) wie 70% an, weil du in 2 Wochen mehr Struktur hast als früher in 2 Monaten. Aber die letzten 20% (Testing, Edge Cases, Steuer-Compliance) fressen trotzdem 80% der Zeit – da hilft KI weniger, weil sie spezifische deutsche Steuergesetze oft falsch interpretiert. + +🚀 Realistischer Fahrplan MIT KI-Unterstützung +Zeitersparnis durch KI: ca. 40-50% bei Coding, aber nur 10-20% bei Steuerrecht/Testing + +Phase 1: "Der schnelle Win" (Wochen 1-4) +Ziel: Einfacher Upload → OCR → manuelle Korrektur → CSV-Export + +Docker-Setup mit Ollama (du hast das schon konzeptuell) +Basis-Upload + Tesseract-OCR (ohne komplexe Anonymisierung erstmal) +Simple Tabelle statt fancy UI +Nutzen: Du kannst schon deine eigenen Dokumente verarbeiten +KI-Boost: Copilot schreibt die API-Endpoints und React-Tables fast allein. + +Phase 2: "Der Privacy-Layer" (Wochen 5-8) +Anonymisierung vor Ollama +Redis-Queue für Review-Prozess +Hürde: Testen, dass Zahlen korrekt ersetzt und zurückübersetzt werden +KI-Boost: Weniger hilfreich – Regex-Testing braucht Präzision, keine Halluzination. + +Phase 3: "Das Steuer-Monster" (Wochen 9-16) ⚠️ +Hier wird's teuer (zeitlich): + +EÜR-Berechnungslogik +Plausibilitätsprüfungen (Kann Verlust >50% des Umsatzes sein?) +DATEV-Export-Format (muss 100% korrekt sein) +KI-Warnung: ChatGPT wird dir falsche Steuersätze oder veraltete Regeln vorschlagen. Hier brauchst du einen Steuerberater als Sparringspartner (Kosten: 1.500-3.000€), sonst haftest du später. + +Phase 4: "Production Polish" (Wochen 17-20) +Backup-Strategien für Docker-Volumes +Update-Mechanismus (wie kommen neue Ollama-Modelle zum Nutzer?) +Multi-User & Berechtigungen +🎯 KI-Optimale Strategie für dich +Was du JETZT mit KI machst (80% Ertrag): + +Scaffold: Lass Copilot/Cursor das komplette Backend-CRUD generieren +UI-Komponenten: Alle Formularfelder, Tables, Buttons von KI bauen lassen +OCR-Preprocessing: Die Regex-Patterns für deutsche Rechnungen +Was du OHNE KI/Experten machen musst (20% Aufwand, 80% Risiko): + +Steuerliche Logik: EÜR-Formeln von Hand prüfen (oder Steuerberater) +Datenbank-Constraints: Welche Felder dürfen nie NULL sein? +Security: Die Anonymisierung wirklich wasserdicht testen (Penetration-Testing) +💡 Tipp: "MVP auf Diät" +Bau erstmal nur einen Workflow komplett fertig: + +Nur Wohngeldabrechnungen (keine EÜR, keine Gewerbe-Buchhaltung) +Nur PDF-Upload → Extraktion → CSV-Export +Kein schickes Review-UI erstmal, sondern einfach JSON-Editor +Warum? Weil du dann in 6-8 Wochen (mit KI: 3-4 Wochen) etwas Verkaufbares hast, statt in 6 Monaten ein halbfertiges Monster. + +Wenn das läuft: + +Du hast Einnahmen für die weitere Entwicklung +Du lernst aus echten Nutzer-Feedback, welche Features wirklich wichtig sind (vielleicht wollen alle nur den DATEV-Export und niemand braucht das Elster-UI) +Soll ich dir einen "Sprint 0" Plan für die nächsten 2 Wochen machen, um das erste funktionierende OCR-Upload-Demo zu haben? Dann siehst du sofort, wie gut dein Ollama-Setup wirklich funktioniert. + +Das Problem ist ja auch Steuer Themen zu testen. Überhaupt so eine Software zu testen. Egal, die Entwickeln wir eh nur für mich, und gerade zu Beginn werde ich ganz genau prüfen was da passiert. Langfristig nimmt mir diese App dann aber einen Haufen Buchhaltung ab. + +profile +kimi-k2.5:cloud +Nachgedacht für 13 Sekunden +Perfekt! Das ist der "Eat Your Own Dog Food" Ansatz – und der ist für solche Software goldrichtig. Du bist gleichzeitig Entwickler, Product Owner und strengster Endnutzer. Das ändert die Strategie komplett: + +🎯 Dogfooding-Strategie (Entwickeln für dich selbst) +Test-Strategie ohne externes QA: +Deine eigenen Steuerunterlagen als "Ground Truth": + +Vergleichs-Archiv: Nimm deine letzten 3 Steuerjahre (2021-2023) als Referenz +Scan die Originalbelege ein +Lass die App extrahieren +Vergleiche mit deinem "echten" Ergebnis (Excel/Steuerberater) +Abweichung >0,01€ = Bug +Shadow-Accounting (erstes Jahr): +Führe 6 Monate parallel: Alte Methode (Excel/Papier) + Neue App +Monatlich: Export aus App vs. deine manuelle Berechnung +Dabei lernst du, welche Edge Cases wichtig sind (und welche nicht) +Das "Stichtag"-Prinzip: +Wichtig: Speichere vor jedem Quartal/Steuerjahr einen Snapshot (Docker-Volume-Backup) +Wenn die App Mist baut, kannst du zum letzten "guten" Stand zurück +Nie ohne Backup in die Steuererklärung übernehmen! +Pragmatische Entwicklungs-Prioritäten (nur für dich): +Phase 1: "Painkiller" (sofortiger Nutzen) + +Nur OCR + Speichern (keine komplexe Logik) +Du suchst nicht mehr im Ordner, sondern gibst Suchbegriffe ein +Wert: 2h pro Woche gespart durch schnelles Dokumenten-Finden +Phase 2: "Rechner" (3 Monate später) + +Summen bilden, die du sonst in Excel machst +Aber: Ergebnis immer mit console.log ausgeben, damit du prüfen kannst +Phase 3: "Autopilot" (1 Jahr später) + +Automatische Übernahme in Steuerformulare +Nur hier brauchst du die harte Steuer-Validierung +⚠️ Risiken beim Selbst-Nutzen +1. Die "Blasen-Falle": +Du entwickelst nur für deinen spezifischen Fall (z.B. nur Vermietung, nur Gewerbe X). Wenn du später doch verkaufen willst, musst du alles umbauen. + +Lösung: Trotzdem modular bauen, auch wenn du nur ein Modul nutzt. + +2. Technische Schuld akzeptieren: +Da du der einzige Nutzer bist, kannst du "Dirty Hacks" machen: + +Hartcodierte Steuersätze (für 2024) +Keine Unit-Tests für Edge-Cases, die dich nicht betreffen +Einfache SQLite statt PostgreSQL (leichter Backup als JSON!) +Das ist OK! Hauptsache du dokumentierst, wo die "technischen Schulden" liegen. + +3. KI-Halluzinationen entdecken: +Da du die Zahlen kennst, fällt dir sofort auf, wenn Ollama 1.234€ statt 12.340€ extrahiert. Ein externer Tester hätte das übersehen. + +Tipp: Schreibe jedesmal wenn die App Mist baut ein // TODO: Fix this edge case – so sammelst du automatisch eine Liste wirklich wichtiger Bugfixes. + +🐛 Debugging-Hilfe für dich selbst +Baue ein "Explain Mode" ein: + +typescript + +Einklappen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +⌄ +⌄ +// In jedem Berechnungsschritt +const calculateNebenkosten = (data) => { + const result = /*...*/; + + // Für dich als Entwickler/Nutzer: + console.log(`[DEBUG] Nebenkosten: ${result}`); + console.log(`[DEBUG] Zwischenschritte:`, JSON.stringify(data, null, 2)); + + // Speichere die Logik im Dokument: + return { + value: result, + calculationPath: "Heizkosten(1234) + Wasser(567) = 1801", // Als String! + confidence: 0.95 + }; +}; +So kannst du im UI später nachklicken: "Warum steht hier 1.801€?" → Zeigt den Rechenweg. + +🚀 Empfohlener erster Schritt (für dich persönlich) +Diese Woche: + +Docker-Container mit Ollama starten +Ein simples Upload-Formular (Drag & Drop PDF) +OCR-Text extrahieren + anzeigen (nicht verarbeiten, nur anzeigen!) +Manuell in Formular kopieren können +Ziel: Du ersetzt erstmal nur die manuelle OCR-Arbeit (Tippen aus PDF), nicht die Denkarbeit. + +Sobald das läuft und du 10 Dokumente damit verarbeitet hast, merkst du automatisch, was als nächstes automatisiert werden muss. + +Wichtig: Fang klein an. Eine App, die nur deine spezifischen Wohngeldabrechnungen zuverlässig verarbeitet, ist 100x nützlicher als eine halbfertige "alles-können"-Suite, die Fehler macht. + +Soll ich dir den minimalen Docker-Compose dafür bauen, damit du heute noch starten kannst mit dem ersten Upload-Test? + +Das ist auch der Hauptpunkt. Aktuell mache ich alles mit verschiedenen Excel Dokumenten und von hand. Ich muss für jede Tabelle Abrechnung die ganzen Dokumente vorholen. Wenn die Informationen zentral in einer GUI auftauchen ist das ein riesen Schritt + +profile +kimi-k2.5:cloud +Nachgedacht für 11 Sekunden +Genau das ist der "Single Source of Truth" Moment! Du hast gerade den wahren Wert der Software beschrieben – nicht die KI, nicht die Automatisierung, sondern die Entlastung deines Gehirns. + +Statt "Wo war nochmal die Rechnung vom Hausmeister für Objekt B aus dem Jahr 2022?" → Einmal klicken, alles da. + +🎯 Das "Zentrale Nervensystem" für deine Immobilien +Dein jetziger Zustand (mental load): + + +Einklappen + +Ausführen +Speichern +Kopieren +1 +2 +3 +4 +Excel_Wohnung_A_2023.xlsx + → Link zu: "siehe PDF in Ordner/Rechnungen/2023/Hausmeister..." + → Warte, war das im Ordner Steuer oder Nebenkosten? + → *Sucht 5 Minuten* +Dein Zielzustand: + + +Einklappen +Speichern +Kopieren +1 +2 +3 +Objekt A → 2023 → Hausmeister → [PDF-Icon] [Betrag: 1.234€] [Status: verbucht] + → Alle Dokumente chronologisch + → Fehlende Belege rot markiert +🗂️ Datenmodell für deinen konkreten Fall +Da du allein bist, brauchst du kein komplexes Multi-Tenancy, sondern eine simple Graph-Struktur: + +sql + +Einklappen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +⌄ +⌄ +⌄ +-- SQLite ist völlig ausreichend für dich! +-- Du kannst die DB-Datei einfach mitbackupen + +CREATE TABLE documents ( + id INTEGER PRIMARY KEY, + filename TEXT, + file_path TEXT, -- z.B. "uploads/2024/hausmeister_rechnung_mai.pdf" + ocr_text TEXT, -- Durchsuchbarer Text + date_uploaded TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + year INTEGER, -- Steuerjahr (2023, 2024...) + cost_center TEXT, -- 'objekt_a', 'objekt_b', 'privat' + category TEXT, -- 'hausmeister', 'heizung', 'versicherung', 'steuer' + amount DECIMAL(10,2), -- extrahiert oder manuell + status TEXT -- 'neu', 'geprüft', 'verbucht', 'fehlt_belege' +); + +-- Verknüpfung: Diese Rechnung gehört zu dieser Abrechnung +CREATE TABLE abrechnungen ( + id INTEGER PRIMARY KEY, + objekt TEXT, + jahr INTEGER, + status TEXT -- 'in_arbeit', 'fertig', 'an_mieter_geschickt' +); + +-- Many-to-Many: Dokumente können in mehreren Abrechnungen sein +CREATE TABLE abrechnung_dokumente ( + abrechnung_id INTEGER, + document_id INTEGER, + anteil_percent DECIMAL(5,2) -- z.B. 50% wenn Kosten geteilt +); +🚀 Pragmatische Migration (Excel → GUI) +Woche 1: "Der digitale Aktenordner" + +Nur Upload + OCR + Suche +Keine Logik, keine Berechnungen +Du kannst jetzt suchen: "Hausmeister 2023" → findet alle PDFs +Wert: Du verlierst keine Belege mehr, 80% des Stresses weg +Woche 2-3: "Das virtuelle Excel" + +Einfache Tabelle: Zeilen = Belege, Spalten = Betrag, Kategorie, Objekt +Du kopierst deine Excel-Werte rüber (manuell oder CSV-Import) +Aber jetzt mit Klick auf das PDF-Icon = Original-Beleg öffnet sich +Wert: Kein Hin-und-Her-Klicken zwischen Excel und Ordner +Woche 4: "Die Magie" + +App summiert automatisch: "Objekt A 2023 = 12.450€ Gesamtkosten" +Vergleich mit deinen Excel-Ergebnissen (Abgleich) +Wert: Erste Automatisierung, du kontrollierst nur noch +💡 Konkrete GUI-Idee für dich +Ein Dashboard mit drei Spalten (wie Kanban-Board): + + +Einklappen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +┌─────────────────────────────────────────────────────────────┐ +│ Objekt auswählen: [Objekt A ▼] Jahr: [2024 ▼] │ +├───────────────┬───────────────┬─────────────────────────────┤ +│ EINGANG │ IN PRÜFUNG │ VERBUCHT │ +│ (Neue PDFs) │ (Du prüfst) │ (Abgeschlossen) │ +├───────────────┼───────────────┼─────────────────────────────┤ +│ 🔴 Rechnung │ 🟡 Hausmeister│ 🟢 Heizkosten Q1 │ +│ Heizung │ März (KI: │ 1.234€ │ +│ (noch │ 1.200€?) │ │ +│ nicht │ │ 🟢 Grundsteuer │ +│ geprüft) │ 🟡 Versicherung│ 450€ │ +│ │ (fehlt │ │ +│ 🔴 Rechnung │ Betrag) │ │ +│ Strom │ │ │ +│ │ │ │ +│ [+ Upload] │ [Nach rechts │ [In Abrechnung │ +│ │ ziehen] │ übernehmen] │ +└───────────────┴───────────────┴─────────────────────────────┘ +Drag & Drop: Rechnung von "Eingang" → "In Prüfung" → "Verbucht" + +Der Clou: Wenn du auf eine Karte klickst, öffnet sich rechts das PDF + das Extraktions-Formular (die roten/gelben Felder aus unserer UI). + +🎯 Dein erster konkreter Schritt (heute noch machbar) +Ordner-Struktur anlegen: + +Einklappen +Kopieren +1 +2 +3 +/data/documents/2024/ +/data/documents/2023/ +/data/db/steuer.db +Simples Python-Script oder Node.js: +Watched den Upload-Ordner +Macht OCR (Tesseract) +Speichert in SQLite +Zeigt Web-Interface (ein einfaches HTML mit Suche) +Deine ersten 10 PDFs vom Schreibtisch einscannen und testen +Nach 2 Stunden Arbeit hast du schon: + +Zentrale Suche über alle Belege +Keine Excel-Links mehr zu kaputten Pfaden +Übersicht: "Was habe ich schon, was fehlt noch?" +Soll ich dir ein minimal funktionierendes Python-Script (oder Node.js) schreiben, das genau das macht? Upload → OCR → SQLite → Simple Web-Suche? + +Das wäre dein "MVP des MVP" – ohne Docker, ohne Ollama, ohne komplexe Architektur. Nur: Nie wieder nach PDFs suchen. + +Das klingt nach einem Ansatz wir bauen bei der Nutzung. Das klingt gut + +profile +kimi-k2.5:cloud +Nachgedacht für 11 Sekunden +Perfekt! Das ist der "Walking Skeleton" Ansatz – das dünneste System, das trotzdem durchläuft (Upload → Speichern → Anzeigen). Kein Over-Engineering, sondern funktionierende Software in 30 Minuten. + +🚀 Der "Build as you use" Start (heute Abend machbar) +Technik-Stack für den ersten Abend: + +Node.js (ein einfaches Script, kein Framework-Wahnsinn) +SQLite (eine Datei, kein Server) +HTML + Vanilla JS (kein React-Build-Prozess) +Kein Docker erstmal (kommt später, wenn es läuft) +Schritt 0: Die "Leiche" (Walking Skeleton) +Erstelle eine Datei server.js: + +javascript + +Einklappen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +⌄ +⌄ +⌄ +⌄ +const express = require('express'); +const multer = require('multer'); +const sqlite3 = require('sqlite3').verbose(); +const path = require('path'); + +const app = express(); +const upload = multer({ dest: 'uploads/' }); + +// SQLite DB (eine Datei!) +const db = new sqlite3.Database('./steuer.db'); + +// Tabelle erstellen (falls nicht da) +db.run(` + CREATE TABLE IF NOT EXISTS documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT, + originalname TEXT, + upload_date DATETIME DEFAULT CURRENT_TIMESTAMP, + year INTEGER, + category TEXT, + amount REAL, + notes TEXT + ) +`); + +// Statische Dateien (Frontend) +app.use(express.static('public')); +app.use(express.json()); + +// Upload-Endpunkt +app.post('/upload', upload.single('file'), (req, res) => { + const { year, category, amount, notes } = req.body; + + db.run( + `INSERT INTO documents (filename, originalname, year, category, amount, notes) + VALUES (?, ?, ?, ?, ?, ?)`, + [req.file.filename, req.file.originalname, year, category, amount, notes], + function(err) { + if (err) return res.status(500).json({ error: err.message }); + res.json({ id: this.lastID, message: 'Gespeichert' }); + } + ); +}); + +// Alle Dokumente laden +app.get('/documents', (req, res) => { + db.all('SELECT * FROM documents ORDER BY upload_date DESC', [], (err, rows) => { + if (err) return res.status(500).json({ error: err.message }); + res.json(rows); + }); +}); + +app.listen(3000, () => console.log('Läuft auf http://localhost:3000')); +Schritt 1: Das hässliche aber funktionierende Frontend +Erstelle Ordner public/ und darin index.html: + +html + +Einklappen +Speichern +Kopieren + +Vorschau +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ + + + + + Meine Steuer-Docs + + + +

📁 Steuer-Dokumente

+ + +
+

Neues Dokument

+
+
+ + + + + +
+
+ + + + + +
+ + + + +Installation & Start (5 Minuten): +bash + +Einklappen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +mkdir steuer-app +cd steuer-app +npm init -y +npm install express multer sqlite3 +mkdir uploads public +# kopiere server.js und public/index.html +node server.js +Ergebnis: Du hast jetzt eine Seite auf localhost:3000 wo du PDFs hochlädst, mit Jahr/Kategorie/Betrag taggst, und danach suchen kannst. + +🔄 Die nächsten Iterationen (wenn du es nutzt) +Iteration 2 (wenn du merkst, dass Beträge wichtig sind): + +Automatische Summen pro Kategorie ("Wie viel Heizung 2024?") +Einfaches Dashboard mit Balken +Iteration 3 (wenn OCR notwendig wird): + +Tesseract.js im Browser hinzufügen +Upload zeigt sofort den OCR-Text neben dem PDF +Du kopierst den Betrag raus (manuell), aber die Suche funktioniert schon über den OCR-Text +Iteration 4 (wenn du die Abrechnung machst): + +"Projekte" oder "Abrechnungen" hinzufügen +Checkboxen: "Diese Rechnung gehört zur Abrechnung 2024 Objekt A" +Export als CSV für Excel (damit du deinen Steuerberater füttern kannst) +Iteration 5+ (wenn Basics laufen): + +Dann Docker, dann Ollama, dann Automatisierung +🎯 Das "Build as you use" Prinzip +Regel: Nach jeder Iteration musst du die Software echt nutzen können für deine nächste Buchhaltungs-Aufgabe. Wenn nicht, war sie zu groß. + +Beispiel: + +❌ Falsch: "Erst baue ich 3 Monate das perfekte System, dann benutze ich es" +✅ Richtig: "Heute speichere ich damit 5 Rechnungen, nächste Woche suche ich damit statt in Ordnern" +Technische Schuld ist erlaubt: + +Hartcodierte Steuersätze? Egal, ändert sich eh nur jährlich +Keine Tests? Egal, du merkst ja wenn es kaputt ist +SQLite statt PostgreSQL? Perfekt, eine Datei, einfach backupbar +Wichtig: Wenn du merkst, dass du etwas brauchst (z.B. "Shit, ich muss das PDF auch öffnen können"), baust du es dann, nicht vorher. + +Soll ich dir für Iteration 2 (die Summen/Dashboard) schon den Code vorbereiten, oder willst du erstmal mit dem Skeleton spielen und mir sagen, was als erstes weh tut? + +Warum nicht direkt in Docker entwickeln? Das Stelle ich mit als einzig pragmatischen weg vor. + +Und auch die GUI. Vielleicht nicht direkt ein Traum, aber hässlich muss ja nicht sein + +profile +kimi-k2.5:cloud +Heute um 20:05 +Nachgedacht für 16 Sekunden +Du hast absolut recht! Wenn du mit Docker vertraut bist, ist das der professionelle Weg – und es verhindert späteres "Läuft auf meinem Rechner"-Chaos. Und eine hässliche GUI motiviert niemanden, sie zu nutzen. + +Hier ist der "Docker-First + schönes UI" Stack: + +🐳 Docker-Dev-Setup (mit Hot-Reload) +Vorteil: Du entwickelst direkt im Container, aber mit Instant-Reload beim Code-Ändern. + +Projektstruktur + +Einklappen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +steuer-app/ +├── docker-compose.yml +├── Dockerfile.dev # Für Entwicklung mit Hot-Reload +├── backend/ +│ ├── package.json +│ ├── server.js # Express API +│ └── uploads/ # Gemountetes Volume +└── frontend/ + ├── package.json + ├── vite.config.js # Vite (schneller als CRA) + ├── tailwind.config.js # Schönes UI + └── src/ + ├── App.jsx + └── components/ +1. Backend (Docker + Express + SQLite) +dockerfile + +Einklappen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +9 +# Dockerfile.dev +FROM node:20-alpine +WORKDIR /app +RUN npm install -g nodemon +COPY package*.json ./ +RUN npm install +COPY . . +EXPOSE 3000 +CMD ["nodemon", "--legacy-watch", "server.js"] +javascript + +Einklappen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +⌄ +⌄ +⌄ +⌄ +⌄ +// backend/server.js +const express = require('express'); +const multer = require('multer'); +const sqlite3 = require('sqlite3'); +const cors = require('cors'); +const path = require('path'); + +const app = express(); +app.use(cors()); +app.use(express.json()); + +// Upload-Config +const storage = multer.diskStorage({ + destination: (req, file, cb) => cb(null, '/app/uploads'), + filename: (req, file, cb) => cb(null, Date.now() + '-' + file.originalname) +}); +const upload = multer({ storage }); + +// DB +const db = new sqlite3.Database('/app/data/steuer.db'); +db.run(` + CREATE TABLE IF NOT EXISTS docs ( + id INTEGER PRIMARY KEY, + filename TEXT, originalname TEXT, year INTEGER, + category TEXT, amount REAL, notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) +`); + +// Routes +app.get('/api/docs', (req, res) => { + db.all('SELECT * FROM docs ORDER BY created_at DESC', [], (err, rows) => { + res.json(rows || []); + }); +}); + +app.post('/api/upload', upload.single('file'), (req, res) => { + const { year, category, amount, notes } = req.body; + db.run( + 'INSERT INTO docs (filename, originalname, year, category, amount, notes) VALUES (?, ?, ?, ?, ?, ?)', + [req.file.filename, req.file.originalname, year, category, amount, notes], + function(err) { + if (err) return res.status(500).json({ error: err }); + res.json({ id: this.lastID }); + } + ); +}); + +// Statische Dateien für Uploads +app.use('/uploads', express.static('/app/uploads')); + +app.listen(3000, () => console.log('API läuft auf :3000')); +json + +Einklappen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +9 +⌄ +⌄ +// backend/package.json +{ + "dependencies": { + "express": "^4.18.2", + "multer": "^1.4.5-lts.1", + "sqlite3": "^5.1.6", + "cors": "^2.8.5" + } +} +2. Frontend (React + Vite + Tailwind - Schnell & Hübsch) +dockerfile + +Einklappen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +# frontend/Dockerfile.dev +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +EXPOSE 5173 +CMD ["npm", "run", "dev", "--", "--host"] +json + +Einklappen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +⌄ +⌄ +⌄ +⌄ +// frontend/package.json +{ + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "lucide-react": "^0.294.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.3.6", + "vite": "^5.0.8" + } +} +javascript + +Einklappen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +⌄ +// frontend/tailwind.config.js +/** @type {import('tailwindcss').Config} */ +export default { + content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], + theme: { extend: {} }, + plugins: [], +} +css + +Einklappen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +⌄ +/* frontend/src/index.css */ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + @apply bg-gray-50 text-gray-900; +} +javascript + +Einklappen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +⌄ +⌄ +⌄ +// frontend/vite.config.js +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + host: '0.0.0.0', + port: 5173, + proxy: { + '/api': 'http://backend:3000', + '/uploads': 'http://backend:3000' + } + } +}) +3. Schöne React-Components (nicht hässlich!) +jsx + +Einklappen +Speichern +Kopieren +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +// frontend/src/App.jsx + + + +
+ + +
+ +
+ + +
+ + + + + + {/* Search */} +
+ + setSearch(e.target.value)} + className="w-full pl-10 pr-4 py-3 rounded-xl border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition" /> +
+ + {/* Document List */} +
+ {filtered.map(doc => ( +
+
+
+
+ +

{doc.originalname}

+ + {doc.category} + +
+ + {doc.notes &&

{doc.notes}

} + +
+ + {doc.year} + + {doc.amount && ( + + {parseFloat(doc.amount).toFixed(2)} € + + )} +
+
+ + + Ansehen → + +
+
+ ))} +
+ + + ); +} + +export default App; +4. Docker Compose (Dev-Version mit Hot-Reload) +yaml + +Einklappen +Speichern +Kopieren +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +⌄ +# docker-compose.yml +version: '3.8' + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile.dev + volumes: + - ./backend:/app # Hot-reload für Code + - ./backend/node_modules:/app/node_modules # Node_modules nicht überschreiben + - uploads:/app/uploads # Persistente Uploads + - db_data:/app/data # Persistente DB + ports: + - "3000:3000" + environment: + - NODE_ENV=development + networks: + - app-net + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.dev + volumes: + - ./frontend:/app + - /app/node_modules # Anonymous volume für node_modules + ports: + - "5173:5173" + environment: + - CHOKIDAR_USEPOLLING=true # Für Hot-reload in Docker + networks: + - app-net + depends_on: + - backend + +volumes: + uploads: + db_data: + +networks: + app-net: + driver: bridge +🚀 Starten +bash + +Einklappen +Speichern +Kopieren +1 +2 +3 +4 +5 +mkdir steuer-app && cd steuer-app +# Ordner erstellen wie oben beschrieben +# Dateien kopieren + +docker-compose up --build +Ergebnis: + +Frontend: http://localhost:5173 (React + Tailwind, schick!) +Backend: http://localhost:3000 (API) +Hot-Reload: Code ändern → sofort sichtbar im Browser +Persistenz: Uploads und DB bleiben auch bei docker-compose down erhalten +Das ist der professionelle Weg – und es sieht gleich aus wie moderne SaaS-Apps (Notion/Linear-Stil). Ist das der Richtige Ansatz für dich? \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ca748c8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,141 @@ +# Finanz Flow by Taeger IT + +## Versionsmanagement + +### Aktuelle Version: 0.1.0-alpha + +**Semantische Versionierung:** +- MAJOR (X.0.0): Inkompatible API-Änderungen +- MINOR (0.X.0): Neue Features (abwärtskompatibel) +- PATCH (0.0.X): Bugfixes +- -alpha: Entwicklungsversion +- -beta: Testversion vor Release + +--- + +## CHANGELOG + +### [0.1.0-alpha] - 2026-04-20 + +#### Features +- ✅ Kredit-Verwaltung mit automatischer Zinsberechnung +- ✅ Nebenkosten-Abrechnung für Mieter +- ✅ Kostenplanung für mehrere Jahre +- ✅ Dashboard mit Übersicht +- ✅ PDF-Export für Nebenkosten +- ✅ Multi-User Support (Admin/User) + +#### Importiert +- ✅ 5 Kredite aus Excel (DSL, PSD, Zingelstr. DSL/Sparkasse, PVCreditplus) +- ✅ 10 Nebenkosten-Abrechnungen (2020-2024) + +#### Bekannte Bugs +- 🐛 PDF-Export Formatierung verbesserungswürdig +- 🐛 Keine Kaltmieten in Nebenkosten hinterlegt +- 🐛 Kein automatisches Backup +- 🐛 Kein Lizenzmanagement + +#### Geplant für 0.2.0 +- [ ] Kaltmieten ergänzen +- [ ] Backup-Automatisierung +- [ ] API-Dokumentation +- [ ] Fehlerbehebung PDF-Export + +--- + +## Lizenz & Verkauf + +**Status:** Nicht marktreif + +**Voraussetzungen für Verkauf:** +- [ ] Versionsmanagement ✅ (heute angelegt) +- [ ] Lizenzmanagement (Hardware-ID oder Key-basiert) +- [ ] Update-Mechanismus (Auto-Update) +- [ ] Backup/Restore-Funktion +- [ ] Dokumentation (Benutzerhandbuch) +- [ ] Support-System (Ticket-System oder Chat) +- [ ] Demo-Version (limitiert) + +**Preisvorstellung:** +- Einmalkauf: 500-2.000 € +- Abo: 20-50 €/Monat +- Support: 80 €/h + +--- + +## Roadmap + +### Phase 1: Stabilisierung (0.1.x) +- Fehlerbehebung +- Performance-Optimierung +- Code-Cleanup + +### Phase 2: Produktreife (0.2.x) +- Lizenzmanagement +- Update-Mechanismus +- Dokumentation +- Demo-Version + +### Phase 3: Markteinführung (1.0.0) +- Website mit Download +- Erste Kunden +- Feedback-System +- Feature-Requests + +--- + +## Feature-Request-Template + +``` +## Feature-Request + +**Beschreibung:** +[Was soll das Feature tun?] + +**Priorität:** +[Low / Medium / High / Critical] + +**Nutzer:** +[Wer braucht das?] + +**Akzeptanzkriterien:** +- [ ] Kriterium 1 +- [ ] Kriterium 2 + +**Geschätzter Aufwand:** +[Stunden / Tage] +``` + +--- + +## Bug-Report-Template + +``` +## Bug-Report + +**Version:** +[z.B. 0.1.0-alpha] + +**Beschreibung:** +[Was ist passiert?] + +**Schritte zum Reproduzieren:** +1. Schritt 1 +2. Schritt 2 + +**Erwartetes Verhalten:** +[Was sollte passieren?] + +**Tatsächliches Verhalten:** +[Was ist passiert?] + +**Screenshots:** +[Falls vorhanden] + +**Logs:** +[Falls relevant] +``` + +--- + +**Letzte Aktualisierung:** 2026-04-20 diff --git a/DOKUMENTATION_NIKI_IMPORT.md b/DOKUMENTATION_NIKI_IMPORT.md new file mode 100644 index 0000000..6e8a105 --- /dev/null +++ b/DOKUMENTATION_NIKI_IMPORT.md @@ -0,0 +1,146 @@ +# Import-Protokoll: Niki-Kredit +**Datum:** 2026-04-20 +**Datei:** Schulden Niki.xlsx + +--- + +## Zusammenfassung + +✅ **STATUS: KOMPLETT UND VERIFIZIERT** + +Alle Zahlungen und Auslagen aus der Excel-Datei sind korrekt in die Datenbank importiert. + +--- + +## Excel-Analyse + +| Kategorie | Anzahl | Summe | +|-----------|--------|-------| +| Zahlungen (Abgezahlt) | 9 | 3.400,00 EUR | +| Auslagen (Neue Kosten) | 4 | 505,00 EUR | +| **Netto tilgt** | | **2.895,00 EUR** | + +### Details der Zahlungen + +| Datum | Betrag | Typ | Beschreibung | +|-------|--------|-----|--------------| +| 2024-04-01 | 500,00 EUR | zahlung_eingang | Zahlung | +| 2024-05-01 | 500,00 EUR | zahlung_eingang | Zahlung | +| 2024-06-01 | 150,00 EUR | zahlung_eingang | Kameras | +| 2024-06-01 | 90,00 EUR | auslage | Kameras | +| 2024-07-01 | 450,00 EUR | zahlung_eingang | Kameras | +| 2024-07-01 | 45,00 EUR | auslage | Kameras | +| 2024-08-01 | 500,00 EUR | zahlung_eingang | Zahlung | +| 2024-09-01 | 300,00 EUR | zahlung_eingang | Zahlung | +| 2024-10-01 | 300,00 EUR | zahlung_eingang | Videorekorder + Zubehör | +| 2024-10-01 | 110,00 EUR | auslage | Videorekorder + Zubehör | +| 2025-02-01 | 200,00 EUR | zahlung_eingang | Zahlung | +| 2025-06-01 | 500,00 EUR | zahlung_eingang | Handyvertag | +| 2025-06-01 | 260,00 EUR | auslage | Handyvertag | + +--- + +## Datenbank-Zustand + +### Kredit-Datensatz +```sql +SELECT * FROM kredite WHERE name ILIKE '%niki%'; +``` + +| Feld | Wert | +|------|------| +| ID | 4ad8826f-ecb4-443d-aef6-ce9162e5f078 | +| Name | Niki Schulden (Forderung) | +| Kreditgeber | Niki | +| Person | Niki | +| Ursprungsschuld | 7.000,00 EUR | +| Restschuld | 4.105,00 EUR | +| Zinssatz | 10% | +| Start-Datum | 2023-07-01 | +| Status | aktiv | + +### Kredit-Zahlungen (13 Einträge) + +Alle 13 Zahlungen/Auslagen korrekt vorhanden in `kredit_zahlungen`. + +--- + +## Verifikation + +### Excel vs. DB Vergleich + +| Metrik | Excel | DB | Status | +|--------|-------|-----|--------| +| Startschuld | 7.000,00 EUR | 7.000,00 EUR | ✅ OK | +| Gesamtzahlungen | 3.400,00 EUR | 3.400,00 EUR | ✅ OK | +| Gesamtauslagen | 505,00 EUR | 505,00 EUR | ✅ OK | +| Netto getilgt | 2.895,00 EUR | 2.895,00 EUR | ✅ OK | +| Restschuld (ohne Zinsen) | - | 4.105,00 EUR | ✅ OK | + +**Wichtiger Hinweis:** +Die Excel-Datei berechnet Restschuld mit Zinsen (aktuell: 6.697,75 EUR). +Die App berechnet Restschuld ohne Zinsen (4.105,00 EUR). +Dies ist beabsichtigt - die App zeigt nur den tatsächlichen Schuldenstand ohne Zinsaufwuchs. + +--- + +## Import-Template für weitere Schulden + +### Format der Excel-Datei (Schulden [Name].xlsx) + +**Spalten (ab Zeile 6):** + +| Spalte | Feld | Bedeutung | +|--------|------|-----------| +| C | Datum | Monat/Jahr der Buchung | +| D | Restschuld | Aktueller Stand (negativ = Schulden) | +| E | Zinsen | Monatliche Zinsen (optional) | +| F | Abgezahlt | Positive Werte = Zahlungen erhalten | +| G | Neue Kosten | Negative Werte = Neue Auslagen/Ausgaben | +| H | Für was | Beschreibung/Notiz | + +### Regeln + +1. **Zahlungen** (Spalte F): Positive Werte = Zahlung eingegangen + - Import als `zahlung_eingang` in `kredit_zahlungen` + +2. **Auslagen** (Spalte G): Negative Werte = Neue Ausgaben + - Import als `auslage` in `kredit_zahlungen` + - Betrag als positiven Wert speichern + +3. **Zinsen** (Spalte E): Werden in der App nicht berücksichtigt + - Nur zur Dokumentation + +4. **Restschuld** (Spalte D): Referenzwert, nicht importieren + - App berechnet Restschuld automatisch + +--- + +## Checkliste für Import + +- [ ] Excel-Datei vorliegend mit Format: "Schulden [Name].xlsx" +- [ ] Spaltenreihenfolge prüfen (C=Datum, D=Restschuld, E=Zinsen, F=Abgezahlt, G=Neue Kosten, H=Beschreibung) +- [ ] Zahlungen und Auslagen identifizieren +- [ ] Kredit in DB anlegen (falls nicht vorhanden) +- [ ] Alle Zahlungen als `zahlung_eingang` importieren +- [ ] Alle Auslagen als `auslage` importieren +- [ ] Verifikation durchführen (Summen vergleichen) +- [ ] Restschuld prüfen + +--- + +## Nächster Import: Schulden Kerstin + +**Datei:** `Schulden Kerstin.xlsx` + +### Vorgehen + +1. Excel-Datei mit `read_excel_values.py` analysieren +2. Zahlungen und Auslagen identifizieren +3. Kredit-Datensatz prüfen/erstellen +4. Import durchführen +5. Verifikation (Summen vergleichen) + +--- + +*Dokumentation erstellt am: 2026-04-20* diff --git a/Kopie von Kostenrechnung der Nächsten jahre (3).xlsx b/Kopie von Kostenrechnung der Nächsten jahre (3).xlsx new file mode 100644 index 0000000000000000000000000000000000000000..3afaf1b3daafb1e2112efad4aaa405fb5cb9fb48 GIT binary patch literal 76534 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

RWjvjjYbX|1QK0CAU+UPmNfZyR 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 PsR1dtg`~=TS^)kFeY@h@ literal 0 HcmV?d00001 diff --git a/MIGRATION-ANLEITUNG.md b/MIGRATION-ANLEITUNG.md new file mode 100644 index 0000000..f0a2fc7 --- /dev/null +++ b/MIGRATION-ANLEITUNG.md @@ -0,0 +1,98 @@ +# Buchhaltungs-App Migration zu Proxmox + +## 1. Voraussetzungen auf Proxmox +- LXC Container erstellt (Ubuntu/Debian empfohlen) +- Docker + Docker Compose installiert +- Netzwerkzugriff (für Zugriff über 192.168.0.x) + +## 2. Dateien kopieren + +Von deinem lokalen Rechner auf Proxmox übertragen: + +```bash +# Auf Proxmox LXC ausführen: +mkdir -p /opt/buchhaltungs-app +cd /opt/buchhaltungs-app + +# Folgende Dateien müssen kopiert werden: +# - docker-compose.yml +# - nginx.conf +# - backend/uploads/ (falls Dokumente vorhanden) +# - backup_vor_umzug_*.sql (Datenbank-Backup) +``` + +## 3. Frontend neu bauen + +```bash +cd /opt/buchhaltungs-app + +# Falls Frontend-Source vorhanden: +cd frontend +npm install +npm run build +cd .. +``` + +## 4. Container starten + +```bash +docker-compose up -d db + +# Warte 10 Sekunden auf DB-Start +sleep 10 + +# Backup einspielen +docker exec -i buchhaltung-db psql -U postgres buchhaltung < backup_vor_umzug_*.sql + +# Alle Container starten +docker-compose up -d +``` + +## 5. Port ändern (optional) + +Falls Port 3000/3001 schon belegt sind, in docker-compose.yml anpassen: + +```yaml +ports: + - "3000:80" # z.B. zu "8080:80" ändern + - "3001:3001" # z.B. zu "8081:3001" ändern +``` + +## 6. Reverse Proxy (empfohlen) + +Für saubere URLs ohne Port: +- Nginx Proxy Manager auf Proxmox +- Oder Traefik im LXC +- Domain: buchhaltung.lan → 192.168.0.xxx:3000 + +## 7. Automatische Backups einrichten + +Cron-Job auf Proxmox: +```bash +0 2 * * * cd /opt/buchhaltungs-app && docker exec buchhaltung-db pg_dump -U postgres buchhaltung > backups/backup_$(date +\%Y\%m\%d).sql +``` + +## Dateien die du brauchst + +| Datei | Quelle | Ziel auf Proxmox | +|-------|--------|------------------| +| docker-compose.yml | `~/.openclaw/workspace/buchhaltungs-app/` | `/opt/buchhaltungs-app/` | +| nginx.conf | `~/.openclaw/workspace/buchhaltungs-app/` | `/opt/buchhaltungs-app/` | +| backend/uploads/ | `~/.openclaw/workspace/buchhaltungs-app/backend/` | `/opt/buchhaltungs-app/backend/uploads/` | +| backup_*.sql | `~/.openclaw/workspace/buchhaltungs-app/` | `/opt/buchhaltungs-app/` | +| frontend/dist/ | `~/.openclaw/workspace/buchhaltungs-app/frontend/` | `/opt/buchhaltungs-app/frontend/dist/` | + +## Test nach Migration + +1. http://192.168.0.xxx:3000 öffnen +2. Mit admin/admin123 anmelden +3. Stunden/Einträge prüfen +4. Dokumente testen + +## Rollback + +Falls was schiefgeht: +```bash +docker-compose down +# Lokal weiterarbeiten - Backup ist ja erstellt +``` diff --git a/NEBENKOSTEN_IMPLEMENTATION.md b/NEBENKOSTEN_IMPLEMENTATION.md new file mode 100644 index 0000000..3ad8823 --- /dev/null +++ b/NEBENKOSTEN_IMPLEMENTATION.md @@ -0,0 +1,111 @@ +# Nebenkostenabrechnung - Implementation Complete + +## Erstellte Dateien + +### Datenbank (Migrationen) +1. `backend/database/migrations/001_nebenkostenabrechnung.sql` + - Tabellen: objekte, mieter, mietvertraege, objektkosten, vorauszahlungen, nebenkostenabrechnungen, abrechnungspositionen + - Indizes und Trigger + +2. `backend/database/migrations/002_nebenkosten_beispieldaten.sql` + - 3 Beispiel-Objekte: Wilster, Segeberg, Zingelstraße 14 + - 5 Beispiel-Mieter + - 3 Mietverträge + - Beispielkosten und Vorauszahlungen für 2024 + +### Backend +3. `backend/routes/nebenkosten.js` + - API-Endpunkte für alle CRUD-Operationen + - Berechnungslogik für Abrechnungen + - PDF-Export mit Entwurf-Wasserzeichen + +### Frontend +4. `frontend/src/api-nebenkosten.js` + - API-Client für Objekte, Mieter, Abrechnungen + +5. `frontend/src/components/nebenkosten/Objekte.jsx` + - Objekt-Verwaltung mit Formular + - CRUD-Operationen + +6. `frontend/src/components/nebenkosten/Mieter.jsx` + - Mieter-Verwaltung + +7. `frontend/src/components/nebenkosten/Nebenkostenabrechnung.jsx` + - Abrechnungs-Erstellung + - Vorschau mit Berechnungen + - PDF-Download + +### Integration +8. `backend/server.js` (modifiziert) + - Import: `const nebenkostenRoutes = require('./routes/nebenkosten');` + - Routes: `nebenkostenRoutes(app);` + +9. `frontend/src/App.jsx` (modifiziert) + - Neue Tabs: "Objekte", "Mieter", "NK-Abrechnung" + - Import der neuen Komponenten + +## Features + +### Objekt-Verwaltung +- Name, Adresse, PLZ, Ort, Wohnfläche, Bemerkung +- CRUD: Anlegen, Bearbeiten, Löschen + +### Mieter-Verwaltung +- Name, E-Mail, Telefon, Adresse +- CRUD: Anlegen, Bearbeiten, Löschen + +### Mietverträge (implizit in API) +- Mieter-Zuordnung zu Objekt +- Wohnfläche, Kaltmiete, Vorauszahlung +- Vertragsbeginn/-ende + +### Kosten pro Objekt +- Kategorien: Grundsteuer, Heizung, Wohngeld, Handwerker, Wasser, Müll, Versicherung, Sonstiges +- Verteilung nach qm/Personen/Zählern +- CRUD über API + +### Vorauszahlungen +- Monatliche Vorauszahlungen pro Mieter +- Bulk-Erstellung für ganze Jahr + +### Abrechnung +- Flexibler Zeitraum (Standard: 1.1. - 31.12.) +- Pro-rata-Berechnung möglich +- Berechnung: (Gesamtkosten / Gesamtfläche) × Mieterfläche +- Abzug: Summe Vorauszahlungen +- Ergebnis: Nachzahlung oder Gutschrift + +### PDF Export +- Firmenlogo und Daten (Täger IT) +- Übersicht aller Kosten +- Berechnung pro Mieter +- Entwurf-Modus mit Wasserzeichen + +## Verwendung + +1. **Datenbank-Migrationen ausführen:** + ```bash + docker-compose exec db psql -U postgres -d buchhaltung -f /migrations/001_nebenkostenabrechnung.sql + docker-compose exec db psql -U postgres -d buchhaltung -f /migrations/002_nebenkosten_beispieldaten.sql + ``` + +2. **Frontend neu bauen:** + ```bash + cd frontend && npm run build + ``` + +3. **Backend neu starten:** + ```bash + docker-compose restart backend + ``` + +## Navigation +- **Objekte**: Immobilien/Objekte verwalten +- **Mieter**: Mieter verwalten +- **NK-Abrechnung**: Neue Abrechnung erstellen, Vorschau berechnen, PDF exportieren + +## Architektur +- Objekte haben Mieter über Mietverträge +- Kosten werden Objekten zugeordnet +- Vorauszahlungen sind Mieter + Objekt + Jahr + Monat +- Abrechnungen erstellen Positionen pro Mieter diff --git a/NEBENKOSTEN_STATUS.md b/NEBENKOSTEN_STATUS.md new file mode 100644 index 0000000..9a2b636 --- /dev/null +++ b/NEBENKOSTEN_STATUS.md @@ -0,0 +1,143 @@ +# Nebenkostenabrechnung - Status Dokumentation + +**Datum:** 21.04.2026 +**Status:** ✅ **VOLLSTÄNDIG IMPLEMENTIERT & DEPLOYED** + +--- + +## ✅ IMPLEMENTIERT + +### Backend (nebenkosten.js) +- ✅ Objekte-CRUD (GET, POST, PUT, DELETE /api/objekte) +- ✅ Mieter-CRUD (GET, POST, PUT, DELETE /api/mieter) +- ✅ Mietvertraege-CRUD (GET, POST, PUT, DELETE /api/mietvertraege) +- ✅ Objektkosten-CRUD (GET, POST, PUT, DELETE /api/objektkosten) +- ✅ Vorauszahlungen-CRUD (GET, POST, PUT, DELETE /api/vorauszahlungen) +- ✅ Vorauszahlungen Bulk-Erstellung (POST /api/vorauszahlungen/bulk) +- ✅ Nebenkostenabrechnung Übersicht (GET /api/nebenkostenabrechnung/uebersicht) +- ✅ Nebenkostenabrechnung Vorschau (POST /api/nebenkostenabrechnung/vorschau) +- ✅ Nebenkostenabrechnung speichern (POST /api/nebenkostenabrechnung) +- ✅ Alle Abrechnungen abrufen (GET /api/nebenkostenabrechnung) +- ✅ Einzelne Abrechnung mit Positionen (GET /api/nebenkostenabrechnung/:id) +- ✅ Status aktualisieren (PUT /api/nebenkostenabrechnung/:id/status) +- ✅ PDF Export (POST /api/nebenkostenabrechnung/:id/pdf) + +### Datenbank-Tabellen +Alle erforderlichen Tabellen werden automatisch erstellt: +- ✅ `objekte` +- ✅ `mieter` +- ✅ `mietvertraege` +- ✅ `objektkosten` +- ✅ `vorauszahlungen` +- ✅ `nebenkostenabrechnungen` +- ✅ `abrechnungspositionen` + +### Frontend +- ✅ Objekte.jsx - Komplette Objektverwaltung +- ✅ Mieter.jsx - Komplette Mieterverwaltung +- ✅ Nebenkostenabrechnung.jsx - Abrechnung erstellen & Berechnung +- ✅ api-nebenkosten.js - Alle API-Calls mit Zusatzfunktionen +- ✅ App.jsx - Routing eingebunden (Objekte, Mieter, NK-Abrechnung) + +### Berechnungslogik +- ✅ Pro-rata Verteilung der Kosten nach Wohnfläche +- ✅ Vorauszahlungen werden verrechnet +- ✅ Nachzahlung/Gutschrift automatisch berechnet +- ✅ PDF-Generation mit Entwurf/Final-Modus + +### Deployment +- ✅ Backend Container erfolgreich neu gebaut +- ✅ Neue Tabellen werden automatisch erstellt +- ✅ Alle Routen aktiv + +--- + +## 📦 VERFÜGBARE ENDPUNKTE + +| Endpunkt | Methode | Beschreibung | +|----------|-----------|--------------| +| /api/objekte | GET/POST | Alle Objekte / Neues Objekt | +| /api/objekte/:id | PUT/DELETE | Objekt aktualisieren/löschen | +| /api/mieter | GET/POST | Alle Mieter / Neuer Mieter | +| /api/mieter/:id | PUT/DELETE | Mieter aktualisieren/löschen | +| /api/mietvertraege | GET/POST | Mietverträge | +| /api/mietvertraege/:id | PUT/DELETE | Mietvertrag aktualisieren/löschen | +| /api/objektkosten | GET/POST | Kosten | +| /api/objektkosten/:id | PUT/DELETE | Kosten aktualisieren/löschen | +| /api/vorauszahlungen | GET/POST | Vorauszahlungen | +| /api/vorauszahlungen/bulk | POST | 12 Monate auf einmal erstellen | +| /api/nebenkostenabrechnung/vorschau | POST | Berechnung durchführen | +| /api/nebenkostenabrechnung | GET/POST | Abrechnungen abrufen/speichern | +| /api/nebenkostenabrechnung/:id/pdf | POST | PDF erstellen | + +--- + +## 🧪 TEST-ANLEITUNG + +### Test 1: Objekt anlegen +1. **Frontend öffnen:** `http://192.168.0.55:3000` +2. **Tab "Objekte" auswählen** +3. **"Neues Objekt" klicken** +4. Daten eingeben: + - Name: "Test Wohnung" + - Adresse: "Musterstraße 1" + - PLZ: "25554" + - Ort: "Wilster" + - Wohnfläche: "85" +5. **"Speichern" klicken** + +### Test 2: Mieter anlegen +1. **Tab "Mieter" auswählen** +2. **"Neuer Mieter" klicken** +3. Daten eingeben: + - Name: "Max Mustermann" + - E-Mail: "max@test.de" +4. **"Speichern"** + +### Test 3: Abrechnung erstellen +1. **Tab "NK-Abrechnung" auswählen** +2. Objekt auswählen +3. Jahr eingeben (z.B. 2026) +4. **"Abrechnung berechnen" klicken** +5. Ergebnis sollte leer sein (noch keine Kosten) +6. **"Als Entwurf speichern"** + +### Test 4: Kosten hinzufügen +Kann über Backend/API erfolgen: +```bash +# Beispiel via curl (von Container aus) +curl -X POST http://localhost:3001/api/objektkosten \ + -H "Content-Type: application/json" \ + -d '{"objekt_id": "", "kategorie": "Heizkosten", "bezeichnung": "Gas 2026", "betrag": 1200, "jahr": 2026}' +``` + +--- + +## 📁 DATEIEN + +### Backend +- `backend/routes/nebenkosten.js` - Alle API-Routen (600+ Zeilen) +- `backend/server.js` - InitDatabase erweitert + +### Frontend +- `frontend/src/components/nebenkosten/Objekte.jsx` +- `frontend/src/components/nebenkosten/Mieter.jsx` +- `frontend/src/components/nebenkosten/Nebenkostenabrechnung.jsx` +- `frontend/src/api-nebenkosten.js` +- `frontend/src/App.jsx` - Navigation erweitert + +### Dokumentation +- `NEBENKOSTEN_STATUS.md` - Diese Datei + +--- + +## 🚀 DEPLOYMENT STATUS + +``` +✅ Container gebaut: buchhaltungs-app-backend:latest +✅ Container gestartet: buchhaltung-backend +✅ Datenbank-Tabellen initialisiert +✅ Backend läuft auf Port 3001 +``` + +**System ist betriebsbereit!** \ No newline at end of file diff --git a/NIKI_IMPORT_ERGEBNIS.md b/NIKI_IMPORT_ERGEBNIS.md new file mode 100644 index 0000000..16b8412 --- /dev/null +++ b/NIKI_IMPORT_ERGEBNIS.md @@ -0,0 +1,62 @@ +# Niki Kredit - Import Ergebnis + +## ✅ Erfolgreich abgeschlossen + +### Was wurde gemacht: +1. **Alten Kredit gelöscht** - Alle vorherigen Niki-Einträge entfernt +2. **Excel analysiert** - "Schulden Niki.xlsx" komplett eingelesen +3. **Korrekten Import durchgeführt** - Alle Zahlungen und Auslagen importiert +4. **Restschuld korrigiert** - Auf korrekte 4105.00 € gesetzt + +### Kredit-Details: +| Feld | Wert | +|------|------| +| Name | Niki Schulden | +| ID | 3c957cd3-e583-4596-a122-1253dd8956ef | +| Ursprungsschuld | 7000.00 € | +| **Restschuld** | **4105.00 €** | +| Zinssatz | 10% | +| Start | 2023-07-01 | + +### Importierte Transaktionen (13 Stück): + +**Zahlungen (9):** +- 2024-04-01: +500.00 € +- 2024-05-01: +500.00 € +- 2024-06-01: +150.00 € (Kameras) +- 2024-07-01: +450.00 € (Kameras) +- 2024-08-01: +500.00 € +- 2024-09-01: +300.00 € +- 2024-10-01: +300.00 € (Videorekorder + Zubehör) +- 2025-02-01: +200.00 € +- 2025-06-01: +500.00 € (Handyvertag) + +**Auslagen/Neue Kosten (4):** +- 2024-06-01: -90.00 € (Kameras) +- 2024-07-01: -45.00 € (Kameras) +- 2024-10-01: -110.00 € (Videorekorder + Zubehör) +- 2025-06-01: -260.00 € (Handyvertag) + +### Berechnung: +- Startschuld: 7000.00 € +- Abzüglich Zahlungen: -3400.00 € +- Plus Auslagen: +505.00 € +- **= Restschuld: 4105.00 €** ✅ + +### Wichtige Erkenntnis für zukünftige Imports: +Die Excel speichert "Neue Kosten" als **NEGATIVE** Zahlen (-90, -45, -110, -260)! +Dies muss beim Import berücksichtigt werden. + +### Für Kerstin-Import: +Das gleiche Schema kann verwendet werden: +1. Excel einlesen (Header Zeile 6, Daten ab Zeile 7) +2. "Abgezahlt" > 0 = Zahlung +3. "Neue Kosten" < 0 = Auslage (als negativer Wert speichern) +4. Restschuld = Startschuld - Summe(Zahlungen) + Summe(|Auslagen|) + +### Verwendete Scripts: +- `import_niki_fixed.js` - Kompletter Import-Workflow +- `fix_restschuld.js` - Restschuld-Korrektur +- `verify_niki_final.js` - Finale Verifikation + +Alle Scripts sind im Verzeichnis `C:\Users\renet\.openclaw\workspace\buchhaltungs-app\` diff --git a/Nebenkosten 2020.xlsx b/Nebenkosten 2020.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..7da30b4955bdc5ee00390d2a7fe76d5fe20722a7 GIT binary patch literal 40773 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�qVxZwGOND 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 literal 0 HcmV?d00001 diff --git a/README.md b/README.md new file mode 100644 index 0000000..bff471b --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# SteuerFlow - Buchhaltungs-App + +Dockerisierte Buchhaltungsanwendung für Steuerunterlagen, Nebenkostenabrechnungen und Gewerbe-Abrechnungen. + +## Features + +- 📄 Dokumenten-Upload (PDF, Bilder) +- 🏷️ Kategorien: Steuer, Nebenkosten, Kredit, Hausmeister, Gewerbe +- 💰 Betrags-Erfassung pro Dokument +- 🗄️ SQLite-Datenbank (persistiert via Docker-Volume) +- 🔍 Schnelle Übersicht aller Dokumente + +## Schnellstart + +### Entwicklung + +```bash +cd buchhaltungs-app +docker-compose up --build +``` + +- Frontend: http://localhost:5173 +- Backend API: http://localhost:3000 + +### Produktion + +```bash +docker-compose -f docker-compose.prod.yml up --build -d +``` + +## Kategorien + +| Kategorie | Verwendung | +|-----------|-----------| +| steuer | Elster-relevante Belege | +| nebenkosten | Wohngeldabrechnungen | +| kredit | Finanzierungsunterlagen | +| hausmeister | Instandhaltung & Reparaturen | +| gewerbe | Gewerbliche Ausgaben (ab Juli) | +| sonstiges | Alles andere | + +## Technologien + +- **Frontend:** React + Vite + Tailwind CSS +- **Backend:** Express + SQLite + Multer +- **Container:** Docker + Docker Compose + +## Projektstruktur + +``` +buchhaltungs-app/ +├── backend/ # Express API +├── frontend/ # React App +├── docker-compose.yml # Dev-Konfiguration +└── README.md +``` + +## Reverse Proxy (Produktion) + +Für HTTPS hinter einem Reverse Proxy (z.B. Traefik, Nginx Proxy Manager): + +1. `docker-compose.prod.yml` anpassen +2. Netzwerk `proxy` hinzufügen +3. Labels für Traefik konfigurieren + +## Datenbank + +SQLite-Datenbank liegt unter `/app/data/steuer.db` im Container. +Wird automatisch beim ersten Start erstellt. + +## Umgebungsvariablen + +Kopiere `.env.example` zu `.env` und passe Werte an: + +```bash +cp .env.example .env +``` diff --git a/Schulden Kerstin.xlsx b/Schulden Kerstin.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..4970aa9b60285b0cf68ad7ec877d842314622b13 GIT binary patch literal 18976 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 literal 0 HcmV?d00001 diff --git a/TEST_GESCHAEFTSPLANUNG.md b/TEST_GESCHAEFTSPLANUNG.md new file mode 100644 index 0000000..3d5dffe --- /dev/null +++ b/TEST_GESCHAEFTSPLANUNG.md @@ -0,0 +1,67 @@ +# Test der Geschäftsplanung-Erweiterung + +## Durchgeführte Änderungen + +### 1. Datenbank-Migration (005_erweiterte_geschaeftsplanung.sql) +- Neue Spalten: `typ`, `unterkategorie`, `ist_einnahme` +- Views für Auswertungen: `geschaeftsplanung_auswertung`, `geschaeftsplanung_monatlich`, `miete_nebenkosten_vergleich` +- Migration wurde erfolgreich ausgeführt + +### 2. Backend-Erweiterungen (server.js) +- API-Endpunkte hinzugefügt: + - `GET /api/geschaeftsplanung/monatlich` - Monatliche Übersicht mit Ergebnis + - `GET /api/geschaeftsplanung/kategorien` - Kategorie-Übersicht + - `GET /api/geschaeftsplanung/objektvergleich/:objekt` - Miete vs Nebenkosten + - `GET /api/geschaeftsplanung/umsatzvorschau` - Umsatzvorschau +- Kostenplanung-API erweitert mit neuen Feldern (typ, unterkategorie, ist_einnahme) + +### 3. Frontend +- **Neue Komponente:** `Geschaeftsplanung.jsx` (ersetzt Kostenplanung.jsx) + - Drei Bereiche: Einnahmen, Betriebskosten, Private Kosten + - Schnellvorlagen für Standard-Kategorien + - Tab-Navigation: Planung und Auswertung + - Monatliche Detailtabelle mit Summen +- **API-Service:** `geschaeftsplanungAPI` in `api.js` hinzugefügt +- **Dashboard:** Neue Kacheln für Einnahmen und Betriebsergebnis + +### 4. App.jsx aktualisiert +- Import geändert von Kostenplanung zu Geschaeftsplanung +- Tab-Name geändert zu "Geschäftsplanung" + +## Features + +### Einnahmen +- Planungshonorare +- Beratungsleistungen +- Installationsleistungen +- Mieteinnahmen + +### Betriebskosten +- Telefon/Internet (Vorschlag: 35 €) +- Büromaterial (Vorschlag: 30 €) +- Versicherungen (Vorschlag: 120 €) +- Rechts/Beratung (Vorschlag: 50 €) +- Kfz-Kosten (Vorschlag: 1.000 €) +- Reisekosten (Vorschlag: 200 €) + +### Private Kosten +- Wohnung (Miete/Nebenkosten) +- Essen/Trinken +- Strom +- Müll +- Internet (privat) + +### Auswertungen +- Monatliche Übersicht: Einnahmen, Betriebskosten, Betriebsergebnis, Private Kosten, Cashflow +- Umsatzvorschau nach Kategorien +- Miete vs Nebenkosten pro Objekt + +## Deployment + +1. Backend wurde neugestartet (Container: buchhaltung-backend) +2. Migration wurde in Datenbank ausgeführt +3. Frontend wurde gebaut und deployed (Container: buchhaltung-frontend) + +## Zugriff + +Die Geschäftsplanung ist unter dem Reiter "Geschäftsplanung" im Finanz Flow erreichbar. diff --git a/add_kosten_routes.js b/add_kosten_routes.js new file mode 100644 index 0000000..ffef55a --- /dev/null +++ b/add_kosten_routes.js @@ -0,0 +1,66 @@ +const fs = require('fs'); + +const filePath = 'C:\\Users\\renet\\.openclaw\\workspace\\buchhaltungs-app\\backend\\routes\\nebenkosten.js'; +let content = fs.readFileSync(filePath, 'utf8'); + +const kostenRoutes = ` + + // ========== OBJEKTKOSTEN ========== + + // Kosten für ein Objekt laden (optional gefiltert nach Jahr) + app.get('/api/objekte/:id/kosten', async (req, res) => { + try { + const { jahr } = req.query; + let query = 'SELECT * FROM objektkosten WHERE objekt_id = $1'; + const params = [req.params.id]; + + if (jahr) { + query += ' AND jahr = $2'; + params.push(jahr); + } + + query += ' ORDER BY jahr DESC, kategorie ASC'; + + const result = await pool.query(query, params); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Kosten hinzufügen + app.post('/api/objekte/:id/kosten', async (req, res) => { + try { + const { kategorie, betrag, jahr } = req.body; + const result = await pool.query( + 'INSERT INTO objektkosten (objekt_id, kategorie, betrag, jahr) VALUES ($1, $2, $3, $4) RETURNING *', + [req.params.id, kategorie, betrag, jahr] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Kosten löschen + app.delete('/api/objekte/:id/kosten/:kostenId', async (req, res) => { + try { + await pool.query('DELETE FROM objektkosten WHERE id = $1 AND objekt_id = $2', [req.params.kostenId, req.params.id]); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); +`; + +// Finde das Ende der Objekt-DELETE Route und füge danach die Kosten-Routen ein +const pattern = /(app\.delete\('\/api\/objekte\/:id',[\s\S]*?}\s*\);)(\s*\/\/ ========== MIETER)/; + +if (content.match(pattern)) { + content = content.replace(pattern, `$1${kostenRoutes}$2`); + fs.writeFileSync(filePath, content); + console.log('✅ Kosten-Routen erfolgreich hinzugefügt!'); +} else { + console.error('❌ Pattern nicht gefunden'); + process.exit(1); +} diff --git a/analyze_all_sheets.py b/analyze_all_sheets.py new file mode 100644 index 0000000..2a4eead --- /dev/null +++ b/analyze_all_sheets.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +""" +Durchsucht ALLE Sheets nach den 5 Krediten mit den korrekten Werten. +""" + +import openpyxl +from datetime import datetime + +# Excel-Datei laden +datei = "Kopie von Kostenrechnung der Nächsten jahre (3).xlsx" +wb = openpyxl.load_workbook(datei, data_only=True) + +# Target-Werte vom User +target_kredite = { + "DSL Bank": 64656.88, + "PSD Nord": 50384.50, + "Zingelstr. 14 DSL": 24382.38, + "Zingelstr. 14 Sparkasse": 8140.11, + "PVCreditplus": 1666.53 +} + +print("SUCHE NACH KREDITEN MIT FOLGENDEN RESTSCHULDEN:") +for name, betrag in target_kredite.items(): + print(f" {name}: {betrag:,.2f} EUR") + +print("\n" + "="*80) + +for sheet_name in wb.sheetnames: + print(f"\n=== SHEET: {sheet_name} ===") + ws = wb[sheet_name] + + # Suche nach den Zielwerten + for row in range(1, min(ws.max_row + 1, 100)): + for col in range(1, min(ws.max_column + 1, 30)): + val = ws.cell(row=row, column=col).value + if val and isinstance(val, (int, float)): + for kredit_name, target in target_kredite.items(): + # Toleranz von 100 EUR + if abs(val - target) < 100: + print(f" ZEILE {row}, Spalte {col}: {val:,.2f} = {kredit_name}!") + # Kontext zeigen + context = [] + for c in range(max(1, col-3), min(col+3, ws.max_column + 1)): + v = ws.cell(row=row, column=c).value + context.append(str(v)[:15] if v else "-") + print(f" Kontext: {context}") + # Header suchen + headers = [] + for c in range(max(1, col-3), min(col+3, ws.max_column + 1)): + h = ws.cell(row=1, column=c).value + headers.append(str(h)[:15] if h else "-") + print(f" Header: {headers}") + +print("\n" + "="*80) +print("SUCHE NACH ZINSSÄTZEN (Text 'Zins' oder '%'):") +for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + for row in range(1, min(20, ws.max_row + 1)): + for col in range(1, min(30, ws.max_column + 1)): + val = ws.cell(row=row, column=col).value + if val: + if isinstance(val, str) and ("zins" in val.lower() or "%" in val): + print(f" [{sheet_name}] Zeile {row}, Spalte {col}: {val}") + elif isinstance(val, (int, float)) and 0.001 < val < 0.2: + # Möglicher Zinssatz als Dezimal + label = ws.cell(row=row-1, column=col).value if row > 1 else None + print(f" [{sheet_name}] Zeile {row}, Spalte {col}: {val} ({val*100:.2f}%) - {label}") diff --git a/analyze_excel.py b/analyze_excel.py new file mode 100644 index 0000000..8fcfb2e --- /dev/null +++ b/analyze_excel.py @@ -0,0 +1,43 @@ +import openpyxl +from datetime import datetime + +file_path = 'Kopie von Kostenrechnung der Nächsten jahre (3).xlsx' +wb = openpyxl.load_workbook(file_path, data_only=True) +sheet = wb['Tilgung bei Gleichbleibenden Be'] + +# Alle Kredit-Header in Zeile 1 +credits = [] +print('=== ALLE KREDITE (Zeile 1 & 2) ===') +for col in range(1, 30): + h1 = sheet.cell(1, col).value + h2 = sheet.cell(2, col).value + if h1 is not None: + print(f'Spalte {col}: Header1="{h1}", Header2="{h2}"') + if 'Restschuld' in str(h2) or 'Rate' in str(h2) or h2 is None: + credits.append({'col': col, 'name': h1, 'type': h2}) + +print('\n=== April 2026 suchen ===') +target_date = datetime(2026, 4, 1) +april_row = None +for row in range(1, 200): + cell_val = sheet.cell(row, 1).value + if isinstance(cell_val, datetime): + if cell_val.year == 2026 and cell_val.month == 4: + print(f'April 2026 in Zeile {row}: {cell_val}') + april_row = row + break + elif cell_val and '2026-04' in str(cell_val): + print(f'April 2026 in Zeile {row}: {cell_val}') + april_row = row + break + +print(f'\n=== Werte für April 2026 (Zeile {april_row}) ===') +print('KREDITE mit Restschuld/Raten:') +print(f'Spalte 2 (Targo Bank Restschuld): {sheet.cell(april_row, 2).value}') +print(f'Spalte 6 (Käpke Restschuld): {sheet.cell(april_row, 6).value}') +print(f'Spalte 9 (DSL Bank Restschuld): {sheet.cell(april_row, 9).value}') +print(f'Spalte 13 (PSD Nord Restschuld): {sheet.cell(april_row, 13).value}') +print(f'Spalte 20 (Zingelstr. 14 Restschuld): {sheet.cell(april_row, 20).value}') +print(f'Spalte 21 (Sparkasse RATE): {sheet.cell(april_row, 21).value}') +print(f'Spalte 22 (Sparkasse Zinsen): {sheet.cell(april_row, 22).value}') +print(f'Spalte 24 (Gesamt Restschuld): {sheet.cell(april_row, 24).value}') diff --git a/analyze_excel2.py b/analyze_excel2.py new file mode 100644 index 0000000..07dab7f --- /dev/null +++ b/analyze_excel2.py @@ -0,0 +1,33 @@ +import openpyxl +from datetime import datetime + +file_path = 'Kopie von Kostenrechnung der Nächsten jahre (3).xlsx' +wb = openpyxl.load_workbook(file_path, data_only=True) +sheet = wb['Tilgung bei Gleichbleibenden Be'] + +print('=== Alle Header Zeile 1 & 2 ===') +headers = [] +for col in range(1, 35): + h1 = sheet.cell(1, col).value + h2 = sheet.cell(2, col).value + headers.append((col, h1, h2)) + if h1 is not None: + print(f'Col {col:2d}: {h1:20s} | {h2}') + +# April 2026 +april_row = 189 + +print(f'\n=== ALLE WERTE für April 2026 (Zeile {april_row}) ===') +for col in range(1, 35): + val = sheet.cell(april_row, col).value + h1 = sheet.cell(1, col).value + if val is not None and val != 0: + print(f'Col {col:2d} ({h1}): {val}') + +# Suche nach aktuellen Daten (letzte Zeile mit Daten) +print('\n=== Suche aktuelle Daten (2025/2026) ===') +for row in range(180, 200): + val = sheet.cell(row, 1).value + rest_gesamt = sheet.cell(row, 24).value + if val: + print(f'Row {row}: {val} -> Gesamt-Restschuld: {rest_gesamt}') diff --git a/analyze_excel3.py b/analyze_excel3.py new file mode 100644 index 0000000..997b047 --- /dev/null +++ b/analyze_excel3.py @@ -0,0 +1,47 @@ +import openpyxl +from datetime import datetime + +file_path = 'Kopie von Kostenrechnung der Nächsten jahre (3).xlsx' +wb = openpyxl.load_workbook(file_path, data_only=True) +sheet = wb['Tilgung bei Gleichbleibenden Be'] + +# Prüfe Spalte 20 (Zingelstr.14) über mehrere Zeilen +print('=== Spalte 20: Zingelstr. 14 (über Zeit) ===') +for row in range(175, 195): + date_val = sheet.cell(row, 1).value + zingel = sheet.cell(row, 20).value + if date_val and zingel is not None and zingel != 0: + print(f'Row {row} ({date_val}): {zingel}') + +# Prüfe Spalte 21 (Sparkasse) über mehrere Zeilen +print('\n=== Spalte 21: Sparkasse (über Zeit) ===') +for row in range(175, 195): + date_val = sheet.cell(row, 1).value + sparkasse = sheet.cell(row, 21).value + if date_val and sparkasse is not None and sparkasse != 0: + print(f'Row {row} ({date_val}): {sparkasse}') + +# Prüfe Spalte 22 (Zinsen Sparkasse) +print('\n=== Spalte 22: Sparkasse Zinsen (über Zeit) ===') +for row in range(175, 195): + date_val = sheet.cell(row, 1).value + zinsen = sheet.cell(row, 22).value + if date_val and zinsen is not None and zinsen != 0: + print(f'Row {row} ({date_val}): {zinsen}') + +# Gibt es vielleicht eine Restschuld für Sparkasse woanders? +# Prüfe Spalten 28-30 (hatten Werte in April 2026!) +print('\n=== Spalten 28-30 (unklare Daten) ===') +for col in range(28, 32): + h1 = sheet.cell(1, col).value + h2 = sheet.cell(2, col).value + val = sheet.cell(189, col).value + print(f'Col {col}: {h1} / {h2} = {val}') + +# Prüfe ob Spalte 28 Restschuld ist +print('\n=== Spalte 28 (Restschuld Check) ===') +for row in range(175, 195): + date_val = sheet.cell(row, 1).value + val28 = sheet.cell(row, 28).value + if date_val and val28 is not None and val28 != 0: + print(f'Row {row} ({date_val}): {val28}') diff --git a/analyze_excel4.py b/analyze_excel4.py new file mode 100644 index 0000000..dc2990f --- /dev/null +++ b/analyze_excel4.py @@ -0,0 +1,58 @@ +import openpyxl + +file_path = 'Kopie von Kostenrechnung der Nächsten jahre (3).xlsx' +wb = openpyxl.load_workbook(file_path, data_only=True) +sheet = wb['Tilgung bei Gleichbleibenden Be'] + +print('=== DETAIL-ANALYSE aller Spalten ===') +print('Format: Spalte | Header Zeile 1 | Header Zeile 2') +print('-' * 60) + +for col in range(1, 35): + h1 = sheet.cell(1, col).value + h2 = sheet.cell(2, col).value + v = sheet.cell(189, col).value # April 2026 + + if h1 is not None or h2 is not None or v is not None: + print(f'{col:2d} | {str(h1):20s} | {str(h2):15s} | April 2026: {v}') + +print('\n=== WELCHES SIND KREDITE? ===') +print('Kriterium: Hat Restschuld + Rate oder ist eindeutig benannt') + +# Analyse der Gesamt-Spalte (24) +print('\nGesamt-Spalte (24) prüfen:') +for row in range(187, 192): + date = sheet.cell(row, 1).value + gesamt = sheet.cell(row, 24).value + print(f'Row {row} ({date}): {gesamt}') + +# Mathematische Prüfung +print('\n=== MATHEMATISCHE PRÜFUNG April 2026 (Row 189) ===') +dsl = sheet.cell(189, 9).value or 0 +psd = sheet.cell(189, 13).value or 0 +zingel20 = sheet.cell(189, 20).value or 0 +sparkasse28 = sheet.cell(189, 28).value or 0 + +print(f'DSL Bank (Col 9): {dsl}') +print(f'PSD Nord (Col 13): {psd}') +print(f'Zingelstr.14 (Col 20): {zingel20}') +print(f'Unbekannt (Col 28): {sparkasse28}') +print(f'Summe: {dsl + psd + zingel20 + sparkasse28}') +print(f'Gesamt (Col 24): {sheet.cell(189, 24).value}') + +print('\n=== IST SPALTE 28 SPARKASSE RESTSCHULD? ===') +# Spalte 21 = Sparkasse RATE +# Spalte 28 könnte Sparkasse Restschuld sein +# Prüfe: Rate 350, Zinsen sinken wie bei Zingelstr.14 + +for row in range(189, 191): + date = sheet.cell(row, 1).value + rate21 = sheet.cell(row, 21).value + rest28 = sheet.cell(row, 28).value + zins30 = sheet.cell(row, 30).value + zins22 = sheet.cell(row, 22).value + print(f'{date}:') + print(f' Spalte 21 (Sparkasse Rate): {rate21}') + print(f' Spalte 22 (Sparkasse Zins): {zins22}') + print(f' Spalte 28 (Restschuld?): {rest28}') + print(f' Spalte 30 (Zinsen?): {zins30}') diff --git a/analyze_excel_kredite.py b/analyze_excel_kredite.py new file mode 100644 index 0000000..53339b5 --- /dev/null +++ b/analyze_excel_kredite.py @@ -0,0 +1,96 @@ +import openpyxl +import sys +from datetime import datetime +import re +import json + +# Excel-Datei laden +datei = "Kopie von Kostenrechnung der Nächsten jahre (3).xlsx" +print(f"Lade {datei}...") + +try: + wb = openpyxl.load_workbook(datei, data_only=True) + + print("\n=== Verfügbare Sheets ===") + for name in wb.sheetnames: + print(f" - {name}") + + # Das Sheet "Tilgung bei Gleichbleibenden Be" laden + sheet_name = "Tilgung bei Gleichbleibenden Be" + if sheet_name in wb.sheetnames: + ws = wb[sheet_name] + print(f"\n=== Analyse: {sheet_name} ===") + print(f"Bereich: {ws.dimensions}") + print(f"Max Zeile: {ws.max_row}, Max Spalte: {ws.max_column}") + else: + print(f"Sheet '{sheet_name}' nicht gefunden!") + print("Verfügbare Sheets:", wb.sheetnames) + sys.exit(1) + + # Ersten 50 Zeilen anzeigen um Struktur zu verstehen + print("\n=== Erste 30 Zeilen (für Struktur) ===") + for row_idx, row in enumerate(ws.iter_rows(min_row=1, max_row=30, values_only=True), 1): + non_empty = [str(cell)[:30] if cell is not None else "" for cell in row[:10]] + if any(non_empty): + print(f"Zeile {row_idx:2}: {non_empty}") + + # Nach Kreditnamen suchen - wir wissen die Kreditnamen + kredit_namen = ["Targo Bank", "Köpke", "DSL Bank", "PSD Nord", "Zingelstr. 14", + "Sparkasse", "Carola", "Kerstin", "PVCreditplus"] + + print("\n=== Suche nach Krediten ===") + kredite_gefunden = {} + + for row_idx, row in enumerate(ws.iter_rows(min_row=1, max_row=ws.max_row, values_only=True), 1): + for cell in row: + if cell and isinstance(cell, str): + for kredit_name in kredit_namen: + if kredit_name.lower() in cell.lower(): + # Zeile extrahieren + row_data = list(row) + print(f"\n🔍 Gefunden: '{kredit_name}' in Zeile {row_idx}") + print(f" Zeile {row_idx}: {row_data[:15]}") + kredite_gefunden[kredit_name] = { + "zeile": row_idx, + "daten": row_data + } + break + + # Nun detaillierte Analyse für jeden gefundenen Kredit + print("\n\n" + "="*80) + print("DETAILLIERTE KREDIT-ANALYSE") + print("="*80) + + # Wir müssen die Struktur verstehen - oft sind Kredite untereinander + # mit Header-Zeilen dazwischen + + # Alternative: Suche nach Mustern wie "Restschuld", "Monatsrate", etc. + print("\n=== Suche nach Schlüsselwörtern ===") + keywords = ["restschuld", "monatsrate", "rate", "zinssatz", "zins", "laufzeit", "betrag", "start"] + + for row_idx, row in enumerate(ws.iter_rows(min_row=1, max_row=100, values_only=True), 1): + for cell in row: + if cell and isinstance(cell, str): + cell_lower = cell.lower() + for kw in keywords: + if kw in cell_lower: + row_data = [str(c)[:25] if c is not None else "" for c in row[:12]] + print(f"Zeile {row_idx:3} [{kw:12}]: {row_data}") + break + + # Versuche konkrete Werte zu finden + print("\n=== Suche nach konkreten Werten (Zahlen > 1000) ===") + for row_idx, row in enumerate(ws.iter_rows(min_row=1, max_row=200, values_only=True), 1): + numbers = [] + for col_idx, cell in enumerate(row[:20], 1): + if isinstance(cell, (int, float)) and cell > 1000: + numbers.append(f"Col{col_idx}:{cell}") + if numbers: + # Zeile auch mit Text anzeigen + texts = [str(c)[:20] for c in row[:5] if c and isinstance(c, str)] + print(f"Zeile {row_idx:3}: {texts} | Zahlen: {numbers[:5]}") + +except Exception as e: + print(f"Fehler: {e}") + import traceback + traceback.print_exc() diff --git a/analyze_final.py b/analyze_final.py new file mode 100644 index 0000000..971a5b5 --- /dev/null +++ b/analyze_final.py @@ -0,0 +1,136 @@ +import openpyxl +from datetime import datetime +import json + +datei = "Kopie von Kostenrechnung der Nächsten jahre (3).xlsx" +print(f"Lade {datei}...") + +wb = openpyxl.load_workbook(datei, data_only=True) +ws = wb["Tilgung bei Gleichbleibenden Be"] + +row1 = list(ws.iter_rows(min_row=1, max_row=1, values_only=True))[0] +row2 = list(ws.iter_rows(min_row=2, max_row=2, values_only=True))[0] + +# Alle Kredite mit ihren Spalten definieren +# Format: (kredit_name, restschuld_col, tilgung_col, zinsen_col, start_datum_col) +kredite_config = [ + ("Targo Bank", 2, 3, 4, 1), + ("Köpke", 6, 7, None, 1), + ("DSL Bank", 9, 10, 11, 1), + ("PSD Nord", 13, 14, 15, 1), + ("Zingelstr. 14", 20, None, 22, 1), # Keine Tilgung-Spalte, aber Rate in 21? + ("Sparkasse", None, 21, 22, 1), # Sparkasse hat Rate statt Restschuld? + ("Gesamt", 24, 25, 26, 1), + ("Carola", 36, None, None, 1), # Nur Restschuld? + ("Kerstin", 39, None, None, 1), # Nur Restschuld? +] + +print("\n" + "="*80) +print("KOMPLETTE KREDIT-ANALYSE") +print("="*80) + +aktive_kredite = [] +abgezahlte_kredite = [] + +for name, rest_col, tilg_col, zins_col, datum_col in kredite_config: + print(f"\n--- {name} ---") + print(f" Spalten: Rest={rest_col}, Tilgung={tilg_col}, Zinsen={zins_col}") + + if not rest_col: + print(f" [KEINE RESTSCHULD-SPAlTE - Überspringe]") + continue + + # Suche die letzte Zeile mit Daten + letzte_restschuld = None + letztes_datum = None + start_datum = None + erster_wert = None + monatsrate = None + zinssatz = None + + letzte_zeile_mit_datum = None + + for row_idx in range(3, min(ws.max_row + 1, 400)): + datum_cell = ws.cell(row=row_idx, column=datum_col).value + rest_cell = ws.cell(row=row_idx, column=rest_col).value + tilg_cell = ws.cell(row=row_idx, column=tilg_col).value if tilg_col else None + + # Konvertiere Restschuld zu Zahl + if rest_cell is not None: + try: + rest_val = float(rest_cell) + except: + continue + else: + continue + + # Startdatum = erstes Datum mit Restschuld > 0 + if start_datum is None and datum_cell and isinstance(datum_cell, datetime) and rest_val > 0: + start_datum = datum_cell + erster_wert = rest_val + + # Monatsrate aus konstanter Tilgung (erster Wert) + if tilg_cell and isinstance(tilg_cell, (int, float)): + if tilg_cell > 0 and monatsrate is None: + monatsrate = tilg_cell + + # Letzter Wert + letzte_restschuld = rest_val + if datum_cell and isinstance(datum_cell, datetime): + letztes_datum = datum_cell + letzte_zeile_mit_datum = row_idx + + # Status ermitteln + if letzte_restschuld is not None: + # Wenn Restschuld < 10 EUR oder negativ, gilt als abbezahlt + status = "ABGEZAHLT" if letzte_restschuld < 10 else "AKTIV" + + print(f" Ursprungsschuld: {erster_wert:,.2f} EUR" if erster_wert else " Ursprungsschuld: N/A") + print(f" Aktuelle Restschuld: {letzte_restschuld:,.2f} EUR") + print(f" Startdatum: {start_datum.strftime('%d.%m.%Y') if start_datum else 'N/A'}") + print(f" Letztes Datum: {letztes_datum.strftime('%d.%m.%Y') if letztes_datum else 'N/A'}") + print(f" Monatsrate: {monatsrate:,.2f} EUR" if monatsrate else " Monatsrate: N/A") + print(f" STATUS: {status}") + + kredit_info = { + "name": name, + "ursprungsschuld": erster_wert if erster_wert else 0, + "restschuld": letzte_restschuld, + "start_datum": start_datum.strftime("%Y-%m-%d") if start_datum else None, + "monatsrate": monatsrate, + "zinssatz": zinssatz, + "status": status + } + + if status == "AKTIV": + aktive_kredite.append(kredit_info) + else: + abgezahlte_kredite.append(kredit_info) + else: + print(f" [Keine Daten gefunden]") + +print("\n" + "="*80) +print("ZUSAMMENFASSUNG") +print("="*80) + +print(f"\n>>> AKTIVE KREDITE ({len(aktive_kredite)}):") +for k in aktive_kredite: + print(f" - {k['name']}: {k['restschuld']:,.2f} EUR Restschuld") + +print(f"\n>>> ABGEZAHLT ({len(abgezahlte_kredite)}):") +for k in abgezahlte_kredite: + print(f" - {k['name']}") + +print("\n" + "="*80) +print("JSON für Import (nur AKTIVE):") +print("="*80) +print(json.dumps(aktive_kredite, indent=2, default=str)) + +# Speichere als Datei +with open("kredite_analyse.json", "w", encoding="utf-8") as f: + json.dump({ + "aktiv": aktive_kredite, + "abgezahlt": abgezahlte_kredite + }, f, indent=2, default=str, ensure_ascii=False) + +print("\n✅ Gespeichert in kredite_analyse.json") diff --git a/analyze_kerstin.py b/analyze_kerstin.py new file mode 100644 index 0000000..78ee6af --- /dev/null +++ b/analyze_kerstin.py @@ -0,0 +1,51 @@ +import openpyxl +from datetime import datetime + +EXCEL_FILE = r"C:\Users\renet\.openclaw\workspace\buchhaltungs-app\Schulden Kerstin.xlsx" + +print(f"Lade: {EXCEL_FILE}") +wb = openpyxl.load_workbook(EXCEL_FILE, data_only=True) +ws = wb['Tabelle2'] + +print(f"\nSheet: {ws.title}") +print(f"Zeilen: {ws.max_row}, Spalten: {ws.max_column}") +print("\n" + "="*100) +print("ALLE ZEILEN MIT INHALT:") +print("="*100) + +zahlungen = [] +for row_idx in range(1, min(ws.max_row + 1, 150)): + row = [ws.cell(row=row_idx, column=c).value for c in range(1, 4)] + + datum = row[0] if isinstance(row[0], datetime) else None + notiz = row[1] if row[1] else None + betrag = row[2] if isinstance(row[2], (int, float)) else None + + if betrag is not None: + print(f"Zeile {row_idx}: Datum={datum}, Notiz={notiz}, Betrag={betrag}") + zahlungen.append({ + 'zeile': row_idx, + 'datum': datum, + 'notiz': notiz, + 'betrag': betrag + }) + +print(f"\n{'='*100}") +print(f"INSGESAMT: {len(zahlungen)} Einträge mit Betrag") +print(f"{'='*100}") + +# Gruppiere nach Jahr +by_year = {} +for z in zahlungen: + if z['datum']: + year = z['datum'].year + by_year[year] = by_year.get(year, 0) + 1 + +print("\nNach Jahr:") +for year, count in sorted(by_year.items()): + print(f" {year}: {count} Einträge") + +print("\nOhne Datum:") +ohne_datum = [z for z in zahlungen if z['datum'] is None] +for z in ohne_datum: + print(f" Zeile {z['zeile']}: {z['notiz']} - {z['betrag']}€") diff --git a/analyze_korrigiert.py b/analyze_korrigiert.py new file mode 100644 index 0000000..6104daf --- /dev/null +++ b/analyze_korrigiert.py @@ -0,0 +1,150 @@ +import openpyxl +from datetime import datetime +import json + +datei = "Kopie von Kostenrechnung der Nächsten jahre (3).xlsx" +print(f"Lade {datei}...") + +wb = openpyxl.load_workbook(datei, data_only=True) +ws = wb["Tilgung bei Gleichbleibenden Be"] + +row1 = list(ws.iter_rows(min_row=1, max_row=1, values_only=True))[0] +row2 = list(ws.iter_rows(min_row=2, max_row=2, values_only=True))[0] + +# Kredite mit ihren Spalten - basierend auf der Header-Struktur +# Format: (kredit_name, restschuld_col, tilgung_col, zinsen_col) +# Spalten sind 1-basiert + +kredite_config = [ + ("Targo Bank", 2, 3, 4), + ("Köpke", 6, 7, 8), + ("DSL Bank", 9, 10, 11), + ("PSD Nord", 13, 14, 15), + ("Zingelstr. 14", 20, 21, 22), # Rate in 21 + ("Carola", 36, 37, 38), + ("Kerstin", 39, 40, 41), + ("PVCreditplus", 42, 43, 44), # Gefunden in Zeile 2, Spalte 42 + ("Sparkasse", 45, 46, 47), +] + +print("\n" + "="*80) +print("KORRIGIERTE KREDIT-ANALYSE") +print("="*80) + +aktive_kredite = [] +abgezahlte_kredite = [] + +# Aktuelles Datum für Vergleich +heute = datetime(2026, 4, 20) + +for name, rest_col, tilg_col, zins_col in kredite_config: + print(f"\n--- {name} ---") + print(f" Spalten: Rest={rest_col}, Tilgung={tilg_col}, Zinsen={zins_col}") + + # Prüfe Header + rest_header = row2[rest_col-1] if rest_col-1 < len(row2) else None + print(f" Header Restschuld-Spalte: {rest_header}") + + if rest_header != "Restschuld": + print(f" [WARNUNG: Header ist '{rest_header}', nicht 'Restschuld']") + + # Suche die relevanten Daten + start_datum = None + start_restschuld = None + aktuelle_restschuld = None + aktuelles_datum = None + monatsrate = None + zinssatz = None + + # Finde erstes Datum mit Restschuld + for row_idx in range(3, min(ws.max_row + 1, 500)): + datum_cell = ws.cell(row=row_idx, column=1).value + rest_cell = ws.cell(row=row_idx, column=rest_col).value + + if datum_cell and isinstance(datum_cell, datetime): + if rest_cell is not None: + try: + rest_val = float(rest_cell) + if rest_val > 0 and start_datum is None: + start_datum = datum_cell + start_restschuld = rest_val + print(f" Erster Wert: {rest_val:,.2f} EUR am {datum_cell.strftime('%d.%m.%Y')}") + + # Monatsrate aus nächster Zeile + next_tilg = ws.cell(row=row_idx+1, column=tilg_col).value if tilg_col else None + if next_tilg and isinstance(next_tilg, (int, float)) and next_tilg > 0: + monatsrate = next_tilg + print(f" Monatsrate: {monatsrate:,.2f} EUR") + break + except: + continue + + # Finde aktuellen Stand (April 2026 oder letzter Wert) + for row_idx in range(3, ws.max_row + 1): + datum_cell = ws.cell(row=row_idx, column=1).value + rest_cell = ws.cell(row=row_idx, column=rest_col).value + + if datum_cell and isinstance(datum_cell, datetime): + if rest_cell is not None: + try: + rest_val = float(rest_cell) + # Nimm den Wert für April 2026 oder den letzten vorhandenen + if datum_cell.year == 2026 and datum_cell.month == 4: + aktuelle_restschuld = rest_val + aktuelles_datum = datum_cell + break + elif datum_cell < heute: + aktuelle_restschuld = rest_val + aktuelles_datum = datum_cell + except: + continue + + if aktuelle_restschuld is not None: + # Status ermitteln + status = "ABGEZAHLT" if aktuelle_restschuld < 100 else "AKTIV" + + print(f" Aktuelle Restschuld ({aktuelles_datum.strftime('%d.%m.%Y')}): {aktuelle_restschuld:,.2f} EUR") + print(f" STATUS: {status}") + + kredit_info = { + "name": name, + "ursprungsschuld": start_restschuld if start_restschuld else aktuelle_restschuld, + "restschuld": aktuelle_restschuld, + "start_datum": start_datum.strftime("%Y-%m-%d") if start_datum else None, + "monatsrate": monatsrate, + "zinssatz": zinssatz, + "status": status + } + + if status == "AKTIV": + aktive_kredite.append(kredit_info) + else: + abgezahlte_kredite.append(kredit_info) + else: + print(f" [Keine Daten gefunden]") + +print("\n" + "="*80) +print("ZUSAMMENFASSUNG") +print("="*80) + +print(f"\n>>> AKTIVE KREDITE ({len(aktive_kredite)}):") +for k in aktive_kredite: + print(f" - {k['name']}: {k['restschuld']:,.2f} EUR Restschuld") + +print(f"\n>>> ABGEZAHLT ({len(abgezahlte_kredite)}):") +for k in abgezahlte_kredite: + print(f" - {k['name']}") + +print("\n" + "="*80) +print("JSON für Import (nur AKTIVE):") +print("="*80) +print(json.dumps(aktive_kredite, indent=2, default=str)) + +# Speichere als Datei +with open("kredite_analyse_korrigiert.json", "w", encoding="utf-8") as f: + json.dump({ + "aktiv": aktive_kredite, + "abgezahlt": abgezahlte_kredite + }, f, indent=2, default=str, ensure_ascii=False) + +print("\nGespeichert in kredite_analyse_korrigiert.json") diff --git a/analyze_kredite_v2.py b/analyze_kredite_v2.py new file mode 100644 index 0000000..be20970 --- /dev/null +++ b/analyze_kredite_v2.py @@ -0,0 +1,145 @@ +import openpyxl +from datetime import datetime + +datei = "Kopie von Kostenrechnung der Nächsten jahre (3).xlsx" +print(f"Lade {datei}...") + +wb = openpyxl.load_workbook(datei, data_only=True) +ws = wb["Tilgung bei Gleichbleibenden Be"] + +# Struktur: Kredite sind nebeneinander, jeweils 3-4 Spalten +# Zeile 1: Kreditnamen +# Zeile 2: Headers (Restschuld, Tilgung, Zinsen) +# Zeile 3+: Monatliche Daten + +# Lese Zeile 1 um Kreditnamen zu finden +print("\n=== KREDITE IM SHEET ===") +row1 = list(ws.iter_rows(min_row=1, max_row=1, values_only=True))[0] + +kredite = {} +for col_idx, cell in enumerate(row1, 1): + if cell and isinstance(cell, str) and cell.strip(): + name = cell.strip() + # Finde Header für diesen Kredit (Zeile 2) + headers = list(ws.iter_rows(min_row=2, max_row=2, values_only=True))[0] + + # Spalten für diesen Kredit + restschuld_col = None + tilgung_col = None + zinsen_col = None + + # Schaue die nächsten 3-4 Spalten für Headers + for h_idx in range(col_idx-1, min(col_idx+3, len(headers))): + if headers[h_idx] == "Restschuld": + restschuld_col = h_idx + 1 # 1-based + elif headers[h_idx] == "Tilgung": + tilgung_col = h_idx + 1 + elif headers[h_idx] == "Zinsen": + zinsen_col = h_idx + 1 + + if restschuld_col: + kredite[name] = { + "restschuld_col": restschuld_col, + "tilgung_col": tilgung_col, + "zinsen_col": zinsen_col, + "start_col": col_idx + } + print(f" {name}: Restschuld in Spalte {restschuld_col}") + +print(f"\nInsgesamt {len(kredite)} Kredite gefunden\n") + +# Analysiere jeden Kredit +print("="*80) +print("ANALYSE JEDES KREDITS - Aktuelle Restschuld") +print("="*80) + +aktive_kredite = [] +abgezahlte_kredite = [] + +for name, cols in kredite.items(): + rest_col = cols["restschuld_col"] + tilg_col = cols["tilgung_col"] + + # Suche letzten Eintrag mit Restschuld + letzte_restschuld = None + letztes_datum = None + start_datum = None + monatsrate = None + zinssatz = None + + for row_idx in range(3, ws.max_row + 1): + datum_cell = ws.cell(row=row_idx, column=1).value + rest_cell = ws.cell(row=row_idx, column=rest_col).value + tilg_cell = ws.cell(row=row_idx, column=tilg_col).value if tilg_col else None + + # Startdatum = erstes Datum + if start_datum is None and datum_cell and isinstance(datum_cell, datetime): + start_datum = datum_cell + + # Monatsrate aus Tilgung-Spalte (wenn konstant) + if tilg_cell and isinstance(tilg_cell, (int, float)) and tilg_cell > 0: + if monatsrate is None: + monatsrate = tilg_cell + + if rest_cell is not None and isinstance(rest_cell, (int, float)): + if rest_cell > 0 or letzte_restschuld is None: + letzte_restschuld = rest_cell + letztes_datum = datum_cell + + # Berechne Zinssatz aus den Daten + if letzte_restschuld is not None and monatsrate: + # Versuche Zinssatz zu extrahieren + # Aus historischen Daten berechnen + for row_idx in range(3, min(30, ws.max_row)): + rest = ws.cell(row=row_idx, column=rest_col).value + zins = ws.cell(row=row_idx, column=cols.get("zinsen_col", 1)).value if cols.get("zinsen_col") else None + + if rest and zins and isinstance(rest, (int, float)) and isinstance(zins, (int, float)): + if rest > 0 and zins > 0: + # Monatlicher Zinssatz = Zinsen / Restschuld + monatlicher_zins = zins / rest + jaehrlicher_zins = monatlicher_zins * 12 * 100 + zinssatz = round(jaehrlicher_zins, 2) + break + + status = "ABGEZAHLT" if letzte_restschuld == 0 or letzte_restschuld is None else "AKTIV" + + kredit_info = { + "name": name, + "restschuld": letzte_restschuld if letzte_restschuld else 0, + "start_datum": start_datum.strftime("%Y-%m-%d") if start_datum else None, + "monatsrate": monatsrate, + "zinssatz": zinssatz, + "letztes_datum": letztes_datum.strftime("%Y-%m-%d") if letztes_datum else None + } + + if status == "AKTIV": + aktive_kredite.append(kredit_info) + else: + abgezahlte_kredite.append(kredit_info) + + print(f"\n{name}:") + print(f" Status: {status}") + print(f" Aktuelle Restschuld: {letzte_restschuld:,.2f} EUR" if letzte_restschuld else " Aktuelle Restschuld: 0,00 EUR") + print(f" Startdatum: {start_datum.strftime('%d.%m.%Y') if start_datum else 'N/A'}") + print(f" Monatsrate: {monatsrate:,.2f} EUR" if monatsrate else " Monatsrate: N/A") + print(f" Geschätzter Zinssatz: {zinssatz}%" if zinssatz else " Zinssatz: N/A") + print(f" Letzter Eintrag: {letztes_datum.strftime('%d.%m.%Y') if letztes_datum else 'N/A'}") + +print("\n" + "="*80) +print("ZUSAMMENFASSUNG") +print("="*80) + +print(f"\n🟢 AKTIVE KREDITE ({len(aktive_kredite)}):") +for k in aktive_kredite: + print(f" - {k['name']}: {k['restschuld']:,.2f} EUR") + +print(f"\n⚫ ABGEZAHLT ({len(abgezahlte_kredite)}):") +for k in abgezahlte_kredite: + print(f" - {k['name']}") + +print("\n" + "="*80) +print("JSON für Import:") +print("="*80) +import json +print(json.dumps(aktive_kredite, indent=2, default=str)) diff --git a/analyze_kredite_v3.py b/analyze_kredite_v3.py new file mode 100644 index 0000000..bfaa37b --- /dev/null +++ b/analyze_kredite_v3.py @@ -0,0 +1,127 @@ +import openpyxl +from datetime import datetime + +datei = "Kopie von Kostenrechnung der Nächsten jahre (3).xlsx" +print(f"Lade {datei}...") + +wb = openpyxl.load_workbook(datei, data_only=True) +ws = wb["Tilgung bei Gleichbleibenden Be"] + +# Zeile 1: Kreditnamen +# Zeile 2: Headers +print("\n=== KREDIT-STRUKTUR (Zeile 1 & 2) ===") + +# Lese Zeile 1 und 2 +row1 = list(ws.iter_rows(min_row=1, max_row=1, values_only=True))[0] +row2 = list(ws.iter_rows(min_row=2, max_row=2, values_only=True))[0] + +# Zeige Spalten 1-30 +print("\nSpaltenübersicht:") +for i in range(30): + val1 = row1[i] if i < len(row1) and row1[i] else "" + val2 = row2[i] if i < len(row2) and row2[i] else "" + if val1 or val2: + print(f" Spalte {i+1:2}: '{val1}' | '{val2}'") + +# Suche nach Kreditnamen in Zeile 1 +kredit_positionen = [] +for col_idx, cell in enumerate(row1, 1): + if cell and isinstance(cell, str) and cell.strip(): + name = cell.strip() + if name not in ['', 'Monat']: + kredit_positionen.append({ + 'name': name, + 'spalte': col_idx, + 'header': row2[col_idx-1] if col_idx-1 < len(row2) else None + }) + +print(f"\n=== Gefundene Kredite: {len(kredit_positionen)} ===") +for kp in kredit_positionen: + print(f" {kp['name']} bei Spalte {kp['spalte']}, Header: {kp['header']}") + +# Für jeden Kredit: Finde Restschuld, Tilgung, Zinsen Spalten +print("\n=== DETAILLIERTE ANALYSE ===") + +for kp in kredit_positionen: + name = kp['name'] + start_col = kp['spalte'] + + # Die nächsten 3 Spalten prüfen + headers = [] + for i in range(start_col-1, min(start_col+2, len(row2))): + headers.append((i+1, row2[i])) + + print(f"\n{name} (Start bei Spalte {start_col}):") + print(f" Headers: {headers}") + + # Finde die relevanten Spalten + restschuld_col = None + tilgung_col = None + zinsen_col = None + + for col_num, header in headers: + if header == "Restschuld": + restschuld_col = col_num + elif header == "Tilgung": + tilgung_col = col_num + elif header == "Zinsen": + zinsen_col = col_num + + print(f" Restschuld: Spalte {restschuld_col}") + print(f" Tilgung: Spalte {tilgung_col}") + print(f" Zinsen: Spalte {zinsen_col}") + + if not restschuld_col: + print(f" [Überspringe - keine Restschuld-Spalte]") + continue + + # Suche die letzte Zeile mit Daten + letzte_restschuld = None + letztes_datum = None + erster_wert = None + erster_wert_datum = None + monatsrate = None + start_datum = None + + for row_idx in range(3, min(ws.max_row + 1, 400)): + datum_cell = ws.cell(row=row_idx, column=1).value + rest_cell = ws.cell(row=row_idx, column=restschuld_col).value + tilg_cell = ws.cell(row=row_idx, column=tilgung_col).value if tilgung_col else None + + # Konvertiere zu Zahl wenn möglich + if rest_cell is not None: + try: + rest_val = float(rest_cell) + except: + continue + else: + continue + + # Startdatum = erstes Datum mit Restschuld + if start_datum is None and datum_cell and isinstance(datum_cell, datetime): + start_datum = datum_cell + erster_wert = rest_val + erster_wert_datum = datum_cell + + # Monatsrate aus konstanter Tilgung + if tilg_cell and isinstance(tilg_cell, (int, float)): + if tilg_cell > 0: + if monatsrate is None: + monatsrate = tilg_cell + + # Letzter Wert + letzte_restschuld = rest_val + if datum_cell and isinstance(datum_cell, datetime): + letztes_datum = datum_cell + + # Status ermitteln + if letzte_restschuld is not None: + status = "ABGEZAHLT" if letzte_restschuld < 10 else "AKTIV" + + print(f" Erster Wert: {erster_wert:,.2f} EUR am {erster_wert_datum.strftime('%d.%m.%Y') if erster_wert_datum else 'N/A'}") + print(f" Letzte Restschuld: {letzte_restschuld:,.2f} EUR") + print(f" Letztes Datum: {letztes_datum.strftime('%d.%m.%Y') if letztes_datum else 'N/A'}") + print(f" Monatsrate: {monatsrate:,.2f} EUR" if monatsrate else " Monatsrate: N/A") + print(f" STATUS: {status}") + else: + print(f" [Keine Daten gefunden]") diff --git a/analyze_kredite_v4.py b/analyze_kredite_v4.py new file mode 100644 index 0000000..3244770 --- /dev/null +++ b/analyze_kredite_v4.py @@ -0,0 +1,78 @@ +import openpyxl +from datetime import datetime + +datei = "Kopie von Kostenrechnung der Nächsten jahre (3).xlsx" +print(f"Lade {datei}...") + +wb = openpyxl.load_workbook(datei, data_only=True) +ws = wb["Tilgung bei Gleichbleibenden Be"] + +# Zeile 1 vollständig lesen +row1 = list(ws.iter_rows(min_row=1, max_row=1, values_only=True))[0] +row2 = list(ws.iter_rows(min_row=2, max_row=2, values_only=True))[0] + +print("\n=== ALLE SPALTEN (1-40) ===") +for i in range(40): + val1 = row1[i] if i < len(row1) and row1[i] else "" + val2 = row2[i] if i < len(row2) and row2[i] else "" + if val1 or val2: + print(f" Spalte {i+1:2}: '{val1}' | '{val2}'") + +# Suche nach allen Kredit-Namen (auch in späteren Spalten) +suchbegriffe = ["Carola", "Kerstin", "PVCreditplus", "PV Creditplus", "Creditplus", "Zingelstr"] + +print("\n=== SUCHE NACH FEHLENDEN KREDITEN ===") +for col_idx, cell in enumerate(row1, 1): + if cell and isinstance(cell, str): + for suchwort in suchbegriffe: + if suchwort.lower() in cell.lower(): + print(f" Gefunden '{cell}' bei Spalte {col_idx}, Header: {row2[col_idx-1] if col_idx-1 < len(row2) else 'N/A'}") + +# Sparkasse genauer prüfen - vielleicht ist es in Spalte 23? +print("\n=== SPARKASSE DETAILS ===") +print("Spalte 21 Header:", row2[20] if len(row2) > 20 else "N/A") +print("Spalte 22 Header:", row2[21] if len(row2) > 21 else "N/A") +print("Spalte 23 Header:", row2[22] if len(row2) > 22 else "N/A") + +# Prüfe Zeile 1 bei Spalte 23 +print("Spalte 21 Name:", row1[20] if len(row1) > 20 else "N/A") +print("Spalte 22 Name:", row1[21] if len(row1) > 21 else "N/A") +print("Spalte 23 Name:", row1[22] if len(row1) > 22 else "N/A") + +# Suche nach "Sparkasse" in Zeile 1 +for col_idx, cell in enumerate(row1, 1): + if cell and isinstance(cell, str) and "sparkasse" in cell.lower(): + print(f"\nSparkasse bei Spalte {col_idx}: '{cell}'") + # Nächste 3 Spalten zeigen + for i in range(col_idx-1, min(col_idx+2, len(row2))): + print(f" Spalte {i+1}: Name='{row1[i]}', Header='{row2[i]}'") + +# Suche nach "Carola" +for col_idx, cell in enumerate(row1, 1): + if cell and isinstance(cell, str) and "carola" in cell.lower(): + print(f"\nCarola bei Spalte {col_idx}: '{cell}'") + for i in range(col_idx-1, min(col_idx+3, len(row2))): + print(f" Spalte {i+1}: Name='{row1[i]}', Header='{row2[i]}'") + +# Suche nach "Kerstin" +for col_idx, cell in enumerate(row1, 1): + if cell and isinstance(cell, str) and "kerstin" in cell.lower(): + print(f"\nKerstin bei Spalte {col_idx}: '{cell}'") + for i in range(col_idx-1, min(col_idx+3, len(row2))): + print(f" Spalte {i+1}: Name='{row1[i]}', Header='{row2[i]}'") + +# Suche nach "PVCreditplus" oder "Creditplus" +for col_idx, cell in enumerate(row1, 1): + if cell and isinstance(cell, str): + if "credit" in cell.lower() or "pvc" in cell.lower() or "plus" in cell.lower(): + print(f"\nCredit/Plus bei Spalte {col_idx}: '{cell}'") + for i in range(col_idx-1, min(col_idx+3, len(row2))): + print(f" Spalte {i+1}: Name='{row1[i]}', Header='{row2[i]}'") + +# Suche nach Zahlungen/Zinsen in Spalte 22-30 +print("\n=== SPALTEN 27-35 ===") +for i in range(26, 35): + if i < len(row1): + val1 = row1[i] if row1[i] else "" + val2 = row2[i] if i < len(row2) and row2[i] else "" + print(f" Spalte {i+1:2}: '{val1}' | '{val2}'") diff --git a/analyze_niki.py b/analyze_niki.py new file mode 100644 index 0000000..536821c --- /dev/null +++ b/analyze_niki.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +""" +Detaillierte Analyse des Niki-Kredits: Excel vs. DB +""" + +import openpyxl +from datetime import datetime + +# Excel-Daten (bereits extrahiert) +excel_data = [ + # (Zeile, Datum, Restschuld, Zinsen, Abgezahlt, Neue_Kosten, Beschreibung) + (7, datetime(2023, 7, 1), -7000, -58.33, None, None, "Start"), + (8, datetime(2023, 8, 1), -7058.33, -58.82, None, None, None), + (9, datetime(2023, 9, 1), -7117.15, -59.31, None, None, None), + (10, datetime(2023, 10, 1), -7176.46, -59.80, None, None, None), + (11, datetime(2023, 11, 1), -7236.27, -60.30, None, None, None), + (12, datetime(2023, 12, 1), -7296.57, -60.80, None, None, None), + (13, datetime(2024, 1, 1), -7357.37, -61.31, None, None, None), + (14, datetime(2024, 2, 1), -7418.68, -61.82, None, None, None), + (15, datetime(2024, 3, 1), -7480.51, -62.34, None, None, None), + (16, datetime(2024, 4, 1), -7542.84, -62.86, 500, None, None), + (17, datetime(2024, 5, 1), -7105.70, -59.21, 500, None, None), # Nach Zahlung + (18, datetime(2024, 6, 1), -6664.92, -55.54, 150, -90, "Kameras"), + (19, datetime(2024, 7, 1), -6660.46, -55.50, 450, -45, "Kameras"), + (20, datetime(2024, 8, 1), -6310.96, -52.59, 500, None, None), + (21, datetime(2024, 9, 1), -5863.55, -48.86, 300, None, None), + (22, datetime(2024, 10, 1), -5612.41, -46.77, 300, -110, "Videorekorder + Zubehör"), + (23, datetime(2024, 11, 1), -5469.18, -45.58, None, None, None), + (24, datetime(2024, 12, 1), -5514.76, -45.96, None, None, None), + (25, datetime(2025, 1, 1), -5560.72, -46.34, None, None, None), + (26, datetime(2025, 2, 1), -5607.06, -46.73, 200, None, None), + (27, datetime(2025, 3, 1), -5453.78, -45.45, None, None, None), + (28, datetime(2025, 4, 1), -5499.23, -45.83, None, None, None), + (29, datetime(2025, 5, 1), -5545.06, -46.21, None, None, None), + (30, datetime(2025, 6, 1), -5591.27, -46.59, 500, -260, "Handyvertag"), + (31, datetime(2025, 7, 1), -5397.86, -44.98, None, None, None), + # ... bis Zeile 57 +] + +# DB-Daten (bereits extrahiert) +db_zahlungen = [ + {"datum": datetime(2024, 4, 1), "betrag": 500.00, "typ": "zahlung_eingang", "notiz": "Zahlung von Niki"}, + {"datum": datetime(2024, 5, 1), "betrag": 500.00, "typ": "zahlung_eingang", "notiz": "Zahlung von Niki"}, + {"datum": datetime(2024, 6, 1), "betrag": 150.00, "typ": "zahlung_eingang", "notiz": "Kameras"}, + {"datum": datetime(2024, 6, 1), "betrag": 90.00, "typ": "auslage", "notiz": "Kameras"}, + {"datum": datetime(2024, 7, 1), "betrag": 450.00, "typ": "zahlung_eingang", "notiz": "Kameras"}, + {"datum": datetime(2024, 7, 1), "betrag": 45.00, "typ": "auslage", "notiz": "Kameras"}, + {"datum": datetime(2024, 8, 1), "betrag": 500.00, "typ": "zahlung_eingang", "notiz": "Zahlung von Niki"}, + {"datum": datetime(2024, 9, 1), "betrag": 300.00, "typ": "zahlung_eingang", "notiz": "Zahlung von Niki"}, + {"datum": datetime(2024, 10, 1), "betrag": 300.00, "typ": "zahlung_eingang", "notiz": "Videorekorder + Zubehör"}, + {"datum": datetime(2024, 10, 1), "betrag": 110.00, "typ": "auslage", "notiz": "Videorekorder + Zubehör"}, + {"datum": datetime(2025, 2, 1), "betrag": 200.00, "typ": "zahlung_eingang", "notiz": "Zahlung von Niki"}, + {"datum": datetime(2025, 6, 1), "betrag": 500.00, "typ": "zahlung_eingang", "notiz": "Handyvertag"}, + {"datum": datetime(2025, 6, 1), "betrag": 260.00, "typ": "auslage", "notiz": "Handyvertag"}, +] + +print("=" * 80) +print("ANALYSE: Niki-Kredit - Excel vs. DB") +print("=" * 80) + +# Berechne Zahlungen aus Excel +print("\n## ZAHLUNGEN/AUSLAGEN aus Excel:") +print("-" * 80) +excel_zahlungen = [] +for row in excel_data: + zeile, datum, restschuld, zinsen, abgezahlt, neue_kosten, beschreibung = row + if abgezahlt: + print(f"Zahlung: {datum.strftime('%Y-%m-%d')} | +{abgezahlt:6.2f} EUR | {beschreibung or 'Zahlung'}") + excel_zahlungen.append({"datum": datum, "betrag": abgezahlt, "typ": "zahlung_eingang", "notiz": beschreibung or "Zahlung"}) + if neue_kosten: + print(f"Auslage: {datum.strftime('%Y-%m-%d')} | {neue_kosten:7.2f} EUR | {beschreibung}") + excel_zahlungen.append({"datum": datum, "betrag": abs(neue_kosten), "typ": "auslage", "notiz": beschreibung}) + +print("\n## VERGLEICH: Excel vs. DB") +print("-" * 80) +print(f"{'Datum':<12} {'Excel Zahlung':>15} {'DB Zahlung':>15} {'Status':<15}") +print("-" * 80) + +# Erstelle Mapping nach Datum +from collections import defaultdict +excel_by_date = defaultdict(list) +db_by_date = defaultdict(list) + +for z in excel_zahlungen: + key = z["datum"].strftime("%Y-%m-%d") + excel_by_date[key].append(z) + +for z in db_zahlungen: + key = z["datum"].strftime("%Y-%m-%d") + db_by_date[key].append(z) + +all_dates = sorted(set(list(excel_by_date.keys()) + list(db_by_date.keys()))) + +fehlend_in_db = [] +zusammenfassung = {"excel": 0, "db": 0, "fehlend": 0} + +for date in all_dates: + excel_list = excel_by_date.get(date, []) + db_list = db_by_date.get(date, []) + + excel_sum = sum(z["betrag"] for z in excel_list) + db_sum = sum(z["betrag"] for z in db_list) + + zusammenfassung["excel"] += excel_sum + zusammenfassung["db"] += db_sum + + status = "OK" if abs(excel_sum - db_sum) < 0.01 else "DIFFERENZ" + if excel_sum > 0 and db_sum == 0: + status = "FEHLT IN DB" + fehlend_in_db.extend(excel_list) + zusammenfassung["fehlend"] += excel_sum + + print(f"{date:<12} {excel_sum:>15.2f} EUR {db_sum:>15.2f} EUR {status}") + +print("-" * 80) +print(f"{'GESAMT':<12} {zusammenfassung['excel']:>15.2f} EUR {zusammenfassung['db']:>15.2f} EUR") +print(f"\nFehlend in DB: {zusammenfassung['fehlend']:.2f} EUR") + +if fehlend_in_db: + print("\n## FEHLENDE EINTRÄGE IN DB:") + print("-" * 80) + for z in fehlend_in_db: + print(f"INSERT INTO kredit_zahlungen (kredit_id, betrag, datum, typ, notiz) VALUES ('4ad8826f-ecb4-443d-aef6-ce9162e5f078', {z['betrag']:.2f}, '{z['datum'].strftime('%Y-%m-%d')}', '{z['typ']}', '{z['notiz']}');") + +# Analyse Restschuld +print("\n" + "=" * 80) +print("RESTSCHULD-ANALYSE") +print("=" * 80) +print(f"Excel (Stand 2023-07-01): -7000.00 EUR") +print(f"DB Restschuld: 4105.00 EUR") +print(f"DB Ursprungsschuld: 7000.00 EUR") +print(f"\nBerechnung: 7000 - Summe(Zahlungen) + Summe(Auslagen) = ?") +print(f"Gesamtzahlungen DB: {zusammenfassung['db']:.2f} EUR") +print(f"Erwartete Restschuld: 7000 - {zusammenfassung['db']:.2f} = {7000 - zusammenfassung['db']:.2f} EUR") +print(f"\nHinweis: Die App scheint die Restschuld anders zu berechnen als Excel.") +print(f"Excel fügt Zinsen hinzu, DB vermutlich ohne/korrigierte Zinsberechnung.") diff --git a/analyze_spalten.py b/analyze_spalten.py new file mode 100644 index 0000000..3b39502 --- /dev/null +++ b/analyze_spalten.py @@ -0,0 +1,28 @@ +import openpyxl + +datei = "Kopie von Kostenrechnung der Nächsten jahre (3).xlsx" +wb = openpyxl.load_workbook(datei, data_only=True) +ws = wb["Tilgung bei Gleichbleibenden Be"] + +row1 = list(ws.iter_rows(min_row=1, max_row=1, values_only=True))[0] +row2 = list(ws.iter_rows(min_row=2, max_row=2, values_only=True))[0] + +print("=== SPALTENÜBERSICHT (alle mit Inhalt) ===") +for i in range(len(row1)): + val1 = row1[i] if i < len(row1) and row1[i] else "" + val2 = row2[i] if i < len(row2) and row2[i] else "" + if val1 or val2: + print(f" Spalte {i+1:2}: Zeile1='{val1}' | Zeile2='{val2}'") + +print("\n=== SUCHE NACH KREDIT-NAMEN ===") +suchbegriffe = ["sparkasse", "carola", "kerstin", "credit", "pvc"] + +for i, (val1, val2) in enumerate(zip(row1, row2)): + if val1 and isinstance(val1, str): + for suchwort in suchbegriffe: + if suchwort.lower() in val1.lower(): + print(f" Gefunden '{val1}' in Spalte {i+1}") + if val2 and isinstance(val2, str): + for suchwort in suchbegriffe: + if suchwort.lower() in val2.lower(): + print(f" Gefunden '{val2}' in Spalte {i+1} (Zeile 2)") diff --git a/analyze_tabelle1.py b/analyze_tabelle1.py new file mode 100644 index 0000000..bd97d41 --- /dev/null +++ b/analyze_tabelle1.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +""" +Analysiert Sheet 'Tabelle1' - wahrscheinlich das Haupt-Sheet mit aktuellen Daten. +""" + +import openpyxl +from datetime import datetime + +datei = "Kopie von Kostenrechnung der Nächsten jahre (3).xlsx" +wb = openpyxl.load_workbook(datei, data_only=True) + +sheet_name = "Tabelle1" +ws = wb[sheet_name] + +print(f"=== SHEET: {sheet_name} ===") +print(f"Max Zeile: {ws.max_row}, Max Spalte: {ws.max_column}") + +# Zeile 1-10: Headers +print("\n=== HEADER (Zeile 1-10) ===") +for row_idx in range(1, 11): + row_data = [] + for col_idx in range(1, 20): + cell = ws.cell(row=row_idx, column=col_idx) + val = cell.value + if val: + if isinstance(val, str): + row_data.append((col_idx, val[:40])) + elif isinstance(val, (int, float)): + row_data.append((col_idx, f"{val:,.2f}")) + elif isinstance(val, datetime): + row_data.append((col_idx, val.strftime("%Y-%m"))) + if row_data: + print(f"Zeile {row_idx}: {row_data}") + +# Suche nach spezifischen Kreditnamen +print("\n=== SUCHE NACH KREDITNAMEN ===") +kredit_namen = ["DSL Bank", "PSD Nord", "Zingelstr", "PVCreditplus", "Sparkasse", + "Targo", "Köpke", "Kopke", "Restschuld", "Kredit"] + +for row_idx in range(1, min(ws.max_row + 1, 200)): + for col_idx in range(1, min(ws.max_column + 1, 30)): + cell = ws.cell(row=row_idx, column=col_idx) + val = cell.value + if val and isinstance(val, str): + for kredit in kredit_namen: + if kredit.lower() in val.lower(): + # Zeige die Zeile + row_context = [] + for c in range(1, 15): + v = ws.cell(row=row_idx, column=c).value + if v: + if isinstance(v, str): + row_context.append(f"{v[:20]}") + elif isinstance(v, (int, float)): + row_context.append(f"{v:,.0f}") + print(f"\nZeile {row_idx}, Spalte {col_idx}: '{val}'") + print(f" Kontext: {row_context[:8]}") + +# Suche nach großen Geldbeträgen (Restschulden > 1000) +print("\n=== GROSSE BETRÄGE (> 10000) IN ERSTEN 100 ZEILEN ===") +gross_betraege = [] +for row_idx in range(1, min(ws.max_row + 1, 100)): + for col_idx in range(1, min(ws.max_column + 1, 30)): + cell = ws.cell(row=row_idx, column=col_idx) + val = cell.value + if val and isinstance(val, (int, float)) and val > 10000: + # Suche nach Label + label = None + for c in range(1, min(30, ws.max_column + 1)): + v = ws.cell(row=row_idx, column=c).value + if v and isinstance(v, str): + label = v + break + gross_betraege.append((row_idx, col_idx, val, label)) + +for row, col, val, label in gross_betraege[:20]: + print(f" Zeile {row}, Spalte {col}: {val:>15,.2f} EUR - Label: {label}") + +# Suche nach den genauen Zielwerten +print("\n=== SUCHE NACH ZIELWERTEN (Toleranz 500 EUR) ===") +targets = [ + ("DSL Bank", 64656.88), + ("PSD Nord", 50384.50), + ("Zingelstr. 14 DSL", 24382.38), + ("Zingelstr. 14 Sparkasse", 8140.11), + ("PVCreditplus", 1666.53) +] + +for row_idx in range(1, ws.max_row + 1): + for col_idx in range(1, min(ws.max_column + 1, 50)): + val = ws.cell(row=row_idx, column=col_idx).value + if val and isinstance(val, (int, float)): + for name, target in targets: + if abs(val - target) < 500: + print(f"\n {name}: {val:,.2f} bei Zeile {row_idx}, Spalte {col_idx}") + # Zeige die Zeile + row_vals = [] + for c in range(1, 20): + v = ws.cell(row=row_idx, column=c).value + if v: + if isinstance(v, str): + row_vals.append(f"S{c}:{v[:15]}") + elif isinstance(v, (int, float)): + row_vals.append(f"S{c}:{v:,.0f}") + print(f" Zeile: {row_vals}") + # Zeige Header + headers = [] + for c in range(1, 20): + h = ws.cell(row=1, column=c).value + if h: + headers.append(f"S{c}:{str(h)[:15]}") + print(f" Header: {headers}") diff --git a/analyze_tilgung_sheet.py b/analyze_tilgung_sheet.py new file mode 100644 index 0000000..b733d77 --- /dev/null +++ b/analyze_tilgung_sheet.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +""" +Analysiert das 'Tilgung bei Gleichbleibenden Be' Sheet genauer. +Sucht nach den Raten: 513,80 / 237,35 / 350 / 1666.53 +""" + +import openpyxl +from datetime import datetime + +datei = "Kopie von Kostenrechnung der Nächsten jahre (3).xlsx" +wb = openpyxl.load_workbook(datei, data_only=True) + +sheet_name = "Tilgung bei Gleichbleibenden Be" +ws = wb[sheet_name] + +print(f"=== SHEET: {sheet_name} ===") +print(f"Max Zeile: {ws.max_row}, Max Spalte: {ws.max_column}") + +# Header (Zeile 1-5) +print("\n=== ZEILE 1-5 (HEADER) ===") +for row_idx in range(1, 6): + row_data = [] + for col_idx in range(1, 30): + cell = ws.cell(row=row_idx, column=col_idx) + val = cell.value + if val: + if isinstance(val, str): + row_data.append(f"S{col_idx}:{val[:15]}") + elif isinstance(val, (int, float)): + row_data.append(f"S{col_idx}:{val:,.2f}") + elif isinstance(val, datetime): + row_data.append(f"S{col_idx}:{val.strftime('%Y-%m')}") + if row_data: + print(f"Zeile {row_idx}: {row_data}") + +# Suche nach den Raten +print("\n=== SUCHE NACH RATEN (513.80, 237.35, 350, 1666.53) ===") +target_rates = [513.80, 237.35, 350, 1666.53, 427] + +for target in target_rates: + print(f"\nSuche nach Rate {target}:") + found = False + for row_idx in range(1, min(ws.max_row + 1, 200)): + for col_idx in range(1, min(ws.max_column + 1, 50)): + val = ws.cell(row=row_idx, column=col_idx).value + if val and isinstance(val, (int, float)): + if abs(val - target) < 1: # Toleranz 1 EUR + bank = ws.cell(row=1, column=col_idx).value + label = ws.cell(row=2, column=col_idx).value + print(f" GEFUNDEN: Zeile {row_idx}, Spalte {col_idx}: {val}") + print(f" Bank: {bank}, Label: {label}") + # Zeige Kontext + context = [] + for c in range(col_idx-2, col_idx+3): + if c > 0: + v = ws.cell(row=row_idx, column=c).value + context.append(f"S{c}:{str(v)[:10] if v else '-'}") + print(f" Kontext: {context}") + found = True + if not found: + print(f" NICHT GEFUNDEN") + +# Lese aktuelle Werte aus den letzten Zeilen +print("\n=== LETZTE ZEILEN MIT DATEN (für aktuelle Restschulden) ===") + +# Finde die letzte Zeile mit Datum +last_date_row = None +for row in range(ws.max_row, 5, -1): + val = ws.cell(row=row, column=1).value + if val and isinstance(val, datetime): + last_date_row = row + break + +print(f"Letzte Zeile mit Datum: {last_date_row} ({ws.cell(row=last_date_row, column=1).value})") + +if last_date_row: + # Lese alle Werte aus der letzten Zeile + print(f"\nWerte in Zeile {last_date_row}:") + for col_idx in range(1, min(ws.max_column + 1, 30)): + val = ws.cell(row=last_date_row, column=col_idx).value + if val and isinstance(val, (int, float)) and val > 100: + bank = ws.cell(row=1, column=col_idx).value + label = ws.cell(row=2, column=col_idx).value + print(f" Spalte {col_idx}: {val:,>12,.2f} - Bank: {bank}, Label: {label}") + +# Zeige auch Zeile 1 Headers +print("\n=== BANKNAMEN IN ZEILE 1 ===") +for col_idx in range(1, 30): + val = ws.cell(row=1, column=col_idx).value + if val: + print(f" Spalte {col_idx:2}: {val}") diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..1b7f021 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,21 @@ +# OCR Backend Service +FROM node:20-alpine + +WORKDIR /app + +# Installiere Tesseract OCR +RUN apk add --no-cache tesseract-ocr tesseract-ocr-data-deu + +# Dependencies +COPY package.json . +RUN npm install + +# App Code +COPY . . + +# Stelle sicher dass public-Verzeichnis existiert +RUN mkdir -p public + +EXPOSE 3000 + +CMD ["node", "server.js"] \ No newline at end of file diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev new file mode 100644 index 0000000..e04ed49 --- /dev/null +++ b/backend/Dockerfile.dev @@ -0,0 +1,8 @@ +FROM node:20-alpine +WORKDIR /app +RUN npm install -g nodemon +COPY package*.json ./ +RUN npm install +COPY . . +EXPOSE 3000 +CMD ["nodemon", "--legacy-watch", "server.js"] diff --git a/backend/fix_server.js b/backend/fix_server.js new file mode 100644 index 0000000..032d289 --- /dev/null +++ b/backend/fix_server.js @@ -0,0 +1,5 @@ +const fs = require('fs'); +let content = fs.readFileSync('server.js', 'utf8'); +content = content.replace(/\r\n/g, '\n'); +fs.writeFileSync('server.js', content, 'utf8'); +console.log('Done'); diff --git a/backend/import-test-brandt2023.js b/backend/import-test-brandt2023.js new file mode 100644 index 0000000..017ac4a --- /dev/null +++ b/backend/import-test-brandt2023.js @@ -0,0 +1,108 @@ +// Import über REST-API statt direkt zu PostgreSQL + +const API_URL = 'http://192.168.0.141:3001/api'; + +// Test-Einheit: Brandt 2023 +const testData = { + jahr: 2023, + wohnung: 'Zingelstr. 14', + mieter: 'Yvonne Brandt', + kaltmiete: 0, + nebenkosten: 501.48, // Summe aus Excel + versicherung: 129.58, // Geb. Vers./Haftpflichtv. + heizkosten: 102.53, // Wartung Heizung + wasser: 0, + muell: 153.85, // Müllabfuhr + sonstiges: 115.53 // Grundsteuer (40.17) + Niederschlagwasser (75.36) +}; + +async function apiCall(endpoint, options = {}) { + const url = `${API_URL}${endpoint}`; + const config = { + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + ...options, + }; + + if (options.body && typeof options.body === 'object') { + config.body = JSON.stringify(options.body); + } + + try { + const response = await fetch(url, config); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(error.error || `HTTP ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error(`API Error (${endpoint}):`, error.message); + throw error; + } +} + +async function importTest() { + console.log('=== TEST-IMPORT: Yvonne Brandt 2023 ===\n'); + console.log('=== ZU IMPORTIERENDE DATEN ==='); + console.log(JSON.stringify(testData, null, 2)); + + console.log('\n=== FÜHRE IMPORT AUS ==='); + + try { + // Erst prüfen, ob Eintrag existiert + const existing = await apiCall('/nebenkosten'); + const brandtEntries = existing.filter(e => e.mieter && e.mieter.includes('Brandt')); + console.log(`Gefunden: ${brandtEntries.length} Einträge für Brandt`); + brandtEntries.forEach(e => { + console.log(` - ${e.jahr}: ${e.mieter} (${e.nebenkosten} €)`); + }); + + // Import + const result = await apiCall('/nebenkosten', { + method: 'POST', + body: testData + }); + + console.log('\n✓ ERFOLGREICH IMPORTIERT:'); + console.log(' ID:', result.id); + console.log(' Jahr:', result.jahr); + console.log(' Wohnung:', result.wohnung); + console.log(' Mieter:', result.mieter); + console.log(' Nebenkosten:', result.nebenkosten, '€'); + console.log(' Versicherung:', result.versicherung, '€'); + console.log(' Heizkosten:', result.heizkosten, '€'); + console.log(' Müll:', result.muell, '€'); + console.log(' Sonstiges:', result.sonstiges, '€'); + + // Verifizieren + const verify = await apiCall('/nebenkosten'); + const imported = verify.find(e => e.id === result.id); + + console.log('\n=== VERIFIZIERUNG ==='); + if (imported) { + console.log('✓ Datensatz in Datenbank bestätigt'); + console.log(' ID:', imported.id); + console.log(' Mieter:', imported.mieter); + console.log(' Jahr:', imported.jahr); + console.log(' Gesamtkosten:', imported.nebenkosten, '€'); + } else { + console.log('✗ Datensatz nicht gefunden!'); + } + + return result; + + } catch (error) { + console.error('\n❌ FEHLER beim Import:', error.message); + throw error; + } +} + +// Führe Import aus +importTest().catch(err => { + console.error('\nImport fehlgeschlagen:', err); + process.exit(1); +}); diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js new file mode 100644 index 0000000..4428aa7 --- /dev/null +++ b/backend/middleware/auth.js @@ -0,0 +1,47 @@ +const jwt = require('jsonwebtoken'); + +const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production'; + +// Middleware: Prüft ob User eingeloggt ist +function authRequired(req, res, next) { + try { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Kein Token vorhanden' }); + } + + const token = authHeader.split(' ')[1]; + const decoded = jwt.verify(token, JWT_SECRET); + + req.user = decoded; + next(); + } catch (error) { + if (error.name === 'JsonWebTokenError') { + return res.status(401).json({ error: 'Token ungültig' }); + } + if (error.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Token abgelaufen' }); + } + console.error('Auth Middleware Error:', error); + return res.status(500).json({ error: 'Server-Fehler' }); + } +} + +// Middleware: Prüft ob User Admin ist +function adminRequired(req, res, next) { + if (!req.user) { + return res.status(401).json({ error: 'Nicht eingeloggt' }); + } + + if (req.user.role !== 'admin') { + return res.status(403).json({ error: 'Zugriff verweigert. Admin-Rechte erforderlich.' }); + } + + next(); +} + +module.exports = { + authRequired, + adminRequired +}; diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..db5fd3f --- /dev/null +++ b/backend/package.json @@ -0,0 +1,24 @@ +{ + "name": "buchhaltung-backend", + "version": "1.0.0", + "description": "OCR Backend für SteuerFlow", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "date-fns": "^3.3.1", + "express": "^4.18.2", + "jsonwebtoken": "^9.0.3", + "multer": "^1.4.5-lts.1", + "pdf-lib": "^1.17.1", + "pg": "^8.11.3", + "sharp": "^0.33.2", + "tesseract.js": "^5.0.5", + "uuid": "^9.0.1", + "xlsx": "^0.18.5" + } +} diff --git a/backend/public/logo.png b/backend/public/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..e8427b3cfdb1261e3c29630c6a26b156169592e7 GIT binary patch literal 20132 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 SsXR!UA-e^J7*y&z!v6yZwtP$g literal 0 HcmV?d00001 diff --git a/backend/routes/auth.js b/backend/routes/auth.js new file mode 100644 index 0000000..d570d74 --- /dev/null +++ b/backend/routes/auth.js @@ -0,0 +1,315 @@ +const express = require('express'); +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); +const { Pool } = require('pg'); +const router = express.Router(); + +const pool = new Pool({ + host: process.env.DB_HOST || 'localhost', + port: process.env.DB_PORT || 5432, + database: process.env.DB_NAME || 'buchhaltung', + user: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || 'postgres', +}); + +const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production'; +const BCRYPT_ROUNDS = 11; + +// Helper: Check if any users exist +async function hasUsers() { + const result = await pool.query('SELECT COUNT(*) FROM users'); + return parseInt(result.rows[0].count) > 0; +} + +// POST /api/auth/setup - Erste Anmeldung (Admin erstellen) +router.post('/setup', async (req, res) => { + try { + // Prüfen ob schon User existieren + const existingUsers = await hasUsers(); + if (existingUsers) { + return res.status(400).json({ error: 'Setup bereits abgeschlossen. Bitte einloggen.' }); + } + + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ error: 'Username und Passwort erforderlich' }); + } + + if (password.length < 6) { + return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' }); + } + + const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS); + + const result = await pool.query( + `INSERT INTO users (username, password_hash, role, is_active) + VALUES ($1, $2, $3, true) + RETURNING id, username, role, created_at`, + [username, passwordHash, 'admin'] + ); + + const user = result.rows[0]; + + const token = jwt.sign( + { userId: user.id, username: user.username, role: user.role }, + JWT_SECRET, + { expiresIn: '7d' } + ); + + res.status(201).json({ + success: true, + message: 'Admin-Account erstellt', + user: { id: user.id, username: user.username, role: user.role }, + token + }); + } catch (error) { + console.error('Setup Error:', error); + if (error.code === '23505') { + return res.status(400).json({ error: 'Username bereits vergeben' }); + } + res.status(500).json({ error: 'Server-Fehler bei der Einrichtung' }); + } +}); + +// POST /api/auth/login - JWT-Login +router.post('/login', async (req, res) => { + try { + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ error: 'Username und Passwort erforderlich' }); + } + + const result = await pool.query( + 'SELECT * FROM users WHERE username = $1 AND is_active = true', + [username] + ); + + if (result.rows.length === 0) { + return res.status(401).json({ error: 'Ungültige Anmeldedaten' }); + } + + const user = result.rows[0]; + const validPassword = await bcrypt.compare(password, user.password_hash); + + if (!validPassword) { + return res.status(401).json({ error: 'Ungültige Anmeldedaten' }); + } + + // Update last_login + await pool.query('UPDATE users SET last_login = NOW() WHERE id = $1', [user.id]); + + const token = jwt.sign( + { userId: user.id, username: user.username, role: user.role }, + JWT_SECRET, + { expiresIn: '7d' } + ); + + res.json({ + success: true, + message: 'Login erfolgreich', + user: { id: user.id, username: user.username, role: user.role }, + token + }); + } catch (error) { + console.error('Login Error:', error); + res.status(500).json({ error: 'Server-Fehler beim Login' }); + } +}); + +// POST /api/auth/logout - Logout (Client-seitig Token entfernen, hier nur Bestätigung) +router.post('/logout', async (req, res) => { + res.json({ success: true, message: 'Logout erfolgreich' }); +}); + +// GET /api/auth/me - Aktueller User +router.get('/me', async (req, res) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Kein Token vorhanden' }); + } + + const token = authHeader.split(' ')[1]; + const decoded = jwt.verify(token, JWT_SECRET); + + const result = await pool.query( + 'SELECT id, username, role, created_at, last_login FROM users WHERE id = $1 AND is_active = true', + [decoded.userId] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'User nicht gefunden' }); + } + + res.json({ + success: true, + user: result.rows[0] + }); + } catch (error) { + console.error('Auth/me Error:', error); + if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Token ungültig oder abgelaufen' }); + } + res.status(500).json({ error: 'Server-Fehler' }); + } +}); + +// POST /api/auth/users - Neuen User anlegen (nur Admin) +router.post('/users', async (req, res) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Kein Token vorhanden' }); + } + + const token = authHeader.split(' ')[1]; + const decoded = jwt.verify(token, JWT_SECRET); + + if (decoded.role !== 'admin') { + return res.status(403).json({ error: 'Nur Admins können User anlegen' }); + } + + const { username, password, role = 'user' } = req.body; + + if (!username || !password) { + return res.status(400).json({ error: 'Username und Passwort erforderlich' }); + } + + if (!['admin', 'user'].includes(role)) { + return res.status(400).json({ error: 'Ungültige Rolle' }); + } + + const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS); + + const result = await pool.query( + `INSERT INTO users (username, password_hash, role, is_active) + VALUES ($1, $2, $3, true) + RETURNING id, username, role, created_at`, + [username, passwordHash, role] + ); + + res.status(201).json({ + success: true, + message: 'User erstellt', + user: result.rows[0] + }); + } catch (error) { + console.error('Create User Error:', error); + if (error.code === '23505') { + return res.status(400).json({ error: 'Username bereits vergeben' }); + } + if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Token ungültig oder abgelaufen' }); + } + res.status(500).json({ error: 'Server-Fehler' }); + } +}); + +// GET /api/auth/users - Alle User auflisten (nur Admin) +router.get('/users', async (req, res) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Kein Token vorhanden' }); + } + + const token = authHeader.split(' ')[1]; + const decoded = jwt.verify(token, JWT_SECRET); + + if (decoded.role !== 'admin') { + return res.status(403).json({ error: 'Nur Admins können User-Liste sehen' }); + } + + const result = await pool.query( + 'SELECT id, username, role, is_active, created_at, last_login FROM users ORDER BY created_at DESC' + ); + + res.json({ + success: true, + users: result.rows + }); + } catch (error) { + console.error('List Users Error:', error); + if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Token ungültig oder abgelaufen' }); + } + res.status(500).json({ error: 'Server-Fehler' }); + } +}); + +// DELETE /api/auth/users/:id - User löschen (nur Admin) +router.delete('/users/:id', async (req, res) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Kein Token vorhanden' }); + } + + const token = authHeader.split(' ')[1]; + const decoded = jwt.verify(token, JWT_SECRET); + + if (decoded.role !== 'admin') { + return res.status(403).json({ error: 'Nur Admins können User löschen' }); + } + + // Cannot delete yourself + if (req.params.id === decoded.userId) { + return res.status(400).json({ error: 'Kann eigenen Account nicht löschen' }); + } + + await pool.query('DELETE FROM users WHERE id = $1', [req.params.id]); + + res.json({ + success: true, + message: 'User gelöscht' + }); + } catch (error) { + console.error('Delete User Error:', error); + if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Token ungültig oder abgelaufen' }); + } + res.status(500).json({ error: 'Server-Fehler' }); + } +}); + +// PUT /api/auth/users/:id/password - Passwort zurücksetzen (nur Admin) +router.put('/users/:id/password', async (req, res) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Kein Token vorhanden' }); + } + + const token = authHeader.split(' ')[1]; + const decoded = jwt.verify(token, JWT_SECRET); + + if (decoded.role !== 'admin') { + return res.status(403).json({ error: 'Nur Admins können Passwörter zurücksetzen' }); + } + + const { newPassword } = req.body; + + if (!newPassword || newPassword.length < 6) { + return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' }); + } + + const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS); + + await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', [passwordHash, req.params.id]); + + res.json({ + success: true, + message: 'Passwort zurückgesetzt' + }); + } catch (error) { + console.error('Reset Password Error:', error); + if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Token ungültig oder abgelaufen' }); + } + res.status(500).json({ error: 'Server-Fehler' }); + } +}); + +module.exports = router; diff --git a/backend/routes/auth.js.unix b/backend/routes/auth.js.unix new file mode 100644 index 0000000..eb3dd73 --- /dev/null +++ b/backend/routes/auth.js.unix @@ -0,0 +1,315 @@ +const express = require('express'); +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); +const { Pool } = require('pg'); +const router = express.Router(); + +const pool = new Pool({ + host: process.env.DB_HOST || 'localhost', + port: process.env.DB_PORT || 5432, + database: process.env.DB_NAME || 'buchhaltung', + user: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || 'postgres', +}); + +const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production'; +const BCRYPT_ROUNDS = 11; + +// Helper: Check if any users exist +async function hasUsers() { + const result = await pool.query('SELECT COUNT(*) FROM users'); + return parseInt(result.rows[0].count) > 0; +} + +// POST /api/auth/setup - Erste Anmeldung (Admin erstellen) +router.post('/setup', async (req, res) => { + try { + // Prüfen ob schon User existieren + const existingUsers = await hasUsers(); + if (existingUsers) { + return res.status(400).json({ error: 'Setup bereits abgeschlossen. Bitte einloggen.' }); + } + + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ error: 'Username und Passwort erforderlich' }); + } + + if (password.length < 6) { + return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' }); + } + + const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS); + + const result = await pool.query( + `INSERT INTO users (username, password_hash, role, is_active) + VALUES ($1, $2, $3, true) + RETURNING id, username, role, created_at`, + [username, passwordHash, 'admin'] + ); + + const user = result.rows[0]; + + const token = jwt.sign( + { userId: user.id, username: user.username, role: user.role }, + JWT_SECRET, + { expiresIn: '7d' } + ); + + res.status(201).json({ + success: true, + message: 'Admin-Account erstellt', + user: { id: user.id, username: user.username, role: user.role }, + token + }); + } catch (error) { + console.error('Setup Error:', error); + if (error.code === '23505') { + return res.status(400).json({ error: 'Username bereits vergeben' }); + } + res.status(500).json({ error: 'Server-Fehler bei der Einrichtung' }); + } +}); + +// POST /api/auth/login - JWT-Login +router.post('/login', async (req, res) => { + try { + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ error: 'Username und Passwort erforderlich' }); + } + + const result = await pool.query( + 'SELECT * FROM users WHERE username = $1 AND is_active = true', + [username] + ); + + if (result.rows.length === 0) { + return res.status(401).json({ error: 'Ungültige Anmeldedaten' }); + } + + const user = result.rows[0]; + const validPassword = await bcrypt.compare(password, user.password_hash); + + if (!validPassword) { + return res.status(401).json({ error: 'Ungültige Anmeldedaten' }); + } + + // Update last_login + await pool.query('UPDATE users SET last_login = NOW() WHERE id = $1', [user.id]); + + const token = jwt.sign( + { userId: user.id, username: user.username, role: user.role }, + JWT_SECRET, + { expiresIn: '7d' } + ); + + res.json({ + success: true, + message: 'Login erfolgreich', + user: { id: user.id, username: user.username, role: user.role }, + token + }); + } catch (error) { + console.error('Login Error:', error); + res.status(500).json({ error: 'Server-Fehler beim Login' }); + } +}); + +// POST /api/auth/logout - Logout (Client-seitig Token entfernen, hier nur Bestätigung) +router.post('/logout', async (req, res) => { + res.json({ success: true, message: 'Logout erfolgreich' }); +}); + +// GET /api/auth/me - Aktueller User +router.get('/me', async (req, res) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Kein Token vorhanden' }); + } + + const token = authHeader.split(' ')[1]; + const decoded = jwt.verify(token, JWT_SECRET); + + const result = await pool.query( + 'SELECT id, username, role, created_at, last_login FROM users WHERE id = $1 AND is_active = true', + [decoded.userId] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'User nicht gefunden' }); + } + + res.json({ + success: true, + user: result.rows[0] + }); + } catch (error) { + console.error('Auth/me Error:', error); + if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Token ungültig oder abgelaufen' }); + } + res.status(500).json({ error: 'Server-Fehler' }); + } +}); + +// POST /api/auth/users - Neuen User anlegen (nur Admin) +router.post('/users', async (req, res) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Kein Token vorhanden' }); + } + + const token = authHeader.split(' ')[1]; + const decoded = jwt.verify(token, JWT_SECRET); + + if (decoded.role !== 'admin') { + return res.status(403).json({ error: 'Nur Admins können User anlegen' }); + } + + const { username, password, role = 'user' } = req.body; + + if (!username || !password) { + return res.status(400).json({ error: 'Username und Passwort erforderlich' }); + } + + if (!['admin', 'user'].includes(role)) { + return res.status(400).json({ error: 'Ungültige Rolle' }); + } + + const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS); + + const result = await pool.query( + `INSERT INTO users (username, password_hash, role, is_active) + VALUES ($1, $2, $3, true) + RETURNING id, username, role, created_at`, + [username, passwordHash, role] + ); + + res.status(201).json({ + success: true, + message: 'User erstellt', + user: result.rows[0] + }); + } catch (error) { + console.error('Create User Error:', error); + if (error.code === '23505') { + return res.status(400).json({ error: 'Username bereits vergeben' }); + } + if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Token ungültig oder abgelaufen' }); + } + res.status(500).json({ error: 'Server-Fehler' }); + } +}); + +// GET /api/auth/users - Alle User auflisten (nur Admin) +router.get('/users', async (req, res) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Kein Token vorhanden' }); + } + + const token = authHeader.split(' ')[1]; + const decoded = jwt.verify(token, JWT_SECRET); + + if (decoded.role !== 'admin') { + return res.status(403).json({ error: 'Nur Admins können User-Liste sehen' }); + } + + const result = await pool.query( + 'SELECT id, username, role, is_active, created_at, last_login FROM users ORDER BY created_at DESC' + ); + + res.json({ + success: true, + users: result.rows + }); + } catch (error) { + console.error('List Users Error:', error); + if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Token ungültig oder abgelaufen' }); + } + res.status(500).json({ error: 'Server-Fehler' }); + } +}); + +// DELETE /api/auth/users/:id - User löschen (nur Admin) +router.delete('/users/:id', async (req, res) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Kein Token vorhanden' }); + } + + const token = authHeader.split(' ')[1]; + const decoded = jwt.verify(token, JWT_SECRET); + + if (decoded.role !== 'admin') { + return res.status(403).json({ error: 'Nur Admins können User löschen' }); + } + + // Cannot delete yourself + if (req.params.id === decoded.userId) { + return res.status(400).json({ error: 'Kann eigenen Account nicht löschen' }); + } + + await pool.query('DELETE FROM users WHERE id = $1', [req.params.id]); + + res.json({ + success: true, + message: 'User gelöscht' + }); + } catch (error) { + console.error('Delete User Error:', error); + if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Token ungültig oder abgelaufen' }); + } + res.status(500).json({ error: 'Server-Fehler' }); + } +}); + +// PUT /api/auth/users/:id/password - Passwort zurücksetzen (nur Admin) +router.put('/users/:id/password', async (req, res) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Kein Token vorhanden' }); + } + + const token = authHeader.split(' ')[1]; + const decoded = jwt.verify(token, JWT_SECRET); + + if (decoded.role !== 'admin') { + return res.status(403).json({ error: 'Nur Admins können Passwörter zurücksetzen' }); + } + + const { newPassword } = req.body; + + if (!newPassword || newPassword.length < 6) { + return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' }); + } + + const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS); + + await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', [passwordHash, req.params.id]); + + res.json({ + success: true, + message: 'Passwort zurückgesetzt' + }); + } catch (error) { + console.error('Reset Password Error:', error); + if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Token ungültig oder abgelaufen' }); + } + res.status(500).json({ error: 'Server-Fehler' }); + } +}); + +module.exports = router; diff --git a/backend/routes/fix_line_endings.js b/backend/routes/fix_line_endings.js new file mode 100644 index 0000000..3248729 --- /dev/null +++ b/backend/routes/fix_line_endings.js @@ -0,0 +1,13 @@ +const fs = require('fs'); + +// Read file +let content = fs.readFileSync(process.argv[1] || 'nebenkosten.js', 'utf8'); + +// Convert to Unix line endings +content = content.replace(/\r\n/g, '\n'); + +// Save +fs.writeFileSync(process.argv[1] || 'nebenkosten.js', content, 'utf8'); + +console.log('Lines:', content.split('\n').length); +console.log('Has vermietung/bilanz:', content.includes("app.get('/api/vermietung/bilanz'")); \ No newline at end of file diff --git a/backend/routes/nebenkosten.js b/backend/routes/nebenkosten.js new file mode 100644 index 0000000..23425a4 --- /dev/null +++ b/backend/routes/nebenkosten.js @@ -0,0 +1,1419 @@ +// API Routes für Nebenkostenabrechnung (Objekte, Mieter, Mietverträge, Kosten, Vorauszahlungen, Abrechnungen) +const { Pool } = require('pg'); + +const pool = new Pool({ + host: process.env.DB_HOST || 'buchhaltung-db', + port: process.env.DB_PORT || 5432, + database: process.env.DB_NAME || 'buchhaltung', + user: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || 'postgres', +}); + +module.exports = (app) => { + + // ========== OBJEKTE ========== + + // Alle Objekte + app.get('/api/objekte', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM objekte ORDER BY name'); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Einzelnes Objekt + app.get('/api/objekte/:id', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM objekte WHERE id = $1', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Objekt erstellen + app.post('/api/objekte', async (req, res) => { + try { + const { name, adresse, plz, ort, wohnflaeche_qm, bemerkung } = req.body; + const result = await pool.query( + 'INSERT INTO objekte (name, adresse, plz, ort, wohnflaeche_qm, bemerkung) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *', + [name, adresse, plz, ort, wohnflaeche_qm || 0, bemerkung] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Objekt aktualisieren + app.put('/api/objekte/:id', async (req, res) => { + try { + const { name, adresse, plz, ort, wohnflaeche_qm, bemerkung } = req.body; + + // Baue dynamisches UPDATE - nur Felder die gesendet wurden + const updates = []; + const values = []; + let paramIndex = 1; + + if (name !== undefined) { updates.push(`name = $${paramIndex++}`); values.push(name); } + if (adresse !== undefined) { updates.push(`adresse = $${paramIndex++}`); values.push(adresse); } + if (plz !== undefined) { updates.push(`plz = $${paramIndex++}`); values.push(plz); } + if (ort !== undefined) { updates.push(`ort = $${paramIndex++}`); values.push(ort); } + if (wohnflaeche_qm !== undefined) { updates.push(`wohnflaeche_qm = $${paramIndex++}`); values.push(wohnflaeche_qm); } + if (bemerkung !== undefined) { updates.push(`bemerkung = $${paramIndex++}`); values.push(bemerkung); } + + if (updates.length === 0) { + return res.status(400).json({ error: 'Keine Felder zum Aktualisieren' }); + } + + values.push(req.params.id); + const query = `UPDATE objekte SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`; + + const result = await pool.query(query, values); + if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Objekt löschen + app.delete('/api/objekte/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM objekte WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== OBJEKTKOSTEN ========== + + // Kosten für ein Objekt laden (optional gefiltert nach Jahr) + app.get('/api/objekte/:id/kosten', async (req, res) => { + try { + const { jahr } = req.query; + let query = 'SELECT * FROM objektkosten WHERE objekt_id = $1'; + const params = [req.params.id]; + + if (jahr) { + query += ' AND jahr = $2'; + params.push(jahr); + } + + query += ' ORDER BY jahr DESC, kategorie ASC'; + + const result = await pool.query(query, params); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Kosten hinzufügen + app.post('/api/objekte/:id/kosten', async (req, res) => { + try { + const { kategorie, betrag, jahr } = req.body; + const result = await pool.query( + 'INSERT INTO objektkosten (objekt_id, kategorie, betrag, jahr) VALUES ($1, $2, $3, $4) RETURNING *', + [req.params.id, kategorie, betrag, jahr] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Kosten löschen + app.delete('/api/objekte/:id/kosten/:kostenId', async (req, res) => { + try { + await pool.query('DELETE FROM objektkosten WHERE id = $1 AND objekt_id = $2', [req.params.kostenId, req.params.id]); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Mieter eines Objekts + app.get('/api/objekte/:id/mieter', async (req, res) => { + try { + const result = await pool.query( + `SELECT m.*, mv.id as mietvertrag_id, mv.wohnflaeche_qm, mv.kaltmiete, + mv.nebenkosten_vorauszahlung, mv.vertragsbeginn, mv.vertragsende, mv.ist_aktuell + FROM mieter m + JOIN mietvertraege mv ON mv.mieter_id = m.id + WHERE mv.objekt_id = $1 + ORDER BY m.name`, + [req.params.id] + ); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== MIETER ========== + + app.get('/api/mieter', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM mieter ORDER BY name'); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.get('/api/mieter/:id', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM mieter WHERE id = $1', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/mieter', async (req, res) => { + try { + const { name, email, telefon, adresse } = req.body; + const result = await pool.query( + 'INSERT INTO mieter (name, email, telefon, adresse) VALUES ($1, $2, $3, $4) RETURNING *', + [name, email, telefon, adresse] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.put('/api/mieter/:id', async (req, res) => { + try { + const { name, email, telefon, adresse } = req.body; + const result = await pool.query( + 'UPDATE mieter SET name = $1, email = $2, telefon = $3, adresse = $4 WHERE id = $5 RETURNING *', + [name, email, telefon, adresse, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.delete('/api/mieter/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM mieter WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Mieter mit Mietvertrag erstellen (Combined) + app.post('/api/mieter-mit-vertrag', async (req, res) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const { name, email, telefon, adresse, objekt_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn, vertragsende } = req.body; + + // Mieter erstellen + const mieterResult = await client.query( + 'INSERT INTO mieter (name, email, telefon, adresse) VALUES ($1, $2, $3, $4) RETURNING *', + [name, email, telefon, adresse] + ); + const mieter = mieterResult.rows[0]; + + // Mietvertrag erstellen + const vertragResult = await client.query( + `INSERT INTO mietvertraege (objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn, vertragsende, ist_aktuell) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, + [objekt_id, mieter.id, wohnflaeche_qm || 0, kaltmiete || 0, nebenkosten_vorauszahlung || 0, vertragsbeginn, vertragsende || null, !vertragsende] + ); + + await client.query('COMMIT'); + res.json({ mieter, mietvertrag: vertragResult.rows[0] }); + } catch (error) { + await client.query('ROLLBACK'); + res.status(500).json({ error: error.message }); + } finally { + client.release(); + } + }); + + // Mieter mit Mietvertrag aktualisieren (Combined) + app.put('/api/mieter/:id/mit-vertrag', async (req, res) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const { name, email, telefon, adresse, objekt_id, kaltmiete, vorauszahlung, vertragsbeginn, vertragsende } = req.body; + + // ist_aktuell automatisch berechnen: true wenn kein vertragsende oder vertragsende in Zukunft + const istAktuell = !vertragsende || new Date(vertragsende) >= new Date(); + + // Mieter aktualisieren + const mieterResult = await client.query( + 'UPDATE mieter SET name = $1, email = $2, telefon = $3, adresse = $4 WHERE id = $5 RETURNING *', + [name, email, telefon, adresse, req.params.id] + ); + if (mieterResult.rows.length === 0) { + await client.query('ROLLBACK'); + return res.status(404).json({ error: 'Mieter nicht gefunden' }); + } + + // Aktiven Mietvertrag finden und aktualisieren + const vertragResult = await client.query( + `UPDATE mietvertraege SET + objekt_id = $1, kaltmiete = $2, nebenkosten_vorauszahlung = $3, + vertragsbeginn = $4, vertragsende = $5, ist_aktuell = $6 + WHERE mieter_id = $7 AND (ist_aktuell = true OR id = ( + SELECT id FROM mietvertraege WHERE mieter_id = $7 ORDER BY vertragsbeginn DESC LIMIT 1 + )) + RETURNING *`, + [objekt_id, kaltmiete, vorauszahlung, vertragsbeginn, vertragsende || null, istAktuell, req.params.id] + ); + + await client.query('COMMIT'); + res.json({ mieter: mieterResult.rows[0], mietvertrag: vertragResult.rows[0] || null }); + } catch (error) { + await client.query('ROLLBACK'); + res.status(500).json({ error: error.message }); + } finally { + client.release(); + } + }); + + // Mietverträge eines Mieters + app.get('/api/mieter/:id/mietvertraege', async (req, res) => { + try { + const result = await pool.query( + `SELECT mv.*, o.name as objekt_name + FROM mietvertraege mv + JOIN objekte o ON o.id = mv.objekt_id + WHERE mv.mieter_id = $1 + ORDER BY mv.vertragsbeginn DESC`, + [req.params.id] + ); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== MIETVERTRÄGE ========== + + app.get('/api/mietvertraege', async (req, res) => { + try { + const result = await pool.query( + `SELECT mv.*, m.name as mieter_name, o.name as objekt_name + FROM mietvertraege mv + JOIN mieter m ON m.id = mv.mieter_id + JOIN objekte o ON o.id = mv.objekt_id + ORDER BY mv.vertragsbeginn DESC` + ); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== MIETVERTRÄGE ========== + + app.get('/api/mietvertraege', async (req, res) => { + try { + const { objekt_id, ist_aktuell } = req.query; + let query = `SELECT mv.*, o.name as objekt_name, o.adresse, o.plz, o.ort, + m.name as mieter_name, m.email as mieter_email + FROM mietvertraege mv + JOIN objekte o ON o.id = mv.objekt_id + JOIN mieter m ON m.id = mv.mieter_id`; + const values = []; + const conditions = []; + + if (objekt_id) { + conditions.push(`mv.objekt_id = $${values.length + 1}`); + values.push(objekt_id); + } + if (ist_aktuell !== undefined) { + conditions.push(`mv.ist_aktuell = $${values.length + 1}`); + values.push(ist_aktuell === 'true'); + } + + if (conditions.length > 0) { + query += ' WHERE ' + conditions.join(' AND '); + } + + query += ' ORDER BY mv.vertragsbeginn DESC'; + + const result = await pool.query(query, values); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/mietvertraege', async (req, res) => { + try { + const { objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn } = req.body; + const result = await pool.query( + `INSERT INTO mietvertraege (objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, + [objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung || 0, vertragsbeginn] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.put('/api/mietvertraege/:id', async (req, res) => { + try { + const { wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsende, ist_aktuell } = req.body; + const result = await pool.query( + `UPDATE mietvertraege SET wohnflaeche_qm = $1, kaltmiete = $2, nebenkosten_vorauszahlung = $3, + vertragsende = $4, ist_aktuell = $5 WHERE id = $6 RETURNING *`, + [wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsende, ist_aktuell !== undefined ? ist_aktuell : true, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/mietvertraege/:id/beenden', async (req, res) => { + try { + const { vertragsende } = req.body; + const result = await pool.query( + `UPDATE mietvertraege SET vertragsende = $1, ist_aktuell = false WHERE id = $2 RETURNING *`, + [vertragsende, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.delete('/api/mietvertraege/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM mietvertraege WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== OBJEKTKOSTEN ========== + + app.get('/api/objektkosten', async (req, res) => { + try { + const { objekt_id, jahr } = req.query; + let query = 'SELECT ok.*, o.name as objekt_name FROM objektkosten ok JOIN objekte o ON o.id = ok.objekt_id'; + const values = []; + const conditions = []; + + if (objekt_id) { + conditions.push(`ok.objekt_id = $${values.length + 1}`); + values.push(objekt_id); + } + if (jahr) { + conditions.push(`ok.jahr = $${values.length + 1}`); + values.push(parseInt(jahr)); + } + + if (conditions.length > 0) { + query += ' WHERE ' + conditions.join(' AND '); + } + + query += ' ORDER BY ok.jahr DESC, ok.kategorie, ok.datum'; + + const result = await pool.query(query, values); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/objektkosten', async (req, res) => { + try { + const { objekt_id, kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung } = req.body; + const result = await pool.query( + `INSERT INTO objektkosten (objekt_id, kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, + [objekt_id, kategorie, bezeichnung, betrag, datum, jahr || new Date().getFullYear(), verteilung || 'qm', bemerkung] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.put('/api/objektkosten/:id', async (req, res) => { + try { + const { kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung } = req.body; + const result = await pool.query( + `UPDATE objektkosten SET kategorie = $1, bezeichnung = $2, betrag = $3, datum = $4, jahr = $5, verteilung = $6, bemerkung = $7 + WHERE id = $8 RETURNING *`, + [kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Kosten nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.delete('/api/objektkosten/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM objektkosten WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Kosten nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== VORAUSZAHLUNGEN ========== + + app.get('/api/vorauszahlungen', async (req, res) => { + try { + const { objekt_id, mieter_id, jahr } = req.query; + let query = `SELECT v.*, o.name as objekt_name, m.name as mieter_name + FROM vorauszahlungen v + JOIN objekte o ON o.id = v.objekt_id + JOIN mieter m ON m.id = v.mieter_id`; + const values = []; + const conditions = []; + + if (objekt_id) { + conditions.push(`v.objekt_id = $${values.length + 1}`); + values.push(objekt_id); + } + if (mieter_id) { + conditions.push(`v.mieter_id = $${values.length + 1}`); + values.push(mieter_id); + } + if (jahr) { + conditions.push(`v.jahr = $${values.length + 1}`); + values.push(parseInt(jahr)); + } + + if (conditions.length > 0) { + query += ' WHERE ' + conditions.join(' AND '); + } + + query += ' ORDER BY v.jahr DESC, v.monat'; + + const result = await pool.query(query, values); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/vorauszahlungen', async (req, res) => { + try { + const { objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung } = req.body; + const result = await pool.query( + `INSERT INTO vorauszahlungen (objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, + [objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Bulk: Vorauszahlungen für Jahr erstellen + app.post('/api/vorauszahlungen/bulk', async (req, res) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const { objektId, mieterId, jahr, monatlicherBetrag } = req.body; + const results = []; + + for (let monat = 1; monat <= 12; monat++) { + const result = await client.query( + `INSERT INTO vorauszahlungen (objekt_id, mieter_id, jahr, monat, betrag) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (objekt_id, mieter_id, jahr, monat) + DO UPDATE SET betrag = $5 + RETURNING *`, + [objektId, mieterId, jahr, monat, monatlicherBetrag] + ); + results.push(result.rows[0]); + } + + await client.query('COMMIT'); + res.json({ success: true, created: results.length, vorauszahlungen: results }); + } catch (error) { + await client.query('ROLLBACK'); + res.status(500).json({ error: error.message }); + } finally { + client.release(); + } + }); + + app.put('/api/vorauszahlungen/:id', async (req, res) => { + try { + const { betrag, bezahlt_am, bemerkung } = req.body; + const result = await pool.query( + `UPDATE vorauszahlungen SET betrag = $1, bezahlt_am = $2, bemerkung = $3 WHERE id = $4 RETURNING *`, + [betrag, bezahlt_am, bemerkung, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Vorauszahlung nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.delete('/api/vorauszahlungen/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM vorauszahlungen WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Vorauszahlung nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== VERMIETUNGSBILANZ ========== + + // Hilfsfunktion: Berechne überlappende Monate zwischen zwei Zeiträumen + function calculateOverlappingMonths(startA, endA, startB, endB) { + const start = new Date(Math.max(startA.getTime(), startB.getTime())); + const end = new Date(Math.min(endA.getTime(), endB.getTime())); + + if (start > end) return 0; + + // Monate berechnen (inklusive Start- und Endmonat) + const months = (end.getFullYear() - start.getFullYear()) * 12 + + (end.getMonth() - start.getMonth()) + 1; + return Math.max(0, months); + } + + // Einnahmen und Ausgaben pro Objekt für Vermietungs-Bilanz + app.get('/api/vermietungsbilanz', async (req, res) => { + try { + const { jahr = new Date().getFullYear() } = req.query; + + const yearStart = new Date(jahr, 0, 1); // 1. Januar + const yearEnd = new Date(jahr, 11, 31); // 31. Dezember + + // Alle Objekte laden + const objekteResult = await pool.query('SELECT id, name FROM objekte ORDER BY name'); + + const bilanzDaten = []; + let gesamtEinnahmen = 0; + let gesamtAusgaben = 0; + + for (const objekt of objekteResult.rows) { + // === EINNAHMEN: Alle Mietverträge mit Zeitraumberechnung === + // Mietverträge laden die im Jahr aktiv waren + const vertraegeResult = await pool.query(` + SELECT mv.*, m.name as mieter_name + FROM mietvertraege mv + JOIN mieter m ON m.id = mv.mieter_id + WHERE mv.objekt_id = $1 + AND mv.vertragsbeginn <= $2 + AND (mv.vertragsende IS NULL OR mv.vertragsende >= $3) + `, [objekt.id, yearEnd, yearStart]); + + // Einnahmen pro Vertrag berechnen + let objektEinnahmen = 0; + const einnahmenDetails = []; + + for (const vertrag of vertraegeResult.rows) { + const vertragsBeginn = new Date(vertrag.vertragsbeginn); + const vertragsEnde = vertrag.vertragsende ? new Date(vertrag.vertragsende) : null; + + // Überlappende Monate berechnen + const effectiveStart = vertragsBeginn > yearStart ? vertragsBeginn : yearStart; + const effectiveEnd = (vertragsEnde && vertragsEnde < yearEnd) ? vertragsEnde : yearEnd; + + const months = calculateOverlappingMonths( + vertragsBeginn, vertragsEnde || new Date(2099, 11, 31), + yearStart, yearEnd + ); + + const kaltmiete = parseFloat(vertrag.kaltmiete) || 0; + const vertragEinnahmen = kaltmiete * months; + objektEinnahmen += vertragEinnahmen; + + einnahmenDetails.push({ + mieter_id: vertrag.mieter_id, + mieter_name: vertrag.mieter_name, + vertragsbeginn: vertrag.vertragsbeginn, + vertragsende: vertrag.vertragsende, + kaltmiete_monat: kaltmiete, + monate: months, + summe: Math.round(vertragEinnahmen * 100) / 100 + }); + } + + // === AUSGABEN: Nebenkosten für das Jahr === + const ausgabenResult = await pool.query(` + SELECT COALESCE(SUM(betrag), 0) as nebenkosten + FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 + `, [objekt.id, jahr]); + const nebenkosten = parseFloat(ausgabenResult.rows[0].nebenkosten); + + // Detaillierte Ausgaben nach Kategorie + const kategorienResult = await pool.query(` + SELECT kategorie, SUM(betrag) as betrag + FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 + GROUP BY kategorie + ORDER BY SUM(betrag) DESC + `, [objekt.id, jahr]); + + // Detaillierte Ausgaben für Modal + const ausgabenDetailsResult = await pool.query(` + SELECT id, kategorie, bezeichnung, betrag, datum + FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 + ORDER BY betrag DESC + `, [objekt.id, jahr]); + + const bilanz = objektEinnahmen - nebenkosten; + gesamtEinnahmen += objektEinnahmen; + gesamtAusgaben += nebenkosten; + + // Anzahl aktiver Mieter + const aktiveMieter = einnahmenDetails.length; + + bilanzDaten.push({ + objekt_id: objekt.id, + objekt_name: objekt.name, + mieteinnahmen: Math.round(objektEinnahmen * 100) / 100, + nebenkosten: Math.round(nebenkosten * 100) / 100, + bilanz: Math.round(bilanz * 100) / 100, + anzahl_mieter: aktiveMieter, + kategorien: kategorienResult.rows, + // Details für Drill-down + einnahmen_details: einnahmenDetails, + ausgaben_details: ausgabenDetailsResult.rows + }); + } + + res.json({ + jahr: parseInt(jahr), + objekte: bilanzDaten, + gesamt_einnahmen: Math.round(gesamtEinnahmen * 100) / 100, + gesamt_ausgaben: Math.round(gesamtAusgaben * 100) / 100, + gesamt_bilanz: Math.round((gesamtEinnahmen - gesamtAusgaben) * 100) / 100 + }); + } catch (error) { + console.error('Vermietungsbilanz Error:', error); + res.status(500).json({ error: error.message }); + } + }); + + // Alias für Frontend-Kompatibilität mit DETAILS + app.get('/api/vermietung/bilanz', async (req, res) => { + try { + const { jahr = new Date().getFullYear() } = req.query; + const yearStart = new Date(jahr, 0, 1); + const yearEnd = new Date(jahr, 11, 31); + + // Alle Objekte laden + const objekteResult = await pool.query('SELECT id, name, adresse FROM objekte ORDER BY name'); + + const bilanzDaten = []; + let gesamtEinnahmen = 0; + let gesamtAusgaben = 0; + + for (const objekt of objekteResult.rows) { + // === EINNAHMEN: Detaillierte Abfrage === + const vertraegeResult = await pool.query(` + SELECT mv.*, m.name as mieter_name + FROM mietvertraege mv + JOIN mieter m ON m.id = mv.mieter_id + WHERE mv.objekt_id = $1 + AND mv.vertragsbeginn <= $2 + AND (mv.vertragsende IS NULL OR mv.vertragsende >= $3) + ORDER BY m.name + `, [objekt.id, yearEnd, yearStart]); + + // Einnahmen berechnen mit Detail-Tracking + let objektEinnahmen = 0; + const einnahmenDetails = []; + + for (const vertrag of vertraegeResult.rows) { + const vertragsBeginn = new Date(vertrag.vertragsbeginn); + const vertragsEnde = vertrag.vertragsende ? new Date(vertrag.vertragsende) : null; + + // Überlappende Monate berechnen + const effectiveStart = vertragsBeginn > yearStart ? vertragsBeginn : yearStart; + const effectiveEnd = (vertragsEnde && vertragsEnde < yearEnd) ? vertragsEnde : yearEnd; + + let monate = 0; + if (effectiveStart <= effectiveEnd) { + monate = (effectiveEnd.getFullYear() - effectiveStart.getFullYear()) * 12 + + (effectiveEnd.getMonth() - effectiveStart.getMonth()) + 1; + } + + const kaltmiete = parseFloat(vertrag.kaltmiete) || 0; + const vertragEinnahmen = kaltmiete * monate; + objektEinnahmen += vertragEinnahmen; + + // Formatierter Zeitraum + const zeitraumVon = effectiveStart.toLocaleDateString('de-DE', { month: 'short' }); + const zeitraumBis = effectiveEnd.toLocaleDateString('de-DE', { month: 'short' }); + const zeitraum = monate === 12 ? 'Jan-Dez' : `${zeitraumVon}-${zeitraumBis}`; + + einnahmenDetails.push({ + mieter: vertrag.mieter_name, + zeitraum: zeitraum, + monate: monate, + kaltmiete: kaltmiete, + summe: Math.round(vertragEinnahmen * 100) / 100 + }); + } + + // === AUSGABEN: Detaillierte Abfrage === + const kostenResult = await pool.query(` + SELECT kategorie, bezeichnung, betrag, datum + FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 + ORDER BY kategorie, bezeichnung + `, [objekt.id, jahr]); + + const ausgabenDetails = kostenResult.rows.map(k => ({ + kategorie: k.kategorie, + bezeichnung: k.bezeichnung, + betrag: parseFloat(k.betrag) || 0, + datum: k.datum + })); + + const objektAusgaben = ausgabenDetails.reduce((sum, k) => sum + k.betrag, 0); + const bilanz = objektEinnahmen - objektAusgaben; + const rendite = objektEinnahmen > 0 ? ((bilanz / objektEinnahmen) * 100) : 0; + + gesamtEinnahmen += objektEinnahmen; + gesamtAusgaben += objektAusgaben; + + bilanzDaten.push({ + id: objekt.id, + name: objekt.name, + adresse: objekt.adresse, + einnahmen: Math.round(objektEinnahmen * 100) / 100, + ausgaben: Math.round(objektAusgaben * 100) / 100, + bilanz: Math.round(bilanz * 100) / 100, + rendite: Math.round(rendite * 10) / 10, + anzahl_mieter: einnahmenDetails.length, + details: { + einnahmen: einnahmenDetails, + ausgaben: ausgabenDetails + } + }); + } + + res.json({ + jahr: parseInt(jahr), + objekte: bilanzDaten, + gesamt: { + einnahmen: Math.round(gesamtEinnahmen * 100) / 100, + ausgaben: Math.round(gesamtAusgaben * 100) / 100, + bilanz: Math.round((gesamtEinnahmen - gesamtAusgaben) * 100) / 100, + rendite: gesamtEinnahmen > 0 ? Math.round(((gesamtEinnahmen - gesamtAusgaben) / gesamtEinnahmen) * 100 * 10) / 10 : 0 + } + }); + } catch (error) { + console.error('Vermietung Bilanz Error:', error); + res.status(500).json({ error: error.message }); + } + }); + + // ========== NEBENKOSTENABRECHNUNG ========== + + // Übersicht + app.get('/api/nebenkostenabrechnung/uebersicht', async (req, res) => { + try { + const { jahr = new Date().getFullYear() } = req.query; + + // Alle Objekte mit Summen + const result = await pool.query(` + SELECT o.*, + COALESCE(k.gesamt_kosten, 0) as gesamt_kosten, + COALESCE(v.gesamt_vorauszahlungen, 0) as gesamt_vorauszahlungen, + COALESCE(m.anzahl_mieter, 0) as anzahl_mieter + FROM objekte o + LEFT JOIN ( + SELECT objekt_id, SUM(betrag) as gesamt_kosten + FROM objektkosten WHERE jahr = $1 GROUP BY objekt_id + ) k ON k.objekt_id = o.id + LEFT JOIN ( + SELECT objekt_id, SUM(betrag) as gesamt_vorauszahlungen + FROM vorauszahlungen WHERE jahr = $1 GROUP BY objekt_id + ) v ON v.objekt_id = o.id + LEFT JOIN ( + SELECT objekt_id, COUNT(*) as anzahl_mieter + FROM mietvertraege WHERE ist_aktuell = true GROUP BY objekt_id + ) m ON m.objekt_id = o.id + ORDER BY o.name + `, [jahr]); + + res.json({ jahr: parseInt(jahr), objekte: result.rows }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Abrechnung Vorschau (Berechnung) + app.post('/api/nebenkostenabrechnung/vorschau', async (req, res) => { + try { + const { objektId, jahr, zeitraumVon, zeitraumBis } = req.body; + + // Objekt-Daten + const objektResult = await pool.query('SELECT * FROM objekte WHERE id = $1', [objektId]); + if (objektResult.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' }); + const objekt = objektResult.rows[0]; + + // Alle Kosten des Objekts im Jahr + const kostenResult = await pool.query( + 'SELECT * FROM objektkosten WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie', + [objektId, jahr] + ); + + // Aktuelle Mieter mit Mietverträgen + const mieterResult = await pool.query(` + SELECT mv.*, m.name as mieter_name, m.email as mieter_email + FROM mietvertraege mv + JOIN mieter m ON m.id = mv.mieter_id + WHERE mv.objekt_id = $1 AND mv.ist_aktuell = true + `, [objektId]); + + // Berechnung + const gesamtKosten = kostenResult.rows.reduce((sum, k) => sum + parseFloat(k.betrag), 0); + const gesamtFlaeche = mieterResult.rows.reduce((sum, m) => sum + parseFloat(m.wohnflaeche_qm), 0); + + // Tage im Abrechnungszeitraum + const vonDatum = new Date(zeitraumVon); + const bisDatum = new Date(zeitraumBis); + const tageGesamt = Math.ceil((bisDatum - vonDatum) / (1000 * 60 * 60 * 24)) + 1; + + // Pro Mieter berechnen + const berechnungen = []; + + for (const mietvertrag of mieterResult.rows) { + // Vorauszahlungen dieses Mieters + const vorauszahlungenResult = await pool.query( + 'SELECT SUM(betrag) as summe FROM vorauszahlungen WHERE objekt_id = $1 AND mieter_id = $2 AND jahr = $3', + [objektId, mietvertrag.mieter_id, jahr] + ); + const summeVorauszahlungen = parseFloat(vorauszahlungenResult.rows[0]?.summe || 0); + + // Anteil berechnen (pro-rata bei Mieterwechsel möglich) + const anteilQm = parseFloat(mietvertrag.wohnflaeche_qm); + const anteilTage = tageGesamt; // Hier könnte differenzierte Logik für Ein-/Auszug stehen + const anteilKosten = gesamtKosten * (anteilQm / gesamtFlaeche); + + const ergebnis = anteilKosten - summeVorauszahlungen; + + berechnungen.push({ + mietvertrag_id: mietvertrag.id, + mieter_id: mietvertrag.mieter_id, + mieter_name: mietvertrag.mieter_name, + anteil_qm: anteilQm, + anteil_tage: anteilTage, + anteil_kosten: Math.round(anteilKosten * 100) / 100, + summe_vorauszahlungen: summeVorauszahlungen, + ergebnis: Math.round(ergebnis * 100) / 100, + ist_nachzahlung: ergebnis > 0, + betrag_nachzahlung: ergebnis > 0 ? ergebnis : 0, + betrag_gutschrift: ergebnis < 0 ? Math.abs(ergebnis) : 0 + }); + } + + res.json({ + objekt, + jahr, + zeitraum_von: zeitraumVon, + zeitraum_bis: zeitraumBis, + tage_gesamt: tageGesamt, + gesamt_kosten: gesamtKosten, + gesamt_flaeche: gesamtFlaeche, + kosten: kostenResult.rows, + mieter: mieterResult.rows, + berechnungen + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Abrechnung speichern + app.post('/api/nebenkostenabrechnung', async (req, res) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const { objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf, berechnungen } = req.body; + + // Bestehende Abrechnung prüfen/löschen + await client.query( + 'DELETE FROM nebenkostenabrechnungen WHERE objekt_id = $1 AND jahr = $2', + [objekt_id, jahr] + ); + + // Neue Abrechnung erstellen + const abrechnungResult = await client.query( + `INSERT INTO nebenkostenabrechnungen (objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf) + VALUES ($1, $2, $3, $4, $5) RETURNING *`, + [objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf !== false] + ); + const abrechnung = abrechnungResult.rows[0]; + + // Positionen speichern + for (const pos of berechnungen) { + await client.query( + `INSERT INTO abrechnungspositionen + (abrechnung_id, mieter_id, mietvertrag_id, anteil_qm, anteil_tage, anteil_kosten, summe_vorauszahlungen, ergebnis) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [abrechnung.id, pos.mieter_id, pos.mietvertrag_id, pos.anteil_qm, pos.anteil_tage, + pos.anteil_kosten, pos.summe_vorauszahlungen, pos.ergebnis] + ); + } + + await client.query('COMMIT'); + res.json({ success: true, abrechnung: abrechnungResult.rows[0] }); + } catch (error) { + await client.query('ROLLBACK'); + res.status(500).json({ error: error.message }); + } finally { + client.release(); + } + }); + + // Alle Abrechnungen + app.get('/api/nebenkostenabrechnung', async (req, res) => { + try { + const result = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + ORDER BY na.jahr DESC, o.name + `); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Alias für Frontend (Plural) + app.get('/api/nebenkostenabrechnungen', async (req, res) => { + try { + const result = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + ORDER BY na.jahr DESC, o.name + `); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Einzelne Abrechnung mit Positionen + app.get('/api/nebenkostenabrechnung/:id', async (req, res) => { + try { + const abrechnungResult = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + WHERE na.id = $1 + `, [req.params.id]); + + if (abrechnungResult.rows.length === 0) { + return res.status(404).json({ error: 'Abrechnung nicht gefunden' }); + } + + const positionenResult = await pool.query(` + SELECT ap.*, m.name as mieter_name, m.email as mieter_email + FROM abrechnungspositionen ap + JOIN mieter m ON m.id = ap.mieter_id + WHERE ap.abrechnung_id = $1 + `, [req.params.id]); + + const kostenResult = await pool.query(` + SELECT * FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 + `, [abrechnungResult.rows[0].objekt_id, abrechnungResult.rows[0].jahr]); + + res.json({ + abrechnung: abrechnungResult.rows[0], + positionen: positionenResult.rows, + kosten: kostenResult.rows + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Status aktualisieren (Entwurf/Final) + app.put('/api/nebenkostenabrechnung/:id/status', async (req, res) => { + try { + const { ist_entwurf } = req.body; + const result = await pool.query( + 'UPDATE nebenkostenabrechnungen SET ist_entwurf = $1 WHERE id = $2 RETURNING *', + [ist_entwurf, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Abrechnung nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // PDF Export - Privat (René Täger) - EINE PDF pro Mieter + app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => { + try { + const { PDFDocument, rgb, StandardFonts } = require('pdf-lib'); + + const abrechnungResult = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort, o.wohnflaeche_qm as objekt_flaeche + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + WHERE na.id = $1 + `, [req.params.id]); + + if (abrechnungResult.rows.length === 0) { + return res.status(404).json({ error: 'Abrechnung nicht gefunden' }); + } + + const abrechnung = abrechnungResult.rows[0]; + + // ALLE Positionen laden + const positionenResult = await pool.query(` + SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse + FROM abrechnungspositionen ap + JOIN mieter m ON m.id = ap.mieter_id + WHERE ap.abrechnung_id = $1 + `, [req.params.id]); + + const kostenResult = await pool.query(` + SELECT * FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie + `, [abrechnung.objekt_id, abrechnung.jahr]); + + // Berechnungsgrundlagen + const gesamtKosten = kostenResult.rows.reduce((sum, k) => sum + parseFloat(k.betrag), 0); + const gesamtFlaeche = parseFloat(abrechnung.objekt_flaeche || 95); + const kostenProQm = gesamtKosten / gesamtFlaeche; + + // PDF erstellen + const pdfDoc = await PDFDocument.create(); + const width = 595.28; + const height = 841.89; + const margin = 50; + + const font = await pdfDoc.embedFont(StandardFonts.Helvetica); + const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + + const istEntwurf = req.query.entwurf === 'true'; + const heute = new Date().toLocaleDateString('de-DE'); + + // Hilfsfunktion für Trennlinie + function drawLine(page, yPos) { + page.drawLine({ + start: { x: margin, y: yPos }, + end: { x: width - margin, y: yPos }, + thickness: 0.5, + color: rgb(0.8, 0.8, 0.8) + }); + } + + // ========== FÜR JEDEN MIETER EINE SEITE ========== + for (const pos of positionenResult.rows) { + const mieterName = pos.mieter_name; + const mieterAdresse = pos.mieter_adresse || ''; + const mieterFlaeche = parseFloat(pos.anteil_qm); + const anteilKosten = parseFloat(pos.anteil_kosten); + const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen); + const ergebnis = parseFloat(pos.ergebnis); + + // Neue Seite für diesen Mieter + const page = pdfDoc.addPage([width, height]); + let y = height - 50; + + // ========== ABSENDER (oben links) ========== + page.drawText('René Täger', { x: margin, y, size: 10, font: fontBold }); + y -= 14; + page.drawText('Zingelstraße 7', { x: margin, y, size: 9, font }); + y -= 14; + page.drawText('25554 Wilster', { x: margin, y, size: 9, font }); + y -= 30; + + // Trennlinie + drawLine(page, y); + y -= 30; + + // ========== EMPFÄNGER (nur dieser Mieter) ========== + page.drawText(mieterName, { x: margin, y, size: 11, font: fontBold }); + y -= 16; + if (mieterAdresse) { + const adressZeilen = mieterAdresse.split('\n'); + for (const zeile of adressZeilen) { + page.drawText(zeile, { x: margin, y, size: 10, font }); + y -= 14; + } + } + y -= 40; + + // ========== DATUM + BETREFF ========== + page.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font }); + + page.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 25; + + if (istEntwurf) { + page.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) }); + } + + page.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font }); + y -= 20; + page.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold }); + y -= 16; + page.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font }); + y -= 16; + page.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font }); + y -= 35; + + // ========== BEGLEITTEXT ========== + page.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold }); + y -= 18; + page.drawText('im Folgenden erhalten Sie Ihre persönliche Nebenkostenabrechnung.', { x: margin, y, size: 9, font }); + y -= 14; + page.drawText('Die Kosten wurden nach Ihrem im Mietvertrag vereinbarten Anteil umgelegt.', { x: margin, y, size: 9, font }); + y -= 30; + + // ========== 1. KOSTENÜBERSICHT (alle Kosten des Objekts) ========== + page.drawText('1. Kostenübersicht des Objekts:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + // Tabellenkopf + page.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold }); + page.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold }); + page.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold }); + y -= 12; + drawLine(page, y + 8); + y -= 5; + + for (const k of kostenResult.rows) { + const betrag = parseFloat(k.betrag); + page.drawText(k.kategorie, { x: margin, y, size: 9, font }); + page.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font }); + page.drawText(`${betrag.toFixed(2)} €`, { x: margin + 350, y, size: 9, font }); + y -= 14; + } + + y -= 5; + drawLine(page, y + 8); + y -= 5; + page.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold }); + page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 350, y, size: 10, font: fontBold }); + y -= 30; + + // ========== 2. BERECHNUNGSGRUNDLAGE ========== + page.drawText('2. Berechnungsgrundlage:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + page.drawText(`Gesamtkosten:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Gesamtwohnfläche:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${gesamtFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Kosten pro qm:`, { x: margin + 20, y, size: 9, font: fontBold }); + page.drawText(`${gesamtKosten.toFixed(2)} € / ${gesamtFlaeche.toFixed(2)} qm = ${kostenProQm.toFixed(2)} €/qm`, { x: margin + 250, y, size: 9, font: fontBold }); + y -= 30; + + // ========== 3. IHRE PERSÖNLICHE BERECHNUNG ========== + page.drawText('3. Ihre persönliche Berechnung:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + page.drawText(`Ihr Name:`, { x: margin + 20, y, size: 9, font }); + page.drawText(mieterName, { x: margin + 250, y, size: 9, font: fontBold }); + y -= 14; + page.drawText(`Ihre Wohnfläche:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${mieterFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Ihr Anteil an den Kosten:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${kostenProQm.toFixed(2)} €/qm × ${mieterFlaeche.toFixed(2)} qm = ${anteilKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Geleistete Vorauszahlungen:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${vorauszahlungen.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 25; + + // Ergebnis hervorgehoben + drawLine(page, y + 8); + y -= 5; + + if (ergebnis > 0) { + page.drawText(`Nachzahlung:`, { x: margin + 20, y, size: 11, font: fontBold }); + page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold }); + y -= 20; + page.drawText(`Bitte überweisen Sie den Betrag innerhalb von 14 Tagen.`, { x: margin + 20, y, size: 9, font, color: rgb(0.8, 0.2, 0.2) }); + } else { + page.drawText(`Gutschrift:`, { x: margin + 20, y, size: 11, font: fontBold }); + page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold }); + y -= 20; + page.drawText(`Der Betrag wird mit der nächsten Miete verrechnet.`, { x: margin + 20, y, size: 9, font, color: rgb(0.2, 0.6, 0.2) }); + } + y -= 25; + + // ========== FUSSZEILE ========== + y -= 20; + drawLine(page, y); + y -= 20; + page.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) }); + y -= 12; + page.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) }); + } + + // PDF speichern + const pdfBytes = await pdfDoc.save(); + const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes); + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Length', pdfBuffer.length); + res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`); + res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition'); + res.end(pdfBuffer); + } catch (error) { + console.error('PDF Error:', error); + res.status(500).json({ error: error.message }); + } + }); + + // ========== VERMIETUNGSBILANZ ========== + app.get('/api/vermietung/bilanz', async (req, res) => { + try { + const { jahr = new Date().getFullYear() } = req.query; + const yearStart = new Date(jahr, 0, 1); + const yearEnd = new Date(jahr, 11, 31); + + // Alle Objekte laden + const objekteResult = await pool.query('SELECT id, name, adresse FROM objekte ORDER BY name'); + + const bilanzDaten = []; + let gesamtEinnahmen = 0; + let gesamtAusgaben = 0; + + for (const objekt of objekteResult.rows) { + // === EINNAHMEN: Detaillierte Abfrage === + const vertraegeResult = await pool.query(` + SELECT mv.*, m.name as mieter_name + FROM mietvertraege mv + JOIN mieter m ON m.id = mv.mieter_id + WHERE mv.objekt_id = $1 + AND mv.vertragsbeginn <= $2 + AND (mv.vertragsende IS NULL OR mv.vertragsende >= $3) + ORDER BY m.name + `, [objekt.id, yearEnd, yearStart]); + + // Einnahmen berechnen mit Detail-Tracking + let objektEinnahmen = 0; + const einnahmenDetails = []; + + for (const vertrag of vertraegeResult.rows) { + const vertragsBeginn = new Date(vertrag.vertragsbeginn); + const vertragsEnde = vertrag.vertragsende ? new Date(vertrag.vertragsende) : null; + + // Überlappende Monate berechnen + const effectiveStart = vertragsBeginn > yearStart ? vertragsBeginn : yearStart; + const effectiveEnd = (vertragsEnde && vertragsEnde < yearEnd) ? vertragsEnde : yearEnd; + + let monate = 0; + if (effectiveStart <= effectiveEnd) { + monate = (effectiveEnd.getFullYear() - effectiveStart.getFullYear()) * 12 + + (effectiveEnd.getMonth() - effectiveStart.getMonth()) + 1; + } + + const kaltmiete = parseFloat(vertrag.kaltmiete) || 0; + const vertragEinnahmen = kaltmiete * monate; + objektEinnahmen += vertragEinnahmen; + + // Formatierter Zeitraum + const zeitraumVon = effectiveStart.toLocaleDateString('de-DE', { month: 'short' }); + const zeitraumBis = effectiveEnd.toLocaleDateString('de-DE', { month: 'short' }); + const zeitraum = monate === 12 ? 'Jan-Dez' : `${zeitraumVon}-${zeitraumBis}`; + + einnahmenDetails.push({ + mieter_name: vertrag.mieter_name, + zeitraum: zeitraum, + monate: monate, + kaltmiete_monat: kaltmiete, + summe: vertragEinnahmen + }); + } + + // === AUSGABEN: Detaillierte Abfrage === + const kostenResult = await pool.query(` + SELECT kategorie, bezeichnung, betrag, datum + FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 + ORDER BY kategorie, bezeichnung + `, [objekt.id, jahr]); + + const ausgabenDetails = kostenResult.rows.map(k => ({ + kategorie: k.kategorie, + bezeichnung: k.bezeichnung, + betrag: parseFloat(k.betrag) || 0, + datum: k.datum + })); + + const objektAusgaben = ausgabenDetails.reduce((sum, k) => sum + k.betrag, 0); + const bilanz = objektEinnahmen - objektAusgaben; + const rendite = objektEinnahmen > 0 ? ((bilanz / objektEinnahmen) * 100) : 0; + + gesamtEinnahmen += objektEinnahmen; + gesamtAusgaben += objektAusgaben; + + bilanzDaten.push({ + id: objekt.id, + name: objekt.name, + adresse: objekt.adresse, + einnahmen: Math.round(objektEinnahmen * 100) / 100, + ausgaben: Math.round(objektAusgaben * 100) / 100, + bilanz: Math.round(bilanz * 100) / 100, + rendite: Math.round(rendite * 10) / 10, + anzahl_mieter: einnahmenDetails.length, + details: { + einnahmen: einnahmenDetails, + ausgaben: ausgabenDetails + } + }); + } + + res.json({ + jahr: parseInt(jahr), + objekte: bilanzDaten, + gesamt: { + einnahmen: Math.round(gesamtEinnahmen * 100) / 100, + ausgaben: Math.round(gesamtAusgaben * 100) / 100, + bilanz: Math.round((gesamtEinnahmen - gesamtAusgaben) * 100) / 100, + rendite: gesamtEinnahmen > 0 ? Math.round(((gesamtEinnahmen - gesamtAusgaben) / gesamtEinnahmen) * 100 * 10) / 10 : 0 + } + }); + } catch (error) { + console.error('Vermietung Bilanz Error:', error); + res.status(500).json({ error: error.message }); + } + }); + +}; + diff --git a/backend/routes/nebenkosten.js.unix b/backend/routes/nebenkosten.js.unix new file mode 100644 index 0000000..75b5a6a --- /dev/null +++ b/backend/routes/nebenkosten.js.unix @@ -0,0 +1,1287 @@ +// API Routes für Nebenkostenabrechnung (Objekte, Mieter, Mietverträge, Kosten, Vorauszahlungen, Abrechnungen) +const { Pool } = require('pg'); + +const pool = new Pool({ + host: process.env.DB_HOST || 'buchhaltung-db', + port: process.env.DB_PORT || 5432, + database: process.env.DB_NAME || 'buchhaltung', + user: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || 'postgres', +}); + +module.exports = (app) => { + + // ========== OBJEKTE ========== + + // Alle Objekte + app.get('/api/objekte', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM objekte ORDER BY name'); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Einzelnes Objekt + app.get('/api/objekte/:id', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM objekte WHERE id = $1', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Objekt erstellen + app.post('/api/objekte', async (req, res) => { + try { + const { name, adresse, plz, ort, wohnflaeche_qm, bemerkung } = req.body; + const result = await pool.query( + 'INSERT INTO objekte (name, adresse, plz, ort, wohnflaeche_qm, bemerkung) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *', + [name, adresse, plz, ort, wohnflaeche_qm || 0, bemerkung] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Objekt aktualisieren + app.put('/api/objekte/:id', async (req, res) => { + try { + const { name, adresse, plz, ort, wohnflaeche_qm, bemerkung } = req.body; + const result = await pool.query( + 'UPDATE objekte SET name = $1, adresse = $2, plz = $3, ort = $4, wohnflaeche_qm = $5, bemerkung = $6 WHERE id = $7 RETURNING *', + [name, adresse, plz, ort, wohnflaeche_qm, bemerkung, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Objekt löschen + app.delete('/api/objekte/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM objekte WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== OBJEKTKOSTEN ========== + + // Kosten für ein Objekt laden (optional gefiltert nach Jahr) + app.get('/api/objekte/:id/kosten', async (req, res) => { + try { + const { jahr } = req.query; + let query = 'SELECT * FROM objektkosten WHERE objekt_id = $1'; + const params = [req.params.id]; + + if (jahr) { + query += ' AND jahr = $2'; + params.push(jahr); + } + + query += ' ORDER BY jahr DESC, kategorie ASC'; + + const result = await pool.query(query, params); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Kosten hinzufügen + app.post('/api/objekte/:id/kosten', async (req, res) => { + try { + const { kategorie, betrag, jahr } = req.body; + const result = await pool.query( + 'INSERT INTO objektkosten (objekt_id, kategorie, betrag, jahr) VALUES ($1, $2, $3, $4) RETURNING *', + [req.params.id, kategorie, betrag, jahr] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Kosten löschen + app.delete('/api/objekte/:id/kosten/:kostenId', async (req, res) => { + try { + await pool.query('DELETE FROM objektkosten WHERE id = $1 AND objekt_id = $2', [req.params.kostenId, req.params.id]); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Mieter eines Objekts + app.get('/api/objekte/:id/mieter', async (req, res) => { + try { + const result = await pool.query( + `SELECT m.*, mv.id as mietvertrag_id, mv.wohnflaeche_qm, mv.kaltmiete, + mv.nebenkosten_vorauszahlung, mv.vertragsbeginn, mv.vertragsende, mv.ist_aktuell + FROM mieter m + JOIN mietvertraege mv ON mv.mieter_id = m.id + WHERE mv.objekt_id = $1 + ORDER BY m.name`, + [req.params.id] + ); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== MIETER ========== + + app.get('/api/mieter', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM mieter ORDER BY name'); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.get('/api/mieter/:id', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM mieter WHERE id = $1', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/mieter', async (req, res) => { + try { + const { name, email, telefon, adresse } = req.body; + const result = await pool.query( + 'INSERT INTO mieter (name, email, telefon, adresse) VALUES ($1, $2, $3, $4) RETURNING *', + [name, email, telefon, adresse] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.put('/api/mieter/:id', async (req, res) => { + try { + const { name, email, telefon, adresse } = req.body; + const result = await pool.query( + 'UPDATE mieter SET name = $1, email = $2, telefon = $3, adresse = $4 WHERE id = $5 RETURNING *', + [name, email, telefon, adresse, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.delete('/api/mieter/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM mieter WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Mieter mit Mietvertrag erstellen (Combined) + app.post('/api/mieter-mit-vertrag', async (req, res) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const { name, email, telefon, adresse, objekt_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn, vertragsende } = req.body; + + // Mieter erstellen + const mieterResult = await client.query( + 'INSERT INTO mieter (name, email, telefon, adresse) VALUES ($1, $2, $3, $4) RETURNING *', + [name, email, telefon, adresse] + ); + const mieter = mieterResult.rows[0]; + + // Mietvertrag erstellen + const vertragResult = await client.query( + `INSERT INTO mietvertraege (objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn, vertragsende, ist_aktuell) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, + [objekt_id, mieter.id, wohnflaeche_qm || 0, kaltmiete || 0, nebenkosten_vorauszahlung || 0, vertragsbeginn, vertragsende || null, !vertragsende] + ); + + await client.query('COMMIT'); + res.json({ mieter, mietvertrag: vertragResult.rows[0] }); + } catch (error) { + await client.query('ROLLBACK'); + res.status(500).json({ error: error.message }); + } finally { + client.release(); + } + }); + + // Mieter mit Mietvertrag aktualisieren (Combined) + app.put('/api/mieter/:id/mit-vertrag', async (req, res) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const { name, email, telefon, adresse, objekt_id, kaltmiete, vorauszahlung, vertragsbeginn, vertragsende } = req.body; + + // ist_aktuell automatisch berechnen: true wenn kein vertragsende oder vertragsende in Zukunft + const istAktuell = !vertragsende || new Date(vertragsende) >= new Date(); + + // Mieter aktualisieren + const mieterResult = await client.query( + 'UPDATE mieter SET name = $1, email = $2, telefon = $3, adresse = $4 WHERE id = $5 RETURNING *', + [name, email, telefon, adresse, req.params.id] + ); + if (mieterResult.rows.length === 0) { + await client.query('ROLLBACK'); + return res.status(404).json({ error: 'Mieter nicht gefunden' }); + } + + // Aktiven Mietvertrag finden und aktualisieren + const vertragResult = await client.query( + `UPDATE mietvertraege SET + objekt_id = $1, kaltmiete = $2, nebenkosten_vorauszahlung = $3, + vertragsbeginn = $4, vertragsende = $5, ist_aktuell = $6 + WHERE mieter_id = $7 AND (ist_aktuell = true OR id = ( + SELECT id FROM mietvertraege WHERE mieter_id = $7 ORDER BY vertragsbeginn DESC LIMIT 1 + )) + RETURNING *`, + [objekt_id, kaltmiete, vorauszahlung, vertragsbeginn, vertragsende || null, istAktuell, req.params.id] + ); + + await client.query('COMMIT'); + res.json({ mieter: mieterResult.rows[0], mietvertrag: vertragResult.rows[0] || null }); + } catch (error) { + await client.query('ROLLBACK'); + res.status(500).json({ error: error.message }); + } finally { + client.release(); + } + }); + + // Mietverträge eines Mieters + app.get('/api/mieter/:id/mietvertraege', async (req, res) => { + try { + const result = await pool.query( + `SELECT mv.*, o.name as objekt_name + FROM mietvertraege mv + JOIN objekte o ON o.id = mv.objekt_id + WHERE mv.mieter_id = $1 + ORDER BY mv.vertragsbeginn DESC`, + [req.params.id] + ); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== MIETVERTRÄGE ========== + + app.get('/api/mietvertraege', async (req, res) => { + try { + const result = await pool.query( + `SELECT mv.*, m.name as mieter_name, o.name as objekt_name + FROM mietvertraege mv + JOIN mieter m ON m.id = mv.mieter_id + JOIN objekte o ON o.id = mv.objekt_id + ORDER BY mv.vertragsbeginn DESC` + ); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== MIETVERTRÄGE ========== + + app.get('/api/mietvertraege', async (req, res) => { + try { + const { objekt_id, ist_aktuell } = req.query; + let query = `SELECT mv.*, o.name as objekt_name, o.adresse, o.plz, o.ort, + m.name as mieter_name, m.email as mieter_email + FROM mietvertraege mv + JOIN objekte o ON o.id = mv.objekt_id + JOIN mieter m ON m.id = mv.mieter_id`; + const values = []; + const conditions = []; + + if (objekt_id) { + conditions.push(`mv.objekt_id = $${values.length + 1}`); + values.push(objekt_id); + } + if (ist_aktuell !== undefined) { + conditions.push(`mv.ist_aktuell = $${values.length + 1}`); + values.push(ist_aktuell === 'true'); + } + + if (conditions.length > 0) { + query += ' WHERE ' + conditions.join(' AND '); + } + + query += ' ORDER BY mv.vertragsbeginn DESC'; + + const result = await pool.query(query, values); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/mietvertraege', async (req, res) => { + try { + const { objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn } = req.body; + const result = await pool.query( + `INSERT INTO mietvertraege (objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, + [objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung || 0, vertragsbeginn] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.put('/api/mietvertraege/:id', async (req, res) => { + try { + const { wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsende, ist_aktuell } = req.body; + const result = await pool.query( + `UPDATE mietvertraege SET wohnflaeche_qm = $1, kaltmiete = $2, nebenkosten_vorauszahlung = $3, + vertragsende = $4, ist_aktuell = $5 WHERE id = $6 RETURNING *`, + [wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsende, ist_aktuell !== undefined ? ist_aktuell : true, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/mietvertraege/:id/beenden', async (req, res) => { + try { + const { vertragsende } = req.body; + const result = await pool.query( + `UPDATE mietvertraege SET vertragsende = $1, ist_aktuell = false WHERE id = $2 RETURNING *`, + [vertragsende, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.delete('/api/mietvertraege/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM mietvertraege WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== OBJEKTKOSTEN ========== + + app.get('/api/objektkosten', async (req, res) => { + try { + const { objekt_id, jahr } = req.query; + let query = 'SELECT ok.*, o.name as objekt_name FROM objektkosten ok JOIN objekte o ON o.id = ok.objekt_id'; + const values = []; + const conditions = []; + + if (objekt_id) { + conditions.push(`ok.objekt_id = $${values.length + 1}`); + values.push(objekt_id); + } + if (jahr) { + conditions.push(`ok.jahr = $${values.length + 1}`); + values.push(parseInt(jahr)); + } + + if (conditions.length > 0) { + query += ' WHERE ' + conditions.join(' AND '); + } + + query += ' ORDER BY ok.jahr DESC, ok.kategorie, ok.datum'; + + const result = await pool.query(query, values); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/objektkosten', async (req, res) => { + try { + const { objekt_id, kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung } = req.body; + const result = await pool.query( + `INSERT INTO objektkosten (objekt_id, kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, + [objekt_id, kategorie, bezeichnung, betrag, datum, jahr || new Date().getFullYear(), verteilung || 'qm', bemerkung] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.put('/api/objektkosten/:id', async (req, res) => { + try { + const { kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung } = req.body; + const result = await pool.query( + `UPDATE objektkosten SET kategorie = $1, bezeichnung = $2, betrag = $3, datum = $4, jahr = $5, verteilung = $6, bemerkung = $7 + WHERE id = $8 RETURNING *`, + [kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Kosten nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.delete('/api/objektkosten/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM objektkosten WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Kosten nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== VORAUSZAHLUNGEN ========== + + app.get('/api/vorauszahlungen', async (req, res) => { + try { + const { objekt_id, mieter_id, jahr } = req.query; + let query = `SELECT v.*, o.name as objekt_name, m.name as mieter_name + FROM vorauszahlungen v + JOIN objekte o ON o.id = v.objekt_id + JOIN mieter m ON m.id = v.mieter_id`; + const values = []; + const conditions = []; + + if (objekt_id) { + conditions.push(`v.objekt_id = $${values.length + 1}`); + values.push(objekt_id); + } + if (mieter_id) { + conditions.push(`v.mieter_id = $${values.length + 1}`); + values.push(mieter_id); + } + if (jahr) { + conditions.push(`v.jahr = $${values.length + 1}`); + values.push(parseInt(jahr)); + } + + if (conditions.length > 0) { + query += ' WHERE ' + conditions.join(' AND '); + } + + query += ' ORDER BY v.jahr DESC, v.monat'; + + const result = await pool.query(query, values); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/vorauszahlungen', async (req, res) => { + try { + const { objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung } = req.body; + const result = await pool.query( + `INSERT INTO vorauszahlungen (objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, + [objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Bulk: Vorauszahlungen für Jahr erstellen + app.post('/api/vorauszahlungen/bulk', async (req, res) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const { objektId, mieterId, jahr, monatlicherBetrag } = req.body; + const results = []; + + for (let monat = 1; monat <= 12; monat++) { + const result = await client.query( + `INSERT INTO vorauszahlungen (objekt_id, mieter_id, jahr, monat, betrag) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (objekt_id, mieter_id, jahr, monat) + DO UPDATE SET betrag = $5 + RETURNING *`, + [objektId, mieterId, jahr, monat, monatlicherBetrag] + ); + results.push(result.rows[0]); + } + + await client.query('COMMIT'); + res.json({ success: true, created: results.length, vorauszahlungen: results }); + } catch (error) { + await client.query('ROLLBACK'); + res.status(500).json({ error: error.message }); + } finally { + client.release(); + } + }); + + app.put('/api/vorauszahlungen/:id', async (req, res) => { + try { + const { betrag, bezahlt_am, bemerkung } = req.body; + const result = await pool.query( + `UPDATE vorauszahlungen SET betrag = $1, bezahlt_am = $2, bemerkung = $3 WHERE id = $4 RETURNING *`, + [betrag, bezahlt_am, bemerkung, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Vorauszahlung nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.delete('/api/vorauszahlungen/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM vorauszahlungen WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Vorauszahlung nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== VERMIETUNGSBILANZ ========== + + // Hilfsfunktion: Berechne überlappende Monate zwischen zwei Zeiträumen + function calculateOverlappingMonths(startA, endA, startB, endB) { + const start = new Date(Math.max(startA.getTime(), startB.getTime())); + const end = new Date(Math.min(endA.getTime(), endB.getTime())); + + if (start > end) return 0; + + // Monate berechnen (inklusive Start- und Endmonat) + const months = (end.getFullYear() - start.getFullYear()) * 12 + + (end.getMonth() - start.getMonth()) + 1; + return Math.max(0, months); + } + + // Einnahmen und Ausgaben pro Objekt für Vermietungs-Bilanz + app.get('/api/vermietungsbilanz', async (req, res) => { + try { + const { jahr = new Date().getFullYear() } = req.query; + + const yearStart = new Date(jahr, 0, 1); // 1. Januar + const yearEnd = new Date(jahr, 11, 31); // 31. Dezember + + // Alle Objekte laden + const objekteResult = await pool.query('SELECT id, name FROM objekte ORDER BY name'); + + const bilanzDaten = []; + let gesamtEinnahmen = 0; + let gesamtAusgaben = 0; + + for (const objekt of objekteResult.rows) { + // === EINNAHMEN: Alle Mietverträge mit Zeitraumberechnung === + // Mietverträge laden die im Jahr aktiv waren + const vertraegeResult = await pool.query(` + SELECT mv.*, m.name as mieter_name + FROM mietvertraege mv + JOIN mieter m ON m.id = mv.mieter_id + WHERE mv.objekt_id = $1 + AND mv.vertragsbeginn <= $2 + AND (mv.vertragsende IS NULL OR mv.vertragsende >= $3) + `, [objekt.id, yearEnd, yearStart]); + + // Einnahmen pro Vertrag berechnen + let objektEinnahmen = 0; + const einnahmenDetails = []; + + for (const vertrag of vertraegeResult.rows) { + const vertragsBeginn = new Date(vertrag.vertragsbeginn); + const vertragsEnde = vertrag.vertragsende ? new Date(vertrag.vertragsende) : null; + + // Überlappende Monate berechnen + const effectiveStart = vertragsBeginn > yearStart ? vertragsBeginn : yearStart; + const effectiveEnd = (vertragsEnde && vertragsEnde < yearEnd) ? vertragsEnde : yearEnd; + + const months = calculateOverlappingMonths( + vertragsBeginn, vertragsEnde || new Date(2099, 11, 31), + yearStart, yearEnd + ); + + const kaltmiete = parseFloat(vertrag.kaltmiete) || 0; + const vertragEinnahmen = kaltmiete * months; + objektEinnahmen += vertragEinnahmen; + + einnahmenDetails.push({ + mieter_id: vertrag.mieter_id, + mieter_name: vertrag.mieter_name, + vertragsbeginn: vertrag.vertragsbeginn, + vertragsende: vertrag.vertragsende, + kaltmiete_monat: kaltmiete, + monate: months, + summe: Math.round(vertragEinnahmen * 100) / 100 + }); + } + + // === AUSGABEN: Nebenkosten für das Jahr === + const ausgabenResult = await pool.query(` + SELECT COALESCE(SUM(betrag), 0) as nebenkosten + FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 + `, [objekt.id, jahr]); + const nebenkosten = parseFloat(ausgabenResult.rows[0].nebenkosten); + + // Detaillierte Ausgaben nach Kategorie + const kategorienResult = await pool.query(` + SELECT kategorie, SUM(betrag) as betrag + FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 + GROUP BY kategorie + ORDER BY SUM(betrag) DESC + `, [objekt.id, jahr]); + + // Detaillierte Ausgaben für Modal + const ausgabenDetailsResult = await pool.query(` + SELECT id, kategorie, bezeichnung, betrag, datum + FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 + ORDER BY betrag DESC + `, [objekt.id, jahr]); + + const bilanz = objektEinnahmen - nebenkosten; + gesamtEinnahmen += objektEinnahmen; + gesamtAusgaben += nebenkosten; + + // Anzahl aktiver Mieter + const aktiveMieter = einnahmenDetails.length; + + bilanzDaten.push({ + objekt_id: objekt.id, + objekt_name: objekt.name, + mieteinnahmen: Math.round(objektEinnahmen * 100) / 100, + nebenkosten: Math.round(nebenkosten * 100) / 100, + bilanz: Math.round(bilanz * 100) / 100, + anzahl_mieter: aktiveMieter, + kategorien: kategorienResult.rows, + // Details für Drill-down + einnahmen_details: einnahmenDetails, + ausgaben_details: ausgabenDetailsResult.rows + }); + } + + res.json({ + jahr: parseInt(jahr), + objekte: bilanzDaten, + gesamt_einnahmen: Math.round(gesamtEinnahmen * 100) / 100, + gesamt_ausgaben: Math.round(gesamtAusgaben * 100) / 100, + gesamt_bilanz: Math.round((gesamtEinnahmen - gesamtAusgaben) * 100) / 100 + }); + } catch (error) { + console.error('Vermietungsbilanz Error:', error); + res.status(500).json({ error: error.message }); + } + }); + + // Alias für Frontend-Kompatibilität mit DETAILS + app.get('/api/vermietung/bilanz', async (req, res) => { + try { + const { jahr = new Date().getFullYear() } = req.query; + const yearStart = new Date(jahr, 0, 1); + const yearEnd = new Date(jahr, 11, 31); + + // Alle Objekte laden + const objekteResult = await pool.query('SELECT id, name, adresse FROM objekte ORDER BY name'); + + const bilanzDaten = []; + let gesamtEinnahmen = 0; + let gesamtAusgaben = 0; + + for (const objekt of objekteResult.rows) { + // === EINNAHMEN: Detaillierte Abfrage === + const vertraegeResult = await pool.query(` + SELECT mv.*, m.name as mieter_name + FROM mietvertraege mv + JOIN mieter m ON m.id = mv.mieter_id + WHERE mv.objekt_id = $1 + AND mv.vertragsbeginn <= $2 + AND (mv.vertragsende IS NULL OR mv.vertragsende >= $3) + ORDER BY m.name + `, [objekt.id, yearEnd, yearStart]); + + // Einnahmen berechnen mit Detail-Tracking + let objektEinnahmen = 0; + const einnahmenDetails = []; + + for (const vertrag of vertraegeResult.rows) { + const vertragsBeginn = new Date(vertrag.vertragsbeginn); + const vertragsEnde = vertrag.vertragsende ? new Date(vertrag.vertragsende) : null; + + // Überlappende Monate berechnen + const effectiveStart = vertragsBeginn > yearStart ? vertragsBeginn : yearStart; + const effectiveEnd = (vertragsEnde && vertragsEnde < yearEnd) ? vertragsEnde : yearEnd; + + let monate = 0; + if (effectiveStart <= effectiveEnd) { + monate = (effectiveEnd.getFullYear() - effectiveStart.getFullYear()) * 12 + + (effectiveEnd.getMonth() - effectiveStart.getMonth()) + 1; + } + + const kaltmiete = parseFloat(vertrag.kaltmiete) || 0; + const vertragEinnahmen = kaltmiete * monate; + objektEinnahmen += vertragEinnahmen; + + // Formatierter Zeitraum + const zeitraumVon = effectiveStart.toLocaleDateString('de-DE', { month: 'short' }); + const zeitraumBis = effectiveEnd.toLocaleDateString('de-DE', { month: 'short' }); + const zeitraum = monate === 12 ? 'Jan-Dez' : `${zeitraumVon}-${zeitraumBis}`; + + einnahmenDetails.push({ + mieter: vertrag.mieter_name, + zeitraum: zeitraum, + monate: monate, + kaltmiete: kaltmiete, + summe: Math.round(vertragEinnahmen * 100) / 100 + }); + } + + // === AUSGABEN: Detaillierte Abfrage === + const kostenResult = await pool.query(` + SELECT kategorie, bezeichnung, betrag, datum + FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 + ORDER BY kategorie, bezeichnung + `, [objekt.id, jahr]); + + const ausgabenDetails = kostenResult.rows.map(k => ({ + kategorie: k.kategorie, + bezeichnung: k.bezeichnung, + betrag: parseFloat(k.betrag) || 0, + datum: k.datum + })); + + const objektAusgaben = ausgabenDetails.reduce((sum, k) => sum + k.betrag, 0); + const bilanz = objektEinnahmen - objektAusgaben; + const rendite = objektEinnahmen > 0 ? ((bilanz / objektEinnahmen) * 100) : 0; + + gesamtEinnahmen += objektEinnahmen; + gesamtAusgaben += objektAusgaben; + + bilanzDaten.push({ + id: objekt.id, + name: objekt.name, + adresse: objekt.adresse, + einnahmen: Math.round(objektEinnahmen * 100) / 100, + ausgaben: Math.round(objektAusgaben * 100) / 100, + bilanz: Math.round(bilanz * 100) / 100, + rendite: Math.round(rendite * 10) / 10, + anzahl_mieter: einnahmenDetails.length, + details: { + einnahmen: einnahmenDetails, + ausgaben: ausgabenDetails + } + }); + } + + res.json({ + jahr: parseInt(jahr), + objekte: bilanzDaten, + gesamt: { + einnahmen: Math.round(gesamtEinnahmen * 100) / 100, + ausgaben: Math.round(gesamtAusgaben * 100) / 100, + bilanz: Math.round((gesamtEinnahmen - gesamtAusgaben) * 100) / 100, + rendite: gesamtEinnahmen > 0 ? Math.round(((gesamtEinnahmen - gesamtAusgaben) / gesamtEinnahmen) * 100 * 10) / 10 : 0 + } + }); + } catch (error) { + console.error('Vermietung Bilanz Error:', error); + res.status(500).json({ error: error.message }); + } + }); + + // ========== NEBENKOSTENABRECHNUNG ========== + + // Übersicht + app.get('/api/nebenkostenabrechnung/uebersicht', async (req, res) => { + try { + const { jahr = new Date().getFullYear() } = req.query; + + // Alle Objekte mit Summen + const result = await pool.query(` + SELECT o.*, + COALESCE(k.gesamt_kosten, 0) as gesamt_kosten, + COALESCE(v.gesamt_vorauszahlungen, 0) as gesamt_vorauszahlungen, + COALESCE(m.anzahl_mieter, 0) as anzahl_mieter + FROM objekte o + LEFT JOIN ( + SELECT objekt_id, SUM(betrag) as gesamt_kosten + FROM objektkosten WHERE jahr = $1 GROUP BY objekt_id + ) k ON k.objekt_id = o.id + LEFT JOIN ( + SELECT objekt_id, SUM(betrag) as gesamt_vorauszahlungen + FROM vorauszahlungen WHERE jahr = $1 GROUP BY objekt_id + ) v ON v.objekt_id = o.id + LEFT JOIN ( + SELECT objekt_id, COUNT(*) as anzahl_mieter + FROM mietvertraege WHERE ist_aktuell = true GROUP BY objekt_id + ) m ON m.objekt_id = o.id + ORDER BY o.name + `, [jahr]); + + res.json({ jahr: parseInt(jahr), objekte: result.rows }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Abrechnung Vorschau (Berechnung) + app.post('/api/nebenkostenabrechnung/vorschau', async (req, res) => { + try { + const { objektId, jahr, zeitraumVon, zeitraumBis } = req.body; + + // Objekt-Daten + const objektResult = await pool.query('SELECT * FROM objekte WHERE id = $1', [objektId]); + if (objektResult.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' }); + const objekt = objektResult.rows[0]; + + // Alle Kosten des Objekts im Jahr + const kostenResult = await pool.query( + 'SELECT * FROM objektkosten WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie', + [objektId, jahr] + ); + + // Aktuelle Mieter mit Mietverträgen + const mieterResult = await pool.query(` + SELECT mv.*, m.name as mieter_name, m.email as mieter_email + FROM mietvertraege mv + JOIN mieter m ON m.id = mv.mieter_id + WHERE mv.objekt_id = $1 AND mv.ist_aktuell = true + `, [objektId]); + + // Berechnung + const gesamtKosten = kostenResult.rows.reduce((sum, k) => sum + parseFloat(k.betrag), 0); + const gesamtFlaeche = mieterResult.rows.reduce((sum, m) => sum + parseFloat(m.wohnflaeche_qm), 0); + + // Tage im Abrechnungszeitraum + const vonDatum = new Date(zeitraumVon); + const bisDatum = new Date(zeitraumBis); + const tageGesamt = Math.ceil((bisDatum - vonDatum) / (1000 * 60 * 60 * 24)) + 1; + + // Pro Mieter berechnen + const berechnungen = []; + + for (const mietvertrag of mieterResult.rows) { + // Vorauszahlungen dieses Mieters + const vorauszahlungenResult = await pool.query( + 'SELECT SUM(betrag) as summe FROM vorauszahlungen WHERE objekt_id = $1 AND mieter_id = $2 AND jahr = $3', + [objektId, mietvertrag.mieter_id, jahr] + ); + const summeVorauszahlungen = parseFloat(vorauszahlungenResult.rows[0]?.summe || 0); + + // Anteil berechnen (pro-rata bei Mieterwechsel möglich) + const anteilQm = parseFloat(mietvertrag.wohnflaeche_qm); + const anteilTage = tageGesamt; // Hier könnte differenzierte Logik für Ein-/Auszug stehen + const anteilKosten = gesamtKosten * (anteilQm / gesamtFlaeche); + + const ergebnis = anteilKosten - summeVorauszahlungen; + + berechnungen.push({ + mietvertrag_id: mietvertrag.id, + mieter_id: mietvertrag.mieter_id, + mieter_name: mietvertrag.mieter_name, + anteil_qm: anteilQm, + anteil_tage: anteilTage, + anteil_kosten: Math.round(anteilKosten * 100) / 100, + summe_vorauszahlungen: summeVorauszahlungen, + ergebnis: Math.round(ergebnis * 100) / 100, + ist_nachzahlung: ergebnis > 0, + betrag_nachzahlung: ergebnis > 0 ? ergebnis : 0, + betrag_gutschrift: ergebnis < 0 ? Math.abs(ergebnis) : 0 + }); + } + + res.json({ + objekt, + jahr, + zeitraum_von: zeitraumVon, + zeitraum_bis: zeitraumBis, + tage_gesamt: tageGesamt, + gesamt_kosten: gesamtKosten, + gesamt_flaeche: gesamtFlaeche, + kosten: kostenResult.rows, + mieter: mieterResult.rows, + berechnungen + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Abrechnung speichern + app.post('/api/nebenkostenabrechnung', async (req, res) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const { objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf, berechnungen } = req.body; + + // Bestehende Abrechnung prüfen/löschen + await client.query( + 'DELETE FROM nebenkostenabrechnungen WHERE objekt_id = $1 AND jahr = $2', + [objekt_id, jahr] + ); + + // Neue Abrechnung erstellen + const abrechnungResult = await client.query( + `INSERT INTO nebenkostenabrechnungen (objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf) + VALUES ($1, $2, $3, $4, $5) RETURNING *`, + [objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf !== false] + ); + const abrechnung = abrechnungResult.rows[0]; + + // Positionen speichern + for (const pos of berechnungen) { + await client.query( + `INSERT INTO abrechnungspositionen + (abrechnung_id, mieter_id, mietvertrag_id, anteil_qm, anteil_tage, anteil_kosten, summe_vorauszahlungen, ergebnis) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [abrechnung.id, pos.mieter_id, pos.mietvertrag_id, pos.anteil_qm, pos.anteil_tage, + pos.anteil_kosten, pos.summe_vorauszahlungen, pos.ergebnis] + ); + } + + await client.query('COMMIT'); + res.json({ success: true, abrechnung: abrechnungResult.rows[0] }); + } catch (error) { + await client.query('ROLLBACK'); + res.status(500).json({ error: error.message }); + } finally { + client.release(); + } + }); + + // Alle Abrechnungen + app.get('/api/nebenkostenabrechnung', async (req, res) => { + try { + const result = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + ORDER BY na.jahr DESC, o.name + `); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Alias für Frontend (Plural) + app.get('/api/nebenkostenabrechnungen', async (req, res) => { + try { + const result = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + ORDER BY na.jahr DESC, o.name + `); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Einzelne Abrechnung mit Positionen + app.get('/api/nebenkostenabrechnung/:id', async (req, res) => { + try { + const abrechnungResult = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + WHERE na.id = $1 + `, [req.params.id]); + + if (abrechnungResult.rows.length === 0) { + return res.status(404).json({ error: 'Abrechnung nicht gefunden' }); + } + + const positionenResult = await pool.query(` + SELECT ap.*, m.name as mieter_name, m.email as mieter_email + FROM abrechnungspositionen ap + JOIN mieter m ON m.id = ap.mieter_id + WHERE ap.abrechnung_id = $1 + `, [req.params.id]); + + const kostenResult = await pool.query(` + SELECT * FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 + `, [abrechnungResult.rows[0].objekt_id, abrechnungResult.rows[0].jahr]); + + res.json({ + abrechnung: abrechnungResult.rows[0], + positionen: positionenResult.rows, + kosten: kostenResult.rows + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Status aktualisieren (Entwurf/Final) + app.put('/api/nebenkostenabrechnung/:id/status', async (req, res) => { + try { + const { ist_entwurf } = req.body; + const result = await pool.query( + 'UPDATE nebenkostenabrechnungen SET ist_entwurf = $1 WHERE id = $2 RETURNING *', + [ist_entwurf, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Abrechnung nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // PDF Export - Privat (René Täger) - EINE PDF pro Mieter + app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => { + try { + const { PDFDocument, rgb, StandardFonts } = require('pdf-lib'); + + const abrechnungResult = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort, o.wohnflaeche_qm as objekt_flaeche + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + WHERE na.id = $1 + `, [req.params.id]); + + if (abrechnungResult.rows.length === 0) { + return res.status(404).json({ error: 'Abrechnung nicht gefunden' }); + } + + const abrechnung = abrechnungResult.rows[0]; + + // ALLE Positionen laden + const positionenResult = await pool.query(` + SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse + FROM abrechnungspositionen ap + JOIN mieter m ON m.id = ap.mieter_id + WHERE ap.abrechnung_id = $1 + `, [req.params.id]); + + const kostenResult = await pool.query(` + SELECT * FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie + `, [abrechnung.objekt_id, abrechnung.jahr]); + + // Berechnungsgrundlagen + const gesamtKosten = kostenResult.rows.reduce((sum, k) => sum + parseFloat(k.betrag), 0); + const gesamtFlaeche = parseFloat(abrechnung.objekt_flaeche || 95); + const kostenProQm = gesamtKosten / gesamtFlaeche; + + // PDF erstellen + const pdfDoc = await PDFDocument.create(); + const width = 595.28; + const height = 841.89; + const margin = 50; + + const font = await pdfDoc.embedFont(StandardFonts.Helvetica); + const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + + const istEntwurf = req.query.entwurf === 'true'; + const heute = new Date().toLocaleDateString('de-DE'); + + // Hilfsfunktion für Trennlinie + function drawLine(page, yPos) { + page.drawLine({ + start: { x: margin, y: yPos }, + end: { x: width - margin, y: yPos }, + thickness: 0.5, + color: rgb(0.8, 0.8, 0.8) + }); + } + + // ========== FÜR JEDEN MIETER EINE SEITE ========== + for (const pos of positionenResult.rows) { + const mieterName = pos.mieter_name; + const mieterAdresse = pos.mieter_adresse || ''; + const mieterFlaeche = parseFloat(pos.anteil_qm); + const anteilKosten = parseFloat(pos.anteil_kosten); + const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen); + const ergebnis = parseFloat(pos.ergebnis); + + // Neue Seite für diesen Mieter + const page = pdfDoc.addPage([width, height]); + let y = height - 50; + + // ========== ABSENDER (oben links) ========== + page.drawText('René Täger', { x: margin, y, size: 10, font: fontBold }); + y -= 14; + page.drawText('Zingelstraße 7', { x: margin, y, size: 9, font }); + y -= 14; + page.drawText('25554 Wilster', { x: margin, y, size: 9, font }); + y -= 30; + + // Trennlinie + drawLine(page, y); + y -= 30; + + // ========== EMPFÄNGER (nur dieser Mieter) ========== + page.drawText(mieterName, { x: margin, y, size: 11, font: fontBold }); + y -= 16; + if (mieterAdresse) { + const adressZeilen = mieterAdresse.split('\n'); + for (const zeile of adressZeilen) { + page.drawText(zeile, { x: margin, y, size: 10, font }); + y -= 14; + } + } + y -= 40; + + // ========== DATUM + BETREFF ========== + page.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font }); + + page.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 25; + + if (istEntwurf) { + page.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) }); + } + + page.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font }); + y -= 20; + page.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold }); + y -= 16; + page.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font }); + y -= 16; + page.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font }); + y -= 35; + + // ========== BEGLEITTEXT ========== + page.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold }); + y -= 18; + page.drawText('im Folgenden erhalten Sie Ihre persönliche Nebenkostenabrechnung.', { x: margin, y, size: 9, font }); + y -= 14; + page.drawText('Die Kosten wurden nach Ihrem im Mietvertrag vereinbarten Anteil umgelegt.', { x: margin, y, size: 9, font }); + y -= 30; + + // ========== 1. KOSTENÜBERSICHT (alle Kosten des Objekts) ========== + page.drawText('1. Kostenübersicht des Objekts:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + // Tabellenkopf + page.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold }); + page.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold }); + page.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold }); + y -= 12; + drawLine(page, y + 8); + y -= 5; + + for (const k of kostenResult.rows) { + const betrag = parseFloat(k.betrag); + page.drawText(k.kategorie, { x: margin, y, size: 9, font }); + page.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font }); + page.drawText(`${betrag.toFixed(2)} €`, { x: margin + 350, y, size: 9, font }); + y -= 14; + } + + y -= 5; + drawLine(page, y + 8); + y -= 5; + page.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold }); + page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 350, y, size: 10, font: fontBold }); + y -= 30; + + // ========== 2. BERECHNUNGSGRUNDLAGE ========== + page.drawText('2. Berechnungsgrundlage:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + page.drawText(`Gesamtkosten:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Gesamtwohnfläche:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${gesamtFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Kosten pro qm:`, { x: margin + 20, y, size: 9, font: fontBold }); + page.drawText(`${gesamtKosten.toFixed(2)} € / ${gesamtFlaeche.toFixed(2)} qm = ${kostenProQm.toFixed(2)} €/qm`, { x: margin + 250, y, size: 9, font: fontBold }); + y -= 30; + + // ========== 3. IHRE PERSÖNLICHE BERECHNUNG ========== + page.drawText('3. Ihre persönliche Berechnung:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + page.drawText(`Ihr Name:`, { x: margin + 20, y, size: 9, font }); + page.drawText(mieterName, { x: margin + 250, y, size: 9, font: fontBold }); + y -= 14; + page.drawText(`Ihre Wohnfläche:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${mieterFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Ihr Anteil an den Kosten:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${kostenProQm.toFixed(2)} €/qm × ${mieterFlaeche.toFixed(2)} qm = ${anteilKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Geleistete Vorauszahlungen:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${vorauszahlungen.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 25; + + // Ergebnis hervorgehoben + drawLine(page, y + 8); + y -= 5; + + if (ergebnis > 0) { + page.drawText(`Nachzahlung:`, { x: margin + 20, y, size: 11, font: fontBold }); + page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold }); + y -= 20; + page.drawText(`Bitte überweisen Sie den Betrag innerhalb von 14 Tagen.`, { x: margin + 20, y, size: 9, font, color: rgb(0.8, 0.2, 0.2) }); + } else { + page.drawText(`Gutschrift:`, { x: margin + 20, y, size: 11, font: fontBold }); + page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold }); + y -= 20; + page.drawText(`Der Betrag wird mit der nächsten Miete verrechnet.`, { x: margin + 20, y, size: 9, font, color: rgb(0.2, 0.6, 0.2) }); + } + y -= 25; + + // ========== FUSSZEILE ========== + y -= 20; + drawLine(page, y); + y -= 20; + page.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) }); + y -= 12; + page.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) }); + } + + // PDF speichern + const pdfBytes = await pdfDoc.save(); + const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes); + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Length', pdfBuffer.length); + res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`); + res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition'); + res.end(pdfBuffer); + } catch (error) { + console.error('PDF Error:', error); + res.status(500).json({ error: error.message }); + } + }); + + +}; + diff --git a/backend/routes/nebenkosten_container.js b/backend/routes/nebenkosten_container.js new file mode 100644 index 0000000..c824d6a --- /dev/null +++ b/backend/routes/nebenkosten_container.js @@ -0,0 +1,962 @@ +// API Routes für Nebenkostenabrechnung (Objekte, Mieter, Mietverträge, Kosten, Vorauszahlungen, Abrechnungen) +const { Pool } = require('pg'); + +const pool = new Pool({ + host: process.env.DB_HOST || 'buchhaltung-db', + port: process.env.DB_PORT || 5432, + database: process.env.DB_NAME || 'buchhaltung', + user: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || 'postgres', +}); + +module.exports = (app) => { + + // ========== OBJEKTE ========== + + // Alle Objekte + app.get('/api/objekte', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM objekte ORDER BY name'); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Einzelnes Objekt + app.get('/api/objekte/:id', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM objekte WHERE id = $1', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Objekt erstellen + app.post('/api/objekte', async (req, res) => { + try { + const { name, adresse, plz, ort, wohnflaeche_qm, bemerkung } = req.body; + const result = await pool.query( + 'INSERT INTO objekte (name, adresse, plz, ort, wohnflaeche_qm, bemerkung) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *', + [name, adresse, plz, ort, wohnflaeche_qm || 0, bemerkung] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Objekt aktualisieren + app.put('/api/objekte/:id', async (req, res) => { + try { + const { name, adresse, plz, ort, wohnflaeche_qm, bemerkung } = req.body; + const result = await pool.query( + 'UPDATE objekte SET name = $1, adresse = $2, plz = $3, ort = $4, wohnflaeche_qm = $5, bemerkung = $6 WHERE id = $7 RETURNING *', + [name, adresse, plz, ort, wohnflaeche_qm, bemerkung, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Objekt löschen + app.delete('/api/objekte/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM objekte WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== OBJEKTKOSTEN ========== + + // Kosten für ein Objekt laden (optional gefiltert nach Jahr) + app.get('/api/objekte/:id/kosten', async (req, res) => { + try { + const { jahr } = req.query; + let query = 'SELECT * FROM objektkosten WHERE objekt_id = $1'; + const params = [req.params.id]; + + if (jahr) { + query += ' AND jahr = $2'; + params.push(jahr); + } + + query += ' ORDER BY jahr DESC, kategorie ASC'; + + const result = await pool.query(query, params); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Kosten hinzufügen + app.post('/api/objekte/:id/kosten', async (req, res) => { + try { + const { kategorie, betrag, jahr } = req.body; + const result = await pool.query( + 'INSERT INTO objektkosten (objekt_id, kategorie, betrag, jahr) VALUES ($1, $2, $3, $4) RETURNING *', + [req.params.id, kategorie, betrag, jahr] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Kosten löschen + app.delete('/api/objekte/:id/kosten/:kostenId', async (req, res) => { + try { + await pool.query('DELETE FROM objektkosten WHERE id = $1 AND objekt_id = $2', [req.params.kostenId, req.params.id]); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Mieter eines Objekts + app.get('/api/objekte/:id/mieter', async (req, res) => { + try { + const result = await pool.query( + `SELECT m.*, mv.id as mietvertrag_id, mv.wohnflaeche_qm, mv.kaltmiete, + mv.nebenkosten_vorauszahlung, mv.vertragsbeginn, mv.vertragsende, mv.ist_aktuell + FROM mieter m + JOIN mietvertraege mv ON mv.mieter_id = m.id + WHERE mv.objekt_id = $1 + ORDER BY m.name`, + [req.params.id] + ); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== MIETER ========== + + app.get('/api/mieter', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM mieter ORDER BY name'); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.get('/api/mieter/:id', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM mieter WHERE id = $1', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/mieter', async (req, res) => { + try { + const { name, email, telefon, adresse } = req.body; + const result = await pool.query( + 'INSERT INTO mieter (name, email, telefon, adresse) VALUES ($1, $2, $3, $4) RETURNING *', + [name, email, telefon, adresse] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.put('/api/mieter/:id', async (req, res) => { + try { + const { name, email, telefon, adresse } = req.body; + const result = await pool.query( + 'UPDATE mieter SET name = $1, email = $2, telefon = $3, adresse = $4 WHERE id = $5 RETURNING *', + [name, email, telefon, adresse, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.delete('/api/mieter/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM mieter WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Mietverträge eines Mieters + app.get('/api/mieter/:id/mietvertraege', async (req, res) => { + try { + const result = await pool.query( + `SELECT mv.*, o.name as objekt_name + FROM mietvertraege mv + JOIN objekte o ON o.id = mv.objekt_id + WHERE mv.mieter_id = $1 + ORDER BY mv.vertragsbeginn DESC`, + [req.params.id] + ); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== MIETVERTRÄGE ========== + + app.get('/api/mietvertraege', async (req, res) => { + try { + const result = await pool.query( + `SELECT mv.*, m.name as mieter_name, o.name as objekt_name + FROM mietvertraege mv + JOIN mieter m ON m.id = mv.mieter_id + JOIN objekte o ON o.id = mv.objekt_id + ORDER BY mv.vertragsbeginn DESC` + ); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== MIETVERTRÄGE ========== + + app.get('/api/mietvertraege', async (req, res) => { + try { + const { objekt_id, ist_aktuell } = req.query; + let query = `SELECT mv.*, o.name as objekt_name, o.adresse, o.plz, o.ort, + m.name as mieter_name, m.email as mieter_email + FROM mietvertraege mv + JOIN objekte o ON o.id = mv.objekt_id + JOIN mieter m ON m.id = mv.mieter_id`; + const values = []; + const conditions = []; + + if (objekt_id) { + conditions.push(`mv.objekt_id = $${values.length + 1}`); + values.push(objekt_id); + } + if (ist_aktuell !== undefined) { + conditions.push(`mv.ist_aktuell = $${values.length + 1}`); + values.push(ist_aktuell === 'true'); + } + + if (conditions.length > 0) { + query += ' WHERE ' + conditions.join(' AND '); + } + + query += ' ORDER BY mv.vertragsbeginn DESC'; + + const result = await pool.query(query, values); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/mietvertraege', async (req, res) => { + try { + const { objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn } = req.body; + const result = await pool.query( + `INSERT INTO mietvertraege (objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, + [objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung || 0, vertragsbeginn] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.put('/api/mietvertraege/:id', async (req, res) => { + try { + const { wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsende, ist_aktuell } = req.body; + const result = await pool.query( + `UPDATE mietvertraege SET wohnflaeche_qm = $1, kaltmiete = $2, nebenkosten_vorauszahlung = $3, + vertragsende = $4, ist_aktuell = $5 WHERE id = $6 RETURNING *`, + [wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsende, ist_aktuell !== undefined ? ist_aktuell : true, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/mietvertraege/:id/beenden', async (req, res) => { + try { + const { vertragsende } = req.body; + const result = await pool.query( + `UPDATE mietvertraege SET vertragsende = $1, ist_aktuell = false WHERE id = $2 RETURNING *`, + [vertragsende, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.delete('/api/mietvertraege/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM mietvertraege WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== OBJEKTKOSTEN ========== + + app.get('/api/objektkosten', async (req, res) => { + try { + const { objekt_id, jahr } = req.query; + let query = 'SELECT ok.*, o.name as objekt_name FROM objektkosten ok JOIN objekte o ON o.id = ok.objekt_id'; + const values = []; + const conditions = []; + + if (objekt_id) { + conditions.push(`ok.objekt_id = $${values.length + 1}`); + values.push(objekt_id); + } + if (jahr) { + conditions.push(`ok.jahr = $${values.length + 1}`); + values.push(parseInt(jahr)); + } + + if (conditions.length > 0) { + query += ' WHERE ' + conditions.join(' AND '); + } + + query += ' ORDER BY ok.jahr DESC, ok.kategorie, ok.datum'; + + const result = await pool.query(query, values); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/objektkosten', async (req, res) => { + try { + const { objekt_id, kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung } = req.body; + const result = await pool.query( + `INSERT INTO objektkosten (objekt_id, kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, + [objekt_id, kategorie, bezeichnung, betrag, datum, jahr || new Date().getFullYear(), verteilung || 'qm', bemerkung] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.put('/api/objektkosten/:id', async (req, res) => { + try { + const { kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung } = req.body; + const result = await pool.query( + `UPDATE objektkosten SET kategorie = $1, bezeichnung = $2, betrag = $3, datum = $4, jahr = $5, verteilung = $6, bemerkung = $7 + WHERE id = $8 RETURNING *`, + [kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Kosten nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.delete('/api/objektkosten/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM objektkosten WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Kosten nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== VORAUSZAHLUNGEN ========== + + app.get('/api/vorauszahlungen', async (req, res) => { + try { + const { objekt_id, mieter_id, jahr } = req.query; + let query = `SELECT v.*, o.name as objekt_name, m.name as mieter_name + FROM vorauszahlungen v + JOIN objekte o ON o.id = v.objekt_id + JOIN mieter m ON m.id = v.mieter_id`; + const values = []; + const conditions = []; + + if (objekt_id) { + conditions.push(`v.objekt_id = $${values.length + 1}`); + values.push(objekt_id); + } + if (mieter_id) { + conditions.push(`v.mieter_id = $${values.length + 1}`); + values.push(mieter_id); + } + if (jahr) { + conditions.push(`v.jahr = $${values.length + 1}`); + values.push(parseInt(jahr)); + } + + if (conditions.length > 0) { + query += ' WHERE ' + conditions.join(' AND '); + } + + query += ' ORDER BY v.jahr DESC, v.monat'; + + const result = await pool.query(query, values); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/vorauszahlungen', async (req, res) => { + try { + const { objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung } = req.body; + const result = await pool.query( + `INSERT INTO vorauszahlungen (objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, + [objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Bulk: Vorauszahlungen für Jahr erstellen + app.post('/api/vorauszahlungen/bulk', async (req, res) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const { objektId, mieterId, jahr, monatlicherBetrag } = req.body; + const results = []; + + for (let monat = 1; monat <= 12; monat++) { + const result = await client.query( + `INSERT INTO vorauszahlungen (objekt_id, mieter_id, jahr, monat, betrag) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (objekt_id, mieter_id, jahr, monat) + DO UPDATE SET betrag = $5 + RETURNING *`, + [objektId, mieterId, jahr, monat, monatlicherBetrag] + ); + results.push(result.rows[0]); + } + + await client.query('COMMIT'); + res.json({ success: true, created: results.length, vorauszahlungen: results }); + } catch (error) { + await client.query('ROLLBACK'); + res.status(500).json({ error: error.message }); + } finally { + client.release(); + } + }); + + app.put('/api/vorauszahlungen/:id', async (req, res) => { + try { + const { betrag, bezahlt_am, bemerkung } = req.body; + const result = await pool.query( + `UPDATE vorauszahlungen SET betrag = $1, bezahlt_am = $2, bemerkung = $3 WHERE id = $4 RETURNING *`, + [betrag, bezahlt_am, bemerkung, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Vorauszahlung nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.delete('/api/vorauszahlungen/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM vorauszahlungen WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Vorauszahlung nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== NEBENKOSTENABRECHNUNG ========== + + // Übersicht + app.get('/api/nebenkostenabrechnung/uebersicht', async (req, res) => { + try { + const { jahr = new Date().getFullYear() } = req.query; + + // Alle Objekte mit Summen + const result = await pool.query(` + SELECT o.*, + COALESCE(k.gesamt_kosten, 0) as gesamt_kosten, + COALESCE(v.gesamt_vorauszahlungen, 0) as gesamt_vorauszahlungen, + COALESCE(m.anzahl_mieter, 0) as anzahl_mieter + FROM objekte o + LEFT JOIN ( + SELECT objekt_id, SUM(betrag) as gesamt_kosten + FROM objektkosten WHERE jahr = $1 GROUP BY objekt_id + ) k ON k.objekt_id = o.id + LEFT JOIN ( + SELECT objekt_id, SUM(betrag) as gesamt_vorauszahlungen + FROM vorauszahlungen WHERE jahr = $1 GROUP BY objekt_id + ) v ON v.objekt_id = o.id + LEFT JOIN ( + SELECT objekt_id, COUNT(*) as anzahl_mieter + FROM mietvertraege WHERE ist_aktuell = true GROUP BY objekt_id + ) m ON m.objekt_id = o.id + ORDER BY o.name + `, [jahr]); + + res.json({ jahr: parseInt(jahr), objekte: result.rows }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Abrechnung Vorschau (Berechnung) + app.post('/api/nebenkostenabrechnung/vorschau', async (req, res) => { + try { + const { objektId, jahr, zeitraumVon, zeitraumBis } = req.body; + + // Objekt-Daten + const objektResult = await pool.query('SELECT * FROM objekte WHERE id = $1', [objektId]); + if (objektResult.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' }); + const objekt = objektResult.rows[0]; + + // Alle Kosten des Objekts im Jahr + const kostenResult = await pool.query( + 'SELECT * FROM objektkosten WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie', + [objektId, jahr] + ); + + // Aktuelle Mieter mit Mietverträgen + const mieterResult = await pool.query(` + SELECT mv.*, m.name as mieter_name, m.email as mieter_email + FROM mietvertraege mv + JOIN mieter m ON m.id = mv.mieter_id + WHERE mv.objekt_id = $1 AND mv.ist_aktuell = true + `, [objektId]); + + // Berechnung + const gesamtKosten = kostenResult.rows.reduce((sum, k) => sum + parseFloat(k.betrag), 0); + const gesamtFlaeche = mieterResult.rows.reduce((sum, m) => sum + parseFloat(m.wohnflaeche_qm), 0); + + // Tage im Abrechnungszeitraum + const vonDatum = new Date(zeitraumVon); + const bisDatum = new Date(zeitraumBis); + const tageGesamt = Math.ceil((bisDatum - vonDatum) / (1000 * 60 * 60 * 24)) + 1; + + // Pro Mieter berechnen + const berechnungen = []; + + for (const mietvertrag of mieterResult.rows) { + // Vorauszahlungen dieses Mieters + const vorauszahlungenResult = await pool.query( + 'SELECT SUM(betrag) as summe FROM vorauszahlungen WHERE objekt_id = $1 AND mieter_id = $2 AND jahr = $3', + [objektId, mietvertrag.mieter_id, jahr] + ); + const summeVorauszahlungen = parseFloat(vorauszahlungenResult.rows[0]?.summe || 0); + + // Anteil berechnen (pro-rata bei Mieterwechsel möglich) + const anteilQm = parseFloat(mietvertrag.wohnflaeche_qm); + const anteilTage = tageGesamt; // Hier könnte differenzierte Logik für Ein-/Auszug stehen + const anteilKosten = gesamtKosten * (anteilQm / gesamtFlaeche); + + const ergebnis = anteilKosten - summeVorauszahlungen; + + berechnungen.push({ + mietvertrag_id: mietvertrag.id, + mieter_id: mietvertrag.mieter_id, + mieter_name: mietvertrag.mieter_name, + anteil_qm: anteilQm, + anteil_tage: anteilTage, + anteil_kosten: Math.round(anteilKosten * 100) / 100, + summe_vorauszahlungen: summeVorauszahlungen, + ergebnis: Math.round(ergebnis * 100) / 100, + ist_nachzahlung: ergebnis > 0, + betrag_nachzahlung: ergebnis > 0 ? ergebnis : 0, + betrag_gutschrift: ergebnis < 0 ? Math.abs(ergebnis) : 0 + }); + } + + res.json({ + objekt, + jahr, + zeitraum_von: zeitraumVon, + zeitraum_bis: zeitraumBis, + tage_gesamt: tageGesamt, + gesamt_kosten: gesamtKosten, + gesamt_flaeche: gesamtFlaeche, + kosten: kostenResult.rows, + mieter: mieterResult.rows, + berechnungen + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Abrechnung speichern + app.post('/api/nebenkostenabrechnung', async (req, res) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const { objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf, berechnungen } = req.body; + + // Bestehende Abrechnung prüfen/löschen + await client.query( + 'DELETE FROM nebenkostenabrechnungen WHERE objekt_id = $1 AND jahr = $2', + [objekt_id, jahr] + ); + + // Neue Abrechnung erstellen + const abrechnungResult = await client.query( + `INSERT INTO nebenkostenabrechnungen (objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf) + VALUES ($1, $2, $3, $4, $5) RETURNING *`, + [objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf !== false] + ); + const abrechnung = abrechnungResult.rows[0]; + + // Positionen speichern + for (const pos of berechnungen) { + await client.query( + `INSERT INTO abrechnungspositionen + (abrechnung_id, mieter_id, mietvertrag_id, anteil_qm, anteil_tage, anteil_kosten, summe_vorauszahlungen, ergebnis) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [abrechnung.id, pos.mieter_id, pos.mietvertrag_id, pos.anteil_qm, pos.anteil_tage, + pos.anteil_kosten, pos.summe_vorauszahlungen, pos.ergebnis] + ); + } + + await client.query('COMMIT'); + res.json({ success: true, abrechnung: abrechnungResult.rows[0] }); + } catch (error) { + await client.query('ROLLBACK'); + res.status(500).json({ error: error.message }); + } finally { + client.release(); + } + }); + + // Alle Abrechnungen + app.get('/api/nebenkostenabrechnung', async (req, res) => { + try { + const result = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + ORDER BY na.jahr DESC, o.name + `); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Alias für Frontend (Plural) + app.get('/api/nebenkostenabrechnungen', async (req, res) => { + try { + const result = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + ORDER BY na.jahr DESC, o.name + `); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Einzelne Abrechnung mit Positionen + app.get('/api/nebenkostenabrechnung/:id', async (req, res) => { + try { + const abrechnungResult = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + WHERE na.id = $1 + `, [req.params.id]); + + if (abrechnungResult.rows.length === 0) { + return res.status(404).json({ error: 'Abrechnung nicht gefunden' }); + } + + const positionenResult = await pool.query(` + SELECT ap.*, m.name as mieter_name, m.email as mieter_email + FROM abrechnungspositionen ap + JOIN mieter m ON m.id = ap.mieter_id + WHERE ap.abrechnung_id = $1 + `, [req.params.id]); + + const kostenResult = await pool.query(` + SELECT * FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 + `, [abrechnungResult.rows[0].objekt_id, abrechnungResult.rows[0].jahr]); + + res.json({ + abrechnung: abrechnungResult.rows[0], + positionen: positionenResult.rows, + kosten: kostenResult.rows + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Status aktualisieren (Entwurf/Final) + app.put('/api/nebenkostenabrechnung/:id/status', async (req, res) => { + try { + const { ist_entwurf } = req.body; + const result = await pool.query( + 'UPDATE nebenkostenabrechnungen SET ist_entwurf = $1 WHERE id = $2 RETURNING *', + [ist_entwurf, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Abrechnung nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // PDF Export - Privat (René Täger) - EINE PDF pro Mieter + app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => { + try { + const { PDFDocument, rgb, StandardFonts } = require('pdf-lib'); + + const abrechnungResult = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort, o.wohnflaeche_qm as objekt_flaeche + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + WHERE na.id = $1 + `, [req.params.id]); + + if (abrechnungResult.rows.length === 0) { + return res.status(404).json({ error: 'Abrechnung nicht gefunden' }); + } + + const abrechnung = abrechnungResult.rows[0]; + + // ALLE Positionen laden + const positionenResult = await pool.query(` + SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse + FROM abrechnungspositionen ap + JOIN mieter m ON m.id = ap.mieter_id + WHERE ap.abrechnung_id = $1 + `, [req.params.id]); + + const kostenResult = await pool.query(` + SELECT * FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie + `, [abrechnung.objekt_id, abrechnung.jahr]); + + // Berechnungsgrundlagen + const gesamtKosten = kostenResult.rows.reduce((sum, k) => sum + parseFloat(k.betrag), 0); + const gesamtFlaeche = parseFloat(abrechnung.objekt_flaeche || 95); + const kostenProQm = gesamtKosten / gesamtFlaeche; + + // PDF erstellen + const pdfDoc = await PDFDocument.create(); + const width = 595.28; + const height = 841.89; + const margin = 50; + + const font = await pdfDoc.embedFont(StandardFonts.Helvetica); + const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + + const istEntwurf = req.query.entwurf === 'true'; + const heute = new Date().toLocaleDateString('de-DE'); + + // Hilfsfunktion für Trennlinie + function drawLine(page, yPos) { + page.drawLine({ + start: { x: margin, y: yPos }, + end: { x: width - margin, y: yPos }, + thickness: 0.5, + color: rgb(0.8, 0.8, 0.8) + }); + } + + // ========== FÜR JEDEN MIETER EINE SEITE ========== + for (const pos of positionenResult.rows) { + const mieterName = pos.mieter_name; + const mieterAdresse = pos.mieter_adresse || ''; + const mieterFlaeche = parseFloat(pos.anteil_qm); + const anteilKosten = parseFloat(pos.anteil_kosten); + const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen); + const ergebnis = parseFloat(pos.ergebnis); + + // Neue Seite für diesen Mieter + const page = pdfDoc.addPage([width, height]); + let y = height - 50; + + // ========== ABSENDER (oben links) ========== + page.drawText('René Täger', { x: margin, y, size: 10, font: fontBold }); + y -= 14; + page.drawText('Zingelstraße 7', { x: margin, y, size: 9, font }); + y -= 14; + page.drawText('25554 Wilster', { x: margin, y, size: 9, font }); + y -= 30; + + // Trennlinie + drawLine(page, y); + y -= 30; + + // ========== EMPFÄNGER (nur dieser Mieter) ========== + page.drawText(mieterName, { x: margin, y, size: 11, font: fontBold }); + y -= 16; + if (mieterAdresse) { + const adressZeilen = mieterAdresse.split('\n'); + for (const zeile of adressZeilen) { + page.drawText(zeile, { x: margin, y, size: 10, font }); + y -= 14; + } + } + y -= 40; + + // ========== DATUM + BETREFF ========== + page.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font }); + + page.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 25; + + if (istEntwurf) { + page.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) }); + } + + page.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font }); + y -= 20; + page.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold }); + y -= 16; + page.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font }); + y -= 16; + page.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font }); + y -= 35; + + // ========== BEGLEITTEXT ========== + page.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold }); + y -= 18; + page.drawText('im Folgenden erhalten Sie Ihre persönliche Nebenkostenabrechnung.', { x: margin, y, size: 9, font }); + y -= 14; + page.drawText('Die Kosten wurden nach Ihrem im Mietvertrag vereinbarten Anteil umgelegt.', { x: margin, y, size: 9, font }); + y -= 30; + + // ========== 1. KOSTENÜBERSICHT (alle Kosten des Objekts) ========== + page.drawText('1. Kostenübersicht des Objekts:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + // Tabellenkopf + page.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold }); + page.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold }); + page.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold }); + y -= 12; + drawLine(page, y + 8); + y -= 5; + + for (const k of kostenResult.rows) { + const betrag = parseFloat(k.betrag); + page.drawText(k.kategorie, { x: margin, y, size: 9, font }); + page.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font }); + page.drawText(`${betrag.toFixed(2)} €`, { x: margin + 350, y, size: 9, font }); + y -= 14; + } + + y -= 5; + drawLine(page, y + 8); + y -= 5; + page.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold }); + page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 350, y, size: 10, font: fontBold }); + y -= 30; + + // ========== 2. BERECHNUNGSGRUNDLAGE ========== + page.drawText('2. Berechnungsgrundlage:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + page.drawText(`Gesamtkosten:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Gesamtwohnfläche:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${gesamtFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Kosten pro qm:`, { x: margin + 20, y, size: 9, font: fontBold }); + page.drawText(`${gesamtKosten.toFixed(2)} € / ${gesamtFlaeche.toFixed(2)} qm = ${kostenProQm.toFixed(2)} €/qm`, { x: margin + 250, y, size: 9, font: fontBold }); + y -= 30; + + // ========== 3. IHRE PERSÖNLICHE BERECHNUNG ========== + page.drawText('3. Ihre persönliche Berechnung:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + page.drawText(`Ihr Name:`, { x: margin + 20, y, size: 9, font }); + page.drawText(mieterName, { x: margin + 250, y, size: 9, font: fontBold }); + y -= 14; + page.drawText(`Ihre Wohnfläche:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${mieterFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Ihr Anteil an den Kosten:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${kostenProQm.toFixed(2)} €/qm × ${mieterFlaeche.toFixed(2)} qm = ${anteilKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Geleistete Vorauszahlungen:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${vorauszahlungen.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 25; + + // Ergebnis hervorgehoben + drawLine(page, y + 8); + y -= 5; + + if (ergebnis > 0) { + page.drawText(`Nachzahlung:`, { x: margin + 20, y, size: 11, font: fontBold }); + page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold }); + y -= 20; + page.drawText(`Bitte überweisen Sie den Betrag innerhalb von 14 Tagen.`, { x: margin + 20, y, size: 9, font, color: rgb(0.8, 0.2, 0.2) }); + } else { + page.drawText(`Gutschrift:`, { x: margin + 20, y, size: 11, font: fontBold }); + page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold }); + y -= 20; + page.drawText(`Der Betrag wird mit der nächsten Miete verrechnet.`, { x: margin + 20, y, size: 9, font, color: rgb(0.2, 0.6, 0.2) }); + } + y -= 25; + + // ========== FUSSZEILE ========== + y -= 20; + drawLine(page, y); + y -= 20; + page.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) }); + y -= 12; + page.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) }); + } + + // PDF speichern + const pdfBytes = await pdfDoc.save(); + const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes); + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Length', pdfBuffer.length); + res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`); + res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition'); + res.end(pdfBuffer); + } catch (error) { + console.error('PDF Error:', error); + res.status(500).json({ error: error.message }); + } + }); + + +}; diff --git a/backend/routes/nebenkosten_container.js.unix b/backend/routes/nebenkosten_container.js.unix new file mode 100644 index 0000000..fa53e10 --- /dev/null +++ b/backend/routes/nebenkosten_container.js.unix @@ -0,0 +1,962 @@ +// API Routes für Nebenkostenabrechnung (Objekte, Mieter, Mietverträge, Kosten, Vorauszahlungen, Abrechnungen) +const { Pool } = require('pg'); + +const pool = new Pool({ + host: process.env.DB_HOST || 'buchhaltung-db', + port: process.env.DB_PORT || 5432, + database: process.env.DB_NAME || 'buchhaltung', + user: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || 'postgres', +}); + +module.exports = (app) => { + + // ========== OBJEKTE ========== + + // Alle Objekte + app.get('/api/objekte', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM objekte ORDER BY name'); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Einzelnes Objekt + app.get('/api/objekte/:id', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM objekte WHERE id = $1', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Objekt erstellen + app.post('/api/objekte', async (req, res) => { + try { + const { name, adresse, plz, ort, wohnflaeche_qm, bemerkung } = req.body; + const result = await pool.query( + 'INSERT INTO objekte (name, adresse, plz, ort, wohnflaeche_qm, bemerkung) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *', + [name, adresse, plz, ort, wohnflaeche_qm || 0, bemerkung] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Objekt aktualisieren + app.put('/api/objekte/:id', async (req, res) => { + try { + const { name, adresse, plz, ort, wohnflaeche_qm, bemerkung } = req.body; + const result = await pool.query( + 'UPDATE objekte SET name = $1, adresse = $2, plz = $3, ort = $4, wohnflaeche_qm = $5, bemerkung = $6 WHERE id = $7 RETURNING *', + [name, adresse, plz, ort, wohnflaeche_qm, bemerkung, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Objekt löschen + app.delete('/api/objekte/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM objekte WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== OBJEKTKOSTEN ========== + + // Kosten für ein Objekt laden (optional gefiltert nach Jahr) + app.get('/api/objekte/:id/kosten', async (req, res) => { + try { + const { jahr } = req.query; + let query = 'SELECT * FROM objektkosten WHERE objekt_id = $1'; + const params = [req.params.id]; + + if (jahr) { + query += ' AND jahr = $2'; + params.push(jahr); + } + + query += ' ORDER BY jahr DESC, kategorie ASC'; + + const result = await pool.query(query, params); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Kosten hinzufügen + app.post('/api/objekte/:id/kosten', async (req, res) => { + try { + const { kategorie, betrag, jahr } = req.body; + const result = await pool.query( + 'INSERT INTO objektkosten (objekt_id, kategorie, betrag, jahr) VALUES ($1, $2, $3, $4) RETURNING *', + [req.params.id, kategorie, betrag, jahr] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Kosten löschen + app.delete('/api/objekte/:id/kosten/:kostenId', async (req, res) => { + try { + await pool.query('DELETE FROM objektkosten WHERE id = $1 AND objekt_id = $2', [req.params.kostenId, req.params.id]); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Mieter eines Objekts + app.get('/api/objekte/:id/mieter', async (req, res) => { + try { + const result = await pool.query( + `SELECT m.*, mv.id as mietvertrag_id, mv.wohnflaeche_qm, mv.kaltmiete, + mv.nebenkosten_vorauszahlung, mv.vertragsbeginn, mv.vertragsende, mv.ist_aktuell + FROM mieter m + JOIN mietvertraege mv ON mv.mieter_id = m.id + WHERE mv.objekt_id = $1 + ORDER BY m.name`, + [req.params.id] + ); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== MIETER ========== + + app.get('/api/mieter', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM mieter ORDER BY name'); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.get('/api/mieter/:id', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM mieter WHERE id = $1', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/mieter', async (req, res) => { + try { + const { name, email, telefon, adresse } = req.body; + const result = await pool.query( + 'INSERT INTO mieter (name, email, telefon, adresse) VALUES ($1, $2, $3, $4) RETURNING *', + [name, email, telefon, adresse] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.put('/api/mieter/:id', async (req, res) => { + try { + const { name, email, telefon, adresse } = req.body; + const result = await pool.query( + 'UPDATE mieter SET name = $1, email = $2, telefon = $3, adresse = $4 WHERE id = $5 RETURNING *', + [name, email, telefon, adresse, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.delete('/api/mieter/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM mieter WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Mietverträge eines Mieters + app.get('/api/mieter/:id/mietvertraege', async (req, res) => { + try { + const result = await pool.query( + `SELECT mv.*, o.name as objekt_name + FROM mietvertraege mv + JOIN objekte o ON o.id = mv.objekt_id + WHERE mv.mieter_id = $1 + ORDER BY mv.vertragsbeginn DESC`, + [req.params.id] + ); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== MIETVERTRÄGE ========== + + app.get('/api/mietvertraege', async (req, res) => { + try { + const result = await pool.query( + `SELECT mv.*, m.name as mieter_name, o.name as objekt_name + FROM mietvertraege mv + JOIN mieter m ON m.id = mv.mieter_id + JOIN objekte o ON o.id = mv.objekt_id + ORDER BY mv.vertragsbeginn DESC` + ); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== MIETVERTRÄGE ========== + + app.get('/api/mietvertraege', async (req, res) => { + try { + const { objekt_id, ist_aktuell } = req.query; + let query = `SELECT mv.*, o.name as objekt_name, o.adresse, o.plz, o.ort, + m.name as mieter_name, m.email as mieter_email + FROM mietvertraege mv + JOIN objekte o ON o.id = mv.objekt_id + JOIN mieter m ON m.id = mv.mieter_id`; + const values = []; + const conditions = []; + + if (objekt_id) { + conditions.push(`mv.objekt_id = $${values.length + 1}`); + values.push(objekt_id); + } + if (ist_aktuell !== undefined) { + conditions.push(`mv.ist_aktuell = $${values.length + 1}`); + values.push(ist_aktuell === 'true'); + } + + if (conditions.length > 0) { + query += ' WHERE ' + conditions.join(' AND '); + } + + query += ' ORDER BY mv.vertragsbeginn DESC'; + + const result = await pool.query(query, values); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/mietvertraege', async (req, res) => { + try { + const { objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn } = req.body; + const result = await pool.query( + `INSERT INTO mietvertraege (objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, + [objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung || 0, vertragsbeginn] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.put('/api/mietvertraege/:id', async (req, res) => { + try { + const { wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsende, ist_aktuell } = req.body; + const result = await pool.query( + `UPDATE mietvertraege SET wohnflaeche_qm = $1, kaltmiete = $2, nebenkosten_vorauszahlung = $3, + vertragsende = $4, ist_aktuell = $5 WHERE id = $6 RETURNING *`, + [wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsende, ist_aktuell !== undefined ? ist_aktuell : true, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/mietvertraege/:id/beenden', async (req, res) => { + try { + const { vertragsende } = req.body; + const result = await pool.query( + `UPDATE mietvertraege SET vertragsende = $1, ist_aktuell = false WHERE id = $2 RETURNING *`, + [vertragsende, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.delete('/api/mietvertraege/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM mietvertraege WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== OBJEKTKOSTEN ========== + + app.get('/api/objektkosten', async (req, res) => { + try { + const { objekt_id, jahr } = req.query; + let query = 'SELECT ok.*, o.name as objekt_name FROM objektkosten ok JOIN objekte o ON o.id = ok.objekt_id'; + const values = []; + const conditions = []; + + if (objekt_id) { + conditions.push(`ok.objekt_id = $${values.length + 1}`); + values.push(objekt_id); + } + if (jahr) { + conditions.push(`ok.jahr = $${values.length + 1}`); + values.push(parseInt(jahr)); + } + + if (conditions.length > 0) { + query += ' WHERE ' + conditions.join(' AND '); + } + + query += ' ORDER BY ok.jahr DESC, ok.kategorie, ok.datum'; + + const result = await pool.query(query, values); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/objektkosten', async (req, res) => { + try { + const { objekt_id, kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung } = req.body; + const result = await pool.query( + `INSERT INTO objektkosten (objekt_id, kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, + [objekt_id, kategorie, bezeichnung, betrag, datum, jahr || new Date().getFullYear(), verteilung || 'qm', bemerkung] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.put('/api/objektkosten/:id', async (req, res) => { + try { + const { kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung } = req.body; + const result = await pool.query( + `UPDATE objektkosten SET kategorie = $1, bezeichnung = $2, betrag = $3, datum = $4, jahr = $5, verteilung = $6, bemerkung = $7 + WHERE id = $8 RETURNING *`, + [kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Kosten nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.delete('/api/objektkosten/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM objektkosten WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Kosten nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== VORAUSZAHLUNGEN ========== + + app.get('/api/vorauszahlungen', async (req, res) => { + try { + const { objekt_id, mieter_id, jahr } = req.query; + let query = `SELECT v.*, o.name as objekt_name, m.name as mieter_name + FROM vorauszahlungen v + JOIN objekte o ON o.id = v.objekt_id + JOIN mieter m ON m.id = v.mieter_id`; + const values = []; + const conditions = []; + + if (objekt_id) { + conditions.push(`v.objekt_id = $${values.length + 1}`); + values.push(objekt_id); + } + if (mieter_id) { + conditions.push(`v.mieter_id = $${values.length + 1}`); + values.push(mieter_id); + } + if (jahr) { + conditions.push(`v.jahr = $${values.length + 1}`); + values.push(parseInt(jahr)); + } + + if (conditions.length > 0) { + query += ' WHERE ' + conditions.join(' AND '); + } + + query += ' ORDER BY v.jahr DESC, v.monat'; + + const result = await pool.query(query, values); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/vorauszahlungen', async (req, res) => { + try { + const { objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung } = req.body; + const result = await pool.query( + `INSERT INTO vorauszahlungen (objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, + [objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Bulk: Vorauszahlungen für Jahr erstellen + app.post('/api/vorauszahlungen/bulk', async (req, res) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const { objektId, mieterId, jahr, monatlicherBetrag } = req.body; + const results = []; + + for (let monat = 1; monat <= 12; monat++) { + const result = await client.query( + `INSERT INTO vorauszahlungen (objekt_id, mieter_id, jahr, monat, betrag) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (objekt_id, mieter_id, jahr, monat) + DO UPDATE SET betrag = $5 + RETURNING *`, + [objektId, mieterId, jahr, monat, monatlicherBetrag] + ); + results.push(result.rows[0]); + } + + await client.query('COMMIT'); + res.json({ success: true, created: results.length, vorauszahlungen: results }); + } catch (error) { + await client.query('ROLLBACK'); + res.status(500).json({ error: error.message }); + } finally { + client.release(); + } + }); + + app.put('/api/vorauszahlungen/:id', async (req, res) => { + try { + const { betrag, bezahlt_am, bemerkung } = req.body; + const result = await pool.query( + `UPDATE vorauszahlungen SET betrag = $1, bezahlt_am = $2, bemerkung = $3 WHERE id = $4 RETURNING *`, + [betrag, bezahlt_am, bemerkung, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Vorauszahlung nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.delete('/api/vorauszahlungen/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM vorauszahlungen WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Vorauszahlung nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== NEBENKOSTENABRECHNUNG ========== + + // Übersicht + app.get('/api/nebenkostenabrechnung/uebersicht', async (req, res) => { + try { + const { jahr = new Date().getFullYear() } = req.query; + + // Alle Objekte mit Summen + const result = await pool.query(` + SELECT o.*, + COALESCE(k.gesamt_kosten, 0) as gesamt_kosten, + COALESCE(v.gesamt_vorauszahlungen, 0) as gesamt_vorauszahlungen, + COALESCE(m.anzahl_mieter, 0) as anzahl_mieter + FROM objekte o + LEFT JOIN ( + SELECT objekt_id, SUM(betrag) as gesamt_kosten + FROM objektkosten WHERE jahr = $1 GROUP BY objekt_id + ) k ON k.objekt_id = o.id + LEFT JOIN ( + SELECT objekt_id, SUM(betrag) as gesamt_vorauszahlungen + FROM vorauszahlungen WHERE jahr = $1 GROUP BY objekt_id + ) v ON v.objekt_id = o.id + LEFT JOIN ( + SELECT objekt_id, COUNT(*) as anzahl_mieter + FROM mietvertraege WHERE ist_aktuell = true GROUP BY objekt_id + ) m ON m.objekt_id = o.id + ORDER BY o.name + `, [jahr]); + + res.json({ jahr: parseInt(jahr), objekte: result.rows }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Abrechnung Vorschau (Berechnung) + app.post('/api/nebenkostenabrechnung/vorschau', async (req, res) => { + try { + const { objektId, jahr, zeitraumVon, zeitraumBis } = req.body; + + // Objekt-Daten + const objektResult = await pool.query('SELECT * FROM objekte WHERE id = $1', [objektId]); + if (objektResult.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' }); + const objekt = objektResult.rows[0]; + + // Alle Kosten des Objekts im Jahr + const kostenResult = await pool.query( + 'SELECT * FROM objektkosten WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie', + [objektId, jahr] + ); + + // Aktuelle Mieter mit Mietverträgen + const mieterResult = await pool.query(` + SELECT mv.*, m.name as mieter_name, m.email as mieter_email + FROM mietvertraege mv + JOIN mieter m ON m.id = mv.mieter_id + WHERE mv.objekt_id = $1 AND mv.ist_aktuell = true + `, [objektId]); + + // Berechnung + const gesamtKosten = kostenResult.rows.reduce((sum, k) => sum + parseFloat(k.betrag), 0); + const gesamtFlaeche = mieterResult.rows.reduce((sum, m) => sum + parseFloat(m.wohnflaeche_qm), 0); + + // Tage im Abrechnungszeitraum + const vonDatum = new Date(zeitraumVon); + const bisDatum = new Date(zeitraumBis); + const tageGesamt = Math.ceil((bisDatum - vonDatum) / (1000 * 60 * 60 * 24)) + 1; + + // Pro Mieter berechnen + const berechnungen = []; + + for (const mietvertrag of mieterResult.rows) { + // Vorauszahlungen dieses Mieters + const vorauszahlungenResult = await pool.query( + 'SELECT SUM(betrag) as summe FROM vorauszahlungen WHERE objekt_id = $1 AND mieter_id = $2 AND jahr = $3', + [objektId, mietvertrag.mieter_id, jahr] + ); + const summeVorauszahlungen = parseFloat(vorauszahlungenResult.rows[0]?.summe || 0); + + // Anteil berechnen (pro-rata bei Mieterwechsel möglich) + const anteilQm = parseFloat(mietvertrag.wohnflaeche_qm); + const anteilTage = tageGesamt; // Hier könnte differenzierte Logik für Ein-/Auszug stehen + const anteilKosten = gesamtKosten * (anteilQm / gesamtFlaeche); + + const ergebnis = anteilKosten - summeVorauszahlungen; + + berechnungen.push({ + mietvertrag_id: mietvertrag.id, + mieter_id: mietvertrag.mieter_id, + mieter_name: mietvertrag.mieter_name, + anteil_qm: anteilQm, + anteil_tage: anteilTage, + anteil_kosten: Math.round(anteilKosten * 100) / 100, + summe_vorauszahlungen: summeVorauszahlungen, + ergebnis: Math.round(ergebnis * 100) / 100, + ist_nachzahlung: ergebnis > 0, + betrag_nachzahlung: ergebnis > 0 ? ergebnis : 0, + betrag_gutschrift: ergebnis < 0 ? Math.abs(ergebnis) : 0 + }); + } + + res.json({ + objekt, + jahr, + zeitraum_von: zeitraumVon, + zeitraum_bis: zeitraumBis, + tage_gesamt: tageGesamt, + gesamt_kosten: gesamtKosten, + gesamt_flaeche: gesamtFlaeche, + kosten: kostenResult.rows, + mieter: mieterResult.rows, + berechnungen + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Abrechnung speichern + app.post('/api/nebenkostenabrechnung', async (req, res) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const { objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf, berechnungen } = req.body; + + // Bestehende Abrechnung prüfen/löschen + await client.query( + 'DELETE FROM nebenkostenabrechnungen WHERE objekt_id = $1 AND jahr = $2', + [objekt_id, jahr] + ); + + // Neue Abrechnung erstellen + const abrechnungResult = await client.query( + `INSERT INTO nebenkostenabrechnungen (objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf) + VALUES ($1, $2, $3, $4, $5) RETURNING *`, + [objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf !== false] + ); + const abrechnung = abrechnungResult.rows[0]; + + // Positionen speichern + for (const pos of berechnungen) { + await client.query( + `INSERT INTO abrechnungspositionen + (abrechnung_id, mieter_id, mietvertrag_id, anteil_qm, anteil_tage, anteil_kosten, summe_vorauszahlungen, ergebnis) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [abrechnung.id, pos.mieter_id, pos.mietvertrag_id, pos.anteil_qm, pos.anteil_tage, + pos.anteil_kosten, pos.summe_vorauszahlungen, pos.ergebnis] + ); + } + + await client.query('COMMIT'); + res.json({ success: true, abrechnung: abrechnungResult.rows[0] }); + } catch (error) { + await client.query('ROLLBACK'); + res.status(500).json({ error: error.message }); + } finally { + client.release(); + } + }); + + // Alle Abrechnungen + app.get('/api/nebenkostenabrechnung', async (req, res) => { + try { + const result = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + ORDER BY na.jahr DESC, o.name + `); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Alias für Frontend (Plural) + app.get('/api/nebenkostenabrechnungen', async (req, res) => { + try { + const result = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + ORDER BY na.jahr DESC, o.name + `); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Einzelne Abrechnung mit Positionen + app.get('/api/nebenkostenabrechnung/:id', async (req, res) => { + try { + const abrechnungResult = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + WHERE na.id = $1 + `, [req.params.id]); + + if (abrechnungResult.rows.length === 0) { + return res.status(404).json({ error: 'Abrechnung nicht gefunden' }); + } + + const positionenResult = await pool.query(` + SELECT ap.*, m.name as mieter_name, m.email as mieter_email + FROM abrechnungspositionen ap + JOIN mieter m ON m.id = ap.mieter_id + WHERE ap.abrechnung_id = $1 + `, [req.params.id]); + + const kostenResult = await pool.query(` + SELECT * FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 + `, [abrechnungResult.rows[0].objekt_id, abrechnungResult.rows[0].jahr]); + + res.json({ + abrechnung: abrechnungResult.rows[0], + positionen: positionenResult.rows, + kosten: kostenResult.rows + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Status aktualisieren (Entwurf/Final) + app.put('/api/nebenkostenabrechnung/:id/status', async (req, res) => { + try { + const { ist_entwurf } = req.body; + const result = await pool.query( + 'UPDATE nebenkostenabrechnungen SET ist_entwurf = $1 WHERE id = $2 RETURNING *', + [ist_entwurf, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Abrechnung nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // PDF Export - Privat (René Täger) - EINE PDF pro Mieter + app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => { + try { + const { PDFDocument, rgb, StandardFonts } = require('pdf-lib'); + + const abrechnungResult = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort, o.wohnflaeche_qm as objekt_flaeche + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + WHERE na.id = $1 + `, [req.params.id]); + + if (abrechnungResult.rows.length === 0) { + return res.status(404).json({ error: 'Abrechnung nicht gefunden' }); + } + + const abrechnung = abrechnungResult.rows[0]; + + // ALLE Positionen laden + const positionenResult = await pool.query(` + SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse + FROM abrechnungspositionen ap + JOIN mieter m ON m.id = ap.mieter_id + WHERE ap.abrechnung_id = $1 + `, [req.params.id]); + + const kostenResult = await pool.query(` + SELECT * FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie + `, [abrechnung.objekt_id, abrechnung.jahr]); + + // Berechnungsgrundlagen + const gesamtKosten = kostenResult.rows.reduce((sum, k) => sum + parseFloat(k.betrag), 0); + const gesamtFlaeche = parseFloat(abrechnung.objekt_flaeche || 95); + const kostenProQm = gesamtKosten / gesamtFlaeche; + + // PDF erstellen + const pdfDoc = await PDFDocument.create(); + const width = 595.28; + const height = 841.89; + const margin = 50; + + const font = await pdfDoc.embedFont(StandardFonts.Helvetica); + const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + + const istEntwurf = req.query.entwurf === 'true'; + const heute = new Date().toLocaleDateString('de-DE'); + + // Hilfsfunktion für Trennlinie + function drawLine(page, yPos) { + page.drawLine({ + start: { x: margin, y: yPos }, + end: { x: width - margin, y: yPos }, + thickness: 0.5, + color: rgb(0.8, 0.8, 0.8) + }); + } + + // ========== FÜR JEDEN MIETER EINE SEITE ========== + for (const pos of positionenResult.rows) { + const mieterName = pos.mieter_name; + const mieterAdresse = pos.mieter_adresse || ''; + const mieterFlaeche = parseFloat(pos.anteil_qm); + const anteilKosten = parseFloat(pos.anteil_kosten); + const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen); + const ergebnis = parseFloat(pos.ergebnis); + + // Neue Seite für diesen Mieter + const page = pdfDoc.addPage([width, height]); + let y = height - 50; + + // ========== ABSENDER (oben links) ========== + page.drawText('René Täger', { x: margin, y, size: 10, font: fontBold }); + y -= 14; + page.drawText('Zingelstraße 7', { x: margin, y, size: 9, font }); + y -= 14; + page.drawText('25554 Wilster', { x: margin, y, size: 9, font }); + y -= 30; + + // Trennlinie + drawLine(page, y); + y -= 30; + + // ========== EMPFÄNGER (nur dieser Mieter) ========== + page.drawText(mieterName, { x: margin, y, size: 11, font: fontBold }); + y -= 16; + if (mieterAdresse) { + const adressZeilen = mieterAdresse.split('\n'); + for (const zeile of adressZeilen) { + page.drawText(zeile, { x: margin, y, size: 10, font }); + y -= 14; + } + } + y -= 40; + + // ========== DATUM + BETREFF ========== + page.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font }); + + page.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 25; + + if (istEntwurf) { + page.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) }); + } + + page.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font }); + y -= 20; + page.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold }); + y -= 16; + page.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font }); + y -= 16; + page.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font }); + y -= 35; + + // ========== BEGLEITTEXT ========== + page.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold }); + y -= 18; + page.drawText('im Folgenden erhalten Sie Ihre persönliche Nebenkostenabrechnung.', { x: margin, y, size: 9, font }); + y -= 14; + page.drawText('Die Kosten wurden nach Ihrem im Mietvertrag vereinbarten Anteil umgelegt.', { x: margin, y, size: 9, font }); + y -= 30; + + // ========== 1. KOSTENÜBERSICHT (alle Kosten des Objekts) ========== + page.drawText('1. Kostenübersicht des Objekts:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + // Tabellenkopf + page.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold }); + page.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold }); + page.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold }); + y -= 12; + drawLine(page, y + 8); + y -= 5; + + for (const k of kostenResult.rows) { + const betrag = parseFloat(k.betrag); + page.drawText(k.kategorie, { x: margin, y, size: 9, font }); + page.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font }); + page.drawText(`${betrag.toFixed(2)} €`, { x: margin + 350, y, size: 9, font }); + y -= 14; + } + + y -= 5; + drawLine(page, y + 8); + y -= 5; + page.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold }); + page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 350, y, size: 10, font: fontBold }); + y -= 30; + + // ========== 2. BERECHNUNGSGRUNDLAGE ========== + page.drawText('2. Berechnungsgrundlage:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + page.drawText(`Gesamtkosten:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Gesamtwohnfläche:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${gesamtFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Kosten pro qm:`, { x: margin + 20, y, size: 9, font: fontBold }); + page.drawText(`${gesamtKosten.toFixed(2)} € / ${gesamtFlaeche.toFixed(2)} qm = ${kostenProQm.toFixed(2)} €/qm`, { x: margin + 250, y, size: 9, font: fontBold }); + y -= 30; + + // ========== 3. IHRE PERSÖNLICHE BERECHNUNG ========== + page.drawText('3. Ihre persönliche Berechnung:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + page.drawText(`Ihr Name:`, { x: margin + 20, y, size: 9, font }); + page.drawText(mieterName, { x: margin + 250, y, size: 9, font: fontBold }); + y -= 14; + page.drawText(`Ihre Wohnfläche:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${mieterFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Ihr Anteil an den Kosten:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${kostenProQm.toFixed(2)} €/qm × ${mieterFlaeche.toFixed(2)} qm = ${anteilKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Geleistete Vorauszahlungen:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${vorauszahlungen.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 25; + + // Ergebnis hervorgehoben + drawLine(page, y + 8); + y -= 5; + + if (ergebnis > 0) { + page.drawText(`Nachzahlung:`, { x: margin + 20, y, size: 11, font: fontBold }); + page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold }); + y -= 20; + page.drawText(`Bitte überweisen Sie den Betrag innerhalb von 14 Tagen.`, { x: margin + 20, y, size: 9, font, color: rgb(0.8, 0.2, 0.2) }); + } else { + page.drawText(`Gutschrift:`, { x: margin + 20, y, size: 11, font: fontBold }); + page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold }); + y -= 20; + page.drawText(`Der Betrag wird mit der nächsten Miete verrechnet.`, { x: margin + 20, y, size: 9, font, color: rgb(0.2, 0.6, 0.2) }); + } + y -= 25; + + // ========== FUSSZEILE ========== + y -= 20; + drawLine(page, y); + y -= 20; + page.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) }); + y -= 12; + page.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) }); + } + + // PDF speichern + const pdfBytes = await pdfDoc.save(); + const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes); + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Length', pdfBuffer.length); + res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`); + res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition'); + res.end(pdfBuffer); + } catch (error) { + console.error('PDF Error:', error); + res.status(500).json({ error: error.message }); + } + }); + + +}; diff --git a/backend/routes/nebenkosten_pdf.js b/backend/routes/nebenkosten_pdf.js new file mode 100644 index 0000000..539f85f --- /dev/null +++ b/backend/routes/nebenkosten_pdf.js @@ -0,0 +1,202 @@ + // PDF Export - Privat (René Täger) + app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => { + try { + const { PDFDocument, rgb, StandardFonts } = require('pdf-lib'); + + const abrechnungResult = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + WHERE na.id = $1 + `, [req.params.id]); + + if (abrechnungResult.rows.length === 0) { + return res.status(404).json({ error: 'Abrechnung nicht gefunden' }); + } + + const abrechnung = abrechnungResult.rows[0]; + const positionenResult = await pool.query(` + SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse + FROM abrechnungspositionen ap + JOIN mieter m ON m.id = ap.mieter_id + WHERE ap.abrechnung_id = $1 + `, [req.params.id]); + + const kostenResult = await pool.query(` + SELECT * FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie + `, [abrechnung.objekt_id, abrechnung.jahr]); + + // PDF erstellen + const pdfDoc = await PDFDocument.create(); + const width = 595.28; + const height = 841.89; + + let currentPage = pdfDoc.addPage([width, height]); + let y = height - 50; + const margin = 50; + const contentWidth = width - 2 * margin; + + const font = await pdfDoc.embedFont(StandardFonts.Helvetica); + const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + + const istEntwurf = req.query.entwurf === 'true'; + + // ========== ABSENDER (oben links) ========== + currentPage.drawText('René Täger', { x: margin, y, size: 10, font: fontBold }); + y -= 14; + currentPage.drawText('Zingelstraße 7', { x: margin, y, size: 9, font }); + y -= 14; + currentPage.drawText('25554 Wilster', { x: margin, y, size: 9, font }); + y -= 30; + + // Trennlinie + currentPage.drawLine({ + start: { x: margin, y }, + end: { x: width - margin, y }, + thickness: 1, + color: rgb(0.7, 0.7, 0.7) + }); + y -= 30; + + // ========== EMPFÄNGER (Mieter) ========== + const empfaenger = positionenResult.rows[0]; + if (empfaenger) { + currentPage.drawText(empfaenger.mieter_name || '', { x: margin, y, size: 11, font: fontBold }); + y -= 16; + if (empfaenger.mieter_adresse) { + const adressZeilen = empfaenger.mieter_adresse.split('\n'); + for (const zeile of adressZeilen) { + currentPage.drawText(zeile, { x: margin, y, size: 10, font }); + y -= 14; + } + } + } + y -= 40; + + // ========== DATUM + BETREFF ========== + const heute = new Date().toLocaleDateString('de-DE'); + currentPage.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font }); + + currentPage.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 25; + + if (istEntwurf) { + currentPage.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) }); + } + + currentPage.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font }); + y -= 20; + currentPage.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold }); + y -= 16; + currentPage.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font }); + y -= 16; + currentPage.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font }); + y -= 35; + + // ========== BEGLEITTEXT ========== + currentPage.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold }); + y -= 18; + currentPage.drawText('im Folgenden erhalten Sie die Nebenkostenabrechnung für das abgelaufene Jahr.', { x: margin, y, size: 9, font }); + y -= 14; + currentPage.drawText('Die Kosten wurden nach den im Mietvertrag vereinbarten Anteilen umgelegt.', { x: margin, y, size: 9, font }); + y -= 30; + + // ========== KOSTENÜBERSICHT ========== + currentPage.drawText('Kostenübersicht:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + // Tabellenkopf + currentPage.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold }); + currentPage.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold }); + currentPage.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold }); + y -= 12; + + // Trennlinie + currentPage.drawLine({ + start: { x: margin, y: y + 8 }, + end: { x: width - margin, y: y + 8 }, + thickness: 0.5, + color: rgb(0.8, 0.8, 0.8) + }); + y -= 5; + + let gesamtKosten = 0; + for (const k of kostenResult.rows) { + const betrag = parseFloat(k.betrag); + gesamtKosten += betrag; + currentPage.drawText(k.kategorie, { x: margin, y, size: 9, font }); + currentPage.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font }); + currentPage.drawText(`${betrag.toFixed(2)} €`, { x: margin + 350, y, size: 9, font }); + y -= 14; + } + + y -= 5; + currentPage.drawLine({ + start: { x: margin, y: y + 8 }, + end: { x: width - margin, y: y + 8 }, + thickness: 0.5, + color: rgb(0.8, 0.8, 0.8) + }); + y -= 5; + currentPage.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold }); + currentPage.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 350, y, size: 10, font: fontBold }); + y -= 30; + + // ========== VERTEILUNG AUF MIETER ========== + currentPage.drawText('Verteilung auf Mieter:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + for (const pos of positionenResult.rows) { + const anteilKosten = parseFloat(pos.anteil_kosten); + const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen); + const ergebnis = parseFloat(pos.ergebnis); + + currentPage.drawText(pos.mieter_name, { x: margin, y, size: 11, font: fontBold }); + y -= 16; + currentPage.drawText(` Wohnfläche: ${pos.anteil_qm} qm | Zeitraum: ${pos.anteil_tage} Tage`, { x: margin + 15, y, size: 9, font }); + y -= 14; + currentPage.drawText(` Ihr Anteil an den Kosten:`, { x: margin + 15, y, size: 9, font }); + currentPage.drawText(`${anteilKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 14; + currentPage.drawText(` Geleistete Vorauszahlungen:`, { x: margin + 15, y, size: 9, font }); + currentPage.drawText(`${vorauszahlungen.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 16; + + if (ergebnis > 0) { + currentPage.drawText(` Nachzahlung:`, { x: margin + 15, y, size: 10, font: fontBold }); + currentPage.drawText(`${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.8, 0.2, 0.2) }); + } else { + currentPage.drawText(` Gutschrift:`, { x: margin + 15, y, size: 10, font: fontBold }); + currentPage.drawText(`${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.2, 0.6, 0.2) }); + } + y -= 25; + } + + // ========== FUSSZEILE ========== + y -= 20; + currentPage.drawLine({ + start: { x: margin, y }, + end: { x: width - margin, y }, + thickness: 1, + color: rgb(0.7, 0.7, 0.7) + }); + y -= 20; + currentPage.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) }); + y -= 12; + currentPage.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) }); + + // PDF speichern + const pdfBytes = await pdfDoc.save(); + const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes); + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Length', pdfBuffer.length); + res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`); + res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition'); + res.end(pdfBuffer); + } catch (error) { + console.error('PDF Error:', error); + res.status(500).json({ error: error.message }); + } + }); \ No newline at end of file diff --git a/backend/routes/nebenkosten_pdf.js.unix b/backend/routes/nebenkosten_pdf.js.unix new file mode 100644 index 0000000..0e8734f --- /dev/null +++ b/backend/routes/nebenkosten_pdf.js.unix @@ -0,0 +1,202 @@ + // PDF Export - Privat (René Täger) + app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => { + try { + const { PDFDocument, rgb, StandardFonts } = require('pdf-lib'); + + const abrechnungResult = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + WHERE na.id = $1 + `, [req.params.id]); + + if (abrechnungResult.rows.length === 0) { + return res.status(404).json({ error: 'Abrechnung nicht gefunden' }); + } + + const abrechnung = abrechnungResult.rows[0]; + const positionenResult = await pool.query(` + SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse + FROM abrechnungspositionen ap + JOIN mieter m ON m.id = ap.mieter_id + WHERE ap.abrechnung_id = $1 + `, [req.params.id]); + + const kostenResult = await pool.query(` + SELECT * FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie + `, [abrechnung.objekt_id, abrechnung.jahr]); + + // PDF erstellen + const pdfDoc = await PDFDocument.create(); + const width = 595.28; + const height = 841.89; + + let currentPage = pdfDoc.addPage([width, height]); + let y = height - 50; + const margin = 50; + const contentWidth = width - 2 * margin; + + const font = await pdfDoc.embedFont(StandardFonts.Helvetica); + const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + + const istEntwurf = req.query.entwurf === 'true'; + + // ========== ABSENDER (oben links) ========== + currentPage.drawText('René Täger', { x: margin, y, size: 10, font: fontBold }); + y -= 14; + currentPage.drawText('Zingelstraße 7', { x: margin, y, size: 9, font }); + y -= 14; + currentPage.drawText('25554 Wilster', { x: margin, y, size: 9, font }); + y -= 30; + + // Trennlinie + currentPage.drawLine({ + start: { x: margin, y }, + end: { x: width - margin, y }, + thickness: 1, + color: rgb(0.7, 0.7, 0.7) + }); + y -= 30; + + // ========== EMPFÄNGER (Mieter) ========== + const empfaenger = positionenResult.rows[0]; + if (empfaenger) { + currentPage.drawText(empfaenger.mieter_name || '', { x: margin, y, size: 11, font: fontBold }); + y -= 16; + if (empfaenger.mieter_adresse) { + const adressZeilen = empfaenger.mieter_adresse.split('\n'); + for (const zeile of adressZeilen) { + currentPage.drawText(zeile, { x: margin, y, size: 10, font }); + y -= 14; + } + } + } + y -= 40; + + // ========== DATUM + BETREFF ========== + const heute = new Date().toLocaleDateString('de-DE'); + currentPage.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font }); + + currentPage.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 25; + + if (istEntwurf) { + currentPage.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) }); + } + + currentPage.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font }); + y -= 20; + currentPage.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold }); + y -= 16; + currentPage.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font }); + y -= 16; + currentPage.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font }); + y -= 35; + + // ========== BEGLEITTEXT ========== + currentPage.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold }); + y -= 18; + currentPage.drawText('im Folgenden erhalten Sie die Nebenkostenabrechnung für das abgelaufene Jahr.', { x: margin, y, size: 9, font }); + y -= 14; + currentPage.drawText('Die Kosten wurden nach den im Mietvertrag vereinbarten Anteilen umgelegt.', { x: margin, y, size: 9, font }); + y -= 30; + + // ========== KOSTENÜBERSICHT ========== + currentPage.drawText('Kostenübersicht:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + // Tabellenkopf + currentPage.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold }); + currentPage.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold }); + currentPage.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold }); + y -= 12; + + // Trennlinie + currentPage.drawLine({ + start: { x: margin, y: y + 8 }, + end: { x: width - margin, y: y + 8 }, + thickness: 0.5, + color: rgb(0.8, 0.8, 0.8) + }); + y -= 5; + + let gesamtKosten = 0; + for (const k of kostenResult.rows) { + const betrag = parseFloat(k.betrag); + gesamtKosten += betrag; + currentPage.drawText(k.kategorie, { x: margin, y, size: 9, font }); + currentPage.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font }); + currentPage.drawText(`${betrag.toFixed(2)} €`, { x: margin + 350, y, size: 9, font }); + y -= 14; + } + + y -= 5; + currentPage.drawLine({ + start: { x: margin, y: y + 8 }, + end: { x: width - margin, y: y + 8 }, + thickness: 0.5, + color: rgb(0.8, 0.8, 0.8) + }); + y -= 5; + currentPage.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold }); + currentPage.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 350, y, size: 10, font: fontBold }); + y -= 30; + + // ========== VERTEILUNG AUF MIETER ========== + currentPage.drawText('Verteilung auf Mieter:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + for (const pos of positionenResult.rows) { + const anteilKosten = parseFloat(pos.anteil_kosten); + const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen); + const ergebnis = parseFloat(pos.ergebnis); + + currentPage.drawText(pos.mieter_name, { x: margin, y, size: 11, font: fontBold }); + y -= 16; + currentPage.drawText(` Wohnfläche: ${pos.anteil_qm} qm | Zeitraum: ${pos.anteil_tage} Tage`, { x: margin + 15, y, size: 9, font }); + y -= 14; + currentPage.drawText(` Ihr Anteil an den Kosten:`, { x: margin + 15, y, size: 9, font }); + currentPage.drawText(`${anteilKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 14; + currentPage.drawText(` Geleistete Vorauszahlungen:`, { x: margin + 15, y, size: 9, font }); + currentPage.drawText(`${vorauszahlungen.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 16; + + if (ergebnis > 0) { + currentPage.drawText(` Nachzahlung:`, { x: margin + 15, y, size: 10, font: fontBold }); + currentPage.drawText(`${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.8, 0.2, 0.2) }); + } else { + currentPage.drawText(` Gutschrift:`, { x: margin + 15, y, size: 10, font: fontBold }); + currentPage.drawText(`${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.2, 0.6, 0.2) }); + } + y -= 25; + } + + // ========== FUSSZEILE ========== + y -= 20; + currentPage.drawLine({ + start: { x: margin, y }, + end: { x: width - margin, y }, + thickness: 1, + color: rgb(0.7, 0.7, 0.7) + }); + y -= 20; + currentPage.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) }); + y -= 12; + currentPage.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) }); + + // PDF speichern + const pdfBytes = await pdfDoc.save(); + const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes); + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Length', pdfBuffer.length); + res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`); + res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition'); + res.end(pdfBuffer); + } catch (error) { + console.error('PDF Error:', error); + res.status(500).json({ error: error.message }); + } + }); \ No newline at end of file diff --git a/backend/routes/nebenkosten_pdf_pro_mieter.js b/backend/routes/nebenkosten_pdf_pro_mieter.js new file mode 100644 index 0000000..c2bc86d --- /dev/null +++ b/backend/routes/nebenkosten_pdf_pro_mieter.js @@ -0,0 +1,220 @@ + // PDF Export - Privat (René Täger) - EINE PDF pro Mieter + app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => { + try { + const { PDFDocument, rgb, StandardFonts } = require('pdf-lib'); + + const abrechnungResult = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort, o.wohnflaeche_qm as objekt_flaeche + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + WHERE na.id = $1 + `, [req.params.id]); + + if (abrechnungResult.rows.length === 0) { + return res.status(404).json({ error: 'Abrechnung nicht gefunden' }); + } + + const abrechnung = abrechnungResult.rows[0]; + + // ALLE Positionen laden + const positionenResult = await pool.query(` + SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse + FROM abrechnungspositionen ap + JOIN mieter m ON m.id = ap.mieter_id + WHERE ap.abrechnung_id = $1 + `, [req.params.id]); + + const kostenResult = await pool.query(` + SELECT * FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie + `, [abrechnung.objekt_id, abrechnung.jahr]); + + // Berechnungsgrundlagen + const gesamtKosten = kostenResult.rows.reduce((sum, k) => sum + parseFloat(k.betrag), 0); + const gesamtFlaeche = parseFloat(abrechnung.objekt_flaeche || 95); + const kostenProQm = gesamtKosten / gesamtFlaeche; + + // PDF erstellen + const pdfDoc = await PDFDocument.create(); + const width = 595.28; + const height = 841.89; + const margin = 50; + + const font = await pdfDoc.embedFont(StandardFonts.Helvetica); + const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + + const istEntwurf = req.query.entwurf === 'true'; + const heute = new Date().toLocaleDateString('de-DE'); + + // Hilfsfunktion für Trennlinie + function drawLine(page, yPos) { + page.drawLine({ + start: { x: margin, y: yPos }, + end: { x: width - margin, y: yPos }, + thickness: 0.5, + color: rgb(0.8, 0.8, 0.8) + }); + } + + // ========== FÜR JEDEN MIETER EINE SEITE ========== + for (const pos of positionenResult.rows) { + const mieterName = pos.mieter_name; + const mieterAdresse = pos.mieter_adresse || ''; + const mieterFlaeche = parseFloat(pos.anteil_qm); + const anteilKosten = parseFloat(pos.anteil_kosten); + const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen); + const ergebnis = parseFloat(pos.ergebnis); + + // Neue Seite für diesen Mieter + const page = pdfDoc.addPage([width, height]); + let y = height - 50; + + // ========== ABSENDER (oben links) ========== + page.drawText('René Täger', { x: margin, y, size: 10, font: fontBold }); + y -= 14; + page.drawText('Zingelstraße 7', { x: margin, y, size: 9, font }); + y -= 14; + page.drawText('25554 Wilster', { x: margin, y, size: 9, font }); + y -= 30; + + // Trennlinie + drawLine(page, y); + y -= 30; + + // ========== EMPFÄNGER (nur dieser Mieter) ========== + page.drawText(mieterName, { x: margin, y, size: 11, font: fontBold }); + y -= 16; + if (mieterAdresse) { + const adressZeilen = mieterAdresse.split('\n'); + for (const zeile of adressZeilen) { + page.drawText(zeile, { x: margin, y, size: 10, font }); + y -= 14; + } + } + y -= 40; + + // ========== DATUM + BETREFF ========== + page.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font }); + + page.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 25; + + if (istEntwurf) { + page.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) }); + } + + page.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font }); + y -= 20; + page.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold }); + y -= 16; + page.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font }); + y -= 16; + page.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font }); + y -= 35; + + // ========== BEGLEITTEXT ========== + page.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold }); + y -= 18; + page.drawText('im Folgenden erhalten Sie Ihre persönliche Nebenkostenabrechnung.', { x: margin, y, size: 9, font }); + y -= 14; + page.drawText('Die Kosten wurden nach Ihrem im Mietvertrag vereinbarten Anteil umgelegt.', { x: margin, y, size: 9, font }); + y -= 30; + + // ========== 1. KOSTENÜBERSICHT (alle Kosten des Objekts) ========== + page.drawText('1. Kostenübersicht des Objekts:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + // Tabellenkopf + page.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold }); + page.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold }); + page.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold }); + y -= 12; + drawLine(page, y + 8); + y -= 5; + + for (const k of kostenResult.rows) { + const betrag = parseFloat(k.betrag); + page.drawText(k.kategorie, { x: margin, y, size: 9, font }); + page.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font }); + page.drawText(`${betrag.toFixed(2)} €`, { x: margin + 350, y, size: 9, font }); + y -= 14; + } + + y -= 5; + drawLine(page, y + 8); + y -= 5; + page.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold }); + page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 350, y, size: 10, font: fontBold }); + y -= 30; + + // ========== 2. BERECHNUNGSGRUNDLAGE ========== + page.drawText('2. Berechnungsgrundlage:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + page.drawText(`Gesamtkosten:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Gesamtwohnfläche:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${gesamtFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Kosten pro qm:`, { x: margin + 20, y, size: 9, font: fontBold }); + page.drawText(`${gesamtKosten.toFixed(2)} € / ${gesamtFlaeche.toFixed(2)} qm = ${kostenProQm.toFixed(2)} €/qm`, { x: margin + 250, y, size: 9, font: fontBold }); + y -= 30; + + // ========== 3. IHRE PERSÖNLICHE BERECHNUNG ========== + page.drawText('3. Ihre persönliche Berechnung:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + page.drawText(`Ihr Name:`, { x: margin + 20, y, size: 9, font }); + page.drawText(mieterName, { x: margin + 250, y, size: 9, font: fontBold }); + y -= 14; + page.drawText(`Ihre Wohnfläche:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${mieterFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Ihr Anteil an den Kosten:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${kostenProQm.toFixed(2)} €/qm × ${mieterFlaeche.toFixed(2)} qm = ${anteilKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Geleistete Vorauszahlungen:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${vorauszahlungen.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 25; + + // Ergebnis hervorgehoben + drawLine(page, y + 8); + y -= 5; + + if (ergebnis > 0) { + page.drawText(`Nachzahlung:`, { x: margin + 20, y, size: 11, font: fontBold }); + page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold }); + y -= 20; + page.drawText(`Bitte überweisen Sie den Betrag innerhalb von 14 Tagen.`, { x: margin + 20, y, size: 9, font, color: rgb(0.8, 0.2, 0.2) }); + } else { + page.drawText(`Gutschrift:`, { x: margin + 20, y, size: 11, font: fontBold }); + page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold }); + y -= 20; + page.drawText(`Der Betrag wird mit der nächsten Miete verrechnet.`, { x: margin + 20, y, size: 9, font, color: rgb(0.2, 0.6, 0.2) }); + } + y -= 25; + + // ========== FUSSZEILE ========== + y -= 20; + drawLine(page, y); + y -= 20; + page.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) }); + y -= 12; + page.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) }); + } + + // PDF speichern + const pdfBytes = await pdfDoc.save(); + const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes); + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Length', pdfBuffer.length); + res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`); + res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition'); + res.end(pdfBuffer); + } catch (error) { + console.error('PDF Error:', error); + res.status(500).json({ error: error.message }); + } + }); \ No newline at end of file diff --git a/backend/routes/nebenkosten_pdf_pro_mieter.js.unix b/backend/routes/nebenkosten_pdf_pro_mieter.js.unix new file mode 100644 index 0000000..1137ad3 --- /dev/null +++ b/backend/routes/nebenkosten_pdf_pro_mieter.js.unix @@ -0,0 +1,220 @@ + // PDF Export - Privat (René Täger) - EINE PDF pro Mieter + app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => { + try { + const { PDFDocument, rgb, StandardFonts } = require('pdf-lib'); + + const abrechnungResult = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort, o.wohnflaeche_qm as objekt_flaeche + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + WHERE na.id = $1 + `, [req.params.id]); + + if (abrechnungResult.rows.length === 0) { + return res.status(404).json({ error: 'Abrechnung nicht gefunden' }); + } + + const abrechnung = abrechnungResult.rows[0]; + + // ALLE Positionen laden + const positionenResult = await pool.query(` + SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse + FROM abrechnungspositionen ap + JOIN mieter m ON m.id = ap.mieter_id + WHERE ap.abrechnung_id = $1 + `, [req.params.id]); + + const kostenResult = await pool.query(` + SELECT * FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie + `, [abrechnung.objekt_id, abrechnung.jahr]); + + // Berechnungsgrundlagen + const gesamtKosten = kostenResult.rows.reduce((sum, k) => sum + parseFloat(k.betrag), 0); + const gesamtFlaeche = parseFloat(abrechnung.objekt_flaeche || 95); + const kostenProQm = gesamtKosten / gesamtFlaeche; + + // PDF erstellen + const pdfDoc = await PDFDocument.create(); + const width = 595.28; + const height = 841.89; + const margin = 50; + + const font = await pdfDoc.embedFont(StandardFonts.Helvetica); + const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + + const istEntwurf = req.query.entwurf === 'true'; + const heute = new Date().toLocaleDateString('de-DE'); + + // Hilfsfunktion für Trennlinie + function drawLine(page, yPos) { + page.drawLine({ + start: { x: margin, y: yPos }, + end: { x: width - margin, y: yPos }, + thickness: 0.5, + color: rgb(0.8, 0.8, 0.8) + }); + } + + // ========== FÜR JEDEN MIETER EINE SEITE ========== + for (const pos of positionenResult.rows) { + const mieterName = pos.mieter_name; + const mieterAdresse = pos.mieter_adresse || ''; + const mieterFlaeche = parseFloat(pos.anteil_qm); + const anteilKosten = parseFloat(pos.anteil_kosten); + const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen); + const ergebnis = parseFloat(pos.ergebnis); + + // Neue Seite für diesen Mieter + const page = pdfDoc.addPage([width, height]); + let y = height - 50; + + // ========== ABSENDER (oben links) ========== + page.drawText('René Täger', { x: margin, y, size: 10, font: fontBold }); + y -= 14; + page.drawText('Zingelstraße 7', { x: margin, y, size: 9, font }); + y -= 14; + page.drawText('25554 Wilster', { x: margin, y, size: 9, font }); + y -= 30; + + // Trennlinie + drawLine(page, y); + y -= 30; + + // ========== EMPFÄNGER (nur dieser Mieter) ========== + page.drawText(mieterName, { x: margin, y, size: 11, font: fontBold }); + y -= 16; + if (mieterAdresse) { + const adressZeilen = mieterAdresse.split('\n'); + for (const zeile of adressZeilen) { + page.drawText(zeile, { x: margin, y, size: 10, font }); + y -= 14; + } + } + y -= 40; + + // ========== DATUM + BETREFF ========== + page.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font }); + + page.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 25; + + if (istEntwurf) { + page.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) }); + } + + page.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font }); + y -= 20; + page.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold }); + y -= 16; + page.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font }); + y -= 16; + page.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font }); + y -= 35; + + // ========== BEGLEITTEXT ========== + page.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold }); + y -= 18; + page.drawText('im Folgenden erhalten Sie Ihre persönliche Nebenkostenabrechnung.', { x: margin, y, size: 9, font }); + y -= 14; + page.drawText('Die Kosten wurden nach Ihrem im Mietvertrag vereinbarten Anteil umgelegt.', { x: margin, y, size: 9, font }); + y -= 30; + + // ========== 1. KOSTENÜBERSICHT (alle Kosten des Objekts) ========== + page.drawText('1. Kostenübersicht des Objekts:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + // Tabellenkopf + page.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold }); + page.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold }); + page.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold }); + y -= 12; + drawLine(page, y + 8); + y -= 5; + + for (const k of kostenResult.rows) { + const betrag = parseFloat(k.betrag); + page.drawText(k.kategorie, { x: margin, y, size: 9, font }); + page.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font }); + page.drawText(`${betrag.toFixed(2)} €`, { x: margin + 350, y, size: 9, font }); + y -= 14; + } + + y -= 5; + drawLine(page, y + 8); + y -= 5; + page.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold }); + page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 350, y, size: 10, font: fontBold }); + y -= 30; + + // ========== 2. BERECHNUNGSGRUNDLAGE ========== + page.drawText('2. Berechnungsgrundlage:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + page.drawText(`Gesamtkosten:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Gesamtwohnfläche:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${gesamtFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Kosten pro qm:`, { x: margin + 20, y, size: 9, font: fontBold }); + page.drawText(`${gesamtKosten.toFixed(2)} € / ${gesamtFlaeche.toFixed(2)} qm = ${kostenProQm.toFixed(2)} €/qm`, { x: margin + 250, y, size: 9, font: fontBold }); + y -= 30; + + // ========== 3. IHRE PERSÖNLICHE BERECHNUNG ========== + page.drawText('3. Ihre persönliche Berechnung:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + page.drawText(`Ihr Name:`, { x: margin + 20, y, size: 9, font }); + page.drawText(mieterName, { x: margin + 250, y, size: 9, font: fontBold }); + y -= 14; + page.drawText(`Ihre Wohnfläche:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${mieterFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Ihr Anteil an den Kosten:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${kostenProQm.toFixed(2)} €/qm × ${mieterFlaeche.toFixed(2)} qm = ${anteilKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Geleistete Vorauszahlungen:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${vorauszahlungen.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 25; + + // Ergebnis hervorgehoben + drawLine(page, y + 8); + y -= 5; + + if (ergebnis > 0) { + page.drawText(`Nachzahlung:`, { x: margin + 20, y, size: 11, font: fontBold }); + page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold }); + y -= 20; + page.drawText(`Bitte überweisen Sie den Betrag innerhalb von 14 Tagen.`, { x: margin + 20, y, size: 9, font, color: rgb(0.8, 0.2, 0.2) }); + } else { + page.drawText(`Gutschrift:`, { x: margin + 20, y, size: 11, font: fontBold }); + page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold }); + y -= 20; + page.drawText(`Der Betrag wird mit der nächsten Miete verrechnet.`, { x: margin + 20, y, size: 9, font, color: rgb(0.2, 0.6, 0.2) }); + } + y -= 25; + + // ========== FUSSZEILE ========== + y -= 20; + drawLine(page, y); + y -= 20; + page.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) }); + y -= 12; + page.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) }); + } + + // PDF speichern + const pdfBytes = await pdfDoc.save(); + const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes); + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Length', pdfBuffer.length); + res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`); + res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition'); + res.end(pdfBuffer); + } catch (error) { + console.error('PDF Error:', error); + res.status(500).json({ error: error.message }); + } + }); \ No newline at end of file diff --git a/backend/routes/nebenkosten_pdf_v3.js b/backend/routes/nebenkosten_pdf_v3.js new file mode 100644 index 0000000..cc1d743 --- /dev/null +++ b/backend/routes/nebenkosten_pdf_v3.js @@ -0,0 +1,219 @@ + // PDF Export - Privat (René Täger) mit Rechenweg + app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => { + try { + const { PDFDocument, rgb, StandardFonts } = require('pdf-lib'); + + const abrechnungResult = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort, o.wohnflaeche_qm as objekt_flaeche + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + WHERE na.id = $1 + `, [req.params.id]); + + if (abrechnungResult.rows.length === 0) { + return res.status(404).json({ error: 'Abrechnung nicht gefunden' }); + } + + const abrechnung = abrechnungResult.rows[0]; + const positionenResult = await pool.query(` + SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse + FROM abrechnungspositionen ap + JOIN mieter m ON m.id = ap.mieter_id + WHERE ap.abrechnung_id = $1 + `, [req.params.id]); + + const kostenResult = await pool.query(` + SELECT * FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie + `, [abrechnung.objekt_id, abrechnung.jahr]); + + // PDF erstellen + const pdfDoc = await PDFDocument.create(); + const width = 595.28; + const height = 841.89; + + let currentPage = pdfDoc.addPage([width, height]); + let y = height - 50; + const margin = 50; + const contentWidth = width - 2 * margin; + + const font = await pdfDoc.embedFont(StandardFonts.Helvetica); + const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + + const istEntwurf = req.query.entwurf === 'true'; + + // Hilfsfunktion für Trennlinie + function drawLine(yPos) { + currentPage.drawLine({ + start: { x: margin, y: yPos }, + end: { x: width - margin, y: yPos }, + thickness: 0.5, + color: rgb(0.8, 0.8, 0.8) + }); + } + + // ========== SEITE 1: ABSENDER + EMPFÄNGER + BETREFF ========== + + // Absender (klein, oben links) + currentPage.drawText('René Täger', { x: margin, y, size: 10, font: fontBold }); + y -= 14; + currentPage.drawText('Zingelstraße 7', { x: margin, y, size: 9, font }); + y -= 14; + currentPage.drawText('25554 Wilster', { x: margin, y, size: 9, font }); + y -= 30; + + // Trennlinie + drawLine(y); + y -= 30; + + // Empfänger (Mieter) + const empfaenger = positionenResult.rows[0]; + if (empfaenger) { + currentPage.drawText(empfaenger.mieter_name || '', { x: margin, y, size: 11, font: fontBold }); + y -= 16; + if (empfaenger.mieter_adresse) { + const adressZeilen = empfaenger.mieter_adresse.split('\n'); + for (const zeile of adressZeilen) { + currentPage.drawText(zeile, { x: margin, y, size: 10, font }); + y -= 14; + } + } + } + y -= 40; + + // Datum + Betreff + const heute = new Date().toLocaleDateString('de-DE'); + currentPage.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font }); + + currentPage.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 25; + + if (istEntwurf) { + currentPage.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) }); + } + + currentPage.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font }); + y -= 20; + currentPage.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold }); + y -= 16; + currentPage.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font }); + y -= 16; + currentPage.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font }); + y -= 35; + + // ========== BEGLEITTEXT ========== + currentPage.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold }); + y -= 18; + currentPage.drawText('im Folgenden erhalten Sie die Nebenkostenabrechnung für das abgelaufene Jahr.', { x: margin, y, size: 9, font }); + y -= 14; + currentPage.drawText('Die Kosten wurden nach den im Mietvertrag vereinbarten Anteilen umgelegt.', { x: margin, y, size: 9, font }); + y -= 30; + + // ========== KOSTENÜBERSICHT ========== + currentPage.drawText('1. Kostenübersicht:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + // Tabellenkopf + currentPage.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold }); + currentPage.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold }); + currentPage.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold }); + y -= 12; + drawLine(y + 8); + y -= 5; + + let gesamtKosten = 0; + for (const k of kostenResult.rows) { + const betrag = parseFloat(k.betrag); + gesamtKosten += betrag; + currentPage.drawText(k.kategorie, { x: margin, y, size: 9, font }); + currentPage.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font }); + currentPage.drawText(`${betrag.toFixed(2)} €`, { x: margin + 350, y, size: 9, font }); + y -= 14; + } + + y -= 5; + drawLine(y + 8); + y -= 5; + currentPage.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold }); + currentPage.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 350, y, size: 10, font: fontBold }); + y -= 30; + + // ========== RECHENWEG (Der Weg ist das Ziel!) ========== + currentPage.drawText('2. Berechnungsgrundlage:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + // Gesamtfläche berechnen + const gesamtFlaeche = parseFloat(abrechnung.objekt_flaeche || 95); + const kostenProQm = gesamtKosten / gesamtFlaeche; + + currentPage.drawText(`Gesamtkosten:`, { x: margin + 20, y, size: 9, font }); + currentPage.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 14; + currentPage.drawText(`Gesamtwohnfläche:`, { x: margin + 20, y, size: 9, font }); + currentPage.drawText(`${gesamtFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font }); + y -= 14; + currentPage.drawText(`Kosten pro qm:`, { x: margin + 20, y, size: 9, font: fontBold }); + currentPage.drawText(`${gesamtKosten.toFixed(2)} € / ${gesamtFlaeche.toFixed(2)} qm = ${kostenProQm.toFixed(2)} €/qm`, { x: margin + 250, y, size: 9, font: fontBold }); + y -= 30; + + // ========== VERTEILUNG AUF MIETER ========== + currentPage.drawText('3. Verteilung auf Mieter:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + for (const pos of positionenResult.rows) { + const anteilKosten = parseFloat(pos.anteil_kosten); + const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen); + const ergebnis = parseFloat(pos.ergebnis); + const mieterFlaeche = parseFloat(pos.anteil_qm); + + currentPage.drawText(pos.mieter_name, { x: margin, y, size: 11, font: fontBold }); + y -= 16; + + // Rechenweg + currentPage.drawText(` Wohnfläche: ${mieterFlaeche.toFixed(2)} qm`, { x: margin + 15, y, size: 9, font }); + y -= 14; + currentPage.drawText(` Berechnung: ${kostenProQm.toFixed(2)} €/qm × ${mieterFlaeche.toFixed(2)} qm = ${anteilKosten.toFixed(2)} €`, { x: margin + 15, y, size: 9, font }); + y -= 14; + currentPage.drawText(` Geleistete Vorauszahlungen:`, { x: margin + 15, y, size: 9, font }); + currentPage.drawText(`${vorauszahlungen.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 16; + + // Ergebnis + if (ergebnis > 0) { + currentPage.drawText(` Ergebnis: ${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € =`, { x: margin + 15, y, size: 9, font }); + currentPage.drawText(`${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.8, 0.2, 0.2) }); + y -= 14; + currentPage.drawText(` Nachzahlung erforderlich:`, { x: margin + 15, y, size: 10, font: fontBold }); + currentPage.drawText(`${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.8, 0.2, 0.2) }); + } else { + currentPage.drawText(` Ergebnis: ${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € =`, { x: margin + 15, y, size: 9, font }); + currentPage.drawText(`${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.2, 0.6, 0.2) }); + y -= 14; + currentPage.drawText(` Gutschrift:`, { x: margin + 15, y, size: 10, font: fontBold }); + currentPage.drawText(`${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.2, 0.6, 0.2) }); + } + y -= 25; + } + + // ========== FUSSZEILE ========== + y -= 20; + drawLine(y); + y -= 20; + currentPage.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) }); + y -= 12; + currentPage.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) }); + + // PDF speichern + const pdfBytes = await pdfDoc.save(); + const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes); + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Length', pdfBuffer.length); + res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`); + res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition'); + res.end(pdfBuffer); + } catch (error) { + console.error('PDF Error:', error); + res.status(500).json({ error: error.message }); + } + }); \ No newline at end of file diff --git a/backend/routes/nebenkosten_pdf_v3.js.unix b/backend/routes/nebenkosten_pdf_v3.js.unix new file mode 100644 index 0000000..fc258a2 --- /dev/null +++ b/backend/routes/nebenkosten_pdf_v3.js.unix @@ -0,0 +1,219 @@ + // PDF Export - Privat (René Täger) mit Rechenweg + app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => { + try { + const { PDFDocument, rgb, StandardFonts } = require('pdf-lib'); + + const abrechnungResult = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort, o.wohnflaeche_qm as objekt_flaeche + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + WHERE na.id = $1 + `, [req.params.id]); + + if (abrechnungResult.rows.length === 0) { + return res.status(404).json({ error: 'Abrechnung nicht gefunden' }); + } + + const abrechnung = abrechnungResult.rows[0]; + const positionenResult = await pool.query(` + SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse + FROM abrechnungspositionen ap + JOIN mieter m ON m.id = ap.mieter_id + WHERE ap.abrechnung_id = $1 + `, [req.params.id]); + + const kostenResult = await pool.query(` + SELECT * FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie + `, [abrechnung.objekt_id, abrechnung.jahr]); + + // PDF erstellen + const pdfDoc = await PDFDocument.create(); + const width = 595.28; + const height = 841.89; + + let currentPage = pdfDoc.addPage([width, height]); + let y = height - 50; + const margin = 50; + const contentWidth = width - 2 * margin; + + const font = await pdfDoc.embedFont(StandardFonts.Helvetica); + const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + + const istEntwurf = req.query.entwurf === 'true'; + + // Hilfsfunktion für Trennlinie + function drawLine(yPos) { + currentPage.drawLine({ + start: { x: margin, y: yPos }, + end: { x: width - margin, y: yPos }, + thickness: 0.5, + color: rgb(0.8, 0.8, 0.8) + }); + } + + // ========== SEITE 1: ABSENDER + EMPFÄNGER + BETREFF ========== + + // Absender (klein, oben links) + currentPage.drawText('René Täger', { x: margin, y, size: 10, font: fontBold }); + y -= 14; + currentPage.drawText('Zingelstraße 7', { x: margin, y, size: 9, font }); + y -= 14; + currentPage.drawText('25554 Wilster', { x: margin, y, size: 9, font }); + y -= 30; + + // Trennlinie + drawLine(y); + y -= 30; + + // Empfänger (Mieter) + const empfaenger = positionenResult.rows[0]; + if (empfaenger) { + currentPage.drawText(empfaenger.mieter_name || '', { x: margin, y, size: 11, font: fontBold }); + y -= 16; + if (empfaenger.mieter_adresse) { + const adressZeilen = empfaenger.mieter_adresse.split('\n'); + for (const zeile of adressZeilen) { + currentPage.drawText(zeile, { x: margin, y, size: 10, font }); + y -= 14; + } + } + } + y -= 40; + + // Datum + Betreff + const heute = new Date().toLocaleDateString('de-DE'); + currentPage.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font }); + + currentPage.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 25; + + if (istEntwurf) { + currentPage.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) }); + } + + currentPage.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font }); + y -= 20; + currentPage.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold }); + y -= 16; + currentPage.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font }); + y -= 16; + currentPage.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font }); + y -= 35; + + // ========== BEGLEITTEXT ========== + currentPage.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold }); + y -= 18; + currentPage.drawText('im Folgenden erhalten Sie die Nebenkostenabrechnung für das abgelaufene Jahr.', { x: margin, y, size: 9, font }); + y -= 14; + currentPage.drawText('Die Kosten wurden nach den im Mietvertrag vereinbarten Anteilen umgelegt.', { x: margin, y, size: 9, font }); + y -= 30; + + // ========== KOSTENÜBERSICHT ========== + currentPage.drawText('1. Kostenübersicht:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + // Tabellenkopf + currentPage.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold }); + currentPage.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold }); + currentPage.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold }); + y -= 12; + drawLine(y + 8); + y -= 5; + + let gesamtKosten = 0; + for (const k of kostenResult.rows) { + const betrag = parseFloat(k.betrag); + gesamtKosten += betrag; + currentPage.drawText(k.kategorie, { x: margin, y, size: 9, font }); + currentPage.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font }); + currentPage.drawText(`${betrag.toFixed(2)} €`, { x: margin + 350, y, size: 9, font }); + y -= 14; + } + + y -= 5; + drawLine(y + 8); + y -= 5; + currentPage.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold }); + currentPage.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 350, y, size: 10, font: fontBold }); + y -= 30; + + // ========== RECHENWEG (Der Weg ist das Ziel!) ========== + currentPage.drawText('2. Berechnungsgrundlage:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + // Gesamtfläche berechnen + const gesamtFlaeche = parseFloat(abrechnung.objekt_flaeche || 95); + const kostenProQm = gesamtKosten / gesamtFlaeche; + + currentPage.drawText(`Gesamtkosten:`, { x: margin + 20, y, size: 9, font }); + currentPage.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 14; + currentPage.drawText(`Gesamtwohnfläche:`, { x: margin + 20, y, size: 9, font }); + currentPage.drawText(`${gesamtFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font }); + y -= 14; + currentPage.drawText(`Kosten pro qm:`, { x: margin + 20, y, size: 9, font: fontBold }); + currentPage.drawText(`${gesamtKosten.toFixed(2)} € / ${gesamtFlaeche.toFixed(2)} qm = ${kostenProQm.toFixed(2)} €/qm`, { x: margin + 250, y, size: 9, font: fontBold }); + y -= 30; + + // ========== VERTEILUNG AUF MIETER ========== + currentPage.drawText('3. Verteilung auf Mieter:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + for (const pos of positionenResult.rows) { + const anteilKosten = parseFloat(pos.anteil_kosten); + const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen); + const ergebnis = parseFloat(pos.ergebnis); + const mieterFlaeche = parseFloat(pos.anteil_qm); + + currentPage.drawText(pos.mieter_name, { x: margin, y, size: 11, font: fontBold }); + y -= 16; + + // Rechenweg + currentPage.drawText(` Wohnfläche: ${mieterFlaeche.toFixed(2)} qm`, { x: margin + 15, y, size: 9, font }); + y -= 14; + currentPage.drawText(` Berechnung: ${kostenProQm.toFixed(2)} €/qm × ${mieterFlaeche.toFixed(2)} qm = ${anteilKosten.toFixed(2)} €`, { x: margin + 15, y, size: 9, font }); + y -= 14; + currentPage.drawText(` Geleistete Vorauszahlungen:`, { x: margin + 15, y, size: 9, font }); + currentPage.drawText(`${vorauszahlungen.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 16; + + // Ergebnis + if (ergebnis > 0) { + currentPage.drawText(` Ergebnis: ${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € =`, { x: margin + 15, y, size: 9, font }); + currentPage.drawText(`${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.8, 0.2, 0.2) }); + y -= 14; + currentPage.drawText(` Nachzahlung erforderlich:`, { x: margin + 15, y, size: 10, font: fontBold }); + currentPage.drawText(`${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.8, 0.2, 0.2) }); + } else { + currentPage.drawText(` Ergebnis: ${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € =`, { x: margin + 15, y, size: 9, font }); + currentPage.drawText(`${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.2, 0.6, 0.2) }); + y -= 14; + currentPage.drawText(` Gutschrift:`, { x: margin + 15, y, size: 10, font: fontBold }); + currentPage.drawText(`${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.2, 0.6, 0.2) }); + } + y -= 25; + } + + // ========== FUSSZEILE ========== + y -= 20; + drawLine(y); + y -= 20; + currentPage.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) }); + y -= 12; + currentPage.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) }); + + // PDF speichern + const pdfBytes = await pdfDoc.save(); + const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes); + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Length', pdfBuffer.length); + res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`); + res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition'); + res.end(pdfBuffer); + } catch (error) { + console.error('PDF Error:', error); + res.status(500).json({ error: error.message }); + } + }); \ No newline at end of file diff --git a/backend/routes/nebenkosten_unix.js b/backend/routes/nebenkosten_unix.js new file mode 100644 index 0000000..75b5a6a --- /dev/null +++ b/backend/routes/nebenkosten_unix.js @@ -0,0 +1,1287 @@ +// API Routes für Nebenkostenabrechnung (Objekte, Mieter, Mietverträge, Kosten, Vorauszahlungen, Abrechnungen) +const { Pool } = require('pg'); + +const pool = new Pool({ + host: process.env.DB_HOST || 'buchhaltung-db', + port: process.env.DB_PORT || 5432, + database: process.env.DB_NAME || 'buchhaltung', + user: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || 'postgres', +}); + +module.exports = (app) => { + + // ========== OBJEKTE ========== + + // Alle Objekte + app.get('/api/objekte', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM objekte ORDER BY name'); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Einzelnes Objekt + app.get('/api/objekte/:id', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM objekte WHERE id = $1', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Objekt erstellen + app.post('/api/objekte', async (req, res) => { + try { + const { name, adresse, plz, ort, wohnflaeche_qm, bemerkung } = req.body; + const result = await pool.query( + 'INSERT INTO objekte (name, adresse, plz, ort, wohnflaeche_qm, bemerkung) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *', + [name, adresse, plz, ort, wohnflaeche_qm || 0, bemerkung] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Objekt aktualisieren + app.put('/api/objekte/:id', async (req, res) => { + try { + const { name, adresse, plz, ort, wohnflaeche_qm, bemerkung } = req.body; + const result = await pool.query( + 'UPDATE objekte SET name = $1, adresse = $2, plz = $3, ort = $4, wohnflaeche_qm = $5, bemerkung = $6 WHERE id = $7 RETURNING *', + [name, adresse, plz, ort, wohnflaeche_qm, bemerkung, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Objekt löschen + app.delete('/api/objekte/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM objekte WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== OBJEKTKOSTEN ========== + + // Kosten für ein Objekt laden (optional gefiltert nach Jahr) + app.get('/api/objekte/:id/kosten', async (req, res) => { + try { + const { jahr } = req.query; + let query = 'SELECT * FROM objektkosten WHERE objekt_id = $1'; + const params = [req.params.id]; + + if (jahr) { + query += ' AND jahr = $2'; + params.push(jahr); + } + + query += ' ORDER BY jahr DESC, kategorie ASC'; + + const result = await pool.query(query, params); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Kosten hinzufügen + app.post('/api/objekte/:id/kosten', async (req, res) => { + try { + const { kategorie, betrag, jahr } = req.body; + const result = await pool.query( + 'INSERT INTO objektkosten (objekt_id, kategorie, betrag, jahr) VALUES ($1, $2, $3, $4) RETURNING *', + [req.params.id, kategorie, betrag, jahr] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Kosten löschen + app.delete('/api/objekte/:id/kosten/:kostenId', async (req, res) => { + try { + await pool.query('DELETE FROM objektkosten WHERE id = $1 AND objekt_id = $2', [req.params.kostenId, req.params.id]); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Mieter eines Objekts + app.get('/api/objekte/:id/mieter', async (req, res) => { + try { + const result = await pool.query( + `SELECT m.*, mv.id as mietvertrag_id, mv.wohnflaeche_qm, mv.kaltmiete, + mv.nebenkosten_vorauszahlung, mv.vertragsbeginn, mv.vertragsende, mv.ist_aktuell + FROM mieter m + JOIN mietvertraege mv ON mv.mieter_id = m.id + WHERE mv.objekt_id = $1 + ORDER BY m.name`, + [req.params.id] + ); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== MIETER ========== + + app.get('/api/mieter', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM mieter ORDER BY name'); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.get('/api/mieter/:id', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM mieter WHERE id = $1', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/mieter', async (req, res) => { + try { + const { name, email, telefon, adresse } = req.body; + const result = await pool.query( + 'INSERT INTO mieter (name, email, telefon, adresse) VALUES ($1, $2, $3, $4) RETURNING *', + [name, email, telefon, adresse] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.put('/api/mieter/:id', async (req, res) => { + try { + const { name, email, telefon, adresse } = req.body; + const result = await pool.query( + 'UPDATE mieter SET name = $1, email = $2, telefon = $3, adresse = $4 WHERE id = $5 RETURNING *', + [name, email, telefon, adresse, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.delete('/api/mieter/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM mieter WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Mieter mit Mietvertrag erstellen (Combined) + app.post('/api/mieter-mit-vertrag', async (req, res) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const { name, email, telefon, adresse, objekt_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn, vertragsende } = req.body; + + // Mieter erstellen + const mieterResult = await client.query( + 'INSERT INTO mieter (name, email, telefon, adresse) VALUES ($1, $2, $3, $4) RETURNING *', + [name, email, telefon, adresse] + ); + const mieter = mieterResult.rows[0]; + + // Mietvertrag erstellen + const vertragResult = await client.query( + `INSERT INTO mietvertraege (objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn, vertragsende, ist_aktuell) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, + [objekt_id, mieter.id, wohnflaeche_qm || 0, kaltmiete || 0, nebenkosten_vorauszahlung || 0, vertragsbeginn, vertragsende || null, !vertragsende] + ); + + await client.query('COMMIT'); + res.json({ mieter, mietvertrag: vertragResult.rows[0] }); + } catch (error) { + await client.query('ROLLBACK'); + res.status(500).json({ error: error.message }); + } finally { + client.release(); + } + }); + + // Mieter mit Mietvertrag aktualisieren (Combined) + app.put('/api/mieter/:id/mit-vertrag', async (req, res) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const { name, email, telefon, adresse, objekt_id, kaltmiete, vorauszahlung, vertragsbeginn, vertragsende } = req.body; + + // ist_aktuell automatisch berechnen: true wenn kein vertragsende oder vertragsende in Zukunft + const istAktuell = !vertragsende || new Date(vertragsende) >= new Date(); + + // Mieter aktualisieren + const mieterResult = await client.query( + 'UPDATE mieter SET name = $1, email = $2, telefon = $3, adresse = $4 WHERE id = $5 RETURNING *', + [name, email, telefon, adresse, req.params.id] + ); + if (mieterResult.rows.length === 0) { + await client.query('ROLLBACK'); + return res.status(404).json({ error: 'Mieter nicht gefunden' }); + } + + // Aktiven Mietvertrag finden und aktualisieren + const vertragResult = await client.query( + `UPDATE mietvertraege SET + objekt_id = $1, kaltmiete = $2, nebenkosten_vorauszahlung = $3, + vertragsbeginn = $4, vertragsende = $5, ist_aktuell = $6 + WHERE mieter_id = $7 AND (ist_aktuell = true OR id = ( + SELECT id FROM mietvertraege WHERE mieter_id = $7 ORDER BY vertragsbeginn DESC LIMIT 1 + )) + RETURNING *`, + [objekt_id, kaltmiete, vorauszahlung, vertragsbeginn, vertragsende || null, istAktuell, req.params.id] + ); + + await client.query('COMMIT'); + res.json({ mieter: mieterResult.rows[0], mietvertrag: vertragResult.rows[0] || null }); + } catch (error) { + await client.query('ROLLBACK'); + res.status(500).json({ error: error.message }); + } finally { + client.release(); + } + }); + + // Mietverträge eines Mieters + app.get('/api/mieter/:id/mietvertraege', async (req, res) => { + try { + const result = await pool.query( + `SELECT mv.*, o.name as objekt_name + FROM mietvertraege mv + JOIN objekte o ON o.id = mv.objekt_id + WHERE mv.mieter_id = $1 + ORDER BY mv.vertragsbeginn DESC`, + [req.params.id] + ); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== MIETVERTRÄGE ========== + + app.get('/api/mietvertraege', async (req, res) => { + try { + const result = await pool.query( + `SELECT mv.*, m.name as mieter_name, o.name as objekt_name + FROM mietvertraege mv + JOIN mieter m ON m.id = mv.mieter_id + JOIN objekte o ON o.id = mv.objekt_id + ORDER BY mv.vertragsbeginn DESC` + ); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== MIETVERTRÄGE ========== + + app.get('/api/mietvertraege', async (req, res) => { + try { + const { objekt_id, ist_aktuell } = req.query; + let query = `SELECT mv.*, o.name as objekt_name, o.adresse, o.plz, o.ort, + m.name as mieter_name, m.email as mieter_email + FROM mietvertraege mv + JOIN objekte o ON o.id = mv.objekt_id + JOIN mieter m ON m.id = mv.mieter_id`; + const values = []; + const conditions = []; + + if (objekt_id) { + conditions.push(`mv.objekt_id = $${values.length + 1}`); + values.push(objekt_id); + } + if (ist_aktuell !== undefined) { + conditions.push(`mv.ist_aktuell = $${values.length + 1}`); + values.push(ist_aktuell === 'true'); + } + + if (conditions.length > 0) { + query += ' WHERE ' + conditions.join(' AND '); + } + + query += ' ORDER BY mv.vertragsbeginn DESC'; + + const result = await pool.query(query, values); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/mietvertraege', async (req, res) => { + try { + const { objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn } = req.body; + const result = await pool.query( + `INSERT INTO mietvertraege (objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, + [objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung || 0, vertragsbeginn] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.put('/api/mietvertraege/:id', async (req, res) => { + try { + const { wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsende, ist_aktuell } = req.body; + const result = await pool.query( + `UPDATE mietvertraege SET wohnflaeche_qm = $1, kaltmiete = $2, nebenkosten_vorauszahlung = $3, + vertragsende = $4, ist_aktuell = $5 WHERE id = $6 RETURNING *`, + [wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsende, ist_aktuell !== undefined ? ist_aktuell : true, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/mietvertraege/:id/beenden', async (req, res) => { + try { + const { vertragsende } = req.body; + const result = await pool.query( + `UPDATE mietvertraege SET vertragsende = $1, ist_aktuell = false WHERE id = $2 RETURNING *`, + [vertragsende, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.delete('/api/mietvertraege/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM mietvertraege WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== OBJEKTKOSTEN ========== + + app.get('/api/objektkosten', async (req, res) => { + try { + const { objekt_id, jahr } = req.query; + let query = 'SELECT ok.*, o.name as objekt_name FROM objektkosten ok JOIN objekte o ON o.id = ok.objekt_id'; + const values = []; + const conditions = []; + + if (objekt_id) { + conditions.push(`ok.objekt_id = $${values.length + 1}`); + values.push(objekt_id); + } + if (jahr) { + conditions.push(`ok.jahr = $${values.length + 1}`); + values.push(parseInt(jahr)); + } + + if (conditions.length > 0) { + query += ' WHERE ' + conditions.join(' AND '); + } + + query += ' ORDER BY ok.jahr DESC, ok.kategorie, ok.datum'; + + const result = await pool.query(query, values); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/objektkosten', async (req, res) => { + try { + const { objekt_id, kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung } = req.body; + const result = await pool.query( + `INSERT INTO objektkosten (objekt_id, kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, + [objekt_id, kategorie, bezeichnung, betrag, datum, jahr || new Date().getFullYear(), verteilung || 'qm', bemerkung] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.put('/api/objektkosten/:id', async (req, res) => { + try { + const { kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung } = req.body; + const result = await pool.query( + `UPDATE objektkosten SET kategorie = $1, bezeichnung = $2, betrag = $3, datum = $4, jahr = $5, verteilung = $6, bemerkung = $7 + WHERE id = $8 RETURNING *`, + [kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Kosten nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.delete('/api/objektkosten/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM objektkosten WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Kosten nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== VORAUSZAHLUNGEN ========== + + app.get('/api/vorauszahlungen', async (req, res) => { + try { + const { objekt_id, mieter_id, jahr } = req.query; + let query = `SELECT v.*, o.name as objekt_name, m.name as mieter_name + FROM vorauszahlungen v + JOIN objekte o ON o.id = v.objekt_id + JOIN mieter m ON m.id = v.mieter_id`; + const values = []; + const conditions = []; + + if (objekt_id) { + conditions.push(`v.objekt_id = $${values.length + 1}`); + values.push(objekt_id); + } + if (mieter_id) { + conditions.push(`v.mieter_id = $${values.length + 1}`); + values.push(mieter_id); + } + if (jahr) { + conditions.push(`v.jahr = $${values.length + 1}`); + values.push(parseInt(jahr)); + } + + if (conditions.length > 0) { + query += ' WHERE ' + conditions.join(' AND '); + } + + query += ' ORDER BY v.jahr DESC, v.monat'; + + const result = await pool.query(query, values); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/vorauszahlungen', async (req, res) => { + try { + const { objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung } = req.body; + const result = await pool.query( + `INSERT INTO vorauszahlungen (objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, + [objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Bulk: Vorauszahlungen für Jahr erstellen + app.post('/api/vorauszahlungen/bulk', async (req, res) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const { objektId, mieterId, jahr, monatlicherBetrag } = req.body; + const results = []; + + for (let monat = 1; monat <= 12; monat++) { + const result = await client.query( + `INSERT INTO vorauszahlungen (objekt_id, mieter_id, jahr, monat, betrag) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (objekt_id, mieter_id, jahr, monat) + DO UPDATE SET betrag = $5 + RETURNING *`, + [objektId, mieterId, jahr, monat, monatlicherBetrag] + ); + results.push(result.rows[0]); + } + + await client.query('COMMIT'); + res.json({ success: true, created: results.length, vorauszahlungen: results }); + } catch (error) { + await client.query('ROLLBACK'); + res.status(500).json({ error: error.message }); + } finally { + client.release(); + } + }); + + app.put('/api/vorauszahlungen/:id', async (req, res) => { + try { + const { betrag, bezahlt_am, bemerkung } = req.body; + const result = await pool.query( + `UPDATE vorauszahlungen SET betrag = $1, bezahlt_am = $2, bemerkung = $3 WHERE id = $4 RETURNING *`, + [betrag, bezahlt_am, bemerkung, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Vorauszahlung nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.delete('/api/vorauszahlungen/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM vorauszahlungen WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Vorauszahlung nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== VERMIETUNGSBILANZ ========== + + // Hilfsfunktion: Berechne überlappende Monate zwischen zwei Zeiträumen + function calculateOverlappingMonths(startA, endA, startB, endB) { + const start = new Date(Math.max(startA.getTime(), startB.getTime())); + const end = new Date(Math.min(endA.getTime(), endB.getTime())); + + if (start > end) return 0; + + // Monate berechnen (inklusive Start- und Endmonat) + const months = (end.getFullYear() - start.getFullYear()) * 12 + + (end.getMonth() - start.getMonth()) + 1; + return Math.max(0, months); + } + + // Einnahmen und Ausgaben pro Objekt für Vermietungs-Bilanz + app.get('/api/vermietungsbilanz', async (req, res) => { + try { + const { jahr = new Date().getFullYear() } = req.query; + + const yearStart = new Date(jahr, 0, 1); // 1. Januar + const yearEnd = new Date(jahr, 11, 31); // 31. Dezember + + // Alle Objekte laden + const objekteResult = await pool.query('SELECT id, name FROM objekte ORDER BY name'); + + const bilanzDaten = []; + let gesamtEinnahmen = 0; + let gesamtAusgaben = 0; + + for (const objekt of objekteResult.rows) { + // === EINNAHMEN: Alle Mietverträge mit Zeitraumberechnung === + // Mietverträge laden die im Jahr aktiv waren + const vertraegeResult = await pool.query(` + SELECT mv.*, m.name as mieter_name + FROM mietvertraege mv + JOIN mieter m ON m.id = mv.mieter_id + WHERE mv.objekt_id = $1 + AND mv.vertragsbeginn <= $2 + AND (mv.vertragsende IS NULL OR mv.vertragsende >= $3) + `, [objekt.id, yearEnd, yearStart]); + + // Einnahmen pro Vertrag berechnen + let objektEinnahmen = 0; + const einnahmenDetails = []; + + for (const vertrag of vertraegeResult.rows) { + const vertragsBeginn = new Date(vertrag.vertragsbeginn); + const vertragsEnde = vertrag.vertragsende ? new Date(vertrag.vertragsende) : null; + + // Überlappende Monate berechnen + const effectiveStart = vertragsBeginn > yearStart ? vertragsBeginn : yearStart; + const effectiveEnd = (vertragsEnde && vertragsEnde < yearEnd) ? vertragsEnde : yearEnd; + + const months = calculateOverlappingMonths( + vertragsBeginn, vertragsEnde || new Date(2099, 11, 31), + yearStart, yearEnd + ); + + const kaltmiete = parseFloat(vertrag.kaltmiete) || 0; + const vertragEinnahmen = kaltmiete * months; + objektEinnahmen += vertragEinnahmen; + + einnahmenDetails.push({ + mieter_id: vertrag.mieter_id, + mieter_name: vertrag.mieter_name, + vertragsbeginn: vertrag.vertragsbeginn, + vertragsende: vertrag.vertragsende, + kaltmiete_monat: kaltmiete, + monate: months, + summe: Math.round(vertragEinnahmen * 100) / 100 + }); + } + + // === AUSGABEN: Nebenkosten für das Jahr === + const ausgabenResult = await pool.query(` + SELECT COALESCE(SUM(betrag), 0) as nebenkosten + FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 + `, [objekt.id, jahr]); + const nebenkosten = parseFloat(ausgabenResult.rows[0].nebenkosten); + + // Detaillierte Ausgaben nach Kategorie + const kategorienResult = await pool.query(` + SELECT kategorie, SUM(betrag) as betrag + FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 + GROUP BY kategorie + ORDER BY SUM(betrag) DESC + `, [objekt.id, jahr]); + + // Detaillierte Ausgaben für Modal + const ausgabenDetailsResult = await pool.query(` + SELECT id, kategorie, bezeichnung, betrag, datum + FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 + ORDER BY betrag DESC + `, [objekt.id, jahr]); + + const bilanz = objektEinnahmen - nebenkosten; + gesamtEinnahmen += objektEinnahmen; + gesamtAusgaben += nebenkosten; + + // Anzahl aktiver Mieter + const aktiveMieter = einnahmenDetails.length; + + bilanzDaten.push({ + objekt_id: objekt.id, + objekt_name: objekt.name, + mieteinnahmen: Math.round(objektEinnahmen * 100) / 100, + nebenkosten: Math.round(nebenkosten * 100) / 100, + bilanz: Math.round(bilanz * 100) / 100, + anzahl_mieter: aktiveMieter, + kategorien: kategorienResult.rows, + // Details für Drill-down + einnahmen_details: einnahmenDetails, + ausgaben_details: ausgabenDetailsResult.rows + }); + } + + res.json({ + jahr: parseInt(jahr), + objekte: bilanzDaten, + gesamt_einnahmen: Math.round(gesamtEinnahmen * 100) / 100, + gesamt_ausgaben: Math.round(gesamtAusgaben * 100) / 100, + gesamt_bilanz: Math.round((gesamtEinnahmen - gesamtAusgaben) * 100) / 100 + }); + } catch (error) { + console.error('Vermietungsbilanz Error:', error); + res.status(500).json({ error: error.message }); + } + }); + + // Alias für Frontend-Kompatibilität mit DETAILS + app.get('/api/vermietung/bilanz', async (req, res) => { + try { + const { jahr = new Date().getFullYear() } = req.query; + const yearStart = new Date(jahr, 0, 1); + const yearEnd = new Date(jahr, 11, 31); + + // Alle Objekte laden + const objekteResult = await pool.query('SELECT id, name, adresse FROM objekte ORDER BY name'); + + const bilanzDaten = []; + let gesamtEinnahmen = 0; + let gesamtAusgaben = 0; + + for (const objekt of objekteResult.rows) { + // === EINNAHMEN: Detaillierte Abfrage === + const vertraegeResult = await pool.query(` + SELECT mv.*, m.name as mieter_name + FROM mietvertraege mv + JOIN mieter m ON m.id = mv.mieter_id + WHERE mv.objekt_id = $1 + AND mv.vertragsbeginn <= $2 + AND (mv.vertragsende IS NULL OR mv.vertragsende >= $3) + ORDER BY m.name + `, [objekt.id, yearEnd, yearStart]); + + // Einnahmen berechnen mit Detail-Tracking + let objektEinnahmen = 0; + const einnahmenDetails = []; + + for (const vertrag of vertraegeResult.rows) { + const vertragsBeginn = new Date(vertrag.vertragsbeginn); + const vertragsEnde = vertrag.vertragsende ? new Date(vertrag.vertragsende) : null; + + // Überlappende Monate berechnen + const effectiveStart = vertragsBeginn > yearStart ? vertragsBeginn : yearStart; + const effectiveEnd = (vertragsEnde && vertragsEnde < yearEnd) ? vertragsEnde : yearEnd; + + let monate = 0; + if (effectiveStart <= effectiveEnd) { + monate = (effectiveEnd.getFullYear() - effectiveStart.getFullYear()) * 12 + + (effectiveEnd.getMonth() - effectiveStart.getMonth()) + 1; + } + + const kaltmiete = parseFloat(vertrag.kaltmiete) || 0; + const vertragEinnahmen = kaltmiete * monate; + objektEinnahmen += vertragEinnahmen; + + // Formatierter Zeitraum + const zeitraumVon = effectiveStart.toLocaleDateString('de-DE', { month: 'short' }); + const zeitraumBis = effectiveEnd.toLocaleDateString('de-DE', { month: 'short' }); + const zeitraum = monate === 12 ? 'Jan-Dez' : `${zeitraumVon}-${zeitraumBis}`; + + einnahmenDetails.push({ + mieter: vertrag.mieter_name, + zeitraum: zeitraum, + monate: monate, + kaltmiete: kaltmiete, + summe: Math.round(vertragEinnahmen * 100) / 100 + }); + } + + // === AUSGABEN: Detaillierte Abfrage === + const kostenResult = await pool.query(` + SELECT kategorie, bezeichnung, betrag, datum + FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 + ORDER BY kategorie, bezeichnung + `, [objekt.id, jahr]); + + const ausgabenDetails = kostenResult.rows.map(k => ({ + kategorie: k.kategorie, + bezeichnung: k.bezeichnung, + betrag: parseFloat(k.betrag) || 0, + datum: k.datum + })); + + const objektAusgaben = ausgabenDetails.reduce((sum, k) => sum + k.betrag, 0); + const bilanz = objektEinnahmen - objektAusgaben; + const rendite = objektEinnahmen > 0 ? ((bilanz / objektEinnahmen) * 100) : 0; + + gesamtEinnahmen += objektEinnahmen; + gesamtAusgaben += objektAusgaben; + + bilanzDaten.push({ + id: objekt.id, + name: objekt.name, + adresse: objekt.adresse, + einnahmen: Math.round(objektEinnahmen * 100) / 100, + ausgaben: Math.round(objektAusgaben * 100) / 100, + bilanz: Math.round(bilanz * 100) / 100, + rendite: Math.round(rendite * 10) / 10, + anzahl_mieter: einnahmenDetails.length, + details: { + einnahmen: einnahmenDetails, + ausgaben: ausgabenDetails + } + }); + } + + res.json({ + jahr: parseInt(jahr), + objekte: bilanzDaten, + gesamt: { + einnahmen: Math.round(gesamtEinnahmen * 100) / 100, + ausgaben: Math.round(gesamtAusgaben * 100) / 100, + bilanz: Math.round((gesamtEinnahmen - gesamtAusgaben) * 100) / 100, + rendite: gesamtEinnahmen > 0 ? Math.round(((gesamtEinnahmen - gesamtAusgaben) / gesamtEinnahmen) * 100 * 10) / 10 : 0 + } + }); + } catch (error) { + console.error('Vermietung Bilanz Error:', error); + res.status(500).json({ error: error.message }); + } + }); + + // ========== NEBENKOSTENABRECHNUNG ========== + + // Übersicht + app.get('/api/nebenkostenabrechnung/uebersicht', async (req, res) => { + try { + const { jahr = new Date().getFullYear() } = req.query; + + // Alle Objekte mit Summen + const result = await pool.query(` + SELECT o.*, + COALESCE(k.gesamt_kosten, 0) as gesamt_kosten, + COALESCE(v.gesamt_vorauszahlungen, 0) as gesamt_vorauszahlungen, + COALESCE(m.anzahl_mieter, 0) as anzahl_mieter + FROM objekte o + LEFT JOIN ( + SELECT objekt_id, SUM(betrag) as gesamt_kosten + FROM objektkosten WHERE jahr = $1 GROUP BY objekt_id + ) k ON k.objekt_id = o.id + LEFT JOIN ( + SELECT objekt_id, SUM(betrag) as gesamt_vorauszahlungen + FROM vorauszahlungen WHERE jahr = $1 GROUP BY objekt_id + ) v ON v.objekt_id = o.id + LEFT JOIN ( + SELECT objekt_id, COUNT(*) as anzahl_mieter + FROM mietvertraege WHERE ist_aktuell = true GROUP BY objekt_id + ) m ON m.objekt_id = o.id + ORDER BY o.name + `, [jahr]); + + res.json({ jahr: parseInt(jahr), objekte: result.rows }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Abrechnung Vorschau (Berechnung) + app.post('/api/nebenkostenabrechnung/vorschau', async (req, res) => { + try { + const { objektId, jahr, zeitraumVon, zeitraumBis } = req.body; + + // Objekt-Daten + const objektResult = await pool.query('SELECT * FROM objekte WHERE id = $1', [objektId]); + if (objektResult.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' }); + const objekt = objektResult.rows[0]; + + // Alle Kosten des Objekts im Jahr + const kostenResult = await pool.query( + 'SELECT * FROM objektkosten WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie', + [objektId, jahr] + ); + + // Aktuelle Mieter mit Mietverträgen + const mieterResult = await pool.query(` + SELECT mv.*, m.name as mieter_name, m.email as mieter_email + FROM mietvertraege mv + JOIN mieter m ON m.id = mv.mieter_id + WHERE mv.objekt_id = $1 AND mv.ist_aktuell = true + `, [objektId]); + + // Berechnung + const gesamtKosten = kostenResult.rows.reduce((sum, k) => sum + parseFloat(k.betrag), 0); + const gesamtFlaeche = mieterResult.rows.reduce((sum, m) => sum + parseFloat(m.wohnflaeche_qm), 0); + + // Tage im Abrechnungszeitraum + const vonDatum = new Date(zeitraumVon); + const bisDatum = new Date(zeitraumBis); + const tageGesamt = Math.ceil((bisDatum - vonDatum) / (1000 * 60 * 60 * 24)) + 1; + + // Pro Mieter berechnen + const berechnungen = []; + + for (const mietvertrag of mieterResult.rows) { + // Vorauszahlungen dieses Mieters + const vorauszahlungenResult = await pool.query( + 'SELECT SUM(betrag) as summe FROM vorauszahlungen WHERE objekt_id = $1 AND mieter_id = $2 AND jahr = $3', + [objektId, mietvertrag.mieter_id, jahr] + ); + const summeVorauszahlungen = parseFloat(vorauszahlungenResult.rows[0]?.summe || 0); + + // Anteil berechnen (pro-rata bei Mieterwechsel möglich) + const anteilQm = parseFloat(mietvertrag.wohnflaeche_qm); + const anteilTage = tageGesamt; // Hier könnte differenzierte Logik für Ein-/Auszug stehen + const anteilKosten = gesamtKosten * (anteilQm / gesamtFlaeche); + + const ergebnis = anteilKosten - summeVorauszahlungen; + + berechnungen.push({ + mietvertrag_id: mietvertrag.id, + mieter_id: mietvertrag.mieter_id, + mieter_name: mietvertrag.mieter_name, + anteil_qm: anteilQm, + anteil_tage: anteilTage, + anteil_kosten: Math.round(anteilKosten * 100) / 100, + summe_vorauszahlungen: summeVorauszahlungen, + ergebnis: Math.round(ergebnis * 100) / 100, + ist_nachzahlung: ergebnis > 0, + betrag_nachzahlung: ergebnis > 0 ? ergebnis : 0, + betrag_gutschrift: ergebnis < 0 ? Math.abs(ergebnis) : 0 + }); + } + + res.json({ + objekt, + jahr, + zeitraum_von: zeitraumVon, + zeitraum_bis: zeitraumBis, + tage_gesamt: tageGesamt, + gesamt_kosten: gesamtKosten, + gesamt_flaeche: gesamtFlaeche, + kosten: kostenResult.rows, + mieter: mieterResult.rows, + berechnungen + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Abrechnung speichern + app.post('/api/nebenkostenabrechnung', async (req, res) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const { objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf, berechnungen } = req.body; + + // Bestehende Abrechnung prüfen/löschen + await client.query( + 'DELETE FROM nebenkostenabrechnungen WHERE objekt_id = $1 AND jahr = $2', + [objekt_id, jahr] + ); + + // Neue Abrechnung erstellen + const abrechnungResult = await client.query( + `INSERT INTO nebenkostenabrechnungen (objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf) + VALUES ($1, $2, $3, $4, $5) RETURNING *`, + [objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf !== false] + ); + const abrechnung = abrechnungResult.rows[0]; + + // Positionen speichern + for (const pos of berechnungen) { + await client.query( + `INSERT INTO abrechnungspositionen + (abrechnung_id, mieter_id, mietvertrag_id, anteil_qm, anteil_tage, anteil_kosten, summe_vorauszahlungen, ergebnis) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [abrechnung.id, pos.mieter_id, pos.mietvertrag_id, pos.anteil_qm, pos.anteil_tage, + pos.anteil_kosten, pos.summe_vorauszahlungen, pos.ergebnis] + ); + } + + await client.query('COMMIT'); + res.json({ success: true, abrechnung: abrechnungResult.rows[0] }); + } catch (error) { + await client.query('ROLLBACK'); + res.status(500).json({ error: error.message }); + } finally { + client.release(); + } + }); + + // Alle Abrechnungen + app.get('/api/nebenkostenabrechnung', async (req, res) => { + try { + const result = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + ORDER BY na.jahr DESC, o.name + `); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Alias für Frontend (Plural) + app.get('/api/nebenkostenabrechnungen', async (req, res) => { + try { + const result = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + ORDER BY na.jahr DESC, o.name + `); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Einzelne Abrechnung mit Positionen + app.get('/api/nebenkostenabrechnung/:id', async (req, res) => { + try { + const abrechnungResult = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + WHERE na.id = $1 + `, [req.params.id]); + + if (abrechnungResult.rows.length === 0) { + return res.status(404).json({ error: 'Abrechnung nicht gefunden' }); + } + + const positionenResult = await pool.query(` + SELECT ap.*, m.name as mieter_name, m.email as mieter_email + FROM abrechnungspositionen ap + JOIN mieter m ON m.id = ap.mieter_id + WHERE ap.abrechnung_id = $1 + `, [req.params.id]); + + const kostenResult = await pool.query(` + SELECT * FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 + `, [abrechnungResult.rows[0].objekt_id, abrechnungResult.rows[0].jahr]); + + res.json({ + abrechnung: abrechnungResult.rows[0], + positionen: positionenResult.rows, + kosten: kostenResult.rows + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Status aktualisieren (Entwurf/Final) + app.put('/api/nebenkostenabrechnung/:id/status', async (req, res) => { + try { + const { ist_entwurf } = req.body; + const result = await pool.query( + 'UPDATE nebenkostenabrechnungen SET ist_entwurf = $1 WHERE id = $2 RETURNING *', + [ist_entwurf, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Abrechnung nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // PDF Export - Privat (René Täger) - EINE PDF pro Mieter + app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => { + try { + const { PDFDocument, rgb, StandardFonts } = require('pdf-lib'); + + const abrechnungResult = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort, o.wohnflaeche_qm as objekt_flaeche + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + WHERE na.id = $1 + `, [req.params.id]); + + if (abrechnungResult.rows.length === 0) { + return res.status(404).json({ error: 'Abrechnung nicht gefunden' }); + } + + const abrechnung = abrechnungResult.rows[0]; + + // ALLE Positionen laden + const positionenResult = await pool.query(` + SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse + FROM abrechnungspositionen ap + JOIN mieter m ON m.id = ap.mieter_id + WHERE ap.abrechnung_id = $1 + `, [req.params.id]); + + const kostenResult = await pool.query(` + SELECT * FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie + `, [abrechnung.objekt_id, abrechnung.jahr]); + + // Berechnungsgrundlagen + const gesamtKosten = kostenResult.rows.reduce((sum, k) => sum + parseFloat(k.betrag), 0); + const gesamtFlaeche = parseFloat(abrechnung.objekt_flaeche || 95); + const kostenProQm = gesamtKosten / gesamtFlaeche; + + // PDF erstellen + const pdfDoc = await PDFDocument.create(); + const width = 595.28; + const height = 841.89; + const margin = 50; + + const font = await pdfDoc.embedFont(StandardFonts.Helvetica); + const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + + const istEntwurf = req.query.entwurf === 'true'; + const heute = new Date().toLocaleDateString('de-DE'); + + // Hilfsfunktion für Trennlinie + function drawLine(page, yPos) { + page.drawLine({ + start: { x: margin, y: yPos }, + end: { x: width - margin, y: yPos }, + thickness: 0.5, + color: rgb(0.8, 0.8, 0.8) + }); + } + + // ========== FÜR JEDEN MIETER EINE SEITE ========== + for (const pos of positionenResult.rows) { + const mieterName = pos.mieter_name; + const mieterAdresse = pos.mieter_adresse || ''; + const mieterFlaeche = parseFloat(pos.anteil_qm); + const anteilKosten = parseFloat(pos.anteil_kosten); + const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen); + const ergebnis = parseFloat(pos.ergebnis); + + // Neue Seite für diesen Mieter + const page = pdfDoc.addPage([width, height]); + let y = height - 50; + + // ========== ABSENDER (oben links) ========== + page.drawText('René Täger', { x: margin, y, size: 10, font: fontBold }); + y -= 14; + page.drawText('Zingelstraße 7', { x: margin, y, size: 9, font }); + y -= 14; + page.drawText('25554 Wilster', { x: margin, y, size: 9, font }); + y -= 30; + + // Trennlinie + drawLine(page, y); + y -= 30; + + // ========== EMPFÄNGER (nur dieser Mieter) ========== + page.drawText(mieterName, { x: margin, y, size: 11, font: fontBold }); + y -= 16; + if (mieterAdresse) { + const adressZeilen = mieterAdresse.split('\n'); + for (const zeile of adressZeilen) { + page.drawText(zeile, { x: margin, y, size: 10, font }); + y -= 14; + } + } + y -= 40; + + // ========== DATUM + BETREFF ========== + page.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font }); + + page.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 25; + + if (istEntwurf) { + page.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) }); + } + + page.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font }); + y -= 20; + page.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold }); + y -= 16; + page.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font }); + y -= 16; + page.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font }); + y -= 35; + + // ========== BEGLEITTEXT ========== + page.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold }); + y -= 18; + page.drawText('im Folgenden erhalten Sie Ihre persönliche Nebenkostenabrechnung.', { x: margin, y, size: 9, font }); + y -= 14; + page.drawText('Die Kosten wurden nach Ihrem im Mietvertrag vereinbarten Anteil umgelegt.', { x: margin, y, size: 9, font }); + y -= 30; + + // ========== 1. KOSTENÜBERSICHT (alle Kosten des Objekts) ========== + page.drawText('1. Kostenübersicht des Objekts:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + // Tabellenkopf + page.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold }); + page.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold }); + page.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold }); + y -= 12; + drawLine(page, y + 8); + y -= 5; + + for (const k of kostenResult.rows) { + const betrag = parseFloat(k.betrag); + page.drawText(k.kategorie, { x: margin, y, size: 9, font }); + page.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font }); + page.drawText(`${betrag.toFixed(2)} €`, { x: margin + 350, y, size: 9, font }); + y -= 14; + } + + y -= 5; + drawLine(page, y + 8); + y -= 5; + page.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold }); + page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 350, y, size: 10, font: fontBold }); + y -= 30; + + // ========== 2. BERECHNUNGSGRUNDLAGE ========== + page.drawText('2. Berechnungsgrundlage:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + page.drawText(`Gesamtkosten:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Gesamtwohnfläche:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${gesamtFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Kosten pro qm:`, { x: margin + 20, y, size: 9, font: fontBold }); + page.drawText(`${gesamtKosten.toFixed(2)} € / ${gesamtFlaeche.toFixed(2)} qm = ${kostenProQm.toFixed(2)} €/qm`, { x: margin + 250, y, size: 9, font: fontBold }); + y -= 30; + + // ========== 3. IHRE PERSÖNLICHE BERECHNUNG ========== + page.drawText('3. Ihre persönliche Berechnung:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + page.drawText(`Ihr Name:`, { x: margin + 20, y, size: 9, font }); + page.drawText(mieterName, { x: margin + 250, y, size: 9, font: fontBold }); + y -= 14; + page.drawText(`Ihre Wohnfläche:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${mieterFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Ihr Anteil an den Kosten:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${kostenProQm.toFixed(2)} €/qm × ${mieterFlaeche.toFixed(2)} qm = ${anteilKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Geleistete Vorauszahlungen:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${vorauszahlungen.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 25; + + // Ergebnis hervorgehoben + drawLine(page, y + 8); + y -= 5; + + if (ergebnis > 0) { + page.drawText(`Nachzahlung:`, { x: margin + 20, y, size: 11, font: fontBold }); + page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold }); + y -= 20; + page.drawText(`Bitte überweisen Sie den Betrag innerhalb von 14 Tagen.`, { x: margin + 20, y, size: 9, font, color: rgb(0.8, 0.2, 0.2) }); + } else { + page.drawText(`Gutschrift:`, { x: margin + 20, y, size: 11, font: fontBold }); + page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold }); + y -= 20; + page.drawText(`Der Betrag wird mit der nächsten Miete verrechnet.`, { x: margin + 20, y, size: 9, font, color: rgb(0.2, 0.6, 0.2) }); + } + y -= 25; + + // ========== FUSSZEILE ========== + y -= 20; + drawLine(page, y); + y -= 20; + page.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) }); + y -= 12; + page.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) }); + } + + // PDF speichern + const pdfBytes = await pdfDoc.save(); + const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes); + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Length', pdfBuffer.length); + res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`); + res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition'); + res.end(pdfBuffer); + } catch (error) { + console.error('PDF Error:', error); + res.status(500).json({ error: error.message }); + } + }); + + +}; + diff --git a/backend/routes/nebenkosten_unix.js.unix b/backend/routes/nebenkosten_unix.js.unix new file mode 100644 index 0000000..54395a4 --- /dev/null +++ b/backend/routes/nebenkosten_unix.js.unix @@ -0,0 +1,1287 @@ +// API Routes für Nebenkostenabrechnung (Objekte, Mieter, Mietverträge, Kosten, Vorauszahlungen, Abrechnungen) +const { Pool } = require('pg'); + +const pool = new Pool({ + host: process.env.DB_HOST || 'buchhaltung-db', + port: process.env.DB_PORT || 5432, + database: process.env.DB_NAME || 'buchhaltung', + user: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || 'postgres', +}); + +module.exports = (app) => { + + // ========== OBJEKTE ========== + + // Alle Objekte + app.get('/api/objekte', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM objekte ORDER BY name'); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Einzelnes Objekt + app.get('/api/objekte/:id', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM objekte WHERE id = $1', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Objekt erstellen + app.post('/api/objekte', async (req, res) => { + try { + const { name, adresse, plz, ort, wohnflaeche_qm, bemerkung } = req.body; + const result = await pool.query( + 'INSERT INTO objekte (name, adresse, plz, ort, wohnflaeche_qm, bemerkung) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *', + [name, adresse, plz, ort, wohnflaeche_qm || 0, bemerkung] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Objekt aktualisieren + app.put('/api/objekte/:id', async (req, res) => { + try { + const { name, adresse, plz, ort, wohnflaeche_qm, bemerkung } = req.body; + const result = await pool.query( + 'UPDATE objekte SET name = $1, adresse = $2, plz = $3, ort = $4, wohnflaeche_qm = $5, bemerkung = $6 WHERE id = $7 RETURNING *', + [name, adresse, plz, ort, wohnflaeche_qm, bemerkung, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Objekt löschen + app.delete('/api/objekte/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM objekte WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== OBJEKTKOSTEN ========== + + // Kosten für ein Objekt laden (optional gefiltert nach Jahr) + app.get('/api/objekte/:id/kosten', async (req, res) => { + try { + const { jahr } = req.query; + let query = 'SELECT * FROM objektkosten WHERE objekt_id = $1'; + const params = [req.params.id]; + + if (jahr) { + query += ' AND jahr = $2'; + params.push(jahr); + } + + query += ' ORDER BY jahr DESC, kategorie ASC'; + + const result = await pool.query(query, params); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Kosten hinzufügen + app.post('/api/objekte/:id/kosten', async (req, res) => { + try { + const { kategorie, betrag, jahr } = req.body; + const result = await pool.query( + 'INSERT INTO objektkosten (objekt_id, kategorie, betrag, jahr) VALUES ($1, $2, $3, $4) RETURNING *', + [req.params.id, kategorie, betrag, jahr] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Kosten löschen + app.delete('/api/objekte/:id/kosten/:kostenId', async (req, res) => { + try { + await pool.query('DELETE FROM objektkosten WHERE id = $1 AND objekt_id = $2', [req.params.kostenId, req.params.id]); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Mieter eines Objekts + app.get('/api/objekte/:id/mieter', async (req, res) => { + try { + const result = await pool.query( + `SELECT m.*, mv.id as mietvertrag_id, mv.wohnflaeche_qm, mv.kaltmiete, + mv.nebenkosten_vorauszahlung, mv.vertragsbeginn, mv.vertragsende, mv.ist_aktuell + FROM mieter m + JOIN mietvertraege mv ON mv.mieter_id = m.id + WHERE mv.objekt_id = $1 + ORDER BY m.name`, + [req.params.id] + ); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== MIETER ========== + + app.get('/api/mieter', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM mieter ORDER BY name'); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.get('/api/mieter/:id', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM mieter WHERE id = $1', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/mieter', async (req, res) => { + try { + const { name, email, telefon, adresse } = req.body; + const result = await pool.query( + 'INSERT INTO mieter (name, email, telefon, adresse) VALUES ($1, $2, $3, $4) RETURNING *', + [name, email, telefon, adresse] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.put('/api/mieter/:id', async (req, res) => { + try { + const { name, email, telefon, adresse } = req.body; + const result = await pool.query( + 'UPDATE mieter SET name = $1, email = $2, telefon = $3, adresse = $4 WHERE id = $5 RETURNING *', + [name, email, telefon, adresse, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.delete('/api/mieter/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM mieter WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Mieter mit Mietvertrag erstellen (Combined) + app.post('/api/mieter-mit-vertrag', async (req, res) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const { name, email, telefon, adresse, objekt_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn, vertragsende } = req.body; + + // Mieter erstellen + const mieterResult = await client.query( + 'INSERT INTO mieter (name, email, telefon, adresse) VALUES ($1, $2, $3, $4) RETURNING *', + [name, email, telefon, adresse] + ); + const mieter = mieterResult.rows[0]; + + // Mietvertrag erstellen + const vertragResult = await client.query( + `INSERT INTO mietvertraege (objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn, vertragsende, ist_aktuell) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, + [objekt_id, mieter.id, wohnflaeche_qm || 0, kaltmiete || 0, nebenkosten_vorauszahlung || 0, vertragsbeginn, vertragsende || null, !vertragsende] + ); + + await client.query('COMMIT'); + res.json({ mieter, mietvertrag: vertragResult.rows[0] }); + } catch (error) { + await client.query('ROLLBACK'); + res.status(500).json({ error: error.message }); + } finally { + client.release(); + } + }); + + // Mieter mit Mietvertrag aktualisieren (Combined) + app.put('/api/mieter/:id/mit-vertrag', async (req, res) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const { name, email, telefon, adresse, objekt_id, kaltmiete, vorauszahlung, vertragsbeginn, vertragsende } = req.body; + + // ist_aktuell automatisch berechnen: true wenn kein vertragsende oder vertragsende in Zukunft + const istAktuell = !vertragsende || new Date(vertragsende) >= new Date(); + + // Mieter aktualisieren + const mieterResult = await client.query( + 'UPDATE mieter SET name = $1, email = $2, telefon = $3, adresse = $4 WHERE id = $5 RETURNING *', + [name, email, telefon, adresse, req.params.id] + ); + if (mieterResult.rows.length === 0) { + await client.query('ROLLBACK'); + return res.status(404).json({ error: 'Mieter nicht gefunden' }); + } + + // Aktiven Mietvertrag finden und aktualisieren + const vertragResult = await client.query( + `UPDATE mietvertraege SET + objekt_id = $1, kaltmiete = $2, nebenkosten_vorauszahlung = $3, + vertragsbeginn = $4, vertragsende = $5, ist_aktuell = $6 + WHERE mieter_id = $7 AND (ist_aktuell = true OR id = ( + SELECT id FROM mietvertraege WHERE mieter_id = $7 ORDER BY vertragsbeginn DESC LIMIT 1 + )) + RETURNING *`, + [objekt_id, kaltmiete, vorauszahlung, vertragsbeginn, vertragsende || null, istAktuell, req.params.id] + ); + + await client.query('COMMIT'); + res.json({ mieter: mieterResult.rows[0], mietvertrag: vertragResult.rows[0] || null }); + } catch (error) { + await client.query('ROLLBACK'); + res.status(500).json({ error: error.message }); + } finally { + client.release(); + } + }); + + // Mietverträge eines Mieters + app.get('/api/mieter/:id/mietvertraege', async (req, res) => { + try { + const result = await pool.query( + `SELECT mv.*, o.name as objekt_name + FROM mietvertraege mv + JOIN objekte o ON o.id = mv.objekt_id + WHERE mv.mieter_id = $1 + ORDER BY mv.vertragsbeginn DESC`, + [req.params.id] + ); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== MIETVERTRÄGE ========== + + app.get('/api/mietvertraege', async (req, res) => { + try { + const result = await pool.query( + `SELECT mv.*, m.name as mieter_name, o.name as objekt_name + FROM mietvertraege mv + JOIN mieter m ON m.id = mv.mieter_id + JOIN objekte o ON o.id = mv.objekt_id + ORDER BY mv.vertragsbeginn DESC` + ); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== MIETVERTRÄGE ========== + + app.get('/api/mietvertraege', async (req, res) => { + try { + const { objekt_id, ist_aktuell } = req.query; + let query = `SELECT mv.*, o.name as objekt_name, o.adresse, o.plz, o.ort, + m.name as mieter_name, m.email as mieter_email + FROM mietvertraege mv + JOIN objekte o ON o.id = mv.objekt_id + JOIN mieter m ON m.id = mv.mieter_id`; + const values = []; + const conditions = []; + + if (objekt_id) { + conditions.push(`mv.objekt_id = $${values.length + 1}`); + values.push(objekt_id); + } + if (ist_aktuell !== undefined) { + conditions.push(`mv.ist_aktuell = $${values.length + 1}`); + values.push(ist_aktuell === 'true'); + } + + if (conditions.length > 0) { + query += ' WHERE ' + conditions.join(' AND '); + } + + query += ' ORDER BY mv.vertragsbeginn DESC'; + + const result = await pool.query(query, values); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/mietvertraege', async (req, res) => { + try { + const { objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn } = req.body; + const result = await pool.query( + `INSERT INTO mietvertraege (objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, + [objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung || 0, vertragsbeginn] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.put('/api/mietvertraege/:id', async (req, res) => { + try { + const { wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsende, ist_aktuell } = req.body; + const result = await pool.query( + `UPDATE mietvertraege SET wohnflaeche_qm = $1, kaltmiete = $2, nebenkosten_vorauszahlung = $3, + vertragsende = $4, ist_aktuell = $5 WHERE id = $6 RETURNING *`, + [wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsende, ist_aktuell !== undefined ? ist_aktuell : true, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/mietvertraege/:id/beenden', async (req, res) => { + try { + const { vertragsende } = req.body; + const result = await pool.query( + `UPDATE mietvertraege SET vertragsende = $1, ist_aktuell = false WHERE id = $2 RETURNING *`, + [vertragsende, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.delete('/api/mietvertraege/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM mietvertraege WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== OBJEKTKOSTEN ========== + + app.get('/api/objektkosten', async (req, res) => { + try { + const { objekt_id, jahr } = req.query; + let query = 'SELECT ok.*, o.name as objekt_name FROM objektkosten ok JOIN objekte o ON o.id = ok.objekt_id'; + const values = []; + const conditions = []; + + if (objekt_id) { + conditions.push(`ok.objekt_id = $${values.length + 1}`); + values.push(objekt_id); + } + if (jahr) { + conditions.push(`ok.jahr = $${values.length + 1}`); + values.push(parseInt(jahr)); + } + + if (conditions.length > 0) { + query += ' WHERE ' + conditions.join(' AND '); + } + + query += ' ORDER BY ok.jahr DESC, ok.kategorie, ok.datum'; + + const result = await pool.query(query, values); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/objektkosten', async (req, res) => { + try { + const { objekt_id, kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung } = req.body; + const result = await pool.query( + `INSERT INTO objektkosten (objekt_id, kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, + [objekt_id, kategorie, bezeichnung, betrag, datum, jahr || new Date().getFullYear(), verteilung || 'qm', bemerkung] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.put('/api/objektkosten/:id', async (req, res) => { + try { + const { kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung } = req.body; + const result = await pool.query( + `UPDATE objektkosten SET kategorie = $1, bezeichnung = $2, betrag = $3, datum = $4, jahr = $5, verteilung = $6, bemerkung = $7 + WHERE id = $8 RETURNING *`, + [kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Kosten nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.delete('/api/objektkosten/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM objektkosten WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Kosten nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== VORAUSZAHLUNGEN ========== + + app.get('/api/vorauszahlungen', async (req, res) => { + try { + const { objekt_id, mieter_id, jahr } = req.query; + let query = `SELECT v.*, o.name as objekt_name, m.name as mieter_name + FROM vorauszahlungen v + JOIN objekte o ON o.id = v.objekt_id + JOIN mieter m ON m.id = v.mieter_id`; + const values = []; + const conditions = []; + + if (objekt_id) { + conditions.push(`v.objekt_id = $${values.length + 1}`); + values.push(objekt_id); + } + if (mieter_id) { + conditions.push(`v.mieter_id = $${values.length + 1}`); + values.push(mieter_id); + } + if (jahr) { + conditions.push(`v.jahr = $${values.length + 1}`); + values.push(parseInt(jahr)); + } + + if (conditions.length > 0) { + query += ' WHERE ' + conditions.join(' AND '); + } + + query += ' ORDER BY v.jahr DESC, v.monat'; + + const result = await pool.query(query, values); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/api/vorauszahlungen', async (req, res) => { + try { + const { objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung } = req.body; + const result = await pool.query( + `INSERT INTO vorauszahlungen (objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, + [objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung] + ); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Bulk: Vorauszahlungen für Jahr erstellen + app.post('/api/vorauszahlungen/bulk', async (req, res) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const { objektId, mieterId, jahr, monatlicherBetrag } = req.body; + const results = []; + + for (let monat = 1; monat <= 12; monat++) { + const result = await client.query( + `INSERT INTO vorauszahlungen (objekt_id, mieter_id, jahr, monat, betrag) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (objekt_id, mieter_id, jahr, monat) + DO UPDATE SET betrag = $5 + RETURNING *`, + [objektId, mieterId, jahr, monat, monatlicherBetrag] + ); + results.push(result.rows[0]); + } + + await client.query('COMMIT'); + res.json({ success: true, created: results.length, vorauszahlungen: results }); + } catch (error) { + await client.query('ROLLBACK'); + res.status(500).json({ error: error.message }); + } finally { + client.release(); + } + }); + + app.put('/api/vorauszahlungen/:id', async (req, res) => { + try { + const { betrag, bezahlt_am, bemerkung } = req.body; + const result = await pool.query( + `UPDATE vorauszahlungen SET betrag = $1, bezahlt_am = $2, bemerkung = $3 WHERE id = $4 RETURNING *`, + [betrag, bezahlt_am, bemerkung, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Vorauszahlung nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.delete('/api/vorauszahlungen/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM vorauszahlungen WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) return res.status(404).json({ error: 'Vorauszahlung nicht gefunden' }); + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // ========== VERMIETUNGSBILANZ ========== + + // Hilfsfunktion: Berechne überlappende Monate zwischen zwei Zeiträumen + function calculateOverlappingMonths(startA, endA, startB, endB) { + const start = new Date(Math.max(startA.getTime(), startB.getTime())); + const end = new Date(Math.min(endA.getTime(), endB.getTime())); + + if (start > end) return 0; + + // Monate berechnen (inklusive Start- und Endmonat) + const months = (end.getFullYear() - start.getFullYear()) * 12 + + (end.getMonth() - start.getMonth()) + 1; + return Math.max(0, months); + } + + // Einnahmen und Ausgaben pro Objekt für Vermietungs-Bilanz + app.get('/api/vermietungsbilanz', async (req, res) => { + try { + const { jahr = new Date().getFullYear() } = req.query; + + const yearStart = new Date(jahr, 0, 1); // 1. Januar + const yearEnd = new Date(jahr, 11, 31); // 31. Dezember + + // Alle Objekte laden + const objekteResult = await pool.query('SELECT id, name FROM objekte ORDER BY name'); + + const bilanzDaten = []; + let gesamtEinnahmen = 0; + let gesamtAusgaben = 0; + + for (const objekt of objekteResult.rows) { + // === EINNAHMEN: Alle Mietverträge mit Zeitraumberechnung === + // Mietverträge laden die im Jahr aktiv waren + const vertraegeResult = await pool.query(` + SELECT mv.*, m.name as mieter_name + FROM mietvertraege mv + JOIN mieter m ON m.id = mv.mieter_id + WHERE mv.objekt_id = $1 + AND mv.vertragsbeginn <= $2 + AND (mv.vertragsende IS NULL OR mv.vertragsende >= $3) + `, [objekt.id, yearEnd, yearStart]); + + // Einnahmen pro Vertrag berechnen + let objektEinnahmen = 0; + const einnahmenDetails = []; + + for (const vertrag of vertraegeResult.rows) { + const vertragsBeginn = new Date(vertrag.vertragsbeginn); + const vertragsEnde = vertrag.vertragsende ? new Date(vertrag.vertragsende) : null; + + // Überlappende Monate berechnen + const effectiveStart = vertragsBeginn > yearStart ? vertragsBeginn : yearStart; + const effectiveEnd = (vertragsEnde && vertragsEnde < yearEnd) ? vertragsEnde : yearEnd; + + const months = calculateOverlappingMonths( + vertragsBeginn, vertragsEnde || new Date(2099, 11, 31), + yearStart, yearEnd + ); + + const kaltmiete = parseFloat(vertrag.kaltmiete) || 0; + const vertragEinnahmen = kaltmiete * months; + objektEinnahmen += vertragEinnahmen; + + einnahmenDetails.push({ + mieter_id: vertrag.mieter_id, + mieter_name: vertrag.mieter_name, + vertragsbeginn: vertrag.vertragsbeginn, + vertragsende: vertrag.vertragsende, + kaltmiete_monat: kaltmiete, + monate: months, + summe: Math.round(vertragEinnahmen * 100) / 100 + }); + } + + // === AUSGABEN: Nebenkosten für das Jahr === + const ausgabenResult = await pool.query(` + SELECT COALESCE(SUM(betrag), 0) as nebenkosten + FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 + `, [objekt.id, jahr]); + const nebenkosten = parseFloat(ausgabenResult.rows[0].nebenkosten); + + // Detaillierte Ausgaben nach Kategorie + const kategorienResult = await pool.query(` + SELECT kategorie, SUM(betrag) as betrag + FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 + GROUP BY kategorie + ORDER BY SUM(betrag) DESC + `, [objekt.id, jahr]); + + // Detaillierte Ausgaben für Modal + const ausgabenDetailsResult = await pool.query(` + SELECT id, kategorie, bezeichnung, betrag, datum + FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 + ORDER BY betrag DESC + `, [objekt.id, jahr]); + + const bilanz = objektEinnahmen - nebenkosten; + gesamtEinnahmen += objektEinnahmen; + gesamtAusgaben += nebenkosten; + + // Anzahl aktiver Mieter + const aktiveMieter = einnahmenDetails.length; + + bilanzDaten.push({ + objekt_id: objekt.id, + objekt_name: objekt.name, + mieteinnahmen: Math.round(objektEinnahmen * 100) / 100, + nebenkosten: Math.round(nebenkosten * 100) / 100, + bilanz: Math.round(bilanz * 100) / 100, + anzahl_mieter: aktiveMieter, + kategorien: kategorienResult.rows, + // Details für Drill-down + einnahmen_details: einnahmenDetails, + ausgaben_details: ausgabenDetailsResult.rows + }); + } + + res.json({ + jahr: parseInt(jahr), + objekte: bilanzDaten, + gesamt_einnahmen: Math.round(gesamtEinnahmen * 100) / 100, + gesamt_ausgaben: Math.round(gesamtAusgaben * 100) / 100, + gesamt_bilanz: Math.round((gesamtEinnahmen - gesamtAusgaben) * 100) / 100 + }); + } catch (error) { + console.error('Vermietungsbilanz Error:', error); + res.status(500).json({ error: error.message }); + } + }); + + // Alias für Frontend-Kompatibilität mit DETAILS + app.get('/api/vermietung/bilanz', async (req, res) => { + try { + const { jahr = new Date().getFullYear() } = req.query; + const yearStart = new Date(jahr, 0, 1); + const yearEnd = new Date(jahr, 11, 31); + + // Alle Objekte laden + const objekteResult = await pool.query('SELECT id, name, adresse FROM objekte ORDER BY name'); + + const bilanzDaten = []; + let gesamtEinnahmen = 0; + let gesamtAusgaben = 0; + + for (const objekt of objekteResult.rows) { + // === EINNAHMEN: Detaillierte Abfrage === + const vertraegeResult = await pool.query(` + SELECT mv.*, m.name as mieter_name + FROM mietvertraege mv + JOIN mieter m ON m.id = mv.mieter_id + WHERE mv.objekt_id = $1 + AND mv.vertragsbeginn <= $2 + AND (mv.vertragsende IS NULL OR mv.vertragsende >= $3) + ORDER BY m.name + `, [objekt.id, yearEnd, yearStart]); + + // Einnahmen berechnen mit Detail-Tracking + let objektEinnahmen = 0; + const einnahmenDetails = []; + + for (const vertrag of vertraegeResult.rows) { + const vertragsBeginn = new Date(vertrag.vertragsbeginn); + const vertragsEnde = vertrag.vertragsende ? new Date(vertrag.vertragsende) : null; + + // Überlappende Monate berechnen + const effectiveStart = vertragsBeginn > yearStart ? vertragsBeginn : yearStart; + const effectiveEnd = (vertragsEnde && vertragsEnde < yearEnd) ? vertragsEnde : yearEnd; + + let monate = 0; + if (effectiveStart <= effectiveEnd) { + monate = (effectiveEnd.getFullYear() - effectiveStart.getFullYear()) * 12 + + (effectiveEnd.getMonth() - effectiveStart.getMonth()) + 1; + } + + const kaltmiete = parseFloat(vertrag.kaltmiete) || 0; + const vertragEinnahmen = kaltmiete * monate; + objektEinnahmen += vertragEinnahmen; + + // Formatierter Zeitraum + const zeitraumVon = effectiveStart.toLocaleDateString('de-DE', { month: 'short' }); + const zeitraumBis = effectiveEnd.toLocaleDateString('de-DE', { month: 'short' }); + const zeitraum = monate === 12 ? 'Jan-Dez' : `${zeitraumVon}-${zeitraumBis}`; + + einnahmenDetails.push({ + mieter: vertrag.mieter_name, + zeitraum: zeitraum, + monate: monate, + kaltmiete: kaltmiete, + summe: Math.round(vertragEinnahmen * 100) / 100 + }); + } + + // === AUSGABEN: Detaillierte Abfrage === + const kostenResult = await pool.query(` + SELECT kategorie, bezeichnung, betrag, datum + FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 + ORDER BY kategorie, bezeichnung + `, [objekt.id, jahr]); + + const ausgabenDetails = kostenResult.rows.map(k => ({ + kategorie: k.kategorie, + bezeichnung: k.bezeichnung, + betrag: parseFloat(k.betrag) || 0, + datum: k.datum + })); + + const objektAusgaben = ausgabenDetails.reduce((sum, k) => sum + k.betrag, 0); + const bilanz = objektEinnahmen - objektAusgaben; + const rendite = objektEinnahmen > 0 ? ((bilanz / objektEinnahmen) * 100) : 0; + + gesamtEinnahmen += objektEinnahmen; + gesamtAusgaben += objektAusgaben; + + bilanzDaten.push({ + id: objekt.id, + name: objekt.name, + adresse: objekt.adresse, + einnahmen: Math.round(objektEinnahmen * 100) / 100, + ausgaben: Math.round(objektAusgaben * 100) / 100, + bilanz: Math.round(bilanz * 100) / 100, + rendite: Math.round(rendite * 10) / 10, + anzahl_mieter: einnahmenDetails.length, + details: { + einnahmen: einnahmenDetails, + ausgaben: ausgabenDetails + } + }); + } + + res.json({ + jahr: parseInt(jahr), + objekte: bilanzDaten, + gesamt: { + einnahmen: Math.round(gesamtEinnahmen * 100) / 100, + ausgaben: Math.round(gesamtAusgaben * 100) / 100, + bilanz: Math.round((gesamtEinnahmen - gesamtAusgaben) * 100) / 100, + rendite: gesamtEinnahmen > 0 ? Math.round(((gesamtEinnahmen - gesamtAusgaben) / gesamtEinnahmen) * 100 * 10) / 10 : 0 + } + }); + } catch (error) { + console.error('Vermietung Bilanz Error:', error); + res.status(500).json({ error: error.message }); + } + }); + + // ========== NEBENKOSTENABRECHNUNG ========== + + // Übersicht + app.get('/api/nebenkostenabrechnung/uebersicht', async (req, res) => { + try { + const { jahr = new Date().getFullYear() } = req.query; + + // Alle Objekte mit Summen + const result = await pool.query(` + SELECT o.*, + COALESCE(k.gesamt_kosten, 0) as gesamt_kosten, + COALESCE(v.gesamt_vorauszahlungen, 0) as gesamt_vorauszahlungen, + COALESCE(m.anzahl_mieter, 0) as anzahl_mieter + FROM objekte o + LEFT JOIN ( + SELECT objekt_id, SUM(betrag) as gesamt_kosten + FROM objektkosten WHERE jahr = $1 GROUP BY objekt_id + ) k ON k.objekt_id = o.id + LEFT JOIN ( + SELECT objekt_id, SUM(betrag) as gesamt_vorauszahlungen + FROM vorauszahlungen WHERE jahr = $1 GROUP BY objekt_id + ) v ON v.objekt_id = o.id + LEFT JOIN ( + SELECT objekt_id, COUNT(*) as anzahl_mieter + FROM mietvertraege WHERE ist_aktuell = true GROUP BY objekt_id + ) m ON m.objekt_id = o.id + ORDER BY o.name + `, [jahr]); + + res.json({ jahr: parseInt(jahr), objekte: result.rows }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Abrechnung Vorschau (Berechnung) + app.post('/api/nebenkostenabrechnung/vorschau', async (req, res) => { + try { + const { objektId, jahr, zeitraumVon, zeitraumBis } = req.body; + + // Objekt-Daten + const objektResult = await pool.query('SELECT * FROM objekte WHERE id = $1', [objektId]); + if (objektResult.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' }); + const objekt = objektResult.rows[0]; + + // Alle Kosten des Objekts im Jahr + const kostenResult = await pool.query( + 'SELECT * FROM objektkosten WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie', + [objektId, jahr] + ); + + // Aktuelle Mieter mit Mietverträgen + const mieterResult = await pool.query(` + SELECT mv.*, m.name as mieter_name, m.email as mieter_email + FROM mietvertraege mv + JOIN mieter m ON m.id = mv.mieter_id + WHERE mv.objekt_id = $1 AND mv.ist_aktuell = true + `, [objektId]); + + // Berechnung + const gesamtKosten = kostenResult.rows.reduce((sum, k) => sum + parseFloat(k.betrag), 0); + const gesamtFlaeche = mieterResult.rows.reduce((sum, m) => sum + parseFloat(m.wohnflaeche_qm), 0); + + // Tage im Abrechnungszeitraum + const vonDatum = new Date(zeitraumVon); + const bisDatum = new Date(zeitraumBis); + const tageGesamt = Math.ceil((bisDatum - vonDatum) / (1000 * 60 * 60 * 24)) + 1; + + // Pro Mieter berechnen + const berechnungen = []; + + for (const mietvertrag of mieterResult.rows) { + // Vorauszahlungen dieses Mieters + const vorauszahlungenResult = await pool.query( + 'SELECT SUM(betrag) as summe FROM vorauszahlungen WHERE objekt_id = $1 AND mieter_id = $2 AND jahr = $3', + [objektId, mietvertrag.mieter_id, jahr] + ); + const summeVorauszahlungen = parseFloat(vorauszahlungenResult.rows[0]?.summe || 0); + + // Anteil berechnen (pro-rata bei Mieterwechsel möglich) + const anteilQm = parseFloat(mietvertrag.wohnflaeche_qm); + const anteilTage = tageGesamt; // Hier könnte differenzierte Logik für Ein-/Auszug stehen + const anteilKosten = gesamtKosten * (anteilQm / gesamtFlaeche); + + const ergebnis = anteilKosten - summeVorauszahlungen; + + berechnungen.push({ + mietvertrag_id: mietvertrag.id, + mieter_id: mietvertrag.mieter_id, + mieter_name: mietvertrag.mieter_name, + anteil_qm: anteilQm, + anteil_tage: anteilTage, + anteil_kosten: Math.round(anteilKosten * 100) / 100, + summe_vorauszahlungen: summeVorauszahlungen, + ergebnis: Math.round(ergebnis * 100) / 100, + ist_nachzahlung: ergebnis > 0, + betrag_nachzahlung: ergebnis > 0 ? ergebnis : 0, + betrag_gutschrift: ergebnis < 0 ? Math.abs(ergebnis) : 0 + }); + } + + res.json({ + objekt, + jahr, + zeitraum_von: zeitraumVon, + zeitraum_bis: zeitraumBis, + tage_gesamt: tageGesamt, + gesamt_kosten: gesamtKosten, + gesamt_flaeche: gesamtFlaeche, + kosten: kostenResult.rows, + mieter: mieterResult.rows, + berechnungen + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Abrechnung speichern + app.post('/api/nebenkostenabrechnung', async (req, res) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const { objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf, berechnungen } = req.body; + + // Bestehende Abrechnung prüfen/löschen + await client.query( + 'DELETE FROM nebenkostenabrechnungen WHERE objekt_id = $1 AND jahr = $2', + [objekt_id, jahr] + ); + + // Neue Abrechnung erstellen + const abrechnungResult = await client.query( + `INSERT INTO nebenkostenabrechnungen (objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf) + VALUES ($1, $2, $3, $4, $5) RETURNING *`, + [objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf !== false] + ); + const abrechnung = abrechnungResult.rows[0]; + + // Positionen speichern + for (const pos of berechnungen) { + await client.query( + `INSERT INTO abrechnungspositionen + (abrechnung_id, mieter_id, mietvertrag_id, anteil_qm, anteil_tage, anteil_kosten, summe_vorauszahlungen, ergebnis) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [abrechnung.id, pos.mieter_id, pos.mietvertrag_id, pos.anteil_qm, pos.anteil_tage, + pos.anteil_kosten, pos.summe_vorauszahlungen, pos.ergebnis] + ); + } + + await client.query('COMMIT'); + res.json({ success: true, abrechnung: abrechnungResult.rows[0] }); + } catch (error) { + await client.query('ROLLBACK'); + res.status(500).json({ error: error.message }); + } finally { + client.release(); + } + }); + + // Alle Abrechnungen + app.get('/api/nebenkostenabrechnung', async (req, res) => { + try { + const result = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + ORDER BY na.jahr DESC, o.name + `); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Alias für Frontend (Plural) + app.get('/api/nebenkostenabrechnungen', async (req, res) => { + try { + const result = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + ORDER BY na.jahr DESC, o.name + `); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Einzelne Abrechnung mit Positionen + app.get('/api/nebenkostenabrechnung/:id', async (req, res) => { + try { + const abrechnungResult = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + WHERE na.id = $1 + `, [req.params.id]); + + if (abrechnungResult.rows.length === 0) { + return res.status(404).json({ error: 'Abrechnung nicht gefunden' }); + } + + const positionenResult = await pool.query(` + SELECT ap.*, m.name as mieter_name, m.email as mieter_email + FROM abrechnungspositionen ap + JOIN mieter m ON m.id = ap.mieter_id + WHERE ap.abrechnung_id = $1 + `, [req.params.id]); + + const kostenResult = await pool.query(` + SELECT * FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 + `, [abrechnungResult.rows[0].objekt_id, abrechnungResult.rows[0].jahr]); + + res.json({ + abrechnung: abrechnungResult.rows[0], + positionen: positionenResult.rows, + kosten: kostenResult.rows + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Status aktualisieren (Entwurf/Final) + app.put('/api/nebenkostenabrechnung/:id/status', async (req, res) => { + try { + const { ist_entwurf } = req.body; + const result = await pool.query( + 'UPDATE nebenkostenabrechnungen SET ist_entwurf = $1 WHERE id = $2 RETURNING *', + [ist_entwurf, req.params.id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Abrechnung nicht gefunden' }); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // PDF Export - Privat (René Täger) - EINE PDF pro Mieter + app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => { + try { + const { PDFDocument, rgb, StandardFonts } = require('pdf-lib'); + + const abrechnungResult = await pool.query(` + SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort, o.wohnflaeche_qm as objekt_flaeche + FROM nebenkostenabrechnungen na + JOIN objekte o ON o.id = na.objekt_id + WHERE na.id = $1 + `, [req.params.id]); + + if (abrechnungResult.rows.length === 0) { + return res.status(404).json({ error: 'Abrechnung nicht gefunden' }); + } + + const abrechnung = abrechnungResult.rows[0]; + + // ALLE Positionen laden + const positionenResult = await pool.query(` + SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse + FROM abrechnungspositionen ap + JOIN mieter m ON m.id = ap.mieter_id + WHERE ap.abrechnung_id = $1 + `, [req.params.id]); + + const kostenResult = await pool.query(` + SELECT * FROM objektkosten + WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie + `, [abrechnung.objekt_id, abrechnung.jahr]); + + // Berechnungsgrundlagen + const gesamtKosten = kostenResult.rows.reduce((sum, k) => sum + parseFloat(k.betrag), 0); + const gesamtFlaeche = parseFloat(abrechnung.objekt_flaeche || 95); + const kostenProQm = gesamtKosten / gesamtFlaeche; + + // PDF erstellen + const pdfDoc = await PDFDocument.create(); + const width = 595.28; + const height = 841.89; + const margin = 50; + + const font = await pdfDoc.embedFont(StandardFonts.Helvetica); + const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + + const istEntwurf = req.query.entwurf === 'true'; + const heute = new Date().toLocaleDateString('de-DE'); + + // Hilfsfunktion für Trennlinie + function drawLine(page, yPos) { + page.drawLine({ + start: { x: margin, y: yPos }, + end: { x: width - margin, y: yPos }, + thickness: 0.5, + color: rgb(0.8, 0.8, 0.8) + }); + } + + // ========== FÜR JEDEN MIETER EINE SEITE ========== + for (const pos of positionenResult.rows) { + const mieterName = pos.mieter_name; + const mieterAdresse = pos.mieter_adresse || ''; + const mieterFlaeche = parseFloat(pos.anteil_qm); + const anteilKosten = parseFloat(pos.anteil_kosten); + const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen); + const ergebnis = parseFloat(pos.ergebnis); + + // Neue Seite für diesen Mieter + const page = pdfDoc.addPage([width, height]); + let y = height - 50; + + // ========== ABSENDER (oben links) ========== + page.drawText('René Täger', { x: margin, y, size: 10, font: fontBold }); + y -= 14; + page.drawText('Zingelstraße 7', { x: margin, y, size: 9, font }); + y -= 14; + page.drawText('25554 Wilster', { x: margin, y, size: 9, font }); + y -= 30; + + // Trennlinie + drawLine(page, y); + y -= 30; + + // ========== EMPFÄNGER (nur dieser Mieter) ========== + page.drawText(mieterName, { x: margin, y, size: 11, font: fontBold }); + y -= 16; + if (mieterAdresse) { + const adressZeilen = mieterAdresse.split('\n'); + for (const zeile of adressZeilen) { + page.drawText(zeile, { x: margin, y, size: 10, font }); + y -= 14; + } + } + y -= 40; + + // ========== DATUM + BETREFF ========== + page.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font }); + + page.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 25; + + if (istEntwurf) { + page.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) }); + } + + page.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font }); + y -= 20; + page.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold }); + y -= 16; + page.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font }); + y -= 16; + page.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font }); + y -= 35; + + // ========== BEGLEITTEXT ========== + page.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold }); + y -= 18; + page.drawText('im Folgenden erhalten Sie Ihre persönliche Nebenkostenabrechnung.', { x: margin, y, size: 9, font }); + y -= 14; + page.drawText('Die Kosten wurden nach Ihrem im Mietvertrag vereinbarten Anteil umgelegt.', { x: margin, y, size: 9, font }); + y -= 30; + + // ========== 1. KOSTENÜBERSICHT (alle Kosten des Objekts) ========== + page.drawText('1. Kostenübersicht des Objekts:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + // Tabellenkopf + page.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold }); + page.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold }); + page.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold }); + y -= 12; + drawLine(page, y + 8); + y -= 5; + + for (const k of kostenResult.rows) { + const betrag = parseFloat(k.betrag); + page.drawText(k.kategorie, { x: margin, y, size: 9, font }); + page.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font }); + page.drawText(`${betrag.toFixed(2)} €`, { x: margin + 350, y, size: 9, font }); + y -= 14; + } + + y -= 5; + drawLine(page, y + 8); + y -= 5; + page.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold }); + page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 350, y, size: 10, font: fontBold }); + y -= 30; + + // ========== 2. BERECHNUNGSGRUNDLAGE ========== + page.drawText('2. Berechnungsgrundlage:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + page.drawText(`Gesamtkosten:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Gesamtwohnfläche:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${gesamtFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Kosten pro qm:`, { x: margin + 20, y, size: 9, font: fontBold }); + page.drawText(`${gesamtKosten.toFixed(2)} € / ${gesamtFlaeche.toFixed(2)} qm = ${kostenProQm.toFixed(2)} €/qm`, { x: margin + 250, y, size: 9, font: fontBold }); + y -= 30; + + // ========== 3. IHRE PERSÖNLICHE BERECHNUNG ========== + page.drawText('3. Ihre persönliche Berechnung:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) }); + y -= 20; + + page.drawText(`Ihr Name:`, { x: margin + 20, y, size: 9, font }); + page.drawText(mieterName, { x: margin + 250, y, size: 9, font: fontBold }); + y -= 14; + page.drawText(`Ihre Wohnfläche:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${mieterFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Ihr Anteil an den Kosten:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${kostenProQm.toFixed(2)} €/qm × ${mieterFlaeche.toFixed(2)} qm = ${anteilKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 14; + page.drawText(`Geleistete Vorauszahlungen:`, { x: margin + 20, y, size: 9, font }); + page.drawText(`${vorauszahlungen.toFixed(2)} €`, { x: margin + 250, y, size: 9, font }); + y -= 25; + + // Ergebnis hervorgehoben + drawLine(page, y + 8); + y -= 5; + + if (ergebnis > 0) { + page.drawText(`Nachzahlung:`, { x: margin + 20, y, size: 11, font: fontBold }); + page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold }); + y -= 20; + page.drawText(`Bitte überweisen Sie den Betrag innerhalb von 14 Tagen.`, { x: margin + 20, y, size: 9, font, color: rgb(0.8, 0.2, 0.2) }); + } else { + page.drawText(`Gutschrift:`, { x: margin + 20, y, size: 11, font: fontBold }); + page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold }); + y -= 20; + page.drawText(`Der Betrag wird mit der nächsten Miete verrechnet.`, { x: margin + 20, y, size: 9, font, color: rgb(0.2, 0.6, 0.2) }); + } + y -= 25; + + // ========== FUSSZEILE ========== + y -= 20; + drawLine(page, y); + y -= 20; + page.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) }); + y -= 12; + page.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) }); + } + + // PDF speichern + const pdfBytes = await pdfDoc.save(); + const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes); + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Length', pdfBuffer.length); + res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`); + res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition'); + res.end(pdfBuffer); + } catch (error) { + console.error('PDF Error:', error); + res.status(500).json({ error: error.message }); + } + }); + + +}; + diff --git a/backend/routes/privat.js b/backend/routes/privat.js new file mode 100644 index 0000000..bb12b26 --- /dev/null +++ b/backend/routes/privat.js @@ -0,0 +1,151 @@ +// API Routes für Private Finanzen (Monatliche Ausgaben, etc.) +const { Pool } = require('pg'); + +const pool = new Pool({ + host: process.env.DB_HOST || 'buchhaltung-db', + port: process.env.DB_PORT || 5432, + database: process.env.DB_NAME || 'buchhaltung', + user: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || 'postgres', +}); + +module.exports = (app) => { + + // ========== PRIVAT AUSGABEN ========== + + // Tabelle erstellen falls nicht existiert + app.post('/api/privat/init', async (req, res) => { + try { + await pool.query(` + CREATE TABLE IF NOT EXISTS privat_ausgaben ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + kategorie VARCHAR(100) NOT NULL, + bezeichnung VARCHAR(255), + betrag DECIMAL(10,2) NOT NULL, + jahr INTEGER NOT NULL, + monat INTEGER NOT NULL, -- 0-11 für Jan-Dez + typ VARCHAR(20) DEFAULT 'einmalig', -- 'einmalig' oder 'wiederkehrend' + wiederkehrend_bis INTEGER, -- Monat bis zu dem es wiederkehrt (optional) + wiederkehrend_bis_jahr INTEGER, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ) + `); + res.json({ success: true, message: 'Tabelle privat_ausgaben erstellt' }); + } catch (error) { + console.error('Init Error:', error); + res.status(500).json({ error: error.message }); + } + }); + + // Alle Ausgaben für Jahr/Monat laden + app.get('/api/privat/ausgaben', async (req, res) => { + try { + const { jahr, monat } = req.query; + + let query = 'SELECT * FROM privat_ausgaben WHERE 1=1'; + const params = []; + let paramCount = 0; + + if (jahr) { + paramCount++; + query += ` AND (jahr = $${paramCount} OR (typ = 'wiederkehrend' AND jahr <= $${paramCount}))`; + params.push(parseInt(jahr)); + } + + if (monat !== undefined) { + paramCount++; + query += ` AND (monat = $${paramCount} OR typ = 'wiederkehrend')`; + params.push(parseInt(monat)); + } + + query += ' ORDER BY created_at DESC'; + + const result = await pool.query(query, params); + res.json(result.rows); + } catch (error) { + // Tabelle existiert nicht - leeres Array zurückgeben + if (error.message.includes('relation "privat_ausgaben" does not exist')) { + return res.json([]); + } + console.error('Load Error:', error); + res.status(500).json({ error: error.message }); + } + }); + + // Ausgabe erstellen + app.post('/api/privat/ausgaben', async (req, res) => { + try { + const { kategorie, bezeichnung, betrag, jahr, monat, typ, wiederkehrend_bis, wiederkehrend_bis_jahr } = req.body; + + // Tabelle erstellen falls nicht existiert + await pool.query(` + CREATE TABLE IF NOT EXISTS privat_ausgaben ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + kategorie VARCHAR(100) NOT NULL, + bezeichnung VARCHAR(255), + betrag DECIMAL(10,2) NOT NULL, + jahr INTEGER NOT NULL, + monat INTEGER NOT NULL, + typ VARCHAR(20) DEFAULT 'einmalig', + wiederkehrend_bis INTEGER, + wiederkehrend_bis_jahr INTEGER, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ) + `); + + const result = await pool.query( + `INSERT INTO privat_ausgaben (kategorie, bezeichnung, betrag, jahr, monat, typ, wiederkehrend_bis, wiederkehrend_bis_jahr) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, + [kategorie, bezeichnung, betrag, jahr, monat, typ || 'einmalig', wiederkehrend_bis, wiederkehrend_bis_jahr] + ); + + res.json(result.rows[0]); + } catch (error) { + console.error('Create Error:', error); + res.status(500).json({ error: error.message }); + } + }); + + // Ausgabe aktualisieren + app.put('/api/privat/ausgaben/:id', async (req, res) => { + try { + const { kategorie, bezeichnung, betrag, jahr, monat, typ, wiederkehrend_bis, wiederkehrend_bis_jahr } = req.body; + + const result = await pool.query( + `UPDATE privat_ausgaben + SET kategorie = $1, bezeichnung = $2, betrag = $3, jahr = $4, monat = $5, + typ = $6, wiederkehrend_bis = $7, wiederkehrend_bis_jahr = $8, updated_at = NOW() + WHERE id = $9 RETURNING *`, + [kategorie, bezeichnung, betrag, jahr, monat, typ, wiederkehrend_bis, wiederkehrend_bis_jahr, req.params.id] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Ausgabe nicht gefunden' }); + } + + res.json(result.rows[0]); + } catch (error) { + console.error('Update Error:', error); + res.status(500).json({ error: error.message }); + } + }); + + // Ausgabe löschen + app.delete('/api/privat/ausgaben/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM privat_ausgaben WHERE id = $1 RETURNING *', [req.params.id]); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Ausgabe nicht gefunden' }); + } + + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + console.error('Delete Error:', error); + res.status(500).json({ error: error.message }); + } + }); + +}; diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..cf53d25 --- /dev/null +++ b/backend/server.js @@ -0,0 +1,1583 @@ +const express = require('express'); +const cors = require('cors'); +const multer = require('multer'); +const sharp = require('sharp'); +const Tesseract = require('tesseract.js'); +const { Pool } = require('pg'); +const { v4: uuidv4 } = require('uuid'); +const fs = require('fs'); +const path = require('path'); +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); + +// Auth imports +const authRoutes = require('./routes/auth'); +const { authRequired, adminRequired } = require('./middleware/auth'); + +const app = express(); +const PORT = process.env.PORT || 3001; + +// Database connection - Docker-compatible defaults +const pool = new Pool({ + host: process.env.DB_HOST || 'buchhaltung-db', + port: process.env.DB_PORT || 5432, + database: process.env.DB_NAME || 'buchhaltung', + user: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || 'postgres', +}); + +// Middleware +app.use(cors()); +app.use(express.json()); +app.use('/uploads', express.static('uploads')); + +// Auth Routes +app.use('/api/auth', authRoutes); + +// Nebenkosten Routes laden +const nebenkostenRoutes = require('./routes/nebenkosten'); +nebenkostenRoutes(app); + +// Ensure uploads directory exists +const uploadsDir = path.join(__dirname, 'uploads'); +if (!fs.existsSync(uploadsDir)) { + fs.mkdirSync(uploadsDir, { recursive: true }); +} + +// Multer configuration +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + cb(null, 'uploads/'); + }, + filename: (req, file, cb) => { + const uniqueName = `${Date.now()}-${uuidv4()}${path.extname(file.originalname)}`; + cb(null, uniqueName); + } +}); + +const upload = multer({ + storage, + limits: { fileSize: 10 * 1024 * 1024 }, // 10MB limit + fileFilter: (req, file, cb) => { + const allowedTypes = /jpeg|jpg|png|pdf|webp/; + const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase()); + const mimetype = allowedTypes.test(file.mimetype); + if (extname && mimetype) { + return cb(null, true); + } + cb(new Error('Nur Bilder und PDFs erlaubt')); + } +}); + +// Initialize database tables +async function initDatabase() { + const client = await pool.connect(); + try { + await client.query(` + CREATE TABLE IF NOT EXISTS belege ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + date DATE NOT NULL, + haendler VARCHAR(255), + betrag DECIMAL(10,2) NOT NULL, + mwst_satz DECIMAL(4,2) DEFAULT 19.00, + kategorie VARCHAR(100), + status VARCHAR(50) DEFAULT 'neu', + file_path VARCHAR(500), + ocr_text TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ) + `); + + await client.query(` + CREATE TABLE IF NOT EXISTS rechnungen ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + rechnung_nr VARCHAR(50) UNIQUE NOT NULL, + kunde VARCHAR(255) NOT NULL, + kunde_email VARCHAR(255), + kunde_adresse TEXT, + leistung TEXT NOT NULL, + betrag DECIMAL(10,2) NOT NULL, + ust_satz DECIMAL(4,2) DEFAULT 19.00, + datum DATE NOT NULL, + faelligkeit DATE, + status VARCHAR(50) DEFAULT 'offen', + pdf_path VARCHAR(500), + created_at TIMESTAMP DEFAULT NOW() + ) + `); + + await client.query(` + CREATE TABLE IF NOT EXISTS nebenkosten ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + jahr INTEGER NOT NULL, + wohnung VARCHAR(100), + mieter VARCHAR(255), + kaltmiete DECIMAL(10,2), + nebenkosten DECIMAL(10,2), + heizkosten DECIMAL(10,2), + wasser DECIMAL(10,2), + muell DECIMAL(10,2), + versicherung DECIMAL(10,2), + sonstiges DECIMAL(10,2), + created_at TIMESTAMP DEFAULT NOW() + ) + `); + + // Kredite Tabelle + await client.query(` + CREATE TABLE IF NOT EXISTS kredite ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + kreditgeber VARCHAR(255), + person VARCHAR(100), -- 'Kerstin', 'Niki', etc. + richtung VARCHAR(50) DEFAULT 'ausgehend', -- 'eingehend' (Forderung) oder 'ausgehend' (Schulden) + ursprungsschuld DECIMAL(12,2) NOT NULL, + restschuld DECIMAL(12,2) NOT NULL, + monatsrate DECIMAL(10,2) NOT NULL, + zinssatz DECIMAL(5,2) DEFAULT 4.00, + start_datum DATE NOT NULL, + end_datum DATE, + laufzeit_monate INTEGER, + faelligkeit_tag INTEGER DEFAULT 1, + status VARCHAR(50) DEFAULT 'aktiv', + notizen TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ) + `); + + // Kredit-Buchungen (Tilgungsverlauf) + await client.query(` + CREATE TABLE IF NOT EXISTS kredit_buchungen ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + kredit_id UUID REFERENCES kredite(id) ON DELETE CASCADE, + datum DATE NOT NULL, + rate_betrag DECIMAL(10,2) NOT NULL, + zinsen DECIMAL(10,2) DEFAULT 0, + tilgung DECIMAL(10,2) NOT NULL, + restschuld_nach DECIMAL(12,2) NOT NULL, + created_at TIMESTAMP DEFAULT NOW() + ) + `); + + // Fixe Ausgaben (für Cashflow) + await client.query(` + CREATE TABLE IF NOT EXISTS fixe_ausgaben ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + kategorie VARCHAR(100), + betrag DECIMAL(10,2) NOT NULL, + intervall VARCHAR(50) DEFAULT 'monatlich', + faelligkeit_tag INTEGER, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW() + ) + `); + + // Stunden Tabelle + await client.query(` + CREATE TABLE IF NOT EXISTS stunden ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + datum DATE NOT NULL, + kunde VARCHAR(255) NOT NULL, + beschreibung TEXT, + stunden DECIMAL(10,2) NOT NULL, + stundensatz DECIMAL(10,2) NOT NULL, + betrag DECIMAL(10,2) NOT NULL, + status VARCHAR(50) DEFAULT 'offen', + created_at TIMESTAMP DEFAULT NOW() + ) + `); + + // Kostenplanung Tabelle + await client.query(` + CREATE TABLE IF NOT EXISTS kostenplanung ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + objekt VARCHAR(100) NOT NULL, + name VARCHAR(255) NOT NULL, + kategorie VARCHAR(100) NOT NULL, + monate DECIMAL(10,2)[] DEFAULT ARRAY[0,0,0,0,0,0,0,0,0,0,0,0], + jahr INTEGER DEFAULT EXTRACT(YEAR FROM CURRENT_DATE), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ) + `); + + // Benutzer (für Auth) + await client.query(` + CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(100) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + pin VARCHAR(10), + role VARCHAR(50) DEFAULT 'user', + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW(), + last_login TIMESTAMP + ) + `); + + console.log('✅ Datenbank-Tabellen erstellt'); + + // Migration: Füge richtung-Spalte zu bestehenden kredite-Tabellen hinzu + await client.query(` + ALTER TABLE kredite + ADD COLUMN IF NOT EXISTS richtung VARCHAR(50) DEFAULT 'ausgehend' + `); + console.log('✅ Migration: richtung-Spalte geprüft/hinzugefügt'); + } finally { + client.release(); + } +} + +// OCR Endpoint +app.post('/api/ocr', upload.single('image'), async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ error: 'Kein Bild hochgeladen' }); + } + + const filePath = req.file.path; + + // Preprocess image with sharp + const processedPath = filePath + '-processed.png'; + await sharp(filePath) + .resize(2000, null, { withoutEnlargement: true }) + .greyscale() + .normalize() + .toFile(processedPath); + + // Run OCR with German language + const result = await Tesseract.recognize( + processedPath, + 'deu', + { logger: m => console.log(m) } + ); + + const text = result.data.text; + + // Extract data with regex patterns + const extractedData = extractReceiptData(text); + + // Save to database + const query = ` + INSERT INTO belege (date, haendler, betrag, mwst_satz, kategorie, status, file_path, ocr_text) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING * + `; + const values = [ + extractedData.date || new Date(), + extractedData.haendler || 'Unbekannt', + extractedData.betrag || 0, + extractedData.mwst || 19.00, + 'unbekannt', + 'neu', + req.file.filename, + text + ]; + + const dbResult = await pool.query(query, values); + + // Cleanup processed file + fs.unlinkSync(processedPath); + + res.json({ + success: true, + beleg: dbResult.rows[0], + extracted: extractedData, + rawText: text.substring(0, 500) // First 500 chars + }); + + } catch (error) { + console.error('OCR Error:', error); + res.status(500).json({ error: error.message }); + } +}); + +// Extract receipt data using regex patterns +function extractReceiptData(text) { + const data = { + haendler: null, + betrag: null, + date: null, + mwst: 19.00 + }; + + // Try to find total amount + const totalPatterns = [ + /(?:gesamt|summe|total|betrag|zu zahlen)[\s:]*([\d.,]+)/i, + /(?:eur|€)[\s:]*([\d.,]+)/i, + /([\d.,]+)\s*(?:eur|€)/i + ]; + + for (const pattern of totalPatterns) { + const match = text.match(pattern); + if (match) { + let amount = match[1].replace(/\./g, '').replace(',', '.'); + data.betrag = parseFloat(amount); + break; + } + } + + // Try to find date + const datePatterns = [ + /(\d{2})[\/.-](\d{2})[\/.-](\d{4})/, // DD.MM.YYYY + /(\d{2})[\/.-](\d{2})[\/.-](\d{2})/, // DD.MM.YY + ]; + + for (const pattern of datePatterns) { + const match = text.match(pattern); + if (match) { + const day = match[1]; + const month = match[2]; + const year = match[3].length === 2 ? '20' + match[3] : match[3]; + data.date = `${year}-${month}-${day}`; + break; + } + } + + // Try to find merchant name (first line often contains it) + const lines = text.split('\n').filter(l => l.trim()); + if (lines.length > 0) { + // Skip common header words + const skipWords = ['bon', 'beleg', 'quittung', 'kasse', 'rechnung']; + for (const line of lines.slice(0, 5)) { + const cleanLine = line.trim(); + if (cleanLine.length > 2 && !skipWords.some(w => cleanLine.toLowerCase().includes(w))) { + data.haendler = cleanLine; + break; + } + } + } + + return data; +} + +// Belege API +app.get('/api/belege', async (req, res) => { + try { + const { status, limit = 50 } = req.query; + let query = 'SELECT * FROM belege ORDER BY created_at DESC LIMIT $1'; + const values = [limit]; + + if (status) { + query = 'SELECT * FROM belege WHERE status = $2 ORDER BY created_at DESC LIMIT $1'; + values.push(status); + } + + const result = await pool.query(query, values); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.get('/api/belege/:id', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM belege WHERE id = $1', [req.params.id]); + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Beleg nicht gefunden' }); + } + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.put('/api/belege/:id', async (req, res) => { + try { + const { haendler, betrag, kategorie, status } = req.body; + const query = ` + UPDATE belege + SET haendler = $1, betrag = $2, kategorie = $3, status = $4, updated_at = NOW() + WHERE id = $5 + RETURNING * + `; + const result = await pool.query(query, [haendler, betrag, kategorie, status, req.params.id]); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.delete('/api/belege/:id', async (req, res) => { + try { + // Get file path first + const beleg = await pool.query('SELECT file_path FROM belege WHERE id = $1', [req.params.id]); + if (beleg.rows.length > 0 && beleg.rows[0].file_path) { + const filePath = path.join(uploadsDir, beleg.rows[0].file_path); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + } + + await pool.query('DELETE FROM belege WHERE id = $1', [req.params.id]); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Rechnungen API +app.get('/api/rechnungen', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM rechnungen ORDER BY datum DESC'); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.post('/api/rechnungen', async (req, res) => { + try { + const { kunde, kunde_email, kunde_adresse, leistung, betrag, ust_satz, datum, faelligkeit } = req.body; + const rechnungNr = `RE-${new Date().getFullYear()}-${String(await getNextRechnungNr()).padStart(3, '0')}`; + + const query = ` + INSERT INTO rechnungen (rechnung_nr, kunde, kunde_email, kunde_adresse, leistung, betrag, ust_satz, datum, faelligkeit) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING * + `; + const result = await pool.query(query, [rechnungNr, kunde, kunde_email, kunde_adresse, leistung, betrag, ust_satz, datum, faelligkeit]); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +async function getNextRechnungNr() { + const result = await pool.query("SELECT COUNT(*) FROM rechnungen WHERE rechnung_nr LIKE 'RE-' || EXTRACT(YEAR FROM CURRENT_DATE) || '-%'"); + return parseInt(result.rows[0].count) + 1; +} + +// EÜR API +app.get('/api/euer', async (req, res) => { + try { + const { year = new Date().getFullYear() } = req.query; + + // Einnahmen aus Rechnungen + const einnahmen = await pool.query(` + SELECT COALESCE(SUM(betrag), 0) as total FROM rechnungen + WHERE status = 'bezahlt' AND EXTRACT(YEAR FROM datum) = $1 + `, [year]); + + // Ausgaben aus Belegen + const ausgaben = await pool.query(` + SELECT COALESCE(SUM(betrag), 0) as total FROM belege + WHERE status = 'fertig' AND EXTRACT(YEAR FROM date) = $1 + `, [year]); + + const buchungen = await pool.query(` + SELECT * FROM euer_buchungen + WHERE EXTRACT(YEAR FROM date) = $1 + ORDER BY date DESC + `, [year]); + + res.json({ + jahr: year, + einnahmen: einnahmen.rows[0].total, + ausgaben: ausgaben.rows[0].total, + ergebnis: einnahmen.rows[0].total - ausgaben.rows[0].total, + buchungen: buchungen.rows + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Kunden API +app.get('/api/kunden', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM kunden ORDER BY name'); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.get('/api/kunden/:id', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM kunden WHERE id = $1', [req.params.id]); + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Kunde nicht gefunden' }); + } + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.post('/api/kunden', async (req, res) => { + try { + const { name, adresse, plz, ort, email, telefon, notizen } = req.body; + const query = ` + INSERT INTO kunden (name, adresse, plz, ort, email, telefon, notizen) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + `; + const result = await pool.query(query, [name, adresse, plz, ort, email, telefon, notizen]); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.put('/api/kunden/:id', async (req, res) => { + try { + const { name, adresse, plz, ort, email, telefon, notizen } = req.body; + const query = ` + UPDATE kunden + SET name = COALESCE($1, name), adresse = COALESCE($2, adresse), plz = COALESCE($3, plz), + ort = COALESCE($4, ort), email = COALESCE($5, email), telefon = COALESCE($6, telefon), notizen = COALESCE($7, notizen) + WHERE id = $8 + RETURNING * + `; + const result = await pool.query(query, [name, adresse, plz, ort, email, telefon, notizen, req.params.id]); + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Kunde nicht gefunden' }); + } + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.delete('/api/kunden/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM kunden WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Kunde nicht gefunden' }); + } + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Auftragsnachweise API +app.get('/api/auftragsnachweise', async (req, res) => { + try { + const result = await pool.query(` + SELECT an.*, k.name as kunden_name + FROM auftragsnachweise an + JOIN kunden k ON an.kunde_id = k.id + ORDER BY an.datum DESC + `); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.get('/api/auftragsnachweise/:id', async (req, res) => { + try { + const result = await pool.query(` + SELECT an.*, k.name as kunden_name + FROM auftragsnachweise an + JOIN kunden k ON an.kunde_id = k.id + WHERE an.id = $1 + `, [req.params.id]); + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Auftragsnachweis nicht gefunden' }); + } + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.post('/api/auftragsnachweise', async (req, res) => { + try { + const { kunde_id, datum, beschreibung, stunden_ids, art_der_arbeit, anfahrt_km, pauschale } = req.body; + const query = ` + INSERT INTO auftragsnachweise (kunde_id, datum, beschreibung, stunden_ids, art_der_arbeit, anfahrt_km, pauschale) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + `; + const result = await pool.query(query, [ + kunde_id, datum, beschreibung, + JSON.stringify(stunden_ids || []), + JSON.stringify(art_der_arbeit || []), + anfahrt_km || 0, + pauschale || 0 + ]); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.delete('/api/auftragsnachweise/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM auftragsnachweise WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Auftragsnachweis nicht gefunden' }); + } + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Nebenkosten API +app.get('/api/nebenkosten', async (req, res) => { + try { + const { jahr } = req.query; + let query = 'SELECT * FROM nebenkosten'; + const values = []; + + if (jahr) { + query += ' WHERE jahr = $1'; + values.push(jahr); + } + query += ' ORDER BY jahr DESC'; + + const result = await pool.query(query, values); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.post('/api/nebenkosten', async (req, res) => { + try { + const { jahr, wohnung, mieter, kaltmiete, nebenkosten, heizkosten, wasser, muell, versicherung, sonstiges } = req.body; + + const query = ` + INSERT INTO nebenkosten (jahr, wohnung, mieter, kaltmiete, nebenkosten, heizkosten, wasser, muell, versicherung, sonstiges) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING * + `; + const result = await pool.query(query, [jahr, wohnung, mieter, kaltmiete, nebenkosten, heizkosten, wasser, muell, versicherung, sonstiges]); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Nebenkosten PUT (Update) +app.put('/api/nebenkosten/:id', async (req, res) => { + try { + const { jahr, wohnung, mieter, kaltmiete, nebenkosten, heizkosten, wasser, muell, versicherung, sonstiges } = req.body; + const query = ` + UPDATE nebenkosten + SET jahr = $1, wohnung = $2, mieter = $3, kaltmiete = $4, nebenkosten = $5, heizkosten = $6, wasser = $7, muell = $8, versicherung = $9, sonstiges = $10 + WHERE id = $11 + RETURNING * + `; + const result = await pool.query(query, [jahr, wohnung, mieter, kaltmiete, nebenkosten, heizkosten, wasser, muell, versicherung, sonstiges, req.params.id]); + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Nebenkosten nicht gefunden' }); + } + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Nebenkosten DELETE +app.delete('/api/nebenkosten/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM nebenkosten WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Nebenkosten nicht gefunden' }); + } + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Kredite API +app.get('/api/kredite', async (req, res) => { + try { + const { person, status } = req.query; + let query = 'SELECT * FROM kredite'; + const values = []; + const conditions = []; + + if (person) { + conditions.push('person = $' + (values.length + 1)); + values.push(person); + } + if (status) { + conditions.push('status = $' + (values.length + 1)); + values.push(status); + } + + if (conditions.length > 0) { + query += ' WHERE ' + conditions.join(' AND '); + } + + query += ' ORDER BY created_at DESC'; + + const result = await pool.query(query, values); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.post('/api/kredite', async (req, res) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const { name, kreditgeber, person, ursprungsschuld, monatsrate, zinssatz, start_datum, laufzeit_monate, faelligkeit_tag, notizen, richtung } = req.body; + + // Ensure richtung column exists + await client.query(` + ALTER TABLE kredite ADD COLUMN IF NOT EXISTS richtung VARCHAR(50) DEFAULT 'ausgehend' + `); + + // 1. Kredit erstellen + const kreditQuery = ` + INSERT INTO kredite (name, kreditgeber, person, ursprungsschuld, restschuld, monatsrate, zinssatz, start_datum, laufzeit_monate, faelligkeit_tag, notizen, richtung) + VALUES ($1, $2, $3, $4, $4, $5, $6, $7, $8, $9, $10, COALESCE($11, 'ausgehend')) + RETURNING * + `; + const kreditResult = await client.query(kreditQuery, [name, kreditgeber, person, ursprungsschuld, monatsrate, zinssatz, start_datum, laufzeit_monate, faelligkeit_tag, notizen, richtung]); + const kredit = kreditResult.rows[0]; + + // 2. Startbuchung erstellen + await client.query( + 'INSERT INTO kredit_buchungen (kredit_id, datum, rate_betrag, zinsen, tilgung, restschuld_nach) VALUES ($1, $2, $3, $4, $5, $6)', + [kredit.id, start_datum || new Date().toISOString().split('T')[0], 0, 0, 0, ursprungsschuld] + ); + + // 3. Monatsbuchungen erstellen (Zinsen immer, Tilgung optional) + // Auch ohne Rate werden Zinsbuchungen erstellt wenn Zinssatz > 0 + const hatZinsen = zinssatz && parseFloat(zinssatz) > 0; + const hatRate = monatsrate && parseFloat(monatsrate) > 0; + + if (hatZinsen || hatRate) { + const startDate = new Date(start_datum || new Date()); + const laufzeit = laufzeit_monate || (hatZinsen && !hatRate ? 12 : 60); // Bei reinen Zinskrediten: 1 Jahr Default + let restschuld = parseFloat(ursprungsschuld); + const rate = parseFloat(monatsrate || 0); + const zins = parseFloat(zinssatz || 0) / 100 / 12; + + for (let i = 0; i < laufzeit; i++) { + const buchungDatum = new Date(startDate); + buchungDatum.setMonth(buchungDatum.getMonth() + i + 1); // +1 weil Startbuchung bereits erstellt + + const zinsen = restschuld * zins; + let tilgung = 0; + + if (hatRate) { + // Normale Annuitätentilgung + tilgung = rate - zinsen; + restschuld -= tilgung; + } else if (hatZinsen) { + // Rate = 0 aber Zinsen > 0: Zinsen werden zum Kapital addiert (Zinseszins) + restschuld += zinsen; + } + // Bei Rate = 0 und Zinsen = 0: Restschuld bleibt gleich + + if (restschuld < 0) restschuld = 0; + + await client.query( + 'INSERT INTO kredit_buchungen (kredit_id, datum, rate_betrag, zinsen, tilgung, restschuld_nach) VALUES ($1, $2, $3, $4, $5, $6)', + [kredit.id, buchungDatum.toISOString().split('T')[0], rate, zinsen, tilgung, restschuld] + ); + + if (restschuld <= 0) break; // Kredit abbezahlt + } + + // Restschuld aktualisieren + await client.query('UPDATE kredite SET restschuld = $1 WHERE id = $2', [restschuld, kredit.id]); + } + + await client.query('COMMIT'); + res.json(kredit); + } catch (error) { + await client.query('ROLLBACK'); + res.status(500).json({ error: error.message }); + } finally { + client.release(); + } +}); + +app.get('/api/kredite/:id/buchungen', async (req, res) => { + try { + const result = await pool.query( + 'SELECT * FROM kredit_buchungen WHERE kredit_id = $1 ORDER BY datum DESC', + [req.params.id] + ); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Kredit PUT (Update) - Verwendet COALESCE für partielle Updates +app.put('/api/kredite/:id', async (req, res) => { + try { + const { name, kreditgeber, person, richtung, ursprungsschuld, restschuld, monatsrate, zinssatz, start_datum, end_datum, laufzeit_monate, faelligkeit_tag, status, notizen } = req.body; + const query = ` + UPDATE kredite + SET + name = COALESCE($1, name), + kreditgeber = COALESCE($2, kreditgeber), + person = COALESCE($3, person), + richtung = COALESCE($4, richtung), + ursprungsschuld = COALESCE($5, ursprungsschuld), + restschuld = COALESCE($6, restschuld), + monatsrate = COALESCE($7, monatsrate), + zinssatz = COALESCE($8, zinssatz), + start_datum = COALESCE($9, start_datum), + end_datum = COALESCE($10, end_datum), + laufzeit_monate = COALESCE($11, laufzeit_monate), + faelligkeit_tag = COALESCE($12, faelligkeit_tag), + status = COALESCE($13, status), + notizen = COALESCE($14, notizen), + updated_at = NOW() + WHERE id = $15 + RETURNING * + `; + const result = await pool.query(query, [name, kreditgeber, person, richtung, ursprungsschuld, restschuld, monatsrate, zinssatz, start_datum, end_datum, laufzeit_monate, faelligkeit_tag, status, notizen, req.params.id]); + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Kredit nicht gefunden' }); + } + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Kredit PATCH (partielles Update) - für z.B. Restschuld-Updates +app.patch('/api/kredite/:id', async (req, res) => { + try { + const { restschuld, status, notizen } = req.body; + const updates = []; + const values = []; + + if (restschuld !== undefined) { + updates.push(`restschuld = $${values.length + 1}`); + values.push(restschuld); + } + if (status !== undefined) { + updates.push(`status = $${values.length + 1}`); + values.push(status); + } + if (notizen !== undefined) { + updates.push(`notizen = $${values.length + 1}`); + values.push(notizen); + } + + if (updates.length === 0) { + return res.status(400).json({ error: 'Keine Felder zum Aktualisieren angegeben' }); + } + + updates.push(`updated_at = NOW()`); + values.push(req.params.id); + + const query = ` + UPDATE kredite + SET ${updates.join(', ')} + WHERE id = $${values.length} + RETURNING * + `; + + const result = await pool.query(query, values); + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Kredit nicht gefunden' }); + } + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Kredit DELETE +app.delete('/api/kredite/:id', async (req, res) => { + try { + // Lösche zuerst alle Buchungen + await pool.query('DELETE FROM kredit_buchungen WHERE kredit_id = $1', [req.params.id]); + // Dann den Kredit + const result = await pool.query('DELETE FROM kredite WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Kredit nicht gefunden' }); + } + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Kredit Buchung hinzufügen +app.post('/api/kredite/:id/buchungen', async (req, res) => { + try { + const { datum, rate_betrag, zinsen, tilgung, restschuld_nach } = req.body; + const query = ` + INSERT INTO kredit_buchungen (kredit_id, datum, rate_betrag, zinsen, tilgung, restschuld_nach) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + `; + const result = await pool.query(query, [req.params.id, datum, rate_betrag, zinsen, tilgung, restschuld_nach]); + + // Update Restschuld im Kredit + await pool.query('UPDATE kredite SET restschuld = $1, updated_at = NOW() WHERE id = $2', [restschuld_nach, req.params.id]); + + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Kredit Buchung löschen +app.delete('/api/kredite/:id/buchungen/:buchungId', async (req, res) => { + try { + await pool.query('DELETE FROM kredit_buchungen WHERE id = $1 AND kredit_id = $2', [req.params.buchungId, req.params.id]); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Kredit Buchungen NEU BERECHNEN (für bestehende Kredite ohne oder mit fehlerhaften Buchungen) +app.post('/api/kredite/:id/buchungen/neuberechnen', async (req, res) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + // 1. Kredit-Daten holen + const kreditResult = await client.query( + 'SELECT * FROM kredite WHERE id = $1', + [req.params.id] + ); + + if (kreditResult.rows.length === 0) { + await client.query('ROLLBACK'); + return res.status(404).json({ error: 'Kredit nicht gefunden' }); + } + + const kredit = kreditResult.rows[0]; + const { start_datum, ursprungsschuld, zinssatz, monatsrate, richtung } = kredit; + + // 2. Alte Buchungen löschen + await client.query('DELETE FROM kredit_buchungen WHERE kredit_id = $1', [req.params.id]); + + // 3. Startbuchung erstellen + await client.query( + 'INSERT INTO kredit_buchungen (kredit_id, datum, rate_betrag, zinsen, tilgung, restschuld_nach) VALUES ($1, $2, $3, $4, $5, $6)', + [req.params.id, start_datum, 0, 0, 0, ursprungsschuld] + ); + + // 4. ALLE Zahlungen holen und chronologisch verarbeiten + const zahlungenResult = await client.query( + 'SELECT * FROM kredit_zahlungen WHERE kredit_id = $1 ORDER BY datum', + [req.params.id] + ); + const zahlungen = zahlungenResult.rows; + + const zinsProMonat = parseFloat(zinssatz || 0) / 100 / 12; + let restschuld = parseFloat(ursprungsschuld); + const istForderung = richtung === 'eingehend'; + let letzteBuchungDatum = new Date(start_datum); + + // Für jeden Monat von Start bis heute: Buchung erstellen + const startDate = new Date(start_datum); + const endDate = new Date(); + let aktuellesDatum = new Date(startDate); + aktuellesDatum.setMonth(aktuellesDatum.getMonth() + 1); // Erster Monat nach Start + + while (aktuellesDatum <= endDate) { + const jahr = aktuellesDatum.getFullYear(); + const monat = aktuellesDatum.getMonth() + 1; + const monatStart = new Date(jahr, aktuellesDatum.getMonth(), 1); + const monatEnde = new Date(jahr, aktuellesDatum.getMonth() + 1, 0); + + // Zahlungen in diesem Monat finden + const zahlungenImMonat = zahlungen.filter(z => { + const zDatum = new Date(z.datum); + return zDatum >= monatStart && zDatum <= monatEnde; + }); + + // Zinsen für den Monat berechnen + const zinsen = restschuld * zinsProMonat; + + if (zahlungenImMonat.length > 0) { + // Es gab Zahlungen in diesem Monat + let monatsTilgung = 0; + let monatsRate = 0; + + for (const z of zahlungenImMonat) { + const betrag = parseFloat(z.betrag); + if (z.typ === 'auslage') { + // Auslage erhöht die Schuld + restschuld += betrag; + monatsTilgung -= betrag; // Negative Tilgung = Erhöhung + } else { + // Ratenzahlung + const rateZinsen = restschuld * zinsProMonat; + const rateTilgung = betrag - rateZinsen; + restschuld -= rateTilgung; + monatsTilgung += rateTilgung; + monatsRate += betrag; + } + } + + await client.query( + 'INSERT INTO kredit_buchungen (kredit_id, datum, rate_betrag, zinsen, tilgung, restschuld_nach) VALUES ($1, $2, $3, $4, $5, $6)', + [req.params.id, monatEnde.toISOString().split('T')[0], monatsRate, zinsen.toFixed(2), monatsTilgung.toFixed(2), restschuld] + ); + } else { + // Keine Zahlung in diesem Monat + if (istForderung) { + // Bei Forderung: Zinsen erhöhen die Restschuld + restschuld += zinsen; + } + // Bei Schuld: Zinsen werden nicht zum Kapitalisiert (nur Zinszahlung fällig) + + await client.query( + 'INSERT INTO kredit_buchungen (kredit_id, datum, rate_betrag, zinsen, tilgung, restschuld_nach) VALUES ($1, $2, $3, $4, $5, $6)', + [req.params.id, monatEnde.toISOString().split('T')[0], 0, zinsen.toFixed(2), 0, restschuld] + ); + } + + // Nächster Monat + aktuellesDatum.setMonth(aktuellesDatum.getMonth() + 1); + } + + // 5. Restschuld aktualisieren + await client.query('UPDATE kredite SET restschuld = $1 WHERE id = $2', [restschuld, req.params.id]); + + await client.query('COMMIT'); + res.json({ + success: true, + message: 'Buchungen neu berechnet', + kreditId: req.params.id, + neueRestschuld: restschuld + }); + } catch (error) { + await client.query('ROLLBACK'); + res.status(500).json({ error: error.message }); + } finally { + client.release(); + } +}); + +// Kredit Zahlungen Tabelle erstellen +async function initZahlungenTable() { + const client = await pool.connect(); + try { + await client.query(` + CREATE TABLE IF NOT EXISTS kredit_zahlungen ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + kredit_id UUID REFERENCES kredite(id) ON DELETE CASCADE, + betrag DECIMAL(10,2) NOT NULL, + datum DATE NOT NULL, + typ VARCHAR(50) NOT NULL DEFAULT 'monatsrate', + notiz TEXT, + created_at TIMESTAMP DEFAULT NOW() + ) + `); + console.log('✅ Kredit Zahlungen Tabelle erstellt'); + } finally { + client.release(); + } +} + +// Zahlungen Endpunkte (Alias für Frontend) +app.get('/api/kredite/:id/zahlungen', async (req, res) => { + try { + // Erstelle Tabelle falls nicht vorhanden + await initZahlungenTable(); + const result = await pool.query( + 'SELECT * FROM kredit_zahlungen WHERE kredit_id = $1 ORDER BY datum DESC', + [req.params.id] + ); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.post('/api/kredite/:id/zahlungen', async (req, res) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await initZahlungenTable(); + + const { betrag, datum, typ, notiz } = req.body; + + // 1. Zahlung speichern + const zahlungResult = await client.query( + 'INSERT INTO kredit_zahlungen (kredit_id, betrag, datum, typ, notiz) VALUES ($1, $2, $3, $4, $5) RETURNING *', + [req.params.id, betrag, datum, typ || 'monatsrate', notiz] + ); + + // 2. Kredit-Daten holen (inkl. Zinssatz) + const kreditResult = await client.query('SELECT restschuld, zinssatz FROM kredite WHERE id = $1', [req.params.id]); + let restschuld = parseFloat(kreditResult.rows[0].restschuld); + const zinssatz = parseFloat(kreditResult.rows[0].zinssatz) || 0; + + let zinsen = 0; + let tilgung = 0; + + // 3. Zinsen berechnen für Ratenzahlungen (nicht für Auslagen) + if (typ !== 'auslage') { + // Monatlicher Zinssatz (jährlich / 12 / 100) + const monatlicherZinssatz = zinssatz / 12 / 100; + zinsen = restschuld * monatlicherZinssatz; + tilgung = parseFloat(betrag) - zinsen; + restschuld -= tilgung; + } else { + // Auslage: erhöht die Restschuld + tilgung = parseFloat(betrag); + restschuld += tilgung; + } + + // 4. Kredit aktualisieren + await client.query('UPDATE kredite SET restschuld = $1 WHERE id = $2', [restschuld, req.params.id]); + + // 5. Buchung erstellen + await client.query( + 'INSERT INTO kredit_buchungen (kredit_id, datum, rate_betrag, zinsen, tilgung, restschuld_nach) VALUES ($1, $2, $3, $4, $5, $6)', + [req.params.id, datum, typ === 'auslage' ? 0 : betrag, zinsen.toFixed(2), tilgung.toFixed(2), restschuld] + ); + + await client.query('COMMIT'); + res.json(zahlungResult.rows[0]); + } catch (error) { + await client.query('ROLLBACK'); + res.status(500).json({ error: error.message }); + } finally { + client.release(); + } +}); + +app.delete('/api/kredite/:id/zahlungen/:zahlungId', async (req, res) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + // 1. Zahlung holen + const zahlungResult = await client.query('SELECT * FROM kredit_zahlungen WHERE id = $1 AND kredit_id = $2', [req.params.zahlungId, req.params.id]); + if (zahlungResult.rows.length === 0) { + await client.query('ROLLBACK'); + return res.status(404).json({ error: 'Zahlung nicht gefunden' }); + } + const zahlung = zahlungResult.rows[0]; + + // 2. Zahlung löschen + await client.query('DELETE FROM kredit_zahlungen WHERE id = $1 AND kredit_id = $2', [req.params.zahlungId, req.params.id]); + + // 3. Aktuelle Restschuld holen + const kreditResult = await client.query('SELECT restschuld FROM kredite WHERE id = $1', [req.params.id]); + let restschuld = parseFloat(kreditResult.rows[0].restschuld); + + // 4. Restschuld zurücksetzen + if (zahlung.typ === 'auslage') { + restschuld -= parseFloat(zahlung.betrag); + } else { + restschuld += parseFloat(zahlung.betrag); + } + + // 5. Kredit aktualisieren + await client.query('UPDATE kredite SET restschuld = $1 WHERE id = $2', [restschuld, req.params.id]); + + // 6. Buchungen neu berechnen (einfacher: alles löschen und neu erstellen) + await client.query('DELETE FROM kredit_buchungen WHERE kredit_id = $1', [req.params.id]); + + // Startbuchung + const kreditInfo = await client.query('SELECT ursprungsschuld, start_datum FROM kredite WHERE id = $1', [req.params.id]); + let r = parseFloat(kreditInfo.rows[0].ursprungsschuld); + await client.query( + 'INSERT INTO kredit_buchungen (kredit_id, datum, rate_betrag, zinsen, tilgung, restschuld_nach) VALUES ($1, $2, $3, $4, $5, $6)', + [req.params.id, kreditInfo.rows[0].start_datum, 0, 0, 0, r] + ); + + // Alle verbleibenden Zahlungen als Buchungen erstellen + const remainingZahlungen = await client.query('SELECT * FROM kredit_zahlungen WHERE kredit_id = $1 ORDER BY datum', [req.params.id]); + for (const z of remainingZahlungen.rows) { + if (z.typ === 'auslage') { + r += parseFloat(z.betrag); + await client.query( + 'INSERT INTO kredit_buchungen (kredit_id, datum, rate_betrag, zinsen, tilgung, restschuld_nach) VALUES ($1, $2, $3, $4, $5, $6)', + [req.params.id, z.datum, 0, 0, z.betrag, r] + ); + } else { + r -= parseFloat(z.betrag); + await client.query( + 'INSERT INTO kredit_buchungen (kredit_id, datum, rate_betrag, zinsen, tilgung, restschuld_nach) VALUES ($1, $2, $3, $4, $5, $6)', + [req.params.id, z.datum, z.betrag, 0, z.betrag, r] + ); + } + } + + await client.query('COMMIT'); + res.json({ success: true, restschuld: r }); + } catch (error) { + await client.query('ROLLBACK'); + res.status(500).json({ error: error.message }); + } finally { + client.release(); + } +}); + +// Kredit-Tilgung simulieren/berechnen +app.post('/api/kredite/:id/simulieren', async (req, res) => { + try { + const kredit = await pool.query('SELECT * FROM kredite WHERE id = $1', [req.params.id]); + if (kredit.rows.length === 0) { + return res.status(404).json({ error: 'Kredit nicht gefunden' }); + } + + const k = kredit.rows[0]; + let restschuld = parseFloat(k.restschuld); + const monatsrate = parseFloat(k.monatsrate); + const zinssatz = parseFloat(k.zinssatz) / 100 / 12; // Monatlicher Zinssatz + + const plan = []; + let monat = 0; + + while (restschuld > 0 && monat < 600) { // Max 50 Jahre + monat++; + const zinsen = restschuld * zinssatz; + let tilgung = monatsrate - zinsen; + + if (tilgung > restschuld) { + tilgung = restschuld; + } + + restschuld -= tilgung; + + const datum = new Date(k.start_datum); + datum.setMonth(datum.getMonth() + monat); + + plan.push({ + monat, + datum: datum.toISOString().split('T')[0], + rate: monatsrate, + zinsen: zinsen.toFixed(2), + tilgung: tilgung.toFixed(2), + restschuld: restschuld.toFixed(2) + }); + + if (restschuld <= 0) break; + } + + res.json({ + kredit: k, + tilgungsplan: plan, + gesamt_monate: monat, + end_datum: plan.length > 0 ? plan[plan.length - 1].datum : null + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Fixe Ausgaben API +app.get('/api/fixe-ausgaben', async (req, res) => { + try { + const result = await pool.query('SELECT * FROM fixe_ausgaben WHERE is_active = true ORDER BY kategorie, name'); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.post('/api/fixe-ausgaben', async (req, res) => { + try { + const { name, kategorie, betrag, intervall, faelligkeit_tag } = req.body; + const query = ` + INSERT INTO fixe_ausgaben (name, kategorie, betrag, intervall, faelligkeit_tag) + VALUES ($1, $2, $3, $4, $5) + RETURNING * + `; + const result = await pool.query(query, [name, kategorie, betrag, intervall, faelligkeit_tag]); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Cashflow Übersicht +app.get('/api/cashflow', async (req, res) => { + try { + const { monat, jahr = new Date().getFullYear() } = req.query; + + // Einnahmen aus Rechnungen + const einnahmenResult = await pool.query(` + SELECT COALESCE(SUM(betrag), 0) as total FROM rechnungen + WHERE status = 'bezahlt' AND EXTRACT(MONTH FROM datum) = $1 AND EXTRACT(YEAR FROM datum) = $2 + `, [monat, jahr]); + + // Variable Ausgaben aus Belegen + const ausgabenResult = await pool.query(` + SELECT COALESCE(SUM(betrag), 0) as total FROM belege + WHERE status = 'fertig' AND EXTRACT(MONTH FROM date) = $1 AND EXTRACT(YEAR FROM date) = $2 + `, [monat, jahr]); + + // Fixe Ausgaben (monatlich) + const fixeResult = await pool.query(` + SELECT COALESCE(SUM(betrag), 0) as total FROM fixe_ausgaben + WHERE is_active = true AND intervall = 'monatlich' + `); + + // Kreditraten (monatlich) + const krediteResult = await pool.query(` + SELECT COALESCE(SUM(monatsrate), 0) as total FROM kredite + WHERE status = 'aktiv' + `); + + const einnahmen = parseFloat(einnahmenResult.rows[0].total); + const ausgaben = parseFloat(ausgabenResult.rows[0].total); + const fixe = parseFloat(fixeResult.rows[0].total); + const raten = parseFloat(krediteResult.rows[0].total); + + res.json({ + monat, + jahr, + einnahmen, + variable_ausgaben: ausgaben, + fixe_ausgaben: fixe, + kreditraten: raten, + gesamt_ausgaben: ausgaben + fixe + raten, + verfuegbar: einnahmen - ausgaben - fixe - raten + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Stunden API +app.get('/api/stunden', async (req, res) => { + try { + const { status, jahr } = req.query; + let query = 'SELECT * FROM stunden ORDER BY datum DESC'; + const values = []; + + if (status) { + query = 'SELECT * FROM stunden WHERE status = $1 ORDER BY datum DESC'; + values.push(status); + } else if (jahr) { + query = 'SELECT * FROM stunden WHERE EXTRACT(YEAR FROM datum) = $1 ORDER BY datum DESC'; + values.push(jahr); + } + + const result = await pool.query(query, values); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.post('/api/stunden', async (req, res) => { + try { + const { datum, kunde, beschreibung, stunden, stundensatz } = req.body; + const betrag = stunden * stundensatz; + + const query = ` + INSERT INTO stunden (datum, kunde, beschreibung, stunden, stundensatz, betrag) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + `; + const result = await pool.query(query, [datum, kunde, beschreibung, stunden, stundensatz, betrag]); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.put('/api/stunden/:id', async (req, res) => { + try { + const { datum, kunde, beschreibung, stunden, stundensatz, status } = req.body; + const betrag = stunden * stundensatz; + + const query = ` + UPDATE stunden + SET datum = $1, kunde = $2, beschreibung = $3, stunden = $4, stundensatz = $5, betrag = $6, status = $7 + WHERE id = $8 + RETURNING * + `; + const result = await pool.query(query, [datum, kunde, beschreibung, stunden, stundensatz, betrag, status, req.params.id]); + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Stunden nicht gefunden' }); + } + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.delete('/api/stunden/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM stunden WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Stunden nicht gefunden' }); + } + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Kostenplanung API +app.get('/api/kostenplanung', async (req, res) => { + try { + const { objekt, jahr } = req.query; + let query = 'SELECT * FROM kostenplanung'; + const values = []; + const conditions = []; + + if (objekt) { + conditions.push('objekt = $' + (values.length + 1)); + values.push(objekt); + } + if (jahr) { + conditions.push('jahr = $' + (values.length + 1)); + values.push(jahr); + } + + if (conditions.length > 0) { + query += ' WHERE ' + conditions.join(' AND '); + } + query += ' ORDER BY kategorie, name'; + + const result = await pool.query(query, values); + res.json(result.rows); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.post('/api/kostenplanung', async (req, res) => { + try { + const { objekt, name, kategorie, monate, jahr } = req.body; + + const query = ` + INSERT INTO kostenplanung (objekt, name, kategorie, monate, jahr) + VALUES ($1, $2, $3, $4, $5) + RETURNING * + `; + const result = await pool.query(query, [objekt, name, kategorie, monate, jahr || new Date().getFullYear()]); + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.put('/api/kostenplanung/:id', async (req, res) => { + try { + const { objekt, name, kategorie, monate, jahr, ist_einnahme } = req.body; + + const query = ` + UPDATE kostenplanung + SET objekt = $1, name = $2, kategorie = $3, monate = $4, jahr = $5, ist_einnahme = $6, updated_at = NOW() + WHERE id = $7 + RETURNING * + `; + const result = await pool.query(query, [objekt, name, kategorie, monate, jahr, ist_einnahme, req.params.id]); + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Kostenplanung nicht gefunden' }); + } + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.delete('/api/kostenplanung/:id', async (req, res) => { + try { + const result = await pool.query('DELETE FROM kostenplanung WHERE id = $1 RETURNING *', [req.params.id]); + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Kostenplanung nicht gefunden' }); + } + res.json({ success: true, deleted: result.rows[0] }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Geschäftsplanung monatliche Summen API (für Dashboard) +app.get('/api/geschaeftsplanung/monatlich', async (req, res) => { + try { + const { jahr } = req.query; + const year = jahr || new Date().getFullYear(); + + // Hole alle Kostenplanung-Einträge für das Jahr + const result = await pool.query( + 'SELECT * FROM kostenplanung WHERE jahr = $1', + [year] + ); + + // Berechne Summen pro Kategorie + const summen = { + einnahmen: 0, + betriebskosten: 0, + betriebsergebnis: 0 + }; + + const monatlich = Array(12).fill(0).map(() => ({ + einnahmen: 0, + betriebskosten: 0, + betriebsergebnis: 0 + })); + + result.rows.forEach(row => { + const monate = row.monate || Array(12).fill(0); + const kategorie = row.kategorie?.toLowerCase() || ''; + + if (kategorie === 'einnahmen') { + monate.forEach((betrag, idx) => { + const val = parseFloat(betrag) || 0; + monatlich[idx].einnahmen += val; + summen.einnahmen += val; + }); + } else if (kategorie === 'betriebskosten') { + monate.forEach((betrag, idx) => { + const val = parseFloat(betrag) || 0; + monatlich[idx].betriebskosten += val; + summen.betriebskosten += val; + }); + } + }); + + summen.betriebsergebnis = summen.einnahmen - summen.betriebskosten; + monatlich.forEach(m => { + m.betriebsergebnis = m.einnahmen - m.betriebskosten; + }); + + res.json({ + jahr: year, + summen, + monatlich + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Error handler +app.use((err, req, res, next) => { + console.error(err.stack); + res.status(500).json({ error: err.message }); +}); + +// Health check endpoint +app.get('/api/health', (req, res) => { + res.json({ status: 'ok', service: 'buchhaltung-backend', timestamp: new Date().toISOString() }); +}); + +// Start server +initDatabase().then(() => { + app.listen(PORT, '0.0.0.0', () => { + console.log(`🚀 Backend läuft auf Port ${PORT}`); + console.log(`📁 Uploads: ${uploadsDir}`); + }); +}).catch(err => { + console.error('Datenbank-Fehler:', err); + process.exit(1); +}); + +// Graceful shutdown +process.on('SIGTERM', async () => { + console.log('SIGTERM empfangen, schließe Verbindungen...'); + await pool.end(); + process.exit(0); +}); \ No newline at end of file diff --git a/backend/services/ocr.js b/backend/services/ocr.js new file mode 100644 index 0000000..0cb4748 --- /dev/null +++ b/backend/services/ocr.js @@ -0,0 +1,80 @@ +const Tesseract = require('tesseract.js'); +const path = require('path'); + +class OCRService { + async processDocument(filePath) { + try { + console.log(`[OCR] Verarbeite: ${filePath}`); + + // Erkenne Dateityp + const ext = path.extname(filePath).toLowerCase(); + + if (ext === '.pdf') { + // Für PDFs: Text-Extraktion (ohne OCR, wenn möglich) + // Hier könnte pdf-parse verwendet werden + return { + success: true, + extracted: { text: 'PDF Text extrahiert (Platzhalter)', type: 'pdf' } + }; + } + + // Für Bilder: Tesseract OCR + if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'].includes(ext)) { + const result = await Tesseract.recognize( + filePath, + 'deu', // Deutsche Sprache + { + logger: m => console.log(`[OCR] ${m.status}: ${Math.round(m.progress * 100)}%`) + } + ); + + // Extrahiere potenzielle Beträge (einfache Regex) + const amounts = this.extractAmounts(result.data.text); + + return { + success: true, + extracted: { + text: result.data.text, + confidence: result.data.confidence, + amounts: amounts, + type: 'image' + } + }; + } + + return { + success: false, + error: 'Nicht unterstütztes Dateiformat' + }; + } catch (error) { + console.error('[OCR] Fehler:', error); + return { + success: false, + error: error.message + }; + } + } + + extractAmounts(text) { + // Deutsche Beträge erkennen (z.B. 1.234,56 oder 1234,56) + const patterns = [ + /(\d{1,3}(?:\.\d{3})*,\d{2})\s*[€$]?/g, // 1.234,56 € + /(\d+,\d{2})\s*[€$]?/g, // 1234,56 € + /[€$]\s*(\d{1,3}(?:,\d{3})*\.\d{2})/g, // € 1,234.56 + /[€$]\s*(\d+\.\d{2})/g // € 1234.56 + ]; + + const amounts = []; + patterns.forEach(pattern => { + const matches = text.match(pattern); + if (matches) { + amounts.push(...matches.map(m => m.replace(/[^\d,]/g, '').replace(',', '.'))); + } + }); + + // Eindeutige Beträge zurückgeben + return [...new Set(amounts)].map(a => parseFloat(a)).filter(a => a > 0); + } +} + +module.exports = new OCRService(); diff --git a/backend/uploads/auftragsnachweis-6a0367a7-b926-4a1f-a025-9500647cfdd0.pdf b/backend/uploads/auftragsnachweis-6a0367a7-b926-4a1f-a025-9500647cfdd0.pdf new file mode 100644 index 0000000000000000000000000000000000000000..7656bfbb828eb7b714adb30a6d40febf40961d57 GIT binary patch literal 17961 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 hGp}{?54Y= 2024: + rest_dsl = ws.cell(row=row_idx, column=9).value + rest_psd = ws.cell(row=row_idx, column=13).value + rest_zing = ws.cell(row=row_idx, column=20).value + print(f"{datum.strftime('%Y-%m')}: DSL={rest_dsl}, PSD={rest_psd}, Zing={rest_zing}") + if datum.year >= 2026 and datum.month >= 4: + break + +# Finde Sparkasse - suchen in allen Zeilen nach "Sparkasse" +print("\n=== SUCHE SPARKASSE ===") +for row_idx in range(1, min(ws.max_row+1, 50)): + for col_idx in range(1, 50): + cell = ws.cell(row=row_idx, column=col_idx).value + if cell and isinstance(cell, str) and "sparkasse" in cell.lower(): + print(f" Gefunden in Zeile {row_idx}, Spalte {col_idx}: '{cell}'") + +# Suche PVCreditplus +print("\n=== SUCHE PVCreditplus ===") +for row_idx in range(1, min(ws.max_row+1, 50)): + for col_idx in range(1, 50): + cell = ws.cell(row=row_idx, column=col_idx).value + if cell and isinstance(cell, str): + if "credit" in cell.lower() or "pvc" in cell.lower(): + print(f" Gefunden in Zeile {row_idx}, Spalte {col_idx}: '{cell}'") diff --git a/debug_sonderkredite.py b/debug_sonderkredite.py new file mode 100644 index 0000000..9ffc8d6 --- /dev/null +++ b/debug_sonderkredite.py @@ -0,0 +1,82 @@ +import openpyxl +from datetime import datetime + +datei = "Kopie von Kostenrechnung der Nächsten jahre (3).xlsx" +wb = openpyxl.load_workbook(datei, data_only=True) +ws = wb["Tilgung bei Gleichbleibenden Be"] + +# Prüfe Zeile 2 für Carola, Kerstin, PVCreditplus +print("=== ZEILE 2 (Carola, Kerstin, PVCreditplus) ===") +row2 = list(ws.iter_rows(min_row=2, max_row=2, values_only=True))[0] +for i in range(35, min(len(row2), 50)): + if row2[i]: + print(f" Spalte {i+1}: '{row2[i]}'") + +# Prüfe Zeile 3-13 für Carola (Spalte 36) +print("\n=== CAROLA (Spalte 36) - Erste 10 Einträge ===") +for row_idx in range(3, 13): + datum = ws.cell(row=row_idx, column=1).value + carola = ws.cell(row=row_idx, column=36).value + print(f" Zeile {row_idx}: Datum={datum}, Carola={carola}") + +# Letzte Einträge Carola +print("\n=== CAROLA (Spalte 36) - Letzte 10 Einträge ===") +for row_idx in range(ws.max_row-10, ws.max_row+1): + datum = ws.cell(row=row_idx, column=1).value + carola = ws.cell(row=row_idx, column=36).value + if carola is not None: + print(f" Zeile {row_idx}: Datum={datum}, Carola={carola}") + +# Prüfe Kerstin (Spalte 39) +print("\n=== KERSTIN (Spalte 39) - Erste und letzte Einträge ===") +for row_idx in [3, 4, 5, ws.max_row-5, ws.max_row-4, ws.max_row-3, ws.max_row-2, ws.max_row-1, ws.max_row]: + datum = ws.cell(row=row_idx, column=1).value + kerstin = ws.cell(row=row_idx, column=39).value + print(f" Zeile {row_idx}: Datum={datum}, Kerstin={kerstin}") + +# Prüfe PVCreditplus (Spalte 42) +print("\n=== PVCREDITPLUS (Spalte 42) - Erste und letzte Einträge ===") +for row_idx in [3, 4, 5, ws.max_row-5, ws.max_row-4, ws.max_row-3, ws.max_row-2, ws.max_row-1, ws.max_row]: + datum = ws.cell(row=row_idx, column=1).value + pvc = ws.cell(row=row_idx, column=42).value + print(f" Zeile {row_idx}: Datum={datum}, PVCreditplus={pvc}") + +# Prüfe Sparkasse - hat "Rate" in Zeile 2, Spalte 21 +# Aber wo ist Restschuld? Vielleicht in Spalte 20? +print("\n=== SPARKASSE - Umgebung Spalte 20-23 ===") +for row_idx in [1, 2, 3, 4, 5, 20, 21, 22]: + vals = [] + for col in range(19, 24): + val = ws.cell(row=row_idx, column=col).value + vals.append(str(val)[:15] if val else "") + print(f" Zeile {row_idx}: {vals}") + +# Aktuelle Werte (2026) +print("\n=== AKTUELLE WERTE (April 2026) ===") +heute = datetime(2026, 4, 1) +for row_idx in range(3, ws.max_row+1): + datum = ws.cell(row=row_idx, column=1).value + if datum and datum.year == 2026 and datum.month == 4: + print(f"Zeile {row_idx} ({datum}):") + # DSL + dsl = ws.cell(row=row_idx, column=9).value + print(f" DSL Bank (9): {dsl}") + # PSD + psd = ws.cell(row=row_idx, column=13).value + print(f" PSD Nord (13): {psd}") + # Zingelstr + zing = ws.cell(row=row_idx, column=20).value + print(f" Zingelstr. 14 (20): {zing}") + # Sparkasse + spark = ws.cell(row=row_idx, column=20).value + print(f" Sparkasse - Spalte 20: {spark}") + # Carola + caro = ws.cell(row=row_idx, column=36).value + print(f" Carola (36): {caro}") + # Kerstin + ker = ws.cell(row=row_idx, column=39).value + print(f" Kerstin (39): {ker}") + # PVCreditplus + pvc = ws.cell(row=row_idx, column=42).value + print(f" PVCreditplus (42): {pvc}") + break diff --git a/delete_old_credit.py b/delete_old_credit.py new file mode 100644 index 0000000..bcb1bb9 --- /dev/null +++ b/delete_old_credit.py @@ -0,0 +1,15 @@ +import urllib.request +import json + +# Loesche den alten falschen Kredit +kredit_id = 'c3342fa9-4499-48fe-bc25-592299d9c9cf' + +url = f'http://localhost:3001/api/kredite/{kredit_id}' +req = urllib.request.Request(url, method='DELETE') + +try: + with urllib.request.urlopen(req) as response: + print(f'[OK] Alter Kredit geloescht: {response.read().decode()}') +except Exception as e: + print(f'[WARN] Konnte alten Kredit nicht loeschen: {e}') + print('Versuche neuen Import trotzdem...') diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..6cbf275 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,35 @@ +version: '3.8' + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + volumes: + - uploads:/app/uploads + - db_data:/app/data + networks: + - app-net + environment: + - NODE_ENV=production + restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "80:80" + networks: + - app-net + depends_on: + - backend + restart: unless-stopped + +volumes: + uploads: + db_data: + +networks: + app-net: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4289c62 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,65 @@ +version: '3.8' + +services: + db: + image: postgres:16-alpine + container_name: buchhaltung-db + environment: + POSTGRES_DB: buchhaltung + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + networks: + - buchhaltung-net + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + backend: + build: ./backend + container_name: buchhaltung-backend + ports: + - "3001:3001" + environment: + DB_HOST: db + DB_PORT: 5432 + DB_NAME: buchhaltung + DB_USER: postgres + DB_PASSWORD: postgres + PORT: 3001 + NODE_ENV: production + JWT_SECRET: change-this-secret-in-production-min-32-chars + volumes: + - ./backend/uploads:/app/uploads + depends_on: + db: + condition: service_healthy + networks: + - buchhaltung-net + restart: unless-stopped + + frontend: + image: nginx:alpine + container_name: buchhaltung-frontend + ports: + - "3000:80" + volumes: + - ./frontend/dist:/usr/share/nginx/html:ro + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - backend + networks: + - buchhaltung-net + restart: unless-stopped + +networks: + buchhaltung-net: + driver: bridge + +volumes: + postgres_data: \ No newline at end of file diff --git a/excel_analysis.json b/excel_analysis.json new file mode 100644 index 0000000..d72c5d6 --- /dev/null +++ b/excel_analysis.json @@ -0,0 +1,1994 @@ +{ + "K\u00e4rger 2020": { + "headers": [], + "mieter": [ + "Ren\u00e9 T\u00e4ger, Zingelstr.7, 25554 Wilster", + "Kevin K\u00e4rger", + "Zingelstr. 14", + "25554 Wilster", + "Nebenkostenabrechnung 2020", + "Abrechnung der Nebenkosten", + "Geb. Vers./Haftpflichtv.", + "Grundsteuer", + "Niederschlagwasser", + "M\u00fcllabfuhr", + "Summe", + "Geleistete Vorrauszahlung" + ], + "rows": [ + [ + "Ren\u00e9 T\u00e4ger, Zingelstr.7, 25554 Wilster", + null, + null, + null, + null, + null, + null, + "Wilster den 05.02.2021", + null, + null, + null + ], + [ + "Kevin K\u00e4rger", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Zingelstr. 14", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "25554 Wilster", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Nebenkostenabrechnung 2020", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Abrechnung der Nebenkosten", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Geb. Vers./Haftpflichtv.", + 393.9, + 2, + 1, + "Einheiten", + "=B13/2", + 365, + "*", + 31, + "=", + "=F13/G13*I13" + ], + [ + "Grundsteuer", + 59.84, + 100, + 30, + "m\u00b2 Wohnfl\u00e4che", + "=B14/100*30", + 365, + "*", + 31, + "=", + "=F14/G14*I14" + ], + [ + "Niederschlagwasser", + 112.27, + 100, + 30, + "m\u00b2 Wohnfl\u00e4che", + "=B15/100*30", + 365, + "*", + 31, + "=", + "=F15/G15*I15" + ], + [ + "M\u00fcllabfuhr", + 85.32, + 2, + 1, + "Einheiten", + "=B16/C16", + 183, + "*", + 31, + "=", + "=F16/G16*I16" + ], + [ + "Summe", + "=B13+B14+B15+B16", + null, + null, + null, + "=F13+F15+F16+F14", + null, + null, + null, + null, + "=K13+K15+K16+K14" + ], + [ + "Geleistete Vorrauszahlung", + null, + null, + null, + null, + null, + null, + -30, + " *", + 1, + "=H21*J21" + ] + ] + }, + "KrohnWelling 2020": { + "headers": [], + "mieter": [ + "Ren\u00e9 T\u00e4ger, Zingelstr.7, 25554 Wilster", + "Johanna Krohn /Tobias Welling", + "Zingelstr. 14", + "25554 Wilster", + "Nebenkostenabrechnung 2020", + "Abrechnung der Nebenkosten", + "Geb. Vers./Haftpflichtv.", + "Grundsteuer", + "Niederschlagwasser", + "M\u00fcllabfuhr", + "Summe", + "Geleistete Vorrauszahlung" + ], + "rows": [ + [ + "Ren\u00e9 T\u00e4ger, Zingelstr.7, 25554 Wilster", + null, + null, + null, + null, + null, + null, + "Wilster den 05.02.2021", + null, + null, + null + ], + [ + "Johanna Krohn /Tobias Welling", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Zingelstr. 14", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "25554 Wilster", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Nebenkostenabrechnung 2020", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Abrechnung der Nebenkosten", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Geb. Vers./Haftpflichtv.", + 393.9, + 2, + 1, + "Einheiten", + "=B13/2", + 365, + "*", + 31, + "=", + "=F13/G13*I13" + ], + [ + "Grundsteuer", + 59.84, + 100, + 70, + "m\u00b2 Wohnfl\u00e4che", + "=B14/100*D14", + 365, + "*", + 31, + "=", + "=F14/G14*I14" + ], + [ + "Niederschlagwasser", + 112.27, + 100, + 70, + "m\u00b2 Wohnfl\u00e4che", + "=B15/100*D15", + 365, + "*", + 31, + "=", + "=F15/G15*I15" + ], + [ + "M\u00fcllabfuhr", + 85.32, + 2, + 1, + "Einheiten", + "=B16/C16", + 183, + "*", + 31, + "=", + "=F16/G16*I16" + ], + [ + "Summe", + "=B13+B14+B15+B16", + null, + null, + null, + "=F13+F15+F16+F14", + null, + null, + null, + null, + "=K13+K15+K16+K14" + ], + [ + "Geleistete Vorrauszahlung", + null, + null, + null, + null, + null, + null, + -30, + " *", + 1, + "=H21*J21" + ] + ] + }, + "K\u00e4rger 2021": { + "headers": [], + "mieter": [ + "Ren\u00e9 T\u00e4ger, Zingelstr.7, 25554 Wilster", + "Kevin K\u00e4rger", + "Zingelstr. 14", + "25554 Wilster", + "Nebenkostenabrechnung 2021", + "Abrechnung der Nebenkosten", + "Geb. Vers./Haftpflichtv.", + "Grundsteuer", + "Niederschlagwasser", + "Wartung Heizung", + "M\u00fcllabfuhr", + "Summe", + "Geleistete Vorrauszahlung" + ], + "rows": [ + [ + "Ren\u00e9 T\u00e4ger, Zingelstr.7, 25554 Wilster", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Kevin K\u00e4rger", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Zingelstr. 14", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "25554 Wilster", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Nebenkostenabrechnung 2021", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Abrechnung der Nebenkosten", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Geb. Vers./Haftpflichtv.", + 428.7, + 2, + 1, + "Einheiten", + "=B13/2", + 365, + "*", + 365, + "=", + "=F13/G13*I13", + null, + null + ], + [ + "Grundsteuer", + 59.84, + 100, + 30, + "m\u00b2 Wohnfl\u00e4che", + "=B14/100*30", + 365, + "*", + 365, + "=", + "=F14/G14*I14", + null, + "=B14/12" + ], + [ + "Niederschlagwasser", + 112.27, + 100, + 30, + "m\u00b2 Wohnfl\u00e4che", + "=B15/100*30", + 365, + "*", + 365, + "=", + "=F15/G15*I15", + null, + null + ], + [ + "Wartung Heizung", + 250.97, + 100, + 30, + "m\u00b2 Wohnfl\u00e4che", + "=B16/100*30", + 365, + "*", + 365, + "=", + "=F16/G16*I16", + null, + null + ], + [ + "M\u00fcllabfuhr", + "=69.39+63.96+63.96+63.96", + 2, + 1, + "Einheiten", + "=B17/C17", + 365, + "*", + 365, + "=", + "=F17/G17*I17", + null, + null + ], + [ + "Summe", + "=B13+B14+B15+B17", + null, + null, + null, + "=F13+F15+F17+F14+F16", + null, + null, + null, + null, + "=K13+K15+K17+K14+K16", + null, + null + ], + [ + "Geleistete Vorrauszahlung", + null, + null, + null, + null, + null, + null, + -30, + " *", + 12, + "=H22*J22", + null, + null + ] + ] + }, + "KrohnWelling 2021": { + "headers": [], + "mieter": [ + "Ren\u00e9 T\u00e4ger, Zingelstr.7, 25554 Wilster", + "Johanna Krohn /Tobias Welling", + "Zingelstr. 14", + "25554 Wilster", + "Nebenkostenabrechnung 2021", + "Abrechnung der Nebenkosten", + "Geb. Vers./Haftpflichtv.", + "Grundsteuer", + "Niederschlagwasser", + "Wartung Heizung", + "M\u00fcllabfuhr", + "Summe", + "Geleistete Vorrauszahlung" + ], + "rows": [ + [ + "Ren\u00e9 T\u00e4ger, Zingelstr.7, 25554 Wilster", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Johanna Krohn /Tobias Welling", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Zingelstr. 14", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "25554 Wilster", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Nebenkostenabrechnung 2021", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Abrechnung der Nebenkosten", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Geb. Vers./Haftpflichtv.", + 407.4, + 2, + 1, + "Einheiten", + "=B13/2", + 365, + "*", + 365, + "=", + "=F13/G13*I13" + ], + [ + "Grundsteuer", + 59.84, + 100, + 70, + "m\u00b2 Wohnfl\u00e4che", + "=B14/100*D14", + 365, + "*", + 365, + "=", + "=F14/G14*I14" + ], + [ + "Niederschlagwasser", + 112.27, + 100, + 70, + "m\u00b2 Wohnfl\u00e4che", + "=B15/100*D15", + 365, + "*", + 365, + "=", + "=F15/G15*I15" + ], + [ + "Wartung Heizung", + 250.97, + 100, + 70, + "m\u00b2 Wohnfl\u00e4che", + "=B16/100*30", + 365, + "*", + 365, + "=", + "=F16/G16*I16" + ], + [ + "M\u00fcllabfuhr", + 261.27, + 2, + 1, + "Einheiten", + "=B17/C17", + 365, + "*", + 365, + "=", + "=F17/G17*I17" + ], + [ + "Summe", + "=B13+B14+B15+B17", + null, + null, + null, + "=F13+F15+F17+F14+F16", + null, + null, + null, + null, + "=K13+K15+K17+K14+K16" + ], + [ + "Geleistete Vorrauszahlung", + null, + null, + null, + null, + null, + null, + -30, + " *", + 12, + "=H22*J22" + ] + ] + }, + "K\u00e4rger 2022": { + "headers": [], + "mieter": [ + "Ren\u00e9 T\u00e4ger, Zingelstr.7, 25554 Wilster", + "Kevin K\u00e4rger", + "Ahornweg 6", + "25524 Itzehoe", + "Nebenkostenabrechnung 2022", + "Abrechnung der Nebenkosten", + "Geb. Vers./Haftpflichtv.", + "Grundsteuer", + "Niederschlagwasser", + "Wartung Heizung", + "M\u00fcllabfuhr", + "Summe", + "Geleistete Vorrauszahlung" + ], + "rows": [ + [ + "Ren\u00e9 T\u00e4ger, Zingelstr.7, 25554 Wilster", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Kevin K\u00e4rger", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Ahornweg 6", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "25524 Itzehoe", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Nebenkostenabrechnung 2022", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Abrechnung der Nebenkosten", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Geb. Vers./Haftpflichtv.", + 460, + 2, + 1, + "Einheiten", + "=B13/2", + 365, + "*", + 365, + "=", + "=F13/G13*I13", + null, + null + ], + [ + "Grundsteuer", + "=4*14.96", + 100, + 30, + "m\u00b2 Wohnfl\u00e4che", + "=B14/100*30", + 365, + "*", + 365, + "=", + "=F14/G14*I14", + null, + null + ], + [ + "Niederschlagwasser", + "=3*28.06+28.09", + 100, + 30, + "m\u00b2 Wohnfl\u00e4che", + "=B15/100*30", + 365, + "*", + 365, + "=", + "=F15/G15*I15", + null, + null + ], + [ + "Wartung Heizung", + 175.76, + 100, + 30, + "m\u00b2 Wohnfl\u00e4che", + "=B16/100*30", + 365, + "*", + 365, + "=", + "=F16/G16*I16", + null, + null + ], + [ + "M\u00fcllabfuhr", + "=76.68*4", + 2, + 1, + "Einheiten", + "=B17/C17", + 365, + "*", + 365, + "=", + "=F17/G17*I17", + null, + null + ], + [ + "Summe", + "=B13+B14+B15+B17", + null, + null, + null, + "=F13+F15+F17+F14+F16", + null, + null, + null, + null, + "=K13+K15+K17+K14+K16", + null, + null + ], + [ + "Geleistete Vorrauszahlung", + null, + null, + null, + null, + null, + null, + -30, + " *", + 12, + "=H22*J22", + null, + null + ] + ] + }, + "KrohnWelling 2022": { + "headers": [], + "mieter": [ + "Ren\u00e9 T\u00e4ger, Zingelstr.7, 25554 Wilster", + "Johanna Krohn /Tobias Welling", + "Ahornweg 6", + "25524 Itzehoe", + "Nebenkostenabrechnung 2022", + "Abrechnung der Nebenkosten", + "Geb. Vers./Haftpflichtv.", + "Grundsteuer", + "Niederschlagwasser", + "Wartung Heizung", + "M\u00fcllabfuhr", + "Summe", + "Geleistete Vorrauszahlung" + ], + "rows": [ + [ + "Ren\u00e9 T\u00e4ger, Zingelstr.7, 25554 Wilster", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Johanna Krohn /Tobias Welling", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Ahornweg 6", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "25524 Itzehoe", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Nebenkostenabrechnung 2022", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Abrechnung der Nebenkosten", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Geb. Vers./Haftpflichtv.", + 460, + 2, + 1, + "Einheiten", + "=B13/2", + 365, + "*", + 365, + "=", + "=F13/G13*I13" + ], + [ + "Grundsteuer", + "=4*14.96", + 100, + 70, + "m\u00b2 Wohnfl\u00e4che", + "=B14/100*D14", + 365, + "*", + 365, + "=", + "=F14/G14*I14" + ], + [ + "Niederschlagwasser", + "=3*28.06+28.09", + 100, + 70, + "m\u00b2 Wohnfl\u00e4che", + "=B15/100*D15", + 365, + "*", + 365, + "=", + "=F15/G15*I15" + ], + [ + "Wartung Heizung", + 175.76, + 100, + 70, + "m\u00b2 Wohnfl\u00e4che", + "=B16/100*30", + 365, + "*", + 365, + "=", + "=F16/G16*I16" + ], + [ + "M\u00fcllabfuhr", + "=76.68*4", + 2, + 1, + "Einheiten", + "=B17/C17", + 365, + "*", + 365, + "=", + "=F17/G17*I17" + ], + [ + "Summe", + "=B13+B14+B15+B17", + null, + null, + null, + "=F13+F15+F17+F14+F16", + null, + null, + null, + null, + "=K13+K15+K17+K14+K16" + ], + [ + "Geleistete Vorrauszahlung", + null, + null, + null, + null, + null, + null, + -30, + " *", + 12, + "=H22*J22" + ] + ] + }, + "KrohnWelling 2023": { + "headers": [], + "mieter": [ + "Ren\u00e9 T\u00e4ger, Zingelstr.7, 25554 Wilster", + "Johanna Krohn /Tobias Welling", + "Ahornweg 6", + "25524 Itzehoe", + "Nebenkostenabrechnung 2023", + "Abrechnung der Nebenkosten", + "Geb. Vers./Haftpflichtv.", + "Grundsteuer", + "Niederschlagwasser", + "Wartung Heizung", + "M\u00fcllabfuhr", + "Summe", + "Geleistete Vorrauszahlung" + ], + "rows": [ + [ + "Ren\u00e9 T\u00e4ger, Zingelstr.7, 25554 Wilster", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Johanna Krohn /Tobias Welling", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Ahornweg 6", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "25524 Itzehoe", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Nebenkostenabrechnung 2023", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Abrechnung der Nebenkosten", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Geb. Vers./Haftpflichtv.", + 386.1, + 2, + 1, + "Einheiten", + "=B13/2", + 365, + "*", + 120, + "=", + "=F13/G13*I13" + ], + [ + "Grundsteuer", + "=4*14.96", + 100, + 70, + "m\u00b2 Wohnfl\u00e4che", + "=B14/100*D14", + 365, + "*", + 120, + "=", + "=F14/G14*I14" + ], + [ + "Niederschlagwasser", + "=3*28.06+28.09", + 100, + 70, + "m\u00b2 Wohnfl\u00e4che", + "=B15/100*D15", + 365, + "*", + 120, + "=", + "=F15/G15*I15" + ], + [ + "Wartung Heizung", + 175.76, + 100, + 70, + "m\u00b2 Wohnfl\u00e4che", + "=B16/100*30", + 365, + "*", + 120, + "=", + "=F16/G16*I16" + ], + [ + "M\u00fcllabfuhr", + "=76.68*4", + 2, + 1, + "Einheiten", + "=B17/C17", + 365, + "*", + 120, + "=", + "=F17/G17*I17" + ], + [ + "Summe", + "=B13+B14+B15+B17", + null, + null, + null, + "=F13+F15+F17+F14+F16", + null, + null, + null, + null, + "=K13+K15+K17+K14+K16" + ], + [ + "Geleistete Vorrauszahlung", + null, + null, + null, + null, + null, + null, + -30, + " *", + 4, + "=H22*J22" + ] + ] + }, + "K\u00e4rger 2023)": { + "headers": [], + "mieter": [ + "Ren\u00e9 T\u00e4ger, Zingelstr.7, 25554 Wilster", + "Kevin K\u00e4rger", + "Ahornweg 6", + "25524 Itzehoe", + "Nebenkostenabrechnung 2023", + "Abrechnung der Nebenkosten", + "Geb. Vers./Haftpflichtv.", + "Grundsteuer", + "Niederschlagwasser", + "Wartung Heizung", + "M\u00fcllabfuhr", + "Summe", + "Geleistete Vorrauszahlung" + ], + "rows": [ + [ + "Ren\u00e9 T\u00e4ger, Zingelstr.7, 25554 Wilster", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Kevin K\u00e4rger", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Ahornweg 6", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "25524 Itzehoe", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Nebenkostenabrechnung 2023", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Abrechnung der Nebenkosten", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Geb. Vers./Haftpflichtv.", + 386.1, + 2, + 1, + "Einheiten", + "=B13/2", + 365, + "*", + 120, + "=", + "=F13/G13*I13", + null, + null + ], + [ + "Grundsteuer", + "=4*14.96", + 100, + 30, + "m\u00b2 Wohnfl\u00e4che", + "=B14/100*30", + 365, + "*", + 120, + "=", + "=F14/G14*I14", + null, + null + ], + [ + "Niederschlagwasser", + "=3*28.06+28.09", + 100, + 30, + "m\u00b2 Wohnfl\u00e4che", + "=B15/100*30", + 365, + "*", + 120, + "=", + "=F15/G15*I15", + null, + null + ], + [ + "Wartung Heizung", + 175.76, + 100, + 30, + "m\u00b2 Wohnfl\u00e4che", + "=B16/100*30", + 365, + "*", + 120, + "=", + "=F16/G16*I16", + null, + null + ], + [ + "M\u00fcllabfuhr", + "=76.68*4", + 2, + 1, + "Einheiten", + "=B17/C17", + 365, + "*", + 120, + "=", + "=F17/G17*I17", + null, + null + ], + [ + "Summe", + "=B13+B14+B15+B17", + null, + null, + null, + "=F13+F15+F17+F14+F16", + null, + null, + null, + null, + "=K13+K15+K17+K14+K16", + null, + null + ], + [ + "Geleistete Vorrauszahlung", + null, + null, + null, + null, + null, + null, + -30, + " *", + 4, + "=H22*J22", + null, + null + ] + ] + }, + "Brandt 2023": { + "headers": [], + "mieter": [ + "Ren\u00e9 T\u00e4ger, Zingelstr.7, 25554 Wilster", + "Yvonne Brandt", + "Zingelstr. 14", + "25554 Wilster", + "Nebenkostenabrechnung 2023 Korrektur", + "Abrechnung der Nebenkosten", + "Geb. Vers./Haftpflichtv.", + "Grundsteuer", + "Niederschlagwasser", + "Wartung Heizung", + "M\u00fcllabfuhr", + "Summe", + "Geleistete Vorrauszahlung" + ], + "rows": [ + [ + "Ren\u00e9 T\u00e4ger, Zingelstr.7, 25554 Wilster", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Yvonne Brandt", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Zingelstr. 14", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "25554 Wilster", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Nebenkostenabrechnung 2023 Korrektur", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Abrechnung der Nebenkosten", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Geb. Vers./Haftpflichtv.", + 386.1, + 1, + 1, + "Einheiten", + "=B13/2", + 365, + "*", + "=365-120", + "=", + "=F13/G13*I13", + null, + null + ], + [ + "Grundsteuer", + "=4*14.96", + 100, + 100, + "m\u00b2 Wohnfl\u00e4che", + "=B14/100*100", + 365, + "*", + 245, + "=", + "=F14/G14*I14", + null, + null + ], + [ + "Niederschlagwasser", + "=3*28.06+28.09", + 100, + 100, + "m\u00b2 Wohnfl\u00e4che", + "=B15/100*100", + 365, + "*", + 245, + "=", + "=F15/G15*I15", + null, + null + ], + [ + "Wartung Heizung", + 175.76, + 100, + 100, + "m\u00b2 Wohnfl\u00e4che", + "=B16/100*100", + 12, + "*", + 7, + "=", + "=F16/G16*I16", + null, + null + ], + [ + "M\u00fcllabfuhr", + 271.04, + 1, + 1, + "Einheiten", + "=B17/C17", + "\u00c4nderung der Tonne ergibt", + null, + null, + "=", + 153.85, + null, + null + ], + [ + "Summe", + "=B13+B14+B15+B17", + null, + null, + null, + "=F13+F15+F17+F14+F16", + null, + null, + null, + null, + "=K13+K15+K17+K14+K16", + null, + null + ], + [ + "Geleistete Vorrauszahlung", + null, + null, + null, + null, + null, + null, + -70, + " *", + 8, + "=H22*J22", + null, + null + ] + ] + }, + "Brandt 2024": { + "headers": [], + "mieter": [ + "Ren\u00e9 T\u00e4ger, Zingelstr.7, 25554 Wilster", + "Yvonne Brandt", + "Zingelstr. 14", + "25554 Wilster", + "Nebenkostenabrechnung 2024", + "Abrechnung der Nebenkosten", + "Geb. Vers./Haftpflichtv.", + "Grundsteuer", + "Niederschlagwasser", + "Wartung Heizung", + "M\u00fcllabfuhr", + "Summe", + "Geleistete Vorrauszahlung" + ], + "rows": [ + [ + "Ren\u00e9 T\u00e4ger, Zingelstr.7, 25554 Wilster", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Yvonne Brandt", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Zingelstr. 14", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "25554 Wilster", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Nebenkostenabrechnung 2024", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Abrechnung der Nebenkosten", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + [ + "Geb. Vers./Haftpflichtv.", + 230.82, + 1, + 1, + "Einheiten", + "=B13/2", + 365, + "*", + 365, + "=", + "=F13/G13*I13", + null, + null + ], + [ + "Grundsteuer", + "=4*14.96", + 100, + 100, + "m\u00b2 Wohnfl\u00e4che", + "=B14/100*100", + 365, + "*", + 365, + "=", + "=F14/G14*I14", + null, + null + ], + [ + "Niederschlagwasser", + "=3*28.06+28.09", + 100, + 100, + "m\u00b2 Wohnfl\u00e4che", + "=B15/100*100", + 365, + "*", + 365, + "=", + "=F15/G15*I15", + null, + null + ], + [ + "Wartung Heizung", + 175.76, + 100, + 100, + "m\u00b2 Wohnfl\u00e4che", + "=B16/100*100", + 365, + "*", + 365, + "=", + "=F16/G16*I16", + null, + null + ], + [ + "M\u00fcllabfuhr", + 212.28, + 1, + 1, + "Einheiten", + "=B17/C17", + 365, + "*", + 365, + "=", + "=F17/G17*I17", + null, + null + ], + [ + "Summe", + "=B13+B14+B15+B17", + null, + null, + null, + "=F13+F15+F17+F14+F16", + null, + null, + null, + null, + "=K13+K15+K17+K14+K16", + null, + null + ], + [ + "Geleistete Vorrauszahlung", + null, + null, + null, + null, + null, + null, + -70, + " *", + 12, + "=H22*J22", + null, + null + ] + ] + } +} \ No newline at end of file diff --git a/execute_import.py b/execute_import.py new file mode 100644 index 0000000..28637d9 --- /dev/null +++ b/execute_import.py @@ -0,0 +1,88 @@ +import psycopg2 + +# Verbindung zur Datenbank +conn = psycopg2.connect( + host='localhost', + port=5432, + database='buchhaltung', + user='postgres', + password='postgres' +) + +cur = conn.cursor() + +print('=== NEBENKOSTEN IMPORT ===\n') + +# Prüfe vorhandene Einträge +cur.execute("SELECT COUNT(*) FROM nebenkosten WHERE jahr BETWEEN 2020 AND 2024") +count = cur.fetchone()[0] +print(f'Vorhandene Einträge 2020-2024: {count}') + +if count > 0: + print('Lösche vorhandene Einträge...') + cur.execute("DELETE FROM nebenkosten WHERE jahr BETWEEN 2020 AND 2024") + conn.commit() + print('Gelöscht.\n') + +# Alle Abrechnungen +abrechnungen = [ + (2020, 'Zingelstr. 14', 'Kevin Körger', 28.34, 16.73, None, 2.86, 7.23, 1.52), + (2020, 'Zingelstr. 14', 'Johanna Krohn / Tobias Welling', 34.19, 16.73, None, 6.67, 7.23, 3.56), + (2021, 'Zingelstr. 14', 'Kevin Körger', 471.91, 214.35, 75.29, 33.68, 130.64, 17.95), + (2021, 'Zingelstr. 14', 'Johanna Krohn / Tobias Welling', 530.10, 203.70, 75.29, 78.59, 130.64, 41.89), + (2022, 'Ahornweg 6', 'Kevin Körger', 487.72, 230.00, 52.73, 33.68, 153.36, 17.95), + (2022, 'Ahornweg 6', 'Johanna Krohn / Tobias Welling', 556.56, 230.00, 52.73, 78.59, 153.36, 41.89), + (2023, 'Ahornweg 6', 'Kevin Körger', 148.20, 63.47, 17.34, 11.07, 50.42, 5.90), + (2023, 'Ahornweg 6', 'Johanna Krohn / Tobias Welling', 170.83, 63.47, 17.34, 25.84, 50.42, 13.77), + (2023, 'Zingelstr. 14', 'Yvonne Brandt', 501.48, 129.58, 102.53, 75.36, 153.85, 40.17), + (2024, 'Zingelstr. 14', 'Yvonne Brandt', 675.56, 115.41, 175.76, 112.27, 212.28, 59.84), +] + +query = """ + INSERT INTO nebenkosten + (jahr, wohnung, mieter, nebenkosten, versicherung, heizkosten, wasser, muell, sonstiges) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) +""" + +imported = 0 +for data in abrechnungen: + try: + cur.execute(query, data) + imported += 1 + print(f'OK {data[2]} {data[0]}: {data[3]} EUR') + except Exception as e: + print(f'FEHLER bei {data[2]} {data[0]}: {e}') + +conn.commit() + +print(f'\n=== VERIFIZIERUNG ===') +print(f'Importiert: {imported} von {len(abrechnungen)}') + +# Zeige alle Einträge +cur.execute(""" + SELECT jahr, mieter, wohnung, nebenkosten + FROM nebenkosten + WHERE jahr BETWEEN 2020 AND 2024 + ORDER BY jahr, mieter +""") + +print('\nAlle Einträge:') +for row in cur.fetchall(): + print(f' {row[0]}: {row[1]} ({row[2]}) - {row[3]} €') + +# Summen pro Jahr +cur.execute(""" + SELECT jahr, COUNT(*), SUM(nebenkosten) + FROM nebenkosten + WHERE jahr BETWEEN 2020 AND 2024 + GROUP BY jahr + ORDER BY jahr +""") + +print('\nSummen pro Jahr:') +for row in cur.fetchall(): + print(f' {row[0]}: {row[1]} Abrechnungen, {row[2]:.2f} €') + +cur.close() +conn.close() +print('\n✅ IMPORT ABGESCHLOSSEN') diff --git a/extract_excel.py b/extract_excel.py new file mode 100644 index 0000000..bbddf61 --- /dev/null +++ b/extract_excel.py @@ -0,0 +1,80 @@ +import openpyxl +import json + +wb = openpyxl.load_workbook('Nebenkosten 2020.xlsx', data_only=True) + +all_data = [] + +for sheet in wb.sheetnames: + ws = wb[sheet] + + year = None + for y in ['2020', '2021', '2022', '2023', '2024']: + if y in sheet: + year = int(y) + break + + if not year: + continue + + mieter = None + address = None + + for row in ws.iter_rows(min_row=1, max_row=15, values_only=True): + for cell in row: + if cell and isinstance(cell, str): + if 'Zingelstr. 14' in cell: + address = 'Zingelstr. 14' + elif 'Zingelstr. 7' in cell or 'Zingelstr.7' in cell: + address = 'Zingelstr. 7' + elif 'Ahornweg 6' in cell: + address = 'Ahornweg 6' + + if 'Krohn' in cell and 'Welling' in cell: + mieter = 'Johanna Krohn / Tobias Welling' + elif 'Körger' in cell or 'K�rger' in cell: + mieter = 'Kevin Körger' + elif 'Brandt' in cell and 'Yvonne' in cell: + mieter = 'Yvonne Brandt' + + if not mieter or not address: + continue + + positions = {} + for row in ws.iter_rows(min_row=7, max_row=30, values_only=True): + if not row or not row[0]: + continue + + pos_name = str(row[0]).strip() + + betrag = None + if len(row) > 10 and row[10] and isinstance(row[10], (int, float)): + betrag = round(row[10], 2) + elif len(row) > 5 and row[5] and isinstance(row[5], (int, float)): + betrag = round(row[5], 2) + + if betrag: + positions[pos_name] = betrag + + all_data.append({ + 'sheet': sheet, + 'year': year, + 'mieter': mieter, + 'address': address, + 'positions': positions + }) + +zingelstr14 = [d for d in all_data if d['address'] == 'Zingelstr. 14'] + +print('=== DATEN FUER ZINGELSTR. 14 ===') +for d in zingelstr14: + print(f"\n{d['sheet']}: {d['mieter']} ({d['year']})") + print(f" Positionen: {len(d['positions'])}") + for pos, val in d['positions'].items(): + print(f" - {pos}: {val}") + +with open('nebenkosten_zingelstr14.json', 'w', encoding='utf-8') as f: + json.dump(zingelstr14, f, indent=2, ensure_ascii=False) + +print(f"\nGesamt: {len(zingelstr14)} Eintraege") +print('Gespeichert in nebenkosten_zingelstr14.json') diff --git a/extract_heizung_muell.py b/extract_heizung_muell.py new file mode 100644 index 0000000..b55a612 --- /dev/null +++ b/extract_heizung_muell.py @@ -0,0 +1,33 @@ +import openpyxl +from openpyxl import load_workbook + +file_path = r"C:\Users\renet\.openclaw\workspace\buchhaltungs-app\Nebenkosten 2020.xlsx" +wb = load_workbook(file_path, data_only=True) + +sheets = ['Körger 2020', 'KrohnWelling 2020', 'Körger 2021', 'KrohnWelling 2021', + 'Körger 2022', 'KrohnWelling 2022', 'KrohnWelling 2023', 'Körger 2023)', 'Brandt 2023', 'Brandt 2024'] + +print("Heizkosten und Müll aus allen Sheets:\n") + +for sheet_name in sheets: + if sheet_name in wb.sheetnames: + ws = wb[sheet_name] + print(f"=== {sheet_name} ===") + + # Zeilen 13-25 nach Heizung und Müll suchen + for row in range(13, 26): + cell_a = ws.cell(row=row, column=1).value + cell_b = ws.cell(row=row, column=2).value # Gesamtkosten + cell_k = ws.cell(row=row, column=11).value # Ihr Anteil (Spalte K) + + if cell_a and ('heiz' in str(cell_a).lower() or 'Heiz' in str(cell_a)): + print(f" Zeile {row}: {cell_a}") + print(f" Gesamtkosten: {cell_b}") + print(f" Anteil: {cell_k}") + + if cell_a and ('müll' in str(cell_a).lower() or 'Müll' in str(cell_a) or 'muell' in str(cell_a).lower()): + print(f" Zeile {row}: {cell_a}") + print(f" Gesamtkosten: {cell_b}") + print(f" Anteil: {cell_k}") + + print() diff --git a/extract_kredite_complete.py b/extract_kredite_complete.py new file mode 100644 index 0000000..cd55b16 --- /dev/null +++ b/extract_kredite_complete.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +""" +Extrahiert alle 5 Kredite mit vollständigen Daten. +""" + +import openpyxl +import json +from datetime import datetime + +# Excel-Datei laden +datei = "Kopie von Kostenrechnung der Nächsten jahre (3).xlsx" +print(f"Lade {datei}...") + +wb = openpyxl.load_workbook(datei, data_only=True) + +# Sheet "Tilgung bei Gleichbleibenden Be" laden +sheet_name = "Tilgung bei Gleichbleibenden Be" +ws = wb[sheet_name] + +# Kredit-Definitionen +kredite = { + "Targo Bank": {"col_base": 2}, # Spalte 2 = Restschuld, 3=Tilgung, 4=Zinsen + "Köpke": {"col_base": 6}, # Spalte 6 = Restschuld, 7=Tilgung + "DSL Bank": {"col_base": 9}, # Spalte 9 = Restschuld, 10=Tilgung, 11=Zinsen + "PSD Nord": {"col_base": 13}, # Spalte 13 = Restschuld, 14=Tilgung, 15=Zinsen + "Zingelstr. 14": {"col_base": 20}, # Spalte 20 = Restschuld, 21=Rate, 22=Zinsen +} + +# Meine 5 Ziel-Kredite +target_kredite = ["DSL Bank", "PSD Nord", "Zingelstr. 14", "PVCreditplus", "Sparkasse"] + +# Für Zingelstr. 14 müssen wir nach Sparkasse suchen (die haben beide Zingelstr. 14) +# Zingelstr. 14 DSL und Zingelstr. 14 Sparkasse sind wahrscheinlich unter "Zingelstr. 14" zusammen + +print("\n=== DATENEXTRAKTION FUER 5 KREDITE ===\n") + +def get_last_value(ws, col, start_row=6): + """Holt den letzten numerischen Wert aus einer Spalte""" + for row in range(ws.max_row, start_row-1, -1): + val = ws.cell(row=row, column=col).value + if val is not None and isinstance(val, (int, float)) and val != 0: + return val, row + return None, None + +def get_first_date(ws, col=1, start_row=6): + """Holt das erste Datum aus Spalte 1 (Monat)""" + val = ws.cell(row=start_row, column=col).value + if val and isinstance(val, datetime): + return val + return None + +def get_monthly_rate(ws, tilgung_col, start_row=6): + """Ermittelt die monatliche Rate (häufigster Tilgungswert)""" + rates = [] + for row in range(start_row, min(start_row + 24, ws.max_row)): + val = ws.cell(row=row, column=tilgung_col).value + if val and isinstance(val, (int, float)) and val > 0: + rates.append(val) + if rates: + from collections import Counter + return Counter(rates).most_common(1)[0][0] + return 0 + +def get_zinssatz_from_berechnung(ws, zinsen_col, restschuld_col, start_row=6): + """Berechnet Zinssatz aus Zinsen/Restschuld * 12""" + for row in range(start_row, min(start_row + 5, ws.max_row)): + zinsen = ws.cell(row=row, column=zinsen_col).value + restschuld = ws.cell(row=row, column=restschuld_col).value + if zinsen and restschuld and isinstance(zinsen, (int, float)) and isinstance(restschuld, (int, float)) and restschuld > 0: + zinssatz = (zinsen / restschuld) * 12 * 100 # Jahreszins in % + if 0 < zinssatz < 20: # Plausibilitätscheck + return round(zinssatz, 2) + return None + +# DSL Bank +print("1. DSL BANK") +print("-" * 40) +col_base = 9 +restschuld_col = col_base +tilgung_col = col_base + 1 +zinsen_col = col_base + 2 + +restschuld, last_row = get_last_value(ws, restschuld_col) +start_datum = get_first_date(ws) +monatsrate = get_monthly_rate(ws, tilgung_col) +zinssatz = get_zinssatz_from_berechnung(ws, zinsen_col, restschuld_col) + +print(f" Restschuld: {restschuld:,.2f} EUR") +print(f" Monatsrate: {monatsrate:,.2f} EUR") +print(f" Zinssatz: {zinssatz}% (berechnet)") +print(f" Startdatum: {start_datum}") +print(f" Letzte Zeile mit Daten: {last_row}") + +# PSD Nord +print("\n2. PSD NORD") +print("-" * 40) +col_base = 13 +restschuld_col = col_base +tilgung_col = col_base + 1 +zinsen_col = col_base + 2 + +restschuld, last_row = get_last_value(ws, restschuld_col) +monatsrate = get_monthly_rate(ws, tilgung_col) +zinssatz = get_zinssatz_from_berechnung(ws, zinsen_col, restschuld_col) + +print(f" Restschuld: {restschuld:,.2f} EUR") +print(f" Monatsrate: {monatsrate:,.2f} EUR") +print(f" Zinssatz: {zinssatz}% (berechnet)") +print(f" Startdatum: {start_datum}") + +# Zingelstr. 14 (kombiniert DSL + Sparkasse?) +print("\n3. ZINGELSTR. 14 (Gesamt)") +print("-" * 40) +col_base = 20 +restschuld_col = col_base +rate_col = col_base + 1 +zinsen_col = col_base + 2 + +restschuld, last_row = get_last_value(ws, restschuld_col) +monatsrate = get_monthly_rate(ws, rate_col) +zinssatz = get_zinssatz_from_berechnung(ws, zinsen_col, restschuld_col) + +print(f" Restschuld: {restschuld:,.2f} EUR") +print(f" Monatsrate: {monatsrate:,.2f} EUR") +print(f" Zinssatz: {zinssatz}% (berechnet)") +print(f" Startdatum: {start_datum}") + +# Zeige einige Zeilen der Zingelstr. 14 Spalten +print("\n Erste 5 Tilgungszeilen für Zingelstr. 14:") +for row in range(6, 11): + monat = ws.cell(row=row, column=1).value + rest = ws.cell(row=row, column=20).value + rate = ws.cell(row=row, column=21).value + zins = ws.cell(row=row, column=22).value + print(f" {row}: Monat={monat}, Rest={rest}, Rate={rate}, Zins={zins}") + +print("\n4. SUCHE NACH PVCreditplus...") +print("-" * 40) +# Suche nach PVCreditplus in Zeile 1 +pvc_col = None +for col in range(1, ws.max_column + 1): + val = ws.cell(row=1, column=col).value + if val and "pvc" in str(val).lower(): + pvc_col = col + print(f" GEFUNDEN in Spalte {col}: {val}") + break + +if not pvc_col: + print(" PVCreditplus nicht im Sheet 'Tilgung bei Gleichbleibenden Be' gefunden!") + print(" Suche in anderen Sheets...") + for sheet_name in wb.sheetnames: + print(f" Checking {sheet_name}...") + temp_ws = wb[sheet_name] + for row in range(1, min(5, temp_ws.max_row + 1)): + for col in range(1, min(20, temp_ws.max_column + 1)): + val = temp_ws.cell(row=row, column=col).value + if val and "pvc" in str(val).lower(): + print(f" GEFUNDEN in Sheet '{sheet_name}', Zeile {row}, Spalte {col}: {val}") + +print("\n5. SUCHE NACH SPARKASSE...") +print("-" * 40) +# Suche nach Sparkasse in Zeile 1 +sparkasse_col = None +for col in range(1, ws.max_column + 1): + val = ws.cell(row=1, column=col).value + if val and "sparkasse" in str(val).lower(): + sparkasse_col = col + print(f" GEFUNDEN in Spalte {col}: {val}") + # Zeige die zugehörigen Labels + label = ws.cell(row=2, column=col).value + label2 = ws.cell(row=2, column=col+1).value + print(f" Labels: {label}, {label2}") + break + +# Jetzt die Restschuld und Rate holen +if sparkasse_col: + restschuld, last_row = get_last_value(ws, sparkasse_col) + monatsrate = get_monthly_rate(ws, sparkasse_col + 1) + zinssatz = get_zinssatz_from_berechnung(ws, sparkasse_col + 2, sparkasse_col) + print(f" Restschuld: {restschuld:,.2f} EUR") + print(f" Monatsrate: {monatsrate:,.2f} EUR") + print(f" Zinssatz: {zinssatz}% (berechnet)") + +print("\n=== ZUSAMMENFASSUNG ===") +print(""" +Die 5 Kredite aus dem Task: +1. DSL Bank -> Spalte 9 in Sheet 'Tilgung bei Gleichbleibenden Be' +2. PSD Nord -> Spalte 13 in Sheet 'Tilgung bei Gleichbleibenden Be' +3. Zingelstr.14 DSL -> Teil von Zingelstr. 14 (Spalte 20) oder separat? +4. Zingelstr.14 Sparkasse -> Gefunden in Spalte 21! +5. PVCreditplus -> NICHT im Tilgung-Sheet gefunden! + +WICHTIG: Die Werte für Zingelstr. 14 scheinen KOMBINIERT zu sein (DSL + Sparkasse) +Für separate Importe müssen wir die Einzelwerte finden! +""") diff --git a/extract_nebenkosten.py b/extract_nebenkosten.py new file mode 100644 index 0000000..4a9eb5d --- /dev/null +++ b/extract_nebenkosten.py @@ -0,0 +1,26 @@ +import openpyxl +import json +import os + +os.chdir(r'C:\Users\renet\.openclaw\workspace\buchhaltungs-app') + +# Excel-Datei laden +wb = openpyxl.load_workbook('Nebenkosten 2020.xlsx', data_only=True) + +print(f"Sheets gefunden: {wb.sheetnames}") +print() + +# Alle Sheets analysieren +for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + print(f"\n=== {sheet_name} ===") + + # Zeilen 1-25 für Header und Struktur + for row in range(1, 26): + values = [] + for col in range(1, 15): # Spalten A-O + cell = ws.cell(row=row, column=col) + val = cell.value + values.append(str(val) if val is not None else "") + if any(values): + print(f"Zeile {row}: {values}") diff --git a/final_analysis.py b/final_analysis.py new file mode 100644 index 0000000..a9fff1d --- /dev/null +++ b/final_analysis.py @@ -0,0 +1,85 @@ +import openpyxl + +file_path = 'Kopie von Kostenrechnung der Nächsten jahre (3).xlsx' +wb = openpyxl.load_workbook(file_path, data_only=True) +sheet = wb['Tilgung bei Gleichbleibenden Be'] + +print('=== ENDGÜLTIGE KREDIT-ANALYSE APRIL 2026 ===') +print() + +# KREDIT 1: DSL Bank (Spalte 9) +dsl_rest = sheet.cell(189, 9).value +dsl_rate = sheet.cell(189, 10).value +print('KREDIT 1: DSL Bank') +print(f' Spalte: 9 (Restschuld), 10 (Rate)') +print(f' Restschuld Apr 2026: {dsl_rest:,.2f} €' if dsl_rest else ' Restschuld: None') +print(f' Monatsrate: {dsl_rate} €' if dsl_rate else ' Rate: None') +print() + +# KREDIT 2: PSD Nord (Spalte 13) +psd_rest = sheet.cell(189, 13).value +psd_rate = sheet.cell(189, 14).value +print('KREDIT 2: PSD Nord') +print(f' Spalte: 13 (Restschuld), 14 (Rate)') +print(f' Restschuld Apr 2026: {psd_rest:,.2f} €' if psd_rest else ' Restschuld: None') +print(f' Monatsrate: {psd_rate} €' if psd_rate else ' Rate: None') +print() + +# KREDIT 3: Zingelstr. 14 - Sparkasse (Spalten 20, 21, 28) +print('=== ZINGELSTR. 14 ANAYLSE ===') +print() + +# Wie interpretieren wir die Daten? +# Option 1: Zingelstr. 14 = 1 Kredit, Sparkasse = Header +# Option 2: Zingelstr. 14 ist ein Überbegriff, Sparkasse und DSL sind 2 Kredite + +zingel_rest = sheet.cell(189, 20).value +sparkasse_rate = sheet.cell(189, 21).value +sparkasse_zins = sheet.cell(189, 22).value + +print('Spalte 20 (Zingelstr. 14):') +print(f' Restschuld: {zingel_rest:,.2f} €' if zingel_rest else ' Restschuld: None') +print() + +print('Spalte 21 (Sparkasse):') +print(f' Rate: {sparkasse_rate} €') +print() + +print('Spalte 28 (kein Header):') +val28 = sheet.cell(189, 28).value +print(f' Wert: {val28:,.2f} €' if val28 else ' Wert: None') +print() + +# PRÜFUNG: Ist Spalte 28 = Zingelstr.14 - Sparkasse Restschuld? +print('=== MATHEMATISCHE PRÜFUNG ===') +sum_check = (dsl_rest or 0) + (psd_rest or 0) + (zingel_rest or 0) +gesamt = sheet.cell(189, 24).value + +print(f'DSL (9) + PSD (13) + Zingel (20) = {sum_check:,.2f} €') +print(f'Gesamt (24) = {gesamt:,.2f} €') +print(f'Differenz = {gesamt - sum_check:,.2f} €') + +# Zingelstr.14 + Spalte 28 +sum_check2 = (dsl_rest or 0) + (psd_rest or 0) + (zingel_rest or 0) + (val28 or 0) +print(f'\\nDSL (9) + PSD (13) + Zingel (20) + Col28 = {sum_check2:,.2f} €') +print(f'Das passt zu Gesamt (24)!' if abs(sum_check2 - gesamt) < 0.01 else f'Passt nicht: Differenz {sum_check2 - gesamt}') +print() + +print('=== ERGEBNIS ===') +print('Es gibt 4 aktive Kredite im April 2026:') +print() +print('1. DSL Bank (Spalte 9)') +print(f' Restschuld: {dsl_rest:,.2f} €') +print(f' Rate: {dsl_rate} €') +print() +print('2. PSD Nord (Spalte 13)') +print(f' Restschuld: {psd_rest:,.2f} €') +print(f' Rate: {psd_rate} €') +print() +print('3. Zingelstr. 14 - DSL (Spalte 20) - oder Gesamt für Zingelstr?') +print(f' Restschuld: {zingel_rest:,.2f} €') +print(f' Rate: unbekannt (nicht in Spalten 21/22)') +print() +print('4. Sparkasse (Spalte 28 - Restschuld, Spalte 21 - Rate)') +print(f' Restschuld: {val28:,.2f} €') +print(f' Rate: {sparkasse_rate} €') diff --git a/fix_niki_restschuld.js b/fix_niki_restschuld.js new file mode 100644 index 0000000..118f14c --- /dev/null +++ b/fix_niki_restschuld.js @@ -0,0 +1,80 @@ +/** + * Restschuld für Niki korrigieren + */ + +const http = require('http'); + +function apiCall(method, endpoint, data = null) { + return new Promise((resolve, reject) => { + const options = { + hostname: 'localhost', + port: 3001, + path: `/api${endpoint}`, + method: method, + headers: { + 'Content-Type': 'application/json' + } + }; + + const req = http.request(options, (res) => { + let responseData = ''; + res.on('data', (chunk) => responseData += chunk); + res.on('end', () => { + try { + const parsed = JSON.parse(responseData); + resolve(parsed); + } catch (e) { + resolve(responseData); + } + }); + }); + + req.on('error', reject); + if (data) req.write(JSON.stringify(data)); + req.end(); + }); +} + +async function main() { + console.log('Korrigiere Restschuld für Niki...\n'); + + // Alle Kredite holen + const kredite = await apiCall('GET', '/kredite'); + const nikiKredit = kredite.find(k => k.name?.toLowerCase().includes('niki')); + + if (!nikiKredit) { + console.log('Kredit nicht gefunden'); + return; + } + + console.log(`Gefunden: ${nikiKredit.name} (${nikiKredit.id})`); + console.log(`Aktuelle Restschuld: ${nikiKredit.restschuld} €`); + console.log(`Ursprungsschuld: ${nikiKredit.ursprungsschuld} €`); + + // Zahlungen holen + const zahlungen = await apiCall('GET', `/kredite/${nikiKredit.id}/zahlungen`); + + const sumZahlungen = zahlungen + .filter(z => z.typ === 'zahlung') + .reduce((sum, z) => sum + parseFloat(z.betrag), 0); + const sumAuslagen = zahlungen + .filter(z => z.typ === 'auslage') + .reduce((sum, z) => sum + parseFloat(z.betrag), 0); + + console.log(`\nSumme Zahlungen: ${sumZahlungen} €`); + console.log(`Summe Auslagen: ${sumAuslagen} €`); + + // Korrekte Restschuld berechnen + const korrekteRestschuld = 7000 - sumZahlungen + Math.abs(sumAuslagen); + console.log(`\nBerechnete Restschuld: ${korrekteRestschuld} €`); + + // Patch via PUT + const updateResult = await apiCall('PUT', `/kredite/${nikiKredit.id}`, { + ...nikiKredit, + restschuld: korrekteRestschuld + }); + + console.log(`\n✅ Restschuld korrigiert: ${updateResult.restschuld} €`); +} + +main().catch(console.error); diff --git a/fix_niki_restschuld.py b/fix_niki_restschuld.py new file mode 100644 index 0000000..ee46dbb --- /dev/null +++ b/fix_niki_restschuld.py @@ -0,0 +1,39 @@ +import urllib.request +import json + +kredit_id = '4ad8826f-ecb4-443d-aef6-ce9162e5f078' + +# Berechne korrekte Restschuld fuer Forderung +# Start: 7000 +# Zahlungen von Niki: +3400 (erhoeht die Forderung) +# Auslagen (Rene gibt Geld): -505 (verringert die Forderung) +# Erwartet: 7000 - 2895 = 4105 + +correct_restschuld = 4105.00 + +print("=" * 60) +print("KORREKTUR: Restschuld fuer Niki Forderung") +print("=" * 60) + +# Update Restschuld +url = f'http://localhost:3001/api/kredite/{kredit_id}' +data = json.dumps({ + 'restschuld': correct_restschuld, + 'notizen': 'Korrigierte Forderung - Rene bekommt Geld von Niki' +}).encode() + +req = urllib.request.Request(url, data=data, headers={'Content-Type': 'application/json'}, method='PUT') + +try: + with urllib.request.urlopen(req) as response: + result = json.loads(response.read().decode()) + print(f"[OK] Restschuld aktualisiert: {result['restschuld']} EUR") + print(f"[OK] Richtung: {result.get('richtung', 'N/A')}") +except Exception as e: + print(f"[ERR] Fehler beim Update: {e}") + +print("\n" + "=" * 60) +print("KORREKTUR ABGESCHLOSSEN") +print("=" * 60) +print(f"\nKorrigierte Restschuld: {correct_restschuld} EUR") +print("Berechnung: 7000 (Start) - 2895 (Netto-Zahlungen) = 4105 EUR") diff --git a/fix_restschuld.js b/fix_restschuld.js new file mode 100644 index 0000000..118f14c --- /dev/null +++ b/fix_restschuld.js @@ -0,0 +1,80 @@ +/** + * Restschuld für Niki korrigieren + */ + +const http = require('http'); + +function apiCall(method, endpoint, data = null) { + return new Promise((resolve, reject) => { + const options = { + hostname: 'localhost', + port: 3001, + path: `/api${endpoint}`, + method: method, + headers: { + 'Content-Type': 'application/json' + } + }; + + const req = http.request(options, (res) => { + let responseData = ''; + res.on('data', (chunk) => responseData += chunk); + res.on('end', () => { + try { + const parsed = JSON.parse(responseData); + resolve(parsed); + } catch (e) { + resolve(responseData); + } + }); + }); + + req.on('error', reject); + if (data) req.write(JSON.stringify(data)); + req.end(); + }); +} + +async function main() { + console.log('Korrigiere Restschuld für Niki...\n'); + + // Alle Kredite holen + const kredite = await apiCall('GET', '/kredite'); + const nikiKredit = kredite.find(k => k.name?.toLowerCase().includes('niki')); + + if (!nikiKredit) { + console.log('Kredit nicht gefunden'); + return; + } + + console.log(`Gefunden: ${nikiKredit.name} (${nikiKredit.id})`); + console.log(`Aktuelle Restschuld: ${nikiKredit.restschuld} €`); + console.log(`Ursprungsschuld: ${nikiKredit.ursprungsschuld} €`); + + // Zahlungen holen + const zahlungen = await apiCall('GET', `/kredite/${nikiKredit.id}/zahlungen`); + + const sumZahlungen = zahlungen + .filter(z => z.typ === 'zahlung') + .reduce((sum, z) => sum + parseFloat(z.betrag), 0); + const sumAuslagen = zahlungen + .filter(z => z.typ === 'auslage') + .reduce((sum, z) => sum + parseFloat(z.betrag), 0); + + console.log(`\nSumme Zahlungen: ${sumZahlungen} €`); + console.log(`Summe Auslagen: ${sumAuslagen} €`); + + // Korrekte Restschuld berechnen + const korrekteRestschuld = 7000 - sumZahlungen + Math.abs(sumAuslagen); + console.log(`\nBerechnete Restschuld: ${korrekteRestschuld} €`); + + // Patch via PUT + const updateResult = await apiCall('PUT', `/kredite/${nikiKredit.id}`, { + ...nikiKredit, + restschuld: korrekteRestschuld + }); + + console.log(`\n✅ Restschuld korrigiert: ${updateResult.restschuld} €`); +} + +main().catch(console.error); diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..684b410 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,18 @@ +# Build stage +FROM node:18-alpine AS builder + +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +# Production stage +FROM nginx:alpine + +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev new file mode 100644 index 0000000..9aaee90 --- /dev/null +++ b/frontend/Dockerfile.dev @@ -0,0 +1,8 @@ +FROM node:20-alpine +WORKDIR /app +RUN npm install -g nodemon +COPY package*.json ./ +RUN npm install +COPY . . +EXPOSE 5173 +CMD ["npm", "run", "dev", "--", "--host"] diff --git a/frontend/build.js b/frontend/build.js new file mode 100644 index 0000000..4c43488 --- /dev/null +++ b/frontend/build.js @@ -0,0 +1,13 @@ +import { build } from 'vite'; +try { + const result = await build(); + console.log('BUILD OK'); + if (result?.output) { + result.output.forEach(o => console.log(' →', o.fileName, o.type, o.code?.length || '')); + } +} catch (e) { + console.error('BUILD ERROR:', e.message); + if (e.loc) console.error('Location:', JSON.stringify(e.loc)); + if (e.frame) console.error(e.frame); + console.error(e.stack); +} \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..fed62e7 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + SteuerFlow - Buchhaltung + + + +

+ + + \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..4a16763 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,25 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_cache_bypass $http_upgrade; + } + + location /uploads { + proxy_pass http://backend:3000; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1739e3d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "buchhaltungs-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "lucide-react": "^0.294.0" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.3.6", + "vite": "^5.0.8" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/logo.png b/frontend/public/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..e8427b3cfdb1261e3c29630c6a26b156169592e7 GIT binary patch literal 20132 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 SsXR!UA-e^J7*y&z!v6yZwtP$g literal 0 HcmV?d00001 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..852e377 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,346 @@ +import { useState, useEffect } from 'react'; +import { + LayoutDashboard, FileText, Clock, CreditCard, Home, TrendingUp, LogOut, Users, + ClipboardCheck, Building2, Calculator, ChevronRight, ChevronDown, Settings, + Briefcase, DollarSign, BarChart3 +} from 'lucide-react'; +import Dashboard from './components/Dashboard'; +import Stunden from './components/Stunden'; +import Kredite from './components/Kredite'; +import PrivateKosten from './components/PrivateKosten'; +import Dokumente from './components/Dokumente'; +import Geschaeftsplanung from './components/Geschaeftsplanung'; +import Auftragsnachweis from './components/Auftragsnachweis'; +import Objekte from './components/nebenkosten/Objekte'; +import Mieter from './components/nebenkosten/Mieter'; +import Nebenkostenabrechnung from './components/nebenkosten/Nebenkostenabrechnung'; +import VermietungsBilanz from './components/nebenkosten/VermietungsBilanz'; +import { AuthProvider, useAuth, ProtectedRoute } from './contexts/AuthContext'; +import Login from './components/auth/Login'; +import SetupWizard from './components/auth/SetupWizard'; +import UserManagement from './components/auth/user-management/UserManagement'; + +const API_URL = '/api'; + +function AppContent() { + const { isAuthenticated, loading, needsSetup, logout, user, isAdmin } = useAuth(); + const [activeTab, setActiveTab] = useState('dashboard'); + const [apiStatus, setApiStatus] = useState('loading'); + const [expandedFolders, setExpandedFolders] = useState(['privat', 'geschaeftlich', 'vermietung', 'steuer', 'auftragsnachweis']); + + useEffect(() => { + if (isAuthenticated) { + fetch(`${API_URL}/health`) + .then(r => r.json()) + .then(data => { + console.log('API OK:', data); + setApiStatus('ok'); + }) + .catch(err => { + console.error('API Error:', err); + setApiStatus('error'); + }); + } + }, [isAuthenticated]); + + const toggleFolder = (folderId) => { + setExpandedFolders(prev => + prev.includes(folderId) + ? prev.filter(id => id !== folderId) + : [...prev, folderId] + ); + }; + + // Dashboard separat ganz oben + const dashboardItem = { id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard }; + + const menuStructure = [ + { + id: 'privat', + label: 'Privat', + icon: Home, + items: [ + { id: 'privat-kredite', label: 'Kredit-Übersicht', icon: CreditCard }, + { id: 'privat-kosten', label: 'Private Kosten', icon: DollarSign }, + ] + }, + { + id: 'geschaeftlich', + label: 'Geschäftlich', + icon: Briefcase, + items: [ + { id: 'stunden', label: 'Stunden & Pauschalen', icon: Clock }, + { id: 'auftragsnachweis', label: 'Auftragsnachweis', icon: ClipboardCheck }, + { id: 'planung', label: 'Geschäftsplanung', icon: TrendingUp }, + ] + }, + { + id: 'vermietung', + label: 'Vermietung', + icon: Building2, + items: [ + { id: 'objekte', label: 'Objekte', icon: Home }, + { id: 'mieter', label: 'Mieter', icon: Users }, + { id: 'nk-abrechnung', label: 'NK-Abrechnung', icon: Calculator }, + { id: 'vermietungs-bilanz', label: 'Vermietungs-Bilanz', icon: BarChart3 }, + ] + }, + { + id: 'steuer', + label: 'Steuer', + icon: FileText, + items: [ + { id: 'docs', label: 'Dokumente', icon: FileText }, + ] + }, + ]; + + // Admin separat + const adminItem = { id: 'users', label: 'Benutzerverwaltung', icon: Settings }; + + // Setup Wizard anzeigen wenn keine User existieren + if (needsSetup) { + return ; + } + + // Login anzeigen wenn nicht eingeloggt + if (!isAuthenticated && !loading) { + return ; + } + + if (apiStatus === 'loading' && isAuthenticated) { + return ( +
+
⏳ Lade SteuerFlow...
+
+ ); + } + + if (apiStatus === 'error') { + return ( +
+
+

❌ Backend nicht erreichbar

+

Bitte überprüfe ob Docker läuft.

+ +
+
+ ); + } + + return ( +
+ {/* Header oben */} +
+
+ {/* Links: Logo + Titel */} +
+ Finanz Flow Logo { e.target.style.display = 'none'; }} + /> +
+

Finanz Flow

+ by Taeger IT +
+
+ + {/* Rechts: Status + User Info */} +
+ + + ✅ Online + + {user?.username} +
+
+
+ + {/* Content-Bereich mit Sidebar */} +
+ {/* Sidebar links */} + + + {/* Main Content */} +
+ + {activeTab === 'dashboard' && } + {activeTab === 'stunden' && } + {activeTab === 'auftragsnachweis' && } + {activeTab === 'privat-kredite' && } + {activeTab === 'privat-kosten' && } + + {activeTab === 'docs' && } + {activeTab === 'planung' && } + {activeTab === 'objekte' && } + {activeTab === 'mieter' && } + {activeTab === 'nk-abrechnung' && } + {activeTab === 'vermietungs-bilanz' && } + {activeTab === 'users' && isAdmin && } + {!['dashboard', 'stunden', 'auftragsnachweis', 'kredite', 'docs', 'planung', 'objekte', 'mieter', 'nk-abrechnung', 'users', 'privat-kredite', 'privat-kosten', 'vermietungs-bilanz'].includes(activeTab) && ( +
+

+ ⏳ Lade... +

+

Diese Komponente wird noch geladen...

+
+ )} +
+
+
+
+ ); +} + +export default function App() { + return ( + + + + ); +} diff --git a/frontend/src/api-nebenkosten.js b/frontend/src/api-nebenkosten.js new file mode 100644 index 0000000..f9d8c61 --- /dev/null +++ b/frontend/src/api-nebenkosten.js @@ -0,0 +1,221 @@ +// API Service für Nebenkosten-Module +const API_URL = '/api'; + +// Objekte API +export const objekteAPI = { + async getAll() { + const res = await fetch(`${API_URL}/objekte`); + if (!res.ok) throw new Error('Fehler beim Laden der Objekte'); + return res.json(); + }, + + async getById(id) { + const res = await fetch(`${API_URL}/objekte/${id}`); + if (!res.ok) throw new Error('Objekt nicht gefunden'); + return res.json(); + }, + + async create(data) { + const res = await fetch(`${API_URL}/objekte`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error('Fehler beim Erstellen'); + return res.json(); + }, + + async update(id, data) { + const res = await fetch(`${API_URL}/objekte/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error('Fehler beim Aktualisieren'); + return res.json(); + }, + + async delete(id) { + const res = await fetch(`${API_URL}/objekte/${id}`, { + method: 'DELETE', + }); + if (!res.ok) throw new Error('Fehler beim Löschen'); + return res.json(); + }, + + async getKosten(objektId, jahr) { + const res = await fetch(`${API_URL}/objekte/${objektId}/kosten?jahr=${jahr}`); + if (!res.ok) throw new Error('Fehler beim Laden der Kosten'); + return res.json(); + }, + + async addKosten(objektId, data) { + const res = await fetch(`${API_URL}/objekte/${objektId}/kosten`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error('Fehler beim Hinzufügen der Kosten'); + return res.json(); + }, + + async deleteKosten(objektId, kostenId) { + const res = await fetch(`${API_URL}/objekte/${objektId}/kosten/${kostenId}`, { + method: 'DELETE', + }); + if (!res.ok) throw new Error('Fehler beim Löschen der Kosten'); + return res.json(); + }, +}; + +// Mieter API +export const mieterAPI = { + async getAll() { + const res = await fetch(`${API_URL}/mieter`); + if (!res.ok) throw new Error('Fehler beim Laden der Mieter'); + return res.json(); + }, + + async getById(id) { + const res = await fetch(`${API_URL}/mieter/${id}`); + if (!res.ok) throw new Error('Mieter nicht gefunden'); + return res.json(); + }, + + async create(data) { + const res = await fetch(`${API_URL}/mieter`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error('Fehler beim Erstellen'); + return res.json(); + }, + + async createWithContract(data) { + const res = await fetch(`${API_URL}/mieter-mit-vertrag`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error('Fehler beim Erstellen'); + return res.json(); + }, + + async update(id, data) { + const res = await fetch(`${API_URL}/mieter/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error('Fehler beim Aktualisieren'); + return res.json(); + }, + + async updateWithContract(id, data) { + const res = await fetch(`${API_URL}/mieter/${id}/mit-vertrag`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error('Fehler beim Aktualisieren'); + return res.json(); + }, + + async delete(id) { + const res = await fetch(`${API_URL}/mieter/${id}`, { + method: 'DELETE', + }); + if (!res.ok) throw new Error('Fehler beim Löschen'); + return res.json(); + }, + + async getMietvertraege(mieterId) { + const res = await fetch(`${API_URL}/mieter/${mieterId}/mietvertraege`); + if (!res.ok) throw new Error('Fehler beim Laden der Mietverträge'); + return res.json(); + }, + + async getVorauszahlungen(mieterId, jahr) { + const res = await fetch(`${API_URL}/mieter/${mieterId}/vorauszahlungen?jahr=${jahr}`); + if (!res.ok) throw new Error('Fehler beim Laden der Vorauszahlungen'); + return res.json(); + }, + + async addVorauszahlung(mieterId, data) { + const res = await fetch(`${API_URL}/mieter/${mieterId}/vorauszahlungen`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error('Fehler beim Hinzufügen'); + return res.json(); + }, + + async deleteVorauszahlung(mieterId, vorauszahlungId) { + const res = await fetch(`${API_URL}/mieter/${mieterId}/vorauszahlungen/${vorauszahlungId}`, { + method: 'DELETE', + }); + if (!res.ok) throw new Error('Fehler beim Löschen'); + return res.json(); + }, +}; + +// Nebenkostenabrechnung API +export const nebenkostenabrechnungAPI = { + async getAll() { + const res = await fetch(`${API_URL}/nebenkostenabrechnungen`); + if (!res.ok) throw new Error('Fehler beim Laden der Abrechnungen'); + return res.json(); + }, + + async getById(id) { + const res = await fetch(`${API_URL}/nebenkostenabrechnungen/${id}`); + if (!res.ok) throw new Error('Abrechnung nicht gefunden'); + return res.json(); + }, + + async vorschau(data) { + const res = await fetch(`${API_URL}/nebenkostenabrechnung/vorschau`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error('Fehler bei der Vorschau'); + return res.json(); + }, + + async create(data) { + const res = await fetch(`${API_URL}/nebenkostenabrechnung`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error('Fehler beim Erstellen'); + return res.json(); + }, + + async delete(id) { + const res = await fetch(`${API_URL}/nebenkostenabrechnungen/${id}`, { + method: 'DELETE', + }); + if (!res.ok) throw new Error('Fehler beim Löschen'); + return res.json(); + }, + + async downloadPDF(id, entwurf = false) { + const url = `${API_URL}/nebenkostenabrechnung/${id}/pdf${entwurf ? '?entwurf=true' : ''}`; + const res = await fetch(url, { method: 'POST' }); + if (!res.ok) throw new Error('Fehler beim PDF-Download'); + return res.blob(); + }, +}; + +// Vermietungsbilanz API +export const vermietungsbilanzAPI = { + async getBilanz(jahr) { + const res = await fetch(`${API_URL}/vermietungsbilanz?jahr=${jahr}`); + if (!res.ok) throw new Error('Fehler beim Laden der Bilanz'); + return res.json(); + }, +}; \ No newline at end of file diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..c1ecfe0 --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,314 @@ +// API Service für alle Backend-Calls +// Docker: Frontend (nginx:80) -> Backend (3001) via nginx proxy +const API_URL = '/api'; + +// Helper für API Calls +async function apiCall(endpoint, options = {}) { + const url = `${API_URL}${endpoint}`; + const config = { + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + ...options, + }; + + if (options.body && typeof options.body === 'object') { + config.body = JSON.stringify(options.body); + } + + try { + const response = await fetch(url, config); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(error.error || `HTTP ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error(`API Error (${endpoint}):`, error); + throw error; + } +} + +// Kredite API +export const krediteAPI = { + getAll: (params = {}) => { + const queryParams = new URLSearchParams(params).toString(); + return apiCall(`/kredite${queryParams ? '?' + queryParams : ''}`); + }, + + create: (data) => apiCall('/kredite', { + method: 'POST', + body: data, + }), + + update: (id, data) => apiCall(`/kredite/${id}`, { + method: 'PUT', + body: data, + }), + + // PATCH für partielle Updates (z.B. nur Restschuld) + patch: (id, data) => apiCall(`/kredite/${id}`, { + method: 'PATCH', + body: data, + }), + + delete: (id) => apiCall(`/kredite/${id}`, { + method: 'DELETE', + }), + + getBuchungen: (id) => apiCall(`/kredite/${id}/buchungen`), + + addBuchung: (id, data) => apiCall(`/kredite/${id}/buchungen`, { + method: 'POST', + body: data, + }), + + deleteBuchung: (id, buchungId) => apiCall(`/kredite/${id}/buchungen/${buchungId}`, { + method: 'DELETE', + }), + + // Zahlungen API (Alias für Buchungen) + getZahlungen: (id) => apiCall(`/kredite/${id}/zahlungen`), + + addZahlung: (id, data) => apiCall(`/kredite/${id}/zahlungen`, { + method: 'POST', + body: data, + }), + + deleteZahlung: (id, zahlungId) => apiCall(`/kredite/${id}/zahlungen/${zahlungId}`, { + method: 'DELETE', + }), +}; + +// Stunden API +export const stundenAPI = { + getAll: (params = {}) => { + const queryParams = new URLSearchParams(params).toString(); + return apiCall(`/stunden${queryParams ? '?' + queryParams : ''}`); + }, + + create: (data) => apiCall('/stunden', { + method: 'POST', + body: data, + }), + + update: (id, data) => apiCall(`/stunden/${id}`, { + method: 'PUT', + body: data, + }), + + delete: (id) => apiCall(`/stunden/${id}`, { + method: 'DELETE', + }), +}; + +// Nebenkosten API +export const nebenkostenAPI = { + getAll: (params = {}) => { + const queryParams = new URLSearchParams(params).toString(); + return apiCall(`/nebenkosten${queryParams ? '?' + queryParams : ''}`); + }, + + create: (data) => apiCall('/nebenkosten', { + method: 'POST', + body: data, + }), + + update: (id, data) => apiCall(`/nebenkosten/${id}`, { + method: 'PUT', + body: data, + }), + + delete: (id) => apiCall(`/nebenkosten/${id}`, { + method: 'DELETE', + }), +}; + +// Kostenplanung API +export const kostenplanungAPI = { + getAll: (params = {}) => { + const queryParams = new URLSearchParams(params).toString(); + return apiCall(`/kostenplanung${queryParams ? '?' + queryParams : ''}`); + }, + + create: (data) => apiCall('/kostenplanung', { + method: 'POST', + body: data, + }), + + update: (id, data) => apiCall(`/kostenplanung/${id}`, { + method: 'PUT', + body: data, + }), + + delete: (id) => apiCall(`/kostenplanung/${id}`, { + method: 'DELETE', + }), +}; + +// Geschäftsplanung API (neu) +export const geschaeftsplanungAPI = { + // Monatliche Übersicht mit Ergebnis und Cashflow + getMonatlich: (jahr) => apiCall(`/geschaeftsplanung/monatlich?jahr=${jahr}`), + + // Kategorie-Übersicht + getKategorien: (jahr) => apiCall(`/geschaeftsplanung/kategorien?jahr=${jahr}`), + + // Miete vs Nebenkosten pro Objekt + getObjektVergleich: (objekt, jahr) => apiCall(`/geschaeftsplanung/objektvergleich/${objekt}?jahr=${jahr}`), + + // Umsatzvorschau (nur Einnahmen) + getUmsatzVorschau: (jahr) => apiCall(`/geschaeftsplanung/umsatzvorschau?jahr=${jahr}`), +}; + +// Kunden API (für Auftragsnachweise) +export const kundenAPI = { + getAll: () => apiCall('/kunden'), + + create: (data) => apiCall('/kunden', { + method: 'POST', + body: data, + }), + + update: (id, data) => apiCall(`/kunden/${id}`, { + method: 'PUT', + body: data, + }), + + delete: (id) => apiCall(`/kunden/${id}`, { + method: 'DELETE', + }), +}; + +// Auftragsnachweise API +export const auftragsnachweisAPI = { + getAll: (params = {}) => { + const queryParams = new URLSearchParams(params).toString(); + return apiCall(`/auftragsnachweise${queryParams ? '?' + queryParams : ''}`); + }, + + getById: (id) => apiCall(`/auftragsnachweise/${id}`), + + create: (data) => { + // Stelle sicher, dass art_der_arbeit ein Array ist + const sanitizedData = { + ...data, + art_der_arbeit: Array.isArray(data.art_der_arbeit) + ? data.art_der_arbeit + : (data.art_der_arbeit ? [data.art_der_arbeit] : []), + stunden_ids: Array.isArray(data.stunden_ids) + ? data.stunden_ids + : (data.stunden_ids ? [data.stunden_ids] : []), + }; + return apiCall('/auftragsnachweise', { + method: 'POST', + body: sanitizedData, + }); + }, + + delete: (id) => apiCall(`/auftragsnachweise/${id}`, { + method: 'DELETE', + }), + + // PDF generieren und herunterladen + generatePDF: async (id, firmenname) => { + const response = await fetch(`${API_URL}/auftragsnachweise/${id}/pdf`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ firmenname }), + }); + + if (!response.ok) { + // Bei Fehler erst als Text versuchen, dann als JSON + const errorText = await response.text().catch(() => 'Unknown error'); + let errorMessage = errorText; + try { + const errorJson = JSON.parse(errorText); + errorMessage = errorJson.error || errorText; + } catch (e) { + // Nicht JSON, Text verwenden + } + throw new Error(errorMessage || `HTTP ${response.status}`); + } + + // Blob für Download + const blob = await response.blob(); + if (blob.size === 0) { + throw new Error('PDF ist leer'); + } + + // Download-Dateiname aus Header extrahieren oder Default verwenden + const disposition = response.headers.get('Content-Disposition'); + let filename = `auftragsnachweis-${id}.pdf`; + if (disposition && disposition.includes('filename=')) { + const match = disposition.match(/filename="([^"]+)"/); + if (match) filename = match[1]; + } + + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + return true; + }, + + // Vorhandenes PDF herunterladen + downloadPDF: async (id) => { + const response = await fetch(`${API_URL}/auftragsnachweise/${id}/pdf`); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + let errorMessage = errorText; + try { + const errorJson = JSON.parse(errorText); + errorMessage = errorJson.error || errorText; + } catch (e) { + // Nicht JSON, Text verwenden + } + throw new Error(errorMessage || `HTTP ${response.status}`); + } + + const blob = await response.blob(); + if (blob.size === 0) { + throw new Error('PDF ist leer'); + } + + const disposition = response.headers.get('Content-Disposition'); + let filename = `auftragsnachweis-${id}.pdf`; + if (disposition && disposition.includes('filename=')) { + const match = disposition.match(/filename="([^"]+)"/); + if (match) filename = match[1]; + } + + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + return true; + }, +}; + +// Health Check +export const healthCheck = () => apiCall('/health'); + +export default { + kredite: krediteAPI, + stunden: stundenAPI, + nebenkosten: nebenkostenAPI, + kostenplanung: kostenplanungAPI, + geschaeftsplanung: geschaeftsplanungAPI, + health: healthCheck, +}; diff --git a/frontend/src/components/Auftragsnachweis.jsx b/frontend/src/components/Auftragsnachweis.jsx new file mode 100644 index 0000000..e2da6c0 --- /dev/null +++ b/frontend/src/components/Auftragsnachweis.jsx @@ -0,0 +1,778 @@ +import { useState, useEffect } from 'react'; +import { FileText, Plus, Trash2, Download, UserPlus, CheckSquare, X, AlertCircle, Clock, Users, FileCheck, Filter, RotateCcw, Calendar } from 'lucide-react'; +import { auftragsnachweisAPI, kundenAPI, stundenAPI } from '../api'; + +// Art der Arbeit Optionen +const ARBEIT_ARTEN = [ + 'Wartung', + 'Regiebericht', + 'Abnahme', + 'Lieferschein', + 'Installation', + 'Instandsetzung', + 'Systempflege', + 'Schulung', +]; + +export default function Auftragsnachweis() { + const [kunden, setKunden] = useState([]); + const [stunden, setStunden] = useState([]); + const [auftragsnachweise, setAuftragsnachweise] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Form States + const [showKundenForm, setShowKundenForm] = useState(false); + const [showAuftragsnachweisForm, setShowAuftragsnachweisForm] = useState(false); + const [selectedStunden, setSelectedStunden] = useState([]); + + // Filter States + const [filterKunde, setFilterKunde] = useState(''); + const [filterDatumVon, setFilterDatumVon] = useState(''); + const [filterDatumBis, setFilterDatumBis] = useState(''); + const [filterArbeitArten, setFilterArbeitArten] = useState([]); + const [showFilter, setShowFilter] = useState(false); + + // Kunden Form + const [kundeForm, setKundeForm] = useState({ + name: '', + strasse: '', + plz: '', + ort: '', + telefon: '', + email: '', + }); + + // Auftragsnachweis Form + const [auftragsnachweisForm, setAuftragsnachweisForm] = useState({ + kunde_id: '', + datum: new Date().toISOString().split('T')[0], + art_der_arbeit: [], + software_version: '', + durchgefuehrte_arbeiten: '', + geliefertes_material: '', + anlage_betriebsbereit: 'voll', + anfahrt_km: '', + abnahmebestaetigung_text: 'Hiermit bestätige ich, dass die Arbeiten durch Täger IT & Gebäude-Systeme ordnungsgemäß durchgeführt wurden.', + }); + + // Daten laden + useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + try { + setLoading(true); + const [kundenData, stundenData, auftraegeData] = await Promise.all([ + kundenAPI.getAll(), + stundenAPI.getAll({ status: 'offen' }), + auftragsnachweisAPI.getAll(), + ]); + setKunden(kundenData); + setStunden(stundenData); + setAuftragsnachweise(auftraegeData); + setError(null); + } catch (err) { + setError('Fehler beim Laden: ' + err.message); + console.error(err); + } finally { + setLoading(false); + } + }; + + // Kunden CRUD + const addKunde = async () => { + if (!kundeForm.name) return; + + try { + await kundenAPI.create(kundeForm); + await loadData(); + setKundeForm({ + name: '', + strasse: '', + plz: '', + ort: '', + telefon: '', + email: '', + }); + setShowKundenForm(false); + } catch (err) { + setError('Fehler beim Speichern: ' + err.message); + } + }; + + const deleteKunde = async (id) => { + if (confirm('Kunde wirklich löschen?')) { + try { + await kundenAPI.delete(id); + await loadData(); + } catch (err) { + setError('Fehler beim Löschen: ' + err.message); + } + } + }; + + // Stunden Auswahl + const toggleStunde = (id) => { + setSelectedStunden(prev => + prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id] + ); + }; + + // Auftragsnachweis erstellen + const createAuftragsnachweis = async () => { + if (!auftragsnachweisForm.kunde_id) { + setError('Bitte einen Kunden auswählen'); + return; + } + + if (selectedStunden.length === 0) { + setError('Bitte mindestens eine Stunde auswählen'); + return; + } + + try { + const data = { + ...auftragsnachweisForm, + stunden_ids: selectedStunden, + }; + + const result = await auftragsnachweisAPI.create(data); + + // PDF generieren und herunterladen + await auftragsnachweisAPI.generatePDF(result.id, 'Täger IT & Gebäude-Systeme'); + + await loadData(); + setShowAuftragsnachweisForm(false); + setSelectedStunden([]); + setAuftragsnachweisForm({ + kunde_id: '', + datum: new Date().toISOString().split('T')[0], + art_der_arbeit: [], + software_version: '', + durchgefuehrte_arbeiten: '', + geliefertes_material: '', + anlage_betriebsbereit: 'voll', + anfahrt_km: '', + abnahmebestaetigung_text: 'Hiermit bestätige ich, dass die Arbeiten durch Täger IT & Gebäude-Systeme ordnungsgemäß durchgeführt wurden.', + }); + } catch (err) { + setError('Fehler beim Erstellen: ' + err.message); + } + }; + + const deleteAuftragsnachweis = async (id) => { + if (confirm('Auftragsnachweis wirklich löschen?')) { + try { + await auftragsnachweisAPI.delete(id); + await loadData(); + } catch (err) { + setError('Fehler beim Löschen: ' + err.message); + } + } + }; + + const downloadPDF = async (id) => { + try { + await auftragsnachweisAPI.downloadPDF(id); + } catch (err) { + setError('Fehler beim Download: ' + err.message); + } + }; + + // Berechne Summe der ausgewählten Stunden + const selectedStundenDetails = stunden.filter(s => selectedStunden.includes(s.id)); + const totalStunden = selectedStundenDetails.reduce((sum, s) => sum + parseFloat(s.stunden), 0); + const totalBetrag = selectedStundenDetails.reduce((sum, s) => sum + parseFloat(s.betrag), 0); + + if (loading) { + return ( +
+
Lade...
+
+ ); + } + + return ( +
+ {/* Error Message */} + {error && ( +
+ + {error} + +
+ )} + + {/* Kundenverwaltung */} +
+
+
+ +

Adressbuch

+
+ +
+ + {showKundenForm && ( +
+
+
+ + setKundeForm({ ...kundeForm, name: e.target.value })} + className="w-full border border-gray-300 rounded px-3 py-2" + placeholder="Firmenname / Kunde" + /> +
+
+ + setKundeForm({ ...kundeForm, strasse: e.target.value })} + className="w-full border border-gray-300 rounded px-3 py-2" + placeholder="Musterstraße 123" + /> +
+
+ + setKundeForm({ ...kundeForm, plz: e.target.value })} + className="w-full border border-gray-300 rounded px-3 py-2" + placeholder="25554" + /> +
+
+ + setKundeForm({ ...kundeForm, ort: e.target.value })} + className="w-full border border-gray-300 rounded px-3 py-2" + placeholder="Wilster" + /> +
+
+ + setKundeForm({ ...kundeForm, telefon: e.target.value })} + className="w-full border border-gray-300 rounded px-3 py-2" + placeholder="+49..." + /> +
+
+ + setKundeForm({ ...kundeForm, email: e.target.value })} + className="w-full border border-gray-300 rounded px-3 py-2" + placeholder="info@kunde.de" + /> +
+
+ +
+ )} + + {/* Kundenliste */} + {kunden.length > 0 ? ( +
+ + + + + + + + + + + {kunden.map(kunde => ( + + + + + + + ))} + +
NameAdresseKontakt
{kunde.name} + {kunde.strasse &&
{kunde.strasse}
} + {(kunde.plz || kunde.ort) && ( +
{kunde.plz} {kunde.ort}
+ )} +
+ {kunde.telefon &&
📞 {kunde.telefon}
} + {kunde.email &&
✉️ {kunde.email}
} +
+ +
+
+ ) : ( +

Noch keine Kunden angelegt.

+ )} +
+ + {/* Auftragsnachweis erstellen */} +
+
+
+ +

Auftragsnachweis erstellen

+
+ +
+ + {(kunden.length === 0 || stunden.length === 0) && ( +
+ + {kunden.length === 0 && 'Bitte zuerst einen Kunden anlegen. '} + {stunden.length === 0 && 'Bitte zuerst Stunden erfassen.'} +
+ )} + + {showAuftragsnachweisForm && ( +
+ {/* Stunden Auswahl */} +
+

+ + Stunden auswählen +

+ + {stunden.length > 0 ? ( +
+ {stunden.sort((a, b) => new Date(b.datum) - new Date(a.datum)).map(s => ( + + ))} +
+ ) : ( +

Keine offenen Stunden vorhanden.

+ )} + + {selectedStunden.length > 0 && ( +
+ Ausgewählt: {selectedStunden.length} Einträge, + {' '}{totalStunden.toFixed(1)} Stunden, + {' '}{totalBetrag.toFixed(2)}€ +
+ )} +
+ + {/* Auftragsnachweis Formular */} +
+

Auftragsdetails

+ +
+
+ + +
+ +
+ + setAuftragsnachweisForm({ ...auftragsnachweisForm, datum: e.target.value })} + className="w-full border border-gray-300 rounded px-3 py-2" + /> +
+
+ + {/* Art der Arbeit */} +
+ +
+ {ARBEIT_ARTEN.map(art => ( + + ))} +
+
+ +
+ + setAuftragsnachweisForm({ ...auftragsnachweisForm, software_version: e.target.value })} + className="w-full border border-gray-300 rounded px-3 py-2" + placeholder="z.B. AEOS 4.2.1" + /> +
+ +
+ +