Files
ApiCore/docs/superpowers/specs/2026-10-02-bestandsabgleich-design.md
T
Nick SzaunigandClaude Opus 5.5 85f05dc541 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]>
2026-10-02 05:51:58 +02:00

13 KiB
Raw Blame History

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).