feat(stock): batch movement ledger, production batches and shop write-back API

One remaining quantity per batch, kept in ApiCore and always derived:
initial quantity (jarCount / produced quantity) plus the signed movements
in the new BatchMovement ledger, which is never deleted.

- Production batches for MANUFACTURED articles (candles, propolis
  products, ...); articles get a kind and a shop category.
- Withdrawals (farm shop, own use, breakage, sample) and inventory
  corrections on bottlings and production batches, never more than the
  remaining stock; every write locks the bottling or the article row.
- Shop bookings via POST /shop-sync/movements (new scope
  shop-sync:write): idempotent per externalId, articles allocated oldest
  best-before first, returns mirrored onto the batches of the sale,
  invalid entries rejected one by one.
- GET /shop-sync/stock and remainingCount in the shop feed; webhook
  stock.changed after every change (ids only, receivers fetch numbers).
- Bottlings and production batches with movements cannot be deleted.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-02 06:38:12 +02:00
1 parent 85f05dc541
commit 04f93e954a
28 files changed
+2444 -34

No files matched your search

@@ -0,0 +1,162 @@
-- Bestandsabgleich mit dem Webshop (docs/superpowers/specs/2026-10-02-bestandsabgleich-design.md):
-- Bewegungsbuch je Charge, Herstellungschargen fuer Nicht-Honig-Artikel,
-- idempotente Buchungsanfragen des Shops. Rein additiv.
-- CreateEnum
CREATE TYPE "ProductKind" AS ENUM ('BOTTLED_HONEY', 'MANUFACTURED');
-- CreateEnum
CREATE TYPE "ShopCategory" AS ENUM ('HONIG', 'KERZEN', 'PROPOLIS', 'SONSTIGES');
-- CreateEnum
CREATE TYPE "BatchMovementType" AS ENUM ('SHOP_SALE', 'SHOP_RETURN', 'WITHDRAWAL', 'CORRECTION');
-- CreateEnum
CREATE TYPE "WithdrawalReason" AS ENUM ('HOFLADEN', 'EIGENBEDARF', 'BRUCH', 'PROBE', 'SONSTIGES');
-- CreateEnum
CREATE TYPE "ShopStockRequestKind" AS ENUM ('SALE', 'RETURN');
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "kind" "ProductKind" NOT NULL DEFAULT 'BOTTLED_HONEY',
ADD COLUMN "shopCategory" "ShopCategory" NOT NULL DEFAULT 'HONIG';
-- CreateTable
CREATE TABLE "ProductionBatch" (
"id" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"lotNumber" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"quantity" INTEGER NOT NULL,
"bestBeforeDate" TIMESTAMP(3),
"notes" TEXT,
"userId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProductionBatch_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BatchMovement" (
"id" TEXT NOT NULL,
"bottlingBatchId" TEXT,
"productionBatchId" TEXT,
"type" "BatchMovementType" NOT NULL,
"quantity" INTEGER NOT NULL,
"reason" "WithdrawalReason",
"notes" TEXT,
"shopRequestId" TEXT,
"userId" TEXT,
"apiKeyId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BatchMovement_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ShopStockRequest" (
"id" TEXT NOT NULL,
"externalId" TEXT NOT NULL,
"kind" "ShopStockRequestKind" NOT NULL,
"bottlingBatchId" TEXT,
"productId" TEXT,
"quantity" INTEGER NOT NULL,
"reversesExternalId" TEXT,
"apiKeyId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ShopStockRequest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ProductionBatch_lotNumber_key" ON "ProductionBatch"("lotNumber");
-- CreateIndex
CREATE INDEX "ProductionBatch_productId_idx" ON "ProductionBatch"("productId");
-- CreateIndex
CREATE INDEX "BatchMovement_bottlingBatchId_idx" ON "BatchMovement"("bottlingBatchId");
-- CreateIndex
CREATE INDEX "BatchMovement_productionBatchId_idx" ON "BatchMovement"("productionBatchId");
-- CreateIndex
CREATE INDEX "BatchMovement_shopRequestId_idx" ON "BatchMovement"("shopRequestId");
-- CreateIndex
CREATE UNIQUE INDEX "ShopStockRequest_externalId_key" ON "ShopStockRequest"("externalId");
-- CreateIndex
CREATE INDEX "ShopStockRequest_reversesExternalId_idx" ON "ShopStockRequest"("reversesExternalId");
-- AddForeignKey
ALTER TABLE "ProductionBatch" ADD CONSTRAINT "ProductionBatch_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProductionBatch" ADD CONSTRAINT "ProductionBatch_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_bottlingBatchId_fkey" FOREIGN KEY ("bottlingBatchId") REFERENCES "BottlingBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_productionBatchId_fkey" FOREIGN KEY ("productionBatchId") REFERENCES "ProductionBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_shopRequestId_fkey" FOREIGN KEY ("shopRequestId") REFERENCES "ShopStockRequest"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Genau eine Charge je Bewegung, genau ein Ziel je Shop-Anfrage.
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_exactly_one_batch"
CHECK (("bottlingBatchId" IS NULL) <> ("productionBatchId" IS NULL));
ALTER TABLE "ShopStockRequest" ADD CONSTRAINT "ShopStockRequest_exactly_one_target"
CHECK (("bottlingBatchId" IS NULL) <> ("productId" IS NULL));
ALTER TABLE "ShopStockRequest" ADD CONSTRAINT "ShopStockRequest_quantity_positive"
CHECK ("quantity" > 0);
ALTER TABLE "ProductionBatch" ADD CONSTRAINT "ProductionBatch_quantity_positive"
CHECK ("quantity" > 0);
-- Permissions: the boot-time catalog sync only grants new permissions to
-- ADMIN; the other built-in roles get them here, matching their existing
-- bottling rights.
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
VALUES
(gen_random_uuid()::text, 'bottling:withdraw', 'bottling', 'withdraw', 'Entnehmen und Inventur', 'Abfüllungen', 1604),
(gen_random_uuid()::text, 'production-batch:read', 'production-batch', 'read', 'Ansehen', 'Herstellungschargen', 1650),
(gen_random_uuid()::text, 'production-batch:create', 'production-batch', 'create', 'Anlegen', 'Herstellungschargen', 1651),
(gen_random_uuid()::text, 'production-batch:update', 'production-batch', 'update', 'Bearbeiten', 'Herstellungschargen', 1652),
(gen_random_uuid()::text, 'production-batch:delete', 'production-batch', 'delete', 'Löschen', 'Herstellungschargen', 1653),
(gen_random_uuid()::text, 'production-batch:withdraw', 'production-batch', 'withdraw', 'Entnehmen und Inventur', 'Herstellungschargen', 1654),
(gen_random_uuid()::text, 'shop-sync:write', 'shop-sync', 'write', 'Verkäufe und Rückbuchungen des Shops buchen', 'Shop-Synchronisation', 3301)
ON CONFLICT (key) DO NOTHING;
-- Wer Abfuellungen bearbeiten darf, darf kuenftig auch entnehmen und
-- Herstellungschargen pflegen; wer Abfuellungen sehen darf, sieht auch
-- Herstellungschargen.
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT DISTINCT rp."roleId", p.id, now()
FROM "RolePermission" rp
JOIN "Permission" existing ON existing.id = rp."permissionId" AND existing.key = 'bottling:update'
JOIN "Permission" p ON p.key IN (
'bottling:withdraw', 'production-batch:create', 'production-batch:update',
'production-batch:delete', 'production-batch:withdraw'
)
ON CONFLICT DO NOTHING;
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT DISTINCT rp."roleId", p.id, now()
FROM "RolePermission" rp
JOIN "Permission" existing ON existing.id = rp."permissionId" AND existing.key = 'bottling:read'
JOIN "Permission" p ON p.key = 'production-batch:read'
ON CONFLICT DO NOTHING;
-- Only ADMIN may hand out shop-sync:write to an API key (a key can only get
-- permissions its creator holds). Inserted here, the row is no longer "new"
-- for the boot-time catalog sync, so the grant has to happen here too.
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE r.key = 'ADMIN' AND p.key = 'shop-sync:write'
ON CONFLICT DO NOTHING;
+117
View File
@@ -120,6 +120,8 @@ model User {
colonyMoves ColonyApiaryHistory[] colonyMoves ColonyApiaryHistory[]
auditLogs AuditLog[] auditLogs AuditLog[]
bottlingBatches BottlingBatch[] bottlingBatches BottlingBatch[]
productionBatches ProductionBatch[]
batchMovements BatchMovement[]
notifications Notification[] notifications Notification[]
queenEvaluations QueenEvaluation[] queenEvaluations QueenEvaluation[]
webhookSubscriptions WebhookSubscription[] webhookSubscriptions WebhookSubscription[]
@@ -979,6 +981,22 @@ enum ProductStatus {
DISCONTINUED 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 { model Product {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
@@ -988,10 +1006,13 @@ model Product {
weight Float? weight Float?
salesUnit String? salesUnit String?
status ProductStatus @default(ACTIVE) status ProductStatus @default(ACTIVE)
kind ProductKind @default(BOTTLED_HONEY)
shopCategory ShopCategory @default(HONIG)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
bottlingBatches BottlingBatch[] bottlingBatches BottlingBatch[]
productionBatches ProductionBatch[]
@@index([status]) @@index([status])
} }
@@ -1046,10 +1067,106 @@ model BottlingBatch {
user User? @relation(fields: [userId], references: [id]) user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now()) createdAt DateTime @default(now())
movements BatchMovement[]
@@index([honeyBatchId]) @@index([honeyBatchId])
@@index([productId]) @@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 // Inventar (Abschnitt 4.22) – v1.x Modul 24
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1,14 +1,17 @@
import { PermissionKey } from '../auth/permissions/permission-catalog'; import { PermissionKey } from '../auth/permissions/permission-catalog';
/** /**
* Permissions an API key may carry. Deliberately a short allowlist of * Permissions an API key may carry. Deliberately a short allowlist, not the
* read-only permissions, not the whole catalog: a key is a long-lived secret * whole catalog: a key is a long-lived secret stored in another system, so it
* stored in another system, so it only ever gets what a machine integration * only ever gets what a machine integration needs. shop-sync:write lets the
* needs. Removing an entry here also takes it away from existing keys (see * 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). * ApiKeysService.authenticate).
*/ */
export const API_KEY_SCOPES = [ export const API_KEY_SCOPES = [
'shop-sync:read', 'shop-sync:read',
'shop-sync:write',
] as const satisfies readonly PermissionKey[]; ] as const satisfies readonly PermissionKey[];
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number]; export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
+2
View File
@@ -27,6 +27,7 @@ import { ExportModule } from './export/export.module';
import { QrModule } from './qr/qr.module'; import { QrModule } from './qr/qr.module';
import { ProductsModule } from './products/products.module'; import { ProductsModule } from './products/products.module';
import { BottlingModule } from './bottling/bottling.module'; import { BottlingModule } from './bottling/bottling.module';
import { ProductionBatchesModule } from './production-batches/production-batches.module';
import { EquipmentModule } from './equipment/equipment.module'; import { EquipmentModule } from './equipment/equipment.module';
import { CalendarModule } from './calendar/calendar.module'; import { CalendarModule } from './calendar/calendar.module';
import { CalendarSyncModule } from './calendar-sync/calendar-sync.module'; import { CalendarSyncModule } from './calendar-sync/calendar-sync.module';
@@ -83,6 +84,7 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
QrModule, QrModule,
ProductsModule, ProductsModule,
BottlingModule, BottlingModule,
ProductionBatchesModule,
EquipmentModule, EquipmentModule,
CalendarModule, CalendarModule,
CalendarSyncModule, CalendarSyncModule,
@@ -204,9 +204,22 @@ export const PERMISSION_CATALOG = [
['create', 'Anlegen'], ['create', 'Anlegen'],
['update', 'Bearbeiten'], ['update', 'Bearbeiten'],
['delete', 'Löschen'], ['delete', 'Löschen'],
['withdraw', 'Entnehmen und Inventur'],
], ],
1600, 1600,
), ),
...group(
'Herstellungschargen',
'production-batch',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['delete', 'Löschen'],
['withdraw', 'Entnehmen und Inventur'],
],
1650,
),
...group( ...group(
'Etiketten', 'Etiketten',
'label-templates', 'label-templates',
@@ -324,7 +337,10 @@ export const PERMISSION_CATALOG = [
...group( ...group(
'Shop-Synchronisation', 'Shop-Synchronisation',
'shop-sync', '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, 3300,
), ),
...group( ...group(
@@ -16,12 +16,18 @@ import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto'; import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
import { BottlingService } from './bottling.service'; import { BottlingService } from './bottling.service';
import { Idempotent } from '../common/idempotency/idempotent.decorator'; 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') @ApiTags('bottling')
@ApiBearerAuth() @ApiBearerAuth()
@Controller('bottling-batches') @Controller('bottling-batches')
export class BottlingController { export class BottlingController {
constructor(private readonly bottlingService: BottlingService) {} constructor(
private readonly bottlingService: BottlingService,
private readonly stock: StockService,
) {}
@RequirePermission('bottling:read') @RequirePermission('bottling:read')
@Get() @Get()
@@ -35,6 +41,37 @@ export class BottlingController {
return this.bottlingService.findById(id); 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') @RequirePermission('bottling:create')
@Idempotent() @Idempotent()
@Post() @Post()
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module'; import { AutomationModule } from '../automation/automation.module';
import { StockModule } from '../stock/stock.module';
import { BottlingController } from './bottling.controller'; import { BottlingController } from './bottling.controller';
import { BottlingService } from './bottling.service'; import { BottlingService } from './bottling.service';
@Module({ @Module({
imports: [AutomationModule], imports: [AutomationModule, StockModule],
controllers: [BottlingController], controllers: [BottlingController],
providers: [BottlingService], providers: [BottlingService],
}) })
@@ -1,9 +1,14 @@
import { BadRequestException, NotFoundException } from '@nestjs/common'; import {
BadRequestException,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import { BottlingService } from './bottling.service'; import { BottlingService } from './bottling.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service'; import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service'; import { AutomationService } from '../automation/automation.service';
import { StockService } from '../stock/stock.service';
type AmountChange = number | { increment?: number; decrement?: number }; type AmountChange = number | { increment?: number; decrement?: number };
@@ -40,6 +45,11 @@ describe('BottlingService', () => {
}; };
}; };
let webhooks: { dispatch: jest.Mock }; 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 // Honey batch rows as the transaction sees them - the tests assert the
// resulting amounts instead of the exact sequence of UPDATE statements. // resulting amounts instead of the exact sequence of UPDATE statements.
let honeyBatches: Map<string, { amount: number | null; status?: string }>; let honeyBatches: Map<string, { amount: number | null; status?: string }>;
@@ -115,11 +125,17 @@ describe('BottlingService', () => {
auditLog = { record: jest.fn() }; auditLog = { record: jest.fn() };
webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
const automation = { generateNumber: jest.fn().mockResolvedValue(null) }; 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( service = new BottlingService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService, auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService, webhooks as unknown as WebhooksService,
automation as unknown as AutomationService, 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'); 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(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
entityType: 'BottlingBatch', entityType: 'BottlingBatch',
entityId: 'b1', entityId: 'b1',
action: 'UPDATE', action: 'UPDATE',
beforeData: before, // The audit entry keeps the stock at that moment as well.
afterData: updated, 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 () => { it('remove throws NotFoundException for an unknown bottling batch', async () => {
prisma.bottlingBatch.findUnique.mockResolvedValueOnce(null); prisma.bottlingBatch.findUnique.mockResolvedValueOnce(null);
@@ -1,5 +1,6 @@
import { import {
BadRequestException, BadRequestException,
ConflictException,
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
@@ -7,6 +8,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service'; import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service'; import { AutomationService } from '../automation/automation.service';
import { StockService } from '../stock/stock.service';
import { BottlingBatch, Prisma } from '../../generated/prisma'; import { BottlingBatch, Prisma } from '../../generated/prisma';
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto'; import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto'; import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
@@ -104,14 +106,22 @@ export class BottlingService {
private readonly auditLog: AuditLogService, private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService, private readonly webhooks: WebhooksService,
private readonly automation: AutomationService, private readonly automation: AutomationService,
private readonly stock: StockService,
) {} ) {}
async findAll(filter: BottlingBatchFilter = {}) { async findAll(filter: BottlingBatchFilter = {}) {
return this.prisma.bottlingBatch.findMany({ const batches = await this.prisma.bottlingBatch.findMany({
where: { honeyBatchId: filter.honeyBatchId }, where: { honeyBatchId: filter.honeyBatchId },
include: bottlingBatchInclude, include: bottlingBatchInclude,
orderBy: { date: 'desc' }, 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) { async findById(id: string) {
@@ -122,7 +132,26 @@ export class BottlingService {
if (!bottlingBatch) { if (!bottlingBatch) {
throw new NotFoundException('Bottling batch not found'); throw new NotFoundException('Bottling batch not found');
} }
return bottlingBatch; const remaining = await this.stock.remainingByBottling([id]);
return {
...bottlingBatch,
remainingCount: remaining.get(id) ?? bottlingBatch.jarCount,
};
}
/** Honey is bottled; other articles keep their stock in production batches. */
private async assertBottledProduct(productId: string): Promise<void> {
const product = await this.prisma.product.findUnique({
where: { id: productId },
});
if (!product) {
throw new NotFoundException('Product not found');
}
if (product.kind !== 'BOTTLED_HONEY') {
throw new BadRequestException(
'Abfüllungen nur für Honig-Artikel - hergestellte Waren bekommen Herstellungschargen',
);
}
} }
async create(dto: CreateBottlingBatchDto, actingUserId: string) { async create(dto: CreateBottlingBatchDto, actingUserId: string) {
@@ -133,12 +162,7 @@ export class BottlingService {
throw new NotFoundException('Honey batch not found'); throw new NotFoundException('Honey batch not found');
} }
if (dto.productId) { if (dto.productId) {
const product = await this.prisma.product.findUnique({ await this.assertBottledProduct(dto.productId);
where: { id: dto.productId },
});
if (!product) {
throw new NotFoundException('Product not found');
}
} }
const totalAmount = roundKg((dto.jarSize * dto.jarCount) / 1000); const totalAmount = roundKg((dto.jarSize * dto.jarCount) / 1000);
@@ -188,6 +212,10 @@ export class BottlingService {
const created = await this.findById(bottlingBatch.id); const created = await this.findById(bottlingBatch.id);
await this.webhooks.dispatch('batch.updated', created.honeyBatch); await this.webhooks.dispatch('batch.updated', created.honeyBatch);
await this.stock.notify({
bottlingBatchIds: [created.id],
productIds: [],
});
return created; return created;
} }
@@ -202,12 +230,7 @@ export class BottlingService {
const before = await this.findById(id); const before = await this.findById(id);
if (dto.productId) { if (dto.productId) {
const product = await this.prisma.product.findUnique({ await this.assertBottledProduct(dto.productId);
where: { id: dto.productId },
});
if (!product) {
throw new NotFoundException('Product not found');
}
} }
const honeyBatchId = dto.honeyBatchId ?? before.honeyBatchId; const honeyBatchId = dto.honeyBatchId ?? before.honeyBatchId;
@@ -280,6 +303,8 @@ export class BottlingService {
afterData: bottlingBatch, afterData: bottlingBatch,
}); });
await this.webhooks.dispatch('batch.updated', bottlingBatch.honeyBatch); 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; return bottlingBatch;
} }
@@ -294,6 +319,14 @@ export class BottlingService {
*/ */
async remove(id: string, actingUserId: string) { async remove(id: string, actingUserId: string) {
const before = await this.findById(id); 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 this.prisma.$transaction(async (tx) => {
await tx.bottlingBatch.delete({ where: { id } }); await tx.bottlingBatch.delete({ where: { id } });
@@ -308,6 +341,7 @@ export class BottlingService {
beforeData: before, beforeData: before,
}); });
await this.webhooks.dispatch('batch.updated', before.honeyBatch); await this.webhooks.dispatch('batch.updated', before.honeyBatch);
await this.stock.notify({ bottlingBatchIds: [id], productIds: [] });
return { id }; return { id };
} }
} }
@@ -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;
}
@@ -0,0 +1,48 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsInt,
IsNotEmpty,
IsOptional,
IsPositive,
IsString,
MaxLength,
ValidateIf,
} from 'class-validator';
/**
* Corrects a production batch entered with a typo. The article is fixed once
* created: moving a batch with sales to another article would rewrite where
* those sales came from. `bestBeforeDate: null` removes the date.
*/
export class UpdateProductionBatchDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(100)
lotNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
date?: string;
@ApiPropertyOptional({ description: 'Hergestellte Stückzahl' })
@IsOptional()
@IsInt()
@IsPositive()
quantity?: number;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@ValidateIf((_object, value) => value !== null)
@IsDateString()
bestBeforeDate?: string | null;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
}
@@ -0,0 +1,100 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
import { StockService } from '../stock/stock.service';
import { WithdrawStockDto } from '../stock/dto/withdraw-stock.dto';
import { CorrectStockDto } from '../stock/dto/correct-stock.dto';
import { CreateProductionBatchDto } from './dto/create-production-batch.dto';
import { UpdateProductionBatchDto } from './dto/update-production-batch.dto';
import { ProductionBatchesService } from './production-batches.service';
@ApiTags('production-batches')
@ApiBearerAuth()
@Controller('production-batches')
export class ProductionBatchesController {
constructor(
private readonly batches: ProductionBatchesService,
private readonly stock: StockService,
) {}
@RequirePermission('production-batch:read')
@Get()
findAll(@Query('productId') productId?: string) {
return this.batches.findAll({ productId });
}
@RequirePermission('production-batch:read')
@Get(':id')
findOne(@Param('id') id: string) {
return this.batches.findById(id);
}
@RequirePermission('production-batch:read')
@Get(':id/stock')
async stockOf(@Param('id') id: string) {
await this.batches.findById(id);
return this.stock.stockOf({ kind: 'production', id });
}
@RequirePermission('production-batch:create')
@Idempotent()
@Post()
create(
@Body() dto: CreateProductionBatchDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.batches.create(dto, actor.id);
}
@RequirePermission('production-batch:withdraw')
@Idempotent()
@Post(':id/withdrawals')
async withdraw(
@Param('id') id: string,
@Body() dto: WithdrawStockDto,
@CurrentUser() actor: AuthenticatedUser,
) {
await this.batches.findById(id);
return this.stock.withdraw({ kind: 'production', id }, dto, actor.id);
}
@RequirePermission('production-batch:withdraw')
@Idempotent()
@Post(':id/corrections')
async correct(
@Param('id') id: string,
@Body() dto: CorrectStockDto,
@CurrentUser() actor: AuthenticatedUser,
) {
await this.batches.findById(id);
return this.stock.correct({ kind: 'production', id }, dto, actor.id);
}
@RequirePermission('production-batch:update')
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateProductionBatchDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.batches.update(id, dto, actor.id);
}
@RequirePermission('production-batch:delete')
@Delete(':id')
remove(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.batches.remove(id, actor.id);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { StockModule } from '../stock/stock.module';
import { ProductionBatchesController } from './production-batches.controller';
import { ProductionBatchesService } from './production-batches.service';
@Module({
imports: [StockModule],
controllers: [ProductionBatchesController],
providers: [ProductionBatchesService],
})
export class ProductionBatchesModule {}
@@ -0,0 +1,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 };
}
}
@@ -1,5 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; 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 { export class CreateProductDto {
@ApiProperty() @ApiProperty()
@@ -30,4 +31,18 @@ export class CreateProductDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
salesUnit?: string; salesUnit?: string;
@ApiPropertyOptional({
enum: ProductKind,
description:
'BOTTLED_HONEY: Bestand je Abfüllung; MANUFACTURED: Bestand je Herstellungscharge',
})
@IsOptional()
@IsEnum(ProductKind)
kind?: ProductKind;
@ApiPropertyOptional({ enum: ShopCategory })
@IsOptional()
@IsEnum(ShopCategory)
shopCategory?: ShopCategory;
} }
@@ -1,4 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { Product, ProductStatus } from '../../generated/prisma'; import { Product, ProductStatus } from '../../generated/prisma';
@@ -49,6 +53,9 @@ export class ProductsService {
actingUserId: string, actingUserId: string,
): Promise<Product> { ): Promise<Product> {
const before = await this.findById(id); 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({ const product = await this.prisma.product.update({
where: { id }, where: { id },
data: { ...dto }, data: { ...dto },
@@ -63,4 +70,21 @@ export class ProductsService {
}); });
return product; return product;
} }
/**
* Where an article's stock lives follows from its kind - bottlings for
* honey, production batches for everything else. Switching the kind while
* batches exist would leave that stock in the wrong place.
*/
private async assertKindChangeable(id: string): Promise<void> {
const [bottlings, productionBatches] = await Promise.all([
this.prisma.bottlingBatch.count({ where: { productId: id } }),
this.prisma.productionBatch.count({ where: { productId: id } }),
]);
if (bottlings > 0 || productionBatches > 0) {
throw new BadRequestException(
'Art des Artikels kann nicht geändert werden, solange Abfüllungen oder Herstellungschargen existieren',
);
}
}
} }
@@ -0,0 +1,72 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Max,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
/**
* One booking from the shop. Exactly one target: a bottling (honey, booked
* per vintage) or a MANUFACTURED article (allocated to its production
* batches, oldest stock first). That rule and the link of a return to its
* sale are checked per request in StockService, so a single bad entry is
* rejected on its own instead of failing the whole request.
*/
export class ShopStockRequestDto {
@ApiProperty({
description:
'Vom Shop vergebene, eindeutige ID - dieselbe ID zweimal bucht nur einmal',
})
@IsString()
@IsNotEmpty()
@MaxLength(100)
externalId!: string;
@ApiProperty({ enum: ['SALE', 'RETURN'] })
@IsIn(['SALE', 'RETURN'])
kind!: 'SALE' | 'RETURN';
@ApiPropertyOptional()
@IsOptional()
@IsString()
bottlingBatchId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
productId?: string;
@ApiProperty({ minimum: 1 })
@IsInt()
@Min(1)
@Max(10000)
quantity!: number;
@ApiPropertyOptional({
description: 'Bei RETURN: externalId des Verkaufs, der zurückgebucht wird',
})
@IsOptional()
@IsString()
@MaxLength(100)
reversesExternalId?: string;
}
export class ShopMovementsDto {
@ApiProperty({ type: [ShopStockRequestDto] })
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(100)
@ValidateNested({ each: true })
@Type(() => ShopStockRequestDto)
requests!: ShopStockRequestDto[];
}
@@ -1,17 +1,51 @@
import { Controller, Get } from '@nestjs/common'; import { Body, Controller, Get, HttpCode, Post, Req } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator'; 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') @ApiTags('shop-sync')
@ApiBearerAuth() @ApiBearerAuth()
@Controller('shop-sync') @Controller('shop-sync')
export class ShopSyncController { export class ShopSyncController {
constructor(private readonly shopSync: ShopSyncService) {} constructor(
private readonly shopSync: ShopSyncService,
private readonly stock: StockService,
) {}
@RequirePermission('shop-sync:read') @RequirePermission('shop-sync:read')
@Get() @Get()
feed(): Promise<ShopSyncFeed> { feed(): Promise<ShopSyncFeed> {
return this.shopSync.feed(); return this.shopSync.feed();
} }
/** Only the remaining stock - for the shop's frequent reconcile. */
@RequirePermission('shop-sync:read')
@Get('stock')
stockLevels(): Promise<ShopSyncStock> {
return this.shopSync.stock();
}
/**
* The shop books its paid sales and its returns. Idempotent per
* externalId; each request is answered on its own (created, duplicate or
* rejected), so one bad entry never blocks the others.
*/
@RequirePermission('shop-sync:write')
@HttpCode(200)
@Post('movements')
movements(
@Body() dto: ShopMovementsDto,
@Req() request: { apiKey?: ApiKeyPrincipal },
): Promise<{ results: ShopStockResult[] }> {
return this.stock
.applyShopRequests(dto.requests, request.apiKey?.id ?? null)
.then((results) => ({ results }));
}
} }
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { StockModule } from '../stock/stock.module';
import { ShopSyncController } from './shop-sync.controller'; import { ShopSyncController } from './shop-sync.controller';
import { ShopSyncService } from './shop-sync.service'; import { ShopSyncService } from './shop-sync.service';
@Module({ @Module({
imports: [StockModule],
controllers: [ShopSyncController], controllers: [ShopSyncController],
providers: [ShopSyncService], providers: [ShopSyncService],
}) })
@@ -1,6 +1,11 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; 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 * Bumped only for breaking changes (a removed or renamed field, a changed
@@ -29,6 +34,26 @@ export interface ShopSyncProduct {
weight: number | null; weight: number | null;
salesUnit: string | null; salesUnit: string | null;
status: ProductStatus; 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 { export interface ShopSyncBottling {
@@ -39,6 +64,8 @@ export interface ShopSyncBottling {
/** Grams per jar. */ /** Grams per jar. */
jarSize: number; jarSize: number;
jarCount: number; jarCount: number;
/** jarCount plus all movements (sales, returns, withdrawals, corrections). */
remainingCount: number;
productId: string | null; productId: string | null;
/** Harvests behind this bottling, oldest first. */ /** Harvests behind this bottling, oldest first. */
harvestIds: string[]; harvestIds: string[];
@@ -50,6 +77,7 @@ export interface ShopSyncFeed {
harvests: ShopSyncHarvest[]; harvests: ShopSyncHarvest[];
products: ShopSyncProduct[]; products: ShopSyncProduct[];
bottlings: ShopSyncBottling[]; bottlings: ShopSyncBottling[];
productionBatches: ShopSyncProductionBatch[];
} }
/** /**
@@ -67,10 +95,39 @@ export interface ShopSyncFeed {
*/ */
@Injectable() @Injectable()
export class ShopSyncService { export class ShopSyncService {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly stockService: StockService,
) {}
async stock(): Promise<ShopSyncStock> {
const [bottlings, products] = await Promise.all([
this.stockService.remainingByBottling(),
this.stockService.remainingByProduct(),
]);
return {
generatedAt: new Date(),
bottlings: [...bottlings].map(([id, remainingCount]) => ({
id,
remainingCount,
})),
products: [...products].map(([id, remainingCount]) => ({
id,
remainingCount,
})),
};
}
async feed(): Promise<ShopSyncFeed> { async feed(): Promise<ShopSyncFeed> {
const [harvests, products, bottlings] = await Promise.all([ const [
harvests,
products,
bottlings,
productionBatches,
remainingByBottling,
remainingByBatch,
remainingByProduct,
] = await Promise.all([
this.prisma.harvest.findMany({ this.prisma.harvest.findMany({
select: { select: {
id: true, id: true,
@@ -94,6 +151,8 @@ export class ShopSyncService {
weight: true, weight: true,
salesUnit: true, salesUnit: true,
status: true, status: true,
kind: true,
shopCategory: true,
}, },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}), }),
@@ -121,6 +180,20 @@ export class ShopSyncService {
}, },
orderBy: { date: 'asc' }, 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 { return {
@@ -130,11 +203,23 @@ export class ShopSyncService {
...harvest, ...harvest,
apiaryName: apiary.name, apiaryName: apiary.name,
})), })),
products, products: products.map((product) => ({
...product,
remainingCount:
product.kind === 'MANUFACTURED'
? (remainingByProduct.get(product.id) ?? 0)
: null,
})),
bottlings: bottlings.map(({ honeyBatch, ...bottling }) => ({ bottlings: bottlings.map(({ honeyBatch, ...bottling }) => ({
...bottling, ...bottling,
remainingCount:
remainingByBottling.get(bottling.id) ?? bottling.jarCount,
harvestIds: honeyBatch.extraction.harvests.map((h) => h.harvestId), harvestIds: honeyBatch.extraction.harvests.map((h) => h.harvestId),
})), })),
productionBatches: productionBatches.map((batch) => ({
...batch,
remainingCount: remainingByBatch.get(batch.id) ?? batch.quantity,
})),
}; };
} }
} }
@@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsInt, IsNotEmpty, IsString, MaxLength, Min } from 'class-validator';
/**
* Inventory count. The counted quantity is entered, not the difference: the
* person at the shelf knows how many jars are there, not how far the books
* are off. The service books the difference.
*/
export class CorrectStockDto {
@ApiProperty({ description: 'Gezählter Bestand' })
@IsInt()
@Min(0)
countedQuantity!: number;
@ApiProperty({ description: 'Pflicht: warum weicht der Bestand ab?' })
@IsString()
@IsNotEmpty()
@MaxLength(500)
notes!: string;
}
@@ -0,0 +1,27 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsEnum,
IsInt,
IsOptional,
IsPositive,
IsString,
MaxLength,
} from 'class-validator';
import { WithdrawalReason } from '../../../generated/prisma';
export class WithdrawStockDto {
@ApiProperty({ description: 'Anzahl Gläser bzw. Stück' })
@IsInt()
@IsPositive()
quantity!: number;
@ApiProperty({ enum: WithdrawalReason })
@IsEnum(WithdrawalReason)
reason!: WithdrawalReason;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
notes?: string;
}
@@ -0,0 +1,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,
);
});
});
+164
View File
@@ -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<T extends FifoBatch>(batches: readonly T[]): T[] {
return [...batches].sort((a, b) => {
const aBest = a.bestBeforeDate?.getTime() ?? Number.POSITIVE_INFINITY;
const bBest = b.bestBeforeDate?.getTime() ?? Number.POSITIVE_INFINITY;
if (aBest !== bBest) return aBest - bBest;
const byDate = a.date.getTime() - b.date.getTime();
if (byDate !== 0) return byDate;
return a.id.localeCompare(b.id);
});
}
export class NoBatchError extends Error {
constructor() {
super('Für diesen Artikel gibt es keine Herstellungscharge');
this.name = 'NoBatchError';
}
}
/**
* Splits a shop sale across batches, oldest stock first.
*
* A sale that exceeds the total remaining stock is still booked - the goods
* are sold already, refusing the booking would only hide the gap. The
* overflow goes to the newest batch (by production date), whose remaining
* stock turns negative and is flagged for review in the UI.
*/
export function allocateSale(
batches: readonly FifoBatch[],
quantity: number,
): Allocation[] {
if (batches.length === 0) throw new NoBatchError();
const allocations = new Map<string, number>();
let open = quantity;
for (const batch of fifoOrder(batches)) {
if (open === 0) break;
const take = Math.min(open, Math.max(batch.remaining, 0));
if (take > 0) {
allocations.set(batch.id, take);
open -= take;
}
}
if (open > 0) {
const newest = [...batches].sort(
(a, b) => b.date.getTime() - a.date.getTime() || b.id.localeCompare(a.id),
)[0];
allocations.set(newest.id, (allocations.get(newest.id) ?? 0) + open);
}
return [...allocations].map(([batchId, qty]) => ({ batchId, quantity: qty }));
}
export class ReturnExceedsSaleError extends Error {
constructor(returnable: number) {
super(
`Rückbuchung übersteigt den Verkauf (noch rückbuchbar: ${returnable})`,
);
this.name = 'ReturnExceedsSaleError';
}
}
/**
* Puts a return back onto exactly the batches the sale was taken from. A
* partial return is booked in reverse order (the batch taken last gets its
* units back first), so repeated partial returns never exceed what a batch
* gave.
*
* @param sold per-batch amounts of the original sale, in allocation order
* @param alreadyReturned per-batch amounts returned earlier for this sale
*/
export function allocateReturn(
sold: readonly Allocation[],
alreadyReturned: ReadonlyMap<string, number>,
quantity: number,
): Allocation[] {
const returnable = sold.map((entry) => ({
batchId: entry.batchId,
quantity: entry.quantity - (alreadyReturned.get(entry.batchId) ?? 0),
}));
const total = returnable.reduce((sum, entry) => sum + entry.quantity, 0);
if (quantity > total) throw new ReturnExceedsSaleError(total);
const allocations: Allocation[] = [];
let open = quantity;
for (const entry of [...returnable].reverse()) {
if (open === 0) break;
const give = Math.min(open, entry.quantity);
if (give > 0) {
allocations.push({ batchId: entry.batchId, quantity: give });
open -= give;
}
}
return allocations;
}
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { StockService } from './stock.service';
@Module({
providers: [StockService],
exports: [StockService],
})
export class StockModule {}
+664
View File
@@ -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<Map<string, number>> {
return this.remainingFor('bottling', ids);
}
/** Remaining stock per production batch; all when `ids` is omitted. */
async remainingByProductionBatch(
ids?: string[],
): Promise<Map<string, number>> {
return this.remainingFor('production', ids);
}
/** Remaining stock per MANUFACTURED article (sum of its batches). */
async remainingByProduct(ids?: string[]): Promise<Map<string, number>> {
const batches = await this.prisma.productionBatch.findMany({
where: {
productId: ids ? { in: ids } : undefined,
product: { kind: 'MANUFACTURED' },
},
select: { id: true, productId: true },
});
const products = await this.prisma.product.findMany({
where: { id: ids ? { in: ids } : undefined, kind: 'MANUFACTURED' },
select: { id: true },
});
const perBatch = await this.remainingByProductionBatch(
batches.map((b) => b.id),
);
const result = new Map<string, number>(products.map((p) => [p.id, 0]));
for (const batch of batches) {
result.set(
batch.productId,
(result.get(batch.productId) ?? 0) + (perBatch.get(batch.id) ?? 0),
);
}
return result;
}
async stockOf(target: StockTarget): Promise<BatchStock> {
const initial = await this.initialQuantity(this.prisma, target);
const movements = await this.prisma.batchMovement.findMany({
where: this.movementWhere(target),
select: movementSelect,
orderBy: { createdAt: 'desc' },
});
return { summary: summarizeStock(initial, movements), movements };
}
async hasMovements(target: StockTarget): Promise<boolean> {
const count = await this.prisma.batchMovement.count({
where: this.movementWhere(target),
});
return count > 0;
}
// -------------------------------------------------------------------------
// Manual bookings in ApiCore
// -------------------------------------------------------------------------
/**
* Takes units out of a batch (farm shop, own use, breakage, sample, ...).
* Never more than the remaining stock: unlike a shop sale, a withdrawal is
* entered by a person who can count again.
*/
async withdraw(
target: StockTarget,
input: { quantity: number; reason: WithdrawalReason; notes?: string },
actingUserId: string,
): Promise<BatchStock> {
const movement = await this.prisma.$transaction(async (tx) => {
await this.lock(tx, target);
const remaining = await this.remainingInTx(tx, target);
if (input.quantity > remaining) {
throw new BadRequestException(
`Entnahme übersteigt den Rest (${remaining})`,
);
}
return tx.batchMovement.create({
data: {
...this.movementTarget(target),
type: 'WITHDRAWAL',
quantity: -input.quantity,
reason: input.reason,
notes: input.notes,
userId: actingUserId,
},
});
});
await this.recordAndNotify(target, movement, actingUserId);
return this.stockOf(target);
}
/**
* Inventory count: books the difference between the counted and the
* booked stock as one correction, so the ledger explains every jump.
*/
async correct(
target: StockTarget,
input: { countedQuantity: number; notes: string },
actingUserId: string,
): Promise<BatchStock> {
const movement = await this.prisma.$transaction(async (tx) => {
await this.lock(tx, target);
const remaining = await this.remainingInTx(tx, target);
const difference = input.countedQuantity - remaining;
if (difference === 0) {
throw new BadRequestException(
'Gezählter Bestand entspricht dem gebuchten Rest',
);
}
return tx.batchMovement.create({
data: {
...this.movementTarget(target),
type: 'CORRECTION',
quantity: difference,
notes: input.notes,
userId: actingUserId,
},
});
});
await this.recordAndNotify(target, movement, actingUserId);
return this.stockOf(target);
}
// -------------------------------------------------------------------------
// Shop bookings
// -------------------------------------------------------------------------
/**
* Books the shop's sales and returns. Each request runs on its own, so one
* invalid request does not hold back the rest of the batch. A request with
* a known externalId books nothing and reports the original result.
*/
async applyShopRequests(
requests: ShopStockRequestInput[],
apiKeyId: string | null,
): Promise<ShopStockResult[]> {
const results: ShopStockResult[] = [];
const change: StockChange = { bottlingBatchIds: [], productIds: [] };
for (const request of requests) {
const result = await this.applyShopRequest(request, apiKeyId);
results.push(result);
if (result.status === 'created') {
if (request.bottlingBatchId) {
change.bottlingBatchIds.push(request.bottlingBatchId);
}
if (request.productId) change.productIds.push(request.productId);
}
}
if (change.bottlingBatchIds.length > 0 || change.productIds.length > 0) {
await this.notify(change);
}
return results;
}
private async applyShopRequest(
request: ShopStockRequestInput,
apiKeyId: string | null,
): Promise<ShopStockResult> {
const known = await this.findShopResult(request.externalId);
if (known) return known;
try {
await this.prisma.$transaction(async (tx) => {
this.assertShapeOf(request);
const created = await tx.shopStockRequest.create({
data: {
externalId: request.externalId,
kind: request.kind,
bottlingBatchId: request.bottlingBatchId,
productId: request.productId,
quantity: request.quantity,
reversesExternalId: request.reversesExternalId,
apiKeyId,
},
});
const allocations = request.bottlingBatchId
? await this.bookOnBottling(tx, request)
: await this.bookOnProduct(tx, request);
await tx.batchMovement.createMany({
data: allocations.map((allocation) => ({
...(request.bottlingBatchId
? { bottlingBatchId: allocation.batchId }
: { productionBatchId: allocation.batchId }),
type:
request.kind === 'SALE'
? ('SHOP_SALE' as const)
: ('SHOP_RETURN' as const),
quantity:
request.kind === 'SALE'
? -allocation.quantity
: allocation.quantity,
shopRequestId: created.id,
apiKeyId,
})),
});
});
} catch (error: unknown) {
if (error instanceof RejectedRequest) {
return {
externalId: request.externalId,
status: 'rejected',
error: error.message,
allocations: [],
remainingCount: null,
};
}
// The same externalId sent twice in parallel: the other request won.
if (isUniqueViolation(error)) {
const winner = await this.findShopResult(request.externalId);
if (winner) return winner;
}
throw error;
}
const result = await this.findShopResult(request.externalId);
if (!result) throw new Error('Shop booking vanished after commit');
return { ...result, status: 'created' };
}
private assertShapeOf(request: ShopStockRequestInput): void {
if (!request.bottlingBatchId === !request.productId) {
throw new RejectedRequest(
'Genau eines von bottlingBatchId oder productId angeben',
);
}
if (request.kind === 'RETURN' && !request.reversesExternalId) {
throw new RejectedRequest('Rückbuchung ohne reversesExternalId');
}
}
private async bookOnBottling(
tx: Tx,
request: ShopStockRequestInput,
): Promise<Allocation[]> {
const bottlingBatchId = request.bottlingBatchId!;
const exists = await tx.bottlingBatch.findUnique({
where: { id: bottlingBatchId },
select: { id: true },
});
if (!exists) throw new RejectedRequest('Abfüllung unbekannt');
await this.lock(tx, { kind: 'bottling', id: bottlingBatchId });
if (request.kind === 'SALE') {
return [{ batchId: bottlingBatchId, quantity: request.quantity }];
}
const { sold, returned } = await this.saleToReverse(tx, request, {
bottlingBatchId,
});
return this.allocateReturnOrReject(sold, returned, request.quantity);
}
private async bookOnProduct(
tx: Tx,
request: ShopStockRequestInput,
): Promise<Allocation[]> {
const productId = request.productId!;
const product = await tx.product.findUnique({
where: { id: productId },
select: { kind: true },
});
if (!product) throw new RejectedRequest('Artikel unbekannt');
if (product.kind !== 'MANUFACTURED') {
throw new RejectedRequest(
'Honig wird je Abfüllung gebucht (bottlingBatchId), nicht je Artikel',
);
}
await this.lockProduct(tx, productId);
if (request.kind === 'RETURN') {
const { sold, returned } = await this.saleToReverse(tx, request, {
productId,
});
return this.allocateReturnOrReject(sold, returned, request.quantity);
}
const batches = await tx.productionBatch.findMany({
where: { productId },
select: { id: true, quantity: true, date: true, bestBeforeDate: true },
});
const sums = await this.sumMovements(
tx,
'production',
batches.map((b) => b.id),
);
try {
return allocateSale(
batches.map((b) => ({
id: b.id,
date: b.date,
bestBeforeDate: b.bestBeforeDate,
remaining: b.quantity + (sums.get(b.id) ?? 0),
})),
request.quantity,
);
} catch (error: unknown) {
if (error instanceof NoBatchError) {
throw new RejectedRequest(error.message);
}
throw error;
}
}
/**
* The sale a return refers to, as per-batch amounts in allocation order,
* plus what earlier returns already gave back per batch.
*/
private async saleToReverse(
tx: Tx,
request: ShopStockRequestInput,
target: { bottlingBatchId: string } | { productId: string },
): Promise<{ sold: Allocation[]; returned: Map<string, number> }> {
const sale = await tx.shopStockRequest.findUnique({
where: { externalId: request.reversesExternalId! },
include: { movements: { orderBy: { id: 'asc' } } },
});
const sameTarget =
sale !== null &&
('bottlingBatchId' in target
? sale.bottlingBatchId === target.bottlingBatchId
: sale.productId === target.productId);
if (!sale || sale.kind !== 'SALE' || !sameTarget) {
throw new RejectedRequest(
'Rückbuchung verweist auf keinen passenden Verkauf',
);
}
const sold = sale.movements.map((m) => ({
batchId: (m.bottlingBatchId ?? m.productionBatchId)!,
quantity: -m.quantity,
}));
const earlier = await tx.batchMovement.findMany({
where: {
shopRequest: { reversesExternalId: sale.externalId, kind: 'RETURN' },
},
select: {
bottlingBatchId: true,
productionBatchId: true,
quantity: true,
},
});
const returned = new Map<string, number>();
for (const movement of earlier) {
const batchId = (movement.bottlingBatchId ?? movement.productionBatchId)!;
returned.set(batchId, (returned.get(batchId) ?? 0) + movement.quantity);
}
return { sold, returned };
}
private allocateReturnOrReject(
sold: Allocation[],
returned: Map<string, number>,
quantity: number,
): Allocation[] {
try {
return allocateReturn(sold, returned, quantity);
} catch (error: unknown) {
if (error instanceof ReturnExceedsSaleError) {
throw new RejectedRequest(error.message);
}
throw error;
}
}
/** The stored outcome of a shop request, or null if it is unknown. */
private async findShopResult(
externalId: string,
): Promise<ShopStockResult | null> {
const request = await this.prisma.shopStockRequest.findUnique({
where: { externalId },
include: {
movements: {
orderBy: { id: 'asc' },
include: {
bottlingBatch: { select: { lotNumber: true } },
productionBatch: { select: { lotNumber: true } },
},
},
},
});
if (!request) return null;
const remaining = request.bottlingBatchId
? (await this.remainingByBottling([request.bottlingBatchId])).get(
request.bottlingBatchId,
)
: (await this.remainingByProduct([request.productId!])).get(
request.productId!,
);
return {
externalId,
status: 'duplicate',
allocations: request.movements.map((m) => ({
batchType: m.bottlingBatchId ? 'BOTTLING' : 'PRODUCTION',
batchId: (m.bottlingBatchId ?? m.productionBatchId)!,
lotNumber: (m.bottlingBatch?.lotNumber ??
m.productionBatch?.lotNumber)!,
quantity: Math.abs(m.quantity),
})),
remainingCount: remaining ?? 0,
};
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
/** Tells subscribers (the shop) that stock changed; they fetch the numbers. */
async notify(change: StockChange): Promise<void> {
await this.webhooks.dispatch('stock.changed', {
bottlingBatchIds: [...new Set(change.bottlingBatchIds)],
productIds: [...new Set(change.productIds)],
});
}
/** The stock.changed payload for one batch. */
async changeFor(target: StockTarget): Promise<StockChange> {
if (target.kind === 'bottling') {
return { bottlingBatchIds: [target.id], productIds: [] };
}
const batch = await this.prisma.productionBatch.findUnique({
where: { id: target.id },
select: { productId: true },
});
return { bottlingBatchIds: [], productIds: batch ? [batch.productId] : [] };
}
private async recordAndNotify(
target: StockTarget,
movement: { id: string },
actingUserId: string,
): Promise<void> {
await this.auditLog.record({
entityType: 'BatchMovement',
entityId: movement.id,
action: 'CREATE',
userId: actingUserId,
afterData: movement,
});
await this.notify(await this.changeFor(target));
}
private async remainingFor(
kind: StockTarget['kind'],
ids?: string[],
): Promise<Map<string, number>> {
const where = { id: ids ? { in: ids } : undefined };
const initial =
kind === 'bottling'
? (
await this.prisma.bottlingBatch.findMany({
where,
select: { id: true, jarCount: true },
})
).map((row) => ({ id: row.id, initial: row.jarCount }))
: (
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<Map<string, number>> {
if (ids.length === 0) return new Map();
if (kind === 'bottling') {
const rows = await db.batchMovement.groupBy({
by: ['bottlingBatchId'],
where: { bottlingBatchId: { in: ids } },
_sum: { quantity: true },
});
return new Map(
rows.map((r) => [r.bottlingBatchId!, r._sum.quantity ?? 0]),
);
}
const rows = await db.batchMovement.groupBy({
by: ['productionBatchId'],
where: { productionBatchId: { in: ids } },
_sum: { quantity: true },
});
return new Map(
rows.map((r) => [r.productionBatchId!, r._sum.quantity ?? 0]),
);
}
private async remainingInTx(tx: Tx, target: StockTarget): Promise<number> {
const initial = await this.initialQuantity(tx, target);
const sums = await this.sumMovements(tx, target.kind, [target.id]);
return initial + (sums.get(target.id) ?? 0);
}
private async initialQuantity(
db: Tx | PrismaService,
target: StockTarget,
): Promise<number> {
if (target.kind === 'bottling') {
const row = await db.bottlingBatch.findUnique({
where: { id: target.id },
select: { jarCount: true },
});
if (!row) throw new NotFoundException('Bottling batch not found');
return row.jarCount;
}
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<void> {
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<void> {
await tx.$queryRaw`SELECT id FROM "Product" WHERE id = ${productId} FOR UPDATE`;
}
}
@@ -7,6 +7,10 @@ export const WEBHOOK_EVENTS = [
'batch.created', 'batch.created',
'batch.updated', 'batch.updated',
'product.stock_changed', '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.created',
'task.completed', 'task.completed',
] as const; ] as const;
+358
View File
@@ -0,0 +1,358 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
interface StockBody {
summary: {
initial: number;
soldInShop: number;
withdrawn: number;
corrected: number;
remaining: number;
};
movements: { type: string; quantity: number }[];
}
interface ShopResult {
externalId: string;
status: 'created' | 'duplicate' | 'rejected';
error?: string;
allocations: { batchId: string; lotNumber: string; quantity: number }[];
remainingCount: number | null;
}
/**
* Stock ledger end to end: withdrawals and inventory corrections in ApiCore,
* the shop's sales and returns via API key, oldest-batch-first allocation,
* idempotency and the row lock against concurrent bookings.
*/
describe('Stock ledger (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
let beekeeperToken: string;
let adminToken: string;
let writeKey: string;
let readKey: string;
let bottlingId: string;
let candleId: string;
let oldBatchId: string;
let newBatchId: string;
const server = () => app.getHttpServer();
const asBeekeeper = (req: request.Test) =>
req.set('Authorization', `Bearer ${beekeeperToken}`);
function book(token: string, requests: Record<string, unknown>[]) {
return request(server())
.post('/api/v1/shop-sync/movements')
.set('Authorization', `Bearer ${token}`)
.send({ requests });
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
const beekeeper = await prisma.user.create({
data: {
email: `stock-beekeeper-${suffix}@example.com`,
passwordHash,
name: 'Beekeeper',
role: { connect: { key: 'BEEKEEPER' } },
},
});
await prisma.user.create({
data: {
email: `stock-admin-${suffix}@example.com`,
passwordHash,
name: 'Admin',
role: { connect: { key: 'ADMIN' } },
},
});
beekeeperToken = (
await login(app, `stock-beekeeper-${suffix}@example.com`, password)
).accessToken;
adminToken = (
await login(app, `stock-admin-${suffix}@example.com`, password)
).accessToken;
const createKey = async (scopes: string[]) => {
const response = await request(server())
.post('/api/v1/api-keys')
.set('Authorization', `Bearer ${adminToken}`)
.send({ name: `Stock ${scopes.join('+')} ${suffix}`, scopes })
.expect(201);
return (response.body as { token: string }).token;
};
writeKey = await createKey(['shop-sync:read', 'shop-sync:write']);
readKey = await createKey(['shop-sync:read']);
// Honey chain: apiary -> harvest -> extraction -> honey batch -> bottling.
const apiary = await prisma.apiary.create({
data: { name: `Stand ${suffix}` },
});
const harvest = await prisma.harvest.create({
data: { apiaryId: apiary.id, date: new Date(), userId: beekeeper.id },
});
const extraction = await prisma.extraction.create({
data: { date: new Date(), userId: beekeeper.id },
});
await prisma.extractionHarvest.create({
data: { extractionId: extraction.id, harvestId: harvest.id },
});
const honeyBatch = await prisma.honeyBatch.create({
data: {
batchNumber: `HC-${suffix}`,
extractionId: extraction.id,
amount: 50,
},
});
const bottling = await asBeekeeper(
request(server()).post('/api/v1/bottling-batches'),
)
.send({
honeyBatchId: honeyBatch.id,
date: '2026-07-01',
jarSize: 500,
jarCount: 10,
bestBeforeDate: '2028-07-01',
lotNumber: `L-${suffix}`,
})
.expect(201);
bottlingId = (bottling.body as { id: string }).id;
const candle = await prisma.product.create({
data: {
name: `Kerze ${suffix}`,
kind: 'MANUFACTURED',
shopCategory: 'KERZEN',
},
});
candleId = candle.id;
const createBatch = async (lot: string, quantity: number, bbd: string) => {
const response = await asBeekeeper(
request(server()).post('/api/v1/production-batches'),
)
.send({
productId: candleId,
lotNumber: `${lot}-${suffix}`,
date: '2026-03-01',
quantity,
bestBeforeDate: bbd,
})
.expect(201);
return (response.body as { id: string }).id;
};
newBatchId = await createBatch('K-NEW', 5, '2027-06-01');
oldBatchId = await createBatch('K-OLD', 3, '2027-01-01');
});
afterAll(async () => {
await app.close();
});
it('withdraws from a bottling and refuses more than the remaining stock', async () => {
const response = await asBeekeeper(
request(server()).post(
`/api/v1/bottling-batches/${bottlingId}/withdrawals`,
),
)
.send({ quantity: 2, reason: 'HOFLADEN' })
.expect(201);
expect((response.body as StockBody).summary).toMatchObject({
initial: 10,
withdrawn: 2,
remaining: 8,
});
await asBeekeeper(
request(server()).post(
`/api/v1/bottling-batches/${bottlingId}/withdrawals`,
),
)
.send({ quantity: 20, reason: 'BRUCH' })
.expect(400);
});
it('books a shop sale once, even when it is sent twice', async () => {
const sale = {
externalId: `sale-honey-${suffix}`,
kind: 'SALE',
bottlingBatchId: bottlingId,
quantity: 3,
};
const first = await book(writeKey, [sale]).expect(200);
const [created] = (first.body as { results: ShopResult[] }).results;
expect(created.status).toBe('created');
expect(created.remainingCount).toBe(5);
const second = await book(writeKey, [sale]).expect(200);
const [duplicate] = (second.body as { results: ShopResult[] }).results;
expect(duplicate.status).toBe('duplicate');
expect(duplicate.remainingCount).toBe(5);
});
it('allocates an article sale to the batch with the earliest best-before date first', async () => {
const response = await book(writeKey, [
{
externalId: `sale-candle-${suffix}`,
kind: 'SALE',
productId: candleId,
quantity: 4,
},
]).expect(200);
const [result] = (response.body as { results: ShopResult[] }).results;
expect(result.status).toBe('created');
expect(
result.allocations.map((a) => [a.batchId, a.quantity]).sort(),
).toEqual(
[
[oldBatchId, 3],
[newBatchId, 1],
].sort(),
);
expect(result.remainingCount).toBe(4);
});
it('returns onto exactly the batches of the sale and refuses to return more', async () => {
const response = await book(writeKey, [
{
externalId: `return-candle-${suffix}`,
kind: 'RETURN',
productId: candleId,
quantity: 2,
reversesExternalId: `sale-candle-${suffix}`,
},
{
externalId: `return-too-much-${suffix}`,
kind: 'RETURN',
productId: candleId,
quantity: 5,
reversesExternalId: `sale-candle-${suffix}`,
},
]).expect(200);
const [returned, tooMuch] = (response.body as { results: ShopResult[] })
.results;
expect(returned.status).toBe('created');
const perBatch = new Map(
returned.allocations.map((a) => [a.batchId, a.quantity]),
);
expect(perBatch.get(newBatchId)).toBe(1);
expect(perBatch.get(oldBatchId)).toBe(1);
expect(returned.remainingCount).toBe(6);
expect(tooMuch.status).toBe('rejected');
});
it('rejects a bad request but still books the rest of the batch', async () => {
const response = await book(writeKey, [
{
externalId: `bad-${suffix}`,
kind: 'SALE',
productId: 'does-not-exist',
quantity: 1,
},
{
externalId: `good-${suffix}`,
kind: 'SALE',
bottlingBatchId: bottlingId,
quantity: 1,
},
]).expect(200);
const [bad, good] = (response.body as { results: ShopResult[] }).results;
expect(bad.status).toBe('rejected');
expect(good.status).toBe('created');
expect(good.remainingCount).toBe(4);
});
it('needs shop-sync:write to book', async () => {
await book(readKey, [
{
externalId: `nope-${suffix}`,
kind: 'SALE',
bottlingBatchId: bottlingId,
quantity: 1,
},
]).expect(403);
});
it('serves the remaining stock to the shop', async () => {
const response = await request(server())
.get('/api/v1/shop-sync/stock')
.set('Authorization', `Bearer ${readKey}`)
.expect(200);
const body = response.body as {
bottlings: { id: string; remainingCount: number }[];
products: { id: string; remainingCount: number }[];
};
expect(
body.bottlings.find((b) => b.id === bottlingId)?.remainingCount,
).toBe(4);
expect(body.products.find((p) => p.id === candleId)?.remainingCount).toBe(
6,
);
});
it('serializes concurrent withdrawals so the stock never goes below zero', async () => {
// newBatch: 5 produced - 1 sold + 1 returned = 5 left; 8 parallel takes of 1.
const attempts = await Promise.all(
Array.from({ length: 8 }, () =>
asBeekeeper(
request(server()).post(
`/api/v1/production-batches/${newBatchId}/withdrawals`,
),
).send({ quantity: 1, reason: 'PROBE' }),
),
);
expect(attempts.filter((r) => r.status === 201)).toHaveLength(5);
expect(attempts.filter((r) => r.status === 400)).toHaveLength(3);
const stock = await asBeekeeper(
request(server()).get(`/api/v1/production-batches/${newBatchId}/stock`),
).expect(200);
expect((stock.body as StockBody).summary.remaining).toBe(0);
});
it('books an inventory count as the difference', async () => {
const response = await asBeekeeper(
request(server()).post(
`/api/v1/bottling-batches/${bottlingId}/corrections`,
),
)
.send({ countedQuantity: 6, notes: 'Zwei Gläser im Keller gefunden' })
.expect(201);
const body = response.body as StockBody;
expect(body.summary.corrected).toBe(2);
expect(body.summary.remaining).toBe(6);
});
it('refuses to delete a bottling with movements', async () => {
await request(server())
.delete(`/api/v1/bottling-batches/${bottlingId}`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(409);
});
it('refuses a production batch for a honey article', async () => {
const honey = await prisma.product.create({
data: { name: `Honig ${suffix}` },
});
await asBeekeeper(request(server()).post('/api/v1/production-batches'))
.send({
productId: honey.id,
lotNumber: `X-${suffix}`,
date: '2026-03-01',
quantity: 1,
})
.expect(400);
});
});