# ERP对接模块说明

> 本文档描述企得宝 ERP 系统集成方案，涵盖 SDK 封装、数据同步、商品/店铺关联管理的完整架构。
> 帮助开发人员理解 ERP 对接的设计决策和关键流程。

---

## 一、模块概述

### 1.1 对接目标

通过企得宝 ERP 开放平台 API（`https://api.7debao.com/router/api`），实现：

- 将本地商品与 ERP 商品建立一对一关联
- 将本地设备与 ERP 店铺建立一对一绑定（ERP 店铺对应系统设备）
- 后续补货单可推送至 ERP 生成采购单据

### 1.2 架构总览

```
┌─────────────────────────────────────────────────────┐
│                   haha-admin-web                     │
│  erp-goods/index.vue     erp-shop/index.vue          │
└──────────────────┬──────────────────────────────────┘
                   │ HTTP API
┌──────────────────▼──────────────────────────────────┐
│                   haha-admin                         │
│  ErpGoodsController     ErpShopController            │
│  QdbSdkConfig (QdbClient Bean)                      │
└──────────────────┬──────────────────────────────────┘
                   │
┌──────────────────▼──────────────────────────────────┐
│                   haha-service                       │
│  ErpGoodsService        ErpShopService               │
│  (sync / bind / unbind / autoBind)                   │
└──────────────────┬──────────────────────────────────┘
                   │
┌──────────────────▼──────────────────────────────────┐
│                   qdb-sdk (子模块)                    │
│  QdbClient → HttpUtil (签名 + HTTP)                  │
│  GoodsApi     ShopApi     OrderApi                   │
└──────────────────┬──────────────────────────────────┘
                   │
┌──────────────────▼──────────────────────────────────┐
│              企得宝 ERP API                           │
│          api.7debao.com/router/api                   │
│  foonsu.erp.sysGoods.list    (系统商品查询)           │
│  foonsu.erp.sysGoods.save    (系统商品上传)           │
│  foonsu.erp.sysShop.list     (店铺查询)               │
│  foonsu.erp.order.save       (订单上传)               │
└─────────────────────────────────────────────────────┘
```

---

## 二、数据库设计

### 2.1 ER图（核心表）

```
t_product                    t_erp_goods              企得宝ERP
┌──────────────┐            ┌──────────────────┐      ┌──────────┐
│ id           │            │ id               │      │ goodsId  │
│ code         │            │ erp_goods_id ◄───┼──────┤ goodsCode │
│ name ◄───────┼────────────┤ goods_code       │      │ goodsName│
│ erp_goods_id ├───────────►│ goods_name       │      │ specs    │
│ barcode      │            │ specs            │      │ skUnit   │
│ pic          │            │ sale_price       │      │ picPath  │
│ ...          │            │ bar_code(JSON)   │      │ ...      │
└──────────────┘            │ modify_date      │      └──────────┘
                            │ status           │
t_device                    └──────────────────┘      企得宝ERP
┌──────────────┐            t_erp_shop                 ┌──────────┐
│ id           │            ┌──────────────────┐      │ shopId   │
│ deviceId ◄───┼────────────┤ shop_code        │      │ shopCode │
│ erp_shop_id  ├───────────►│ erp_shop_id ◄────┼──────┤ shopName │
│ name         │            │ shop_name        │      │ ...      │
│ ...          │            │ nick_name        │      └──────────┘
└──────────────┘            │ status           │
                            └──────────────────┘
```

### 2.2 关联关系

- `t_product.erp_goods_id` → `t_erp_goods.erp_goods_id`：本地商品与 ERP 商品的**一对一关联**
- `t_device.erp_shop_id` → `t_erp_shop.erp_shop_id`：本地设备与 ERP 店铺的**一对一绑定**（ERP 店铺对应系统中的设备，ERP 的 `shopCode` = 设备的 `deviceId`）
- `t_product.name` = `t_erp_goods.goods_code`：商品名称即 ERP 编码，是一键自动关联的依据
- `t_device.deviceId` = `t_erp_shop.shopCode`：设备ID即 ERP 店铺编码，是一键自动绑定的依据

### 2.3 DDL脚本

