feat(print): route print jobs through a local agent, fall back to TCP
CI/CD / CI · API (dotnet build + test) (push) Successful in 47s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m9s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 1m40s
CI/CD / CI · API (dotnet build + test) (push) Successful in 47s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m9s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 1m40s
Phase 2. NetworkPrinterService now builds the ESC/POS bytes (as before) and dispatches them via a new router: if the receipt/kitchen/station printer is mapped to a PrintDevice whose agent is online, the bytes are sent to that agent over the hub and we await its ack; otherwise it falls back to a direct TCP connection (raw IP), so existing on-prem/reachable printers keep working unchanged. Adds nullable mapping columns Branch.ReceiptPrintDeviceId / KitchenPrintDeviceId and KitchenStation.PrintDeviceId (additive migration), plus TestPrintDeviceAsync for testing an agent printer. The cloud can now reach LAN/USB printers it never could. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ public interface IPrinterService
|
|||||||
string? stationId = null,
|
string? stationId = null,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
Task<PrintResult> TestPrintAsync(string printerIp, int port, CancellationToken ct = default);
|
Task<PrintResult> TestPrintAsync(string printerIp, int port, CancellationToken ct = default);
|
||||||
|
Task<PrintResult> TestPrintDeviceAsync(string cafeId, string deviceId, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class NetworkPrinterService : IPrinterService
|
public class NetworkPrinterService : IPrinterService
|
||||||
@@ -29,17 +30,20 @@ public class NetworkPrinterService : IPrinterService
|
|||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly IOrderService _orders;
|
private readonly IOrderService _orders;
|
||||||
private readonly ReceiptBuilder _receiptBuilder;
|
private readonly ReceiptBuilder _receiptBuilder;
|
||||||
|
private readonly IPrintAgentRegistry _agents;
|
||||||
private readonly ILogger<NetworkPrinterService> _logger;
|
private readonly ILogger<NetworkPrinterService> _logger;
|
||||||
|
|
||||||
public NetworkPrinterService(
|
public NetworkPrinterService(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
IOrderService orders,
|
IOrderService orders,
|
||||||
ReceiptBuilder receiptBuilder,
|
ReceiptBuilder receiptBuilder,
|
||||||
|
IPrintAgentRegistry agents,
|
||||||
ILogger<NetworkPrinterService> logger)
|
ILogger<NetworkPrinterService> logger)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_orders = orders;
|
_orders = orders;
|
||||||
_receiptBuilder = receiptBuilder;
|
_receiptBuilder = receiptBuilder;
|
||||||
|
_agents = agents;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,13 +53,16 @@ public class NetworkPrinterService : IPrinterService
|
|||||||
if (ctx is null)
|
if (ctx is null)
|
||||||
return PrintResult.Fail("ORDER_NOT_FOUND");
|
return PrintResult.Fail("ORDER_NOT_FOUND");
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(ctx.Value.branch.ReceiptPrinterIp))
|
var branch = ctx.Value.branch;
|
||||||
|
if (string.IsNullOrWhiteSpace(branch.ReceiptPrintDeviceId) && string.IsNullOrWhiteSpace(branch.ReceiptPrinterIp))
|
||||||
return PrintResult.Fail("PRINTER_NOT_CONFIGURED");
|
return PrintResult.Fail("PRINTER_NOT_CONFIGURED");
|
||||||
|
|
||||||
var bytes = _receiptBuilder.BuildReceipt(ctx.Value.printCtx);
|
var bytes = _receiptBuilder.BuildReceipt(ctx.Value.printCtx);
|
||||||
return await SendToPrinterAsync(
|
return await DispatchAsync(
|
||||||
ctx.Value.branch.ReceiptPrinterIp!,
|
cafeId,
|
||||||
ctx.Value.branch.ReceiptPrinterPort ?? 9100,
|
branch.ReceiptPrintDeviceId,
|
||||||
|
branch.ReceiptPrinterIp,
|
||||||
|
branch.ReceiptPrinterPort ?? 9100,
|
||||||
bytes,
|
bytes,
|
||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
@@ -122,18 +129,21 @@ public class NetworkPrinterService : IPrinterService
|
|||||||
? null
|
? null
|
||||||
: stations.FirstOrDefault(s => s.Id == group.Key);
|
: stations.FirstOrDefault(s => s.Id == group.Key);
|
||||||
|
|
||||||
|
string? deviceId;
|
||||||
string? ip;
|
string? ip;
|
||||||
int port;
|
int port;
|
||||||
string? stationLabel = null;
|
string? stationLabel = null;
|
||||||
|
|
||||||
if (station is not null && !string.IsNullOrWhiteSpace(station.PrinterIp))
|
if (station is not null && (!string.IsNullOrWhiteSpace(station.PrintDeviceId) || !string.IsNullOrWhiteSpace(station.PrinterIp)))
|
||||||
{
|
{
|
||||||
|
deviceId = station.PrintDeviceId;
|
||||||
ip = station.PrinterIp;
|
ip = station.PrinterIp;
|
||||||
port = station.PrinterPort;
|
port = station.PrinterPort;
|
||||||
stationLabel = station.Name;
|
stationLabel = station.Name;
|
||||||
}
|
}
|
||||||
else if (!string.IsNullOrWhiteSpace(ctx.Value.branch.KitchenPrinterIp))
|
else if (!string.IsNullOrWhiteSpace(ctx.Value.branch.KitchenPrintDeviceId) || !string.IsNullOrWhiteSpace(ctx.Value.branch.KitchenPrinterIp))
|
||||||
{
|
{
|
||||||
|
deviceId = ctx.Value.branch.KitchenPrintDeviceId;
|
||||||
ip = ctx.Value.branch.KitchenPrinterIp;
|
ip = ctx.Value.branch.KitchenPrinterIp;
|
||||||
port = ctx.Value.branch.KitchenPrinterPort ?? 9100;
|
port = ctx.Value.branch.KitchenPrinterPort ?? 9100;
|
||||||
}
|
}
|
||||||
@@ -147,7 +157,7 @@ public class NetworkPrinterService : IPrinterService
|
|||||||
var bytes = _receiptBuilder.BuildKitchenTicket(
|
var bytes = _receiptBuilder.BuildKitchenTicket(
|
||||||
ctx.Value.printCtx with { StationName = stationLabel },
|
ctx.Value.printCtx with { StationName = stationLabel },
|
||||||
itemsOnly);
|
itemsOnly);
|
||||||
var result = await SendToPrinterAsync(ip!, port, bytes, ct);
|
var result = await DispatchAsync(cafeId, deviceId, ip, port, bytes, ct);
|
||||||
if (result.Success)
|
if (result.Success)
|
||||||
anyPrinted = true;
|
anyPrinted = true;
|
||||||
else
|
else
|
||||||
@@ -166,6 +176,66 @@ public class NetworkPrinterService : IPrinterService
|
|||||||
return await SendToPrinterAsync(printerIp.Trim(), port, bytes, ct);
|
return await SendToPrinterAsync(printerIp.Trim(), port, bytes, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<PrintResult> TestPrintDeviceAsync(string cafeId, string deviceId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var device = await _db.PrintDevices.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(d => d.Id == deviceId && d.CafeId == cafeId, ct);
|
||||||
|
if (device is null)
|
||||||
|
return PrintResult.Fail("DEVICE_NOT_FOUND");
|
||||||
|
if (!_agents.IsOnline(device.AgentId))
|
||||||
|
return PrintResult.Fail("AGENT_OFFLINE");
|
||||||
|
|
||||||
|
var bytes = _receiptBuilder.BuildTestPage();
|
||||||
|
var outcome = await _agents.SendJobAsync(device.AgentId, new PrintJobRequest(device.SystemName, bytes), ct);
|
||||||
|
return outcome.Success ? PrintResult.Ok() : PrintResult.Fail("AGENT_PRINT_FAILED", outcome.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Send bytes to a printer, preferring a local print agent when one is mapped and
|
||||||
|
/// online (the only way to reach a LAN/USB printer from the cloud); otherwise fall
|
||||||
|
/// back to a direct TCP connection (on-prem deployments / reachable printers).
|
||||||
|
/// </summary>
|
||||||
|
private async Task<PrintResult> DispatchAsync(
|
||||||
|
string cafeId,
|
||||||
|
string? deviceId,
|
||||||
|
string? ip,
|
||||||
|
int port,
|
||||||
|
byte[] bytes,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(deviceId))
|
||||||
|
{
|
||||||
|
var device = await _db.PrintDevices.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(d => d.Id == deviceId && d.CafeId == cafeId, ct);
|
||||||
|
|
||||||
|
if (device is not null && _agents.IsOnline(device.AgentId))
|
||||||
|
{
|
||||||
|
var outcome = await _agents.SendJobAsync(
|
||||||
|
device.AgentId, new PrintJobRequest(device.SystemName, bytes), ct);
|
||||||
|
if (outcome.Success)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Printed {Bytes} bytes via agent {Agent} → {Printer}",
|
||||||
|
bytes.Length, device.AgentId, device.SystemName);
|
||||||
|
return PrintResult.Ok();
|
||||||
|
}
|
||||||
|
_logger.LogWarning("Agent print failed ({Printer}): {Error}", device.SystemName, outcome.Error);
|
||||||
|
// Only surface the failure if there's no IP to fall back to.
|
||||||
|
if (string.IsNullOrWhiteSpace(ip))
|
||||||
|
return PrintResult.Fail("AGENT_PRINT_FAILED", outcome.Error);
|
||||||
|
}
|
||||||
|
else if (string.IsNullOrWhiteSpace(ip))
|
||||||
|
{
|
||||||
|
return PrintResult.Fail("AGENT_OFFLINE");
|
||||||
|
}
|
||||||
|
// Agent offline/missing but an IP is configured → fall through to TCP.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(ip))
|
||||||
|
return await SendToPrinterAsync(ip!.Trim(), port, bytes, ct);
|
||||||
|
|
||||||
|
return PrintResult.Fail("PRINTER_NOT_CONFIGURED");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<(Branch branch, ReceiptPrintContext printCtx)?> BuildContextAsync(
|
private async Task<(Branch branch, ReceiptPrintContext printCtx)?> BuildContextAsync(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
string orderId,
|
string orderId,
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ public class Branch : TenantEntity
|
|||||||
public int? ReceiptPrinterPort { get; set; }
|
public int? ReceiptPrinterPort { get; set; }
|
||||||
public string? KitchenPrinterIp { get; set; }
|
public string? KitchenPrinterIp { get; set; }
|
||||||
public int? KitchenPrinterPort { get; set; }
|
public int? KitchenPrinterPort { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional <see cref="PrintDevice"/> to route through a local print agent
|
||||||
|
/// (preferred over the raw IP when its agent is online). Cloud-hosted cafés use this.</summary>
|
||||||
|
public string? ReceiptPrintDeviceId { get; set; }
|
||||||
|
public string? KitchenPrintDeviceId { get; set; }
|
||||||
public int PaperWidthMm { get; set; } = 80;
|
public int PaperWidthMm { get; set; } = 80;
|
||||||
public bool AutoCutEnabled { get; set; } = true;
|
public bool AutoCutEnabled { get; set; } = true;
|
||||||
public string? ReceiptHeader { get; set; }
|
public string? ReceiptHeader { get; set; }
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ public class KitchenStation : TenantEntity
|
|||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public string? PrinterIp { get; set; }
|
public string? PrinterIp { get; set; }
|
||||||
public int PrinterPort { get; set; } = 9100;
|
public int PrinterPort { get; set; } = 9100;
|
||||||
|
|
||||||
|
/// <summary>Optional <see cref="PrintDevice"/> routed through a local print agent
|
||||||
|
/// (preferred over <see cref="PrinterIp"/> when its agent is online).</summary>
|
||||||
|
public string? PrintDeviceId { get; set; }
|
||||||
|
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
|
|
||||||
public Cafe Cafe { get; set; } = null!;
|
public Cafe Cafe { get; set; } = null!;
|
||||||
|
|||||||
+3652
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Meezi.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPrintDeviceRouting : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "PrintDeviceId",
|
||||||
|
table: "KitchenStations",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "KitchenPrintDeviceId",
|
||||||
|
table: "Branches",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ReceiptPrintDeviceId",
|
||||||
|
table: "Branches",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PrintDeviceId",
|
||||||
|
table: "KitchenStations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "KitchenPrintDeviceId",
|
||||||
|
table: "Branches");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ReceiptPrintDeviceId",
|
||||||
|
table: "Branches");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -155,6 +155,9 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
b.Property<bool>("IsActive")
|
b.Property<bool>("IsActive")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("KitchenPrintDeviceId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("KitchenPrinterIp")
|
b.Property<string>("KitchenPrinterIp")
|
||||||
.HasMaxLength(45)
|
.HasMaxLength(45)
|
||||||
.HasColumnType("character varying(45)");
|
.HasColumnType("character varying(45)");
|
||||||
@@ -192,6 +195,9 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
.HasMaxLength(500)
|
.HasMaxLength(500)
|
||||||
.HasColumnType("character varying(500)");
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("ReceiptPrintDeviceId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("ReceiptPrinterIp")
|
b.Property<string>("ReceiptPrinterIp")
|
||||||
.HasMaxLength(45)
|
.HasMaxLength(45)
|
||||||
.HasColumnType("character varying(45)");
|
.HasColumnType("character varying(45)");
|
||||||
@@ -1313,6 +1319,9 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("character varying(100)");
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("PrintDeviceId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("PrinterIp")
|
b.Property<string>("PrinterIp")
|
||||||
.HasMaxLength(45)
|
.HasMaxLength(45)
|
||||||
.HasColumnType("character varying(45)");
|
.HasColumnType("character varying(45)");
|
||||||
|
|||||||
Reference in New Issue
Block a user