190 lines
8.2 KiB
Python
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()
|