Browse Source

feat: ERP店铺绑定改为设备绑定+一键自动绑定+浮窗详情

- 绑定对象从门店改为设备(t_device.erp_shop_id)
- 新增一键自动绑定:按 shopCode 匹配 deviceId,CASE WHEN 批量UPDATE
- 绑定设备标签悬停浮窗显示设备详情
- Device 实体新增 erpShopId 字段+DB迁移脚本

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
skyline 4 days ago
parent
commit
780c881ff7

+ 6 - 0
docs/database/add_device_erp_shop_id.sql

@@ -0,0 +1,6 @@
+-- t_device 增加 erp_shop_id 字段,用于与 ERP 店铺建立一对一绑定
+-- 创建日期: 2026-07-09
+
+ALTER TABLE t_device ADD COLUMN erp_shop_id BIGINT DEFAULT NULL COMMENT '关联的ERP店铺ID(ERP内部shopId)' AFTER device_id;
+
+CREATE INDEX idx_device_erp_shop_id ON t_device(erp_shop_id);

+ 10 - 6
haha-admin-web/src/api/erpShop.ts

@@ -14,10 +14,14 @@ export const syncErpShops = () =>
 export const getErpShopList = (params: { keyword?: string; page: number; pageSize: number }) =>
   http.request<Result>("get", "/erp-shops", { params });
 
-/** 绑定门店与 ERP 店铺 */
-export const bindErpShop = (shopId: number, erpShopId: number) =>
-  http.request<Result>("post", "/erp-shops/bind", { data: { shopId, erpShopId } });
+/** 绑定设备与 ERP 店铺 */
+export const bindErpShop = (deviceId: number, erpShopId: number) =>
+  http.request<Result>("post", "/erp-shops/bind", { data: { deviceId, erpShopId } });
 
-/** 解除门店与 ERP 店铺的绑定 */
-export const unbindErpShop = (shopId: number) =>
-  http.request<Result>("post", "/erp-shops/unbind", { data: { shopId } });
+/** 解除设备与 ERP 店铺的绑定 */
+export const unbindErpShop = (deviceId: number) =>
+  http.request<Result>("post", "/erp-shops/unbind", { data: { deviceId } });
+
+/** 一键自动绑定:按 shopCode 匹配设备 deviceId */
+export const autoBindErpShop = () =>
+  http.request<Result>("post", "/erp-shops/auto-bind");

+ 79 - 33
haha-admin-web/src/views/erp-shop/index.vue

@@ -9,9 +9,10 @@ import {
   syncErpShops,
   getErpShopList,
   bindErpShop,
-  unbindErpShop
+  unbindErpShop,
+  autoBindErpShop
 } from "@/api/erpShop";
