feat(raw-materials): wax and propolis lots consumed by production batches

- RawMaterialLot (beeswax, propolis) in grams with origin (apiary,
  harvest); stock in the same movement ledger: withdrawals, inventory
  counts, and the new movement type CONSUMPTION.
- Production batches take inputs (raw lot + grams); batch and consumption
  are one transaction, never more than a lot has left. Deleting a
  mistaken batch gives the material back as its own movement.
- Traceability both ways: batch -> inputs, raw lot -> usedIn.
- Movement belongs to exactly one of bottling / production / raw lot
  (CHECK); raw material changes do not trigger the shop webhook.
- Permissions raw-material:*, granted to roles that maintain production
  batches.

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 23:54:43 +02:00
1 parent 41c2af7284
commit e73c8b2cb4
15 files changed
+986 -32

No files matched your search

@@ -0,0 +1,106 @@
-- Rohstoffe (Wachs, Propolis) mit eigenen Chargen und Bestand; Herstellungs-
-- chargen verbrauchen sie (ProductionInput + Bewegung CONSUMPTION). Damit
-- reicht die Rueckverfolgung vom Rohstoff ueber die Charge bis zur Kundin.
-- CreateEnum
CREATE TYPE "RawMaterialKind" AS ENUM ('BEESWAX', 'PROPOLIS');
-- AlterEnum
ALTER TYPE "BatchMovementType" ADD VALUE 'CONSUMPTION';
-- AlterTable
ALTER TABLE "BatchMovement" ADD COLUMN "rawMaterialLotId" TEXT;
-- CreateTable
CREATE TABLE "RawMaterialLot" (
"id" TEXT NOT NULL,
"kind" "RawMaterialKind" NOT NULL,
"lotNumber" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"quantity" INTEGER NOT NULL,
"apiaryId" TEXT,
"harvestId" TEXT,
"notes" TEXT,
"userId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RawMaterialLot_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProductionInput" (
"id" TEXT NOT NULL,
"productionBatchId" TEXT NOT NULL,
"rawMaterialLotId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProductionInput_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "RawMaterialLot_lotNumber_key" ON "RawMaterialLot"("lotNumber");
-- CreateIndex
CREATE INDEX "RawMaterialLot_kind_idx" ON "RawMaterialLot"("kind");
-- CreateIndex
CREATE INDEX "ProductionInput_productionBatchId_idx" ON "ProductionInput"("productionBatchId");
-- CreateIndex
CREATE INDEX "ProductionInput_rawMaterialLotId_idx" ON "ProductionInput"("rawMaterialLotId");
-- CreateIndex
CREATE INDEX "BatchMovement_rawMaterialLotId_idx" ON "BatchMovement"("rawMaterialLotId");
-- AddForeignKey
ALTER TABLE "RawMaterialLot" ADD CONSTRAINT "RawMaterialLot_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RawMaterialLot" ADD CONSTRAINT "RawMaterialLot_harvestId_fkey" FOREIGN KEY ("harvestId") REFERENCES "Harvest"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RawMaterialLot" ADD CONSTRAINT "RawMaterialLot_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProductionInput" ADD CONSTRAINT "ProductionInput_productionBatchId_fkey" FOREIGN KEY ("productionBatchId") REFERENCES "ProductionBatch"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProductionInput" ADD CONSTRAINT "ProductionInput_rawMaterialLotId_fkey" FOREIGN KEY ("rawMaterialLotId") REFERENCES "RawMaterialLot"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_rawMaterialLotId_fkey" FOREIGN KEY ("rawMaterialLotId") REFERENCES "RawMaterialLot"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Eine Bewegung gehoert jetzt zu genau einer von drei Chargenarten.
ALTER TABLE "BatchMovement" DROP CONSTRAINT "BatchMovement_exactly_one_batch";
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_exactly_one_batch"
CHECK (num_nonnulls("bottlingBatchId", "productionBatchId", "rawMaterialLotId") = 1);
ALTER TABLE "RawMaterialLot" ADD CONSTRAINT "RawMaterialLot_quantity_positive" CHECK ("quantity" > 0);
ALTER TABLE "ProductionInput" ADD CONSTRAINT "ProductionInput_quantity_positive" CHECK ("quantity" > 0);
-- Permissions (catalog sync grants only brand-new rows to ADMIN): roles that
-- may maintain production batches may maintain raw materials, readers read.
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
VALUES
(gen_random_uuid()::text, 'raw-material:read', 'raw-material', 'read', 'Ansehen', 'Rohstoffe', 1630),
(gen_random_uuid()::text, 'raw-material:create', 'raw-material', 'create', 'Anlegen', 'Rohstoffe', 1631),
(gen_random_uuid()::text, 'raw-material:update', 'raw-material', 'update', 'Bearbeiten', 'Rohstoffe', 1632),
(gen_random_uuid()::text, 'raw-material:delete', 'raw-material', 'delete', 'Löschen', 'Rohstoffe', 1633),
(gen_random_uuid()::text, 'raw-material:withdraw', 'raw-material', 'withdraw', 'Entnehmen und Inventur', 'Rohstoffe', 1634)
ON CONFLICT (key) 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 = 'production-batch:create'
JOIN "Permission" p ON p.key IN ('raw-material:create', 'raw-material:update', 'raw-material:delete', 'raw-material: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 = 'production-batch:read'
JOIN "Permission" p ON p.key = 'raw-material:read'
ON CONFLICT DO NOTHING;
+64
View File
@@ -122,6 +122,7 @@ model User {
bottlingBatches BottlingBatch[]
productionBatches ProductionBatch[]
batchMovements BatchMovement[]
rawMaterialLots RawMaterialLot[]
notifications Notification[]
queenEvaluations QueenEvaluation[]
webhookSubscriptions WebhookSubscription[]
@@ -239,6 +240,7 @@ model Apiary {
colonyMoves ColonyApiaryHistory[]
harvests Harvest[]
treatments Treatment[]
rawMaterialLots RawMaterialLot[]
@@index([status])
}
@@ -761,6 +763,7 @@ model Harvest {
colonies HarvestColony[]
extractions ExtractionHarvest[]
rawMaterialLots RawMaterialLot[]
@@index([apiaryId, date])
}
@@ -1097,15 +1100,73 @@ model ProductionBatch {
updatedAt DateTime @updatedAt
movements BatchMovement[]
// Woraus die Charge hergestellt wurde (Rueckverfolgung Rohstoff -> Ware).
inputs ProductionInput[]
@@index([productId])
}
// ---------------------------------------------------------------------------
// Rohstoffe (Wachs, Propolis) und ihr Verbrauch in der Herstellung
// ---------------------------------------------------------------------------
enum RawMaterialKind {
BEESWAX
PROPOLIS
}
// Eine Rohstoff-Charge, z. B. "Wachs 2026-01: 4200 g" aus dem Entdeckeln
// oder "Propolis 2026-02: 180 g" vom Abkratzen der Gitter. Menge in Gramm;
// der Rest folgt wie bei allen Chargen aus dem Bewegungsbuch.
model RawMaterialLot {
id String @id @default(cuid())
kind RawMaterialKind
lotNumber String @unique
// Gewinnungsdatum
date DateTime
// Gewonnene Menge in Gramm - Ausgangsmenge fuer den Rest.
quantity Int
// Herkunft, optional: Stand und/oder Ernte, aus der der Rohstoff stammt.
apiaryId String?
apiary Apiary? @relation(fields: [apiaryId], references: [id])
harvestId String?
harvest Harvest? @relation(fields: [harvestId], references: [id])
notes String?
userId String?
user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
movements BatchMovement[]
usedIn ProductionInput[]
@@index([kind])
}
// Verbrauch einer Rohstoff-Charge in einer Herstellungscharge. Die
// Abbuchung selbst steht als CONSUMPTION im Bewegungsbuch des Rohstoffs.
model ProductionInput {
id String @id @default(cuid())
productionBatchId String
productionBatch ProductionBatch @relation(fields: [productionBatchId], references: [id], onDelete: Cascade)
rawMaterialLotId String
rawMaterialLot RawMaterialLot @relation(fields: [rawMaterialLotId], references: [id], onDelete: Restrict)
// Verbrauchte Menge in Gramm.
quantity Int
createdAt DateTime @default(now())
@@index([productionBatchId])
@@index([rawMaterialLotId])
}
enum BatchMovementType {
SHOP_SALE
SHOP_RETURN
WITHDRAWAL
CORRECTION
// Rohstoff in einer Herstellungscharge verbraucht (minus) bzw. beim
// Loeschen einer irrtuemlich angelegten Charge zurueckgegeben (plus).
CONSUMPTION
}
enum WithdrawalReason {
@@ -1126,6 +1187,8 @@ model BatchMovement {
bottlingBatch BottlingBatch? @relation(fields: [bottlingBatchId], references: [id], onDelete: Restrict)
productionBatchId String?
productionBatch ProductionBatch? @relation(fields: [productionBatchId], references: [id], onDelete: Restrict)
rawMaterialLotId String?
rawMaterialLot RawMaterialLot? @relation(fields: [rawMaterialLotId], references: [id], onDelete: Restrict)
type BatchMovementType
// Mit Vorzeichen: minus = raus, plus = rein.
quantity Int
@@ -1140,6 +1203,7 @@ model BatchMovement {
@@index([bottlingBatchId])
@@index([productionBatchId])
@@index([rawMaterialLotId])
@@index([shopRequestId])
}