chore: apply ESLint auto-fix across entire backend
Build and Push Multi-Platform Images / build-and-push (push) Successful in 41s
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>
This commit is contained in:
@@ -0,0 +1,988 @@
|
||||
# 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
|
||||
},
|
||||
};
|
||||
```
|
||||
Reference in New Issue
Block a user