Bladeren bron

fix: 补货/盘点页搜索 + 扫码返回修复 + 支付分预授权过期兜底

前端 (haha-admin-mp):
- scan.vue: navigateTo 改为 redirectTo,关门后不再回到扫码页
- operation.vue & audit.vue: 新增搜索框,按商品名称/编码模糊搜索

后端 (haha-service):
- PayScoreServiceImpl: Redis 预授权 TTL 从 10 分钟延长至 30 分钟
- HahaCallbackServiceImpl: getOrCreateOrder / fetchRecognitionAndCreateOrder
  / processPayScorePayment 三处添加 tryRecoverPayScoreOrderId 兜底恢复,
  Redis 主 key 过期后从 24h 追踪 SET 恢复 payScoreOrderId,防止支付分扣费被跳过

Co-Authored-By: Claude <noreply@anthropic.com>
skyline 3 dagen geleden
bovenliggende
commit
ed85f524ea

+ 39 - 8
haha-admin-mp/src/pages/replenish/audit.vue

@@ -12,7 +12,13 @@
       <view class="inventory-section">
         <view class="section-head">
           <text class="section-title">盘点清单</text>
-          <text class="section-count">{{ inventoryList.length }} 种</text>
+          <text class="section-count">{{ searchKeyword ? filteredInventoryList.length + ' / ' + inventoryList.length : inventoryList.length }} 种</text>
+        </view>
+
+        <view class="search-bar">
+          <view class="search-icon"></view>
+          <input class="search-input" v-model="searchKeyword" placeholder="搜索商品名称或编码" confirm-type="search" />
+          <view class="search-clear" v-if="searchKeyword" @click="searchKeyword = ''"><text>✕</text></view>
         </view>
 
         <view class="empty-state" v-if="inventoryList.length === 0">
@@ -20,14 +26,18 @@
           <text class="empty-text">暂无商品库存数据</text>
         </view>
 
-        <view class="inventory-card" v-for="(item, index) in inventoryList" :key="index">
+        <view class="empty-state" v-else-if="inventoryList.length > 0 && filteredInventoryList.length === 0">
+          <text class="empty-text">未找到匹配商品</text>
+        </view>
+
+        <view class="inventory-card" v-for="({ item, idx }, i) in filteredInventoryList" :key="idx">
           <view class="item-row">
             <image v-if="item.product_image" :src="item.product_image" class="item-img" mode="aspectFill" />
             <view v-else class="item-img-placeholder"><text>无图</text></view>
             <view class="item-info">
               <view class="item-name-row">
                 <text class="item-name">{{ item.product_name || item.productName || '未知商品' }}</text>
-                <view class="stock-badge" :class="getDiffClass(item, index)">{{ getDiffLabel(item, index) }}</view>
+                <view class="stock-badge" :class="getDiffClass(item, idx)">{{ getDiffLabel(item, idx) }}</view>
               </view>
               <view class="item-meta">
                 <text class="item-code" v-if="item.product_code || item.productCode">{{ item.product_code || item.productCode }}</text>
@@ -38,12 +48,12 @@
           </view>
           <view class="audit-row">
             <view class="qty-control">
-              <view class="qty-btn" @click="decrease(index)"><text>-</text></view>
-              <input class="qty-input" type="number" v-model.number="auditItems[index].newStock" />
-              <view class="qty-btn" @click="increase(index)"><text>+</text></view>
+              <view class="qty-btn" @click="decrease(idx)"><text>-</text></view>
+              <input class="qty-input" type="number" v-model.number="auditItems[idx].newStock" />
+              <view class="qty-btn" @click="increase(idx)"><text>+</text></view>
             </view>
-            <view class="quick-btn" @click="setToStandard(item, index)"><text>设标</text></view>
-            <view class="quick-btn quick-reset" @click="setToCurrentStock(item, index)"><text>重置</text></view>
+            <view class="quick-btn" @click="setToStandard(item, idx)"><text>设标</text></view>
+            <view class="quick-btn quick-reset" @click="setToCurrentStock(item, idx)"><text>重置</text></view>
           </view>
         </view>
       </view>
@@ -79,6 +89,19 @@ const deviceName = ref('');
 const inventoryList = ref<any[]>([]);
 const auditItems = ref<AuditInput[]>([]);
 const submitting = ref(false);
