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 = ` +