bifroest/app/demo/bestellservice.py
2026-10-27 08:34:17 +01:00

325 lines
12 KiB
Python

"""Demo „Bestellservice“: kleiner Python-Dienst, Bestelldaten 2026, Projektunterlagen.
Bewusst eingebaut: Der Code rechnet noch mit der alten Rabattregel, das Protokoll von vor zehn Tagen hat eine
neue beschlossen. Daran sieht man, wie die KI Quellen verknüpft und eine Änderung vorschlägt.
"""
import csv
import io
import random
from datetime import date, timedelta
from app.demo import Demo, day, shop_db
NAME = "Demo: Bestellservice"
DESCRIPTION = ("Beispiel-Workspace mit dem Repository eines kleinen Bestellservice, Bestelldaten 2026 und "
"Projektunterlagen (Protokolle, Wartungsvertrag, Betriebshandbuch).")
EXAMPLES = [
"Wie ist der Bestellservice aufgebaut? Gib mir einen Überblick über die wichtigsten Module.",
"Was hat sich in den letzten zwei Wochen im Repository geändert?",
"Welche Region hatte 2026 den höchsten Umsatz? Zeig den Umsatz je Monat und Region als Diagramm.",
"Welche Reaktionszeit sieht der Wartungsvertrag bei einer Störung mit Priorität 1 vor, und bis wann "
"können wir kündigen?",
"Passt die Rabattberechnung im Code zu dem, was in der Abstimmung zu den Rabatten beschlossen wurde?",
"Setze die neue Rabattregel aus dem Protokoll im Code um und passe die Tests an.",
]
# --- Repository: (Tage zurück, Autor, Nachricht, Dateien) -------------------------------------------------------
README = """# Bestellservice
Kleiner Dienst für Bestellungen: Preise, Versandkosten, Rabatte und eine schlanke API.
## Aufbau
- `bestellservice/bestellung.py`: Bestellung und Positionen, Gesamtpreis
- `bestellservice/versand.py`: Versandkosten
- `bestellservice/rabatt.py`: Rabattstaffel
- `bestellservice/api.py`: Handler für Anlegen, Abrufen und Stornieren
- `tests/`: Tests mit pytest
"""
BETRIEB = """
## Betrieb
- Deployment über die Pipeline `deploy-bestellservice`, Freigabe durch den Fachbereich.
- Logs im zentralen Logging unter `app=bestellservice`.
- Störungen: siehe Betriebshandbuch in den Projektunterlagen.
"""
BESTELLUNG = '''"""Bestellung mit Positionen; Preise in Cent, damit nicht gerundet werden muss."""
from dataclasses import dataclass, field
from bestellservice.rabatt import rabatt_prozent
from bestellservice.versand import versandkosten
@dataclass
class Position:
artikel: str
menge: int
einzelpreis: int # Cent
@dataclass
class Bestellung:
nummer: str
kunde: str
positionen: list[Position] = field(default_factory=list)
storniert: bool = False
def warenwert(self) -> int:
return sum(p.menge * p.einzelpreis for p in self.positionen)
def gesamtpreis(self) -> int:
"""Warenwert abzüglich Rabatt, zuzüglich Versand."""
wert = self.warenwert()
rabatt = wert * rabatt_prozent(wert) // 100
return wert - rabatt + versandkosten(wert)
'''
BESTELLUNG_START = '''"""Bestellung mit Positionen; Preise in Cent, damit nicht gerundet werden muss."""
from dataclasses import dataclass, field
@dataclass
class Position:
artikel: str
menge: int
einzelpreis: int # Cent
@dataclass
class Bestellung:
nummer: str
kunde: str
positionen: list[Position] = field(default_factory=list)
def warenwert(self) -> int:
return sum(p.menge * p.einzelpreis for p in self.positionen)
def gesamtpreis(self) -> int:
return self.warenwert()
'''
VERSAND = '''"""Versandkosten: pauschal, ab einem Warenwert von 50 Euro frei."""
PAUSCHALE = 495 # Cent
FREI_AB = 5000 # Cent
def versandkosten(warenwert: int) -> int:
return 0 if warenwert >= FREI_AB else PAUSCHALE
'''
RABATT = '''"""Rabattstaffel nach Warenwert (in Cent)."""
# Ab 500 Euro Warenwert 10 Prozent Rabatt
SCHWELLE = 50000
PROZENT = 10
def rabatt_prozent(warenwert: int) -> int:
return PROZENT if warenwert >= SCHWELLE else 0
'''
API = '''"""Handler der API; das Web-Framework ruft sie mit bereits geprüften Daten auf."""
from bestellservice.bestellung import Bestellung, Position
BESTELLUNGEN: dict[str, Bestellung] = {}
def anlegen(nummer: str, kunde: str, positionen: list[dict]) -> dict:
bestellung = Bestellung(nummer, kunde, [Position(**p) for p in positionen])
BESTELLUNGEN[nummer] = bestellung
return {"nummer": nummer, "gesamtpreis": bestellung.gesamtpreis()}
def abrufen(nummer: str) -> dict:
bestellung = BESTELLUNGEN[nummer]
return {"nummer": nummer, "kunde": bestellung.kunde, "gesamtpreis": bestellung.gesamtpreis(),
"storniert": bestellung.storniert}
'''
API_STORNO = API + '''
def stornieren(nummer: str) -> dict:
"""Nur nicht bereits stornierte Bestellungen; Erstattung übernimmt die Buchhaltung."""
bestellung = BESTELLUNGEN[nummer]
if bestellung.storniert:
raise ValueError(f"Bestellung {nummer} ist bereits storniert.")
bestellung.storniert = True
return {"nummer": nummer, "storniert": True}
'''
TEST_RABATT = '''from bestellservice.rabatt import rabatt_prozent
def test_kein_rabatt_unter_der_schwelle():
assert rabatt_prozent(49999) == 0
def test_rabatt_ab_500_euro():
assert rabatt_prozent(50000) == 10
'''
TEST_VERSAND = '''from bestellservice.versand import versandkosten
def test_versand_frei_ab_50_euro():
assert versandkosten(5000) == 0
assert versandkosten(4999) == 495
'''
AGENTS = """# Projektregeln
- Python 3.12, Typ-Hinweise überall, keine neuen Abhängigkeiten ohne Absprache.
- Beträge immer in Cent (int), nie als float.
- Jede fachliche Regel hat einen Test unter `tests/`.
- Commit-Nachrichten auf Deutsch, im Imperativ ("Rabatt anpassen").
"""
PYPROJECT = """[project]
name = "bestellservice"
version = "0.3.0"
requires-python = ">=3.12"
[tool.pytest.ini_options]
testpaths = ["tests"]
"""
GUTSCHEIN = '''"""Gutscheine (in Arbeit): fester Betrag in Cent, einmal einlösbar."""
EINGELOEST: set[str] = set()
def einloesen(code: str, betrag: int) -> int:
if code in EINGELOEST:
raise ValueError("Gutschein bereits eingelöst.")
EINGELOEST.add(code)
return betrag
'''
COMMITS = [
(21, "ben", "Grundgerüst Bestellservice", {
"README.md": README, "pyproject.toml": PYPROJECT, "bestellservice/__init__.py": "",
"bestellservice/bestellung.py": BESTELLUNG_START, "bestellservice/api.py": API}),
(18, "clara", "Versandkosten: frei ab 50 Euro", {
"bestellservice/versand.py": VERSAND, "tests/test_versand.py": TEST_VERSAND}),
(12, "ben", "Rabattstaffel einführen", {
"bestellservice/rabatt.py": RABATT, "tests/test_rabatt.py": TEST_RABATT,
"bestellservice/bestellung.py": BESTELLUNG.replace(" storniert: bool = False\n", "")}),
(6, "anna", "Projektregeln in AGENTS.md festhalten", {"AGENTS.md": AGENTS}),
(3, "ben", "API: Bestellung stornieren", {
"bestellservice/api.py": API_STORNO, "bestellservice/bestellung.py": BESTELLUNG}),
(1, "clara", "README: Betrieb ergänzen", {"README.md": README + BETRIEB}),
]
BRANCH = ("feature/gutscheine", 2, "ben", "Gutscheine: erster Entwurf",
{"bestellservice/gutschein.py": GUTSCHEIN})
# --- Projektunterlagen (Pfad, Text) -----------------------------------------------------------------------------
# Projektstart vor dem ersten Commit, Rabatt-Abstimmung nach dem Commit mit der alten Staffel
START, RABATTE = day(25), day(10)
DOCUMENTS = {
f"Protokolle/{RABATTE:%Y-%m-%d} Abstimmung Rabatte.md": f"""# Abstimmung Rabatte, {RABATTE:%d.%m.%Y}
Teilnehmende: Fachbereich Vertrieb, Entwicklung, Controlling
## Beschlüsse
1. Die bisherige Staffel (10 % ab 500 Euro Warenwert) entfällt, weil sie die Marge zu stark drückt.
2. Neu gilt ab sofort: **5 % Rabatt ab 1.000 Euro Warenwert**. Darunter kein Rabatt.
3. Versandkosten bleiben unverändert (frei ab 50 Euro).
4. Die Entwicklung setzt die Regel innerhalb von zwei Wochen um, mit Tests für die Grenzwerte.
## Offene Punkte
- Gutscheine: Entwurf liegt im Branch `feature/gutscheine`, Abstimmung im November.
""",
f"Protokolle/{START:%Y-%m-%d} Projektstart.md": f"""# Projektstart Bestellservice, {START:%d.%m.%Y}
- Ziel: Bestellungen aus dem Webshop und von der Hotline in einem Dienst bündeln.
- Beträge werden durchgängig in Cent geführt.
- Betrieb über den bestehenden Wartungsvertrag (Rahmenvertrag Anwendungsbetrieb).
- Nächster Termin: Abstimmung Rabatte am {RABATTE:%d.%m.}
""",
"Wartungsvertrag.md": """# Wartungsvertrag Anwendungsbetrieb (Auszug)
## § 3 Reaktionszeiten
| Priorität | Beschreibung | Reaktionszeit | Wiederherstellung |
|---|---|---|---|
| 1 | Dienst nicht verfügbar | 2 Stunden, rund um die Uhr | 8 Stunden |
| 2 | Wesentliche Funktion gestört | 8 Stunden an Werktagen | 3 Werktage |
| 3 | Sonstige Störung | 2 Werktage | nach Absprache |
## § 7 Laufzeit und Kündigung
Der Vertrag läuft bis zum 31.12.2027 und verlängert sich danach jeweils um ein Jahr. Er kann mit einer Frist
von **drei Monaten zum Quartalsende** gekündigt werden, erstmals zum 31.12.2027 (Kündigung also spätestens
am 30.09.2027).
""",
"Betriebshandbuch.md": """# Betriebshandbuch Bestellservice
## Deployment
Über die Pipeline `deploy-bestellservice`. Produktion nur nach Freigabe durch den Fachbereich, nie freitags
nach 14 Uhr.
## Überwachung
- Health-Check: `/healthz`, alle 30 Sekunden.
- Alarm, wenn mehr als 2 % der Bestellungen in 5 Minuten fehlschlagen.
## Störungen
1. Priorität nach Wartungsvertrag § 3 einstufen.
2. Priorität 1: Rufbereitschaft der Entwicklung über die Leitstelle alarmieren.
3. Ticket im Service-Desk anlegen, Ursache im Nachgang dokumentieren.
""",
"Onboarding.md": """# Onboarding im Team Bestellservice
- Zugang zum Repository über das Team „Bestellservice“.
- Projektregeln stehen in `AGENTS.md` im Repository.
- Fragen zu Fachlichkeit: Fachbereich Vertrieb; zu Betrieb: Betriebshandbuch.
""",
}
# --- Bestelldaten -----------------------------------------------------------------------------------------------
PRODUCTS = {"Kaffeemaschine": 24900, "Wasserkocher": 3990, "Mühle": 8990, "Milchaufschäumer": 5490,
"Kaffeebohnen 1 kg": 1890, "Entkalker": 790}
REGIONS = {"Nord": 1.0, "Süd": 1.25, "West": 1.1, "Ost": 0.8}
CHANNELS = ["Online", "Online", "Online", "Filiale", "Telefon"]
def orders_csv(rows: int = 1200) -> bytes:
"""Bestellungen 2026 (Januar bis Oktober), reproduzierbar; Süd wächst über das Jahr am stärksten."""
rng = random.Random(2026)
out = io.StringIO()
writer = csv.writer(out)
writer.writerow(["bestellnr", "datum", "region", "kanal", "produkt", "menge", "einzelpreis_eur",
"rabatt_prozent", "umsatz_eur", "retoure"])
start = date(2026, 1, 1)
for n in range(rows):
day = start + timedelta(days=rng.randrange(0, 297))
region = rng.choices(list(REGIONS), weights=[REGIONS[r] * (1 + (day.month / 10 if r == "Süd" else 0))
for r in REGIONS])[0]
product = rng.choice(list(PRODUCTS))
quantity = rng.choices([1, 2, 3, 5, 10], weights=[60, 20, 10, 6, 4])[0]
price = PRODUCTS[product]
value = price * quantity
discount = 10 if value >= 50000 else 0
revenue = value * (100 - discount) / 100 / 100
writer.writerow([f"B-2026-{n + 1:05d}", day.isoformat(), region, rng.choice(CHANNELS), product, quantity,
f"{price / 100:.2f}", discount, f"{revenue:.2f}", "ja" if rng.random() < 0.04 else "nein"])
return out.getvalue().encode()
DEMO = Demo(key="bestellservice", name=NAME, description=DESCRIPTION, examples=EXAMPLES, repo="bestellservice",
commits=COMMITS, branch=BRANCH, upload=("bestellungen_2026.csv", orders_csv),
folder="Projektunterlagen", documents=DOCUMENTS, extra=shop_db.setup)