fix(msp): Robustheits-Fixes nach Browser-Testrunde (Dropzone + Wizard)

10 End-to-End-Testszenarien durchgespielt (Drag&Drop, ZIP, Multi-File,
Idempotenz, Unmatched, Error, Doc-Link, Reload-ohne-Kontext, Format-Drift,
Gutschriften). 3 echte Bugs gefunden und behoben, 2 UX-Verbesserungen
ergänzt.

Fixes:

- ZIP-Auto-Detection war zu restriktiv: sniff() hat sich auf Namens-
  Heuristik verlassen (confidence 0.85 nur bei 'rechnungen'/'433148' im
  Dateinamen, sonst 0.3 → unter Cutoff). Umbenannte ZIPs wurden nicht
  erkannt. Fix: ZIP in-memory öffnen, erste CSV-Zeile lesen,
  Signatur-Spalten matchen — gleiche Logik wie bei direkten CSVs.
  Zusätzlich liest analyze_file bei ZIPs die komplette Datei (ZIP
  Central Directory steht am Ende und passt oft nicht in 8 KiB).

- Unmatched-Dateien landeten im Error-State statt im Dropdown-Flow.
  Backend hat error="Kein passender Handler …" gesetzt, Frontend
  triggerte dadurch set_error(). Fix: analyze_file gibt für Unmatched
  jetzt error=null zurück, handler_key=null — das Frontend bietet den
  Dropdown mit manueller Handler-Zuordnung an (war schon vorhanden,
  wurde aber nie erreicht).

- Fehler-Meldungen im Wizard-Ergebnis zeigten "[object Object]" statt
  Text. Frappe-RPC-Errors liefern die Nachricht in _server_messages
  als JSON-String. Fix: extract_error_message() parst das sauber aus
  (mit Fallback auf exception / message / JSON-Stringify).

UX-Verbesserungen:

- Drift-Checkbox ("Abweichendes Format akzeptieren") ist jetzt immer
  sichtbar, nicht nur wenn die Analyse Drift erkannt hat. Hilfreich
  beim manuellen Handler-Zuordnungsflow, wenn der User eine Datei
  trotz Parser-Ablehnung forcen will. Der Hinweistext passt sich dem
  Kontext an.

- Wizard lädt bei fehlender Preview (z. B. Aufruf ohne Dropzone-
  Voranalyse) selbst analyze_file, um Stats und Format-Drift-Status
  nachzuziehen. Kein "ohne Stats"-Leerzustand mehr im Standardpfad.

Testresultate im Überblick (alle 10 ✓):
  1. Drag & Drop: Karte erscheint, 100% Confidence
  2. ZIP: nach Fix 100% Confidence (vorher 0)
  3. Multi-File (4 parallel): alle Karten nebeneinander
  4. Idempotenz: 0 Docs, 116 Skipped, Status Succeeded
  5. Unmatched-Flow: Dropdown + disabled Button bis Handler gewählt
  6. Broken file: ❌-Karte, keine Crashes
  7. Doc-Link: öffnet SINV-271766 als Formular
  8. Reload ohne Kontext: "Kein Kontext" + Link zur Dropzone
  9. Format-Drift: Drift-Checkbox aktivierbar, nach Bestätigen läuft
     Import durch
 10. Gutschrift: 2 Credit-Note-Zeilen → negative DN (−101,95 €) als
     eigenes Dokument in Ergebnistabelle
