1	use rusqlite::{Connection, OptionalExtension};
2	use serde::Serialize;
3
4	const DB_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../data/wuerth.db");
5
6	#[derive(Serialize)]
7	struct Kunde360 {
8	    kunde_id: i64,
9	    // geliefert
10	    name_roh: String,
11	    name_1: Option<String>,
12	    strasse: Option<String>,
13	    plz: Option<String>,
14	    land: Option<String>,
15	    n_auftraege: i64,
16	    umsatz_netto: f64,
17	    // angereichert
18	    branche_text: Option<String>,
19	    gewerk: Option<String>,
20	    segment: Option<String>,
21	    mitarbeiter_kl: Option<String>,
22	    gruendungsjahr: Option<i64>,
23	    rechtsform: Option<String>,
24	    website: Option<String>,
25	    ort: Option<String>,
26	    konfidenz: Option<f64>,
27	    bestaetigt: Option<i64>,
28	    quelle_primaer: Option<String>,
29	}
30
31	#[tauri::command]
32	fn list_kunden_360() -> Result<Vec<Kunde360>, String> {
33	    let con = Connection::open(DB_PATH).map_err(|e| e.to_string())?;
34	    let mut stmt = con
35	        .prepare(
36	            "SELECT kunde_id, name_roh, name_1, strasse, plz, land, n_auftraege, \
37	             umsatz_netto, branche_text, gewerk, segment, mitarbeiter_kl, gruendungsjahr, \
38	             rechtsform, website, ort, konfidenz, bestaetigt, quelle_primaer \
39	             FROM v_kunde_360 ORDER BY name_roh",
40	        )
41	        .map_err(|e| e.to_string())?;
42	    let rows = stmt
43	        .query_map([], |r| {
44	            Ok(Kunde360 {
45	                kunde_id: r.get(0)?,
46	                name_roh: r.get(1)?,
47	                name_1: r.get(2)?,
48	                strasse: r.get(3)?,
49	                plz: r.get(4)?,
50	                land: r.get(5)?,
51	                n_auftraege: r.get(6)?,
52	                umsatz_netto: r.get(7)?,
53	                branche_text: r.get(8)?,
54	                gewerk: r.get(9)?,
55	                segment: r.get(10)?,
56	                mitarbeiter_kl: r.get(11)?,
57	                gruendungsjahr: r.get(12)?,
58	                rechtsform: r.get(13)?,
59	                website: r.get(14)?,
60	                ort: r.get(15)?,
61	                konfidenz: r.get(16)?,
62	                bestaetigt: r.get(17)?,
63	                quelle_primaer: r.get(18)?,
64	            })
65	        })
66	        .map_err(|e| e.to_string())?;
67	    let mut out = Vec::new();
68	    for row in rows {
69	        out.push(row.map_err(|e| e.to_string())?);
70	    }
71	    Ok(out)
72	}
73
74	#[derive(Serialize)]
75	struct Auftrag {
76	    kunde_id: i64,
77	    auftrag_id: String,
78	    datum: Option<String>,
79	    fzg_klasse: Option<String>,
80	    fzg_hersteller: Option<String>,
81	    fzg_modell: Option<String>,
82	    fzg_groesse: Option<String>,
83	    umsatz_netto: f64, // Landeswährung
84	    umsatz_eur: f64,   // umgerechnet
85	    waehrung: Option<String>,
86	    wechselkurs: Option<f64>,
87	    kurs_datum: Option<String>,
88	    quotation_state: Option<String>,
89	}
90
91	#[tauri::command]
92	fn list_auftraege() -> Result<Vec<Auftrag>, String> {
93	    let con = Connection::open(DB_PATH).map_err(|e| e.to_string())?;
94	    let mut stmt = con
95	        .prepare(
96	            "SELECT a.kunde_id, a.auftrag_id, a.datum, f.fzg_klasse, f.hersteller, \
97	             f.modell, f.groesse, a.kundenpreis_netto, \
98	             a.kundenpreis_netto / COALESCE(a.wechselkurs, 1) AS umsatz_eur, \
99	             a.waehrung, a.wechselkurs, a.kurs_datum, a.quotation_state \
100	             FROM auftraege a LEFT JOIN auftrag_fzg f USING (auftrag_id) \
101	             ORDER BY a.datum DESC",
102	        )
103	        .map_err(|e| e.to_string())?;
104	    let rows = stmt
105	        .query_map([], |r| {
106	            Ok(Auftrag {
107	                kunde_id: r.get(0)?,
108	                auftrag_id: r.get(1)?,
109	                datum: r.get(2)?,
110	                fzg_klasse: r.get(3)?,
111	                fzg_hersteller: r.get(4)?,
112	                fzg_modell: r.get(5)?,
113	                fzg_groesse: r.get(6)?,
114	                umsatz_netto: r.get(7)?,
115	                umsatz_eur: r.get(8)?,
116	                waehrung: r.get(9)?,
117	                wechselkurs: r.get(10)?,
118	                kurs_datum: r.get(11)?,
119	                quotation_state: r.get(12)?,
120	            })
121	        })
122	        .map_err(|e| e.to_string())?;
123	    let mut out = Vec::new();
124	    for row in rows {
125	        out.push(row.map_err(|e| e.to_string())?);
126	    }
127	    Ok(out)
128	}
129
130	#[derive(Serialize)]
131	struct Position {
132	    modul_id: String,
133	    artikelnummer: Option<String>,
134	    klartext: String,
135	    menge: i64,
136	    einzelpreis: f64,
137	    pos_wert: f64,
138	    eur_wert: f64,
139	}
140
141	#[tauri::command]
142	fn list_positionen(auftrag_id: String) -> Result<Vec<Position>, String> {
143	    let con = Connection::open(DB_PATH).map_err(|e| e.to_string())?;
144	    let mut stmt = con
145	        .prepare(
146	            "SELECT p.modul_id, m.artikelnummer, COALESCE(m.klartext, p.modul_id), \
147	             COALESCE(p.menge, 1), p.preis, p.preis * COALESCE(p.menge, 1) AS pos_wert, \
148	             p.preis * COALESCE(p.menge, 1) / COALESCE(a.wechselkurs, 1) AS eur_wert \
149	             FROM positionen p LEFT JOIN module m USING (modul_id) \
150	             JOIN auftraege a ON a.auftrag_id = p.auftrag_id \
151	             WHERE p.auftrag_id = ?1 ORDER BY pos_wert DESC",
152	        )
153	        .map_err(|e| e.to_string())?;
154	    let rows = stmt
155	        .query_map([auftrag_id], |r| {
156	            Ok(Position {
157	                modul_id: r.get(0)?,
158	                artikelnummer: r.get(1)?,
159	                klartext: r.get(2)?,
160	                menge: r.get(3)?,
161	                einzelpreis: r.get(4)?,
162	                pos_wert: r.get(5)?,
163	                eur_wert: r.get(6)?,
164	            })
165	        })
166	        .map_err(|e| e.to_string())?;
167	    let mut out = Vec::new();
168	    for row in rows {
169	        out.push(row.map_err(|e| e.to_string())?);
170	    }
171	    Ok(out)
172	}
173
174	// ---------------------------------------------------------------------------
175	// Natursprache-Assistent: Frage -> Claude erzeugt read-only SQL + Chart-Spec
176	// -> Rust prüft (nur SELECT) und führt aus -> Frontend rendert.
177	// ---------------------------------------------------------------------------
178
179	#[derive(Serialize)]
180	struct AiAntwort {
181	    frage: String,
182	    sql: String,
183	    chart: String,             // kpi | bar | table
184	    label: Option<String>,     // Spaltenname für Kategorie
185	    value: Option<String>,     // Spaltenname für Wert
186	    titel: String,
187	    erklaerung: String,
188	    columns: Vec<String>,
189	    rows: Vec<Vec<serde_json::Value>>,
190	}
191
192	fn read_api_key() -> Result<String, String> {
193	    let home = std::env::var("HOME").map_err(|_| "HOME nicht gesetzt".to_string())?;
194	    let path = format!("{home}/.ssh/anthropic_api_key");
195	    let content =
196	        std::fs::read_to_string(&path).map_err(|e| format!("Key-Datei {path}: {e}"))?;
197	    content
198	        .lines()
199	        .map(|l| l.trim())
200	        .find(|l| l.starts_with("sk-ant-"))
201	        .map(|s| s.to_string())
202	        .ok_or_else(|| format!("kein sk-ant-Key in {path}"))
203	}
204
205	fn db_schema(con: &Connection) -> Result<String, String> {
206	    let mut stmt = con
207	        .prepare(
208	            "SELECT sql FROM sqlite_master \
209	             WHERE type IN ('table','view') AND sql IS NOT NULL \
210	             AND name NOT LIKE 'sqlite_%' ORDER BY type DESC, name",
211	        )
212	        .map_err(|e| e.to_string())?;
213	    let rows = stmt
214	        .query_map([], |r| r.get::<_, String>(0))
215	        .map_err(|e| e.to_string())?;
216	    let mut out = String::new();
217	    for r in rows {
218	        out.push_str(&r.map_err(|e| e.to_string())?);
219	        out.push_str(";\n");
220	    }
221	    Ok(out)
222	}
223
224	fn is_read_only(sql: &str) -> bool {
225	    let up = sql.trim().to_uppercase();
226	    if !(up.starts_with("SELECT") || up.starts_with("WITH")) {
227	        return false;
228	    }
229	    // kein Statement-Trenner (nur SELECT), keine schreibenden Schlüsselwörter
230	    if sql.trim_end().trim_end_matches(';').contains(';') {
231	        return false;
232	    }
233	    const VERBOTEN: [&str; 9] = [
234	        "INSERT ", "UPDATE ", "DELETE ", "DROP ", "ALTER ", "CREATE ", "ATTACH ",
235	        "PRAGMA ", "REPLACE ",
236	    ];
237	    !VERBOTEN.iter().any(|w| up.contains(w))
238	}
239
240	fn cell_to_json(v: rusqlite::types::ValueRef) -> serde_json::Value {
241	    use rusqlite::types::ValueRef;
242	    match v {
243	        ValueRef::Null => serde_json::Value::Null,
244	        ValueRef::Integer(n) => serde_json::Value::from(n),
245	        ValueRef::Real(f) => serde_json::Value::from(f),
246	        ValueRef::Text(t) => serde_json::Value::from(String::from_utf8_lossy(t).to_string()),
247	        ValueRef::Blob(_) => serde_json::Value::from("<blob>"),
248	    }
249	}
250
251	const SYSTEM_TMPL: &str = "Du übersetzt deutsche Fragen in **eine** SQLite-Abfrage über die \
252	folgende Datenbank und wählst die beste Visualisierung. Antworte AUSSCHLIESSLICH mit einem \
253	JSON-Objekt, keine Erklärung davor/danach, keine Markdown-Zäune.\n\n\
254	Schema:\n{SCHEMA}\n\
255	Hinweise:\n\
256	- Nur eine read-only SELECT-Abfrage (ggf. mit WITH). Kein Semikolon, kein INSERT/UPDATE/DELETE/DDL.\n\
257	- Beträge liegen in Landeswährung; EUR = Betrag / COALESCE(wechselkurs,1). auftraege.kundenpreis_netto ist der Auftragswert.\n\
258	- v_kunde_360.umsatz_netto ist bereits EUR-normiert. gewerk/land/segment kommen aus der Anreicherung.\n\
259	- Kunden-Anzeigename ist kunden.name_roh (bzw. v_kunde_360.name_roh), NICHT name_1.\n\
260	- Für Rankings/Vergleiche LIMIT setzen (z.B. 10). Spaltennamen sprechend aliasen.\n\n\
261	JSON-Format:\n\
262	{\"sql\": \"<SELECT ...>\", \"chart\": \"kpi|bar|table\", \"label\": \"<Spalte für Kategorie oder null>\", \
263	\"value\": \"<Spalte für Wert oder null>\", \"titel\": \"<kurzer Titel>\", \"erklaerung\": \"<1 Satz>\"}\n\
264	- chart=kpi: eine Kennzahl (value = die Wertspalte). chart=bar: label + value. chart=table: sonst.";
265
266	// Ein Claude-Aufruf (system + eine User-Nachricht), gibt den text-Block zurück.
267	// Im Thinking-Modus steht ein thinking-Block vor dem text-Block -> gezielt den text-Block nehmen.
268	fn claude_text(
269	    modell: &str,
270	    thinking: bool,
271	    max_tokens_plain: u32,
272	    system: &str,
273	    user: &str,
274	) -> Result<String, String> {
275	    let key = read_api_key()?;
276	    // Thinking-Konfiguration je Modell; bei Thinking mehr Output-Budget
277	    let (thinking_cfg, max_tokens) = if !thinking {
278	        (serde_json::json!({"type": "disabled"}), max_tokens_plain)
279	    } else if modell.starts_with("claude-haiku") {
280	        (serde_json::json!({"type": "enabled", "budget_tokens": 2000}), 4096)
281	    } else {
282	        (serde_json::json!({"type": "adaptive"}), 4096)
283	    };
284
285	    let body = serde_json::json!({
286	        "model": modell,
287	        "max_tokens": max_tokens,
288	        "thinking": thinking_cfg,
289	        "system": system,
290	        "messages": [{"role": "user", "content": user}]
291	    });
292
293	    let client = reqwest::blocking::Client::builder()
294	        .timeout(std::time::Duration::from_secs(60))
295	        .build()
296	        .map_err(|e| e.to_string())?;
297	    let resp = client
298	        .post("https://api.anthropic.com/v1/messages")
299	        .header("x-api-key", key)
300	        .header("anthropic-version", "2023-06-01")
301	        .header("content-type", "application/json")
302	        .json(&body)
303	        .send()
304	        .map_err(|e| format!("API-Aufruf fehlgeschlagen: {e}"))?;
305
306	    let status = resp.status();
307	    let json: serde_json::Value = resp.json().map_err(|e| e.to_string())?;
308	    if !status.is_success() {
309	        return Err(format!("API {status}: {}", json));
310	    }
311	    json["content"]
312	        .as_array()
313	        .and_then(|blocks| {
314	            blocks
315	                .iter()
316	                .find(|b| b["type"] == "text")
317	                .and_then(|b| b["text"].as_str())
318	        })
319	        .map(|s| s.to_string())
320	        .ok_or_else(|| format!("kein Text in API-Antwort: {json}"))
321	}
322
323	// JSON-Objekt aus dem Antworttext herausschneiden (erstes { bis letztes })
324	fn json_objekt(text: &str) -> Result<serde_json::Value, String> {
325	    let start = text.find('{').ok_or("kein JSON in der Antwort")?;
326	    let end = text.rfind('}').ok_or("kein JSON in der Antwort")?;
327	    serde_json::from_str(&text[start..=end]).map_err(|e| format!("JSON-Parse: {e}"))
328	}
329
330	fn frage_ai_inner(frage: String, modell: String, thinking: bool) -> Result<AiAntwort, String> {
331	    let con = Connection::open(DB_PATH).map_err(|e| e.to_string())?;
332	    let schema = db_schema(&con)?;
333	    let system = SYSTEM_TMPL.replace("{SCHEMA}", &schema);
334
335	    // Modell auf erlaubte Werte begrenzen
336	    let modell = match modell.as_str() {
337	        "claude-opus-4-8" | "claude-sonnet-5" | "claude-haiku-4-5" => modell,
338	        _ => "claude-sonnet-5".to_string(),
339	    };
340
341	    let text = claude_text(&modell, thinking, 1024, &system, &frage)?;
342	    let spec = json_objekt(&text)?;
343
344	    let sql = spec["sql"].as_str().unwrap_or("").trim().to_string();
345	    if !is_read_only(&sql) {
346	        return Err(format!("Abfrage abgelehnt (nicht read-only): {sql}"));
347	    }
348	    let chart = spec["chart"].as_str().unwrap_or("table").to_string();
349	    let label = spec["label"].as_str().map(|s| s.to_string());
350	    let value = spec["value"].as_str().map(|s| s.to_string());
351	    let titel = spec["titel"].as_str().unwrap_or("").to_string();
352	    let erklaerung = spec["erklaerung"].as_str().unwrap_or("").to_string();
353
354	    let mut stmt = con.prepare(&sql).map_err(|e| format!("SQL-Fehler: {e}"))?;
355	    let columns: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();
356	    let ncol = columns.len();
357	    let rows_iter = stmt
358	        .query_map([], |r| {
359	            let mut row = Vec::with_capacity(ncol);
360	            for i in 0..ncol {
361	                row.push(cell_to_json(r.get_ref(i)?));
362	            }
363	            Ok(row)
364	        })
365	        .map_err(|e| e.to_string())?;
366	    let mut rows = Vec::new();
367	    for r in rows_iter {
368	        rows.push(r.map_err(|e| e.to_string())?);
369	    }
370
371	    Ok(AiAntwort {
372	        frage,
373	        sql,
374	        chart,
375	        label,
376	        value,
377	        titel,
378	        erklaerung,
379	        columns,
380	        rows,
381	    })
382	}
383
384	#[tauri::command]
385	async fn frage_ai(frage: String, modell: String, thinking: bool) -> Result<AiAntwort, String> {
386	    tauri::async_runtime::spawn_blocking(move || frage_ai_inner(frage, modell, thinking))
387	        .await
388	        .map_err(|e| e.to_string())?
389	}
390
391	// ---------------------------------------------------------------------------
392	// Vorschlagsengine: Profil (Gewerk / Land / Größe / Fahrzeug) -> Korrelations-
393	// analyse über den Bestand: je Merkmal wird gemessen, ob es die Modulwahl
394	// verschiebt; Merkmale ohne Einfluss fließen nicht in den Bereich ein.
395	// Je Modul: Anteil im Bereich vs. gesamt (Lift). Claude macht aus den Zahlen
396	// die Vorschlagskonfiguration mit Begründung. Harte Regeln (Tabelle regeln)
397	// flaggen Konflikte mit dem gewählten Fahrzeug, sperren aber nicht.
398	// ---------------------------------------------------------------------------
399
400	#[derive(Serialize, Clone)]
401	struct KorrModul {
402	    modul_id: String,
403	    klartext: String,
404	    n_bereich: i64,
405	    p_bereich: f64, // Anteil der Bereichs-Aufträge mit diesem Modul (0..1)
406	    p_gesamt: f64,
407	    lift: f64,
408	}
409
410	#[derive(Serialize)]
411	struct VorschlagPosition {
412	    modul_id: String,
413	    artikelnummer: Option<String>,
414	    klartext: String,
415	    menge: i64,
416	    einzelpreis_eur: f64, // Ø aus den Bereichs-Aufträgen
417	    begruendung: String,
418	    p_bereich: f64,
419	    p_gesamt: f64,
420	    lift: f64,
421	    flag: Option<String>, // Regelkonflikt mit dem gewählten Fahrzeug
422	}
423
424	#[derive(Serialize)]
425	struct VorschlagAntwort {
426	    n_basis: i64, // Aufträge im Bereich
427	    n_gesamt: i64,
428	    mit_einfluss: Vec<String>,
429	    ohne_einfluss: Vec<String>,
430	    erklaerung: String,
431	    positionen: Vec<VorschlagPosition>,
432	    korrelationen: Vec<KorrModul>,
433	}
434
435	// Einfluss-Kriterium: ein Merkmal zählt nur, wenn es die Modulwahl messbar
436	// verschiebt — mindestens MIN_VERSCHOBEN Module mit Lift außerhalb
437	// [LIFT_TIEF, LIFT_HOCH] (Überrepräsentation ab MIN_SUPPORT Aufträgen im
438	// Segment, Unterrepräsentation nur bei global gängigen Modulen). Segmente
439	// unter MIN_SEGMENT Aufträgen tragen keine belastbare Aussage.
440	const MIN_SUPPORT: i64 = 3;
441	const MIN_SEGMENT: i64 = 5;
442	const LIFT_HOCH: f64 = 1.5;
443	const LIFT_TIEF: f64 = 0.5;
444	const MIN_VERSCHOBEN: usize = 2;
445
446	const ORDER_JOINS: &str = "auftraege a \
447	     JOIN kunden k ON k.kunde_id = a.kunde_id \
448	     LEFT JOIN kunde_firmendaten f ON f.kunde_id = a.kunde_id \
449	     LEFT JOIN auftrag_fzg v ON v.auftrag_id = a.auftrag_id";
450
451	const POS_JOINS: &str = "positionen p \
452	     JOIN auftraege a ON a.auftrag_id = p.auftrag_id \
453	     JOIN kunden k ON k.kunde_id = a.kunde_id \
454	     LEFT JOIN kunde_firmendaten f ON f.kunde_id = a.kunde_id \
455	     LEFT JOIN auftrag_fzg v ON v.auftrag_id = a.auftrag_id";
456
457	fn where_und(conds: &[&str]) -> String {
458	    if conds.is_empty() {
459	        "1=1".to_string()
460	    } else {
461	        conds.join(" AND ")
462	    }
463	}
464
465	fn order_count(con: &Connection, conds: &[&str], werte: &[&String]) -> Result<i64, String> {
466	    con.query_row(
467	        &format!(
468	            "SELECT COUNT(DISTINCT a.auftrag_id) FROM {ORDER_JOINS} WHERE {}",
469	            where_und(conds)
470	        ),
471	        rusqlite::params_from_iter(werte.iter()),
472	        |r| r.get(0),
473	    )
474	    .map_err(|e| e.to_string())
475	}
476
477	// Modul -> Anzahl Aufträge (distinct) im Segment
478	fn modul_counts(
479	    con: &Connection,
480	    conds: &[&str],
481	    werte: &[&String],
482	) -> Result<std::collections::HashMap<String, i64>, String> {
483	    let mut stmt = con
484	        .prepare(&format!(
485	            "SELECT p.modul_id, COUNT(DISTINCT p.auftrag_id) \
486	             FROM {POS_JOINS} \
487	             WHERE {} GROUP BY p.modul_id",
488	            where_und(conds)
489	        ))
490	        .map_err(|e| e.to_string())?;
491	    let rows = stmt
492	        .query_map(rusqlite::params_from_iter(werte.iter()), |r| {
493	            Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))
494	        })
495	        .map_err(|e| e.to_string())?;
496	    let mut out = std::collections::HashMap::new();
497	    for row in rows {
498	        let (id, n) = row.map_err(|e| e.to_string())?;
499	        out.insert(id, n);
500	    }
501	    Ok(out)
502	}
503
504	const VORSCHLAG_TMPL: &str = "Du konfigurierst Würth-Fahrzeugeinrichtungen. Grundlage ist eine \
505	Korrelationsanalyse über den Auftragsbestand: je Modul der Anteil der Aufträge im Bereich \
506	(= Profil), die es enthalten, verglichen mit dem Gesamtbestand (Lift = Anteil Bereich / Anteil \
507	gesamt). Stelle EINE stimmige Vorschlagskonfiguration zusammen. Antworte AUSSCHLIESSLICH mit \
508	einem JSON-Objekt, keine Erklärung davor/danach, keine Markdown-Zäune.\n\
509	Regeln:\n\
510	- Verwende ausschließlich modul_id-Werte aus der Liste.\n\
511	- Bevorzuge Module mit hohem Anteil im Bereich; hoher Lift heißt charakteristisch für den Bereich.\n\
512	- Eine komplette Einrichtung, typisch 6-15 Positionen; menge ganzzahlig, an der Ø-Menge orientiert.\n\
513	- begruendung je Position: kurz, mit Zahlen aus der Analyse (z. B. 'in 68 % der Aufträge im Bereich, Lift 2,1').\n\
514	- erklaerung: 2-3 Sätze zur Konfiguration; nenne auch, welche Profilmerkmale eingeflossen sind \
515	und welche ohne Einfluss blieben.\n\
516	JSON-Format:\n\
517	{\"erklaerung\": \"<2-3 Sätze>\", \"positionen\": [{\"modul_id\": \"<aus Liste>\", \"menge\": 1, \
518	\"begruendung\": \"<kurz>\"}]}";
519
520	fn vorschlag_ai_inner(
521	    gewerk: String,
522	    land: String,
523	    groesse: String,
524	    fzg_klasse: String,
525	    fzg_groesse: String,
526	) -> Result<VorschlagAntwort, String> {
527	    let con = Connection::open(DB_PATH).map_err(|e| e.to_string())?;
528	    let n_gesamt = order_count(&con, &[], &[])?;
529
530	    // Globale Modul-Statistik (Klartext, Artikelnummer, Häufigkeit im Gesamtbestand)
531	    struct ModulInfo {
532	        klartext: String,
533	        artikelnummer: Option<String>,
534	        n_all: i64,
535	    }
536	    let mut stmt = con
537	        .prepare(
538	            "SELECT p.modul_id, COALESCE(m.klartext, p.modul_id), m.artikelnummer, \
539	             COUNT(DISTINCT p.auftrag_id) \
540	             FROM positionen p LEFT JOIN module m ON m.modul_id = p.modul_id \
541	             GROUP BY p.modul_id",
542	        )
543	        .map_err(|e| e.to_string())?;
544	    let rows = stmt
545	        .query_map([], |r| {
546	            Ok((
547	                r.get::<_, String>(0)?,
548	                ModulInfo {
549	                    klartext: r.get(1)?,
550	                    artikelnummer: r.get(2)?,
551	                    n_all: r.get(3)?,
552	                },
553	            ))
554	        })
555	        .map_err(|e| e.to_string())?;
556	    let mut global = std::collections::HashMap::new();
557	    for row in rows {
558	        let (id, info) = row.map_err(|e| e.to_string())?;
559	        global.insert(id, info);
560	    }
561
562	    // Merkmals-Relevanz: verschiebt das einzelne Merkmal die Modulwahl?
563	    let gesetzt: Vec<(&str, &str, &String)> = [
564	        ("Branche", "f.gewerk = ?", &gewerk),
565	        ("Land", "k.land = ?", &land),
566	        ("Größe", "f.mitarbeiter_kl = ?", &groesse),
567	        ("Fahrzeugklasse", "v.fzg_klasse = ?", &fzg_klasse),
568	        ("Fahrzeuggröße", "v.groesse = ?", &fzg_groesse),
569	    ]
570	    .into_iter()
571	    .filter(|(_, _, w)| !w.is_empty())
572	    .collect();
573
574	    let mut mit_einfluss: Vec<String> = Vec::new();
575	    let mut ohne_einfluss: Vec<String> = Vec::new();
576	    let mut b_conds: Vec<&str> = Vec::new();
577	    let mut b_werte: Vec<&String> = Vec::new();
578	    for (name, cond, wert) in &gesetzt {
579	        let m = order_count(&con, &[cond], &[wert])?;
580	        let einfluss = if m < MIN_SEGMENT {
581	            false
582	        } else {
583	            let seg = modul_counts(&con, &[cond], &[wert])?;
584	            let verschoben = global
585	                .iter()
586	                .filter(|(id, info)| {
587	                    let n_seg = *seg.get(*id).unwrap_or(&0);
588	                    let p_seg = n_seg as f64 / m as f64;
589	                    let p_all = info.n_all as f64 / n_gesamt as f64;
590	                    let lift = p_seg / p_all;
591	                    (n_seg >= MIN_SUPPORT && lift >= LIFT_HOCH)
592	                        || (info.n_all >= MIN_SEGMENT && lift <= LIFT_TIEF)
593	                })
594	                .count();
595	            verschoben >= MIN_VERSCHOBEN
596	        };
597	        if einfluss {
598	            mit_einfluss.push(name.to_string());
599	            b_conds.push(cond);
600	            b_werte.push(wert);
601	        } else {
602	            ohne_einfluss.push(name.to_string());
603	        }
604	    }
605
606	    // Bereich = Schnitt der einflussreichen Merkmale (keine -> Gesamtbestand)
607	    let n_basis = order_count(&con, &b_conds, &b_werte)?;
608	    if n_basis == 0 {
609	        return Err(
610	            "Die einflussreichen Merkmale treffen zusammen keine Aufträge.".to_string(),
611	        );
612	    }
613	    let ber_counts = modul_counts(&con, &b_conds, &b_werte)?;
614
615	    // Ø Menge und Ø Preis (EUR) je Modul aus den Bereichs-Aufträgen
616	    let mut stmt = con
617	        .prepare(&format!(
618	            "SELECT p.modul_id, AVG(COALESCE(p.menge, 1)), \
619	             AVG(p.preis / COALESCE(a.wechselkurs, 1)) \
620	             FROM {POS_JOINS} \
621	             WHERE {} GROUP BY p.modul_id",
622	            where_und(&b_conds)
623	        ))
624	        .map_err(|e| e.to_string())?;
625	    let rows = stmt
626	        .query_map(rusqlite::params_from_iter(b_werte.iter()), |r| {
627	            Ok((r.get::<_, String>(0)?, (r.get::<_, f64>(1)?, r.get::<_, f64>(2)?)))
628	        })
629	        .map_err(|e| e.to_string())?;
630	    let mut ber_preise = std::collections::HashMap::new();
631	    for row in rows {
632	        let (id, mp) = row.map_err(|e| e.to_string())?;
633	        ber_preise.insert(id, mp);
634	    }
635
636	    // Korrelationstabelle: wiederkehrende Module des Bereichs mit Lift
637	    let mut korrelationen: Vec<KorrModul> = ber_counts
638	        .iter()
639	        .filter(|(_, n)| **n >= 2)
640	        .map(|(id, n)| {
641	            let info = &global[id];
642	            let p_bereich = *n as f64 / n_basis as f64;
643	            let p_gesamt = info.n_all as f64 / n_gesamt as f64;
644	            KorrModul {
645	                modul_id: id.clone(),
646	                klartext: info.klartext.clone(),
647	                n_bereich: *n,
648	                p_bereich,
649	                p_gesamt,
650	                lift: p_bereich / p_gesamt,
651	            }
652	        })
653	        .collect();
654	    korrelationen.sort_unstable_by(|a, b| {
655	        b.n_bereich
656	            .cmp(&a.n_bereich)
657	            .then(b.lift.total_cmp(&a.lift))
658	    });
659	    korrelationen.truncate(60);
660	    if korrelationen.is_empty() {
661	        return Err("Keine wiederkehrenden Module im Bereich.".to_string());
662	    }
663
664	    // Prompt: Profilstatus + Korrelationszeilen
665	    let mut user = String::from("Profil:\n");
666	    for (name, _, wert) in [
667	        ("Branche", "", &gewerk),
668	        ("Land", "", &land),
669	        ("Größe (Mitarbeiter)", "", &groesse),
670	        ("Fahrzeugklasse", "", &fzg_klasse),
671	        ("Fahrzeuggröße", "", &fzg_groesse),
672	    ] {
673	        let kurz = name.split(' ').next().unwrap();
674	        let status = if wert.is_empty() {
675	            "nicht gesetzt".to_string()
676	        } else if mit_einfluss.iter().any(|e| e == kurz) {
677	            format!("{wert} (eingeflossen)")
678	        } else {
679	            format!("{wert} (ohne messbaren Einfluss auf die Modulwahl — ignoriert)")
680	        };
681	        user.push_str(&format!("- {name}: {status}\n"));
682	    }
683	    user.push_str(&format!(
684	        "Bereich: {n_basis} von {n_gesamt} Aufträgen.\n\n\
685	         Korrelationen (modul_id | Bezeichnung | Artikelnr | Anteil Bereich | Anteil gesamt | Lift | Ø Menge | Ø Preis EUR):\n"
686	    ));
687	    for k in &korrelationen {
688	        let (avg_menge, avg_preis) = ber_preise[&k.modul_id];
689	        user.push_str(&format!(
690	            "{} | {} | {} | {:.0} % ({} von {}) | {:.0} % | {:.2} | {:.1} | {:.2}\n",
691	            k.modul_id,
692	            k.klartext,
693	            global[&k.modul_id].artikelnummer.as_deref().unwrap_or("—"),
694	            100.0 * k.p_bereich,
695	            k.n_bereich,
696	            n_basis,
697	            100.0 * k.p_gesamt,
698	            k.lift,
699	            avg_menge,
700	            avg_preis
701	        ));
702	    }
703
704	    let text = claude_text("claude-sonnet-5", true, 4096, VORSCHLAG_TMPL, &user)?;
705	    let spec = json_objekt(&text)?;
706
707	    let erklaerung = spec["erklaerung"].as_str().unwrap_or("").to_string();
708	    let pos_arr = spec["positionen"]
709	        .as_array()
710	        .ok_or("keine positionen im LLM-JSON")?;
711
712	    // Regelabgleich gegen das gewählte Fahrzeug: flaggt, sperrt nicht.
713	    // Ohne gewähltes Fahrzeug greift keine der (fahrzeugbedingten) Regeln.
714	    let mut regel_stmt = con
715	        .prepare(
716	            "SELECT text FROM regeln \
717	             WHERE (fzg_klasse IS NULL OR fzg_klasse = ?1) \
718	             AND (groesse_muster IS NULL OR ?2 LIKE groesse_muster) \
719	             AND (?3 LIKE klartext_muster OR ?4 LIKE klartext_muster) \
720	             LIMIT 1",
721	        )
722	        .map_err(|e| e.to_string())?;
723
724	    let korr_map: std::collections::HashMap<&str, &KorrModul> = korrelationen
725	        .iter()
726	        .map(|k| (k.modul_id.as_str(), k))
727	        .collect();
728	    let mut positionen = Vec::new();
729	    for p in pos_arr {
730	        let id = p["modul_id"].as_str().ok_or("modul_id fehlt im LLM-JSON")?;
731	        let menge = p["menge"]
732	            .as_i64()
733	            .ok_or_else(|| format!("menge fehlt bei {id}"))?;
734	        let k = korr_map
735	            .get(id)
736	            .ok_or_else(|| format!("LLM lieferte modul_id außerhalb der Korrelationsliste: {id}"))?;
737	        let flag: Option<String> = regel_stmt
738	            .query_row(
739	                rusqlite::params![fzg_klasse, fzg_groesse, k.klartext, k.modul_id],
740	                |r| r.get(0),
741	            )
742	            .optional()
743	            .map_err(|e| e.to_string())?;
744	        positionen.push(VorschlagPosition {
745	            modul_id: id.to_string(),
746	            artikelnummer: global[id].artikelnummer.clone(),
747	            klartext: k.klartext.clone(),
748	            menge,
749	            einzelpreis_eur: ber_preise[id].1,
750	            begruendung: p["begruendung"].as_str().unwrap_or("").to_string(),
751	            p_bereich: k.p_bereich,
752	            p_gesamt: k.p_gesamt,
753	            lift: k.lift,
754	            flag,
755	        });
756	    }
757
758	    Ok(VorschlagAntwort {
759	        n_basis,
760	        n_gesamt,
761	        mit_einfluss,
762	        ohne_einfluss,
763	        erklaerung,
764	        positionen,
765	        korrelationen,
766	    })
767	}
768
769	#[tauri::command]
770	async fn vorschlag_ai(
771	    gewerk: String,
772	    land: String,
773	    groesse: String,
774	    fzg_klasse: String,
775	    fzg_groesse: String,
776	) -> Result<VorschlagAntwort, String> {
777	    tauri::async_runtime::spawn_blocking(move || {
778	        vorschlag_ai_inner(gewerk, land, groesse, fzg_klasse, fzg_groesse)
779	    })
780	    .await
781	    .map_err(|e| e.to_string())?
782	}
783
784	#[cfg_attr(mobile, tauri::mobile_entry_point)]
785	pub fn run() {
786	    tauri::Builder::default()
787	        .plugin(tauri_plugin_opener::init())
788	        .invoke_handler(tauri::generate_handler![
789	            list_kunden_360,
790	            list_auftraege,
791	            list_positionen,
792	            frage_ai,
793	            vorschlag_ai
794	        ])
795	        .run(tauri::generate_context!())
796	        .expect("error while running tauri application");
797	}
798

