Lokalen Rechnungsassistenten mit Vorlagen und PDF-Vorschau ergänzen

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

No files matched your search

+739
View File
@@ -0,0 +1,739 @@
"""Lokaler Rechnungsassistent mit Qt und eingebetteter PDF-Ansicht."""
from __future__ import annotations
import argparse
import calendar
from datetime import date
import json
from pathlib import Path
import sys
import tempfile
from PySide6.QtCore import QDate, Qt, QObject, QProcess, QUrl, Signal
from PySide6.QtGui import QDesktopServices, QIcon
from PySide6.QtPdf import QPdfDocument
from PySide6.QtPdfWidgets import QPdfView
from PySide6.QtWidgets import (
QApplication, QComboBox, QDateEdit, QFileDialog, QFormLayout, QFrame,
QGridLayout, QGroupBox, QHBoxLayout, QInputDialog, QLabel, QLineEdit,
QMainWindow, QMessageBox, QPlainTextEdit, QProgressBar, QPushButton,
QScrollArea, QSpinBox, QSplitter, QVBoxLayout, QWidget,
)
from .calc import compute_totals, line_net
from .cli import _default_mustang_jar
from .create import CreatedInvoice, decimal_input, find_customer
from .gui_data import InvoiceForm, InvoiceTemplate, load_template, save_template
from .models import Invoice, Item, Party
from .pipeline import BuildResult
from .paths import private_root, presets_root, resource_root
from .presets import load_yaml
from .utils import money_fmt
STYLE = """
QWidget { font-family: 'Noto Sans', 'DejaVu Sans'; font-size: 13px; color: #24374b; }
QMainWindow, QScrollArea, QWidget#editor { background: #f1f5f9; }
QFrame#header { background: #123655; border-radius: 12px; }
QLabel#brand { color: white; font-size: 25px; font-weight: 700; }
QLabel#tagline { color: #c7dbea; }
QLabel#badge { color: #b4eadb; background: #204a61; padding: 8px 14px; border-radius: 14px; }
QGroupBox { background: white; border: 1px solid #dce5ed; border-radius: 10px;
margin-top: 22px; padding: 20px 14px 12px; font-weight: 600; }
QGroupBox::title { subcontrol-origin: margin; left: 14px; padding: 0 4px; }
QLineEdit, QPlainTextEdit, QComboBox, QDateEdit, QSpinBox {
background: white; color: #24374b; selection-background-color: #c1e9df;
border: 1px solid #c7d4df; border-radius: 5px; padding: 7px; min-height: 20px; }
QLineEdit:focus, QPlainTextEdit:focus, QComboBox:focus, QDateEdit:focus, QSpinBox:focus {
border: 1px solid #168575; }
QLineEdit:read-only { background: #f4f7fa; color: #52677b; }
QPushButton { background: white; border: 1px solid #bdcdda; border-radius: 6px;
padding: 9px 13px; min-height: 18px; font-weight: 600; }
QPushButton:hover { background: #e7f0f6; }
QPushButton:disabled { color: #8696a7; background: #edf1f4; border-color: #dce5ed; }
QPushButton#primary { color: white; background: #087f70; border: 0; padding: 12px 18px; }
QPushButton#primary:hover { background: #066c60; }
QPushButton#primary:disabled { background: #8bbab1; }
QPushButton#remove { color: #a34242; font-size: 12px; padding: 5px 9px; }
QLabel#muted { color: #60768b; }
QLabel#total { color: #123655; font-size: 21px; font-weight: 700; }
QFrame#summary { background: #e4f1ed; border-radius: 8px; padding: 6px; }
QLabel#notice { background: #e8eff5; padding: 12px; border-radius: 7px; }
QProgressBar { border: 0; background: #e1e9ef; height: 5px; border-radius: 2px; }
QProgressBar::chunk { background: #087f70; }
QSplitter::handle { background: #dce5ed; width: 3px; }
"""
def label(text: str, name: str = '') -> QLabel:
widget = QLabel(text)
widget.setObjectName(name)
widget.setWordWrap(True)
# Fachliche Eingaben dürfen keine HTML-Auszeichnung in Statusfeldern erzeugen.
widget.setTextFormat(Qt.TextFormat.PlainText)
return widget
def button(text: str, callback, name: str = '') -> QPushButton:
widget = QPushButton(text)
widget.setObjectName(name)
widget.clicked.connect(callback)
return widget
def date_edit(value: date) -> QDateEdit:
widget = QDateEdit(QDate(value.year, value.month, value.day))
widget.setCalendarPopup(True)
widget.setDisplayFormat('dd.MM.yyyy')
return widget
def german_number(value) -> str:
return format(value, 'f').replace('.', ',')
class Position(QGroupBox):
"""Eine Position mit mehrzeiliger Beschreibung und deutscher Zahleneingabe."""
changed = Signal()
remove_requested = Signal(object)
def __init__(self, item: Item | None = None):
super().__init__('Rechnungsposition')
layout = QVBoxLayout(self)
head = QHBoxLayout()
self.name = QLineEdit()
self.name.setPlaceholderText('Leistung, z. B. Softwareentwicklung')
head.addWidget(self.name, 1)
head.addWidget(button('Entfernen', lambda: self.remove_requested.emit(self), 'remove'))
layout.addLayout(head)
self.description = QPlainTextEdit()
self.description.setPlaceholderText('Weitere Angaben zur Leistung (optional)')
self.description.setFixedHeight(62)
layout.addWidget(self.description)
grid = QGridLayout()
self.quantity = QLineEdit('1')
self.unit = QComboBox()
for text, code in [('Stunden', 'HUR'), ('Stück', 'C62'), ('Tage', 'DAY')]:
self.unit.addItem(text, code)
self.price = QLineEdit('0,00')
self.tax = QLineEdit('19')
self.total = label('0,00 EUR', 'muted')
for col, (title, widget) in enumerate([
('Menge', self.quantity), ('Einheit', self.unit), ('Einzelpreis netto', self.price),
('USt %', self.tax), ('Position netto', self.total),
]):
grid.addWidget(label(title, 'muted'), 0, col)
grid.addWidget(widget, 1, col)
grid.setColumnStretch(col, 1)
layout.addLayout(grid)
if item:
self.name.setText(item.name)
self.description.setPlainText(item.description or '')
self.quantity.setText(german_number(item.quantity))
self.price.setText(german_number(item.unit_price_net))
self.tax.setText(german_number(item.tax_rate))
index = self.unit.findData(item.unit)
if index < 0:
self.unit.addItem(item.unit, item.unit)
index = self.unit.count() - 1
self.unit.setCurrentIndex(index)
for field in (self.name, self.quantity, self.price, self.tax):
field.textChanged.connect(self.changed)
self.description.textChanged.connect(self.changed)
self.unit.currentIndexChanged.connect(self.changed)
def item(self) -> Item:
return Item(name=self.name.text(), description=self.description.toPlainText() or None,
quantity=decimal_input(self.quantity.text()), unit=self.unit.currentData(),
unit_price_net=decimal_input(self.price.text()), tax_rate=decimal_input(self.tax.text()))
class InvoiceWorker(QObject):
"""PDF und Java-Prüfung von der Qt-Laufzeit vollständig trennen."""
progress = Signal(str)
succeeded = Signal(object)
failed = Signal(str)
finished = Signal()
def __init__(self, request, parent=None):
super().__init__(parent)
self.directory = tempfile.TemporaryDirectory(prefix='jr-einvoice-auftrag-')
self.request_path = Path(self.directory.name) / 'request.json'
self.request_path.write_text(json.dumps(request), encoding='utf-8')
self.request_path.chmod(0o600)
self.process = QProcess(self)
self.process.setProgram(sys.executable)
self.process.setArguments(['-I', '-m', 'jr_einvoice.gui_worker', str(self.request_path)])
self.process.readyReadStandardOutput.connect(self.read_progress)
self.process.finished.connect(self.completed)
self.process.errorOccurred.connect(self.process_error)
self.buffer = b''
self.done = False
def start(self):
self.process.start()
def read_progress(self):
self.buffer += bytes(self.process.readAllStandardOutput())
while b'\n' in self.buffer:
line, self.buffer = self.buffer.split(b'\n', 1)
try:
self.progress.emit(json.loads(line)['progress'])
except (ValueError, KeyError):
pass
def process_error(self, error):
if error == QProcess.ProcessError.FailedToStart:
self.completed(-1)
def completed(self, code, *_):
if self.done:
return
self.done = True
try:
path = self.request_path.with_name('result.json')
if not path.is_file():
detail = bytes(self.process.readAllStandardError()).decode('utf-8', errors='replace')[-4000:]
raise RuntimeError(f'Hintergrundprozess fehlgeschlagen (Exit {code}). {detail}')
payload = json.loads(path.read_text(encoding='utf-8'))
if code or 'error' in payload:
raise RuntimeError(payload.get('error', f'Hintergrundprozess beendet (Exit {code})'))
self.succeeded.emit(payload['result'])
except Exception as exc:
self.failed.emit(str(exc))
finally:
self.directory.cleanup()
self.finished.emit()
class InvoiceWindow(QMainWindow):
def __init__(self, *, root: Path | None = None, presets: Path | None = None,
jar: Path | None = None):
super().__init__()
self.resources = resource_root()
self.root = root or private_root() / 'invoices'
self.presets = presets or presets_root()
self.jar = jar or _default_mustang_jar()
self.template_dir = private_root() / 'templates'
self.scratch = tempfile.TemporaryDirectory(prefix='jr-einvoice-vorschau-')
self.worker = None
self.positions: list[Position] = []
self.result = None
self.generated = False
self.dirty = False
self.loading = True
self.setWindowTitle('JR IT Services · eInvoice')
self.resize(1520, 1000)
self.setMinimumSize(1000, 720)
icon = self.resources / 'assets/logo.svg'
self.setWindowIcon(QIcon(str(icon)))
self.setStyleSheet(STYLE)
central = QWidget()
self.setCentralWidget(central)
outer = QVBoxLayout(central)
outer.setContentsMargins(20, 16, 20, 16)
header = QFrame()
header.setObjectName('header')
row = QHBoxLayout(header)
row.setContentsMargins(22, 18, 22, 18)
titles = QVBoxLayout()
titles.addWidget(label('JR IT Services / eInvoice', 'brand'))
titles.addWidget(label('Von der Vorlage zur geprüften E-Rechnung.', 'tagline'))
row.addLayout(titles, 1)
row.addWidget(label('LOKAL · ZUGFeRD / Factur-X', 'badge'))
row.addWidget(button('Neue Rechnung', self.new_invoice))
outer.addWidget(header)
splitter = QSplitter()
outer.addWidget(splitter, 1)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
self.editor = QWidget()
self.editor.setObjectName('editor')
layout = QVBoxLayout(self.editor)
layout.setContentsMargins(0, 0, 14, 0)
scroll.setWidget(self.editor)
splitter.addWidget(scroll)
self._template_section(layout)
self._customer_section(layout)
self._invoice_section(layout)
self.positions_layout = QVBoxLayout()
self.positions_layout.setContentsMargins(0, 0, 0, 0)
layout.addLayout(self.positions_layout)
layout.addWidget(button('+ Position hinzufügen', self.add_position))
self.notes = QPlainTextEdit('Vielen Dank für Ihren Auftrag.')
self.notes.setFixedHeight(70)
layout.addWidget(label('Hinweise auf der Rechnung', 'muted'))
layout.addWidget(self.notes)
layout.addStretch()
self._preview_section(splitter)
splitter.setSizes([760, 720])
splitter.setChildrenCollapsible(False)
self.progress = QProgressBar()
self.progress.setRange(0, 1)
self.progress.setValue(0)
self.progress.setTextVisible(False)
outer.addWidget(self.progress)
self.status = label('Kunden und Positionen erfassen oder eine Vorlage laden.', 'notice')
self.status.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
outer.addWidget(self.status)
actions = QHBoxLayout()
self.totals = label('Netto — · USt — · Brutto —', 'total')
actions.addWidget(self.totals, 1)
self.preview_button = button('Entwurf ansehen', self.preview)
self.generate_button = button('E-Rechnung erstellen', self.generate, 'primary')
actions.addWidget(self.preview_button)
actions.addWidget(self.generate_button)
outer.addLayout(actions)
self._connect_changes()
self.refresh_customers()
self.refresh_templates()
self.add_position()
self.loading = False
self.dirty = False
self.changed()
self.dirty = False
def _template_section(self, layout):
group = QGroupBox('01 Vorlage')
box = QVBoxLayout(group)
self.templates = QComboBox()
self.templates.setMinimumContentsLength(20)
self.templates.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
box.addWidget(self.templates)
row = QHBoxLayout()
row.addWidget(button('Auswahl laden', self.load_selected_template))
row.addWidget(button('Datei öffnen …', self.load_template_file))
row.addWidget(button('Als Vorlage speichern …', self.save_current_template))
box.addLayout(row)
box.addWidget(label('Vorlagen und frühere Rechnungen als Ausgangspunkt verwenden.', 'muted'))
layout.addWidget(group)
def _customer_section(self, layout):
group = QGroupBox('02 Rechnungsempfänger')
box = QVBoxLayout(group)
self.customers = QComboBox()
self.customers.currentIndexChanged.connect(self.select_customer)
box.addWidget(self.customers)
self.customer_summary = label('', 'muted')
box.addWidget(self.customer_summary)
self.customer_toggle = button('Anschrift anzeigen / verbergen', self.toggle_customer_details)
box.addWidget(self.customer_toggle)
self.customer_details = QWidget()
form = QFormLayout(self.customer_details)
form.setContentsMargins(0, 0, 0, 0)
box.addWidget(self.customer_details)
self.buyer_fields = {}
for key, title in [('name', 'Firma / Name'), ('street', 'Straße / Hausnummer'),
('postal_code', 'Postleitzahl'), ('city', 'Ort'), ('country', 'Land (ISO-Code)'),
('email', 'Rechnungs-E-Mail (optional)'), ('vat_id', 'USt-ID (optional)')]:
field = QLineEdit('DE' if key == 'country' else '')
self.buyer_fields[key] = field
form.addRow(title, field)
self.customer_note = label('Neue Kunden werden bei der Erstellung gespeichert.', 'muted')
box.addWidget(self.customer_note)
layout.addWidget(group)
def toggle_customer_details(self):
self.customer_details.setVisible(not self.customer_details.isVisible())
def _invoice_section(self, layout):
group = QGroupBox('03 Rechnungsdaten')
form = QGridLayout(group)
self.number = QLineEdit()
self.number.setPlaceholderText('Automatisch: JR-JJJJ-NNNN')
self.issued = date_edit(date.today())
today = date.today()
self.start = date_edit(today.replace(day=1))
self.end = date_edit(today.replace(day=calendar.monthrange(today.year, today.month)[1]))
self.due = QSpinBox()
self.due.setRange(0, 3650)
self.due.setValue(30)
self.due.setSuffix(' Tage')
self.buyer_reference = QLineEdit()
self.order_reference = QLineEdit()
for index, (text, field) in enumerate([
('Rechnungsnummer', self.number), ('Rechnungsdatum', self.issued),
('Leistung von', self.start), ('Leistung bis', self.end),
('Zahlungsziel', self.due), ('Bestellnummer', self.order_reference),
('Kundenreferenz', self.buyer_reference)]):
row, col = divmod(index, 2)
form.addWidget(label(text, 'muted'), row * 2, col)
form.addWidget(field, row * 2 + 1, col)
form.setColumnStretch(0, 1)
form.setColumnStretch(1, 1)
layout.addWidget(group)
def _preview_section(self, splitter):
pane = QWidget()
layout = QVBoxLayout(pane)
layout.setContentsMargins(14, 4, 0, 0)
row = QHBoxLayout()
row.addWidget(label('Rechnungsvorschau', 'total'), 1)
zoom = QComboBox()
zoom.addItems(['Seitenbreite', 'Ganze Seite', '100 %'])
row.addWidget(zoom)
layout.addLayout(row)
self.preview_state = label('Hier erscheint der Entwurf oder die fertige, geprüfte PDF.', 'notice')
layout.addWidget(self.preview_state)
self.document = QPdfDocument(self)
self.pdf = QPdfView()
self.pdf.setDocument(self.document)
self.pdf.setPageMode(QPdfView.PageMode.MultiPage)
self.pdf.setZoomMode(QPdfView.ZoomMode.FitToWidth)
zoom.currentIndexChanged.connect(lambda index: self.pdf.setZoomMode([
QPdfView.ZoomMode.FitToWidth, QPdfView.ZoomMode.FitInView, QPdfView.ZoomMode.Custom][index]))
layout.addWidget(self.pdf, 1)
self.path_label = label('', 'muted')
self.path_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
layout.addWidget(self.path_label)
row = QHBoxLayout()
self.open_pdf = button('PDF extern öffnen', lambda: self.open_result('pdf'))
self.open_folder = button('Rechnungsordner', lambda: self.open_result('folder'))
self.open_report = button('Prüfbericht', lambda: self.open_result('report'))
for widget in (self.open_pdf, self.open_folder, self.open_report):
widget.setEnabled(False)
row.addWidget(widget)
layout.addLayout(row)
splitter.addWidget(pane)
def _connect_changes(self):
for field in [*self.buyer_fields.values(), self.number, self.buyer_reference, self.order_reference]:
field.textChanged.connect(self.changed)
for field in (self.issued, self.start, self.end):
field.dateChanged.connect(self.changed)
self.due.valueChanged.connect(self.changed)
self.notes.textChanged.connect(self.changed)
def changed(self, *_):
if self.loading:
return
self.dirty = True
self.generated = False
self.generate_button.setEnabled(self.worker is None)
self.preview_button.setEnabled(self.worker is None)
if self.document.pageCount():
self.preview_state.setText('Eingaben geändert · Die angezeigte PDF zeigt den vorherigen Stand.')
for widget in (self.open_pdf, self.open_folder, self.open_report):
widget.setEnabled(False)
try:
items = [position.item() for position in self.positions]
# Gleiche Rundung wie im Rechnungskern, einschließlich Steuergruppen.
from types import SimpleNamespace
totals = compute_totals(SimpleNamespace(items=items))
self.totals.setText(f'Netto {money_fmt(totals.net_total)} · USt {money_fmt(totals.tax_total)}'
f' · Brutto {money_fmt(totals.gross_total)}')
for position, item in zip(self.positions, items):
position.total.setText(money_fmt(line_net(item)))
except (ValueError, ArithmeticError):
self.totals.setText('Beträge ergänzen, um die Summe zu berechnen.')
def add_position(self, item=None):
# Das clicked-Signal liefert bei einem Button ein bool statt einer Position.
position = Position(item if isinstance(item, Item) else None)
position.changed.connect(self.changed)
position.remove_requested.connect(self.remove_position)
self.positions.append(position)
self.positions_layout.addWidget(position)
self.changed()
def remove_position(self, position):
self.positions.remove(position)
self.positions_layout.removeWidget(position)
position.deleteLater()
self.changed()
def refresh_customers(self):
self.customers.blockSignals(True)
self.customers.clear()
self.customers.addItem('Neuen Kunden erfassen', None)
for path in sorted((self.presets / 'customers').glob('*.yaml')):
buyer = Party.model_validate(load_yaml(path)['buyer'])
self.customers.addItem(f'{buyer.name} ({path.stem})', buyer)
self.customers.blockSignals(False)
self.select_customer()
def select_customer(self, *_):
buyer = self.customers.currentData()
data = buyer.model_dump() if buyer else {}
address = data.get('address', {})
for key, field in self.buyer_fields.items():
field.setReadOnly(buyer is not None)
field.setText(str(data.get(key) or address.get(key) or ('DE' if key == 'country' else '')))
self.customer_note.setText('Gespeicherte Stammdaten werden unverändert verwendet.' if buyer else
'Neue Kunden werden bei der Erstellung gespeichert.')
self.customer_summary.setText(f'{buyer.name}\n{buyer.address.street} · '
f'{buyer.address.postal_code} {buyer.address.city} · {buyer.address.country}'
if buyer else '')
self.customer_summary.setVisible(buyer is not None)
self.customer_toggle.setVisible(buyer is not None)
self.customer_details.setVisible(buyer is None)
self.changed()
def refresh_templates(self):
self.templates.clear()
self.templates.addItem('Vorlage oder frühere Rechnung auswählen …', None)
for path in sorted(self.template_dir.glob('*.yaml')):
self.templates.addItem(f'Vorlage · {path.stem}', path)
for path in sorted(self.root.glob('*/invoice.yaml'), reverse=True):
self.templates.addItem(f'Rechnung · {path.parent.name}', path)
def form(self) -> InvoiceForm:
fields = {key: field.text().strip() for key, field in self.buyer_fields.items()}
buyer = self.customers.currentData() or Party.model_validate({
'name': fields['name'], 'email': fields['email'] or None, 'vat_id': fields['vat_id'] or None,
'address': {key: fields[key].upper() if key == 'country' else fields[key]
for key in ('street', 'postal_code', 'city', 'country')}})
return InvoiceForm(buyer=buyer, items=[position.item() for position in self.positions],
issued=self.issued.date().toPython(), due_days=self.due.value(),
start=self.start.date().toPython(), end=self.end.date().toPython(),
number=self.number.text().strip() or None, notes=self.notes.toPlainText(),
buyer_reference=self.buyer_reference.text().strip() or None,
order_reference=self.order_reference.text().strip() or None)
def error(self, message):
self.status.setText('Bitte prüfen: ' + message)
dialog = QMessageBox(self)
dialog.setIcon(QMessageBox.Icon.Warning)
dialog.setWindowTitle('Rechnung konnte nicht verarbeitet werden')
dialog.setTextFormat(Qt.TextFormat.PlainText)
dialog.setText(message)
dialog.exec()
def confirm_replace(self) -> bool:
return not self.dirty or QMessageBox.question(
self, 'Formular ersetzen?', 'Die aktuellen Formulareingaben durch die Vorlage ersetzen?',
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No) == QMessageBox.StandardButton.Yes
def new_invoice(self):
if self.worker is not None or not self.confirm_replace():
return
self.loading = True
self.refresh_customers()
for position in list(self.positions):
self.remove_position(position)
self.add_position()
self.number.clear()
today = date.today()
self.issued.setDate(QDate(today.year, today.month, today.day))
self.start.setDate(QDate(today.year, today.month, 1))
self.end.setDate(QDate(today.year, today.month, calendar.monthrange(today.year, today.month)[1]))
self.due.setValue(30)
self.buyer_reference.clear()
self.order_reference.clear()
self.notes.setPlainText('Vielen Dank für Ihren Auftrag.')
self.result = None
self.document.close()
self.path_label.clear()
self.preview_state.setText('Hier erscheint der Entwurf oder die fertige, geprüfte PDF.')
self.loading = False
self.changed()
self.dirty = False
self.status.setText('Neue Rechnung: Kunden und Positionen erfassen oder Vorlage laden.')
def load_selected_template(self):
path = self.templates.currentData()
if path:
self.apply_template(path)
def load_template_file(self):
path, _ = QFileDialog.getOpenFileName(self, 'Rechnungsvorlage laden', str(self.template_dir),
'YAML-Dateien (*.yaml *.yml)')
if path:
self.apply_template(Path(path))
def apply_template(self, path: Path):
try:
template = load_template(path)
if not self.confirm_replace():
return
self.loading = True
self.customers.setCurrentIndex(0)
# Bei importierten Vorlagen die vollständigen Stammdaten vergleichen.
found = False
for index in range(1, self.customers.count()):
if self.customers.itemData(index) == template.buyer:
self.customers.setCurrentIndex(index)
found = True
break
if not found:
buyer = template.buyer.model_dump()
for key, field in self.buyer_fields.items():
field.setText(str(buyer.get(key) or buyer['address'].get(key) or ''))
# Nicht sichtbare zusätzliche Stammdaten (z. B. Steuernummer) bewahren.
if not found:
self.customers.addItem(f'Vorlage · {template.buyer.name}', template.buyer)
self.customers.setCurrentIndex(self.customers.count() - 1)
for position in list(self.positions):
self.remove_position(position)
for item in template.items:
self.add_position(item)
self.number.clear()
today = date.today()
self.issued.setDate(QDate(today.year, today.month, today.day))
self.start.setDate(QDate(today.year, today.month, 1))
self.end.setDate(QDate(today.year, today.month, calendar.monthrange(today.year, today.month)[1]))
self.due.setValue(template.due_days)
self.notes.setPlainText(template.notes)
self.buyer_reference.setText(template.buyer_reference or '')
self.order_reference.setText(template.order_reference or '')
self.status.setText('Vorlage geladen. Nummer zurückgesetzt; Datumsfelder auf heute / aktuellen Monat gesetzt. '
'Leistungsbeschreibung, Mengen, Preise und Zeitraum für die neue Rechnung prüfen.')
except Exception as exc:
self.error(str(exc))
finally:
self.loading = False
self.changed()
def save_current_template(self):
try:
template = InvoiceTemplate.from_form(self.form())
name, accepted = QInputDialog.getText(self, 'Vorlage speichern', 'Name der Rechnungsvorlage:')
if not accepted:
return
try:
path = save_template(self.template_dir, name.strip(), template)
except FileExistsError:
if QMessageBox.question(self, 'Vorlage vorhanden', 'Diese Vorlage überschreiben?',
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No) != QMessageBox.StandardButton.Yes:
return
path = save_template(self.template_dir, name.strip(), template, overwrite=True)
self.refresh_templates()
self.status.setText(f'Vorlage gespeichert: {path}')
except Exception as exc:
self.error(str(exc))
def start_task(self, mode, form, finished):
if self.worker is not None:
return
self.editor.setEnabled(False)
self.preview_button.setEnabled(False)
self.generate_button.setEnabled(False)
self.progress.setRange(0, 0)
request = {'mode': mode, 'form': form.model_dump(mode='json'),
'root': str(self.root), 'presets': str(self.presets), 'resources': str(self.resources),
'jar': str(self.jar), 'scratch': self.scratch.name}
self.worker = InvoiceWorker(request, self)
self.worker.progress.connect(self.status.setText)
self.worker.succeeded.connect(finished)
self.worker.failed.connect(self.error)
self.worker.finished.connect(self.task_finished)
self.worker.finished.connect(self.worker.deleteLater)
self.worker.start()
def task_finished(self):
self.worker = None
self.editor.setEnabled(True)
self.preview_button.setEnabled(not self.generated)
self.generate_button.setEnabled(not self.generated)
self.progress.setRange(0, 1)
self.progress.setValue(1)
def preview(self):
try:
form = self.form()
form.invoice(self.presets)
# Ein Dokument vor erneutem Schreiben freigeben (auch unter Windows).
self.document.close()
self.start_task('preview', form, self.show_draft)
except Exception as exc:
self.error(str(exc))
def show_pdf(self, path: Path):
self.document.close()
error = self.document.load(str(path))
if error != QPdfDocument.Error.None_:
raise RuntimeError(f'PDF kann nicht angezeigt werden: {path} ({error.name})')
self.path_label.setText(str(path))
def show_draft(self, payload):
try:
self.show_pdf(Path(payload['pdf']))
self.preview_state.setText('ENTWURF · Keine Nummer vergeben · Noch keine E-Rechnung')
self.status.setText('Entwurf bereit. Mit „E-Rechnung erstellen“ die endgültige PDF erzeugen und prüfen.')
except Exception as exc:
self.error(str(exc))
def generate(self):
# Auch programmatische Doppelaufrufe dürfen keine zweite Rechnung erzeugen.
if self.worker is not None or self.generated:
return
try:
form = self.form()
form.invoice(self.presets)
if not self.jar or not self.jar.is_file():
raise ValueError('Mustang fehlt. Einmalig jr-einvoice setup-mustang ausführen.')
existing = find_customer(self.presets, form.buyer.name)
if existing and Party.model_validate(existing[1]['buyer']) != form.buyer:
raise ValueError('Dieser Kundenname ist mit anderen Stammdaten gespeichert. '
'Bitte den gespeicherten Kunden auswählen oder die Stammdaten in der Kundendatei prüfen.')
self.status.setText('E-Rechnung wird erstellt …')
self.start_task('generate', form, self.show_result)
except Exception as exc:
self.error(str(exc))
def show_result(self, payload):
result = CreatedInvoice(BuildResult(**{key: Path(value) if value is not None else None
for key, value in payload['build'].items()}),
Path(payload['attachment']), Invoice.model_validate(payload['invoice']))
self.result = result
self.generated = True
self.dirty = False
self.refresh_templates()
for widget in (self.open_pdf, self.open_folder, self.open_report):
widget.setEnabled(True)
self.preview_state.setText(f'{result.invoice.meta.invoice_number} · PDF/A und EN16931 technisch gültig')
self.status.setText('Fertige E-Rechnung gespeichert. Empfänger, Leistungsdaten und Betrag vor Versand prüfen. '
'Die angezeigte PDF kann direkt als E-Mail-Anhang verwendet werden.')
try:
self.show_pdf(result.attachment)
except Exception as exc:
self.error('Rechnung erfolgreich gespeichert; Vorschau fehlgeschlagen: ' + str(exc))
def open_result(self, kind):
if self.result:
path = {'pdf': self.result.attachment, 'folder': self.result.build.invoice_dir,
'report': self.result.build.validate_report}[kind]
QDesktopServices.openUrl(QUrl.fromLocalFile(str(path)))
def closeEvent(self, event):
if self.worker is not None:
self.status.setText('Bitte warten: Die laufende Erstellung oder Prüfung wird zuerst abgeschlossen.')
event.ignore()
return
if self.dirty and QMessageBox.question(
self, 'Formular schließen?', 'Ungespeicherte Formulareingaben verwerfen und schließen?',
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No) != QMessageBox.StandardButton.Yes:
event.ignore()
return
self.document.close()
self.scratch.cleanup()
event.accept()
def main(argv=None):
parser = argparse.ArgumentParser(description='Lokaler Rechnungsassistent mit PDF-Vorschau für JR IT Services.')
parser.add_argument('--root', type=Path, help='Abweichender Rechnungsstamm')
parser.add_argument('--mustang-jar', type=Path, help='Abweichende Mustang-CLI.jar')
args = parser.parse_args(argv)
app = QApplication(sys.argv[:1])
app.setApplicationName('JR eInvoice')
app.setOrganizationName('JR IT Services')
app.setStyle('Fusion')
app.setPalette(app.style().standardPalette())
try:
window = InvoiceWindow(root=args.root, jar=args.mustang_jar)
window.show()
return app.exec()
except Exception as exc:
print(f'GUI konnte nicht gestartet werden: {exc}', file=sys.stderr)
return 1
if __name__ == '__main__':
raise SystemExit(main())