Stapelbetrieb: Ordner einlesen, per Ampel prüfen, gesammelt exportieren.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
619159cd33
commit
8e44e64930
62
README.md
62
README.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
Dieses Tool konvertiert Rechnungen im PDF-Format über eine Weboberfläche in die offiziellen E-Rechnungsformate XRechnung (UBL 3.0) und ZUGFeRD 2.4 (hybride PDF mit eingebettetem CII-XML). Die Datenextraktion erfolgt über ein LLM, das intern im Firmennetzwerk läuft – sensible Finanzdaten verlassen das lokale Netzwerk nicht.
|
Dieses Tool konvertiert Rechnungen im PDF-Format über eine Weboberfläche in die offiziellen E-Rechnungsformate XRechnung (UBL) und ZUGFeRD 2.4 (hybride PDF mit eingebettetem CII-XML). Die Datenextraktion ist vollständig deterministisch (Regex und Zeilen-Auswertung) – es wird kein LLM und kein externer Dienst verwendet, sensible Finanzdaten verlassen das lokale Netzwerk nicht.
|
||||||
|
|
||||||
## Zugriff
|
## Zugriff
|
||||||
|
|
||||||
|
|
@ -12,10 +12,13 @@ http://erechnung
|
||||||
|
|
||||||
## Aufbau
|
## Aufbau
|
||||||
|
|
||||||
Das System besteht aus zwei Komponenten:
|
| Datei | Zweck |
|
||||||
|
|---|---|
|
||||||
- **Web-Oberfläche** (`main.py`, Streamlit): PDF-Upload, Datenvalidierung, Export
|
| `main.py` | Streamlit-Oberfläche für die Einzelrechnung, Export |
|
||||||
- **vLLM-Backend**: separat betriebener Inferenz-Server (Standard: `gemma431b`), der die strukturierten Metadaten aus dem Rechnungstext extrahiert
|
| `invoice_logic.py` | Extraktion, Berechnung, XML-Erzeugung (UBL und CII), PDF-Helfer |
|
||||||
|
| `batch_folder.py` | Ordner-Anbindung und Ampel-Bewertung des Stapelbetriebs (ohne Streamlit) |
|
||||||
|
| `batch_mode.py` | Oberfläche des Stapelbetriebs |
|
||||||
|
| `intro_overlay.py` | Intro-Video beim ersten Besuch |
|
||||||
|
|
||||||
## Installation (Docker)
|
## Installation (Docker)
|
||||||
|
|
||||||
|
|
@ -26,17 +29,20 @@ git clone https://gitea.scitech-gmbh.de:8443/dfuhrmann/E-Rechnung.git
|
||||||
cd E-Rechnung
|
cd E-Rechnung
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Umgebungsvariablen prüfen
|
### 2. Austauschordner einbinden (nur für den Stapelbetrieb)
|
||||||
|
|
||||||
In `docker-compose.yml` sind das vLLM-Backend und Modell hinterlegt:
|
Der Stapelbetrieb liest Rechnungen aus einem Ordner und schreibt die fertigen E-Rechnungen zurück. Dafür muss die Netzwerkfreigabe dem Container zugänglich gemacht werden – ein Windows-Pfad wie `\\FS01\Daten\Technik\AI` lässt sich **nicht** in der Oberfläche eintragen, sondern wird in `docker-compose.yml` gemountet:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
environment:
|
volumes:
|
||||||
- VLLM_URL=http://10.192.0.140:8002
|
- /mnt/fs01/Daten/Technik/AI:/data
|
||||||
- LLM_MODEL=gemma431b
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Bei Bedarf hier anpassen (z.B. anderes Modell oder Backend-Host).
|
Wichtig: Binden Sie den **übergeordneten** Ordner ein, nicht einen einzelnen Unterordner. Alle Unterordner darunter sind dann in der Oberfläche frei wählbar, ohne dass diese Datei erneut angefasst werden muss.
|
||||||
|
|
||||||
|
Alternativ kann Docker die Freigabe selbst per CIFS einhängen – der entsprechende Block steht auskommentiert am Ende von `docker-compose.yml`. Das benötigte Konto braucht Lese- **und** Schreibrechte.
|
||||||
|
|
||||||
|
Wird kein Ordner eingebunden, funktioniert die Einzelrechnung uneingeschränkt; der Stapelbetrieb weist dann auf den fehlenden Mount hin.
|
||||||
|
|
||||||
### 3. Container bauen und starten
|
### 3. Container bauen und starten
|
||||||
|
|
||||||
|
|
@ -50,16 +56,36 @@ Die Anwendung ist danach unter `http://localhost:8040` (bzw. im Firmennetz unter
|
||||||
|
|
||||||
## Use
|
## Use
|
||||||
|
|
||||||
Der Konversionsprozess gliedert sich in folgende Schritte:
|
Die Betriebsart wird links in der Seitenleiste gewählt.
|
||||||
|
|
||||||
|
### Einzelrechnung
|
||||||
|
|
||||||
1. **Dokumenten-Upload:** Laden Sie die Rechnungs-PDF über die Weboberfläche hoch. Die PDF wird direkt zur Kontrolle angezeigt.
|
1. **Dokumenten-Upload:** Laden Sie die Rechnungs-PDF über die Weboberfläche hoch. Die PDF wird direkt zur Kontrolle angezeigt.
|
||||||
2. **Extraktion:** Starten Sie die Analyse über den Button "Daten extrahieren".
|
2. **Leistungsnachweise (optional):** Bis zu drei weitere PDFs anhängen. Ihre Seiten werden beim ZUGFeRD-Export ans Ende der Rechnung gehängt, sodass eine einzelne PDF entsteht. Aus diesen Dateien werden keine Daten ausgelesen.
|
||||||
3. **Validierung:** Prüfen Sie die extrahierten Kopfdaten und die Positionstabelle in der Benutzeroberfläche. Da alle Felder editierbar sind, können Korrekturen direkt vorgenommen werden. Pflichtfelder sind mit :red[*] markiert; der Export wird blockiert, solange Pflichtangaben fehlen.
|
3. **Extraktion:** Starten Sie die Analyse über den Button "Daten extrahieren".
|
||||||
4. **Export:** Wählen Sie das gewünschte Format (XRechnung-XML oder ZUGFeRD-PDF) und starten Sie den Download.
|
4. **Validierung:** Prüfen Sie die extrahierten Kopfdaten und die Positionstabelle. Alle Felder sind editierbar. Pflichtfelder sind mit :red[*] markiert; der Export wird blockiert, solange Pflichtangaben fehlen.
|
||||||
|
5. **Export:** Wählen Sie das Format (XRechnung-XML oder ZUGFeRD-PDF) und starten Sie den Download.
|
||||||
|
|
||||||
|
### Stapel (Ordner)
|
||||||
|
|
||||||
|
1. **Ordner wählen:** Eingangsordner (von DATEV befüllt) und Ausgabeordner aus den Unterordnern des eingebundenen Verzeichnisses auswählen.
|
||||||
|
2. **Einlesen:** Alle PDFs des Eingangsordners werden analysiert und bewertet.
|
||||||
|
3. **Prüfen:** Die Übersicht zeigt je Rechnung eine Ampel. ✅ bedeutet, dass alle Pflichtangaben vorliegen **und** die Summe der erkannten Positionen zur ausgewiesenen Bemessungsgrundlage passt. ⚠️ markierte Rechnungen nennen im Klartext, was fehlt; sie werden übersprungen und lassen sich im Einzelmodus korrigieren.
|
||||||
|
4. **Nachweise zuordnen (optional):** Liegt ein Leistungsnachweis im selben Ordner, ordnen Sie ihn der zugehörigen Rechnung zu. Er zählt dann nicht mehr als eigene Rechnung.
|
||||||
|
5. **Exportieren:** Alle grünen Rechnungen werden in einem Zug konvertiert und in den Ausgabeordner geschrieben. Die Quelldateien wandern in den Unterordner `verarbeitet` – gelöscht wird nichts.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
### Human-in-the-Loop
|
### Kontrolle durch den Sachbearbeiter
|
||||||
Das Tool dient als Automations-Assistent. Vor dem finalen Export ist eine visuelle Endkontrolle der extrahierten Daten (insbesondere Rechnungsnummer und Empfängeradresse) durch den Sachbearbeiter zwingend erforderlich, um KI-Fehlinterpretationen auszuschließen.
|
|
||||||
|
Das Tool ist ein Automations-Assistent, kein Ersatz für die fachliche Prüfung.
|
||||||
|
|
||||||
|
Im **Einzelmodus** liegen alle extrahierten Werte zur Sichtprüfung und Korrektur offen; der Export wird blockiert, solange Pflichtangaben fehlen.
|
||||||
|
|
||||||
|
Im **Stapelmodus** tritt an die Stelle der Sichtprüfung jeder einzelnen Rechnung eine Sammelfreigabe: Es werden nur Rechnungen exportiert, bei denen alle Pflichtfelder belegt sind und die Summe der erkannten Positionen exakt der auf der Rechnung ausgewiesenen Bemessungsgrundlage entspricht. Dieser Summen-Abgleich ist das entscheidende Sicherheitsnetz – er deckt eine unvollständige Positionserkennung zuverlässig auf. Alles Auffällige wird markiert und übersprungen, nie stillschweigend exportiert.
|
||||||
|
|
||||||
|
Die Verantwortung für die inhaltliche Richtigkeit der Rechnung bleibt in beiden Betriebsarten beim Sachbearbeiter.
|
||||||
|
|
||||||
### GoBD-Konformität
|
### GoBD-Konformität
|
||||||
Das Tool generiert ausschließlich das strukturierte XML- bzw. hybride PDF-Format. Für die Einhaltung der GoBD-Richtlinien (u. a. die revisionssichere Archivierung der Original-PDF sowie der generierten E-Rechnung über den gesetzlichen Zeitraum von 10 Jahren) ist der Betreiber der Software selbst verantwortlich.
|
|
||||||
|
Das Tool generiert ausschließlich das strukturierte XML- bzw. hybride PDF-Format. Für die Einhaltung der GoBD-Richtlinien (u. a. die revisionssichere Archivierung der Original-PDF sowie der generierten E-Rechnung über den gesetzlichen Zeitraum von 10 Jahren) ist der Betreiber der Software selbst verantwortlich. Der Stapelbetrieb löscht keine Quelldateien, sondern verschiebt sie nach `verarbeitet`.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,236 @@
|
||||||
|
"""Ordner-Anbindung fuer den Stapelbetrieb.
|
||||||
|
|
||||||
|
Bewusst OHNE Streamlit-Abhaengigkeit: die Pfad-, Auswahl- und Bewertungslogik ist damit
|
||||||
|
vollstaendig testbar, auch dort wo die App selbst nicht startbar ist.
|
||||||
|
|
||||||
|
Die App laeuft im Container und kann Windows-UNC-Pfade (\\\\FS01\\...) nicht verwenden. Die
|
||||||
|
Netzwerkfreigabe wird stattdessen EINMALIG als Volume unter BATCH_ROOT_DIR gemountet; alles
|
||||||
|
darunter ist zur Laufzeit frei waehlbar. Jede Pfadoperation prueft, dass ihr Ziel innerhalb
|
||||||
|
dieser Wurzel liegt - ein im UI gewaehlter Ordnername darf nicht aus dem Mount ausbrechen.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import fitz
|
||||||
|
|
||||||
|
from invoice_logic import (
|
||||||
|
VERKAEUFER,
|
||||||
|
VERKAEUFER_IBAN,
|
||||||
|
collect_blockers,
|
||||||
|
compute_invoice_totals,
|
||||||
|
detect_invoice_type_code,
|
||||||
|
extract_document_references,
|
||||||
|
extract_header_metadata_pure_python,
|
||||||
|
extract_invoice_items,
|
||||||
|
extract_labeled_header_value,
|
||||||
|
extract_pre_position_freetext,
|
||||||
|
extract_referenznummer,
|
||||||
|
extract_service_period,
|
||||||
|
extract_stated_tax_basis,
|
||||||
|
kaufmaennisches_runden,
|
||||||
|
resolve_buyer_endpoint,
|
||||||
|
sums_match,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wurzel des gemounteten Austauschverzeichnisses. Alles darunter ist im UI waehlbar.
|
||||||
|
BATCH_ROOT_DIR = os.environ.get("BATCH_ROOT_DIR", "/data")
|
||||||
|
|
||||||
|
# Unterordner, in den verarbeitete Quell-PDFs wandern. Sie werden NICHT geloescht - die
|
||||||
|
# Originale sind GoBD-relevant und muessen aufbewahrt werden.
|
||||||
|
PROCESSED_SUBDIR = "verarbeitet"
|
||||||
|
|
||||||
|
|
||||||
|
def _root(root=None) -> Path:
|
||||||
|
return Path(root if root is not None else BATCH_ROOT_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_within_root(relative_path, root=None) -> Path:
|
||||||
|
"""Loest einen (relativen) Pfad gegen die Wurzel auf und stellt sicher, dass das Ergebnis
|
||||||
|
INNERHALB der Wurzel liegt. Wirft ValueError bei einem Ausbruchsversuch ('../' oder ein
|
||||||
|
absoluter Pfad ausserhalb) - der Ordnername kommt aus dem UI und ist damit Eingabe, der
|
||||||
|
nicht blind vertraut werden darf."""
|
||||||
|
base = _root(root).resolve()
|
||||||
|
target = (base / Path(relative_path)).resolve()
|
||||||
|
if target != base and base not in target.parents:
|
||||||
|
raise ValueError(
|
||||||
|
f"Pfad '{relative_path}' liegt ausserhalb des freigegebenen Verzeichnisses '{base}'."
|
||||||
|
)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def list_subfolders(root=None) -> list:
|
||||||
|
"""Namen der direkten Unterordner der Wurzel, alphabetisch - die Auswahlliste im UI.
|
||||||
|
Existiert die Wurzel nicht (Mount fehlt), kommt eine leere Liste zurueck."""
|
||||||
|
base = _root(root)
|
||||||
|
if not base.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted(p.name for p in base.iterdir() if p.is_dir())
|
||||||
|
|
||||||
|
|
||||||
|
def list_input_pdfs(relative_dir, root=None) -> list:
|
||||||
|
"""Alle PDFs eines Eingangsordners, alphabetisch. Der Unterordner mit den bereits
|
||||||
|
verarbeiteten Dateien wird ausgelassen, damit ein Stapel nicht doppelt laeuft."""
|
||||||
|
directory = resolve_within_root(relative_dir, root)
|
||||||
|
if not directory.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted(
|
||||||
|
(p for p in directory.iterdir() if p.is_file() and p.suffix.lower() == ".pdf"),
|
||||||
|
key=lambda p: p.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _free_path(directory: Path, filename: str) -> Path:
|
||||||
|
"""Ein noch freier Dateiname in directory: haengt _2, _3 ... an, statt eine vorhandene
|
||||||
|
Datei zu ueberschreiben. Ein frueherer Export darf nicht still verlorengehen."""
|
||||||
|
candidate = directory / filename
|
||||||
|
if not candidate.exists():
|
||||||
|
return candidate
|
||||||
|
stem, suffix = Path(filename).stem, Path(filename).suffix
|
||||||
|
counter = 2
|
||||||
|
while (directory / f"{stem}_{counter}{suffix}").exists():
|
||||||
|
counter += 1
|
||||||
|
return directory / f"{stem}_{counter}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def write_output(relative_dir, filename, data: bytes, root=None) -> Path:
|
||||||
|
"""Schreibt eine fertige Export-Datei in den Ausgabeordner und gibt den tatsaechlich
|
||||||
|
verwendeten Pfad zurueck (kann bei Namenskollision abweichen). Der Ordner wird bei Bedarf
|
||||||
|
angelegt."""
|
||||||
|
directory = resolve_within_root(relative_dir, root)
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
target = _free_path(directory, filename)
|
||||||
|
target.write_bytes(data)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def move_to_processed(pdf_path, root=None) -> Path:
|
||||||
|
"""Verschiebt eine verarbeitete Quell-PDF in den Unterordner 'verarbeitet' und gibt das
|
||||||
|
Ziel zurueck. Bei Namenskollision wird angehaengt statt ueberschrieben."""
|
||||||
|
source = Path(pdf_path).resolve()
|
||||||
|
base = _root(root).resolve()
|
||||||
|
if base not in source.parents:
|
||||||
|
raise ValueError(
|
||||||
|
f"Datei '{source}' liegt ausserhalb des freigegebenen Verzeichnisses '{base}'."
|
||||||
|
)
|
||||||
|
processed_dir = source.parent / PROCESSED_SUBDIR
|
||||||
|
processed_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
target = _free_path(processed_dir, source.name)
|
||||||
|
shutil.move(str(source), str(target))
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def pdf_text_of(pdf_bytes: bytes) -> str:
|
||||||
|
"""Extrahiert den Text einer PDF - identisch zum Einzelmodus (main.py)."""
|
||||||
|
text = ""
|
||||||
|
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
|
||||||
|
try:
|
||||||
|
for page in doc:
|
||||||
|
text += page.get_text("text") + "\n"
|
||||||
|
finally:
|
||||||
|
doc.close()
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def analyse_pdf(pdf_bytes: bytes) -> dict:
|
||||||
|
"""Fuehrt fuer eine Rechnungs-PDF dieselbe Extraktion aus wie der Einzelmodus und liefert
|
||||||
|
ein exportfertiges Rechnungs-Dict samt berechneter Summen.
|
||||||
|
|
||||||
|
Spiegelt bewusst die Reihenfolge aus main.py: Kopfdaten, Positionen ueber den DISPATCHER
|
||||||
|
(nicht die Pure-Python-Variante - sonst faellt der Buendel-Parser aus), Bemessungs-
|
||||||
|
grundlage, Leistungszeitraum-Fallback, Freitext, Belegreferenzen."""
|
||||||
|
text = pdf_text_of(pdf_bytes)
|
||||||
|
data = extract_header_metadata_pure_python(text)
|
||||||
|
|
||||||
|
if data.get("recipient_email") == VERKAEUFER["email"]:
|
||||||
|
data["recipient_email"] = ""
|
||||||
|
|
||||||
|
items = extract_invoice_items(text)
|
||||||
|
for item in items:
|
||||||
|
item["total_amount"] = kaufmaennisches_runden(
|
||||||
|
float(item.get("quantity") or 0.0) * float(item.get("unit_price") or 0.0)
|
||||||
|
)
|
||||||
|
item["vat_rate_percent"] = float(
|
||||||
|
item.get("vat_rate_percent") or data.get("vat_rate_percent") or 19.0
|
||||||
|
)
|
||||||
|
data["items"] = items
|
||||||
|
data["stated_tax_basis"] = extract_stated_tax_basis(text)
|
||||||
|
|
||||||
|
if not data.get("service_start_date") and not data.get("service_end_date"):
|
||||||
|
start, end = extract_service_period(text)
|
||||||
|
if start and end:
|
||||||
|
data["service_start_date"], data["service_end_date"] = start, end
|
||||||
|
|
||||||
|
data["invoice_type_code"] = detect_invoice_type_code(text)
|
||||||
|
|
||||||
|
freetext = extract_pre_position_freetext(text)
|
||||||
|
if freetext is not None:
|
||||||
|
data["invoice_note"] = freetext
|
||||||
|
|
||||||
|
for field, value in extract_document_references(text).items():
|
||||||
|
data[field] = value
|
||||||
|
|
||||||
|
ihr_auftrag = extract_labeled_header_value(text, "Ihr Auftrag:")
|
||||||
|
if ihr_auftrag:
|
||||||
|
data["buyer_reference"] = ihr_auftrag
|
||||||
|
|
||||||
|
data["bearbeiter"] = extract_labeled_header_value(text, "Bearbeiter:")
|
||||||
|
|
||||||
|
refnr = extract_referenznummer(text)
|
||||||
|
if refnr and refnr not in str(data.get("invoice_note", "")):
|
||||||
|
existing = str(data.get("invoice_note", "")).strip()
|
||||||
|
line = f"Referenznummer: {refnr}"
|
||||||
|
data["invoice_note"] = f"{existing}\n{line}".strip() if existing else line
|
||||||
|
|
||||||
|
# Summen und die uebrigen Exportfelder ergaenzen, damit das Dict ohne UI exportfertig ist.
|
||||||
|
totals = compute_invoice_totals(items, discount_percent=0.0)
|
||||||
|
endpoint, scheme = resolve_buyer_endpoint(
|
||||||
|
data.get("recipient_email", ""), "EM", data.get("leitweg_id", "")
|
||||||
|
)
|
||||||
|
data.update({
|
||||||
|
"total_net_amount": totals["tax_basis_total"],
|
||||||
|
"total_vat_amount": totals["total_vat_amount"],
|
||||||
|
"total_gross_amount": totals["total_gross_amount"],
|
||||||
|
"tax_buckets": totals["tax_buckets"],
|
||||||
|
"line_net_total": totals["line_net_total"],
|
||||||
|
"allowance_amount": totals["allowance_amount"],
|
||||||
|
"allowance_reason": "",
|
||||||
|
"iban": VERKAEUFER_IBAN,
|
||||||
|
"payment_means_code": "58",
|
||||||
|
"skonto_percent": 0.0,
|
||||||
|
"skonto_days": 0,
|
||||||
|
"vorkasse": False,
|
||||||
|
"seller_contact_name": data.get("bearbeiter", ""),
|
||||||
|
"seller_endpoint": VERKAEUFER["email"],
|
||||||
|
"seller_contact_email": VERKAEUFER["email"],
|
||||||
|
"recipient_email": endpoint,
|
||||||
|
"recipient_endpoint_scheme": scheme,
|
||||||
|
"payment_reference": "",
|
||||||
|
})
|
||||||
|
data["computed_net_amount"] = totals["tax_basis_total"]
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_invoice(data, computed_net_amount, is_xrechnung=False) -> list:
|
||||||
|
"""Ampel-Bewertung einer Rechnung: leere Liste = gruen (freigabefaehig), sonst die
|
||||||
|
Klartext-Probleme.
|
||||||
|
|
||||||
|
Enthaelt dieselbe Pflichtfeld-Pruefung wie der Einzelmodus (collect_blockers) plus den
|
||||||
|
Summen-Abgleich gegen die auf der Rechnung ausgewiesene Bemessungsgrundlage. Letzterer ist
|
||||||
|
der zuverlaessigste Hinweis auf eine unvollstaendige Positionserkennung und traegt im
|
||||||
|
Stapelbetrieb die Freigabe ohne visuelle Einzelkontrolle."""
|
||||||
|
problems = collect_blockers(data, is_xrechnung)
|
||||||
|
|
||||||
|
stated = data.get("stated_tax_basis")
|
||||||
|
if stated is None:
|
||||||
|
problems.append(
|
||||||
|
"Keine ausgewiesene Bemessungsgrundlage gefunden - die Positionssumme ist nicht "
|
||||||
|
"gegenpruefbar. Bitte einzeln kontrollieren."
|
||||||
|
)
|
||||||
|
elif not sums_match(computed_net_amount, stated):
|
||||||
|
problems.append(
|
||||||
|
f"Summen-Abgleich fehlgeschlagen: erkannte Positionen {computed_net_amount:.2f} € "
|
||||||
|
f"weichen von der ausgewiesenen Bemessungsgrundlage {float(stated):.2f} € ab."
|
||||||
|
)
|
||||||
|
return problems
|
||||||
|
|
@ -0,0 +1,189 @@
|
||||||
|
"""Stapelbetrieb: Oberflaeche fuer die Verarbeitung eines ganzen Ordners.
|
||||||
|
|
||||||
|
Statt jede Rechnung einzeln hoch- und wieder herunterzuladen, liest das Tool einen von DATEV
|
||||||
|
befuellten Ordner ein, bewertet alle Rechnungen per Ampel und exportiert die freigegebenen in
|
||||||
|
einem Zug in einen Ausgabeordner.
|
||||||
|
|
||||||
|
Die eigentliche Logik liegt in batch_folder (ohne Streamlit, daher testbar); hier steht nur
|
||||||
|
die Darstellung. build_zugferd_pdf wird als Parameter hereingereicht, weil sie in main.py
|
||||||
|
gecacht definiert ist - ein Import waere zirkulaer.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
import batch_folder
|
||||||
|
from invoice_logic import MAX_ATTACHMENT_PDFS, append_pdf_pages, build_export_basename
|
||||||
|
|
||||||
|
_STATE_KEY = "batch_invoices"
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_batch():
|
||||||
|
st.session_state.pop(_STATE_KEY, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_folder(input_dir):
|
||||||
|
"""Liest alle PDFs des Eingangsordners ein und bewertet sie. Ein Lesefehler bei einer
|
||||||
|
einzelnen Datei stoppt den Stapel nicht - die Datei wird als rot mit Klartext gemeldet."""
|
||||||
|
entries = []
|
||||||
|
for path in batch_folder.list_input_pdfs(input_dir):
|
||||||
|
try:
|
||||||
|
data = batch_folder.analyse_pdf(path.read_bytes())
|
||||||
|
problems = batch_folder.evaluate_invoice(data, data["computed_net_amount"])
|
||||||
|
except Exception as e:
|
||||||
|
data, problems = None, [f"PDF konnte nicht gelesen werden: {e}"]
|
||||||
|
entries.append({"path": path, "data": data, "problems": problems, "attachments": []})
|
||||||
|
st.session_state[_STATE_KEY] = entries
|
||||||
|
|
||||||
|
|
||||||
|
def _assigned_attachment_names(entries):
|
||||||
|
"""Alle Dateinamen, die als Leistungsnachweis zugeordnet sind - sie sind damit keine
|
||||||
|
eigenstaendigen Rechnungen mehr."""
|
||||||
|
return {name for e in entries for name in e["attachments"]}
|
||||||
|
|
||||||
|
|
||||||
|
def render_batch_mode(build_zugferd_pdf):
|
||||||
|
st.subheader("Stapelverarbeitung")
|
||||||
|
|
||||||
|
subfolders = batch_folder.list_subfolders()
|
||||||
|
if not subfolders:
|
||||||
|
st.error(
|
||||||
|
f"❌ Im freigegebenen Verzeichnis `{batch_folder.BATCH_ROOT_DIR}` sind keine Ordner "
|
||||||
|
"sichtbar.\n\n"
|
||||||
|
"Das ist zu erwarten, solange die Netzwerkfreigabe nicht als Volume in den Container "
|
||||||
|
"eingebunden ist. Ein Windows-Pfad wie `\\\\FS01\\Daten\\...` lässt sich hier nicht "
|
||||||
|
"direkt eintragen - er muss in `docker-compose.yml` gemountet werden. Danach sind alle "
|
||||||
|
"Unterordner ohne weitere Konfiguration wählbar."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
c_in, c_out = st.columns(2)
|
||||||
|
with c_in:
|
||||||
|
input_dir = st.selectbox("Eingangsordner (von DATEV befüllt)", options=subfolders,
|
||||||
|
key="batch_input_dir", on_change=_reset_batch)
|
||||||
|
with c_out:
|
||||||
|
output_dir = st.selectbox("Ausgabeordner (fertige ZUGFeRD-PDFs)", options=subfolders,
|
||||||
|
key="batch_output_dir")
|
||||||
|
|
||||||
|
if input_dir == output_dir:
|
||||||
|
st.warning("⚠️ Ein- und Ausgabeordner sind identisch. Die erzeugten PDFs würden neben den "
|
||||||
|
"Quelldateien liegen. Bitte wählen Sie unterschiedliche Ordner.")
|
||||||
|
|
||||||
|
if st.button("📂 Ordner einlesen", width="stretch"):
|
||||||
|
with st.spinner("Lese Rechnungen ein..."):
|
||||||
|
_load_folder(input_dir)
|
||||||
|
|
||||||
|
entries = st.session_state.get(_STATE_KEY)
|
||||||
|
if entries is None:
|
||||||
|
return
|
||||||
|
if not entries:
|
||||||
|
st.info(f"Im Ordner `{input_dir}` liegen keine PDFs.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- Leistungsnachweise zuordnen -------------------------------------------------
|
||||||
|
all_names = [e["path"].name for e in entries]
|
||||||
|
st.markdown("---")
|
||||||
|
st.markdown("**Leistungsnachweise zuordnen** (optional)")
|
||||||
|
st.caption(
|
||||||
|
f"Ordnen Sie einer Rechnung bis zu {MAX_ATTACHMENT_PDFS} PDFs aus demselben Ordner zu. "
|
||||||
|
"Zugeordnete Dateien werden nicht mehr als eigene Rechnung behandelt, sondern hinten "
|
||||||
|
"an die gewählte Rechnung gehängt."
|
||||||
|
)
|
||||||
|
for entry in entries:
|
||||||
|
name = entry["path"].name
|
||||||
|
others = [n for n in all_names if n != name]
|
||||||
|
chosen = st.multiselect(
|
||||||
|
f"Nachweise für {name}", options=others, key=f"batch_att_{name}", default=[],
|
||||||
|
)
|
||||||
|
entry["attachments"] = chosen[:MAX_ATTACHMENT_PDFS]
|
||||||
|
if len(chosen) > MAX_ATTACHMENT_PDFS:
|
||||||
|
st.error(f"Maximal {MAX_ATTACHMENT_PDFS} Nachweise je Rechnung - "
|
||||||
|
f"die überzähligen werden ignoriert.")
|
||||||
|
|
||||||
|
# --- Uebersicht mit Ampel --------------------------------------------------------
|
||||||
|
assigned = _assigned_attachment_names(entries)
|
||||||
|
invoices = [e for e in entries if e["path"].name not in assigned]
|
||||||
|
green = [e for e in invoices if not e["problems"]]
|
||||||
|
red = [e for e in invoices if e["problems"]]
|
||||||
|
|
||||||
|
st.markdown("---")
|
||||||
|
c1, c2, c3 = st.columns(3)
|
||||||
|
c1.metric("Rechnungen", len(invoices))
|
||||||
|
c2.metric("Freigabefähig", len(green))
|
||||||
|
c3.metric("Zu prüfen", len(red))
|
||||||
|
|
||||||
|
for entry in invoices:
|
||||||
|
data, problems = entry["data"], entry["problems"]
|
||||||
|
icon = "✅" if not problems else "⚠️"
|
||||||
|
nr = (data or {}).get("invoice_number", "") or "?"
|
||||||
|
empf = (data or {}).get("recipient_name", "") or "?"
|
||||||
|
betrag = f"{(data or {}).get('computed_net_amount', 0.0):.2f} €" if data else "-"
|
||||||
|
anh = f" +{len(entry['attachments'])} Nachweis(e)" if entry["attachments"] else ""
|
||||||
|
|
||||||
|
if problems:
|
||||||
|
with st.expander(f"{icon} {nr} - {empf} - {betrag}{anh}", expanded=False):
|
||||||
|
for p in problems:
|
||||||
|
st.warning(p)
|
||||||
|
st.caption(
|
||||||
|
"Diese Rechnung wird beim Sammel-Export übersprungen. Korrigieren Sie sie im "
|
||||||
|
"Reiter *Einzelrechnung* - dort stehen alle Felder zur Bearbeitung bereit."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
st.markdown(f"{icon} **{nr}** - {empf} - {betrag}{anh}")
|
||||||
|
|
||||||
|
# --- Sammel-Export ----------------------------------------------------------------
|
||||||
|
st.markdown("---")
|
||||||
|
if not green:
|
||||||
|
st.info("Keine freigabefähige Rechnung - bitte zuerst die markierten Fälle klären.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if st.button(f"🔒 {len(green)} Rechnung(en) exportieren", type="primary", width="stretch",
|
||||||
|
disabled=input_dir == output_dir):
|
||||||
|
by_name = {e["path"].name: e["path"] for e in entries}
|
||||||
|
progress = st.progress(0.0)
|
||||||
|
done, failed = [], []
|
||||||
|
|
||||||
|
for idx, entry in enumerate(green, start=1):
|
||||||
|
path, data = entry["path"], entry["data"]
|
||||||
|
try:
|
||||||
|
pdf_bytes = path.read_bytes()
|
||||||
|
if entry["attachments"]:
|
||||||
|
extras = [by_name[n].read_bytes() for n in entry["attachments"]]
|
||||||
|
pdf_bytes = append_pdf_pages(pdf_bytes, *extras)
|
||||||
|
|
||||||
|
zugferd = build_zugferd_pdf(
|
||||||
|
json.dumps(data, default=str, sort_keys=True), pdf_bytes
|
||||||
|
)
|
||||||
|
basename = build_export_basename(
|
||||||
|
data.get("invoice_number", ""), data.get("recipient_name", ""),
|
||||||
|
data.get("bearbeiter", ""),
|
||||||
|
)
|
||||||
|
written = batch_folder.write_output(output_dir, f"{basename}.pdf", zugferd)
|
||||||
|
|
||||||
|
# Erst nach erfolgreichem Schreiben verschieben - sonst waere die Quelle weg,
|
||||||
|
# ohne dass ein Ergebnis existiert.
|
||||||
|
batch_folder.move_to_processed(path)
|
||||||
|
for n in entry["attachments"]:
|
||||||
|
batch_folder.move_to_processed(by_name[n])
|
||||||
|
done.append(written.name)
|
||||||
|
except Exception as e:
|
||||||
|
failed.append((path.name, str(e)))
|
||||||
|
progress.progress(idx / len(green))
|
||||||
|
|
||||||
|
if done:
|
||||||
|
st.success(
|
||||||
|
f"✅ {len(done)} Rechnung(en) nach `{output_dir}` exportiert. Die Quelldateien "
|
||||||
|
f"liegen jetzt im Unterordner `{batch_folder.PROCESSED_SUBDIR}`."
|
||||||
|
)
|
||||||
|
st.caption(" · ".join(done))
|
||||||
|
if failed:
|
||||||
|
# Ein Fehlschlag stoppt den Stapel nicht - die betroffene Datei bleibt im
|
||||||
|
# Eingangsordner und wird hier namentlich gemeldet.
|
||||||
|
st.error(
|
||||||
|
f"❌ {len(failed)} Rechnung(en) konnten nicht exportiert werden und liegen "
|
||||||
|
"unverändert im Eingangsordner:\n\n"
|
||||||
|
+ "\n".join(f"- **{n}**: {msg}" for n, msg in failed)
|
||||||
|
)
|
||||||
|
if red:
|
||||||
|
st.info(f"{len(red)} Rechnung(en) wurden übersprungen, weil sie zu prüfen sind.")
|
||||||
|
_reset_batch()
|
||||||
|
|
@ -6,7 +6,29 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- "8040:8040"
|
- "8040:8040"
|
||||||
environment:
|
environment:
|
||||||
- VLLM_URL=http://10.192.0.140:8002
|
# Wurzel des Austauschverzeichnisses fuer den Stapelbetrieb. Alles darunter ist im UI
|
||||||
- LLM_MODEL=gemma431b
|
# frei waehlbar - fuer neue Ordner muss diese Datei NICHT angefasst werden.
|
||||||
- LLM_BACKEND=vllm
|
- BATCH_ROOT_DIR=/data
|
||||||
|
volumes:
|
||||||
|
# Netzwerkfreigabe fuer den Stapelbetrieb einbinden. Ein UNC-Pfad (\\FS01\Daten\...) laesst
|
||||||
|
# sich im Container nicht direkt verwenden - er muss hier gemountet werden.
|
||||||
|
#
|
||||||
|
# Variante A (empfohlen): Freigabe ist auf dem Docker-Host bereits eingehaengt,
|
||||||
|
# dann nur den Host-Pfad eintragen:
|
||||||
|
# - /mnt/fs01/Daten/Technik/AI:/data
|
||||||
|
#
|
||||||
|
# Variante B: Docker haengt die Freigabe selbst per CIFS ein - dazu unten den
|
||||||
|
# auskommentierten Block 'volumes:' am Dateiende aktivieren und hier verwenden:
|
||||||
|
# - datev_austausch:/data
|
||||||
|
- ./batch_data:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Variante B fuer den Mount: Docker bindet die SMB-Freigabe selbst ein. Benoetigt ein Konto
|
||||||
|
# mit Lese- UND Schreibrechten auf der Freigabe.
|
||||||
|
# volumes:
|
||||||
|
# datev_austausch:
|
||||||
|
# driver: local
|
||||||
|
# driver_opts:
|
||||||
|
# type: cifs
|
||||||
|
# o: "username=DIENSTKONTO,password=GEHEIM,uid=1000,gid=1000,vers=3.0"
|
||||||
|
# device: "//FS01/Daten/Technik/AI"
|
||||||
|
|
@ -207,6 +207,62 @@ def build_payment_terms_note(data):
|
||||||
return " ".join(parts)
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# Zulaessige Abweichung beim Abgleich der Positionssumme gegen die ausgewiesene
|
||||||
|
# Bemessungsgrundlage. Ein Cent deckt uebliche Rundungsdifferenzen ab.
|
||||||
|
SUM_TOLERANCE_EUR = 0.01
|
||||||
|
|
||||||
|
|
||||||
|
def sums_match(computed_net_amount, stated_tax_basis) -> bool:
|
||||||
|
"""True, wenn die Summe der erkannten Positionen zur ausgewiesenen Bemessungsgrundlage
|
||||||
|
passt. Die Differenz wird VOR dem Vergleich auf Cent gerundet: 100.01 - 100.00 ergibt in
|
||||||
|
Fliesskomma 0.010000000000005 und wuerde eine exakt eingehaltene Ein-Cent-Toleranz sonst
|
||||||
|
faelschlich reissen.
|
||||||
|
|
||||||
|
Bewusst hier statt in der UI: Einzel- und Stapelmodus muessen identisch bewerten, sonst
|
||||||
|
gilt im Stapel als gruen, was einzeln eine Warnung ausloesen wuerde."""
|
||||||
|
if stated_tax_basis is None:
|
||||||
|
return False
|
||||||
|
return round(abs(float(computed_net_amount) - float(stated_tax_basis)), 2) <= SUM_TOLERANCE_EUR
|
||||||
|
|
||||||
|
|
||||||
|
def collect_blockers(data, is_xrechnung):
|
||||||
|
"""Sammelt alle blockierenden Pflichtfeld-Probleme einer Rechnung als Liste von Klartexten.
|
||||||
|
Leere Liste = exportierbar.
|
||||||
|
|
||||||
|
Bewusst hier statt in der UI: Einzel- und Stapelmodus muessen exakt dieselbe Pruefung
|
||||||
|
verwenden, sonst gilt im Stapel als gruen, was einzeln blockiert wuerde.
|
||||||
|
|
||||||
|
BT-10 (Leitweg-ID) und BT-49 (elektronische Adresse) sind NUR bei XRechnung Pflicht
|
||||||
|
(Behoerden/B2G). Bei ZUGFeRD (B2B) duerfen sie leer bleiben und blockieren nicht."""
|
||||||
|
blocker = []
|
||||||
|
if not str(data.get("invoice_number") or "").strip():
|
||||||
|
blocker.append("Rechnungsnummer (BT-1) fehlt.")
|
||||||
|
if parse_to_strict_xml_date(data.get("invoice_date")) == "":
|
||||||
|
blocker.append("Rechnungsdatum (BT-2) fehlt oder ist unlesbar (erwartet: YYYY-MM-DD). "
|
||||||
|
"Bitte korrigieren - es wird KEIN Datum automatisch eingesetzt.")
|
||||||
|
if is_xrechnung and not str(data.get("leitweg_id") or "").strip():
|
||||||
|
blocker.append("Leitweg-ID (BT-10) fehlt. Bei XRechnung (in der Regel an Behörden) ist sie "
|
||||||
|
"Pflicht (BR-DE-15) und dient als Käuferreferenz - im Reiter 'Kopfdaten' ins "
|
||||||
|
"Feld 'Leitweg-ID (BT-10)' eintragen.")
|
||||||
|
if is_xrechnung and not str(data.get("recipient_email") or "").strip():
|
||||||
|
blocker.append("Elektronische Adresse des Kunden (BT-49) fehlt. Bei XRechnung ist sie "
|
||||||
|
"Pflicht (PEPPOL-EN16931-R010). Ist oben eine Leitweg-ID (BT-10) eingetragen, "
|
||||||
|
"wird sie automatisch als BT-49 (Schema 0204) übernommen. Alternativ im Reiter "
|
||||||
|
"'Adressen' die E-Mail-Adresse des Empfängers (Schema EM) oder eine GLN (0088) eintragen.")
|
||||||
|
_delivery = data.get("delivery_date")
|
||||||
|
if _delivery and parse_to_strict_xml_date(_delivery) == "":
|
||||||
|
blocker.append("Lieferdatum (BT-72) ist unlesbar (erwartet: YYYY-MM-DD). Bitte korrigieren oder leeren.")
|
||||||
|
if not data.get("items"):
|
||||||
|
blocker.append("Die Rechnung enthält keine Position (mindestens eine ist erforderlich).")
|
||||||
|
_pt = str(data.get("payment_term_days") or "").strip()
|
||||||
|
if _pt == "":
|
||||||
|
blocker.append("Zahlungsziel (BT-9/BT-20) fehlt. Es wird KEIN Standardwert (z.B. 14 Tage) "
|
||||||
|
"angenommen - bitte das tatsächliche Zahlungsziel der Rechnung in Tagen eintragen.")
|
||||||
|
elif not _pt.isdigit():
|
||||||
|
blocker.append("Zahlungsziel (BT-9/BT-20) muss eine Zahl in Tagen sein (z.B. 14 oder 30).")
|
||||||
|
return blocker
|
||||||
|
|
||||||
|
|
||||||
ANGEBOT_PATTERN = re.compile(
|
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})",
|
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,
|
re.IGNORECASE,
|
||||||
|
|
|
||||||
59
main.py
59
main.py
|
|
@ -37,8 +37,11 @@ from invoice_logic import (
|
||||||
extract_labeled_header_value,
|
extract_labeled_header_value,
|
||||||
extract_referenznummer,
|
extract_referenznummer,
|
||||||
extract_header_metadata_pure_python,
|
extract_header_metadata_pure_python,
|
||||||
|
collect_blockers,
|
||||||
|
sums_match,
|
||||||
)
|
)
|
||||||
from intro_overlay import render_intro_overlay
|
from intro_overlay import render_intro_overlay
|
||||||
|
from batch_mode import render_batch_mode
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 0. SITZUNGS-STATUS INITIALISIEREN (ZÄHLER GEGEN WIDGET-CACHING)
|
# 0. SITZUNGS-STATUS INITIALISIEREN (ZÄHLER GEGEN WIDGET-CACHING)
|
||||||
|
|
@ -105,6 +108,19 @@ st.set_page_config(page_title="PDF zu E-Rechnung", page_icon=_page_icon, layout=
|
||||||
st.title("📄 PDF zu E-Rechnung Konverter")
|
st.title("📄 PDF zu E-Rechnung Konverter")
|
||||||
render_intro_overlay()
|
render_intro_overlay()
|
||||||
|
|
||||||
|
# Betriebsart-Umschaltung. Bewusst ueber die Seitenleiste + st.stop() statt ueber st.tabs:
|
||||||
|
# Tabs wuerden verlangen, den gesamten Einzelmodus einzuruecken - so bleibt er unveraendert
|
||||||
|
# und der Stapelmodus ist rein additiv.
|
||||||
|
_mode = st.sidebar.radio(
|
||||||
|
"Betriebsart",
|
||||||
|
["Einzelrechnung", "Stapel (Ordner)"],
|
||||||
|
help="Einzelrechnung: eine PDF hochladen, prüfen, exportieren. "
|
||||||
|
"Stapel: einen ganzen Ordner einlesen, per Ampel prüfen und gesammelt exportieren.",
|
||||||
|
)
|
||||||
|
if _mode.startswith("Stapel"):
|
||||||
|
render_batch_mode(build_zugferd_pdf)
|
||||||
|
st.stop()
|
||||||
|
|
||||||
col1, col2 = st.columns(2)
|
col1, col2 = st.columns(2)
|
||||||
|
|
||||||
with col1:
|
with col1:
|
||||||
|
|
@ -543,8 +559,11 @@ with col2:
|
||||||
# gegen die Summe der erkannten Positionen abgleichen. Weichen sie ab, ist die
|
# gegen die Summe der erkannten Positionen abgleichen. Weichen sie ab, ist die
|
||||||
# Positionserkennung vermutlich unvollständig - dann deutlich warnen, damit der
|
# Positionserkennung vermutlich unvollständig - dann deutlich warnen, damit der
|
||||||
# Sachbearbeiter die Positionen prüft/korrigiert (v.a. bei komplexen Bündel-Rechnungen).
|
# Sachbearbeiter die Positionen prüft/korrigiert (v.a. bei komplexen Bündel-Rechnungen).
|
||||||
|
# Der Abgleich selbst liegt in invoice_logic.sums_match - der Stapelmodus bewertet damit
|
||||||
|
# identisch. Dort wird die Differenz vor dem Vergleich auf Cent gerundet, sonst reisst
|
||||||
|
# eine exakt eingehaltene Ein-Cent-Toleranz durch Fliesskomma-Ungenauigkeit.
|
||||||
_stated_basis = data.get("stated_tax_basis")
|
_stated_basis = data.get("stated_tax_basis")
|
||||||
if _stated_basis is not None and abs(computed_net_amount - float(_stated_basis)) > 0.01:
|
if _stated_basis is not None and not sums_match(computed_net_amount, _stated_basis):
|
||||||
st.warning(
|
st.warning(
|
||||||
f"⚠️ **Summen-Abgleich fehlgeschlagen:** Die Summe der erkannten Positionen "
|
f"⚠️ **Summen-Abgleich fehlgeschlagen:** Die Summe der erkannten Positionen "
|
||||||
f"(**{computed_net_amount:.2f} €**) weicht von der auf der Rechnung ausgewiesenen "
|
f"(**{computed_net_amount:.2f} €**) weicht von der auf der Rechnung ausgewiesenen "
|
||||||
|
|
@ -609,31 +628,9 @@ with col2:
|
||||||
# (Behörden/B2G). Bei ZUGFeRD (B2B) dürfen sie leer bleiben und blockieren den Export nicht.
|
# (Behörden/B2G). Bei ZUGFeRD (B2B) dürfen sie leer bleiben und blockieren den Export nicht.
|
||||||
is_xrechnung = "UBL XML" in export_format
|
is_xrechnung = "UBL XML" in export_format
|
||||||
|
|
||||||
blocker = []
|
# Die Pruefung selbst liegt in invoice_logic.collect_blockers, damit der Stapelmodus
|
||||||
if not str(inv_num).strip():
|
# exakt dieselbe Logik nutzt - sonst gaelte dort als gruen, was hier blockiert wuerde.
|
||||||
blocker.append("Rechnungsnummer (BT-1) fehlt.")
|
blocker = collect_blockers(final_invoice_dict, is_xrechnung)
|
||||||
if parse_to_strict_xml_date(inv_date) == "":
|
|
||||||
blocker.append("Rechnungsdatum (BT-2) fehlt oder ist unlesbar (erwartet: YYYY-MM-DD). "
|
|
||||||
"Bitte korrigieren - es wird KEIN Datum automatisch eingesetzt.")
|
|
||||||
if is_xrechnung and not str(leitweg_id).strip():
|
|
||||||
blocker.append("Leitweg-ID (BT-10) fehlt. Bei XRechnung (in der Regel an Behörden) ist sie "
|
|
||||||
"Pflicht (BR-DE-15) und dient als Käuferreferenz - im Reiter 'Kopfdaten' ins "
|
|
||||||
"Feld 'Leitweg-ID (BT-10)' eintragen.")
|
|
||||||
if is_xrechnung and not str(r_email_effective).strip():
|
|
||||||
blocker.append("Elektronische Adresse des Kunden (BT-49) fehlt. Bei XRechnung ist sie "
|
|
||||||
"Pflicht (PEPPOL-EN16931-R010). Ist oben eine Leitweg-ID (BT-10) eingetragen, "
|
|
||||||
"wird sie automatisch als BT-49 (Schema 0204) übernommen. Alternativ im Reiter "
|
|
||||||
"'Adressen' die E-Mail-Adresse des Empfängers (Schema EM) oder eine GLN (0088) eintragen.")
|
|
||||||
if inv_delivery and parse_to_strict_xml_date(inv_delivery) == "":
|
|
||||||
blocker.append("Lieferdatum (BT-72) ist unlesbar (erwartet: YYYY-MM-DD). Bitte korrigieren oder leeren.")
|
|
||||||
if not edited_items:
|
|
||||||
blocker.append("Die Rechnung enthält keine Position (mindestens eine ist erforderlich).")
|
|
||||||
_pt = str(pay_term).strip()
|
|
||||||
if _pt == "":
|
|
||||||
blocker.append("Zahlungsziel (BT-9/BT-20) fehlt. Es wird KEIN Standardwert (z.B. 14 Tage) "
|
|
||||||
"angenommen - bitte das tatsächliche Zahlungsziel der Rechnung in Tagen eintragen.")
|
|
||||||
elif not _pt.isdigit():
|
|
||||||
blocker.append("Zahlungsziel (BT-9/BT-20) muss eine Zahl in Tagen sein (z.B. 14 oder 30).")
|
|
||||||
|
|
||||||
if blocker:
|
if blocker:
|
||||||
st.warning("⚠️ Export blockiert - folgende Pflichtangaben fehlen oder sind fehlerhaft:\n\n"
|
st.warning("⚠️ Export blockiert - folgende Pflichtangaben fehlen oder sind fehlerhaft:\n\n"
|
||||||
|
|
@ -751,9 +748,13 @@ with col2:
|
||||||
st.markdown("---")
|
st.markdown("---")
|
||||||
st.caption(
|
st.caption(
|
||||||
"📝 **Hinweise:**\n\n"
|
"📝 **Hinweise:**\n\n"
|
||||||
"**Human-in-the-Loop:** "
|
"**Kontrolle durch den Sachbearbeiter:** "
|
||||||
"Das Tool dient als Automations-Assistent. Vor dem finalen Export ist eine visuelle Endkontrolle der extrahierten Daten "
|
"Das Tool dient als Automations-Assistent. In der Einzelrechnung ist vor dem Export eine visuelle "
|
||||||
"(insbesondere Rechnungsnummer und Empfängeradresse) durch den Sachbearbeiter zwingend erforderlich, um KI-Fehlinterpretationen auszuschließen.\n\n"
|
"Endkontrolle der extrahierten Daten (insbesondere Rechnungsnummer und Empfängeradresse) erforderlich. "
|
||||||
|
"Im Stapelbetrieb tritt an deren Stelle die Sammelfreigabe: exportiert wird nur, was alle Pflichtfelder "
|
||||||
|
"erfüllt und dessen Positionssumme exakt der ausgewiesenen Bemessungsgrundlage entspricht - alles "
|
||||||
|
"Auffällige wird markiert und übersprungen. Die Verantwortung für die inhaltliche Richtigkeit bleibt "
|
||||||
|
"in beiden Fällen beim Sachbearbeiter.\n\n"
|
||||||
"**GoBD-Konformität:** "
|
"**GoBD-Konformität:** "
|
||||||
"Das Tool generiert ausschließlich das strukturierte XML- bzw. hybride PDF-Format. Für die Einhaltung der GoBD-Richtlinien "
|
"Das Tool generiert ausschließlich das strukturierte XML- bzw. hybride PDF-Format. Für die Einhaltung der GoBD-Richtlinien "
|
||||||
"(u. a. die revisionssichere Archivierung der Original-PDF sowie der generierten E-Rechnung über den gesetzlichen Zeitraum von 10 Jahren) "
|
"(u. a. die revisionssichere Archivierung der Original-PDF sowie der generierten E-Rechnung über den gesetzlichen Zeitraum von 10 Jahren) "
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue