#!/usr/bin/env python3

# Constraint_Attribution.py
"""
Kapitel Praxisfallen: Welche Bedingung kostet wie viel - und was sagt man
dem Management?

Zwischen den beiden bekannten Faellen klafft eine Luecke:

  * Erklaerbarkeit.py beantwortet "warum diese eine Zuweisung?"
  * Konfliktsuche.py (Anhang Fehlerdiagnose) beantwortet "warum geht es gar
    nicht?"

Der haeufigste Fall in der Praxis liegt dazwischen: Das Modell rechnet, der
Plan ist zulaessig - und trotzdem unbefriedigend. Niemand weiss, WORAN es
liegt. Genau das beantwortet Constraint Attribution.

Gerechnet wird auf derselben Fabrik wie in Konfliktsuche.py, nur nach der
Reparatur: Dort war die Lackierkapazitaet von 150 Stunden der Kern des
Widerspruchs (2*40 + 3*30 = 170 > 150). Eine zweite Schicht hat sie auf 210
gebracht, und der Mindestumsatz wurde auf 10.000 EUR gesenkt. Jetzt ist das
Modell loesbar - und die Frage lautet nicht mehr "warum nicht?", sondern
"was kostet uns was?".

Gezeigt werden vier Dinge:
  1. Welche Bedingungen binden - und dass "bindend" nicht "teuer" heisst.
  2. Dass der Schattenpreis eine MOMENTAUFNAHME ist: hochgerechnet auf eine
     realistische Sonderschicht verspricht er hier das Doppelte des
     tatsaechlichen Nutzens.
  3. Dass ein Wunsch aus dem Vertrieb nicht teuer, sondern unmoeglich sein
     kann - und welche Bedingungen ihn gemeinsam blockieren.
  4. Wie aus alledem ein Bericht in Alltagssprache wird.

Benoetigt: numpy, scipy
"""

from __future__ import annotations

import textwrap

import numpy as np
from scipy.optimize import linprog

PRODUKTE = ["Rahmen", "Gehaeuse", "Deckel", "Traeger", "Halter"]
DECKUNGSBEITRAG = np.array([35.0, 28.0, 9.0, 22.0, 6.0])   # EUR je Stueck

# Dieselben Bedingungen wie in Konfliktsuche.py, mit den beiden Reparaturen.
# Der sprechende Name ist keine Kosmetik: Ohne ihn ist kein Bericht moeglich,
# der jemandem ausserhalb der Modellierung etwas sagt.
BEDINGUNGEN: list[tuple[str, list[float], str, float]] = [
    ("Kapazitaet Montage",       [3, 2, 1, 4, 2],      "<=",   400),
    ("Kapazitaet Lackieren",     [2, 3, 0, 1, 0],      "<=",   210),
    ("Kapazitaet Pruefung",      [1, 1, 1, 1, 1],      "<=",   300),
    ("Liefervertrag Rahmen",     [1, 0, 0, 0, 0],      ">=",    40),
    ("Liefervertrag Gehaeuse",   [0, 1, 0, 0, 0],      ">=",    30),
    ("Liefervertrag Deckel",     [0, 0, 1, 0, 0],      ">=",    20),
    ("Liefervertrag Traeger",    [0, 0, 0, 1, 0],      ">=",    25),
    ("Marktgrenze Rahmen",       [1, 0, 0, 0, 0],      "<=",   120),
    ("Marktgrenze Gehaeuse",     [0, 1, 0, 0, 0],      "<=",    90),
    ("Marktgrenze Deckel",       [0, 0, 1, 0, 0],      "<=",   150),
    ("Marktgrenze Halter",       [0, 0, 0, 0, 1],      "<=",   200),
    ("Mindestumsatz",            [90, 70, 30, 60, 20], ">=", 10000),
    ("Sortimentsbreite",         [0, 0, 1, 1, 1],      ">=",   100),
    ("Lackierbudget Schicht 2",  [0, 1, 0, 1, 0],      "<=",    55),
]

