From 04f93e954a946c9a05a3fc68c9c3d6bc57723428 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Fri, 2 Oct 2026 06:38:12 +0200 Subject: [PATCH] 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) --- .../20261002100000_stock_ledger/migration.sql | 162 +++++ ApiCore-Backend/prisma/schema.prisma | 119 +++- .../src/api-keys/api-key-scopes.ts | 11 +- ApiCore-Backend/src/app.module.ts | 2 + .../auth/permissions/permission-catalog.ts | 18 +- .../src/bottling/bottling.controller.ts | 39 +- .../src/bottling/bottling.module.ts | 3 +- .../src/bottling/bottling.service.spec.ts | 71 +- .../src/bottling/bottling.service.ts | 62 +- .../dto/create-production-batch.dto.ts | 42 ++ .../dto/update-production-batch.dto.ts | 48 ++ .../production-batches.controller.ts | 100 +++ .../production-batches.module.ts | 11 + .../production-batches.service.ts | 160 +++++ .../src/products/dto/create-product.dto.ts | 17 +- .../src/products/products.service.ts | 26 +- .../src/shop-sync/dto/shop-movements.dto.ts | 72 ++ .../src/shop-sync/shop-sync.controller.ts | 40 +- .../src/shop-sync/shop-sync.module.ts | 2 + .../src/shop-sync/shop-sync.service.ts | 93 ++- .../src/stock/dto/correct-stock.dto.ts | 20 + .../src/stock/dto/withdraw-stock.dto.ts | 27 + .../src/stock/stock-ledger.spec.ts | 137 ++++ ApiCore-Backend/src/stock/stock-ledger.ts | 164 +++++ ApiCore-Backend/src/stock/stock.module.ts | 8 + ApiCore-Backend/src/stock/stock.service.ts | 664 ++++++++++++++++++ .../src/webhooks/webhook-events.ts | 4 + ApiCore-Backend/test/stock.e2e-spec.ts | 358 ++++++++++ 28 files changed, 2445 insertions(+), 35 deletions(-) create mode 100644 ApiCore-Backend/prisma/migrations/20261002100000_stock_ledger/migration.sql create mode 100644 ApiCore-Backend/src/production-batches/dto/create-production-batch.dto.ts create mode 100644 ApiCore-Backend/src/production-batches/dto/update-production-batch.dto.ts create mode 100644 ApiCore-Backend/src/production-batches/production-batches.controller.ts create mode 100644 ApiCore-Backend/src/production-batches/production-batches.module.ts create mode 100644 ApiCore-Backend/src/production-batches/production-batches.service.ts create mode 100644 ApiCore-Backend/src/shop-sync/dto/shop-movements.dto.ts create mode 100644 ApiCore-Backend/src/stock/dto/correct-stock.dto.ts create mode 100644 ApiCore-Backend/src/stock/dto/withdraw-stock.dto.ts create mode 100644 ApiCore-Backend/src/stock/stock-ledger.spec.ts create mode 100644 ApiCore-Backend/src/stock/stock-ledger.ts create mode 100644 ApiCore-Backend/src/stock/stock.module.ts create mode 100644 ApiCore-Backend/src/stock/stock.service.ts create mode 100644 ApiCore-Backend/test/stock.e2e-spec.ts diff --git a/ApiCore-Backend/prisma/migrations/20261002100000_stock_ledger/migration.sql b/ApiCore-Backend/prisma/migrations/20261002100000_stock_ledger/migration.sql new file mode 100644 index 0000000..337567a --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261002100000_stock_ledger/migration.sql @@ -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; diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index e2bc51d..198d21b 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -120,6 +120,8 @@ model User { colonyMoves ColonyApiaryHistory[] auditLogs AuditLog[] bottlingBatches BottlingBatch[] + productionBatches ProductionBatch[] + batchMovements BatchMovement[] notifications Notification[] queenEvaluations QueenEvaluation[] webhookSubscriptions WebhookSubscription[] @@ -979,6 +981,22 @@ enum ProductStatus { DISCONTINUED } +// Wo der Bestand eines Artikels liegt: Honig je Abfuellung (BottlingBatch), +// alles andere (Kerzen, Propolis-Produkte, ...) je Herstellungscharge +// (ProductionBatch). Nicht umstellbar, sobald Chargen existieren. +enum ProductKind { + BOTTLED_HONEY + MANUFACTURED +} + +// Vorgabe fuer die Kategorie des Produkts im Webshop (Wabenhain). +enum ShopCategory { + HONIG + KERZEN + PROPOLIS + SONSTIGES +} + model Product { id String @id @default(cuid()) name String @@ -988,10 +1006,13 @@ model Product { weight Float? salesUnit String? status ProductStatus @default(ACTIVE) + kind ProductKind @default(BOTTLED_HONEY) + shopCategory ShopCategory @default(HONIG) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - bottlingBatches BottlingBatch[] + bottlingBatches BottlingBatch[] + productionBatches ProductionBatch[] @@index([status]) } @@ -1046,10 +1067,106 @@ model BottlingBatch { user User? @relation(fields: [userId], references: [id]) createdAt DateTime @default(now()) + movements BatchMovement[] + @@index([honeyBatchId]) @@index([productId]) } +// --------------------------------------------------------------------------- +// Herstellungschargen und Bestandsbewegungen (Bestandsabgleich mit dem Shop, +// docs/superpowers/specs/2026-10-02-bestandsabgleich-design.md) +// --------------------------------------------------------------------------- + +// Gegenstueck zur Abfuellung fuer Artikel der Art MANUFACTURED (Kerzen, +// Propolis-Tinkturen, Salben, ...). Spaeter Ziel eines Herstellungslaufs, der +// Rohstoff-Chargen verbraucht. +model ProductionBatch { + id String @id @default(cuid()) + productId String + product Product @relation(fields: [productId], references: [id]) + lotNumber String @unique + date DateTime + // Hergestellte Stueckzahl - Ausgangsmenge fuer den Rest. + quantity Int + bestBeforeDate DateTime? + notes String? + userId String? + user User? @relation(fields: [userId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + movements BatchMovement[] + + @@index([productId]) +} + +enum BatchMovementType { + SHOP_SALE + SHOP_RETURN + WITHDRAWAL + CORRECTION +} + +enum WithdrawalReason { + HOFLADEN + EIGENBEDARF + BRUCH + PROBE + SONSTIGES +} + +// Bewegungsbuch: jede Bestandsaenderung einer Charge, nie geloescht (Fehler +// werden gegengebucht). Rest = Ausgangsmenge + Summe aller quantity. +// Genau eine der beiden Chargen-Spalten ist gesetzt (CHECK-Constraint in der +// Migration). +model BatchMovement { + id String @id @default(cuid()) + bottlingBatchId String? + bottlingBatch BottlingBatch? @relation(fields: [bottlingBatchId], references: [id], onDelete: Restrict) + productionBatchId String? + productionBatch ProductionBatch? @relation(fields: [productionBatchId], references: [id], onDelete: Restrict) + type BatchMovementType + // Mit Vorzeichen: minus = raus, plus = rein. + quantity Int + reason WithdrawalReason? + notes String? + shopRequestId String? + shopRequest ShopStockRequest? @relation(fields: [shopRequestId], references: [id], onDelete: Restrict) + userId String? + user User? @relation(fields: [userId], references: [id]) + apiKeyId String? + createdAt DateTime @default(now()) + + @@index([bottlingBatchId]) + @@index([productionBatchId]) + @@index([shopRequestId]) +} + +enum ShopStockRequestKind { + SALE + RETURN +} + +// Eine Buchungsanfrage des Shops. externalId (vom Shop vergeben) macht jede +// Anfrage idempotent: dieselbe Anfrage zweimal bucht nur einmal. +model ShopStockRequest { + id String @id @default(cuid()) + externalId String @unique + kind ShopStockRequestKind + // Ziel: eine Abfuellung (Honig) ODER ein Artikel (MANUFACTURED, FIFO). + bottlingBatchId String? + productId String? + quantity Int + reversesExternalId String? + apiKeyId String? + createdAt DateTime @default(now()) + + movements BatchMovement[] + + @@index([reversesExternalId]) +} + // --------------------------------------------------------------------------- // Inventar (Abschnitt 4.22) – v1.x Modul 24 // --------------------------------------------------------------------------- diff --git a/ApiCore-Backend/src/api-keys/api-key-scopes.ts b/ApiCore-Backend/src/api-keys/api-key-scopes.ts index 5744591..1d80684 100644 --- a/ApiCore-Backend/src/api-keys/api-key-scopes.ts +++ b/ApiCore-Backend/src/api-keys/api-key-scopes.ts @@ -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]; diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 38fa3e7..fa3daa4 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -27,6 +27,7 @@ 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 { EquipmentModule } from './equipment/equipment.module'; import { CalendarModule } from './calendar/calendar.module'; import { CalendarSyncModule } from './calendar-sync/calendar-sync.module'; @@ -83,6 +84,7 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea QrModule, ProductsModule, BottlingModule, + ProductionBatchesModule, EquipmentModule, CalendarModule, CalendarSyncModule, diff --git a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts index bc47606..a23da22 100644 --- a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts +++ b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts @@ -204,9 +204,22 @@ export const PERMISSION_CATALOG = [ ['create', 'Anlegen'], ['update', 'Bearbeiten'], ['delete', 'Löschen'], + ['withdraw', 'Entnehmen und Inventur'], ], 1600, ), + ...group( + 'Herstellungschargen', + 'production-batch', + [ + ['read', 'Ansehen'], + ['create', 'Anlegen'], + ['update', 'Bearbeiten'], + ['delete', 'Löschen'], + ['withdraw', 'Entnehmen und Inventur'], + ], + 1650, + ), ...group( 'Etiketten', 'label-templates', @@ -324,7 +337,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( diff --git a/ApiCore-Backend/src/bottling/bottling.controller.ts b/ApiCore-Backend/src/bottling/bottling.controller.ts index 35ec4b1..b587514 100644 --- a/ApiCore-Backend/src/bottling/bottling.controller.ts +++ b/ApiCore-Backend/src/bottling/bottling.controller.ts @@ -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() diff --git a/ApiCore-Backend/src/bottling/bottling.module.ts b/ApiCore-Backend/src/bottling/bottling.module.ts index 2df68f1..83ba5a3 100644 --- a/ApiCore-Backend/src/bottling/bottling.module.ts +++ b/ApiCore-Backend/src/bottling/bottling.module.ts @@ -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], }) diff --git a/ApiCore-Backend/src/bottling/bottling.service.spec.ts b/ApiCore-Backend/src/bottling/bottling.service.spec.ts index 26675d9..9dc489c 100644 --- a/ApiCore-Backend/src/bottling/bottling.service.spec.ts +++ b/ApiCore-Backend/src/bottling/bottling.service.spec.ts @@ -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; @@ -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); diff --git a/ApiCore-Backend/src/bottling/bottling.service.ts b/ApiCore-Backend/src/bottling/bottling.service.ts index 03dbd77..d02b9e5 100644 --- a/ApiCore-Backend/src/bottling/bottling.service.ts +++ b/ApiCore-Backend/src/bottling/bottling.service.ts @@ -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 { + 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 }; } } diff --git a/ApiCore-Backend/src/production-batches/dto/create-production-batch.dto.ts b/ApiCore-Backend/src/production-batches/dto/create-production-batch.dto.ts new file mode 100644 index 0000000..fed7a30 --- /dev/null +++ b/ApiCore-Backend/src/production-batches/dto/create-production-batch.dto.ts @@ -0,0 +1,42 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsDateString, + IsInt, + IsNotEmpty, + IsOptional, + IsPositive, + IsString, + MaxLength, +} from 'class-validator'; + +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; +} diff --git a/ApiCore-Backend/src/production-batches/dto/update-production-batch.dto.ts b/ApiCore-Backend/src/production-batches/dto/update-production-batch.dto.ts new file mode 100644 index 0000000..f7f9e1e --- /dev/null +++ b/ApiCore-Backend/src/production-batches/dto/update-production-batch.dto.ts @@ -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; +} diff --git a/ApiCore-Backend/src/production-batches/production-batches.controller.ts b/ApiCore-Backend/src/production-batches/production-batches.controller.ts new file mode 100644 index 0000000..84c0b62 --- /dev/null +++ b/ApiCore-Backend/src/production-batches/production-batches.controller.ts @@ -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); + } +} diff --git a/ApiCore-Backend/src/production-batches/production-batches.module.ts b/ApiCore-Backend/src/production-batches/production-batches.module.ts new file mode 100644 index 0000000..4bbbc70 --- /dev/null +++ b/ApiCore-Backend/src/production-batches/production-batches.module.ts @@ -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 {} diff --git a/ApiCore-Backend/src/production-batches/production-batches.service.ts b/ApiCore-Backend/src/production-batches/production-batches.service.ts new file mode 100644 index 0000000..8d989e5 --- /dev/null +++ b/ApiCore-Backend/src/production-batches/production-batches.service.ts @@ -0,0 +1,160 @@ +import { + BadRequestException, + 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 { CreateProductionBatchDto } from './dto/create-production-batch.dto'; +import { UpdateProductionBatchDto } from './dto/update-production-batch.dto'; + +const include = { product: 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, + ) {} + + 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) { + 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. + const batch = await this.prisma.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, + }, + }); + 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.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', + ); + } + await this.prisma.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 }; + } +} diff --git a/ApiCore-Backend/src/products/dto/create-product.dto.ts b/ApiCore-Backend/src/products/dto/create-product.dto.ts index bfd5394..25366a4 100644 --- a/ApiCore-Backend/src/products/dto/create-product.dto.ts +++ b/ApiCore-Backend/src/products/dto/create-product.dto.ts @@ -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; } diff --git a/ApiCore-Backend/src/products/products.service.ts b/ApiCore-Backend/src/products/products.service.ts index 6f315eb..c6af680 100644 --- a/ApiCore-Backend/src/products/products.service.ts +++ b/ApiCore-Backend/src/products/products.service.ts @@ -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 { 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 { + 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', + ); + } + } } diff --git a/ApiCore-Backend/src/shop-sync/dto/shop-movements.dto.ts b/ApiCore-Backend/src/shop-sync/dto/shop-movements.dto.ts new file mode 100644 index 0000000..52df35f --- /dev/null +++ b/ApiCore-Backend/src/shop-sync/dto/shop-movements.dto.ts @@ -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[]; +} diff --git a/ApiCore-Backend/src/shop-sync/shop-sync.controller.ts b/ApiCore-Backend/src/shop-sync/shop-sync.controller.ts index c551c42..b52b6fc 100644 --- a/ApiCore-Backend/src/shop-sync/shop-sync.controller.ts +++ b/ApiCore-Backend/src/shop-sync/shop-sync.controller.ts @@ -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 { return this.shopSync.feed(); } + + /** Only the remaining stock - for the shop's frequent reconcile. */ + @RequirePermission('shop-sync:read') + @Get('stock') + stockLevels(): Promise { + 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 })); + } } diff --git a/ApiCore-Backend/src/shop-sync/shop-sync.module.ts b/ApiCore-Backend/src/shop-sync/shop-sync.module.ts index d433576..68d0a21 100644 --- a/ApiCore-Backend/src/shop-sync/shop-sync.module.ts +++ b/ApiCore-Backend/src/shop-sync/shop-sync.module.ts @@ -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], }) diff --git a/ApiCore-Backend/src/shop-sync/shop-sync.service.ts b/ApiCore-Backend/src/shop-sync/shop-sync.service.ts index 3cd9875..05db25d 100644 --- a/ApiCore-Backend/src/shop-sync/shop-sync.service.ts +++ b/ApiCore-Backend/src/shop-sync/shop-sync.service.ts @@ -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 { + 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 { - 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, + })), }; } } diff --git a/ApiCore-Backend/src/stock/dto/correct-stock.dto.ts b/ApiCore-Backend/src/stock/dto/correct-stock.dto.ts new file mode 100644 index 0000000..132cf37 --- /dev/null +++ b/ApiCore-Backend/src/stock/dto/correct-stock.dto.ts @@ -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; +} diff --git a/ApiCore-Backend/src/stock/dto/withdraw-stock.dto.ts b/ApiCore-Backend/src/stock/dto/withdraw-stock.dto.ts new file mode 100644 index 0000000..b78042a --- /dev/null +++ b/ApiCore-Backend/src/stock/dto/withdraw-stock.dto.ts @@ -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; +} diff --git a/ApiCore-Backend/src/stock/stock-ledger.spec.ts b/ApiCore-Backend/src/stock/stock-ledger.spec.ts new file mode 100644 index 0000000..cd15084 --- /dev/null +++ b/ApiCore-Backend/src/stock/stock-ledger.spec.ts @@ -0,0 +1,137 @@ +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, + remaining: 81, + }); + }); + + 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, + ); + }); +}); diff --git a/ApiCore-Backend/src/stock/stock-ledger.ts b/ApiCore-Backend/src/stock/stock-ledger.ts new file mode 100644 index 0000000..8b997d1 --- /dev/null +++ b/ApiCore-Backend/src/stock/stock-ledger.ts @@ -0,0 +1,164 @@ +/** + * 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'; + +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; + remaining: number; +} + +export function summarizeStock( + initial: number, + entries: readonly LedgerEntry[], +): StockSummary { + let soldInShop = 0; + let withdrawn = 0; + let corrected = 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 { + corrected += entry.quantity; + } + } + return { + initial, + soldInShop, + withdrawn, + corrected, + remaining: initial - soldInShop - withdrawn + 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(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(); + 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, + 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; +} diff --git a/ApiCore-Backend/src/stock/stock.module.ts b/ApiCore-Backend/src/stock/stock.module.ts new file mode 100644 index 0000000..f245b50 --- /dev/null +++ b/ApiCore-Backend/src/stock/stock.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { StockService } from './stock.service'; + +@Module({ + providers: [StockService], + exports: [StockService], +}) +export class StockModule {} diff --git a/ApiCore-Backend/src/stock/stock.service.ts b/ApiCore-Backend/src/stock/stock.service.ts new file mode 100644 index 0000000..df37dce --- /dev/null +++ b/ApiCore-Backend/src/stock/stock.service.ts @@ -0,0 +1,664 @@ +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 or a production batch. */ +export type StockTarget = + { kind: 'bottling'; id: string } | { kind: 'production'; id: string }; + +/** 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> { + return this.remainingFor('bottling', ids); + } + + /** Remaining stock per production batch; all when `ids` is omitted. */ + async remainingByProductionBatch( + ids?: string[], + ): Promise> { + return this.remainingFor('production', ids); + } + + /** Remaining stock per MANUFACTURED article (sum of its batches). */ + async remainingByProduct(ids?: string[]): Promise> { + 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(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 { + 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 { + 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 { + 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 { + 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); + } + + // ------------------------------------------------------------------------- + // 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 { + 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 { + 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 { + 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 { + 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 }> { + 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(); + 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, + 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 { + 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 { + 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 { + if (target.kind === 'bottling') { + return { bottlingBatchIds: [target.id], 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 { + 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> { + 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 })) + : ( + await this.prisma.productionBatch.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> { + 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]), + ); + } + 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 { + 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 { + 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; + } + 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 target.kind === 'bottling' + ? { bottlingBatchId: target.id } + : { productionBatchId: target.id }; + } + + private movementTarget( + target: StockTarget, + ): { bottlingBatchId: string } | { productionBatchId: string } { + return target.kind === 'bottling' + ? { bottlingBatchId: target.id } + : { productionBatchId: target.id }; + } + + private async lock(tx: Tx, target: StockTarget): Promise { + if (target.kind === 'bottling') { + await tx.$queryRaw`SELECT id FROM "BottlingBatch" WHERE id = ${target.id} FOR UPDATE`; + 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 { + await tx.$queryRaw`SELECT id FROM "Product" WHERE id = ${productId} FOR UPDATE`; + } +} diff --git a/ApiCore-Backend/src/webhooks/webhook-events.ts b/ApiCore-Backend/src/webhooks/webhook-events.ts index d6a3e04..f65b368 100644 --- a/ApiCore-Backend/src/webhooks/webhook-events.ts +++ b/ApiCore-Backend/src/webhooks/webhook-events.ts @@ -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; diff --git a/ApiCore-Backend/test/stock.e2e-spec.ts b/ApiCore-Backend/test/stock.e2e-spec.ts new file mode 100644 index 0000000..f1cc433 --- /dev/null +++ b/ApiCore-Backend/test/stock.e2e-spec.ts @@ -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; + 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[]) { + 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); + }); +});