dcdb0d5747
CI/CD / CI · API (dotnet build + test) (push) Successful in 47s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 32s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m4s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 3m9s
Guest orders from the QR/digital menu already notified via SignalR, but only screens that were open (KDS/POS/tables) reacted — and silently (a data refresh, no alert). So staff on any other screen never knew a menu order arrived. - Add a global useOrderAlerts() mounted in the dashboard shell: connects to /hubs/kds, joins the café group, and on a new GUEST order plays a chime + shows a toast (localized fa/en/ar) + nudges order/KDS/POS lists to refresh — on every screen. - Filter to guest QR-menu orders only (not staff POS orders): LiveOrderDto now carries Source, set in MapLiveOrder (+ the delivery/snappfood mappers). 86 API tests pass; dashboard tsc + build clean.
351 lines
12 KiB
C#
351 lines
12 KiB
C#
using System.Text.Json;
|
|
using Meezi.API.Models.Orders;
|
|
using Meezi.API.Services.Printing;
|
|
using Meezi.Core.Delivery;
|
|
using Meezi.Core.Entities;
|
|
using Meezi.Core.Enums;
|
|
using Meezi.Core.Interfaces;
|
|
using Meezi.Infrastructure.Data;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Meezi.API.Services.Delivery;
|
|
|
|
public record DeliveryProcessResult(bool Success, string? MeeziOrderId, string? ErrorCode, string? Message);
|
|
|
|
public interface IDeliveryOrderProcessor
|
|
{
|
|
Task<DeliveryProcessResult> ProcessAsync(
|
|
string webhookLogId,
|
|
UnifiedDeliveryOrder unified,
|
|
CancellationToken ct = default);
|
|
}
|
|
|
|
public class DeliveryOrderProcessor : IDeliveryOrderProcessor
|
|
{
|
|
private readonly AppDbContext _db;
|
|
private readonly IKdsNotifier _kds;
|
|
private readonly ICommissionCalculator _commission;
|
|
private readonly IInventoryService _inventory;
|
|
private readonly ISnappfoodClient _snappfood;
|
|
private readonly ITap30Client _tap30;
|
|
private readonly IServiceScopeFactory _scopeFactory;
|
|
private readonly ILogger<DeliveryOrderProcessor> _logger;
|
|
|
|
public DeliveryOrderProcessor(
|
|
AppDbContext db,
|
|
IKdsNotifier kds,
|
|
ICommissionCalculator commission,
|
|
IInventoryService inventory,
|
|
ISnappfoodClient snappfood,
|
|
ITap30Client tap30,
|
|
IServiceScopeFactory scopeFactory,
|
|
ILogger<DeliveryOrderProcessor> logger)
|
|
{
|
|
_db = db;
|
|
_kds = kds;
|
|
_commission = commission;
|
|
_inventory = inventory;
|
|
_snappfood = snappfood;
|
|
_tap30 = tap30;
|
|
_scopeFactory = scopeFactory;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<DeliveryProcessResult> ProcessAsync(
|
|
string webhookLogId,
|
|
UnifiedDeliveryOrder unified,
|
|
CancellationToken ct = default)
|
|
{
|
|
var log = await _db.WebhookLogs.FirstOrDefaultAsync(w => w.Id == webhookLogId, ct);
|
|
if (log is null)
|
|
return new DeliveryProcessResult(false, null, "LOG_NOT_FOUND", "Webhook log missing.");
|
|
|
|
log.AttemptCount++;
|
|
try
|
|
{
|
|
var cafe = await ResolveCafeAsync(unified, ct);
|
|
if (cafe is null)
|
|
{
|
|
await FailLogAsync(log, "Unknown vendor.", ct);
|
|
return new DeliveryProcessResult(false, null, "VENDOR_NOT_FOUND", "Unknown vendor.");
|
|
}
|
|
|
|
log.CafeId = cafe.Id;
|
|
log.ExternalOrderId = unified.ExternalId;
|
|
|
|
var duplicate = await _db.Orders.AnyAsync(
|
|
o => o.CafeId == cafe.Id
|
|
&& o.DeliveryPlatform == unified.Platform
|
|
&& o.ExternalOrderId == unified.ExternalId,
|
|
ct);
|
|
|
|
if (duplicate)
|
|
{
|
|
await CompleteLogAsync(log, null, success: true, error: null, ct);
|
|
return new DeliveryProcessResult(true, null, null, "Duplicate ignored.");
|
|
}
|
|
|
|
var branchId = await _db.Branches
|
|
.Where(b => b.CafeId == cafe.Id && b.IsActive)
|
|
.OrderBy(b => b.Name)
|
|
.Select(b => b.Id)
|
|
.FirstOrDefaultAsync(ct);
|
|
|
|
var menuItems = await _db.MenuItems
|
|
.Where(m => m.CafeId == cafe.Id && m.IsAvailable)
|
|
.ToListAsync(ct);
|
|
|
|
var orderItems = new List<OrderItem>();
|
|
decimal subtotal = 0;
|
|
|
|
foreach (var line in unified.Items)
|
|
{
|
|
var menuItem = menuItems.FirstOrDefault(m =>
|
|
(!string.IsNullOrEmpty(line.Sku) && m.Id == line.Sku)
|
|
|| m.Name.Equals(line.Name, StringComparison.OrdinalIgnoreCase)
|
|
|| (m.NameEn != null && m.NameEn.Equals(line.Name, StringComparison.OrdinalIgnoreCase)));
|
|
|
|
if (menuItem is null)
|
|
{
|
|
_logger.LogWarning(
|
|
"Delivery {Platform} item {Name} not matched for cafe {CafeId}",
|
|
unified.Platform,
|
|
line.Name,
|
|
cafe.Id);
|
|
continue;
|
|
}
|
|
|
|
subtotal += line.UnitPrice * line.Quantity;
|
|
orderItems.Add(new OrderItem
|
|
{
|
|
MenuItemId = menuItem.Id,
|
|
Quantity = line.Quantity,
|
|
UnitPrice = line.UnitPrice,
|
|
Notes = line.Notes ?? unified.Platform.ToString()
|
|
});
|
|
}
|
|
|
|
if (orderItems.Count == 0)
|
|
{
|
|
await FailLogAsync(log, "No menu items matched.", ct);
|
|
return new DeliveryProcessResult(false, null, "INVALID_MENU_ITEMS", "No menu items matched.");
|
|
}
|
|
|
|
var platformCommission = await _commission.CalculateForOrderAsync(cafe.Id, unified, ct);
|
|
|
|
var taxRate = cafe.DefaultTaxRate > 0
|
|
? cafe.DefaultTaxRate
|
|
: await _db.Taxes.Where(t => t.CafeId == cafe.Id && t.IsDefault)
|
|
.Select(t => t.Rate)
|
|
.FirstOrDefaultAsync(ct);
|
|
|
|
if (taxRate == 0) taxRate = 9m;
|
|
|
|
var gross = unified.Payment.Total > 0 ? unified.Payment.Total : subtotal;
|
|
var taxTotal = Math.Round((gross - platformCommission) * taxRate / 100m, 0);
|
|
var total = gross;
|
|
|
|
var displayNumber = await AllocateDisplayNumberAsync(cafe.Id, ct);
|
|
var order = new Order
|
|
{
|
|
CafeId = cafe.Id,
|
|
BranchId = branchId,
|
|
OrderType = unified.Delivery.Type == "pickup" ? OrderType.Takeaway : OrderType.Delivery,
|
|
Source = MapSource(unified.Platform),
|
|
Status = MapStatus(unified.Status),
|
|
DisplayNumber = displayNumber,
|
|
ExternalOrderId = unified.ExternalId,
|
|
DeliveryPlatform = unified.Platform,
|
|
PlatformCommission = platformCommission,
|
|
DeliveryMetaJson = JsonSerializer.Serialize(unified.Delivery),
|
|
Subtotal = subtotal,
|
|
TaxTotal = taxTotal,
|
|
Total = total,
|
|
Items = orderItems
|
|
};
|
|
|
|
if (unified.Platform == DeliveryPlatform.Snappfood)
|
|
order.SnappfoodOrderId = unified.ExternalId;
|
|
|
|
if (!string.IsNullOrWhiteSpace(unified.Customer.Phone))
|
|
{
|
|
var phone = unified.Customer.Phone.Trim();
|
|
var customer = await _db.Customers
|
|
.FirstOrDefaultAsync(c => c.CafeId == cafe.Id && c.Phone == phone, ct);
|
|
if (customer is null)
|
|
{
|
|
customer = new Customer
|
|
{
|
|
CafeId = cafe.Id,
|
|
Name = unified.Customer.Name,
|
|
Phone = phone,
|
|
Group = CustomerGroup.New
|
|
};
|
|
_db.Customers.Add(customer);
|
|
await _db.SaveChangesAsync(ct);
|
|
}
|
|
|
|
order.CustomerId = customer.Id;
|
|
order.GuestName = unified.Customer.Name;
|
|
order.GuestPhone = phone;
|
|
}
|
|
else
|
|
{
|
|
order.GuestName = unified.Customer.Name;
|
|
}
|
|
|
|
if (unified.Payment.IsPaid && total > 0)
|
|
{
|
|
order.Payments.Add(new Payment
|
|
{
|
|
Method = unified.Payment.Method.Equals("cash", StringComparison.OrdinalIgnoreCase)
|
|
? PaymentMethod.Cash
|
|
: PaymentMethod.Card,
|
|
Amount = total,
|
|
Status = PaymentStatus.Completed
|
|
});
|
|
}
|
|
|
|
_db.Orders.Add(order);
|
|
await _db.SaveChangesAsync(ct);
|
|
|
|
await TryDeductInventoryAsync(cafe.Id, orderItems, ct);
|
|
|
|
var loaded = await _db.Orders
|
|
.Include(o => o.Items)
|
|
.ThenInclude(i => i.MenuItem)
|
|
.Include(o => o.Table)
|
|
.FirstAsync(o => o.Id == order.Id, ct);
|
|
|
|
await _kds.NotifyOrderCreatedAsync(cafe.Id, MapLive(loaded), ct);
|
|
PrinterBackgroundJobs.QueueKitchenPrint(_scopeFactory, cafe.Id, order.Id);
|
|
|
|
await AcknowledgePlatformAsync(unified, ct);
|
|
|
|
await CompleteLogAsync(log, order.Id, success: true, error: null, ct);
|
|
return new DeliveryProcessResult(true, order.Id, null, null);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Delivery order processing failed for log {LogId}", webhookLogId);
|
|
await FailLogAsync(log, ex.Message, ct);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private async Task<Cafe?> ResolveCafeAsync(UnifiedDeliveryOrder unified, CancellationToken ct) =>
|
|
unified.Platform switch
|
|
{
|
|
DeliveryPlatform.Snappfood => await _db.Cafes
|
|
.FirstOrDefaultAsync(c => c.SnappfoodVendorId == unified.VendorId, ct),
|
|
DeliveryPlatform.Tap30 => await _db.Cafes
|
|
.FirstOrDefaultAsync(c => c.Tap30VendorId == unified.VendorId, ct),
|
|
DeliveryPlatform.Digikala => await _db.Cafes
|
|
.FirstOrDefaultAsync(c => c.DigikalaVendorId == unified.VendorId, ct),
|
|
_ => null
|
|
};
|
|
|
|
private async Task AcknowledgePlatformAsync(UnifiedDeliveryOrder unified, CancellationToken ct)
|
|
{
|
|
switch (unified.Platform)
|
|
{
|
|
case DeliveryPlatform.Snappfood:
|
|
await _snappfood.AcknowledgeOrderAsync(unified.ExternalId, ct);
|
|
break;
|
|
case DeliveryPlatform.Tap30:
|
|
await _tap30.AcknowledgeOrderAsync(unified.ExternalId, ct);
|
|
break;
|
|
}
|
|
}
|
|
|
|
private async Task TryDeductInventoryAsync(
|
|
string cafeId,
|
|
List<OrderItem> items,
|
|
CancellationToken ct)
|
|
{
|
|
if (items.Count == 0) return;
|
|
var orderId = items[0].OrderId;
|
|
await _inventory.DeductForOrderAsync(
|
|
cafeId,
|
|
orderId,
|
|
items.Select(i => (i.MenuItemId, i.Quantity)).ToList(),
|
|
ct);
|
|
}
|
|
|
|
private static OrderSource MapSource(DeliveryPlatform platform) => platform switch
|
|
{
|
|
DeliveryPlatform.Snappfood => OrderSource.SnappFood,
|
|
DeliveryPlatform.Tap30 => OrderSource.Tap30,
|
|
DeliveryPlatform.Digikala => OrderSource.Digikala,
|
|
_ => OrderSource.Pos
|
|
};
|
|
|
|
private static OrderStatus MapStatus(UnifiedDeliveryStatus status) => status switch
|
|
{
|
|
UnifiedDeliveryStatus.Pending => OrderStatus.Pending,
|
|
UnifiedDeliveryStatus.Confirmed => OrderStatus.Confirmed,
|
|
UnifiedDeliveryStatus.Preparing => OrderStatus.Preparing,
|
|
UnifiedDeliveryStatus.Ready => OrderStatus.Ready,
|
|
UnifiedDeliveryStatus.Delivered => OrderStatus.Delivered,
|
|
UnifiedDeliveryStatus.Cancelled => OrderStatus.Cancelled,
|
|
_ => OrderStatus.Confirmed
|
|
};
|
|
|
|
private async Task FailLogAsync(WebhookLog log, string error, CancellationToken ct)
|
|
{
|
|
log.Success = false;
|
|
log.Processed = true;
|
|
log.ErrorMessage = error.Length > 2000 ? error[..2000] : error;
|
|
log.ProcessedAt = DateTime.UtcNow;
|
|
await _db.SaveChangesAsync(ct);
|
|
|
|
if (log.AttemptCount >= 3)
|
|
_logger.LogError(
|
|
"Delivery webhook dead-letter: platform {Platform} external {ExternalId} — {Error}",
|
|
log.Platform,
|
|
log.ExternalOrderId,
|
|
error);
|
|
}
|
|
|
|
private async Task CompleteLogAsync(
|
|
WebhookLog log,
|
|
string? meeziOrderId,
|
|
bool success,
|
|
string? error,
|
|
CancellationToken ct)
|
|
{
|
|
log.Success = success;
|
|
log.Processed = true;
|
|
log.MeeziOrderId = meeziOrderId;
|
|
log.ErrorMessage = error;
|
|
log.ProcessedAt = DateTime.UtcNow;
|
|
await _db.SaveChangesAsync(ct);
|
|
}
|
|
|
|
|
|
private async Task<int> AllocateDisplayNumberAsync(string cafeId, CancellationToken ct)
|
|
{
|
|
var max = await _db.Orders
|
|
.Where(o => o.CafeId == cafeId)
|
|
.MaxAsync(o => (int?)o.DisplayNumber, ct);
|
|
return (max ?? 0) + 1;
|
|
} private static LiveOrderDto MapLive(Order o) => new(
|
|
o.Id,
|
|
o.DisplayNumber > 0 ? o.DisplayNumber : ReceiptPrintFormatting.StableDisplayNumberFromId(o.Id),
|
|
o.Status,
|
|
o.Table?.Number,
|
|
o.OrderType,
|
|
o.Total,
|
|
o.CreatedAt,
|
|
o.Items.Select(i => new OrderItemDto(
|
|
i.Id,
|
|
i.MenuItemId,
|
|
i.MenuItem?.Name ?? "",
|
|
i.Quantity,
|
|
i.UnitPrice,
|
|
i.Notes,
|
|
i.IsVoided,
|
|
i.VoidedAt)).ToList(),
|
|
o.Source);
|
|
}
|