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

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

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

No files matched your search

@@ -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