mirror of
https://github.com/itsdave-de/msp.git
synced 2026-09-28 04:02:42 -03:00
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:
@@ -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}")
|
||||||
@@ -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}"
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user