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

+25 -10
View File
@@ -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}')
+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())
+99
View File
@@ -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
+76
View File
@@ -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())
+13 -6
View File
@@ -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