feat: 支付用券/会员折扣写入负数应收行,订单应收与实付一致

- 支付成功后写入 coupon_discount / member_discount 负数应收(币种、阶段跟随运费行,记录券码/会员等级)
- 营销重算的折前应收排除抵扣行,避免重复扣减;规则公式忽略抵扣行
- 预报/入库计费任务跳过已付款订单,防止重复投递冲掉应收
- 手工新增费用禁止抵扣费用项;费用输出给出抵扣行显示名
- 订单详情返回折前小计、会员折扣、优惠券抵扣、客户实付
- 新增 fee_item 字典迁移 migrate_fee_item_marketing_discount_20261019.sql
This commit is contained in:
zzh 2026-10-19 15:47:39 +08:00
parent ce75fca721
commit d38963055b
13 changed files with 198 additions and 6 deletions

View File

@ -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'
);

View File

@ -20,6 +20,11 @@ public class OrderFeeOutput
/// </summary> /// </summary>
public string CostItem { get; set; } = string.Empty; public string CostItem { get; set; } = string.Empty;
/// <summary>
/// 费用项显示名:仅营销抵扣行(优惠券抵扣/会员折扣)由后端给出,其余为空由前端按 fee_item 字典解析。
/// </summary>
public string? CostItemName { get; set; }
/// <summary> /// <summary>
/// 金额。 /// 金额。
/// </summary> /// </summary>

View File

@ -368,6 +368,31 @@ public class OrderOutput
/// </summary> /// </summary>
public decimal? ForecastPayableAmount { get; set; } public decimal? ForecastPayableAmount { get; set; }
/// <summary>
/// 营销快照:折前应收小计。
/// </summary>
public decimal? ReceivableSubtotal { get; set; }
/// <summary>
/// 营销快照:会员折扣金额(与券互斥)。
/// </summary>
public decimal LevelDiscountAmount { get; set; }
/// <summary>
/// 营销快照:优惠券抵扣金额。
/// </summary>
public decimal CouponDiscountAmount { get; set; }
/// <summary>
/// 营销快照:客户实付金额(支付扣款口径;区别于应付成本)。
/// </summary>
public decimal? ActualPayAmount { get; set; }
/// <summary>
/// 营销快照:使用的优惠券实例 Id。
/// </summary>
public int? CouponInstanceId { get; set; }
/// <summary> /// <summary>
/// 订单绑定的销售产品规则发布版本。 /// 订单绑定的销售产品规则发布版本。
/// </summary> /// </summary>

View File