+const searchKeyword = ref('');
+
+const filteredInventoryList = computed(() => {
+  const kw = searchKeyword.value.trim().toLowerCase();
+  return inventoryList.value
+    .map((item, idx) => ({ item, idx }))
+    .filter(({ item }) => {
+      if (!kw) return true;
+      const name = (item.product_name || item.productName || '').toLowerCase();
+      const code = (item.product_code || item.productCode || '').toLowerCase();
+      return name.includes(kw) || code.includes(kw);
+    });
+});
 
 const changedCount = computed(() =>
   auditItems.value.filter(i => i.productId != null && i.newStock !== i.originalStock).length
@@ -188,6 +211,14 @@ onMounted(async () => {
 .section-head { display: flex; align-items: center; justify-content: space-between; padding: 16rpx 0 12rpx; }
 .section-title { font-size: $font-size-md; font-weight: 700; color: $text-color-primary; }
 .section-count { font-size: $font-size-sm; color: $text-color-muted; }
+.search-bar { display: flex; align-items: center; background: $bg-color-card; border: 1rpx solid $border-color; border-radius: $radius-base; padding: 0 16rpx; height: 60rpx; margin-bottom: 12rpx; }
+.search-icon { width: 28rpx; height: 28rpx; border: 2.5rpx solid $text-color-muted; border-radius: 50%; position: relative; flex-shrink: 0; margin-right: 10rpx;
+  &::after { content: ''; position: absolute; bottom: -2rpx; right: -4rpx; width: 2.5rpx; height: 10rpx; background: $text-color-muted; border-radius: 1rpx; transform: rotate(-45deg); }
+}
+.search-input { flex: 1; font-size: $font-size-sm; color: $text-color-primary; height: 100%; }
+.search-clear { width: 36rpx; height: 36rpx; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: $bg-color-secondary; flex-shrink: 0; margin-left: 8rpx;
+  text { font-size: 20rpx; color: $text-color-muted; line-height: 1; }
+}
 
 .empty-state { display: flex; flex-direction: column; align-items: center; padding: 80rpx 0; }
 .empty-icon-box { width: 96rpx; height: 96rpx; background: $bg-color-secondary; border-radius: $radius-lg; display: flex; align-items: center; justify-content: center; margin-bottom: $spacing-2; }

+ 36 - 5
haha-admin-mp/src/pages/replenish/operation.vue

@@ -28,12 +28,22 @@
           </view>
         </view>
 
+        <view class="search-bar">
+          <view class="search-icon"></view>
+          <input class="search-input" v-model="searchKeyword" placeholder="搜索商品名称或编码" confirm-type="search" />
+          <view class="search-clear" v-if="searchKeyword" @click="searchKeyword = ''"><text>✕</text></view>
+        </view>
+
         <view class="empty-state" v-if="!loading && inventoryList.length === 0">
           <view class="empty-icon-box"><view class="empty-doc-icon"></view></view>
           <text class="empty-text">暂无商品库存数据</text>
         </view>
 
-        <view class="inventory-card" v-for="(item, index) in inventoryList" :key="index" v-show="!showOrderOnly || isInOrder(item)">
+        <view class="empty-state" v-else-if="!loading && inventoryList.length > 0 && filteredInventoryList.length === 0">
+          <text class="empty-text">未找到匹配商品</text>
+        </view>
+
+        <view class="inventory-card" v-for="({ item, idx }, i) in filteredInventoryList" :key="idx" v-show="!showOrderOnly || isInOrder(item)">
           <view class="item-row">
             <image v-if="item.product_image" :src="item.product_image" class="item-img" mode="aspectFill" />
             <view v-else class="item-img-placeholder"><text>无图</text></view>
@@ -51,11 +61,11 @@
           </view>
           <view class="replenish-row">
             <view class="qty-control">
-              <view class="qty-btn" @click="decrease(item, index)"><text>-</text></view>
-              <input class="qty-input" type="number" v-model="replenishItems[index].quantity" @blur="validateQuantity(index)" />
-              <view class="qty-btn" @click="increase(item, index)"><text>+</text></view>
+              <view class="qty-btn" @click="decrease(item, idx)"><text>-</text></view>
+              <input class="qty-input" type="number" v-model="replenishItems[idx].quantity" @blur="validateQuantity(idx)" />
+              <view class="qty-btn" @click="increase(item, idx)"><text>+</text></view>
             </view>
-            <view class="fill-btn" @click="suggestQuantity(item, index)"><text>补满</text></view>
+            <view class="fill-btn" @click="suggestQuantity(item, idx)"><text>补满</text></view>
           </view>
         </view>
       </view>
@@ -107,6 +117,19 @@ const orderId = ref('');
 const orderNo = ref('');
 const showOrderOnly = ref(false);
 const orderItemCodes = ref<Set<string>>(new Set());
+const searchKeyword = ref('');
+
+const filteredInventoryList = computed(() => {
+  const kw = searchKeyword.value.trim().toLowerCase();
+  return inventoryList.value
+    .map((item, idx) => ({ item, idx }))
+    .filter(({ item }) => {
+      if (!kw) return true;
+      const name = (item.product_name || item.productName || '').toLowerCase();
+      const code = (item.product_code || item.productCode || '').toLowerCase();
+      return name.includes(kw) || code.includes(kw);
+    });
+});
 const pendingOrders = ref<any[]>([]);
 const selectedOrderIndex = ref(0);
 const doorClosedAlertShown = ref(false);
@@ -348,6 +371,14 @@ onUnmounted(() => {
 .inventory-section { padding: 0 24rpx; flex: 1; height: 0; overflow-y: auto; }
 .section-head { display: flex; align-items: center; justify-content: space-between; padding: 16rpx 0 12rpx; }
 .section-title { font-size: $font-size-md; font-weight: 700; color: $text-color-primary; }
+.search-bar { display: flex; align-items: center; background: $bg-color-card; border: 1rpx solid $border-color; border-radius: $radius-base; padding: 0 16rpx; height: 60rpx; margin-bottom: 12rpx; }
+.search-icon { width: 28rpx; height: 28rpx; border: 2.5rpx solid $text-color-muted; border-radius: 50%; position: relative; flex-shrink: 0; margin-right: 10rpx;
+  &::after { content: ''; position: absolute; bottom: -2rpx; right: -4rpx; width: 2.5rpx; height: 10rpx; background: $text-color-muted; border-radius: 1rpx; transform: rotate(-45deg); }
+}
+.search-input { flex: 1; font-size: $font-size-sm; color: $text-color-primary; height: 100%; }
+.search-clear { width: 36rpx; height: 36rpx; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: $bg-color-secondary; flex-shrink: 0; margin-left: 8rpx;
+  text { font-size: 20rpx; color: $text-color-muted; line-height: 1; }
+}
 .order-toggle { display: flex; align-items: center; gap: 8rpx; }
 .toggle-label { font-size: 20rpx; color: $text-color-muted; }
 .toggle-switch { width: 56rpx; height: 32rpx; border-radius: 16rpx; background: $border-color; position: relative; transition: background 0.2s;

+ 2 - 2
haha-admin-mp/src/pages/replenish/scan.vue

@@ -83,9 +83,9 @@ function onScanCode(e: any) {
       itemList: ['设备补货', '库存盘点'],
       success: (sheetRes: any) => {
         if (sheetRes.tapIndex === 0) {
-          uni.navigateTo({ url: `/pages/replenish/operation?deviceId=${deviceId}` });
+          uni.redirectTo({ url: `/pages/replenish/operation?deviceId=${deviceId}` });
         } else if (sheetRes.tapIndex === 1) {
-          uni.navigateTo({ url: `/pages/replenish/audit?deviceId=${deviceId}` });
+          uni.redirectTo({ url: `/pages/replenish/audit?deviceId=${deviceId}` });
         }
       },
       fail: () => { scanned = false; }

+ 54 - 4
haha-service/src/main/java/com/haha/service/impl/HahaCallbackServiceImpl.java

@@ -499,6 +499,18 @@ public class HahaCallbackServiceImpl implements HahaCallbackService {
                 payScoreOrderId = preAuth.getString("payScoreOrderId");
                 payScoreState = preAuth.getString("payScoreState");
                 serviceId = preAuth.getString("serviceId");
+            } else {
+                // Redis 预授权已过期,尝试从追踪SET恢复
+                payScoreOrderId = ((com.haha.service.payment.payscore.impl.PayScoreServiceImpl) payScoreService)
+                        .tryRecoverPayScoreOrderId(record.getUserId(), record.getDeviceId());
+                if (payScoreOrderId != null) {
+                    payScoreState = PayScoreState.CREATED.getCode();
+                    log.info("从追踪SET恢复支付分订单ID成功 - userId: {}, deviceId: {}, payScoreOrderId: {}",
+                            record.getUserId(), record.getDeviceId(), payScoreOrderId);
+                } else {
+                    log.warn("Redis预授权已过期且追踪SET未找到 - userId: {}, deviceId: {}",
+                            record.getUserId(), record.getDeviceId());
+                }
             }
         }
 
@@ -1157,12 +1169,24 @@ public class HahaCallbackServiceImpl implements HahaCallbackService {
                 String payScoreState = null;
                 String serviceId = null;
                 if (payScoreService != null && deviceId != null && userId != null) {
+                    Long uid = Long.parseLong(userId);
                     com.alibaba.fastjson2.JSONObject preAuth = ((com.haha.service.payment.payscore.impl.PayScoreServiceImpl) payScoreService)
-                            .readPreAuthFromRedis(deviceId, Long.parseLong(userId));
+                            .readPreAuthFromRedis(deviceId, uid);
                     if (preAuth != null) {
                         payScoreOrderId = preAuth.getString("payScoreOrderId");
                         payScoreState = preAuth.getString("payScoreState");
                         serviceId = preAuth.getString("serviceId");
+                    } else {
+                        // Redis 预授权已过期,尝试从追踪SET恢复
+                        payScoreOrderId = ((com.haha.service.payment.payscore.impl.PayScoreServiceImpl) payScoreService)
+                                .tryRecoverPayScoreOrderId(uid, deviceId);
+                        if (payScoreOrderId != null) {
+                            payScoreState = PayScoreState.CREATED.getCode();
+                            log.info("从追踪SET恢复支付分订单ID成功 - userId: {}, deviceId: {}, payScoreOrderId: {}",
+                                    userId, deviceId, payScoreOrderId);
+                        } else {
+                            log.warn("Redis预授权已过期且追踪SET未找到 - userId: {}, deviceId: {}", userId, deviceId);
+                        }
                     }
                 }
 
@@ -1605,9 +1629,35 @@ public class HahaCallbackServiceImpl implements HahaCallbackService {
         boolean isPayScore = PaymentChannel.WECHAT_PAYSCORE.getCode().equals(order.getPayChannel())
                 || order.getPayScoreOrderId() != null;
         if (!isPayScore) {
-            log.debug("订单未使用支付分,跳过扣费 - orderId: {}, payChannel: {}",
-                    order.getId(), order.getPayChannel());
-            return;
+            // 最后一层兜底:尝试从 Redis 追踪SET 恢复支付分订单ID
+            if (payScoreService != null && order.getUserId() != null && order.getDeviceId() != null) {
+                String recovered = ((com.haha.service.payment.payscore.impl.PayScoreServiceImpl) payScoreService)
+                        .tryRecoverPayScoreOrderId(order.getUserId(), order.getDeviceId());
+                if (recovered != null) {
+                    order.setPayScoreOrderId(recovered);
+                    order.setPayChannel(PaymentChannel.WECHAT_PAYSCORE.getCode());
+                    order.setPayType("微信支付分");
+                    order.setPayScoreState(PayScoreState.CREATED.getCode());
+                    // 回写到数据库
+                    orderService.lambdaUpdate()
+                            .set(Order::getPayScoreOrderId, recovered)
+                            .set(Order::getPayChannel, PaymentChannel.WECHAT_PAYSCORE.getCode())
+                            .set(Order::getPayType, "微信支付分")
+                            .set(Order::getPayScoreState, PayScoreState.CREATED.getCode())
+                            .eq(Order::getId, order.getId())
+                            .update();
+                    log.info("支付分订单ID已从追踪SET恢复 - orderId: {}, payScoreOrderId: {}",
+                            order.getId(), recovered);
+                } else {
+                    log.debug("订单未使用支付分,跳过扣费 - orderId: {}, payChannel: {}",
+                            order.getId(), order.getPayChannel());
+                    return;
+                }
+            } else {
+                log.debug("订单未使用支付分,跳过扣费 - orderId: {}, payChannel: {}",
+                        order.getId(), order.getPayChannel());
+                return;
+            }
         }
 
         // 补全可能丢失的 payChannel

+ 1 - 1
haha-service/src/main/java/com/haha/service/payment/payscore/impl/PayScoreServiceImpl.java

@@ -60,7 +60,7 @@ public class PayScoreServiceImpl implements PayScoreService {
     private com.haha.service.OrderInventoryService orderInventoryService;
 
     private static final String REDIS_KEY_PREFIX = "payscore:preauth:";
-    private static final long REDIS_TTL_MINUTES = 10;
+    private static final long REDIS_TTL_MINUTES = 30;
     private static final String REDIS_TRACKING_KEY = "payscore:preauth:tracking:";
     private static final long REDIS_TRACKING_TTL_HOURS = 24;