diff --git a/msp/importers/assistant.py b/msp/importers/assistant.py new file mode 100644 index 0000000..55bb52b --- /dev/null +++ b/msp/importers/assistant.py @@ -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}") diff --git a/msp/importers/handlers/__init__.py b/msp/importers/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/msp/importers/handlers/_handler_smoke.py b/msp/importers/handlers/_handler_smoke.py new file mode 100644 index 0000000..33881b7 --- /dev/null +++ b/msp/importers/handlers/_handler_smoke.py @@ -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) diff --git a/msp/importers/handlers/adn_monthly_csv_handler.py b/msp/importers/handlers/adn_monthly_csv_handler.py new file mode 100644 index 0000000..4c11858 --- /dev/null +++ b/msp/importers/handlers/adn_monthly_csv_handler.py @@ -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}" diff --git a/msp/importers/handlers/base.py b/msp/importers/handlers/base.py new file mode 100644 index 0000000..dccbe61 --- /dev/null +++ b/msp/importers/handlers/base.py @@ -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"] diff --git a/msp/importers/handlers/registry.py b/msp/importers/handlers/registry.py new file mode 100644 index 0000000..45745cf --- /dev/null +++ b/msp/importers/handlers/registry.py @@ -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 diff --git a/msp/importers/zip_unpack.py b/msp/importers/zip_unpack.py new file mode 100644 index 0000000..58d6a31 --- /dev/null +++ b/msp/importers/zip_unpack.py @@ -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 diff --git a/msp/msp/page/import_dropzone/__init__.py b/msp/msp/page/import_dropzone/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/msp/msp/page/import_dropzone/import_dropzone.js b/msp/msp/page/import_dropzone/import_dropzone.js new file mode 100644 index 0000000..4b5f559 --- /dev/null +++ b/msp/msp/page/import_dropzone/import_dropzone.js @@ -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 = ` +
+
+
⬆
+
${__("Dateien per Drag & Drop ablegen oder")} + +
+
+ ${__("Unterstützt: CSV · ZIP (mit einer CSV)")} +
+ +
+
+
+ `; + + // 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 = ` +
${icon_for(file.name)}
+
+
+ + +
+
⏳ ${__("Warte …")}
+ + + + +
+ `; + 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 = `⏳ ${label}`; + else state_el.innerHTML = ""; + }, + set_progress(pct) { + el.querySelector(".msp-card__state").innerHTML = + `⏳ ${__("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 = ` + + + `; + 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")}: ${frappe.utils.escape_html(result.display_name)}` + + ` (${Math.round(result.confidence * 100)}%)`; + + const preview = result.preview || {}; + const stat_parts = []; + if (preview.line_count != null) stat_parts.push(`${preview.line_count} ${__("Zeilen")}`); + if (preview.customer_count != null) stat_parts.push(`${preview.customer_count} ${__("Kunden")}`); + if (preview.product_count != null) stat_parts.push(`${preview.product_count} ${__("Produkte")}`); + if (preview.period_label) stat_parts.push(`${frappe.utils.escape_html(preview.period_label)}`); + if (preview.credit_note_lines) stat_parts.push(`${preview.credit_note_lines} ${__("Gutschriftzeilen")}`); + 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("
"); + } + + actions_el.hidden = false; + actions_el.innerHTML = ` + + `; + 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/). + // 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 "📎"; + } +}; diff --git a/msp/msp/page/import_dropzone/import_dropzone.json b/msp/msp/page/import_dropzone/import_dropzone.json new file mode 100644 index 0000000..08d601e --- /dev/null +++ b/msp/msp/page/import_dropzone/import_dropzone.json @@ -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" +}