Lokalen Rechnungsassistenten mit Vorlagen und PDF-Vorschau ergänzen
This commit is contained in:
1 parent
f447603af4
commit
b9f307fc81
15 files changed
+1423
-26
No files matched your search
+25
-10
@@ -1,4 +1,4 @@
|
||||
"""One-command invoice creation with reusable private customer records."""
|
||||
"""Rechnungen für Konsole und Oberfläche mit gemeinsamen Prüfungen erstellen."""
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal, InvalidOperation
|
||||
@@ -16,7 +16,7 @@ from .calc import compute_totals
|
||||
from .utils import money_fmt
|
||||
from .folder_naming import default_invoice_folder_name
|
||||
from .invoice_number import invoice_root_lock
|
||||
from .models import Invoice, Party
|
||||
from .models import Invoice, Item, Party
|
||||
from .pipeline import build_in_folder, BuildResult
|
||||
from .presets import load_yaml
|
||||
|
||||
@@ -24,7 +24,7 @@ MONTHS = 'januar februar märz april mai juni juli august september oktober nove
|
||||
|
||||
|
||||
def decimal_input(value: str) -> Decimal:
|
||||
"""Accept 85,00 €, 1.234,56 EUR or ungrouped decimal-point notation."""
|
||||
"""Deutsche Beträge und ungruppierte Zahlen mit Dezimalpunkt einlesen."""
|
||||
raw = re.sub(r'\s*(?:€|EUR)\s*$', '', value.strip(), flags=re.I)
|
||||
raw = raw.replace('\u00a0', '').strip()
|
||||
if ',' in raw:
|
||||
@@ -43,7 +43,7 @@ def decimal_input(value: str) -> Decimal:
|
||||
|
||||
|
||||
def customer_key(name: str) -> str:
|
||||
# Hash prevents punctuation/transliteration collisions and path traversal.
|
||||
# Der Hash verhindert Namenskollisionen und Pfadmanipulation durch Kundennamen.
|
||||
readable = re.sub(r'[^a-z0-9]+', '-', name.casefold()).strip('-')[:32] or 'kunde'
|
||||
return f'{readable}-{hashlib.sha256(name.casefold().encode()).hexdigest()[:8]}'
|
||||
|
||||
@@ -67,7 +67,7 @@ def save_customer(presets: Path, name: str, buyer: dict) -> tuple[str, dict]:
|
||||
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
target = directory / f'{key}.yaml'
|
||||
data = {'buyer': buyer}
|
||||
# Exclusive create; never replace an existing customer's stored details silently.
|
||||
# Vorhandene Kundenstammdaten niemals stillschweigend ersetzen.
|
||||
with target.open('x', encoding='utf-8') as file:
|
||||
target.chmod(0o600)
|
||||
yaml.safe_dump(data, file, allow_unicode=True, sort_keys=False)
|
||||
@@ -106,6 +106,10 @@ def create_invoice(*, root: Path, resources: Path, presets: Path, customer: str,
|
||||
month: str | None = None, service_date: date | None = None,
|
||||
unit: str = 'HUR', tax: Decimal = Decimal('19'), due_days: int = 30,
|
||||
buyer_reference: str | None = None, order_reference: str | None = None,
|
||||
items: list[Item] | None = None,
|
||||
notes: str = 'Vielen Dank für Ihren Auftrag.',
|
||||
service_period_start: date | None = None,
|
||||
service_period_end: date | None = None,
|
||||
progress=lambda message: None) -> CreatedInvoice:
|
||||
if due_days < 0:
|
||||
raise ValueError('Zahlungsziel darf nicht negativ sein')
|
||||
@@ -120,9 +124,21 @@ def create_invoice(*, root: Path, resources: Path, presets: Path, customer: str,
|
||||
key, record = match
|
||||
issued = issued or date.today()
|
||||
delivery, start, end = service_period(entry, month, service_date, issued)
|
||||
if service_period_start is not None or service_period_end is not None:
|
||||
if month or service_date:
|
||||
raise ValueError('Leistungszeitraum nicht mit Monat oder Einzeltermin kombinieren')
|
||||
if not service_period_start or not service_period_end or service_period_start > service_period_end:
|
||||
raise ValueError('Leistungszeitraum benötigt einen Beginn und ein Ende in richtiger Reihenfolge')
|
||||
start, end, delivery = service_period_start, service_period_end, service_period_end
|
||||
positions = items if items is not None else [Item(name=entry, quantity=volume, unit=unit,
|
||||
unit_price_net=price, tax_rate=tax)]
|
||||
# Eingaben vor Nummernreservierung und Dateizugriff auf ihre Form prüfen.
|
||||
positions = [Item.model_validate(item) for item in positions]
|
||||
if not positions:
|
||||
raise ValueError('Mindestens eine Rechnungsposition ist erforderlich')
|
||||
root = root.expanduser().resolve()
|
||||
with invoice_root_lock(root):
|
||||
# Only dedicated invoice directories; unknown/corrupt records stop allocation.
|
||||
# Unklare vorhandene Entwürfe stoppen die Vergabe neuer Nummern.
|
||||
used = set()
|
||||
for path in root.glob('*/invoice.yaml'):
|
||||
existing = load_yaml(path).get('meta', {}).get('invoice_number')
|
||||
@@ -145,12 +161,11 @@ def create_invoice(*, root: Path, resources: Path, presets: Path, customer: str,
|
||||
'meta': {'invoice_number': number, 'invoice_date': issued, 'service_date': delivery,
|
||||
'service_period_start': start, 'service_period_end': end,
|
||||
'buyer_reference': buyer_reference, 'order_reference': order_reference,
|
||||
'notes': 'Vielen Dank für Ihren Auftrag.'},
|
||||
'notes': notes},
|
||||
'seller': seller_data['seller'], 'buyer': record['buyer'],
|
||||
'payment': {**seller_data['payment'], 'due_date': issued + timedelta(days=due_days),
|
||||
'reference': f'Rechnung {number}'},
|
||||
'items': [{'name': entry, 'quantity': volume, 'unit': unit,
|
||||
'unit_price_net': price, 'tax_rate': tax}],
|
||||
'items': [item.model_dump(mode='json') for item in positions],
|
||||
})
|
||||
with tempfile.TemporaryDirectory(prefix='.create-', dir=root) as staging:
|
||||
work = Path(staging) / 'invoice'
|
||||
@@ -164,7 +179,7 @@ def create_invoice(*, root: Path, resources: Path, presets: Path, customer: str,
|
||||
if logo.is_file():
|
||||
shutil.copy2(logo, work / 'assets/logo.svg')
|
||||
work.rename(target)
|
||||
# Retain the reserved number and input on build failures for diagnosis/retry.
|
||||
# Bei Fehlern Nummer und Entwurf für Diagnose und Wiederholung erhalten.
|
||||
from .logging_utils import setup_logging
|
||||
logger = setup_logging('WARNING', target / 'out/logs/run.log')
|
||||
progress(f'Rechnungsdaten vollständig: {number} · {inv.buyer.name}')
|
||||
|
||||
@@ -0,0 +1,739 @@
|
||||
"""Lokaler Rechnungsassistent mit Qt und eingebetteter PDF-Ansicht."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import calendar
|
||||
from datetime import date
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from PySide6.QtCore import QDate, Qt, QObject, QProcess, QUrl, Signal
|
||||
from PySide6.QtGui import QDesktopServices, QIcon
|
||||
from PySide6.QtPdf import QPdfDocument
|
||||
from PySide6.QtPdfWidgets import QPdfView
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication, QComboBox, QDateEdit, QFileDialog, QFormLayout, QFrame,
|
||||
QGridLayout, QGroupBox, QHBoxLayout, QInputDialog, QLabel, QLineEdit,
|
||||
QMainWindow, QMessageBox, QPlainTextEdit, QProgressBar, QPushButton,
|
||||
QScrollArea, QSpinBox, QSplitter, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from .calc import compute_totals, line_net
|
||||
from .cli import _default_mustang_jar
|
||||
from .create import CreatedInvoice, decimal_input, find_customer
|
||||
from .gui_data import InvoiceForm, InvoiceTemplate, load_template, save_template
|
||||
from .models import Invoice, Item, Party
|
||||
from .pipeline import BuildResult
|
||||
from .paths import private_root, presets_root, resource_root
|
||||
from .presets import load_yaml
|
||||
from .utils import money_fmt
|
||||
|
||||
|
||||
STYLE = """
|
||||
QWidget { font-family: 'Noto Sans', 'DejaVu Sans'; font-size: 13px; color: #24374b; }
|
||||
QMainWindow, QScrollArea, QWidget#editor { background: #f1f5f9; }
|
||||
QFrame#header { background: #123655; border-radius: 12px; }
|
||||
QLabel#brand { color: white; font-size: 25px; font-weight: 700; }
|
||||
QLabel#tagline { color: #c7dbea; }
|
||||
QLabel#badge { color: #b4eadb; background: #204a61; padding: 8px 14px; border-radius: 14px; }
|
||||
QGroupBox { background: white; border: 1px solid #dce5ed; border-radius: 10px;
|
||||
margin-top: 22px; padding: 20px 14px 12px; font-weight: 600; }
|
||||
QGroupBox::title { subcontrol-origin: margin; left: 14px; padding: 0 4px; }
|
||||
QLineEdit, QPlainTextEdit, QComboBox, QDateEdit, QSpinBox {
|
||||
background: white; color: #24374b; selection-background-color: #c1e9df;
|
||||
border: 1px solid #c7d4df; border-radius: 5px; padding: 7px; min-height: 20px; }
|
||||
QLineEdit:focus, QPlainTextEdit:focus, QComboBox:focus, QDateEdit:focus, QSpinBox:focus {
|
||||
border: 1px solid #168575; }
|
||||
QLineEdit:read-only { background: #f4f7fa; color: #52677b; }
|
||||
QPushButton { background: white; border: 1px solid #bdcdda; border-radius: 6px;
|
||||
padding: 9px 13px; min-height: 18px; font-weight: 600; }
|
||||
QPushButton:hover { background: #e7f0f6; }
|
||||
QPushButton:disabled { color: #8696a7; background: #edf1f4; border-color: #dce5ed; }
|
||||
QPushButton#primary { color: white; background: #087f70; border: 0; padding: 12px 18px; }
|
||||
QPushButton#primary:hover { background: #066c60; }
|
||||
QPushButton#primary:disabled { background: #8bbab1; }
|
||||
QPushButton#remove { color: #a34242; font-size: 12px; padding: 5px 9px; }
|
||||
QLabel#muted { color: #60768b; }
|
||||
QLabel#total { color: #123655; font-size: 21px; font-weight: 700; }
|
||||
QFrame#summary { background: #e4f1ed; border-radius: 8px; padding: 6px; }
|
||||
QLabel#notice { background: #e8eff5; padding: 12px; border-radius: 7px; }
|
||||
QProgressBar { border: 0; background: #e1e9ef; height: 5px; border-radius: 2px; }
|
||||
QProgressBar::chunk { background: #087f70; }
|
||||
QSplitter::handle { background: #dce5ed; width: 3px; }
|
||||
"""
|
||||
|
||||
|
||||
def label(text: str, name: str = '') -> QLabel:
|
||||
widget = QLabel(text)
|
||||
widget.setObjectName(name)
|
||||
widget.setWordWrap(True)
|
||||
# Fachliche Eingaben dürfen keine HTML-Auszeichnung in Statusfeldern erzeugen.
|
||||
widget.setTextFormat(Qt.TextFormat.PlainText)
|
||||
return widget
|
||||
|
||||
|
||||
def button(text: str, callback, name: str = '') -> QPushButton:
|
||||
widget = QPushButton(text)
|
||||
widget.setObjectName(name)
|
||||
widget.clicked.connect(callback)
|
||||
return widget
|
||||
|
||||
|
||||
def date_edit(value: date) -> QDateEdit:
|
||||
widget = QDateEdit(QDate(value.year, value.month, value.day))
|
||||
widget.setCalendarPopup(True)
|
||||
widget.setDisplayFormat('dd.MM.yyyy')
|
||||
return widget
|
||||
|
||||
|
||||
def german_number(value) -> str:
|
||||
return format(value, 'f').replace('.', ',')
|
||||
|
||||
|
||||
class Position(QGroupBox):
|
||||
"""Eine Position mit mehrzeiliger Beschreibung und deutscher Zahleneingabe."""
|
||||
|
||||
changed = Signal()
|
||||
remove_requested = Signal(object)
|
||||
|
||||
def __init__(self, item: Item | None = None):
|
||||
super().__init__('Rechnungsposition')
|
||||
layout = QVBoxLayout(self)
|
||||
head = QHBoxLayout()
|
||||
self.name = QLineEdit()
|
||||
self.name.setPlaceholderText('Leistung, z. B. Softwareentwicklung')
|
||||
head.addWidget(self.name, 1)
|
||||
head.addWidget(button('Entfernen', lambda: self.remove_requested.emit(self), 'remove'))
|
||||
layout.addLayout(head)
|
||||
self.description = QPlainTextEdit()
|
||||
self.description.setPlaceholderText('Weitere Angaben zur Leistung (optional)')
|
||||
self.description.setFixedHeight(62)
|
||||
layout.addWidget(self.description)
|
||||
grid = QGridLayout()
|
||||
self.quantity = QLineEdit('1')
|
||||
self.unit = QComboBox()
|
||||
for text, code in [('Stunden', 'HUR'), ('Stück', 'C62'), ('Tage', 'DAY')]:
|
||||
self.unit.addItem(text, code)
|
||||
self.price = QLineEdit('0,00')
|
||||
self.tax = QLineEdit('19')
|
||||
self.total = label('0,00 EUR', 'muted')
|
||||
for col, (title, widget) in enumerate([
|
||||
('Menge', self.quantity), ('Einheit', self.unit), ('Einzelpreis netto', self.price),
|
||||
('USt %', self.tax), ('Position netto', self.total),
|
||||
]):
|
||||
grid.addWidget(label(title, 'muted'), 0, col)
|
||||
grid.addWidget(widget, 1, col)
|
||||
grid.setColumnStretch(col, 1)
|
||||
layout.addLayout(grid)
|
||||
if item:
|
||||
self.name.setText(item.name)
|
||||
self.description.setPlainText(item.description or '')
|
||||
self.quantity.setText(german_number(item.quantity))
|
||||
self.price.setText(german_number(item.unit_price_net))
|
||||
self.tax.setText(german_number(item.tax_rate))
|
||||
index = self.unit.findData(item.unit)
|
||||
if index < 0:
|
||||
self.unit.addItem(item.unit, item.unit)
|
||||
index = self.unit.count() - 1
|
||||
self.unit.setCurrentIndex(index)
|
||||
for field in (self.name, self.quantity, self.price, self.tax):
|
||||
field.textChanged.connect(self.changed)
|
||||
self.description.textChanged.connect(self.changed)
|
||||
self.unit.currentIndexChanged.connect(self.changed)
|
||||
|
||||
def item(self) -> Item:
|
||||
return Item(name=self.name.text(), description=self.description.toPlainText() or None,
|
||||
quantity=decimal_input(self.quantity.text()), unit=self.unit.currentData(),
|
||||
unit_price_net=decimal_input(self.price.text()), tax_rate=decimal_input(self.tax.text()))
|
||||
|
||||
|
||||
class InvoiceWorker(QObject):
|
||||
"""PDF und Java-Prüfung von der Qt-Laufzeit vollständig trennen."""
|
||||
|
||||
progress = Signal(str)
|
||||
succeeded = Signal(object)
|
||||
failed = Signal(str)
|
||||
finished = Signal()
|
||||
|
||||
def __init__(self, request, parent=None):
|
||||
super().__init__(parent)
|
||||
self.directory = tempfile.TemporaryDirectory(prefix='jr-einvoice-auftrag-')
|
||||
self.request_path = Path(self.directory.name) / 'request.json'
|
||||
self.request_path.write_text(json.dumps(request), encoding='utf-8')
|
||||
self.request_path.chmod(0o600)
|
||||
self.process = QProcess(self)
|
||||
self.process.setProgram(sys.executable)
|
||||
self.process.setArguments(['-I', '-m', 'jr_einvoice.gui_worker', str(self.request_path)])
|
||||
self.process.readyReadStandardOutput.connect(self.read_progress)
|
||||
self.process.finished.connect(self.completed)
|
||||
self.process.errorOccurred.connect(self.process_error)
|
||||
self.buffer = b''
|
||||
self.done = False
|
||||
|
||||
def start(self):
|
||||
self.process.start()
|
||||
|
||||
def read_progress(self):
|
||||
self.buffer += bytes(self.process.readAllStandardOutput())
|
||||
while b'\n' in self.buffer:
|
||||
line, self.buffer = self.buffer.split(b'\n', 1)
|
||||
try:
|
||||
self.progress.emit(json.loads(line)['progress'])
|
||||
except (ValueError, KeyError):
|
||||
pass
|
||||
|
||||
def process_error(self, error):
|
||||
if error == QProcess.ProcessError.FailedToStart:
|
||||
self.completed(-1)
|
||||
|
||||
def completed(self, code, *_):
|
||||
if self.done:
|
||||
return
|
||||
self.done = True
|
||||
try:
|
||||
path = self.request_path.with_name('result.json')
|
||||
if not path.is_file():
|
||||
detail = bytes(self.process.readAllStandardError()).decode('utf-8', errors='replace')[-4000:]
|
||||
raise RuntimeError(f'Hintergrundprozess fehlgeschlagen (Exit {code}). {detail}')
|
||||
payload = json.loads(path.read_text(encoding='utf-8'))
|
||||
if code or 'error' in payload:
|
||||
raise RuntimeError(payload.get('error', f'Hintergrundprozess beendet (Exit {code})'))
|
||||
self.succeeded.emit(payload['result'])
|
||||
except Exception as exc:
|
||||
self.failed.emit(str(exc))
|
||||
finally:
|
||||
self.directory.cleanup()
|
||||
self.finished.emit()
|
||||
|
||||
|
||||
class InvoiceWindow(QMainWindow):
|
||||
def __init__(self, *, root: Path | None = None, presets: Path | None = None,
|
||||
jar: Path | None = None):
|
||||
super().__init__()
|
||||
self.resources = resource_root()
|
||||
self.root = root or private_root() / 'invoices'
|
||||
self.presets = presets or presets_root()
|
||||
self.jar = jar or _default_mustang_jar()
|
||||
self.template_dir = private_root() / 'templates'
|
||||
self.scratch = tempfile.TemporaryDirectory(prefix='jr-einvoice-vorschau-')
|
||||
self.worker = None
|
||||
self.positions: list[Position] = []
|
||||
self.result = None
|
||||
self.generated = False
|
||||
self.dirty = False
|
||||
self.loading = True
|
||||
self.setWindowTitle('JR IT Services · eInvoice')
|
||||
self.resize(1520, 1000)
|
||||
self.setMinimumSize(1000, 720)
|
||||
icon = self.resources / 'assets/logo.svg'
|
||||
self.setWindowIcon(QIcon(str(icon)))
|
||||
self.setStyleSheet(STYLE)
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
outer = QVBoxLayout(central)
|
||||
outer.setContentsMargins(20, 16, 20, 16)
|
||||
header = QFrame()
|
||||
header.setObjectName('header')
|
||||
row = QHBoxLayout(header)
|
||||
row.setContentsMargins(22, 18, 22, 18)
|
||||
titles = QVBoxLayout()
|
||||
titles.addWidget(label('JR IT Services / eInvoice', 'brand'))
|
||||
titles.addWidget(label('Von der Vorlage zur geprüften E-Rechnung.', 'tagline'))
|
||||
row.addLayout(titles, 1)
|
||||
row.addWidget(label('LOKAL · ZUGFeRD / Factur-X', 'badge'))
|
||||
row.addWidget(button('Neue Rechnung', self.new_invoice))
|
||||
outer.addWidget(header)
|
||||
splitter = QSplitter()
|
||||
outer.addWidget(splitter, 1)
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||
self.editor = QWidget()
|
||||
self.editor.setObjectName('editor')
|
||||
layout = QVBoxLayout(self.editor)
|
||||
layout.setContentsMargins(0, 0, 14, 0)
|
||||
scroll.setWidget(self.editor)
|
||||
splitter.addWidget(scroll)
|
||||
self._template_section(layout)
|
||||
self._customer_section(layout)
|
||||
self._invoice_section(layout)
|
||||
self.positions_layout = QVBoxLayout()
|
||||
self.positions_layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.addLayout(self.positions_layout)
|
||||
layout.addWidget(button('+ Position hinzufügen', self.add_position))
|
||||
self.notes = QPlainTextEdit('Vielen Dank für Ihren Auftrag.')
|
||||
self.notes.setFixedHeight(70)
|
||||
layout.addWidget(label('Hinweise auf der Rechnung', 'muted'))
|
||||
layout.addWidget(self.notes)
|
||||
layout.addStretch()
|
||||
self._preview_section(splitter)
|
||||
splitter.setSizes([760, 720])
|
||||
splitter.setChildrenCollapsible(False)
|
||||
self.progress = QProgressBar()
|
||||
self.progress.setRange(0, 1)
|
||||
self.progress.setValue(0)
|
||||
self.progress.setTextVisible(False)
|
||||
outer.addWidget(self.progress)
|
||||
self.status = label('Kunden und Positionen erfassen oder eine Vorlage laden.', 'notice')
|
||||
self.status.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
outer.addWidget(self.status)
|
||||
actions = QHBoxLayout()
|
||||
self.totals = label('Netto — · USt — · Brutto —', 'total')
|
||||
actions.addWidget(self.totals, 1)
|
||||
self.preview_button = button('Entwurf ansehen', self.preview)
|
||||
self.generate_button = button('E-Rechnung erstellen', self.generate, 'primary')
|
||||
actions.addWidget(self.preview_button)
|
||||
actions.addWidget(self.generate_button)
|
||||
outer.addLayout(actions)
|
||||
self._connect_changes()
|
||||
self.refresh_customers()
|
||||
self.refresh_templates()
|
||||
self.add_position()
|
||||
self.loading = False
|
||||
self.dirty = False
|
||||
self.changed()
|
||||
self.dirty = False
|
||||
|
||||
def _template_section(self, layout):
|
||||
group = QGroupBox('01 Vorlage')
|
||||
box = QVBoxLayout(group)
|
||||
self.templates = QComboBox()
|
||||
self.templates.setMinimumContentsLength(20)
|
||||
self.templates.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
|
||||
box.addWidget(self.templates)
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(button('Auswahl laden', self.load_selected_template))
|
||||
row.addWidget(button('Datei öffnen …', self.load_template_file))
|
||||
row.addWidget(button('Als Vorlage speichern …', self.save_current_template))
|
||||
box.addLayout(row)
|
||||
box.addWidget(label('Vorlagen und frühere Rechnungen als Ausgangspunkt verwenden.', 'muted'))
|
||||
layout.addWidget(group)
|
||||
|
||||
def _customer_section(self, layout):
|
||||
group = QGroupBox('02 Rechnungsempfänger')
|
||||
box = QVBoxLayout(group)
|
||||
self.customers = QComboBox()
|
||||
self.customers.currentIndexChanged.connect(self.select_customer)
|
||||
box.addWidget(self.customers)
|
||||
self.customer_summary = label('', 'muted')
|
||||
box.addWidget(self.customer_summary)
|
||||
self.customer_toggle = button('Anschrift anzeigen / verbergen', self.toggle_customer_details)
|
||||
box.addWidget(self.customer_toggle)
|
||||
self.customer_details = QWidget()
|
||||
form = QFormLayout(self.customer_details)
|
||||
form.setContentsMargins(0, 0, 0, 0)
|
||||
box.addWidget(self.customer_details)
|
||||
self.buyer_fields = {}
|
||||
for key, title in [('name', 'Firma / Name'), ('street', 'Straße / Hausnummer'),
|
||||
('postal_code', 'Postleitzahl'), ('city', 'Ort'), ('country', 'Land (ISO-Code)'),
|
||||
('email', 'Rechnungs-E-Mail (optional)'), ('vat_id', 'USt-ID (optional)')]:
|
||||
field = QLineEdit('DE' if key == 'country' else '')
|
||||
self.buyer_fields[key] = field
|
||||
form.addRow(title, field)
|
||||
self.customer_note = label('Neue Kunden werden bei der Erstellung gespeichert.', 'muted')
|
||||
box.addWidget(self.customer_note)
|
||||
layout.addWidget(group)
|
||||
|
||||
def toggle_customer_details(self):
|
||||
self.customer_details.setVisible(not self.customer_details.isVisible())
|
||||
|
||||
def _invoice_section(self, layout):
|
||||
group = QGroupBox('03 Rechnungsdaten')
|
||||
form = QGridLayout(group)
|
||||
self.number = QLineEdit()
|
||||
self.number.setPlaceholderText('Automatisch: JR-JJJJ-NNNN')
|
||||
self.issued = date_edit(date.today())
|
||||
today = date.today()
|
||||
self.start = date_edit(today.replace(day=1))
|
||||
self.end = date_edit(today.replace(day=calendar.monthrange(today.year, today.month)[1]))
|
||||
self.due = QSpinBox()
|
||||
self.due.setRange(0, 3650)
|
||||
self.due.setValue(30)
|
||||
self.due.setSuffix(' Tage')
|
||||
self.buyer_reference = QLineEdit()
|
||||
self.order_reference = QLineEdit()
|
||||
for index, (text, field) in enumerate([
|
||||
('Rechnungsnummer', self.number), ('Rechnungsdatum', self.issued),
|
||||
('Leistung von', self.start), ('Leistung bis', self.end),
|
||||
('Zahlungsziel', self.due), ('Bestellnummer', self.order_reference),
|
||||
('Kundenreferenz', self.buyer_reference)]):
|
||||
row, col = divmod(index, 2)
|
||||
form.addWidget(label(text, 'muted'), row * 2, col)
|
||||
form.addWidget(field, row * 2 + 1, col)
|
||||
form.setColumnStretch(0, 1)
|
||||
form.setColumnStretch(1, 1)
|
||||
layout.addWidget(group)
|
||||
|
||||
def _preview_section(self, splitter):
|
||||
pane = QWidget()
|
||||
layout = QVBoxLayout(pane)
|
||||
layout.setContentsMargins(14, 4, 0, 0)
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(label('Rechnungsvorschau', 'total'), 1)
|
||||
zoom = QComboBox()
|
||||
zoom.addItems(['Seitenbreite', 'Ganze Seite', '100 %'])
|
||||
row.addWidget(zoom)
|
||||
layout.addLayout(row)
|
||||
self.preview_state = label('Hier erscheint der Entwurf oder die fertige, geprüfte PDF.', 'notice')
|
||||
layout.addWidget(self.preview_state)
|
||||
self.document = QPdfDocument(self)
|
||||
self.pdf = QPdfView()
|
||||
self.pdf.setDocument(self.document)
|
||||
self.pdf.setPageMode(QPdfView.PageMode.MultiPage)
|
||||
self.pdf.setZoomMode(QPdfView.ZoomMode.FitToWidth)
|
||||
zoom.currentIndexChanged.connect(lambda index: self.pdf.setZoomMode([
|
||||
QPdfView.ZoomMode.FitToWidth, QPdfView.ZoomMode.FitInView, QPdfView.ZoomMode.Custom][index]))
|
||||
layout.addWidget(self.pdf, 1)
|
||||
self.path_label = label('', 'muted')
|
||||
self.path_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
layout.addWidget(self.path_label)
|
||||
row = QHBoxLayout()
|
||||
self.open_pdf = button('PDF extern öffnen', lambda: self.open_result('pdf'))
|
||||
self.open_folder = button('Rechnungsordner', lambda: self.open_result('folder'))
|
||||
self.open_report = button('Prüfbericht', lambda: self.open_result('report'))
|
||||
for widget in (self.open_pdf, self.open_folder, self.open_report):
|
||||
widget.setEnabled(False)
|
||||
row.addWidget(widget)
|
||||
layout.addLayout(row)
|
||||
splitter.addWidget(pane)
|
||||
|
||||
def _connect_changes(self):
|
||||
for field in [*self.buyer_fields.values(), self.number, self.buyer_reference, self.order_reference]:
|
||||
field.textChanged.connect(self.changed)
|
||||
for field in (self.issued, self.start, self.end):
|
||||
field.dateChanged.connect(self.changed)
|
||||
self.due.valueChanged.connect(self.changed)
|
||||
self.notes.textChanged.connect(self.changed)
|
||||
|
||||
def changed(self, *_):
|
||||
if self.loading:
|
||||
return
|
||||
self.dirty = True
|
||||
self.generated = False
|
||||
self.generate_button.setEnabled(self.worker is None)
|
||||
self.preview_button.setEnabled(self.worker is None)
|
||||
if self.document.pageCount():
|
||||
self.preview_state.setText('Eingaben geändert · Die angezeigte PDF zeigt den vorherigen Stand.')
|
||||
for widget in (self.open_pdf, self.open_folder, self.open_report):
|
||||
widget.setEnabled(False)
|
||||
try:
|
||||
items = [position.item() for position in self.positions]
|
||||
# Gleiche Rundung wie im Rechnungskern, einschließlich Steuergruppen.
|
||||
from types import SimpleNamespace
|
||||
totals = compute_totals(SimpleNamespace(items=items))
|
||||
self.totals.setText(f'Netto {money_fmt(totals.net_total)} · USt {money_fmt(totals.tax_total)}'
|
||||
f' · Brutto {money_fmt(totals.gross_total)}')
|
||||
for position, item in zip(self.positions, items):
|
||||
position.total.setText(money_fmt(line_net(item)))
|
||||
except (ValueError, ArithmeticError):
|
||||
self.totals.setText('Beträge ergänzen, um die Summe zu berechnen.')
|
||||
|
||||
def add_position(self, item=None):
|
||||
# Das clicked-Signal liefert bei einem Button ein bool statt einer Position.
|
||||
position = Position(item if isinstance(item, Item) else None)
|
||||
position.changed.connect(self.changed)
|
||||
position.remove_requested.connect(self.remove_position)
|
||||
self.positions.append(position)
|
||||
self.positions_layout.addWidget(position)
|
||||
self.changed()
|
||||
|
||||
def remove_position(self, position):
|
||||
self.positions.remove(position)
|
||||
self.positions_layout.removeWidget(position)
|
||||
position.deleteLater()
|
||||
self.changed()
|
||||
|
||||
def refresh_customers(self):
|
||||
self.customers.blockSignals(True)
|
||||
self.customers.clear()
|
||||
self.customers.addItem('Neuen Kunden erfassen', None)
|
||||
for path in sorted((self.presets / 'customers').glob('*.yaml')):
|
||||
buyer = Party.model_validate(load_yaml(path)['buyer'])
|
||||
self.customers.addItem(f'{buyer.name} ({path.stem})', buyer)
|
||||
self.customers.blockSignals(False)
|
||||
self.select_customer()
|
||||
|
||||
def select_customer(self, *_):
|
||||
buyer = self.customers.currentData()
|
||||
data = buyer.model_dump() if buyer else {}
|
||||
address = data.get('address', {})
|
||||
for key, field in self.buyer_fields.items():
|
||||
field.setReadOnly(buyer is not None)
|
||||
field.setText(str(data.get(key) or address.get(key) or ('DE' if key == 'country' else '')))
|
||||
self.customer_note.setText('Gespeicherte Stammdaten werden unverändert verwendet.' if buyer else
|
||||
'Neue Kunden werden bei der Erstellung gespeichert.')
|
||||
self.customer_summary.setText(f'{buyer.name}\n{buyer.address.street} · '
|
||||
f'{buyer.address.postal_code} {buyer.address.city} · {buyer.address.country}'
|
||||
if buyer else '')
|
||||
self.customer_summary.setVisible(buyer is not None)
|
||||
self.customer_toggle.setVisible(buyer is not None)
|
||||
self.customer_details.setVisible(buyer is None)
|
||||
self.changed()
|
||||
|
||||
def refresh_templates(self):
|
||||
self.templates.clear()
|
||||
self.templates.addItem('Vorlage oder frühere Rechnung auswählen …', None)
|
||||
for path in sorted(self.template_dir.glob('*.yaml')):
|
||||
self.templates.addItem(f'Vorlage · {path.stem}', path)
|
||||
for path in sorted(self.root.glob('*/invoice.yaml'), reverse=True):
|
||||
self.templates.addItem(f'Rechnung · {path.parent.name}', path)
|
||||
|
||||
def form(self) -> InvoiceForm:
|
||||
fields = {key: field.text().strip() for key, field in self.buyer_fields.items()}
|
||||
buyer = self.customers.currentData() or Party.model_validate({
|
||||
'name': fields['name'], 'email': fields['email'] or None, 'vat_id': fields['vat_id'] or None,
|
||||
'address': {key: fields[key].upper() if key == 'country' else fields[key]
|
||||
for key in ('street', 'postal_code', 'city', 'country')}})
|
||||
return InvoiceForm(buyer=buyer, items=[position.item() for position in self.positions],
|
||||
issued=self.issued.date().toPython(), due_days=self.due.value(),
|
||||
start=self.start.date().toPython(), end=self.end.date().toPython(),
|
||||
number=self.number.text().strip() or None, notes=self.notes.toPlainText(),
|
||||
buyer_reference=self.buyer_reference.text().strip() or None,
|
||||
order_reference=self.order_reference.text().strip() or None)
|
||||
|
||||
def error(self, message):
|
||||
self.status.setText('Bitte prüfen: ' + message)
|
||||
dialog = QMessageBox(self)
|
||||
dialog.setIcon(QMessageBox.Icon.Warning)
|
||||
dialog.setWindowTitle('Rechnung konnte nicht verarbeitet werden')
|
||||
dialog.setTextFormat(Qt.TextFormat.PlainText)
|
||||
dialog.setText(message)
|
||||
dialog.exec()
|
||||
|
||||
def confirm_replace(self) -> bool:
|
||||
return not self.dirty or QMessageBox.question(
|
||||
self, 'Formular ersetzen?', 'Die aktuellen Formulareingaben durch die Vorlage ersetzen?',
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No) == QMessageBox.StandardButton.Yes
|
||||
|
||||
def new_invoice(self):
|
||||
if self.worker is not None or not self.confirm_replace():
|
||||
return
|
||||
self.loading = True
|
||||
self.refresh_customers()
|
||||
for position in list(self.positions):
|
||||
self.remove_position(position)
|
||||
self.add_position()
|
||||
self.number.clear()
|
||||
today = date.today()
|
||||
self.issued.setDate(QDate(today.year, today.month, today.day))
|
||||
self.start.setDate(QDate(today.year, today.month, 1))
|
||||
self.end.setDate(QDate(today.year, today.month, calendar.monthrange(today.year, today.month)[1]))
|
||||
self.due.setValue(30)
|
||||
self.buyer_reference.clear()
|
||||
self.order_reference.clear()
|
||||
self.notes.setPlainText('Vielen Dank für Ihren Auftrag.')
|
||||
self.result = None
|
||||
self.document.close()
|
||||
self.path_label.clear()
|
||||
self.preview_state.setText('Hier erscheint der Entwurf oder die fertige, geprüfte PDF.')
|
||||
self.loading = False
|
||||
self.changed()
|
||||
self.dirty = False
|
||||
self.status.setText('Neue Rechnung: Kunden und Positionen erfassen oder Vorlage laden.')
|
||||
|
||||
def load_selected_template(self):
|
||||
path = self.templates.currentData()
|
||||
if path:
|
||||
self.apply_template(path)
|
||||
|
||||
def load_template_file(self):
|
||||
path, _ = QFileDialog.getOpenFileName(self, 'Rechnungsvorlage laden', str(self.template_dir),
|
||||
'YAML-Dateien (*.yaml *.yml)')
|
||||
if path:
|
||||
self.apply_template(Path(path))
|
||||
|
||||
def apply_template(self, path: Path):
|
||||
try:
|
||||
template = load_template(path)
|
||||
if not self.confirm_replace():
|
||||
return
|
||||
self.loading = True
|
||||
self.customers.setCurrentIndex(0)
|
||||
# Bei importierten Vorlagen die vollständigen Stammdaten vergleichen.
|
||||
found = False
|
||||
for index in range(1, self.customers.count()):
|
||||
if self.customers.itemData(index) == template.buyer:
|
||||
self.customers.setCurrentIndex(index)
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
buyer = template.buyer.model_dump()
|
||||
for key, field in self.buyer_fields.items():
|
||||
field.setText(str(buyer.get(key) or buyer['address'].get(key) or ''))
|
||||
# Nicht sichtbare zusätzliche Stammdaten (z. B. Steuernummer) bewahren.
|
||||
if not found:
|
||||
self.customers.addItem(f'Vorlage · {template.buyer.name}', template.buyer)
|
||||
self.customers.setCurrentIndex(self.customers.count() - 1)
|
||||
for position in list(self.positions):
|
||||
self.remove_position(position)
|
||||
for item in template.items:
|
||||
self.add_position(item)
|
||||
self.number.clear()
|
||||
today = date.today()
|
||||
self.issued.setDate(QDate(today.year, today.month, today.day))
|
||||
self.start.setDate(QDate(today.year, today.month, 1))
|
||||
self.end.setDate(QDate(today.year, today.month, calendar.monthrange(today.year, today.month)[1]))
|
||||
self.due.setValue(template.due_days)
|
||||
self.notes.setPlainText(template.notes)
|
||||
self.buyer_reference.setText(template.buyer_reference or '')
|
||||
self.order_reference.setText(template.order_reference or '')
|
||||
self.status.setText('Vorlage geladen. Nummer zurückgesetzt; Datumsfelder auf heute / aktuellen Monat gesetzt. '
|
||||
'Leistungsbeschreibung, Mengen, Preise und Zeitraum für die neue Rechnung prüfen.')
|
||||
except Exception as exc:
|
||||
self.error(str(exc))
|
||||
finally:
|
||||
self.loading = False
|
||||
self.changed()
|
||||
|
||||
def save_current_template(self):
|
||||
try:
|
||||
template = InvoiceTemplate.from_form(self.form())
|
||||
name, accepted = QInputDialog.getText(self, 'Vorlage speichern', 'Name der Rechnungsvorlage:')
|
||||
if not accepted:
|
||||
return
|
||||
try:
|
||||
path = save_template(self.template_dir, name.strip(), template)
|
||||
except FileExistsError:
|
||||
if QMessageBox.question(self, 'Vorlage vorhanden', 'Diese Vorlage überschreiben?',
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No) != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
path = save_template(self.template_dir, name.strip(), template, overwrite=True)
|
||||
self.refresh_templates()
|
||||
self.status.setText(f'Vorlage gespeichert: {path}')
|
||||
except Exception as exc:
|
||||
self.error(str(exc))
|
||||
|
||||
def start_task(self, mode, form, finished):
|
||||
if self.worker is not None:
|
||||
return
|
||||
self.editor.setEnabled(False)
|
||||
self.preview_button.setEnabled(False)
|
||||
self.generate_button.setEnabled(False)
|
||||
self.progress.setRange(0, 0)
|
||||
request = {'mode': mode, 'form': form.model_dump(mode='json'),
|
||||
'root': str(self.root), 'presets': str(self.presets), 'resources': str(self.resources),
|
||||
'jar': str(self.jar), 'scratch': self.scratch.name}
|
||||
self.worker = InvoiceWorker(request, self)
|
||||
self.worker.progress.connect(self.status.setText)
|
||||
self.worker.succeeded.connect(finished)
|
||||
self.worker.failed.connect(self.error)
|
||||
self.worker.finished.connect(self.task_finished)
|
||||
self.worker.finished.connect(self.worker.deleteLater)
|
||||
self.worker.start()
|
||||
|
||||
def task_finished(self):
|
||||
self.worker = None
|
||||
self.editor.setEnabled(True)
|
||||
self.preview_button.setEnabled(not self.generated)
|
||||
self.generate_button.setEnabled(not self.generated)
|
||||
self.progress.setRange(0, 1)
|
||||
self.progress.setValue(1)
|
||||
|
||||
def preview(self):
|
||||
try:
|
||||
form = self.form()
|
||||
form.invoice(self.presets)
|
||||
# Ein Dokument vor erneutem Schreiben freigeben (auch unter Windows).
|
||||
self.document.close()
|
||||
self.start_task('preview', form, self.show_draft)
|
||||
except Exception as exc:
|
||||
self.error(str(exc))
|
||||
|
||||
def show_pdf(self, path: Path):
|
||||
self.document.close()
|
||||
error = self.document.load(str(path))
|
||||
if error != QPdfDocument.Error.None_:
|
||||
raise RuntimeError(f'PDF kann nicht angezeigt werden: {path} ({error.name})')
|
||||
self.path_label.setText(str(path))
|
||||
|
||||
def show_draft(self, payload):
|
||||
try:
|
||||
self.show_pdf(Path(payload['pdf']))
|
||||
self.preview_state.setText('ENTWURF · Keine Nummer vergeben · Noch keine E-Rechnung')
|
||||
self.status.setText('Entwurf bereit. Mit „E-Rechnung erstellen“ die endgültige PDF erzeugen und prüfen.')
|
||||
except Exception as exc:
|
||||
self.error(str(exc))
|
||||
|
||||
def generate(self):
|
||||
# Auch programmatische Doppelaufrufe dürfen keine zweite Rechnung erzeugen.
|
||||
if self.worker is not None or self.generated:
|
||||
return
|
||||
try:
|
||||
form = self.form()
|
||||
form.invoice(self.presets)
|
||||
if not self.jar or not self.jar.is_file():
|
||||
raise ValueError('Mustang fehlt. Einmalig jr-einvoice setup-mustang ausführen.')
|
||||
existing = find_customer(self.presets, form.buyer.name)
|
||||
if existing and Party.model_validate(existing[1]['buyer']) != form.buyer:
|
||||
raise ValueError('Dieser Kundenname ist mit anderen Stammdaten gespeichert. '
|
||||
'Bitte den gespeicherten Kunden auswählen oder die Stammdaten in der Kundendatei prüfen.')
|
||||
self.status.setText('E-Rechnung wird erstellt …')
|
||||
self.start_task('generate', form, self.show_result)
|
||||
except Exception as exc:
|
||||
self.error(str(exc))
|
||||
|
||||
def show_result(self, payload):
|
||||
result = CreatedInvoice(BuildResult(**{key: Path(value) if value is not None else None
|
||||
for key, value in payload['build'].items()}),
|
||||
Path(payload['attachment']), Invoice.model_validate(payload['invoice']))
|
||||
self.result = result
|
||||
self.generated = True
|
||||
self.dirty = False
|
||||
self.refresh_templates()
|
||||
for widget in (self.open_pdf, self.open_folder, self.open_report):
|
||||
widget.setEnabled(True)
|
||||
self.preview_state.setText(f'{result.invoice.meta.invoice_number} · PDF/A und EN16931 technisch gültig')
|
||||
self.status.setText('Fertige E-Rechnung gespeichert. Empfänger, Leistungsdaten und Betrag vor Versand prüfen. '
|
||||
'Die angezeigte PDF kann direkt als E-Mail-Anhang verwendet werden.')
|
||||
try:
|
||||
self.show_pdf(result.attachment)
|
||||
except Exception as exc:
|
||||
self.error('Rechnung erfolgreich gespeichert; Vorschau fehlgeschlagen: ' + str(exc))
|
||||
|
||||
def open_result(self, kind):
|
||||
if self.result:
|
||||
path = {'pdf': self.result.attachment, 'folder': self.result.build.invoice_dir,
|
||||
'report': self.result.build.validate_report}[kind]
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(str(path)))
|
||||
|
||||
def closeEvent(self, event):
|
||||
if self.worker is not None:
|
||||
self.status.setText('Bitte warten: Die laufende Erstellung oder Prüfung wird zuerst abgeschlossen.')
|
||||
event.ignore()
|
||||
return
|
||||
if self.dirty and QMessageBox.question(
|
||||
self, 'Formular schließen?', 'Ungespeicherte Formulareingaben verwerfen und schließen?',
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No) != QMessageBox.StandardButton.Yes:
|
||||
event.ignore()
|
||||
return
|
||||
self.document.close()
|
||||
self.scratch.cleanup()
|
||||
event.accept()
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description='Lokaler Rechnungsassistent mit PDF-Vorschau für JR IT Services.')
|
||||
parser.add_argument('--root', type=Path, help='Abweichender Rechnungsstamm')
|
||||
parser.add_argument('--mustang-jar', type=Path, help='Abweichende Mustang-CLI.jar')
|
||||
args = parser.parse_args(argv)
|
||||
app = QApplication(sys.argv[:1])
|
||||
app.setApplicationName('JR eInvoice')
|
||||
app.setOrganizationName('JR IT Services')
|
||||
app.setStyle('Fusion')
|
||||
app.setPalette(app.style().standardPalette())
|
||||
try:
|
||||
window = InvoiceWindow(root=args.root, jar=args.mustang_jar)
|
||||
window.show()
|
||||
return app.exec()
|
||||
except Exception as exc:
|
||||
print(f'GUI konnte nicht gestartet werden: {exc}', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Geprüfte Formulardaten und private Vorlagen ohne Abhängigkeit von Qt."""
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
import yaml
|
||||
|
||||
from .models import Invoice, Item, Party, StrictModel
|
||||
from .presets import load_yaml
|
||||
|
||||
|
||||
class InvoiceForm(StrictModel):
|
||||
"""Vollständiger neuer Auftrag; die endgültige Nummer vergibt der Kern."""
|
||||
|
||||
buyer: Party
|
||||
items: list[Item] = Field(min_length=1)
|
||||
issued: date = Field(default_factory=date.today)
|
||||
due_days: int = Field(default=30, ge=0, le=3650)
|
||||
start: date
|
||||
end: date
|
||||
number: str | None = Field(default=None, pattern=r'^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$')
|
||||
notes: str = 'Vielen Dank für Ihren Auftrag.'
|
||||
buyer_reference: str | None = None
|
||||
order_reference: str | None = None
|
||||
|
||||
@model_validator(mode='after')
|
||||
def ordered_period(self):
|
||||
if self.start > self.end:
|
||||
raise ValueError('Der Leistungsbeginn muss vor dem Leistungsende liegen')
|
||||
return self
|
||||
|
||||
def invoice(self, presets: Path) -> Invoice:
|
||||
"""Auch Absender und Zahlungsdaten vor jeder Erstellung vollständig prüfen."""
|
||||
seller = load_yaml(presets / 'seller.default.yaml')
|
||||
if not seller.get('seller') or not seller.get('payment'):
|
||||
raise ValueError(f'Absender und Zahlungsdaten fehlen: {presets / "seller.default.yaml"}')
|
||||
return Invoice.model_validate({
|
||||
'seller': seller['seller'], 'buyer': self.buyer,
|
||||
'items': self.items,
|
||||
'meta': {'invoice_number': self.number or 'AUTOMATISCH', 'invoice_date': self.issued,
|
||||
'service_date': self.end, 'service_period_start': self.start,
|
||||
'service_period_end': self.end, 'notes': self.notes,
|
||||
'buyer_reference': self.buyer_reference, 'order_reference': self.order_reference},
|
||||
'payment': {**seller['payment'], 'due_date': self.issued + timedelta(days=self.due_days),
|
||||
'reference': f'Rechnung {self.number or "AUTOMATISCH"}'}})
|
||||
|
||||
|
||||
class InvoiceTemplate(StrictModel):
|
||||
"""Vorlagen enthalten bewusst keine alte Rechnungsnummer oder Leistungsdaten."""
|
||||
|
||||
schema_version: int = Field(default=1, ge=1, le=1)
|
||||
buyer: Party
|
||||
items: list[Item] = Field(min_length=1)
|
||||
due_days: int = Field(default=30, ge=0, le=3650)
|
||||
notes: str = 'Vielen Dank für Ihren Auftrag.'
|
||||
buyer_reference: str | None = None
|
||||
order_reference: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_form(cls, form: InvoiceForm):
|
||||
return cls.model_validate(form.model_dump(exclude={'issued', 'start', 'end', 'number'}))
|
||||
|
||||
|
||||
def load_template(path: Path) -> InvoiceTemplate:
|
||||
"""Eigene Vorlagen oder bestehende invoice.yaml als neue Rechnung übernehmen."""
|
||||
data = load_yaml(path)
|
||||
if 'meta' in data:
|
||||
inv = Invoice.model_validate(data)
|
||||
return InvoiceTemplate(buyer=inv.buyer, items=inv.items,
|
||||
due_days=(inv.payment.due_date - inv.meta.invoice_date).days,
|
||||
notes=inv.meta.notes or '', buyer_reference=inv.meta.buyer_reference,
|
||||
order_reference=inv.meta.order_reference)
|
||||
return InvoiceTemplate.model_validate(data)
|
||||
|
||||
|
||||
def save_template(directory: Path, name: str, template: InvoiceTemplate, *, overwrite=False) -> Path:
|
||||
"""Vorlagen geschützt speichern; vorhandene Namen nur ausdrücklich überschreiben."""
|
||||
if not re.fullmatch(r'[\wÄÖÜäöüß][\wÄÖÜäöüß .-]{0,79}', name) or name.endswith(('.', ' ')):
|
||||
raise ValueError('Vorlagenname: 1–80 Buchstaben/Ziffern, Leerzeichen, Punkt oder Bindestrich')
|
||||
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
target = directory / f'{name}.yaml'
|
||||
# Die temporäre Datei bleibt bei jedem Fehler außerhalb der Vorlagenauswahl.
|
||||
fd, temporary = tempfile.mkstemp(prefix='.vorlage-', dir=directory)
|
||||
try:
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as stream:
|
||||
yaml.safe_dump(template.model_dump(mode='json'), stream, allow_unicode=True, sort_keys=False)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
if overwrite:
|
||||
os.replace(temporary, target)
|
||||
else:
|
||||
# link statt exists+rename verhindert auch gleichzeitiges Überschreiben.
|
||||
os.link(temporary, target)
|
||||
finally:
|
||||
Path(temporary).unlink(missing_ok=True)
|
||||
return target
|
||||
@@ -0,0 +1,76 @@
|
||||
"""PDF-Erzeugung in einem eigenen Prozess ohne Qt-Laufzeit ausführen."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
from .create import create_invoice, find_customer, save_customer
|
||||
from .gui_data import InvoiceForm
|
||||
from .models import Party
|
||||
from .render import render_html, render_pdf
|
||||
|
||||
|
||||
def execute(request: dict, progress) -> dict:
|
||||
"""Nur geprüfte Formulardaten an die gemeinsame Rechnungserstellung übergeben."""
|
||||
form = InvoiceForm.model_validate(request['form'])
|
||||
presets, resources = Path(request['presets']), Path(request['resources'])
|
||||
invoice = form.invoice(presets)
|
||||
if request['mode'] == 'preview':
|
||||
progress('Entwurf wird ohne Nummernreservierung erstellt …')
|
||||
invoice.meta.invoice_number = 'ENTWURF'
|
||||
invoice.payment.reference = 'Entwurf – noch keine Rechnungsnummer vergeben'
|
||||
invoice.meta.notes = 'ENTWURF – nicht zum Versand.\n' + (invoice.meta.notes or '')
|
||||
folder = Path(request['scratch'])
|
||||
(folder / 'assets').mkdir(exist_ok=True)
|
||||
logo = presets / 'logo.svg'
|
||||
if not logo.is_file():
|
||||
logo = resources / 'assets/logo.svg'
|
||||
shutil.copy2(logo, folder / 'assets/logo.svg')
|
||||
result = render_pdf(render_html(invoice, resources / 'templates/invoice.html.j2'),
|
||||
folder / 'entwurf.pdf', logging.getLogger('gui-preview'), base_url=str(folder))
|
||||
if not result.ok:
|
||||
raise RuntimeError(result.error)
|
||||
return {'pdf': str(result.path)}
|
||||
if request['mode'] != 'generate':
|
||||
raise ValueError('Unbekannter Auftrag')
|
||||
jar = Path(request['jar'])
|
||||
if not jar.is_file():
|
||||
raise ValueError('Mustang fehlt. Einmalig jr-einvoice setup-mustang ausführen.')
|
||||
existing = find_customer(presets, form.buyer.name)
|
||||
if existing and Party.model_validate(existing[1]['buyer']) != form.buyer:
|
||||
raise ValueError('Dieser Kundenname ist mit anderen Stammdaten gespeichert. '
|
||||
'Bitte den gespeicherten Kunden auswählen oder die Kundendatei prüfen.')
|
||||
# Vorlagen dürfen vorhandene Kundenadressen nicht stillschweigend ersetzen.
|
||||
match = existing or save_customer(presets, form.buyer.name, form.buyer.model_dump(mode='json'))
|
||||
first = form.items[0]
|
||||
result = create_invoice(root=Path(request['root']), resources=resources, presets=presets,
|
||||
customer=match[0], entry=first.name, volume=first.quantity,
|
||||
price=first.unit_price_net, items=form.items, notes=form.notes,
|
||||
jar=jar, logger=logging.getLogger('gui'), number=form.number,
|
||||
issued=form.issued, due_days=form.due_days,
|
||||
service_period_start=form.start, service_period_end=form.end,
|
||||
buyer_reference=form.buyer_reference, order_reference=form.order_reference,
|
||||
progress=progress)
|
||||
return {'invoice': result.invoice.model_dump(mode='json'), 'attachment': str(result.attachment),
|
||||
'build': {key: str(value) if value is not None else None
|
||||
for key, value in vars(result.build).items()}}
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
request_path = Path((argv or sys.argv[1:])[0])
|
||||
result_path = request_path.with_name('result.json')
|
||||
try:
|
||||
request = json.loads(request_path.read_text(encoding='utf-8'))
|
||||
result = execute(request, lambda message: print(json.dumps({'progress': message}), flush=True))
|
||||
result_path.write_text(json.dumps({'result': result}), encoding='utf-8')
|
||||
return 0
|
||||
except Exception as exc:
|
||||
result_path.write_text(json.dumps({'error': str(exc)}), encoding='utf-8')
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Checkout maintenance; standard library only, so broken dependencies cannot block rollback."""
|
||||
"""Laufzeiten isoliert aktualisieren; Rollback bleibt ohne Zusatzpakete nutzbar."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
@@ -61,6 +61,7 @@ class Maintenance:
|
||||
env.pop(key, None)
|
||||
env['JR_EINVOICE_MUSTANG_JAR'] = str(release / 'Mustang-CLI.jar')
|
||||
env['JR_EINVOICE_INTEGRATION'] = '1'
|
||||
env['QT_QPA_PLATFORM'] = 'offscreen'
|
||||
self.log.info('PRÜFUNG: pip check, WeasyPrint, CLI und echte Rechnungs-/Negativtests')
|
||||
self.run([python, '-I', '-m', 'pip', 'check'], env=env)
|
||||
with tempfile.TemporaryDirectory(prefix='einvoice-health-') as folder:
|
||||
@@ -87,7 +88,7 @@ class Maintenance:
|
||||
def activate(self, target: Path):
|
||||
active = self.state / 'current'
|
||||
old = active.resolve() if active.is_symlink() else self.baseline()
|
||||
# Persist rollback target before changing the single runtime pointer.
|
||||
# Rücksprungziel vor dem einzigen aktiven Laufzeitzeiger sichern.
|
||||
point(self.state / 'previous', old)
|
||||
self.log.info('AKTIVIERUNG: %s', target)
|
||||
point(active, target)
|
||||
@@ -99,7 +100,7 @@ class Maintenance:
|
||||
raise
|
||||
self.log.info('ERFOLG: Laufzeit aktiv. Rollback verfügbar: %s', old)
|
||||
|
||||
def update(self, apply=False, version=None):
|
||||
def update(self, apply=False, version=None, gui=False):
|
||||
version = version or get_latest_mustang_version(self.log)
|
||||
if not re.fullmatch(r'\d+(?:\.\d+)+', version):
|
||||
raise ValueError(f'Keine stabile Mustang-Version: {version!r}; --mustang-version verwenden.')
|
||||
@@ -109,9 +110,14 @@ class Maintenance:
|
||||
self.run(['java', '-version'])
|
||||
self.run([sys.executable, '-m', 'venv', release / 'venv'])
|
||||
python = release / 'venv/bin/python'
|
||||
# Eine einmal aktivierte Oberfläche auch bei späteren Updates mitnehmen.
|
||||
current_manifest = self.state / 'current/manifest.json'
|
||||
if current_manifest.is_file():
|
||||
gui = gui or json.loads(current_manifest.read_text()).get('gui', False)
|
||||
extras = 'pdf,gui' if gui else 'pdf'
|
||||
self.run([python, '-m', 'pip', 'install', '--upgrade', 'pip'])
|
||||
self.run([python, '-m', 'pip', 'install', '--upgrade', '--upgrade-strategy', 'eager',
|
||||
'--report', release / 'install-report.json', f'{self.root}[pdf]'])
|
||||
'--report', release / 'install-report.json', f'{self.root}[{extras}]'])
|
||||
install_mustang(version, release / 'Mustang-CLI.jar', self.log)
|
||||
frozen = self.run([python, '-m', 'pip', 'freeze', '--all'])
|
||||
(release / 'requirements.freeze.txt').write_text(frozen)
|
||||
@@ -122,7 +128,7 @@ class Maintenance:
|
||||
before = old.get(package['name'].lower(), '(neu)')
|
||||
if before != package['version']:
|
||||
self.log.info('VERSION: %s: %s -> %s', package['name'], before, package['version'])
|
||||
manifest = {'mustang': version, 'jar_sha256': hashlib.sha256((release / 'Mustang-CLI.jar').read_bytes()).hexdigest(),
|
||||
manifest = {'mustang': version, 'gui': gui, 'jar_sha256': hashlib.sha256((release / 'Mustang-CLI.jar').read_bytes()).hexdigest(),
|
||||
'packages': packages, 'source_commit': self.run(['git', 'rev-parse', 'HEAD']).strip()}
|
||||
(release / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
|
||||
self.health(release)
|
||||
@@ -147,6 +153,7 @@ def main(argv=None):
|
||||
mode.add_argument('--rollback', action='store_true', help='Vorherigen Stand prüfen und aktivieren')
|
||||
mode.add_argument('--status', action='store_true', help='Aktiven und vorherigen Stand anzeigen, ohne Netzwerk')
|
||||
parser.add_argument('--mustang-version', help='Explizite stabile Version statt Maven-Release')
|
||||
parser.add_argument('--with-gui', action='store_true', help='Desktop-Oberfläche einschließlich PDF-Anzeige installieren')
|
||||
args = parser.parse_args(argv)
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
state = root / '.runtime'
|
||||
@@ -170,7 +177,7 @@ def main(argv=None):
|
||||
elif args.rollback:
|
||||
maintenance.rollback()
|
||||
else:
|
||||
maintenance.update(args.apply, args.mustang_version)
|
||||
maintenance.update(args.apply, args.mustang_version, args.with_gui)
|
||||
except (Exception, KeyboardInterrupt) as exc:
|
||||
logging.getLogger('maintenance').error('FEHLER: %s; nicht aktivierte Kandidaten bleiben zur Diagnose erhalten.', exc)
|
||||
return 1
|
||||
|
||||
Reference in new issue
Block a user