feat: implement backend label print agent system for remote label rendering and job management
Build and Push Multi-Platform Images / build-and-push (push) Successful in 33s
Build and Push Multi-Platform Images / build-and-push (push) Successful in 33s
This commit is contained in:
@@ -17,6 +17,7 @@ import { StatsModule } from './stats/stats.module';
|
||||
import { BarcodeModule } from './barcode/barcode.module';
|
||||
import { InboxPostprocessorModule } from './inbox-postprocessor/inbox-postprocessor.module';
|
||||
import { UserSettingsModule } from './user-settings/user-settings.module';
|
||||
import { LabelPrintAgentModule } from './label-print-agent/label-print-agent.module';
|
||||
import * as path from 'path';
|
||||
|
||||
@Module({
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
InboxPostprocessingAction,
|
||||
CorrespondentEmailMapping,
|
||||
UserSettings,
|
||||
LabelPrintJob,
|
||||
} from './entities';
|
||||
|
||||
const entities = [
|
||||
@@ -49,6 +50,7 @@ const entities = [
|
||||
InboxPostprocessingAction,
|
||||
CorrespondentEmailMapping,
|
||||
UserSettings,
|
||||
LabelPrintJob,
|
||||
];
|
||||
|
||||
@Module({
|
||||
|
||||
@@ -8,6 +8,17 @@ import {
|
||||
|
||||
export type BarcodeActionType = 'SEND_TO_PAPERLESS' | 'SEND_BY_EMAIL';
|
||||
|
||||
export interface LabelInputField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'number' | 'date';
|
||||
}
|
||||
|
||||
export type LabelElement =
|
||||
| { type: 'text'; content: string; x: number; y: number; fontSize: number; bold?: boolean; align?: 'left' | 'center' | 'right'; maxWidth?: number }
|
||||
| { type: 'qr'; content: string; x: number; y: number; sizeMm: number }
|
||||
| { type: 'line'; x1: number; y1: number; x2: number; y2: number; lineWidth?: number };
|
||||
|
||||
@Entity('barcode_templates')
|
||||
export class BarcodeTemplate {
|
||||
@PrimaryGeneratedColumn()
|
||||
@@ -28,6 +39,31 @@ export class BarcodeTemplate {
|
||||
@Column({ type: 'json' })
|
||||
Actions!: BarcodeActionType[];
|
||||
|
||||
// ── Label-Konfiguration ─────────────────────────────────────
|
||||
@Column({ type: 'boolean', default: false })
|
||||
LabelEnabled!: boolean;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
LabelWidthMm!: number | null;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
LabelHeightMm!: number | null;
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
LabelInputFields!: LabelInputField[] | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 1000, nullable: true })
|
||||
LabelGetUrl!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 1000, nullable: true })
|
||||
LabelPrintedUrl!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 1000, nullable: true })
|
||||
LabelReleaseUrl!: string | null;
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
LabelLayout!: LabelElement[] | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
CreatedAt!: Date;
|
||||
|
||||
|
||||
@@ -20,3 +20,4 @@ export { InboxDocument } from './inbox-document.entity';
|
||||
export { InboxPostprocessingAction } from './inbox-postprocessing-action.entity';
|
||||
export { CorrespondentEmailMapping } from './correspondent-email-mapping.entity';
|
||||
export { UserSettings } from './user-settings.entity';
|
||||
export { LabelPrintJob } from './label-print-job.entity';
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('label_print_jobs')
|
||||
export class LabelPrintJob {
|
||||
@PrimaryGeneratedColumn()
|
||||
Id!: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'pending' })
|
||||
Status!: 'pending' | 'printed' | 'error';
|
||||
|
||||
@Column({ type: 'mediumblob', nullable: true })
|
||||
LabelImageData!: Buffer | null;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
LabelWidthMm!: number;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
LabelHeightMm!: number;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
BarcodeTemplateId!: number | null;
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
LabelVariables!: Record<string, string> | null;
|
||||
|
||||
@Column({ type: 'datetime', nullable: true })
|
||||
LockedAt!: Date | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
LockedByAgent!: string | null;
|
||||
|
||||
@Column({ type: 'datetime', nullable: true })
|
||||
PrintedAt!: Date | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
PrintedByAgent!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
PrinterName!: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
ErrorMessage!: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
CreatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { JwtOrApiKeyGuard } from '../auth/jwt-or-apikey.guard';
|
||||
import { RequirePermissions } from '../auth/permissions.decorator';
|
||||
import { Permission } from '../auth/permissions.enum';
|
||||
import { LabelPrintAgentService } from './label-print-agent.service';
|
||||
|
||||
@Controller('api/label-print-agent')
|
||||
@UseGuards(JwtOrApiKeyGuard)
|
||||
export class LabelPrintAgentController {
|
||||
constructor(private readonly service: LabelPrintAgentService) {}
|
||||
|
||||
// Manuell einen Job anlegen (Frontend → Backend)
|
||||
@Post('jobs')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@RequirePermissions(Permission.VIEW_SCANNER)
|
||||
async createJob(
|
||||
@Body() body: { templateId: number; fieldValues?: Record<string, string> },
|
||||
) {
|
||||
const job = await this.service.createJob(body.templateId, body.fieldValues ?? {});
|
||||
return { jobId: String(job.Id) };
|
||||
}
|
||||
|
||||
// Agent: nächsten Job abholen (Polling)
|
||||
@Get('jobs/next')
|
||||
async getNextJob(@Query('agentId') agentId: string, @Res({ passthrough: true }) res: Response) {
|
||||
const job = await this.service.claimNextJob(agentId ?? 'unknown');
|
||||
if (!job) {
|
||||
res.status(HttpStatus.NO_CONTENT).send();
|
||||
return;
|
||||
}
|
||||
return {
|
||||
jobId: String(job.Id),
|
||||
labelImageBase64: job.LabelImageData ? job.LabelImageData.toString('base64') : null,
|
||||
labelImageContentType: 'image/png',
|
||||
labelWidthMm: job.LabelWidthMm,
|
||||
labelHeightMm: job.LabelHeightMm,
|
||||
};
|
||||
}
|
||||
|
||||
// Agent: Bild separat abrufen
|
||||
@Get('jobs/:id/image')
|
||||
async getImage(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<StreamableFile> {
|
||||
const buf = await this.service.getJobImage(id);
|
||||
if (!buf) throw new NotFoundException('Bild nicht gefunden');
|
||||
const { Readable } = await import('stream');
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
return new StreamableFile(Readable.from(buf));
|
||||
}
|
||||
|
||||
// Agent: Druck erfolgreich
|
||||
@Post('jobs/:id/printed')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async markPrinted(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() body: { agentId?: string; printerName?: string },
|
||||
) {
|
||||
await this.service.markPrinted(id, body.agentId ?? 'unknown', body.printerName ?? '');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// Agent: Druckfehler
|
||||
@Post('jobs/:id/error')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async markError(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() body: { agentId?: string; printerName?: string; errorMessage?: string },
|
||||
) {
|
||||
await this.service.markError(id, body.agentId ?? 'unknown', body.printerName ?? '', body.errorMessage ?? '');
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { LabelPrintJob } from '../database/entities/label-print-job.entity';
|
||||
import { BarcodeTemplate } from '../database/entities/barcode-template.entity';
|
||||
import { LabelPrintAgentService } from './label-print-agent.service';
|
||||
import { LabelRendererService } from './label-renderer.service';
|
||||
import { LabelPrintAgentController } from './label-print-agent.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([LabelPrintJob, BarcodeTemplate]),
|
||||
AuthModule,
|
||||
],
|
||||
providers: [LabelPrintAgentService, LabelRendererService],
|
||||
controllers: [LabelPrintAgentController],
|
||||
exports: [LabelPrintAgentService, LabelRendererService],
|
||||
})
|
||||
export class LabelPrintAgentModule {}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, LessThan, IsNull, Or } from 'typeorm';
|
||||
import { LabelPrintJob } from '../database/entities/label-print-job.entity';
|
||||
import { BarcodeTemplate } from '../database/entities/barcode-template.entity';
|
||||
import { LabelRendererService } from './label-renderer.service';
|
||||
|
||||
function applyVars(template: string, vars: Record<string, string>): string {
|
||||
return template.replace(/\{([^}]+)\}/g, (_, key) => vars[key] ?? '');
|
||||
}
|
||||
|
||||
function lockExpiry(): Date {
|
||||
const d = new Date();
|
||||
d.setMinutes(d.getMinutes() - 5);
|
||||
return d;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LabelPrintAgentService {
|
||||
private readonly logger = new Logger(LabelPrintAgentService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(LabelPrintJob)
|
||||
private readonly jobRepo: Repository<LabelPrintJob>,
|
||||
@InjectRepository(BarcodeTemplate)
|
||||
private readonly templateRepo: Repository<BarcodeTemplate>,
|
||||
private readonly renderer: LabelRendererService,
|
||||
) {}
|
||||
|
||||
async createJob(
|
||||
templateId: number,
|
||||
fieldValues: Record<string, string>,
|
||||
): Promise<LabelPrintJob> {
|
||||
const template = await this.templateRepo.findOne({ where: { Id: templateId } });
|
||||
if (!template) throw new NotFoundException('Template nicht gefunden');
|
||||
if (!template.LabelEnabled) throw new BadRequestException('Etikett-Druck für dieses Template nicht aktiviert');
|
||||
|
||||
// Variablen aufbauen
|
||||
const vars: Record<string, string> = { ...fieldValues };
|
||||
|
||||
// Datum-Felder: year/month/day separat ablegen
|
||||
for (const field of template.LabelInputFields ?? []) {
|
||||
if (field.type === 'date' && fieldValues[field.name]) {
|
||||
const raw = fieldValues[field.name]; // erwartet YYYY-MM-DD
|
||||
const parts = raw.split('-');
|
||||
if (parts.length === 3) {
|
||||
vars[`${field.name}.year`] = parts[0];
|
||||
vars[`${field.name}.month`] = parts[1];
|
||||
vars[`${field.name}.day`] = parts[2];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GET-URL aufrufen → {number}
|
||||
if (template.LabelGetUrl) {
|
||||
const url = applyVars(template.LabelGetUrl, vars);
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const text = (await res.text()).trim();
|
||||
vars['number'] = text;
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`GET-URL fehlgeschlagen (${url}): ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const job = this.jobRepo.create({
|
||||
Status: 'pending',
|
||||
LabelImageData: null,
|
||||
LabelWidthMm: template.LabelWidthMm ?? 57,
|
||||
LabelHeightMm: template.LabelHeightMm ?? 32,
|
||||
BarcodeTemplateId: template.Id,
|
||||
LabelVariables: vars,
|
||||
LockedAt: null,
|
||||
LockedByAgent: null,
|
||||
});
|
||||
|
||||
return this.jobRepo.save(job);
|
||||
}
|
||||
|
||||
async claimNextJob(agentId: string): Promise<LabelPrintJob | null> {
|
||||
// Kandidat: pending und kein Lock oder Lock abgelaufen (> 5 Min)
|
||||
const candidate = await this.jobRepo.findOne({
|
||||
where: [
|
||||
{ Status: 'pending', LockedAt: IsNull() },
|
||||
{ Status: 'pending', LockedAt: LessThan(lockExpiry()) },
|
||||
],
|
||||
order: { CreatedAt: 'ASC' },
|
||||
});
|
||||
if (!candidate) return null;
|
||||
|
||||
// Lock setzen
|
||||
candidate.LockedAt = new Date();
|
||||
candidate.LockedByAgent = agentId;
|
||||
await this.jobRepo.save(candidate);
|
||||
|
||||
// Lazy render
|
||||
if (!candidate.LabelImageData) {
|
||||
const template = await this.templateRepo.findOne({ where: { Id: candidate.BarcodeTemplateId ?? undefined } });
|
||||
if (template?.LabelLayout?.length) {
|
||||
try {
|
||||
candidate.LabelImageData = await this.renderer.render(
|
||||
template.LabelLayout,
|
||||
candidate.LabelWidthMm,
|
||||
candidate.LabelHeightMm,
|
||||
candidate.LabelVariables ?? {},
|
||||
);
|
||||
await this.jobRepo.save(candidate);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Label-Rendering fehlgeschlagen für Job ${candidate.Id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
async getJobImage(jobId: number): Promise<Buffer | null> {
|
||||
const job = await this.jobRepo.findOne({ where: { Id: jobId } });
|
||||
return job?.LabelImageData ?? null;
|
||||
}
|
||||
|
||||
async markPrinted(jobId: number, agentId: string, printerName: string): Promise<void> {
|
||||
const job = await this.jobRepo.findOne({ where: { Id: jobId } });
|
||||
if (!job) throw new NotFoundException('Job nicht gefunden');
|
||||
|
||||
job.Status = 'printed';
|
||||
job.PrintedAt = new Date();
|
||||
job.PrintedByAgent = agentId;
|
||||
job.PrinterName = printerName;
|
||||
await this.jobRepo.save(job);
|
||||
|
||||
await this.callUrl('PRINTED', job);
|
||||
}
|
||||
|
||||
async markError(jobId: number, agentId: string, printerName: string, errorMessage: string): Promise<void> {
|
||||
const job = await this.jobRepo.findOne({ where: { Id: jobId } });
|
||||
if (!job) throw new NotFoundException('Job nicht gefunden');
|
||||
|
||||
job.Status = 'error';
|
||||
job.PrintedByAgent = agentId;
|
||||
job.PrinterName = printerName;
|
||||
job.ErrorMessage = errorMessage;
|
||||
await this.jobRepo.save(job);
|
||||
|
||||
await this.callUrl('RELEASE', job);
|
||||
}
|
||||
|
||||
private async callUrl(type: 'PRINTED' | 'RELEASE', job: LabelPrintJob): Promise<void> {
|
||||
const template = job.BarcodeTemplateId
|
||||
? await this.templateRepo.findOne({ where: { Id: job.BarcodeTemplateId } })
|
||||
: null;
|
||||
if (!template) return;
|
||||
|
||||
const urlTemplate = type === 'PRINTED' ? template.LabelPrintedUrl : template.LabelReleaseUrl;
|
||||
if (!urlTemplate) return;
|
||||
|
||||
const url = applyVars(urlTemplate, job.LabelVariables ?? {});
|
||||
try {
|
||||
await fetch(url, { method: 'POST' });
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`${type}-URL fehlgeschlagen (${url}): ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as QRCode from 'qrcode';
|
||||
import sharp from 'sharp';
|
||||
import type { LabelElement } from '../database/entities/barcode-template.entity';
|
||||
|
||||
const MM_TO_PX = 300 / 25.4; // 300 DPI
|
||||
|
||||
function mm(v: number): number {
|
||||
return Math.round(v * MM_TO_PX);
|
||||
}
|
||||
|
||||
function escape(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function applyVars(template: string, vars: Record<string, string>): string {
|
||||
return template.replace(/\{([^}]+)\}/g, (_, key) => vars[key] ?? `{${key}}`);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LabelRendererService {
|
||||
private readonly logger = new Logger(LabelRendererService.name);
|
||||
|
||||
async render(
|
||||
layout: LabelElement[],
|
||||
widthMm: number,
|
||||
heightMm: number,
|
||||
variables: Record<string, string>,
|
||||
): Promise<Buffer> {
|
||||
const W = mm(widthMm);
|
||||
const H = mm(heightMm);
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const el of layout) {
|
||||
if (el.type === 'text') {
|
||||
const x = mm(el.x);
|
||||
const y = mm(el.y);
|
||||
const fontSize = mm(el.fontSize);
|
||||
const content = escape(applyVars(el.content, variables));
|
||||
const fontWeight = el.bold ? 'bold' : 'normal';
|
||||
const textAnchor = el.align === 'center' ? 'middle' : el.align === 'right' ? 'end' : 'start';
|
||||
const maxWidthAttr = el.maxWidth ? ` textLength="${mm(el.maxWidth)}" lengthAdjust="spacingAndGlyphs"` : '';
|
||||
parts.push(
|
||||
`<text x="${x}" y="${y}" font-family="Arial,Helvetica,sans-serif" font-size="${fontSize}" font-weight="${fontWeight}" text-anchor="${textAnchor}" dominant-baseline="hanging"${maxWidthAttr}>${content}</text>`,
|
||||
);
|
||||
} else if (el.type === 'qr') {
|
||||
const x = mm(el.x);
|
||||
const y = mm(el.y);
|
||||
const size = mm(el.sizeMm);
|
||||
const content = applyVars(el.content, variables);
|
||||
try {
|
||||
const qrBuffer = await QRCode.toBuffer(content, {
|
||||
type: 'png',
|
||||
margin: 0,
|
||||
width: size,
|
||||
errorCorrectionLevel: 'M',
|
||||
});
|
||||
const b64 = qrBuffer.toString('base64');
|
||||
parts.push(`<image href="data:image/png;base64,${b64}" x="${x}" y="${y}" width="${size}" height="${size}"/>`);
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`QR-Code-Rendering fehlgeschlagen für "${content}": ${err.message}`);
|
||||
}
|
||||
} else if (el.type === 'line') {
|
||||
const x1 = mm(el.x1);
|
||||
const y1 = mm(el.y1);
|
||||
const x2 = mm(el.x2);
|
||||
const y2 = mm(el.y2);
|
||||
const strokeWidth = el.lineWidth ? mm(el.lineWidth) : 1;
|
||||
parts.push(`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="black" stroke-width="${strokeWidth}"/>`);
|
||||
}
|
||||
}
|
||||
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
|
||||
<rect width="${W}" height="${H}" fill="white"/>
|
||||
${parts.join('\n ')}
|
||||
</svg>`;
|
||||
|
||||
return sharp(Buffer.from(svg)).png().toBuffer();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user