Files
meezi/src/Meezi.API/Services/Printing/NetworkPrinterService.cs
T
soroush.asadi 9e47a4e60c
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
feat(print): route print jobs through a local agent, fall back to TCP
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>
2026-06-25 12:07:16 +03:30

338 lines
13 KiB
C#

using System.Net.Sockets;
using Meezi.API.Models.Orders;
using Meezi.API.Models.Printing;
using Meezi.Core.Entities;
using Meezi.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace Meezi.API.Services.Printing;
public record PrintResult(bool Success, string? ErrorCode, string? ErrorDetail = null)
{
public static PrintResult Ok() => new(true, null);
public static PrintResult Fail(string code, string? detail = null) => new(false, code, detail);
}
public interface IPrinterService
{
Task<PrintResult> PrintReceiptAsync(string cafeId, string orderId, CancellationToken ct = default);
Task<PrintResult> PrintKitchenTicketAsync(
string cafeId,
string orderId,
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
{
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;
}
public async Task<PrintResult> PrintReceiptAsync(string cafeId, string orderId, CancellationToken ct = default)
{
var ctx = await BuildContextAsync(cafeId, orderId, ct);
if (ctx is null)
return PrintResult.Fail("ORDER_NOT_FOUND");
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 DispatchAsync(
cafeId,
branch.ReceiptPrintDeviceId,
branch.ReceiptPrinterIp,
branch.ReceiptPrinterPort ?? 9100,
bytes,
ct);
}
public async Task<PrintResult> PrintKitchenTicketAsync(
string cafeId,
string orderId,
string? stationId = null,
CancellationToken ct = default)
{
var ctx = await BuildContextAsync(cafeId, orderId, ct);
if (ctx is null)
return PrintResult.Fail("ORDER_NOT_FOUND");
var order = ctx.Value.printCtx.Order;
var activeItems = order.Items.Where(i => !i.IsVoided).ToList();
if (activeItems.Count == 0)
return PrintResult.Ok();
var menuItemIds = activeItems.Select(i => i.MenuItemId).Distinct().ToList();
// Per-item station overrides the category's station; fall back to category.
var itemStations = await (
from m in _db.MenuItems.AsNoTracking()
join c in _db.MenuCategories.AsNoTracking() on m.CategoryId equals c.Id
where menuItemIds.Contains(m.Id) && m.CafeId == cafeId
select new { m.Id, StationId = m.KitchenStationId ?? c.KitchenStationId }
).ToListAsync(ct);
var stationIds = itemStations
.Select(x => x.StationId)
.Where(id => !string.IsNullOrEmpty(id))
.Distinct()
.ToList();
var stations = stationIds.Count == 0
? []
: await _db.KitchenStations
.AsNoTracking()
.Where(s => stationIds.Contains(s.Id) && s.CafeId == cafeId)
.ToListAsync(ct);
var groups = activeItems
.GroupBy(item =>
{
var map = itemStations.FirstOrDefault(c => c.Id == item.MenuItemId);
return map?.StationId;
})
.ToList();
// Optionally reprint a single station only (e.g. just the bar ticket).
if (!string.IsNullOrEmpty(stationId))
{
groups = groups.Where(g => g.Key == stationId).ToList();
if (groups.Count == 0)
return PrintResult.Fail("NO_STATION_ITEMS");
}
PrintResult? lastFail = null;
var anyPrinted = false;
foreach (var group in groups)
{
var station = string.IsNullOrEmpty(group.Key)
? 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.PrintDeviceId) || !string.IsNullOrWhiteSpace(station.PrinterIp)))
{
deviceId = station.PrintDeviceId;
ip = station.PrinterIp;
port = station.PrinterPort;
stationLabel = station.Name;
}
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;
}
else
{
lastFail = PrintResult.Fail("KITCHEN_PRINTER_NOT_CONFIGURED");
continue;
}
var itemsOnly = group.ToList();
var bytes = _receiptBuilder.BuildKitchenTicket(
ctx.Value.printCtx with { StationName = stationLabel },
itemsOnly);
var result = await DispatchAsync(cafeId, deviceId, ip, port, bytes, ct);
if (result.Success)
anyPrinted = true;
else
lastFail = result;
}
return anyPrinted ? PrintResult.Ok() : lastFail ?? PrintResult.Fail("KITCHEN_PRINTER_NOT_CONFIGURED");
}
public async Task<PrintResult> TestPrintAsync(string printerIp, int port, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(printerIp))
return PrintResult.Fail("PRINTER_NOT_CONFIGURED");
var bytes = _receiptBuilder.BuildTestPage();
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,
CancellationToken ct)
{
var order = await _orders.GetOrderAsync(cafeId, orderId, ct);
if (order is null || string.IsNullOrEmpty(order.BranchId))
return null;
var branch = await _db.Branches
.AsNoTracking()
.Include(b => b.Cafe)
.FirstOrDefaultAsync(b => b.Id == order.BranchId && b.CafeId == cafeId, ct);
if (branch is null)
return null;
var print = new ReceiptPrintContext(
order,
branch.Cafe.Name,
branch.Name,
branch.ReceiptHeader,
branch.ReceiptFooter,
branch.WifiPassword,
branch.PaperWidthMm is 58 or 80 ? branch.PaperWidthMm : 80,
branch.AutoCutEnabled);
return (branch, printCtx: print);
}
private async Task<PrintResult> SendToPrinterAsync(
string ip,
int port,
byte[] data,
CancellationToken ct)
{
try
{
using var client = new TcpClient();
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(5));
await client.ConnectAsync(ip, port, timeout.Token);
await using var stream = client.GetStream();
await stream.WriteAsync(data, timeout.Token);
await stream.FlushAsync(timeout.Token);
_logger.LogInformation("Printed {Bytes} bytes to {Ip}:{Port}", data.Length, ip, port);
return PrintResult.Ok();
}
catch (Exception ex)
{
_logger.LogError(ex, "Print failed to {Ip}:{Port}", ip, port);
return PrintResult.Fail("PRINTER_CONNECTION_FAILED", ex.Message);
}
}
}
public static class PrinterBackgroundJobs
{
public static void QueueReceiptPrint(IServiceScopeFactory scopeFactory, string cafeId, string orderId)
{
_ = Task.Run(async () =>
{
await using var scope = scopeFactory.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<NetworkPrinterService>>();
try
{
var printer = scope.ServiceProvider.GetRequiredService<IPrinterService>();
var result = await printer.PrintReceiptAsync(cafeId, orderId, CancellationToken.None);
if (!result.Success)
logger.LogWarning("Auto-print receipt failed for {OrderId}: {Code}", orderId, result.ErrorCode);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Auto-print receipt failed for order {OrderId}", orderId);
}
});
}
public static void QueueKitchenPrint(IServiceScopeFactory scopeFactory, string cafeId, string orderId)
{
_ = Task.Run(async () =>
{
await using var scope = scopeFactory.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<NetworkPrinterService>>();
try
{
var printer = scope.ServiceProvider.GetRequiredService<IPrinterService>();
var result = await printer.PrintKitchenTicketAsync(cafeId, orderId, null, CancellationToken.None);
if (!result.Success)
logger.LogWarning("Kitchen print failed for {OrderId}: {Code}", orderId, result.ErrorCode);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Kitchen print failed for order {OrderId}", orderId);
}
});
}
}