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