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,
|
||||
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
|
||||
@@ -29,17 +30,20 @@ public class NetworkPrinterService : IPrinterService
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IOrderService _orders;
|
||||
private readonly ReceiptBuilder _receiptBuilder;
|
||||
private readonly IPrintAgentRegistry _agents;
|
||||
private readonly ILogger<NetworkPrinterService> _logger;
|
||||
|
||||
public NetworkPrinterService(
|
||||
AppDbContext db,
|
||||
IOrderService orders,
|
||||
ReceiptBuilder receiptBuilder,
|
||||
IPrintAgentRegistry agents,
|
||||
ILogger<NetworkPrinterService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_orders = orders;
|
||||
_receiptBuilder = receiptBuilder;
|
||||
_agents = agents;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -49,13 +53,16 @@ public class NetworkPrinterService : IPrinterService
|
||||
if (ctx is null)
|
||||
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");
|
||||
|
||||
var bytes = _receiptBuilder.BuildReceipt(ctx.Value.printCtx);
|
||||
return await SendToPrinterAsync(
|
||||
ctx.Value.branch.ReceiptPrinterIp!,
|
||||
ctx.Value.branch.ReceiptPrinterPort ?? 9100,
|
||||
return await DispatchAsync(
|
||||
cafeId,
|
||||
branch.ReceiptPrintDeviceId,
|
||||
branch.ReceiptPrinterIp,
|
||||
branch.ReceiptPrinterPort ?? 9100,
|
||||
bytes,
|
||||
ct);
|
||||
}
|
||||
@@ -122,18 +129,21 @@ public class NetworkPrinterService : IPrinterService
|
||||
? null
|
||||
: stations.FirstOrDefault(s => s.Id == group.Key);
|
||||
|
||||
string? deviceId;
|
||||
string? ip;
|
||||
int port;
|
||||
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;
|
||||
port = station.PrinterPort;
|
||||
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;
|
||||
port = ctx.Value.branch.KitchenPrinterPort ?? 9100;
|
||||
}
|
||||
@@ -147,7 +157,7 @@ public class NetworkPrinterService : IPrinterService
|
||||
var bytes = _receiptBuilder.BuildKitchenTicket(
|
||||
ctx.Value.printCtx with { StationName = stationLabel },
|
||||
itemsOnly);
|
||||
var result = await SendToPrinterAsync(ip!, port, bytes, ct);
|
||||
var result = await DispatchAsync(cafeId, deviceId, ip, port, bytes, ct);
|
||||
if (result.Success)
|
||||
anyPrinted = true;
|
||||
else
|
||||
@@ -166,6 +176,66 @@ public class NetworkPrinterService : IPrinterService
|
||||
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(
|
||||
string cafeId,
|
||||
string orderId,
|
||||
|
||||
@@ -18,6 +18,11 @@ public class Branch : TenantEntity
|
||||
public int? ReceiptPrinterPort { get; set; }
|
||||
public string? KitchenPrinterIp { 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 bool AutoCutEnabled { get; set; } = true;
|
||||
public string? ReceiptHeader { get; set; }
|
||||
|
||||
@@ -7,6 +7,11 @@ public class KitchenStation : TenantEntity
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? PrinterIp { get; set; }
|
||||
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 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")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("KitchenPrintDeviceId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("KitchenPrinterIp")
|
||||
.HasMaxLength(45)
|
||||
.HasColumnType("character varying(45)");
|
||||
@@ -192,6 +195,9 @@ namespace Meezi.Infrastructure.Data.Migrations
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("ReceiptPrintDeviceId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ReceiptPrinterIp")
|
||||
.HasMaxLength(45)
|
||||
.HasColumnType("character varying(45)");
|
||||
@@ -1313,6 +1319,9 @@ namespace Meezi.Infrastructure.Data.Migrations
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("PrintDeviceId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PrinterIp")
|
||||
.HasMaxLength(45)
|
||||
.HasColumnType("character varying(45)");
|
||||
|
||||
Reference in New Issue
Block a user