using Meezi.API.Models.Orders; using Meezi.API.Models.Public; using Meezi.API.Services.Delivery; using Meezi.API.Services.Printing; using Meezi.Core.Entities; using Meezi.Core.Enums; using Meezi.Core.Utilities; using Meezi.Core.Interfaces; using Meezi.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; namespace Meezi.API.Services; public record OrderServiceResult(bool Success, T? Data, string? ErrorCode = null, string? Field = null); public interface IOrderService { Task> GetOrdersAsync(string cafeId, OrderStatus? status, CancellationToken cancellationToken = default); Task> GetOpenOrdersAsync(string cafeId, string? search, CancellationToken cancellationToken = default); Task> GetLiveOrdersAsync(string cafeId, CancellationToken cancellationToken = default); Task GetOrderAsync(string cafeId, string orderId, CancellationToken cancellationToken = default); Task GetActiveOrderByTableAsync(string cafeId, string tableId, CancellationToken cancellationToken = default); Task> CreateOrderAsync(string cafeId, ITenantContext tenant, CreateOrderRequest request, CancellationToken cancellationToken = default); Task CreateGuestOrderAsync( string cafeId, CreateOrderRequest request, string? guestPhone, string? guestName, CancellationToken cancellationToken = default); Task<(GuestQrOrderPlacedDto? Data, string? ErrorCode, string? Message)> PlaceBranchGuestOrderAsync( string cafeId, string branchId, PlaceGuestOrderRequest request, CancellationToken cancellationToken = default); Task> AppendOrderItemsAsync( string cafeId, string orderId, AppendOrderItemsRequest request, CancellationToken cancellationToken = default); Task> UpdateOrderSessionAsync( string cafeId, string orderId, UpdateOrderSessionRequest request, CancellationToken cancellationToken = default); Task> VoidOrderItemAsync( string cafeId, string orderId, string itemId, string voidedByUserId, CancellationToken cancellationToken = default); Task> TransferTableAsync( string cafeId, string orderId, string targetTableId, CancellationToken cancellationToken = default); Task UpdateStatusAsync(string cafeId, string orderId, OrderStatus status, CancellationToken cancellationToken = default); Task> CancelOrderAsync( string cafeId, string orderId, string? reason, string? cancelledByEmployeeId, CancellationToken cancellationToken = default); Task>> RecordPaymentsAsync( string cafeId, string orderId, RecordPaymentsRequest request, string? userId, CancellationToken cancellationToken = default); Task<(IReadOnlyList Items, int Total)> GetClosedOrdersAsync( string cafeId, DateOnly date, string? branchId, int page, int pageSize, CancellationToken cancellationToken = default); Task> CorrectPaymentsAsync( string cafeId, string orderId, CorrectPaymentsRequest request, string? userId, CancellationToken cancellationToken = default); } public class OrderService : IOrderService { public static readonly OrderStatus[] LiveStatuses = [ OrderStatus.Pending, OrderStatus.Confirmed, OrderStatus.Preparing, OrderStatus.Ready ]; public static readonly OrderStatus[] OpenForPaymentStatuses = [ OrderStatus.Pending, OrderStatus.Confirmed, OrderStatus.Preparing, OrderStatus.Ready ]; private readonly AppDbContext _db; private readonly IKdsNotifier _kdsNotifier; private readonly ISnappfoodClient _snappfood; private readonly IDeliveryStatusSyncService _deliverySync; private readonly ILoyaltyService _loyalty; private readonly IShiftService _shiftService; private readonly IServiceScopeFactory _scopeFactory; private readonly IOrderNotificationService _orderNotifications; private readonly IInventoryService _inventory; public OrderService( AppDbContext db, IKdsNotifier kdsNotifier, ISnappfoodClient snappfood, IDeliveryStatusSyncService deliverySync, IShiftService shiftService, IServiceScopeFactory scopeFactory, IOrderNotificationService orderNotifications, IInventoryService inventory, ILoyaltyService loyalty) { _db = db; _kdsNotifier = kdsNotifier; _snappfood = snappfood; _deliverySync = deliverySync; _shiftService = shiftService; _scopeFactory = scopeFactory; _orderNotifications = orderNotifications; _inventory = inventory; _loyalty = loyalty; } public async Task> GetOrdersAsync(string cafeId, OrderStatus? status, CancellationToken cancellationToken = default) { var query = ApplyOrderIncludes(_db.Orders.Where(o => o.CafeId == cafeId)); if (status.HasValue) query = query.Where(o => o.Status == status.Value); var orders = await query.OrderByDescending(o => o.CreatedAt).ToListAsync(cancellationToken); return orders.Select(MapOrder).ToList(); } public async Task> GetOpenOrdersAsync( string cafeId, string? search, CancellationToken cancellationToken = default) { var query = ApplyOrderIncludes(_db.Orders.Where(o => o.CafeId == cafeId)) .Where(o => OpenForPaymentStatuses.Contains(o.Status)); if (!string.IsNullOrWhiteSpace(search)) { var term = search.Trim(); query = query.Where(o => o.Id.Contains(term) || (o.GuestName != null && o.GuestName.Contains(term)) || (o.GuestPhone != null && o.GuestPhone.Contains(term)) || (o.CustomerId != null && o.CustomerId == term) || (o.Customer != null && o.Customer.Phone.Contains(term)) || (o.Table != null && o.Table.Number.Contains(term))); } var orders = await query.OrderByDescending(o => o.CreatedAt).ToListAsync(cancellationToken); return orders.Select(MapOrder).ToList(); } public async Task> GetLiveOrdersAsync(string cafeId, CancellationToken cancellationToken = default) { var orders = await _db.Orders .Include(o => o.Items) .ThenInclude(i => i.MenuItem) .ThenInclude(m => m.Category) .ThenInclude(c => c.KitchenStation) .Include(o => o.Table) .Where(o => o.CafeId == cafeId && LiveStatuses.Contains(o.Status)) .OrderBy(o => o.CreatedAt) .ToListAsync(cancellationToken); return orders.Select(MapLiveOrder).ToList(); } public async Task GetOrderAsync(string cafeId, string orderId, CancellationToken cancellationToken = default) { var order = await LoadOrderAsync(cafeId, orderId, cancellationToken); return order is null ? null : MapOrder(order); } public async Task GetActiveOrderByTableAsync( string cafeId, string tableId, CancellationToken cancellationToken = default) { var order = await FindOpenOrderForTableAsync(cafeId, tableId, cancellationToken); return order is null ? null : MapOrder(order); } public async Task CreateGuestOrderAsync( string cafeId, CreateOrderRequest request, string? guestPhone, string? guestName, CancellationToken cancellationToken = default) { var result = await CreateOrderCoreAsync( cafeId, request, employeeId: null, guestPhone, guestName, source: OrderSource.GuestQr, cancellationToken: cancellationToken); return result.Data; } public async Task<(GuestQrOrderPlacedDto? Data, string? ErrorCode, string? Message)> PlaceBranchGuestOrderAsync( string cafeId, string branchId, PlaceGuestOrderRequest request, CancellationToken cancellationToken = default) { if (request.Items.Count == 0) return (null, "VALIDATION_ERROR", "Order must include at least one item."); var table = await _db.Tables.FirstOrDefaultAsync( t => t.Id == request.TableId && t.CafeId == cafeId && t.BranchId == branchId && t.IsActive, cancellationToken); if (table is null) return (null, "TABLE_NOT_FOUND", "Table not found."); if (table.IsCleaning) return (null, "TABLE_CLEANING", "Table is being cleaned."); var branchOk = await _db.Branches.AnyAsync( b => b.Id == branchId && b.CafeId == cafeId && b.IsActive, cancellationToken); if (!branchOk) return (null, "BRANCH_NOT_FOUND", "Branch not found."); var menuItemIds = request.Items.Select(i => i.MenuItemId).Distinct().ToList(); var menuItems = await _db.MenuItems .Where(m => m.CafeId == cafeId && menuItemIds.Contains(m.Id) && m.IsAvailable) .ToListAsync(cancellationToken); if (menuItems.Count != menuItemIds.Count) return (null, "INVALID_MENU_ITEMS", "One or more menu items are invalid."); var overrides = await _db.BranchMenuItemOverrides .Where(o => o.CafeId == cafeId && o.BranchId == branchId && menuItemIds.Contains(o.MenuItemId)) .ToDictionaryAsync(o => o.MenuItemId, cancellationToken); foreach (var item in menuItems) { if (overrides.TryGetValue(item.Id, out var ov) && !ov.IsAvailable) return (null, "INVALID_MENU_ITEMS", "Item is not available at this branch."); } var priceByItem = menuItems.ToDictionary( m => m.Id, m => { overrides.TryGetValue(m.Id, out var ov); return ov?.PriceOverride ?? m.Price; }); var createRequest = new CreateOrderRequest( OrderType.DineIn, branchId, request.TableId, null, request.GuestName, request.GuestPhone, null, null, request.Items); var result = await CreateOrderCoreAsync( cafeId, createRequest, employeeId: null, request.GuestPhone, request.GuestName, source: OrderSource.GuestQr, unitPriceByMenuItemId: priceByItem, cancellationToken: cancellationToken); if (!result.Success || result.Data is null) return (null, result.ErrorCode ?? "ERROR", "Could not place order."); var itemCount = request.Items.Sum(i => i.Quantity); var orderNumber = ReceiptPrintFormatting.OrderNumberLabel(result.Data.DisplayNumber); var orderEntity = await _db.Orders .FirstOrDefaultAsync(o => o.Id == result.Data.Id && o.CafeId == cafeId, cancellationToken); if (orderEntity is null) return (null, "ORDER_NOT_FOUND", "Could not load placed order."); if (string.IsNullOrEmpty(orderEntity.GuestTrackingToken)) { orderEntity.GuestTrackingToken = OrderTrackingHelper.NewTrackingToken(); orderEntity.StatusUpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(cancellationToken); } return ( new GuestQrOrderPlacedDto( result.Data.Id, orderNumber, result.Data.Total, itemCount, result.Data.Status, orderEntity.GuestTrackingToken), null, null); } public async Task> CreateOrderAsync( string cafeId, ITenantContext tenant, CreateOrderRequest request, CancellationToken cancellationToken = default) => await CreateOrderCoreAsync( cafeId, request, tenant.UserId, request.GuestPhone, request.GuestName, cancellationToken: cancellationToken); public async Task> AppendOrderItemsAsync( string cafeId, string orderId, AppendOrderItemsRequest request, CancellationToken cancellationToken = default) { await using var tx = await BeginTransactionIfSupportedAsync(cancellationToken); var order = await ApplyOrderIncludes(_db.Orders) .FirstOrDefaultAsync(o => o.Id == orderId && o.CafeId == cafeId, cancellationToken); if (order is null) return new OrderServiceResult(false, null, "ORDER_NOT_FOUND"); if (!OpenForPaymentStatuses.Contains(order.Status)) return new OrderServiceResult(false, null, "ORDER_NOT_OPEN"); var menuItemIds = request.Items.Select(i => i.MenuItemId).Distinct().ToList(); var menuItems = await _db.MenuItems .Where(m => m.CafeId == cafeId && menuItemIds.Contains(m.Id) && m.IsAvailable) .ToListAsync(cancellationToken); if (menuItems.Count != menuItemIds.Count) return new OrderServiceResult(false, null, "INVALID_ORDER"); foreach (var line in request.Items) { var menuItem = menuItems.First(m => m.Id == line.MenuItemId); order.Items.Add(new OrderItem { OrderId = order.Id, MenuItemId = menuItem.Id, Quantity = line.Quantity, UnitPrice = menuItem.Price, Notes = line.Notes }); } await RecalculateOrderTotalsAsync(order, cafeId, cancellationToken); await _db.SaveChangesAsync(cancellationToken); if (tx is not null) await tx.CommitAsync(cancellationToken); var loaded = await LoadOrderAsync(cafeId, order.Id, cancellationToken); if (loaded is not null) { await _kdsNotifier.NotifyOrderStatusChangedAsync(cafeId, order.Id, loaded.Status, cancellationToken); if (!string.IsNullOrEmpty(loaded.TableId)) await _kdsNotifier.NotifyTableStatusChangedAsync(cafeId, cancellationToken); PrinterBackgroundJobs.QueueKitchenPrint(_scopeFactory, cafeId, order.Id); } return loaded is null ? new OrderServiceResult(false, null, "ORDER_NOT_FOUND") : new OrderServiceResult(true, MapOrder(loaded)); } public async Task> UpdateOrderSessionAsync( string cafeId, string orderId, UpdateOrderSessionRequest request, CancellationToken cancellationToken = default) { var order = await _db.Orders .FirstOrDefaultAsync(o => o.Id == orderId && o.CafeId == cafeId, cancellationToken); if (order is null) return new OrderServiceResult(false, null, "ORDER_NOT_FOUND"); if (!OpenForPaymentStatuses.Contains(order.Status)) return new OrderServiceResult(false, null, "ORDER_NOT_OPEN"); if (request.GuestName is not null) order.GuestName = string.IsNullOrWhiteSpace(request.GuestName) ? null : request.GuestName.Trim(); if (request.GuestPhone is not null) order.GuestPhone = NormalizeGuestPhone(request.GuestPhone); if (request.CustomerId is not null) { if (string.IsNullOrEmpty(request.CustomerId)) { order.CustomerId = null; } else { var exists = await _db.Customers.AnyAsync( c => c.Id == request.CustomerId && c.CafeId == cafeId, cancellationToken); if (!exists) return new OrderServiceResult(false, null, "INVALID_ORDER", "customerId"); order.CustomerId = request.CustomerId; } } if (!string.IsNullOrEmpty(order.GuestPhone) && string.IsNullOrEmpty(order.CustomerId)) order.CustomerId = await ResolveCustomerIdFromPhoneAsync( cafeId, order.GuestPhone, order.GuestName, cancellationToken); await _db.SaveChangesAsync(cancellationToken); var loaded = await LoadOrderAsync(cafeId, orderId, cancellationToken); return loaded is null ? new OrderServiceResult(false, null, "ORDER_NOT_FOUND") : new OrderServiceResult(true, MapOrder(loaded)); } public async Task> VoidOrderItemAsync( string cafeId, string orderId, string itemId, string voidedByUserId, CancellationToken cancellationToken = default) { var order = await _db.Orders .Include(o => o.Items) .FirstOrDefaultAsync(o => o.Id == orderId && o.CafeId == cafeId, cancellationToken); if (order is null) return new OrderServiceResult(false, null, "ORDER_NOT_FOUND"); if (order.Status is OrderStatus.Delivered or OrderStatus.Cancelled) return new OrderServiceResult(false, null, "ORDER_ALREADY_CLOSED"); if (!OpenForPaymentStatuses.Contains(order.Status)) return new OrderServiceResult(false, null, "ORDER_NOT_OPEN"); var item = order.Items.FirstOrDefault(i => i.Id == itemId); if (item is null) return new OrderServiceResult(false, null, "ITEM_NOT_FOUND"); if (item.IsVoided) return new OrderServiceResult(false, null, "ITEM_ALREADY_VOIDED"); item.IsVoided = true; item.VoidedAt = DateTime.UtcNow; item.VoidedByUserId = voidedByUserId; await RecalculateOrderTotalsAsync(order, cafeId, cancellationToken); await _db.SaveChangesAsync(cancellationToken); var loaded = await LoadOrderAsync(cafeId, orderId, cancellationToken); if (loaded is not null && !string.IsNullOrEmpty(loaded.TableId)) await _kdsNotifier.NotifyTableStatusChangedAsync(cafeId, cancellationToken); return loaded is null ? new OrderServiceResult(false, null, "ORDER_NOT_FOUND") : new OrderServiceResult(true, MapOrder(loaded)); } public async Task> TransferTableAsync( string cafeId, string orderId, string targetTableId, CancellationToken cancellationToken = default) { var order = await _db.Orders .FirstOrDefaultAsync(o => o.Id == orderId && o.CafeId == cafeId, cancellationToken); if (order is null) return new OrderServiceResult(false, null, "ORDER_NOT_FOUND"); if (order.Status is OrderStatus.Delivered or OrderStatus.Cancelled) return new OrderServiceResult(false, null, "ORDER_ALREADY_CLOSED"); if (!OpenForPaymentStatuses.Contains(order.Status)) return new OrderServiceResult(false, null, "ORDER_NOT_OPEN"); var targetTable = await _db.Tables .FirstOrDefaultAsync(t => t.Id == targetTableId && t.CafeId == cafeId, cancellationToken); if (targetTable is null) return new OrderServiceResult(false, null, "TABLE_NOT_FOUND"); if (targetTable.IsCleaning) return new OrderServiceResult(false, null, "TABLE_CLEANING", "targetTableId"); var targetOccupied = await _db.Orders.AnyAsync( o => o.TableId == targetTableId && o.CafeId == cafeId && OpenForPaymentStatuses.Contains(o.Status) && o.Id != orderId, cancellationToken); if (targetOccupied) return new OrderServiceResult(false, null, "TABLE_OCCUPIED", "targetTableId"); order.TableId = targetTableId; await _db.SaveChangesAsync(cancellationToken); await _kdsNotifier.NotifyTableStatusChangedAsync(cafeId, cancellationToken); var loaded = await LoadOrderAsync(cafeId, orderId, cancellationToken); return loaded is null ? new OrderServiceResult(false, null, "ORDER_NOT_FOUND") : new OrderServiceResult(true, MapOrder(loaded)); } private async Task> CreateOrderCoreAsync( string cafeId, CreateOrderRequest request, string? employeeId, string? guestPhone, string? guestName, OrderSource source = OrderSource.Pos, IReadOnlyDictionary? unitPriceByMenuItemId = null, CancellationToken cancellationToken = default) { if (request.Items.Count == 0) return new OrderServiceResult(false, null, "INVALID_ORDER"); await using var tx = await BeginTransactionIfSupportedAsync(cancellationToken); var tableId = request.TableId; TableReservation? reservation = null; if (!string.IsNullOrEmpty(request.ReservationId)) { reservation = await _db.TableReservations .Include(r => r.Table) .FirstOrDefaultAsync( r => r.Id == request.ReservationId && r.CafeId == cafeId, cancellationToken); if (reservation is null || reservation.Status is ReservationStatus.Cancelled || reservation.Status is ReservationStatus.Completed) return new OrderServiceResult(false, null, "INVALID_ORDER"); if (string.IsNullOrEmpty(tableId) && !string.IsNullOrEmpty(reservation.TableId)) tableId = reservation.TableId; if (string.IsNullOrWhiteSpace(guestPhone)) guestPhone = reservation.GuestPhone; if (string.IsNullOrWhiteSpace(guestName)) guestName = reservation.GuestName; reservation.Status = ReservationStatus.Seated; } if (!string.IsNullOrEmpty(tableId)) { var table = await _db.Tables.FirstOrDefaultAsync( t => t.Id == tableId && t.CafeId == cafeId, cancellationToken); if (table is null) return new OrderServiceResult(false, null, "INVALID_ORDER", "tableId"); if (table.IsCleaning) return new OrderServiceResult(false, null, "TABLE_NOT_AVAILABLE", "tableId"); var existing = await FindOpenOrderForTableAsync(cafeId, tableId, cancellationToken); if (existing is not null) { if (source == OrderSource.GuestQr) existing.Source = OrderSource.GuestQr; await AppendLinesToOrderAsync( existing, request.Items, cafeId, unitPriceByMenuItemId, cancellationToken); ApplyGuestFields(existing, request, guestPhone, guestName); if (!string.IsNullOrEmpty(request.CustomerId)) existing.CustomerId = request.CustomerId; else if (!string.IsNullOrWhiteSpace(guestPhone)) existing.CustomerId = await ResolveCustomerIdFromPhoneAsync( cafeId, guestPhone.Trim(), existing.GuestName ?? guestName, cancellationToken); await RecalculateOrderTotalsAsync(existing, cafeId, cancellationToken); await _db.SaveChangesAsync(cancellationToken); await _inventory.DeductForOrderAsync( cafeId, existing.Id, request.Items.Select(i => (i.MenuItemId, i.Quantity)).ToList(), cancellationToken); if (tx is not null) await tx.CommitAsync(cancellationToken); var merged = await LoadOrderAsync(cafeId, existing.Id, cancellationToken); if (merged is not null) { if (source == OrderSource.GuestQr && string.IsNullOrEmpty(merged.GuestTrackingToken)) { merged.GuestTrackingToken = OrderTrackingHelper.NewTrackingToken(); merged.StatusUpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(cancellationToken); } await _kdsNotifier.NotifyOrderStatusChangedAsync(cafeId, merged.Id, merged.Status, cancellationToken); await _kdsNotifier.NotifyTableStatusChangedAsync(cafeId, cancellationToken); if (source == OrderSource.GuestQr) { var entity = await LoadOrderAsync(cafeId, merged.Id, cancellationToken); if (entity is not null) { await _orderNotifications.NotifyGuestOrderPlacedAsync( entity, MapLiveOrder(entity), cancellationToken); } } } return merged is null ? new OrderServiceResult(false, null, "ORDER_NOT_FOUND") : new OrderServiceResult(true, MapOrder(merged)); } } var createResult = await CreateNewOrderAsync( cafeId, request, employeeId, guestPhone, guestName, tableId, reservation, source, unitPriceByMenuItemId, cancellationToken); if (!createResult.Success) return createResult; if (tx is not null) await tx.CommitAsync(cancellationToken); return createResult; } private async Task BeginTransactionIfSupportedAsync(CancellationToken cancellationToken) { if (!_db.Database.IsRelational()) return null; return await _db.Database.BeginTransactionAsync(cancellationToken); } private async Task> CreateNewOrderAsync( string cafeId, CreateOrderRequest request, string? employeeId, string? guestPhone, string? guestName, string? tableId, TableReservation? reservation, OrderSource source, IReadOnlyDictionary? unitPriceByMenuItemId, CancellationToken cancellationToken) { var menuItemIds = request.Items.Select(i => i.MenuItemId).Distinct().ToList(); var menuItems = await _db.MenuItems .Include(m => m.Category) .Where(m => m.CafeId == cafeId && menuItemIds.Contains(m.Id) && m.IsAvailable) .ToListAsync(cancellationToken); if (menuItems.Count != menuItemIds.Count) return new OrderServiceResult(false, null, "INVALID_ORDER"); string? orderBranchId = request.BranchId; if (!string.IsNullOrEmpty(orderBranchId)) { var branchOk = await _db.Branches.AnyAsync( b => b.Id == orderBranchId && b.CafeId == cafeId, cancellationToken); if (!branchOk) orderBranchId = null; } var orderGuestName = string.IsNullOrWhiteSpace(request.GuestName) ? null : request.GuestName.Trim(); if (orderGuestName is null && !string.IsNullOrWhiteSpace(guestName)) orderGuestName = guestName.Trim(); var orderGuestPhone = NormalizeGuestPhone(request.GuestPhone); if (orderGuestPhone is null) orderGuestPhone = NormalizeGuestPhone(guestPhone); var orderItems = new List(); foreach (var line in request.Items) { var menuItem = menuItems.First(m => m.Id == line.MenuItemId); var unitPrice = unitPriceByMenuItemId?.GetValueOrDefault(menuItem.Id) ?? menuItem.Price; orderItems.Add(new OrderItem { MenuItemId = menuItem.Id, Quantity = line.Quantity, UnitPrice = unitPrice, Notes = line.Notes }); } string? customerId = request.CustomerId; if (string.IsNullOrEmpty(customerId) && !string.IsNullOrWhiteSpace(orderGuestPhone)) customerId = await ResolveCustomerIdFromPhoneAsync( cafeId, orderGuestPhone, orderGuestName ?? guestName, cancellationToken); if (string.IsNullOrEmpty(orderBranchId) && !string.IsNullOrEmpty(tableId)) { var tableBranch = await _db.Tables .Where(t => t.Id == tableId && t.CafeId == cafeId) .Select(t => t.BranchId) .FirstOrDefaultAsync(cancellationToken); if (!string.IsNullOrEmpty(tableBranch)) orderBranchId = tableBranch; } var displayNumber = await AllocateDisplayNumberAsync(cafeId, cancellationToken); var order = new Order { CafeId = cafeId, BranchId = orderBranchId, TableId = tableId, ReservationId = request.ReservationId, GuestName = orderGuestName, GuestPhone = orderGuestPhone, CustomerId = customerId, EmployeeId = employeeId, OrderType = request.OrderType, Source = source, Status = OrderStatus.Pending, DisplayNumber = displayNumber, StatusUpdatedAt = DateTime.UtcNow, GuestTrackingToken = source == OrderSource.GuestQr ? OrderTrackingHelper.NewTrackingToken() : null, CouponId = request.CouponId, Items = orderItems }; _db.Orders.Add(order); await RecalculateOrderTotalsAsync(order, cafeId, cancellationToken); await _db.SaveChangesAsync(cancellationToken); await _inventory.DeductForOrderAsync( cafeId, order.Id, orderItems.Select(i => (i.MenuItemId, i.Quantity)).ToList(), cancellationToken); var loaded = await LoadOrderAsync(cafeId, order.Id, cancellationToken); if (loaded is not null) { await _kdsNotifier.NotifyOrderCreatedAsync(cafeId, MapLiveOrder(loaded), cancellationToken); if (!string.IsNullOrEmpty(loaded.TableId)) await _kdsNotifier.NotifyTableStatusChangedAsync(cafeId, cancellationToken); if (source == OrderSource.GuestQr) await _orderNotifications.NotifyGuestOrderPlacedAsync(loaded, MapLiveOrder(loaded), cancellationToken); } return loaded is null ? new OrderServiceResult(false, null, "INVALID_ORDER") : new OrderServiceResult(true, MapOrder(loaded)); } private static void ApplyGuestFields( Order order, CreateOrderRequest request, string? guestPhone, string? guestName) { if (!string.IsNullOrWhiteSpace(request.GuestName)) order.GuestName = request.GuestName.Trim(); else if (!string.IsNullOrWhiteSpace(guestName)) order.GuestName = guestName.Trim(); var phone = NormalizeGuestPhone(request.GuestPhone) ?? NormalizeGuestPhone(guestPhone); if (phone is not null) order.GuestPhone = phone; } private static string? NormalizeGuestPhone(string? phone) { if (string.IsNullOrWhiteSpace(phone)) return null; var normalized = PhoneNormalizer.Normalize(phone); return PhoneNormalizer.IsValidIranMobile(normalized) ? normalized : null; } private async Task AppendLinesToOrderAsync( Order order, IReadOnlyList lines, string cafeId, IReadOnlyDictionary? unitPriceByMenuItemId, CancellationToken cancellationToken) { var menuItemIds = lines.Select(i => i.MenuItemId).Distinct().ToList(); var menuItems = await _db.MenuItems .Where(m => m.CafeId == cafeId && menuItemIds.Contains(m.Id) && m.IsAvailable) .ToListAsync(cancellationToken); if (menuItems.Count != menuItemIds.Count) throw new InvalidOperationException("Invalid menu items."); foreach (var line in lines) { var menuItem = menuItems.First(m => m.Id == line.MenuItemId); var unitPrice = unitPriceByMenuItemId?.GetValueOrDefault(menuItem.Id) ?? menuItem.Price; order.Items.Add(new OrderItem { OrderId = order.Id, MenuItemId = menuItem.Id, Quantity = line.Quantity, UnitPrice = unitPrice, Notes = line.Notes }); } } private async Task ResolveCustomerIdFromPhoneAsync( string cafeId, string phone, string? guestName, CancellationToken cancellationToken) { var existing = await _db.Customers.FirstOrDefaultAsync( c => c.CafeId == cafeId && c.Phone == phone, cancellationToken); if (existing is not null) return existing.Id; if (string.IsNullOrWhiteSpace(guestName)) return null; var customer = new Customer { CafeId = cafeId, Name = guestName.Trim(), Phone = phone, Group = CustomerGroup.New }; _db.Customers.Add(customer); await _db.SaveChangesAsync(cancellationToken); return customer.Id; } private async Task ResolveEffectiveTaxRateAsync( string cafeId, string? branchId, CancellationToken cancellationToken) { var cafe = await _db.Cafes.AsNoTracking() .FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken); if (cafe is not null && cafe.DefaultTaxRate > 0) { if (!string.IsNullOrEmpty(branchId) && cafe.AllowBranchTaxOverride) { var branchRate = await _db.Branches.AsNoTracking() .Where(b => b.Id == branchId && b.CafeId == cafeId) .Select(b => b.TaxRate) .FirstOrDefaultAsync(cancellationToken); if (branchRate is > 0) return branchRate.Value; } return cafe.DefaultTaxRate; } var taxTableRate = await _db.Taxes .Where(t => t.CafeId == cafeId && t.IsDefault) .Select(t => t.Rate) .FirstOrDefaultAsync(cancellationToken); return taxTableRate > 0 ? taxTableRate : 9m; } private async Task RecalculateOrderTotalsAsync( Order order, string cafeId, CancellationToken cancellationToken) { await _db.Entry(order).Collection(o => o.Items).LoadAsync(cancellationToken); var subtotal = order.Items.Where(i => !i.IsVoided).Sum(i => i.UnitPrice * i.Quantity); var discountAmount = 0m; if (!string.IsNullOrEmpty(order.CouponId)) { var coupon = await _db.Coupons.FirstOrDefaultAsync( c => c.Id == order.CouponId && c.CafeId == cafeId && c.IsActive, cancellationToken); if (coupon is not null && coupon.DeletedAt is null && (coupon.UsageLimit is null || coupon.UsedCount < coupon.UsageLimit) && (coupon.StartsAt is null || coupon.StartsAt <= DateTime.UtcNow) && (coupon.ExpiresAt is null || coupon.ExpiresAt >= DateTime.UtcNow) && (coupon.MinOrderAmount is null || subtotal >= coupon.MinOrderAmount)) { discountAmount = CouponService.CalculateDiscount(coupon, subtotal); } } var defaultTaxRate = await ResolveEffectiveTaxRateAsync(cafeId, order.BranchId, cancellationToken); var taxable = subtotal - discountAmount; var taxTotal = Math.Round(taxable * defaultTaxRate / 100m, 0); order.Subtotal = subtotal; order.DiscountAmount = discountAmount; order.TaxTotal = taxTotal; order.Total = taxable + taxTotal; } private async Task FindOpenOrderForTableAsync( string cafeId, string tableId, CancellationToken cancellationToken) => await ApplyOrderIncludes(_db.Orders) .Where(o => o.CafeId == cafeId && o.TableId == tableId && OpenForPaymentStatuses.Contains(o.Status)) .OrderBy(o => o.CreatedAt) .FirstOrDefaultAsync(cancellationToken); public async Task UpdateStatusAsync(string cafeId, string orderId, OrderStatus status, CancellationToken cancellationToken = default) { var order = await _db.Orders.FirstOrDefaultAsync(o => o.Id == orderId && o.CafeId == cafeId, cancellationToken); if (order is null) return null; if (status == OrderStatus.Cancelled && !OpenForPaymentStatuses.Contains(order.Status)) return null; order.Status = status; order.StatusUpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(cancellationToken); await _kdsNotifier.NotifyOrderStatusChangedAsync(cafeId, orderId, status, cancellationToken); if (!string.IsNullOrEmpty(order.TableId)) await _kdsNotifier.NotifyTableStatusChangedAsync(cafeId, cancellationToken); await _deliverySync.SyncInternalStatusAsync(cafeId, orderId, status, cancellationToken); var loaded = await LoadOrderAsync(cafeId, orderId, cancellationToken); if (loaded is not null) await _orderNotifications.NotifyOrderStatusChangedAsync(loaded, cancellationToken); return await GetOrderAsync(cafeId, orderId, cancellationToken); } public async Task> CancelOrderAsync( string cafeId, string orderId, string? reason, string? cancelledByEmployeeId, CancellationToken cancellationToken = default) { var order = await _db.Orders .Include(o => o.Payments) .FirstOrDefaultAsync(o => o.Id == orderId && o.CafeId == cafeId, cancellationToken); if (order is null) return new OrderServiceResult(false, null, "ORDER_NOT_FOUND"); if (order.Status == OrderStatus.Cancelled) return new OrderServiceResult(false, null, "ORDER_ALREADY_CANCELLED"); if (order.Status == OrderStatus.Delivered) return new OrderServiceResult(false, null, "ORDER_NOT_OPEN"); // Integrity / anti-fraud: once the kitchen has acted on the order // (Confirmed / Preparing / Ready) the food has been produced, so the order // can no longer be cancelled/deleted — otherwise a cashier could fire an // order, take cash without recording a payment, then erase it. Only a // not-yet-started (Pending) order may be cancelled; a started one must be // completed (and refunded via the audited refund flow if needed). if (order.Status != OrderStatus.Pending) return new OrderServiceResult(false, null, "ORDER_IN_PREPARATION"); // A paid order must be refunded through the payment flow first — cancelling it // here would silently strip the recorded money. Block and surface the reason. if (order.Payments.Any(p => p.DeletedAt == null)) return new OrderServiceResult(false, null, "ORDER_HAS_PAYMENTS"); order.Status = OrderStatus.Cancelled; order.StatusUpdatedAt = DateTime.UtcNow; order.CancelledAt = DateTime.UtcNow; order.CancelReason = string.IsNullOrWhiteSpace(reason) ? null : reason.Trim(); order.CancelledByEmployeeId = cancelledByEmployeeId; await _db.SaveChangesAsync(cancellationToken); await _kdsNotifier.NotifyOrderStatusChangedAsync(cafeId, orderId, OrderStatus.Cancelled, cancellationToken); if (!string.IsNullOrEmpty(order.TableId)) await _kdsNotifier.NotifyTableStatusChangedAsync(cafeId, cancellationToken); await _deliverySync.SyncInternalStatusAsync(cafeId, orderId, OrderStatus.Cancelled, cancellationToken); var loaded = await LoadOrderAsync(cafeId, orderId, cancellationToken); if (loaded is not null) await _orderNotifications.NotifyOrderStatusChangedAsync(loaded, cancellationToken); return loaded is null ? new OrderServiceResult(false, null, "ORDER_NOT_FOUND") : new OrderServiceResult(true, MapOrder(loaded)); } public async Task>> RecordPaymentsAsync( string cafeId, string orderId, RecordPaymentsRequest request, string? userId, CancellationToken cancellationToken = default) { var order = await _db.Orders .Include(o => o.Payments) .FirstOrDefaultAsync(o => o.Id == orderId && o.CafeId == cafeId, cancellationToken); if (order is null) return new OrderServiceResult>(false, null, "ORDER_NOT_FOUND"); // Never take payment on an already-closed order — a double-tap on Pay, or // paying a closed order reopened from the board, would otherwise record // duplicate payments, re-earn loyalty, reprint, and overstate the drawer. if (order.Status is OrderStatus.Delivered or OrderStatus.Cancelled) return new OrderServiceResult>(false, null, "ORDER_ALREADY_CLOSED"); var branchId = await ResolveOrderBranchIdAsync(order, cafeId, cancellationToken); if (string.IsNullOrEmpty(branchId)) return new OrderServiceResult>(false, null, "NO_OPEN_SHIFT", "branchId"); if (string.IsNullOrEmpty(order.BranchId)) { order.BranchId = branchId; } var shiftCheck = await _shiftService.RequireOpenShiftForBranchAsync( cafeId, branchId, cancellationToken); if (!shiftCheck.Success) return new OrderServiceResult>(false, null, shiftCheck.ErrorCode, shiftCheck.Field); if (request.LoyaltyPointsToRedeem is > 0) { var redeem = await _loyalty.RedeemOnOrderAsync( cafeId, order, request.LoyaltyPointsToRedeem.Value, cancellationToken); if (!redeem.Success) { return new OrderServiceResult>( false, null, redeem.ErrorCode ?? "LOYALTY_REDEEM_FAILED"); } if (redeem.Data is { DiscountToman: > 0 }) { order.DiscountAmount += redeem.Data.DiscountToman; var taxRate = await ResolveEffectiveTaxRateAsync(cafeId, order.BranchId, cancellationToken); var taxable = Math.Max(0, order.Subtotal - order.DiscountAmount); order.TaxTotal = Math.Round(taxable * taxRate / 100m, 0); order.Total = taxable + order.TaxTotal; } } var openShift = shiftCheck.Data!; var createdBy = userId ?? order.EmployeeId ?? openShift.OpenedByUserId; var payments = request.Payments.Select(p => new Payment { OrderId = orderId, Method = p.Method, Amount = p.Amount, Reference = p.Reference, Status = PaymentStatus.Completed }).ToList(); _db.Payments.AddRange(payments); var paidTotal = order.Payments.Where(p => p.Status == PaymentStatus.Completed).Sum(p => p.Amount) + payments.Sum(p => p.Amount); if (paidTotal >= order.Total) { order.Status = OrderStatus.Delivered; if (!string.IsNullOrEmpty(order.ReservationId)) { var reservation = await _db.TableReservations.FirstOrDefaultAsync( r => r.Id == order.ReservationId && r.CafeId == cafeId, cancellationToken); if (reservation is not null) reservation.Status = ReservationStatus.Completed; } } await _db.SaveChangesAsync(cancellationToken); foreach (var payment in payments) { await _shiftService.RecordTransactionAsync( cafeId, openShift.Id, CashTransactionType.OrderPayment, payment.Method, payment.Amount, createdBy, orderId, null, cancellationToken); } if (!string.IsNullOrEmpty(order.TableId)) await _kdsNotifier.NotifyTableStatusChangedAsync(cafeId, cancellationToken); if (paidTotal >= order.Total) { // Receipt is printed explicitly from the POS success sheet (single // print path) — no auto-print here, to avoid a duplicate receipt. await _loyalty.ApplyEarnOnOrderPaidAsync(cafeId, order.CustomerId, paidTotal, cancellationToken); await _deliverySync.SyncInternalStatusAsync(cafeId, orderId, OrderStatus.Delivered, cancellationToken); } var dtos = payments.Select(p => new PaymentDto(p.Id, p.Method, p.Amount, p.Status, p.Reference)).ToList(); return new OrderServiceResult>(true, dtos); } public async Task<(IReadOnlyList Items, int Total)> GetClosedOrdersAsync( string cafeId, DateOnly date, string? branchId, int page, int pageSize, CancellationToken cancellationToken = default) { var (utcStart, utcEnd) = IranCalendar.GetUtcRangeForIranDay(date); var query = _db.Orders .Where(o => o.CafeId == cafeId && (o.Status == OrderStatus.Delivered || o.Status == OrderStatus.Cancelled) && o.CreatedAt >= utcStart && o.CreatedAt < utcEnd); if (!string.IsNullOrEmpty(branchId)) query = query.Where(o => o.BranchId == branchId); var total = await query.CountAsync(cancellationToken); var orders = await ApplyOrderIncludes(query) .OrderByDescending(o => o.CreatedAt) .Skip((page - 1) * pageSize) .Take(pageSize) .AsNoTracking() .ToListAsync(cancellationToken); return (orders.Select(MapOrder).ToList(), total); } public async Task> CorrectPaymentsAsync( string cafeId, string orderId, CorrectPaymentsRequest request, string? userId, CancellationToken cancellationToken = default) { var order = await LoadOrderAsync(cafeId, orderId, cancellationToken); if (order is null) return new OrderServiceResult(false, null, "ORDER_NOT_FOUND"); // Resolve the payments being voided — they must belong to this order and // still be live. Payments are never deleted; voiding marks them Refunded // so the original سند stays visible in history and audit. var toVoid = new List(); foreach (var paymentId in request.VoidPaymentIds.Distinct()) { var payment = order.Payments.FirstOrDefault(p => p.Id == paymentId); if (payment is null) return new OrderServiceResult(false, null, "PAYMENT_NOT_FOUND", "voidPaymentIds"); if (payment.Status != PaymentStatus.Completed) return new OrderServiceResult(false, null, "PAYMENT_ALREADY_REFUNDED", "voidPaymentIds"); toVoid.Add(payment); } var branchId = await ResolveOrderBranchIdAsync(order, cafeId, cancellationToken); if (string.IsNullOrEmpty(branchId)) return new OrderServiceResult(false, null, "NO_OPEN_SHIFT", "branchId"); // Corrections move money through the drawer, so they need an open shift // exactly like recording a payment does. var shiftCheck = await _shiftService.RequireOpenShiftForBranchAsync(cafeId, branchId, cancellationToken); if (!shiftCheck.Success) return new OrderServiceResult(false, null, shiftCheck.ErrorCode, shiftCheck.Field); var openShift = shiftCheck.Data!; foreach (var payment in toVoid) payment.Status = PaymentStatus.Refunded; var replacements = request.Replacements.Select(p => new Payment { OrderId = orderId, Method = p.Method, Amount = p.Amount, Reference = p.Reference, Status = PaymentStatus.Completed }).ToList(); _db.Payments.AddRange(replacements); // Fully paid again after the correction → ensure the order is closed; // underpaid → leave the status alone (the remainder can be collected // through the normal payment flow later). EF navigation fixup may have // already appended the replacements to order.Payments, so exclude them // by reference to avoid double-counting. var paidTotal = order.Payments .Where(p => p.Status == PaymentStatus.Completed && !replacements.Contains(p)) .Sum(p => p.Amount) + replacements.Sum(p => p.Amount); if (paidTotal >= order.Total && OpenForPaymentStatuses.Contains(order.Status)) order.Status = OrderStatus.Delivered; await _db.SaveChangesAsync(cancellationToken); var createdBy = userId ?? openShift.OpenedByUserId; foreach (var payment in toVoid) { await _shiftService.RecordTransactionAsync( cafeId, openShift.Id, CashTransactionType.Refund, payment.Method, payment.Amount, createdBy, orderId, request.Reason, cancellationToken); } foreach (var payment in replacements) { await _shiftService.RecordTransactionAsync( cafeId, openShift.Id, CashTransactionType.OrderPayment, payment.Method, payment.Amount, createdBy, orderId, request.Reason, cancellationToken); } return new OrderServiceResult(true, MapOrder(order)); } private static IQueryable ApplyOrderIncludes(IQueryable query) => query .Include(o => o.Items) .ThenInclude(i => i.MenuItem) .Include(o => o.Table) .Include(o => o.Customer) .Include(o => o.Reservation) .Include(o => o.Payments); private async Task LoadOrderAsync(string cafeId, string orderId, CancellationToken cancellationToken) => await ApplyOrderIncludes(_db.Orders) .FirstOrDefaultAsync(o => o.Id == orderId && o.CafeId == cafeId, cancellationToken); private async Task ResolveOrderBranchIdAsync( Order order, string cafeId, CancellationToken cancellationToken) { if (!string.IsNullOrEmpty(order.BranchId)) return order.BranchId; if (!string.IsNullOrEmpty(order.TableId)) { var tableBranchId = await _db.Tables .Where(t => t.Id == order.TableId && t.CafeId == cafeId) .Select(t => t.BranchId) .FirstOrDefaultAsync(cancellationToken); if (!string.IsNullOrEmpty(tableBranchId)) return tableBranchId; } return await _db.Branches .Where(b => b.CafeId == cafeId && b.IsActive) .OrderBy(b => b.CreatedAt) .Select(b => b.Id) .FirstOrDefaultAsync(cancellationToken); } private async Task AllocateDisplayNumberAsync(string cafeId, CancellationToken cancellationToken) { var max = await _db.Orders .Where(o => o.CafeId == cafeId) .MaxAsync(o => (int?)o.DisplayNumber, cancellationToken); return (max ?? 0) + 1; } private static OrderDto MapOrder(Order o) { var paid = o.Payments .Where(p => p.Status == PaymentStatus.Completed) .Sum(p => p.Amount); return new OrderDto( o.Id, o.CafeId, o.BranchId, o.TableId, o.Table?.Number, o.GuestName, o.GuestPhone, o.Customer?.Name, o.Customer?.Phone, o.CustomerId, o.EmployeeId, o.OrderType, o.Source, o.Status, o.Subtotal, o.TaxTotal, o.DiscountAmount, o.Total, paid, o.CreatedAt, o.DisplayNumber > 0 ? o.DisplayNumber : ReceiptPrintFormatting.StableDisplayNumberFromId(o.Id), 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.Payments.Select(p => new PaymentDto(p.Id, p.Method, p.Amount, p.Status, p.Reference)).ToList()); } private static LiveOrderDto MapLiveOrder(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, i.MenuItem?.Category?.KitchenStationId, i.MenuItem?.Category?.KitchenStation?.Name)).ToList(), o.Source); }