Files
meezi/src/Meezi.API/Services/Delivery/DeliveryOrderProcessor.cs
T
soroush.asadi 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
feat(realtime): global guest-order alert on the dashboard
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.
2026-06-03 02:42:29 +03:30

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);
}