commit 6b5f32b16c0c9a354e5597e241ba0afd424a91ba Author: data Date: Thu Mar 5 09:35:16 2026 +0100 feat: Initial commit - Nebenkostenabrechnung v1.0.0 PySide6 GUI-Anwendung zur Erstellung von Nebenkostenabrechnungen für Mietobjekte gemäß § 556 BGB. Funktionen: - Stammdaten-Verwaltung (Vermieter/Mieter) - Kostenarten-Verwaltung - PDF-Erstellung mit Briefkopf-Vorlage - Beleg-Anhänge (PDF, JPG, PNG) - OCR-basierte Seitenrotation und Leerseiten-Filterung - Entwurf-Speicherung und Historie - Startmenü-Integration Co-Authored-By: Claude Opus 4.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1648b97 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +*.pyo +venv/ +.venv/ +*.egg-info/ +dist/ +build/ diff --git a/daten/historie.json b/daten/historie.json new file mode 100644 index 0000000..ed1ff32 --- /dev/null +++ b/daten/historie.json @@ -0,0 +1,23 @@ +{ + "2025": { + "jahr": 2025, + "mieter": "Annemarie und Bastian Schilder", + "kosten": { + "Wasser/Abwasser": 0.0, + "Müllabfuhr": 0.0, + "Schornsteinfeger": 0.0 + }, + "belege": { + "Wasser/Abwasser": [], + "Müllabfuhr": [], + "Schornsteinfeger": [] + }, + "summe_kosten": 0.0, + "vorauszahlung_monat": 70.0, + "anzahl_monate": 12, + "summe_vorauszahlung": 840.0, + "differenz": -840.0, + "status": "fertig", + "datum": "2026-03-05 09:17" + } +} \ No newline at end of file diff --git a/daten/stammdaten.json b/daten/stammdaten.json new file mode 100644 index 0000000..0ef633a --- /dev/null +++ b/daten/stammdaten.json @@ -0,0 +1,25 @@ +{ + "vermieter": { + "name": "Eduard Wisch", + "adresse": "Todtenhemmer Weg 116, 25764 Wesselburen", + "iban": "DE38100777770466026201", + "bic": "NORSDE51XXX", + "bank": "Norisbank" + }, + "mieter": { + "name": "Annemarie und Bastian Schilder", + "adresse": "Koogchaussee 8, 25761 Hedwigenkoog", + "email": "annemarieschilder@gmx.de", + "iban": "", + "bic": "", + "bank": "" + }, + "objekt_adresse": "Koogchaussee 8, 25764 Hedwigenkoog", + "vorauszahlung_monat": 70.0, + "kostenarten": [ + "Wasser/Abwasser", + "Müllabfuhr", + "Schornsteinfeger" + ], + "briefkopf_hoehe": 4.5 +} \ No newline at end of file diff --git a/dokumente/2025/2026-03-05 - Nebenkostenabrechnung - Koogchaussee 8, 25764 Hedwigenkoog.pdf b/dokumente/2025/2026-03-05 - Nebenkostenabrechnung - Koogchaussee 8, 25764 Hedwigenkoog.pdf new file mode 100644 index 0000000..88bd680 Binary files /dev/null and b/dokumente/2025/2026-03-05 - Nebenkostenabrechnung - Koogchaussee 8, 25764 Hedwigenkoog.pdf differ diff --git a/dokumente/2025/belege/Müllabfuhr_1.pdf b/dokumente/2025/belege/Müllabfuhr_1.pdf new file mode 100644 index 0000000..b55b193 Binary files /dev/null and b/dokumente/2025/belege/Müllabfuhr_1.pdf differ diff --git a/dokumente/2025/belege/Schornsteinfeger_1.pdf b/dokumente/2025/belege/Schornsteinfeger_1.pdf new file mode 100644 index 0000000..37ee5e4 Binary files /dev/null and b/dokumente/2025/belege/Schornsteinfeger_1.pdf differ diff --git a/dokumente/2025/belege/Wasser_Abwasser_1.pdf b/dokumente/2025/belege/Wasser_Abwasser_1.pdf new file mode 100644 index 0000000..b98ee10 Binary files /dev/null and b/dokumente/2025/belege/Wasser_Abwasser_1.pdf differ diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..020cb8b --- /dev/null +++ b/icon.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/nebenkostenabrechnung.desktop b/nebenkostenabrechnung.desktop new file mode 100644 index 0000000..a04ed8e --- /dev/null +++ b/nebenkostenabrechnung.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Version=1.0 +Type=Application +Name=Nebenkostenabrechnung +Comment=Nebenkostenabrechnung erstellen und verwalten +Exec=python3 "/mnt/17 - Entwicklungen/20 - Projekte/Nebenkostenabrechnung/nebenkostenabrechnung.py" +Icon=/mnt/17 - Entwicklungen/20 - Projekte/Nebenkostenabrechnung/icon.svg +Terminal=false +Categories=Office;Finance; +StartupNotify=true diff --git a/nebenkostenabrechnung.py b/nebenkostenabrechnung.py new file mode 100644 index 0000000..9e88d97 --- /dev/null +++ b/nebenkostenabrechnung.py @@ -0,0 +1,1599 @@ +#!/usr/bin/env python3 +""" +Nebenkostenabrechnung - PySide6 GUI-Anwendung +""" + +import sys +import os +import json +from pathlib import Path +from datetime import datetime +from io import BytesIO + +from PySide6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QFormLayout, QGridLayout, QStackedWidget, QListWidget, QListWidgetItem, + QLineEdit, QDoubleSpinBox, QSpinBox, QPushButton, QLabel, QGroupBox, + QTableWidget, QTableWidgetItem, QHeaderView, QMessageBox, QFileDialog, + QComboBox, QFrame, QSplitter, QScrollArea, QSizePolicy +) +from PySide6.QtCore import Qt, QSize +from PySide6.QtGui import QFont, QIcon, QColor + +from reportlab.lib.pagesizes import A4 +from reportlab.lib.units import cm +from reportlab.pdfgen import canvas +from reportlab.lib import colors + +try: + from PyPDF2 import PdfReader, PdfWriter + PYPDF2_AVAILABLE = True +except ImportError: + PYPDF2_AVAILABLE = False + + +class DatenManager: + """Verwaltet Stammdaten und Historie""" + + # Konfigurations-Datei im Home-Verzeichnis + CONFIG_DATEI = Path.home() / ".config" / "nebenkostenabrechnung" / "config.json" + + def __init__(self): + # Arbeitsverzeichnis setzen + self.basis_pfad = Path(__file__).parent + os.chdir(self.basis_pfad) + + # Standard-Pfade + self.daten_ordner = str(self.basis_pfad / "daten") + self.dokumente_ordner = str(self.basis_pfad / "dokumente") + self.vorlagen_ordner = str(self.basis_pfad / "vorlagen") + + # Konfig laden (überschreibt Standard-Pfade) + self.lade_config() + + # Ordner erstellen + Path(self.daten_ordner).mkdir(parents=True, exist_ok=True) + Path(self.dokumente_ordner).mkdir(parents=True, exist_ok=True) + Path(self.vorlagen_ordner).mkdir(parents=True, exist_ok=True) + + # Dynamische Pfade + self.stammdaten_datei = Path(self.daten_ordner) / "stammdaten.json" + self.historie_datei = Path(self.daten_ordner) / "historie.json" + self.vorlage_datei = Path(self.vorlagen_ordner) / "vorlage.pdf" + + # Standard-Daten + self.vermieter = { + "name": "", "adresse": "", "iban": "", "bic": "", "bank": "" + } + self.mieter = { + "name": "", "adresse": "", "iban": "", "bic": "", "bank": "", "email": "" + } + self.objekt_adresse = "" + self.vorauszahlung_monat = 50.0 + self.kostenarten = ["Wasser/Abwasser", "Müllabfuhr", "Schornsteinfeger"] + self.briefkopf_hoehe = 4.5 + self.historie = {} + + self.lade_stammdaten() + self.lade_historie() + + def lade_config(self): + """Lädt Pfad-Konfiguration""" + if self.CONFIG_DATEI.exists(): + try: + with open(self.CONFIG_DATEI, "r", encoding="utf-8") as f: + config = json.load(f) + self.daten_ordner = config.get("daten_ordner", self.daten_ordner) + self.dokumente_ordner = config.get("dokumente_ordner", self.dokumente_ordner) + self.vorlagen_ordner = config.get("vorlagen_ordner", self.vorlagen_ordner) + except: + pass + + def speichere_config(self): + """Speichert Pfad-Konfiguration""" + self.CONFIG_DATEI.parent.mkdir(parents=True, exist_ok=True) + config = { + "daten_ordner": self.daten_ordner, + "dokumente_ordner": self.dokumente_ordner, + "vorlagen_ordner": self.vorlagen_ordner, + } + with open(self.CONFIG_DATEI, "w", encoding="utf-8") as f: + json.dump(config, f, ensure_ascii=False, indent=2) + + def aktualisiere_pfade(self): + """Aktualisiert die Dateipfade nach Änderung der Ordner""" + Path(self.daten_ordner).mkdir(parents=True, exist_ok=True) + Path(self.dokumente_ordner).mkdir(parents=True, exist_ok=True) + Path(self.vorlagen_ordner).mkdir(parents=True, exist_ok=True) + self.stammdaten_datei = Path(self.daten_ordner) / "stammdaten.json" + self.historie_datei = Path(self.daten_ordner) / "historie.json" + self.vorlage_datei = Path(self.vorlagen_ordner) / "vorlage.pdf" + + def lade_stammdaten(self): + if self.stammdaten_datei.exists(): + try: + with open(self.stammdaten_datei, "r", encoding="utf-8") as f: + daten = json.load(f) + self.vermieter = daten.get("vermieter", self.vermieter) + self.mieter = daten.get("mieter", self.mieter) + self.objekt_adresse = daten.get("objekt_adresse", "") + self.vorauszahlung_monat = daten.get("vorauszahlung_monat", 50.0) + self.kostenarten = daten.get("kostenarten", self.kostenarten) + self.briefkopf_hoehe = daten.get("briefkopf_hoehe", 4.5) + except Exception as e: + print(f"Fehler beim Laden: {e}") + + def speichere_stammdaten(self): + daten = { + "vermieter": self.vermieter, + "mieter": self.mieter, + "objekt_adresse": self.objekt_adresse, + "vorauszahlung_monat": self.vorauszahlung_monat, + "kostenarten": self.kostenarten, + "briefkopf_hoehe": self.briefkopf_hoehe, + } + with open(self.stammdaten_datei, "w", encoding="utf-8") as f: + json.dump(daten, f, ensure_ascii=False, indent=2) + + def lade_historie(self): + if self.historie_datei.exists(): + try: + with open(self.historie_datei, "r", encoding="utf-8") as f: + self.historie = json.load(f) + except: + pass + + def get_abrechnung_ordner(self, jahr): + """Gibt den Ordner für eine Abrechnung zurück""" + ordner = Path(self.dokumente_ordner) / str(jahr) + ordner.mkdir(parents=True, exist_ok=True) + return ordner + + def get_belege_ordner(self, jahr): + """Gibt den Belege-Ordner für eine Abrechnung zurück""" + ordner = self.get_abrechnung_ordner(jahr) / "belege" + ordner.mkdir(parents=True, exist_ok=True) + return ordner + + def speichere_entwurf(self, jahr, kosten, belege, anzahl_monate, bemerkungen=""): + """Speichert einen Entwurf (ohne PDF zu erstellen)""" + import shutil + + belege_ordner = self.get_belege_ordner(jahr) + gespeicherte_belege = {} + + # Belege kopieren + for kostenart, dateien in belege.items(): + gespeicherte_belege[kostenart] = [] + for i, datei in enumerate(dateien): + if datei and Path(datei).exists(): + # Dateiname: Kostenart_Nr.pdf + ext = Path(datei).suffix + ziel_name = f"{kostenart.replace('/', '_')}_{i+1}{ext}" + ziel = belege_ordner / ziel_name + if str(datei) != str(ziel): # Nur kopieren wenn nicht gleich + shutil.copy2(datei, ziel) + gespeicherte_belege[kostenart].append(str(ziel)) + + summe_kosten = sum(kosten.values()) + summe_vz = self.vorauszahlung_monat * anzahl_monate + + self.historie[str(jahr)] = { + "jahr": jahr, + "mieter": self.mieter["name"], + "kosten": kosten, + "belege": gespeicherte_belege, + "bemerkungen": bemerkungen, + "summe_kosten": summe_kosten, + "vorauszahlung_monat": self.vorauszahlung_monat, + "anzahl_monate": anzahl_monate, + "summe_vorauszahlung": summe_vz, + "differenz": summe_kosten - summe_vz, + "status": "entwurf", + "datum": datetime.now().strftime("%Y-%m-%d %H:%M"), + } + + with open(self.historie_datei, "w", encoding="utf-8") as f: + json.dump(self.historie, f, ensure_ascii=False, indent=2) + + return gespeicherte_belege + + def lade_entwurf(self, jahr): + """Lädt einen gespeicherten Entwurf""" + if str(jahr) in self.historie: + return self.historie[str(jahr)] + return None + + def speichere_abrechnung(self, jahr, kosten, belege, anzahl_monate, bemerkungen=""): + """Speichert eine fertige Abrechnung""" + # Erst als Entwurf speichern (kopiert Belege) + gespeicherte_belege = self.speichere_entwurf(jahr, kosten, belege, anzahl_monate, bemerkungen) + + summe_kosten = sum(kosten.values()) + summe_vz = self.vorauszahlung_monat * anzahl_monate + + self.historie[str(jahr)] = { + "jahr": jahr, + "mieter": self.mieter["name"], + "kosten": kosten, + "belege": gespeicherte_belege, + "summe_kosten": summe_kosten, + "vorauszahlung_monat": self.vorauszahlung_monat, + "anzahl_monate": anzahl_monate, + "summe_vorauszahlung": summe_vz, + "differenz": summe_kosten - summe_vz, + "status": "fertig", + "datum": datetime.now().strftime("%Y-%m-%d %H:%M"), + } + + with open(self.historie_datei, "w", encoding="utf-8") as f: + json.dump(self.historie, f, ensure_ascii=False, indent=2) + + return gespeicherte_belege + + def get_vorjahr_kosten(self, jahr): + vorjahr = str(jahr - 1) + if vorjahr in self.historie: + return self.historie[vorjahr].get("kosten", {}) + return {} + + +class StammdatenSeite(QWidget): + """Seite für Stammdaten-Eingabe""" + + def __init__(self, daten: DatenManager): + super().__init__() + self.daten = daten + self.setup_ui() + self.lade_daten() + + def setup_ui(self): + layout = QVBoxLayout(self) + layout.setSpacing(20) + + # Titel + titel = QLabel("Stammdaten") + titel.setFont(QFont("", 18, QFont.Bold)) + layout.addWidget(titel) + + # Scroll-Bereich + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.NoFrame) + + content = QWidget() + content_layout = QVBoxLayout(content) + content_layout.setSpacing(15) + + # Vermieter + self.vermieter_group = QGroupBox("Vermieter") + vl = QFormLayout(self.vermieter_group) + self.v_name = QLineEdit() + self.v_adresse = QLineEdit() + self.v_iban = QLineEdit() + self.v_bic = QLineEdit() + self.v_bank = QLineEdit() + vl.addRow("Name:", self.v_name) + vl.addRow("Adresse:", self.v_adresse) + vl.addRow("IBAN:", self.v_iban) + vl.addRow("BIC:", self.v_bic) + vl.addRow("Bank:", self.v_bank) + content_layout.addWidget(self.vermieter_group) + + # Mieter + self.mieter_group = QGroupBox("Mieter") + ml = QFormLayout(self.mieter_group) + self.m_name = QLineEdit() + self.m_adresse = QLineEdit() + self.m_email = QLineEdit() + self.m_email.setPlaceholderText("Nur intern - nicht im PDF") + self.m_iban = QLineEdit() + self.m_iban.setPlaceholderText("Für Rückzahlung bei Guthaben") + self.m_bic = QLineEdit() + self.m_bank = QLineEdit() + ml.addRow("Name:", self.m_name) + ml.addRow("Adresse:", self.m_adresse) + ml.addRow("E-Mail:", self.m_email) + ml.addRow("IBAN:", self.m_iban) + ml.addRow("BIC:", self.m_bic) + ml.addRow("Bank:", self.m_bank) + content_layout.addWidget(self.mieter_group) + + # Objekt + self.objekt_group = QGroupBox("Mietobjekt") + ol = QFormLayout(self.objekt_group) + self.objekt_adresse = QLineEdit() + self.vorauszahlung = QDoubleSpinBox() + self.vorauszahlung.setRange(0, 10000) + self.vorauszahlung.setSuffix(" €/Monat") + self.vorauszahlung.setDecimals(2) + ol.addRow("Adresse:", self.objekt_adresse) + ol.addRow("Vorauszahlung:", self.vorauszahlung) + content_layout.addWidget(self.objekt_group) + + content_layout.addStretch() + scroll.setWidget(content) + layout.addWidget(scroll) + + # Speichern-Button + btn_layout = QHBoxLayout() + btn_layout.addStretch() + self.btn_speichern = QPushButton("Speichern") + self.btn_speichern.setMinimumWidth(150) + self.btn_speichern.clicked.connect(self.speichern) + btn_layout.addWidget(self.btn_speichern) + layout.addLayout(btn_layout) + + def lade_daten(self): + v = self.daten.vermieter + self.v_name.setText(v.get("name", "")) + self.v_adresse.setText(v.get("adresse", "")) + self.v_iban.setText(v.get("iban", "")) + self.v_bic.setText(v.get("bic", "")) + self.v_bank.setText(v.get("bank", "")) + + m = self.daten.mieter + self.m_name.setText(m.get("name", "")) + self.m_adresse.setText(m.get("adresse", "")) + self.m_email.setText(m.get("email", "")) + self.m_iban.setText(m.get("iban", "")) + self.m_bic.setText(m.get("bic", "")) + self.m_bank.setText(m.get("bank", "")) + + self.objekt_adresse.setText(self.daten.objekt_adresse) + self.vorauszahlung.setValue(self.daten.vorauszahlung_monat) + + def speichern(self): + self.daten.vermieter = { + "name": self.v_name.text(), + "adresse": self.v_adresse.text(), + "iban": self.v_iban.text(), + "bic": self.v_bic.text(), + "bank": self.v_bank.text(), + } + self.daten.mieter = { + "name": self.m_name.text(), + "adresse": self.m_adresse.text(), + "email": self.m_email.text(), + "iban": self.m_iban.text(), + "bic": self.m_bic.text(), + "bank": self.m_bank.text(), + } + self.daten.objekt_adresse = self.objekt_adresse.text() + self.daten.vorauszahlung_monat = self.vorauszahlung.value() + self.daten.speichere_stammdaten() + + QMessageBox.information(self, "Gespeichert", "Stammdaten wurden gespeichert.") + + +class KostenartenSeite(QWidget): + """Seite für Kostenarten-Verwaltung""" + + def __init__(self, daten: DatenManager): + super().__init__() + self.daten = daten + self.setup_ui() + self.lade_daten() + + def setup_ui(self): + layout = QVBoxLayout(self) + layout.setSpacing(20) + + titel = QLabel("Kostenarten verwalten") + titel.setFont(QFont("", 18, QFont.Bold)) + layout.addWidget(titel) + + info = QLabel("Diese Kostenarten werden bei jeder Abrechnung abgefragt.") + info.setStyleSheet("color: gray;") + layout.addWidget(info) + + # Tabelle + self.tabelle = QTableWidget() + self.tabelle.setColumnCount(1) + self.tabelle.setHorizontalHeaderLabels(["Kostenart"]) + self.tabelle.horizontalHeader().setStretchLastSection(True) + self.tabelle.setSelectionBehavior(QTableWidget.SelectRows) + layout.addWidget(self.tabelle) + + # Buttons + btn_layout = QHBoxLayout() + + self.input_neu = QLineEdit() + self.input_neu.setPlaceholderText("Neue Kostenart eingeben...") + self.input_neu.returnPressed.connect(self.hinzufuegen) + btn_layout.addWidget(self.input_neu) + + self.btn_add = QPushButton("Hinzufügen") + self.btn_add.clicked.connect(self.hinzufuegen) + btn_layout.addWidget(self.btn_add) + + self.btn_del = QPushButton("Löschen") + self.btn_del.clicked.connect(self.loeschen) + btn_layout.addWidget(self.btn_del) + + layout.addLayout(btn_layout) + + def lade_daten(self): + self.tabelle.setRowCount(len(self.daten.kostenarten)) + for i, art in enumerate(self.daten.kostenarten): + self.tabelle.setItem(i, 0, QTableWidgetItem(art)) + + def hinzufuegen(self): + text = self.input_neu.text().strip() + if text and text not in self.daten.kostenarten: + self.daten.kostenarten.append(text) + self.daten.speichere_stammdaten() + self.lade_daten() + self.input_neu.clear() + + def loeschen(self): + row = self.tabelle.currentRow() + if row >= 0: + art = self.daten.kostenarten[row] + self.daten.kostenarten.remove(art) + self.daten.speichere_stammdaten() + self.lade_daten() + + +class KostenZeile(QWidget): + """Widget für eine Kostenart mit Betrag und Belegen""" + + def __init__(self, kostenart, vorjahr_wert=0, parent=None): + super().__init__(parent) + self.kostenart = kostenart + self.belege = [] # Liste der Beleg-Pfade + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 2, 0, 2) + + # Label + label = kostenart + if vorjahr_wert > 0: + label = f"{kostenart} (VJ: {vorjahr_wert:.2f} €)" + self.lbl = QLabel(label) + self.lbl.setMinimumWidth(200) + layout.addWidget(self.lbl) + + # Betrag + self.betrag = QDoubleSpinBox() + self.betrag.setRange(0, 100000) + self.betrag.setDecimals(2) + self.betrag.setSuffix(" €") + self.betrag.setMinimumWidth(120) + layout.addWidget(self.betrag) + + # Beleg-Button + self.btn_beleg = QPushButton("📎 Beleg...") + self.btn_beleg.setMinimumWidth(100) + self.btn_beleg.clicked.connect(self.waehle_beleg) + layout.addWidget(self.btn_beleg) + + # Beleg-Anzeige + self.lbl_beleg = QLabel("") + self.lbl_beleg.setStyleSheet("color: gray; font-size: 9px;") + layout.addWidget(self.lbl_beleg, 1) + + def waehle_beleg(self): + dateien, _ = QFileDialog.getOpenFileNames( + self, f"Belege für {self.kostenart}", + "", "PDF/Bilder (*.pdf *.jpg *.jpeg *.png);;Alle (*.*)" + ) + if dateien: + self.belege = dateien + self.aktualisiere_beleg_anzeige() + + def aktualisiere_beleg_anzeige(self): + if self.belege: + namen = [Path(b).name for b in self.belege] + text = ", ".join(namen) + if len(text) > 40: + text = f"{len(self.belege)} Belege" + self.lbl_beleg.setText(f"✓ {text}") + self.lbl_beleg.setStyleSheet("color: green; font-size: 9px;") + else: + self.lbl_beleg.setText("") + + def setze_belege(self, belege_liste): + """Setzt Belege aus gespeichertem Entwurf""" + self.belege = [b for b in belege_liste if Path(b).exists()] + self.aktualisiere_beleg_anzeige() + + +class AbrechnungSeite(QWidget): + """Seite für neue Abrechnung mit Beleg-Upload""" + + def __init__(self, daten: DatenManager): + super().__init__() + self.daten = daten + self.kosten_zeilen = {} # kostenart -> KostenZeile + self.setup_ui() + + def setup_ui(self): + layout = QVBoxLayout(self) + layout.setSpacing(15) + + # Titel und Status + titel_layout = QHBoxLayout() + titel = QLabel("Abrechnung") + titel.setFont(QFont("", 18, QFont.Bold)) + titel_layout.addWidget(titel) + + self.lbl_status = QLabel("") + self.lbl_status.setStyleSheet("color: gray;") + titel_layout.addWidget(self.lbl_status) + titel_layout.addStretch() + layout.addLayout(titel_layout) + + # Jahr, Monate und Laden + periode_layout = QHBoxLayout() + + periode_layout.addWidget(QLabel("Jahr:")) + self.jahr = QSpinBox() + self.jahr.setRange(2000, 2100) + self.jahr.setValue(datetime.now().year - 1) + self.jahr.valueChanged.connect(self.jahr_geaendert) + periode_layout.addWidget(self.jahr) + + periode_layout.addWidget(QLabel("Monate:")) + self.monate = QSpinBox() + self.monate.setRange(1, 12) + self.monate.setValue(12) + self.monate.valueChanged.connect(self.berechne) + periode_layout.addWidget(self.monate) + + self.btn_laden = QPushButton("Entwurf laden") + self.btn_laden.clicked.connect(self.lade_entwurf) + periode_layout.addWidget(self.btn_laden) + + periode_layout.addStretch() + layout.addLayout(periode_layout) + + # Scroll-Bereich für Kosten + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.NoFrame) + scroll.setMaximumHeight(300) + + self.kosten_widget = QWidget() + self.kosten_layout = QVBoxLayout(self.kosten_widget) + self.kosten_layout.setSpacing(5) + scroll.setWidget(self.kosten_widget) + + kosten_group = QGroupBox("Nebenkosten (Jahressummen) - mit Belegen") + kosten_group_layout = QVBoxLayout(kosten_group) + kosten_group_layout.addWidget(scroll) + layout.addWidget(kosten_group) + + # Zusammenfassung + summen_group = QGroupBox("Zusammenfassung") + sl = QFormLayout(summen_group) + + self.lbl_summe_kosten = QLabel("0,00 €") + self.lbl_summe_kosten.setFont(QFont("", 11)) + sl.addRow("Summe Nebenkosten:", self.lbl_summe_kosten) + + self.lbl_vorauszahlung = QLabel("0,00 €") + self.lbl_vorauszahlung.setFont(QFont("", 11)) + sl.addRow("Vorauszahlungen:", self.lbl_vorauszahlung) + + self.lbl_differenz = QLabel("0,00 €") + self.lbl_differenz.setFont(QFont("", 14, QFont.Bold)) + sl.addRow("Ergebnis:", self.lbl_differenz) + + layout.addWidget(summen_group) + + # Bemerkungen + from PySide6.QtWidgets import QTextEdit + bemerk_group = QGroupBox("Bemerkungen (erscheint im PDF)") + bemerk_layout = QVBoxLayout(bemerk_group) + self.bemerkungen = QTextEdit() + self.bemerkungen.setMaximumHeight(80) + self.bemerkungen.setPlaceholderText("Optionale Anmerkungen zur Abrechnung...") + bemerk_layout.addWidget(self.bemerkungen) + layout.addWidget(bemerk_group) + + # Buttons + btn_layout = QHBoxLayout() + + self.btn_entwurf = QPushButton("💾 Entwurf speichern") + self.btn_entwurf.clicked.connect(self.speichere_entwurf) + btn_layout.addWidget(self.btn_entwurf) + + btn_layout.addStretch() + + self.btn_pdf = QPushButton("📄 PDF erstellen") + self.btn_pdf.setMinimumWidth(150) + self.btn_pdf.clicked.connect(self.erstelle_pdf) + btn_layout.addWidget(self.btn_pdf) + + layout.addLayout(btn_layout) + layout.addStretch() + + # Initial laden (inkl. vorhandener Daten) + self.jahr_geaendert() + + def jahr_geaendert(self): + """Wird aufgerufen wenn das Jahr geändert wird - lädt automatisch vorhandene Daten""" + self.aktualisiere_kosten() + + # Prüfen ob Daten existieren und automatisch laden + entwurf = self.daten.lade_entwurf(self.jahr.value()) + if entwurf: + # Automatisch laden + self.monate.setValue(entwurf.get("anzahl_monate", 12)) + + kosten = entwurf.get("kosten", {}) + belege = entwurf.get("belege", {}) + + for art, zeile in self.kosten_zeilen.items(): + if art in kosten: + zeile.betrag.setValue(kosten[art]) + if art in belege: + zeile.setze_belege(belege[art]) + + # Bemerkungen laden + self.bemerkungen.setPlainText(entwurf.get("bemerkungen", "")) + + status = entwurf.get("status", "entwurf") + status_text = "Abrechnung" if status == "fertig" else "Entwurf" + self.lbl_status.setText(f"[{status_text} vom {entwurf.get('datum', '?')}]") + self.btn_laden.setEnabled(True) + self.berechne() + else: + self.lbl_status.setText("") + self.btn_laden.setEnabled(False) + + def aktualisiere_kosten(self): + """Erstellt die Kosten-Zeilen neu""" + # Alte Widgets entfernen + while self.kosten_layout.count(): + item = self.kosten_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + + self.kosten_zeilen = {} + vorjahr = self.daten.get_vorjahr_kosten(self.jahr.value()) + + for art in self.daten.kostenarten: + vj_wert = vorjahr.get(art, 0) + zeile = KostenZeile(art, vj_wert) + zeile.betrag.valueChanged.connect(self.berechne) + self.kosten_layout.addWidget(zeile) + self.kosten_zeilen[art] = zeile + + self.kosten_layout.addStretch() + self.berechne() + + def lade_entwurf(self): + """Lädt einen gespeicherten Entwurf""" + entwurf = self.daten.lade_entwurf(self.jahr.value()) + if not entwurf: + QMessageBox.information(self, "Kein Entwurf", "Kein Entwurf für dieses Jahr gefunden.") + return + + # Monate setzen + self.monate.setValue(entwurf.get("anzahl_monate", 12)) + + # Kosten und Belege setzen + kosten = entwurf.get("kosten", {}) + belege = entwurf.get("belege", {}) + + for art, zeile in self.kosten_zeilen.items(): + if art in kosten: + zeile.betrag.setValue(kosten[art]) + if art in belege: + zeile.setze_belege(belege[art]) + + self.lbl_status.setText(f"[Geladen: {entwurf.get('datum', '?')}]") + self.berechne() + + def speichere_entwurf(self): + """Speichert den aktuellen Stand als Entwurf""" + kosten = {art: zeile.betrag.value() for art, zeile in self.kosten_zeilen.items()} + belege = {art: zeile.belege for art, zeile in self.kosten_zeilen.items()} + bemerkungen = self.bemerkungen.toPlainText() + + self.daten.speichere_entwurf( + self.jahr.value(), kosten, belege, self.monate.value(), bemerkungen + ) + + self.lbl_status.setText(f"[Entwurf gespeichert: {datetime.now().strftime('%H:%M')}]") + QMessageBox.information(self, "Gespeichert", "Entwurf wurde gespeichert.\nBelege wurden kopiert.") + + def berechne(self): + summe = sum(zeile.betrag.value() for zeile in self.kosten_zeilen.values()) + vz = self.daten.vorauszahlung_monat * self.monate.value() + diff = summe - vz + + self.lbl_summe_kosten.setText(f"{summe:,.2f} €".replace(",", "X").replace(".", ",").replace("X", ".")) + self.lbl_vorauszahlung.setText(f"{vz:,.2f} €".replace(",", "X").replace(".", ",").replace("X", ".")) + + if diff > 0: + self.lbl_differenz.setText(f"Nachzahlung: {diff:,.2f} €".replace(",", "X").replace(".", ",").replace("X", ".")) + self.lbl_differenz.setStyleSheet("color: #CC0000;") + else: + self.lbl_differenz.setText(f"Guthaben: {abs(diff):,.2f} €".replace(",", "X").replace(".", ",").replace("X", ".")) + self.lbl_differenz.setStyleSheet("color: #006600;") + + def erstelle_pdf(self): + if not self.daten.vermieter.get("name"): + QMessageBox.warning(self, "Fehler", "Bitte zuerst Stammdaten eingeben!") + return + + # Kosten und Belege aus den Zeilen sammeln + kosten = {art: zeile.betrag.value() for art, zeile in self.kosten_zeilen.items()} + belege = {art: zeile.belege for art, zeile in self.kosten_zeilen.items()} + jahr = self.jahr.value() + anzahl_monate = self.monate.value() + + bemerkungen = self.bemerkungen.toPlainText() + + # Busy-Cursor anzeigen während PDF erstellt wird + QApplication.setOverrideCursor(Qt.WaitCursor) + self.lbl_status.setText("PDF wird erstellt...") + QApplication.processEvents() + + try: + # Speichern (kopiert auch Belege) + gespeicherte_belege = self.daten.speichere_abrechnung(jahr, kosten, belege, anzahl_monate, bemerkungen) + + # PDF erstellen (mit angehängten Belegen) + pdf_pfad = self._erstelle_pdf(jahr, kosten, anzahl_monate, gespeicherte_belege, bemerkungen) + finally: + QApplication.restoreOverrideCursor() + + QMessageBox.information(self, "PDF erstellt", f"PDF gespeichert:\n{pdf_pfad}") + + self.lbl_status.setText(f"[PDF erstellt: {datetime.now().strftime('%H:%M')}]") + + # PDF öffnen + import subprocess + subprocess.run(["xdg-open", pdf_pfad]) + + def _erstelle_pdf(self, jahr, kosten, anzahl_monate, belege=None, bemerkungen=""): + # Absoluten Pfad sicherstellen + dok_ordner = Path(self.daten.dokumente_ordner).resolve() + jahr_ordner = dok_ordner / str(jahr) + jahr_ordner.mkdir(parents=True, exist_ok=True) + print(f"PDF-Ordner: {jahr_ordner}") + + # Dateiname: Datum - Nebenkostenabrechnung - Objekt.pdf + datum_str = datetime.now().strftime("%Y-%m-%d") + objekt_safe = self.daten.objekt_adresse.replace('/', '-').replace('\\', '-').replace(':', '-') + dateiname = jahr_ordner / f"{datum_str} - Nebenkostenabrechnung - {objekt_safe}.pdf" + + summe_kosten = sum(kosten.values()) + summe_vz = self.daten.vorauszahlung_monat * anzahl_monate + differenz = summe_kosten - summe_vz + + # Hack-Schrift für PDF registrieren + from reportlab.pdfbase import pdfmetrics + from reportlab.pdfbase.ttfonts import TTFont + try: + pdfmetrics.registerFont(TTFont('Hack', '/usr/share/fonts/TTF/Hack-Regular.ttf')) + pdfmetrics.registerFont(TTFont('Hack-Bold', '/usr/share/fonts/TTF/Hack-Bold.ttf')) + FONT = 'Hack' + FONT_BOLD = 'Hack-Bold' + except: + FONT = 'Helvetica' + FONT_BOLD = 'Helvetica-Bold' + + # PDF erstellen + buffer = BytesIO() + c = canvas.Canvas(buffer, pagesize=A4) + breite, hoehe = A4 + + def format_eur(betrag): + return f"{betrag:,.2f} EUR".replace(",", "X").replace(".", ",").replace("X", ".") + + def format_iban(iban): + iban = iban.replace(" ", "") + return " ".join([iban[i:i+4] for i in range(0, len(iban), 4)]) + + # ============ BRIEFKOPF ============ + # Absender kommt aus der Vorlage (Briefkopf) + + # Absenderzeile klein (direkt über Empfänger mit 1 Leerzeile) + y = hoehe - 4.5*cm + c.setFont(FONT, 7) + absender_zeile = f"{self.daten.vermieter['name']} · {self.daten.vermieter['adresse']}" + c.drawString(2*cm, y, absender_zeile) + + # Leerzeile + y -= 0.4*cm + + # Empfänger (Mieter) - "An:" + y -= 0.4*cm + c.setFont(FONT_BOLD, 10) + c.drawString(2*cm, y, "An:") + y -= 0.5*cm + c.setFont(FONT, 11) + c.drawString(2*cm, y, self.daten.mieter["name"]) + y -= 0.45*cm + # Adresse aufteilen falls mit Komma + adresse_teile = self.daten.mieter["adresse"].split(",") + for teil in adresse_teile: + c.drawString(2*cm, y, teil.strip()) + y -= 0.45*cm + + # Datum rechts + c.setFont(FONT, 10) + c.drawRightString(breite - 2*cm, y + 0.9*cm, f"Datum: {datetime.now().strftime('%d.%m.%Y')}") + + # ============ BETREFF / TITEL ============ + # 6 Zeilen tiefer (ca. 2.5cm) + y -= 3.3*cm + c.setFont(FONT_BOLD, 12) + c.drawString(2*cm, y, f"Nebenkostenabrechnung {jahr}") + y -= 0.5*cm + c.setFont(FONT, 10) + c.drawString(2*cm, y, f"Abrechnungszeitraum: 01.01.{jahr} - 31.12.{jahr}") + + # Objekt und Umlageschlüssel + y -= 0.6*cm + c.setFont(FONT, 9) + c.drawString(2*cm, y, f"Mietobjekt: {self.daten.objekt_adresse}") + y -= 0.4*cm + c.drawString(2*cm, y, "Umlageschlüssel: 100%") + + # Trennlinie + y -= 0.5*cm + c.line(2*cm, y, breite - 2*cm, y) + + # Kosten + y -= 0.8*cm + c.setFont(FONT_BOLD, 11) + c.drawString(2*cm, y, "Kostenaufstellung") + + y -= 0.5*cm + c.setFont(FONT_BOLD, 9) + c.drawString(2*cm, y, "Kostenart") + c.drawRightString(breite - 2*cm, y, "Betrag") + y -= 0.15*cm + c.line(2*cm, y, breite - 2*cm, y) + + c.setFont(FONT, 9) + for art, betrag in kosten.items(): + if betrag > 0: + y -= 0.45*cm + c.drawString(2*cm, y, art) + c.drawRightString(breite - 2*cm, y, format_eur(betrag)) + + y -= 0.15*cm + c.line(2*cm, y, breite - 2*cm, y) + y -= 0.45*cm + c.setFont(FONT_BOLD, 9) + c.drawString(2*cm, y, "Summe Nebenkosten") + c.drawRightString(breite - 2*cm, y, format_eur(summe_kosten)) + + # Vorauszahlung + y -= 0.8*cm + c.setFont(FONT_BOLD, 11) + c.drawString(2*cm, y, "Vorauszahlungen") + y -= 0.5*cm + c.setFont(FONT, 9) + c.drawString(2*cm, y, f"{anzahl_monate} Monate x {format_eur(self.daten.vorauszahlung_monat)}") + c.drawRightString(breite - 2*cm, y, format_eur(summe_vz)) + + # Ergebnis + y -= 0.7*cm + c.setLineWidth(1.5) + c.line(2*cm, y, breite - 2*cm, y) + y -= 0.6*cm + c.setFont(FONT_BOLD, 11) + + if differenz > 0: + c.drawString(2*cm, y, "Nachzahlung durch Mieter") + c.setFillColor(colors.HexColor("#CC0000")) + else: + c.drawString(2*cm, y, "Guthaben für Mieter") + c.setFillColor(colors.HexColor("#006600")) + + c.drawRightString(breite - 2*cm, y, format_eur(abs(differenz))) + c.setFillColor(colors.black) + + # Zahlungshinweis + y -= 1*cm + c.setFont(FONT_BOLD, 9) + c.drawString(2*cm, y, "Zahlungshinweis:") + y -= 0.4*cm + c.setFont(FONT, 8) + + if differenz > 0: + c.drawString(2*cm, y, f"Bitte überweisen Sie {format_eur(abs(differenz))} innerhalb von 30 Tagen an:") + y -= 0.4*cm + if self.daten.vermieter["iban"]: + c.drawString(2*cm, y, f"IBAN: {format_iban(self.daten.vermieter['iban'])}") + if self.daten.vermieter["bank"]: + c.drawString(10*cm, y, f"Bank: {self.daten.vermieter['bank']}") + y -= 0.35*cm + c.drawString(2*cm, y, f"Verwendungszweck: Nebenkostennachzahlung {jahr}") + else: + c.drawString(2*cm, y, f"Das Guthaben von {format_eur(abs(differenz))} wird überwiesen an:") + y -= 0.4*cm + c.drawString(2*cm, y, f"Empfänger: {self.daten.mieter['name']}") + if self.daten.mieter["iban"]: + y -= 0.35*cm + c.drawString(2*cm, y, f"IBAN: {format_iban(self.daten.mieter['iban'])}") + + # Bemerkungen (falls vorhanden) + if bemerkungen and bemerkungen.strip(): + y -= 0.8*cm + c.setFont(FONT_BOLD, 9) + c.drawString(2*cm, y, "Bemerkungen:") + y -= 0.4*cm + c.setFont(FONT, 8) + for zeile in bemerkungen.strip().split('\n'): + c.drawString(2*cm, y, zeile) + y -= 0.3*cm + + # Anlagen-Verzeichnis (falls Belege vorhanden) + anlagen_liste = [] + if belege: + for kostenart, dateien in belege.items(): + for datei in dateien: + if datei and Path(datei).exists(): + anlagen_liste.append((kostenart, Path(datei).name)) + + if anlagen_liste: + y -= 0.8*cm + c.setFont(FONT_BOLD, 9) + c.drawString(2*cm, y, f"Anlagen ({len(anlagen_liste)} Beleg{'e' if len(anlagen_liste) > 1 else ''}):") + y -= 0.4*cm + c.setFont(FONT, 8) + for i, (kostenart, anlage_name) in enumerate(anlagen_liste, 1): + c.drawString(2*cm, y, f" {i}. {kostenart}: {anlage_name}") + y -= 0.3*cm + + # Rechtliche Hinweise (Pflichtangaben gem. § 556 BGB) + y -= 0.5*cm + c.setFont(FONT_BOLD, 8) + c.drawString(2*cm, y, "Rechtliche Hinweise:") + y -= 0.35*cm + c.setFont(FONT, 7) + c.drawString(2*cm, y, "• Sie haben das Recht, die dieser Abrechnung zugrunde liegenden Belege einzusehen (§ 556 Abs. 4 BGB).") + y -= 0.3*cm + c.drawString(2*cm, y, "• Einwendungen gegen diese Abrechnung sind innerhalb von 12 Monaten nach Zugang schriftlich geltend zu machen (§ 556 Abs. 3 BGB).") + y -= 0.3*cm + c.drawString(2*cm, y, "• Nach Ablauf dieser Frist können Einwendungen nicht mehr geltend gemacht werden, es sei denn, der Mieter hat die") + y -= 0.3*cm + c.drawString(2*cm, y, " verspätete Geltendmachung nicht zu vertreten.") + + # Unterschrift + y -= 0.8*cm + c.setLineWidth(0.5) + c.line(2*cm, y, 6*cm, y) + y -= 0.3*cm + c.setFont(FONT, 8) + c.drawString(2*cm, y, self.daten.vermieter["name"]) + + c.save() + buffer.seek(0) + + # PDF zusammenstellen + writer = PdfWriter() + + # Mit Vorlage zusammenführen oder direkt verwenden + vorlage_pfad = self.daten.vorlage_datei + if vorlage_pfad.exists() and PYPDF2_AVAILABLE: + try: + vorlage = PdfReader(vorlage_pfad) + vorlage_seite = vorlage.pages[0] + inhalt = PdfReader(buffer) + vorlage_seite.merge_page(inhalt.pages[0]) + writer.add_page(vorlage_seite) + except: + buffer.seek(0) + inhalt = PdfReader(buffer) + writer.add_page(inhalt.pages[0]) + elif PYPDF2_AVAILABLE: + inhalt = PdfReader(buffer) + writer.add_page(inhalt.pages[0]) + else: + # Ohne PyPDF2 - nur Abrechnung, keine Belege + with open(str(dateiname), "wb") as f: + f.write(buffer.getvalue()) + return str(dateiname) + + # Hilfsfunktion: Prüft ob Seite leer ist (mit OCR) + def ist_seite_leer(seite): + """Prüft ob eine PDF-Seite leer/ohne sinnvollen Inhalt ist""" + try: + # Basis-Prüfungen + if '/Contents' not in seite: + return True + contents = seite['/Contents'] + if contents is None: + return True + if '/MediaBox' in seite: + box = seite['/MediaBox'] + if box[2] < 50 or box[3] < 50: + return True + + # OCR-Prüfung: Hat die Seite lesbaren Text? + try: + import pytesseract + from pdf2image import convert_from_bytes + from PyPDF2 import PdfWriter as TempWriter + + temp_writer = TempWriter() + temp_writer.add_page(seite) + temp_buffer = BytesIO() + temp_writer.write(temp_buffer) + temp_buffer.seek(0) + + images = convert_from_bytes(temp_buffer.read(), dpi=100) + if not images: + return True + + # Text extrahieren + text = pytesseract.image_to_string(images[0], lang='deu') + # Nur alphanumerische Zeichen zählen + alnum_chars = sum(1 for c in text if c.isalnum()) + + # Weniger als 50 Zeichen = wahrscheinlich leer + if alnum_chars < 50: + print(f"Seite gefiltert: nur {alnum_chars} Zeichen erkannt") + return True + + except Exception as e: + print(f"OCR-Prüfung fehlgeschlagen: {e}") + + return False + except: + return False + + # Orientierung erkennen und korrigieren + def korrigiere_seite(seite): + """Erkennt Textausrichtung mit OCR und dreht Seite falls nötig""" + try: + import pytesseract + from pdf2image import convert_from_bytes + from PyPDF2 import PdfWriter as TempWriter + + # Seite temporär als PDF speichern + temp_writer = TempWriter() + temp_writer.add_page(seite) + temp_buffer = BytesIO() + temp_writer.write(temp_buffer) + temp_buffer.seek(0) + + # PDF in Bild konvertieren + images = convert_from_bytes(temp_buffer.read(), dpi=150) + if not images: + return seite + + # Orientierung mit Tesseract erkennen + osd = pytesseract.image_to_osd(images[0], output_type=pytesseract.Output.DICT) + rotation_needed = osd.get('rotate', 0) + + print(f"OCR erkannt: Rotation {rotation_needed}°") + + # Seite drehen falls nötig + if rotation_needed != 0: + seite.rotate(rotation_needed) + seite.transfer_rotation_to_content() + + return seite + except Exception as e: + print(f"Orientierungserkennung fehlgeschlagen: {e}") + return seite + + # Belege anhängen + beleg_seiten = [] # Sammelt alle Beleg-Seiten + if belege and PYPDF2_AVAILABLE: + for kostenart, dateien in belege.items(): + for datei in dateien: + if datei and Path(datei).exists(): + datei_pfad = Path(datei) + + # Leere/sehr kleine Dateien überspringen + if datei_pfad.stat().st_size < 500: # < 500 Bytes + print(f"Überspringe leere Datei: {datei}") + continue + + # PDFs direkt anhängen + if datei_pfad.suffix.lower() == '.pdf': + try: + beleg_pdf = PdfReader(str(datei_pfad)) + for seite in beleg_pdf.pages: + # Leere Seiten überspringen + if ist_seite_leer(seite): + print(f"Überspringe leere Seite in: {datei}") + continue + # ERST rotieren + seite = korrigiere_seite(seite) + beleg_seiten.append((seite, kostenart, datei_pfad.name)) + except Exception as e: + print(f"Fehler beim Anhängen von {datei}: {e}") + # Bilder in PDF konvertieren + elif datei_pfad.suffix.lower() in ['.jpg', '.jpeg', '.png']: + try: + from reportlab.lib.utils import ImageReader + bild_buffer = BytesIO() + bild_c = canvas.Canvas(bild_buffer, pagesize=A4) + bild_breite, bild_hoehe = A4 + + img = ImageReader(str(datei_pfad)) + img_w, img_h = img.getSize() + + # Skalierung (max 85% wegen Fußzeile) + max_w = bild_breite * 0.85 + max_h = (bild_hoehe - 2*cm) * 0.85 + scale = min(max_w / img_w, max_h / img_h) + new_w = img_w * scale + new_h = img_h * scale + + # Zentriert zeichnen (etwas höher wegen Fußzeile) + x = (bild_breite - new_w) / 2 + y = (bild_hoehe - new_h) / 2 + 0.5*cm + + bild_c.drawImage(str(datei_pfad), x, y, new_w, new_h) + bild_c.save() + bild_buffer.seek(0) + + bild_pdf = PdfReader(bild_buffer) + beleg_seiten.append((bild_pdf.pages[0], kostenart, datei_pfad.name)) + except Exception as e: + print(f"Fehler beim Konvertieren von {datei}: {e}") + + # Alle Beleg-Seiten zum Writer hinzufügen + for seite, _, _ in beleg_seiten: + writer.add_page(seite) + + # Gesamtseitenzahl ermitteln + gesamt_seiten = len(writer.pages) + + # Seitenzahlen als Overlay hinzufügen + final_writer = PdfWriter() + for i, seite in enumerate(writer.pages): + seiten_nr = i + 1 + + # Text für Fußzeile + seiten_text = f"Seite {seiten_nr} von {gesamt_seiten}" + anlage_text = "" + if seiten_nr > 1 and (seiten_nr - 2) < len(beleg_seiten): + _, kostenart, dateiname_beleg = beleg_seiten[seiten_nr - 2] + anlage_text = f"Anlage: {kostenart} - {dateiname_beleg}" + + # Overlay erstellen - Text links am Rand vertikal + overlay_buffer = BytesIO() + overlay_c = canvas.Canvas(overlay_buffer, pagesize=A4) + overlay_c.setFont(FONT, 8) + + # Beide Texte links am Rand (vertikal, von unten nach oben) + overlay_c.saveState() + overlay_c.translate(0.8*cm, 2*cm) + overlay_c.rotate(90) + # Anlage-Text und Seitenzahl kombinieren + if anlage_text: + overlay_c.drawString(0, 0, f"{anlage_text} - {seiten_text}") + else: + overlay_c.drawString(0, 0, seiten_text) + overlay_c.restoreState() + overlay_c.save() + overlay_buffer.seek(0) + + # Overlay auf Seite legen + overlay_pdf = PdfReader(overlay_buffer) + seite.merge_page(overlay_pdf.pages[0]) + + final_writer.add_page(seite) + + # Finales PDF speichern + with open(str(dateiname), "wb") as f: + final_writer.write(f) + + return str(dateiname) + + +class StatistikSeite(QWidget): + """Seite für Statistik/Historie""" + + def __init__(self, daten: DatenManager): + super().__init__() + self.daten = daten + self.setup_ui() + + def setup_ui(self): + layout = QVBoxLayout(self) + layout.setSpacing(20) + + titel = QLabel("Statistik / Historie") + titel.setFont(QFont("", 18, QFont.Bold)) + layout.addWidget(titel) + + # Übersicht + self.tabelle = QTableWidget() + self.tabelle.setColumnCount(4) + self.tabelle.setHorizontalHeaderLabels(["Jahr", "Kosten", "Vorauszahlung", "Differenz"]) + self.tabelle.horizontalHeader().setStretchLastSection(True) + self.tabelle.setSelectionBehavior(QTableWidget.SelectRows) + layout.addWidget(self.tabelle) + + # Refresh Button + btn = QPushButton("Aktualisieren") + btn.clicked.connect(self.lade_daten) + layout.addWidget(btn) + + self.lade_daten() + + def lade_daten(self): + self.daten.lade_historie() + jahre = sorted(self.daten.historie.keys(), reverse=True) + + self.tabelle.setRowCount(len(jahre)) + + for i, jahr in enumerate(jahre): + eintrag = self.daten.historie[jahr] + + self.tabelle.setItem(i, 0, QTableWidgetItem(str(jahr))) + self.tabelle.setItem(i, 1, QTableWidgetItem(f"{eintrag.get('summe_kosten', 0):.2f} €")) + self.tabelle.setItem(i, 2, QTableWidgetItem(f"{eintrag.get('summe_vorauszahlung', 0):.2f} €")) + + diff = eintrag.get('differenz', 0) + diff_item = QTableWidgetItem(f"{diff:+.2f} €") + if diff > 0: + diff_item.setForeground(QColor("#CC0000")) + else: + diff_item.setForeground(QColor("#006600")) + self.tabelle.setItem(i, 3, diff_item) + + +class EinstellungenSeite(QWidget): + """Seite für Einstellungen""" + + def __init__(self, daten: DatenManager): + super().__init__() + self.daten = daten + self.setup_ui() + + def setup_ui(self): + layout = QVBoxLayout(self) + layout.setSpacing(20) + + titel = QLabel("Einstellungen") + titel.setFont(QFont("", 18, QFont.Bold)) + layout.addWidget(titel) + + # Ordner-Einstellungen + ordner_group = QGroupBox("Ordner") + ol = QFormLayout(ordner_group) + + # Daten-Ordner + daten_layout = QHBoxLayout() + self.daten_pfad = QLineEdit(self.daten.daten_ordner) + daten_layout.addWidget(self.daten_pfad) + btn_daten = QPushButton("...") + btn_daten.setFixedWidth(40) + btn_daten.clicked.connect(lambda: self.waehle_ordner(self.daten_pfad)) + daten_layout.addWidget(btn_daten) + ol.addRow("Daten:", daten_layout) + + # Dokumente-Ordner + dok_layout = QHBoxLayout() + self.dok_pfad = QLineEdit(self.daten.dokumente_ordner) + dok_layout.addWidget(self.dok_pfad) + btn_dok = QPushButton("...") + btn_dok.setFixedWidth(40) + btn_dok.clicked.connect(lambda: self.waehle_ordner(self.dok_pfad)) + dok_layout.addWidget(btn_dok) + ol.addRow("Dokumente:", dok_layout) + + # Vorlagen-Ordner + vorl_layout = QHBoxLayout() + self.vorl_pfad = QLineEdit(self.daten.vorlagen_ordner) + vorl_layout.addWidget(self.vorl_pfad) + btn_vorl = QPushButton("...") + btn_vorl.setFixedWidth(40) + btn_vorl.clicked.connect(lambda: self.waehle_ordner(self.vorl_pfad)) + vorl_layout.addWidget(btn_vorl) + ol.addRow("Vorlagen:", vorl_layout) + + layout.addWidget(ordner_group) + + # PDF-Einstellungen + pdf_group = QGroupBox("PDF-Vorlage") + pl = QFormLayout(pdf_group) + + self.briefkopf = QDoubleSpinBox() + self.briefkopf.setRange(0, 15) + self.briefkopf.setDecimals(1) + self.briefkopf.setSuffix(" cm") + self.briefkopf.setValue(self.daten.briefkopf_hoehe) + pl.addRow("Briefkopf-Höhe:", self.briefkopf) + + self.vorlage_label = QLabel(self._get_vorlage_status()) + pl.addRow("Vorlage-Status:", self.vorlage_label) + + layout.addWidget(pdf_group) + + # Speichern + btn_layout = QHBoxLayout() + btn_layout.addStretch() + btn = QPushButton("Speichern") + btn.setMinimumWidth(150) + btn.clicked.connect(self.speichern) + btn_layout.addWidget(btn) + layout.addLayout(btn_layout) + + layout.addStretch() + + def waehle_ordner(self, line_edit): + ordner = QFileDialog.getExistingDirectory(self, "Ordner wählen", line_edit.text()) + if ordner: + line_edit.setText(ordner) + + def _get_vorlage_status(self): + if self.daten.vorlage_datei.exists(): + return f"✓ {self.daten.vorlage_datei}" + return f"Keine Vorlage in {self.daten.vorlagen_ordner}" + + def speichern(self): + # Ordner aktualisieren + self.daten.daten_ordner = self.daten_pfad.text() + self.daten.dokumente_ordner = self.dok_pfad.text() + self.daten.vorlagen_ordner = self.vorl_pfad.text() + self.daten.speichere_config() + self.daten.aktualisiere_pfade() + + # PDF-Einstellungen + self.daten.briefkopf_hoehe = self.briefkopf.value() + self.daten.speichere_stammdaten() + + # Status aktualisieren + self.vorlage_label.setText(self._get_vorlage_status()) + + QMessageBox.information(self, "Gespeichert", "Einstellungen gespeichert.") + + +VERSION = "1.0.0" + + +class HauptFenster(QMainWindow): + """Hauptfenster der Anwendung""" + + def __init__(self): + super().__init__() + self.daten = DatenManager() + self.letzte_pdf = None + self.setup_ui() + self.setup_menu() + + def setup_ui(self): + self.setWindowTitle("Nebenkostenabrechnung") + self.setMinimumSize(900, 650) + + # Icon setzen + icon_pfad = Path(__file__).parent / "icon.svg" + if icon_pfad.exists(): + self.setWindowIcon(QIcon(str(icon_pfad))) + + # Zentrales Widget + central = QWidget() + self.setCentralWidget(central) + layout = QHBoxLayout(central) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # Navigation (links) + self.nav = QListWidget() + self.nav.setFixedWidth(180) + self.nav.setSpacing(2) + + items = [ + ("📝 Neue Abrechnung", 0), + ("👤 Stammdaten", 1), + ("📋 Kostenarten", 2), + ("📊 Statistik", 3), + ("⚙️ Einstellungen", 4), + ] + + for text, _ in items: + item = QListWidgetItem(text) + item.setSizeHint(QSize(170, 45)) + self.nav.addItem(item) + + self.nav.currentRowChanged.connect(self.seite_wechseln) + layout.addWidget(self.nav) + + # Trennlinie + line = QFrame() + line.setFrameShape(QFrame.VLine) + line.setStyleSheet("color: #ccc;") + layout.addWidget(line) + + # Seiten (rechts) + self.stack = QStackedWidget() + + self.abrechnung_seite = AbrechnungSeite(self.daten) + self.stammdaten_seite = StammdatenSeite(self.daten) + self.kostenarten_seite = KostenartenSeite(self.daten) + self.statistik_seite = StatistikSeite(self.daten) + self.einstellungen_seite = EinstellungenSeite(self.daten) + + self.stack.addWidget(self.abrechnung_seite) + self.stack.addWidget(self.stammdaten_seite) + self.stack.addWidget(self.kostenarten_seite) + self.stack.addWidget(self.statistik_seite) + self.stack.addWidget(self.einstellungen_seite) + + content_widget = QWidget() + content_layout = QVBoxLayout(content_widget) + content_layout.setContentsMargins(20, 20, 20, 20) + content_layout.addWidget(self.stack) + + layout.addWidget(content_widget, 1) + + # Erste Seite auswählen + self.nav.setCurrentRow(0) + + def setup_menu(self): + """Erstellt die Menüleiste""" + from PySide6.QtGui import QAction, QKeySequence + + menubar = self.menuBar() + + # Datei-Menü + datei_menu = menubar.addMenu("&Datei") + + action_neu = QAction("&Neue Abrechnung", self) + action_neu.setShortcut(QKeySequence("Ctrl+N")) + action_neu.triggered.connect(lambda: self.nav.setCurrentRow(0)) + datei_menu.addAction(action_neu) + + action_drucken = QAction("&Drucken...", self) + action_drucken.setShortcut(QKeySequence("Ctrl+P")) + action_drucken.triggered.connect(self.drucken) + datei_menu.addAction(action_drucken) + + action_ordner = QAction("Dokumente-&Ordner öffnen", self) + action_ordner.triggered.connect(self.ordner_oeffnen) + datei_menu.addAction(action_ordner) + + datei_menu.addSeparator() + + action_beenden = QAction("&Beenden", self) + action_beenden.setShortcut(QKeySequence("Ctrl+Q")) + action_beenden.triggered.connect(self.close) + datei_menu.addAction(action_beenden) + + # Bearbeiten-Menü + bearbeiten_menu = menubar.addMenu("&Bearbeiten") + + action_stamm = QAction("&Stammdaten", self) + action_stamm.triggered.connect(lambda: self.nav.setCurrentRow(1)) + bearbeiten_menu.addAction(action_stamm) + + action_kosten = QAction("&Kostenarten", self) + action_kosten.triggered.connect(lambda: self.nav.setCurrentRow(2)) + bearbeiten_menu.addAction(action_kosten) + + action_einst = QAction("&Einstellungen", self) + action_einst.triggered.connect(lambda: self.nav.setCurrentRow(4)) + bearbeiten_menu.addAction(action_einst) + + # Ansicht-Menü + ansicht_menu = menubar.addMenu("&Ansicht") + + action_statistik = QAction("&Statistik / Historie", self) + action_statistik.triggered.connect(lambda: self.nav.setCurrentRow(3)) + ansicht_menu.addAction(action_statistik) + + # Hilfe-Menü + hilfe_menu = menubar.addMenu("&?") + + action_info = QAction("&Info", self) + action_info.setShortcut(QKeySequence("F1")) + action_info.triggered.connect(self.zeige_info) + hilfe_menu.addAction(action_info) + + def drucken(self): + """Öffnet den Druckdialog für das letzte PDF""" + from PySide6.QtPrintSupport import QPrinter, QPrintDialog + from PySide6.QtGui import QPainter, QPixmap + + # Prüfen ob ein PDF existiert + jahr = self.abrechnung_seite.jahr.value() + mieter_name = self.daten.mieter.get("name", "").replace(" ", "_") + pdf_pfad = Path(self.daten.dokumente_ordner) / str(jahr) / f"Nebenkostenabrechnung_{jahr}_{mieter_name}.pdf" + + if not pdf_pfad.exists(): + QMessageBox.warning(self, "Kein PDF", "Bitte erst eine Abrechnung erstellen.") + return + + # Druckdialog + printer = QPrinter(QPrinter.HighResolution) + dialog = QPrintDialog(printer, self) + + if dialog.exec() == QPrintDialog.Accepted: + # PDF mit Systembefehl drucken + import subprocess + subprocess.run(["lpr", str(pdf_pfad)]) + QMessageBox.information(self, "Gedruckt", f"Druckauftrag gesendet:\n{pdf_pfad.name}") + + def ordner_oeffnen(self): + """Öffnet den Dokumente-Ordner""" + import subprocess + subprocess.run(["xdg-open", self.daten.dokumente_ordner]) + + def zeige_info(self): + """Zeigt den Info-Dialog""" + info_text = f"""

