Compare commits

...
11 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5.5 fc887d8431 fix(ui): encode every id in API paths (client-side path traversal)
Backend Image / build-and-push (push) Successful in 1m9s
Frontend Image / build-and-push (push) Successful in 2m2s
Follow-up to a7609ff for the whole frontend: all 30 API helpers in
src/lib now pass path values through encodeURIComponent, so an id taken
from the URL can never leave its own path segment and make the page call
another endpoint with the viewer's token.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-06 06:40:25 +02:00
Nick SzaunigandClaude Opus 5.5 a7609ff3f8 fix(ui): encode ids in raw material, stock and production batch API paths
Security review finding (client-side path traversal): ids taken from the
URL were interpolated into API paths unencoded, so a crafted link like
/raw-materials/..%2Fusers made the page call another endpoint with the
viewer's token. Ids are now encodeURIComponent-ed; a regression test pins
"../users" to its own path segment.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-06 06:34:07 +02:00
Nick SzaunigandClaude Opus 5.5 44971c23e6 docs(spec): raw materials and production addendum
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-06 06:31:38 +02:00
Nick SzaunigandClaude Opus 5.5 569a43d247 feat(ui): raw material lots and production inputs
- Pages for raw material lots (beeswax, propolis): list, create in grams
  with optional apiary, detail with stock card (harvested / used in
  production / withdrawn / remaining) and "used in" batches.
- Production batch form takes the raw material used (lot + grams) and the
  batch page shows what it was made from - traceability both ways.
- Sidebar entry "Rohstoffe".
- Removes backend files that were committed into ApiCore-Frontend/src by
  mistake in e52a605 (copies of ApiCore-Backend/src/raw-materials).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-06 06:31:07 +02:00
Nick SzaunigandClaude Opus 5.5 f9899d5621 fix(stock): require raw-material permission to consume, guard quantity edits
Security review findings on the raw material commit:
- Creating a production batch with inputs books raw material off other
  lots; production-batch:create alone no longer suffices, the user also
  needs raw-material:withdraw (403 otherwise).
- Correcting the quantity of a raw lot or production batch below what was
  already consumed, sold or withdrawn is refused (row lock + check in the
  same transaction) instead of silently driving the remaining stock
  negative; shortfalls go through an inventory correction.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-06 06:31:06 +02:00
Nick Szaunig e52a6057a2 Bestandsabgleich umgesetzt 2026-10-06 00:02:35 +02:00
Nick SzaunigandClaude Opus 5.5 e73c8b2cb4 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]>
2026-10-05 23:54:43 +02:00
Nick SzaunigandClaude Opus 5.5 41c2af7284 feat(ui): article stock overview and production batch editing
- New article page /products/[id]: remaining stock summed over all
  production batches (or bottlings for honey), each batch linked to its
  own page for withdrawals and counts; "Bestand ansehen" in the list.
- Production batches can be corrected (lot number, date, quantity,
  best-before date, note) via PATCH; the article stays fixed.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-05 21:28:15 +02:00
Nick SzaunigandClaude Opus 5.5 f57db97578 feat(ui): stock card with withdraw and inventory count, production batches
- Bottling detail shows Abgefüllt / Webshop verkauft / Entnommen / Rest
  (negative rest flagged "Prüfen") and the full movement list; buttons
  "Entnehmen" (reason, note) and "Inventur korrigieren" (counted
  quantity, difference booked).
- Rest column in the bottling list.
- New pages for production batches (list, create, detail with the same
  stock card), sidebar entry with its own permission.
- Products get "Art" (honey vs. manufactured goods) and the webshop
  category; bottlings only offer honey articles.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-02 06:44:21 +02:00
