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

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:
soroush.asadi
2026-06-25 12:07:16 +03:30
parent cb57c61a11
commit 9e47a4e60c
6 changed files with 3796 additions and 7 deletions
@@ -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,
+5
View File
@@ -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!;
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)");