zum Blättern · Enter öffnet · Esc schließt

Kontakt aufnehmen.

Projekte

Akkubohrer auf Lager: erklärbare Preisempfehlung in Go.

Demo6. September 2026

Bereich
Einzelhandel
Umsetzung
4 Stunden
Kompetenz
Data Engineering · Webentwicklung
Stack
Go · PostgreSQL · Docker · Git

Ergebnis

  • CSV-Import ohne Duplikate: ein Verkaufssatz je Laden, Artikel und Tag
  • DRILL-18V: Prognose 9,3 Stück/Tag, Empfehlung 12,60 € statt 12,00 €
  • Reason-Codes in derselben Antwort, nicht als Nachsatz in einem Ticket
  • CI mit PostgreSQL 16, go test, gofmt und Docker-Build

In vier Sätzen: In Filiale MANNHEIM-01 liegen rund 1.000 Akkubohrer DRILL-18V, und die Verkäufe der letzten Wochen gehen zurück. Die naheliegende Idee ist: Preis runter, Bestand abbauen. Ich habe dafür einen kleinen Go-Dienst mit PostgreSQL gebaut: CSV-Import, Nachfrage-Schätzung aus 7- und 28-Tage-Mitteln, dann eine Preisempfehlung mit Begründung. Am Bohrer gewinnt trotzdem nicht der Abverkauf, sondern die erwartete Tagesmarge: 12,60 € statt 12,00 €. In derselben Antwort stehen HIGH_STOCK_COVER und NEGATIVE_DEMAND_TREND.

Das ist bewusst eine Demo, kein Kundenprojekt und kein kaufmännisches Preissystem. Alle Läden, Artikel und Verkäufe sind synthetisch und reproduzierbar. Die Regeln sind deterministisch, ohne trainiertes Modell. Code: demand-pricing-go.

Die Frage in Filiale MANNHEIM-01

Ein Preisanalyst in einer Baumarkt-Kette braucht keine weitere Prognose-Folie. Er braucht eine Antwort auf eine enge Frage: Was tun wir mit diesem Artikel in diesem Laden, und warum?

Die Demo spielt das mit fünf Filialen, fünfzig Artikeln und 120 Tagen Tagesumsatz durch. Der Datensatz ist absichtlich so gebaut, dass verschiedene Alltagslagen vorkommen: volles Lager, knappes Lager, laufende Aktion, dünne Historie. Der Bohrer ist der Leitfaden, weil er die Spannung sichtbar macht.

Am 30. April 2025 (letzter Tag der Zeitreihe) sieht DRILL-18V in MANNHEIM-01 so aus:

Signal Wert
Aktueller Stückpreis 12,00 €
Einkauf / Untergrenze / Obergrenze 7,20 € / 9,60 € / 14,40 €
Bestand 1.000 Stück
Prognose 9,3 Stück/Tag
Trend (7 gegen 28 Tage) -11,1 %
Reichweite rund 108 Tage
Konfidenz 1,0 (28 Beobachtungen ohne Aktion)

Wer nur Bestand und Trend liest, senkt den Preis. Die Demo tut das nicht blind. Sie prüft fünf Kandidaten um den aktuellen Preis (-10 %, -5 %, halten, +3 %, +5 %), klemmt jeden an die erlaubte Spanne und schätzt für jeden die Tagesmarge:

geschätzte Menge × (Kandidatenpreis - Einkauf).

Elastizität ist hier -1,0: zehn Prozent billiger heißt grob zehn Prozent mehr Stück. Mehr Stück bei kleinerer Spanne kann trotzdem weniger Geld sein.

Kandidat Preis grob Stück/Tag erwartete Tagesmarge
-10 % 10,80 € 10,3 37,20 €
-5 % 11,40 € 9,8 41,12 €
halten 12,00 € 9,3 44,64 €
+3 % 12,36 € 9,0 46,59 €
+5 % 12,60 € 8,9 47,83 €

Zehn Prozent Rabatt verkauft mehr und verdient weniger. Der Bestand bleibt in den Reason-Codes stehen, damit ein Mensch die Spannung sieht: Lager voll, Nachfrage weich, Marge trotzdem besser bei 12,60 €. Die Regel «viel Lager und fallender Trend → billiger» gibt den günstigeren Kandidaten nur einen kleinen Zuschlag auf die Punktzahl. Gegen 47,83 € gegen 44,64 € reicht das nicht.

Genau das soll «erklärbar» heißen. Nicht: die Software hat recht. Sondern: man kann der Empfehlung in den Mund schauen.

Fünf Szenarien im selben Datensatz

Dieselben Regeln, andere Artikel. Die Zahlen stammen aus dem Generator im Repository, jeweils für MANNHEIM-01 am letzten Tag mit Umsatz.

