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:
parent
0dd9916079
commit
3b3a2dce90
|
|
@ -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
|
||||
|
|
|
|||
22
main.py
22
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(
|
||||
|
|
|
|||
|
|
@ -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.") == []
|
||||
|
|
|
|||
Loading…
Reference in New Issue