"""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 from pathlib import Path import calendar import hashlib import json import re import shutil import tempfile import yaml 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, Item, Party from .pipeline import build_in_folder, BuildResult from .presets import load_yaml MONTHS = 'januar februar märz april mai juni juli august september oktober november dezember'.split() def decimal_input(value: str) -> Decimal: """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: if not re.fullmatch(r'[+-]?(?:\d+|\d{1,3}(?:\.\d{3})+),\d+', raw): raise ValueError(f'Ungültige Dezimalzahl: {value!r}') raw = raw.replace('.', '').replace(',', '.') elif not re.fullmatch(r'[+-]?\d+(?:\.\d+)?', raw): raise ValueError(f'Ungültige Dezimalzahl: {value!r}') try: result = Decimal(raw) except InvalidOperation as exc: raise ValueError(f'Ungültige Dezimalzahl: {value!r}') from exc if not result.is_finite(): raise ValueError('Zahl muss endlich sein') return result def customer_key(name: str) -> str: # 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]}' def find_customer(presets: Path, name: str) -> tuple[str, dict] | None: wanted = name.strip().casefold() matches = [] for path in sorted((presets / 'customers').glob('*.yaml')): data = load_yaml(path) if wanted in (path.stem.casefold(), str(data.get('buyer', {}).get('name', '')).casefold()): matches.append((path.stem, data)) if len(matches) > 1: raise ValueError('Kundenname ist mehrdeutig; bitte den eindeutigen Kundenschlüssel verwenden') return matches[0] if matches else None def save_customer(presets: Path, name: str, buyer: dict) -> tuple[str, dict]: buyer = Party.model_validate(buyer).model_dump(mode='json') key = customer_key(name) directory = presets / 'customers' directory.mkdir(parents=True, exist_ok=True, mode=0o700) target = directory / f'{key}.yaml' data = {'buyer': buyer} # 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) return key, data def service_period(entry: str, month: str | None, service_date: date | None, issued: date): if month and service_date: raise ValueError('--Month und --ServiceDate können nicht kombiniert werden') if service_date: return service_date, None, None if month is None: match = re.search(r'\b(' + '|'.join(MONTHS) + r')\s+(\d{4})\b', entry.casefold()) if match: month = f'{match[2]}-{MONTHS.index(match[1]) + 1:02d}' if month: if not re.fullmatch(r'\d{4}-\d{2}', month): raise ValueError('--Month muss YYYY-MM sein') year, m = map(int, month.split('-')) first = date(year, m, 1) last = date(year, m, calendar.monthrange(year, m)[1]) return last, first, last return issued, None, None @dataclass class CreatedInvoice: build: BuildResult attachment: Path invoice: Invoice def create_invoice(*, root: Path, resources: Path, presets: Path, customer: str, entry: str, volume: Decimal, price: Decimal, jar: Path, logger, number: str | None = None, issued: date | None = None, 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') if not jar.is_file(): raise FileNotFoundError('Mustang fehlt. Einmalig jr-einvoice setup-mustang ausführen') seller_data = load_yaml(presets / 'seller.default.yaml') if not seller_data.get('seller') or not seller_data.get('payment'): raise ValueError(f'Eigene Absender- und Zahlungsdaten in {presets / "seller.default.yaml"} hinterlegen') match = find_customer(presets, customer) if match is None: raise ValueError(f'Kunde {customer!r} ist noch nicht angelegt') 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): # 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') if not isinstance(existing, str) or not existing.strip(): raise ValueError(f'Rechnungsnummer im vorhandenen Entwurf unklar: {path}') used.add(existing) if not number: prefix = f'JR-{issued.year}-' sequence = max((int(n[len(prefix):]) for n in used if n.startswith(prefix) and n[len(prefix):].isdigit()), default=0) + 1 number = f'{prefix}{sequence:04d}' if number in used: raise ValueError(f'Rechnungsnummer bereits vergeben: {number}') if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_-]{0,63}', number): raise ValueError('Rechnungsnummer: 1–64 Buchstaben/Ziffern, Bindestrich oder Unterstrich') target = root / default_invoice_folder_name(number, key) if target.exists(): raise FileExistsError(f'Rechnungsordner existiert bereits: {target}') inv = Invoice.model_validate({ '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': notes}, 'seller': seller_data['seller'], 'buyer': record['buyer'], 'payment': {**seller_data['payment'], 'due_date': issued + timedelta(days=due_days), 'reference': f'Rechnung {number}'}, 'items': [item.model_dump(mode='json') for item in positions], }) with tempfile.TemporaryDirectory(prefix='.create-', dir=root) as staging: work = Path(staging) / 'invoice' work.mkdir(mode=0o700) (work / 'invoice.yaml').write_text(yaml.safe_dump(inv.model_dump(mode='json'), allow_unicode=True, sort_keys=False), encoding='utf-8') (work / 'assets').mkdir() logo = presets / 'logo.svg' if not logo.is_file(): logo = resources / 'assets/logo.svg' if logo.is_file(): shutil.copy2(logo, work / 'assets/logo.svg') work.rename(target) # 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}') progress('PDF/A und strukturierte Rechnungsdaten werden erzeugt …') try: build = build_in_folder(target, 'out', resources / 'templates/invoice.html.j2', logger, jar, True, True) except Exception as exc: raise RuntimeError(f'Erstellung fehlgeschlagen. Entwurf: {target}. Log: {target / "out/logs/run.log"}. {exc}') from exc progress('Rechnung validiert: PDF/A und EN16931-XML technisch gültig.') attachment = target / f'Rechnung_{number}.pdf' shutil.copy2(build.facturx_pdf, attachment) manifest = {str(p.relative_to(target)): hashlib.sha256(p.read_bytes()).hexdigest() for p in [target / 'invoice.yaml', build.facturx_xml, build.facturx_pdf, build.validate_report, attachment]} (target / 'out/manifest.json').write_text(json.dumps(manifest, indent=2) + '\n', encoding='utf-8') totals = compute_totals(inv) progress(f'Rechnung erzeugt · Netto: {money_fmt(totals.net_total)} · USt: {money_fmt(totals.tax_total)} · Brutto: {money_fmt(totals.gross_total)}') return CreatedInvoice(build, attachment, inv)