import os import io import json import re import xml.etree.ElementTree as ET from xml.dom import minidom from datetime import datetime, timedelta from decimal import Decimal, ROUND_HALF_UP import pikepdf from pydantic import BaseModel, Field # ========================================== # 2. METADATEN SCHREIBSCHEMA # ========================================== class InvoiceMetadata(BaseModel): invoice_number: str = Field(description="Die Rechnungsnummer nach 'Nummer:' (z.B. R26-219)") invoice_date: str = Field(description="Das Rechnungsdatum nach 'Datum:' im Format YYYY-MM-DD") delivery_date: str = Field(description="Das Lieferdatum nach 'Lieferdatum:' im Format YYYY-MM-DD") customer_number: str = Field(default="", description="Kundennummer (BT-46), unter der der Kunde beim Verkäufer geführt wird (z.B. der exakte Wert nach 'Kunden-Nr:'). Falls im Text keine Kundennummer erkennbar ist: leer lassen.") leitweg_id: str = Field(default="", description="Leitweg-ID (BT-10) - NUR bei Rechnungen an Behörden (B2G), z.B. '04011000-1234-56'. Ist im Text keine Leitweg-ID erkennbar: leer lassen (NICHT die Kundennummer hier eintragen).") buyer_reference: str = Field(default="", description="Bestellnummer des KÄUFERS für diesen Auftrag (BT-13): der exakte Wert nach 'Ihr Auftrag:' o.ä. (z.B. 05.06.2026-Herr.Knospe). NICHT die Kundennummer (die gehört in customer_number) und NICHT unsere eigene Auftragsbestätigungsnummer (die gehört in order_id). Wenn im Text keine solche Käufer-Bestellnummer erkennbar ist: leer lassen.") recipient_name: str = Field(description="Vollständige Firma des Kunden/Empfängers (z.B. Stadtwerke Hennigsdorf GmbH)") recipient_street: str = Field(description="Straße und Hausnummer des Kunden") recipient_zip: str = Field(description="Postleitzahl (PLZ) des Kunden") recipient_city: str = Field(description="Stadt des Kunden") recipient_email: str = Field(description="E-Mail-Adresse des Kunden (LEER lassen, wenn nicht explizit im Text!)") vat_rate_percent: float = Field(description="Der primäre Umsatzsteuersatz in Prozent (z.B. 19.0)") delivery_note_id: str = Field(default="", description="Gefundene Lieferscheinnummer (z.B. LS26-035) VOR Position 1. Sonst leer lassen.") order_id: str = Field(default="", description="Gefundene EIGENE Auftragsbestätigungs-/Verkaufsauftragsnummer (BT-14, z.B. AB26-080) VOR Position 1. Das ist unsere eigene Nummer, NICHT die Bestellnummer des Käufers. Sonst leer lassen.") contract_id: str = Field(default="", description="Vertragsnummer (BT-12) VOR Position 1 - NUR ein echter Vertrag. NICHT ein Vergabeangebot/eine Ausschreibung (die gehören in tender_id) und NICHT ein 'Angebot' (z.B. 'ANGEBOT AN26-174 vom ...' - das wird separat als Freitext behandelt, hier KEIN Wert eintragen). Sonst leer lassen.") tender_id: str = Field(default="", description="Nummer eines Vergabeangebots oder einer Ausschreibung (BT-17, z.B. AN26-130) VOR Position 1. NICHT die Vertragsnummer (die gehört in contract_id) und NICHT ein bloßes 'Angebot ... vom ...' (das ist KEIN Vergabeangebot). Sonst leer lassen.") service_start_date: str = Field(default="", description="Startdatum eines explizit genannten Einsatz- oder Leistungszeitraums VOR Position 1 im Format YYYY-MM-DD. Sonst leer.") service_end_date: str = Field(default="", description="Enddatum eines explizit genannten Einsatz- oder Leistungszeitraums VOR Position 1 im Format YYYY-MM-DD. Sonst leer.") payment_term_days: str = Field(default="", description="Das Zahlungsziel in TAGEN als reine Zahl (z.B. '14' oder '30'). Suche im Text nach Formulierungen wie 'zahlbar innerhalb X Tagen', 'Zahlungsziel X Tage', 'X Tage netto'. Steht stattdessen ein konkretes Fälligkeitsdatum (z.B. 'zahlbar bis 31.07.2026'), berechne die Anzahl Tage zwischen Rechnungsdatum und diesem Datum und gib NUR diese Zahl zurück. Wenn im Text KEIN Zahlungsziel und KEIN Fälligkeitsdatum erkennbar ist: leer lassen (NICHT raten, NICHT 14 annehmen).") invoice_note: str = Field(default="", description="Sämtlicher unstrukturierter Fließtext, Beschreibungen, Abnahmekriterien (Acceptance Criteria) oder Projektklauseln, die sich VOR Position 1 befinden. Vollständig mit Zeilenumbrüchen sichern.") # ========================================== # 3. MATHEMATISCHE & PARSING-FUNKTIONEN # ========================================== # Zentrale Geschäftskonstanten - hier zentral pflegen statt an mehreren Stellen im Code. VERKAEUFER_IBAN = "DE06120700240456207000" # Verkäufer-Stammdaten (SCITECH). Werte bleiben fest hinterlegt, sind aber an EINER Stelle # gepflegt und werden in beiden XML-Generatoren sowie in der UI referenziert - so bleibt bei # einer Adress-/Kontaktänderung nichts inkonsistent zurück. VERKAEUFER = { "name": "scitech it solutions GmbH", # BT-27 (Firmenname) "email": "office@scitech-gmbh.de", # BT-34 (elektronische Adresse) / Kontakt "phone": "+49 3302 4940537", # BT-42 (Kontakttelefon) "contact_name": "Geschäftsführung", # BG-6 (Ansprechpartner) "street": "Hermann-Schumann-Str. 4", # BT-35 "zip": "16761", # BT-38 "city": "Hennigsdorf", # BT-37 "country": "DE", # BT-40 "vat_id": "DE218436692", # BT-31 (Umsatzsteuer-ID) "register_id": "HRB 6058 NP", # BT-30 (Handelsregisternummer, NICHT die USt-ID) } # UN/EDIFACT 4461 Zahlungsmittelcodes (BT-81), Auswahl der fuer B2B/B2G relevanten Codes. # "58" bleibt der Standardwert (SEPA-Ueberweisung), da alle bisherigen Rechnungen diesen nutzen. PAYMENT_MEANS_CODES = { "58": "SEPA-Überweisung", "30": "Überweisung (allgemein)", "31": "SEPA-Lastschrift", "42": "Einzahlung auf Bankkonto", "48": "Bankkarte (Kredit-/Debitkarte)", } # Schema-Codes fuer die elektronische Adresse des Kaeufers (BT-49-1, Code-Liste EAS). "EM" (E-Mail) # ist der Standard fuer den B2B-Versand per Mail; die weiteren gelten v.a. beim Peppol-Versand an # Behoerden (B2G). Die Leitweg-ID hat ihr eigentliches "Zuhause" in BT-10, kann als elektronische # Adresse aber ueber Schema 0204 zugestellt werden. BUYER_ENDPOINT_SCHEMES = { "EM": "E-Mail-Adresse (Standard, B2B)", "0204": "Leitweg-ID (deutsche Behörden / B2G)", "0088": "GLN (Global Location Number)", } def resolve_buyer_endpoint(recipient_email, recipient_scheme, leitweg_id): """Ermittelt die effektive elektronische Adresse des Kaeufers (BT-49) samt Schema (BT-49-1). Zuerst wird der Wert bestimmt: das BT-49-Feld hat Vorrang; ist es leer, wird die Leitweg-ID (BT-10) genommen (so muss sie nur einmal eingegeben werden). Dann das Schema: 1. Enthaelt der Wert ein '@', ist es immer eine E-Mail -> Schema 'EM' (egal aus welchem Feld er stammt und unabhaengig vom Dropdown - ein falsch gewaehltes Schema schlaegt nicht durch). 2. Stammt der Wert aus der Leitweg-ID (BT-49-Feld war leer) -> Schema '0204'. 3. Sonst (Wert direkt in BT-49 eingetragen, ohne '@', z.B. GLN) -> das gewaehlte Schema. Ist beides leer, bleibt die Adresse leer (Schema unveraendert). Gibt (adresse, schema) zurueck.""" email = str(recipient_email or "").strip() if email: value, from_leitweg = email, False else: value, from_leitweg = str(leitweg_id or "").strip(), True if not value: return "", recipient_scheme if "@" in value: return value, "EM" if from_leitweg: return value, "0204" return value, recipient_scheme # Fester Hinweistext fuer Vorkasse. Vorkasse ist eine Zahlungs*bedingung* (BT-20), kein # Zahlungs*mittel* - das Zahlungsmittel bleibt die Ueberweisung. Der Text wird daher zu den # Zahlungsbedingungen (BT-20) gelegt, nicht in den Zahlungsmittelcode (BT-81). VORKASSE_HINWEIS = "Zahlung erfolgt per Vorkasse vor Lieferung." def berechne_faelligkeit(invoice_date_str, term_days): """Fälligkeitsdatum = Rechnungsdatum + term_days. Gibt 'YYYY-MM-DD' zurück, oder leer, wenn Rechnungsdatum unlesbar oder kein gültiges Zahlungsziel angegeben ist. KEIN fester Fallback - fehlt das Zahlungsziel, bleibt das Feld leer und die UI blockiert den Export.""" base = parse_to_strict_xml_date(invoice_date_str) if not base: return "" try: days = int(term_days) except (ValueError, TypeError): return "" if days < 0: return "" try: base_date = datetime.strptime(base, "%Y-%m-%d") except (ValueError, TypeError): return "" return (base_date + timedelta(days=days)).strftime("%Y-%m-%d") def format_skonto_text(skonto_percent, skonto_days, invoice_date_str): """Baut den Freitext fuer eine Skonto-Vereinbarung. EN16931 kennt kein eigenes strukturiertes Skonto-Feld - der Text wird daher als cbc:Note (UBL) bzw. ram:Description (CII) neben dem regulaeren Faelligkeitsdatum in den Zahlungsbedingungen (BT-20) abgelegt. Gibt "" zurueck, wenn kein gueltiges Skonto vereinbart ist (KEIN Rateraten bei fehlenden Angaben).""" try: percent = float(skonto_percent) except (TypeError, ValueError): return "" if percent <= 0: return "" try: days = int(skonto_days) except (TypeError, ValueError): return "" if days <= 0: return "" percent_str = f"{percent:.2f}".rstrip("0").rstrip(".") skonto_date = berechne_faelligkeit(invoice_date_str, days) if skonto_date: return f"Bei Zahlung bis {skonto_date} gewähren wir {percent_str}% Skonto." return f"Bei Zahlung innerhalb von {days} Tagen gewähren wir {percent_str}% Skonto." def build_payment_terms_note(data): """Kombinierter Freitext fuer die Zahlungsbedingungen (BT-20): optionaler Vorkasse-Hinweis und/oder eine Skonto-Vereinbarung. Beides sind Zahlungsbedingungen, fuer die EN16931 kein eigenes strukturiertes Feld kennt. Gibt "" zurueck, wenn keiner von beiden gesetzt ist.""" parts = [] if data.get("vorkasse"): parts.append(VORKASSE_HINWEIS) skonto_text = format_skonto_text( data.get("skonto_percent"), data.get("skonto_days"), data["invoice_date"] ) if skonto_text: parts.append(skonto_text) return " ".join(parts) ANGEBOT_PATTERN = re.compile( r"\bAngebot\s+([A-Za-z0-9][A-Za-z0-9\-/.]*)\s+vom\s+(\d{1,2}[.\-/]\d{1,2}[.\-/]\d{2,4})", re.IGNORECASE, ) def extract_angebot_reference(text): """Findet einen Angebotsbezug der Form 'Angebot vom ' im Rechnungstext. EN16931 kennt kein eigenes Feld fuer einen Angebotsbezug, daher wird der Fund als Freitext (BT-22) uebernommen. Gibt den normalisierten Text zurueck oder "" wenn nichts passt.""" m = ANGEBOT_PATTERN.search(text) if not m: return "" return f"Angebot {m.group(1)} vom {m.group(2)}" # Referenzzeilen, die ein eigenes EN16931-Feld haben und daher NICHT in den Vorlauf-Freitext # gehoeren (Auftragsbestaetigung BT-14, Angebot/Vergabeangebot, Lieferschein BT-16, Vertrag BT-12). _REFERENCE_LINE_PATTERN = re.compile( r"^(AUFTRAGSBESTÄTIGUNG|VERGABEANGEBOT|ANGEBOT|AUSSCHREIBUNG|LIEFERSCHEIN|VERTRAG|BESTELLUNG)\b", re.IGNORECASE, ) # Positionsnummer: alleinstehend "1" / "12" oder gegliedert "2.1". Markiert das Ende des Vorlaufs. _POSITION_NUMBER_PATTERN = re.compile(r"^\d{1,3}(?:\.\d{1,3})?$") def extract_pre_position_freetext(text): """Liest den beschreibenden Vorlauftext zwischen dem Tabellenkopf ('Pos.') und der ersten Position deterministisch aus dem PDF-Text - unabhaengig vom (oft unvollstaendigen) LLM. Alles zwischen der ersten 'Pos.'-Zeile und der ersten Positionsnummer wird uebernommen, ausser den Referenzzeilen (Auftragsbestaetigung/Angebot/Vergabeangebot/Lieferschein/Vertrag), die eigene Felder haben, und der zwischen Seiten wiederholten Moeblierung (Seitenkopf, Bank-/Register- zeilen, Uebertrag/Vortrag) - relevant bei mehrseitigen Rechnungen, deren erste 'Pos.'-Zeile auf Seite 1 steht, die erste echte Position aber erst nach einem Seitenumbruch. Gibt den Freitext zurueck ("" wenn keiner vorhanden) oder None, wenn die Tabellenstruktur nicht erkennbar ist (dann bleibt ein evtl. LLM-Freitext unveraendert).""" lines = [ln.strip() for ln in text.split("\n")] header_idx = next((i for i, ln in enumerate(lines) if ln == "Pos."), None) if header_idx is None: return None first_pos_idx = next( (i for i in range(header_idx + 1, len(lines)) if _POSITION_NUMBER_PATTERN.match(lines[i])), None, ) if first_pos_idx is None: return None # Leere Zeilen raus, dann die Seiten-Moeblierung strippen (Seitenkopf 'RECHNUNG'..'Betrag €', # Bank-/Registerzeilen, Uebertrag/Vortrag), zuletzt die Referenzzeilen mit eigenem Feld. region = [ln for ln in lines[header_idx + 1:first_pos_idx] if ln] region = _strip_bundle_furniture(region) freetext_lines = [ln for ln in region if not _REFERENCE_LINE_PATTERN.match(ln)] return "\n".join(freetext_lines) # Beleg-Referenzen aus den Referenzzeilen: Schluesselwort -> Feld. Zuverlaessiger als eine # LLM-Zuordnung, die z.B. ein Vergabeangebot faelschlich in BT-12 (Vertrag) statt BT-17 einsortiert. _DOC_REF_PATTERNS = { "order_id": re.compile(r"\bAUFTRAGSBESTÄTIGUNG\s+([A-Za-z0-9][A-Za-z0-9\-/.]*)", re.IGNORECASE), "delivery_note_id": re.compile(r"\bLIEFERSCHEIN\s+([A-Za-z0-9][A-Za-z0-9\-/.]*)", re.IGNORECASE), } # BT-17: Vergabeangebot. '\bVERGABEANGEBOT' greift dank Wortgrenze NICHT in ein blosses 'ANGEBOT'. _TENDER_VERGABE_PATTERN = re.compile(r"\bVERGABEANGEBOT\s+([A-Za-z0-9][A-Za-z0-9\-/.]*)", re.IGNORECASE) def extract_document_references(text): """Liest Beleg-Referenzen deterministisch aus den Referenzzeilen des PDF-Texts: Auftragsbestaetigung (BT-14), Lieferschein (BT-16) und - fuer BT-17 - Vergabeangebot ODER, falls keines vorhanden ist, ein einfaches Angebot ('Angebot vom '). Pro Rechnung kommt nur eines von beiden vor; beide gehoeren fachlich in BT-17. Gibt ein Dict nur mit den gefundenen Feldern zurueck, damit fehlende Felder nicht ueberschrieben werden.""" result = {} for field, pattern in _DOC_REF_PATTERNS.items(): m = pattern.search(text) if m: result[field] = m.group(1).strip() # BT-17: Vergabeangebot bevorzugt; sonst ein einfaches 'Angebot vom ' # (ANGEBOT_PATTERN erzwingt die 'vom '-Form und vermeidet so Fehltreffer auf # Fliesstext wie 'Angebot erstellen'). m_vergabe = _TENDER_VERGABE_PATTERN.search(text) if m_vergabe: result["tender_id"] = m_vergabe.group(1).strip() else: m_angebot = ANGEBOT_PATTERN.search(text) if m_angebot: result["tender_id"] = m_angebot.group(1).strip() return result def extract_labeled_header_value(text, label): """Liest einen Kopf-Feldwert deterministisch aus: findet die Zeile, die exakt