feat: add SSE event stream for print jobs, implement batch printing in frontend, and update API documentation.
Build and Push Multi-Platform Images / build-and-push (push) Successful in 36s

This commit is contained in:
2026-05-09 09:04:20 +02:00
parent 3683fe9487
commit f4428afb9b
4 changed files with 181 additions and 100 deletions
+133 -95
View File
@@ -1,40 +1,94 @@
# Backend API für LabelPrintAgent # Backend API LabelPrintAgent
Diese Datei beschreibt die Endpunkte, die der PaperlessManager bereitstellen muss, damit der LabelPrintAgent Etiketten abholen, drucken und das Ergebnis zurückmelden kann. Diese Datei beschreibt die Endpunkte des PaperlessManager-Backends für den LabelPrintAgent.
Der LabelPrintAgent rendert keine Layouts selbst. Das Backend liefert ein fertiges Etikettbild. Das Backend rendert das fertige Etikettbild (SVG → PNG via resvg-js). Der Agent ist nur lokaler Druck-Connector.
## Authentifizierung ## Authentifizierung
Alle Endpunkte sollten denselben Bearer Token akzeptieren: Alle Endpunkte erfordern einen Bearer Token (JWT oder API-Key):
```http ```http
Authorization: Bearer {apiToken} Authorization: Bearer {token}
``` ```
Der Token wird im Agent lokal verschlüsselt gespeichert. `/jobs/next`, `/jobs/:id/image`, `/jobs/:id/printed` und `/jobs/:id/error` benötigen **keine** spezifische Permission (nur gültigen Token). `POST /jobs` und `POST /preview` erfordern `VIEW_SCANNER`.
## 1. Nächsten Druckjob abrufen ---
## 1. Job manuell anlegen (Frontend → Backend)
```http
POST /api/label-print-agent/jobs
Content-Type: application/json
```
### Request Body
```json
{
"templateId": 3,
"fieldValues": {
"datum": "2026-05-09"
}
}
```
| Feld | Pflicht | Beschreibung |
|---|---|---|
| `templateId` | ja | ID des BarcodeTemplates |
| `fieldValues` | nein | Feldwerte für Platzhalter; Datumsfelder im Format `YYYY-MM-DD` |
### Antwort
```http
201 Created
```
```json
{ "jobId": "42" }
```
---
## 2. Vorschau-Bild rendern (kein Job, keine Nummer-Reservierung)
```http
POST /api/label-print-agent/preview
Content-Type: application/json
```
### Request Body
Identisch mit `POST /jobs`. `{number}` wird immer als `1` gerendert — die GET-URL wird **nicht** aufgerufen.
### Antwort
```http
200 OK
Content-Type: image/png
```
Body: binäres PNG-Bild.
---
## 3. Nächsten Druckjob abrufen (Agent-Polling)
```http ```http
GET /api/label-print-agent/jobs/next?agentId={agentId} GET /api/label-print-agent/jobs/next?agentId={agentId}
``` ```
Der Agent ruft diesen Endpunkt alle X Sekunden auf. | Parameter | Pflicht | Beschreibung |
|---|---|---|
| `agentId` | nein | Eindeutige Agent-ID (z. B. Rechnername); Fallback: `"unknown"` |
### Query-Parameter ### Antwort kein Job vorhanden
| Name | Pflicht | Beschreibung |
| --- | --- | --- |
| `agentId` | ja | Eindeutige ID des Agents, z. B. Rechnername |
### Antwort, wenn kein Job vorhanden ist
```http ```http
204 No Content 204 No Content
``` ```
### Antwort mit Bild direkt im JSON ### Antwort Job vorhanden
```http ```http
200 OK 200 OK
@@ -43,7 +97,7 @@ Content-Type: application/json
```json ```json
{ {
"jobId": "12345", "jobId": "42",
"labelImageBase64": "iVBORw0KGgoAAAANSUhEUgAA...", "labelImageBase64": "iVBORw0KGgoAAAANSUhEUgAA...",
"labelImageContentType": "image/png", "labelImageContentType": "image/png",
"labelWidthMm": 57, "labelWidthMm": 57,
@@ -51,34 +105,20 @@ Content-Type: application/json
} }
``` ```
### Antwort mit separater Bild-URL | Feld | Beschreibung |
|---|---|
| `jobId` | Job-ID für Rückmeldungen |
| `labelImageBase64` | Base64-PNG; `null` wenn Rendering fehlgeschlagen |
| `labelImageContentType` | Immer `"image/png"` |
| `labelWidthMm` / `labelHeightMm` | Etikettenmaß in mm |
```json Das Backend setzt beim Ausliefern einen Lock (5-Minuten-TTL). Jobs mit abgelaufenem Lock werden erneut angeboten.
{
"jobId": "12345",
"labelImageUrl": "/api/label-print-agent/jobs/12345/image",
"labelImageContentType": "image/png",
"labelWidthMm": 57,
"labelHeightMm": 32
}
```
### Felder ---
| Feld | Pflicht | Beschreibung | ## 4. Etikettbild separat abrufen
| --- | --- | --- |
| `jobId` | ja | Eindeutige Job-ID für Rückmeldungen |
| `labelImageBase64` | bedingt | Base64-kodiertes Etikettbild |
| `labelImageUrl` | bedingt | URL zum Nachladen des Etikettbilds |
| `labelImageContentType` | empfohlen | z. B. `image/png` |
| `labelWidthMm` | ja | Etikettenbreite in mm, z. B. `57` |
| `labelHeightMm` | ja | Etikettenhöhe in mm, z. B. `32` |
`labelImageBase64` oder `labelImageUrl` muss gesetzt sein. Alternativ zum Base64-Feld in `jobs/next`.
## 2. Etikettbild nachladen
Nur erforderlich, wenn `labelImageUrl` verwendet wird.
```http ```http
GET /api/label-print-agent/jobs/{jobId}/image GET /api/label-print-agent/jobs/{jobId}/image
@@ -91,11 +131,15 @@ GET /api/label-print-agent/jobs/{jobId}/image
Content-Type: image/png Content-Type: image/png
``` ```
Body: Binärdaten des fertigen Etikettbilds. Body: binäres PNG-Bild.
Empfehlung: PNG, schwarz/weiß, passend zum Etikettenformat, z. B. 57 x 32 mm bei 300 dpi. ```http
404 Not Found Job oder Bild nicht vorhanden
```
## 3. Erfolgreichen Druck melden ---
## 5. Erfolgreichen Druck melden
```http ```http
POST /api/label-print-agent/jobs/{jobId}/printed POST /api/label-print-agent/jobs/{jobId}/printed
@@ -111,21 +155,22 @@ Content-Type: application/json
} }
``` ```
Alle Felder optional; Fallback jeweils `""` / `"unknown"`.
### Antwort ### Antwort
```http ```http
200 OK 200 OK
``` ```
```json
oder: { "ok": true }
```http
204 No Content
``` ```
Das Backend sollte den Job erst hier endgültig als gedruckt markieren. Das Backend setzt den Job auf `printed`, speichert Zeitstempel und ruft die konfigurierte `LabelPrintedUrl` des Templates auf (`POST`).
## 4. Fehler melden ---
## 6. Druckfehler melden
```http ```http
POST /api/label-print-agent/jobs/{jobId}/error POST /api/label-print-agent/jobs/{jobId}/error
@@ -138,7 +183,7 @@ Content-Type: application/json
{ {
"agentId": "PC-BUERO", "agentId": "PC-BUERO",
"printerName": "DYMO LabelWriter 450", "printerName": "DYMO LabelWriter 450",
"errorMessage": "Drucker ist nicht verfügbar." "errorMessage": "Drucker nicht verfügbar."
} }
``` ```
@@ -147,64 +192,57 @@ Content-Type: application/json
```http ```http
200 OK 200 OK
``` ```
```json
oder: { "ok": true }
```http
204 No Content
``` ```
Das Backend entscheidet danach, ob der Job erneut angeboten wird oder auf Fehler bleibt. Das Backend setzt den Job auf `error` und ruft die konfigurierte `LabelReleaseUrl` des Templates auf (`POST`).
## Backend-Verhalten ---
Empfohlener Ablauf im Backend: ## Job-Lebenszyklus
1. Job erstellen und serverseitig Layout, Nummern, QR-Code und Bild erzeugen. ```
2. Job bleibt wartend, bis ein Agent ihn abholt. createJob()
3. `jobs/next` liefert jeweils höchstens einen Job.
4. Backend reserviert oder lockt den Job beim Ausliefern, damit zwei Agents ihn nicht parallel drucken.
5. Agent druckt lokal. pending ──── jobs/next ──── Lock (5 Min TTL)
6. Agent meldet `printed` oder `error`.
7. Backend setzt den finalen Status. Agent druckt
┌───────┴───────┐
printed error
(PrintedUrl) (ReleaseUrl)
```
## Empfohlene Statuscodes ## 7. Server-Sent Events neue Druckaufträge (Push)
| Situation | Status |
| --- | --- |
| Kein Job vorhanden | `204 No Content` |
| Job vorhanden | `200 OK` |
| Token fehlt/ungültig | `401 Unauthorized` |
| Agent darf nicht drucken | `403 Forbidden` |
| Job-ID unbekannt | `404 Not Found` |
| Backend-Fehler | `500 Internal Server Error` |
## Server-Sent Events optional
Später kann das Backend zusätzlich einen Event-Endpunkt anbieten:
```http ```http
GET /api/label-print-agent/events?agentId={agentId} GET /api/label-print-agent/events
Authorization: Bearer {token}
Accept: text/event-stream Accept: text/event-stream
``` ```
Beispiel: Der Agent verbindet sich einmalig. Sobald ein neuer Druckauftrag erstellt wird, sendet das Backend:
```text ```
event: label-job-available data: {"type":"label-job-available"}
data: {"count":1}
``` ```
Der Agent könnte dann bei einem Event sofort `jobs/next` aufrufen. Polling bleibt trotzdem als Fallback sinnvoll. Der Agent ruft daraufhin sofort `GET /jobs/next` auf. Polling bleibt als Fallback sinnvoll (z. B. alle 30 s), falls die SSE-Verbindung unterbrochen wurde.
## Wichtige Designentscheidung Es werden keine agentId-Parameter ausgewertet alle verbundenen Agents erhalten das Event.
Der Agent kennt keine fachlichen Layouts mehr: ---
- keine `layout_key` ## Statuscodes
- keine lokalen LabelTemplates
- keine MySQL-Verbindung
- keine Nummernreservierung
- kein QR-Code-Rendering
Das Backend liefert ein fertiges Bild. Der Agent ist nur noch lokaler Windows-Druck-Connector. | Situation | Status |
|---|---|
| Kein Job vorhanden | `204 No Content` |
| Job / Bild vorhanden | `200 OK` |
| Job erstellt | `201 Created` |
| Token fehlt / ungültig | `401 Unauthorized` |
| Fehlende Permission | `403 Forbidden` |
| Job-ID unbekannt | `404 Not Found` |
| Backend-Fehler | `500 Internal Server Error` |
@@ -4,16 +4,20 @@ import {
Get, Get,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
MessageEvent,
NotFoundException, NotFoundException,
Param, Param,
ParseIntPipe, ParseIntPipe,
Post, Post,
Query, Query,
Res, Res,
Sse,
StreamableFile, StreamableFile,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import type { Response } from 'express'; import type { Response } from 'express';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { JwtOrApiKeyGuard } from '../auth/jwt-or-apikey.guard'; import { JwtOrApiKeyGuard } from '../auth/jwt-or-apikey.guard';
import { RequirePermissions } from '../auth/permissions.decorator'; import { RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum'; import { Permission } from '../auth/permissions.enum';
@@ -48,6 +52,14 @@ export class LabelPrintAgentController {
return { jobId: String(job.Id) }; return { jobId: String(job.Id) };
} }
// Agent: SSE-Stream für neue Druckaufträge
@Sse('events')
sseEvents(): Observable<MessageEvent> {
return this.service.newJob$.pipe(
map(() => ({ data: { type: 'label-job-available' } } as MessageEvent)),
);
}
// Agent: nächsten Job abholen (Polling) // Agent: nächsten Job abholen (Polling)
@Get('jobs/next') @Get('jobs/next')
async getNextJob(@Query('agentId') agentId: string, @Res({ passthrough: true }) res: Response) { async getNextJob(@Query('agentId') agentId: string, @Res({ passthrough: true }) res: Response) {
@@ -1,6 +1,7 @@
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, LessThan, IsNull, Or } from 'typeorm'; import { Repository, LessThan, IsNull } from 'typeorm';
import { Subject, Observable } from 'rxjs';
import { LabelPrintJob } from '../database/entities/label-print-job.entity'; import { LabelPrintJob } from '../database/entities/label-print-job.entity';
import { BarcodeTemplate } from '../database/entities/barcode-template.entity'; import { BarcodeTemplate } from '../database/entities/barcode-template.entity';
import { LabelRendererService } from './label-renderer.service'; import { LabelRendererService } from './label-renderer.service';
@@ -28,6 +29,11 @@ function lockExpiry(): Date {
@Injectable() @Injectable()
export class LabelPrintAgentService { export class LabelPrintAgentService {
private readonly logger = new Logger(LabelPrintAgentService.name); private readonly logger = new Logger(LabelPrintAgentService.name);
private readonly jobCreated$ = new Subject<void>();
get newJob$(): Observable<void> {
return this.jobCreated$.asObservable();
}
constructor( constructor(
@InjectRepository(LabelPrintJob) @InjectRepository(LabelPrintJob)
@@ -85,7 +91,9 @@ export class LabelPrintAgentService {
LockedByAgent: null, LockedByAgent: null,
}); });
return this.jobRepo.save(job); const saved = await this.jobRepo.save(job);
this.jobCreated$.next();
return saved;
} }
async claimNextJob(agentId: string): Promise<LabelPrintJob | null> { async claimNextJob(agentId: string): Promise<LabelPrintJob | null> {
+26 -3
View File
@@ -125,6 +125,15 @@ function DocumentPreviewPopover({ record, children }: { record: InboxFile; child
); );
} }
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;
}
return values;
}
export default function InboxPage() { export default function InboxPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [files, setFiles] = useState<InboxFile[]>([]); const [files, setFiles] = useState<InboxFile[]>([]);
@@ -137,6 +146,7 @@ export default function InboxPage() {
const [selectedTemplate, setSelectedTemplate] = useState<BarcodeTemplate | null>(null); const [selectedTemplate, setSelectedTemplate] = useState<BarcodeTemplate | null>(null);
const [fieldValues, setFieldValues] = useState<Record<string, string>>({}); const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
const [printing, setPrinting] = useState(false); const [printing, setPrinting] = useState(false);
const [labelCount, setLabelCount] = useState(1);
const openPrintDialog = async () => { const openPrintDialog = async () => {
try { try {
@@ -148,21 +158,24 @@ export default function InboxPage() {
} }
setSelectedTemplate(null); setSelectedTemplate(null);
setFieldValues({}); setFieldValues({});
setLabelCount(1);
setPrintDialogOpen(true); setPrintDialogOpen(true);
}; };
const handleTemplateSelect = (id: number) => { const handleTemplateSelect = (id: number) => {
const t = labelTemplates.find((t) => t.Id === id) ?? null; const t = labelTemplates.find((t) => t.Id === id) ?? null;
setSelectedTemplate(t); setSelectedTemplate(t);
setFieldValues({}); setFieldValues(buildInitialFieldValues(t));
}; };
const handlePrint = async () => { const handlePrint = async () => {
if (!selectedTemplate) return; if (!selectedTemplate) return;
setPrinting(true); setPrinting(true);
try { try {
await labelPrintAgentApi.createJob(selectedTemplate.Id, fieldValues); for (let i = 0; i < labelCount; i++) {
message.success('Druckauftrag erstellt'); await labelPrintAgentApi.createJob(selectedTemplate.Id, fieldValues);
}
message.success(labelCount === 1 ? 'Druckauftrag erstellt' : `${labelCount} Druckaufträge erstellt`);
setPrintDialogOpen(false); setPrintDialogOpen(false);
} catch (err: any) { } catch (err: any) {
message.error(err?.response?.data?.message ?? 'Druckauftrag fehlgeschlagen'); message.error(err?.response?.data?.message ?? 'Druckauftrag fehlgeschlagen');
@@ -406,6 +419,16 @@ export default function InboxPage() {
/> />
</Form.Item> </Form.Item>
<Form.Item label="Anzahl Etiketten">
<InputNumber
min={1}
max={100}
value={labelCount}
onChange={(v) => setLabelCount(v ?? 1)}
style={{ width: '100%' }}
/>
</Form.Item>
{selectedTemplate?.LabelInputFields?.map((field) => ( {selectedTemplate?.LabelInputFields?.map((field) => (
<Form.Item key={field.name} label={field.label || field.name}> <Form.Item key={field.name} label={field.label || field.name}>
{field.type === 'date' ? ( {field.type === 'date' ? (