Files
ApiCore/ApiCore-Backend/src/veterinary-medicines/veterinary-medicine-lots.service.ts
T
Nick SzaunigandClaude Opus 5.5 c18860c75c
Backend Image / build-and-push (push) Successful in 6m34s
Frontend Image / build-and-push (push) Successful in 10m17s
feat: T6 wave 2 – medicine book, document rights, label particulars, herd events
- Veterinary medicine catalogue, lots and append-only movement ledger
  (rest always computed; corrections as counter-bookings; inactive
  medicines take no new stock; unit must match on lot moves).
- Documents of medicine lots, authority notices and restrictions need the
  domain right on top of documents:* (403 before any lookup).
- Label particulars: pure checkLabel with six mandatory gaps and four
  warnings, AFM font metrics, Berlin-day dates, geometric reading order
  for split addresses, new master-data fields with row-locked updates.
- Herd events ColonyCreated, ColonyMoved, ApiaryChanged and
  TskReferenceDayReached (ids only, never forwarded to webhooks); CSV
  import emits ColonyCreated with audit.
- ARCHITECTURE.md §12 Compliance (T6), Annahmen 59–70.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 13:43:44 +02:00

387 lines
12 KiB
TypeScript

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<LotView[]> {
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<LotDetailView> {
return this.detail(this.prisma, id);
}
async create(dto: CreateLotDto, actingUserId: string): Promise<LotView> {
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<LotView> {
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<LotDetailView> {
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<string | null> {
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<void> {
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<LotDetailView> {
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 };
}
}