Switch queue to local label templates

This commit is contained in:
2026-05-07 15:16:24 +02:00
parent 61ea6f7c96
commit 85a2766256
19 changed files with 1021 additions and 520 deletions
+108 -74
View File
@@ -1,109 +1,143 @@
# LabelPrintAgent
Windows-Tray-Anwendung für den späteren Etikettendruck aus JSON-Layouts.
Windows-Tray-Anwendung zum Rendern und Drucken von Etiketten über installierte Windows-Drucker, z. B. einen Dymo LabelWriter.
## Etappe 1
## Aktueller Stand
Die erste Etappe enthält das lauffähige Grundgerüst:
Der Agent arbeitet jetzt mit lokalen `LabelTemplates` in `C:\ProgramData\LabelPrintAgent\settings.json`.
- .NET-9-Windows-Forms-Projekt
- Tray-Icon mit Kontextmenü
- Einstellungsdialog mit Tabs:
- Allgemein
- Datenbank
- Drucker
- Layout
- Fehlerhafte Druckaufträge
- lokale Konfiguration unter `C:\ProgramData\LabelPrintAgent\settings.json`
- automatische Anlage von `C:\ProgramData\LabelPrintAgent`
- Layout-Ordner unter `C:\ProgramData\LabelPrintAgent\layouts`
- Log-Ordner unter `C:\ProgramData\LabelPrintAgent\logs`
- verschlüsselte Passwortspeicherung per Windows DPAPI
- Auflistung installierter Windows-Drucker
- Beispiel-Layout `dymo_57x32_standard`
- Layout-JSON laden und speichern
Ein Queue-Job enthält nur noch:
## Etappe 2
- `barcode_template_id`
- `payload_json`
Die zweite Etappe ergänzt das Layoutmodell und die JSON-Validierung:
Die alte Zuordnung über `layout_key` gibt es nicht mehr. Stattdessen gilt:
- typisierte Layoutklassen für Text, Linie, Rechteck und QR-Code
- JSON-Deserialisierung anhand der Element-Eigenschaft `type`
- Validierung des Layoutkopfs und aller Elemente
- Sammlung aller Validierungsfehler statt Abbruch beim ersten Fehler
- Anzeige der Validierungsfehler im Layout-Tab
- Speichern nur bei gültigem Layout
- formatierte Speicherung des Layout-JSON
```text
label_print_queue.barcode_template_id
-> lokales LabelTemplate im LabelPrintAgent
-> darin enthaltenes Layout rendern
```
## Etappe 3
Die Tabelle `barcode_templates` wird nicht verändert. Sie bleibt nur die fachliche Referenz für die `barcode_template_id`.
Die dritte Etappe ergänzt die Rendering-Engine und die Vorschau im Layout-Tab:
## LabelTemplates
- Bitmap-Rendering mit 300 dpi
- Text, Linien, Rechtecke und QR-Codes
- Platzhalterersetzung wie `{titel}`, `{datum:dd.MM.yyyy}` und `{menge:0.00}`
- automatische Beispiel-Daten für Vorschauen
- AutoShrink für Textfelder
- Warnungen, wenn Platzhalter fehlen oder Text nicht vollständig passt
Ein lokales LabelTemplate enthält:
Eine Vorschau erzeugst du im Tab `Layout` mit dem Button `Vorschau`. Zuerst wird das JSON validiert, dann wird das Etikett mit folgenden Beispiel-Daten gerendert:
- `barcodeTemplateId`
- `name`
- `getNumberUrl`
- `numberPrintedUrl`
- `reservedNumberPayloadKey`
- `qrTemplate`
- `layout`
Das Layout liegt vollständig eingebettet im Template. Es gibt keine separate Layout-Datei und keinen `layoutKey` mehr.
## Nummernserver
`getNumberUrl` wird per HTTP GET aufgerufen und gibt Plain Text zurück, z. B.:
```text
123
```
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
{
"titel": "Beleg privat",
"beschreibung": "Dokument 2026-000123",
"nummer": "2026-000123",
"datum": "2026-05-07",
"menge": 42.5,
"reservedNumber": 123,
"nummer": 123,
"qr": "bjoernprivat 0000123"
}
```
## Etappe 4
## Oberfläche
Die vierte Etappe ergänzt den Testdruck über installierte Windows-Drucker:
Im Einstellungsdialog gibt es den Tab `Label-Templates`.
- Druckerliste mit Standarddrucker-Erkennung
- Prüfung, ob der konfigurierte Drucker noch vorhanden ist
- Testdruck im Tab `Drucker`
- Testdruck im Tab `Layout` direkt aus dem aktuell bearbeiteten JSON
- Ausgabe des gerenderten Bitmaps über `System.Drawing.Printing.PrintDocument`
- benutzerdefiniertes Papierformat aus dem Layout, beim Beispiel `57 x 32 mm`
- keine zusätzlichen Druckränder; der Layout-Rand steckt bereits im gerenderten Bitmap
Dort kannst du:
Für den Dymo LabelWriter muss der Drucker in Windows bereits als normaler Windows-Drucker eingerichtet sein. Stelle im Dymo-Treiber möglichst ebenfalls das Etikettenformat `57 x 32 mm` bzw. das passende Dymo-Label ein. Die App sendet ein fertiges Bild an den Windows-Drucker; es wird kein ZPL, EPL oder TSPL verwendet.
- Templates anlegen
- Templates löschen
- `barcodeTemplateId` bearbeiten
- Nummernserver-URLs bearbeiten
- QR-Template bearbeiten
- eingebettetes Layout-JSON bearbeiten
- validieren
- Vorschau erzeugen
- Testdruck auslösen
Einen Testdruck machst du so:
Die Vorschau verwendet eine Dummy-Nummer `123` und reserviert keine Nummer beim Nummernserver.
## Dymo-Testdruck
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.
Testdruck:
1. Im Tab `Drucker` den Dymo LabelWriter auswählen.
2. `Speichern` klicken.
3. `Testdruck` klicken, um das ausgewählte Beispiel-Layout zu drucken.
4. Alternativ im Tab `Layout` das JSON bearbeiten und dort `Testdruck` klicken.
3. Im Tab `Label-Templates` ein Template auswählen.
4. `Vorschau` prüfen.
5. `Testdruck` klicken.
Typische Fehler:
- Falscher Drucker gewählt: im Tab `Drucker` den Dymo LabelWriter auswählen.
- Falsches Etikettenformat im Treiber: im Windows-Druckertreiber `57 x 32 mm` bzw. das passende Label einstellen.
- Ausdruck zu groß oder zu klein: prüfen, ob Treiber-Skalierung deaktiviert ist und das Layout `57 x 32 mm` verwendet.
- Etikett wird gedreht: im Dymo-Treiber das physische Label `57 x 32 mm` wählen. Die App setzt das Papierformat bereits quer als `57 x 32 mm` und sendet keine zusätzliche Windows-Landscape-Rotation.
- 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.
Noch nicht enthalten sind MySQL-Worker und automatische Datenbankabfrage.
## Datenbank
## Startanleitung
1. `LabelPrintAgent.sln` in Visual Studio oder Rider öffnen.
2. Auf einem Windows-Rechner bauen und starten.
3. Das Tray-Symbol anklicken oder per Kontextmenü `Einstellungen` öffnen.
4. Im Tab `Drucker` einen installierten Windows-Drucker auswählen und speichern.
5. Im Tab `Layout` das Beispiel-Layout prüfen, bearbeiten und speichern.
6. Im Tab `Layout` oder `Drucker` einen Testdruck auslösen.
Beim ersten Start werden die Programmdatenordner und das Beispiel-Layout automatisch angelegt.
## Spätere Etappen
Die SQL-Datei für die spätere Druckwarteschlange liegt bereits unter:
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.
+20 -9
View File
@@ -1,29 +1,40 @@
CREATE TABLE IF NOT EXISTS label_print_queue (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
layout_key VARCHAR(100) NOT NULL,
barcode_template_id INT(11) NOT NULL,
payload_json JSON NOT NULL,
status ENUM('pending','printing','printed','error','deleted') NOT NULL DEFAULT 'pending',
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_layout_key (layout_key)
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
(layout_key, payload_json, status)
(barcode_template_id, payload_json, status)
VALUES
(
'dymo_57x32_standard',
1,
JSON_OBJECT(
'titel', 'Beleg privat',
'beschreibung', 'Dokument 2026-000123',
'nummer', '2026-000123',
'datum', '2026-05-07',
'qr', 'bjoernprivat 0000123'
'beschreibung', 'Tankbeleg',
'datum', '2026-05-07'
),
'pending'
);
+1 -4
View File
@@ -1,5 +1,4 @@
using LabelPrintAgent.Configuration;
using LabelPrintAgent.Layout;
using LabelPrintAgent.Logging;
using LabelPrintAgent.Printing;
using Serilog;
@@ -20,12 +19,10 @@ internal static class Program
var settings = settingsStore.Load();
settings.EnsureDirectories();
LogSetup.Configure(settings.Paths.LogFolder);
LayoutStore.EnsureExampleLayout(settings.Paths.LayoutFolder);
using var layoutStore = new LayoutStore(settings.Paths.LayoutFolder);
var printerService = new PrinterService();
Application.Run(new TrayApplicationContext(settingsStore, layoutStore, printerService));
Application.Run(new TrayApplicationContext(settingsStore, printerService));
}
catch (Exception ex)
{
+229 -175
View File
@@ -1,5 +1,6 @@
using System.Drawing;
using LabelPrintAgent.Configuration;
using LabelPrintAgent.Database;
using LabelPrintAgent.Layout;
using LabelPrintAgent.Printing;
using LabelPrintAgent.Rendering;
@@ -14,9 +15,9 @@ internal sealed class SettingsForm : Form
private const string AutoStartValueName = "LabelPrintAgent";
private readonly SettingsStore _settingsStore;
private readonly LayoutStore _layoutStore;
private readonly PrinterService _printerService;
private readonly LabelRenderer _labelRenderer = new();
private readonly LayoutValidator _layoutValidator = new();
private readonly CheckBox _autoStartCheckBox = new() { Text = "Autostart aktivieren", AutoSize = true };
private readonly NumericUpDown _pollInterval = new() { Minimum = 1, Maximum = 3600, Value = 5, Width = 100 };
@@ -32,31 +33,37 @@ internal sealed class SettingsForm : Form
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 ComboBox _layoutKeys = new() { Width = 280, DropDownStyle = ComboBoxStyle.DropDownList };
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 = 650,
Height = 420
Width = 610,
Height = 260
};
private readonly TextBox _layoutValidationErrors = new()
private readonly TextBox _templateMessages = new()
{
Multiline = true,
ReadOnly = true,
ScrollBars = ScrollBars.Vertical,
Width = 1050,
Width = 930,
Height = 72
};
private readonly PictureBox _layoutPreview = new()
private readonly PictureBox _templatePreview = new()
{
BorderStyle = BorderStyle.FixedSingle,
SizeMode = PictureBoxSizeMode.Zoom,
BackColor = Color.White,
Width = 360,
Height = 220
Width = 280,
Height = 170
};
private readonly DataGridView _errorJobs = new()
@@ -69,22 +76,21 @@ internal sealed class SettingsForm : Form
Dock = DockStyle.Fill
};
public SettingsForm(SettingsStore settingsStore, LayoutStore layoutStore, PrinterService printerService)
public SettingsForm(SettingsStore settingsStore, PrinterService printerService)
{
_settingsStore = settingsStore;
_layoutStore = layoutStore;
_printerService = printerService;
Text = "LabelPrintAgent Einstellungen";
Width = 1160;
Height = 760;
Width = 1240;
Height = 820;
StartPosition = FormStartPosition.CenterScreen;
var tabs = new TabControl { Dock = DockStyle.Fill };
tabs.TabPages.Add(CreateGeneralTab());
tabs.TabPages.Add(CreateDatabaseTab());
tabs.TabPages.Add(CreatePrinterTab());
tabs.TabPages.Add(CreateLayoutTab());
tabs.TabPages.Add(CreateLabelTemplatesTab());
tabs.TabPages.Add(CreateErrorJobsTab());
Controls.Add(tabs);
@@ -92,7 +98,7 @@ internal sealed class SettingsForm : Form
{
LoadSettingsIntoUi();
LoadPrinters();
LoadLayouts();
LoadTemplates();
LoadEmptyErrorJobsTable();
};
}
@@ -116,7 +122,6 @@ internal sealed class SettingsForm : Form
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()));
panel.Controls.Add(LabelAt("Verbindungstest folgt in Etappe 2.", 140, 276, 340));
return new TabPage("Datenbank") { Controls = { panel } };
}
@@ -128,36 +133,49 @@ 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 PrintSelectedLayoutTestAsync()));
panel.Controls.Add(ButtonAt("Testdruck", 420, 150, async (_, _) => await PrintSelectedTemplateAsync()));
return new TabPage("Drucker") { Controls = { panel } };
}
private TabPage CreateLayoutTab()
private TabPage CreateLabelTemplatesTab()
{
var panel = CreatePaddedPanel();
panel.Controls.Add(Row(20, Label("Layout", 80), _layoutKeys));
_layoutKeys.SelectedIndexChanged += (_, _) => LoadSelectedLayoutJson();
_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);
_layoutJson.Left = 20;
_layoutJson.Top = 60;
panel.Controls.Add(_layoutPreview);
_layoutPreview.Left = 700;
_layoutPreview.Top = 60;
panel.Controls.Add(_layoutValidationErrors);
_layoutValidationErrors.Left = 20;
_layoutValidationErrors.Top = 500;
panel.Controls.Add(ButtonAt("Validieren", 20, 595, (_, _) => ValidateLayout(showSuccessMessage: true)));
panel.Controls.Add(ButtonAt("Speichern", 130, 595, (_, _) => SaveLayout()));
panel.Controls.Add(ButtonAt("Vorschau", 240, 595, (_, _) => RenderLayoutPreview()));
panel.Controls.Add(ButtonAt("Testdruck", 350, 595, async (_, _) => await PrintCurrentLayoutTestAsync()));
return new TabPage("Layout") { Controls = { panel } };
_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 umgesetzt ist.", 12, 14, 600));
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);
return page;
@@ -189,66 +207,50 @@ internal sealed class SettingsForm : Form
}
var configuredPrinter = printers.FirstOrDefault(printer => string.Equals(printer.Name, selected, StringComparison.OrdinalIgnoreCase));
if (configuredPrinter is not null)
{
_printer.SelectedItem = configuredPrinter;
return;
}
var defaultPrinter = printers.FirstOrDefault(printer => printer.IsDefault);
if (defaultPrinter is not null)
{
_printer.SelectedItem = defaultPrinter;
return;
}
if (_printer.Items.Count > 0)
_printer.SelectedItem = configuredPrinter ?? printers.FirstOrDefault(printer => printer.IsDefault);
if (_printer.SelectedItem is null && _printer.Items.Count > 0)
{
_printer.SelectedIndex = 0;
}
}
private void LoadLayouts()
private void LoadTemplates()
{
var selected = _layoutKeys.SelectedItem?.ToString();
_layoutKeys.Items.Clear();
foreach (var key in _layoutStore.GetLayoutKeys())
var settings = _settingsStore.Load();
_templateList.Items.Clear();
foreach (var template in settings.LabelTemplates.OrderBy(template => template.BarcodeTemplateId))
{
_layoutKeys.Items.Add(key);
_templateList.Items.Add(template);
}
if (selected is not null && _layoutKeys.Items.Contains(selected))
if (_templateList.Items.Count > 0)
{
_layoutKeys.SelectedItem = selected;
}
else if (_layoutKeys.Items.Count > 0)
{
_layoutKeys.SelectedIndex = 0;
_templateList.SelectedIndex = 0;
}
}
private void LoadSelectedLayoutJson()
private void LoadSelectedTemplateIntoUi()
{
if (_layoutKeys.SelectedItem is not string key)
if (_templateList.SelectedItem is not LabelTemplateConfig template)
{
return;
}
try
{
_layoutJson.Text = _layoutStore.LoadJsonByKey(key);
}
catch (Exception ex)
{
ShowError(ex);
}
_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, string.Empty, string.Empty, 0)
new ErrorJobRow(0, 0, string.Empty, 0)
};
}
@@ -286,122 +288,188 @@ internal sealed class SettingsForm : Form
}
}
private bool ValidateLayout(bool showSuccessMessage, out LabelLayout? layout)
private void NewTemplate()
{
layout = null;
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 result = _layoutStore.ValidateJson(_layoutJson.Text, out layout);
if (result.IsValid)
var layout = LayoutJsonSerializer.Deserialize(_layoutJson.Text);
template = new LabelTemplateConfig
{
_layoutValidationErrors.Text = string.Empty;
if (showSuccessMessage)
{
MessageBox.Show("Layout ist gültig.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
}
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()
};
return true;
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.");
}
var message = string.Join(Environment.NewLine, result.Errors);
_layoutValidationErrors.Text = message;
MessageBox.Show(message, "Layout ist ungültig", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return false;
if (!string.IsNullOrWhiteSpace(template.QrTemplate))
{
_ = TemplateFormatter.Format(template.QrTemplate, BuildPreviewPayload(template, dummyNumber: 123), errors);
}
errors.AddRange(_layoutValidator.Validate(template.Layout).Errors);
}
catch (Exception ex)
{
ShowError(ex);
return false;
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 ValidateLayout(bool showSuccessMessage)
private bool HasDuplicateBarcodeTemplateId(int barcodeTemplateId)
{
return ValidateLayout(showSuccessMessage, out _);
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 SaveLayout()
private void RenderTemplatePreview()
{
try
{
if (!ValidateLayout(showSuccessMessage: false, out var layout) || layout is null)
if (!ValidateCurrentTemplate(showSuccessMessage: false, out var template) || template is null)
{
return;
}
_layoutStore.SaveLayout(layout);
_layoutJson.Text = LayoutJsonSerializer.Serialize(layout);
LoadLayouts();
MessageBox.Show("Layout gespeichert.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
ShowError(ex);
}
}
private void RenderLayoutPreview()
{
try
{
if (!ValidateLayout(showSuccessMessage: false, out var layout) || layout is null)
{
return;
}
using var result = _labelRenderer.Render(layout, PreviewDataProvider.CreatePayload());
var oldImage = _layoutPreview.Image;
_layoutPreview.Image = (Bitmap)result.Image.Clone();
using var result = _labelRenderer.Render(template.Layout, BuildPreviewPayload(template, dummyNumber: 123));
var oldImage = _templatePreview.Image;
_templatePreview.Image = (Bitmap)result.Image.Clone();
oldImage?.Dispose();
_layoutValidationErrors.Text = result.Warnings.Count == 0
_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);
: $"Vorschau erzeugt: {result.WidthPx} x {result.HeightPx} px{Environment.NewLine}" + string.Join(Environment.NewLine, result.Warnings);
}
catch (Exception ex)
{
Log.Error(ex, "Could not render layout preview");
Log.Error(ex, "Could not render label template preview");
MessageBox.Show($"Vorschau konnte nicht erzeugt werden: {ex.Message}", Text, MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private async Task PrintSelectedLayoutTestAsync()
private async Task PrintSelectedTemplateAsync()
{
try
if (_templateList.SelectedItem is LabelTemplateConfig template)
{
SavePrinter(showMessage: false);
var layout = LoadSelectedLayoutForTest();
await PrintLayoutAsync(layout);
await PrintTemplateAsync(template);
}
catch (Exception ex)
else
{
Log.Error(ex, "Could not start printer-tab test print");
MessageBox.Show($"Testdruck fehlgeschlagen: {ex.Message}", Text, MessageBoxButtons.OK, MessageBoxIcon.Error);
MessageBox.Show("Bitte zuerst ein Label-Template auswählen.", Text, MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
private async Task PrintCurrentLayoutTestAsync()
private async Task PrintCurrentTemplateAsync()
{
try
if (ValidateCurrentTemplate(showSuccessMessage: false, out var template) && template is not null)
{
SavePrinter(showMessage: false);
if (!ValidateLayout(showSuccessMessage: false, out var layout) || layout is null)
{
return;
}
await PrintLayoutAsync(layout);
}
catch (Exception ex)
{
Log.Error(ex, "Could not start layout-tab test print");
MessageBox.Show($"Testdruck fehlgeschlagen: {ex.Message}", Text, MessageBoxButtons.OK, MessageBoxIcon.Error);
await PrintTemplateAsync(template);
}
}
private async Task PrintLayoutAsync(LabelLayout layout)
private async Task PrintTemplateAsync(LabelTemplateConfig template)
{
SavePrinter(showMessage: false);
var printerName = GetSelectedPrinterName();
if (string.IsNullOrWhiteSpace(printerName))
{
@@ -415,18 +483,10 @@ internal sealed class SettingsForm : Form
return;
}
using var renderResult = _labelRenderer.Render(layout, PreviewDataProvider.CreatePayload());
var printResult = await _printerService.PrintAsync(
renderResult.Image,
printerName,
layout.WidthMm,
layout.HeightMm);
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)
{
_layoutValidationErrors.Text = renderResult.Warnings.Count == 0
? string.Empty
: string.Join(Environment.NewLine, renderResult.Warnings);
MessageBox.Show("Testdruck wurde an den Drucker gesendet.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
@@ -434,24 +494,22 @@ internal sealed class SettingsForm : Form
MessageBox.Show($"Testdruck fehlgeschlagen: {printResult.ErrorMessage}", Text, MessageBoxButtons.OK, MessageBoxIcon.Error);
}
private LabelLayout LoadSelectedLayoutForTest()
private static Dictionary<string, object?> BuildPreviewPayload(LabelTemplateConfig template, long dummyNumber)
{
if (_layoutKeys.SelectedItem is not null)
var job = new PrintJob
{
var key = _layoutKeys.SelectedItem.ToString();
if (!string.IsNullOrWhiteSpace(key))
Id = 0,
BarcodeTemplateId = template.BarcodeTemplateId,
PayloadJson = """
{
return _layoutStore.LoadByKey(key.Replace(" (Standard)", string.Empty));
"titel": "Beleg privat",
"beschreibung": "Tankbeleg",
"datum": "2026-05-07"
}
}
var firstKey = _layoutStore.GetLayoutKeys().FirstOrDefault();
if (firstKey is null)
{
throw new InvalidOperationException("Es ist kein Layout für den Testdruck vorhanden.");
}
return _layoutStore.LoadByKey(firstKey);
""",
ReservedNumber = dummyNumber
};
return LabelPayloadBuilder.Build(job, template, dummyNumber);
}
private string? GetSelectedPrinterName()
@@ -500,13 +558,15 @@ internal sealed class SettingsForm : Form
};
}
private static FlowLayoutPanel Row(int top, params Control[] controls)
private static FlowLayoutPanel Row(int top, params Control[] controls) => RowAt(20, top, controls);
private static FlowLayoutPanel RowAt(int left, int top, params Control[] controls)
{
var row = new FlowLayoutPanel
{
Left = 20,
Left = left,
Top = top,
Width = 760,
Width = 880,
Height = 32,
FlowDirection = FlowDirection.LeftToRight,
WrapContents = false
@@ -522,11 +582,5 @@ internal sealed class SettingsForm : Form
return button;
}
private void ShowError(Exception ex)
{
Log.Error(ex, "Settings form action failed");
MessageBox.Show(ex.Message, Text, MessageBoxButtons.OK, MessageBoxIcon.Error);
}
private sealed record ErrorJobRow(long Id, string Layout, string Fehler, int Versuche);
private sealed record ErrorJobRow(long Id, int BarcodeTemplateId, string Fehler, int Versuche);
}
@@ -1,5 +1,4 @@
using LabelPrintAgent.Configuration;
using LabelPrintAgent.Layout;
using LabelPrintAgent.Printing;
using Serilog;
@@ -9,17 +8,14 @@ internal sealed class TrayApplicationContext : ApplicationContext
{
private readonly NotifyIcon _notifyIcon;
private readonly SettingsStore _settingsStore;
private readonly LayoutStore _layoutStore;
private readonly PrinterService _printerService;
private SettingsForm? _settingsForm;
public TrayApplicationContext(
SettingsStore settingsStore,
LayoutStore layoutStore,
PrinterService printerService)
{
_settingsStore = settingsStore;
_layoutStore = layoutStore;
_printerService = printerService;
var menu = new ContextMenuStrip();
@@ -48,7 +44,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
{
if (_settingsForm is null || _settingsForm.IsDisposed)
{
_settingsForm = new SettingsForm(_settingsStore, _layoutStore, _printerService);
_settingsForm = new SettingsForm(_settingsStore, _printerService);
}
_settingsForm.Show();
@@ -1,3 +1,5 @@
using LabelPrintAgent.Layout;
namespace LabelPrintAgent.Configuration;
public sealed class AppSettings
@@ -5,6 +7,7 @@ public sealed class AppSettings
public DatabaseSettings Database { 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()
@@ -15,6 +18,117 @@ public sealed class AppSettings
}
}
public sealed class LabelTemplateConfig
{
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";
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using LabelPrintAgent.Layout;
using Serilog;
namespace LabelPrintAgent.Configuration;
@@ -10,7 +11,8 @@ public sealed class SettingsStore
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new LayoutElementJsonConverter() }
};
private readonly ProtectedStringService _protectedStringService;
@@ -38,6 +40,12 @@ 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)
@@ -0,0 +1,10 @@
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;
}
@@ -0,0 +1,217 @@
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
@@ -0,0 +1,18 @@
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,6 +11,7 @@
</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" />
@@ -3,7 +3,6 @@ namespace LabelPrintAgent.Layout;
public sealed class LabelLayout
{
public string Name { get; set; } = string.Empty;
public string Key { get; set; } = string.Empty;
public double WidthMm { get; set; } = 57;
public double HeightMm { get; set; } = 32;
public int Dpi { get; set; } = 300;
-246
View File
@@ -1,246 +0,0 @@
using System.Text.Json;
using Serilog;
namespace LabelPrintAgent.Layout;
public sealed class LayoutStore : IDisposable
{
private readonly string _layoutFolder;
private readonly LayoutValidator _validator = new();
public LayoutStore(string layoutFolder)
{
_layoutFolder = layoutFolder;
Directory.CreateDirectory(_layoutFolder);
}
public IReadOnlyList<string> GetLayoutKeys()
{
return Directory.GetFiles(_layoutFolder, "*.json")
.Select(TryLoad)
.Where(layout => layout is not null)
.Select(layout => layout!.Key)
.Where(key => !string.IsNullOrWhiteSpace(key))
.OrderBy(key => key)
.ToList();
}
public LabelLayout LoadByKey(string key)
{
foreach (var path in Directory.GetFiles(_layoutFolder, "*.json"))
{
var layout = Load(path);
if (string.Equals(layout.Key, key, StringComparison.OrdinalIgnoreCase))
{
return layout;
}
}
throw new FileNotFoundException($"Layout '{key}' wurde nicht gefunden.");
}
public string LoadJsonByKey(string key)
{
foreach (var path in Directory.GetFiles(_layoutFolder, "*.json"))
{
var layout = Load(path);
if (string.Equals(layout.Key, key, StringComparison.OrdinalIgnoreCase))
{
return File.ReadAllText(path);
}
}
throw new FileNotFoundException($"Layout '{key}' wurde nicht gefunden.");
}
public LabelLayout DeserializeJson(string json)
{
return LayoutJsonSerializer.Deserialize(json)
?? throw new InvalidOperationException("Layout-JSON ist leer.");
}
public LayoutValidationResult ValidateJson(string json, out LabelLayout? layout)
{
layout = null;
try
{
layout = DeserializeJson(json);
return _validator.Validate(layout);
}
catch (JsonException ex)
{
Log.Warning(ex, "Invalid layout JSON");
var result = new LayoutValidationResult();
result.AddError($"Layout-JSON ist ungültig: {ex.Message}");
return result;
}
catch (Exception ex)
{
Log.Warning(ex, "Could not parse layout JSON");
var result = new LayoutValidationResult();
result.AddError($"Layout-JSON konnte nicht gelesen werden: {ex.Message}");
return result;
}
}
public void SaveLayout(LabelLayout layout)
{
var validation = _validator.Validate(layout);
if (!validation.IsValid)
{
throw new InvalidOperationException(string.Join(Environment.NewLine, validation.Errors));
}
var fileName = $"{layout.Key}.json";
File.WriteAllText(Path.Combine(_layoutFolder, fileName), LayoutJsonSerializer.Serialize(layout));
}
public void SaveJson(string json)
{
var validation = ValidateJson(json, out var layout);
if (!validation.IsValid || layout is null)
{
throw new InvalidOperationException(string.Join(Environment.NewLine, validation.Errors));
}
SaveLayout(layout);
}
private static LabelLayout Load(string path)
{
var json = File.ReadAllText(path);
return LayoutJsonSerializer.Deserialize(json)
?? throw new InvalidOperationException($"Layout '{path}' ist ungültig.");
}
private static LabelLayout? TryLoad(string path)
{
try
{
return Load(path);
}
catch (Exception ex)
{
Log.Warning(ex, "Could not load layout file {LayoutPath}", path);
return null;
}
}
public static void EnsureExampleLayout(string layoutFolder)
{
Directory.CreateDirectory(layoutFolder);
var path = Path.Combine(layoutFolder, "dymo_57x32_standard.json");
if (File.Exists(path))
{
return;
}
File.WriteAllText(path, ExampleLayoutJson);
}
public void Dispose()
{
}
public const string ExamplePayloadJson = """
{
"titel": "Beleg privat",
"beschreibung": "Dokument 2026-000123",
"nummer": "2026-000123",
"datum": "2026-05-07",
"qr": "bjoernprivat 0000123"
}
""";
public const string ExampleLayoutJson = """
{
"name": "Dymo_57x32_Standard",
"key": "dymo_57x32_standard",
"widthMm": 57,
"heightMm": 32,
"dpi": 300,
"marginMm": 3,
"orientation": "landscape",
"elements": [
{
"type": "rectangle",
"xMm": 1.5,
"yMm": 1.5,
"widthMm": 54,
"heightMm": 29,
"strokeWidthMm": 0.25,
"filled": false
},
{
"type": "text",
"xMm": 3,
"yMm": 3,
"widthMm": 36,
"heightMm": 8,
"value": "{titel}",
"fontFamily": "Arial",
"fontSizePt": 10,
"minFontSizePt": 6,
"autoShrink": true,
"bold": true,
"italic": false,
"underline": false,
"horizontalAlign": "left",
"verticalAlign": "top",
"multiline": true
},
{
"type": "text",
"xMm": 3,
"yMm": 12,
"widthMm": 36,
"heightMm": 8,
"value": "{beschreibung}",
"fontFamily": "Arial",
"fontSizePt": 8,
"minFontSizePt": 6,
"autoShrink": true,
"bold": false,
"italic": false,
"underline": false,
"horizontalAlign": "left",
"verticalAlign": "top",
"multiline": true
},
{
"type": "qr",
"xMm": 41,
"yMm": 4,
"sizeMm": 16,
"value": "{qr}"
},
{
"type": "line",
"x1Mm": 3,
"y1Mm": 23,
"x2Mm": 54,
"y2Mm": 23,
"strokeWidthMm": 0.3
},
{
"type": "text",
"xMm": 3,
"yMm": 24,
"widthMm": 51,
"heightMm": 5,
"value": "Nr: {nummer} | {datum:dd.MM.yyyy}",
"fontFamily": "Arial",
"fontSizePt": 7,
"minFontSizePt": 5,
"autoShrink": true,
"bold": false,
"italic": false,
"underline": false,
"horizontalAlign": "center",
"verticalAlign": "middle",
"multiline": false
}
]
}
""";
}
@@ -43,11 +43,6 @@ public sealed class LayoutValidator
result.AddError("Layout: Name darf nicht leer sein.");
}
if (string.IsNullOrWhiteSpace(layout.Key))
{
result.AddError("Layout: Key darf nicht leer sein.");
}
if (layout.WidthMm <= 0)
{
result.AddError("Layout: WidthMm muss größer als 0 sein.");
@@ -0,0 +1,26 @@
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
};
}
}
@@ -0,0 +1,92 @@
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);
}
}
}
@@ -0,0 +1,28 @@
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();
}
@@ -0,0 +1,32 @@
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;
}
}
@@ -0,0 +1,115 @@
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);
}
}