feat(raw-materials): wax and propolis lots consumed by production batches

- RawMaterialLot (beeswax, propolis) in grams with origin (apiary,
  harvest); stock in the same movement ledger: withdrawals, inventory
  counts, and the new movement type CONSUMPTION.
- Production batches take inputs (raw lot + grams); batch and consumption
  are one transaction, never more than a lot has left. Deleting a
  mistaken batch gives the material back as its own movement.
- Traceability both ways: batch -> inputs, raw lot -> usedIn.
- Movement belongs to exactly one of bottling / production / raw lot
  (CHECK); raw material changes do not trigger the shop webhook.
- Permissions raw-material:*, granted to roles that maintain production
  batches.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-05 23:54:43 +02:00
1 parent 41c2af7284
commit e73c8b2cb4
15 files changed
+986 -32

No files matched your search

@@ -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;
+64
View File
@@ -122,6 +122,7 @@ model User {
bottlingBatches BottlingBatch[] bottlingBatches BottlingBatch[]
productionBatches ProductionBatch[] productionBatches ProductionBatch[]
batchMovements BatchMovement[] batchMovements BatchMovement[]
rawMaterialLots RawMaterialLot[]
notifications Notification[] notifications Notification[]
queenEvaluations QueenEvaluation[] queenEvaluations QueenEvaluation[]
webhookSubscriptions WebhookSubscription[] webhookSubscriptions WebhookSubscription[]
@@ -239,6 +240,7 @@ model Apiary {
colonyMoves ColonyApiaryHistory[] colonyMoves ColonyApiaryHistory[]
harvests Harvest[] harvests Harvest[]
treatments Treatment[] treatments Treatment[]
rawMaterialLots RawMaterialLot[]
@@index([status]) @@index([status])
} }
@@ -761,6 +763,7 @@ model Harvest {
colonies HarvestColony[] colonies HarvestColony[]
extractions ExtractionHarvest[] extractions ExtractionHarvest[]
rawMaterialLots RawMaterialLot[]
@@index([apiaryId, date]) @@index([apiaryId, date])
} }
@@ -1097,15 +1100,73 @@ model ProductionBatch {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
movements BatchMovement[] movements BatchMovement[]
// Woraus die Charge hergestellt wurde (Rueckverfolgung Rohstoff -> Ware).
inputs ProductionInput[]
@@index([productId]) @@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 { enum BatchMovementType {
SHOP_SALE SHOP_SALE
SHOP_RETURN SHOP_RETURN
WITHDRAWAL WITHDRAWAL
CORRECTION CORRECTION
// Rohstoff in einer Herstellungscharge verbraucht (minus) bzw. beim
// Loeschen einer irrtuemlich angelegten Charge zurueckgegeben (plus).
CONSUMPTION
} }
enum WithdrawalReason { enum WithdrawalReason {
@@ -1126,6 +1187,8 @@ model BatchMovement {
bottlingBatch BottlingBatch? @relation(fields: [bottlingBatchId], references: [id], onDelete: Restrict) bottlingBatch BottlingBatch? @relation(fields: [bottlingBatchId], references: [id], onDelete: Restrict)
productionBatchId String? productionBatchId String?
productionBatch ProductionBatch? @relation(fields: [productionBatchId], references: [id], onDelete: Restrict) productionBatch ProductionBatch? @relation(fields: [productionBatchId], references: [id], onDelete: Restrict)
rawMaterialLotId String?
rawMaterialLot RawMaterialLot? @relation(fields: [rawMaterialLotId], references: [id], onDelete: Restrict)
type BatchMovementType type BatchMovementType
// Mit Vorzeichen: minus = raus, plus = rein. // Mit Vorzeichen: minus = raus, plus = rein.
quantity Int quantity Int
@@ -1140,6 +1203,7 @@ model BatchMovement {
@@index([bottlingBatchId]) @@index([bottlingBatchId])
@@index([productionBatchId]) @@index([productionBatchId])
@@index([rawMaterialLotId])
@@index([shopRequestId]) @@index([shopRequestId])
} }
+2
View File
@@ -28,6 +28,7 @@ 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 { ProductionBatchesModule } from './production-batches/production-batches.module';
import { RawMaterialsModule } from './raw-materials/raw-materials.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';
@@ -85,6 +86,7 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
ProductsModule, ProductsModule,
BottlingModule, BottlingModule,
ProductionBatchesModule, ProductionBatchesModule,
RawMaterialsModule,
EquipmentModule, EquipmentModule,
CalendarModule, CalendarModule,
CalendarSyncModule, CalendarSyncModule,
@@ -208,6 +208,18 @@ export const PERMISSION_CATALOG = [
], ],
1600, 1600,
), ),
...group(
'Rohstoffe',
'raw-material',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['delete', 'Löschen'],
['withdraw', 'Entnehmen und Inventur'],
],
1630,
),
...group( ...group(
'Herstellungschargen', 'Herstellungschargen',
'production-batch', 'production-batch',
@@ -1,5 +1,9 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { import {
ArrayMaxSize,
IsArray,
ValidateNested,
IsDateString, IsDateString,
IsInt, IsInt,
IsNotEmpty, IsNotEmpty,
@@ -9,6 +13,18 @@ import {
MaxLength, MaxLength,
} from 'class-validator'; } 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 { export class CreateProductionBatchDto {
@ApiProperty({ description: 'Artikel der Art MANUFACTURED' }) @ApiProperty({ description: 'Artikel der Art MANUFACTURED' })
@IsString() @IsString()
@@ -39,4 +55,16 @@ export class CreateProductionBatchDto {
@IsString() @IsString()
@MaxLength(2000) @MaxLength(2000)
notes?: string; 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[];
} }
@@ -10,7 +10,17 @@ import { StockService } from '../stock/stock.service';
import { CreateProductionBatchDto } from './dto/create-production-batch.dto'; import { CreateProductionBatchDto } from './dto/create-production-batch.dto';
import { UpdateProductionBatchDto } from './dto/update-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 { export interface ProductionBatchFilter {
productId?: string; productId?: string;
@@ -66,19 +76,33 @@ export class ProductionBatchesService {
); );
} }
// A duplicate lotNumber surfaces as P2002 and becomes a 409 with a // A duplicate lotNumber surfaces as P2002 and becomes a 409 with a
// field-specific message in AllExceptionsFilter. // field-specific message in AllExceptionsFilter. Batch and raw material
const batch = await this.prisma.productionBatch.create({ // consumption are one transaction: a batch never exists without its
data: { // inputs being booked, and not enough raw material creates no batch.
productId: dto.productId, const batch = await this.prisma.$transaction(async (tx) => {
lotNumber: dto.lotNumber, const created = await tx.productionBatch.create({
date: new Date(dto.date), data: {
quantity: dto.quantity, productId: dto.productId,
bestBeforeDate: dto.bestBeforeDate lotNumber: dto.lotNumber,
? new Date(dto.bestBeforeDate) date: new Date(dto.date),
: undefined, quantity: dto.quantity,
notes: dto.notes, bestBeforeDate: dto.bestBeforeDate
userId: actingUserId, ? 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({ await this.auditLog.record({
entityType: 'ProductionBatch', entityType: 'ProductionBatch',
@@ -143,7 +167,17 @@ export class ProductionBatchesService {
'Charge hat bereits Bestandsbewegungen und kann nicht gelöscht werden - Bestand über "Inventur korrigieren" anpassen', '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({ await this.auditLog.record({
entityType: 'ProductionBatch', entityType: 'ProductionBatch',
entityId: id, entityId: id,
@@ -0,0 +1,51 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsEnum,
IsInt,
IsNotEmpty,
IsOptional,
IsPositive,
IsString,
MaxLength,
} from 'class-validator';
import { RawMaterialKind } from '../../../generated/prisma';
export class CreateRawMaterialLotDto {
@ApiProperty({ enum: RawMaterialKind })
@IsEnum(RawMaterialKind)
kind!: RawMaterialKind;
@ApiProperty()
@IsString()
@IsNotEmpty()
@MaxLength(100)
lotNumber!: string;
@ApiProperty({ description: 'Gewinnungsdatum' })
@IsDateString()
date!: string;
@ApiProperty({ description: 'Gewonnene Menge in Gramm' })
@IsInt()
@IsPositive()
quantity!: number;
@ApiPropertyOptional({ description: 'Stand, von dem der Rohstoff stammt' })
@IsOptional()
@IsString()
apiaryId?: string;
@ApiPropertyOptional({
description: 'Ernte, bei der er anfiel (z. B. Entdeckelungswachs)',
})
@IsOptional()
@IsString()
harvestId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
}
@@ -0,0 +1,54 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsInt,
IsNotEmpty,
IsOptional,
IsPositive,
IsString,
MaxLength,
ValidateIf,
} from 'class-validator';
/**
* Corrects a raw material lot entered with a typo. The kind stays fixed -
* wax that went into candles does not turn into propolis afterwards.
* `apiaryId: null` / `harvestId: null` remove the origin.
*/
export class UpdateRawMaterialLotDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(100)
lotNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
date?: string;
@ApiPropertyOptional({ description: 'Gewonnene Menge in Gramm' })
@IsOptional()
@IsInt()
@IsPositive()
quantity?: number;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@ValidateIf((_object, value) => value !== null)
@IsString()
apiaryId?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@ValidateIf((_object, value) => value !== null)
@IsString()
harvestId?: string | null;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
}
@@ -0,0 +1,106 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
import { StockService } from '../stock/stock.service';
import { WithdrawStockDto } from '../stock/dto/withdraw-stock.dto';
import { CorrectStockDto } from '../stock/dto/correct-stock.dto';
import { RawMaterialKind } from '../../generated/prisma';
import { CreateRawMaterialLotDto } from './dto/create-raw-material-lot.dto';
import { UpdateRawMaterialLotDto } from './dto/update-raw-material-lot.dto';
import { RawMaterialsService } from './raw-materials.service';
const KINDS = Object.values(RawMaterialKind) as string[];
@ApiTags('raw-materials')
@ApiBearerAuth()
@Controller('raw-material-lots')
export class RawMaterialsController {
constructor(
private readonly lots: RawMaterialsService,
private readonly stock: StockService,
) {}
@RequirePermission('raw-material:read')
@Get()
findAll(@Query('kind') kind?: string) {
return this.lots.findAll({
kind:
kind && KINDS.includes(kind) ? (kind as RawMaterialKind) : undefined,
});
}
@RequirePermission('raw-material:read')
@Get(':id')
findOne(@Param('id') id: string) {
return this.lots.findById(id);
}
@RequirePermission('raw-material:read')
@Get(':id/stock')
async stockOf(@Param('id') id: string) {
await this.lots.findById(id);
return this.stock.stockOf({ kind: 'raw', id });
}
@RequirePermission('raw-material:create')
@Idempotent()
@Post()
create(
@Body() dto: CreateRawMaterialLotDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.lots.create(dto, actor.id);
}
@RequirePermission('raw-material:withdraw')
@Idempotent()
@Post(':id/withdrawals')
async withdraw(
@Param('id') id: string,
@Body() dto: WithdrawStockDto,
@CurrentUser() actor: AuthenticatedUser,
) {
await this.lots.findById(id);
return this.stock.withdraw({ kind: 'raw', id }, dto, actor.id);
}
@RequirePermission('raw-material:withdraw')
@Idempotent()
@Post(':id/corrections')
async correct(
@Param('id') id: string,
@Body() dto: CorrectStockDto,
@CurrentUser() actor: AuthenticatedUser,
) {
await this.lots.findById(id);
return this.stock.correct({ kind: 'raw', id }, dto, actor.id);
}
@RequirePermission('raw-material:update')
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateRawMaterialLotDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.lots.update(id, dto, actor.id);
}
@RequirePermission('raw-material:delete')
@Delete(':id')
remove(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.lots.remove(id, actor.id);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { StockModule } from '../stock/stock.module';
import { RawMaterialsController } from './raw-materials.controller';
import { RawMaterialsService } from './raw-materials.service';
@Module({
imports: [StockModule],
controllers: [RawMaterialsController],
providers: [RawMaterialsService],
})
export class RawMaterialsModule {}
@@ -0,0 +1,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 };
}
}
@@ -35,10 +35,21 @@ describe('summarizeStock', () => {
soldInShop: 28, soldInShop: 28,
withdrawn: 10, withdrawn: 10,
corrected: -1, corrected: -1,
consumed: 0,
remaining: 81, 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', () => { it('equals the initial quantity without movements', () => {
expect(summarizeStock(50, []).remaining).toBe(50); expect(summarizeStock(50, []).remaining).toBe(50);
}); });
+8 -2
View File
@@ -8,7 +8,7 @@
*/ */
export type MovementType = export type MovementType =
'SHOP_SALE' | 'SHOP_RETURN' | 'WITHDRAWAL' | 'CORRECTION'; 'SHOP_SALE' | 'SHOP_RETURN' | 'WITHDRAWAL' | 'CORRECTION' | 'CONSUMPTION';
export interface LedgerEntry { export interface LedgerEntry {
type: MovementType; type: MovementType;
@@ -25,6 +25,8 @@ export interface StockSummary {
withdrawn: number; withdrawn: number;
/** Net inventory corrections (positive = more found than booked). */ /** Net inventory corrections (positive = more found than booked). */
corrected: number; corrected: number;
/** Raw material used up in production batches (net of give-backs). */
consumed: number;
remaining: number; remaining: number;
} }
@@ -35,11 +37,14 @@ export function summarizeStock(
let soldInShop = 0; let soldInShop = 0;
let withdrawn = 0; let withdrawn = 0;
let corrected = 0; let corrected = 0;
let consumed = 0;
for (const entry of entries) { for (const entry of entries) {
if (entry.type === 'SHOP_SALE' || entry.type === 'SHOP_RETURN') { if (entry.type === 'SHOP_SALE' || entry.type === 'SHOP_RETURN') {
soldInShop -= entry.quantity; soldInShop -= entry.quantity;
} else if (entry.type === 'WITHDRAWAL') { } else if (entry.type === 'WITHDRAWAL') {
withdrawn -= entry.quantity; withdrawn -= entry.quantity;
} else if (entry.type === 'CONSUMPTION') {
consumed -= entry.quantity;
} else { } else {
corrected += entry.quantity; corrected += entry.quantity;
} }
@@ -49,7 +54,8 @@ export function summarizeStock(
soldInShop, soldInShop,
withdrawn, withdrawn,
corrected, corrected,
remaining: initial - soldInShop - withdrawn + corrected, consumed,
remaining: initial - soldInShop - withdrawn - consumed + corrected,
}; };
} }
+157 -15
View File
@@ -19,9 +19,17 @@ import {
type Tx = Prisma.TransactionClient; 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 = 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. */ /** What changed - the payload of the stock.changed webhook. */
export interface StockChange { export interface StockChange {
@@ -122,6 +130,13 @@ export class StockService {
return this.remainingFor('production', ids); return this.remainingFor('production', ids);
} }
/** Remaining grams per raw material lot; all when `ids` is omitted. */
async remainingByRawMaterialLot(
ids?: string[],
): Promise<Map<string, number>> {
return this.remainingFor('raw', ids);
}
/** Remaining stock per MANUFACTURED article (sum of its batches). */ /** Remaining stock per MANUFACTURED article (sum of its batches). */
async remainingByProduct(ids?: string[]): Promise<Map<string, number>> { async remainingByProduct(ids?: string[]): Promise<Map<string, number>> {
const batches = await this.prisma.productionBatch.findMany({ const batches = await this.prisma.productionBatch.findMany({
@@ -234,6 +249,88 @@ export class StockService {
return this.stockOf(target); return this.stockOf(target);
} }
// -------------------------------------------------------------------------
// Production: raw material in, production batch out
// -------------------------------------------------------------------------
/**
* Books the raw material a production batch was made from - inside the
* caller's transaction, so the batch and its consumption exist together
* or not at all. Never more than a lot's remaining stock: the person
* producing can weigh again. Lots are locked in id order, so two
* productions drawing on the same lots cannot deadlock.
*/
async consumeForProduction(
tx: Tx,
productionBatchId: string,
lotNumber: string,
inputs: ProductionInputRequest[],
actingUserId: string,
): Promise<void> {
const perLot = new Map<string, number>();
for (const input of inputs) {
perLot.set(
input.rawMaterialLotId,
(perLot.get(input.rawMaterialLotId) ?? 0) + input.quantity,
);
}
for (const [rawMaterialLotId, quantity] of [...perLot].sort(([a], [b]) =>
a.localeCompare(b),
)) {
const target: StockTarget = { kind: 'raw', id: rawMaterialLotId };
await this.lock(tx, target);
const remaining = await this.remainingInTx(tx, target);
if (quantity > remaining) {
const lot = await tx.rawMaterialLot.findUnique({
where: { id: rawMaterialLotId },
select: { lotNumber: true },
});
throw new BadRequestException(
`Rohstoff ${lot?.lotNumber ?? rawMaterialLotId}: nur noch ${remaining} g vorhanden, ${quantity} g angegeben`,
);
}
await tx.productionInput.create({
data: { productionBatchId, rawMaterialLotId, quantity },
});
await tx.batchMovement.create({
data: {
rawMaterialLotId,
type: 'CONSUMPTION',
quantity: -quantity,
notes: `Herstellung ${lotNumber}`,
userId: actingUserId,
},
});
}
}
/**
* Gives the raw material of a production batch back - only for a batch
* deleted as a typo. The give-back is a movement of its own, so the raw
* lot's ledger still shows that it was booked and returned.
*/
async returnProductionInputs(
tx: Tx,
productionBatchId: string,
lotNumber: string,
actingUserId: string,
): Promise<void> {
const inputs = await tx.productionInput.findMany({
where: { productionBatchId },
});
for (const input of inputs) {
await tx.batchMovement.create({
data: {
rawMaterialLotId: input.rawMaterialLotId,
type: 'CONSUMPTION',
quantity: input.quantity,
notes: `Rückgabe: Charge ${lotNumber} gelöscht`,
userId: actingUserId,
},
});
}
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Shop bookings // Shop bookings
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -517,6 +614,13 @@ export class StockService {
/** Tells subscribers (the shop) that stock changed; they fetch the numbers. */ /** Tells subscribers (the shop) that stock changed; they fetch the numbers. */
async notify(change: StockChange): Promise<void> { async notify(change: StockChange): Promise<void> {
// Raw material is not sold in the shop - nothing to tell.
if (
change.bottlingBatchIds.length === 0 &&
change.productIds.length === 0
) {
return;
}
await this.webhooks.dispatch('stock.changed', { await this.webhooks.dispatch('stock.changed', {
bottlingBatchIds: [...new Set(change.bottlingBatchIds)], bottlingBatchIds: [...new Set(change.bottlingBatchIds)],
productIds: [...new Set(change.productIds)], productIds: [...new Set(change.productIds)],
@@ -528,6 +632,9 @@ export class StockService {
if (target.kind === 'bottling') { if (target.kind === 'bottling') {
return { bottlingBatchIds: [target.id], productIds: [] }; return { bottlingBatchIds: [target.id], productIds: [] };
} }
if (target.kind === 'raw') {
return { bottlingBatchIds: [], productIds: [] };
}
const batch = await this.prisma.productionBatch.findUnique({ const batch = await this.prisma.productionBatch.findUnique({
where: { id: target.id }, where: { id: target.id },
select: { productId: true }, select: { productId: true },
@@ -563,12 +670,19 @@ export class StockService {
select: { id: true, jarCount: true }, select: { id: true, jarCount: true },
}) })
).map((row) => ({ id: row.id, initial: row.jarCount })) ).map((row) => ({ id: row.id, initial: row.jarCount }))
: ( : kind === 'production'
await this.prisma.productionBatch.findMany({ ? (
where, await this.prisma.productionBatch.findMany({
select: { id: true, quantity: true }, where,
}) select: { id: true, quantity: true },
).map((row) => ({ id: row.id, initial: row.quantity })); })
).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( const sums = await this.sumMovements(
this.prisma, this.prisma,
kind, kind,
@@ -595,6 +709,16 @@ export class StockService {
rows.map((r) => [r.bottlingBatchId!, r._sum.quantity ?? 0]), 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({ const rows = await db.batchMovement.groupBy({
by: ['productionBatchId'], by: ['productionBatchId'],
where: { productionBatchId: { in: ids } }, where: { productionBatchId: { in: ids } },
@@ -623,6 +747,14 @@ export class StockService {
if (!row) throw new NotFoundException('Bottling batch not found'); if (!row) throw new NotFoundException('Bottling batch not found');
return row.jarCount; 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({ const row = await db.productionBatch.findUnique({
where: { id: target.id }, where: { id: target.id },
select: { quantity: true }, select: { quantity: true },
@@ -632,17 +764,18 @@ export class StockService {
} }
private movementWhere(target: StockTarget): Prisma.BatchMovementWhereInput { private movementWhere(target: StockTarget): Prisma.BatchMovementWhereInput {
return target.kind === 'bottling' return this.movementTarget(target);
? { bottlingBatchId: target.id }
: { productionBatchId: target.id };
} }
private movementTarget( private movementTarget(
target: StockTarget, target: StockTarget,
): { bottlingBatchId: string } | { productionBatchId: string } { ):
return target.kind === 'bottling' | { bottlingBatchId: string }
? { bottlingBatchId: target.id } | { productionBatchId: string }
: { productionBatchId: target.id }; | { rawMaterialLotId: string } {
if (target.kind === 'bottling') return { bottlingBatchId: target.id };
if (target.kind === 'raw') return { rawMaterialLotId: target.id };
return { productionBatchId: target.id };
} }
private async lock(tx: Tx, target: StockTarget): Promise<void> { private async lock(tx: Tx, target: StockTarget): Promise<void> {
@@ -650,6 +783,15 @@ export class StockService {
await tx.$queryRaw`SELECT id FROM "BottlingBatch" WHERE id = ${target.id} FOR UPDATE`; await tx.$queryRaw`SELECT id FROM "BottlingBatch" WHERE id = ${target.id} FOR UPDATE`;
return; 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({ const batch = await tx.productionBatch.findUnique({
where: { id: target.id }, where: { id: target.id },
select: { productId: true }, select: { productId: true },
@@ -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<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
let token: string;
let waxId: string;
let propolisId: string;
let productId: string;
let batchId: string;
const server = () => app.getHttpServer();
const auth = (req: request.Test) =>
req.set('Authorization', `Bearer ${token}`);
async function stockOf(lotId: string): Promise<StockBody> {
const response = await auth(
request(server()).get(`/api/v1/raw-material-lots/${lotId}/stock`),
).expect(200);
return response.body as StockBody;
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
await prisma.user.create({
data: {
email: `raw-beekeeper-${suffix}@example.com`,
passwordHash: await bcrypt.hash(password, 12),
name: 'Beekeeper',
role: { connect: { key: 'BEEKEEPER' } },
},
});
token = (await login(app, `raw-beekeeper-${suffix}@example.com`, password))
.accessToken;
const createLot = async (kind: string, lot: string, grams: number) => {
const response = await auth(
request(server()).post('/api/v1/raw-material-lots'),
)
.send({
kind,
lotNumber: `${lot}-${suffix}`,
date: '2026-06-01',
quantity: grams,
})
.expect(201);
return (response.body as LotBody).id;
};
waxId = await createLot('BEESWAX', 'W', 1000);
propolisId = await createLot('PROPOLIS', 'P', 200);
const product = await prisma.product.create({
data: {
name: `Propolis-Salbe ${suffix}`,
kind: 'MANUFACTURED',
shopCategory: 'PROPOLIS',
},
});
productId = product.id;
});
afterAll(async () => {
await app.close();
});
it('books the raw material when a production batch is made from it', async () => {
const response = await auth(
request(server()).post('/api/v1/production-batches'),
)
.send({
productId,
lotNumber: `S-${suffix}`,
date: '2026-06-10',
quantity: 20,
inputs: [
{ rawMaterialLotId: waxId, quantity: 300 },
{ rawMaterialLotId: propolisId, quantity: 50 },
],
})
.expect(201);
const batch = response.body as BatchBody;
batchId = batch.id;
expect(batch.inputs).toHaveLength(2);
expect((await stockOf(waxId)).summary).toMatchObject({
consumed: 300,
remaining: 700,
});
expect((await stockOf(propolisId)).summary.remaining).toBe(150);
const wax = await auth(
request(server()).get(`/api/v1/raw-material-lots/${waxId}`),
).expect(200);
expect(
(wax.body as LotBody).usedIn.map((u) => u.productionBatch.id),
).toEqual([batchId]);
});
it('refuses a production batch that needs more raw material than is left - and creates nothing', async () => {
await auth(request(server()).post('/api/v1/production-batches'))
.send({
productId,
lotNumber: `S2-${suffix}`,
date: '2026-06-11',
quantity: 10,
inputs: [{ rawMaterialLotId: waxId, quantity: 800 }],
})
.expect(400);
expect(
await prisma.productionBatch.count({
where: { lotNumber: `S2-${suffix}` },
}),
).toBe(0);
expect((await stockOf(waxId)).summary.remaining).toBe(700);
});
it('withdraws raw material like any other stock', async () => {
const response = await auth(
request(server()).post(`/api/v1/raw-material-lots/${waxId}/withdrawals`),
)
.send({ quantity: 100, reason: 'SONSTIGES', notes: 'Wachstücher' })
.expect(201);
expect((response.body as StockBody).summary).toMatchObject({
withdrawn: 100,
consumed: 300,
remaining: 600,
});
});
it('gives the raw material back when a mistaken production batch is deleted', async () => {
await auth(
request(server()).delete(`/api/v1/production-batches/${batchId}`),
).expect(200);
const wax = await stockOf(waxId);
expect(wax.summary).toMatchObject({ consumed: 0, remaining: 900 });
expect(wax.movements.some((m) => m.notes?.startsWith('Rückgabe'))).toBe(
true,
);
expect((await stockOf(propolisId)).summary.remaining).toBe(200);
});
it('keeps a raw lot with movements as history', async () => {
await auth(
request(server()).delete(`/api/v1/raw-material-lots/${waxId}`),
).expect(409);
});
});