feat(api): .NET 10 multi-tenant REST API
Full backend implementation: - Multi-tenant cafe/restaurant management (menus, orders, tables, staff) - POS order flow with ZarinPal and Snappfood payment integration - OTP authentication via Kavenegar SMS - QR digital menu with public discover/finder endpoints - Customer loyalty, coupons, CRM - PostgreSQL via EF Core, Redis for caching/sessions - Background jobs, webhook handlers - Full migration history Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
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());
|
||||
}
|
||||
Reference in New Issue
Block a user