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";