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 { 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; } function formatDate(date: Date): string { return date.toLocaleDateString('de-DE'); } // 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}`; } function dataUrlToBuffer(dataUrl: string): Buffer | null { const match = /^data:image\/(png|jpe?g);base64,(.+)$/.exec(dataUrl); if (!match) { return null; } return Buffer.from(match[2], 'base64'); } @Injectable() export class LabelTemplatesService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, ) {} async findAll(): Promise { return this.prisma.labelTemplate.findMany({ orderBy: { name: 'asc' } }); } async findById(id: string): Promise { 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 { 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 { 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 { 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 { 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: Record = { batchNumber: bottlingBatch.honeyBatch.batchNumber, honeyType: bottlingBatch.honeyBatch.honeyType ?? '', lotNumber: bottlingBatch.lotNumber, bottlingDate: formatDate(bottlingBatch.date), bestBeforeDate: formatDate(bottlingBatch.bestBeforeDate), jarSize: `${bottlingBatch.jarSize} ml`, productName: bottlingBatch.product?.name ?? '', }; 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 { 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 maxColumns = Math.max( 1, Math.floor((usableWidthMm + gapXMm) / (widthMm + gapXMm)), ); const maxRows = Math.max( 1, Math.floor((usableHeightMm + gapYMm) / (heightMm + gapYMm)), ); // 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 = Math.min(layout.columns ?? maxColumns, maxColumns); const rows = Math.min(layout.rows ?? maxRows, maxRows); const perPage = columns * rows; // 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 * (widthMm + gapXMm); const yMm = marginTopMm + row * (heightMm + gapYMm); const originX = xMm * MM_TO_PT; const originY = yMm * MM_TO_PT; const w = widthMm * MM_TO_PT; const h = heightMm * MM_TO_PT; doc .rect(originX, originY, w, h) .dash(2, { space: 2 }) .stroke('#999999') .undash(); for (const element of elements) { this.drawElement(doc, element, originX, originY, qrCodeBuffer); } } doc.end(); }); } 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; } } } }