diff --git a/src/Oms.Application/Dtos/Orders/OrderPayDtos.cs b/src/Oms.Application/Dtos/Orders/OrderPayDtos.cs
new file mode 100644
index 0000000..6b3b12f
--- /dev/null
+++ b/src/Oms.Application/Dtos/Orders/OrderPayDtos.cs
@@ -0,0 +1,81 @@
+using Oms.Application.Dtos.Marketing;
+
+namespace Oms.Application.Dtos.Orders;
+
+///
+/// 订单支付入参(支付时选券)。
+///
+public class OrderPayInput : OrderActionInput
+{
+ ///
+ /// 选用的优惠券实例 Id:null 沿用订单已绑定的券(不可用则不使用);0 不使用券;大于 0 使用指定券。
+ ///
+ public int? CouponInstanceId { get; set; }
+}
+
+///
+/// 订单支付预览入参。
+///
+public class OrderPayPreviewInput
+{
+ ///
+ /// 订单主键。
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// 试算用券:null 自动推荐(优先已绑定券,否则选优惠最大且优于会员折的券);0 不使用券;大于 0 使用指定券。
+ ///
+ public int? CouponInstanceId { get; set; }
+}
+
+///
+/// 订单支付预览:应付拆解与可选券。
+///
+public class OrderPayPreviewOutput
+{
+ ///
+ /// 订单主键。
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// 应收小计(折前)。
+ ///
+ public decimal ReceivableSubtotal { get; set; }
+
+ ///
+ /// 基础运费(券门槛与抵扣口径)。
+ ///
+ public decimal BaseFreightAmount { get; set; }
+
+ ///
+ /// 会员折扣金额(与券互斥,用券时为 0)。
+ ///
+ public decimal LevelDiscountAmount { get; set; }
+
+ ///
+ /// 优惠券抵扣金额。
+ ///
+ public decimal CouponDiscountAmount { get; set; }
+
+ ///
+ /// 实际应付金额(支付扣款金额)。
+ ///
+ public decimal PayableAmount { get; set; }
+
+ ///
+ /// 会员等级名称。
+ ///
+ public string? MemberLevelName { get; set; }
+
+ ///
+ /// 本次试算实际生效的券实例 Id;为空表示未用券。
+ ///
+ public int? AppliedCouponInstanceId { get; set; }
+
+ ///
+ /// 当前客户可选券(CanUse=false 时附不可用原因,含「优惠不高于会员折扣」)。
+ ///
+ public List AvailableCoupons { get; set; } = [];
+}
diff --git a/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs b/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs
index d15d384..e2e95c9 100644
--- a/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs
@@ -11,7 +11,7 @@ namespace Oms.Application.Services.Orders;
public partial class OrderService
{
internal Task PayForBatchAsync(int orderId, CancellationToken cancellationToken = default) =>
- PayAsync(new OrderActionInput { Id = orderId });
+ PayAsync(new OrderPayInput { Id = orderId });
internal Task PreDeclareForBatchAsync(int orderId, CancellationToken cancellationToken = default) =>
PreDeclareAsync(new OrderActionInput { Id = orderId });
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
index 9199c16..8e2609f 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
@@ -3378,7 +3378,8 @@ public partial class OrderService
receivableSubtotal,
baseFreightAmount,
order.CouponInstanceId,
- order.ConsigneeCountry);
+ order.ConsigneeCountry,
+ order.Id);
marketingHelper.ApplySnapshotToOrder(order, breakdown, order.CouponInstanceId);
}
diff --git a/src/Oms.Application/Services/Orders/OrderService.PayCoupon.cs b/src/Oms.Application/Services/Orders/OrderService.PayCoupon.cs
new file mode 100644
index 0000000..9dbf1ff
--- /dev/null
+++ b/src/Oms.Application/Services/Orders/OrderService.PayCoupon.cs
@@ -0,0 +1,142 @@
+using Fake.Application;
+using Microsoft.AspNetCore.Mvc;
+using Oms.Application.Dtos.Marketing;
+using Oms.Application.Dtos.Orders;
+using Oms.Application.Shared;
+using Oms.Domain.OrdersAggregate.Entities;
+
+namespace Oms.Application.Services.Orders;
+
+public partial class OrderService
+{
+ private const string CouponNotBetterThanLevelReason = "优惠不高于会员折扣";
+
+ ///
+ /// 支付预览:按当前应收计算会员折/券互斥后的应付,并返回本单可选券。
+ ///
+ /// 入参:订单 Id;couponInstanceId 为 null 自动推荐,0 不用券,大于 0 试算指定券。
+ /// 应付拆解、实际生效券与可选券列表;指定券不可用(未解锁/过期/线路不符等)时抛业务异常。
+ [HttpGet("/order/pay-preview")]
+ public async Task GetPayPreviewAsync([FromQuery] OrderPayPreviewInput input)
+ {
+ var order = await GetOrderOrThrowAsync(input.Id);
+ var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]);
+ var receivableSubtotal = MarketingFeeHelper.SumReceivable(fees);
+ var baseFreightAmount = MarketingFeeHelper.SumReceivableBaseFreight(fees);
+ var marketingHelper = LazyServiceProvider.GetRequiredService();
+
+ var memberOnly = await marketingHelper.BuildBreakdownAsync(
+ order.CustomerId,
+ receivableSubtotal,
+ baseFreightAmount,
+ null,
+ order.ConsigneeCountry,
+ order.Id);
+ var coupons = await marketingHelper.BuildAvailableCouponsAsync(
+ order.CustomerId,
+ baseFreightAmount,
+ order.ConsigneeCountry,
+ order.Id);
+ foreach (var coupon in coupons.Where(x => x.CanUse
+ && (x.EstimatedDiscount ?? 0M) <= memberOnly.LevelDiscountAmount))
+ {
+ coupon.CanUse = false;
+ coupon.UnavailableReason = CouponNotBetterThanLevelReason;
+ }
+
+ coupons = coupons
+ .OrderByDescending(x => x.CanUse)
+ .ThenByDescending(x => x.EstimatedDiscount ?? 0M)
+ .ThenBy(x => x.ValidEndTime)
+ .ToList();
+
+ var targetCouponId = input.CouponInstanceId switch
+ {
+ 0 => null,
+ > 0 => input.CouponInstanceId,
+ _ => ResolveRecommendedPayCouponId(order, coupons)
+ };
+
+ var breakdown = targetCouponId.HasValue
+ ? await marketingHelper.BuildBreakdownAsync(
+ order.CustomerId,
+ receivableSubtotal,
+ baseFreightAmount,
+ targetCouponId,
+ order.ConsigneeCountry,
+ order.Id)
+ : memberOnly;
+
+ return new OrderPayPreviewOutput
+ {
+ Id = order.Id,
+ ReceivableSubtotal = breakdown.ReceivableSubtotal,
+ BaseFreightAmount = breakdown.BaseFreightAmount,
+ LevelDiscountAmount = breakdown.LevelDiscountAmount,
+ CouponDiscountAmount = breakdown.CouponDiscountAmount,
+ PayableAmount = breakdown.PayableAmount,
+ MemberLevelName = breakdown.MemberLevelName,
+ AppliedCouponInstanceId = breakdown.CouponInstanceId,
+ AvailableCoupons = coupons
+ };
+ }
+
+ ///
+ /// 计算支付应付:couponInstanceId 为 null 沿用订单已绑定券(不可用则不用券),0 不用券,大于 0 使用指定券。
+ ///
+ private async Task BuildPayBreakdownAsync(
+ Order order,
+ IEnumerable fees,
+ int? couponInstanceId)
+ {
+ var feeList = fees as IList ?? fees.ToList();
+ var receivableSubtotal = MarketingFeeHelper.SumReceivable(feeList);
+ var baseFreightAmount = MarketingFeeHelper.SumReceivableBaseFreight(feeList);
+ var marketingHelper = LazyServiceProvider.GetRequiredService();
+
+ Task Build(int? couponId) =>
+ marketingHelper.BuildBreakdownAsync(
+ order.CustomerId,
+ receivableSubtotal,
+ baseFreightAmount,
+ couponId,
+ order.ConsigneeCountry,
+ order.Id);
+
+ if (couponInstanceId is > 0)
+ {
+ var breakdown = await Build(couponInstanceId);
+ if (breakdown.CouponInstanceId != couponInstanceId)
+ {
+ throw new BusinessException("所选优惠券未达使用门槛或优惠不高于会员折扣,请重新选择");
+ }
+
+ return breakdown;
+ }
+
+ if (couponInstanceId == 0 || order.CouponInstanceId is not > 0)
+ {
+ return await Build(null);
+ }
+
+ try
+ {
+ return await Build(order.CouponInstanceId);
+ }
+ catch (BusinessException)
+ {
+ return await Build(null);
+ }
+ }
+
+ private static int? ResolveRecommendedPayCouponId(Order order, List coupons)
+ {
+ if (order.CouponInstanceId is > 0
+ && coupons.Any(x => x.CanUse && x.CouponInstanceId == order.CouponInstanceId.Value))
+ {
+ return order.CouponInstanceId;
+ }
+
+ return coupons.FirstOrDefault(x => x.CanUse)?.CouponInstanceId;
+ }
+}
diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs
index 7022a57..5751fe7 100644
--- a/src/Oms.Application/Services/Orders/OrderService.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.cs
@@ -127,12 +127,12 @@ public partial class OrderService : ApplicationService
}
///
- /// 执行订单支付。
+ /// 执行订单支付(余额扣款,支持支付时选券)。
///
- /// 入参:订单操作参数。
- /// 无返回体,仅完成订单支付处理。
+ /// 入参:订单 Id;couponInstanceId 为 null 沿用已绑定券,0 不用券,大于 0 使用指定券。
+ /// 无返回体;指定券不可用、余额不足或订单状态不可支付时抛业务异常。
[HttpPost("/order/pay")]
- public async Task PayAsync(OrderActionInput input)
+ public async Task PayAsync(OrderPayInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
if (order.IsPaid > 0)
@@ -152,33 +152,42 @@ public partial class OrderService : ApplicationService
var beforeSnap = await CaptureOrderAuditSnapshotAsync(order);
var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]);
- var payAmount = order.PayableAmount ?? fees
- .Where(x => x.Type == OrderFeeTypeCodes.Receivable)
- .Sum(x => x.Amount);
- if (payAmount <= 0)
+ var breakdown = await BuildPayBreakdownAsync(order, fees, input.CouponInstanceId);
+ if (breakdown.ReceivableSubtotal <= 0)
{
throw new BusinessException("订单应付金额无效,无法支付");
}
+ var payAmount = breakdown.PayableAmount;
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value);
if (customer == null)
{
throw new BusinessException("客户不存在");
}
- var balanceMutator = LazyServiceProvider.GetRequiredService();
- await balanceMutator.DebitOrderPayAsync(
- customer,
- payAmount,
- order.Id,
- order.ServiceNo,
- $"订单支付 {order.ServiceNo}");
-
- if (order.CouponInstanceId.HasValue)
+ if (payAmount > 0)
+ {
+ var balanceMutator = LazyServiceProvider.GetRequiredService();
+ await balanceMutator.DebitOrderPayAsync(
+ customer,
+ payAmount,
+ order.Id,
+ order.ServiceNo,
+ $"订单支付 {order.ServiceNo}");
+ }
+
+ var couponManager = LazyServiceProvider.GetRequiredService();
+ if (order.CouponInstanceId is > 0 && order.CouponInstanceId != breakdown.CouponInstanceId)
+ {
+ await couponManager.ReleaseLockAsync(order.CouponInstanceId.Value, order.Id);
+ }
+
+ LazyServiceProvider.GetRequiredService()
+ .ApplySnapshotToOrder(order, breakdown, null);
+ if (breakdown.CouponInstanceId.HasValue)
{
- var couponManager = LazyServiceProvider.GetRequiredService();
var mainCustomerId = await couponManager.ResolveMainCustomerIdAsync(customer.Id);
- await couponManager.MarkUsedAsync(order.CouponInstanceId.Value, mainCustomerId, order.Id);
+ await couponManager.MarkUsedAsync(breakdown.CouponInstanceId.Value, mainCustomerId, order.Id);
}
var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(CustomerRepository, customer);
@@ -197,12 +206,15 @@ public partial class OrderService : ApplicationService
order = await GetOrderOrThrowAsync(order.Id);
var afterSnap = await CaptureOrderAuditSnapshotAsync(order);
var amountText = payAmount.ToString("0.####", CultureInfo.InvariantCulture);
+ var couponText = breakdown.CouponDiscountAmount > 0
+ ? $",优惠券抵扣 {breakdown.CouponDiscountAmount.ToString("0.####", CultureInfo.InvariantCulture)}"
+ : string.Empty;
await OperationLogWriter.TryWriteAsync(
order,
OrderOperationActionCodes.Pay,
beforeSnap,
afterSnap,
- $"支付金额 {amountText}");
+ $"支付金额 {amountText}{couponText}");
}
///
diff --git a/src/Oms.Application/Shared/MarketingCouponManager.cs b/src/Oms.Application/Shared/MarketingCouponManager.cs
index e6ac36c..d944692 100644
--- a/src/Oms.Application/Shared/MarketingCouponManager.cs
+++ b/src/Oms.Application/Shared/MarketingCouponManager.cs
@@ -314,7 +314,10 @@ public class MarketingCouponManager : IScopedDependency
await _instanceRepository.UpdateAsync(instance, cancellationToken);
}
- public async Task ReleaseLockAsync(int couponInstanceId, CancellationToken cancellationToken = default)
+ ///
+ /// 释放券锁定;传 时仅释放被该订单锁定的券。
+ ///
+ public async Task ReleaseLockAsync(int couponInstanceId, int? ownerOrderId = null, CancellationToken cancellationToken = default)
{
var instance = await _instanceRepository.FirstOrDefaultAsync(x => x.Id == couponInstanceId, cancellationToken);
if (instance == null || instance.Status != CouponInstanceStatus.Locked)
@@ -322,6 +325,11 @@ public class MarketingCouponManager : IScopedDependency
return;
}
+ if (ownerOrderId.HasValue && instance.OrderId != ownerOrderId.Value)
+ {
+ return;
+ }
+
instance.Status = CouponInstanceStatus.Available;
instance.OrderId = null;
instance.LockExpireTime = null;
@@ -383,10 +391,14 @@ public class MarketingCouponManager : IScopedDependency
}
}
+ ///
+ /// 解析券抵扣参数; 为当前订单时,被该订单锁定的券视为可用。
+ ///
public async Task ResolveCouponDiscountInputAsync(
int? couponInstanceId,
int mainCustomerId,
string? destinationCountry = null,
+ int? ownerOrderId = null,
CancellationToken cancellationToken = default)
{
if (!couponInstanceId.HasValue)
@@ -395,7 +407,7 @@ public class MarketingCouponManager : IScopedDependency
}
var instance = await GetOwnedInstanceAsync(couponInstanceId.Value, mainCustomerId, cancellationToken);
- EnsureUsable(instance);
+ EnsureUsable(instance, ownerOrderId);
var template = await _templateRepository.FirstOrDefaultAsync(x => x.Id == instance.TemplateId, cancellationToken)
?? throw new BusinessException("券模板不存在");
if (!MarketingCouponScope.Matches(template.ApplyScopeJson, destinationCountry))
@@ -422,13 +434,31 @@ public class MarketingCouponManager : IScopedDependency
return await MarketingCustomerHelper.ResolveMarketingMainCustomerIdAsync(_customerRepository, customer, cancellationToken);
}
+ ///
+ /// 构建可选券列表;传 时额外包含被该订单锁定且仍在有效期内的券。
+ ///
public async Task> BuildAvailableCouponsAsync(
int mainCustomerId,
decimal? baseFreightAmount,
string? destinationCountry = null,
+ int? ownerOrderId = null,
CancellationToken cancellationToken = default)
{
var instances = await _instanceRepository.GetAvailableByCustomerAsync(mainCustomerId, cancellationToken);
+ if (ownerOrderId is > 0)
+ {
+ var now = DateTime.Now;
+ var orderId = ownerOrderId.Value;
+ var ownLocked = await _instanceRepository.GetListAsync(
+ x => x.CustomerId == mainCustomerId
+ && x.Status == CouponInstanceStatus.Locked
+ && x.OrderId == orderId
+ && x.ValidStartTime <= now
+ && x.ValidEndTime >= now);
+ var knownIds = instances.Select(x => x.Id).ToHashSet();
+ instances.AddRange(ownLocked.Where(x => !knownIds.Contains(x.Id)));
+ }
+
if (instances.Count == 0)
{
return [];
@@ -541,7 +571,7 @@ public class MarketingCouponManager : IScopedDependency
return instance;
}
- private static void EnsureUsable(CouponInstance instance)
+ private static void EnsureUsable(CouponInstance instance, int? ownerOrderId = null)
{
var now = DateTime.Now;
if (instance.Status == CouponInstanceStatus.PendingUnlock)
@@ -566,7 +596,8 @@ public class MarketingCouponManager : IScopedDependency
if (instance.Status == CouponInstanceStatus.Locked
&& instance.LockExpireTime.HasValue
- && instance.LockExpireTime.Value >= now)
+ && instance.LockExpireTime.Value >= now
+ && !(ownerOrderId.HasValue && instance.OrderId == ownerOrderId.Value))
{
throw new BusinessException("优惠券已被其他订单占用");
}
diff --git a/src/Oms.Application/Shared/MarketingOrderHelper.cs b/src/Oms.Application/Shared/MarketingOrderHelper.cs
index f06a035..ce60a9b 100644
--- a/src/Oms.Application/Shared/MarketingOrderHelper.cs
+++ b/src/Oms.Application/Shared/MarketingOrderHelper.cs
@@ -53,15 +53,19 @@ public class MarketingOrderHelper : IScopedDependency
baseFreightAmount,
couponInstanceId,
null,
- cancellationToken);
+ cancellationToken: cancellationToken);
}
+ ///
+ /// 计算会员折/券互斥后的应付拆解; 为当前订单时允许使用被该订单锁定的券。
+ ///
public async Task BuildBreakdownAsync(
int? customerId,
decimal receivableSubtotal,
decimal baseFreightAmount,
int? couponInstanceId,
string? destinationCountry,
+ int? ownerOrderId = null,
CancellationToken cancellationToken = default)
{
if (!customerId.HasValue || customerId.Value <= 0)
@@ -87,7 +91,7 @@ public class MarketingOrderHelper : IScopedDependency
var priceTierKey = MarketingCustomerHelper.NormalizePriceTierKeyOrDefault(mainCustomer.PriceTierKey);
var couponInput = await _couponManager.ResolveCouponDiscountInputAsync(
- couponInstanceId, mainCustomerId, destinationCountry, cancellationToken);
+ couponInstanceId, mainCustomerId, destinationCountry, ownerOrderId, cancellationToken);
return MarketingBillingCalculator.Calculate(
receivableSubtotal,
@@ -114,6 +118,7 @@ public class MarketingOrderHelper : IScopedDependency
int? customerId,
decimal? baseFreightAmount,
string? destinationCountry = null,
+ int? ownerOrderId = null,
CancellationToken cancellationToken = default)
{
if (!customerId.HasValue || customerId.Value <= 0)
@@ -132,6 +137,6 @@ public class MarketingOrderHelper : IScopedDependency
sessionCustomer,
cancellationToken);
return await _couponManager.BuildAvailableCouponsAsync(
- mainCustomer.Id, baseFreightAmount, destinationCountry, cancellationToken);
+ mainCustomer.Id, baseFreightAmount, destinationCountry, ownerOrderId, cancellationToken);
}
}