# Was liesse sich im Ernstfall wirklich bewegen - und um wie viel? Nicht "eine
# Einheit", sondern der Schritt, den ein Planer tatsaechlich gehen kann: eine
# Sonderschicht, ein nachverhandelter Vertrag. Genau an dieser Groesse
# scheitert das blosse Hochrechnen des Schattenpreises.
STELLSCHRAUBEN: dict[str, tuple[float, str]] = {
    "Kapazitaet Montage":      (+50, "eine Sonderschicht Montage"),
    "Kapazitaet Lackieren":    (+30, "eine Sonderschicht Lackieren"),
    "Kapazitaet Pruefung":     (+50, "eine Sonderschicht Pruefung"),
    "Lackierbudget Schicht 2": (+10, "10 Stunden mehr Budget in Schicht 2"),
    "Liefervertrag Traeger":   (-10, "10 Stueck weniger Abnahmepflicht Traeger"),
    "Liefervertrag Gehaeuse":  (-10, "10 Stueck weniger Abnahmepflicht Gehaeuse"),
}

WUNSCH_MENGE = 55.0      # der Vertrieb haette gern mehr Rahmen
WUNSCH_PRODUKT = "Rahmen"


def loese(bedingungen: list[tuple[str, list[float], str, float]]):
    """Maximiert den Deckungsbeitrag. linprog minimiert, daher das Vorzeichen."""
    matrix, rechte_seite = [], []
    for _, koeffizienten, richtung, grenze in bedingungen:
        zeile = np.array(koeffizienten, dtype=float)
        matrix.append(zeile if richtung == "<=" else -zeile)
        rechte_seite.append(grenze if richtung == "<=" else -grenze)
    return linprog(-DECKUNGSBEITRAG,
                   A_ub=np.array(matrix), b_ub=np.array(rechte_seite),
                   bounds=[(0, None)] * len(PRODUKTE), method="highs")


def geaendert(name: str, delta: float,
              basis=None) -> list[tuple[str, list[float], str, float]]:
    """Dieselben Bedingungen, eine davon um delta verschoben."""
    return [(n, k, r, g + delta if n == name else g)
            for n, k, r, g in (basis or BEDINGUNGEN)]


def auswertung(ergebnis, bedingungen=None):
    """Schlupf und Schattenpreis je Bedingung."""
    x = ergebnis.x
    dual = ergebnis.ineqlin.marginals
    zeilen = []
    for i, (name, koeff, richtung, grenze) in enumerate(bedingungen or BEDINGUNGEN):
        links = float(np.array(koeff, dtype=float) @ x)
        schlupf = (grenze - links) if richtung == "<=" else (links - grenze)
        zeilen.append((name, links, richtung, grenze, schlupf, abs(dual[i])))
    return zeilen


def blockierer(bedingungen, zusatz) -> list[str]:
    """Welche Bedingungen verhindern 'zusatz' - einzeln weggelassen.

    Dasselbe Vorgehen wie der Deletion Filter in Konfliktsuche.py, nur mit
    umgekehrtem Vorzeichen: Dort wird gesucht, was den Widerspruch AUSMACHT,
    hier, was einen Wunsch VERHINDERT. Wird das Modell ohne eine Bedingung
    loesbar, gehoert sie zum Konflikt.
    """
    gefunden = []
    for i, (name, _, _, _) in enumerate(bedingungen):
        rest = [c for j, c in enumerate(bedingungen) if j != i] + [zusatz]
        if loese(rest).success:
            gefunden.append(name)
    return gefunden


def euro(betrag: float) -> str:
    return f"{betrag:,.2f} EUR"


