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) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-02 05:51:58 +02:00
1 parent e231fb95dc
commit 85f05dc541
1 file changed
+230
@@ -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).