Преглед на файлове

fix: ERP商品同步功能修复+GoodsApi嵌套JSON解析+QdbSdkConfig启用+前端完善

- GoodsApi.listSysGoods 改用 JSONObject 拆包解析 ERP 返回的嵌套 {data:{data:[...]}} 结构
- QdbSdkConfig 取消注释,qdbClient Bean 正式注册
- haha-admin/pom.xml 修复 qdb-sdk groupId (com.qdb→com.haha),消除旧 jar classpath 冲突
- syncFromErp 改为 @Async 异步逐条反查,每10条暂停200ms控速
- ErpGoods 实体新增 barCode/modifyDate 字段,同步时保存条码和修改时间
- 前端:图片预览列+preview-teleported修复预览层叠问题
- Vite 代理白名单新增 /erp-goods、/erp-shop
- 菜单 SQL 去掉 icon 保持一致

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
skyline преди 5 дни
родител
ревизия
357a49365b

+ 6 - 0
docs/database/add_erp_goods_bar_code.sql

@@ -0,0 +1,6 @@
+-- t_erp_goods 新增 bar_code 和 modify_date 字段
+-- 创建日期: 2026-07-08
+
+ALTER TABLE `t_erp_goods`
+    ADD COLUMN `bar_code` VARCHAR(2000) DEFAULT NULL COMMENT '商品条码(JSON数组)' AFTER `weight`,
+    ADD COLUMN `modify_date` DATETIME DEFAULT NULL COMMENT 'ERP最后修改时间(用于增量同步)' AFTER `bar_code`;

+ 2 - 0
docs/database/create_erp_goods.sql

