feat: 支付用券/会员折扣写入负数应收行,订单应收与实付一致
- 支付成功后写入 coupon_discount / member_discount 负数应收(币种、阶段跟随运费行,记录券码/会员等级) - 营销重算的折前应收排除抵扣行,避免重复扣减;规则公式忽略抵扣行 - 预报/入库计费任务跳过已付款订单,防止重复投递冲掉应收 - 手工新增费用禁止抵扣费用项;费用输出给出抵扣行显示名 - 订单详情返回折前小计、会员折扣、优惠券抵扣、客户实付 - 新增 fee_item 字典迁移 migrate_fee_item_marketing_discount_20261019.sql
This commit is contained in:
parent
ce75fca721
commit
d38963055b
34
sql/migrate_fee_item_marketing_discount_20261019.sql
Normal file
34
sql/migrate_fee_item_marketing_discount_20261019.sql
Normal 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'
|
||||
);
|
||||
@ -20,6 +20,11 @@ public class OrderFeeOutput
|
||||
/// </summary>
|
||||
public string CostItem { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 费用项显示名:仅营销抵扣行(优惠券抵扣/会员折扣)由后端给出,其余为空由前端按 fee_item 字典解析。
|
||||
/// </summary>
|
||||
public string? CostItemName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 金额。
|
||||
/// </summary>
|
||||
|
||||
@ -368,6 +368,31 @@ public class OrderOutput
|
||||
/// </summary>
|
||||
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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/// <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) =>
|
||||
field switch
|
||||
{
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
if (order.CouponInstanceId is > 0
|
||||
|
||||
@ -184,12 +184,16 @@ public partial class OrderService : ApplicationService
|
||||
|
||||
LazyServiceProvider.GetRequiredService<MarketingOrderHelper>()
|
||||
.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<MarketingPointsManager>();
|
||||
var paidBaseFreight = MarketingFeeHelper.ResolvePaidBaseFreight(
|
||||
|
||||
@ -1075,6 +1075,8 @@ internal static class ParcelBillingCalculator
|
||||
"handling" => "操作费",
|
||||
"proxyPacking" => "代打包费",
|
||||
"insurance" => "保险费",
|
||||
OrderFeeDiscountCostItems.CouponDiscount => "优惠券抵扣",
|
||||
OrderFeeDiscountCostItems.MemberDiscount => "会员折扣",
|
||||
_ => costItem
|
||||
};
|
||||
}
|
||||
|
||||
@ -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)
|
||||
/// <summary>
|
||||
/// 核销券并返回券实例(调用方可取券码写入费用行)。
|
||||
/// </summary>
|
||||
public async Task<CouponInstance> 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)
|
||||
|
||||
@ -12,8 +12,13 @@ public static class MarketingFeeHelper
|
||||
public static bool IsBaseFreightCostItem(string? costItem) =>
|
||||
!string.IsNullOrWhiteSpace(costItem) && ParcelBillingCalculator.IsFreightCostItem(costItem);
|
||||
|
||||
/// <summary>
|
||||
/// 折前应收小计:排除支付时写入的营销抵扣行,避免重算营销金额时重复扣减。
|
||||
/// </summary>
|
||||
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) =>
|
||||
fees.Where(x => x.Type == OrderFeeTypeCodes.Receivable && IsBaseFreightCostItem(x.CostItem))
|
||||
|
||||
@ -6,6 +6,23 @@ public static class OrderFeeTypeCodes
|
||||
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 const string Forecast = "forecast";
|
||||
|
||||
Loading…
Reference in New Issue
Block a user