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
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>
1371 lines
54 KiB
C#
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);
|
|
}
|