Artikel Lage Status Empfehlung
DRILL-18V 1.000 Stück, Trend -11 % recommended 12,60 €
SAW-CORDLESS 20 Stück, Prognose ~28/Tag recommended 15,75 €
PAINT-PRO laufende Aktion, letzter Preis schon 15,30 € promotion_active bleibt 15,30 €
TILE-CUTTER mittlerer Bestand, Trend leicht plus recommended 22,05 €
LADDER-ALU nur 10 Verkaufstage low_confidence bleibt 24,00 €

Die Säge hat fast leeres Fach (Reichweite unter einem Tag). Die «teurer weil knapp»-Regel zündet trotzdem nicht: dafür müsste der Trend über +10 % liegen, hier sind es rund +4 %. Wieder gewinnt die Marge, wieder steht das in der Antwort. Die Farbe bleibt unangetastet, weil eine Aktion läuft. Die Leiter bleibt stehen, weil 10 Beobachtungen unter der Schwelle von 14 liegen.

Die Spezifikation hatte für den Bohrer «wahrscheinlich günstiger» notiert und für den Plattenschneider eine Margenuntergrenze. Der laufende Code widerspricht dem ersten Punkt, und die Untergrenze greift im Seed so nicht: der billigste Kandidat liegt dort noch über Einkauf plus Mindestmarge. Die Untergrenze selbst ist in der Produkttabelle und in einem Unit-Test, der einen engeren Fall konstruiert. Ich lasse die Differenz stehen. Ein Demo, das der eigenen Skizze immer recht gibt, wäre weniger nützlich als eines, das die Skizze an den Zahlen misst.

CSV, Upsert, PostgreSQL

Vom Tagesumsatz zur begründeten Empfehlung CSV Laden, SKU, Datum Menge, Preis, Aktion Ingest Zeilen prüfen Upsert je Charge PostgreSQL UNIQUE je Laden, Artikel, Tag Prognose 0,6 × 7 Tage 0,4 × 28 Tage Empfehlung fünf Preise JSON + Codes REST daneben: Stores, Produkte, Demand, Recommendation, Health, Metrics Docker Compose startet Postgres, Migration, Seed. Der zweite CSV-Lauf überschreibt, er verdoppelt nicht.
Der Weg ist absichtlich kurz: eine Datei, eine Tabelle für den Tagessatz, dann Rechnung und API. Kein Bus, kein Warehouse, kein Frontend-Framework.

Verkäufe kommen als schmale CSV: store_code, sku, sale_date, units_sold, unit_price, promotion_code. Eine kaputte Zeile wird mit Nummer und Grund gemeldet. Der Rest der Datei bleibt gültig. Der Import läuft in einer Transaktion je Charge.

In PostgreSQL darf es für Laden, Artikel und Tag nur einen Satz geben. Beim zweiten Lauf derselben Datei gewinnt der neue Inhalt, es entsteht keine zweite Wahrheit:

UNIQUE (store_id, product_id, sale_date);

INSERT INTO daily_sales (
    store_id, product_id, sale_date, units_sold, revenue, unit_price, promotion_id
) VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (store_id, product_id, sale_date) DO UPDATE SET
    units_sold   = EXCLUDED.units_sold,
    revenue      = EXCLUDED.revenue,
    unit_price   = EXCLUDED.unit_price,
    promotion_id = EXCLUDED.promotion_id;

Geld liegt als NUMERIC(12, 2) in der Datenbank, nicht als Gleitkomma. Preise, Einkauf und Marge sollen sich später noch zu Cent addieren. Go rechnet die Preislogik mit shopspring/decimal. Die übrigen Checks sitzen in der Migration: leere SKUs, max_price >= min_price, nicht-negative Bestände, Aktionsfenster mit ends_on >= starts_on. Die Datenbank ist hier nicht nur Speicher. Sie lehnt unsinnige Zeilen ab, bevor der Dienst sie schönredet.

Kein ORM. Die SQL-Texte liegen in den Repository-Dateien, die HTTP-Handler sehen sie nicht. Drei Einstiege (cmd/api, cmd/ingest, cmd/seed) verdrahten dieselben inneren Pakete.

Prognose und die fünf Preis-Kandidaten

Die Nachfrage ist absichtlich kein Modell. Für ein Datum zählt der Dienst die letzten 28 Tage, lässt Aktionstage weg, sobald genug normale Tage da sind, und mischt zwei Mittel:

Prognose = 0,6 × Mittel_7d + 0,4 × Mittel_28d
Trend    = (Mittel_7d - Mittel_28d) / Mittel_28d
Konfidenz = min(1, gültige_Tage_ohne_Aktion / 28)

