From 61fc8c45a617da5bde3a2b9a0de0a7b0873d7048 Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Thu, 30 Jul 2026 16:45:28 +0200 Subject: [PATCH] Stapel: PDFs direkt hochladen statt Serverordner, Ergebnis als ZIP. Co-Authored-By: Claude Opus 5 --- README.md | 21 +-- batch_folder.py | 26 ++++ batch_mode.py | 330 ++++++++++++++++++++++++++++-------------------- 3 files changed, 229 insertions(+), 148 deletions(-) diff --git a/README.md b/README.md index 14b4b2f..f65d817 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,11 @@ git clone https://gitea.scitech-gmbh.de:8443/dfuhrmann/E-Rechnung.git cd E-Rechnung ``` -### 2. Austauschordner einbinden (nur für den Stapelbetrieb) +### 2. Austauschordner einbinden (optional) -Der Stapelbetrieb liest Rechnungen aus einem Ordner und schreibt die fertigen E-Rechnungen zurück. Die Netzwerkfreigabe wird dafür in `docker-compose.yml` eingetragen – der Container arbeitet dann **direkt** auf ihr, es wird nichts kopiert oder synchronisiert. +**Für den Stapelbetrieb nicht erforderlich** – dort können die PDFs einfach hochgeladen werden. Dieser Schritt ist nur sinnvoll, wenn die Rechnungen ohnehin auf einer Netzwerkfreigabe liegen und Sie sich das Hoch- und Herunterladen sparen wollen. + +Ist ein Verzeichnis eingebunden, liest der Stapelbetrieb Rechnungen direkt daraus und schreibt die fertigen E-Rechnungen zurück. Die Netzwerkfreigabe wird dafür in `docker-compose.yml` eingetragen – der Container arbeitet dann **direkt** auf ihr, es wird nichts kopiert oder synchronisiert. Der Windows-Pfad wird dabei in Docker-Schreibweise angegeben: aus `\\FS01\Daten\Technik\AI` wird `//FS01/Daten/Technik/AI`. @@ -75,13 +77,16 @@ Die Betriebsart wird links in der Seitenleiste gewählt. 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) +### Stapel -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. +Mehrere Rechnungen in einem Durchgang. Es ist **keine Einrichtung nötig** – der Stapel funktioniert direkt. + +1. **Dateien auswählen:** Markieren Sie im Explorer alle gewünschten Rechnungs-PDFs und ziehen Sie sie in das Upload-Feld. Lokale Ordner und Netzlaufwerke funktionieren gleichermaßen. +2. **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 in der Betriebsart *Einzelrechnung* korrigieren. +3. **Nachweise zuordnen (optional):** Ist ein Leistungsnachweis mit hochgeladen, ordnen Sie ihn der zugehörigen Rechnung zu. Er zählt dann nicht mehr als eigene Rechnung, sondern wird hinten angehängt. +4. **Konvertieren:** Alle grünen Rechnungen werden in einem Zug verarbeitet und als ZIP zum Download angeboten. + +**Optional: Ordner auf dem Server.** Ist ein Austauschverzeichnis eingebunden (siehe Installation), erscheint zusätzlich die Quelle „Ordner auf dem Server". Dann werden Ein- und Ausgabeordner ausgewählt, die Ergebnisse landen direkt im Ausgabeordner und die Quelldateien wandern nach `verarbeitet` – gelöscht wird nichts. Das spart das Herunterladen, ist aber nicht Voraussetzung für den Stapelbetrieb. ## Notes diff --git a/batch_folder.py b/batch_folder.py index 7ac8845..544e9d7 100644 --- a/batch_folder.py +++ b/batch_folder.py @@ -121,6 +121,32 @@ def move_to_processed(pdf_path, root=None) -> Path: return target +def build_zip(files) -> bytes: + """Packt fertige Exporte in ein ZIP - der Rueckweg fuer den Upload-Stapel, bei dem es + keinen Ausgabeordner auf dem Server gibt. + + Nimmt ein Dict {name: bytes} oder eine Liste [(name, bytes), ...]. Doppelte Namen werden + mit _2, _3 ... entzerrt, statt sich gegenseitig zu ueberschreiben - zwei Rechnungen koennen + denselben Exportnamen ergeben.""" + import zipfile + + items = list(files.items()) if isinstance(files, dict) else list(files) + buf = io.BytesIO() + used = set() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive: + for name, data in items: + final = name + if final in used: + stem, suffix = Path(name).stem, Path(name).suffix + counter = 2 + while f"{stem}_{counter}{suffix}" in used: + counter += 1 + final = f"{stem}_{counter}{suffix}" + used.add(final) + archive.writestr(final, data) + return buf.getvalue() + + def pdf_text_of(pdf_bytes: bytes) -> str: """Extrahiert den Text einer PDF - identisch zum Einzelmodus (main.py).""" text = "" diff --git a/batch_mode.py b/batch_mode.py index d753804..3532d01 100644 --- a/batch_mode.py +++ b/batch_mode.py @@ -1,12 +1,14 @@ -"""Stapelbetrieb: Oberflaeche fuer die Verarbeitung eines ganzen Ordners. +"""Stapelbetrieb: mehrere Rechnungen in einem Durchgang statt einzeln. -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. +Zwei Quellen: + * Dateien hochladen - funktioniert immer, egal ob die PDFs lokal oder auf einer + Netzwerkfreigabe liegen; der Browser kommt an beides. Ergebnis kommt als ZIP zurueck. + * Ordner auf dem Server - nur wenn ein Austauschverzeichnis gemountet ist. Dann bleiben die + Dateien serverseitig und die Ergebnisse landen direkt im 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. +Die Logik liegt in batch_folder (ohne Streamlit, daher testbar); hier steht nur die +Darstellung. build_zugferd_pdf wird hereingereicht, weil sie in main.py gecacht definiert ist - +ein Import waere zirkulaer. """ import json @@ -22,87 +24,43 @@ 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 _make_entry(name, pdf_bytes): + """Analysiert eine PDF und verpackt sie mit ihrer Bewertung. Ein Lesefehler stoppt den + Stapel nicht - die Datei wird als zu pruefen mit Klartext gemeldet.""" + try: + data = batch_folder.analyse_pdf(pdf_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}"] + return {"name": name, "bytes": pdf_bytes, "data": data, + "problems": problems, "attachments": [], "source": None} -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_overview(entries, build_zugferd_pdf, write_to_folder=None): + """Zuordnung, Ampel und Sammel-Export. write_to_folder ist der Ausgabeordner im + Server-Betrieb; ist er None, wird ein ZIP zum Download erzeugt.""" + all_names = [e["name"] for e in entries] + if len(entries) > 1: + with st.expander("Leistungsnachweise zuordnen (optional)"): + st.caption( + f"Gehoert eine der hochgeladenen PDFs als Nachweis zu einer Rechnung, ordnen Sie " + f"sie hier zu (max. {MAX_ATTACHMENT_PDFS} je Rechnung). Zugeordnete Dateien " + "zaehlen dann nicht mehr als eigene Rechnung, sondern werden hinten angehaengt." + ) + for entry in entries: + others = [n for n in all_names if n != entry["name"]] + chosen = st.multiselect( + f"Nachweise für {entry['name']}", options=others, + key=f"batch_att_{entry['name']}", + ) + entry["attachments"] = chosen[:MAX_ATTACHMENT_PDFS] + if len(chosen) > MAX_ATTACHMENT_PDFS: + st.error(f"Maximal {MAX_ATTACHMENT_PDFS} Nachweise je Rechnung - " + "die überzähligen werden ignoriert.") -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] + assigned = {n for e in entries for n in e["attachments"]} + invoices = [e for e in entries if e["name"] not in assigned] green = [e for e in invoices if not e["problems"]] red = [e for e in invoices if e["problems"]] @@ -113,77 +71,169 @@ def render_batch_mode(build_zugferd_pdf): 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 "" + data = entry["data"] or {} + nr = data.get("invoice_number") or "?" + empf = data.get("recipient_name") or "?" + betrag = f"{data.get('computed_net_amount', 0.0):.2f} €" if entry["data"] else "-" + anh = f" · +{len(entry['attachments'])} Nachweis(e)" if entry["attachments"] else "" + titel = f"{nr} — {empf} — {betrag}{anh}" - if problems: - with st.expander(f"{icon} {nr} - {empf} - {betrag}{anh}", expanded=False): - for p in problems: + if entry["problems"]: + with st.expander(f"⚠️ {titel}"): + for p in entry["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." + "Diese Rechnung wird beim Sammel-Export übersprungen. Korrigieren Sie sie in " + "der Betriebsart *Einzelrechnung* - dort stehen alle Felder bereit." ) else: - st.markdown(f"{icon} **{nr}** - {empf} - {betrag}{anh}") + st.markdown(f"✅ **{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 = [], [] + if not st.button(f"🔒 {len(green)} Rechnung(en) konvertieren", type="primary", width="stretch"): + return - for idx, entry in enumerate(green, start=1): - path, data = entry["path"], entry["data"] + by_name = {e["name"]: e for e in entries} + progress = st.progress(0.0) + results, failed = [], [] + + for idx, entry in enumerate(green, start=1): + try: + pdf_bytes = entry["bytes"] + if entry["attachments"]: + extras = [by_name[n]["bytes"] for n in entry["attachments"]] + pdf_bytes = append_pdf_pages(pdf_bytes, *extras) + + zugferd = build_zugferd_pdf( + json.dumps(entry["data"], default=str, sort_keys=True), pdf_bytes + ) + basename = build_export_basename( + entry["data"].get("invoice_number", ""), + entry["data"].get("recipient_name", ""), + entry["data"].get("bearbeiter", ""), + ) + results.append((f"{basename}.pdf", zugferd, entry)) + except Exception as e: + failed.append((entry["name"], str(e))) + progress.progress(idx / len(green)) + + if results and write_to_folder is not None: + # Server-Betrieb: direkt in den Ausgabeordner schreiben, Quellen wegraeumen. + written = [] + for name, data, entry in results: 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) + out = batch_folder.write_output(write_to_folder, name, data) + batch_folder.move_to_processed(entry["source"]) for n in entry["attachments"]: - batch_folder.move_to_processed(by_name[n]) - done.append(written.name) + batch_folder.move_to_processed(by_name[n]["source"]) + written.append(out.name) except Exception as e: - failed.append((path.name, str(e))) - progress.progress(idx / len(green)) - - if done: + failed.append((entry["name"], str(e))) + if written: st.success( - f"✅ {len(done)} Rechnung(en) nach `{output_dir}` exportiert. Die Quelldateien " - f"liegen jetzt im Unterordner `{batch_folder.PROCESSED_SUBDIR}`." + f"✅ {len(written)} Rechnung(en) nach `{write_to_folder}` geschrieben. Die " + f"Quelldateien 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.") + st.caption(" · ".join(written)) _reset_batch() + elif results: + # Upload-Betrieb: alles in ein ZIP, das der Nutzer herunterlaedt. + zip_bytes = batch_folder.build_zip([(name, data) for name, data, _ in results]) + st.success(f"✅ {len(results)} Rechnung(en) konvertiert.") + st.download_button( + f"⬇️ {len(results)} E-Rechnung(en) als ZIP herunterladen", + data=zip_bytes, + file_name="e-rechnungen.zip", + mime="application/zip", + type="primary", + width="stretch", + ) + with st.expander("Enthaltene Dateien"): + for name, data, _ in results: + st.markdown(f"- `{name}` ({len(data) / 1024:.0f} KB)") + + if failed: + # Ein Fehlschlag stoppt den Stapel nicht - die uebrigen laufen durch. + st.error( + f"❌ {len(failed)} Rechnung(en) konnten nicht konvertiert werden:\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.") + + +def render_batch_mode(build_zugferd_pdf): + st.subheader("Stapelverarbeitung") + st.caption( + "Mehrere Rechnungen in einem Durchgang: einlesen, per Ampel prüfen, gesammelt " + "konvertieren. Geprüft wird, ob alle Pflichtfelder belegt sind und ob die Summe der " + "erkannten Positionen zur ausgewiesenen Bemessungsgrundlage passt." + ) + + subfolders = batch_folder.list_subfolders() + quelle = "Dateien hochladen" + if subfolders: + quelle = st.radio( + "Quelle", ["Dateien hochladen", "Ordner auf dem Server"], horizontal=True, + on_change=_reset_batch, + ) + + if quelle == "Dateien hochladen": + uploads = st.file_uploader( + "Rechnungs-PDFs auswählen (mehrere möglich)", type=["pdf"], + accept_multiple_files=True, key="batch_uploads", + ) + st.caption( + "Markieren Sie im Explorer alle gewünschten PDFs und ziehen Sie sie hierher - " + "lokale Ordner und Netzlaufwerke funktionieren gleichermaßen." + ) + if not uploads: + return + + signature = [(f.name, f.size) for f in uploads] + if st.session_state.get("_batch_signature") != signature: + st.session_state._batch_signature = signature + with st.spinner(f"Analysiere {len(uploads)} Rechnung(en)..."): + entries = [] + for f in uploads: + f.seek(0) + entries.append(_make_entry(f.name, f.read())) + st.session_state[_STATE_KEY] = entries + + entries = st.session_state.get(_STATE_KEY) or [] + if entries: + _render_overview(entries, build_zugferd_pdf) + return + + # --- Server-Ordner --------------------------------------------------------------- + c_in, c_out = st.columns(2) + with c_in: + input_dir = st.selectbox("Eingangsordner", options=subfolders, + key="batch_input_dir", on_change=_reset_batch) + with c_out: + output_dir = st.selectbox("Ausgabeordner", options=subfolders, key="batch_output_dir") + + if input_dir == output_dir: + st.warning("⚠️ Ein- und Ausgabeordner sind identisch. Bitte unterschiedliche Ordner wählen.") + return + + if st.button("📂 Ordner einlesen", width="stretch"): + with st.spinner("Lese Rechnungen ein..."): + entries = [] + for path in batch_folder.list_input_pdfs(input_dir): + entry = _make_entry(path.name, path.read_bytes()) + entry["source"] = path + entries.append(entry) + st.session_state[_STATE_KEY] = entries + + 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 + _render_overview(entries, build_zugferd_pdf, write_to_folder=output_dir)