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