228 lines
7.7 KiB
TypeScript
228 lines
7.7 KiB
TypeScript
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository, LessThan, IsNull } from 'typeorm';
|
|
import { Subject, Observable } from 'rxjs';
|
|
import { LabelPrintJob } from '../database/entities/label-print-job.entity';
|
|
import { BarcodeTemplate } from '../database/entities/barcode-template.entity';
|
|
import { LabelRendererService } from './label-renderer.service';
|
|
|
|
function isSafeUrl(url: string): boolean {
|
|
try {
|
|
const parsed = new URL(url);
|
|
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function applyVars(template: string, vars: Record<string, string>): string {
|
|
return template.replace(/\{([^}]+)\}/g, (_, key: string) => {
|
|
const colonIdx = key.indexOf(':');
|
|
if (colonIdx !== -1) {
|
|
const varName = key.slice(0, colonIdx);
|
|
const width = parseInt(key.slice(colonIdx + 1), 10);
|
|
if (!isNaN(width) && varName in vars) {
|
|
return vars[varName].padStart(width, '0');
|
|
}
|
|
}
|
|
return 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);
|
|
private readonly jobCreated$ = new Subject<void>();
|
|
|
|
get newJob$(): Observable<void> {
|
|
return this.jobCreated$.asObservable();
|
|
}
|
|
|
|
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] = `${parts[2]}.${parts[1]}.${parts[0]}`; // dd.MM.yyyy
|
|
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);
|
|
if (isSafeUrl(url)) {
|
|
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}`);
|
|
}
|
|
} else {
|
|
this.logger.warn(`GET-URL übersprungen (ungültiges Protokoll): ${url}`);
|
|
}
|
|
}
|
|
|
|
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,
|
|
});
|
|
|
|
const saved = await this.jobRepo.save(job);
|
|
this.jobCreated$.next();
|
|
return saved;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
async renderPreview(templateId: number, fieldValues: Record<string, string>): Promise<Buffer> {
|
|
const template = await this.templateRepo.findOne({ where: { Id: templateId } });
|
|
if (!template) throw new NotFoundException('Template nicht gefunden');
|
|
if (!template.LabelLayout?.length) throw new BadRequestException('Kein Layout definiert');
|
|
|
|
const vars: Record<string, string> = { ...fieldValues };
|
|
for (const field of template.LabelInputFields ?? []) {
|
|
if (field.type === 'date' && fieldValues[field.name]) {
|
|
const parts = fieldValues[field.name].split('-');
|
|
if (parts.length === 3) {
|
|
vars[field.name] = `${parts[2]}.${parts[1]}.${parts[0]}`; // dd.MM.yyyy
|
|
vars[`${field.name}.year`] = parts[0];
|
|
vars[`${field.name}.month`] = parts[1];
|
|
vars[`${field.name}.day`] = parts[2];
|
|
}
|
|
}
|
|
}
|
|
vars['number'] = '1';
|
|
|
|
return this.renderer.render(
|
|
template.LabelLayout,
|
|
template.LabelWidthMm ?? 57,
|
|
template.LabelHeightMm ?? 32,
|
|
vars,
|
|
);
|
|
}
|
|
|
|
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 ?? {});
|
|
if (!isSafeUrl(url)) {
|
|
this.logger.warn(`${type}-URL übersprungen (ungültiges Protokoll): ${url}`);
|
|
return;
|
|
}
|
|
try {
|
|
await fetch(url, { method: 'POST' });
|
|
} catch (err: any) {
|
|
this.logger.warn(`${type}-URL fehlgeschlagen (${url}): ${err.message}`);
|
|
}
|
|
}
|
|
}
|