"""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())