feat: T6 wave 3 – treatment books from lot, label print lock, restricted zone, authority notices
- Treatments book their dose from a medicine lot (delta booking on edit, bulk all-or-nothing, idempotent); Bestandsbuch columns from the lot, FEHLT exactly where treatmentLogGaps reports a gap; Bestandsbuch export needs veterinary-medicines:read. - Label printing: 409 LABEL_PARTICULARS_MISSING on gaps, proof print with watermark, PRINT audit before the PDF, check endpoints; digit rules only from the seeded ComplianceSettings row (fail closed). - Restricted zone (Berlin day): create, update, move, merge and CSV import blocked inside an active restriction; override with reason and compliance:manage, audited, never stored on the colony; split, harvest and dissolve stay free; restriction details only with compliance:read. - Authority notices: settings, herd snapshot, registration/herd change/TSK notices with one open draft per kind under the settings lock. - Label check refinements (geometric reading order, number|unit pairs). - ARCHITECTURE.md §12.6–12.8, Annahmen 71–77; spec §5.6 Berlin day. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c18860c75c
commit
edc193f128
73 files changed
+9470
-171
No files matched your search
@@ -0,0 +1,154 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayUnique,
|
||||
Equals,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { PLACEHOLDER_KEYS } from '../label-element.types';
|
||||
|
||||
const FONT_FAMILIES = ['sans', 'serif', 'mono'] as const;
|
||||
const TEXT_ALIGNS = ['left', 'center', 'right'] as const;
|
||||
const MAX_ELEMENTS = 100;
|
||||
const MAX_FONT_SIZE_PT = 500;
|
||||
// A label is a few centimetres; the bounds only keep absurd numbers out.
|
||||
const MAX_COORDINATE_MM = 1000;
|
||||
const MIN_BOX_MM = 0.1;
|
||||
const MAX_SHEET_MM = 1000;
|
||||
|
||||
/**
|
||||
* A text element of a label draft. The check only looks at text, so every
|
||||
* other element type is refused here instead of being ignored: a draft that
|
||||
* sends an image is a client bug worth a 400.
|
||||
*/
|
||||
export class LabelTextElementDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ enum: ['text'] })
|
||||
@Equals('text')
|
||||
type!: 'text';
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
@Min(-MAX_COORDINATE_MM)
|
||||
@Max(MAX_COORDINATE_MM)
|
||||
x!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
@Min(-MAX_COORDINATE_MM)
|
||||
@Max(MAX_COORDINATE_MM)
|
||||
y!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
@Min(MIN_BOX_MM)
|
||||
@Max(MAX_SHEET_MM)
|
||||
width!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
@Min(MIN_BOX_MM)
|
||||
@Max(MAX_SHEET_MM)
|
||||
height!: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Literaler Text' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
text?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: PLACEHOLDER_KEYS })
|
||||
@IsOptional()
|
||||
@IsIn(PLACEHOLDER_KEYS)
|
||||
placeholderKey?: (typeof PLACEHOLDER_KEYS)[number] | null;
|
||||
|
||||
@ApiProperty({ description: 'Schriftgröße in pt' })
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
@Max(MAX_FONT_SIZE_PT)
|
||||
fontSize!: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: FONT_FAMILIES })
|
||||
@IsOptional()
|
||||
@IsIn(FONT_FAMILIES)
|
||||
fontFamily?: (typeof FONT_FAMILIES)[number];
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
bold?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
italic?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
color?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: TEXT_ALIGNS })
|
||||
@IsOptional()
|
||||
@IsIn(TEXT_ALIGNS)
|
||||
align?: (typeof TEXT_ALIGNS)[number];
|
||||
}
|
||||
|
||||
/** POST /label-templates/check: a draft that is not saved yet. */
|
||||
export class CheckLabelTemplateDto {
|
||||
@ApiProperty({ type: [LabelTextElementDto] })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(MAX_ELEMENTS)
|
||||
@ArrayUnique((element: LabelTextElementDto) => element.id)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => LabelTextElementDto)
|
||||
elements!: LabelTextElementDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Wird geprüft, fließt aber nicht in die Prüfung ein.',
|
||||
})
|
||||
@IsNumber()
|
||||
@Min(10)
|
||||
@Max(MAX_SHEET_MM)
|
||||
widthMm!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
@Min(10)
|
||||
@Max(MAX_SHEET_MM)
|
||||
heightMm!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Kleinste x-Höhe (mm) des Entwurfs, 0,5 bis 5.',
|
||||
})
|
||||
@IsNumber()
|
||||
@Min(0.5)
|
||||
@Max(5)
|
||||
minXHeightMm!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Abfüllung, gegen die geprüft wird. Leer = Beispielwerte.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@Matches(/^[A-Za-z0-9_-]+$/)
|
||||
bottlingBatchId?: string;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { ConflictException, Logger } from '@nestjs/common';
|
||||
import {
|
||||
expectedParticulars,
|
||||
loadDigitRules,
|
||||
parseDigitRules,
|
||||
} from './label-check.loader';
|
||||
|
||||
describe('expectedParticulars', () => {
|
||||
it('maps a bottling batch to the expected particulars (jar grams, best-before day, lot, origin, master data)', () => {
|
||||
const expected = expectedParticulars(
|
||||
{
|
||||
jarSize: 500,
|
||||
lotNumber: '2026-0042',
|
||||
// Midnight UTC: stays on its own calendar day.
|
||||
bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
originCountry: 'Deutschland',
|
||||
foodBusinessName: 'Imkerei Muster',
|
||||
foodBusinessAddress: 'Bühlweg 10, 86450 Altenmünster',
|
||||
},
|
||||
);
|
||||
|
||||
expect(expected).toEqual({
|
||||
jarSizeGrams: 500,
|
||||
bestBefore: '2028-06-01',
|
||||
lotNumber: '2026-0042',
|
||||
originCountry: 'Deutschland',
|
||||
foodBusinessName: 'Imkerei Muster',
|
||||
foodBusinessAddress: 'Bühlweg 10, 86450 Altenmünster',
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the best-before day from labelDayOf, an instant maps to its Berlin day', () => {
|
||||
const expected = expectedParticulars(
|
||||
{
|
||||
jarSize: 250,
|
||||
lotNumber: 'L1',
|
||||
bestBeforeDate: new Date('2028-06-01T22:30:00.000Z'),
|
||||
},
|
||||
{
|
||||
originCountry: 'Deutschland',
|
||||
foodBusinessName: null,
|
||||
foodBusinessAddress: null,
|
||||
},
|
||||
);
|
||||
|
||||
expect(expected.bestBefore).toBe('2028-06-02');
|
||||
expect(expected.foodBusinessName).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDigitRules', () => {
|
||||
const open = { maxGrams: null, minMm: 5 };
|
||||
|
||||
it('reads the stored rules', () => {
|
||||
const rules = [{ maxGrams: 100, minMm: 2 }, open];
|
||||
expect(parseDigitRules(rules)).toEqual(rules);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['null', null],
|
||||
['undefined', undefined],
|
||||
['a string', 'x'],
|
||||
['an object', {}],
|
||||
['an empty array', []],
|
||||
['a non-numeric limit', [{ maxGrams: 'a', minMm: 2 }, open]],
|
||||
['an empty rule', [{}]],
|
||||
['minMm zero', [{ maxGrams: 100, minMm: 0 }, open]],
|
||||
['minMm negative', [{ maxGrams: 100, minMm: -1 }, open]],
|
||||
['minMm not finite', [{ maxGrams: 100, minMm: Infinity }, open]],
|
||||
['maxGrams not finite', [{ maxGrams: Infinity, minMm: 2 }, open]],
|
||||
['no rule without a limit', [{ maxGrams: 100, minMm: 2 }]],
|
||||
['two rules without a limit', [open, { maxGrams: null, minMm: 6 }]],
|
||||
])('rejects %s', (_name, value) => {
|
||||
expect(parseDigitRules(value)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadDigitRules', () => {
|
||||
let errorLog: jest.SpyInstance;
|
||||
const dbWith = (row: unknown) => ({
|
||||
complianceSettings: { findUnique: jest.fn().mockResolvedValue(row) },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
errorLog = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
async function refusal(row: unknown): Promise<ConflictException> {
|
||||
try {
|
||||
await loadDigitRules(dbWith(row) as never);
|
||||
} catch (error) {
|
||||
return error as ConflictException;
|
||||
}
|
||||
throw new Error('expected a refusal');
|
||||
}
|
||||
|
||||
it('fails closed with 409 when the settings row is missing', async () => {
|
||||
const error = await refusal(null);
|
||||
expect(error).toBeInstanceOf(ConflictException);
|
||||
expect(error.getResponse()).toEqual({
|
||||
code: 'COMPLIANCE_SETTINGS_INVALID',
|
||||
message: 'Compliance-Einstellungen fehlen oder sind ungültig.',
|
||||
});
|
||||
expect(errorLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fails closed with 409 when the stored rules are invalid', async () => {
|
||||
const error = await refusal({ netQuantityDigitRules: [{ minMm: 0 }] });
|
||||
expect(error.getStatus()).toBe(409);
|
||||
});
|
||||
|
||||
it('uses the rules of the settings row', async () => {
|
||||
const rules = [{ maxGrams: null, minMm: 7 }];
|
||||
const db = dbWith({ netQuantityDigitRules: rules });
|
||||
await expect(loadDigitRules(db as never)).resolves.toEqual(rules);
|
||||
expect(db.complianceSettings.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'singleton' },
|
||||
select: { netQuantityDigitRules: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { ConflictException, Logger } from '@nestjs/common';
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import { DbClient } from '../prisma/db-client';
|
||||
import { LabelCheckInput, NetQuantityDigitRule } from './label-particulars';
|
||||
import { labelDayOf } from './label-text';
|
||||
|
||||
const COMPLIANCE_SETTINGS_ID = 'singleton';
|
||||
export const COMPLIANCE_SETTINGS_INVALID = 'COMPLIANCE_SETTINGS_INVALID';
|
||||
const logger = new Logger('LabelCheckLoader');
|
||||
|
||||
export interface BottlingForCheck {
|
||||
jarSize: number;
|
||||
lotNumber: string;
|
||||
bestBeforeDate: Date;
|
||||
}
|
||||
|
||||
export interface MasterDataForCheck {
|
||||
originCountry: string;
|
||||
foodBusinessName: string | null;
|
||||
foodBusinessAddress: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the label must show for this bottling. The best-before day comes from
|
||||
* `labelDayOf`, the function the printed text uses too, so check and print
|
||||
* cannot disagree about the day.
|
||||
*/
|
||||
export function expectedParticulars(
|
||||
bottling: BottlingForCheck,
|
||||
settings: MasterDataForCheck,
|
||||
): LabelCheckInput['expected'] {
|
||||
return {
|
||||
jarSizeGrams: bottling.jarSize,
|
||||
bestBefore: labelDayOf(bottling.bestBeforeDate),
|
||||
lotNumber: bottling.lotNumber,
|
||||
originCountry: settings.originCountry,
|
||||
foodBusinessName: settings.foodBusinessName,
|
||||
foodBusinessAddress: settings.foodBusinessAddress,
|
||||
};
|
||||
}
|
||||
|
||||
function isDigitRule(value: unknown): value is NetQuantityDigitRule {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
const { maxGrams, minMm } = value as Record<string, unknown>;
|
||||
return (
|
||||
(maxGrams === null ||
|
||||
(typeof maxGrams === 'number' && Number.isFinite(maxGrams))) &&
|
||||
typeof minMm === 'number' &&
|
||||
Number.isFinite(minMm)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored JSON as rules, or null when it cannot be trusted: a rule needs
|
||||
* a finite positive height, the list must not be empty, and exactly one rule
|
||||
* (the catch-all for any size) has no upper limit.
|
||||
*/
|
||||
export function parseDigitRules(value: unknown): NetQuantityDigitRule[] | null {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length === 0 ||
|
||||
!value.every(isDigitRule)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const rules = value.map(({ maxGrams, minMm }) => ({ maxGrams, minMm }));
|
||||
const open = rules.filter((rule) => rule.maxGrams === null).length;
|
||||
return open === 1 && rules.every((rule) => rule.minMm > 0) ? rules : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The digit rules of the compliance settings. No legal value lives in the
|
||||
* code: a missing row or unusable rules refuse the check (409) instead of
|
||||
* checking against a guess.
|
||||
*/
|
||||
export async function loadDigitRules(
|
||||
db: DbClient,
|
||||
): Promise<NetQuantityDigitRule[]> {
|
||||
const row = await db.complianceSettings.findUnique({
|
||||
where: { id: COMPLIANCE_SETTINGS_ID },
|
||||
select: { netQuantityDigitRules: true },
|
||||
});
|
||||
const rules = parseDigitRules(row?.netQuantityDigitRules);
|
||||
if (!rules) {
|
||||
const { message } = loggableError(
|
||||
new Error(
|
||||
row
|
||||
? 'Invalid netQuantityDigitRules'
|
||||
: 'ComplianceSettings row missing',
|
||||
),
|
||||
);
|
||||
logger.error(message);
|
||||
throw new ConflictException({
|
||||
code: COMPLIANCE_SETTINGS_INVALID,
|
||||
message: 'Compliance-Einstellungen fehlen oder sind ungültig.',
|
||||
});
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
@@ -68,7 +68,7 @@ const FORBIDDEN = new RegExp(
|
||||
'toLocaleTimeString\\(',
|
||||
'toLocaleDateString\\((?![^)]*timeZone)',
|
||||
'toLocaleString\\((?![^)]*timeZone)',
|
||||
'new Intl\\.DateTimeFormat\\((?![^)]*timeZone)',
|
||||
'Intl\\.DateTimeFormat\\((?![^)]*timeZone)',
|
||||
// new Date(y, m, d, ...): several arguments build a local-time date
|
||||
'new Date\\((?:[^(),]|\\([^()]*\\))+,',
|
||||
].join('|'),
|
||||
@@ -86,6 +86,7 @@ describe('local-time guard', () => {
|
||||
'date.toDateString()',
|
||||
"date.toLocaleDateString('de-DE')",
|
||||
"new Intl.DateTimeFormat('de-DE')",
|
||||
"Intl.DateTimeFormat('de-DE')",
|
||||
'new Date(2027, 2, 1)',
|
||||
])('flags %s', (sample) => {
|
||||
expect(sample).toMatch(FORBIDDEN);
|
||||
|
||||
@@ -735,23 +735,57 @@ describe('checkLabel: round 3', () => {
|
||||
});
|
||||
|
||||
it('measures the distance against the whole group, not the last element', () => {
|
||||
// a tall street box, an empty box below it, the town further down: the
|
||||
// empty box alone would be 24 mm from the town, the street box is not
|
||||
// A is a tall box, B a short one below its top, C lies 24 mm below B but
|
||||
// inside A's box: only the running extent of the group joins C
|
||||
const elements = [
|
||||
...base(),
|
||||
textElement('street', null, 62, {
|
||||
text: 'Bühlweg 10',
|
||||
height: 40,
|
||||
}),
|
||||
textElement('empty', null, 64, { text: '', height: 2 }),
|
||||
textElement('town', null, 90, {
|
||||
text: '86450 Altenmünster',
|
||||
height: 5,
|
||||
}),
|
||||
textElement('a', null, 62, { text: 'Bühlweg', height: 40 }),
|
||||
textElement('b', null, 64, { x: 30, text: '10', height: 2 }),
|
||||
textElement('c', null, 90, { text: '86450 Altenmünster', height: 5 }),
|
||||
];
|
||||
expect(checkLabel(input({ elements })).gaps).toEqual([]);
|
||||
});
|
||||
|
||||
it('an empty box does not bridge distances', () => {
|
||||
const elements = [
|
||||
...base(),
|
||||
textElement('street', null, 62, { text: 'Bühlweg 10', height: 5 }),
|
||||
textElement('empty', null, 66, { text: '', height: 40 }),
|
||||
textElement('town', null, 100, { text: '86450 Altenmünster', height: 5 }),
|
||||
];
|
||||
expect(codes(checkLabel(input({ elements })).gaps)).toEqual([
|
||||
'FOOD_BUSINESS',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a tall text box bridges rows by its box height (documented)', () => {
|
||||
const street = (height: number) => [
|
||||
...base(),
|
||||
textElement('street', null, 62, { text: 'Bühlweg 10', height }),
|
||||
textElement('town', null, 100, { text: '86450 Altenmünster', height: 5 }),
|
||||
];
|
||||
expect(checkLabel(input({ elements: street(40) })).gaps).toEqual([]);
|
||||
expect(codes(checkLabel(input({ elements: street(5) })).gaps)).toEqual([
|
||||
'FOOD_BUSINESS',
|
||||
]);
|
||||
});
|
||||
|
||||
it('names only clipped parts that belong to the match', () => {
|
||||
const measurer: TextMeasurer = {
|
||||
heightOfStringPt: (_text, element) =>
|
||||
element.id === 'town' || element.id === 'lot-ref' ? 999 : 0,
|
||||
};
|
||||
const elements = [
|
||||
...base(),
|
||||
textElement('street', null, 62, { text: 'Bühlweg 10', height: 5 }),
|
||||
textElement('town', null, 68, { text: '86450 Altenmünster', height: 5 }),
|
||||
textElement('lot-ref', null, 90, { text: 'Lot 10', height: 5 }),
|
||||
];
|
||||
const result = checkLabel(input({ elements, measurer }));
|
||||
expect(codes(result.gaps)).toEqual(['FOOD_BUSINESS']);
|
||||
expect(result.gaps[0].elementIds).toEqual(['town']);
|
||||
});
|
||||
|
||||
describe('number and unit in two elements', () => {
|
||||
function split(numberSize: number, unitSize: number): LabelTextElement[] {
|
||||
return [
|
||||
@@ -792,6 +826,38 @@ describe('checkLabel: round 3', () => {
|
||||
expect(codes(result.warnings)).not.toContain('NET_QUANTITY_HEIGHT');
|
||||
});
|
||||
|
||||
it('finds a big number with a small, bottom-aligned unit', () => {
|
||||
const elements = [
|
||||
...without('net'),
|
||||
textElement('num', null, 14, {
|
||||
x: 2,
|
||||
width: 20,
|
||||
height: 10,
|
||||
fontSize: 20,
|
||||
text: '500',
|
||||
}),
|
||||
textElement('unit', null, 20, {
|
||||
x: 24,
|
||||
width: 10,
|
||||
height: 4,
|
||||
fontSize: 10,
|
||||
text: 'g',
|
||||
}),
|
||||
];
|
||||
expect(checkLabel(input({ elements })).gaps).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not pair a far left number with a far right unit', () => {
|
||||
const elements = [
|
||||
...without('net'),
|
||||
textElement('num', null, 14, { x: 2, width: 20, text: '500' }),
|
||||
textElement('unit', null, 14, { x: 70, width: 10, text: 'g' }),
|
||||
];
|
||||
expect(codes(checkLabel(input({ elements })).gaps)).toEqual([
|
||||
'NET_QUANTITY',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not join across rows or onto a bigger number', () => {
|
||||
const otherRow = [
|
||||
...without('net'),
|
||||
|
||||
@@ -203,7 +203,8 @@ function distanceTo(span: Span, candidate: Candidate): number {
|
||||
function groupByDistance(list: Candidate[]): Candidate[][] {
|
||||
const groups: Candidate[][] = [];
|
||||
let span: Span = { top: 0, bottom: 0 };
|
||||
for (const candidate of list) {
|
||||
// An empty box (a placeholder without a value) must not bridge distances.
|
||||
for (const candidate of list.filter((entry) => entry.normalized !== '')) {
|
||||
const group = groups[groups.length - 1];
|
||||
if (group && distanceTo(span, candidate) <= MAX_JOIN_GAP_MM) {
|
||||
group.push(candidate);
|
||||
@@ -295,9 +296,20 @@ function quantityPairs(views: TextViews, needles: string[]): Candidate[][] {
|
||||
const pairs: Candidate[][] = [];
|
||||
views.visible.forEach((first, index) => {
|
||||
const second = views.visible[index + 1];
|
||||
// Side by side: the boxes overlap vertically (a big number with a small,
|
||||
// bottom-aligned unit), the unit lies to the right and not far away.
|
||||
if (
|
||||
!second ||
|
||||
Math.abs(second.element.y - first.element.y) > ROW_TOLERANCE_MM
|
||||
distanceTo(
|
||||
{
|
||||
top: first.element.y,
|
||||
bottom: first.element.y + first.element.height,
|
||||
},
|
||||
second,
|
||||
) > 0 ||
|
||||
second.element.x < first.element.x ||
|
||||
second.element.x - (first.element.x + first.element.width) >
|
||||
MAX_JOIN_GAP_MM
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -528,6 +540,7 @@ export function checkLabel(input: LabelCheckInput): LabelCheckResult {
|
||||
const candidates = candidatesOf(input, measurer);
|
||||
const visible = candidates.filter((candidate) => !candidate.clipped);
|
||||
const views = textViews(visible);
|
||||
const allViews = textViews(candidates);
|
||||
const combined = views.full;
|
||||
|
||||
const gaps: LabelFinding[] = [];
|
||||
@@ -558,13 +571,20 @@ export function checkLabel(input: LabelCheckInput): LabelCheckResult {
|
||||
...new Set([...singleHolders, ...splitParts, ...pairs.flat()]),
|
||||
];
|
||||
holders.forEach((candidate) => carriers.add(candidate));
|
||||
// Clipped parts of a split phrase: those that would be part of the match
|
||||
// had they been visible.
|
||||
const clippedSplit = new Set(
|
||||
splitPhrases.flatMap((phrase) =>
|
||||
groupsHolding(allViews, phrase)
|
||||
.flat()
|
||||
.filter((candidate) => candidate.clipped),
|
||||
),
|
||||
);
|
||||
const clippedHolders = candidates.filter(
|
||||
(candidate) =>
|
||||
candidate.clipped &&
|
||||
(particular.isCarriedBy(candidate.normalized) ||
|
||||
splitPhrases.some((phrase) =>
|
||||
sharesWord(candidate.normalized, phrase),
|
||||
)),
|
||||
clippedSplit.has(candidate)),
|
||||
);
|
||||
clippedHolders.forEach((candidate) => clippedCarriers.add(candidate));
|
||||
if (!isOnLabel) {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { drawProofWatermark } from './label-proof-watermark';
|
||||
|
||||
type Call = [name: string, ...args: unknown[]];
|
||||
|
||||
/** A PDFKit stand-in that records the drawing calls in order. */
|
||||
function fakeDoc() {
|
||||
const calls: Call[] = [];
|
||||
const doc: Record<string, unknown> = {};
|
||||
const record =
|
||||
(name: string) =>
|
||||
(...args: unknown[]) => {
|
||||
calls.push([name, ...args]);
|
||||
return doc;
|
||||
};
|
||||
for (const name of [
|
||||
'save',
|
||||
'restore',
|
||||
'rotate',
|
||||
'fillColor',
|
||||
'fillOpacity',
|
||||
'font',
|
||||
'fontSize',
|
||||
'text',
|
||||
]) {
|
||||
doc[name] = record(name);
|
||||
}
|
||||
doc.widthOfString = () => 100;
|
||||
return { doc: doc as unknown as PDFKit.PDFDocument, calls };
|
||||
}
|
||||
|
||||
const callsOf = (calls: Call[], name: string) =>
|
||||
calls.filter(([callName]) => callName === name);
|
||||
|
||||
describe('drawProofWatermark', () => {
|
||||
it('draws the text MUSTER – nicht verkaufsfähig rotated, grey, half opaque, once per label', () => {
|
||||
const { doc, calls } = fakeDoc();
|
||||
|
||||
drawProofWatermark(doc, 10, 20, 255, 99);
|
||||
|
||||
const texts = callsOf(calls, 'text');
|
||||
expect(texts).toHaveLength(1);
|
||||
expect(texts[0][1]).toBe('MUSTER – nicht verkaufsfähig');
|
||||
expect(callsOf(calls, 'fillOpacity')).toEqual([['fillOpacity', 0.5]]);
|
||||
const [, color] = callsOf(calls, 'fillColor')[0];
|
||||
expect(color).toBe('#808080');
|
||||
const [, angle] = callsOf(calls, 'rotate')[0];
|
||||
expect(angle).toBeLessThan(0);
|
||||
expect(angle).toBeGreaterThan(-90);
|
||||
});
|
||||
|
||||
it('saves and restores the graphics state around the watermark', () => {
|
||||
const { doc, calls } = fakeDoc();
|
||||
|
||||
drawProofWatermark(doc, 0, 0, 255, 99);
|
||||
|
||||
expect(calls[0][0]).toBe('save');
|
||||
expect(calls[calls.length - 1][0]).toBe('restore');
|
||||
});
|
||||
|
||||
it('keeps the text on the label: centred on the label box', () => {
|
||||
const { doc, calls } = fakeDoc();
|
||||
|
||||
drawProofWatermark(doc, 10, 20, 200, 100);
|
||||
|
||||
const [, , options] = callsOf(calls, 'rotate')[0] as [
|
||||
string,
|
||||
number,
|
||||
{ origin: [number, number] },
|
||||
];
|
||||
expect(options.origin).toEqual([110, 70]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
export const PROOF_WATERMARK_TEXT = 'MUSTER – nicht verkaufsfähig';
|
||||
|
||||
const WATERMARK_COLOR = '#808080';
|
||||
const WATERMARK_OPACITY = 0.5;
|
||||
const WATERMARK_FONT = 'Helvetica-Bold';
|
||||
// The text runs along the label's diagonal; this share of it is covered.
|
||||
const DIAGONAL_COVERAGE = 0.8;
|
||||
// Never taller than this share of the label's height.
|
||||
const MAX_HEIGHT_SHARE = 0.5;
|
||||
const MEASURE_SIZE_PT = 10;
|
||||
|
||||
/**
|
||||
* Draws the proof stamp "MUSTER – nicht verkaufsfähig" over one label: grey,
|
||||
* half opaque, rotated along the label's diagonal and centred. Called once
|
||||
* per label, after its content. The graphics state is restored, so nothing
|
||||
* leaks into the next label.
|
||||
*/
|
||||
export function drawProofWatermark(
|
||||
doc: PDFKit.PDFDocument,
|
||||
originX: number,
|
||||
originY: number,
|
||||
widthPt: number,
|
||||
heightPt: number,
|
||||
): void {
|
||||
const centerX = originX + widthPt / 2;
|
||||
const centerY = originY + heightPt / 2;
|
||||
const angleDeg = (Math.atan2(heightPt, widthPt) * 180) / Math.PI;
|
||||
|
||||
doc.save();
|
||||
doc.font(WATERMARK_FONT).fontSize(MEASURE_SIZE_PT);
|
||||
const widthAtMeasureSize = doc.widthOfString(PROOF_WATERMARK_TEXT);
|
||||
const targetWidth = Math.hypot(widthPt, heightPt) * DIAGONAL_COVERAGE;
|
||||
const fontSize = Math.min(
|
||||
(MEASURE_SIZE_PT * targetWidth) / widthAtMeasureSize,
|
||||
heightPt * MAX_HEIGHT_SHARE,
|
||||
);
|
||||
const textWidth = (widthAtMeasureSize * fontSize) / MEASURE_SIZE_PT;
|
||||
|
||||
doc.fillColor(WATERMARK_COLOR).fillOpacity(WATERMARK_OPACITY);
|
||||
doc.rotate(-angleDeg, { origin: [centerX, centerY] });
|
||||
doc.fontSize(fontSize);
|
||||
doc.text(
|
||||
PROOF_WATERMARK_TEXT,
|
||||
centerX - textWidth / 2,
|
||||
centerY - fontSize / 2,
|
||||
{ lineBreak: false },
|
||||
);
|
||||
doc.restore();
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseBoolPipe,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
@@ -12,18 +16,34 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { CreateLabelTemplateDto } from './dto/create-label-template.dto';
|
||||
import { UpdateLabelTemplateDto } from './dto/update-label-template.dto';
|
||||
import { CheckLabelTemplateDto } from './dto/check-label-template.dto';
|
||||
import { LabelTemplatesService } from './label-templates.service';
|
||||
|
||||
// Far above any sheet grid; the service clamps to the real slot count.
|
||||
const MAX_START_POSITION = 1000;
|
||||
|
||||
@ApiTags('label-templates')
|
||||
@ApiBearerAuth()
|
||||
@Controller('label-templates')
|
||||
export class LabelTemplatesController {
|
||||
constructor(private readonly labelTemplatesService: LabelTemplatesService) {}
|
||||
constructor(
|
||||
private readonly labelTemplatesService: LabelTemplatesService,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
) {}
|
||||
|
||||
// A check or print reads the bottling (lot, dates, jar size), so it needs
|
||||
// the right to see bottlings as well as the label permission.
|
||||
private async assertCanReadBottling(userId: string): Promise<void> {
|
||||
if (!(await this.permissionResolver.hasAny(userId, ['bottling:read']))) {
|
||||
throw new ForbiddenException('Missing permission: bottling:read');
|
||||
}
|
||||
}
|
||||
|
||||
@RequirePermission('label-templates:read')
|
||||
@Get()
|
||||
@@ -31,6 +51,30 @@ export class LabelTemplatesController {
|
||||
return this.labelTemplatesService.findAll();
|
||||
}
|
||||
|
||||
@RequirePermission('label-templates:read')
|
||||
@Post('check')
|
||||
@HttpCode(200)
|
||||
async checkDraft(
|
||||
@Body() dto: CheckLabelTemplateDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
if (dto.bottlingBatchId) {
|
||||
await this.assertCanReadBottling(actor.id);
|
||||
}
|
||||
return this.labelTemplatesService.checkDraft(dto);
|
||||
}
|
||||
|
||||
@RequirePermission('label-templates:read')
|
||||
@Get(':id/check/:bottlingBatchId')
|
||||
async checkForBottling(
|
||||
@Param('id') id: string,
|
||||
@Param('bottlingBatchId') bottlingBatchId: string,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
await this.assertCanReadBottling(actor.id);
|
||||
return this.labelTemplatesService.checkForBottling(id, bottlingBatchId);
|
||||
}
|
||||
|
||||
@RequirePermission('label-templates:read')
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
@@ -69,12 +113,25 @@ export class LabelTemplatesController {
|
||||
@Param('bottlingBatchId') bottlingBatchId: string,
|
||||
@Query('startPosition', new ParseIntPipe({ optional: true }))
|
||||
startPosition: number | undefined,
|
||||
@Query('proof', new ParseBoolPipe({ optional: true }))
|
||||
proof: boolean | undefined,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
if (
|
||||
startPosition !== undefined &&
|
||||
(startPosition < 1 || startPosition > MAX_START_POSITION)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`startPosition must be between 1 and ${MAX_START_POSITION}`,
|
||||
);
|
||||
}
|
||||
await this.assertCanReadBottling(actor.id);
|
||||
const pdf = await this.labelTemplatesService.renderLabelsPdf(
|
||||
id,
|
||||
bottlingBatchId,
|
||||
startPosition,
|
||||
{ startPosition, proof },
|
||||
actor.id,
|
||||
);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
LabelTemplatesService,
|
||||
buildPlaceholderValues,
|
||||
@@ -7,12 +7,16 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { SiteSettingsService } from '../site-settings/site-settings.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { LabelElement } from './label-element.types';
|
||||
import { LabelElement, LabelTextElement } from './label-element.types';
|
||||
import * as watermark from './label-proof-watermark';
|
||||
import { CheckLabelTemplateDto } from './dto/check-label-template.dto';
|
||||
|
||||
jest.mock('../prisma/row-lock', () => ({
|
||||
lockRowForUpdate: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
const PROOF = { proof: true };
|
||||
|
||||
const SETTINGS = {
|
||||
originCountry: 'Deutschland',
|
||||
foodBusinessName: 'Imkerei Muster' as string | null,
|
||||
@@ -38,6 +42,7 @@ describe('LabelTemplatesService', () => {
|
||||
update: jest.Mock;
|
||||
};
|
||||
bottlingBatch: { findUnique: jest.Mock };
|
||||
complianceSettings: { findUnique: jest.Mock };
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
@@ -90,6 +95,7 @@ describe('LabelTemplatesService', () => {
|
||||
gapYMm: 2,
|
||||
isDefault: false,
|
||||
active: true,
|
||||
minXHeightMm: 1.2,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -117,6 +123,14 @@ describe('LabelTemplatesService', () => {
|
||||
update: jest.fn(),
|
||||
},
|
||||
bottlingBatch: { findUnique: jest.fn() },
|
||||
complianceSettings: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
netQuantityDigitRules: [
|
||||
{ maxGrams: 1000, minMm: 4 },
|
||||
{ maxGrams: null, minMm: 6 },
|
||||
],
|
||||
}),
|
||||
},
|
||||
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
|
||||
callback(tx),
|
||||
),
|
||||
@@ -248,7 +262,12 @@ describe('LabelTemplatesService', () => {
|
||||
product: { name: 'Glas 500g' },
|
||||
});
|
||||
|
||||
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
|
||||
const pdf = await service.renderLabelsPdf(
|
||||
'template-1',
|
||||
'batch-1',
|
||||
PROOF,
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(pdf).toBeInstanceOf(Buffer);
|
||||
expect(pdf.length).toBeGreaterThan(0);
|
||||
@@ -261,7 +280,7 @@ describe('LabelTemplatesService', () => {
|
||||
prisma.bottlingBatch.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.renderLabelsPdf('template-1', 'missing'),
|
||||
service.renderLabelsPdf('template-1', 'missing', PROOF, 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
@@ -288,7 +307,12 @@ describe('LabelTemplatesService', () => {
|
||||
product: { name: 'Glas 500g' },
|
||||
});
|
||||
|
||||
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
|
||||
const pdf = await service.renderLabelsPdf(
|
||||
'template-1',
|
||||
'batch-1',
|
||||
PROOF,
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
|
||||
// A PDF with an embedded raster image contains an XObject /Image entry.
|
||||
@@ -323,7 +347,12 @@ describe('LabelTemplatesService', () => {
|
||||
product: { name: 'Glas 500g' },
|
||||
});
|
||||
|
||||
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
|
||||
const pdf = await service.renderLabelsPdf(
|
||||
'template-1',
|
||||
'batch-1',
|
||||
PROOF,
|
||||
'user-1',
|
||||
);
|
||||
|
||||
// pdfkit always lists /ImageB /ImageC /ImageI in a page's ProcSet
|
||||
// regardless of whether anything is actually embedded, so a plain
|
||||
@@ -358,7 +387,12 @@ describe('LabelTemplatesService', () => {
|
||||
product: { name: 'Glas 500g' },
|
||||
});
|
||||
|
||||
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
|
||||
const pdf = await service.renderLabelsPdf(
|
||||
'template-1',
|
||||
'batch-1',
|
||||
PROOF,
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
|
||||
expect(pdf.toString('latin1')).not.toMatch(/\/Subtype\s*\/Image\b/);
|
||||
@@ -386,7 +420,12 @@ describe('LabelTemplatesService', () => {
|
||||
product: { name: 'Glas 500g' },
|
||||
});
|
||||
|
||||
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
|
||||
const pdf = await service.renderLabelsPdf(
|
||||
'template-1',
|
||||
'batch-1',
|
||||
PROOF,
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(countPages(pdf)).toBe(3);
|
||||
});
|
||||
@@ -411,7 +450,12 @@ describe('LabelTemplatesService', () => {
|
||||
product: { name: 'Glas 500g' },
|
||||
});
|
||||
|
||||
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
|
||||
const pdf = await service.renderLabelsPdf(
|
||||
'template-1',
|
||||
'batch-1',
|
||||
PROOF,
|
||||
'user-1',
|
||||
);
|
||||
|
||||
// Clamped to the real max of 2 columns x 1 row = 2 per page, so 3
|
||||
// copies still need 2 pages - same as the explicit columns:2 test.
|
||||
@@ -434,7 +478,12 @@ describe('LabelTemplatesService', () => {
|
||||
product: { name: 'Glas 500g' },
|
||||
});
|
||||
|
||||
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
|
||||
const pdf = await service.renderLabelsPdf(
|
||||
'template-1',
|
||||
'batch-1',
|
||||
PROOF,
|
||||
'user-1',
|
||||
);
|
||||
|
||||
// Only fits on a single page if the rotated 15-per-page layout was
|
||||
// actually used - upright would need a second page for the 15th copy.
|
||||
@@ -458,7 +507,12 @@ describe('LabelTemplatesService', () => {
|
||||
product: { name: 'Glas 500g' },
|
||||
});
|
||||
|
||||
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
|
||||
const pdf = await service.renderLabelsPdf(
|
||||
'template-1',
|
||||
'batch-1',
|
||||
PROOF,
|
||||
'user-1',
|
||||
);
|
||||
|
||||
// An explicit grid describes a real pre-printed sheet - it must be
|
||||
// honoured as-is (14 per page), needing 2 pages for 15 copies, even
|
||||
@@ -483,7 +537,12 @@ describe('LabelTemplatesService', () => {
|
||||
product: { name: 'Glas 500g' },
|
||||
});
|
||||
|
||||
const pdf = await service.renderLabelsPdf('template-1', 'batch-1', 2);
|
||||
const pdf = await service.renderLabelsPdf(
|
||||
'template-1',
|
||||
'batch-1',
|
||||
{ startPosition: 2, proof: true },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
// perPage=2, skip=1: label 1 fills the remaining slot on page 1,
|
||||
// labels 2-3 fill both slots of page 2.
|
||||
@@ -491,6 +550,296 @@ describe('LabelTemplatesService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('print guard, proof and audit', () => {
|
||||
function textElement(
|
||||
id: string,
|
||||
y: number,
|
||||
placeholderKey: LabelTextElement['placeholderKey'],
|
||||
fontSize = 9,
|
||||
): LabelTextElement {
|
||||
return {
|
||||
id,
|
||||
type: 'text',
|
||||
x: 2,
|
||||
y,
|
||||
width: 80,
|
||||
height: 8,
|
||||
text: '',
|
||||
placeholderKey,
|
||||
fontSize,
|
||||
fontFamily: 'sans',
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: '#000000',
|
||||
align: 'left',
|
||||
};
|
||||
}
|
||||
|
||||
// Every mandatory particular, type big enough: no gap and no warning.
|
||||
const completeElements: LabelTextElement[] = [
|
||||
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'),
|
||||
];
|
||||
const completeTemplate = {
|
||||
...template,
|
||||
id: 'template-1',
|
||||
heightMm: 70,
|
||||
elements: completeElements,
|
||||
};
|
||||
const bottling = {
|
||||
id: 'batch-1',
|
||||
jarSize: 500,
|
||||
jarCount: 3,
|
||||
lotNumber: 'L-001',
|
||||
date: new Date('2026-06-01T00:00:00.000Z'),
|
||||
bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'),
|
||||
honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' },
|
||||
product: { name: 'Glas 500g', legalName: 'Blütenhonig' },
|
||||
};
|
||||
const NO_FIRM = { ...SETTINGS, foodBusinessName: null };
|
||||
|
||||
beforeEach(() => {
|
||||
prisma.labelTemplate.findUnique.mockResolvedValue(completeTemplate);
|
||||
prisma.bottlingBatch.findUnique.mockResolvedValue(bottling);
|
||||
});
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
async function rejectionOf(promise: Promise<unknown>): Promise<unknown> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
throw new Error('expected a rejection');
|
||||
}
|
||||
|
||||
it('prints a complete label and audits it', async () => {
|
||||
const pdf = await service.renderLabelsPdf(
|
||||
'template-1',
|
||||
'batch-1',
|
||||
{},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
|
||||
expect(auditLog.record).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('print refuses with 409 and the gap list', async () => {
|
||||
siteSettings.get.mockResolvedValue(NO_FIRM);
|
||||
|
||||
const error = (await rejectionOf(
|
||||
service.renderLabelsPdf('template-1', 'batch-1', {}, 'user-1'),
|
||||
)) as ConflictException;
|
||||
|
||||
expect(error).toBeInstanceOf(ConflictException);
|
||||
expect(error.getStatus()).toBe(409);
|
||||
const body = error.getResponse() as {
|
||||
code: string;
|
||||
message: string;
|
||||
gaps: { code: string }[];
|
||||
};
|
||||
expect(body.code).toBe('LABEL_PARTICULARS_MISSING');
|
||||
expect(body.message).toEqual(expect.any(String));
|
||||
expect(body.gaps.map((gap) => gap.code)).toEqual(['FOOD_BUSINESS']);
|
||||
expect(auditLog.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('proof print renders despite a gap, adds the watermark and writes a PRINT audit entry', async () => {
|
||||
siteSettings.get.mockResolvedValue(NO_FIRM);
|
||||
const mark = jest.spyOn(watermark, 'drawProofWatermark');
|
||||
|
||||
const pdf = await service.renderLabelsPdf(
|
||||
'template-1',
|
||||
'batch-1',
|
||||
{ proof: true },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
|
||||
// One watermark per label, one label per jar.
|
||||
expect(mark).toHaveBeenCalledTimes(bottling.jarCount);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'PRINT', userId: 'user-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('a real print draws no watermark', async () => {
|
||||
const mark = jest.spyOn(watermark, 'drawProofWatermark');
|
||||
|
||||
await service.renderLabelsPdf('template-1', 'batch-1', {}, 'user-1');
|
||||
|
||||
expect(mark).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('the audit entry carries templateId, copies, proof, gapCodes and warningCodes', async () => {
|
||||
siteSettings.get.mockResolvedValue(NO_FIRM);
|
||||
|
||||
await service.renderLabelsPdf(
|
||||
'template-1',
|
||||
'batch-1',
|
||||
{ proof: true },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(auditLog.record).toHaveBeenCalledWith({
|
||||
entityType: 'BottlingBatch',
|
||||
entityId: 'batch-1',
|
||||
action: 'PRINT',
|
||||
userId: 'user-1',
|
||||
afterData: {
|
||||
templateId: 'template-1',
|
||||
copies: 3,
|
||||
proof: true,
|
||||
gapCodes: ['FOOD_BUSINESS'],
|
||||
warningCodes: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('a print with warnings only is rendered and audited with the warning codes', async () => {
|
||||
prisma.bottlingBatch.findUnique.mockResolvedValue({
|
||||
...bottling,
|
||||
lotNumber: 'AB-001',
|
||||
});
|
||||
prisma.labelTemplate.findUnique.mockResolvedValue({
|
||||
...completeTemplate,
|
||||
elements: completeElements.map((element) =>
|
||||
element.id === 'lot'
|
||||
? { ...element, placeholderKey: 'lotNumber' }
|
||||
: element,
|
||||
),
|
||||
});
|
||||
|
||||
const pdf = await service.renderLabelsPdf(
|
||||
'template-1',
|
||||
'batch-1',
|
||||
{},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
afterData: expect.objectContaining({
|
||||
proof: false,
|
||||
gapCodes: [],
|
||||
warningCodes: ['LOT_PREFIX'],
|
||||
}) as unknown,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('the audit is written before the PDF is returned and a failing audit fails the print', async () => {
|
||||
const order: string[] = [];
|
||||
auditLog.record.mockImplementation(() => {
|
||||
order.push('audit');
|
||||
return Promise.resolve();
|
||||
});
|
||||
await service.renderLabelsPdf('template-1', 'batch-1', {}, 'user-1');
|
||||
order.push('returned');
|
||||
expect(order).toEqual(['audit', 'returned']);
|
||||
|
||||
auditLog.record.mockRejectedValue(new Error('audit down'));
|
||||
await expect(
|
||||
service.renderLabelsPdf('template-1', 'batch-1', {}, 'user-1'),
|
||||
).rejects.toThrow('audit down');
|
||||
});
|
||||
|
||||
it('checkDraft without a bottling uses the sample values and sets sample true', async () => {
|
||||
const dto = {
|
||||
widthMm: 90,
|
||||
heightMm: 70,
|
||||
minXHeightMm: 1.2,
|
||||
elements: [textElement('legal', 1, 'legalName')],
|
||||
} as unknown as CheckLabelTemplateDto;
|
||||
|
||||
const result = await service.checkDraft(dto);
|
||||
|
||||
expect(result.sample).toBe(true);
|
||||
expect(prisma.bottlingBatch.findUnique).not.toHaveBeenCalled();
|
||||
const codes = result.gaps.map((gap) => gap.code);
|
||||
// The sample's legal name fills the placeholder; the rest is missing.
|
||||
expect(codes).not.toContain('LEGAL_NAME');
|
||||
expect(codes).toContain('NET_QUANTITY');
|
||||
});
|
||||
|
||||
it('checkDraft with a bottling uses that bottling and sample false', async () => {
|
||||
const dto = {
|
||||
widthMm: 90,
|
||||
heightMm: 70,
|
||||
minXHeightMm: 1.2,
|
||||
bottlingBatchId: 'batch-1',
|
||||
elements: completeElements,
|
||||
} as unknown as CheckLabelTemplateDto;
|
||||
|
||||
const result = await service.checkDraft(dto);
|
||||
|
||||
expect(result.sample).toBe(false);
|
||||
expect(result.printable).toBe(true);
|
||||
});
|
||||
|
||||
it('checkDraft answers 404 for an unknown bottling', async () => {
|
||||
prisma.bottlingBatch.findUnique.mockResolvedValue(null);
|
||||
const dto = {
|
||||
widthMm: 90,
|
||||
heightMm: 70,
|
||||
minXHeightMm: 1.2,
|
||||
bottlingBatchId: 'missing',
|
||||
elements: [],
|
||||
} as unknown as CheckLabelTemplateDto;
|
||||
|
||||
await expect(service.checkDraft(dto)).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('checkForBottling uses the placeholder values of that bottling and the master data', async () => {
|
||||
siteSettings.get.mockResolvedValue(NO_FIRM);
|
||||
prisma.complianceSettings.findUnique.mockResolvedValue({
|
||||
netQuantityDigitRules: [{ maxGrams: null, minMm: 1 }],
|
||||
});
|
||||
|
||||
const result = await service.checkForBottling('template-1', 'batch-1');
|
||||
|
||||
expect(result.sample).toBe(false);
|
||||
expect(result.printable).toBe(false);
|
||||
expect(result.gaps.map((gap) => gap.code)).toEqual(['FOOD_BUSINESS']);
|
||||
expect(result.requirements.netQuantityMinCapHeightMm).toBe(1);
|
||||
});
|
||||
|
||||
it('refuses the print and the check when the compliance settings are missing', async () => {
|
||||
prisma.complianceSettings.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.renderLabelsPdf('template-1', 'batch-1', {}, 'user-1'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
await expect(
|
||||
service.checkForBottling('template-1', 'batch-1'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(auditLog.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('checkForBottling ignores non-text elements', async () => {
|
||||
prisma.labelTemplate.findUnique.mockResolvedValue({
|
||||
...completeTemplate,
|
||||
elements: [
|
||||
...completeElements,
|
||||
{ id: 'qr', type: 'qrCode', x: 0, y: 0, size: 10 },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await service.checkForBottling('template-1', 'batch-1');
|
||||
|
||||
expect(result.printable).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPlaceholderValues', () => {
|
||||
const bottlingBatch = {
|
||||
jarSize: 500,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import PDFDocument from 'pdfkit';
|
||||
import * as QRCode from 'qrcode';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -10,16 +14,49 @@ import { LabelTemplate } from '../../generated/prisma';
|
||||
import { computeCropMarks } from './crop-marks';
|
||||
import { CreateLabelTemplateDto } from './dto/create-label-template.dto';
|
||||
import { UpdateLabelTemplateDto } from './dto/update-label-template.dto';
|
||||
import { berlinDayOf } from '../common/dates';
|
||||
import { CheckLabelTemplateDto } from './dto/check-label-template.dto';
|
||||
import { formatDayDe } from '../common/format-day';
|
||||
import { MM_TO_PT, pdfFontName } from './font-metrics';
|
||||
import { lotStartsWithL } from './label-text';
|
||||
import { LabelElement, PlaceholderKey } from './label-element.types';
|
||||
import {
|
||||
MM_TO_PT,
|
||||
pdfFontName,
|
||||
pdfTextMeasurer,
|
||||
TextMeasurer,
|
||||
} from './font-metrics';
|
||||
import { labelDayOf, lotStartsWithL } from './label-text';
|
||||
import {
|
||||
LabelElement,
|
||||
LabelTextElement,
|
||||
PlaceholderKey,
|
||||
} from './label-element.types';
|
||||
import { expectedParticulars, loadDigitRules } from './label-check.loader';
|
||||
import {
|
||||
checkLabel,
|
||||
LabelCheckResult,
|
||||
NetQuantityDigitRule,
|
||||
SAMPLE_BOTTLING,
|
||||
} from './label-particulars';
|
||||
import { drawProofWatermark } from './label-proof-watermark';
|
||||
|
||||
const A4_WIDTH_MM = 210;
|
||||
const A4_HEIGHT_MM = 297;
|
||||
const QR_SOURCE_SIZE_PX = 400;
|
||||
|
||||
export interface PrintOptions {
|
||||
/** First free slot on the first sheet (1 = top left). */
|
||||
startPosition?: number;
|
||||
/** A sample print: allowed with gaps, stamped "MUSTER". */
|
||||
proof?: boolean;
|
||||
}
|
||||
|
||||
type PlaceholderBottling = Parameters<typeof buildPlaceholderValues>[0];
|
||||
|
||||
interface CheckContext {
|
||||
settings: Parameters<typeof buildPlaceholderValues>[1];
|
||||
digitRules: NetQuantityDigitRule[];
|
||||
}
|
||||
|
||||
const LABEL_PARTICULARS_MISSING = 'LABEL_PARTICULARS_MISSING';
|
||||
|
||||
interface SheetLayout {
|
||||
widthMm: number;
|
||||
heightMm: number;
|
||||
@@ -36,15 +73,7 @@ interface SheetLayout {
|
||||
// not to show if the blade lands a fraction beside it.
|
||||
const CROP_MARK_LINE_WIDTH_PT = 0.3;
|
||||
|
||||
/**
|
||||
* The calendar day a stored date prints and is checked as (YYYY-MM-DD). Dates
|
||||
* are midnight-UTC columns or instants; both map to their Europe/Berlin day,
|
||||
* independent of the process time zone. The label text and the label check
|
||||
* (`expected.bestBefore`) must both come from this function.
|
||||
*/
|
||||
export function labelDayOf(date: Date): string {
|
||||
return berlinDayOf(date);
|
||||
}
|
||||
export { labelDayOf };
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return formatDayDe(labelDayOf(date), true);
|
||||
@@ -140,6 +169,33 @@ export function buildPlaceholderValues(
|
||||
};
|
||||
}
|
||||
|
||||
function textElementsOf(template: LabelTemplate): LabelTextElement[] {
|
||||
return elementsOf(template).filter(
|
||||
(element): element is LabelTextElement => element.type === 'text',
|
||||
);
|
||||
}
|
||||
|
||||
function textElementFromDraft(
|
||||
element: CheckLabelTemplateDto['elements'][number],
|
||||
): LabelTextElement {
|
||||
return {
|
||||
id: element.id,
|
||||
type: 'text',
|
||||
x: element.x,
|
||||
y: element.y,
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
text: element.text ?? '',
|
||||
placeholderKey: element.placeholderKey ?? null,
|
||||
fontSize: element.fontSize,
|
||||
fontFamily: element.fontFamily ?? 'sans',
|
||||
bold: element.bold ?? false,
|
||||
italic: element.italic ?? false,
|
||||
color: element.color ?? '#000000',
|
||||
align: element.align ?? 'left',
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LabelTemplatesService {
|
||||
constructor(
|
||||
@@ -244,24 +300,40 @@ export class LabelTemplatesService {
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints the labels of one bottling. A mandatory particular that is missing
|
||||
* blocks the print (409) unless it is a proof print. The audit entry is
|
||||
* written before the PDF is handed out: no unaudited print.
|
||||
*/
|
||||
async renderLabelsPdf(
|
||||
templateId: string,
|
||||
bottlingBatchId: string,
|
||||
startPosition = 1,
|
||||
options: PrintOptions,
|
||||
actingUserId: string,
|
||||
): Promise<Buffer> {
|
||||
const proof = options.proof === true;
|
||||
const template = await this.findById(templateId);
|
||||
const bottlingBatch = await this.prisma.bottlingBatch.findUnique({
|
||||
where: { id: bottlingBatchId },
|
||||
include: { honeyBatch: true, product: true },
|
||||
});
|
||||
if (!bottlingBatch) {
|
||||
throw new NotFoundException('Bottling batch not found');
|
||||
const bottlingBatch = await this.findBottling(bottlingBatchId);
|
||||
const context = await this.loadCheckContext();
|
||||
|
||||
const result = this.check(
|
||||
context,
|
||||
textElementsOf(template),
|
||||
template.minXHeightMm,
|
||||
bottlingBatch,
|
||||
false,
|
||||
pdfTextMeasurer(),
|
||||
);
|
||||
if (result.gaps.length > 0 && !proof) {
|
||||
throw new ConflictException({
|
||||
code: LABEL_PARTICULARS_MISSING,
|
||||
message:
|
||||
'Pflichtangaben fehlen auf dem Etikett. Ein Probedruck ist möglich.',
|
||||
gaps: result.gaps,
|
||||
});
|
||||
}
|
||||
|
||||
const values = buildPlaceholderValues(
|
||||
bottlingBatch,
|
||||
await this.siteSettings.get(),
|
||||
);
|
||||
const values = buildPlaceholderValues(bottlingBatch, context.settings);
|
||||
|
||||
const elements = elementsOf(template).map((element) =>
|
||||
element.type === 'text' && element.placeholderKey
|
||||
@@ -283,7 +355,7 @@ export class LabelTemplatesService {
|
||||
});
|
||||
}
|
||||
|
||||
return this.renderPdf(
|
||||
const pdf = await this.renderPdf(
|
||||
elements,
|
||||
{
|
||||
widthMm: template.widthMm,
|
||||
@@ -298,8 +370,94 @@ export class LabelTemplatesService {
|
||||
},
|
||||
bottlingBatch.jarCount,
|
||||
qrCodeBuffer,
|
||||
startPosition,
|
||||
options.startPosition ?? 1,
|
||||
proof,
|
||||
);
|
||||
|
||||
await this.auditLog.record({
|
||||
entityType: 'BottlingBatch',
|
||||
entityId: bottlingBatchId,
|
||||
action: 'PRINT',
|
||||
userId: actingUserId,
|
||||
afterData: {
|
||||
templateId,
|
||||
copies: bottlingBatch.jarCount,
|
||||
proof,
|
||||
gapCodes: result.gaps.map((gap) => gap.code),
|
||||
warningCodes: result.warnings.map((warning) => warning.code),
|
||||
},
|
||||
});
|
||||
return pdf;
|
||||
}
|
||||
|
||||
/** Checks an unsaved draft; without a bottling the sample values fill it. */
|
||||
async checkDraft(dto: CheckLabelTemplateDto): Promise<LabelCheckResult> {
|
||||
const bottling = dto.bottlingBatchId
|
||||
? await this.findBottling(dto.bottlingBatchId)
|
||||
: SAMPLE_BOTTLING;
|
||||
return this.check(
|
||||
await this.loadCheckContext(),
|
||||
dto.elements.map(textElementFromDraft),
|
||||
dto.minXHeightMm,
|
||||
bottling,
|
||||
!dto.bottlingBatchId,
|
||||
);
|
||||
}
|
||||
|
||||
/** The same check the print runs, for one saved template and bottling. */
|
||||
async checkForBottling(
|
||||
templateId: string,
|
||||
bottlingBatchId: string,
|
||||
): Promise<LabelCheckResult> {
|
||||
const template = await this.findById(templateId);
|
||||
const bottling = await this.findBottling(bottlingBatchId);
|
||||
return this.check(
|
||||
await this.loadCheckContext(),
|
||||
textElementsOf(template),
|
||||
template.minXHeightMm,
|
||||
bottling,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
private async findBottling(id: string) {
|
||||
const bottlingBatch = await this.prisma.bottlingBatch.findUnique({
|
||||
where: { id },
|
||||
include: { honeyBatch: true, product: true },
|
||||
});
|
||||
if (!bottlingBatch) {
|
||||
throw new NotFoundException('Bottling batch not found');
|
||||
}
|
||||
return bottlingBatch;
|
||||
}
|
||||
|
||||
private async loadCheckContext(): Promise<CheckContext> {
|
||||
const [settings, digitRules] = await Promise.all([
|
||||
this.siteSettings.get(),
|
||||
loadDigitRules(this.prisma),
|
||||
]);
|
||||
return { settings, digitRules };
|
||||
}
|
||||
|
||||
// The one place that builds a label check: the print and both check
|
||||
// endpoints go through it.
|
||||
private check(
|
||||
context: CheckContext,
|
||||
elements: LabelTextElement[],
|
||||
minXHeightMm: number,
|
||||
bottling: PlaceholderBottling,
|
||||
sample: boolean,
|
||||
measurer?: TextMeasurer,
|
||||
): LabelCheckResult {
|
||||
return checkLabel({
|
||||
elements,
|
||||
minXHeightMm,
|
||||
digitRules: context.digitRules,
|
||||
values: buildPlaceholderValues(bottling, context.settings),
|
||||
expected: expectedParticulars(bottling, context.settings),
|
||||
sample,
|
||||
measurer,
|
||||
});
|
||||
}
|
||||
|
||||
private renderPdf(
|
||||
@@ -308,6 +466,7 @@ export class LabelTemplatesService {
|
||||
copies: number,
|
||||
qrCodeBuffer: Buffer | null,
|
||||
startPosition: number,
|
||||
proof: boolean,
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 0 });
|
||||
@@ -436,10 +595,18 @@ export class LabelTemplatesService {
|
||||
centerX - w / 2,
|
||||
centerY - h / 2,
|
||||
qrCodeBuffer,
|
||||
proof ? { widthPt: w, heightPt: h } : null,
|
||||
);
|
||||
doc.restore();
|
||||
} else {
|
||||
this.drawLabel(doc, elements, originX, originY, qrCodeBuffer);
|
||||
this.drawLabel(
|
||||
doc,
|
||||
elements,
|
||||
originX,
|
||||
originY,
|
||||
qrCodeBuffer,
|
||||
proof ? { widthPt: slotW, heightPt: slotH } : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,10 +620,20 @@ export class LabelTemplatesService {
|
||||
originX: number,
|
||||
originY: number,
|
||||
qrCodeBuffer: Buffer | null,
|
||||
proofStamp: { widthPt: number; heightPt: number } | null,
|
||||
): void {
|
||||
for (const element of elements) {
|
||||
this.drawElement(doc, element, originX, originY, qrCodeBuffer);
|
||||
}
|
||||
if (proofStamp) {
|
||||
drawProofWatermark(
|
||||
doc,
|
||||
originX,
|
||||
originY,
|
||||
proofStamp.widthPt,
|
||||
proofStamp.heightPt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private drawElement(
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { berlinDayOf } from '../common/dates';
|
||||
import { LabelTextElement, PlaceholderKey } from './label-element.types';
|
||||
|
||||
/**
|
||||
* The calendar day a stored date prints and is checked as (YYYY-MM-DD). Dates
|
||||
* are midnight-UTC columns or instants; both map to their Europe/Berlin day,
|
||||
* independent of the process time zone. The label text and the label check
|
||||
* (`expected.bestBefore`) must both come from this function.
|
||||
*/
|
||||
export function labelDayOf(date: Date): string {
|
||||
return berlinDayOf(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Text as the label check compares it: NFKC, lower case, every character that
|
||||
* is not a letter or digit becomes a space, runs of spaces collapse. So
|
||||
|
||||
Reference in new issue
Block a user