dad0136365
Build and Push Multi-Platform Images / build-and-push (push) Successful in 41s
Reformats code style (line breaks, indentation, type annotations) without changing logic. Also includes minor feature additions bundled in the same lint run (stats service, user-settings groups, agrarmonitor polling improvements). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
989 lines
38 KiB
Markdown
989 lines
38 KiB
Markdown
# Label-Print-Agent – Architektur & Implementierungsreferenz
|
||
|
||
Dieses Dokument beschreibt vollständig das Label-Print-Agent-System aus dem Paperless Manager. Es dient als Grundlage für die Reimplementierung in einem anderen Projekt.
|
||
|
||
---
|
||
|
||
## 1. Systemübersicht
|
||
|
||
Das System ermöglicht es, **Etiketten-Templates** zu definieren (mit Layout, Variablen-Feldern und Abmessungen) und daraus **Druckjobs** zu erzeugen, die von einem oder mehreren **Print Agents** abgeholt und gedruckt werden.
|
||
|
||
### Beteiligte Komponenten
|
||
|
||
| Komponente | Aufgabe |
|
||
|---|---|
|
||
| **Frontend** | Template-Verwaltung, Job-Erstellung, Label-Vorschau |
|
||
| **Backend API** | REST-Endpunkte, Job-Queue, Rendering |
|
||
| **Datenbank** | Persistenz für Templates und Jobs |
|
||
| **LabelRenderer** | SVG → PNG (300 DPI) |
|
||
| **Print Agent** | Eigenständiger Client, der Jobs abholt und druckt |
|
||
|
||
### Datenfluss (ASCII)
|
||
|
||
```
|
||
┌────────────────────────────────────────────────────────────────────┐
|
||
│ FRONTEND │
|
||
│ • Template anlegen (Name, Layout, Felder, Maße) │
|
||
│ • Vorschau rendern (POST /preview → PNG) │
|
||
│ • Druckjob erstellen (POST /jobs → { jobId }) │
|
||
└──────────────────────────────┬─────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌────────────────────────────────────────────────────────────────────┐
|
||
│ BACKEND: createJob() │
|
||
│ 1. Template laden │
|
||
│ 2. Variablen aufbauen (Felder + Datumssubfelder + {number}) │
|
||
│ 3. LabelGetUrl aufrufen → {number} reservieren (optional) │
|
||
│ 4. Job speichern (Status=pending, imageData=null) │
|
||
│ 5. newJob$.next() → SSE-Notification an alle Agents │
|
||
└──────────────────────────────┬─────────────────────────────────────┘
|
||
│
|
||
┌───────────────┴──────────────────┐
|
||
│ SSE-Push │ Polling (Fallback)
|
||
▼ ▼
|
||
┌──────────────────────────────────────────────────────────────────┐
|
||
│ PRINT AGENT │
|
||
│ GET /jobs/next?agentId=MEIN-PC │
|
||
│ → Lock setzen (lockedAt = now) │
|
||
│ → Lazy Render: Layout → SVG → PNG → in DB speichern │
|
||
│ → { jobId, labelImageBase64, widthMm, heightMm } │
|
||
│ │
|
||
│ Drucken → POST /jobs/:id/printed (Erfolg) │
|
||
│ → POST /jobs/:id/error (Fehler) │
|
||
└──────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 2. Datenmodelle
|
||
|
||
### 2.1 LabelTemplate (in PM: Teil des `BarcodeTemplate`)
|
||
|
||
Für das neue Projekt empfiehlt sich eine eigenständige `LabelTemplate`-Entity:
|
||
|
||
```typescript
|
||
export interface LabelInputField {
|
||
name: string; // Technischer Feldname (Variablenname)
|
||
label: string; // Anzeigetext im Formular
|
||
type: 'text' | 'number' | 'date';
|
||
}
|
||
|
||
export type LabelElement =
|
||
| {
|
||
type: 'text';
|
||
content: string; // Inhalt, kann {variablenname} enthalten
|
||
x: number; // X-Position in mm ab linker Kante
|
||
y: number; // Y-Position in mm ab oberer Kante
|
||
fontSize: number; // Schriftgröße in mm
|
||
bold?: boolean;
|
||
align?: 'left' | 'center' | 'right';
|
||
maxWidth?: number; // Maximale Breite in mm (Text wird gestreckt/gestaucht)
|
||
}
|
||
| {
|
||
type: 'qr';
|
||
content: string; // QR-Inhalt, kann {variablenname} enthalten
|
||
x: number; // X-Position in mm
|
||
y: number; // Y-Position in mm
|
||
sizeMm: number; // Quadratische Größe in mm
|
||
}
|
||
| {
|
||
type: 'line';
|
||
x1: number; y1: number; // Startpunkt in mm
|
||
x2: number; y2: number; // Endpunkt in mm
|
||
lineWidth?: number; // Linienbreite in mm (default: ~0,085mm = 1px bei 300DPI)
|
||
};
|
||
|
||
// TypeORM Entity (vereinfacht)
|
||
@Entity('label_templates')
|
||
export class LabelTemplate {
|
||
@PrimaryGeneratedColumn()
|
||
id: number;
|
||
|
||
@Column({ length: 100 })
|
||
name: string;
|
||
|
||
@Column({ type: 'int', default: 57 })
|
||
widthMm: number; // Etikettenbreite in mm
|
||
|
||
@Column({ type: 'int', default: 32 })
|
||
heightMm: number; // Etikettenhöhe in mm
|
||
|
||
@Column({ type: 'simple-json', nullable: true })
|
||
inputFields: LabelInputField[] | null; // Formularfelder
|
||
|
||
@Column({ type: 'simple-json', nullable: true })
|
||
layout: LabelElement[] | null; // Etikett-Elemente
|
||
|
||
@Column({ length: 1000, nullable: true })
|
||
getUrl: string | null; // URL zur Nummernreservierung (GET, {var} möglich)
|
||
|
||
@Column({ length: 1000, nullable: true })
|
||
printedUrl: string | null; // Callback nach erfolgreichem Druck (POST)
|
||
|
||
@Column({ length: 1000, nullable: true })
|
||
releaseUrl: string | null; // Callback bei Druckfehler (POST)
|
||
|
||
@CreateDateColumn()
|
||
createdAt: Date;
|
||
|
||
@UpdateDateColumn()
|
||
updatedAt: Date;
|
||
}
|
||
```
|
||
|
||
### 2.2 LabelPrintJob
|
||
|
||
Die DB-Tabelle fungiert als **persistente Job-Queue**.
|
||
|
||
```typescript
|
||
// Originaldatei: paperless-backend/src/database/entities/label-print-job.entity.ts
|
||
|
||
@Entity('label_print_jobs')
|
||
export class LabelPrintJob {
|
||
@PrimaryGeneratedColumn()
|
||
Id: number;
|
||
|
||
@Index()
|
||
@Column({ type: 'varchar', length: 20, default: 'pending' })
|
||
Status: 'pending' | 'printed' | 'error';
|
||
|
||
@Column({ type: 'mediumblob', nullable: true })
|
||
LabelImageData: Buffer | null; // Lazy-rendered PNG (kann null sein bis Claim)
|
||
|
||
@Column({ type: 'int' })
|
||
LabelWidthMm: number;
|
||
|
||
@Column({ type: 'int' })
|
||
LabelHeightMm: number;
|
||
|
||
@Column({ type: 'int', nullable: true })
|
||
BarcodeTemplateId: number | null; // Referenz auf Template (ohne FK-Constraint)
|
||
|
||
@Column({ type: 'simple-json', nullable: true })
|
||
LabelVariables: Record<string, string> | null; // Aufgelöste Variablen zum Druckzeitpunkt
|
||
|
||
@Index()
|
||
@Column({ type: 'datetime', nullable: true })
|
||
LockedAt: Date | null; // Zeitstempel des Locks
|
||
|
||
@Column({ length: 255, nullable: true })
|
||
LockedByAgent: string | null; // Agenten-ID (z.B. "BUERO-PC")
|
||
|
||
@Column({ type: 'datetime', nullable: true })
|
||
PrintedAt: Date | null;
|
||
|
||
@Column({ length: 255, nullable: true })
|
||
PrintedByAgent: string | null;
|
||
|
||
@Column({ length: 255, nullable: true })
|
||
PrinterName: string | null;
|
||
|
||
@Column({ type: 'text', nullable: true })
|
||
ErrorMessage: string | null;
|
||
|
||
@CreateDateColumn()
|
||
CreatedAt: Date;
|
||
}
|
||
```
|
||
|
||
**Wichtig:** `LabelVariables` speichert die bereits aufgelösten Variablen zum Zeitpunkt der Job-Erstellung. Das Template-Layout kann sich danach ändern — der Job verwendet trotzdem die ursprünglichen Werte.
|
||
|
||
---
|
||
|
||
## 3. API-Endpunkte
|
||
|
||
Alle Endpunkte unter `/api/label-print-agent`:
|
||
|
||
### Frontend-Endpunkte (erfordern Authentifizierung)
|
||
|
||
#### `POST /preview` — Label-Vorschau rendern
|
||
|
||
Rendert ein PNG ohne DB-Eintrag. Zum Testen von Layouts.
|
||
|
||
```
|
||
Request: { templateId: number, fieldValues?: Record<string, string> }
|
||
Response: image/png (Buffer)
|
||
```
|
||
|
||
#### `POST /jobs` — Druckjob erstellen
|
||
|
||
```
|
||
Request: { templateId: number, fieldValues?: Record<string, string> }
|
||
Response: { jobId: string }
|
||
Status: 201 Created
|
||
```
|
||
|
||
### Agent-Endpunkte
|
||
|
||
#### `GET /events` — SSE-Stream (Server-Sent Events)
|
||
|
||
Agent hält diese Verbindung dauerhaft offen. Bei neuem Job erhält er eine Notification.
|
||
|
||
```
|
||
Response: text/event-stream
|
||
Payload: { type: 'label-job-available' }
|
||
Header: X-Accel-Buffering: no (wichtig für nginx-Reverse-Proxy)
|
||
```
|
||
|
||
#### `GET /jobs/next?agentId=MEIN-PC` — Nächsten Job beanspruchen
|
||
|
||
```
|
||
Response 200: {
|
||
jobId: string,
|
||
labelImageBase64: string | null, // PNG als Base64
|
||
labelImageContentType: 'image/png',
|
||
labelWidthMm: number,
|
||
labelHeightMm: number
|
||
}
|
||
Response 204: (kein Job verfügbar)
|
||
```
|
||
|
||
#### `GET /jobs/:id/image` — Job-Bild separat abrufen
|
||
|
||
```
|
||
Response: image/png (Buffer)
|
||
```
|
||
|
||
#### `POST /jobs/:id/printed` — Job als gedruckt markieren
|
||
|
||
```
|
||
Request: { agentId?: string, printerName?: string }
|
||
Response: { ok: true }
|
||
```
|
||
|
||
#### `POST /jobs/:id/error` — Druckfehler melden
|
||
|
||
```
|
||
Request: { agentId?: string, printerName?: string, errorMessage?: string }
|
||
Response: { ok: true }
|
||
```
|
||
|
||
---
|
||
|
||
## 4. SVG-Rendering-Pipeline
|
||
|
||
**Originaldatei:** `paperless-backend/src/label-print-agent/label-renderer.service.ts`
|
||
|
||
### Abhängigkeiten
|
||
|
||
```bash
|
||
npm install qrcode @resvg/resvg-js
|
||
npm install --save-dev @types/qrcode
|
||
```
|
||
|
||
### Vollständige Implementierung
|
||
|
||
```typescript
|
||
import * as QRCode from 'qrcode';
|
||
import { Resvg } from '@resvg/resvg-js';
|
||
|
||
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, '"');
|
||
}
|
||
|
||
// Variablen-Substitution: {varName} oder {varName:6} (zero-padded auf 6 Stellen)
|
||
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)) {
|
||
return (vars[varName] ?? '').padStart(width, '0');
|
||
}
|
||
}
|
||
return vars[key] ?? `{${key}}`; // Unbekannte Variablen: Platzhalter stehen lassen
|
||
});
|
||
}
|
||
|
||
async function renderLabel(
|
||
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 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"`
|
||
: '';
|
||
// Wichtig: librsvg ignoriert dominant-baseline.
|
||
// Y-Koordinate ist die Oberkante des Texts → fontSize addieren für Baseline.
|
||
const yBaseline = mm(el.y) + fontSize;
|
||
parts.push(
|
||
`<text x="${x}" y="${yBaseline}" ` +
|
||
`font-family="Arial,Helvetica,sans-serif" font-size="${fontSize}" ` +
|
||
`font-weight="${fontWeight}" text-anchor="${textAnchor}"${maxWidthAttr}>${content}</text>`,
|
||
);
|
||
|
||
} else if (el.type === 'qr') {
|
||
const size = mm(el.sizeMm);
|
||
const content = applyVars(el.content, variables);
|
||
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="${mm(el.x)}" y="${mm(el.y)}" width="${size}" height="${size}"/>`,
|
||
);
|
||
|
||
} else if (el.type === 'line') {
|
||
const strokeWidth = el.lineWidth ? mm(el.lineWidth) : 1;
|
||
parts.push(
|
||
`<line x1="${mm(el.x1)}" y1="${mm(el.y1)}" ` +
|
||
`x2="${mm(el.x2)}" y2="${mm(el.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>`,
|
||
].join('\n');
|
||
|
||
const resvg = new Resvg(svg, {
|
||
font: {
|
||
loadSystemFonts: true,
|
||
fontDirs: ['/usr/share/fonts', '/usr/local/share/fonts'],
|
||
defaultFontFamily: 'Liberation Sans',
|
||
sansSerifFamily: 'Liberation Sans',
|
||
},
|
||
});
|
||
return Buffer.from(resvg.render().asPng());
|
||
}
|
||
```
|
||
|
||
### Bekannte Eigenheiten
|
||
|
||
- **Baseline-Problem:** `librsvg` (verwendet von `@resvg/resvg-js`) ignoriert `dominant-baseline`. Daher muss zur Y-Koordinate die Schriftgröße addiert werden, damit die **Oberkante** des Textes der eingegebenen Y-Position entspricht (s. `yBaseline`-Berechnung).
|
||
- **Schriften:** Auf Systemen ohne `Liberation Sans` (z.B. Windows) fällt die Bibliothek auf Arial zurück. Für plattformübergreifende Konsistenz können Schriften auch per Buffer eingebettet werden.
|
||
- **QR-Code:** `errorCorrectionLevel: 'M'` ist ein guter Kompromiss zwischen Fehlertoleranz und Dichte.
|
||
|
||
---
|
||
|
||
## 5. Job Queue & SSE-Mechanismus
|
||
|
||
**Originaldateien:**
|
||
- `paperless-backend/src/label-print-agent/label-print-agent.service.ts`
|
||
- `paperless-backend/src/label-print-agent/label-print-agent.controller.ts`
|
||
|
||
### Job erstellen
|
||
|
||
```typescript
|
||
// Originaldatei: label-print-agent.service.ts
|
||
|
||
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] ?? '';
|
||
});
|
||
}
|
||
|
||
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');
|
||
|
||
const vars: Record<string, string> = { ...fieldValues };
|
||
|
||
// Datum-Felder aufsplitten: {datum} → dd.MM.yyyy, {datum.year}, {datum.month}, {datum.day}
|
||
for (const field of template.LabelInputFields ?? []) {
|
||
if (field.type === 'date' && fieldValues[field.name]) {
|
||
const parts = fieldValues[field.name].split('-'); // YYYY-MM-DD
|
||
if (parts.length === 3) {
|
||
vars[field.name] = `${parts[2]}.${parts[1]}.${parts[0]}`;
|
||
vars[`${field.name}.year`] = parts[0];
|
||
vars[`${field.name}.month`] = parts[1];
|
||
vars[`${field.name}.day`] = parts[2];
|
||
}
|
||
}
|
||
}
|
||
|
||
// Externe Nummern-Reservierung (optional)
|
||
if (template.LabelGetUrl) {
|
||
const url = applyVars(template.LabelGetUrl, vars);
|
||
const res = await fetch(url);
|
||
vars['number'] = (await res.text()).trim();
|
||
}
|
||
|
||
const job = this.jobRepo.create({
|
||
Status: 'pending',
|
||
LabelImageData: null, // Lazy rendering: erst beim Claim rendern
|
||
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(); // SSE-Notification an alle verbundenen Agents
|
||
return saved;
|
||
}
|
||
```
|
||
|
||
### Job beanspruchen (Optimistic Locking)
|
||
|
||
```typescript
|
||
// Lock-Ablaufzeit: 5 Minuten in der Vergangenheit
|
||
function lockExpiry(): Date {
|
||
const d = new Date();
|
||
d.setMinutes(d.getMinutes() - 5);
|
||
return d;
|
||
}
|
||
|
||
async claimNextJob(agentId: string): Promise<LabelPrintJob | null> {
|
||
// Finde den ältesten pending Job ohne Lock oder mit abgelaufenem Lock
|
||
const candidate = await this.jobRepo.findOne({
|
||
where: [
|
||
{ Status: 'pending', LockedAt: IsNull() },
|
||
{ Status: 'pending', LockedAt: LessThan(lockExpiry()) },
|
||
],
|
||
order: { CreatedAt: 'ASC' }, // FIFO
|
||
});
|
||
if (!candidate) return null;
|
||
|
||
// Lock setzen (optimistisch, kein DB-Row-Lock)
|
||
candidate.LockedAt = new Date();
|
||
candidate.LockedByAgent = agentId;
|
||
await this.jobRepo.save(candidate);
|
||
|
||
// Lazy Render: PNG erst jetzt erzeugen
|
||
if (!candidate.LabelImageData) {
|
||
const template = await this.templateRepo.findOne({ where: { Id: candidate.BarcodeTemplateId } });
|
||
if (template?.LabelLayout?.length) {
|
||
candidate.LabelImageData = await this.renderer.render(
|
||
template.LabelLayout,
|
||
candidate.LabelWidthMm,
|
||
candidate.LabelHeightMm,
|
||
candidate.LabelVariables ?? {},
|
||
);
|
||
await this.jobRepo.save(candidate);
|
||
}
|
||
}
|
||
|
||
return candidate;
|
||
}
|
||
```
|
||
|
||
### SSE-Stream (NestJS)
|
||
|
||
```typescript
|
||
// Originaldatei: label-print-agent.controller.ts
|
||
|
||
private readonly jobCreated$ = new Subject<void>(); // im Service
|
||
|
||
// Controller:
|
||
@Sse('events')
|
||
sseEvents(@Res({ passthrough: true }) res: Response): Observable<MessageEvent> {
|
||
res.setHeader('X-Accel-Buffering', 'no'); // nginx: kein Buffering
|
||
return this.service.newJob$.pipe(
|
||
map(() => ({ data: { type: 'label-job-available' } } as MessageEvent)),
|
||
);
|
||
}
|
||
```
|
||
|
||
### SSE-Client (Agent-Seite, Plain JavaScript)
|
||
|
||
```javascript
|
||
const evtSource = new EventSource('http://backend/api/label-print-agent/events', {
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
});
|
||
|
||
evtSource.onmessage = async (event) => {
|
||
const data = JSON.parse(event.data);
|
||
if (data.type === 'label-job-available') {
|
||
await pollAndPrint();
|
||
}
|
||
};
|
||
|
||
// Fallback: alle 30 Sekunden pollen
|
||
setInterval(pollAndPrint, 30_000);
|
||
|
||
async function pollAndPrint() {
|
||
const res = await fetch(`http://backend/api/label-print-agent/jobs/next?agentId=MEIN-PC`);
|
||
if (res.status === 204) return; // kein Job
|
||
|
||
const job = await res.json();
|
||
const imageBytes = Buffer.from(job.labelImageBase64, 'base64');
|
||
|
||
try {
|
||
await printToPrinter(imageBytes, job.labelWidthMm, job.labelHeightMm);
|
||
await fetch(`http://backend/api/label-print-agent/jobs/${job.jobId}/printed`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ agentId: 'MEIN-PC', printerName: 'DYMO 450' }),
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
} catch (err) {
|
||
await fetch(`http://backend/api/label-print-agent/jobs/${job.jobId}/error`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ agentId: 'MEIN-PC', errorMessage: String(err) }),
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 6. Variablen-System
|
||
|
||
### Template-Syntax
|
||
|
||
| Syntax | Bedeutung | Beispiel |
|
||
|---|---|---|
|
||
| `{feldname}` | Wert des Feldes | `{name}` → `Müller GmbH` |
|
||
| `{feldname:6}` | Zero-padded auf N Stellen | `{nr:6}` → `000042` |
|
||
| `{datum}` (date-Feld) | Formatiert als `dd.MM.yyyy` | `{datum}` → `15.05.2026` |
|
||
| `{datum.year}` | Nur das Jahr | `{datum.year}` → `2026` |
|
||
| `{datum.month}` | Nur den Monat | `{datum.month}` → `05` |
|
||
| `{datum.day}` | Nur den Tag | `{datum.day}` → `15` |
|
||
| `{number}` | Reservierte Nummer (via GetUrl) | `{number}` → `1234` |
|
||
|
||
### URL-Templates
|
||
|
||
Die Felder `LabelGetUrl`, `LabelPrintedUrl`, `LabelReleaseUrl` unterstützen ebenfalls Variablen-Substitution:
|
||
|
||
```
|
||
LabelGetUrl: https://api.example.com/numbers/next?batch={batch}
|
||
LabelPrintedUrl: https://api.example.com/numbers/{number}/confirm
|
||
LabelReleaseUrl: https://api.example.com/numbers/{number}/release
|
||
```
|
||
|
||
**Sicherheitscheck:** Nur `http://` und `https://` werden akzeptiert.
|
||
|
||
---
|
||
|
||
## 7. Vollständiger Job-Lebenszyklus
|
||
|
||
```
|
||
Status: pending → (Lock gesetzt) → printed / error
|
||
|
||
┌─ createJob() ──────────────────────────────────────────────────┐
|
||
│ Template laden → Vars aufbauen → GetUrl aufrufen (optional) │
|
||
│ Job speichern: Status=pending, LockedAt=null, imageData=null │
|
||
│ newJob$.next() → SSE-Push │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
|
||
┌─ claimNextJob() ───────────────────────────────────────────────┐
|
||
│ Kandidat suchen: pending AND (LockedAt IS NULL OR < -5min) │
|
||
│ Lock setzen: LockedAt=now, LockedByAgent=agentId │
|
||
│ Lazy Render: Layout + Vars → SVG → PNG → LabelImageData │
|
||
│ Rückgabe: Job + PNG (base64) │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
|
||
┌─ markPrinted() ────────────────────────────────────────────────┐
|
||
│ Status = printed, PrintedAt = now, PrintedByAgent, Printer │
|
||
│ POST LabelPrintedUrl (optional, Variablen aufgelöst) │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
|
||
┌─ markError() ──────────────────────────────────────────────────┐
|
||
│ Status = error, ErrorMessage │
|
||
│ POST LabelReleaseUrl (optional) │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Lock-TTL:** 5 Minuten. Stürzt ein Agent ab, kann ein anderer Agent (oder derselbe nach Neustart) den Job nach 5 Minuten erneut beanspruchen.
|
||
|
||
---
|
||
|
||
## 8. NestJS-Modul-Setup
|
||
|
||
```typescript
|
||
// label-print-agent.module.ts
|
||
@Module({
|
||
imports: [
|
||
TypeOrmModule.forFeature([LabelPrintJob, LabelTemplate]),
|
||
],
|
||
controllers: [LabelPrintAgentController],
|
||
providers: [LabelPrintAgentService, LabelRendererService],
|
||
})
|
||
export class LabelPrintAgentModule {}
|
||
```
|
||
|
||
---
|
||
|
||
## 9. Empfehlungen für das neue Projekt
|
||
|
||
### Architektur übernehmen
|
||
|
||
Das Muster **DB als Queue + SSE als Push + Polling als Fallback** ist einfach und robust. Es benötigt keine externe Message-Queue (Redis, RabbitMQ etc.) und funktioniert gut für geringe bis mittlere Job-Volumina.
|
||
|
||
### Locking-Strategie
|
||
|
||
| Szenario | Empfehlung |
|
||
|---|---|
|
||
| Ein Agent | Optimistic Lock (5-Min-TTL) reicht aus |
|
||
| Mehrere Agents, wenige Jobs | Optimistic Lock reicht aus |
|
||
| Viele Agents, hoher Durchsatz | DB-Row-Lock (`SELECT ... FOR UPDATE`) oder Redis-Lock |
|
||
|
||
### Rendering-Alternativen
|
||
|
||
| Umgebung | Alternative |
|
||
|---|---|
|
||
| Node.js | `@resvg/resvg-js` + `qrcode` (wie hier) |
|
||
| Node.js (Docker) | Puppeteer (HTML → PNG/PDF, braucht Chrome) |
|
||
| Python | `reportlab` oder `cairosvg` |
|
||
| Kein Node.js | Server-seitiges Rendering via separatem Microservice |
|
||
|
||
### SSE-Alternativen
|
||
|
||
| Alternative | Wann |
|
||
|---|---|
|
||
| SSE (wie hier) | Einfachste Lösung, funktioniert durch nginx |
|
||
| WebSocket | Wenn bidirektionale Kommunikation nötig ist |
|
||
| Polling-only | Wenn der Agent kein persistentes HTTP unterstützt |
|
||
|
||
### Template-Verwaltung im neuen Projekt
|
||
|
||
Im Paperless Manager sind Label-Templates Teil der `BarcodeTemplate`-Entity (historisch gewachsen). Für ein neues Projekt empfiehlt sich eine **eigenständige `LabelTemplate`-Entity** ohne nicht-Label-relevante Felder — das vereinfacht CRUD und API erheblich.
|
||
|
||
---
|
||
|
||
## 10. Quell-Dateien (Paperless Manager)
|
||
|
||
| Datei | Inhalt |
|
||
|---|---|
|
||
| `paperless-backend/src/label-print-agent/label-print-agent.service.ts` | Job-Lifecycle, Locking, Callbacks |
|
||
| `paperless-backend/src/label-print-agent/label-print-agent.controller.ts` | REST-Endpunkte, SSE |
|
||
| `paperless-backend/src/label-print-agent/label-renderer.service.ts` | SVG→PNG Rendering |
|
||
| `paperless-backend/src/database/entities/label-print-job.entity.ts` | Job-Entity |
|
||
| `paperless-backend/src/database/entities/barcode-template.entity.ts` | Template-Entity (inkl. `LabelElement`, `LabelInputField` Typen) |
|
||
| `paperless-frontend/src/api/labelPrintAgent.ts` | Frontend-API-Client |
|
||
|
||
---
|
||
|
||
## 11. Frontend-UI: Template-Editor (SettingsPage)
|
||
|
||
**Originaldatei:** `paperless-frontend/src/pages/SettingsPage.tsx`
|
||
|
||
Der Template-Editor ist ein **Modal-Formular** (720px breit) mit Ant Design. Er enthält zwei eigenständige React-Komponenten.
|
||
|
||
### Komponenten-Übersicht
|
||
|
||
| Komponente | Zweck |
|
||
|---|---|
|
||
| `BarcodeTemplatesTab` | Tabelle mit allen Templates + CRUD-Logik |
|
||
| `LabelElementRow` | Eine Zeile im Layout-Editor (typ-abhängige Felder) |
|
||
|
||
---
|
||
|
||
### BarcodeTemplatesTab
|
||
|
||
Verwaltet den gesamten Template-Lebenszyklus. State:
|
||
|
||
```typescript
|
||
const [data, setData] = useState<BarcodeTemplate[]>([]);
|
||
const [editing, setEditing] = useState<BarcodeTemplate | null>(null);
|
||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||
const [testPrinting, setTestPrinting] = useState(false);
|
||
const [form] = Form.useForm();
|
||
```
|
||
|
||
**Formular-Initialwerte** (beim Neu-Anlegen):
|
||
|
||
```typescript
|
||
form.setFieldsValue({
|
||
SplitBefore: false,
|
||
DateinameTemplate: '',
|
||
LabelEnabled: false,
|
||
LabelWidthMm: 57, // Standard: DYMO 57mm
|
||
LabelHeightMm: 32,
|
||
LabelInputFields: [],
|
||
LabelLayout: [],
|
||
});
|
||
```
|
||
|
||
**Testetikett-Funktion** (`handleTestLabel`):
|
||
|
||
```typescript
|
||
const handleTestLabel = async () => {
|
||
const values = await form.validateFields();
|
||
await barcodeTemplatesApi.update(editing.Id, values); // erst speichern
|
||
|
||
// Auto-Testwerte für alle Felder generieren
|
||
const testFieldValues: Record<string, string> = {};
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
for (const f of values.LabelInputFields ?? []) {
|
||
if (f.type === 'date') testFieldValues[f.name] = today;
|
||
else if (f.type === 'number') testFieldValues[f.name] = '1';
|
||
else testFieldValues[f.name] = 'Test';
|
||
}
|
||
|
||
const url = await labelPrintAgentApi.previewLabel(editing.Id, testFieldValues);
|
||
setPreviewUrl(url); // öffnet zweites Modal mit PNG
|
||
};
|
||
```
|
||
|
||
**Modal-Footer-Buttons:**
|
||
|
||
```
|
||
[Testetikett erstellen] [Abbrechen] [Speichern]
|
||
↑
|
||
Nur sichtbar wenn bestehende Vorlage (editing && !isNew)
|
||
```
|
||
|
||
---
|
||
|
||
### Formular-Aufbau (innerhalb des Modals)
|
||
|
||
```
|
||
┌─ Name ────────────────────────────────────────────────────────┐
|
||
│ Input: "z. B. Lager-Etikett" (required) │
|
||
└───────────────────────────────────────────────────────────────┘
|
||
┌─ Regex ───────────────────────────────────────────────────────┐
|
||
│ Input mit Regex-Validator │
|
||
│ Extra: "JavaScript-Syntax, z. B. ^\d{4}-\d{6}-\d{8}$" │
|
||
└───────────────────────────────────────────────────────────────┘
|
||
┌─ Regex testen ────────────────────────────────────────────────┐
|
||
│ Input (live) → Tag: [Treffer] / [Kein Treffer] / [Ungültig] │
|
||
└───────────────────────────────────────────────────────────────┘
|
||
┌─ Checkbox: "Vor diesem Barcode ein neues Dokument starten" ───┐
|
||
└───────────────────────────────────────────────────────────────┘
|
||
┌─ Belegname ───────────────────────────────────────────────────┐
|
||
│ Input: Platzhalter {barcode}, {datum}, {barcode.gruppe} │
|
||
└───────────────────────────────────────────────────────────────┘
|
||
|
||
────────────── Etikett ──────────────
|
||
|
||
┌─ Switch: "Etikett-Druck aktivieren" ─────────────────────────┐
|
||
└───────────────────────────────────────────────────────────────┘
|
||
|
||
[Nur sichtbar wenn Switch aktiv]
|
||
┌─ Breite (mm) │ Höhe (mm) ─────────────────────────────────┐
|
||
│ InputNumber │ InputNumber (Standard: 57 / 32) │
|
||
└──────────────────────────────────────────────────────────────┘
|
||
┌─ Eingabefelder ─────────────────────────────────────────────┐
|
||
│ Form.List: je Zeile: │
|
||
│ [Feldname Input] [Bezeichnung Input] [Typ-Select] [−] │
|
||
│ Typen: text | number | date │
|
||
│ [+ Feld hinzufügen] │
|
||
└──────────────────────────────────────────────────────────────┘
|
||
┌─ GET-URL ───────────────────────────────────────────────────┐
|
||
│ Extra zeigt verfügbare Platzhalter live aus Eingabefeldern │
|
||
└──────────────────────────────────────────────────────────────┘
|
||
┌─ PRINTED-URL ───────────────────────────────────────────────┐
|
||
│ Extra: Platzhalter + {number} │
|
||
└──────────────────────────────────────────────────────────────┘
|
||
┌─ RELEASE-URL ───────────────────────────────────────────────┐
|
||
│ Extra: Platzhalter + {number} │
|
||
└──────────────────────────────────────────────────────────────┘
|
||
┌─ Layout-Elemente ───────────────────────────────────────────┐
|
||
│ Extra: verfügbare Platzhalter │
|
||
│ Form.List → je Element: LabelElementRow-Komponente │
|
||
│ [+ Text] [+ QR-Code] [+ Linie] │
|
||
└──────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Platzhalter-Chips werden live berechnet:**
|
||
|
||
```typescript
|
||
// Aus den aktuellen LabelInputFields dynamisch erzeugt
|
||
const chips: string[] = [];
|
||
for (const f of inputFields) {
|
||
chips.push(`{${f.name}}`);
|
||
if (f.type === 'date') {
|
||
chips.push(`{${f.name}.year}`, `{${f.name}.month}`, `{${f.name}.day}`);
|
||
}
|
||
}
|
||
const chipsWithNumber = [...chips, '{number}'];
|
||
// → wird als `extra`-Text bei URL-Feldern und Layout-Elementen angezeigt
|
||
```
|
||
|
||
---
|
||
|
||
### LabelElementRow (je Layout-Element eine Card)
|
||
|
||
Typ wird per `Form.useWatch` reaktiv ausgelesen → zeigt typ-spezifische Felder:
|
||
|
||
```typescript
|
||
const type = Form.useWatch(['LabelLayout', listName, 'type'], form);
|
||
```
|
||
|
||
**Typ: Text**
|
||
|
||
```
|
||
[X mm] [Y mm] [Schrift (mm)] [Max. Breite (mm)] [Fett ☐] [Ausrichtung ▼]
|
||
[Inhalt: Input mit Platzhalter-Hinweis "{nummer} oder {datum}"]
|
||
```
|
||
|
||
Default beim Hinzufügen: `{ type: 'text', x: 0, y: 0, fontSize: 3, content: '', bold: false }`
|
||
|
||
**Typ: QR-Code**
|
||
|
||
```
|
||
[X mm] [Y mm] [Größe (mm)] [Inhalt: Input mit "{number}"]
|
||
```
|
||
|
||
Default: `{ type: 'qr', x: 0, y: 0, sizeMm: 20, content: '' }`
|
||
|
||
**Typ: Linie**
|
||
|
||
```
|
||
[X1 mm] [Y1 mm] [X2 mm] [Y2 mm] [Stärke (mm, step: 0.1)]
|
||
```
|
||
|
||
Default: `{ type: 'line', x1: 0, y1: 0, x2: 50, y2: 0 }`
|
||
|
||
Jede Card hat rechts oben einen Löschen-Button (`DeleteOutlined`).
|
||
|
||
---
|
||
|
||
### Vorschau-Modal
|
||
|
||
Nach „Testetikett erstellen" öffnet sich ein zweites Modal (520px) mit dem gerenderten PNG:
|
||
|
||
```tsx
|
||
<Modal title="Etikett-Vorschau" width={520}>
|
||
<div style={{ background: '#e0e0e0', padding: 24, display: 'flex', justifyContent: 'center' }}>
|
||
<img src={previewUrl} style={{ maxWidth: '100%', boxShadow: '0 2px 8px rgba(0,0,0,0.3)' }} />
|
||
</div>
|
||
</Modal>
|
||
```
|
||
|
||
Das `previewUrl` ist ein `URL.createObjectURL(blob)` — muss mit `URL.revokeObjectURL()` beim Schließen freigegeben werden.
|
||
|
||
---
|
||
|
||
## 12. Frontend-UI: Druck-Dialog (InboxPage)
|
||
|
||
**Originaldatei:** `paperless-frontend/src/pages/InboxPage.tsx`
|
||
|
||
### Einstiegspunkt
|
||
|
||
Button in der Toolbar der InboxPage:
|
||
|
||
```tsx
|
||
<Button icon={<PrinterOutlined />} onClick={openPrintDialog}>
|
||
Etikett drucken
|
||
</Button>
|
||
```
|
||
|
||
### openPrintDialog
|
||
|
||
Lädt beim Öffnen parallel Templates und User-Settings (für Standard-Template):
|
||
|
||
```typescript
|
||
const openPrintDialog = async () => {
|
||
const [all, settings] = await Promise.all([
|
||
barcodeTemplatesApi.list(),
|
||
userSettingsApi.get(),
|
||
]);
|
||
const enabled = all.filter((t) => t.LabelEnabled); // nur aktive Templates
|
||
setLabelTemplates(enabled);
|
||
|
||
// Standard-Template aus User-Settings vorauswählen
|
||
const defaultId = settings.defaultLabelTemplateId;
|
||
const defaultTemplate = defaultId != null
|
||
? (enabled.find((t) => t.Id === defaultId) ?? null)
|
||
: null;
|
||
setSelectedTemplate(defaultTemplate);
|
||
setFieldValues(buildInitialFieldValues(defaultTemplate));
|
||
setLabelCount(1);
|
||
setPrintDialogOpen(true);
|
||
};
|
||
```
|
||
|
||
### Datum-Vorausfüllung
|
||
|
||
```typescript
|
||
function buildInitialFieldValues(template: BarcodeTemplate | null): Record<string, string> {
|
||
const today = dayjs().format('YYYY-MM-DD');
|
||
const values: Record<string, string> = {};
|
||
for (const field of template?.LabelInputFields ?? []) {
|
||
if (field.type === 'date') values[field.name] = today;
|
||
// text- und number-Felder bleiben leer
|
||
}
|
||
return values;
|
||
}
|
||
```
|
||
|
||
### Druck-Modal-Aufbau
|
||
|
||
```
|
||
┌─ Eingangsdokumentart ─────────────────────────────────────────┐
|
||
│ Select → nur Templates mit LabelEnabled=true │
|
||
└───────────────────────────────────────────────────────────────┘
|
||
┌─ Anzahl Etiketten ────────────────────────────────────────────┐
|
||
│ InputNumber (min: 1, max: 100, default: 1) │
|
||
└───────────────────────────────────────────────────────────────┘
|
||
┌─ [dynamisch: LabelInputFields des gewählten Templates] ────────┐
|
||
│ date → DatePicker (Format: DD.MM.YYYY, Wert: YYYY-MM-DD) │
|
||
│ number → InputNumber │
|
||
│ text → Input │
|
||
└───────────────────────────────────────────────────────────────┘
|
||
|
||
Footer: [Abbrechen] [Drucken] ← disabled solange kein Template
|
||
```
|
||
|
||
### Batch-Druck
|
||
|
||
```typescript
|
||
const handlePrint = async () => {
|
||
setPrinting(true);
|
||
for (let i = 0; i < labelCount; i++) {
|
||
await labelPrintAgentApi.createJob(selectedTemplate.Id, fieldValues);
|
||
// Erzeugt N separate Jobs sequenziell (selbe fieldValues)
|
||
}
|
||
message.success(labelCount === 1 ? 'Druckauftrag erstellt' : `${labelCount} Druckaufträge erstellt`);
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## 13. Frontend API-Client
|
||
|
||
**Originaldatei:** `paperless-frontend/src/api/labelPrintAgent.ts`
|
||
|
||
```typescript
|
||
export const labelPrintAgentApi = {
|
||
// Job erstellen (von InboxPage)
|
||
createJob: (templateId: number, fieldValues: Record<string, string>) =>
|
||
api.post('/api/label-print-agent/jobs', { templateId, fieldValues }),
|
||
|
||
// Vorschau-PNG als Object-URL (von SettingsPage)
|
||
previewLabel: async (templateId: number, fieldValues: Record<string, string>): Promise<string> => {
|
||
const res = await api.post('/api/label-print-agent/preview',
|
||
{ templateId, fieldValues },
|
||
{ responseType: 'blob' },
|
||
);
|
||
return URL.createObjectURL(res.data); // muss nach Verwendung revoked werden
|
||
},
|
||
};
|
||
```
|