use rusqlite::{Connection, OptionalExtension};
use serde::Serialize;

const DB_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../data/wuerth.db");

#[derive(Serialize)]
struct Kunde360 {
    kunde_id: i64,
    // geliefert
    name_roh: String,
    name_1: Option<String>,
    strasse: Option<String>,
    plz: Option<String>,
    land: Option<String>,
    n_auftraege: i64,
    umsatz_netto: f64,
    // angereichert
    branche_text: Option<String>,
    gewerk: Option<String>,
    segment: Option<String>,
    mitarbeiter_kl: Option<String>,
    gruendungsjahr: Option<i64>,
    rechtsform: Option<String>,
    website: Option<String>,
    ort: Option<String>,
    konfidenz: Option<f64>,
    bestaetigt: Option<i64>,
    quelle_primaer: Option<String>,
}

#[tauri::command]
fn list_kunden_360() -> Result<Vec<Kunde360>, String> {
    let con = Connection::open(DB_PATH).map_err(|e| e.to_string())?;
    let mut stmt = con
        .prepare(
            "SELECT kunde_id, name_roh, name_1, strasse, plz, land, n_auftraege, \
             umsatz_netto, branche_text, gewerk, segment, mitarbeiter_kl, gruendungsjahr, \
             rechtsform, website, ort, konfidenz, bestaetigt, quelle_primaer \
             FROM v_kunde_360 ORDER BY name_roh",
        )
        .map_err(|e| e.to_string())?;
    let rows = stmt
        .query_map([], |r| {
            Ok(Kunde360 {
                kunde_id: r.get(0)?,
                name_roh: r.get(1)?,
                name_1: r.get(2)?,
                strasse: r.get(3)?,
                plz: r.get(4)?,
                land: r.get(5)?,
                n_auftraege: r.get(6)?,
                umsatz_netto: r.get(7)?,
                branche_text: r.get(8)?,
                gewerk: r.get(9)?,
                segment: r.get(10)?,
                mitarbeiter_kl: r.get(11)?,
                gruendungsjahr: r.get(12)?,
                rechtsform: r.get(13)?,
                website: r.get(14)?,
                ort: r.get(15)?,
                konfidenz: r.get(16)?,
                bestaetigt: r.get(17)?,
                quelle_primaer: r.get(18)?,
            })
        })
        .map_err(|e| e.to_string())?;
    let mut out = Vec::new();
    for row in rows {
        out.push(row.map_err(|e| e.to_string())?);
    }
    Ok(out)
}