@@ -21,6 +21,8 @@ CREATE TABLE IF NOT EXISTS `t_erp_goods` (
     `article_no` VARCHAR(100) DEFAULT NULL COMMENT '商品货号',
     `pic_path` VARCHAR(500) DEFAULT NULL COMMENT '图片路径',
     `weight` DECIMAL(10, 2) DEFAULT NULL COMMENT '重量',
+    `bar_code` VARCHAR(2000) DEFAULT NULL COMMENT '商品条码(JSON数组)',
+    `modify_date` DATETIME DEFAULT NULL COMMENT 'ERP最后修改时间(用于增量同步)',
     `status` TINYINT DEFAULT 1 COMMENT '状态:0-停用,1-启用',
     `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',

+ 1 - 1
docs/database/menu_add_erp_goods.sql

@@ -4,7 +4,7 @@
 -- rank=4,排在商品总库之后
 
 INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, icon, show_link, show_parent)
-VALUES (404, 400, 0, 'ERP商品关联', 'ErpGoodsList', '/product/erp-goods', '', 4, 'ri:links-line', 1, 0);
+VALUES (404, 400, 0, 'ERP商品关联', 'ErpGoodsList', '/product/erp-goods', '', 4, '', 1, 0);
 
 -- 为超级管理员角色(role_id=1)授权
 INSERT INTO t_role_menu (id, role_id, menu_id) VALUES (90055, 1, 404);

+ 1 - 1
docs/database/menu_add_erp_shop.sql

@@ -4,7 +4,7 @@
 -- rank=5,排在上货记录之后
 
 INSERT INTO t_menu (id, parent_id, menu_type, title, name, path, component, `rank`, icon, show_link, show_parent)
-VALUES (305, 300, 0, 'ERP店铺管理', 'ErpShopList', '/operation/erp-shop', '', 5, 'ri:store-2-line', 1, 0);
+VALUES (305, 300, 0, 'ERP店铺管理', 'ErpShopList', '/operation/erp-shop', '', 5, '', 1, 0);
 
 -- 为超级管理员角色(role_id=1)授权
 INSERT INTO t_role_menu (id, role_id, menu_id) VALUES (90054, 1, 305);

+ 0 - 1
haha-admin-web/src/router/modules/operation.ts

@@ -49,7 +49,6 @@ export default {
       name: "ErpShopList",
       component: () => import("@/views/erp-shop/index.vue"),
       meta: {
-        icon: "ri:store-2-line",
         title: "ERP店铺管理"
       }
     }

+ 0 - 1
haha-admin-web/src/router/modules/product.ts

@@ -40,7 +40,6 @@ export default {
       name: "ErpGoodsList",
       component: () => import("@/views/erp-goods/index.vue"),
       meta: {
-        icon: "ri:links-line",
         title: "ERP商品关联"
       }
     }

+ 16 - 2
haha-admin-web/src/views/erp-goods/index.vue

@@ -62,11 +62,12 @@ async function handleSync() {
   try {
     const res = await syncErpGoods();
     if (res.code === 200) {
-      ElMessage.success("同步成功");
-      await fetchList();
+      ElMessage.success(res.message || "同步任务已启动");
     } else {
       ElMessage.error(res.message || "同步失败");
     }
+  } catch (e) {
+    ElMessage.error("同步请求失败,请稍后重试");
   } finally {
     syncing.value = false;
   }
@@ -175,6 +176,19 @@ onMounted(() => {
         <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 label="图片" width="80">
+          <template #default="{ row }">
+            <el-image
+              v-if="row.picPath"
+              :src="row.picPath"
+              :preview-src-list="[row.picPath]"
+              fit="cover"
+              preview-teleported
+              style="width:48px;height:48px;border-radius:4px"
+            />
+            <span v-else class="text-gray-400 text-xs">无</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="specs" label="规格" width="120" show-overflow-tooltip />
         <el-table-column prop="skUnit" label="单位" width="80" />
         <el-table-column prop="brand" label="品牌" width="120" />

+ 3 - 1
haha-admin-web/vite.config.ts

@@ -43,7 +43,9 @@ export default async ({ mode }: ConfigEnv): Promise<UserConfigExport> => {
     "/replenishers",
     "/menu",
     "/upload",
-    "/replenishment-orders"
+    "/replenishment-orders",
+    "/erp-goods",
+    "/erp-shop"
   ];
   
   // 动态生成代理配置

+ 1 - 2
haha-admin/pom.xml

@@ -24,9 +24,8 @@
 
         <!-- 企得宝 ERP SDK -->
         <dependency>
-            <groupId>com.qdb</groupId>
+            <groupId>com.haha</groupId>
             <artifactId>qdb-sdk</artifactId>
-            <version>1.0.0</version>
         </dependency>
 
         <!-- 依赖Service模块 -->

+ 26 - 30
haha-admin/src/main/java/com/haha/admin/config/QdbSdkConfig.java

@@ -1,49 +1,45 @@
 package com.haha.admin.config;
 
-// TODO: 企得宝 ERP SDK - 暂未对接,后续接入时取消注释
-// import com.qdb.sdk.QdbClient;
-// import com.qdb.sdk.QdbConfig;
-// import lombok.extern.slf4j.Slf4j;
-// import org.springframework.beans.factory.annotation.Value;
-// import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-// import org.springframework.context.annotation.Bean;
-// import org.springframework.context.annotation.Configuration;
+import com.qdb.sdk.QdbClient;
+import com.qdb.sdk.QdbConfig;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
 
 /**
  * 企得宝 ERP SDK 配置类
  */
-// TODO: 企得宝 ERP SDK - 暂未对接,后续接入时取消注释
-// @Slf4j
-// @Configuration
+@Slf4j
+@Configuration
 public class QdbSdkConfig {
 
-    // TODO: 企得宝 ERP SDK - 暂未对接,后续接入时取消注释
-    // @Value("${qdb.client-id:}")
+    @Value("${qdb.client-id:}")
     private String clientId;
 
-    // @Value("${qdb.client-secret:}")
+    @Value("${qdb.client-secret:}")
     private String clientSecret;
 
-    // @Value("${qdb.api-url:https://api.7debao.com/router/api}")
+    @Value("${qdb.api-url:https://api.7debao.com/router/api}")
     private String apiUrl;
 
     /**
-     * TODO: 企得宝ERP SDK - 暂未对接,后续接入时取消注释
      * 创建企得宝ERP SDK客户端Bean
      * 仅在配置了 client-id 和 client-secret 时才创建
      */
-    // @Bean
-    // @ConditionalOnProperty(prefix = "qdb", name = {"client-id", "client-secret"})
-    // public QdbClient qdbClient() {
-    //     QdbConfig config = QdbConfig.builder()
-    //             .clientId(clientId)
-    //             .clientSecret(clientSecret)
-    //             .apiUrl(apiUrl)
-    //             .build();
-    //
-    //     config.validate();
-    //
-    //     log.info("企得宝ERP SDK 初始化成功, clientId: {}", clientId);
-    //     return new QdbClient(config);
-    // }
+    @Bean
+    @ConditionalOnProperty(prefix = "qdb", name = {"client-id", "client-secret"})
+    public QdbClient qdbClient() {
+        QdbConfig config = QdbConfig.builder()
+                .clientId(clientId)
+                .clientSecret(clientSecret)
+                .apiUrl(apiUrl)
+                .build();
+
+        config.validate();
+
+        log.info("企得宝ERP SDK 初始化成功, clientId: {}", clientId);
+        return new QdbClient(config);
+    }
 }

+ 1 - 1
haha-admin/src/main/java/com/haha/admin/controller/ErpGoodsController.java

@@ -32,7 +32,7 @@ public class ErpGoodsController {
     @PostMapping("/sync")
     public Result<Void> syncFromErp() {
         erpGoodsService.syncFromErp();
-        return Result.success("同步成功");
+        return Result.success("同步任务已启动,正在后台执行,请稍后刷新页面查看结果");
     }
 
     /**

+ 7 - 0
haha-entity/src/main/java/com/haha/entity/ErpGoods.java

@@ -76,6 +76,13 @@ public class ErpGoods implements Serializable {
     /** 重量 */
     private BigDecimal weight;
 
+    /** 商品条码(JSON数组字符串,如 ["6928804012345","6928804012346"]) */
+    private String barCode;
+
+    /** ERP最后修改时间(用于增量同步) */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
+    private LocalDateTime modifyDate;
+
     /** 状态:0-停用,1-启用 */
     private Integer status;
 

+ 69 - 41
haha-service/src/main/java/com/haha/service/impl/ErpGoodsServiceImpl.java

@@ -1,5 +1,6 @@
 package com.haha.service.impl;
 
+import com.alibaba.fastjson2.JSON;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.haha.common.exception.BusinessException;
@@ -12,21 +13,19 @@ import com.qdb.sdk.QdbClient;
 import com.qdb.sdk.QdbException;
 import com.qdb.sdk.model.request.SysGoodsQueryRequest;
 import com.qdb.sdk.model.response.QdbResponse;
-import com.qdb.sdk.model.response.SysGoodsPageResult;
 import com.qdb.sdk.model.response.SysGoodsVO;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
+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.time.ZoneId;
 import java.util.List;
 
-/**
- * ERP 商品信息服务实现
- */
 @Slf4j
 @Service
 @RequiredArgsConstructor
@@ -37,55 +36,75 @@ public class ErpGoodsServiceImpl extends ServiceImpl<ErpGoodsMapper, ErpGoods> i
     @Autowired(required = false)
     private QdbClient qdbClient;
 
+    @Async("taskExecutor")
     @Override
-    @Transactional(rollbackFor = Exception.class)
     public void syncFromErp() {
         if (qdbClient == null) {
             throw new BusinessException(500, "QdbClient 未配置,无法同步ERP商品数据");
         }
 
-        int pageNo = 1;
-        int pageSize = 50;
-        boolean hasMore = true;
-        int syncedCount = 0;
-
-        while (hasMore) {
-            SysGoodsQueryRequest request = SysGoodsQueryRequest.builder()
-                    .pageIndex(pageNo)
-                    .pageSize(pageSize)
-                    .status(1)
-                    .build();
+        List<Product> products = productMapper.selectList(new LambdaQueryWrapper<Product>()
+                .eq(Product::getIsDeleted, 0)
+                .isNotNull(Product::getName)
+                .ne(Product::getName, ""));
 
-            try {
-                QdbResponse<SysGoodsPageResult> response = qdbClient.getGoodsApi().listSysGoodsPaged(request);
-                SysGoodsPageResult pageResult = response.getData();
+        if (products.isEmpty()) {
+            log.info("无本地商品需要同步");
+            return;
+        }
 
-                if (pageResult == null || pageResult.getData() == null || pageResult.getData().isEmpty()) {
-                    hasMore = false;
-                    break;
-                }
+        log.info("开始逐条同步ERP商品: 共 {} 个本地商品", products.size());
+        int syncedCount = 0;
+        int missCount = 0;
+        int total = products.size();
 
-                for (SysGoodsVO goods : pageResult.getData()) {
-                    upsertErpGoods(goods);
-                    syncedCount++;
-                }
+        for (int i = 0; i < total; i++) {
+            Product product = products.get(i);
+            String goodsCode = product.getName();
 
-                Integer total = pageResult.getTotal();
-                if (total != null && pageNo * pageSize >= total) {
-                    hasMore = false;
-                } else if (pageResult.getData().size() < pageSize) {
-                    hasMore = false;
+            try {
+                SysGoodsQueryRequest request = SysGoodsQueryRequest.builder()
+                        .pageIndex(1)
+                        .pageSize(1)
+                        .goodsCode(goodsCode)
+                        .status(1)
+                        .combination(0)
+                        .build();
+
+                QdbResponse<List<SysGoodsVO>> response = qdbClient.getGoodsApi()
+                        .listSysGoods(request);
+                List<SysGoodsVO> goodsList = response.getData();
+
+                if (goodsList != null && !goodsList.isEmpty()) {
+                    for (SysGoodsVO goods : goodsList) {
+                        if (goods.getGoodsId() == null) {
+                            log.warn("ERP返回的商品缺少goodsId, 跳过: goodsCode={}", goodsCode);
+                            continue;
+                        }
+                        upsertErpGoods(goods);
+                        syncedCount++;
+                    }
                 } else {
-                    pageNo++;
+                    log.debug("ERP中未找到商品: goodsCode={}, productId={}", goodsCode, product.getId());
+                    missCount++;
                 }
 
             } catch (QdbException e) {
-                log.error("同步ERP商品失败: pageNo={}, error={}", pageNo, e.getMessage());
-                throw new BusinessException(500, "ERP商品同步失败: " + e.getMessage());
+                log.error("查询ERP商品失败: goodsCode={}, productId={}, error={}",
+                        goodsCode, product.getId(), e.getMessage());
+            }
+
+            if ((i + 1) % 10 == 0 && i + 1 < total) {
+                try {
+                    Thread.sleep(200);
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                    break;
+                }
             }
         }
 
-        log.info("ERP商品同步完成: 共同步 {} 条", syncedCount);
+        log.info("ERP商品同步完成: 处理 {} 条, 命中 {} 条, 未找到 {} 条", total, syncedCount, missCount);
     }
 
     @Override
@@ -109,14 +128,12 @@ public class ErpGoodsServiceImpl extends ServiceImpl<ErpGoodsMapper, ErpGoods> i
             throw new BusinessException(404, "商品不存在");
         }
 
-        // 检查 ERP 商品是否存在
         ErpGoods erpGoods = getOne(new LambdaQueryWrapper<ErpGoods>()
                 .eq(ErpGoods::getErpGoodsId, erpGoodsId));
         if (erpGoods == null) {
             throw new BusinessException(404, "ERP商品不存在,请先同步ERP商品数据");
         }
 
-        // 检查是否已被其他本地商品绑定
         List<Product> boundProducts = productMapper.selectList(new LambdaQueryWrapper<Product>()
                 .eq(Product::getErpGoodsId, erpGoodsId)
                 .eq(Product::getIsDeleted, 0));
@@ -147,10 +164,9 @@ public class ErpGoodsServiceImpl extends ServiceImpl<ErpGoodsMapper, ErpGoods> i
         log.info("商品解除ERP商品关联: productId={}, productName={}", productId, product.getName());
     }
 
-    /**
-     * 插入或更新 ERP 商品记录(按 erp_goods_id 判断)
-     */
     private void upsertErpGoods(SysGoodsVO goods) {
+        log.info("同步ERP商品: goodsId={}, goodsCode={}, goodsName={}",
+                goods.getGoodsId(), goods.getGoodsCode(), goods.getGoodsName());
         ErpGoods existing = getOne(new LambdaQueryWrapper<ErpGoods>()
                 .eq(ErpGoods::getErpGoodsId, goods.getGoodsId()));
 
@@ -171,6 +187,12 @@ public class ErpGoodsServiceImpl extends ServiceImpl<ErpGoodsMapper, ErpGoods> i
             existing.setArticleNo(goods.getArticleNo());
             existing.setPicPath(goods.getPicPath());
             existing.setWeight(goods.getWeight());
+            existing.setBarCode(goods.getBarCodeList() != null && !goods.getBarCodeList().isEmpty()
+                    ? JSON.toJSONString(goods.getBarCodeList()) : null);
+            if (goods.getModifyDate() != null) {
+                existing.setModifyDate(goods.getModifyDate().toInstant()
+                        .atZone(ZoneId.systemDefault()).toLocalDateTime());
+            }
             existing.setUpdateTime(LocalDateTime.now());
             updateById(existing);
         } else {
@@ -192,6 +214,12 @@ public class ErpGoodsServiceImpl extends ServiceImpl<ErpGoodsMapper, ErpGoods> i
             erpGoods.setArticleNo(goods.getArticleNo());
             erpGoods.setPicPath(goods.getPicPath());
             erpGoods.setWeight(goods.getWeight());
+            erpGoods.setBarCode(goods.getBarCodeList() != null && !goods.getBarCodeList().isEmpty()
+                    ? JSON.toJSONString(goods.getBarCodeList()) : null);
+            if (goods.getModifyDate() != null) {
+                erpGoods.setModifyDate(goods.getModifyDate().toInstant()
+                        .atZone(ZoneId.systemDefault()).toLocalDateTime());
+            }
             erpGoods.setStatus(1);
             erpGoods.setCreateTime(LocalDateTime.now());
             erpGoods.setUpdateTime(LocalDateTime.now());

+ 27 - 22
qdb-sdk/src/main/java/com/qdb/sdk/api/GoodsApi.java

@@ -1,11 +1,14 @@
 package com.qdb.sdk.api;
 
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
 import com.alibaba.fastjson2.TypeReference;
 import com.qdb.sdk.QdbClient;
 import com.qdb.sdk.QdbException;
 import com.qdb.sdk.model.request.*;
 import com.qdb.sdk.model.response.*;
 
+import java.util.Collections;
 import java.util.List;
 
 /**
@@ -87,34 +90,36 @@ public class GoodsApi extends BaseQdbApi {
     /**
      * 系统商品查询
      * <p>
-     * 查询系统商品信息(即【商品信息】、【组合商品】页面数据)
-     * 注意:此方法解析 data 为扁平列表,不适用于分页场景。
-     * 分页场景请使用 {@link #listSysGoodsPaged(SysGoodsQueryRequest)}
+     * 查询系统商品信息,自动处理 ERP 返回的嵌套分页结构
+     * ERP 的 data 字段实际为 {@code { total, data: [...] }},
+     * 本方法手动提取内层 data 数组并解析为 SysGoodsVO 列表
      * </p>
      *
-     * @param request 查询请求参数(支持多种筛选条件)
+     * @param request 查询请求参数
      * @return 响应对象,data 为系统商品列表
      * @throws QdbException 调用失败时抛出
      */
     public QdbResponse<List<SysGoodsVO>> listSysGoods(SysGoodsQueryRequest request) throws QdbException {
-        return execute(METHOD_SYS_GOODS_LIST, request,
-                new TypeReference<List<SysGoodsVO>>() {});
-    }
-
-    /**
-     * 系统商品查询(带分页信息)
-     * <p>
-     * 查询系统商品信息,返回包含 total 和 data 的分页结果。
-     * 适用于需要分页遍历所有系统商品的场景(如同步到本地缓存)。
-     * </p>
-     *
-     * @param request 查询请求参数
-     * @return 响应对象,data 为包含 total 和商品列表的分页结果
-     * @throws QdbException 调用失败时抛出
-     */
-    public QdbResponse<SysGoodsPageResult> listSysGoodsPaged(SysGoodsQueryRequest request) throws QdbException {
-        return execute(METHOD_SYS_GOODS_LIST, request,
-                new TypeReference<SysGoodsPageResult>() {});
+        // 先用 JSONObject 接收原始 data,避免泛型解析失败
+        QdbResponse<JSONObject> rawResponse = execute(METHOD_SYS_GOODS_LIST, request, JSONObject.class);
+        JSONObject rawData = rawResponse.getData();
+
+        List<SysGoodsVO> goodsList = Collections.emptyList();
+        if (rawData != null) {
+            Object inner = rawData.get("data");
+            if (inner instanceof List) {
+                goodsList = JSON.parseArray(JSON.toJSONString(inner), SysGoodsVO.class);
+            }
+        }
+
+        QdbResponse<List<SysGoodsVO>> result = new QdbResponse<>();
+        result.setSuccess(rawResponse.isSuccess());
+        result.setMsg(rawResponse.getMsg());
+        result.setErrorCode(rawResponse.getErrorCode());
+        result.setDate(rawResponse.getDate());
+        result.setVersion(rawResponse.getVersion());
+        result.setData(goodsList);
+        return result;
     }
 
     /**