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.
This commit is contained in:
David Malinowski
2026-04-14 21:35:39 +02:00
parent 211b68bbb8
commit bb76afd19f
5 changed files with 511 additions and 20 deletions
+26
View File
@@ -87,6 +87,32 @@ def analyze_file(file_url: str) -> dict:
return result 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() @frappe.whitelist()
def process_file(file_url: str, handler_key: str, def process_file(file_url: str, handler_key: str,
config: str | dict | None = None) -> dict: config: str | dict | None = None) -> dict:
@@ -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 = `
<div class="msp-assistant-empty">
${__("Kein Kontext. Bitte eine Datei über die")}
<a href="/app/import-dropzone">${__("Dropzone")}</a> ${__("einwerfen.")}
</div>`;
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 = `
<div class="msp-assistant">
<div class="msp-stepper">
<div class="step" data-step="1"><span class="num">1</span> ${__("Konfiguration")}</div>
<div class="step" data-step="2"><span class="num">2</span> ${__("Import")}</div>
<div class="step" data-step="3"><span class="num">3</span> ${__("Ergebnis")}</div>
</div>
<div class="msp-panel" data-panel="1">
${render_config_panel(ctx)}
</div>
<div class="msp-panel" data-panel="2" hidden>
${render_running_panel()}
</div>
<div class="msp-panel" data-panel="3" hidden>
<!-- wird nach Import dynamisch gefüllt -->
</div>
</div>
`;
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(`<span class="stat"><strong>${p.line_count}</strong> ${__("Zeilen")}</span>`);
if (p.customer_count != null) stat_parts.push(`<span class="stat"><strong>${p.customer_count}</strong> ${__("Kunden")}</span>`);
if (p.product_count != null) stat_parts.push(`<span class="stat"><strong>${p.product_count}</strong> ${__("Produkte")}</span>`);
if (p.period_label) stat_parts.push(`<span class="stat">${frappe.utils.escape_html(p.period_label)}</span>`);
if (p.credit_note_lines) stat_parts.push(`<span class="stat pill-warn">${p.credit_note_lines} ${__("Gutschriftzeilen")}</span>`);
return `
<section class="msp-config">
<header>
<h3>${__("Zusammenfassung")}</h3>
<div class="file-line">
<span class="icon">${icon_for(ctx.file_url)}</span>
<span class="name">${frappe.utils.escape_html(filename_of(ctx.file_url))}</span>
<span class="sep">·</span>
<span class="handler">${frappe.utils.escape_html(ctx.display_name || ctx.handler_key)}</span>
</div>
<div class="stats">${stat_parts.join(" ")}</div>
</header>
<div class="config-grid">
<div class="form-row">
<label>${__("Profil")}</label>
<select class="form-control field-profile">
<option value="">${__("Lade Profile …")}</option>
</select>
<div class="hint">${__("Legt Defaults wie Steuertemplate, AGBs und Titel fest.")}</div>
</div>
<div class="form-row">
<label>${__("Ziel-Dokument")}</label>
<select class="form-control field-output-mode">
<option value="">${__("Profil-Default verwenden")}</option>
<option value="Sales Invoice">${__("Ausgangsrechnung erzwingen")}</option>
<option value="Delivery Note">${__("Lieferschein erzwingen")}</option>
</select>
<div class="hint">${__("Leer = der Customer.billing_mode entscheidet pro Kunde.")}</div>
</div>
<div class="form-row field-drift-wrapper" ${ctx.format_drift ? "" : "hidden"}>
<label>
<input type="checkbox" class="field-ack-drift"
${ctx.format_drift ? "checked" : ""} />
${__("Abweichendes Format akzeptieren")}
</label>
<div class="hint warn">${__("Die Analyse hat ein abweichendes CSV-Layout erkannt. Nur aktivieren, wenn du die Quelle kennst.")}</div>
</div>
</div>
<footer>
<button class="btn btn-primary btn-start-import">
${__("Import starten")} →
</button>
</footer>
</section>
`;
}
function render_running_panel() {
return `
<section class="msp-running">
<div class="spin">⏳</div>
<div class="label">${__("Import läuft …")}</div>
<div class="hint">${__("Parst Zeilen, erzeugt Dokumente, aktualisiert Supply Subscriptions.")}</div>
<div class="progress-note" hidden></div>
</section>
`;
}
function populate_profile_select(root, state) {
const sel = root.querySelector(".field-profile");
if (!sel) return;
if (!state.profiles.length) {
sel.innerHTML = `<option value="">${__("Kein aktives Profil gefunden")}</option>`;
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 `<option value="${frappe.utils.escape_html(p.name)}" ${selected}>${frappe.utils.escape_html(label)}</option>`;
})
.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
? `<a href="/app/supplier-import-run/${encodeURIComponent(r.run_name)}">${frappe.utils.escape_html(r.run_name)}</a>`
: "—";
panel.innerHTML = `
<section class="msp-result ${status_class}">
<div class="hero">
<span class="icon">${status_icon}</span>
<div>
<div class="status">${__("Import abgeschlossen")} — <strong>${r.status || "?"}</strong></div>
<div class="sub">Run ${run_link}</div>
</div>
</div>
<div class="counters">
<div class="counter"><strong>${r.document_count || 0}</strong><span>${__("Dokumente")}</span></div>
<div class="counter"><strong>${r.line_count || 0}</strong><span>${__("Zeilen")}</span></div>
<div class="counter"><strong>${r.skipped_count || 0}</strong><span>${__("Übersprungen")}</span></div>
<div class="counter ${r.error_count ? 'counter--bad' : ''}"><strong>${r.error_count || 0}</strong><span>${__("Fehler")}</span></div>
</div>
${r.message ? `<div class="msg">${frappe.utils.escape_html(r.message)}</div>` : ""}
<div class="actions">
<a class="btn btn-primary" href="/app/supplier-import-run/${encodeURIComponent(r.run_name || "")}">
${__("Run-Details öffnen")}
</a>
<a class="btn btn-default" href="/app/import-dropzone">
${__("Weiteren Import starten")}
</a>
</div>
${render_documents_block(r)}
</section>
`;
// Nachladen: erzeugte Dokumente auflisten
if (r.run_name) load_documents(panel, r.run_name);
}
function render_documents_block() {
return `
<div class="documents">
<h4>${__("Erzeugte Dokumente")}</h4>
<div class="documents-list"><span class="muted">${__("Lade …")}</span></div>
</div>
`;
}
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 = `<span class="muted">${__("Keine neuen Dokumente — alles übersprungen oder fehlgeschlagen.")}</span>`;
return;
}
list.innerHTML = `
<table class="table table-sm">
<thead><tr>
<th>${__("Kunde")}</th>
<th>${__("Dokument")}</th>
<th class="num">${__("Zeilen")}</th>
<th class="num">${__("Summe (netto)")}</th>
</tr></thead>
<tbody>
${entries.map((e) => `
<tr>
<td>${frappe.utils.escape_html(e.customer || "")}</td>
<td><a href="/app/${slug(e.target_doc_type)}/${encodeURIComponent(e.target_doc_name)}">
${frappe.utils.escape_html(e.target_doc_type)} ${frappe.utils.escape_html(e.target_doc_name)}
</a></td>
<td class="num">${e.line_count || 0}</td>
<td class="num">${format_eur(e.total_amount)}</td>
</tr>
`).join("")}
</tbody>
</table>
`;
} 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);
}
@@ -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"
}
+25 -20
View File
@@ -275,29 +275,34 @@ frappe.pages["import-dropzone"].on_page_load = function (wrapper) {
actions_el actions_el
.querySelector(".btn-open-assistant") .querySelector(".btn-open-assistant")
.addEventListener("click", () => .addEventListener("click", () =>
open_assistant(file_url, result.handler_key, { open_assistant(
wizard_route: result.wizard_route, file_url,
format_drift: preview.format_drift, result.handler_key,
suggested_profile: preview.suggested_profile, {
}) 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 = {}) { function open_assistant(file_url, handler_key, opts = {}, analysis = {}) {
// Phase 4: eigenes Assistent-Routing (/import-assistant/<handler>). // Kontext für den Wizard in route_options ablegen — Frappe überträgt das
// Bis das da ist: öffne den Supplier Import Run mit vorbelegtem File + Profil. // beim set_route in die Ziel-Page, dort wird es ausgelesen und sofort
const route_base = opts.wizard_route || "import-assistant"; // gelöscht.
if (route_base === "import-assistant") { frappe.route_options = {
// Aktuell als Sprung auf die bestehende Run-Form mit ?file=…&profile=… file_url,
const params = new URLSearchParams({ file: file_url }); handler_key,
if (opts.suggested_profile) params.set("profile", opts.suggested_profile); display_name: analysis.display_name || opts.display_name || "",
if (opts.format_drift) params.set("acknowledge_format_drift", "1"); suggested_profile: opts.suggested_profile || "",
const url = `/app/supplier-import-run/new?${params.toString()}`; format_drift: !!opts.format_drift,
window.location.href = url; preview: analysis.preview || null,
} else { };
// Spezial-Wizard-Routen (Future): frappe.set_route(...) const route = opts.wizard_route || "import-assistant";
frappe.set_route(opts.wizard_route, file_url, handler_key); frappe.set_route(route);
}
} }
async function upload_file(file, on_progress) { async function upload_file(file, on_progress) {