fix: Bug fixed für Rollen
This commit is contained in:
1 parent
8432313fa9
commit
5f4e1a7ba9
9 files changed
+311
-341
No files matched your search
@@ -1,23 +1,28 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { LabelElementDto } from './label-element.dto';
|
||||
|
||||
export class CreateLabelTemplateDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Etikettentext mit Platzhaltern: {{batchNumber}} {{honeyType}} {{lotNumber}} {{bottlingDate}} {{bestBeforeDate}} {{jarSize}} {{productName}}',
|
||||
})
|
||||
@IsString()
|
||||
content!: string;
|
||||
@ApiProperty({ type: [LabelElementDto] })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => LabelElementDto)
|
||||
elements!: LabelElementDto[];
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common';
|
||||
import { LabelTemplatesService } from './label-templates.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { LabelElement } from './label-element.types';
|
||||
|
||||
describe('LabelTemplatesService', () => {
|
||||
let service: LabelTemplatesService;
|
||||
@@ -23,10 +24,44 @@ describe('LabelTemplatesService', () => {
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
|
||||
const elements: LabelElement[] = [
|
||||
{
|
||||
id: 'el-1',
|
||||
type: 'text',
|
||||
x: 2,
|
||||
y: 2,
|
||||
width: 60,
|
||||
height: 10,
|
||||
text: '',
|
||||
placeholderKey: 'batchNumber',
|
||||
fontSize: 9,
|
||||
fontFamily: 'sans',
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: '#000000',
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
id: 'el-2',
|
||||
type: 'text',
|
||||
x: 2,
|
||||
y: 14,
|
||||
width: 60,
|
||||
height: 10,
|
||||
text: 'MHD:',
|
||||
fontSize: 9,
|
||||
fontFamily: 'sans',
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: '#000000',
|
||||
align: 'left',
|
||||
},
|
||||
];
|
||||
|
||||
const template = {
|
||||
id: 'template-1',
|
||||
name: 'Standard',
|
||||
content: 'Charge {{batchNumber}} - MHD {{bestBeforeDate}}',
|
||||
elements,
|
||||
widthMm: 90,
|
||||
heightMm: 35,
|
||||
isDefault: false,
|
||||
@@ -74,7 +109,7 @@ describe('LabelTemplatesService', () => {
|
||||
});
|
||||
|
||||
await service.create(
|
||||
{ name: 'Standard', content: 'x', isDefault: true },
|
||||
{ name: 'Standard', elements: [], isDefault: true },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
@@ -90,7 +125,7 @@ describe('LabelTemplatesService', () => {
|
||||
it('does not touch other defaults when isDefault is not set', async () => {
|
||||
tx.labelTemplate.create.mockResolvedValue(template);
|
||||
|
||||
await service.create({ name: 'Standard', content: 'x' }, 'user-1');
|
||||
await service.create({ name: 'Standard', elements: [] }, 'user-1');
|
||||
|
||||
expect(tx.labelTemplate.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -119,7 +154,7 @@ describe('LabelTemplatesService', () => {
|
||||
});
|
||||
|
||||
describe('renderLabelsPdf', () => {
|
||||
it('fills placeholders and tiles one label per jar', async () => {
|
||||
it('resolves placeholder text and tiles one label per jar', async () => {
|
||||
prisma.labelTemplate.findUnique.mockResolvedValue(template);
|
||||
prisma.bottlingBatch.findUnique.mockResolvedValue({
|
||||
id: 'batch-1',
|
||||
@@ -149,10 +184,17 @@ describe('LabelTemplatesService', () => {
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('embeds a QR code pointing at the Herkunft page when the template uses {{qrCode}}', async () => {
|
||||
it('embeds a QR code pointing at the Herkunft page when the template has a qrCode element', async () => {
|
||||
const qrElement: LabelElement = {
|
||||
id: 'el-qr',
|
||||
type: 'qrCode',
|
||||
x: 60,
|
||||
y: 2,
|
||||
size: 20,
|
||||
};
|
||||
prisma.labelTemplate.findUnique.mockResolvedValue({
|
||||
...template,
|
||||
content: `${template.content} {{qrCode}}`,
|
||||
elements: [...elements, qrElement],
|
||||
});
|
||||
prisma.bottlingBatch.findUnique.mockResolvedValue({
|
||||
id: 'batch-1',
|
||||
|
||||
@@ -6,25 +6,54 @@ 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 PAGE_MARGIN_MM = 5;
|
||||
const LABEL_GAP_MM = 2;
|
||||
const A4_WIDTH_MM = 210;
|
||||
const A4_HEIGHT_MM = 297;
|
||||
const QR_CODE_PLACEHOLDER = '{{qrCode}}';
|
||||
const QR_SOURCE_SIZE_PX = 400;
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return date.toLocaleDateString('de-DE');
|
||||
}
|
||||
|
||||
function fillPlaceholders(
|
||||
content: string,
|
||||
values: Record<string, string>,
|
||||
// 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 {
|
||||
return content.replace(/\{\{(\w+)\}\}/g, (match, key: string) =>
|
||||
key in values ? values[key] : match,
|
||||
);
|
||||
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()
|
||||
@@ -59,7 +88,9 @@ export class LabelTemplatesService {
|
||||
data: { isDefault: false },
|
||||
});
|
||||
}
|
||||
return tx.labelTemplate.create({ data: { ...dto } });
|
||||
return tx.labelTemplate.create({
|
||||
data: { ...dto, elements: dto.elements as never },
|
||||
});
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'LabelTemplate',
|
||||
@@ -84,7 +115,10 @@ export class LabelTemplatesService {
|
||||
data: { isDefault: false },
|
||||
});
|
||||
}
|
||||
return tx.labelTemplate.update({ where: { id }, data: { ...dto } });
|
||||
return tx.labelTemplate.update({
|
||||
where: { id },
|
||||
data: { ...dto, elements: dto.elements as never },
|
||||
});
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'LabelTemplate',
|
||||
@@ -127,7 +161,7 @@ export class LabelTemplatesService {
|
||||
throw new NotFoundException('Bottling batch not found');
|
||||
}
|
||||
|
||||
const values: Record<string, string> = {
|
||||
const values: Record<PlaceholderKey, string> = {
|
||||
batchNumber: bottlingBatch.honeyBatch.batchNumber,
|
||||
honeyType: bottlingBatch.honeyBatch.honeyType ?? '',
|
||||
lotNumber: bottlingBatch.lotNumber,
|
||||
@@ -136,25 +170,29 @@ export class LabelTemplatesService {
|
||||
jarSize: `${bottlingBatch.jarSize} ml`,
|
||||
productName: bottlingBatch.product?.name ?? '',
|
||||
};
|
||||
const labelText = fillPlaceholders(template.content, values);
|
||||
|
||||
// The QR code is generated once (not per copy) - it always points at the
|
||||
// public Herkunft/traceability page for this batch, which already shows
|
||||
// honey type, dates, and origin, so the label itself only needs to
|
||||
// reference it rather than duplicate that information.
|
||||
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 (labelText.includes(QR_CODE_PLACEHOLDER)) {
|
||||
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: 300,
|
||||
width: QR_SOURCE_SIZE_PX,
|
||||
});
|
||||
}
|
||||
|
||||
return this.renderPdf(
|
||||
labelText,
|
||||
elements,
|
||||
template.widthMm,
|
||||
template.heightMm,
|
||||
bottlingBatch.jarCount,
|
||||
@@ -163,7 +201,7 @@ export class LabelTemplatesService {
|
||||
}
|
||||
|
||||
private renderPdf(
|
||||
labelText: string,
|
||||
elements: LabelElement[],
|
||||
widthMm: number,
|
||||
heightMm: number,
|
||||
copies: number,
|
||||
@@ -197,40 +235,85 @@ export class LabelTemplatesService {
|
||||
const row = Math.floor(onPage / columns);
|
||||
const xMm = PAGE_MARGIN_MM + column * (widthMm + LABEL_GAP_MM);
|
||||
const yMm = PAGE_MARGIN_MM + row * (heightMm + LABEL_GAP_MM);
|
||||
const x = xMm * MM_TO_PT;
|
||||
const y = yMm * MM_TO_PT;
|
||||
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(x, y, w, h).dash(2, { space: 2 }).stroke('#999999').undash();
|
||||
doc
|
||||
.rect(originX, originY, w, h)
|
||||
.dash(2, { space: 2 })
|
||||
.stroke('#999999')
|
||||
.undash();
|
||||
|
||||
if (qrCodeBuffer) {
|
||||
const qrSide = Math.min(w, h) - 8;
|
||||
const qrX = x + w - qrSide - 4;
|
||||
const qrY = y + (h - qrSide) / 2;
|
||||
doc.image(qrCodeBuffer, qrX, qrY, { width: qrSide, height: qrSide });
|
||||
const textWidth = qrX - 4 - (x + 4);
|
||||
doc
|
||||
.fontSize(9)
|
||||
.fillColor('#000000')
|
||||
.text(
|
||||
labelText.replace(QR_CODE_PLACEHOLDER, '').trim(),
|
||||
x + 4,
|
||||
y + 4,
|
||||
{
|
||||
width: Math.max(textWidth, 0),
|
||||
height: h - 8,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
doc
|
||||
.fontSize(9)
|
||||
.fillColor('#000000')
|
||||
.text(labelText, x + 4, y + 4, { width: w - 8, height: h - 8 });
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user