feat: T6 wave 5 – readiness checks and report, compliance and medicine UI, label UI
Backend Image / build-and-push (push) Successful in 6m45s
Frontend Image / build-and-push (push) Failing after 9m30s

- Readiness: all 18 checks (medicine lots, treatment log gaps, labels,
  harvest withdrawal), medicine subjects masked without
  veterinary-medicines:read, report PDF (counts only), summary cached 60 s,
  409 codes READINESS_*.
- Frontend: /compliance page with acknowledge/revoke, dashboard line and
  nav; veterinary medicine catalogue, lots, movement book with inventory
  and disposal; label designer check panel, print card with gap list and
  proof print, food business master data, product legal name.
- Service worker: cacheability is path-based (the backend sends no-store
  on every answer); compliance, medicine, restriction, notice and label
  check/print paths and restricted document kinds are never cached.
- ARCHITECTURE.md §2, §12.10–12.11, Annahmen 86–90.

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 15:59:08 +02:00
1 parent b912d29166
commit b4746a2b17
149 files changed
+11869 -320

No files matched your search

@@ -63,7 +63,7 @@ function dto(patch: Record<string, string> = {}) {
describe('AcknowledgementsService', () => {
let service: AcknowledgementsService;
let readiness: { evaluate: jest.Mock };
let readiness: { evaluate: jest.Mock; invalidateSummary: jest.Mock };
let auditLog: { record: jest.Mock };
let tx: {
complianceAcknowledgement: {
@@ -109,7 +109,7 @@ describe('AcknowledgementsService', () => {
callback(tx),
),
} as unknown as PrismaService;
readiness = { evaluate: jest.fn() };
readiness = { evaluate: jest.fn(), invalidateSummary: jest.fn() };
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
service = new AcknowledgementsService(
prisma,
@@ -156,23 +156,34 @@ describe('AcknowledgementsService', () => {
});
it('refuses a stale fingerprint with 409', async () => {
await expect(
service.create(dto({ fingerprint: 'b'.repeat(64) }), 'u1'),
).rejects.toBeInstanceOf(ConflictException);
const error: unknown = await service
.create(dto({ fingerprint: 'b'.repeat(64) }), 'u1')
.catch((e: unknown) => e);
expect(error).toBeInstanceOf(ConflictException);
expect((error as ConflictException).getResponse()).toEqual({
code: 'READINESS_FINGERPRINT_STALE',
message: 'Der Punkt hat sich geändert. Bitte neu laden.',
});
expect(tx.complianceAcknowledgement.create).not.toHaveBeenCalled();
});
it('refuses an item that is not open (gone or done) with 409', async () => {
reportWith(item({ state: 'DONE' }));
await expect(service.create(dto(), 'u1')).rejects.toBeInstanceOf(
ConflictException,
);
for (const setup of [
() => reportWith(item({ state: 'DONE' })),
() => reportWith(),
]) {
setup();
const error: unknown = await service
.create(dto(), 'u1')
.catch((e: unknown) => e);
reportWith();
await expect(service.create(dto(), 'u1')).rejects.toBeInstanceOf(
ConflictException,
);
expect(error).toBeInstanceOf(ConflictException);
expect((error as ConflictException).getResponse()).toMatchObject({
code: 'READINESS_ITEM_NOT_OPEN',
});
}
});
it('refuses a PFLICHT item and an unknown item key with 400', async () => {
@@ -236,9 +247,14 @@ describe('AcknowledgementsService', () => {
id: 'ack1',
revokedAt: new Date('2027-03-01T00:00:00.000Z'),
});
await expect(service.revoke('ack1', 'u2')).rejects.toBeInstanceOf(
ConflictException,
);
const revoked: unknown = await service
.revoke('ack1', 'u2')
.catch((e: unknown) => e);
expect(revoked).toBeInstanceOf(ConflictException);
expect((revoked as ConflictException).getResponse()).toMatchObject({
code: 'READINESS_ACK_REVOKED',
message: 'Die Quittung ist schon zurückgenommen.',
});
expect(tx.$executeRaw).not.toHaveBeenCalled();
lockMock.mockResolvedValue(false);
@@ -246,4 +262,27 @@ describe('AcknowledgementsService', () => {
NotFoundException,
);
});
it('forgets the cached summary after an acknowledgement and after a revoke', async () => {
await service.create(dto(), 'u1');
expect(readiness.invalidateSummary).toHaveBeenCalledTimes(1);
tx.complianceAcknowledgement.findUnique.mockResolvedValue({
id: 'ack1',
revokedAt: null,
});
tx.complianceAcknowledgement.findUniqueOrThrow.mockResolvedValue({
id: 'ack1',
});
await service.revoke('ack1', 'u2');
expect(readiness.invalidateSummary).toHaveBeenCalledTimes(2);
});
it('keeps the cache when the acknowledgement is refused', async () => {
await service
.create(dto({ fingerprint: 'b'.repeat(64) }), 'u1')
.catch(() => undefined);
expect(readiness.invalidateSummary).not.toHaveBeenCalled();
});
});
@@ -18,6 +18,11 @@ export const MIN_NOTE_LENGTH = 10;
export const MAX_NOTE_LENGTH = 2000;
const NO_PERMISSIONS: ReadonlySet<string> = new Set();
/** Stable codes of the 409 answers; the frontend switches on them. */
export const READINESS_FINGERPRINT_STALE = 'READINESS_FINGERPRINT_STALE';
export const READINESS_ITEM_NOT_OPEN = 'READINESS_ITEM_NOT_OPEN';
export const READINESS_ACK_REVOKED = 'READINESS_ACK_REVOKED';
const STALE_MESSAGE = 'Der Punkt hat sich geändert. Bitte neu laden.';
@Injectable()
@@ -60,10 +65,16 @@ export class AcknowledgementsService {
(entry) => entry.key === dto.itemKey && entry.subjectId === dto.subjectId,
);
if (!item || item.state === 'DONE') {
throw new ConflictException(STALE_MESSAGE);
throw new ConflictException({
code: READINESS_ITEM_NOT_OPEN,
message: STALE_MESSAGE,
});
}
if (item.fingerprint !== dto.fingerprint) {
throw new ConflictException(STALE_MESSAGE);
throw new ConflictException({
code: READINESS_FINGERPRINT_STALE,
message: STALE_MESSAGE,
});
}
const created = await this.prisma.$transaction(async (tx) => {
const row = await tx.complianceAcknowledgement.create({
@@ -98,6 +109,7 @@ export class AcknowledgementsService {
);
return row;
});
this.readiness.invalidateSummary();
return {
...item,
state: 'ACKNOWLEDGED',
@@ -125,7 +137,10 @@ export class AcknowledgementsService {
throw new NotFoundException('Die Quittung gibt es nicht.');
}
if (before.revokedAt) {
throw new ConflictException('Die Quittung ist schon zurückgenommen.');
throw new ConflictException({
code: READINESS_ACK_REVOKED,
message: 'Die Quittung ist schon zurückgenommen.',
});
}
await tx.$executeRaw`
UPDATE "ComplianceAcknowledgement"
@@ -147,5 +162,6 @@ export class AcknowledgementsService {
tx,
);
});
this.readiness.invalidateSummary();
}
}
@@ -0,0 +1,92 @@
import type { DbClient } from '../../../prisma/db-client';
import type { NotificationsService } from '../../../notifications/notifications.service';
import { withdrawalWindowOf } from '../../../treatments/withdrawal-window';
import { WithdrawalCheckService } from '../../../withdrawal/withdrawal-check.service';
import { loadHarvests } from './harvests.loader';
const TREATMENT = {
id: 't1',
colonyId: 'c1',
product: 'Mittel Eins',
date: new Date('2027-02-01T00:00:00.000Z'),
startDate: null,
endDate: new Date('2027-02-05T00:00:00.000Z'),
withdrawalPeriodDays: 10,
};
function harvest(id: string, iso: string) {
return {
id,
date: new Date(iso),
colonies: [{ colonyId: 'c1' }],
};
}
function fakeDb(harvests: unknown[], treatments: unknown[] = [TREATMENT]) {
const findHarvests = jest.fn().mockResolvedValue(harvests);
const db = {
harvest: { findMany: findHarvests },
treatment: { findMany: jest.fn().mockResolvedValue(treatments) },
} as unknown as DbClient;
return { db, findHarvests };
}
const withdrawal = new WithdrawalCheckService({} as NotificationsService);
describe('loadHarvests', () => {
it('reads the harvests of the last 2 years', async () => {
const { db, findHarvests } = fakeDb([]);
await loadHarvests(db, withdrawal, '2027-03-15');
const args = (findHarvests.mock.calls as unknown[][])[0][0] as {
where: { date: { gte: Date } };
};
expect(args.where.date.gte).toEqual(new Date('2025-03-15T00:00:00.000Z'));
});
it('reports a harvest inside the window of a participating colony and no harvest after it, exactly as the window helper says', async () => {
const window = withdrawalWindowOf(TREATMENT);
if (window === null) {
throw new Error('window expected');
}
// fromDay 2027-02-01, untilDay 2027-02-15 (exclusive).
const { db } = fakeDb([
harvest('inside', '2027-02-14T10:00:00.000Z'),
harvest('free', '2027-02-15T10:00:00.000Z'),
harvest('before', '2027-01-31T10:00:00.000Z'),
]);
const harvests = await loadHarvests(db, withdrawal, '2027-03-15');
expect(window.untilDay).toBe('2027-02-15');
expect(harvests.map((entry) => [entry.id, entry.conflicts.length])).toEqual(
[
['inside', 1],
['free', 0],
['before', 0],
],
);
expect(harvests[0].conflicts[0]).toMatchObject({
harvestId: 'inside',
colonyId: 'c1',
treatmentId: 't1',
untilDay: '2027-02-15',
});
expect(harvests[0].day).toBe('2027-02-14');
});
it('asks the service once for all harvests', async () => {
const spy = jest.spyOn(withdrawal, 'conflictsForHarvests');
const { db } = fakeDb([
harvest('a', '2027-02-10T10:00:00.000Z'),
harvest('b', '2027-02-11T10:00:00.000Z'),
]);
await loadHarvests(db, withdrawal, '2027-03-15');
expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0][1]).toEqual(['a', 'b']);
spy.mockRestore();
});
});
@@ -0,0 +1,49 @@
import {
addMonthsClamped,
berlinDayOf,
dayToDate,
} from '../../../common/dates';
import type { DbClient } from '../../../prisma/db-client';
import type {
WithdrawalCheckService,
WithdrawalConflict,
} from '../../../withdrawal/withdrawal-check.service';
import { HARVEST_WITHDRAWAL_LOOKBACK_YEARS } from '../readiness-limits';
export interface ReadinessHarvest {
id: string;
/** Berlin calendar day of the harvest. */
day: string;
/** One per participating colony whose withdrawal window covers the day. */
conflicts: WithdrawalConflict[];
}
/**
* The harvests of the last 2 years with their withdrawal conflicts. The
* windows are never calculated here: `conflictsForHarvests` decides, with
* `withdrawalWindowOf` and `isInWindow`.
*/
export async function loadHarvests(
db: DbClient,
withdrawal: Pick<WithdrawalCheckService, 'conflictsForHarvests'>,
today: string,
): Promise<ReadinessHarvest[]> {
const since = addMonthsClamped(
today,
-12 * HARVEST_WITHDRAWAL_LOOKBACK_YEARS,
);
const harvests = await db.harvest.findMany({
where: { date: { gte: dayToDate(since) } },
select: { id: true, date: true },
orderBy: [{ date: 'asc' }, { id: 'asc' }],
});
const conflicts = await withdrawal.conflictsForHarvests(
db,
harvests.map((harvest) => harvest.id),
);
return harvests.map((harvest) => ({
id: harvest.id,
day: berlinDayOf(harvest.date),
conflicts: conflicts.filter((entry) => entry.harvestId === harvest.id),
}));
}
@@ -0,0 +1,179 @@
import { SAMPLE_BOTTLING } from '../../../label-templates/label-particulars';
import type { DbClient } from '../../../prisma/db-client';
import { loadLabelTemplates } from './labels.loader';
const TEXT = {
id: 'e1',
type: 'text',
x: 0,
y: 0,
width: 50,
height: 10,
text: 'Honig',
placeholderKey: null,
fontSize: 12,
fontFamily: 'sans',
bold: false,
italic: false,
color: '#000000',
align: 'left',
};
function template(patch: Record<string, unknown> = {}) {
return {
id: 'tpl1',
name: 'Vorlage',
minXHeightMm: 1.2,
elements: [
TEXT,
{ id: 'q', type: 'qrCode', x: 0, y: 0, width: 10, height: 10 },
{ id: 'l', type: 'line', x: 0, y: 0, width: 10, height: 1 },
],
...patch,
};
}
function bottling(id: string, jarCount: number, day: string) {
return {
id,
jarSize: 500,
jarCount,
lotNumber: `L-${id}`,
date: new Date(`${day}T00:00:00.000Z`),
bestBeforeDate: new Date('2029-01-01T00:00:00.000Z'),
honeyBatch: { batchNumber: 'HB', honeyType: null },
product: { name: 'Honig', legalName: null },
};
}
function fakeDb(
templates: unknown[],
bottlings: unknown[] = [],
movements: unknown[] = [],
) {
const findBottlings = jest.fn().mockResolvedValue(bottlings);
const groupBy = jest.fn().mockResolvedValue(movements);
const db = {
labelTemplate: { findMany: jest.fn().mockResolvedValue(templates) },
bottlingBatch: { findMany: findBottlings },
batchMovement: { groupBy },
} as unknown as DbClient;
return { db, findBottlings, groupBy };
}
describe('loadLabelTemplates', () => {
it('reads only active templates and keeps their text elements', async () => {
const { db } = fakeDb([template()]);
const templates = await loadLabelTemplates(db);
expect(
(
(db as unknown as { labelTemplate: { findMany: jest.Mock } })
.labelTemplate.findMany.mock.calls as unknown[][]
)[0][0],
).toMatchObject({ where: { active: true } });
expect(templates).toHaveLength(1);
expect(templates[0].elements.map((element) => element.id)).toEqual(['e1']);
});
it('asks for no bottling without an active template', async () => {
const { db, findBottlings } = fakeDb([]);
expect(await loadLabelTemplates(db)).toEqual([]);
expect(findBottlings).not.toHaveBeenCalled();
});
it('targets the newest bottling that still has stock', async () => {
// Newest first, as asked: b3 is sold out (60 of 60), b2 has 10 left.
const { db } = fakeDb(
[template()],
[
bottling('b3', 60, '2027-03-01'),
bottling('b2', 40, '2027-02-01'),
bottling('b1', 20, '2027-01-01'),
],
[
{ bottlingBatchId: 'b3', type: 'SHOP_SALE', _sum: { quantity: -60 } },
{ bottlingBatchId: 'b2', type: 'WITHDRAWAL', _sum: { quantity: -30 } },
],
);
const [loaded] = await loadLabelTemplates(db);
expect(loaded.target.sample).toBe(false);
expect(loaded.target.bottling.lotNumber).toBe('L-b2');
});
it('falls back to the newest bottling when none has stock', async () => {
const { db } = fakeDb(
[template()],
[bottling('b2', 40, '2027-02-01'), bottling('b1', 20, '2027-01-01')],
[
{ bottlingBatchId: 'b2', type: 'SHOP_SALE', _sum: { quantity: -40 } },
{ bottlingBatchId: 'b1', type: 'SHOP_SALE', _sum: { quantity: -20 } },
],
);
const [loaded] = await loadLabelTemplates(db);
expect(loaded.target.sample).toBe(false);
expect(loaded.target.bottling.lotNumber).toBe('L-b2');
});
it('falls back to the sample values when there is no bottling at all', async () => {
const { db } = fakeDb([template()], []);
const [loaded] = await loadLabelTemplates(db);
expect(loaded.target).toEqual({ sample: true, bottling: SAMPLE_BOTTLING });
});
it('shares one target between all templates', async () => {
const { db, findBottlings } = fakeDb(
[template(), template({ id: 'tpl2' })],
[bottling('b1', 20, '2027-01-01')],
);
const templates = await loadLabelTemplates(db);
expect(templates[0].target).toBe(templates[1].target);
expect(findBottlings).toHaveBeenCalledTimes(1);
});
it('finds an older bottling with stock behind 200 sold-out ones', async () => {
const soldOut = Array.from({ length: 200 }, (_, index) =>
bottling(`s${index}`, 10, '2027-03-01'),
);
const older = bottling('old', 10, '2020-01-01');
const findBottlings = jest
.fn()
.mockImplementation(({ skip }: { skip: number }) =>
Promise.resolve(skip === 0 ? soldOut : [older]),
);
const groupBy = jest
.fn()
.mockImplementation(
({ where }: { where: { bottlingBatchId: { in: string[] } } }) =>
Promise.resolve(
where.bottlingBatchId.in
.filter((id) => id.startsWith('s'))
.map((id) => ({
bottlingBatchId: id,
type: 'SHOP_SALE',
_sum: { quantity: -10 },
})),
),
);
const db = {
labelTemplate: { findMany: jest.fn().mockResolvedValue([template()]) },
bottlingBatch: { findMany: findBottlings },
batchMovement: { groupBy },
} as unknown as DbClient;
const [loaded] = await loadLabelTemplates(db);
expect(loaded.target.bottling.lotNumber).toBe('L-old');
expect(findBottlings).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,125 @@
import type { LabelTextElement } from '../../../label-templates/label-element.types';
import { SAMPLE_BOTTLING } from '../../../label-templates/label-particulars';
import type { buildPlaceholderValues } from '../../../label-templates/label-templates.service';
import type { DbClient } from '../../../prisma/db-client';
import { summarizeStock } from '../../../stock/stock-ledger';
/** Bottlings read per page while looking for one with stock. */
const BOTTLING_PAGE = 200;
export type LabelBottling = Parameters<typeof buildPlaceholderValues>[0];
/** What a template is checked against. */
export interface ReadinessLabelTarget {
/** True when no bottling exists and the sample values stand in. */
sample: boolean;
bottling: LabelBottling;
}
export interface ReadinessLabelTemplate {
id: string;
name: string;
minXHeightMm: number;
/** Text elements only: images, lines and QR codes carry no particular. */
elements: LabelTextElement[];
target: ReadinessLabelTarget;
}
function textElementsOf(elements: unknown): LabelTextElement[] {
return Array.isArray(elements)
? (elements as { type?: unknown }[]).filter(
(element): element is LabelTextElement =>
typeof element === 'object' &&
element !== null &&
element.type === 'text',
)
: [];
}
const BOTTLING_SELECT = {
id: true,
jarSize: true,
jarCount: true,
lotNumber: true,
date: true,
bestBeforeDate: true,
honeyBatch: { select: { batchNumber: true, honeyType: true } },
product: { select: { name: true, legalName: true } },
} as const;
/**
* The bottling a template is checked against: the newest with stock left
* (the one being sold), else the newest, else the sample values. The
* bottlings are read newest first in pages until one has stock, so a long
* run of sold-out bottlings hides nothing; a movement sum per page uses the
* stock arithmetic of the shop (`summarizeStock`).
*/
async function loadTarget(db: DbClient): Promise<ReadinessLabelTarget> {
let newest: LabelBottling | null = null;
for (let skip = 0; ; skip += BOTTLING_PAGE) {
const page = await db.bottlingBatch.findMany({
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
skip,
take: BOTTLING_PAGE,
select: BOTTLING_SELECT,
});
newest ??= page[0] ?? null;
const withStock = await firstWithStock(db, page);
if (withStock) {
return { sample: false, bottling: withStock };
}
if (page.length < BOTTLING_PAGE) {
break;
}
}
return newest
? { sample: false, bottling: newest }
: { sample: true, bottling: SAMPLE_BOTTLING };
}
async function firstWithStock<T extends { id: string; jarCount: number }>(
db: DbClient,
bottlings: readonly T[],
): Promise<T | undefined> {
if (bottlings.length === 0) {
return undefined;
}
const sums = await db.batchMovement.groupBy({
by: ['bottlingBatchId', 'type'],
where: {
bottlingBatchId: { in: bottlings.map((bottling) => bottling.id) },
},
_sum: { quantity: true },
});
return bottlings.find(
(bottling) =>
summarizeStock(
bottling.jarCount,
sums
.filter((sum) => sum.bottlingBatchId === bottling.id)
.map((sum) => ({ type: sum.type, quantity: sum._sum.quantity ?? 0 })),
).remaining > 0,
);
}
/** The active templates, each with the bottling it is checked against. */
export async function loadLabelTemplates(
db: DbClient,
): Promise<ReadinessLabelTemplate[]> {
const templates = await db.labelTemplate.findMany({
where: { active: true },
select: { id: true, name: true, minXHeightMm: true, elements: true },
orderBy: [{ name: 'asc' }, { id: 'asc' }],
});
if (templates.length === 0) {
return [];
}
const target = await loadTarget(db);
return templates.map((template) => ({
id: template.id,
name: template.name,
minXHeightMm: template.minXHeightMm,
elements: textElementsOf(template.elements),
target,
}));
}
@@ -0,0 +1,86 @@
import type { DbClient } from '../../../prisma/db-client';
import { loadLots } from './lots.loader';
function lotRow(patch: Record<string, unknown> = {}) {
return {
id: 'l1',
medicineId: 'm1',
purchasedOn: new Date('2027-01-05T00:00:00.000Z'),
quantity: 10,
expiresOn: new Date('2028-01-05T00:00:00.000Z'),
supplier: 'Lieferant',
purchaseProof: 'Beleg',
prescribingVet: null,
medicine: { name: 'Mittel Eins', prescriptionOnly: true },
...patch,
};
}
function fakeDb(lots: unknown[], medicines: unknown[], sums: unknown[] = []) {
const groupBy = jest.fn().mockResolvedValue(sums);
const db = {
veterinaryMedicineLot: { findMany: jest.fn().mockResolvedValue(lots) },
veterinaryMedicine: { findMany: jest.fn().mockResolvedValue(medicines) },
veterinaryMedicineMovement: { groupBy },
} as unknown as DbClient;
return { db, groupBy };
}
describe('loadLots', () => {
it('computes the rest of each lot from one grouped sum query', async () => {
const { db, groupBy } = fakeDb(
[lotRow({ id: 'l1' }), lotRow({ id: 'l2', quantity: 5 })],
[],
[
{ lotId: 'l1', _sum: { quantity: -3.5 }, _count: { _all: 2 } },
{ lotId: 'l2', _sum: { quantity: -7 }, _count: { _all: 1 } },
],
);
const { lots } = await loadLots(db);
expect(lots.map((lot) => [lot.id, lot.remaining])).toEqual([
['l1', 6.5],
['l2', -2],
]);
expect(groupBy).toHaveBeenCalledTimes(1);
});
it('maps a lot with its medicine and days as day texts', async () => {
const { db } = fakeDb([lotRow()], []);
const { lots } = await loadLots(db);
expect(lots[0]).toEqual({
id: 'l1',
medicineId: 'm1',
medicineName: 'Mittel Eins',
prescriptionOnly: true,
purchasedOn: '2027-01-05',
expiresOn: '2028-01-05',
supplier: 'Lieferant',
purchaseProof: 'Beleg',
prescribingVet: null,
remaining: 10,
});
});
it('reads only active medicines', async () => {
const { db } = fakeDb(
[],
[{ id: 'm1', name: 'A', withdrawalPeriodDays: null }],
);
const { medicines } = await loadLots(db);
expect(medicines).toEqual([
{ id: 'm1', name: 'A', withdrawalPeriodDays: null },
]);
expect(
(
(db as unknown as { veterinaryMedicine: { findMany: jest.Mock } })
.veterinaryMedicine.findMany.mock.calls as unknown[][]
)[0][0],
).toMatchObject({ where: { active: true } });
});
});
@@ -0,0 +1,79 @@
import type { DbClient } from '../../../prisma/db-client';
import {
dayText,
lotTotals,
remainingOf,
} from '../../../veterinary-medicines/medicine-views';
export interface ReadinessLot {
id: string;
medicineId: string;
/** Only for subject labels, which need veterinary-medicines:read. */
medicineName: string;
prescriptionOnly: boolean;
/** 'YYYY-MM-DD' */
purchasedOn: string;
/** 'YYYY-MM-DD' */
expiresOn: string | null;
supplier: string | null;
purchaseProof: string | null;
prescribingVet: string | null;
/** quantity + Σ movements, the same number the lot pages show. */
remaining: number;
}
export interface ReadinessMedicine {
id: string;
name: string;
withdrawalPeriodDays: number | null;
}
export interface ReadinessLots {
lots: ReadinessLot[];
/** The active catalogue entries. */
medicines: ReadinessMedicine[];
}
/** All lots with their rest (one grouped sum query) and the active medicines. */
export async function loadLots(db: DbClient): Promise<ReadinessLots> {
const [rows, medicines] = await Promise.all([
db.veterinaryMedicineLot.findMany({
select: {
id: true,
medicineId: true,
purchasedOn: true,
quantity: true,
expiresOn: true,
supplier: true,
purchaseProof: true,
prescribingVet: true,
medicine: { select: { name: true, prescriptionOnly: true } },
},
orderBy: [{ purchasedOn: 'asc' }, { id: 'asc' }],
}),
db.veterinaryMedicine.findMany({
where: { active: true },
select: { id: true, name: true, withdrawalPeriodDays: true },
orderBy: [{ name: 'asc' }, { id: 'asc' }],
}),
]);
const totals = await lotTotals(
db,
rows.map((row) => row.id),
);
return {
lots: rows.map((row): ReadinessLot => ({
id: row.id,
medicineId: row.medicineId,
medicineName: row.medicine.name,
prescriptionOnly: row.medicine.prescriptionOnly,
purchasedOn: dayText(row.purchasedOn),
expiresOn: row.expiresOn === null ? null : dayText(row.expiresOn),
supplier: row.supplier,
purchaseProof: row.purchaseProof,
prescribingVet: row.prescribingVet,
remaining: remainingOf(row, totals.get(row.id)),
})),
medicines,
};
}
@@ -0,0 +1,142 @@
import type { DbClient } from '../../../prisma/db-client';
import { loadTreatments } from './treatments.loader';
const MOVE = {
colonyId: 'c1',
apiaryId: 'a2',
movedAt: new Date('2027-03-01T00:00:00.000Z'),
};
function treatmentRow(patch: Record<string, unknown> = {}) {
return {
id: 't1',
colonyId: 'c1',
date: new Date('2027-02-10T00:00:00.000Z'),
startDate: null,
endDate: null,
product: 'Mittel Eins',
amount: '30 ml',
batchNumber: null,
supplier: null,
purchaseProof: null,
prescribingVet: null,
withdrawalPeriodDays: 0,
apiaryId: null,
doseQuantity: 2.5,
medicineId: 'm1',
colony: { code: 'V-1', apiaryId: 'a1' },
user: { name: 'Imker' },
medicine: { prescriptionOnly: true, unit: 'ML' },
medicineLot: {
id: 'l1',
batchNumber: 'CH-1',
supplier: 'Lieferant',
purchaseProof: 'Beleg',
prescribingVet: 'Dr. Vet',
},
...patch,
};
}
function fakeDb(rows: unknown[], moves: unknown[] = [MOVE]) {
const findTreatments = jest.fn().mockResolvedValue(rows);
const findMoves = jest.fn().mockResolvedValue(moves);
const db = {
treatment: { findMany: findTreatments },
colonyApiaryHistory: { findMany: findMoves },
} as unknown as DbClient;
return { db, findTreatments, findMoves };
}
describe('loadTreatments', () => {
it('reads the last 6 calendar years with a column list, no whole rows', async () => {
const { db, findTreatments } = fakeDb([]);
await loadTreatments(db, '2027-03-15');
const args = (findTreatments.mock.calls as unknown[][])[0][0] as {
where: { date: { gte: Date } };
select?: unknown;
include?: unknown;
};
expect(args.where.date.gte).toEqual(new Date('2022-01-01T00:00:00.000Z'));
expect(args.select).toBeDefined();
expect(args.include).toBeUndefined();
});
it('maps rows to the sources of the Bestandsbuch', async () => {
const { db } = fakeDb([treatmentRow()]);
const { treatments } = await loadTreatments(db, '2027-03-15');
expect(treatments).toEqual([
{
id: 't1',
colonyId: 'c1',
colonyCode: 'V-1',
colonyApiaryId: 'a1',
apiaryId: null,
date: new Date('2027-02-10T00:00:00.000Z'),
startDate: null,
endDate: null,
product: 'Mittel Eins',
amount: '30 ml',
batchNumber: null,
supplier: null,
purchaseProof: null,
prescribingVet: null,
withdrawalPeriodDays: 0,
administeredBy: 'Imker',
medicineId: 'm1',
prescriptionOnly: true,
doseQuantity: 2.5,
medicineUnit: 'ML',
lot: {
id: 'l1',
batchNumber: 'CH-1',
supplier: 'Lieferant',
purchaseProof: 'Beleg',
prescribingVet: 'Dr. Vet',
},
},
]);
});
it('loads the moves of the colonies whose treatment has no stored apiary, and only those', async () => {
const { db, findMoves } = fakeDb([
treatmentRow({ id: 't1', colonyId: 'c1', apiaryId: null }),
treatmentRow({ id: 't2', colonyId: 'c2', apiaryId: 'a1' }),
treatmentRow({ id: 't3', colonyId: 'c1', apiaryId: null }),
]);
const { moves } = await loadTreatments(db, '2027-03-15');
expect(moves).toEqual([MOVE]);
expect((findMoves.mock.calls as unknown[][])[0][0]).toMatchObject({
where: { colonyId: { in: ['c1'] } },
});
});
it('asks for no moves when every treatment stores its apiary', async () => {
const { db, findMoves } = fakeDb([treatmentRow({ apiaryId: 'a1' })]);
const { moves } = await loadTreatments(db, '2027-03-15');
expect(moves).toEqual([]);
expect(findMoves).not.toHaveBeenCalled();
});
it('treats a treatment without a catalogue entry as over the counter', async () => {
const { db } = fakeDb([
treatmentRow({ medicineId: null, medicine: null, medicineLot: null }),
]);
const { treatments } = await loadTreatments(db, '2027-03-15');
expect(treatments[0]).toMatchObject({
prescriptionOnly: false,
medicineUnit: null,
lot: null,
});
});
});
@@ -0,0 +1,101 @@
import type {
ColonyMove,
TreatmentLogSource,
} from '../../../export/treatment-log';
import type { DbClient } from '../../../prisma/db-client';
import { firstTreatmentYear } from '../readiness-limits';
export interface ReadinessTreatments {
treatments: TreatmentLogSource[];
/** The moves of the colonies whose treatment stores no apiary. */
moves: ColonyMove[];
}
/**
* The treatments of the lookback as the sources of the Bestandsbuch (the
* same shape the PDF is built from), with only the columns the rules read.
*/
export async function loadTreatments(
db: DbClient,
today: string,
): Promise<ReadinessTreatments> {
const rows = await db.treatment.findMany({
where: {
date: { gte: new Date(Date.UTC(firstTreatmentYear(today), 0, 1)) },
},
select: {
id: true,
colonyId: true,
date: true,
startDate: true,
endDate: true,
product: true,
amount: true,
batchNumber: true,
supplier: true,
purchaseProof: true,
prescribingVet: true,
withdrawalPeriodDays: true,
apiaryId: true,
doseQuantity: true,
medicineId: true,
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,
},
},
},
orderBy: [{ date: 'asc' }, { id: 'asc' }],
});
const treatments = rows.map((row): TreatmentLogSource => ({
id: row.id,
colonyId: row.colonyId,
colonyCode: row.colony.code,
colonyApiaryId: row.colony.apiaryId,
apiaryId: row.apiaryId,
date: row.date,
startDate: row.startDate,
endDate: row.endDate,
product: row.product,
amount: row.amount,
batchNumber: row.batchNumber,
supplier: row.supplier,
purchaseProof: row.purchaseProof,
prescribingVet: row.prescribingVet,
withdrawalPeriodDays: row.withdrawalPeriodDays,
administeredBy: row.user.name,
medicineId: row.medicineId,
prescriptionOnly: row.medicine?.prescriptionOnly ?? false,
doseQuantity: row.doseQuantity,
medicineUnit: row.medicine?.unit ?? null,
lot: row.medicineLot,
}));
return { treatments, moves: await loadMoves(db, treatments) };
}
async function loadMoves(
db: DbClient,
treatments: readonly TreatmentLogSource[],
): Promise<ColonyMove[]> {
const colonyIds = [
...new Set(
treatments
.filter((treatment) => treatment.apiaryId === null)
.map((treatment) => treatment.colonyId),
),
].sort();
if (colonyIds.length === 0) {
return [];
}
return db.colonyApiaryHistory.findMany({
where: { colonyId: { in: colonyIds } },
select: { colonyId: true, apiaryId: true, movedAt: true },
});
}
@@ -1,3 +1,7 @@
import type {
ColonyMove,
TreatmentLogSource,
} from '../../export/treatment-log';
import type {
ApiaryRestriction,
AuthorityNotice,
@@ -6,7 +10,24 @@ import type {
} from '../../../generated/prisma';
import { berlinToday, dayToDate } from '../../common/dates';
import type { DbClient } from '../../prisma/db-client';
import type { WithdrawalCheckService } from '../../withdrawal/withdrawal-check.service';
import { herdStatusIn, type HerdStatus } from '../herd-status';
import { loadHarvests, type ReadinessHarvest } from './loaders/harvests.loader';
import {
loadLabelTemplates,
type ReadinessLabelTemplate,
} from './loaders/labels.loader';
import {
loadLots,
type ReadinessLot,
type ReadinessMedicine,
} from './loaders/lots.loader';
import { loadTreatments } from './loaders/treatments.loader';
export {
HARVEST_WITHDRAWAL_LOOKBACK_YEARS,
TREATMENT_LOOKBACK_CALENDAR_YEARS,
} from './readiness-limits';
const SITE_SETTINGS_ID = 'singleton';
/** TSK reports of this year and the one before matter for the readiness. */
@@ -21,10 +42,12 @@ export type ReadinessRestriction = ApiaryRestriction & {
apiary: { id: string; name: string };
};
/**
* Everything the rules read, loaded once per evaluation. Task 13 adds the
* fields of the other rule groups.
*/
/** What the context needs besides the database. */
export interface ReadinessContextDeps {
withdrawal: Pick<WithdrawalCheckService, 'conflictsForHarvests'>;
}
/** Everything the rules read, loaded once per evaluation. */
export interface ReadinessContext {
now: Date;
/** The Berlin calendar day of `now` (YYYY-MM-DD). */
@@ -35,6 +58,18 @@ export interface ReadinessContext {
notices: ReadinessNotice[];
/** The restrictions active on `today`. */
restrictions: ReadinessRestriction[];
/** The last 6 calendar years, as the sources of the Bestandsbuch. */
treatments: TreatmentLogSource[];
/** The moves of the colonies whose treatment stores no apiary. */
moves: ColonyMove[];
/** All lots with their rest. */
lots: ReadinessLot[];
/** The active catalogue entries. */
medicines: ReadinessMedicine[];
/** The active label templates, each with the bottling it is checked on. */
templates: ReadinessLabelTemplate[];
/** The harvests of the last 2 years with their withdrawal conflicts. */
harvests: ReadinessHarvest[];
}
/** Before the first save there is no row: every field is empty. */
@@ -74,46 +109,61 @@ const NOTICE_COLUMNS = {
export async function loadReadinessContext(
db: DbClient,
now: Date,
deps: ReadinessContextDeps,
): Promise<ReadinessContext> {
const today = berlinToday(now);
const day = dayToDate(today);
const firstTskYear = Number(today.slice(0, 4)) - TSK_LOOKBACK_YEARS;
const [site, herd, sentRegistration, draftRegistration, tskReports, active] =
await Promise.all([
db.siteSettings.findUnique({ where: { id: SITE_SETTINGS_ID } }),
herdStatusIn(db, now),
// The registrations have their own queries, so an old registration can
// never fall out of the window meant for the TSK reports.
db.authorityNotice.findFirst({
where: { kind: 'REGISTRATION', status: 'SENT' },
orderBy: [{ sentOn: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
select: NOTICE_COLUMNS,
}),
db.authorityNotice.findFirst({
where: { kind: 'REGISTRATION', status: 'DRAFT' },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
select: NOTICE_COLUMNS,
}),
db.authorityNotice.findMany({
where: {
kind: 'TSK_REPORT',
status: { not: 'CANCELLED' },
year: { gte: firstTskYear },
},
select: NOTICE_COLUMNS,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: MAX_TSK_NOTICES,
}),
db.apiaryRestriction.findMany({
where: {
startsOn: { lte: day },
OR: [{ endsOn: null }, { endsOn: { gte: day } }],
},
include: { apiary: { select: { id: true, name: true } } },
orderBy: [{ startsOn: 'asc' }, { id: 'asc' }],
take: MAX_RESTRICTIONS,
}),
]);
const [
site,
herd,
sentRegistration,
draftRegistration,
tskReports,
active,
treatmentData,
lotData,
templates,
harvests,
] = await Promise.all([
db.siteSettings.findUnique({ where: { id: SITE_SETTINGS_ID } }),
herdStatusIn(db, now),
// The registrations have their own queries, so an old registration can
// never fall out of the window meant for the TSK reports.
db.authorityNotice.findFirst({
where: { kind: 'REGISTRATION', status: 'SENT' },
orderBy: [{ sentOn: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
select: NOTICE_COLUMNS,
}),
db.authorityNotice.findFirst({
where: { kind: 'REGISTRATION', status: 'DRAFT' },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
select: NOTICE_COLUMNS,
}),
db.authorityNotice.findMany({
where: {
kind: 'TSK_REPORT',
status: { not: 'CANCELLED' },
year: { gte: firstTskYear },
},
select: NOTICE_COLUMNS,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: MAX_TSK_NOTICES,
}),
db.apiaryRestriction.findMany({
where: {
startsOn: { lte: day },
OR: [{ endsOn: null }, { endsOn: { gte: day } }],
},
include: { apiary: { select: { id: true, name: true } } },
orderBy: [{ startsOn: 'asc' }, { id: 'asc' }],
take: MAX_RESTRICTIONS,
}),
loadTreatments(db, today),
loadLots(db),
loadLabelTemplates(db),
loadHarvests(db, deps.withdrawal, today),
]);
const registrations = [sentRegistration, draftRegistration].filter(
(entry): entry is ReadinessNotice => entry !== null,
);
@@ -125,5 +175,11 @@ export async function loadReadinessContext(
herd,
notices: [...registrations, ...tskReports],
restrictions: active,
treatments: treatmentData.treatments,
moves: treatmentData.moves,
lots: lotData.lots,
medicines: lotData.medicines,
templates,
harvests,
};
}
@@ -0,0 +1,18 @@
/** Honey is sold within about 2 years: older harvests are not checked. */
export const HARVEST_WITHDRAWAL_LOOKBACK_YEARS = 2;
/** The current calendar year and the 5 before it (the book is kept 5 years). */
export const TREATMENT_LOOKBACK_CALENDAR_YEARS = 6;
/** The first year of the treatment lookback, for a Berlin day 'YYYY-MM-DD'. */
export function firstTreatmentYear(today: string): number {
return Number(today.slice(0, 4)) - (TREATMENT_LOOKBACK_CALENDAR_YEARS - 1);
}
/**
* The calendar year a treatment belongs to in the Bestandsbuch: the PDF
* selects its year on the UTC date, the readiness uses the same year so that
* item and PDF cover the same rows.
*/
export function treatmentYearOf(date: Date): number {
return date.getUTCFullYear();
}
@@ -0,0 +1,176 @@
import { pdfText } from '../../../test/utils/pdf-text';
import { renderReadinessReportPdf } from './readiness-report';
import type { ReadinessItem, ReadinessReport } from './readiness.types';
function item(patch: Partial<ReadinessItem> = {}): ReadinessItem {
return {
key: 'holder-data',
area: 'STAMMDATEN',
severity: 'PFLICHT',
subjectId: 'global',
title: 'Daten fehlen',
detail: 'Name fehlt.',
legalBasis: 'Art. 108 VO',
acknowledgeable: false,
fingerprint: 'f'.repeat(64),
subjects: [],
more: 0,
state: 'OPEN',
acknowledgement: null,
...patch,
};
}
function report(items: ReadinessItem[]): ReadinessReport {
const openCount = items.filter((entry) => entry.state === 'OPEN').length;
return {
status: openCount === 0 ? 'READY' : 'OPEN',
generatedAt: '2027-03-15T10:00:00.000Z',
openCount,
items,
};
}
const HOLDER = { name: 'Imker Eins', address: 'Weg 1, 12345 Ort' };
describe('renderReadinessReportPdf', () => {
it('lists status, areas, items with severity word, legal basis and acknowledgement note, user and date', async () => {
const pdf = await renderReadinessReportPdf(
report([
item(),
item({
key: 'harvest-withdrawal',
subjectId: 'h1',
area: 'ERNTE',
severity: 'PRUEFEN',
title: 'Ernte in Wartezeit',
legalBasis: 'Wartezeit laut Behandlung',
acknowledgeable: true,
state: 'ACKNOWLEDGED',
acknowledgement: {
id: 'a1',
note: 'Alles in Ordnung',
userName: 'Imker Zwei',
createdAt: '2027-03-01T08:00:00.000Z',
},
}),
item({
key: 'vet-office',
area: 'STAMMDATEN',
severity: 'PRUEFEN',
title: 'Amt ist eingetragen',
legalBasis: null,
state: 'DONE',
detail: 'Nichts offen.',
}),
]),
HOLDER,
);
const text = pdfText(pdf);
expect(pdf.subarray(0, 5).toString()).toBe('%PDF-');
expect(text).toContain('Prüfbericht');
expect(text).toContain('Imker Eins');
expect(text).toContain('15.03.2027');
expect(text).toContain('Offen');
for (const heading of ['Stammdaten', 'Ernte']) {
expect(text).toContain(heading);
}
expect(text).toContain('Pflicht');
expect(text).toContain('Prüfen');
expect(text).toContain('Daten fehlen');
expect(text).toContain('Art. 108 VO');
expect(text).toContain('Erledigt');
expect(text).toContain('Quittiert');
expect(text).toContain('Alles in Ordnung');
expect(text).toContain('Imker Zwei');
expect(text).toContain('01.03.2027');
});
it('shows the overall status Bereit when nothing is open', async () => {
const pdf = await renderReadinessReportPdf(
report([item({ state: 'DONE', detail: 'Nichts offen.' })]),
HOLDER,
);
const text = pdfText(pdf);
expect(text).toContain('Bereit');
expect(text).not.toContain('FEHLT');
});
it('prints FEHLT for an empty holder', async () => {
const pdf = await renderReadinessReportPdf(report([item()]), {
name: null,
address: ' ',
});
expect(pdfText(pdf)).toContain('FEHLT');
});
it('prints no subject labels, only how many objects an item names', async () => {
const pdf = await renderReadinessReportPdf(
report([
item({
subjects: [{ type: 'LOT', id: 'l1', label: 'Geheimmittel, Packung' }],
more: 2,
}),
]),
HOLDER,
);
const text = pdfText(pdf);
expect(text).not.toContain('Geheimmittel');
expect(text).toContain('3 Objekte');
});
it('words the header without telling to treat items red and keeps a long norm in one piece', async () => {
const pdf = await renderReadinessReportPdf(
report([
item({
key: 'label-template-form',
area: 'ETIKETTEN',
legalBasis: 'LMIV Art. 13; Fertigpackungsverordnung (Annahme)',
}),
]),
HOLDER,
);
const text = pdfText(pdf).replaceAll('­', '');
expect(text).toContain('Jeder offene Punkt ist zu behandeln.');
expect(text).not.toContain('rot');
expect(
text
.split(String.fromCharCode(10))
.some((line) => line.includes('Fertigpackungsverordnung')),
).toBe(true);
});
it('cuts an acknowledgement note at 600 characters with an ellipsis', async () => {
const pdf = await renderReadinessReportPdf(
report([
item({
state: 'ACKNOWLEDGED',
acknowledgeable: true,
acknowledgement: {
id: 'a1',
note: `${'x'.repeat(600)}HINTEN`,
userName: 'Imker',
createdAt: '2027-03-01T08:00:00.000Z',
},
}),
]),
HOLDER,
);
const text = pdfText(pdf)
.replaceAll('­', '')
.replaceAll(String.fromCharCode(10), '');
expect(text).not.toContain('HINTEN');
expect(text).toContain(`${'x'.repeat(600)}${String.fromCharCode(0x85)}`);
});
});
@@ -0,0 +1,154 @@
import PDFDocument from 'pdfkit';
import {
PDF_MARGIN,
drawSection,
drawTable,
formatDay,
orDash,
} from '../../export/pdf-table';
import type {
ReadinessArea,
ReadinessItem,
ReadinessReport,
ReadinessSeverity,
} from './readiness.types';
const MISSING = 'FEHLT';
/** A longer note is cut here and ends with an ellipsis. */
export const MAX_NOTE_CHARS = 600;
const AREA_TITLES: Readonly<Record<ReadinessArea, string>> = {
STAMMDATEN: 'Stammdaten',
BEHOERDEN: 'Behörden',
TIERARZNEIMITTEL: 'Tierarzneimittel',
ETIKETTEN: 'Etiketten',
ERNTE: 'Ernte',
};
const AREA_ORDER: readonly ReadinessArea[] = [
'STAMMDATEN',
'BEHOERDEN',
'TIERARZNEIMITTEL',
'ETIKETTEN',
'ERNTE',
];
const SEVERITY_WORDS: Readonly<Record<ReadinessSeverity, string>> = {
PFLICHT: 'Pflicht',
PRUEFEN: 'Prüfen',
};
const STATE_WORDS: Readonly<Record<ReadinessItem['state'], string>> = {
OPEN: 'Offen',
DONE: 'Erledigt',
ACKNOWLEDGED: 'Quittiert',
};
const COLUMN_WIDTHS = { state: 52, severity: 46, norm: 130, note: 120 };
export interface ReadinessReportHolder {
name: string | null;
address: string | null;
}
function hasText(value: string | null | undefined): value is string {
return value !== null && value !== undefined && value.trim() !== '';
}
function holderLine(holder: ReadinessReportHolder): string {
const parts = [holder.name, holder.address].filter(hasText);
return parts.length > 0 ? parts.join(', ') : MISSING;
}
/** Only how many objects an item names, never their labels. */
function objectsText(item: ReadinessItem): string {
const count = item.subjects.length + item.more;
return count > 0 ? ` (${count} ${count === 1 ? 'Objekt' : 'Objekte'})` : '';
}
function shortNote(note: string): string {
return note.length > MAX_NOTE_CHARS
? `${note.slice(0, MAX_NOTE_CHARS)}…`
: note;
}
function itemRow(item: ReadinessItem): string[] {
const ack = item.acknowledgement;
return [
STATE_WORDS[item.state],
SEVERITY_WORDS[item.severity],
`${item.title}${objectsText(item)}\n${item.detail}`,
orDash(item.legalBasis),
ack
? `${shortNote(ack.note)}\n${ack.userName}, ${formatDay(new Date(ack.createdAt))}`
: '–',
];
}
function drawHeader(
doc: PDFKit.PDFDocument,
report: ReadinessReport,
holder: ReadinessReportHolder,
): void {
doc
.font('Helvetica-Bold')
.fontSize(14)
.text('Prüfbericht zur Prüfbereitschaft', PDF_MARGIN, PDF_MARGIN);
doc.font('Helvetica').fontSize(9);
doc.text(
`Erstellt am ${formatDay(new Date(report.generatedAt))} · Bienenhalter: ${holderLine(holder)}`,
);
doc.moveDown(0.4);
const status =
report.status === 'READY'
? 'Bereit: kein Punkt ist offen.'
: `Offen: ${report.openCount} ${report.openCount === 1 ? 'Punkt ist' : 'Punkte sind'} offen.`;
doc.font('Helvetica-Bold').fontSize(11).text(`Gesamtstatus: ${status}`);
doc.font('Helvetica').fontSize(8);
doc.text(
'Jeder offene Punkt ist zu behandeln. „Pflicht“ muss behoben werden, „Prüfen“ kann nach Prüfung mit Notiz quittiert werden.',
);
}
/** The inspection report: status, then one table per area. */
export function renderReadinessReportPdf(
report: ReadinessReport,
holder: ReadinessReportHolder,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ size: 'A4', margin: PDF_MARGIN });
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
drawHeader(doc, report, holder);
const itemWidth =
doc.page.width -
2 * PDF_MARGIN -
COLUMN_WIDTHS.state -
COLUMN_WIDTHS.severity -
COLUMN_WIDTHS.norm -
COLUMN_WIDTHS.note;
for (const area of AREA_ORDER) {
const items = report.items.filter((item) => item.area === area);
if (items.length === 0) {
continue;
}
drawSection(doc, AREA_TITLES[area]);
drawTable(
doc,
[
{ header: 'Status', width: COLUMN_WIDTHS.state },
{ header: 'Schwere', width: COLUMN_WIDTHS.severity },
{ header: 'Prüfpunkt', width: itemWidth },
{ header: 'Norm', width: COLUMN_WIDTHS.norm },
{ header: 'Quittung', width: COLUMN_WIDTHS.note },
],
items.map(itemRow),
);
}
doc.end();
});
}
@@ -1,5 +1,6 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
import { RequirePermission } from '../../auth/decorators/require-permission.decorator';
import { PermissionResolverService } from '../../auth/permissions/permission-resolver.service';
@@ -7,6 +8,7 @@ import type { AuthenticatedUser } from '../../auth/types/authenticated-user';
import { Idempotent } from '../../common/idempotency/idempotent.decorator';
import { AcknowledgementsService } from './acknowledgements.service';
import { CreateAcknowledgementDto } from './dto/create-acknowledgement.dto';
import { renderReadinessReportPdf } from './readiness-report';
import { ReadinessService } from './readiness.service';
@ApiTags('compliance')
@@ -28,6 +30,27 @@ export class ReadinessController {
);
}
/** The inspection report; the subjects follow the caller's rights. */
@RequirePermission('compliance:read')
@Get('readiness/report.pdf')
async reportPdf(
@CurrentUser() actor: AuthenticatedUser,
@Res() res: Response,
): Promise<void> {
const { report, holder } = await this.readiness.evaluateWithHolder(
new Date(),
await this.permissions.getPermissions(actor.id),
);
const pdf = await renderReadinessReportPdf(report, holder);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Cache-Control', 'no-store');
res.setHeader(
'Content-Disposition',
'attachment; filename="pruefbericht.pdf"',
);
res.send(pdf);
}
@RequirePermission('compliance:read')
@Get('readiness/summary')
summary() {
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { WithdrawalModule } from '../../withdrawal/withdrawal.module';
import { ComplianceModule } from '../compliance.module';
import { READINESS_RULES, READINESS_RULES_TOKEN } from './rules';
import { AcknowledgementsService } from './acknowledgements.service';
@@ -8,7 +9,7 @@ import { ReadinessService } from './readiness.service';
// Not for WorkerModule: the controller injects the permission resolver (to
// hide the subjects a caller may not see). The services themselves do not.
@Module({
imports: [ComplianceModule],
imports: [ComplianceModule, WithdrawalModule],
controllers: [ReadinessController],
providers: [
ReadinessService,
@@ -1,4 +1,5 @@
import { PrismaService } from '../../prisma/prisma.service';
import type { WithdrawalCheckService } from '../../withdrawal/withdrawal-check.service';
import { fingerprintOf } from './fingerprint';
import { loadReadinessContext } from './readiness-context';
import { ReadinessService } from './readiness.service';
@@ -7,7 +8,7 @@ import type {
ReadinessRule,
ReadinessSubject,
} from './readiness.types';
import { readinessContext } from './rules/rule-spec-helpers';
import { readinessContext, siteSettings } from './rules/rule.spec-helper';
jest.mock('./readiness-context', () => ({
loadReadinessContext: jest.fn(),
@@ -72,6 +73,7 @@ function ack(patch: Record<string, unknown> = {}) {
describe('ReadinessService', () => {
let service: ReadinessService;
let findAcknowledgements: jest.Mock;
const withdrawal = {} as WithdrawalCheckService;
beforeEach(() => {
rules.splice(0, rules.length);
@@ -80,7 +82,7 @@ describe('ReadinessService', () => {
const prisma = {
complianceAcknowledgement: { findMany: findAcknowledgements },
} as unknown as PrismaService;
service = new ReadinessService(prisma, rules);
service = new ReadinessService(prisma, rules, withdrawal);
});
it('is READY when no rule has an open finding', async () => {
@@ -190,6 +192,7 @@ describe('ReadinessService', () => {
});
rules.splice(2, 1);
service.invalidateSummary();
expect(await service.summary(NOW)).toEqual({
status: 'OPEN',
@@ -198,6 +201,7 @@ describe('ReadinessService', () => {
});
rules.splice(1, 1);
service.invalidateSummary();
expect(await service.summary(NOW)).toEqual({
status: 'READY',
@@ -292,4 +296,105 @@ describe('ReadinessService', () => {
['2027', 'Regel'],
]);
});
it('hides subject labels of the medicine area without veterinary-medicines:read and keeps the count', async () => {
rules.push(
fakeRule({
key: 'lot-gaps',
area: 'TIERARZNEIMITTEL',
subjectPermission: 'veterinary-medicines:read',
findings: [
finding({
subjects: [
{ type: 'LOT', id: 'l1', label: 'Geheimmittel, Packung' },
{ type: 'LOT', id: 'l2', label: 'Geheimmittel, Packung' },
],
}),
],
}),
);
const hidden = (
await service.evaluate(
NOW,
new Set(['compliance:read', 'treatments:read']),
)
).items[0];
const shown = (
await service.evaluate(NOW, new Set(['veterinary-medicines:read']))
).items[0];
expect(hidden.subjects).toEqual([]);
expect(hidden.more).toBe(2);
expect(JSON.stringify(hidden)).not.toContain('Geheimmittel');
expect(shown.subjects).toHaveLength(2);
});
it('hands the loader the withdrawal service and gives the holder with the report', async () => {
rules.push(fakeRule({ key: 'a' }));
loadContextMock.mockResolvedValue(
readinessContext({
site: siteSettings({ holderName: 'Imker', holderAddress: null }),
}),
);
const result = await service.evaluateWithHolder(NOW, new Set());
expect(loadContextMock).toHaveBeenCalledWith(expect.anything(), NOW, {
withdrawal,
});
expect(result.holder).toEqual({ name: 'Imker', address: null });
expect(result.report.status).toBe('READY');
});
it('serves the summary from memory within 60 s and loads the context once', async () => {
rules.push(fakeRule({ key: 'a', findings: [finding()] }));
const first = await service.summary(NOW);
const second = await service.summary(new Date(NOW.getTime() + 59_000));
expect(second).toBe(first);
expect(loadContextMock).toHaveBeenCalledTimes(1);
});
it('recomputes the summary after the TTL and after invalidateSummary', async () => {
rules.push(fakeRule({ key: 'a', findings: [finding()] }));
await service.summary(NOW);
await service.summary(new Date(NOW.getTime() + 61_000));
expect(loadContextMock).toHaveBeenCalledTimes(2);
service.invalidateSummary();
await service.summary(new Date(NOW.getTime() + 62_000));
expect(loadContextMock).toHaveBeenCalledTimes(3);
});
it('never caches evaluate', async () => {
rules.push(fakeRule({ key: 'a' }));
await service.evaluate(NOW, new Set());
await service.evaluate(NOW, new Set());
expect(loadContextMock).toHaveBeenCalledTimes(2);
});
it('asks for the acknowledgements by item key and matches the fingerprints in memory', async () => {
rules.push(
fakeRule({ key: 'fake', acknowledgeable: true, findings: [finding()] }),
);
findAcknowledgements.mockResolvedValue([
ack(),
ack({ id: 'old', fingerprint: 'x'.repeat(64) }),
]);
const report = await service.evaluate(NOW, new Set());
expect(
(findAcknowledgements.mock.calls as unknown[][])[0][0],
).toMatchObject({
where: { revokedAt: null, itemKey: { in: ['fake'] } },
});
expect(report.items[0]).toMatchObject({
state: 'ACKNOWLEDGED',
acknowledgement: { id: 'ack1' },
});
});
});
@@ -1,7 +1,11 @@
import { Inject, Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { WithdrawalCheckService } from '../../withdrawal/withdrawal-check.service';
import { fingerprintOf } from './fingerprint';
import { loadReadinessContext } from './readiness-context';
import {
loadReadinessContext,
type ReadinessContext,
} from './readiness-context';
import {
GLOBAL_SUBJECT_ID,
MAX_SUBJECTS,
@@ -17,6 +21,15 @@ import { READINESS_RULES_TOKEN } from './rules';
const DONE_DETAIL = 'Nichts offen.';
/** How long `summary` may be served from memory. */
export const SUMMARY_TTL_MS = 60_000;
/** Name and address of the beekeeper as stored (null when empty). */
export interface ReadinessHolder {
name: string | null;
address: string | null;
}
interface AcknowledgementRow {
id: string;
itemKey: string;
@@ -125,13 +138,45 @@ export class ReadinessService {
private readonly prisma: PrismaService,
@Inject(READINESS_RULES_TOKEN)
private readonly rules: readonly ReadinessRule[],
private readonly withdrawal: WithdrawalCheckService,
) {}
// The dashboard asks often: the summary is kept per process for a minute.
// Changes made by another process show up after at most SUMMARY_TTL_MS.
private summaryCache: { at: number; value: ReadinessSummary } | null = null;
async evaluate(
now: Date,
permissions: ReadonlySet<string>,
): Promise<ReadinessReport> {
const ctx = await loadReadinessContext(this.prisma, now);
return this.evaluateIn(await this.loadContext(now), permissions);
}
/** The report and the holder it is printed for, from one loaded context. */
async evaluateWithHolder(
now: Date,
permissions: ReadonlySet<string>,
): Promise<{ report: ReadinessReport; holder: ReadinessHolder }> {
const ctx = await this.loadContext(now);
return {
report: await this.evaluateIn(ctx, permissions),
holder: {
name: ctx.site.holderName,
address: ctx.site.holderAddress,
},
};
}
private loadContext(now: Date): Promise<ReadinessContext> {
return loadReadinessContext(this.prisma, now, {
withdrawal: this.withdrawal,
});
}
private async evaluateIn(
ctx: ReadinessContext,
permissions: ReadonlySet<string>,
): Promise<ReadinessReport> {
const drafts = this.rules.flatMap((rule) => {
const findings = rule.evaluate(ctx);
return findings.length === 0
@@ -148,7 +193,7 @@ export class ReadinessService {
const openCount = items.filter(isOpen).length;
return {
status: openCount === 0 ? 'READY' : 'OPEN',
generatedAt: now.toISOString(),
generatedAt: ctx.now.toISOString(),
openCount,
items,
};
@@ -156,14 +201,25 @@ export class ReadinessService {
/** No names in it: the subjects stay hidden. */
async summary(now: Date): Promise<ReadinessSummary> {
const cached = this.summaryCache;
if (cached && now.getTime() - cached.at < SUMMARY_TTL_MS) {
return cached.value;
}
const report = await this.evaluate(now, new Set());
const open = report.items.filter(isOpen);
const first = open.find((item) => item.severity === 'PFLICHT') ?? open[0];
return {
const value: ReadinessSummary = {
status: report.status,
openCount: report.openCount,
firstItemKey: first?.key ?? null,
};
this.summaryCache = { at: now.getTime(), value };
return value;
}
/** Forgets the cached summary (an acknowledgement changed in this process). */
invalidateSummary(): void {
this.summaryCache = null;
}
// The valid acknowledgement of each open item that can be acknowledged:
@@ -182,11 +238,7 @@ export class ReadinessService {
await this.prisma.complianceAcknowledgement.findMany({
where: {
revokedAt: null,
OR: candidates.map((item) => ({
itemKey: item.key,
subjectId: item.subjectId,
fingerprint: item.fingerprint,
})),
itemKey: { in: [...new Set(candidates.map((item) => item.key))] },
},
select: {
id: true,
@@ -1,5 +1,5 @@
import { foodBusinessDataRule } from './food-business-data.rule';
import { readinessContext, siteSettings } from './rule-spec-helpers';
import { readinessContext, siteSettings } from './rule.spec-helper';
describe('food-business-data rule', () => {
it('is open when the name is empty', () => {
@@ -0,0 +1,140 @@
import { berlinDayOf } from '../../../common/dates';
import {
isInWindow,
withdrawalWindowOf,
} from '../../../treatments/withdrawal-window';
import { withdrawalRangeText } from '../../../withdrawal/withdrawal-text';
import { harvestWithdrawalRule } from './harvest-withdrawal.rule';
import {
conflict,
readinessContext,
readinessHarvest,
} from './rule.spec-helper';
describe('harvest-withdrawal rule', () => {
it('opens one item per harvest with a conflict, subjectId is the harvest', () => {
const findings = harvestWithdrawalRule.evaluate(
readinessContext({
harvests: [
readinessHarvest({ id: 'h1', conflicts: [conflict()] }),
readinessHarvest({ id: 'h2' }),
readinessHarvest({
id: 'h3',
day: '2027-03-02',
conflicts: [
conflict({ harvestId: 'h3', harvestedOn: '2027-03-02' }),
],
}),
],
}),
);
expect(findings.map((finding) => finding.subjectId)).toEqual(['h1', 'h3']);
expect(findings[0].subjects).toEqual([
expect.objectContaining({
type: 'HARVEST',
id: 'h1',
label: expect.stringContaining('01.03.2027') as unknown,
}),
]);
});
it('is done for a harvest without a conflict', () => {
expect(
harvestWithdrawalRule.evaluate(
readinessContext({ harvests: [readinessHarvest()] }),
),
).toEqual([]);
});
it('words the window end with the shared helper and uses the longest window of the harvest', () => {
const [finding] = harvestWithdrawalRule.evaluate(
readinessContext({
today: '2027-03-15',
harvests: [
readinessHarvest({
conflicts: [
conflict({ colonyId: 'c1', untilDay: '2027-03-10' }),
conflict({ colonyId: 'c2', untilDay: '2027-03-20' }),
],
}),
],
}),
);
expect(finding.detail).toContain(
withdrawalRangeText('2027-03-20', '2027-03-15'),
);
});
it('agrees with the window helper: inside the window is open, the first free day is clean', () => {
const treatment = {
id: 't1',
colonyId: 'c1',
product: 'Mittel Eins',
date: new Date('2027-02-01T00:00:00.000Z'),
startDate: null,
endDate: new Date('2027-02-05T00:00:00.000Z'),
withdrawalPeriodDays: 10,
};
const window = withdrawalWindowOf(treatment);
if (window === null) {
throw new Error('window expected');
}
// What the service reports for a harvest on this day in this window.
const harvestWith = (day: string) =>
readinessHarvest({
id: `h-${day}`,
day,
conflicts: isInWindow(window, berlinDayOf(new Date(`${day}T00:00:00Z`)))
? [
conflict({
harvestId: `h-${day}`,
harvestedOn: day,
treatmentId: window.treatmentId,
untilDay: window.untilDay,
}),
]
: [],
});
const lastBlocked = '2027-02-14';
const firstFree = window.untilDay;
const findings = harvestWithdrawalRule.evaluate(
readinessContext({
harvests: [harvestWith(lastBlocked), harvestWith(firstFree)],
}),
);
expect(firstFree).toBe('2027-02-15');
expect(findings.map((finding) => finding.subjectId)).toEqual([
`h-${lastBlocked}`,
]);
});
it('keeps product names out of the texts, is acknowledgeable and the fingerprint follows the windows', () => {
const open = (untilDay: string) =>
harvestWithdrawalRule.evaluate(
readinessContext({
harvests: [readinessHarvest({ conflicts: [conflict({ untilDay })] })],
}),
)[0];
expect(
JSON.stringify([open('2027-03-10').title, open('2027-03-10').detail]),
).not.toContain('Mittel Eins');
expect(open('2027-03-10').fingerprintParts).toEqual(
open('2027-03-10').fingerprintParts,
);
expect(open('2027-03-10').fingerprintParts).not.toEqual(
open('2027-03-12').fingerprintParts,
);
expect(harvestWithdrawalRule).toMatchObject({
key: 'harvest-withdrawal',
area: 'ERNTE',
severity: 'PRUEFEN',
acknowledgeable: true,
subjectPermission: 'treatments:read',
});
});
});
@@ -0,0 +1,51 @@
import { formatDayDe } from '../../../common/format-day';
import { withdrawalRangeText } from '../../../withdrawal/withdrawal-text';
import type { ReadinessFinding, ReadinessRule } from '../readiness.types';
export const harvestWithdrawalRule: ReadinessRule = {
key: 'harvest-withdrawal',
area: 'ERNTE',
severity: 'PRUEFEN',
title: 'Eine Ernte fiel in die Wartezeit eines Volks.',
legalBasis: 'Wartezeit laut Behandlung (Annahme 10)',
acknowledgeable: true,
subjectPermission: 'treatments:read',
evaluate: ({ harvests, today }): ReadinessFinding[] =>
harvests
.filter((harvest) => harvest.conflicts.length > 0)
.map((harvest) => {
// The window that blocks the longest, worded like everywhere else.
const untilDay = harvest.conflicts
.map((conflict) => conflict.untilDay)
.sort()
.at(-1) as string;
const colonies = new Set(
harvest.conflicts.map((conflict) => conflict.colonyId),
).size;
return {
subjectId: harvest.id,
detail: `${colonies === 1 ? 'Ein Volk' : `${colonies} Völker`} in der Wartezeit: ${withdrawalRangeText(untilDay, today)}.`,
fingerprintParts: {
harvestId: harvest.id,
conflicts: harvest.conflicts
.map((conflict) => ({
colonyId: conflict.colonyId,
treatmentId: conflict.treatmentId,
untilDay: conflict.untilDay,
}))
.sort((a, b) =>
`${a.colonyId}:${a.treatmentId}`.localeCompare(
`${b.colonyId}:${b.treatmentId}`,
),
),
},
subjects: [
{
type: 'HARVEST',
id: harvest.id,
label: `Ernte vom ${formatDayDe(harvest.day, true)}`,
},
],
};
}),
};
@@ -6,7 +6,7 @@ import {
herdStatus,
notice,
readinessContext,
} from './rule-spec-helpers';
} from './rule.spec-helper';
function deviation(patch: Partial<HerdDeviation> = {}): HerdDeviation {
return {
@@ -1,5 +1,5 @@
import { holderDataRule } from './holder-data.rule';
import { readinessContext, siteSettings } from './rule-spec-helpers';
import { readinessContext, siteSettings } from './rule.spec-helper';
describe('holder-data rule', () => {
it('is open when the holder name is empty', () => {
@@ -1,12 +1,20 @@
import { READINESS_RULES } from './index';
import {
complianceSettings,
conflict,
completeTemplate,
herdStatus,
readinessHarvest,
readinessLot,
readinessMedicine,
treatmentLotRef,
treatmentSource,
notice,
readinessContext,
restriction,
siteSettings,
} from './rule-spec-helpers';
colonyMove,
} from './rule.spec-helper';
describe('READINESS_RULES', () => {
it('has unique keys', () => {
@@ -27,6 +35,38 @@ describe('READINESS_RULES', () => {
restriction({ id: 'r1' }),
restriction({ id: 'r2', apiary: { id: 'a2', name: 'Zwei' } }),
],
treatments: [
treatmentSource({ id: 't1', supplier: null, apiaryId: null }),
treatmentSource({
id: 't2',
date: new Date('2026-02-01T00:00:00.000Z'),
amount: null,
lot: treatmentLotRef({ id: 'l1', supplier: null }),
}),
],
moves: [colonyMove()],
lots: [
readinessLot({
id: 'l1',
supplier: null,
prescriptionOnly: true,
expiresOn: '2020-01-01',
remaining: -1,
}),
readinessLot({ id: 'l2', expiresOn: '2020-01-01', remaining: 2 }),
],
medicines: [readinessMedicine({ withdrawalPeriodDays: null })],
templates: [
completeTemplate({ elements: [] }),
completeTemplate({ id: 'tpl2' }),
],
harvests: [
readinessHarvest({ id: 'h1', conflicts: [conflict()] }),
readinessHarvest({
id: 'h2',
conflicts: [conflict({ harvestId: 'h2' })],
}),
],
});
for (const rule of READINESS_RULES) {
@@ -38,4 +78,106 @@ describe('READINESS_RULES', () => {
}).toEqual({ key: rule.key, unique: true });
}
});
it('lists the 18 checks of the spec table with area, severity, acknowledgement and subject permission', () => {
expect(
READINESS_RULES.map((rule) => [
rule.key,
rule.area,
rule.severity,
rule.acknowledgeable,
rule.subjectPermission ?? null,
]),
).toEqual([
['holder-data', 'STAMMDATEN', 'PFLICHT', false, null],
['food-business-data', 'STAMMDATEN', 'PFLICHT', false, null],
['vet-office', 'STAMMDATEN', 'PRUEFEN', false, null],
['registration', 'BEHOERDEN', 'PFLICHT', false, null],
['herd-change', 'BEHOERDEN', 'PFLICHT', false, 'apiaries:read'],
['tsk-report', 'BEHOERDEN', 'PFLICHT', false, null],
['restriction-active', 'BEHOERDEN', 'PFLICHT', false, 'apiaries:read'],
[
'treatment-log-gaps',
'TIERARZNEIMITTEL',
'PFLICHT',
false,
'treatments:read',
],
[
'treatment-location-uncertain',
'TIERARZNEIMITTEL',
'PRUEFEN',
false,
'treatments:read',
],
[
'lot-gaps',
'TIERARZNEIMITTEL',
'PFLICHT',
false,
'veterinary-medicines:read',
],
[
'lot-prescription',
'TIERARZNEIMITTEL',
'PFLICHT',
false,
'veterinary-medicines:read',
],
[
'medicine-withdrawal-missing',
'TIERARZNEIMITTEL',
'PRUEFEN',
false,
'veterinary-medicines:read',
],
[
'lot-expired-stock',
'TIERARZNEIMITTEL',
'PRUEFEN',
true,
'veterinary-medicines:read',
],
[
'lot-negative',
'TIERARZNEIMITTEL',
'PRUEFEN',
false,
'veterinary-medicines:read',
],
[
'treatment-expired-lot',
'TIERARZNEIMITTEL',
'PRUEFEN',
true,
'veterinary-medicines:read',
],
['label-template-gaps', 'ETIKETTEN', 'PFLICHT', false, null],
['label-template-form', 'ETIKETTEN', 'PRUEFEN', true, null],
['harvest-withdrawal', 'ERNTE', 'PRUEFEN', true, 'treatments:read'],
]);
});
it('keeps medicine and product names out of every title and detail of the medicine rules', () => {
const ctx = readinessContext({
treatments: [treatmentSource({ id: 't1', supplier: null })],
lots: [
readinessLot({
supplier: null,
remaining: -1,
expiresOn: '2020-01-01',
}),
],
medicines: [readinessMedicine({ withdrawalPeriodDays: null })],
harvests: [readinessHarvest({ conflicts: [conflict()] })],
});
for (const rule of READINESS_RULES) {
for (const finding of rule.evaluate(ctx)) {
expect(
JSON.stringify([rule.title, finding.title, finding.detail]),
).not.toContain('Mittel Eins');
}
}
});
});
@@ -1,13 +1,24 @@
import type { ReadinessRule } from '../readiness.types';
import { foodBusinessDataRule } from './food-business-data.rule';
import { herdChangeRule } from './herd-change.rule';
import { harvestWithdrawalRule } from './harvest-withdrawal.rule';
import { holderDataRule } from './holder-data.rule';
import { labelTemplateFormRule } from './label-template-form.rule';
import { labelTemplateGapsRule } from './label-template-gaps.rule';
import { lotExpiredStockRule } from './lot-expired-stock.rule';
import { lotGapsRule } from './lot-gaps.rule';
import { lotNegativeRule } from './lot-negative.rule';
import { lotPrescriptionRule } from './lot-prescription.rule';
import { medicineWithdrawalMissingRule } from './medicine-withdrawal-missing.rule';
import { registrationRule } from './registration.rule';
import { restrictionActiveRule } from './restriction-active.rule';
import { treatmentExpiredLotRule } from './treatment-expired-lot.rule';
import { treatmentLocationUncertainRule } from './treatment-location-uncertain.rule';
import { treatmentLogGapsRule } from './treatment-log-gaps.rule';
import { tskReportRule } from './tsk-report.rule';
import { vetOfficeRule } from './vet-office.rule';
/** In the order of the report; Task 13 appends the other rules. */
/** The 18 checks, in the order of the report. */
export const READINESS_RULES: readonly ReadinessRule[] = [
holderDataRule,
foodBusinessDataRule,
@@ -16,6 +27,17 @@ export const READINESS_RULES: readonly ReadinessRule[] = [
herdChangeRule,
tskReportRule,
restrictionActiveRule,
treatmentLogGapsRule,
treatmentLocationUncertainRule,
lotGapsRule,
lotPrescriptionRule,
medicineWithdrawalMissingRule,
lotExpiredStockRule,
lotNegativeRule,
treatmentExpiredLotRule,
labelTemplateGapsRule,
labelTemplateFormRule,
harvestWithdrawalRule,
];
/** Injection token: the module provides the list, tests may override it. */
@@ -0,0 +1,47 @@
import {
parseDigitRules,
expectedParticulars,
} from '../../../label-templates/label-check.loader';
import { pdfTextMeasurer } from '../../../label-templates/font-metrics';
import {
checkLabel,
type LabelCheckResult,
} from '../../../label-templates/label-particulars';
import { buildPlaceholderValues } from '../../../label-templates/label-templates.service';
import type { ReadinessLabelTemplate } from '../loaders/labels.loader';
import type { ReadinessContext } from '../readiness-context';
const cache = new WeakMap<ReadinessContext, Map<string, LabelCheckResult>>();
/**
* The print check of one template against its target bottling: the very
* `checkLabel` the print runs, fed with the master data of the context and the
* digit rules of the compliance settings. Both label rules read it; the
* result is kept per context so a template is measured once.
*
* Digit rules that cannot be read give no rules: the height of the net
* quantity is then not judged (the print refuses such settings with 409).
*/
export function labelCheckOf(
ctx: ReadinessContext,
template: ReadinessLabelTemplate,
): LabelCheckResult {
const known = cache.get(ctx) ?? new Map<string, LabelCheckResult>();
cache.set(ctx, known);
const found = known.get(template.id);
if (found) {
return found;
}
const { bottling, sample } = template.target;
const result = checkLabel({
elements: template.elements,
minXHeightMm: template.minXHeightMm,
digitRules: parseDigitRules(ctx.settings.netQuantityDigitRules) ?? [],
values: buildPlaceholderValues(bottling, ctx.site),
expected: expectedParticulars(bottling, ctx.site),
sample,
measurer: pdfTextMeasurer(),
});
known.set(template.id, result);
return result;
}
@@ -0,0 +1,93 @@
import { labelTemplateFormRule } from './label-template-form.rule';
import {
complianceSettings,
completeTemplate,
readinessContext,
SPEC_DIGIT_RULES,
} from './rule.spec-helper';
function run(
template = completeTemplate(),
digitRules: unknown = SPEC_DIGIT_RULES,
) {
return labelTemplateFormRule.evaluate(
readinessContext({
templates: [template],
settings: complianceSettings({
netQuantityDigitRules: digitRules as never,
}),
}),
);
}
function smallNetQuantity(fontSize: number) {
const base = completeTemplate();
return {
...base,
elements: base.elements.map((element) =>
element.id === 'e2' ? { ...element, fontSize } : element,
),
};
}
describe('label-template-form rule', () => {
it('is done for a template without warnings', () => {
expect(run()).toEqual([]);
});
it('lists a template whose net quantity is set too small for the digit rules of the settings', () => {
const findings = run(smallNetQuantity(10));
expect(findings).toHaveLength(1);
expect(findings[0].subjectId).toBe('tpl1');
expect(findings[0].subjects).toEqual([
expect.objectContaining({
type: 'LABEL_TEMPLATE',
id: 'tpl1',
missing: [expect.stringContaining('Füllmenge')],
}),
]);
});
it('takes the digit rules only from the compliance settings', () => {
expect(run(smallNetQuantity(10), [{ maxGrams: null, minMm: 1 }])).toEqual(
[],
);
expect(run(smallNetQuantity(10), 'broken')).toEqual([]);
});
it('reports warnings only, a gap alone gives no finding', () => {
const base = completeTemplate();
const noOrigin = {
...base,
elements: base.elements.filter((element) => element.id !== 'e5'),
};
expect(run(noOrigin)).toEqual([]);
});
it('is acknowledgeable and has a new fingerprint when the warning codes change', () => {
const small = run(smallNetQuantity(10))[0];
const again = run(smallNetQuantity(10))[0];
const smallLot = completeTemplate();
const withTwoWarnings = run({
...smallLot,
elements: smallLot.elements.map((element) =>
element.id === 'e2' || element.id === 'e4'
? { ...element, fontSize: 4 }
: element,
),
})[0];
expect(labelTemplateFormRule).toMatchObject({
key: 'label-template-form',
area: 'ETIKETTEN',
severity: 'PRUEFEN',
acknowledgeable: true,
});
expect(again.fingerprintParts).toEqual(small.fingerprintParts);
expect(withTwoWarnings.fingerprintParts).not.toEqual(
small.fingerprintParts,
);
});
});
@@ -0,0 +1,40 @@
import type { ReadinessFinding, ReadinessRule } from '../readiness.types';
import { labelCheckOf } from './label-checks';
export const labelTemplateFormRule: ReadinessRule = {
key: 'label-template-form',
area: 'ETIKETTEN',
severity: 'PRUEFEN',
title: 'Ein Etikett hat Hinweise zur Gestaltung.',
legalBasis: 'LMIV Art. 13; Fertigpackungsverordnung (Annahme)',
acknowledgeable: true,
evaluate: (ctx): ReadinessFinding[] =>
ctx.templates.flatMap((template): ReadinessFinding[] => {
const { warnings } = labelCheckOf(ctx, template);
if (warnings.length === 0) {
return [];
}
return [
{
subjectId: template.id,
detail:
warnings.length === 1
? 'Das Etikett hat einen Hinweis zur Gestaltung. Im Designer ansehen.'
: `Das Etikett hat ${warnings.length} Hinweise zur Gestaltung. Im Designer ansehen.`,
// The codes: another warning (or none of the old) reopens it.
fingerprintParts: {
templateId: template.id,
warningCodes: warnings.map((warning) => warning.code).sort(),
},
subjects: [
{
type: 'LABEL_TEMPLATE',
id: template.id,
label: template.name,
missing: warnings.map((warning) => warning.title),
},
],
},
];
}),
};
@@ -0,0 +1,102 @@
import { labelTemplateGapsRule } from './label-template-gaps.rule';
import {
completeTemplate,
readinessContext,
textElement,
} from './rule.spec-helper';
function run(template = completeTemplate()) {
return labelTemplateGapsRule.evaluate(
readinessContext({ templates: [template] }),
);
}
describe('label-template-gaps rule', () => {
it('is done for a template that carries every mandatory particular', () => {
expect(run()).toEqual([]);
expect(labelTemplateGapsRule.evaluate(readinessContext())).toEqual([]);
});
it('opens one item per template with a gap, subjectId is the template', () => {
const base = completeTemplate();
const withoutOrigin = {
...base,
elements: base.elements.filter((element) => element.id !== 'e5'),
};
const findings = run(withoutOrigin);
expect(findings).toHaveLength(1);
expect(findings[0].subjectId).toBe('tpl1');
expect(findings[0].subjects).toEqual([
expect.objectContaining({
type: 'LABEL_TEMPLATE',
id: 'tpl1',
label: 'Vorlage Eins',
missing: [expect.stringContaining('Herkunft')],
}),
]);
});
it('uses the target bottling of the template (the newest with stock, else the newest, else the sample)', () => {
const base = completeTemplate();
const fixedBestBefore = base.elements.map((element) =>
element.id === 'e3'
? textElement({
...element,
placeholderKey: null,
text: 'Mindestens haltbar bis 10.01.2029',
})
: element,
);
const matching = { ...base, elements: fixedBestBefore };
const otherBottling = {
...matching,
target: {
...matching.target,
bottling: {
...matching.target.bottling,
bestBeforeDate: new Date('2030-06-01T00:00:00.000Z'),
},
},
};
expect(run(matching)).toEqual([]);
const findings = run(otherBottling);
expect(findings[0].subjects[0].missing).toEqual([
expect.stringContaining('Mindesthaltbarkeit'),
]);
});
it('names no template or master data value in the texts, the fingerprint follows the gap codes', () => {
const base = completeTemplate();
const noOrigin = {
...base,
elements: base.elements.filter((element) => element.id !== 'e5'),
};
const noOriginNoLot = {
...base,
elements: base.elements.filter(
(element) => element.id !== 'e5' && element.id !== 'e4',
),
};
const [one] = run(noOrigin);
const [same] = run(noOrigin);
const [two] = run(noOriginNoLot);
expect(JSON.stringify([one.title, one.detail])).not.toContain(
'Vorlage Eins',
);
expect(same.fingerprintParts).toEqual(one.fingerprintParts);
expect(two.fingerprintParts).not.toEqual(one.fingerprintParts);
});
it('is a Pflicht rule of the label area that cannot be acknowledged', () => {
expect(labelTemplateGapsRule).toMatchObject({
key: 'label-template-gaps',
area: 'ETIKETTEN',
severity: 'PFLICHT',
acknowledgeable: false,
});
});
});
@@ -0,0 +1,39 @@
import type { ReadinessFinding, ReadinessRule } from '../readiness.types';
import { labelCheckOf } from './label-checks';
export const labelTemplateGapsRule: ReadinessRule = {
key: 'label-template-gaps',
area: 'ETIKETTEN',
severity: 'PFLICHT',
title: 'Auf einem Etikett fehlen Pflichtangaben.',
legalBasis: 'LMIV, HonigV, LKV',
acknowledgeable: false,
evaluate: (ctx): ReadinessFinding[] =>
ctx.templates.flatMap((template): ReadinessFinding[] => {
const { gaps } = labelCheckOf(ctx, template);
if (gaps.length === 0) {
return [];
}
return [
{
subjectId: template.id,
detail:
gaps.length === 1
? 'Dem Etikett fehlt eine Pflichtangabe. Im Designer ergänzen.'
: `Dem Etikett fehlen ${gaps.length} Pflichtangaben. Im Designer ergänzen.`,
fingerprintParts: {
templateId: template.id,
gapCodes: gaps.map((gap) => gap.code).sort(),
},
subjects: [
{
type: 'LABEL_TEMPLATE',
id: template.id,
label: template.name,
missing: gaps.map((gap) => gap.title),
},
],
},
];
}),
};
@@ -0,0 +1,65 @@
import { lotExpiredStockRule } from './lot-expired-stock.rule';
import { readinessContext, readinessLot } from './rule.spec-helper';
const TODAY = '2027-03-15';
function run(lots: ReturnType<typeof readinessLot>[]) {
return lotExpiredStockRule.evaluate(readinessContext({ today: TODAY, lots }));
}
describe('lot-expired-stock rule', () => {
it('opens one item per expired lot with a rest, subjectId is the lot', () => {
const findings = run([
readinessLot({ id: 'l1', expiresOn: '2027-03-14', remaining: 5 }),
readinessLot({ id: 'l2', expiresOn: '2026-01-01', remaining: 0.5 }),
]);
expect(findings.map((finding) => finding.subjectId)).toEqual(['l1', 'l2']);
expect(findings[0].subjects).toEqual([
expect.objectContaining({ type: 'LOT', id: 'l1' }),
]);
});
it('is done for a lot that expires today or later, one without a rest, one without expiry', () => {
expect(
run([
readinessLot({ id: 'a', expiresOn: TODAY, remaining: 5 }),
readinessLot({ id: 'b', expiresOn: '2020-01-01', remaining: 0 }),
readinessLot({ id: 'c', expiresOn: '2020-01-01', remaining: -2 }),
readinessLot({ id: 'd', expiresOn: null, remaining: 5 }),
]),
).toEqual([]);
});
it('is acknowledgeable per lot and gets a new fingerprint when the rest changes', () => {
const [before] = run([
readinessLot({ id: 'l1', expiresOn: '2027-01-01', remaining: 5 }),
]);
const [same] = run([
readinessLot({ id: 'l1', expiresOn: '2027-01-01', remaining: 5 }),
]);
const [changed] = run([
readinessLot({ id: 'l1', expiresOn: '2027-01-01', remaining: 3 }),
]);
expect(lotExpiredStockRule.acknowledgeable).toBe(true);
expect(same.fingerprintParts).toEqual(before.fingerprintParts);
expect(changed.fingerprintParts).not.toEqual(before.fingerprintParts);
});
it('keeps names out of the texts', () => {
const [finding] = run([
readinessLot({ expiresOn: '2027-01-01', remaining: 5 }),
]);
expect(JSON.stringify([finding.title, finding.detail])).not.toContain(
'Mittel Eins',
);
expect(lotExpiredStockRule).toMatchObject({
key: 'lot-expired-stock',
area: 'TIERARZNEIMITTEL',
severity: 'PRUEFEN',
subjectPermission: 'veterinary-medicines:read',
});
});
});
@@ -0,0 +1,31 @@
import type { ReadinessFinding, ReadinessRule } from '../readiness.types';
import { lotSubject } from './medicine-rule-helpers';
export const lotExpiredStockRule: ReadinessRule = {
key: 'lot-expired-stock',
area: 'TIERARZNEIMITTEL',
severity: 'PRUEFEN',
title: 'Eine abgelaufene Packung hat noch Bestand.',
legalBasis: null,
acknowledgeable: true,
subjectPermission: 'veterinary-medicines:read',
evaluate: ({ lots, today }): ReadinessFinding[] =>
lots
// Expired from the day after `expiresOn`; a negative rest is another item.
.filter(
(lot) =>
lot.expiresOn !== null && lot.expiresOn < today && lot.remaining > 0,
)
.map((lot) => ({
subjectId: lot.id,
detail:
'Die Packung ist abgelaufen und hat noch Bestand. Die Entsorgung buchen.',
// The rest is part of it: a disposal or a count changes it.
fingerprintParts: {
lotId: lot.id,
expiresOn: lot.expiresOn,
remaining: lot.remaining,
},
subjects: [lotSubject(lot)],
})),
};
@@ -0,0 +1,59 @@
import { lotGapsRule } from './lot-gaps.rule';
import { readinessContext, readinessLot } from './rule.spec-helper';
describe('lot-gaps rule', () => {
it('lists a lot without supplier or proof with the missing fields', () => {
const findings = lotGapsRule.evaluate(
readinessContext({
lots: [
readinessLot({ id: 'l1', supplier: null }),
readinessLot({ id: 'l2', purchaseProof: ' ' }),
readinessLot({ id: 'l3', supplier: null, purchaseProof: null }),
readinessLot({ id: 'l4' }),
],
}),
);
expect(findings).toHaveLength(1);
expect(findings[0].subjectId).toBe('global');
expect(findings[0].subjects.map((s) => [s.id, s.missing, s.type])).toEqual([
['l1', ['Lieferant'], 'LOT'],
['l2', ['Beleg'], 'LOT'],
['l3', ['Lieferant', 'Beleg'], 'LOT'],
]);
});
it('is done when every lot has supplier and proof', () => {
expect(
lotGapsRule.evaluate(readinessContext({ lots: [readinessLot()] })),
).toEqual([]);
expect(lotGapsRule.evaluate(readinessContext())).toEqual([]);
});
it('keeps the medicine name in the gated subject label only and the fingerprint follows the gaps', () => {
const first = lotGapsRule.evaluate(
readinessContext({ lots: [readinessLot({ supplier: null })] }),
)[0];
const second = lotGapsRule.evaluate(
readinessContext({
lots: [readinessLot({ supplier: null, purchaseProof: null })],
}),
)[0];
expect(JSON.stringify([first.title, first.detail])).not.toContain(
'Mittel Eins',
);
expect(first.subjects[0].label).toContain('Mittel Eins');
expect(first.fingerprintParts).not.toEqual(second.fingerprintParts);
});
it('is a Pflicht rule of the medicine area that cannot be acknowledged', () => {
expect(lotGapsRule).toMatchObject({
key: 'lot-gaps',
area: 'TIERARZNEIMITTEL',
severity: 'PFLICHT',
acknowledgeable: false,
subjectPermission: 'veterinary-medicines:read',
});
});
});
@@ -0,0 +1,46 @@
import type { ReadinessLot } from '../loaders/lots.loader';
import { isBlank } from './rule-helpers';
import {
GLOBAL_SUBJECT_ID,
type ReadinessFinding,
type ReadinessRule,
} from '../readiness.types';
import { lotSubject } from './medicine-rule-helpers';
function missingOf(lot: ReadinessLot): string[] {
return [
...(isBlank(lot.supplier) ? ['Lieferant'] : []),
...(isBlank(lot.purchaseProof) ? ['Beleg'] : []),
];
}
export const lotGapsRule: ReadinessRule = {
key: 'lot-gaps',
area: 'TIERARZNEIMITTEL',
severity: 'PFLICHT',
title: 'Bei Packungen fehlen Lieferant oder Beleg.',
legalBasis: 'Art. 108 VO (EU) 2019/6',
acknowledgeable: false,
subjectPermission: 'veterinary-medicines:read',
evaluate: ({ lots }): ReadinessFinding[] => {
const gaps = lots
.map((lot) => ({ lot, missing: missingOf(lot) }))
.filter(({ missing }) => missing.length > 0);
if (gaps.length === 0) {
return [];
}
return [
{
subjectId: GLOBAL_SUBJECT_ID,
detail:
gaps.length === 1
? 'Eine Packung hat keinen Lieferanten oder keinen Beleg.'
: `${gaps.length} Packungen haben keinen Lieferanten oder keinen Beleg.`,
fingerprintParts: {
lots: gaps.map(({ lot, missing }) => ({ id: lot.id, missing })),
},
subjects: gaps.map(({ lot, missing }) => lotSubject(lot, missing)),
},
];
},
};
@@ -0,0 +1,56 @@
import { lotNegativeRule } from './lot-negative.rule';
import { readinessContext, readinessLot } from './rule.spec-helper';
describe('lot-negative rule', () => {
it('lists the lots with a rest below zero', () => {
const findings = lotNegativeRule.evaluate(
readinessContext({
lots: [
readinessLot({ id: 'l1', remaining: -0.5 }),
readinessLot({ id: 'l2', remaining: 0 }),
readinessLot({ id: 'l3', remaining: 4 }),
readinessLot({ id: 'l4', remaining: -10 }),
],
}),
);
expect(findings).toHaveLength(1);
expect(findings[0].subjectId).toBe('global');
expect(findings[0].subjects.map((subject) => subject.id)).toEqual([
'l1',
'l4',
]);
});
it('is done when no lot is negative and a changed rest changes the fingerprint', () => {
expect(
lotNegativeRule.evaluate(
readinessContext({ lots: [readinessLot({ remaining: 0 })] }),
),
).toEqual([]);
const a = lotNegativeRule.evaluate(
readinessContext({ lots: [readinessLot({ remaining: -1 })] }),
)[0];
const b = lotNegativeRule.evaluate(
readinessContext({ lots: [readinessLot({ remaining: -2 })] }),
)[0];
expect(a.fingerprintParts).not.toEqual(b.fingerprintParts);
});
it('cannot be acknowledged and keeps names out of the texts', () => {
const [finding] = lotNegativeRule.evaluate(
readinessContext({ lots: [readinessLot({ remaining: -1 })] }),
);
expect(JSON.stringify([finding.title, finding.detail])).not.toContain(
'Mittel Eins',
);
expect(lotNegativeRule).toMatchObject({
key: 'lot-negative',
area: 'TIERARZNEIMITTEL',
severity: 'PRUEFEN',
acknowledgeable: false,
subjectPermission: 'veterinary-medicines:read',
});
});
});
@@ -0,0 +1,38 @@
import {
GLOBAL_SUBJECT_ID,
type ReadinessFinding,
type ReadinessRule,
} from '../readiness.types';
import { lotSubject } from './medicine-rule-helpers';
export const lotNegativeRule: ReadinessRule = {
key: 'lot-negative',
area: 'TIERARZNEIMITTEL',
severity: 'PRUEFEN',
title: 'Eine Packung hat einen negativen Bestand.',
legalBasis: null,
acknowledgeable: false,
subjectPermission: 'veterinary-medicines:read',
evaluate: ({ lots }): ReadinessFinding[] => {
const negative = lots.filter((lot) => lot.remaining < 0);
if (negative.length === 0) {
return [];
}
return [
{
subjectId: GLOBAL_SUBJECT_ID,
detail:
negative.length === 1
? 'Aus einer Packung wurde mehr entnommen als erfasst. Inventur oder fehlenden Erwerb erfassen.'
: `Aus ${negative.length} Packungen wurde mehr entnommen als erfasst. Inventur oder fehlenden Erwerb erfassen.`,
fingerprintParts: {
lots: negative.map((lot) => ({
id: lot.id,
remaining: lot.remaining,
})),
},
subjects: negative.map((lot) => lotSubject(lot)),
},
];
},
};
@@ -0,0 +1,53 @@
import { lotPrescriptionRule } from './lot-prescription.rule';
import { readinessContext, readinessLot } from './rule.spec-helper';
describe('lot-prescription rule', () => {
it('lists a lot of a prescription-only medicine without a vet', () => {
const findings = lotPrescriptionRule.evaluate(
readinessContext({
lots: [
readinessLot({ id: 'l1', prescriptionOnly: true }),
readinessLot({
id: 'l2',
prescriptionOnly: true,
prescribingVet: ' ',
}),
],
}),
);
expect(findings).toHaveLength(1);
expect(findings[0].subjects.map((s) => [s.id, s.missing])).toEqual([
['l1', ['Tierarzt']],
['l2', ['Tierarzt']],
]);
});
it('is done for a lot with a vet and for an over-the-counter medicine', () => {
expect(
lotPrescriptionRule.evaluate(
readinessContext({
lots: [
readinessLot({ prescriptionOnly: true, prescribingVet: 'Dr. Vet' }),
readinessLot({ id: 'l2', prescriptionOnly: false }),
],
}),
),
).toEqual([]);
});
it('keeps names out of the texts', () => {
const [finding] = lotPrescriptionRule.evaluate(
readinessContext({ lots: [readinessLot({ prescriptionOnly: true })] }),
);
expect(JSON.stringify([finding.detail])).not.toContain('Mittel Eins');
expect(lotPrescriptionRule).toMatchObject({
key: 'lot-prescription',
area: 'TIERARZNEIMITTEL',
severity: 'PFLICHT',
acknowledgeable: false,
subjectPermission: 'veterinary-medicines:read',
});
});
});
@@ -0,0 +1,36 @@
import {
GLOBAL_SUBJECT_ID,
type ReadinessFinding,
type ReadinessRule,
} from '../readiness.types';
import { lotSubject } from './medicine-rule-helpers';
import { isBlank } from './rule-helpers';
export const lotPrescriptionRule: ReadinessRule = {
key: 'lot-prescription',
area: 'TIERARZNEIMITTEL',
severity: 'PFLICHT',
title: 'Bei verschreibungspflichtigen Packungen fehlt der Tierarzt.',
legalBasis: 'Art. 108 VO (EU) 2019/6',
acknowledgeable: false,
subjectPermission: 'veterinary-medicines:read',
evaluate: ({ lots }): ReadinessFinding[] => {
const open = lots.filter(
(lot) => lot.prescriptionOnly && isBlank(lot.prescribingVet),
);
if (open.length === 0) {
return [];
}
return [
{
subjectId: GLOBAL_SUBJECT_ID,
detail:
open.length === 1
? 'Bei einer verschreibungspflichtigen Packung fehlt der Tierarzt.'
: `Bei ${open.length} verschreibungspflichtigen Packungen fehlt der Tierarzt.`,
fingerprintParts: { lotIds: open.map((lot) => lot.id).sort() },
subjects: open.map((lot) => lotSubject(lot, ['Tierarzt'])),
},
];
},
};
@@ -0,0 +1,75 @@
import { berlinDayOf } from '../../../common/dates';
import { formatDayDe } from '../../../common/format-day';
import type {
TreatmentLogGap,
TreatmentLogSource,
} from '../../../export/treatment-log';
import type { ReadinessLot } from '../loaders/lots.loader';
import type { ReadinessContext } from '../readiness-context';
import { firstTreatmentYear, treatmentYearOf } from '../readiness-limits';
import type { ReadinessSubject } from '../readiness.types';
/** The German name of a gap of the Bestandsbuch, as the form names it. */
export const GAP_NAMES: Readonly<Record<TreatmentLogGap, string>> = {
SUPPLIER: 'Lieferant',
PROOF: 'Beleg',
AMOUNT: 'Menge',
WITHDRAWAL: 'Wartezeit',
VET: 'Tierarzt',
};
/** The order `treatmentLogGaps` reports the gaps in. */
export const GAP_ORDER: readonly TreatmentLogGap[] = [
'SUPPLIER',
'PROOF',
'AMOUNT',
'WITHDRAWAL',
'VET',
];
/** The gaps that live on the lot (supplier, proof and vet come from there). */
export const LOT_GAPS: ReadonlySet<TreatmentLogGap> = new Set([
'SUPPLIER',
'PROOF',
'VET',
]);
/** The treatments of the last 6 calendar years, the book's lookback. */
export function treatmentsInLookback(
ctx: Pick<ReadinessContext, 'treatments' | 'today'>,
): TreatmentLogSource[] {
const first = firstTreatmentYear(ctx.today);
return ctx.treatments.filter(
(treatment) => treatmentYearOf(treatment.date) >= first,
);
}
/**
* A treatment as a subject. The label names the day and the colony but
* never the product: medicine names need veterinary-medicines:read.
*/
export function treatmentSubject(
source: Pick<TreatmentLogSource, 'id' | 'colonyId' | 'colonyCode' | 'date'>,
missing?: string[],
): ReadinessSubject {
return {
type: 'TREATMENT',
id: source.id,
label: `Behandlung vom ${formatDayDe(berlinDayOf(source.date), true)}, Volk ${source.colonyCode}`,
parentId: source.colonyId,
...(missing ? { missing } : {}),
};
}
/** A lot as a subject; its medicine name is gated by the rule's permission. */
export function lotSubject(
lot: Pick<ReadinessLot, 'id' | 'medicineName' | 'purchasedOn'>,
missing?: string[],
): ReadinessSubject {
return {
type: 'LOT',
id: lot.id,
label: `${lot.medicineName}, Packung vom ${formatDayDe(lot.purchasedOn, true)}`,
...(missing ? { missing } : {}),
};
}
@@ -0,0 +1,51 @@
import { medicineWithdrawalMissingRule } from './medicine-withdrawal-missing.rule';
import { readinessContext, readinessMedicine } from './rule.spec-helper';
describe('medicine-withdrawal-missing rule', () => {
it('lists active medicines without a withdrawal period, 0 days counts as given', () => {
const findings = medicineWithdrawalMissingRule.evaluate(
readinessContext({
medicines: [
readinessMedicine({ id: 'm1', withdrawalPeriodDays: null }),
readinessMedicine({ id: 'm2', withdrawalPeriodDays: 0 }),
readinessMedicine({ id: 'm3', withdrawalPeriodDays: 21 }),
],
}),
);
expect(findings).toHaveLength(1);
expect(findings[0].subjects).toEqual([
expect.objectContaining({
type: 'MEDICINE',
id: 'm1',
label: 'Mittel Eins',
missing: ['Wartezeit'],
}),
]);
});
it('is done without a medicine or when all have a period', () => {
expect(medicineWithdrawalMissingRule.evaluate(readinessContext())).toEqual(
[],
);
});
it('keeps the name out of the texts and is a Prüfen rule that cannot be acknowledged', () => {
const [finding] = medicineWithdrawalMissingRule.evaluate(
readinessContext({
medicines: [readinessMedicine({ withdrawalPeriodDays: null })],
}),
);
expect(JSON.stringify([finding.title, finding.detail])).not.toContain(
'Mittel Eins',
);
expect(medicineWithdrawalMissingRule).toMatchObject({
key: 'medicine-withdrawal-missing',
area: 'TIERARZNEIMITTEL',
severity: 'PRUEFEN',
acknowledgeable: false,
subjectPermission: 'veterinary-medicines:read',
});
});
});
@@ -0,0 +1,40 @@
import {
GLOBAL_SUBJECT_ID,
type ReadinessFinding,
type ReadinessRule,
} from '../readiness.types';
export const medicineWithdrawalMissingRule: ReadinessRule = {
key: 'medicine-withdrawal-missing',
area: 'TIERARZNEIMITTEL',
severity: 'PRUEFEN',
title: 'Bei Mitteln im Katalog fehlt die Wartezeit.',
legalBasis: null,
acknowledgeable: false,
subjectPermission: 'veterinary-medicines:read',
evaluate: ({ medicines }): ReadinessFinding[] => {
// 0 days is a value: only a missing period counts.
const open = medicines.filter(
(medicine) => medicine.withdrawalPeriodDays === null,
);
if (open.length === 0) {
return [];
}
return [
{
subjectId: GLOBAL_SUBJECT_ID,
detail:
open.length === 1
? 'Bei einem aktiven Mittel ist keine Wartezeit eingetragen.'
: `Bei ${open.length} aktiven Mitteln ist keine Wartezeit eingetragen.`,
fingerprintParts: { medicineIds: open.map((m) => m.id).sort() },
subjects: open.map((medicine) => ({
type: 'MEDICINE',
id: medicine.id,
label: medicine.name,
missing: ['Wartezeit'],
})),
},
];
},
};
@@ -1,5 +1,5 @@
import { registrationRule } from './registration.rule';
import { notice, readinessContext } from './rule-spec-helpers';
import { notice, readinessContext } from './rule.spec-helper';
describe('registration rule', () => {
it('is open without any registration', () => {
@@ -1,5 +1,5 @@
import { restrictionActiveRule } from './restriction-active.rule';
import { readinessContext, restriction } from './rule-spec-helpers';
import { readinessContext, restriction } from './rule.spec-helper';
describe('restriction-active rule', () => {
it('is open with one finding listing each apiary with an active restriction once', () => {
@@ -1,122 +0,0 @@
import type {
ApiaryRestriction,
ComplianceSettings,
SiteSettings,
} from '../../../../generated/prisma';
import type { HerdSnapshot } from '../../herd-snapshot';
import type { HerdStatus } from '../../herd-status';
import type { ReadinessContext, ReadinessNotice } from '../readiness-context';
/** Builders for the pure rule specs (and for the rules of later tasks). */
export function siteSettings(patch: Partial<SiteSettings> = {}): SiteSettings {
return {
id: 'singleton',
youtubeUrl: null,
instagramUrl: null,
tiktokUrl: null,
websiteUrl: null,
holderName: 'Halter',
holderAddress: 'Weg 1, 12345 Ort',
foodBusinessName: 'Imkerei',
foodBusinessAddress: 'Weg 2, 12345 Ort',
originCountry: 'Deutschland',
updatedAt: new Date(0),
...patch,
};
}
export function complianceSettings(
patch: Partial<ComplianceSettings> = {},
): ComplianceSettings {
return {
id: 'singleton',
veterinaryOfficeName: 'Veterinäramt',
veterinaryOfficeAddress: null,
registrationNumber: null,
noticeOnColonyCountChange: true,
noticeOnApiaryChange: true,
tskReferenceDay: '01-01',
tskReportDueDay: null,
tskMemberNumber: null,
tskExcludedColonyTypes: [],
netQuantityDigitRules: [],
updatedAt: new Date(0),
...patch,
};
}
export function emptySnapshot(): HerdSnapshot {
return {
version: 1,
takenAt: '2027-03-15T10:00:00.000Z',
apiaries: [],
totalLiving: 0,
};
}
export function notice(patch: Partial<ReadinessNotice> = {}): ReadinessNotice {
return {
id: 'n1',
kind: 'REGISTRATION',
status: 'SENT',
year: null,
referenceDate: new Date('2026-01-01T00:00:00.000Z'),
countedAt: new Date('2026-01-01T10:00:00.000Z'),
totalColonies: 3,
sentOn: new Date('2026-01-02T00:00:00.000Z'),
sentVia: null,
reference: null,
notes: null,
openKey: null,
createdByUserId: null,
createdAt: new Date(0),
updatedAt: new Date(0),
...patch,
};
}
export function restriction(
patch: Partial<ApiaryRestriction> & { apiary?: { id: string; name: string } },
): ApiaryRestriction & { apiary: { id: string; name: string } } {
const apiary = patch.apiary ?? { id: 'a1', name: 'Stand Eins' };
return {
id: 'r1',
apiaryId: apiary.id,
disease: 'Faulbrut',
authority: null,
reference: null,
startsOn: new Date('2027-03-01T00:00:00.000Z'),
endsOn: null,
notes: null,
createdByUserId: null,
createdAt: new Date(0),
updatedAt: new Date(0),
...patch,
apiary,
};
}
export function herdStatus(patch: Partial<HerdStatus> = {}): HerdStatus {
return {
settings: complianceSettings(),
baseline: null,
current: emptySnapshot(),
deviation: null,
...patch,
};
}
export function readinessContext(
patch: Partial<ReadinessContext> = {},
): ReadinessContext {
return {
now: new Date('2027-03-15T10:00:00.000Z'),
today: '2027-03-15',
site: siteSettings(),
settings: complianceSettings(),
herd: herdStatus(),
notices: [],
restrictions: [],
...patch,
};
}
@@ -0,0 +1,298 @@
import type {
ApiaryRestriction,
ComplianceSettings,
SiteSettings,
} from '../../../../generated/prisma';
import type { LabelTextElement } from '../../../label-templates/label-element.types';
import type {
ColonyMove,
TreatmentLogSource,
} from '../../../export/treatment-log';
import type { WithdrawalConflict } from '../../../withdrawal/withdrawal-check.service';
import type { HerdSnapshot } from '../../herd-snapshot';
import type { HerdStatus } from '../../herd-status';
import type { ReadinessHarvest } from '../loaders/harvests.loader';
import type { ReadinessLabelTemplate } from '../loaders/labels.loader';
import type { ReadinessLot, ReadinessMedicine } from '../loaders/lots.loader';
import type { ReadinessContext, ReadinessNotice } from '../readiness-context';
/** Builders for the pure rule specs (and for the rules of later tasks). */
export function siteSettings(patch: Partial<SiteSettings> = {}): SiteSettings {
return {
id: 'singleton',
youtubeUrl: null,
instagramUrl: null,
tiktokUrl: null,
websiteUrl: null,
holderName: 'Halter',
holderAddress: 'Weg 1, 12345 Ort',
foodBusinessName: 'Imkerei',
foodBusinessAddress: 'Weg 2, 12345 Ort',
originCountry: 'Deutschland',
updatedAt: new Date(0),
...patch,
};
}
export function complianceSettings(
patch: Partial<ComplianceSettings> = {},
): ComplianceSettings {
return {
id: 'singleton',
veterinaryOfficeName: 'Veterinäramt',
veterinaryOfficeAddress: null,
registrationNumber: null,
noticeOnColonyCountChange: true,
noticeOnApiaryChange: true,
tskReferenceDay: '01-01',
tskReportDueDay: null,
tskMemberNumber: null,
tskExcludedColonyTypes: [],
netQuantityDigitRules: [],
updatedAt: new Date(0),
...patch,
};
}
export function emptySnapshot(): HerdSnapshot {
return {
version: 1,
takenAt: '2027-03-15T10:00:00.000Z',
apiaries: [],
totalLiving: 0,
};
}
export function notice(patch: Partial<ReadinessNotice> = {}): ReadinessNotice {
return {
id: 'n1',
kind: 'REGISTRATION',
status: 'SENT',
year: null,
referenceDate: new Date('2026-01-01T00:00:00.000Z'),
countedAt: new Date('2026-01-01T10:00:00.000Z'),
totalColonies: 3,
sentOn: new Date('2026-01-02T00:00:00.000Z'),
sentVia: null,
reference: null,
notes: null,
openKey: null,
createdByUserId: null,
createdAt: new Date(0),
updatedAt: new Date(0),
...patch,
};
}
export function restriction(
patch: Partial<ApiaryRestriction> & { apiary?: { id: string; name: string } },
): ApiaryRestriction & { apiary: { id: string; name: string } } {
const apiary = patch.apiary ?? { id: 'a1', name: 'Stand Eins' };
return {
id: 'r1',
apiaryId: apiary.id,
disease: 'Faulbrut',
authority: null,
reference: null,
startsOn: new Date('2027-03-01T00:00:00.000Z'),
endsOn: null,
notes: null,
createdByUserId: null,
createdAt: new Date(0),
updatedAt: new Date(0),
...patch,
apiary,
};
}
export function herdStatus(patch: Partial<HerdStatus> = {}): HerdStatus {
return {
settings: complianceSettings(),
baseline: null,
current: emptySnapshot(),
deviation: null,
...patch,
};
}
export function readinessContext(
patch: Partial<ReadinessContext> = {},
): ReadinessContext {
return {
now: new Date('2027-03-15T10:00:00.000Z'),
today: '2027-03-15',
site: siteSettings(),
settings: complianceSettings(),
herd: herdStatus(),
notices: [],
restrictions: [],
treatments: [],
moves: [],
lots: [],
medicines: [],
templates: [],
harvests: [],
...patch,
};
}
/** A complete treatment without a pack: nothing missing, apiary known. */
export function treatmentSource(
patch: Partial<TreatmentLogSource> = {},
): TreatmentLogSource {
return {
id: 't1',
colonyId: 'c1',
colonyCode: 'V-1',
colonyApiaryId: 'a1',
apiaryId: 'a1',
date: new Date('2027-02-10T00:00:00.000Z'),
startDate: null,
endDate: null,
product: 'Mittel Eins',
amount: '30 ml',
batchNumber: 'CH-1',
supplier: 'Lieferant',
purchaseProof: 'Beleg 1',
prescribingVet: null,
withdrawalPeriodDays: 0,
administeredBy: 'Imker',
medicineId: null,
prescriptionOnly: false,
doseQuantity: null,
medicineUnit: null,
lot: null,
...patch,
};
}
export function treatmentLotRef(
patch: Partial<NonNullable<TreatmentLogSource['lot']>> = {},
): NonNullable<TreatmentLogSource['lot']> {
return {
id: 'l1',
batchNumber: 'CH-1',
supplier: 'Lieferant',
purchaseProof: 'Beleg 1',
prescribingVet: null,
...patch,
};
}
export function colonyMove(patch: Partial<ColonyMove> = {}): ColonyMove {
return {
colonyId: 'c1',
apiaryId: 'a2',
movedAt: new Date('2027-03-01T00:00:00.000Z'),
...patch,
};
}
/** A complete pack with a rest. */
export function readinessLot(patch: Partial<ReadinessLot> = {}): ReadinessLot {
return {
id: 'l1',
medicineId: 'm1',
medicineName: 'Mittel Eins',
prescriptionOnly: false,
purchasedOn: '2027-01-05',
expiresOn: '2028-01-05',
supplier: 'Lieferant',
purchaseProof: 'Beleg 1',
prescribingVet: null,
remaining: 10,
...patch,
};
}
export function readinessMedicine(
patch: Partial<ReadinessMedicine> = {},
): ReadinessMedicine {
return { id: 'm1', name: 'Mittel Eins', withdrawalPeriodDays: 0, ...patch };
}
export function textElement(
patch: Partial<LabelTextElement> = {},
): LabelTextElement {
return {
id: 'e1',
type: 'text',
x: 2,
y: 2,
width: 150,
height: 10,
text: '',
placeholderKey: null,
fontSize: 12,
fontFamily: 'sans',
bold: false,
italic: false,
color: '#000000',
align: 'left',
...patch,
};
}
export const SPEC_DIGIT_RULES = [
{ maxGrams: 50, minMm: 2 },
{ maxGrams: 200, minMm: 3 },
{ maxGrams: 1000, minMm: 4 },
{ maxGrams: null, minMm: 6 },
];
/** A template that carries every mandatory particular in large type. */
export function completeTemplate(
patch: Partial<ReadinessLabelTemplate> = {},
): ReadinessLabelTemplate {
const line = (
id: string,
y: number,
key: LabelTextElement['placeholderKey'],
) => textElement({ id, y, placeholderKey: key, fontSize: 18, height: 10 });
return {
id: 'tpl1',
name: 'Vorlage Eins',
minXHeightMm: 1.2,
elements: [
line('e1', 0, 'legalName'),
line('e2', 12, 'jarSize'),
line('e3', 24, 'bestBeforeLine'),
line('e4', 36, 'lotLine'),
line('e5', 48, 'originCountry'),
line('e6', 60, 'foodBusinessName'),
line('e7', 72, 'foodBusinessAddress'),
],
target: {
sample: false,
bottling: {
jarSize: 500,
lotNumber: '2027-0001',
date: new Date('2027-01-10T00:00:00.000Z'),
bestBeforeDate: new Date('2029-01-10T00:00:00.000Z'),
honeyBatch: { batchNumber: 'HB-1', honeyType: 'Sommerhonig' },
product: { name: 'Sommerhonig', legalName: 'Honig' },
},
},
...patch,
};
}
export function readinessHarvest(
patch: Partial<ReadinessHarvest> = {},
): ReadinessHarvest {
return { id: 'h1', day: '2027-03-01', conflicts: [], ...patch };
}
export function conflict(
patch: Partial<WithdrawalConflict> = {},
): WithdrawalConflict {
return {
harvestId: 'h1',
harvestedOn: '2027-03-01',
colonyId: 'c1',
treatmentId: 't1',
product: 'Mittel Eins',
untilDay: '2027-03-10',
...patch,
};
}
@@ -0,0 +1,104 @@
import { treatmentExpiredLotRule } from './treatment-expired-lot.rule';
import {
readinessContext,
readinessLot,
treatmentLotRef,
treatmentSource,
} from './rule.spec-helper';
function run(treatments: ReturnType<typeof treatmentSource>[]) {
return treatmentExpiredLotRule.evaluate(
readinessContext({
lots: [readinessLot({ id: 'l1', expiresOn: '2027-02-10' })],
treatments,
}),
);
}
describe('treatment-expired-lot rule', () => {
it('opens one item per treatment dated after the expiry of its lot', () => {
const findings = run([
treatmentSource({
id: 't1',
date: new Date('2027-02-11T00:00:00.000Z'),
lot: treatmentLotRef({ id: 'l1' }),
}),
treatmentSource({
id: 't2',
date: new Date('2027-03-01T00:00:00.000Z'),
colonyId: 'c2',
lot: treatmentLotRef({ id: 'l1' }),
}),
]);
expect(findings.map((finding) => finding.subjectId)).toEqual(['t1', 't2']);
expect(findings[1].subjects).toEqual([
expect.objectContaining({ type: 'TREATMENT', id: 't2', parentId: 'c2' }),
]);
});
it('is done on the day of expiry, before it, without a lot and for a lot without expiry', () => {
expect(
run([
treatmentSource({
id: 'a',
date: new Date('2027-02-10T00:00:00.000Z'),
lot: treatmentLotRef({ id: 'l1' }),
}),
treatmentSource({
id: 'b',
date: new Date('2027-01-01T00:00:00.000Z'),
lot: treatmentLotRef({ id: 'l1' }),
}),
treatmentSource({
id: 'c',
date: new Date('2027-03-01T00:00:00.000Z'),
}),
treatmentSource({
id: 'd',
date: new Date('2027-03-01T00:00:00.000Z'),
lot: treatmentLotRef({ id: 'unknown' }),
}),
]),
).toEqual([]);
});
it('is acknowledgeable per treatment and keeps names out of the texts', () => {
const [finding] = run([
treatmentSource({
id: 't1',
date: new Date('2027-03-01T00:00:00.000Z'),
lot: treatmentLotRef({ id: 'l1' }),
}),
]);
expect(
JSON.stringify([finding.title, finding.detail, finding.subjects]),
).not.toContain('Mittel Eins');
expect(treatmentExpiredLotRule).toMatchObject({
key: 'treatment-expired-lot',
area: 'TIERARZNEIMITTEL',
severity: 'PRUEFEN',
acknowledgeable: true,
subjectPermission: 'veterinary-medicines:read',
});
});
it('only looks at the last 6 calendar years', () => {
const findings = treatmentExpiredLotRule.evaluate(
readinessContext({
today: '2027-03-15',
lots: [readinessLot({ id: 'l1', expiresOn: '2020-01-01' })],
treatments: [
treatmentSource({
id: 'old',
date: new Date('2021-05-01T00:00:00.000Z'),
lot: treatmentLotRef({ id: 'l1' }),
}),
],
}),
);
expect(findings).toEqual([]);
});
});
@@ -0,0 +1,39 @@
import { berlinDayOf } from '../../../common/dates';
import type { ReadinessFinding, ReadinessRule } from '../readiness.types';
import {
treatmentSubject,
treatmentsInLookback,
} from './medicine-rule-helpers';
export const treatmentExpiredLotRule: ReadinessRule = {
key: 'treatment-expired-lot',
area: 'TIERARZNEIMITTEL',
severity: 'PRUEFEN',
title: 'Eine Behandlung liegt nach dem Verfall ihrer Packung.',
legalBasis: null,
acknowledgeable: true,
subjectPermission: 'veterinary-medicines:read',
evaluate: (ctx): ReadinessFinding[] => {
const expiry = new Map(ctx.lots.map((lot) => [lot.id, lot.expiresOn]));
return treatmentsInLookback(ctx).flatMap((source): ReadinessFinding[] => {
const expiresOn = source.lot ? (expiry.get(source.lot.id) ?? null) : null;
if (expiresOn === null || berlinDayOf(source.date) <= expiresOn) {
return [];
}
return [
{
subjectId: source.id,
detail:
'Die Behandlung liegt nach dem Verfall der Packung. Bitte prüfen.',
fingerprintParts: {
treatmentId: source.id,
lotId: source.lot?.id,
expiresOn,
day: berlinDayOf(source.date),
},
subjects: [treatmentSubject(source)],
},
];
});
},
};
@@ -0,0 +1,84 @@
import { treatmentLocationUncertainRule } from './treatment-location-uncertain.rule';
import {
colonyMove,
readinessContext,
treatmentSource,
} from './rule.spec-helper';
type Source = ReturnType<typeof treatmentSource>;
describe('treatment-location-uncertain rule', () => {
// No apiary stored with the treatment, and the colony moved later.
const uncertain = (patch: Partial<Source> = {}) =>
treatmentSource({ id: 't1', apiaryId: null, colonyId: 'c1', ...patch });
const laterMove = colonyMove({
movedAt: new Date('2027-03-01T00:00:00.000Z'),
});
it('flags a treatment whose apiary on the day is not known (the * row)', () => {
const findings = treatmentLocationUncertainRule.evaluate(
readinessContext({ treatments: [uncertain()], moves: [laterMove] }),
);
expect(findings).toHaveLength(1);
expect(findings[0].subjectId).toBe('global');
expect(findings[0].subjects).toEqual([
expect.objectContaining({ type: 'TREATMENT', id: 't1', parentId: 'c1' }),
]);
});
it('is done when the apiary is stored, known from a move, or the colony never moved', () => {
const run = (
treatments: Source[],
moves: ReturnType<typeof colonyMove>[] = [],
) =>
treatmentLocationUncertainRule.evaluate(
readinessContext({ treatments, moves }),
);
expect(run([treatmentSource({ apiaryId: 'a1' })], [laterMove])).toEqual([]);
expect(run([uncertain()])).toEqual([]);
expect(
run(
[uncertain()],
[colonyMove({ movedAt: new Date('2027-01-01T00:00:00.000Z') })],
),
).toEqual([]);
});
it('only looks at the last 6 calendar years', () => {
const findings = treatmentLocationUncertainRule.evaluate(
readinessContext({
today: '2027-03-15',
treatments: [uncertain({ date: new Date('2021-05-01T00:00:00.000Z') })],
moves: [laterMove],
}),
);
expect(findings).toEqual([]);
});
it('keeps the product out of the texts and follows the set of treatments in the fingerprint', () => {
const one = treatmentLocationUncertainRule.evaluate(
readinessContext({ treatments: [uncertain()], moves: [laterMove] }),
)[0];
const two = treatmentLocationUncertainRule.evaluate(
readinessContext({
treatments: [uncertain(), uncertain({ id: 't2' })],
moves: [laterMove],
}),
)[0];
expect(JSON.stringify([one.title, one.detail, one.subjects])).not.toContain(
'Mittel Eins',
);
expect(one.fingerprintParts).not.toEqual(two.fingerprintParts);
expect(treatmentLocationUncertainRule).toMatchObject({
key: 'treatment-location-uncertain',
area: 'TIERARZNEIMITTEL',
severity: 'PRUEFEN',
acknowledgeable: false,
subjectPermission: 'treatments:read',
});
});
});
@@ -0,0 +1,40 @@
import { apiaryOnDate } from '../../../export/treatment-log';
import {
GLOBAL_SUBJECT_ID,
type ReadinessFinding,
type ReadinessRule,
} from '../readiness.types';
import {
treatmentSubject,
treatmentsInLookback,
} from './medicine-rule-helpers';
export const treatmentLocationUncertainRule: ReadinessRule = {
key: 'treatment-location-uncertain',
area: 'TIERARZNEIMITTEL',
severity: 'PRUEFEN',
title: 'Bei Behandlungen ist der Stand am Behandlungstag unsicher.',
legalBasis: 'Art. 108 VO (EU) 2019/6 (Identifizierung)',
acknowledgeable: false,
subjectPermission: 'treatments:read',
evaluate: (ctx): ReadinessFinding[] => {
// The rows the PDF marks with *: `apiaryOnDate` decides, as it does there.
const uncertain = treatmentsInLookback(ctx).filter(
(source) => apiaryOnDate(source, ctx.moves).uncertain,
);
if (uncertain.length === 0) {
return [];
}
return [
{
subjectId: GLOBAL_SUBJECT_ID,
detail:
uncertain.length === 1
? 'Bei einer Behandlung ist der Stand nicht sicher. Den Stand bestätigen.'
: `Bei ${uncertain.length} Behandlungen ist der Stand nicht sicher. Den Stand bestätigen.`,
fingerprintParts: { treatmentIds: uncertain.map((s) => s.id).sort() },
subjects: uncertain.map((source) => treatmentSubject(source)),
},
];
},
};
@@ -0,0 +1,227 @@
import {
groupTreatmentLog,
treatmentLogGaps,
} from '../../../export/treatment-log';
import { treatmentLogGapsRule } from './treatment-log-gaps.rule';
import {
readinessContext,
treatmentLotRef,
treatmentSource,
} from './rule.spec-helper';
const GERMAN = {
SUPPLIER: 'Lieferant',
PROOF: 'Beleg',
AMOUNT: 'Menge',
WITHDRAWAL: 'Wartezeit',
VET: 'Tierarzt',
} as const;
describe('treatment-log-gaps rule', () => {
it('opens one item per year with gap rows', () => {
const findings = treatmentLogGapsRule.evaluate(
readinessContext({
treatments: [
treatmentSource({ id: 't1', supplier: null }),
treatmentSource({
id: 't2',
date: new Date('2026-06-01T00:00:00.000Z'),
purchaseProof: null,
}),
treatmentSource({ id: 't3' }),
],
}),
);
expect(findings.map((finding) => finding.subjectId)).toEqual([
'2027',
'2026',
]);
expect(findings[0].subjects.map((subject) => subject.id)).toEqual(['t1']);
expect(findings[1].subjects.map((subject) => subject.id)).toEqual(['t2']);
});
it('is done when every row of the Bestandsbuch is complete', () => {
expect(
treatmentLogGapsRule.evaluate(
readinessContext({ treatments: [treatmentSource()] }),
),
).toEqual([]);
});
it('reports a lot with missing supplier once as a lot subject, not once per treatment', () => {
const lot = treatmentLotRef({ id: 'l1', supplier: null });
const findings = treatmentLogGapsRule.evaluate(
readinessContext({
treatments: [
treatmentSource({ id: 't1', lot }),
treatmentSource({ id: 't2', colonyId: 'c2', colonyCode: 'V-2', lot }),
treatmentSource({ id: 't3', lot }),
],
}),
);
expect(findings).toHaveLength(1);
expect(findings[0].subjects).toEqual([
expect.objectContaining({
type: 'LOT',
id: 'l1',
missing: ['Lieferant'],
}),
]);
});
it('reports treatments without a lot as treatment subjects with the missing fields and the colony as parentId', () => {
const findings = treatmentLogGapsRule.evaluate(
readinessContext({
treatments: [
treatmentSource({
id: 't1',
colonyId: 'c9',
supplier: null,
amount: null,
withdrawalPeriodDays: null,
}),
],
}),
);
expect(findings[0].subjects).toEqual([
expect.objectContaining({
type: 'TREATMENT',
id: 't1',
parentId: 'c9',
missing: ['Lieferant', 'Menge', 'Wartezeit'],
}),
]);
});
it('keeps the fields of the treatment itself on a treatment subject even when the lot causes other gaps', () => {
const findings = treatmentLogGapsRule.evaluate(
readinessContext({
treatments: [
treatmentSource({
id: 't1',
amount: null,
lot: treatmentLotRef({ id: 'l1', purchaseProof: null }),
}),
],
}),
);
expect(findings[0].subjects).toEqual([
expect.objectContaining({
type: 'TREATMENT',
id: 't1',
missing: ['Menge'],
}),
expect.objectContaining({ type: 'LOT', id: 'l1', missing: ['Beleg'] }),
]);
});
it('the gap codes equal the codes treatmentLogGaps reports for the same row', () => {
const source = treatmentSource({
id: 't1',
supplier: null,
purchaseProof: null,
amount: null,
withdrawalPeriodDays: null,
prescriptionOnly: true,
prescribingVet: null,
});
const [sheet] = groupTreatmentLog([source], []);
const expected = treatmentLogGaps(sheet.rows[0]).map(
(code) => GERMAN[code],
);
const [finding] = treatmentLogGapsRule.evaluate(
readinessContext({ treatments: [source] }),
);
expect(finding.subjects[0].missing).toEqual(expected);
expect(expected).toHaveLength(5);
});
it('counts the rows of the book with a gap in the text, as the PDF groups them', () => {
const [finding] = treatmentLogGapsRule.evaluate(
readinessContext({
treatments: [
treatmentSource({ id: 't1', supplier: null }),
treatmentSource({
id: 't2',
colonyId: 'c2',
colonyCode: 'V-2',
supplier: null,
}),
],
}),
);
expect(finding.subjects).toHaveLength(2);
expect(finding.detail).toContain('1 Zeile');
});
it('requires the vet only for prescription-only medicines', () => {
const run = (prescriptionOnly: boolean) =>
treatmentLogGapsRule.evaluate(
readinessContext({
treatments: [treatmentSource({ prescriptionOnly })],
}),
);
expect(run(false)).toEqual([]);
expect(run(true)[0].subjects[0].missing).toEqual(['Tierarzt']);
});
it('looks back over 6 calendar years and not further', () => {
const findings = treatmentLogGapsRule.evaluate(
readinessContext({
today: '2027-03-15',
treatments: [
treatmentSource({
id: 'old',
date: new Date('2021-12-31T00:00:00.000Z'),
supplier: null,
}),
treatmentSource({
id: 'edge',
date: new Date('2022-01-01T00:00:00.000Z'),
supplier: null,
}),
],
}),
);
expect(findings.map((finding) => finding.subjectId)).toEqual(['2022']);
});
it('keeps product names out of titles, details and labels, the fingerprint follows the gaps', () => {
const build = (patch: Parameters<typeof treatmentSource>[0]) =>
treatmentLogGapsRule.evaluate(
readinessContext({
treatments: [treatmentSource({ id: 't1', ...patch })],
}),
);
const [open] = build({ supplier: null });
expect(
JSON.stringify([open.title, open.detail, open.subjects]),
).not.toContain('Mittel Eins');
expect(build({ supplier: null })[0].fingerprintParts).toEqual(
open.fingerprintParts,
);
expect(
build({ supplier: null, amount: null })[0].fingerprintParts,
).not.toEqual(open.fingerprintParts);
});
it('is a Pflicht rule of the medicine area that cannot be acknowledged', () => {
expect(treatmentLogGapsRule).toMatchObject({
key: 'treatment-log-gaps',
area: 'TIERARZNEIMITTEL',
severity: 'PFLICHT',
acknowledgeable: false,
subjectPermission: 'treatments:read',
});
});
});
@@ -0,0 +1,130 @@
import {
groupTreatmentLog,
resolveSourceValues,
treatmentLogGaps,
type TreatmentLogGap,
type TreatmentLogSource,
} from '../../../export/treatment-log';
import { treatmentYearOf } from '../readiness-limits';
import type {
ReadinessFinding,
ReadinessRule,
ReadinessSubject,
} from '../readiness.types';
import {
GAP_NAMES,
GAP_ORDER,
LOT_GAPS,
treatmentSubject,
treatmentsInLookback,
} from './medicine-rule-helpers';
/**
* The gaps of one treatment, from the very functions that print the PDF:
* the columns come from `resolveSourceValues`, the list from
* `treatmentLogGaps`. Grouped rows share these values, so a row has exactly
* the gaps of each of its treatments.
*/
function gapsOf(source: TreatmentLogSource): TreatmentLogGap[] {
const values = resolveSourceValues(source);
return treatmentLogGaps({
supplier: values.supplier,
purchaseProof: values.purchaseProof,
amount: values.amount,
prescribingVet: values.prescribingVet,
withdrawalPeriodDays: source.withdrawalPeriodDays,
prescriptionOnly: source.prescriptionOnly,
});
}
interface YearSubjects {
subjects: ReadinessSubject[];
parts: { type: string; id: string; missing: string[] }[];
}
/** A lot with a gap appears once, however many treatments came from it. */
function subjectsOfYear(sources: readonly TreatmentLogSource[]): YearSubjects {
const treatments: ReadinessSubject[] = [];
const lots = new Map<string, Set<TreatmentLogGap>>();
for (const source of sources) {
const gaps = gapsOf(source);
const own = source.lot ? gaps.filter((gap) => !LOT_GAPS.has(gap)) : gaps;
if (own.length > 0) {
treatments.push(
treatmentSubject(
source,
own.map((gap) => GAP_NAMES[gap]),
),
);
}
if (source.lot) {
const known = lots.get(source.lot.id) ?? new Set<TreatmentLogGap>();
gaps.filter((gap) => LOT_GAPS.has(gap)).forEach((gap) => known.add(gap));
lots.set(source.lot.id, known);
}
}
const lotSubjects = [...lots]
.filter(([, gaps]) => gaps.size > 0)
.sort(([a], [b]) => a.localeCompare(b))
.map(([id, gaps]): ReadinessSubject => ({
type: 'LOT',
id,
// Not the medicine: this item is seen with treatments:read only.
label: 'Packung der Behandlungen',
missing: GAP_ORDER.filter((gap) => gaps.has(gap)).map(
(gap) => GAP_NAMES[gap],
),
}));
const subjects = [...treatments, ...lotSubjects];
return {
subjects,
parts: subjects.map(({ type, id, missing }) => ({
type,
id,
missing: missing ?? [],
})),
};
}
function rowCountText(rows: number, year: number): string {
return `Im Bestandsbuch ${year} fehlen Angaben in ${rows} ${rows === 1 ? 'Zeile' : 'Zeilen'}.`;
}
export const treatmentLogGapsRule: ReadinessRule = {
key: 'treatment-log-gaps',
area: 'TIERARZNEIMITTEL',
severity: 'PFLICHT',
title: 'Im Bestandsbuch fehlen Angaben.',
legalBasis: 'Art. 108 VO (EU) 2019/6',
acknowledgeable: false,
subjectPermission: 'treatments:read',
evaluate: (ctx): ReadinessFinding[] => {
const byYear = new Map<number, TreatmentLogSource[]>();
for (const source of treatmentsInLookback(ctx)) {
const year = treatmentYearOf(source.date);
byYear.set(year, [...(byYear.get(year) ?? []), source]);
}
return [...byYear]
.sort(([a], [b]) => b - a)
.flatMap(([year, sources]): ReadinessFinding[] => {
// The rows the PDF prints for this year: the count comes from the
// same grouping.
const rowsWithGap = groupTreatmentLog(sources, ctx.moves)
.flatMap((sheet) => sheet.rows)
.filter((row) => treatmentLogGaps(row).length > 0).length;
const { subjects, parts } = subjectsOfYear(sources);
if (rowsWithGap === 0 || subjects.length === 0) {
return [];
}
return [
{
subjectId: String(year),
title: `Bestandsbuch ${year}: Angaben fehlen`,
detail: rowCountText(rowsWithGap, year),
fingerprintParts: { year, subjects: parts },
subjects,
},
];
});
},
};
@@ -2,7 +2,7 @@ import {
complianceSettings,
notice,
readinessContext,
} from './rule-spec-helpers';
} from './rule.spec-helper';
import { tskReportRule } from './tsk-report.rule';
function at(today: string, patch: Parameters<typeof readinessContext>[0] = {}) {
@@ -1,5 +1,5 @@
import { vetOfficeRule } from './vet-office.rule';
import { complianceSettings, readinessContext } from './rule-spec-helpers';
import { complianceSettings, readinessContext } from './rule.spec-helper';
describe('vet-office rule', () => {
it('is open without an office name', () => {
@@ -5,7 +5,11 @@ import { Test } from '@nestjs/testing';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { berlinToday, dayToDate } from '../src/common/dates';
import { addDays, berlinToday, dayToDate } from '../src/common/dates';
import {
buildTreatmentLog,
treatmentLogGaps,
} from '../src/export/treatment-log';
import type {
ReadinessItem,
ReadinessReport,
@@ -20,6 +24,7 @@ import { configureApp } from '../src/app.config';
import { THROTTLER_NAMESPACE } from '../src/common/throttler/prisma-throttler.storage';
import { PrismaService } from '../src/prisma/prisma.service';
import { login } from './utils/api';
import { pdfText } from './utils/pdf-text';
const READINESS = '/api/v1/compliance/readiness';
const ACKNOWLEDGEMENTS = '/api/v1/compliance/acknowledgements';
@@ -428,6 +433,10 @@ describe('Compliance readiness (e2e)', () => {
});
expect(stale.status).toBe(409);
expect(stale.body).toMatchObject({
code: 'READINESS_FINGERPRINT_STALE',
message: 'Der Punkt hat sich geändert. Bitte neu laden.',
});
expect(created.status).toBe(201);
const createdItem = created.body as ReadinessItem;
expect(createdItem.state).toBe('ACKNOWLEDGED');
@@ -458,7 +467,12 @@ describe('Compliance readiness (e2e)', () => {
.set(as('beekeeper'));
expect((await revoke()).status).toBe(201);
expect(itemOf(await report(), SOFT_KEY).state).toBe('OPEN');
expect((await revoke()).status).toBe(409);
const again = await revoke();
expect(again.status).toBe(409);
expect(again.body).toMatchObject({
code: 'READINESS_ACK_REVOKED',
message: 'Die Quittung ist schon zurückgenommen.',
});
const row = await prisma.complianceAcknowledgement.findUniqueOrThrow({
where: { id: acknowledgementId },
});
@@ -466,6 +480,21 @@ describe('Compliance readiness (e2e)', () => {
expect(row.revokedByUserId).not.toBeNull();
});
it('acknowledging a subject that is not open answers 409 READINESS_ITEM_NOT_OPEN', async () => {
const response = await acknowledge({
itemKey: SOFT_KEY,
subjectId: 'no-such-subject',
fingerprint: 'a'.repeat(64),
note: 'Geprüft und in Ordnung.',
});
expect(response.status).toBe(409);
expect(response.body).toMatchObject({
code: 'READINESS_ITEM_NOT_OPEN',
message: 'Der Punkt hat sich geändert. Bitte neu laden.',
});
});
it('revoking an unknown acknowledgement is a 404', async () => {
const response = await request(server())
.post(`${ACKNOWLEDGEMENTS}/does-not-exist/revoke`)
@@ -474,4 +503,457 @@ describe('Compliance readiness (e2e)', () => {
expect(response.status).toBe(404);
});
});
describe('medicine, label and harvest areas', () => {
const area = Date.now();
const today = berlinToday();
const created = {
treatments: [] as string[],
lots: [] as string[],
harvests: [] as string[],
};
let colonyId: string;
let medicineId: string;
let lotOk: string;
let lotSmall: string;
let lotOld: string;
let treatmentOk: string;
let treatmentSmall: string;
let treatmentOld: string;
let harvestInside: string;
let harvestAfter: string;
let productId: string;
let templateId: string;
let userId: string;
let extractionId: string;
let honeyBatchId: string;
let bottlingId: string;
let medicineName: string;
const textElement = (id: string, y: number, key: string, size = 9) => ({
id,
type: 'text',
x: 2,
y,
width: 80,
height: 8,
text: '',
placeholderKey: key,
fontSize: size,
fontFamily: 'sans',
bold: false,
italic: false,
color: '#000000',
align: 'left',
});
const itemsOf = (body: ReadinessReport, key: string) =>
body.items.filter((item) => item.key === key);
const openSubjectIds = (body: ReadinessReport, key: string) =>
itemsOf(body, key)
.filter((item) => item.state === 'OPEN')
.flatMap((item) => [
item.subjectId,
...item.subjects.map((subject) => subject.id),
]);
async function postTreatment(body: Record<string, unknown>) {
const response = await request(server())
.post('/api/v1/treatments')
.set(as('beekeeper'))
.send({ colonyId, date: today, ...body });
expect(response.status).toBe(201);
const id = (response.body as { id: string }).id;
created.treatments.push(id);
return id;
}
async function makeLot(patch: Record<string, unknown>) {
const lot = await prisma.veterinaryMedicineLot.create({
data: {
medicineId,
purchasedOn: dayToDate('2026-01-10'),
quantity: 100,
supplier: 'Apotheke E2E',
purchaseProof: 'Beleg E2E',
expiresOn: dayToDate('2099-01-01'),
...patch,
},
});
created.lots.push(lot.id);
return lot.id;
}
beforeAll(async () => {
const owner = await prisma.user.findFirstOrThrow({
where: { email: `readiness-e2e-beekeeper-${suffix}@example.com` },
});
userId = owner.id;
colonyId = (
await prisma.colony.create({
data: {
code: `RA-${area}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
})
).id;
medicineName = `Readiness-Mittel ${area}`;
medicineId = (
await prisma.veterinaryMedicine.create({
data: { name: medicineName, unit: 'ML', withdrawalPeriodDays: 0 },
})
).id;
lotOk = await makeLot({});
lotSmall = await makeLot({ quantity: 10 });
lotOld = await makeLot({
quantity: 5,
expiresOn: dayToDate('2020-01-01'),
});
treatmentOk = await postTreatment({
medicineLotId: lotOk,
doseQuantity: 30,
withdrawalPeriodDays: 0,
});
treatmentSmall = await postTreatment({
medicineLotId: lotSmall,
doseQuantity: 30,
withdrawalPeriodDays: 0,
});
treatmentOld = await postTreatment({
medicineLotId: lotOld,
doseQuantity: 1,
withdrawalPeriodDays: 0,
});
await postTreatment({
product: `Wartezeit-Mittel ${area}`,
amount: '10 ml',
supplier: 'Apotheke E2E',
purchaseProof: 'Beleg E2E',
withdrawalPeriodDays: 10,
});
harvestInside = (
await prisma.harvest.create({
data: { apiaryId, date: dayToDate(today), userId },
})
).id;
harvestAfter = (
await prisma.harvest.create({
data: { apiaryId, date: dayToDate(addDays(today, 20)), userId },
})
).id;
created.harvests.push(harvestInside, harvestAfter);
for (const harvestId of created.harvests) {
await prisma.harvestColony.create({ data: { harvestId, colonyId } });
}
// A complete label: its own bottling (far in the future, so it is the
// newest one with stock) and a product with a legal name.
productId = (
await prisma.product.create({
data: { name: `Readiness-Glas ${area}`, legalName: 'Blütenhonig' },
})
).id;
extractionId = (
await prisma.extraction.create({ data: { date: new Date(), userId } })
).id;
honeyBatchId = (
await prisma.honeyBatch.create({
data: {
batchNumber: `RA-${area}`,
extractionId,
amount: 25,
initialAmount: 25,
},
})
).id;
bottlingId = (
await prisma.bottlingBatch.create({
data: {
honeyBatchId,
productId,
date: new Date('2099-06-01T00:00:00.000Z'),
jarSize: 500,
jarCount: 2,
totalAmount: 1,
bestBeforeDate: new Date('2101-06-01T00:00:00.000Z'),
lotNumber: `RA-L-${area}`,
userId,
},
})
).id;
templateId = (
await prisma.labelTemplate.create({
data: {
name: `Readiness-Vorlage ${area}`,
elements: [
textElement('legal', 1, 'legalName'),
textElement('net', 10, 'jarSize', 16),
textElement('mhd', 20, 'bestBeforeLine'),
textElement('lot', 29, 'lotLine'),
textElement('origin', 38, 'originCountry'),
textElement('firm', 47, 'foodBusinessName'),
textElement('address', 56, 'foodBusinessAddress'),
],
widthMm: 90,
heightMm: 70,
},
})
).id;
await setSite({
holderName: 'Imker E2E',
holderAddress: 'Weg 1, 12345 Ort',
foodBusinessName: 'Imkerei E2E',
foodBusinessAddress: 'Weg 2, 12345 Ort',
});
await prisma.complianceSettings.upsert({
where: { id: 'singleton' },
update: {},
create: { id: 'singleton' },
});
});
afterAll(async () => {
await prisma.veterinaryMedicineMovement.deleteMany({
where: { lotId: { in: created.lots } },
});
await prisma.domainEvent.deleteMany({
where: {
aggregateType: 'Treatment',
aggregateId: { in: created.treatments },
},
});
await prisma.treatment.deleteMany({
where: { id: { in: created.treatments } },
});
await prisma.veterinaryMedicineLot.deleteMany({
where: { id: { in: created.lots } },
});
await prisma.veterinaryMedicine.deleteMany({
where: { id: medicineId },
});
await prisma.harvestColony.deleteMany({
where: { harvestId: { in: created.harvests } },
});
await prisma.harvest.deleteMany({
where: { id: { in: created.harvests } },
});
await prisma.bottlingBatch.deleteMany({ where: { id: bottlingId } });
await prisma.honeyBatch.deleteMany({ where: { id: honeyBatchId } });
await prisma.extraction.deleteMany({ where: { id: extractionId } });
await prisma.labelTemplate.deleteMany({ where: { id: templateId } });
await prisma.product.deleteMany({ where: { id: productId } });
await prisma.colony.deleteMany({ where: { id: colonyId } });
});
it('complete data in every area leaves no open item for the subjects of this test and no open item of the Stammdaten rules', async () => {
await setVetOffice('Amt E2E');
const body = await report();
const keys = [
'treatment-log-gaps',
'treatment-location-uncertain',
'lot-gaps',
'lot-prescription',
'lot-expired-stock',
'treatment-expired-lot',
'label-template-gaps',
'label-template-form',
'medicine-withdrawal-missing',
];
const ownIds = [
treatmentOk,
treatmentSmall,
lotOk,
lotSmall,
medicineId,
templateId,
harvestAfter,
];
for (const key of ['holder-data', 'food-business-data', 'vet-office']) {
expect(itemOf(body, key).state).toBe('DONE');
}
for (const key of keys) {
const open = openSubjectIds(body, key);
for (const id of ownIds) {
expect({ key, id, open: open.includes(id) }).toEqual({
key,
id,
open: false,
});
}
}
});
it('a treatment from a complete lot gives a Bestandsbuch row without FEHLT and no treatment-log-gaps item', async () => {
const year = Number(today.slice(0, 4));
const log = await buildTreatmentLog(prisma, year, apiaryId);
const rows = log.sheets
.flatMap((sheet) => sheet.rows)
.filter((row) => row.lotId === lotOk);
expect(rows).toHaveLength(1);
expect(treatmentLogGaps(rows[0])).toEqual([]);
const open = openSubjectIds(await report(), 'treatment-log-gaps');
expect(open).not.toContain(treatmentOk);
expect(open).not.toContain(lotOk);
});
it('the gap names of an incomplete treatment equal the gaps the Bestandsbuch row reports', async () => {
// Dated in the first year of the lookback: the item lists at most 50
// subjects and the shared database holds other treatments.
const year = Number(today.slice(0, 4)) - 5;
const id = await postTreatment({
date: `${year}-01-01`,
product: `Luecken-Mittel ${area}`,
withdrawalPeriodDays: 0,
});
const item = itemOf(await report(), 'treatment-log-gaps', String(year));
const subject = item.subjects.find((entry) => entry.id === id);
const log = await buildTreatmentLog(prisma, year, apiaryId);
const row = log.sheets
.flatMap((sheet) => sheet.rows)
.find((entry) => entry.product === `Luecken-Mittel ${area}`)!;
const names = {
SUPPLIER: 'Lieferant',
PROOF: 'Beleg',
AMOUNT: 'Menge',
WITHDRAWAL: 'Wartezeit',
VET: 'Tierarzt',
};
expect(item.state).toBe('OPEN');
expect(subject).toMatchObject({ type: 'TREATMENT', parentId: colonyId });
expect(subject!.missing).toEqual(
treatmentLogGaps(row).map((code) => names[code]),
);
expect(JSON.stringify(item)).not.toContain('Luecken-Mittel');
});
it('a negative rest after a treatment from a too small lot opens lot-negative', async () => {
const body = await report();
expect(itemOf(body, 'lot-negative').state).toBe('OPEN');
expect(openSubjectIds(body, 'lot-negative')).toContain(lotSmall);
expect(openSubjectIds(body, 'lot-negative')).not.toContain(lotOk);
});
it('lot-expired-stock is acknowledgeable per lot, a booking that changes the rest reopens it', async () => {
const before = itemOf(await report(), 'lot-expired-stock', lotOld);
expect(before).toMatchObject({ state: 'OPEN', acknowledgeable: true });
expect(openSubjectIds(await report(), 'treatment-expired-lot')).toContain(
treatmentOld,
);
const response = await acknowledge({
itemKey: 'lot-expired-stock',
subjectId: lotOld,
fingerprint: before.fingerprint,
note: 'Wird bei der nächsten Abgabe entsorgt.',
});
expect(response.status).toBe(201);
acknowledgementIds.push(
(response.body as ReadinessItem).acknowledgement!.id,
);
expect(itemOf(await report(), 'lot-expired-stock', lotOld).state).toBe(
'ACKNOWLEDGED',
);
const disposal = await request(server())
.post(`/api/v1/veterinary-medicine-lots/${lotOld}/movements`)
.set(as('beekeeper'))
.send({ type: 'DISPOSAL', quantity: 1, reason: 'Teil entsorgt' });
expect(disposal.status).toBe(201);
expect(itemOf(await report(), 'lot-expired-stock', lotOld).state).toBe(
'OPEN',
);
});
it('hides the lot subjects without veterinary-medicines:read but keeps the count', async () => {
const masked = itemOf(await report('complianceonly'), 'lot-negative');
const full = itemOf(await report('beekeeper'), 'lot-negative');
expect(masked.state).toBe('OPEN');
expect(masked.subjects).toEqual([]);
expect(masked.more).toBeGreaterThanOrEqual(1);
expect(JSON.stringify(masked)).not.toContain(medicineName);
expect(full.subjects.map((subject) => subject.id)).toContain(lotSmall);
});
it('opens harvest-withdrawal only for the harvest inside the window and acknowledges it', async () => {
const body = await report();
const open = openSubjectIds(body, 'harvest-withdrawal');
expect(open).toContain(harvestInside);
expect(open).not.toContain(harvestAfter);
const item = itemOf(body, 'harvest-withdrawal', harvestInside);
expect(JSON.stringify(item)).not.toContain('Wartezeit-Mittel');
const response = await acknowledge({
itemKey: 'harvest-withdrawal',
subjectId: harvestInside,
fingerprint: item.fingerprint,
note: 'Honig bleibt bis zum Ende der Wartezeit zurück.',
});
expect(response.status).toBe(201);
acknowledgementIds.push(
(response.body as ReadinessItem).acknowledgement!.id,
);
});
it('the label rules judge a template against the newest bottling with stock', async () => {
expect(
openSubjectIds(await report(), 'label-template-gaps'),
).not.toContain(templateId);
await prisma.labelTemplate.update({
where: { id: templateId },
data: { elements: [textElement('legal', 1, 'legalName')] },
});
expect(openSubjectIds(await report(), 'label-template-gaps')).toContain(
templateId,
);
});
const fetchPdf = (user: string) =>
request(server())
.get(`${READINESS}/report.pdf`)
.set(as(user))
.buffer(true)
.parse((res, callback) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => callback(null, Buffer.concat(chunks)));
});
it('GET /compliance/readiness/report.pdf answers application/pdf and no-store', async () => {
const response = await fetchPdf('beekeeper');
expect(response.status).toBe(200);
expect(response.headers['content-type']).toContain('application/pdf');
expect(response.headers['cache-control']).toBe('no-store');
expect(response.headers['content-disposition']).toContain(
'pruefbericht.pdf',
);
const text = pdfText(response.body as Buffer);
expect(text).toContain('Prüfbericht');
expect(text).not.toContain(medicineName);
expect((await fetchPdf('apiclient')).status).toBe(403);
});
it('the report PDF of a user with compliance:read but without veterinary-medicines:read names no medicine', async () => {
const response = await fetchPdf('complianceonly');
expect(response.status).toBe(200);
const text = pdfText(response.body as Buffer);
expect(text).toContain('Prüfbericht');
for (const name of [
medicineName,
`Wartezeit-Mittel ${area}`,
`Luecken-Mittel ${area}`,
]) {
expect(text).not.toContain(name);
}
});
});
});