Pārlūkot izejas kodu

fix(mallApp): 完善混合结算超限商品处理

- 统一解析限购与库存不足响应,并在确认页和购物车高亮提示超出数量
- 回写历史已购和剩余库存进行二次校验,成功或普通失败后及时清理共享缓存
shizhongqi 2 dienas atpakaļ
vecāks
revīzija
ff606ea5a1

+ 6 - 0
mallApp/src/constant/storageKeys.js

@@ -15,3 +15,9 @@ export const SHOP_NAV_ACTIVE_KEY = "shopNavActive";
  * 立即购买独立于购物车:只把当前商品带入结算,下单成功后不清空购物车。
  */
 export const BUY_NOW_MIX_LIST_KEY = "buyNowMixList";
+
+/**
+ * 混合结算提交后后端返回的限购超限 / 库存不足商品列表。
+ * 确认页高亮用,同时写入本地,购物车返回后也能展示并引导减数量。
+ */
+export const MIX_LIMIT_EXCEED_KEY = "mixLimitExceed";

+ 6 - 3
mallApp/src/mixins/cgProduct.js

@@ -433,8 +433,9 @@ export default {
 		},
 		/**
 		 * 校验花束本次数量是否超过商品库存 / 秒杀活动库存 / 秒杀限购。
-		 * 不计入客户历史已购(当前没有该数据);existingCount 用于加购合并购物车已有件数。
-		 * @param {Object} goods 带 stock / activityType / activityLimit / activityStock 的商品或购物车行
+		 * existingCount 用于加购合并购物车已有件数。
+		 * activityBoughtCount 为结算失败后回写的历史已购,有值时与本次数量一并计入限购。
+		 * @param {Object} goods 带 stock / activityType / activityLimit / activityStock / activityBoughtCount 的商品或购物车行
 		 * @param {Number} buyNum 本次数量
 		 * @param {Number} existingCount 购物车已有数量,立即购买传 0
 		 * @returns {boolean} 是否通过
@@ -469,7 +470,9 @@ export default {
 					return false;
 				}
 				const limit = Number(goods.activityLimit) || 0;
-				if (limit > 0 && total > limit) {
+				// 结算失败后回写的已购数量,避免「限购1件且已买过1件」仍能加到购物车
+				const bought = Number(goods.activityBoughtCount) || 0;
+				if (limit > 0 && (total + bought) > limit) {
 					this.$msg('超过限购数量,每人限购' + limit + '件');
 					return false;
 				}

+ 76 - 22
mallApp/src/pages/billing/affirmMix.vue

@@ -4,6 +4,7 @@
   整单优惠:会员折扣与门店满减互斥,左侧名称、右侧 -¥N,只展示优惠金额更大的一项
   布局:贺卡/匿名/备注后为运费+折扣+红包同一卡片;贺卡默认不需要,匿名默认否,备注单行
   顶部公告:/shop-notice/show-list position=8(订单提交),公告条与分类页 shop-category 同款(noticeBar)
+  限购超限 / 库存不足:create-mix-order 返回超限或库存不足商品后红框高亮,并写入本地供购物车继续展示
 -->
 <template>
 	<view class="app-main app-content affirm-page">
@@ -53,10 +54,10 @@
 								<view class="item-row-qty">
 									<text class="qty-display">{{ getItemQtyDisplay(item) }}</text>
 								</view>
-								<view v-if="!isBouquetItem(item) && (isLimitExceededItem(item) || getLimitBuyPriceTip(item) || item.presell == 1)" class="item-row-tips">
-									<text v-if="isLimitExceededItem(item)" class="limit-exceed-tip">限购 {{ limitExceedLimitBuy }},超出 {{ limitExceedNum }}</text>
+								<view v-if="showItemTips(item)" class="item-row-tips">
+									<text v-if="isLimitExceededItem(item)" class="limit-exceed-tip">{{ getLimitExceedTipText(item) }}</text>
 									<text v-if="getLimitBuyPriceTip(item)" class="limit-buy-price-tip">{{ getLimitBuyPriceTip(item) }}</text>
-									<text v-if="item.presell == 1" class="presell-tip">预售花材下单不能退款</text>
+									<text v-if="!isBouquetItem(item) && item.presell == 1" class="presell-tip">预售花材下单不能退款</text>
 								</view>
 							</view>
 						</view>
@@ -502,6 +503,7 @@ import HbSelect from "@/components/hb/hb-select";
 import { isHbAvailable } from "@/utils/hbScope";
 import HomeNoticeBar from "@/components/home/noticeBar.vue";
 import { BUY_NOW_MIX_LIST_KEY } from "@/constant/storageKeys";
+import { parseLimitExceedData, findLimitExceedRow, buildLimitExceedTip, saveMixLimitExceed, clearMixLimitExceed, isLimitExceedMatch } from "@/utils/limitExceed";
 
 /** 配送时段间隔(分钟),与参考图 10 分钟一档一致 */
 const REACH_TIME_SLOT_MINUTES = 10;
