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