feat(stock): batch movement ledger, production batches and shop write-back API

One remaining quantity per batch, kept in ApiCore and always derived:
initial quantity (jarCount / produced quantity) plus the signed movements
in the new BatchMovement ledger, which is never deleted.

- Production batches for MANUFACTURED articles (candles, propolis
  products, ...); articles get a kind and a shop category.
- Withdrawals (farm shop, own use, breakage, sample) and inventory
  corrections on bottlings and production batches, never more than the
  remaining stock; every write locks the bottling or the article row.
- Shop bookings via POST /shop-sync/movements (new scope
  shop-sync:write): idempotent per externalId, articles allocated oldest
  best-before first, returns mirrored onto the batches of the sale,
  invalid entries rejected one by one.
- GET /shop-sync/stock and remainingCount in the shop feed; webhook
  stock.changed after every change (ids only, receivers fetch numbers).
- Bottlings and production batches with movements cannot be deleted.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-02 06:38:12 +02:00
1 parent 85f05dc541
commit 04f93e954a
28 files changed
+2445 -35

No files matched your search

+118 -1
View File
@@ -120,6 +120,8 @@ model User {
colonyMoves ColonyApiaryHistory[]
auditLogs AuditLog[]
bottlingBatches BottlingBatch[]
productionBatches ProductionBatch[]
batchMovements BatchMovement[]
notifications Notification[]
queenEvaluations QueenEvaluation[]
webhookSubscriptions WebhookSubscription[]
@@ -979,6 +981,22 @@ enum ProductStatus {
DISCONTINUED
}
// Wo der Bestand eines Artikels liegt: Honig je Abfuellung (BottlingBatch),
// alles andere (Kerzen, Propolis-Produkte, ...) je Herstellungscharge
// (ProductionBatch). Nicht umstellbar, sobald Chargen existieren.
enum ProductKind {
BOTTLED_HONEY
MANUFACTURED
}
// Vorgabe fuer die Kategorie des Produkts im Webshop (Wabenhain).
enum ShopCategory {
HONIG
KERZEN
PROPOLIS
SONSTIGES
}
model Product {
id String @id @default(cuid())
name String
@@ -988,10 +1006,13 @@ model Product {
weight Float?
salesUnit String?
status ProductStatus @default(ACTIVE)
kind ProductKind @default(BOTTLED_HONEY)
shopCategory ShopCategory @default(HONIG)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bottlingBatches BottlingBatch[]
bottlingBatches BottlingBatch[]
productionBatches ProductionBatch[]
@@index([status])
}
@@ -1046,10 +1067,106 @@ model BottlingBatch {
user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
movements BatchMovement[]
@@index([honeyBatchId])
@@index([productId])
}
// ---------------------------------------------------------------------------
// Herstellungschargen und Bestandsbewegungen (Bestandsabgleich mit dem Shop,
// docs/superpowers/specs/2026-10-02-bestandsabgleich-design.md)
// ---------------------------------------------------------------------------
// Gegenstueck zur Abfuellung fuer Artikel der Art MANUFACTURED (Kerzen,
// Propolis-Tinkturen, Salben, ...). Spaeter Ziel eines Herstellungslaufs, der
// Rohstoff-Chargen verbraucht.
model ProductionBatch {
id String @id @default(cuid())
productId String
product Product @relation(fields: [productId], references: [id])
lotNumber String @unique
date DateTime
// Hergestellte Stueckzahl - Ausgangsmenge fuer den Rest.
quantity Int
bestBeforeDate DateTime?
notes String?
userId String?
user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
movements BatchMovement[]
@@index([productId])
}
enum BatchMovementType {
SHOP_SALE
SHOP_RETURN
WITHDRAWAL
CORRECTION
}
enum WithdrawalReason {
HOFLADEN
EIGENBEDARF
BRUCH
PROBE
SONSTIGES
}
// Bewegungsbuch: jede Bestandsaenderung einer Charge, nie geloescht (Fehler
// werden gegengebucht). Rest = Ausgangsmenge + Summe aller quantity.
// Genau eine der beiden Chargen-Spalten ist gesetzt (CHECK-Constraint in der
// Migration).
model BatchMovement {
id String @id @default(cuid())
bottlingBatchId String?
bottlingBatch BottlingBatch? @relation(fields: [bottlingBatchId], references: [id], onDelete: Restrict)
productionBatchId String?
productionBatch ProductionBatch? @relation(fields: [productionBatchId], references: [id], onDelete: Restrict)
type BatchMovementType
// Mit Vorzeichen: minus = raus, plus = rein.
quantity Int
reason WithdrawalReason?
notes String?
shopRequestId String?
shopRequest ShopStockRequest? @relation(fields: [shopRequestId], references: [id], onDelete: Restrict)
userId String?
user User? @relation(fields: [userId], references: [id])
apiKeyId String?
createdAt DateTime @default(now())
@@index([bottlingBatchId])
@@index([productionBatchId])
@@index([shopRequestId])
}
enum ShopStockRequestKind {
SALE
RETURN
}
// Eine Buchungsanfrage des Shops. externalId (vom Shop vergeben) macht jede
// Anfrage idempotent: dieselbe Anfrage zweimal bucht nur einmal.
model ShopStockRequest {
id String @id @default(cuid())
externalId String @unique
kind ShopStockRequestKind
// Ziel: eine Abfuellung (Honig) ODER ein Artikel (MANUFACTURED, FIFO).
bottlingBatchId String?
productId String?
quantity Int
reversesExternalId String?
apiKeyId String?
createdAt DateTime @default(now())
movements BatchMovement[]
@@index([reversesExternalId])
}
// ---------------------------------------------------------------------------
// Inventar (Abschnitt 4.22) – v1.x Modul 24
// ---------------------------------------------------------------------------