- 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]>
387 lines
12 KiB
TypeScript
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 };
|
|
}
|
|
}
|