#[derive(Serialize)]
struct Auftrag {
    kunde_id: i64,
    auftrag_id: String,
    datum: Option<String>,
    fzg_klasse: Option<String>,
    fzg_hersteller: Option<String>,
    fzg_modell: Option<String>,
    fzg_groesse: Option<String>,
    umsatz_netto: f64, // Landeswährung
    umsatz_eur: f64,   // umgerechnet
    waehrung: Option<String>,
    wechselkurs: Option<f64>,
    kurs_datum: Option<String>,
    quotation_state: Option<String>,
}

#[tauri::command]
fn list_auftraege() -> Result<Vec<Auftrag>, String> {
    let con = Connection::open(DB_PATH).map_err(|e| e.to_string())?;
    let mut stmt = con
        .prepare(
            "SELECT a.kunde_id, a.auftrag_id, a.datum, f.fzg_klasse, f.hersteller, \
             f.modell, f.groesse, a.kundenpreis_netto, \
             a.kundenpreis_netto / COALESCE(a.wechselkurs, 1) AS umsatz_eur, \
             a.waehrung, a.wechselkurs, a.kurs_datum, a.quotation_state \
             FROM auftraege a LEFT JOIN auftrag_fzg f USING (auftrag_id) \
             ORDER BY a.datum DESC",
        )
        .map_err(|e| e.to_string())?;
    let rows = stmt
        .query_map([], |r| {
            Ok(Auftrag {
                kunde_id: r.get(0)?,
                auftrag_id: r.get(1)?,
                datum: r.get(2)?,
                fzg_klasse: r.get(3)?,
                fzg_hersteller: r.get(4)?,
                fzg_modell: r.get(5)?,
                fzg_groesse: r.get(6)?,
                umsatz_netto: r.get(7)?,
                umsatz_eur: r.get(8)?,
                waehrung: r.get(9)?,
                wechselkurs: r.get(10)?,
                kurs_datum: r.get(11)?,
                quotation_state: r.get(12)?,
            })
        })
        .map_err(|e| e.to_string())?;
    let mut out = Vec::new();
    for row in rows {
        out.push(row.map_err(|e| e.to_string())?);
    }
    Ok(out)
}

#[derive(Serialize)]
struct Position {
    modul_id: String,
    artikelnummer: Option<String>,
    klartext: String,
    menge: i64,
    einzelpreis: f64,
    pos_wert: f64,
    eur_wert: f64,
}

#[tauri::command]
fn list_positionen(auftrag_id: String) -> Result<Vec<Position>, String> {
    let con = Connection::open(DB_PATH).map_err(|e| e.to_string())?;
    let mut stmt = con
        .prepare(
            "SELECT p.modul_id, m.artikelnummer, COALESCE(m.klartext, p.modul_id), \
             COALESCE(p.menge, 1), p.preis, p.preis * COALESCE(p.menge, 1) AS pos_wert, \
             p.preis * COALESCE(p.menge, 1) / COALESCE(a.wechselkurs, 1) AS eur_wert \
             FROM positionen p LEFT JOIN module m USING (modul_id) \
             JOIN auftraege a ON a.auftrag_id = p.auftrag_id \
             WHERE p.auftrag_id = ?1 ORDER BY pos_wert DESC",
        )
        .map_err(|e| e.to_string())?;
    let rows = stmt
        .query_map([auftrag_id], |r| {
            Ok(Position {
                modul_id: r.get(0)?,
                artikelnummer: r.get(1)?,
                klartext: r.get(2)?,
                menge: r.get(3)?,
                einzelpreis: r.get(4)?,
                pos_wert: r.get(5)?,
                eur_wert: r.get(6)?,
            })
        })
        .map_err(|e| e.to_string())?;
    let mut out = Vec::new();
    for row in rows {
        out.push(row.map_err(|e| e.to_string())?);
    }
    Ok(out)
}

// ---------------------------------------------------------------------------
// Natursprache-Assistent: Frage -> Claude erzeugt read-only SQL + Chart-Spec
// -> Rust prüft (nur SELECT) und führt aus -> Frontend rendert.
// ---------------------------------------------------------------------------

#[derive(Serialize)]
struct AiAntwort {
    frage: String,
    sql: String,
    chart: String,             // kpi | bar | table
    label: Option<String>,     // Spaltenname für Kategorie
    value: Option<String>,     // Spaltenname für Wert
    titel: String,
    erklaerung: String,
    columns: Vec<String>,
    rows: Vec<Vec<serde_json::Value>>,
}

fn read_api_key() -> Result<String, String> {
    let home = std::env::var("HOME").map_err(|_| "HOME nicht gesetzt".to_string())?;
    let path = format!("{home}/.ssh/anthropic_api_key");
    let content =
        std::fs::read_to_string(&path).map_err(|e| format!("Key-Datei {path}: {e}"))?;
    content
        .lines()
        .map(|l| l.trim())
        .find(|l| l.starts_with("sk-ant-"))
        .map(|s| s.to_string())
        .ok_or_else(|| format!("kein sk-ant-Key in {path}"))
}

fn db_schema(con: &Connection) -> Result<String, String> {
    let mut stmt = con
        .prepare(
            "SELECT sql FROM sqlite_master \
             WHERE type IN ('table','view') AND sql IS NOT NULL \
             AND name NOT LIKE 'sqlite_%' ORDER BY type DESC, name",
        )
        .map_err(|e| e.to_string())?;
    let rows = stmt
        .query_map([], |r| r.get::<_, String>(0))
        .map_err(|e| e.to_string())?;
    let mut out = String::new();
    for r in rows {
        out.push_str(&r.map_err(|e| e.to_string())?);
        out.push_str(";\n");
    }
    Ok(out)
}

