feat(msp): FileHandler-Abstraktion + Dropzone-Page mit Format-Auto-Detection

Fundament für den geplanten Multi-Format-Import-Assistenten. Die bestehende
Parser/Builder-Infrastruktur bleibt unverändert — die neue Handler-Schicht
ist eine dünne Hülle darüber, die zusätzlich ZIP-Entpacken und Sniff-basierte
Auto-Erkennung erledigt.

Neue Bausteine:
- msp.importers.handlers.base.BaseFileHandler mit sniff() / preview() /
  execute() und Dataclasses PreviewResult / ExecuteResult.
- msp.importers.handlers.registry mit detect_best_handler() (Scoring aller
  registrierten Handler gegen Dateiinhalt + Name, Cutoff bei confidence >= 0.5).
- msp.importers.zip_unpack: entpackt ZIPs mit genau einer CSV in ein Temp-
  Verzeichnis (wie ADN sie monatlich liefert).
- msp.importers.handlers.adn_monthly_csv_handler: erster Handler, wrappt den
  bestehenden ADN-Parser und Orchestrator. Sniff erkennt ADN-Header mit 100%
  Confidence, ZIP-Dateien mit ADN-Namensmuster mit 85% Confidence.

HTTP-Endpoints für die Dropzone-UI (msp.importers.assistant):
- list_handlers(): Metadaten aller Handler — für manuelle Format-Auswahl
  in der UI, wenn Auto-Erkennung fehlschlägt.
- analyze_file(file_url): Detection + DB-freie Vorschau — liefert
  line_count, customer_count, product_count, period_label, format_drift,
  credit_note_lines, suggested_profile.
- process_file(file_url, handler_key, config): führt den Import aus, gibt
  Zusammenfassung + Supplier-Import-Run-Name zurück.

Frappe-Page „Lieferanten-Import" (/app/import-dropzone):
- Drag-&-Drop-Zone für CSV und ZIP, Multi-Upload.
- Pro Datei eine reaktive Karte mit Lifecycle
  (uploading → analyzing → matched/unmatched/error), die ihre Backend-Calls
  selbst steuert (XHR-Upload mit Progress-Bar, anschließend
  msp.importers.assistant.analyze_file).
- Bei Auto-Erkennung: Stats + „Assistent öffnen"-Button. Routet aktuell
  noch auf die bestehende Supplier-Import-Run-Form mit vorbelegten
  Parametern — wird in Phase 4 durch einen eigenen Wizard abgelöst.
- Bei Unmatched: Dropdown mit allen verfügbaren Handlern zur manuellen
  Zuordnung.

