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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
Rechnungsnummer, Berechnung, PDF/A-Erstellung, Einbettung des EN16931-XML und
|
||||
@@ -114,7 +159,7 @@ einvoice --update --rollback
|
||||
jr-einvoice --help
|
||||
```
|
||||
|
||||
Der Installer verlinkt beide Starter nach `~/.local/bin` und prüft ihre Funktion.
|
||||
Der Installer verlinkt die Konsolen- und GUI-Starter nach `~/.local/bin` und prüft die Konsole.
|
||||
Dort vorhandene andere Befehle werden vor dem Ersetzen gesichert. Wiederholtes
|
||||
Ausführen ist möglich. Auf diesem Rechner ist das Verzeichnis bereits im PATH.
|
||||
Bei einem gespeicherten alten Shell-Pfad einmal `rehash` (zsh) bzw. `hash -r`
|
||||
|
||||
+9
-1
@@ -6,7 +6,7 @@ if [[ "${1:-}" == --update ]]; then
|
||||
shift
|
||||
exec python3 "$root/scripts/update.py" "$@"
|
||||
fi
|
||||
# Resolve once: one invocation uses one complete release even during an update.
|
||||
# Ein Aufruf verwendet auch während eines Updates eine vollständige Laufzeit.
|
||||
if [[ -L "$root/.runtime/current" ]]; then
|
||||
release="$(readlink -f -- "$root/.runtime/current")"
|
||||
export JR_EINVOICE_MUSTANG_JAR="$release/Mustang-CLI.jar"
|
||||
@@ -16,6 +16,14 @@ else
|
||||
fi
|
||||
export JR_EINVOICE_HOME="${JR_EINVOICE_HOME:-$root/private}"
|
||||
export JR_EINVOICE_PRESETS="${JR_EINVOICE_PRESETS:-$root/private/presets}"
|
||||
if [[ "$(basename -- "$0")" == einvoice-ui || "${1:-}" == --gui ]]; then
|
||||
if [[ "${1:-}" == --gui ]]; then shift; fi
|
||||
if ! "$python" -I -c 'from PySide6.QtPdfWidgets import QPdfView' 2>/dev/null; then
|
||||
echo 'Oberfläche fehlt. Einmalig: einvoice --update --apply --with-gui' >&2
|
||||
exit 1
|
||||
fi
|
||||
exec "$python" -I -m jr_einvoice.gui "$@"
|
||||
fi
|
||||
if [[ "$(basename -- "$0")" == jr-einvoice ]]; then
|
||||
exec "$python" -I -c 'from jr_einvoice.cli import app; app()' "$@"
|
||||
fi
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
einvoice
|
||||
+9
-1
@@ -6,7 +6,7 @@ if [[ "${1:-}" == --update ]]; then
|
||||
shift
|
||||
exec python3 "$root/scripts/update.py" "$@"
|
||||
fi
|
||||
# Resolve once: one invocation uses one complete release even during an update.
|
||||
# Ein Aufruf verwendet auch während eines Updates eine vollständige Laufzeit.
|
||||
if [[ -L "$root/.runtime/current" ]]; then
|
||||
release="$(readlink -f -- "$root/.runtime/current")"
|
||||
export JR_EINVOICE_MUSTANG_JAR="$release/Mustang-CLI.jar"
|
||||
@@ -16,6 +16,14 @@ else
|
||||
fi
|
||||
export JR_EINVOICE_HOME="${JR_EINVOICE_HOME:-$root/private}"
|
||||
export JR_EINVOICE_PRESETS="${JR_EINVOICE_PRESETS:-$root/private/presets}"
|
||||
if [[ "$(basename -- "$0")" == einvoice-ui || "${1:-}" == --gui ]]; then
|
||||
if [[ "${1:-}" == --gui ]]; then shift; fi
|
||||
if ! "$python" -I -c 'from PySide6.QtPdfWidgets import QPdfView' 2>/dev/null; then
|
||||
echo 'Oberfläche fehlt. Einmalig: einvoice --update --apply --with-gui' >&2
|
||||
exit 1
|
||||
fi
|
||||
exec "$python" -I -m jr_einvoice.gui "$@"
|
||||
fi
|
||||
if [[ "$(basename -- "$0")" == jr-einvoice ]]; then
|
||||
exec "$python" -I -c 'from jr_einvoice.cli import app; app()' "$@"
|
||||
fi
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 198 KiB |
@@ -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 datetime import date, timedelta
|
||||
from decimal import Decimal, InvalidOperation
|
||||
@@ -16,7 +16,7 @@ from .calc import compute_totals
|
||||
from .utils import money_fmt
|
||||
from .folder_naming import default_invoice_folder_name
|
||||
from .invoice_number import invoice_root_lock
|
||||
from .models import Invoice, Party
|
||||
from .models import Invoice, Item, Party
|
||||
from .pipeline import build_in_folder, BuildResult
|
||||
from .presets import load_yaml
|
||||
|
||||
@@ -24,7 +24,7 @@ MONTHS = 'januar februar märz april mai juni juli august september oktober nove
|
||||
|
||||
|
||||
def decimal_input(value: str) -> Decimal:
|
||||
"""Accept 85,00 €, 1.234,56 EUR or ungrouped decimal-point notation."""
|
||||
"""Deutsche Beträge und ungruppierte Zahlen mit Dezimalpunkt einlesen."""
|
||||
raw = re.sub(r'\s*(?:€|EUR)\s*$', '', value.strip(), flags=re.I)
|
||||
raw = raw.replace('\u00a0', '').strip()
|
||||
if ',' in raw:
|
||||
@@ -43,7 +43,7 @@ def decimal_input(value: str) -> Decimal:
|
||||
|
||||
|
||||
def customer_key(name: str) -> str:
|
||||
# Hash prevents punctuation/transliteration collisions and path traversal.
|
||||
# Der Hash verhindert Namenskollisionen und Pfadmanipulation durch Kundennamen.
|
||||
readable = re.sub(r'[^a-z0-9]+', '-', name.casefold()).strip('-')[:32] or 'kunde'
|
||||
return f'{readable}-{hashlib.sha256(name.casefold().encode()).hexdigest()[:8]}'
|
||||
|
||||
@@ -67,7 +67,7 @@ def save_customer(presets: Path, name: str, buyer: dict) -> tuple[str, dict]:
|
||||
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
target = directory / f'{key}.yaml'
|
||||
data = {'buyer': buyer}
|
||||
# Exclusive create; never replace an existing customer's stored details silently.
|
||||
# Vorhandene Kundenstammdaten niemals stillschweigend ersetzen.
|
||||
with target.open('x', encoding='utf-8') as file:
|
||||
target.chmod(0o600)
|
||||
yaml.safe_dump(data, file, allow_unicode=True, sort_keys=False)
|
||||
@@ -106,6 +106,10 @@ def create_invoice(*, root: Path, resources: Path, presets: Path, customer: str,
|
||||
month: str | None = None, service_date: date | None = None,
|
||||
unit: str = 'HUR', tax: Decimal = Decimal('19'), due_days: int = 30,
|
||||
buyer_reference: str | None = None, order_reference: str | None = None,
|
||||
items: list[Item] | None = None,
|
||||
notes: str = 'Vielen Dank für Ihren Auftrag.',
|
||||
service_period_start: date | None = None,
|
||||
service_period_end: date | None = None,
|
||||
progress=lambda message: None) -> CreatedInvoice:
|
||||
if due_days < 0:
|
||||
raise ValueError('Zahlungsziel darf nicht negativ sein')
|
||||
@@ -120,9 +124,21 @@ def create_invoice(*, root: Path, resources: Path, presets: Path, customer: str,
|
||||
key, record = match
|
||||
issued = issued or date.today()
|
||||
delivery, start, end = service_period(entry, month, service_date, issued)
|
||||
if service_period_start is not None or service_period_end is not None:
|
||||
if month or service_date:
|
||||
raise ValueError('Leistungszeitraum nicht mit Monat oder Einzeltermin kombinieren')
|
||||
if not service_period_start or not service_period_end or service_period_start > service_period_end:
|
||||
raise ValueError('Leistungszeitraum benötigt einen Beginn und ein Ende in richtiger Reihenfolge')
|
||||
start, end, delivery = service_period_start, service_period_end, service_period_end
|
||||
positions = items if items is not None else [Item(name=entry, quantity=volume, unit=unit,
|
||||
unit_price_net=price, tax_rate=tax)]
|
||||
# Eingaben vor Nummernreservierung und Dateizugriff auf ihre Form prüfen.
|
||||
positions = [Item.model_validate(item) for item in positions]
|
||||
if not positions:
|
||||
raise ValueError('Mindestens eine Rechnungsposition ist erforderlich')
|
||||
root = root.expanduser().resolve()
|
||||
with invoice_root_lock(root):
|
||||
# Only dedicated invoice directories; unknown/corrupt records stop allocation.
|
||||
# Unklare vorhandene Entwürfe stoppen die Vergabe neuer Nummern.
|
||||
used = set()
|
||||
for path in root.glob('*/invoice.yaml'):
|
||||
existing = load_yaml(path).get('meta', {}).get('invoice_number')
|
||||
@@ -145,12 +161,11 @@ def create_invoice(*, root: Path, resources: Path, presets: Path, customer: str,
|
||||
'meta': {'invoice_number': number, 'invoice_date': issued, 'service_date': delivery,
|
||||
'service_period_start': start, 'service_period_end': end,
|
||||
'buyer_reference': buyer_reference, 'order_reference': order_reference,
|
||||
'notes': 'Vielen Dank für Ihren Auftrag.'},
|
||||
'notes': notes},
|
||||
'seller': seller_data['seller'], 'buyer': record['buyer'],
|
||||
'payment': {**seller_data['payment'], 'due_date': issued + timedelta(days=due_days),
|
||||
'reference': f'Rechnung {number}'},
|
||||
'items': [{'name': entry, 'quantity': volume, 'unit': unit,
|
||||
'unit_price_net': price, 'tax_rate': tax}],
|
||||
'items': [item.model_dump(mode='json') for item in positions],
|
||||
})
|
||||
with tempfile.TemporaryDirectory(prefix='.create-', dir=root) as staging:
|
||||
work = Path(staging) / 'invoice'
|
||||
@@ -164,7 +179,7 @@ def create_invoice(*, root: Path, resources: Path, presets: Path, customer: str,
|
||||
if logo.is_file():
|
||||
shutil.copy2(logo, work / 'assets/logo.svg')
|
||||
work.rename(target)
|
||||
# Retain the reserved number and input on build failures for diagnosis/retry.
|
||||
# Bei Fehlern Nummer und Entwurf für Diagnose und Wiederholung erhalten.
|
||||
from .logging_utils import setup_logging
|
||||
logger = setup_logging('WARNING', target / 'out/logs/run.log')
|
||||
progress(f'Rechnungsdaten vollständig: {number} · {inv.buyer.name}')
|
||||
|
||||
@@ -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
|
||||
|
||||
import argparse
|
||||
@@ -61,6 +61,7 @@ class Maintenance:
|
||||
env.pop(key, None)
|
||||
env['JR_EINVOICE_MUSTANG_JAR'] = str(release / 'Mustang-CLI.jar')
|
||||
env['JR_EINVOICE_INTEGRATION'] = '1'
|
||||
env['QT_QPA_PLATFORM'] = 'offscreen'
|
||||
self.log.info('PRÜFUNG: pip check, WeasyPrint, CLI und echte Rechnungs-/Negativtests')
|
||||
self.run([python, '-I', '-m', 'pip', 'check'], env=env)
|
||||
with tempfile.TemporaryDirectory(prefix='einvoice-health-') as folder:
|
||||
@@ -87,7 +88,7 @@ class Maintenance:
|
||||
def activate(self, target: Path):
|
||||
active = self.state / 'current'
|
||||
old = active.resolve() if active.is_symlink() else self.baseline()
|
||||
# Persist rollback target before changing the single runtime pointer.
|
||||
# Rücksprungziel vor dem einzigen aktiven Laufzeitzeiger sichern.
|
||||
point(self.state / 'previous', old)
|
||||
self.log.info('AKTIVIERUNG: %s', target)
|
||||
point(active, target)
|
||||
@@ -99,7 +100,7 @@ class Maintenance:
|
||||
raise
|
||||
self.log.info('ERFOLG: Laufzeit aktiv. Rollback verfügbar: %s', old)
|
||||
|
||||
def update(self, apply=False, version=None):
|
||||
def update(self, apply=False, version=None, gui=False):
|
||||
version = version or get_latest_mustang_version(self.log)
|
||||
if not re.fullmatch(r'\d+(?:\.\d+)+', version):
|
||||
raise ValueError(f'Keine stabile Mustang-Version: {version!r}; --mustang-version verwenden.')
|
||||
@@ -109,9 +110,14 @@ class Maintenance:
|
||||
self.run(['java', '-version'])
|
||||
self.run([sys.executable, '-m', 'venv', release / 'venv'])
|
||||
python = release / 'venv/bin/python'
|
||||
# Eine einmal aktivierte Oberfläche auch bei späteren Updates mitnehmen.
|
||||
current_manifest = self.state / 'current/manifest.json'
|
||||
if current_manifest.is_file():
|
||||
gui = gui or json.loads(current_manifest.read_text()).get('gui', False)
|
||||
extras = 'pdf,gui' if gui else 'pdf'
|
||||
self.run([python, '-m', 'pip', 'install', '--upgrade', 'pip'])
|
||||
self.run([python, '-m', 'pip', 'install', '--upgrade', '--upgrade-strategy', 'eager',
|
||||
'--report', release / 'install-report.json', f'{self.root}[pdf]'])
|
||||
'--report', release / 'install-report.json', f'{self.root}[{extras}]'])
|
||||
install_mustang(version, release / 'Mustang-CLI.jar', self.log)
|
||||
frozen = self.run([python, '-m', 'pip', 'freeze', '--all'])
|
||||
(release / 'requirements.freeze.txt').write_text(frozen)
|
||||
@@ -122,7 +128,7 @@ class Maintenance:
|
||||
before = old.get(package['name'].lower(), '(neu)')
|
||||
if before != package['version']:
|
||||
self.log.info('VERSION: %s: %s -> %s', package['name'], before, package['version'])
|
||||
manifest = {'mustang': version, 'jar_sha256': hashlib.sha256((release / 'Mustang-CLI.jar').read_bytes()).hexdigest(),
|
||||
manifest = {'mustang': version, 'gui': gui, 'jar_sha256': hashlib.sha256((release / 'Mustang-CLI.jar').read_bytes()).hexdigest(),
|
||||
'packages': packages, 'source_commit': self.run(['git', 'rev-parse', 'HEAD']).strip()}
|
||||
(release / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
|
||||
self.health(release)
|
||||
@@ -147,6 +153,7 @@ def main(argv=None):
|
||||
mode.add_argument('--rollback', action='store_true', help='Vorherigen Stand prüfen und aktivieren')
|
||||
mode.add_argument('--status', action='store_true', help='Aktiven und vorherigen Stand anzeigen, ohne Netzwerk')
|
||||
parser.add_argument('--mustang-version', help='Explizite stabile Version statt Maven-Release')
|
||||
parser.add_argument('--with-gui', action='store_true', help='Desktop-Oberfläche einschließlich PDF-Anzeige installieren')
|
||||
args = parser.parse_args(argv)
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
state = root / '.runtime'
|
||||
@@ -170,7 +177,7 @@ def main(argv=None):
|
||||
elif args.rollback:
|
||||
maintenance.rollback()
|
||||
else:
|
||||
maintenance.update(args.apply, args.mustang_version)
|
||||
maintenance.update(args.apply, args.mustang_version, args.with_gui)
|
||||
except (Exception, KeyboardInterrupt) as exc:
|
||||
logging.getLogger('maintenance').error('FEHLER: %s; nicht aktivierte Kandidaten bleiben zur Diagnose erhalten.', exc)
|
||||
return 1
|
||||
|
||||
+5
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "jr-einvoice-toolchain"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
description = "Folder-first Factur-X (ZUGFeRD/EN16931) invoice generator for Linux/Arch: invoice folder + YAML -> PDF + XML -> Factur-X PDF + validation."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -22,10 +22,14 @@ dependencies = [
|
||||
pdf = [
|
||||
"weasyprint>=62.3"
|
||||
]
|
||||
gui = [
|
||||
"PySide6>=6.8,<7"
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
jr-einvoice = "jr_einvoice.cli:app"
|
||||
einvoice = "jr_einvoice.quick_cli:main"
|
||||
einvoice-ui = "jr_einvoice.gui:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["jr_einvoice"]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install per-user launchers without sudo or changing shell configuration."""
|
||||
"""Starter im Benutzer-Suchpfad ohne sudo oder Änderungen an der Shell einrichten."""
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import os
|
||||
@@ -15,7 +15,7 @@ def install(bin_dir: Path) -> None:
|
||||
bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
changed = []
|
||||
try:
|
||||
for name in ('einvoice', 'jr-einvoice'):
|
||||
for name in ('einvoice', 'jr-einvoice', 'einvoice-ui'):
|
||||
source = root / 'bin' / name
|
||||
target = bin_dir / name
|
||||
if target.is_symlink() and target.resolve() == source:
|
||||
@@ -41,7 +41,7 @@ def install(bin_dir: Path) -> None:
|
||||
temporary.unlink(missing_ok=True)
|
||||
changed.append((target, backup))
|
||||
print(f'Eingerichtet: {target} -> {source}', flush=True)
|
||||
# A different cwd verifies that symlinks resolve to the checkout correctly.
|
||||
# Aus fremdem Verzeichnis die Auflösung der Konsolenstarter prüfen.
|
||||
for name in ('einvoice', 'jr-einvoice'):
|
||||
subprocess.run([str(bin_dir / name), '--help'], cwd=bin_dir,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True)
|
||||
@@ -56,16 +56,30 @@ def install(bin_dir: Path) -> None:
|
||||
if str(bin_dir) not in os.environ.get('PATH', '').split(os.pathsep):
|
||||
import shlex
|
||||
print(f'Für diese Shell ausführen: export PATH={shlex.quote(str(bin_dir))}:"$PATH"')
|
||||
print('Ab jetzt: einvoice --help | einvoice --update --status | jr-einvoice --help')
|
||||
print('Ab jetzt: einvoice --help | einvoice-ui | einvoice --update --status | jr-einvoice --help')
|
||||
print('Falls die Shell noch den alten Pfad merkt: rehash (zsh) bzw. hash -r (bash).')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='einvoice und jr-einvoice im Benutzer-Suchpfad einrichten.')
|
||||
parser.add_argument('--bin-dir', type=Path, default=Path.home() / '.local/bin')
|
||||
parser.add_argument('--desktop', action='store_true', help='Zusätzlich einen Eintrag im Anwendungsmenü installieren')
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
install(args.bin_dir)
|
||||
if args.desktop:
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
applications = Path(os.environ.get('XDG_DATA_HOME', Path.home() / '.local/share')) / 'applications'
|
||||
applications.mkdir(parents=True, exist_ok=True)
|
||||
launcher = str(root / 'bin/einvoice-ui').replace('%', '%%')
|
||||
for char in ('\\', '"', '`', '$'):
|
||||
launcher = launcher.replace(char, '\\' + char)
|
||||
entry = applications / 'de.jr-it-services.einvoice.desktop'
|
||||
entry.write_text('[Desktop Entry]\nType=Application\nName=JR eInvoice\n'
|
||||
'Comment=E-Rechnungen erstellen und als PDF ansehen\n'
|
||||
f'Exec="{launcher}"\nIcon={root / "assets/logo.svg"}\n'
|
||||
'Terminal=false\nCategories=Office;Finance;\nStartupNotify=true\n', encoding='utf-8')
|
||||
print(f'Anwendungsmenü eingerichtet: {entry}')
|
||||
except (Exception, KeyboardInterrupt) as exc:
|
||||
print(f'FEHLER: {exc}; geänderte Befehle wurden zurückgesetzt.', file=sys.stderr)
|
||||
if isinstance(exc, subprocess.CalledProcessError) and exc.stderr:
|
||||
|
||||
@@ -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