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]>
This commit is contained in:
1 parent
8a31c42578
commit
c18860c75c
73 files changed
+7121
-309
No files matched your search
@@ -0,0 +1,386 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user