fn is_read_only(sql: &str) -> bool {
    let up = sql.trim().to_uppercase();
    if !(up.starts_with("SELECT") || up.starts_with("WITH")) {
        return false;
    }
    // kein Statement-Trenner (nur SELECT), keine schreibenden Schlüsselwörter
    if sql.trim_end().trim_end_matches(';').contains(';') {
        return false;
    }
    const VERBOTEN: [&str; 9] = [
        "INSERT ", "UPDATE ", "DELETE ", "DROP ", "ALTER ", "CREATE ", "ATTACH ",
        "PRAGMA ", "REPLACE ",
    ];
    !VERBOTEN.iter().any(|w| up.contains(w))
}

fn cell_to_json(v: rusqlite::types::ValueRef) -> serde_json::Value {
    use rusqlite::types::ValueRef;
    match v {
        ValueRef::Null => serde_json::Value::Null,
        ValueRef::Integer(n) => serde_json::Value::from(n),
        ValueRef::Real(f) => serde_json::Value::from(f),
        ValueRef::Text(t) => serde_json::Value::from(String::from_utf8_lossy(t).to_string()),
        ValueRef::Blob(_) => serde_json::Value::from("<blob>"),
    }
}

const SYSTEM_TMPL: &str = "Du übersetzt deutsche Fragen in **eine** SQLite-Abfrage über die \
folgende Datenbank und wählst die beste Visualisierung. Antworte AUSSCHLIESSLICH mit einem \
JSON-Objekt, keine Erklärung davor/danach, keine Markdown-Zäune.\n\n\
Schema:\n{SCHEMA}\n\
Hinweise:\n\
- Nur eine read-only SELECT-Abfrage (ggf. mit WITH). Kein Semikolon, kein INSERT/UPDATE/DELETE/DDL.\n\
- Beträge liegen in Landeswährung; EUR = Betrag / COALESCE(wechselkurs,1). auftraege.kundenpreis_netto ist der Auftragswert.\n\
- v_kunde_360.umsatz_netto ist bereits EUR-normiert. gewerk/land/segment kommen aus der Anreicherung.\n\
- Kunden-Anzeigename ist kunden.name_roh (bzw. v_kunde_360.name_roh), NICHT name_1.\n\
- Für Rankings/Vergleiche LIMIT setzen (z.B. 10). Spaltennamen sprechend aliasen.\n\n\
JSON-Format:\n\
{\"sql\": \"<SELECT ...>\", \"chart\": \"kpi|bar|table\", \"label\": \"<Spalte für Kategorie oder null>\", \
\"value\": \"<Spalte für Wert oder null>\", \"titel\": \"<kurzer Titel>\", \"erklaerung\": \"<1 Satz>\"}\n\
- chart=kpi: eine Kennzahl (value = die Wertspalte). chart=bar: label + value. chart=table: sonst.";

// Ein Claude-Aufruf (system + eine User-Nachricht), gibt den text-Block zurück.
// Im Thinking-Modus steht ein thinking-Block vor dem text-Block -> gezielt den text-Block nehmen.
fn claude_text(
    modell: &str,
    thinking: bool,
    max_tokens_plain: u32,
    system: &str,
    user: &str,
) -> Result<String, String> {
    let key = read_api_key()?;
    // Thinking-Konfiguration je Modell; bei Thinking mehr Output-Budget
    let (thinking_cfg, max_tokens) = if !thinking {
        (serde_json::json!({"type": "disabled"}), max_tokens_plain)
    } else if modell.starts_with("claude-haiku") {
        (serde_json::json!({"type": "enabled", "budget_tokens": 2000}), 4096)
    } else {
        (serde_json::json!({"type": "adaptive"}), 4096)
    };

    let body = serde_json::json!({
        "model": modell,
        "max_tokens": max_tokens,
        "thinking": thinking_cfg,
        "system": system,
        "messages": [{"role": "user", "content": user}]
    });

    let client = reqwest::blocking::Client::builder()
        .timeout(std::time::Duration::from_secs(60))
        .build()
        .map_err(|e| e.to_string())?;
    let resp = client
        .post("https://api.anthropic.com/v1/messages")
        .header("x-api-key", key)
        .header("anthropic-version", "2023-06-01")
        .header("content-type", "application/json")
        .json(&body)
        .send()
        .map_err(|e| format!("API-Aufruf fehlgeschlagen: {e}"))?;

    let status = resp.status();
    let json: serde_json::Value = resp.json().map_err(|e| e.to_string())?;
    if !status.is_success() {
        return Err(format!("API {status}: {}", json));
    }
    json["content"]
        .as_array()
        .and_then(|blocks| {
            blocks
                .iter()
                .find(|b| b["type"] == "text")
                .and_then(|b| b["text"].as_str())
        })
        .map(|s| s.to_string())
        .ok_or_else(|| format!("kein Text in API-Antwort: {json}"))
}

// JSON-Objekt aus dem Antworttext herausschneiden (erstes { bis letztes })
fn json_objekt(text: &str) -> Result<serde_json::Value, String> {
    let start = text.find('{').ok_or("kein JSON in der Antwort")?;
    let end = text.rfind('}').ok_or("kein JSON in der Antwort")?;
    serde_json::from_str(&text[start..=end]).map_err(|e| format!("JSON-Parse: {e}"))
}

fn frage_ai_inner(frage: String, modell: String, thinking: bool) -> Result<AiAntwort, String> {
    let con = Connection::open(DB_PATH).map_err(|e| e.to_string())?;
    let schema = db_schema(&con)?;
    let system = SYSTEM_TMPL.replace("{SCHEMA}", &schema);

    // Modell auf erlaubte Werte begrenzen
    let modell = match modell.as_str() {
        "claude-opus-4-8" | "claude-sonnet-5" | "claude-haiku-4-5" => modell,
        _ => "claude-sonnet-5".to_string(),
    };

    let text = claude_text(&modell, thinking, 1024, &system, &frage)?;
    let spec = json_objekt(&text)?;

    let sql = spec["sql"].as_str().unwrap_or("").trim().to_string();
    if !is_read_only(&sql) {
        return Err(format!("Abfrage abgelehnt (nicht read-only): {sql}"));
    }
    let chart = spec["chart"].as_str().unwrap_or("table").to_string();
    let label = spec["label"].as_str().map(|s| s.to_string());
    let value = spec["value"].as_str().map(|s| s.to_string());
    let titel = spec["titel"].as_str().unwrap_or("").to_string();
    let erklaerung = spec["erklaerung"].as_str().unwrap_or("").to_string();

    let mut stmt = con.prepare(&sql).map_err(|e| format!("SQL-Fehler: {e}"))?;
    let columns: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();
    let ncol = columns.len();
    let rows_iter = stmt
        .query_map([], |r| {
            let mut row = Vec::with_capacity(ncol);
            for i in 0..ncol {
                row.push(cell_to_json(r.get_ref(i)?));
            }
            Ok(row)
        })
        .map_err(|e| e.to_string())?;
    let mut rows = Vec::new();
    for r in rows_iter {
        rows.push(r.map_err(|e| e.to_string())?);
    }

    Ok(AiAntwort {
        frage,
        sql,
        chart,
        label,
        value,
        titel,
        erklaerung,
        columns,
        rows,
    })
}

#[tauri::command]
async fn frage_ai(frage: String, modell: String, thinking: bool) -> Result<AiAntwort, String> {
    tauri::async_runtime::spawn_blocking(move || frage_ai_inner(frage, modell, thinking))
        .await
        .map_err(|e| e.to_string())?
}

// ---------------------------------------------------------------------------
// Vorschlagsengine: Profil (Gewerk / Land / Größe / Fahrzeug) -> Korrelations-
// analyse über den Bestand: je Merkmal wird gemessen, ob es die Modulwahl
// verschiebt; Merkmale ohne Einfluss fließen nicht in den Bereich ein.
// Je Modul: Anteil im Bereich vs. gesamt (Lift). Claude macht aus den Zahlen
// die Vorschlagskonfiguration mit Begründung. Harte Regeln (Tabelle regeln)
// flaggen Konflikte mit dem gewählten Fahrzeug, sperren aber nicht.
// ---------------------------------------------------------------------------

#[derive(Serialize, Clone)]
struct KorrModul {
    modul_id: String,
    klartext: String,
    n_bereich: i64,
    p_bereich: f64, // Anteil der Bereichs-Aufträge mit diesem Modul (0..1)
    p_gesamt: f64,
    lift: f64,
}

#[derive(Serialize)]
struct VorschlagPosition {
    modul_id: String,
    artikelnummer: Option<String>,
    klartext: String,
    menge: i64,
    einzelpreis_eur: f64, // Ø aus den Bereichs-Aufträgen
    begruendung: String,
    p_bereich: f64,
    p_gesamt: f64,
    lift: f64,
    flag: Option<String>, // Regelkonflikt mit dem gewählten Fahrzeug
}

#[derive(Serialize)]
struct VorschlagAntwort {
    n_basis: i64, // Aufträge im Bereich
    n_gesamt: i64,
    mit_einfluss: Vec<String>,
    ohne_einfluss: Vec<String>,
    erklaerung: String,
    positionen: Vec<VorschlagPosition>,
    korrelationen: Vec<KorrModul>,
}

// Einfluss-Kriterium: ein Merkmal zählt nur, wenn es die Modulwahl messbar
// verschiebt — mindestens MIN_VERSCHOBEN Module mit Lift außerhalb
// [LIFT_TIEF, LIFT_HOCH] (Überrepräsentation ab MIN_SUPPORT Aufträgen im
// Segment, Unterrepräsentation nur bei global gängigen Modulen). Segmente
// unter MIN_SEGMENT Aufträgen tragen keine belastbare Aussage.
const MIN_SUPPORT: i64 = 3;
const MIN_SEGMENT: i64 = 5;
const LIFT_HOCH: f64 = 1.5;
const LIFT_TIEF: f64 = 0.5;
const MIN_VERSCHOBEN: usize = 2;

const ORDER_JOINS: &str = "auftraege a \
     JOIN kunden k ON k.kunde_id = a.kunde_id \
     LEFT JOIN kunde_firmendaten f ON f.kunde_id = a.kunde_id \
     LEFT JOIN auftrag_fzg v ON v.auftrag_id = a.auftrag_id";

const POS_JOINS: &str = "positionen p \
     JOIN auftraege a ON a.auftrag_id = p.auftrag_id \
     JOIN kunden k ON k.kunde_id = a.kunde_id \
     LEFT JOIN kunde_firmendaten f ON f.kunde_id = a.kunde_id \
     LEFT JOIN auftrag_fzg v ON v.auftrag_id = a.auftrag_id";

fn where_und(conds: &[&str]) -> String {
    if conds.is_empty() {
        "1=1".to_string()
    } else {
        conds.join(" AND ")
    }
}

fn order_count(con: &Connection, conds: &[&str], werte: &[&String]) -> Result<i64, String> {
    con.query_row(
        &format!(
            "SELECT COUNT(DISTINCT a.auftrag_id) FROM {ORDER_JOINS} WHERE {}",
            where_und(conds)
        ),
        rusqlite::params_from_iter(werte.iter()),
        |r| r.get(0),
    )
    .map_err(|e| e.to_string())
}

// Modul -> Anzahl Aufträge (distinct) im Segment
fn modul_counts(
    con: &Connection,
    conds: &[&str],
    werte: &[&String],
) -> Result<std::collections::HashMap<String, i64>, String> {
    let mut stmt = con
        .prepare(&format!(
            "SELECT p.modul_id, COUNT(DISTINCT p.auftrag_id) \
             FROM {POS_JOINS} \
             WHERE {} GROUP BY p.modul_id",
            where_und(conds)
        ))
        .map_err(|e| e.to_string())?;
    let rows = stmt
        .query_map(rusqlite::params_from_iter(werte.iter()), |r| {
            Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))
        })
        .map_err(|e| e.to_string())?;
    let mut out = std::collections::HashMap::new();
    for row in rows {
        let (id, n) = row.map_err(|e| e.to_string())?;
        out.insert(id, n);
    }
    Ok(out)
}

const VORSCHLAG_TMPL: &str = "Du konfigurierst Würth-Fahrzeugeinrichtungen. Grundlage ist eine \
Korrelationsanalyse über den Auftragsbestand: je Modul der Anteil der Aufträge im Bereich \
(= Profil), die es enthalten, verglichen mit dem Gesamtbestand (Lift = Anteil Bereich / Anteil \
gesamt). Stelle EINE stimmige Vorschlagskonfiguration zusammen. Antworte AUSSCHLIESSLICH mit \
einem JSON-Objekt, keine Erklärung davor/danach, keine Markdown-Zäune.\n\
Regeln:\n\
- Verwende ausschließlich modul_id-Werte aus der Liste.\n\
- Bevorzuge Module mit hohem Anteil im Bereich; hoher Lift heißt charakteristisch für den Bereich.\n\
- Eine komplette Einrichtung, typisch 6-15 Positionen; menge ganzzahlig, an der Ø-Menge orientiert.\n\
- begruendung je Position: kurz, mit Zahlen aus der Analyse (z. B. 'in 68 % der Aufträge im Bereich, Lift 2,1').\n\
- erklaerung: 2-3 Sätze zur Konfiguration; nenne auch, welche Profilmerkmale eingeflossen sind \
und welche ohne Einfluss blieben.\n\
JSON-Format:\n\
{\"erklaerung\": \"<2-3 Sätze>\", \"positionen\": [{\"modul_id\": \"<aus Liste>\", \"menge\": 1, \
\"begruendung\": \"<kurz>\"}]}";

