feat: extend regex parser for IT-Systemhaus service periods, contract types, and serial numbers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
dfuhrmann3 2026-07-07 15:16:30 +02:00
parent 0dd9916079
commit 3b3a2dce90
3 changed files with 137 additions and 4 deletions

View File

@ -140,7 +140,13 @@ def extract_invoice_items_pure_python(text):
items = [] items = []
lines = [line.strip() for line in text.split("\n") if line.strip()] 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}$") price_stop_pattern = re.compile(r"^[\d\.]+,[\d]{2}$")
for i, line in enumerate(lines): for i, line in enumerate(lines):
@ -190,10 +196,63 @@ def extract_invoice_items_pure_python(text):
def map_unece_unit_code(unit_str): def map_unece_unit_code(unit_str):
u = str(unit_str).lower() 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" 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" 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): def parse_to_strict_xml_date(date_str):
"""Normalisiert ein Datum auf 'YYYY-MM-DD'. Gibt leeren String zurück, wenn das Datum """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 fehlt ODER nicht geparst werden kann - NICHT still auf 'heute' fallen, damit fehlerhafte

18
main.py
View File

@ -24,6 +24,9 @@ from invoice_logic import (
add_pdfa_output_intent, add_pdfa_output_intent,
fix_xrechnung_xmp_conformance, fix_xrechnung_xmp_conformance,
compute_invoice_totals, compute_invoice_totals,
extract_service_period,
detect_contract_type,
extract_hardware_serial_numbers,
) )
# ========================================== # ==========================================
@ -177,6 +180,17 @@ with col2:
final_data["items"] = extract_invoice_items_pure_python(pdf_text) 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.invoice_counter += 1
st.session_state.extracted_data = final_data st.session_state.extracted_data = final_data
@ -184,6 +198,10 @@ with col2:
data = st.session_state.extracted_data 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.") 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Ü --- # --- BT-3 RECHNUNGSTYP DROPDOWN-MENÜ ---
inv_type_ui = st.selectbox( inv_type_ui = st.selectbox(

View File

@ -15,6 +15,9 @@ from invoice_logic import (
aggregate_tax_subtotals, aggregate_tax_subtotals,
compute_invoice_totals, compute_invoice_totals,
format_skonto_text, 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" 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(): def test_parse_to_strict_xml_date_german_format():
assert parse_to_strict_xml_date("15.01.2026") == "2026-01-15" 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"] 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) total_alloc = sum(float(a.find("cbc:Amount", ns).text) for a in allowances)
assert abs(total_alloc - 20.0) < 0.001 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.") == []