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
@@ -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
|
||||
Reference in new issue
Block a user