feat(stock): keep shop stock in sync with ApiCore, write sales back
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m40s

ApiCore now owns the remaining stock per batch; the shop sells from it
and reports every paid sale and every return back.

- Outbox apicore_stock_events, written in the same transaction as the
  status change: paid -> SALE, cancelled-after-payment / refunded return
  -> RETURN onto the batches of the sale, reopened paid order -> SALE.
  Whether a return is due is decided by the open sales in the outbox,
  so unpaid orders never book and nothing is booked twice.
- Delivery right after the response (next/server after()) plus CronJob
  wabenhain-apicore-stock every 10 minutes with 1/5/15/60 min backoff;
  ApiCore deduplicates by event id. Rejected bookings stop and show up
  on the dashboard.
- Reconcile: shop stock = ApiCore remaining - unconfirmed bookings -
  reservations of unpaid orders, in a serializable transaction like the
  checkout. Triggered after bookings, by the signed ApiCore webhook
  stock.changed (ids only, numbers are fetched) and by the sync button.
- Manufactured goods (candles, propolis products, ...): one shop product
  per ApiCore article via Product.apicoreProductId, drafts created by the
  sync, existing products linkable in the product form; new category
  SONSTIGES. Stock field is read-only for linked products.
- OrderItem.apicoreAllocations keeps which lots were sold (recalls).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-05 21:16:54 +02:00
1 parent 641afa1ae4
commit 93b589ff04
39 files changed
+1400 -37

No files matched your search

+64
View File
@@ -212,6 +212,7 @@ enum ProductCategory {
HONIG
KERZEN
PROPOLIS
SONSTIGES
}
model Product {
@@ -235,6 +236,12 @@ model Product {
/// bleiben ("waldhonig-2026"), darf sich also nicht nach einer fremden ID
/// richten.
apicoreBottlingId String? @unique
/// ApiCore-ID des ARTIKELS fuer hergestellte Waren (Kerzen, Propolis-
/// Produkte, ...): deren Bestand liegt in ApiCore je Herstellungscharge und
/// wird als Summe gefuehrt, ein Shop-Produkt je Artikel. Honig nutzt
/// stattdessen apicoreBottlingId. Hoechstens eines von beiden ist gesetzt;
/// ist eines gesetzt, kommt der Bestand aus ApiCore (lib/apicore-stock).
apicoreProductId String? @unique
/// Nur fuer Honig-Produkte gesetzt (Kerzen/Propolis sind keiner Ernte/Charge zugeordnet)
harvestId String?
batchId String?
@@ -280,6 +287,7 @@ model Product {
inventory Inventory?
orderItems OrderItem[]
reviews Review[]
apicoreStockEvents ApiCoreStockEvent[]
wishlistItems WishlistItem[]
stockNotifications StockNotification[]
@@ -525,9 +533,16 @@ model OrderItem {
/// feststellbar ist, welcher Satz gegolten haette.
vatRatePercent Int?
/// Aus welchen ApiCore-Chargen die verkaufte Ware stammt, wie ApiCore sie
/// beim Verbuchen zugeordnet hat: [{ lotNumber, quantity }]. Fuer
/// Rueckverfolgung und Rueckruf; null, solange der Verkauf noch nicht
/// bestaetigt ist oder das Produkt nicht mit ApiCore verknuepft ist.
apicoreAllocations Json?
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
batch Batch? @relation(fields: [batchId], references: [id], onDelete: Restrict)
apicoreStockEvents ApiCoreStockEvent[]
/// Wird bei jedem Laden der Positionen einer Bestellung (Konto, Admin,
/// Rechnung) ueber die order.items-Relation abgefragt.
@@ -551,6 +566,55 @@ model OrderItem {
@@map("order_items")
}
// ---------------------------------------------------------------------------
// Bestandsabgleich mit ApiCore
// ---------------------------------------------------------------------------
enum ApiCoreStockEventKind {
SALE
RETURN
}
/// Warteschlange der Bestandsbuchungen an ApiCore (lib/apicore-stock).
///
/// Geschrieben in DERSELBEN Transaktion wie die ausloesende Statusaenderung
/// (bezahlt, storniert, erstattet), gesendet danach — sofort und per
/// CronJob nachgereicht. So geht keine Buchung verloren, wenn ApiCore gerade
/// nicht erreichbar ist. Die id geht als externalId an ApiCore; ApiCore bucht
/// jede id nur einmal, Wiederholungen sind also gefahrlos.
model ApiCoreStockEvent {
id String @id @default(cuid())
kind ApiCoreStockEventKind
orderItemId String
/// Wabenhain-Produkt, dessen Shop-Bestand von dieser Buchung abhaengt.
productId String
/// Ziel in ApiCore: eine Abfuellung (Honig) ODER ein Artikel (FIFO ueber
/// dessen Herstellungschargen).
apicoreBottlingId String?
apicoreProductId String?
quantity Int
/// Bei RETURN: der Verkauf, der zurueckgebucht wird.
reversesEventId String?
createdAt DateTime @default(now())
/// Von ApiCore bestaetigt (neu gebucht oder schon bekannt).
sentAt DateTime?
/// Von ApiCore abgelehnt — wird nicht wiederholt, das Dashboard zeigt es.
rejectedAt DateTime?
attempts Int @default(0)
lastError String?
nextAttemptAt DateTime @default(now())
orderItem OrderItem @relation(fields: [orderItemId], references: [id], onDelete: Restrict)
product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
reverses ApiCoreStockEvent? @relation("StockEventReversal", fields: [reversesEventId], references: [id], onDelete: Restrict)
reversedBy ApiCoreStockEvent[] @relation("StockEventReversal")
@@index([sentAt, rejectedAt, nextAttemptAt])
@@index([orderItemId])
@@index([productId])
@@map("apicore_stock_events")
}
enum PaymentStatus {
PENDING
SUCCEEDED