fix: 修改订单计算逻辑

This commit is contained in:
2025-09-19 18:32:25 +08:00
parent 37f1079b1f
commit f76dff67d4
20 changed files with 1800 additions and 701 deletions

View File

@@ -1,7 +1,15 @@
import { BigNumber } from 'bignumber.js';
// 配置BigNumber精度
BigNumber.set({
DECIMAL_PLACES: 2,
ROUNDING_MODE: BigNumber.ROUND_DOWN // 向下取整,符合业务需求
});
/**
* 购物车订单价格计算公共库
* 功能:覆盖订单全链路费用计算(商品价格、优惠券、积分、餐位费等),支持策略扩展
* 小数处理:统一舍去小数点后两位(如 10.129 → 10.1215.998 → 15.99
* 小数处理:使用bignumber.js确保精度统一舍去小数点后两位(如 10.129 → 10.1215.998 → 15.99
* 扩展设计:优惠券/营销活动采用策略模式,新增类型仅需扩展策略,无需修改核心逻辑
* 关键规则:
* - 所有优惠券均支持指定门槛商品后端foods字段空字符串=全部商品ID字符串=指定商品ID
@@ -22,17 +30,20 @@ export enum GoodsType {
PACKAGE = 'package'// 打包商品(如套餐/预打包商品,按普通商品逻辑处理,可扩展特殊规则)
}
/** 优惠券计算结果类型 */
/** 优惠券计算结果类型(新增细分字段) */
interface CouponResult {
deductionAmount: number; // 抵扣金额
excludedProductIds: string[]; // 不适用商品ID列表注意是商品ID非购物车ID
usedCoupon: Coupon | undefined; // 实际使用的优惠券
productCouponDeduction: number; // 新增:商品优惠券抵扣(兑换券等)
fullCouponDeduction: number; // 新增:满减优惠券抵扣
}
/** 兑换券计算结果类型 */
/** 兑换券计算结果类型(新增细分字段) */
interface ExchangeCalculationResult {
deductionAmount: number;
excludedProductIds: string[]; // 不适用商品ID列表商品ID
productCouponDeduction: number; // 新增:兑换券属于商品券,同步记录
}
/** 优惠券类型枚举 */
@@ -193,10 +204,23 @@ export interface SeatFeeConfig {
personCount: number; // 用餐人数默认1
isEnabled: boolean; // 是否启用餐位费默认false
}
/** 商家减免类型枚举 */
export enum MerchantReductionType {
FIXED_AMOUNT = 'fixed_amount', // 固定金额减免(如直接减 10 元)
DISCOUNT_RATE = 'discount_rate' // 比例折扣减免(如打 9 折,即减免 10%
}
/** 商家减免配置(新增,替代原单一金额字段) */
export interface MerchantReductionConfig {
type: MerchantReductionType; // 减免类型(二选一)
fixedAmount?: number; // 固定减免金额(元,仅 FIXED_AMOUNT 生效≥0
discountRate?: number; // 折扣率(%,仅 DISCOUNT_RATE 生效0-100如 90 代表 9 折)
}
/** 订单额外费用配置 */
export interface OrderExtraConfig {
merchantReduction: number; // 商家减免金额默认0
// merchantReduction: number; // 商家减免金额默认0
// 替换原单一金额字段,支持两种减免形式
merchantReduction: MerchantReductionConfig;
additionalFee: number; // 附加费如余额充值、券包默认0
pointDeductionRule: PointDeductionRule; // 积分抵扣规则
seatFeeConfig: SeatFeeConfig; // 餐位费配置
@@ -206,19 +230,27 @@ export interface OrderExtraConfig {
memberDiscountRate?: number; // 会员折扣率如0.95=95折无会员价时用
}
/** 订单费用汇总(所有子项清晰展示,方便调用方使用 */
/** 订单费用汇总(修改:补充商家减免类型和明细 */
export interface OrderCostSummary {
goodsOriginalAmount: number; // 商品原价总和
goodsDiscountAmount: number; // 商品折扣金额(原价-实际价)
couponDeductionAmount: number; // 优惠券抵扣金额
pointDeductionAmount: number; // 积分抵扣金额
seatFee: number; // 餐位费
packFee: number; // 打包费
merchantReductionAmount: number; // 商家减免金额
additionalFee: number; // 附加
finalPayAmount: number; // 最终实付金额
couponUsed?: Coupon; // 实际使用的优惠券(互斥时选最优)
pointUsed: number; // 实际使用的积分
goodsRealAmount: number; // 商品真实原价总和
goodsOriginalAmount: number; // 商品原价总和
goodsDiscountAmount: number; // 商品折扣金额
couponDeductionAmount: number; // 优惠券总抵扣
productCouponDeduction: number; // 商品优惠券抵扣
fullCouponDeduction: number; // 满减优惠券抵扣
pointDeductionAmount: number; // 积分抵扣金额
seatFee: number; // 餐位
packFee: number; // 打包费
// 新增:商家减免明细
merchantReduction: {
type: MerchantReductionType; // 实际使用的减免类型
originalConfig: MerchantReductionConfig; // 原始配置(便于前端展示)
actualAmount: number; // 实际减免金额计算后的值≥0
};
additionalFee: number; // 附加费
finalPayAmount: number; // 最终实付金额
couponUsed?: Coupon; // 实际使用的优惠券
pointUsed: number; // 实际使用的积分
}
// ============================ 2. 基础工具函数核心修正所有商品ID匹配用product_id ============================
@@ -265,9 +297,8 @@ export function convertBackendCouponToToolCoupon(
vipPriceShare: backendCoupon.vipPriceShare === 1,
useType: backendCoupon.useType ? backendCoupon.useType.split(',') : [],
isValid: isCouponInValidPeriod(backendCoupon, currentTime),
applicableProductIds: applicableProductIds
applicableProductIds: applicableProductIds,
};
// 5. 按券类型补充专属字段
switch (couponType) {
case CouponType.FULL_REDUCTION:
@@ -336,9 +367,6 @@ function isCouponAvailable(
// 1. 状态校验必须启用status=1
if (backendCoupon.status !== 1) return false;
// 2. 库存校验:剩余数量>0-10086表示不限量
if (backendCoupon.leftNum !== -10086 && (backendCoupon.leftNum || 0) <= 0) return false;
// 3. 有效期校验:必须在有效期内
if (!isCouponInValidPeriod(backendCoupon, currentTime)) return false;
@@ -377,6 +405,7 @@ function isCouponInValidPeriod(backendCoupon: BackendCoupon, currentTime: Date):
if (validType === 'custom' && createTime && validDays) {
const create = new Date(createTime);
const end = new Date(create.getTime() + validDays * 24 * 60 * 60 * 1000); // 加N天
return currentTime <= end;
}
@@ -501,12 +530,13 @@ export function formatDiscountRate(backendDiscountRate?: number): number {
}
/**
* 统一小数处理:舍去小数点后两位(如 10.129 → 10.1215.998 → 15.99
* 统一小数处理:舍去小数点后两位以后的数字(解决浮点数精度偏差
* 如 10.129 → 10.121.01 → 1.011.0100000001 → 1.01
* @param num 待处理数字
* @returns 处理后保留两位小数的数字
*/
export function truncateToTwoDecimals(num: number): number {
return Math.floor(num * 100) / 100;
export function truncateToTwoDecimals(num: number | string): number {
return new BigNumber(num).decimalPlaces(2, BigNumber.ROUND_DOWN).toNumber();
}
/**
@@ -539,7 +569,7 @@ export function calcMemberPrice(
isMember: boolean,
memberDiscountRate?: number
): number {
if (!isMember) return goods.salePrice;
if (!isMember) return truncateToTwoDecimals(goods.salePrice);
// 优先级SKU会员价 > 商品会员价 > 商品原价(无会员价时用会员折扣)
const basePrice = goods.skuData?.memberPrice
@@ -547,9 +577,13 @@ export function calcMemberPrice(
?? goods.salePrice;
// 仅当无SKU会员价、无商品会员价时才应用会员折扣率
return memberDiscountRate && !goods.skuData?.memberPrice && !goods.memberPrice
? truncateToTwoDecimals(basePrice * memberDiscountRate)
: basePrice;
if (memberDiscountRate && !goods.skuData?.memberPrice && !goods.memberPrice) {
return truncateToTwoDecimals(
new BigNumber(basePrice).multipliedBy(memberDiscountRate).toNumber()
);
}
return truncateToTwoDecimals(basePrice);
}
/**
@@ -631,37 +665,39 @@ export function calcCouponThresholdAmount(
config: Pick<OrderExtraConfig, 'isMember' | 'memberDiscountRate'>,
activities: ActivityConfig[] = []
): number {
return truncateToTwoDecimals(
eligibleGoods.reduce((total, goods) => {
const availableNum = Math.max(0, goods.number - (goods.returnNum || 0));
if (availableNum <= 0) return total;
let total = new BigNumber(0);
// 1. 基础金额默认用商品原价SKU原价优先
const basePrice = goods.skuData?.salePrice ?? goods.salePrice;
let itemAmount = basePrice * availableNum;
for (const goods of eligibleGoods) {
const availableNum = Math.max(0, goods.number - (goods.returnNum || 0));
if (availableNum <= 0) continue;
// 2. 处理「与会员价/会员折扣同享」规则:若开启,用会员价计算
if (coupon.vipPriceShare) {
const memberPrice = calcMemberPrice(goods, config.isMember, config.memberDiscountRate);
itemAmount = memberPrice * availableNum;
// 1. 基础金额默认用商品原价SKU原价优先
const basePrice = new BigNumber(goods.skuData?.salePrice ?? goods.salePrice);
let itemAmount = basePrice.multipliedBy(availableNum);
// 2. 处理「与会员价/会员折扣同享」规则:若开启,用会员价计算
if (coupon.vipPriceShare) {
const memberPrice = new BigNumber(calcMemberPrice(goods, config.isMember, config.memberDiscountRate));
itemAmount = memberPrice.multipliedBy(availableNum);
}
// 3. 处理「与限时折扣同享」规则若开启叠加限时折扣按商品ID匹配活动
if (coupon.discountShare) {
const activity = goods.activityInfo
?? activities.find(act =>
act.type === ActivityType.TIME_LIMIT_DISCOUNT
&& (act.applicableProductIds || []).includes(String(goods.product_id)) // 核心修正用商品ID匹配活动
);
if (activity) {
itemAmount = itemAmount.multipliedBy(activity.discountRate); // 叠加限时折扣
}
}
// 3. 处理「与限时折扣同享」规则若开启叠加限时折扣按商品ID匹配活动
if (coupon.discountShare) {
const activity = goods.activityInfo
?? activities.find(act =>
act.type === ActivityType.TIME_LIMIT_DISCOUNT
&& (act.applicableProductIds || []).includes(String(goods.product_id)) // 核心修正用商品ID匹配活动
);
total = total.plus(itemAmount);
}
if (activity) {
itemAmount = itemAmount * activity.discountRate; // 叠加限时折扣
}
}
return total + itemAmount;
}, 0)
);
return truncateToTwoDecimals(total.toNumber());
}
// ============================ 3. 商品核心价格计算核心修正按商品ID匹配营销活动 ============================
@@ -685,25 +721,30 @@ export function calcSingleGoodsRealPrice(
}
// 2. 优先级2会员价含会员折扣率SKU会员价优先
const memberPrice = calcMemberPrice(goods, isMember, memberDiscountRate);
const memberPrice = new BigNumber(calcMemberPrice(goods, isMember, memberDiscountRate));
// 3. 优先级3营销活动折扣如限时折扣需按商品ID匹配活动
const isActivityApplicable = activity
? (activity.applicableProductIds || []).includes(String(goods.product_id)) // 核心修正用商品ID匹配活动
: false;
if (!activity || !isActivityApplicable) {
return memberPrice;
return memberPrice.toNumber();
}
// 处理活动与会员的同享/不同享逻辑
if (activity.vipPriceShare) {
// 同享:会员价基础上叠加工活动折扣
return truncateToTwoDecimals(memberPrice * activity.discountRate);
return truncateToTwoDecimals(
memberPrice.multipliedBy(activity.discountRate).toNumber()
);
} else {
// 不同享取会员价和活动价的最小值活动价用SKU原价计算
const basePriceForActivity = goods.skuData?.salePrice ?? goods.salePrice;
const activityPrice = truncateToTwoDecimals(basePriceForActivity * activity.discountRate);
return Math.min(memberPrice, activityPrice);
const basePriceForActivity = new BigNumber(goods.skuData?.salePrice ?? goods.salePrice);
const activityPrice = basePriceForActivity.multipliedBy(activity.discountRate);
return truncateToTwoDecimals(
memberPrice.isLessThanOrEqualTo(activityPrice) ? memberPrice.toNumber() : activityPrice.toNumber()
);
}
}
@@ -713,13 +754,15 @@ export function calcSingleGoodsRealPrice(
* @returns 商品原价总和(元)
*/
export function calcGoodsOriginalAmount(goodsList: BaseCartItem[]): number {
return truncateToTwoDecimals(
goodsList.reduce((total, goods) => {
const availableNum = Math.max(0, goods.number - (goods.returnNum || 0));
const basePrice = goods.skuData?.salePrice ?? goods.salePrice; // SKU原价优先
return total + basePrice * availableNum;
}, 0)
);
let total = new BigNumber(0);
for (const goods of goodsList) {
const availableNum = Math.max(0, goods.number - (goods.returnNum || 0));
const basePrice = new BigNumber(goods.skuData?.salePrice ?? goods.salePrice); // SKU原价优先
total = total.plus(basePrice.multipliedBy(availableNum));
}
return truncateToTwoDecimals(total.toNumber());
}
/**
@@ -734,21 +777,23 @@ export function calcGoodsRealAmount(
config: Pick<OrderExtraConfig, 'isMember' | 'memberDiscountRate'>,
activities: ActivityConfig[] = []
): number {
return truncateToTwoDecimals(
goodsList.reduce((total, goods) => {
const availableNum = Math.max(0, goods.number - (goods.returnNum || 0));
if (availableNum <= 0) return total;
let total = new BigNumber(0);
// 匹配商品参与的营销活动按商品ID匹配优先商品自身配置
const activity = goods.activityInfo
?? activities.find(act =>
(act.applicableProductIds || []).includes(String(goods.product_id)) // 核心修正用商品ID匹配活动
);
for (const goods of goodsList) {
const availableNum = Math.max(0, goods.number - (goods.returnNum || 0));
if (availableNum <= 0) continue;
const realPrice = calcSingleGoodsRealPrice(goods, { ...config, activity });
return total + realPrice * availableNum;
}, 0)
);
// 匹配商品参与的营销活动按商品ID匹配优先商品自身配置
const activity = goods.activityInfo
?? activities.find(act =>
(act.applicableProductIds || []).includes(String(goods.product_id)) // 核心修正用商品ID匹配活动
);
const realPrice = new BigNumber(calcSingleGoodsRealPrice(goods, { ...config, activity }));
total = total.plus(realPrice.multipliedBy(availableNum));
}
return truncateToTwoDecimals(total.toNumber());
}
/**
@@ -761,7 +806,11 @@ export function calcGoodsDiscountAmount(
goodsOriginalAmount: number,
goodsRealAmount: number
): number {
return truncateToTwoDecimals(Math.max(0, goodsOriginalAmount - goodsRealAmount));
const original = new BigNumber(goodsOriginalAmount);
const real = new BigNumber(goodsRealAmount);
const discount = original.minus(real);
return truncateToTwoDecimals(Math.max(0, discount.toNumber()));
}
// ============================ 4. 优惠券抵扣计算策略模式核心修正按商品ID匹配 ============================
@@ -778,6 +827,8 @@ interface CouponCalculationStrategy {
): {
deductionAmount: number;
excludedProductIds: string[]; // 排除的商品ID列表商品ID
productCouponDeduction?: number; // 新增:当前券属于商品券时的抵扣金额
fullCouponDeduction?: number; // 新增:当前券属于满减券时的抵扣金额
};
}
@@ -787,35 +838,48 @@ class FullReductionStrategy implements CouponCalculationStrategy {
coupon: FullReductionCoupon,
goodsList: BaseCartItem[],
config: any
): { deductionAmount: number; excludedProductIds: string[] } {
): { deductionAmount: number; excludedProductIds: string[]; fullCouponDeduction: number } {
// 1. 基础校验:优惠券不可用/门店不匹配 → 抵扣0
if (!coupon.available || !isStoreMatchByList(coupon.useShops, config.currentStoreId)) {
return { deductionAmount: 0, excludedProductIds: [] };
return { deductionAmount: 0, excludedProductIds: [], fullCouponDeduction: 0 };
}
// 2. 第一步:排除临时菜/赠菜/已抵扣商品基础合格商品按商品ID排除
const baseEligibleGoods = filterCouponEligibleGoods(goodsList, config.excludedProductIds || []);
// 3. 第二步按商品ID筛选门槛商品匹配applicableProductIds
const thresholdGoods = filterThresholdGoods(baseEligibleGoods, coupon.applicableProductIds);
if (thresholdGoods.length === 0) {
return { deductionAmount: 0, excludedProductIds: [] };
return { deductionAmount: 0, excludedProductIds: [], fullCouponDeduction: 0 };
}
// 4. 按同享规则计算门槛金额按商品ID匹配活动
const thresholdAmount = calcCouponThresholdAmount(
const thresholdAmount = new BigNumber(calcCouponThresholdAmount(
thresholdGoods,
coupon,
{ isMember: config.isMember, memberDiscountRate: config.memberDiscountRate },
config.activities
);
));
// 5. 满门槛则抵扣否则0不超过最大减免
if (thresholdAmount < coupon.fullAmount) {
return { deductionAmount: 0, excludedProductIds: [] };
if (thresholdAmount.isLessThan(coupon.fullAmount)) {
return { deductionAmount: 0, excludedProductIds: [], fullCouponDeduction: 0 };
}
const maxReduction = coupon.maxDiscountAmount ?? coupon.discountAmount;
const deductionAmount = truncateToTwoDecimals(Math.min(coupon.discountAmount, maxReduction));
return { deductionAmount, excludedProductIds: [] };
const deductionAmount = truncateToTwoDecimals(
new BigNumber(coupon.discountAmount).isLessThan(maxReduction)
? coupon.discountAmount
: maxReduction
);
// 满减券计入满减优惠券抵扣
return {
deductionAmount,
excludedProductIds: [],
fullCouponDeduction: deductionAmount
};
}
}
@@ -825,10 +889,10 @@ class DiscountStrategy implements CouponCalculationStrategy {
coupon: DiscountCoupon,
goodsList: BaseCartItem[],
config: any
): { deductionAmount: number; excludedProductIds: string[] } {
): { deductionAmount: number; excludedProductIds: string[]; productCouponDeduction: number } {
// 1. 基础校验:优惠券不可用/门店不匹配 → 抵扣0
if (!coupon.available || !isStoreMatchByList(coupon.useShops, config.currentStoreId)) {
return { deductionAmount: 0, excludedProductIds: [] };
return { deductionAmount: 0, excludedProductIds: [], productCouponDeduction: 0 };
}
// 2. 第一步:排除临时菜/赠菜/已抵扣商品基础合格商品按商品ID排除
@@ -836,21 +900,31 @@ class DiscountStrategy implements CouponCalculationStrategy {
// 3. 第二步按商品ID筛选门槛商品匹配applicableProductIds
const thresholdGoods = filterThresholdGoods(baseEligibleGoods, coupon.applicableProductIds);
if (thresholdGoods.length === 0) {
return { deductionAmount: 0, excludedProductIds: [] };
return { deductionAmount: 0, excludedProductIds: [], productCouponDeduction: 0 };
}
// 4. 按同享规则计算折扣基数按商品ID匹配活动
const discountBaseAmount = calcCouponThresholdAmount(
const discountBaseAmount = new BigNumber(calcCouponThresholdAmount(
thresholdGoods,
coupon,
{ isMember: config.isMember, memberDiscountRate: config.memberDiscountRate },
config.activities
);
));
// 5. 计算折扣金额(不超过最大减免)
const discountAmount = truncateToTwoDecimals(discountBaseAmount * (1 - coupon.discountRate));
const deductionAmount = truncateToTwoDecimals(Math.min(discountAmount, coupon.maxDiscountAmount));
return { deductionAmount, excludedProductIds: [] };
const discountAmount = discountBaseAmount.multipliedBy(new BigNumber(1).minus(coupon.discountRate));
const deductionAmount = truncateToTwoDecimals(
discountAmount.isLessThan(coupon.maxDiscountAmount)
? discountAmount.toNumber()
: coupon.maxDiscountAmount
);
// 折扣券计入商品优惠券抵扣(可根据业务调整归类)
return {
deductionAmount,
excludedProductIds: [],
productCouponDeduction: deductionAmount
};
}
}
@@ -860,13 +934,13 @@ class SecondHalfPriceStrategy implements CouponCalculationStrategy {
coupon: SecondHalfPriceCoupon,
goodsList: BaseCartItem[],
config: any
): { deductionAmount: number; excludedProductIds: string[] } {
): { deductionAmount: number; excludedProductIds: string[]; productCouponDeduction: number } {
// 1. 基础校验:优惠券不可用/门店不匹配 → 抵扣0
if (!coupon.available || !isStoreMatchByList(coupon.useShops, config.currentStoreId)) {
return { deductionAmount: 0, excludedProductIds: [] };
return { deductionAmount: 0, excludedProductIds: [], productCouponDeduction: 0 };
}
let totalDeduction = 0;
let totalDeduction = new BigNumber(0);
const excludedProductIds: string[] = []; // 存储排除的商品ID商品ID
// 2. 第一步:排除临时菜/赠菜/已抵扣商品基础合格商品按商品ID排除
@@ -874,7 +948,7 @@ class SecondHalfPriceStrategy implements CouponCalculationStrategy {
// 3. 第二步按商品ID筛选门槛商品匹配applicableProductIds
const thresholdGoods = filterThresholdGoods(baseEligibleGoods, coupon.applicableProductIds);
if (thresholdGoods.length === 0) {
return { deductionAmount: 0, excludedProductIds: [] };
return { deductionAmount: 0, excludedProductIds: [], productCouponDeduction: 0 };
}
// 按商品ID分组避免同商品多次处理用商品ID作为分组key
@@ -905,20 +979,23 @@ class SecondHalfPriceStrategy implements CouponCalculationStrategy {
const activity = config.activities.find((act: ActivityConfig) =>
(act.applicableProductIds || []).includes(productIdStr) // 按商品ID匹配活动
);
const realPrice = calcSingleGoodsRealPrice(sampleGood, {
const realPrice = new BigNumber(calcSingleGoodsRealPrice(sampleGood, {
isMember: config.isMember,
memberDiscountRate: config.memberDiscountRate,
activity
});
}));
// 累计抵扣金额并标记已优惠商品记录商品ID
totalDeduction += realPrice * 0.5 * discountCount;
totalDeduction = totalDeduction.plus(realPrice.multipliedBy(0.5).multipliedBy(discountCount));
excludedProductIds.push(productIdStr);
}
const deductionAmount = truncateToTwoDecimals(totalDeduction.toNumber());
// 第二件半价券计入商品优惠券抵扣
return {
deductionAmount: truncateToTwoDecimals(totalDeduction),
excludedProductIds
deductionAmount,
excludedProductIds,
productCouponDeduction: deductionAmount
};
}
}
@@ -929,13 +1006,13 @@ class BuyOneGetOneStrategy implements CouponCalculationStrategy {
coupon: BuyOneGetOneCoupon,
goodsList: BaseCartItem[],
config: any
): { deductionAmount: number; excludedProductIds: string[] } {
): { deductionAmount: number; excludedProductIds: string[]; productCouponDeduction: number } {
// 1. 基础校验:优惠券不可用/门店不匹配 → 抵扣0
if (!coupon.available || !isStoreMatchByList(coupon.useShops, config.currentStoreId)) {
return { deductionAmount: 0, excludedProductIds: [] };
return { deductionAmount: 0, excludedProductIds: [], productCouponDeduction: 0 };
}
let totalDeduction = 0;
let totalDeduction = new BigNumber(0);
const excludedProductIds: string[] = []; // 存储排除的商品ID商品ID
// 2. 第一步:排除临时菜/赠菜/已抵扣商品基础合格商品按商品ID排除
@@ -943,7 +1020,7 @@ class BuyOneGetOneStrategy implements CouponCalculationStrategy {
// 3. 第二步按商品ID筛选门槛商品匹配applicableProductIds
const thresholdGoods = filterThresholdGoods(baseEligibleGoods, coupon.applicableProductIds);
if (thresholdGoods.length === 0) {
return { deductionAmount: 0, excludedProductIds: [] };
return { deductionAmount: 0, excludedProductIds: [], productCouponDeduction: 0 };
}
// 按商品ID分组用商品ID作为分组key
@@ -971,20 +1048,23 @@ class BuyOneGetOneStrategy implements CouponCalculationStrategy {
const activity = config.activities.find((act: ActivityConfig) =>
(act.applicableProductIds || []).includes(productIdStr) // 按商品ID匹配活动
);
const realPrice = calcSingleGoodsRealPrice(sampleGood, {
const realPrice = new BigNumber(calcSingleGoodsRealPrice(sampleGood, {
isMember: config.isMember,
memberDiscountRate: config.memberDiscountRate,
activity
});
}));
// 累计抵扣金额送1件=减免1件价格并标记商品ID
totalDeduction += realPrice * 1 * discountCount;
totalDeduction = totalDeduction.plus(realPrice.multipliedBy(1).multipliedBy(discountCount));
excludedProductIds.push(productIdStr);
}
const deductionAmount = truncateToTwoDecimals(totalDeduction.toNumber());
// 买一送一券计入商品优惠券抵扣
return {
deductionAmount: truncateToTwoDecimals(totalDeduction),
excludedProductIds
deductionAmount,
excludedProductIds,
productCouponDeduction: deductionAmount
};
}
}
@@ -995,10 +1075,10 @@ class ExchangeCouponStrategy implements CouponCalculationStrategy {
coupon: ExchangeCoupon,
goodsList: BaseCartItem[],
config: any
): { deductionAmount: number; excludedProductIds: string[] } {
): { deductionAmount: number; excludedProductIds: string[]; productCouponDeduction: number } {
// 1. 基础校验:优惠券不可用/门店不匹配 → 抵扣0
if (!coupon.available || !isStoreMatchByList(coupon.useShops, config.currentStoreId)) {
return { deductionAmount: 0, excludedProductIds: [] };
return { deductionAmount: 0, excludedProductIds: [], productCouponDeduction: 0 };
}
// 2. 第一步:排除临时菜/赠菜/已抵扣商品基础合格商品按商品ID排除
@@ -1006,13 +1086,13 @@ class ExchangeCouponStrategy implements CouponCalculationStrategy {
// 3. 第二步按商品ID筛选门槛商品匹配applicableProductIds
const thresholdGoods = filterThresholdGoods(baseEligibleGoods, coupon.applicableProductIds);
if (thresholdGoods.length === 0) {
return { deductionAmount: 0, excludedProductIds: [] };
return { deductionAmount: 0, excludedProductIds: [], productCouponDeduction: 0 };
}
// 按规则排序商品(按价格/数量/加入顺序)
const sortedGoods = sortGoodsForCoupon(thresholdGoods, coupon.sortRule, config.cartOrder);
let remainingCount = coupon.deductCount;
let totalDeduction = 0;
let totalDeduction = new BigNumber(0);
const excludedProductIds: string[] = []; // 存储排除的商品ID商品ID
// 计算兑换抵扣金额按商品ID累计避免重复抵扣
@@ -1030,22 +1110,25 @@ class ExchangeCouponStrategy implements CouponCalculationStrategy {
const activity = config.activities.find((act: ActivityConfig) =>
(act.applicableProductIds || []).includes(productIdStr) // 按商品ID匹配活动
);
const realPrice = calcSingleGoodsRealPrice(goods, {
const realPrice = new BigNumber(calcSingleGoodsRealPrice(goods, {
isMember: config.isMember,
memberDiscountRate: config.memberDiscountRate,
activity
});
}));
// 累计抵扣金额并标记商品
totalDeduction += realPrice * deductCount;
totalDeduction = totalDeduction.plus(realPrice.multipliedBy(deductCount));
excludedProductIds.push(productIdStr);
usedProductIds.add(productIdStr);
remainingCount -= deductCount;
}
const deductionAmount = truncateToTwoDecimals(totalDeduction.toNumber());
// 商品兑换券计入商品优惠券抵扣
return {
deductionAmount: truncateToTwoDecimals(totalDeduction),
excludedProductIds
deductionAmount,
excludedProductIds,
productCouponDeduction: deductionAmount
};
}
}
@@ -1082,11 +1165,11 @@ function getCouponStrategy(couponType: CouponType): CouponCalculationStrategy {
}
/**
* 计算优惠券抵扣金额处理互斥逻辑选择最优优惠券按商品ID排除
* 计算优惠券抵扣金额处理互斥逻辑选择最优优惠券按商品ID排除,新增细分统计
* @param backendCoupons 后端优惠券列表
* @param goodsList 商品列表
* @param config 订单配置(含就餐类型)
* @returns 最优优惠券的抵扣结果
* @returns 最优优惠券的抵扣结果(含商品券/满减券细分)
*/
export function calcCouponDeduction(
backendCoupons: BackendCoupon[],
@@ -1099,6 +1182,8 @@ export function calcCouponDeduction(
}
): {
deductionAmount: number;
productCouponDeduction: number; // 新增:商品优惠券抵扣(兑换券/折扣券/买一送一等)
fullCouponDeduction: number; // 新增:满减优惠券抵扣
usedCoupon?: Coupon;
excludedProductIds: string[]; // 排除的商品ID列表商品ID
} {
@@ -1111,20 +1196,26 @@ export function calcCouponDeduction(
config.currentTime
))
.filter(Boolean) as Coupon[];
if (toolCoupons.length === 0) {
return { deductionAmount: 0, excludedProductIds: [] };
return {
deductionAmount: 0,
productCouponDeduction: 0,
fullCouponDeduction: 0,
excludedProductIds: []
};
}
// 2. 优惠券互斥逻辑:兑换券与其他券互斥,优先选最优
const exchangeCoupons = toolCoupons.filter(c => c.type === CouponType.EXCHANGE);
const nonExchangeCoupons = toolCoupons.filter(c => c.type !== CouponType.EXCHANGE);
// 3. 计算非兑换券最优抵扣传递已抵扣商品ID避免重复
// 3. 计算非兑换券最优抵扣传递已抵扣商品ID避免重复,统计细分字段
let nonExchangeResult: CouponResult = {
deductionAmount: 0,
excludedProductIds: [],
usedCoupon: undefined
usedCoupon: undefined,
productCouponDeduction: 0,
fullCouponDeduction: 0
};
if (nonExchangeCoupons.length > 0) {
nonExchangeResult = nonExchangeCoupons.reduce((best, coupon) => {
@@ -1134,15 +1225,26 @@ export function calcCouponDeduction(
excludedProductIds: best.excludedProductIds // 传递已排除的商品ID商品ID
});
const currentResult: CouponResult = {
...result,
usedCoupon: coupon
deductionAmount: result.deductionAmount,
excludedProductIds: result.excludedProductIds,
usedCoupon: coupon,
// 按策略返回的字段赋值细分抵扣
productCouponDeduction: result.productCouponDeduction || 0,
fullCouponDeduction: result.fullCouponDeduction || 0
};
return currentResult.deductionAmount > best.deductionAmount ? currentResult : best;
// 按总抵扣金额选择最优
return new BigNumber(currentResult.deductionAmount).isGreaterThan(best.deductionAmount)
? currentResult
: best;
}, nonExchangeResult);
}
// 4. 计算兑换券抵扣排除非兑换券已抵扣的商品ID
let exchangeResult: ExchangeCalculationResult = { deductionAmount: 0, excludedProductIds: [] };
// 4. 计算兑换券抵扣排除非兑换券已抵扣的商品ID,统计商品券细分
let exchangeResult: ExchangeCalculationResult = {
deductionAmount: 0,
excludedProductIds: [],
productCouponDeduction: 0
};
if (exchangeCoupons.length > 0) {
exchangeResult = exchangeCoupons.reduce((best, coupon) => {
const strategy = getCouponStrategy(coupon.type);
@@ -1150,14 +1252,25 @@ export function calcCouponDeduction(
...config,
excludedProductIds: [...nonExchangeResult.excludedProductIds, ...best.excludedProductIds] // 合并排除的商品ID
});
return result.deductionAmount > best.deductionAmount ? result : best;
return new BigNumber(result.deductionAmount).isGreaterThan(best.deductionAmount)
? {
deductionAmount: result.deductionAmount,
excludedProductIds: result.excludedProductIds,
productCouponDeduction: result.productCouponDeduction || 0 // 兑换券属于商品券
}
: best;
}, exchangeResult);
}
// 5. 汇总结果:兑换券与非兑换券不可同时使用,取抵扣金额大的
const isExchangeBetter = exchangeResult.deductionAmount > nonExchangeResult.deductionAmount;
const exchangeBn = new BigNumber(exchangeResult.deductionAmount);
const nonExchangeBn = new BigNumber(nonExchangeResult.deductionAmount);
const isExchangeBetter = exchangeBn.isGreaterThan(nonExchangeBn);
return {
deductionAmount: truncateToTwoDecimals(isExchangeBetter ? exchangeResult.deductionAmount : nonExchangeResult.deductionAmount),
productCouponDeduction: isExchangeBetter ? exchangeResult.productCouponDeduction : nonExchangeResult.productCouponDeduction,
fullCouponDeduction: isExchangeBetter ? 0 : nonExchangeResult.fullCouponDeduction, // 兑换券与满减券互斥满减券抵扣置0
usedCoupon: isExchangeBetter ? undefined : nonExchangeResult.usedCoupon,
excludedProductIds: isExchangeBetter ? exchangeResult.excludedProductIds : nonExchangeResult.excludedProductIds
};
@@ -1174,22 +1287,24 @@ export function calcTotalPackFee(
goodsList: BaseCartItem[],
dinnerType: 'dine-in' | 'take-out'
): number {
return truncateToTwoDecimals(
goodsList.reduce((total, goods) => {
const availableNum = Math.max(0, goods.number - (goods.returnNum || 0));
if (availableNum === 0) return total;
let total = new BigNumber(0);
// 计算单个商品打包数量外卖全打包堂食按配置称重商品≤1
let packNum = dinnerType === 'take-out'
? availableNum
: (goods.packNumber || 0);
if (goods.product_type === GoodsType.WEIGHT) {
packNum = Math.min(packNum, 1);
}
for (const goods of goodsList) {
const availableNum = Math.max(0, goods.number - (goods.returnNum || 0));
if (availableNum === 0) continue;
return total + (goods.packFee || 0) * packNum;
}, 0)
);
// 计算单个商品打包数量外卖全打包堂食按配置称重商品≤1
let packNum = dinnerType === 'take-out'
? availableNum
: (goods.packNumber || 0);
if (goods.product_type === GoodsType.WEIGHT) {
packNum = Math.min(packNum, 1);
}
total = total.plus(new BigNumber(goods.packFee || 0).multipliedBy(packNum));
}
return truncateToTwoDecimals(total.toNumber());
}
/**
@@ -1198,9 +1313,11 @@ export function calcTotalPackFee(
* @returns 餐位费未启用则0
*/
export function calcSeatFee(config: SeatFeeConfig): number {
if (!config.isEnabled) return 0;
if (!config.isEnabled || config.personCount == 0) return 0;
const personCount = Math.max(1, config.personCount); // 至少1人
return truncateToTwoDecimals(config.pricePerPerson * personCount);
return truncateToTwoDecimals(
new BigNumber(config.pricePerPerson).multipliedBy(personCount).toNumber()
);
}
/**
@@ -1222,23 +1339,30 @@ export function calcPointDeduction(
return { deductionAmount: 0, usedPoints: 0 };
}
const userPointsBn = new BigNumber(userPoints);
const pointsPerYuanBn = new BigNumber(rule.pointsPerYuan);
const maxLimitBn = new BigNumber(maxDeductionLimit);
// 最大可抵扣金额(积分可抵金额 vs 规则最大 vs 订单上限)
const maxDeductByPoints = truncateToTwoDecimals(userPoints / rule.pointsPerYuan);
const maxDeductAmount = Math.min(
maxDeductByPoints,
rule.maxDeductionAmount ?? Infinity,
maxDeductionLimit
);
const maxDeductByPoints = userPointsBn.dividedBy(pointsPerYuanBn);
const maxDeductAmount = maxDeductByPoints
.isLessThan(rule.maxDeductionAmount ?? Infinity)
? maxDeductByPoints
: new BigNumber(rule.maxDeductionAmount || Infinity)
.isLessThan(maxLimitBn)
? maxDeductByPoints
: maxLimitBn;
// 实际使用积分 = 抵扣金额 * 积分兑换比例
const usedPoints = truncateToTwoDecimals(maxDeductAmount * rule.pointsPerYuan);
const usedPoints = maxDeductAmount.multipliedBy(pointsPerYuanBn);
return {
deductionAmount: maxDeductAmount,
usedPoints: Math.min(usedPoints, userPoints) // 避免积分超扣
deductionAmount: truncateToTwoDecimals(maxDeductAmount.toNumber()),
usedPoints: truncateToTwoDecimals(Math.min(usedPoints.toNumber(), userPoints)) // 避免积分超扣
};
}
// ============================ 6. 订单总费用汇总与实付金额计算(核心入口,逻辑不变 ============================
// ============================ 6. 订单总费用汇总与实付金额计算(核心入口,补充细分字段 ============================
/**
* 计算订单所有费用子项并汇总(核心入口函数)
* @param goodsList 购物车商品列表
@@ -1248,7 +1372,7 @@ export function calcPointDeduction(
* @param config 订单额外配置(会员、积分、餐位费等)
* @param cartOrder 商品加入购物车顺序key=购物车IDvalue=时间戳)
* @param currentTime 当前时间(用于优惠券有效期判断)
* @returns 订单费用汇总(含所有子项和实付金额)
* @returns 订单费用汇总(含所有子项和实付金额,新增商品券/满减券细分
*/
export function calculateOrderCostSummary(
goodsList: BaseCartItem[],
@@ -1259,7 +1383,7 @@ export function calculateOrderCostSummary(
cartOrder: Record<string, number> = {},
currentTime: Date = new Date()
): OrderCostSummary {
// 1. 基础费用计算
// 1. 基础费用计算(原有逻辑不变)
const goodsOriginalAmount = calcGoodsOriginalAmount(goodsList);
const goodsRealAmount = calcGoodsRealAmount(goodsList, {
isMember: config.isMember,
@@ -1267,8 +1391,14 @@ export function calculateOrderCostSummary(
}, activities);
const goodsDiscountAmount = calcGoodsDiscountAmount(goodsOriginalAmount, goodsRealAmount);
// 2. 优惠券抵扣(传递就餐类型用于可用性判断
const { deductionAmount: couponDeductionAmount, usedCoupon, excludedProductIds } = calcCouponDeduction(
// 2. 优惠券抵扣(原有逻辑不变
const {
deductionAmount: couponDeductionAmount,
usedCoupon,
excludedProductIds,
productCouponDeduction,
fullCouponDeduction
} = calcCouponDeduction(
backendCoupons,
goodsList,
{
@@ -1282,47 +1412,103 @@ export function calculateOrderCostSummary(
}
);
// 3. 其他费用计算
// 3. 其他费用计算(原有逻辑不变)
const packFee = calcTotalPackFee(goodsList, dinnerType);
const seatFee = calcSeatFee(config.seatFeeConfig);
// 积分抵扣上限:商品实际价 - 优惠券抵扣(避免负抵扣)
const additionalFee = Math.max(0, config.additionalFee);
// 4. 积分抵扣(原有逻辑不变,先于商家减免计算)
const maxPointDeductionLimit = Math.max(0, goodsRealAmount - couponDeductionAmount);
const { deductionAmount: pointDeductionAmount, usedPoints } = calcPointDeduction(
const {
deductionAmount: pointDeductionAmount,
usedPoints
} = calcPointDeduction(
config.userPoints,
config.pointDeductionRule,
maxPointDeductionLimit
);
const merchantReductionAmount = Math.max(0, config.merchantReduction);
const additionalFee = Math.max(0, config.additionalFee);
// 4. 计算最终实付金额(确保非负)
const finalPayAmount = truncateToTwoDecimals(
goodsOriginalAmount
- goodsDiscountAmount
- couponDeductionAmount
- pointDeductionAmount
+ seatFee
+ packFee
- merchantReductionAmount
+ additionalFee
);
const finalPayAmountNonNegative = Math.max(0, finalPayAmount);
// ============================ 新增:商家减免计算(支持两种形式) ============================
// 商家减免计算时机:在商品折扣、优惠券、积分抵扣之后(避免过度减免)
const merchantReductionConfig = config.merchantReduction;
let merchantReductionActualAmount = 0;
// 计算商家减免的可抵扣上限:商品实际金额 - 优惠券 - 积分(避免减免后为负)
const maxMerchantReductionLimit = new BigNumber(goodsRealAmount)
.minus(couponDeductionAmount)
.minus(pointDeductionAmount)
.isGreaterThan(0)
? new BigNumber(goodsRealAmount)
.minus(couponDeductionAmount)
.minus(pointDeductionAmount)
: new BigNumber(0);
switch (merchantReductionConfig.type) {
case MerchantReductionType.FIXED_AMOUNT:
// 固定金额减免取配置金额与上限的最小值且不小于0
const fixedAmount = new BigNumber(merchantReductionConfig.fixedAmount || 0);
merchantReductionActualAmount = fixedAmount
.isLessThanOrEqualTo(maxMerchantReductionLimit)
? fixedAmount.toNumber()
: maxMerchantReductionLimit.toNumber();
merchantReductionActualAmount = Math.max(0, merchantReductionActualAmount);
break;
case MerchantReductionType.DISCOUNT_RATE:
// 比例折扣减免先校验折扣率0-100%),再按比例计算减免金额
const validDiscountRate = Math.max(0, Math.min(100, merchantReductionConfig.discountRate || 0));
// 折扣率转小数(如 90% → 0.9),减免金额 = 可抵扣上限 * (1 - 折扣率)
merchantReductionActualAmount = maxMerchantReductionLimit
.multipliedBy(new BigNumber(1).minus(validDiscountRate / 100))
.toNumber();
// 确保减免金额不超过上限且非负
merchantReductionActualAmount = Math.min(
merchantReductionActualAmount,
maxMerchantReductionLimit.toNumber()
);
break;
}
// 5. 最终实付金额计算(整合所有费用)
const finalPayAmount = new BigNumber(goodsOriginalAmount) // 商品原价总和
.minus(goodsDiscountAmount) // 减去商品折扣
.minus(couponDeductionAmount) // 减去优惠券抵扣
.minus(pointDeductionAmount) // 减去积分抵扣
.minus(merchantReductionActualAmount) // 减去商家实际减免金额
.plus(seatFee) // 加上餐位费(不参与减免)
.plus(packFee) // 加上打包费(不参与减免)
.plus(additionalFee); // 加上附加费
const finalPayAmountNonNegative = Math.max(0, finalPayAmount.toNumber());
// 6. 返回完整费用汇总(包含商家减免明细)
return {
goodsRealAmount,
goodsOriginalAmount,
goodsDiscountAmount,
couponDeductionAmount,
productCouponDeduction: truncateToTwoDecimals(productCouponDeduction || 0),
fullCouponDeduction: truncateToTwoDecimals(fullCouponDeduction || 0),
pointDeductionAmount,
seatFee,
packFee,
merchantReductionAmount,
// 商家减免明细(含类型、原始配置、实际金额)
merchantReduction: {
type: merchantReductionConfig.type,
originalConfig: merchantReductionConfig,
actualAmount: truncateToTwoDecimals(merchantReductionActualAmount)
},
additionalFee,
finalPayAmount: finalPayAmountNonNegative,
finalPayAmount: truncateToTwoDecimals(finalPayAmountNonNegative),
couponUsed: usedCoupon,
pointUsed: usedPoints
};
}
export function isWeightGoods(goods: BaseCartItem): boolean {
return goods.product_type === GoodsType.WEIGHT;
}
// ============================ 7. 对外暴露工具库 ============================
export const OrderPriceCalculator = {
// 基础工具
@@ -1331,6 +1517,7 @@ export const OrderPriceCalculator = {
isGiftGoods,
formatDiscountRate,
filterThresholdGoods,
isWeightGoods,
// 优惠券转换
convertBackendCouponToToolCoupon,
// 商品价格计算

View File

@@ -161,6 +161,7 @@ const testBackendCoupons: BackendCoupon[] = [
{
id: 1,
title: "满减券",
status: 1,
couponType: 1,
fullAmount: 2,
discountAmount: 1,
@@ -172,6 +173,9 @@ const testBackendCoupons: BackendCoupon[] = [
validEndTime: '2025-09-30 16:00:00',
useDays: '周一,周二,周三,周四,周五',
useTimeType: 'all',
shopId: 101,
createTime: '2025-09-16 13:30:50',
validDays: 2
},
];
const testOrderConfig = {
@@ -192,7 +196,7 @@ const testOrderConfig = {
additionalFee: 0,
};
const testActivities: ActivityConfig[] = [];
const testCurrentTime = new Date("2024-06-01 12:00:00");
const testCurrentTime = new Date();
// 调用函数(此时类型完全匹配)
const result = OrderPriceCalculator.calculateOrderCostSummary(