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 PrintReceiptAsync(string cafeId, string orderId, CancellationToken ct = default); Task PrintKitchenTicketAsync( string cafeId, string orderId, string? stationId = null, CancellationToken ct = default); Task TestPrintAsync(string printerIp, int port, CancellationToken ct = default); Task 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 _logger; public NetworkPrinterService( AppDbContext db, IOrderService orders, ReceiptBuilder receiptBuilder, IPrintAgentRegistry agents, ILogger logger) { _db = db; _orders = orders; _receiptBuilder = receiptBuilder; _agents = agents; _logger = logger; } public async Task 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 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 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 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); } /// /// 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). /// private async Task 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 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>(); try { var printer = scope.ServiceProvider.GetRequiredService(); 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>(); try { var printer = scope.ServiceProvider.GetRequiredService(); 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); } }); } }