-import { getEnabledShops } from "@/api/shop";
+import { getDeviceList } from "@/api/device";
 
 defineOptions({
   name: "ErpShopList"
@@ -19,14 +20,15 @@ defineOptions({
 
 const loading = ref(false);
 const syncing = ref(false);
+const autoBinding = ref(false);
 const keyword = ref("");
 const dataList = ref<any[]>([]);
 const total = ref(0);
 const pagination = reactive({ currentPage: 1, pageSize: 10 });
-const shopOptions = ref<any[]>([]);
+const deviceOptions = ref<any[]>([]);
 const bindDialogVisible = ref(false);
 const currentErpShop = ref<any>(null);
-const selectedShopId = ref<number | null>(null);
+const selectedDeviceId = ref<number | null>(null);
 const bindLoading = ref(false);
 
 async function fetchList() {
@@ -64,14 +66,13 @@ function handleSearch() {
 }
 
 async function fetchBoundStatus() {
-  // 获取门店列表来展示绑定关系
   try {
-    const res = await getEnabledShops();
+    const res = await getDeviceList({ page: 1, pageSize: 1000 });
     if (res.code === 200) {
-      const shops = res.data || [];
+      const devices = res.data?.list || res.data || [];
       dataList.value.forEach((erp: any) => {
-        const bound = shops.find((s: any) => s.erpShopId === erp.erpShopId);
-        erp._boundShop = bound || null;
+        const bound = devices.find((d: any) => d.erpShopId === erp.erpShopId);
+        erp._boundDevice = bound || null;
       });
     }
   } catch (e) {
@@ -95,26 +96,42 @@ async function handleSync() {
   }
 }
 
+async function handleAutoBind() {
+  autoBinding.value = true;
+  try {
+    const res = await autoBindErpShop();
+    if (res.code === 200) {
+      ElMessage.success(res.message || "自动绑定完成");
+      await fetchList();
+    } else {
+      ElMessage.error(res.message || "自动绑定失败");
+    }
+  } catch (e) {
+    ElMessage.error("自动绑定请求失败");
+  } finally {
+    autoBinding.value = false;
+  }
+}
+
 function openBindDialog(row: any) {
   currentErpShop.value = row;
-  selectedShopId.value = row._boundShop?.id || null;
+  selectedDeviceId.value = row._boundDevice?.id || null;
   bindDialogVisible.value = true;
-  // 加载门店下拉选项
-  getEnabledShops().then(res => {
+  getDeviceList({ page: 1, pageSize: 1000 }).then(res => {
     if (res.code === 200) {
-      shopOptions.value = res.data || [];
+      deviceOptions.value = res.data?.list || res.data || [];
     }
   });
 }
 
 async function handleBind() {
-  if (!selectedShopId.value) {
-    ElMessage.warning("请选择门店");
+  if (!selectedDeviceId.value) {
+    ElMessage.warning("请选择设备");
     return;
   }
   bindLoading.value = true;
   try {
-    const res = await bindErpShop(selectedShopId.value, currentErpShop.value.erpShopId);
+    const res = await bindErpShop(selectedDeviceId.value, currentErpShop.value.erpShopId);
     if (res.code === 200) {
       ElMessage.success("绑定成功");
       bindDialogVisible.value = false;
@@ -128,14 +145,14 @@ async function handleBind() {
 }
 
 async function handleUnbind(row: any) {
-  if (!row._boundShop) return;
+  if (!row._boundDevice) return;
   try {
     await ElMessageBox.confirm(
-      `确认解除门店「${row._boundShop.name}」与 ERP 店铺「${row.shopName}」的绑定?`,
+      `确认解除设备「${row._boundDevice.deviceId || row._boundDevice.name}」与 ERP 店铺「${row.shopName}」的绑定?`,
       "解除绑定",
       { type: "warning" }
     );
-    const res = await unbindErpShop(row._boundShop.id);
+    const res = await unbindErpShop(row._boundDevice.id);
     if (res.code === 200) {
       ElMessage.success("解绑成功");
       await fetchList();
@@ -173,6 +190,13 @@ onMounted(() => {
         >
           从 ERP 同步
         </el-button>
+        <el-button
+          type="success"
+          :loading="autoBinding"
+          @click="handleAutoBind"
+        >
+          一键绑定
+        </el-button>
       </div>
     </div>
 
@@ -188,19 +212,41 @@ onMounted(() => {
         <el-table-column prop="erpShopId" label="ERP店铺ID" width="160" />
         <el-table-column prop="shopName" label="店铺名称" min-width="180" />
         <el-table-column prop="nickName" label="卖家账号" min-width="180" />
-        <el-table-column prop="shopPlatformCode" label="平台编码" width="100" />
-        <el-table-column label="绑定门店" min-width="160">
+        <el-table-column prop="shopCode" label="设备ID" width="180" />
+        <el-table-column label="绑定设备" min-width="160">
           <template #default="{ row }">
-            <el-tag v-if="row._boundShop" type="success" size="small">
-              {{ row._boundShop.name }}
-            </el-tag>
+            <el-popover
+              v-if="row._boundDevice"
+              trigger="hover"
+              placement="right"
+              :width="260"
+            >
+              <template #reference>
+                <el-tag type="success" size="small" style="cursor:default">
+                  {{ row._boundDevice.deviceId }}-{{ row._boundDevice.name || '未命名' }}
+                </el-tag>
+              </template>
+              <div class="text-sm">
+                <div class="font-medium text-base mb-3">{{ row._boundDevice.deviceId || row._boundDevice.name }}</div>
+                <div class="grid grid-cols-[60px_1fr] gap-y-2 gap-x-2 text-xs">
+                  <span class="text-gray-400">设备名称</span>
+                  <span>{{ row._boundDevice.name || '-' }}</span>
+                  <span class="text-gray-400">设备ID</span>
+                  <span>{{ row._boundDevice.deviceId || '-' }}</span>
+                  <span class="text-gray-400">所属门店</span>
+                  <span>{{ row._boundDevice.shopName || row._boundDevice.shopId || '-' }}</span>
+                  <span class="text-gray-400">地址</span>
+                  <span>{{ row._boundDevice.address || '-' }}</span>
+                </div>
+              </div>
+            </el-popover>
             <span v-else class="text-gray-400">未绑定</span>
           </template>
         </el-table-column>
         <el-table-column label="操作" width="160" fixed="right">
           <template #default="{ row }">
             <el-button
-              v-if="!row._boundShop"
+              v-if="!row._boundDevice"
               link
               type="primary"
               :icon="useRenderIcon(Link)"
@@ -240,7 +286,7 @@ onMounted(() => {
     <!-- 绑定对话框 -->
     <el-dialog
       v-model="bindDialogVisible"
-      title="绑定门店"
+      title="绑定设备"
       width="500px"
       :close-on-click-modal="false"
     >
@@ -251,19 +297,19 @@ onMounted(() => {
             disabled
           />
         </el-form-item>
-        <el-form-item label="选择门店">
+        <el-form-item label="选择设备">
           <el-select
-            v-model="selectedShopId"
-            placeholder="请选择要绑定的门店"
+            v-model="selectedDeviceId"
+            placeholder="请选择要绑定的设备"
             clearable
             filterable
             class="w-full!"
           >
             <el-option
-              v-for="s in shopOptions"
-              :key="s.id"
-              :label="s.name"
-              :value="s.id"
+              v-for="d in deviceOptions"
+              :key="d.id"
+              :label="d.deviceId + ' - ' + (d.name || '未命名')"
+              :value="d.id"
             />
           </el-select>
         </el-form-item>

+ 21 - 9
haha-admin/src/main/java/com/haha/admin/controller/ErpShopController.java

@@ -49,18 +49,18 @@ public class ErpShopController {
     }
 
     /**
-     * 绑定系统门店与 ERP 店铺
+     * 绑定设备与 ERP 店铺
      */
     @RequirePermission("erp:shop:bind")
     @Log(module = "ERP店铺管理", operation = OperationType.UPDATE, summary = "绑定ERP店铺")
     @PostMapping("/bind")
     public Result<Void> bind(@RequestBody Map<String, Long> params) {
-        Long shopId = params.get("shopId");
+        Long deviceId = params.get("deviceId");
         Long erpShopId = params.get("erpShopId");
-        if (shopId == null || erpShopId == null) {
-            return Result.error("shopId 和 erpShopId 不能为空");
+        if (deviceId == null || erpShopId == null) {
+            return Result.error("deviceId 和 erpShopId 不能为空");
         }
-        erpShopService.bindShop(shopId, erpShopId);
+        erpShopService.bindDevice(deviceId, erpShopId);
         return Result.success("绑定成功");
     }
 
@@ -71,11 +71,23 @@ public class ErpShopController {
     @Log(module = "ERP店铺管理", operation = OperationType.UPDATE, summary = "解绑ERP店铺")
     @PostMapping("/unbind")
     public Result<Void> unbind(@RequestBody Map<String, Long> params) {
-        Long shopId = params.get("shopId");
-        if (shopId == null) {
-            return Result.error("shopId 不能为空");
+        Long deviceId = params.get("deviceId");
+        if (deviceId == null) {
+            return Result.error("deviceId 不能为空");
         }
-        erpShopService.unbindShop(shopId);
+        erpShopService.unbindDevice(deviceId);
         return Result.success("解绑成功");
     }
+
+    /**
+     * 一键自动绑定:按 shopCode 匹配设备 deviceId
+     */
+    @RequirePermission("erp:shop:bind")
+    @Log(module = "ERP店铺管理", operation = OperationType.UPDATE, summary = "一键自动绑定ERP店铺")
+    @PostMapping("/auto-bind")
+    public Result<Map<String, Integer>> autoBind() {
+        Map<String, Integer> result = erpShopService.autoBind();
+        return Result.success(String.format("绑定 %d 台,跳过 %d 台",
+                result.get("bound"), result.get("skipped")), result);
+    }
 }

+ 6 - 0
haha-entity/src/main/java/com/haha/entity/Device.java

@@ -22,6 +22,12 @@ public class Device implements Serializable {
 
     private String deviceId;
 
+    /**
+     * 关联的ERP店铺ID(ERP内部shopId)
+     */
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long erpShopId;
+
     @JsonSerialize(using = ToStringSerializer.class)
     private Long shopId;
 

+ 4 - 16
haha-service/src/main/java/com/haha/service/ErpShopService.java

@@ -11,23 +11,11 @@ public interface ErpShopService extends IService<ErpShop> {
 
     void syncFromErp();
 
-    /**
-     * 分页查询已缓存的 ERP 店铺
-     */
     IPage<ErpShop> listAll(String keyword, int page, int pageSize);
 
-    /**
-     * 将系统门店与 ERP 店铺一对一绑定
-     *
-     * @param shopId    系统门店ID
-     * @param erpShopId ERP店铺主键ID(ERP内部shopId)
-     */
-    void bindShop(Long shopId, Long erpShopId);
+    void bindDevice(Long deviceId, Long erpShopId);
 
-    /**
-     * 解除系统门店与 ERP 店铺的绑定
-     *
-     * @param shopId 系统门店ID
-     */
-    void unbindShop(Long shopId);
+    void unbindDevice(Long deviceId);
+
+    java.util.Map<String, Integer> autoBind();
 }

+ 127 - 33
haha-service/src/main/java/com/haha/service/impl/ErpShopServiceImpl.java

@@ -6,10 +6,10 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.haha.common.exception.BusinessException;
+import com.haha.entity.Device;
 import com.haha.entity.ErpShop;
-import com.haha.entity.Shop;
+import com.haha.mapper.DeviceMapper;
 import com.haha.mapper.ErpShopMapper;
-import com.haha.mapper.ShopMapper;
 import com.haha.service.ErpShopService;
 import com.qdb.sdk.QdbClient;
 import com.qdb.sdk.QdbException;
@@ -20,27 +20,34 @@ import com.qdb.sdk.model.response.ShopPageResult;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.StringUtils;
 
 import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
 
-/**
- * ERP 店铺信息服务实现
- */
 @Slf4j
 @Service
 @RequiredArgsConstructor
 public class ErpShopServiceImpl extends ServiceImpl<ErpShopMapper, ErpShop> implements ErpShopService {
 
-    private final ShopMapper shopMapper;
+    private final DeviceMapper deviceMapper;
 
     @Autowired(required = false)
     private QdbClient qdbClient;
 
+    @Autowired
+    private JdbcTemplate jdbcTemplate;
+
     @Async("taskExecutor")
     @Override
     @Transactional(rollbackFor = Exception.class)
@@ -113,53 +120,140 @@ public class ErpShopServiceImpl extends ServiceImpl<ErpShopMapper, ErpShop> impl
 
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public void bindShop(Long shopId, Long erpShopId) {
-        Shop shop = shopMapper.selectById(shopId);
-        if (shop == null) {
-            throw new BusinessException(404, "门店不存在");
+    public void bindDevice(Long deviceId, Long erpShopId) {
+        Device device = deviceMapper.selectById(deviceId);
+        if (device == null) {
+            throw new BusinessException(404, "设备不存在");
         }
 
-        // 检查 ERP 店铺是否存在
         ErpShop erpShop = getOne(new LambdaQueryWrapper<ErpShop>()
                 .eq(ErpShop::getErpShopId, erpShopId));
         if (erpShop == null) {
             throw new BusinessException(404, "ERP店铺不存在,请先同步ERP店铺数据");
         }
 
-        // 检查是否已被其他门店绑定
-        List<Shop> boundShops = shopMapper.selectList(new LambdaQueryWrapper<Shop>()
-                .eq(Shop::getErpShopId, erpShopId));
-        if (!boundShops.isEmpty() && !boundShops.get(0).getId().equals(shopId)) {
-            throw new BusinessException(400, "该ERP店铺已绑定到门店: " + boundShops.get(0).getName());
+        List<Device> boundDevices = deviceMapper.selectList(new LambdaQueryWrapper<Device>()
+                .eq(Device::getErpShopId, erpShopId));
+        if (!boundDevices.isEmpty() && !boundDevices.get(0).getId().equals(deviceId)) {
+            throw new BusinessException(400, "该ERP店铺已绑定到设备: " + boundDevices.get(0).getDeviceId());
+        }
+
+        device.setErpShopId(erpShopId);
+        device.setUpdateTime(LocalDateTime.now());
+        deviceMapper.updateById(device);
+
+        log.info("设备绑定ERP店铺: deviceId={}, erpShopId={}, erpShopName={}",
+                device.getDeviceId(), erpShopId, erpShop.getShopName());
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void unbindDevice(Long deviceId) {
+        Device device = deviceMapper.selectById(deviceId);
+        if (device == null) {
+            throw new BusinessException(404, "设备不存在");
         }
 
-        shop.setErpShopId(erpShopId);
-        shop.setUpdateTime(LocalDateTime.now());
-        shopMapper.updateById(shop);
+        deviceMapper.update(null, new LambdaUpdateWrapper<Device>()
+                .set(Device::getErpShopId, null)
+                .set(Device::getUpdateTime, LocalDateTime.now())
+                .eq(Device::getId, deviceId));
 
-        log.info("门店绑定ERP店铺: shopId={}, shopName={}, erpShopId={}, erpShopName={}",
-                shopId, shop.getName(), erpShopId, erpShop.getShopName());
+        log.info("设备解绑ERP店铺: deviceId={}", device.getDeviceId());
     }
 
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public void unbindShop(Long shopId) {
-        Shop shop = shopMapper.selectById(shopId);
-        if (shop == null) {
-            throw new BusinessException(404, "门店不存在");
+    public Map<String, Integer> autoBind() {
+        List<ErpShop> erpShopList = list(new LambdaQueryWrapper<ErpShop>()
+                .eq(ErpShop::getStatus, 1));
+        if (erpShopList.isEmpty()) {
+            Map<String, Integer> empty = new HashMap<>();
+            empty.put("bound", 0);
+            empty.put("skipped", 0);
+            return empty;
+        }
+
+        // 1. 收集所有 shopCode → erpShopId 映射 (shopCode 即设备ID)
+        Map<String, Long> shopCodeToErpId = new LinkedHashMap<>();
+        for (ErpShop erp : erpShopList) {
+            if (StringUtils.hasText(erp.getShopCode())) {
+                shopCodeToErpId.put(erp.getShopCode(), erp.getErpShopId());
+            }
         }
 
-        shopMapper.update(null, new LambdaUpdateWrapper<Shop>()
-                .set(Shop::getErpShopId, null)
-                .set(Shop::getUpdateTime, LocalDateTime.now())
-                .eq(Shop::getId, shopId));
+        // 2. 批量查已绑定的设备
+        List<Long> allErpShopIds = erpShopList.stream().map(ErpShop::getErpShopId).toList();
+        Set<Long> boundErpIds = new HashSet<>(deviceMapper.selectList(new LambdaQueryWrapper<Device>()
+                .in(Device::getErpShopId, allErpShopIds)
+                .isNotNull(Device::getErpShopId))
+                .stream().map(Device::getErpShopId).toList());
+
+        // 3. 过滤掉已绑定的,剩下的按 deviceId 批量查设备
+        List<String> targetDeviceIds = new ArrayList<>();
+        for (Map.Entry<String, Long> e : shopCodeToErpId.entrySet()) {
+            if (!boundErpIds.contains(e.getValue())) {
+                targetDeviceIds.add(e.getKey());
+            }
+        }
+        int alreadyBoundSkip = shopCodeToErpId.size() - targetDeviceIds.size();
+
+        Map<String, Long> deviceIdToDbId = new LinkedHashMap<>();
+        for (int i = 0; i < targetDeviceIds.size(); i += 100) {
+            int end = Math.min(i + 100, targetDeviceIds.size());
+            List<String> batch = targetDeviceIds.subList(i, end);
+            List<Device> devices = deviceMapper.selectList(new LambdaQueryWrapper<Device>()
+                    .in(Device::getDeviceId, batch));
+            for (Device d : devices) {
+                deviceIdToDbId.put(d.getDeviceId(), d.getId());
+            }
+        }
+
+        // 4. 唯一匹配的收集起来准备批量 UPDATE
+        List<Long> bindDbIds = new ArrayList<>();
+        List<Long> bindErpIds = new ArrayList<>();
+        int skipped = alreadyBoundSkip;
+
+        for (String deviceId : targetDeviceIds) {
+            Long dbId = deviceIdToDbId.get(deviceId);
+            if (dbId != null && dbId > 0) {
+                bindDbIds.add(dbId);
+                bindErpIds.add(shopCodeToErpId.get(deviceId));
+            } else {
+                skipped++;
+            }
+        }
+
+        // 5. 批量 UPDATE(CASE WHEN,100条一批)
+        int bound = 0;
+        for (int i = 0; i < bindDbIds.size(); i += 100) {
+            int end = Math.min(i + 100, bindDbIds.size());
+            List<Long> batchIds = bindDbIds.subList(i, end);
+            List<Long> batchErpIds = bindErpIds.subList(i, end);
+
+            StringBuilder sql = new StringBuilder(
+                    "UPDATE t_device SET erp_shop_id = CASE id ");
+            for (int j = 0; j < batchIds.size(); j++) {
+                sql.append("WHEN ").append(batchIds.get(j))
+                   .append(" THEN ").append(batchErpIds.get(j)).append(" ");
+            }
+            sql.append("END, update_time = NOW() WHERE id IN (");
+            for (int j = 0; j < batchIds.size(); j++) {
+                if (j > 0) sql.append(",");
+                sql.append(batchIds.get(j));
+            }
+            sql.append(")");
+
+            bound += jdbcTemplate.update(sql.toString());
+        }
 
-        log.info("门店解绑ERP店铺: shopId={}, shopName={}", shopId, shop.getName());
+        log.info("一键自动绑定完成: 成功 {} 条, 跳过 {} 条", bound, skipped);
+        Map<String, Integer> result = new HashMap<>();
+        result.put("bound", bound);
+        result.put("skipped", skipped);
+        return result;
     }
 
-    /**
-     * 插入或更新 ERP 店铺记录(按 erp_shop_id 判断)
-     */
     private void upsertErpShop(ShopInfo info) {
         ErpShop existing = getOne(new LambdaQueryWrapper<ErpShop>()
                 .eq(ErpShop::getErpShopId, info.getShopId()));