From 85f05dc541e96a149d83aff7b2d09a7bac415fee Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Fri, 2 Oct 2026 05:51:58 +0200 Subject: [PATCH] docs(spec): stock sync between ApiCore and the Wabenhain shop One remaining quantity per batch, kept in ApiCore: movement ledger for bottlings and new production batches, shop write-back of paid sales and returns, withdrawals in ApiCore reflected in the shop via webhook plus a 10-minute reconcile. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../2026-10-02-bestandsabgleich-design.md | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-02-bestandsabgleich-design.md diff --git a/docs/superpowers/specs/2026-10-02-bestandsabgleich-design.md b/docs/superpowers/specs/2026-10-02-bestandsabgleich-design.md new file mode 100644 index 0000000..5808c39 --- /dev/null +++ b/docs/superpowers/specs/2026-10-02-bestandsabgleich-design.md @@ -0,0 +1,230 @@ +# Bestandsabgleich ApiCore ↔ Wabenhain — Design + +Stand: 2026-10-02 · Betrifft: ApiCore (Backend + Frontend) und Wabenhain (Webshop) + +## 1. Ziel + +Es gibt **eine** Restmenge je Charge, und sie wird in ApiCore geführt. ApiCore und der Webshop zeigen jederzeit dieselbe Zahl: + +- Ein bezahlter Webshop-Verkauf mindert den Rest in ApiCore. +- Storno nach Zahlung und erstattete Retoure buchen zurück. +- Eine Entnahme in ApiCore (Hofladen, Eigenbedarf, Bruch …) mindert sofort auch den Webshop-Bestand. +- ApiCore unterscheidet sichtbar zwischen abgefüllter bzw. hergestellter Menge und Rest. + +Das gilt für alle Waren: Honig sowie Kerzen, Propolis-Produkte und andere Waren. + +### Entscheidungen + +| Frage | Entscheidung | +|---|---| +| Wann gilt eine Bestellung als verkauft? | Bei Zahlung (Status `BEZAHLT`). Unbezahlte Reservierungen erreichen ApiCore nie. | +| Woraus verkauft der Shop? | Aus dem gesamten Restbestand, nicht aus einem Kontingent. | +| Weg der Daten | Ereignisse in beide Richtungen + vollständiger Abgleich alle 10 Minuten (Variante A). | +| Honig im Shop | Ein Shop-Produkt je Abfüllung (Jahrgang), wie bisher. | +| Andere Waren im Shop | Ein Shop-Produkt je Artikel; Bestand = Summe aller Chargen; Abbuchung älteste Charge zuerst (FIFO). | + +### Nicht Teil dieses Designs (eigene Specs, in dieser Reihenfolge) + +1. **Rohstoffe:** Wachs und Propolis (Ernteform) mit eigenen Chargen und Beständen erfassen. +2. **Herstellung:** Ein Herstellungslauf verbraucht Rohstoff-Chargen und erzeugt eine Herstellungscharge (z. B. Wachs → Kerzen, Propolis → Tinktur/Salbe). + +Dieses Design lässt dafür Platz: Die Herstellungscharge (Abschnitt 2.2) ist das spätere Ziel eines Herstellungslaufs; eine Tabelle "verbrauchte Rohstoffe" kann ohne Umbau daran hängen. + +## 2. ApiCore + +### 2.1 Artikelart + +Neue Pflichtfelder am Artikel (`Product`): + +- `kind`: `BOTTLED_HONEY` (Bestand je Abfüllung) oder `MANUFACTURED` (Bestand je Herstellungscharge). +- `shopCategory`: `HONIG`, `KERZEN`, `PROPOLIS`, `SONSTIGES` — Vorgabe für die Kategorie des Shop-Produkts. + +Migration: bestehende Artikel bekommen `BOTTLED_HONEY` / `HONIG`. Ein Artikel mit Abfüllungen kann nicht auf `MANUFACTURED` umgestellt werden und umgekehrt (sonst wäre unklar, wo der Bestand liegt). + +### 2.2 Herstellungscharge (neu) + +`ProductionBatch` — Gegenstück zur Abfüllung für `MANUFACTURED`-Artikel: + +| Feld | Bedeutung | +|---|---| +| `productId` | Artikel (muss `MANUFACTURED` sein) | +| `lotNumber` | Chargennummer, eindeutig | +| `date` | Herstelldatum | +| `quantity` | hergestellte Stückzahl (Ganzzahl > 0) | +| `bestBeforeDate` | optional (z. B. Propolis-Tinktur) | +| `notes` | optional, intern | + +Seiten im Frontend: Liste, Anlegen, Detail — aufgebaut wie die Abfüllungs-Seiten. + +### 2.3 Bewegungsbuch + +`BatchMovement` — jede Bestandsänderung einer Charge, nie gelöscht: + +| Feld | Bedeutung | +|---|---| +| `bottlingBatchId` / `productionBatchId` | genau eines von beiden gesetzt (CHECK-Constraint) | +| `type` | `SHOP_SALE`, `SHOP_RETURN`, `WITHDRAWAL`, `CORRECTION` | +| `quantity` | Ganzzahl mit Vorzeichen: minus = raus, plus = rein | +| `reason` | bei `WITHDRAWAL`: `HOFLADEN`, `EIGENBEDARF`, `BRUCH`, `PROBE`, `SONSTIGES` | +| `notes` | optional | +| `shopRequestId` | gesetzt bei Buchungen aus dem Shop (siehe 2.4) | +| `userId` / `apiKeyId` | wer gebucht hat | +| `createdAt` | Zeitpunkt | + +**Rest einer Charge** = Ausgangsmenge (`jarCount` bzw. `quantity`) + Summe ihrer Bewegungen. Der Rest wird immer berechnet, nie gespeichert — er kann nicht auseinanderlaufen. Eine nachträgliche Korrektur von `jarCount`/`quantity` wirkt sofort auf den Rest. + +Fehlbuchungen werden durch Gegenbuchung korrigiert, nicht durch Löschen. Eine Charge mit Bewegungen kann nicht gelöscht werden (409, "Bewegungen vorhanden"). + +### 2.4 Shop-Buchungen und Idempotenz + +`ShopStockRequest` — eine Buchungsanfrage des Shops: + +| Feld | Bedeutung | +|---|---| +| `externalId` | eindeutig, vom Shop vergeben (ID seiner Warteschlangen-Zeile) | +| `kind` | `SALE` oder `RETURN` | +| `bottlingBatchId` / `productId` | Ziel: eine Abfüllung (Honig) oder ein Artikel (`MANUFACTURED`) | +| `quantity` | Stückzahl, immer positiv | +| `reversesExternalId` | bei `RETURN`: die `externalId` des Verkaufs, der zurückgebucht wird | + +Eine Anfrage erzeugt eine oder mehrere `BatchMovement`-Zeilen: + +- **Honig-Verkauf:** eine Bewegung `SHOP_SALE` auf der Abfüllung. +- **Artikel-Verkauf (FIFO):** Verteilung auf die Chargen des Artikels mit Rest > 0, sortiert nach MHD aufsteigend (ohne MHD zuletzt), dann Herstelldatum aufsteigend. Reicht der Gesamtrest nicht, geht der Überhang auf die neueste Charge (Rest wird negativ, siehe 2.7). +- **Rückbuchung:** spiegelt genau die Bewegungen des Verkaufs `reversesExternalId` (gleiche Chargen, gleiche Mengen; bei Teilmenge in umgekehrter FIFO-Reihenfolge). + +Kommt eine `externalId` erneut, wird nichts gebucht; die Antwort meldet `duplicate` mit dem Ergebnis der ersten Buchung. + +### 2.5 API + +Alle unter `/api/v1/shop-sync`, Zugang per API-Schlüssel. + +| Endpunkt | Recht | Inhalt | +|---|---|---| +| `GET /shop-sync` | `shop-sync:read` | wie bisher; zusätzlich je Abfüllung `remainingCount`, neue Liste `productionBatches` (id, productId, lotNumber, date, quantity, bestBeforeDate, remainingCount), je Artikel `kind`, `shopCategory`, `remainingCount` (nur `MANUFACTURED`) | +| `GET /shop-sync/stock` | `shop-sync:read` | nur Bestände: `bottlings: [{id, remainingCount}]`, `products: [{id, remainingCount}]` (nur `MANUFACTURED`) | +| `POST /shop-sync/movements` | `shop-sync:write` | Body `{ requests: ShopStockRequest[] }` (max. 100); Antwort je Anfrage `{ externalId, status: "created" \| "duplicate" \| "rejected", error?, allocations: [{ batchType, batchId, lotNumber, quantity }], remainingCount }` | + +Neue Felder halten `SHOP_SYNC_CONTRACT_VERSION = 1` (Verbraucher ignorieren Unbekanntes). Ungültige Anfragen (unbekanntes Ziel, Ziel passt nicht zu `kind`, `reversesExternalId` unbekannt) bekommen `rejected` mit Fehlertext; die übrigen Anfragen derselben Sendung werden trotzdem verarbeitet. + +`shop-sync:write` kommt in `API_KEY_SCOPES` (bisher nur Lesen). + +### 2.6 Webhook + +Neues Ereignis `stock.changed` nach jeder Bewegung (aus UI oder Shop). Nutzlast nur Bezeichner: `{ bottlingBatchIds: [], productIds: [] }`. Der Empfänger holt die Zahlen selbst über `GET /shop-sync/stock` — ein Webhook setzt nie Bestände. + +### 2.7 Oberfläche + +**Abfüllung (Detail)** und **Herstellungscharge (Detail):** + +- Kopfzeile: **Abgefüllt/Hergestellt · Webshop verkauft · Entnommen · Rest**. Rest < 0 rot mit "Prüfen". +- Knopf **Entnehmen**: Anzahl (1 … Rest), Grund, Notiz. Mehr als der Rest wird abgelehnt. +- Knopf **Inventur korrigieren**: gezählten Bestand eingeben → Bewegung `CORRECTION` mit der Differenz; Notiz Pflicht. +- Bewegungsliste: Datum, Art, Anzahl, Grund/Notiz, wer. + +**Listen:** Spalte "Rest" in Abfüllungs- und Chargenliste. **Artikel-Detail (`MANUFACTURED`):** Summe Rest und Liste der Chargen. + +### 2.8 Rechte + +- `bottling:withdraw` — Entnehmen und Inventur bei Abfüllungen. +- `production-batch:read`, `:create`, `:update`, `:delete`, `:withdraw` — Herstellungschargen. +- Bestehende Rollen: wer heute `bottling:update` hat, bekommt die neuen Rechte per Migration. + +## 3. Wabenhain + +### 3.1 Verknüpfung + +- Honig: `Product.apicoreBottlingId` (besteht). +- Andere Waren: neu `Product.apicoreProductId` (eindeutig). +- Neue Kategorie `SONSTIGES` in `ProductCategory`. +- Produkt-Formular: Auswahl **"ApiCore-Artikel verknüpfen"** (nur `MANUFACTURED`-Artikel, noch nicht verknüpft). Damit werden bestehende Kerzen/Propolis-Produkte angebunden — vor dem ersten Sync, sonst legt der Sync zusätzliche Entwürfe an. +- Sync: legt für unverknüpfte `MANUFACTURED`-Artikel deaktivierte Entwürfe an (Preis 0, wie beim Honig). +- Produkte ohne Verknüpfung behalten den manuellen Bestand; das Dashboard zeigt "N Produkte noch nicht mit ApiCore verknüpft". + +### 3.2 Bestand + +Für verknüpfte Produkte wird `Inventory.quantity` nicht mehr von Hand gepflegt, sondern berechnet: + +``` +Shop-Bestand = ApiCore-Rest + + Summe der noch nicht bestätigten Warteschlangen-Buchungen + (Verkauf negativ, Rückbuchung positiv) + − in unbezahlten Bestellungen (Status NEU) reservierte Menge +``` + +Beispiel: ApiCore-Rest 100, 2 Gläser bezahlt aber noch nicht bestätigt, 1 Glas in einer unbezahlten Bestellung → 97. Nach Bestätigung: ApiCore-Rest 98, nichts offen → weiterhin 97. + +Der Abgleich schreibt diesen Wert in einer Transaktion mit Zeilensperre auf `Inventory` — derselbe Schutz, den der Checkout nutzt. Kein Überverkauf bei gleichzeitigem Checkout. Ein negativer Wert wird als 0 geschrieben. Das Bestandsfeld im Admin ist für verknüpfte Produkte gesperrt ("Entnahmen und Inventur in ApiCore"). + +`Batch.glassCount` und `Batch.remainingGlasses` (Honig) zeigen die ApiCore-Werte. + +### 3.3 Warteschlange + +`ApiCoreStockEvent` — geschrieben in **derselben Transaktion** wie die auslösende Statusänderung: + +| Feld | Bedeutung | +|---|---| +| `id` | wird als `externalId` gesendet | +| `kind` | `SALE` / `RETURN` | +| `orderItemId` | Bestellposition | +| `apicoreBottlingId` / `apicoreProductId` | Ziel | +| `quantity` | positiv | +| `reversesEventId` | bei `RETURN`: der Verkauf, der zurückgebucht wird | +| `sentAt`, `attempts`, `lastError`, `nextAttemptAt` | Zustellung | + +Auslöser: + +| Ereignis im Shop | Buchung | +|---|---| +| Bestellung bezahlt | `SALE` je Position | +| Storno einer bezahlten Bestellung | `RETURN` je Position | +| Retoure erstattet | `RETURN` je Position | +| Stornierte Bestellung wieder geöffnet | `SALE` je Position (neue Zeilen) | +| Bezahlte Bestellung gelöscht | `RETURN` je Position | +| Unbezahlte Bestellung verfällt / wird storniert | keine | + +Alle Stellen, die heute `reserveOrderItems`/`restockOrderItems` aufrufen, gehen künftig über eine gemeinsame Funktion, die je nach Zahlungsstand auch die Warteschlangen-Zeilen schreibt — damit kein Pfad vergessen wird. + +Nach Bestätigung durch ApiCore speichert Wabenhain die Chargen-Zuordnung an der Bestellposition (`OrderItem.apicoreAllocations`, JSON: Chargennummer + Menge) — für Rückverfolgung und Rückruf. + +### 3.4 Übertragung + +- **Sofort:** nach dem Commit, nicht blockierend (`after()` aus `next/server`). +- **Nachreichen + Vollabgleich:** CronJob `wabenhain-apicore-stock` alle 10 Minuten (`scripts/apicore-stock-sync.ts`, Muster wie `release-stale-orders`): offene Buchungen senden (Backoff 1, 5, 15, 60 Minuten, danach stündlich), dann alle verknüpften Bestände abgleichen. +- **Webhook-Empfang:** `POST /api/apicore/webhook`. Prüft HMAC-Signatur mit `APICORE_WEBHOOK_SECRET` und Zeitstempel (max. 5 Minuten alt), sonst 401. Gleicht danach nur die genannten Ziele ab (Zahlen über `GET /shop-sync/stock`). Rate-limitiert. +- **Sync-Knopf im Admin:** ruft zusätzlich denselben Abgleich auf. +- **Dashboard-Warnung:** Buchung länger als 15 Minuten unbestätigt → Hinweis mit letztem Fehler; 403 von ApiCore → "ApiCore-Schlüssel braucht Schreibrecht". + +## 4. Fehlerfälle + +| Fall | Verhalten | +|---|---| +| ApiCore nicht erreichbar | Shop verkauft aus letztem Stand; Buchungen warten; Nachreichen per CronJob; Warnung ab 15 Minuten. | +| Doppelte Zustellung | `externalId` eindeutig → `duplicate`, keine zweite Buchung. | +| Verkauf > Rest | Wird gebucht (Ware ist verkauft); Rest negativ, rot "Prüfen". | +| Webhook gefälscht/alt | 401; selbst gültige Webhooks setzen keine Zahlen. | +| Webhook verloren | Vollabgleich spätestens nach 10 Minuten. | +| Abgleich parallel zum Checkout | Zeilensperre, kein Überverkauf. | +| Retoure Lagerware | Rückbuchung auf exakt die Chargen des Verkaufs. | +| Charge mit Bewegungen löschen | 409. | +| Schlüssel ohne Schreibrecht | 403; Dashboard-Hinweis. | +| Buchung von ApiCore abgelehnt (`rejected`) | Kein automatischer Neuversuch; Dashboard zeigt Bestellung und Fehlertext. | + +## 5. Tests + +- **ApiCore Unit:** Restberechnung; FIFO inkl. Überhang; Duplikat; Entnahme > Rest abgelehnt; Rückbuchung auf richtige Chargen; Artikelart-Wechsel gesperrt. +- **ApiCore e2e:** `POST /shop-sync/movements` mit/ohne `shop-sync:write`; Webhook wird ausgelöst; Löschen mit Bewegungen → 409; `GET /shop-sync/stock`. +- **Wabenhain Unit:** Bestandsformel; Auslöser-Tabelle (alle sechs Zeilen); Signaturprüfung inkl. Zeitstempel. +- **Wabenhain Integration (lokale DB):** Checkout und Abgleich gleichzeitig ohne Überverkauf; Warteschlange übersteht Ausfall und sendet nach. +- **Durchlauf lokal (beide Systeme):** kaufen → in ApiCore entnehmen → Retoure; beide Seiten zeigen dieselbe Zahl. + +## 6. Einrichtung nach dem Deploy + +1. ApiCore: dem Wabenhain-API-Schlüssel `shop-sync:write` geben. +2. ApiCore: Webhook `stock.changed` → `https://wabenhain.com/api/apicore/webhook`; Secret in Wabenhain als `APICORE_WEBHOOK_SECRET`. +3. Wabenhain: CronJob `wabenhain-apicore-stock` aktivieren. +4. Bestehende Kerzen/Propolis-Produkte in ApiCore anlegen (Artikel + Herstellungscharge mit aktuellem Bestand), dann in Wabenhain verknüpfen, dann Sync. + +## 7. Aufwand + +Etwa 3 Arbeitstage: ApiCore ~1,5 (Bewegungsbuch, Herstellungschargen, API, Oberfläche), Wabenhain ~1,5 (Warteschlange, Auslöser, Abgleich, Webhook, Verknüpfung).