feat: create validated ZUGFeRD invoices in one console command

This commit is contained in:
admin committed 2026-10-16 09:29:08 +02:00
1 parent e0c15fc7f2
commit a643cf5227
30 files changed
+1509 -207

No files matched your search

+185
View File
@@ -0,0 +1,185 @@
"""One-command invoice creation with reusable private customer records."""
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, 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:
"""Accept 85,00 €, 1.234,56 EUR or ungrouped decimal-point notation."""
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:
# Hash prevents punctuation/transliteration collisions and path traversal.
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}
# Exclusive create; never replace an existing customer's stored details silently.
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,
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)
root = root.expanduser().resolve()
with invoice_root_lock(root):
# Only dedicated invoice directories; unknown/corrupt records stop allocation.
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': 'Vielen Dank für Ihren Auftrag.'},
'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}],
})
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)
# Retain the reserved number and input on build failures for diagnosis/retry.
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)