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[]
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])
}
+2
View File
@@ -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,
@@ -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',
@@ -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[];
}
@@ -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,
@@ -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,
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);
});
+8 -2
View File
@@ -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,
};
}
+157 -15
View File
@@ -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<Map<string, number>> {
return this.remainingFor('raw', ids);
}
/** Remaining stock per MANUFACTURED article (sum of its batches). */
async remainingByProduct(ids?: string[]): Promise<Map<string, number>> {
const batches = await this.prisma.productionBatch.findMany({
@@ -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<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
// -------------------------------------------------------------------------
@@ -517,6 +614,13 @@ export class StockService {
/** Tells subscribers (the shop) that stock changed; they fetch the numbers. */
async notify(change: StockChange): Promise<void> {
// Raw material is not sold in the shop - nothing to tell.
if (
change.bottlingBatchIds.length === 0 &&
change.productIds.length === 0
) {
return;
}
await this.webhooks.dispatch('stock.changed', {
bottlingBatchIds: [...new Set(change.bottlingBatchIds)],
productIds: [...new Set(change.productIds)],
@@ -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<void> {
@@ -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 },
@@ -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);
});
});