| 脚本 | 用途 |
|------|------|
| `docs/database/create_erp_goods.sql` | 创建 `t_erp_goods` 缓存表 |
| `docs/database/create_erp_shop.sql` | 创建 `t_erp_shop` 缓存表 |
| `docs/database/add_erp_goods_id.sql` | `t_product` 增加 `erp_goods_id` 字段 |
| `docs/database/add_erp_shop_id.sql` | `t_shop` 增加 `erp_shop_id` 字段 |
| `docs/database/add_device_erp_shop_id.sql` | `t_device` 增加 `erp_shop_id` 字段 |
| `docs/database/add_erp_goods_bar_code.sql` | `t_erp_goods` 增加 `bar_code`、`modify_date` |
| `docs/database/menu_add_erp_goods.sql` | 插入"ERP商品关联"菜单 |
| `docs/database/menu_add_erp_shop.sql` | 插入"ERP店铺管理"菜单 |
| `docs/database/add_erp_permissions.sql` | 插入 ERP 功能权限数据 |

---

## 三、SDK集成 (qdb-sdk)

### 3.1 模块定位

`qdb-sdk` 是 `haha-parent` 的子模块（`com.haha:qdb-sdk`），统一版本管理。提供企得宝 ERP 开放 API 的 Java SDK 封装。

### 3.2 核心类

| 类 | 职责 |
|----|------|
| `QdbConfig` | SDK 配置（clientId、clientSecret、apiUrl、超时等） |
| `QdbClient` | SDK 入口，持有各业务 API 实例 + `HttpUtil` |
| `HttpUtil` | HTTP 请求封装（签名、OkHttp 调用） |
| `SignUtil` | MD5 签名算法 |
| `BaseQdbApi` | API 基类，提供 `execute()` 通用调用方法 |
| `GoodsApi` | 商品管理 API（查询、上传） |
| `ShopApi` | 店铺管理 API（查询） |
| `OrderApi` | 订单管理 API（上传、查询） |

### 3.3 Bean 配置

`QdbSdkConfig`（haha-admin 模块）：

```java
@Configuration
public class QdbSdkConfig {
    @Value("${qdb.client-id:}")
    private String clientId;
    @Value("${qdb.client-secret:}")
    private String clientSecret;
    @Value("${qdb.api-url:https://api.7debao.com/router/api}")
    private String apiUrl;

    @Bean
    @ConditionalOnProperty(prefix = "qdb", name = {"client-id", "client-secret"})
    public QdbClient qdbClient() { ... }
}
```

配置项（`application.yml`）：

```yaml
qdb:
  client-id: fs2009263
  client-secret: your_secret_here
  api-url: https://api.7debao.com/router/api
```

**安全提醒**：`client-id` 和 `client-secret` 应使用环境变量或外部配置文件管理，避免提交到代码仓库。

### 3.4 API 调用方式

ERP API 统一 POST 到 `https://api.7debao.com/router/api`：
- 公共参数（client_id, method, timestamp, sign...）拼接到 URL Query String
- 业务参数以 JSON 放入 Request Body
- 签名：公共参数按 key 字典序排列 → 拼接 `&key=clientSecret` → MD5

---

## 四、ERP商品同步

### 4.1 同步策略

**不是拉取 ERP 全量商品**，而是**以本地商品为基准逐条反查**。

原因：ERP 中有多个客户的数据，全量拉取无法区分哪些是本系统需要的。系统只关心已在哈哈平台上架的商品。

### 4.2 同步流程

```
① SELECT t_product WHERE is_deleted=0 AND name IS NOT NULL
     → 获取所有本地商品（name 作为 ERP goodsCode）

② 逐条调用 foonsu.erp.sysGoods.list
     参数: { pageIndex:1, pageSize:1, goodsCode: product.name, status:1, combination:0 }
     频率控制: 每 10 条暂停 200ms

③ ERP 返回: { data: { total: N, data: [{ goodsId, goodsCode, goodsName, ... }] } }
     → GoodsApi 自动拆包提取内层数组 → List<SysGoodsVO>

④ 命中 → upsert 到 t_erp_goods（按 erp_goods_id 判断新增/更新）
   未命中 → 记入日志，在同步完成后整齐打印清单
```

### 4.3 异步执行

