Stapel: PDFs direkt hochladen statt Serverordner, Ergebnis als ZIP.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
a4e9918e23
commit
61fc8c45a6
21
README.md
21
README.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = ""
|
||||||
|
|
|
||||||
276
batch_mode.py
276
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
|
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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue