Files
msp/msp/importers/assistant.py
T
David Malinowski bb76afd19f feat(msp): Import-Assistent als Wizard-Page mit Ergebnis-View
Neue Frappe-Page /app/import-assistant, die den Import-Flow in drei Schritten
durchführt:

  1. Konfiguration — zeigt die Analyse-Zusammenfassung aus dem Dropzone-Flow
     (Dateiname, erkannter Handler, Zeilen/Kunden/Produkte/Zeitraum) und
     bietet Formfelder für Profil, Output-Mode-Override sowie den
     „Abweichendes Format akzeptieren"-Toggle (wird nur angezeigt, wenn die
     Analyse Format-Drift signalisiert).
  2. Import — synchroner Aufruf msp.importers.assistant.process_file, der
     einen Supplier Import Run anlegt und den Orchestrator startet.
  3. Ergebnis — Status-Hero + Counter (Dokumente/Zeilen/Übersprungen/Fehler)
     und eine Tabelle aller erzeugten Sales Invoices / Delivery Notes mit
     Kunde, klickbarem Doc-Link, Zeilenanzahl und Netto-Summe. Dazu
     Folge-Buttons „Run-Details öffnen" und „Weiteren Import starten".

Der Dropzone-Flow (Phase 3) übergibt den Wizard-Kontext jetzt über
frappe.route_options (file_url, handler_key, display_name, suggested_profile,
format_drift, preview) anstatt auf das Supplier-Import-Run-Formular zu
springen — sauberer Schritt-Übergang ohne URL-Parameter-Akrobatik.

Zusätzlich msp.importers.assistant.run_documents — whitelisted Endpoint,
der pro Run die erzeugten Zieldokumente gruppiert zurückgibt. Frappe's
client.get_list liefert auf Child-Doctypes nur die name-Spalte, daher der
dedizierte SQL-Helper.

End-to-End im Browser validiert:
  Dropzone-Upload → Analyse (100 % Confidence) → Assistent öffnet sich
  mit vorausgefülltem Profil → Import starten → 57 Dokumente erzeugt
  (Mix aus Sales Invoices und Delivery Notes gemäß Customer.billing_mode),
  Tabelle zeigt alle mit klickbaren Links.
2026-04-14 21:35:39 +02:00

164 lines
5.0 KiB
Python

"""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 run_documents(run_name: str) -> list[dict]:
"""Gibt die pro Supplier Import Run erzeugten Zieldokumente zurück —
gruppiert pro (target_doc_type, target_doc_name), mit Zeilen-Anzahl und
Summe. Wird vom Import-Assistent-Wizard benötigt, weil
``frappe.client.get_list`` auf Child-Doctypes die `fields` ignoriert."""
if not run_name:
return []
rows = frappe.db.sql(
"""
SELECT customer, target_doc_type, target_doc_name,
COUNT(*) AS line_count, SUM(COALESCE(amount, 0)) AS total_amount
FROM `tabSupplier Import Line`
WHERE parent = %(run)s
AND line_status = 'Created'
AND target_doc_name IS NOT NULL
AND target_doc_name != ''
GROUP BY customer, target_doc_type, target_doc_name
ORDER BY customer
""",
{"run": run_name},
as_dict=True,
)
return rows
@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}")