Sieben Tage ziehen stärker, 28 Tage halten gegen einen einzelnen merkwürdigen Montag. Wenig Historie macht die Zahl nicht «genauer», sondern ehrlicher: die Konfidenz sinkt.

Dann die Preisregeln. Eine laufende Aktion überschreibt nichts. Unter 14 gültigen Beobachtungen bleibt der Preis stehen, Status low_confidence. Jeder Kandidat wird in min_price, max_price und Einkauf plus Mindestmarge gezwängt. Erst danach darf die erwartete Marge entscheiden.

if input.PromotionActive {
    return Recommendation{
        RecommendedPrice: currentPrice,
        Status:           "promotion_active",
        ReasonCodes:      []string{"PROMOTION_ACTIVE"},
        Explanation:      "an active promotion keeps the current price unchanged",
    }
}
if input.ObservationCount < 14 {
    return Recommendation{
        RecommendedPrice: currentPrice,
        Status:           "low_confidence",
        ReasonCodes:      []string{"LOW_CONFIDENCE"},
        Explanation:      "fewer than 14 valid observations are available for a price change",
    }
}
Zuerst Bremsen, dann Kandidaten Aktion aktiv? Preis bleibt, Status promotion_active Unter 14 Beobachtungen? Preis bleibt, Status low_confidence Spanne klemmen min, max, Einkauf + Marge Fünf Kandidaten beste erwartete Marge Lager und Trend nur als Hinweis Reichweite über 21 Tage und Trend unter -10 %: günstigere Kandidaten bekommen 2 % Zuschlag. Beim Bohrer reicht das gegen die Marge nicht. API am Bohrer, 30. April 2025 GET .../stores/MANNHEIM-01/products/DRILL-18V/recommendation 12,60 € · HIGH_STOCK_COVER · NEGATIVE_DEMAND_TREND Dieselbe Antwort, die ein Mensch im Ticket lesen könnte.
Die Reihenfolge ist die Aussage: erst darf die Software nichts kaputtmachen, dann darf sie eine Marge vergleichen.

Die API gibt die Domain-Struktur direkt als JSON zurück (Felder in Go-Schreibweise, Beträge als Decimal-Strings). Für den Bohrer sieht das so aus:

{
  "RecommendedPrice": "12.6",
  "ExpectedDailyUnits": 8.857142857142858,
  "ExpectedDailyMargin": "47.83",
  "Status": "recommended",
  "ReasonCodes": ["HIGH_STOCK_COVER", "NEGATIVE_DEMAND_TREND"],
  "Explanation": "candidate price 12.6 has the highest expected daily gross margin"
}

Das ist Absicht der Demo, keine polierte öffentliche API. Ein nächster Schritt wäre ein eigenes Response-Objekt mit stabilen snake_case-Namen. Hier sollte sichtbar bleiben, was der Optimizer wirklich berechnet, ohne eine zweite Übersetzungsschicht.

Daneben liegen GET /healthz, GET /readyz (Postgres erreichbar) und GET /metrics im Prometheus-Format. GitHub Actions startet PostgreSQL 16, prüft go mod tidy, gofmt, go vet, go test und baut das Docker-Image. Lokal reicht Docker Compose: Datenbank, Migration, fünf Läden, fünfzig Artikel.

Was das Demo bewusst weglässt

Es gibt keine Anmeldung, keine echte Händler-API, keine Queue, kein Kubernetes, kein Cloud-Deploy. Der Dashboard-Einstieg ist eine Schale, die Arbeit sitzt in API und SQL. Für einen Preisdienst in Produktion fehlten unter anderem: Rechte, Audit, Job-Steuerung für große Importe, Überwachung der Datenqualität, und vor allem eine vom Einkauf getragene Preispolitik. Die Elastizität im Seed ist eine gesetzte Annahme, keine gemessene Kurve.

Python wäre der kürzere Weg gewesen, wenn hier ein Modell trainiert werden sollte. Es wird keines trainiert. Go gibt einen Dienst mit wenigen Abhängigkeiten, explizitem SQL und einem CI-Lauf, den man in einem Nachmittag nachvollziehen kann. Genau diese Schicht wollte ich zeigen: Regeln, die ein Mensch prüfen kann, in einer Sprache und einer Datenbank, in denen solche Dienste oft landen.

Reproduktion: Repository klonen, docker compose -f deploy/docker-compose.yml up --build, dann Demand und Recommendation für MANNHEIM-01 / DRILL-18V lesen. Die Zahlen oben kommen aus dem Generator mit Start 1. Januar 2025 und festem Zufalls-Seed, nicht aus einer Screenshot-Session.

← Alle Projekte