E-Rechnung/batch_mode.py

190 lines
8.2 KiB
Python

"""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()