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;