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
Build and Push Multi-Platform Images / build-and-push (push) Successful in 36s
This commit is contained in:
+133
-95
@@ -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
|
||||
|
||||
Alle Endpunkte sollten denselben Bearer Token akzeptieren:
|
||||
Alle Endpunkte erfordern einen Bearer Token (JWT oder API-Key):
|
||||
|
||||
```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
|
||||
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
|
||||
|
||||
| Name | Pflicht | Beschreibung |
|
||||
| --- | --- | --- |
|
||||
| `agentId` | ja | Eindeutige ID des Agents, z. B. Rechnername |
|
||||
|
||||
### Antwort, wenn kein Job vorhanden ist
|
||||
### Antwort – kein Job vorhanden
|
||||
|
||||
```http
|
||||
204 No Content
|
||||
```
|
||||
|
||||
### Antwort mit Bild direkt im JSON
|
||||
### Antwort – Job vorhanden
|
||||
|
||||
```http
|
||||
200 OK
|
||||
@@ -43,7 +97,7 @@ Content-Type: application/json
|
||||
|
||||
```json
|
||||
{
|
||||
"jobId": "12345",
|
||||
"jobId": "42",
|
||||
"labelImageBase64": "iVBORw0KGgoAAAANSUhEUgAA...",
|
||||
"labelImageContentType": "image/png",
|
||||
"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
|
||||
{
|
||||
"jobId": "12345",
|
||||
"labelImageUrl": "/api/label-print-agent/jobs/12345/image",
|
||||
"labelImageContentType": "image/png",
|
||||
"labelWidthMm": 57,
|
||||
"labelHeightMm": 32
|
||||
}
|
||||
```
|
||||
Das Backend setzt beim Ausliefern einen Lock (5-Minuten-TTL). Jobs mit abgelaufenem Lock werden erneut angeboten.
|
||||
|
||||
### Felder
|
||||
---
|
||||
|
||||
| Feld | Pflicht | Beschreibung |
|
||||
| --- | --- | --- |
|
||||
| `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` |
|
||||
## 4. Etikettbild separat abrufen
|
||||
|
||||
`labelImageBase64` oder `labelImageUrl` muss gesetzt sein.
|
||||
|
||||
## 2. Etikettbild nachladen
|
||||
|
||||
Nur erforderlich, wenn `labelImageUrl` verwendet wird.
|
||||
Alternativ zum Base64-Feld in `jobs/next`.
|
||||
|
||||
```http
|
||||
GET /api/label-print-agent/jobs/{jobId}/image
|
||||
@@ -91,11 +131,15 @@ GET /api/label-print-agent/jobs/{jobId}/image
|
||||
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
|
||||
POST /api/label-print-agent/jobs/{jobId}/printed
|
||||
@@ -111,21 +155,22 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
Alle Felder optional; Fallback jeweils `""` / `"unknown"`.
|
||||
|
||||
### Antwort
|
||||
|
||||
```http
|
||||
200 OK
|
||||
```
|
||||
|
||||
oder:
|
||||
|
||||
```http
|
||||
204 No Content
|
||||
```json
|
||||
{ "ok": true }
|
||||
```
|
||||
|
||||
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
|
||||
POST /api/label-print-agent/jobs/{jobId}/error
|
||||
@@ -138,7 +183,7 @@ Content-Type: application/json
|
||||
{
|
||||
"agentId": "PC-BUERO",
|
||||
"printerName": "DYMO LabelWriter 450",
|
||||
"errorMessage": "Drucker ist nicht verfügbar."
|
||||
"errorMessage": "Drucker nicht verfügbar."
|
||||
}
|
||||
```
|
||||
|
||||
@@ -147,64 +192,57 @@ Content-Type: application/json
|
||||
```http
|
||||
200 OK
|
||||
```
|
||||
|
||||
oder:
|
||||
|
||||
```http
|
||||
204 No Content
|
||||
```json
|
||||
{ "ok": true }
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
6. Agent meldet `printed` oder `error`.
|
||||
7. Backend setzt den finalen Status.
|
||||
```
|
||||
createJob()
|
||||
│
|
||||
▼
|
||||
pending ──── jobs/next ──── Lock (5 Min TTL)
|
||||
│
|
||||
Agent druckt
|
||||
│
|
||||
┌───────┴───────┐
|
||||
printed error
|
||||
(PrintedUrl) (ReleaseUrl)
|
||||
```
|
||||
|
||||
## Empfohlene Statuscodes
|
||||
|
||||
| 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:
|
||||
## 7. Server-Sent Events – neue Druckaufträge (Push)
|
||||
|
||||
```http
|
||||
GET /api/label-print-agent/events?agentId={agentId}
|
||||
GET /api/label-print-agent/events
|
||||
Authorization: Bearer {token}
|
||||
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: {"count":1}
|
||||
```
|
||||
data: {"type":"label-job-available"}
|
||||
```
|
||||
|
||||
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`
|
||||
- keine lokalen LabelTemplates
|
||||
- keine MySQL-Verbindung
|
||||
- keine Nummernreservierung
|
||||
- kein QR-Code-Rendering
|
||||
## Statuscodes
|
||||
|
||||
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,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
MessageEvent,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
Sse,
|
||||
StreamableFile,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { JwtOrApiKeyGuard } from '../auth/jwt-or-apikey.guard';
|
||||
import { RequirePermissions } from '../auth/permissions.decorator';
|
||||
import { Permission } from '../auth/permissions.enum';
|
||||
@@ -48,6 +52,14 @@ export class LabelPrintAgentController {
|
||||
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)
|
||||
@Get('jobs/next')
|
||||
async getNextJob(@Query('agentId') agentId: string, @Res({ passthrough: true }) res: Response) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
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 { BarcodeTemplate } from '../database/entities/barcode-template.entity';
|
||||
import { LabelRendererService } from './label-renderer.service';
|
||||
@@ -28,6 +29,11 @@ function lockExpiry(): Date {
|
||||
@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)
|
||||
@@ -85,7 +91,9 @@ export class LabelPrintAgentService {
|
||||
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> {
|
||||
|
||||
@@ -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() {
|
||||
const navigate = useNavigate();
|
||||
const [files, setFiles] = useState<InboxFile[]>([]);
|
||||
@@ -137,6 +146,7 @@ export default function InboxPage() {
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<BarcodeTemplate | null>(null);
|
||||
const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
|
||||
const [printing, setPrinting] = useState(false);
|
||||
const [labelCount, setLabelCount] = useState(1);
|
||||
|
||||
const openPrintDialog = async () => {
|
||||
try {
|
||||
@@ -148,21 +158,24 @@ export default function InboxPage() {
|
||||
}
|
||||
setSelectedTemplate(null);
|
||||
setFieldValues({});
|
||||
setLabelCount(1);
|
||||
setPrintDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleTemplateSelect = (id: number) => {
|
||||
const t = labelTemplates.find((t) => t.Id === id) ?? null;
|
||||
setSelectedTemplate(t);
|
||||
setFieldValues({});
|
||||
setFieldValues(buildInitialFieldValues(t));
|
||||
};
|
||||
|
||||
const handlePrint = async () => {
|
||||
if (!selectedTemplate) return;
|
||||
setPrinting(true);
|
||||
try {
|
||||
await labelPrintAgentApi.createJob(selectedTemplate.Id, fieldValues);
|
||||
message.success('Druckauftrag erstellt');
|
||||
for (let i = 0; i < labelCount; i++) {
|
||||
await labelPrintAgentApi.createJob(selectedTemplate.Id, fieldValues);
|
||||
}
|
||||
message.success(labelCount === 1 ? 'Druckauftrag erstellt' : `${labelCount} Druckaufträge erstellt`);
|
||||
setPrintDialogOpen(false);
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.message ?? 'Druckauftrag fehlgeschlagen');
|
||||
@@ -406,6 +419,16 @@ export default function InboxPage() {
|
||||
/>
|
||||
</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) => (
|
||||
<Form.Item key={field.name} label={field.label || field.name}>
|
||||
{field.type === 'date' ? (
|
||||
|
||||
Reference in New Issue
Block a user