Files
ApiCore/docs/superpowers/specs/2026-10-02-bestandsabgleich-design.md
2026-10-06 06:31:38 +02:00

243 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.