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

@@ -0,0 +1,162 @@
-- Bestandsabgleich mit dem Webshop (docs/superpowers/specs/2026-10-02-bestandsabgleich-design.md):
-- Bewegungsbuch je Charge, Herstellungschargen fuer Nicht-Honig-Artikel,
-- idempotente Buchungsanfragen des Shops. Rein additiv.
-- CreateEnum
CREATE TYPE "ProductKind" AS ENUM ('BOTTLED_HONEY', 'MANUFACTURED');
-- CreateEnum
CREATE TYPE "ShopCategory" AS ENUM ('HONIG', 'KERZEN', 'PROPOLIS', 'SONSTIGES');
-- CreateEnum
CREATE TYPE "BatchMovementType" AS ENUM ('SHOP_SALE', 'SHOP_RETURN', 'WITHDRAWAL', 'CORRECTION');
-- CreateEnum
CREATE TYPE "WithdrawalReason" AS ENUM ('HOFLADEN', 'EIGENBEDARF', 'BRUCH', 'PROBE', 'SONSTIGES');
-- CreateEnum
CREATE TYPE "ShopStockRequestKind" AS ENUM ('SALE', 'RETURN');
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "kind" "ProductKind" NOT NULL DEFAULT 'BOTTLED_HONEY',
ADD COLUMN "shopCategory" "ShopCategory" NOT NULL DEFAULT 'HONIG';
-- CreateTable
CREATE TABLE "ProductionBatch" (
"id" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"lotNumber" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"quantity" INTEGER NOT NULL,
"bestBeforeDate" TIMESTAMP(3),
"notes" TEXT,
"userId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProductionBatch_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BatchMovement" (
"id" TEXT NOT NULL,
"bottlingBatchId" TEXT,
"productionBatchId" TEXT,
"type" "BatchMovementType" NOT NULL,
"quantity" INTEGER NOT NULL,
"reason" "WithdrawalReason",
"notes" TEXT,
"shopRequestId" TEXT,
"userId" TEXT,
"apiKeyId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BatchMovement_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ShopStockRequest" (
"id" TEXT NOT NULL,
"externalId" TEXT NOT NULL,
"kind" "ShopStockRequestKind" NOT NULL,
"bottlingBatchId" TEXT,
"productId" TEXT,
"quantity" INTEGER NOT NULL,
"reversesExternalId" TEXT,
"apiKeyId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ShopStockRequest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ProductionBatch_lotNumber_key" ON "ProductionBatch"("lotNumber");
-- CreateIndex
CREATE INDEX "ProductionBatch_productId_idx" ON "ProductionBatch"("productId");
-- CreateIndex
CREATE INDEX "BatchMovement_bottlingBatchId_idx" ON "BatchMovement"("bottlingBatchId");
-- CreateIndex
CREATE INDEX "BatchMovement_productionBatchId_idx" ON "BatchMovement"("productionBatchId");
-- CreateIndex
CREATE INDEX "BatchMovement_shopRequestId_idx" ON "BatchMovement"("shopRequestId");
-- CreateIndex
CREATE UNIQUE INDEX "ShopStockRequest_externalId_key" ON "ShopStockRequest"("externalId");
-- CreateIndex
CREATE INDEX "ShopStockRequest_reversesExternalId_idx" ON "ShopStockRequest"("reversesExternalId");
-- AddForeignKey
ALTER TABLE "ProductionBatch" ADD CONSTRAINT "ProductionBatch_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProductionBatch" ADD CONSTRAINT "ProductionBatch_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_bottlingBatchId_fkey" FOREIGN KEY ("bottlingBatchId") REFERENCES "BottlingBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_productionBatchId_fkey" FOREIGN KEY ("productionBatchId") REFERENCES "ProductionBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_shopRequestId_fkey" FOREIGN KEY ("shopRequestId") REFERENCES "ShopStockRequest"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Genau eine Charge je Bewegung, genau ein Ziel je Shop-Anfrage.
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_exactly_one_batch"
CHECK (("bottlingBatchId" IS NULL) <> ("productionBatchId" IS NULL));
ALTER TABLE "ShopStockRequest" ADD CONSTRAINT "ShopStockRequest_exactly_one_target"
CHECK (("bottlingBatchId" IS NULL) <> ("productId" IS NULL));
ALTER TABLE "ShopStockRequest" ADD CONSTRAINT "ShopStockRequest_quantity_positive"
CHECK ("quantity" > 0);
ALTER TABLE "ProductionBatch" ADD CONSTRAINT "ProductionBatch_quantity_positive"
CHECK ("quantity" > 0);
-- Permissions: the boot-time catalog sync only grants new permissions to
-- ADMIN; the other built-in roles get them here, matching their existing
-- bottling rights.
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
VALUES
(gen_random_uuid()::text, 'bottling:withdraw', 'bottling', 'withdraw', 'Entnehmen und Inventur', 'Abfüllungen', 1604),
(gen_random_uuid()::text, 'production-batch:read', 'production-batch', 'read', 'Ansehen', 'Herstellungschargen', 1650),
(gen_random_uuid()::text, 'production-batch:create', 'production-batch', 'create', 'Anlegen', 'Herstellungschargen', 1651),
(gen_random_uuid()::text, 'production-batch:update', 'production-batch', 'update', 'Bearbeiten', 'Herstellungschargen', 1652),
(gen_random_uuid()::text, 'production-batch:delete', 'production-batch', 'delete', 'Löschen', 'Herstellungschargen', 1653),
(gen_random_uuid()::text, 'production-batch:withdraw', 'production-batch', 'withdraw', 'Entnehmen und Inventur', 'Herstellungschargen', 1654),
(gen_random_uuid()::text, 'shop-sync:write', 'shop-sync', 'write', 'Verkäufe und Rückbuchungen des Shops buchen', 'Shop-Synchronisation', 3301)
ON CONFLICT (key) DO NOTHING;
-- Wer Abfuellungen bearbeiten darf, darf kuenftig auch entnehmen und
-- Herstellungschargen pflegen; wer Abfuellungen sehen darf, sieht auch
-- Herstellungschargen.
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT DISTINCT rp."roleId", p.id, now()
FROM "RolePermission" rp
JOIN "Permission" existing ON existing.id = rp."permissionId" AND existing.key = 'bottling:update'
JOIN "Permission" p ON p.key IN (
'bottling:withdraw', 'production-batch:create', 'production-batch:update',
'production-batch:delete', 'production-batch:withdraw'
)
ON CONFLICT DO NOTHING;
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT DISTINCT rp."roleId", p.id, now()
FROM "RolePermission" rp
JOIN "Permission" existing ON existing.id = rp."permissionId" AND existing.key = 'bottling:read'
JOIN "Permission" p ON p.key = 'production-batch:read'
ON CONFLICT DO NOTHING;
-- Only ADMIN may hand out shop-sync:write to an API key (a key can only get
-- permissions its creator holds). Inserted here, the row is no longer "new"
-- for the boot-time catalog sync, so the grant has to happen here too.
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE r.key = 'ADMIN' AND p.key = 'shop-sync:write'
ON CONFLICT DO NOTHING;
+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
// ---------------------------------------------------------------------------