import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; import { AutomationService } from '../automation/automation.service'; import { BottlingBatch } from '../../generated/prisma'; import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto'; import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto'; const bottlingBatchInclude = { honeyBatch: true, product: true, } as const; export interface BottlingBatchFilter { honeyBatchId?: string; } /** * Amounts are kept in kilograms with gram precision. Rounding on every write * keeps a honey batch's remaining amount from drifting when a bottling batch * is corrected or removed and its amount is given back. */ function roundKg(amount: number): number { return Math.round(amount * 1000) / 1000; } @Injectable() export class BottlingService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, private readonly webhooks: WebhooksService, private readonly automation: AutomationService, ) {} async findAll(filter: BottlingBatchFilter = {}) { return this.prisma.bottlingBatch.findMany({ where: { honeyBatchId: filter.honeyBatchId }, include: bottlingBatchInclude, orderBy: { date: 'desc' }, }); } async findById(id: string) { const bottlingBatch = await this.prisma.bottlingBatch.findUnique({ where: { id }, include: bottlingBatchInclude, }); if (!bottlingBatch) { throw new NotFoundException('Bottling batch not found'); } return bottlingBatch; } async create(dto: CreateBottlingBatchDto, actingUserId: string) { const honeyBatch = await this.prisma.honeyBatch.findUnique({ where: { id: dto.honeyBatchId }, }); if (!honeyBatch) { throw new NotFoundException('Honey batch not found'); } if (dto.productId) { const product = await this.prisma.product.findUnique({ where: { id: dto.productId }, }); if (!product) { throw new NotFoundException('Product not found'); } } const totalAmount = (dto.jarSize * dto.jarCount) / 1000; if (honeyBatch.amount !== null && totalAmount > honeyBatch.amount) { throw new BadRequestException( 'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge', ); } const lotNumber = dto.lotNumber ?? (await this.automation.generateNumber('BOTTLING_LOT_NUMBER_FORMAT')); if (!lotNumber) { throw new BadRequestException( 'Los-/Chargennummer erforderlich (oder Automatisierung "Los-/Chargennummer-Format" aktivieren)', ); } const bottlingBatch: BottlingBatch = await this.prisma.$transaction( async (tx) => { const created = await tx.bottlingBatch.create({ data: { honeyBatchId: dto.honeyBatchId, productId: dto.productId, date: new Date(dto.date), jarSize: dto.jarSize, jarCount: dto.jarCount, totalAmount, bestBeforeDate: new Date(dto.bestBeforeDate), lotNumber, userId: actingUserId, }, }); await tx.honeyBatch.update({ where: { id: dto.honeyBatchId }, data: { amount: honeyBatch.amount !== null ? honeyBatch.amount - totalAmount : null, status: 'BOTTLED', }, }); return created; }, ); await this.auditLog.record({ entityType: 'BottlingBatch', entityId: bottlingBatch.id, action: 'CREATE', userId: actingUserId, afterData: bottlingBatch, }); const created = await this.findById(bottlingBatch.id); await this.webhooks.dispatch('batch.updated', created.honeyBatch); return created; } /** * Corrects any field of an existing bottling batch. Changing the honey * batch, the jar size or the jar count also rebalances the remaining amount * of the affected honey batch(es): the previously bottled amount is given * back before the corrected amount is taken again, so a correction never * double-charges the source batch. */ async update(id: string, dto: UpdateBottlingBatchDto, actingUserId: string) { const before = await this.findById(id); if (dto.productId) { const product = await this.prisma.product.findUnique({ where: { id: dto.productId }, }); if (!product) { throw new NotFoundException('Product not found'); } } const honeyBatchId = dto.honeyBatchId ?? before.honeyBatchId; const movedToOtherHoneyBatch = honeyBatchId !== before.honeyBatchId; const jarSize = dto.jarSize ?? before.jarSize; const jarCount = dto.jarCount ?? before.jarCount; const totalAmount = roundKg((jarSize * jarCount) / 1000); const targetHoneyBatch = movedToOtherHoneyBatch ? await this.prisma.honeyBatch.findUnique({ where: { id: honeyBatchId } }) : before.honeyBatch; if (!targetHoneyBatch) { throw new NotFoundException('Honey batch not found'); } // Capacity on the target batch, with this bottling batch's current amount // released first when it already sits on that same batch. const availableOnTarget = targetHoneyBatch.amount === null ? null : roundKg( targetHoneyBatch.amount + (movedToOtherHoneyBatch ? 0 : before.totalAmount), ); if (availableOnTarget !== null && totalAmount > availableOnTarget) { throw new BadRequestException( 'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge', ); } // A duplicate lotNumber surfaces as Prisma P2002 and is turned into a // 409 with a field-specific message by AllExceptionsFilter. await this.prisma.$transaction(async (tx) => { await tx.bottlingBatch.update({ where: { id }, data: { honeyBatchId, productId: dto.productId === undefined ? undefined : dto.productId, date: dto.date ? new Date(dto.date) : undefined, jarSize, jarCount, totalAmount, bestBeforeDate: dto.bestBeforeDate ? new Date(dto.bestBeforeDate) : undefined, lotNumber: dto.lotNumber, }, }); if (movedToOtherHoneyBatch) { await tx.honeyBatch.update({ where: { id: before.honeyBatchId }, data: { amount: before.honeyBatch.amount === null ? null : roundKg(before.honeyBatch.amount + before.totalAmount), }, }); await tx.honeyBatch.update({ where: { id: honeyBatchId }, data: { amount: availableOnTarget === null ? null : roundKg(availableOnTarget - totalAmount), status: 'BOTTLED', }, }); } else if (totalAmount !== before.totalAmount) { await tx.honeyBatch.update({ where: { id: honeyBatchId }, data: { amount: availableOnTarget === null ? null : roundKg(availableOnTarget - totalAmount), }, }); } }); const bottlingBatch = await this.findById(id); await this.auditLog.record({ entityType: 'BottlingBatch', entityId: id, action: 'UPDATE', userId: actingUserId, beforeData: before, afterData: bottlingBatch, }); await this.webhooks.dispatch('batch.updated', bottlingBatch.honeyBatch); return bottlingBatch; } /** * Removes a bottling batch that was entered by mistake and gives its amount * back to the honey batch. The full record is kept in the audit log * (action DELETE, beforeData), so the history survives the row. * * The honey batch's status is deliberately left untouched: the status it * held before being bottled is not recoverable here, and guessing it would * write a fact that never happened. */ async remove(id: string, actingUserId: string) { const before = await this.findById(id); await this.prisma.$transaction(async (tx) => { await tx.bottlingBatch.delete({ where: { id } }); if (before.honeyBatch.amount !== null) { await tx.honeyBatch.update({ where: { id: before.honeyBatchId }, data: { amount: roundKg(before.honeyBatch.amount + before.totalAmount), }, }); } }); await this.auditLog.record({ entityType: 'BottlingBatch', entityId: id, action: 'DELETE', userId: actingUserId, beforeData: before, }); await this.webhooks.dispatch('batch.updated', before.honeyBatch); return { id }; } }