fn vorschlag_ai_inner(
    gewerk: String,
    land: String,
    groesse: String,
    fzg_klasse: String,
    fzg_groesse: String,
) -> Result<VorschlagAntwort, String> {
    let con = Connection::open(DB_PATH).map_err(|e| e.to_string())?;
    let n_gesamt = order_count(&con, &[], &[])?;

    // Globale Modul-Statistik (Klartext, Artikelnummer, Häufigkeit im Gesamtbestand)
    struct ModulInfo {
        klartext: String,
        artikelnummer: Option<String>,
        n_all: i64,
    }
    let mut stmt = con
        .prepare(
            "SELECT p.modul_id, COALESCE(m.klartext, p.modul_id), m.artikelnummer, \
             COUNT(DISTINCT p.auftrag_id) \
             FROM positionen p LEFT JOIN module m ON m.modul_id = p.modul_id \
             GROUP BY p.modul_id",
        )
        .map_err(|e| e.to_string())?;
    let rows = stmt
        .query_map([], |r| {
            Ok((
                r.get::<_, String>(0)?,
                ModulInfo {
                    klartext: r.get(1)?,
                    artikelnummer: r.get(2)?,
                    n_all: r.get(3)?,
                },
            ))
        })
        .map_err(|e| e.to_string())?;
    let mut global = std::collections::HashMap::new();
    for row in rows {
        let (id, info) = row.map_err(|e| e.to_string())?;
        global.insert(id, info);
    }

    // Merkmals-Relevanz: verschiebt das einzelne Merkmal die Modulwahl?
    let gesetzt: Vec<(&str, &str, &String)> = [
        ("Branche", "f.gewerk = ?", &gewerk),
        ("Land", "k.land = ?", &land),
        ("Größe", "f.mitarbeiter_kl = ?", &groesse),
        ("Fahrzeugklasse", "v.fzg_klasse = ?", &fzg_klasse),
        ("Fahrzeuggröße", "v.groesse = ?", &fzg_groesse),
    ]
    .into_iter()
    .filter(|(_, _, w)| !w.is_empty())
    .collect();

    let mut mit_einfluss: Vec<String> = Vec::new();
    let mut ohne_einfluss: Vec<String> = Vec::new();
    let mut b_conds: Vec<&str> = Vec::new();
    let mut b_werte: Vec<&String> = Vec::new();
    for (name, cond, wert) in &gesetzt {
        let m = order_count(&con, &[cond], &[wert])?;
        let einfluss = if m < MIN_SEGMENT {
            false
        } else {
            let seg = modul_counts(&con, &[cond], &[wert])?;
            let verschoben = global
                .iter()
                .filter(|(id, info)| {
                    let n_seg = *seg.get(*id).unwrap_or(&0);
                    let p_seg = n_seg as f64 / m as f64;
                    let p_all = info.n_all as f64 / n_gesamt as f64;
                    let lift = p_seg / p_all;
                    (n_seg >= MIN_SUPPORT && lift >= LIFT_HOCH)
                        || (info.n_all >= MIN_SEGMENT && lift <= LIFT_TIEF)
                })
                .count();
            verschoben >= MIN_VERSCHOBEN
        };
        if einfluss {
            mit_einfluss.push(name.to_string());
            b_conds.push(cond);
            b_werte.push(wert);
        } else {
            ohne_einfluss.push(name.to_string());
        }
    }

    // Bereich = Schnitt der einflussreichen Merkmale (keine -> Gesamtbestand)
    let n_basis = order_count(&con, &b_conds, &b_werte)?;
    if n_basis == 0 {
        return Err(
            "Die einflussreichen Merkmale treffen zusammen keine Aufträge.".to_string(),
        );
    }
    let ber_counts = modul_counts(&con, &b_conds, &b_werte)?;

    // Ø Menge und Ø Preis (EUR) je Modul aus den Bereichs-Aufträgen
    let mut stmt = con
        .prepare(&format!(
            "SELECT p.modul_id, AVG(COALESCE(p.menge, 1)), \
             AVG(p.preis / COALESCE(a.wechselkurs, 1)) \
             FROM {POS_JOINS} \
             WHERE {} GROUP BY p.modul_id",
            where_und(&b_conds)
        ))
        .map_err(|e| e.to_string())?;
    let rows = stmt
        .query_map(rusqlite::params_from_iter(b_werte.iter()), |r| {
            Ok((r.get::<_, String>(0)?, (r.get::<_, f64>(1)?, r.get::<_, f64>(2)?)))
        })
        .map_err(|e| e.to_string())?;
    let mut ber_preise = std::collections::HashMap::new();
    for row in rows {
        let (id, mp) = row.map_err(|e| e.to_string())?;
        ber_preise.insert(id, mp);
    }

    // Korrelationstabelle: wiederkehrende Module des Bereichs mit Lift
    let mut korrelationen: Vec<KorrModul> = ber_counts
        .iter()
        .filter(|(_, n)| **n >= 2)
        .map(|(id, n)| {
            let info = &global[id];
            let p_bereich = *n as f64 / n_basis as f64;
            let p_gesamt = info.n_all as f64 / n_gesamt as f64;
            KorrModul {
                modul_id: id.clone(),
                klartext: info.klartext.clone(),
                n_bereich: *n,
                p_bereich,
                p_gesamt,
                lift: p_bereich / p_gesamt,
            }
        })
        .collect();
    korrelationen.sort_unstable_by(|a, b| {
        b.n_bereich
            .cmp(&a.n_bereich)
            .then(b.lift.total_cmp(&a.lift))
    });
    korrelationen.truncate(60);
    if korrelationen.is_empty() {
        return Err("Keine wiederkehrenden Module im Bereich.".to_string());
    }

    // Prompt: Profilstatus + Korrelationszeilen
    let mut user = String::from("Profil:\n");
    for (name, _, wert) in [
        ("Branche", "", &gewerk),
        ("Land", "", &land),
        ("Größe (Mitarbeiter)", "", &groesse),
        ("Fahrzeugklasse", "", &fzg_klasse),
        ("Fahrzeuggröße", "", &fzg_groesse),
    ] {
        let kurz = name.split(' ').next().unwrap();
        let status = if wert.is_empty() {
            "nicht gesetzt".to_string()
        } else if mit_einfluss.iter().any(|e| e == kurz) {
            format!("{wert} (eingeflossen)")
        } else {
            format!("{wert} (ohne messbaren Einfluss auf die Modulwahl — ignoriert)")
        };
        user.push_str(&format!("- {name}: {status}\n"));
    }
    user.push_str(&format!(
        "Bereich: {n_basis} von {n_gesamt} Aufträgen.\n\n\
         Korrelationen (modul_id | Bezeichnung | Artikelnr | Anteil Bereich | Anteil gesamt | Lift | Ø Menge | Ø Preis EUR):\n"
    ));
    for k in &korrelationen {
        let (avg_menge, avg_preis) = ber_preise[&k.modul_id];
        user.push_str(&format!(
            "{} | {} | {} | {:.0} % ({} von {}) | {:.0} % | {:.2} | {:.1} | {:.2}\n",
            k.modul_id,
            k.klartext,
            global[&k.modul_id].artikelnummer.as_deref().unwrap_or("—"),
            100.0 * k.p_bereich,
            k.n_bereich,
            n_basis,
            100.0 * k.p_gesamt,
            k.lift,
            avg_menge,
            avg_preis
        ));
    }

    let text = claude_text("claude-sonnet-5", true, 4096, VORSCHLAG_TMPL, &user)?;
    let spec = json_objekt(&text)?;

    let erklaerung = spec["erklaerung"].as_str().unwrap_or("").to_string();
    let pos_arr = spec["positionen"]
        .as_array()
        .ok_or("keine positionen im LLM-JSON")?;

    // Regelabgleich gegen das gewählte Fahrzeug: flaggt, sperrt nicht.
    // Ohne gewähltes Fahrzeug greift keine der (fahrzeugbedingten) Regeln.
    let mut regel_stmt = con
        .prepare(
            "SELECT text FROM regeln \
             WHERE (fzg_klasse IS NULL OR fzg_klasse = ?1) \
             AND (groesse_muster IS NULL OR ?2 LIKE groesse_muster) \
             AND (?3 LIKE klartext_muster OR ?4 LIKE klartext_muster) \
             LIMIT 1",
        )
        .map_err(|e| e.to_string())?;

    let korr_map: std::collections::HashMap<&str, &KorrModul> = korrelationen
        .iter()
        .map(|k| (k.modul_id.as_str(), k))
        .collect();
    let mut positionen = Vec::new();
    for p in pos_arr {
        let id = p["modul_id"].as_str().ok_or("modul_id fehlt im LLM-JSON")?;
        let menge = p["menge"]
            .as_i64()
            .ok_or_else(|| format!("menge fehlt bei {id}"))?;
        let k = korr_map
            .get(id)
            .ok_or_else(|| format!("LLM lieferte modul_id außerhalb der Korrelationsliste: {id}"))?;
        let flag: Option<String> = regel_stmt
            .query_row(
                rusqlite::params![fzg_klasse, fzg_groesse, k.klartext, k.modul_id],
                |r| r.get(0),
            )
            .optional()
            .map_err(|e| e.to_string())?;
        positionen.push(VorschlagPosition {
            modul_id: id.to_string(),
            artikelnummer: global[id].artikelnummer.clone(),
            klartext: k.klartext.clone(),
            menge,
            einzelpreis_eur: ber_preise[id].1,
            begruendung: p["begruendung"].as_str().unwrap_or("").to_string(),
            p_bereich: k.p_bereich,
            p_gesamt: k.p_gesamt,
            lift: k.lift,
            flag,
        });
    }

    Ok(VorschlagAntwort {
        n_basis,
        n_gesamt,
        mit_einfluss,
        ohne_einfluss,
        erklaerung,
        positionen,
        korrelationen,
    })
}

