1546 lines
64 KiB
TypeScript
1546 lines
64 KiB
TypeScript
import { BigNumber } from 'bignumber.js';
|
||
|
||
// 配置BigNumber精度
|
||
BigNumber.set({
|
||
DECIMAL_PLACES: 2,
|
||
ROUNDING_MODE: BigNumber.ROUND_DOWN // 向下取整,符合业务需求
|
||
});
|
||
|
||
/**
|
||
* 购物车订单价格计算公共库
|
||
* 功能:覆盖订单全链路费用计算(商品价格、优惠券、积分、餐位费等),支持策略扩展
|
||
* 小数处理:使用bignumber.js确保精度,统一舍去小数点后两位(如 10.129 → 10.12,15.998 → 15.99)
|
||
* 扩展设计:优惠券/营销活动采用策略模式,新增类型仅需扩展策略,无需修改核心逻辑
|
||
* 关键规则:
|
||
* - 所有优惠券均支持指定门槛商品(后端foods字段:空字符串=全部商品,ID字符串=指定商品ID)
|
||
* - 与限时折扣/会员价同享规则:开启则门槛计算含对应折扣,关闭则用原价/非会员价
|
||
* 字段说明:
|
||
* - BaseCartItem.id:购物车项ID(唯一标识购物车中的条目)
|
||
* - BaseCartItem.product_id:商品ID(唯一标识商品,用于优惠券/活动匹配)
|
||
* - BaseCartItem.skuData.id:SKU ID(唯一标识商品规格)
|
||
*/
|
||
|
||
// ============================ 1. 基础类型定义(核心修正:明确ID含义) ============================
|
||
/** 商品类型枚举 */
|
||
export enum GoodsType {
|
||
NORMAL = 'normal', // 普通商品
|
||
WEIGHT = 'weight', // 称重商品
|
||
GIFT = 'gift', // 赠菜(继承普通商品逻辑,标记用)
|
||
EMPTY = '', // 空字符串类型(后端未返回时默认归类为普通商品)
|
||
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; // 新增:兑换券属于商品券,同步记录
|
||
}
|
||
|
||
/** 优惠券类型枚举 */
|
||
export enum CouponType {
|
||
FULL_REDUCTION = 'full_reduction', // 满减券
|
||
DISCOUNT = 'discount', // 折扣券
|
||
SECOND_HALF = 'second_half', // 第二件半价券
|
||
BUY_ONE_GET_ONE = 'buy_one_get_one', // 买一送一券
|
||
EXCHANGE = 'exchange', // 商品兑换券
|
||
}
|
||
|
||
/** 后端返回的优惠券原始字段类型 */
|
||
export interface BackendCoupon {
|
||
id?: number; // 自增主键(int64)
|
||
shopId?: number; // 店铺ID(int64)
|
||
syncId?: number; // 同步Id(int64)
|
||
couponType?: number; // 优惠券类型:1-满减券,2-商品兑换券,3-折扣券,4-第二件半价券,5-消费送券,6-买一送一券,7-固定价格券,8-免配送费券
|
||
title?: string; // 券名称
|
||
useShopType?: string; // 可用门店类型:only-仅本店;all-所有门店,custom-指定门店
|
||
useShops?: string; // 可用门店(逗号分隔字符串,如"1,2,3")
|
||
useType?: string; // 可使用类型:dine堂食/pickup自取/deliv配送/express快递
|
||
validType?: string; // 有效期类型:fixed(固定时间),custom(自定义时间)
|
||
validDays?: number; // 有效期(天)
|
||
validStartTime?: string; // 有效期开始时间(如"2024-01-01 00:00:00")
|
||
validEndTime?: string; // 有效期结束时间
|
||
daysToTakeEffect?: number; // 隔天生效
|
||
useDays?: string; // 可用周期(如"周一,周二")
|
||
useTimeType?: string; // 可用时间段类型:all-全时段,custom-指定时段
|
||
useStartTime?: string; // 可用开始时间(每日)
|
||
useEndTime?: string; // 可用结束时间(每日)
|
||
getType?: string; // 发放设置:不可自行领取/no,可领取/yes
|
||
getMode?: string; // 用户领取方式
|
||
giveNum?: number; // 总发放数量,-10086为不限量
|
||
getUserType?: string; // 可领取用户:全部/all,新用户一次/new,仅会员/vip
|
||
getLimit?: number; // 每人领取限量,-10086为不限量
|
||
useLimit?: number; // 每人每日使用限量,-10086为不限量
|
||
discountShare?: number; // 与限时折扣同享:0-否,1-是
|
||
vipPriceShare?: number; // 与会员价同享:0-否,1-是
|
||
ruleDetails?: string; // 附加规则说明
|
||
status?: number; // 状态:0-禁用,1-启用
|
||
useNum?: number; // 已使用数量
|
||
leftNum?: number; // 剩余数量
|
||
foods?: string; // 指定门槛商品(逗号分隔字符串,如"101,102",此处为商品ID)
|
||
fullAmount?: number; // 使用门槛:满多少金额(元)
|
||
discountAmount?: number; // 使用门槛:减多少金额(元)
|
||
discountRate?: number; // 折扣%(如90=9折)
|
||
maxDiscountAmount?: number; // 可抵扣最大金额(元)
|
||
useRule?: string; // 使用规则:price_asc-价格低到高,price_desc-高到低
|
||
discountNum?: number; // 抵扣数量
|
||
otherCouponShare?: number; // 与其它优惠共享:0-否,1-是
|
||
createTime?: string; // 创建时间
|
||
updateTime?: string; // 更新时间
|
||
}
|
||
|
||
/** 营销活动类型枚举 */
|
||
export enum ActivityType {
|
||
TIME_LIMIT_DISCOUNT = 'time_limit_discount', // 限时折扣
|
||
}
|
||
|
||
/** 基础购物车商品项(核心修正:新增product_id,明确各ID含义) */
|
||
export interface BaseCartItem {
|
||
id: string | number; // 购物车ID(唯一标识购物车中的条目,如购物车项主键)
|
||
product_id: string | number; // 商品ID(唯一标识商品,用于优惠券/活动匹配,必选)
|
||
salePrice: number; // 商品原价(元)
|
||
number: number; // 商品数量
|
||
product_type: GoodsType; // 商品类型
|
||
is_temporary?: boolean; // 是否临时菜(默认false)
|
||
is_gift?: boolean; // 是否赠菜(默认false)
|
||
returnNum?: number; // 退货数量(历史订单用,默认0)
|
||
memberPrice?: number; // 商品会员价(元,优先级:商品会员价 > 会员折扣)
|
||
discountSaleAmount?: number; // 商家改价后单价(元,优先级最高)
|
||
packFee?: number; // 单份打包费(元,默认0)
|
||
packNumber?: number; // 堂食打包数量(默认0)
|
||
activityInfo?: { // 商品参与的营销活动(如限时折扣)
|
||
type: ActivityType;
|
||
discountRate: number; // 折扣率(如0.8=8折)
|
||
vipPriceShare: boolean; // 是否与会员优惠同享(默认false)
|
||
};
|
||
skuData?: { // SKU扩展数据(可选)
|
||
id: string | number; // SKU ID(唯一标识商品规格,如颜色/尺寸)
|
||
memberPrice?: number; // SKU会员价
|
||
salePrice?: number; // SKU原价
|
||
};
|
||
}
|
||
|
||
/** 基础优惠券接口(所有券类型继承,包含统一门槛商品字段) */
|
||
export interface BaseCoupon {
|
||
id: string | number; // 优惠券ID
|
||
type: CouponType; // 工具库字符串枚举(由后端couponType转换)
|
||
name: string; // 对应后端title
|
||
available: boolean; // 基于BackendCoupon字段计算的可用性
|
||
useShopType?: string; // only-仅本店;all-所有门店,custom-指定门店
|
||
useShops: string[]; // 可用门店ID列表
|
||
discountShare: boolean; // 与限时折扣同享:0-否,1-是(后端字段转换为布尔值)
|
||
vipPriceShare: boolean; // 与会员价同享:0-否,1-是(后端字段转换为布尔值)
|
||
useType?: string[]; // 可使用类型:dine堂食/pickup自取/deliv配送/express快递
|
||
isValid: boolean; // 是否在有效期内
|
||
discountAmount?: number; // 减免金额 (满减券有)
|
||
fullAmount?: number; // 使用门槛:满多少金额
|
||
maxDiscountAmount?: number; // 可抵扣最大金额 元
|
||
applicableProductIds: string[]; // 门槛商品ID列表(空数组=全部商品,非空=指定商品ID)
|
||
}
|
||
|
||
/** 满减券(适配后端字段) */
|
||
export interface FullReductionCoupon extends BaseCoupon {
|
||
type: CouponType.FULL_REDUCTION;
|
||
fullAmount: number; // 对应后端fullAmount(满减门槛)
|
||
discountAmount: number; // 对应后端discountAmount(减免金额)
|
||
maxDiscountAmount?: number; // 对应后端maxDiscountAmount(最大减免)
|
||
}
|
||
|
||
/** 折扣券(适配后端字段) */
|
||
export interface DiscountCoupon extends BaseCoupon {
|
||
type: CouponType.DISCOUNT;
|
||
discountRate: number; // 后端discountRate(%)转小数(如90→0.9)
|
||
maxDiscountAmount: number; // 对应后端maxDiscountAmount(最大减免)
|
||
}
|
||
|
||
/** 第二件半价券(适配后端字段) */
|
||
export interface SecondHalfPriceCoupon extends BaseCoupon {
|
||
type: CouponType.SECOND_HALF;
|
||
maxUseCountPerOrder?: number; // 对应后端useLimit(-10086=不限)
|
||
}
|
||
|
||
/** 买一送一券(适配后端字段) */
|
||
export interface BuyOneGetOneCoupon extends BaseCoupon {
|
||
type: CouponType.BUY_ONE_GET_ONE;
|
||
maxUseCountPerOrder?: number; // 对应后端useLimit(-10086=不限)
|
||
}
|
||
|
||
/** 商品兑换券(适配后端字段) */
|
||
export interface ExchangeCoupon extends BaseCoupon {
|
||
type: CouponType.EXCHANGE;
|
||
deductCount: number; // 对应后端discountNum(抵扣数量)
|
||
sortRule: 'low_price_first' | 'high_price_first'; // 后端useRule转换
|
||
}
|
||
|
||
/** 所有优惠券类型联合 */
|
||
export type Coupon = FullReductionCoupon | DiscountCoupon | SecondHalfPriceCoupon | BuyOneGetOneCoupon | ExchangeCoupon;
|
||
|
||
/** 营销活动配置(如限时折扣,applicableProductIds为商品ID列表) */
|
||
export interface ActivityConfig {
|
||
type: ActivityType;
|
||
applicableProductIds?: string[]; // 适用商品ID列表(与BaseCartItem.product_id匹配)
|
||
discountRate: number; // 折扣率(如0.8=8折)
|
||
vipPriceShare: boolean; // 是否与会员优惠同享
|
||
}
|
||
|
||
/** 积分抵扣规则 */
|
||
export interface PointDeductionRule {
|
||
pointsPerYuan: number; // X积分=1元(如100=100积分抵1元)
|
||
maxDeductionAmount?: number; // 最大抵扣金额(元,默认不限)
|
||
}
|
||
|
||
/** 餐位费配置 */
|
||
export interface SeatFeeConfig {
|
||
pricePerPerson: number; // 每人餐位费(元)
|
||
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: MerchantReductionConfig;
|
||
additionalFee: number; // 附加费(元,如余额充值、券包,默认0)
|
||
pointDeductionRule: PointDeductionRule; // 积分抵扣规则
|
||
seatFeeConfig: SeatFeeConfig; // 餐位费配置
|
||
currentStoreId: string; // 当前门店ID(用于验证优惠券适用门店)
|
||
userPoints: number; // 用户当前积分(用于积分抵扣)
|
||
isMember: boolean; // 用户是否会员(用于会员优惠)
|
||
memberDiscountRate?: number; // 会员折扣率(如0.95=95折,无会员价时用)
|
||
}
|
||
|
||
/** 订单费用汇总(修改:补充商家减免类型和明细) */
|
||
export interface OrderCostSummary {
|
||
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) ============================
|
||
/**
|
||
* 后端优惠券转工具库Coupon的转换函数
|
||
* @param backendCoupon 后端返回的优惠券
|
||
* @param currentStoreId 当前门店ID(用于验证门店适用性)
|
||
* @param dinnerType 就餐类型(用于验证使用场景)
|
||
* @param currentTime 当前时间(默认取当前时间,用于有效期判断)
|
||
* @returns 工具库 Coupon | null(不支持的券类型/无效券返回null)
|
||
*/
|
||
export function convertBackendCouponToToolCoupon(
|
||
backendCoupon: BackendCoupon,
|
||
currentStoreId: string,
|
||
dinnerType: 'dine-in' | 'take-out',
|
||
currentTime: Date = new Date()
|
||
): Coupon | null {
|
||
// 1. 基础校验:必选字段缺失直接返回null
|
||
if (!backendCoupon.id || backendCoupon.couponType === undefined || !backendCoupon.title) {
|
||
console.warn('优惠券必选字段缺失', backendCoupon);
|
||
return null;
|
||
}
|
||
|
||
// 2. 转换券类型:后端数字枚举 → 工具库字符串枚举
|
||
const couponType = mapBackendCouponTypeToTool(backendCoupon.couponType);
|
||
if (!couponType) {
|
||
console.warn(`不支持的优惠券类型:${backendCoupon.couponType}(券ID:${backendCoupon.id})`);
|
||
return null;
|
||
}
|
||
|
||
// 3. 统一处理所有券类型的applicableProductIds(映射后端foods,此处为商品ID列表)
|
||
const applicableProductIds = backendCoupon.foods === '' || !backendCoupon.foods
|
||
? [] // 空字符串/undefined → 全部商品(按商品ID匹配)
|
||
: backendCoupon.foods.split(',').map(id => id.trim()); // 逗号分隔 → 指定商品ID数组
|
||
|
||
// 4. 计算基础公共字段(含多维度可用性校验)
|
||
const baseCoupon: BaseCoupon = {
|
||
id: backendCoupon.id,
|
||
type: couponType,
|
||
name: backendCoupon.title,
|
||
available: isCouponAvailable(backendCoupon, currentStoreId, dinnerType, currentTime),
|
||
useShops: getApplicableStoreIds(backendCoupon, currentStoreId),
|
||
discountShare: backendCoupon.discountShare === 1,
|
||
vipPriceShare: backendCoupon.vipPriceShare === 1,
|
||
useType: backendCoupon.useType ? backendCoupon.useType.split(',') : [],
|
||
isValid: isCouponInValidPeriod(backendCoupon, currentTime),
|
||
applicableProductIds: applicableProductIds,
|
||
};
|
||
// 5. 按券类型补充专属字段
|
||
switch (couponType) {
|
||
case CouponType.FULL_REDUCTION:
|
||
return {
|
||
...baseCoupon,
|
||
fullAmount: backendCoupon.fullAmount || 0,
|
||
discountAmount: backendCoupon.discountAmount || 0,
|
||
maxDiscountAmount: backendCoupon.maxDiscountAmount ?? Infinity
|
||
} as FullReductionCoupon;
|
||
|
||
case CouponType.DISCOUNT:
|
||
return {
|
||
...baseCoupon,
|
||
discountRate: formatDiscountRate(backendCoupon.discountRate),
|
||
maxDiscountAmount: backendCoupon.maxDiscountAmount ?? Infinity
|
||
} as DiscountCoupon;
|
||
|
||
case CouponType.SECOND_HALF:
|
||
return {
|
||
...baseCoupon,
|
||
maxUseCountPerOrder: backendCoupon.useLimit === -10086 ? Infinity : backendCoupon.useLimit || 1
|
||
} as SecondHalfPriceCoupon;
|
||
|
||
case CouponType.BUY_ONE_GET_ONE:
|
||
return {
|
||
...baseCoupon,
|
||
maxUseCountPerOrder: backendCoupon.useLimit === -10086 ? Infinity : backendCoupon.useLimit || 1
|
||
} as BuyOneGetOneCoupon;
|
||
|
||
case CouponType.EXCHANGE:
|
||
return {
|
||
...baseCoupon,
|
||
deductCount: backendCoupon.discountNum || 1,
|
||
sortRule: backendCoupon.useRule === 'price_asc' ? 'low_price_first' : 'high_price_first'
|
||
} as ExchangeCoupon;
|
||
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ------------------------------ 转换辅助函数 ------------------------------
|
||
/**
|
||
* 后端优惠券类型(数字)→ 工具库优惠券类型(字符串枚举)
|
||
*/
|
||
function mapBackendCouponTypeToTool(backendType: number): CouponType | undefined {
|
||
const typeMap: Record<number, CouponType> = {
|
||
1: CouponType.FULL_REDUCTION, // 1-满减券
|
||
2: CouponType.EXCHANGE, // 2-商品兑换券
|
||
3: CouponType.DISCOUNT, // 3-折扣券
|
||
4: CouponType.SECOND_HALF, // 4-第二件半价券
|
||
6: CouponType.BUY_ONE_GET_ONE // 6-买一送一券
|
||
};
|
||
return typeMap[backendType];
|
||
}
|
||
|
||
/**
|
||
* 多维度判断优惠券是否可用:状态+库存+有效期+隔天生效+每日时段+每周周期+门店+就餐类型
|
||
*/
|
||
function isCouponAvailable(
|
||
backendCoupon: BackendCoupon,
|
||
currentStoreId: string,
|
||
dinnerType: 'dine-in' | 'take-out',
|
||
currentTime: Date = new Date()
|
||
): boolean {
|
||
// 1. 状态校验:必须启用(status=1)
|
||
if (backendCoupon.status !== 1) return false;
|
||
|
||
// 3. 有效期校验:必须在有效期内
|
||
if (!isCouponInValidPeriod(backendCoupon, currentTime)) return false;
|
||
|
||
// 4. 隔天生效校验:若设置了隔天生效,需超过生效时间
|
||
if (!isCouponEffectiveAfterDays(backendCoupon, currentTime)) return false;
|
||
|
||
// 5. 每日时段校验:当前时间需在每日可用时段内(useTimeType=custom时生效)
|
||
if (!isCouponInDailyTimeRange(backendCoupon, currentTime)) return false;
|
||
|
||
// 6. 每周周期校验:当前星期几需在可用周期内(useDays非空时生效)
|
||
if (!isCouponInWeekDays(backendCoupon, currentTime)) return false;
|
||
|
||
// 7. 门店匹配校验:当前门店需在适用门店范围内
|
||
if (!isStoreMatch(backendCoupon, currentStoreId)) return false;
|
||
|
||
// 8. 就餐类型校验:当前就餐类型需在可用类型范围内
|
||
if (!isDinnerTypeMatch(backendCoupon, dinnerType)) return false;
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 判断优惠券是否在有效期内(处理后端validType逻辑)
|
||
*/
|
||
function isCouponInValidPeriod(backendCoupon: BackendCoupon, currentTime: Date): boolean {
|
||
const { validType, validStartTime, validEndTime, validDays, createTime } = backendCoupon;
|
||
|
||
// 固定时间有效期(validType=fixed):直接对比validStartTime和validEndTime
|
||
if (validType === 'fixed' && validStartTime && validEndTime) {
|
||
const start = new Date(validStartTime);
|
||
const end = new Date(validEndTime);
|
||
return currentTime >= start && currentTime <= end;
|
||
}
|
||
|
||
// 自定义天数有效期(validType=custom):创建时间+validDays天
|
||
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;
|
||
}
|
||
|
||
// 无有效期配置:默认视为无效
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 隔天生效校验:若设置了daysToTakeEffect,需超过生效时间(创建时间+N天的0点)
|
||
*/
|
||
function isCouponEffectiveAfterDays(backendCoupon: BackendCoupon, currentTime: Date): boolean {
|
||
if (!backendCoupon.daysToTakeEffect || backendCoupon.daysToTakeEffect <= 0) return true;
|
||
if (!backendCoupon.createTime) return false;
|
||
|
||
const create = new Date(backendCoupon.createTime);
|
||
const effectiveTime = new Date(create);
|
||
effectiveTime.setDate(create.getDate() + backendCoupon.daysToTakeEffect);
|
||
effectiveTime.setHours(0, 0, 0, 0); // 隔天0点生效
|
||
|
||
return currentTime >= effectiveTime;
|
||
}
|
||
|
||
/**
|
||
* 每日时段校验:当前时间需在useStartTime和useEndTime之间(仅比较时分秒,支持跨天)
|
||
*/
|
||
function isCouponInDailyTimeRange(backendCoupon: BackendCoupon, currentTime: Date): boolean {
|
||
// 全时段可用或未配置时段类型 → 直接通过
|
||
if (backendCoupon.useTimeType === 'all' || !backendCoupon.useTimeType) return true;
|
||
// 非自定义时段 → 默认可用(兼容未配置场景)
|
||
if (backendCoupon.useTimeType !== 'custom') return true;
|
||
// 缺少时段配置 → 无效
|
||
if (!backendCoupon.useStartTime || !backendCoupon.useEndTime) return false;
|
||
|
||
// 解析时分(如"10:30" → [10, 30])
|
||
const [startHours, startMinutes] = backendCoupon.useStartTime.split(':').map(Number);
|
||
const [endHours, endMinutes] = backendCoupon.useEndTime.split(':').map(Number);
|
||
|
||
// 转换为当天分钟数(便于比较)
|
||
const currentMinutes = currentTime.getHours() * 60 + currentTime.getMinutes();
|
||
const startTotalMinutes = startHours * 60 + startMinutes;
|
||
const endTotalMinutes = endHours * 60 + endMinutes;
|
||
|
||
// 处理跨天场景(如22:00-02:00)
|
||
if (startTotalMinutes <= endTotalMinutes) {
|
||
return currentMinutes >= startTotalMinutes && currentMinutes <= endTotalMinutes;
|
||
} else {
|
||
return currentMinutes >= startTotalMinutes || currentMinutes <= endTotalMinutes;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 每周周期校验:当前星期几需在useDays范围内(如"周一,周二")
|
||
*/
|
||
function isCouponInWeekDays(backendCoupon: BackendCoupon, currentTime: Date): boolean {
|
||
if (!backendCoupon.useDays) return true; // 未配置周期 → 默认可用
|
||
|
||
// 星期映射:getDay()返回0=周日,1=周一...6=周六
|
||
const weekDayMap = {
|
||
0: '周七',
|
||
1: '周一',
|
||
2: '周二',
|
||
3: '周三',
|
||
4: '周四',
|
||
5: '周五',
|
||
6: '周六'
|
||
};
|
||
const currentWeekDay = weekDayMap[currentTime.getDay() as keyof typeof weekDayMap];
|
||
return backendCoupon.useDays.split(',').includes(currentWeekDay);
|
||
}
|
||
|
||
/**
|
||
* 门店匹配校验:根据useShopType判断当前门店是否适用
|
||
*/
|
||
function isStoreMatch(backendCoupon: BackendCoupon, currentStoreId: string): boolean {
|
||
const { useShopType, useShops, shopId } = backendCoupon;
|
||
|
||
switch (useShopType) {
|
||
case 'all': // 所有门店适用
|
||
return true;
|
||
case 'custom': // 指定门店适用(useShops逗号分割,门店ID)
|
||
return useShops ? useShops.split(',').includes(currentStoreId) : false;
|
||
case 'only': // 仅本店适用(shopId为门店ID)
|
||
return shopId ? String(shopId) === currentStoreId : false;
|
||
default: // 未配置 → 默认为仅本店
|
||
return shopId ? String(shopId) === currentStoreId : false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 就餐类型匹配校验:当前就餐类型需在useType范围内(如"dine,pickup")
|
||
*/
|
||
function isDinnerTypeMatch(backendCoupon: BackendCoupon, dinnerType: string): boolean {
|
||
if (!backendCoupon.useType) return true; // 未配置 → 默认可用
|
||
return backendCoupon.useType.split(',').includes(dinnerType);
|
||
}
|
||
|
||
/**
|
||
* 处理适用门店ID:根据useShopType返回对应数组(供BaseCoupon使用)
|
||
*/
|
||
function getApplicableStoreIds(backendCoupon: BackendCoupon, currentStoreId: string): string[] {
|
||
const { useShopType, useShops, shopId } = backendCoupon;
|
||
|
||
switch (useShopType) {
|
||
case 'all': // 所有门店适用:返回空数组(工具库空数组表示无限制)
|
||
return [];
|
||
case 'custom': // 指定门店适用:useShops逗号分割转数组(门店ID)
|
||
return useShops ? useShops.split(',').map(id => id.trim()) : [];
|
||
case 'only': // 仅当前店铺适用:返回shopId(转字符串,门店ID)
|
||
return shopId ? [shopId.toString()] : [];
|
||
default: // 未配置:默认仅当前门店适用
|
||
return [currentStoreId];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 折扣率格式化:后端discountRate(%)→ 工具库小数(如90→0.9)
|
||
*/
|
||
export function formatDiscountRate(backendDiscountRate?: number): number {
|
||
if (!backendDiscountRate || backendDiscountRate <= 0) return 1; // 默认无折扣(1=100%)
|
||
// 后端若为百分比(如90=9折),除以100;若已为小数(如0.9)直接返回
|
||
return backendDiscountRate >= 1 ? backendDiscountRate / 100 : backendDiscountRate;
|
||
}
|
||
|
||
/**
|
||
* 统一小数处理:舍去小数点后两位以后的数字(解决浮点数精度偏差)
|
||
* 如 10.129 → 10.12,1.01 → 1.01,1.0100000001 → 1.01
|
||
* @param num 待处理数字
|
||
* @returns 处理后保留两位小数的数字
|
||
*/
|
||
export function truncateToTwoDecimals(num: number | string): number {
|
||
return new BigNumber(num).decimalPlaces(2, BigNumber.ROUND_DOWN).toNumber();
|
||
}
|
||
|
||
/**
|
||
* 判断商品是否为临时菜(临时菜不参与优惠券门槛和折扣计算)
|
||
* @param goods 商品项
|
||
* @returns 是否临时菜
|
||
*/
|
||
export function isTemporaryGoods(goods: BaseCartItem): boolean {
|
||
return !!goods.is_temporary;
|
||
}
|
||
|
||
/**
|
||
* 判断商品是否为赠菜(赠菜不计入优惠券活动,但计打包费)
|
||
* @param goods 商品项
|
||
* @returns 是否赠菜
|
||
*/
|
||
export function isGiftGoods(goods: BaseCartItem): boolean {
|
||
return !!goods.is_gift;
|
||
}
|
||
|
||
/**
|
||
* 计算单个商品的会员价(优先级:SKU会员价 > 商品会员价 > 会员折扣率)
|
||
* @param goods 商品项
|
||
* @param isMember 是否会员
|
||
* @param memberDiscountRate 会员折扣率(如0.95=95折)
|
||
* @returns 会员价(元)
|
||
*/
|
||
export function calcMemberPrice(
|
||
goods: BaseCartItem,
|
||
isMember: boolean,
|
||
memberDiscountRate?: number
|
||
): number {
|
||
if (!isMember) return truncateToTwoDecimals(goods.salePrice);
|
||
|
||
// 优先级:SKU会员价 > 商品会员价 > 商品原价(无会员价时用会员折扣)
|
||
const basePrice = goods.skuData?.memberPrice
|
||
?? goods.memberPrice
|
||
?? goods.salePrice;
|
||
|
||
// 仅当无SKU会员价、无商品会员价时,才应用会员折扣率
|
||
if (memberDiscountRate && !goods.skuData?.memberPrice && !goods.memberPrice) {
|
||
return truncateToTwoDecimals(
|
||
new BigNumber(basePrice).multipliedBy(memberDiscountRate).toNumber()
|
||
);
|
||
}
|
||
|
||
return truncateToTwoDecimals(basePrice);
|
||
}
|
||
|
||
/**
|
||
* 过滤可参与优惠券计算的商品(排除临时菜、赠菜、已用兑换券的商品)
|
||
* @param goodsList 商品列表
|
||
* @param excludedProductIds 需排除的商品ID列表(商品ID,非购物车ID)
|
||
* @returns 可参与优惠券计算的商品列表
|
||
*/
|
||
export function filterCouponEligibleGoods(
|
||
goodsList: BaseCartItem[],
|
||
excludedProductIds: string[] = []
|
||
): BaseCartItem[] {
|
||
return goodsList.filter(goods =>
|
||
!isTemporaryGoods(goods)
|
||
&& !isGiftGoods(goods)
|
||
&& !excludedProductIds.includes(String(goods.product_id)) // 核心修正:用商品ID排除
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 统一筛选门槛商品的工具函数(所有券类型复用,按商品ID匹配)
|
||
* @param baseEligibleGoods 基础合格商品(已排除临时菜/赠菜/已抵扣商品)
|
||
* @param applicableProductIds 优惠券指定的门槛商品ID数组
|
||
* @returns 最终参与优惠券计算的商品列表
|
||
*/
|
||
export function filterThresholdGoods(
|
||
baseEligibleGoods: BaseCartItem[],
|
||
applicableProductIds: string[]
|
||
): BaseCartItem[] {
|
||
// 空数组=全部基础合格商品;非空=仅商品ID匹配的商品(转字符串兼容类型)
|
||
return applicableProductIds.length === 0
|
||
? baseEligibleGoods
|
||
: baseEligibleGoods.filter(goods => applicableProductIds.includes(String(goods.product_id))); // 核心修正:用商品ID匹配
|
||
}
|
||
|
||
/**
|
||
* 商品排序(用于商品兑换券:按价格/数量/加入顺序排序,按商品ID分组去重)
|
||
* @param goodsList 商品列表
|
||
* @param sortRule 排序规则(low_price_first/high_price_first)
|
||
* @param cartOrder 商品加入购物车的顺序(key=购物车ID,value=加入时间戳)
|
||
* @returns 排序后的商品列表
|
||
*/
|
||
export function sortGoodsForCoupon(
|
||
goodsList: BaseCartItem[],
|
||
sortRule: 'low_price_first' | 'high_price_first',
|
||
cartOrder: Record<string, number> = {}
|
||
): BaseCartItem[] {
|
||
return [...goodsList].sort((a, b) => {
|
||
// 1. 按商品单价排序(优先级最高)
|
||
const priceA = a.skuData?.salePrice ?? a.salePrice;
|
||
const priceB = b.skuData?.salePrice ?? b.salePrice;
|
||
if (priceA !== priceB) {
|
||
return sortRule === 'low_price_first' ? priceA - priceB : priceB - priceA;
|
||
}
|
||
|
||
// 2. 同价格按商品数量排序(降序,多的优先)
|
||
if (a.number !== b.number) {
|
||
return b.number - a.number;
|
||
}
|
||
|
||
// 3. 同价格同数量按加入购物车顺序(早的优先,用购物车ID匹配)
|
||
const orderA = cartOrder[String(a.id)] ?? Infinity;
|
||
const orderB = cartOrder[String(b.id)] ?? Infinity;
|
||
return orderA - orderB;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 计算优惠券门槛金额(根据同享规则,按商品ID匹配限时折扣)
|
||
* @param eligibleGoods 可参与优惠券的商品列表(已过滤临时菜/赠菜)
|
||
* @param coupon 优惠券(含discountShare/vipPriceShare配置)
|
||
* @param config 订单配置(会员信息)
|
||
* @param activities 全局营销活动(限时折扣,applicableProductIds为商品ID)
|
||
* @returns 满足优惠券门槛的金额基数
|
||
*/
|
||
export function calcCouponThresholdAmount(
|
||
eligibleGoods: BaseCartItem[],
|
||
coupon: BaseCoupon,
|
||
config: Pick<OrderExtraConfig, 'isMember' | 'memberDiscountRate'>,
|
||
activities: ActivityConfig[] = []
|
||
): number {
|
||
let total = new BigNumber(0);
|
||
|
||
for (const goods of eligibleGoods) {
|
||
const availableNum = Math.max(0, goods.number - (goods.returnNum || 0));
|
||
if (availableNum <= 0) continue;
|
||
|
||
// 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); // 叠加限时折扣
|
||
}
|
||
}
|
||
|
||
total = total.plus(itemAmount);
|
||
}
|
||
|
||
return truncateToTwoDecimals(total.toNumber());
|
||
}
|
||
|
||
// ============================ 3. 商品核心价格计算(核心修正:按商品ID匹配营销活动) ============================
|
||
/**
|
||
* 计算单个商品的实际单价(整合商家改价、会员优惠、营销活动折扣,按商品ID匹配活动)
|
||
* @param goods 商品项
|
||
* @param config 订单额外配置(含会员、活动信息)
|
||
* @returns 单个商品实际单价(元)
|
||
*/
|
||
export function calcSingleGoodsRealPrice(
|
||
goods: BaseCartItem,
|
||
config: Pick<OrderExtraConfig, 'isMember' | 'memberDiscountRate'> & {
|
||
activity?: ActivityConfig; // 商品参与的营销活动(如限时折扣,按商品ID匹配)
|
||
}
|
||
): number {
|
||
const { isMember, memberDiscountRate, activity } = config;
|
||
|
||
// 1. 优先级1:商家改价(改价后单价>0才生效)
|
||
if (goods.discountSaleAmount && goods.discountSaleAmount > 0) {
|
||
return truncateToTwoDecimals(goods.discountSaleAmount);
|
||
}
|
||
|
||
// 2. 优先级2:会员价(含会员折扣率,SKU会员价优先)
|
||
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.toNumber();
|
||
}
|
||
|
||
// 处理活动与会员的同享/不同享逻辑
|
||
if (activity.vipPriceShare) {
|
||
// 同享:会员价基础上叠加工活动折扣
|
||
return truncateToTwoDecimals(
|
||
memberPrice.multipliedBy(activity.discountRate).toNumber()
|
||
);
|
||
} else {
|
||
// 不同享:取会员价和活动价的最小值(活动价用SKU原价计算)
|
||
const basePriceForActivity = new BigNumber(goods.skuData?.salePrice ?? goods.salePrice);
|
||
const activityPrice = basePriceForActivity.multipliedBy(activity.discountRate);
|
||
|
||
return truncateToTwoDecimals(
|
||
memberPrice.isLessThanOrEqualTo(activityPrice) ? memberPrice.toNumber() : activityPrice.toNumber()
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 计算商品原价总和(所有商品:原价*数量,含临时菜、赠菜,用SKU原价优先)
|
||
* @param goodsList 商品列表
|
||
* @returns 商品原价总和(元)
|
||
*/
|
||
export function calcGoodsOriginalAmount(goodsList: BaseCartItem[]): number {
|
||
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());
|
||
}
|
||
|
||
/**
|
||
* 计算商品实际总价(所有商品:实际单价*数量,含临时菜、赠菜,按商品ID匹配活动)
|
||
* @param goodsList 商品列表
|
||
* @param config 订单额外配置(含会员、活动信息)
|
||
* @param activities 全局营销活动列表(如限时折扣,applicableProductIds为商品ID)
|
||
* @returns 商品实际总价(元)
|
||
*/
|
||
export function calcGoodsRealAmount(
|
||
goodsList: BaseCartItem[],
|
||
config: Pick<OrderExtraConfig, 'isMember' | 'memberDiscountRate'>,
|
||
activities: ActivityConfig[] = []
|
||
): number {
|
||
let total = new BigNumber(0);
|
||
|
||
for (const goods of goodsList) {
|
||
const availableNum = Math.max(0, goods.number - (goods.returnNum || 0));
|
||
if (availableNum <= 0) continue;
|
||
|
||
// 匹配商品参与的营销活动(按商品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());
|
||
}
|
||
|
||
/**
|
||
* 计算商品折扣总金额(商品原价总和 - 商品实际总价)
|
||
* @param goodsOriginalAmount 商品原价总和
|
||
* @param goodsRealAmount 商品实际总价
|
||
* @returns 商品折扣总金额(元,≥0)
|
||
*/
|
||
export function calcGoodsDiscountAmount(
|
||
goodsOriginalAmount: number,
|
||
goodsRealAmount: number
|
||
): number {
|
||
const original = new BigNumber(goodsOriginalAmount);
|
||
const real = new BigNumber(goodsRealAmount);
|
||
const discount = original.minus(real);
|
||
|
||
return truncateToTwoDecimals(Math.max(0, discount.toNumber()));
|
||
}
|
||
|
||
// ============================ 4. 优惠券抵扣计算(策略模式,核心修正:按商品ID匹配) ============================
|
||
/** 优惠券计算策略接口(新增优惠券类型时,实现此接口即可) */
|
||
interface CouponCalculationStrategy {
|
||
calculate(
|
||
coupon: Coupon,
|
||
goodsList: BaseCartItem[],
|
||
config: Pick<OrderExtraConfig, 'currentStoreId' | 'isMember' | 'memberDiscountRate'> & {
|
||
activities: ActivityConfig[];
|
||
cartOrder: Record<string, number>;
|
||
excludedProductIds?: string[]; // 需排除的商品ID列表(商品ID)
|
||
}
|
||
): {
|
||
deductionAmount: number;
|
||
excludedProductIds: string[]; // 排除的商品ID列表(商品ID)
|
||
productCouponDeduction?: number; // 新增:当前券属于商品券时的抵扣金额
|
||
fullCouponDeduction?: number; // 新增:当前券属于满减券时的抵扣金额
|
||
};
|
||
}
|
||
|
||
/** 满减券计算策略(按商品ID筛选门槛商品) */
|
||
class FullReductionStrategy implements CouponCalculationStrategy {
|
||
calculate(
|
||
coupon: FullReductionCoupon,
|
||
goodsList: BaseCartItem[],
|
||
config: any
|
||
): { deductionAmount: number; excludedProductIds: string[]; fullCouponDeduction: number } {
|
||
|
||
// 1. 基础校验:优惠券不可用/门店不匹配 → 抵扣0
|
||
if (!coupon.available || !isStoreMatchByList(coupon.useShops, config.currentStoreId)) {
|
||
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: [], fullCouponDeduction: 0 };
|
||
}
|
||
|
||
// 4. 按同享规则计算门槛金额(按商品ID匹配活动)
|
||
const thresholdAmount = new BigNumber(calcCouponThresholdAmount(
|
||
thresholdGoods,
|
||
coupon,
|
||
{ isMember: config.isMember, memberDiscountRate: config.memberDiscountRate },
|
||
config.activities
|
||
));
|
||
|
||
// 5. 满门槛则抵扣,否则0(不超过最大减免)
|
||
if (thresholdAmount.isLessThan(coupon.fullAmount)) {
|
||
return { deductionAmount: 0, excludedProductIds: [], fullCouponDeduction: 0 };
|
||
}
|
||
|
||
const maxReduction = coupon.maxDiscountAmount ?? coupon.discountAmount;
|
||
const deductionAmount = truncateToTwoDecimals(
|
||
new BigNumber(coupon.discountAmount).isLessThan(maxReduction)
|
||
? coupon.discountAmount
|
||
: maxReduction
|
||
);
|
||
|
||
// 满减券计入满减优惠券抵扣
|
||
return {
|
||
deductionAmount,
|
||
excludedProductIds: [],
|
||
fullCouponDeduction: deductionAmount
|
||
};
|
||
}
|
||
}
|
||
|
||
/** 折扣券计算策略(按商品ID筛选门槛商品) */
|
||
class DiscountStrategy implements CouponCalculationStrategy {
|
||
calculate(
|
||
coupon: DiscountCoupon,
|
||
goodsList: BaseCartItem[],
|
||
config: any
|
||
): { deductionAmount: number; excludedProductIds: string[]; productCouponDeduction: number } {
|
||
// 1. 基础校验:优惠券不可用/门店不匹配 → 抵扣0
|
||
if (!coupon.available || !isStoreMatchByList(coupon.useShops, config.currentStoreId)) {
|
||
return { deductionAmount: 0, excludedProductIds: [], productCouponDeduction: 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: [], productCouponDeduction: 0 };
|
||
}
|
||
|
||
// 4. 按同享规则计算折扣基数(按商品ID匹配活动)
|
||
const discountBaseAmount = new BigNumber(calcCouponThresholdAmount(
|
||
thresholdGoods,
|
||
coupon,
|
||
{ isMember: config.isMember, memberDiscountRate: config.memberDiscountRate },
|
||
config.activities
|
||
));
|
||
|
||
// 5. 计算折扣金额(不超过最大减免)
|
||
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
|
||
};
|
||
}
|
||
}
|
||
|
||
/** 第二件半价券计算策略(按商品ID分组,筛选门槛商品) */
|
||
class SecondHalfPriceStrategy implements CouponCalculationStrategy {
|
||
calculate(
|
||
coupon: SecondHalfPriceCoupon,
|
||
goodsList: BaseCartItem[],
|
||
config: any
|
||
): { deductionAmount: number; excludedProductIds: string[]; productCouponDeduction: number } {
|
||
// 1. 基础校验:优惠券不可用/门店不匹配 → 抵扣0
|
||
if (!coupon.available || !isStoreMatchByList(coupon.useShops, config.currentStoreId)) {
|
||
return { deductionAmount: 0, excludedProductIds: [], productCouponDeduction: 0 };
|
||
}
|
||
|
||
let totalDeduction = new BigNumber(0);
|
||
const excludedProductIds: string[] = []; // 存储排除的商品ID(商品ID)
|
||
|
||
// 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: [], productCouponDeduction: 0 };
|
||
}
|
||
|
||
// 按商品ID分组(避免同商品多次处理,用商品ID作为分组key)
|
||
const goodsGroupByProductId = thresholdGoods.reduce((group, goods) => {
|
||
const productIdStr = String(goods.product_id); // 商品ID转字符串作为key
|
||
if (!group[productIdStr]) group[productIdStr] = [];
|
||
group[productIdStr].push(goods);
|
||
return group;
|
||
}, {} as Record<string, BaseCartItem[]>);
|
||
|
||
// 遍历每组商品计算半价优惠
|
||
for (const [productIdStr, productGoods] of Object.entries(goodsGroupByProductId)) {
|
||
if (excludedProductIds.includes(productIdStr)) continue; // 按商品ID排除已处理商品
|
||
|
||
// 合并同商品数量(所有购物车项的数量总和)
|
||
const totalNum = productGoods.reduce((sum, g) => sum + (g.number - (g.returnNum || 0)), 0);
|
||
if (totalNum < 2) continue; // 至少2件才享受优惠
|
||
|
||
// 计算优惠次数(每2件1次,不超过最大使用次数)
|
||
const discountCount = Math.min(
|
||
Math.floor(totalNum / 2),
|
||
coupon.maxUseCountPerOrder || Infinity
|
||
);
|
||
if (discountCount <= 0) continue;
|
||
|
||
// 计算单件实际价格(取组内任意一个商品的配置,同商品规格一致)
|
||
const sampleGood = productGoods[0];
|
||
const activity = config.activities.find((act: ActivityConfig) =>
|
||
(act.applicableProductIds || []).includes(productIdStr) // 按商品ID匹配活动
|
||
);
|
||
const realPrice = new BigNumber(calcSingleGoodsRealPrice(sampleGood, {
|
||
isMember: config.isMember,
|
||
memberDiscountRate: config.memberDiscountRate,
|
||
activity
|
||
}));
|
||
|
||
// 累计抵扣金额并标记已优惠商品(记录商品ID)
|
||
totalDeduction = totalDeduction.plus(realPrice.multipliedBy(0.5).multipliedBy(discountCount));
|
||
excludedProductIds.push(productIdStr);
|
||
}
|
||
|
||
const deductionAmount = truncateToTwoDecimals(totalDeduction.toNumber());
|
||
// 第二件半价券计入商品优惠券抵扣
|
||
return {
|
||
deductionAmount,
|
||
excludedProductIds,
|
||
productCouponDeduction: deductionAmount
|
||
};
|
||
}
|
||
}
|
||
|
||
/** 买一送一券计算策略(按商品ID分组,筛选门槛商品) */
|
||
class BuyOneGetOneStrategy implements CouponCalculationStrategy {
|
||
calculate(
|
||
coupon: BuyOneGetOneCoupon,
|
||
goodsList: BaseCartItem[],
|
||
config: any
|
||
): { deductionAmount: number; excludedProductIds: string[]; productCouponDeduction: number } {
|
||
// 1. 基础校验:优惠券不可用/门店不匹配 → 抵扣0
|
||
if (!coupon.available || !isStoreMatchByList(coupon.useShops, config.currentStoreId)) {
|
||
return { deductionAmount: 0, excludedProductIds: [], productCouponDeduction: 0 };
|
||
}
|
||
|
||
let totalDeduction = new BigNumber(0);
|
||
const excludedProductIds: string[] = []; // 存储排除的商品ID(商品ID)
|
||
|
||
// 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: [], productCouponDeduction: 0 };
|
||
}
|
||
|
||
// 按商品ID分组(用商品ID作为分组key)
|
||
const goodsGroupByProductId = thresholdGoods.reduce((group, goods) => {
|
||
const productIdStr = String(goods.product_id);
|
||
if (!group[productIdStr]) group[productIdStr] = [];
|
||
group[productIdStr].push(goods);
|
||
return group;
|
||
}, {} as Record<string, BaseCartItem[]>);
|
||
|
||
// 遍历每组商品计算买一送一优惠
|
||
for (const [productIdStr, productGoods] of Object.entries(goodsGroupByProductId)) {
|
||
if (excludedProductIds.includes(productIdStr)) continue; // 按商品ID排除已处理商品
|
||
|
||
// 合并同商品数量
|
||
const totalNum = productGoods.reduce((sum, g) => sum + (g.number - (g.returnNum || 0)), 0);
|
||
if (totalNum < 2) continue; // 至少2件才享受优惠
|
||
|
||
// 计算优惠次数(每2件送1件)
|
||
const discountCount = Math.floor(totalNum / 2);
|
||
if (discountCount <= 0) continue;
|
||
|
||
// 计算单件实际价格(按商品ID匹配活动)
|
||
const sampleGood = productGoods[0];
|
||
const activity = config.activities.find((act: ActivityConfig) =>
|
||
(act.applicableProductIds || []).includes(productIdStr) // 按商品ID匹配活动
|
||
);
|
||
const realPrice = new BigNumber(calcSingleGoodsRealPrice(sampleGood, {
|
||
isMember: config.isMember,
|
||
memberDiscountRate: config.memberDiscountRate,
|
||
activity
|
||
}));
|
||
|
||
// 累计抵扣金额(送1件=减免1件价格)并标记商品ID
|
||
totalDeduction = totalDeduction.plus(realPrice.multipliedBy(1).multipliedBy(discountCount));
|
||
excludedProductIds.push(productIdStr);
|
||
}
|
||
|
||
const deductionAmount = truncateToTwoDecimals(totalDeduction.toNumber());
|
||
// 买一送一券计入商品优惠券抵扣
|
||
return {
|
||
deductionAmount,
|
||
excludedProductIds,
|
||
productCouponDeduction: deductionAmount
|
||
};
|
||
}
|
||
}
|
||
|
||
/** 商品兑换券计算策略(按商品ID筛选门槛商品) */
|
||
class ExchangeCouponStrategy implements CouponCalculationStrategy {
|
||
calculate(
|
||
coupon: ExchangeCoupon,
|
||
goodsList: BaseCartItem[],
|
||
config: any
|
||
): { deductionAmount: number; excludedProductIds: string[]; productCouponDeduction: number } {
|
||
// 1. 基础校验:优惠券不可用/门店不匹配 → 抵扣0
|
||
if (!coupon.available || !isStoreMatchByList(coupon.useShops, config.currentStoreId)) {
|
||
return { deductionAmount: 0, excludedProductIds: [], productCouponDeduction: 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: [], productCouponDeduction: 0 };
|
||
}
|
||
|
||
// 按规则排序商品(按价格/数量/加入顺序)
|
||
const sortedGoods = sortGoodsForCoupon(thresholdGoods, coupon.sortRule, config.cartOrder);
|
||
let remainingCount = coupon.deductCount;
|
||
let totalDeduction = new BigNumber(0);
|
||
const excludedProductIds: string[] = []; // 存储排除的商品ID(商品ID)
|
||
|
||
// 计算兑换抵扣金额(按商品ID累计,避免重复抵扣)
|
||
const usedProductIds = new Set<string>(); // 记录已使用的商品ID(避免同商品多次抵扣)
|
||
for (const goods of sortedGoods) {
|
||
if (remainingCount <= 0) break;
|
||
const productIdStr = String(goods.product_id);
|
||
if (usedProductIds.has(productIdStr)) continue; // 同商品仅抵扣一次
|
||
|
||
const availableNum = Math.max(0, goods.number - (goods.returnNum || 0));
|
||
if (availableNum === 0) continue;
|
||
|
||
// 计算当前商品可抵扣的件数(不超过剩余可抵扣数量)
|
||
const deductCount = Math.min(availableNum, remainingCount);
|
||
const activity = config.activities.find((act: ActivityConfig) =>
|
||
(act.applicableProductIds || []).includes(productIdStr) // 按商品ID匹配活动
|
||
);
|
||
const realPrice = new BigNumber(calcSingleGoodsRealPrice(goods, {
|
||
isMember: config.isMember,
|
||
memberDiscountRate: config.memberDiscountRate,
|
||
activity
|
||
}));
|
||
|
||
// 累计抵扣金额并标记商品
|
||
totalDeduction = totalDeduction.plus(realPrice.multipliedBy(deductCount));
|
||
excludedProductIds.push(productIdStr);
|
||
usedProductIds.add(productIdStr);
|
||
remainingCount -= deductCount;
|
||
}
|
||
|
||
const deductionAmount = truncateToTwoDecimals(totalDeduction.toNumber());
|
||
// 商品兑换券计入商品优惠券抵扣
|
||
return {
|
||
deductionAmount,
|
||
excludedProductIds,
|
||
productCouponDeduction: deductionAmount
|
||
};
|
||
}
|
||
}
|
||
|
||
// ------------------------------ 策略辅助函数 ------------------------------
|
||
/**
|
||
* 根据优惠券useShops列表判断门店是否匹配(适配BaseCoupon的useShops字段)
|
||
*/
|
||
function isStoreMatchByList(useShops: string[], currentStoreId: string): boolean {
|
||
// 适用门店为空数组 → 无限制(所有门店适用)
|
||
if (useShops.length === 0) return true;
|
||
// 匹配当前门店ID(字符串比较,避免类型问题)
|
||
return useShops.includes(currentStoreId);
|
||
}
|
||
|
||
/**
|
||
* 优惠券计算策略工厂(根据优惠券类型获取对应策略,易扩展)
|
||
*/
|
||
function getCouponStrategy(couponType: CouponType): CouponCalculationStrategy {
|
||
switch (couponType) {
|
||
case CouponType.FULL_REDUCTION:
|
||
return new FullReductionStrategy();
|
||
case CouponType.DISCOUNT:
|
||
return new DiscountStrategy();
|
||
case CouponType.SECOND_HALF:
|
||
return new SecondHalfPriceStrategy();
|
||
case CouponType.BUY_ONE_GET_ONE:
|
||
return new BuyOneGetOneStrategy();
|
||
case CouponType.EXCHANGE:
|
||
return new ExchangeCouponStrategy();
|
||
default:
|
||
throw new Error(`不支持的优惠券类型:${couponType}`);
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* 计算优惠券抵扣金额(处理互斥逻辑,选择最优优惠券,按商品ID排除,新增细分统计)
|
||
* @param backendCoupons 后端优惠券列表
|
||
* @param goodsList 商品列表
|
||
* @param config 订单配置(含就餐类型)
|
||
* @returns 最优优惠券的抵扣结果(含商品券/满减券细分)
|
||
*/
|
||
export function calcCouponDeduction(
|
||
backendCoupons: BackendCoupon[],
|
||
goodsList: BaseCartItem[],
|
||
config: Pick<OrderExtraConfig, 'currentStoreId' | 'isMember' | 'memberDiscountRate'> & {
|
||
activities: ActivityConfig[];
|
||
cartOrder: Record<string, number>;
|
||
dinnerType: 'dine-in' | 'take-out';
|
||
currentTime?: Date;
|
||
}
|
||
): {
|
||
deductionAmount: number;
|
||
productCouponDeduction: number; // 新增:商品优惠券抵扣(兑换券/折扣券/买一送一等)
|
||
fullCouponDeduction: number; // 新增:满减优惠券抵扣
|
||
usedCoupon?: Coupon;
|
||
excludedProductIds: string[]; // 排除的商品ID列表(商品ID)
|
||
} {
|
||
// 1. 后端优惠券转工具库Coupon(过滤无效/不支持的券)
|
||
const toolCoupons = backendCoupons
|
||
.map(coupon => convertBackendCouponToToolCoupon(
|
||
coupon,
|
||
config.currentStoreId,
|
||
config.dinnerType,
|
||
config.currentTime
|
||
))
|
||
.filter(Boolean) as Coupon[];
|
||
if (toolCoupons.length === 0) {
|
||
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,避免重复,统计细分字段)
|
||
let nonExchangeResult: CouponResult = {
|
||
deductionAmount: 0,
|
||
excludedProductIds: [],
|
||
usedCoupon: undefined,
|
||
productCouponDeduction: 0,
|
||
fullCouponDeduction: 0
|
||
};
|
||
if (nonExchangeCoupons.length > 0) {
|
||
nonExchangeResult = nonExchangeCoupons.reduce((best, coupon) => {
|
||
const strategy = getCouponStrategy(coupon.type);
|
||
const result = strategy.calculate(coupon, goodsList, {
|
||
...config,
|
||
excludedProductIds: best.excludedProductIds // 传递已排除的商品ID(商品ID)
|
||
});
|
||
const currentResult: CouponResult = {
|
||
deductionAmount: result.deductionAmount,
|
||
excludedProductIds: result.excludedProductIds,
|
||
usedCoupon: coupon,
|
||
// 按策略返回的字段赋值细分抵扣
|
||
productCouponDeduction: result.productCouponDeduction || 0,
|
||
fullCouponDeduction: result.fullCouponDeduction || 0
|
||
};
|
||
// 按总抵扣金额选择最优
|
||
return new BigNumber(currentResult.deductionAmount).isGreaterThan(best.deductionAmount)
|
||
? currentResult
|
||
: best;
|
||
}, nonExchangeResult);
|
||
}
|
||
|
||
// 4. 计算兑换券抵扣(排除非兑换券已抵扣的商品ID,统计商品券细分)
|
||
let exchangeResult: ExchangeCalculationResult = {
|
||
deductionAmount: 0,
|
||
excludedProductIds: [],
|
||
productCouponDeduction: 0
|
||
};
|
||
if (exchangeCoupons.length > 0) {
|
||
exchangeResult = exchangeCoupons.reduce((best, coupon) => {
|
||
const strategy = getCouponStrategy(coupon.type);
|
||
const result = strategy.calculate(coupon, goodsList, {
|
||
...config,
|
||
excludedProductIds: [...nonExchangeResult.excludedProductIds, ...best.excludedProductIds] // 合并排除的商品ID
|
||
});
|
||
return new BigNumber(result.deductionAmount).isGreaterThan(best.deductionAmount)
|
||
? {
|
||
deductionAmount: result.deductionAmount,
|
||
excludedProductIds: result.excludedProductIds,
|
||
productCouponDeduction: result.productCouponDeduction || 0 // 兑换券属于商品券
|
||
}
|
||
: best;
|
||
}, exchangeResult);
|
||
}
|
||
|
||
// 5. 汇总结果:兑换券与非兑换券不可同时使用,取抵扣金额大的
|
||
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
|
||
};
|
||
}
|
||
|
||
// ============================ 5. 其他费用计算(无ID依赖,逻辑不变) ============================
|
||
/**
|
||
* 计算总打包费(赠菜也计算,称重商品打包数量≤1)
|
||
* @param goodsList 商品列表
|
||
* @param dinnerType 就餐类型(堂食dine-in/外卖take-out)
|
||
* @returns 总打包费(元)
|
||
*/
|
||
export function calcTotalPackFee(
|
||
goodsList: BaseCartItem[],
|
||
dinnerType: 'dine-in' | 'take-out'
|
||
): number {
|
||
let total = new BigNumber(0);
|
||
|
||
for (const goods of goodsList) {
|
||
const availableNum = Math.max(0, goods.number - (goods.returnNum || 0));
|
||
if (availableNum === 0) continue;
|
||
|
||
// 计算单个商品打包数量(外卖全打包,堂食按配置,称重商品≤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());
|
||
}
|
||
|
||
/**
|
||
* 计算餐位费(按人数,不参与营销活动)
|
||
* @param config 餐位费配置
|
||
* @returns 餐位费(元,未启用则0)
|
||
*/
|
||
export function calcSeatFee(config: SeatFeeConfig): number {
|
||
if (!config.isEnabled || config.personCount == 0) return 0;
|
||
const personCount = Math.max(1, config.personCount); // 至少1人
|
||
return truncateToTwoDecimals(
|
||
new BigNumber(config.pricePerPerson).multipliedBy(personCount).toNumber()
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 计算积分抵扣金额(按X积分=1元,不超过最大抵扣和用户积分)
|
||
* @param userPoints 用户当前积分
|
||
* @param rule 积分抵扣规则
|
||
* @param maxDeductionLimit 最大抵扣上限(通常为订单金额,避免超扣)
|
||
* @returns 积分抵扣金额 + 实际使用积分
|
||
*/
|
||
export function calcPointDeduction(
|
||
userPoints: number,
|
||
rule: PointDeductionRule,
|
||
maxDeductionLimit: number
|
||
): {
|
||
deductionAmount: number;
|
||
usedPoints: number;
|
||
} {
|
||
if (rule.pointsPerYuan <= 0 || userPoints <= 0) {
|
||
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 = userPointsBn.dividedBy(pointsPerYuanBn);
|
||
const maxDeductAmount = maxDeductByPoints
|
||
.isLessThan(rule.maxDeductionAmount ?? Infinity)
|
||
? maxDeductByPoints
|
||
: new BigNumber(rule.maxDeductionAmount || Infinity)
|
||
.isLessThan(maxLimitBn)
|
||
? maxDeductByPoints
|
||
: maxLimitBn;
|
||
|
||
// 实际使用积分 = 抵扣金额 * 积分兑换比例
|
||
const usedPoints = maxDeductAmount.multipliedBy(pointsPerYuanBn);
|
||
|
||
return {
|
||
deductionAmount: truncateToTwoDecimals(maxDeductAmount.toNumber()),
|
||
usedPoints: truncateToTwoDecimals(Math.min(usedPoints.toNumber(), userPoints)) // 避免积分超扣
|
||
};
|
||
}
|
||
|
||
// ============================ 6. 订单总费用汇总与实付金额计算(核心入口,补充细分字段) ============================
|
||
/**
|
||
* 计算订单所有费用子项并汇总(核心入口函数)
|
||
* @param goodsList 购物车商品列表
|
||
* @param dinnerType 就餐类型
|
||
* @param backendCoupons 后端优惠券列表
|
||
* @param activities 全局营销活动列表
|
||
* @param config 订单额外配置(会员、积分、餐位费等)
|
||
* @param cartOrder 商品加入购物车顺序(key=购物车ID,value=时间戳)
|
||
* @param currentTime 当前时间(用于优惠券有效期判断)
|
||
* @returns 订单费用汇总(含所有子项和实付金额,新增商品券/满减券细分)
|
||
*/
|
||
export function calculateOrderCostSummary(
|
||
goodsList: BaseCartItem[],
|
||
dinnerType: 'dine-in' | 'take-out',
|
||
backendCoupons: BackendCoupon[] = [],
|
||
activities: ActivityConfig[] = [],
|
||
config: OrderExtraConfig,
|
||
cartOrder: Record<string, number> = {},
|
||
currentTime: Date = new Date()
|
||
): OrderCostSummary {
|
||
// 1. 基础费用计算(原有逻辑不变)
|
||
const goodsOriginalAmount = calcGoodsOriginalAmount(goodsList);
|
||
const goodsRealAmount = calcGoodsRealAmount(goodsList, {
|
||
isMember: config.isMember,
|
||
memberDiscountRate: config.memberDiscountRate
|
||
}, activities);
|
||
const goodsDiscountAmount = calcGoodsDiscountAmount(goodsOriginalAmount, goodsRealAmount);
|
||
|
||
// 2. 优惠券抵扣(原有逻辑不变)
|
||
const {
|
||
deductionAmount: couponDeductionAmount,
|
||
usedCoupon,
|
||
excludedProductIds,
|
||
productCouponDeduction,
|
||
fullCouponDeduction
|
||
} = calcCouponDeduction(
|
||
backendCoupons,
|
||
goodsList,
|
||
{
|
||
currentStoreId: config.currentStoreId,
|
||
isMember: config.isMember,
|
||
memberDiscountRate: config.memberDiscountRate,
|
||
activities,
|
||
cartOrder,
|
||
dinnerType,
|
||
currentTime
|
||
}
|
||
);
|
||
|
||
// 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(
|
||
config.userPoints,
|
||
config.pointDeductionRule,
|
||
maxPointDeductionLimit
|
||
);
|
||
|
||
// ============================ 新增:商家减免计算(支持两种形式) ============================
|
||
// 商家减免计算时机:在商品折扣、优惠券、积分抵扣之后(避免过度减免)
|
||
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,
|
||
// 商家减免明细(含类型、原始配置、实际金额)
|
||
merchantReduction: {
|
||
type: merchantReductionConfig.type,
|
||
originalConfig: merchantReductionConfig,
|
||
actualAmount: truncateToTwoDecimals(merchantReductionActualAmount)
|
||
},
|
||
additionalFee,
|
||
finalPayAmount: truncateToTwoDecimals(finalPayAmountNonNegative),
|
||
couponUsed: usedCoupon,
|
||
pointUsed: usedPoints
|
||
};
|
||
}
|
||
|
||
export function isWeightGoods(goods: BaseCartItem): boolean {
|
||
return goods.product_type === GoodsType.WEIGHT;
|
||
}
|
||
|
||
// ============================ 7. 对外暴露工具库 ============================
|
||
export const OrderPriceCalculator = {
|
||
// 基础工具
|
||
truncateToTwoDecimals,
|
||
isTemporaryGoods,
|
||
isGiftGoods,
|
||
formatDiscountRate,
|
||
filterThresholdGoods,
|
||
isWeightGoods,
|
||
// 优惠券转换
|
||
convertBackendCouponToToolCoupon,
|
||
// 商品价格计算
|
||
calcSingleGoodsRealPrice,
|
||
calcGoodsOriginalAmount,
|
||
calcGoodsRealAmount,
|
||
calcGoodsDiscountAmount,
|
||
// 优惠券计算
|
||
calcCouponDeduction,
|
||
// 其他费用计算
|
||
calcTotalPackFee,
|
||
calcSeatFee,
|
||
calcPointDeduction,
|
||
// 核心入口
|
||
calculateOrderCostSummary,
|
||
// 枚举导出
|
||
Enums: {
|
||
GoodsType,
|
||
CouponType,
|
||
ActivityType,
|
||
}
|
||
};
|
||
|
||
export default OrderPriceCalculator;
|