cashier-ipad/lib/goods.ts

1374 lines
46 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唯一标识商品规格
*/
import {
Coupon,
GoodsType,
BackendCoupon,
ExchangeCalculationResult,
PointDeductionRule,
ShopUserInfo,
OrderCostSummary,
CouponResult,
SeatFeeConfig,
FullReductionActivity,
BaseCartItem,
TimeLimitDiscountConfig,
BaseCoupon,
OrderExtraConfig,
ActivityConfig,
FullReductionThreshold,
CouponType,
ActivityType,
MerchantReductionType,
WEEKDAY_MAP,
} from "./types";
/**
* 辅助:校验当前时间是否在活动的「每日可用时段」内
* @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(",");
//满减活动的就餐类型和当前券类型字段值不一样暂时返回true
return true;
}
//判断商品是否可以使用限时折扣
export function returnCanUseLimitTimeDiscount(
goods: BaseCartItem,
limitTimeDiscount: TimeLimitDiscountConfig | null | undefined,
useVipPrice: boolean,
idKey = "product_id"
) {
if (!limitTimeDiscount || !limitTimeDiscount.id) {
return false;
}
const canUseFoods = (limitTimeDiscount.foods || "").split(",");
const goodsCanUse =
limitTimeDiscount.foodType == 1 ||
canUseFoods.includes("" + goods[idKey as keyof BaseCartItem]);
if (!goodsCanUse) {
return false;
}
if (limitTimeDiscount.discountPriority == "limit-time") {
return true;
}
if (limitTimeDiscount.discountPriority == "vip-price") {
if (!useVipPrice) {
return true;
}
if (useVipPrice && goods.hasOwnProperty("memberPrice")) {
if (goods.memberPrice && goods.memberPrice * 1 <= 0) {
return true;
}
}
}
return false;
}
function returnMemberPrice(useVipPrice: boolean, goods: BaseCartItem) {
if (useVipPrice) {
return goods.memberPrice || goods.salePrice;
} else {
return goods.salePrice;
}
}
/**
* 返回商品限时折扣价格
*/
function returnLimitPrice(
goods: BaseCartItem,
limitTimeDiscount: TimeLimitDiscountConfig | null | undefined,
useVipPrice: boolean
) {
if (!limitTimeDiscount) {
return 0;
}
const discountRate = new BigNumber(limitTimeDiscount.discountRate).dividedBy(
100
);
const canuseLimit = returnCanUseLimitTimeDiscount(
goods,
limitTimeDiscount,
useVipPrice
);
if (canuseLimit) {
//可以使用限时折扣
if (limitTimeDiscount.discountPriority == "limit-time") {
//限时价优先
const result = BigNumber(goods.salePrice)
.times(discountRate)
.decimalPlaces(2, BigNumber.ROUND_UP)
.toNumber();
return result;
}
if (limitTimeDiscount.discountPriority == "vip-price") {
//会员价优先
if (useVipPrice && goods.memberPrice && goods.memberPrice * 1 > 0) {
//使用会员价
return returnMemberPrice(useVipPrice, goods);
} else {
//不使用会员价
const result = BigNumber(goods.salePrice)
.times(discountRate)
.decimalPlaces(2, BigNumber.ROUND_UP)
.toNumber();
return result;
}
}
} else {
//不可以使用限时折扣
//会员价优先
if (useVipPrice) {
//使用会员价
return returnMemberPrice(useVipPrice, goods);
} else {
return goods.salePrice;
}
}
}
/**
* 计算商品计算门槛时的金额
*/
export function returnCalcPrice(
goods: BaseCartItem,
fullReductionActivitie: FullReductionActivity | undefined,
limitTimeDiscount: TimeLimitDiscountConfig | null | undefined,
useVipPrice: boolean,
idKey = "product_id"
) {
if (goods.discountSaleAmount && goods.discountSaleAmount * 1 > 0) {
return goods.salePrice;
}
//限时折扣和满减活动都有
if (fullReductionActivitie && limitTimeDiscount) {
if (
fullReductionActivitie.discountShare == 1 &&
fullReductionActivitie.vipPriceShare == 1
) {
//与限时折扣同享,与会员价不同享
return returnLimitPrice(goods, limitTimeDiscount, useVipPrice);
}
if (
fullReductionActivitie.discountShare != 1 &&
fullReductionActivitie.vipPriceShare == 1
) {
//与限时折扣不同享,与会员价同享
return returnMemberPrice(useVipPrice, goods);
}
if (fullReductionActivitie.vipPriceShare != 1) {
//与会员价不同享
return goods.salePrice;
}
return goods.salePrice;
}
//只有满减活动
if (fullReductionActivitie) {
if (fullReductionActivitie.vipPriceShare == 1) {
return returnMemberPrice(useVipPrice, goods);
} else {
return goods.salePrice;
}
}
//只有限时折扣
if (limitTimeDiscount) {
return returnLimitPrice(goods, limitTimeDiscount, useVipPrice);
}
if (useVipPrice) {
return returnMemberPrice(useVipPrice, goods);
}
return goods.salePrice;
}
/**
* 计算满减活动门槛
*/
export function calcFullReductionActivityFullAmount(
goodsList: BaseCartItem[],
fullReductionActivitie: FullReductionActivity | undefined,
limitTimeDiscount: TimeLimitDiscountConfig | null | undefined,
useVipPrice: boolean,
seatFee: number,
packFee: number
): number {
if (!fullReductionActivitie) {
return 0;
}
let amount = 0;
for (let goods of goodsList) {
const availableNum = Math.max(0, goods.number - (goods.returnNum || 0));
if (goods.is_temporary ||goods.isTemporary || goods.is_gift ||goods.isGift || availableNum <= 0) {
//临时菜,赠菜,数量<=0的商品不计算
continue;
}
const calcPrice = returnCalcPrice(
goods,
fullReductionActivitie,
limitTimeDiscount,
useVipPrice,
"product_id"
);
if (calcPrice !== undefined) {
amount += calcPrice * availableNum;
}
}
return amount + seatFee + packFee;
}
/**
* 筛选最优满减活动(对齐后端逻辑:状态→时间→周期→时段→就餐类型→排序→修改时间)
* @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 || !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.useTimeType == "all") {
return true;
}
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.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|| !!goods.isTemporary;
}
/**
* 判断商品是否为赠菜(赠菜不计入优惠券活动,但计打包费)
* @param goods 商品项
* @returns 是否赠菜
*/
export function isGiftGoods(goods: BaseCartItem): boolean {
return !!goods.is_gift || !!goods.isGift;
}
/**
* 判断可用类型是否可用
*/
export function useTypeCanUse(useType: string[]) {
const arr = ["all", "dine-in", "take-out", "take-away", "post"];
return useType.some((item) => arr.includes(item));
}
/**
* 计算单个商品的会员价优先级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.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" | "limitTimeDiscount"
>
): number {
const { isMember, memberDiscountRate, limitTimeDiscount: activity } = config;
//如果是增菜价格为0
if (goods.is_gift||goods.isGift) {
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)
);
if(goods.is_time_discount||goods.isTimeDiscount){
//限时折扣优先
return truncateToTwoDecimals(
new BigNumber(goods.salePrice)
.times((activity?activity.discountRate:100) / 100)
.decimalPlaces(2, BigNumber.ROUND_UP)
.toNumber()
);
}
// 3. 优先级3营销活动折扣如限时折扣需按商品ID匹配活动
let isActivityApplicable = false;
if (activity) {
if (activity.foodType == 1) {
isActivityApplicable = true;
} else {
const canUseGoods = activity.foods?.split(",") || [];
if (canUseGoods.find((v) => v == String(goods.product_id))) {
isActivityApplicable = true;
}
}
}
if (!activity || !isActivityApplicable) {
return memberPrice.toNumber();
}
//限时折扣优先或者会员价优先但是不是会员或者未开启会员价格时限时折扣优先
if (
activity.discountPriority == "limit-time" ||
(activity.discountPriority == "vip-price" && !isMember) ||
(activity.discountPriority == "vip-price" && isMember && !goods.memberPrice)
)
{
//限时折扣优先
return truncateToTwoDecimals(
new BigNumber(goods.salePrice)
.times(activity.discountRate / 100)
.decimalPlaces(2, BigNumber.ROUND_UP)
.toNumber()
);
}
if (activity.discountPriority == "vip-price" && isMember) {
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||goods.isTemporary) {
basePrice = new BigNumber(goods?.discountSaleAmount ?? 0);
} else if (goods.is_gift||goods.isGift) {
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<
OrderExtraConfig,
"isMember" | "memberDiscountRate" | "limitTimeDiscount"
>
): 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;
const realPrice = new BigNumber(calcSingleGoodsRealPrice(goods, config));
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 = baseAmount;
// 第二步:筛选「满金额≤基数」且「减免金额>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;
// 找到抵扣金额最大的门槛项
const maxDiscountThreshold = validThresholds.reduce(
(maxItem, currentItem) => {
// 处理空值默认抵扣金额为0
const maxDiscount = new BigNumber(maxItem?.discountAmount || 0);
const currentDiscount = new BigNumber(currentItem?.discountAmount || 0);
// 比较当前项和已存最大项的抵扣金额,保留更大的
return currentDiscount.gt(maxDiscount) ? currentItem : maxItem;
},
validThresholds[0] || null
); // 初始值为数组第一项若数组为空则返回null
return maxDiscountThreshold;
}
/**
* 计算满减实际减免金额(适配多门槛、同享规则)
* @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<string, number>;
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));
if (availableNum === 0) continue;
// 计算单个商品打包数量外卖全打包堂食按配置称重商品≤1
let packNum = Math.min(availableNum, packNumber);
if (dinnerType === "take-out") {
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)
), // 避免积分超扣
};
}
function calcVipDiscountAmount(
goodsRealAmount: number,
shopUserInfo: ShopUserInfo
): number {
if (!shopUserInfo.isVip || shopUserInfo.discount === 0) return 0;
if (shopUserInfo.isVip == 1 && shopUserInfo.isMemberPrice != 1) {
return 0;
}
return truncateToTwoDecimals(
new BigNumber(goodsRealAmount)
.times((100 - (shopUserInfo.discount || 100)) / 100)
.decimalPlaces(2, BigNumber.ROUND_DOWN)
.toNumber()
);
}
// ============================ 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, // 含后端满减活动、currentDinnerType
cartOrder: Record<string, number> = {},
currentTime: Date = new Date()
): OrderCostSummary {
//是否使用霸王餐,霸王餐配置
const {
isFreeDine,
freeDineConfig,
limitTimeDiscount,
fullReductionActivities,
shopUserInfo,
} = config;
// ------------------------------ 1. 基础费用计算 ------------------------------
const goodsOriginalAmount = calcGoodsOriginalAmount(goodsList); // 商品原价总和
const goodsRealAmount = calcGoodsRealAmount(
// 商品折扣后总和
goodsList,
{
isMember: config.isMember,
memberDiscountRate: config.memberDiscountRate,
limitTimeDiscount: config.limitTimeDiscount,
}
);
const goodsDiscountAmount = calcGoodsDiscountAmount(
goodsOriginalAmount,
goodsRealAmount
); // 商品折扣金额
const newUserDiscount = config.newUserDiscount || 0; // 新客立减
// 其他费用计算(原有逻辑不变) ------------------------------
const packFee = calcTotalPackFee(goodsList, dinnerType); // 打包费
let seatFee = calcSeatFee(config.seatFeeConfig); // 餐位费
seatFee = dinnerType === "dine-in" ? seatFee : 0; // 外卖不收餐位费
const additionalFee = Math.max(0, config.additionalFee); // 附加费
// ------------------------------ 2. 满减活动计算(核心步骤) ------------------------------
let usedFullReductionActivity: FullReductionActivity | undefined;
let usedFullReductionThreshold: FullReductionThreshold | undefined;
let fullReductionAmount = 0;
let usedFullReductionActivityFullAmount = calcFullReductionActivityFullAmount(
goodsList,
usedFullReductionActivity,
config.limitTimeDiscount,
config.isMember,
seatFee,
packFee
);
// 2.1 筛选最优满减活动(后端活动列表、当前店铺、就餐类型、时间)
usedFullReductionActivity = filterOptimalFullReductionActivity(
config.fullReductionActivities,
Number(config.currentStoreId), // 转换为数字后端shopId是number
config.currentDinnerType, // 后端useType匹配的就餐类型如"dine"
currentTime
);
// 2.2 计算满减基数(先扣新客立减)
let baseAfterNewUserDiscount = new BigNumber(
limitTimeDiscount &&
limitTimeDiscount.id &&
usedFullReductionActivity &&
!usedFullReductionActivity.discountShare
? goodsRealAmount
: goodsRealAmount
)
.minus(newUserDiscount)
.plus(packFee)
.plus(seatFee)
.plus(additionalFee)
.toNumber();
baseAfterNewUserDiscount =
baseAfterNewUserDiscount > 0 ? baseAfterNewUserDiscount : 0;
// 2.3 选择最优满减阈值(多门槛场景)
if (usedFullReductionActivity) {
//计算当前满减活动的门槛金额
usedFullReductionActivityFullAmount = calcFullReductionActivityFullAmount(
goodsList,
usedFullReductionActivity,
config.limitTimeDiscount,
config.isMember,
seatFee,
packFee
);
usedFullReductionThreshold = selectOptimalThreshold(
usedFullReductionActivity.thresholds,
usedFullReductionActivityFullAmount,
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[] = [];
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;
// 若满减与优惠券同享couponShare=1才计算优惠券否则优惠券抵扣为0
if (
usedFullReductionThreshold &&
(!usedFullReductionActivity || !usedFullReductionActivity.couponShare)
) {
couponDeductionAmount = 0;
productCouponDeduction = 0;
fullCouponDeduction = 0;
usedCoupon = undefined;
excludedProductIds = [];
}
// ------------------------------ 4. 积分抵扣(适配满减同享规则) ------------------------------
let pointDeductionAmount = 0;
let usedPoints = 0;
// 计算积分抵扣基数(商品折扣后 - 新客立减 - 满减 - 优惠券 + 餐位费 + 打包费 + 附加费)
let maxPointDeductionLimit = new BigNumber(goodsRealAmount)
.minus(newUserDiscount)
.minus(fullReductionAmount)
.minus(couponDeductionAmount)
.plus(seatFee)
.plus(packFee)
.plus(additionalFee)
.toNumber();
maxPointDeductionLimit =
maxPointDeductionLimit > 0 ? maxPointDeductionLimit : 0;
const pointResult = calcPointDeduction(
config.userPoints,
config.pointDeductionRule,
maxPointDeductionLimit
);
pointDeductionAmount = pointResult.deductionAmount;
usedPoints = pointResult.usedPoints;
// 若满减与积分不同享pointsShare=1积分抵扣为0
if (
usedFullReductionThreshold &&
(!usedFullReductionActivity || !usedFullReductionActivity.pointsShare)
) {
pointDeductionAmount = 0;
usedPoints = 0;
}
//使用霸王餐
if (isFreeDine && freeDineConfig && freeDineConfig.enable) {
fullReductionAmount = 0;
//不与优惠券同享
if (!freeDineConfig.withCoupon) {
couponDeductionAmount = 0;
productCouponDeduction = 0;
fullCouponDeduction = 0;
usedCoupon = undefined;
excludedProductIds = [];
}
//不与积分同享
if (!freeDineConfig.withPoints) {
pointDeductionAmount = 0;
usedPoints = 0;
}
}
// 商家减免计算(原有逻辑不变)
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)
);
// 会员折扣减免
const vipDiscountAmount = calcVipDiscountAmount(
new BigNumber(goodsRealAmount)
.minus(couponDeductionAmount)
.plus(packFee)
.plus(seatFee)
.minus(newUserDiscount)
.minus(fullReductionAmount)
.toNumber(),
shopUserInfo
);
// ------------------------------ 6. 最终实付金额计算 ------------------------------
const finalPayAmountBn = new BigNumber(goodsRealAmount)
.minus(newUserDiscount)
.minus(vipDiscountAmount)
.minus(fullReductionAmount)
.minus(couponDeductionAmount)
.minus(pointDeductionAmount)
.minus(merchantReductionActualAmount)
.plus(seatFee)
.plus(packFee)
.plus(additionalFee);
let finalPayAmount = Math.max(
0,
truncateToTwoDecimals(finalPayAmountBn.toNumber())
);
// ------------------------------ 使用霸王餐计算 ------------------------------
let orderOriginFinalPayAmount = finalPayAmount;
if (isFreeDine && freeDineConfig && freeDineConfig.enable) {
finalPayAmount = BigNumber(finalPayAmount)
.times(freeDineConfig.rechargeTimes)
.toNumber();
}
// ------------------------------ 7. 总优惠金额计算 ------------------------------
const totalDiscountAmount = truncateToTwoDecimals(
new BigNumber(goodsDiscountAmount)
.plus(newUserDiscount)
.plus(fullReductionAmount)
.plus(couponDeductionAmount)
.plus(pointDeductionAmount)
.plus(merchantReductionActualAmount)
.plus(vipDiscountAmount)
.toNumber()
);
//积分可抵扣最大金额 最终支付金额+积分抵扣-商家减免
const scoreMaxMoney = new BigNumber(finalPayAmount)
.plus(pointDeductionAmount)
.minus(merchantReductionActualAmount)
.toNumber();
// ------------------------------ 8. 返回完整结果 ------------------------------
return {
goodsList: goodsList,
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,
//最终支付原金额
orderOriginFinalPayAmount,
//积分最大可抵扣金额
scoreMaxMoney,
// 满减活动明细(后端字段)
fullReduction: {
usedFullReductionActivityFullAmount: usedFullReductionActivityFullAmount,
usedActivity: usedFullReductionActivity,
usedThreshold: usedFullReductionThreshold,
actualAmount: truncateToTwoDecimals(fullReductionAmount),
},
vipDiscountAmount: vipDiscountAmount, //会员折扣减免金额
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;