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

+103
View File
@@ -0,0 +1,103 @@
"""Friendly CLI: einvoice --create --Customer ... --Entry ... --Volume ... --Price ..."""
import argparse
from datetime import date
import json
from pathlib import Path
import sys
from .create import create_invoice, decimal_input, find_customer, save_customer
from .logging_utils import setup_logging
from .paths import private_root, presets_root, resource_root
def parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description='Mit einem Aufruf eine validierte ZUGFeRD-Rechnung als PDF erzeugen.',
allow_abbrev=False)
mode = p.add_mutually_exclusive_group(required=True)
mode.add_argument('--create', action='store_true', help='Rechnung anlegen, PDF erzeugen und validieren')
mode.add_argument('--customers', action='store_true', help='Gespeicherte Kunden anzeigen')
p.add_argument('--Customer', '--customer', dest='customer', help='Gespeicherter Kundenname oder neuer Kunde')
p.add_argument('--Entry', '--entry', dest='entry', help='Leistungsbeschreibung')
p.add_argument('--Volume', '--volume', dest='volume', type=decimal_input, help='Menge, z. B. 120 oder 72,5')
p.add_argument('--Price', '--price', dest='price', type=decimal_input, help='Nettopreis je Einheit, z. B. "85,00 €"')
p.add_argument('--Unit', '--unit', dest='unit', default='HUR', help='Einheit: HUR=Stunden (Standard), C62=Stück')
p.add_argument('--Tax', '--tax', dest='tax', type=decimal_input, default=decimal_input('19'), help='USt in Prozent, Standard 19')
p.add_argument('--Number', '--number', dest='number', help='Freie Nummer; Standard automatisch JR-JJJJ-NNNN')
p.add_argument('--Date', '--date', dest='issued', type=date.fromisoformat, help='Rechnungsdatum YYYY-MM-DD, Standard heute')
p.add_argument('--Month', '--month', dest='month', help='Leistungsmonat YYYY-MM; auch aus deutschem Monatsnamen in Entry erkannt')
p.add_argument('--ServiceDate', '--service-date', dest='service_date', type=date.fromisoformat, help='Einzelnes Leistungsdatum YYYY-MM-DD')
p.add_argument('--DueDays', '--due-days', dest='due_days', type=int, default=30, help='Zahlungsziel in Tagen, Standard 30')
p.add_argument('--BuyerReference', '--buyer-reference', dest='buyer_reference', help='Kundenreferenz')
p.add_argument('--OrderReference', '--order-reference', dest='order_reference', help='Bestellnummer')
p.add_argument('--Street', '--street', dest='street', help='Straße/Hausnummer bei neuem Kunden')
p.add_argument('--PostalCode', '--postal-code', dest='postal_code', help='Postleitzahl bei neuem Kunden')
p.add_argument('--City', '--city', dest='city', help='Ort bei neuem Kunden')
p.add_argument('--Country', '--country', dest='country', default='DE', help='Ländercode, Standard DE')
p.add_argument('--Email', '--email', dest='email', help='Rechnungsadresse bei neuem Kunden (optional)')
p.add_argument('--VatId', '--vat-id', dest='vat_id', help='USt-ID bei neuem Kunden (optional)')
p.add_argument('--root', type=Path, default=private_root() / 'invoices', help='Stammordner für Rechnungen')
p.add_argument('--mustang-jar', type=Path, help='Abweichender Mustang-CLI-Pfad')
p.add_argument('--json', action='store_true', help='Ergebnisse als JSON; keine interaktiven Fragen')
p.add_argument('--non-interactive', action='store_true', help='Fehlende Daten als Fehler melden statt fragen')
return p
def main(argv=None):
p = parser()
args = p.parse_args(argv)
presets = presets_root()
try:
if args.customers:
for path in sorted((presets / 'customers').glob('*.yaml')):
from .presets import load_yaml
print(f'{path.stem}: {load_yaml(path).get("buyer", {}).get("name", "?")}')
return
missing = [flag for flag in ('customer', 'entry', 'volume', 'price') if getattr(args, flag) is None]
if missing:
p.error('Für --create fehlen: ' + ', '.join('--' + name.title() for name in missing))
if args.volume <= 0 or args.price < 0 or not (0 < args.tax <= 100) or args.due_days < 0:
p.error('Menge muss positiv sein; Preis/Zahlungsziel nicht negativ; Steuer > 0 und <= 100')
if find_customer(presets, args.customer) is None:
fields = {'street': args.street, 'postal_code': args.postal_code, 'city': args.city}
labels = {'street': 'Straße und Hausnummer', 'postal_code': 'Postleitzahl', 'city': 'Ort'}
absent = [name for name, value in fields.items() if not value]
if absent and (args.non_interactive or args.json or not sys.stdin.isatty()):
raise ValueError('Neuer Kunde: bitte ' + ', '.join('--' + {'street':'Street','postal_code':'PostalCode','city':'City'}[name] for name in absent) + ' angeben')
for name in absent:
fields[name] = input(f'Neuer Kunde {args.customer} – {labels[name]}: ').strip()
save_customer(presets, args.customer, {'name': args.customer, 'email': args.email,
'vat_id': args.vat_id, 'address': {**fields, 'country': args.country.upper()}})
if not args.json:
print(f'Kunde gespeichert: {args.customer}', flush=True)
else:
if any([args.street, args.postal_code, args.city, args.email, args.vat_id]) or args.country != 'DE':
raise ValueError('Kunde bereits gespeichert. Adressoptionen gelten nur bei Neuanlage; Stammdaten separat bearbeiten')
from .cli import _default_mustang_jar
result = create_invoice(root=args.root, resources=resource_root(), presets=presets,
customer=args.customer, entry=args.entry, volume=args.volume,
price=args.price, jar=args.mustang_jar or _default_mustang_jar(),
logger=setup_logging('WARNING', None), number=args.number,
issued=args.issued, month=args.month, service_date=args.service_date,
unit=args.unit, tax=args.tax, due_days=args.due_days,
buyer_reference=args.buyer_reference, order_reference=args.order_reference,
progress=(lambda message: None) if args.json else lambda message: print(message, flush=True))
if args.json:
print(json.dumps({'invoice_number': result.invoice.meta.invoice_number,
'invoice_dir': str(result.build.invoice_dir), 'pdf': str(result.attachment),
'validation_report': str(result.build.validate_report),
'log': str(result.build.out_dir / 'logs/run.log'),
'technical_validation': 'valid'}, ensure_ascii=False))
else:
inv = result.invoice
print(f'Leistung: {inv.meta.service_period_start or inv.meta.service_date}' +
(f' bis {inv.meta.service_period_end}' if inv.meta.service_period_end else ''))
print(f'PDF zum Anhängen an deine E-Mail:\n{result.attachment}')
print(f'Prüfbericht: {result.build.validate_report}')
print('Vor Versand Leistungsdaten, Betrag und Empfänger prüfen.')
except Exception as exc:
print(f'FEHLER: {exc}', file=sys.stderr)
raise SystemExit(1) from exc
if __name__ == '__main__':
main()