diff --git a/ApiCore-Backend/prisma/migrations/20261005100000_raw_materials_production/migration.sql b/ApiCore-Backend/prisma/migrations/20261005100000_raw_materials_production/migration.sql new file mode 100644 index 0000000..8a1c958 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261005100000_raw_materials_production/migration.sql @@ -0,0 +1,106 @@ +-- Rohstoffe (Wachs, Propolis) mit eigenen Chargen und Bestand; Herstellungs- +-- chargen verbrauchen sie (ProductionInput + Bewegung CONSUMPTION). Damit +-- reicht die Rueckverfolgung vom Rohstoff ueber die Charge bis zur Kundin. + +-- CreateEnum +CREATE TYPE "RawMaterialKind" AS ENUM ('BEESWAX', 'PROPOLIS'); + +-- AlterEnum +ALTER TYPE "BatchMovementType" ADD VALUE 'CONSUMPTION'; + +-- AlterTable +ALTER TABLE "BatchMovement" ADD COLUMN "rawMaterialLotId" TEXT; + +-- CreateTable +CREATE TABLE "RawMaterialLot" ( + "id" TEXT NOT NULL, + "kind" "RawMaterialKind" NOT NULL, + "lotNumber" TEXT NOT NULL, + "date" TIMESTAMP(3) NOT NULL, + "quantity" INTEGER NOT NULL, + "apiaryId" TEXT, + "harvestId" TEXT, + "notes" TEXT, + "userId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "RawMaterialLot_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ProductionInput" ( + "id" TEXT NOT NULL, + "productionBatchId" TEXT NOT NULL, + "rawMaterialLotId" TEXT NOT NULL, + "quantity" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ProductionInput_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "RawMaterialLot_lotNumber_key" ON "RawMaterialLot"("lotNumber"); + +-- CreateIndex +CREATE INDEX "RawMaterialLot_kind_idx" ON "RawMaterialLot"("kind"); + +-- CreateIndex +CREATE INDEX "ProductionInput_productionBatchId_idx" ON "ProductionInput"("productionBatchId"); + +-- CreateIndex +CREATE INDEX "ProductionInput_rawMaterialLotId_idx" ON "ProductionInput"("rawMaterialLotId"); + +-- CreateIndex +CREATE INDEX "BatchMovement_rawMaterialLotId_idx" ON "BatchMovement"("rawMaterialLotId"); + +-- AddForeignKey +ALTER TABLE "RawMaterialLot" ADD CONSTRAINT "RawMaterialLot_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RawMaterialLot" ADD CONSTRAINT "RawMaterialLot_harvestId_fkey" FOREIGN KEY ("harvestId") REFERENCES "Harvest"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RawMaterialLot" ADD CONSTRAINT "RawMaterialLot_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProductionInput" ADD CONSTRAINT "ProductionInput_productionBatchId_fkey" FOREIGN KEY ("productionBatchId") REFERENCES "ProductionBatch"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProductionInput" ADD CONSTRAINT "ProductionInput_rawMaterialLotId_fkey" FOREIGN KEY ("rawMaterialLotId") REFERENCES "RawMaterialLot"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_rawMaterialLotId_fkey" FOREIGN KEY ("rawMaterialLotId") REFERENCES "RawMaterialLot"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + + +-- Eine Bewegung gehoert jetzt zu genau einer von drei Chargenarten. +ALTER TABLE "BatchMovement" DROP CONSTRAINT "BatchMovement_exactly_one_batch"; +ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_exactly_one_batch" + CHECK (num_nonnulls("bottlingBatchId", "productionBatchId", "rawMaterialLotId") = 1); +ALTER TABLE "RawMaterialLot" ADD CONSTRAINT "RawMaterialLot_quantity_positive" CHECK ("quantity" > 0); +ALTER TABLE "ProductionInput" ADD CONSTRAINT "ProductionInput_quantity_positive" CHECK ("quantity" > 0); + +-- Permissions (catalog sync grants only brand-new rows to ADMIN): roles that +-- may maintain production batches may maintain raw materials, readers read. +INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder") +VALUES + (gen_random_uuid()::text, 'raw-material:read', 'raw-material', 'read', 'Ansehen', 'Rohstoffe', 1630), + (gen_random_uuid()::text, 'raw-material:create', 'raw-material', 'create', 'Anlegen', 'Rohstoffe', 1631), + (gen_random_uuid()::text, 'raw-material:update', 'raw-material', 'update', 'Bearbeiten', 'Rohstoffe', 1632), + (gen_random_uuid()::text, 'raw-material:delete', 'raw-material', 'delete', 'Löschen', 'Rohstoffe', 1633), + (gen_random_uuid()::text, 'raw-material:withdraw', 'raw-material', 'withdraw', 'Entnehmen und Inventur', 'Rohstoffe', 1634) +ON CONFLICT (key) DO NOTHING; + +INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt") +SELECT DISTINCT rp."roleId", p.id, now() +FROM "RolePermission" rp +JOIN "Permission" existing ON existing.id = rp."permissionId" AND existing.key = 'production-batch:create' +JOIN "Permission" p ON p.key IN ('raw-material:create', 'raw-material:update', 'raw-material:delete', 'raw-material:withdraw') +ON CONFLICT DO NOTHING; + +INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt") +SELECT DISTINCT rp."roleId", p.id, now() +FROM "RolePermission" rp +JOIN "Permission" existing ON existing.id = rp."permissionId" AND existing.key = 'production-batch:read' +JOIN "Permission" p ON p.key = 'raw-material:read' +ON CONFLICT DO NOTHING; diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 198d21b..3246aaa 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -122,6 +122,7 @@ model User { bottlingBatches BottlingBatch[] productionBatches ProductionBatch[] batchMovements BatchMovement[] + rawMaterialLots RawMaterialLot[] notifications Notification[] queenEvaluations QueenEvaluation[] webhookSubscriptions WebhookSubscription[] @@ -239,6 +240,7 @@ model Apiary { colonyMoves ColonyApiaryHistory[] harvests Harvest[] treatments Treatment[] + rawMaterialLots RawMaterialLot[] @@index([status]) } @@ -761,6 +763,7 @@ model Harvest { colonies HarvestColony[] extractions ExtractionHarvest[] + rawMaterialLots RawMaterialLot[] @@index([apiaryId, date]) } @@ -1097,15 +1100,73 @@ model ProductionBatch { updatedAt DateTime @updatedAt movements BatchMovement[] + // Woraus die Charge hergestellt wurde (Rueckverfolgung Rohstoff -> Ware). + inputs ProductionInput[] @@index([productId]) } +// --------------------------------------------------------------------------- +// Rohstoffe (Wachs, Propolis) und ihr Verbrauch in der Herstellung +// --------------------------------------------------------------------------- + +enum RawMaterialKind { + BEESWAX + PROPOLIS +} + +// Eine Rohstoff-Charge, z. B. "Wachs 2026-01: 4200 g" aus dem Entdeckeln +// oder "Propolis 2026-02: 180 g" vom Abkratzen der Gitter. Menge in Gramm; +// der Rest folgt wie bei allen Chargen aus dem Bewegungsbuch. +model RawMaterialLot { + id String @id @default(cuid()) + kind RawMaterialKind + lotNumber String @unique + // Gewinnungsdatum + date DateTime + // Gewonnene Menge in Gramm - Ausgangsmenge fuer den Rest. + quantity Int + // Herkunft, optional: Stand und/oder Ernte, aus der der Rohstoff stammt. + apiaryId String? + apiary Apiary? @relation(fields: [apiaryId], references: [id]) + harvestId String? + harvest Harvest? @relation(fields: [harvestId], references: [id]) + notes String? + userId String? + user User? @relation(fields: [userId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + movements BatchMovement[] + usedIn ProductionInput[] + + @@index([kind]) +} + +// Verbrauch einer Rohstoff-Charge in einer Herstellungscharge. Die +// Abbuchung selbst steht als CONSUMPTION im Bewegungsbuch des Rohstoffs. +model ProductionInput { + id String @id @default(cuid()) + productionBatchId String + productionBatch ProductionBatch @relation(fields: [productionBatchId], references: [id], onDelete: Cascade) + rawMaterialLotId String + rawMaterialLot RawMaterialLot @relation(fields: [rawMaterialLotId], references: [id], onDelete: Restrict) + // Verbrauchte Menge in Gramm. + quantity Int + createdAt DateTime @default(now()) + + @@index([productionBatchId]) + @@index([rawMaterialLotId]) +} + enum BatchMovementType { SHOP_SALE SHOP_RETURN WITHDRAWAL CORRECTION + // Rohstoff in einer Herstellungscharge verbraucht (minus) bzw. beim + // Loeschen einer irrtuemlich angelegten Charge zurueckgegeben (plus). + CONSUMPTION } enum WithdrawalReason { @@ -1126,6 +1187,8 @@ model BatchMovement { bottlingBatch BottlingBatch? @relation(fields: [bottlingBatchId], references: [id], onDelete: Restrict) productionBatchId String? productionBatch ProductionBatch? @relation(fields: [productionBatchId], references: [id], onDelete: Restrict) + rawMaterialLotId String? + rawMaterialLot RawMaterialLot? @relation(fields: [rawMaterialLotId], references: [id], onDelete: Restrict) type BatchMovementType // Mit Vorzeichen: minus = raus, plus = rein. quantity Int @@ -1140,6 +1203,7 @@ model BatchMovement { @@index([bottlingBatchId]) @@index([productionBatchId]) + @@index([rawMaterialLotId]) @@index([shopRequestId]) } diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index fa3daa4..c38337b 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -28,6 +28,7 @@ import { QrModule } from './qr/qr.module'; import { ProductsModule } from './products/products.module'; import { BottlingModule } from './bottling/bottling.module'; import { ProductionBatchesModule } from './production-batches/production-batches.module'; +import { RawMaterialsModule } from './raw-materials/raw-materials.module'; import { EquipmentModule } from './equipment/equipment.module'; import { CalendarModule } from './calendar/calendar.module'; import { CalendarSyncModule } from './calendar-sync/calendar-sync.module'; @@ -85,6 +86,7 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea ProductsModule, BottlingModule, ProductionBatchesModule, + RawMaterialsModule, EquipmentModule, CalendarModule, CalendarSyncModule, diff --git a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts index a23da22..4d86b24 100644 --- a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts +++ b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts @@ -208,6 +208,18 @@ export const PERMISSION_CATALOG = [ ], 1600, ), + ...group( + 'Rohstoffe', + 'raw-material', + [ + ['read', 'Ansehen'], + ['create', 'Anlegen'], + ['update', 'Bearbeiten'], + ['delete', 'Löschen'], + ['withdraw', 'Entnehmen und Inventur'], + ], + 1630, + ), ...group( 'Herstellungschargen', 'production-batch', 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 index fed7a30..25155a3 100644 --- a/ApiCore-Backend/src/production-batches/dto/create-production-batch.dto.ts +++ b/ApiCore-Backend/src/production-batches/dto/create-production-batch.dto.ts @@ -1,5 +1,9 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; import { + ArrayMaxSize, + IsArray, + ValidateNested, IsDateString, IsInt, IsNotEmpty, @@ -9,6 +13,18 @@ import { MaxLength, } from 'class-validator'; +/** Raw material that went into the batch, in grams. */ +export class ProductionInputDto { + @ApiProperty() + @IsString() + rawMaterialLotId!: string; + + @ApiProperty({ description: 'Verbrauchte Menge in Gramm' }) + @IsInt() + @IsPositive() + quantity!: number; +} + export class CreateProductionBatchDto { @ApiProperty({ description: 'Artikel der Art MANUFACTURED' }) @IsString() @@ -39,4 +55,16 @@ export class CreateProductionBatchDto { @IsString() @MaxLength(2000) notes?: string; + + @ApiPropertyOptional({ + type: [ProductionInputDto], + description: + 'Verbrauchte Rohstoff-Chargen - werden beim Anlegen vom Rohstoff abgebucht', + }) + @IsOptional() + @IsArray() + @ArrayMaxSize(20) + @ValidateNested({ each: true }) + @Type(() => ProductionInputDto) + inputs?: ProductionInputDto[]; } diff --git a/ApiCore-Backend/src/production-batches/production-batches.service.ts b/ApiCore-Backend/src/production-batches/production-batches.service.ts index 8d989e5..2aa9941 100644 --- a/ApiCore-Backend/src/production-batches/production-batches.service.ts +++ b/ApiCore-Backend/src/production-batches/production-batches.service.ts @@ -10,7 +10,17 @@ 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; +const include = { + product: true, + // Backward traceability: which raw material lots went into this batch. + inputs: { + select: { + id: true, + quantity: true, + rawMaterialLot: { select: { id: true, lotNumber: true, kind: true } }, + }, + }, +} as const; export interface ProductionBatchFilter { productId?: string; @@ -66,19 +76,33 @@ export class ProductionBatchesService { ); } // 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, - }, + // field-specific message in AllExceptionsFilter. Batch and raw material + // consumption are one transaction: a batch never exists without its + // inputs being booked, and not enough raw material creates no batch. + const batch = await this.prisma.$transaction(async (tx) => { + const created = await tx.productionBatch.create({ + data: { + productId: dto.productId, + lotNumber: dto.lotNumber, + date: new Date(dto.date), + quantity: dto.quantity, + bestBeforeDate: dto.bestBeforeDate + ? new Date(dto.bestBeforeDate) + : undefined, + notes: dto.notes, + userId: actingUserId, + }, + }); + if (dto.inputs && dto.inputs.length > 0) { + await this.stock.consumeForProduction( + tx, + created.id, + created.lotNumber, + dto.inputs, + actingUserId, + ); + } + return created; }); await this.auditLog.record({ entityType: 'ProductionBatch', @@ -143,7 +167,17 @@ export class ProductionBatchesService { 'Charge hat bereits Bestandsbewegungen und kann nicht gelöscht werden - Bestand über "Inventur korrigieren" anpassen', ); } - await this.prisma.productionBatch.delete({ where: { id } }); + // The raw material goes back to its lots as a movement of its own; the + // input rows go with the batch (cascade). + await this.prisma.$transaction(async (tx) => { + await this.stock.returnProductionInputs( + tx, + id, + before.lotNumber, + actingUserId, + ); + await tx.productionBatch.delete({ where: { id } }); + }); await this.auditLog.record({ entityType: 'ProductionBatch', entityId: id, diff --git a/ApiCore-Backend/src/raw-materials/dto/create-raw-material-lot.dto.ts b/ApiCore-Backend/src/raw-materials/dto/create-raw-material-lot.dto.ts new file mode 100644 index 0000000..e37a372 --- /dev/null +++ b/ApiCore-Backend/src/raw-materials/dto/create-raw-material-lot.dto.ts @@ -0,0 +1,51 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsDateString, + IsEnum, + IsInt, + IsNotEmpty, + IsOptional, + IsPositive, + IsString, + MaxLength, +} from 'class-validator'; +import { RawMaterialKind } from '../../../generated/prisma'; + +export class CreateRawMaterialLotDto { + @ApiProperty({ enum: RawMaterialKind }) + @IsEnum(RawMaterialKind) + kind!: RawMaterialKind; + + @ApiProperty() + @IsString() + @IsNotEmpty() + @MaxLength(100) + lotNumber!: string; + + @ApiProperty({ description: 'Gewinnungsdatum' }) + @IsDateString() + date!: string; + + @ApiProperty({ description: 'Gewonnene Menge in Gramm' }) + @IsInt() + @IsPositive() + quantity!: number; + + @ApiPropertyOptional({ description: 'Stand, von dem der Rohstoff stammt' }) + @IsOptional() + @IsString() + apiaryId?: string; + + @ApiPropertyOptional({ + description: 'Ernte, bei der er anfiel (z. B. Entdeckelungswachs)', + }) + @IsOptional() + @IsString() + harvestId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(2000) + notes?: string; +} diff --git a/ApiCore-Backend/src/raw-materials/dto/update-raw-material-lot.dto.ts b/ApiCore-Backend/src/raw-materials/dto/update-raw-material-lot.dto.ts new file mode 100644 index 0000000..1472f65 --- /dev/null +++ b/ApiCore-Backend/src/raw-materials/dto/update-raw-material-lot.dto.ts @@ -0,0 +1,54 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsDateString, + IsInt, + IsNotEmpty, + IsOptional, + IsPositive, + IsString, + MaxLength, + ValidateIf, +} from 'class-validator'; + +/** + * Corrects a raw material lot entered with a typo. The kind stays fixed - + * wax that went into candles does not turn into propolis afterwards. + * `apiaryId: null` / `harvestId: null` remove the origin. + */ +export class UpdateRawMaterialLotDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(100) + lotNumber?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + date?: string; + + @ApiPropertyOptional({ description: 'Gewonnene Menge in Gramm' }) + @IsOptional() + @IsInt() + @IsPositive() + quantity?: number; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + @ValidateIf((_object, value) => value !== null) + @IsString() + apiaryId?: string | null; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + @ValidateIf((_object, value) => value !== null) + @IsString() + harvestId?: string | null; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(2000) + notes?: string; +} diff --git a/ApiCore-Backend/src/raw-materials/raw-materials.controller.ts b/ApiCore-Backend/src/raw-materials/raw-materials.controller.ts new file mode 100644 index 0000000..1d6a8a0 --- /dev/null +++ b/ApiCore-Backend/src/raw-materials/raw-materials.controller.ts @@ -0,0 +1,106 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { RequirePermission } from '../auth/decorators/require-permission.decorator'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import type { AuthenticatedUser } from '../auth/types/authenticated-user'; +import { Idempotent } from '../common/idempotency/idempotent.decorator'; +import { StockService } from '../stock/stock.service'; +import { WithdrawStockDto } from '../stock/dto/withdraw-stock.dto'; +import { CorrectStockDto } from '../stock/dto/correct-stock.dto'; +import { RawMaterialKind } from '../../generated/prisma'; +import { CreateRawMaterialLotDto } from './dto/create-raw-material-lot.dto'; +import { UpdateRawMaterialLotDto } from './dto/update-raw-material-lot.dto'; +import { RawMaterialsService } from './raw-materials.service'; + +const KINDS = Object.values(RawMaterialKind) as string[]; + +@ApiTags('raw-materials') +@ApiBearerAuth() +@Controller('raw-material-lots') +export class RawMaterialsController { + constructor( + private readonly lots: RawMaterialsService, + private readonly stock: StockService, + ) {} + + @RequirePermission('raw-material:read') + @Get() + findAll(@Query('kind') kind?: string) { + return this.lots.findAll({ + kind: + kind && KINDS.includes(kind) ? (kind as RawMaterialKind) : undefined, + }); + } + + @RequirePermission('raw-material:read') + @Get(':id') + findOne(@Param('id') id: string) { + return this.lots.findById(id); + } + + @RequirePermission('raw-material:read') + @Get(':id/stock') + async stockOf(@Param('id') id: string) { + await this.lots.findById(id); + return this.stock.stockOf({ kind: 'raw', id }); + } + + @RequirePermission('raw-material:create') + @Idempotent() + @Post() + create( + @Body() dto: CreateRawMaterialLotDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.lots.create(dto, actor.id); + } + + @RequirePermission('raw-material:withdraw') + @Idempotent() + @Post(':id/withdrawals') + async withdraw( + @Param('id') id: string, + @Body() dto: WithdrawStockDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + await this.lots.findById(id); + return this.stock.withdraw({ kind: 'raw', id }, dto, actor.id); + } + + @RequirePermission('raw-material:withdraw') + @Idempotent() + @Post(':id/corrections') + async correct( + @Param('id') id: string, + @Body() dto: CorrectStockDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + await this.lots.findById(id); + return this.stock.correct({ kind: 'raw', id }, dto, actor.id); + } + + @RequirePermission('raw-material:update') + @Patch(':id') + update( + @Param('id') id: string, + @Body() dto: UpdateRawMaterialLotDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.lots.update(id, dto, actor.id); + } + + @RequirePermission('raw-material:delete') + @Delete(':id') + remove(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) { + return this.lots.remove(id, actor.id); + } +} diff --git a/ApiCore-Backend/src/raw-materials/raw-materials.module.ts b/ApiCore-Backend/src/raw-materials/raw-materials.module.ts new file mode 100644 index 0000000..33e2584 --- /dev/null +++ b/ApiCore-Backend/src/raw-materials/raw-materials.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { StockModule } from '../stock/stock.module'; +import { RawMaterialsController } from './raw-materials.controller'; +import { RawMaterialsService } from './raw-materials.service'; + +@Module({ + imports: [StockModule], + controllers: [RawMaterialsController], + providers: [RawMaterialsService], +}) +export class RawMaterialsModule {} diff --git a/ApiCore-Backend/src/raw-materials/raw-materials.service.ts b/ApiCore-Backend/src/raw-materials/raw-materials.service.ts new file mode 100644 index 0000000..436e516 --- /dev/null +++ b/ApiCore-Backend/src/raw-materials/raw-materials.service.ts @@ -0,0 +1,143 @@ +import { + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { StockService } from '../stock/stock.service'; +import { RawMaterialKind } from '../../generated/prisma'; +import { CreateRawMaterialLotDto } from './dto/create-raw-material-lot.dto'; +import { UpdateRawMaterialLotDto } from './dto/update-raw-material-lot.dto'; + +const include = { + apiary: { select: { id: true, name: true } }, + harvest: { select: { id: true, date: true } }, + // Forward traceability: which production batches this lot went into. + usedIn: { + select: { + id: true, + quantity: true, + productionBatch: { + select: { + id: true, + lotNumber: true, + product: { select: { id: true, name: true } }, + }, + }, + }, + orderBy: { createdAt: 'desc' as const }, + }, +} as const; + +export interface RawMaterialLotFilter { + kind?: RawMaterialKind; +} + +/** + * Raw material lots (beeswax, propolis) in grams. Their stock lives in the + * same movement ledger as bottlings and production batches: withdrawals and + * inventory counts by hand, consumption when a production batch is made. + */ +@Injectable() +export class RawMaterialsService { + constructor( + private readonly prisma: PrismaService, + private readonly auditLog: AuditLogService, + private readonly stock: StockService, + ) {} + + async findAll(filter: RawMaterialLotFilter = {}) { + const lots = await this.prisma.rawMaterialLot.findMany({ + where: { kind: filter.kind }, + include, + orderBy: { date: 'desc' }, + }); + const remaining = await this.stock.remainingByRawMaterialLot( + lots.map((lot) => lot.id), + ); + return lots.map((lot) => ({ + ...lot, + remainingCount: remaining.get(lot.id) ?? lot.quantity, + })); + } + + async findById(id: string) { + const lot = await this.prisma.rawMaterialLot.findUnique({ + where: { id }, + include, + }); + if (!lot) throw new NotFoundException('Raw material lot not found'); + const remaining = await this.stock.remainingByRawMaterialLot([id]); + return { ...lot, remainingCount: remaining.get(id) ?? lot.quantity }; + } + + async create(dto: CreateRawMaterialLotDto, actingUserId: string) { + // A duplicate lotNumber surfaces as P2002 and becomes a 409 in + // AllExceptionsFilter; an unknown apiary/harvest as a foreign-key error. + const lot = await this.prisma.rawMaterialLot.create({ + data: { + kind: dto.kind, + lotNumber: dto.lotNumber, + date: new Date(dto.date), + quantity: dto.quantity, + apiaryId: dto.apiaryId, + harvestId: dto.harvestId, + notes: dto.notes, + userId: actingUserId, + }, + }); + await this.auditLog.record({ + entityType: 'RawMaterialLot', + entityId: lot.id, + action: 'CREATE', + userId: actingUserId, + afterData: lot, + }); + return this.findById(lot.id); + } + + async update(id: string, dto: UpdateRawMaterialLotDto, actingUserId: string) { + const before = await this.findById(id); + await this.prisma.rawMaterialLot.update({ + where: { id }, + data: { + lotNumber: dto.lotNumber, + date: dto.date ? new Date(dto.date) : undefined, + quantity: dto.quantity, + apiaryId: dto.apiaryId, + harvestId: dto.harvestId, + notes: dto.notes, + }, + }); + const after = await this.findById(id); + await this.auditLog.record({ + entityType: 'RawMaterialLot', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: after, + }); + return after; + } + + /** Only for lots entered by mistake: once anything was booked, it stays. */ + async remove(id: string, actingUserId: string) { + const before = await this.findById(id); + if (await this.stock.hasMovements({ kind: 'raw', id })) { + throw new ConflictException( + 'Rohstoff-Charge hat bereits Bewegungen (Verbrauch, Entnahme oder Inventur) und kann nicht gelöscht werden', + ); + } + await this.prisma.rawMaterialLot.delete({ where: { id } }); + await this.auditLog.record({ + entityType: 'RawMaterialLot', + entityId: id, + action: 'DELETE', + userId: actingUserId, + beforeData: before, + }); + return { id }; + } +} diff --git a/ApiCore-Backend/src/stock/stock-ledger.spec.ts b/ApiCore-Backend/src/stock/stock-ledger.spec.ts index cd15084..597026c 100644 --- a/ApiCore-Backend/src/stock/stock-ledger.spec.ts +++ b/ApiCore-Backend/src/stock/stock-ledger.spec.ts @@ -35,10 +35,21 @@ describe('summarizeStock', () => { soldInShop: 28, withdrawn: 10, corrected: -1, + consumed: 0, remaining: 81, }); }); + it('counts raw material used in production, net of give-backs', () => { + const summary = summarizeStock(4200, [ + { type: 'CONSUMPTION', quantity: -800 }, + { type: 'CONSUMPTION', quantity: -300 }, + { type: 'CONSUMPTION', quantity: 300 }, + ]); + expect(summary.consumed).toBe(800); + expect(summary.remaining).toBe(3400); + }); + it('equals the initial quantity without movements', () => { expect(summarizeStock(50, []).remaining).toBe(50); }); diff --git a/ApiCore-Backend/src/stock/stock-ledger.ts b/ApiCore-Backend/src/stock/stock-ledger.ts index 8b997d1..f57b325 100644 --- a/ApiCore-Backend/src/stock/stock-ledger.ts +++ b/ApiCore-Backend/src/stock/stock-ledger.ts @@ -8,7 +8,7 @@ */ export type MovementType = - 'SHOP_SALE' | 'SHOP_RETURN' | 'WITHDRAWAL' | 'CORRECTION'; + 'SHOP_SALE' | 'SHOP_RETURN' | 'WITHDRAWAL' | 'CORRECTION' | 'CONSUMPTION'; export interface LedgerEntry { type: MovementType; @@ -25,6 +25,8 @@ export interface StockSummary { withdrawn: number; /** Net inventory corrections (positive = more found than booked). */ corrected: number; + /** Raw material used up in production batches (net of give-backs). */ + consumed: number; remaining: number; } @@ -35,11 +37,14 @@ export function summarizeStock( let soldInShop = 0; let withdrawn = 0; let corrected = 0; + let consumed = 0; for (const entry of entries) { if (entry.type === 'SHOP_SALE' || entry.type === 'SHOP_RETURN') { soldInShop -= entry.quantity; } else if (entry.type === 'WITHDRAWAL') { withdrawn -= entry.quantity; + } else if (entry.type === 'CONSUMPTION') { + consumed -= entry.quantity; } else { corrected += entry.quantity; } @@ -49,7 +54,8 @@ export function summarizeStock( soldInShop, withdrawn, corrected, - remaining: initial - soldInShop - withdrawn + corrected, + consumed, + remaining: initial - soldInShop - withdrawn - consumed + corrected, }; } diff --git a/ApiCore-Backend/src/stock/stock.service.ts b/ApiCore-Backend/src/stock/stock.service.ts index df37dce..645a689 100644 --- a/ApiCore-Backend/src/stock/stock.service.ts +++ b/ApiCore-Backend/src/stock/stock.service.ts @@ -19,9 +19,17 @@ import { type Tx = Prisma.TransactionClient; -/** A batch whose stock is tracked: a honey bottling or a production batch. */ +/** A batch whose stock is tracked: a honey bottling, a production batch or a raw material lot. */ export type StockTarget = - { kind: 'bottling'; id: string } | { kind: 'production'; id: string }; + | { kind: 'bottling'; id: string } + | { kind: 'production'; id: string } + | { kind: 'raw'; id: string }; + +/** Raw material going into a production batch. Quantities in grams. */ +export interface ProductionInputRequest { + rawMaterialLotId: string; + quantity: number; +} /** What changed - the payload of the stock.changed webhook. */ export interface StockChange { @@ -122,6 +130,13 @@ export class StockService { return this.remainingFor('production', ids); } + /** Remaining grams per raw material lot; all when `ids` is omitted. */ + async remainingByRawMaterialLot( + ids?: string[], + ): Promise> { + return this.remainingFor('raw', ids); + } + /** Remaining stock per MANUFACTURED article (sum of its batches). */ async remainingByProduct(ids?: string[]): Promise> { const batches = await this.prisma.productionBatch.findMany({ @@ -234,6 +249,88 @@ export class StockService { return this.stockOf(target); } + // ------------------------------------------------------------------------- + // Production: raw material in, production batch out + // ------------------------------------------------------------------------- + + /** + * Books the raw material a production batch was made from - inside the + * caller's transaction, so the batch and its consumption exist together + * or not at all. Never more than a lot's remaining stock: the person + * producing can weigh again. Lots are locked in id order, so two + * productions drawing on the same lots cannot deadlock. + */ + async consumeForProduction( + tx: Tx, + productionBatchId: string, + lotNumber: string, + inputs: ProductionInputRequest[], + actingUserId: string, + ): Promise { + const perLot = new Map(); + for (const input of inputs) { + perLot.set( + input.rawMaterialLotId, + (perLot.get(input.rawMaterialLotId) ?? 0) + input.quantity, + ); + } + for (const [rawMaterialLotId, quantity] of [...perLot].sort(([a], [b]) => + a.localeCompare(b), + )) { + const target: StockTarget = { kind: 'raw', id: rawMaterialLotId }; + await this.lock(tx, target); + const remaining = await this.remainingInTx(tx, target); + if (quantity > remaining) { + const lot = await tx.rawMaterialLot.findUnique({ + where: { id: rawMaterialLotId }, + select: { lotNumber: true }, + }); + throw new BadRequestException( + `Rohstoff ${lot?.lotNumber ?? rawMaterialLotId}: nur noch ${remaining} g vorhanden, ${quantity} g angegeben`, + ); + } + await tx.productionInput.create({ + data: { productionBatchId, rawMaterialLotId, quantity }, + }); + await tx.batchMovement.create({ + data: { + rawMaterialLotId, + type: 'CONSUMPTION', + quantity: -quantity, + notes: `Herstellung ${lotNumber}`, + userId: actingUserId, + }, + }); + } + } + + /** + * Gives the raw material of a production batch back - only for a batch + * deleted as a typo. The give-back is a movement of its own, so the raw + * lot's ledger still shows that it was booked and returned. + */ + async returnProductionInputs( + tx: Tx, + productionBatchId: string, + lotNumber: string, + actingUserId: string, + ): Promise { + const inputs = await tx.productionInput.findMany({ + where: { productionBatchId }, + }); + for (const input of inputs) { + await tx.batchMovement.create({ + data: { + rawMaterialLotId: input.rawMaterialLotId, + type: 'CONSUMPTION', + quantity: input.quantity, + notes: `Rückgabe: Charge ${lotNumber} gelöscht`, + userId: actingUserId, + }, + }); + } + } + // ------------------------------------------------------------------------- // Shop bookings // ------------------------------------------------------------------------- @@ -517,6 +614,13 @@ export class StockService { /** Tells subscribers (the shop) that stock changed; they fetch the numbers. */ async notify(change: StockChange): Promise { + // Raw material is not sold in the shop - nothing to tell. + if ( + change.bottlingBatchIds.length === 0 && + change.productIds.length === 0 + ) { + return; + } await this.webhooks.dispatch('stock.changed', { bottlingBatchIds: [...new Set(change.bottlingBatchIds)], productIds: [...new Set(change.productIds)], @@ -528,6 +632,9 @@ export class StockService { if (target.kind === 'bottling') { return { bottlingBatchIds: [target.id], productIds: [] }; } + if (target.kind === 'raw') { + return { bottlingBatchIds: [], productIds: [] }; + } const batch = await this.prisma.productionBatch.findUnique({ where: { id: target.id }, select: { productId: true }, @@ -563,12 +670,19 @@ export class StockService { 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 })); + : kind === 'production' + ? ( + await this.prisma.productionBatch.findMany({ + where, + select: { id: true, quantity: true }, + }) + ).map((row) => ({ id: row.id, initial: row.quantity })) + : ( + await this.prisma.rawMaterialLot.findMany({ + where, + select: { id: true, quantity: true }, + }) + ).map((row) => ({ id: row.id, initial: row.quantity })); const sums = await this.sumMovements( this.prisma, kind, @@ -595,6 +709,16 @@ export class StockService { rows.map((r) => [r.bottlingBatchId!, r._sum.quantity ?? 0]), ); } + if (kind === 'raw') { + const rows = await db.batchMovement.groupBy({ + by: ['rawMaterialLotId'], + where: { rawMaterialLotId: { in: ids } }, + _sum: { quantity: true }, + }); + return new Map( + rows.map((r) => [r.rawMaterialLotId!, r._sum.quantity ?? 0]), + ); + } const rows = await db.batchMovement.groupBy({ by: ['productionBatchId'], where: { productionBatchId: { in: ids } }, @@ -623,6 +747,14 @@ export class StockService { if (!row) throw new NotFoundException('Bottling batch not found'); return row.jarCount; } + if (target.kind === 'raw') { + const row = await db.rawMaterialLot.findUnique({ + where: { id: target.id }, + select: { quantity: true }, + }); + if (!row) throw new NotFoundException('Raw material lot not found'); + return row.quantity; + } const row = await db.productionBatch.findUnique({ where: { id: target.id }, select: { quantity: true }, @@ -632,17 +764,18 @@ export class StockService { } private movementWhere(target: StockTarget): Prisma.BatchMovementWhereInput { - return target.kind === 'bottling' - ? { bottlingBatchId: target.id } - : { productionBatchId: target.id }; + return this.movementTarget(target); } private movementTarget( target: StockTarget, - ): { bottlingBatchId: string } | { productionBatchId: string } { - return target.kind === 'bottling' - ? { bottlingBatchId: target.id } - : { productionBatchId: target.id }; + ): + | { bottlingBatchId: string } + | { productionBatchId: string } + | { rawMaterialLotId: string } { + if (target.kind === 'bottling') return { bottlingBatchId: target.id }; + if (target.kind === 'raw') return { rawMaterialLotId: target.id }; + return { productionBatchId: target.id }; } private async lock(tx: Tx, target: StockTarget): Promise { @@ -650,6 +783,15 @@ export class StockService { await tx.$queryRaw`SELECT id FROM "BottlingBatch" WHERE id = ${target.id} FOR UPDATE`; return; } + if (target.kind === 'raw') { + const locked = await tx.$queryRaw< + { id: string }[] + >`SELECT id FROM "RawMaterialLot" WHERE id = ${target.id} FOR UPDATE`; + if (locked.length === 0) { + throw new NotFoundException('Raw material lot not found'); + } + return; + } const batch = await tx.productionBatch.findUnique({ where: { id: target.id }, select: { productId: true }, diff --git a/ApiCore-Backend/test/raw-materials.e2e-spec.ts b/ApiCore-Backend/test/raw-materials.e2e-spec.ts new file mode 100644 index 0000000..2f05d9c --- /dev/null +++ b/ApiCore-Backend/test/raw-materials.e2e-spec.ts @@ -0,0 +1,184 @@ +import { INestApplication } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { createTestApp } from './utils/create-test-app'; +import { login } from './utils/api'; + +interface StockBody { + summary: { + initial: number; + withdrawn: number; + consumed: number; + remaining: number; + }; + movements: { type: string; quantity: number; notes: string | null }[]; +} + +interface LotBody { + id: string; + remainingCount: number; + usedIn: { quantity: number; productionBatch: { id: string } }[]; +} + +interface BatchBody { + id: string; + inputs: { quantity: number; rawMaterialLot: { id: string } }[]; +} + +/** + * Raw material lots (wax, propolis) and their consumption in production: + * creating a production batch books the inputs off the raw lots in the same + * transaction, never more than is left; deleting a mistaken batch gives the + * material back as its own movement. + */ +describe('Raw materials and production (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; + const password = 'SuperSecret123!'; + let token: string; + let waxId: string; + let propolisId: string; + let productId: string; + let batchId: string; + + const server = () => app.getHttpServer(); + const auth = (req: request.Test) => + req.set('Authorization', `Bearer ${token}`); + + async function stockOf(lotId: string): Promise { + const response = await auth( + request(server()).get(`/api/v1/raw-material-lots/${lotId}/stock`), + ).expect(200); + return response.body as StockBody; + } + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + await prisma.user.create({ + data: { + email: `raw-beekeeper-${suffix}@example.com`, + passwordHash: await bcrypt.hash(password, 12), + name: 'Beekeeper', + role: { connect: { key: 'BEEKEEPER' } }, + }, + }); + token = (await login(app, `raw-beekeeper-${suffix}@example.com`, password)) + .accessToken; + + const createLot = async (kind: string, lot: string, grams: number) => { + const response = await auth( + request(server()).post('/api/v1/raw-material-lots'), + ) + .send({ + kind, + lotNumber: `${lot}-${suffix}`, + date: '2026-06-01', + quantity: grams, + }) + .expect(201); + return (response.body as LotBody).id; + }; + waxId = await createLot('BEESWAX', 'W', 1000); + propolisId = await createLot('PROPOLIS', 'P', 200); + + const product = await prisma.product.create({ + data: { + name: `Propolis-Salbe ${suffix}`, + kind: 'MANUFACTURED', + shopCategory: 'PROPOLIS', + }, + }); + productId = product.id; + }); + + afterAll(async () => { + await app.close(); + }); + + it('books the raw material when a production batch is made from it', async () => { + const response = await auth( + request(server()).post('/api/v1/production-batches'), + ) + .send({ + productId, + lotNumber: `S-${suffix}`, + date: '2026-06-10', + quantity: 20, + inputs: [ + { rawMaterialLotId: waxId, quantity: 300 }, + { rawMaterialLotId: propolisId, quantity: 50 }, + ], + }) + .expect(201); + const batch = response.body as BatchBody; + batchId = batch.id; + + expect(batch.inputs).toHaveLength(2); + expect((await stockOf(waxId)).summary).toMatchObject({ + consumed: 300, + remaining: 700, + }); + expect((await stockOf(propolisId)).summary.remaining).toBe(150); + + const wax = await auth( + request(server()).get(`/api/v1/raw-material-lots/${waxId}`), + ).expect(200); + expect( + (wax.body as LotBody).usedIn.map((u) => u.productionBatch.id), + ).toEqual([batchId]); + }); + + it('refuses a production batch that needs more raw material than is left - and creates nothing', async () => { + await auth(request(server()).post('/api/v1/production-batches')) + .send({ + productId, + lotNumber: `S2-${suffix}`, + date: '2026-06-11', + quantity: 10, + inputs: [{ rawMaterialLotId: waxId, quantity: 800 }], + }) + .expect(400); + expect( + await prisma.productionBatch.count({ + where: { lotNumber: `S2-${suffix}` }, + }), + ).toBe(0); + expect((await stockOf(waxId)).summary.remaining).toBe(700); + }); + + it('withdraws raw material like any other stock', async () => { + const response = await auth( + request(server()).post(`/api/v1/raw-material-lots/${waxId}/withdrawals`), + ) + .send({ quantity: 100, reason: 'SONSTIGES', notes: 'Wachstücher' }) + .expect(201); + expect((response.body as StockBody).summary).toMatchObject({ + withdrawn: 100, + consumed: 300, + remaining: 600, + }); + }); + + it('gives the raw material back when a mistaken production batch is deleted', async () => { + await auth( + request(server()).delete(`/api/v1/production-batches/${batchId}`), + ).expect(200); + + const wax = await stockOf(waxId); + expect(wax.summary).toMatchObject({ consumed: 0, remaining: 900 }); + expect(wax.movements.some((m) => m.notes?.startsWith('Rückgabe'))).toBe( + true, + ); + expect((await stockOf(propolisId)).summary.remaining).toBe(200); + }); + + it('keeps a raw lot with movements as history', async () => { + await auth( + request(server()).delete(`/api/v1/raw-material-lots/${waxId}`), + ).expect(409); + }); +});