Lokalen Rechnungsassistenten mit Vorlagen und PDF-Vorschau ergänzen
This commit is contained in:
1 parent
f447603af4
commit
b9f307fc81
15 files changed
+1423
-26
No files matched your search
+25
-10
@@ -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}')
|
||||
|
||||
Reference in new issue
Block a user