End-to-End im Browser validiert: CSV-Upload → Karte mit 100%-Confidence-
Erkennung → 116 Zeilen, 57 Kunden, 11 Produkte, Zeitraum 02.2026–02.2027.
ZIP-Upload funktioniert analog (getestet via Smoke-Skript).
This commit is contained in:
David Malinowski
2026-04-14 21:27:30 +02:00
parent bcafb78fa7
commit 211b68bbb8
10 changed files with 992 additions and 0 deletions
+137
View File
@@ -0,0 +1,137 @@
"""HTTP-Endpoints für die Import-Dropzone-Page.
Drei whitelisted Methoden:
- analyze_file(file_url): Detect handler + DB-freie Vorschau
- process_file(file_url, handler_key, config): Führt den Import aus
- list_handlers(): Metadaten aller registrierten Handler (für manuelle Auswahl)
"""
from __future__ import annotations
import json
import os
import traceback
import frappe
from msp.importers.handlers.registry import all_handlers, detect_best_handler, get as get_handler
@frappe.whitelist()
def list_handlers() -> list[dict]:
"""Metadaten aller registrierten Handler — für manuelle Format-Zuordnung in der UI."""
out = []
for cls in all_handlers():
out.append({
"handler_key": cls.handler_key,
"display_name": cls.display_name,
"accepted_extensions": cls.accepted_extensions,
"wizard_route": cls.wizard_route,
})
return out
@frappe.whitelist()
def analyze_file(file_url: str) -> dict:
"""Erkennt das Format einer hochgeladenen Datei und liefert eine DB-freie Vorschau.
``file_url`` ist die `/private/files/…`- oder `/files/…`-URL des File-Doctypes.
"""
path = _resolve_file_url(file_url)
if not os.path.exists(path):
frappe.throw(f"Datei nicht gefunden: {file_url}")
with open(path, "rb") as f:
sample = f.read(8192)
filename = os.path.basename(path)
best_cls, confidence, scores = detect_best_handler(sample, filename)
result = {
"file_url": file_url,
"filename": filename,
"size": os.path.getsize(path),
"confidence": confidence,
"scores": scores,
"handler_key": None,
"display_name": None,
"wizard_route": None,
"preview": None,
"error": None,
}
if best_cls is None:
result["error"] = "Kein passender Handler — Format nicht erkannt."
return result
result["handler_key"] = best_cls.handler_key
result["display_name"] = best_cls.display_name
result["wizard_route"] = best_cls.wizard_route
try:
preview = best_cls.preview(path)
except Exception as e:
result["error"] = f"Vorschau fehlgeschlagen: {e}"
result["preview_traceback"] = traceback.format_exc(limit=5)
return result
result["preview"] = {
"line_count": preview.line_count,
"customer_count": preview.customer_count,
"product_count": preview.product_count,
"period_label": preview.period_label,
"format_drift": preview.format_drift,
"credit_note_lines": preview.credit_note_lines,
"warnings": preview.warnings,
"suggested_profile": preview.supplier_profile_suggested,
"extra": preview.extra,
}
return result
@frappe.whitelist()
def process_file(file_url: str, handler_key: str,
config: str | dict | None = None) -> dict:
"""Führt den Import mit dem gewählten Handler durch.
``config`` kann als JSON-String (vom Browser) oder als dict übergeben werden.
Typische Felder: profile, output_mode, acknowledge_format_drift.
"""
if isinstance(config, str):
try:
config = json.loads(config or "{}")
except Exception:
frappe.throw("Ungültige config — muss valides JSON sein.")
config = config or {}
handler_cls = get_handler(handler_key)
path = _resolve_file_url(file_url)
if not os.path.exists(path):
frappe.throw(f"Datei nicht gefunden: {file_url}")
result = handler_cls.execute(path, config=config)
return {
"status": result.status,
"run_name": result.run_name,
"document_count": result.document_count,
"line_count": result.line_count,
"error_count": result.error_count,
"skipped_count": result.skipped_count,
"message": result.message,
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _resolve_file_url(file_url: str) -> str:
"""Wandelt `/private/files/...` oder `/files/...` in einen absoluten Dateipfad."""
if not file_url:
frappe.throw("Keine Datei-URL übergeben.")
if os.path.isabs(file_url) and os.path.exists(file_url):
return file_url
site_path = os.path.abspath(frappe.get_site_path())
if file_url.startswith("/private/files/"):
return os.path.join(site_path, "private", "files", file_url.split("/private/files/", 1)[1])
if file_url.startswith("/files/"):
return os.path.join(site_path, "public", "files", file_url.split("/files/", 1)[1])
frappe.throw(f"Unbekanntes Dateipfad-Format: {file_url}")
View File
+30
View File
@@ -0,0 +1,30 @@
"""Dev-Helper: End-to-End-Check für einen Handler (sniff + preview)."""
from __future__ import annotations
import json
from msp.importers.handlers.registry import detect_best_handler
def run(path: str) -> str:
with open(path, "rb") as f:
sample = f.read(8192)
filename = path.rsplit("/", 1)[-1]
best, conf, scores = detect_best_handler(sample, filename)
out = {"filename": filename, "best": None, "confidence": conf, "scores": scores}
if best:
preview = best.preview(path)
out["best"] = {
"handler_key": best.handler_key,
"display_name": best.display_name,
"line_count": preview.line_count,
"customer_count": preview.customer_count,
"product_count": preview.product_count,
"period_label": preview.period_label,
"format_drift": preview.format_drift,
"credit_note_lines": preview.credit_note_lines,
"suggested_profile": preview.supplier_profile_suggested,
"warnings": preview.warnings,
}
return json.dumps(out, default=str, indent=2, ensure_ascii=False)
@@ -0,0 +1,229 @@
"""Handler für ADNs monatliche Rechnungs-CSV (und die ZIP-verpackte Variante).
Wrap-Schicht über dem bestehenden Parser :class:`msp.importers.adn_monthly_csv.ADNMonthlyCSVParser`
und dem Orchestrator. Neue Funktionalität: ZIP-Entpacken, Sniff-basierte Auto-Erkennung,
DB-freie Vorschau.
"""
from __future__ import annotations
import os
import shutil
from typing import Iterable
import frappe
from msp.importers.adn_monthly_csv import EXPECTED_HEADER, ADNMonthlyCSVParser
from msp.importers.base import CanonicalRow
from msp.importers.handlers.base import (
BaseFileHandler,
ExecuteResult,
PreviewResult,
ProgressCallback,
)
from msp.importers.handlers.registry import register
from msp.importers.zip_unpack import is_zip_path, unpack_single_csv
@register
class ADNMonthlyCSVHandler(BaseFileHandler):
handler_key = "adn_monthly_csv_v1"
display_name = "ADN Monthly CSV (Microsoft-Rechnungen)"
accepted_extensions = [".csv", ".zip"]
# ------------------------------------------------------------------
# Sniff
# ------------------------------------------------------------------
_SIGNATURE_COLUMNS = {
"RECHNUNG", "DATUM", "ENDKUNDE", "HERSTELLERNUMMER", "POSITIONSPREIS",
"ENDKUNDE_REFERENCE", "SUBSCRIPTION_ID_EXTERNAL",
}
@classmethod
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.
if name.endswith(".zip"):
if "rechnungen" in name or "433148" in name:
return 0.85
return 0.3 # irgendein ZIP — lieber unbestimmt
if not name.endswith(".csv"):
return 0.0
# CSV-Header inspizieren: erste Zeile (bis 8 KiB reicht)
try:
head = sample_bytes.decode("utf-8", errors="replace")
except Exception:
return 0.0
first_line = head.split("\n", 1)[0].lstrip("\ufeff").strip()
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)
# ------------------------------------------------------------------
# Preview
# ------------------------------------------------------------------
@classmethod
def preview(cls, file_path: str) -> PreviewResult:
csv_path, temp_dir = cls._resolve_csv_path(file_path)
try:
parser = ADNMonthlyCSVParser()
result = parser.parse(csv_path)
customers: set[str] = set()
products: set[str] = set()
period_starts: list = []
period_ends: list = []
credit_lines = 0
for r in result.rows:
if r.customer_external_ref:
customers.add(r.customer_external_ref)
if r.vendor_product_id:
products.add(r.vendor_product_id)
if r.period_start:
period_starts.append(r.period_start)
if r.period_end:
period_ends.append(r.period_end)
if (r.qty or 0) < 0 or (r.amount or 0) < 0 or r.document_type == "Gutschrift":
credit_lines += 1
period_label = cls._format_period_label(period_starts, period_ends)
# Passendes Supplier Import Profile vorschlagen: aktiviertes Profil mit
# unserem parser_key (wenn vorhanden)
suggested_profile = frappe.db.get_value(
"Supplier Import Profile",
{"parser_key": cls.handler_key, "enabled": 1},
"name",
)
return PreviewResult(
supplier_profile_suggested=suggested_profile,
line_count=len(result.rows),
customer_count=len(customers),
product_count=len(products),
period_label=period_label,
format_drift=result.format_drift,
credit_note_lines=credit_lines,
warnings=list(result.warnings),
extra={"invoice_date": str(result.invoice_date) if result.invoice_date else None},
)
finally:
if temp_dir:
shutil.rmtree(temp_dir, ignore_errors=True)
# ------------------------------------------------------------------
# Execute
# ------------------------------------------------------------------
@classmethod
def execute(cls, file_path: str, *, config: dict,
progress: ProgressCallback | None = None) -> ExecuteResult:
csv_path, temp_dir = cls._resolve_csv_path(file_path)
created_run_name: str | None = None
try:
# Pfad zur Website-internen „private files"-URL normalisieren —
# der Supplier Import Run-Orchestrator erwartet das.
site_file_url = cls._ensure_site_file_url(csv_path)
profile_name = config.get("profile") or cls._default_profile_name()
if not profile_name:
return ExecuteResult(status="Failed", message="Kein Profil gefunden/konfiguriert.")
run_doc = frappe.get_doc({
"doctype": "Supplier Import Run",
"profile": profile_name,
"file": site_file_url,
"output_mode": config.get("output_mode") or None,
"acknowledge_format_drift": 1 if config.get("acknowledge_format_drift") else 0,
})
run_doc.insert(ignore_permissions=True)
created_run_name = run_doc.name
frappe.db.commit()
if progress:
progress({"phase": "starting", "run": created_run_name})
from msp.importers.run_orchestrator import execute_run
summary = execute_run(run_doc)
return ExecuteResult(
status=summary.get("status", "Failed"),
run_name=created_run_name,
document_count=summary.get("document_count", 0),
line_count=summary.get("line_count", 0),
error_count=summary.get("error_count", 0),
skipped_count=summary.get("skipped_count", 0),
)
finally:
if temp_dir:
shutil.rmtree(temp_dir, ignore_errors=True)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@classmethod
def _resolve_csv_path(cls, file_path: str) -> tuple[str, str | None]:
"""Wenn ``file_path`` ein ZIP ist, entpacken und Pfad der CSV zurückgeben.
Rückgabe: (csv_path, temp_dir_to_clean_up_or_None)."""
if is_zip_path(file_path):
temp_dir, csv_path = unpack_single_csv(file_path)
return csv_path, temp_dir
return file_path, None
@staticmethod
def _format_period_label(starts: Iterable, ends: Iterable) -> str | None:
starts = [d for d in starts if d]
ends = [d for d in ends if d]
if not (starts and ends):
return None
min_start = min(starts)
max_end = max(ends)
if min_start.strftime("%Y-%m") == max_end.strftime("%Y-%m"):
return min_start.strftime("%m.%Y")
return f"{min_start.strftime('%m.%Y')} – {max_end.strftime('%m.%Y')}"
@staticmethod
def _default_profile_name() -> str | None:
return frappe.db.get_value(
"Supplier Import Profile",
{"parser_key": "adn_monthly_csv_v1", "enabled": 1},
"name",
)
@staticmethod
def _ensure_site_file_url(csv_path: str) -> str:
"""Gibt den /private/files/...-Pfad zurück, den der Orchestrator erwartet.
Wenn die CSV außerhalb des private-files-Verzeichnisses liegt (z. B. nach
ZIP-Entpacken in /tmp), wird eine Kopie nach private/files/ angelegt und
dieser Pfad zurückgegeben, damit das Supplier Import Run-File-Feld
auflösbar ist.
"""
import shutil as _shutil
site_path = os.path.abspath(frappe.get_site_path())
private_files = os.path.join(site_path, "private", "files")
csv_abs = os.path.abspath(csv_path)
if csv_abs.startswith(private_files + os.sep):
relative = csv_abs[len(private_files) + 1:]
return f"/private/files/{relative}"
# Kopie nach private/files/ mit Original-Dateinamen
basename = os.path.basename(csv_abs)
target = os.path.join(private_files, basename)
if not os.path.exists(target):
_shutil.copyfile(csv_abs, target)
return f"/private/files/{basename}"
+99
View File
@@ -0,0 +1,99 @@
"""FileHandler-Abstraktion für den Import-Assistenten.
Ein ``FileHandler`` weiß, ob er eine hochgeladene Datei verarbeiten kann
(``sniff``), liefert eine DB-freie Vorschau (``preview``) und kann den
eigentlichen Import ausführen (``execute``). Er ist die Einheit, die der
Dropzone-/Assistent-UI zugrunde liegt.
Bestehende Parser (``msp.importers.base.BaseSupplierParser``) bleiben
darunter erhalten — ein Handler nutzt sie als innere Mechanik.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Callable
ProgressCallback = Callable[[dict], None]
@dataclass
class PreviewResult:
"""Leichtgewichtige Statistik für die Dropzone-Karte."""
supplier_profile_suggested: str | None = None # Supplier Import Profile-Name
line_count: int = 0
customer_count: int = 0
product_count: int = 0
period_label: str | None = None # z. B. „03.2026" oder „09/2025 – 02/2026"
format_drift: bool = False
credit_note_lines: int = 0
warnings: list[str] = field(default_factory=list)
extra: dict = field(default_factory=dict)
@dataclass
class ExecuteResult:
"""Zusammenfassung eines fertigen Imports."""
status: str # Succeeded / Partial / Failed
run_name: str | None = None # Supplier Import Run-Name, falls angelegt
document_count: int = 0
line_count: int = 0
error_count: int = 0
skipped_count: int = 0
message: str | None = None
class BaseFileHandler:
"""Basisklasse für alle Datei-Handler.
Subklassen registrieren sich über :func:`msp.importers.handlers.registry.register`.
"""
#: Eindeutiger Schlüssel, unter dem der Handler in der Registry liegt.
handler_key: str = ""
#: Menschlich lesbarer Name (für UI-Karten).
display_name: str = ""
#: Welche Dateiendungen der Handler prinzipiell akzeptiert (kleingeschrieben, mit Punkt).
accepted_extensions: list[str] = []
#: Optionaler abweichender Wizard-Pfad. ``None`` ⇒ Default-Wizard wird genutzt.
wizard_route: str | None = None
# ------------------------------------------------------------------
# Abstrakte Methoden — in Subklassen überschreiben
# ------------------------------------------------------------------
@classmethod
def sniff(cls, sample_bytes: bytes, filename: str) -> float:
"""Gibt Confidence 0.0–1.0 zurück, dass dieser Handler die Datei verarbeiten kann.
``sample_bytes`` enthält die ersten ~8 KiB der (ggf. schon entpackten) Datei.
``filename`` ist der ursprüngliche Dateiname (hilfreich bei Extension-Matching).
"""
raise NotImplementedError
@classmethod
def preview(cls, file_path: str) -> PreviewResult:
"""Leichte Analyse ohne DB-Writes. Wird vom Dropzone-Analyze-Endpoint aufgerufen."""
raise NotImplementedError
@classmethod
def execute(cls, file_path: str, *, config: dict,
progress: ProgressCallback | None = None) -> ExecuteResult:
"""Führt den tatsächlichen Import durch.
``config`` enthält mindestens: ``profile`` (Supplier Import Profile-Name),
``output_mode`` (leer / "Sales Invoice" / "Delivery Note"),
``acknowledge_format_drift`` (bool). Weitere Felder je Handler.
``progress`` wird (falls gegeben) regelmäßig mit Fortschritts-Dicts aufgerufen,
z. B. ``{"phase": "importing", "processed": 47, "total": 113}``.
"""
raise NotImplementedError
__all__ = ["BaseFileHandler", "PreviewResult", "ExecuteResult", "ProgressCallback"]
+68
View File
@@ -0,0 +1,68 @@
"""Registry und Dispatch der FileHandler."""
from __future__ import annotations
from typing import Type
from msp.importers.handlers.base import BaseFileHandler
_HANDLERS: dict[str, Type[BaseFileHandler]] = {}
def register(handler_cls: Type[BaseFileHandler]) -> Type[BaseFileHandler]:
key = getattr(handler_cls, "handler_key", "")
if not key:
raise ValueError(f"{handler_cls.__name__} ohne handler_key kann nicht registriert werden")
if key in _HANDLERS:
raise ValueError(f"handler_key bereits belegt: {key}")
_HANDLERS[key] = handler_cls
return handler_cls
def get(handler_key: str) -> Type[BaseFileHandler]:
_autoload()
if handler_key not in _HANDLERS:
raise KeyError(f"Kein Handler mit key='{handler_key}' registriert. "
f"Verfügbar: {sorted(_HANDLERS)}")
return _HANDLERS[handler_key]
def all_handlers() -> list[Type[BaseFileHandler]]:
_autoload()
return list(_HANDLERS.values())
def detect_best_handler(sample_bytes: bytes, filename: str,
*, min_confidence: float = 0.5,
) -> tuple[Type[BaseFileHandler] | None, float, list[dict]]:
"""Sniff alle Handler, liefert besten Match + Score + komplette Ranking-Liste.
Rückgabe:
``(best_handler_cls | None, best_confidence, scores)`` — best_handler_cls ist
``None``, wenn keiner über ``min_confidence`` liegt.
``scores`` ist eine nach Confidence absteigend sortierte Liste
``[{"handler_key": ..., "display_name": ..., "confidence": ...}]``.
"""
_autoload()
scores = []
for cls in _HANDLERS.values():
try:
c = float(cls.sniff(sample_bytes, filename) or 0)
except Exception:
c = 0.0
scores.append({
"handler_key": cls.handler_key,
"display_name": cls.display_name,
"confidence": c,
})
scores.sort(key=lambda x: x["confidence"], reverse=True)
top = scores[0] if scores else None
best_cls = _HANDLERS.get(top["handler_key"]) if top and top["confidence"] >= min_confidence else None
best_conf = top["confidence"] if top else 0.0
return best_cls, best_conf, scores
def _autoload() -> None:
"""Importiert bekannte Handler-Module, damit @register-Dekoratoren laufen."""
from msp.importers.handlers import adn_monthly_csv_handler # noqa: F401
+53
View File
@@ -0,0 +1,53 @@
"""Helper zum Entpacken von ZIPs, die genau eine CSV enthalten.
ADN liefert die Monatsrechnungen gezippt (eine CSV pro ZIP). Dieser Helper
extrahiert die CSV in ein Temp-Verzeichnis und gibt den Pfad zurück. Aufrufer
sind dafür verantwortlich, das Temp-Verzeichnis nach Verarbeitung wieder
aufzuräumen.
"""
from __future__ import annotations
import os
import tempfile
import zipfile
from pathlib import Path
class ZipUnpackError(Exception):
pass
def is_zip_path(path: str) -> bool:
return path.lower().endswith(".zip")
def unpack_single_csv(zip_path: str) -> tuple[str, str]:
"""Entpackt ein ZIP in ein Temp-Verzeichnis und erwartet genau eine CSV drin.
Rückgabe: ``(temp_dir_path, csv_file_path)``. Aufrufer muss ``temp_dir_path``
aufräumen (z. B. ``shutil.rmtree(temp_dir_path)``).
"""
if not zipfile.is_zipfile(zip_path):
raise ZipUnpackError(f"Keine gültige ZIP-Datei: {zip_path}")
with zipfile.ZipFile(zip_path, "r") as z:
csv_members = [m for m in z.namelist()
if m.lower().endswith(".csv") and not m.endswith("/")]
if len(csv_members) == 0:
raise ZipUnpackError("ZIP enthält keine CSV-Datei.")
if len(csv_members) > 1:
raise ZipUnpackError(
f"ZIP enthält {len(csv_members)} CSV-Dateien; genau eine wird erwartet. "
f"Gefunden: {csv_members[:3]}{' …' if len(csv_members) > 3 else ''}"
)
member = csv_members[0]
temp_dir = tempfile.mkdtemp(prefix="msp_import_unzip_")
z.extract(member, temp_dir)
# Normalisieren: Mitglieder-Pfad kann Unterverzeichnisse enthalten
extracted = os.path.join(temp_dir, member)
# Falls ZIP ein führendes Verzeichnis hatte, in den temp_dir-root mappen
final_path = os.path.join(temp_dir, Path(member).name)
if extracted != final_path:
os.replace(extracted, final_path)
return temp_dir, final_path
@@ -0,0 +1,349 @@
/**
* Frappe-Page „Lieferanten-Import" — Drag-&-Drop-Dropzone für CSV/ZIP mit
* automatischer Format-Erkennung.
*
* Pro Datei: Upload → Analyse (msp.importers.assistant.analyze_file) →
* Karte mit Statistik und „Assistent öffnen"-Button. Unbekannte Formate
* bieten manuelle Handler-Auswahl an.
*/
frappe.pages["import-dropzone"].on_page_load = function (wrapper) {
const page = frappe.ui.make_app_page({
parent: wrapper,
title: __("Lieferanten-Import"),
single_column: true,
});
// Frappe übergibt ``page.main`` als jQuery-Wrapper — wir brauchen das native Element.
const root = page.main[0] || page.main;
root.innerHTML = `
<div class="msp-dropzone-page">
<div class="msp-dropzone" tabindex="0">
<div class="msp-dropzone__icon">⬆</div>
<div class="msp-dropzone__label">${__("Dateien per Drag & Drop ablegen oder")}
<button class="btn btn-primary btn-sm msp-dropzone__browse">
${__("Datei auswählen")}
</button>
</div>
<div class="msp-dropzone__hint">
${__("Unterstützt: CSV · ZIP (mit einer CSV)")}
</div>
<input type="file" class="msp-dropzone__input" multiple hidden
accept=".csv,.zip,application/zip,application/x-zip-compressed,text/csv" />
</div>
<div class="msp-dropzone-cards"></div>
</div>
`;
// Styles inline — spart einen zusätzlichen Bundle-Step.
const style = document.createElement("style");
style.textContent = `
.msp-dropzone-page { padding: 10px; }
.msp-dropzone {
border: 2px dashed var(--border-color);
border-radius: 8px;
padding: 40px 24px;
text-align: center;
transition: background-color .15s, border-color .15s;
cursor: pointer;
background: var(--bg-color);
}
.msp-dropzone.is-dragover {
border-color: var(--primary);
background: var(--bg-light-gray);
}
.msp-dropzone__icon { font-size: 42px; opacity: .6; margin-bottom: 8px; }
.msp-dropzone__label { font-size: 15px; margin-bottom: 6px; }
.msp-dropzone__hint { color: var(--text-muted); font-size: 12px; }
.msp-dropzone__browse { margin-left: 6px; }
.msp-dropzone-cards {
display: flex; flex-direction: column; gap: 12px;
margin-top: 20px;
}
.msp-card {
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 14px 16px;
background: var(--card-bg, var(--fg-color));
display: flex; gap: 16px; align-items: flex-start;
}
.msp-card__icon {
width: 40px; height: 40px; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
font-size: 18px; flex: 0 0 40px;
background: var(--bg-light-gray);
}
.msp-card__body { flex: 1; min-width: 0; }
.msp-card__title { font-weight: 600; margin-bottom: 2px; display: flex; gap: 8px; align-items: baseline; }
.msp-card__subtitle { color: var(--text-muted); font-size: 12px; }
.msp-card__stats { margin-top: 8px; font-size: 13px; color: var(--text-color); }
.msp-card__stats .stat { display: inline-block; margin-right: 14px; }
.msp-card__stats .stat strong { color: var(--heading-color); }
.msp-card__warn { color: var(--orange-500); font-size: 12px; margin-top: 6px; }
.msp-card__error { color: var(--red-600); font-size: 12px; margin-top: 6px; white-space: pre-wrap; }
.msp-card__actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.msp-card--uploading, .msp-card--analyzing { opacity: .85; }
.msp-card--matched { border-left: 3px solid var(--green-500); }
.msp-card--unmatched { border-left: 3px solid var(--orange-500); }
.msp-card--error { border-left: 3px solid var(--red-500); }
`;
root.appendChild(style);
const dz = root.querySelector(".msp-dropzone");
const cards = root.querySelector(".msp-dropzone-cards");
const fileInput = root.querySelector(".msp-dropzone__input");
// Drag & Drop
["dragenter", "dragover"].forEach((ev) =>
dz.addEventListener(ev, (e) => {
e.preventDefault();
e.stopPropagation();
dz.classList.add("is-dragover");
})
);
["dragleave", "drop"].forEach((ev) =>
dz.addEventListener(ev, (e) => {
e.preventDefault();
e.stopPropagation();
dz.classList.remove("is-dragover");
})
);
dz.addEventListener("drop", (e) => handle_files(e.dataTransfer.files));
dz.addEventListener("click", (e) => {
if (e.target.classList.contains("msp-dropzone__browse") || e.target === dz) {
fileInput.click();
}
});
fileInput.addEventListener("change", (e) => handle_files(e.target.files));
function handle_files(file_list) {
if (!file_list || !file_list.length) return;
for (const file of file_list) {
process_one_file(file);
}
}
// Für manuelle Zuordnung: Liste aller Handler beim Seitenaufruf laden
let HANDLERS = [];
frappe.call({
method: "msp.importers.assistant.list_handlers",
callback: (r) => (HANDLERS = r.message || []),
});
async function process_one_file(file) {
const card = make_card(file);
cards.prepend(card.el);
try {
card.set_state("uploading");
const file_url = await upload_file(file, (pct) => card.set_progress(pct));
card.set_state("analyzing");
const analysis = await frappe.call({
method: "msp.importers.assistant.analyze_file",
args: { file_url },
});
render_analysis(card, analysis.message, file_url);
} catch (err) {
console.error(err);
card.set_error(err && err.message ? err.message : String(err));
}
}
function make_card(file) {
const el = document.createElement("div");
el.className = "msp-card msp-card--uploading";
el.innerHTML = `
<div class="msp-card__icon">${icon_for(file.name)}</div>
<div class="msp-card__body">
<div class="msp-card__title">
<span class="name"></span>
<span class="msp-card__subtitle"></span>
</div>
<div class="msp-card__state"><span class="spinner">⏳</span> ${__("Warte …")}</div>
<div class="msp-card__stats" hidden></div>
<div class="msp-card__warn" hidden></div>
<div class="msp-card__error" hidden></div>
<div class="msp-card__actions" hidden></div>
</div>
`;
el.querySelector(".name").textContent = file.name;
el.querySelector(".msp-card__subtitle").textContent = format_size(file.size);
return {
el,
set_state(state, text) {
el.classList.remove(
"msp-card--uploading", "msp-card--analyzing",
"msp-card--matched", "msp-card--unmatched", "msp-card--error"
);
el.classList.add(`msp-card--${state}`);
const state_el = el.querySelector(".msp-card__state");
const label = {
uploading: __("Lade hoch …"),
analyzing: __("Analysiere …"),
}[state] || text || "";
if (label) state_el.innerHTML = `<span class="spinner">⏳</span> ${label}`;
else state_el.innerHTML = "";
},
set_progress(pct) {
el.querySelector(".msp-card__state").innerHTML =
`<span class="spinner">⏳</span> ${__("Lade hoch …")} ${Math.round(pct)}%`;
},
set_error(msg) {
el.classList.add("msp-card--error");
el.querySelector(".msp-card__state").innerHTML = `❌ ${__("Fehler")}`;
const err_el = el.querySelector(".msp-card__error");
err_el.hidden = false;
err_el.textContent = msg;
},
};
}
function render_analysis(card, result, file_url) {
const el = card.el;
const state_el = el.querySelector(".msp-card__state");
const stats_el = el.querySelector(".msp-card__stats");
const warn_el = el.querySelector(".msp-card__warn");
const err_el = el.querySelector(".msp-card__error");
const actions_el = el.querySelector(".msp-card__actions");
if (result.error) {
card.set_error(result.error);
return;
}
if (!result.handler_key) {
// Format nicht erkannt — manuelle Zuordnung ermöglichen
el.classList.remove("msp-card--analyzing");
el.classList.add("msp-card--unmatched");
state_el.innerHTML = `⚠ ${__("Format nicht erkannt")}`;
warn_el.hidden = false;
warn_el.textContent = __(
"Keiner der verfügbaren Handler konnte die Datei zuverlässig erkennen. Bitte manuell zuordnen:"
);
actions_el.hidden = false;
actions_el.innerHTML = `
<select class="form-control form-control-sm" style="max-width: 280px;">
<option value="">${__("Handler wählen …")}</option>
${HANDLERS.map((h) =>
`<option value="${h.handler_key}">${frappe.utils.escape_html(h.display_name)}</option>`
).join("")}
</select>
<button class="btn btn-default btn-sm btn-open-assistant" disabled>
${__("Assistent öffnen")}
</button>
`;
const select = actions_el.querySelector("select");
const btn = actions_el.querySelector(".btn-open-assistant");
select.addEventListener("change", () => {
btn.disabled = !select.value;
});
btn.addEventListener("click", () => open_assistant(file_url, select.value));
return;
}
// Erkannt
el.classList.remove("msp-card--analyzing");
el.classList.add("msp-card--matched");
state_el.innerHTML = `✓ ${__("Erkannt")}: <strong>${frappe.utils.escape_html(result.display_name)}</strong>`
+ ` <span class="msp-card__subtitle">(${Math.round(result.confidence * 100)}%)</span>`;
const preview = result.preview || {};
const stat_parts = [];
if (preview.line_count != null) stat_parts.push(`<span class="stat"><strong>${preview.line_count}</strong> ${__("Zeilen")}</span>`);
if (preview.customer_count != null) stat_parts.push(`<span class="stat"><strong>${preview.customer_count}</strong> ${__("Kunden")}</span>`);
if (preview.product_count != null) stat_parts.push(`<span class="stat"><strong>${preview.product_count}</strong> ${__("Produkte")}</span>`);
if (preview.period_label) stat_parts.push(`<span class="stat">${frappe.utils.escape_html(preview.period_label)}</span>`);
if (preview.credit_note_lines) stat_parts.push(`<span class="stat">${preview.credit_note_lines} ${__("Gutschriftzeilen")}</span>`);
stats_el.hidden = false;
stats_el.innerHTML = stat_parts.join(" ");
const warnings = preview.warnings || [];
if (preview.format_drift) warnings.unshift(__("Format weicht vom erwarteten Layout ab"));
if (warnings.length) {
warn_el.hidden = false;
warn_el.innerHTML = warnings.map((w) => `⚠ ${frappe.utils.escape_html(w)}`).join("<br>");
}
actions_el.hidden = false;
actions_el.innerHTML = `
<button class="btn btn-primary btn-sm btn-open-assistant">
${__("Assistent öffnen")} →
</button>
`;
actions_el
.querySelector(".btn-open-assistant")
.addEventListener("click", () =>
open_assistant(file_url, result.handler_key, {
wizard_route: result.wizard_route,
format_drift: preview.format_drift,
suggested_profile: preview.suggested_profile,
})
);
}
function open_assistant(file_url, handler_key, opts = {}) {
// Phase 4: eigenes Assistent-Routing (/import-assistant/<handler>).
// Bis das da ist: öffne den Supplier Import Run mit vorbelegtem File + Profil.
const route_base = opts.wizard_route || "import-assistant";
if (route_base === "import-assistant") {
// Aktuell als Sprung auf die bestehende Run-Form mit ?file=…&profile=…
const params = new URLSearchParams({ file: file_url });
if (opts.suggested_profile) params.set("profile", opts.suggested_profile);
if (opts.format_drift) params.set("acknowledge_format_drift", "1");
const url = `/app/supplier-import-run/new?${params.toString()}`;
window.location.href = url;
} else {
// Spezial-Wizard-Routen (Future): frappe.set_route(...)
frappe.set_route(opts.wizard_route, file_url, handler_key);
}
}
async function upload_file(file, on_progress) {
return new Promise((resolve, reject) => {
const form = new FormData();
form.append("file", file, file.name);
form.append("is_private", "1");
form.append("folder", "Home");
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/method/upload_file");
xhr.setRequestHeader("X-Frappe-CSRF-Token", frappe.csrf_token);
xhr.upload.onprogress = (e) => {
if (on_progress && e.lengthComputable) on_progress((e.loaded / e.total) * 100);
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const json = JSON.parse(xhr.responseText);
const file_url = (json.message || {}).file_url;
if (!file_url) return reject(new Error("Upload: keine file_url in Antwort"));
resolve(file_url);
} catch (e) {
reject(e);
}
} else {
reject(new Error(`Upload fehlgeschlagen (HTTP ${xhr.status})`));
}
};
xhr.onerror = () => reject(new Error("Upload-Netzwerkfehler"));
xhr.send(form);
});
}
function format_size(bytes) {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / 1024 / 1024).toFixed(1) + " MB";
}
function icon_for(filename) {
const name = (filename || "").toLowerCase();
if (name.endsWith(".zip")) return "🗜️";
if (name.endsWith(".csv")) return "📄";
if (name.endsWith(".xlsx") || name.endsWith(".xls")) return "📊";
return "📎";
}
};
@@ -0,0 +1,27 @@
{
"content": null,
"creation": "2026-04-14 20:00:00.000000",
"docstatus": 0,
"doctype": "Page",
"icon": "octicon octicon-cloud-upload",
"idx": 0,
"modified": "2026-04-14 20:00:00.000000",
"modified_by": "Administrator",
"module": "MSP",
"name": "import-dropzone",
"owner": "Administrator",
"page_name": "import-dropzone",
"roles": [
{
"role": "System Manager"
},
{
"role": "Accounts Manager"
}
],
"script": null,
"standard": "Yes",
"style": null,
"system_page": 0,
"title": "Lieferanten-Import"
}