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:
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;
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user