This commit is contained in:
David Malinowski
2026-04-14 21:55:51 +02:00
parent bb76afd19f
commit a2d11476d1
3 changed files with 89 additions and 13 deletions
+6 -2
View File
@@ -41,8 +41,11 @@ def analyze_file(file_url: str) -> dict:
if not os.path.exists(path):
frappe.throw(f"Datei nicht gefunden: {file_url}")
# ZIPs müssen komplett gelesen werden (Central Directory steht am Ende);
# bei CSVs reichen 8 KiB für den Header.
read_full = path.lower().endswith(".zip")
with open(path, "rb") as f:
sample = f.read(8192)
sample = f.read() if read_full else f.read(8192)
filename = os.path.basename(path)
best_cls, confidence, scores = detect_best_handler(sample, filename)
@@ -59,7 +62,8 @@ def analyze_file(file_url: str) -> dict:
"error": None,
}
if best_cls is None:
result["error"] = "Kein passender Handler — Format nicht erkannt."
# Kein Fehler — sondern „Unmatched": Frontend soll eine manuelle
# Handler-Zuordnung über das Dropdown anbieten. error bleibt null.
return result
result["handler_key"] = best_cls.handler_key
@@ -44,13 +44,17 @@ class ADNMonthlyCSVHandler(BaseFileHandler):
def sniff(cls, sample_bytes: bytes, filename: str) -> float:
name = (filename or "").lower()
# ZIP-Dateien können wir nur nach Extension & Namensheuristik erkennen —
# der tatsächliche Header kommt erst nach dem Entpacken. Hohe Confidence
# bei typischen ADN-Dateinamen, moderate sonst.
# ZIP: Inhalt auspacken und die erste CSV-Kopfzeile prüfen. So funktioniert
# die Erkennung auch bei umbenannten ZIPs.
if name.endswith(".zip"):
if "rechnungen" in name or "433148" in name:
return 0.85
return 0.3 # irgendein ZIP — lieber unbestimmt
first_line = cls._peek_first_line_in_zip(sample_bytes)
if first_line is None:
# Kein CSV im ZIP zu sehen (vielleicht verschlüsselt oder zu groß
# im Sample) — greifen zurück auf Namens-Heuristik.
if "rechnungen" in name or "433148" in name:
return 0.65
return 0.0
return cls._score_first_line(first_line)
if not name.endswith(".csv"):
return 0.0
@@ -61,15 +65,42 @@ class ADNMonthlyCSVHandler(BaseFileHandler):
except Exception:
return 0.0
first_line = head.split("\n", 1)[0].lstrip("\ufeff").strip()
first_line = head.split("\n", 1)[0]
return cls._score_first_line(first_line)
@classmethod
def _score_first_line(cls, first_line: str) -> float:
first_line = (first_line or "").lstrip("\ufeff").strip()
if not first_line:
return 0.0
cols = {c.strip().upper() for c in first_line.split(";")}
matching = cls._SIGNATURE_COLUMNS & cols
if not matching:
return 0.0
ratio = len(matching) / len(cls._SIGNATURE_COLUMNS)
# ratio=1.0 ⇒ alle Signatur-Spalten drin, perfekter Match
return min(1.0, 0.4 + 0.6 * ratio)
@classmethod
def _peek_first_line_in_zip(cls, sample_bytes: bytes) -> str | None:
"""Öffnet das ZIP in-memory und gibt die erste Zeile der enthaltenen CSV
zurück. None, wenn keine CSV gefunden oder der Sample zu klein ist."""
import io
import zipfile
if not sample_bytes:
return None
try:
with zipfile.ZipFile(io.BytesIO(sample_bytes)) as z:
csvs = [m for m in z.namelist()
if m.lower().endswith(".csv") and not m.endswith("/")]
if not csvs:
return None
with z.open(csvs[0]) as fh:
head = fh.read(2048).decode("utf-8", errors="replace")
return head.split("\n", 1)[0]
except (zipfile.BadZipFile, EOFError, KeyError):
return None
# ------------------------------------------------------------------
# Preview
# ------------------------------------------------------------------
@@ -35,6 +35,26 @@ frappe.pages["import-assistant"].on_page_load = function (wrapper) {
}
inject_style(root);
// Falls keine Preview mitgereicht wurde (z. B. bei manueller Handler-Zuordnung
// in der Dropzone), hole sie jetzt nach — damit Stats angezeigt und die
// Drift-Checkbox korrekt positioniert werden kann.
if (!ctx.preview) {
root.innerHTML = `<div class="msp-assistant-empty">${__("Analysiere Datei …")}</div>`;
frappe.call({
method: "msp.importers.assistant.analyze_file",
args: { file_url: ctx.file_url },
callback: (r) => {
const m = r.message || {};
if (m.preview) ctx.preview = m.preview;
if (m.display_name) ctx.display_name = ctx.display_name || m.display_name;
if (m.preview && m.preview.format_drift) ctx.format_drift = true;
render_page(root, ctx, page);
},
});
return;
}
render_page(root, ctx, page);
};
@@ -133,13 +153,17 @@ function render_config_panel(ctx) {
</select>
<div class="hint">${__("Leer = der Customer.billing_mode entscheidet pro Kunde.")}</div>
</div>
<div class="form-row field-drift-wrapper" ${ctx.format_drift ? "" : "hidden"}>
<div class="form-row field-drift-wrapper">
<label>
<input type="checkbox" class="field-ack-drift"
${ctx.format_drift ? "checked" : ""} />
${__("Abweichendes Format akzeptieren")}
</label>
<div class="hint warn">${__("Die Analyse hat ein abweichendes CSV-Layout erkannt. Nur aktivieren, wenn du die Quelle kennst.")}</div>
<div class="hint ${ctx.format_drift ? "warn" : ""}">
${ctx.format_drift
? __("Die Analyse hat ein abweichendes CSV-Layout erkannt. Nur aktivieren, wenn du die Quelle kennst.")
: __("Aktivieren, falls der Parser die Datei ablehnt, obwohl sie inhaltlich passt.")}
</div>
</div>
</div>
<footer>
@@ -205,11 +229,28 @@ async function kick_off_import(root, state, ctx) {
state.result = response.message || {};
show_result(root, state, ctx);
} catch (err) {
state.result = { status: "Failed", message: (err && err.message) || String(err) };
state.result = { status: "Failed", message: extract_error_message(err) };
show_result(root, state, ctx);
}
}
function extract_error_message(err) {
if (!err) return "Unbekannter Fehler";
// Frappe-RPC-Errors kommen als { exc, exception, _server_messages, ... }
if (err._server_messages) {
try {
const msgs = JSON.parse(err._server_messages);
const first = Array.isArray(msgs) ? msgs[0] : msgs;
const parsed = typeof first === "string" ? JSON.parse(first) : first;
if (parsed && parsed.message) return parsed.message;
} catch (_) { /* fallthrough */ }
}
if (typeof err === "string") return err;
if (err.exception) return String(err.exception);
if (err.message && typeof err.message === "string") return err.message;
try { return JSON.stringify(err); } catch (_) { return String(err); }
}
function show_result(root, state, ctx) {
set_step(root, 3);
const r = state.result || {};