740 lines
34 KiB
Python
740 lines
34 KiB
Python
"""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())
|