@ -29,7 +29,7 @@ public static class OrderFormulaFieldResolver
if (fieldRef.Table == "t_order_fee") 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); var raw = GetRawValue(fieldRef, context);
@ -255,10 +255,16 @@ public static class OrderFormulaFieldResolver
private static object? ResolveFeeField(string field, OrderRuleEvaluationContext context) 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); return fee == null ? null : GetPropertyValue(fee, field);
} }
/// <summary>
/// 规则只看计费产生的费用行,忽略支付写入的营销抵扣负数行。
/// </summary>
private static IEnumerable<OrderFee> RuleFees(OrderRuleEvaluationContext context) =>
context.Fees.Where(f => !OrderFeeDiscountCostItems.IsDiscount(f.CostItem));
private static object? GetDeclarationValue(OrderDeclaration declaration, string field) => private static object? GetDeclarationValue(OrderDeclaration declaration, string field) =>
field switch field switch
{ {

View File

@ -263,6 +263,11 @@ public partial class OrderService
HasInsurance = hasInsurance, HasInsurance = hasInsurance,
ForecastReceivableAmount = order.ForecastReceivableAmount, ForecastReceivableAmount = order.ForecastReceivableAmount,
ForecastPayableAmount = order.ForecastPayableAmount, ForecastPayableAmount = order.ForecastPayableAmount,
ReceivableSubtotal = order.ReceivableSubtotal,
LevelDiscountAmount = order.LevelDiscountAmount,
CouponDiscountAmount = order.CouponDiscountAmount,
ActualPayAmount = order.PayableAmount,
CouponInstanceId = order.CouponInstanceId,
SalesProductRulesVersion = string.IsNullOrWhiteSpace(order.SalesProductRulesVersion) SalesProductRulesVersion = string.IsNullOrWhiteSpace(order.SalesProductRulesVersion)
? null ? null
: order.SalesProductRulesVersion.Trim(), : order.SalesProductRulesVersion.Trim(),
@ -851,6 +856,9 @@ public partial class OrderService
Id = x.Id, Id = x.Id,
Type = x.Type, Type = x.Type,
CostItem = x.CostItem, CostItem = x.CostItem,
CostItemName = OrderFeeDiscountCostItems.IsDiscount(x.CostItem)
? ParcelBillingCalculator.ResolveFeeDisplayName(x.CostItem, null)
: null,
Amount = x.Amount, Amount = x.Amount,
Currency = x.Currency, Currency = x.Currency,
DownstreamProvider = x.DownstreamProvider, DownstreamProvider = x.DownstreamProvider,

View File

@ -900,6 +900,11 @@ public partial class OrderService
throw new BusinessException("费用项不能为空"); throw new BusinessException("费用项不能为空");
} }
if (OrderFeeDiscountCostItems.IsDiscount(costItem))
{
throw new BusinessException("优惠券抵扣、会员折扣由支付自动生成,不能手工添加");
}
if (input.Amount <= 0) if (input.Amount <= 0)
{ {
throw new BusinessException("金额必须大于0"); throw new BusinessException("金额必须大于0");

View File

@ -2449,6 +2449,12 @@ public partial class OrderService
{ {
_ = cancellationToken; _ = cancellationToken;
var order = await GetOrderOrThrowAsync(orderId); var order = await GetOrderOrThrowAsync(orderId);
// 已付款订单应收已固化(含营销抵扣行),重复投递的入库计费消息不得重算
if (order.IsPaid != 0)
{
return;
}
if (string.IsNullOrWhiteSpace(order.ProductCode)) if (string.IsNullOrWhiteSpace(order.ProductCode))
{ {
order.OrderStatus = OrderStatusCodes.PendingAllocation; order.OrderStatus = OrderStatusCodes.PendingAllocation;
@ -2630,7 +2636,8 @@ public partial class OrderService
throw new InvalidOperationException($"预报计费时订单尚未可见,稍后重试 OrderId={orderId}"); 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; return;
} }

View File

@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Marketing; using Oms.Application.Dtos.Marketing;
using Oms.Application.Dtos.Orders; using Oms.Application.Dtos.Orders;
using Oms.Application.Shared; using Oms.Application.Shared;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities; using Oms.Domain.OrdersAggregate.Entities;
namespace Oms.Application.Services.Orders; namespace Oms.Application.Services.Orders;
@ -129,6 +130,75 @@ public partial class OrderService
} }
} }
/// <summary>
/// 支付成功后把会员折扣 / 优惠券抵扣写成负数应收行,使订单应收与实付一致;币种与计费阶段跟随基础运费行。
/// </summary>
private async Task UpsertMarketingDiscountFeesAsync(
Order order,
IEnumerable<OrderFee> fees,
MarketingPriceBreakdown breakdown,
string? couponCode)
{
var discountRows = new List<OrderFee>();
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);
}
/// <summary>
/// 抵扣行取入库优先、其次预报的阶段;出库阶段客户侧不展示,排最后。
/// </summary>
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<CouponAvailableOutput> coupons) private static int? ResolveRecommendedPayCouponId(Order order, List<CouponAvailableOutput> coupons)
{ {
if (order.CouponInstanceId is > 0 if (order.CouponInstanceId is > 0

View File

@ -184,12 +184,16 @@ public partial class OrderService : ApplicationService
LazyServiceProvider.GetRequiredService<MarketingOrderHelper>() LazyServiceProvider.GetRequiredService<MarketingOrderHelper>()
.ApplySnapshotToOrder(order, breakdown, null); .ApplySnapshotToOrder(order, breakdown, null);
string? couponCode = null;
if (breakdown.CouponInstanceId.HasValue) if (breakdown.CouponInstanceId.HasValue)
{ {
var mainCustomerId = await couponManager.ResolveMainCustomerIdAsync(customer.Id); 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 mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(CustomerRepository, customer);
var pointsManager = LazyServiceProvider.GetRequiredService<MarketingPointsManager>(); var pointsManager = LazyServiceProvider.GetRequiredService<MarketingPointsManager>();
var paidBaseFreight = MarketingFeeHelper.ResolvePaidBaseFreight( var paidBaseFreight = MarketingFeeHelper.ResolvePaidBaseFreight(

View File

@ -1075,6 +1075,8 @@ internal static class ParcelBillingCalculator
"handling" => "操作费", "handling" => "操作费",
"proxyPacking" => "代打包费", "proxyPacking" => "代打包费",
"insurance" => "保险费", "insurance" => "保险费",
OrderFeeDiscountCostItems.CouponDiscount => "优惠券抵扣",
OrderFeeDiscountCostItems.MemberDiscount => "会员折扣",
_ => costItem _ => costItem
}; };
} }

View File

@ -266,7 +266,10 @@ public class MarketingCouponManager : IScopedDependency
await _instanceRepository.UpdateAsync(instance, cancellationToken); await _instanceRepository.UpdateAsync(instance, cancellationToken);
} }
public async Task MarkUsedAsync(int couponInstanceId, int mainCustomerId, int orderId, CancellationToken cancellationToken = default) /// <summary>
/// 核销券并返回券实例(调用方可取券码写入费用行)。
/// </summary>
public async Task<CouponInstance> MarkUsedAsync(int couponInstanceId, int mainCustomerId, int orderId, CancellationToken cancellationToken = default)
{ {
var instance = await GetOwnedInstanceAsync(couponInstanceId, mainCustomerId, cancellationToken); var instance = await GetOwnedInstanceAsync(couponInstanceId, mainCustomerId, cancellationToken);
instance.Status = CouponInstanceStatus.Used; instance.Status = CouponInstanceStatus.Used;
@ -274,6 +277,7 @@ public class MarketingCouponManager : IScopedDependency
instance.UseTime = DateTime.Now; instance.UseTime = DateTime.Now;
instance.LockExpireTime = null; instance.LockExpireTime = null;
await _instanceRepository.UpdateAsync(instance, cancellationToken); await _instanceRepository.UpdateAsync(instance, cancellationToken);
return instance;
} }
public async Task RestoreOnOrderRefundAsync(Order order, bool isFullRefund, CancellationToken cancellationToken = default) public async Task RestoreOnOrderRefundAsync(Order order, bool isFullRefund, CancellationToken cancellationToken = default)

View File

@ -12,8 +12,13 @@ public static class MarketingFeeHelper
public static bool IsBaseFreightCostItem(string? costItem) => public static bool IsBaseFreightCostItem(string? costItem) =>
!string.IsNullOrWhiteSpace(costItem) && ParcelBillingCalculator.IsFreightCostItem(costItem); !string.IsNullOrWhiteSpace(costItem) && ParcelBillingCalculator.IsFreightCostItem(costItem);
/// <summary>
/// 折前应收小计:排除支付时写入的营销抵扣行,避免重算营销金额时重复扣减。
/// </summary>
public static decimal SumReceivable(IEnumerable<OrderFee> fees) => public static decimal SumReceivable(IEnumerable<OrderFee> 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<OrderFee> fees) => public static decimal SumReceivableBaseFreight(IEnumerable<OrderFee> fees) =>
fees.Where(x => x.Type == OrderFeeTypeCodes.Receivable && IsBaseFreightCostItem(x.CostItem)) fees.Where(x => x.Type == OrderFeeTypeCodes.Receivable && IsBaseFreightCostItem(x.CostItem))

View File

@ -6,6 +6,23 @@ public static class OrderFeeTypeCodes
public const string Payable = "payable"; public const string Payable = "payable";
} }
/// <summary>
/// 营销抵扣费用项(支付时写入的负数应收行,fee_item 字典同 code)。
/// </summary>
public static class OrderFeeDiscountCostItems
{
/// <summary>优惠券抵扣。</summary>
public const string CouponDiscount = "coupon_discount";
/// <summary>会员折扣。</summary>
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 static class OrderFeeChargingStageCodes
{ {
public const string Forecast = "forecast"; public const string Forecast = "forecast";