`ErpGoodsServiceImpl.syncFromErp()` 标注 `@Async("taskExecutor")`，接口 `POST /erp-goods/sync` 立即返回"同步任务已启动"，后台线程池执行。

### 4.4 保存字段

同步时保存：`goodsId`, `goodsCode`, `goodsName`, `shortGoodsName`, `specs`, `skUnit`, `goodsKindCode`, `goodsKindName`, `brand`, `salePrice`, `costPrice`, `providerName`, `batchType`, `combination`, `articleNo`, `picPath`, `weight`, `barCodeList(JSON)`, `modifyDate`

---

## 五、商品关联

### 5.1 手动关联/解除

- `POST /erp-goods/bind` — 选择本地商品与 ERP 商品一对一关联
- `POST /erp-goods/unbind` — 解除关联（使用 `LambdaUpdateWrapper.set(null)` 强制写 NULL，避免 MP 默认跳过 null 字段）

### 5.2 一键自动关联

- `POST /erp-goods/auto-bind` — 遍历所有 ERP 缓存商品，用 `goodsCode` 匹配本地 `product.name`
- 唯一匹配 → 自动绑定；0 个或多于 1 个匹配 → 跳过
- **批量优化**：
  - 查询已绑定：1 次 `WHERE erp_goods_id IN (...)` 
  - 查询匹配：每 100 个 code 一批 `WHERE name IN (...)`
  - 批量 UPDATE：`CASE WHEN` SQL，每 100 条一批

---

## 六、ERP店铺管理（设备绑定）

### 6.1 同步

- `POST /erp-shops/sync` — 从 ERP 全量翻页查询店铺列表
- **名称过滤**：只保存店铺名称包含"自助售卖机"的记录到 `t_erp_shop`
- **异步执行**：`@Async("taskExecutor")`，接口立即返回
- 日志区分 `syncedCount` / `filteredCount`

### 6.2 手动绑定/解绑

- `POST /erp-shops/bind` — 选择本地设备与 ERP 店铺一对一绑定
- `POST /erp-shops/unbind` — 解除绑定（使用 `LambdaUpdateWrapper.set(null)` 强制写 NULL）
- 绑定对象是 **设备**（`t_device`），非门店。ERP 店铺的 `shopCode` 对应设备的 `deviceId`

### 6.3 一键自动绑定

- `POST /erp-shops/auto-bind` — 遍历所有 ERP 缓存店铺，用 `shopCode` 匹配设备 `deviceId`
- 匹配成功 → 自动绑定；未匹配到设备 → 跳过
- **批量优化**：
  - 查询已绑定：1 次 `WHERE erp_shop_id IN (...)`
  - 查询设备：每 100 个 deviceId 一批 `WHERE device_id IN (...)`
  - 批量 UPDATE：`CASE WHEN` SQL，每 100 条一批

### 6.4 列表页

- 分页展示（10/20/50 条/页），支持搜索店铺名称/卖家账号
- 列：ERP店铺ID、店铺名称、卖家账号、设备ID、绑定设备、操作
- 设备绑定标签悬停浮窗展示设备详情（deviceId、名称、门店、地址）

---

## 七、权限配置

### 7.1 权限码

| 权限码 | 说明 | 类型 |
|--------|------|------|
| `erp:goods:sync` | ERP商品同步 | execute |
| `erp:goods:read` | ERP商品查看 | read |
| `erp:goods:bind` | ERP商品关联/解绑/一键关联 | update |
| `erp:shop:sync` | ERP店铺同步 | execute |
| `erp:shop:read` | ERP店铺查看 | read |
| `erp:shop:bind` | ERP店铺绑定/解绑 | update |

### 7.2 菜单

| 菜单ID | 父菜单 | 路径 | 名称 |
|--------|--------|------|------|
| 404 | 商品管理(400) | /product/erp-goods | ERP商品关联 |
| 305 | 运营管理(300) | /operation/erp-shop | ERP店铺管理 |

### 7.3 权限校验机制

- `@RequirePermission` 注解通过 AOP 切面（`PermissionAspect`）校验
- 超级管理员（role_id=1）直接放行，不查权限表
- 非超管角色需在 `t_data_permission` 表有对应权限码，且在 `t_role_menu` 表有菜单授权

---

## 八、前端页面

