This commit is contained in:
gyq
2025-11-18 11:18:09 +08:00
parent 9904c044c3
commit 9441a3c6d8
9 changed files with 81 additions and 921 deletions

View File

@@ -1,856 +0,0 @@
/**
* 购物车订单价格计算公共库
* 功能:覆盖订单全链路费用计算(商品价格、优惠券、积分、餐位费等),支持策略扩展
* 小数处理:统一舍去小数点后两位(如 10.129 → 10.1215.998 → 15.99
* 扩展设计:优惠券/营销活动采用策略模式,新增类型仅需扩展策略,无需修改核心逻辑
*/
// ============================ 1. 基础类型定义(扩展可复用) ============================
/** 商品类型枚举 */
export enum GoodsType {
NORMAL = 'normal', // 普通商品
WEIGHT = 'weight', // 称重商品
GIFT = 'gift', // 赠菜(继承普通商品逻辑,标记用)
}
// 定义计算结果类型(核心修正)
interface CouponResult {
deductionAmount: number; // 抵扣金额
excludedProductIds: string[]; // 不适用商品ID列表修正为 string[],而非 never[]
usedCoupon: Coupon | undefined; // 允许为优惠券类型或 undefined
}
interface ExchangeCalculationResult {
deductionAmount: number;
excludedProductIds: string[];
}
/** 优惠券类型枚举 */
export enum CouponType {
FULL_REDUCTION = 'full_reduction', // 满减券
DISCOUNT = 'discount', // 折扣券
SECOND_HALF = 'second_half', // 第二件半价券
BUY_ONE_GET_ONE = 'buy_one_get_one', // 买一送一券
EXCHANGE = 'exchange', // 商品兑换券
}
/** 营销活动类型枚举 */
export enum ActivityType {
TIME_LIMIT_DISCOUNT = 'time_limit_discount', // 限时折扣
}
/** 基础购物车商品项(含普通/称重/临时/赠菜) */
export interface BaseCartItem {
id: string | number;
salePrice: number; // 商品原价(元)
number: number; // 商品数量
productType: GoodsType; // 商品类型
isTemporary?: boolean; // 是否临时菜默认false
isGift?: boolean; // 是否赠菜默认false
returnNum?: number; // 退货数量历史订单用默认0
memberPrice?: number; // 商品会员价(元,优先级:会员价 > 会员折扣)
discountSaleAmount?: number; // 商家改价后单价(元,优先级最高)
packFee?: number; // 单份打包费默认0
packNumber?: number; // 堂食打包数量默认0
activityInfo?: { // 商品参与的营销活动(如限时折扣)
type: ActivityType;
discountRate: number; // 折扣率如0.8=8折
shareWithMember: boolean; // 是否与会员优惠同享默认false
};
skuData?: { // SKU扩展数据可选
memberPrice?: number;
salePrice?: number;
};
}
/** 基础优惠券接口 */
interface BaseCoupon {
id: string | number;
type: CouponType; // 优惠券类型
name: string; // 优惠券名称
available: boolean; // 是否可用(当日剩余数量>0
applicableStoreIds: string[]; // 适用门店ID当前门店需在列
shareWithActivity: boolean; // 是否与营销活动同享默认false
shareWithMember: boolean; // 是否与会员优惠同享默认false
}
/** 满减券示例满100减20 */
export interface FullReductionCoupon extends BaseCoupon {
type: CouponType.FULL_REDUCTION;
fullAmount: number; // 满减门槛(元)
reductionAmount: number; // 减免金额(元)
maxReductionAmount?: number; // 最大减免金额默认等于reductionAmount
}
/** 折扣券示例9折最大减50 */
export interface DiscountCoupon extends BaseCoupon {
type: CouponType.DISCOUNT;
discountRate: number; // 折扣率如0.9=9折需>0且<1
maxReductionAmount: number; // 最大减免金额(元,避免折扣过大)
}
/** 第二件半价券(限指定商品) */
export interface SecondHalfPriceCoupon extends BaseCoupon {
type: CouponType.SECOND_HALF;
applicableProductIds: string[]; // 适用商品ID
maxUseCountPerOrder?: number; // 每单最大使用次数(默认不限,按商品数量算)
}
/** 买一送一券(限指定商品) */
export interface BuyOneGetOneCoupon extends BaseCoupon {
type: CouponType.BUY_ONE_GET_ONE;
applicableProductIds: string[]; // 适用商品ID
maxUseCountPerOrder?: number; // 每单最大使用次数(默认不限,按商品数量算)
}
/** 商品兑换券(抵扣指定商品金额) */
export interface ExchangeCoupon extends BaseCoupon {
type: CouponType.EXCHANGE;
applicableProductIds: string[]; // 适用商品ID
deductCount: number; // 可抵扣商品件数如1=抵扣1件
sortRule: 'low_price_first' | 'high_price_first'; // 商品排序规则
}
/** 所有优惠券类型联合 */
export type Coupon = FullReductionCoupon | DiscountCoupon | SecondHalfPriceCoupon | BuyOneGetOneCoupon | ExchangeCoupon;
/** 营销活动配置(如限时折扣) */
export interface ActivityConfig {
type: ActivityType;
applicableProductIds?: string[]; // 适用商品ID
discountRate: number; // 折扣率如0.8=8折
shareWithMember: 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 interface OrderExtraConfig {
merchantReduction: number; // 商家减免金额默认0
additionalFee: number; // 附加费如余额充值、券包默认0
pointDeductionRule: PointDeductionRule; // 积分抵扣规则
seatFeeConfig: SeatFeeConfig; // 餐位费配置
currentStoreId: string; // 当前门店ID用于验证优惠券适用门店
userPoints: number; // 用户当前积分(用于积分抵扣)
isMember: boolean; // 用户是否会员(用于会员优惠)
memberDiscountRate?: number; // 会员折扣率如0.95=95折无会员价时用
}
/** 订单费用汇总(所有子项清晰展示,方便调用方使用) */
export interface OrderCostSummary {
goodsOriginalAmount: number; // 商品原价总和
goodsDiscountAmount: number; // 商品折扣金额(原价-实际价)
couponDeductionAmount: number; // 优惠券抵扣金额
pointDeductionAmount: number; // 积分抵扣金额
seatFee: number; // 餐位费
packFee: number; // 打包费
merchantReductionAmount: number; // 商家减免金额
additionalFee: number; // 附加费
finalPayAmount: number; // 最终实付金额
// 辅助信息
couponUsed?: Coupon; // 实际使用的优惠券(互斥时选最优)
pointUsed: number; // 实际使用的积分
}
// ============================ 2. 基础工具函数(通用能力,无业务耦合) ============================
/**
* 统一小数处理:舍去小数点后两位(如 10.129 → 10.1215.998 → 15.99
* @param num 待处理数字
* @returns 处理后保留两位小数的数字
*/
export function truncateToTwoDecimals(num: number): number {
return Math.floor(num * 100) / 100;
}
/**
* 判断商品是否为临时菜(临时菜不参与优惠券门槛和折扣计算)
* @param goods 商品项
* @returns 是否临时菜
*/
export function isTemporaryGoods(goods: BaseCartItem): boolean {
return !!goods.isTemporary;
}
/**
* 判断商品是否为赠菜(赠菜不计入优惠券活动,但计打包费)
* @param goods 商品项
* @returns 是否赠菜
*/
export function isGiftGoods(goods: BaseCartItem): boolean {
return !!goods.isGift;
}
/**
* 计算单个商品的会员价(优先级:商品会员价 > 会员折扣率)
* @param goods 商品项
* @param isMember 是否会员
* @param memberDiscountRate 会员折扣率如0.95=95折
* @returns 会员价(元)
*/
export function calcMemberPrice(
goods: BaseCartItem,
isMember: boolean,
memberDiscountRate?: number
): number {
if (!isMember) return goods.salePrice;
// 优先级:商品会员价 > SKU会员价 > 会员折扣价 > 原价
const basePrice = goods.memberPrice
?? goods.skuData?.memberPrice
?? goods.salePrice;
return memberDiscountRate && !goods.memberPrice && !goods.skuData?.memberPrice
? truncateToTwoDecimals(basePrice * memberDiscountRate)
: basePrice;
}
/**
* 过滤可参与优惠券计算的商品(排除临时菜、赠菜、已用兑换券的商品)
* @param goodsList 商品列表
* @param excludedProductIds 需排除的商品ID如兑换券已抵扣的商品
* @returns 可参与优惠券计算的商品列表
*/
export function filterCouponEligibleGoods(
goodsList: BaseCartItem[],
excludedProductIds: string[] = []
): BaseCartItem[] {
return goodsList.filter(goods =>
!isTemporaryGoods(goods)
&& !isGiftGoods(goods)
&& !excludedProductIds.includes(String(goods.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.salePrice;
const priceB = 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. 同价格同数量按加入购物车顺序(早的优先)
const orderA = cartOrder[String(a.id)] ?? Infinity;
const orderB = cartOrder[String(b.id)] ?? Infinity;
return orderA - orderB;
});
}
// ============================ 3. 商品核心价格计算(含折扣、会员优惠) ============================
/**
* 计算单个商品的实际单价(整合商家改价、会员优惠、营销活动折扣)
* @param goods 商品项
* @param config 订单额外配置(含会员、活动信息)
* @returns 单个商品实际单价(元)
*/
export function calcSingleGoodsRealPrice(
goods: BaseCartItem,
config: Pick<OrderExtraConfig, 'isMember' | 'memberDiscountRate'> & {
activity?: ActivityConfig; // 商品参与的营销活动(如限时折扣)
}
): number {
const { isMember, memberDiscountRate, activity } = config;
// 1. 优先级1商家改价改价后单价>0才生效
if (goods.discountSaleAmount && goods.discountSaleAmount > 0) {
return truncateToTwoDecimals(goods.discountSaleAmount);
}
// 2. 优先级2会员价含会员折扣率
const memberPrice = calcMemberPrice(goods, isMember, memberDiscountRate);
// 3. 优先级3营销活动折扣如限时折扣
if (!activity || (activity.applicableProductIds && !activity.applicableProductIds.includes(String(goods.id)))) {
return memberPrice;
}
// 处理活动与会员的同享/不同享逻辑
if (activity.shareWithMember) {
// 同享:会员价基础上叠加工活动折扣
return truncateToTwoDecimals(memberPrice * activity.discountRate);
} else {
// 不同享:取会员价和活动价的最小值(最大折扣)
const activityPrice = truncateToTwoDecimals(goods.salePrice * activity.discountRate);
return Math.min(memberPrice, activityPrice);
}
}
/**
* 计算商品原价总和(所有商品:原价*数量,含临时菜、赠菜)
* @param goodsList 商品列表
* @returns 商品原价总和(元)
*/
export function calcGoodsOriginalAmount(goodsList: BaseCartItem[]): number {
return truncateToTwoDecimals(
goodsList.reduce((total, goods) => {
const availableNum = Math.max(0, goods.number - (goods.returnNum || 0));
return total + goods.salePrice * availableNum;
}, 0)
);
}
/**
* 计算商品实际总价(所有商品:实际单价*数量,含临时菜、赠菜)
* @param goodsList 商品列表
* @param config 订单额外配置(含会员、活动信息)
* @param activities 全局营销活动列表(如限时折扣)
* @returns 商品实际总价(元)
*/
export function calcGoodsRealAmount(
goodsList: BaseCartItem[],
config: Pick<OrderExtraConfig, 'isMember' | 'memberDiscountRate'>,
activities: ActivityConfig[] = []
): number {
return truncateToTwoDecimals(
goodsList.reduce((total, goods) => {
const availableNum = Math.max(0, goods.number - (goods.returnNum || 0));
if (availableNum <= 0) return total;
// 匹配商品参与的营销活动(优先商品自身配置,无则匹配全局活动)
const activity = goods.activityInfo
?? activities.find(act => (act.applicableProductIds || []).includes(String(goods.id)));
const realPrice = calcSingleGoodsRealPrice(goods, { ...config, activity });
return total + realPrice * availableNum;
}, 0)
);
}
/**
* 计算商品折扣总金额(商品原价总和 - 商品实际总价)
* @param goodsOriginalAmount 商品原价总和
* @param goodsRealAmount 商品实际总价
* @returns 商品折扣总金额≥0
*/
export function calcGoodsDiscountAmount(
goodsOriginalAmount: number,
goodsRealAmount: number
): number {
return truncateToTwoDecimals(Math.max(0, goodsOriginalAmount - goodsRealAmount));
}
// ============================ 4. 优惠券抵扣计算(策略模式,易扩展) ============================
/** 优惠券计算策略接口(新增优惠券类型时,实现此接口即可) */
interface CouponCalculationStrategy {
/**
* 计算优惠券抵扣金额
* @param coupon 优惠券信息
* @param goodsList 商品列表
* @param config 订单配置(门店、会员、活动等)
* @returns 抵扣金额 + 额外信息如排除的商品ID
*/
calculate(
coupon: Coupon,
goodsList: BaseCartItem[],
config: Pick<OrderExtraConfig, 'currentStoreId' | 'isMember' | 'memberDiscountRate'> & {
activities: ActivityConfig[]; // 营销活动列表
cartOrder: Record<string, number>; // 商品加入购物车顺序
goodsList?: BaseCartItem[]; // 可选的过滤后商品列表(仅兑换券策略用)
}
): {
deductionAmount: number; // 抵扣金额(元)
excludedProductIds: string[]; // 需排除的商品ID如兑换券抵扣的商品
};
}
/** 满减券计算策略 */
class FullReductionStrategy implements CouponCalculationStrategy {
calculate(
coupon: FullReductionCoupon,
goodsList: BaseCartItem[],
config: any
): { deductionAmount: number; excludedProductIds: string[] } {
// 1. 验证基础条件(适用门店、可用状态)
if (!coupon.available || !coupon.applicableStoreIds.includes(config.currentStoreId)) {
return { deductionAmount: 0, excludedProductIds: [] };
}
// 2. 计算优惠券门槛(排除临时菜、赠菜)
const eligibleGoods = filterCouponEligibleGoods(goodsList);
const eligibleAmount = calcGoodsRealAmount(eligibleGoods, {
isMember: config.isMember,
memberDiscountRate: config.memberDiscountRate
}, config.activities);
// 3. 满足门槛则抵扣否则0
if (eligibleAmount < coupon.fullAmount) {
return { deductionAmount: 0, excludedProductIds: [] };
}
// 4. 计算实际抵扣金额(不超过最大减免)
const maxReduction = coupon.maxReductionAmount ?? coupon.reductionAmount;
const deductionAmount = truncateToTwoDecimals(Math.min(coupon.reductionAmount, maxReduction));
return { deductionAmount, excludedProductIds: [] };
}
}
/** 折扣券计算策略 */
class DiscountStrategy implements CouponCalculationStrategy {
calculate(
coupon: DiscountCoupon,
goodsList: BaseCartItem[],
config: any
): { deductionAmount: number; excludedProductIds: string[] } {
// 1. 验证基础条件
if (!coupon.available || !coupon.applicableStoreIds.includes(config.currentStoreId)) {
return { deductionAmount: 0, excludedProductIds: [] };
}
// 2. 计算可折扣金额(排除临时菜、赠菜)
const eligibleGoods = filterCouponEligibleGoods(goodsList);
const eligibleAmount = calcGoodsRealAmount(eligibleGoods, {
isMember: config.isMember,
memberDiscountRate: config.memberDiscountRate
}, config.activities);
// 3. 计算折扣金额(不超过最大减免)
const discountAmount = truncateToTwoDecimals(eligibleAmount * (1 - coupon.discountRate));
const deductionAmount = Math.min(discountAmount, coupon.maxReductionAmount);
return { deductionAmount: truncateToTwoDecimals(deductionAmount), excludedProductIds: [] };
}
}
/** 第二件半价券计算策略 */
class SecondHalfPriceStrategy implements CouponCalculationStrategy {
calculate(
coupon: SecondHalfPriceCoupon,
goodsList: BaseCartItem[],
config: any
): { deductionAmount: number; excludedProductIds: string[] } {
// 1. 验证基础条件
if (!coupon.available || !coupon.applicableStoreIds.includes(config.currentStoreId)) {
return { deductionAmount: 0, excludedProductIds: [] };
}
let totalDeduction = 0;
// 2. 遍历适用商品,计算每类商品的优惠
for (const productId of coupon.applicableProductIds) {
const goods = goodsList.find(g => String(g.id) === productId && !isGiftGoods(g) && !isTemporaryGoods(g));
if (!goods) continue;
const availableNum = Math.max(0, goods.number - (goods.returnNum || 0));
if (availableNum < 2) continue; // 至少2件才享受优惠
// 3. 计算单类商品优惠每2件减免0.5件的实际价超过2件部分按原价
const realPrice = calcSingleGoodsRealPrice(goods, {
isMember: config.isMember,
memberDiscountRate: config.memberDiscountRate,
activity: config.activities.find((act: { applicableProductIds: string | string[]; }) => act.applicableProductIds.includes(productId))
});
const discountCount = Math.floor(availableNum / 2); // 优惠次数每2件1次
totalDeduction += realPrice * 0.5 * discountCount;
}
return {
deductionAmount: truncateToTwoDecimals(totalDeduction),
excludedProductIds: []
};
}
}
/** 买一送一券计算策略 */
class BuyOneGetOneStrategy implements CouponCalculationStrategy {
calculate(
coupon: BuyOneGetOneCoupon,
goodsList: BaseCartItem[],
config: any
): { deductionAmount: number; excludedProductIds: string[] } {
// 1. 验证基础条件
if (!coupon.available || !coupon.applicableStoreIds.includes(config.currentStoreId)) {
return { deductionAmount: 0, excludedProductIds: [] };
}
let totalDeduction = 0;
// 2. 遍历适用商品,计算每类商品的优惠
for (const productId of coupon.applicableProductIds) {
const goods = goodsList.find(g => String(g.id) === productId && !isGiftGoods(g) && !isTemporaryGoods(g));
if (!goods) continue;
const availableNum = Math.max(0, goods.number - (goods.returnNum || 0));
if (availableNum < 2) continue; // 至少2件才享受优惠
// 3. 计算单类商品优惠每2件减免1件的实际价
const realPrice = calcSingleGoodsRealPrice(goods, {
isMember: config.isMember,
memberDiscountRate: config.memberDiscountRate,
activity: config.activities.find((act: { applicableProductIds: string | string[]; }) => act.applicableProductIds.includes(productId))
});
const discountCount = Math.floor(availableNum / 2); // 优惠次数
totalDeduction += realPrice * 1 * discountCount;
}
return {
deductionAmount: truncateToTwoDecimals(totalDeduction),
excludedProductIds: []
};
}
}
/** 商品兑换券计算策略 */
class ExchangeCouponStrategy implements CouponCalculationStrategy {
calculate(
coupon: ExchangeCoupon,
goodsList: BaseCartItem[],
config: any
): { deductionAmount: number; excludedProductIds: string[] } {
// 1. 验证基础条件
if (!coupon.available || !coupon.applicableStoreIds.includes(config.currentStoreId)) {
return { deductionAmount: 0, excludedProductIds: [] };
}
// 2. 筛选适用商品(排除临时菜、赠菜)
const eligibleGoods = goodsList.filter(goods =>
coupon.applicableProductIds.includes(String(goods.id))
&& !isTemporaryGoods(goods)
&& !isGiftGoods(goods)
);
if (eligibleGoods.length === 0) {
return { deductionAmount: 0, excludedProductIds: [] };
}
// 3. 按规则排序商品
const sortedGoods = sortGoodsForCoupon(eligibleGoods, coupon.sortRule, config.cartOrder);
// 4. 计算抵扣金额(按可抵扣件数,累计商品实际价)
let remainingCount = coupon.deductCount;
let totalDeduction = 0;
const excludedProductIds: string[] = [];
for (const goods of sortedGoods) {
if (remainingCount <= 0) break;
const availableNum = Math.max(0, goods.number - (goods.returnNum || 0));
if (availableNum === 0) continue;
// 计算当前商品可抵扣的件数
const deductCount = Math.min(availableNum, remainingCount);
const realPrice = calcSingleGoodsRealPrice(goods, {
isMember: config.isMember,
memberDiscountRate: config.memberDiscountRate,
activity: config.activities.find((act: { applicableProductIds: string | string[]; }) => act.applicableProductIds.includes(String(goods.id)))
});
// 累计抵扣金额和排除商品ID
totalDeduction += realPrice * deductCount;
excludedProductIds.push(String(goods.id));
remainingCount -= deductCount;
}
return {
deductionAmount: truncateToTwoDecimals(totalDeduction),
excludedProductIds
};
}
}
/**
* 优惠券计算策略工厂(根据优惠券类型获取对应策略,易扩展)
* @param couponType 优惠券类型
* @returns 对应的计算策略实例
*/
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}`);
}
}
/**
* 计算优惠券抵扣金额(处理互斥逻辑,选择最优优惠券)
* @param coupons 用户选择的优惠券列表(可能多选,需处理互斥)
* @param goodsList 商品列表
* @param config 订单配置
* @returns 最优优惠券的抵扣结果
*/
export function calcCouponDeduction(
coupons: Coupon[],
goodsList: BaseCartItem[],
config: Pick<OrderExtraConfig, 'currentStoreId' | 'isMember' | 'memberDiscountRate'> & {
activities: ActivityConfig[];
cartOrder: Record<string, number>; // 商品加入购物车顺序(用于兑换券排序)
}
): {
deductionAmount: number;
usedCoupon?: Coupon;
excludedProductIds: string[];
} {
if (coupons.length === 0) {
return { deductionAmount: 0, excludedProductIds: [] };
}
// 1. 处理优惠券互斥逻辑(满减/折扣/第二件半价/买一送一 互斥;兑换券可单独用或同享)
const exchangeCoupons = coupons.filter(c => c.type === CouponType.EXCHANGE);
const nonExchangeCoupons = coupons.filter(c => c.type !== CouponType.EXCHANGE);
// 2. 计算非兑换券的最优抵扣(互斥,选最大)
let nonExchangeResult:CouponResult = {
deductionAmount: 0,
excludedProductIds: [],
usedCoupon: undefined
};
if (nonExchangeCoupons.length > 0) {
nonExchangeResult = nonExchangeCoupons.reduce((best, coupon) => {
const strategy = getCouponStrategy(coupon.type);
// 确保 calculate 返回的 result 包含 deductionAmount 和 excludedProductIds
const result: Omit<CouponResult, 'usedCoupon'> = strategy.calculate(coupon, goodsList, config);
// 合并结果,补充 usedCoupon
const currentResult: CouponResult = {
...result,
usedCoupon: coupon
};
// 比较抵扣金额,返回更优结果
return currentResult.deductionAmount > best.deductionAmount
? currentResult
: best;
}, nonExchangeResult); // 初始值类型现在与回调返回值一致
}
// 3. 计算兑换券抵扣(可与非兑换券同享,需排除兑换券已抵扣的商品)
let exchangeResult:ExchangeCalculationResult = { deductionAmount: 0, excludedProductIds: [] };
if (exchangeCoupons.length > 0) {
exchangeResult = exchangeCoupons.reduce((best, coupon) => {
const strategy = getCouponStrategy(coupon.type);
const result = strategy.calculate(coupon, goodsList, {
...config,
// 排除已抵扣的商品
goodsList: goodsList.filter(g => !best.excludedProductIds.includes(String(g.id)))
});
return result.deductionAmount > best.deductionAmount ? result : best;
}, exchangeResult);
}
// 4. 汇总结果(兑换券可与非兑换券同享,总抵扣=两者之和)
return {
deductionAmount: truncateToTwoDecimals(nonExchangeResult.deductionAmount + exchangeResult.deductionAmount),
usedCoupon: nonExchangeResult.usedCoupon,
excludedProductIds: [...nonExchangeResult.excludedProductIds, ...exchangeResult.excludedProductIds]
};
}
// ============================ 5. 其他费用计算(打包费、餐位费、积分抵扣) ============================
/**
* 计算总打包费赠菜也计算称重商品打包数量≤1
* @param goodsList 商品列表
* @param dinnerType 就餐类型堂食dine-in/外卖take-out
* @returns 总打包费(元)
*/
export function calcTotalPackFee(
goodsList: BaseCartItem[],
dinnerType: 'dine-in' | 'take-out'
): number {
return truncateToTwoDecimals(
goodsList.reduce((total, goods) => {
const availableNum = Math.max(0, goods.number - (goods.returnNum || 0));
if (availableNum === 0) return total;
// 计算单个商品打包数量外卖全打包堂食按配置称重商品≤1
let packNum = dinnerType === 'take-out'
? availableNum
: (goods.packNumber || 0);
if (goods.productType === GoodsType.WEIGHT) {
packNum = Math.min(packNum, 1);
}
return total + (goods.packFee || 0) * packNum;
}, 0)
);
}
/**
* 计算餐位费(按人数,不参与营销活动)
* @param config 餐位费配置
* @returns 餐位费未启用则0
*/
export function calcSeatFee(config: SeatFeeConfig): number {
if (!config.isEnabled) return 0;
const personCount = Math.max(1, config.personCount); // 至少1人
return truncateToTwoDecimals(config.pricePerPerson * personCount);
}
/**
* 计算积分抵扣金额按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 };
}
// 最大可抵扣金额(积分可抵金额 vs 规则最大 vs 订单上限)
const maxDeductByPoints = truncateToTwoDecimals(userPoints / rule.pointsPerYuan);
const maxDeductAmount = Math.min(
maxDeductByPoints,
rule.maxDeductionAmount ?? Infinity,
maxDeductionLimit
);
// 实际使用积分 = 抵扣金额 * 积分兑换比例
const usedPoints = truncateToTwoDecimals(maxDeductAmount * rule.pointsPerYuan);
return {
deductionAmount: maxDeductAmount,
usedPoints: Math.min(usedPoints, userPoints) // 避免积分超扣
};
}
// ============================ 6. 订单总费用汇总与实付金额计算(最终入口) ============================
/**
* 计算订单所有费用子项并汇总(核心入口函数)
* @param goodsList 购物车商品列表
* @param dinnerType 就餐类型
* @param coupons 用户选择的优惠券列表
* @param activities 全局营销活动列表
* @param config 订单额外配置(会员、积分、餐位费等)
* @param cartOrder 商品加入购物车顺序key=商品IDvalue=时间戳)
* @returns 订单费用汇总(含所有子项和实付金额)
*/
export function calculateOrderCostSummary(
goodsList: BaseCartItem[],
dinnerType: 'dine-in' | 'take-out',
coupons: Coupon[] = [],
activities: ActivityConfig[] = [],
config: OrderExtraConfig,
cartOrder: Record<string, number> = {}
): 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 } = calcCouponDeduction(
coupons,
goodsList,
{
currentStoreId: config.currentStoreId,
isMember: config.isMember,
memberDiscountRate: config.memberDiscountRate,
activities,
cartOrder
}
);
// 3. 其他固定费用:打包费、餐位费
const packFee = calcTotalPackFee(goodsList, dinnerType);
const seatFee = calcSeatFee(config.seatFeeConfig);
// 4. 积分抵扣(最大抵扣上限=商品实际价-优惠券抵扣,避免负金额)
const maxPointDeductionLimit = Math.max(0, goodsRealAmount - couponDeductionAmount);
const { deductionAmount: pointDeductionAmount, usedPoints } = calcPointDeduction(
config.userPoints,
config.pointDeductionRule,
maxPointDeductionLimit
);
// 5. 商家减免和附加费
const merchantReductionAmount = Math.max(0, config.merchantReduction); // 减免不能为负
const additionalFee = Math.max(0, config.additionalFee); // 附加费不能为负
// 6. 计算最终实付金额(按用户公式:实付=原价-折扣-优惠券-积分+餐位费+打包费-商家减免+附加费)
const finalPayAmount = truncateToTwoDecimals(
goodsOriginalAmount
- goodsDiscountAmount
- couponDeductionAmount
- pointDeductionAmount
+ seatFee
+ packFee
- merchantReductionAmount
+ additionalFee
);
// 确保实付金额≥0
const finalPayAmountNonNegative = Math.max(0, finalPayAmount);
// 返回完整费用汇总
return {
goodsOriginalAmount,
goodsDiscountAmount,
couponDeductionAmount,
pointDeductionAmount,
seatFee,
packFee,
merchantReductionAmount,
additionalFee,
finalPayAmount: finalPayAmountNonNegative,
couponUsed: usedCoupon,
pointUsed: usedPoints
};
}
// ============================ 7. 对外暴露工具库(按模块组织,方便调用) ============================
export const OrderPriceCalculator = {
// 基础工具
truncateToTwoDecimals,
isTemporaryGoods,
isGiftGoods,
// 商品价格计算
calcGoodsOriginalAmount,
calcGoodsRealAmount,
calcGoodsDiscountAmount,
// 优惠券计算
calcCouponDeduction,
// 其他费用计算
calcTotalPackFee,
calcSeatFee,
calcPointDeduction,
// 核心入口
calculateOrderCostSummary,
// 类型导出(方便外部使用类型定义)
Types: {
GoodsType,
CouponType,
ActivityType,
}
};
export default OrderPriceCalculator;

View File

@@ -2,8 +2,11 @@ import { BigNumber } from "bignumber.js";
import _ from "lodash";
import {
ShopInfo,couponCalcParams,
BaseCartItem,TimeLimitDiscountConfig,CanDikouGoodsArrArgs,
ShopInfo,
couponCalcParams,
BaseCartItem,
TimeLimitDiscountConfig,
CanDikouGoodsArrArgs,
Coupon,
ShopUserInfo,
GoodsType,
@@ -13,10 +16,6 @@ import {
OrderCostSummary,
} from "./types";
/**
* 返回商品单价
* @param goods 商品
@@ -41,11 +40,18 @@ export function returnGoodsPrice(
shopInfo &&
shopInfo.isMemberPrice;
// 商家改价
if (goods.discount_sale_amount&&goods.discount_sale_amount * 1 > 0) {
if (goods.discount_sale_amount && goods.discount_sale_amount * 1 > 0) {
return goods.salePrice;
}
// 限时折扣
if (limitTimeDiscount && limitTimeDiscount.id) {
//优先使用
if (goods.isTimeDiscount || goods.is_time_discount) {
return new BigNumber(goods.salePrice)
.times(limitTimeDiscount.discountRate / 100)
.decimalPlaces(2, BigNumber.ROUND_UP)
.toNumber();
}
const canUseFoods = limitTimeDiscount.foods.split(",");
const canUseLimit =
limitTimeDiscount.foodType == 1 ||
@@ -149,13 +155,13 @@ export function returnCanDikouGoodsArr(args: CanDikouGoodsArrArgs) {
// 根据优惠券类型判断扣减数量
if ([4, 6].includes(findCart.couponType ?? 0)) {
// 类型4第二件半价或6买一送一数量减2
if(v.num){
if (v.num) {
v.num -= 2;
}
} else {
// 其他类型如类型2商品券按原逻辑扣减对应数量
if(v.num){
v.num -= findCart.num??0;
if (v.num) {
v.num -= findCart.num ?? 0;
}
}
}
@@ -163,7 +169,7 @@ export function returnCanDikouGoodsArr(args: CanDikouGoodsArrArgs) {
})
.filter((v) => {
const canUseNum = (v.num ?? 0) - (v.returnNum || 0);
if (canUseNum <= 0 || v.is_temporary || v.is_gift ) {
if (canUseNum <= 0 || v.is_temporary || v.is_gift) {
return false;
}
@@ -208,8 +214,6 @@ function returnCanCalcGoodsList(
user: ShopUserInfo
) {
return canCalcGoodsArr.filter((goods) => {
console.log("goods");
console.log(goods);
if (
!coupon.discountShare &&
(goods.is_time_discount || goods.isTimeDiscount)
@@ -290,11 +294,10 @@ export function returnCouponCanUse(args: couponCalcParams) {
shopInfo,
user
);
console.log("canCalcGoodsArr");
console.log(canCalcGoodsArr);
fullAmount = canCalcGoodsArr.reduce((pre, cur) => {
return (
pre + returnGoodsPrice(cur, user, shopInfo, limitTimeDiscount) * (cur.num||0)
pre +
returnGoodsPrice(cur, user, shopInfo, limitTimeDiscount) * (cur.num || 0)
);
}, 0);
@@ -443,7 +446,7 @@ export function calcDiscountGoodsArrPrice(
}
const goods = discountGoodsArr[i];
const shengyuNum = discountNum - hasCountNum;
const num = Math.min((goods.num || 0), shengyuNum);
const num = Math.min(goods.num || 0, shengyuNum);
const realPrice = returnGoodsPrice(
goods,
user,
@@ -568,7 +571,7 @@ export function returnCouponZhekouDiscount(
// 计算优惠比例:(100 - 折扣率) / 100
const discountAmountRatio = new BigNumber(100)
.minus(discountRate||0)
.minus(discountRate || 0)
.dividedBy(100);
// 计算折扣金额:调整后的商品订单金额 × 优惠比例
@@ -637,8 +640,8 @@ export function returnCouponProductDiscount(
return result;
}
// 返回买一送一券抵扣详情
/**
* 返回买一送一券抵扣详情
* @param canDikouGoodsArr 可抵扣商品列表
* @param coupon 优惠券
* @param user 用户信息
@@ -764,7 +767,7 @@ export function returnCanDikouGoods(
) {
const result = arr
.filter((v) => {
return !v.is_temporary && !v.is_gift ;
return !v.is_temporary && !v.is_gift;
})
.filter((v) => {
return (v.num || 0) > 0;
@@ -777,6 +780,10 @@ export function returnCanDikouGoods(
});
return result;
}
export const utils = {
returnGoodsPrice,
returnGoodsGroupMap,

View File

@@ -117,6 +117,7 @@ export function returnCanUseLimitTimeDiscount(
useVipPrice: boolean,
idKey = "product_id"
) {
goods={...goods,product_id:goods.product_id||goods.productId|| goods.id|| ''}
if (!limitTimeDiscount || !limitTimeDiscount.id) {
return false;
}
@@ -135,8 +136,12 @@ export function returnCanUseLimitTimeDiscount(
return true;
}
if (useVipPrice && goods.hasOwnProperty("memberPrice")) {
if (goods.memberPrice && goods.memberPrice * 1 <= 0) {
if ( goods.memberPrice * 1 <= 0) {
return true;
}else{
return false;
}
}
}
@@ -172,6 +177,7 @@ function returnLimitPrice(
limitTimeDiscount,
useVipPrice
);
if (canuseLimit) {
//可以使用限时折扣
if (limitTimeDiscount.discountPriority == "limit-time") {
@@ -183,6 +189,7 @@ function returnLimitPrice(
return result;
}
if (limitTimeDiscount.discountPriority == "vip-price") {
//会员价优先
if (useVipPrice && goods.memberPrice && goods.memberPrice * 1 > 0) {
//使用会员价
@@ -228,7 +235,7 @@ export function returnCalcPrice(
fullReductionActivitie.discountShare == 1 &&
fullReductionActivitie.vipPriceShare == 1
) {
//与限时折扣同享,与会员价同享
//与限时折扣同享,与会员价同享
return returnLimitPrice(goods, limitTimeDiscount, useVipPrice);
}
if (
@@ -280,7 +287,7 @@ export function calcFullReductionActivityFullAmount(
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) {
if (goods.is_temporary ||goods.isTemporary || goods.is_gift ||goods.isGift || availableNum <= 0) {
//临时菜,赠菜,数量<=0的商品不计算
continue;
}
@@ -389,7 +396,7 @@ export function truncateToTwoDecimals(num: number | string): number {
* @returns 是否临时菜
*/
export function isTemporaryGoods(goods: BaseCartItem): boolean {
return !!goods.is_temporary || !!goods.isTemporary;
return !!goods.is_temporary|| !!goods.isTemporary;
}
/**
@@ -466,8 +473,8 @@ export function filterThresholdGoods(
return applicableProductIds.length === 0
? baseEligibleGoods
: baseEligibleGoods.filter((goods) =>
applicableProductIds.includes(String(goods.product_id))
); // 核心修正用商品ID匹配
applicableProductIds.includes(String(goods.product_id))
); // 核心修正用商品ID匹配
}
/**
@@ -574,7 +581,7 @@ export function calcSingleGoodsRealPrice(
const { isMember, memberDiscountRate, limitTimeDiscount: activity } = config;
//如果是增菜价格为0
if (goods.is_gift || goods.isGift) {
if (goods.is_gift||goods.isGift) {
return 0;
}
@@ -587,15 +594,15 @@ export function calcSingleGoodsRealPrice(
const memberPrice = new BigNumber(
calcMemberPrice(goods, isMember, memberDiscountRate)
);
if (goods.is_time_discount || goods.isTimeDiscount) {
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()
);
}
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) {
@@ -612,13 +619,14 @@ export function calcSingleGoodsRealPrice(
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)
@@ -665,9 +673,9 @@ export function calcGoodsOriginalAmount(goodsList: BaseCartItem[]): number {
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) {
if (goods.is_temporary||goods.isTemporary) {
basePrice = new BigNumber(goods?.discountSaleAmount ?? 0);
} else if (goods.is_gift || goods.isGift) {
} else if (goods.is_gift||goods.isGift) {
basePrice = new BigNumber(0);
} else {
basePrice = new BigNumber(goods.skuData?.salePrice ?? goods.salePrice); // SKU原价优先
@@ -956,8 +964,8 @@ export function calcPointDeduction(
)
? maxDeductByPoints
: new BigNumber(rule.maxDeductionAmount || Infinity).isLessThan(maxLimitBn)
? maxDeductByPoints
: maxLimitBn;
? maxDeductByPoints
: maxLimitBn;
// 实际使用积分 = 抵扣金额 * 积分兑换比例
const usedPoints = maxDeductAmount.multipliedBy(pointsPerYuanBn);
@@ -1064,9 +1072,9 @@ export function calculateOrderCostSummary(
// 2.2 计算满减基数(先扣新客立减)
let baseAfterNewUserDiscount = new BigNumber(
limitTimeDiscount &&
limitTimeDiscount.id &&
usedFullReductionActivity &&
!usedFullReductionActivity.discountShare
limitTimeDiscount.id &&
usedFullReductionActivity &&
!usedFullReductionActivity.discountShare
? goodsRealAmount
: goodsRealAmount
)
@@ -1089,7 +1097,6 @@ export function calculateOrderCostSummary(
seatFee,
packFee
);
usedFullReductionThreshold = selectOptimalThreshold(
usedFullReductionActivity.thresholds,
usedFullReductionActivityFullAmount,
@@ -1097,7 +1104,6 @@ export function calculateOrderCostSummary(
goodsRealAmount,
usedFullReductionActivity.discountShare || 0 // 与限时折扣同享规则
);
// 2.4 计算满减实际减免金额
fullReductionAmount = calcFullReductionAmount(
baseAfterNewUserDiscount,
@@ -1208,13 +1214,13 @@ export function calculateOrderCostSummary(
.plus(packFee)
.isGreaterThan(0)
? new BigNumber(goodsRealAmount)
.minus(newUserDiscount)
.minus(fullReductionAmount)
.minus(couponDeductionAmount)
.minus(pointDeductionAmount)
.plus(seatFee)
.plus(packFee)
.toNumber()
.minus(newUserDiscount)
.minus(fullReductionAmount)
.minus(couponDeductionAmount)
.minus(pointDeductionAmount)
.plus(seatFee)
.plus(packFee)
.toNumber()
: 0;
switch (merchantReductionConfig.type) {

View File

@@ -29,6 +29,9 @@ export function canUseLimitTimeDiscount(
) {
shopInfo = shopInfo || {};
shopUserInfo = shopUserInfo || {};
if(shopInfo.isMemberPrice){
shopUserInfo.isMemberPrice=1
}
if (!limitTimeDiscountRes || !limitTimeDiscountRes.id) {
return false;
}
@@ -45,17 +48,15 @@ export function canUseLimitTimeDiscount(
return true;
}
if (limitTimeDiscountRes.discountPriority == "vip-price") {
if (shopUserInfo.isVip != 1 || shopUserInfo.isMemberPrice != 1) {
return true;
}
if (
shopUserInfo.isVip == 1 &&
shopUserInfo.isMemberPrice == 1 &&
goods.memberPrice * 1 <= 0
goods.memberPrice * 1 > 0
) {
return true;
return false;
}
return true;
}
return false;
@@ -138,7 +139,6 @@ export function returnPrice(args: returnPriceArgs) {
return memberPrice;
}
} else {
// console.log('不是会员或者没有启用会员价',goods,limitTimeDiscountRes);
//不是会员或者没有启用会员价
if (limitTimeDiscountRes && limitTimeDiscountRes.id && includesGoods) {
const price = returnLimitPrice({

0
src/lib/socket.ts Normal file
View File

View File

@@ -286,6 +286,7 @@ export interface ShopUserInfo {
isVip: number | null; //是否会员
discount: number | null; //用户折扣
isMemberPrice: number | null; //会员折扣与会员价是否同时使用
id?: number; //用户ID
}
/** 订单额外费用配置 */
export interface OrderExtraConfig {

View File

@@ -119,14 +119,16 @@
</div>
</div>
<div v-else>
<div v-if="useVipPrice && vipAllPrice * 1 != allPrice * 1">
<div v-if="useVipPrice && vipAllPrice * 1 != allPrice * 1 && item.lowMemberPrice <= 0">
¥{{ to2(vipAllPrice) }}
</div>
<div :class="{
'free-price': useVipPrice && vipAllPrice != allPrice,
}">
<span class="onderline" v-if="cartStore.useVipPrice && item.memberPrice !== item.salePrice">¥{{
to2(item.salePrice) }}</span>
<span class="onderline"
v-if="cartStore.useVipPrice && item.memberPrice !== item.salePrice && item.lowMemberPrice > 0">
¥{{ to2(item.salePrice) }}
</span>
<span>¥{{ to2(allPrice) }}</span>
</div>
</div>

View File

@@ -15,7 +15,7 @@
<span class="sale_price">{{ item.time_discount_price }}</span>
</div>
<div class="limit_wrap" v-else>
<template v-if="cartStore.useVipPrice && item.lowPrice !== item.lowMemberPrice">
<template v-if="cartStore.useVipPrice && item.lowMemberPrice > 0">
<span class="o_price">{{ item.lowPrice }}</span>
<span>{{ item.lowMemberPrice }}</span>
</template>

View File

@@ -68,7 +68,7 @@
<div class="u-flex u-col-center u-m-t-20 no-wrap">
<span class="u-font-14 font-bold u-m-r-20">优惠券</span>
<div
v-if="carts.orderCostSummary.fullReduction.usedThreshold !== undefined && carts.orderCostSummary.fullReduction.usedThreshold.activityId"
v-if="carts.orderCostSummary.fullReduction !== undefined && carts.orderCostSummary.fullReduction.actualAmount > 0"
style="font-size: 14px;color: #555;">参与满减活动不可用优惠券</div>
<div class="u-flex my-select" @click="openCoupon" v-else>
<span class="u-m-r-10">选择优惠券</span>