@@ -587,6 +589,8 @@ export default {
 			limitExceedProductId: "",
 			limitExceedLimitBuy: "",
 			limitExceedNum: "",
+			/** 下单失败返回的超限商品列表,花束/花材都按此高亮 */
+			limitExceedList: [],
 			/** 跑腿模式下是否已手动选择地址(区别于自动加载) */
 			addressManuallySelected: false,
 			/** 门店配送方式配置(xhPsMethod + xhPsExplain) */
@@ -2342,9 +2346,7 @@ export default {
 			createMixOrder(params).then((res) => {
 				uni.hideLoading();
 				if (res.code && res.code == 1) {
-					this.limitExceedProductId = "";
-					this.limitExceedLimitBuy = "";
-					this.limitExceedNum = "";
+					this.clearLimitExceedState();
 					const account = this.option.account ? this.option.account : uni.getStorageSync("account") || 0;
 					const getPayType = res.data.getPayType ? res.data.getPayType : 0;
 					const totalPrice = res.data.totalPrice ? res.data.totalPrice : 0;
@@ -2354,24 +2356,18 @@ export default {
 						url: "/pages/callback/pay?account=" + account + "&id=" + id + "&hdId=" + hdId + "&pageStatus=1&getPayType=" + getPayType + "&totalPrice=" + totalPrice + "&orderSn=" + orderSn,
 						type: 2
 					});
-				} else if (res.code == -1 && res.data && String(res.data.productId || "") !== "") {
-					this.limitExceedProductId = res.data.productId ? String(res.data.productId) : "";
-					this.limitExceedLimitBuy = res.data.limitBuy ? String(res.data.limitBuy) : "0";
-					this.limitExceedNum = res.data.exceed ? String(res.data.exceed) : "0";
-					this.$msg(res.msg || "累计已超出限购数");
-					return false;
+					// 仅下单成功才清空购物车记忆;失败时保留,便于返回购物车减数量
+					if (!this.isBuyNow) {
+						this.removeMemory();
+					}
+				} else if (this.applyLimitExceedFromResponse(res)) {
+					this.$msg(res.msg || "商品超出可购买数量");
 				} else {
-					this.limitExceedProductId = "";
-					this.limitExceedLimitBuy = "";
-					this.limitExceedNum = "";
+					this.clearLimitExceedState();
 					if (res.msg) {
 						this.$msg(res.msg);
 					}
 				}
-				// 立即购买下单成功不影响购物车:仅购物车结算成功后才清空购物车
-				if (!this.isBuyNow) {
-					this.removeMemory();
-				}
 			}).catch((err) => {
 				uni.hideLoading();
 				this.$msg(err.msg);
@@ -2382,11 +2378,69 @@ export default {
 			const hdId = this.option.hdId ? this.option.hdId : uni.getStorageSync("hdId") || 0;
 			this.$util.pageTo({ url: "/pages/home/cart?account=" + account + "&hdId=" + hdId, type: 2 });
 		},
-		isLimitExceededItem(item) {
-			if (!item || !this.limitExceedProductId) {
+		/**
+		 * 解析下单失败里的超限/库存不足商品,写入本页高亮并同步给购物车。
+		 * 秒杀限购、秒杀库存、花束/花材库存都走 code=-1 + productId。
+		 */
+		applyLimitExceedFromResponse(res) {
+			if (!res || Number(res.code) !== -1 || !res.data) {
+				return false;
+			}
+			const rows = parseLimitExceedData(res.data).map((row) => {
+				if (row.num !== undefined && row.num !== null && row.num !== "") {
+					return row;
+				}
+				const item = (this.list || []).find((it) => isLimitExceedMatch(it, row));
+				if (!item) {
+					return row;
+				}
+				return { ...row, num: Number(item.bigCount) || 0 };
+			});
+			if (!rows.length) {
+				return false;
+			}
+			this.limitExceedList = rows;
+			const first = rows[0];
+			this.limitExceedProductId = first.productId ? String(first.productId) : "";
+			this.limitExceedLimitBuy = first.limitBuy !== undefined && first.limitBuy !== null ? String(first.limitBuy) : "0";
+			this.limitExceedNum = first.exceed !== undefined && first.exceed !== null ? String(first.exceed) : "0";
+			saveMixLimitExceed(rows);
+			return true;
+		},
+		/**
+		 * 清空本页与购物车共享的超限状态,避免成功下单或其他失败后继续使用旧库存/限购信息。
+		 */
+		clearLimitExceedState() {
+			this.limitExceedProductId = "";
+			this.limitExceedLimitBuy = "";
+			this.limitExceedNum = "";
+			this.limitExceedList = [];
+			clearMixLimitExceed();
+		},
+		showItemTips(item) {
+			if (!item) {
 				return false;
 			}
-			return String(item.id) === String(this.limitExceedProductId);
+			if (this.isLimitExceededItem(item)) {
+				return true;
+			}
+			if (!this.isBouquetItem(item) && (this.getLimitBuyPriceTip(item) || item.presell == 1)) {
+				return true;
+			}
+			return false;
+		},
+		isLimitExceededItem(item) {
+			return !!findLimitExceedRow(item, this.limitExceedList);
+		},
+		getLimitExceedTipText(item) {
+			const row = findLimitExceedRow(item, this.limitExceedList);
+			if (row) {
+				return buildLimitExceedTip(row);
+			}
+			if (!this.limitExceedLimitBuy) {
+				return "";
+			}
+			return "限购 " + this.limitExceedLimitBuy + ",超出 " + this.limitExceedNum;
 		},
 		/**
 		 * 拉取当前门店可用红包列表。

+ 138 - 2
mallApp/src/pages/home/cart.vue

@@ -4,6 +4,7 @@
   顶部公告:/shop-notice/show-list position=4(购物车),公告条与分类页 shop-category 同款(noticeBar)
   滚动:页面原生滚动(不用 scroll-view),公告条 disableTouch,减少滑动卡顿
   未登录:参考消息 Tab,空态提示并点击跳转登录,不请求需登录接口
+  限购超限 / 库存不足:结算失败后回写对应商品,红框高亮并提示超出件数,方便减数量后再去结算
   路由:pages/home/cart?account=xxx&hdId=xxx
 -->
 <template>
@@ -47,6 +48,7 @@
       <view class="cart-list" v-if="displayCartList.length">
         <view
           class="cart-item"
+          :class="{ 'cart-item--limit-exceed': isLimitExceededItem(item) }"
           v-for="(item, index) in displayCartList"
           :key="getItemKey(item)"
         >
@@ -75,6 +77,7 @@
               </view>
             </view>
             <text class="cart-item-spec">{{ getItemSpec(item) }}</text>
+            <text v-if="getCartLimitExceedTip(item)" class="cart-item-limit-tip">{{ getCartLimitExceedTip(item) }}</text>
             <view class="cart-item-bottom">
               <view class="cart-item-price-wrap">
                 <text class="cart-item-price">¥{{ formatPrice(getCartItemUnitPrice(item)) }}</text>
@@ -170,6 +173,7 @@ import productMins from '@/mixins/cgProduct'
 import { getInfo } from '@/api/shop'
 import { getLimitBuyInfo } from '@/api/order'
 import { share } from '@/mixins'
+import { loadMixLimitExceed, findLimitExceedRow, buildLimitExceedTip, getItemSaleProductId, isLimitExceedMatch } from '@/utils/limitExceed'
 
 export default {
   name: 'shopCart',
@@ -201,6 +205,8 @@ export default {
         smallCount: 0
       },
       limitBuyWarnList: [],
+      /** 结算失败回写的超限商品,用于红框高亮和提示超出件数 */
+      limitExceedList: [],
       /** 本页是否已完成过登录态下的 init(登录返回后可补 init) */
       cartBooted: false
     }
@@ -294,6 +300,7 @@ export default {
     this.cleanupCartList()
     this.restoreItemCartFromStorage()
     this.cleanupCartList()
+    this.applyLimitExceedFromStorage()
     this.syncCheckedMap()
   },
   methods: {
@@ -358,6 +365,7 @@ export default {
       }
       this.applyShopFromCache()
       this.restoreItemCartFromStorage()
+      this.applyLimitExceedFromStorage()
       this.syncCheckedMap()
       this.loadShopInfo()
       this.cartBooted = true
@@ -391,6 +399,109 @@ export default {
         this.cleanupCartList()
       }
     },
+    /**
+     * 读取结算失败写入的超限/库存不足商品:本页高亮,并把已购数量、剩余库存回写到对应购物车行,
+     * 这样加减数量/再去结算时能按最新限购和库存拦住。
+     */
+    applyLimitExceedFromStorage() {
+      const rows = loadMixLimitExceed()
+      this.limitExceedList = rows
+      if (!rows.length) {
+        return
+      }
+      const list = this.sanitizeCartList(this.$util.copyObject(this.selectList || []))
+      let changed = false
+      list.forEach((item, idx) => {
+        const row = rows.find((r) => isLimitExceedMatch(item, r))
+        if (!row) {
+          return
+        }
+        const bought = Number(row.boughtCount)
+        const limitBuy = Number(row.limitBuy)
+        const apiStock = Number(row.stock)
+        const next = { ...item }
+        let rowChanged = false
+        if (isFinite(bought) && Number(item.activityBoughtCount) !== bought) {
+          next.activityBoughtCount = bought
+          rowChanged = true
+        }
+        // 缓存行可能缺 activityLimit,用接口限购数补上,后续加减/去结算才能拦住
+        if (isFinite(limitBuy) && limitBuy > 0 && !(Number(item.activityLimit) > 0) && (row.activityType === 'seckill' || item.activityType === 'seckill')) {
+          next.activityLimit = limitBuy
+          rowChanged = true
+        }
+        // 库存不足回写剩余库存,减数量后可按最新库存重新校验
+        if (row.reason === 'stock' && isFinite(apiStock)) {
+          if (Number(item.stock) !== apiStock) {
+            next.stock = apiStock
+            rowChanged = true
+          }
+          if ((row.activityType === 'seckill' || item.activityType === 'seckill') && Number(item.activityStock) !== apiStock) {
+            next.activityStock = apiStock
+            rowChanged = true
+          }
+        }
+        if (rowChanged) {
+          list[idx] = next
+          changed = true
+        }
+      })
+      if (changed) {
+        this.setSelectInfoByType({ type: this.pageType, info: list })
+        this.persistCartToStorage()
+      }
+    },
+    getCartLimitExceedInfo(item) {
+      const fromApi = findLimitExceedRow(item, this.limitExceedList)
+      if (fromApi) {
+        return fromApi
+      }
+      if (!item) {
+        return null
+      }
+      const qty = Number(item.bigCount) || 0
+      if (item.activityType === 'seckill') {
+        const limit = Number(item.activityLimit) || 0
+        const bought = Number(item.activityBoughtCount) || 0
+        if (limit > 0 && (qty + bought) > limit) {
+          return {
+            productId: getItemSaleProductId(item),
+            reason: 'limit',
+            limitBuy: limit,
+            boughtCount: bought,
+            exceed: parseFloat((qty + bought - limit).toFixed(2))
+          }
+        }
+        const actStockRaw = item.activityStock
+        const hasActStock = actStockRaw !== undefined && actStockRaw !== null && actStockRaw !== ''
+        const actStock = Number(actStockRaw)
+        if (hasActStock && isFinite(actStock) && qty > actStock) {
+          return {
+            productId: getItemSaleProductId(item),
+            reason: 'stock',
+            stock: Math.max(0, actStock),
+            exceed: parseFloat((qty - Math.max(0, actStock)).toFixed(2))
+          }
+        }
+      }
+      const stockSet = Number(item.stockSet)
+      const stock = Number(item.stock)
+      if (stockSet === 1 && isFinite(stock) && qty > stock) {
+        return {
+          productId: getItemSaleProductId(item),
+          reason: 'stock',
+          stock: Math.max(0, stock),
+          exceed: parseFloat((qty - Math.max(0, stock)).toFixed(2))
+        }
+      }
+      return null
+    },
+    isLimitExceededItem(item) {
+      return !!this.getCartLimitExceedInfo(item)
+    },
+    getCartLimitExceedTip(item) {
+      return buildLimitExceedTip(this.getCartLimitExceedInfo(item))
+    },
     loadShopInfo() {
       return getInfo({ hdId: this.hdId || uni.getStorageSync('hdId') || 0 }).then((res) => {
         if (res.code !== 1 || !res.data) {
@@ -713,18 +824,31 @@ export default {
         this.$msg('请选择要结算的商品')
         return
       }
-      // 秒杀限购兜底:正常应在加购/加数量时拦住;已在车内的超量行仍在此拦截,避免拖到结算页才失败
+      // 秒杀限购兜底:加上历史已购(下单失败回写的 activityBoughtCount),避免已购满仍能进结算页
       const seckillOver = list.find((item) => {
         if (item.activityType !== 'seckill') {
           return false
         }
         const limit = Number(item.activityLimit) || 0
-        return limit > 0 && (Number(item.bigCount) || 0) > limit
+        const bought = Number(item.activityBoughtCount) || 0
+        return limit > 0 && ((Number(item.bigCount) || 0) + bought) > limit
       })
       if (seckillOver) {
         this.$msg('超过限购数量,每人限购' + seckillOver.activityLimit + '件')
         return
       }
+      // 结算失败回写的超限/库存不足:减到不超前不进结算页
+      const stillOver = list.find((item) => this.isLimitExceededItem(item))
+      if (stillOver) {
+        const info = this.getCartLimitExceedInfo(stillOver)
+        if (info && info.reason === 'stock') {
+          this.$msg('库存不足,请先减数量')
+          return
+        }
+        const limitText = info && info.limitBuy ? String(info.limitBuy) : ''
+        this.$msg(limitText ? ('超过限购数量,每人限购' + limitText + '件') : '有商品超出限购,请先减数量')
+        return
+      }
       const ghsId = Number(this.shopAccount) || Number(uni.getStorageSync('account')) || 0
       if (!ghsId) {
         this.$msg('缺少店铺信息')
@@ -893,6 +1017,10 @@ page {
   box-sizing: border-box;
 }
 
+.cart-item--limit-exceed {
+  border: 2upx solid #ff4d4f;
+}
+
 .cart-item-check {
   padding-top: 48upx;
   margin-right: 16upx;
@@ -988,6 +1116,14 @@ page {
   white-space: nowrap;
 }
 
+.cart-item-limit-tip {
+  display: block;
+  margin-top: 8upx;
+  color: #ff4d4f;
+  font-size: 24upx;
+  font-weight: bold;
+}
+
 .cart-item-bottom {
   margin-top: 16upx;
   display: flex;

+ 200 - 0
mallApp/src/utils/limitExceed.js

@@ -0,0 +1,200 @@
+/**
+ * 混合下单限购超限 / 库存不足:解析后端 data、匹配购物车/结算行、按当前数量回算超出件数。
+ * 结算页与购物车共用,避免两处对 productId / 花束规格 id 的匹配不一致。
+ */
+import { MIX_LIMIT_EXCEED_KEY } from "@/constant/storageKeys";
+
+/**
+ * 展示用数量:去掉多余小数位,1.00 显示为 1。
+ */
+export function formatLimitNum(val) {
+	const n = Number(val);
+	if (!isFinite(n)) {
+		return String(val || "0");
+	}
+	return String(parseFloat(n.toFixed(2)));
+}
+
+/**
+ * 花束实际售卖 id:有规格用 specGoodsId,否则用主商品 id。
+ * 与 affirmMix 提交 create-mix-order 时的 productId 对齐。
+ */
+export function getItemSaleProductId(item) {
+	if (!item) {
+		return "";
+	}
+	if (isBouquetItem(item)) {
+		const specId = Number(item.specGoodsId) || 0;
+		return String(specId > 0 ? specId : item.id);
+	}
+	return String(item.id || item.productId || "");
+}
+
+function isBouquetItem(item) {
+	return !!(item && (item.property === 0 || item.property === "0"));
+}
+
+function getItemQty(item) {
+	if (!item) {
+		return 0;
+	}
+	if (isBouquetItem(item)) {
+		return Number(item.bigCount) || 0;
+	}
+	let qty = Number(item.bigCount) || 0;
+	const small = Number(item.smallCount) || 0;
+	const ratio = Number(item.ratio) || 0;
+	if (small > 0 && ratio > 0) {
+		qty += small / ratio;
+	}
+	return qty;
+}
+
+/**
+ * 把接口 data 规范成超限行数组。
+ * 兼容花材 handleLimitBuy 的单对象 { productId, limitBuy, exceed },以及秒杀带 items 的形态。
+ */
+export function parseLimitExceedData(data) {
+	if (!data) {
+		return [];
+	}
+	if (Array.isArray(data)) {
+		return data.filter((row) => row && String(row.productId || "") !== "");
+	}
+	if (typeof data !== "object") {
+		return [];
+	}
+	const items = Array.isArray(data.items) ? data.items : [];
+	const rows = items.length ? items : [data];
+	return rows.filter((row) => row && String(row.productId || "") !== "");
+}
+
+/**
+ * 判断结算/购物车行是否对应某条超限记录。
+ * 花束按售卖 id(规格优先)匹配,并尽量带上 activityType,避免秒杀行和普通行串单。
+ * 花材按商品 id 匹配(与 handleLimitBuy 返回的 productId 一致)。
+ */
+export function isLimitExceedMatch(item, row) {
+	if (!item || !row) {
+		return false;
+	}
+	const productId = String(row.productId || "");
+	if (!productId) {
+		return false;
+	}
+	const rowProperty = row.property;
+	if (rowProperty === 0 || rowProperty === "0") {
+		const saleId = getItemSaleProductId(item);
+		const hit = saleId === productId
+			|| String(item.id) === productId
+			|| String(item.specGoodsId || "") === productId;
+		if (!hit) {
+			return false;
+		}
+		if (row.activityType && item.activityType && String(row.activityType) !== String(item.activityType)) {
+			return false;
+		}
+		return isBouquetItem(item);
+	}
+	// 花材限购(handleLimitBuy 不带 property)只匹配花材行,避免与花束 id 撞车
+	if (isBouquetItem(item)) {
+		return false;
+	}
+	return String(item.id) === productId || String(item.productId || "") === productId;
+}
+
+function getRowReason(row) {
+	if (!row) {
+		return "limit";
+	}
+	if (row.reason === "stock" || row.reason === "limit") {
+		return row.reason;
+	}
+	if (row.stock !== undefined && row.stock !== null && row.stock !== "" && (row.limitBuy === undefined || row.limitBuy === null || row.limitBuy === "")) {
+		return "stock";
+	}
+	return "limit";
+}
+
+/**
+ * 按当前数量回算超出件数。
+ * 库存不足:超出 = 当前数量 - 剩余库存。
+ * 限购:有 boughtCount 时超出 = 当前数量 - max(0, 限购 - 已购);否则用接口带回的 exceed,并按 num 与当前数量的差值扣减。
+ */
+export function calcExceedNum(item, row) {
+	if (!row) {
+		return 0;
+	}
+	const qty = getItemQty(item);
+	if (getRowReason(row) === "stock") {
+		const remain = Math.max(0, Number(row.stock) || 0);
+		return Math.max(0, parseFloat((qty - remain).toFixed(2)));
+	}
+	const limitBuy = Number(row.limitBuy) || 0;
+	const hasBought = row.boughtCount !== undefined && row.boughtCount !== null && row.boughtCount !== "";
+	if (hasBought && isFinite(Number(row.boughtCount))) {
+		const remain = Math.max(0, limitBuy - Number(row.boughtCount));
+		return Math.max(0, parseFloat((qty - remain).toFixed(2)));
+	}
+	const originExceed = Number(row.exceed) || 0;
+	if (row.num !== undefined && row.num !== null && row.num !== "") {
+		const delta = (Number(row.num) || 0) - qty;
+		return Math.max(0, parseFloat((originExceed - delta).toFixed(2)));
+	}
+	return Math.max(0, originExceed);
+}
+
+/**
+ * 找到当前行对应的超限信息;已减到不超限时返回 null。
+ */
+export function findLimitExceedRow(item, rows) {
+	if (!item || !Array.isArray(rows) || !rows.length) {
+		return null;
+	}
+	const row = rows.find((r) => isLimitExceedMatch(item, r));
+	if (!row) {
+		return null;
+	}
+	const exceed = calcExceedNum(item, row);
+	if (!(exceed > 0)) {
+		return null;
+	}
+	return {
+		...row,
+		reason: getRowReason(row),
+		exceed,
+		limitBuy: Number(row.limitBuy) || 0,
+		stock: Number(row.stock) || 0
+	};
+}
+
+export function buildLimitExceedTip(row) {
+	if (!row) {
+		return "";
+	}
+	if (getRowReason(row) === "stock") {
+		return "库存 " + formatLimitNum(row.stock) + ",超出 " + formatLimitNum(row.exceed);
+	}
+	return "限购 " + formatLimitNum(row.limitBuy) + ",超出 " + formatLimitNum(row.exceed);
+}
+
+export function saveMixLimitExceed(rows) {
+	if (!rows || !rows.length) {
+		uni.removeStorageSync(MIX_LIMIT_EXCEED_KEY);
+		return;
+	}
+	uni.setStorageSync(MIX_LIMIT_EXCEED_KEY, rows);
+}
+
+export function loadMixLimitExceed() {
+	try {
+		const raw = uni.getStorageSync(MIX_LIMIT_EXCEED_KEY);
+		return parseLimitExceedData(raw);
+	} catch (e) {
+		return [];
+	}
+}
+
+export function clearMixLimitExceed() {
+	uni.removeStorageSync(MIX_LIMIT_EXCEED_KEY);
+}