Simplify agent to backend-driven printing
This commit is contained in:
@@ -1,143 +1,149 @@
|
||||
# LabelPrintAgent
|
||||
|
||||
Windows-Tray-Anwendung zum Rendern und Drucken von Etiketten über installierte Windows-Drucker, z. B. einen Dymo LabelWriter.
|
||||
Windows-Tray-Connector für den PaperlessManager.
|
||||
|
||||
## Aktueller Stand
|
||||
Der Agent speichert keine Layouts, keine Nummernserver-Regeln und keine MySQL-Queue-Logik mehr. Das Backend entscheidet, welche Etiketten gedruckt werden, rendert bzw. liefert das fertige Etikett als Bild, und der Agent druckt dieses Bild über einen installierten Windows-Drucker.
|
||||
|
||||
Der Agent arbeitet jetzt mit lokalen `LabelTemplates` in `C:\ProgramData\LabelPrintAgent\settings.json`.
|
||||
|
||||
Ein Queue-Job enthält nur noch:
|
||||
|
||||
- `barcode_template_id`
|
||||
- `payload_json`
|
||||
|
||||
Die alte Zuordnung über `layout_key` gibt es nicht mehr. Stattdessen gilt:
|
||||
## Ablauf
|
||||
|
||||
```text
|
||||
label_print_queue.barcode_template_id
|
||||
-> lokales LabelTemplate im LabelPrintAgent
|
||||
-> darin enthaltenes Layout rendern
|
||||
LabelPrintAgent
|
||||
-> fragt alle X Sekunden PaperlessManager-Backend
|
||||
-> erhält einen druckfertigen Etikettenjob
|
||||
-> lädt/liest das Etikettbild
|
||||
-> druckt über Windows-Drucker
|
||||
-> meldet Erfolg oder Fehler ans Backend zurück
|
||||
```
|
||||
|
||||
Die Tabelle `barcode_templates` wird nicht verändert. Sie bleibt nur die fachliche Referenz für die `barcode_template_id`.
|
||||
Optional kann später statt Polling Server-Sent Events ergänzt werden. Der aktuelle Stand nutzt bewusst Polling, weil es robuster und einfacher zu betreiben ist.
|
||||
|
||||
## LabelTemplates
|
||||
## Backend-Vertrag
|
||||
|
||||
Ein lokales LabelTemplate enthält:
|
||||
Der Agent fragt standardmäßig:
|
||||
|
||||
- `barcodeTemplateId`
|
||||
- `name`
|
||||
- `getNumberUrl`
|
||||
- `numberPrintedUrl`
|
||||
- `reservedNumberPayloadKey`
|
||||
- `qrTemplate`
|
||||
- `layout`
|
||||
```http
|
||||
GET /api/label-print-agent/jobs/next?agentId={agentId}
|
||||
Authorization: Bearer {apiToken}
|
||||
```
|
||||
|
||||
Das Layout liegt vollständig eingebettet im Template. Es gibt keine separate Layout-Datei und keinen `layoutKey` mehr.
|
||||
Wenn nichts zu drucken ist:
|
||||
|
||||
## Nummernserver
|
||||
```http
|
||||
204 No Content
|
||||
```
|
||||
|
||||
`getNumberUrl` wird per HTTP GET aufgerufen und gibt Plain Text zurück, z. B.:
|
||||
Wenn ein Etikett vorhanden ist:
|
||||
|
||||
```json
|
||||
{
|
||||
"jobId": "12345",
|
||||
"labelImageBase64": "...",
|
||||
"labelImageContentType": "image/png",
|
||||
"labelWidthMm": 57,
|
||||
"labelHeightMm": 32
|
||||
}
|
||||
```
|
||||
|
||||
Alternativ darf das Backend statt `labelImageBase64` eine URL liefern:
|
||||
|
||||
```json
|
||||
{
|
||||
"jobId": "12345",
|
||||
"labelImageUrl": "/api/label-print-agent/jobs/12345/image",
|
||||
"labelImageContentType": "image/png",
|
||||
"labelWidthMm": 57,
|
||||
"labelHeightMm": 32
|
||||
}
|
||||
```
|
||||
|
||||
Der Agent meldet Erfolg:
|
||||
|
||||
```http
|
||||
POST /api/label-print-agent/jobs/{jobId}/printed
|
||||
Authorization: Bearer {apiToken}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"agentId": "PC-BUERO",
|
||||
"printerName": "DYMO LabelWriter 450"
|
||||
}
|
||||
```
|
||||
|
||||
Der Agent meldet Fehler:
|
||||
|
||||
```http
|
||||
POST /api/label-print-agent/jobs/{jobId}/error
|
||||
Authorization: Bearer {apiToken}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"agentId": "PC-BUERO",
|
||||
"printerName": "DYMO LabelWriter 450",
|
||||
"errorMessage": "Drucker ist nicht verfügbar."
|
||||
}
|
||||
```
|
||||
|
||||
## Lokale Einstellungen
|
||||
|
||||
Die Einstellungen liegen unter:
|
||||
|
||||
```text
|
||||
123
|
||||
C:\ProgramData\LabelPrintAgent\settings.json
|
||||
```
|
||||
|
||||
Der Nummernserver liefert keine führenden Nullen. Die Datenbank speichert `reserved_number` als `BIGINT`, also z. B. `123`.
|
||||
|
||||
Führende Nullen entstehen nur über Formatierung im Template:
|
||||
|
||||
```text
|
||||
{reservedNumber:0000000} -> 0000123
|
||||
{nummer:0000000} -> 0000123
|
||||
```
|
||||
|
||||
Nach erfolgreichem Windows-Druck wird `numberPrintedUrl` aufgerufen. Erst wenn diese Bestätigung erfolgreich war, darf der Job als `printed` markiert werden.
|
||||
|
||||
Wenn der Druck erfolgreich war, aber `numberPrintedUrl` fehlschlägt, bleibt `reserved_number` erhalten und der Job geht auf `error`. Beim erneuten Drucken wird dieselbe Nummer wiederverwendet; es wird keine neue Nummer reserviert.
|
||||
|
||||
## Payload-Erweiterung
|
||||
|
||||
Vor dem Rendern wird `payload_json` erweitert:
|
||||
|
||||
- `reservedNumber`
|
||||
- der konfigurierte `reservedNumberPayloadKey`, z. B. `nummer`
|
||||
- `qr`, wenn `qrTemplate` gesetzt ist
|
||||
- `jobId`
|
||||
- `barcodeTemplateId`
|
||||
|
||||
Beispiel:
|
||||
|
||||
```json
|
||||
{
|
||||
"reservedNumber": 123,
|
||||
"nummer": 123,
|
||||
"qr": "bjoernprivat 0000123"
|
||||
"backend": {
|
||||
"baseUrl": "https://paperlessmanager.local",
|
||||
"agentId": "PC-BUERO",
|
||||
"encryptedApiToken": "",
|
||||
"nextJobPath": "/api/label-print-agent/jobs/next",
|
||||
"reportSuccessPath": "/api/label-print-agent/jobs/{jobId}/printed",
|
||||
"reportErrorPath": "/api/label-print-agent/jobs/{jobId}/error"
|
||||
},
|
||||
"printer": {
|
||||
"printerName": "DYMO LabelWriter 450",
|
||||
"labelWidthMm": 57,
|
||||
"labelHeightMm": 32,
|
||||
"dpi": 300
|
||||
},
|
||||
"worker": {
|
||||
"enabled": true,
|
||||
"pollIntervalSeconds": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Oberfläche
|
||||
Der API-Token wird lokal mit Windows DPAPI verschlüsselt gespeichert.
|
||||
|
||||
Im Einstellungsdialog gibt es den Tab `Label-Templates`.
|
||||
## Dymo-Druck
|
||||
|
||||
Dort kannst du:
|
||||
Der Dymo LabelWriter muss in Windows als normaler Drucker eingerichtet sein. Das Backend liefert ein fertiges Bild für das Etikett, typischerweise PNG in `57 x 32 mm`.
|
||||
|
||||
- Templates anlegen
|
||||
- Templates löschen
|
||||
- `barcodeTemplateId` bearbeiten
|
||||
- Nummernserver-URLs bearbeiten
|
||||
- QR-Template bearbeiten
|
||||
- eingebettetes Layout-JSON bearbeiten
|
||||
- validieren
|
||||
- Vorschau erzeugen
|
||||
- Testdruck auslösen
|
||||
Wichtig:
|
||||
|
||||
Die Vorschau verwendet eine Dummy-Nummer `123` und reserviert keine Nummer beim Nummernserver.
|
||||
- Der Agent verwendet `PrintDocument`.
|
||||
- Es wird kein ZPL, EPL oder TSPL verwendet.
|
||||
- Das Bild wird auf die komplette Papierfläche gedruckt.
|
||||
- Der Dymo-Treiber sollte auf das passende Etikettenformat eingestellt sein.
|
||||
|
||||
## Dymo-Testdruck
|
||||
## Bedienung
|
||||
|
||||
Für den Dymo LabelWriter muss der Drucker in Windows als normaler Drucker eingerichtet sein. Stelle im Dymo-Treiber möglichst das passende Etikettenformat `57 x 32 mm` ein.
|
||||
1. Anwendung starten.
|
||||
2. Tray-Symbol öffnen.
|
||||
3. Im Tab `Backend` BaseUrl, AgentId und optional API-Token eintragen.
|
||||
4. Im Tab `Drucker` den Dymo LabelWriter auswählen.
|
||||
5. Im Tab `Allgemein` Polling aktivieren und Intervall setzen.
|
||||
6. Mit `Jetzt prüfen` kann sofort ein einzelner Backend-Poll ausgelöst werden.
|
||||
|
||||
Testdruck:
|
||||
## Nicht mehr im Agent
|
||||
|
||||
1. Im Tab `Drucker` den Dymo LabelWriter auswählen.
|
||||
2. `Speichern` klicken.
|
||||
3. Im Tab `Label-Templates` ein Template auswählen.
|
||||
4. `Vorschau` prüfen.
|
||||
5. `Testdruck` klicken.
|
||||
- keine Layout-JSON-Verwaltung
|
||||
- keine lokalen LabelTemplates
|
||||
- keine MySQL-Verbindung
|
||||
- keine Nummernserver-URLs
|
||||
- keine Nummernreservierung im Agent
|
||||
|
||||
Typische Fehler:
|
||||
|
||||
- Falscher Drucker gewählt.
|
||||
- Falsches Etikettenformat im Dymo-Treiber.
|
||||
- Treiber skaliert auf A4/Letter.
|
||||
- Etikett ist gedreht: Dymo-Treiber-Labelgröße und physische Orientierung prüfen.
|
||||
|
||||
## Datenbank
|
||||
|
||||
Die SQL-Datei liegt unter:
|
||||
|
||||
```text
|
||||
sql/create_label_print_queue.sql
|
||||
```
|
||||
|
||||
Beispiel-Insert:
|
||||
|
||||
```sql
|
||||
INSERT INTO label_print_queue
|
||||
(barcode_template_id, payload_json, status)
|
||||
VALUES
|
||||
(
|
||||
1,
|
||||
JSON_OBJECT(
|
||||
'titel', 'Beleg privat',
|
||||
'beschreibung', 'Tankbeleg',
|
||||
'datum', '2026-05-07'
|
||||
),
|
||||
'pending'
|
||||
);
|
||||
```
|
||||
|
||||
## Noch offen
|
||||
|
||||
Der automatische MySQL-Worker ist noch nicht aktiv verdrahtet. Die dafür benötigten Modell-, Repository-, Nummernserver- und Druckprozessor-Klassen sind vorbereitet.
|
||||
Diese Verantwortung liegt vollständig im PaperlessManager-Backend.
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS label_print_queue (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
|
||||
barcode_template_id INT(11) NOT NULL,
|
||||
payload_json JSON NOT NULL,
|
||||
|
||||
reserved_number BIGINT NULL,
|
||||
number_reserved_at DATETIME NULL,
|
||||
number_printed_at DATETIME NULL,
|
||||
|
||||
status ENUM('pending','number_reserved','printing','printed','error','deleted') NOT NULL DEFAULT 'pending',
|
||||
|
||||
printer_name VARCHAR(255) NULL,
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
error_message TEXT NULL,
|
||||
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
locked_at DATETIME NULL,
|
||||
printed_at DATETIME NULL,
|
||||
|
||||
INDEX idx_label_print_queue_status_created (status, created_at),
|
||||
INDEX idx_label_print_queue_barcode_template_id (barcode_template_id),
|
||||
|
||||
CONSTRAINT fk_label_print_queue_barcode_template
|
||||
FOREIGN KEY (barcode_template_id)
|
||||
REFERENCES barcode_templates(Id)
|
||||
);
|
||||
|
||||
INSERT INTO label_print_queue
|
||||
(barcode_template_id, payload_json, status)
|
||||
VALUES
|
||||
(
|
||||
1,
|
||||
JSON_OBJECT(
|
||||
'titel', 'Beleg privat',
|
||||
'beschreibung', 'Tankbeleg',
|
||||
'datum', '2026-05-07'
|
||||
),
|
||||
'pending'
|
||||
);
|
||||
@@ -1,3 +1,4 @@
|
||||
using LabelPrintAgent.Backend;
|
||||
using LabelPrintAgent.Configuration;
|
||||
using LabelPrintAgent.Logging;
|
||||
using LabelPrintAgent.Printing;
|
||||
@@ -21,8 +22,11 @@ internal static class Program
|
||||
LogSetup.Configure(settings.Paths.LogFolder);
|
||||
|
||||
var printerService = new PrinterService();
|
||||
var backendClient = new BackendClient(settingsStore);
|
||||
using var backendWorker = new BackendPollingWorker(settingsStore, backendClient, printerService);
|
||||
backendWorker.Start();
|
||||
|
||||
Application.Run(new TrayApplicationContext(settingsStore, printerService));
|
||||
Application.Run(new TrayApplicationContext(settingsStore, printerService, backendWorker));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
using System.Drawing;
|
||||
using LabelPrintAgent.Backend;
|
||||
using LabelPrintAgent.Configuration;
|
||||
using LabelPrintAgent.Database;
|
||||
using LabelPrintAgent.Layout;
|
||||
using LabelPrintAgent.Printing;
|
||||
using LabelPrintAgent.Rendering;
|
||||
using Microsoft.Win32;
|
||||
using Serilog;
|
||||
|
||||
@@ -16,113 +13,80 @@ internal sealed class SettingsForm : Form
|
||||
|
||||
private readonly SettingsStore _settingsStore;
|
||||
private readonly PrinterService _printerService;
|
||||
private readonly LabelRenderer _labelRenderer = new();
|
||||
private readonly LayoutValidator _layoutValidator = new();
|
||||
private readonly BackendPollingWorker _backendWorker;
|
||||
|
||||
private readonly CheckBox _autoStartCheckBox = new() { Text = "Autostart aktivieren", AutoSize = true };
|
||||
private readonly CheckBox _workerEnabled = new() { Text = "Backend automatisch abfragen", AutoSize = true };
|
||||
private readonly NumericUpDown _pollInterval = new() { Minimum = 1, Maximum = 3600, Value = 5, Width = 100 };
|
||||
private readonly TextBox _programDataFolder = new() { ReadOnly = true, Width = 540 };
|
||||
|
||||
private readonly TextBox _host = new() { Width = 260 };
|
||||
private readonly NumericUpDown _port = new() { Minimum = 1, Maximum = 65535, Value = 3306, Width = 100 };
|
||||
private readonly TextBox _database = new() { Width = 260 };
|
||||
private readonly TextBox _username = new() { Width = 260 };
|
||||
private readonly TextBox _password = new() { Width = 260, UseSystemPasswordChar = true };
|
||||
private readonly TextBox _backendBaseUrl = new() { Width = 420 };
|
||||
private readonly TextBox _agentId = new() { Width = 260 };
|
||||
private readonly TextBox _apiToken = new() { Width = 420, UseSystemPasswordChar = true };
|
||||
private readonly TextBox _nextJobPath = new() { Width = 420 };
|
||||
private readonly TextBox _reportSuccessPath = new() { Width = 420 };
|
||||
private readonly TextBox _reportErrorPath = new() { Width = 420 };
|
||||
|
||||
private readonly ComboBox _printer = new() { Width = 360, DropDownStyle = ComboBoxStyle.DropDownList };
|
||||
private readonly NumericUpDown _labelWidth = new() { Minimum = 1, Maximum = 500, DecimalPlaces = 1, Value = 57, Width = 100 };
|
||||
private readonly NumericUpDown _labelHeight = new() { Minimum = 1, Maximum = 500, DecimalPlaces = 1, Value = 32, Width = 100 };
|
||||
|
||||
private readonly ListBox _templateList = new() { Width = 250, Height = 520 };
|
||||
private readonly NumericUpDown _barcodeTemplateId = new() { Minimum = 1, Maximum = int.MaxValue, Width = 100 };
|
||||
private readonly TextBox _templateName = new() { Width = 360 };
|
||||
private readonly TextBox _getNumberUrl = new() { Width = 600 };
|
||||
private readonly TextBox _numberPrintedUrl = new() { Width = 600 };
|
||||
private readonly TextBox _reservedNumberPayloadKey = new() { Width = 180 };
|
||||
private readonly TextBox _qrTemplate = new() { Width = 360 };
|
||||
private readonly TextBox _layoutJson = new()
|
||||
{
|
||||
Multiline = true,
|
||||
ScrollBars = ScrollBars.Both,
|
||||
WordWrap = false,
|
||||
Font = new Font(FontFamily.GenericMonospace, 9),
|
||||
Width = 610,
|
||||
Height = 260
|
||||
};
|
||||
private readonly TextBox _templateMessages = new()
|
||||
private readonly TextBox _status = new()
|
||||
{
|
||||
Multiline = true,
|
||||
ReadOnly = true,
|
||||
ScrollBars = ScrollBars.Vertical,
|
||||
Width = 930,
|
||||
Height = 72
|
||||
};
|
||||
private readonly PictureBox _templatePreview = new()
|
||||
{
|
||||
BorderStyle = BorderStyle.FixedSingle,
|
||||
SizeMode = PictureBoxSizeMode.Zoom,
|
||||
BackColor = Color.White,
|
||||
Width = 280,
|
||||
Height = 170
|
||||
};
|
||||
|
||||
private readonly DataGridView _errorJobs = new()
|
||||
{
|
||||
ReadOnly = true,
|
||||
AllowUserToAddRows = false,
|
||||
AllowUserToDeleteRows = false,
|
||||
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
|
||||
SelectionMode = DataGridViewSelectionMode.FullRowSelect,
|
||||
Dock = DockStyle.Fill
|
||||
};
|
||||
|
||||
public SettingsForm(SettingsStore settingsStore, PrinterService printerService)
|
||||
public SettingsForm(SettingsStore settingsStore, PrinterService printerService, BackendPollingWorker backendWorker)
|
||||
{
|
||||
_settingsStore = settingsStore;
|
||||
_printerService = printerService;
|
||||
_backendWorker = backendWorker;
|
||||
|
||||
Text = "LabelPrintAgent Einstellungen";
|
||||
Width = 1240;
|
||||
Height = 820;
|
||||
Width = 900;
|
||||
Height = 620;
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
|
||||
var tabs = new TabControl { Dock = DockStyle.Fill };
|
||||
tabs.TabPages.Add(CreateGeneralTab());
|
||||
tabs.TabPages.Add(CreateDatabaseTab());
|
||||
tabs.TabPages.Add(CreateBackendTab());
|
||||
tabs.TabPages.Add(CreatePrinterTab());
|
||||
tabs.TabPages.Add(CreateLabelTemplatesTab());
|
||||
tabs.TabPages.Add(CreateErrorJobsTab());
|
||||
tabs.TabPages.Add(CreateStatusTab());
|
||||
Controls.Add(tabs);
|
||||
|
||||
Load += (_, _) =>
|
||||
{
|
||||
LoadSettingsIntoUi();
|
||||
LoadPrinters();
|
||||
LoadTemplates();
|
||||
LoadEmptyErrorJobsTable();
|
||||
};
|
||||
}
|
||||
|
||||
private TabPage CreateGeneralTab()
|
||||
{
|
||||
var panel = CreatePaddedPanel();
|
||||
panel.Controls.Add(Row(20, Label("Autostart", 140), _autoStartCheckBox));
|
||||
panel.Controls.Add(Row(60, Label("Prüfintervall", 140), _pollInterval, Label("Sekunden", 80)));
|
||||
panel.Controls.Add(Row(100, Label("Programmdaten", 140), _programDataFolder));
|
||||
panel.Controls.Add(ButtonAt("Speichern", 160, 150, (_, _) => SaveGeneral()));
|
||||
panel.Controls.Add(Row(20, Label("Autostart", 160), _autoStartCheckBox));
|
||||
panel.Controls.Add(Row(60, Label("Worker", 160), _workerEnabled));
|
||||
panel.Controls.Add(Row(100, Label("Prüfintervall", 160), _pollInterval, Label("Sekunden", 80)));
|
||||
panel.Controls.Add(Row(140, Label("Programmdaten", 160), _programDataFolder));
|
||||
panel.Controls.Add(ButtonAt("Speichern", 180, 190, (_, _) => SaveGeneral()));
|
||||
return new TabPage("Allgemein") { Controls = { panel } };
|
||||
}
|
||||
|
||||
private TabPage CreateDatabaseTab()
|
||||
private TabPage CreateBackendTab()
|
||||
{
|
||||
var panel = CreatePaddedPanel();
|
||||
panel.Controls.Add(Row(20, Label("Host", 120), _host));
|
||||
panel.Controls.Add(Row(60, Label("Port", 120), _port));
|
||||
panel.Controls.Add(Row(100, Label("Datenbank", 120), _database));
|
||||
panel.Controls.Add(Row(140, Label("Benutzer", 120), _username));
|
||||
panel.Controls.Add(Row(180, Label("Passwort", 120), _password));
|
||||
panel.Controls.Add(ButtonAt("Speichern", 140, 230, (_, _) => SaveDatabase()));
|
||||
return new TabPage("Datenbank") { Controls = { panel } };
|
||||
panel.Controls.Add(Row(20, Label("BaseUrl", 160), _backendBaseUrl));
|
||||
panel.Controls.Add(Row(60, Label("AgentId", 160), _agentId));
|
||||
panel.Controls.Add(Row(100, Label("API Token", 160), _apiToken));
|
||||
panel.Controls.Add(Row(140, Label("NextJobPath", 160), _nextJobPath));
|
||||
panel.Controls.Add(Row(180, Label("ReportSuccessPath", 160), _reportSuccessPath));
|
||||
panel.Controls.Add(Row(220, Label("ReportErrorPath", 160), _reportErrorPath));
|
||||
panel.Controls.Add(ButtonAt("Speichern", 180, 275, (_, _) => SaveBackend(showMessage: true)));
|
||||
panel.Controls.Add(ButtonAt("Jetzt prüfen", 300, 275, async (_, _) => await PollOnceFromUiAsync()));
|
||||
return new TabPage("Backend") { Controls = { panel } };
|
||||
}
|
||||
|
||||
private TabPage CreatePrinterTab()
|
||||
@@ -133,51 +97,13 @@ internal sealed class SettingsForm : Form
|
||||
panel.Controls.Add(Row(100, Label("Höhe", 120), _labelHeight, Label("mm", 40)));
|
||||
panel.Controls.Add(ButtonAt("Drucker neu laden", 140, 150, (_, _) => LoadPrinters()));
|
||||
panel.Controls.Add(ButtonAt("Speichern", 300, 150, (_, _) => SavePrinter(showMessage: true)));
|
||||
panel.Controls.Add(ButtonAt("Testdruck", 420, 150, async (_, _) => await PrintSelectedTemplateAsync()));
|
||||
return new TabPage("Drucker") { Controls = { panel } };
|
||||
}
|
||||
|
||||
private TabPage CreateLabelTemplatesTab()
|
||||
private TabPage CreateStatusTab()
|
||||
{
|
||||
var panel = CreatePaddedPanel();
|
||||
_templateList.Left = 20;
|
||||
_templateList.Top = 20;
|
||||
_templateList.SelectedIndexChanged += (_, _) => LoadSelectedTemplateIntoUi();
|
||||
panel.Controls.Add(_templateList);
|
||||
|
||||
panel.Controls.Add(RowAt(290, 20, Label("BarcodeTemplateId", 150), _barcodeTemplateId));
|
||||
panel.Controls.Add(RowAt(290, 60, Label("Name", 150), _templateName));
|
||||
panel.Controls.Add(RowAt(290, 100, Label("GetNumberUrl", 150), _getNumberUrl));
|
||||
panel.Controls.Add(RowAt(290, 140, Label("NumberPrintedUrl", 150), _numberPrintedUrl));
|
||||
panel.Controls.Add(RowAt(290, 180, Label("ReservedNumberKey", 150), _reservedNumberPayloadKey));
|
||||
panel.Controls.Add(RowAt(290, 220, Label("QrTemplate", 150), _qrTemplate));
|
||||
|
||||
_layoutJson.Left = 290;
|
||||
_layoutJson.Top = 270;
|
||||
panel.Controls.Add(_layoutJson);
|
||||
_templatePreview.Left = 925;
|
||||
_templatePreview.Top = 270;
|
||||
panel.Controls.Add(_templatePreview);
|
||||
_templateMessages.Left = 290;
|
||||
_templateMessages.Top = 545;
|
||||
panel.Controls.Add(_templateMessages);
|
||||
|
||||
panel.Controls.Add(ButtonAt("Neu", 20, 560, (_, _) => NewTemplate()));
|
||||
panel.Controls.Add(ButtonAt("Löschen", 120, 560, (_, _) => DeleteTemplate()));
|
||||
panel.Controls.Add(ButtonAt("Validieren", 290, 640, (_, _) => ValidateCurrentTemplate(showSuccessMessage: true)));
|
||||
panel.Controls.Add(ButtonAt("Speichern", 405, 640, (_, _) => SaveTemplate()));
|
||||
panel.Controls.Add(ButtonAt("Vorschau", 520, 640, (_, _) => RenderTemplatePreview()));
|
||||
panel.Controls.Add(ButtonAt("Testdruck", 630, 640, async (_, _) => await PrintCurrentTemplateAsync()));
|
||||
return new TabPage("Label-Templates") { Controls = { panel } };
|
||||
}
|
||||
|
||||
private TabPage CreateErrorJobsTab()
|
||||
{
|
||||
var page = new TabPage("Fehlerhafte Druckaufträge");
|
||||
var footer = new Panel { Dock = DockStyle.Bottom, Height = 48, Padding = new Padding(12) };
|
||||
footer.Controls.Add(LabelAt("Fehlerhafte Jobs werden angezeigt, sobald der MySQL-Worker aktiviert ist.", 12, 14, 620));
|
||||
page.Controls.Add(_errorJobs);
|
||||
page.Controls.Add(footer);
|
||||
var page = new TabPage("Status");
|
||||
page.Controls.Add(_status);
|
||||
return page;
|
||||
}
|
||||
|
||||
@@ -185,13 +111,17 @@ internal sealed class SettingsForm : Form
|
||||
{
|
||||
var settings = _settingsStore.Load();
|
||||
_autoStartCheckBox.Checked = IsAutostartEnabled();
|
||||
_workerEnabled.Checked = settings.Worker.Enabled;
|
||||
_pollInterval.Value = Math.Clamp(settings.Worker.PollIntervalSeconds, 1, 3600);
|
||||
_programDataFolder.Text = settings.Paths.BaseFolder;
|
||||
_host.Text = settings.Database.Host;
|
||||
_port.Value = settings.Database.Port;
|
||||
_database.Text = settings.Database.Database;
|
||||
_username.Text = settings.Database.Username;
|
||||
_password.Text = _settingsStore.DecryptPassword(settings);
|
||||
|
||||
_backendBaseUrl.Text = settings.Backend.BaseUrl;
|
||||
_agentId.Text = settings.Backend.AgentId;
|
||||
_apiToken.Text = _settingsStore.DecryptBackendApiToken(settings);
|
||||
_nextJobPath.Text = settings.Backend.NextJobPath;
|
||||
_reportSuccessPath.Text = settings.Backend.ReportSuccessPath;
|
||||
_reportErrorPath.Text = settings.Backend.ReportErrorPath;
|
||||
|
||||
_labelWidth.Value = settings.Printer.LabelWidthMm;
|
||||
_labelHeight.Value = settings.Printer.LabelHeightMm;
|
||||
}
|
||||
@@ -214,65 +144,31 @@ internal sealed class SettingsForm : Form
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadTemplates()
|
||||
{
|
||||
var settings = _settingsStore.Load();
|
||||
_templateList.Items.Clear();
|
||||
foreach (var template in settings.LabelTemplates.OrderBy(template => template.BarcodeTemplateId))
|
||||
{
|
||||
_templateList.Items.Add(template);
|
||||
}
|
||||
|
||||
if (_templateList.Items.Count > 0)
|
||||
{
|
||||
_templateList.SelectedIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadSelectedTemplateIntoUi()
|
||||
{
|
||||
if (_templateList.SelectedItem is not LabelTemplateConfig template)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_barcodeTemplateId.Value = Math.Max(1, template.BarcodeTemplateId);
|
||||
_templateName.Text = template.Name;
|
||||
_getNumberUrl.Text = template.GetNumberUrl;
|
||||
_numberPrintedUrl.Text = template.NumberPrintedUrl;
|
||||
_reservedNumberPayloadKey.Text = template.ReservedNumberPayloadKey;
|
||||
_qrTemplate.Text = template.QrTemplate;
|
||||
_layoutJson.Text = LayoutJsonSerializer.Serialize(template.Layout);
|
||||
_templateMessages.Text = string.Empty;
|
||||
}
|
||||
|
||||
private void LoadEmptyErrorJobsTable()
|
||||
{
|
||||
_errorJobs.DataSource = new[]
|
||||
{
|
||||
new ErrorJobRow(0, 0, string.Empty, 0)
|
||||
};
|
||||
}
|
||||
|
||||
private void SaveGeneral()
|
||||
{
|
||||
var settings = _settingsStore.Load();
|
||||
settings.Worker.Enabled = _workerEnabled.Checked;
|
||||
settings.Worker.PollIntervalSeconds = (int)_pollInterval.Value;
|
||||
_settingsStore.Save(settings);
|
||||
SetAutostart(_autoStartCheckBox.Checked);
|
||||
MessageBox.Show("Allgemeine Einstellungen gespeichert.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
AppendStatus("Allgemeine Einstellungen gespeichert.");
|
||||
}
|
||||
|
||||
private void SaveDatabase()
|
||||
private void SaveBackend(bool showMessage)
|
||||
{
|
||||
var settings = _settingsStore.Load();
|
||||
settings.Database.Host = _host.Text.Trim();
|
||||
settings.Database.Port = (uint)_port.Value;
|
||||
settings.Database.Database = _database.Text.Trim();
|
||||
settings.Database.Username = _username.Text.Trim();
|
||||
settings.Database.EncryptedPassword = _settingsStore.EncryptPassword(_password.Text);
|
||||
settings.Backend.BaseUrl = _backendBaseUrl.Text.Trim();
|
||||
settings.Backend.AgentId = _agentId.Text.Trim();
|
||||
settings.Backend.EncryptedApiToken = _settingsStore.EncryptPassword(_apiToken.Text);
|
||||
settings.Backend.NextJobPath = _nextJobPath.Text.Trim();
|
||||
settings.Backend.ReportSuccessPath = _reportSuccessPath.Text.Trim();
|
||||
settings.Backend.ReportErrorPath = _reportErrorPath.Text.Trim();
|
||||
_settingsStore.Save(settings);
|
||||
MessageBox.Show("Datenbankeinstellungen gespeichert.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
AppendStatus("Backend-Einstellungen gespeichert.");
|
||||
if (showMessage)
|
||||
{
|
||||
MessageBox.Show("Backend-Einstellungen gespeichert.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
}
|
||||
}
|
||||
|
||||
private void SavePrinter(bool showMessage)
|
||||
@@ -282,236 +178,31 @@ internal sealed class SettingsForm : Form
|
||||
settings.Printer.LabelWidthMm = _labelWidth.Value;
|
||||
settings.Printer.LabelHeightMm = _labelHeight.Value;
|
||||
_settingsStore.Save(settings);
|
||||
AppendStatus("Druckereinstellungen gespeichert.");
|
||||
if (showMessage)
|
||||
{
|
||||
MessageBox.Show("Druckereinstellungen gespeichert.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
}
|
||||
}
|
||||
|
||||
private void NewTemplate()
|
||||
{
|
||||
var settings = _settingsStore.Load();
|
||||
var nextId = settings.LabelTemplates.Count == 0 ? 1 : settings.LabelTemplates.Max(template => template.BarcodeTemplateId) + 1;
|
||||
var template = LabelTemplateConfig.CreateDefault();
|
||||
template.BarcodeTemplateId = nextId;
|
||||
template.Name = $"Neues Template {nextId}";
|
||||
settings.LabelTemplates.Add(template);
|
||||
_settingsStore.Save(settings);
|
||||
LoadTemplates();
|
||||
_templateList.SelectedItem = _templateList.Items.Cast<LabelTemplateConfig>().First(item => item.BarcodeTemplateId == nextId);
|
||||
}
|
||||
|
||||
private void DeleteTemplate()
|
||||
{
|
||||
if (_templateList.SelectedItem is not LabelTemplateConfig selected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var settings = _settingsStore.Load();
|
||||
settings.LabelTemplates.RemoveAll(template => template.BarcodeTemplateId == selected.BarcodeTemplateId);
|
||||
if (settings.LabelTemplates.Count == 0)
|
||||
{
|
||||
settings.LabelTemplates.Add(LabelTemplateConfig.CreateDefault());
|
||||
}
|
||||
|
||||
_settingsStore.Save(settings);
|
||||
LoadTemplates();
|
||||
}
|
||||
|
||||
private void SaveTemplate()
|
||||
{
|
||||
if (!ValidateCurrentTemplate(showSuccessMessage: false, out var template) || template is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var settings = _settingsStore.Load();
|
||||
var selectedTemplateId = _templateList.SelectedItem is LabelTemplateConfig selected
|
||||
? selected.BarcodeTemplateId
|
||||
: (int?)null;
|
||||
var existingIndex = selectedTemplateId.HasValue
|
||||
? settings.LabelTemplates.FindIndex(item => item.BarcodeTemplateId == selectedTemplateId.Value)
|
||||
: settings.LabelTemplates.FindIndex(item => item.BarcodeTemplateId == template.BarcodeTemplateId);
|
||||
if (existingIndex >= 0)
|
||||
{
|
||||
settings.LabelTemplates[existingIndex] = template;
|
||||
}
|
||||
else
|
||||
{
|
||||
settings.LabelTemplates.Add(template);
|
||||
}
|
||||
|
||||
_settingsStore.Save(settings);
|
||||
_layoutJson.Text = LayoutJsonSerializer.Serialize(template.Layout);
|
||||
LoadTemplates();
|
||||
_templateList.SelectedItem = _templateList.Items.Cast<LabelTemplateConfig>().First(item => item.BarcodeTemplateId == template.BarcodeTemplateId);
|
||||
MessageBox.Show("Label-Template gespeichert.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
}
|
||||
|
||||
private bool ValidateCurrentTemplate(bool showSuccessMessage)
|
||||
{
|
||||
return ValidateCurrentTemplate(showSuccessMessage, out _);
|
||||
}
|
||||
|
||||
private bool ValidateCurrentTemplate(bool showSuccessMessage, out LabelTemplateConfig? template)
|
||||
{
|
||||
template = null;
|
||||
var errors = new List<string>();
|
||||
try
|
||||
{
|
||||
var layout = LayoutJsonSerializer.Deserialize(_layoutJson.Text);
|
||||
template = new LabelTemplateConfig
|
||||
{
|
||||
BarcodeTemplateId = (int)_barcodeTemplateId.Value,
|
||||
Name = _templateName.Text.Trim(),
|
||||
GetNumberUrl = _getNumberUrl.Text.Trim(),
|
||||
NumberPrintedUrl = _numberPrintedUrl.Text.Trim(),
|
||||
ReservedNumberPayloadKey = _reservedNumberPayloadKey.Text.Trim(),
|
||||
QrTemplate = _qrTemplate.Text,
|
||||
Layout = layout ?? new LabelLayout()
|
||||
};
|
||||
|
||||
if (template.BarcodeTemplateId <= 0) errors.Add("BarcodeTemplateId muss größer als 0 sein.");
|
||||
if (string.IsNullOrWhiteSpace(template.Name)) errors.Add("Name darf nicht leer sein.");
|
||||
if (string.IsNullOrWhiteSpace(template.GetNumberUrl)) errors.Add("GetNumberUrl darf nicht leer sein.");
|
||||
if (string.IsNullOrWhiteSpace(template.NumberPrintedUrl)) errors.Add("NumberPrintedUrl darf nicht leer sein.");
|
||||
if (string.IsNullOrWhiteSpace(template.ReservedNumberPayloadKey)) errors.Add("ReservedNumberPayloadKey darf nicht leer sein.");
|
||||
if (HasDuplicateBarcodeTemplateId(template.BarcodeTemplateId))
|
||||
{
|
||||
errors.Add($"BarcodeTemplateId {template.BarcodeTemplateId} ist bereits in einem anderen Label-Template vorhanden.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(template.QrTemplate))
|
||||
{
|
||||
_ = TemplateFormatter.Format(template.QrTemplate, BuildPreviewPayload(template, dummyNumber: 123), errors);
|
||||
}
|
||||
|
||||
errors.AddRange(_layoutValidator.Validate(template.Layout).Errors);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Could not validate label template");
|
||||
errors.Add($"Layout-JSON konnte nicht gelesen werden: {ex.Message}");
|
||||
}
|
||||
|
||||
if (errors.Count == 0)
|
||||
{
|
||||
_templateMessages.Text = string.Empty;
|
||||
if (showSuccessMessage)
|
||||
{
|
||||
MessageBox.Show("Label-Template ist gültig.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
var message = string.Join(Environment.NewLine, errors);
|
||||
_templateMessages.Text = message;
|
||||
MessageBox.Show(message, "Label-Template ist ungültig", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool HasDuplicateBarcodeTemplateId(int barcodeTemplateId)
|
||||
{
|
||||
var selectedTemplateId = _templateList.SelectedItem is LabelTemplateConfig selected
|
||||
? selected.BarcodeTemplateId
|
||||
: (int?)null;
|
||||
|
||||
return _settingsStore.Load().LabelTemplates.Any(template =>
|
||||
template.BarcodeTemplateId == barcodeTemplateId
|
||||
&& (!selectedTemplateId.HasValue || template.BarcodeTemplateId != selectedTemplateId.Value));
|
||||
}
|
||||
|
||||
private void RenderTemplatePreview()
|
||||
private async Task PollOnceFromUiAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!ValidateCurrentTemplate(showSuccessMessage: false, out var template) || template is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var result = _labelRenderer.Render(template.Layout, BuildPreviewPayload(template, dummyNumber: 123));
|
||||
var oldImage = _templatePreview.Image;
|
||||
_templatePreview.Image = (Bitmap)result.Image.Clone();
|
||||
oldImage?.Dispose();
|
||||
_templateMessages.Text = result.Warnings.Count == 0
|
||||
? $"Vorschau erzeugt: {result.WidthPx} x {result.HeightPx} px"
|
||||
: $"Vorschau erzeugt: {result.WidthPx} x {result.HeightPx} px{Environment.NewLine}" + string.Join(Environment.NewLine, result.Warnings);
|
||||
SaveBackend(showMessage: false);
|
||||
SavePrinter(showMessage: false);
|
||||
AppendStatus("Backend wird geprüft...");
|
||||
await _backendWorker.PollOnceAsync();
|
||||
AppendStatus("Backend-Prüfung abgeschlossen.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Could not render label template preview");
|
||||
MessageBox.Show($"Vorschau konnte nicht erzeugt werden: {ex.Message}", Text, MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
Log.Error(ex, "Manual backend poll failed");
|
||||
AppendStatus($"Backend-Prüfung fehlgeschlagen: {ex.Message}");
|
||||
MessageBox.Show($"Backend-Prüfung fehlgeschlagen: {ex.Message}", Text, MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PrintSelectedTemplateAsync()
|
||||
{
|
||||
if (_templateList.SelectedItem is LabelTemplateConfig template)
|
||||
{
|
||||
await PrintTemplateAsync(template);
|
||||
}
|
||||
else
|
||||
{
|
||||
MessageBox.Show("Bitte zuerst ein Label-Template auswählen.", Text, MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PrintCurrentTemplateAsync()
|
||||
{
|
||||
if (ValidateCurrentTemplate(showSuccessMessage: false, out var template) && template is not null)
|
||||
{
|
||||
await PrintTemplateAsync(template);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PrintTemplateAsync(LabelTemplateConfig template)
|
||||
{
|
||||
SavePrinter(showMessage: false);
|
||||
var printerName = GetSelectedPrinterName();
|
||||
if (string.IsNullOrWhiteSpace(printerName))
|
||||
{
|
||||
MessageBox.Show("Bitte zuerst einen Drucker auswählen.", Text, MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_printerService.IsPrinterAvailable(printerName))
|
||||
{
|
||||
MessageBox.Show($"Der Drucker '{printerName}' ist nicht verfügbar.", Text, MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
using var renderResult = _labelRenderer.Render(template.Layout, BuildPreviewPayload(template, dummyNumber: 123));
|
||||
var printResult = await _printerService.PrintAsync(renderResult.Image, printerName, template.Layout.WidthMm, template.Layout.HeightMm);
|
||||
if (printResult.Success)
|
||||
{
|
||||
MessageBox.Show("Testdruck wurde an den Drucker gesendet.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
MessageBox.Show($"Testdruck fehlgeschlagen: {printResult.ErrorMessage}", Text, MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> BuildPreviewPayload(LabelTemplateConfig template, long dummyNumber)
|
||||
{
|
||||
var job = new PrintJob
|
||||
{
|
||||
Id = 0,
|
||||
BarcodeTemplateId = template.BarcodeTemplateId,
|
||||
PayloadJson = """
|
||||
{
|
||||
"titel": "Beleg privat",
|
||||
"beschreibung": "Tankbeleg",
|
||||
"datum": "2026-05-07"
|
||||
}
|
||||
""",
|
||||
ReservedNumber = dummyNumber
|
||||
};
|
||||
return LabelPayloadBuilder.Build(job, template, dummyNumber);
|
||||
}
|
||||
|
||||
private string? GetSelectedPrinterName()
|
||||
{
|
||||
return _printer.SelectedItem switch
|
||||
@@ -522,6 +213,11 @@ internal sealed class SettingsForm : Form
|
||||
};
|
||||
}
|
||||
|
||||
private void AppendStatus(string message)
|
||||
{
|
||||
_status.AppendText($"[{DateTime.Now:HH:mm:ss}] {message}{Environment.NewLine}");
|
||||
}
|
||||
|
||||
private bool IsAutostartEnabled()
|
||||
{
|
||||
using var key = Registry.CurrentUser.OpenSubKey(AutoStartRunKey, writable: false);
|
||||
@@ -545,28 +241,13 @@ internal sealed class SettingsForm : Form
|
||||
|
||||
private static Label Label(string text, int width) => new() { Text = text, Width = width, TextAlign = ContentAlignment.MiddleLeft };
|
||||
|
||||
private static Label LabelAt(string text, int left, int top, int width)
|
||||
{
|
||||
return new Label
|
||||
{
|
||||
Text = text,
|
||||
Left = left,
|
||||
Top = top,
|
||||
Width = width,
|
||||
Height = 24,
|
||||
TextAlign = ContentAlignment.MiddleLeft
|
||||
};
|
||||
}
|
||||
|
||||
private static FlowLayoutPanel Row(int top, params Control[] controls) => RowAt(20, top, controls);
|
||||
|
||||
private static FlowLayoutPanel RowAt(int left, int top, params Control[] controls)
|
||||
private static FlowLayoutPanel Row(int top, params Control[] controls)
|
||||
{
|
||||
var row = new FlowLayoutPanel
|
||||
{
|
||||
Left = left,
|
||||
Left = 20,
|
||||
Top = top,
|
||||
Width = 880,
|
||||
Width = 760,
|
||||
Height = 32,
|
||||
FlowDirection = FlowDirection.LeftToRight,
|
||||
WrapContents = false
|
||||
@@ -581,6 +262,4 @@ internal sealed class SettingsForm : Form
|
||||
button.Click += click;
|
||||
return button;
|
||||
}
|
||||
|
||||
private sealed record ErrorJobRow(long Id, int BarcodeTemplateId, string Fehler, int Versuche);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using LabelPrintAgent.Backend;
|
||||
using LabelPrintAgent.Configuration;
|
||||
using LabelPrintAgent.Printing;
|
||||
using Serilog;
|
||||
@@ -9,14 +10,17 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
private readonly NotifyIcon _notifyIcon;
|
||||
private readonly SettingsStore _settingsStore;
|
||||
private readonly PrinterService _printerService;
|
||||
private readonly BackendPollingWorker _backendWorker;
|
||||
private SettingsForm? _settingsForm;
|
||||
|
||||
public TrayApplicationContext(
|
||||
SettingsStore settingsStore,
|
||||
PrinterService printerService)
|
||||
PrinterService printerService,
|
||||
BackendPollingWorker backendWorker)
|
||||
{
|
||||
_settingsStore = settingsStore;
|
||||
_printerService = printerService;
|
||||
_backendWorker = backendWorker;
|
||||
|
||||
var menu = new ContextMenuStrip();
|
||||
menu.Items.Add("Einstellungen", null, (_, _) => ShowSettings());
|
||||
@@ -44,7 +48,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
{
|
||||
if (_settingsForm is null || _settingsForm.IsDisposed)
|
||||
{
|
||||
_settingsForm = new SettingsForm(_settingsStore, _printerService);
|
||||
_settingsForm = new SettingsForm(_settingsStore, _printerService, _backendWorker);
|
||||
}
|
||||
|
||||
_settingsForm.Show();
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Drawing;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using LabelPrintAgent.Configuration;
|
||||
|
||||
namespace LabelPrintAgent.Backend;
|
||||
|
||||
public sealed class BackendClient
|
||||
{
|
||||
private readonly SettingsStore _settingsStore;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public BackendClient(SettingsStore settingsStore, HttpClient? httpClient = null)
|
||||
{
|
||||
_settingsStore = settingsStore;
|
||||
_httpClient = httpClient ?? new HttpClient();
|
||||
}
|
||||
|
||||
public async Task<BackendLabelJob?> GetNextJobAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var settings = _settingsStore.Load();
|
||||
using var request = CreateRequest(HttpMethod.Get, BuildUrl(settings.Backend.BaseUrl, settings.Backend.NextJobPath, settings.Backend.AgentId), settings);
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<BackendLabelJob>(cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Bitmap> GetLabelImageAsync(BackendLabelJob job, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(job.LabelImageBase64))
|
||||
{
|
||||
var bytes = Convert.FromBase64String(job.LabelImageBase64);
|
||||
return CreateBitmap(bytes);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(job.LabelImageUrl))
|
||||
{
|
||||
throw new InvalidOperationException("Backend-Job enthält weder labelImageBase64 noch labelImageUrl.");
|
||||
}
|
||||
|
||||
var settings = _settingsStore.Load();
|
||||
using var request = CreateRequest(HttpMethod.Get, MakeAbsoluteUrl(settings.Backend.BaseUrl, job.LabelImageUrl), settings);
|
||||
var bytesFromUrl = await (await _httpClient.SendAsync(request, cancellationToken)).Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
return CreateBitmap(bytesFromUrl);
|
||||
}
|
||||
|
||||
public Task ReportPrintedAsync(string jobId, string printerName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var settings = _settingsStore.Load();
|
||||
var request = new BackendReportRequest
|
||||
{
|
||||
AgentId = settings.Backend.AgentId,
|
||||
PrinterName = printerName
|
||||
};
|
||||
return PostReportAsync(settings, settings.Backend.ReportSuccessPath, jobId, request, cancellationToken);
|
||||
}
|
||||
|
||||
public Task ReportErrorAsync(string jobId, string printerName, string errorMessage, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var settings = _settingsStore.Load();
|
||||
var request = new BackendReportRequest
|
||||
{
|
||||
AgentId = settings.Backend.AgentId,
|
||||
PrinterName = printerName,
|
||||
ErrorMessage = errorMessage
|
||||
};
|
||||
return PostReportAsync(settings, settings.Backend.ReportErrorPath, jobId, request, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task PostReportAsync(AppSettings settings, string pathTemplate, string jobId, BackendReportRequest body, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = pathTemplate.Replace("{jobId}", Uri.EscapeDataString(jobId), StringComparison.OrdinalIgnoreCase);
|
||||
using var request = CreateRequest(HttpMethod.Post, MakeAbsoluteUrl(settings.Backend.BaseUrl, path), settings);
|
||||
request.Content = JsonContent.Create(body);
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private HttpRequestMessage CreateRequest(HttpMethod method, string url, AppSettings settings)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, url);
|
||||
var token = _settingsStore.DecryptBackendApiToken(settings);
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
private static string BuildUrl(string baseUrl, string path, string agentId)
|
||||
{
|
||||
var separator = path.Contains('?') ? '&' : '?';
|
||||
return $"{MakeAbsoluteUrl(baseUrl, path)}{separator}agentId={Uri.EscapeDataString(agentId)}";
|
||||
}
|
||||
|
||||
private static string MakeAbsoluteUrl(string baseUrl, string pathOrUrl)
|
||||
{
|
||||
if (Uri.TryCreate(pathOrUrl, UriKind.Absolute, out var absolute))
|
||||
{
|
||||
return absolute.ToString();
|
||||
}
|
||||
|
||||
return new Uri(new Uri(EnsureTrailingSlash(baseUrl)), pathOrUrl.TrimStart('/')).ToString();
|
||||
}
|
||||
|
||||
private static string EnsureTrailingSlash(string url) => url.EndsWith('/') ? url : url + "/";
|
||||
|
||||
private static Bitmap CreateBitmap(byte[] bytes)
|
||||
{
|
||||
using var stream = new MemoryStream(bytes);
|
||||
return new Bitmap(stream);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace LabelPrintAgent.Backend;
|
||||
|
||||
public sealed class BackendLabelJob
|
||||
{
|
||||
public string JobId { get; set; } = string.Empty;
|
||||
public string? LabelImageBase64 { get; set; }
|
||||
public string? LabelImageUrl { get; set; }
|
||||
public string LabelImageContentType { get; set; } = "image/png";
|
||||
public double LabelWidthMm { get; set; } = 57;
|
||||
public double LabelHeightMm { get; set; } = 32;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using LabelPrintAgent.Configuration;
|
||||
using LabelPrintAgent.Printing;
|
||||
using Serilog;
|
||||
|
||||
namespace LabelPrintAgent.Backend;
|
||||
|
||||
public sealed class BackendPollingWorker : IDisposable
|
||||
{
|
||||
private readonly SettingsStore _settingsStore;
|
||||
private readonly BackendClient _backendClient;
|
||||
private readonly PrinterService _printerService;
|
||||
private readonly SemaphoreSlim _semaphore = new(1, 1);
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
private Task? _task;
|
||||
|
||||
public BackendPollingWorker(SettingsStore settingsStore, BackendClient backendClient, PrinterService printerService)
|
||||
{
|
||||
_settingsStore = settingsStore;
|
||||
_backendClient = backendClient;
|
||||
_printerService = printerService;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_task is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
_task = Task.Run(() => RunAsync(_cancellationTokenSource.Token));
|
||||
}
|
||||
|
||||
public async Task PollOnceAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!await _semaphore.WaitAsync(0, cancellationToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var settings = _settingsStore.Load();
|
||||
if (!settings.Worker.Enabled || string.IsNullOrWhiteSpace(settings.Backend.BaseUrl))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var printerName = settings.Printer.PrinterName;
|
||||
if (string.IsNullOrWhiteSpace(printerName) || !_printerService.IsPrinterAvailable(printerName))
|
||||
{
|
||||
Log.Warning("No available printer configured for backend polling");
|
||||
return;
|
||||
}
|
||||
|
||||
var job = await _backendClient.GetNextJobAsync(cancellationToken);
|
||||
if (job is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var bitmap = await _backendClient.GetLabelImageAsync(job, cancellationToken);
|
||||
var result = await _printerService.PrintAsync(bitmap, printerName, job.LabelWidthMm, job.LabelHeightMm, cancellationToken);
|
||||
if (result.Success)
|
||||
{
|
||||
await _backendClient.ReportPrintedAsync(job.JobId, printerName, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await _backendClient.ReportErrorAsync(job.JobId, printerName, result.ErrorMessage ?? "Druck fehlgeschlagen.", cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Could not process backend label job {JobId}", job.JobId);
|
||||
await _backendClient.ReportErrorAsync(job.JobId, printerName, ex.Message, cancellationToken);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await PollOnceAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Backend polling failed");
|
||||
}
|
||||
|
||||
var interval = Math.Max(1, _settingsStore.Load().Worker.PollIntervalSeconds);
|
||||
await Task.Delay(TimeSpan.FromSeconds(interval), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cancellationTokenSource?.Cancel();
|
||||
try
|
||||
{
|
||||
_task?.Wait(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
_cancellationTokenSource?.Dispose();
|
||||
_semaphore.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace LabelPrintAgent.Backend;
|
||||
|
||||
public sealed class BackendReportRequest
|
||||
{
|
||||
public string AgentId { get; set; } = string.Empty;
|
||||
public string? PrinterName { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
@@ -1,141 +1,27 @@
|
||||
using LabelPrintAgent.Layout;
|
||||
|
||||
namespace LabelPrintAgent.Configuration;
|
||||
|
||||
public sealed class AppSettings
|
||||
{
|
||||
public DatabaseSettings Database { get; set; } = new();
|
||||
public BackendSettings Backend { get; set; } = new();
|
||||
public PrinterSettings Printer { get; set; } = new();
|
||||
public WorkerSettings Worker { get; set; } = new();
|
||||
public List<LabelTemplateConfig> LabelTemplates { get; set; } = [LabelTemplateConfig.CreateDefault()];
|
||||
public PathSettings Paths { get; set; } = PathSettings.CreateDefault();
|
||||
|
||||
public void EnsureDirectories()
|
||||
{
|
||||
Directory.CreateDirectory(Paths.BaseFolder);
|
||||
Directory.CreateDirectory(Paths.LayoutFolder);
|
||||
Directory.CreateDirectory(Paths.LogFolder);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class LabelTemplateConfig
|
||||
public sealed class BackendSettings
|
||||
{
|
||||
public int BarcodeTemplateId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string GetNumberUrl { get; set; } = string.Empty;
|
||||
public string NumberPrintedUrl { get; set; } = string.Empty;
|
||||
public string ReservedNumberPayloadKey { get; set; } = "nummer";
|
||||
public string QrTemplate { get; set; } = string.Empty;
|
||||
public LabelLayout Layout { get; set; } = new();
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"{BarcodeTemplateId}: {Name}";
|
||||
}
|
||||
|
||||
public static LabelTemplateConfig CreateDefault()
|
||||
{
|
||||
return new LabelTemplateConfig
|
||||
{
|
||||
BarcodeTemplateId = 1,
|
||||
Name = "Beleg privat",
|
||||
GetNumberUrl = "https://nummernserver.local/getNumber?type=privat",
|
||||
NumberPrintedUrl = "https://nummernserver.local/numberPrinted?type=privat&number={reservedNumber}",
|
||||
ReservedNumberPayloadKey = "nummer",
|
||||
QrTemplate = "bjoernprivat {reservedNumber:0000000}",
|
||||
Layout = new LabelLayout
|
||||
{
|
||||
Name = "Beleg privat",
|
||||
WidthMm = 57,
|
||||
HeightMm = 32,
|
||||
Dpi = 300,
|
||||
MarginMm = 3,
|
||||
Orientation = "landscape",
|
||||
Elements =
|
||||
[
|
||||
new RectangleElement
|
||||
{
|
||||
XMm = 1.5,
|
||||
YMm = 1.5,
|
||||
WidthMm = 54,
|
||||
HeightMm = 29,
|
||||
StrokeWidthMm = 0.25,
|
||||
Filled = false
|
||||
},
|
||||
new TextElement
|
||||
{
|
||||
XMm = 3,
|
||||
YMm = 3,
|
||||
WidthMm = 36,
|
||||
HeightMm = 8,
|
||||
Value = "{titel}",
|
||||
FontFamily = "Arial",
|
||||
FontSizePt = 10,
|
||||
MinFontSizePt = 6,
|
||||
AutoShrink = true,
|
||||
Bold = true,
|
||||
HorizontalAlign = "left",
|
||||
VerticalAlign = "top",
|
||||
Multiline = true
|
||||
},
|
||||
new TextElement
|
||||
{
|
||||
XMm = 3,
|
||||
YMm = 12,
|
||||
WidthMm = 36,
|
||||
HeightMm = 8,
|
||||
Value = "{beschreibung}",
|
||||
FontFamily = "Arial",
|
||||
FontSizePt = 8,
|
||||
MinFontSizePt = 6,
|
||||
AutoShrink = true,
|
||||
HorizontalAlign = "left",
|
||||
VerticalAlign = "top",
|
||||
Multiline = true
|
||||
},
|
||||
new QrCodeElement
|
||||
{
|
||||
XMm = 41,
|
||||
YMm = 4,
|
||||
SizeMm = 16,
|
||||
Value = "{qr}"
|
||||
},
|
||||
new LineElement
|
||||
{
|
||||
X1Mm = 3,
|
||||
Y1Mm = 23,
|
||||
X2Mm = 54,
|
||||
Y2Mm = 23,
|
||||
StrokeWidthMm = 0.3
|
||||
},
|
||||
new TextElement
|
||||
{
|
||||
XMm = 3,
|
||||
YMm = 24,
|
||||
WidthMm = 51,
|
||||
HeightMm = 5,
|
||||
Value = "Nr: {nummer:0000000} | {datum:dd.MM.yyyy}",
|
||||
FontFamily = "Arial",
|
||||
FontSizePt = 7,
|
||||
MinFontSizePt = 5,
|
||||
AutoShrink = true,
|
||||
HorizontalAlign = "center",
|
||||
VerticalAlign = "middle",
|
||||
Multiline = false
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DatabaseSettings
|
||||
{
|
||||
public string Host { get; set; } = "10.1.10.xxx";
|
||||
public uint Port { get; set; } = 3306;
|
||||
public string Database { get; set; } = "labeldb";
|
||||
public string Username { get; set; } = "label_user";
|
||||
public string EncryptedPassword { get; set; } = string.Empty;
|
||||
public string BaseUrl { get; set; } = "https://paperlessmanager.local";
|
||||
public string AgentId { get; set; } = Environment.MachineName;
|
||||
public string EncryptedApiToken { get; set; } = string.Empty;
|
||||
public string NextJobPath { get; set; } = "/api/label-print-agent/jobs/next";
|
||||
public string ReportSuccessPath { get; set; } = "/api/label-print-agent/jobs/{jobId}/printed";
|
||||
public string ReportErrorPath { get; set; } = "/api/label-print-agent/jobs/{jobId}/error";
|
||||
}
|
||||
|
||||
public sealed class PrinterSettings
|
||||
@@ -148,13 +34,13 @@ public sealed class PrinterSettings
|
||||
|
||||
public sealed class WorkerSettings
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
public int PollIntervalSeconds { get; set; } = 5;
|
||||
}
|
||||
|
||||
public sealed class PathSettings
|
||||
{
|
||||
public string BaseFolder { get; set; } = DefaultBaseFolder;
|
||||
public string LayoutFolder { get; set; } = Path.Combine(DefaultBaseFolder, "layouts");
|
||||
public string LogFolder { get; set; } = Path.Combine(DefaultBaseFolder, "logs");
|
||||
|
||||
public static string DefaultBaseFolder =>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using LabelPrintAgent.Layout;
|
||||
using Serilog;
|
||||
|
||||
namespace LabelPrintAgent.Configuration;
|
||||
@@ -11,8 +10,7 @@ public sealed class SettingsStore
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new LayoutElementJsonConverter() }
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly ProtectedStringService _protectedStringService;
|
||||
@@ -40,12 +38,6 @@ public sealed class SettingsStore
|
||||
var json = File.ReadAllText(SettingsFilePath);
|
||||
var settings = JsonSerializer.Deserialize<AppSettings>(json, JsonOptions) ?? new AppSettings();
|
||||
settings.Paths ??= PathSettings.CreateDefault();
|
||||
if (settings.LabelTemplates is null || settings.LabelTemplates.Count == 0)
|
||||
{
|
||||
settings.LabelTemplates = [LabelTemplateConfig.CreateDefault()];
|
||||
Save(settings);
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -64,5 +56,5 @@ public sealed class SettingsStore
|
||||
|
||||
public string EncryptPassword(string password) => _protectedStringService.Protect(password);
|
||||
|
||||
public string DecryptPassword(AppSettings settings) => _protectedStringService.Unprotect(settings.Database.EncryptedPassword);
|
||||
public string DecryptBackendApiToken(AppSettings settings) => _protectedStringService.Unprotect(settings.Backend.EncryptedApiToken);
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace LabelPrintAgent.Database;
|
||||
|
||||
public sealed class DbConnectionSettings
|
||||
{
|
||||
public string Host { get; set; } = string.Empty;
|
||||
public uint Port { get; set; } = 3306;
|
||||
public string Database { get; set; } = string.Empty;
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
using LabelPrintAgent.Configuration;
|
||||
using MySqlConnector;
|
||||
|
||||
namespace LabelPrintAgent.Database;
|
||||
|
||||
public sealed class MySqlLabelRepository
|
||||
{
|
||||
private readonly SettingsStore _settingsStore;
|
||||
private readonly ProtectedStringService _protectedStringService;
|
||||
|
||||
public MySqlLabelRepository(SettingsStore settingsStore, ProtectedStringService protectedStringService)
|
||||
{
|
||||
_settingsStore = settingsStore;
|
||||
_protectedStringService = protectedStringService;
|
||||
}
|
||||
|
||||
public async Task TestConnectionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = new MySqlConnection(BuildConnectionString());
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PrintJob>> LoadPendingJobsAsync(int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await LoadJobsAsync("""
|
||||
SELECT *
|
||||
FROM label_print_queue
|
||||
WHERE status = 'pending'
|
||||
ORDER BY created_at
|
||||
LIMIT @limit;
|
||||
""", limit, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PrintJob>> LoadErrorJobsAsync(int limit = 100, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await LoadJobsAsync("""
|
||||
SELECT *
|
||||
FROM label_print_queue
|
||||
WHERE status = 'error'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @limit;
|
||||
""", limit, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PrintJob?> LoadByIdAsync(long id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = new MySqlConnection(BuildConnectionString());
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT *
|
||||
FROM label_print_queue
|
||||
WHERE id = @id;
|
||||
""";
|
||||
command.Parameters.AddWithValue("@id", id);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
return await reader.ReadAsync(cancellationToken) ? ReadJob(reader) : null;
|
||||
}
|
||||
|
||||
public async Task<bool> MarkAsPrintingAsync(long id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var affectedRows = await ExecuteAsync("""
|
||||
UPDATE label_print_queue
|
||||
SET status = 'printing',
|
||||
locked_at = NOW()
|
||||
WHERE id = @id
|
||||
AND status IN ('pending','number_reserved');
|
||||
""", id, cancellationToken);
|
||||
return affectedRows == 1;
|
||||
}
|
||||
|
||||
public async Task MarkNumberReservedAsync(long id, long reservedNumber, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await ExecuteAsync("""
|
||||
UPDATE label_print_queue
|
||||
SET status = 'number_reserved',
|
||||
reserved_number = @reservedNumber,
|
||||
number_reserved_at = NOW(),
|
||||
error_message = NULL
|
||||
WHERE id = @id;
|
||||
""", id, cancellationToken, ("@reservedNumber", reservedNumber));
|
||||
}
|
||||
|
||||
public async Task MarkAsPrintedAsync(long id, string printerName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await ExecuteAsync("""
|
||||
UPDATE label_print_queue
|
||||
SET status = 'printed',
|
||||
printed_at = NOW(),
|
||||
number_printed_at = NOW(),
|
||||
printer_name = @printerName,
|
||||
error_message = NULL
|
||||
WHERE id = @id;
|
||||
""", id, cancellationToken, ("@printerName", printerName));
|
||||
}
|
||||
|
||||
public async Task MarkAsErrorAsync(long id, string errorMessage, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await ExecuteAsync("""
|
||||
UPDATE label_print_queue
|
||||
SET status = 'error',
|
||||
error_message = @errorMessage,
|
||||
attempts = attempts + 1
|
||||
WHERE id = @id;
|
||||
""", id, cancellationToken, ("@errorMessage", errorMessage));
|
||||
}
|
||||
|
||||
public async Task ResetToPendingAsync(long id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await ExecuteAsync("""
|
||||
UPDATE label_print_queue
|
||||
SET status = 'pending',
|
||||
error_message = NULL,
|
||||
locked_at = NULL
|
||||
WHERE id = @id
|
||||
AND status = 'error';
|
||||
""", id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task MarkAsDeletedAsync(long id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await ExecuteAsync("""
|
||||
UPDATE label_print_queue
|
||||
SET status = 'deleted',
|
||||
error_message = 'Vom Benutzer verworfen'
|
||||
WHERE id = @id
|
||||
AND status = 'error';
|
||||
""", id, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<PrintJob>> LoadJobsAsync(string sql, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = new MySqlConnection(BuildConnectionString());
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
command.Parameters.AddWithValue("@limit", limit);
|
||||
|
||||
var jobs = new List<PrintJob>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
jobs.Add(ReadJob(reader));
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
private async Task<int> ExecuteAsync(string sql, long id, CancellationToken cancellationToken, params (string Name, object Value)[] parameters)
|
||||
{
|
||||
await using var connection = new MySqlConnection(BuildConnectionString());
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
command.Parameters.AddWithValue("@id", id);
|
||||
foreach (var parameter in parameters)
|
||||
{
|
||||
command.Parameters.AddWithValue(parameter.Name, parameter.Value);
|
||||
}
|
||||
|
||||
return await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private string BuildConnectionString()
|
||||
{
|
||||
var settings = _settingsStore.Load();
|
||||
var builder = new MySqlConnectionStringBuilder
|
||||
{
|
||||
Server = settings.Database.Host,
|
||||
Port = settings.Database.Port,
|
||||
Database = settings.Database.Database,
|
||||
UserID = settings.Database.Username,
|
||||
Password = _protectedStringService.Unprotect(settings.Database.EncryptedPassword),
|
||||
SslMode = MySqlSslMode.Preferred
|
||||
};
|
||||
return builder.ConnectionString;
|
||||
}
|
||||
|
||||
private static PrintJob ReadJob(MySqlDataReader reader)
|
||||
{
|
||||
return new PrintJob
|
||||
{
|
||||
Id = reader.GetInt64("id"),
|
||||
BarcodeTemplateId = reader.GetInt32("barcode_template_id"),
|
||||
PayloadJson = reader.GetString("payload_json"),
|
||||
ReservedNumber = ReadNullableInt64(reader, "reserved_number"),
|
||||
NumberReservedAt = ReadNullableDateTime(reader, "number_reserved_at"),
|
||||
NumberPrintedAt = ReadNullableDateTime(reader, "number_printed_at"),
|
||||
Status = reader.GetString("status"),
|
||||
PrinterName = ReadNullableString(reader, "printer_name"),
|
||||
Attempts = reader.GetInt32("attempts"),
|
||||
ErrorMessage = ReadNullableString(reader, "error_message"),
|
||||
CreatedAt = reader.GetDateTime("created_at"),
|
||||
LockedAt = ReadNullableDateTime(reader, "locked_at"),
|
||||
PrintedAt = ReadNullableDateTime(reader, "printed_at")
|
||||
};
|
||||
}
|
||||
|
||||
private static long? ReadNullableInt64(MySqlDataReader reader, string column)
|
||||
{
|
||||
var ordinal = reader.GetOrdinal(column);
|
||||
return reader.IsDBNull(ordinal) ? null : reader.GetInt64(ordinal);
|
||||
}
|
||||
|
||||
private static DateTime? ReadNullableDateTime(MySqlDataReader reader, string column)
|
||||
{
|
||||
var ordinal = reader.GetOrdinal(column);
|
||||
return reader.IsDBNull(ordinal) ? null : reader.GetDateTime(ordinal);
|
||||
}
|
||||
|
||||
private static string? ReadNullableString(MySqlDataReader reader, string column)
|
||||
{
|
||||
var ordinal = reader.GetOrdinal(column);
|
||||
return reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
namespace LabelPrintAgent.Database;
|
||||
|
||||
public sealed class PrintJob
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public int BarcodeTemplateId { get; set; }
|
||||
public string PayloadJson { get; set; } = "{}";
|
||||
public long? ReservedNumber { get; set; }
|
||||
public DateTime? NumberReservedAt { get; set; }
|
||||
public DateTime? NumberPrintedAt { get; set; }
|
||||
public string Status { get; set; } = "pending";
|
||||
public string? PrinterName { get; set; }
|
||||
public int Attempts { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? LockedAt { get; set; }
|
||||
public DateTime? PrintedAt { get; set; }
|
||||
}
|
||||
@@ -11,8 +11,6 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MySqlConnector" Version="2.4.0" />
|
||||
<PackageReference Include="QRCoder" Version="1.6.0" />
|
||||
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="9.0.0" />
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace LabelPrintAgent.Layout;
|
||||
|
||||
public sealed class LabelLayout
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public double WidthMm { get; set; } = 57;
|
||||
public double HeightMm { get; set; } = 32;
|
||||
public int Dpi { get; set; } = 300;
|
||||
public double MarginMm { get; set; } = 3;
|
||||
public string Orientation { get; set; } = "landscape";
|
||||
public List<LayoutElement> Elements { get; set; } = [];
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace LabelPrintAgent.Layout;
|
||||
|
||||
public abstract class LayoutElement
|
||||
{
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public double XMm { get; set; }
|
||||
public double YMm { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class UnknownLayoutElement : LayoutElement
|
||||
{
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LabelPrintAgent.Layout;
|
||||
|
||||
public sealed class LayoutElementJsonConverter : JsonConverter<LayoutElement>
|
||||
{
|
||||
public override LayoutElement Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
using var document = JsonDocument.ParseValue(ref reader);
|
||||
if (!document.RootElement.TryGetProperty("type", out var typeProperty))
|
||||
{
|
||||
return new UnknownLayoutElement();
|
||||
}
|
||||
|
||||
var raw = document.RootElement.GetRawText();
|
||||
var type = typeProperty.GetString()?.ToLowerInvariant() ?? string.Empty;
|
||||
return type switch
|
||||
{
|
||||
"text" => WithType(JsonSerializer.Deserialize<TextElement>(raw, options), type),
|
||||
"line" => WithType(JsonSerializer.Deserialize<LineElement>(raw, options), type),
|
||||
"rectangle" => WithType(JsonSerializer.Deserialize<RectangleElement>(raw, options), type),
|
||||
"qr" => WithType(JsonSerializer.Deserialize<QrCodeElement>(raw, options), type),
|
||||
_ => new UnknownLayoutElement { Type = type }
|
||||
};
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, LayoutElement value, JsonSerializerOptions options)
|
||||
{
|
||||
JsonSerializer.Serialize(writer, (object)value, value.GetType(), options);
|
||||
}
|
||||
|
||||
private static T WithType<T>(T? element, string type) where T : LayoutElement
|
||||
{
|
||||
if (element is null)
|
||||
{
|
||||
throw new JsonException();
|
||||
}
|
||||
|
||||
element.Type = type;
|
||||
return element;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LabelPrintAgent.Layout;
|
||||
|
||||
public static class LayoutJsonSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new LayoutElementJsonConverter() }
|
||||
};
|
||||
|
||||
public static LabelLayout? Deserialize(string json)
|
||||
{
|
||||
return JsonSerializer.Deserialize<LabelLayout>(json, JsonOptions);
|
||||
}
|
||||
|
||||
public static string Serialize(LabelLayout layout)
|
||||
{
|
||||
return JsonSerializer.Serialize(layout, JsonOptions);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
namespace LabelPrintAgent.Layout;
|
||||
|
||||
public sealed class LayoutValidationResult
|
||||
{
|
||||
public List<string> Errors { get; } = [];
|
||||
|
||||
public bool IsValid => Errors.Count == 0;
|
||||
|
||||
public void AddError(string error)
|
||||
{
|
||||
Errors.Add(error);
|
||||
}
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
namespace LabelPrintAgent.Layout;
|
||||
|
||||
public sealed class LayoutValidator
|
||||
{
|
||||
private static readonly HashSet<string> Orientations = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"landscape",
|
||||
"portrait"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> HorizontalAlignments = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"left",
|
||||
"center",
|
||||
"right"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> VerticalAlignments = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"top",
|
||||
"middle",
|
||||
"bottom"
|
||||
};
|
||||
|
||||
public LayoutValidationResult Validate(LabelLayout? layout)
|
||||
{
|
||||
var result = new LayoutValidationResult();
|
||||
if (layout is null)
|
||||
{
|
||||
result.AddError("Layout: JSON enthält kein Layout.");
|
||||
return result;
|
||||
}
|
||||
|
||||
ValidateLayout(layout, result);
|
||||
ValidateElements(layout, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ValidateLayout(LabelLayout layout, LayoutValidationResult result)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(layout.Name))
|
||||
{
|
||||
result.AddError("Layout: Name darf nicht leer sein.");
|
||||
}
|
||||
|
||||
if (layout.WidthMm <= 0)
|
||||
{
|
||||
result.AddError("Layout: WidthMm muss größer als 0 sein.");
|
||||
}
|
||||
|
||||
if (layout.HeightMm <= 0)
|
||||
{
|
||||
result.AddError("Layout: HeightMm muss größer als 0 sein.");
|
||||
}
|
||||
|
||||
if (layout.Dpi <= 0)
|
||||
{
|
||||
result.AddError("Layout: Dpi muss größer als 0 sein.");
|
||||
}
|
||||
|
||||
if (layout.MarginMm < 0)
|
||||
{
|
||||
result.AddError("Layout: MarginMm darf nicht negativ sein.");
|
||||
}
|
||||
|
||||
if (!Orientations.Contains(layout.Orientation))
|
||||
{
|
||||
result.AddError("Layout: Orientation muss 'landscape' oder 'portrait' sein.");
|
||||
}
|
||||
|
||||
if (layout.Elements is null)
|
||||
{
|
||||
result.AddError("Layout: Elements darf nicht null sein.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateElements(LabelLayout layout, LayoutValidationResult result)
|
||||
{
|
||||
if (layout.Elements is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < layout.Elements.Count; i++)
|
||||
{
|
||||
var index = i + 1;
|
||||
var element = layout.Elements[i];
|
||||
switch (element)
|
||||
{
|
||||
case null:
|
||||
result.AddError($"Element {index}: Element darf nicht null sein.");
|
||||
break;
|
||||
case TextElement text:
|
||||
ValidateTextElement(layout, text, index, result);
|
||||
break;
|
||||
case LineElement line:
|
||||
ValidateLineElement(layout, line, index, result);
|
||||
break;
|
||||
case RectangleElement rectangle:
|
||||
ValidateRectangleElement(layout, rectangle, index, result);
|
||||
break;
|
||||
case QrCodeElement qrCode:
|
||||
ValidateQrCodeElement(layout, qrCode, index, result);
|
||||
break;
|
||||
default:
|
||||
result.AddError($"Element {index}: Unbekannter Elementtyp '{element.Type}'.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateTextElement(LabelLayout layout, TextElement element, int index, LayoutValidationResult result)
|
||||
{
|
||||
if (!IsBoxInside(layout, element.XMm, element.YMm, element.WidthMm, element.HeightMm))
|
||||
{
|
||||
result.AddError($"Element {index}: Textfeld liegt außerhalb des Etiketts.");
|
||||
}
|
||||
|
||||
if (element.WidthMm <= 0)
|
||||
{
|
||||
result.AddError($"Element {index}: Textfeld hat ungültige Breite.");
|
||||
}
|
||||
|
||||
if (element.HeightMm <= 0)
|
||||
{
|
||||
result.AddError($"Element {index}: Textfeld hat ungültige Höhe.");
|
||||
}
|
||||
|
||||
if (element.FontSizePt <= 0)
|
||||
{
|
||||
result.AddError($"Element {index}: Textfeld hat ungültige Schriftgröße.");
|
||||
}
|
||||
|
||||
if (element.MinFontSizePt <= 0)
|
||||
{
|
||||
result.AddError($"Element {index}: Textfeld hat ungültige Mindestschriftgröße.");
|
||||
}
|
||||
|
||||
if (element.MinFontSizePt > element.FontSizePt)
|
||||
{
|
||||
result.AddError($"Element {index}: MinFontSizePt darf nicht größer als FontSizePt sein.");
|
||||
}
|
||||
|
||||
if (!HorizontalAlignments.Contains(element.HorizontalAlign))
|
||||
{
|
||||
result.AddError($"Element {index}: HorizontalAlign muss left, center oder right sein.");
|
||||
}
|
||||
|
||||
if (!VerticalAlignments.Contains(element.VerticalAlign))
|
||||
{
|
||||
result.AddError($"Element {index}: VerticalAlign muss top, middle oder bottom sein.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(element.Value))
|
||||
{
|
||||
result.AddError($"Element {index}: Textfeld-Value darf nicht leer sein.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateLineElement(LabelLayout layout, LineElement element, int index, LayoutValidationResult result)
|
||||
{
|
||||
if (!IsPointInside(layout, element.X1Mm, element.Y1Mm) || !IsPointInside(layout, element.X2Mm, element.Y2Mm))
|
||||
{
|
||||
result.AddError($"Element {index}: Linie liegt außerhalb des Etiketts.");
|
||||
}
|
||||
|
||||
if (element.StrokeWidthMm <= 0)
|
||||
{
|
||||
result.AddError($"Element {index}: Linie hat ungültige Strichstärke.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateRectangleElement(LabelLayout layout, RectangleElement element, int index, LayoutValidationResult result)
|
||||
{
|
||||
if (!IsBoxInside(layout, element.XMm, element.YMm, element.WidthMm, element.HeightMm))
|
||||
{
|
||||
result.AddError($"Element {index}: Rechteck liegt außerhalb des Etiketts.");
|
||||
}
|
||||
|
||||
if (element.WidthMm <= 0)
|
||||
{
|
||||
result.AddError($"Element {index}: Rechteck hat ungültige Breite.");
|
||||
}
|
||||
|
||||
if (element.HeightMm <= 0)
|
||||
{
|
||||
result.AddError($"Element {index}: Rechteck hat ungültige Höhe.");
|
||||
}
|
||||
|
||||
if (element.StrokeWidthMm <= 0)
|
||||
{
|
||||
result.AddError($"Element {index}: Rechteck hat ungültige Strichstärke.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateQrCodeElement(LabelLayout layout, QrCodeElement element, int index, LayoutValidationResult result)
|
||||
{
|
||||
if (!IsBoxInside(layout, element.XMm, element.YMm, element.SizeMm, element.SizeMm))
|
||||
{
|
||||
result.AddError($"Element {index}: QR-Code liegt außerhalb des Etiketts.");
|
||||
}
|
||||
|
||||
if (element.SizeMm <= 0)
|
||||
{
|
||||
result.AddError($"Element {index}: QR-Code hat ungültige Größe.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(element.Value))
|
||||
{
|
||||
result.AddError($"Element {index}: QR-Code-Value darf nicht leer sein.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsPointInside(LabelLayout layout, double xMm, double yMm)
|
||||
{
|
||||
return xMm >= 0 && yMm >= 0 && xMm <= layout.WidthMm && yMm <= layout.HeightMm;
|
||||
}
|
||||
|
||||
private static bool IsBoxInside(LabelLayout layout, double xMm, double yMm, double widthMm, double heightMm)
|
||||
{
|
||||
return xMm >= 0
|
||||
&& yMm >= 0
|
||||
&& xMm + Math.Max(0, widthMm) <= layout.WidthMm
|
||||
&& yMm + Math.Max(0, heightMm) <= layout.HeightMm;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
namespace LabelPrintAgent.Layout;
|
||||
|
||||
public sealed class LineElement : LayoutElement
|
||||
{
|
||||
public LineElement()
|
||||
{
|
||||
Type = "line";
|
||||
}
|
||||
|
||||
public double X1Mm { get; set; }
|
||||
public double Y1Mm { get; set; }
|
||||
public double X2Mm { get; set; }
|
||||
public double Y2Mm { get; set; }
|
||||
public double StrokeWidthMm { get; set; } = 0.3;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace LabelPrintAgent.Layout;
|
||||
|
||||
public sealed class QrCodeElement : LayoutElement
|
||||
{
|
||||
public QrCodeElement()
|
||||
{
|
||||
Type = "qr";
|
||||
}
|
||||
|
||||
public double SizeMm { get; set; }
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
namespace LabelPrintAgent.Layout;
|
||||
|
||||
public sealed class RectangleElement : LayoutElement
|
||||
{
|
||||
public RectangleElement()
|
||||
{
|
||||
Type = "rectangle";
|
||||
}
|
||||
|
||||
public double WidthMm { get; set; }
|
||||
public double HeightMm { get; set; }
|
||||
public double StrokeWidthMm { get; set; } = 0.3;
|
||||
public bool Filled { get; set; }
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
namespace LabelPrintAgent.Layout;
|
||||
|
||||
public sealed class TextElement : LayoutElement
|
||||
{
|
||||
public TextElement()
|
||||
{
|
||||
Type = "text";
|
||||
}
|
||||
|
||||
public double WidthMm { get; set; }
|
||||
public double HeightMm { get; set; }
|
||||
public string Value { get; set; } = string.Empty;
|
||||
public string FontFamily { get; set; } = "Arial";
|
||||
public double FontSizePt { get; set; } = 9;
|
||||
public double MinFontSizePt { get; set; } = 6;
|
||||
public bool AutoShrink { get; set; } = true;
|
||||
public bool Bold { get; set; }
|
||||
public bool Italic { get; set; }
|
||||
public bool Underline { get; set; }
|
||||
public string HorizontalAlign { get; set; } = "left";
|
||||
public string VerticalAlign { get; set; } = "top";
|
||||
public bool Multiline { get; set; } = true;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
namespace LabelPrintAgent.Numbering;
|
||||
|
||||
public sealed class NumberReservationResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public long? ReservedNumber { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
public static NumberReservationResult Ok(long reservedNumber)
|
||||
{
|
||||
return new NumberReservationResult
|
||||
{
|
||||
Success = true,
|
||||
ReservedNumber = reservedNumber
|
||||
};
|
||||
}
|
||||
|
||||
public static NumberReservationResult Fail(string errorMessage)
|
||||
{
|
||||
return new NumberReservationResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = errorMessage
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
using LabelPrintAgent.Configuration;
|
||||
using LabelPrintAgent.Database;
|
||||
using LabelPrintAgent.Rendering;
|
||||
using Serilog;
|
||||
|
||||
namespace LabelPrintAgent.Numbering;
|
||||
|
||||
public sealed class NumberReservationService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public NumberReservationService(HttpClient? httpClient = null)
|
||||
{
|
||||
_httpClient = httpClient ?? new HttpClient();
|
||||
}
|
||||
|
||||
public async Task<NumberReservationResult> ReserveNumberAsync(
|
||||
PrintJob job,
|
||||
LabelTemplateConfig template,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (job.ReservedNumber.HasValue)
|
||||
{
|
||||
return NumberReservationResult.Ok(job.ReservedNumber.Value);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(template.GetNumberUrl))
|
||||
{
|
||||
return NumberReservationResult.Fail("GetNumberUrl ist nicht konfiguriert.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var payload = LabelPayloadBuilder.Build(job, template);
|
||||
var url = UrlTemplateFormatter.Format(template.GetNumberUrl, payload);
|
||||
using var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
var body = (await response.Content.ReadAsStringAsync(cancellationToken)).Trim();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return NumberReservationResult.Fail($"Nummernserver antwortete mit HTTP {(int)response.StatusCode}: {body}");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
return NumberReservationResult.Fail("Nummernserver hat keine Nummer zurückgegeben.");
|
||||
}
|
||||
|
||||
if (!long.TryParse(body, out var reservedNumber))
|
||||
{
|
||||
return NumberReservationResult.Fail($"Nummernserver-Antwort ist keine gültige Nummer: '{body}'.");
|
||||
}
|
||||
|
||||
return NumberReservationResult.Ok(reservedNumber);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Could not reserve number for job {JobId}", job.Id);
|
||||
return NumberReservationResult.Fail(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NumberReservationResult> ConfirmPrintedAsync(
|
||||
LabelTemplateConfig template,
|
||||
Dictionary<string, object?> payload,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(template.NumberPrintedUrl))
|
||||
{
|
||||
return NumberReservationResult.Ok(Convert.ToInt64(payload["reservedNumber"]));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var url = UrlTemplateFormatter.Format(template.NumberPrintedUrl, payload);
|
||||
using var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
var body = (await response.Content.ReadAsStringAsync(cancellationToken)).Trim();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return NumberReservationResult.Fail($"Nummernserver-Bestätigung fehlgeschlagen mit HTTP {(int)response.StatusCode}: {body}");
|
||||
}
|
||||
|
||||
return NumberReservationResult.Ok(Convert.ToInt64(payload["reservedNumber"]));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Could not confirm printed number");
|
||||
return NumberReservationResult.Fail(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace LabelPrintAgent.Numbering;
|
||||
|
||||
public static partial class UrlTemplateFormatter
|
||||
{
|
||||
public static string Format(string template, Dictionary<string, object?> payload)
|
||||
{
|
||||
return PlaceholderRegex().Replace(template, match =>
|
||||
{
|
||||
var field = match.Groups["field"].Value;
|
||||
var format = match.Groups["format"].Success ? match.Groups["format"].Value : null;
|
||||
if (!payload.TryGetValue(field, out var value) || value is null)
|
||||
{
|
||||
return match.Value;
|
||||
}
|
||||
|
||||
var formatted = string.IsNullOrWhiteSpace(format)
|
||||
? Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty
|
||||
: string.Format(CultureInfo.InvariantCulture, "{0:" + format + "}", value);
|
||||
return Uri.EscapeDataString(formatted);
|
||||
});
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\{(?<field>[A-Za-z0-9_]+)(:(?<format>[^}]+))?\}")]
|
||||
private static partial Regex PlaceholderRegex();
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
using LabelPrintAgent.Configuration;
|
||||
using LabelPrintAgent.Database;
|
||||
|
||||
namespace LabelPrintAgent.Rendering;
|
||||
|
||||
public static class LabelPayloadBuilder
|
||||
{
|
||||
public static Dictionary<string, object?> Build(PrintJob job, LabelTemplateConfig template, long? dummyNumberWhenMissing = null)
|
||||
{
|
||||
var payload = TemplateFormatter.FromJson(job.PayloadJson);
|
||||
var reservedNumber = job.ReservedNumber ?? dummyNumberWhenMissing;
|
||||
|
||||
payload["jobId"] = job.Id;
|
||||
payload["barcodeTemplateId"] = job.BarcodeTemplateId;
|
||||
|
||||
if (reservedNumber.HasValue)
|
||||
{
|
||||
payload["reservedNumber"] = reservedNumber.Value;
|
||||
if (!string.IsNullOrWhiteSpace(template.ReservedNumberPayloadKey))
|
||||
{
|
||||
payload[template.ReservedNumberPayloadKey] = reservedNumber.Value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(template.QrTemplate))
|
||||
{
|
||||
payload["qr"] = TemplateFormatter.Format(template.QrTemplate, payload);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Drawing.Text;
|
||||
using LabelPrintAgent.Layout;
|
||||
using QRCoder;
|
||||
|
||||
namespace LabelPrintAgent.Rendering;
|
||||
|
||||
public sealed class LabelRenderer
|
||||
{
|
||||
public RenderResult Render(LabelLayout layout, Dictionary<string, object?> payload)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
var widthPx = MmToPixelConverter.MmToPx(layout.WidthMm, layout.Dpi);
|
||||
var heightPx = MmToPixelConverter.MmToPx(layout.HeightMm, layout.Dpi);
|
||||
var bitmap = new Bitmap(widthPx, heightPx, PixelFormat.Format32bppArgb);
|
||||
bitmap.SetResolution(layout.Dpi, layout.Dpi);
|
||||
|
||||
using var graphics = Graphics.FromImage(bitmap);
|
||||
graphics.Clear(Color.White);
|
||||
graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
graphics.TextRenderingHint = TextRenderingHint.ClearTypeGridFit;
|
||||
graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
|
||||
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
|
||||
|
||||
foreach (var element in layout.Elements)
|
||||
{
|
||||
switch (element)
|
||||
{
|
||||
case TextElement text:
|
||||
DrawText(graphics, text, payload, layout.Dpi, warnings);
|
||||
break;
|
||||
case LineElement line:
|
||||
DrawLine(graphics, line, layout.Dpi);
|
||||
break;
|
||||
case RectangleElement rectangle:
|
||||
DrawRectangle(graphics, rectangle, layout.Dpi);
|
||||
break;
|
||||
case QrCodeElement qrCode:
|
||||
DrawQrCode(graphics, qrCode, payload, layout.Dpi, warnings);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new RenderResult(bitmap, warnings);
|
||||
}
|
||||
|
||||
private static void DrawText(Graphics graphics, TextElement element, Dictionary<string, object?> payload, int dpi, List<string> warnings)
|
||||
{
|
||||
var text = TemplateFormatter.Format(element.Value, payload, warnings).Replace("\\n", Environment.NewLine);
|
||||
var bounds = new RectangleF(
|
||||
MmToPixelConverter.MmToPxFloat(element.XMm, dpi),
|
||||
MmToPixelConverter.MmToPxFloat(element.YMm, dpi),
|
||||
MmToPixelConverter.MmToPxFloat(element.WidthMm, dpi),
|
||||
MmToPixelConverter.MmToPxFloat(element.HeightMm, dpi));
|
||||
|
||||
using var format = CreateStringFormat(element);
|
||||
var style = CreateFontStyle(element);
|
||||
using var clipRegion = graphics.Clip.Clone();
|
||||
graphics.SetClip(bounds);
|
||||
|
||||
Font? font = null;
|
||||
try
|
||||
{
|
||||
font = CreateFittingFont(graphics, element, text, bounds, format, style, warnings);
|
||||
using var brush = new SolidBrush(Color.Black);
|
||||
graphics.DrawString(text, font, brush, bounds, format);
|
||||
}
|
||||
finally
|
||||
{
|
||||
graphics.Clip = clipRegion;
|
||||
font?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static Font CreateFittingFont(Graphics graphics, TextElement element, string text, RectangleF bounds, StringFormat format, FontStyle style, List<string> warnings)
|
||||
{
|
||||
var fontSize = element.FontSizePt;
|
||||
Font? currentFont = null;
|
||||
|
||||
while (true)
|
||||
{
|
||||
currentFont?.Dispose();
|
||||
currentFont = new Font(element.FontFamily, (float)fontSize, style, GraphicsUnit.Point);
|
||||
var measured = graphics.MeasureString(text, currentFont, bounds.Size, format);
|
||||
var fits = measured.Width <= bounds.Width + 1 && measured.Height <= bounds.Height + 1;
|
||||
if (fits)
|
||||
{
|
||||
return currentFont;
|
||||
}
|
||||
|
||||
if (!element.AutoShrink || fontSize <= element.MinFontSizePt)
|
||||
{
|
||||
warnings.Add($"Text '{Shorten(text)}' passt nicht vollständig in den Elementbereich.");
|
||||
return currentFont;
|
||||
}
|
||||
|
||||
fontSize = Math.Max(element.MinFontSizePt, fontSize - 0.5d);
|
||||
}
|
||||
}
|
||||
|
||||
private static StringFormat CreateStringFormat(TextElement element)
|
||||
{
|
||||
return new StringFormat
|
||||
{
|
||||
Alignment = element.HorizontalAlign.ToLowerInvariant() switch
|
||||
{
|
||||
"center" => StringAlignment.Center,
|
||||
"right" => StringAlignment.Far,
|
||||
_ => StringAlignment.Near
|
||||
},
|
||||
LineAlignment = element.VerticalAlign.ToLowerInvariant() switch
|
||||
{
|
||||
"middle" => StringAlignment.Center,
|
||||
"bottom" => StringAlignment.Far,
|
||||
_ => StringAlignment.Near
|
||||
},
|
||||
FormatFlags = element.Multiline ? 0 : StringFormatFlags.NoWrap,
|
||||
Trimming = StringTrimming.EllipsisCharacter
|
||||
};
|
||||
}
|
||||
|
||||
private static FontStyle CreateFontStyle(TextElement element)
|
||||
{
|
||||
var style = FontStyle.Regular;
|
||||
if (element.Bold) style |= FontStyle.Bold;
|
||||
if (element.Italic) style |= FontStyle.Italic;
|
||||
if (element.Underline) style |= FontStyle.Underline;
|
||||
return style;
|
||||
}
|
||||
|
||||
private static void DrawLine(Graphics graphics, LineElement element, int dpi)
|
||||
{
|
||||
using var pen = new Pen(Color.Black, Math.Max(1f, MmToPixelConverter.MmToPxFloat(element.StrokeWidthMm, dpi)));
|
||||
graphics.DrawLine(
|
||||
pen,
|
||||
MmToPixelConverter.MmToPxFloat(element.X1Mm, dpi),
|
||||
MmToPixelConverter.MmToPxFloat(element.Y1Mm, dpi),
|
||||
MmToPixelConverter.MmToPxFloat(element.X2Mm, dpi),
|
||||
MmToPixelConverter.MmToPxFloat(element.Y2Mm, dpi));
|
||||
}
|
||||
|
||||
private static void DrawRectangle(Graphics graphics, RectangleElement element, int dpi)
|
||||
{
|
||||
var rectangle = new RectangleF(
|
||||
MmToPixelConverter.MmToPxFloat(element.XMm, dpi),
|
||||
MmToPixelConverter.MmToPxFloat(element.YMm, dpi),
|
||||
MmToPixelConverter.MmToPxFloat(element.WidthMm, dpi),
|
||||
MmToPixelConverter.MmToPxFloat(element.HeightMm, dpi));
|
||||
|
||||
if (element.Filled)
|
||||
{
|
||||
using var brush = new SolidBrush(Color.Black);
|
||||
graphics.FillRectangle(brush, rectangle);
|
||||
return;
|
||||
}
|
||||
|
||||
using var pen = new Pen(Color.Black, Math.Max(1f, MmToPixelConverter.MmToPxFloat(element.StrokeWidthMm, dpi)));
|
||||
graphics.DrawRectangle(pen, rectangle.X, rectangle.Y, rectangle.Width, rectangle.Height);
|
||||
}
|
||||
|
||||
private static void DrawQrCode(Graphics graphics, QrCodeElement element, Dictionary<string, object?> payload, int dpi, List<string> warnings)
|
||||
{
|
||||
var value = TemplateFormatter.Format(element.Value, payload, warnings);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
warnings.Add("QR-Code wurde nicht gerendert, weil der Wert leer ist.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var generator = new QRCodeGenerator();
|
||||
using var qrData = generator.CreateQrCode(value, QRCodeGenerator.ECCLevel.Q);
|
||||
using var qrCode = new QRCode(qrData);
|
||||
using var qrBitmap = qrCode.GetGraphic(12, Color.Black, Color.White, drawQuietZones: true);
|
||||
|
||||
graphics.DrawImage(
|
||||
qrBitmap,
|
||||
MmToPixelConverter.MmToPxFloat(element.XMm, dpi),
|
||||
MmToPixelConverter.MmToPxFloat(element.YMm, dpi),
|
||||
MmToPixelConverter.MmToPxFloat(element.SizeMm, dpi),
|
||||
MmToPixelConverter.MmToPxFloat(element.SizeMm, dpi));
|
||||
}
|
||||
|
||||
private static string Shorten(string value)
|
||||
{
|
||||
return value.Length <= 40 ? value : value[..37] + "...";
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
namespace LabelPrintAgent.Rendering;
|
||||
|
||||
public static class MmToPixelConverter
|
||||
{
|
||||
public static int MmToPx(double mm, int dpi)
|
||||
{
|
||||
return (int)Math.Round(mm / 25.4d * dpi);
|
||||
}
|
||||
|
||||
public static float MmToPxFloat(double mm, int dpi)
|
||||
{
|
||||
return (float)(mm / 25.4d * dpi);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
namespace LabelPrintAgent.Rendering;
|
||||
|
||||
public static class PreviewDataProvider
|
||||
{
|
||||
public static Dictionary<string, object?> CreatePayload()
|
||||
{
|
||||
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["titel"] = "Beleg privat",
|
||||
["beschreibung"] = "Dokument 2026-000123",
|
||||
["nummer"] = "2026-000123",
|
||||
["datum"] = new DateTime(2026, 5, 7),
|
||||
["menge"] = 42.5m,
|
||||
["qr"] = "bjoernprivat 0000123"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
using System.Drawing;
|
||||
|
||||
namespace LabelPrintAgent.Rendering;
|
||||
|
||||
public sealed class RenderResult : IDisposable
|
||||
{
|
||||
public RenderResult(Bitmap image, List<string> warnings)
|
||||
{
|
||||
Image = image;
|
||||
WidthPx = image.Width;
|
||||
HeightPx = image.Height;
|
||||
Warnings = warnings;
|
||||
}
|
||||
|
||||
public Bitmap Image { get; }
|
||||
public int WidthPx { get; }
|
||||
public int HeightPx { get; }
|
||||
public List<string> Warnings { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Image.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace LabelPrintAgent.Rendering;
|
||||
|
||||
public static partial class TemplateFormatter
|
||||
{
|
||||
public static string Format(string template, Dictionary<string, object?> payload)
|
||||
{
|
||||
return Format(template, payload, warnings: null);
|
||||
}
|
||||
|
||||
public static string Format(string template, Dictionary<string, object?> payload, List<string>? warnings)
|
||||
{
|
||||
return PlaceholderRegex().Replace(template, match =>
|
||||
{
|
||||
var field = match.Groups["field"].Value;
|
||||
var format = match.Groups["format"].Success ? match.Groups["format"].Value : null;
|
||||
|
||||
if (!payload.TryGetValue(field, out var rawValue) || rawValue is null)
|
||||
{
|
||||
warnings?.Add($"Platzhalter '{field}' wurde nicht in den Vorschau-Daten gefunden.");
|
||||
return match.Value;
|
||||
}
|
||||
|
||||
var value = NormalizeValue(rawValue);
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(format))
|
||||
{
|
||||
return Convert.ToString(value, CultureInfo.CurrentCulture) ?? string.Empty;
|
||||
}
|
||||
|
||||
return string.Format(CultureInfo.CurrentCulture, "{0:" + format + "}", value);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
warnings?.Add($"Platzhalter '{field}' hat ein ungültiges Format '{format}'.");
|
||||
return Convert.ToString(value, CultureInfo.CurrentCulture) ?? string.Empty;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static Dictionary<string, object?> FromJson(string payloadJson)
|
||||
{
|
||||
using var document = JsonDocument.Parse(payloadJson);
|
||||
return FromJsonElement(document.RootElement);
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> FromJsonElement(JsonElement element)
|
||||
{
|
||||
var payload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return payload;
|
||||
}
|
||||
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
payload[property.Name] = ConvertJsonValue(property.Value);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private static object? ConvertJsonValue(JsonElement value)
|
||||
{
|
||||
return value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => ParseStringValue(value.GetString()),
|
||||
JsonValueKind.Number when value.TryGetInt64(out var longValue) => longValue,
|
||||
JsonValueKind.Number when value.TryGetDecimal(out var decimalValue) => decimalValue,
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
_ => value.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static object? ParseStringValue(string? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var dateTime))
|
||||
{
|
||||
return dateTime;
|
||||
}
|
||||
|
||||
if (decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out var decimalValue))
|
||||
{
|
||||
return decimalValue;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static object NormalizeValue(object value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
JsonElement jsonElement => ConvertJsonValue(jsonElement) ?? string.Empty,
|
||||
string stringValue => ParseStringValue(stringValue) ?? string.Empty,
|
||||
_ => value
|
||||
};
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\{(?<field>[A-Za-z0-9_]+)(:(?<format>[^}]+))?\}")]
|
||||
private static partial Regex PlaceholderRegex();
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
using LabelPrintAgent.Configuration;
|
||||
using LabelPrintAgent.Database;
|
||||
using LabelPrintAgent.Layout;
|
||||
using LabelPrintAgent.Numbering;
|
||||
using LabelPrintAgent.Printing;
|
||||
using LabelPrintAgent.Rendering;
|
||||
using Serilog;
|
||||
|
||||
namespace LabelPrintAgent.Worker;
|
||||
|
||||
public sealed class PrintJobProcessor
|
||||
{
|
||||
private readonly MySqlLabelRepository _repository;
|
||||
private readonly PrinterService _printerService;
|
||||
private readonly NumberReservationService _numberReservationService;
|
||||
private readonly LabelRenderer _renderer;
|
||||
private readonly LayoutValidator _layoutValidator = new();
|
||||
|
||||
public PrintJobProcessor(
|
||||
MySqlLabelRepository repository,
|
||||
PrinterService printerService,
|
||||
NumberReservationService numberReservationService,
|
||||
LabelRenderer renderer)
|
||||
{
|
||||
_repository = repository;
|
||||
_printerService = printerService;
|
||||
_numberReservationService = numberReservationService;
|
||||
_renderer = renderer;
|
||||
}
|
||||
|
||||
public async Task<PrintResult> PrintJobAsync(
|
||||
PrintJob job,
|
||||
LabelTemplateConfig template,
|
||||
string printerName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (job.BarcodeTemplateId != template.BarcodeTemplateId)
|
||||
{
|
||||
throw new InvalidOperationException($"Job {job.Id} passt nicht zu LabelTemplate {template.BarcodeTemplateId}.");
|
||||
}
|
||||
|
||||
var validation = _layoutValidator.Validate(template.Layout);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
throw new InvalidOperationException(string.Join(Environment.NewLine, validation.Errors));
|
||||
}
|
||||
|
||||
if (!job.ReservedNumber.HasValue)
|
||||
{
|
||||
var reservation = await _numberReservationService.ReserveNumberAsync(job, template, cancellationToken);
|
||||
if (!reservation.Success || !reservation.ReservedNumber.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException(reservation.ErrorMessage ?? "Nummer konnte nicht reserviert werden.");
|
||||
}
|
||||
|
||||
await _repository.MarkNumberReservedAsync(job.Id, reservation.ReservedNumber.Value, cancellationToken);
|
||||
job.ReservedNumber = reservation.ReservedNumber.Value;
|
||||
}
|
||||
|
||||
var payload = LabelPayloadBuilder.Build(job, template);
|
||||
using var renderResult = _renderer.Render(template.Layout, payload);
|
||||
|
||||
if (!await _repository.MarkAsPrintingAsync(job.Id, cancellationToken))
|
||||
{
|
||||
throw new InvalidOperationException($"Job {job.Id} konnte nicht auf printing gesetzt werden.");
|
||||
}
|
||||
|
||||
var printResult = await _printerService.PrintAsync(
|
||||
renderResult.Image,
|
||||
printerName,
|
||||
template.Layout.WidthMm,
|
||||
template.Layout.HeightMm,
|
||||
cancellationToken);
|
||||
|
||||
if (!printResult.Success)
|
||||
{
|
||||
throw new InvalidOperationException(printResult.ErrorMessage ?? "Druck fehlgeschlagen.");
|
||||
}
|
||||
|
||||
var confirmResult = await _numberReservationService.ConfirmPrintedAsync(template, payload, cancellationToken);
|
||||
if (!confirmResult.Success)
|
||||
{
|
||||
throw new InvalidOperationException(confirmResult.ErrorMessage ?? "Nummer konnte nicht als gedruckt bestätigt werden.");
|
||||
}
|
||||
|
||||
await _repository.MarkAsPrintedAsync(job.Id, printerName, cancellationToken);
|
||||
return printResult;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Could not print job {JobId}", job.Id);
|
||||
await _repository.MarkAsErrorAsync(job.Id, ex.Message, cancellationToken);
|
||||
return PrintResult.Fail(printerName, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public RenderResult RenderJobPreview(PrintJob job, LabelTemplateConfig template, long dummyNumber = 123)
|
||||
{
|
||||
if (job.BarcodeTemplateId != template.BarcodeTemplateId)
|
||||
{
|
||||
throw new InvalidOperationException($"Job {job.Id} passt nicht zu LabelTemplate {template.BarcodeTemplateId}.");
|
||||
}
|
||||
|
||||
var validation = _layoutValidator.Validate(template.Layout);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
throw new InvalidOperationException(string.Join(Environment.NewLine, validation.Errors));
|
||||
}
|
||||
|
||||
var payload = LabelPayloadBuilder.Build(job, template, dummyNumberWhenMissing: dummyNumber);
|
||||
return _renderer.Render(template.Layout, payload);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user