# 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). ## 8. Nachtrag: Rohstoffe und Herstellung (umgesetzt) Die in Abschnitt 1 zurückgestellten Bausteine sind umgesetzt, ohne Umbau des Bewegungsbuchs: - **Rohstoff-Charge** (`RawMaterialLot`): Art (`BEESWAX`, `PROPOLIS`), Chargennummer, Gewinnungsdatum, Menge **in Gramm**, Herkunft optional (Stand, Ernte), Notiz. Rest wie bei allen Chargen aus dem Bewegungsbuch; Entnehmen und Inventur funktionieren gleich. - **Herstellung**: Beim Anlegen einer Herstellungscharge werden die verbrauchten Rohstoffe angegeben (`inputs`: Rohstoff-Charge + Gramm). Charge und Verbrauch entstehen in einer Transaktion; mehr als der Rest einer Rohstoff-Charge wird abgelehnt (400). Gebucht als neue Bewegungsart `CONSUMPTION`; `ProductionInput` hält die Zuordnung. - **Rückverfolgung** in beide Richtungen: Herstellungscharge → „Hergestellt aus", Rohstoff-Charge → „Verwendet in". Zusammen mit `OrderItem.apicoreAllocations` in Wabenhain reicht die Kette vom Wachs bis zur Bestellung. - **Löschen** einer irrtümlich angelegten Herstellungscharge (nur ohne eigene Bewegungen) gibt den Rohstoff als eigene Bewegung zurück. Rohstoff-Chargen mit Bewegungen sind nicht löschbar. - **Rechte**: `raw-material:read|create|update|delete|withdraw`. Rohstoff verbrauchen beim Anlegen einer Herstellungscharge verlangt zusätzlich `raw-material:withdraw`. - **Mengenkorrektur**: Die Menge einer Rohstoff- oder Herstellungscharge kann nicht unter das bereits Verbuchte gesenkt werden; Fehlmengen gehen über „Inventur korrigieren". - Rohstoffe werden nicht im Webshop verkauft; ihre Bewegungen lösen keinen `stock.changed`-Webhook aus.