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,
|
||||
|
||||
Reference in New Issue
Block a user