Switch queue to local label templates
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user