diff --git a/Documentation.md b/Documentation.md index 290f485..5c2221c 100644 --- a/Documentation.md +++ b/Documentation.md @@ -1,6 +1,15 @@ # Bedienung und technische Dokumentation -Stand: 16.10.2026 · Version 1.0 +Stand: 19.10.2026 · Version 1.1 + +## Desktop-Oberfläche + +`einvoice-ui` ergänzt die Konsole um Vorlagen, Kundenformular, mehrere Positionen, +Betragsübersicht, Entwurfsvorschau und eingebettete fertige PDF. +Installation: `einvoice --update --apply --with-gui`, danach +`python3 scripts/install_commands.py --desktop`. +Die GUI nutzt dieselbe Erstellung und Validierung wie die Konsole. +[Vollständige Bedienungs- und Entwicklungsdokumentation](docs/desktop-ui.md). ## Einrichtung und persönliche Vorlagen diff --git a/README.md b/README.md index 9230439..16e8f96 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,49 @@ -# einvoice – Rechnung mit einem Konsolenaufruf +# einvoice – Rechnungen per Oberfläche oder Konsolenaufruf + +## Desktop-Oberfläche + +**Vorlage laden → Daten prüfen und ergänzen → E-Rechnung erstellen → fertige PDF im Fenster ansehen.** +Die lokale Anwendung verwendet dieselbe Berechnung, Nummernvergabe und technische +Validierung wie die Konsole. Ein separates Repository ist deshalb nicht erforderlich. + +```bash +# Auf diesem Rechner eingerichtet: +einvoice-ui +# Alternativ: +einvoice --gui +``` + +Im Anwendungsmenü steht **JR eInvoice**. Kunden lassen sich wiederverwenden oder +neu erfassen. Mehrere Positionen mit mehrzeiliger Beschreibung, Mengen, deutschen +Nettopreisen, Steuersätzen, Leistungszeitraum und Zahlungsziel sind bearbeitbar. +Die Betragsübersicht rechnet sofort mit. „Entwurf ansehen“ zeigt eine PDF ohne +Nummernreservierung; „E-Rechnung erstellen“ speichert und validiert die endgültige +ZUGFeRD-PDF und zeigt sie direkt an. Ordner und Prüfbericht sind per Knopf erreichbar. + +Eigene YAML-Vorlagen und frühere `invoice.yaml`-Dateien können geladen werden. +Dabei werden Nummer und Datumsfelder für eine **neue** Rechnung zurückgesetzt. +Leistungsbeschreibung, Referenzen, Mengen, Preise und Zeitraum vor Erstellung prüfen. +Vorlagen ändern das Rechnungsdaten-Gerüst; das Layout kommt weiterhin aus der +gemeinsamen HTML/Jinja2-Vorlage. Die PDF-Ansicht ist schreibgeschützt. + +![Desktop-Oberfläche mit fiktiver Beispielrechnung](docs/assets/einvoice-ui.png) + +Erstinstallation / anderer Rechner: + +```bash +# Bestehendes Checkout mit funktionierender Laufzeit: +einvoice --update --apply --with-gui +python3 scripts/install_commands.py --desktop + +# Entwicklerumgebung (Java und Mustang wie unten einrichten): +pip install -e '.[pdf,gui]' +.venv/bin/einvoice-ui +``` + +Die GUI ist optional; reine Konsoleninstallationen benötigen Qt nicht. Nach einem +Update mit `--with-gui` behalten weitere Updates die Oberfläche bei. Rechnungs- und +Kundendaten bleiben lokal; die Anwendung versendet keine E-Mails. +[Bedienung, Installation und technische Details](docs/desktop-ui.md). Kundenname, Leistung, Menge und Nettopreis eingeben: `einvoice --create` erledigt Rechnungsnummer, Berechnung, PDF/A-Erstellung, Einbettung des EN16931-XML und @@ -114,7 +159,7 @@ einvoice --update --rollback jr-einvoice --help ``` -Der Installer verlinkt beide Starter nach `~/.local/bin` und prüft ihre Funktion. +Der Installer verlinkt die Konsolen- und GUI-Starter nach `~/.local/bin` und prüft die Konsole. Dort vorhandene andere Befehle werden vor dem Ersetzen gesichert. Wiederholtes Ausführen ist möglich. Auf diesem Rechner ist das Verzeichnis bereits im PATH. Bei einem gespeicherten alten Shell-Pfad einmal `rehash` (zsh) bzw. `hash -r` diff --git a/bin/einvoice b/bin/einvoice index 9c87ab7..2d02337 100755 --- a/bin/einvoice +++ b/bin/einvoice @@ -6,7 +6,7 @@ if [[ "${1:-}" == --update ]]; then shift exec python3 "$root/scripts/update.py" "$@" fi -# Resolve once: one invocation uses one complete release even during an update. +# Ein Aufruf verwendet auch während eines Updates eine vollständige Laufzeit. if [[ -L "$root/.runtime/current" ]]; then release="$(readlink -f -- "$root/.runtime/current")" export JR_EINVOICE_MUSTANG_JAR="$release/Mustang-CLI.jar" @@ -16,6 +16,14 @@ else fi export JR_EINVOICE_HOME="${JR_EINVOICE_HOME:-$root/private}" export JR_EINVOICE_PRESETS="${JR_EINVOICE_PRESETS:-$root/private/presets}" +if [[ "$(basename -- "$0")" == einvoice-ui || "${1:-}" == --gui ]]; then + if [[ "${1:-}" == --gui ]]; then shift; fi + if ! "$python" -I -c 'from PySide6.QtPdfWidgets import QPdfView' 2>/dev/null; then + echo 'Oberfläche fehlt. Einmalig: einvoice --update --apply --with-gui' >&2 + exit 1 + fi + exec "$python" -I -m jr_einvoice.gui "$@" +fi if [[ "$(basename -- "$0")" == jr-einvoice ]]; then exec "$python" -I -c 'from jr_einvoice.cli import app; app()' "$@" fi diff --git a/bin/einvoice-ui b/bin/einvoice-ui new file mode 120000 index 0000000..3764aa3 --- /dev/null +++ b/bin/einvoice-ui @@ -0,0 +1 @@ +einvoice \ No newline at end of file diff --git a/bin/jr-einvoice b/bin/jr-einvoice index 9c87ab7..2d02337 100755 --- a/bin/jr-einvoice +++ b/bin/jr-einvoice @@ -6,7 +6,7 @@ if [[ "${1:-}" == --update ]]; then shift exec python3 "$root/scripts/update.py" "$@" fi -# Resolve once: one invocation uses one complete release even during an update. +# Ein Aufruf verwendet auch während eines Updates eine vollständige Laufzeit. if [[ -L "$root/.runtime/current" ]]; then release="$(readlink -f -- "$root/.runtime/current")" export JR_EINVOICE_MUSTANG_JAR="$release/Mustang-CLI.jar" @@ -16,6 +16,14 @@ else fi export JR_EINVOICE_HOME="${JR_EINVOICE_HOME:-$root/private}" export JR_EINVOICE_PRESETS="${JR_EINVOICE_PRESETS:-$root/private/presets}" +if [[ "$(basename -- "$0")" == einvoice-ui || "${1:-}" == --gui ]]; then + if [[ "${1:-}" == --gui ]]; then shift; fi + if ! "$python" -I -c 'from PySide6.QtPdfWidgets import QPdfView' 2>/dev/null; then + echo 'Oberfläche fehlt. Einmalig: einvoice --update --apply --with-gui' >&2 + exit 1 + fi + exec "$python" -I -m jr_einvoice.gui "$@" +fi if [[ "$(basename -- "$0")" == jr-einvoice ]]; then exec "$python" -I -c 'from jr_einvoice.cli import app; app()' "$@" fi diff --git a/docs/assets/einvoice-ui.png b/docs/assets/einvoice-ui.png new file mode 100644 index 0000000..b718644 Binary files /dev/null and b/docs/assets/einvoice-ui.png differ diff --git a/docs/desktop-ui.md b/docs/desktop-ui.md new file mode 100644 index 0000000..92e3c4b --- /dev/null +++ b/docs/desktop-ui.md @@ -0,0 +1,148 @@ +# JR eInvoice: Desktop-Oberfläche + +Die Oberfläche ist Teil des bestehenden eInvoice-Repositorys. Berechnung, +Rechnungsnummern, XML, PDF/A und Mustang-Validierung werden gemeinsam mit der +Konsole weiterentwickelt. Es gibt weder einen Webserver noch ein Cloudkonto. +Das Fenster benötigt eine lokale Wayland- oder X11-Sitzung. + +## Start und Installation + +Auf Johannes' Rechner: **JR eInvoice** im Anwendungsmenü oder `einvoice-ui` in der +Konsole. `einvoice --gui` startet dasselbe Fenster. Die bestehende CLI funktioniert +weiter wie bisher. + +```bash +cd /home/johannes/localdev/einvoice +einvoice --update --apply --with-gui +python3 scripts/install_commands.py --desktop +einvoice-ui +``` + +Der Updater erstellt eine isolierte Laufzeit mit PDF- und GUI-Abhängigkeiten, +prüft sie und aktiviert sie erst bei Erfolg. GUI-Installation wird im +Laufzeitmanifest gemerkt und bei weiteren Updates beibehalten. +`einvoice --update --rollback` stellt die vorherige Laufzeit wieder her. Falls +diese noch keine GUI hat, funktioniert die Konsole weiterhin und der GUI-Starter +zeigt den Installationsbefehl. Es werden keine Rechnungsdaten zurückgerollt. + +Entwickler: `pip install -e '.[pdf,gui]'`; anschließend +`.venv/bin/einvoice-ui`. Nach Wheel-Installation heißt der Einstieg ebenfalls +`einvoice-ui`. PySide6 bringt Qt und die PDF-Ansicht mit; Java und die nativen +WeasyPrint-Bibliotheken bleiben Systemvoraussetzungen. Ein vorhandenes System- +PySide kann unvollständig sein; die isolierte Projektinstallation verwenden. + +## Eine Rechnung erstellen + +1. Eine eigene Vorlage oder frühere Rechnung aus der Auswahl laden. „Datei öffnen“ + liest auch eine beliebige kompatible YAML-Datei. „Neue Rechnung“ beginnt leer. +2. Einen gespeicherten Kunden wählen oder einen neuen Kunden mit Anschrift anlegen. + E-Mail und USt-ID sind optional. Neue Kunden werden bei der endgültigen + Erstellung einmalig gespeichert. Bei gespeicherten Kunden zeigt „Anschrift + anzeigen / verbergen“ die vollständigen schreibgeschützten Stammdaten. +3. Rechnungsdatum, tatsächlichen Leistungszeitraum, Zahlungsziel und bei Bedarf + Kundenreferenz oder Bestellnummer festlegen. Gleiches Von-/Bis-Datum bezeichnet + eine Leistung an einem Tag. Eine leere Rechnungsnummer wird automatisch vergeben. +4. Positionen ergänzen oder entfernen. Menge und Preis akzeptieren zum Beispiel + `7,5`, `85,00 €` und `1.234,56 EUR`. Preise sind netto je Einheit. Stunden, Stück + und Tage stehen zur Auswahl. Weitere Einheiten aus geladenen Rechnungen bleiben + erhalten. Zusätzliche Beschreibungen sind mehrzeilig. +5. „Entwurf ansehen“ erzeugt eine PDF im rechten Fensterbereich. Sie ist als Entwurf + gekennzeichnet, erhält keine Nummer und speichert keinen Kunden. Die Entwurfs- + PDF ist noch keine technisch validierte E-Rechnung. Die Vorschau lässt sich auf + Seitenbreite, ganze Seite oder 100 Prozent stellen; mehrere Seiten sind scrollbar. +6. „E-Rechnung erstellen“ legt einen neuen Rechnungsordner an, reserviert die Nummer + und erzeugt und prüft PDF/A und EN16931-XML. Nach Erfolg erscheint die **fertige + ZUGFeRD-PDF** im Fenster. „PDF extern öffnen“, „Rechnungsordner“ und „Prüfbericht“ + führen zu den gespeicherten Ergebnissen. Den PDF-Anhang direkt versenden. + +Die Betragsübersicht nutzt dieselbe Dezimalrechnung und Rundung pro Position und +Steuergruppe wie die Konsole. Ohne vollständige Eingaben wird keine Rechnung +angelegt. Nach erfolgreicher Erstellung ist der Knopf für dasselbe unveränderte +Formular gesperrt, damit ein Doppelklick keine weitere Rechnung anlegt. + +Wenn Eingaben geändert werden, bleibt die bisherige PDF sichtbar, erhält aber +einen Hinweis auf den vorherigen Stand. Die Ergebnis-Knöpfe werden gesperrt, bis +erneut eine endgültige Rechnung erfolgreich erstellt wurde. Eine erzeugte Rechnung +wird durch Formularänderungen nicht nachträglich verändert. Auch „Neue Rechnung“ +löscht keine vorhandene Rechnung. + +Technische Validierung prüft das Dokumentformat und die strukturierten Daten. +Empfänger, Leistungsdaten, Nummer und Betrag vor Versand inhaltlich prüfen. +Der Assistent unterstützt dieselben Rechnungstypen wie die Konsole; insbesondere +noch keine Gutschriften, Stornierung, Steuerbefreiung oder Reverse Charge. + +## Vorlagen und Layout + +„Als Vorlage speichern“ legt eine geschützte YAML-Datei unter +`private/templates/` an; vorhandene Namen werden nur nach Bestätigung ersetzt. +Eine Vorlage speichert Kunde, Positionen, Zahlungsziel, Hinweise und Referenzen. +**Rechnungsnummer, Rechnungsdatum und Leistungszeitraum sind nicht enthalten.** +Beim Laden werden Rechnungsdatum auf heute und Leistungszeitraum auf den aktuellen +Kalendermonat gesetzt. Das gilt ebenso beim Laden einer bestehenden `invoice.yaml`. +Beschreibungen mit alten Monatsnamen und alte Bestellnummern werden nicht automatisch +umgeschrieben; sie sind vor der nächsten Rechnung zu aktualisieren. + +Alte Rechnungen bleiben unverändert. Es werden aktuelle lokale Absender- und +Zahlungsdaten verwendet; fremde Bankdaten aus einer importierten Rechnung werden +nicht in die neue Rechnung übernommen. Kundenstammdaten bleiben vollständig erhalten. +Wenn derselbe Kundenname bereits mit einer anderen Anschrift oder anderen Angaben +gespeichert ist, wird die Erstellung gestoppt. Dann den passenden gespeicherten +Kunden auswählen oder die lokale Kundendatei bewusst korrigieren. + +Die gemeinsame Layoutvorlage liegt in `templates/invoice.html.j2`; es gibt keinen +visuellen Layoutdesigner. Ein eigenes Logo wird aus `private/presets/logo.svg` +geladen. Die Oberfläche rendert dieselbe Vorlage wie die Konsole. Unterstützung +der PDF-Ansicht beruht auf den offiziellen Qt-Komponenten +[QPdfDocument](https://doc.qt.io/qtforpython-6/PySide6/QtPdf/QPdfDocument.html) und +[QPdfView](https://doc.qt.io/qtforpython-6/PySide6/QtPdfWidgets/QPdfView.html). + +## Daten, Fehler und Betrieb + +`JR_EINVOICE_HOME`, `JR_EINVOICE_PRESETS` und `JR_EINVOICE_MUSTANG_JAR` gelten auch +für die Oberfläche. `einvoice-ui --root /pfad/rechnungen` ändert den Rechnungsstamm; +`--mustang-jar /pfad/Mustang-CLI.jar` wählt eine andere Mustang-Datei. Private Daten, +Vorlagen und Rechnungen werden nicht mit Git veröffentlicht. Vorlagen enthalten +Kundenangaben und gehören deshalb ebenfalls ins eigene geschützte Backup. + +Die PDF-Erzeugung läuft über `QProcess` in einem eigenen Python-Prozess. Das hält +das Fenster bedienbar und trennt die nativen Qt- und PDF-Bibliotheken voneinander. +Der Austausch erfolgt über einen temporären Ordner mit Modus 0700; die Auftrags- +datei hat Modus 0600. Aufträge und Entwurfsvorschau werden nach normalem Abschluss +bzw. Schließen bereinigt. Nach einem harten Prozessabbruch können temporäre Dateien +im Benutzer-/System-Tempverzeichnis verbleiben; sie werden nicht als Rechnungen +oder Vorlagen registriert. Es werden weder Nutzereingaben als Shell-Befehle +ausgeführt noch Serverports geöffnet. + +Während einer Erstellung kann das Fenster nicht geschlossen werden. Ein Fehler +erscheint mit Diagnosepfad. Nach erfolgter Nummernreservierung bleiben Nummer, +Entwurf und Log erhalten; der Knopf ist nach dem Fehler wieder bedienbar. Ein neuer +Versuch mit automatischer Nummer kann die nächste Nummer reservieren. Den vorhandenen +Entwurf stattdessen über `jr-einvoice generate ` wiederholen. +Ein technischer Fehler veröffentlicht keine fertige E-Rechnung. Beim erfolgreichen +Ergebnis bleiben `invoice.yaml`, XML, PDF, Prüfbericht, Log und Hashmanifest im +Rechnungsordner erhalten. + +## Entwicklung und Prüfung + +Neuer Quellcode und Kommentare sind deutsch beschrieben: + +| Datei | Aufgabe | +| --- | --- | +| `jr_einvoice/gui.py` | Fenster, Formulare, PDF-Ansicht, Prozesssteuerung | +| `jr_einvoice/gui_data.py` | Geprüfte Formulardaten und Vorlagen | +| `jr_einvoice/gui_worker.py` | PDF-Aufträge ohne Qt im eigenen Prozess | +| `jr_einvoice/create.py` | Gemeinsame Rechnungserstellung für GUI und CLI | +| `tests/test_gui.py` | Vorlagen, Fehlerfälle, Vorschau, echter Erstellungsablauf | + +```bash +QT_QPA_PLATFORM=offscreen .venv/bin/python -m unittest discover -s tests -v +QT_QPA_PLATFORM=offscreen JR_EINVOICE_INTEGRATION=1 \ + .venv/bin/python -m unittest discover -s tests -v +``` + +Ohne GUI-Extra werden Qt-Tests übersprungen, Vorlagentests laufen weiterhin. +Die Integration prüft einen tatsächlichen Button-Klick bis zur validierten PDF in +der eingebetteten Ansicht. Hinzu kommen ungültige Eingaben, geschützte Vorlagen, +alte Rechnungsdaten, Kundenkonflikte, Hintergrundfehler und Doppelklickschutz. +Tests verwenden ausschließlich temporäre Verzeichnisse und fiktive Beispieldaten; +sie belegen keine Nummern im echten Rechnungsstamm. diff --git a/jr_einvoice/create.py b/jr_einvoice/create.py index 54b4451..c403a98 100644 --- a/jr_einvoice/create.py +++ b/jr_einvoice/create.py @@ -1,4 +1,4 @@ -"""One-command invoice creation with reusable private customer records.""" +"""Rechnungen für Konsole und Oberfläche mit gemeinsamen Prüfungen erstellen.""" from dataclasses import dataclass from datetime import date, timedelta from decimal import Decimal, InvalidOperation @@ -16,7 +16,7 @@ from .calc import compute_totals from .utils import money_fmt from .folder_naming import default_invoice_folder_name from .invoice_number import invoice_root_lock -from .models import Invoice, Party +from .models import Invoice, Item, Party from .pipeline import build_in_folder, BuildResult from .presets import load_yaml @@ -24,7 +24,7 @@ MONTHS = 'januar februar märz april mai juni juli august september oktober nove def decimal_input(value: str) -> Decimal: - """Accept 85,00 €, 1.234,56 EUR or ungrouped decimal-point notation.""" + """Deutsche Beträge und ungruppierte Zahlen mit Dezimalpunkt einlesen.""" raw = re.sub(r'\s*(?:€|EUR)\s*$', '', value.strip(), flags=re.I) raw = raw.replace('\u00a0', '').strip() if ',' in raw: @@ -43,7 +43,7 @@ def decimal_input(value: str) -> Decimal: def customer_key(name: str) -> str: - # Hash prevents punctuation/transliteration collisions and path traversal. + # Der Hash verhindert Namenskollisionen und Pfadmanipulation durch Kundennamen. readable = re.sub(r'[^a-z0-9]+', '-', name.casefold()).strip('-')[:32] or 'kunde' return f'{readable}-{hashlib.sha256(name.casefold().encode()).hexdigest()[:8]}' @@ -67,7 +67,7 @@ def save_customer(presets: Path, name: str, buyer: dict) -> tuple[str, dict]: directory.mkdir(parents=True, exist_ok=True, mode=0o700) target = directory / f'{key}.yaml' data = {'buyer': buyer} - # Exclusive create; never replace an existing customer's stored details silently. + # Vorhandene Kundenstammdaten niemals stillschweigend ersetzen. with target.open('x', encoding='utf-8') as file: target.chmod(0o600) yaml.safe_dump(data, file, allow_unicode=True, sort_keys=False) @@ -106,6 +106,10 @@ def create_invoice(*, root: Path, resources: Path, presets: Path, customer: str, month: str | None = None, service_date: date | None = None, unit: str = 'HUR', tax: Decimal = Decimal('19'), due_days: int = 30, buyer_reference: str | None = None, order_reference: str | None = None, + items: list[Item] | None = None, + notes: str = 'Vielen Dank für Ihren Auftrag.', + service_period_start: date | None = None, + service_period_end: date | None = None, progress=lambda message: None) -> CreatedInvoice: if due_days < 0: raise ValueError('Zahlungsziel darf nicht negativ sein') @@ -120,9 +124,21 @@ def create_invoice(*, root: Path, resources: Path, presets: Path, customer: str, key, record = match issued = issued or date.today() delivery, start, end = service_period(entry, month, service_date, issued) + if service_period_start is not None or service_period_end is not None: + if month or service_date: + raise ValueError('Leistungszeitraum nicht mit Monat oder Einzeltermin kombinieren') + if not service_period_start or not service_period_end or service_period_start > service_period_end: + raise ValueError('Leistungszeitraum benötigt einen Beginn und ein Ende in richtiger Reihenfolge') + start, end, delivery = service_period_start, service_period_end, service_period_end + positions = items if items is not None else [Item(name=entry, quantity=volume, unit=unit, + unit_price_net=price, tax_rate=tax)] + # Eingaben vor Nummernreservierung und Dateizugriff auf ihre Form prüfen. + positions = [Item.model_validate(item) for item in positions] + if not positions: + raise ValueError('Mindestens eine Rechnungsposition ist erforderlich') root = root.expanduser().resolve() with invoice_root_lock(root): - # Only dedicated invoice directories; unknown/corrupt records stop allocation. + # Unklare vorhandene Entwürfe stoppen die Vergabe neuer Nummern. used = set() for path in root.glob('*/invoice.yaml'): existing = load_yaml(path).get('meta', {}).get('invoice_number') @@ -145,12 +161,11 @@ def create_invoice(*, root: Path, resources: Path, presets: Path, customer: str, 'meta': {'invoice_number': number, 'invoice_date': issued, 'service_date': delivery, 'service_period_start': start, 'service_period_end': end, 'buyer_reference': buyer_reference, 'order_reference': order_reference, - 'notes': 'Vielen Dank für Ihren Auftrag.'}, + 'notes': notes}, 'seller': seller_data['seller'], 'buyer': record['buyer'], 'payment': {**seller_data['payment'], 'due_date': issued + timedelta(days=due_days), 'reference': f'Rechnung {number}'}, - 'items': [{'name': entry, 'quantity': volume, 'unit': unit, - 'unit_price_net': price, 'tax_rate': tax}], + 'items': [item.model_dump(mode='json') for item in positions], }) with tempfile.TemporaryDirectory(prefix='.create-', dir=root) as staging: work = Path(staging) / 'invoice' @@ -164,7 +179,7 @@ def create_invoice(*, root: Path, resources: Path, presets: Path, customer: str, if logo.is_file(): shutil.copy2(logo, work / 'assets/logo.svg') work.rename(target) - # Retain the reserved number and input on build failures for diagnosis/retry. + # Bei Fehlern Nummer und Entwurf für Diagnose und Wiederholung erhalten. from .logging_utils import setup_logging logger = setup_logging('WARNING', target / 'out/logs/run.log') progress(f'Rechnungsdaten vollständig: {number} · {inv.buyer.name}') diff --git a/jr_einvoice/gui.py b/jr_einvoice/gui.py new file mode 100644 index 0000000..f16045f --- /dev/null +++ b/jr_einvoice/gui.py @@ -0,0 +1,739 @@ +"""Lokaler Rechnungsassistent mit Qt und eingebetteter PDF-Ansicht.""" +from __future__ import annotations + +import argparse +import calendar +from datetime import date +import json +from pathlib import Path +import sys +import tempfile + +from PySide6.QtCore import QDate, Qt, QObject, QProcess, QUrl, Signal +from PySide6.QtGui import QDesktopServices, QIcon +from PySide6.QtPdf import QPdfDocument +from PySide6.QtPdfWidgets import QPdfView +from PySide6.QtWidgets import ( + QApplication, QComboBox, QDateEdit, QFileDialog, QFormLayout, QFrame, + QGridLayout, QGroupBox, QHBoxLayout, QInputDialog, QLabel, QLineEdit, + QMainWindow, QMessageBox, QPlainTextEdit, QProgressBar, QPushButton, + QScrollArea, QSpinBox, QSplitter, QVBoxLayout, QWidget, +) + +from .calc import compute_totals, line_net +from .cli import _default_mustang_jar +from .create import CreatedInvoice, decimal_input, find_customer +from .gui_data import InvoiceForm, InvoiceTemplate, load_template, save_template +from .models import Invoice, Item, Party +from .pipeline import BuildResult +from .paths import private_root, presets_root, resource_root +from .presets import load_yaml +from .utils import money_fmt + + +STYLE = """ +QWidget { font-family: 'Noto Sans', 'DejaVu Sans'; font-size: 13px; color: #24374b; } +QMainWindow, QScrollArea, QWidget#editor { background: #f1f5f9; } +QFrame#header { background: #123655; border-radius: 12px; } +QLabel#brand { color: white; font-size: 25px; font-weight: 700; } +QLabel#tagline { color: #c7dbea; } +QLabel#badge { color: #b4eadb; background: #204a61; padding: 8px 14px; border-radius: 14px; } +QGroupBox { background: white; border: 1px solid #dce5ed; border-radius: 10px; + margin-top: 22px; padding: 20px 14px 12px; font-weight: 600; } +QGroupBox::title { subcontrol-origin: margin; left: 14px; padding: 0 4px; } +QLineEdit, QPlainTextEdit, QComboBox, QDateEdit, QSpinBox { + background: white; color: #24374b; selection-background-color: #c1e9df; + border: 1px solid #c7d4df; border-radius: 5px; padding: 7px; min-height: 20px; } +QLineEdit:focus, QPlainTextEdit:focus, QComboBox:focus, QDateEdit:focus, QSpinBox:focus { + border: 1px solid #168575; } +QLineEdit:read-only { background: #f4f7fa; color: #52677b; } +QPushButton { background: white; border: 1px solid #bdcdda; border-radius: 6px; + padding: 9px 13px; min-height: 18px; font-weight: 600; } +QPushButton:hover { background: #e7f0f6; } +QPushButton:disabled { color: #8696a7; background: #edf1f4; border-color: #dce5ed; } +QPushButton#primary { color: white; background: #087f70; border: 0; padding: 12px 18px; } +QPushButton#primary:hover { background: #066c60; } +QPushButton#primary:disabled { background: #8bbab1; } +QPushButton#remove { color: #a34242; font-size: 12px; padding: 5px 9px; } +QLabel#muted { color: #60768b; } +QLabel#total { color: #123655; font-size: 21px; font-weight: 700; } +QFrame#summary { background: #e4f1ed; border-radius: 8px; padding: 6px; } +QLabel#notice { background: #e8eff5; padding: 12px; border-radius: 7px; } +QProgressBar { border: 0; background: #e1e9ef; height: 5px; border-radius: 2px; } +QProgressBar::chunk { background: #087f70; } +QSplitter::handle { background: #dce5ed; width: 3px; } +""" + + +def label(text: str, name: str = '') -> QLabel: + widget = QLabel(text) + widget.setObjectName(name) + widget.setWordWrap(True) + # Fachliche Eingaben dürfen keine HTML-Auszeichnung in Statusfeldern erzeugen. + widget.setTextFormat(Qt.TextFormat.PlainText) + return widget + + +def button(text: str, callback, name: str = '') -> QPushButton: + widget = QPushButton(text) + widget.setObjectName(name) + widget.clicked.connect(callback) + return widget + + +def date_edit(value: date) -> QDateEdit: + widget = QDateEdit(QDate(value.year, value.month, value.day)) + widget.setCalendarPopup(True) + widget.setDisplayFormat('dd.MM.yyyy') + return widget + + +def german_number(value) -> str: + return format(value, 'f').replace('.', ',') + + +class Position(QGroupBox): + """Eine Position mit mehrzeiliger Beschreibung und deutscher Zahleneingabe.""" + + changed = Signal() + remove_requested = Signal(object) + + def __init__(self, item: Item | None = None): + super().__init__('Rechnungsposition') + layout = QVBoxLayout(self) + head = QHBoxLayout() + self.name = QLineEdit() + self.name.setPlaceholderText('Leistung, z. B. Softwareentwicklung') + head.addWidget(self.name, 1) + head.addWidget(button('Entfernen', lambda: self.remove_requested.emit(self), 'remove')) + layout.addLayout(head) + self.description = QPlainTextEdit() + self.description.setPlaceholderText('Weitere Angaben zur Leistung (optional)') + self.description.setFixedHeight(62) + layout.addWidget(self.description) + grid = QGridLayout() + self.quantity = QLineEdit('1') + self.unit = QComboBox() + for text, code in [('Stunden', 'HUR'), ('Stück', 'C62'), ('Tage', 'DAY')]: + self.unit.addItem(text, code) + self.price = QLineEdit('0,00') + self.tax = QLineEdit('19') + self.total = label('0,00 EUR', 'muted') + for col, (title, widget) in enumerate([ + ('Menge', self.quantity), ('Einheit', self.unit), ('Einzelpreis netto', self.price), + ('USt %', self.tax), ('Position netto', self.total), + ]): + grid.addWidget(label(title, 'muted'), 0, col) + grid.addWidget(widget, 1, col) + grid.setColumnStretch(col, 1) + layout.addLayout(grid) + if item: + self.name.setText(item.name) + self.description.setPlainText(item.description or '') + self.quantity.setText(german_number(item.quantity)) + self.price.setText(german_number(item.unit_price_net)) + self.tax.setText(german_number(item.tax_rate)) + index = self.unit.findData(item.unit) + if index < 0: + self.unit.addItem(item.unit, item.unit) + index = self.unit.count() - 1 + self.unit.setCurrentIndex(index) + for field in (self.name, self.quantity, self.price, self.tax): + field.textChanged.connect(self.changed) + self.description.textChanged.connect(self.changed) + self.unit.currentIndexChanged.connect(self.changed) + + def item(self) -> Item: + return Item(name=self.name.text(), description=self.description.toPlainText() or None, + quantity=decimal_input(self.quantity.text()), unit=self.unit.currentData(), + unit_price_net=decimal_input(self.price.text()), tax_rate=decimal_input(self.tax.text())) + + +class InvoiceWorker(QObject): + """PDF und Java-Prüfung von der Qt-Laufzeit vollständig trennen.""" + + progress = Signal(str) + succeeded = Signal(object) + failed = Signal(str) + finished = Signal() + + def __init__(self, request, parent=None): + super().__init__(parent) + self.directory = tempfile.TemporaryDirectory(prefix='jr-einvoice-auftrag-') + self.request_path = Path(self.directory.name) / 'request.json' + self.request_path.write_text(json.dumps(request), encoding='utf-8') + self.request_path.chmod(0o600) + self.process = QProcess(self) + self.process.setProgram(sys.executable) + self.process.setArguments(['-I', '-m', 'jr_einvoice.gui_worker', str(self.request_path)]) + self.process.readyReadStandardOutput.connect(self.read_progress) + self.process.finished.connect(self.completed) + self.process.errorOccurred.connect(self.process_error) + self.buffer = b'' + self.done = False + + def start(self): + self.process.start() + + def read_progress(self): + self.buffer += bytes(self.process.readAllStandardOutput()) + while b'\n' in self.buffer: + line, self.buffer = self.buffer.split(b'\n', 1) + try: + self.progress.emit(json.loads(line)['progress']) + except (ValueError, KeyError): + pass + + def process_error(self, error): + if error == QProcess.ProcessError.FailedToStart: + self.completed(-1) + + def completed(self, code, *_): + if self.done: + return + self.done = True + try: + path = self.request_path.with_name('result.json') + if not path.is_file(): + detail = bytes(self.process.readAllStandardError()).decode('utf-8', errors='replace')[-4000:] + raise RuntimeError(f'Hintergrundprozess fehlgeschlagen (Exit {code}). {detail}') + payload = json.loads(path.read_text(encoding='utf-8')) + if code or 'error' in payload: + raise RuntimeError(payload.get('error', f'Hintergrundprozess beendet (Exit {code})')) + self.succeeded.emit(payload['result']) + except Exception as exc: + self.failed.emit(str(exc)) + finally: + self.directory.cleanup() + self.finished.emit() + + +class InvoiceWindow(QMainWindow): + def __init__(self, *, root: Path | None = None, presets: Path | None = None, + jar: Path | None = None): + super().__init__() + self.resources = resource_root() + self.root = root or private_root() / 'invoices' + self.presets = presets or presets_root() + self.jar = jar or _default_mustang_jar() + self.template_dir = private_root() / 'templates' + self.scratch = tempfile.TemporaryDirectory(prefix='jr-einvoice-vorschau-') + self.worker = None + self.positions: list[Position] = [] + self.result = None + self.generated = False + self.dirty = False + self.loading = True + self.setWindowTitle('JR IT Services · eInvoice') + self.resize(1520, 1000) + self.setMinimumSize(1000, 720) + icon = self.resources / 'assets/logo.svg' + self.setWindowIcon(QIcon(str(icon))) + self.setStyleSheet(STYLE) + central = QWidget() + self.setCentralWidget(central) + outer = QVBoxLayout(central) + outer.setContentsMargins(20, 16, 20, 16) + header = QFrame() + header.setObjectName('header') + row = QHBoxLayout(header) + row.setContentsMargins(22, 18, 22, 18) + titles = QVBoxLayout() + titles.addWidget(label('JR IT Services / eInvoice', 'brand')) + titles.addWidget(label('Von der Vorlage zur geprüften E-Rechnung.', 'tagline')) + row.addLayout(titles, 1) + row.addWidget(label('LOKAL · ZUGFeRD / Factur-X', 'badge')) + row.addWidget(button('Neue Rechnung', self.new_invoice)) + outer.addWidget(header) + splitter = QSplitter() + outer.addWidget(splitter, 1) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + self.editor = QWidget() + self.editor.setObjectName('editor') + layout = QVBoxLayout(self.editor) + layout.setContentsMargins(0, 0, 14, 0) + scroll.setWidget(self.editor) + splitter.addWidget(scroll) + self._template_section(layout) + self._customer_section(layout) + self._invoice_section(layout) + self.positions_layout = QVBoxLayout() + self.positions_layout.setContentsMargins(0, 0, 0, 0) + layout.addLayout(self.positions_layout) + layout.addWidget(button('+ Position hinzufügen', self.add_position)) + self.notes = QPlainTextEdit('Vielen Dank für Ihren Auftrag.') + self.notes.setFixedHeight(70) + layout.addWidget(label('Hinweise auf der Rechnung', 'muted')) + layout.addWidget(self.notes) + layout.addStretch() + self._preview_section(splitter) + splitter.setSizes([760, 720]) + splitter.setChildrenCollapsible(False) + self.progress = QProgressBar() + self.progress.setRange(0, 1) + self.progress.setValue(0) + self.progress.setTextVisible(False) + outer.addWidget(self.progress) + self.status = label('Kunden und Positionen erfassen oder eine Vorlage laden.', 'notice') + self.status.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + outer.addWidget(self.status) + actions = QHBoxLayout() + self.totals = label('Netto — · USt — · Brutto —', 'total') + actions.addWidget(self.totals, 1) + self.preview_button = button('Entwurf ansehen', self.preview) + self.generate_button = button('E-Rechnung erstellen', self.generate, 'primary') + actions.addWidget(self.preview_button) + actions.addWidget(self.generate_button) + outer.addLayout(actions) + self._connect_changes() + self.refresh_customers() + self.refresh_templates() + self.add_position() + self.loading = False + self.dirty = False + self.changed() + self.dirty = False + + def _template_section(self, layout): + group = QGroupBox('01 Vorlage') + box = QVBoxLayout(group) + self.templates = QComboBox() + self.templates.setMinimumContentsLength(20) + self.templates.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon) + box.addWidget(self.templates) + row = QHBoxLayout() + row.addWidget(button('Auswahl laden', self.load_selected_template)) + row.addWidget(button('Datei öffnen …', self.load_template_file)) + row.addWidget(button('Als Vorlage speichern …', self.save_current_template)) + box.addLayout(row) + box.addWidget(label('Vorlagen und frühere Rechnungen als Ausgangspunkt verwenden.', 'muted')) + layout.addWidget(group) + + def _customer_section(self, layout): + group = QGroupBox('02 Rechnungsempfänger') + box = QVBoxLayout(group) + self.customers = QComboBox() + self.customers.currentIndexChanged.connect(self.select_customer) + box.addWidget(self.customers) + self.customer_summary = label('', 'muted') + box.addWidget(self.customer_summary) + self.customer_toggle = button('Anschrift anzeigen / verbergen', self.toggle_customer_details) + box.addWidget(self.customer_toggle) + self.customer_details = QWidget() + form = QFormLayout(self.customer_details) + form.setContentsMargins(0, 0, 0, 0) + box.addWidget(self.customer_details) + self.buyer_fields = {} + for key, title in [('name', 'Firma / Name'), ('street', 'Straße / Hausnummer'), + ('postal_code', 'Postleitzahl'), ('city', 'Ort'), ('country', 'Land (ISO-Code)'), + ('email', 'Rechnungs-E-Mail (optional)'), ('vat_id', 'USt-ID (optional)')]: + field = QLineEdit('DE' if key == 'country' else '') + self.buyer_fields[key] = field + form.addRow(title, field) + self.customer_note = label('Neue Kunden werden bei der Erstellung gespeichert.', 'muted') + box.addWidget(self.customer_note) + layout.addWidget(group) + + def toggle_customer_details(self): + self.customer_details.setVisible(not self.customer_details.isVisible()) + + def _invoice_section(self, layout): + group = QGroupBox('03 Rechnungsdaten') + form = QGridLayout(group) + self.number = QLineEdit() + self.number.setPlaceholderText('Automatisch: JR-JJJJ-NNNN') + self.issued = date_edit(date.today()) + today = date.today() + self.start = date_edit(today.replace(day=1)) + self.end = date_edit(today.replace(day=calendar.monthrange(today.year, today.month)[1])) + self.due = QSpinBox() + self.due.setRange(0, 3650) + self.due.setValue(30) + self.due.setSuffix(' Tage') + self.buyer_reference = QLineEdit() + self.order_reference = QLineEdit() + for index, (text, field) in enumerate([ + ('Rechnungsnummer', self.number), ('Rechnungsdatum', self.issued), + ('Leistung von', self.start), ('Leistung bis', self.end), + ('Zahlungsziel', self.due), ('Bestellnummer', self.order_reference), + ('Kundenreferenz', self.buyer_reference)]): + row, col = divmod(index, 2) + form.addWidget(label(text, 'muted'), row * 2, col) + form.addWidget(field, row * 2 + 1, col) + form.setColumnStretch(0, 1) + form.setColumnStretch(1, 1) + layout.addWidget(group) + + def _preview_section(self, splitter): + pane = QWidget() + layout = QVBoxLayout(pane) + layout.setContentsMargins(14, 4, 0, 0) + row = QHBoxLayout() + row.addWidget(label('Rechnungsvorschau', 'total'), 1) + zoom = QComboBox() + zoom.addItems(['Seitenbreite', 'Ganze Seite', '100 %']) + row.addWidget(zoom) + layout.addLayout(row) + self.preview_state = label('Hier erscheint der Entwurf oder die fertige, geprüfte PDF.', 'notice') + layout.addWidget(self.preview_state) + self.document = QPdfDocument(self) + self.pdf = QPdfView() + self.pdf.setDocument(self.document) + self.pdf.setPageMode(QPdfView.PageMode.MultiPage) + self.pdf.setZoomMode(QPdfView.ZoomMode.FitToWidth) + zoom.currentIndexChanged.connect(lambda index: self.pdf.setZoomMode([ + QPdfView.ZoomMode.FitToWidth, QPdfView.ZoomMode.FitInView, QPdfView.ZoomMode.Custom][index])) + layout.addWidget(self.pdf, 1) + self.path_label = label('', 'muted') + self.path_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + layout.addWidget(self.path_label) + row = QHBoxLayout() + self.open_pdf = button('PDF extern öffnen', lambda: self.open_result('pdf')) + self.open_folder = button('Rechnungsordner', lambda: self.open_result('folder')) + self.open_report = button('Prüfbericht', lambda: self.open_result('report')) + for widget in (self.open_pdf, self.open_folder, self.open_report): + widget.setEnabled(False) + row.addWidget(widget) + layout.addLayout(row) + splitter.addWidget(pane) + + def _connect_changes(self): + for field in [*self.buyer_fields.values(), self.number, self.buyer_reference, self.order_reference]: + field.textChanged.connect(self.changed) + for field in (self.issued, self.start, self.end): + field.dateChanged.connect(self.changed) + self.due.valueChanged.connect(self.changed) + self.notes.textChanged.connect(self.changed) + + def changed(self, *_): + if self.loading: + return + self.dirty = True + self.generated = False + self.generate_button.setEnabled(self.worker is None) + self.preview_button.setEnabled(self.worker is None) + if self.document.pageCount(): + self.preview_state.setText('Eingaben geändert · Die angezeigte PDF zeigt den vorherigen Stand.') + for widget in (self.open_pdf, self.open_folder, self.open_report): + widget.setEnabled(False) + try: + items = [position.item() for position in self.positions] + # Gleiche Rundung wie im Rechnungskern, einschließlich Steuergruppen. + from types import SimpleNamespace + totals = compute_totals(SimpleNamespace(items=items)) + self.totals.setText(f'Netto {money_fmt(totals.net_total)} · USt {money_fmt(totals.tax_total)}' + f' · Brutto {money_fmt(totals.gross_total)}') + for position, item in zip(self.positions, items): + position.total.setText(money_fmt(line_net(item))) + except (ValueError, ArithmeticError): + self.totals.setText('Beträge ergänzen, um die Summe zu berechnen.') + + def add_position(self, item=None): + # Das clicked-Signal liefert bei einem Button ein bool statt einer Position. + position = Position(item if isinstance(item, Item) else None) + position.changed.connect(self.changed) + position.remove_requested.connect(self.remove_position) + self.positions.append(position) + self.positions_layout.addWidget(position) + self.changed() + + def remove_position(self, position): + self.positions.remove(position) + self.positions_layout.removeWidget(position) + position.deleteLater() + self.changed() + + def refresh_customers(self): + self.customers.blockSignals(True) + self.customers.clear() + self.customers.addItem('Neuen Kunden erfassen', None) + for path in sorted((self.presets / 'customers').glob('*.yaml')): + buyer = Party.model_validate(load_yaml(path)['buyer']) + self.customers.addItem(f'{buyer.name} ({path.stem})', buyer) + self.customers.blockSignals(False) + self.select_customer() + + def select_customer(self, *_): + buyer = self.customers.currentData() + data = buyer.model_dump() if buyer else {} + address = data.get('address', {}) + for key, field in self.buyer_fields.items(): + field.setReadOnly(buyer is not None) + field.setText(str(data.get(key) or address.get(key) or ('DE' if key == 'country' else ''))) + self.customer_note.setText('Gespeicherte Stammdaten werden unverändert verwendet.' if buyer else + 'Neue Kunden werden bei der Erstellung gespeichert.') + self.customer_summary.setText(f'{buyer.name}\n{buyer.address.street} · ' + f'{buyer.address.postal_code} {buyer.address.city} · {buyer.address.country}' + if buyer else '') + self.customer_summary.setVisible(buyer is not None) + self.customer_toggle.setVisible(buyer is not None) + self.customer_details.setVisible(buyer is None) + self.changed() + + def refresh_templates(self): + self.templates.clear() + self.templates.addItem('Vorlage oder frühere Rechnung auswählen …', None) + for path in sorted(self.template_dir.glob('*.yaml')): + self.templates.addItem(f'Vorlage · {path.stem}', path) + for path in sorted(self.root.glob('*/invoice.yaml'), reverse=True): + self.templates.addItem(f'Rechnung · {path.parent.name}', path) + + def form(self) -> InvoiceForm: + fields = {key: field.text().strip() for key, field in self.buyer_fields.items()} + buyer = self.customers.currentData() or Party.model_validate({ + 'name': fields['name'], 'email': fields['email'] or None, 'vat_id': fields['vat_id'] or None, + 'address': {key: fields[key].upper() if key == 'country' else fields[key] + for key in ('street', 'postal_code', 'city', 'country')}}) + return InvoiceForm(buyer=buyer, items=[position.item() for position in self.positions], + issued=self.issued.date().toPython(), due_days=self.due.value(), + start=self.start.date().toPython(), end=self.end.date().toPython(), + number=self.number.text().strip() or None, notes=self.notes.toPlainText(), + buyer_reference=self.buyer_reference.text().strip() or None, + order_reference=self.order_reference.text().strip() or None) + + def error(self, message): + self.status.setText('Bitte prüfen: ' + message) + dialog = QMessageBox(self) + dialog.setIcon(QMessageBox.Icon.Warning) + dialog.setWindowTitle('Rechnung konnte nicht verarbeitet werden') + dialog.setTextFormat(Qt.TextFormat.PlainText) + dialog.setText(message) + dialog.exec() + + def confirm_replace(self) -> bool: + return not self.dirty or QMessageBox.question( + self, 'Formular ersetzen?', 'Die aktuellen Formulareingaben durch die Vorlage ersetzen?', + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No) == QMessageBox.StandardButton.Yes + + def new_invoice(self): + if self.worker is not None or not self.confirm_replace(): + return + self.loading = True + self.refresh_customers() + for position in list(self.positions): + self.remove_position(position) + self.add_position() + self.number.clear() + today = date.today() + self.issued.setDate(QDate(today.year, today.month, today.day)) + self.start.setDate(QDate(today.year, today.month, 1)) + self.end.setDate(QDate(today.year, today.month, calendar.monthrange(today.year, today.month)[1])) + self.due.setValue(30) + self.buyer_reference.clear() + self.order_reference.clear() + self.notes.setPlainText('Vielen Dank für Ihren Auftrag.') + self.result = None + self.document.close() + self.path_label.clear() + self.preview_state.setText('Hier erscheint der Entwurf oder die fertige, geprüfte PDF.') + self.loading = False + self.changed() + self.dirty = False + self.status.setText('Neue Rechnung: Kunden und Positionen erfassen oder Vorlage laden.') + + def load_selected_template(self): + path = self.templates.currentData() + if path: + self.apply_template(path) + + def load_template_file(self): + path, _ = QFileDialog.getOpenFileName(self, 'Rechnungsvorlage laden', str(self.template_dir), + 'YAML-Dateien (*.yaml *.yml)') + if path: + self.apply_template(Path(path)) + + def apply_template(self, path: Path): + try: + template = load_template(path) + if not self.confirm_replace(): + return + self.loading = True + self.customers.setCurrentIndex(0) + # Bei importierten Vorlagen die vollständigen Stammdaten vergleichen. + found = False + for index in range(1, self.customers.count()): + if self.customers.itemData(index) == template.buyer: + self.customers.setCurrentIndex(index) + found = True + break + if not found: + buyer = template.buyer.model_dump() + for key, field in self.buyer_fields.items(): + field.setText(str(buyer.get(key) or buyer['address'].get(key) or '')) + # Nicht sichtbare zusätzliche Stammdaten (z. B. Steuernummer) bewahren. + if not found: + self.customers.addItem(f'Vorlage · {template.buyer.name}', template.buyer) + self.customers.setCurrentIndex(self.customers.count() - 1) + for position in list(self.positions): + self.remove_position(position) + for item in template.items: + self.add_position(item) + self.number.clear() + today = date.today() + self.issued.setDate(QDate(today.year, today.month, today.day)) + self.start.setDate(QDate(today.year, today.month, 1)) + self.end.setDate(QDate(today.year, today.month, calendar.monthrange(today.year, today.month)[1])) + self.due.setValue(template.due_days) + self.notes.setPlainText(template.notes) + self.buyer_reference.setText(template.buyer_reference or '') + self.order_reference.setText(template.order_reference or '') + self.status.setText('Vorlage geladen. Nummer zurückgesetzt; Datumsfelder auf heute / aktuellen Monat gesetzt. ' + 'Leistungsbeschreibung, Mengen, Preise und Zeitraum für die neue Rechnung prüfen.') + except Exception as exc: + self.error(str(exc)) + finally: + self.loading = False + self.changed() + + def save_current_template(self): + try: + template = InvoiceTemplate.from_form(self.form()) + name, accepted = QInputDialog.getText(self, 'Vorlage speichern', 'Name der Rechnungsvorlage:') + if not accepted: + return + try: + path = save_template(self.template_dir, name.strip(), template) + except FileExistsError: + if QMessageBox.question(self, 'Vorlage vorhanden', 'Diese Vorlage überschreiben?', + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No) != QMessageBox.StandardButton.Yes: + return + path = save_template(self.template_dir, name.strip(), template, overwrite=True) + self.refresh_templates() + self.status.setText(f'Vorlage gespeichert: {path}') + except Exception as exc: + self.error(str(exc)) + + def start_task(self, mode, form, finished): + if self.worker is not None: + return + self.editor.setEnabled(False) + self.preview_button.setEnabled(False) + self.generate_button.setEnabled(False) + self.progress.setRange(0, 0) + request = {'mode': mode, 'form': form.model_dump(mode='json'), + 'root': str(self.root), 'presets': str(self.presets), 'resources': str(self.resources), + 'jar': str(self.jar), 'scratch': self.scratch.name} + self.worker = InvoiceWorker(request, self) + self.worker.progress.connect(self.status.setText) + self.worker.succeeded.connect(finished) + self.worker.failed.connect(self.error) + self.worker.finished.connect(self.task_finished) + self.worker.finished.connect(self.worker.deleteLater) + self.worker.start() + + def task_finished(self): + self.worker = None + self.editor.setEnabled(True) + self.preview_button.setEnabled(not self.generated) + self.generate_button.setEnabled(not self.generated) + self.progress.setRange(0, 1) + self.progress.setValue(1) + + def preview(self): + try: + form = self.form() + form.invoice(self.presets) + # Ein Dokument vor erneutem Schreiben freigeben (auch unter Windows). + self.document.close() + self.start_task('preview', form, self.show_draft) + except Exception as exc: + self.error(str(exc)) + + def show_pdf(self, path: Path): + self.document.close() + error = self.document.load(str(path)) + if error != QPdfDocument.Error.None_: + raise RuntimeError(f'PDF kann nicht angezeigt werden: {path} ({error.name})') + self.path_label.setText(str(path)) + + def show_draft(self, payload): + try: + self.show_pdf(Path(payload['pdf'])) + self.preview_state.setText('ENTWURF · Keine Nummer vergeben · Noch keine E-Rechnung') + self.status.setText('Entwurf bereit. Mit „E-Rechnung erstellen“ die endgültige PDF erzeugen und prüfen.') + except Exception as exc: + self.error(str(exc)) + + def generate(self): + # Auch programmatische Doppelaufrufe dürfen keine zweite Rechnung erzeugen. + if self.worker is not None or self.generated: + return + try: + form = self.form() + form.invoice(self.presets) + if not self.jar or not self.jar.is_file(): + raise ValueError('Mustang fehlt. Einmalig jr-einvoice setup-mustang ausführen.') + existing = find_customer(self.presets, form.buyer.name) + if existing and Party.model_validate(existing[1]['buyer']) != form.buyer: + raise ValueError('Dieser Kundenname ist mit anderen Stammdaten gespeichert. ' + 'Bitte den gespeicherten Kunden auswählen oder die Stammdaten in der Kundendatei prüfen.') + self.status.setText('E-Rechnung wird erstellt …') + self.start_task('generate', form, self.show_result) + except Exception as exc: + self.error(str(exc)) + + def show_result(self, payload): + result = CreatedInvoice(BuildResult(**{key: Path(value) if value is not None else None + for key, value in payload['build'].items()}), + Path(payload['attachment']), Invoice.model_validate(payload['invoice'])) + self.result = result + self.generated = True + self.dirty = False + self.refresh_templates() + for widget in (self.open_pdf, self.open_folder, self.open_report): + widget.setEnabled(True) + self.preview_state.setText(f'{result.invoice.meta.invoice_number} · PDF/A und EN16931 technisch gültig') + self.status.setText('Fertige E-Rechnung gespeichert. Empfänger, Leistungsdaten und Betrag vor Versand prüfen. ' + 'Die angezeigte PDF kann direkt als E-Mail-Anhang verwendet werden.') + try: + self.show_pdf(result.attachment) + except Exception as exc: + self.error('Rechnung erfolgreich gespeichert; Vorschau fehlgeschlagen: ' + str(exc)) + + def open_result(self, kind): + if self.result: + path = {'pdf': self.result.attachment, 'folder': self.result.build.invoice_dir, + 'report': self.result.build.validate_report}[kind] + QDesktopServices.openUrl(QUrl.fromLocalFile(str(path))) + + def closeEvent(self, event): + if self.worker is not None: + self.status.setText('Bitte warten: Die laufende Erstellung oder Prüfung wird zuerst abgeschlossen.') + event.ignore() + return + if self.dirty and QMessageBox.question( + self, 'Formular schließen?', 'Ungespeicherte Formulareingaben verwerfen und schließen?', + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No) != QMessageBox.StandardButton.Yes: + event.ignore() + return + self.document.close() + self.scratch.cleanup() + event.accept() + + +def main(argv=None): + parser = argparse.ArgumentParser(description='Lokaler Rechnungsassistent mit PDF-Vorschau für JR IT Services.') + parser.add_argument('--root', type=Path, help='Abweichender Rechnungsstamm') + parser.add_argument('--mustang-jar', type=Path, help='Abweichende Mustang-CLI.jar') + args = parser.parse_args(argv) + app = QApplication(sys.argv[:1]) + app.setApplicationName('JR eInvoice') + app.setOrganizationName('JR IT Services') + app.setStyle('Fusion') + app.setPalette(app.style().standardPalette()) + try: + window = InvoiceWindow(root=args.root, jar=args.mustang_jar) + window.show() + return app.exec() + except Exception as exc: + print(f'GUI konnte nicht gestartet werden: {exc}', file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/jr_einvoice/gui_data.py b/jr_einvoice/gui_data.py new file mode 100644 index 0000000..98500fb --- /dev/null +++ b/jr_einvoice/gui_data.py @@ -0,0 +1,99 @@ +"""Geprüfte Formulardaten und private Vorlagen ohne Abhängigkeit von Qt.""" +from datetime import date, timedelta +from pathlib import Path +import os +import re +import tempfile + +from pydantic import Field, model_validator +import yaml + +from .models import Invoice, Item, Party, StrictModel +from .presets import load_yaml + + +class InvoiceForm(StrictModel): + """Vollständiger neuer Auftrag; die endgültige Nummer vergibt der Kern.""" + + buyer: Party + items: list[Item] = Field(min_length=1) + issued: date = Field(default_factory=date.today) + due_days: int = Field(default=30, ge=0, le=3650) + start: date + end: date + number: str | None = Field(default=None, pattern=r'^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$') + notes: str = 'Vielen Dank für Ihren Auftrag.' + buyer_reference: str | None = None + order_reference: str | None = None + + @model_validator(mode='after') + def ordered_period(self): + if self.start > self.end: + raise ValueError('Der Leistungsbeginn muss vor dem Leistungsende liegen') + return self + + def invoice(self, presets: Path) -> Invoice: + """Auch Absender und Zahlungsdaten vor jeder Erstellung vollständig prüfen.""" + seller = load_yaml(presets / 'seller.default.yaml') + if not seller.get('seller') or not seller.get('payment'): + raise ValueError(f'Absender und Zahlungsdaten fehlen: {presets / "seller.default.yaml"}') + return Invoice.model_validate({ + 'seller': seller['seller'], 'buyer': self.buyer, + 'items': self.items, + 'meta': {'invoice_number': self.number or 'AUTOMATISCH', 'invoice_date': self.issued, + 'service_date': self.end, 'service_period_start': self.start, + 'service_period_end': self.end, 'notes': self.notes, + 'buyer_reference': self.buyer_reference, 'order_reference': self.order_reference}, + 'payment': {**seller['payment'], 'due_date': self.issued + timedelta(days=self.due_days), + 'reference': f'Rechnung {self.number or "AUTOMATISCH"}'}}) + + +class InvoiceTemplate(StrictModel): + """Vorlagen enthalten bewusst keine alte Rechnungsnummer oder Leistungsdaten.""" + + schema_version: int = Field(default=1, ge=1, le=1) + buyer: Party + items: list[Item] = Field(min_length=1) + due_days: int = Field(default=30, ge=0, le=3650) + notes: str = 'Vielen Dank für Ihren Auftrag.' + buyer_reference: str | None = None + order_reference: str | None = None + + @classmethod + def from_form(cls, form: InvoiceForm): + return cls.model_validate(form.model_dump(exclude={'issued', 'start', 'end', 'number'})) + + +def load_template(path: Path) -> InvoiceTemplate: + """Eigene Vorlagen oder bestehende invoice.yaml als neue Rechnung übernehmen.""" + data = load_yaml(path) + if 'meta' in data: + inv = Invoice.model_validate(data) + return InvoiceTemplate(buyer=inv.buyer, items=inv.items, + due_days=(inv.payment.due_date - inv.meta.invoice_date).days, + notes=inv.meta.notes or '', buyer_reference=inv.meta.buyer_reference, + order_reference=inv.meta.order_reference) + return InvoiceTemplate.model_validate(data) + + +def save_template(directory: Path, name: str, template: InvoiceTemplate, *, overwrite=False) -> Path: + """Vorlagen geschützt speichern; vorhandene Namen nur ausdrücklich überschreiben.""" + if not re.fullmatch(r'[\wÄÖÜäöüß][\wÄÖÜäöüß .-]{0,79}', name) or name.endswith(('.', ' ')): + raise ValueError('Vorlagenname: 1–80 Buchstaben/Ziffern, Leerzeichen, Punkt oder Bindestrich') + directory.mkdir(parents=True, exist_ok=True, mode=0o700) + target = directory / f'{name}.yaml' + # Die temporäre Datei bleibt bei jedem Fehler außerhalb der Vorlagenauswahl. + fd, temporary = tempfile.mkstemp(prefix='.vorlage-', dir=directory) + try: + with os.fdopen(fd, 'w', encoding='utf-8') as stream: + yaml.safe_dump(template.model_dump(mode='json'), stream, allow_unicode=True, sort_keys=False) + stream.flush() + os.fsync(stream.fileno()) + if overwrite: + os.replace(temporary, target) + else: + # link statt exists+rename verhindert auch gleichzeitiges Überschreiben. + os.link(temporary, target) + finally: + Path(temporary).unlink(missing_ok=True) + return target diff --git a/jr_einvoice/gui_worker.py b/jr_einvoice/gui_worker.py new file mode 100644 index 0000000..3adfc53 --- /dev/null +++ b/jr_einvoice/gui_worker.py @@ -0,0 +1,76 @@ +"""PDF-Erzeugung in einem eigenen Prozess ohne Qt-Laufzeit ausführen.""" +from __future__ import annotations + +import json +import logging +from pathlib import Path +import shutil +import sys + +from .create import create_invoice, find_customer, save_customer +from .gui_data import InvoiceForm +from .models import Party +from .render import render_html, render_pdf + + +def execute(request: dict, progress) -> dict: + """Nur geprüfte Formulardaten an die gemeinsame Rechnungserstellung übergeben.""" + form = InvoiceForm.model_validate(request['form']) + presets, resources = Path(request['presets']), Path(request['resources']) + invoice = form.invoice(presets) + if request['mode'] == 'preview': + progress('Entwurf wird ohne Nummernreservierung erstellt …') + invoice.meta.invoice_number = 'ENTWURF' + invoice.payment.reference = 'Entwurf – noch keine Rechnungsnummer vergeben' + invoice.meta.notes = 'ENTWURF – nicht zum Versand.\n' + (invoice.meta.notes or '') + folder = Path(request['scratch']) + (folder / 'assets').mkdir(exist_ok=True) + logo = presets / 'logo.svg' + if not logo.is_file(): + logo = resources / 'assets/logo.svg' + shutil.copy2(logo, folder / 'assets/logo.svg') + result = render_pdf(render_html(invoice, resources / 'templates/invoice.html.j2'), + folder / 'entwurf.pdf', logging.getLogger('gui-preview'), base_url=str(folder)) + if not result.ok: + raise RuntimeError(result.error) + return {'pdf': str(result.path)} + if request['mode'] != 'generate': + raise ValueError('Unbekannter Auftrag') + jar = Path(request['jar']) + if not jar.is_file(): + raise ValueError('Mustang fehlt. Einmalig jr-einvoice setup-mustang ausführen.') + existing = find_customer(presets, form.buyer.name) + if existing and Party.model_validate(existing[1]['buyer']) != form.buyer: + raise ValueError('Dieser Kundenname ist mit anderen Stammdaten gespeichert. ' + 'Bitte den gespeicherten Kunden auswählen oder die Kundendatei prüfen.') + # Vorlagen dürfen vorhandene Kundenadressen nicht stillschweigend ersetzen. + match = existing or save_customer(presets, form.buyer.name, form.buyer.model_dump(mode='json')) + first = form.items[0] + result = create_invoice(root=Path(request['root']), resources=resources, presets=presets, + customer=match[0], entry=first.name, volume=first.quantity, + price=first.unit_price_net, items=form.items, notes=form.notes, + jar=jar, logger=logging.getLogger('gui'), number=form.number, + issued=form.issued, due_days=form.due_days, + service_period_start=form.start, service_period_end=form.end, + buyer_reference=form.buyer_reference, order_reference=form.order_reference, + progress=progress) + return {'invoice': result.invoice.model_dump(mode='json'), 'attachment': str(result.attachment), + 'build': {key: str(value) if value is not None else None + for key, value in vars(result.build).items()}} + + +def main(argv=None): + request_path = Path((argv or sys.argv[1:])[0]) + result_path = request_path.with_name('result.json') + try: + request = json.loads(request_path.read_text(encoding='utf-8')) + result = execute(request, lambda message: print(json.dumps({'progress': message}), flush=True)) + result_path.write_text(json.dumps({'result': result}), encoding='utf-8') + return 0 + except Exception as exc: + result_path.write_text(json.dumps({'error': str(exc)}), encoding='utf-8') + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/jr_einvoice/maintenance.py b/jr_einvoice/maintenance.py index b9aad68..def65c8 100644 --- a/jr_einvoice/maintenance.py +++ b/jr_einvoice/maintenance.py @@ -1,4 +1,4 @@ -"""Checkout maintenance; standard library only, so broken dependencies cannot block rollback.""" +"""Laufzeiten isoliert aktualisieren; Rollback bleibt ohne Zusatzpakete nutzbar.""" from __future__ import annotations import argparse @@ -61,6 +61,7 @@ class Maintenance: env.pop(key, None) env['JR_EINVOICE_MUSTANG_JAR'] = str(release / 'Mustang-CLI.jar') env['JR_EINVOICE_INTEGRATION'] = '1' + env['QT_QPA_PLATFORM'] = 'offscreen' self.log.info('PRÜFUNG: pip check, WeasyPrint, CLI und echte Rechnungs-/Negativtests') self.run([python, '-I', '-m', 'pip', 'check'], env=env) with tempfile.TemporaryDirectory(prefix='einvoice-health-') as folder: @@ -87,7 +88,7 @@ class Maintenance: def activate(self, target: Path): active = self.state / 'current' old = active.resolve() if active.is_symlink() else self.baseline() - # Persist rollback target before changing the single runtime pointer. + # Rücksprungziel vor dem einzigen aktiven Laufzeitzeiger sichern. point(self.state / 'previous', old) self.log.info('AKTIVIERUNG: %s', target) point(active, target) @@ -99,7 +100,7 @@ class Maintenance: raise self.log.info('ERFOLG: Laufzeit aktiv. Rollback verfügbar: %s', old) - def update(self, apply=False, version=None): + def update(self, apply=False, version=None, gui=False): version = version or get_latest_mustang_version(self.log) if not re.fullmatch(r'\d+(?:\.\d+)+', version): raise ValueError(f'Keine stabile Mustang-Version: {version!r}; --mustang-version verwenden.') @@ -109,9 +110,14 @@ class Maintenance: self.run(['java', '-version']) self.run([sys.executable, '-m', 'venv', release / 'venv']) python = release / 'venv/bin/python' + # Eine einmal aktivierte Oberfläche auch bei späteren Updates mitnehmen. + current_manifest = self.state / 'current/manifest.json' + if current_manifest.is_file(): + gui = gui or json.loads(current_manifest.read_text()).get('gui', False) + extras = 'pdf,gui' if gui else 'pdf' self.run([python, '-m', 'pip', 'install', '--upgrade', 'pip']) self.run([python, '-m', 'pip', 'install', '--upgrade', '--upgrade-strategy', 'eager', - '--report', release / 'install-report.json', f'{self.root}[pdf]']) + '--report', release / 'install-report.json', f'{self.root}[{extras}]']) install_mustang(version, release / 'Mustang-CLI.jar', self.log) frozen = self.run([python, '-m', 'pip', 'freeze', '--all']) (release / 'requirements.freeze.txt').write_text(frozen) @@ -122,7 +128,7 @@ class Maintenance: before = old.get(package['name'].lower(), '(neu)') if before != package['version']: self.log.info('VERSION: %s: %s -> %s', package['name'], before, package['version']) - manifest = {'mustang': version, 'jar_sha256': hashlib.sha256((release / 'Mustang-CLI.jar').read_bytes()).hexdigest(), + manifest = {'mustang': version, 'gui': gui, 'jar_sha256': hashlib.sha256((release / 'Mustang-CLI.jar').read_bytes()).hexdigest(), 'packages': packages, 'source_commit': self.run(['git', 'rev-parse', 'HEAD']).strip()} (release / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n') self.health(release) @@ -147,6 +153,7 @@ def main(argv=None): mode.add_argument('--rollback', action='store_true', help='Vorherigen Stand prüfen und aktivieren') mode.add_argument('--status', action='store_true', help='Aktiven und vorherigen Stand anzeigen, ohne Netzwerk') parser.add_argument('--mustang-version', help='Explizite stabile Version statt Maven-Release') + parser.add_argument('--with-gui', action='store_true', help='Desktop-Oberfläche einschließlich PDF-Anzeige installieren') args = parser.parse_args(argv) root = Path(__file__).resolve().parent.parent state = root / '.runtime' @@ -170,7 +177,7 @@ def main(argv=None): elif args.rollback: maintenance.rollback() else: - maintenance.update(args.apply, args.mustang_version) + maintenance.update(args.apply, args.mustang_version, args.with_gui) except (Exception, KeyboardInterrupt) as exc: logging.getLogger('maintenance').error('FEHLER: %s; nicht aktivierte Kandidaten bleiben zur Diagnose erhalten.', exc) return 1 diff --git a/pyproject.toml b/pyproject.toml index 8456c20..a06fea1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "jr-einvoice-toolchain" -version = "1.0.0" +version = "1.1.0" description = "Folder-first Factur-X (ZUGFeRD/EN16931) invoice generator for Linux/Arch: invoice folder + YAML -> PDF + XML -> Factur-X PDF + validation." readme = "README.md" requires-python = ">=3.11" @@ -22,10 +22,14 @@ dependencies = [ pdf = [ "weasyprint>=62.3" ] +gui = [ + "PySide6>=6.8,<7" +] [project.scripts] jr-einvoice = "jr_einvoice.cli:app" einvoice = "jr_einvoice.quick_cli:main" +einvoice-ui = "jr_einvoice.gui:main" [tool.hatch.build.targets.wheel] packages = ["jr_einvoice"] diff --git a/scripts/install_commands.py b/scripts/install_commands.py index 6d24b64..fdaf06c 100755 --- a/scripts/install_commands.py +++ b/scripts/install_commands.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Install per-user launchers without sudo or changing shell configuration.""" +"""Starter im Benutzer-Suchpfad ohne sudo oder Änderungen an der Shell einrichten.""" import argparse from datetime import datetime, timezone import os @@ -15,7 +15,7 @@ def install(bin_dir: Path) -> None: bin_dir.mkdir(parents=True, exist_ok=True) changed = [] try: - for name in ('einvoice', 'jr-einvoice'): + for name in ('einvoice', 'jr-einvoice', 'einvoice-ui'): source = root / 'bin' / name target = bin_dir / name if target.is_symlink() and target.resolve() == source: @@ -41,7 +41,7 @@ def install(bin_dir: Path) -> None: temporary.unlink(missing_ok=True) changed.append((target, backup)) print(f'Eingerichtet: {target} -> {source}', flush=True) - # A different cwd verifies that symlinks resolve to the checkout correctly. + # Aus fremdem Verzeichnis die Auflösung der Konsolenstarter prüfen. for name in ('einvoice', 'jr-einvoice'): subprocess.run([str(bin_dir / name), '--help'], cwd=bin_dir, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True) @@ -56,16 +56,30 @@ def install(bin_dir: Path) -> None: if str(bin_dir) not in os.environ.get('PATH', '').split(os.pathsep): import shlex print(f'Für diese Shell ausführen: export PATH={shlex.quote(str(bin_dir))}:"$PATH"') - print('Ab jetzt: einvoice --help | einvoice --update --status | jr-einvoice --help') + print('Ab jetzt: einvoice --help | einvoice-ui | einvoice --update --status | jr-einvoice --help') print('Falls die Shell noch den alten Pfad merkt: rehash (zsh) bzw. hash -r (bash).') def main(): parser = argparse.ArgumentParser(description='einvoice und jr-einvoice im Benutzer-Suchpfad einrichten.') parser.add_argument('--bin-dir', type=Path, default=Path.home() / '.local/bin') + parser.add_argument('--desktop', action='store_true', help='Zusätzlich einen Eintrag im Anwendungsmenü installieren') args = parser.parse_args() try: install(args.bin_dir) + if args.desktop: + root = Path(__file__).resolve().parent.parent + applications = Path(os.environ.get('XDG_DATA_HOME', Path.home() / '.local/share')) / 'applications' + applications.mkdir(parents=True, exist_ok=True) + launcher = str(root / 'bin/einvoice-ui').replace('%', '%%') + for char in ('\\', '"', '`', '$'): + launcher = launcher.replace(char, '\\' + char) + entry = applications / 'de.jr-it-services.einvoice.desktop' + entry.write_text('[Desktop Entry]\nType=Application\nName=JR eInvoice\n' + 'Comment=E-Rechnungen erstellen und als PDF ansehen\n' + f'Exec="{launcher}"\nIcon={root / "assets/logo.svg"}\n' + 'Terminal=false\nCategories=Office;Finance;\nStartupNotify=true\n', encoding='utf-8') + print(f'Anwendungsmenü eingerichtet: {entry}') except (Exception, KeyboardInterrupt) as exc: print(f'FEHLER: {exc}; geänderte Befehle wurden zurückgesetzt.', file=sys.stderr) if isinstance(exc, subprocess.CalledProcessError) and exc.stderr: diff --git a/tests/test_gui.py b/tests/test_gui.py new file mode 100644 index 0000000..a174b9a --- /dev/null +++ b/tests/test_gui.py @@ -0,0 +1,224 @@ +"""Vorlagen, GUI-Zustände und optional die vollständige echte Rechnung prüfen.""" +import calendar +from datetime import date +import importlib.util +import logging +import os +from pathlib import Path +import tempfile +import time +import unittest +from unittest.mock import patch + +import yaml + +from jr_einvoice.calc import compute_totals +from jr_einvoice.create import create_invoice, save_customer +from jr_einvoice.models import Invoice + +# Beim Rollback ältere Laufzeiten mit ihrer tatsächlich verfügbaren Funktion prüfen. +HAS_GUI_DATA = importlib.util.find_spec('jr_einvoice.gui_data') is not None +if HAS_GUI_DATA: + from jr_einvoice.gui_data import InvoiceForm, InvoiceTemplate, load_template, save_template + +ROOT = Path(__file__).resolve().parent.parent +SAMPLE = Invoice.model_validate(yaml.safe_load((ROOT / 'examples/invoice.sample.yaml').read_text())) +HAS_QT = importlib.util.find_spec('PySide6') is not None + + +@unittest.skipUnless(HAS_GUI_DATA, 'Diese Laufzeit enthält noch keinen Desktop-Assistenten') +class TemplateTest(unittest.TestCase): + def test_roundtrip_excludes_old_number_dates_and_bank_details(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + template = load_template(ROOT / 'examples/invoice.sample.yaml') + path = save_template(root / 'templates', 'IT-Beratung', template) + self.assertEqual(load_template(path), template) + data = yaml.safe_load(path.read_text()) + for excluded in ('meta', 'number', 'issued', 'start', 'end', 'seller', 'payment'): + self.assertNotIn(excluded, data) + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + self.assertEqual(path.parent.stat().st_mode & 0o777, 0o700) + with self.assertRaises(FileExistsError): + save_template(path.parent, 'IT-Beratung', template) + save_template(path.parent, 'IT-Beratung', template, overwrite=True) + for name in ('../escape', '/tmp/escape', '', 'a/b', '..', 'trailing.'): + with self.assertRaises(ValueError): + save_template(path.parent, name, template) + + def test_invalid_template_version_and_position_rejected(self): + template = load_template(ROOT / 'examples/invoice.sample.yaml') + for changes in ({'schema_version': 2}, {'items': []}, {'old_number': '2020-1234'}): + with self.assertRaises(ValueError): + InvoiceTemplate.model_validate(template.model_dump() | changes) + + def test_multiple_items_custom_period_and_notes_use_shared_core(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + presets = root / 'presets' + presets.mkdir() + (presets / 'seller.default.yaml').write_text(yaml.safe_dump({ + 'seller': SAMPLE.seller.model_dump(mode='json'), 'payment': SAMPLE.payment.model_dump(mode='json')})) + save_customer(presets, SAMPLE.buyer.name, SAMPLE.buyer.model_dump(mode='json')) + items = SAMPLE.items + form = InvoiceForm(buyer=SAMPLE.buyer, items=items, start=date(2026, 9, 10), + end=date(2026, 9, 20), notes='Individuelle Hinweise', issued=date(2026, 10, 19)) + invoice = form.invoice(presets) + self.assertEqual(compute_totals(invoice).gross_total, compute_totals(SAMPLE).gross_total) + jar = root / 'mustang.jar' + jar.touch() + # Abbruch genau am Übergang zur PDF-Erzeugung: gespeicherte Daten prüfen. + with patch('jr_einvoice.create.build_in_folder', side_effect=RuntimeError('Testabbruch')): + with self.assertRaises(RuntimeError): + create_invoice(root=root / 'invoices', resources=ROOT, presets=presets, + customer=SAMPLE.buyer.name, entry='Oktober 2026', volume=1, price=1, + items=items, notes=form.notes, jar=jar, logger=logging.getLogger('test'), + service_period_start=form.start, service_period_end=form.end) + saved = Invoice.model_validate(yaml.safe_load(next((root / 'invoices').glob('*/invoice.yaml')).read_text())) + self.assertEqual(saved.items, items) + self.assertEqual(saved.meta.service_period_start, form.start) + self.assertEqual(saved.meta.service_period_end, form.end) + self.assertEqual(saved.meta.notes, form.notes) + + +@unittest.skipUnless(HAS_QT and HAS_GUI_DATA, 'Diese Laufzeit enthält keine installierte Desktop-Oberfläche') +class GuiTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen') + from PySide6.QtWidgets import QApplication + cls.app = QApplication.instance() or QApplication([]) + cls.app.setStyle('Fusion') + + def setUp(self): + from jr_einvoice.gui import InvoiceWindow + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.home = Path(self.tmp.name) + self.env = patch.dict(os.environ, {'JR_EINVOICE_HOME': str(self.home)}) + self.env.start() + self.addCleanup(self.env.stop) + presets = self.home / 'presets' + presets.mkdir() + (presets / 'seller.default.yaml').write_text(yaml.safe_dump({ + 'seller': SAMPLE.seller.model_dump(mode='json'), 'payment': SAMPLE.payment.model_dump(mode='json')})) + self.jar = Path(os.environ.get('JR_EINVOICE_MUSTANG_JAR', ROOT / 'tools/mustang/Mustang-CLI.jar')) + self.window = InvoiceWindow(root=self.home / 'invoices', presets=presets, jar=self.jar) + self.addCleanup(self.cleanup_window) + self.window.confirm_replace = lambda: True + self.errors = [] + self.window.error = self.errors.append + + def cleanup_window(self): + self.wait_worker() + self.window.dirty = False + self.window.close() + self.window.deleteLater() + self.app.processEvents() + + def wait_worker(self): + from PySide6.QtTest import QTest + deadline = time.monotonic() + 150 + while self.window.worker is not None and time.monotonic() < deadline: + self.app.processEvents() + QTest.qWait(20) + self.assertIsNone(self.window.worker, 'Hintergrundauftrag nicht rechtzeitig abgeschlossen') + self.app.processEvents() + + def load_sample(self): + self.window.apply_template(ROOT / 'examples/invoice.sample.yaml') + self.assertFalse(self.errors) + + def test_load_earlier_invoice_resets_dates_but_preserves_details(self): + self.load_sample() + form = self.window.form() + self.assertEqual(form.issued, date.today()) + self.assertEqual(form.start, date.today().replace(day=1)) + self.assertEqual(form.end.day, calendar.monthrange(date.today().year, date.today().month)[1]) + self.assertIsNone(form.number) + self.assertEqual(form.buyer, SAMPLE.buyer) + self.assertEqual(form.items, SAMPLE.items) + self.window.positions[0].quantity.setText('7,5') + self.window.positions[0].price.setText('1.234,56 €') + self.assertEqual(str(self.window.form().items[0].unit_price_net), '1234.56') + self.assertIn('Brutto', self.window.totals.text()) + + def test_invalid_fields_do_not_save_customer_or_reserve_number(self): + self.load_sample() + self.window.positions[0].quantity.setText('-1') + self.window.generate() + self.assertTrue(self.errors) + self.assertIsNone(self.window.worker) + self.assertFalse(self.window.root.exists()) + self.assertEqual(list((self.window.presets / 'customers').glob('*.yaml')), []) + + def test_existing_customer_cannot_be_silently_overwritten(self): + save_customer(self.window.presets, SAMPLE.buyer.name, + SAMPLE.buyer.model_dump(mode='json') | {'email': 'andere@example.invalid'}) + self.load_sample() + self.window.generate() + self.assertIn('anderen Stammdaten', self.errors[0]) + self.assertIsNone(self.window.worker) + self.assertFalse(self.window.root.exists()) + + def test_preview_creates_no_customer_or_invoice_number(self): + self.load_sample() + self.window.preview() + self.wait_worker() + self.assertFalse(self.errors) + self.assertGreater(self.window.document.pageCount(), 0) + self.assertIn('ENTWURF', self.window.preview_state.text()) + self.assertFalse(self.window.root.exists()) + self.assertEqual(list((self.window.presets / 'customers').glob('*.yaml')), []) + self.assertFalse(self.window.open_pdf.isEnabled()) + + def test_background_failure_restores_editing_without_success(self): + self.load_sample() + self.window.jar = self.home / 'kaputte-mustang.jar' + self.window.jar.write_text('Keine gültige JAR') + self.window.generate() + self.wait_worker() + self.assertEqual(len(self.errors), 1) + self.assertIn('Erstellung fehlgeschlagen', self.errors[0]) + self.assertTrue(self.window.editor.isEnabled()) + self.assertTrue(self.window.generate_button.isEnabled()) + self.assertFalse(self.window.open_pdf.isEnabled()) + self.assertIsNone(self.window.result) + + def test_worker_start_failure_restores_form(self): + self.load_sample() + with patch('jr_einvoice.gui.sys.executable', '/nonexistent-jr-einvoice-python'): + self.window.preview() + self.wait_worker() + self.assertEqual(len(self.errors), 1) + self.assertIn('Hintergrundprozess fehlgeschlagen', self.errors[0]) + self.assertTrue(self.window.editor.isEnabled()) + self.assertTrue(self.window.preview_button.isEnabled()) + + @unittest.skipUnless(os.environ.get('JR_EINVOICE_INTEGRATION') == '1', 'Echte Mustang-Prüfung nur nach Opt-in') + def test_real_click_to_validated_pdf_preview_and_new_invoice(self): + self.load_sample() + self.window.generate_button.click() + self.assertFalse(self.window.editor.isEnabled()) + self.wait_worker() + self.assertFalse(self.errors) + result = self.window.result + self.assertIsNotNone(result) + self.assertIn('status="valid"', result.build.validate_report.read_text()) + self.assertGreater(self.window.document.pageCount(), 0) + self.assertEqual(result.attachment.read_bytes(), result.build.facturx_pdf.read_bytes()) + self.assertFalse(self.window.generate_button.isEnabled()) + self.assertFalse(self.window.preview_button.isEnabled()) + self.assertTrue(self.window.open_pdf.isEnabled()) + # Doppelaufruf desselben Formulars legt keine zusätzliche Nummer an. + self.window.generate() + self.assertIsNone(self.window.worker) + self.assertEqual(len(list(self.window.root.glob('*/invoice.yaml'))), 1) + self.window.notes.setPlainText('Geänderte Hinweise') + self.assertIn('vorherigen Stand', self.window.preview_state.text()) + self.assertFalse(self.window.open_pdf.isEnabled()) + self.window.new_invoice() + self.assertIsNone(self.window.result) + self.assertEqual(len(self.window.positions), 1) + self.assertEqual(self.window.document.pageCount(), 0) + self.assertFalse(self.window.dirty)