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)}
+
+
+ ${render_running_panel()}
+
+
+
+
+
+ `;
+
+ 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 `
+
+
+
+
+
+ `;
+}
+
+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 = `
+
+ `;
+ } 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) {