Edit

// ---------------------------------------------------------------------------
// Vorschlagsengine: Profil (Gewerk / Land / Größe) + Fahrzeug -> Claude stellt
// aus den Modulen historischer Aufträge ähnlicher Kunden eine Konfiguration
// zusammen. Preise kommen aus der Historie (Ø EUR), nicht vom LLM.
// ---------------------------------------------------------------------------

#[derive(Serialize)]
struct VorschlagPosition {
    modul_id: String,
    artikelnummer: Option<String>,
    klartext: String,
    menge: i64,
    einzelpreis_eur: f64,
    begruendung: String,
}

#[derive(Serialize)]
struct VorschlagAntwort {
    n_basis: i64,      // Aufträge, die zum Profil passen
    n_kandidaten: i64, // Module, aus denen gewählt wurde
    erklaerung: String,
    positionen: Vec<VorschlagPosition>,
}

struct Kandidat {
    klartext: String,
    artikelnummer: Option<String>,
    n_auftraege: i64,
    avg_menge: f64,
    avg_preis_eur: f64,
}

const VORSCHLAG_TMPL: &str = "Du konfigurierst Würth-Fahrzeugeinrichtungen. Aus den Modulen \
historischer Aufträge ähnlicher Kunden stellst du EINE stimmige Vorschlagskonfiguration für das \
angegebene Profil 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 Kandidatenliste.\n\
- Häufigkeit (in wie vielen Aufträgen ein Modul vorkam) ist das wichtigste Signal.\n\
- Eine komplette Einrichtung: Basis (Boden/Verkleidung) falls in der Liste, dann Regale/Schränke, \
dann Befestigung/Zubehör. Typisch 6-15 Positionen.\n\
- menge ganzzahlig, an der Ø-Menge orientiert.\n\
- begruendung: ein kurzer Halbsatz je Position.\n\
JSON-Format:\n\
{\"erklaerung\": \"<2-3 Sätze, warum diese Konfiguration zum Profil passt>\", \
\"positionen\": [{\"modul_id\": \"<aus Liste>\", \"menge\": 1, \"begruendung\": \"<kurz>\"}]}";

