Sfoglia il codice sorgente

feat: 同步异步化+删除商品清理+ERP列表优化+解除关联修复

- 设备/商品同步改为 @Async 异步后台执行,接口立即返回
- 商品同步后清理哈哈中已删除的本地商品(标记 is_deleted=1)
- 解除关联修复:LambdaUpdateWrapper.set 强制写入 null
- ERP商品列表优化:行高 64px、列宽调整、悬停浮窗展示本地商品详情
- 同步页面图标改用 unplugin-icons 直接导入,修复只显示色块问题
- 新增 ERP 权限数据 SQL 脚本

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
skyline 5 giorni fa
parent
commit
87384bb664

+ 11 - 0
docs/database/add_erp_permissions.sql

@@ -0,0 +1,11 @@
+-- 新增 ERP 功能权限数据
+-- 创建日期: 2026-07-09
+
+INSERT INTO t_data_permission (id, permission_code, permission_name, module_code, module_name, operation_type, description, sort_order, status, create_time, update_time)
+VALUES
+(90060, 'erp:goods:sync', 'ERP商品同步', 'erp-goods', 'ERP商品管理', 'execute', '从企得宝ERP同步系统商品数据到本地缓存', 1, 1, NOW(), NOW()),
+(90061, 'erp:goods:read', 'ERP商品查看', 'erp-goods', 'ERP商品管理', 'read', '查看已缓存的ERP商品列表及关联状态', 2, 1, NOW(), NOW()),
+(90062, 'erp:goods:bind', 'ERP商品关联', 'erp-goods', 'ERP商品管理', 'update', '将本地商品与ERP商品绑定或解除关联', 3, 1, NOW(), NOW()),
+(90063, 'erp:shop:sync',  'ERP店铺同步', 'erp-shop',  'ERP店铺管理', 'execute', '从企得宝ERP同步店铺数据到本地缓存', 1, 1, NOW(), NOW()),
+(90064, 'erp:shop:read',  'ERP店铺查看', 'erp-shop',  'ERP店铺管理', 'read',   '查看已缓存的ERP店铺列表及绑定状态', 2, 1, NOW(), NOW()),
+(90065, 'erp:shop:bind',  'ERP店铺关联', 'erp-shop',  'ERP店铺管理', 'update', '将本地门店与ERP店铺绑定或解除绑定', 3, 1, NOW(), NOW());

+ 5 - 1
haha-admin-web/src/components/ReIcon/src/offlineIcon.ts

@@ -40,6 +40,8 @@ import RiInformationLine from "~icons/ri/information-line?raw";
 import RiTerminalWindowLine from "~icons/ri/terminal-window-line?raw";
 import RiCheckboxCircleLine from "~icons/ri/checkbox-circle-line?raw";
 import RiBarChartHorizontalLine from "~icons/ri/bar-chart-horizontal-line?raw";
