cashier-web/src/utils/goods.ts

1546 lines
64 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { BigNumber } from 'bignumber.js';
// 配置BigNumber精度
BigNumber.set({
DECIMAL_PLACES: 2,
ROUNDING_MODE: BigNumber.ROUND_DOWN // 向下取整,符合业务需求
});
/**
* 购物车订单价格计算公共库
* 功能:覆盖订单全链路费用计算(商品价格、优惠券、积分、餐位费等),支持策略扩展
* 小数处理使用bignumber.js确保精度统一舍去小数点后两位如 10.129 → 10.1215.998 → 15.99
* 扩展设计:优惠券/营销活动采用策略模式,新增类型仅需扩展策略,无需修改核心逻辑
* 关键规则:
* - 所有优惠券均支持指定门槛商品后端foods字段空字符串=全部商品ID字符串=指定商品ID
* - 与限时折扣/会员价同享规则:开启则门槛计算含对应折扣,关闭则用原价/非会员价
* 字段说明:
* - BaseCartItem.id购物车项ID唯一标识购物车中的条目
* - BaseCartItem.product_id商品ID唯一标识商品用于优惠券/活动匹配)
* - BaseCartItem.skuData.idSKU 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; // 店铺IDint64
syncId?: number; // 同步Idint64
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.121.01 → 1.011.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=购物车IDvalue=加入时间戳)
* @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=购物车IDvalue=时间戳)
* @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;