const VORSCHLAG_FILTER: &str = "(?1 = '' OR f.gewerk = ?1) \
     AND (?2 = '' OR k.land = ?2) \
     AND (?3 = '' OR f.mitarbeiter_kl = ?3) \
     AND (?4 = '' OR v.fzg_klasse = ?4) \
     AND (?5 = '' OR v.groesse = ?5)";

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 params = rusqlite::params![gewerk, land, groesse, fzg_klasse, fzg_groesse];

    // Basis: wie viele Aufträge passen zum Profil
    let n_basis: i64 = con
        .query_row(
            &format!(
                "SELECT COUNT(DISTINCT a.auftrag_id) \
                 FROM auftraege a \
                 JOIN kunden k USING (kunde_id) \
                 LEFT JOIN kunde_firmendaten f USING (kunde_id) \
                 LEFT JOIN auftrag_fzg v USING (auftrag_id) \
                 WHERE {VORSCHLAG_FILTER}"
            ),
            params,
            |r| r.get(0),
        )
        .map_err(|e| e.to_string())?;
    if n_basis == 0 {
        return Err("Keine Aufträge passen zu diesem Profil — Filter lockern.".to_string());
    }

    // Kandidaten: Module dieser Aufträge, nach Häufigkeit
    let mut stmt = con
        .prepare(&format!(
            "SELECT p.modul_id, COALESCE(m.klartext, p.modul_id), m.artikelnummer, \
             COUNT(DISTINCT p.auftrag_id) AS n, \
             AVG(COALESCE(p.menge, 1)), \
             AVG(p.preis / COALESCE(a.wechselkurs, 1)) \
             FROM 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 = k.kunde_id \
             LEFT JOIN auftrag_fzg v ON v.auftrag_id = p.auftrag_id \
             LEFT JOIN module m ON m.modul_id = p.modul_id \
             WHERE {VORSCHLAG_FILTER} \
             GROUP BY p.modul_id ORDER BY n DESC LIMIT 60"
        ))
        .map_err(|e| e.to_string())?;
    let rows = stmt
        .query_map(params, |r| {
            Ok((
                r.get::<_, String>(0)?,
                Kandidat {
                    klartext: r.get(1)?,
                    artikelnummer: r.get(2)?,
                    n_auftraege: r.get(3)?,
                    avg_menge: r.get(4)?,
                    avg_preis_eur: r.get(5)?,
                },
            ))
        })
        .map_err(|e| e.to_string())?;
    let mut kandidaten: Vec<(String, Kandidat)> = Vec::new();
    for row in rows {
        kandidaten.push(row.map_err(|e| e.to_string())?);
    }

    // Prompt: Profil + Kandidatenliste
    let oder = |v: &str| if v.is_empty() { "beliebig".to_string() } else { v.to_string() };
    let fahrzeug = if fzg_klasse.is_empty() && fzg_groesse.is_empty() {
        "beliebig".to_string()
    } else {
        format!("{} {}", oder(&fzg_klasse), fzg_groesse).trim().to_string()
    };
    let mut user = format!(
        "Profil:\n- Gewerk: {}\n- Land: {}\n- Größe (Mitarbeiter): {}\n- Fahrzeug: {}\n\
         Basis: {} Aufträge passen zum Profil.\n\n\
         Kandidaten (modul_id | Bezeichnung | Artikelnr | in Aufträgen | Ø Menge | Ø Preis EUR):\n",
        oder(&gewerk),
        oder(&land),
        oder(&groesse),
        fahrzeug,
        n_basis
    );
    for (id, k) in &kandidaten {
        user.push_str(&format!(
            "{} | {} | {} | {} | {:.1} | {:.2}\n",
            id,
            k.klartext,
            k.artikelnummer.as_deref().unwrap_or("—"),
            k.n_auftraege,
            k.avg_menge,
            k.avg_preis_eur
        ));
    }

    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")?;

    let kand_map: std::collections::HashMap<&str, &Kandidat> =
        kandidaten.iter().map(|(id, k)| (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 = kand_map
            .get(id)
            .ok_or_else(|| format!("LLM lieferte modul_id außerhalb der Kandidaten: {id}"))?;
        positionen.push(VorschlagPosition {
            modul_id: id.to_string(),
            artikelnummer: k.artikelnummer.clone(),
            klartext: k.klartext.clone(),
            menge,
            einzelpreis_eur: k.avg_preis_eur,
            begruendung: p["begruendung"].as_str().unwrap_or("").to_string(),
        });
    }

    Ok(VorschlagAntwort {
        n_basis,
        n_kandidaten: kandidaten.len() as i64,
        erklaerung,
        positionen,
    })
}

// ---------------------------------------------------------------------------
// 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";

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 positionen p JOIN {ORDER_JOINS} ON a.auftrag_id = p.auftrag_id \
             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 positionen p JOIN {ORDER_JOINS} ON a.auftrag_id = p.auftrag_id \
             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.starts_with(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,
    })
}


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)

// ---------------------------------------------------------------------------
// Vorschlagsengine: Profil (Gewerk / Land / Größe) + Fahrzeug -> Claude stellt
// aus den Modulen historischer Aufträge ähnlicher Kunden eine Konfiguration
// zusammen. Preise kommen aus der Historie (Ø EUR), nicht vom LLM.
// ---------------------------------------------------------------------------

#[derive(Serialize)]
struct VorschlagPosition {
    modul_id: String,
    artikelnummer: Option<String>,
    klartext: String,
    menge: i64,
    einzelpreis_eur: f64,
    begruendung: String,
}

#[derive(Serialize)]
struct VorschlagAntwort {
    n_basis: i64,      // Aufträge, die zum Profil passen
    n_kandidaten: i64, // Module, aus denen gewählt wurde
    erklaerung: String,
    positionen: Vec<VorschlagPosition>,
}

struct Kandidat {
    klartext: String,
    artikelnummer: Option<String>,
    n_auftraege: i64,
    avg_menge: f64,
    avg_preis_eur: f64,
}

