257 lines
9.1 KiB
C#
257 lines
9.1 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,
|
||
|
|
CancellationToken ct = default);
|
||
|
|
Task<PrintResult> TestPrintAsync(string printerIp, int port, CancellationToken ct = default);
|
||
|
|
}
|
||
|
|
|
||
|
|
public class NetworkPrinterService : IPrinterService
|
||
|
|
{
|
||
|
|
private readonly AppDbContext _db;
|
||
|
|
private readonly IOrderService _orders;
|
||
|
|
private readonly ReceiptBuilder _receiptBuilder;
|
||
|
|
private readonly ILogger<NetworkPrinterService> _logger;
|
||
|
|
|
||
|
|
public NetworkPrinterService(
|
||
|
|
AppDbContext db,
|
||
|
|
IOrderService orders,
|
||
|
|
ReceiptBuilder receiptBuilder,
|
||
|
|
ILogger<NetworkPrinterService> logger)
|
||
|
|
{
|
||
|
|
_db = db;
|
||
|
|
_orders = orders;
|
||
|
|
_receiptBuilder = receiptBuilder;
|
||
|
|
_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");
|
||
|
|
|
||
|
|
if (string.IsNullOrWhiteSpace(ctx.Value.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,
|
||
|
|
bytes,
|
||
|
|
ct);
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<PrintResult> PrintKitchenTicketAsync(
|
||
|
|
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 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();
|
||
|
|
var categoryStations = 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, c.KitchenStationId }
|
||
|
|
).ToListAsync(ct);
|
||
|
|
|
||
|
|
var stationIds = categoryStations
|
||
|
|
.Select(x => x.KitchenStationId)
|
||
|
|
.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 cat = categoryStations.FirstOrDefault(c => c.Id == item.MenuItemId);
|
||
|
|
return cat?.KitchenStationId;
|
||
|
|
})
|
||
|
|
.ToList();
|
||
|
|
|
||
|
|
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? ip;
|
||
|
|
int port;
|
||
|
|
string? stationLabel = null;
|
||
|
|
|
||
|
|
if (station is not null && !string.IsNullOrWhiteSpace(station.PrinterIp))
|
||
|
|
{
|
||
|
|
ip = station.PrinterIp;
|
||
|
|
port = station.PrinterPort;
|
||
|
|
stationLabel = station.Name;
|
||
|
|
}
|
||
|
|
else if (!string.IsNullOrWhiteSpace(ctx.Value.branch.KitchenPrinterIp))
|
||
|
|
{
|
||
|
|
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 SendToPrinterAsync(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);
|
||
|
|
}
|
||
|
|
|
||
|
|
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, 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);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|