Lokalen Rechnungsassistenten mit Vorlagen und PDF-Vorschau ergänzen

This commit is contained in:
admin committed 2026-10-19 16:52:48 +02:00
1 parent f447603af4
commit b9f307fc81
15 files changed
+1423 -26

No files matched your search

+10 -1
View File
@@ -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
+47 -2
View File
@@ -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`
+9 -1
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
einvoice
+9 -1
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

+148
View File
@@ -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 <Rechnungsordner>` 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.
+25 -10
View File
@@ -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}')
+739
View File
@@ -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())
+99
View File
@@ -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
+76
View File
@@ -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())
+13 -6
View File
@@ -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
+5 -1
View File
@@ -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"]
+18 -4
View File
@@ -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:
+224
View File
@@ -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)