const VORSCHLAG_TMPL: &str = "Du konfigurierst Würth-Fahrzeugeinrichtungen. Aus den Modulen \
historischer Aufträge ähnlicher Kunden stellst du EINE stimmige Vorschlagskonfiguration für das \
angegebene Profil 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 Kandidatenliste.\n\
- Häufigkeit (in wie vielen Aufträgen ein Modul vorkam) ist das wichtigste Signal.\n\
- Eine komplette Einrichtung: Basis (Boden/Verkleidung) falls in der Liste, dann Regale/Schränke, \
dann Befestigung/Zubehör. Typisch 6-15 Positionen.\n\
- menge ganzzahlig, an der Ø-Menge orientiert.\n\
- begruendung: ein kurzer Halbsatz je Position.\n\
JSON-Format:\n\
{\"erklaerung\": \"<2-3 Sätze, warum diese Konfiguration zum Profil passt>\", \
\"positionen\": [{\"modul_id\": \"<aus Liste>\", \"menge\": 1, \"begruendung\": \"<kurz>\"}]}";

const VORSCHLAG_FILTER: &str = "(?1 = '' OR f.gewerk = ?1) \
     AND (?2 = '' OR k.land = ?2) \
     AND (?3 = '' OR f.mitarbeiter_kl = ?3) \
     AND (?4 = '' OR v.fzg_klasse = ?4) \
     AND (?5 = '' OR v.groesse = ?5)";

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 params = rusqlite::params![gewerk, land, groesse, fzg_klasse, fzg_groesse];

    // Basis: wie viele Aufträge passen zum Profil
    let n_basis: i64 = con
        .query_row(
            &format!(
                "SELECT COUNT(DISTINCT a.auftrag_id) \
                 FROM auftraege a \
                 JOIN kunden k USING (kunde_id) \
                 LEFT JOIN kunde_firmendaten f USING (kunde_id) \
                 LEFT JOIN auftrag_fzg v USING (auftrag_id) \
                 WHERE {VORSCHLAG_FILTER}"
            ),
            params,
            |r| r.get(0),
        )
        .map_err(|e| e.to_string())?;
    if n_basis == 0 {
        return Err("Keine Aufträge passen zu diesem Profil — Filter lockern.".to_string());
    }

    // Kandidaten: Module dieser Aufträge, nach Häufigkeit
    let mut stmt = con
        .prepare(&format!(
            "SELECT p.modul_id, COALESCE(m.klartext, p.modul_id), m.artikelnummer, \
             COUNT(DISTINCT p.auftrag_id) AS n, \
             AVG(COALESCE(p.menge, 1)), \
             AVG(p.preis / COALESCE(a.wechselkurs, 1)) \
             FROM 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 = k.kunde_id \
             LEFT JOIN auftrag_fzg v ON v.auftrag_id = p.auftrag_id \
             LEFT JOIN module m ON m.modul_id = p.modul_id \
             WHERE {VORSCHLAG_FILTER} \
             GROUP BY p.modul_id ORDER BY n DESC LIMIT 60"
        ))
        .map_err(|e| e.to_string())?;
    let rows = stmt
        .query_map(params, |r| {
            Ok((
                r.get::<_, String>(0)?,
                Kandidat {
                    klartext: r.get(1)?,
                    artikelnummer: r.get(2)?,
                    n_auftraege: r.get(3)?,
                    avg_menge: r.get(4)?,
                    avg_preis_eur: r.get(5)?,
                },
            ))
        })
        .map_err(|e| e.to_string())?;
    let mut kandidaten: Vec<(String, Kandidat)> = Vec::new();
    for row in rows {
        kandidaten.push(row.map_err(|e| e.to_string())?);
    }

    // Prompt: Profil + Kandidatenliste
    let oder = |v: &str| if v.is_empty() { "beliebig".to_string() } else { v.to_string() };
    let fahrzeug = if fzg_klasse.is_empty() && fzg_groesse.is_empty() {
        "beliebig".to_string()
    } else {
        format!("{} {}", oder(&fzg_klasse), fzg_groesse).trim().to_string()
    };
    let mut user = format!(
        "Profil:\n- Gewerk: {}\n- Land: {}\n- Größe (Mitarbeiter): {}\n- Fahrzeug: {}\n\
         Basis: {} Aufträge passen zum Profil.\n\n\
         Kandidaten (modul_id | Bezeichnung | Artikelnr | in Aufträgen | Ø Menge | Ø Preis EUR):\n",
        oder(&gewerk),
        oder(&land),
        oder(&groesse),
        fahrzeug,
        n_basis
    );
    for (id, k) in &kandidaten {
        user.push_str(&format!(
            "{} | {} | {} | {} | {:.1} | {:.2}\n",
            id,
            k.klartext,
            k.artikelnummer.as_deref().unwrap_or("—"),
            k.n_auftraege,
            k.avg_menge,
            k.avg_preis_eur
        ));
    }

    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")?;

    let kand_map: std::collections::HashMap<&str, &Kandidat> =
        kandidaten.iter().map(|(id, k)| (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 = kand_map
            .get(id)
            .ok_or_else(|| format!("LLM lieferte modul_id außerhalb der Kandidaten: {id}"))?;
        positionen.push(VorschlagPosition {
            modul_id: id.to_string(),
            artikelnummer: k.artikelnummer.clone(),
            klartext: k.klartext.clone(),
            menge,
            einzelpreis_eur: k.avg_preis_eur,
            begruendung: p["begruendung"].as_str().unwrap_or("").to_string(),
        });
    }

    Ok(VorschlagAntwort {
        n_basis,
        n_kandidaten: kandidaten.len() as i64,
        erklaerung,
        positionen,
    })
}

