From d38963055bf1f4a131b2a48b1d739af96423acd8 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Mon, 19 Oct 2026 15:47:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E4=BB=98=E7=94=A8=E5=88=B8/?= =?UTF-8?q?=E4=BC=9A=E5=91=98=E6=8A=98=E6=89=A3=E5=86=99=E5=85=A5=E8=B4=9F?= =?UTF-8?q?=E6=95=B0=E5=BA=94=E6=94=B6=E8=A1=8C=EF=BC=8C=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=E5=BA=94=E6=94=B6=E4=B8=8E=E5=AE=9E=E4=BB=98=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支付成功后写入 coupon_discount / member_discount 负数应收(币种、阶段跟随运费行,记录券码/会员等级) - 营销重算的折前应收排除抵扣行,避免重复扣减;规则公式忽略抵扣行 - 预报/入库计费任务跳过已付款订单,防止重复投递冲掉应收 - 手工新增费用禁止抵扣费用项;费用输出给出抵扣行显示名 - 订单详情返回折前小计、会员折扣、优惠券抵扣、客户实付 - 新增 fee_item 字典迁移 migrate_fee_item_marketing_discount_20261019.sql --- ...e_fee_item_marketing_discount_20261019.sql | 34 +++++++++ .../Dtos/Orders/OrderFeeOutput.cs | 5 ++ .../Dtos/Orders/OrderOutput.cs | 25 +++++++ .../Issues/OrderFormulaFieldResolver.cs | 10 ++- .../Services/Orders/OrderService.Helpers.cs | 8 +++ .../Services/Orders/OrderService.OrderEdit.cs | 5 ++ .../Orders/OrderService.ParcelBilling.cs | 9 ++- .../Services/Orders/OrderService.PayCoupon.cs | 70 +++++++++++++++++++ .../Services/Orders/OrderService.cs | 6 +- .../Orders/ParcelBillingCalculator.cs | 2 + .../Shared/MarketingCouponManager.cs | 6 +- .../Shared/MarketingFeeHelper.cs | 7 +- .../Consts/OrderFeeTypeCodes.cs | 17 +++++ 13 files changed, 198 insertions(+), 6 deletions(-) create mode 100644 sql/migrate_fee_item_marketing_discount_20261019.sql diff --git a/sql/migrate_fee_item_marketing_discount_20261019.sql b/sql/migrate_fee_item_marketing_discount_20261019.sql new file mode 100644 index 0000000..8d3b700 --- /dev/null +++ b/sql/migrate_fee_item_marketing_discount_20261019.sql @@ -0,0 +1,34 @@ +-- 费用项字典:营销抵扣(支付时写入的负数应收行)。可重复执行。 +-- coupon_discount 优惠券抵扣、member_discount 会员折扣;由 /order/pay 自动生成,禁止手工添加。 + +-- 确保字典类型存在 +INSERT INTO `t_dict_type` (`DictType`, `Remark`, `TypeSort`, `IsDeleted`, `CreateUserId`) +SELECT 'fee_item', '费用项', 0, 0, 0 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `t_dict_type` + WHERE `DictType` = 'fee_item' AND `IsDeleted` = 0 +); + +-- 曾被软删的同 code 项直接恢复 +UPDATE `t_dict` +SET `IsDeleted` = 0, `UpdateTime` = NOW() +WHERE `DictType` = 'fee_item' + AND `DictCode` IN ('coupon_discount', 'member_discount') + AND `IsDeleted` = 1; + +INSERT INTO `t_dict` (`DictType`, `DictSort`, `DictCode`, `DictName`, `IsDeleted`, `CreateUserId`) +SELECT 'fee_item', 900, 'coupon_discount', '优惠券抵扣', 0, 0 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `t_dict` + WHERE `DictType` = 'fee_item' AND `DictCode` = 'coupon_discount' +); + +INSERT INTO `t_dict` (`DictType`, `DictSort`, `DictCode`, `DictName`, `IsDeleted`, `CreateUserId`) +SELECT 'fee_item', 901, 'member_discount', '会员折扣', 0, 0 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `t_dict` + WHERE `DictType` = 'fee_item' AND `DictCode` = 'member_discount' +); diff --git a/src/Oms.Application/Dtos/Orders/OrderFeeOutput.cs b/src/Oms.Application/Dtos/Orders/OrderFeeOutput.cs index bc6a90f..e721c11 100644 --- a/src/Oms.Application/Dtos/Orders/OrderFeeOutput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderFeeOutput.cs @@ -20,6 +20,11 @@ public class OrderFeeOutput /// public string CostItem { get; set; } = string.Empty; + /// + /// 费用项显示名:仅营销抵扣行(优惠券抵扣/会员折扣)由后端给出,其余为空由前端按 fee_item 字典解析。 + /// + public string? CostItemName { get; set; } + /// /// 金额。 /// diff --git a/src/Oms.Application/Dtos/Orders/OrderOutput.cs b/src/Oms.Application/Dtos/Orders/OrderOutput.cs index 4eaed1f..c508855 100644 --- a/src/Oms.Application/Dtos/Orders/OrderOutput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderOutput.cs @@ -368,6 +368,31 @@ public class OrderOutput /// public decimal? ForecastPayableAmount { get; set; } + /// + /// 营销快照:折前应收小计。 + /// + public decimal? ReceivableSubtotal { get; set; } + + /// + /// 营销快照:会员折扣金额(与券互斥)。 + /// + public decimal LevelDiscountAmount { get; set; } + + /// + /// 营销快照:优惠券抵扣金额。 + /// + public decimal CouponDiscountAmount { get; set; } + + /// + /// 营销快照:客户实付金额(支付扣款口径;区别于应付成本)。 + /// + public decimal? ActualPayAmount { get; set; } + + /// + /// 营销快照:使用的优惠券实例 Id。 + /// + public int? CouponInstanceId { get; set; } + /// /// 订单绑定的销售产品规则发布版本。 /// diff --git a/src/Oms.Application/Services/Orders/Issues/OrderFormulaFieldResolver.cs b/src/Oms.Application/Services/Orders/Issues/OrderFormulaFieldResolver.cs index 077a4bf..3393c17 100644 --- a/src/Oms.Application/Services/Orders/Issues/OrderFormulaFieldResolver.cs +++ b/src/Oms.Application/Services/Orders/Issues/OrderFormulaFieldResolver.cs @@ -29,7 +29,7 @@ public static class OrderFormulaFieldResolver if (fieldRef.Table == "t_order_fee") { - return context.Fees.Any(f => ContainsValue(GetPropertyValue(f, fieldRef.Field), needle)); + return RuleFees(context).Any(f => ContainsValue(GetPropertyValue(f, fieldRef.Field), needle)); } var raw = GetRawValue(fieldRef, context); @@ -255,10 +255,16 @@ public static class OrderFormulaFieldResolver private static object? ResolveFeeField(string field, OrderRuleEvaluationContext context) { - var fee = context.Fees.FirstOrDefault(); + var fee = RuleFees(context).FirstOrDefault(); return fee == null ? null : GetPropertyValue(fee, field); } + /// + /// 规则只看计费产生的费用行,忽略支付写入的营销抵扣负数行。 + /// + private static IEnumerable RuleFees(OrderRuleEvaluationContext context) => + context.Fees.Where(f => !OrderFeeDiscountCostItems.IsDiscount(f.CostItem)); + private static object? GetDeclarationValue(OrderDeclaration declaration, string field) => field switch { diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs index 6728828..c15f066 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs @@ -263,6 +263,11 @@ public partial class OrderService HasInsurance = hasInsurance, ForecastReceivableAmount = order.ForecastReceivableAmount, ForecastPayableAmount = order.ForecastPayableAmount, + ReceivableSubtotal = order.ReceivableSubtotal, + LevelDiscountAmount = order.LevelDiscountAmount, + CouponDiscountAmount = order.CouponDiscountAmount, + ActualPayAmount = order.PayableAmount, + CouponInstanceId = order.CouponInstanceId, SalesProductRulesVersion = string.IsNullOrWhiteSpace(order.SalesProductRulesVersion) ? null : order.SalesProductRulesVersion.Trim(), @@ -851,6 +856,9 @@ public partial class OrderService Id = x.Id, Type = x.Type, CostItem = x.CostItem, + CostItemName = OrderFeeDiscountCostItems.IsDiscount(x.CostItem) + ? ParcelBillingCalculator.ResolveFeeDisplayName(x.CostItem, null) + : null, Amount = x.Amount, Currency = x.Currency, DownstreamProvider = x.DownstreamProvider, diff --git a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs index 6b43287..1f95706 100644 --- a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs +++ b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs @@ -900,6 +900,11 @@ public partial class OrderService throw new BusinessException("费用项不能为空"); } + if (OrderFeeDiscountCostItems.IsDiscount(costItem)) + { + throw new BusinessException("优惠券抵扣、会员折扣由支付自动生成,不能手工添加"); + } + if (input.Amount <= 0) { throw new BusinessException("金额必须大于0"); diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 8e2609f..e9afb21 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -2449,6 +2449,12 @@ public partial class OrderService { _ = cancellationToken; var order = await GetOrderOrThrowAsync(orderId); + // 已付款订单应收已固化(含营销抵扣行),重复投递的入库计费消息不得重算 + if (order.IsPaid != 0) + { + return; + } + if (string.IsNullOrWhiteSpace(order.ProductCode)) { order.OrderStatus = OrderStatusCodes.PendingAllocation; @@ -2630,7 +2636,8 @@ public partial class OrderService throw new InvalidOperationException($"预报计费时订单尚未可见,稍后重试 OrderId={orderId}"); } - if (string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase) + || order.IsPaid != 0) { return; } diff --git a/src/Oms.Application/Services/Orders/OrderService.PayCoupon.cs b/src/Oms.Application/Services/Orders/OrderService.PayCoupon.cs index 9dbf1ff..e47a62b 100644 --- a/src/Oms.Application/Services/Orders/OrderService.PayCoupon.cs +++ b/src/Oms.Application/Services/Orders/OrderService.PayCoupon.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Marketing; using Oms.Application.Dtos.Orders; using Oms.Application.Shared; +using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; namespace Oms.Application.Services.Orders; @@ -129,6 +130,75 @@ public partial class OrderService } } + /// + /// 支付成功后把会员折扣 / 优惠券抵扣写成负数应收行,使订单应收与实付一致;币种与计费阶段跟随基础运费行。 + /// + private async Task UpsertMarketingDiscountFeesAsync( + Order order, + IEnumerable fees, + MarketingPriceBreakdown breakdown, + string? couponCode) + { + var discountRows = new List(); + if (breakdown.CouponDiscountAmount <= 0 && breakdown.LevelDiscountAmount <= 0) + { + return; + } + + var receivable = fees + .Where(x => x.Type == OrderFeeTypeCodes.Receivable + && !OrderFeeDiscountCostItems.IsDiscount(x.CostItem)) + .ToList(); + var anchor = receivable + .Where(x => MarketingFeeHelper.IsBaseFreightCostItem(x.CostItem)) + .OrderByDescending(x => ChargingStageRank(x.ChargingStage)) + .FirstOrDefault() + ?? receivable.OrderByDescending(x => ChargingStageRank(x.ChargingStage)).FirstOrDefault(); + var currency = string.IsNullOrWhiteSpace(anchor?.Currency) ? "CNY" : anchor!.Currency; + var stage = anchor?.ChargingStage; + + if (breakdown.CouponDiscountAmount > 0) + { + discountRows.Add(new OrderFee + { + Type = OrderFeeTypeCodes.Receivable, + CostItem = OrderFeeDiscountCostItems.CouponDiscount, + Amount = -breakdown.CouponDiscountAmount, + Currency = currency, + ChargingStage = stage, + PriceNo = couponCode, + PriceEffectiveTime = DateTime.Now + }); + } + + if (breakdown.LevelDiscountAmount > 0) + { + discountRows.Add(new OrderFee + { + Type = OrderFeeTypeCodes.Receivable, + CostItem = OrderFeeDiscountCostItems.MemberDiscount, + Amount = -breakdown.LevelDiscountAmount, + Currency = currency, + ChargingStage = stage, + PriceNo = breakdown.MemberLevelName, + PriceEffectiveTime = DateTime.Now + }); + } + + await OrderRepository.UpsertFeesAsync(order.Id, discountRows); + } + + /// + /// 抵扣行取入库优先、其次预报的阶段;出库阶段客户侧不展示,排最后。 + /// + private static int ChargingStageRank(string? stage) => stage?.Trim().ToLowerInvariant() switch + { + OrderFeeChargingStageCodes.Outbound => -1, + OrderFeeChargingStageCodes.Inbound => 2, + OrderFeeChargingStageCodes.Forecast => 1, + _ => 0 + }; + private static int? ResolveRecommendedPayCouponId(Order order, List coupons) { if (order.CouponInstanceId is > 0 diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index 5751fe7..61eaddd 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -184,12 +184,16 @@ public partial class OrderService : ApplicationService LazyServiceProvider.GetRequiredService() .ApplySnapshotToOrder(order, breakdown, null); + string? couponCode = null; if (breakdown.CouponInstanceId.HasValue) { var mainCustomerId = await couponManager.ResolveMainCustomerIdAsync(customer.Id); - await couponManager.MarkUsedAsync(breakdown.CouponInstanceId.Value, mainCustomerId, order.Id); + var coupon = await couponManager.MarkUsedAsync(breakdown.CouponInstanceId.Value, mainCustomerId, order.Id); + couponCode = coupon.CouponCode; } + await UpsertMarketingDiscountFeesAsync(order, fees, breakdown, couponCode); + var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(CustomerRepository, customer); var pointsManager = LazyServiceProvider.GetRequiredService(); var paidBaseFreight = MarketingFeeHelper.ResolvePaidBaseFreight( diff --git a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs index 1f836a7..a1dea3b 100644 --- a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs +++ b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs @@ -1075,6 +1075,8 @@ internal static class ParcelBillingCalculator "handling" => "操作费", "proxyPacking" => "代打包费", "insurance" => "保险费", + OrderFeeDiscountCostItems.CouponDiscount => "优惠券抵扣", + OrderFeeDiscountCostItems.MemberDiscount => "会员折扣", _ => costItem }; } diff --git a/src/Oms.Application/Shared/MarketingCouponManager.cs b/src/Oms.Application/Shared/MarketingCouponManager.cs index d944692..328b537 100644 --- a/src/Oms.Application/Shared/MarketingCouponManager.cs +++ b/src/Oms.Application/Shared/MarketingCouponManager.cs @@ -266,7 +266,10 @@ public class MarketingCouponManager : IScopedDependency await _instanceRepository.UpdateAsync(instance, cancellationToken); } - public async Task MarkUsedAsync(int couponInstanceId, int mainCustomerId, int orderId, CancellationToken cancellationToken = default) + /// + /// 核销券并返回券实例(调用方可取券码写入费用行)。 + /// + public async Task MarkUsedAsync(int couponInstanceId, int mainCustomerId, int orderId, CancellationToken cancellationToken = default) { var instance = await GetOwnedInstanceAsync(couponInstanceId, mainCustomerId, cancellationToken); instance.Status = CouponInstanceStatus.Used; @@ -274,6 +277,7 @@ public class MarketingCouponManager : IScopedDependency instance.UseTime = DateTime.Now; instance.LockExpireTime = null; await _instanceRepository.UpdateAsync(instance, cancellationToken); + return instance; } public async Task RestoreOnOrderRefundAsync(Order order, bool isFullRefund, CancellationToken cancellationToken = default) diff --git a/src/Oms.Application/Shared/MarketingFeeHelper.cs b/src/Oms.Application/Shared/MarketingFeeHelper.cs index 52a8bf1..5ff47ca 100644 --- a/src/Oms.Application/Shared/MarketingFeeHelper.cs +++ b/src/Oms.Application/Shared/MarketingFeeHelper.cs @@ -12,8 +12,13 @@ public static class MarketingFeeHelper public static bool IsBaseFreightCostItem(string? costItem) => !string.IsNullOrWhiteSpace(costItem) && ParcelBillingCalculator.IsFreightCostItem(costItem); + /// + /// 折前应收小计:排除支付时写入的营销抵扣行,避免重算营销金额时重复扣减。 + /// public static decimal SumReceivable(IEnumerable fees) => - fees.Where(x => x.Type == OrderFeeTypeCodes.Receivable).Sum(x => x.Amount); + fees.Where(x => x.Type == OrderFeeTypeCodes.Receivable + && !OrderFeeDiscountCostItems.IsDiscount(x.CostItem)) + .Sum(x => x.Amount); public static decimal SumReceivableBaseFreight(IEnumerable fees) => fees.Where(x => x.Type == OrderFeeTypeCodes.Receivable && IsBaseFreightCostItem(x.CostItem)) diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderFeeTypeCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderFeeTypeCodes.cs index 0cb6040..8039945 100644 --- a/src/Oms.Domain/OrdersAggregate/Consts/OrderFeeTypeCodes.cs +++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderFeeTypeCodes.cs @@ -6,6 +6,23 @@ public static class OrderFeeTypeCodes public const string Payable = "payable"; } +/// +/// 营销抵扣费用项(支付时写入的负数应收行,fee_item 字典同 code)。 +/// +public static class OrderFeeDiscountCostItems +{ + /// 优惠券抵扣。 + public const string CouponDiscount = "coupon_discount"; + + /// 会员折扣。 + public const string MemberDiscount = "member_discount"; + + public static bool IsDiscount(string? costItem) => + !string.IsNullOrWhiteSpace(costItem) + && (string.Equals(costItem.Trim(), CouponDiscount, StringComparison.OrdinalIgnoreCase) + || string.Equals(costItem.Trim(), MemberDiscount, StringComparison.OrdinalIgnoreCase)); +} + public static class OrderFeeChargingStageCodes { public const string Forecast = "forecast";