nebenkostenabrechnung/nebenkostenabrechnung.py
data 6b5f32b16c 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 <noreply@anthropic.com>
2026-03-05 09:35:16 +01:00

1599 lines
58 KiB
Python

#!/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"""<h2>Nebenkostenabrechnung</h2>
<p><b>Version:</b> {VERSION}</p>
<p><b>Beschreibung:</b><br>
Erstellt Nebenkostenabrechnungen für Mietobjekte
gemäß § 556 BGB mit allen Pflichtangaben.</p>
<p><b>Funktionen:</b></p>
<ul>
<li>Automatische Speicherung von Entwürfen</li>
<li>Belege anhängen (PDF, JPG, PNG)</li>
<li>PDF-Vorlage mit Briefkopf</li>
<li>Vorjahresvergleich</li>
<li>Historie aller Abrechnungen</li>
</ul>
<p><b>Ordner:</b></p>
<ul>
<li>Daten: {self.daten.daten_ordner}</li>
<li>Dokumente: {self.daten.dokumente_ordner}</li>
<li>Vorlagen: {self.daten.vorlagen_ordner}</li>
</ul>
<p><b>PDF-Vorlage:</b><br>
Legen Sie eine Datei <code>vorlage.pdf</code> im Vorlagen-Ordner ab,
um einen Briefkopf/Wasserzeichen zu verwenden.</p>
<hr>
<p style="color: gray;">© 2024 - Entwickelt mit Python & PySide6</p>
"""
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()