diff --git a/README.md b/README.md index 789d1e0..703777e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/sql/create_label_print_queue.sql b/sql/create_label_print_queue.sql index 4a2772a..2538bea 100644 --- a/sql/create_label_print_queue.sql +++ b/sql/create_label_print_queue.sql @@ -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' ); diff --git a/src/LabelPrintAgent/App/Program.cs b/src/LabelPrintAgent/App/Program.cs index 190b2b4..95b9874 100644 --- a/src/LabelPrintAgent/App/Program.cs +++ b/src/LabelPrintAgent/App/Program.cs @@ -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) { diff --git a/src/LabelPrintAgent/App/SettingsForm.cs b/src/LabelPrintAgent/App/SettingsForm.cs index 8cf3b01..bc3e4ea 100644 --- a/src/LabelPrintAgent/App/SettingsForm.cs +++ b/src/LabelPrintAgent/App/SettingsForm.cs @@ -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().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().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(); 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 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); } diff --git a/src/LabelPrintAgent/App/TrayApplicationContext.cs b/src/LabelPrintAgent/App/TrayApplicationContext.cs index 8a7bd8d..b355625 100644 --- a/src/LabelPrintAgent/App/TrayApplicationContext.cs +++ b/src/LabelPrintAgent/App/TrayApplicationContext.cs @@ -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(); diff --git a/src/LabelPrintAgent/Configuration/AppSettings.cs b/src/LabelPrintAgent/Configuration/AppSettings.cs index a052be4..fabf805 100644 --- a/src/LabelPrintAgent/Configuration/AppSettings.cs +++ b/src/LabelPrintAgent/Configuration/AppSettings.cs @@ -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 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"; diff --git a/src/LabelPrintAgent/Configuration/SettingsStore.cs b/src/LabelPrintAgent/Configuration/SettingsStore.cs index 588511a..f5dd7f5 100644 --- a/src/LabelPrintAgent/Configuration/SettingsStore.cs +++ b/src/LabelPrintAgent/Configuration/SettingsStore.cs @@ -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(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) diff --git a/src/LabelPrintAgent/Database/DbConnectionSettings.cs b/src/LabelPrintAgent/Database/DbConnectionSettings.cs new file mode 100644 index 0000000..3fe0f59 --- /dev/null +++ b/src/LabelPrintAgent/Database/DbConnectionSettings.cs @@ -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; +} diff --git a/src/LabelPrintAgent/Database/MySqlLabelRepository.cs b/src/LabelPrintAgent/Database/MySqlLabelRepository.cs new file mode 100644 index 0000000..d579ab8 --- /dev/null +++ b/src/LabelPrintAgent/Database/MySqlLabelRepository.cs @@ -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> 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> 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 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 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> 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(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + jobs.Add(ReadJob(reader)); + } + + return jobs; + } + + private async Task 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); + } +} diff --git a/src/LabelPrintAgent/Database/PrintJob.cs b/src/LabelPrintAgent/Database/PrintJob.cs new file mode 100644 index 0000000..1abfaef --- /dev/null +++ b/src/LabelPrintAgent/Database/PrintJob.cs @@ -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; } +} diff --git a/src/LabelPrintAgent/LabelPrintAgent.csproj b/src/LabelPrintAgent/LabelPrintAgent.csproj index e765ac4..ee4dea9 100644 --- a/src/LabelPrintAgent/LabelPrintAgent.csproj +++ b/src/LabelPrintAgent/LabelPrintAgent.csproj @@ -11,6 +11,7 @@ + diff --git a/src/LabelPrintAgent/Layout/LabelLayout.cs b/src/LabelPrintAgent/Layout/LabelLayout.cs index 7425093..60ad177 100644 --- a/src/LabelPrintAgent/Layout/LabelLayout.cs +++ b/src/LabelPrintAgent/Layout/LabelLayout.cs @@ -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; diff --git a/src/LabelPrintAgent/Layout/LayoutStore.cs b/src/LabelPrintAgent/Layout/LayoutStore.cs deleted file mode 100644 index 2b61c3f..0000000 --- a/src/LabelPrintAgent/Layout/LayoutStore.cs +++ /dev/null @@ -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 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 - } - ] -} -"""; -} diff --git a/src/LabelPrintAgent/Layout/LayoutValidator.cs b/src/LabelPrintAgent/Layout/LayoutValidator.cs index 497423a..917709e 100644 --- a/src/LabelPrintAgent/Layout/LayoutValidator.cs +++ b/src/LabelPrintAgent/Layout/LayoutValidator.cs @@ -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."); diff --git a/src/LabelPrintAgent/Numbering/NumberReservationResult.cs b/src/LabelPrintAgent/Numbering/NumberReservationResult.cs new file mode 100644 index 0000000..19c8be9 --- /dev/null +++ b/src/LabelPrintAgent/Numbering/NumberReservationResult.cs @@ -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 + }; + } +} diff --git a/src/LabelPrintAgent/Numbering/NumberReservationService.cs b/src/LabelPrintAgent/Numbering/NumberReservationService.cs new file mode 100644 index 0000000..a86e757 --- /dev/null +++ b/src/LabelPrintAgent/Numbering/NumberReservationService.cs @@ -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 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 ConfirmPrintedAsync( + LabelTemplateConfig template, + Dictionary 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); + } + } +} diff --git a/src/LabelPrintAgent/Numbering/UrlTemplateFormatter.cs b/src/LabelPrintAgent/Numbering/UrlTemplateFormatter.cs new file mode 100644 index 0000000..eb681ac --- /dev/null +++ b/src/LabelPrintAgent/Numbering/UrlTemplateFormatter.cs @@ -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 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(@"\{(?[A-Za-z0-9_]+)(:(?[^}]+))?\}")] + private static partial Regex PlaceholderRegex(); +} diff --git a/src/LabelPrintAgent/Rendering/LabelPayloadBuilder.cs b/src/LabelPrintAgent/Rendering/LabelPayloadBuilder.cs new file mode 100644 index 0000000..42e09af --- /dev/null +++ b/src/LabelPrintAgent/Rendering/LabelPayloadBuilder.cs @@ -0,0 +1,32 @@ +using LabelPrintAgent.Configuration; +using LabelPrintAgent.Database; + +namespace LabelPrintAgent.Rendering; + +public static class LabelPayloadBuilder +{ + public static Dictionary 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; + } +} diff --git a/src/LabelPrintAgent/Worker/PrintJobProcessor.cs b/src/LabelPrintAgent/Worker/PrintJobProcessor.cs new file mode 100644 index 0000000..f37af59 --- /dev/null +++ b/src/LabelPrintAgent/Worker/PrintJobProcessor.cs @@ -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 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); + } +}