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

+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