feat: T6 wave 5 – readiness checks and report, compliance and medicine UI, label UI
- 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:
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 } : {}),
|
||||
};
|
||||
}
|
||||
+51
@@ -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)],
|
||||
},
|
||||
];
|
||||
});
|
||||
},
|
||||
};
|
||||
+84
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user