feat: T6 wave 3 – treatment books from lot, label print lock, restricted zone, authority notices
Backend Image / build-and-push (push) Successful in 5m13s
Frontend Image / build-and-push (push) Successful in 8m54s

- Treatments book their dose from a medicine lot (delta booking on edit,
  bulk all-or-nothing, idempotent); Bestandsbuch columns from the lot,
  FEHLT exactly where treatmentLogGaps reports a gap; Bestandsbuch export
  needs veterinary-medicines:read.
- Label printing: 409 LABEL_PARTICULARS_MISSING on gaps, proof print with
  watermark, PRINT audit before the PDF, check endpoints; digit rules only
  from the seeded ComplianceSettings row (fail closed).
- Restricted zone (Berlin day): create, update, move, merge and CSV import
  blocked inside an active restriction; override with reason and
  compliance:manage, audited, never stored on the colony; split, harvest
  and dissolve stay free; restriction details only with compliance:read.
- Authority notices: settings, herd snapshot, registration/herd change/TSK
  notices with one open draft per kind under the settings lock.
- Label check refinements (geometric reading order, number|unit pairs).
- ARCHITECTURE.md §12.6–12.8, Annahmen 71–77; spec §5.6 Berlin day.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 14:18:41 +02:00
1 parent c18860c75c
commit edc193f128
73 files changed
+9470 -171

No files matched your search