if __name__ == "__main__":
    basis = loese(BEDINGUNGEN)
    db_basis = -basis.fun
    plan = dict(zip(PRODUKTE, basis.x))

    print("=" * 78)
    print("  CONSTRAINT ATTRIBUTION: Was kostet uns welche Bedingung?")
    print("=" * 78)
    print(f"Status: {basis.message.split('(')[0].strip()}")
    print(f"Deckungsbeitrag des Plans: {euro(db_basis)}\n")
    print("  " + "  ".join(f"{p}: {m:.1f}" for p, m in plan.items()))

    # --- 1. Wer begrenzt den Plan? -----------------------------------------
    print("\n" + "=" * 78)
    print("  (1) Welche Bedingungen binden - und was kostet die naechste Einheit?")
    print("=" * 78)
    print(f"{'Bedingung':<24} {'genutzt':>9} {'Grenze':>7} {'Schlupf':>8} "
          f"{'Schattenpreis':>11}")
    print("-" * 78)
    bindend = []
    for name, links, richtung, grenze, schlupf, dual in auswertung(basis):
        marke = ""
        if abs(schlupf) < 1e-6:
            bindend.append((name, dual))
            marke = "  <-- bindend"
        print(f"{name:<24} {links:>9.1f} {grenze:>7.0f} {schlupf:>8.1f} "
              f"{dual:>11.2f}{marke}")

    ohne_preis = [n for n, d in bindend if d < 1e-9]
    print(f"\n  {len(bindend)} Bedingungen binden. Aber: "
          f"{', '.join(ohne_preis)} bindet")
    print("  mit einem Schattenpreis von 0,00 EUR - sie beruehrt den Plan, ohne ihn")
    print("  zu verteuern. Bindend und kostenrelevant sind zwei verschiedene Dinge.")

    # --- 2. Der Schattenpreis als Momentaufnahme ---------------------------
    print("\n" + "=" * 78)
    print("  (2) Was der Schattenpreis verschweigt")
    print("=" * 78)
    print("Der Schattenpreis gilt fuer die NAECHSTE Einheit. Ein Planer kauft aber")
    print("keine Einheit, sondern eine ganze Schicht. Beides gegenuebergestellt:\n")
    preise = {name: dual for name, *_, dual in auswertung(basis)}
    print(f"{'Stellschraube':<24} {'Preis':>9} {'Schritt':>7} "
          f"{'hochgerechnet':>13} {'gemessen':>9} {'Abw.':>9}")
    print("-" * 78)
    wirkung = {}
    for name, (delta, _) in STELLSCHRAUBEN.items():
        ergebnis = loese(geaendert(name, delta))
        echt = (-ergebnis.fun) - db_basis if ergebnis.success else 0.0
        linear = preise[name] * abs(delta)
        wirkung[name] = echt
        print(f"{name:<24} {preise[name]:>9.2f} {delta:>+7.0f} "
              f"{linear:>13.2f} {echt:>9.2f} {echt - linear:>+9.2f}")

    # Wo endet der Gueltigkeitsbereich? Schrittweise nachfahren.
    abweichler = max(STELLSCHRAUBEN,
                     key=lambda n: preise[n] * abs(STELLSCHRAUBEN[n][0]) - wirkung[n])
    delta_max = STELLSCHRAUBEN[abweichler][0]
    grenze_gueltig = 0.0
    schritt = delta_max / 60.0
    vorher = db_basis
    for i in range(1, 61):
        d = schritt * i
        e = loese(geaendert(abweichler, d))
        db = -e.fun if e.success else vorher
        if db - vorher > 1e-9:
            grenze_gueltig = d
        vorher = db
    print(f"\n  '{abweichler}': hochgerechnet "
          f"{preise[abweichler] * abs(delta_max):.2f} EUR, tatsaechlich "
          f"{wirkung[abweichler]:.2f} EUR.")
    print(f"  Der Preis von {preise[abweichler]:.2f} EUR gilt nur bis "
          f"+{grenze_gueltig:.0f} Stunden. Jede weitere Stunde")
    print("  dieser Sonderschicht bringt exakt nichts - der Engpass ist dann ein anderer.")

    nach_preis = sorted(STELLSCHRAUBEN, key=lambda n: -preise[n])[0]
    nach_wirkung = max(wirkung, key=wirkung.get)
    print(f"\n  Und die Ranglisten drehen sich: Nach Schattenpreis fuehrt")
    print(f"  '{nach_preis}' ({preise[nach_preis]:.2f} EUR je Einheit),")
    print(f"  nach tatsaechlicher Wirkung '{nach_wirkung}' "
          f"({wirkung[nach_wirkung]:.2f} EUR).")
    print("  Der Schattenpreis sagt, was eine Einheit wert ist - nicht, wie viele")
    print("  Einheiten zu haben sind.")

    # --- 3. Der Wunsch aus dem Vertrieb ------------------------------------
    print("\n" + "=" * 78)
    print(f"  (3) Was-waere-wenn: mindestens {WUNSCH_MENGE:.0f} "
          f"{WUNSCH_PRODUKT} statt {plan[WUNSCH_PRODUKT]:.1f}")
    print("=" * 78)
    wunsch = (f"Wunsch Vertrieb {WUNSCH_PRODUKT}",
              [1 if p == WUNSCH_PRODUKT else 0 for p in PRODUKTE], ">=", WUNSCH_MENGE)
    mit_wunsch = loese(BEDINGUNGEN + [wunsch])
    schuldige: list[str] = []
    engpass = noetig = None
    wunsch_gratis = False

    if mit_wunsch.success:
        print(f"Erfuellbar, kostet {euro(db_basis - (-mit_wunsch.fun))}.")
    else:
        schuldige = blockierer(BEDINGUNGEN, wunsch)
        print("Der Wunsch ist nicht teuer - er ist UNMOEGLICH.\n")
        print(f"Blockiert wird er von {len(schuldige)} Bedingungen gemeinsam. "
              f"Faellt eine davon")
        print("weg, ist er erfuellbar; keine allein ist 'der' Grund:")
        for name in schuldige:
            e = loese([c for c in BEDINGUNGEN if c[0] != name] + [wunsch])
            print(f"   ohne '{name}': moeglich, Deckungsbeitrag {euro(-e.fun)}")

        engpass = next((n for n in schuldige if n in STELLSCHRAUBEN), schuldige[0])
        for schritt in range(1, 61):
            if loese(geaendert(engpass, schritt) + [wunsch]).success:
                noetig = schritt
                break
        if noetig is not None:
            e = loese(geaendert(engpass, noetig) + [wunsch])
            ohne = loese(geaendert(engpass, noetig))
            wunsch_gratis = abs((-e.fun) - (-ohne.fun)) < 1e-6
            print(f"\n   Mit +{noetig} bei '{engpass}' wird der Wunsch erfuellbar:")
            print(f"   Deckungsbeitrag {euro(-e.fun)} "
                  f"({-e.fun - db_basis:+,.2f} gegenueber heute).")
            if wunsch_gratis:
                print(f"   Und ohne den Wunsch ergaebe dieselbe Lockerung "
                      f"{euro(-ohne.fun)} bei")
                print(f"   {ohne.x[PRODUKTE.index(WUNSCH_PRODUKT)]:.0f} "
                      f"{WUNSCH_PRODUKT} - der Wunsch kostet also NICHTS.")
                print("   Er war nie das Problem. Der Engpass war es.")

    # --- 4. Der Bericht ----------------------------------------------------
    # Aus denselben Zahlen, die oben in Tabellen stehen, wird hier Prosa. Kein
    # Satz enthaelt eine Zahl, die nicht vorher gerechnet wurde - das ist der
    # ganze Trick und zugleich die Bedingung dafuer, dass der Bericht nicht
    # irgendwann leise falsch wird.
    print("\n" + "=" * 78)
    print("  (4) Und so steht es in der Vorlage fuer die Abteilungsleiterrunde")
    print("=" * 78)

    teuerste = sorted(((n, d) for n, d in bindend if d > 0), key=lambda t: -t[1])
    gross = STELLSCHRAUBEN[nach_wirkung][1]
    klein = STELLSCHRAUBEN[abweichler][1]

    absaetze = [
        f"Der Plan bringt {euro(db_basis)} Deckungsbeitrag. Begrenzt wird er von "
        f"{len(bindend)} Bedingungen, von denen {len(teuerste)} tatsaechlich Geld "
        f"kosten; \"{ohne_preis[0]}\" beruehrt den Plan, ohne ihn zu verteuern.",

        f"Teuerste Bindung ist \"{teuerste[0][0]}\" mit "
        f"{teuerste[0][1]:.2f} EUR je Einheit, gefolgt von \"{teuerste[1][0]}\" "
        f"mit {teuerste[1][1]:.2f} EUR.",

        f"Die groesste einzelne Verbesserung bringt {gross}: "
        f"{euro(wirkung[nach_wirkung])} mehr Deckungsbeitrag. {klein[0].upper()}{klein[1:]} "
        f"dagegen bringt nur {euro(wirkung[abweichler])} statt der rechnerischen "
        f"{euro(preise[abweichler] * abs(delta_max))} - ab +{grenze_gueltig:.0f} "
        f"Stunden begrenzt uns etwas anderes. Wer die ganze Schicht bezahlt, "
        f"bezahlt die zweite Haelfte umsonst.",
    ]

    if schuldige and noetig is not None:
        namen = ", ".join(f'"{s}"' for s in schuldige)
        satz = (f"Zum Wunsch des Vertriebs ({WUNSCH_MENGE:.0f} {WUNSCH_PRODUKT} statt "
                f"{plan[WUNSCH_PRODUKT]:.1f}): Er ist mit der heutigen Ausstattung "
                f"nicht erfuellbar - nicht aus Kostengruenden, sondern weil {namen} "
                f"ihn gemeinsam blockieren. Mit +{noetig} Stunden bei "
                f'"{engpass}" wird er moeglich')
        satz += (", und er kostet dann nichts: Der Plan waehlt diese Menge von "
                 "sich aus." if wunsch_gratis else ".")
        absaetze.append(satz)

    for absatz in absaetze:
        print()
        print(textwrap.fill(absatz, width=76))
    print("\n" + "=" * 78)
