diff --git a/invoice_logic.py b/invoice_logic.py index 46c3058..960992c 100644 --- a/invoice_logic.py +++ b/invoice_logic.py @@ -140,7 +140,13 @@ def extract_invoice_items_pure_python(text): items = [] lines = [line.strip() for line in text.split("\n") if line.strip()] - unit_pattern = re.compile(r"^(\d+(?:[\.,]\d+)?)\s*(Tag\(e\)|Std\.|Stk\.|Stunden|Stück|stk|stck|stück)$", re.IGNORECASE) + # IT-Systemhaus-Erweiterung: zusaetzlich Monats-/Lizenz-/Personentag-Abrechnungseinheiten, + # wie sie bei Managed-Services-, Cloud-Abo- und Projekthonorar-Rechnungen ueblich sind. + unit_pattern = re.compile( + r"^(\d+(?:[\.,]\d+)?)\s*(Tag\(e\)|Std\.|Stk\.|Stunden|Stück|stk|stck|stück|" + r"Monat\(e\)|Monate|Personentage|Personentag|PT|Lizenz\(en\)|Lizenzen|Lizenz|Wartungspauschale)$", + re.IGNORECASE, + ) price_stop_pattern = re.compile(r"^[\d\.]+,[\d]{2}$") for i, line in enumerate(lines): @@ -190,10 +196,63 @@ def extract_invoice_items_pure_python(text): def map_unece_unit_code(unit_str): u = str(unit_str).lower() + if "monat" in u: return "MON" + if "personentag" in u or u == "pt": return "DAY" if "tag" in u or "day" in u: return "DAY" - elif "std" in u or "stund" in u or "hour" in u or "hur" in u: return "HUR" + if "std" in u or "stund" in u or "hour" in u or "hur" in u: return "HUR" return "C62" +SERVICE_PERIOD_PATTERN = re.compile( + r"(?:Leistungszeitraum|Zeitraum|Einsatzzeitraum)\s*:?\s*" + r"(?:vom\s+)?(\d{1,2}[.\-]\d{1,2}[.\-]\d{4})\s*(?:bis|-|–)\s*(\d{1,2}[.\-]\d{1,2}[.\-]\d{4})", + re.IGNORECASE, +) + + +def extract_service_period(text): + """Regex-Fallback fuer den Leistungszeitraum (z.B. 'Leistungszeitraum: 01.03.2026 bis + 31.03.2026'), falls das LLM diesen nicht extrahiert hat. Gibt (start, end) als YYYY-MM-DD + zurueck, oder ("", "") wenn nichts gefunden wurde.""" + match = SERVICE_PERIOD_PATTERN.search(text) + if not match: + return "", "" + start = parse_to_strict_xml_date(match.group(1).replace("-", ".")) + end = parse_to_strict_xml_date(match.group(2).replace("-", ".")) + return start, end + + +CONTRACT_TYPE_KEYWORDS = { + "Managed Services": re.compile(r"managed\s+services?", re.IGNORECASE), + "Cloud-Abonnement": re.compile(r"cloud[\s\-]?(abo(nnement)?|subscription)", re.IGNORECASE), + "Projekthonorar": re.compile(r"projekt(honorar|vergütung|pauschale)", re.IGNORECASE), +} + + +def detect_contract_type(text): + """Erkennt den IT-Systemhaus-Vertragstyp anhand von Schluesselwoertern im Rechnungstext. + Gibt das erste gefundene Label zurueck oder "", wenn keines passt (keine Vermutung).""" + for label, pattern in CONTRACT_TYPE_KEYWORDS.items(): + if pattern.search(text): + return label + return "" + + +SERIAL_NUMBER_PATTERN = re.compile( + r"(?:S/?N|Seriennummer|Serial(?:\s*No\.?)?)\s*:?\s*([A-Z0-9][A-Z0-9\-]{4,})", + re.IGNORECASE, +) + + +def extract_hardware_serial_numbers(text): + """Findet Hardware-Seriennummern im Rechnungstext (Muster 'S/N: ...', 'Seriennummer: ...'). + Gibt eine Liste der gefundenen Seriennummern in Text-Reihenfolge zurueck, ohne Duplikate.""" + found = [] + for match in SERIAL_NUMBER_PATTERN.finditer(text): + serial = match.group(1).strip() + if serial not in found: + found.append(serial) + return found + def parse_to_strict_xml_date(date_str): """Normalisiert ein Datum auf 'YYYY-MM-DD'. Gibt leeren String zurück, wenn das Datum fehlt ODER nicht geparst werden kann - NICHT still auf 'heute' fallen, damit fehlerhafte diff --git a/main.py b/main.py index e522aed..47de350 100644 --- a/main.py +++ b/main.py @@ -24,6 +24,9 @@ from invoice_logic import ( add_pdfa_output_intent, fix_xrechnung_xmp_conformance, compute_invoice_totals, + extract_service_period, + detect_contract_type, + extract_hardware_serial_numbers, ) # ========================================== @@ -174,9 +177,20 @@ with col2: if final_data.get("recipient_email") == VERKAEUFER["email"]: final_data["recipient_email"] = "" - + final_data["items"] = extract_invoice_items_pure_python(pdf_text) - + + # IT-Systemhaus-Regex-Fallbacks: nur ergaenzen, wenn das LLM die entsprechenden + # Felder nicht bereits geliefert hat. + if not final_data.get("service_start_date") and not final_data.get("service_end_date"): + fallback_start, fallback_end = extract_service_period(pdf_text) + if fallback_start and fallback_end: + final_data["service_start_date"] = fallback_start + final_data["service_end_date"] = fallback_end + + final_data["contract_type"] = detect_contract_type(pdf_text) + final_data["hardware_serials"] = extract_hardware_serial_numbers(pdf_text) + st.session_state.invoice_counter += 1 st.session_state.extracted_data = final_data @@ -184,6 +198,10 @@ with col2: data = st.session_state.extracted_data st.caption("Mit :red[*] markierte Felder sind EN16931-Pflichtfelder und müssen für einen gültigen Export befüllt sein.") + if data.get("contract_type"): + st.caption(f"🏷️ Erkannter Vertragstyp: **{data['contract_type']}**") + if data.get("hardware_serials"): + st.caption(f"🔧 Erkannte Seriennummern: {', '.join(data['hardware_serials'])}") # --- BT-3 RECHNUNGSTYP DROPDOWN-MENÜ --- inv_type_ui = st.selectbox( diff --git a/tests/test_invoice_logic.py b/tests/test_invoice_logic.py index b4b60db..eb76057 100644 --- a/tests/test_invoice_logic.py +++ b/tests/test_invoice_logic.py @@ -15,6 +15,9 @@ from invoice_logic import ( aggregate_tax_subtotals, compute_invoice_totals, format_skonto_text, + extract_service_period, + detect_contract_type, + extract_hardware_serial_numbers, ) @@ -96,6 +99,25 @@ def test_map_unece_unit_code_hours(): assert map_unece_unit_code("Std.") == "HUR" +def test_extract_invoice_items_pure_python_recognizes_person_days(): + text = "1\nProjektarbeit\n5,00 Personentage\n800,00\n4000,00\n" + items = extract_invoice_items_pure_python(text) + assert items[0]["quantity"] == 5.0 + assert items[0]["unit"] == "Personentage" + + +def test_extract_invoice_items_pure_python_recognizes_license_months(): + text = "1\nCloud-Lizenz\n3,00 Monat(e)\n49,00\n147,00\n" + items = extract_invoice_items_pure_python(text) + assert items[0]["unit"] == "Monat(e)" + + +def test_map_unece_unit_code_extended_units(): + assert map_unece_unit_code("Monat(e)") == "MON" + assert map_unece_unit_code("Personentage") == "DAY" + assert map_unece_unit_code("Lizenz(en)") == "C62" + + def test_parse_to_strict_xml_date_german_format(): assert parse_to_strict_xml_date("15.01.2026") == "2026-01-15" @@ -304,3 +326,37 @@ def test_generate_compliant_xrechnung_mixed_rate_discount_emits_allowance_per_ra assert percents == ["19.00", "7.00"] or percents == ["7.00", "19.00"] total_alloc = sum(float(a.find("cbc:Amount", ns).text) for a in allowances) assert abs(total_alloc - 20.0) < 0.001 + + +def test_extract_service_period_finds_range(): + text = "Managed Services\nLeistungszeitraum: 01.03.2026 bis 31.03.2026\n1\n..." + start, end = extract_service_period(text) + assert start == "2026-03-01" + assert end == "2026-03-31" + + +def test_extract_service_period_returns_empty_when_absent(): + assert extract_service_period("Keine Zeitangabe hier.") == ("", "") + + +def test_detect_contract_type_managed_services(): + assert detect_contract_type("Wartungsvertrag Managed Services Tier 2") == "Managed Services" + + +def test_detect_contract_type_cloud_abo(): + assert detect_contract_type("Ihr Cloud-Abonnement für 2026") == "Cloud-Abonnement" + + +def test_detect_contract_type_returns_empty_when_no_match(): + assert detect_contract_type("Ganz normale Rechnung ohne Vertragstyp") == "" + + +def test_extract_hardware_serial_numbers_finds_sn_pattern(): + text = "Server Dell R750\nS/N: ABCD-1234-XYZ\nSwitch Cisco\nSeriennummer: SN9988771" + serials = extract_hardware_serial_numbers(text) + assert "ABCD-1234-XYZ" in serials + assert "SN9988771" in serials + + +def test_extract_hardware_serial_numbers_empty_when_absent(): + assert extract_hardware_serial_numbers("Keine Seriennummern hier.") == []