Nebenkostenabrechnung

+

Version: {VERSION}

+

Beschreibung:
+ Erstellt Nebenkostenabrechnungen für Mietobjekte + gemäß § 556 BGB mit allen Pflichtangaben.

+ +

Funktionen:

+ + +

Ordner:

+ + +

PDF-Vorlage:
+ Legen Sie eine Datei vorlage.pdf im Vorlagen-Ordner ab, + um einen Briefkopf/Wasserzeichen zu verwenden.

+ +
+

© 2024 - Entwickelt mit Python & PySide6

+ """ + + QMessageBox.about(self, "Info", info_text) + + def seite_wechseln(self, index): + self.stack.setCurrentIndex(index) + + # Daten aktualisieren + if index == 0: + self.abrechnung_seite.aktualisiere_kosten() + elif index == 2: + self.kostenarten_seite.lade_daten() + elif index == 3: + self.statistik_seite.lade_daten() + + +def installiere_startmenu(): + """Installiert Startmenü-Eintrag beim ersten Start""" + import shutil + + # Pfade + programm_ordner = Path(__file__).parent.resolve() + desktop_quelle = programm_ordner / "nebenkostenabrechnung.desktop" + desktop_ziel = Path.home() / ".local" / "share" / "applications" / "nebenkostenabrechnung.desktop" + + # Nur installieren wenn Quelldatei existiert und Ziel noch nicht + if desktop_quelle.exists() and not desktop_ziel.exists(): + try: + desktop_ziel.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(desktop_quelle, desktop_ziel) + print(f"Startmenü-Eintrag installiert: {desktop_ziel}") + except Exception as e: + print(f"Startmenü-Installation fehlgeschlagen: {e}") + + +def main(): + # Startmenü-Eintrag installieren (beim ersten Start) + installiere_startmenu() + + app = QApplication(sys.argv) + app.setStyle("Fusion") + + fenster = HauptFenster() + fenster.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b3f0212 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +PySide6>=6.5 +reportlab>=4.0 +PyPDF2>=3.0 diff --git a/vorlagen/vorlage.pdf b/vorlagen/vorlage.pdf new file mode 100644 index 0000000..032b474 Binary files /dev/null and b/vorlagen/vorlage.pdf differ