renderLabelsPdf resolved the jarSize placeholder to "500 ml". Everywhere else in the app jarSize is a fill weight in grams - the bottling forms label it "Glasgröße (g)", the batch list and the public Herkunft page print "g", and BottlingService divides by 1000 to get kilograms. Only the printed label said millilitres, so every label produced so far declares the wrong net quantity for a food sold by mass. The placeholder mapping moved into an exported buildPlaceholderValues() so the wording can be asserted directly. It could not be before: the render output is a PDF buffer whose text a test cannot read, which is how the wrong unit shipped unnoticed. Three tests now cover it, including one that rejects any volume unit outright. Labels already printed with "500 ml" have to be reprinted - the code change does not correct paper. Co-Authored-By: Claude Opus 5 <[email protected]>
468 lines
15 KiB
TypeScript
468 lines
15 KiB
TypeScript
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
import PDFDocument from 'pdfkit';
|
|
import * as QRCode from 'qrcode';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { AuditLogService } from '../audit/audit-log.service';
|
|
import { matchesFileSignature } from '../common/file-signature';
|
|
import { LabelTemplate } from '../../generated/prisma';
|
|
import { CreateLabelTemplateDto } from './dto/create-label-template.dto';
|
|
import { UpdateLabelTemplateDto } from './dto/update-label-template.dto';
|
|
import {
|
|
LabelElement,
|
|
LabelFontFamily,
|
|
PlaceholderKey,
|
|
} from './label-element.types';
|
|
|
|
const MM_TO_PT = 72 / 25.4;
|
|
const A4_WIDTH_MM = 210;
|
|
const A4_HEIGHT_MM = 297;
|
|
const QR_SOURCE_SIZE_PX = 400;
|
|
|
|
interface SheetLayout {
|
|
widthMm: number;
|
|
heightMm: number;
|
|
columns: number | null;
|
|
rows: number | null;
|
|
marginTopMm: number;
|
|
marginLeftMm: number;
|
|
gapXMm: number;
|
|
gapYMm: number;
|
|
}
|
|
|
|
// '2-digit' forces a leading zero for day/month (e.g. "05.03.2026") - the
|
|
// default 'numeric' formatting drops it ("5.3.2026"), which is what the MHD
|
|
// on a printed label was showing.
|
|
function formatDate(date: Date): string {
|
|
return date.toLocaleDateString('de-DE', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
});
|
|
}
|
|
|
|
// Prisma stores `elements` as a plain Json column; the shape is only ever
|
|
// written through CreateLabelTemplateDto/UpdateLabelTemplateDto, so casting
|
|
// back to the app-level type here is safe.
|
|
function elementsOf(template: LabelTemplate): LabelElement[] {
|
|
return template.elements as unknown as LabelElement[];
|
|
}
|
|
|
|
function pdfFontName(
|
|
family: LabelFontFamily,
|
|
bold: boolean,
|
|
italic: boolean,
|
|
): string {
|
|
const base =
|
|
family === 'serif' ? 'Times' : family === 'mono' ? 'Courier' : 'Helvetica';
|
|
if (base === 'Times') {
|
|
if (bold && italic) return 'Times-BoldItalic';
|
|
if (bold) return 'Times-Bold';
|
|
if (italic) return 'Times-Italic';
|
|
return 'Times-Roman';
|
|
}
|
|
const suffix =
|
|
bold && italic ? '-BoldOblique' : bold ? '-Bold' : italic ? '-Oblique' : '';
|
|
return `${base}${suffix}`;
|
|
}
|
|
|
|
interface GridFit {
|
|
columns: number;
|
|
rows: number;
|
|
}
|
|
|
|
function gridFit(
|
|
cellWidthMm: number,
|
|
cellHeightMm: number,
|
|
usableWidthMm: number,
|
|
usableHeightMm: number,
|
|
gapXMm: number,
|
|
gapYMm: number,
|
|
): GridFit {
|
|
return {
|
|
columns: Math.max(
|
|
1,
|
|
Math.floor((usableWidthMm + gapXMm) / (cellWidthMm + gapXMm)),
|
|
),
|
|
rows: Math.max(
|
|
1,
|
|
Math.floor((usableHeightMm + gapYMm) / (cellHeightMm + gapYMm)),
|
|
),
|
|
};
|
|
}
|
|
|
|
function dataUrlToBuffer(dataUrl: string): Buffer | null {
|
|
const match = /^data:image\/(png|jpe?g);base64,(.+)$/.exec(dataUrl);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
const mimeType = match[1] === 'png' ? 'image/png' : 'image/jpeg';
|
|
const buffer = Buffer.from(match[2], 'base64');
|
|
// Same magic-byte check as every other upload path (avatar, documents) -
|
|
// a template's embedded image is only ever entered by a label-template
|
|
// editor (label-templates:create/update), but there's no reason to hand
|
|
// pdfkit's image parser bytes that don't match what they claim to be.
|
|
return matchesFileSignature(buffer, mimeType) ? buffer : null;
|
|
}
|
|
|
|
/**
|
|
* The text every placeholder resolves to on a printed label.
|
|
*
|
|
* Extracted from the render path so the wording is unit-testable: the render
|
|
* output is a PDF buffer whose text is not readable by a test, which is how
|
|
* the jar size once shipped printed as "500 ml". jarSize is a fill weight in
|
|
* grams everywhere else in the app, and honey is a food sold by mass, so a
|
|
* label stating millilitres declares the wrong net quantity.
|
|
*/
|
|
export function buildPlaceholderValues(bottlingBatch: {
|
|
jarSize: number;
|
|
lotNumber: string;
|
|
date: Date;
|
|
bestBeforeDate: Date;
|
|
honeyBatch: { batchNumber: string; honeyType: string | null };
|
|
product: { name: string } | null;
|
|
}): Record<PlaceholderKey, string> {
|
|
return {
|
|
batchNumber: bottlingBatch.honeyBatch.batchNumber,
|
|
honeyType: bottlingBatch.honeyBatch.honeyType ?? '',
|
|
lotNumber: bottlingBatch.lotNumber,
|
|
bottlingDate: formatDate(bottlingBatch.date),
|
|
bestBeforeDate: formatDate(bottlingBatch.bestBeforeDate),
|
|
jarSize: `${bottlingBatch.jarSize} g`,
|
|
productName: bottlingBatch.product?.name ?? '',
|
|
};
|
|
}
|
|
|
|
@Injectable()
|
|
export class LabelTemplatesService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly auditLog: AuditLogService,
|
|
) {}
|
|
|
|
async findAll(): Promise<LabelTemplate[]> {
|
|
return this.prisma.labelTemplate.findMany({ orderBy: { name: 'asc' } });
|
|
}
|
|
|
|
async findById(id: string): Promise<LabelTemplate> {
|
|
const template = await this.prisma.labelTemplate.findUnique({
|
|
where: { id },
|
|
});
|
|
if (!template) {
|
|
throw new NotFoundException('Label template not found');
|
|
}
|
|
return template;
|
|
}
|
|
|
|
async create(
|
|
dto: CreateLabelTemplateDto,
|
|
actingUserId: string,
|
|
): Promise<LabelTemplate> {
|
|
const template = await this.prisma.$transaction(async (tx) => {
|
|
if (dto.isDefault) {
|
|
await tx.labelTemplate.updateMany({
|
|
where: {},
|
|
data: { isDefault: false },
|
|
});
|
|
}
|
|
return tx.labelTemplate.create({
|
|
data: { ...dto, elements: dto.elements as never },
|
|
});
|
|
});
|
|
await this.auditLog.record({
|
|
entityType: 'LabelTemplate',
|
|
entityId: template.id,
|
|
action: 'CREATE',
|
|
userId: actingUserId,
|
|
afterData: template,
|
|
});
|
|
return template;
|
|
}
|
|
|
|
async update(
|
|
id: string,
|
|
dto: UpdateLabelTemplateDto,
|
|
actingUserId: string,
|
|
): Promise<LabelTemplate> {
|
|
const before = await this.findById(id);
|
|
const template = await this.prisma.$transaction(async (tx) => {
|
|
if (dto.isDefault) {
|
|
await tx.labelTemplate.updateMany({
|
|
where: { id: { not: id } },
|
|
data: { isDefault: false },
|
|
});
|
|
}
|
|
return tx.labelTemplate.update({
|
|
where: { id },
|
|
data: { ...dto, elements: dto.elements as never },
|
|
});
|
|
});
|
|
await this.auditLog.record({
|
|
entityType: 'LabelTemplate',
|
|
entityId: id,
|
|
action: 'UPDATE',
|
|
userId: actingUserId,
|
|
beforeData: before,
|
|
afterData: template,
|
|
});
|
|
return template;
|
|
}
|
|
|
|
async deactivate(id: string, actingUserId: string): Promise<LabelTemplate> {
|
|
const before = await this.findById(id);
|
|
const template = await this.prisma.labelTemplate.update({
|
|
where: { id },
|
|
data: { active: false, isDefault: false },
|
|
});
|
|
await this.auditLog.record({
|
|
entityType: 'LabelTemplate',
|
|
entityId: id,
|
|
action: 'DEACTIVATE',
|
|
userId: actingUserId,
|
|
beforeData: before,
|
|
afterData: template,
|
|
});
|
|
return template;
|
|
}
|
|
|
|
async renderLabelsPdf(
|
|
templateId: string,
|
|
bottlingBatchId: string,
|
|
startPosition = 1,
|
|
): Promise<Buffer> {
|
|
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 values = buildPlaceholderValues(bottlingBatch);
|
|
|
|
const elements = elementsOf(template).map((element) =>
|
|
element.type === 'text' && element.placeholderKey
|
|
? { ...element, text: values[element.placeholderKey] }
|
|
: element,
|
|
);
|
|
|
|
// The QR code always points at the public Herkunft/traceability page for
|
|
// this batch (honey type, dates, origin already shown there), generated
|
|
// once and reused across every copy and every qrCode element.
|
|
let qrCodeBuffer: Buffer | null = null;
|
|
if (elements.some((element) => element.type === 'qrCode')) {
|
|
const frontendOrigin =
|
|
process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000';
|
|
const herkunftUrl = `${frontendOrigin}/herkunft/${encodeURIComponent(bottlingBatch.lotNumber)}`;
|
|
qrCodeBuffer = await QRCode.toBuffer(herkunftUrl, {
|
|
margin: 1,
|
|
width: QR_SOURCE_SIZE_PX,
|
|
});
|
|
}
|
|
|
|
return this.renderPdf(
|
|
elements,
|
|
{
|
|
widthMm: template.widthMm,
|
|
heightMm: template.heightMm,
|
|
columns: template.columns,
|
|
rows: template.rows,
|
|
marginTopMm: template.marginTopMm,
|
|
marginLeftMm: template.marginLeftMm,
|
|
gapXMm: template.gapXMm,
|
|
gapYMm: template.gapYMm,
|
|
},
|
|
bottlingBatch.jarCount,
|
|
qrCodeBuffer,
|
|
startPosition,
|
|
);
|
|
}
|
|
|
|
private renderPdf(
|
|
elements: LabelElement[],
|
|
layout: SheetLayout,
|
|
copies: number,
|
|
qrCodeBuffer: Buffer | null,
|
|
startPosition: number,
|
|
): Promise<Buffer> {
|
|
return new Promise((resolve, reject) => {
|
|
const doc = new PDFDocument({ size: 'A4', margin: 0 });
|
|
const chunks: Buffer[] = [];
|
|
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
|
doc.on('error', (error: Error) => reject(error));
|
|
|
|
const { widthMm, heightMm, marginTopMm, marginLeftMm, gapXMm, gapYMm } =
|
|
layout;
|
|
const usableWidthMm = A4_WIDTH_MM - 2 * marginLeftMm;
|
|
const usableHeightMm = A4_HEIGHT_MM - 2 * marginTopMm;
|
|
const normalFit = gridFit(
|
|
widthMm,
|
|
heightMm,
|
|
usableWidthMm,
|
|
usableHeightMm,
|
|
gapXMm,
|
|
gapYMm,
|
|
);
|
|
// Only considered when fully automatic (neither columns nor rows set
|
|
// explicitly): an explicit grid describes a real pre-printed sheet
|
|
// (e.g. an Avery template) whose physical slots have a fixed size and
|
|
// orientation - rotating the content wouldn't change how many fit and
|
|
// would just misalign every label against the sheet.
|
|
const isFullyAutomatic = layout.columns === null && layout.rows === null;
|
|
const rotatedFit = isFullyAutomatic
|
|
? gridFit(
|
|
heightMm,
|
|
widthMm,
|
|
usableWidthMm,
|
|
usableHeightMm,
|
|
gapXMm,
|
|
gapYMm,
|
|
)
|
|
: null;
|
|
const useRotated =
|
|
rotatedFit !== null &&
|
|
rotatedFit.columns * rotatedFit.rows >
|
|
normalFit.columns * normalFit.rows;
|
|
|
|
// An explicit columns/rows value from the sheet-layout designer is
|
|
// clamped to what actually fits the A4 page - without this, a value
|
|
// typed in by the user (or already saved from before this fix) would
|
|
// place labels past the page's right/bottom edge, where printers just
|
|
// clip them instead of printing anything.
|
|
const columns = useRotated
|
|
? rotatedFit.columns
|
|
: Math.min(layout.columns ?? normalFit.columns, normalFit.columns);
|
|
const rows = useRotated
|
|
? rotatedFit.rows
|
|
: Math.min(layout.rows ?? normalFit.rows, normalFit.rows);
|
|
const perPage = columns * rows;
|
|
// The grid slot's own footprint on the page - swapped from the
|
|
// label's natural widthMm/heightMm when printing rotated, since a
|
|
// rotated label takes up a heightMm-wide by widthMm-tall slot.
|
|
const slotWidthMm = useRotated ? heightMm : widthMm;
|
|
const slotHeightMm = useRotated ? widthMm : heightMm;
|
|
// startPosition only skips ahead on the first sheet (partially-used
|
|
// physical sheet, e.g. a few labels already peeled off) - every sheet
|
|
// after that always starts fresh at the top-left slot.
|
|
const skip = Math.min(Math.max(0, startPosition - 1), perPage - 1);
|
|
|
|
for (let index = 0; index < copies; index += 1) {
|
|
const slotOnPage = (skip + index) % perPage;
|
|
if (index > 0 && slotOnPage === 0) {
|
|
doc.addPage({ size: 'A4', margin: 0 });
|
|
}
|
|
const column = slotOnPage % columns;
|
|
const row = Math.floor(slotOnPage / columns);
|
|
const xMm = marginLeftMm + column * (slotWidthMm + gapXMm);
|
|
const yMm = marginTopMm + row * (slotHeightMm + gapYMm);
|
|
const originX = xMm * MM_TO_PT;
|
|
const originY = yMm * MM_TO_PT;
|
|
const slotW = slotWidthMm * MM_TO_PT;
|
|
const slotH = slotHeightMm * MM_TO_PT;
|
|
|
|
if (useRotated) {
|
|
// Rotating a widthMm x heightMm label by exactly 90 degrees around
|
|
// its own center always produces a heightMm x widthMm box centred
|
|
// on that same point - true regardless of pdfkit's rotation
|
|
// direction convention, so the label's own content (still laid
|
|
// out in its normal, unrotated widthMm x heightMm coordinates)
|
|
// just needs to be drawn centered in the rotated slot.
|
|
doc.save();
|
|
const centerX = originX + slotW / 2;
|
|
const centerY = originY + slotH / 2;
|
|
doc.rotate(90, { origin: [centerX, centerY] });
|
|
const w = widthMm * MM_TO_PT;
|
|
const h = heightMm * MM_TO_PT;
|
|
this.drawLabel(
|
|
doc,
|
|
elements,
|
|
centerX - w / 2,
|
|
centerY - h / 2,
|
|
qrCodeBuffer,
|
|
);
|
|
doc.restore();
|
|
} else {
|
|
this.drawLabel(doc, elements, originX, originY, qrCodeBuffer);
|
|
}
|
|
}
|
|
|
|
doc.end();
|
|
});
|
|
}
|
|
|
|
private drawLabel(
|
|
doc: PDFKit.PDFDocument,
|
|
elements: LabelElement[],
|
|
originX: number,
|
|
originY: number,
|
|
qrCodeBuffer: Buffer | null,
|
|
): void {
|
|
for (const element of elements) {
|
|
this.drawElement(doc, element, originX, originY, qrCodeBuffer);
|
|
}
|
|
}
|
|
|
|
private drawElement(
|
|
doc: PDFKit.PDFDocument,
|
|
element: LabelElement,
|
|
originX: number,
|
|
originY: number,
|
|
qrCodeBuffer: Buffer | null,
|
|
): void {
|
|
const x = originX + element.x * MM_TO_PT;
|
|
const y = originY + element.y * MM_TO_PT;
|
|
|
|
switch (element.type) {
|
|
case 'text': {
|
|
doc
|
|
.font(pdfFontName(element.fontFamily, element.bold, element.italic))
|
|
.fontSize(element.fontSize)
|
|
.fillColor(element.color)
|
|
.text(element.text, x, y, {
|
|
width: element.width * MM_TO_PT,
|
|
height: element.height * MM_TO_PT,
|
|
align: element.align,
|
|
});
|
|
return;
|
|
}
|
|
case 'qrCode': {
|
|
if (!qrCodeBuffer) {
|
|
return;
|
|
}
|
|
const side = element.size * MM_TO_PT;
|
|
doc.image(qrCodeBuffer, x, y, { width: side, height: side });
|
|
return;
|
|
}
|
|
case 'image': {
|
|
const buffer = dataUrlToBuffer(element.src);
|
|
if (!buffer) {
|
|
return;
|
|
}
|
|
doc.image(buffer, x, y, {
|
|
width: element.width * MM_TO_PT,
|
|
height: element.height * MM_TO_PT,
|
|
});
|
|
return;
|
|
}
|
|
case 'line': {
|
|
const endX =
|
|
element.orientation === 'horizontal'
|
|
? x + element.length * MM_TO_PT
|
|
: x;
|
|
const endY =
|
|
element.orientation === 'vertical'
|
|
? y + element.length * MM_TO_PT
|
|
: y;
|
|
doc
|
|
.moveTo(x, y)
|
|
.lineTo(endX, endY)
|
|
.lineWidth(element.thickness)
|
|
.strokeColor(element.color)
|
|
.stroke();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|