Zuletzt aktualisiert:

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. kinddedup (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

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>0wohl_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_mussbald_wegfrisch 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_factorMAX, 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).