diff --git a/App.vue b/App.vue index af912d8..0c1c10b 100644 --- a/App.vue +++ b/App.vue @@ -160,6 +160,8 @@ App.vue本身不是页面,这里不能编写视图元素,也就是没有 \ No newline at end of file diff --git a/commons/utils/rsaEncrypt.js b/commons/utils/rsaEncrypt.js index 1948ddd..5cb655f 100644 --- a/commons/utils/rsaEncrypt.js +++ b/commons/utils/rsaEncrypt.js @@ -1,14 +1,5 @@ -import JSEncrypt from 'jsencrypt/bin/jsencrypt.min' - -// 密钥对生成 http://web.chacuo.net/netrsakeypair - -const publicKey = 'MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANL378k3RiZHWx5AfJqdH9xRNBmD9wGD\n' + - '2iRe41HdTNF8RUhNnHit5NpMNtGL0NPTSSpPjjI1kJfVorRvaQerUgkCAwEAAQ==' - -// 加密 export function encrypt(txt) { - const encryptor = new JSEncrypt() - encryptor.setPublicKey(publicKey) // 设置公钥 - return encryptor.encrypt(txt) // 对需要加密的数据进行加密 + + return txt } diff --git a/pageMarket/discountActivity/components/hour-area.vue b/components/my-components/my-hour-area.vue similarity index 100% rename from pageMarket/discountActivity/components/hour-area.vue rename to components/my-components/my-hour-area.vue diff --git a/pageMarket/discountActivity/components/time-area.vue b/components/my-components/my-time-area.vue similarity index 100% rename from pageMarket/discountActivity/components/time-area.vue rename to components/my-components/my-time-area.vue diff --git a/pageMarket/discountActivity/components/week-sel.vue b/components/my-components/my-week-sel.vue similarity index 100% rename from pageMarket/discountActivity/components/week-sel.vue rename to components/my-components/my-week-sel.vue diff --git a/http/api/market/consumeDiscount.js b/http/api/market/consumeDiscount.js new file mode 100644 index 0000000..883151c --- /dev/null +++ b/http/api/market/consumeDiscount.js @@ -0,0 +1,17 @@ +import http from "@/http/http.js"; +const request = http.request; +const urlType = "market"; + +/** + * 新客立减 + * @returns + */ +export function getDiscountByUserId(params) { + return request({ + url: marketUrl + `/admin/consumeDiscount/getDiscountByUserId`, + method: "get", + params: { + ...params, + }, + }); +} diff --git a/http/api/market/discountActivity.js b/http/api/market/discountActivity.js index 4d8b410..35434c2 100644 --- a/http/api/market/discountActivity.js +++ b/http/api/market/discountActivity.js @@ -38,3 +38,18 @@ export function del(id) { }) } +/** + * 满减活动 + * @returns + */ +export function discountActivity(params) { + return request({ + url: urlType+`/admin/discountActivity`, + method: 'get', + params: { + ...params + } + }) +} + + diff --git a/http/api/market/limitTimeDiscount.js b/http/api/market/limitTimeDiscount.js new file mode 100644 index 0000000..54ed613 --- /dev/null +++ b/http/api/market/limitTimeDiscount.js @@ -0,0 +1,17 @@ +import http from "@/http/http.js"; +const request = http.request; +const urlType = "market"; + +/** + * 限时折扣 + * @returns + */ +export function limitTimeDiscount(params) { + return request({ + url: urlType + `/admin/limitTimeDiscount`, + method: "get", + params: { + ...params, + }, + }); +} diff --git a/http/api/pay.js b/http/api/pay.js index 658c643..2283a4e 100644 --- a/http/api/pay.js +++ b/http/api/pay.js @@ -8,7 +8,7 @@ const request = http.request export function getOrderPayUrl(data, urlType = 'order') { return request({ url: `${urlType}/pay/shopPayApi/orderPayUrl`, - method: "GET", + method: "POST", data: { ...data } diff --git a/http/api/table.js b/http/api/table.js index fcbc313..194a4de 100644 --- a/http/api/table.js +++ b/http/api/table.js @@ -21,7 +21,7 @@ export function getShopTable(data, urlType = 'account') { */ export function getShopTableDetail(data, urlType = 'account') { return request({ - url: `${urlType}/admin/shopTable/detail`, + url: `/account/admin/shopTable/detail`, method: "GET", data: { ...data diff --git a/http/http.js b/http/http.js index 017b47a..2837d66 100644 --- a/http/http.js +++ b/http/http.js @@ -15,6 +15,7 @@ import infoBox from "@/commons/utils/infoBox.js" import go from '@/commons/utils/go.js'; import { reject } from 'lodash'; // 设置node环境 +// envConfig.changeEnv(storageManage.env('production')) envConfig.changeEnv(storageManage.env('development')) // 测试服 diff --git a/http/yskApi/Instead.js b/http/yskApi/Instead.js new file mode 100644 index 0000000..bb60fd4 --- /dev/null +++ b/http/yskApi/Instead.js @@ -0,0 +1,415 @@ +// 代课下单 +import http from '@/http/yskApi/http.js' + +import { accountUrl, marketUrl } from './prveUrl.js' + +const request = http.request + + +//就餐形式,默认堂食后付费 +const useType = 'dine-in-after' + +function getUseType() { + const type = uni.getStorageSync("useType") + return type ? type : useType +} +// import { +// webscoketUtill +// } from '../websock.js' +// let wxObj = null +// wx初始化购物车 +// export function getWXCart(params) { +// let wxUrl = 'ws://192.168.1.31:2348/?' + objectToString(params) +// wxObj = new webscoketUtill(wxUrl,3000,9,(e)=>{ +// console.log('收到消息'); +// console.log(e); +// }) +// return uni.getStorageSync('wxList') +// } +// 新增\删除\修改到购物车 +// export function addWXCart(params) { +// wxObj.ws.send(params) +// } + + +function objectToString(obj) { + let result = ''; + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + result += `${key}=${obj[key]}&`; + } + } + // 去掉最后一个多余的 & + return result.slice(0, -1); +} +/** + * 获取当前台桌订单信息 + * @returns + */ +export function getCart(params) { + return request({ + url: `/api/place/cart`, + method: "get", + params: { + shopId: uni.getStorageSync("shopId"), + useType: getUseType(), + ...params + } + }); +} +/** + * 已上架商品列表 + * @returns + */ +export function getGoodsLists(params, showLoading = true) { + return request({ + url: `/product/admin/product/list`, + method: "get", + params: { + shopId: uni.getStorageSync("shopId"), + ...params + }, + showLoading + }); +} + +/** + * 点单 + * @returns + */ +export function addCart(data) { + return request({ + url: `/api/place/addCart`, + method: "post", + data: { + shopId: uni.getStorageSync("shopId"), + useType: getUseType(), + ...data + } + }); +} + +/** + * 清空购物车/支付订单 + * @returns + */ +export function $clearCart(data) { + return request({ + url: `/api/place/clearCart`, + method: "delete", + data: { + shopId: uni.getStorageSync("shopId"), + useType: getUseType(), + ...data + } + }); +} + +/** + * 删除购物车某个商品 + * @returns + */ +export function $removeCart(data) { + return request({ + url: `/api/place/removeCart`, + method: "delete", + data: { + shopId: uni.getStorageSync("shopId"), + useType: getUseType(), + ...data + } + }); +} +/** + * 更新规格 + * @returns + */ +export function $updateCart(data) { + return request({ + url: `/api/place/updateCart`, + method: "put", + data: { + shopId: uni.getStorageSync("shopId"), + ...data + } + }); +} +/** + * 批量打包 + * @returns + */ +export function $allPack(data) { + return request({ + url: `/api/place/pack`, + method: "put", + data: { + shopId: uni.getStorageSync("shopId"), + ...data + } + }); +} +/** + * 获取取餐号 + * @returns + */ +export function $getMasterId(data) { + return request({ + url: `/api/place/masterId`, + method: "get", + params: { + shopId: uni.getStorageSync("shopId"), + useType: getUseType(), + ...data + } + }); +} +/** + * 支付方式获取 + * @returns + */ +export function $getPayType(data) { + return request({ + url: `/account/admin/payType`, + method: "get", + params: { + shopId: uni.getStorageSync("shopId"), + ...data + } + }); +} +/** + * 创建订单 + * @returns + */ +export function $createOrder(data) { + return request({ + url: `/api/place/order`, + method: "post", + data: { + shopId: uni.getStorageSync("shopId"), + useType: getUseType(), + ...data + } + }); +} + + +/** + * 挂起订单 + * @returns + */ +export function $cacheOrder(data) { + return request({ + url: `/api/place/pending`, + method: "post", + data: { + shopId: uni.getStorageSync("shopId"), + useType: getUseType(), + ...data + } + }); +} + +/** + * 获取已挂起订单 + * @returns + */ +export function $getCacheOrder(data) { + return request({ + url: `/api/place/pending/cart`, + method: "get", + params: { + shopId: uni.getStorageSync("shopId"), + useType: getUseType(), + ...data + } + }); +} + +// 会员点单/取消会员点单 +export function $setUser(data) { + return request({ + url: `/api/place/updateVip`, + method: "put", + data: { + shopId: uni.getStorageSync("shopId"), + ...data + } + }); +} +// 删除订单 +export function $delOrder(data) { + return request({ + url: `/api/place/order`, + method: "delete", + data: { + shopId: uni.getStorageSync("shopId"), + ...data + } + }); +} +// 支付订单 +export function $payOrder(data) { + return request({ + url: '/api/place/pay', + method: "put", + data: { + shopId: uni.getStorageSync("shopId"), + ...data + } + }); +} +//退单 + +export function $returnCart(data) { + return request({ + url: '/api/place/returnCart', + method: "put", + data: { + shopId: uni.getStorageSync("shopId"), + ...data + } + }); +} +// 选择台桌 +export function $choseTable(data) { + return request({ + url: '/api/place/choseTable', + method: "put", + data: { + shopId: uni.getStorageSync("shopId"), + ...data + } + }); +} +// 用餐人数 + +export function $choseCount(data) { + return request({ + url: '/api/place/choseCount', + method: "put", + data: { + shopId: uni.getStorageSync("shopId"), + useType: getUseType(), + ...data + } + }); +} + +// 批量生成台桌 +export function $fastCreateTable(data) { + return request({ + url: '/api/tbShopTable/generate', + method: "post", + data: { + shopId: uni.getStorageSync("shopId"), + ...data + } + }); +} + +//打印当前台桌订单 +export function $printOrder(data) { + return request({ + url: '/api/place/printOrder', + method: "post", + data: { + shopId: uni.getStorageSync("shopId"), + ...data + } + }); +} +//打印当前台桌菜品 + +export function $printDishes(data) { + return request({ + url: '/api/place/printDishes', + method: "post", + data: { + shopId: uni.getStorageSync("shopId"), + ...data + } + }); +} + +// 就餐模式切换 +export function $changeUseType(data) { + return request({ + url: '/api/place/choseModel', + method: "put", + data: { + shopId: uni.getStorageSync("shopId"), + ...data + } + }); +} + +// 退款 +export function $returnOrder(data) { + return request({ + url: '/api/place/returnOrder', + method: "post", + data: { + shopId: uni.getStorageSync("shopId"), + ...data + } + }); +} + +//获取用户可用优惠券 +export function $findCoupon(data) { + return request({ + url: marketUrl+'/admin/coupon/findCoupon', + method: "get", + params: { + shopId: uni.getStorageSync("shopId"), + ...data + } + }); +} + +//会员积分列表 +export function $returnMemberPointsList(data) { + return request({ + url: '/api/points/member-points/page', + method: "get", + params: { + shopId: uni.getStorageSync("shopId"), + ...data + } + }); +} + +// 会员积分账户信息 +export function $returnMemberPoints(memberId) { + return request({ + url: '/api/points/member-points/' + memberId, + method: "get", + params: { + shopId: uni.getStorageSync("shopId"), + ...data + } + }); +} +//002-获取订单可用积分及抵扣金额(支付页面使用) +export function $calcUsablePoints(data) { + return request({ + url: '/api/points/member-points/calc-usable-points', + method: "get", + params: { + shopId: uni.getStorageSync("shopId"), + ...data + } + }); +} +// 003-根据积分计算可抵扣金额 +export function $calcDeDuctionPoints(data) { + return request({ + url: '/api/points/member-points/calc-deduction-amount', + method: "get", + params: { + shopId: uni.getStorageSync("shopId"), + ...data + } + }); +} \ No newline at end of file diff --git a/http/yskApi/http.js b/http/yskApi/http.js new file mode 100644 index 0000000..2837d66 --- /dev/null +++ b/http/yskApi/http.js @@ -0,0 +1,235 @@ +/** + * HTTP的封装, 基于uni.request + * 包括: 通用响应结果的处理 和 业务的增删改查函数 + * + * @author terrfly + * @site https://www.jeequan.com + * @date 2021/12/16 18:35 + */ +// 设置env配置文件 +import envConfig from '@/env/config.js' +// 导入全局属性 +import appConfig from '@/config/appConfig.js' +import storageManage from '@/commons/utils/storageManage.js' +import infoBox from "@/commons/utils/infoBox.js" +import go from '@/commons/utils/go.js'; +import { reject } from 'lodash'; +// 设置node环境 +// envConfig.changeEnv(storageManage.env('production')) +envConfig.changeEnv(storageManage.env('development')) + +// 测试服 +// #ifdef H5 +let baseUrl = '/api/' +// #endif +// #ifndef H5 +// let baseUrl = 'https://tapi.cashier.sxczgkj.cn/' +//预发布 +// let baseUrl = 'https://pre-cashieradmin.sxczgkj.cn' + +//正式 +// let baseUrl = 'https://cashier.sxczgkj.com/' +let baseUrl = appConfig.env.JEEPAY_BASE_URL +// #endif + +const loadingShowTime = 200 + +function getHeader(){ + const headerObject={} + headerObject["token"] = storageManage.token() + headerObject["shopId"] = uni.getStorageSync("shopInfo").id + headerObject["platformType"] = 'APP' + + return headerObject +} + +// 通用处理逻辑 +function commonsProcess(showLoading, httpReqCallback) { + + // 判断是否请求完成(用作 是否loading ) + // 包括: 'ing', 'ingLoading', 'finish' + let reqState = 'ing' + + // 是否已经提示的错误信息 + let isShowErrorToast = false + + + // 请求完成, 需要处理的动作 + let reqFinishFunc = () => { + + if (reqState == 'ingLoading') { // 关闭loading弹层 + infoBox.hideLoading() + } + reqState = 'finish' // 请求完毕 + } + + // 明确显示loading + if (showLoading) { + // xx ms内响应完成,不提示loading + setTimeout(() => { + if (reqState == 'ing') { + reqState = 'ingLoading' + infoBox.showLoading() + } + }, loadingShowTime) + } + + return httpReqCallback().then((httpData) => { + reqFinishFunc(); // 请求完毕的动作 + // 从http响应数据中解构响应数据 [ 响应码、 bodyData ] + let { + statusCode, + data + } = httpData + // 避免混淆重新命名 + let bodyData = data + if (statusCode == 500) { + isShowErrorToast = true + return Promise.reject(bodyData) // 跳转到catch函数 + } + if (statusCode == 501) { + // storageManage.token(null, true) + // 提示信息 + isShowErrorToast = true + // infoBox.showErrorToast('请登录').then(() => { + // go.to("PAGES_LOGIN", {}, go.GO_TYPE_RELAUNCH) + // }) + return Promise.reject(bodyData) // 跳转到catch函数 + } + // http响应码不正确 + if (statusCode != 200 && statusCode != 204 && statusCode != 201) { + isShowErrorToast = true + bodyData.msg=bodyData.msg=='Bad credentials'?'用户名或密码错误':bodyData.msg + infoBox.showToast(bodyData.msg || '服务器异常') + return Promise.reject(bodyData) // 跳转到catch函数 + } + + // // 业务响应异常 + if (bodyData.hasOwnProperty('code') && bodyData.code != 200) { + isShowErrorToast = true + infoBox.showToast(bodyData.msg) + // if (bodyData.code == 5005) { // 密码已过期, 直接跳转到更改密码页面 + // uni.reLaunch({ + // url: '/pageUser/setting/updatePwd' + // }) + // } + // if(bodyData.code == 500){ // 密码已过期, 直接跳转到更改密码页面 + // uni.redirectTo({url: '/pages/login/index'}) + // } + return Promise.reject(bodyData) // 跳转到catch函数 + } + + // 构造请求成功的响应数据 + return Promise.resolve(bodyData.data) + + }).catch(res => { + console.log(res) + if(res.code==501){ + storageManage.token(null, true) + infoBox.showToast('登录过期,请重新登录').then(() => { + uni.redirectTo({url: '/pages/login/index'}) + reject() + }) + } + // if(res.status==400){ + // storageManage.token(null, true) + // infoBox.showErrorToast('').then(() => { + // go.to("PAGES_LOGIN", {}, go.GO_TYPE_RELAUNCH) + // }) + // } + if(res.code==500){ + infoBox.showToast(res.msg||'服务器异常').then(() => {}) + } + // if(res&&res.msg){ + // infoBox.showErrorToast(res.msg) + // } + reqFinishFunc(); // 请求完毕的动作 + + // 如果没有提示错误, 那么此处提示 异常。 + if (!isShowErrorToast) { + infoBox.showToast(`请求网络异常`) + } + + return Promise.reject(res) + + }).finally(() => { // finally 是 then结束后再执行, 此处不适用。 需要在请求完成后立马调用: reqFinishFunc() + + }); + +} + + +// 默认 显示loading(控制 xxs 内 不提示loading ) +function req(uri, data, method = "GET", showLoading = true, extParams = {}) { + // headerObject[appConfig.tokenKey] = storageManage.token() + return commonsProcess(showLoading, () => { + return uni.request( + Object.assign({ + url: baseUrl + uri, + data: data, + method: method, + header: getHeader() + }, extParams) + ) + }) +} + + +// 默认 显示loading(控制 xxs 内 不提示loading ) +function request(args) { + const { + url, + data, + params, + method = "GET", + showLoading = true, + extParams = {} + } = args + let headerObject = {} + // headerObject[appConfig.tokenKey] = storageManage.token() + return commonsProcess(showLoading, () => { + return uni.request( + Object.assign({ + url: baseUrl + url, + data: params||data, + method: method, + header: getHeader() + }, extParams) + + ) + }) +} + + + +// 上传 +function upload(uri, data, file, showLoading = true, extParams = {}) { + // 放置token + let headerObject = {} + // headerObject[appConfig.tokenKey] = storageManage.token() + + return commonsProcess(showLoading, () => { + return uni.uploadFile( + Object.assign({ + url: baseUrl + uri, + formData: data, + name: "file", + filePath: file.path||file.url, + header: getHeader() + }, extParams) + ).then((httpData) => { + // uni.upload 返回bodyData 的是 string类型。 需要解析。 + httpData.data = JSON.parse(httpData.data) + return Promise.resolve(httpData) + }).catch(err=>{ + uni.hideLoading() + infoBox.showErrorToast(`上传失败`) + }) + }) +} + +export default { + req: req, + request, + upload: upload +} \ No newline at end of file diff --git a/http/yskApi/limitTimeDiscount.js b/http/yskApi/limitTimeDiscount.js new file mode 100644 index 0000000..bbfc3e0 --- /dev/null +++ b/http/yskApi/limitTimeDiscount.js @@ -0,0 +1,19 @@ +import http from '@/http/yskApi/http.js' +const request = http.request +import {marketUrl} from './prveUrl.js' + +/** + * 限时折扣 + * @returns + */ +export function limitTimeDiscount(params) { + return request({ + url: marketUrl+`/admin/limitTimeDiscount`, + method: 'get', + params: { + shopId: uni.getStorageSync('shopInfo').id, + ...params + } + }) +} + diff --git a/http/yskApi/market/consumeDiscount.js b/http/yskApi/market/consumeDiscount.js new file mode 100644 index 0000000..8e1f4d5 --- /dev/null +++ b/http/yskApi/market/consumeDiscount.js @@ -0,0 +1,20 @@ +import http from '@/http/yskApi/http.js' + +const request = http.request +import {marketUrl} from '../prveUrl.js' + +/** + * 新客立减 + * @returns + */ +export function getDiscountByUserId(params) { + return request({ + url: marketUrl+`/admin/consumeDiscount/getDiscountByUserId`, + method: 'get', + params: { + shopId: uni.getStorageSync('shopInfo').id, + ...params + } + }) +} + diff --git a/http/yskApi/market/discountActivity.js b/http/yskApi/market/discountActivity.js new file mode 100644 index 0000000..5ff5157 --- /dev/null +++ b/http/yskApi/market/discountActivity.js @@ -0,0 +1,21 @@ +import http from '@/http/yskApi/http.js' + +const request = http.request +import {marketUrl} from '../prveUrl.js' + +/** + * 满减活动 + * @returns + */ +export function discountActivity(params) { + console.log(uni.getStorageSync('shopInfo').id) + return request({ + url: marketUrl+`/admin/discountActivity`, + method: 'get', + params: { + shopId: uni.getStorageSync('shopInfo').id, + ...params + } + }) +} + diff --git a/http/yskApi/order.js b/http/yskApi/order.js new file mode 100644 index 0000000..3c9bea5 --- /dev/null +++ b/http/yskApi/order.js @@ -0,0 +1,118 @@ +import http from '@/http/yskApi/http.js' + +const request = http.request +import {orderUrl} from './prveUrl.js' + +/** + * 查询订单 + * @param {*} data + * @returns + */ +export function tbOrderInfoData(data) { + return request({ + url: "/order/admin/order", + method: "get", + data: { + shopId: uni.getStorageSync('shopId'), + ...data + } + }); +} + +/** + * 导出数据 + * @param {*} data + * @returns + */ +export function tbOrderInfoDownload(data) { + return request({ + url: "/api/tbOrderInfo/download", + method: "post", + data: { + shopId: uni.getStorageSync('shopId'), + ...data + }, + responseType: "blob" + }); +} +export function createOrder(data, urlType = 'order') { + return request({ + url: `/${urlType}/admin/order/createOrder`, + method: "POST", + data: { + ...data + } + }) +} +/** + * 通过Id查询订单 + * @param {*} id + * @returns + */ +export function tbOrderInfoDetail(id) { + return request({ + url: orderUrl+ `/admin/order/historyOrder?orderId=${id}`, + method: "get" + }); +} + +/** + * 通过Id查询订单 + * @param {*} createdAt + * @returns + */ +export function payCount(createdAt) { + console.log(createdAt); + return request({ + url: `/api/tbOrderInfo/payCount`, + method: "post", + data: { + shopId: uni.getStorageSync('shopId'), + createdAt: createdAt + } + }); +} + +/** + * 订单列表 + * @param {*} createdAt + * @returns + */ +export function tbGroupOrderInfo(params) { + return request({ + url: `/api/tbGroupOrderInfo`, + method: "post", + data: { + shopId: uni.getStorageSync('shopId'), + ...params + } + }); +} + +/** + * 退单 + * @param {*} data + * @returns + */ +export function returnGpOrder(data) { + return request({ + url: `/api/tbGroupOrderInfo/returnGpOrder`, + method: "post", + data + }); +} + + +/** + * 店铺订单支付获取链接 + */ +export function $getOrderPayUrl(data) { + return request({ + url: `/api/shopPayApi/getOrderPayUrl`, + method: "get", + data: { + shopId: uni.getStorageSync('shopId'), + ...data + } + }); +} \ No newline at end of file diff --git a/http/yskApi/prveUrl.js b/http/yskApi/prveUrl.js new file mode 100644 index 0000000..19f19ed --- /dev/null +++ b/http/yskApi/prveUrl.js @@ -0,0 +1,3 @@ +export const marketUrl = '/market' +export const accountUrl = '/account' +export const orderUrl = '/order' \ No newline at end of file diff --git a/http/yskApi/shop-user.js b/http/yskApi/shop-user.js new file mode 100644 index 0000000..12275f5 --- /dev/null +++ b/http/yskApi/shop-user.js @@ -0,0 +1,59 @@ +// 用户管理 +import http from './http.js' +import {accountUrl} from './prveUrl.js' + + +const request=http.request + +// 获取店铺会员二维码 +export function getwxacode(data) { + return request({ + url: `/shop/storage/getwxacode`, + method: "post", + data + }); +} +/** + * 商家用户列表 + * @returns + */ +export function queryAllShopUser(params) { + return request({ + url: accountUrl+`/admin/shopUser`, + method: "get", + params: { + shopId: uni.getStorageSync('shopId'), + ...params + } + }); +} +/** + * 查询商家用户概述信息 + * @returns + */ +export function queryAllShopInfo(params) { + return request({ + url: `/api/tbShopUser/summary`, + method: "get", + params: { + shopId: uni.getStorageSync('shopId'), + isVip:1, + ...params + } + }); +} + +/** + * 获取店铺用户详情 + * @returns + */ +export function shopUserDetail(params) { + return request({ + url: accountUrl+`/admin/shopUser/detail`, + method: "get", + params: { + shopId: uni.getStorageSync('shopId'), + ...params + } + }); +} diff --git a/lib/carts.ts b/lib/carts.ts new file mode 100644 index 0000000..e69de29 diff --git a/lib/coupon.ts b/lib/coupon.ts new file mode 100644 index 0000000..2632cc6 --- /dev/null +++ b/lib/coupon.ts @@ -0,0 +1,852 @@ +import { BigNumber } from "bignumber.js"; +import _ from "lodash"; + +import { + ShopInfo, + couponCalcParams, + BaseCartItem, + TimeLimitDiscountConfig, + CanDikouGoodsArrArgs, + Coupon, + ShopUserInfo, + GoodsType, + BackendCoupon, + ExchangeCalculationResult, + PointDeductionRule, + OrderCostSummary, +} from "./types"; + +import { getCompatibleFieldValue } from "./utils"; + +/** + * 返回商品单价 + * @param goods 商品 + * @param user 用户信息 + * @param {Object} shopInfo + */ +export function returnGoodsPrice( + goods: BaseCartItem, + user: ShopUserInfo, + shopInfo: ShopInfo, + limitTimeDiscount: TimeLimitDiscountConfig | null | undefined +) { + if (!goods) { + return 0; + } + //是否可以使用会员价 + const canUseVipPrice = + user && + user.isVip && + user.isMemberPrice && + goods.memberPrice * 1 > 0 && + shopInfo && + shopInfo.isMemberPrice; + // 商家改价 + if (goods.discount_sale_amount && goods.discount_sale_amount * 1 > 0) { + return goods.salePrice; + } + // 限时折扣 + if (limitTimeDiscount && limitTimeDiscount.id) { + //优先使用 + // 兼容 isTimeDiscount/is_time_discount(这里顺便处理该字段的命名兼容) + const isTimeDiscount = getCompatibleFieldValue( + goods, + "isTimeDiscount", + "is_time_discount" + ); + if (isTimeDiscount) { + 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 || + canUseFoods.includes(`${goods.productId}`); + if (canUseLimit && limitTimeDiscount.discountPriority == "limit-time") { + return new BigNumber(goods.salePrice) + .times(limitTimeDiscount.discountRate / 100) + .decimalPlaces(2, BigNumber.ROUND_UP) + .toNumber(); + } + + if (canUseLimit && limitTimeDiscount.discountPriority == "vip-price") { + if (canUseVipPrice) { + return goods.memberPrice; + } else { + return new BigNumber(goods.salePrice) + .times(limitTimeDiscount.discountRate / 100) + .decimalPlaces(2, BigNumber.ROUND_UP) + .toNumber(); + } + } + } + if (canUseVipPrice) { + return goods.memberPrice; + } + return goods.salePrice; +} + +/** + * 返回商品分组 + * @param arr 商品列表 + */ +export function returnGoodsGroupMap(arr: BaseCartItem[]) { + let map: { [key: string]: BaseCartItem[] } = {}; + arr.forEach((v) => { + const key = v.productId + "_" + v.skuId; + if (!map[key]) { + map[key] = []; + } + map[key].push(v); + }); + return map; +} + +interface CouponTypes { + 1: "满减券"; + 2: "商品券"; + 3: "折扣券"; + 4: "第二件半价券"; + 5: "消费送券"; + 6: "买一送一券"; + 7: "固定价格券"; + 8: "免配送费券"; +} +/** + * 优惠券类型:1-满减券,2-商品兑换券,3-折扣券,4-第二件半价券,5-消费送券,6-买一送一券,7-固定价格券,8-免配送费券 + * @param coupon + */ +export function returnCoupType(coupon: Coupon) { + const couponTypes: CouponTypes = { + 1: "满减券", + 2: "商品券", + 3: "折扣券", + 4: "第二件半价券", + 5: "消费送券", + 6: "买一送一券", + 7: "固定价格券", + 8: "免配送费券", + }; + return couponTypes[coupon.type as keyof CouponTypes] || "未知类型"; +} + +/** + * 返回商品券抵扣后的商品列表 + * @param canDikouGoodsArr 可抵扣商品列表 + * @param selCoupon 已选择的优惠券列表 + * @param user 用户信息 + */ +export function returnCanDikouGoodsArr(args: CanDikouGoodsArrArgs) { + const { canDikouGoodsArr, selCoupon, user, shopInfo, limitTimeDiscount } = + args; + const types = [2, 4, 6]; + // 收集已抵扣商品并关联对应的优惠券类型 + const goodsCouponGoods = selCoupon + .filter((v) => types.includes(v.type)) + .reduce((prev: BaseCartItem[], cur) => { + // 给每个抵扣商品添加所属优惠券类型 + if (cur && cur.discount) { + const goodsWithType = cur.discount.hasDiscountGoodsArr.map((goods) => ({ + ...goods, + couponType: cur.type, // 记录该商品是被哪种类型的优惠券抵扣的 + })); + prev.push(...goodsWithType); + } + return prev; + }, []); + const arr = _.cloneDeep(canDikouGoodsArr) + .map((v) => { + const findCart = goodsCouponGoods.find((carts) => carts.id == v.id); + if (findCart) { + // 根据优惠券类型判断扣减数量 + if ([4, 6].includes(findCart.couponType ?? 0)) { + // 类型4(第二件半价)或6(买一送一),数量减2 + if (v.num) { + v.num -= 2; + } + } else { + // 其他类型(如类型2商品券),按原逻辑扣减对应数量 + if (v.num) { + v.num -= findCart.num ?? 0; + } + } + } + return v; + }) + .filter((v) => { + const canUseNum = (v.num ?? 0) - (v.returnNum || 0); + // 兼容 is_temporary/isTemporary 和 is_gift/isGift + const isTemporary = getCompatibleFieldValue( + v, + "isTemporary", + "is_temporary" + ); + const isGift = getCompatibleFieldValue(v, "isGift", "is_gift"); + + if (canUseNum <= 0 || isTemporary || isGift) { + return false; + } + + return true; + }); // 过滤掉数量<=0的商品,赠菜,临时菜 + + return arr; +} + +/** + * 返回商品是否享用了会员价/会员折扣 + * @param {*} goods + */ +function returnGoodsIsUseVipPrice( + shopInfo: ShopInfo, + user: ShopUserInfo, + goods: BaseCartItem +) { + // 兼容 isTimeDiscount/is_time_discount + const isTimeDiscount = getCompatibleFieldValue( + goods, + "isTimeDiscount", + "is_time_discount" + ); + if (isTimeDiscount) { + return false; + } + if (shopInfo.isMemberPrice != 1 || user.isVip != 1) { + return false; + } + if (shopInfo.isMemberPrice == 1 && user.isVip == 1) { + if (goods.memberPrice <= 0) { + return false; + } else { + return true; + } + } + return false; +} + +/** + * 返回可以计算抵扣金额的商品列表 + */ +function returnCanCalcGoodsList( + canCalcGoodsArr: BaseCartItem[], + coupon: Coupon, + shopInfo: ShopInfo, + user: ShopUserInfo +) { + return canCalcGoodsArr.filter((goods) => { + // 兼容 isTimeDiscount/is_time_discount + const isTimeDiscount = getCompatibleFieldValue( + goods, + "isTimeDiscount", + "is_time_discount" + ); + if (!coupon.discountShare && isTimeDiscount) { + return false; + } + + if ( + !coupon.vipPriceShare && + returnGoodsIsUseVipPrice(shopInfo, user, goods) + ) { + return false; + } + return true; + }); +} + +/** + * 判断优惠券是否可使用,并返回不可用原因 + * + * @param {Object} args - 函数参数集合 + * @param {Array} args.canDikouGoodsArr - 可参与抵扣的商品列表 + * @param {Object} args.coupon - 优惠券信息对象 + * @param {boolean} args.coupon.use - 优惠券是否启用 + * @param {Array} args.coupon.useFoods - 优惠券适用的商品ID列表 + * @param {number} args.coupon.fullAmount - 优惠券使用门槛金额 + * @param {number} args.coupon.type - 优惠券类型 + * @param {number} args.goodsOrderPrice - 订单中所有商品的总金额 + * @param {Object} args.user - 用户信息对象 + * @param {Object} args.selCoupon - 已经选择的优惠券信息对象 + * @param {Object} args.shopInfo + * @param {boolean} args.limitTimeDiscount - 限时折扣 + * @returns {Object} - { canUse: boolean, reason: string } 可用状态及不可用原因 + */ +export function returnCouponCanUse(args: couponCalcParams) { + let { + canDikouGoodsArr, + coupon, + goodsOrderPrice, + user, + selCoupon, + shopInfo, + isMemberPrice, + limitTimeDiscount, + } = args; + // 优惠券未启用 + if (!coupon.use) { + return { + canUse: false, + reason: coupon.noUseRestrictions || "不在可用时间段内", + }; + } + if ( + limitTimeDiscount && + limitTimeDiscount.id && + limitTimeDiscount.foodType == 1 && + !coupon.discountShare + ) { + return { + canUse: false, + reason: coupon.noUseRestrictions || "不可与限时折扣同享", + }; + } + + // 计算门槛金额 + let fullAmount = goodsOrderPrice; + canDikouGoodsArr = returnCanDikouGoodsArr(args); + //优惠券指定门槛商品列表 + let canCalcGoodsArr = [...canDikouGoodsArr]; + //部分商品参与门槛计算 + if (coupon.thresholdFoods.length) { + canCalcGoodsArr = canDikouGoodsArr.filter((v) => { + return coupon.thresholdFoods.find((food) => food.id == v.productId); + }); + } + + canCalcGoodsArr = returnCanCalcGoodsList( + canCalcGoodsArr, + coupon, + shopInfo, + user + ); + + fullAmount = canCalcGoodsArr.reduce((pre, cur) => { + return ( + pre + + returnGoodsPrice(cur, user, shopInfo, limitTimeDiscount) * (cur.num || 0) + ); + }, 0); + + // 是否全部商品可用 + const isDikouAll = coupon.useFoods.length === 0; + // 订单可用商品列表 + let canUseGoodsArr: BaseCartItem[] = []; + if (!isDikouAll) { + canUseGoodsArr = canDikouGoodsArr.filter((v) => { + return coupon.useFoods.find((food) => food.id == v.productId); + }); + } + // if (user.isVip && !coupon.vipPriceShare) { + // return { + // canUse: false, + // reason: "非会员可用", + // }; + // } + if (selCoupon.length > 0 && !selCoupon[0].otherCouponShare) { + return { + canUse: false, + reason: "当前选中的券不可与其他券同享", + }; + } + if (selCoupon.length > 0 && !coupon.otherCouponShare) { + return { + canUse: false, + reason: "当前选中的券不可与其他券同享", + }; + } + + // 满减券和折扣券计算门槛金额是否满足 + if ([1, 3].includes(coupon.type)) { + if (canCalcGoodsArr.length <= 0) { + return { + canUse: false, + reason: "没有可参与计算门槛的商品", + }; + } + // 不满足门槛金额 + if (fullAmount < (coupon.fullAmount || 0)) { + return { + canUse: false, + reason: `满${coupon.fullAmount}元可用,当前可参与金额${fullAmount}元`, + }; + } + } + // 商品兑换券,第二件半价和买一送一判断是否有可用商品 + if ([2, 4, 5].includes(coupon.type)) { + // 没有符合条件的商品 + if (isDikouAll && canDikouGoodsArr.length === 0) { + return { + canUse: false, + reason: "没有符合条件的商品", + }; + } + if (!isDikouAll && canUseGoodsArr.length === 0) { + return { + canUse: false, + reason: "没有符合条件的商品", + }; + } + if (coupon.type == 2) { + if (canCalcGoodsArr.length <= 0) { + return { + canUse: false, + reason: "没有符合计算门槛条件的商品", + }; + } + if (fullAmount < (coupon.fullAmount || 0)) { + return { + canUse: false, + reason: `满${coupon.fullAmount}元可用,当前可参与金额${fullAmount}元`, + }; + } + } + } + //商品兑换券是否达到门槛金额 + if (coupon.type == 2 && goodsOrderPrice < (coupon.fullAmount || 0)) { + return { + canUse: false, + reason: `满${coupon.fullAmount}元可用,当前可参与金额${fullAmount}元`, + }; + } + + // 买一送一券特殊验证 + if (coupon.type === 6) { + let canUse = false; + if (isDikouAll) { + canUse = canDikouGoodsArr.some((v) => (v.num || 0) >= 2); + } else if (canUseGoodsArr.length > 0) { + canUse = canUseGoodsArr.some((v) => (v.num || 0) >= 2); + } + + if (!canUse) { + return { + canUse: false, + reason: "需要购买至少2件相同的商品才能使用", + }; + } + } + + // 第二件半价券特殊验证 + if (coupon.type === 4) { + let canUse = false; + if (isDikouAll) { + canUse = canDikouGoodsArr.some((v) => (v.num || 0) >= 2); + } else if (canUseGoodsArr.length > 0) { + canUse = canUseGoodsArr.some((v) => (v.num || 0) >= 2); + } + if (!canUse) { + return { + canUse: false, + reason: "需要购买至少2件相同的商品才能使用", + }; + } + } + + // 所有条件都满足 + return { + canUse: true, + reason: "", + }; +} + +/** + * 计算抵扣商品金额 + * @param discountGoodsArr 可抵扣商品列表 + * @param discountNum 抵扣数量 + * @param user 用户信息 + * @param {Object} shopInfo 店铺信息 + */ +export function calcDiscountGoodsArrPrice( + discountGoodsArr: BaseCartItem[], + discountNum: number, + user: ShopUserInfo, + shopInfo: ShopInfo, + limitTimeDiscount?: TimeLimitDiscountConfig | null | undefined +) { + let hasCountNum = 0; + let discountPrice = 0; + let hasDiscountGoodsArr:BaseCartItem[] = []; + + for (let i = 0; i < discountGoodsArr.length; i++) { + if (hasCountNum >= discountNum) { + break; + } + const goods = discountGoodsArr[i]; + const shengyuNum = discountNum - hasCountNum; + const num = Math.min(goods.num || 0, shengyuNum); + const realPrice = returnGoodsPrice( + goods, + user, + shopInfo, + limitTimeDiscount + ); + + discountPrice += realPrice * num; + + hasCountNum += num; + if(goods){ + hasDiscountGoodsArr.push({ + ...goods, + num, + }); + } + + } + + return { + discountPrice, + hasDiscountGoodsArr, + }; +} + +/** + * 计算优惠券抵扣金额 + * @param arr 可抵扣商品列表 + * @param coupon 优惠券 + * @param user 用户信息 + * @param goodsOrderPrice 商品订单金额 + * @param selCoupon 已选择的优惠券列表 + * @param shopInfo 店铺信息 + * @param limitTimeDiscount 限时折扣 + */ +export function returnCouponDiscount( + arr: BaseCartItem[], + coupon: Coupon, + user: ShopUserInfo, + goodsOrderPrice: number, + selCoupon: Coupon[], + shopInfo: ShopInfo, + limitTimeDiscount?: TimeLimitDiscountConfig | null | undefined +) { + arr = returnCanDikouGoods(arr, user, shopInfo, limitTimeDiscount); + const canDikouGoodsArr = returnCanDikouGoodsArr({ + canDikouGoodsArr: arr, + selCoupon, + user, + shopInfo, + limitTimeDiscount, + }); + if (coupon.type == 2) { + return returnCouponProductDiscount( + canDikouGoodsArr, + coupon, + user, + shopInfo, + limitTimeDiscount + ); + } + if (coupon.type == 6) { + const result = returnCouponBuyOneGiveOneDiscount( + canDikouGoodsArr, + coupon, + user, + shopInfo, + limitTimeDiscount + ); + return result; + } + if (coupon.type == 4) { + return returnSecoendDiscount( + canDikouGoodsArr, + coupon, + user, + shopInfo, + limitTimeDiscount + ); + } + if (coupon.type == 3) { + return returnCouponZhekouDiscount( + canDikouGoodsArr, + coupon, + user, + goodsOrderPrice, + selCoupon, + limitTimeDiscount + ); + } +} + +/** + * 折扣券抵扣金额 + * @param canDikouGoodsArr 可抵扣商品列表 + * @param coupon 优惠券 + * @param user 用户信息 + * @param goodsOrderPrice 商品订单金额 + * @param selCoupon 已选择的优惠券列表 + * @param limitTimeDiscount 限时折扣 + */ +export function returnCouponZhekouDiscount( + canDikouGoodsArr: BaseCartItem[], + coupon: Coupon, + user: ShopUserInfo, + goodsOrderPrice: number, + selCoupon: Coupon[], + limitTimeDiscount?: TimeLimitDiscountConfig | null | undefined +) { + let { discountRate, maxDiscountAmount } = coupon; + maxDiscountAmount = maxDiscountAmount || 0; + // 计算商品优惠券折扣总和,使用BigNumber避免精度问题 + const goodsCouponDiscount = selCoupon + .filter((v) => v.type == 2) + .reduce((prve, cur) => { + return new BigNumber(prve).plus( + new BigNumber(cur?.discount?.discountPrice || 0) + ); + }, new BigNumber(0)); + + // 将商品订单价格转换为BigNumber并减去优惠券折扣 + const adjustedGoodsOrderPrice = new BigNumber(goodsOrderPrice).minus( + goodsCouponDiscount + ); + + // 计算优惠比例:(100 - 折扣率) / 100 + const discountAmountRatio = new BigNumber(100) + .minus(discountRate || 0) + .dividedBy(100); + + // 计算折扣金额:调整后的商品订单金额 × 优惠比例 + let discountPrice = adjustedGoodsOrderPrice + .times(discountAmountRatio) + .decimalPlaces(2, BigNumber.ROUND_FLOOR) + .toNumber(); + + // 应用最大折扣金额限制 + if (maxDiscountAmount !== 0) { + discountPrice = + discountPrice >= maxDiscountAmount ? maxDiscountAmount : discountPrice; + } + + return { + discountPrice, // 折扣抵扣金额(即优惠的金额) + hasDiscountGoodsArr: [], + }; +} + +/** + * 商品券抵扣金额 + * @param canDikouGoodsArr 可抵扣商品列表 + * @param coupon 优惠券 + * @param user 用户信息 + * @param shopInfo 店铺信息 + */ +export function returnCouponProductDiscount( + canDikouGoodsArr: BaseCartItem[], + coupon: Coupon, + user: ShopUserInfo, + shopInfo: ShopInfo, + limitTimeDiscount?: TimeLimitDiscountConfig | null | undefined +) { + let { useFoods, discountNum, useRule } = coupon; + discountNum = discountNum || 0; + //抵扣商品数组 + let discountGoodsArr:BaseCartItem[] = []; + + //抵扣全部商品 + if (useFoods.length === 0) { + if (useRule == "price_asc") { + discountGoodsArr = canDikouGoodsArr.slice(discountNum * -1).reverse(); + } else { + discountGoodsArr = canDikouGoodsArr.slice(0, discountNum); + } + } else { + //抵扣选中商品 + const discountSelGoodsArr = canDikouGoodsArr.filter((v) => + useFoods.find((food) => food.id == v.productId) + ); + if (useRule == "price_asc") { + discountGoodsArr = discountSelGoodsArr.slice(discountNum * -1).reverse(); + } else { + discountGoodsArr = discountSelGoodsArr.slice(0, discountNum); + } + } + + const result = calcDiscountGoodsArrPrice( + discountGoodsArr, + discountNum, + user, + shopInfo, + limitTimeDiscount + ); + return result; +} + +/** + * 返回买一送一券抵扣详情 + * @param canDikouGoodsArr 可抵扣商品列表 + * @param coupon 优惠券 + * @param user 用户信息 + * @param shopInfo 店铺信息 + */ +function returnCouponBuyOneGiveOneDiscount( + canDikouGoodsArr: BaseCartItem[], + coupon: Coupon, + user: ShopUserInfo, + shopInfo: ShopInfo, + limitTimeDiscount?: TimeLimitDiscountConfig | null | undefined +) { + const { useFoods, useRule } = coupon; + //抵扣商品 + let discountGoods:BaseCartItem | undefined = undefined; + //符合买一送一条件的商品(数量>=2 + 非临时/非赠品) + const canUseGoods = canDikouGoodsArr.filter((v) => { + const isTemporary = getCompatibleFieldValue( + v, + "isTemporary", + "is_temporary" + ); + const isGift = getCompatibleFieldValue(v, "isGift", "is_gift"); + return (v.num || 0) >= 2 && !isTemporary && !isGift; + }); + //抵扣全部商品 + if (useFoods.length === 0) { + if (useRule == "price_asc") { + discountGoods = canUseGoods[canUseGoods.length - 1]; + } else { + discountGoods = canUseGoods[0]; + } + } else { + //符合抵扣条件的商品 + const canUseGoods1 = canUseGoods.filter((v) => + useFoods.find((food) => food.id == v.productId) + ); + if (useRule == "price_asc") { + discountGoods = canUseGoods1[canUseGoods1.length - 1]; + } else { + discountGoods = canUseGoods1[0]; + } + } + let discountPrice = 0; + let hasDiscountGoodsArr: BaseCartItem[] = []; + if (discountGoods) { + discountPrice = returnGoodsPrice( + discountGoods, + user, + shopInfo, + limitTimeDiscount + ); + hasDiscountGoodsArr = [discountGoods]; + } + return { + discountPrice: discountPrice <= 0 ? 0 : discountPrice, + hasDiscountGoodsArr, + }; +} + +/** + * 返回第二件半价券抵扣详情 + * @param canDikouGoodsArr 可抵扣商品列表 + * @param coupon 优惠券 + * @param user 用户信息 + * @param shopInfo 店铺信息 + */ +function returnSecoendDiscount( + canDikouGoodsArr: BaseCartItem[], + coupon: Coupon, + user: ShopUserInfo, + shopInfo: ShopInfo, + limitTimeDiscount?: TimeLimitDiscountConfig | null | undefined +) { + const { useFoods, useRule } = coupon; + //抵扣商品 + let discountGoods:BaseCartItem | undefined = undefined; + //符合条件的商品(数量>=2 + 非临时/非赠品) + const canUseGoods = canDikouGoodsArr.filter((v) => { + const isTemporary = getCompatibleFieldValue( + v, + "isTemporary", + "is_temporary" + ); + const isGift = getCompatibleFieldValue(v, "isGift", "is_gift"); + return (v.num || 0) >= 2 && !isTemporary && !isGift; + }); + //抵扣全部商品 + if (useFoods.length === 0) { + if (useRule == "price_asc") { + discountGoods = canUseGoods[canUseGoods.length - 1]; + } else { + discountGoods = canUseGoods[0]; + } + } else { + //符合抵扣条件的商品 + const canUseGoods1 = canUseGoods.filter((v) => + useFoods.find((food) => food.id == v.productId) + ); + if (useRule == "price_asc") { + discountGoods = canUseGoods1[canUseGoods1.length - 1]; + } else { + discountGoods = canUseGoods1[0]; + } + } + let discountPrice = 0; + let hasDiscountGoodsArr: BaseCartItem[] = []; + if (discountGoods) { + discountPrice = returnGoodsPrice( + discountGoods, + user, + shopInfo, + limitTimeDiscount + ); + hasDiscountGoodsArr = [discountGoods]; + } + //返回半价价格 + return { + discountPrice: + discountPrice <= 0 + ? 0 + : new BigNumber(discountPrice).dividedBy(2).toNumber(), + hasDiscountGoodsArr, + }; +} + +/** + * 返回可以抵扣优惠券的商品列表,过滤掉赠品、临时商品,价格从高到低排序 + * @param arr 商品列表 + * @param user 用户信息 + * @param shopInfo 店铺信息 + * @param limitTimeDiscount 限时折扣 + */ +export function returnCanDikouGoods( + arr: BaseCartItem[], + user: ShopUserInfo, + shopInfo: ShopInfo, + limitTimeDiscount?: TimeLimitDiscountConfig | null | undefined +) { + const result = arr + .filter((v) => { + // 兼容 is_temporary/isTemporary 和 is_gift/isGift + const isTemporary = getCompatibleFieldValue( + v, + "isTemporary", + "is_temporary" + ); + const isGift = getCompatibleFieldValue(v, "isGift", "is_gift"); + return !isTemporary && !isGift; + }) + .filter((v) => { + return (v.num || 0) > 0; + }) + .sort((a, b) => { + return ( + returnGoodsPrice(b, user, shopInfo, limitTimeDiscount) - + returnGoodsPrice(a, user, shopInfo, limitTimeDiscount) + ); + }); + return result; +} + +export const utils = { + returnGoodsPrice, + returnGoodsGroupMap, + returnCoupType, + returnCanDikouGoods, + returnCanDikouGoodsArr, + returnCouponCanUse, + calcDiscountGoodsArrPrice, + returnCouponDiscount, + returnCouponProductDiscount, + returnCouponZhekouDiscount, +}; + +export default utils; diff --git a/lib/goods-1.0.47-back.ts b/lib/goods-1.0.47-back.ts new file mode 100644 index 0000000..ff65346 --- /dev/null +++ b/lib/goods-1.0.47-back.ts @@ -0,0 +1,1275 @@ +import { BigNumber } from "bignumber.js"; + +// 配置BigNumber精度 +BigNumber.set({ + DECIMAL_PLACES: 2, + ROUNDING_MODE: BigNumber.ROUND_DOWN, // 向下取整,符合业务需求 +}); + +/** + * 购物车订单价格计算公共库 + * 功能:覆盖订单全链路费用计算(商品价格、优惠券、积分、餐位费等),支持策略扩展 + * 小数处理:使用bignumber.js确保精度,统一舍去小数点后两位(如 10.129 → 10.12,15.998 → 15.99) + * 扩展设计:优惠券/营销活动采用策略模式,新增类型仅需扩展策略,无需修改核心逻辑 + * 关键规则: + * - 所有优惠券均支持指定门槛商品(后端foods字段:空字符串=全部商品,ID字符串=指定商品ID) + * - 与限时折扣/会员价同享规则:开启则门槛计算含对应折扣,关闭则用原价/非会员价 + * 字段说明: + * - BaseCartItem.id:购物车项ID(唯一标识购物车中的条目) + * - BaseCartItem.product_id:商品ID(唯一标识商品,用于优惠券/活动匹配) + * - BaseCartItem.skuData.id:SKU ID(唯一标识商品规格) + */ + +// ============================ 1. 基础类型定义(核心修正:明确ID含义) ============================ +/** 商品类型枚举 */ +export enum GoodsType { + NORMAL = "normal", // 普通商品 + WEIGHT = "weight", // 称重商品 + GIFT = "gift", // 赠菜(继承普通商品逻辑,标记用) + EMPTY = "", // 空字符串类型(后端未返回时默认归类为普通商品) + PACKAGE = "package", // 打包商品(如套餐/预打包商品,按普通商品逻辑处理,可扩展特殊规则) +} + +/** 优惠券计算结果类型(新增细分字段) */ +interface CouponResult { + deductionAmount: number; // 抵扣金额 + excludedProductIds: string[]; // 不适用商品ID列表(注意:是商品ID,非购物车ID) + usedCoupon: Coupon | undefined; // 实际使用的优惠券 + productCouponDeduction: number; // 新增:商品优惠券抵扣(兑换券等) + fullCouponDeduction: number; // 新增:满减优惠券抵扣 +} + +/** 兑换券计算结果类型(新增细分字段) */ +interface ExchangeCalculationResult { + deductionAmount: number; + excludedProductIds: string[]; // 不适用商品ID列表(商品ID) + productCouponDeduction: number; // 新增:兑换券属于商品券,同步记录 +} + +/** 优惠券类型枚举 */ +export enum CouponType { + FULL_REDUCTION = "full_reduction", // 满减券 + DISCOUNT = "discount", // 折扣券 + SECOND_HALF = "second_half", // 第二件半价券 + BUY_ONE_GET_ONE = "buy_one_get_one", // 买一送一券 + EXCHANGE = "exchange", // 商品兑换券 +} + +/** 后端返回的优惠券原始字段类型 */ +export interface BackendCoupon { + id?: number; // 自增主键(int64) + shopId?: number; // 店铺ID(int64) + syncId?: number; // 同步Id(int64) + type?: number; // 优惠券类型:1-满减券,2-商品兑换券,3-折扣券,4-第二件半价券,5-消费送券,6-买一送一券,7-固定价格券,8-免配送费券 + name?: string; // 券名称 + useShopType?: string; // 可用门店类型:only-仅本店;all-所有门店,custom-指定门店 + useShops?: string; // 可用门店(逗号分隔字符串,如"1,2,3") + useType?: string; // 可使用类型:dine堂食/pickup自取/deliv配送/express快递 + validType?: string; // 有效期类型:fixed(固定时间),custom(自定义时间) + validDays?: number; // 有效期(天) + validStartTime?: string; // 有效期开始时间(如"2024-01-01 00:00:00") + validEndTime?: string; // 有效期结束时间 + daysToTakeEffect?: number; // 隔天生效 + useDays?: string; // 可用周期(如"周一,周二") + useTimeType?: string; // 可用时间段类型:all-全时段,custom-指定时段 + useStartTime?: string; // 可用开始时间(每日) + useEndTime?: string; // 可用结束时间(每日) + getType?: string; // 发放设置:不可自行领取/no,可领取/yes + getMode?: string; // 用户领取方式 + giveNum?: number; // 总发放数量,-10086为不限量 + getUserType?: string; // 可领取用户:全部/all,新用户一次/new,仅会员/vip + getLimit?: number; // 每人领取限量,-10086为不限量 + useLimit?: number; // 每人每日使用限量,-10086为不限量 + discountShare?: number; // 与限时折扣同享:0-否,1-是 + vipPriceShare?: number; // 与会员价同享:0-否,1-是 + ruleDetails?: string; // 附加规则说明 + status?: number; // 状态:0-禁用,1-启用 + useNum?: number; // 已使用数量 + leftNum?: number; // 剩余数量 + foods?: string; // 指定门槛商品(逗号分隔字符串,如"101,102",此处为商品ID) + fullAmount?: number; // 使用门槛:满多少金额(元) + discountAmount?: number; // 使用门槛:减多少金额(元) + discountRate?: number; // 折扣%(如90=9折) + maxDiscountAmount?: number; // 可抵扣最大金额(元) + useRule?: string; // 使用规则:price_asc-价格低到高,price_desc-高到低 + discountNum?: number; // 抵扣数量 + otherCouponShare?: number; // 与其它优惠共享:0-否,1-是 + createTime?: string; // 创建时间 + updateTime?: string; // 更新时间 +} + +/** 营销活动类型枚举 */ +export enum ActivityType { + TIME_LIMIT_DISCOUNT = "time_limit_discount", // 限时折扣 +} + +/** 基础购物车商品项(核心修正:新增product_id,明确各ID含义) */ +export interface BaseCartItem { + id: string | number; // 购物车ID(唯一标识购物车中的条目,如购物车项主键) + product_id: string | number; // 商品ID(唯一标识商品,用于优惠券/活动匹配,必选) + salePrice: number; // 商品原价(元) + number: number; // 商品数量 + product_type: GoodsType; // 商品类型 + is_temporary?: boolean; // 是否临时菜(默认false) + is_gift?: boolean; // 是否赠菜(默认false) + returnNum?: number; // 退货数量(历史订单用,默认0) + memberPrice?: number; // 商品会员价(元,优先级:商品会员价 > 会员折扣) + discountSaleAmount?: number; // 商家改价后单价(元,优先级最高) + packFee?: number; // 单份打包费(元,默认0) + packNumber?: number; // 堂食打包数量(默认0) + activityInfo?: { + // 商品参与的营销活动(如限时折扣) + type: ActivityType; + discountRate: number; // 折扣率(如0.8=8折) + vipPriceShare: boolean; // 是否与会员优惠同享(默认false) + }; + skuData?: { + // SKU扩展数据(可选) + id: string | number; // SKU ID(唯一标识商品规格,如颜色/尺寸) + memberPrice?: number; // SKU会员价 + salePrice?: number; // SKU原价 + }; +} + +/** 基础优惠券接口(所有券类型继承,包含统一门槛商品字段) */ +export interface BaseCoupon { + id: string | number; // 优惠券ID + type: CouponType; // 工具库字符串枚举(由后端couponType转换) + name: string; // 对应后端title + available: boolean; // 基于BackendCoupon字段计算的可用性 + useShopType?: string; // only-仅本店;all-所有门店,custom-指定门店 + useShops: string[]; // 可用门店ID列表 + discountShare: boolean; // 与限时折扣同享:0-否,1-是(后端字段转换为布尔值) + vipPriceShare: boolean; // 与会员价同享:0-否,1-是(后端字段转换为布尔值) + useType?: string[]; // 可使用类型:dine堂食/pickup自取/deliv配送/express快递 + isValid: boolean; // 是否在有效期内 + discountAmount?: number; // 减免金额 (满减券有) + fullAmount?: number; // 使用门槛:满多少金额 + maxDiscountAmount?: number; // 可抵扣最大金额 元 + applicableProductIds: string[]; // 门槛商品ID列表(空数组=全部商品,非空=指定商品ID) +} + +/** 满减券(适配后端字段) */ +export interface FullReductionCoupon extends BaseCoupon { + type: CouponType.FULL_REDUCTION; + fullAmount: number; // 对应后端fullAmount(满减门槛) + discountAmount: number; // 对应后端discountAmount(减免金额) + maxDiscountAmount?: number; // 对应后端maxDiscountAmount(最大减免) +} + +/** 折扣券(适配后端字段) */ +export interface DiscountCoupon extends BaseCoupon { + type: CouponType.DISCOUNT; + discountRate: number; // 后端discountRate(%)转小数(如90→0.9) + maxDiscountAmount: number; // 对应后端maxDiscountAmount(最大减免) +} + +/** 第二件半价券(适配后端字段) */ +export interface SecondHalfPriceCoupon extends BaseCoupon { + type: CouponType.SECOND_HALF; + maxUseCountPerOrder?: number; // 对应后端useLimit(-10086=不限) +} + +/** 买一送一券(适配后端字段) */ +export interface BuyOneGetOneCoupon extends BaseCoupon { + type: CouponType.BUY_ONE_GET_ONE; + maxUseCountPerOrder?: number; // 对应后端useLimit(-10086=不限) +} + +/** 商品兑换券(适配后端字段) */ +export interface ExchangeCoupon extends BaseCoupon { + type: CouponType.EXCHANGE; + deductCount: number; // 对应后端discountNum(抵扣数量) + sortRule: "low_price_first" | "high_price_first"; // 后端useRule转换 +} + +/** 所有优惠券类型联合 */ +export type Coupon = + | FullReductionCoupon + | DiscountCoupon + | SecondHalfPriceCoupon + | BuyOneGetOneCoupon + | ExchangeCoupon; + +/** 营销活动配置(如限时折扣,applicableProductIds为商品ID列表) */ +export interface ActivityConfig { + type: ActivityType; + applicableProductIds?: string[]; // 适用商品ID列表(与BaseCartItem.product_id匹配) + discountRate: number; // 折扣率(如0.8=8折) + vipPriceShare: boolean; // 是否与会员优惠同享 +} + +/** 积分抵扣规则 */ +export interface PointDeductionRule { + pointsPerYuan: number; // X积分=1元(如100=100积分抵1元) + maxDeductionAmount?: number; // 最大抵扣金额(元,默认不限) +} + +/** 餐位费配置 */ +export interface SeatFeeConfig { + pricePerPerson: number; // 每人餐位费(元) + personCount: number; // 用餐人数(默认1) + isEnabled: boolean; // 是否启用餐位费(默认false) +} +/** 商家减免类型枚举 */ +export enum MerchantReductionType { + FIXED_AMOUNT = "fixed_amount", // 固定金额减免(如直接减 10 元) + DISCOUNT_RATE = "discount_rate", // 比例折扣减免(如打 9 折,即减免 10%) +} + +/** 商家减免配置(新增,替代原单一金额字段) */ +export interface MerchantReductionConfig { + type: MerchantReductionType; // 减免类型(二选一) + fixedAmount?: number; // 固定减免金额(元,仅 FIXED_AMOUNT 生效,≥0) + discountRate?: number; // 折扣率(%,仅 DISCOUNT_RATE 生效,0-100,如 90 代表 9 折) +} +/** 订单额外费用配置 */ +export interface OrderExtraConfig { + // merchantReduction: number; // 商家减免金额(元,默认0) + // 替换原单一金额字段,支持两种减免形式 + merchantReduction: MerchantReductionConfig; + additionalFee: number; // 附加费(元,如余额充值、券包,默认0) + pointDeductionRule: PointDeductionRule; // 积分抵扣规则 + seatFeeConfig: SeatFeeConfig; // 餐位费配置 + currentStoreId: string; // 当前门店ID(用于验证优惠券适用门店) + userPoints: number; // 用户当前积分(用于积分抵扣) + isMember: boolean; // 用户是否会员(用于会员优惠) + memberDiscountRate?: number; // 会员折扣率(如0.95=95折,无会员价时用) + newUserDiscount?: number; // 新用户减免金额(元,默认0) +} + +/** 订单费用汇总(修改:补充商家减免类型和明细) */ +export interface OrderCostSummary { + // 商品总件数 + goodsTotal: number; + totalDiscountAmount: number, + goodsRealAmount: number; // 商品真实原价总和 + goodsOriginalAmount: number; // 商品原价总和 + goodsDiscountAmount: number; // 商品折扣金额 + couponDeductionAmount: number; // 优惠券总抵扣 + productCouponDeduction: number; // 商品优惠券抵扣 + fullCouponDeduction: number; // 满减优惠券抵扣 + pointDeductionAmount: number; // 积分抵扣金额 + seatFee: number; // 餐位费 + packFee: number; // 打包费 + // 新增:商家减免明细 + merchantReduction: { + type: MerchantReductionType; // 实际使用的减免类型 + originalConfig: MerchantReductionConfig; // 原始配置(便于前端展示) + actualAmount: number; // 实际减免金额(计算后的值,≥0) + }; + additionalFee: number; // 附加费 + finalPayAmount: number; // 最终实付金额 + couponUsed?: Coupon; // 实际使用的优惠券 + pointUsed: number; // 实际使用的积分 + newUserDiscount: number; // 新用户减免金额(元,默认0) + dinnerType?: "dine-in" | "take-out"; // 就餐类型(堂食/自取/配送/快递) + config: OrderExtraConfig; // 订单额外费用配置 + //满减活动 + fullReduction: { + usedActivity?: FullReductionActivity; // 实际使用的满减活动 + usedThreshold?: FullReductionThreshold; // 实际使用的满减阈值(多门槛中选最优) + actualAmount: number; // 满减实际减免金额(元) + }; +} + + +/** 满减活动阈值(单条满减规则:满X减Y)- 对应 MkDiscountThresholdInsertGroupDefaultGroup */ +export interface FullReductionThreshold { + activityId?: number; // 关联满减活动ID + fullAmount?: number; // 满多少金额(元,必填) + discountAmount?: number; // 减多少金额(元,必填) +} + +/** 满减活动主表 - 对应 Request 接口(后端真实字段) */ +export interface FullReductionActivity { + id?: number; // 自增主键(后端字段:id) + shopId?: number; // 店铺ID(后端字段:shopId) + status?: number; // 活动状态:1=未开始,2=进行中,3=已结束(后端字段:status) + sort?: number; // 排序值(越大优先级越高,后端字段:sort) + createTime?: string; // 创建时间(后端字段:createTime,格式如"2025-10-14 13:56:07") + updateTime?: string; // 最新修改时间(后端字段:updateTime,用于优先级排序) + validStartTime?: string; // 有效期开始时间(后端字段:validStartTime,格式如"2025-10-14") + validEndTime?: string; // 有效期结束时间(后端字段:validEndTime,格式如"2025-12-14") + useType?: string; // 可使用类型(后端字段:useType,如"dine,pickup,deliv,express") + useDays?: string; // 可用周期(后端字段:useDays,如"周一,周二,周三,周四,周五,周六,周日") + useTimeType?: string; // 可用时间段类型(后端字段:useTimeType,all=全时段,custom=指定时段) + useStartTime?: string; // 每日可用开始时间(后端字段:useStartTime,如"09:00:00",仅custom时有效) + useEndTime?: string; // 每日可用结束时间(后端字段:useEndTime,如"22:00:00",仅custom时有效) + couponShare?: number; // 与优惠券同享:0=否,1=是(后端字段:couponShare) + discountShare?: number; // 与限时折扣同享:0=否,1=是(后端字段:discountShare) + vipPriceShare?: number; // 与会员价同享:0=否,1=是(后端字段:vipPriceShare) + pointsShare?: number; // 与积分抵扣同享:0=否,1=是(后端字段:pointsShare) + thresholds?: FullReductionThreshold[]; // 满减阈值列表(多门槛,后端字段:thresholds) + isDel?: boolean; // 是否删除:0=否,1=是(后端字段:isDel,默认false) +} + +// ============================ 扩展:订单配置与费用汇总(加入后端满减类型) ============================ +/** 扩展订单额外配置:使用后端满减活动类型 */ +export interface OrderExtraConfig { + // ... 原有字段不变 ... + fullReductionActivities: FullReductionActivity[]; // 当前店铺的满减活动列表(后端返回结构) + currentDinnerType: "dine" | "pickup" | "deliv" | "express"; // 当前就餐类型(匹配useType) +} + + + +// 辅助枚举:星期映射(用于useDays校验) +const WEEKDAY_MAP = { + "周一": 1, + "周二": 2, + "周三": 3, + "周四": 4, + "周五": 5, + "周六": 6, + "周日": 0 // JS中getDay()返回0=周日 +}; + +/** + * 辅助:校验当前时间是否在活动的「每日可用时段」内 + * @param activity 满减活动 + * @param currentTime 当前时间 + * @returns 是否在时段内 + */ +function isInDailyTimeRange(activity: FullReductionActivity, currentTime: Date): boolean { + // 全时段无需校验 + if (activity.useTimeType === "all") return true; + // 无时段配置则不通过 + if (!activity.useStartTime || !activity.useEndTime) return false; + + const [startHour, startMinute] = activity.useStartTime.split(":").map(Number); + const [endHour, endMinute] = activity.useEndTime.split(":").map(Number); + const currentHour = currentTime.getHours(); + const currentMinute = currentTime.getMinutes(); + + // 转换为分钟数比较 + const startTotalMin = startHour * 60 + startMinute; + const endTotalMin = endHour * 60 + endMinute; + const currentTotalMin = currentHour * 60 + currentMinute; + + // 处理跨天场景(如23:00-02:00) + if (startTotalMin <= endTotalMin) { + return currentTotalMin >= startTotalMin && currentTotalMin <= endTotalMin; + } else { + return currentTotalMin >= startTotalMin || currentTotalMin <= endTotalMin; + } +} + +/** + * 辅助:校验当前时间是否在活动的「可用周期」内(如周一至周日) + * @param activity 满减活动 + * @param currentTime 当前时间 + * @returns 是否在周期内 + */ +function isInWeeklyCycle(activity: FullReductionActivity, currentTime: Date): boolean { + // 无周期配置则不通过 + if (!activity.useDays) return false; + const currentWeekday = currentTime.getDay(); // 0=周日,1=周一...6=周六 + const allowedWeekdays = activity.useDays.split(",").map(day => WEEKDAY_MAP[day as keyof typeof WEEKDAY_MAP]); + return allowedWeekdays.includes(currentWeekday); +} + +/** + * 辅助:校验当前就餐类型是否在活动的「可用类型」内(如堂食/自取) + * @param activity 满减活动 + * @param currentDinnerType 当前就餐类型 + * @returns 是否匹配 + */ +function isDinnerTypeMatch(activity: FullReductionActivity, currentDinnerType: string): boolean { + if (!activity.useType) return false; + const allowedTypes = activity.useType.split(","); + return allowedTypes.includes(currentDinnerType); +} +/** + * 筛选最优满减活动(对齐后端逻辑:状态→时间→周期→时段→就餐类型→排序→修改时间) + * @param activities 后端返回的满减活动列表 + * @param currentShopId 当前店铺ID + * @param currentDinnerType 当前就餐类型(dine/pickup等) + * @param currentTime 当前时间(默认当前时间) + * @returns 最优满减活动(无符合条件则返回undefined) + */ +export function filterOptimalFullReductionActivity( + activities: FullReductionActivity[], + currentShopId: number, + currentDinnerType: string, + currentTime: Date = new Date() +): FullReductionActivity | undefined { + if (!activities.length) return undefined; + + // 第一步:基础筛选(未删除+当前店铺+活动进行中+就餐类型匹配) + const baseEligible = activities.filter(activity => { + return ( + activity.isDel !== true && // 未删除 + activity.shopId === currentShopId && // 当前店铺 + activity.status === 2 && // 状态=2(进行中) + isDinnerTypeMatch(activity, currentDinnerType) && // 就餐类型匹配 + activity.thresholds?.length // 至少有一个满减阈值 + ); + }); + + if (!baseEligible.length) return undefined; + + // 第二步:时间筛选(有效期内+周期内+时段内) + const timeEligible = baseEligible.filter(activity => { + // 1. 校验有效期(validStartTime ~ validEndTime) + if (!activity.validStartTime || !activity.validEndTime) return false; + const startDate = new Date(activity.validStartTime); + const endDate = new Date(activity.validEndTime); + // 处理有效期结束日期的23:59:59 + endDate.setHours(23, 59, 59, 999); + if (currentTime < startDate || currentTime > endDate) return false; + + // 2. 校验可用周期(如周一至周日) + if (!isInWeeklyCycle(activity, currentTime)) return false; + + // 3. 校验每日可用时段(如09:00-22:00) + if (!isInDailyTimeRange(activity, currentTime)) return false; + + return true; + }); + + if (!timeEligible.length) return undefined; + + // 第三步:按优先级排序(需求规则) + return timeEligible.sort((a, b) => { + // 1. 先比排序值:排序值大的优先 + if ((a.sort || 0) !== (b.sort || 0)) { + return (b.sort || 0) - (a.sort || 0); + } + // 2. 再比修改时间:最新修改的优先(时间戳降序) + const aUpdateTime = a.updateTime ? new Date(a.updateTime).getTime() : 0; + const bUpdateTime = b.updateTime ? new Date(b.updateTime).getTime() : 0; + return bUpdateTime - aUpdateTime; + })[0]; // 取最优活动 +} +/** + * 折扣率格式化:后端discountRate(%)→ 工具库小数(如90→0.9) + */ +export function formatDiscountRate(backendDiscountRate?: number): number { + if (!backendDiscountRate || backendDiscountRate <= 0) return 1; // 默认无折扣(1=100%) + // 后端若为百分比(如90=9折),除以100;若已为小数(如0.9)直接返回 + return backendDiscountRate >= 1 + ? backendDiscountRate / 100 + : backendDiscountRate; +} + +/** + * 统一小数处理:舍去小数点后两位以后的数字(解决浮点数精度偏差) + * 如 10.129 → 10.12,1.01 → 1.01,1.0100000001 → 1.01 + * @param num 待处理数字 + * @returns 处理后保留两位小数的数字 + */ +export function truncateToTwoDecimals(num: number | string): number { + return new BigNumber(num).decimalPlaces(2, BigNumber.ROUND_DOWN).toNumber(); +} + +/** + * 判断商品是否为临时菜(临时菜不参与优惠券门槛和折扣计算) + * @param goods 商品项 + * @returns 是否临时菜 + */ +export function isTemporaryGoods(goods: BaseCartItem): boolean { + return !!goods.is_temporary; +} + +/** + * 判断商品是否为赠菜(赠菜不计入优惠券活动,但计打包费) + * @param goods 商品项 + * @returns 是否赠菜 + */ +export function isGiftGoods(goods: BaseCartItem): boolean { + return !!goods.is_gift; +} + +/** + * 计算单个商品的会员价(优先级:SKU会员价 > 商品会员价 > 会员折扣率) + * @param goods 商品项 + * @param isMember 是否会员 + * @param memberDiscountRate 会员折扣率(如0.95=95折) + * @returns 会员价(元) + */ +export function calcMemberPrice( + goods: BaseCartItem, + isMember: boolean, + memberDiscountRate?: number +): number { + if (!isMember) return truncateToTwoDecimals(goods.salePrice); + + // 优先级:SKU会员价 > 商品会员价 > 商品原价(无会员价时用会员折扣) + const basePrice = + goods.skuData?.memberPrice ?? goods.memberPrice ?? goods.salePrice; + + // 仅当无SKU会员价、无商品会员价时,才应用会员折扣率 + if (memberDiscountRate && !goods.skuData?.memberPrice && !goods.memberPrice) { + return truncateToTwoDecimals( + new BigNumber(basePrice).multipliedBy(memberDiscountRate).toNumber() + ); + } + + return truncateToTwoDecimals(basePrice); +} + +/** + * 过滤可参与优惠券计算的商品(排除临时菜、赠菜、已用兑换券的商品) + * @param goodsList 商品列表 + * @param excludedProductIds 需排除的商品ID列表(商品ID,非购物车ID) + * @returns 可参与优惠券计算的商品列表 + */ +export function filterCouponEligibleGoods( + goodsList: BaseCartItem[], + excludedProductIds: string[] = [] +): BaseCartItem[] { + return goodsList.filter( + (goods) => + !isTemporaryGoods(goods) && + !isGiftGoods(goods) && + !excludedProductIds.includes(String(goods.product_id)) // 核心修正:用商品ID排除 + ); +} + +/** + * 统一筛选门槛商品的工具函数(所有券类型复用,按商品ID匹配) + * @param baseEligibleGoods 基础合格商品(已排除临时菜/赠菜/已抵扣商品) + * @param applicableProductIds 优惠券指定的门槛商品ID数组 + * @returns 最终参与优惠券计算的商品列表 + */ +export function filterThresholdGoods( + baseEligibleGoods: BaseCartItem[], + applicableProductIds: string[] +): BaseCartItem[] { + // 空数组=全部基础合格商品;非空=仅商品ID匹配的商品(转字符串兼容类型) + return applicableProductIds.length === 0 + ? baseEligibleGoods + : baseEligibleGoods.filter((goods) => + applicableProductIds.includes(String(goods.product_id)) + ); // 核心修正:用商品ID匹配 +} + +/** + * 商品排序(用于商品兑换券:按价格/数量/加入顺序排序,按商品ID分组去重) + * @param goodsList 商品列表 + * @param sortRule 排序规则(low_price_first/high_price_first) + * @param cartOrder 商品加入购物车的顺序(key=购物车ID,value=加入时间戳) + * @returns 排序后的商品列表 + */ +export function sortGoodsForCoupon( + goodsList: BaseCartItem[], + sortRule: "low_price_first" | "high_price_first", + cartOrder: Record = {} +): BaseCartItem[] { + return [...goodsList].sort((a, b) => { + // 1. 按商品单价排序(优先级最高) + const priceA = a.skuData?.salePrice ?? a.salePrice; + const priceB = b.skuData?.salePrice ?? b.salePrice; + if (priceA !== priceB) { + return sortRule === "low_price_first" ? priceA - priceB : priceB - priceA; + } + + // 2. 同价格按商品数量排序(降序,多的优先) + if (a.number !== b.number) { + return b.number - a.number; + } + + // 3. 同价格同数量按加入购物车顺序(早的优先,用购物车ID匹配) + const orderA = cartOrder[String(a.id)] ?? Infinity; + const orderB = cartOrder[String(b.id)] ?? Infinity; + return orderA - orderB; + }); +} + +/** + * 计算优惠券门槛金额(根据同享规则,按商品ID匹配限时折扣) + * @param eligibleGoods 可参与优惠券的商品列表(已过滤临时菜/赠菜) + * @param coupon 优惠券(含discountShare/vipPriceShare配置) + * @param config 订单配置(会员信息) + * @param activities 全局营销活动(限时折扣,applicableProductIds为商品ID) + * @returns 满足优惠券门槛的金额基数 + */ +export function calcCouponThresholdAmount( + eligibleGoods: BaseCartItem[], + coupon: BaseCoupon, + config: Pick, + activities: ActivityConfig[] = [] +): number { + let total = new BigNumber(0); + + for (const goods of eligibleGoods) { + const availableNum = Math.max(0, goods.number - (goods.returnNum || 0)); + if (availableNum <= 0) continue; + + // 1. 基础金额:默认用商品原价(SKU原价优先) + const basePrice = new BigNumber( + goods.skuData?.salePrice ?? goods.salePrice + ); + let itemAmount = basePrice.multipliedBy(availableNum); + + // 2. 处理「与会员价/会员折扣同享」规则:若开启,用会员价计算 + if (coupon.vipPriceShare) { + const memberPrice = new BigNumber( + calcMemberPrice(goods, config.isMember, config.memberDiscountRate) + ); + itemAmount = memberPrice.multipliedBy(availableNum); + } + + // 3. 处理「与限时折扣同享」规则:若开启,叠加限时折扣(按商品ID匹配活动) + if (coupon.discountShare) { + const activity = + goods.activityInfo ?? + activities.find( + (act) => + act.type === ActivityType.TIME_LIMIT_DISCOUNT && + (act.applicableProductIds || []).includes(String(goods.product_id)) // 核心修正:用商品ID匹配活动 + ); + + if (activity) { + itemAmount = itemAmount.multipliedBy(activity.discountRate); // 叠加限时折扣 + } + } + + total = total.plus(itemAmount); + } + + return truncateToTwoDecimals(total.toNumber()); +} + +// ============================ 3. 商品核心价格计算(核心修正:按商品ID匹配营销活动) ============================ +/** + * 计算单个商品的实际单价(整合商家改价、会员优惠、营销活动折扣,按商品ID匹配活动) + * @param goods 商品项 + * @param config 订单额外配置(含会员、活动信息) + * @returns 单个商品实际单价(元) + */ +export function calcSingleGoodsRealPrice( + goods: BaseCartItem, + config: Pick & { + activity?: ActivityConfig; // 商品参与的营销活动(如限时折扣,按商品ID匹配) + } +): number { + const { isMember, memberDiscountRate, activity } = config; + + //如果是增菜价格为0 + if (goods.is_gift) { + return 0; + } + + // 1. 优先级1:商家改价(改价后单价>0才生效) + if (goods.discountSaleAmount && goods.discountSaleAmount > 0) { + return truncateToTwoDecimals(goods.discountSaleAmount); + } + + // 2. 优先级2:会员价(含会员折扣率,SKU会员价优先) + const memberPrice = new BigNumber( + calcMemberPrice(goods, isMember, memberDiscountRate) + ); + + // 3. 优先级3:营销活动折扣(如限时折扣,需按商品ID匹配活动) + const isActivityApplicable = activity + ? (activity.applicableProductIds || []).includes(String(goods.product_id)) // 核心修正:用商品ID匹配活动 + : false; + if (!activity || !isActivityApplicable) { + return memberPrice.toNumber(); + } + + // 处理活动与会员的同享/不同享逻辑 + if (activity.vipPriceShare) { + // 同享:会员价基础上叠加工活动折扣 + return truncateToTwoDecimals( + memberPrice.multipliedBy(activity.discountRate).toNumber() + ); + } else { + // 不同享:取会员价和活动价的最小值(活动价用SKU原价计算) + const basePriceForActivity = new BigNumber( + goods.skuData?.salePrice ?? goods.salePrice + ); + const activityPrice = basePriceForActivity.multipliedBy( + activity.discountRate + ); + + return truncateToTwoDecimals( + memberPrice.isLessThanOrEqualTo(activityPrice) + ? memberPrice.toNumber() + : activityPrice.toNumber() + ); + } +} + +/** + * 计算商品原价总和(所有商品:原价*数量,含临时菜、赠菜,用SKU原价优先) + * @param goodsList 商品列表 + * @returns 商品原价总和(元) + */ +export function calcGoodsOriginalAmount(goodsList: BaseCartItem[]): number { + let total = new BigNumber(0); + + for (const goods of goodsList) { + const availableNum = Math.max(0, goods.number - (goods.returnNum || 0)); + let basePrice = new BigNumber(0); + if (goods.is_temporary) { + basePrice = new BigNumber(goods?.discountSaleAmount ?? 0); + } else if (goods.is_gift) { + basePrice = new BigNumber(0); + } + else { + basePrice = new BigNumber( + goods.skuData?.salePrice ?? goods.salePrice + ); // SKU原价优先 + } + + total = total.plus(basePrice.multipliedBy(availableNum)); + } + + return truncateToTwoDecimals(total.toNumber()); +} + +/** + * 计算商品实际总价(所有商品:实际单价*数量,含临时菜、赠菜,按商品ID匹配活动) + * @param goodsList 商品列表 + * @param config 订单额外配置(含会员、活动信息) + * @param activities 全局营销活动列表(如限时折扣,applicableProductIds为商品ID) + * @returns 商品实际总价(元) + */ +export function calcGoodsRealAmount( + goodsList: BaseCartItem[], + config: Pick, + activities: ActivityConfig[] = [] +): number { + let total = new BigNumber(0); + + for (const goods of goodsList) { + const availableNum = Math.max(0, goods.number - (goods.returnNum || 0)); + if (availableNum <= 0) continue; + + // 匹配商品参与的营销活动(按商品ID匹配,优先商品自身配置) + const activity = + goods.activityInfo ?? + activities.find( + (act) => + (act.applicableProductIds || []).includes(String(goods.product_id)) // 核心修正:用商品ID匹配活动 + ); + + const realPrice = new BigNumber( + calcSingleGoodsRealPrice(goods, { ...config, activity }) + ); + total = total.plus(realPrice.multipliedBy(availableNum)); + } + + return truncateToTwoDecimals(total.toNumber()); +} + +/** + * 计算商品折扣总金额(商品原价总和 - 商品实际总价) + * @param goodsOriginalAmount 商品原价总和 + * @param goodsRealAmount 商品实际总价 + * @returns 商品折扣总金额(元,≥0) + */ +export function calcGoodsDiscountAmount( + goodsOriginalAmount: number, + goodsRealAmount: number +): number { + const original = new BigNumber(goodsOriginalAmount); + const real = new BigNumber(goodsRealAmount); + const discount = original.minus(real); + + return truncateToTwoDecimals(Math.max(0, discount.toNumber())); +} +/** + * 从满减活动的多门槛中,选择最优阈值(满金额最小且减免金额最大) + * @param thresholds 满减阈值列表(多门槛) + * @param baseAmount 满减计算基数(新客立减后的金额) + * @param goodsOriginalAmount 商品原价总和(discountShare=0时用) + * @param goodsRealAmount 商品折扣后总和(discountShare=1时用) + * @param discountShare 与限时折扣同享:0=否,1=是 + * @returns 最优阈值(无符合条件则返回undefined) + */ +export function selectOptimalThreshold( + thresholds: FullReductionThreshold[] = [], + baseAmount: number, + goodsOriginalAmount: number, + goodsRealAmount: number, + discountShare: number = 0 +): FullReductionThreshold | undefined { + if (!thresholds.length) return undefined; + + // 第一步:确定满减门槛基数(根据discountShare规则) + const thresholdBase = discountShare === 1 + ? new BigNumber(goodsRealAmount) // 与限时折扣同享→用折扣后金额 + : new BigNumber(goodsOriginalAmount); // 不同享→用原价 + + // 第二步:筛选「满金额≤基数」且「减免金额>0」的有效阈值 + const validThresholds = thresholds.filter(threshold => { + const fullAmount = new BigNumber(threshold.fullAmount || 0); + const discountAmount = new BigNumber(threshold.discountAmount || 0); + return fullAmount.isLessThanOrEqualTo(thresholdBase) && discountAmount.isGreaterThan(0); + }); + + if (!validThresholds.length) return undefined; + + // 第三步:选择最优阈值(优先级:1.满金额最小 → 2.减免金额最大) + return validThresholds.sort((a, b) => { + const aFull = new BigNumber(a.fullAmount || 0); + const bFull = new BigNumber(b.fullAmount || 0); + const aDiscount = new BigNumber(a.discountAmount || 0); + const bDiscount = new BigNumber(b.discountAmount || 0); + + // 先比满金额:越小越优先(满1减10 比 满100减20 更优) + if (!aFull.isEqualTo(bFull)) { + return aFull.comparedTo(bFull) || 0; // Ensure a number is always returned + } + // 再比减免金额:越大越优先 + return bDiscount.comparedTo(aDiscount) || 0; // Ensure a number is always returned + })[0]; +} + +/** + * 计算满减实际减免金额(适配多门槛、同享规则) + * @param optimalActivity 最优满减活动 + * @param optimalThreshold 最优满减阈值 + * @param baseAmount 计算基数(新客立减后的金额) + * @returns 实际减免金额(元,未达门槛则0) + */ +export function calcFullReductionAmount( + baseAmount: number, + optimalActivity?: FullReductionActivity, + optimalThreshold?: FullReductionThreshold, +): number { + if (!optimalActivity || !optimalThreshold) return 0; + + const baseAmountBn = new BigNumber(baseAmount); + const discountAmountBn = new BigNumber(optimalThreshold.discountAmount || 0); + + // 1. 基数必须为正(避免减免后为负) + if (baseAmountBn.isLessThanOrEqualTo(0)) return 0; + + // 2. 减免金额不能超过基数(避免减成负数) + const maxReducible = baseAmountBn; + const actualReduction = discountAmountBn.isLessThanOrEqualTo(maxReducible) + ? discountAmountBn + : maxReducible; + + return truncateToTwoDecimals(actualReduction.toNumber()); +} + + +// ------------------------------ 策略辅助函数 ------------------------------ +/** + * 根据优惠券useShops列表判断门店是否匹配(适配BaseCoupon的useShops字段) + */ +function isStoreMatchByList( + useShops: string[], + currentStoreId: string +): boolean { + // 适用门店为空数组 → 无限制(所有门店适用) + if (useShops.length === 0) return true; + // 匹配当前门店ID(字符串比较,避免类型问题) + return useShops.includes(currentStoreId); +} + + + +/** + * 计算优惠券抵扣金额(处理互斥逻辑,选择最优优惠券,按商品ID排除,新增细分统计) + * @param backendCoupons 后端优惠券列表 + * @param goodsList 商品列表 + * @param config 订单配置(含就餐类型) + * @returns 最优优惠券的抵扣结果(含商品券/满减券细分) + */ +export function calcCouponDeduction( + backendCoupons: BackendCoupon[], + goodsList: BaseCartItem[], + config: Pick< + OrderExtraConfig, + "currentStoreId" | "isMember" | "memberDiscountRate" + > & { + activities: ActivityConfig[]; + cartOrder: Record; + dinnerType: "dine-in" | "take-out"; + currentTime?: Date; + } +): { + deductionAmount: number; + productCouponDeduction: number; // 新增:商品优惠券抵扣(兑换券/折扣券/买一送一等) + fullCouponDeduction: number; // 新增:满减优惠券抵扣 + usedCoupon?: Coupon; + excludedProductIds: string[]; // 排除的商品ID列表(商品ID) +} { + const goodsCoupon = backendCoupons.filter((v) => v.type == 2); + const discountCoupon = backendCoupons.filter((v) => v.type != 2); + + // 3. 计算非兑换券最优抵扣(传递已抵扣商品ID,避免重复,统计细分字段) + let nonExchangeResult: CouponResult = { + deductionAmount: discountCoupon.reduce((prve, cur): number => { + return prve + (cur.discountAmount || 0); + }, 0), + excludedProductIds: [], + usedCoupon: undefined, + productCouponDeduction: 0, + fullCouponDeduction: 0, + }; + + // 4. 计算兑换券抵扣(排除非兑换券已抵扣的商品ID,统计商品券细分) + let exchangeResult: ExchangeCalculationResult = { + deductionAmount: goodsCoupon.reduce((prve, cur): number => { + return prve + (cur.discountAmount || 0); + }, 0), + excludedProductIds: [], + productCouponDeduction: 0, + }; + + // 5. 汇总结果:兑换券与非兑换券不可同时使用,取抵扣金额大的 + const exchangeBn = new BigNumber(exchangeResult.deductionAmount); + const nonExchangeBn = new BigNumber(nonExchangeResult.deductionAmount); + const isExchangeBetter = exchangeBn.isGreaterThan(nonExchangeBn); + + return { + deductionAmount: exchangeBn.plus(nonExchangeBn).toNumber(), + productCouponDeduction: exchangeResult.deductionAmount, + fullCouponDeduction: nonExchangeResult.deductionAmount, // 兑换券与满减券互斥,满减券抵扣置0 + usedCoupon: isExchangeBetter ? undefined : nonExchangeResult.usedCoupon, + excludedProductIds: isExchangeBetter + ? exchangeResult.excludedProductIds + : nonExchangeResult.excludedProductIds, + }; +} + +// ============================ 5. 其他费用计算(无ID依赖,逻辑不变) ============================ +/** + * 计算总打包费(赠菜也计算,称重商品打包数量≤1) + * @param goodsList 商品列表 + * @param dinnerType 就餐类型(堂食dine-in/外卖take-out) + * @returns 总打包费(元) + */ +export function calcTotalPackFee( + goodsList: BaseCartItem[], + dinnerType: "dine-in" | "take-out" +): number { + // if (dinnerType !== "take-out") return 0; + let total = new BigNumber(0); + + for (const goods of goodsList) { + const packNumber = goods.packNumber ? goods.packNumber * 1 : 0; + let availableNum = Math.max(0, goods.number - (goods.returnNum || 0)); + availableNum = Math.min(availableNum, packNumber); + + if (availableNum === 0) continue; + + // 计算单个商品打包数量(外卖全打包,堂食按配置,称重商品≤1) + let packNum = availableNum; + if (goods.product_type === GoodsType.WEIGHT) { + packNum = Math.min(packNum, 1); + } + + total = total.plus(new BigNumber(goods.packFee || 0).multipliedBy(packNum)); + } + + return truncateToTwoDecimals(total.toNumber()); +} + +/** + * 计算餐位费(按人数,不参与营销活动) + * @param config 餐位费配置 + * @returns 餐位费(元,未启用则0) + */ +export function calcSeatFee(config: SeatFeeConfig): number { + if (!config.isEnabled || config.personCount == 0) return 0; + const personCount = Math.max(1, config.personCount); // 至少1人 + return truncateToTwoDecimals( + new BigNumber(config.pricePerPerson).multipliedBy(personCount).toNumber() + ); +} + +/** + * 计算积分抵扣金额(按X积分=1元,不超过最大抵扣和用户积分) + * @param userPoints 用户当前积分 + * @param rule 积分抵扣规则 + * @param maxDeductionLimit 最大抵扣上限(通常为订单金额,避免超扣) + * @returns 积分抵扣金额 + 实际使用积分 + */ +export function calcPointDeduction( + userPoints: number, + rule: PointDeductionRule, + maxDeductionLimit: number +): { + deductionAmount: number; + usedPoints: number; +} { + if (rule.pointsPerYuan <= 0 || userPoints <= 0) { + return { deductionAmount: 0, usedPoints: 0 }; + } + + const userPointsBn = new BigNumber(userPoints); + const pointsPerYuanBn = new BigNumber(rule.pointsPerYuan); + const maxLimitBn = new BigNumber(maxDeductionLimit); + + // 最大可抵扣金额(积分可抵金额 vs 规则最大 vs 订单上限) + const maxDeductByPoints = userPointsBn.dividedBy(pointsPerYuanBn); + const maxDeductAmount = maxDeductByPoints.isLessThan( + rule.maxDeductionAmount ?? Infinity + ) + ? maxDeductByPoints + : new BigNumber(rule.maxDeductionAmount || Infinity).isLessThan(maxLimitBn) + ? maxDeductByPoints + : maxLimitBn; + + // 实际使用积分 = 抵扣金额 * 积分兑换比例 + const usedPoints = maxDeductAmount.multipliedBy(pointsPerYuanBn); + + return { + deductionAmount: truncateToTwoDecimals(maxDeductAmount.toNumber()), + usedPoints: truncateToTwoDecimals( + Math.min(usedPoints.toNumber(), userPoints) + ), // 避免积分超扣 + }; +} + +// ============================ 6. 订单总费用汇总与实付金额计算(核心入口,补充细分字段) ============================ +/** + * 计算订单所有费用子项并汇总(核心入口函数) + * @param goodsList 购物车商品列表 + * @param dinnerType 就餐类型 + * @param backendCoupons 后端优惠券列表 + * @param activities 全局营销活动列表 + * @param config 订单额外配置(会员、积分、餐位费等) + * @param cartOrder 商品加入购物车顺序(key=购物车ID,value=时间戳) + * @param currentTime 当前时间(用于优惠券有效期判断) + * @returns 订单费用汇总(含所有子项和实付金额,新增商品券/满减券细分) + */ +export function calculateOrderCostSummary( + goodsList: BaseCartItem[], + dinnerType: "dine-in" | "take-out", // 前端就餐类型 + backendCoupons: BackendCoupon[] = [], + activities: ActivityConfig[] = [], + config: OrderExtraConfig, // 含后端满减活动、currentDinnerType + cartOrder: Record = {}, + currentTime: Date = new Date() +): OrderCostSummary { + // ------------------------------ 1. 基础费用计算 ------------------------------ + const goodsOriginalAmount = calcGoodsOriginalAmount(goodsList); // 商品原价总和 + const goodsRealAmount = calcGoodsRealAmount( // 商品折扣后总和 + goodsList, + { isMember: config.isMember, memberDiscountRate: config.memberDiscountRate }, + activities + ); + const goodsDiscountAmount = calcGoodsDiscountAmount(goodsOriginalAmount, goodsRealAmount); // 商品折扣金额 + const newUserDiscount = config.newUserDiscount || 0; // 新客立减 + + // ------------------------------ 2. 满减活动计算(核心步骤) ------------------------------ + let usedFullReductionActivity: FullReductionActivity | undefined; + let usedFullReductionThreshold: FullReductionThreshold | undefined; + let fullReductionAmount = 0; + + // 2.1 筛选最优满减活动(后端活动列表、当前店铺、就餐类型、时间) + usedFullReductionActivity = filterOptimalFullReductionActivity( + config.fullReductionActivities, + Number(config.currentStoreId), // 转换为数字(后端shopId是number) + config.currentDinnerType, // 后端useType匹配的就餐类型(如"dine") + currentTime + ); + + // 2.2 计算满减基数(先扣新客立减) + const baseAfterNewUserDiscount = new BigNumber(goodsRealAmount) + .minus(newUserDiscount) + .isGreaterThan(0) + ? new BigNumber(goodsRealAmount).minus(newUserDiscount).toNumber() + : 0; + + // 2.3 选择最优满减阈值(多门槛场景) + if (usedFullReductionActivity) { + usedFullReductionThreshold = selectOptimalThreshold( + usedFullReductionActivity.thresholds, + baseAfterNewUserDiscount, + goodsOriginalAmount, + goodsRealAmount, + usedFullReductionActivity.discountShare || 0 // 与限时折扣同享规则 + ); + + // 2.4 计算满减实际减免金额 + fullReductionAmount = calcFullReductionAmount( + baseAfterNewUserDiscount, + usedFullReductionActivity, + usedFullReductionThreshold, + ); + } + + // ------------------------------ 3. 优惠券抵扣(适配满减同享规则) ------------------------------ + let couponDeductionAmount = 0; + let productCouponDeduction = 0; + let fullCouponDeduction = 0; + let usedCoupon: Coupon | undefined; + let excludedProductIds: string[] = []; + + // 若满减与优惠券同享(couponShare=1),才计算优惠券;否则优惠券抵扣为0 + if (usedFullReductionActivity?.couponShare === 1) { + const couponResult = calcCouponDeduction( // 原有优惠券计算函数 + backendCoupons, + goodsList, + { + currentStoreId: config.currentStoreId, + isMember: config.isMember, + memberDiscountRate: config.memberDiscountRate, + activities, + cartOrder, + dinnerType, + currentTime + } + ); + couponDeductionAmount = couponResult.deductionAmount; + productCouponDeduction = couponResult.productCouponDeduction; + fullCouponDeduction = couponResult.fullCouponDeduction; + usedCoupon = couponResult.usedCoupon; + excludedProductIds = couponResult.excludedProductIds; + } + + // ------------------------------ 4. 积分抵扣(适配满减同享规则) ------------------------------ + let pointDeductionAmount = 0; + let usedPoints = 0; + + // 计算积分抵扣基数(商品折扣后 - 新客立减 - 满减 - 优惠券) + const maxPointDeductionLimit = new BigNumber(goodsRealAmount) + .minus(newUserDiscount) + .minus(fullReductionAmount) + .minus(couponDeductionAmount) + .isGreaterThan(0) + ? new BigNumber(goodsRealAmount).minus(newUserDiscount).minus(fullReductionAmount).minus(couponDeductionAmount).toNumber() + : 0; + + // 若满减与积分同享(pointsShare=1),才计算积分;否则积分抵扣为0 + if ((usedFullReductionActivity?.pointsShare === 1) && maxPointDeductionLimit > 0) { + const pointResult = calcPointDeduction( + config.userPoints, + config.pointDeductionRule, + maxPointDeductionLimit + ); + pointDeductionAmount = pointResult.deductionAmount; + usedPoints = pointResult.usedPoints; + } + + // ------------------------------ 5. 其他费用计算(原有逻辑不变) ------------------------------ + const packFee = calcTotalPackFee(goodsList, dinnerType); // 打包费 + let seatFee = calcSeatFee(config.seatFeeConfig); // 餐位费 + seatFee = dinnerType === "dine-in" ? seatFee : 0; // 外卖不收餐位费 + const additionalFee = Math.max(0, config.additionalFee); // 附加费 + + // 商家减免计算(原有逻辑不变) + const merchantReductionConfig = config.merchantReduction; + let merchantReductionActualAmount = 0; + const maxMerchantReductionLimit = new BigNumber(goodsRealAmount) + .minus(newUserDiscount) + .minus(fullReductionAmount) + .minus(couponDeductionAmount) + .minus(pointDeductionAmount) + .plus(seatFee) + .plus(packFee) + .isGreaterThan(0) + ? new BigNumber(goodsRealAmount).minus(newUserDiscount).minus(fullReductionAmount).minus(couponDeductionAmount).minus(pointDeductionAmount).plus(seatFee).plus(packFee).toNumber() + : 0; + + switch (merchantReductionConfig.type) { + case MerchantReductionType.FIXED_AMOUNT: + merchantReductionActualAmount = Math.min( + merchantReductionConfig.fixedAmount || 0, + maxMerchantReductionLimit + ); + break; + case MerchantReductionType.DISCOUNT_RATE: + const validRate = Math.max(0, Math.min(100, merchantReductionConfig.discountRate || 0)) / 100; + merchantReductionActualAmount = maxMerchantReductionLimit * (1 - validRate); + break; + } + merchantReductionActualAmount = Math.max(0, truncateToTwoDecimals(merchantReductionActualAmount)); + + // ------------------------------ 6. 最终实付金额计算 ------------------------------ + const finalPayAmountBn = new BigNumber(goodsRealAmount) + .minus(newUserDiscount) + .minus(fullReductionAmount) + .minus(couponDeductionAmount) + .minus(pointDeductionAmount) + .minus(merchantReductionActualAmount) + .plus(seatFee) + .plus(packFee) + .plus(additionalFee); + const finalPayAmount = Math.max(0, truncateToTwoDecimals(finalPayAmountBn.toNumber())); + + // ------------------------------ 7. 总优惠金额计算 ------------------------------ + const totalDiscountAmount = truncateToTwoDecimals( + new BigNumber(goodsDiscountAmount) + .plus(newUserDiscount) + .plus(fullReductionAmount) + .plus(couponDeductionAmount) + .plus(pointDeductionAmount) + .plus(merchantReductionActualAmount) + .toNumber() + ); + // 积分可抵扣最大金额 + + // ------------------------------ 8. 返回完整结果 ------------------------------ + return { + goodsTotal: goodsList.reduce((sum, g) => sum + Math.max(0, g.number - (g.returnNum || 0)), 0), + goodsRealAmount, + goodsOriginalAmount, + goodsDiscountAmount, + couponDeductionAmount, + productCouponDeduction, + fullCouponDeduction, + pointDeductionAmount, + seatFee, + packFee, + totalDiscountAmount, + // 满减活动明细(后端字段) + fullReduction: { + usedActivity: usedFullReductionActivity, + usedThreshold: usedFullReductionThreshold, + actualAmount: truncateToTwoDecimals(fullReductionAmount) + }, + merchantReduction: { + type: merchantReductionConfig.type, + originalConfig: merchantReductionConfig, + actualAmount: merchantReductionActualAmount + }, + additionalFee, + finalPayAmount, + couponUsed: usedCoupon, + pointUsed: usedPoints, + newUserDiscount, + dinnerType, + config + }; +} + +export function isWeightGoods(goods: BaseCartItem): boolean { + return goods.product_type === GoodsType.WEIGHT; +} + +// ============================ 7. 对外暴露工具库 ============================ +export const OrderPriceCalculator = { + // 基础工具 + truncateToTwoDecimals, + isTemporaryGoods, + isGiftGoods, + formatDiscountRate, + filterThresholdGoods, + isWeightGoods, + // 商品价格计算 + calcSingleGoodsRealPrice, + calcGoodsOriginalAmount, + calcGoodsRealAmount, + calcGoodsDiscountAmount, + //满减活动工具 + filterOptimalFullReductionActivity, + // 优惠券计算 + calcCouponDeduction, + // 其他费用计算 + calcTotalPackFee, + calcSeatFee, + calcPointDeduction, + // 核心入口 + calculateOrderCostSummary, + // 枚举导出 + Enums: { + GoodsType, + CouponType, + ActivityType, + WEEKDAY_MAP, + }, +}; + +export default OrderPriceCalculator; diff --git a/lib/goods.ts b/lib/goods.ts new file mode 100644 index 0000000..91c5924 --- /dev/null +++ b/lib/goods.ts @@ -0,0 +1,1382 @@ +import { BigNumber } from "bignumber.js"; + +// 配置BigNumber精度 +BigNumber.set({ + DECIMAL_PLACES: 2, + ROUNDING_MODE: BigNumber.ROUND_DOWN, // 向下取整,符合业务需求 +}); +/** + * 购物车订单价格计算公共库 + * 功能:覆盖订单全链路费用计算(商品价格、优惠券、积分、餐位费等),支持策略扩展 + * 小数处理:使用bignumber.js确保精度,统一舍去小数点后两位(如 10.129 → 10.12,15.998 → 15.99) + * 扩展设计:优惠券/营销活动采用策略模式,新增类型仅需扩展策略,无需修改核心逻辑 + * 关键规则: + * - 所有优惠券均支持指定门槛商品(后端foods字段:空字符串=全部商品,ID字符串=指定商品ID) + * - 与限时折扣/会员价同享规则:开启则门槛计算含对应折扣,关闭则用原价/非会员价 + * 字段说明: + * - BaseCartItem.id:购物车项ID(唯一标识购物车中的条目) + * - BaseCartItem.product_id:商品ID(唯一标识商品,用于优惠券/活动匹配) + * - BaseCartItem.skuData.id:SKU ID(唯一标识商品规格) + */ + +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" +) { + goods={...goods,product_id:goods.product_id||goods.productId|| goods.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 * 1 <= 0) { + + return true; + }else{ + return false; + } + } + } + + 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 + ); + let canuseLimit=false; + if(goods.hasOwnProperty('isTimeDiscount')||goods.hasOwnProperty('is_time_discount')){ + canuseLimit=goods.isTimeDiscount?true:goods.is_time_discount?true:false; + }else{ + 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.12,1.01 → 1.01,1.0100000001 → 1.01 + * @param num 待处理数字 + * @returns 处理后保留两位小数的数字 + */ +export function truncateToTwoDecimals(num: number | string): number { + return new BigNumber(num).decimalPlaces(2, BigNumber.ROUND_DOWN).toNumber(); +} + +/** + * 判断商品是否为临时菜(临时菜不参与优惠券门槛和折扣计算) + * @param goods 商品项 + * @returns 是否临时菜 + */ +export function isTemporaryGoods(goods: BaseCartItem): boolean { + return !!goods.is_temporary|| !!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=购物车ID,value=加入时间戳) + * @returns 排序后的商品列表 + */ +export function sortGoodsForCoupon( + goodsList: BaseCartItem[], + sortRule: "low_price_first" | "high_price_first", + cartOrder: Record = {} +): BaseCartItem[] { + return [...goodsList].sort((a, b) => { + // 1. 按商品单价排序(优先级最高) + const priceA = a.skuData?.salePrice ?? a.salePrice; + const priceB = b.skuData?.salePrice ?? b.salePrice; + if (priceA !== priceB) { + return sortRule === "low_price_first" ? priceA - priceB : priceB - priceA; + } + + // 2. 同价格按商品数量排序(降序,多的优先) + if (a.number !== b.number) { + return b.number - a.number; + } + + // 3. 同价格同数量按加入购物车顺序(早的优先,用购物车ID匹配) + const orderA = cartOrder[String(a.id)] ?? Infinity; + const orderB = cartOrder[String(b.id)] ?? Infinity; + return orderA - orderB; + }); +} + +/** + * 计算优惠券门槛金额(根据同享规则,按商品ID匹配限时折扣) + * @param eligibleGoods 可参与优惠券的商品列表(已过滤临时菜/赠菜) + * @param coupon 优惠券(含discountShare/vipPriceShare配置) + * @param config 订单配置(会员信息) + * @param activities 全局营销活动(限时折扣,applicableProductIds为商品ID) + * @returns 满足优惠券门槛的金额基数 + */ +export function calcCouponThresholdAmount( + eligibleGoods: BaseCartItem[], + coupon: BaseCoupon, + config: Pick, + activities: ActivityConfig[] = [] +): number { + let total = new BigNumber(0); + + for (const goods of eligibleGoods) { + const availableNum = Math.max(0, goods.number - (goods.returnNum || 0)); + if (availableNum <= 0) continue; + + // 1. 基础金额:默认用商品原价(SKU原价优先) + const basePrice = new BigNumber( + goods.skuData?.salePrice ?? goods.salePrice + ); + let itemAmount = basePrice.multipliedBy(availableNum); + + // 2. 处理「与会员价/会员折扣同享」规则:若开启,用会员价计算 + if (coupon.vipPriceShare) { + const memberPrice = new BigNumber( + calcMemberPrice(goods, config.isMember, config.memberDiscountRate) + ); + itemAmount = memberPrice.multipliedBy(availableNum); + } + + // 3. 处理「与限时折扣同享」规则:若开启,叠加限时折扣(按商品ID匹配活动) + if (coupon.discountShare) { + const activity = + goods.activityInfo ?? + activities.find( + (act) => + act.type === ActivityType.TIME_LIMIT_DISCOUNT && + (act.applicableProductIds || []).includes(String(goods.product_id)) // 核心修正:用商品ID匹配活动 + ); + + if (activity) { + itemAmount = itemAmount.multipliedBy(activity.discountRate); // 叠加限时折扣 + } + } + + total = total.plus(itemAmount); + } + + return truncateToTwoDecimals(total.toNumber()); +} + +// ============================ 3. 商品核心价格计算(核心修正:按商品ID匹配营销活动) ============================ +/** + * 计算单个商品的实际单价(整合商家改价、会员优惠、营销活动折扣,按商品ID匹配活动) + * @param goods 商品项 + * @param config 订单额外配置(含会员、活动信息) + * @returns 单个商品实际单价(元) + */ +export function calcSingleGoodsRealPrice( + goods: BaseCartItem, + config: Pick< + 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; + 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=购物车ID,value=时间戳) + * @param currentTime 当前时间(用于优惠券有效期判断) + * @returns 订单费用汇总(含所有子项和实付金额,新增商品券/满减券细分) + */ +export function calculateOrderCostSummary( + goodsList: BaseCartItem[], + dinnerType: "dine-in" | "take-out", // 前端就餐类型 + backendCoupons: BackendCoupon[] = [], + activities: ActivityConfig[] = [], + config: OrderExtraConfig, // 含后端满减活动、currentDinnerType + cartOrder: Record = {}, + currentTime: Date = new Date() +): OrderCostSummary { + //是否使用霸王餐,霸王餐配置 + 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; diff --git a/lib/index.ts b/lib/index.ts new file mode 100644 index 0000000..b534bf5 --- /dev/null +++ b/lib/index.ts @@ -0,0 +1,11 @@ +export * from "./types"; +import OrderPriceCalculator from "./goods"; +import couponUtils from "./coupon"; +import limitUtils from "./limit"; + +export { OrderPriceCalculator, couponUtils, limitUtils }; +export default { + OrderPriceCalculator, + couponUtils, + limitUtils, +}; diff --git a/lib/limit.ts b/lib/limit.ts new file mode 100644 index 0000000..f0591b3 --- /dev/null +++ b/lib/limit.ts @@ -0,0 +1,216 @@ +import BigNumber from "bignumber.js"; + +import _ from "lodash"; + +import { + BaseCartItem, + ShopUserInfo, + ShopInfo, + TimeLimitDiscountConfig, + CanReturnMemberPriceArgs, + returnPriceArgs, +} from "./types"; + +/** + * 判断商品是否可以使用限时折扣 + * @param goods 商品对象 + * @param limitTimeDiscountRes 限时折扣配置 + * @param shopInfo 店铺信息 + * @param shopUserInfo 店铺用户信息 + * @param idKey 商品ID键名,默认"id" + * @returns + */ +export function canUseLimitTimeDiscount( + goods: BaseCartItem, + limitTimeDiscountRes: TimeLimitDiscountConfig | null | undefined, + shopInfo: ShopInfo, + shopUserInfo: ShopUserInfo, + idKey = "id" as keyof BaseCartItem +) { + shopInfo = shopInfo || {}; + shopUserInfo = shopUserInfo || {}; + if(shopInfo.isMemberPrice){ + shopUserInfo.isMemberPrice=1 + } + if (!limitTimeDiscountRes || !limitTimeDiscountRes.id) { + return false; + } + + const canUseFoods = (limitTimeDiscountRes.foods || "").split(","); + + const goodsCanUse = + limitTimeDiscountRes.foodType == 1 || + canUseFoods.includes(`${goods[idKey]}`); + if (!goodsCanUse) { + return false; + } + if (limitTimeDiscountRes.discountPriority == "limit-time") { + return true; + } + if (limitTimeDiscountRes.discountPriority == "vip-price") { + if ( + shopUserInfo.isVip == 1 && + shopUserInfo.isMemberPrice == 1 && + goods.memberPrice * 1 > 0 + ) { + return false; + } + return true; + + } + + return false; +} + +/** + * 返回商品显示价格 + * @params {*} args 参数对象 + * @params {*} args.goods 商品对象 + * @params {*} args.shopInfo 店铺信息 + * @params {*} args.limitTimeDiscountRes 限时折扣信息 + * @params {*} args.shopUserInfo 店铺用户信息 + * @returns + */ +export function returnPrice(args: returnPriceArgs) { + let { + goods, + shopInfo, + limitTimeDiscountRes, + shopUserInfo, + idKey = "product_id", + } = args; + limitTimeDiscountRes = limitTimeDiscountRes || { + foods: "", + foodType: 2, + discountPriority: "", + discountRate: 0, + id: 0, + shopId: 0, + useType: "", + }; + const canUseFoods = (limitTimeDiscountRes.foods || "").split(","); + const includesGoods = + limitTimeDiscountRes.foodType == 1 || + canUseFoods.includes("" + goods[idKey]); + shopInfo = shopInfo || {}; + shopUserInfo = shopUserInfo || {}; + if ( + shopUserInfo.isMemberPrice == 1 && + shopUserInfo.isVip == 1 && + shopInfo.isMemberPrice == 1 + ) { + const memberPrice = goods.memberPrice || goods.salePrice; + + //是会员而且启用会员价 + if (limitTimeDiscountRes) { + //使用限时折扣 + //限时折扣优先 + if (limitTimeDiscountRes.discountPriority == "limit-time") { + if (includesGoods) { + return returnLimitPrice({ + price: goods.salePrice, + limitTimeDiscountRes, + }); + } else { + return memberPrice; + } + } + if ( + limitTimeDiscountRes.discountPriority == "vip-price" && + includesGoods + ) { + if (goods.memberPrice * 1 > 0) { + //会员优先 + return memberPrice; + } else { + const price = returnLimitPrice({ + price: goods.salePrice, + limitTimeDiscountRes, + goods: goods, + }); + + return price; + } + } else { + return memberPrice; + } + } else { + //是会员没有限时折扣 + return memberPrice; + } + } else { + //不是会员或者没有启用会员价 + if (limitTimeDiscountRes && limitTimeDiscountRes.id && includesGoods) { + const price = returnLimitPrice({ + price: goods.salePrice, + limitTimeDiscountRes, + goods: goods, + }); + + return price; + } else { + return goods.salePrice; + } + } +} + +interface returnLimitPriceArgs { + limitTimeDiscountRes: TimeLimitDiscountConfig | null | undefined; + price: number; + goods?: BaseCartItem; +} +/** + * 返回限时折扣价格 + * @params {*} args 参数对象 + * @params {*} args.limitTimeDiscountRes 限时折扣信息 + * @params {*} args.price 商品价格 + * @param {*} args.goods 商品对象 + * @returns + */ +export function returnLimitPrice(args: returnLimitPriceArgs) { + const { limitTimeDiscountRes, price, goods } = args; + const discountRate = new BigNumber( + limitTimeDiscountRes ? limitTimeDiscountRes.discountRate : 100 + ).dividedBy(100); + + const result = BigNumber(price) + .times(discountRate) + .decimalPlaces(2, BigNumber.ROUND_UP) + .toNumber(); + return result; +} + +/** + * 判断是否返回会员价 + * @param {*} args 参数对象 + * @param {*} args.shopInfo 店铺信息 + * @param {*} args.shopUserInfo 店铺用户信息 + * @returns + */ +export function canReturnMemberPrice(args: CanReturnMemberPriceArgs) { + const { shopInfo, shopUserInfo } = args; + if (shopUserInfo.isMemberPrice == 1 && shopUserInfo.isVip == 1) { + return true; + } else { + return false; + } +} + +/** + * 返回会员价格 + * @param {*} goods + * @returns + */ +export function returnMemberPrice(goods: BaseCartItem) { + return goods.memberPrice || goods.salePrice; +} + +export const utils = { + returnPrice, + canUseLimitTimeDiscount, + returnLimitPrice, + canReturnMemberPrice, + returnMemberPrice, +}; + +export default utils; diff --git a/lib/socket.ts b/lib/socket.ts new file mode 100644 index 0000000..e69de29 diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 0000000..4cb709c --- /dev/null +++ b/lib/types.ts @@ -0,0 +1,430 @@ +/** 商品类型枚举 */ +export enum GoodsType { + NORMAL = "normal", // 普通商品 + WEIGHT = "weight", // 称重商品 + GIFT = "gift", // 赠菜(继承普通商品逻辑,标记用) + EMPTY = "", // 空字符串类型(后端未返回时默认归类为普通商品) + PACKAGE = "package", // 打包商品(如套餐/预打包商品,按普通商品逻辑处理,可扩展特殊规则) +} + +/** 优惠券计算结果类型(新增细分字段) */ +export interface CouponResult { + deductionAmount: number; // 抵扣金额 + excludedProductIds: string[]; // 不适用商品ID列表(注意:是商品ID,非购物车ID) + usedCoupon: Coupon | undefined; // 实际使用的优惠券 + productCouponDeduction: number; // 新增:商品优惠券抵扣(兑换券等) + fullCouponDeduction: number; // 新增:满减优惠券抵扣 +} + +/** 兑换券计算结果类型(新增细分字段) */ +export interface ExchangeCalculationResult { + deductionAmount: number; + excludedProductIds: string[]; // 不适用商品ID列表(商品ID) + productCouponDeduction: number; // 新增:兑换券属于商品券,同步记录 +} + +export interface CouponTypes { + 1: "满减券"; + 2: "商品券"; + 3: "折扣券"; + 4: "第二件半价券"; + 5: "消费送券"; + 6: "买一送一券"; + 7: "固定价格券"; + 8: "免配送费券"; +} + +/** 优惠券类型枚举 */ +export enum CouponType { + FULL_REDUCTION = "full_reduction", // 满减券 + DISCOUNT = "discount", // 折扣券 + SECOND_HALF = "second_half", // 第二件半价券 + BUY_ONE_GET_ONE = "buy_one_get_one", // 买一送一券 + EXCHANGE = "exchange", // 商品兑换券 +} + +/** 后端返回的优惠券原始字段类型 */ +export interface BackendCoupon { + id?: number; // 自增主键(int64) + shopId?: number; // 店铺ID(int64) + syncId?: number; // 同步Id(int64) + type?: number; // 优惠券类型:1-满减券,2-商品兑换券,3-折扣券,4-第二件半价券,5-消费送券,6-买一送一券,7-固定价格券,8-免配送费券 + name?: string; // 券名称 + useShopType?: string; // 可用门店类型:only-仅本店;all-所有门店,custom-指定门店 + useShops?: string; // 可用门店(逗号分隔字符串,如"1,2,3") + useType?: string; // 可使用类型:dine堂食/pickup自取/deliv配送/express快递 + validType?: string; // 有效期类型:fixed(固定时间),custom(自定义时间) + validDays?: number; // 有效期(天) + validStartTime?: string; // 有效期开始时间(如"2024-01-01 00:00:00") + validEndTime?: string; // 有效期结束时间 + daysToTakeEffect?: number; // 隔天生效 + useDays?: string; // 可用周期(如"周一,周二") + useTimeType?: string; // 可用时间段类型:all-全时段,custom-指定时段 + useStartTime?: string; // 可用开始时间(每日) + useEndTime?: string; // 可用结束时间(每日) + getType?: string; // 发放设置:不可自行领取/no,可领取/yes + getMode?: string; // 用户领取方式 + giveNum?: number; // 总发放数量,-10086为不限量 + getUserType?: string; // 可领取用户:全部/all,新用户一次/new,仅会员/vip + getLimit?: number; // 每人领取限量,-10086为不限量 + useLimit?: number; // 每人每日使用限量,-10086为不限量 + discountShare?: number; // 与限时折扣同享:0-否,1-是 + vipPriceShare?: number; // 与会员价同享:0-否,1-是 + ruleDetails?: string; // 附加规则说明 + status?: number; // 状态:0-禁用,1-启用 + useNum?: number; // 已使用数量 + leftNum?: number; // 剩余数量 + foods?: string; // 指定门槛商品(逗号分隔字符串,如"101,102",此处为商品ID) + fullAmount?: number; // 使用门槛:满多少金额(元) + discountAmount?: number; // 使用门槛:减多少金额(元) + discountRate?: number; // 折扣%(如90=9折) + maxDiscountAmount?: number; // 可抵扣最大金额(元) + useRule?: string; // 使用规则:price_asc-价格低到高,price_desc-高到低 + discountNum?: number; // 抵扣数量 + otherCouponShare?: number; // 与其它优惠共享:0-否,1-是 + createTime?: string; // 创建时间 + updateTime?: string; // 更新时间 +} + +/** 营销活动类型枚举 */ +export enum ActivityType { + TIME_LIMIT_DISCOUNT = "time_limit_discount", // 限时折扣 +} + +/** 基础购物车商品项(核心修正:新增product_id,明确各ID含义) */ +export interface BaseCartItem { + id: string | number; // 购物车ID(唯一标识购物车中的条目,如购物车项主键) + product_id: string | number; // 商品ID(唯一标识商品,用于优惠券/活动匹配,必选) + productId?: string | number; // 商品ID + salePrice: number; // 商品原价(元) + number: number; // 商品数量 + num?: number; // 商品数量 + isTimeDiscount?: boolean; // 是否限时折扣商品(默认false) + is_time_discount?: boolean; // 是否限时折扣商品(默认false) + product_type: GoodsType; // 商品类型 + is_temporary?: boolean; // 是否临时菜(默认false) + isTemporary?: boolean; // 是否临时菜(默认false) + is_gift?: boolean; // 是否赠菜(默认false) + isGift?: boolean; // 是否赠菜(默认false) + returnNum?: number; // 退货数量(历史订单用,默认0) + memberPrice: number; // 商品会员价(元,优先级:商品会员价 > 会员折扣) + discountSaleAmount?: number; // 商家改价后单价(元,优先级最高) + discount_sale_amount?: 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原价 + }; + skuId?: string | number; // SKU ID(唯一标识商品规格,如颜色/尺寸) + couponType?: number; // 优惠券类型:1-满减券,2-商品兑换券,3-折扣券,4-第二件半价券,5-消费送券,6-买一送一券,7-固定价格券,8-免配送费券 +} + +export interface CouponFoods { + id: string; + name: string; + images: string; +} + +/** 基础优惠券接口(所有券类型继承,包含统一门槛商品字段) */ +export interface BaseCoupon { + otherCouponShare?: number; // 与其它优惠共享:0-否,1-是 + id: string | number; // 优惠券ID + type: number; // 工具库字符串枚举(由后端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; // 可抵扣最大金额 元 + use: boolean; + discountNum?: number; // 抵扣数量 + useRule?: string; // 使用规则:price_asc-价格低到高,price_desc-高到低 + discountRate?: number; // 折扣%(如90=9折) + noUseRestrictions?: boolean; // 是不可用原因 + thresholdFoods: CouponFoods[]; // 门槛商品ID列表(空数组=全部商品,非空=指定商品ID) + useFoods: CouponFoods[]; // 可用商品ID列表(空数组=全部商品,非空=指定商品ID) +} +export interface couponDiscount { + discountPrice: number; + hasDiscountGoodsArr: BaseCartItem[]; +} +/** 满减券(适配后端字段) */ +export interface FullReductionCoupon extends BaseCoupon { + fullAmount: number; // 对应后端fullAmount(满减门槛) + discountAmount: number; // 对应后端discountAmount(减免金额) + maxDiscountAmount?: number; // 对应后端maxDiscountAmount(最大减免) + discount?: couponDiscount; +} + +/** 折扣券(适配后端字段) */ +export interface DiscountCoupon extends BaseCoupon { + discountRate: number; // 后端discountRate(%)转小数(如90→0.9) + maxDiscountAmount: number; // 对应后端maxDiscountAmount(最大减免) + discount?: couponDiscount; +} + +/** 第二件半价券(适配后端字段) */ +export interface SecondHalfPriceCoupon extends BaseCoupon { + maxUseCountPerOrder?: number; // 对应后端useLimit(-10086=不限) + discount?: couponDiscount; +} + +/** 买一送一券(适配后端字段) */ +export interface BuyOneGetOneCoupon extends BaseCoupon { + maxUseCountPerOrder?: number; // 对应后端useLimit(-10086=不限) + discount?: couponDiscount; +} + +/** 商品兑换券(适配后端字段) */ +export interface ExchangeCoupon extends BaseCoupon { + deductCount: number; // 对应后端discountNum(抵扣数量) + sortRule: "low_price_first" | "high_price_first"; // 后端useRule转换 + discount?: couponDiscount; +} + +/** 所有优惠券类型联合 */ +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 FreeDineConfig { + enable: boolean; //是否开启 + rechargeThreshold: number; //订单满多少元可以使用 + rechargeTimes: number; //充值多少倍免单 + withCoupon: boolean; //与优惠券同享 + withPoints: boolean; //与积分同享 + useType?: string[]; //使用类型 dine-in店内 takeout 自取 post快递,takeaway外卖 + useShopType?: string; //all 全部 part部分 + shopIdList?: number[]; //可用门店id +} + +//限时折扣配置 +export interface TimeLimitDiscountConfig { + /** + * 折扣优先级 limit-time/vip-price + */ + discountPriority: string; + /** + * 折扣% 范围1-99 + */ + discountRate: number; + /** + * 参与商品 + */ + foods: string; + /** + * 参与商品 1全部 2部分 + */ + foodType: number; + /** + * 自增主键 + */ + id: number; + /** + * 店铺ID + */ + shopId: number; + /** + * 可使用类型:堂食 dine-in 外带 take-out 外卖 take-away 配送 post + */ + useType: string; + [property: string]: any; +} + +//用户信息 +export interface ShopUserInfo { + isVip: number | null; //是否会员 + discount: number | null; //用户折扣 + isMemberPrice: number | null; //会员折扣与会员价是否同时使用 + id?: number; //用户ID +} +/** 订单额外费用配置 */ +export interface OrderExtraConfig { + // merchantReduction: number; // 商家减免金额(元,默认0) + // 替换原单一金额字段,支持两种减免形式 + merchantReduction: MerchantReductionConfig; + additionalFee: number; // 附加费(元,如余额充值、券包,默认0) + pointDeductionRule: PointDeductionRule; // 积分抵扣规则 + seatFeeConfig: SeatFeeConfig; // 餐位费配置 + currentStoreId: string; // 当前门店ID(用于验证优惠券适用门店) + userPoints: number; // 用户当前积分(用于积分抵扣) + isMember: boolean; // 用户是否会员(用于会员优惠) + memberDiscountRate?: number; // 会员折扣率(如0.95=95折,无会员价时用) + newUserDiscount?: number; // 新用户减免金额(元,默认0) + fullReductionActivities: FullReductionActivity[]; // 当前店铺的满减活动列表(后端返回结构) + currentDinnerType: "dine-in" | "take-out" | "take-away" | "post"; // 当前就餐类型(匹配useType) + isFreeDine?: boolean; //是否霸王餐 + freeDineConfig?: FreeDineConfig; + limitTimeDiscount?: TimeLimitDiscountConfig; //限时折扣 + shopUserInfo: ShopUserInfo; // 用户信息 +} + +/** 订单费用汇总(修改:补充商家减免类型和明细) */ +export interface OrderCostSummary { + goodsList: BaseCartItem[]; + // 商品总件数 + goodsTotal: number; + totalDiscountAmount: number; + goodsRealAmount: number; // 商品真实原价总和 + goodsOriginalAmount: number; // 商品原价总和 + goodsDiscountAmount: number; // 商品折扣金额 + couponDeductionAmount: number; // 优惠券总抵扣 + productCouponDeduction: number; // 商品优惠券抵扣 + fullCouponDeduction: number; // 满减优惠券抵扣 + pointDeductionAmount: number; // 积分抵扣金额 + seatFee: number; // 餐位费 + packFee: number; // 打包费 + scoreMaxMoney: number; // 积分最大可抵扣金额 + // 新增:商家减免明细 + merchantReduction: { + type: MerchantReductionType; // 实际使用的减免类型 + originalConfig: MerchantReductionConfig; // 原始配置(便于前端展示) + actualAmount: number; // 实际减免金额(计算后的值,≥0) + }; + additionalFee: number; // 附加费 + finalPayAmount: number; // 最终实付金额 + couponUsed?: Coupon; // 实际使用的优惠券 + pointUsed: number; // 实际使用的积分 + newUserDiscount: number; // 新用户减免金额(元,默认0) + dinnerType?: "dine-in" | "take-out"; // 就餐类型(堂食/自取/配送/快递) + config: OrderExtraConfig; // 订单额外费用配置 + //满减活动 + fullReduction: { + usedFullReductionActivityFullAmount: number; // 计算出的满减活动的门槛金额 + usedActivity?: FullReductionActivity; // 实际使用的满减活动 + usedThreshold?: FullReductionThreshold; // 实际使用的满减阈值(多门槛中选最优) + actualAmount: number; // 满减实际减免金额(元) + }; + vipDiscountAmount: number; //会员折扣减免金额 + // 订单原支付金额 + orderOriginFinalPayAmount: number; //订单原金额(包含打包费+餐位费) +} + +/** 满减活动阈值(单条满减规则:满X减Y)- 对应 MkDiscountThresholdInsertGroupDefaultGroup */ +export interface FullReductionThreshold { + activityId?: number; // 关联满减活动ID + fullAmount?: number; // 满多少金额(元,必填) + discountAmount?: number; // 减多少金额(元,必填) +} + +/** 满减活动主表 - 对应 Request 接口(后端真实字段) */ +export interface FullReductionActivity { + id?: number; // 自增主键(后端字段:id) + shopId?: number; // 店铺ID(后端字段:shopId) + status?: number; // 活动状态:1=未开始,2=进行中,3=已结束(后端字段:status) + sort?: number; // 排序值(越大优先级越高,后端字段:sort) + createTime?: string; // 创建时间(后端字段:createTime,格式如"2025-10-14 13:56:07") + updateTime?: string; // 最新修改时间(后端字段:updateTime,用于优先级排序) + validStartTime?: string; // 有效期开始时间(后端字段:validStartTime,格式如"2025-10-14") + validEndTime?: string; // 有效期结束时间(后端字段:validEndTime,格式如"2025-12-14") + useType?: string; // 可使用类型(后端字段:useType,如"dine,pickup,deliv,express") + useDays?: string; // 可用周期(后端字段:useDays,如"周一,周二,周三,周四,周五,周六,周日") + useTimeType?: string; // 可用时间段类型(后端字段:useTimeType,all=全时段,custom=指定时段) + useStartTime?: string; // 每日可用开始时间(后端字段:useStartTime,如"09:00:00",仅custom时有效) + useEndTime?: string; // 每日可用结束时间(后端字段:useEndTime,如"22:00:00",仅custom时有效) + couponShare?: number; // 与优惠券同享:0=否,1=是(后端字段:couponShare) + discountShare?: number; // 与限时折扣同享:0=否,1=是(后端字段:discountShare) + vipPriceShare?: number; // 与会员价同享:0=否,1=是(后端字段:vipPriceShare) + pointsShare?: number; // 与积分抵扣同享:0=否,1=是(后端字段:pointsShare) + thresholds?: FullReductionThreshold[]; // 满减阈值列表(多门槛,后端字段:thresholds) + isDel?: boolean; // 是否删除:0=否,1=是(后端字段:isDel,默认false) +} + +// 辅助枚举:星期映射(用于useDays校验) +export const WEEKDAY_MAP = { + 周一: 1, + 周二: 2, + 周三: 3, + 周四: 4, + 周五: 5, + 周六: 6, + 周日: 0, // JS中getDay()返回0=周日 +}; + +export interface ShopInfo { + isMemberPrice: number; // 是否开启会员价 1是开启 + [property: string]: any; +} + +export interface couponCalcParams { + canDikouGoodsArr: BaseCartItem[]; + coupon: Coupon; + user: ShopUserInfo; + shopInfo: ShopInfo; + selCoupon: Coupon[]; + goodsOrderPrice: number; //商品订单总价 + isMemberPrice: number; // 是否开启会员价 1是开启 + limitTimeDiscount?: TimeLimitDiscountConfig | null | undefined; +} +export interface CanDikouGoodsArrArgs { + canDikouGoodsArr: BaseCartItem[]; + selCoupon: Coupon[]; + user: ShopUserInfo; + shopInfo: ShopInfo; + limitTimeDiscount?: TimeLimitDiscountConfig | null | undefined; +} +export interface returnPriceArgs { + goods: BaseCartItem; + selCoupon: Coupon[]; + user: ShopUserInfo; + shopInfo: ShopInfo; + shopUserInfo: ShopUserInfo; + limitTimeDiscountRes?: TimeLimitDiscountConfig | null | undefined; + idKey?: keyof BaseCartItem; +} + + +export interface CanReturnMemberPriceArgs { + shopInfo?: ShopInfo; + shopUserInfo: ShopUserInfo; +} diff --git a/lib/utils.ts b/lib/utils.ts new file mode 100644 index 0000000..5e25e75 --- /dev/null +++ b/lib/utils.ts @@ -0,0 +1,33 @@ +/** + * 通用字段兼容工具函数:处理驼峰/下划线命名的字段取值 + * @param obj 目标对象(如商品信息 BaseCartItem) + * @param camelCaseKey 驼峰命名字段(如 'isTemporary') + * @param snakeCaseKey 下划线命名字段(如 'is_temporary') + * @param defaultValue 默认值(默认 false,适配布尔类型字段) + * @returns 字段值(优先取存在的字段,无则返回默认值) + */ +export function getCompatibleFieldValue( + obj: Record, + camelCaseKey: string, + snakeCaseKey: string, + defaultValue: boolean = false +): boolean { + // 优先判断驼峰字段(如果存在且不是 undefined/null) + if ( + obj.hasOwnProperty(camelCaseKey) && + obj[camelCaseKey] !== undefined && + obj[camelCaseKey] !== null + ) { + return Boolean(obj[camelCaseKey]); + } + // 再判断下划线字段 + if ( + obj.hasOwnProperty(snakeCaseKey) && + obj[snakeCaseKey] !== undefined && + obj[snakeCaseKey] !== null + ) { + return Boolean(obj[snakeCaseKey]); + } + // 都不存在时返回默认值(布尔类型字段默认 false) + return defaultValue; +} \ No newline at end of file diff --git a/main.js b/main.js index 4e9d56d..ccd5728 100644 --- a/main.js +++ b/main.js @@ -7,6 +7,9 @@ import dict from '@/commons/utils/dict.js' import {utils} from '@/commons/utils/index.js' import uviewPlus from 'uview-plus' import * as Pinia from 'pinia'; +import { + createUnistorage +} from "pinia-plugin-unistorage"; // 设置node环境 envConfig.changeEnv(storageManage.env()) @@ -39,7 +42,9 @@ export function createApp() { const app = createSSRApp(App) app.use(uviewPlus) - app.use(Pinia.createPinia()); + const store = Pinia.createPinia(); + store.use(createUnistorage()); + app.use(store) app.config.globalProperties.$appName = appConfig.appName uni.$appName = appConfig.appName app.config.globalProperties.$utils = utils diff --git a/package.json b/package.json index 23df660..93b0524 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "dependencies": { + "bignumber.js": "^9.3.1", "clipboard": "^2.0.11", "dayjs": "^1.11.13", "gm-crypto": "^0.1.8", @@ -8,8 +9,10 @@ "jsbn": "^1.1.0", "jsencrypt": "^3.3.2", "lodash": "^4.17.21", + "pinia-plugin-unistorage": "^0.1.2", "to-arraybuffer": "^1.0.1", - "uview-plus": "^3.3.32" + "uview-plus": "^3.3.32", + "ysk-utils": "^1.0.78" }, "devDependencies": { "copy-webpack-plugin": "^12.0.2", diff --git a/pageGoodsGroup/edit-group-goods/components/goods.vue b/pageGoodsGroup/edit-group-goods/components/goods.vue index 5b57e61..482958e 100644 --- a/pageGoodsGroup/edit-group-goods/components/goods.vue +++ b/pageGoodsGroup/edit-group-goods/components/goods.vue @@ -4,7 +4,7 @@ - + diff --git a/pageMarket/discountActivity/add.vue b/pageMarket/discountActivity/add.vue index ef5e552..7d82407 100644 --- a/pageMarket/discountActivity/add.vue +++ b/pageMarket/discountActivity/add.vue @@ -41,10 +41,10 @@ 活动日期 - + > @@ -53,7 +53,7 @@ 可用周期 - + @@ -61,11 +61,11 @@ 指定时间段 - + > @@ -144,15 +144,11 @@ > - + diff --git a/pageProduct/index/components/goods.vue b/pageProduct/index/components/goods.vue index 9807017..86e8090 100644 --- a/pageProduct/index/components/goods.vue +++ b/pageProduct/index/components/goods.vue @@ -26,7 +26,7 @@ - + diff --git a/pages/appliccation/marketing.vue b/pages/appliccation/marketing.vue index 4c2a5cb..5a1fae2 100644 --- a/pages/appliccation/marketing.vue +++ b/pages/appliccation/marketing.vue @@ -1,154 +1,228 @@ diff --git a/pagesCreateOrder/choose-user/choose-user.vue b/pagesCreateOrder/choose-user/choose-user.vue index f5d6465..15d871e 100644 --- a/pagesCreateOrder/choose-user/choose-user.vue +++ b/pagesCreateOrder/choose-user/choose-user.vue @@ -79,7 +79,6 @@ name: '', totalElements: 0, size: 10, - isVip: 1 }) const list = reactive([]) let hasAjax = ref(false) diff --git a/pagesCreateOrder/confirm-order/components/list.vue b/pagesCreateOrder/confirm-order/components/list.vue new file mode 100644 index 0000000..bbcfc99 --- /dev/null +++ b/pagesCreateOrder/confirm-order/components/list.vue @@ -0,0 +1,610 @@ + + + + + \ No newline at end of file diff --git a/pagesCreateOrder/confirm-order/confirm-order.vue b/pagesCreateOrder/confirm-order/confirm-order.vue index 85d8bb9..3fccc9c 100644 --- a/pagesCreateOrder/confirm-order/confirm-order.vue +++ b/pagesCreateOrder/confirm-order/confirm-order.vue @@ -1,391 +1,675 @@ - - - 订单备注 - - - - - + + + 订单备注 + + + + + - - - - {{ goods.list.length }} - 份菜品 - + + + + {{ allGoodsList.length }} + 份菜品 + - - - - - - 临时菜 - - - - - - - - - - - - - - - - {{ item.name }} - - - {{ item.specInfo || ' ' }} - - - - - - - - ×{{ item.number }} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + 限时折扣 + + + + 临时菜 + + + + + + + + + + + + + + + + + + + {{ item.name }} + + + {{ + item.specInfo || " " + }} + + + + + + + + ×{{ item.number }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - 优惠金额 - ¥{{ $utils.toFixed(youhui) }} - - + + + + 优惠金额 + ¥{{ orderCostSummary.totalDiscountAmount }} + + - - 实收金额 - ¥{{ allPrice }} - - - + + 实收金额 + ¥{{ orderCostSummary.finalPayAmount }} + + + - - - - - - {{ pageData.shopInfo.registerType == 'before' || pageData.eatTypes.active == 'take-out' ? '结算' : '下单' }} - - - - - - - - - - + + + + + + + 下单 + + + {{ goods.list.length > 0 ? "下单" : "结算" }} + + + + + + + + + + + diff --git a/pagesCreateOrder/index/components/car.vue b/pagesCreateOrder/index/components/car.vue index 0707c20..db8eaed 100644 --- a/pagesCreateOrder/index/components/car.vue +++ b/pagesCreateOrder/index/components/car.vue @@ -1,285 +1,402 @@ @@ -288,104 +405,108 @@ $car-size: 96rpx; $car-top: -16rpx; @mixin fixedAll { - position: fixed; - left: 0; - right: 0; - top: 0; - bottom: 0; + position: fixed; + left: 0; + right: 0; + top: 0; + bottom: 0; } .total { - left: 0; - right: 0; - top: 0; - z-index: 1; + left: 0; + right: 0; + top: 0; + z-index: 1; } .icon { - width: 40rpx; - height: 40rpx; + width: 40rpx; + height: 40rpx; } .grayscale { - filter: opacity(60%); + filter: opacity(60%); } .mask { - @include fixedAll; - background: rgba(51, 51, 51, 0.5); + @include fixedAll; + background: rgba(51, 51, 51, 0.5); } .goods { - position: absolute; - bottom: 100%; - left: 0; - right: 0; - transition: all 0.2s ease-in-out; - overflow: hidden; + position: absolute; + bottom: 100%; + left: 0; + right: 0; + transition: all 0.2s ease-in-out; + overflow: hidden; - .item { - padding: 32rpx 28rpx; - } + .item { + padding: 32rpx 28rpx; + } } .border-bottom { - border-bottom: 1px solid #e5e5e5; + border-bottom: 1px solid #e5e5e5; } .border-top { - border-top: 1px solid #e5e5e5; + border-top: 1px solid #e5e5e5; } .red { - color: #eb4f4f; + color: #eb4f4f; } .car { - padding: 0 28rpx; - position: relative; - background-color: #fff; - padding-top: 10rpx; - padding-bottom: calc(40rpx + env(safe-area-inset-bottom)); - /* #ifdef H5 */ - padding-bottom: 68rpx; + padding: 0 28rpx; + position: relative; + background-color: #fff; + padding-top: 10rpx; + padding-bottom: calc(40rpx + env(safe-area-inset-bottom)); + /* #ifdef H5 */ + padding-bottom: 68rpx; - /* #endif */ - .icon-car-box { - position: absolute; - left: 28rpx; - top: $car-top; - display: flex; - width: $car-size; - height: $car-size; - justify-content: center; - align-items: center; - z-index: 2; + /* #endif */ + .icon-car-box { + position: absolute; + left: 28rpx; + top: $car-top; + display: flex; + width: $car-size; + height: $car-size; + justify-content: center; + align-items: center; + z-index: 2; - .dot { - position: absolute; - right: 0; - top: 0; - width: 28rpx; - height: 28rpx; - background: #eb4f4f; - border-radius: 50%; - display: flex; - justify-content: center; - align-items: center; - font-size: 20rpx; - font-weight: bold; - color: #ffffff; - } - } + .dot { + position: absolute; + right: 0; + top: 0; + width: 28rpx; + height: 28rpx; + background: #eb4f4f; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + font-size: 20rpx; + font-weight: bold; + color: #ffffff; + } + } - .price { - color: #eb4f4f; - margin-left: calc(38rpx + $car-size); - transform: translateY(calc($car-top / 2)); - } + .price { + color: #eb4f4f; + margin-left: calc(38rpx + $car-size); + transform: translateY(calc($car-top / 2)); + } - .icon-car { - width: $car-size; - height: $car-size; - } + .icon-car { + width: $car-size; + height: $car-size; + } +} +.old-price { + color: #999; + text-decoration: line-through; } diff --git a/pagesCreateOrder/index/components/guige.vue b/pagesCreateOrder/index/components/guige.vue index ac24f1f..e9d60af 100644 --- a/pagesCreateOrder/index/components/guige.vue +++ b/pagesCreateOrder/index/components/guige.vue @@ -1,233 +1,299 @@ \ No newline at end of file diff --git a/pagesCreateOrder/index/components/list-goods-item.vue b/pagesCreateOrder/index/components/list-goods-item.vue index 8f9abab..4f82604 100644 --- a/pagesCreateOrder/index/components/list-goods-item.vue +++ b/pagesCreateOrder/index/components/list-goods-item.vue @@ -1,232 +1,345 @@ \ No newline at end of file diff --git a/pagesCreateOrder/index/index.vue b/pagesCreateOrder/index/index.vue index a39f82e..5e21927 100644 --- a/pagesCreateOrder/index/index.vue +++ b/pagesCreateOrder/index/index.vue @@ -1,274 +1,379 @@ diff --git a/pagesCreateOrder/util.js b/pagesCreateOrder/util.js index 570bf6f..7c705f5 100644 --- a/pagesCreateOrder/util.js +++ b/pagesCreateOrder/util.js @@ -4,6 +4,8 @@ export function getNowCart(carItem,goodsList,user) { // console.log("carItem===",carItem) // console.log("goodsList===",goodsList) // const nowCart = records.find(v => v.placeNum == 0) + console.log("carItem===",carItem) + const arr = [] if( carItem.is_temporary != 1 ){ carItem.isGrounding = false; @@ -13,8 +15,9 @@ export function getNowCart(carItem,goodsList,user) { if(carItem.sku_id == item.id){ carItem.lowPrice = item.salePrice carItem.lowMemberPrice = item.memberPrice + carItem.memberPrice = item.memberPrice carItem.specInfo = item.specInfo - carItem.suitNum = item.suitNum + carItem.salePrice = item.salePrice if( uni.getStorageSync('shopInfo').isMemberPrice && user && user.id && user.isVip ){ carItem.salePrice = item.memberPrice @@ -38,8 +41,10 @@ export function getNowCart(carItem,goodsList,user) { carItem.number = parseFloat(carItem.number) carItem.name = carItem.product_name carItem.lowPrice = carItem.discount_sale_amount - carItem.discount_sale_amount = 0 + carItem.discount_sale_amount = carItem.discount_sale_amount?carItem.discount_sale_amount*1:0 + carItem.discountSaleAmount = carItem.discount_sale_amount + + // carItem.discount_sale_amount = 0 } - return carItem } \ No newline at end of file diff --git a/pagesOrder/pay-order/pay-order.vue b/pagesOrder/pay-order/pay-order.vue index d0b4d3b..a210866 100644 --- a/pagesOrder/pay-order/pay-order.vue +++ b/pagesOrder/pay-order/pay-order.vue @@ -1,1184 +1,1510 @@