Vorrat-Radar – Backend-Vertrag (B1–B6)
Stand 09.06.2026, StratoClaude. Liefert das Backend zum Vorrat-Redesign
(Spec: app/ios-native/reimplementierung/vorrat-redesign/05-synthese.md,
MacClaude-Revier). Die native App (C1–C6) verdrahtet gegen diese Endpoints.
Alle Endpoints additiv, hinter Auth (Session oder X-Internal-Token).
Datenmodell
inventory (Nutzer-Override + Kassensturz): band (Override),
confirmed_at, last_known_qty, last_known_at.
foods (AUTO-Cache, vom staple-refresh-Cron geschrieben, NIE von Hand):
is_staple_auto (1 = radar-Klasse), staple_median_days, staple_mad_days,
staple_freq_class (radar|mid_freq|NULL), staple_band (gecachtes
Auto-Band), staple_last_bought, staple_n, staple_dominant (food_id der
Gruppe), staple_refreshed_at.
product_groups / product_group_members (editierbar, #28): Kuratierung.
kind ∈ dedup (gleicher Artikel, mehrere food_ids → Historien zusammenzählen) ·
basic (markenagnostische Basics, EIN Rhythmus; subsumiert überlappende
dedup) · staple_exclude (nie Staple). Seed: app/seed_product_groups.py aus
vorrat-review-decisions.json. Der spätere Power-User-Editor (C6) CRUDt nur noch.
Effektives Band
band = inventory.band (Override) ?? foods.staple_band (Auto). Enum:
wohl_da · geht_zur_neige · wohl_leer · unsicher (vorhergesagt-leer UND
keine Bestätigung/inv_log seit letztem Kauf → wir wissen es nicht). Vorhersage
(FreqRealist §A2) aus age = heute − last_bought gegen median ± mad.
Staple-Mathematik (backend/staples.py)
Pro kanonischer Gruppe: Kauf-Intervalle → Median, MAD-robust (Cap 3×Median),
CV_mad = mad/median. Recency = gleitendes 6-Monats-Fenster-Median mit Fallback
auf flachen Median (Exponential-Gewicht war bei kleinem N instabil — verworfen).
Klassifikation: N≥3, CV≤0.6, age ≤ 2·median+60, und
median ≤ 21 d→radar(is_staple_auto=1)22–60 d→mid_freq(leise Klasse, nicht im Radar)- sonst / excluded → keiner.
Hart erarbeitet (B0-Dry-Run gg. echte ~450 Bons): das Kriterium OHNE Häufigkeits-Obergrenze ergibt ~100 „Staples"; erst der
median≤21-Deckel trifft Dennis' Erwartung (kuratiert auf 28, Engine liefert 29 radar). Ohne ein-eindeutige EAN fragmentiert derselbe Artikel über mehrere food_ids → darum die dedup-/basic-Gruppen.
Endpoints
| Methode + Pfad | Liefert / tut | |
|---|---|---|
| B4 | GET /api/inventory |
+ band, freq_class (radar|mid_freq|none), is_staple, is_staple_manual, is_staple_auto (SILV-338; bei Gruppen vom Dominanten), confirmed_at, last_known_qty, last_known_at, fresh_status, group_id, group_kind, member_food_ids (#44, s.u.) |
| B1 | GET /api/inventory/radar |
{items:[{food_id,name,group,band,freq_class,median_interval_days,mad_days,n_purchases,last_bought,age_days,in_stock,confirmed_at,last_known_qty,last_known_at,suggestion_score}]}, nach Score sortiert |
| B2 | POST /api/inventory/{id}/confirm {qty?, band?} · POST /api/inventory/confirm-bulk {items:[{food_id,qty?,band?}]} |
Kassensturz: qty=0→Band wohl_leer & Bestand 0; qty>0→wohl_da + last_known_qty; ohne qty nur confirmed_at. band (wohl_da|geht_zur_neige|wohl_leer) setzt den Override direkt ohne qty-Umweg (Band-Pille als Steuerelement, A); invalid→400. Bulk transaktional. |
| B3 | GET /api/inventory/fresh |
{items} mit fresh_status (weg_muss→bald_weg→frisch sortiert), via Kategorie-Haltbarkeit (SHELF_DAYS, tax_type-Filter) |
| B5 | POST /api/inventory/staple-refresh |
Engine neu rechnen (intern; Cron 08:20 täglich nach Bon-Import 07:45). Idempotent. |
| SILV-338 | POST /api/foods/{id}/staple {on} · POST /api/inventory/group/{gid}/staple {on} |
Ehrlicher „Immer im Haus"-Schalter (s.u.) — serverseitig gekapselt, nicht über /pin+product-groups nachbauen. |
| B6 | POST /api/shopping/{week}/generate |
+ suggestions: Top-5 Radar-Staples (score≥0.4), die für die Woche noch nicht gelistet sind |
suggestion_score (FreqRealist §A5) = urgency × recency × confidence
(0..1): urgency 0/0.5/1.0 (da/neige/leer+unsicher) · recency 1.0/0.7/0.3
(<90/≤180/>180 d) · confidence min(N/5, 1).
fresh_status / band-Read sind datumsabhängig → alle Read-Endpoints nehmen
optional ?today=YYYY-MM-DD (Tests/Determinismus; Prod lässt weg → Berlin-Tag).
#44 – Vorrat-Collapse (gruppierte Artikel → EINE Zeile)
GET /api/inventory klappt Mitglieder einer basic/dedup-Gruppe zu einer
synthetischen Zeile zusammen (non_food/staple_exclude NICHT — reine Filter).
Aggregation serverseitig: qty=Summe der Mitglieds-Bestände · band/freq_class/
is_staple vom dominanten Staple-Mitglied (Override gewinnt, sonst verfügbarste
Member-Bänder, sonst wohl_da bei Bestand) · fresh_status=dringlichstes Mitglied ·
name=Title-Case-Label · id=repräsentativer food_id. Zusatzfelder:
group_id, group_kind (basic|dedup), member_food_ids:[…]. Einzelartikel:
group_id=null. Zeile erscheint, sobald ein Mitglied Bestand hat.
Aktionen auf Gruppen-Zeilen (Client schickt group_id, Server resolvt das
Mitglied — Anti-Drift): POST /api/inventory/consume + /adjust + /confirm-bulk
(items) nehmen optional group_id statt food_id; POST /api/inventory/group/{id}/confirm
für den Band-Pillen-Tap. Resolver: Abzug (consume / adjust delta<0) → Mitglied mit
Bestand, kleinste Menge zuerst (offene Packung aufbrauchen); confirm / band-set /
adjust≥0 → dominantes Staple-Mitglied, sonst meiste Menge. Antworten geben den
aufgelösten food_id zurück. food_id ODER group_id nötig, sonst 400.
SILV-338 – Ehrlicher „Immer im Haus"-Schalter
Drei Wahrheiten am Steckbrief, die der Client als Quellen-Icon nutzt:
is_staple (effektiv = is_staple_auto OR is_staple_manual), is_staple_manual
(Pin) und is_staple_auto (regelmäßig gekauft, von der Engine erkannt). Bei
Gruppen-Zeilen kommen alle drei vom Dominanten (kein Dominant → alle false).
Der Toggle kapselt die Semantik serverseitig (Anti-Drift — der Client darf das
NICHT aus /pin + product-groups zusammenstückeln):
| Methode + Pfad | on |
Wirkung |
|---|---|---|
POST /api/foods/{id}/staple |
true |
is_staple_manual=1; aus staple_exclude ENTFERNEN → Auto-Erkennung darf wieder greifen. |
false |
is_staple_manual=0; in staple_exclude AUFNEHMEN (Gruppe ggf. anlegen); zusätzlich is_staple_auto=0 SOFORT — sonst zeigt die Zeile bis zum nächsten staple-refresh-Cron weiter „regelmäßig gekauft" = Lüge. |
|
POST /api/inventory/group/{gid}/staple |
true/false |
Selbe Semantik auf ALLE Mitglieder der Gruppe (nicht nur das Dominante). |
Idempotent. 404 bei unbekannter food_id / leerer Gruppe. Einzel-Return
{ok, is_staple_manual, is_staple_auto} (effektiv nach der Op), Gruppe {ok}.
staple_exclude ist EINE geteilte kanonische Gruppe (load_groups sammelt über
ALLE solchen Gruppen → die Engine überspringt jeden Key, dessen dominantes
Mitglied excludiert ist). Abgrenzung: das alte POST /api/foods/{id}/pin setzt
nur is_staple_manual (bulk-Editor-Altpfad) und fasst staple_exclude/
is_staple_auto NICHT an — für die ehrliche Steuerung den /staple-Endpoint nehmen.
C6 – Power-User-Gruppen-Editor (CRUD auf product_groups)
Damit Dennis Sammelartikel/Dedup/Ausschlüsse selbst pflegt (ohne EAN entstehen
ständig neue Dubletten). Jeder Write triggert sofort staple-refresh und
liefert "recompute": true → die Bänder stimmen ohne Warten auf den Cron.
| Methode + Pfad | Tut |
|---|---|
GET /api/product-groups |
{groups:[{id,kind,label,members:[{food_id,name,n_purchases}]}]} |
POST /api/product-groups {kind,label,food_ids} |
→ {id, recompute:true} · kind ∉ Enum → 400 |
PATCH /api/product-groups/{id} {label?,add_food_ids?,remove_food_ids?} |
→ {ok,recompute:true} · fehlt → 404 |
DELETE /api/product-groups/{id} |
→ {ok,recompute:true} · fehlt → 404 |
GET /api/product-groups/merge-candidates |
{candidates:[…namens-gleich…], semantic:[…KI…]} (s.u.) |
POST /api/product-groups/dedup-scan |
stößt den KI-Scan an → {queued,job_id} (idempotent) |
POST /api/product-groups/merge-suggestions/{id}/dismiss |
Vorschlag verwerfen |
merge-candidates hat zwei Töpfe: candidates = namens-gleiche, noch nicht
gruppierte food_ids (deterministisch, nach Gesamt-Käufen sortiert; Paare, die
in food_pairs verdict='different' tragen — Dennis-Review ODER KI — fallen
raus, Bus #62: eine Kandidaten-Gruppe bleibt nur, solange darin mind. EIN Paar
NICHT als verschieden abgehakt ist → ein abgelehntes Paar kehrt nicht wieder) ·
semantic = [{id,label,reason,ids:[{food_id,name,n_purchases}]}] aus der
KI-Dedup-Stufe-2 (Bus #39 C): der merge_suggest-Job (gemini-3.1-flash-lite,
Haiku-Sub-Fallback) findet gleiche Artikel mit anderem Wortlaut
(Klopapier=Toilettenpapier, „Coca-Cola Zero Sugar"=„Coca-Cola Zero"), die der
Namensvergleich verfehlt. KEIN Auto-Merge: Bestätigen = im Editor eine echte
dedup-Gruppe aus den food_ids anlegen (POST /api/product-groups); der
Vorschlag wird dann automatisch abgeräumt (semantic auto-pruned, sobald die IDs
gruppiert sind). Verwerfen = dismiss. „🔄 neu scannen" = dedup-scan.
n_purchases = distinct Kauftage je food_id (gleiche Definition wie die Engine).
#20 – KI-Synonym-Vorschläge für Sammelartikel (nur basic-Gruppen): welche
Artikel passen thematisch in den Sammelartikel (Klopapier→Toilettenpapier-Varianten)?
POST /api/product-groups/{id}/suggest-members → {queued,job_id} (nur basic→400,
idempotent) queut den member_suggest-Job (gemini-3.1-flash-lite, Haiku-Fallback).
GET /api/product-groups/{id}/member-suggestions → {name_matches, ai}:
name_matches = deterministisch live (NUR Label-Token, Generik-Stoppwörter raus),
ai = [{id,food_id,name,reason}] aus dem Job (auto-pruned sobald Mitglied).
Übernehmen = PATCH add_food_ids; Verwerfen = POST .../member-suggestions/{id}/dismiss.
Modell ist konservativ (gleicher Sammelartikel, nicht bloss gleiche Warengruppe).
kind='non_food' (B, Dennis #6a): kuratierbare Non-Food-Gruppe. cook-from-Korb
+ B6-Radar-Vorschläge filtern Mitglieder raus; Radar-Items tragen non_food:bool.
Seed-Heuristik (seed_product_groups.py): is_food=0 · Gruppen Tierbedarf/Drogerie ·
Keywords (klopapier/reiniger/shampoo/…). Getränke bewusst NICHT (tax_type='B'
verworfen — Cola/Säfte sind nachkaufbar); Dennis schiebt sie bei Bedarf per Editor
selbst zu non_food.
Frisch-Haltbarkeit (SHELF_DAYS, Code-Konstante)
(best_days, max_days) je Gruppe; Default (7,21). Erscheint als Frischware
wenn best·0.7 < age < max·1.5 und Bestand vorhanden; ≤best→frisch,
≤max→bald_weg, sonst weg_muss. (Später wie product_groups editierbar, falls
Dennis es will.)
Frische in der Vorschlags-Schärfe (perish_urgency, SILV-266 E-1, F3=a)
Frische verstärkt die Schärfe auf EINER Rang-Achse (Dennis-Lock F3=a) — sie ist
kein zweites sichtbares Etikett. Der interne Sortier-Rang ist
rank = max(suggestion_score, perish_urgency) · overdue_factor — MAX, nicht Summe
(Summe doppelzählt → false precision). perish_urgency 0..1 gilt nur bei Bestand
(qty>0) und kommt aus fresh_status × effective_fresh_class (SILV-262):
| fresh_status | fast | short | long |
|---|---|---|---|
weg_muss |
1.0 | 1.0 | 0.9 |
bald_weg |
0.7 | 0.6 | 0.4 |
frisch/None |
0.0 | 0.0 | 0.0 |
So zieht ein verderblicher Nicht-Staple (median>21 d, gar kein Band) aus der
stummen Recall-Zone hoch. perish_urgency wird additiv im DTO mitgeliefert
(RadarSuggestion/CatalogItem), der Client zeigt es NICHT (nur Sortier-
Transparenz/Debug). Fehlerfälle (qty null, is_food=0, Gruppe unbekannt, Override
none) → 0.0, deterministisch (kein KI).