From bb76afd19f8f212c173fde2400750ea5daf4027a Mon Sep 17 00:00:00 2001 From: David Malinowski Date: Tue, 14 Apr 2026 21:35:39 +0200 Subject: [PATCH] feat(msp): Import-Assistent als Wizard-Page mit Ergebnis-View MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- msp/importers/assistant.py | 26 ++ msp/msp/page/import_assistant/__init__.py | 0 .../page/import_assistant/import_assistant.js | 433 ++++++++++++++++++ .../import_assistant/import_assistant.json | 27 ++ .../page/import_dropzone/import_dropzone.js | 45 +- 5 files changed, 511 insertions(+), 20 deletions(-) create mode 100644 msp/msp/page/import_assistant/__init__.py create mode 100644 msp/msp/page/import_assistant/import_assistant.js create mode 100644 msp/msp/page/import_assistant/import_assistant.json diff --git a/msp/importers/assistant.py b/msp/importers/assistant.py index 55bb52b..904c739 100644 --- a/msp/importers/assistant.py +++ b/msp/importers/assistant.py @@ -87,6 +87,32 @@ def analyze_file(file_url: str) -> dict: 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: diff --git a/msp/msp/page/import_assistant/__init__.py b/msp/msp/page/import_assistant/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/msp/msp/page/import_assistant/import_assistant.js b/msp/msp/page/import_assistant/import_assistant.js new file mode 100644 index 0000000..a5e4c62 --- /dev/null +++ b/msp/msp/page/import_assistant/import_assistant.js @@ -0,0 +1,433 @@ +/** + * Frappe-Page „Import-Assistent" — Wizard, der den Import einer einzelnen + * Datei (vom Dropzone-Flow übergeben) Schritt für Schritt durchführt. + * + * Aufruf: frappe.route_options = { file_url, handler_key, display_name, + * suggested_profile, format_drift, preview } + * frappe.set_route("import-assistant"); + * + * Schritte (Stepper): + * 1. Zusammenfassung + Konfiguration + * 2. Import (Live-Progress) + * 3. Ergebnis mit Links zu den erzeugten Dokumenten + */ + +frappe.pages["import-assistant"].on_page_load = function (wrapper) { + const page = frappe.ui.make_app_page({ + parent: wrapper, + title: __("Import-Assistent"), + single_column: true, + }); + const root = page.main[0] || page.main; + + const ctx = frappe.route_options || {}; + // route_options werden in Frappe nach dem ersten Lesen verworfen — lokal halten + frappe.route_options = {}; + + if (!ctx.file_url || !ctx.handler_key) { + root.innerHTML = ` +
+ ${__("Kein Kontext. Bitte eine Datei über die")} + ${__("Dropzone")} ${__("einwerfen.")} +
`; + inject_style(root); + return; + } + + inject_style(root); + render_page(root, ctx, page); +}; + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +function render_page(root, ctx, page) { + const state = { + step: 1, // 1 = config, 2 = running, 3 = done + config: { + profile: ctx.suggested_profile || "", + output_mode: "", + acknowledge_format_drift: !!ctx.format_drift, + }, + result: null, // füllt sich in Schritt 3 + profiles: [], + }; + + root.innerHTML = ` +
+
+
1 ${__("Konfiguration")}
+
2 ${__("Import")}
+
3 ${__("Ergebnis")}
+
+
+ ${render_config_panel(ctx)} +
+ + +
+ `; + + set_step(root, state.step); + + // Profile laden für Dropdown (Filter: parser_key = handler_key) + frappe.call({ + method: "frappe.client.get_list", + args: { + doctype: "Supplier Import Profile", + filters: { parser_key: ctx.handler_key, enabled: 1 }, + fields: ["name", "profile_name"], + limit_page_length: 50, + }, + callback: (r) => { + state.profiles = r.message || []; + populate_profile_select(root, state); + }, + }); + + // Handlers + const btn_start = root.querySelector(".btn-start-import"); + btn_start.addEventListener("click", () => kick_off_import(root, state, ctx)); +} + +function render_config_panel(ctx) { + const p = ctx.preview || {}; + const stat_parts = []; + if (p.line_count != null) stat_parts.push(`${p.line_count} ${__("Zeilen")}`); + if (p.customer_count != null) stat_parts.push(`${p.customer_count} ${__("Kunden")}`); + if (p.product_count != null) stat_parts.push(`${p.product_count} ${__("Produkte")}`); + if (p.period_label) stat_parts.push(`${frappe.utils.escape_html(p.period_label)}`); + if (p.credit_note_lines) stat_parts.push(`${p.credit_note_lines} ${__("Gutschriftzeilen")}`); + + return ` +
+
+

${__("Zusammenfassung")}

+
+ ${icon_for(ctx.file_url)} + ${frappe.utils.escape_html(filename_of(ctx.file_url))} + · + ${frappe.utils.escape_html(ctx.display_name || ctx.handler_key)} +
+
${stat_parts.join(" ")}
+
+
+
+ + +
${__("Legt Defaults wie Steuertemplate, AGBs und Titel fest.")}
+
+
+ + +
${__("Leer = der Customer.billing_mode entscheidet pro Kunde.")}
+
+
+ +
${__("Die Analyse hat ein abweichendes CSV-Layout erkannt. Nur aktivieren, wenn du die Quelle kennst.")}
+
+
+
+ +
+
+ `; +} + +function render_running_panel() { + return ` +
+
⏳
+
${__("Import läuft …")}
+
${__("Parst Zeilen, erzeugt Dokumente, aktualisiert Supply Subscriptions.")}
+ +
+ `; +} + +function populate_profile_select(root, state) { + const sel = root.querySelector(".field-profile"); + if (!sel) return; + if (!state.profiles.length) { + sel.innerHTML = ``; + sel.disabled = true; + return; + } + sel.innerHTML = state.profiles + .map((p) => { + const selected = p.name === state.config.profile ? "selected" : ""; + const label = p.profile_name || p.name; + return ``; + }) + .join(""); +} + +async function kick_off_import(root, state, ctx) { + // Werte aus Form einsammeln + state.config.profile = root.querySelector(".field-profile").value; + state.config.output_mode = root.querySelector(".field-output-mode").value; + state.config.acknowledge_format_drift = + root.querySelector(".field-ack-drift")?.checked ? 1 : 0; + + if (!state.config.profile) { + frappe.msgprint({ title: __("Profil fehlt"), message: __("Bitte ein Import-Profil auswählen."), indicator: "orange" }); + return; + } + + set_step(root, 2); + + try { + const response = await frappe.call({ + method: "msp.importers.assistant.process_file", + args: { + file_url: ctx.file_url, + handler_key: ctx.handler_key, + config: JSON.stringify(state.config), + }, + }); + state.result = response.message || {}; + show_result(root, state, ctx); + } catch (err) { + state.result = { status: "Failed", message: (err && err.message) || String(err) }; + show_result(root, state, ctx); + } +} + +function show_result(root, state, ctx) { + set_step(root, 3); + const r = state.result || {}; + const panel = root.querySelector(".msp-panel[data-panel='3']"); + + const status_icon = { "Succeeded": "✓", "Partial": "◉", "Failed": "✗" }[r.status] || "?"; + const status_class = { "Succeeded": "ok", "Partial": "warn", "Failed": "error" }[r.status] || "warn"; + + const run_link = r.run_name + ? `${frappe.utils.escape_html(r.run_name)}` + : "—"; + + panel.innerHTML = ` +
+
+ ${status_icon} +
+
${__("Import abgeschlossen")} — ${r.status || "?"}
+
Run ${run_link}
+
+
+
+
${r.document_count || 0}${__("Dokumente")}
+
${r.line_count || 0}${__("Zeilen")}
+
${r.skipped_count || 0}${__("Übersprungen")}
+
${r.error_count || 0}${__("Fehler")}
+
+ ${r.message ? `
${frappe.utils.escape_html(r.message)}
` : ""} + + ${render_documents_block(r)} +
+ `; + + // Nachladen: erzeugte Dokumente auflisten + if (r.run_name) load_documents(panel, r.run_name); +} + +function render_documents_block() { + return ` +
+

${__("Erzeugte Dokumente")}

+
${__("Lade …")}
+
+ `; +} + +async function load_documents(panel, run_name) { + try { + const response = await frappe.call({ + method: "msp.importers.assistant.run_documents", + args: { run_name }, + }); + const entries = (response.message || []).sort((a, b) => + String(a.customer || "").localeCompare(String(b.customer || "")) + ); + const list = panel.querySelector(".documents-list"); + if (!entries.length) { + list.innerHTML = `${__("Keine neuen Dokumente — alles übersprungen oder fehlgeschlagen.")}`; + return; + } + list.innerHTML = ` + + + + + + + + + ${entries.map((e) => ` + + + + + + + `).join("")} + +
${__("Kunde")}${__("Dokument")}${__("Zeilen")}${__("Summe (netto)")}
${frappe.utils.escape_html(e.customer || "")} + ${frappe.utils.escape_html(e.target_doc_type)} ${frappe.utils.escape_html(e.target_doc_name)} + ${e.line_count || 0}${format_eur(e.total_amount)}
+ `; + } catch (err) { + console.error(err); + } +} + +function set_step(root, step) { + root.querySelectorAll(".msp-stepper .step").forEach((el) => { + const s = parseInt(el.dataset.step, 10); + el.classList.toggle("active", s === step); + el.classList.toggle("done", s < step); + }); + root.querySelectorAll(".msp-panel").forEach((el) => { + el.hidden = parseInt(el.dataset.panel, 10) !== step; + }); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function filename_of(url) { + return (url || "").split("/").pop(); +} + +function icon_for(url) { + const n = (url || "").toLowerCase(); + if (n.endsWith(".zip")) return "🗜️"; + if (n.endsWith(".csv")) return "📄"; + return "📎"; +} + +function slug(doctype) { + return (doctype || "").toLowerCase().replace(/\s+/g, "-"); +} + +function format_eur(v) { + if (v == null || isNaN(v)) return "–"; + return Number(v).toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " €"; +} + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +function inject_style(root) { + if (root.querySelector("style[data-msp-assistant]")) return; + const style = document.createElement("style"); + style.setAttribute("data-msp-assistant", "1"); + style.textContent = ` + .msp-assistant { padding: 10px; max-width: 980px; margin: 0 auto; } + .msp-assistant-empty { padding: 40px; text-align: center; color: var(--text-muted); } + + .msp-stepper { + display: flex; gap: 16px; margin-bottom: 28px; + border-bottom: 1px solid var(--border-color); + padding-bottom: 12px; + } + .msp-stepper .step { + display: flex; align-items: center; gap: 8px; + color: var(--text-muted); font-size: 14px; + } + .msp-stepper .step .num { + width: 24px; height: 24px; border-radius: 50%; + background: var(--bg-light-gray); color: var(--text-muted); + display: flex; align-items: center; justify-content: center; + font-size: 12px; font-weight: 600; + } + .msp-stepper .step.active { color: var(--heading-color); font-weight: 600; } + .msp-stepper .step.active .num { background: var(--primary); color: #fff; } + .msp-stepper .step.done .num { background: var(--green-500); color: #fff; } + + .msp-config header { margin-bottom: 20px; } + .msp-config .file-line { + display: flex; gap: 8px; align-items: baseline; margin: 8px 0 12px; + font-size: 15px; + } + .msp-config .file-line .name { font-weight: 600; } + .msp-config .file-line .sep { color: var(--text-muted); } + .msp-config .file-line .handler { color: var(--text-muted); } + .msp-config .stats .stat { margin-right: 16px; font-size: 13px; } + .msp-config .stats strong { color: var(--heading-color); } + .msp-config .stats .pill-warn { color: var(--orange-600); } + + .config-grid { display: grid; gap: 18px; max-width: 640px; } + .config-grid .form-row label { font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .04em; display: block; margin-bottom: 4px; } + .config-grid .hint { font-size: 12px; color: var(--text-muted); margin-top: 4px; } + .config-grid .hint.warn { color: var(--orange-600); } + + .msp-config footer { margin-top: 22px; } + + .msp-running { text-align: center; padding: 60px 20px; } + .msp-running .spin { font-size: 48px; animation: msp-spin 2s linear infinite; display: inline-block; } + .msp-running .label { font-size: 16px; margin-top: 12px; } + .msp-running .hint { color: var(--text-muted); margin-top: 6px; } + @keyframes msp-spin { from { transform: rotate(0); } to { transform: rotate(360deg); } } + + .msp-result .hero { + display: flex; gap: 16px; align-items: center; + padding: 16px 0 20px; border-bottom: 1px solid var(--border-color); + margin-bottom: 20px; + } + .msp-result .hero .icon { + font-size: 40px; width: 60px; height: 60px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + background: var(--bg-light-gray); + } + .msp-result.ok .hero .icon { background: var(--green-100); color: var(--green-600); } + .msp-result.warn .hero .icon { background: var(--orange-100); color: var(--orange-600); } + .msp-result.error .hero .icon { background: var(--red-100); color: var(--red-600); } + .msp-result .status { font-size: 16px; } + .msp-result .sub { color: var(--text-muted); font-size: 13px; margin-top: 2px; } + + .msp-result .counters { display: flex; gap: 16px; margin-bottom: 18px; } + .msp-result .counter { + background: var(--bg-light-gray); border-radius: 8px; padding: 14px 18px; + flex: 1; text-align: center; + } + .msp-result .counter strong { display: block; font-size: 22px; font-weight: 600; } + .msp-result .counter span { font-size: 12px; color: var(--text-muted); } + .msp-result .counter--bad { background: var(--red-50); } + .msp-result .counter--bad strong { color: var(--red-600); } + + .msp-result .actions { display: flex; gap: 8px; margin: 18px 0 24px; } + .msp-result .msg { background: var(--red-50); color: var(--red-600); padding: 8px 12px; border-radius: 4px; margin-bottom: 12px; } + + .msp-result .documents h4 { margin: 20px 0 8px; } + .msp-result .documents table { width: 100%; font-size: 13px; } + .msp-result .documents td.num, .msp-result .documents th.num { text-align: right; } + .msp-result .documents .muted { color: var(--text-muted); font-size: 13px; } + `; + root.appendChild(style); +} diff --git a/msp/msp/page/import_assistant/import_assistant.json b/msp/msp/page/import_assistant/import_assistant.json new file mode 100644 index 0000000..906c811 --- /dev/null +++ b/msp/msp/page/import_assistant/import_assistant.json @@ -0,0 +1,27 @@ +{ + "content": null, + "creation": "2026-04-14 20:30:00.000000", + "docstatus": 0, + "doctype": "Page", + "icon": "octicon octicon-file-directory", + "idx": 0, + "modified": "2026-04-14 20:30:00.000000", + "modified_by": "Administrator", + "module": "MSP", + "name": "import-assistant", + "owner": "Administrator", + "page_name": "import-assistant", + "roles": [ + { + "role": "System Manager" + }, + { + "role": "Accounts Manager" + } + ], + "script": null, + "standard": "Yes", + "style": null, + "system_page": 0, + "title": "Import-Assistent" +} diff --git a/msp/msp/page/import_dropzone/import_dropzone.js b/msp/msp/page/import_dropzone/import_dropzone.js index 4b5f559..fe71069 100644 --- a/msp/msp/page/import_dropzone/import_dropzone.js +++ b/msp/msp/page/import_dropzone/import_dropzone.js @@ -275,29 +275,34 @@ frappe.pages["import-dropzone"].on_page_load = function (wrapper) { 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, - }) + open_assistant( + file_url, + result.handler_key, + { + wizard_route: result.wizard_route, + format_drift: preview.format_drift, + suggested_profile: preview.suggested_profile, + display_name: result.display_name, + }, + result + ) ); } - 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); - } + function open_assistant(file_url, handler_key, opts = {}, analysis = {}) { + // Kontext für den Wizard in route_options ablegen — Frappe überträgt das + // beim set_route in die Ziel-Page, dort wird es ausgelesen und sofort + // gelöscht. + frappe.route_options = { + file_url, + handler_key, + display_name: analysis.display_name || opts.display_name || "", + suggested_profile: opts.suggested_profile || "", + format_drift: !!opts.format_drift, + preview: analysis.preview || null, + }; + const route = opts.wizard_route || "import-assistant"; + frappe.set_route(route); } async function upload_file(file, on_progress) {