{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Kapitel 4: Vom Management-Wunsch zum Modell\n",
    "\n",
    "Begleitnotebook zu *Optimierte Entscheidungsfindung mit Python*. Die Codezellen sind identisch mit den im Buch abgedruckten Programmen.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Einmalig ausfuehren: installiert alle im Buch verwendeten Pakete.\n",
    "# Lokal in einer virtuellen Umgebung genauso gueltig wie in Google Colab.\n",
    "%pip install --quiet ortools highspy cvxpy scipy numpy pandas polars \\\n",
    "    scikit-learn matplotlib plotly pyomo linopy pymoo pydantic openpyxl"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Derselbe Datensatz, fünf Modelle\n",
    "\n",
    "`Vom_Wunsch_zum_Modell.py`\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#!/usr/bin/env python3\n",
    "\n",
    "# Vom_Wunsch_zum_Modell.py\n",
    "\"\"\"\n",
    "Kapitel Modellierung: Derselbe Datensatz, fuenf zulaessige Modelle, fuenf andere Plaene.\n",
    "\n",
    "Eine Lohnfertigung kann nicht alle Anfragen annehmen: 14 Auftraege verlangen\n",
    "zusammen 800 Maschinenstunden, verfuegbar sind 300. Es muss also ausgewaehlt\n",
    "werden. Die Daten stehen fest - was FEHLT, ist die Frage.\n",
    "\n",
    "Das Programm zeigt in vier Teilen, was daran haengt:\n",
    "\n",
    "  1. Drei naheliegende Ziele aus dem Managementgespraech (\"Umsatz\", \"Auslastung\",\n",
    "     \"Deckungsbeitrag\") - und wie weit die Plaene auseinanderlaufen.\n",
    "  2. Der Satz \"Stammkunden duerfen wir nicht verlieren\" in drei Lesarten. Alle\n",
    "     drei sind vertretbar, alle drei kosten unterschiedlich viel.\n",
    "  3. Was ein Strafgewicht daraus macht - und ab wann es dasselbe tut wie eine\n",
    "     harte Regel.\n",
    "  4. Was die harte Lesart ein Quartal spaeter anrichtet.\n",
    "\n",
    "Der Punkt des Kapitels: Nicht die Daten bestimmen das Modell, sondern die\n",
    "Frage. Und die steht selten in der Anforderung.\n",
    "\n",
    "Benoetigt: numpy, scipy\n",
    "\"\"\"\n",
    "\n",
    "from __future__ import annotations\n",
    "\n",
    "import numpy as np\n",
    "from scipy.optimize import linprog\n",
    "\n",
    "# (Nummer, Kunde, Stammkunde?, Maschinenstunden, Umsatz, Materialkosten)\n",
    "AUFTRAEGE = [\n",
    "    (\"A-101\", \"Weber GmbH\",     True,   40,  28_000,  19_000),\n",
    "    (\"A-102\", \"Weber GmbH\",     True,   25,  15_000,   9_500),\n",
    "    (\"A-103\", \"Kranz AG\",       True,   60,  33_000,  24_000),\n",
    "    (\"A-104\", \"Kranz AG\",       True,   35,  19_000,  13_500),\n",
    "    (\"A-105\", \"Obermeier KG\",   True,   20,  12_000,   7_000),\n",
    "    (\"A-106\", \"Siedler & Co\",   False, 120,  84_000,  71_000),\n",
    "    (\"A-107\", \"Siedler & Co\",   False, 110,  74_000,  63_000),\n",
    "    (\"A-108\", \"Novatek\",        False,  95,  61_000,  52_000),\n",
    "    (\"A-109\", \"Novatek\",        False,  30,  31_000,  17_000),\n",
    "    (\"A-110\", \"Hellwig\",        False,  15,  17_000,   8_000),\n",
    "    (\"A-111\", \"Hellwig\",        False,  55,  41_000,  30_000),\n",
    "    (\"A-112\", \"Pfeiffer Werke\", False,  80,  46_000,  39_000),\n",
    "    (\"A-113\", \"Obermeier KG\",   True,   45,  26_000,  17_000),\n",
    "    (\"A-114\", \"Pfeiffer Werke\", False,  70,  52_000,  36_000),\n",
    "]\n",
    "KAPAZITAET = 300          # Maschinenstunden im Planungszeitraum\n",
    "\n",
    "STUNDEN = np.array([a[3] for a in AUFTRAEGE], dtype=float)\n",
    "UMSATZ = np.array([a[4] for a in AUFTRAEGE], dtype=float)\n",
    "DECKUNGSBEITRAG = np.array([a[4] - a[5] for a in AUFTRAEGE], dtype=float)\n",
    "IST_STAMM = np.array([a[2] for a in AUFTRAEGE])\n",
    "STAMMKUNDEN = sorted({a[1] for a in AUFTRAEGE if a[2]})\n",
    "\n",
    "\n",
    "def waehle_aus(ziel: np.ndarray, zusatz_ub=None, zusatz_b_ub=None,\n",
    "               zusatz_eq=None, zusatz_b_eq=None, auftraege=AUFTRAEGE,\n",
    "               stunden=STUNDEN, kapazitaet=KAPAZITAET):\n",
    "    \"\"\"Ein Auftrag wird angenommen oder nicht - mehr Modell ist das nicht.\n",
    "\n",
    "    Der ganze Unterschied zwischen den fuenf Varianten unten steckt in 'ziel'\n",
    "    und in den Zusatzbedingungen. Das Modell selbst bleibt dasselbe.\n",
    "    \"\"\"\n",
    "    ergebnis = linprog(\n",
    "        -ziel,\n",
    "        A_ub=[stunden] + (zusatz_ub or []),\n",
    "        b_ub=[kapazitaet] + (zusatz_b_ub or []),\n",
    "        A_eq=zusatz_eq, b_eq=zusatz_b_eq,\n",
    "        bounds=(0, 1), integrality=1, method=\"highs\")\n",
    "    if not ergebnis.success:\n",
    "        return None\n",
    "    return np.round(ergebnis.x).astype(int)\n",
    "\n",
    "\n",
    "def kennzahlen(plan, auftraege=AUFTRAEGE, stunden=STUNDEN, umsatz=UMSATZ,\n",
    "               db=DECKUNGSBEITRAG, ist_stamm=IST_STAMM, kapazitaet=KAPAZITAET):\n",
    "    \"\"\"Immer dieselben fuenf Zahlen - nur so sind die Varianten vergleichbar.\"\"\"\n",
    "    abgelehnte_stammauftraege = int(((1 - plan) & ist_stamm.astype(int)).sum())\n",
    "    leer_ausgegangen = sum(\n",
    "        1 for kunde in sorted({a[1] for a in auftraege if a[2]})\n",
    "        if not any(plan[i] for i, a in enumerate(auftraege) if a[1] == kunde))\n",
    "    return {\"umsatz\": float(umsatz @ plan), \"db\": float(db @ plan),\n",
    "            \"auslastung\": float(stunden @ plan) / kapazitaet * 100,\n",
    "            \"stamm_abgelehnt\": abgelehnte_stammauftraege,\n",
    "            \"kunden_leer\": leer_ausgegangen, \"angenommen\": int(plan.sum())}\n",
    "\n",
    "\n",
    "def zeile(name: str, plan) -> None:\n",
    "    if plan is None:\n",
    "        print(f\"  {name:<34} {'UNZULAESSIG - kein Plan moeglich':>44}\")\n",
    "        return\n",
    "    k = kennzahlen(plan)\n",
    "    print(f\"  {name:<34} {k['umsatz']:>10,.0f} {k['db']:>9,.0f} \"\n",
    "          f\"{k['auslastung']:>6.0f}% {k['stamm_abgelehnt']:>7} \"\n",
    "          f\"{k['kunden_leer']:>6} {k['angenommen']:>7}\")\n",
    "\n",
    "\n",
    "def kopfzeile() -> None:\n",
    "    print(f\"  {'':<34} {'Umsatz':>10} {'DB':>9} {'Aus-':>7} {'Stamm-':>7} \"\n",
    "          f\"{'Kunden':>6} {'ange-':>7}\")\n",
    "    print(f\"  {'':<34} {'':>10} {'':>9} {'lastung':>7} {'auftr.':>7} \"\n",
    "          f\"{'ohne':>6} {'nommen':>7}\")\n",
    "    print(\"  \" + \"-\" * 78)\n",
    "\n",
    "\n",
    "if __name__ == \"__main__\":\n",
    "    print(\"=\" * 84)\n",
    "    print(\"  VOM MANAGEMENT-WUNSCH ZUM MODELL\")\n",
    "    print(\"=\" * 84)\n",
    "    print(f\"{len(AUFTRAEGE)} Anfragen verlangen {STUNDEN.sum():.0f} Maschinenstunden, \"\n",
    "          f\"verfuegbar sind {KAPAZITAET}.\")\n",
    "    print(f\"Es muss also ausgewaehlt werden. {len(STAMMKUNDEN)} der Kunden sind \"\n",
    "          f\"Stammkunden:\")\n",
    "    print(f\"  {', '.join(STAMMKUNDEN)}\\n\")\n",
    "\n",
    "    # --- 1. Drei Saetze aus dem Gespraech, drei Ziele ---------------------\n",
    "    print(\"-\" * 84)\n",
    "    print(\"1. Was im Gespraech gesagt wurde - drei naheliegende Ziele\\n\")\n",
    "    kopfzeile()\n",
    "    plaene = {}\n",
    "    for name, ziel in [(\"\\\"Umsatz maximieren\\\"\", UMSATZ),\n",
    "                       (\"\\\"Maschinen besser auslasten\\\"\", STUNDEN),\n",
    "                       (\"\\\"Deckungsbeitrag maximieren\\\"\", DECKUNGSBEITRAG)]:\n",
    "        plaene[name] = waehle_aus(ziel)\n",
    "        zeile(name, plaene[name])\n",
    "\n",
    "    umsatzplan = kennzahlen(plaene[\"\\\"Umsatz maximieren\\\"\"])\n",
    "    auslastungsplan = kennzahlen(plaene[\"\\\"Maschinen besser auslasten\\\"\"])\n",
    "    dbplan = kennzahlen(plaene[\"\\\"Deckungsbeitrag maximieren\\\"\"])\n",
    "\n",
    "    mehr_umsatz = umsatzplan[\"umsatz\"] - dbplan[\"umsatz\"]\n",
    "    weniger_db = dbplan[\"db\"] - umsatzplan[\"db\"]\n",
    "    print(f\"\\n  Der Umsatzplan bringt {mehr_umsatz:,.0f} EUR mehr Umsatz und \"\n",
    "          f\"{weniger_db:,.0f} EUR WENIGER\")\n",
    "    print(f\"  Deckungsbeitrag. Fuer {mehr_umsatz:,.0f} EUR Umsatz werden also \"\n",
    "          f\"{weniger_db:,.0f} EUR Ergebnis\")\n",
    "    print(f\"  aufgegeben - und {umsatzplan['stamm_abgelehnt']} von \"\n",
    "          f\"{int(IST_STAMM.sum())} Stammkundenauftraegen dazu.\")\n",
    "    print()\n",
    "    print(\"  \\\"Auslastung\\\" ist der haeufigste Wunsch und der schaedlichste. Der\")\n",
    "    print(f\"  darauf optimierte Plan bringt {dbplan['db'] - auslastungsplan['db']:,.0f} EUR weniger \"\n",
    "          f\"Deckungsbeitrag und\")\n",
    "    print(f\"  {dbplan['umsatz'] - auslastungsplan['umsatz']:,.0f} EUR weniger Umsatz als der \"\n",
    "          f\"DB-Plan - und erreicht dabei\")\n",
    "    print(f\"  nicht einmal mehr Auslastung: alle drei liegen bei \"\n",
    "          f\"{dbplan['auslastung']:.0f} %.\")\n",
    "    print(\"  Eine volle Maschine ist eben kein Ziel, sondern ein Nebenprodukt.\")\n",
    "\n",
    "    # --- 2. Ein Satz, drei Lesarten --------------------------------------\n",
    "    print(\"\\n\" + \"-\" * 84)\n",
    "    print(\"2. \\\"Stammkunden duerfen wir nicht verlieren\\\" - aber was heisst das?\\n\")\n",
    "    kopfzeile()\n",
    "    zeile(\"ohne die Regel (DB maximieren)\", waehle_aus(DECKUNGSBEITRAG))\n",
    "\n",
    "    # Lesart A: jeder Stammkundenauftrag wird angenommen\n",
    "    alle_stamm = [[1.0 if IST_STAMM[i] else 0.0 for i in range(len(AUFTRAEGE))]]\n",
    "    lesart_a = waehle_aus(DECKUNGSBEITRAG, zusatz_eq=alle_stamm,\n",
    "                          zusatz_b_eq=[float(IST_STAMM.sum())])\n",
    "    zeile(\"A: jeden Stammauftrag annehmen\", lesart_a)\n",
    "\n",
    "    # Lesart B: kein Stammkunde geht leer aus (mindestens ein Auftrag je Kunde)\n",
    "    je_kunde_ub, je_kunde_b = [], []\n",
    "    for kunde in STAMMKUNDEN:\n",
    "        je_kunde_ub.append([-1.0 if a[1] == kunde else 0.0 for a in AUFTRAEGE])\n",
    "        je_kunde_b.append(-1.0)\n",
    "    lesart_b = waehle_aus(DECKUNGSBEITRAG, zusatz_ub=je_kunde_ub,\n",
    "                          zusatz_b_ub=je_kunde_b)\n",
    "    zeile(\"B: kein Stammkunde geht leer aus\", lesart_b)\n",
    "\n",
    "    a, b = kennzahlen(lesart_a), kennzahlen(lesart_b)\n",
    "    ohne = kennzahlen(waehle_aus(DECKUNGSBEITRAG))\n",
    "    print(\"\\n  Beide Lesarten erreichen dasselbe: kein Stammkunde geht leer aus.\")\n",
    "    print(f\"  Lesart A kostet dafuer {ohne['db'] - a['db']:,.0f} EUR Deckungsbeitrag, \"\n",
    "          f\"Lesart B nur {ohne['db'] - b['db']:,.0f} EUR -\")\n",
    "    print(f\"  ein Unterschied von {b['db'] - a['db']:,.0f} EUR fuer denselben Satz aus derselben\")\n",
    "    print(\"  Besprechung. Wer nicht nachfragt, entscheidet das selbst, ohne es\")\n",
    "    print(\"  zu merken.\")\n",
    "\n",
    "    # --- 3. Die weiche Variante ------------------------------------------\n",
    "    print(\"\\n\" + \"-\" * 84)\n",
    "    print(\"3. Dieselbe Regel als Strafkosten je abgelehntem Stammauftrag\\n\")\n",
    "    kopfzeile()\n",
    "    for strafe in (3_000, 6_000, 10_000):\n",
    "        ziel = DECKUNGSBEITRAG + np.where(IST_STAMM, float(strafe), 0.0)\n",
    "        zeile(f\"C: Strafe {strafe:>6,} EUR je Auftrag\", waehle_aus(ziel))\n",
    "\n",
    "    print(\"\\n  Bei 3.000 EUR aendert sich nichts: Die Strafe liegt unter dem, was\")\n",
    "    print(\"  die Auftraege an Deckungsbeitrag verdraengen wuerden. Bei 10.000 EUR\")\n",
    "    print(\"  kommt genau Lesart A heraus. Eine harte Regel ist also nichts\")\n",
    "    print(\"  anderes als eine weiche mit hinreichend grosser Strafe - was ein\")\n",
    "    print(\"  Strafgewicht sonst noch bedeutet, steht im Kapitel CP-SAT.\")\n",
    "\n",
    "    # --- 4. Was die harte Lesart ein Quartal spaeter anrichtet -----------\n",
    "    print(\"\\n\" + \"-\" * 84)\n",
    "    print(\"4. Ein Quartal spaeter: Weber fragt einen Grossauftrag an\\n\")\n",
    "    erweitert = AUFTRAEGE + [(\"A-115\", \"Weber GmbH\", True, 320, 210_000, 150_000)]\n",
    "    stunden_neu = np.array([a[3] for a in erweitert], dtype=float)\n",
    "    db_neu = np.array([a[4] - a[5] for a in erweitert], dtype=float)\n",
    "    stamm_neu = np.array([a[2] for a in erweitert])\n",
    "\n",
    "    print(f\"  A-115: Weber GmbH, {erweitert[-1][3]} Maschinenstunden - allein mehr,\")\n",
    "    print(f\"  als der ganze Zeitraum hergibt ({KAPAZITAET} h).\\n\")\n",
    "\n",
    "    alle_stamm_neu = [[1.0 if stamm_neu[i] else 0.0 for i in range(len(erweitert))]]\n",
    "    hart = waehle_aus(db_neu, zusatz_eq=alle_stamm_neu,\n",
    "                      zusatz_b_eq=[float(stamm_neu.sum())],\n",
    "                      auftraege=erweitert, stunden=stunden_neu)\n",
    "    weich = waehle_aus(db_neu + np.where(stamm_neu, 10_000.0, 0.0),\n",
    "                       auftraege=erweitert, stunden=stunden_neu)\n",
    "\n",
    "    print(f\"  Lesart A (hart):  \"\n",
    "          f\"{'UNZULAESSIG - der Nachtlauf bricht ab' if hart is None else 'loesbar'}\")\n",
    "    if weich is not None:\n",
    "        k = kennzahlen(weich, erweitert, stunden_neu,\n",
    "                       np.array([a[4] for a in erweitert], dtype=float),\n",
    "                       db_neu, stamm_neu)\n",
    "        print(f\"  Lesart C (weich): loesbar - {k['db']:,.0f} EUR Deckungsbeitrag, \"\n",
    "              f\"{k['stamm_abgelehnt']} Stammauftrag abgelehnt\")\n",
    "\n",
    "    print(\"\\n\" + \"=\" * 84)\n",
    "    print(\"  WORAUF ES ANKOMMT\")\n",
    "    print(\"=\" * 84)\n",
    "    print(\"Die Daten waren in allen vier Teilen dieselben. Was sich geaendert hat,\")\n",
    "    print(\"war nur die Frage - und mit ihr der Plan, der Deckungsbeitrag und die\")\n",
    "    print(\"Kundenbeziehung.\")\n",
    "    print()\n",
    "    print(\"Drei Dinge, die deshalb vor dem ersten Modell geklaert gehoeren:\")\n",
    "    print()\n",
    "    print(\"  1. WORAN wird der Erfolg gemessen? Nicht 'was ist Ihnen wichtig',\")\n",
    "    print(\"     sondern: Welche Zahl steht am Jahresende im Bericht? Umsatz,\")\n",
    "    print(\"     Auslastung und Deckungsbeitrag fuehren hier zu drei\")\n",
    "    print(\"     verschiedenen Plaenen.\")\n",
    "    print(\"  2. WAS GENAU heisst der Satz? 'Stammkunden nicht verlieren' hat\")\n",
    "    print(f\"     mindestens drei Lesarten mit {b['db'] - a['db']:,.0f} EUR Unterschied. Die Frage\")\n",
    "    print(\"     'meinen Sie jeden Auftrag oder jeden Kunden?' dauert zehn\")\n",
    "    print(\"     Sekunden.\")\n",
    "    print(\"  3. WAS PASSIERT, WENN ES NICHT GEHT? Eine harte Regel, die niemand\")\n",
    "    print(\"     hinterfragt hat, macht das System eines Tages unloesbar - und\")\n",
    "    print(\"     zwar genau dann, wenn ein besonders grosser Auftrag hereinkommt.\")\n",
    "    print(\"     Weiche Regeln antworten auch dann noch.\")\n",
    "    print(\"=\" * 84)"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.11"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
