Files
meezi/src/Meezi.API/Services/OrderService.cs
T
soroush.asadi 27ca80fd54
CI/CD / CI · API (dotnet build + test) (push) Successful in 52s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
CI/CD / CI · Admin Web (tsc) (push) Successful in 39s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 1m40s
fix(orders): block cancelling an order once the kitchen has started it
Anti-fraud / integrity: a cashier could fire an order to the kitchen, take cash
without recording a payment, then cancel (soft-delete) the unpaid order to erase
it. CancelOrderAsync now only allows cancelling a still-Pending order; once the
kitchen has acted on it (Confirmed/Preparing/Ready) it returns ORDER_IN_PREPARATION
and a started order can no longer be removed — it must be completed (and refunded
through the audited refund flow if needed). Delivered → ORDER_NOT_OPEN; paid →
ORDER_HAS_PAYMENTS (unchanged). Orders are never hard-deleted and every cancel is
already audited with the actor. Applies to all roles, independent of permissions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:41:30 +03:30

1371 lines
54 KiB
C#

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<T>(bool Success, T? Data, string? ErrorCode = null, string? Field = null);
public interface IOrderService
{
Task<IReadOnlyList<OrderDto>> GetOrdersAsync(string cafeId, OrderStatus? status, CancellationToken cancellationToken = default);
Task<IReadOnlyList<OrderDto>> GetOpenOrdersAsync(string cafeId, string? search, CancellationToken cancellationToken = default);
Task<IReadOnlyList<LiveOrderDto>> GetLiveOrdersAsync(string cafeId, CancellationToken cancellationToken = default);
Task<OrderDto?> GetOrderAsync(string cafeId, string orderId, CancellationToken cancellationToken = default);
Task<OrderDto?> GetActiveOrderByTableAsync(string cafeId, string tableId, CancellationToken cancellationToken = default);
Task<OrderServiceResult<OrderDto>> CreateOrderAsync(string cafeId, ITenantContext tenant, CreateOrderRequest request, CancellationToken cancellationToken = default);
Task<OrderDto?> 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<OrderServiceResult<OrderDto>> AppendOrderItemsAsync(
string cafeId,
string orderId,
AppendOrderItemsRequest request,
CancellationToken cancellationToken = default);
Task<OrderServiceResult<OrderDto>> UpdateOrderSessionAsync(
string cafeId,
string orderId,
UpdateOrderSessionRequest request,
CancellationToken cancellationToken = default);
Task<OrderServiceResult<OrderDto>> VoidOrderItemAsync(
string cafeId,
string orderId,
string itemId,
string voidedByUserId,
CancellationToken cancellationToken = default);
Task<OrderServiceResult<OrderDto>> TransferTableAsync(
string cafeId,
string orderId,
string targetTableId,
CancellationToken cancellationToken = default);
Task<OrderDto?> UpdateStatusAsync(string cafeId, string orderId, OrderStatus status, CancellationToken cancellationToken = default);
Task<OrderServiceResult<OrderDto>> CancelOrderAsync(
string cafeId,
string orderId,
string? reason,
string? cancelledByEmployeeId,
CancellationToken cancellationToken = default);
Task<OrderServiceResult<IReadOnlyList<PaymentDto>>> RecordPaymentsAsync(
string cafeId,
string orderId,
RecordPaymentsRequest request,
string? userId,
CancellationToken cancellationToken = default);
Task<(IReadOnlyList<OrderDto> Items, int Total)> GetClosedOrdersAsync(
string cafeId,
DateOnly date,
string? branchId,
int page,
int pageSize,
CancellationToken cancellationToken = default);
Task<OrderServiceResult<OrderDto>> 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<IReadOnlyList<OrderDto>> 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<IReadOnlyList<OrderDto>> 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<IReadOnlyList<LiveOrderDto>> 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<OrderDto?> 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<OrderDto?> 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<OrderDto?> 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<OrderServiceResult<OrderDto>> 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<OrderServiceResult<OrderDto>> 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<OrderDto>(false, null, "ORDER_NOT_FOUND");
if (!OpenForPaymentStatuses.Contains(order.Status))
return new OrderServiceResult<OrderDto>(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<OrderDto>(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<OrderDto>(false, null, "ORDER_NOT_FOUND")
: new OrderServiceResult<OrderDto>(true, MapOrder(loaded));
}
public async Task<OrderServiceResult<OrderDto>> 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<OrderDto>(false, null, "ORDER_NOT_FOUND");
if (!OpenForPaymentStatuses.Contains(order.Status))
return new OrderServiceResult<OrderDto>(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<OrderDto>(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<OrderDto>(false, null, "ORDER_NOT_FOUND")
: new OrderServiceResult<OrderDto>(true, MapOrder(loaded));
}
public async Task<OrderServiceResult<OrderDto>> 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<OrderDto>(false, null, "ORDER_NOT_FOUND");
if (order.Status is OrderStatus.Delivered or OrderStatus.Cancelled)
return new OrderServiceResult<OrderDto>(false, null, "ORDER_ALREADY_CLOSED");
if (!OpenForPaymentStatuses.Contains(order.Status))
return new OrderServiceResult<OrderDto>(false, null, "ORDER_NOT_OPEN");
var item = order.Items.FirstOrDefault(i => i.Id == itemId);
if (item is null)
return new OrderServiceResult<OrderDto>(false, null, "ITEM_NOT_FOUND");
if (item.IsVoided)
return new OrderServiceResult<OrderDto>(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<OrderDto>(false, null, "ORDER_NOT_FOUND")
: new OrderServiceResult<OrderDto>(true, MapOrder(loaded));
}
public async Task<OrderServiceResult<OrderDto>> 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<OrderDto>(false, null, "ORDER_NOT_FOUND");
if (order.Status is OrderStatus.Delivered or OrderStatus.Cancelled)
return new OrderServiceResult<OrderDto>(false, null, "ORDER_ALREADY_CLOSED");
if (!OpenForPaymentStatuses.Contains(order.Status))
return new OrderServiceResult<OrderDto>(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<OrderDto>(false, null, "TABLE_NOT_FOUND");
if (targetTable.IsCleaning)
return new OrderServiceResult<OrderDto>(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<OrderDto>(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<OrderDto>(false, null, "ORDER_NOT_FOUND")
: new OrderServiceResult<OrderDto>(true, MapOrder(loaded));
}
private async Task<OrderServiceResult<OrderDto>> CreateOrderCoreAsync(
string cafeId,
CreateOrderRequest request,
string? employeeId,
string? guestPhone,
string? guestName,
OrderSource source = OrderSource.Pos,
IReadOnlyDictionary<string, decimal>? unitPriceByMenuItemId = null,
CancellationToken cancellationToken = default)
{
if (request.Items.Count == 0)
return new OrderServiceResult<OrderDto>(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<OrderDto>(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<OrderDto>(false, null, "INVALID_ORDER", "tableId");
if (table.IsCleaning)
return new OrderServiceResult<OrderDto>(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<OrderDto>(false, null, "ORDER_NOT_FOUND")
: new OrderServiceResult<OrderDto>(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<IDbContextTransaction?> BeginTransactionIfSupportedAsync(CancellationToken cancellationToken)
{
if (!_db.Database.IsRelational())
return null;
return await _db.Database.BeginTransactionAsync(cancellationToken);
}
private async Task<OrderServiceResult<OrderDto>> CreateNewOrderAsync(
string cafeId,
CreateOrderRequest request,
string? employeeId,
string? guestPhone,
string? guestName,
string? tableId,
TableReservation? reservation,
OrderSource source,
IReadOnlyDictionary<string, decimal>? 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<OrderDto>(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<OrderItem>();
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<OrderDto>(false, null, "INVALID_ORDER")
: new OrderServiceResult<OrderDto>(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<CreateOrderItemRequest> lines,
string cafeId,
IReadOnlyDictionary<string, decimal>? 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<string?> 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<decimal> 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<Order?> 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<OrderDto?> 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<OrderServiceResult<OrderDto>> 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<OrderDto>(false, null, "ORDER_NOT_FOUND");
if (order.Status == OrderStatus.Cancelled)
return new OrderServiceResult<OrderDto>(false, null, "ORDER_ALREADY_CANCELLED");
if (order.Status == OrderStatus.Delivered)
return new OrderServiceResult<OrderDto>(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<OrderDto>(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<OrderDto>(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<OrderDto>(false, null, "ORDER_NOT_FOUND")
: new OrderServiceResult<OrderDto>(true, MapOrder(loaded));
}
public async Task<OrderServiceResult<IReadOnlyList<PaymentDto>>> 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<IReadOnlyList<PaymentDto>>(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<IReadOnlyList<PaymentDto>>(false, null, "ORDER_ALREADY_CLOSED");
var branchId = await ResolveOrderBranchIdAsync(order, cafeId, cancellationToken);
if (string.IsNullOrEmpty(branchId))
return new OrderServiceResult<IReadOnlyList<PaymentDto>>(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<IReadOnlyList<PaymentDto>>(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<IReadOnlyList<PaymentDto>>(
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<IReadOnlyList<PaymentDto>>(true, dtos);
}
public async Task<(IReadOnlyList<OrderDto> 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<OrderServiceResult<OrderDto>> 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<OrderDto>(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<Payment>();
foreach (var paymentId in request.VoidPaymentIds.Distinct())
{
var payment = order.Payments.FirstOrDefault(p => p.Id == paymentId);
if (payment is null)
return new OrderServiceResult<OrderDto>(false, null, "PAYMENT_NOT_FOUND", "voidPaymentIds");
if (payment.Status != PaymentStatus.Completed)
return new OrderServiceResult<OrderDto>(false, null, "PAYMENT_ALREADY_REFUNDED", "voidPaymentIds");
toVoid.Add(payment);
}
var branchId = await ResolveOrderBranchIdAsync(order, cafeId, cancellationToken);
if (string.IsNullOrEmpty(branchId))
return new OrderServiceResult<OrderDto>(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<OrderDto>(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<OrderDto>(true, MapOrder(order));
}
private static IQueryable<Order> ApplyOrderIncludes(IQueryable<Order> 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<Order?> LoadOrderAsync(string cafeId, string orderId, CancellationToken cancellationToken) =>
await ApplyOrderIncludes(_db.Orders)
.FirstOrDefaultAsync(o => o.Id == orderId && o.CafeId == cafeId, cancellationToken);
private async Task<string?> 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<int> 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);
}