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}')