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, Prisma } 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; } const INSUFFICIENT_AMOUNT_MESSAGE = 'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge'; type Tx = Prisma.TransactionClient; /** * Deducts `amount` kg from a honey batch in one conditional UPDATE, so the * database - not a value read earlier - decides whether enough is left. * A batch without a recorded amount (null) is not tracked and stays null. */ async function takeFromHoneyBatch( tx: Tx, honeyBatchId: string, amount: number, ): Promise { const current = await tx.honeyBatch.findUnique({ where: { id: honeyBatchId }, select: { amount: true }, }); if (!current) { throw new NotFoundException('Honey batch not found'); } if (current.amount === null) { await tx.honeyBatch.update({ where: { id: honeyBatchId }, data: { status: 'BOTTLED' }, }); return; } // Tolerance of half a gram absorbs float noise from earlier writes. const booked = await tx.honeyBatch.updateMany({ where: { id: honeyBatchId, amount: { gte: amount - 0.0005 } }, data: { amount: { decrement: amount }, status: 'BOTTLED' }, }); if (booked.count === 0) { throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE); } await normalizeAmount(tx, honeyBatchId); } async function giveBackToHoneyBatch( tx: Tx, honeyBatchId: string, amount: number, ): Promise { await tx.honeyBatch.updateMany({ where: { id: honeyBatchId, amount: { not: null } }, data: { amount: { increment: amount } }, }); await normalizeAmount(tx, honeyBatchId); } /** Keeps gram precision and clamps float noise below zero to exactly 0. */ async function normalizeAmount(tx: Tx, honeyBatchId: string): Promise { const row = await tx.honeyBatch.findUnique({ where: { id: honeyBatchId }, select: { amount: true }, }); if (row?.amount == null) return; const normalized = Math.max(0, roundKg(row.amount)); if (normalized !== row.amount) { await tx.honeyBatch.update({ where: { id: honeyBatchId }, data: { amount: normalized }, }); } } @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 = roundKg((dto.jarSize * dto.jarCount) / 1000); if (honeyBatch.amount !== null && totalAmount > honeyBatch.amount) { throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE); } 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) => { // Booked against the stored amount inside the transaction, never // against the value read above: two bottlings submitted at the same // time (double tap, two devices) must not both pass the check and // overwrite each other's deduction. await takeFromHoneyBatch(tx, dto.honeyBatchId, totalAmount); return 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 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'); } // Early, friendly check against the amounts read above. The booking // itself happens atomically inside the transaction below. const availableOnTarget = targetHoneyBatch.amount === null ? null : roundKg( targetHoneyBatch.amount + (movedToOtherHoneyBatch ? 0 : before.totalAmount), ); if (availableOnTarget !== null && totalAmount > availableOnTarget) { throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE); } // 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) => { const amountChanged = movedToOtherHoneyBatch || totalAmount !== before.totalAmount; if (amountChanged) { // Give the previous amount back first, then take the corrected one - // a correction never double-charges the source batch. await giveBackToHoneyBatch(tx, before.honeyBatchId, before.totalAmount); await takeFromHoneyBatch(tx, honeyBatchId, totalAmount); } if (movedToOtherHoneyBatch) { await tx.honeyBatch.update({ where: { id: honeyBatchId }, data: { status: 'BOTTLED' }, }); } 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, }, }); }); 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 } }); await giveBackToHoneyBatch(tx, before.honeyBatchId, 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 }; } }