Merge branch 'feature/PRI-9-interactive-console-test-runner'
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<Version>1.0.12</Version>
|
<Version>1.0.14</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Inspectron.Epson;
|
using Inspectron.Epson;
|
||||||
using Inspectron.Epson.PrintServer;
|
using Inspectron.Epson.PrintServer;
|
||||||
using Inspectron.Epson.PrintServer.ConfigurationSources;
|
using Inspectron.Epson.PrintServer.ConfigurationSources;
|
||||||
|
using Inspectron.Epson.PrintServer.Hooks;
|
||||||
using Inspectron.Epson.PrintServer.JobSources;
|
using Inspectron.Epson.PrintServer.JobSources;
|
||||||
using Inspectron.Epson.PrintServer.JobStatusReporters;
|
using Inspectron.Epson.PrintServer.JobStatusReporters;
|
||||||
using Inspectron.Epson.PrintServer.PrinterAssinment;
|
using Inspectron.Epson.PrintServer.PrinterAssinment;
|
||||||
@@ -17,6 +18,7 @@ public class PrintServerBootstrapper
|
|||||||
{
|
{
|
||||||
private readonly EpsonPrintServiceConfiguration _config;
|
private readonly EpsonPrintServiceConfiguration _config;
|
||||||
private readonly CancellationTokenSource _cts;
|
private readonly CancellationTokenSource _cts;
|
||||||
|
private readonly List<PrintJobArrivedHandler> _arrivedHandlers = new();
|
||||||
private StandardKernel? _kernel;
|
private StandardKernel? _kernel;
|
||||||
|
|
||||||
public bool IsRunning { get; private set; }
|
public bool IsRunning { get; private set; }
|
||||||
@@ -27,6 +29,17 @@ public class PrintServerBootstrapper
|
|||||||
_cts = cts;
|
_cts = cts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="StartAsync"/>.
|
||||||
|
/// Handler exceptions are logged and swallowed; they never block a print.
|
||||||
|
/// </summary>
|
||||||
|
public void OnPrintJobArrived(PrintJobArrivedHandler handler)
|
||||||
|
{
|
||||||
|
if (IsRunning) throw new InvalidOperationException("Register hooks before StartAsync().");
|
||||||
|
_arrivedHandlers.Add(handler);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task StartAsync()
|
public async Task StartAsync()
|
||||||
{
|
{
|
||||||
_kernel = new StandardKernel();
|
_kernel = new StandardKernel();
|
||||||
@@ -54,6 +67,13 @@ public class PrintServerBootstrapper
|
|||||||
}
|
}
|
||||||
else
|
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<IDiscoveryService>().ToConstant(discovery).InSingletonScope();\
|
||||||
|
|
||||||
_kernel.Bind<IDiscoveryService>().To<DiscoveryService>().InSingletonScope();
|
_kernel.Bind<IDiscoveryService>().To<DiscoveryService>().InSingletonScope();
|
||||||
_kernel.Bind<IPrinterFactory>().To<EpsonPrinterFactory>();
|
_kernel.Bind<IPrinterFactory>().To<EpsonPrinterFactory>();
|
||||||
}
|
}
|
||||||
@@ -73,6 +93,10 @@ public class PrintServerBootstrapper
|
|||||||
_kernel.Bind<IReceiptConverterFactory>().To<ReceiptConverterFactory>().InSingletonScope();
|
_kernel.Bind<IReceiptConverterFactory>().To<ReceiptConverterFactory>().InSingletonScope();
|
||||||
_kernel.Bind<IJobStatusReporter>().To<JamesStatusReporter>();
|
_kernel.Bind<IJobStatusReporter>().To<JamesStatusReporter>();
|
||||||
_kernel.Bind<PrintServer>().ToSelf().InSingletonScope();
|
_kernel.Bind<PrintServer>().ToSelf().InSingletonScope();
|
||||||
|
foreach (var handler in _arrivedHandlers)
|
||||||
|
{
|
||||||
|
_kernel.Bind<PrintJobArrivedHandler>().ToConstant(handler);
|
||||||
|
}
|
||||||
_kernel.Bind<IDiscoveredPrintersReceiver>().To<JamesDiscoveredPrintersReceiver>().InSingletonScope();
|
_kernel.Bind<IDiscoveredPrintersReceiver>().To<JamesDiscoveredPrintersReceiver>().InSingletonScope();
|
||||||
_kernel.Bind<PrinterDiscoveryBackgroundTask>().ToSelf().InSingletonScope();
|
_kernel.Bind<PrinterDiscoveryBackgroundTask>().ToSelf().InSingletonScope();
|
||||||
_kernel.Bind<HeartbeatBackgroundTask>().ToSelf().InSingletonScope();
|
_kernel.Bind<HeartbeatBackgroundTask>().ToSelf().InSingletonScope();
|
||||||
|
|||||||
118
EpsonPrintService/PuduRobotClient.cs
Normal file
118
EpsonPrintService/PuduRobotClient.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
138
EpsonPrintService/robot_control.md
Normal file
138
EpsonPrintService/robot_control.md
Normal file
@@ -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 <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
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 <ROBOT_SN> <TABLE_NAME>`
|
||||||
|
|
||||||
|
## 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 <token>` header.
|
||||||
29
EpsonPrintService/send_robot.sh
Normal file
29
EpsonPrintService/send_robot.sh
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ $# -ne 2 ]]; then
|
||||||
|
echo "Usage: $0 <ROBOT_SN> <TABLE_NAME>" >&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
|
||||||
@@ -3,9 +3,28 @@ using Inspectron.Epson.PrintServer.Printers.Utils.BarReceipt;
|
|||||||
using Inspectron.Epson.PrintServer.Printers.Utils.FinalReceipt;
|
using Inspectron.Epson.PrintServer.Printers.Utils.FinalReceipt;
|
||||||
using Inspectron.Epson.PrintServer.Printers.Utils.InvoiceReceipt;
|
using Inspectron.Epson.PrintServer.Printers.Utils.InvoiceReceipt;
|
||||||
using Inspectron.Epson.PrintServer.Printers.Utils.KitchenReceipt;
|
using Inspectron.Epson.PrintServer.Printers.Utils.KitchenReceipt;
|
||||||
|
using Inspectron.Epson.PrintServer.Printers.Utils.MoneyReturnReceipt;
|
||||||
using Inspectron.Epson.PrintServer.Printers.Utils.OrderItems;
|
using Inspectron.Epson.PrintServer.Printers.Utils.OrderItems;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
|
// Non-interactive mode: `dotnet run -- <receiptType> [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)
|
while (true)
|
||||||
{
|
{
|
||||||
Console.Clear();
|
Console.Clear();
|
||||||
@@ -18,10 +37,11 @@ while (true)
|
|||||||
"InvoiceReceipt",
|
"InvoiceReceipt",
|
||||||
"BarReceipt",
|
"BarReceipt",
|
||||||
"OrderItems",
|
"OrderItems",
|
||||||
|
"MoneyReturnReceipt",
|
||||||
"Exit"
|
"Exit"
|
||||||
});
|
});
|
||||||
|
|
||||||
if (receiptType == 5) break;
|
if (receiptType == 6) break;
|
||||||
|
|
||||||
var printerTarget = ShowMenu("Select printer target:", new[]
|
var printerTarget = ShowMenu("Select printer target:", new[]
|
||||||
{
|
{
|
||||||
@@ -96,9 +116,21 @@ static string GetReceiptTypeName(int index) => index switch
|
|||||||
2 => "InvoiceReceipt",
|
2 => "InvoiceReceipt",
|
||||||
3 => "BarReceipt",
|
3 => "BarReceipt",
|
||||||
4 => "OrderItems",
|
4 => "OrderItems",
|
||||||
|
5 => "MoneyReturnReceipt",
|
||||||
_ => "Unknown"
|
_ => "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)
|
static async Task RunTest(int receiptType, int printerTarget)
|
||||||
{
|
{
|
||||||
switch (receiptType)
|
switch (receiptType)
|
||||||
@@ -108,6 +140,7 @@ static async Task RunTest(int receiptType, int printerTarget)
|
|||||||
case 2: await RunInvoiceReceiptTest(printerTarget); break;
|
case 2: await RunInvoiceReceiptTest(printerTarget); break;
|
||||||
case 3: await RunBarReceiptTest(printerTarget); break;
|
case 3: await RunBarReceiptTest(printerTarget); break;
|
||||||
case 4: await RunOrderItemsTest(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);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Inspectron.Epson.PrintServer.Printers.Utils.BarReceipt;
|
|||||||
using Inspectron.Epson.PrintServer.Printers.Utils.FinalRecipe;
|
using Inspectron.Epson.PrintServer.Printers.Utils.FinalRecipe;
|
||||||
using Inspectron.Epson.PrintServer.Printers.Utils.InvoiceReceipt;
|
using Inspectron.Epson.PrintServer.Printers.Utils.InvoiceReceipt;
|
||||||
using Inspectron.Epson.PrintServer.Printers.Utils.KitchenReceipt;
|
using Inspectron.Epson.PrintServer.Printers.Utils.KitchenReceipt;
|
||||||
|
using Inspectron.Epson.PrintServer.Printers.Utils.MoneyReturnReceipt;
|
||||||
using Inspectron.Epson.PrintServer.Printers.Utils.OrderItems;
|
using Inspectron.Epson.PrintServer.Printers.Utils.OrderItems;
|
||||||
|
|
||||||
namespace EpsonTest;
|
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<RefundedItem>
|
||||||
|
{
|
||||||
|
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
|
#endregion
|
||||||
|
|
||||||
#region Print Helpers
|
#region Print Helpers
|
||||||
@@ -498,7 +541,7 @@ public static class TestHelpers
|
|||||||
public static async Task PrintCommandsToEpsonPrinterAsync(
|
public static async Task PrintCommandsToEpsonPrinterAsync(
|
||||||
EpsonPrinter printer,
|
EpsonPrinter printer,
|
||||||
IEnumerable<PrintCommand> commands,
|
IEnumerable<PrintCommand> commands,
|
||||||
bool useDelay = true,
|
bool useDelay = false,
|
||||||
int delayMs = 200)
|
int delayMs = 200)
|
||||||
{
|
{
|
||||||
await printer.FeedLinesAsync(1);
|
await printer.FeedLinesAsync(1);
|
||||||
|
|||||||
75
Inspectron.Epson/PrintServer/Hooks/PrintJobArrivedHandler.cs
Normal file
75
Inspectron.Epson/PrintServer/Hooks/PrintJobArrivedHandler.cs
Normal file
@@ -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<KitchenReceiptModel>(ContentJson);
|
||||||
|
}
|
||||||
|
return _kitchen;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FinalReceiptModel? AsFinal()
|
||||||
|
{
|
||||||
|
if (_finalParsed) return _final;
|
||||||
|
_finalParsed = true;
|
||||||
|
if (ReceiptType == ReceiptConverterFactory.EReceiptType.Receipt)
|
||||||
|
{
|
||||||
|
_final = JsonSerializer.Deserialize<FinalReceiptModel>(ContentJson);
|
||||||
|
}
|
||||||
|
return _final;
|
||||||
|
}
|
||||||
|
|
||||||
|
public InvoiceReceiptModel? AsInvoice()
|
||||||
|
{
|
||||||
|
if (_invoiceParsed) return _invoice;
|
||||||
|
_invoiceParsed = true;
|
||||||
|
if (ReceiptType == ReceiptConverterFactory.EReceiptType.Invoice)
|
||||||
|
{
|
||||||
|
_invoice = JsonSerializer.Deserialize<InvoiceReceiptModel>(ContentJson);
|
||||||
|
}
|
||||||
|
return _invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OrderItemsReceiptModel? AsOrderItems()
|
||||||
|
{
|
||||||
|
if (_orderItemsParsed) return _orderItems;
|
||||||
|
_orderItemsParsed = true;
|
||||||
|
if (ReceiptType == ReceiptConverterFactory.EReceiptType.OrdersOverview)
|
||||||
|
{
|
||||||
|
_orderItems = JsonSerializer.Deserialize<OrderItemsReceiptModel>(ContentJson);
|
||||||
|
}
|
||||||
|
return _orderItems;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Inspectron.Epson.PrintServer.PrintServices;
|
using Inspectron.Epson.PrintServer.Hooks;
|
||||||
|
using Inspectron.Epson.PrintServer.PrintServices;
|
||||||
using Inspectron.Epson.Queue;
|
using Inspectron.Epson.Queue;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -9,17 +10,19 @@ public class PrintLoop
|
|||||||
private readonly global::Inspectron.Epson.Queue.PrintServer _printServer;
|
private readonly global::Inspectron.Epson.Queue.PrintServer _printServer;
|
||||||
private readonly IPrintService _printService;
|
private readonly IPrintService _printService;
|
||||||
private readonly IPrintJobSource _jobSource;
|
private readonly IPrintJobSource _jobSource;
|
||||||
|
private readonly IReadOnlyList<PrintJobArrivedHandler> _arrivedHandlers;
|
||||||
|
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
private CancellationTokenSource? _cancellationSource;
|
private CancellationTokenSource? _cancellationSource;
|
||||||
private CancellationToken _cancellationToken;
|
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<PrintJobArrivedHandler>? arrivedHandlers = null)
|
||||||
{
|
{
|
||||||
_printServer = printServer;
|
_printServer = printServer;
|
||||||
_printService = printService;
|
_printService = printService;
|
||||||
_jobSource = jobSource;
|
_jobSource = jobSource;
|
||||||
|
_arrivedHandlers = arrivedHandlers?.ToList() ?? (IReadOnlyList<PrintJobArrivedHandler>)Array.Empty<PrintJobArrivedHandler>();
|
||||||
|
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +38,23 @@ public class PrintLoop
|
|||||||
while (!_cancellationSource!.Token.IsCancellationRequested)
|
while (!_cancellationSource!.Token.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
var job = await _jobSource.GetNextJobAsync(_cancellationToken);
|
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);
|
_printServer.SubmitJob(job.IP, job);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Inspectron.Epson.PrintServer.Printers.Utils.MoneyReturnReceipt;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public class MoneyReturnReceipt
|
||||||
|
{
|
||||||
|
/// <summary>Base64-encoded restaurant logo (optional). Rendered by the print pipeline, not the text converter.</summary>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>VAT registration number. Note the misspelled JSON key ("Restauant...").</summary>
|
||||||
|
[JsonPropertyName("RestauantVATNumber")]
|
||||||
|
public string? RestaurantVatNumber { get; set; }
|
||||||
|
|
||||||
|
public string ThanksMessage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When true the sale is not subject to VAT and the tax table is replaced by a note.</summary>
|
||||||
|
public bool NoVAT { get; set; }
|
||||||
|
|
||||||
|
public long? RefundReceiptId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Credit note number (shown as "Credit note No. {RefundNumber}").</summary>
|
||||||
|
public long RefundNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When the refund was issued, as Unix epoch milliseconds.</summary>
|
||||||
|
public long RefundTimestamp { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Number of the original transaction/invoice being refunded ("Orig. invoice No.").</summary>
|
||||||
|
public long OriginalTransactionNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When the original payment was made, as Unix epoch milliseconds ("Orig. date").</summary>
|
||||||
|
public long OriginalPaymentTimestamp { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Method the original payment was made with (e.g. "Cash").</summary>
|
||||||
|
public string OriginalPaymentMethod { get; set; }
|
||||||
|
|
||||||
|
public string Currency { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Refund type, e.g. "Full" or "Partial" (rendered as "Full refund").</summary>
|
||||||
|
public string RefundType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Tip amount included in the refund. Shown as "incl. tip" when non-zero.</summary>
|
||||||
|
public decimal TipAmount { get; set; }
|
||||||
|
|
||||||
|
public List<RefundedItem> RefundedItems { get; set; } = new List<RefundedItem>();
|
||||||
|
|
||||||
|
/// <summary>Total refunded amount (positive). Rendered negative on the receipt.</summary>
|
||||||
|
public decimal RefundedAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Standard-rate VAT bucket. Note the misspelled JSON key ("Standart...").</summary>
|
||||||
|
[JsonPropertyName("StandartRate")]
|
||||||
|
public TaxRateBreakdown StandardRate { get; set; }
|
||||||
|
|
||||||
|
public TaxRateBreakdown ReducedRate { get; set; }
|
||||||
|
|
||||||
|
public TaxRateBreakdown SpecialRateForAccommodation { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Method the refund was paid out with (e.g. "Cash").</summary>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>Gross amount at this rate.</summary>
|
||||||
|
public decimal TotalAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Net amount at this rate.</summary>
|
||||||
|
public decimal TotalAmountNetto { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Tax amount at this rate.</summary>
|
||||||
|
public decimal TotalAmountTax { get; set; }
|
||||||
|
}
|
||||||
@@ -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<PrintCommand> Convert(string jsonContent)
|
||||||
|
{
|
||||||
|
var receipt = JsonSerializer.Deserialize<MoneyReturnReceipt>(jsonContent);
|
||||||
|
var converter = new ReceiptConverter(_lineWidth, _bigFontLineWidth);
|
||||||
|
return converter.ConvertToPrintCommands(receipt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
namespace Inspectron.Epson.PrintServer.Printers.Utils.MoneyReturnReceipt;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<PrintCommand> ConvertToPrintCommands(MoneyReturnReceipt receipt)
|
||||||
|
{
|
||||||
|
var commands = new List<PrintCommand>();
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ public class ReceiptConverterFactory : IReceiptConverterFactory
|
|||||||
EReceiptType.NextCourse => new KitchenReceipt.KitchenReceiptConverterAdapter(33, 20),
|
EReceiptType.NextCourse => new KitchenReceipt.KitchenReceiptConverterAdapter(33, 20),
|
||||||
EReceiptType.OrdersOverview => new OrderItemsReceiptConverter(48, 24),
|
EReceiptType.OrdersOverview => new OrderItemsReceiptConverter(48, 24),
|
||||||
EReceiptType.Invoice => new InvoiceReceiptConverter(48, 24),
|
EReceiptType.Invoice => new InvoiceReceiptConverter(48, 24),
|
||||||
|
EReceiptType.MoneyReturn => new MoneyReturnReceipt.MoneyReturnReceiptConverter(48, 24),
|
||||||
_ => throw new ArgumentException($"Unknown receipt type: {receiptType}")
|
_ => throw new ArgumentException($"Unknown receipt type: {receiptType}")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -30,6 +31,7 @@ public class ReceiptConverterFactory : IReceiptConverterFactory
|
|||||||
WorkareaTicket,
|
WorkareaTicket,
|
||||||
NextCourse,
|
NextCourse,
|
||||||
OrdersOverview,
|
OrdersOverview,
|
||||||
Invoice
|
Invoice,
|
||||||
|
MoneyReturn
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
57
data (1).json
Normal file
57
data (1).json
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"sdk": {
|
|
||||||
"version": "8.0.419",
|
|
||||||
"rollForward": "latestPatch"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BIN
money_return_receipt.jpg
Normal file
BIN
money_return_receipt.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
58
money_return_receipt_data.json
Normal file
58
money_return_receipt_data.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user