Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
15 KiB
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)
- Rohstoffe: Wachs und Propolis (Ernteform) mit eigenen Chargen und Beständen erfassen.
- 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) oderMANUFACTURED(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_SALEauf 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
CORRECTIONmit 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:updatehat, bekommt die neuen Rechte per Migration.
3. Wabenhain
3.1 Verknüpfung
- Honig:
Product.apicoreBottlingId(besteht). - Andere Waren: neu
Product.apicoreProductId(eindeutig). - Neue Kategorie
SONSTIGESinProductCategory. - 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()ausnext/server). - Nachreichen + Vollabgleich: CronJob
wabenhain-apicore-stockalle 10 Minuten (scripts/apicore-stock-sync.ts, Muster wierelease-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 mitAPICORE_WEBHOOK_SECRETund Zeitstempel (max. 5 Minuten alt), sonst 401. Gleicht danach nur die genannten Ziele ab (Zahlen überGET /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/movementsmit/ohneshop-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
- ApiCore: dem Wabenhain-API-Schlüssel
shop-sync:writegeben. - ApiCore: Webhook
stock.changed→https://wabenhain.com/api/apicore/webhook; Secret in Wabenhain alsAPICORE_WEBHOOK_SECRET. - Wabenhain: CronJob
wabenhain-apicore-stockaktivieren. - 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 BewegungsartCONSUMPTION;ProductionInputhält die Zuordnung. - Rückverfolgung in beide Richtungen: Herstellungscharge → „Hergestellt aus", Rohstoff-Charge → „Verwendet in". Zusammen mit
OrderItem.apicoreAllocationsin 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ätzlichraw-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.