// ---------------------------------------------------------------------------
// 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";

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 positionen p JOIN {ORDER_JOINS} ON a.auftrag_id = p.auftrag_id \
             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 positionen p JOIN {ORDER_JOINS} ON a.auftrag_id = p.auftrag_id \
             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.starts_with(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,
    })
}

-// Vorschlagsengine: Profil (Gewerk / Land / Größe) + Fahrzeug -> Claude stellt

-// aus den Modulen historischer Aufträge ähnlicher Kunden eine Konfiguration

-// zusammen. Preise kommen aus der Historie (Ø EUR), nicht vom LLM.

+// 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.

+    p_bereich: f64, // Anteil der Bereichs-Aufträge mit diesem Modul (0..1)

+    einzelpreis_eur: f64, // Ø aus den Bereichs-Aufträgen

+    flag: Option<String>, // Regelkonflikt mit dem gewählten Fahrzeug

-    n_basis: i64,      // Aufträge, die zum Profil passen

-    n_kandidaten: i64, // Module, aus denen gewählt wurde

+    n_basis: i64, // Aufträge im Bereich

+// 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 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";

+fn where_und(conds: &[&str]) -> String {

-const VORSCHLAG_TMPL: &str = "Du konfigurierst Würth-Fahrzeugeinrichtungen. Aus den Modulen \

-historischer Aufträge ähnlicher Kunden stellst du EINE stimmige Vorschlagskonfiguration für das \

-angegebene Profil zusammen. Antworte AUSSCHLIESSLICH mit einem JSON-Objekt, keine Erklärung \

+fn order_count(con: &Connection, conds: &[&str], werte: &[&String]) -> Result<i64, String> {

+            "SELECT COUNT(DISTINCT a.auftrag_id) FROM {ORDER_JOINS} WHERE {}",

+        rusqlite::params_from_iter(werte.iter()),

+// Modul -> Anzahl Aufträge (distinct) im Segment

+) -> Result<std::collections::HashMap<String, i64>, String> {

+            "SELECT p.modul_id, COUNT(DISTINCT p.auftrag_id) \

+             FROM positionen p JOIN {ORDER_JOINS} ON a.auftrag_id = p.auftrag_id \

+             WHERE {} GROUP BY p.modul_id",

+        .query_map(rusqlite::params_from_iter(werte.iter()), |r| {

+    let mut out = std::collections::HashMap::new();

+        let (id, n) = row.map_err(|e| e.to_string())?;

+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\

-- Verwende ausschließlich modul_id-Werte aus der Kandidatenliste.\n\

-- Häufigkeit (in wie vielen Aufträgen ein Modul vorkam) ist das wichtigste Signal.\n\

-- Eine komplette Einrichtung: Basis (Boden/Verkleidung) falls in der Liste, dann Regale/Schränke, \

-dann Befestigung/Zubehör. Typisch 6-15 Positionen.\n\

-- menge ganzzahlig, an der Ø-Menge orientiert.\n\

-- begruendung: ein kurzer Halbsatz je Position.\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 \

-{\"erklaerung\": \"<2-3 Sätze, warum diese Konfiguration zum Profil passt>\", \

-\"positionen\": [{\"modul_id\": \"<aus Liste>\", \"menge\": 1, \"begruendung\": \"<kurz>\"}]}";

+{\"erklaerung\": \"<2-3 Sätze>\", \"positionen\": [{\"modul_id\": \"<aus Liste>\", \"menge\": 1, \

-const VORSCHLAG_FILTER: &str = "(?1 = '' OR f.gewerk = ?1) \

-     AND (?3 = '' OR f.mitarbeiter_kl = ?3) \

-     AND (?4 = '' OR v.fzg_klasse = ?4) \

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

-    let params = rusqlite::params![gewerk, land, groesse, fzg_klasse, fzg_groesse];

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

-    // Basis: wie viele Aufträge passen zum Profil

-                "SELECT COUNT(DISTINCT a.auftrag_id) \

-                 JOIN kunden k USING (kunde_id) \

-                 LEFT JOIN kunde_firmendaten f USING (kunde_id) \

-                 LEFT JOIN auftrag_fzg v USING (auftrag_id) \

-                 WHERE {VORSCHLAG_FILTER}"

-        return Err("Keine Aufträge passen zu diesem Profil — Filter lockern.".to_string());

+    // Globale Modul-Statistik (Klartext, Artikelnummer, Häufigkeit im Gesamtbestand)

-    // Kandidaten: Module dieser Aufträge, nach Häufigkeit

-             COUNT(DISTINCT p.auftrag_id) AS n, \

-             AVG(COALESCE(p.menge, 1)), \

-             AVG(p.preis / COALESCE(a.wechselkurs, 1)) \

-             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 = k.kunde_id \

-             LEFT JOIN auftrag_fzg v ON v.auftrag_id = p.auftrag_id \

-             LEFT JOIN module m ON m.modul_id = p.modul_id \

-             WHERE {VORSCHLAG_FILTER} \

-             GROUP BY p.modul_id ORDER BY n DESC LIMIT 60"

+             COUNT(DISTINCT p.auftrag_id) \

+             FROM positionen p LEFT JOIN module m ON m.modul_id = p.modul_id \

-                    n_auftraege: r.get(3)?,

-                    avg_menge: r.get(4)?,

-                    avg_preis_eur: r.get(5)?,

-    let mut kandidaten: Vec<(String, Kandidat)> = Vec::new();

+    let mut global = std::collections::HashMap::new();

-        kandidaten.push(row.map_err(|e| e.to_string())?);

+        let (id, info) = row.map_err(|e| e.to_string())?;

-    // Prompt: Profil + Kandidatenliste

-    let oder = |v: &str| if v.is_empty() { "beliebig".to_string() } else { v.to_string() };

-    let fahrzeug = if fzg_klasse.is_empty() && fzg_groesse.is_empty() {

-        format!("{} {}", oder(&fzg_klasse), fzg_groesse).trim().to_string()

-        "Profil:\n- Gewerk: {}\n- Land: {}\n- Größe (Mitarbeiter): {}\n- Fahrzeug: {}\n\

-         Basis: {} Aufträge passen zum Profil.\n\n\

-         Kandidaten (modul_id | Bezeichnung | Artikelnr | in Aufträgen | Ø Menge | Ø Preis EUR):\n",

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

+    let gesetzt: Vec<(&str, &str, &String)> = [

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

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

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

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

+    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 {

+            let seg = modul_counts(&con, &[cond], &[wert])?;

+                    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)

+            verschoben >= MIN_VERSCHOBEN

+            mit_einfluss.push(name.to_string());

+            ohne_einfluss.push(name.to_string());

+    // Bereich = Schnitt der einflussreichen Merkmale (keine -> Gesamtbestand)

+    let n_basis = order_count(&con, &b_conds, &b_werte)?;

+            "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

+            "SELECT p.modul_id, AVG(COALESCE(p.menge, 1)), \

+             AVG(p.preis / COALESCE(a.wechselkurs, 1)) \

+             WHERE {} GROUP BY p.modul_id",

+        .query_map(rusqlite::params_from_iter(b_werte.iter()), |r| {

+    let mut ber_preise = std::collections::HashMap::new();

+        let (id, mp) = row.map_err(|e| e.to_string())?;

+    // Korrelationstabelle: wiederkehrende Module des Bereichs mit Lift

+    let mut korrelationen: Vec<KorrModul> = ber_counts

+            let p_bereich = *n as f64 / n_basis as f64;

+            let p_gesamt = info.n_all as f64 / n_gesamt as f64;

+                klartext: info.klartext.clone(),

+                lift: p_bereich / p_gesamt,

+    korrelationen.sort_unstable_by(|a, b| {

+            .then(b.lift.total_cmp(&a.lift))

+        return Err("Keine wiederkehrenden Module im Bereich.".to_string());

+    // Prompt: Profilstatus + Korrelationszeilen

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

+        ("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.starts_with(kurz)) {

+            format!("{wert} (eingeflossen)")

+            format!("{wert} (ohne messbaren Einfluss auf die Modulwahl — ignoriert)")

+        user.push_str(&format!("- {name}: {status}\n"));

+        "Bereich: {n_basis} von {n_gesamt} Aufträgen.\n\n\

+         Korrelationen (modul_id | Bezeichnung | Artikelnr | Anteil Bereich | Anteil gesamt | Lift | Ø Menge | Ø Preis EUR):\n"

+        let (avg_menge, avg_preis) = ber_preise[&k.modul_id];

-            k.artikelnummer.as_deref().unwrap_or("—"),

+            global[&k.modul_id].artikelnummer.as_deref().unwrap_or("—"),

.ok_or("keine positionen im LLM-JSON")?;

-    let kand_map: std::collections::HashMap<&str, &Kandidat> =

-        kandidaten.iter().map(|(id, k)| (id.as_str(), k)).collect();

+    // Regelabgleich gegen das gewählte Fahrzeug: flaggt, sperrt nicht.

+    // Ohne gewähltes Fahrzeug greift keine der (fahrzeugbedingten) 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) \

+    let korr_map: std::collections::HashMap<&str, &KorrModul> = korrelationen

+        .map(|k| (k.modul_id.as_str(), k))

.ok_or_else(|| format!("menge fehlt bei {id}"))?;

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

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

+        let flag: Option<String> = regel_stmt

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

+            .map_err(|e| e.to_string())?;

-            artikelnummer: k.artikelnummer.clone(),

+            artikelnummer: global[id].artikelnummer.clone(),

-            einzelpreis_eur: k.avg_preis_eur,

+            einzelpreis_eur: ber_preise[id].1,

-        n_kandidaten: kandidaten.len() as i64,


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 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";


            "SELECT p.modul_id, COUNT(DISTINCT p.auftrag_id) \
             FROM positionen p JOIN {ORDER_JOINS} ON a.auftrag_id = p.auftrag_id \
             WHERE {} GROUP BY p.modul_id",

            "SELECT p.modul_id, COUNT(DISTINCT p.auftrag_id) \
             FROM {POS_JOINS} \
             WHERE {} GROUP BY p.modul_id",


            "SELECT p.modul_id, AVG(COALESCE(p.menge, 1)), \
             AVG(p.preis / COALESCE(a.wechselkurs, 1)) \
             FROM positionen p JOIN {ORDER_JOINS} ON a.auftrag_id = p.auftrag_id \
             WHERE {} GROUP BY p.modul_id",

            "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",


} else if mit_einfluss.iter().any(|e| e.starts_with(kurz)) {

} else if mit_einfluss.iter().any(|e| e == kurz) {


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";

+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 {


            "SELECT p.modul_id, COUNT(DISTINCT p.auftrag_id) \
             FROM positionen p JOIN {ORDER_JOINS} ON a.auftrag_id = p.auftrag_id \
             WHERE {} GROUP BY p.modul_id",

            "SELECT p.modul_id, COUNT(DISTINCT p.auftrag_id) \
             FROM {POS_JOINS} \
             WHERE {} GROUP BY p.modul_id",

-             FROM positionen p JOIN {ORDER_JOINS} ON a.auftrag_id = p.auftrag_id \


            "SELECT p.modul_id, AVG(COALESCE(p.menge, 1)), \
             AVG(p.preis / COALESCE(a.wechselkurs, 1)) \
             FROM positionen p JOIN {ORDER_JOINS} ON a.auftrag_id = p.auftrag_id \
             WHERE {} GROUP BY p.modul_id",

            "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",


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

-        } else if mit_einfluss.iter().any(|e| e.starts_with(kurz)) {

+        } else if mit_einfluss.iter().any(|e| e == kurz) {

format!("{wert} (ohne messbaren Einfluss auf die Modulwahl — ignoriert)")


Frontend anpassen.


// ---- Vorschlagsengine (vorschlag_ai) ----
interface VorschlagPosition {
  modul_id: string;
  artikelnummer: string | null;
  klartext: string;
  menge: number;
  einzelpreis_eur: number;
  begruendung: string;
}
interface VorschlagAntwort {
  n_basis: number;
  n_kandidaten: number;
  erklaerung: string;
  positionen: VorschlagPosition[];
}

// ---- 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 vLaden = ref(false);
const vFehler = ref("");
const vAntwort = ref<VorschlagAntwort | null>(null);

const vLaden = ref(false);
const vFehler = ref("");
const vAntwort = ref<VorschlagAntwort | null>(null);
const vKorrOffen = ref(false);


  vLaden.value = true;
  vFehler.value = "";
  vAntwort.value = null;
  try {

  vLaden.value = true;
  vFehler.value = "";
  vAntwort.value = null;
  vKorrOffen.value = false;
  try {


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)

// ---- Vorschlagsengine (vorschlag_ai) ----
interface VorschlagPosition {
  modul_id: string;
  artikelnummer: string | null;
  klartext: string;
  menge: number;
  einzelpreis_eur: number;
  begruendung: string;
}
interface VorschlagAntwort {
  n_basis: number;
  n_kandidaten: number;
  erklaerung: string;
  positionen: VorschlagPosition[];
}

// ---- 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,
    }) + "×"
  );
}

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_kandidaten: 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 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 }[] {


  vLaden.value = true;
  vFehler.value = "";
  vAntwort.value = null;
  try {

  vLaden.value = true;
  vFehler.value = "";
  vAntwort.value = null;
  vKorrOffen.value = false;
  try {

vAntwort.value = await invoke<VorschlagAntwort>("vorschlag_ai", {


Vorschlagskonfiguration

{{ vAntwort.erklaerung }}

        Basis: {{ vAntwort.n_basis }} Aufträge zum Profil ·
        {{ vAntwort.n_kandidaten }} Kandidaten-Module

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

            {{ idx + 1 }}

              {{ p.klartext }}
              {{ p.modul_id }}

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

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

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) }}


Vorschlagskonfiguration

{{ vAntwort.erklaerung }}

        Basis: {{ vAntwort.n_basis }} Aufträge zum Profil ·
        {{ vAntwort.n_kandidaten }} Kandidaten-Module

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

            {{ idx + 1 }}

              {{ p.klartext }}
              {{ p.modul_id }}

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

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

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) }}

-        Basis: {{ vAntwort.n_basis }} Aufträge zum Profil ·

-        {{ vAntwort.n_kandidaten }} Kandidaten-Module

+        Bereich: {{ vAntwort.n_basis }} von {{ vAntwort.n_gesamt }} Aufträgen

+        <template v-if="vAntwort.mit_einfluss.length">

+          · eingeflossen: {{ vAntwort.mit_einfluss.join(", ") }}</template

+        <span v-if="vAntwort.ohne_einfluss.length" class="ohnee">

+          · ohne Einfluss: {{ vAntwort.ohne_einfluss.join(", ") }}</span

-          <tr v-for="(p, idx) in vAntwort.positionen" :key="p.modul_id">

+            v-for="(p, idx) in vAntwort.positionen"

+            :class="{ konflikt: p.flag }"

+              <span v-if="p.flag" class="pflag"

+                >⚠ {{ p.flag }} — ggf. anderes Fahrzeug wählen</span

+      <button class="sqltoggle" @click="vKorrOffen = !vKorrOffen">

+        {{ vKorrOffen ? "Korrelationen ausblenden" : "Korrelationen anzeigen" }}

+      <table v-if="vKorrOffen" class="stueck korrtable">

+          <tr v-for="k in vAntwort.korrelationen" :key="k.modul_id">


.c-beg {
  font-size: 0.74rem;
  color: var(--dim);
  max-width: 16rem;
  text-align: left;
}
.vorschlag .stueck td.c-beg {
  white-space: normal;
}

.c-beg {
  font-size: 0.74rem;
  color: var(--dim);
  max-width: 16rem;
  text-align: left;
}
.vorschlag .stueck td.c-beg {
  white-space: normal;
}
.ohnee {
  font-style: italic;
}
.pflag {
  display: block;
  margin-top: 0.15rem;
  font-size: 0.72rem;
  color: #b45309;
}
tr.konflikt td {
  background: rgba(245, 158, 11, 0.09);
}
.korrtable {
  margin-top: 0.4rem;
}


.c-beg {
  font-size: 0.74rem;
  color: var(--dim);
  max-width: 16rem;
  text-align: left;
}
.vorschlag .stueck td.c-beg {
  white-space: normal;
}
.ohnee {
  font-style: italic;
}
.pflag {
  display: block;
  margin-top: 0.15rem;
  font-size: 0.72rem;
  color: #b45309;
}
tr.konflikt td {
  background: rgba(245, 158, 11, 0.09);
}
.korrtable {
  margin-top: 0.4rem;
}


Bash

cd /Users/marcus.hinz/projects/08-07 && npx vue-tsc --noEmit 2>&1 | head -20; echo "tsc exit: $?"

Type-check the Vue frontend


tsc exit: 0
Shell cwd was reset to /Users/marcus.hinz/claude-projects/robotunits


cd /Users/marcus.hinz/projects/08-07 && sqlite3 data/wuerth.db "SELECT COUNT(DISTINCT a.auftrag_id) FROM 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 WHERE f.gewerk = 'Kfz';" && sqlite3 data/wuerth.db "SELECT p.modul_id, COUNT(DISTINCT p.auftrag_id) FROM 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 WHERE v.fzg_klasse = 'van' AND v.groesse = 'L2H1' GROUP BY p.modul_id ORDER BY 2 DESC LIMIT 5;" && sqlite3 data/wuerth.db "SELECT text FROM regeln WHERE (fzg_klasse IS NULL OR fzg_klasse = 'van') AND (groesse_muster IS NULL OR 'L1H1' LIKE groesse_muster) AND ('Werkbank klappbar' LIKE klartext_muster OR 'WKV42' LIKE klartext_muster) LIMIT 1;"

Validate the generated SQL shapes against the live database


35
MVSBS|24
VBW003|22
VKB01|22
MAM40|18
LOGOCLIP|16
Werkbank braucht Laderaumlänge ab L2 — passt nicht in L1
Shell cwd was reset to /Users/marcus.hinz/claude-projects/robotunits


Umbau fertig. Alle drei SQL-Formen laufen gegen die DB, und der Regelabgleich greift (Werkbank + L1H1 → Flag).

Was jetzt drin ist:

**Korrelationsanalyse (Rust, `vorschlag_ai`)**
- Je gesetztem Merkmal (Branche, Land, Größe, Fahrzeugklasse, Fahrzeuggröße) wird einzeln gemessen, ob es die Modulwahl verschiebt: Lift je Modul gegen den Gesamtbestand; Einfluss erst ab 2 verschobenen Modulen (Lift ≥ 1,5 bzw. ≤ 0,5, Mindest-Support 3, Segmente unter 5 Aufträgen zählen nicht). Merkmale ohne Einfluss definieren den Bereich nicht mit.
- Der Bereich ist der Schnitt der einflussreichen Merkmale; je Modul gehen Anteil im Bereich, Anteil gesamt und Lift in die Analyse.

**LLM**
- Sonnet 5 bekommt Profilstatus (eingeflossen / ohne Einfluss / nicht gesetzt) plus die Korrelationstabelle und erstellt die Konfiguration; Begründungen sollen die Zahlen zitieren („in 68 % der Aufträge im Bereich, Lift 2,1"). Modul-IDs außerhalb der Liste sind ein harter Fehler; Preise und Artikelnummern kommen aus der Historie.

**Regeln (Tabelle `regeln`, in `schema.sql` und Live-DB)**
- 6 handgepflegte Demo-Regeln (Werkbank/Schraubstock/Langgutwannen vs. L1/L2, Ständerrahmen vs. Pickup). Konflikte mit dem gewählten Fahrzeug sperren nichts: Die Position bleibt im Vorschlag und wird geflaggt.

**UI**
- Kopfzeile „Bereich: 33 von 191 Aufträgen · eingeflossen: … · ohne Einfluss: …", Tabelle mit Spalten Bereich-Anteil und Lift, geflaggte Zeilen amber mit „⚠ … — ggf. anderes Fahrzeug wählen", Korrelationstabelle aufklappbar wie „SQL anzeigen".

TypeScript-Check sauber; der Rust-Teil zeigt sich beim nächsten `npm run tauri dev`.