### 8.1 ERP商品关联 `erp-goods/index.vue`

| 功能 | 描述 |
|------|------|
| 列表 | 分页展示 ERP 商品（图片、编码、名称、规格、单位、售价、关联状态） |
| 搜索 | 按商品名称/编码搜索 |
| 同步 | 异步后台执行，完成后需手动刷新 |
| 一键关联 | 按 goodsCode 自动匹配本地商品 |
| 手动关联弹窗 | 展示 ERP 商品信息卡片 + 带缩略图的下拉选择框 |
| 解除关联 | 确认弹窗后解除 |
| 关联浮窗 | 悬停已关联标签时展示本地商品详情（含图片预览） |

### 8.2 ERP店铺管理 `erp-shop/index.vue`

| 功能 | 描述 |
|------|------|
| 列表 | 分页展示 ERP 店铺（店铺ID、名称、卖家账号、设备ID、绑定状态） |
| 搜索 | 按店铺名称/卖家账号搜索 |
| 同步 | 异步后台执行，过滤"自助售卖机"名称 |
| 一键绑定 | 按 shopCode 自动匹配设备 deviceId |
| 手动绑定弹窗 | 选择本地设备进行绑定 |
| 解除绑定 | 确认弹窗后解除 |
| 设备浮窗 | 悬停已绑定设备标签时展示设备详情（deviceId、名称、门店、地址） |

### 8.3 Vite 代理配置

`/erp-goods` 和 `/erp-shops` 路径需在 `vite.config.ts` 的 `apiPaths` 数组中注册，否则开发环境请求不会被转发到后端。

---

## 九、关键设计决策

### 9.1 为什么不用 GoodsApi 的泛型直接解析？

ERP 的 `foonsu.erp.sysGoods.list` 返回的 `data` 字段是嵌套结构 `{ total: N, data: [...] }`，不是扁平数组。`GoodsApi.listSysGoods()` 内部用 `JSONObject` 接收原始 data，再手动提取内层数组，避免 fastjson2 泛型解析的类型不匹配。

### 9.2 为什么同步用 goodsCode 逐条查而不是全量翻页？

ERP 中有多客户的商品数据，全量拉取无法过滤。只有本地商品的 `name` 字段才对应 ERP 的 `goodsCode`，以此为 key 逐条精确查询是最准确的方式。

### 9.3 为什么解除关联用 LambdaUpdateWrapper.set(null)？

MyBatis-Plus 默认 `FieldStrategy.NOT_NULL`，`updateById(entity)` 会跳过 null 值字段。设置 `erpGoodsId = null` 时需要使用 `LambdaUpdateWrapper.set(Product::getErpGoodsId, null)` 强制写入。

### 9.4 为什么 qdb-sdk 改为 haha-parent 子模块？

原 `com.qdb:qdb-sdk:1.0.0` 是独立模块，与 `com.haha:qdb-sdk` 并存会导致 classpath 上有**两份** `GoodsApi.class`，旧版本优先加载 → NoSuchMethodError / ClassCastException。改为子模块统一 version，删除 Maven 本地旧 jar 后解决。

### 9.5 为什么 ERP 店铺绑定的是设备而不是门店？

ERP 中的店铺（自助售卖机）在业务上对应的是系统中的一个设备（售货机）。ERP 的 `shopCode` 字段存储的是设备 ID（`deviceId`），因此绑定关系建立在 `t_device` 表上。一键绑定时用 `shopCode` ↔ `deviceId` 做精确匹配。

### 9.6 为什么同步和自动绑定都要用 CASE WHEN 批量 UPDATE？

逐条 UPDATE 在数据量较大时性能很差（N 次网络往返）。`CASE WHEN` 将多条 UPDATE 合并为一条 SQL，每 100 行一批，大幅减少数据库交互次数。

---

## 十、待完成

| 项目 | 状态 | 说明 |
|------|------|------|
| 补货单推送 ERP | TODO | `ReplenishmentOrderServiceImpl.syncToErp()` 当前为 no-op |
| 商品增量同步 | 未实现 | 每次全量遍历本地商品，无时间戳过滤 |
| 店铺增量同步 | 未实现 | 每次全量翻页，无时间戳过滤 |

---

> 创建日期：2026-07-09
> 维护者：开发团队
