Simplify agent to backend-driven printing

This commit is contained in:
2026-05-07 16:52:05 +02:00
parent 85a2766256
commit 5a45aa0a93
35 changed files with 473 additions and 1973 deletions
+115 -109
View File
@@ -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.
-40
View File
@@ -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'
);
+5 -1
View File
@@ -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)
{
+75 -396
View File
@@ -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);
}
}
-18
View File
@@ -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" />
-12
View File
@@ -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;
}
}
-15
View File
@@ -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; }
}
-23
View File
@@ -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);
}
}