Nick SzaunigandClaude Opus 5.5 04f93e954a 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]>
2026-10-02 06:38:12 +02:00
Nick SzaunigandClaude Opus 5.5 85f05dc541 docs(spec): stock sync between ApiCore and the Wabenhain shop
One remaining quantity per batch, kept in ApiCore: movement ledger for
bottlings and new production batches, shop write-back of paid sales and
returns, withdrawals in ApiCore reflected in the shop via webhook plus a
10-minute reconcile.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-02 05:51:58 +02:00
98 changed files with 6414 additions and 132 deletions

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;
@@ -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;
+182 -1
View File
@@ -120,6 +120,9 @@ model User {
colonyMoves ColonyApiaryHistory[]
auditLogs AuditLog[]
bottlingBatches BottlingBatch[]
productionBatches ProductionBatch[]
batchMovements BatchMovement[]
rawMaterialLots RawMaterialLot[]
notifications Notification[]
queenEvaluations QueenEvaluation[]
webhookSubscriptions WebhookSubscription[]
@@ -237,6 +240,7 @@ model Apiary {
colonyMoves ColonyApiaryHistory[]
harvests Harvest[]
treatments Treatment[]
rawMaterialLots RawMaterialLot[]
@@index([status])
}
@@ -759,6 +763,7 @@ model Harvest {
colonies HarvestColony[]
extractions ExtractionHarvest[]
rawMaterialLots RawMaterialLot[]
@@index([apiaryId, date])
}
@@ -979,6 +984,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 +1009,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 +1070,167 @@ 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[]
// 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 {
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)
rawMaterialLotId String?
rawMaterialLot RawMaterialLot? @relation(fields: [rawMaterialLotId], 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([rawMaterialLotId])
@@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
// ---------------------------------------------------------------------------
@@ -1,14 +1,17 @@
import { PermissionKey } from '../auth/permissions/permission-catalog';
/**
* Permissions an API key may carry. Deliberately a short allowlist of
* read-only permissions, not the whole catalog: a key is a long-lived secret
* stored in another system, so it only ever gets what a machine integration
* needs. Removing an entry here also takes it away from existing keys (see
* Permissions an API key may carry. Deliberately a short allowlist, not the
* whole catalog: a key is a long-lived secret stored in another system, so it
* only ever gets what a machine integration needs. shop-sync:write lets the
* shop book its own sales and returns - nothing else - and every booking is
* idempotent and kept in the ledger, so a leaked key cannot erase history.
* Removing an entry here also takes it away from existing keys (see
* ApiKeysService.authenticate).
*/
export const API_KEY_SCOPES = [
'shop-sync:read',
'shop-sync:write',
] as const satisfies readonly PermissionKey[];
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
+4
View File
@@ -27,6 +27,8 @@ import { ExportModule } from './export/export.module';
import { QrModule } from './qr/qr.module';
import { ProductsModule } from './products/products.module';
import { BottlingModule } from './bottling/bottling.module';
import { ProductionBatchesModule } from './production-batches/production-batches.module';
import { RawMaterialsModule } from './raw-materials/raw-materials.module';
import { EquipmentModule } from './equipment/equipment.module';
import { CalendarModule } from './calendar/calendar.module';
import { CalendarSyncModule } from './calendar-sync/calendar-sync.module';
@@ -83,6 +85,8 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
QrModule,
ProductsModule,
BottlingModule,
ProductionBatchesModule,
RawMaterialsModule,
EquipmentModule,
CalendarModule,
CalendarSyncModule,
@@ -204,9 +204,34 @@ export const PERMISSION_CATALOG = [
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['delete', 'Löschen'],
['withdraw', 'Entnehmen und Inventur'],
],
1600,
),
...group(
'Rohstoffe',
'raw-material',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['delete', 'Löschen'],
['withdraw', 'Entnehmen und Inventur'],
],
1630,
),
...group(
'Herstellungschargen',
'production-batch',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['delete', 'Löschen'],
['withdraw', 'Entnehmen und Inventur'],
],
1650,
),
...group(
'Etiketten',
'label-templates',
@@ -324,7 +349,10 @@ export const PERMISSION_CATALOG = [
...group(
'Shop-Synchronisation',
'shop-sync',
[['read', 'Freigegebene Daten für den Shop lesen']],
[
['read', 'Freigegebene Daten für den Shop lesen'],
['write', 'Verkäufe und Rückbuchungen des Shops buchen'],
],
3300,
),
...group(
@@ -16,12 +16,18 @@ import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
import { BottlingService } from './bottling.service';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
import { StockService } from '../stock/stock.service';
import { WithdrawStockDto } from '../stock/dto/withdraw-stock.dto';
import { CorrectStockDto } from '../stock/dto/correct-stock.dto';
@ApiTags('bottling')
@ApiBearerAuth()
@Controller('bottling-batches')
export class BottlingController {
constructor(private readonly bottlingService: BottlingService) {}
constructor(
private readonly bottlingService: BottlingService,
private readonly stock: StockService,
) {}
@RequirePermission('bottling:read')
@Get()
@@ -35,6 +41,37 @@ export class BottlingController {
return this.bottlingService.findById(id);
}
@RequirePermission('bottling:read')
@Get(':id/stock')
async stockOf(@Param('id') id: string) {
await this.bottlingService.findById(id);
return this.stock.stockOf({ kind: 'bottling', id });
}
@RequirePermission('bottling:withdraw')
@Idempotent()
@Post(':id/withdrawals')
async withdraw(
@Param('id') id: string,
@Body() dto: WithdrawStockDto,
@CurrentUser() actor: AuthenticatedUser,
) {
await this.bottlingService.findById(id);
return this.stock.withdraw({ kind: 'bottling', id }, dto, actor.id);
}
@RequirePermission('bottling:withdraw')
@Idempotent()
@Post(':id/corrections')
async correct(
@Param('id') id: string,
@Body() dto: CorrectStockDto,
@CurrentUser() actor: AuthenticatedUser,
) {
await this.bottlingService.findById(id);
return this.stock.correct({ kind: 'bottling', id }, dto, actor.id);
}
@RequirePermission('bottling:create')
@Idempotent()
@Post()
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module';
import { StockModule } from '../stock/stock.module';
import { BottlingController } from './bottling.controller';
import { BottlingService } from './bottling.service';
@Module({
imports: [AutomationModule],
imports: [AutomationModule, StockModule],
controllers: [BottlingController],
providers: [BottlingService],
})
@@ -1,9 +1,14 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import { BottlingService } from './bottling.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service';
import { StockService } from '../stock/stock.service';
type AmountChange = number | { increment?: number; decrement?: number };
@@ -40,6 +45,11 @@ describe('BottlingService', () => {
};
};
let webhooks: { dispatch: jest.Mock };
let stock: {
remainingByBottling: jest.Mock;
notify: jest.Mock;
hasMovements: jest.Mock;
};
// Honey batch rows as the transaction sees them - the tests assert the
// resulting amounts instead of the exact sequence of UPDATE statements.
let honeyBatches: Map<string, { amount: number | null; status?: string }>;
@@ -115,11 +125,17 @@ describe('BottlingService', () => {
auditLog = { record: jest.fn() };
webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
const automation = { generateNumber: jest.fn().mockResolvedValue(null) };
stock = {
remainingByBottling: jest.fn().mockResolvedValue(new Map()),
notify: jest.fn().mockResolvedValue(undefined),
hasMovements: jest.fn().mockResolvedValue(false),
};
service = new BottlingService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService,
automation as unknown as AutomationService,
stock as unknown as StockService,
);
});
@@ -241,14 +257,20 @@ describe('BottlingService', () => {
const result = await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
expect(result).toEqual(updated);
// Without movements the remaining stock equals the jar count.
expect(result).toEqual({ ...updated, remainingCount: 10 });
expect(stock.notify).toHaveBeenCalledWith({
bottlingBatchIds: ['b1'],
productIds: [],
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'BottlingBatch',
entityId: 'b1',
action: 'UPDATE',
beforeData: before,
afterData: updated,
// The audit entry keeps the stock at that moment as well.
beforeData: { ...before, remainingCount: 10 },
afterData: { ...updated, remainingCount: 10 },
}),
);
});
@@ -378,6 +400,47 @@ describe('BottlingService', () => {
);
});
it('remove refuses a bottling whose jars were already sold or withdrawn', async () => {
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 2 },
totalAmount: 5,
});
stock.hasMovements.mockResolvedValueOnce(true);
await expect(service.remove('b1', 'user-1')).rejects.toBeInstanceOf(
ConflictException,
);
expect(tx.bottlingBatch.delete).not.toHaveBeenCalled();
});
it('refuses to bottle onto an article that keeps stock in production batches', async () => {
prisma.honeyBatch.findUnique.mockResolvedValueOnce({
id: 'h1',
amount: 10,
});
prisma.product.findUnique.mockResolvedValueOnce({
id: 'candle',
kind: 'MANUFACTURED',
});
await expect(
service.create(
{
honeyBatchId: 'h1',
productId: 'candle',
date: '2026-07-01',
jarSize: 500,
jarCount: 2,
bestBeforeDate: '2028-07-01',
lotNumber: 'L1',
},
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('remove throws NotFoundException for an unknown bottling batch', async () => {
prisma.bottlingBatch.findUnique.mockResolvedValueOnce(null);
@@ -1,5 +1,6 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
@@ -7,6 +8,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service';
import { StockService } from '../stock/stock.service';
import { BottlingBatch, Prisma } from '../../generated/prisma';
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
@@ -104,14 +106,22 @@ export class BottlingService {
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
private readonly automation: AutomationService,
private readonly stock: StockService,
) {}
async findAll(filter: BottlingBatchFilter = {}) {
return this.prisma.bottlingBatch.findMany({
const batches = await this.prisma.bottlingBatch.findMany({
where: { honeyBatchId: filter.honeyBatchId },
include: bottlingBatchInclude,
orderBy: { date: 'desc' },
});
const remaining = await this.stock.remainingByBottling(
batches.map((b) => b.id),
);
return batches.map((b) => ({
...b,
remainingCount: remaining.get(b.id) ?? b.jarCount,
}));
}
async findById(id: string) {
@@ -122,7 +132,26 @@ export class BottlingService {
if (!bottlingBatch) {
throw new NotFoundException('Bottling batch not found');
}
return bottlingBatch;
const remaining = await this.stock.remainingByBottling([id]);
return {
...bottlingBatch,
remainingCount: remaining.get(id) ?? bottlingBatch.jarCount,
};
}
/** Honey is bottled; other articles keep their stock in production batches. */
private async assertBottledProduct(productId: string): Promise<void> {
const product = await this.prisma.product.findUnique({
where: { id: productId },
});
if (!product) {
throw new NotFoundException('Product not found');
}
if (product.kind !== 'BOTTLED_HONEY') {
throw new BadRequestException(
'Abfüllungen nur für Honig-Artikel - hergestellte Waren bekommen Herstellungschargen',
);
}
}
async create(dto: CreateBottlingBatchDto, actingUserId: string) {
@@ -133,12 +162,7 @@ export class BottlingService {
throw new NotFoundException('Honey batch not found');
}
if (dto.productId) {
const product = await this.prisma.product.findUnique({
where: { id: dto.productId },
});
if (!product) {
throw new NotFoundException('Product not found');
}
await this.assertBottledProduct(dto.productId);
}
const totalAmount = roundKg((dto.jarSize * dto.jarCount) / 1000);
@@ -188,6 +212,10 @@ export class BottlingService {
const created = await this.findById(bottlingBatch.id);
await this.webhooks.dispatch('batch.updated', created.honeyBatch);
await this.stock.notify({
bottlingBatchIds: [created.id],
productIds: [],
});
return created;
}
@@ -202,12 +230,7 @@ export class BottlingService {
const before = await this.findById(id);
if (dto.productId) {
const product = await this.prisma.product.findUnique({
where: { id: dto.productId },
});
if (!product) {
throw new NotFoundException('Product not found');
}
await this.assertBottledProduct(dto.productId);
}
const honeyBatchId = dto.honeyBatchId ?? before.honeyBatchId;
@@ -280,6 +303,8 @@ export class BottlingService {
afterData: bottlingBatch,
});
await this.webhooks.dispatch('batch.updated', bottlingBatch.honeyBatch);
// A corrected jar count changes the remaining stock as well.
await this.stock.notify({ bottlingBatchIds: [id], productIds: [] });
return bottlingBatch;
}
@@ -294,6 +319,14 @@ export class BottlingService {
*/
async remove(id: string, actingUserId: string) {
const before = await this.findById(id);
// Once jars were sold or withdrawn, the bottling is history: deleting it
// would erase where they came from. Wrong stock is fixed by an inventory
// correction instead.
if (await this.stock.hasMovements({ kind: 'bottling', id })) {
throw new ConflictException(
'Abfüllung hat bereits Bestandsbewegungen und kann nicht gelöscht werden - Bestand über "Inventur korrigieren" anpassen',
);
}
await this.prisma.$transaction(async (tx) => {
await tx.bottlingBatch.delete({ where: { id } });
@@ -308,6 +341,7 @@ export class BottlingService {
beforeData: before,
});
await this.webhooks.dispatch('batch.updated', before.honeyBatch);
await this.stock.notify({ bottlingBatchIds: [id], productIds: [] });
return { id };
}
}
@@ -0,0 +1,70 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
ValidateNested,
IsDateString,
IsInt,
IsNotEmpty,
IsOptional,
IsPositive,
IsString,
MaxLength,
} from 'class-validator';
/** Raw material that went into the batch, in grams. */
export class ProductionInputDto {
@ApiProperty()
@IsString()
rawMaterialLotId!: string;
@ApiProperty({ description: 'Verbrauchte Menge in Gramm' })
@IsInt()
@IsPositive()
quantity!: number;
}
export class CreateProductionBatchDto {
@ApiProperty({ description: 'Artikel der Art MANUFACTURED' })
@IsString()
productId!: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
@MaxLength(100)
lotNumber!: string;
@ApiProperty({ description: 'Herstelldatum' })
@IsDateString()
date!: string;
@ApiProperty({ description: 'Hergestellte Stückzahl' })
@IsInt()
@IsPositive()
quantity!: number;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
bestBeforeDate?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
@ApiPropertyOptional({
type: [ProductionInputDto],
description:
'Verbrauchte Rohstoff-Chargen - werden beim Anlegen vom Rohstoff abgebucht',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(20)
@ValidateNested({ each: true })
@Type(() => ProductionInputDto)
inputs?: ProductionInputDto[];
}
@@ -0,0 +1,48 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsInt,
IsNotEmpty,
IsOptional,
IsPositive,
IsString,
MaxLength,
ValidateIf,
} from 'class-validator';
/**
* Corrects a production batch entered with a typo. The article is fixed once
* created: moving a batch with sales to another article would rewrite where
* those sales came from. `bestBeforeDate: null` removes the date.
*/
export class UpdateProductionBatchDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(100)
lotNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
date?: string;
@ApiPropertyOptional({ description: 'Hergestellte Stückzahl' })
@IsOptional()
@IsInt()
@IsPositive()
quantity?: number;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@ValidateIf((_object, value) => value !== null)
@IsDateString()
bestBeforeDate?: string | null;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
}
@@ -0,0 +1,100 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
import { StockService } from '../stock/stock.service';
import { WithdrawStockDto } from '../stock/dto/withdraw-stock.dto';
import { CorrectStockDto } from '../stock/dto/correct-stock.dto';
import { CreateProductionBatchDto } from './dto/create-production-batch.dto';
import { UpdateProductionBatchDto } from './dto/update-production-batch.dto';
import { ProductionBatchesService } from './production-batches.service';
@ApiTags('production-batches')
@ApiBearerAuth()
@Controller('production-batches')
export class ProductionBatchesController {
constructor(
private readonly batches: ProductionBatchesService,
private readonly stock: StockService,
) {}
@RequirePermission('production-batch:read')
@Get()
findAll(@Query('productId') productId?: string) {
return this.batches.findAll({ productId });
}
@RequirePermission('production-batch:read')
@Get(':id')
findOne(@Param('id') id: string) {
return this.batches.findById(id);
}
@RequirePermission('production-batch:read')
@Get(':id/stock')
async stockOf(@Param('id') id: string) {
await this.batches.findById(id);
return this.stock.stockOf({ kind: 'production', id });
}
@RequirePermission('production-batch:create')
@Idempotent()
@Post()
create(
@Body() dto: CreateProductionBatchDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.batches.create(dto, actor.id);
}
@RequirePermission('production-batch:withdraw')
@Idempotent()
@Post(':id/withdrawals')
async withdraw(
@Param('id') id: string,
@Body() dto: WithdrawStockDto,
@CurrentUser() actor: AuthenticatedUser,
) {
await this.batches.findById(id);
return this.stock.withdraw({ kind: 'production', id }, dto, actor.id);
}
@RequirePermission('production-batch:withdraw')
@Idempotent()
@Post(':id/corrections')
async correct(
@Param('id') id: string,
@Body() dto: CorrectStockDto,
@CurrentUser() actor: AuthenticatedUser,
) {
await this.batches.findById(id);
return this.stock.correct({ kind: 'production', id }, dto, actor.id);
}
@RequirePermission('production-batch:update')
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateProductionBatchDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.batches.update(id, dto, actor.id);
}
@RequirePermission('production-batch:delete')
@Delete(':id')
remove(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.batches.remove(id, actor.id);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { StockModule } from '../stock/stock.module';
import { ProductionBatchesController } from './production-batches.controller';
import { ProductionBatchesService } from './production-batches.service';
@Module({
imports: [StockModule],
controllers: [ProductionBatchesController],
providers: [ProductionBatchesService],
})
export class ProductionBatchesModule {}
@@ -0,0 +1,216 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { StockService } from '../stock/stock.service';
import { CreateProductionBatchDto } from './dto/create-production-batch.dto';
import { UpdateProductionBatchDto } from './dto/update-production-batch.dto';
const include = {
product: true,
// Backward traceability: which raw material lots went into this batch.
inputs: {
select: {
id: true,
quantity: true,
rawMaterialLot: { select: { id: true, lotNumber: true, kind: true } },
},
},
} as const;
export interface ProductionBatchFilter {
productId?: string;
}
/**
* Production batches of MANUFACTURED articles (candles, propolis tinctures,
* salves, ...) - the counterpart of a honey bottling. Each one carries its
* own stock in the movement ledger (StockService); later a production run
* will create them from raw-material batches.
*/
@Injectable()
export class ProductionBatchesService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly stock: StockService,
private readonly permissions: PermissionResolverService,
) {}
async findAll(filter: ProductionBatchFilter = {}) {
const batches = await this.prisma.productionBatch.findMany({
where: { productId: filter.productId },
include,
orderBy: { date: 'desc' },
});
const remaining = await this.stock.remainingByProductionBatch(
batches.map((b) => b.id),
);
return batches.map((b) => ({
...b,
remainingCount: remaining.get(b.id) ?? b.quantity,
}));
}
async findById(id: string) {
const batch = await this.prisma.productionBatch.findUnique({
where: { id },
include,
});
if (!batch) throw new NotFoundException('Production batch not found');
const remaining = await this.stock.remainingByProductionBatch([id]);
return { ...batch, remainingCount: remaining.get(id) ?? batch.quantity };
}
async create(dto: CreateProductionBatchDto, actingUserId: string) {
// Consuming raw material is a withdrawal from someone else's stock:
// production-batch:create alone must not be enough to empty a wax lot.
if (dto.inputs && dto.inputs.length > 0) {
const granted = await this.permissions.getPermissions(actingUserId);
if (!granted.has('raw-material:withdraw')) {
throw new ForbiddenException(
'Rohstoffe verbrauchen erfordert die Berechtigung "Rohstoffe: Entnehmen und Inventur"',
);
}
}
const product = await this.prisma.product.findUnique({
where: { id: dto.productId },
});
if (!product) throw new NotFoundException('Product not found');
if (product.kind !== 'MANUFACTURED') {
throw new BadRequestException(
'Herstellungschargen nur für Artikel der Art "Hergestellte Ware" - Honig wird abgefüllt',
);
}
// A duplicate lotNumber surfaces as P2002 and becomes a 409 with a
// field-specific message in AllExceptionsFilter. Batch and raw material
// consumption are one transaction: a batch never exists without its
// inputs being booked, and not enough raw material creates no batch.
const batch = await this.prisma.$transaction(async (tx) => {
const created = await tx.productionBatch.create({
data: {
productId: dto.productId,
lotNumber: dto.lotNumber,
date: new Date(dto.date),
quantity: dto.quantity,
bestBeforeDate: dto.bestBeforeDate
? new Date(dto.bestBeforeDate)
: undefined,
notes: dto.notes,
userId: actingUserId,
},
});
if (dto.inputs && dto.inputs.length > 0) {
await this.stock.consumeForProduction(
tx,
created.id,
created.lotNumber,
dto.inputs,
actingUserId,
);
}
return created;
});
await this.auditLog.record({
entityType: 'ProductionBatch',
entityId: batch.id,
action: 'CREATE',
userId: actingUserId,
afterData: batch,
});
await this.stock.notify({
bottlingBatchIds: [],
productIds: [batch.productId],
});
return this.findById(batch.id);
}
async update(
id: string,
dto: UpdateProductionBatchDto,
actingUserId: string,
) {
const before = await this.findById(id);
await this.prisma.$transaction(async (tx) => {
if (dto.quantity !== undefined && dto.quantity !== before.quantity) {
await this.stock.assertInitialCovers(
tx,
{ kind: 'production', id },
dto.quantity,
);
}
await tx.productionBatch.update({
where: { id },
data: {
lotNumber: dto.lotNumber,
date: dto.date ? new Date(dto.date) : undefined,
quantity: dto.quantity,
bestBeforeDate:
dto.bestBeforeDate === undefined
? undefined
: dto.bestBeforeDate === null
? null
: new Date(dto.bestBeforeDate),
notes: dto.notes,
},
});
});
const after = await this.findById(id);
await this.auditLog.record({
entityType: 'ProductionBatch',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: after,
});
await this.stock.notify({
bottlingBatchIds: [],
productIds: [after.productId],
});
return after;
}
/**
* Only for batches entered by mistake: once stock moved (a sale, a
* withdrawal), the batch is history and stays. Corrections then go through
* an inventory correction.
*/
async remove(id: string, actingUserId: string) {
const before = await this.findById(id);
if (await this.stock.hasMovements({ kind: 'production', id })) {
throw new ConflictException(
'Charge hat bereits Bestandsbewegungen und kann nicht gelöscht werden - Bestand über "Inventur korrigieren" anpassen',
);
}
// The raw material goes back to its lots as a movement of its own; the
// input rows go with the batch (cascade).
await this.prisma.$transaction(async (tx) => {
await this.stock.returnProductionInputs(
tx,
id,
before.lotNumber,
actingUserId,
);
await tx.productionBatch.delete({ where: { id } });
});
await this.auditLog.record({
entityType: 'ProductionBatch',
entityId: id,
action: 'DELETE',
userId: actingUserId,
beforeData: before,
});
await this.stock.notify({
bottlingBatchIds: [],
productIds: [before.productId],
});
return { id };
}
}
@@ -1,5 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNumber, IsOptional, IsString } from 'class-validator';
import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator';
import { ProductKind, ShopCategory } from '../../../generated/prisma';
export class CreateProductDto {
@ApiProperty()
@@ -30,4 +31,18 @@ export class CreateProductDto {
@IsOptional()
@IsString()
salesUnit?: string;
@ApiPropertyOptional({
enum: ProductKind,
description:
'BOTTLED_HONEY: Bestand je Abfüllung; MANUFACTURED: Bestand je Herstellungscharge',
})
@IsOptional()
@IsEnum(ProductKind)
kind?: ProductKind;
@ApiPropertyOptional({ enum: ShopCategory })
@IsOptional()
@IsEnum(ShopCategory)
shopCategory?: ShopCategory;
}
@@ -1,4 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { Product, ProductStatus } from '../../generated/prisma';
@@ -49,6 +53,9 @@ export class ProductsService {
actingUserId: string,
): Promise<Product> {
const before = await this.findById(id);
if (dto.kind !== undefined && dto.kind !== before.kind) {
await this.assertKindChangeable(id);
}
const product = await this.prisma.product.update({
where: { id },
data: { ...dto },
@@ -63,4 +70,21 @@ export class ProductsService {
});
return product;
}
/**
* Where an article's stock lives follows from its kind - bottlings for
* honey, production batches for everything else. Switching the kind while
* batches exist would leave that stock in the wrong place.
*/
private async assertKindChangeable(id: string): Promise<void> {
const [bottlings, productionBatches] = await Promise.all([
this.prisma.bottlingBatch.count({ where: { productId: id } }),
this.prisma.productionBatch.count({ where: { productId: id } }),
]);
if (bottlings > 0 || productionBatches > 0) {
throw new BadRequestException(
'Art des Artikels kann nicht geändert werden, solange Abfüllungen oder Herstellungschargen existieren',
);
}
}
}
@@ -0,0 +1,51 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsEnum,
IsInt,
IsNotEmpty,
IsOptional,
IsPositive,
IsString,
MaxLength,
} from 'class-validator';
import { RawMaterialKind } from '../../../generated/prisma';
export class CreateRawMaterialLotDto {
@ApiProperty({ enum: RawMaterialKind })
@IsEnum(RawMaterialKind)
kind!: RawMaterialKind;
@ApiProperty()
@IsString()
@IsNotEmpty()
@MaxLength(100)
lotNumber!: string;
@ApiProperty({ description: 'Gewinnungsdatum' })
@IsDateString()
date!: string;
@ApiProperty({ description: 'Gewonnene Menge in Gramm' })
@IsInt()
@IsPositive()
quantity!: number;
@ApiPropertyOptional({ description: 'Stand, von dem der Rohstoff stammt' })
@IsOptional()
@IsString()
apiaryId?: string;
@ApiPropertyOptional({
description: 'Ernte, bei der er anfiel (z. B. Entdeckelungswachs)',
})
@IsOptional()
@IsString()
harvestId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
}
@@ -0,0 +1,54 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsInt,
IsNotEmpty,
IsOptional,
IsPositive,
IsString,
MaxLength,
ValidateIf,
} from 'class-validator';
/**
* Corrects a raw material lot entered with a typo. The kind stays fixed -
* wax that went into candles does not turn into propolis afterwards.
* `apiaryId: null` / `harvestId: null` remove the origin.
*/
export class UpdateRawMaterialLotDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(100)
lotNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
date?: string;
@ApiPropertyOptional({ description: 'Gewonnene Menge in Gramm' })
@IsOptional()
@IsInt()
@IsPositive()
quantity?: number;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@ValidateIf((_object, value) => value !== null)
@IsString()
apiaryId?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@ValidateIf((_object, value) => value !== null)
@IsString()
harvestId?: string | null;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
}
@@ -0,0 +1,106 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
import { StockService } from '../stock/stock.service';
import { WithdrawStockDto } from '../stock/dto/withdraw-stock.dto';
import { CorrectStockDto } from '../stock/dto/correct-stock.dto';
import { RawMaterialKind } from '../../generated/prisma';
import { CreateRawMaterialLotDto } from './dto/create-raw-material-lot.dto';
import { UpdateRawMaterialLotDto } from './dto/update-raw-material-lot.dto';
import { RawMaterialsService } from './raw-materials.service';
const KINDS = Object.values(RawMaterialKind) as string[];
@ApiTags('raw-materials')
@ApiBearerAuth()
@Controller('raw-material-lots')
export class RawMaterialsController {
constructor(
private readonly lots: RawMaterialsService,
private readonly stock: StockService,
) {}
@RequirePermission('raw-material:read')
@Get()
findAll(@Query('kind') kind?: string) {
return this.lots.findAll({
kind:
kind && KINDS.includes(kind) ? (kind as RawMaterialKind) : undefined,
});
}
@RequirePermission('raw-material:read')
@Get(':id')
findOne(@Param('id') id: string) {
return this.lots.findById(id);
}
@RequirePermission('raw-material:read')
@Get(':id/stock')
async stockOf(@Param('id') id: string) {
await this.lots.findById(id);
return this.stock.stockOf({ kind: 'raw', id });
}
@RequirePermission('raw-material:create')
@Idempotent()
@Post()
create(
@Body() dto: CreateRawMaterialLotDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.lots.create(dto, actor.id);
}
@RequirePermission('raw-material:withdraw')
@Idempotent()
@Post(':id/withdrawals')
async withdraw(
@Param('id') id: string,
@Body() dto: WithdrawStockDto,
@CurrentUser() actor: AuthenticatedUser,
) {
await this.lots.findById(id);
return this.stock.withdraw({ kind: 'raw', id }, dto, actor.id);
}
@RequirePermission('raw-material:withdraw')
@Idempotent()
@Post(':id/corrections')
async correct(
@Param('id') id: string,
@Body() dto: CorrectStockDto,
@CurrentUser() actor: AuthenticatedUser,
) {
await this.lots.findById(id);
return this.stock.correct({ kind: 'raw', id }, dto, actor.id);
}
@RequirePermission('raw-material:update')
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateRawMaterialLotDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.lots.update(id, dto, actor.id);
}
@RequirePermission('raw-material:delete')
@Delete(':id')
remove(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.lots.remove(id, actor.id);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { StockModule } from '../stock/stock.module';
import { RawMaterialsController } from './raw-materials.controller';
import { RawMaterialsService } from './raw-materials.service';
@Module({
imports: [StockModule],
controllers: [RawMaterialsController],
providers: [RawMaterialsService],
})
export class RawMaterialsModule {}
@@ -0,0 +1,152 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { StockService } from '../stock/stock.service';
import { RawMaterialKind } from '../../generated/prisma';
import { CreateRawMaterialLotDto } from './dto/create-raw-material-lot.dto';
import { UpdateRawMaterialLotDto } from './dto/update-raw-material-lot.dto';
const include = {
apiary: { select: { id: true, name: true } },
harvest: { select: { id: true, date: true } },
// Forward traceability: which production batches this lot went into.
usedIn: {
select: {
id: true,
quantity: true,
productionBatch: {
select: {
id: true,
lotNumber: true,
product: { select: { id: true, name: true } },
},
},
},
orderBy: { createdAt: 'desc' as const },
},
} as const;
export interface RawMaterialLotFilter {
kind?: RawMaterialKind;
}
/**
* Raw material lots (beeswax, propolis) in grams. Their stock lives in the
* same movement ledger as bottlings and production batches: withdrawals and
* inventory counts by hand, consumption when a production batch is made.
*/
@Injectable()
export class RawMaterialsService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly stock: StockService,
) {}
async findAll(filter: RawMaterialLotFilter = {}) {
const lots = await this.prisma.rawMaterialLot.findMany({
where: { kind: filter.kind },
include,
orderBy: { date: 'desc' },
});
const remaining = await this.stock.remainingByRawMaterialLot(
lots.map((lot) => lot.id),
);
return lots.map((lot) => ({
...lot,
remainingCount: remaining.get(lot.id) ?? lot.quantity,
}));
}
async findById(id: string) {
const lot = await this.prisma.rawMaterialLot.findUnique({
where: { id },
include,
});
if (!lot) throw new NotFoundException('Raw material lot not found');
const remaining = await this.stock.remainingByRawMaterialLot([id]);
return { ...lot, remainingCount: remaining.get(id) ?? lot.quantity };
}
async create(dto: CreateRawMaterialLotDto, actingUserId: string) {
// A duplicate lotNumber surfaces as P2002 and becomes a 409 in
// AllExceptionsFilter; an unknown apiary/harvest as a foreign-key error.
const lot = await this.prisma.rawMaterialLot.create({
data: {
kind: dto.kind,
lotNumber: dto.lotNumber,
date: new Date(dto.date),
quantity: dto.quantity,
apiaryId: dto.apiaryId,
harvestId: dto.harvestId,
notes: dto.notes,
userId: actingUserId,
},
});
await this.auditLog.record({
entityType: 'RawMaterialLot',
entityId: lot.id,
action: 'CREATE',
userId: actingUserId,
afterData: lot,
});
return this.findById(lot.id);
}
async update(id: string, dto: UpdateRawMaterialLotDto, actingUserId: string) {
const before = await this.findById(id);
await this.prisma.$transaction(async (tx) => {
if (dto.quantity !== undefined && dto.quantity !== before.quantity) {
await this.stock.assertInitialCovers(
tx,
{ kind: 'raw', id },
dto.quantity,
);
}
await tx.rawMaterialLot.update({
where: { id },
data: {
lotNumber: dto.lotNumber,
date: dto.date ? new Date(dto.date) : undefined,
quantity: dto.quantity,
apiaryId: dto.apiaryId,
harvestId: dto.harvestId,
notes: dto.notes,
},
});
});
const after = await this.findById(id);
await this.auditLog.record({
entityType: 'RawMaterialLot',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: after,
});
return after;
}
/** Only for lots entered by mistake: once anything was booked, it stays. */
async remove(id: string, actingUserId: string) {
const before = await this.findById(id);
if (await this.stock.hasMovements({ kind: 'raw', id })) {
throw new ConflictException(
'Rohstoff-Charge hat bereits Bewegungen (Verbrauch, Entnahme oder Inventur) und kann nicht gelöscht werden',
);
}
await this.prisma.rawMaterialLot.delete({ where: { id } });
await this.auditLog.record({
entityType: 'RawMaterialLot',
entityId: id,
action: 'DELETE',
userId: actingUserId,
beforeData: before,
});
return { id };
}
}
@@ -0,0 +1,72 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Max,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
/**
* One booking from the shop. Exactly one target: a bottling (honey, booked
* per vintage) or a MANUFACTURED article (allocated to its production
* batches, oldest stock first). That rule and the link of a return to its
* sale are checked per request in StockService, so a single bad entry is
* rejected on its own instead of failing the whole request.
*/
export class ShopStockRequestDto {
@ApiProperty({
description:
'Vom Shop vergebene, eindeutige ID - dieselbe ID zweimal bucht nur einmal',
})
@IsString()
@IsNotEmpty()
@MaxLength(100)
externalId!: string;
@ApiProperty({ enum: ['SALE', 'RETURN'] })
@IsIn(['SALE', 'RETURN'])
kind!: 'SALE' | 'RETURN';
@ApiPropertyOptional()
@IsOptional()
@IsString()
bottlingBatchId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
productId?: string;
@ApiProperty({ minimum: 1 })
@IsInt()
@Min(1)
@Max(10000)
quantity!: number;
@ApiPropertyOptional({
description: 'Bei RETURN: externalId des Verkaufs, der zurückgebucht wird',
})
@IsOptional()
@IsString()
@MaxLength(100)
reversesExternalId?: string;
}
export class ShopMovementsDto {
@ApiProperty({ type: [ShopStockRequestDto] })
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(100)
@ValidateNested({ each: true })
@Type(() => ShopStockRequestDto)
requests!: ShopStockRequestDto[];
}
@@ -1,17 +1,51 @@
import { Controller, Get } from '@nestjs/common';
import { Body, Controller, Get, HttpCode, Post, Req } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { ShopSyncFeed, ShopSyncService } from './shop-sync.service';
import type { ApiKeyPrincipal } from '../api-keys/api-keys.service';
import { StockService, type ShopStockResult } from '../stock/stock.service';
import { ShopMovementsDto } from './dto/shop-movements.dto';
import {
ShopSyncFeed,
ShopSyncService,
ShopSyncStock,
} from './shop-sync.service';
@ApiTags('shop-sync')
@ApiBearerAuth()
@Controller('shop-sync')
export class ShopSyncController {
constructor(private readonly shopSync: ShopSyncService) {}
constructor(
private readonly shopSync: ShopSyncService,
private readonly stock: StockService,
) {}
@RequirePermission('shop-sync:read')
@Get()
feed(): Promise<ShopSyncFeed> {
return this.shopSync.feed();
}
/** Only the remaining stock - for the shop's frequent reconcile. */
@RequirePermission('shop-sync:read')
@Get('stock')
stockLevels(): Promise<ShopSyncStock> {
return this.shopSync.stock();
}
/**
* The shop books its paid sales and its returns. Idempotent per
* externalId; each request is answered on its own (created, duplicate or
* rejected), so one bad entry never blocks the others.
*/
@RequirePermission('shop-sync:write')
@HttpCode(200)
@Post('movements')
movements(
@Body() dto: ShopMovementsDto,
@Req() request: { apiKey?: ApiKeyPrincipal },
): Promise<{ results: ShopStockResult[] }> {
return this.stock
.applyShopRequests(dto.requests, request.apiKey?.id ?? null)
.then((results) => ({ results }));
}
}
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { StockModule } from '../stock/stock.module';
import { ShopSyncController } from './shop-sync.controller';
import { ShopSyncService } from './shop-sync.service';
@Module({
imports: [StockModule],
controllers: [ShopSyncController],
providers: [ShopSyncService],
})
@@ -1,6 +1,11 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { ProductStatus } from '../../generated/prisma';
import { StockService } from '../stock/stock.service';
import {
ProductKind,
ProductStatus,
ShopCategory,
} from '../../generated/prisma';
/**
* Bumped only for breaking changes (a removed or renamed field, a changed
@@ -29,6 +34,26 @@ export interface ShopSyncProduct {
weight: number | null;
salesUnit: string | null;
status: ProductStatus;
kind: ProductKind;
shopCategory: ShopCategory;
/** Sum over the production batches; null for bottled honey (see bottlings). */
remainingCount: number | null;
}
export interface ShopSyncProductionBatch {
id: string;
productId: string;
lotNumber: string;
date: Date;
bestBeforeDate: Date | null;
quantity: number;
remainingCount: number;
}
export interface ShopSyncStock {
generatedAt: Date;
bottlings: { id: string; remainingCount: number }[];
products: { id: string; remainingCount: number }[];
}
export interface ShopSyncBottling {
@@ -39,6 +64,8 @@ export interface ShopSyncBottling {
/** Grams per jar. */
jarSize: number;
jarCount: number;
/** jarCount plus all movements (sales, returns, withdrawals, corrections). */
remainingCount: number;
productId: string | null;
/** Harvests behind this bottling, oldest first. */
harvestIds: string[];
@@ -50,6 +77,7 @@ export interface ShopSyncFeed {
harvests: ShopSyncHarvest[];
products: ShopSyncProduct[];
bottlings: ShopSyncBottling[];
productionBatches: ShopSyncProductionBatch[];
}
/**
@@ -67,10 +95,39 @@ export interface ShopSyncFeed {
*/
@Injectable()
export class ShopSyncService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly stockService: StockService,
) {}
async stock(): Promise<ShopSyncStock> {
const [bottlings, products] = await Promise.all([
this.stockService.remainingByBottling(),
this.stockService.remainingByProduct(),
]);
return {
generatedAt: new Date(),
bottlings: [...bottlings].map(([id, remainingCount]) => ({
id,
remainingCount,
})),
products: [...products].map(([id, remainingCount]) => ({
id,
remainingCount,
})),
};
}
async feed(): Promise<ShopSyncFeed> {
const [harvests, products, bottlings] = await Promise.all([
const [
harvests,
products,
bottlings,
productionBatches,
remainingByBottling,
remainingByBatch,
remainingByProduct,
] = await Promise.all([
this.prisma.harvest.findMany({
select: {
id: true,
@@ -94,6 +151,8 @@ export class ShopSyncService {
weight: true,
salesUnit: true,
status: true,
kind: true,
shopCategory: true,
},
orderBy: { name: 'asc' },
}),
@@ -121,6 +180,20 @@ export class ShopSyncService {
},
orderBy: { date: 'asc' },
}),
this.prisma.productionBatch.findMany({
select: {
id: true,
productId: true,
lotNumber: true,
date: true,
bestBeforeDate: true,
quantity: true,
},
orderBy: { date: 'asc' },
}),
this.stockService.remainingByBottling(),
this.stockService.remainingByProductionBatch(),
this.stockService.remainingByProduct(),
]);
return {
@@ -130,11 +203,23 @@ export class ShopSyncService {
...harvest,
apiaryName: apiary.name,
})),
products,
products: products.map((product) => ({
...product,
remainingCount:
product.kind === 'MANUFACTURED'
? (remainingByProduct.get(product.id) ?? 0)
: null,
})),
bottlings: bottlings.map(({ honeyBatch, ...bottling }) => ({
...bottling,
remainingCount:
remainingByBottling.get(bottling.id) ?? bottling.jarCount,
harvestIds: honeyBatch.extraction.harvests.map((h) => h.harvestId),
})),
productionBatches: productionBatches.map((batch) => ({
...batch,
remainingCount: remainingByBatch.get(batch.id) ?? batch.quantity,
})),
};
}
}
@@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsInt, IsNotEmpty, IsString, MaxLength, Min } from 'class-validator';
/**
* Inventory count. The counted quantity is entered, not the difference: the
* person at the shelf knows how many jars are there, not how far the books
* are off. The service books the difference.
*/
export class CorrectStockDto {
@ApiProperty({ description: 'Gezählter Bestand' })
@IsInt()
@Min(0)
countedQuantity!: number;
@ApiProperty({ description: 'Pflicht: warum weicht der Bestand ab?' })
@IsString()
@IsNotEmpty()
@MaxLength(500)
notes!: string;
}
@@ -0,0 +1,27 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsEnum,
IsInt,
IsOptional,
IsPositive,
IsString,
MaxLength,
} from 'class-validator';
import { WithdrawalReason } from '../../../generated/prisma';
export class WithdrawStockDto {
@ApiProperty({ description: 'Anzahl Gläser bzw. Stück' })
@IsInt()
@IsPositive()
quantity!: number;
@ApiProperty({ enum: WithdrawalReason })
@IsEnum(WithdrawalReason)
reason!: WithdrawalReason;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
notes?: string;
}
@@ -0,0 +1,148 @@
import {
allocateReturn,
allocateSale,
fifoOrder,
NoBatchError,
ReturnExceedsSaleError,
summarizeStock,
type FifoBatch,
} from './stock-ledger';
function batch(
id: string,
remaining: number,
date: string,
bestBefore: string | null = null,
): FifoBatch {
return {
id,
remaining,
date: new Date(date),
bestBeforeDate: bestBefore ? new Date(bestBefore) : null,
};
}
describe('summarizeStock', () => {
it('derives the remaining stock from the initial quantity and the ledger', () => {
const summary = summarizeStock(120, [
{ type: 'SHOP_SALE', quantity: -30 },
{ type: 'SHOP_RETURN', quantity: 2 },
{ type: 'WITHDRAWAL', quantity: -10 },
{ type: 'CORRECTION', quantity: -1 },
]);
expect(summary).toEqual({
initial: 120,
soldInShop: 28,
withdrawn: 10,
corrected: -1,
consumed: 0,
remaining: 81,
});
});
it('counts raw material used in production, net of give-backs', () => {
const summary = summarizeStock(4200, [
{ type: 'CONSUMPTION', quantity: -800 },
{ type: 'CONSUMPTION', quantity: -300 },
{ type: 'CONSUMPTION', quantity: 300 },
]);
expect(summary.consumed).toBe(800);
expect(summary.remaining).toBe(3400);
});
it('equals the initial quantity without movements', () => {
expect(summarizeStock(50, []).remaining).toBe(50);
});
});
describe('fifoOrder', () => {
it('sorts by best-before date first, batches without one last', () => {
const ordered = fifoOrder([
batch('none', 5, '2026-01-01'),
batch('late', 5, '2026-01-01', '2027-06-01'),
batch('early', 5, '2026-03-01', '2027-01-01'),
]);
expect(ordered.map((b) => b.id)).toEqual(['early', 'late', 'none']);
});
it('falls back to the production date', () => {
const ordered = fifoOrder([
batch('may', 5, '2026-05-01'),
batch('march', 5, '2026-03-01'),
]);
expect(ordered.map((b) => b.id)).toEqual(['march', 'may']);
});
});
describe('allocateSale', () => {
it('takes from the oldest batch first and spills over to the next', () => {
const allocations = allocateSale(
[batch('new', 10, '2026-05-01'), batch('old', 3, '2026-02-01')],
5,
);
expect(allocations).toEqual([
{ batchId: 'old', quantity: 3 },
{ batchId: 'new', quantity: 2 },
]);
});
it('skips batches that are already empty or negative', () => {
const allocations = allocateSale(
[
batch('empty', 0, '2026-01-01'),
batch('negative', -2, '2026-02-01'),
batch('full', 4, '2026-03-01'),
],
1,
);
expect(allocations).toEqual([{ batchId: 'full', quantity: 1 }]);
});
it('books an oversell on the newest batch instead of refusing it', () => {
const allocations = allocateSale(
[batch('old', 2, '2026-01-01'), batch('new', 1, '2026-04-01')],
5,
);
expect(allocations).toEqual([
{ batchId: 'old', quantity: 2 },
{ batchId: 'new', quantity: 3 },
]);
});
it('refuses a sale for an article without any batch', () => {
expect(() => allocateSale([], 1)).toThrow(NoBatchError);
});
});
describe('allocateReturn', () => {
const sold = [
{ batchId: 'old', quantity: 3 },
{ batchId: 'new', quantity: 2 },
];
it('returns a full sale onto exactly the batches it came from', () => {
expect(allocateReturn(sold, new Map(), 5)).toEqual([
{ batchId: 'new', quantity: 2 },
{ batchId: 'old', quantity: 3 },
]);
});
it('books a partial return on the batch taken last first', () => {
expect(allocateReturn(sold, new Map(), 1)).toEqual([
{ batchId: 'new', quantity: 1 },
]);
});
it('accounts for earlier partial returns', () => {
const earlier = new Map([['new', 2]]);
expect(allocateReturn(sold, earlier, 2)).toEqual([
{ batchId: 'old', quantity: 2 },
]);
});
it('refuses to return more than was sold', () => {
expect(() => allocateReturn(sold, new Map([['old', 3]]), 3)).toThrow(
ReturnExceedsSaleError,
);
});
});
+170
View File
@@ -0,0 +1,170 @@
/**
* Pure stock arithmetic for the batch movement ledger - no database, so the
* rules that decide where a jar is booked can be tested on their own.
*
* Remaining stock of a batch = initial quantity (jarCount of a bottling,
* quantity of a production batch) + the sum of its signed movements. It is
* always derived, never stored, so it cannot drift from the ledger.
*/
export type MovementType =
'SHOP_SALE' | 'SHOP_RETURN' | 'WITHDRAWAL' | 'CORRECTION' | 'CONSUMPTION';
export interface LedgerEntry {
type: MovementType;
/** Signed: negative takes stock out, positive puts it back. */
quantity: number;
}
export interface StockSummary {
/** Bottled or produced. */
initial: number;
/** Net shop sales (sales minus shop returns). */
soldInShop: number;
/** Withdrawn in ApiCore (farm shop, own use, breakage, ...). */
withdrawn: number;
/** Net inventory corrections (positive = more found than booked). */
corrected: number;
/** Raw material used up in production batches (net of give-backs). */
consumed: number;
remaining: number;
}
export function summarizeStock(
initial: number,
entries: readonly LedgerEntry[],
): StockSummary {
let soldInShop = 0;
let withdrawn = 0;
let corrected = 0;
let consumed = 0;
for (const entry of entries) {
if (entry.type === 'SHOP_SALE' || entry.type === 'SHOP_RETURN') {
soldInShop -= entry.quantity;
} else if (entry.type === 'WITHDRAWAL') {
withdrawn -= entry.quantity;
} else if (entry.type === 'CONSUMPTION') {
consumed -= entry.quantity;
} else {
corrected += entry.quantity;
}
}
return {
initial,
soldInShop,
withdrawn,
corrected,
consumed,
remaining: initial - soldInShop - withdrawn - consumed + corrected,
};
}
export interface FifoBatch {
id: string;
remaining: number;
bestBeforeDate: Date | null;
date: Date;
}
export interface Allocation {
batchId: string;
/** Always positive: how many units this batch gives or takes back. */
quantity: number;
}
/**
* Oldest stock first: earliest best-before date first (batches without one
* last), then earliest production date, then id for a stable order.
*/
export function fifoOrder<T extends FifoBatch>(batches: readonly T[]): T[] {
return [...batches].sort((a, b) => {
const aBest = a.bestBeforeDate?.getTime() ?? Number.POSITIVE_INFINITY;
const bBest = b.bestBeforeDate?.getTime() ?? Number.POSITIVE_INFINITY;
if (aBest !== bBest) return aBest - bBest;
const byDate = a.date.getTime() - b.date.getTime();
if (byDate !== 0) return byDate;
return a.id.localeCompare(b.id);
});
}
export class NoBatchError extends Error {
constructor() {
super('Für diesen Artikel gibt es keine Herstellungscharge');
this.name = 'NoBatchError';
}
}
/**
* Splits a shop sale across batches, oldest stock first.
*
* A sale that exceeds the total remaining stock is still booked - the goods
* are sold already, refusing the booking would only hide the gap. The
* overflow goes to the newest batch (by production date), whose remaining
* stock turns negative and is flagged for review in the UI.
*/
export function allocateSale(
batches: readonly FifoBatch[],
quantity: number,
): Allocation[] {
if (batches.length === 0) throw new NoBatchError();
const allocations = new Map<string, number>();
let open = quantity;
for (const batch of fifoOrder(batches)) {
if (open === 0) break;
const take = Math.min(open, Math.max(batch.remaining, 0));
if (take > 0) {
allocations.set(batch.id, take);
open -= take;
}
}
if (open > 0) {
const newest = [...batches].sort(
(a, b) => b.date.getTime() - a.date.getTime() || b.id.localeCompare(a.id),
)[0];
allocations.set(newest.id, (allocations.get(newest.id) ?? 0) + open);
}
return [...allocations].map(([batchId, qty]) => ({ batchId, quantity: qty }));
}
export class ReturnExceedsSaleError extends Error {
constructor(returnable: number) {
super(
`Rückbuchung übersteigt den Verkauf (noch rückbuchbar: ${returnable})`,
);
this.name = 'ReturnExceedsSaleError';
}
}
/**
* Puts a return back onto exactly the batches the sale was taken from. A
* partial return is booked in reverse order (the batch taken last gets its
* units back first), so repeated partial returns never exceed what a batch
* gave.
*
* @param sold per-batch amounts of the original sale, in allocation order
* @param alreadyReturned per-batch amounts returned earlier for this sale
*/
export function allocateReturn(
sold: readonly Allocation[],
alreadyReturned: ReadonlyMap<string, number>,
quantity: number,
): Allocation[] {
const returnable = sold.map((entry) => ({
batchId: entry.batchId,
quantity: entry.quantity - (alreadyReturned.get(entry.batchId) ?? 0),
}));
const total = returnable.reduce((sum, entry) => sum + entry.quantity, 0);
if (quantity > total) throw new ReturnExceedsSaleError(total);
const allocations: Allocation[] = [];
let open = quantity;
for (const entry of [...returnable].reverse()) {
if (open === 0) break;
const give = Math.min(open, entry.quantity);
if (give > 0) {
allocations.push({ batchId: entry.batchId, quantity: give });
open -= give;
}
}
return allocations;
}
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { StockService } from './stock.service';
@Module({
providers: [StockService],
exports: [StockService],
})
export class StockModule {}
+828
View File
@@ -0,0 +1,828 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { Prisma, WithdrawalReason } from '../../generated/prisma';
import {
allocateReturn,
allocateSale,
NoBatchError,
ReturnExceedsSaleError,
summarizeStock,
type Allocation,
type StockSummary,
} from './stock-ledger';
type Tx = Prisma.TransactionClient;
/** A batch whose stock is tracked: a honey bottling, a production batch or a raw material lot. */
export type StockTarget =
| { kind: 'bottling'; id: string }
| { kind: 'production'; id: string }
| { kind: 'raw'; id: string };
/** Raw material going into a production batch. Quantities in grams. */
export interface ProductionInputRequest {
rawMaterialLotId: string;
quantity: number;
}
/** What changed - the payload of the stock.changed webhook. */
export interface StockChange {
bottlingBatchIds: string[];
productIds: string[];
}
const movementSelect = {
id: true,
type: true,
quantity: true,
reason: true,
notes: true,
apiKeyId: true,
createdAt: true,
user: { select: { id: true, name: true } },
shopRequest: { select: { externalId: true, kind: true } },
} satisfies Prisma.BatchMovementSelect;
export type StockMovementView = Prisma.BatchMovementGetPayload<{
select: typeof movementSelect;
}>;
export interface BatchStock {
summary: StockSummary;
movements: StockMovementView[];
}
export interface ShopStockRequestInput {
externalId: string;
kind: 'SALE' | 'RETURN';
bottlingBatchId?: string;
productId?: string;
quantity: number;
reversesExternalId?: string;
}
export interface ShopStockAllocation {
batchType: 'BOTTLING' | 'PRODUCTION';
batchId: string;
lotNumber: string;
quantity: number;
}
export interface ShopStockResult {
externalId: string;
status: 'created' | 'duplicate' | 'rejected';
error?: string;
allocations: ShopStockAllocation[];
/** Remaining stock of the target after the booking; null when rejected. */
remainingCount: number | null;
}
/** A shop booking that cannot be accepted as sent - reported, never retried. */
class RejectedRequest extends Error {}
function isUniqueViolation(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'P2002'
);
}
/**
* The batch movement ledger: remaining stock, withdrawals, inventory
* corrections and the shop's sales and returns.
*
* Every write runs in a transaction that first locks one row - the bottling
* for honey, the article (Product) for production batches - so two bookings
* on the same stock are serialized and a "not more than the remaining stock"
* check cannot be raced. Production batches lock their article, not
* themselves, because a shop sale allocates across all batches of the
* article at once.
*/
@Injectable()
export class StockService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
) {}
// -------------------------------------------------------------------------
// Reading
// -------------------------------------------------------------------------
/** Remaining stock per bottling; all bottlings when `ids` is omitted. */
async remainingByBottling(ids?: string[]): Promise<Map<string, number>> {
return this.remainingFor('bottling', ids);
}
/** Remaining stock per production batch; all when `ids` is omitted. */
async remainingByProductionBatch(
ids?: string[],
): Promise<Map<string, number>> {
return this.remainingFor('production', ids);
}
/** Remaining grams per raw material lot; all when `ids` is omitted. */
async remainingByRawMaterialLot(
ids?: string[],
): Promise<Map<string, number>> {
return this.remainingFor('raw', ids);
}
/** Remaining stock per MANUFACTURED article (sum of its batches). */
async remainingByProduct(ids?: string[]): Promise<Map<string, number>> {
const batches = await this.prisma.productionBatch.findMany({
where: {
productId: ids ? { in: ids } : undefined,
product: { kind: 'MANUFACTURED' },
},
select: { id: true, productId: true },
});
const products = await this.prisma.product.findMany({
where: { id: ids ? { in: ids } : undefined, kind: 'MANUFACTURED' },
select: { id: true },
});
const perBatch = await this.remainingByProductionBatch(
batches.map((b) => b.id),
);
const result = new Map<string, number>(products.map((p) => [p.id, 0]));
for (const batch of batches) {
result.set(
batch.productId,
(result.get(batch.productId) ?? 0) + (perBatch.get(batch.id) ?? 0),
);
}
return result;
}
async stockOf(target: StockTarget): Promise<BatchStock> {
const initial = await this.initialQuantity(this.prisma, target);
const movements = await this.prisma.batchMovement.findMany({
where: this.movementWhere(target),
select: movementSelect,
orderBy: { createdAt: 'desc' },
});
return { summary: summarizeStock(initial, movements), movements };
}
async hasMovements(target: StockTarget): Promise<boolean> {
const count = await this.prisma.batchMovement.count({
where: this.movementWhere(target),
});
return count > 0;
}
// -------------------------------------------------------------------------
// Manual bookings in ApiCore
// -------------------------------------------------------------------------
/**
* Takes units out of a batch (farm shop, own use, breakage, sample, ...).
* Never more than the remaining stock: unlike a shop sale, a withdrawal is
* entered by a person who can count again.
*/
async withdraw(
target: StockTarget,
input: { quantity: number; reason: WithdrawalReason; notes?: string },
actingUserId: string,
): Promise<BatchStock> {
const movement = await this.prisma.$transaction(async (tx) => {
await this.lock(tx, target);
const remaining = await this.remainingInTx(tx, target);
if (input.quantity > remaining) {
throw new BadRequestException(
`Entnahme übersteigt den Rest (${remaining})`,
);
}
return tx.batchMovement.create({
data: {
...this.movementTarget(target),
type: 'WITHDRAWAL',
quantity: -input.quantity,
reason: input.reason,
notes: input.notes,
userId: actingUserId,
},
});
});
await this.recordAndNotify(target, movement, actingUserId);
return this.stockOf(target);
}
/**
* Inventory count: books the difference between the counted and the
* booked stock as one correction, so the ledger explains every jump.
*/
async correct(
target: StockTarget,
input: { countedQuantity: number; notes: string },
actingUserId: string,
): Promise<BatchStock> {
const movement = await this.prisma.$transaction(async (tx) => {
await this.lock(tx, target);
const remaining = await this.remainingInTx(tx, target);
const difference = input.countedQuantity - remaining;
if (difference === 0) {
throw new BadRequestException(
'Gezählter Bestand entspricht dem gebuchten Rest',
);
}
return tx.batchMovement.create({
data: {
...this.movementTarget(target),
type: 'CORRECTION',
quantity: difference,
notes: input.notes,
userId: actingUserId,
},
});
});
await this.recordAndNotify(target, movement, actingUserId);
return this.stockOf(target);
}
// -------------------------------------------------------------------------
// Production: raw material in, production batch out
// -------------------------------------------------------------------------
/**
* Books the raw material a production batch was made from - inside the
* caller's transaction, so the batch and its consumption exist together
* or not at all. Never more than a lot's remaining stock: the person
* producing can weigh again. Lots are locked in id order, so two
* productions drawing on the same lots cannot deadlock.
*/
async consumeForProduction(
tx: Tx,
productionBatchId: string,
lotNumber: string,
inputs: ProductionInputRequest[],
actingUserId: string,
): Promise<void> {
const perLot = new Map<string, number>();
for (const input of inputs) {
perLot.set(
input.rawMaterialLotId,
(perLot.get(input.rawMaterialLotId) ?? 0) + input.quantity,
);
}
for (const [rawMaterialLotId, quantity] of [...perLot].sort(([a], [b]) =>
a.localeCompare(b),
)) {
const target: StockTarget = { kind: 'raw', id: rawMaterialLotId };
await this.lock(tx, target);
const remaining = await this.remainingInTx(tx, target);
if (quantity > remaining) {
const lot = await tx.rawMaterialLot.findUnique({
where: { id: rawMaterialLotId },
select: { lotNumber: true },
});
throw new BadRequestException(
`Rohstoff ${lot?.lotNumber ?? rawMaterialLotId}: nur noch ${remaining} g vorhanden, ${quantity} g angegeben`,
);
}
await tx.productionInput.create({
data: { productionBatchId, rawMaterialLotId, quantity },
});
await tx.batchMovement.create({
data: {
rawMaterialLotId,
type: 'CONSUMPTION',
quantity: -quantity,
notes: `Herstellung ${lotNumber}`,
userId: actingUserId,
},
});
}
}
/**
* Guards a corrected initial quantity (produced pieces, harvested grams):
* it may not drop below what was already sold, withdrawn or consumed -
* that would turn the remaining stock negative without anyone booking a
* movement. Locks the stock row; call inside the transaction that writes
* the new quantity. A real shortfall belongs in an inventory correction.
*/
async assertInitialCovers(
tx: Tx,
target: StockTarget,
newInitial: number,
): Promise<void> {
await this.lock(tx, target);
const sums = await this.sumMovements(tx, target.kind, [target.id]);
const moved = sums.get(target.id) ?? 0;
if (newInitial + moved < 0) {
throw new BadRequestException(
`Menge ${newInitial} ist kleiner als bereits verbucht (${-moved}) - Fehlmengen bitte über "Inventur korrigieren" buchen`,
);
}
}
/**
* Gives the raw material of a production batch back - only for a batch
* deleted as a typo. The give-back is a movement of its own, so the raw
* lot's ledger still shows that it was booked and returned.
*/
async returnProductionInputs(
tx: Tx,
productionBatchId: string,
lotNumber: string,
actingUserId: string,
): Promise<void> {
const inputs = await tx.productionInput.findMany({
where: { productionBatchId },
});
for (const input of inputs) {
await tx.batchMovement.create({
data: {
rawMaterialLotId: input.rawMaterialLotId,
type: 'CONSUMPTION',
quantity: input.quantity,
notes: `Rückgabe: Charge ${lotNumber} gelöscht`,
userId: actingUserId,
},
});
}
}
// -------------------------------------------------------------------------
// Shop bookings
// -------------------------------------------------------------------------
/**
* Books the shop's sales and returns. Each request runs on its own, so one
* invalid request does not hold back the rest of the batch. A request with
* a known externalId books nothing and reports the original result.
*/
async applyShopRequests(
requests: ShopStockRequestInput[],
apiKeyId: string | null,
): Promise<ShopStockResult[]> {
const results: ShopStockResult[] = [];
const change: StockChange = { bottlingBatchIds: [], productIds: [] };
for (const request of requests) {
const result = await this.applyShopRequest(request, apiKeyId);
results.push(result);
if (result.status === 'created') {
if (request.bottlingBatchId) {
change.bottlingBatchIds.push(request.bottlingBatchId);
}
if (request.productId) change.productIds.push(request.productId);
}
}
if (change.bottlingBatchIds.length > 0 || change.productIds.length > 0) {
await this.notify(change);
}
return results;
}
private async applyShopRequest(
request: ShopStockRequestInput,
apiKeyId: string | null,
): Promise<ShopStockResult> {
const known = await this.findShopResult(request.externalId);
if (known) return known;
try {
await this.prisma.$transaction(async (tx) => {
this.assertShapeOf(request);
const created = await tx.shopStockRequest.create({
data: {
externalId: request.externalId,
kind: request.kind,
bottlingBatchId: request.bottlingBatchId,
productId: request.productId,
quantity: request.quantity,
reversesExternalId: request.reversesExternalId,
apiKeyId,
},
});
const allocations = request.bottlingBatchId
? await this.bookOnBottling(tx, request)
: await this.bookOnProduct(tx, request);
await tx.batchMovement.createMany({
data: allocations.map((allocation) => ({
...(request.bottlingBatchId
? { bottlingBatchId: allocation.batchId }
: { productionBatchId: allocation.batchId }),
type:
request.kind === 'SALE'
? ('SHOP_SALE' as const)
: ('SHOP_RETURN' as const),
quantity:
request.kind === 'SALE'
? -allocation.quantity
: allocation.quantity,
shopRequestId: created.id,
apiKeyId,
})),
});
});
} catch (error: unknown) {
if (error instanceof RejectedRequest) {
return {
externalId: request.externalId,
status: 'rejected',
error: error.message,
allocations: [],
remainingCount: null,
};
}
// The same externalId sent twice in parallel: the other request won.
if (isUniqueViolation(error)) {
const winner = await this.findShopResult(request.externalId);
if (winner) return winner;
}
throw error;
}
const result = await this.findShopResult(request.externalId);
if (!result) throw new Error('Shop booking vanished after commit');
return { ...result, status: 'created' };
}
private assertShapeOf(request: ShopStockRequestInput): void {
if (!request.bottlingBatchId === !request.productId) {
throw new RejectedRequest(
'Genau eines von bottlingBatchId oder productId angeben',
);
}
if (request.kind === 'RETURN' && !request.reversesExternalId) {
throw new RejectedRequest('Rückbuchung ohne reversesExternalId');
}
}
private async bookOnBottling(
tx: Tx,
request: ShopStockRequestInput,
): Promise<Allocation[]> {
const bottlingBatchId = request.bottlingBatchId!;
const exists = await tx.bottlingBatch.findUnique({
where: { id: bottlingBatchId },
select: { id: true },
});
if (!exists) throw new RejectedRequest('Abfüllung unbekannt');
await this.lock(tx, { kind: 'bottling', id: bottlingBatchId });
if (request.kind === 'SALE') {
return [{ batchId: bottlingBatchId, quantity: request.quantity }];
}
const { sold, returned } = await this.saleToReverse(tx, request, {
bottlingBatchId,
});
return this.allocateReturnOrReject(sold, returned, request.quantity);
}
private async bookOnProduct(
tx: Tx,
request: ShopStockRequestInput,
): Promise<Allocation[]> {
const productId = request.productId!;
const product = await tx.product.findUnique({
where: { id: productId },
select: { kind: true },
});
if (!product) throw new RejectedRequest('Artikel unbekannt');
if (product.kind !== 'MANUFACTURED') {
throw new RejectedRequest(
'Honig wird je Abfüllung gebucht (bottlingBatchId), nicht je Artikel',
);
}
await this.lockProduct(tx, productId);
if (request.kind === 'RETURN') {
const { sold, returned } = await this.saleToReverse(tx, request, {
productId,
});
return this.allocateReturnOrReject(sold, returned, request.quantity);
}
const batches = await tx.productionBatch.findMany({
where: { productId },
select: { id: true, quantity: true, date: true, bestBeforeDate: true },
});
const sums = await this.sumMovements(
tx,
'production',
batches.map((b) => b.id),
);
try {
return allocateSale(
batches.map((b) => ({
id: b.id,
date: b.date,
bestBeforeDate: b.bestBeforeDate,
remaining: b.quantity + (sums.get(b.id) ?? 0),
})),
request.quantity,
);
} catch (error: unknown) {
if (error instanceof NoBatchError) {
throw new RejectedRequest(error.message);
}
throw error;
}
}
/**
* The sale a return refers to, as per-batch amounts in allocation order,
* plus what earlier returns already gave back per batch.
*/
private async saleToReverse(
tx: Tx,
request: ShopStockRequestInput,
target: { bottlingBatchId: string } | { productId: string },
): Promise<{ sold: Allocation[]; returned: Map<string, number> }> {
const sale = await tx.shopStockRequest.findUnique({
where: { externalId: request.reversesExternalId! },
include: { movements: { orderBy: { id: 'asc' } } },
});
const sameTarget =
sale !== null &&
('bottlingBatchId' in target
? sale.bottlingBatchId === target.bottlingBatchId
: sale.productId === target.productId);
if (!sale || sale.kind !== 'SALE' || !sameTarget) {
throw new RejectedRequest(
'Rückbuchung verweist auf keinen passenden Verkauf',
);
}
const sold = sale.movements.map((m) => ({
batchId: (m.bottlingBatchId ?? m.productionBatchId)!,
quantity: -m.quantity,
}));
const earlier = await tx.batchMovement.findMany({
where: {
shopRequest: { reversesExternalId: sale.externalId, kind: 'RETURN' },
},
select: {
bottlingBatchId: true,
productionBatchId: true,
quantity: true,
},
});
const returned = new Map<string, number>();
for (const movement of earlier) {
const batchId = (movement.bottlingBatchId ?? movement.productionBatchId)!;
returned.set(batchId, (returned.get(batchId) ?? 0) + movement.quantity);
}
return { sold, returned };
}
private allocateReturnOrReject(
sold: Allocation[],
returned: Map<string, number>,
quantity: number,
): Allocation[] {
try {
return allocateReturn(sold, returned, quantity);
} catch (error: unknown) {
if (error instanceof ReturnExceedsSaleError) {
throw new RejectedRequest(error.message);
}
throw error;
}
}
/** The stored outcome of a shop request, or null if it is unknown. */
private async findShopResult(
externalId: string,
): Promise<ShopStockResult | null> {
const request = await this.prisma.shopStockRequest.findUnique({
where: { externalId },
include: {
movements: {
orderBy: { id: 'asc' },
include: {
bottlingBatch: { select: { lotNumber: true } },
productionBatch: { select: { lotNumber: true } },
},
},
},
});
if (!request) return null;
const remaining = request.bottlingBatchId
? (await this.remainingByBottling([request.bottlingBatchId])).get(
request.bottlingBatchId,
)
: (await this.remainingByProduct([request.productId!])).get(
request.productId!,
);
return {
externalId,
status: 'duplicate',
allocations: request.movements.map((m) => ({
batchType: m.bottlingBatchId ? 'BOTTLING' : 'PRODUCTION',
batchId: (m.bottlingBatchId ?? m.productionBatchId)!,
lotNumber: (m.bottlingBatch?.lotNumber ??
m.productionBatch?.lotNumber)!,
quantity: Math.abs(m.quantity),
})),
remainingCount: remaining ?? 0,
};
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
/** Tells subscribers (the shop) that stock changed; they fetch the numbers. */
async notify(change: StockChange): Promise<void> {
// Raw material is not sold in the shop - nothing to tell.
if (
change.bottlingBatchIds.length === 0 &&
change.productIds.length === 0
) {
return;
}
await this.webhooks.dispatch('stock.changed', {
bottlingBatchIds: [...new Set(change.bottlingBatchIds)],
productIds: [...new Set(change.productIds)],
});
}
/** The stock.changed payload for one batch. */
async changeFor(target: StockTarget): Promise<StockChange> {
if (target.kind === 'bottling') {
return { bottlingBatchIds: [target.id], productIds: [] };
}
if (target.kind === 'raw') {
return { bottlingBatchIds: [], productIds: [] };
}
const batch = await this.prisma.productionBatch.findUnique({
where: { id: target.id },
select: { productId: true },
});
return { bottlingBatchIds: [], productIds: batch ? [batch.productId] : [] };
}
private async recordAndNotify(
target: StockTarget,
movement: { id: string },
actingUserId: string,
): Promise<void> {
await this.auditLog.record({
entityType: 'BatchMovement',
entityId: movement.id,
action: 'CREATE',
userId: actingUserId,
afterData: movement,
});
await this.notify(await this.changeFor(target));
}
private async remainingFor(
kind: StockTarget['kind'],
ids?: string[],
): Promise<Map<string, number>> {
const where = { id: ids ? { in: ids } : undefined };
const initial =
kind === 'bottling'
? (
await this.prisma.bottlingBatch.findMany({
where,
select: { id: true, jarCount: true },
})
).map((row) => ({ id: row.id, initial: row.jarCount }))
: kind === 'production'
? (
await this.prisma.productionBatch.findMany({
where,
select: { id: true, quantity: true },
})
).map((row) => ({ id: row.id, initial: row.quantity }))
: (
await this.prisma.rawMaterialLot.findMany({
where,
select: { id: true, quantity: true },
})
).map((row) => ({ id: row.id, initial: row.quantity }));
const sums = await this.sumMovements(
this.prisma,
kind,
initial.map((row) => row.id),
);
return new Map(
initial.map((row) => [row.id, row.initial + (sums.get(row.id) ?? 0)]),
);
}
private async sumMovements(
db: Tx | PrismaService,
kind: StockTarget['kind'],
ids: string[],
): Promise<Map<string, number>> {
if (ids.length === 0) return new Map();
if (kind === 'bottling') {
const rows = await db.batchMovement.groupBy({
by: ['bottlingBatchId'],
where: { bottlingBatchId: { in: ids } },
_sum: { quantity: true },
});
return new Map(
rows.map((r) => [r.bottlingBatchId!, r._sum.quantity ?? 0]),
);
}
if (kind === 'raw') {
const rows = await db.batchMovement.groupBy({
by: ['rawMaterialLotId'],
where: { rawMaterialLotId: { in: ids } },
_sum: { quantity: true },
});
return new Map(
rows.map((r) => [r.rawMaterialLotId!, r._sum.quantity ?? 0]),
);
}
const rows = await db.batchMovement.groupBy({
by: ['productionBatchId'],
where: { productionBatchId: { in: ids } },
_sum: { quantity: true },
});
return new Map(
rows.map((r) => [r.productionBatchId!, r._sum.quantity ?? 0]),
);
}
private async remainingInTx(tx: Tx, target: StockTarget): Promise<number> {
const initial = await this.initialQuantity(tx, target);
const sums = await this.sumMovements(tx, target.kind, [target.id]);
return initial + (sums.get(target.id) ?? 0);
}
private async initialQuantity(
db: Tx | PrismaService,
target: StockTarget,
): Promise<number> {
if (target.kind === 'bottling') {
const row = await db.bottlingBatch.findUnique({
where: { id: target.id },
select: { jarCount: true },
});
if (!row) throw new NotFoundException('Bottling batch not found');
return row.jarCount;
}
if (target.kind === 'raw') {
const row = await db.rawMaterialLot.findUnique({
where: { id: target.id },
select: { quantity: true },
});
if (!row) throw new NotFoundException('Raw material lot not found');
return row.quantity;
}
const row = await db.productionBatch.findUnique({
where: { id: target.id },
select: { quantity: true },
});
if (!row) throw new NotFoundException('Production batch not found');
return row.quantity;
}
private movementWhere(target: StockTarget): Prisma.BatchMovementWhereInput {
return this.movementTarget(target);
}
private movementTarget(
target: StockTarget,
):
| { bottlingBatchId: string }
| { productionBatchId: string }
| { rawMaterialLotId: string } {
if (target.kind === 'bottling') return { bottlingBatchId: target.id };
if (target.kind === 'raw') return { rawMaterialLotId: target.id };
return { productionBatchId: target.id };
}
private async lock(tx: Tx, target: StockTarget): Promise<void> {
if (target.kind === 'bottling') {
await tx.$queryRaw`SELECT id FROM "BottlingBatch" WHERE id = ${target.id} FOR UPDATE`;
return;
}
if (target.kind === 'raw') {
const locked = await tx.$queryRaw<
{ id: string }[]
>`SELECT id FROM "RawMaterialLot" WHERE id = ${target.id} FOR UPDATE`;
if (locked.length === 0) {
throw new NotFoundException('Raw material lot not found');
}
return;
}
const batch = await tx.productionBatch.findUnique({
where: { id: target.id },
select: { productId: true },
});
if (!batch) throw new NotFoundException('Production batch not found');
await this.lockProduct(tx, batch.productId);
}
private async lockProduct(tx: Tx, productId: string): Promise<void> {
await tx.$queryRaw`SELECT id FROM "Product" WHERE id = ${productId} FOR UPDATE`;
}
}
@@ -7,6 +7,10 @@ export const WEBHOOK_EVENTS = [
'batch.created',
'batch.updated',
'product.stock_changed',
// Remaining stock of a bottling or production batch changed (sale,
// return, withdrawal, correction, corrected initial quantity). Payload:
// { bottlingBatchIds, productIds } - receivers fetch the numbers.
'stock.changed',
'task.created',
'task.completed',
] as const;
@@ -0,0 +1,239 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
interface StockBody {
summary: {
initial: number;
withdrawn: number;
consumed: number;
remaining: number;
};
movements: { type: string; quantity: number; notes: string | null }[];
}
interface LotBody {
id: string;
remainingCount: number;
usedIn: { quantity: number; productionBatch: { id: string } }[];
}
interface BatchBody {
id: string;
inputs: { quantity: number; rawMaterialLot: { id: string } }[];
}
/**
* Raw material lots (wax, propolis) and their consumption in production:
* creating a production batch books the inputs off the raw lots in the same
* transaction, never more than is left; deleting a mistaken batch gives the
* material back as its own movement.
*/
describe('Raw materials and production (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
let token: string;
let waxId: string;
let propolisId: string;
let productId: string;
let batchId: string;
const server = () => app.getHttpServer();
const auth = (req: request.Test) =>
req.set('Authorization', `Bearer ${token}`);
async function stockOf(lotId: string): Promise<StockBody> {
const response = await auth(
request(server()).get(`/api/v1/raw-material-lots/${lotId}/stock`),
).expect(200);
return response.body as StockBody;
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
await prisma.user.create({
data: {
email: `raw-beekeeper-${suffix}@example.com`,
passwordHash: await bcrypt.hash(password, 12),
name: 'Beekeeper',
role: { connect: { key: 'BEEKEEPER' } },
},
});
token = (await login(app, `raw-beekeeper-${suffix}@example.com`, password))
.accessToken;
const createLot = async (kind: string, lot: string, grams: number) => {
const response = await auth(
request(server()).post('/api/v1/raw-material-lots'),
)
.send({
kind,
lotNumber: `${lot}-${suffix}`,
date: '2026-06-01',
quantity: grams,
})
.expect(201);
return (response.body as LotBody).id;
};
waxId = await createLot('BEESWAX', 'W', 1000);
propolisId = await createLot('PROPOLIS', 'P', 200);
const product = await prisma.product.create({
data: {
name: `Propolis-Salbe ${suffix}`,
kind: 'MANUFACTURED',
shopCategory: 'PROPOLIS',
},
});
productId = product.id;
});
afterAll(async () => {
await app.close();
});
it('books the raw material when a production batch is made from it', async () => {
const response = await auth(
request(server()).post('/api/v1/production-batches'),
)
.send({
productId,
lotNumber: `S-${suffix}`,
date: '2026-06-10',
quantity: 20,
inputs: [
{ rawMaterialLotId: waxId, quantity: 300 },
{ rawMaterialLotId: propolisId, quantity: 50 },
],
})
.expect(201);
const batch = response.body as BatchBody;
batchId = batch.id;
expect(batch.inputs).toHaveLength(2);
expect((await stockOf(waxId)).summary).toMatchObject({
consumed: 300,
remaining: 700,
});
expect((await stockOf(propolisId)).summary.remaining).toBe(150);
const wax = await auth(
request(server()).get(`/api/v1/raw-material-lots/${waxId}`),
).expect(200);
expect(
(wax.body as LotBody).usedIn.map((u) => u.productionBatch.id),
).toEqual([batchId]);
});
it('refuses a production batch that needs more raw material than is left - and creates nothing', async () => {
await auth(request(server()).post('/api/v1/production-batches'))
.send({
productId,
lotNumber: `S2-${suffix}`,
date: '2026-06-11',
quantity: 10,
inputs: [{ rawMaterialLotId: waxId, quantity: 800 }],
})
.expect(400);
expect(
await prisma.productionBatch.count({
where: { lotNumber: `S2-${suffix}` },
}),
).toBe(0);
expect((await stockOf(waxId)).summary.remaining).toBe(700);
});
it('withdraws raw material like any other stock', async () => {
const response = await auth(
request(server()).post(`/api/v1/raw-material-lots/${waxId}/withdrawals`),
)
.send({ quantity: 100, reason: 'SONSTIGES', notes: 'Wachstücher' })
.expect(201);
expect((response.body as StockBody).summary).toMatchObject({
withdrawn: 100,
consumed: 300,
remaining: 600,
});
});
it('gives the raw material back when a mistaken production batch is deleted', async () => {
await auth(
request(server()).delete(`/api/v1/production-batches/${batchId}`),
).expect(200);
const wax = await stockOf(waxId);
expect(wax.summary).toMatchObject({ consumed: 0, remaining: 900 });
expect(wax.movements.some((m) => m.notes?.startsWith('Rückgabe'))).toBe(
true,
);
expect((await stockOf(propolisId)).summary.remaining).toBe(200);
});
it('refuses to correct a raw lot below what was already used or withdrawn', async () => {
// wax: 1000 g harvested, 100 g withdrawn -> at least 100 g must remain booked
await auth(request(server()).patch(`/api/v1/raw-material-lots/${waxId}`))
.send({ quantity: 50 })
.expect(400);
await auth(request(server()).patch(`/api/v1/raw-material-lots/${waxId}`))
.send({ quantity: 950 })
.expect(200);
expect((await stockOf(waxId)).summary.remaining).toBe(850);
});
it('needs the raw material withdraw permission to consume raw material', async () => {
const role = await prisma.role.create({
data: {
key: `PRODUCER_ONLY_${suffix}`,
name: `Nur Herstellung ${suffix}`,
permissions: {
create: (
await prisma.permission.findMany({
where: {
key: {
in: ['production-batch:read', 'production-batch:create'],
},
},
})
).map((permission) => ({ permissionId: permission.id })),
},
},
});
await prisma.user.create({
data: {
email: `raw-producer-${suffix}@example.com`,
passwordHash: await bcrypt.hash(password, 12),
name: 'Producer',
roleId: role.id,
},
});
const producerToken = (
await login(app, `raw-producer-${suffix}@example.com`, password)
).accessToken;
await request(server())
.post('/api/v1/production-batches')
.set('Authorization', `Bearer ${producerToken}`)
.send({
productId,
lotNumber: `S3-${suffix}`,
date: '2026-06-12',
quantity: 5,
inputs: [{ rawMaterialLotId: propolisId, quantity: 10 }],
})
.expect(403);
expect((await stockOf(propolisId)).summary.remaining).toBe(200);
});
it('keeps a raw lot with movements as history', async () => {
await auth(
request(server()).delete(`/api/v1/raw-material-lots/${waxId}`),
).expect(409);
});
});
+358
View File
@@ -0,0 +1,358 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
interface StockBody {
summary: {
initial: number;
soldInShop: number;
withdrawn: number;
corrected: number;
remaining: number;
};
movements: { type: string; quantity: number }[];
}
interface ShopResult {
externalId: string;
status: 'created' | 'duplicate' | 'rejected';
error?: string;
allocations: { batchId: string; lotNumber: string; quantity: number }[];
remainingCount: number | null;
}
/**
* Stock ledger end to end: withdrawals and inventory corrections in ApiCore,
* the shop's sales and returns via API key, oldest-batch-first allocation,
* idempotency and the row lock against concurrent bookings.
*/
describe('Stock ledger (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
let beekeeperToken: string;
let adminToken: string;
let writeKey: string;
let readKey: string;
let bottlingId: string;
let candleId: string;
let oldBatchId: string;
let newBatchId: string;
const server = () => app.getHttpServer();
const asBeekeeper = (req: request.Test) =>
req.set('Authorization', `Bearer ${beekeeperToken}`);
function book(token: string, requests: Record<string, unknown>[]) {
return request(server())
.post('/api/v1/shop-sync/movements')
.set('Authorization', `Bearer ${token}`)
.send({ requests });
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
const beekeeper = await prisma.user.create({
data: {
email: `stock-beekeeper-${suffix}@example.com`,
passwordHash,
name: 'Beekeeper',
role: { connect: { key: 'BEEKEEPER' } },
},
});
await prisma.user.create({
data: {
email: `stock-admin-${suffix}@example.com`,
passwordHash,
name: 'Admin',
role: { connect: { key: 'ADMIN' } },
},
});
beekeeperToken = (
await login(app, `stock-beekeeper-${suffix}@example.com`, password)
).accessToken;
adminToken = (
await login(app, `stock-admin-${suffix}@example.com`, password)
).accessToken;
const createKey = async (scopes: string[]) => {
const response = await request(server())
.post('/api/v1/api-keys')
.set('Authorization', `Bearer ${adminToken}`)
.send({ name: `Stock ${scopes.join('+')} ${suffix}`, scopes })
.expect(201);
return (response.body as { token: string }).token;
};
writeKey = await createKey(['shop-sync:read', 'shop-sync:write']);
readKey = await createKey(['shop-sync:read']);
// Honey chain: apiary -> harvest -> extraction -> honey batch -> bottling.
const apiary = await prisma.apiary.create({
data: { name: `Stand ${suffix}` },
});
const harvest = await prisma.harvest.create({
data: { apiaryId: apiary.id, date: new Date(), userId: beekeeper.id },
});
const extraction = await prisma.extraction.create({
data: { date: new Date(), userId: beekeeper.id },
});
await prisma.extractionHarvest.create({
data: { extractionId: extraction.id, harvestId: harvest.id },
});
const honeyBatch = await prisma.honeyBatch.create({
data: {
batchNumber: `HC-${suffix}`,
extractionId: extraction.id,
amount: 50,
},
});
const bottling = await asBeekeeper(
request(server()).post('/api/v1/bottling-batches'),
)
.send({
honeyBatchId: honeyBatch.id,
date: '2026-07-01',
jarSize: 500,
jarCount: 10,
bestBeforeDate: '2028-07-01',
lotNumber: `L-${suffix}`,
})
.expect(201);
bottlingId = (bottling.body as { id: string }).id;
const candle = await prisma.product.create({
data: {
name: `Kerze ${suffix}`,
kind: 'MANUFACTURED',
shopCategory: 'KERZEN',
},
});
candleId = candle.id;
const createBatch = async (lot: string, quantity: number, bbd: string) => {
const response = await asBeekeeper(
request(server()).post('/api/v1/production-batches'),
)
.send({
productId: candleId,
lotNumber: `${lot}-${suffix}`,
date: '2026-03-01',
quantity,
bestBeforeDate: bbd,
})
.expect(201);
return (response.body as { id: string }).id;
};
newBatchId = await createBatch('K-NEW', 5, '2027-06-01');
oldBatchId = await createBatch('K-OLD', 3, '2027-01-01');
});
afterAll(async () => {
await app.close();
});
it('withdraws from a bottling and refuses more than the remaining stock', async () => {
const response = await asBeekeeper(
request(server()).post(
`/api/v1/bottling-batches/${bottlingId}/withdrawals`,
),
)
.send({ quantity: 2, reason: 'HOFLADEN' })
.expect(201);
expect((response.body as StockBody).summary).toMatchObject({
initial: 10,
withdrawn: 2,
remaining: 8,
});
await asBeekeeper(
request(server()).post(
`/api/v1/bottling-batches/${bottlingId}/withdrawals`,
),
)
.send({ quantity: 20, reason: 'BRUCH' })
.expect(400);
});
it('books a shop sale once, even when it is sent twice', async () => {
const sale = {
externalId: `sale-honey-${suffix}`,
kind: 'SALE',
bottlingBatchId: bottlingId,
quantity: 3,
};
const first = await book(writeKey, [sale]).expect(200);
const [created] = (first.body as { results: ShopResult[] }).results;
expect(created.status).toBe('created');
expect(created.remainingCount).toBe(5);
const second = await book(writeKey, [sale]).expect(200);
const [duplicate] = (second.body as { results: ShopResult[] }).results;
expect(duplicate.status).toBe('duplicate');
expect(duplicate.remainingCount).toBe(5);
});
it('allocates an article sale to the batch with the earliest best-before date first', async () => {
const response = await book(writeKey, [
{
externalId: `sale-candle-${suffix}`,
kind: 'SALE',
productId: candleId,
quantity: 4,
},
]).expect(200);
const [result] = (response.body as { results: ShopResult[] }).results;
expect(result.status).toBe('created');
expect(
result.allocations.map((a) => [a.batchId, a.quantity]).sort(),
).toEqual(
[
[oldBatchId, 3],
[newBatchId, 1],
].sort(),
);
expect(result.remainingCount).toBe(4);
});
it('returns onto exactly the batches of the sale and refuses to return more', async () => {
const response = await book(writeKey, [
{
externalId: `return-candle-${suffix}`,
kind: 'RETURN',
productId: candleId,
quantity: 2,
reversesExternalId: `sale-candle-${suffix}`,
},
{
externalId: `return-too-much-${suffix}`,
kind: 'RETURN',
productId: candleId,
quantity: 5,
reversesExternalId: `sale-candle-${suffix}`,
},
]).expect(200);
const [returned, tooMuch] = (response.body as { results: ShopResult[] })
.results;
expect(returned.status).toBe('created');
const perBatch = new Map(
returned.allocations.map((a) => [a.batchId, a.quantity]),
);
expect(perBatch.get(newBatchId)).toBe(1);
expect(perBatch.get(oldBatchId)).toBe(1);
expect(returned.remainingCount).toBe(6);
expect(tooMuch.status).toBe('rejected');
});
it('rejects a bad request but still books the rest of the batch', async () => {
const response = await book(writeKey, [
{
externalId: `bad-${suffix}`,
kind: 'SALE',
productId: 'does-not-exist',
quantity: 1,
},
{
externalId: `good-${suffix}`,
kind: 'SALE',
bottlingBatchId: bottlingId,
quantity: 1,
},
]).expect(200);
const [bad, good] = (response.body as { results: ShopResult[] }).results;
expect(bad.status).toBe('rejected');
expect(good.status).toBe('created');
expect(good.remainingCount).toBe(4);
});
it('needs shop-sync:write to book', async () => {
await book(readKey, [
{
externalId: `nope-${suffix}`,
kind: 'SALE',
bottlingBatchId: bottlingId,
quantity: 1,
},
]).expect(403);
});
it('serves the remaining stock to the shop', async () => {
const response = await request(server())
.get('/api/v1/shop-sync/stock')
.set('Authorization', `Bearer ${readKey}`)
.expect(200);
const body = response.body as {
bottlings: { id: string; remainingCount: number }[];
products: { id: string; remainingCount: number }[];
};
expect(
body.bottlings.find((b) => b.id === bottlingId)?.remainingCount,
).toBe(4);
expect(body.products.find((p) => p.id === candleId)?.remainingCount).toBe(
6,
);
});
it('serializes concurrent withdrawals so the stock never goes below zero', async () => {
// newBatch: 5 produced - 1 sold + 1 returned = 5 left; 8 parallel takes of 1.
const attempts = await Promise.all(
Array.from({ length: 8 }, () =>
asBeekeeper(
request(server()).post(
`/api/v1/production-batches/${newBatchId}/withdrawals`,
),
).send({ quantity: 1, reason: 'PROBE' }),
),
);
expect(attempts.filter((r) => r.status === 201)).toHaveLength(5);
expect(attempts.filter((r) => r.status === 400)).toHaveLength(3);
const stock = await asBeekeeper(
request(server()).get(`/api/v1/production-batches/${newBatchId}/stock`),
).expect(200);
expect((stock.body as StockBody).summary.remaining).toBe(0);
});
it('books an inventory count as the difference', async () => {
const response = await asBeekeeper(
request(server()).post(
`/api/v1/bottling-batches/${bottlingId}/corrections`,
),
)
.send({ countedQuantity: 6, notes: 'Zwei Gläser im Keller gefunden' })
.expect(201);
const body = response.body as StockBody;
expect(body.summary.corrected).toBe(2);
expect(body.summary.remaining).toBe(6);
});
it('refuses to delete a bottling with movements', async () => {
await request(server())
.delete(`/api/v1/bottling-batches/${bottlingId}`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(409);
});
it('refuses a production batch for a honey article', async () => {
const honey = await prisma.product.create({
data: { name: `Honig ${suffix}` },
});
await asBeekeeper(request(server()).post('/api/v1/production-batches'))
.send({
productId: honey.id,
lotNumber: `X-${suffix}`,
date: '2026-03-01',
quantity: 1,
})
.expect(400);
});
});
@@ -12,6 +12,7 @@ import { BottlingEditDialog } from "./bottling-edit-dialog";
import { BottlingDeleteButton } from "./bottling-delete-button";
import { PrintLabelsCard } from "./print-labels-card";
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
import { StockCard } from "@/components/stock/stock-card";
const BACK = { backHref: "/bottling", backLabel: "Alle Abfüllungen" };
@@ -78,6 +79,13 @@ export default function BottlingBatchDetailPage() {
</p>
</CardContent>
</Card>
<StockCard
path="bottling-batches"
id={bottlingBatch.id}
initialLabel="Abgefüllt"
unit="Gläser"
canBook={hasPermission("bottling:withdraw")}
/>
<PrintLabelsCard bottlingBatchId={bottlingBatch.id} />
</div>
</main>
@@ -309,7 +309,9 @@ export default function NewBottlingBatchPage() {
<SelectValue placeholder="Produkt wählen" />
</SelectTrigger>
<SelectContent>
{products?.map((product) => (
{products
?.filter((product) => product.kind !== "MANUFACTURED")
.map((product) => (
<SelectItem key={product.id} value={product.id}>
{product.name}
</SelectItem>
@@ -63,6 +63,13 @@ export default function BottlingBatchesPage() {
label: "Menge",
value: `${bottlingBatch.jarCount} × ${bottlingBatch.jarSize} g`,
},
{
label: "Rest",
value:
bottlingBatch.remainingCount < 0
? `${bottlingBatch.remainingCount} (prüfen)`
: `${bottlingBatch.remainingCount} Gläser`,
},
{ label: "Datum", value: formatDate(bottlingBatch.date) },
]}
/>
@@ -0,0 +1,172 @@
"use client";
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateProductionBatch } from "@/lib/production-batches/api";
import { ProductionBatch } from "@/lib/production-batches/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
/**
* Corrects a production batch entered with a typo. The article stays fixed:
* moving a batch with sales to another article would rewrite where those
* sales came from. A changed quantity changes the remaining stock - in the
* shop as well.
*/
export function EditProductionBatchDialog({
batch,
}: {
batch: ProductionBatch;
}) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="outline" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Herstellungscharge bearbeiten</DialogTitle>
</DialogHeader>
{open ? (
<EditForm batch={batch} onSaved={() => setOpen(false)} />
) : null}
</DialogContent>
</Dialog>
);
}
function EditForm({
batch,
onSaved,
}: {
batch: ProductionBatch;
onSaved: () => void;
}) {
const queryClient = useQueryClient();
const [lotNumber, setLotNumber] = useState(batch.lotNumber);
const [date, setDate] = useState(batch.date.slice(0, 10));
const [quantity, setQuantity] = useState(String(batch.quantity));
const [bestBeforeDate, setBestBeforeDate] = useState(
batch.bestBeforeDate?.slice(0, 10) ?? "",
);
const [notes, setNotes] = useState(batch.notes ?? "");
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: () =>
updateProductionBatch(batch.id, {
lotNumber: lotNumber.trim(),
date: new Date(date).toISOString(),
quantity: Number(quantity),
bestBeforeDate: bestBeforeDate
? new Date(bestBeforeDate).toISOString()
: null,
notes: notes.trim(),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["production-batches"] });
void queryClient.invalidateQueries({
queryKey: ["stock", "production-batches", batch.id],
});
toast.success("Charge gespeichert");
onSaved();
},
onError: (mutationError: Error) => {
const message =
mutationError.message || "Charge konnte nicht gespeichert werden";
setError(message);
toast.error(message);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
mutation.mutate();
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="pb-edit-lot">Chargennummer</Label>
<Input
id="pb-edit-lot"
required
value={lotNumber}
onChange={(event) => setLotNumber(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="pb-edit-date">Herstelldatum</Label>
<Input
id="pb-edit-date"
type="date"
required
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="pb-edit-quantity">Hergestellte Stückzahl</Label>
<Input
id="pb-edit-quantity"
type="number"
inputMode="numeric"
min={1}
step={1}
required
value={quantity}
onChange={(event) => setQuantity(event.target.value)}
/>
<p className="text-xs text-muted-foreground">
Ändert den Rest (auch im Webshop). Fehlende oder gefundene Stück bitte
über &bdquo;Inventur korrigieren&ldquo; buchen, nicht hier.
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="pb-edit-best-before">
Mindesthaltbar bis (optional)
</Label>
<Input
id="pb-edit-best-before"
type="date"
value={bestBeforeDate}
onChange={(event) => setBestBeforeDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="pb-edit-notes">Notiz (intern)</Label>
<Textarea
id="pb-edit-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</form>
);
}
@@ -0,0 +1,140 @@
"use client";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { Factory } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
deleteProductionBatch,
fetchProductionBatch,
} from "@/lib/production-batches/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
import { StockCard } from "@/components/stock/stock-card";
import { useAuth } from "@/lib/auth/auth-context";
import { EditProductionBatchDialog } from "./edit-production-batch-dialog";
import {
RAW_MATERIAL_KIND_LABELS,
formatGrams,
} from "@/lib/raw-materials/labels";
const BACK = {
backHref: "/production-batches",
backLabel: "Alle Herstellungschargen",
};
export default function ProductionBatchDetailPage() {
const params = useParams<{ id: string }>();
const router = useRouter();
const queryClient = useQueryClient();
const { hasPermission } = useAuth();
const batchQuery = useQuery({
queryKey: ["production-batches", params.id],
queryFn: () => fetchProductionBatch(params.id),
});
const batch = batchQuery.data;
const deletion = useMutation({
mutationFn: () => deleteProductionBatch(params.id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["production-batches"] });
toast.success("Charge gelöscht");
router.push("/production-batches");
},
onError: (error: Error) =>
toast.error(error.message || "Charge konnte nicht gelöscht werden"),
});
if (batchQuery.isLoading || batchQuery.isError || !batch) {
return (
<DetailQueryFallback
query={batchQuery}
what="Daten der Charge"
{...BACK}
/>
);
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={Factory}
title={`Charge ${batch.lotNumber}`}
subtitle={`${batch.product.name} · ${batch.quantity} Stück hergestellt`}
{...BACK}
/>
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
<Card>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2.5">
<CardTitle>Angaben</CardTitle>
<div className="flex flex-wrap items-center gap-2">
{hasPermission("production-batch:update") ? (
<EditProductionBatchDialog batch={batch} />
) : null}
{hasPermission("production-batch:delete") ? (
<ConfirmDialog
triggerLabel="Löschen"
triggerVariant="outline"
triggerSize="sm"
title="Charge löschen"
description={`Charge ${batch.lotNumber} wird entfernt. Das geht nur, solange noch nichts verkauft oder entnommen wurde - danach bleibt die Charge als Historie erhalten.`}
confirmLabel="Endgültig löschen"
onConfirm={() => deletion.mutate()}
isPending={deletion.isPending}
/>
) : null}
</div>
</CardHeader>
<CardContent className="flex flex-col gap-2 text-sm">
<p>
Artikel:{" "}
<Link className="underline" href={`/products/${batch.productId}`}>
{batch.product.name}
</Link>
</p>
<p>
Herstelldatum: {new Date(batch.date).toLocaleDateString("de-DE")}
</p>
{batch.bestBeforeDate ? (
<p>
Mindesthaltbar bis:{" "}
{new Date(batch.bestBeforeDate).toLocaleDateString("de-DE")}
</p>
) : null}
{batch.notes ? (
<p className="whitespace-pre-line">Notiz: {batch.notes}</p>
) : null}
{batch.inputs && batch.inputs.length > 0 ? (
<div className="flex flex-col gap-1">
<p className="font-medium">Hergestellt aus</p>
<ul className="flex flex-col gap-1">
{batch.inputs.map((input) => (
<li key={input.id}>
<Link
className="underline"
href={`/raw-materials/${input.rawMaterialLot.id}`}
>
{RAW_MATERIAL_KIND_LABELS[input.rawMaterialLot.kind]}{" "}
{input.rawMaterialLot.lotNumber}
</Link>
: {formatGrams(input.quantity)}
</li>
))}
</ul>
</div>
) : null}
</CardContent>
</Card>
<StockCard
path="production-batches"
id={batch.id}
initialLabel="Hergestellt"
unit="Stück"
canBook={hasPermission("production-batch:withdraw")}
/>
</div>
</main>
);
}
@@ -0,0 +1,308 @@
"use client";
import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
import { Factory } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { createProductionBatch } from "@/lib/production-batches/api";
import { fetchProducts } from "@/lib/products/api";
import { fetchRawMaterialLots } from "@/lib/raw-materials/api";
import {
RAW_MATERIAL_KIND_LABELS,
formatGrams,
} from "@/lib/raw-materials/labels";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
function today(): string {
return new Date().toISOString().slice(0, 10);
}
export default function NewProductionBatchPage() {
const router = useRouter();
const queryClient = useQueryClient();
const [productId, setProductId] = useState("");
const [lotNumber, setLotNumber] = useState("");
const [date, setDate] = useState(today());
const [quantity, setQuantity] = useState("");
const [bestBeforeDate, setBestBeforeDate] = useState("");
const [notes, setNotes] = useState("");
// Verbrauchte Rohstoffe: je Zeile eine Rohstoff-Charge und Gramm.
const [inputs, setInputs] = useState<
{ rawMaterialLotId: string; grams: string }[]
>([]);
const { data: rawLots } = useQuery({
queryKey: ["raw-material-lots"],
queryFn: () => fetchRawMaterialLots(),
});
const availableLots = (rawLots ?? []).filter((lot) => lot.remainingCount > 0);
const updateInput = (
index: number,
change: Partial<{ rawMaterialLotId: string; grams: string }>,
) =>
setInputs((current) =>
current.map((row, i) => (i === index ? { ...row, ...change } : row)),
);
const [error, setError] = useState<string | null>(null);
const { data: products } = useQuery({
queryKey: ["products", { status: "ACTIVE" }],
queryFn: () => fetchProducts({ status: "ACTIVE" }),
});
const manufactured = (products ?? []).filter(
(product) => product.kind === "MANUFACTURED",
);
const selected = manufactured.find((product) => product.id === productId);
const mutation = useMutation({
mutationFn: () =>
createProductionBatch({
productId,
lotNumber: lotNumber.trim(),
date: new Date(date).toISOString(),
quantity: Number(quantity),
bestBeforeDate: bestBeforeDate
? new Date(bestBeforeDate).toISOString()
: undefined,
notes: notes.trim() || undefined,
inputs: inputs
.filter((row) => row.rawMaterialLotId && Number(row.grams) > 0)
.map((row) => ({
rawMaterialLotId: row.rawMaterialLotId,
quantity: Number(row.grams),
})),
}),
onSuccess: (batch) => {
void queryClient.invalidateQueries({ queryKey: ["production-batches"] });
toast.success(`Charge ${batch.lotNumber} angelegt`);
router.push(`/production-batches/${batch.id}`);
},
onError: (mutationError: Error) => {
const message =
mutationError.message || "Charge konnte nicht angelegt werden";
setError(message);
toast.error(message);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (!productId) {
setError("Bitte einen Artikel wählen.");
return;
}
mutation.mutate();
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Factory}
title="Neue Herstellungscharge"
description="Was hast du hergestellt, und wie viele Stück?"
/>
<Card>
<CardContent>
<form
className="flex max-w-xl flex-col gap-4"
onSubmit={handleSubmit}
>
<div className="flex flex-col gap-2">
<Label htmlFor="pb-product">Artikel</Label>
<Select
value={productId}
onValueChange={(value) => setProductId(value ?? "")}
>
<SelectTrigger id="pb-product" className="w-full">
<SelectValue placeholder="Artikel wählen">
{selected?.name}
</SelectValue>
</SelectTrigger>
<SelectContent>
{manufactured.map((product) => (
<SelectItem key={product.id} value={product.id}>
{product.name}
</SelectItem>
))}
</SelectContent>
</Select>
{products && manufactured.length === 0 ? (
<p className="text-xs text-muted-foreground">
Noch kein Artikel der Art &bdquo;Hergestellte Ware&ldquo;.
Lege ihn unter Produkte an.
</p>
) : null}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="pb-lot">Chargennummer</Label>
<Input
id="pb-lot"
required
value={lotNumber}
onChange={(event) => setLotNumber(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="pb-date">Herstelldatum</Label>
<Input
id="pb-date"
type="date"
required
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="pb-quantity">Hergestellte Stückzahl</Label>
<Input
id="pb-quantity"
type="number"
inputMode="numeric"
min={1}
step={1}
required
value={quantity}
onChange={(event) => setQuantity(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="pb-best-before">
Mindesthaltbar bis (optional)
</Label>
<Input
id="pb-best-before"
type="date"
value={bestBeforeDate}
onChange={(event) => setBestBeforeDate(event.target.value)}
/>
</div>
<fieldset className="flex flex-col gap-3 rounded-lg border p-3">
<legend className="px-1 text-sm font-medium">
Verbrauchte Rohstoffe (optional)
</legend>
<p className="text-xs text-muted-foreground">
Wird beim Anlegen vom Rohstoff abgebucht. So lässt sich später
nachverfolgen, aus welchem Wachs oder Propolis diese Charge
stammt.
</p>
{inputs.map((row, index) => {
const lot = availableLots.find(
(candidate) => candidate.id === row.rawMaterialLotId,
);
return (
<div key={index} className="flex flex-wrap items-end gap-2">
<div className="flex min-w-48 flex-1 flex-col gap-1">
<Label htmlFor={`pb-input-lot-${index}`}>Rohstoff</Label>
<Select
value={row.rawMaterialLotId}
onValueChange={(value) =>
updateInput(index, { rawMaterialLotId: value ?? "" })
}
>
<SelectTrigger
id={`pb-input-lot-${index}`}
className="w-full"
>
<SelectValue placeholder="Charge wählen">
{lot
? `${RAW_MATERIAL_KIND_LABELS[lot.kind]} ${lot.lotNumber}`
: undefined}
</SelectValue>
</SelectTrigger>
<SelectContent>
{availableLots.map((candidate) => (
<SelectItem key={candidate.id} value={candidate.id}>
{RAW_MATERIAL_KIND_LABELS[candidate.kind]}{" "}
{candidate.lotNumber} (noch{" "}
{formatGrams(candidate.remainingCount)})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-28 flex-col gap-1">
<Label htmlFor={`pb-input-grams-${index}`}>Gramm</Label>
<Input
id={`pb-input-grams-${index}`}
type="number"
inputMode="numeric"
min={1}
max={lot?.remainingCount}
step={1}
value={row.grams}
onChange={(event) =>
updateInput(index, { grams: event.target.value })
}
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setInputs((current) =>
current.filter((_, i) => i !== index),
)
}
>
Entfernen
</Button>
</div>
);
})}
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
disabled={availableLots.length === 0}
onClick={() =>
setInputs((current) => [
...current,
{ rawMaterialLotId: "", grams: "" },
])
}
>
+ Rohstoff hinzufügen
</Button>
{rawLots && availableLots.length === 0 ? (
<p className="text-xs text-muted-foreground">
Kein Rohstoff mit Restbestand vorhanden.
</p>
) : null}
</fieldset>
<div className="flex flex-col gap-2">
<Label htmlFor="pb-notes">Notiz (optional, intern)</Label>
<Textarea
id="pb-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<Button type="submit" size="touch" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Charge anlegen"}
</Button>
</form>
</CardContent>
</Card>
</main>
);
}
@@ -0,0 +1,83 @@
"use client";
import Link from "next/link";
import { Factory } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchProductionBatches } from "@/lib/production-batches/api";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card";
import { QueryState } from "@/components/query-state";
import { useAuth } from "@/lib/auth/auth-context";
export default function ProductionBatchesPage() {
const { hasPermission } = useAuth();
const canCreate = hasPermission("production-batch:create");
const batchesQuery = useQuery({
queryKey: ["production-batches"],
queryFn: () => fetchProductionBatches(),
});
const newButton = canCreate ? (
<Button
size="touch"
nativeButton={false}
render={<Link href="/production-batches/new" />}
>
+ Neue Herstellungscharge
</Button>
) : null;
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Factory}
title="Herstellungschargen"
description="Kerzen, Propolis-Produkte und andere hergestellte Waren - je Charge mit hergestellter Menge und Rest."
actions={newButton}
/>
<QueryState
query={batchesQuery}
what="Herstellungschargen"
empty={
<EmptyState
icon={Factory}
title="Noch keine Herstellungschargen"
description="Lege eine Charge an, sobald du Kerzen, Tinkturen oder Salben hergestellt hast. Der Artikel muss die Art „Hergestellte Ware“ haben."
action={newButton ?? undefined}
/>
}
>
{(batches) => (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{batches.map((batch) => (
<EntityCard
key={batch.id}
href={`/production-batches/${batch.id}`}
icon={Factory}
title={<span className="font-mono">{batch.lotNumber}</span>}
subtitle={batch.product.name}
fields={[
{ label: "Hergestellt", value: String(batch.quantity) },
{
label: "Rest",
value:
batch.remainingCount < 0
? `${batch.remainingCount} (prüfen)`
: String(batch.remainingCount),
},
{
label: "Datum",
value: new Date(batch.date).toLocaleDateString("de-DE"),
},
]}
/>
))}
</div>
)}
</QueryState>
</main>
);
}
@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import ProductDetailPage from "./page";
import { fetchProduct } from "@/lib/products/api";
import { fetchProductionBatches } from "@/lib/production-batches/api";
vi.mock("next/navigation", () => ({
useParams: () => ({ id: "candle" }),
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: () => false }),
}));
vi.mock("@/lib/products/api", () => ({
fetchProduct: vi.fn(),
updateProduct: vi.fn(),
}));
vi.mock("@/lib/production-batches/api", () => ({
fetchProductionBatches: vi.fn(),
}));
vi.mock("@/lib/bottling/api", () => ({
fetchBottlingBatches: vi.fn(async () => []),
}));
function renderPage() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<ProductDetailPage />
</QueryClientProvider>,
);
}
describe("ProductDetailPage", () => {
beforeEach(() => {
vi.mocked(fetchProduct).mockResolvedValue({
id: "candle",
name: "Bienenwachskerze klein",
description: null,
sku: null,
packagingSize: null,
weight: null,
salesUnit: null,
status: "ACTIVE",
kind: "MANUFACTURED",
shopCategory: "KERZEN",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(fetchProductionBatches).mockResolvedValue([
{
id: "b1",
productId: "candle",
lotNumber: "K-1",
date: "2026-02-01T00:00:00.000Z",
quantity: 3,
bestBeforeDate: null,
notes: null,
createdAt: "2026-02-01T00:00:00.000Z",
remainingCount: 1,
product: { id: "candle", name: "Bienenwachskerze klein" },
},
{
id: "b2",
productId: "candle",
lotNumber: "K-2",
date: "2026-05-01T00:00:00.000Z",
quantity: 5,
bestBeforeDate: null,
notes: null,
createdAt: "2026-05-01T00:00:00.000Z",
remainingCount: 5,
product: { id: "candle", name: "Bienenwachskerze klein" },
},
]);
});
it("sums the remaining stock over all production batches", async () => {
renderPage();
expect(await screen.findByTestId("total-remaining")).toHaveTextContent(
"6 Stück",
);
expect(screen.getByText("K-1")).toBeInTheDocument();
expect(screen.getByText("K-2")).toBeInTheDocument();
});
it("links every batch to its own page for withdrawals", async () => {
renderPage();
const link = (await screen.findByText("K-1")).closest("a");
expect(link).toHaveAttribute("href", "/production-batches/b1");
});
});
@@ -0,0 +1,273 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { ShoppingBag } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchProduct } from "@/lib/products/api";
import { fetchProductionBatches } from "@/lib/production-batches/api";
import { fetchBottlingBatches } from "@/lib/bottling/api";
import {
PRODUCT_KIND_LABELS,
PRODUCT_STATUS_LABELS,
SHOP_CATEGORY_LABELS,
} from "@/lib/products/labels";
import { Product } from "@/lib/products/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
import { useAuth } from "@/lib/auth/auth-context";
import { EditProductDialog } from "../edit-product-dialog";
const BACK = { backHref: "/products", backLabel: "Alle Produkte" };
/** One stock line: a bottling (honey) or a production batch. */
interface StockRow {
id: string;
href: string;
lotNumber: string;
date: string;
initial: number;
remaining: number;
}
export default function ProductDetailPage() {
const params = useParams<{ id: string }>();
const { hasPermission } = useAuth();
const productQuery = useQuery({
queryKey: ["products", params.id],
queryFn: () => fetchProduct(params.id),
});
const product = productQuery.data;
if (productQuery.isLoading || productQuery.isError || !product) {
return (
<DetailQueryFallback
query={productQuery}
what="Daten des Produkts"
{...BACK}
/>
);
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={ShoppingBag}
title={product.name}
subtitle={PRODUCT_KIND_LABELS[product.kind]}
{...BACK}
/>
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
<Card>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2.5">
<CardTitle>Angaben</CardTitle>
{hasPermission("products:update") ? (
<EditProductDialog product={product} />
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-2 text-sm">
<p>
Status:{" "}
<Badge
variant={product.status === "ACTIVE" ? "default" : "secondary"}
>
{PRODUCT_STATUS_LABELS[product.status]}
</Badge>
</p>
<p>
Kategorie im Webshop: {SHOP_CATEGORY_LABELS[product.shopCategory]}
</p>
{product.sku ? <p>SKU: {product.sku}</p> : null}
{product.salesUnit ? (
<p>Verkaufseinheit: {product.salesUnit}</p>
) : null}
{product.description ? (
<p className="whitespace-pre-line">{product.description}</p>
) : null}
</CardContent>
</Card>
{product.kind === "MANUFACTURED" ? (
<ManufacturedStock product={product} />
) : (
<BottledStock product={product} />
)}
</div>
</main>
);
}
function ManufacturedStock({ product }: { product: Product }) {
const { hasPermission } = useAuth();
const batchesQuery = useQuery({
queryKey: ["production-batches", { productId: product.id }],
queryFn: () => fetchProductionBatches({ productId: product.id }),
});
const rows: StockRow[] = (batchesQuery.data ?? []).map((batch) => ({
id: batch.id,
href: `/production-batches/${batch.id}`,
lotNumber: batch.lotNumber,
date: batch.date,
initial: batch.quantity,
remaining: batch.remainingCount,
}));
return (
<StockOverview
title="Bestand (alle Herstellungschargen)"
initialLabel="Hergestellt"
unit="Stück"
rows={rows}
isLoading={batchesQuery.isLoading}
isError={batchesQuery.isError}
empty="Noch keine Herstellungscharge - der Bestand ist 0."
action={
hasPermission("production-batch:create") ? (
<Button
size="sm"
nativeButton={false}
render={<Link href="/production-batches/new" />}
>
+ Neue Charge
</Button>
) : null
}
/>
);
}
function BottledStock({ product }: { product: Product }) {
const bottlingQuery = useQuery({
queryKey: ["bottling-batches"],
queryFn: () => fetchBottlingBatches(),
});
const rows: StockRow[] = (bottlingQuery.data ?? [])
.filter((bottling) => bottling.productId === product.id)
.map((bottling) => ({
id: bottling.id,
href: `/bottling/${bottling.id}`,
lotNumber: bottling.lotNumber,
date: bottling.date,
initial: bottling.jarCount,
remaining: bottling.remainingCount,
}));
return (
<StockOverview
title="Bestand (alle Abfüllungen)"
initialLabel="Abgefüllt"
unit="Gläser"
rows={rows}
isLoading={bottlingQuery.isLoading}
isError={bottlingQuery.isError}
empty="Noch keine Abfüllung mit diesem Produkt."
action={null}
/>
);
}
/**
* Summe über alle Chargen eines Artikels - genau die Zahl, die der Webshop
* als Bestand fuehrt (abzueglich offener Bestellungen dort). Jede Zeile
* fuehrt zur Charge, wo entnommen und inventiert wird.
*/
function StockOverview({
title,
initialLabel,
unit,
rows,
isLoading,
isError,
empty,
action,
}: {
title: string;
initialLabel: string;
unit: string;
rows: StockRow[];
isLoading: boolean;
isError: boolean;
empty: string;
action: React.ReactNode;
}) {
const totalInitial = rows.reduce((sum, row) => sum + row.initial, 0);
const totalRemaining = rows.reduce((sum, row) => sum + row.remaining, 0);
return (
<Card>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2.5">
<CardTitle>{title}</CardTitle>
{action}
</CardHeader>
<CardContent className="flex flex-col gap-4">
{isError ? (
<p role="alert" className="text-sm text-destructive">
Bestand konnte nicht geladen werden.
</p>
) : isLoading ? (
<p className="text-sm text-muted-foreground">Lädt…</p>
) : (
<>
<dl className="grid grid-cols-2 gap-3">
<div className="rounded-lg border p-3">
<dt className="text-xs text-muted-foreground">
{initialLabel}
</dt>
<dd className="text-xl font-semibold tabular-nums">
{totalInitial}
</dd>
</div>
<div
className={
totalRemaining < 0
? "rounded-lg border border-destructive bg-destructive/10 p-3"
: "rounded-lg border border-primary bg-primary/5 p-3"
}
>
<dt className="text-xs text-muted-foreground">Rest gesamt</dt>
<dd
className="text-xl font-semibold tabular-nums"
data-testid="total-remaining"
>
{totalRemaining} {unit}
</dd>
</div>
</dl>
{rows.length === 0 ? (
<p className="text-sm text-muted-foreground">{empty}</p>
) : (
<ul className="flex flex-col divide-y rounded-lg border text-sm">
{rows.map((row) => (
<li key={row.id}>
<Link
href={row.href}
className="flex items-baseline justify-between gap-2 p-3 hover:bg-muted"
>
<span className="flex flex-col">
<span className="font-mono font-medium">
{row.lotNumber}
</span>
<span className="text-xs text-muted-foreground">
{new Date(row.date).toLocaleDateString("de-DE")} ·{" "}
{initialLabel} {row.initial}
</span>
</span>
<span
className={
row.remaining < 0
? "font-semibold tabular-nums text-destructive"
: "font-semibold tabular-nums"
}
>
Rest {row.remaining}
{row.remaining < 0 ? " (prüfen)" : ""}
</span>
</Link>
</li>
))}
</ul>
)}
</>
)}
</CardContent>
</Card>
);
}
@@ -5,8 +5,12 @@ import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateProduct } from "@/lib/products/api";
import { Product, ProductStatus } from "@/lib/products/types";
import { PRODUCT_STATUS_LABELS } from "@/lib/products/labels";
import { Product, ProductStatus, ShopCategory } from "@/lib/products/types";
import {
PRODUCT_KIND_LABELS,
PRODUCT_STATUS_LABELS,
SHOP_CATEGORY_LABELS,
} from "@/lib/products/labels";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -37,6 +41,7 @@ export function EditProductDialog({ product }: { product: Product }) {
const [weight, setWeight] = useState(product.weight?.toString() ?? "");
const [salesUnit, setSalesUnit] = useState(product.salesUnit ?? "");
const [status, setStatus] = useState<ProductStatus>(product.status);
const [shopCategory, setShopCategory] = useState<ShopCategory>(product.shopCategory);
const mutation = useMutation({
mutationFn: () =>
@@ -48,6 +53,7 @@ export function EditProductDialog({ product }: { product: Product }) {
weight: weight ? Number(weight) : undefined,
salesUnit: salesUnit || undefined,
status,
shopCategory,
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["products"] });
@@ -145,6 +151,29 @@ export function EditProductDialog({ product }: { product: Product }) {
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="product-edit-shop-category">Kategorie im Webshop</Label>
<Select
value={shopCategory}
onValueChange={(value) =>
setShopCategory((value ?? shopCategory) as ShopCategory)
}
>
<SelectTrigger id="product-edit-shop-category" className="w-full">
<SelectValue>{SHOP_CATEGORY_LABELS[shopCategory]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(SHOP_CATEGORY_LABELS) as ShopCategory[]).map((option) => (
<SelectItem key={option} value={option}>
{SHOP_CATEGORY_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Art: {PRODUCT_KIND_LABELS[product.kind]} - nicht änderbar, sobald Chargen existieren.
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="product-edit-sales-unit">Verkaufseinheit</Label>
<Input
@@ -59,6 +59,8 @@ describe("NewProductPage", () => {
packagingSize: 500,
weight: undefined,
salesUnit: undefined,
kind: "BOTTLED_HONEY",
shopCategory: "HONIG",
});
expect(push).toHaveBeenCalledWith("/products");
});
@@ -17,6 +17,15 @@ import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { PRODUCT_KIND_LABELS, SHOP_CATEGORY_LABELS } from "@/lib/products/labels";
import type { ProductKind, ShopCategory } from "@/lib/products/types";
import {
ProductFormErrors,
ProductFormValues,
@@ -121,6 +130,51 @@ export default function NewProductPage() {
/>
)}
</Field>
<Field
label="Art"
hint="Honig wird abgefüllt; Kerzen, Tinkturen und Salben bekommen Herstellungschargen"
>
{({ id, describedBy }) => (
<Select
value={values.kind}
onValueChange={(value) =>
set("kind", (value ?? values.kind) as ProductKind)
}
>
<SelectTrigger id={id} aria-describedby={describedBy} className="w-full">
<SelectValue>{PRODUCT_KIND_LABELS[values.kind]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(PRODUCT_KIND_LABELS) as ProductKind[]).map((option) => (
<SelectItem key={option} value={option}>
{PRODUCT_KIND_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</Field>
<Field label="Kategorie im Webshop">
{({ id, describedBy }) => (
<Select
value={values.shopCategory}
onValueChange={(value) =>
set("shopCategory", (value ?? values.shopCategory) as ShopCategory)
}
>
<SelectTrigger id={id} aria-describedby={describedBy} className="w-full">
<SelectValue>{SHOP_CATEGORY_LABELS[values.shopCategory]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(SHOP_CATEGORY_LABELS) as ShopCategory[]).map((option) => (
<SelectItem key={option} value={option}>
{SHOP_CATEGORY_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</Field>
<Field label="Verkaufseinheit" hint="z. B. Glas, Flasche">
{({ id, describedBy }) => (
<Input
@@ -1,4 +1,8 @@
import type { CreateProductInput } from "@/lib/products/types";
import type {
CreateProductInput,
ProductKind,
ShopCategory,
} from "@/lib/products/types";
/** Everything the form holds, as plain values that survive a draft save. */
export interface ProductFormValues {
@@ -8,6 +12,8 @@ export interface ProductFormValues {
weight: string;
sku: string;
description: string;
kind: ProductKind;
shopCategory: ShopCategory;
}
export type ProductFormErrors = Partial<Record<keyof ProductFormValues, string>>;
@@ -20,6 +26,8 @@ export function initialProductValues(): ProductFormValues {
weight: "",
sku: "",
description: "",
kind: "BOTTLED_HONEY",
shopCategory: "HONIG",
};
}
@@ -43,5 +51,7 @@ export function toProductInput(values: ProductFormValues): CreateProductInput {
packagingSize: numberOrUndefined(values.packagingSize),
weight: numberOrUndefined(values.weight),
salesUnit: textOrUndefined(values.salesUnit),
kind: values.kind,
shopCategory: values.shopCategory,
};
}
@@ -92,11 +92,24 @@ function ProductGrid({ products }: { products: Product[] }) {
{PRODUCT_STATUS_LABELS[product.status]}
</Badge>
}
subtitle={product.kind === "MANUFACTURED" ? "Hergestellte Ware" : "Honig"}
fields={[
{ label: "SKU", value: product.sku ?? "—" },
{ label: "Einheit", value: product.salesUnit ?? "—" },
]}
actions={<EditProductDialog product={product} />}
actions={
<>
<Button
variant="outline"
size="sm"
nativeButton={false}
render={<Link href={`/products/${product.id}`} />}
>
Bestand ansehen
</Button>
<EditProductDialog product={product} />
</>
}
/>
))}
</div>
@@ -0,0 +1,136 @@
"use client";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { Boxes } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
deleteRawMaterialLot,
fetchRawMaterialLot,
} from "@/lib/raw-materials/api";
import {
RAW_MATERIAL_KIND_LABELS,
formatGrams,
} from "@/lib/raw-materials/labels";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
import { StockCard } from "@/components/stock/stock-card";
import { useAuth } from "@/lib/auth/auth-context";
const BACK = { backHref: "/raw-materials", backLabel: "Alle Rohstoffe" };
export default function RawMaterialDetailPage() {
const params = useParams<{ id: string }>();
const router = useRouter();
const queryClient = useQueryClient();
const { hasPermission } = useAuth();
const lotQuery = useQuery({
queryKey: ["raw-material-lots", params.id],
queryFn: () => fetchRawMaterialLot(params.id),
});
const lot = lotQuery.data;
const deletion = useMutation({
mutationFn: () => deleteRawMaterialLot(params.id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["raw-material-lots"] });
toast.success("Rohstoff gelöscht");
router.push("/raw-materials");
},
onError: (error: Error) =>
toast.error(error.message || "Rohstoff konnte nicht gelöscht werden"),
});
if (lotQuery.isLoading || lotQuery.isError || !lot) {
return (
<DetailQueryFallback
query={lotQuery}
what="Daten des Rohstoffs"
{...BACK}
/>
);
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={Boxes}
title={`${RAW_MATERIAL_KIND_LABELS[lot.kind]} ${lot.lotNumber}`}
subtitle={`${formatGrams(lot.quantity)} gewonnen am ${new Date(lot.date).toLocaleDateString("de-DE")}`}
{...BACK}
/>
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-4">
<Card>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2.5">
<CardTitle>Angaben</CardTitle>
{hasPermission("raw-material:delete") ? (
<ConfirmDialog
triggerLabel="Löschen"
triggerVariant="outline"
triggerSize="sm"
title="Rohstoff löschen"
description={`${lot.lotNumber} wird entfernt. Das geht nur, solange nichts verbraucht, entnommen oder inventiert wurde.`}
confirmLabel="Endgültig löschen"
onConfirm={() => deletion.mutate()}
isPending={deletion.isPending}
/>
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-2 text-sm">
<p>Art: {RAW_MATERIAL_KIND_LABELS[lot.kind]}</p>
{lot.apiary ? <p>Stand: {lot.apiary.name}</p> : null}
{lot.notes ? (
<p className="whitespace-pre-line">Notiz: {lot.notes}</p>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Verwendet in</CardTitle>
</CardHeader>
<CardContent>
{lot.usedIn.length === 0 ? (
<p className="text-sm text-muted-foreground">
Noch in keiner Herstellungscharge verwendet.
</p>
) : (
<ul className="flex flex-col divide-y rounded-lg border text-sm">
{lot.usedIn.map((use) => (
<li key={use.id}>
<Link
href={`/production-batches/${use.productionBatch.id}`}
className="flex items-baseline justify-between gap-2 p-3 hover:bg-muted"
>
<span>
<span className="font-mono font-medium">
{use.productionBatch.lotNumber}
</span>{" "}
<span className="text-muted-foreground">
{use.productionBatch.product.name}
</span>
</span>
<span className="tabular-nums">
{formatGrams(use.quantity)}
</span>
</Link>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
<StockCard
path="raw-material-lots"
id={lot.id}
initialLabel="Gewonnen"
unit="g"
canBook={hasPermission("raw-material:withdraw")}
/>
</div>
</main>
);
}
@@ -0,0 +1,186 @@
"use client";
import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
import { Boxes } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { createRawMaterialLot } from "@/lib/raw-materials/api";
import { RAW_MATERIAL_KIND_LABELS } from "@/lib/raw-materials/labels";
import { RawMaterialKind } from "@/lib/raw-materials/types";
import { fetchApiaries } from "@/lib/apiaries/api";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const NO_APIARY = "__none__";
export default function NewRawMaterialPage() {
const router = useRouter();
const queryClient = useQueryClient();
const [kind, setKind] = useState<RawMaterialKind>("BEESWAX");
const [lotNumber, setLotNumber] = useState("");
const [date, setDate] = useState(new Date().toISOString().slice(0, 10));
const [quantity, setQuantity] = useState("");
const [apiaryId, setApiaryId] = useState(NO_APIARY);
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
const { data: apiaries } = useQuery({
queryKey: ["apiaries"],
queryFn: () => fetchApiaries(),
});
const selectedApiary = apiaries?.find((apiary) => apiary.id === apiaryId);
const mutation = useMutation({
mutationFn: () =>
createRawMaterialLot({
kind,
lotNumber: lotNumber.trim(),
date: new Date(date).toISOString(),
quantity: Number(quantity),
apiaryId: apiaryId === NO_APIARY ? undefined : apiaryId,
notes: notes.trim() || undefined,
}),
onSuccess: (lot) => {
void queryClient.invalidateQueries({ queryKey: ["raw-material-lots"] });
toast.success(`Rohstoff ${lot.lotNumber} angelegt`);
router.push(`/raw-materials/${lot.id}`);
},
onError: (mutationError: Error) => {
const message =
mutationError.message || "Rohstoff konnte nicht angelegt werden";
setError(message);
toast.error(message);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
mutation.mutate();
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Boxes}
title="Neuer Rohstoff"
description="Was hast du gewonnen, und wie viel?"
/>
<Card>
<CardContent>
<form
className="flex max-w-xl flex-col gap-4"
onSubmit={handleSubmit}
>
<div className="flex flex-col gap-2">
<Label htmlFor="raw-kind">Art</Label>
<Select
value={kind}
onValueChange={(value) =>
setKind((value ?? kind) as RawMaterialKind)
}
>
<SelectTrigger id="raw-kind" className="w-full">
<SelectValue>{RAW_MATERIAL_KIND_LABELS[kind]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(
Object.keys(RAW_MATERIAL_KIND_LABELS) as RawMaterialKind[]
).map((option) => (
<SelectItem key={option} value={option}>
{RAW_MATERIAL_KIND_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="raw-lot">Chargennummer</Label>
<Input
id="raw-lot"
required
value={lotNumber}
onChange={(event) => setLotNumber(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="raw-date">Gewonnen am</Label>
<Input
id="raw-date"
type="date"
required
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="raw-quantity">Menge (g)</Label>
<Input
id="raw-quantity"
type="number"
inputMode="numeric"
min={1}
step={1}
required
value={quantity}
onChange={(event) => setQuantity(event.target.value)}
/>
<p className="text-xs text-muted-foreground">
In Gramm, z. B. 4200 für 4,2 kg Wachs.
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="raw-apiary">Stand (optional)</Label>
<Select
value={apiaryId}
onValueChange={(value) => setApiaryId(value ?? NO_APIARY)}
>
<SelectTrigger id="raw-apiary" className="w-full">
<SelectValue>
{selectedApiary?.name ?? "Kein Stand"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_APIARY}>Kein Stand</SelectItem>
{(apiaries ?? []).map((apiary) => (
<SelectItem key={apiary.id} value={apiary.id}>
{apiary.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="raw-notes">Notiz (optional, intern)</Label>
<Textarea
id="raw-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<Button type="submit" size="touch" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Rohstoff anlegen"}
</Button>
</form>
</CardContent>
</Card>
</main>
);
}
@@ -0,0 +1,88 @@
"use client";
import Link from "next/link";
import { Boxes } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchRawMaterialLots } from "@/lib/raw-materials/api";
import {
RAW_MATERIAL_KIND_LABELS,
formatGrams,
} from "@/lib/raw-materials/labels";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card";
import { QueryState } from "@/components/query-state";
import { useAuth } from "@/lib/auth/auth-context";
export default function RawMaterialsPage() {
const { hasPermission } = useAuth();
const lotsQuery = useQuery({
queryKey: ["raw-material-lots"],
queryFn: () => fetchRawMaterialLots(),
});
const newButton = hasPermission("raw-material:create") ? (
<Button
size="touch"
nativeButton={false}
render={<Link href="/raw-materials/new" />}
>
+ Neuer Rohstoff
</Button>
) : null;
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Boxes}
title="Rohstoffe"
description="Wachs und Propolis je Charge - mit gewonnener Menge, Verbrauch in der Herstellung und Rest."
actions={newButton}
/>
<QueryState
query={lotsQuery}
what="Rohstoffe"
empty={
<EmptyState
icon={Boxes}
title="Noch keine Rohstoffe"
description="Erfasse Wachs vom Entdeckeln oder Propolis von den Gittern, um daraus Kerzen, Tinkturen oder Salben herzustellen."
action={newButton ?? undefined}
/>
}
>
{(lots) => (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{lots.map((lot) => (
<EntityCard
key={lot.id}
href={`/raw-materials/${lot.id}`}
icon={Boxes}
title={<span className="font-mono">{lot.lotNumber}</span>}
subtitle={RAW_MATERIAL_KIND_LABELS[lot.kind]}
fields={[
{ label: "Gewonnen", value: formatGrams(lot.quantity) },
{
label: "Rest",
value:
lot.remainingCount < 0
? `${formatGrams(lot.remainingCount)} (prüfen)`
: formatGrams(lot.remainingCount),
},
{
label: "Datum",
value: new Date(lot.date).toLocaleDateString("de-DE"),
},
{
label: "Verwendet in",
value: `${lot.usedIn.length} Charge(n)`,
},
]}
/>
))}
</div>
)}
</QueryState>
</main>
);
}
+32 -4
View File
@@ -8,6 +8,8 @@ import {
Crown,
Database,
Droplet,
Factory,
Boxes,
FileClock,
FlaskConical,
Hexagon,
@@ -81,6 +83,18 @@ const NAV_GROUPS: NavGroup[] = [
{ label: "Schleuderungen", href: "/extractions", icon: FlaskConical },
{ label: "Honigchargen", href: "/honey-batches", icon: Droplet },
{ label: "Abfüllungen", href: "/bottling", icon: Package },
{
label: "Rohstoffe",
href: "/raw-materials",
icon: Boxes,
permission: "raw-material:read",
},
{
label: "Herstellungschargen",
href: "/production-batches",
icon: Factory,
permission: "production-batch:read",
},
{ label: "Produkte", href: "/products", icon: ShoppingBag },
{ label: "Lager", href: "/storage", icon: Warehouse },
{ label: "Inventar", href: "/equipment", icon: Wrench },
@@ -121,7 +135,12 @@ const ADMIN_GROUP: NavGroup = {
icon: Database,
permission: ["backup:export", "backup:import", "import:execute"],
},
{ label: "Audit-Log", href: "/settings/audit-log", icon: History, permission: "audit:read" },
{
label: "Audit-Log",
href: "/settings/audit-log",
icon: History,
permission: "audit:read",
},
],
};
@@ -150,7 +169,10 @@ function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
);
}
function hasNavPermission(user: AuthUser, permission: NavItem["permission"]): boolean {
function hasNavPermission(
user: AuthUser,
permission: NavItem["permission"],
): boolean {
if (!permission) {
return true;
}
@@ -178,13 +200,19 @@ export function Sidebar({ user }: { user: AuthUser }) {
className="flex size-8 items-center justify-center rounded-xl shadow-[var(--shadow-soft)]"
style={{ background: "var(--gradient-honey)" }}
>
<Hexagon className="size-4.5 fill-white/25 text-white" strokeWidth={2.25} />
<Hexagon
className="size-4.5 fill-white/25 text-white"
strokeWidth={2.25}
/>
</span>
ApiCore
</Link>
<nav className="flex flex-1 flex-col gap-5">
{groups.map((group, index) => (
<div key={group.label ?? `group-${index}`} className="flex flex-col gap-0.5">
<div
key={group.label ?? `group-${index}`}
className="flex flex-col gap-0.5"
>
{group.label ? (
<p className="px-3 pb-1 text-xs font-semibold tracking-wide text-muted-foreground/80 uppercase">
{group.label}
@@ -0,0 +1,110 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { StockCard } from "./stock-card";
import { fetchStock } from "@/lib/stock/api";
import type { BatchStock } from "@/lib/stock/types";
vi.mock("@/lib/stock/api", () => ({
fetchStock: vi.fn(),
withdrawStock: vi.fn(),
correctStock: vi.fn(),
}));
function renderCard(canBook = true) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<StockCard
path="bottling-batches"
id="b1"
initialLabel="Abgefüllt"
unit="Gläser"
canBook={canBook}
/>
</QueryClientProvider>,
);
}
function stock(remaining: number): BatchStock {
return {
summary: {
initial: 120,
soldInShop: 30,
withdrawn: 10,
corrected: 0,
consumed: 0,
remaining,
},
movements: [
{
id: "m1",
type: "SHOP_SALE",
quantity: -30,
reason: null,
notes: null,
apiKeyId: "k1",
createdAt: "2026-10-01T10:00:00.000Z",
user: null,
shopRequest: { externalId: "x", kind: "SALE" },
},
{
id: "m2",
type: "WITHDRAWAL",
quantity: -10,
reason: "HOFLADEN",
notes: null,
apiKeyId: null,
createdAt: "2026-10-01T11:00:00.000Z",
user: { id: "u1", name: "Nick" },
shopRequest: null,
},
],
};
}
describe("StockCard", () => {
beforeEach(() => vi.mocked(fetchStock).mockReset());
it("separates bottled, sold, withdrawn and remaining", async () => {
vi.mocked(fetchStock).mockResolvedValue(stock(80));
renderCard();
expect(await screen.findByText("Abgefüllt")).toBeInTheDocument();
expect(screen.getByText("120")).toBeInTheDocument();
expect(screen.getByText("Webshop verkauft")).toBeInTheDocument();
expect(screen.getByText("80")).toBeInTheDocument();
expect(screen.queryByText("Prüfen")).not.toBeInTheDocument();
});
it("lists shop sales as coming from the shop and withdrawals with their reason", async () => {
vi.mocked(fetchStock).mockResolvedValue(stock(80));
renderCard();
expect(await screen.findByText("Webshop-Verkauf")).toBeInTheDocument();
expect(screen.getByText(/· Webshop$/)).toBeInTheDocument();
expect(screen.getByText("Entnahme · Hofladen")).toBeInTheDocument();
});
it("flags a negative remaining stock for review", async () => {
vi.mocked(fetchStock).mockResolvedValue(stock(-2));
renderCard();
expect(await screen.findByText("Prüfen")).toBeInTheDocument();
});
it("shows the booking buttons only with permission", async () => {
vi.mocked(fetchStock).mockResolvedValue(stock(80));
renderCard(false);
await screen.findByText("Abgefüllt");
expect(
screen.queryByRole("button", { name: /Entnehmen/ }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /Inventur/ }),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,453 @@
"use client";
import { FormEvent, useState } from "react";
import { ClipboardCheck, PackageMinus } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { correctStock, fetchStock, withdrawStock } from "@/lib/stock/api";
import {
MOVEMENT_TYPE_LABELS,
WITHDRAWAL_REASON_LABELS,
} from "@/lib/stock/labels";
import {
StockBatchPath,
StockMovement,
StockSummary,
WithdrawalReason,
} from "@/lib/stock/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
interface StockCardProps {
path: StockBatchPath;
id: string;
/** "Abgefüllt" for bottlings, "Hergestellt" for production batches. */
initialLabel: string;
/** Unit for counts, e.g. "Gläser" or "Stück". */
unit: string;
canBook: boolean;
}
/**
* Remaining stock of one batch: bottled/produced vs. what the shop sold, what
* was withdrawn here and inventory corrections - plus the two ways to book a
* change by hand. The numbers come from the movement ledger; nothing here is
* stored separately, so they cannot drift.
*/
export function StockCard({
path,
id,
initialLabel,
unit,
canBook,
}: StockCardProps) {
const stockQuery = useQuery({
queryKey: ["stock", path, id],
queryFn: () => fetchStock(path, id),
});
const stock = stockQuery.data;
return (
<Card>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2.5">
<CardTitle>Bestand</CardTitle>
{canBook && stock ? (
<div className="flex flex-wrap items-center gap-2">
<WithdrawDialog
path={path}
id={id}
unit={unit}
remaining={stock.summary.remaining}
/>
<CorrectDialog
path={path}
id={id}
unit={unit}
remaining={stock.summary.remaining}
/>
</div>
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-4">
{stockQuery.isError ? (
<p role="alert" className="text-sm text-destructive">
Bestand konnte nicht geladen werden.
</p>
) : !stock ? (
<p className="text-sm text-muted-foreground">Lädt…</p>
) : (
<>
<SummaryGrid
summary={stock.summary}
initialLabel={initialLabel}
isRawMaterial={path === "raw-material-lots"}
/>
<MovementList movements={stock.movements} />
</>
)}
</CardContent>
</Card>
);
}
function SummaryGrid({
summary,
initialLabel,
isRawMaterial,
}: {
isRawMaterial: boolean;
summary: StockSummary;
initialLabel: string;
}) {
const needsReview = summary.remaining < 0;
const items = [
{ label: initialLabel, value: summary.initial },
isRawMaterial
? { label: "In Herstellung verbraucht", value: summary.consumed }
: { label: "Webshop verkauft", value: summary.soldInShop },
{ label: "Entnommen", value: summary.withdrawn },
];
return (
<dl className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{items.map((item) => (
<div key={item.label} className="rounded-lg border p-3">
<dt className="text-xs text-muted-foreground">{item.label}</dt>
<dd className="text-xl font-semibold tabular-nums">{item.value}</dd>
</div>
))}
<div
className={
needsReview
? "rounded-lg border border-destructive bg-destructive/10 p-3"
: "rounded-lg border border-primary bg-primary/5 p-3"
}
>
<dt className="flex items-center gap-2 text-xs text-muted-foreground">
Rest
{needsReview ? <Badge variant="destructive">Prüfen</Badge> : null}
</dt>
<dd className="text-xl font-semibold tabular-nums">
{summary.remaining}
</dd>
{summary.corrected !== 0 ? (
<p className="text-xs text-muted-foreground">
inkl. Inventur {summary.corrected > 0 ? "+" : ""}
{summary.corrected}
</p>
) : null}
</div>
</dl>
);
}
function MovementList({ movements }: { movements: StockMovement[] }) {
if (movements.length === 0) {
return (
<p className="text-sm text-muted-foreground">
Noch keine Bewegungen - der Rest entspricht der Ausgangsmenge.
</p>
);
}
return (
<ul className="flex flex-col divide-y rounded-lg border text-sm">
{movements.map((movement) => (
<li
key={movement.id}
className="flex flex-wrap items-baseline justify-between gap-2 p-3"
>
<div className="flex flex-col">
<span className="font-medium">
{MOVEMENT_TYPE_LABELS[movement.type]}
{movement.reason
? ` · ${WITHDRAWAL_REASON_LABELS[movement.reason]}`
: ""}
</span>
<span className="text-xs text-muted-foreground">
{new Date(movement.createdAt).toLocaleString("de-DE")} ·{" "}
{movement.user?.name ??
(movement.shopRequest ? "Webshop" : "System")}
</span>
{movement.notes ? (
<span className="text-xs text-muted-foreground">
{movement.notes}
</span>
) : null}
</div>
<span
className={
movement.quantity < 0
? "font-semibold tabular-nums text-destructive"
: "font-semibold tabular-nums text-primary"
}
>
{movement.quantity > 0 ? "+" : ""}
{movement.quantity}
</span>
</li>
))}
</ul>
);
}
function useStockMutationDone(path: StockBatchPath, id: string) {
const queryClient = useQueryClient();
return () => {
void queryClient.invalidateQueries({ queryKey: ["stock", path, id] });
void queryClient.invalidateQueries({ queryKey: [path] });
};
}
function WithdrawDialog({
path,
id,
unit,
remaining,
}: {
path: StockBatchPath;
id: string;
unit: string;
remaining: number;
}) {
const [open, setOpen] = useState(false);
const [quantity, setQuantity] = useState("1");
const [reason, setReason] = useState<WithdrawalReason>("HOFLADEN");
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
const done = useStockMutationDone(path, id);
const mutation = useMutation({
mutationFn: () =>
withdrawStock(path, id, {
quantity: Number(quantity),
reason,
notes: notes.trim() || undefined,
}),
onSuccess: () => {
done();
toast.success(`${quantity} ${unit} entnommen`);
setOpen(false);
setQuantity("1");
setNotes("");
},
onError: (mutationError: Error) => {
const message =
mutationError.message || "Entnahme konnte nicht gebucht werden";
setError(message);
toast.error(message);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
mutation.mutate();
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button size="sm" disabled={remaining <= 0}>
<PackageMinus className="size-3.5" />
Entnehmen
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Entnehmen</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<p className="text-sm text-muted-foreground">
Noch da: {remaining} {unit}. Die Entnahme mindert auch den Bestand
im Webshop.
</p>
<div className="flex flex-col gap-2">
<Label htmlFor="withdraw-quantity">Anzahl ({unit})</Label>
<Input
id="withdraw-quantity"
type="number"
inputMode="numeric"
min={1}
max={remaining}
step={1}
required
value={quantity}
onChange={(event) => setQuantity(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="withdraw-reason">Grund</Label>
<Select
value={reason}
onValueChange={(value) =>
setReason((value ?? reason) as WithdrawalReason)
}
>
<SelectTrigger id="withdraw-reason" className="w-full">
<SelectValue>{WITHDRAWAL_REASON_LABELS[reason]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(
Object.keys(WITHDRAWAL_REASON_LABELS) as WithdrawalReason[]
).map((option) => (
<SelectItem key={option} value={option}>
{WITHDRAWAL_REASON_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="withdraw-notes">Notiz (optional)</Label>
<Textarea
id="withdraw-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Bucht…" : "Entnahme buchen"}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
function CorrectDialog({
path,
id,
unit,
remaining,
}: {
path: StockBatchPath;
id: string;
unit: string;
remaining: number;
}) {
const [open, setOpen] = useState(false);
const [counted, setCounted] = useState(String(Math.max(remaining, 0)));
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
const done = useStockMutationDone(path, id);
const difference = counted === "" ? 0 : Number(counted) - remaining;
const mutation = useMutation({
mutationFn: () =>
correctStock(path, id, {
countedQuantity: Number(counted),
notes: notes.trim(),
}),
onSuccess: () => {
done();
toast.success("Inventur gebucht");
setOpen(false);
setNotes("");
},
onError: (mutationError: Error) => {
const message =
mutationError.message || "Inventur konnte nicht gebucht werden";
setError(message);
toast.error(message);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
mutation.mutate();
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) setCounted(String(Math.max(remaining, 0)));
}}
>
<DialogTrigger
render={
<Button variant="outline" size="sm">
<ClipboardCheck className="size-3.5" />
Inventur korrigieren
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Inventur korrigieren</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<p className="text-sm text-muted-foreground">
Gebucht sind {remaining} {unit}. Trag ein, wie viele du gezählt hast
- gebucht wird die Differenz.
</p>
<div className="flex flex-col gap-2">
<Label htmlFor="correct-counted">Gezählt ({unit})</Label>
<Input
id="correct-counted"
type="number"
inputMode="numeric"
min={0}
step={1}
required
value={counted}
onChange={(event) => setCounted(event.target.value)}
/>
{difference !== 0 ? (
<p className="text-xs text-muted-foreground">
Differenz: {difference > 0 ? "+" : ""}
{difference} {unit}
</p>
) : null}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="correct-notes">Warum weicht der Bestand ab?</Label>
<Textarea
id="correct-notes"
required
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<Button
type="submit"
disabled={mutation.isPending || difference === 0}
>
{mutation.isPending ? "Bucht…" : "Inventur buchen"}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
+2 -2
View File
@@ -13,12 +13,12 @@ export function createApiKey(input: CreateApiKeyInput): Promise<CreatedApiKey> {
}
export function updateApiKeyAllowedIps(id: string, allowedIps: string[]): Promise<ApiKey> {
return apiFetch<ApiKey>(`/api-keys/${id}`, {
return apiFetch<ApiKey>(`/api-keys/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify({ allowedIps }),
});
}
export function revokeApiKey(id: string): Promise<ApiKey> {
return apiFetch<ApiKey>(`/api-keys/${id}/revoke`, { method: "POST" });
return apiFetch<ApiKey>(`/api-keys/${encodeURIComponent(id)}/revoke`, { method: "POST" });
}
+4 -4
View File
@@ -15,7 +15,7 @@ export function fetchApiaries(filter: ApiaryFilter = {}): Promise<Apiary[]> {
}
export function fetchApiary(id: string): Promise<Apiary> {
return apiFetch<Apiary>(`/apiaries/${id}`);
return apiFetch<Apiary>(`/apiaries/${encodeURIComponent(id)}`);
}
export function createApiary(input: ApiaryInput): Promise<Apiary> {
@@ -26,16 +26,16 @@ export function createApiary(input: ApiaryInput): Promise<Apiary> {
}
export function updateApiary(id: string, input: Partial<ApiaryInput>): Promise<Apiary> {
return apiFetch<Apiary>(`/apiaries/${id}`, {
return apiFetch<Apiary>(`/apiaries/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function deactivateApiary(id: string): Promise<Apiary> {
return apiFetch<Apiary>(`/apiaries/${id}/deactivate`, { method: "POST" });
return apiFetch<Apiary>(`/apiaries/${encodeURIComponent(id)}/deactivate`, { method: "POST" });
}
export function reactivateApiary(id: string): Promise<Apiary> {
return apiFetch<Apiary>(`/apiaries/${id}/reactivate`, { method: "POST" });
return apiFetch<Apiary>(`/apiaries/${encodeURIComponent(id)}/reactivate`, { method: "POST" });
}
+2 -2
View File
@@ -9,14 +9,14 @@ export function updateAutomationRule(
id: string,
input: UpdateAutomationRuleInput,
): Promise<AutomationRule> {
return apiFetch<AutomationRule>(`/automation-rules/${id}`, {
return apiFetch<AutomationRule>(`/automation-rules/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function resetAutomationRuleSequence(id: string): Promise<AutomationRule> {
return apiFetch<AutomationRule>(`/automation-rules/${id}/reset-sequence`, {
return apiFetch<AutomationRule>(`/automation-rules/${encodeURIComponent(id)}/reset-sequence`, {
method: "POST",
});
}
+3 -3
View File
@@ -16,7 +16,7 @@ export function fetchBottlingBatches(
}
export function fetchBottlingBatch(id: string): Promise<BottlingBatch> {
return apiFetch<BottlingBatch>(`/bottling-batches/${id}`);
return apiFetch<BottlingBatch>(`/bottling-batches/${encodeURIComponent(id)}`);
}
export function createBottlingBatch(
@@ -32,14 +32,14 @@ export function updateBottlingBatch(
id: string,
input: UpdateBottlingBatchInput,
): Promise<BottlingBatch> {
return apiFetch<BottlingBatch>(`/bottling-batches/${id}`, {
return apiFetch<BottlingBatch>(`/bottling-batches/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function deleteBottlingBatch(id: string): Promise<{ id: string }> {
return apiFetch<{ id: string }>(`/bottling-batches/${id}`, {
return apiFetch<{ id: string }>(`/bottling-batches/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
@@ -9,6 +9,8 @@ export interface BottlingBatch {
bestBeforeDate: string;
lotNumber: string;
createdAt: string;
/** jarCount plus all stock movements (shop sales, withdrawals, ...). */
remainingCount: number;
honeyBatch: { id: string; batchNumber: string };
product: { id: string; name: string } | null;
}
+8 -8
View File
@@ -24,11 +24,11 @@ export function fetchColonies(filter: ColonyFilter = {}): Promise<Colony[]> {
}
export function fetchColony(id: string): Promise<Colony> {
return apiFetch<Colony>(`/colonies/${id}`);
return apiFetch<Colony>(`/colonies/${encodeURIComponent(id)}`);
}
export function fetchColonyHistory(id: string): Promise<ColonyHistory> {
return apiFetch<ColonyHistory>(`/colonies/${id}/history`);
return apiFetch<ColonyHistory>(`/colonies/${encodeURIComponent(id)}/history`);
}
export function createColony(input: CreateColonyInput): Promise<Colony> {
@@ -39,37 +39,37 @@ export function createColony(input: CreateColonyInput): Promise<Colony> {
}
export function updateColony(id: string, input: UpdateColonyInput): Promise<Colony> {
return apiFetch<Colony>(`/colonies/${id}`, {
return apiFetch<Colony>(`/colonies/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function moveColony(id: string, input: MoveColonyInput): Promise<Colony> {
return apiFetch<Colony>(`/colonies/${id}/move`, {
return apiFetch<Colony>(`/colonies/${encodeURIComponent(id)}/move`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function splitColony(id: string, input: SplitColonyInput): Promise<Colony> {
return apiFetch<Colony>(`/colonies/${id}/split`, {
return apiFetch<Colony>(`/colonies/${encodeURIComponent(id)}/split`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function mergeColony(id: string, input: MergeColonyInput): Promise<Colony> {
return apiFetch<Colony>(`/colonies/${id}/merge`, {
return apiFetch<Colony>(`/colonies/${encodeURIComponent(id)}/merge`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function dissolveColony(id: string): Promise<Colony> {
return apiFetch<Colony>(`/colonies/${id}/dissolve`, { method: "POST" });
return apiFetch<Colony>(`/colonies/${encodeURIComponent(id)}/dissolve`, { method: "POST" });
}
export function reactivateColony(id: string): Promise<Colony> {
return apiFetch<Colony>(`/colonies/${id}/reactivate`, { method: "POST" });
return apiFetch<Colony>(`/colonies/${encodeURIComponent(id)}/reactivate`, { method: "POST" });
}
+3 -3
View File
@@ -30,17 +30,17 @@ export function updateDocument(
id: string,
input: UpdateDocumentInput,
): Promise<DocumentSummary> {
return apiFetch<DocumentSummary>(`/documents/${id}`, {
return apiFetch<DocumentSummary>(`/documents/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function deleteDocument(id: string): Promise<void> {
return apiFetch<void>(`/documents/${id}`, { method: "DELETE" });
return apiFetch<void>(`/documents/${encodeURIComponent(id)}`, { method: "DELETE" });
}
export async function downloadDocument(id: string, fileName: string): Promise<void> {
const { blob, filename } = await apiFetchBlob(`/documents/${id}/download`, fileName);
const { blob, filename } = await apiFetchBlob(`/documents/${encodeURIComponent(id)}/download`, fileName);
downloadBlob(blob, filename);
}
+2 -2
View File
@@ -14,7 +14,7 @@ export function fetchEquipmentList(filter: EquipmentFilter = {}): Promise<Equipm
}
export function fetchEquipment(id: string): Promise<Equipment> {
return apiFetch<Equipment>(`/equipment/${id}`);
return apiFetch<Equipment>(`/equipment/${encodeURIComponent(id)}`);
}
export function createEquipment(input: CreateEquipmentInput): Promise<Equipment> {
@@ -28,7 +28,7 @@ export function updateEquipment(
id: string,
input: UpdateEquipmentInput,
): Promise<Equipment> {
return apiFetch<Equipment>(`/equipment/${id}`, {
return apiFetch<Equipment>(`/equipment/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
+2 -2
View File
@@ -6,7 +6,7 @@ export function fetchExtractions(): Promise<Extraction[]> {
}
export function fetchExtraction(id: string): Promise<Extraction> {
return apiFetch<Extraction>(`/extractions/${id}`);
return apiFetch<Extraction>(`/extractions/${encodeURIComponent(id)}`);
}
export function createExtraction(input: CreateExtractionInput): Promise<Extraction> {
@@ -20,7 +20,7 @@ export function updateExtraction(
id: string,
input: UpdateExtractionInput,
): Promise<Extraction> {
return apiFetch<Extraction>(`/extractions/${id}`, {
return apiFetch<Extraction>(`/extractions/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
+3 -3
View File
@@ -8,11 +8,11 @@ import {
} from "./types";
export function fetchColonyFeedings(colonyId: string): Promise<Feeding[]> {
return apiFetch<Feeding[]>(`/colonies/${colonyId}/feedings`);
return apiFetch<Feeding[]>(`/colonies/${encodeURIComponent(colonyId)}/feedings`);
}
export function fetchColonyFeedingsSummary(colonyId: string): Promise<FeedingSummaryEntry[]> {
return apiFetch<FeedingSummaryEntry[]>(`/colonies/${colonyId}/feedings/summary`);
return apiFetch<FeedingSummaryEntry[]>(`/colonies/${encodeURIComponent(colonyId)}/feedings/summary`);
}
export function createFeeding(
@@ -30,7 +30,7 @@ export function updateFeeding(
id: string,
input: UpdateFeedingInput,
): Promise<Feeding> {
return apiFetch<Feeding>(`/feedings/${id}`, {
return apiFetch<Feeding>(`/feedings/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
+2 -2
View File
@@ -7,7 +7,7 @@ export function fetchHarvests(): Promise<Harvest[]> {
}
export function fetchHarvest(id: string): Promise<Harvest> {
return apiFetch<Harvest>(`/harvests/${id}`);
return apiFetch<Harvest>(`/harvests/${encodeURIComponent(id)}`);
}
export function createHarvest(
@@ -22,7 +22,7 @@ export function createHarvest(
}
export function updateHarvest(id: string, input: UpdateHarvestInput): Promise<Harvest> {
return apiFetch<Harvest>(`/harvests/${id}`, {
return apiFetch<Harvest>(`/harvests/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
+10 -10
View File
@@ -24,11 +24,11 @@ export function fetchHoneyBatches(filter: HoneyBatchFilter = {}): Promise<HoneyB
}
export function fetchHoneyBatch(id: string): Promise<HoneyBatch> {
return apiFetch<HoneyBatch>(`/honey-batches/${id}`);
return apiFetch<HoneyBatch>(`/honey-batches/${encodeURIComponent(id)}`);
}
export function fetchHoneyBatchTraceability(id: string): Promise<Traceability> {
return apiFetch<Traceability>(`/honey-batches/${id}/traceability`);
return apiFetch<Traceability>(`/honey-batches/${encodeURIComponent(id)}/traceability`);
}
export function createHoneyBatch(input: CreateHoneyBatchInput): Promise<HoneyBatch> {
@@ -42,7 +42,7 @@ export function updateHoneyBatch(
id: string,
input: UpdateHoneyBatchInput,
): Promise<HoneyBatch> {
return apiFetch<HoneyBatch>(`/honey-batches/${id}`, {
return apiFetch<HoneyBatch>(`/honey-batches/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
@@ -52,7 +52,7 @@ export function updateHoneyMeasurement(
measurementId: string,
input: UpdateHoneyMeasurementInput,
): Promise<HoneyBatch> {
return apiFetch<HoneyBatch>(`/honey-batches/measurements/${measurementId}`, {
return apiFetch<HoneyBatch>(`/honey-batches/measurements/${encodeURIComponent(measurementId)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
@@ -62,7 +62,7 @@ export function addHoneyMeasurement(
id: string,
input: CreateHoneyMeasurementInput,
): Promise<HoneyBatch> {
return apiFetch<HoneyBatch>(`/honey-batches/${id}/measurements`, {
return apiFetch<HoneyBatch>(`/honey-batches/${encodeURIComponent(id)}/measurements`, {
method: "POST",
body: JSON.stringify(input),
});
@@ -72,20 +72,20 @@ export function relocateHoneyBatch(
id: string,
input: RelocateHoneyBatchInput,
): Promise<HoneyBatch> {
return apiFetch<HoneyBatch>(`/honey-batches/${id}/relocate`, {
return apiFetch<HoneyBatch>(`/honey-batches/${encodeURIComponent(id)}/relocate`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function fetchHoneyBatchRelocations(id: string): Promise<HoneyBatchRelocation[]> {
return apiFetch<HoneyBatchRelocation[]>(`/honey-batches/${id}/relocations`);
return apiFetch<HoneyBatchRelocation[]>(`/honey-batches/${encodeURIComponent(id)}/relocations`);
}
export function fetchHoneyBatchContainerAllocations(
id: string,
): Promise<HoneyBatchContainerAllocation[]> {
return apiFetch<HoneyBatchContainerAllocation[]>(`/honey-batches/${id}/containers`);
return apiFetch<HoneyBatchContainerAllocation[]>(`/honey-batches/${encodeURIComponent(id)}/containers`);
}
export function setHoneyBatchContainerAllocation(
@@ -94,7 +94,7 @@ export function setHoneyBatchContainerAllocation(
amount: number,
): Promise<HoneyBatchContainerAllocation> {
return apiFetch<HoneyBatchContainerAllocation>(
`/honey-batches/${id}/containers/${containerId}`,
`/honey-batches/${encodeURIComponent(id)}/containers/${encodeURIComponent(containerId)}`,
{ method: "PUT", body: JSON.stringify({ amount }) },
);
}
@@ -103,7 +103,7 @@ export function removeHoneyBatchContainerAllocation(
id: string,
containerId: string,
): Promise<void> {
return apiFetch<void>(`/honey-batches/${id}/containers/${containerId}`, {
return apiFetch<void>(`/honey-batches/${encodeURIComponent(id)}/containers/${encodeURIComponent(containerId)}`, {
method: "DELETE",
});
}
+1 -1
View File
@@ -4,5 +4,5 @@ import { ImportEntity, ImportReport } from "./types";
export function importCsv(entity: ImportEntity, file: File): Promise<ImportReport> {
const formData = new FormData();
formData.append("file", file);
return apiFetchFormData<ImportReport>(`/import/${entity}`, formData);
return apiFetchFormData<ImportReport>(`/import/${encodeURIComponent(entity)}`, formData);
}
+3 -3
View File
@@ -9,7 +9,7 @@ import {
} from "./types";
export function fetchColonyInspections(colonyId: string): Promise<Inspection[]> {
return apiFetch<Inspection[]>(`/colonies/${colonyId}/inspections`);
return apiFetch<Inspection[]>(`/colonies/${encodeURIComponent(colonyId)}/inspections`);
}
export function createInspection(
@@ -27,7 +27,7 @@ export function updateInspection(
id: string,
input: UpdateInspectionInput,
): Promise<Inspection> {
return apiFetch<Inspection>(`/inspections/${id}`, {
return apiFetch<Inspection>(`/inspections/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
@@ -37,7 +37,7 @@ export function createTaskFromInspection(
id: string,
input: CreateTaskFromInspectionInput,
): Promise<InspectionTask> {
return apiFetch<InspectionTask>(`/inspections/${id}/create-task`, {
return apiFetch<InspectionTask>(`/inspections/${encodeURIComponent(id)}/create-task`, {
method: "POST",
body: JSON.stringify(input),
});
@@ -17,14 +17,14 @@ export function updateLabelTemplate(
id: string,
input: UpdateLabelTemplateInput,
): Promise<LabelTemplate> {
return apiFetch<LabelTemplate>(`/label-templates/${id}`, {
return apiFetch<LabelTemplate>(`/label-templates/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function deactivateLabelTemplate(id: string): Promise<LabelTemplate> {
return apiFetch<LabelTemplate>(`/label-templates/${id}`, { method: "DELETE" });
return apiFetch<LabelTemplate>(`/label-templates/${encodeURIComponent(id)}`, { method: "DELETE" });
}
export async function printLabels(
@@ -34,7 +34,7 @@ export async function printLabels(
): Promise<void> {
const query = startPosition && startPosition > 1 ? `?startPosition=${startPosition}` : "";
const { blob, filename } = await apiFetchBlob(
`/label-templates/${templateId}/print/${bottlingBatchId}${query}`,
`/label-templates/${encodeURIComponent(templateId)}/print/${encodeURIComponent(bottlingBatchId)}${query}`,
"etiketten.pdf",
);
downloadBlob(blob, filename);
@@ -6,5 +6,5 @@ export function fetchNotifications(unreadOnly = false): Promise<Notification[]>
}
export function markNotificationRead(id: string): Promise<Notification> {
return apiFetch<Notification>(`/notifications/${id}/read`, { method: "POST" });
return apiFetch<Notification>(`/notifications/${encodeURIComponent(id)}/read`, { method: "POST" });
}
@@ -0,0 +1,55 @@
import { apiFetch } from "@/lib/auth/api-client";
import {
CreateProductionBatchInput,
ProductionBatch,
ProductionBatchFilter,
UpdateProductionBatchInput,
} from "./types";
export function fetchProductionBatches(
filter: ProductionBatchFilter = {},
): Promise<ProductionBatch[]> {
const params = new URLSearchParams();
if (filter.productId) params.set("productId", filter.productId);
const query = params.toString();
return apiFetch<ProductionBatch[]>(
`/production-batches${query ? `?${query}` : ""}`,
);
}
export function fetchProductionBatch(id: string): Promise<ProductionBatch> {
return apiFetch<ProductionBatch>(
`/production-batches/${encodeURIComponent(id)}`,
);
}
export function createProductionBatch(
input: CreateProductionBatchInput,
): Promise<ProductionBatch> {
return apiFetch<ProductionBatch>("/production-batches", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateProductionBatch(
id: string,
input: UpdateProductionBatchInput,
): Promise<ProductionBatch> {
return apiFetch<ProductionBatch>(
`/production-batches/${encodeURIComponent(id)}`,
{
method: "PATCH",
body: JSON.stringify(input),
},
);
}
export function deleteProductionBatch(id: string): Promise<{ id: string }> {
return apiFetch<{ id: string }>(
`/production-batches/${encodeURIComponent(id)}`,
{
method: "DELETE",
},
);
}
@@ -0,0 +1,45 @@
export interface ProductionBatch {
id: string;
productId: string;
lotNumber: string;
date: string;
quantity: number;
bestBeforeDate: string | null;
notes: string | null;
createdAt: string;
remainingCount: number;
product: { id: string; name: string };
/** Raw material this batch was made from (grams). */
inputs?: {
id: string;
quantity: number;
rawMaterialLot: {
id: string;
lotNumber: string;
kind: "BEESWAX" | "PROPOLIS";
};
}[];
}
export interface CreateProductionBatchInput {
productId: string;
lotNumber: string;
date: string;
quantity: number;
bestBeforeDate?: string;
notes?: string;
inputs?: { rawMaterialLotId: string; quantity: number }[];
}
export interface UpdateProductionBatchInput {
lotNumber?: string;
date?: string;
quantity?: number;
/** `null` removes the date, `undefined` leaves it untouched. */
bestBeforeDate?: string | null;
notes?: string;
}
export interface ProductionBatchFilter {
productId?: string;
}
+2 -2
View File
@@ -14,7 +14,7 @@ export function fetchProducts(filter: ProductFilter = {}): Promise<Product[]> {
}
export function fetchProduct(id: string): Promise<Product> {
return apiFetch<Product>(`/products/${id}`);
return apiFetch<Product>(`/products/${encodeURIComponent(id)}`);
}
export function createProduct(input: CreateProductInput): Promise<Product> {
@@ -25,7 +25,7 @@ export function createProduct(input: CreateProductInput): Promise<Product> {
}
export function updateProduct(id: string, input: UpdateProductInput): Promise<Product> {
return apiFetch<Product>(`/products/${id}`, {
return apiFetch<Product>(`/products/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
+13 -1
View File
@@ -1,6 +1,18 @@
import { ProductStatus } from "./types";
import { ProductKind, ProductStatus, ShopCategory } from "./types";
export const PRODUCT_STATUS_LABELS: Record<ProductStatus, string> = {
ACTIVE: "Aktiv",
DISCONTINUED: "Eingestellt",
};
export const PRODUCT_KIND_LABELS: Record<ProductKind, string> = {
BOTTLED_HONEY: "Honig (Bestand je Abfüllung)",
MANUFACTURED: "Hergestellte Ware (Bestand je Herstellungscharge)",
};
export const SHOP_CATEGORY_LABELS: Record<ShopCategory, string> = {
HONIG: "Honig",
KERZEN: "Kerzen",
PROPOLIS: "Propolis",
SONSTIGES: "Sonstiges",
};
@@ -1,5 +1,10 @@
export type ProductStatus = "ACTIVE" | "DISCONTINUED";
/** Honey: stock per bottling. Everything else: stock per production batch. */
export type ProductKind = "BOTTLED_HONEY" | "MANUFACTURED";
export type ShopCategory = "HONIG" | "KERZEN" | "PROPOLIS" | "SONSTIGES";
export interface Product {
id: string;
name: string;
@@ -9,6 +14,8 @@ export interface Product {
weight: number | null;
salesUnit: string | null;
status: ProductStatus;
kind: ProductKind;
shopCategory: ShopCategory;
createdAt: string;
updatedAt: string;
}
@@ -20,6 +27,8 @@ export interface CreateProductInput {
packagingSize?: number;
weight?: number;
salesUnit?: string;
kind?: ProductKind;
shopCategory?: ShopCategory;
}
export interface UpdateProductInput extends Partial<CreateProductInput> {
+2 -2
View File
@@ -9,11 +9,11 @@ export type QrEntityType =
| "bottling-batches";
export async function fetchQrCodeSvg(entityType: QrEntityType, id: string): Promise<Blob> {
const { blob } = await apiFetchBlob(`/qr/${entityType}/${id}.svg`, `${entityType}.svg`);
const { blob } = await apiFetchBlob(`/qr/${encodeURIComponent(entityType)}/${encodeURIComponent(id)}.svg`, `${entityType}.svg`);
return blob;
}
export async function fetchQrLink(entityType: QrEntityType, id: string): Promise<string> {
const { url } = await apiFetch<{ url: string }>(`/qr/${entityType}/${id}/link`);
const { url } = await apiFetch<{ url: string }>(`/qr/${encodeURIComponent(entityType)}/${encodeURIComponent(id)}/link`);
return url;
}
+8 -8
View File
@@ -24,15 +24,15 @@ export function fetchQueens(filter: QueenFilter = {}): Promise<Queen[]> {
}
export function fetchQueen(id: string): Promise<Queen> {
return apiFetch<Queen>(`/queens/${id}`);
return apiFetch<Queen>(`/queens/${encodeURIComponent(id)}`);
}
export function fetchQueenHistory(id: string): Promise<QueenColonyHistoryEntry[]> {
return apiFetch<QueenColonyHistoryEntry[]>(`/queens/${id}/history`);
return apiFetch<QueenColonyHistoryEntry[]>(`/queens/${encodeURIComponent(id)}/history`);
}
export function fetchQueenLineage(id: string): Promise<QueenLineage> {
return apiFetch<QueenLineage>(`/queens/${id}/lineage`);
return apiFetch<QueenLineage>(`/queens/${encodeURIComponent(id)}/lineage`);
}
export function createQueen(input: CreateQueenInput): Promise<Queen> {
@@ -43,28 +43,28 @@ export function createQueen(input: CreateQueenInput): Promise<Queen> {
}
export function updateQueen(id: string, input: UpdateQueenInput): Promise<Queen> {
return apiFetch<Queen>(`/queens/${id}`, {
return apiFetch<Queen>(`/queens/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function assignQueenColony(id: string, input: AssignColonyInput): Promise<Queen> {
return apiFetch<Queen>(`/queens/${id}/assign-colony`, {
return apiFetch<Queen>(`/queens/${encodeURIComponent(id)}/assign-colony`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function fetchQueenEvaluations(id: string): Promise<QueenEvaluation[]> {
return apiFetch<QueenEvaluation[]>(`/queens/${id}/evaluations`);
return apiFetch<QueenEvaluation[]>(`/queens/${encodeURIComponent(id)}/evaluations`);
}
export function updateQueenEvaluation(
evaluationId: string,
input: UpdateQueenEvaluationInput,
): Promise<QueenEvaluation> {
return apiFetch<QueenEvaluation>(`/queens/evaluations/${evaluationId}`, {
return apiFetch<QueenEvaluation>(`/queens/evaluations/${encodeURIComponent(evaluationId)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
@@ -74,7 +74,7 @@ export function addQueenEvaluation(
id: string,
input: CreateQueenEvaluationInput,
): Promise<QueenEvaluation> {
return apiFetch<QueenEvaluation>(`/queens/${id}/evaluations`, {
return apiFetch<QueenEvaluation>(`/queens/${encodeURIComponent(id)}/evaluations`, {
method: "POST",
body: JSON.stringify(input),
});
@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch } from "@/lib/auth/api-client";
import { deleteRawMaterialLot, fetchRawMaterialLot } from "./api";
import { fetchStock } from "@/lib/stock/api";
import { fetchProductionBatch } from "@/lib/production-batches/api";
vi.mock("@/lib/auth/api-client", () => ({ apiFetch: vi.fn(async () => ({})) }));
/**
* Ids come from the URL (useParams). Unencoded, a crafted link such as
* /raw-materials/..%2Fusers would make the page call another endpoint with
* the viewer's token (client-side path traversal). Every helper must keep
* the id inside its own path segment.
*/
describe("id encoding in API paths", () => {
beforeEach(() => vi.mocked(apiFetch).mockClear());
const hostile = "../users";
it("keeps a raw material id inside its path segment", async () => {
await fetchRawMaterialLot(hostile);
await deleteRawMaterialLot(hostile);
expect(vi.mocked(apiFetch).mock.calls.map(([path]) => path)).toEqual([
"/raw-material-lots/..%2Fusers",
"/raw-material-lots/..%2Fusers",
]);
});
it("keeps stock and production batch ids inside their path segment", async () => {
await fetchStock("production-batches", hostile);
await fetchProductionBatch(hostile);
expect(vi.mocked(apiFetch).mock.calls.map(([path]) => path)).toEqual([
"/production-batches/..%2Fusers/stock",
"/production-batches/..%2Fusers",
]);
});
});
@@ -0,0 +1,38 @@
import { apiFetch } from "@/lib/auth/api-client";
import {
CreateRawMaterialLotInput,
RawMaterialKind,
RawMaterialLot,
} from "./types";
export function fetchRawMaterialLots(
kind?: RawMaterialKind,
): Promise<RawMaterialLot[]> {
return apiFetch<RawMaterialLot[]>(
`/raw-material-lots${kind ? `?kind=${kind}` : ""}`,
);
}
export function fetchRawMaterialLot(id: string): Promise<RawMaterialLot> {
return apiFetch<RawMaterialLot>(
`/raw-material-lots/${encodeURIComponent(id)}`,
);
}
export function createRawMaterialLot(
input: CreateRawMaterialLotInput,
): Promise<RawMaterialLot> {
return apiFetch<RawMaterialLot>("/raw-material-lots", {
method: "POST",
body: JSON.stringify(input),
});
}
export function deleteRawMaterialLot(id: string): Promise<{ id: string }> {
return apiFetch<{ id: string }>(
`/raw-material-lots/${encodeURIComponent(id)}`,
{
method: "DELETE",
},
);
}
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { formatGrams } from "./labels";
describe("formatGrams", () => {
it("shows kilograms from 1000 g on", () => {
expect(formatGrams(4200)).toBe("4,2 kg");
expect(formatGrams(1000)).toBe("1 kg");
});
it("keeps grams below a kilogram", () => {
expect(formatGrams(180)).toBe("180 g");
});
});
@@ -0,0 +1,14 @@
import { RawMaterialKind } from "./types";
export const RAW_MATERIAL_KIND_LABELS: Record<RawMaterialKind, string> = {
BEESWAX: "Bienenwachs",
PROPOLIS: "Propolis",
};
/** 4200 -> "4,2 kg", 180 -> "180 g". */
export function formatGrams(grams: number): string {
if (Math.abs(grams) >= 1000) {
return `${(grams / 1000).toLocaleString("de-DE", { maximumFractionDigits: 2 })} kg`;
}
return `${grams.toLocaleString("de-DE")} g`;
}
@@ -0,0 +1,36 @@
export type RawMaterialKind = "BEESWAX" | "PROPOLIS";
export interface RawMaterialLot {
id: string;
kind: RawMaterialKind;
lotNumber: string;
date: string;
/** Gewonnene Menge in Gramm. */
quantity: number;
apiaryId: string | null;
harvestId: string | null;
notes: string | null;
createdAt: string;
/** Rest in Gramm (Ausgangsmenge plus alle Bewegungen). */
remainingCount: number;
apiary: { id: string; name: string } | null;
harvest: { id: string; date: string } | null;
usedIn: {
id: string;
quantity: number;
productionBatch: {
id: string;
lotNumber: string;
product: { id: string; name: string };
};
}[];
}
export interface CreateRawMaterialLotInput {
kind: RawMaterialKind;
lotNumber: string;
date: string;
quantity: number;
apiaryId?: string;
notes?: string;
}
+3 -3
View File
@@ -22,16 +22,16 @@ export function createRole(input: CreateRoleInput): Promise<RoleSummary> {
}
export function updateRole(id: string, input: UpdateRoleInput): Promise<RoleSummary> {
return apiFetch<RoleSummary>(`/roles/${id}`, {
return apiFetch<RoleSummary>(`/roles/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function deactivateRole(id: string): Promise<RoleSummary> {
return apiFetch<RoleSummary>(`/roles/${id}/deactivate`, { method: "POST" });
return apiFetch<RoleSummary>(`/roles/${encodeURIComponent(id)}/deactivate`, { method: "POST" });
}
export function reactivateRole(id: string): Promise<RoleSummary> {
return apiFetch<RoleSummary>(`/roles/${id}/reactivate`, { method: "POST" });
return apiFetch<RoleSummary>(`/roles/${encodeURIComponent(id)}/reactivate`, { method: "POST" });
}
+42
View File
@@ -0,0 +1,42 @@
import { apiFetch } from "@/lib/auth/api-client";
import {
BatchStock,
CorrectInput,
StockBatchPath,
WithdrawInput,
} from "./types";
export function fetchStock(
path: StockBatchPath,
id: string,
): Promise<BatchStock> {
return apiFetch<BatchStock>(`/${encodeURIComponent(path)}/${encodeURIComponent(id)}/stock`);
}
export function withdrawStock(
path: StockBatchPath,
id: string,
input: WithdrawInput,
): Promise<BatchStock> {
return apiFetch<BatchStock>(
`/${encodeURIComponent(path)}/${encodeURIComponent(id)}/withdrawals`,
{
method: "POST",
body: JSON.stringify(input),
},
);
}
export function correctStock(
path: StockBatchPath,
id: string,
input: CorrectInput,
): Promise<BatchStock> {
return apiFetch<BatchStock>(
`/${encodeURIComponent(path)}/${encodeURIComponent(id)}/corrections`,
{
method: "POST",
body: JSON.stringify(input),
},
);
}
+17
View File
@@ -0,0 +1,17 @@
import { MovementType, WithdrawalReason } from "./types";
export const WITHDRAWAL_REASON_LABELS: Record<WithdrawalReason, string> = {
HOFLADEN: "Hofladen",
EIGENBEDARF: "Eigenbedarf / Geschenk",
BRUCH: "Bruch / Verlust",
PROBE: "Probe / Muster",
SONSTIGES: "Sonstiges",
};
export const MOVEMENT_TYPE_LABELS: Record<MovementType, string> = {
SHOP_SALE: "Webshop-Verkauf",
SHOP_RETURN: "Webshop-Rückbuchung",
WITHDRAWAL: "Entnahme",
CORRECTION: "Inventur",
CONSUMPTION: "Verbrauch in Herstellung",
};
+51
View File
@@ -0,0 +1,51 @@
/** Collection path of a batch whose stock is tracked in the movement ledger. */
export type StockBatchPath =
"bottling-batches" | "production-batches" | "raw-material-lots";
export type WithdrawalReason =
"HOFLADEN" | "EIGENBEDARF" | "BRUCH" | "PROBE" | "SONSTIGES";
export type MovementType =
"SHOP_SALE" | "SHOP_RETURN" | "WITHDRAWAL" | "CORRECTION" | "CONSUMPTION";
export interface StockSummary {
/** Bottled or produced. */
initial: number;
/** Net shop sales (sales minus shop returns). */
soldInShop: number;
withdrawn: number;
/** Net inventory corrections. */
corrected: number;
/** Raw material used in production (net of give-backs). */
consumed: number;
remaining: number;
}
export interface StockMovement {
id: string;
type: MovementType;
/** Signed: negative takes stock out. */
quantity: number;
reason: WithdrawalReason | null;
notes: string | null;
apiKeyId: string | null;
createdAt: string;
user: { id: string; name: string } | null;
shopRequest: { externalId: string; kind: "SALE" | "RETURN" } | null;
}
export interface BatchStock {
summary: StockSummary;
movements: StockMovement[];
}
export interface WithdrawInput {
quantity: number;
reason: WithdrawalReason;
notes?: string;
}
export interface CorrectInput {
countedQuantity: number;
notes: string;
}
+3 -3
View File
@@ -26,7 +26,7 @@ export function updateStorageContainer(
id: string,
input: UpdateStorageContainerInput,
): Promise<StorageContainer> {
return apiFetch<StorageContainer>(`/storage-containers/${id}`, {
return apiFetch<StorageContainer>(`/storage-containers/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
@@ -47,7 +47,7 @@ export function updateStockItem(
id: string,
input: UpdateStockItemInput,
): Promise<StockItem> {
return apiFetch<StockItem>(`/stock-items/${id}`, {
return apiFetch<StockItem>(`/stock-items/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
@@ -57,7 +57,7 @@ export function addStockMovement(
id: string,
input: CreateStockMovementInput,
): Promise<StockItem> {
return apiFetch<StockItem>(`/stock-items/${id}/movements`, {
return apiFetch<StockItem>(`/stock-items/${encodeURIComponent(id)}/movements`, {
method: "POST",
body: JSON.stringify(input),
});
@@ -7,7 +7,7 @@ import {
} from "./types";
export function fetchColonySwarmControlChecks(colonyId: string): Promise<SwarmControlCheck[]> {
return apiFetch<SwarmControlCheck[]>(`/colonies/${colonyId}/swarm-control-checks`);
return apiFetch<SwarmControlCheck[]>(`/colonies/${encodeURIComponent(colonyId)}/swarm-control-checks`);
}
export function createSwarmControlCheck(
@@ -25,7 +25,7 @@ export function updateSwarmControlCheck(
id: string,
input: UpdateSwarmControlCheckInput,
): Promise<SwarmControlCheck> {
return apiFetch<SwarmControlCheck>(`/swarm-control-checks/${id}`, {
return apiFetch<SwarmControlCheck>(`/swarm-control-checks/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
@@ -13,12 +13,12 @@ export function createTaskTemplate(input: CreateTaskTemplateInput): Promise<Task
}
export function updateTaskTemplate(id: string, input: UpdateTaskTemplateInput): Promise<TaskTemplate> {
return apiFetch<TaskTemplate>(`/task-templates/${id}`, {
return apiFetch<TaskTemplate>(`/task-templates/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function deactivateTaskTemplate(id: string): Promise<TaskTemplate> {
return apiFetch<TaskTemplate>(`/task-templates/${id}/deactivate`, { method: "POST" });
return apiFetch<TaskTemplate>(`/task-templates/${encodeURIComponent(id)}/deactivate`, { method: "POST" });
}
+2 -2
View File
@@ -17,12 +17,12 @@ export function createTask(input: CreateTaskInput): Promise<Task> {
}
export function updateTask(id: string, input: UpdateTaskInput): Promise<Task> {
return apiFetch<Task>(`/tasks/${id}`, {
return apiFetch<Task>(`/tasks/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function completeTask(id: string): Promise<Task> {
return apiFetch<Task>(`/tasks/${id}/complete`, { method: "POST" });
return apiFetch<Task>(`/tasks/${encodeURIComponent(id)}/complete`, { method: "POST" });
}
+2 -2
View File
@@ -8,7 +8,7 @@ import {
} from "./types";
export function fetchColonyTreatments(colonyId: string): Promise<Treatment[]> {
return apiFetch<Treatment[]>(`/colonies/${colonyId}/treatments`);
return apiFetch<Treatment[]>(`/colonies/${encodeURIComponent(colonyId)}/treatments`);
}
export function fetchWithdrawalStatus(
@@ -37,7 +37,7 @@ export function updateTreatment(
id: string,
input: UpdateTreatmentInput,
): Promise<Treatment> {
return apiFetch<Treatment>(`/treatments/${id}`, {
return apiFetch<Treatment>(`/treatments/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
+5 -5
View File
@@ -13,7 +13,7 @@ export function createUser(input: CreateUserInput): Promise<User> {
}
export function updateUser(id: string, input: UpdateUserInput): Promise<User> {
return apiFetch<User>(`/users/${id}`, {
return apiFetch<User>(`/users/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
@@ -23,20 +23,20 @@ export function resetUserPassword(
id: string,
password: string,
): Promise<User> {
return apiFetch<User>(`/users/${id}/reset-password`, {
return apiFetch<User>(`/users/${encodeURIComponent(id)}/reset-password`, {
method: "POST",
body: JSON.stringify({ password }),
});
}
export function deactivateUser(id: string): Promise<User> {
return apiFetch<User>(`/users/${id}/deactivate`, { method: "POST" });
return apiFetch<User>(`/users/${encodeURIComponent(id)}/deactivate`, { method: "POST" });
}
export function activateUser(id: string): Promise<User> {
return apiFetch<User>(`/users/${id}/activate`, { method: "POST" });
return apiFetch<User>(`/users/${encodeURIComponent(id)}/activate`, { method: "POST" });
}
export function resetUserMfa(id: string): Promise<User> {
return apiFetch<User>(`/users/${id}/mfa/reset`, { method: "POST" });
return apiFetch<User>(`/users/${encodeURIComponent(id)}/mfa/reset`, { method: "POST" });
}
+2 -2
View File
@@ -7,7 +7,7 @@ import {
} from "./types";
export function fetchColonyVarroaMeasurements(colonyId: string): Promise<VarroaMeasurement[]> {
return apiFetch<VarroaMeasurement[]>(`/colonies/${colonyId}/varroa-measurements`);
return apiFetch<VarroaMeasurement[]>(`/colonies/${encodeURIComponent(colonyId)}/varroa-measurements`);
}
export function createVarroaMeasurement(
@@ -25,7 +25,7 @@ export function updateVarroaMeasurement(
id: string,
input: UpdateVarroaMeasurementInput,
): Promise<VarroaMeasurement> {
return apiFetch<VarroaMeasurement>(`/varroa-measurements/${id}`, {
return apiFetch<VarroaMeasurement>(`/varroa-measurements/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
+2 -2
View File
@@ -2,9 +2,9 @@ import { apiFetch } from "@/lib/auth/api-client";
import { ApiaryWeather, Trachtprognose } from "./types";
export function fetchApiaryWeather(apiaryId: string): Promise<ApiaryWeather> {
return apiFetch<ApiaryWeather>(`/apiaries/${apiaryId}/weather`);
return apiFetch<ApiaryWeather>(`/apiaries/${encodeURIComponent(apiaryId)}/weather`);
}
export function fetchTrachtprognose(apiaryId: string): Promise<Trachtprognose> {
return apiFetch<Trachtprognose>(`/apiaries/${apiaryId}/trachtprognose`);
return apiFetch<Trachtprognose>(`/apiaries/${encodeURIComponent(apiaryId)}/trachtprognose`);
}
+2 -2
View File
@@ -21,12 +21,12 @@ export function updateWebhook(
id: string,
input: UpdateWebhookInput,
): Promise<WebhookSubscription> {
return apiFetch<WebhookSubscription>(`/webhooks/${id}`, {
return apiFetch<WebhookSubscription>(`/webhooks/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function deleteWebhook(id: string): Promise<void> {
return apiFetch<void>(`/webhooks/${id}`, { method: "DELETE" });
return apiFetch<void>(`/webhooks/${encodeURIComponent(id)}`, { method: "DELETE" });
}
@@ -30,7 +30,7 @@ export function updateWinterCheck(
id: string,
input: Partial<WinterCheckInput>,
): Promise<WinterCheck> {
return apiFetch<WinterCheck>(`/winter-checks/${id}`, {
return apiFetch<WinterCheck>(`/winter-checks/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
@@ -0,0 +1,242 @@
# Bestandsabgleich ApiCore ↔ Wabenhain — Design
Stand: 2026-10-02 · Betrifft: ApiCore (Backend + Frontend) und Wabenhain (Webshop)
## 1. Ziel
Es gibt **eine** Restmenge je Charge, und sie wird in ApiCore geführt. ApiCore und der Webshop zeigen jederzeit dieselbe Zahl:
- Ein bezahlter Webshop-Verkauf mindert den Rest in ApiCore.
- Storno nach Zahlung und erstattete Retoure buchen zurück.
- Eine Entnahme in ApiCore (Hofladen, Eigenbedarf, Bruch …) mindert sofort auch den Webshop-Bestand.
- ApiCore unterscheidet sichtbar zwischen abgefüllter bzw. hergestellter Menge und Rest.
Das gilt für alle Waren: Honig sowie Kerzen, Propolis-Produkte und andere Waren.
### Entscheidungen
| Frage | Entscheidung |
|---|---|
| Wann gilt eine Bestellung als verkauft? | Bei Zahlung (Status `BEZAHLT`). Unbezahlte Reservierungen erreichen ApiCore nie. |
| Woraus verkauft der Shop? | Aus dem gesamten Restbestand, nicht aus einem Kontingent. |
| Weg der Daten | Ereignisse in beide Richtungen + vollständiger Abgleich alle 10 Minuten (Variante A). |
| Honig im Shop | Ein Shop-Produkt je Abfüllung (Jahrgang), wie bisher. |
| Andere Waren im Shop | Ein Shop-Produkt je Artikel; Bestand = Summe aller Chargen; Abbuchung älteste Charge zuerst (FIFO). |
### Nicht Teil dieses Designs (eigene Specs, in dieser Reihenfolge)
1. **Rohstoffe:** Wachs und Propolis (Ernteform) mit eigenen Chargen und Beständen erfassen.
2. **Herstellung:** Ein Herstellungslauf verbraucht Rohstoff-Chargen und erzeugt eine Herstellungscharge (z. B. Wachs → Kerzen, Propolis → Tinktur/Salbe).
Dieses Design lässt dafür Platz: Die Herstellungscharge (Abschnitt 2.2) ist das spätere Ziel eines Herstellungslaufs; eine Tabelle "verbrauchte Rohstoffe" kann ohne Umbau daran hängen.
## 2. ApiCore
### 2.1 Artikelart
Neue Pflichtfelder am Artikel (`Product`):
- `kind`: `BOTTLED_HONEY` (Bestand je Abfüllung) oder `MANUFACTURED` (Bestand je Herstellungscharge).
- `shopCategory`: `HONIG`, `KERZEN`, `PROPOLIS`, `SONSTIGES` — Vorgabe für die Kategorie des Shop-Produkts.
Migration: bestehende Artikel bekommen `BOTTLED_HONEY` / `HONIG`. Ein Artikel mit Abfüllungen kann nicht auf `MANUFACTURED` umgestellt werden und umgekehrt (sonst wäre unklar, wo der Bestand liegt).
### 2.2 Herstellungscharge (neu)
`ProductionBatch` — Gegenstück zur Abfüllung für `MANUFACTURED`-Artikel:
| Feld | Bedeutung |
|---|---|
| `productId` | Artikel (muss `MANUFACTURED` sein) |
| `lotNumber` | Chargennummer, eindeutig |
| `date` | Herstelldatum |
| `quantity` | hergestellte Stückzahl (Ganzzahl > 0) |
| `bestBeforeDate` | optional (z. B. Propolis-Tinktur) |
| `notes` | optional, intern |
Seiten im Frontend: Liste, Anlegen, Detail — aufgebaut wie die Abfüllungs-Seiten.
### 2.3 Bewegungsbuch
`BatchMovement` — jede Bestandsänderung einer Charge, nie gelöscht:
| Feld | Bedeutung |
|---|---|
| `bottlingBatchId` / `productionBatchId` | genau eines von beiden gesetzt (CHECK-Constraint) |
| `type` | `SHOP_SALE`, `SHOP_RETURN`, `WITHDRAWAL`, `CORRECTION` |
| `quantity` | Ganzzahl mit Vorzeichen: minus = raus, plus = rein |
| `reason` | bei `WITHDRAWAL`: `HOFLADEN`, `EIGENBEDARF`, `BRUCH`, `PROBE`, `SONSTIGES` |
| `notes` | optional |
| `shopRequestId` | gesetzt bei Buchungen aus dem Shop (siehe 2.4) |
| `userId` / `apiKeyId` | wer gebucht hat |
| `createdAt` | Zeitpunkt |
**Rest einer Charge** = Ausgangsmenge (`jarCount` bzw. `quantity`) + Summe ihrer Bewegungen. Der Rest wird immer berechnet, nie gespeichert — er kann nicht auseinanderlaufen. Eine nachträgliche Korrektur von `jarCount`/`quantity` wirkt sofort auf den Rest.
Fehlbuchungen werden durch Gegenbuchung korrigiert, nicht durch Löschen. Eine Charge mit Bewegungen kann nicht gelöscht werden (409, "Bewegungen vorhanden").
### 2.4 Shop-Buchungen und Idempotenz
`ShopStockRequest` — eine Buchungsanfrage des Shops:
| Feld | Bedeutung |
|---|---|
| `externalId` | eindeutig, vom Shop vergeben (ID seiner Warteschlangen-Zeile) |
| `kind` | `SALE` oder `RETURN` |
| `bottlingBatchId` / `productId` | Ziel: eine Abfüllung (Honig) oder ein Artikel (`MANUFACTURED`) |
| `quantity` | Stückzahl, immer positiv |
| `reversesExternalId` | bei `RETURN`: die `externalId` des Verkaufs, der zurückgebucht wird |
Eine Anfrage erzeugt eine oder mehrere `BatchMovement`-Zeilen:
- **Honig-Verkauf:** eine Bewegung `SHOP_SALE` auf der Abfüllung.
- **Artikel-Verkauf (FIFO):** Verteilung auf die Chargen des Artikels mit Rest > 0, sortiert nach MHD aufsteigend (ohne MHD zuletzt), dann Herstelldatum aufsteigend. Reicht der Gesamtrest nicht, geht der Überhang auf die neueste Charge (Rest wird negativ, siehe 2.7).
- **Rückbuchung:** spiegelt genau die Bewegungen des Verkaufs `reversesExternalId` (gleiche Chargen, gleiche Mengen; bei Teilmenge in umgekehrter FIFO-Reihenfolge).
Kommt eine `externalId` erneut, wird nichts gebucht; die Antwort meldet `duplicate` mit dem Ergebnis der ersten Buchung.
### 2.5 API
Alle unter `/api/v1/shop-sync`, Zugang per API-Schlüssel.
| Endpunkt | Recht | Inhalt |
|---|---|---|
| `GET /shop-sync` | `shop-sync:read` | wie bisher; zusätzlich je Abfüllung `remainingCount`, neue Liste `productionBatches` (id, productId, lotNumber, date, quantity, bestBeforeDate, remainingCount), je Artikel `kind`, `shopCategory`, `remainingCount` (nur `MANUFACTURED`) |
| `GET /shop-sync/stock` | `shop-sync:read` | nur Bestände: `bottlings: [{id, remainingCount}]`, `products: [{id, remainingCount}]` (nur `MANUFACTURED`) |
| `POST /shop-sync/movements` | `shop-sync:write` | Body `{ requests: ShopStockRequest[] }` (max. 100); Antwort je Anfrage `{ externalId, status: "created" \| "duplicate" \| "rejected", error?, allocations: [{ batchType, batchId, lotNumber, quantity }], remainingCount }` |
Neue Felder halten `SHOP_SYNC_CONTRACT_VERSION = 1` (Verbraucher ignorieren Unbekanntes). Ungültige Anfragen (unbekanntes Ziel, Ziel passt nicht zu `kind`, `reversesExternalId` unbekannt) bekommen `rejected` mit Fehlertext; die übrigen Anfragen derselben Sendung werden trotzdem verarbeitet.
`shop-sync:write` kommt in `API_KEY_SCOPES` (bisher nur Lesen).
### 2.6 Webhook
Neues Ereignis `stock.changed` nach jeder Bewegung (aus UI oder Shop). Nutzlast nur Bezeichner: `{ bottlingBatchIds: [], productIds: [] }`. Der Empfänger holt die Zahlen selbst über `GET /shop-sync/stock` — ein Webhook setzt nie Bestände.
### 2.7 Oberfläche
**Abfüllung (Detail)** und **Herstellungscharge (Detail):**
- Kopfzeile: **Abgefüllt/Hergestellt · Webshop verkauft · Entnommen · Rest**. Rest < 0 rot mit "Prüfen".
- Knopf **Entnehmen**: Anzahl (1 … Rest), Grund, Notiz. Mehr als der Rest wird abgelehnt.
- Knopf **Inventur korrigieren**: gezählten Bestand eingeben → Bewegung `CORRECTION` mit der Differenz; Notiz Pflicht.
- Bewegungsliste: Datum, Art, Anzahl, Grund/Notiz, wer.
**Listen:** Spalte "Rest" in Abfüllungs- und Chargenliste. **Artikel-Detail (`MANUFACTURED`):** Summe Rest und Liste der Chargen.
### 2.8 Rechte
- `bottling:withdraw` — Entnehmen und Inventur bei Abfüllungen.
- `production-batch:read`, `:create`, `:update`, `:delete`, `:withdraw` — Herstellungschargen.
- Bestehende Rollen: wer heute `bottling:update` hat, bekommt die neuen Rechte per Migration.
## 3. Wabenhain
### 3.1 Verknüpfung
- Honig: `Product.apicoreBottlingId` (besteht).
- Andere Waren: neu `Product.apicoreProductId` (eindeutig).
- Neue Kategorie `SONSTIGES` in `ProductCategory`.
- Produkt-Formular: Auswahl **"ApiCore-Artikel verknüpfen"** (nur `MANUFACTURED`-Artikel, noch nicht verknüpft). Damit werden bestehende Kerzen/Propolis-Produkte angebunden — vor dem ersten Sync, sonst legt der Sync zusätzliche Entwürfe an.
- Sync: legt für unverknüpfte `MANUFACTURED`-Artikel deaktivierte Entwürfe an (Preis 0, wie beim Honig).
- Produkte ohne Verknüpfung behalten den manuellen Bestand; das Dashboard zeigt "N Produkte noch nicht mit ApiCore verknüpft".
### 3.2 Bestand
Für verknüpfte Produkte wird `Inventory.quantity` nicht mehr von Hand gepflegt, sondern berechnet:
```
Shop-Bestand = ApiCore-Rest
+ Summe der noch nicht bestätigten Warteschlangen-Buchungen
(Verkauf negativ, Rückbuchung positiv)
− in unbezahlten Bestellungen (Status NEU) reservierte Menge
```
Beispiel: ApiCore-Rest 100, 2 Gläser bezahlt aber noch nicht bestätigt, 1 Glas in einer unbezahlten Bestellung → 97. Nach Bestätigung: ApiCore-Rest 98, nichts offen → weiterhin 97.
Der Abgleich schreibt diesen Wert in einer Transaktion mit Zeilensperre auf `Inventory` — derselbe Schutz, den der Checkout nutzt. Kein Überverkauf bei gleichzeitigem Checkout. Ein negativer Wert wird als 0 geschrieben. Das Bestandsfeld im Admin ist für verknüpfte Produkte gesperrt ("Entnahmen und Inventur in ApiCore").
`Batch.glassCount` und `Batch.remainingGlasses` (Honig) zeigen die ApiCore-Werte.
### 3.3 Warteschlange
`ApiCoreStockEvent` — geschrieben in **derselben Transaktion** wie die auslösende Statusänderung:
| Feld | Bedeutung |
|---|---|
| `id` | wird als `externalId` gesendet |
| `kind` | `SALE` / `RETURN` |
| `orderItemId` | Bestellposition |
| `apicoreBottlingId` / `apicoreProductId` | Ziel |
| `quantity` | positiv |
| `reversesEventId` | bei `RETURN`: der Verkauf, der zurückgebucht wird |
| `sentAt`, `attempts`, `lastError`, `nextAttemptAt` | Zustellung |
Auslöser:
| Ereignis im Shop | Buchung |
|---|---|
| Bestellung bezahlt | `SALE` je Position |
| Storno einer bezahlten Bestellung | `RETURN` je Position |
| Retoure erstattet | `RETURN` je Position |
| Stornierte Bestellung wieder geöffnet | `SALE` je Position (neue Zeilen) |
| Bezahlte Bestellung gelöscht | `RETURN` je Position |
| Unbezahlte Bestellung verfällt / wird storniert | keine |
Alle Stellen, die heute `reserveOrderItems`/`restockOrderItems` aufrufen, gehen künftig über eine gemeinsame Funktion, die je nach Zahlungsstand auch die Warteschlangen-Zeilen schreibt — damit kein Pfad vergessen wird.
Nach Bestätigung durch ApiCore speichert Wabenhain die Chargen-Zuordnung an der Bestellposition (`OrderItem.apicoreAllocations`, JSON: Chargennummer + Menge) — für Rückverfolgung und Rückruf.
### 3.4 Übertragung
- **Sofort:** nach dem Commit, nicht blockierend (`after()` aus `next/server`).
- **Nachreichen + Vollabgleich:** CronJob `wabenhain-apicore-stock` alle 10 Minuten (`scripts/apicore-stock-sync.ts`, Muster wie `release-stale-orders`): offene Buchungen senden (Backoff 1, 5, 15, 60 Minuten, danach stündlich), dann alle verknüpften Bestände abgleichen.
- **Webhook-Empfang:** `POST /api/apicore/webhook`. Prüft HMAC-Signatur mit `APICORE_WEBHOOK_SECRET` und Zeitstempel (max. 5 Minuten alt), sonst 401. Gleicht danach nur die genannten Ziele ab (Zahlen über `GET /shop-sync/stock`). Rate-limitiert.
- **Sync-Knopf im Admin:** ruft zusätzlich denselben Abgleich auf.
- **Dashboard-Warnung:** Buchung länger als 15 Minuten unbestätigt → Hinweis mit letztem Fehler; 403 von ApiCore → "ApiCore-Schlüssel braucht Schreibrecht".
## 4. Fehlerfälle
| Fall | Verhalten |
|---|---|
| ApiCore nicht erreichbar | Shop verkauft aus letztem Stand; Buchungen warten; Nachreichen per CronJob; Warnung ab 15 Minuten. |
| Doppelte Zustellung | `externalId` eindeutig → `duplicate`, keine zweite Buchung. |
| Verkauf > Rest | Wird gebucht (Ware ist verkauft); Rest negativ, rot "Prüfen". |
| Webhook gefälscht/alt | 401; selbst gültige Webhooks setzen keine Zahlen. |
| Webhook verloren | Vollabgleich spätestens nach 10 Minuten. |
| Abgleich parallel zum Checkout | Zeilensperre, kein Überverkauf. |
| Retoure Lagerware | Rückbuchung auf exakt die Chargen des Verkaufs. |
| Charge mit Bewegungen löschen | 409. |
| Schlüssel ohne Schreibrecht | 403; Dashboard-Hinweis. |
| Buchung von ApiCore abgelehnt (`rejected`) | Kein automatischer Neuversuch; Dashboard zeigt Bestellung und Fehlertext. |
## 5. Tests
- **ApiCore Unit:** Restberechnung; FIFO inkl. Überhang; Duplikat; Entnahme > Rest abgelehnt; Rückbuchung auf richtige Chargen; Artikelart-Wechsel gesperrt.
- **ApiCore e2e:** `POST /shop-sync/movements` mit/ohne `shop-sync:write`; Webhook wird ausgelöst; Löschen mit Bewegungen → 409; `GET /shop-sync/stock`.
- **Wabenhain Unit:** Bestandsformel; Auslöser-Tabelle (alle sechs Zeilen); Signaturprüfung inkl. Zeitstempel.
- **Wabenhain Integration (lokale DB):** Checkout und Abgleich gleichzeitig ohne Überverkauf; Warteschlange übersteht Ausfall und sendet nach.
- **Durchlauf lokal (beide Systeme):** kaufen → in ApiCore entnehmen → Retoure; beide Seiten zeigen dieselbe Zahl.
## 6. Einrichtung nach dem Deploy
1. ApiCore: dem Wabenhain-API-Schlüssel `shop-sync:write` geben.
2. ApiCore: Webhook `stock.changed` → `https://wabenhain.com/api/apicore/webhook`; Secret in Wabenhain als `APICORE_WEBHOOK_SECRET`.
3. Wabenhain: CronJob `wabenhain-apicore-stock` aktivieren.
4. Bestehende Kerzen/Propolis-Produkte in ApiCore anlegen (Artikel + Herstellungscharge mit aktuellem Bestand), dann in Wabenhain verknüpfen, dann Sync.
## 7. Aufwand
Etwa 3 Arbeitstage: ApiCore ~1,5 (Bewegungsbuch, Herstellungschargen, API, Oberfläche), Wabenhain ~1,5 (Warteschlange, Auslöser, Abgleich, Webhook, Verknüpfung).
## 8. Nachtrag: Rohstoffe und Herstellung (umgesetzt)
Die in Abschnitt 1 zurückgestellten Bausteine sind umgesetzt, ohne Umbau des Bewegungsbuchs:
- **Rohstoff-Charge** (`RawMaterialLot`): Art (`BEESWAX`, `PROPOLIS`), Chargennummer, Gewinnungsdatum, Menge **in Gramm**, Herkunft optional (Stand, Ernte), Notiz. Rest wie bei allen Chargen aus dem Bewegungsbuch; Entnehmen und Inventur funktionieren gleich.
- **Herstellung**: Beim Anlegen einer Herstellungscharge werden die verbrauchten Rohstoffe angegeben (`inputs`: Rohstoff-Charge + Gramm). Charge und Verbrauch entstehen in einer Transaktion; mehr als der Rest einer Rohstoff-Charge wird abgelehnt (400). Gebucht als neue Bewegungsart `CONSUMPTION`; `ProductionInput` hält die Zuordnung.
- **Rückverfolgung** in beide Richtungen: Herstellungscharge → „Hergestellt aus", Rohstoff-Charge → „Verwendet in". Zusammen mit `OrderItem.apicoreAllocations` in Wabenhain reicht die Kette vom Wachs bis zur Bestellung.
- **Löschen** einer irrtümlich angelegten Herstellungscharge (nur ohne eigene Bewegungen) gibt den Rohstoff als eigene Bewegung zurück. Rohstoff-Chargen mit Bewegungen sind nicht löschbar.
- **Rechte**: `raw-material:read|create|update|delete|withdraw`. Rohstoff verbrauchen beim Anlegen einer Herstellungscharge verlangt zusätzlich `raw-material:withdraw`.
- **Mengenkorrektur**: Die Menge einer Rohstoff- oder Herstellungscharge kann nicht unter das bereits Verbuchte gesenkt werden; Fehlmengen gehen über „Inventur korrigieren".
- Rohstoffe werden nicht im Webshop verkauft; ihre Bewegungen lösen keinen `stock.changed`-Webhook aus.