Eine explizit im Etiketten-Designer eingetragene Spalten-/Zeilenzahl wurde beim PDF-Rendern ungeprueft uebernommen. Passt sie nicht auf ein A4-Blatt (z.B. 90mm breite Etiketten x 4 Spalten bei 5mm Rand), ragen die hinteren Spalten/Zeilen ueber den Blattrand hinaus - der Drucker schneidet sie dann einfach ab, statt sie zu drucken. - Backend (label-templates.service.ts): Spalten/Zeilen werden jetzt auf das tatsaechlich auf A4 passende Maximum begrenzt, bevor gerendert wird - betrifft auch bereits gespeicherte Vorlagen mit zu grossen Werten. - Frontend (label-designer.tsx): zeigt die maximal passende Spalten-/ Zeilenzahl direkt im Seiten-Layout-Panel an und warnt inline, wenn der eingetragene Wert das Blatt sprengen wuerde. Co-Authored-By: Claude Sonnet 5 <[email protected]>
352 lines
11 KiB
TypeScript
352 lines
11 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 { 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<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: Record<PlaceholderKey, string> = {
|
|
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<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 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;
|
|
}
|
|
}
|
|
}
|
|
}
|