Lokalen Rechnungsassistenten mit Vorlagen und PDF-Vorschau ergänzen
This commit is contained in:
1 parent
f447603af4
commit
b9f307fc81
15 files changed
+1423
-26
No files matched your search
+10
-1
@@ -1,6 +1,15 @@
|
|||||||
# Bedienung und technische Dokumentation
|
# 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
|
## Einrichtung und persönliche Vorlagen
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
|
Kundenname, Leistung, Menge und Nettopreis eingeben: `einvoice --create` erledigt
|
||||||
Rechnungsnummer, Berechnung, PDF/A-Erstellung, Einbettung des EN16931-XML und
|
Rechnungsnummer, Berechnung, PDF/A-Erstellung, Einbettung des EN16931-XML und
|
||||||
@@ -114,7 +159,7 @@ einvoice --update --rollback
|
|||||||
jr-einvoice --help
|
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
|
Dort vorhandene andere Befehle werden vor dem Ersetzen gesichert. Wiederholtes
|
||||||
Ausführen ist möglich. Auf diesem Rechner ist das Verzeichnis bereits im PATH.
|
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`
|
Bei einem gespeicherten alten Shell-Pfad einmal `rehash` (zsh) bzw. `hash -r`
|
||||||
|
|||||||
+9
-1
@@ -6,7 +6,7 @@ if [[ "${1:-}" == --update ]]; then
|
|||||||
shift
|
shift
|
||||||
exec python3 "$root/scripts/update.py" "$@"
|
exec python3 "$root/scripts/update.py" "$@"
|
||||||
fi
|
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
|
if [[ -L "$root/.runtime/current" ]]; then
|
||||||
release="$(readlink -f -- "$root/.runtime/current")"
|
release="$(readlink -f -- "$root/.runtime/current")"
|
||||||
export JR_EINVOICE_MUSTANG_JAR="$release/Mustang-CLI.jar"
|
export JR_EINVOICE_MUSTANG_JAR="$release/Mustang-CLI.jar"
|
||||||
@@ -16,6 +16,14 @@ else
|
|||||||
fi
|
fi
|
||||||
export JR_EINVOICE_HOME="${JR_EINVOICE_HOME:-$root/private}"
|
export JR_EINVOICE_HOME="${JR_EINVOICE_HOME:-$root/private}"
|
||||||
export JR_EINVOICE_PRESETS="${JR_EINVOICE_PRESETS:-$root/private/presets}"
|
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
|
if [[ "$(basename -- "$0")" == jr-einvoice ]]; then
|
||||||
exec "$python" -I -c 'from jr_einvoice.cli import app; app()' "$@"
|
exec "$python" -I -c 'from jr_einvoice.cli import app; app()' "$@"
|
||||||
fi
|
fi
|
||||||
|
|||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
einvoice
|
||||||
+9
-1
@@ -6,7 +6,7 @@ if [[ "${1:-}" == --update ]]; then
|
|||||||
shift
|
shift
|
||||||
exec python3 "$root/scripts/update.py" "$@"
|
exec python3 "$root/scripts/update.py" "$@"
|
||||||
fi
|
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
|
if [[ -L "$root/.runtime/current" ]]; then
|
||||||
release="$(readlink -f -- "$root/.runtime/current")"
|
release="$(readlink -f -- "$root/.runtime/current")"
|
||||||
export JR_EINVOICE_MUSTANG_JAR="$release/Mustang-CLI.jar"
|
export JR_EINVOICE_MUSTANG_JAR="$release/Mustang-CLI.jar"
|
||||||
@@ -16,6 +16,14 @@ else
|
|||||||
fi
|
fi
|
||||||
export JR_EINVOICE_HOME="${JR_EINVOICE_HOME:-$root/private}"
|
export JR_EINVOICE_HOME="${JR_EINVOICE_HOME:-$root/private}"
|
||||||
export JR_EINVOICE_PRESETS="${JR_EINVOICE_PRESETS:-$root/private/presets}"
|
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
|
if [[ "$(basename -- "$0")" == jr-einvoice ]]; then
|
||||||
exec "$python" -I -c 'from jr_einvoice.cli import app; app()' "$@"
|
exec "$python" -I -c 'from jr_einvoice.cli import app; app()' "$@"
|
||||||
fi
|
fi
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 198 KiB |
@@ -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
@@ -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 dataclasses import dataclass
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
@@ -16,7 +16,7 @@ from .calc import compute_totals
|
|||||||
from .utils import money_fmt
|
from .utils import money_fmt
|
||||||
from .folder_naming import default_invoice_folder_name
|
from .folder_naming import default_invoice_folder_name
|
||||||
from .invoice_number import invoice_root_lock
|
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 .pipeline import build_in_folder, BuildResult
|
||||||
from .presets import load_yaml
|
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:
|
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 = re.sub(r'\s*(?:€|EUR)\s*$', '', value.strip(), flags=re.I)
|
||||||
raw = raw.replace('\u00a0', '').strip()
|
raw = raw.replace('\u00a0', '').strip()
|
||||||
if ',' in raw:
|
if ',' in raw:
|
||||||
@@ -43,7 +43,7 @@ def decimal_input(value: str) -> Decimal:
|
|||||||
|
|
||||||
|
|
||||||
def customer_key(name: str) -> str:
|
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'
|
readable = re.sub(r'[^a-z0-9]+', '-', name.casefold()).strip('-')[:32] or 'kunde'
|
||||||
return f'{readable}-{hashlib.sha256(name.casefold().encode()).hexdigest()[:8]}'
|
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)
|
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
target = directory / f'{key}.yaml'
|
target = directory / f'{key}.yaml'
|
||||||
data = {'buyer': buyer}
|
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:
|
with target.open('x', encoding='utf-8') as file:
|
||||||
target.chmod(0o600)
|
target.chmod(0o600)
|
||||||
yaml.safe_dump(data, file, allow_unicode=True, sort_keys=False)
|
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,
|
month: str | None = None, service_date: date | None = None,
|
||||||
unit: str = 'HUR', tax: Decimal = Decimal('19'), due_days: int = 30,
|
unit: str = 'HUR', tax: Decimal = Decimal('19'), due_days: int = 30,
|
||||||
buyer_reference: str | None = None, order_reference: str | None = None,
|
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:
|
progress=lambda message: None) -> CreatedInvoice:
|
||||||
if due_days < 0:
|
if due_days < 0:
|
||||||
raise ValueError('Zahlungsziel darf nicht negativ sein')
|
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
|
key, record = match
|
||||||
issued = issued or date.today()
|
issued = issued or date.today()
|
||||||
delivery, start, end = service_period(entry, month, service_date, issued)
|
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()
|
root = root.expanduser().resolve()
|
||||||
with invoice_root_lock(root):
|
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()
|
used = set()
|
||||||
for path in root.glob('*/invoice.yaml'):
|
for path in root.glob('*/invoice.yaml'):
|
||||||
existing = load_yaml(path).get('meta', {}).get('invoice_number')
|
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,
|
'meta': {'invoice_number': number, 'invoice_date': issued, 'service_date': delivery,
|
||||||
'service_period_start': start, 'service_period_end': end,
|
'service_period_start': start, 'service_period_end': end,
|
||||||
'buyer_reference': buyer_reference, 'order_reference': order_reference,
|
'buyer_reference': buyer_reference, 'order_reference': order_reference,
|
||||||
'notes': 'Vielen Dank für Ihren Auftrag.'},
|
'notes': notes},
|
||||||
'seller': seller_data['seller'], 'buyer': record['buyer'],
|
'seller': seller_data['seller'], 'buyer': record['buyer'],
|
||||||
'payment': {**seller_data['payment'], 'due_date': issued + timedelta(days=due_days),
|
'payment': {**seller_data['payment'], 'due_date': issued + timedelta(days=due_days),
|
||||||
'reference': f'Rechnung {number}'},
|
'reference': f'Rechnung {number}'},
|
||||||
'items': [{'name': entry, 'quantity': volume, 'unit': unit,
|
'items': [item.model_dump(mode='json') for item in positions],
|
||||||
'unit_price_net': price, 'tax_rate': tax}],
|
|
||||||
})
|
})
|
||||||
with tempfile.TemporaryDirectory(prefix='.create-', dir=root) as staging:
|
with tempfile.TemporaryDirectory(prefix='.create-', dir=root) as staging:
|
||||||
work = Path(staging) / 'invoice'
|
work = Path(staging) / 'invoice'
|
||||||
@@ -164,7 +179,7 @@ def create_invoice(*, root: Path, resources: Path, presets: Path, customer: str,
|
|||||||
if logo.is_file():
|
if logo.is_file():
|
||||||
shutil.copy2(logo, work / 'assets/logo.svg')
|
shutil.copy2(logo, work / 'assets/logo.svg')
|
||||||
work.rename(target)
|
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
|
from .logging_utils import setup_logging
|
||||||
logger = setup_logging('WARNING', target / 'out/logs/run.log')
|
logger = setup_logging('WARNING', target / 'out/logs/run.log')
|
||||||
progress(f'Rechnungsdaten vollständig: {number} · {inv.buyer.name}')
|
progress(f'Rechnungsdaten vollständig: {number} · {inv.buyer.name}')
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -61,6 +61,7 @@ class Maintenance:
|
|||||||
env.pop(key, None)
|
env.pop(key, None)
|
||||||
env['JR_EINVOICE_MUSTANG_JAR'] = str(release / 'Mustang-CLI.jar')
|
env['JR_EINVOICE_MUSTANG_JAR'] = str(release / 'Mustang-CLI.jar')
|
||||||
env['JR_EINVOICE_INTEGRATION'] = '1'
|
env['JR_EINVOICE_INTEGRATION'] = '1'
|
||||||
|
env['QT_QPA_PLATFORM'] = 'offscreen'
|
||||||
self.log.info('PRÜFUNG: pip check, WeasyPrint, CLI und echte Rechnungs-/Negativtests')
|
self.log.info('PRÜFUNG: pip check, WeasyPrint, CLI und echte Rechnungs-/Negativtests')
|
||||||
self.run([python, '-I', '-m', 'pip', 'check'], env=env)
|
self.run([python, '-I', '-m', 'pip', 'check'], env=env)
|
||||||
with tempfile.TemporaryDirectory(prefix='einvoice-health-') as folder:
|
with tempfile.TemporaryDirectory(prefix='einvoice-health-') as folder:
|
||||||
@@ -87,7 +88,7 @@ class Maintenance:
|
|||||||
def activate(self, target: Path):
|
def activate(self, target: Path):
|
||||||
active = self.state / 'current'
|
active = self.state / 'current'
|
||||||
old = active.resolve() if active.is_symlink() else self.baseline()
|
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)
|
point(self.state / 'previous', old)
|
||||||
self.log.info('AKTIVIERUNG: %s', target)
|
self.log.info('AKTIVIERUNG: %s', target)
|
||||||
point(active, target)
|
point(active, target)
|
||||||
@@ -99,7 +100,7 @@ class Maintenance:
|
|||||||
raise
|
raise
|
||||||
self.log.info('ERFOLG: Laufzeit aktiv. Rollback verfügbar: %s', old)
|
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)
|
version = version or get_latest_mustang_version(self.log)
|
||||||
if not re.fullmatch(r'\d+(?:\.\d+)+', version):
|
if not re.fullmatch(r'\d+(?:\.\d+)+', version):
|
||||||
raise ValueError(f'Keine stabile Mustang-Version: {version!r}; --mustang-version verwenden.')
|
raise ValueError(f'Keine stabile Mustang-Version: {version!r}; --mustang-version verwenden.')
|
||||||
@@ -109,9 +110,14 @@ class Maintenance:
|
|||||||
self.run(['java', '-version'])
|
self.run(['java', '-version'])
|
||||||
self.run([sys.executable, '-m', 'venv', release / 'venv'])
|
self.run([sys.executable, '-m', 'venv', release / 'venv'])
|
||||||
python = release / 'venv/bin/python'
|
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', 'pip'])
|
||||||
self.run([python, '-m', 'pip', 'install', '--upgrade', '--upgrade-strategy', 'eager',
|
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)
|
install_mustang(version, release / 'Mustang-CLI.jar', self.log)
|
||||||
frozen = self.run([python, '-m', 'pip', 'freeze', '--all'])
|
frozen = self.run([python, '-m', 'pip', 'freeze', '--all'])
|
||||||
(release / 'requirements.freeze.txt').write_text(frozen)
|
(release / 'requirements.freeze.txt').write_text(frozen)
|
||||||
@@ -122,7 +128,7 @@ class Maintenance:
|
|||||||
before = old.get(package['name'].lower(), '(neu)')
|
before = old.get(package['name'].lower(), '(neu)')
|
||||||
if before != package['version']:
|
if before != package['version']:
|
||||||
self.log.info('VERSION: %s: %s -> %s', package['name'], 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()}
|
'packages': packages, 'source_commit': self.run(['git', 'rev-parse', 'HEAD']).strip()}
|
||||||
(release / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
|
(release / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
|
||||||
self.health(release)
|
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('--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')
|
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('--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)
|
args = parser.parse_args(argv)
|
||||||
root = Path(__file__).resolve().parent.parent
|
root = Path(__file__).resolve().parent.parent
|
||||||
state = root / '.runtime'
|
state = root / '.runtime'
|
||||||
@@ -170,7 +177,7 @@ def main(argv=None):
|
|||||||
elif args.rollback:
|
elif args.rollback:
|
||||||
maintenance.rollback()
|
maintenance.rollback()
|
||||||
else:
|
else:
|
||||||
maintenance.update(args.apply, args.mustang_version)
|
maintenance.update(args.apply, args.mustang_version, args.with_gui)
|
||||||
except (Exception, KeyboardInterrupt) as exc:
|
except (Exception, KeyboardInterrupt) as exc:
|
||||||
logging.getLogger('maintenance').error('FEHLER: %s; nicht aktivierte Kandidaten bleiben zur Diagnose erhalten.', exc)
|
logging.getLogger('maintenance').error('FEHLER: %s; nicht aktivierte Kandidaten bleiben zur Diagnose erhalten.', exc)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
+5
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jr-einvoice-toolchain"
|
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."
|
description = "Folder-first Factur-X (ZUGFeRD/EN16931) invoice generator for Linux/Arch: invoice folder + YAML -> PDF + XML -> Factur-X PDF + validation."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
@@ -22,10 +22,14 @@ dependencies = [
|
|||||||
pdf = [
|
pdf = [
|
||||||
"weasyprint>=62.3"
|
"weasyprint>=62.3"
|
||||||
]
|
]
|
||||||
|
gui = [
|
||||||
|
"PySide6>=6.8,<7"
|
||||||
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
jr-einvoice = "jr_einvoice.cli:app"
|
jr-einvoice = "jr_einvoice.cli:app"
|
||||||
einvoice = "jr_einvoice.quick_cli:main"
|
einvoice = "jr_einvoice.quick_cli:main"
|
||||||
|
einvoice-ui = "jr_einvoice.gui:main"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["jr_einvoice"]
|
packages = ["jr_einvoice"]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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
|
import argparse
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import os
|
import os
|
||||||
@@ -15,7 +15,7 @@ def install(bin_dir: Path) -> None:
|
|||||||
bin_dir.mkdir(parents=True, exist_ok=True)
|
bin_dir.mkdir(parents=True, exist_ok=True)
|
||||||
changed = []
|
changed = []
|
||||||
try:
|
try:
|
||||||
for name in ('einvoice', 'jr-einvoice'):
|
for name in ('einvoice', 'jr-einvoice', 'einvoice-ui'):
|
||||||
source = root / 'bin' / name
|
source = root / 'bin' / name
|
||||||
target = bin_dir / name
|
target = bin_dir / name
|
||||||
if target.is_symlink() and target.resolve() == source:
|
if target.is_symlink() and target.resolve() == source:
|
||||||
@@ -41,7 +41,7 @@ def install(bin_dir: Path) -> None:
|
|||||||
temporary.unlink(missing_ok=True)
|
temporary.unlink(missing_ok=True)
|
||||||
changed.append((target, backup))
|
changed.append((target, backup))
|
||||||
print(f'Eingerichtet: {target} -> {source}', flush=True)
|
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'):
|
for name in ('einvoice', 'jr-einvoice'):
|
||||||
subprocess.run([str(bin_dir / name), '--help'], cwd=bin_dir,
|
subprocess.run([str(bin_dir / name), '--help'], cwd=bin_dir,
|
||||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True)
|
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):
|
if str(bin_dir) not in os.environ.get('PATH', '').split(os.pathsep):
|
||||||
import shlex
|
import shlex
|
||||||
print(f'Für diese Shell ausführen: export PATH={shlex.quote(str(bin_dir))}:"$PATH"')
|
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).')
|
print('Falls die Shell noch den alten Pfad merkt: rehash (zsh) bzw. hash -r (bash).')
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description='einvoice und jr-einvoice im Benutzer-Suchpfad einrichten.')
|
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('--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()
|
args = parser.parse_args()
|
||||||
try:
|
try:
|
||||||
install(args.bin_dir)
|
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:
|
except (Exception, KeyboardInterrupt) as exc:
|
||||||
print(f'FEHLER: {exc}; geänderte Befehle wurden zurückgesetzt.', file=sys.stderr)
|
print(f'FEHLER: {exc}; geänderte Befehle wurden zurückgesetzt.', file=sys.stderr)
|
||||||
if isinstance(exc, subprocess.CalledProcessError) and exc.stderr:
|
if isinstance(exc, subprocess.CalledProcessError) and exc.stderr:
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in new issue
Block a user