import { BadRequestException, ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import { Prisma, type MedicineUnit } from '../../generated/prisma'; import { AuditLogService } from '../audit/audit-log.service'; import { dayToDate } from '../common/dates'; import { roundToThousandths } from '../common/quantity'; import { DbClient } from '../prisma/db-client'; import { PrismaService } from '../prisma/prisma.service'; import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock'; import { fifoOrder } from '../stock/stock-ledger'; import { CreateLotDto } from './dto/create-lot.dto'; import { CreateMovementDto } from './dto/create-movement.dto'; import { UpdateLotDto } from './dto/update-lot.dto'; import { summarizeLot, type LotDetailView, type LotView, } from './medicine-ledger'; import { LOT_MEDICINE_INCLUDE, lotTotals, remainingOf, toLotView, type LotRow, } from './medicine-views'; const AUDIT_ENTITY = 'VeterinaryMedicineLot'; const MEDICINE_NOT_FOUND = 'Tierarzneimittel nicht gefunden'; const LOT_NOT_FOUND = 'Packung nicht gefunden'; export interface LotListFilter { medicineId?: string; selectable?: boolean; /** With `selectable`: this lot stays in the list even when empty or inactive. */ includeLotId?: string; } /** The lot row as audited: without the medicine the view joins in. */ function auditRow(lot: LotRow, remaining?: number): object { const { id, medicineId, batchNumber, purchasedOn, quantity, expiresOn } = lot; const row = { id, medicineId, batchNumber, purchasedOn, quantity, expiresOn, supplier: lot.supplier, purchaseProof: lot.purchaseProof, prescribingVet: lot.prescribingVet, notes: lot.notes, userId: lot.userId, createdAt: lot.createdAt, updatedAt: lot.updatedAt, }; return remaining === undefined ? row : { ...row, remaining }; } @Injectable() export class VeterinaryMedicineLotsService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, ) {} /** * Newest purchase first. `selectable`: lots of active medicines with a rest * > 0, oldest expiry first (FIFO); `includeLotId` (the lot of a treatment * being edited) stays selectable even when empty or its medicine is inactive. */ async findAll(filter: LotListFilter): Promise { const lots = await this.prisma.veterinaryMedicineLot.findMany({ where: { medicineId: filter.medicineId, ...(filter.selectable && { OR: [ { medicine: { active: true } }, ...(filter.includeLotId ? [{ id: filter.includeLotId }] : []), ], }), }, include: LOT_MEDICINE_INCLUDE, orderBy: [{ purchasedOn: 'desc' }, { id: 'asc' }], }); const totals = await lotTotals( this.prisma, lots.map((lot) => lot.id), ); const entries = lots.map((lot) => ({ id: lot.id, remaining: remainingOf(lot, totals.get(lot.id)), bestBeforeDate: lot.expiresOn, date: lot.purchasedOn, view: toLotView(lot, totals.get(lot.id)), })); const chosen = filter.selectable ? fifoOrder( entries.filter( (entry) => entry.remaining > 0 || entry.id === filter.includeLotId, ), ) : entries; return chosen.map((entry) => entry.view); } async findOne(id: string): Promise { return this.detail(this.prisma, id); } async create(dto: CreateLotDto, actingUserId: string): Promise { return this.prisma.$transaction(async (tx) => { // The medicine first: changing its unit counts the lots under this lock. if (!(await lockRowForUpdate(tx, 'VeterinaryMedicine', dto.medicineId))) { throw new NotFoundException(MEDICINE_NOT_FOUND); } await this.assertMedicineActive(tx, dto.medicineId); const created = await tx.veterinaryMedicineLot.create({ data: { ...dto, purchasedOn: dayToDate(dto.purchasedOn), expiresOn: dto.expiresOn == null ? dto.expiresOn : dayToDate(dto.expiresOn), userId: actingUserId, }, include: LOT_MEDICINE_INCLUDE, }); await this.auditLog.record( { entityType: AUDIT_ENTITY, entityId: created.id, action: 'CREATE', userId: actingUserId, afterData: auditRow(created), }, tx, ); return toLotView(created, undefined); }); } /** * The medicine of a lot is fixed once a movement exists (409). Lock order: * medicines (ascending), then the lot - the order of the lock map. */ async update( id: string, dto: UpdateLotDto, actingUserId: string, ): Promise { return this.prisma.$transaction(async (tx) => { const readMedicineId = await this.lockMedicinesThenLot(tx, id, dto); const before = await tx.veterinaryMedicineLot.findUnique({ where: { id }, include: LOT_MEDICINE_INCLUDE, }); if (!before) { throw new NotFoundException(LOT_NOT_FOUND); } if ( dto.medicineId !== undefined && dto.medicineId !== before.medicineId ) { await this.assertMedicineChangeable(tx, before, readMedicineId, dto); } const { purchasedOn, expiresOn, ...rest } = dto; const updated = await tx.veterinaryMedicineLot.update({ where: { id }, data: { ...rest, ...(purchasedOn !== undefined && { purchasedOn: dayToDate(purchasedOn), }), ...(expiresOn !== undefined && { expiresOn: expiresOn === null ? null : dayToDate(expiresOn), }), }, include: LOT_MEDICINE_INCLUDE, }); await this.auditLog.record( { entityType: AUDIT_ENTITY, entityId: id, action: 'UPDATE', userId: actingUserId, beforeData: auditRow(before), afterData: auditRow(updated), }, tx, ); const totals = await lotTotals(tx, [id]); return toLotView(updated, totals.get(id)); }); } /** * CORRECTION books the difference between the rest in the book and the rest * counted (nothing for a difference of 0); DISPOSAL books the quantity as a * negative one. The rest is read under the lot lock, so two inventories at * the same time are applied one after the other. */ async addMovement( id: string, dto: CreateMovementDto, actingUserId: string, ): Promise { const manual = this.manualBooking(dto); return this.prisma.$transaction(async (tx) => { if (!(await lockRowForUpdate(tx, 'VeterinaryMedicineLot', id))) { throw new NotFoundException(LOT_NOT_FOUND); } const lot = await tx.veterinaryMedicineLot.findUnique({ where: { id }, include: LOT_MEDICINE_INCLUDE, }); if (!lot) { throw new NotFoundException(LOT_NOT_FOUND); } const booked = await tx.veterinaryMedicineMovement.aggregate({ where: { lotId: id }, _sum: { quantity: true }, }); const { remaining } = summarizeLot(lot.quantity, [ { quantity: booked._sum.quantity ?? 0 }, ]); const quantity = manual.type === 'CORRECTION' ? roundToThousandths(manual.actualRemaining - remaining) : roundToThousandths(-manual.quantity); if (quantity === 0) { if (manual.type === 'DISPOSAL') { throw new BadRequestException('Die Menge ist zu klein'); } return this.detail(tx, id); } await tx.veterinaryMedicineMovement.create({ data: { lotId: id, type: manual.type, quantity, reason: manual.reason, userId: actingUserId, }, }); await this.auditLog.record( { entityType: AUDIT_ENTITY, entityId: id, action: 'UPDATE', userId: actingUserId, beforeData: auditRow(lot, remaining), afterData: auditRow(lot, roundToThousandths(remaining + quantity)), }, tx, ); return this.detail(tx, id); }); } private manualBooking( dto: CreateMovementDto, ): | { type: 'CORRECTION'; actualRemaining: number; reason: string | null } | { type: 'DISPOSAL'; quantity: number; reason: string } { if (dto.type === 'CORRECTION') { if (dto.actualRemaining === undefined || !(dto.actualRemaining >= 0)) { throw new BadRequestException('Der tatsächliche Rest fehlt'); } return { type: 'CORRECTION', actualRemaining: dto.actualRemaining, reason: dto.reason ?? null, }; } if (dto.quantity === undefined || !(dto.quantity > 0)) { throw new BadRequestException('Die Menge muss größer als 0 sein'); } if (dto.reason === undefined || dto.reason.trim() === '') { throw new BadRequestException('Für eine Entsorgung ist ein Grund nötig'); } return { type: 'DISPOSAL', quantity: dto.quantity, reason: dto.reason }; } /** Returns the medicine id read before the locks, to detect a concurrent change. */ private async lockMedicinesThenLot( tx: Prisma.TransactionClient, id: string, dto: UpdateLotDto, ): Promise { let readMedicineId: string | null = null; if (dto.medicineId !== undefined) { const current = await tx.veterinaryMedicineLot.findUnique({ where: { id }, select: { medicineId: true }, }); if (!current) { throw new NotFoundException(LOT_NOT_FOUND); } readMedicineId = current.medicineId; await lockRowsForUpdate(tx, 'VeterinaryMedicine', [ current.medicineId, dto.medicineId, ]); } if (!(await lockRowForUpdate(tx, 'VeterinaryMedicineLot', id))) { throw new NotFoundException(LOT_NOT_FOUND); } return readMedicineId; } /** Read under the medicine lock: an inactive medicine takes no new stock. */ private async assertMedicineActive( tx: Prisma.TransactionClient, medicineId: string | undefined, ): Promise<{ unit: MedicineUnit }> { const medicine = await tx.veterinaryMedicine.findUnique({ where: { id: medicineId }, select: { unit: true, active: true }, }); if (!medicine) { throw new NotFoundException(MEDICINE_NOT_FOUND); } if (!medicine.active) { throw new BadRequestException('Das Mittel ist deaktiviert.'); } return medicine; } private async assertMedicineChangeable( tx: Prisma.TransactionClient, before: LotRow, readMedicineId: string | null, dto: UpdateLotDto, ): Promise { if (readMedicineId !== before.medicineId) { throw new ConflictException( 'Die Packung wurde gerade geändert. Bitte neu laden.', ); } const target = await this.assertMedicineActive(tx, dto.medicineId); if (target.unit !== before.medicine.unit) { throw new BadRequestException( 'Die Einheit des Mittels passt nicht zur Packung.', ); } if ( (await tx.veterinaryMedicineMovement.count({ where: { lotId: before.id }, })) > 0 ) { throw new ConflictException( 'Das Mittel ist festgelegt, sobald Buchungen existieren', ); } } private async detail(db: DbClient, id: string): Promise { const lot = await db.veterinaryMedicineLot.findUnique({ where: { id }, include: LOT_MEDICINE_INCLUDE, }); if (!lot) { throw new NotFoundException(LOT_NOT_FOUND); } const [totals, movements] = await Promise.all([ lotTotals(db, [id]), db.veterinaryMedicineMovement.findMany({ where: { lotId: id }, orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], select: { id: true, type: true, quantity: true, treatmentId: true, reason: true, userId: true, createdAt: true, }, }), ]); return { ...toLotView(lot, totals.get(id)), movements }; } }