diff --git a/EpsonPrintService/EpsonPrintService.csproj b/EpsonPrintService/EpsonPrintService.csproj index 505cace..c490f69 100644 --- a/EpsonPrintService/EpsonPrintService.csproj +++ b/EpsonPrintService/EpsonPrintService.csproj @@ -5,7 +5,7 @@ net8.0 enable enable - 1.0.12 + 1.0.14 diff --git a/EpsonPrintService/PrintServerBootstrapper.cs b/EpsonPrintService/PrintServerBootstrapper.cs index 043cd7d..40f5906 100644 --- a/EpsonPrintService/PrintServerBootstrapper.cs +++ b/EpsonPrintService/PrintServerBootstrapper.cs @@ -1,6 +1,7 @@ using Inspectron.Epson; using Inspectron.Epson.PrintServer; using Inspectron.Epson.PrintServer.ConfigurationSources; +using Inspectron.Epson.PrintServer.Hooks; using Inspectron.Epson.PrintServer.JobSources; using Inspectron.Epson.PrintServer.JobStatusReporters; using Inspectron.Epson.PrintServer.PrinterAssinment; @@ -17,6 +18,7 @@ public class PrintServerBootstrapper { private readonly EpsonPrintServiceConfiguration _config; private readonly CancellationTokenSource _cts; + private readonly List _arrivedHandlers = new(); private StandardKernel? _kernel; public bool IsRunning { get; private set; } @@ -27,6 +29,17 @@ public class PrintServerBootstrapper _cts = cts; } + /// + /// Register a handler that fires once per print job as it arrives from the job source, + /// before it's enqueued on the printer queue. Register all handlers before calling . + /// Handler exceptions are logged and swallowed; they never block a print. + /// + public void OnPrintJobArrived(PrintJobArrivedHandler handler) + { + if (IsRunning) throw new InvalidOperationException("Register hooks before StartAsync()."); + _arrivedHandlers.Add(handler); + } + public async Task StartAsync() { _kernel = new StandardKernel(); @@ -54,6 +67,13 @@ public class PrintServerBootstrapper } else { + // hardcoded discovery + // quick and dirty fix for testing without discovery - just bind a preconfigured discovery service with known printer IPs + // DO NOT REMOVE !!! + //var discovery = new PreconfiguredDiscoveryService(); + //discovery.AddPrinter("192.168.1.124", "TM-T30III"); + //_kernel.Bind().ToConstant(discovery).InSingletonScope();\ + _kernel.Bind().To().InSingletonScope(); _kernel.Bind().To(); } @@ -73,6 +93,10 @@ public class PrintServerBootstrapper _kernel.Bind().To().InSingletonScope(); _kernel.Bind().To(); _kernel.Bind().ToSelf().InSingletonScope(); + foreach (var handler in _arrivedHandlers) + { + _kernel.Bind().ToConstant(handler); + } _kernel.Bind().To().InSingletonScope(); _kernel.Bind().ToSelf().InSingletonScope(); _kernel.Bind().ToSelf().InSingletonScope(); diff --git a/EpsonPrintService/PuduRobotClient.cs b/EpsonPrintService/PuduRobotClient.cs new file mode 100644 index 0000000..fabc2a1 --- /dev/null +++ b/EpsonPrintService/PuduRobotClient.cs @@ -0,0 +1,118 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; + +namespace EpsonPrintService; + +public sealed class PuduRobotClient +{ + private readonly HttpClient _http; + private readonly string _baseUrl; + private readonly string _username; + private readonly string _password; + private string? _token; + + public PuduRobotClient(string baseUrl, string username, string password, HttpClient? http = null) + { + _baseUrl = baseUrl.TrimEnd('/'); + _username = username; + _password = password; + _http = http ?? new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + } + + public async Task SendToTableAsync(string robotSn, string targetPoint, CancellationToken ct = default) + { + await EnsureTokenAsync(ct); + try + { + await PostAddCommandAsync(robotSn, targetPoint, ct); + } + catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized) + { + // Token expired — refresh once and retry. + _token = null; + await EnsureTokenAsync(ct); + await PostAddCommandAsync(robotSn, targetPoint, ct); + } + } + + private async Task EnsureTokenAsync(CancellationToken ct) + { + if (_token is not null) return; + + using var resp = await _http.PostAsJsonAsync( + $"{_baseUrl}/api/Auth/login", + new { username = _username, password = _password }, + ct); + resp.EnsureSuccessStatusCode(); + + var bodyText = await resp.Content.ReadAsStringAsync(ct); + var token = ExtractToken(bodyText); + if (string.IsNullOrEmpty(token)) + throw new InvalidOperationException($"Pudu login: no token returned. Body: {bodyText}"); + + _token = token; + } + + private async Task PostAddCommandAsync(string robotSn, string targetPoint, CancellationToken ct) + { + using var req = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}/api/Robots/add-command") + { + Content = JsonContent.Create(new { Sn = robotSn, TargetPoint = targetPoint }) + }; + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); + + using var resp = await _http.SendAsync(req, ct); + resp.EnsureSuccessStatusCode(); + } + + // Recursive search so we match the token regardless of wrapping (e.g. {"data":{"token":"..."}}). + // Mirrors the behavior of `sed -n 's/.*"token":"\([^"]*\)".*/\1/p'` in send_robot.sh. + private static string? ExtractToken(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + return FindTokenProperty(doc.RootElement); + } + catch (JsonException) + { + return null; + } + } + + private static string? FindTokenProperty(JsonElement element) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (var prop in element.EnumerateObject()) + { + if ((prop.Name.Equals("token", StringComparison.OrdinalIgnoreCase) + || prop.Name.Equals("accessToken", StringComparison.OrdinalIgnoreCase)) + && prop.Value.ValueKind == JsonValueKind.String) + { + return prop.Value.GetString(); + } + } + foreach (var prop in element.EnumerateObject()) + { + var nested = FindTokenProperty(prop.Value); + if (nested is not null) return nested; + } + return null; + + case JsonValueKind.Array: + foreach (var item in element.EnumerateArray()) + { + var nested = FindTokenProperty(item); + if (nested is not null) return nested; + } + return null; + + default: + return null; + } + } +} diff --git a/EpsonPrintService/robot_control.md b/EpsonPrintService/robot_control.md new file mode 100644 index 0000000..bccb909 --- /dev/null +++ b/EpsonPrintService/robot_control.md @@ -0,0 +1,138 @@ +# Robot Control — Send a Robot to a Table + +Step-by-step guide to authorize against the `PuduControl.DataSync.API` and dispatch a robot (by serial number) to a specific table (by table name). + +## Prerequisites + +- API running locally at `http://localhost:5022` (see `Properties/launchSettings.json`). +- Credentials: `admin` / `admin`. +- Robot serial number (`Sn`) and the exact table name configured as a destination point (`TargetPoint`) in the robot's shop. +- A REST client (`curl`, Postman, HTTPie, etc.). + +All endpoints return a uniform envelope: + +```json +{ "success": true, "data": { ... }, "errorMessage": null } +``` + +## Step 1 — Authorize (obtain a JWT) + +`POST /api/Auth/login` + +Request body: + +```json +{ "username": "admin", "password": "admin" } +``` + +`curl` example: + +```bash +curl -s -X POST http://localhost:5022/api/Auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"admin"}' +``` + +Sample response: + +```json +{ + "success": true, + "data": { + "message": "Login successful", + "username": "admin", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "expiresIn": 86400 + }, + "errorMessage": null +} +``` + +Copy `data.token` — every subsequent call must include it as: + +``` +Authorization: Bearer +``` + +The token is valid for 24 hours. + +## Step 2 — Send the robot to a table + +`POST /api/Robots/add-command` (requires `Authorization` header) + +Request body: + +| Field | Type | Description | +|---------------|--------|--------------------------------------------------| +| `Sn` | string | Robot serial number | +| `TargetPoint` | string | Table name (must exist in the robot's shop map) | + +```json +{ "Sn": "ROBOT_SERIAL_HERE", "TargetPoint": "TABLE_NAME_HERE" } +``` + +`curl` example: + +```bash +TOKEN="eyJhbGciOi..." # token from Step 1 + +curl -s -X POST http://localhost:5022/api/Robots/add-command \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"Sn":"ROBOT_SERIAL_HERE","TargetPoint":"TABLE_NAME_HERE"}' +``` + +Success response: + +```json +{ + "success": true, + "data": { "message": "Command added successfully." }, + "errorMessage": null +} +``` + +The command is persisted and picked up by the `DataSyncHosted` background service, which forwards it to the robot through the Pudu API. + +## One-shot script + +```bash +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="http://localhost:5022" +ROBOT_SN="$1" +TABLE_NAME="$2" + +TOKEN=$(curl -s -X POST "$BASE_URL/api/Auth/login" \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"admin"}' \ + | sed -n 's/.*"token":"\([^"]*\)".*/\1/p') + +curl -s -X POST "$BASE_URL/api/Robots/add-command" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d "{\"Sn\":\"$ROBOT_SN\",\"TargetPoint\":\"$TABLE_NAME\"}" +``` + +Usage: `./send_robot.sh ` + +## Troubleshooting + +- **401 Unauthorized** — token missing, expired, or malformed. Repeat Step 1. +- **`You do not have permission to control this robot.`** — the logged-in user does not own the shop this robot belongs to. Assign the shop to the user via `POST /api/Robots/assign-shop` (admin only). +- **Target point not reached / ignored** — verify `TargetPoint` matches a point name on the robot's map exactly (case-sensitive). Use `GET /api/Robots/shops/{shopId}` to list available points. +- **Listing available robots** — `GET /api/Robots/shops/{shopId}/robots` returns robots (and their `Sn`) in a shop; `GET /api/Robots/shops` lists shops the user can access. + +## Helpful companion endpoints + +| Purpose | Method & Path | +|-------------------------------|---------------------------------------------------| +| List accessible shops | `GET /api/Robots/shops` | +| List robots in a shop | `GET /api/Robots/shops/{shopId}/robots` | +| Get shop layout / points | `GET /api/Robots/shops/{shopId}` | +| Get robot status | `GET /api/Robots/shops/{shopId}/status` | +| List queued commands | `POST /api/Robots/list-commands` | +| Cancel a queued command | `POST /api/Robots/delete-command` | + +All of the above require the `Authorization: Bearer ` header. diff --git a/EpsonPrintService/send_robot.sh b/EpsonPrintService/send_robot.sh new file mode 100644 index 0000000..f96ff83 --- /dev/null +++ b/EpsonPrintService/send_robot.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +BASE_URL="${BASE_URL:-https://pudu-api.tes.gd}" +USERNAME="${USERNAME:-admin}" +PASSWORD="${PASSWORD:-admin}" +ROBOT_SN="$1" +TABLE_NAME="$2" + +TOKEN=$(curl -fsS -X POST "$BASE_URL/api/Auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD\"}" \ + | sed -n 's/.*"token":"\([^"]*\)".*/\1/p') + +if [[ -z "$TOKEN" ]]; then + echo "Login failed: could not extract token." >&2 + exit 1 +fi + +curl -fsS -X POST "$BASE_URL/api/Robots/add-command" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d "{\"Sn\":\"$ROBOT_SN\",\"TargetPoint\":\"$TABLE_NAME\"}" +echo diff --git a/EpsonTest/Program.cs b/EpsonTest/Program.cs index 125957b..d58c249 100644 --- a/EpsonTest/Program.cs +++ b/EpsonTest/Program.cs @@ -3,9 +3,28 @@ using Inspectron.Epson.PrintServer.Printers.Utils.BarReceipt; using Inspectron.Epson.PrintServer.Printers.Utils.FinalReceipt; using Inspectron.Epson.PrintServer.Printers.Utils.InvoiceReceipt; using Inspectron.Epson.PrintServer.Printers.Utils.KitchenReceipt; +using Inspectron.Epson.PrintServer.Printers.Utils.MoneyReturnReceipt; using Inspectron.Epson.PrintServer.Printers.Utils.OrderItems; using System.Text.Json; +// Non-interactive mode: `dotnet run -- [html|epson]` +// receiptType: kitchen | final | invoice | bar | orderitems | moneyreturn (or 0-5) +if (args.Length > 0) +{ + int rt = ParseReceiptTypeArg(args[0]); + if (rt < 0) + { + Console.WriteLine($"Unknown receipt type: {args[0]}"); + return; + } + + int pt = args.Length > 1 && args[1].Equals("epson", StringComparison.OrdinalIgnoreCase) ? 1 : 0; + Console.WriteLine($"Running {GetReceiptTypeName(rt)} on {(pt == 0 ? "HTML" : "Epson")} printer...\n"); + await RunTest(rt, pt); + Console.WriteLine("\nDone."); + return; +} + while (true) { Console.Clear(); @@ -18,10 +37,11 @@ while (true) "InvoiceReceipt", "BarReceipt", "OrderItems", + "MoneyReturnReceipt", "Exit" }); - if (receiptType == 5) break; + if (receiptType == 6) break; var printerTarget = ShowMenu("Select printer target:", new[] { @@ -96,9 +116,21 @@ static string GetReceiptTypeName(int index) => index switch 2 => "InvoiceReceipt", 3 => "BarReceipt", 4 => "OrderItems", + 5 => "MoneyReturnReceipt", _ => "Unknown" }; +static int ParseReceiptTypeArg(string arg) => arg.ToLowerInvariant() switch +{ + "0" or "kitchen" or "kitchenreceipt" => 0, + "1" or "final" or "finalreceipt" => 1, + "2" or "invoice" or "invoicereceipt" => 2, + "3" or "bar" or "barreceipt" => 3, + "4" or "orderitems" => 4, + "5" or "moneyreturn" or "moneyreturnreceipt" or "refund" or "creditnote" => 5, + _ => -1 +}; + static async Task RunTest(int receiptType, int printerTarget) { switch (receiptType) @@ -108,6 +140,7 @@ static async Task RunTest(int receiptType, int printerTarget) case 2: await RunInvoiceReceiptTest(printerTarget); break; case 3: await RunBarReceiptTest(printerTarget); break; case 4: await RunOrderItemsTest(printerTarget); break; + case 5: await RunMoneyReturnReceiptTest(printerTarget); break; } } @@ -235,3 +268,29 @@ static async Task RunOrderItemsTest(int printerTarget) await TestHelpers.PrintCommandsToEpsonPrinterAsync(printer, printCommands); } } + +static async Task RunMoneyReturnReceiptTest(int printerTarget) +{ + var receipt = TestHelpers.BuildSampleMoneyReturnReceipt(); + var serializedReceipt = JsonSerializer.Serialize(receipt, new JsonSerializerOptions { WriteIndented = true }); + + var converter = new MoneyReturnReceiptConverter(lineWidth: 48, bigFontLineWidth: 24); + var printCommands = converter.Convert(serializedReceipt); + + TestHelpers.PrintCommandsToConsole(printCommands); + + if (printerTarget == 0) + { + var printer = await TestHelpers.CreateHtmlPrinterAsync(path: @".\money_return_test.html"); + await TestHelpers.PrintCommandsToHtmlPrinterAsync( + printer, + printCommands, + logoPath: "ristorante-klinglers.ch-logo_white_bg.png"); + Console.WriteLine("Output written to: money_return_test.html"); + } + else + { + var printer = await TestHelpers.CreateEpsonPrinterAsync(); + await TestHelpers.PrintCommandsToEpsonPrinterAsync(printer, printCommands); + } +} diff --git a/EpsonTest/TestHelpers.cs b/EpsonTest/TestHelpers.cs index 310febe..0209d9d 100644 --- a/EpsonTest/TestHelpers.cs +++ b/EpsonTest/TestHelpers.cs @@ -5,6 +5,7 @@ using Inspectron.Epson.PrintServer.Printers.Utils.BarReceipt; using Inspectron.Epson.PrintServer.Printers.Utils.FinalRecipe; using Inspectron.Epson.PrintServer.Printers.Utils.InvoiceReceipt; using Inspectron.Epson.PrintServer.Printers.Utils.KitchenReceipt; +using Inspectron.Epson.PrintServer.Printers.Utils.MoneyReturnReceipt; using Inspectron.Epson.PrintServer.Printers.Utils.OrderItems; namespace EpsonTest; @@ -462,6 +463,48 @@ public static class TestHelpers }; } + // Mirrors money_return_receipt_data.json (restaurant refund payload with + // Unix-ms timestamps and per-rate VAT breakdown). + public static MoneyReturnReceipt BuildSampleMoneyReturnReceipt() + { + return new MoneyReturnReceipt + { + RestaurantName = "Gaumenfreuden", + RestaurantPhoneNumber = "043 810 48 48", + RestaurantAddressLine1 = "Seestrasse 11", + RestaurantAddressLine2 = "8810 Horgen", + RestaurantWebsite = "https://gaumen-freuden.ch", + RestaurantVatNumber = null, + ThanksMessage = "Thank you for your order!", + NoVAT = false, + RefundReceiptId = null, + RefundNumber = 1, + RefundTimestamp = 1783522075649, + OriginalTransactionNumber = 186, + OriginalPaymentTimestamp = 1783521986035, + OriginalPaymentMethod = "Cash", + Currency = "CHF", + RefundType = "Full", + TipAmount = 0.3m, + RefundedAmount = 11.0m, + RefundPaymentMethod = "Cash", + RefundedItems = new List + { + new() { Name = "Cappuccino", Size = "", Quantity = 1, Price = 5.8m, TaxAbbr = "A" }, + new() { Name = "Kaffee Crème", Size = "", Quantity = 1, Price = 4.9m, TaxAbbr = "A" } + }, + StandardRate = new TaxRateBreakdown + { + Rate = 8.1m, + TotalAmount = 10.7m, + TotalAmountNetto = 9.90m, + TotalAmountTax = 0.80m + }, + ReducedRate = new TaxRateBreakdown { Rate = 0m, TotalAmount = 0m, TotalAmountNetto = 0m, TotalAmountTax = 0m }, + SpecialRateForAccommodation = new TaxRateBreakdown { Rate = 0m, TotalAmount = 0m, TotalAmountNetto = 0m, TotalAmountTax = 0m } + }; + } + #endregion #region Print Helpers @@ -498,7 +541,7 @@ public static class TestHelpers public static async Task PrintCommandsToEpsonPrinterAsync( EpsonPrinter printer, IEnumerable commands, - bool useDelay = true, + bool useDelay = false, int delayMs = 200) { await printer.FeedLinesAsync(1); diff --git a/Inspectron.Epson/PrintServer/Hooks/PrintJobArrivedHandler.cs b/Inspectron.Epson/PrintServer/Hooks/PrintJobArrivedHandler.cs new file mode 100644 index 0000000..e9ad144 --- /dev/null +++ b/Inspectron.Epson/PrintServer/Hooks/PrintJobArrivedHandler.cs @@ -0,0 +1,75 @@ +using System.Text.Json; +using Inspectron.Epson.PrintServer.Printers.Utils; +using Inspectron.Epson.Queue; +using FinalReceiptModel = Inspectron.Epson.PrintServer.Printers.Utils.FinalRecipe.FinalReceipt; +using InvoiceReceiptModel = Inspectron.Epson.PrintServer.Printers.Utils.InvoiceReceipt.InvoiceReceipt; +using KitchenReceiptModel = Inspectron.Epson.PrintServer.Printers.Utils.KitchenReceipt.KitchenReceipt; +using OrderItemsReceiptModel = Inspectron.Epson.PrintServer.Printers.Utils.OrderItems.OrderItemsReceipt; + +namespace Inspectron.Epson.PrintServer.Hooks; + +public delegate Task PrintJobArrivedHandler(PrintJobArrivedContext ctx, CancellationToken cancellationToken); + +public sealed class PrintJobArrivedContext +{ + private KitchenReceiptModel? _kitchen; + private FinalReceiptModel? _final; + private InvoiceReceiptModel? _invoice; + private OrderItemsReceiptModel? _orderItems; + private bool _kitchenParsed, _finalParsed, _invoiceParsed, _orderItemsParsed; + + public PrintJob Job { get; } + public int ReceiptTypeId => Job.Document.ReceiptType; + public ReceiptConverterFactory.EReceiptType ReceiptType => (ReceiptConverterFactory.EReceiptType)Job.Document.ReceiptType; + public string ContentJson => Job.Document.Content; + + public PrintJobArrivedContext(PrintJob job) + { + Job = job; + } + + public KitchenReceiptModel? AsKitchen() + { + if (_kitchenParsed) return _kitchen; + _kitchenParsed = true; + if (ReceiptType is ReceiptConverterFactory.EReceiptType.WorkareaTicket + or ReceiptConverterFactory.EReceiptType.NextCourse) + { + _kitchen = JsonSerializer.Deserialize(ContentJson); + } + return _kitchen; + } + + public FinalReceiptModel? AsFinal() + { + if (_finalParsed) return _final; + _finalParsed = true; + if (ReceiptType == ReceiptConverterFactory.EReceiptType.Receipt) + { + _final = JsonSerializer.Deserialize(ContentJson); + } + return _final; + } + + public InvoiceReceiptModel? AsInvoice() + { + if (_invoiceParsed) return _invoice; + _invoiceParsed = true; + if (ReceiptType == ReceiptConverterFactory.EReceiptType.Invoice) + { + _invoice = JsonSerializer.Deserialize(ContentJson); + } + return _invoice; + } + + public OrderItemsReceiptModel? AsOrderItems() + { + if (_orderItemsParsed) return _orderItems; + _orderItemsParsed = true; + if (ReceiptType == ReceiptConverterFactory.EReceiptType.OrdersOverview) + { + _orderItems = JsonSerializer.Deserialize(ContentJson); + } + return _orderItems; + } +} diff --git a/Inspectron.Epson/PrintServer/PrintLoop.cs b/Inspectron.Epson/PrintServer/PrintLoop.cs index 7bf5074..6c24640 100644 --- a/Inspectron.Epson/PrintServer/PrintLoop.cs +++ b/Inspectron.Epson/PrintServer/PrintLoop.cs @@ -1,4 +1,5 @@ -using Inspectron.Epson.PrintServer.PrintServices; +using Inspectron.Epson.PrintServer.Hooks; +using Inspectron.Epson.PrintServer.PrintServices; using Inspectron.Epson.Queue; using Microsoft.Extensions.Logging; @@ -9,17 +10,19 @@ public class PrintLoop private readonly global::Inspectron.Epson.Queue.PrintServer _printServer; private readonly IPrintService _printService; private readonly IPrintJobSource _jobSource; - + private readonly IReadOnlyList _arrivedHandlers; + private readonly ILogger _logger; private CancellationTokenSource? _cancellationSource; private CancellationToken _cancellationToken; - public PrintLoop(global::Inspectron.Epson.Queue.PrintServer printServer,IPrintService printService, IPrintJobSource jobSource, ILogger logger) + public PrintLoop(global::Inspectron.Epson.Queue.PrintServer printServer, IPrintService printService, IPrintJobSource jobSource, ILogger logger, IEnumerable? arrivedHandlers = null) { _printServer = printServer; _printService = printService; _jobSource = jobSource; - + _arrivedHandlers = arrivedHandlers?.ToList() ?? (IReadOnlyList)Array.Empty(); + _logger = logger; } @@ -35,7 +38,23 @@ public class PrintLoop while (!_cancellationSource!.Token.IsCancellationRequested) { var job = await _jobSource.GetNextJobAsync(_cancellationToken); - + + if (_arrivedHandlers.Count > 0) + { + var ctx = new PrintJobArrivedContext(job); + foreach (var handler in _arrivedHandlers) + { + try + { + await handler(ctx, _cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Print job arrived hook threw (job {JobId})", job.JobId); + } + } + } + _printServer.SubmitJob(job.IP, job); } } diff --git a/Inspectron.Epson/PrintServer/Printers/Utils/MoneyReturnReceipt/Models.cs b/Inspectron.Epson/PrintServer/Printers/Utils/MoneyReturnReceipt/Models.cs new file mode 100644 index 0000000..43ef754 --- /dev/null +++ b/Inspectron.Epson/PrintServer/Printers/Utils/MoneyReturnReceipt/Models.cs @@ -0,0 +1,97 @@ +using System.Text.Json.Serialization; + +namespace Inspectron.Epson.PrintServer.Printers.Utils.MoneyReturnReceipt; + +/// +/// Data model for a refund / credit note ("money return") receipt. +/// Matches the refund payload (see money_return_receipt_data.json / data (1).json): +/// restaurant header info, original transaction reference, refunded items and the +/// per-rate VAT breakdown. Timestamps are Unix epoch milliseconds. +/// +public class MoneyReturnReceipt +{ + /// Base64-encoded restaurant logo (optional). Rendered by the print pipeline, not the text converter. + public string? RestaurantLogo { get; set; } + + public string RestaurantName { get; set; } + public string RestaurantPhoneNumber { get; set; } + public string RestaurantAddressLine1 { get; set; } + public string RestaurantAddressLine2 { get; set; } + public string RestaurantWebsite { get; set; } + + /// VAT registration number. Note the misspelled JSON key ("Restauant..."). + [JsonPropertyName("RestauantVATNumber")] + public string? RestaurantVatNumber { get; set; } + + public string ThanksMessage { get; set; } + + /// When true the sale is not subject to VAT and the tax table is replaced by a note. + public bool NoVAT { get; set; } + + public long? RefundReceiptId { get; set; } + + /// Credit note number (shown as "Credit note No. {RefundNumber}"). + public long RefundNumber { get; set; } + + /// When the refund was issued, as Unix epoch milliseconds. + public long RefundTimestamp { get; set; } + + /// Number of the original transaction/invoice being refunded ("Orig. invoice No."). + public long OriginalTransactionNumber { get; set; } + + /// When the original payment was made, as Unix epoch milliseconds ("Orig. date"). + public long OriginalPaymentTimestamp { get; set; } + + /// Method the original payment was made with (e.g. "Cash"). + public string OriginalPaymentMethod { get; set; } + + public string Currency { get; set; } + + /// Refund type, e.g. "Full" or "Partial" (rendered as "Full refund"). + public string RefundType { get; set; } + + /// Tip amount included in the refund. Shown as "incl. tip" when non-zero. + public decimal TipAmount { get; set; } + + public List RefundedItems { get; set; } = new List(); + + /// Total refunded amount (positive). Rendered negative on the receipt. + public decimal RefundedAmount { get; set; } + + /// Standard-rate VAT bucket. Note the misspelled JSON key ("Standart..."). + [JsonPropertyName("StandartRate")] + public TaxRateBreakdown StandardRate { get; set; } + + public TaxRateBreakdown ReducedRate { get; set; } + + public TaxRateBreakdown SpecialRateForAccommodation { get; set; } + + /// Method the refund was paid out with (e.g. "Cash"). + public string RefundPaymentMethod { get; set; } + + public string TerminalReceiptData { get; set; } + public string WebPaymentReceiptData { get; set; } +} + +public class RefundedItem +{ + public string Name { get; set; } + public string Size { get; set; } + public int Quantity { get; set; } + public decimal Price { get; set; } + public string TaxAbbr { get; set; } +} + +public class TaxRateBreakdown +{ + public decimal Rate { get; set; } + + /// Gross amount at this rate. + public decimal TotalAmount { get; set; } + + /// Net amount at this rate. + public decimal TotalAmountNetto { get; set; } + + /// Tax amount at this rate. + public decimal TotalAmountTax { get; set; } +} diff --git a/Inspectron.Epson/PrintServer/Printers/Utils/MoneyReturnReceipt/MoneyReturnReceiptConverter.cs b/Inspectron.Epson/PrintServer/Printers/Utils/MoneyReturnReceipt/MoneyReturnReceiptConverter.cs new file mode 100644 index 0000000..80e2d42 --- /dev/null +++ b/Inspectron.Epson/PrintServer/Printers/Utils/MoneyReturnReceipt/MoneyReturnReceiptConverter.cs @@ -0,0 +1,22 @@ +using System.Text.Json; + +namespace Inspectron.Epson.PrintServer.Printers.Utils.MoneyReturnReceipt; + +public class MoneyReturnReceiptConverter : IReceiptConverter +{ + private readonly int _lineWidth; + private readonly int _bigFontLineWidth; + + public MoneyReturnReceiptConverter(int lineWidth, int bigFontLineWidth) + { + _lineWidth = lineWidth; + _bigFontLineWidth = bigFontLineWidth; + } + + public List Convert(string jsonContent) + { + var receipt = JsonSerializer.Deserialize(jsonContent); + var converter = new ReceiptConverter(_lineWidth, _bigFontLineWidth); + return converter.ConvertToPrintCommands(receipt); + } +} diff --git a/Inspectron.Epson/PrintServer/Printers/Utils/MoneyReturnReceipt/ReceiptConverter.cs b/Inspectron.Epson/PrintServer/Printers/Utils/MoneyReturnReceipt/ReceiptConverter.cs new file mode 100644 index 0000000..bd21217 --- /dev/null +++ b/Inspectron.Epson/PrintServer/Printers/Utils/MoneyReturnReceipt/ReceiptConverter.cs @@ -0,0 +1,154 @@ +namespace Inspectron.Epson.PrintServer.Printers.Utils.MoneyReturnReceipt; + +/// +/// Builds the print command list for a refund / credit note receipt. +/// Layout follows money_return_receipt.jpg: restaurant header, "Refund / Credit note" +/// title, credit note + original transaction reference block, the negative credit total, +/// the (negated) per-rate VAT breakdown and the thank-you footer. +/// +public class ReceiptConverter +{ + private readonly int _lineWidth; + private readonly int _bigFontLineWidth; + + public ReceiptConverter(int lineWidth = 48, int bigFontLineWidth = 24) + { + _lineWidth = lineWidth; + _bigFontLineWidth = bigFontLineWidth; + } + + public List ConvertToPrintCommands(MoneyReturnReceipt receipt) + { + var commands = new List(); + + // Header - Restaurant info + commands.Add(new PrintCommand(Center(receipt.RestaurantName, false))); + commands.Add(new PrintCommand(Center(receipt.RestaurantAddressLine1, false))); + commands.Add(new PrintCommand(Center(receipt.RestaurantAddressLine2, false))); + commands.Add(new PrintCommand(Center(receipt.RestaurantPhoneNumber, false))); + if (!string.IsNullOrWhiteSpace(receipt.RestaurantWebsite)) + commands.Add(new PrintCommand(Center(receipt.RestaurantWebsite, false))); + commands.Add(new PrintCommand("")); + commands.Add(new PrintCommand("")); + + // Title between separators + commands.Add(new PrintCommand(new string('-', _lineWidth))); + commands.Add(new PrintCommand(Center("Refund / Credit note", false), isBold: true)); + commands.Add(new PrintCommand(new string('-', _lineWidth))); + commands.Add(new PrintCommand("")); + + // Credit note number + issue date + commands.Add(new PrintCommand(Justify( + $"Credit note No. {receipt.RefundNumber}", + FormatTimestamp(receipt.RefundTimestamp)))); + commands.Add(new PrintCommand("")); + + // Original transaction reference block + commands.Add(new PrintCommand(Justify("Orig. invoice No.:", receipt.OriginalTransactionNumber.ToString()))); + commands.Add(new PrintCommand(Justify("Orig. date:", FormatTimestamp(receipt.OriginalPaymentTimestamp)))); + commands.Add(new PrintCommand(Justify("Orig. payment method:", receipt.OriginalPaymentMethod ?? ""))); + if (!string.IsNullOrEmpty(receipt.RefundType)) + commands.Add(new PrintCommand(Justify("Type:", $"{receipt.RefundType} refund"))); + commands.Add(new PrintCommand("")); + + commands.Add(new PrintCommand(new string('-', _lineWidth))); + commands.Add(new PrintCommand("")); + + // Included tip + if (receipt.TipAmount != 0) + { + commands.Add(new PrintCommand($"incl. tip {receipt.Currency}: {receipt.TipAmount:F2}".PadLeft(_lineWidth))); + commands.Add(new PrintCommand("")); + } + + // Credit total (refund is shown as a negative amount), big + bold, centered + string creditLine = $"Credit: {-receipt.RefundedAmount:F2} {receipt.Currency}"; + commands.Add(new PrintCommand(Center(creditLine, true), true, true)); + commands.Add(new PrintCommand("")); + + // Refund payment method line (e.g. "Cash: -11.00 CHF") + commands.Add(new PrintCommand($"{receipt.RefundPaymentMethod}: {-receipt.RefundedAmount:F2} {receipt.Currency}".PadLeft(_lineWidth))); + commands.Add(new PrintCommand("")); + + // VAT breakdown (amounts negated for the refund) + if (receipt.NoVAT) + { + commands.Add(new PrintCommand("Not subject to value added tax")); + } + else + { + var rates = new List<(string Category, TaxRateBreakdown Rate)>(); + AddIfPresent(rates, "A", receipt.StandardRate); + AddIfPresent(rates, "B", receipt.ReducedRate); + AddIfPresent(rates, "C", receipt.SpecialRateForAccommodation); + + if (rates.Count > 0) + { + commands.Add(new PrintCommand("VAT %".PadRight(_lineWidth / 4) + + "Gross".PadLeft(_lineWidth / 4) + + "Net".PadLeft(_lineWidth / 4) + + "VAT".PadLeft(_lineWidth / 4))); + + foreach (var (category, rate) in rates) + { + commands.Add(new PrintCommand( + ($"{category}:" + $"{rate.Rate}%".PadLeft(5)).PadRight(_lineWidth / 4) + + $"{-rate.TotalAmount:F2} {receipt.Currency}".PadLeft(_lineWidth / 4) + + $"{-rate.TotalAmountNetto:F2} {receipt.Currency}".PadLeft(_lineWidth / 4) + + $"{-rate.TotalAmountTax:F2} {receipt.Currency}".PadLeft(_lineWidth / 4))); + } + } + } + + commands.Add(new PrintCommand("")); + + // VAT registration number (if provided) + if (!string.IsNullOrWhiteSpace(receipt.RestaurantVatNumber)) + { + commands.Add(new PrintCommand(Center(receipt.RestaurantVatNumber, false))); + } + + commands.Add(new PrintCommand("")); + commands.Add(new PrintCommand("")); + + // Footer + commands.Add(new PrintCommand(Center(receipt.ThanksMessage, false))); + + return commands; + } + + private static void AddIfPresent(List<(string, TaxRateBreakdown)> rates, string category, TaxRateBreakdown? rate) + { + if (rate != null && (rate.Rate != 0 || rate.TotalAmount != 0)) + rates.Add((category, rate)); + } + + private static string FormatTimestamp(long unixMilliseconds) + { + return DateTimeOffset.FromUnixTimeMilliseconds(unixMilliseconds).LocalDateTime.ToString("HH:mm dd.MM.yyyy"); + } + + private string Justify(string left, string right) + { + left ??= ""; + right ??= ""; + + int spaces = _lineWidth - left.Length - right.Length; + if (spaces < 1) spaces = 1; + + return left + new string(' ', spaces) + right; + } + + private string Center(string text, bool isBigFont) + { + int effectiveLineWidth = isBigFont ? _bigFontLineWidth : _lineWidth; + + if (string.IsNullOrEmpty(text) || text.Length >= effectiveLineWidth) + return text; + + int totalPadding = effectiveLineWidth - text.Length; + int leftPadding = totalPadding / 2; + + return new string(' ', leftPadding) + text; + } +} diff --git a/Inspectron.Epson/PrintServer/Printers/Utils/ReceiptConverterFactory.cs b/Inspectron.Epson/PrintServer/Printers/Utils/ReceiptConverterFactory.cs index 5bde730..8c65f0d 100644 --- a/Inspectron.Epson/PrintServer/Printers/Utils/ReceiptConverterFactory.cs +++ b/Inspectron.Epson/PrintServer/Printers/Utils/ReceiptConverterFactory.cs @@ -20,6 +20,7 @@ public class ReceiptConverterFactory : IReceiptConverterFactory EReceiptType.NextCourse => new KitchenReceipt.KitchenReceiptConverterAdapter(33, 20), EReceiptType.OrdersOverview => new OrderItemsReceiptConverter(48, 24), EReceiptType.Invoice => new InvoiceReceiptConverter(48, 24), + EReceiptType.MoneyReturn => new MoneyReturnReceipt.MoneyReturnReceiptConverter(48, 24), _ => throw new ArgumentException($"Unknown receipt type: {receiptType}") }; } @@ -30,6 +31,7 @@ public class ReceiptConverterFactory : IReceiptConverterFactory WorkareaTicket, NextCourse, OrdersOverview, - Invoice + Invoice, + MoneyReturn } } diff --git a/data (1).json b/data (1).json new file mode 100644 index 0000000..19aa5b0 --- /dev/null +++ b/data (1).json @@ -0,0 +1,57 @@ +{ + "RestaurantName":"Gaumenfreuden", + "RestaurantPhoneNumber":"043 810 48 48", + "RestaurantAddressLine1":"Seestrasse 11", + "RestaurantAddressLine2":"8810 Horgen", + "RestaurantWebsite":"https://gaumen-freuden.ch", + "RestauantVATNumber":null, + "ThanksMessage":"Thank you for your order!", + "NoVAT":false, + "RefundReceiptId":null, + "RefundNumber":1, + "RefundTimestamp":1783522075649, + "OriginalTransactionNumber":186, + "OriginalPaymentTimestamp":1783521986035, + "OriginalPaymentMethod":"Cash", + "Currency":"CHF", + "RefundType":"Full", + "TipAmount":0.3, + "RefundedItems":[ + { + "Name":"Cappuccino", + "Size":"", + "Quantity":1, + "Price":5.8, + "TaxAbbr":"A" + }, + { + "Name":"Kaffee Cr\u00E8me", + "Size":"", + "Quantity":1, + "Price":4.9, + "TaxAbbr":"A" + } + ], + "RefundedAmount":11.0, + "StandartRate":{ + "Rate":8.1, + "TotalAmount":10.7, + "TotalAmountNetto":9.90, + "TotalAmountTax":0.80 + }, + "ReducedRate":{ + "Rate":0, + "TotalAmount":0, + "TotalAmountNetto":0, + "TotalAmountTax":0 + }, + "SpecialRateForAccommodation":{ + "Rate":0, + "TotalAmount":0, + "TotalAmountNetto":0, + "TotalAmountTax":0 + }, + "RefundPaymentMethod":"Cash", + "TerminalReceiptData":null, + "WebPaymentReceiptData":null +} \ No newline at end of file diff --git a/global.json b/global.json deleted file mode 100644 index 74ce97c..0000000 --- a/global.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "sdk": { - "version": "8.0.419", - "rollForward": "latestPatch" - } -} diff --git a/money_return_receipt.jpg b/money_return_receipt.jpg new file mode 100644 index 0000000..c07432c Binary files /dev/null and b/money_return_receipt.jpg differ diff --git a/money_return_receipt_data.json b/money_return_receipt_data.json new file mode 100644 index 0000000..a3fcac1 --- /dev/null +++ b/money_return_receipt_data.json @@ -0,0 +1,58 @@ +{ + "RestaurantLogo":null, + "RestaurantName":"Gaumenfreuden", + "RestaurantPhoneNumber":"043 810 48 48", + "RestaurantAddressLine1":"Seestrasse 11", + "RestaurantAddressLine2":"8810 Horgen", + "RestaurantWebsite":"https://gaumen-freuden.ch", + "RestauantVATNumber":null, + "ThanksMessage":"Thank you for your order!", + "NoVAT":false, + "RefundReceiptId":null, + "RefundNumber":1, + "RefundTimestamp":1783522075649, + "OriginalTransactionNumber":186, + "OriginalPaymentTimestamp":1783521986035, + "OriginalPaymentMethod":"Cash", + "Currency":"CHF", + "RefundType":"Full", + "TipAmount":0.3, + "RefundedItems":[ + { + "Name":"Cappuccino", + "Size":"", + "Quantity":1, + "Price":5.8, + "TaxAbbr":"A" + }, + { + "Name":"Kaffee Crème", + "Size":"", + "Quantity":1, + "Price":4.9, + "TaxAbbr":"A" + } + ], + "RefundedAmount":11.0, + "StandartRate":{ + "Rate":8.1, + "TotalAmount":10.7, + "TotalAmountNetto":9.90, + "TotalAmountTax":0.80 + }, + "ReducedRate":{ + "Rate":0, + "TotalAmount":0, + "TotalAmountNetto":0, + "TotalAmountTax":0 + }, + "SpecialRateForAccommodation":{ + "Rate":0, + "TotalAmount":0, + "TotalAmountNetto":0, + "TotalAmountTax":0 + }, + "RefundPaymentMethod":"Cash", + "TerminalReceiptData":null, + "WebPaymentReceiptData":null +}