From 9d3ae6272d00ccbf06297e065c04e09966c00784 Mon Sep 17 00:00:00 2001 From: YeMingfei666 <1619116647@qq.com> Date: Mon, 15 Sep 2025 10:03:16 +0800 Subject: [PATCH 01/13] =?UTF-8?q?add:=20=E6=9B=B4=E6=96=B0=E4=BC=98?= =?UTF-8?q?=E6=83=A0=E5=88=B8=E7=9B=B8=E5=85=B3=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/market/coupon.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/api/market/coupon.ts b/src/api/market/coupon.ts index b8b3ba1..a4c2c26 100644 --- a/src/api/market/coupon.ts +++ b/src/api/market/coupon.ts @@ -1,5 +1,6 @@ import request from "@/utils/request"; import { Market_BaseUrl } from "@/api/config"; +import { get } from "lodash"; const baseURL = Market_BaseUrl + "/admin/coupon"; const API = { getList(params: any) { @@ -9,6 +10,14 @@ const API = { params }); }, + //优惠券列表/已领取详情 + getDetail(params: any) { + return request({ + url: `${baseURL}/record`, + method: "get", + params + }); + }, } export default API; From 794c0ec25b462364b0acb9918fdb6d56211d53bb Mon Sep 17 00:00:00 2001 From: YeMingfei666 <1619116647@qq.com> Date: Mon, 15 Sep 2025 10:03:38 +0800 Subject: [PATCH 02/13] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E5=BC=80=E9=80=9A=E5=9B=9E=E6=98=BE=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/marketing_center/super_vip/index.vue | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/views/marketing_center/super_vip/index.vue b/src/views/marketing_center/super_vip/index.vue index 217d6d6..10aeb3f 100644 --- a/src/views/marketing_center/super_vip/index.vue +++ b/src/views/marketing_center/super_vip/index.vue @@ -344,7 +344,7 @@ const activeTab = ref("basic"); const conditionLists = ref([ { label: "绑定手机号", checked: false, code: "BIND_PHONE" }, { label: "订单达成指定次数", checked: false, value: "", code: "ORDER" }, - { label: "消费达到指定金额", checked: false, value: "", cpde: "COST_AMOUNT" }, + { label: "消费达到指定金额", checked: false, value: "", code: "COST_AMOUNT" }, { label: "充值达到指定金额", checked: false, value: "", code: "RECHARGE_AMOUNT" }, ]); const basicForm = reactive({ @@ -383,6 +383,14 @@ function basicSubmit() { // if (data.openType == "CONDITION") { // data.configList = null; // } + data.conditionList = conditionLists.value + .filter((v) => v.checked) + .map((v) => { + return { + code: v.code, + value: v.value, + }; + }); memberApi.editConfig(data).then((res) => { ElMessage.success("保存成功"); }); From 642e0a552ec58b44f59a1a4ddd046ce443206f50 Mon Sep 17 00:00:00 2001 From: YeMingfei666 <1619116647@qq.com> Date: Mon, 15 Sep 2025 10:04:42 +0800 Subject: [PATCH 03/13] =?UTF-8?q?docs:=20=E6=96=87=E6=A1=A3=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=8A=A0ysk=E5=B7=A5=E5=85=B7=E5=8C=85?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index fd2125a..5880cc4 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,16 @@ 基于 Vue3 + Vite5+ TypeScript5 + Element-Plus + Pinia 等主流技术栈构建 +## ysk-utils 工具类包 + +安装 +pnpm install ysk-utils +更新 +pnpm update ysk-utils +vscode如果无代码提示 +重启 VS Code 的 TypeScript 服务器 +输入 TypeScript: Restart TS Server + ## 正式宝塔 From 8714b04565719ba9bf86e8ce20a0862e20e247e0 Mon Sep 17 00:00:00 2001 From: YeMingfei666 <1619116647@qq.com> Date: Mon, 15 Sep 2025 10:05:12 +0800 Subject: [PATCH 04/13] =?UTF-8?q?add:=20=E5=A2=9E=E5=8A=A0ysk-utils?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index aee93eb..6763d88 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,8 @@ "vue": "^3.5.13", "vue-clipboard3": "^2.0.0", "vue-i18n": "^11.1.0", - "vue-router": "^4.5.0" + "vue-router": "^4.5.0", + "ysk-utils": "^1.0.5" }, "devDependencies": { "@commitlint/cli": "^19.7.1", From 82dcba273ed1b13f91aafbffa7ca371eeb17452f Mon Sep 17 00:00:00 2001 From: YeMingfei666 <1619116647@qq.com> Date: Tue, 16 Sep 2025 09:47:22 +0800 Subject: [PATCH 05/13] =?UTF-8?q?docs:=20=E6=96=87=E6=A1=A3=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5880cc4..3840cb2 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,21 @@ ## ysk-utils 工具类包 安装 + +``` pnpm install ysk-utils +``` + 更新 + +``` pnpm update ysk-utils +``` + vscode如果无代码提示 重启 VS Code 的 TypeScript 服务器 -输入 TypeScript: Restart TS Server +输入: +```TypeScript: Restart TS Server``` ## 正式宝塔 From 07146b89c1b81be4c27a12e3037b29ac9491f2d2 Mon Sep 17 00:00:00 2001 From: YeMingfei666 <1619116647@qq.com> Date: Tue, 16 Sep 2025 09:49:26 +0800 Subject: [PATCH 06/13] =?UTF-8?q?add:=20=E7=94=A8=E6=88=B7=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E5=A2=9E=E5=8A=A0=E4=BC=98=E6=83=A0=E5=88=B8=E5=88=97?= =?UTF-8?q?=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../list/components/user-coupon-dialog.vue | 114 ++++++++++++++++++ src/views/user/list/config/content.ts | 8 ++ src/views/user/list/index.vue | 19 +++ 3 files changed, 141 insertions(+) create mode 100644 src/views/user/list/components/user-coupon-dialog.vue diff --git a/src/views/user/list/components/user-coupon-dialog.vue b/src/views/user/list/components/user-coupon-dialog.vue new file mode 100644 index 0000000..f0a7294 --- /dev/null +++ b/src/views/user/list/components/user-coupon-dialog.vue @@ -0,0 +1,114 @@ + + + \ No newline at end of file diff --git a/src/views/user/list/config/content.ts b/src/views/user/list/config/content.ts index b8220e4..ce5403f 100644 --- a/src/views/user/list/config/content.ts +++ b/src/views/user/list/config/content.ts @@ -56,6 +56,14 @@ const contentConfig: IContentConfig = { templet: "custom", slotName: "mobile", }, + { + label: "优惠券", + align: "center", + prop: "coupon", + width: 140, + templet: "custom", + slotName: "coupon", + }, { label: "余额", align: "center", diff --git a/src/views/user/list/index.vue b/src/views/user/list/index.vue index a16489c..b32fac4 100644 --- a/src/views/user/list/index.vue +++ b/src/views/user/list/index.vue @@ -86,6 +86,15 @@ style="margin-left: 2px" /> + + @@ -108,10 +117,14 @@ @formDataChange="formDataChange" @submit-click="handleSubmitClick" > + + + diff --git a/src/views/marketing_center/new_user_discount/components/coup-lists.vue b/src/views/marketing_center/new_user_discount/components/coup-lists.vue new file mode 100644 index 0000000..1a6e156 --- /dev/null +++ b/src/views/marketing_center/new_user_discount/components/coup-lists.vue @@ -0,0 +1,85 @@ + + \ No newline at end of file diff --git a/src/views/marketing_center/new_user_discount/components/dialog-coup.vue b/src/views/marketing_center/new_user_discount/components/dialog-coup.vue new file mode 100644 index 0000000..e69de29 diff --git a/src/views/marketing_center/new_user_discount/components/dialog-plans.vue b/src/views/marketing_center/new_user_discount/components/dialog-plans.vue new file mode 100644 index 0000000..3a1cd0f --- /dev/null +++ b/src/views/marketing_center/new_user_discount/components/dialog-plans.vue @@ -0,0 +1,153 @@ + + + \ No newline at end of file diff --git a/src/views/marketing_center/new_user_discount/index.vue b/src/views/marketing_center/new_user_discount/index.vue new file mode 100644 index 0000000..f3df597 --- /dev/null +++ b/src/views/marketing_center/new_user_discount/index.vue @@ -0,0 +1,291 @@ + + + + + From a66de31f1b12c9eb37b18ea468cc6473a59acd0b Mon Sep 17 00:00:00 2001 From: YeMingfei666 <1619116647@qq.com> Date: Tue, 16 Sep 2025 13:57:35 +0800 Subject: [PATCH 12/13] =?UTF-8?q?fix:=20=E5=88=A0=E9=99=A4=E6=97=A0?= =?UTF-8?q?=E7=94=A8=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/coup-lists.vue | 85 ------------------- .../components/dialog-coup.vue | 0 2 files changed, 85 deletions(-) delete mode 100644 src/views/marketing_center/new_user_discount/components/coup-lists.vue delete mode 100644 src/views/marketing_center/new_user_discount/components/dialog-coup.vue diff --git a/src/views/marketing_center/new_user_discount/components/coup-lists.vue b/src/views/marketing_center/new_user_discount/components/coup-lists.vue deleted file mode 100644 index 1a6e156..0000000 --- a/src/views/marketing_center/new_user_discount/components/coup-lists.vue +++ /dev/null @@ -1,85 +0,0 @@ - - \ No newline at end of file diff --git a/src/views/marketing_center/new_user_discount/components/dialog-coup.vue b/src/views/marketing_center/new_user_discount/components/dialog-coup.vue deleted file mode 100644 index e69de29..0000000 From 171c81eea6171c3c5f4d8d1c9c8ed0f2ae5fb32f Mon Sep 17 00:00:00 2001 From: YeMingfei666 <1619116647@qq.com> Date: Wed, 17 Sep 2025 09:19:12 +0800 Subject: [PATCH 13/13] =?UTF-8?q?add:=20=E5=A2=9E=E5=8A=A0=E6=96=B0?= =?UTF-8?q?=E5=AE=A2=E7=AB=8B=E5=87=8F=E5=92=8C=E8=AE=A1=E7=AE=97=E5=85=AC?= =?UTF-8?q?=E5=BC=8F=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- .../modules/{carts copy.ts => carts-back.ts} | 0 src/store/modules/carts.ts | 112 +- src/utils/goods.ts | 1357 +++++++++++++++++ src/utils/test.ts | 210 +++ .../components/dialog-plans.vue | 6 +- .../new_user_discount/index.vue | 1 - .../marketing_center/super_vip/index.vue | 1 + 8 files changed, 1603 insertions(+), 86 deletions(-) rename src/store/modules/{carts copy.ts => carts-back.ts} (100%) create mode 100644 src/utils/goods.ts create mode 100644 src/utils/test.ts diff --git a/package.json b/package.json index 6763d88..a57b7a2 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "vue-clipboard3": "^2.0.0", "vue-i18n": "^11.1.0", "vue-router": "^4.5.0", - "ysk-utils": "^1.0.5" + "ysk-utils": "^1.0.12" }, "devDependencies": { "@commitlint/cli": "^19.7.1", diff --git a/src/store/modules/carts copy.ts b/src/store/modules/carts-back.ts similarity index 100% rename from src/store/modules/carts copy.ts rename to src/store/modules/carts-back.ts diff --git a/src/store/modules/carts.ts b/src/store/modules/carts.ts index 8073d2f..e9643a2 100644 --- a/src/store/modules/carts.ts +++ b/src/store/modules/carts.ts @@ -4,7 +4,9 @@ import orderApi from "@/api/order/order"; import { useUserStoreHook } from "@/store/modules/user"; import { customTruncateToTwoDecimals } from '@/utils/tools' import productApi from "@/api/product/index"; - +import yskUtils from 'ysk-utils' +const { OrderPriceCalculator } = yskUtils +console.log(OrderPriceCalculator) const shopUser = useUserStoreHook(); export interface CartsState { id: string | number; @@ -22,11 +24,6 @@ export const useCartsStore = defineStore("carts", () => { let dinnerType = ref('dine-in'); - //就餐模式 先付 后付 - const isPayBefore = computed(() => { - return shopUser.userInfo.registerType == 'before' ? true : false; - }); - //是否启用会员价 const useVipPrice = computed(() => { @@ -42,22 +39,21 @@ export const useCartsStore = defineStore("carts", () => { //当前购物车数据 const list = useStorage("carts", []); //历史订单数据 - // const oldOrder = useStorage("Instead_olold_order", { - // detailMap: [], - // originAmount: 0 - // }); - const oldOrder = ref({ + const oldOrder = useStorage("Instead_olold_order", { detailMap: [], originAmount: 0 - }) + }); //代客下单页面商品缓存 const goods = useStorage("Instead_goods", []); async function getGoods(query: any) { - const res = await productApi.list({ + const res = await productApi.getPage({ + page: 1, + size: 999, + status: "on_sale", ...query, }); - goods.value = res.filter((v: { type: string }) => v.type != 'coupon'); + goods.value = res.records; setGoodsMap(goods.value) } @@ -135,11 +131,9 @@ export const useCartsStore = defineStore("carts", () => { }) //返回打包数量(称重商品打包数量最大为1) function returnCartPackNumber(cur: any) { - const maxReturnNum = cur.number - (cur.returnNum || 0); - let pack_number = (dinnerType.value == 'take-out' ? cur.number : cur.pack_number * 1); + console.log(cur) + let pack_number = (dinnerType.value == 'take-out' ? cur.number : cur.pack_number * 1) pack_number = (cur.product_type == 'weight' && pack_number > 1) ? 1 : pack_number; - pack_number = Math.min(maxReturnNum, pack_number); - pack_number = pack_number <= 0 ? 0 : pack_number return pack_number * 1 } //打包数量 @@ -247,7 +241,7 @@ export const useCartsStore = defineStore("carts", () => { const price = (cur.discount_sale_amount * 1 || cur.salePrice || 0) return acc + cur.number * (discount_sale_amount || (useVipPrice.value ? memberPrice : price)) }, 0) - return (money + packFee.value * 1 + oldOrderMoney.value * 1).toFixed(2) + return (money + packFee.value + oldOrderMoney.value * 1).toFixed(2) }) //只算商品的总价 const goodsTotal = computed(() => { @@ -264,7 +258,7 @@ export const useCartsStore = defineStore("carts", () => { const cartNumber = list.value.reduce((acc: number, cur: any) => { return acc + cur.number * 1 }, 0) - const giftNumber = giftList.value.reduce((acc: number, cur: any) => { + const giftNumber = list.value.reduce((acc: number, cur: any) => { return acc + cur.number * 1 }, 0) let oldNumber = 0 @@ -284,11 +278,11 @@ export const useCartsStore = defineStore("carts", () => { return; } const newNumber = item.number * 1 + step * 1; - let pack_number = newNumber < item.pack_number ? (item.pack_number * 1 + step * 1) : item.pack_number * 1; + let pack_number = newNumber < item.pack_number ? (item.pack_number * 1 + step * 1) : item.pack_number; if (dinnerType.value == 'take-out') { pack_number = newNumber } - if (item.product_type == 'weight' && item.pack_number * 1 >= 1) { + if (item.product_type == 'weight') { pack_number = 1 } update({ ...item, number: newNumber, pack_number }); @@ -308,8 +302,11 @@ export const useCartsStore = defineStore("carts", () => { }) return } - selPlaceNum.value = -1; - isOldOrder.value = false; + if (cart.is_gift) { + isSelGift.value = true + } else { + isSelGift.value = false + } if (cart.is_gift) { isSelGift.value = true @@ -320,7 +317,6 @@ export const useCartsStore = defineStore("carts", () => { isSelGift.value = false selListIndex.value = list.value.findIndex((item: CartsState) => item.id === cart.id); } - } @@ -336,8 +332,7 @@ export const useCartsStore = defineStore("carts", () => { product_name: "", remark: "", sku_id: '', - product_type: '', - suitNum: 1 + product_type: '' } //当前购物车直接添加 function cartsPush(data: any) { @@ -377,10 +372,6 @@ export const useCartsStore = defineStore("carts", () => { cart_id }); } - //清空历史订单 - function clearHistory() { - sendMessage('clearOrder', {}); - } function del(data: any) { @@ -394,7 +385,7 @@ export const useCartsStore = defineStore("carts", () => { return sendMessage('del', data); } const pack_number = dinnerType.value == 'take-out' ? data.number : data.pack_number - sendMessage('edit', { ...data, suitNum, pack_number }); + sendMessage('edit', { ...data, pack_number }); } function updateTag(key: string, val: any, cart: CartsState = selCart.value) { const skuData = cart.skuData || { suitNum: 1 } @@ -408,7 +399,6 @@ export const useCartsStore = defineStore("carts", () => { const msg = { ...cart, [key]: val } if (key == 'number' && dinnerType.value == 'take-out') { msg.pack_number == val - msg.suitNum == skuData.suitNum } sendMessage('edit', msg); @@ -462,15 +452,8 @@ export const useCartsStore = defineStore("carts", () => { //获取历史订单 async function getOldOrder(table_code: string | number) { const res = await orderApi.getHistoryList({ tableCode: table_code }) - console.log('getOldOrder'); - console.log(res); if (res) { setOldOrder(res) - } else { - oldOrder.value = { - detailMap: [], - originAmount: 0 - } } } @@ -512,22 +495,12 @@ export const useCartsStore = defineStore("carts", () => { for (let i in data) { newData[i] = data[i].map((v: any) => { const skuData = getProductDetails({ product_id: v.productId, sku_id: v.skuId }) - console.log(skuData) - console.log(v) - return { ...v, ...skuData, - skuData: { - ...skuData, - salePrice: v.price, - memberPrice: v.memberPrice - }, placeNum: v.placeNum, number: v.num, id: v.id, - salePrice: v.price, - memberPrice: v.memberPrice, pack_number: v.packNumber, discount_sale_amount: v.discountSaleAmount * 1 || 0, is_print: v.isPrint, @@ -538,12 +511,10 @@ export const useCartsStore = defineStore("carts", () => { product_name: v.productName, sku_name: v.skuName, sku_id: v.skuId, - product_type: v.productType, - packFee: v.packAmount, + product_type: v.productType } }) } - console.log('newData', newData) return newData } @@ -573,7 +544,7 @@ export const useCartsStore = defineStore("carts", () => { if ($oldOrder) { setOldOrder($oldOrder) } else { - oldOrder.value = { detailMap: [], originAmount: 0 } + oldOrder.value = { detailMap: [] } } // console.log('oldOrder.detailMap', oldOrder.value.detailMap) @@ -593,19 +564,9 @@ export const useCartsStore = defineStore("carts", () => { console.log("初始化参数", initParams); WebSocketManager.subscribeToTopic(initParams, (msg) => { console.log("收到消息:", msg); + console.log([...list.value, ...giftList.value]) if (msg.hasOwnProperty('status') && msg.status != 1) { - if (msg.type === 'no_suit_num' && selListIndex.value != -1) { - return ElMessageBox.confirm(`${list.value[selListIndex.value].name}库存不足`, '提示', { - confirmButtonText: '确定', - - callback: (action: string) => { - if (action == 'confirm') { - list.value.splice(selListIndex.value, 1) - } - } - }); - } - return ElMessage.error(msg.message || msg.msg || '操作失败') + return ElMessage.error(msg.message || '操作失败') } if (msg && msg.data) { if (Array.isArray(msg.data) && msg.data.length && msg.data[0].table_code) { @@ -636,7 +597,7 @@ export const useCartsStore = defineStore("carts", () => { v[key] = skuData[key]; }); } else { - // del({ id: v.id }) + del({ id: v.id }) return false } return !v.is_gift @@ -666,13 +627,10 @@ export const useCartsStore = defineStore("carts", () => { return ElMessage.warning(msg.message || '该商品已存在') } const skuData = getProductDetails({ product_id: msg.data.product_id, sku_id: msg.data.sku_id }) - if (skuData || msg.data.is_temporary) { - const newGoods = { ...skuData, ...msg.data } - console.log('newGoods', newGoods) - list.value.push(newGoods) - return ElMessage.success(msg.message || '添加成功') - } - + const newGoods = { ...skuData, ...msg.data } + console.log('newGoods', newGoods) + list.value.push(newGoods) + return ElMessage.success(msg.message || '添加成功') } if (msg.operate_type === "manage_edit") { @@ -723,9 +681,6 @@ export const useCartsStore = defineStore("carts", () => { if (msg.operate_type === "batch") { concocatSocket({ ...$initParams, table_code: table_code.value }) } - if (msg.operate_type === "manage_clearOrder") { - getOldOrder(msg.data.table_code) - } if (msg.operate_type === "product_update") { console.log('商品更新') init($initParams, oldOrder.value) @@ -747,7 +702,6 @@ export const useCartsStore = defineStore("carts", () => { WebSocketManager.sendMessage(msg); } return { - clearHistory, disconnect, dinnerType, changePack, @@ -781,7 +735,7 @@ export const useCartsStore = defineStore("carts", () => { changeTable, rotTable, getGoods, - setGoodsMap, isPayBefore + setGoodsMap }; }); diff --git a/src/utils/goods.ts b/src/utils/goods.ts new file mode 100644 index 0000000..e5f736f --- /dev/null +++ b/src/utils/goods.ts @@ -0,0 +1,1357 @@ +/** + * 购物车订单价格计算公共库 + * 功能:覆盖订单全链路费用计算(商品价格、优惠券、积分、餐位费等),支持策略扩展 + * 小数处理:统一舍去小数点后两位(如 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; // 实际使用的优惠券 +} + +/** 兑换券计算结果类型 */ +interface ExchangeCalculationResult { + deductionAmount: number; + excludedProductIds: string[]; // 不适用商品ID列表(商品ID) +} + +/** 优惠券类型枚举 */ +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) + couponType?: number; // 优惠券类型:1-满减券,2-商品兑换券,3-折扣券,4-第二件半价券,5-消费送券,6-买一送一券,7-固定价格券,8-免配送费券 + title?: string; // 券名称 + useShopType?: string; // 可用门店类型:only-仅本店;all-所有门店,custom-指定门店 + useShops?: string; // 可用门店(逗号分隔字符串,如"1,2,3") + useType?: string; // 可使用类型:dine堂食/pickup自取/deliv配送/express快递 + validType?: string; // 有效期类型:fixed(固定时间),custom(自定义时间) + validDays?: number; // 有效期(天) + validStartTime?: string; // 有效期开始时间(如"2024-01-01 00:00:00") + validEndTime?: string; // 有效期结束时间 + daysToTakeEffect?: number; // 隔天生效 + useDays?: string; // 可用周期(如"周一,周二") + useTimeType?: string; // 可用时间段类型:all-全时段,custom-指定时段 + useStartTime?: string; // 可用开始时间(每日) + useEndTime?: string; // 可用结束时间(每日) + getType?: string; // 发放设置:不可自行领取/no,可领取/yes + getMode?: string; // 用户领取方式 + giveNum?: number; // 总发放数量,-10086为不限量 + getUserType?: string; // 可领取用户:全部/all,新用户一次/new,仅会员/vip + getLimit?: number; // 每人领取限量,-10086为不限量 + useLimit?: number; // 每人每日使用限量,-10086为不限量 + discountShare?: number; // 与限时折扣同享:0-否,1-是 + vipPriceShare?: number; // 与会员价同享:0-否,1-是 + ruleDetails?: string; // 附加规则说明 + status?: number; // 状态:0-禁用,1-启用 + useNum?: number; // 已使用数量 + leftNum?: number; // 剩余数量 + foods?: string; // 指定门槛商品(逗号分隔字符串,如"101,102",此处为商品ID) + fullAmount?: number; // 使用门槛:满多少金额(元) + discountAmount?: number; // 使用门槛:减多少金额(元) + discountRate?: number; // 折扣%(如90=9折) + maxDiscountAmount?: number; // 可抵扣最大金额(元) + useRule?: string; // 使用规则:price_asc-价格低到高,price_desc-高到低 + discountNum?: number; // 抵扣数量 + otherCouponShare?: number; // 与其它优惠共享:0-否,1-是 + createTime?: string; // 创建时间 + updateTime?: string; // 更新时间 +} + +/** 营销活动类型枚举 */ +export enum ActivityType { + TIME_LIMIT_DISCOUNT = 'time_limit_discount', // 限时折扣 +} + +/** 基础购物车商品项(核心修正:新增product_id,明确各ID含义) */ +export interface BaseCartItem { + id: string | number; // 购物车ID(唯一标识购物车中的条目,如购物车项主键) + product_id: string | number; // 商品ID(唯一标识商品,用于优惠券/活动匹配,必选) + salePrice: number; // 商品原价(元) + number: number; // 商品数量 + product_type: GoodsType; // 商品类型 + is_temporary?: boolean; // 是否临时菜(默认false) + is_gift?: boolean; // 是否赠菜(默认false) + returnNum?: number; // 退货数量(历史订单用,默认0) + memberPrice?: number; // 商品会员价(元,优先级:商品会员价 > 会员折扣) + discountSaleAmount?: number; // 商家改价后单价(元,优先级最高) + packFee?: number; // 单份打包费(元,默认0) + packNumber?: number; // 堂食打包数量(默认0) + activityInfo?: { // 商品参与的营销活动(如限时折扣) + type: ActivityType; + discountRate: number; // 折扣率(如0.8=8折) + vipPriceShare: boolean; // 是否与会员优惠同享(默认false) + }; + skuData?: { // SKU扩展数据(可选) + id: string | number; // SKU ID(唯一标识商品规格,如颜色/尺寸) + memberPrice?: number; // SKU会员价 + salePrice?: number; // SKU原价 + }; +} + +/** 基础优惠券接口(所有券类型继承,包含统一门槛商品字段) */ +export interface BaseCoupon { + id: string | number; // 优惠券ID + type: CouponType; // 工具库字符串枚举(由后端couponType转换) + name: string; // 对应后端title + available: boolean; // 基于BackendCoupon字段计算的可用性 + useShopType?: string; // only-仅本店;all-所有门店,custom-指定门店 + useShops: string[]; // 可用门店ID列表 + discountShare: boolean; // 与限时折扣同享:0-否,1-是(后端字段转换为布尔值) + vipPriceShare: boolean; // 与会员价同享:0-否,1-是(后端字段转换为布尔值) + useType?: string[]; // 可使用类型:dine堂食/pickup自取/deliv配送/express快递 + isValid: boolean; // 是否在有效期内 + discountAmount?: number; // 减免金额 (满减券有) + fullAmount?: number; // 使用门槛:满多少金额 + maxDiscountAmount?: number; // 可抵扣最大金额 元 + applicableProductIds: string[]; // 门槛商品ID列表(空数组=全部商品,非空=指定商品ID) +} + +/** 满减券(适配后端字段) */ +export interface FullReductionCoupon extends BaseCoupon { + type: CouponType.FULL_REDUCTION; + fullAmount: number; // 对应后端fullAmount(满减门槛) + discountAmount: number; // 对应后端discountAmount(减免金额) + maxDiscountAmount?: number; // 对应后端maxDiscountAmount(最大减免) +} + +/** 折扣券(适配后端字段) */ +export interface DiscountCoupon extends BaseCoupon { + type: CouponType.DISCOUNT; + discountRate: number; // 后端discountRate(%)转小数(如90→0.9) + maxDiscountAmount: number; // 对应后端maxDiscountAmount(最大减免) +} + +/** 第二件半价券(适配后端字段) */ +export interface SecondHalfPriceCoupon extends BaseCoupon { + type: CouponType.SECOND_HALF; + maxUseCountPerOrder?: number; // 对应后端useLimit(-10086=不限) +} + +/** 买一送一券(适配后端字段) */ +export interface BuyOneGetOneCoupon extends BaseCoupon { + type: CouponType.BUY_ONE_GET_ONE; + maxUseCountPerOrder?: number; // 对应后端useLimit(-10086=不限) +} + +/** 商品兑换券(适配后端字段) */ +export interface ExchangeCoupon extends BaseCoupon { + type: CouponType.EXCHANGE; + deductCount: number; // 对应后端discountNum(抵扣数量) + sortRule: 'low_price_first' | 'high_price_first'; // 后端useRule转换 +} + +/** 所有优惠券类型联合 */ +export type Coupon = FullReductionCoupon | DiscountCoupon | SecondHalfPriceCoupon | BuyOneGetOneCoupon | ExchangeCoupon; + +/** 营销活动配置(如限时折扣,applicableProductIds为商品ID列表) */ +export interface ActivityConfig { + type: ActivityType; + applicableProductIds?: string[]; // 适用商品ID列表(与BaseCartItem.product_id匹配) + discountRate: number; // 折扣率(如0.8=8折) + vipPriceShare: boolean; // 是否与会员优惠同享 +} + +/** 积分抵扣规则 */ +export interface PointDeductionRule { + pointsPerYuan: number; // X积分=1元(如100=100积分抵1元) + maxDeductionAmount?: number; // 最大抵扣金额(元,默认不限) +} + +/** 餐位费配置 */ +export interface SeatFeeConfig { + pricePerPerson: number; // 每人餐位费(元) + personCount: number; // 用餐人数(默认1) + isEnabled: boolean; // 是否启用餐位费(默认false) +} + +/** 订单额外费用配置 */ +export interface OrderExtraConfig { + merchantReduction: number; // 商家减免金额(元,默认0) + additionalFee: number; // 附加费(元,如余额充值、券包,默认0) + pointDeductionRule: PointDeductionRule; // 积分抵扣规则 + seatFeeConfig: SeatFeeConfig; // 餐位费配置 + currentStoreId: string; // 当前门店ID(用于验证优惠券适用门店) + userPoints: number; // 用户当前积分(用于积分抵扣) + isMember: boolean; // 用户是否会员(用于会员优惠) + memberDiscountRate?: number; // 会员折扣率(如0.95=95折,无会员价时用) +} + +/** 订单费用汇总(所有子项清晰展示,方便调用方使用) */ +export interface OrderCostSummary { + goodsOriginalAmount: number; // 商品原价总和 + goodsDiscountAmount: number; // 商品折扣金额(原价-实际价) + couponDeductionAmount: number; // 优惠券抵扣金额 + pointDeductionAmount: number; // 积分抵扣金额 + seatFee: number; // 餐位费 + packFee: number; // 打包费 + merchantReductionAmount: number; // 商家减免金额 + additionalFee: number; // 附加费 + finalPayAmount: number; // 最终实付金额 + couponUsed?: Coupon; // 实际使用的优惠券(互斥时选最优) + pointUsed: number; // 实际使用的积分 +} + +// ============================ 2. 基础工具函数(核心修正:所有商品ID匹配用product_id) ============================ +/** + * 后端优惠券转工具库Coupon的转换函数 + * @param backendCoupon 后端返回的优惠券 + * @param currentStoreId 当前门店ID(用于验证门店适用性) + * @param dinnerType 就餐类型(用于验证使用场景) + * @param currentTime 当前时间(默认取当前时间,用于有效期判断) + * @returns 工具库 Coupon | null(不支持的券类型/无效券返回null) + */ +export function convertBackendCouponToToolCoupon( + backendCoupon: BackendCoupon, + currentStoreId: string, + dinnerType: 'dine-in' | 'take-out', + currentTime: Date = new Date() +): Coupon | null { + // 1. 基础校验:必选字段缺失直接返回null + if (!backendCoupon.id || backendCoupon.couponType === undefined || !backendCoupon.title) { + console.warn('优惠券必选字段缺失', backendCoupon); + return null; + } + + // 2. 转换券类型:后端数字枚举 → 工具库字符串枚举 + const couponType = mapBackendCouponTypeToTool(backendCoupon.couponType); + if (!couponType) { + console.warn(`不支持的优惠券类型:${backendCoupon.couponType}(券ID:${backendCoupon.id})`); + return null; + } + + // 3. 统一处理所有券类型的applicableProductIds(映射后端foods,此处为商品ID列表) + const applicableProductIds = backendCoupon.foods === '' || !backendCoupon.foods + ? [] // 空字符串/undefined → 全部商品(按商品ID匹配) + : backendCoupon.foods.split(',').map(id => id.trim()); // 逗号分隔 → 指定商品ID数组 + + // 4. 计算基础公共字段(含多维度可用性校验) + const baseCoupon: BaseCoupon = { + id: backendCoupon.id, + type: couponType, + name: backendCoupon.title, + available: isCouponAvailable(backendCoupon, currentStoreId, dinnerType, currentTime), + useShops: getApplicableStoreIds(backendCoupon, currentStoreId), + discountShare: backendCoupon.discountShare === 1, + vipPriceShare: backendCoupon.vipPriceShare === 1, + useType: backendCoupon.useType ? backendCoupon.useType.split(',') : [], + isValid: isCouponInValidPeriod(backendCoupon, currentTime), + applicableProductIds: applicableProductIds + }; + + // 5. 按券类型补充专属字段 + switch (couponType) { + case CouponType.FULL_REDUCTION: + return { + ...baseCoupon, + fullAmount: backendCoupon.fullAmount || 0, + discountAmount: backendCoupon.discountAmount || 0, + maxDiscountAmount: backendCoupon.maxDiscountAmount ?? Infinity + } as FullReductionCoupon; + + case CouponType.DISCOUNT: + return { + ...baseCoupon, + discountRate: formatDiscountRate(backendCoupon.discountRate), + maxDiscountAmount: backendCoupon.maxDiscountAmount ?? Infinity + } as DiscountCoupon; + + case CouponType.SECOND_HALF: + return { + ...baseCoupon, + maxUseCountPerOrder: backendCoupon.useLimit === -10086 ? Infinity : backendCoupon.useLimit || 1 + } as SecondHalfPriceCoupon; + + case CouponType.BUY_ONE_GET_ONE: + return { + ...baseCoupon, + maxUseCountPerOrder: backendCoupon.useLimit === -10086 ? Infinity : backendCoupon.useLimit || 1 + } as BuyOneGetOneCoupon; + + case CouponType.EXCHANGE: + return { + ...baseCoupon, + deductCount: backendCoupon.discountNum || 1, + sortRule: backendCoupon.useRule === 'price_asc' ? 'low_price_first' : 'high_price_first' + } as ExchangeCoupon; + + default: + return null; + } +} + +// ------------------------------ 转换辅助函数 ------------------------------ +/** + * 后端优惠券类型(数字)→ 工具库优惠券类型(字符串枚举) + */ +function mapBackendCouponTypeToTool(backendType: number): CouponType | undefined { + const typeMap: Record = { + 1: CouponType.FULL_REDUCTION, // 1-满减券 + 2: CouponType.EXCHANGE, // 2-商品兑换券 + 3: CouponType.DISCOUNT, // 3-折扣券 + 4: CouponType.SECOND_HALF, // 4-第二件半价券 + 6: CouponType.BUY_ONE_GET_ONE // 6-买一送一券 + }; + return typeMap[backendType]; +} + +/** + * 多维度判断优惠券是否可用:状态+库存+有效期+隔天生效+每日时段+每周周期+门店+就餐类型 + */ +function isCouponAvailable( + backendCoupon: BackendCoupon, + currentStoreId: string, + dinnerType: 'dine-in' | 'take-out', + currentTime: Date = new Date() +): boolean { + // 1. 状态校验:必须启用(status=1) + if (backendCoupon.status !== 1) return false; + + // 2. 库存校验:剩余数量>0(-10086表示不限量) + if (backendCoupon.leftNum !== -10086 && (backendCoupon.leftNum || 0) <= 0) return false; + + // 3. 有效期校验:必须在有效期内 + if (!isCouponInValidPeriod(backendCoupon, currentTime)) return false; + + // 4. 隔天生效校验:若设置了隔天生效,需超过生效时间 + if (!isCouponEffectiveAfterDays(backendCoupon, currentTime)) return false; + + // 5. 每日时段校验:当前时间需在每日可用时段内(useTimeType=custom时生效) + if (!isCouponInDailyTimeRange(backendCoupon, currentTime)) return false; + + // 6. 每周周期校验:当前星期几需在可用周期内(useDays非空时生效) + if (!isCouponInWeekDays(backendCoupon, currentTime)) return false; + + // 7. 门店匹配校验:当前门店需在适用门店范围内 + if (!isStoreMatch(backendCoupon, currentStoreId)) return false; + + // 8. 就餐类型校验:当前就餐类型需在可用类型范围内 + if (!isDinnerTypeMatch(backendCoupon, dinnerType)) return false; + + return true; +} + +/** + * 判断优惠券是否在有效期内(处理后端validType逻辑) + */ +function isCouponInValidPeriod(backendCoupon: BackendCoupon, currentTime: Date): boolean { + const { validType, validStartTime, validEndTime, validDays, createTime } = backendCoupon; + + // 固定时间有效期(validType=fixed):直接对比validStartTime和validEndTime + if (validType === 'fixed' && validStartTime && validEndTime) { + const start = new Date(validStartTime); + const end = new Date(validEndTime); + return currentTime >= start && currentTime <= end; + } + + // 自定义天数有效期(validType=custom):创建时间+validDays天 + if (validType === 'custom' && createTime && validDays) { + const create = new Date(createTime); + const end = new Date(create.getTime() + validDays * 24 * 60 * 60 * 1000); // 加N天 + return currentTime <= end; + } + + // 无有效期配置:默认视为无效 + return false; +} + +/** + * 隔天生效校验:若设置了daysToTakeEffect,需超过生效时间(创建时间+N天的0点) + */ +function isCouponEffectiveAfterDays(backendCoupon: BackendCoupon, currentTime: Date): boolean { + if (!backendCoupon.daysToTakeEffect || backendCoupon.daysToTakeEffect <= 0) return true; + if (!backendCoupon.createTime) return false; + + const create = new Date(backendCoupon.createTime); + const effectiveTime = new Date(create); + effectiveTime.setDate(create.getDate() + backendCoupon.daysToTakeEffect); + effectiveTime.setHours(0, 0, 0, 0); // 隔天0点生效 + + return currentTime >= effectiveTime; +} + +/** + * 每日时段校验:当前时间需在useStartTime和useEndTime之间(仅比较时分秒,支持跨天) + */ +function isCouponInDailyTimeRange(backendCoupon: BackendCoupon, currentTime: Date): boolean { + // 全时段可用或未配置时段类型 → 直接通过 + if (backendCoupon.useTimeType === 'all' || !backendCoupon.useTimeType) return true; + // 非自定义时段 → 默认可用(兼容未配置场景) + if (backendCoupon.useTimeType !== 'custom') return true; + // 缺少时段配置 → 无效 + if (!backendCoupon.useStartTime || !backendCoupon.useEndTime) return false; + + // 解析时分(如"10:30" → [10, 30]) + const [startHours, startMinutes] = backendCoupon.useStartTime.split(':').map(Number); + const [endHours, endMinutes] = backendCoupon.useEndTime.split(':').map(Number); + + // 转换为当天分钟数(便于比较) + const currentMinutes = currentTime.getHours() * 60 + currentTime.getMinutes(); + const startTotalMinutes = startHours * 60 + startMinutes; + const endTotalMinutes = endHours * 60 + endMinutes; + + // 处理跨天场景(如22:00-02:00) + if (startTotalMinutes <= endTotalMinutes) { + return currentMinutes >= startTotalMinutes && currentMinutes <= endTotalMinutes; + } else { + return currentMinutes >= startTotalMinutes || currentMinutes <= endTotalMinutes; + } +} + +/** + * 每周周期校验:当前星期几需在useDays范围内(如"周一,周二") + */ +function isCouponInWeekDays(backendCoupon: BackendCoupon, currentTime: Date): boolean { + if (!backendCoupon.useDays) return true; // 未配置周期 → 默认可用 + + // 星期映射:getDay()返回0=周日,1=周一...6=周六 + const weekDayMap = { + 0: '周七', + 1: '周一', + 2: '周二', + 3: '周三', + 4: '周四', + 5: '周五', + 6: '周六' + }; + const currentWeekDay = weekDayMap[currentTime.getDay() as keyof typeof weekDayMap]; + return backendCoupon.useDays.split(',').includes(currentWeekDay); +} + +/** + * 门店匹配校验:根据useShopType判断当前门店是否适用 + */ +function isStoreMatch(backendCoupon: BackendCoupon, currentStoreId: string): boolean { + const { useShopType, useShops, shopId } = backendCoupon; + + switch (useShopType) { + case 'all': // 所有门店适用 + return true; + case 'custom': // 指定门店适用(useShops逗号分割,门店ID) + return useShops ? useShops.split(',').includes(currentStoreId) : false; + case 'only': // 仅本店适用(shopId为门店ID) + return shopId ? String(shopId) === currentStoreId : false; + default: // 未配置 → 默认为仅本店 + return shopId ? String(shopId) === currentStoreId : false; + } +} + +/** + * 就餐类型匹配校验:当前就餐类型需在useType范围内(如"dine,pickup") + */ +function isDinnerTypeMatch(backendCoupon: BackendCoupon, dinnerType: string): boolean { + if (!backendCoupon.useType) return true; // 未配置 → 默认可用 + return backendCoupon.useType.split(',').includes(dinnerType); +} + +/** + * 处理适用门店ID:根据useShopType返回对应数组(供BaseCoupon使用) + */ +function getApplicableStoreIds(backendCoupon: BackendCoupon, currentStoreId: string): string[] { + const { useShopType, useShops, shopId } = backendCoupon; + + switch (useShopType) { + case 'all': // 所有门店适用:返回空数组(工具库空数组表示无限制) + return []; + case 'custom': // 指定门店适用:useShops逗号分割转数组(门店ID) + return useShops ? useShops.split(',').map(id => id.trim()) : []; + case 'only': // 仅当前店铺适用:返回shopId(转字符串,门店ID) + return shopId ? [shopId.toString()] : []; + default: // 未配置:默认仅当前门店适用 + return [currentStoreId]; + } +} + +/** + * 折扣率格式化:后端discountRate(%)→ 工具库小数(如90→0.9) + */ +export function formatDiscountRate(backendDiscountRate?: number): number { + if (!backendDiscountRate || backendDiscountRate <= 0) return 1; // 默认无折扣(1=100%) + // 后端若为百分比(如90=9折),除以100;若已为小数(如0.9)直接返回 + return backendDiscountRate >= 1 ? backendDiscountRate / 100 : backendDiscountRate; +} + +/** + * 统一小数处理:舍去小数点后两位(如 10.129 → 10.12,15.998 → 15.99) + * @param num 待处理数字 + * @returns 处理后保留两位小数的数字 + */ +export function truncateToTwoDecimals(num: number): number { + return Math.floor(num * 100) / 100; +} + +/** + * 判断商品是否为临时菜(临时菜不参与优惠券门槛和折扣计算) + * @param goods 商品项 + * @returns 是否临时菜 + */ +export function isTemporaryGoods(goods: BaseCartItem): boolean { + return !!goods.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 goods.salePrice; + + // 优先级:SKU会员价 > 商品会员价 > 商品原价(无会员价时用会员折扣) + const basePrice = goods.skuData?.memberPrice + ?? goods.memberPrice + ?? goods.salePrice; + + // 仅当无SKU会员价、无商品会员价时,才应用会员折扣率 + return memberDiscountRate && !goods.skuData?.memberPrice && !goods.memberPrice + ? truncateToTwoDecimals(basePrice * memberDiscountRate) + : 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 { + return truncateToTwoDecimals( + eligibleGoods.reduce((total, goods) => { + const availableNum = Math.max(0, goods.number - (goods.returnNum || 0)); + if (availableNum <= 0) return total; + + // 1. 基础金额:默认用商品原价(SKU原价优先) + const basePrice = goods.skuData?.salePrice ?? goods.salePrice; + let itemAmount = basePrice * availableNum; + + // 2. 处理「与会员价/会员折扣同享」规则:若开启,用会员价计算 + if (coupon.vipPriceShare) { + const memberPrice = calcMemberPrice(goods, config.isMember, config.memberDiscountRate); + itemAmount = memberPrice * 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 * activity.discountRate; // 叠加限时折扣 + } + } + + return total + itemAmount; + }, 0) + ); +} + +// ============================ 3. 商品核心价格计算(核心修正:按商品ID匹配营销活动) ============================ +/** + * 计算单个商品的实际单价(整合商家改价、会员优惠、营销活动折扣,按商品ID匹配活动) + * @param goods 商品项 + * @param config 订单额外配置(含会员、活动信息) + * @returns 单个商品实际单价(元) + */ +export function calcSingleGoodsRealPrice( + goods: BaseCartItem, + config: Pick & { + activity?: ActivityConfig; // 商品参与的营销活动(如限时折扣,按商品ID匹配) + } +): number { + const { isMember, memberDiscountRate, activity } = config; + + // 1. 优先级1:商家改价(改价后单价>0才生效) + if (goods.discountSaleAmount && goods.discountSaleAmount > 0) { + return truncateToTwoDecimals(goods.discountSaleAmount); + } + + // 2. 优先级2:会员价(含会员折扣率,SKU会员价优先) + const memberPrice = calcMemberPrice(goods, isMember, memberDiscountRate); + + // 3. 优先级3:营销活动折扣(如限时折扣,需按商品ID匹配活动) + const isActivityApplicable = activity + ? (activity.applicableProductIds || []).includes(String(goods.product_id)) // 核心修正:用商品ID匹配活动 + : false; + if (!activity || !isActivityApplicable) { + return memberPrice; + } + + // 处理活动与会员的同享/不同享逻辑 + if (activity.vipPriceShare) { + // 同享:会员价基础上叠加工活动折扣 + return truncateToTwoDecimals(memberPrice * activity.discountRate); + } else { + // 不同享:取会员价和活动价的最小值(活动价用SKU原价计算) + const basePriceForActivity = goods.skuData?.salePrice ?? goods.salePrice; + const activityPrice = truncateToTwoDecimals(basePriceForActivity * activity.discountRate); + return Math.min(memberPrice, activityPrice); + } +} + +/** + * 计算商品原价总和(所有商品:原价*数量,含临时菜、赠菜,用SKU原价优先) + * @param goodsList 商品列表 + * @returns 商品原价总和(元) + */ +export function calcGoodsOriginalAmount(goodsList: BaseCartItem[]): number { + return truncateToTwoDecimals( + goodsList.reduce((total, goods) => { + const availableNum = Math.max(0, goods.number - (goods.returnNum || 0)); + const basePrice = goods.skuData?.salePrice ?? goods.salePrice; // SKU原价优先 + return total + basePrice * availableNum; + }, 0) + ); +} + +/** + * 计算商品实际总价(所有商品:实际单价*数量,含临时菜、赠菜,按商品ID匹配活动) + * @param goodsList 商品列表 + * @param config 订单额外配置(含会员、活动信息) + * @param activities 全局营销活动列表(如限时折扣,applicableProductIds为商品ID) + * @returns 商品实际总价(元) + */ +export function calcGoodsRealAmount( + goodsList: BaseCartItem[], + config: Pick, + activities: ActivityConfig[] = [] +): number { + return truncateToTwoDecimals( + goodsList.reduce((total, goods) => { + const availableNum = Math.max(0, goods.number - (goods.returnNum || 0)); + if (availableNum <= 0) return total; + + // 匹配商品参与的营销活动(按商品ID匹配,优先商品自身配置) + const activity = goods.activityInfo + ?? activities.find(act => + (act.applicableProductIds || []).includes(String(goods.product_id)) // 核心修正:用商品ID匹配活动 + ); + + const realPrice = calcSingleGoodsRealPrice(goods, { ...config, activity }); + return total + realPrice * availableNum; + }, 0) + ); +} + +/** + * 计算商品折扣总金额(商品原价总和 - 商品实际总价) + * @param goodsOriginalAmount 商品原价总和 + * @param goodsRealAmount 商品实际总价 + * @returns 商品折扣总金额(元,≥0) + */ +export function calcGoodsDiscountAmount( + goodsOriginalAmount: number, + goodsRealAmount: number +): number { + return truncateToTwoDecimals(Math.max(0, goodsOriginalAmount - goodsRealAmount)); +} + +// ============================ 4. 优惠券抵扣计算(策略模式,核心修正:按商品ID匹配) ============================ +/** 优惠券计算策略接口(新增优惠券类型时,实现此接口即可) */ +interface CouponCalculationStrategy { + calculate( + coupon: Coupon, + goodsList: BaseCartItem[], + config: Pick & { + activities: ActivityConfig[]; + cartOrder: Record; + excludedProductIds?: string[]; // 需排除的商品ID列表(商品ID) + } + ): { + deductionAmount: number; + excludedProductIds: string[]; // 排除的商品ID列表(商品ID) + }; +} + +/** 满减券计算策略(按商品ID筛选门槛商品) */ +class FullReductionStrategy implements CouponCalculationStrategy { + calculate( + coupon: FullReductionCoupon, + goodsList: BaseCartItem[], + config: any + ): { deductionAmount: number; excludedProductIds: string[] } { + // 1. 基础校验:优惠券不可用/门店不匹配 → 抵扣0 + if (!coupon.available || !isStoreMatchByList(coupon.useShops, config.currentStoreId)) { + return { deductionAmount: 0, excludedProductIds: [] }; + } + + // 2. 第一步:排除临时菜/赠菜/已抵扣商品(基础合格商品,按商品ID排除) + const baseEligibleGoods = filterCouponEligibleGoods(goodsList, config.excludedProductIds || []); + // 3. 第二步:按商品ID筛选门槛商品(匹配applicableProductIds) + const thresholdGoods = filterThresholdGoods(baseEligibleGoods, coupon.applicableProductIds); + if (thresholdGoods.length === 0) { + return { deductionAmount: 0, excludedProductIds: [] }; + } + + // 4. 按同享规则计算门槛金额(按商品ID匹配活动) + const thresholdAmount = calcCouponThresholdAmount( + thresholdGoods, + coupon, + { isMember: config.isMember, memberDiscountRate: config.memberDiscountRate }, + config.activities + ); + + // 5. 满门槛则抵扣,否则0(不超过最大减免) + if (thresholdAmount < coupon.fullAmount) { + return { deductionAmount: 0, excludedProductIds: [] }; + } + const maxReduction = coupon.maxDiscountAmount ?? coupon.discountAmount; + const deductionAmount = truncateToTwoDecimals(Math.min(coupon.discountAmount, maxReduction)); + return { deductionAmount, excludedProductIds: [] }; + } +} + +/** 折扣券计算策略(按商品ID筛选门槛商品) */ +class DiscountStrategy implements CouponCalculationStrategy { + calculate( + coupon: DiscountCoupon, + goodsList: BaseCartItem[], + config: any + ): { deductionAmount: number; excludedProductIds: string[] } { + // 1. 基础校验:优惠券不可用/门店不匹配 → 抵扣0 + if (!coupon.available || !isStoreMatchByList(coupon.useShops, config.currentStoreId)) { + return { deductionAmount: 0, excludedProductIds: [] }; + } + + // 2. 第一步:排除临时菜/赠菜/已抵扣商品(基础合格商品,按商品ID排除) + const baseEligibleGoods = filterCouponEligibleGoods(goodsList, config.excludedProductIds || []); + // 3. 第二步:按商品ID筛选门槛商品(匹配applicableProductIds) + const thresholdGoods = filterThresholdGoods(baseEligibleGoods, coupon.applicableProductIds); + if (thresholdGoods.length === 0) { + return { deductionAmount: 0, excludedProductIds: [] }; + } + + // 4. 按同享规则计算折扣基数(按商品ID匹配活动) + const discountBaseAmount = calcCouponThresholdAmount( + thresholdGoods, + coupon, + { isMember: config.isMember, memberDiscountRate: config.memberDiscountRate }, + config.activities + ); + + // 5. 计算折扣金额(不超过最大减免) + const discountAmount = truncateToTwoDecimals(discountBaseAmount * (1 - coupon.discountRate)); + const deductionAmount = truncateToTwoDecimals(Math.min(discountAmount, coupon.maxDiscountAmount)); + return { deductionAmount, excludedProductIds: [] }; + } +} + +/** 第二件半价券计算策略(按商品ID分组,筛选门槛商品) */ +class SecondHalfPriceStrategy implements CouponCalculationStrategy { + calculate( + coupon: SecondHalfPriceCoupon, + goodsList: BaseCartItem[], + config: any + ): { deductionAmount: number; excludedProductIds: string[] } { + // 1. 基础校验:优惠券不可用/门店不匹配 → 抵扣0 + if (!coupon.available || !isStoreMatchByList(coupon.useShops, config.currentStoreId)) { + return { deductionAmount: 0, excludedProductIds: [] }; + } + + let totalDeduction = 0; + const excludedProductIds: string[] = []; // 存储排除的商品ID(商品ID) + + // 2. 第一步:排除临时菜/赠菜/已抵扣商品(基础合格商品,按商品ID排除) + const baseEligibleGoods = filterCouponEligibleGoods(goodsList, config.excludedProductIds || []); + // 3. 第二步:按商品ID筛选门槛商品(匹配applicableProductIds) + const thresholdGoods = filterThresholdGoods(baseEligibleGoods, coupon.applicableProductIds); + if (thresholdGoods.length === 0) { + return { deductionAmount: 0, excludedProductIds: [] }; + } + + // 按商品ID分组(避免同商品多次处理,用商品ID作为分组key) + const goodsGroupByProductId = thresholdGoods.reduce((group, goods) => { + const productIdStr = String(goods.product_id); // 商品ID转字符串作为key + if (!group[productIdStr]) group[productIdStr] = []; + group[productIdStr].push(goods); + return group; + }, {} as Record); + + // 遍历每组商品计算半价优惠 + for (const [productIdStr, productGoods] of Object.entries(goodsGroupByProductId)) { + if (excludedProductIds.includes(productIdStr)) continue; // 按商品ID排除已处理商品 + + // 合并同商品数量(所有购物车项的数量总和) + const totalNum = productGoods.reduce((sum, g) => sum + (g.number - (g.returnNum || 0)), 0); + if (totalNum < 2) continue; // 至少2件才享受优惠 + + // 计算优惠次数(每2件1次,不超过最大使用次数) + const discountCount = Math.min( + Math.floor(totalNum / 2), + coupon.maxUseCountPerOrder || Infinity + ); + if (discountCount <= 0) continue; + + // 计算单件实际价格(取组内任意一个商品的配置,同商品规格一致) + const sampleGood = productGoods[0]; + const activity = config.activities.find((act: ActivityConfig) => + (act.applicableProductIds || []).includes(productIdStr) // 按商品ID匹配活动 + ); + const realPrice = calcSingleGoodsRealPrice(sampleGood, { + isMember: config.isMember, + memberDiscountRate: config.memberDiscountRate, + activity + }); + + // 累计抵扣金额并标记已优惠商品(记录商品ID) + totalDeduction += realPrice * 0.5 * discountCount; + excludedProductIds.push(productIdStr); + } + + return { + deductionAmount: truncateToTwoDecimals(totalDeduction), + excludedProductIds + }; + } +} + +/** 买一送一券计算策略(按商品ID分组,筛选门槛商品) */ +class BuyOneGetOneStrategy implements CouponCalculationStrategy { + calculate( + coupon: BuyOneGetOneCoupon, + goodsList: BaseCartItem[], + config: any + ): { deductionAmount: number; excludedProductIds: string[] } { + // 1. 基础校验:优惠券不可用/门店不匹配 → 抵扣0 + if (!coupon.available || !isStoreMatchByList(coupon.useShops, config.currentStoreId)) { + return { deductionAmount: 0, excludedProductIds: [] }; + } + + let totalDeduction = 0; + const excludedProductIds: string[] = []; // 存储排除的商品ID(商品ID) + + // 2. 第一步:排除临时菜/赠菜/已抵扣商品(基础合格商品,按商品ID排除) + const baseEligibleGoods = filterCouponEligibleGoods(goodsList, config.excludedProductIds || []); + // 3. 第二步:按商品ID筛选门槛商品(匹配applicableProductIds) + const thresholdGoods = filterThresholdGoods(baseEligibleGoods, coupon.applicableProductIds); + if (thresholdGoods.length === 0) { + return { deductionAmount: 0, excludedProductIds: [] }; + } + + // 按商品ID分组(用商品ID作为分组key) + const goodsGroupByProductId = thresholdGoods.reduce((group, goods) => { + const productIdStr = String(goods.product_id); + if (!group[productIdStr]) group[productIdStr] = []; + group[productIdStr].push(goods); + return group; + }, {} as Record); + + // 遍历每组商品计算买一送一优惠 + for (const [productIdStr, productGoods] of Object.entries(goodsGroupByProductId)) { + if (excludedProductIds.includes(productIdStr)) continue; // 按商品ID排除已处理商品 + + // 合并同商品数量 + const totalNum = productGoods.reduce((sum, g) => sum + (g.number - (g.returnNum || 0)), 0); + if (totalNum < 2) continue; // 至少2件才享受优惠 + + // 计算优惠次数(每2件送1件) + const discountCount = Math.floor(totalNum / 2); + if (discountCount <= 0) continue; + + // 计算单件实际价格(按商品ID匹配活动) + const sampleGood = productGoods[0]; + const activity = config.activities.find((act: ActivityConfig) => + (act.applicableProductIds || []).includes(productIdStr) // 按商品ID匹配活动 + ); + const realPrice = calcSingleGoodsRealPrice(sampleGood, { + isMember: config.isMember, + memberDiscountRate: config.memberDiscountRate, + activity + }); + + // 累计抵扣金额(送1件=减免1件价格)并标记商品ID + totalDeduction += realPrice * 1 * discountCount; + excludedProductIds.push(productIdStr); + } + + return { + deductionAmount: truncateToTwoDecimals(totalDeduction), + excludedProductIds + }; + } +} + +/** 商品兑换券计算策略(按商品ID筛选门槛商品) */ +class ExchangeCouponStrategy implements CouponCalculationStrategy { + calculate( + coupon: ExchangeCoupon, + goodsList: BaseCartItem[], + config: any + ): { deductionAmount: number; excludedProductIds: string[] } { + // 1. 基础校验:优惠券不可用/门店不匹配 → 抵扣0 + if (!coupon.available || !isStoreMatchByList(coupon.useShops, config.currentStoreId)) { + return { deductionAmount: 0, excludedProductIds: [] }; + } + + // 2. 第一步:排除临时菜/赠菜/已抵扣商品(基础合格商品,按商品ID排除) + const baseEligibleGoods = filterCouponEligibleGoods(goodsList, config.excludedProductIds || []); + // 3. 第二步:按商品ID筛选门槛商品(匹配applicableProductIds) + const thresholdGoods = filterThresholdGoods(baseEligibleGoods, coupon.applicableProductIds); + if (thresholdGoods.length === 0) { + return { deductionAmount: 0, excludedProductIds: [] }; + } + + // 按规则排序商品(按价格/数量/加入顺序) + const sortedGoods = sortGoodsForCoupon(thresholdGoods, coupon.sortRule, config.cartOrder); + let remainingCount = coupon.deductCount; + let totalDeduction = 0; + const excludedProductIds: string[] = []; // 存储排除的商品ID(商品ID) + + // 计算兑换抵扣金额(按商品ID累计,避免重复抵扣) + const usedProductIds = new Set(); // 记录已使用的商品ID(避免同商品多次抵扣) + for (const goods of sortedGoods) { + if (remainingCount <= 0) break; + const productIdStr = String(goods.product_id); + if (usedProductIds.has(productIdStr)) continue; // 同商品仅抵扣一次 + + const availableNum = Math.max(0, goods.number - (goods.returnNum || 0)); + if (availableNum === 0) continue; + + // 计算当前商品可抵扣的件数(不超过剩余可抵扣数量) + const deductCount = Math.min(availableNum, remainingCount); + const activity = config.activities.find((act: ActivityConfig) => + (act.applicableProductIds || []).includes(productIdStr) // 按商品ID匹配活动 + ); + const realPrice = calcSingleGoodsRealPrice(goods, { + isMember: config.isMember, + memberDiscountRate: config.memberDiscountRate, + activity + }); + + // 累计抵扣金额并标记商品 + totalDeduction += realPrice * deductCount; + excludedProductIds.push(productIdStr); + usedProductIds.add(productIdStr); + remainingCount -= deductCount; + } + + return { + deductionAmount: truncateToTwoDecimals(totalDeduction), + excludedProductIds + }; + } +} + +// ------------------------------ 策略辅助函数 ------------------------------ +/** + * 根据优惠券useShops列表判断门店是否匹配(适配BaseCoupon的useShops字段) + */ +function isStoreMatchByList(useShops: string[], currentStoreId: string): boolean { + // 适用门店为空数组 → 无限制(所有门店适用) + if (useShops.length === 0) return true; + // 匹配当前门店ID(字符串比较,避免类型问题) + return useShops.includes(currentStoreId); +} + +/** + * 优惠券计算策略工厂(根据优惠券类型获取对应策略,易扩展) + */ +function getCouponStrategy(couponType: CouponType): CouponCalculationStrategy { + switch (couponType) { + case CouponType.FULL_REDUCTION: + return new FullReductionStrategy(); + case CouponType.DISCOUNT: + return new DiscountStrategy(); + case CouponType.SECOND_HALF: + return new SecondHalfPriceStrategy(); + case CouponType.BUY_ONE_GET_ONE: + return new BuyOneGetOneStrategy(); + case CouponType.EXCHANGE: + return new ExchangeCouponStrategy(); + default: + throw new Error(`不支持的优惠券类型:${couponType}`); + } +} + +/** + * 计算优惠券抵扣金额(处理互斥逻辑,选择最优优惠券,按商品ID排除) + * @param backendCoupons 后端优惠券列表 + * @param goodsList 商品列表 + * @param config 订单配置(含就餐类型) + * @returns 最优优惠券的抵扣结果 + */ +export function calcCouponDeduction( + backendCoupons: BackendCoupon[], + goodsList: BaseCartItem[], + config: Pick & { + activities: ActivityConfig[]; + cartOrder: Record; + dinnerType: 'dine-in' | 'take-out'; + currentTime?: Date; + } +): { + deductionAmount: number; + usedCoupon?: Coupon; + excludedProductIds: string[]; // 排除的商品ID列表(商品ID) +} { + // 1. 后端优惠券转工具库Coupon(过滤无效/不支持的券) + const toolCoupons = backendCoupons + .map(coupon => convertBackendCouponToToolCoupon( + coupon, + config.currentStoreId, + config.dinnerType, + config.currentTime + )) + .filter(Boolean) as Coupon[]; + + if (toolCoupons.length === 0) { + return { deductionAmount: 0, excludedProductIds: [] }; + } + + // 2. 优惠券互斥逻辑:兑换券与其他券互斥,优先选最优 + const exchangeCoupons = toolCoupons.filter(c => c.type === CouponType.EXCHANGE); + const nonExchangeCoupons = toolCoupons.filter(c => c.type !== CouponType.EXCHANGE); + + // 3. 计算非兑换券最优抵扣(传递已抵扣商品ID,避免重复) + let nonExchangeResult: CouponResult = { + deductionAmount: 0, + excludedProductIds: [], + usedCoupon: undefined + }; + if (nonExchangeCoupons.length > 0) { + nonExchangeResult = nonExchangeCoupons.reduce((best, coupon) => { + const strategy = getCouponStrategy(coupon.type); + const result = strategy.calculate(coupon, goodsList, { + ...config, + excludedProductIds: best.excludedProductIds // 传递已排除的商品ID(商品ID) + }); + const currentResult: CouponResult = { + ...result, + usedCoupon: coupon + }; + return currentResult.deductionAmount > best.deductionAmount ? currentResult : best; + }, nonExchangeResult); + } + + // 4. 计算兑换券抵扣(排除非兑换券已抵扣的商品ID) + let exchangeResult: ExchangeCalculationResult = { deductionAmount: 0, excludedProductIds: [] }; + if (exchangeCoupons.length > 0) { + exchangeResult = exchangeCoupons.reduce((best, coupon) => { + const strategy = getCouponStrategy(coupon.type); + const result = strategy.calculate(coupon, goodsList, { + ...config, + excludedProductIds: [...nonExchangeResult.excludedProductIds, ...best.excludedProductIds] // 合并排除的商品ID + }); + return result.deductionAmount > best.deductionAmount ? result : best; + }, exchangeResult); + } + + // 5. 汇总结果:兑换券与非兑换券不可同时使用,取抵扣金额大的 + const isExchangeBetter = exchangeResult.deductionAmount > nonExchangeResult.deductionAmount; + return { + deductionAmount: truncateToTwoDecimals(isExchangeBetter ? exchangeResult.deductionAmount : nonExchangeResult.deductionAmount), + 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 { + return truncateToTwoDecimals( + goodsList.reduce((total, goods) => { + const availableNum = Math.max(0, goods.number - (goods.returnNum || 0)); + if (availableNum === 0) return total; + + // 计算单个商品打包数量(外卖全打包,堂食按配置,称重商品≤1) + let packNum = dinnerType === 'take-out' + ? availableNum + : (goods.packNumber || 0); + if (goods.product_type === GoodsType.WEIGHT) { + packNum = Math.min(packNum, 1); + } + + return total + (goods.packFee || 0) * packNum; + }, 0) + ); +} + +/** + * 计算餐位费(按人数,不参与营销活动) + * @param config 餐位费配置 + * @returns 餐位费(元,未启用则0) + */ +export function calcSeatFee(config: SeatFeeConfig): number { + if (!config.isEnabled) return 0; + const personCount = Math.max(1, config.personCount); // 至少1人 + return truncateToTwoDecimals(config.pricePerPerson * personCount); +} + +/** + * 计算积分抵扣金额(按X积分=1元,不超过最大抵扣和用户积分) + * @param userPoints 用户当前积分 + * @param rule 积分抵扣规则 + * @param maxDeductionLimit 最大抵扣上限(通常为订单金额,避免超扣) + * @returns 积分抵扣金额 + 实际使用积分 + */ +export function calcPointDeduction( + userPoints: number, + rule: PointDeductionRule, + maxDeductionLimit: number +): { + deductionAmount: number; + usedPoints: number; +} { + if (rule.pointsPerYuan <= 0 || userPoints <= 0) { + return { deductionAmount: 0, usedPoints: 0 }; + } + + // 最大可抵扣金额(积分可抵金额 vs 规则最大 vs 订单上限) + const maxDeductByPoints = truncateToTwoDecimals(userPoints / rule.pointsPerYuan); + const maxDeductAmount = Math.min( + maxDeductByPoints, + rule.maxDeductionAmount ?? Infinity, + maxDeductionLimit + ); + + // 实际使用积分 = 抵扣金额 * 积分兑换比例 + const usedPoints = truncateToTwoDecimals(maxDeductAmount * rule.pointsPerYuan); + return { + deductionAmount: maxDeductAmount, + usedPoints: Math.min(usedPoints, userPoints) // 避免积分超扣 + }; +} + +// ============================ 6. 订单总费用汇总与实付金额计算(核心入口,逻辑不变) ============================ +/** + * 计算订单所有费用子项并汇总(核心入口函数) + * @param goodsList 购物车商品列表 + * @param dinnerType 就餐类型 + * @param 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, + 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); + + // 2. 优惠券抵扣(传递就餐类型用于可用性判断) + const { deductionAmount: couponDeductionAmount, usedCoupon, excludedProductIds } = calcCouponDeduction( + backendCoupons, + goodsList, + { + currentStoreId: config.currentStoreId, + isMember: config.isMember, + memberDiscountRate: config.memberDiscountRate, + activities, + cartOrder, + dinnerType, + currentTime + } + ); + + // 3. 其他费用计算 + const packFee = calcTotalPackFee(goodsList, dinnerType); + const seatFee = calcSeatFee(config.seatFeeConfig); + // 积分抵扣上限:商品实际价 - 优惠券抵扣(避免负抵扣) + const maxPointDeductionLimit = Math.max(0, goodsRealAmount - couponDeductionAmount); + const { deductionAmount: pointDeductionAmount, usedPoints } = calcPointDeduction( + config.userPoints, + config.pointDeductionRule, + maxPointDeductionLimit + ); + const merchantReductionAmount = Math.max(0, config.merchantReduction); + const additionalFee = Math.max(0, config.additionalFee); + + // 4. 计算最终实付金额(确保非负) + const finalPayAmount = truncateToTwoDecimals( + goodsOriginalAmount + - goodsDiscountAmount + - couponDeductionAmount + - pointDeductionAmount + + seatFee + + packFee + - merchantReductionAmount + + additionalFee + ); + const finalPayAmountNonNegative = Math.max(0, finalPayAmount); + + return { + goodsOriginalAmount, + goodsDiscountAmount, + couponDeductionAmount, + pointDeductionAmount, + seatFee, + packFee, + merchantReductionAmount, + additionalFee, + finalPayAmount: finalPayAmountNonNegative, + couponUsed: usedCoupon, + pointUsed: usedPoints + }; +} + +// ============================ 7. 对外暴露工具库 ============================ +export const OrderPriceCalculator = { + // 基础工具 + truncateToTwoDecimals, + isTemporaryGoods, + isGiftGoods, + formatDiscountRate, + filterThresholdGoods, + // 优惠券转换 + convertBackendCouponToToolCoupon, + // 商品价格计算 + calcSingleGoodsRealPrice, + calcGoodsOriginalAmount, + calcGoodsRealAmount, + calcGoodsDiscountAmount, + // 优惠券计算 + calcCouponDeduction, + // 其他费用计算 + calcTotalPackFee, + calcSeatFee, + calcPointDeduction, + // 核心入口 + calculateOrderCostSummary, + // 枚举导出 + Enums: { + GoodsType, + CouponType, + ActivityType, + } +}; + +export default OrderPriceCalculator; diff --git a/src/utils/test.ts b/src/utils/test.ts new file mode 100644 index 0000000..d2a5b93 --- /dev/null +++ b/src/utils/test.ts @@ -0,0 +1,210 @@ +import { OrderPriceCalculator, GoodsType, BackendCoupon, ActivityConfig, CouponType } from "./goods"; + +// 修正后的测试数据(严格匹配BaseCartItem类型) +const testGoodsList = [ + { + // 核心修正1:product_type使用GoodsType枚举(替代原product_type字符串) + product_type: GoodsType.NORMAL, // "sku"类型归类为普通商品 + // 核心修正2:isTemporary/isGift从数字转为布尔值 + isTemporary: false, + isGift: false, + // 核心修正3:discountSaleAmount从字符串转为数字 + discountSaleAmount: 0, + + // 原有字段(保持不变) + salePrice: 2, + memberPrice: 2, + coverImg: "https://czg-oss.oss-cn-hangzhou.aliyuncs.com/catering/store/9660.png", + name: "多规格起售3", + specInfo: "常温", + packFee: 1, + type: "sku", + skuData: { + barCode: "88888888888888888888", + costPrice: 2, + coverImg: "", + createTime: "2025-03-27 16:23:49", + id: "2451", + isDel: 0, + isGrounding: 1, + isPauseSale: 0, + isSale: 1, + isSoldStock: 0, + lowPrice: 2, + memberPrice: 2, + name: "常温", + originPrice: 2, + productId: "946", + realSalesNumber: 0, + salePrice: 2, + shopId: "29", + specInfo: "常温", + suitNum: 3, + updateTime: "2025-03-27 16:23:49", + weight: 0, + }, + id: 18264, + shop_id: 29, + table_code: "APC36217948", + sku_id: 2451, + product_id: 946, + product_name: "", + sku_name: "", + number: 4, + pack_number: 0, + discount_sale_note: "", + is_print: 1, + is_wait_call: 0, + pro_group_info: "", + remark: "", + create_time: "2025-09-16 16:00:25", + update_time: null, + }, + { + // 核心修正1:package类型映射为GoodsType.PACKAGE + product_type: GoodsType.PACKAGE, + // 核心修正2:布尔值转换 + isTemporary: false, + isGift: false, + // 核心修正3:字符串转数字 + discountSaleAmount: 5, + + // 原有字段 + salePrice: 11, + memberPrice: 22, + coverImg: "https://czg-oss.oss-cn-hangzhou.aliyuncs.com/catering/store/8351.png", + name: "蜜雪冰城", + specInfo: "", + packFee: 555, + type: "package", + skuData: { + barCode: "291739694712255", + costPrice: 0, + coverImg: "", + createTime: "2025-02-16 16:36:46", + id: "2383", + isDel: 0, + isGrounding: 1, + isPauseSale: 0, + isSale: 1, + isSoldStock: 0, + lowPrice: 11, + memberPrice: 22, + name: "", + originPrice: 11, + productId: "943", + realSalesNumber: 0, + salePrice: 11, + shopId: "29", + specInfo: "", + suitNum: 33, + updateTime: null, + weight: 0, + }, + id: 18263, + shop_id: 29, + table_code: "APC36217948", + sku_id: 2383, + product_id: 943, + product_name: "", + sku_name: "", + number: 35, + pack_number: 1, + discount_sale_note: "", + is_print: 1, + is_wait_call: 0, + pro_group_info: "", + remark: "", + create_time: "2025-09-16 15:59:59", + update_time: "2025-09-16 16:00:48", + }, + { + // 核心修正1:空字符串product_type映射为GoodsType.EMPTY + product_type: GoodsType.EMPTY, + // 核心修正2:临时菜标记为true + isTemporary: true, + isGift: false, + // 核心修正3:折扣金额为数字 + discountSaleAmount: 1, + // 补充必需字段:salePrice(临时菜原价) + salePrice: 1, + + // 原有字段 + id: 18265, + shop_id: 29, + table_code: "APC36217948", + sku_id: -999, + product_id: -18293045, + product_name: "临时菜", + sku_name: "", + number: 1, + pack_number: 0, + discount_sale_note: "", + is_print: 1, + is_wait_call: 0, + pro_group_info: "", + remark: "", + create_time: "2025-09-16 16:00:57", + update_time: null, + }, +]; + +// 修正testCartOrder的key与商品id匹配(确保排序正确) +const testCartOrder = { + "18264": 1717200000000, // 对应第一个商品id + "18263": 1717200100000, // 对应第二个商品id + "18265": 1717200200000, // 对应第三个商品id +}; + +// 其他配置保持不变 +const testBackendCoupons: BackendCoupon[] = [ + { + id: 1, + title: "满减券", + couponType: 1, + fullAmount: 2, + discountAmount: 1, + foods: '946', + useType: 'dine-in,pickup', + useShopType: 'all', + validType: 'custom', + validStartTime: '2025-09-16 16:00:00', + validEndTime: '2025-09-30 16:00:00', + useDays: '周一,周二,周三,周四,周五', + useTimeType: 'all', + }, +]; +const testOrderConfig = { + currentStoreId: "101", + isMember: false, + memberDiscountRate: 0.95, + userPoints: 0, + pointDeductionRule: { + pointsPerYuan: 100, + maxDeductionAmount: 50, + }, + seatFeeConfig: { + isEnabled: false, + pricePerPerson: 3, + personCount: 2, + }, + merchantReduction: 10, + additionalFee: 0, +}; +const testActivities: ActivityConfig[] = []; +const testCurrentTime = new Date("2024-06-01 12:00:00"); + +// 调用函数(此时类型完全匹配) +const result = OrderPriceCalculator.calculateOrderCostSummary( + testGoodsList, + "dine-in", + testBackendCoupons, + testActivities, + testOrderConfig, + testCartOrder, + testCurrentTime +); +console.log("计算结果:", result); + + +export default {} \ No newline at end of file diff --git a/src/views/marketing_center/new_user_discount/components/dialog-plans.vue b/src/views/marketing_center/new_user_discount/components/dialog-plans.vue index 3a1cd0f..3684750 100644 --- a/src/views/marketing_center/new_user_discount/components/dialog-plans.vue +++ b/src/views/marketing_center/new_user_discount/components/dialog-plans.vue @@ -11,9 +11,7 @@ - - - +