Files

100 lines
4.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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