@@ -1,14 +1,27 @@
import { Controller, Get, Param, Query, Res } from '@nestjs/common';
import {
Controller,
ForbiddenException,
Get,
Param,
Query,
Res,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { ExportService } from './export.service';
@ApiTags('export')
@ApiBearerAuth()
@Controller('export')
export class ExportController {
constructor(private readonly exportService: ExportService) {}
constructor(
private readonly exportService: ExportService,
private readonly permissions: PermissionResolverService,
) {}
@RequirePermission('export:read')
@Get('colonies.csv')
@@ -54,8 +67,17 @@ export class ExportController {
@Query('colonyId') colonyId: string | undefined,
@Query('apiaryId') apiaryId: string | undefined,
@Query('year') year: string | undefined,
@CurrentUser() actor: AuthenticatedUser,
@Res() res: Response,
): Promise<void> {
// The Bestandsbuch prints supplier, purchase proofs and the vet: those
// are visible with veterinary-medicines:read only.
if (report === 'treatment-log') {
const held = await this.permissions.getPermissions(actor.id);
if (!held.has('veterinary-medicines:read')) {
throw new ForbiddenException('Insufficient permissions');
}
}
const pdf = await this.exportService.exportReportPdf(report, {
honeyBatchId,
colonyId,
@@ -1,6 +1,11 @@
import {
apiaryOnDate,
groupTreatmentLog,
LOT_FOOTNOTE,
MISSING,
resolveSourceValues,
treatmentLogCells,
treatmentLogGaps,
TreatmentLogSource,
} from './treatment-log';
@@ -24,10 +29,51 @@ function source(overrides: Partial<TreatmentLogSource>): TreatmentLogSource {
prescribingVet: null,
withdrawalPeriodDays: 0,
administeredBy: 'Imker',
medicineId: null,
prescriptionOnly: false,
doseQuantity: null,
medicineUnit: null,
lot: null,
...overrides,
};
}
type Lot = NonNullable<TreatmentLogSource['lot']>;
const lotOf = (overrides: Partial<Lot> = {}): Lot => ({
id: 'lot-1',
batchNumber: 'LOT-B',
supplier: 'Apotheke Y, Hauptstr. 1',
purchaseProof: 'Rechnung 77',
prescribingVet: 'Dr. Vet',
...overrides,
});
// A treatment from a pack: supplier, proof, batch and vet only on the lot.
const fromLot = (
overrides: Partial<TreatmentLogSource> = {},
): TreatmentLogSource =>
source({
medicineId: 'med-1',
batchNumber: null,
supplier: null,
purchaseProof: null,
prescribingVet: null,
lot: lotOf(),
...overrides,
});
const withDose = (
dose: number,
unit: 'ML' | 'G' | 'PIECE',
): TreatmentLogSource =>
source({
amount: null,
medicineId: 'med-1',
doseQuantity: dose,
medicineUnit: unit,
});
describe('apiaryOnDate', () => {
it('uses the apiary stored with the treatment', () => {
expect(apiaryOnDate(source({ apiaryId: 'stored' }), [])).toEqual({
@@ -124,3 +170,178 @@ describe('groupTreatmentLog', () => {
});
});
});
describe('resolveSourceValues', () => {
it('takes supplier, proof, batch and vet from the lot', () => {
expect(
resolveSourceValues(fromLot({ batchNumber: 'ignored-by-design' })),
).toMatchObject({
batchNumber: 'LOT-B',
supplier: 'Apotheke Y, Hauptstr. 1',
purchaseProof: 'Rechnung 77',
prescribingVet: 'Dr. Vet',
});
});
it('never mixes treatment text into a row with a lot', () => {
const values = resolveSourceValues(
fromLot({
supplier: 'alt',
lot: lotOf({ supplier: null, purchaseProof: null }),
}),
);
expect(values.supplier).toBeNull();
expect(values.purchaseProof).toBeNull();
});
it('reads the treatment text when there is no lot', () => {
expect(resolveSourceValues(source({}))).toMatchObject({
batchNumber: 'B1',
supplier: 'Imkereibedarf X',
purchaseProof: 'Rechnung 1',
prescribingVet: null,
amount: '30 ml',
});
});
it('prints the dose with unit when the amount text is empty', () => {
expect(resolveSourceValues(withDose(30, 'ML')).amount).toBe('30 ml');
expect(resolveSourceValues(withDose(2.5, 'G')).amount).toBe('2,5 g');
expect(resolveSourceValues(withDose(1, 'PIECE')).amount).toBe('1 Stück');
});
it('prefers the amount text over the dose', () => {
expect(
resolveSourceValues({ ...withDose(30, 'ML'), amount: '5 ml je Volk' })
.amount,
).toBe('5 ml je Volk');
});
it('leaves the amount empty without text and without dose', () => {
expect(resolveSourceValues(source({ amount: null })).amount).toBeNull();
expect(resolveSourceValues(source({ amount: ' ' })).amount).toBeNull();
});
});
describe('treatmentLogGaps', () => {
const complete = {
supplier: 'S',
purchaseProof: 'P',
amount: '1 ml',
withdrawalPeriodDays: 0,
prescribingVet: null,
prescriptionOnly: false,
};
it('reports nothing for a complete row; a withdrawal of 0 days is complete', () => {
expect(treatmentLogGaps(complete)).toEqual([]);
});
it('reports each missing mandatory field', () => {
expect(
treatmentLogGaps({
...complete,
supplier: null,
purchaseProof: '',
amount: null,
withdrawalPeriodDays: null,
}),
).toEqual(['SUPPLIER', 'PROOF', 'AMOUNT', 'WITHDRAWAL']);
});
it('requires the vet only for prescription-only medicines', () => {
expect(treatmentLogGaps({ ...complete, prescriptionOnly: true })).toEqual([
'VET',
]);
expect(
treatmentLogGaps({
...complete,
prescriptionOnly: true,
prescribingVet: 'Dr. Vet',
}),
).toEqual([]);
expect(treatmentLogGaps(complete)).toEqual([]);
});
});
describe('Bestandsbuch from the lot', () => {
it('groups two colonies of the same lot and day into one row', () => {
const [sheet] = groupTreatmentLog(
[
fromLot({ id: '1', colonyCode: 'V-02', apiaryId: 'a' }),
fromLot({ id: '2', colonyId: 'c2', colonyCode: 'V-01', apiaryId: 'a' }),
],
[],
);
expect(sheet.rows).toHaveLength(1);
expect(sheet.rows[0].colonyCodes).toEqual(['V-01', 'V-02']);
expect(sheet.rows[0]).toMatchObject({
lotId: 'lot-1',
supplier: 'Apotheke Y, Hauptstr. 1',
batchNumber: 'LOT-B',
});
});
it('keeps colonies of two different lots in two rows', () => {
const [sheet] = groupTreatmentLog(
[
fromLot({ id: '1', apiaryId: 'a' }),
fromLot({
id: '2',
colonyId: 'c2',
apiaryId: 'a',
lot: lotOf({ id: 'lot-2' }),
}),
],
[],
);
expect(sheet.rows).toHaveLength(2);
});
it('prints FEHLT exactly where treatmentLogGaps reports a gap', () => {
const variants: Partial<TreatmentLogSource>[] = [
{},
{ lot: lotOf({ supplier: null }) },
{ lot: lotOf({ purchaseProof: '' }) },
{ amount: null },
{ withdrawalPeriodDays: null },
{ prescriptionOnly: true, lot: lotOf({ prescribingVet: null }) },
{ prescriptionOnly: true },
{
lot: lotOf({ supplier: null, purchaseProof: null }),
amount: null,
withdrawalPeriodDays: null,
prescriptionOnly: true,
},
];
for (const overrides of variants) {
const [sheet] = groupTreatmentLog(
[fromLot({ apiaryId: 'a', ...overrides })],
[],
);
const row = sheet.rows[0];
const gaps = treatmentLogGaps(row);
const cells = treatmentLogCells(row);
const [, , supplierCell, proofCell, , amountCell, withdrawalCell] = cells;
expect(supplierCell.startsWith(MISSING)).toBe(gaps.includes('SUPPLIER'));
expect(supplierCell.includes(`Tierarzt: ${MISSING}`)).toBe(
gaps.includes('VET'),
);
expect(proofCell === MISSING).toBe(gaps.includes('PROOF'));
expect(amountCell === MISSING).toBe(gaps.includes('AMOUNT'));
expect(withdrawalCell === MISSING).toBe(gaps.includes('WITHDRAWAL'));
// No FEHLT anywhere else.
expect(cells.join('|').split(MISSING).length - 1).toBe(gaps.length);
}
});
it('footnote says supplier, proof and batch come from the recorded lot', () => {
expect(LOT_FOOTNOTE).toBe(
'Lieferant, Beleg und Ch.-B. stammen aus der erfassten Packung.',
);
});
});
+149 -21
View File
@@ -1,4 +1,5 @@
import PDFDocument from 'pdfkit';
import type { MedicineUnit } from '../../generated/prisma';
import { PrismaService } from '../prisma/prisma.service';
import {
PDF_MARGIN,
@@ -24,6 +25,15 @@ import {
export const MISSING = 'FEHLT';
export const LOT_FOOTNOTE =
'Lieferant, Beleg und Ch.-B. stammen aus der erfassten Packung.';
const UNIT_LABELS: Record<MedicineUnit, string> = {
ML: 'ml',
G: 'g',
PIECE: 'Stück',
};
export interface TreatmentLogSource {
id: string;
colonyId: string;
@@ -42,6 +52,19 @@ export interface TreatmentLogSource {
prescribingVet: string | null;
withdrawalPeriodDays: number | null;
administeredBy: string;
medicineId: string | null;
/** Of the catalogue entry; the vet is only mandatory for these. */
prescriptionOnly: boolean;
doseQuantity: number | null;
medicineUnit: MedicineUnit | null;
/** The pack the dose was taken from: its values replace the treatment's. */
lot: {
id: string;
batchNumber: string | null;
supplier: string | null;
purchaseProof: string | null;
prescribingVet: string | null;
} | null;
}
export interface ColonyMove {
@@ -65,6 +88,76 @@ export interface TreatmentLogRow {
administeredBy: string;
/** Apiary derived from today's assignment, not known for that date. */
locationUncertain: boolean;
lotId: string | null;
prescriptionOnly: boolean;
}
export type TreatmentLogGap =
'SUPPLIER' | 'PROOF' | 'AMOUNT' | 'WITHDRAWAL' | 'VET';
function hasText(value: string | null | undefined): value is string {
return value !== null && value !== undefined && value.trim() !== '';
}
function orNull(value: string | null | undefined): string | null {
return hasText(value) ? value : null;
}
function doseText(dose: number, unit: MedicineUnit): string {
return `${String(dose).replace('.', ',')} ${UNIT_LABELS[unit]}`;
}
/**
* The columns of one treatment, each from exactly one source: the pack when
* the dose was taken from one (supplier, proof, batch number and vet are never
* mixed with text on the treatment), else the treatment itself. The amount is
* the treatment's own text, else its dose with the unit of the medicine.
*/
export function resolveSourceValues(
source: TreatmentLogSource,
): Pick<
TreatmentLogSource,
'batchNumber' | 'supplier' | 'purchaseProof' | 'prescribingVet'
> & { amount: string | null } {
const origin = source.lot ?? source;
const amount = hasText(source.amount)
? source.amount
: source.doseQuantity !== null && source.medicineUnit !== null
? doseText(source.doseQuantity, source.medicineUnit)
: null;
return {
batchNumber: orNull(origin.batchNumber),
supplier: orNull(origin.supplier),
purchaseProof: orNull(origin.purchaseProof),
prescribingVet: orNull(origin.prescribingVet),
amount,
};
}
/**
* The mandatory fields a row lacks: supplier, proof, amount and withdrawal
* period (0 days counts as given), and the vet only for prescription-only
* medicines. The PDF prints FEHLT exactly where this reports a gap, and the
* readiness check reads the same list.
*/
export function treatmentLogGaps(
row: Pick<
TreatmentLogRow,
| 'supplier'
| 'purchaseProof'
| 'amount'
| 'withdrawalPeriodDays'
| 'prescribingVet'
| 'prescriptionOnly'
>,
): TreatmentLogGap[] {
const gaps: TreatmentLogGap[] = [];
if (!hasText(row.supplier)) gaps.push('SUPPLIER');
if (!hasText(row.purchaseProof)) gaps.push('PROOF');
if (!hasText(row.amount)) gaps.push('AMOUNT');
if (row.withdrawalPeriodDays === null) gaps.push('WITHDRAWAL');
if (row.prescriptionOnly && !hasText(row.prescribingVet)) gaps.push('VET');
return gaps;
}
export interface TreatmentLogSheet {
@@ -120,17 +213,20 @@ export function groupTreatmentLog(
for (const source of sorted) {
const { apiaryId, uncertain } = apiaryOnDate(source, moves);
const start = source.startDate ?? source.date;
const values = resolveSourceValues(source);
const key = [
source.date.toISOString(),
source.endDate?.toISOString() ?? '',
source.product,
source.batchNumber ?? '',
source.amount ?? '',
source.supplier ?? '',
source.purchaseProof ?? '',
source.prescribingVet ?? '',
values.batchNumber ?? '',
values.amount ?? '',
values.supplier ?? '',
values.purchaseProof ?? '',
values.prescribingVet ?? '',
source.withdrawalPeriodDays ?? '',
source.administeredBy,
source.lot?.id ?? '',
source.prescriptionOnly,
uncertain,
].join('|');
@@ -142,17 +238,19 @@ export function groupTreatmentLog(
rows.set(key, {
colonyCodes: [source.colonyCode],
product: source.product,
batchNumber: source.batchNumber,
supplier: source.supplier,
prescribingVet: source.prescribingVet,
purchaseProof: source.purchaseProof,
batchNumber: values.batchNumber,
supplier: values.supplier,
prescribingVet: values.prescribingVet,
purchaseProof: values.purchaseProof,
date: source.date,
endDate: source.endDate,
durationDays: durationDays(start, source.endDate),
amount: source.amount,
amount: values.amount,
withdrawalPeriodDays: source.withdrawalPeriodDays,
administeredBy: source.administeredBy,
locationUncertain: uncertain,
lotId: source.lot?.id ?? null,
prescriptionOnly: source.prescriptionOnly,
});
}
sheets.set(apiaryId, rows);
@@ -194,6 +292,16 @@ export async function buildTreatmentLog(
include: {
colony: { select: { code: true, apiaryId: true } },
user: { select: { name: true } },
medicine: { select: { prescriptionOnly: true, unit: true } },
medicineLot: {
select: {
id: true,
batchNumber: true,
supplier: true,
purchaseProof: true,
prescribingVet: true,
},
},
},
});
const colonyIds = [...new Set(treatments.map((t) => t.colonyId))];
@@ -226,6 +334,11 @@ export async function buildTreatmentLog(
prescribingVet: t.prescribingVet,
withdrawalPeriodDays: t.withdrawalPeriodDays,
administeredBy: t.user.name,
medicineId: t.medicineId,
prescriptionOnly: t.medicine?.prescriptionOnly ?? false,
doseQuantity: t.doseQuantity,
medicineUnit: t.medicine?.unit ?? null,
lot: t.medicineLot,
})),
moves,
).filter((sheet) => !apiaryId || sheet.apiaryId === apiaryId);
@@ -255,6 +368,30 @@ function orMissing(value: string | number | null | undefined): string {
: String(value);
}
/**
* The printed cells of one row. FEHLT stands exactly where treatmentLogGaps
* reports a gap - the supplier column also carries the vet, with FEHLT after
* "Tierarzt:" when one is required and missing.
*/
export function treatmentLogCells(row: TreatmentLogRow): string[] {
const gaps = treatmentLogGaps(row);
const vetPart = row.prescribingVet
? `; Tierarzt: ${row.prescribingVet}`
: gaps.includes('VET')
? `; Tierarzt: ${MISSING}`
: '';
return [
`${row.colonyCodes.length} Volk/Völker: ${row.colonyCodes.join(', ')}${row.locationUncertain ? ' *' : ''}`,
`${row.product}${row.batchNumber ? ` (Ch.-B. ${row.batchNumber})` : ''}`,
`${gaps.includes('SUPPLIER') ? MISSING : row.supplier}${vetPart}`,
gaps.includes('PROOF') ? MISSING : (row.purchaseProof ?? MISSING),
`${formatDay(row.date)}${row.endDate ? ` – ${formatDay(row.endDate)}` : ''}, ${row.durationDays} Tag(e)`,
gaps.includes('AMOUNT') ? MISSING : (row.amount ?? MISSING),
gaps.includes('WITHDRAWAL') ? MISSING : orMissing(row.withdrawalPeriodDays),
orDash(row.administeredBy),
];
}
function renderSheet(
doc: PDFKit.PDFDocument,
log: TreatmentLog,
@@ -305,16 +442,7 @@ function renderSheet(
{ header: 'Wartezeit (Tage)', width: 50 },
{ header: 'Anwendende Person', width: 0 },
]),
sheet.rows.map((row) => [
`${row.colonyCodes.length} Volk/Völker: ${row.colonyCodes.join(', ')}${row.locationUncertain ? ' *' : ''}`,
`${row.product}${row.batchNumber ? ` (Ch.-B. ${row.batchNumber})` : ''}`,
`${orMissing(row.supplier)}${row.prescribingVet ? `; Tierarzt: ${row.prescribingVet}` : ''}`,
orMissing(row.purchaseProof),
`${formatDay(row.date)}${row.endDate ? ` – ${formatDay(row.endDate)}` : ''}, ${row.durationDays} Tag(e)`,
orMissing(row.amount),
orMissing(row.withdrawalPeriodDays),
orDash(row.administeredBy),
]),
sheet.rows.map(treatmentLogCells),
);
doc.font('Helvetica').fontSize(7);
if (uncertain) {
@@ -324,7 +452,7 @@ function renderSheet(
);
}
doc.text(
`"${MISSING}" = Pflichtangabe fehlt – bitte in der Behandlung nachtragen. Bestandsbuch und Erwerbsbelege 5 Jahre aufbewahren (Art. 108 Abs. 5 VO (EU) 2019/6).`,
`"${MISSING}" = Pflichtangabe fehlt – bitte in der Behandlung oder in der Packung nachtragen. ${LOT_FOOTNOTE} Bestandsbuch und Erwerbsbelege 5 Jahre aufbewahren (Art. 108 Abs. 5 VO (EU) 2019/6).`,
PDF_MARGIN,
);
}