Stapel: PDFs direkt hochladen statt Serverordner, Ergebnis als ZIP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dfuhrmann3 2026-07-30 16:45:28 +02:00
parent a4e9918e23
commit 61fc8c45a6
3 changed files with 229 additions and 148 deletions

View File

@ -29,9 +29,11 @@ git clone https://gitea.scitech-gmbh.de:8443/dfuhrmann/E-Rechnung.git
cd E-Rechnung 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`. 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. 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. 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. Mehrere Rechnungen in einem Durchgang. Es ist **keine Einrichtung nötig** – der Stapel funktioniert direkt.
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. 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.
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. 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.
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. 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 ## Notes

View File

@ -121,6 +121,32 @@ def move_to_processed(pdf_path, root=None) -> Path:
return target 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: def pdf_text_of(pdf_bytes: bytes) -> str:
"""Extrahiert den Text einer PDF - identisch zum Einzelmodus (main.py).""" """Extrahiert den Text einer PDF - identisch zum Einzelmodus (main.py)."""
text = "" text = ""

View File

@ -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 Zwei Quellen:
befuellten Ordner ein, bewertet alle Rechnungen per Ampel und exportiert die freigegebenen in * Dateien hochladen - funktioniert immer, egal ob die PDFs lokal oder auf einer
einem Zug in einen Ausgabeordner. 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 Logik liegt in batch_folder (ohne Streamlit, daher testbar); hier steht nur die
die Darstellung. build_zugferd_pdf wird als Parameter hereingereicht, weil sie in main.py Darstellung. build_zugferd_pdf wird hereingereicht, weil sie in main.py gecacht definiert ist -
gecacht definiert ist - ein Import waere zirkulaer. ein Import waere zirkulaer.
""" """
import json import json
@ -22,87 +24,43 @@ def _reset_batch():
st.session_state.pop(_STATE_KEY, None) st.session_state.pop(_STATE_KEY, None)
def _load_folder(input_dir): def _make_entry(name, pdf_bytes):
"""Liest alle PDFs des Eingangsordners ein und bewertet sie. Ein Lesefehler bei einer """Analysiert eine PDF und verpackt sie mit ihrer Bewertung. Ein Lesefehler stoppt den
einzelnen Datei stoppt den Stapel nicht - die Datei wird als rot mit Klartext gemeldet.""" Stapel nicht - die Datei wird als zu pruefen mit Klartext gemeldet."""
entries = []
for path in batch_folder.list_input_pdfs(input_dir):
try: try:
data = batch_folder.analyse_pdf(path.read_bytes()) data = batch_folder.analyse_pdf(pdf_bytes)
problems = batch_folder.evaluate_invoice(data, data["computed_net_amount"]) problems = batch_folder.evaluate_invoice(data, data["computed_net_amount"])
except Exception as e: except Exception as e:
data, problems = None, [f"PDF konnte nicht gelesen werden: {e}"] data, problems = None, [f"PDF konnte nicht gelesen werden: {e}"]
entries.append({"path": path, "data": data, "problems": problems, "attachments": []}) return {"name": name, "bytes": pdf_bytes, "data": data,
st.session_state[_STATE_KEY] = entries "problems": problems, "attachments": [], "source": None}
def _assigned_attachment_names(entries): def _render_overview(entries, build_zugferd_pdf, write_to_folder=None):
"""Alle Dateinamen, die als Leistungsnachweis zugeordnet sind - sie sind damit keine """Zuordnung, Ampel und Sammel-Export. write_to_folder ist der Ausgabeordner im
eigenstaendigen Rechnungen mehr.""" Server-Betrieb; ist er None, wird ein ZIP zum Download erzeugt."""
return {name for e in entries for name in e["attachments"]} all_names = [e["name"] for e in entries]
if len(entries) > 1:
def render_batch_mode(build_zugferd_pdf): with st.expander("Leistungsnachweise zuordnen (optional)"):
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( st.caption(
f"Ordnen Sie einer Rechnung bis zu {MAX_ATTACHMENT_PDFS} PDFs aus demselben Ordner zu. " f"Gehoert eine der hochgeladenen PDFs als Nachweis zu einer Rechnung, ordnen Sie "
"Zugeordnete Dateien werden nicht mehr als eigene Rechnung behandelt, sondern hinten " f"sie hier zu (max. {MAX_ATTACHMENT_PDFS} je Rechnung). Zugeordnete Dateien "
"an die gewählte Rechnung gehängt." "zaehlen dann nicht mehr als eigene Rechnung, sondern werden hinten angehaengt."
) )
for entry in entries: for entry in entries:
name = entry["path"].name others = [n for n in all_names if n != entry["name"]]
others = [n for n in all_names if n != name]
chosen = st.multiselect( chosen = st.multiselect(
f"Nachweise für {name}", options=others, key=f"batch_att_{name}", default=[], f"Nachweise für {entry['name']}", options=others,
key=f"batch_att_{entry['name']}",
) )
entry["attachments"] = chosen[:MAX_ATTACHMENT_PDFS] entry["attachments"] = chosen[:MAX_ATTACHMENT_PDFS]
if len(chosen) > MAX_ATTACHMENT_PDFS: if len(chosen) > MAX_ATTACHMENT_PDFS:
st.error(f"Maximal {MAX_ATTACHMENT_PDFS} Nachweise je Rechnung - " st.error(f"Maximal {MAX_ATTACHMENT_PDFS} Nachweise je Rechnung - "
f"die überzähligen werden ignoriert.") "die überzähligen werden ignoriert.")
# --- Uebersicht mit Ampel -------------------------------------------------------- assigned = {n for e in entries for n in e["attachments"]}
assigned = _assigned_attachment_names(entries) invoices = [e for e in entries if e["name"] not in assigned]
invoices = [e for e in entries if e["path"].name not in assigned]
green = [e for e in invoices if not e["problems"]] green = [e for e in invoices if not e["problems"]]
red = [e for e in invoices if 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)) c3.metric("Zu prüfen", len(red))
for entry in invoices: for entry in invoices:
data, problems = entry["data"], entry["problems"] data = entry["data"] or {}
icon = "✅" if not problems else "⚠️" nr = data.get("invoice_number") or "?"
nr = (data or {}).get("invoice_number", "") or "?" empf = data.get("recipient_name") or "?"
empf = (data or {}).get("recipient_name", "") or "?" betrag = f"{data.get('computed_net_amount', 0.0):.2f} €" if entry["data"] else "-"
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 ""
anh = f" +{len(entry['attachments'])} Nachweis(e)" if entry["attachments"] else "" titel = f"{nr} — {empf} — {betrag}{anh}"
if problems: if entry["problems"]:
with st.expander(f"{icon} {nr} - {empf} - {betrag}{anh}", expanded=False): with st.expander(f"⚠️ {titel}"):
for p in problems: for p in entry["problems"]:
st.warning(p) st.warning(p)
st.caption( st.caption(
"Diese Rechnung wird beim Sammel-Export übersprungen. Korrigieren Sie sie im " "Diese Rechnung wird beim Sammel-Export übersprungen. Korrigieren Sie sie in "
"Reiter *Einzelrechnung* - dort stehen alle Felder zur Bearbeitung bereit." "der Betriebsart *Einzelrechnung* - dort stehen alle Felder bereit."
) )
else: else:
st.markdown(f"{icon} **{nr}** - {empf} - {betrag}{anh}") st.markdown(f"✅ **{nr}** — {empf} — {betrag}{anh}")
# --- Sammel-Export ----------------------------------------------------------------
st.markdown("---") st.markdown("---")
if not green: if not green:
st.info("Keine freigabefähige Rechnung - bitte zuerst die markierten Fälle klären.") st.info("Keine freigabefähige Rechnung - bitte zuerst die markierten Fälle klären.")
return return
if st.button(f"🔒 {len(green)} Rechnung(en) exportieren", type="primary", width="stretch", if not st.button(f"🔒 {len(green)} Rechnung(en) konvertieren", type="primary", width="stretch"):
disabled=input_dir == output_dir): return
by_name = {e["path"].name: e["path"] for e in entries}
by_name = {e["name"]: e for e in entries}
progress = st.progress(0.0) progress = st.progress(0.0)
done, failed = [], [] results, failed = [], []
for idx, entry in enumerate(green, start=1): for idx, entry in enumerate(green, start=1):
path, data = entry["path"], entry["data"]
try: try:
pdf_bytes = path.read_bytes() pdf_bytes = entry["bytes"]
if entry["attachments"]: if entry["attachments"]:
extras = [by_name[n].read_bytes() for n in entry["attachments"]] extras = [by_name[n]["bytes"] for n in entry["attachments"]]
pdf_bytes = append_pdf_pages(pdf_bytes, *extras) pdf_bytes = append_pdf_pages(pdf_bytes, *extras)
zugferd = build_zugferd_pdf( zugferd = build_zugferd_pdf(
json.dumps(data, default=str, sort_keys=True), pdf_bytes json.dumps(entry["data"], default=str, sort_keys=True), pdf_bytes
) )
basename = build_export_basename( basename = build_export_basename(
data.get("invoice_number", ""), data.get("recipient_name", ""), entry["data"].get("invoice_number", ""),
data.get("bearbeiter", ""), entry["data"].get("recipient_name", ""),
entry["data"].get("bearbeiter", ""),
) )
written = batch_folder.write_output(output_dir, f"{basename}.pdf", zugferd) results.append((f"{basename}.pdf", zugferd, entry))
# 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: except Exception as e:
failed.append((path.name, str(e))) failed.append((entry["name"], str(e)))
progress.progress(idx / len(green)) progress.progress(idx / len(green))
if done: 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:
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]["source"])
written.append(out.name)
except Exception as e:
failed.append((entry["name"], str(e)))
if written:
st.success( st.success(
f"✅ {len(done)} Rechnung(en) nach `{output_dir}` exportiert. Die Quelldateien " f"✅ {len(written)} Rechnung(en) nach `{write_to_folder}` geschrieben. Die "
f"liegen jetzt im Unterordner `{batch_folder.PROCESSED_SUBDIR}`." f"Quelldateien liegen jetzt im Unterordner `{batch_folder.PROCESSED_SUBDIR}`."
) )
st.caption(" · ".join(done)) 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: if failed:
# Ein Fehlschlag stoppt den Stapel nicht - die betroffene Datei bleibt im # Ein Fehlschlag stoppt den Stapel nicht - die uebrigen laufen durch.
# Eingangsordner und wird hier namentlich gemeldet.
st.error( st.error(
f"❌ {len(failed)} Rechnung(en) konnten nicht exportiert werden und liegen " f"❌ {len(failed)} Rechnung(en) konnten nicht konvertiert werden:\n\n"
"unverändert im Eingangsordner:\n\n"
+ "\n".join(f"- **{n}**: {msg}" for n, msg in failed) + "\n".join(f"- **{n}**: {msg}" for n, msg in failed)
) )
if red: if red:
st.info(f"{len(red)} Rechnung(en) wurden übersprungen, weil sie zu prüfen sind.") st.info(f"{len(red)} Rechnung(en) wurden übersprungen, weil sie zu prüfen sind.")
_reset_batch()
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)