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) type?: number; // 优惠券类型:1-满减券,2-商品兑换券,3-折扣券,4-第二件半价券,5-消费送券,6-买一送一券,7-固定价格券,8-免配送费券 name?: 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折,无会员价时用) newUserDiscount?: number; // 新用户减免金额(元,默认0) } /** 订单费用汇总(修改:补充商家减免类型和明细) */ export interface OrderCostSummary { // 商品总件数 goodsTotal: number; totalDiscountAmount: 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; // 实际使用的积分 newUserDiscount: number; // 新用户减免金额(元,默认0) dinnerType?: "dine-in" | "take-out"; // 就餐类型(堂食/自取/配送/快递) config: OrderExtraConfig; // 订单额外费用配置 //满减活动 fullReduction: { usedActivity?: FullReductionActivity; // 实际使用的满减活动 usedThreshold?: FullReductionThreshold; // 实际使用的满减阈值(多门槛中选最优) actualAmount: number; // 满减实际减免金额(元) }; } /** 满减活动阈值(单条满减规则:满X减Y)- 对应 MkDiscountThresholdInsertGroupDefaultGroup */ export interface FullReductionThreshold { activityId?: number; // 关联满减活动ID fullAmount?: number; // 满多少金额(元,必填) discountAmount?: number; // 减多少金额(元,必填) } /** 满减活动主表 - 对应 Request 接口(后端真实字段) */ export interface FullReductionActivity { id?: number; // 自增主键(后端字段:id) shopId?: number; // 店铺ID(后端字段:shopId) status?: number; // 活动状态:1=未开始,2=进行中,3=已结束(后端字段:status) sort?: number; // 排序值(越大优先级越高,后端字段:sort) createTime?: string; // 创建时间(后端字段:createTime,格式如"2025-10-14 13:56:07") updateTime?: string; // 最新修改时间(后端字段:updateTime,用于优先级排序) validStartTime?: string; // 有效期开始时间(后端字段:validStartTime,格式如"2025-10-14") validEndTime?: string; // 有效期结束时间(后端字段:validEndTime,格式如"2025-12-14") useType?: string; // 可使用类型(后端字段:useType,如"dine,pickup,deliv,express") useDays?: string; // 可用周期(后端字段:useDays,如"周一,周二,周三,周四,周五,周六,周日") useTimeType?: string; // 可用时间段类型(后端字段:useTimeType,all=全时段,custom=指定时段) useStartTime?: string; // 每日可用开始时间(后端字段:useStartTime,如"09:00:00",仅custom时有效) useEndTime?: string; // 每日可用结束时间(后端字段:useEndTime,如"22:00:00",仅custom时有效) couponShare?: number; // 与优惠券同享:0=否,1=是(后端字段:couponShare) discountShare?: number; // 与限时折扣同享:0=否,1=是(后端字段:discountShare) vipPriceShare?: number; // 与会员价同享:0=否,1=是(后端字段:vipPriceShare) pointsShare?: number; // 与积分抵扣同享:0=否,1=是(后端字段:pointsShare) thresholds?: FullReductionThreshold[]; // 满减阈值列表(多门槛,后端字段:thresholds) isDel?: boolean; // 是否删除:0=否,1=是(后端字段:isDel,默认false) } // ============================ 扩展:订单配置与费用汇总(加入后端满减类型) ============================ /** 扩展订单额外配置:使用后端满减活动类型 */ export interface OrderExtraConfig { // ... 原有字段不变 ... fullReductionActivities: FullReductionActivity[]; // 当前店铺的满减活动列表(后端返回结构) currentDinnerType: "dine" | "pickup" | "deliv" | "express"; // 当前就餐类型(匹配useType) } // 辅助枚举:星期映射(用于useDays校验) const WEEKDAY_MAP = { "周一": 1, "周二": 2, "周三": 3, "周四": 4, "周五": 5, "周六": 6, "周日": 0 // JS中getDay()返回0=周日 }; /** * 辅助:校验当前时间是否在活动的「每日可用时段」内 * @param activity 满减活动 * @param currentTime 当前时间 * @returns 是否在时段内 */ function isInDailyTimeRange(activity: FullReductionActivity, currentTime: Date): boolean { // 全时段无需校验 if (activity.useTimeType === "all") return true; // 无时段配置则不通过 if (!activity.useStartTime || !activity.useEndTime) return false; const [startHour, startMinute] = activity.useStartTime.split(":").map(Number); const [endHour, endMinute] = activity.useEndTime.split(":").map(Number); const currentHour = currentTime.getHours(); const currentMinute = currentTime.getMinutes(); // 转换为分钟数比较 const startTotalMin = startHour * 60 + startMinute; const endTotalMin = endHour * 60 + endMinute; const currentTotalMin = currentHour * 60 + currentMinute; // 处理跨天场景(如23:00-02:00) if (startTotalMin <= endTotalMin) { return currentTotalMin >= startTotalMin && currentTotalMin <= endTotalMin; } else { return currentTotalMin >= startTotalMin || currentTotalMin <= endTotalMin; } } /** * 辅助:校验当前时间是否在活动的「可用周期」内(如周一至周日) * @param activity 满减活动 * @param currentTime 当前时间 * @returns 是否在周期内 */ function isInWeeklyCycle(activity: FullReductionActivity, currentTime: Date): boolean { // 无周期配置则不通过 if (!activity.useDays) return false; const currentWeekday = currentTime.getDay(); // 0=周日,1=周一...6=周六 const allowedWeekdays = activity.useDays.split(",").map(day => WEEKDAY_MAP[day as keyof typeof WEEKDAY_MAP]); return allowedWeekdays.includes(currentWeekday); } /** * 辅助:校验当前就餐类型是否在活动的「可用类型」内(如堂食/自取) * @param activity 满减活动 * @param currentDinnerType 当前就餐类型 * @returns 是否匹配 */ function isDinnerTypeMatch(activity: FullReductionActivity, currentDinnerType: string): boolean { if (!activity.useType) return false; const allowedTypes = activity.useType.split(","); return allowedTypes.includes(currentDinnerType); } /** * 筛选最优满减活动(对齐后端逻辑:状态→时间→周期→时段→就餐类型→排序→修改时间) * @param activities 后端返回的满减活动列表 * @param currentShopId 当前店铺ID * @param currentDinnerType 当前就餐类型(dine/pickup等) * @param currentTime 当前时间(默认当前时间) * @returns 最优满减活动(无符合条件则返回undefined) */ export function filterOptimalFullReductionActivity( activities: FullReductionActivity[], currentShopId: number, currentDinnerType: string, currentTime: Date = new Date() ): FullReductionActivity | undefined { if (!activities.length) return undefined; // 第一步:基础筛选(未删除+当前店铺+活动进行中+就餐类型匹配) const baseEligible = activities.filter(activity => { return ( activity.isDel !== true && // 未删除 activity.shopId === currentShopId && // 当前店铺 activity.status === 2 && // 状态=2(进行中) isDinnerTypeMatch(activity, currentDinnerType) && // 就餐类型匹配 activity.thresholds?.length // 至少有一个满减阈值 ); }); if (!baseEligible.length) return undefined; // 第二步:时间筛选(有效期内+周期内+时段内) const timeEligible = baseEligible.filter(activity => { // 1. 校验有效期(validStartTime ~ validEndTime) if (!activity.validStartTime || !activity.validEndTime) return false; const startDate = new Date(activity.validStartTime); const endDate = new Date(activity.validEndTime); // 处理有效期结束日期的23:59:59 endDate.setHours(23, 59, 59, 999); if (currentTime < startDate || currentTime > endDate) return false; // 2. 校验可用周期(如周一至周日) if (!isInWeeklyCycle(activity, currentTime)) return false; // 3. 校验每日可用时段(如09:00-22:00) if (!isInDailyTimeRange(activity, currentTime)) return false; return true; }); if (!timeEligible.length) return undefined; // 第三步:按优先级排序(需求规则) return timeEligible.sort((a, b) => { // 1. 先比排序值:排序值大的优先 if ((a.sort || 0) !== (b.sort || 0)) { return (b.sort || 0) - (a.sort || 0); } // 2. 再比修改时间:最新修改的优先(时间戳降序) const aUpdateTime = a.updateTime ? new Date(a.updateTime).getTime() : 0; const bUpdateTime = b.updateTime ? new Date(b.updateTime).getTime() : 0; return bUpdateTime - aUpdateTime; })[0]; // 取最优活动 } /** * 折扣率格式化:后端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 = {} ): 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, 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 & { activity?: ActivityConfig; // 商品参与的营销活动(如限时折扣,按商品ID匹配) } ): number { const { isMember, memberDiscountRate, activity } = config; //如果是增菜价格为0 if (goods.is_gift) { return 0; } // 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)); let basePrice = new BigNumber(0); if (goods.is_temporary) { basePrice = new BigNumber(goods?.discountSaleAmount ?? 0); } else if (goods.is_gift) { basePrice = new BigNumber(0); } else { 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, 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())); } /** * 从满减活动的多门槛中,选择最优阈值(满金额最小且减免金额最大) * @param thresholds 满减阈值列表(多门槛) * @param baseAmount 满减计算基数(新客立减后的金额) * @param goodsOriginalAmount 商品原价总和(discountShare=0时用) * @param goodsRealAmount 商品折扣后总和(discountShare=1时用) * @param discountShare 与限时折扣同享:0=否,1=是 * @returns 最优阈值(无符合条件则返回undefined) */ export function selectOptimalThreshold( thresholds: FullReductionThreshold[] = [], baseAmount: number, goodsOriginalAmount: number, goodsRealAmount: number, discountShare: number = 0 ): FullReductionThreshold | undefined { if (!thresholds.length) return undefined; // 第一步:确定满减门槛基数(根据discountShare规则) const thresholdBase = discountShare === 1 ? new BigNumber(goodsRealAmount) // 与限时折扣同享→用折扣后金额 : new BigNumber(goodsOriginalAmount); // 不同享→用原价 // 第二步:筛选「满金额≤基数」且「减免金额>0」的有效阈值 const validThresholds = thresholds.filter(threshold => { const fullAmount = new BigNumber(threshold.fullAmount || 0); const discountAmount = new BigNumber(threshold.discountAmount || 0); return fullAmount.isLessThanOrEqualTo(thresholdBase) && discountAmount.isGreaterThan(0); }); if (!validThresholds.length) return undefined; // 第三步:选择最优阈值(优先级:1.满金额最小 → 2.减免金额最大) return validThresholds.sort((a, b) => { const aFull = new BigNumber(a.fullAmount || 0); const bFull = new BigNumber(b.fullAmount || 0); const aDiscount = new BigNumber(a.discountAmount || 0); const bDiscount = new BigNumber(b.discountAmount || 0); // 先比满金额:越小越优先(满1减10 比 满100减20 更优) if (!aFull.isEqualTo(bFull)) { return aFull.comparedTo(bFull) || 0; // Ensure a number is always returned } // 再比减免金额:越大越优先 return bDiscount.comparedTo(aDiscount) || 0; // Ensure a number is always returned })[0]; } /** * 计算满减实际减免金额(适配多门槛、同享规则) * @param optimalActivity 最优满减活动 * @param optimalThreshold 最优满减阈值 * @param baseAmount 计算基数(新客立减后的金额) * @returns 实际减免金额(元,未达门槛则0) */ export function calcFullReductionAmount( baseAmount: number, optimalActivity?: FullReductionActivity, optimalThreshold?: FullReductionThreshold, ): number { if (!optimalActivity || !optimalThreshold) return 0; const baseAmountBn = new BigNumber(baseAmount); const discountAmountBn = new BigNumber(optimalThreshold.discountAmount || 0); // 1. 基数必须为正(避免减免后为负) if (baseAmountBn.isLessThanOrEqualTo(0)) return 0; // 2. 减免金额不能超过基数(避免减成负数) const maxReducible = baseAmountBn; const actualReduction = discountAmountBn.isLessThanOrEqualTo(maxReducible) ? discountAmountBn : maxReducible; return truncateToTwoDecimals(actualReduction.toNumber()); } // ------------------------------ 策略辅助函数 ------------------------------ /** * 根据优惠券useShops列表判断门店是否匹配(适配BaseCoupon的useShops字段) */ function isStoreMatchByList( useShops: string[], currentStoreId: string ): boolean { // 适用门店为空数组 → 无限制(所有门店适用) if (useShops.length === 0) return true; // 匹配当前门店ID(字符串比较,避免类型问题) return useShops.includes(currentStoreId); } /** * 计算优惠券抵扣金额(处理互斥逻辑,选择最优优惠券,按商品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; dinnerType: "dine-in" | "take-out"; currentTime?: Date; } ): { deductionAmount: number; productCouponDeduction: number; // 新增:商品优惠券抵扣(兑换券/折扣券/买一送一等) fullCouponDeduction: number; // 新增:满减优惠券抵扣 usedCoupon?: Coupon; excludedProductIds: string[]; // 排除的商品ID列表(商品ID) } { const goodsCoupon = backendCoupons.filter((v) => v.type == 2); const discountCoupon = backendCoupons.filter((v) => v.type != 2); // 3. 计算非兑换券最优抵扣(传递已抵扣商品ID,避免重复,统计细分字段) let nonExchangeResult: CouponResult = { deductionAmount: discountCoupon.reduce((prve, cur): number => { return prve + (cur.discountAmount || 0); }, 0), excludedProductIds: [], usedCoupon: undefined, productCouponDeduction: 0, fullCouponDeduction: 0, }; // 4. 计算兑换券抵扣(排除非兑换券已抵扣的商品ID,统计商品券细分) let exchangeResult: ExchangeCalculationResult = { deductionAmount: goodsCoupon.reduce((prve, cur): number => { return prve + (cur.discountAmount || 0); }, 0), excludedProductIds: [], productCouponDeduction: 0, }; // 5. 汇总结果:兑换券与非兑换券不可同时使用,取抵扣金额大的 const exchangeBn = new BigNumber(exchangeResult.deductionAmount); const nonExchangeBn = new BigNumber(nonExchangeResult.deductionAmount); const isExchangeBetter = exchangeBn.isGreaterThan(nonExchangeBn); return { deductionAmount: exchangeBn.plus(nonExchangeBn).toNumber(), productCouponDeduction: exchangeResult.deductionAmount, fullCouponDeduction: nonExchangeResult.deductionAmount, // 兑换券与满减券互斥,满减券抵扣置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 { // if (dinnerType !== "take-out") return 0; let total = new BigNumber(0); for (const goods of goodsList) { const packNumber = goods.packNumber ? goods.packNumber * 1 : 0; let availableNum = Math.max(0, goods.number - (goods.returnNum || 0)); availableNum = Math.min(availableNum, packNumber); if (availableNum === 0) continue; // 计算单个商品打包数量(外卖全打包,堂食按配置,称重商品≤1) let packNum = availableNum; 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, // 含后端满减活动、currentDinnerType cartOrder: Record = {}, 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); // 商品折扣金额 const newUserDiscount = config.newUserDiscount || 0; // 新客立减 // ------------------------------ 2. 满减活动计算(核心步骤) ------------------------------ let usedFullReductionActivity: FullReductionActivity | undefined; let usedFullReductionThreshold: FullReductionThreshold | undefined; let fullReductionAmount = 0; // 2.1 筛选最优满减活动(后端活动列表、当前店铺、就餐类型、时间) usedFullReductionActivity = filterOptimalFullReductionActivity( config.fullReductionActivities, Number(config.currentStoreId), // 转换为数字(后端shopId是number) config.currentDinnerType, // 后端useType匹配的就餐类型(如"dine") currentTime ); // 2.2 计算满减基数(先扣新客立减) const baseAfterNewUserDiscount = new BigNumber(goodsRealAmount) .minus(newUserDiscount) .isGreaterThan(0) ? new BigNumber(goodsRealAmount).minus(newUserDiscount).toNumber() : 0; // 2.3 选择最优满减阈值(多门槛场景) if (usedFullReductionActivity) { usedFullReductionThreshold = selectOptimalThreshold( usedFullReductionActivity.thresholds, baseAfterNewUserDiscount, goodsOriginalAmount, goodsRealAmount, usedFullReductionActivity.discountShare || 0 // 与限时折扣同享规则 ); // 2.4 计算满减实际减免金额 fullReductionAmount = calcFullReductionAmount( baseAfterNewUserDiscount, usedFullReductionActivity, usedFullReductionThreshold, ); } // ------------------------------ 3. 优惠券抵扣(适配满减同享规则) ------------------------------ let couponDeductionAmount = 0; let productCouponDeduction = 0; let fullCouponDeduction = 0; let usedCoupon: Coupon | undefined; let excludedProductIds: string[] = []; // 若满减与优惠券同享(couponShare=1),才计算优惠券;否则优惠券抵扣为0 if (usedFullReductionActivity?.couponShare === 1) { const couponResult = calcCouponDeduction( // 原有优惠券计算函数 backendCoupons, goodsList, { currentStoreId: config.currentStoreId, isMember: config.isMember, memberDiscountRate: config.memberDiscountRate, activities, cartOrder, dinnerType, currentTime } ); couponDeductionAmount = couponResult.deductionAmount; productCouponDeduction = couponResult.productCouponDeduction; fullCouponDeduction = couponResult.fullCouponDeduction; usedCoupon = couponResult.usedCoupon; excludedProductIds = couponResult.excludedProductIds; } // ------------------------------ 4. 积分抵扣(适配满减同享规则) ------------------------------ let pointDeductionAmount = 0; let usedPoints = 0; // 计算积分抵扣基数(商品折扣后 - 新客立减 - 满减 - 优惠券) const maxPointDeductionLimit = new BigNumber(goodsRealAmount) .minus(newUserDiscount) .minus(fullReductionAmount) .minus(couponDeductionAmount) .isGreaterThan(0) ? new BigNumber(goodsRealAmount).minus(newUserDiscount).minus(fullReductionAmount).minus(couponDeductionAmount).toNumber() : 0; // 若满减与积分同享(pointsShare=1),才计算积分;否则积分抵扣为0 if ((usedFullReductionActivity?.pointsShare === 1) && maxPointDeductionLimit > 0) { const pointResult = calcPointDeduction( config.userPoints, config.pointDeductionRule, maxPointDeductionLimit ); pointDeductionAmount = pointResult.deductionAmount; usedPoints = pointResult.usedPoints; } // ------------------------------ 5. 其他费用计算(原有逻辑不变) ------------------------------ const packFee = calcTotalPackFee(goodsList, dinnerType); // 打包费 let seatFee = calcSeatFee(config.seatFeeConfig); // 餐位费 seatFee = dinnerType === "dine-in" ? seatFee : 0; // 外卖不收餐位费 const additionalFee = Math.max(0, config.additionalFee); // 附加费 // 商家减免计算(原有逻辑不变) const merchantReductionConfig = config.merchantReduction; let merchantReductionActualAmount = 0; const maxMerchantReductionLimit = new BigNumber(goodsRealAmount) .minus(newUserDiscount) .minus(fullReductionAmount) .minus(couponDeductionAmount) .minus(pointDeductionAmount) .plus(seatFee) .plus(packFee) .isGreaterThan(0) ? new BigNumber(goodsRealAmount).minus(newUserDiscount).minus(fullReductionAmount).minus(couponDeductionAmount).minus(pointDeductionAmount).plus(seatFee).plus(packFee).toNumber() : 0; switch (merchantReductionConfig.type) { case MerchantReductionType.FIXED_AMOUNT: merchantReductionActualAmount = Math.min( merchantReductionConfig.fixedAmount || 0, maxMerchantReductionLimit ); break; case MerchantReductionType.DISCOUNT_RATE: const validRate = Math.max(0, Math.min(100, merchantReductionConfig.discountRate || 0)) / 100; merchantReductionActualAmount = maxMerchantReductionLimit * (1 - validRate); break; } merchantReductionActualAmount = Math.max(0, truncateToTwoDecimals(merchantReductionActualAmount)); // ------------------------------ 6. 最终实付金额计算 ------------------------------ const finalPayAmountBn = new BigNumber(goodsRealAmount) .minus(newUserDiscount) .minus(fullReductionAmount) .minus(couponDeductionAmount) .minus(pointDeductionAmount) .minus(merchantReductionActualAmount) .plus(seatFee) .plus(packFee) .plus(additionalFee); const finalPayAmount = Math.max(0, truncateToTwoDecimals(finalPayAmountBn.toNumber())); // ------------------------------ 7. 总优惠金额计算 ------------------------------ const totalDiscountAmount = truncateToTwoDecimals( new BigNumber(goodsDiscountAmount) .plus(newUserDiscount) .plus(fullReductionAmount) .plus(couponDeductionAmount) .plus(pointDeductionAmount) .plus(merchantReductionActualAmount) .toNumber() ); // 积分可抵扣最大金额 // ------------------------------ 8. 返回完整结果 ------------------------------ return { goodsTotal: goodsList.reduce((sum, g) => sum + Math.max(0, g.number - (g.returnNum || 0)), 0), goodsRealAmount, goodsOriginalAmount, goodsDiscountAmount, couponDeductionAmount, productCouponDeduction, fullCouponDeduction, pointDeductionAmount, seatFee, packFee, totalDiscountAmount, // 满减活动明细(后端字段) fullReduction: { usedActivity: usedFullReductionActivity, usedThreshold: usedFullReductionThreshold, actualAmount: truncateToTwoDecimals(fullReductionAmount) }, merchantReduction: { type: merchantReductionConfig.type, originalConfig: merchantReductionConfig, actualAmount: merchantReductionActualAmount }, additionalFee, finalPayAmount, couponUsed: usedCoupon, pointUsed: usedPoints, newUserDiscount, dinnerType, config }; } export function isWeightGoods(goods: BaseCartItem): boolean { return goods.product_type === GoodsType.WEIGHT; } // ============================ 7. 对外暴露工具库 ============================ export const OrderPriceCalculator = { // 基础工具 truncateToTwoDecimals, isTemporaryGoods, isGiftGoods, formatDiscountRate, filterThresholdGoods, isWeightGoods, // 商品价格计算 calcSingleGoodsRealPrice, calcGoodsOriginalAmount, calcGoodsRealAmount, calcGoodsDiscountAmount, //满减活动工具 filterOptimalFullReductionActivity, // 优惠券计算 calcCouponDeduction, // 其他费用计算 calcTotalPackFee, calcSeatFee, calcPointDeduction, // 核心入口 calculateOrderCostSummary, // 枚举导出 Enums: { GoodsType, CouponType, ActivityType, WEEKDAY_MAP, }, }; export default OrderPriceCalculator;