Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
243 lines
15 KiB
Markdown
243 lines
15 KiB
Markdown
# 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.
|