+import RiComputerLine from "~icons/ri/computer-line?raw";
+import RiShoppingBag3Line from "~icons/ri/shopping-bag-3-line?raw";
 
 const icons = [
   // Element Plus Icon: https://github.com/element-plus/element-plus-icons
@@ -78,7 +80,9 @@ const icons = [
   ["ri/information-line", RiInformationLine],
   ["ri/terminal-window-line", RiTerminalWindowLine],
   ["ri/checkbox-circle-line", RiCheckboxCircleLine],
-  ["ri/bar-chart-horizontal-line", RiBarChartHorizontalLine]
+  ["ri/bar-chart-horizontal-line", RiBarChartHorizontalLine],
+  ["ri/computer-line", RiComputerLine],
+  ["ri/shopping-bag-3-line", RiShoppingBag3Line]
 ];
 
 // 本地菜单图标,后端在路由的 icon 中返回对应的图标字符串并且前端在此处使用 addIcon 添加即可渲染菜单图标

+ 48 - 8
haha-admin-web/src/views/erp-goods/index.vue

@@ -194,10 +194,11 @@ onMounted(() => {
         stripe
         style="width: 100%"
         empty-text="暂无数据,请先同步ERP商品"
+        :row-style="{ height: '64px' }"
       >
         <el-table-column prop="erpGoodsId" label="ERP商品ID" width="150" />
-        <el-table-column prop="goodsCode" label="商品编码" width="140" />
-        <el-table-column prop="goodsName" label="商品名称" min-width="180" show-overflow-tooltip />
+        <el-table-column prop="goodsCode" label="商品编码" width="280" />
+        <el-table-column prop="goodsName" label="商品名称" width="140" show-overflow-tooltip />
         <el-table-column label="图片" width="80">
           <template #default="{ row }">
             <el-image
@@ -208,18 +209,57 @@ onMounted(() => {
               preview-teleported
               style="width:48px;height:48px;border-radius:4px"
             />
-            <span v-else class="text-gray-400 text-xs">无</span>
+            <div
+              v-else
+              style="width:48px;height:48px;border-radius:4px;background:#f3f4f6;display:flex;align-items:center;justify-content:center"
+            >
+              <span class="text-gray-400 text-xs">无</span>
+            </div>
           </template>
         </el-table-column>
-        <el-table-column prop="specs" label="规格" width="120" show-overflow-tooltip />
+        <el-table-column prop="specs" label="规格名称" width="320" show-overflow-tooltip />
         <el-table-column prop="skUnit" label="单位" width="80" />
-        <el-table-column prop="brand" label="品牌" width="120" />
         <el-table-column prop="salePrice" label="售价" width="100" />
         <el-table-column label="关联本地商品" min-width="160">
           <template #default="{ row }">
-            <el-tag v-if="row._boundProduct" type="success" size="small">
-              {{ row._boundProduct.name }}
-            </el-tag>
+            <el-popover
+              v-if="row._boundProduct"
+              trigger="hover"
+              placement="right"
+              :width="260"
+            >
+              <template #reference>
+                <el-tag type="success" size="small" style="cursor:default">
+                  {{ row._boundProduct.name }}
+                </el-tag>
+              </template>
+              <div class="text-sm">
+                <div class="font-medium text-base mb-3">{{ row._boundProduct.name }}</div>
+                <div class="grid grid-cols-[70px_1fr] gap-y-2 gap-x-2 text-xs">
+                  <span class="text-gray-400">商品编码</span>
+                  <span>{{ row._boundProduct.code || '-' }}</span>
+                  <span class="text-gray-400">条码</span>
+                  <span>{{ row._boundProduct.barcode || '-' }}</span>
+                  <span class="text-gray-400">零售价</span>
+                  <span>¥{{ row._boundProduct.retailPrice || '-' }}</span>
+                  <span class="text-gray-400">成本价</span>
+                  <span>¥{{ row._boundProduct.costPrice || '-' }}</span>
+                  <span class="text-gray-400">品类</span>
+                  <span>{{ row._boundProduct.type || '-' }}</span>
+                  <span class="text-gray-400">状态</span>
+                  <span>{{ row._boundProduct.applyStatus || '-' }}</span>
+                </div>
+                <div v-if="row._boundProduct.pic" class="mt-3 bg-gray-100 rounded-md overflow-hidden">
+                  <el-image
+                    :src="row._boundProduct.pic"
+                    fit="contain"
+                    :preview-src-list="[row._boundProduct.pic]"
+                    preview-teleported
+                    style="width:100%;height:180px"
+                  />
+                </div>
+              </div>
+            </el-popover>
             <span v-else class="text-gray-400">未关联</span>
           </template>
         </el-table-column>

+ 10 - 3
haha-admin-web/src/views/sync/index.vue

@@ -7,6 +7,13 @@ import dayjs from "dayjs";
 
 import Refresh from "~icons/ep/refresh";
 import View from "~icons/ep/view";
+import RiComputerLine from "~icons/ri/computer-line";
+import RiShoppingBag3Line from "~icons/ri/shopping-bag-3-line";
+
+const moduleIcons: Record<string, any> = {
+  device: RiComputerLine,
+  product: RiShoppingBag3Line
+};
 
 defineOptions({
   name: "DataSync"
@@ -76,7 +83,7 @@ function getStatusText(status?: number): string {
         >
           <div class="card-top">
             <div class="card-icon" :style="{ backgroundColor: mod.bgColor, color: mod.color }">
-              <IconifyIconOffline :icon="mod.icon" width="26" />
+              <component :is="moduleIcons[mod.key]" width="26" />
             </div>
             <div class="card-meta">
               <div class="card-title">{{ mod.title }}</div>
@@ -98,7 +105,7 @@ function getStatusText(status?: number): string {
               @click="handleSync(mod)"
             >
               <el-icon v-if="!(syncing && currentSyncKey === mod.key)" style="margin-right: 4px">
-                <IconifyIconOffline icon="ri:refresh-line" width="14" />
+                <component :is="Refresh" />
               </el-icon>
               {{ syncing && currentSyncKey === mod.key ? '同步中' : '立即同步' }}
             </el-button>
@@ -121,7 +128,7 @@ function getStatusText(status?: number): string {
         >
           <div class="card-top">
             <div class="card-icon" :style="{ backgroundColor: mod.bgColor, color: mod.color }">
-              <IconifyIconOffline :icon="mod.icon" width="26" />
+              <component :is="moduleIcons[mod.key]" width="26" />
             </div>
             <div class="card-meta">
               <div class="card-title">{{ mod.title }}</div>

+ 20 - 54
haha-admin/src/main/java/com/haha/admin/controller/DataSyncController.java

@@ -3,10 +3,8 @@ package com.haha.admin.controller;
 import cn.dev33.satoken.stp.StpUtil;
 import com.haha.admin.annotation.RequirePermission;
 import com.haha.common.annotation.Log;
-import com.haha.common.constant.SyncConstants;
 import com.haha.common.enums.OperationType;
 import com.haha.common.vo.Result;
-import com.haha.entity.SyncRecord;
 import com.haha.service.DataSyncService;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -26,73 +24,41 @@ public class DataSyncController {
     private final DataSyncService dataSyncService;
 
     /**
-     * 同步设备数据
-     * 从哈哈平台拉取设备列表并存储到本地数据库
+     * 同步设备数据(异步后台执行)
      */
     @RequirePermission("sync:execute")
     @Log(module = "数据同步", operation = OperationType.SYNC, summary = "同步设备数据")
     @PostMapping("/devices")
-    public Result<SyncRecord> syncDevices() {
-        try {
-            // 获取当前登录用户信息
-            Long operatorId = StpUtil.getLoginIdAsLong();
-            String operatorName = StpUtil.getLoginIdAsString();
-            
-            // 尝试从Session中获取用户名
-            try {
-                Object nickname = StpUtil.getSession().get("nickname");
-                if (nickname != null) {
-                    operatorName = nickname.toString();
-                }
-            } catch (Exception e) {
-                log.debug("获取用户昵称失败,使用默认值");
-            }
-
-            SyncRecord record = dataSyncService.syncDevices(operatorId, operatorName);
-            
-            if (record.getStatus() == SyncConstants.STATUS_SYNC_FAILED) {
-                return Result.error("同步失败: " + record.getErrorMessage());
-            }
-            return Result.success(record);
-        } catch (Exception e) {
-            log.error("同步设备数据失败", e);
-            return Result.error("同步失败: " + e.getMessage());
-        }
+    public Result<Void> syncDevices() {
+        Long operatorId = StpUtil.getLoginIdAsLong();
+        String operatorName = getOperatorName();
+        dataSyncService.syncDevices(operatorId, operatorName);
+        return Result.success("同步任务已启动,正在后台执行,请稍后刷新查看结果");
     }
 
     /**
-     * 同步商品数据
-     * 从哈哈平台商家商品库拉取商品列表并存储到本地数据库
+     * 同步商品数据(异步后台执行)
      */
     @RequirePermission("sync:execute")
     @Log(module = "数据同步", operation = OperationType.SYNC, summary = "同步商品数据")
     @PostMapping("/products")
-    public Result<SyncRecord> syncProducts() {
-        try {
-            // 获取当前登录用户信息
-            Long operatorId = StpUtil.getLoginIdAsLong();
-            String operatorName = StpUtil.getLoginIdAsString();
-            
-            // 尝试从Session中获取用户名
-            try {
-                Object nickname = StpUtil.getSession().get("nickname");
-                if (nickname != null) {
-                    operatorName = nickname.toString();
-                }
-            } catch (Exception e) {
-                log.debug("获取用户昵称失败,使用默认值");
-            }
+    public Result<Void> syncProducts() {
+        Long operatorId = StpUtil.getLoginIdAsLong();
+        String operatorName = getOperatorName();
+        dataSyncService.syncProducts(operatorId, operatorName);
+        return Result.success("同步任务已启动,正在后台执行,请稍后刷新查看结果");
+    }
 
-            SyncRecord record = dataSyncService.syncProducts(operatorId, operatorName);
-            
-            if (record.getStatus() == SyncConstants.STATUS_SYNC_FAILED) {
-                return Result.error("同步失败: " + record.getErrorMessage());
+    private String getOperatorName() {
+        try {
+            Object nickname = StpUtil.getSession().get("nickname");
+            if (nickname != null) {
+                return nickname.toString();
             }
-            return Result.success(record);
         } catch (Exception e) {
-            log.error("同步商品数据失败", e);
-            return Result.error("同步失败: " + e.getMessage());
+            log.debug("获取用户昵称失败");
         }
+        return StpUtil.getLoginIdAsString();
     }
 
     /**

+ 4 - 14
haha-service/src/main/java/com/haha/service/DataSyncService.java

@@ -13,24 +13,14 @@ import java.util.Map;
 public interface DataSyncService extends IService<SyncRecord> {
 
     /**
-     * 同步设备列表
-     * 从哈哈平台拉取设备数据并存储到本地数据库
-     * 
-     * @param operatorId 操作人ID
-     * @param operatorName 操作人名称
-     * @return 同步记录
+     * 同步设备列表(异步后台执行)
      */
-    SyncRecord syncDevices(Long operatorId, String operatorName);
+    void syncDevices(Long operatorId, String operatorName);
 
     /**
-     * 同步商品列表
-     * 从哈哈平台商家商品库拉取商品数据并存储到本地数据库
-     * 
-     * @param operatorId 操作人ID
-     * @param operatorName 操作人名称
-     * @return 同步记录
+     * 同步商品列表(异步后台执行)
      */
-    SyncRecord syncProducts(Long operatorId, String operatorName);
+    void syncProducts(Long operatorId, String operatorName);
 
     /**
      * 获取同步记录列表

+ 38 - 12
haha-service/src/main/java/com/haha/service/impl/DataSyncServiceImpl.java

@@ -56,9 +56,10 @@ public class DataSyncServiceImpl extends ServiceImpl<SyncRecordMapper, SyncRecor
     private static final String SYNC_TYPE_DEVICE = "DEVICE";
     private static final String SYNC_TYPE_PRODUCT = "PRODUCT";
 
+    @Async("taskExecutor")
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public SyncRecord syncDevices(Long operatorId, String operatorName) {
+    public void syncDevices(Long operatorId, String operatorName) {
         log.info("开始同步设备数据,操作人: {}", operatorName);
 
         // 1. 创建同步记录
@@ -129,8 +130,6 @@ public class DataSyncServiceImpl extends ServiceImpl<SyncRecordMapper, SyncRecor
             record.setErrorMessage("同步异常: " + e.getMessage());
             updateById(record);
         }
-
-        return record;
     }
 
     /**
@@ -251,9 +250,10 @@ public class DataSyncServiceImpl extends ServiceImpl<SyncRecordMapper, SyncRecor
         return stats;
     }
 
+    @Async("taskExecutor")
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public SyncRecord syncProducts(Long operatorId, String operatorName) {
+    public void syncProducts(Long operatorId, String operatorName) {
         log.info("开始同步商品数据,操作人: {}", operatorName);
 
         // 1. 创建同步记录
@@ -272,7 +272,9 @@ public class DataSyncServiceImpl extends ServiceImpl<SyncRecordMapper, SyncRecor
         int successCount = 0;
         int failCount = 0;
         int totalCount = 0;
+        int deletedCount = 0;
         StringBuilder errorMsg = new StringBuilder();
+        java.util.Set<String> hahaCodes = new java.util.HashSet<>();
 
         try {
             // 2. 分页从哈哈平台商家商品库获取商品列表
@@ -283,7 +285,7 @@ public class DataSyncServiceImpl extends ServiceImpl<SyncRecordMapper, SyncRecor
             while (hasMore) {
                 // 调用哈哈平台API获取商家商品库列表
                 Map<String, Object> result = hahaClient.getGoodsApi().getMerchantList(pageNo, pageSize, null, null, null, null);
-                
+
                 if (result == null || !result.containsKey("listData")) {
                     log.warn("获取商品列表返回数据为空");
                     break;
@@ -298,19 +300,22 @@ public class DataSyncServiceImpl extends ServiceImpl<SyncRecordMapper, SyncRecor
                 // 3. 遍历商品列表,同步到本地数据库
                 for (Map<String, Object> productData : listData) {
                     totalCount++;
+                    String code = productData.get("code") != null ? productData.get("code").toString() : null;
+                    if (code != null) {
+                        hahaCodes.add(code);
+                    }
                     try {
                         syncSingleProduct(record.getId(), productData);
                         successCount++;
                     } catch (Exception e) {
                         failCount++;
-                        String code = productData.get("code") != null ? productData.get("code").toString() : "unknown";
                         String name = productData.get("name") != null ? productData.get("name").toString() : "unknown";
-                        String errMsg = String.format("商品 %s(%s) 同步失败: %s", code, name, e.getMessage());
+                        String errMsg = String.format("商品 %s(%s) 同步失败: %s", code != null ? code : "unknown", name, e.getMessage());
                         errorMsg.append(errMsg).append("; ");
                         log.error(errMsg, e);
 
                         // 记录失败日志
-                        saveSyncLog(record.getId(), SYNC_TYPE_PRODUCT, code, name, 0, errMsg, JSON.toJSONString(productData));
+                        saveSyncLog(record.getId(), SYNC_TYPE_PRODUCT, code != null ? code : "unknown", name, 0, errMsg, JSON.toJSONString(productData));
                     }
                 }
 
@@ -324,7 +329,30 @@ public class DataSyncServiceImpl extends ServiceImpl<SyncRecordMapper, SyncRecor
                 }
             }
 
-            // 4. 更新同步记录
+            // 4. 清理哈哈中已删除的商品:标记本地已同步但哈哈中不存在的商品为删除
+            if (totalCount > 0 && !hahaCodes.isEmpty()) {
+                List<Product> orphanProducts = productService.lambdaQuery()
+                        .eq(Product::getIsDeleted, CommonConstants.NOT_DELETED)
+                        .eq(Product::getSyncStatus, SyncConstants.STATUS_SYNCED)
+                        .notIn(Product::getCode, hahaCodes)
+                        .list();
+
+                if (!orphanProducts.isEmpty()) {
+                    deletedCount = orphanProducts.size();
+                    List<Long> orphanIds = orphanProducts.stream().map(Product::getId).toList();
+                    productService.lambdaUpdate()
+                            .set(Product::getIsDeleted, CommonConstants.DELETED)
+                            .set(Product::getUpdateTime, LocalDateTime.now())
+                            .in(Product::getId, orphanIds)
+                            .update();
+
+                    log.info("标记已删除商品: {} 条, codes={}",
+                            deletedCount,
+                            orphanProducts.stream().map(Product::getCode).limit(20).toList());
+                }
+            }
+
+            // 5. 更新同步记录
             record.setTotalCount(totalCount);
             record.setSuccessCount(successCount);
             record.setFailCount(failCount);
@@ -336,7 +364,7 @@ public class DataSyncServiceImpl extends ServiceImpl<SyncRecordMapper, SyncRecor
             }
             updateById(record);
 
-            log.info("商品数据同步完成,总数: {}, 成功: {}, 失败: {}", totalCount, successCount, failCount);
+            log.info("商品数据同步完成,总数: {}, 成功: {}, 失败: {}, 标记删除: {}", totalCount, successCount, failCount, deletedCount);
 
         } catch (HahaException e) {
             log.error("调用哈哈平台API失败", e);
@@ -353,8 +381,6 @@ public class DataSyncServiceImpl extends ServiceImpl<SyncRecordMapper, SyncRecor
             record.setErrorMessage("同步异常: " + e.getMessage());
             updateById(record);
         }
-
-        return record;
     }
 
     /**

+ 5 - 3
haha-service/src/main/java/com/haha/service/impl/ErpGoodsServiceImpl.java

@@ -2,6 +2,7 @@ package com.haha.service.impl;
 
 import com.alibaba.fastjson2.JSON;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
@@ -162,9 +163,10 @@ public class ErpGoodsServiceImpl extends ServiceImpl<ErpGoodsMapper, ErpGoods> i
             throw new BusinessException(404, "商品不存在");
         }
 
-        product.setErpGoodsId(null);
-        product.setUpdateTime(LocalDateTime.now());
-        productMapper.updateById(product);
+        productMapper.update(null, new LambdaUpdateWrapper<Product>()
+                .set(Product::getErpGoodsId, null)
+                .set(Product::getUpdateTime, LocalDateTime.now())
+                .eq(Product::getId, productId));
 
         log.info("商品解除ERP商品关联: productId={}, productName={}", productId, product.getName());
     }