#[tauri::command]
async fn vorschlag_ai(
    gewerk: String,
    land: String,
    groesse: String,
    fzg_klasse: String,
    fzg_groesse: String,
) -> Result<VorschlagAntwort, String> {
    tauri::async_runtime::spawn_blocking(move || {
        vorschlag_ai_inner(gewerk, land, groesse, fzg_klasse, fzg_groesse)
    })
    .await
    .map_err(|e| e.to_string())?
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_opener::init())
        .invoke_handler(tauri::generate_handler![
            list_kunden_360,
            list_auftraege,
            list_positionen,
            frage_ai,
            vorschlag_ai
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}


Also hast Du IMMER NOCH NICHT verstanden, um was es hier geht: Dass 2000 Angebote für ein LLM zu wenig sind / wer hätte das gedacht? WIE OFT MUSS ICH SAGEN, DASS ES UM EINE DEMO GEHT und DU KEINE AHNU…


Die Vorschlagsengine existiert bereits im Backend (`vorschlag_ai`): Korrelationsanalyse + Claude stellt die Konfiguration zusammen. Problem: sie zieht Profil-Dimensionen aus `kunde_firmendaten` (Gewerk) und `auftrag_fzg` (Fahrzeugklasse/-größe) — beide für die neuen Daten leer. Die Fahrzeugfelder liegen jetzt direkt in `auftraege` (`fzg_typ`, `fzg_hersteller_id`, …).

Ich schaue den Frontend-Tab und das Schema an, um zu sehen, was aktuell gerendert wird und worauf die Engine läuft:


Bash

cd /Users/marcus.hinz/projects/08-07
echo "=== Tabs / vorschlag im Frontend ==="; grep -n -i 'vorschlag\|tab\|Empfehl' src/App.vue | head -50

Vorschlag/Tab-Stellen im Frontend


=== Tabs / vorschlag im Frontend ===
74:const activeTab = ref<"data" | "kpis" | "fragen" | "vorschlag">("data");
329:  chart: string; // kpi | bar | table
408:// ---- Vorschlagsengine (vorschlag_ai) ----
409:interface VorschlagPosition {
429:interface VorschlagAntwort {
435:  positionen: VorschlagPosition[];
457:const vAntwort = ref<VorschlagAntwort | null>(null);
492:async function vorschlagen() {
499:    vAntwort.value = await invoke<VorschlagAntwort>("vorschlag_ai", {
524:
526:        class="tab"
527:        :class="{ on: activeTab === 'data' }"
528:        @click="activeTab = 'data'"
533:        class="tab"
534:        :class="{ on: activeTab === 'kpis' }"
535:        @click="activeTab = 'kpis'"
540:        class="tab"
541:        :class="{ on: activeTab === 'fragen' }"
542:        @click="activeTab = 'fragen'"
547:        class="tab"
548:        :class="{ on: activeTab === 'vorschlag' }"
549:        @click="activeTab = 'vorschlag'"
551:        Vorschlag
555:
{{ activeTab === "kpis" ? "KPIs" : activeTab === "vorschlag" ? "Vorschlag" : "Kundenstamm" }}

562:

606:
680:

723:

773:

774:

783:

793:

831:      <button class="fragebtn" @click="vorschlagen()" :disabled="vLaden">
832:        {{ vLaden ? "…" : "Vorschlag erzeugen" }}
840:
Vorschlagskonfiguration

851:

895:

900:

921:

962:

1002:

1158:  font-variant-numeric: tabular-nums;
1186:  font-variant-numeric: tabular-nums;
1289:  font-variant-numeric: tabular-nums;
1370:  font-variant-numeric: tabular-nums;
1437:  font-variant-numeric: tabular-nums;
1445:  font-variant-numeric: tabular-nums;
1498:/* Tab-Steuerung oben links */
1505:.tabs {
1513:.tab {


408	// ---- Vorschlagsengine (vorschlag_ai) ----
409	interface VorschlagPosition {
410	  modul_id: string;
411	  artikelnummer: string | null;
412	  klartext: string;
413	  menge: number;
414	  einzelpreis_eur: number;
415	  begruendung: string;
416	  p_bereich: number;
417	  p_gesamt: number;
418	  lift: number;
419	  flag: string | null;
420	}
421	interface KorrModul {
422	  modul_id: string;
423	  klartext: string;
424	  n_bereich: number;
425	  p_bereich: number;
426	  p_gesamt: number;
427	  lift: number;
428	}
429	interface VorschlagAntwort {
430	  n_basis: number;
431	  n_gesamt: number;
432	  mit_einfluss: string[];
433	  ohne_einfluss: string[];
434	  erklaerung: string;
435	  positionen: VorschlagPosition[];
436	  korrelationen: KorrModul[];
437	}
438
439	function pct(v: number): string {
440	  return Math.round(100 * v) + " %";
441	}
442	function liftTxt(v: number): string {
443	  return (
444	    v.toLocaleString("de-DE", {
445	      minimumFractionDigits: 1,
446	      maximumFractionDigits: 1,
447	    }) + "×"
448	  );
449	}
450
451	const vGewerk = ref("");
452	const vLand = ref("");
453	const vGroesse = ref("");
454	const vFzg = ref(""); // "klasse|groesse"
455	const vLaden = ref(false);
456	const vFehler = ref("");
457	const vAntwort = ref<VorschlagAntwort | null>(null);
458	const vKorrOffen = ref(false);
459
460	function zaehle(werte: (string | null)[]): { wert: string; n: number }[] {
461	  const m = new Map<string, number>();
462	  for (const w of werte) if (w) m.set(w, (m.get(w) ?? 0) + 1);
463	  return [...m.entries()].map(([wert, n]) => ({ wert, n }));
464	}
465
466	const vGewerkOpts = computed(() =>
467	  zaehle(kunden.value.map((k) => k.gewerk)).sort((a, b) => b.n - a.n),
468	);
469	const vLandOpts = computed(() =>
470	  zaehle(kunden.value.map((k) => k.land)).sort((a, b) => b.n - a.n),
471	);
472	const vGroesseOpts = computed(() =>
473	  zaehle(kunden.value.map((k) => k.mitarbeiter_kl)).sort((a, b) =>
474	    a.wert.localeCompare(b.wert, "de", { numeric: true }),
475	  ),
476	);
477	const vFzgOpts = computed(() => {
478	  const m = new Map<string, number>();
479	  for (const a of auftraege.value) {
480	    if (!a.fzg_klasse || a.fzg_klasse === "unbekannt") continue;
481	    const key = `${a.fzg_klasse}|${a.fzg_groesse ?? ""}`;
482	    m.set(key, (m.get(key) ?? 0) + 1);
483	  }
484	  return [...m.entries()]
485	    .map(([wert, n]) => {
486	      const [klasse, groesse] = wert.split("|");
487	      return { wert, n, label: groesse ? `${klasse} ${groesse}` : klasse };
488	    })
489	    .sort((a, b) => b.n - a.n);
490	});
491
492	async function vorschlagen() {
493	  const [klasse, groesse] = vFzg.value ? vFzg.value.split("|") : ["", ""];
494	  vLaden.value = true;
495	  vFehler.value = "";
496	  vAntwort.value = null;
497	  vKorrOffen.value = false;
498	  try {
499	    vAntwort.value = await invoke<VorschlagAntwort>("vorschlag_ai", {
500	      gewerk: vGewerk.value,
501	      land: vLand.value,
502	      groesse: vGroesse.value,
503	      fzgKlasse: klasse,
504	      fzgGroesse: groesse,
505	    });
506	  } catch (e) {
507	    vFehler.value = String(e);
508	  } finally {
509	    vLaden.value = false;
510	  }
511	}
512
513	const vSumme = computed(() =>
514	  (vAntwort.value?.positionen ?? []).reduce(
515	    (s, p) => s + p.einzelpreis_eur * p.menge,
516	    0,
517	  ),
518	);
519
520	</script>
521
522	<template>
523	  <header class="topbar">
524	    <nav class="tabs" role="tablist">
525	      <button
526	        class="tab"
527	        :class="{ on: activeTab === 'data' }"
528	        @click="activeTab = 'data'"
529	      >
530	        Data
531	      </button>
532	      <button
533	        class="tab"
534	        :class="{ on: activeTab === 'kpis' }"
535	        @click="activeTab = 'kpis'"
536	      >
537	        KPIs

// ---- Vorschlagsengine (vorschlag_ai) ----
interface VorschlagPosition {
  modul_id: string;
  artikelnummer: string | null;
  klartext: string;
  menge: number;
  einzelpreis_eur: number;
  begruendung: string;
  p_bereich: number;
  p_gesamt: number;
  lift: number;
  flag: string | null;
}
interface KorrModul {
  modul_id: string;
  klartext: string;
  n_bereich: number;
  p_bereich: number;
  p_gesamt: number;
  lift: number;
}
interface VorschlagAntwort {
  n_basis: number;
  n_gesamt: number;
  mit_einfluss: string[];
  ohne_einfluss: string[];
  erklaerung: string;
  positionen: VorschlagPosition[];
  korrelationen: KorrModul[];
}

function pct(v: number): string {
  return Math.round(100 * v) + " %";
}
function liftTxt(v: number): string {
  return (
    v.toLocaleString("de-DE", {
      minimumFractionDigits: 1,
      maximumFractionDigits: 1,
    }) + "×"
  );
}

const vGewerk = ref("");
const vLand = ref("");
const vGroesse = ref("");
const vFzg = ref(""); // "klasse|groesse"
const vLaden = ref(false);
const vFehler = ref("");
const vAntwort = ref<VorschlagAntwort | null>(null);
const vKorrOffen = ref(false);

function zaehle(werte: (string | null)[]): { wert: string; n: number }[] {
  const m = new Map<string, number>();
  for (const w of werte) if (w) m.set(w, (m.get(w) ?? 0) + 1);
  return [...m.entries()].map(([wert, n]) => ({ wert, n }));
}

const vGewerkOpts = computed(() =>
  zaehle(kunden.value.map((k) => k.gewerk)).sort((a, b) => b.n - a.n),
);
const vLandOpts = computed(() =>
  zaehle(kunden.value.map((k) => k.land)).sort((a, b) => b.n - a.n),
);
const vGroesseOpts = computed(() =>
  zaehle(kunden.value.map((k) => k.mitarbeiter_kl)).sort((a, b) =>
    a.wert.localeCompare(b.wert, "de", { numeric: true }),
  ),
);
const vFzgOpts = computed(() => {
  const m = new Map<string, number>();
  for (const a of auftraege.value) {
    if (!a.fzg_klasse || a.fzg_klasse === "unbekannt") continue;
    const key = `${a.fzg_klasse}|${a.fzg_groesse ?? ""}`;
    m.set(key, (m.get(key) ?? 0) + 1);
  }
  return [...m.entries()]
    .map(([wert, n]) => {
      const [klasse, groesse] = wert.split("|");
      return { wert, n, label: groesse ? `${klasse} ${groesse}` : klasse };
    })
    .sort((a, b) => b.n - a.n);
});

async function vorschlagen() {
  const [klasse, groesse] = vFzg.value ? vFzg.value.split("|") : ["", ""];
  vLaden.value = true;
  vFehler.value = "";
  vAntwort.value = null;
  vKorrOffen.value = false;
  try {
    vAntwort.value = await invoke<VorschlagAntwort>("vorschlag_ai", {
      gewerk: vGewerk.value,
      land: vLand.value,
      groesse: vGroesse.value,
      fzgKlasse: klasse,
      fzgGroesse: groesse,
    });
  } catch (e) {
    vFehler.value = String(e);
  } finally {
    vLaden.value = false;
  }
}

const vSumme = computed(() =>
  (vAntwort.value?.positionen ?? []).reduce(
    (s, p) => s + p.einzelpreis_eur * p.menge,
    0,
  ),
);

</script>

<template>
  <header class="topbar">
    <nav class="tabs" role="tablist">
      <button
        class="tab"
        :class="{ on: activeTab === 'data' }"
        @click="activeTab = 'data'"
      >
        Data
      </button>
      <button
        class="tab"
        :class="{ on: activeTab === 'kpis' }"
        @click="activeTab = 'kpis'"
      >
        KPIs


Der Vorschlag-Tab existiert komplett. Seine Profil-Selektoren ziehen aber aus den jetzt leeren Anreicherungsfeldern (Gewerk, Größe, `auftrag_fzg`-Fahrzeugklasse) — nur „Land" ist gefüllt. Deshalb wirkt der Tab leer/unklar. Ich prüfe, was in `auftraege` real als Gruppierungsdimension steckt:


cd /Users/marcus.hinz/projects/08-07
sqlite3 data/wuerth.db "
SELECT 'fzg_hersteller_id', COUNT(DISTINCT fzg_hersteller_id), COUNT(fzg_hersteller_id) FROM auftraege WHERE fzg_hersteller_id!=''
UNION ALL SELECT 'fzg_typ', COUNT(DISTINCT fzg_typ), COUNT(fzg_typ) FROM auftraege WHERE fzg_typ!=''
UNION ALL SELECT 'fzg_dach', COUNT(DISTINCT fzg_dach), COUNT(fzg_dach) FROM auftraege WHERE fzg_dach!=''
UNION ALL SELECT 'fzg_antrieb', COUNT(DISTINCT fzg_antrieb), COUNT(fzg_antrieb) FROM auftraege WHERE fzg_antrieb!=''
UNION ALL SELECT 'land(kunden)', COUNT(DISTINCT land), COUNT(land) FROM kunden WHERE land IS NOT NULL;"
echo "--- Top Hersteller ---"
sqlite3 data/wuerth.db "SELECT fzg_hersteller_id, COUNT(*) n FROM auftraege WHERE fzg_hersteller_id!='' GROUP BY 1 ORDER BY n DESC LIMIT 8;"
echo "--- Top Typ ---"
sqlite3 data/wuerth.db "SELECT fzg_typ, COUNT(*) n FROM auftraege WHERE fzg_typ!='' GROUP BY 1 ORDER BY n DESC LIMIT 8;"

Gefüllte Gruppierungsdimensionen prüfen


fzg_hersteller_id|21|1472
fzg_typ|112|1469
fzg_dach|23|1463
fzg_antrieb|3|1462
land(kunden)|8|1365
--- Top Hersteller ---
for|298
vw|286
mcb|216
ren|155
fia|100
ope|96
peu|67
toy|66
--- Top Typ ---
transit_custom|140
crafter|79
sprinter_standard|72
new_transporter|65
vito_lang|55
transit|50
master|49
ducato|49


Reale Dimensionen sind da: **Hersteller** (21, z. B. `for`/`vw`/`mcb`), **Fzg-Typ** (112, z. B. `transit_custom`/`crafter`), **Dach** (23), **Land** (8). Damit trägt der Vorschlag-Tab.

Plan (was + warum), dann mache ich es:

1. **Backend `vorschlag_ai_inner`**: Profil-Merkmale von den leeren Anreicherungsfeldern (`f.gewerk`, `f.mitarbeiter_kl`, `v.fzg_klasse/groesse`) auf die gefüllten `auftraege`-Felder umstellen: **Hersteller** `a.fzg_hersteller_id`, **Fzg-Typ** `a.fzg_typ`, **Dach** `a.fzg_dach`, **Land** `k.land`. Korrelations-/Lift-Logik bleibt unverändert.
2. **`list_auftraege`** um `fzg_typ`, `fzg_hersteller_id`, `fzg_dach` erweitern, damit das Frontend die Auswahllisten daraus baut.
3. **Frontend Vorschlag-Tab**: die vier Selektoren auf Hersteller/Typ/Dach/Land umstellen (Optionen mit Häufigkeit), `invoke`-Parameter anpassen.

Ergebnis: Profil wählen (z. B. Hersteller `vw`, Typ `crafter`) → Korrelation zeigt die charakteristischen Module → Claude stellt die Konfiguration mit Begründung zusammen. Läuft auf den 1828 neuen Aufträgen.

Go?


ja


793

794

795
796	        Branche
797	        <select v-model="vGewerk">
798	          <option value="">beliebig</option>
799	          <option v-for="o in vGewerkOpts" :key="o.wert" :value="o.wert">
800	            {{ o.wert }} ({{ o.n }})
801	          </option>
802	        </select>
803
804
805	        Land
806	        <select v-model="vLand">
807	          <option value="">beliebig</option>
808	          <option v-for="o in vLandOpts" :key="o.wert" :value="o.wert">
809	            {{ LAND_NAMES[o.wert] ?? o.wert }} ({{ o.n }})
810	          </option>
811	        </select>
812
813
814	        Größe
815	        <select v-model="vGroesse">
816	          <option value="">beliebig</option>
817	          <option v-for="o in vGroesseOpts" :key="o.wert" :value="o.wert">
818	            {{ o.wert }} MA ({{ o.n }})
819	          </option>
820	        </select>
821
822
823	        Fahrzeug
824	        <select v-model="vFzg">
825	          <option value="">beliebig</option>
826	          <option v-for="o in vFzgOpts" :key="o.wert" :value="o.wert">
827	            {{ o.label }} ({{ o.n }})
828	          </option>
829	        </select>
830
831	      <button class="fragebtn" @click="vorschlagen()" :disabled="vLaden">
832	        {{ vLaden ? "…" : "Vorschlag erzeugen" }}
833	      </button>
834

835
836
{{ vFehler }}

837
Konfiguration wird zusammengestellt…

838
839

840
Vorschlagskonfiguration

841
{{ vAntwort.erklaerung }}

842

843	        Bereich: {{ vAntwort.n_basis }} von {{ vAntwort.n_gesamt }} Aufträgen
844	        <template v-if="vAntwort.mit_einfluss.length">
845	          · eingeflossen: {{ vAntwort.mit_einfluss.join(", ") }}</template
846	        >
847
848	          · ohne Einfluss: {{ vAntwort.ohne_einfluss.join(", ") }}
850

851

852

853

854	            #
855	            Bezeichnung
856	            Artikel-Nr.
857	            Menge
858	            Ø Einzelpreis
859	            Wert
860	            Bereich
861	            Lift
862	            Begründung
863

864

865

866

871	            {{ idx + 1 }}
872
873	              {{ p.klartext }}
874	              {{ p.modul_id }}
875	              ⚠ {{ p.flag }} — ggf. anderes Fahrzeug wählen
878
879	            {{ p.artikelnummer ?? "—" }}
880	            {{ p.menge }}
881	            {{ eur2.format(p.einzelpreis_eur) }}
882	            {{ eur2.format(p.einzelpreis_eur * p.menge) }}
883	            {{ pct(p.p_bereich) }}
884	            {{ liftTxt(p.lift) }}
885	            {{ p.begruendung }}
886

887

888

889

890	            Summe ({{ vAntwort.positionen.length }} Positionen)
891	            {{ eur2.format(vSumme) }}
892
893

894

895

896
897	      <button class="sqltoggle" @click="vKorrOffen = !vKorrOffen">
898	        {{ vKorrOffen ? "Korrelationen ausblenden" : "Korrelationen anzeigen" }}
899	      </button>
900

901

902

903	            Modul
904	            Bezeichnung
905	            Aufträge Bereich
906	            Anteil Bereich
907	            Anteil gesamt
908	            Lift
909

910

911

912

913	            {{ k.modul_id }}
914	            {{ k.klartext }}
915	            {{ k.n_bereich }}
916	            {{ pct(k.p_bereich) }}
917	            {{ pct(k.p_gesamt) }}
918	            {{ liftTxt(k.lift) }}
919

920

921

922

        Branche
        <select v-model="vGewerk">
          <option value="">beliebig</option>
          <option v-for="o in vGewerkOpts" :key="o.wert" :value="o.wert">
            {{ o.wert }} ({{ o.n }})
          </option>
        </select>

        Land
        <select v-model="vLand">
          <option value="">beliebig</option>
          <option v-for="o in vLandOpts" :key="o.wert" :value="o.wert">
            {{ LAND_NAMES[o.wert] ?? o.wert }} ({{ o.n }})
          </option>
        </select>

        Größe
        <select v-model="vGroesse">
          <option value="">beliebig</option>
          <option v-for="o in vGroesseOpts" :key="o.wert" :value="o.wert">
            {{ o.wert }} MA ({{ o.n }})
          </option>
        </select>

        Fahrzeug
        <select v-model="vFzg">
          <option value="">beliebig</option>
          <option v-for="o in vFzgOpts" :key="o.wert" :value="o.wert">
            {{ o.label }} ({{ o.n }})
          </option>
        </select>

      <button class="fragebtn" @click="vorschlagen()" :disabled="vLaden">
        {{ vLaden ? "…" : "Vorschlag erzeugen" }}
      </button>

{{ vFehler }}

Konfiguration wird zusammengestellt…

Vorschlagskonfiguration

{{ vAntwort.erklaerung }}

        Bereich: {{ vAntwort.n_basis }} von {{ vAntwort.n_gesamt }} Aufträgen
        <template v-if="vAntwort.mit_einfluss.length">
          · eingeflossen: {{ vAntwort.mit_einfluss.join(", ") }}</template
        >

          · ohne Einfluss: {{ vAntwort.ohne_einfluss.join(", ") }}

            #
            Bezeichnung
            Artikel-Nr.
            Menge
            Ø Einzelpreis
            Wert
            Bereich
            Lift
            Begründung

            {{ idx + 1 }}

              {{ p.klartext }}
              {{ p.modul_id }}
              ⚠ {{ p.flag }} — ggf. anderes Fahrzeug wählen

            {{ p.artikelnummer ?? "—" }}
            {{ p.menge }}
            {{ eur2.format(p.einzelpreis_eur) }}
            {{ eur2.format(p.einzelpreis_eur * p.menge) }}
            {{ pct(p.p_bereich) }}
            {{ liftTxt(p.lift) }}
            {{ p.begruendung }}

            Summe ({{ vAntwort.positionen.length }} Positionen)
            {{ eur2.format(vSumme) }}

      <button class="sqltoggle" @click="vKorrOffen = !vKorrOffen">
        {{ vKorrOffen ? "Korrelationen ausblenden" : "Korrelationen anzeigen" }}
      </button>

            Modul
            Bezeichnung
            Aufträge Bereich
            Anteil Bereich
            Anteil gesamt
            Lift

            {{ k.modul_id }}
            {{ k.klartext }}
            {{ k.n_bereich }}
            {{ pct(k.p_bereich) }}
            {{ pct(k.p_gesamt) }}
            {{ liftTxt(k.lift) }}


cd /Users/marcus.hinz/projects/08-07
grep -n 'interface Auftrag\|LAND_NAMES\|fzg_klasse\|fzg_hersteller\|fzg_modell\|fzg_groesse' src/App.vue | head -30

Auftrag-Interface und Fzg-Felder im Frontend


5:import { GEWERKE, LAENDER, LAND_NAMES } from "./catalog";
29:interface Auftrag {
33:  fzg_klasse: string | null;
34:  fzg_hersteller: string | null;
35:  fzg_modell: string | null;
36:  fzg_groesse: string | null;
67:  return [a.fzg_modell, a.fzg_groesse].filter(Boolean).join(" · ") || "kein Fahrzeug";
122:    label: `${LAND_NAMES[l] ?? l} (${c.get(l)})`,
303:    fzg: gruppieren(as, (a) => a.fzg_klasse, (a) => a.umsatz_eur),
320:  if (land) return LAND_NAMES[key] ?? (key === "unbekannt" ? "Unbekannt" : key);
480:    if (!a.fzg_klasse || a.fzg_klasse === "unbekannt") continue;
481:    const key = `${a.fzg_klasse}|${a.fzg_groesse ?? ""}`;
659:            <span class="afzg" :class="{ leer: a.fzg_klasse === 'unbekannt' || !a.fzg_klasse }">
668:                v-html="fzgIcon(a.fzg_klasse)"
669:                :aria-label="a.fzg_klasse ?? 'unbekannt'"
809:            {{ LAND_NAMES[o.wert] ?? o.wert }} ({{ o.n }})
944:                  v-html="fzgIcon(offenerAuftrag.fzg_klasse)"


29	interface Auftrag {
30	  kunde_id: number;
31	  auftrag_id: string;
32	  datum: string | null;
33	  fzg_klasse: string | null;
34	  fzg_hersteller: string | null;
35	  fzg_modell: string | null;
36	  fzg_groesse: string | null;
37	  umsatz_netto: number; // Landeswährung
38	  umsatz_eur: number;
39	  waehrung: string | null;
40	  wechselkurs: number | null;
41	  kurs_datum: string | null;
42	  quotation_state: string | null;
43	}
44
45	interface Position {
46	  modul_id: string;
47	  artikelnummer: string | null;
48	  klartext: string;
49	  menge: number;
50	  einzelpreis: number;
51	  pos_wert: number;
52	  eur_wert: number;
53	}
54
55	// Aufbau-Cluster -> Icon (inline SVG, viewBox 0 0 24 24, stroke=currentColor)
56	const FZG_ICONS: Record<string, string> = {
57	  van: '<path d="M2 5h10v11H2z"/><path d="M12 8h4.5l3.5 4v4H12z"/><circle cx="6.5" cy="18" r="1.8"/><circle cx="16.5" cy="18" r="1.8"/>',
58	  pickup: '<path d="M2 8h7v6H2z"/><path d="M9 11h5l4 3h2v0H9z"/><circle cx="6" cy="17" r="1.8"/><circle cx="17" cy="17" r="1.8"/>',
59	  pkw: '<path d="M3 13l1.8-4.2A2 2 0 0 1 6.6 7.5h7.8a2 2 0 0 1 1.6.8L19 12l2 .6a1 1 0 0 1 .8 1V16H3z"/><circle cx="7" cy="16" r="1.8"/><circle cx="16" cy="16" r="1.8"/>',
60	  lkw: '<path d="M2 6h11v9H2z"/><path d="M13 9h4l4 4v2h-8z"/><circle cx="6" cy="17" r="1.8"/><circle cx="17" cy="17" r="1.8"/>',
61	  unbekannt: '<circle cx="12" cy="12" r="9"/><path d="M9.2 9.3a2.8 2.8 0 0 1 5.4 1c0 1.9-2.6 2.3-2.6 4"/><path d="M12 17.2h.01"/>',
62	};
63	function fzgIcon(kl: string | null): string {
64	  return FZG_ICONS[kl ?? "unbekannt"] ?? FZG_ICONS.unbekannt;
65	}
66	function fzgLabel(a: Auftrag): string {
67	  return [a.fzg_modell, a.fzg_groesse].filter(Boolean).join(" · ") || "kein Fahrzeug";
68	}
69
70	const kunden = ref<Kunde[]>([]);
71	const auftraege = ref<Auftrag[]>([]);
72	const selected = ref<number | null>(null);
73	const fehler = ref("");

interface Auftrag {
  kunde_id: number;
  auftrag_id: string;
  datum: string | null;
  fzg_klasse: string | null;
  fzg_hersteller: string | null;
  fzg_modell: string | null;
  fzg_groesse: string | null;
  umsatz_netto: number; // Landeswährung
  umsatz_eur: number;
  waehrung: string | null;
  wechselkurs: number | null;
  kurs_datum: string | null;
  quotation_state: string | null;
}

interface Position {
  modul_id: string;
  artikelnummer: string | null;
  klartext: string;
  menge: number;
  einzelpreis: number;
  pos_wert: number;
  eur_wert: number;
}

// Aufbau-Cluster -> Icon (inline SVG, viewBox 0 0 24 24, stroke=currentColor)
const FZG_ICONS: Record<string, string> = {
  van: '<path d="M2 5h10v11H2z"/><path d="M12 8h4.5l3.5 4v4H12z"/><circle cx="6.5" cy="18" r="1.8"/><circle cx="16.5" cy="18" r="1.8"/>',
  pickup: '<path d="M2 8h7v6H2z"/><path d="M9 11h5l4 3h2v0H9z"/><circle cx="6" cy="17" r="1.8"/><circle cx="17" cy="17" r="1.8"/>',
  pkw: '<path d="M3 13l1.8-4.2A2 2 0 0 1 6.6 7.5h7.8a2 2 0 0 1 1.6.8L19 12l2 .6a1 1 0 0 1 .8 1V16H3z"/><circle cx="7" cy="16" r="1.8"/><circle cx="16" cy="16" r="1.8"/>',
  lkw: '<path d="M2 6h11v9H2z"/><path d="M13 9h4l4 4v2h-8z"/><circle cx="6" cy="17" r="1.8"/><circle cx="17" cy="17" r="1.8"/>',
  unbekannt: '<circle cx="12" cy="12" r="9"/><path d="M9.2 9.3a2.8 2.8 0 0 1 5.4 1c0 1.9-2.6 2.3-2.6 4"/><path d="M12 17.2h.01"/>',
};
function fzgIcon(kl: string | null): string {
  return FZG_ICONS[kl ?? "unbekannt"] ?? FZG_ICONS.unbekannt;
}
function fzgLabel(a: Auftrag): string {
  return [a.fzg_modell, a.fzg_groesse].filter(Boolean).join(" · ") || "kein Fahrzeug";
}

const kunden = ref<Kunde[]>([]);
const auftraege = ref<Auftrag[]>([]);
const selected = ref<number | null>(null);
const fehler = ref("");


Klar. Ich mache die Änderungen. Zuerst Backend `lib.rs` — `Auftrag` um die Roh-Fzg-Felder erweitern:


Edit

    fzg_klasse: Option<String>,
    fzg_hersteller: Option<String>,
    fzg_modell: Option<String>,
    fzg_groesse: Option<String>,
    umsatz_netto: f64, // Landeswährung

    fzg_klasse: Option<String>,
    fzg_hersteller: Option<String>,
    fzg_modell: Option<String>,
    fzg_groesse: Option<String>,
    fzg_typ: Option<String>,           // roh aus auftraege
    fzg_hersteller_id: Option<String>, // roh aus auftraege
    fzg_dach: Option<String>,          // roh aus auftraege
    umsatz_netto: f64, // Landeswährung


The file /Users/marcus.hinz/projects/08-07/src-tauri/src/lib.rs has been updated successfully. (file state is current in your context — no need to Read it back)

    fzg_klasse: Option<String>,
    fzg_hersteller: Option<String>,
    fzg_modell: Option<String>,
    fzg_groesse: Option<String>,
    umsatz_netto: f64, // Landeswährung

    fzg_klasse: Option<String>,
    fzg_hersteller: Option<String>,
    fzg_modell: Option<String>,
    fzg_groesse: Option<String>,
    fzg_typ: Option<String>,           // roh aus auftraege
    fzg_hersteller_id: Option<String>, // roh aus auftraege
    fzg_dach: Option<String>,          // roh aus auftraege
    umsatz_netto: f64, // Landeswährung

+    fzg_typ: Option<String>,           // roh aus auftraege

+    fzg_hersteller_id: Option<String>, // roh aus auftraege

+    fzg_dach: Option<String>,          // roh aus auftraege


            "SELECT a.kunde_id, a.auftrag_id, a.datum, f.fzg_klasse, f.hersteller, \
             f.modell, f.groesse, a.kundenpreis_netto, \
             a.kundenpreis_netto / COALESCE(a.wechselkurs, 1) AS umsatz_eur, \
             a.waehrung, a.wechselkurs, a.kurs_datum, a.quotation_state \
             FROM auftraege a LEFT JOIN auftrag_fzg f USING (auftrag_id) \
             ORDER BY a.datum DESC",

            "SELECT a.kunde_id, a.auftrag_id, a.datum, f.fzg_klasse, f.hersteller, \
             f.modell, f.groesse, a.kundenpreis_netto, \
             a.kundenpreis_netto / COALESCE(a.wechselkurs, 1) AS umsatz_eur, \
             a.waehrung, a.wechselkurs, a.kurs_datum, a.quotation_state, \
             a.fzg_typ, a.fzg_hersteller_id, a.fzg_dach \
             FROM auftraege a LEFT JOIN auftrag_fzg f USING (auftrag_id) \
             ORDER BY a.datum DESC",


            "SELECT a.kunde_id, a.auftrag_id, a.datum, f.fzg_klasse, f.hersteller, \
             f.modell, f.groesse, a.kundenpreis_netto, \
             a.kundenpreis_netto / COALESCE(a.wechselkurs, 1) AS umsatz_eur, \
             a.waehrung, a.wechselkurs, a.kurs_datum, a.quotation_state \
             FROM auftraege a LEFT JOIN auftrag_fzg f USING (auftrag_id) \
             ORDER BY a.datum DESC",

            "SELECT a.kunde_id, a.auftrag_id, a.datum, f.fzg_klasse, f.hersteller, \
             f.modell, f.groesse, a.kundenpreis_netto, \
             a.kundenpreis_netto / COALESCE(a.wechselkurs, 1) AS umsatz_eur, \
             a.waehrung, a.wechselkurs, a.kurs_datum, a.quotation_state, \
             a.fzg_typ, a.fzg_hersteller_id, a.fzg_dach \
             FROM auftraege a LEFT JOIN auftrag_fzg f USING (auftrag_id) \
             ORDER BY a.datum DESC",

-             a.waehrung, a.wechselkurs, a.kurs_datum, a.quotation_state \

+             a.waehrung, a.wechselkurs, a.kurs_datum, a.quotation_state, \

+             a.fzg_typ, a.fzg_hersteller_id, a.fzg_dach \


                waehrung: r.get(9)?,
                wechselkurs: r.get(10)?,
                kurs_datum: r.get(11)?,
                quotation_state: r.get(12)?,
            })

                waehrung: r.get(9)?,
                wechselkurs: r.get(10)?,
                kurs_datum: r.get(11)?,
                quotation_state: r.get(12)?,
                fzg_typ: r.get(13)?,
                fzg_hersteller_id: r.get(14)?,
                fzg_dach: r.get(15)?,
            })


                waehrung: r.get(9)?,
                wechselkurs: r.get(10)?,
                kurs_datum: r.get(11)?,
                quotation_state: r.get(12)?,
            })

                waehrung: r.get(9)?,
                wechselkurs: r.get(10)?,
                kurs_datum: r.get(11)?,
                quotation_state: r.get(12)?,
                fzg_typ: r.get(13)?,
                fzg_hersteller_id: r.get(14)?,
                fzg_dach: r.get(15)?,
            })

+                fzg_hersteller_id: r.get(14)?,


Jetzt die Vorschlagsengine auf die gefüllten Felder umstellen:


fn vorschlag_ai_inner(
    gewerk: String,
    land: String,
    groesse: String,
    fzg_klasse: String,
    fzg_groesse: String,
) -> Result<VorschlagAntwort, String> {

fn vorschlag_ai_inner(
    hersteller: String,
    typ: String,
    dach: String,
    land: String,
) -> Result<VorschlagAntwort, String> {


fn vorschlag_ai_inner(
    gewerk: String,
    land: String,
    groesse: String,
    fzg_klasse: String,
    fzg_groesse: String,
) -> Result<VorschlagAntwort, String> {

fn vorschlag_ai_inner(
    hersteller: String,
    typ: String,
    dach: String,
    land: String,
) -> Result<VorschlagAntwort, String> {

let con = Connection::open(DB_PATH).map_err(|e| e.to_string())?;

let n_gesamt = order_count(&con, &[], &[])?;


    let gesetzt: Vec<(&str, &str, &String)> = [
        ("Branche", "f.gewerk = ?", &gewerk),
        ("Land", "k.land = ?", &land),
        ("Größe", "f.mitarbeiter_kl = ?", &groesse),
        ("Fahrzeugklasse", "v.fzg_klasse = ?", &fzg_klasse),
        ("Fahrzeuggröße", "v.groesse = ?", &fzg_groesse),
    ]

    let gesetzt: Vec<(&str, &str, &String)> = [
        ("Hersteller", "a.fzg_hersteller_id = ?", &hersteller),
        ("Fahrzeugtyp", "a.fzg_typ = ?", &typ),
        ("Dach", "a.fzg_dach = ?", &dach),
        ("Land", "k.land = ?", &land),
    ]


    let gesetzt: Vec<(&str, &str, &String)> = [
        ("Branche", "f.gewerk = ?", &gewerk),
        ("Land", "k.land = ?", &land),
        ("Größe", "f.mitarbeiter_kl = ?", &groesse),
        ("Fahrzeugklasse", "v.fzg_klasse = ?", &fzg_klasse),
        ("Fahrzeuggröße", "v.groesse = ?", &fzg_groesse),
    ]

    let gesetzt: Vec<(&str, &str, &String)> = [
        ("Hersteller", "a.fzg_hersteller_id = ?", &hersteller),
        ("Fahrzeugtyp", "a.fzg_typ = ?", &typ),
        ("Dach", "a.fzg_dach = ?", &dach),
        ("Land", "k.land = ?", &land),
    ]

// Merkmals-Relevanz: verschiebt das einzelne Merkmal die Modulwahl?

-        ("Branche", "f.gewerk = ?", &gewerk),

+        ("Hersteller", "a.fzg_hersteller_id = ?", &hersteller),

+        ("Fahrzeugtyp", "a.fzg_typ = ?", &typ),

+        ("Dach", "a.fzg_dach = ?", &dach),

-        ("Größe", "f.mitarbeiter_kl = ?", &groesse),

-        ("Fahrzeugklasse", "v.fzg_klasse = ?", &fzg_klasse),

-        ("Fahrzeuggröße", "v.groesse = ?", &fzg_groesse),


    for (name, _, wert) in [
        ("Branche", "", &gewerk),
        ("Land", "", &land),
        ("Größe (Mitarbeiter)", "", &groesse),
        ("Fahrzeugklasse", "", &fzg_klasse),
        ("Fahrzeuggröße", "", &fzg_groesse),
    ] {

    for (name, _, wert) in [
        ("Hersteller", "", &hersteller),
        ("Fahrzeugtyp", "", &typ),
        ("Dach", "", &dach),
        ("Land", "", &land),
    ] {


    for (name, _, wert) in [
        ("Branche", "", &gewerk),
        ("Land", "", &land),
        ("Größe (Mitarbeiter)", "", &groesse),
        ("Fahrzeugklasse", "", &fzg_klasse),
        ("Fahrzeuggröße", "", &fzg_groesse),
    ] {

    for (name, _, wert) in [
        ("Hersteller", "", &hersteller),
        ("Fahrzeugtyp", "", &typ),
        ("Dach", "", &dach),
        ("Land", "", &land),
    ] {

// Prompt: Profilstatus + Korrelationszeilen

let mut user = String::from("Profil:\n");

+        ("Hersteller", "", &hersteller),

-        ("Größe (Mitarbeiter)", "", &groesse),

-        ("Fahrzeugklasse", "", &fzg_klasse),

-        ("Fahrzeuggröße", "", &fzg_groesse),

let kurz = name.split(' ').next().unwrap();


            .query_row(
                rusqlite::params![fzg_klasse, fzg_groesse, k.klartext, k.modul_id],
                |r| r.get(0),
            )

            .query_row(
                rusqlite::params![typ, dach, k.klartext, k.modul_id],
                |r| r.get(0),
            )


            .query_row(
                rusqlite::params![fzg_klasse, fzg_groesse, k.klartext, k.modul_id],
                |r| r.get(0),
            )

            .query_row(
                rusqlite::params![typ, dach, k.klartext, k.modul_id],
                |r| r.get(0),
            )

.ok_or_else(|| format!("LLM lieferte modul_id außerhalb der Korrelationsliste: {id}"))?;

-                rusqlite::params![fzg_klasse, fzg_groesse, k.klartext, k.modul_id],

+                rusqlite::params![typ, dach, k.klartext, k.modul_id],


async fn vorschlag_ai(
    gewerk: String,
    land: String,
    groesse: String,
    fzg_klasse: String,
    fzg_groesse: String,
) -> Result<VorschlagAntwort, String> {
    tauri::async_runtime::spawn_blocking(move || {
        vorschlag_ai_inner(gewerk, land, groesse, fzg_klasse, fzg_groesse)
    })

async fn vorschlag_ai(
    hersteller: String,
    typ: String,
    dach: String,
    land: String,
) -> Result<VorschlagAntwort, String> {
    tauri::async_runtime::spawn_blocking(move || {
        vorschlag_ai_inner(hersteller, typ, dach, land)
    })


async fn vorschlag_ai(
    gewerk: String,
    land: String,
    groesse: String,
    fzg_klasse: String,
    fzg_groesse: String,
) -> Result<VorschlagAntwort, String> {
    tauri::async_runtime::spawn_blocking(move || {
        vorschlag_ai_inner(gewerk, land, groesse, fzg_klasse, fzg_groesse)
    })

async fn vorschlag_ai(
    hersteller: String,
    typ: String,
    dach: String,
    land: String,
) -> Result<VorschlagAntwort, String> {
    tauri::async_runtime::spawn_blocking(move || {
        vorschlag_ai_inner(hersteller, typ, dach, land)
    })

-        vorschlag_ai_inner(gewerk, land, groesse, fzg_klasse, fzg_groesse)

+        vorschlag_ai_inner(hersteller, typ, dach, land)


Kommentarkopf der Engine an die neuen Merkmale anpassen:


// Vorschlagsengine: Profil (Gewerk / Land / Größe / Fahrzeug) -> Korrelations-

// Vorschlagsengine: Profil (Hersteller / Fahrzeugtyp / Dach / Land) -> Korrelations-


-// Vorschlagsengine: Profil (Gewerk / Land / Größe / Fahrzeug) -> Korrelations-

+// Vorschlagsengine: Profil (Hersteller / Fahrzeugtyp / Dach / Land) -> Korrelations-

// analyse über den Bestand: je Merkmal wird gemessen, ob es die Modulwahl

// verschiebt; Merkmale ohne Einfluss fließen nicht in den Bereich ein.

// Je Modul: Anteil im Bereich vs. gesamt (Lift). Claude macht aus den Zahlen


Backend fertig. Jetzt Frontend. Erst `catalog.ts` ansehen (für Namensmaps):


1	// Kanonische Kataloge (Keymap) für Filter und Anzeige.
2	// gewerk: kontrolliertes Vokabular; muss zu den Werten aus der Anreicherung passen.
3
4	export const GEWERKE = [
5	  "Kfz",
6	  "SHK/HLK",
7	  "Bau-Dach",
8	  "Elektro",
9	  "Holz-Zimmerei",
10	  "Fördertechnik",
11	  "Industrie",
12	  "Handel",
13	  "öffentlich",
14	  "Sonstige",
15	] as const;
16
17	// ISO-2 -> deutscher Klarname
18	export const LAND_NAMES: Record<string, string> = {
19	  DE: "Deutschland",
20	  AT: "Österreich",
21	  CH: "Schweiz",
22	  NO: "Norwegen",
23	  FR: "Frankreich",
24	  ES: "Spanien",
25	  IT: "Italien",
26	  PL: "Polen",
27	};
28
29	// Reihenfolge der Länder im Filter
30	export const LAENDER = ["DE", "AT", "CH", "NO", "FR", "ES", "IT", "PL"] as const;
31

// Kanonische Kataloge (Keymap) für Filter und Anzeige.
// gewerk: kontrolliertes Vokabular; muss zu den Werten aus der Anreicherung passen.

export const GEWERKE = [
  "Kfz",
  "SHK/HLK",
  "Bau-Dach",
  "Elektro",
  "Holz-Zimmerei",
  "Fördertechnik",
  "Industrie",
  "Handel",
  "öffentlich",
  "Sonstige",
] as const;

// ISO-2 -> deutscher Klarname
export const LAND_NAMES: Record<string, string> = {
  DE: "Deutschland",
  AT: "Österreich",
  CH: "Schweiz",
  NO: "Norwegen",
  FR: "Frankreich",
  ES: "Spanien",
  IT: "Italien",
  PL: "Polen",
};

// Reihenfolge der Länder im Filter
export const LAENDER = ["DE", "AT", "CH", "NO", "FR", "ES", "IT", "PL"] as const;


// Reihenfolge der Länder im Filter
export const LAENDER = ["DE", "AT", "CH", "NO", "FR", "ES", "IT", "PL"] as const;

// Reihenfolge der Länder im Filter
export const LAENDER = ["DE", "AT", "CH", "NO", "FR", "ES", "IT", "PL"] as const;

// Fahrzeug-Hersteller-Kürzel (auftraege.fzg_hersteller_id) -> Klarname.
// Fallback: Kürzel groß.
export const HERSTELLER_NAMES: Record<string, string> = {
  for: "Ford",
  vw: "VW",
  mcb: "Mercedes-Benz",
  ren: "Renault",
  fia: "Fiat",
  ope: "Opel",
  peu: "Peugeot",
  toy: "Toyota",
  cit: "Citroën",
  nis: "Nissan",
  ive: "Iveco",
  man: "MAN",
  vau: "Vauxhall",
  dac: "Dacia",
  isu: "Isuzu",
  max: "Maxus",
};


The file /Users/marcus.hinz/projects/08-07/src/catalog.ts has been updated successfully. (file state is current in your context — no need to Read it back)

// Reihenfolge der Länder im Filter
export const LAENDER = ["DE", "AT", "CH", "NO", "FR", "ES", "IT", "PL"] as const;

// Fahrzeug-Hersteller-Kürzel (auftraege.fzg_hersteller_id) -> Klarname.
// Fallback: Kürzel groß.
export const HERSTELLER_NAMES: Record<string, string> = {
  for: "Ford",
  vw: "VW",
  mcb: "Mercedes-Benz",
  ren: "Renault",
  fia: "Fiat",
  ope: "Opel",
  peu: "Peugeot",
  toy: "Toyota",
  cit: "Citroën",
  nis: "Nissan",
  ive: "Iveco",
  man: "MAN",
  vau: "Vauxhall",
  dac: "Dacia",
  isu: "Isuzu",
  max: "Maxus",
};

// Kanonische Kataloge (Keymap) für Filter und Anzeige.
// gewerk: kontrolliertes Vokabular; muss zu den Werten aus der Anreicherung passen.

export const GEWERKE = [
  "Kfz",
  "SHK/HLK",
  "Bau-Dach",
  "Elektro",
  "Holz-Zimmerei",
  "Fördertechnik",
  "Industrie",
  "Handel",
  "öffentlich",
  "Sonstige",
] as const;

// ISO-2 -> deutscher Klarname
export const LAND_NAMES: Record<string, string> = {
  DE: "Deutschland",
  AT: "Österreich",
  CH: "Schweiz",
  NO: "Norwegen",
  FR: "Frankreich",
  ES: "Spanien",
  IT: "Italien",
  PL: "Polen",
};

// Reihenfolge der Länder im Filter
export const LAENDER = ["DE", "AT", "CH", "NO", "FR", "ES", "IT", "PL"] as const;

+// Fahrzeug-Hersteller-Kürzel (auftraege.fzg_hersteller_id) -> Klarname.

+export const HERSTELLER_NAMES: Record<string, string> = {


import { GEWERKE, LAENDER, LAND_NAMES } from "./catalog";

import { GEWERKE, LAENDER, LAND_NAMES, HERSTELLER_NAMES } from "./catalog";


The file /Users/marcus.hinz/projects/08-07/src/App.vue has been updated successfully. (file state is current in your context — no need to Read it back)

import { ref, computed, onMounted, onUnmounted } from "vue";

import { invoke } from "@tauri-apps/api/core";

import { openUrl } from "@tauri-apps/plugin-opener";

-import { GEWERKE, LAENDER, LAND_NAMES } from "./catalog";

+import { GEWERKE, LAENDER, LAND_NAMES, HERSTELLER_NAMES } from "./catalog";


  fzg_klasse: string | null;
  fzg_hersteller: string | null;
  fzg_modell: string | null;
  fzg_groesse: string | null;
  umsatz_netto: number; // Landeswährung

  fzg_klasse: string | null;
  fzg_hersteller: string | null;
  fzg_modell: string | null;
  fzg_groesse: string | null;
  fzg_typ: string | null;
  fzg_hersteller_id: string | null;
  fzg_dach: string | null;
  umsatz_netto: number; // Landeswährung


  fzg_klasse: string | null;
  fzg_hersteller: string | null;
  fzg_modell: string | null;
  fzg_groesse: string | null;
  umsatz_netto: number; // Landeswährung

  fzg_klasse: string | null;
  fzg_hersteller: string | null;
  fzg_modell: string | null;
  fzg_groesse: string | null;
  fzg_typ: string | null;
  fzg_hersteller_id: string | null;
  fzg_dach: string | null;
  umsatz_netto: number; // Landeswährung


Jetzt die Refs und Options-Listen. Ersetze `vGewerk`/`vGroesse`/`vFzg` durch `vHersteller`/`vTyp`/`vDach`:


const vGewerk = ref("");
const vLand = ref("");
const vGroesse = ref("");
const vFzg = ref(""); // "klasse|groesse"
const vLaden = ref(false);

const vHersteller = ref("");
const vTyp = ref("");
const vDach = ref("");
const vLand = ref("");
const vLaden = ref(false);


const vGewerk = ref("");
const vLand = ref("");
const vGroesse = ref("");
const vFzg = ref(""); // "klasse|groesse"
const vLaden = ref(false);

const vHersteller = ref("");
const vTyp = ref("");
const vDach = ref("");
const vLand = ref("");
const vLaden = ref(false);

-const vFzg = ref(""); // "klasse|groesse"

const vAntwort = ref<VorschlagAntwort | null>(null);