using System.Text.Json; using Meezi.API.Models.Orders; using Meezi.API.Services.Printing; using Meezi.Core.Delivery; using Meezi.Core.Entities; using Meezi.Core.Enums; using Meezi.Core.Interfaces; using Meezi.Infrastructure.Data; using Microsoft.EntityFrameworkCore; namespace Meezi.API.Services.Delivery; public record DeliveryProcessResult(bool Success, string? MeeziOrderId, string? ErrorCode, string? Message); public interface IDeliveryOrderProcessor { Task ProcessAsync( string webhookLogId, UnifiedDeliveryOrder unified, CancellationToken ct = default); } public class DeliveryOrderProcessor : IDeliveryOrderProcessor { private readonly AppDbContext _db; private readonly IKdsNotifier _kds; private readonly ICommissionCalculator _commission; private readonly IInventoryService _inventory; private readonly ISnappfoodClient _snappfood; private readonly ITap30Client _tap30; private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; public DeliveryOrderProcessor( AppDbContext db, IKdsNotifier kds, ICommissionCalculator commission, IInventoryService inventory, ISnappfoodClient snappfood, ITap30Client tap30, IServiceScopeFactory scopeFactory, ILogger logger) { _db = db; _kds = kds; _commission = commission; _inventory = inventory; _snappfood = snappfood; _tap30 = tap30; _scopeFactory = scopeFactory; _logger = logger; } public async Task ProcessAsync( string webhookLogId, UnifiedDeliveryOrder unified, CancellationToken ct = default) { var log = await _db.WebhookLogs.FirstOrDefaultAsync(w => w.Id == webhookLogId, ct); if (log is null) return new DeliveryProcessResult(false, null, "LOG_NOT_FOUND", "Webhook log missing."); log.AttemptCount++; try { var cafe = await ResolveCafeAsync(unified, ct); if (cafe is null) { await FailLogAsync(log, "Unknown vendor.", ct); return new DeliveryProcessResult(false, null, "VENDOR_NOT_FOUND", "Unknown vendor."); } log.CafeId = cafe.Id; log.ExternalOrderId = unified.ExternalId; var duplicate = await _db.Orders.AnyAsync( o => o.CafeId == cafe.Id && o.DeliveryPlatform == unified.Platform && o.ExternalOrderId == unified.ExternalId, ct); if (duplicate) { await CompleteLogAsync(log, null, success: true, error: null, ct); return new DeliveryProcessResult(true, null, null, "Duplicate ignored."); } var branchId = await _db.Branches .Where(b => b.CafeId == cafe.Id && b.IsActive) .OrderBy(b => b.Name) .Select(b => b.Id) .FirstOrDefaultAsync(ct); var menuItems = await _db.MenuItems .Where(m => m.CafeId == cafe.Id && m.IsAvailable) .ToListAsync(ct); var orderItems = new List(); decimal subtotal = 0; foreach (var line in unified.Items) { var menuItem = menuItems.FirstOrDefault(m => (!string.IsNullOrEmpty(line.Sku) && m.Id == line.Sku) || m.Name.Equals(line.Name, StringComparison.OrdinalIgnoreCase) || (m.NameEn != null && m.NameEn.Equals(line.Name, StringComparison.OrdinalIgnoreCase))); if (menuItem is null) { _logger.LogWarning( "Delivery {Platform} item {Name} not matched for cafe {CafeId}", unified.Platform, line.Name, cafe.Id); continue; } subtotal += line.UnitPrice * line.Quantity; orderItems.Add(new OrderItem { MenuItemId = menuItem.Id, Quantity = line.Quantity, UnitPrice = line.UnitPrice, Notes = line.Notes ?? unified.Platform.ToString() }); } if (orderItems.Count == 0) { await FailLogAsync(log, "No menu items matched.", ct); return new DeliveryProcessResult(false, null, "INVALID_MENU_ITEMS", "No menu items matched."); } var platformCommission = await _commission.CalculateForOrderAsync(cafe.Id, unified, ct); var taxRate = cafe.DefaultTaxRate > 0 ? cafe.DefaultTaxRate : await _db.Taxes.Where(t => t.CafeId == cafe.Id && t.IsDefault) .Select(t => t.Rate) .FirstOrDefaultAsync(ct); if (taxRate == 0) taxRate = 9m; var gross = unified.Payment.Total > 0 ? unified.Payment.Total : subtotal; var taxTotal = Math.Round((gross - platformCommission) * taxRate / 100m, 0); var total = gross; var displayNumber = await AllocateDisplayNumberAsync(cafe.Id, ct); var order = new Order { CafeId = cafe.Id, BranchId = branchId, OrderType = unified.Delivery.Type == "pickup" ? OrderType.Takeaway : OrderType.Delivery, Source = MapSource(unified.Platform), Status = MapStatus(unified.Status), DisplayNumber = displayNumber, ExternalOrderId = unified.ExternalId, DeliveryPlatform = unified.Platform, PlatformCommission = platformCommission, DeliveryMetaJson = JsonSerializer.Serialize(unified.Delivery), Subtotal = subtotal, TaxTotal = taxTotal, Total = total, Items = orderItems }; if (unified.Platform == DeliveryPlatform.Snappfood) order.SnappfoodOrderId = unified.ExternalId; if (!string.IsNullOrWhiteSpace(unified.Customer.Phone)) { var phone = unified.Customer.Phone.Trim(); var customer = await _db.Customers .FirstOrDefaultAsync(c => c.CafeId == cafe.Id && c.Phone == phone, ct); if (customer is null) { customer = new Customer { CafeId = cafe.Id, Name = unified.Customer.Name, Phone = phone, Group = CustomerGroup.New }; _db.Customers.Add(customer); await _db.SaveChangesAsync(ct); } order.CustomerId = customer.Id; order.GuestName = unified.Customer.Name; order.GuestPhone = phone; } else { order.GuestName = unified.Customer.Name; } if (unified.Payment.IsPaid && total > 0) { order.Payments.Add(new Payment { Method = unified.Payment.Method.Equals("cash", StringComparison.OrdinalIgnoreCase) ? PaymentMethod.Cash : PaymentMethod.Card, Amount = total, Status = PaymentStatus.Completed }); } _db.Orders.Add(order); await _db.SaveChangesAsync(ct); await TryDeductInventoryAsync(cafe.Id, orderItems, ct); var loaded = await _db.Orders .Include(o => o.Items) .ThenInclude(i => i.MenuItem) .Include(o => o.Table) .FirstAsync(o => o.Id == order.Id, ct); await _kds.NotifyOrderCreatedAsync(cafe.Id, MapLive(loaded), ct); PrinterBackgroundJobs.QueueKitchenPrint(_scopeFactory, cafe.Id, order.Id); await AcknowledgePlatformAsync(unified, ct); await CompleteLogAsync(log, order.Id, success: true, error: null, ct); return new DeliveryProcessResult(true, order.Id, null, null); } catch (Exception ex) { _logger.LogError(ex, "Delivery order processing failed for log {LogId}", webhookLogId); await FailLogAsync(log, ex.Message, ct); throw; } } private async Task ResolveCafeAsync(UnifiedDeliveryOrder unified, CancellationToken ct) => unified.Platform switch { DeliveryPlatform.Snappfood => await _db.Cafes .FirstOrDefaultAsync(c => c.SnappfoodVendorId == unified.VendorId, ct), DeliveryPlatform.Tap30 => await _db.Cafes .FirstOrDefaultAsync(c => c.Tap30VendorId == unified.VendorId, ct), DeliveryPlatform.Digikala => await _db.Cafes .FirstOrDefaultAsync(c => c.DigikalaVendorId == unified.VendorId, ct), _ => null }; private async Task AcknowledgePlatformAsync(UnifiedDeliveryOrder unified, CancellationToken ct) { switch (unified.Platform) { case DeliveryPlatform.Snappfood: await _snappfood.AcknowledgeOrderAsync(unified.ExternalId, ct); break; case DeliveryPlatform.Tap30: await _tap30.AcknowledgeOrderAsync(unified.ExternalId, ct); break; } } private async Task TryDeductInventoryAsync( string cafeId, List items, CancellationToken ct) { if (items.Count == 0) return; var orderId = items[0].OrderId; await _inventory.DeductForOrderAsync( cafeId, orderId, items.Select(i => (i.MenuItemId, i.Quantity)).ToList(), ct); } private static OrderSource MapSource(DeliveryPlatform platform) => platform switch { DeliveryPlatform.Snappfood => OrderSource.SnappFood, DeliveryPlatform.Tap30 => OrderSource.Tap30, DeliveryPlatform.Digikala => OrderSource.Digikala, _ => OrderSource.Pos }; private static OrderStatus MapStatus(UnifiedDeliveryStatus status) => status switch { UnifiedDeliveryStatus.Pending => OrderStatus.Pending, UnifiedDeliveryStatus.Confirmed => OrderStatus.Confirmed, UnifiedDeliveryStatus.Preparing => OrderStatus.Preparing, UnifiedDeliveryStatus.Ready => OrderStatus.Ready, UnifiedDeliveryStatus.Delivered => OrderStatus.Delivered, UnifiedDeliveryStatus.Cancelled => OrderStatus.Cancelled, _ => OrderStatus.Confirmed }; private async Task FailLogAsync(WebhookLog log, string error, CancellationToken ct) { log.Success = false; log.Processed = true; log.ErrorMessage = error.Length > 2000 ? error[..2000] : error; log.ProcessedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); if (log.AttemptCount >= 3) _logger.LogError( "Delivery webhook dead-letter: platform {Platform} external {ExternalId} — {Error}", log.Platform, log.ExternalOrderId, error); } private async Task CompleteLogAsync( WebhookLog log, string? meeziOrderId, bool success, string? error, CancellationToken ct) { log.Success = success; log.Processed = true; log.MeeziOrderId = meeziOrderId; log.ErrorMessage = error; log.ProcessedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); } private async Task AllocateDisplayNumberAsync(string cafeId, CancellationToken ct) { var max = await _db.Orders .Where(o => o.CafeId == cafeId) .MaxAsync(o => (int?)o.DisplayNumber, ct); return (max ?? 0) + 1; } private static LiveOrderDto MapLive(Order o) => new( o.Id, o.DisplayNumber > 0 ? o.DisplayNumber : ReceiptPrintFormatting.StableDisplayNumberFromId(o.Id), o.Status, o.Table?.Number, o.OrderType, o.Total, o.CreatedAt, o.Items.Select(i => new OrderItemDto( i.Id, i.MenuItemId, i.MenuItem?.Name ?? "", i.Quantity, i.UnitPrice, i.Notes, i.IsVoided, i.VoidedAt)).ToList(), o.Source); }