# 上货员（补货员）模块说明

> 本文档描述系统上货员模块的完整架构，涵盖独立补货员体系、微信绑定流程、补货操作流程及相关实现。
> 帮助开发人员理解"为什么这样设计"以及各环节的特殊处理方式。

---

## 一、模块概述

### 1.1 什么是上货员/补货员？

上货员（系统内称"补货员"，Replenisher）指负责为智能售卖柜补货的工作人员。他们通过手机小程序查看库存、执行补货操作。

### 1.2 独立补货员体系（V2）

系统使用独立补货员体系，核心表：

| 体系 | 数据表 | 说明 |
|------|--------|------|
| **独立补货员** | `t_replenisher` + `t_replenisher_device` | 独立账号体系，设备级绑定粒度，微信扫码绑定 |

> **历史背景**：早期存在基于 `t_shop_replenisher` + `t_admin` 的 V1 体系（补货员需是系统管理员），已于 2026年4月 完成迁移清理，V1 表 `t_shop_replenisher` 及所有相关代码已彻底移除。

### 1.3 为什么使用独立补货员体系？

独立补货员体系相比旧的 V1（基于 `t_admin` 管理员表）解决了以下问题：
- 补货员必须是系统管理员，具备过多后台权限
- 粒度仅到门店级别，无法精确控制设备权限
- 全凭管理员账号密码登录，补货员使用体验差

V2 独立补货员体系的核心优势：
- **独立账号**：`t_replenisher` 表独立管理，不与管理员混合
- **设备级绑定**：通过 `t_replenisher_device` 表实现设备级粒度绑定
- **微信扫码绑定**：后台创建账号 → 生成绑定码 → 微信扫码绑定，纯手机端操作

---

## 二、数据库设计

### 2.1 核心表

#### `t_replenisher` — 补货员表

| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | BIGINT | 主键，雪花算法 |
| `name` | VARCHAR(50) | 姓名 |
| `phone` | VARCHAR(20) | 手机号（可选） |
| `employee_id` | VARCHAR(50) | 工号（可选） |
| `wechat_openid` | VARCHAR(100) | 微信 OpenID（扫码绑定时回填） |
| `avatar` | VARCHAR(500) | 头像 URL |
| `status` | INT | 状态：1-启用，0-禁用 |
| `total_tasks` | INT | 累计任务数 |
| `last_task_time` | DATETIME | 最后任务时间 |
| `create_time` | DATETIME | 创建时间 |
| `update_time` | DATETIME | 更新时间 |

#### `t_replenisher_device` — 补货员-设备关联表

| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | BIGINT | 主键，雪花算法 |
| `replenisher_id` | BIGINT | 补货员ID |
| `device_id` | VARCHAR(50) | 设备SN号 |
| `source` | VARCHAR(20) | 绑定来源：`MANUAL`-手动，`QR`-扫码绑定，`SHOP_INHERIT`-门店继承 |
| `create_time` | DATETIME | 创建时间 |

### 2.2 补货记录表

#### `t_stock_record` — 上货/补货记录表

| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | BIGINT | 主键，雪花算法 |
| `device_id` | VARCHAR(50) | 设备ID |
| `activity_id` | VARCHAR(100) | 哈哈平台活动号 |
| `stock_type` | INT | 类型：1-上货，2-补货 |
| `stocker_id` | BIGINT | 上货员ID |
| `stocker_name` | VARCHAR(50) | 上货员名称 |
| `stocker_phone` | VARCHAR(20) | 上货员电话 |
| `total_items` | INT | 上货商品种类数 |
| `total_quantity` | INT | 上货总数量 |
| `status` | INT | 状态：1-进行中，2-已完成，3-已取消 |
| `start_time` | DATETIME | 开始时间 |
| `end_time` | DATETIME | 结束时间 |
| `remark` | VARCHAR(500) | 备注 |
| `create_time` | DATETIME | 创建时间 |
| `update_time` | DATETIME | 更新时间 |

#### `t_stock_record_item` — 补货明细表

| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | BIGINT | 主键，雪花算法 |
| `record_id` | BIGINT | 上货记录ID |
| `device_id` | VARCHAR(50) | 设备ID |
| `product_id` | BIGINT | 商品ID |
| `product_code` | VARCHAR(50) | 商品编码 |
| `product_name` | VARCHAR(100) | 商品名称 |
| `shelf_num` | INT | 货架层号 |
| `quantity` | INT | 上货数量 |
| `before_stock` | INT | 上货前库存 |
| `after_stock` | INT | 上货后库存 |
| `create_time` | DATETIME | 创建时间 |

### 2.3 数据迁移

`docs/database/migrate_replenisher.sql` 提供了从旧表 `t_stocker` 到 `t_replenisher` 的数据迁移脚本。

> V1 表 `t_shop_replenisher` 及所有相关后端/前端代码已于 2026年4月 彻底清理移除。

---

## 三、后端实现

### 3.1 模块目录结构

```
haha-entity
├── Replenisher.java               # 补货员实体
├── ReplenisherDevice.java          # 补货员-设备关联实体
├── StockRecord.java                # 补货记录实体
├── StockRecordItem.java            # 补货明细实体
├── dto/
│   ├── ReplenisherCreateDTO.java   # 创建补货员
│   ├── ReplenisherUpdateDTO.java   # 更新补货员
│   ├── ReplenisherQueryDTO.java    # 分页查询参数
│   ├── ReplenisherBindDTO.java     # 微信扫码绑定
│   ├── BindReplenisherDTO.java     # 绑定补货员到设备/门店
│   ├── ReplenishDTO.java           # 执行补货操作
│   ├── BindDeviceDTO.java          # 绑定设备
│   ├── BatchBindDevicesDTO.java    # 批量绑定设备
│   ├── StatusDTO.java              # 通用状态更新
│   └── WechatLoginDTO.java         # 微信登录

haha-mapper
├── ReplenisherMapper.java          # 补货员 Mapper
├── ReplenisherDeviceMapper.java    # 关联 Mapper
├── resources/mapper/
│   ├── ReplenisherMapper.xml       # 分页带设备绑定数查询
│   └── ReplenisherDeviceMapper.xml # 批量插入忽略已存在

haha-service
├── ReplenisherService.java         # 补货员服务接口
├── impl/
│   └── ReplenisherServiceImpl.java # 补货员服务实现

haha-admin (运营平台)
├── controller/
│   ├── ReplenisherController.java          # 补货员 CRUD 管理
│   ├── ReplenisherLoginController.java     # 微信登录/绑定
│   └── ReplenisherOperationController.java # 补货操作（设备查询/库存/补货）

haha-admin-web (前端)
├── src/api/replenisher.ts                  # API 接口层
├── src/views/replenisher/
│   ├── index.vue                           # 补货员管理页面
│   └── utils/
│       ├── hook.tsx                        # 逻辑钩子
│       └── types.ts                        # 类型定义

haha-admin-mp (管理端小程序)
├── src/api/replenish.ts                    # 补货员 API
├── src/pages/
│   ├── login/login.vue                     # 微信登录 + 绑定码入口
│   └── replenish/
│       ├── bind.vue                        # 微信绑定页
│       └── index.vue / ...                 # 补货工作台

haha-mp (用户端小程序)
├── src/api/replenish.ts                    # 补货员 API
├── src/pages/replenish/bind.vue            # 微信绑定页
```

### 3.2 核心实体关系

```
┌─────────────┐     ┌──────────────────────┐     ┌──────────┐
│ t_replenisher│────<│ t_replenisher_device │────>│ t_device │
│  (补货员)    │     │  (补货员-设备关联)    │     │  (设备)   │
└─────────────┘     └──────────────────────┘     └──────────┘
       │
       │ (wechat_openid 绑定)
       │
  ┌────┴─────┐
  │ 微信 OpenID│
  └──────────┘
```

### 3.3 API 接口总览

#### 3.3.1 补货员管理（运营平台，需要管理员权限）

| 接口 | 方法 | 路径 | 权限 | 说明 |
|------|------|------|------|------|
| 分页列表 | GET | `/replenishers/list` | `replenisher:read` | 关键词+状态筛选，含设备绑定数 |
| 详情 | GET | `/replenishers/{id}` | `replenisher:read` | 含设备绑定数 |
| 搜索 | GET | `/replenishers/search` | `replenisher:read` | 仅搜索启用状态，用于下拉选择 |
| 创建 | POST | `/replenishers` | `replenisher:create` | 姓名必填，手机号可选 |
| 更新 | PUT | `/replenishers/{id}` | `replenisher:update` | 仅更新非空字段 |
| 状态 | PUT | `/replenishers/{id}/status` | `replenisher:update` | 启用/禁用 |
| 删除 | DELETE | `/replenishers/{id}` | `replenisher:delete` | 级联解绑设备 |
| 绑定设备 | POST | `/replenishers/{id}/devices` | `replenisher:update` | 绑定设备到补货员 |
| 解绑设备 | DELETE | `/replenishers/{id}/devices/{deviceId}` | `replenisher:update` | 解绑单个设备 |
| 批量绑定 | POST | `/replenishers/batch-bind` | `replenisher:update` | 多补货员→多设备 |
| **生成绑定码** | POST | `/replenishers/{id}/binding-code` | `replenisher:update` | **生成微信绑定码** |

#### 3.3.2 门店补货员关联（运营平台）

| 接口 | 方法 | 路径 | 权限 | 说明 |
|------|------|------|------|------|
| 获取门店补货员 | GET | `/shops/{id}/replenishers` | `shop:read` | 通过设备绑定反向推导门店关联的补货员 |
| 绑定到门店 | POST | `/shops/{id}/replenishers` | `shop:update` | 将补货员绑定到门店下的所有设备 |

#### 3.3.3 补货员登录/绑定（免登录）

| 接口 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 微信静默登录 | POST | `/replenisher/login/wechat` | 已绑定微信的补货员直接登录 |
| 扫码绑定微信 | POST | `/replenisher/login/bind` | 绑定码 + 微信凭证进行绑定 |

#### 3.3.4 补货员操作（需补货员身份认证）

| 接口 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 获取个人信息 | GET | `/replenisher/my-info` | 含设备绑定数 |
| 设备列表 | GET | `/replenisher/device/list` | 含库存概览 |
| 设备库存 | GET | `/replenisher/device/inventory/{deviceId}` | 详细库存 |
| 执行补货 | POST | `/replenisher/stock/replenish` | 增加库存 |

---

## 四、微信绑定流程

### 4.1 流程概述

```
┌─────────────────────────────────────────────────────────────────┐
│                    补货员微信绑定流程                              │
│                                                                 │
│  ┌──────────┐    ┌──────────────┐    ┌──────────────────┐      │
│  │ 管理员后台 │───>│ 生成一次性绑定码│───>│ 展示二维码/绑定码  │      │
│  │ 创建补货员 │    │ (Redis 24h)  │    │ (前端弹窗展示)   │      │
│  └──────────┘    └──────────────┘    └────────┬─────────┘      │
│                                               │                │
│                                               ▼                │
│  ┌──────────┐    ┌──────────────┐    ┌──────────────────┐      │
│  │ 绑定成功  │<───│ 存入openid +  │<───│ 补货员扫码/输入   │      │
│  │ 自动登录  │    │ 删除绑定码    │    │  + 微信授权      │      │
│  └──────────┘    └──────────────┘    └──────────────────┘      │
└─────────────────────────────────────────────────────────────────┘
```

### 4.2 详细步骤

**1. 管理员创建补货员**
- 运营平台后台 → 补货员管理 → 创建补货员
- 输入姓名、手机号（可选）、工号（可选）
- 创建后状态为启用，`wechat_openid` 为空

**2. 管理员生成绑定码**
- 在补货员列表操作列点击"绑定码"
- 后端生成 24 位 UUID 大写绑定码，存入 Redis（24 小时 TTL）
- 前端弹窗展示绑定码和二维码（小程序路径 `pages/replenish/bind?code=xxx`）
- 同一补货员如已绑定微信则拒绝生成

**3. 补货员扫码绑定**
- 打开管理端小程序（`haha-admin-mp`）或用户端小程序（`haha-mp`）
- 扫描二维码或手动输入绑定码
- 点击"绑定微信" → `uni.login()` 获取微信 code
- 调用 `POST /replenisher/login/bind` 接口，传入 `bindingCode` + `code`
- 后端验证 Redis 绑定码 → `jscode2session` 获取 openid → 检查 openid 唯一性 → 存入补货员记录 → 删除 Redis 绑定码 → 自动登录

**4. 后续登录**
- 已绑定微信的补货员可直接使用微信静默登录 `POST /replenisher/login/wechat`
- 后端通过 `wechat_openid` 定位补货员 → 检查状态 → Sa-Token 登录

### 4.3 安全机制

| 机制 | 说明 |
|------|------|
| **绑定码一次性有效** | 使用后立即从 Redis 删除 |
| **绑定码过期** | Redis TTL 24 小时 |
| **openid 唯一性检查** | 绑定和登录时都检查 openid 是否已被其他账号占用 |
| **禁止重复绑定** | 已绑定微信的补货员不能再生成绑定码 |
| **账号状态检查** | 禁用状态的补货员无法登录 |

### 4.4 微信配置

补货员使用**管理端微信小程序**的 AppId/Secret：

```yaml
wechat:
  admin-miniapp:
    app-id: xxxxxxxxxxxxxx
    secret: xxxxxxxxxxxxxx
```

通过 `jscode2session` 接口获取 openid：

```
GET https://api.weixin.qq.com/sns/jscode2session
  ?appid={appid}
  &secret={secret}
  &js_code={code}
  &grant_type=authorization_code
```

### 4.5 认证方式

补货员登录使用 **Sa-Token 同一域名**（与运营平台共享 `adminAccessToken`），通过 Token Session 中的 `userType` 字段区分身份：

```java
StpUtil.login(replenisher.getId());
StpUtil.getTokenSession().set("userType", "REPLENISHER");
```

登录成功后返回 `LoginVO`：

```json
{
  "token": "sa-token-value",
  "userInfo": {
    "id": "182734...",
    "nickname": "张三",
    "avatar": "https://...",
    "phone": "138..."
  }
}
```

补货员操作接口通过 `getCurrentReplenisher()` 方法验证身份：
```java
String userType = StpUtil.getTokenSession().getString("userType");
if (!"REPLENISHER".equals(userType)) {
    throw new BusinessException(403, "无访问权限");
}
```

---

## 五、补货操作流程

### 5.1 补货流程

```
┌──────────┐    ┌──────────────┐    ┌──────────────────┐    ┌──────────┐
│ 登录小程序 │───>│ 查看绑定设备   │───>│ 查看设备库存详情   │───>│ 执行补货  │
│ 微信静默   │    │ 列表含库存概览 │    │ 含商品库存明细    │    │ 增加库存  │
└──────────┘    └──────────────┘    └──────────────────┘    └──────────┘
```

### 5.2 补货 API

**请求**：
```json
POST /replenisher/stock/replenish
{
  "deviceId": "B142977",
  "items": [
    {
      "productId": 123456,
      "productCode": "6921234567890",
      "productName": "矿泉水550ml",
      "quantity": 24,
      "shelfNum": 1,
      "position": "A1"
    }
  ]
}
```

**响应**：
```json
{
  "code": 200,
  "message": "补货完成，成功1项，失败0项",
  "data": {
    "total": 1,
    "success": 1,
    "details": [
      {
        "productId": 123456,
        "productName": "矿泉水550ml",
        "quantity": 24,
        "afterStock": 120,
        "success": true
      }
    ]
  }
}
```

### 5.3 安全校验

补货操作前进行权限验证：
1. 验证当前用户是否为补货员（通过 `userType` 身份标识）
2. 验证设备是否绑定到此补货员（从 `t_replenisher_device` 查询）
3. 逐项执行补货，每项独立事务，失败不影响其他项

---

## 六、前端实现

### 6.1 管理后台（haha-admin-web）

- **页面**：`src/views/replenisher/index.vue` — 补货员管理页面
- **API**：`src/api/replenisher.ts` — 全部 CRUD + 绑定设备 + 生成绑定码
- **功能**：
  - 补货员分页列表（含设备绑定数）
  - 创建/编辑/删除补货员
  - 启用/禁用
  - 绑定/解绑设备
  - **生成绑定码弹窗**（展示 24 位绑定码 + 二维码）
- **依赖**：`qrcode` npm 包用于生成二维码图片

### 6.2 管理端小程序（haha-admin-mp）

- **登录页**：`src/pages/login/login.vue`
  - 微信一键登录（`POST /replenisher/login/wechat`）
  - 未绑定提示 → 展开绑定码输入区域
  - 绑定码 + 微信授权 → 绑定登录
- **绑定页**：`src/pages/replenish/bind.vue`
  - 从 `options.code` / `scene` 获取绑定码
  - 绿色清新 UI，绑定流程指引
  - 微信授权 → 绑定 API → 自动跳转
- **补货工作台**：`src/pages/replenish/` 目录下
  - 设备列表
  - 库存查看
  - 执行补货

### 6.3 用户端小程序（haha-mp）

- **绑定页**：`src/pages/replenish/bind.vue`
  - 金色品牌风格 UI，三步骤流程指引
  - 支持 `options.code` / `options.bindingCode` / `options.scene` 获取绑定码
  - 绑定成功后保存 token 并跳转首页
  - 已加入免登录白名单

---

## 七、Redis 键设计

| Key | 格式 | 说明 | TTL |
|-----|------|------|-----|
| 补货员绑定码 | `replenisher:bind:code:{bindingCode}` | Value 为补货员 ID | 24 小时 |

定义在 `RedisConstants.java`：
```java
public static final String REPLENISHER_BIND_CODE_KEY = "replenisher:bind:code:%s";
public static final String REPLENISHER_BIND_CODE_KEY_PREFIX = "replenisher:bind:code:";
```

---

## 八、关键设计决策

### 8.1 为什么不允许主动注册？

**决策**：补货员不允许自主注册，必须由管理员在后台创建后，通过绑定码完成微信绑定。

**原因**：
- 补货员属于内部人员，需要经过公司审核和培训
- 设备绑定权限不可随意分配（关系到库存数据和设备安全）
- 避免未授权的第三方通过扫描二维码自动注册

### 8.2 为什么使用 Redis 存储绑定码而非数据库？

- 绑定码是一次性凭证，无需持久化存储
- Redis 自动过期（TTL）机制天然适合此类场景
- 避免数据库表膨胀（绑定码数据量少但频繁生成/删除）
- 高性能读写，不增加数据库压力

### 8.3 为什么补货员使用管理端小程序的 AppId？

补货员和管理端共用同一套微信小程序（`haha-admin-mp`），使用管理端小程序的 AppId/Secret 进行微信登录。这意味着：
- 补货员和管理员扫码的是同一个微信小程序
- 通过 `userType` 区分身份和权限
- 补货员无法访问管理后台页面

### 8.4 补货操作的事务处理

补货操作的 `increaseStock` 方法内部通过 `synchronized (deviceId.intern())` 实现设备级锁，防止同一设备并发补货导致库存数据不一致。每条商品补货独立执行，失败不影响其他商品。

---

## 九、SQL 参考

### 9.1 补货员分页查询（含设备绑定数）

```xml
<select id="selectWithDeviceCount" resultMap="BaseResultMap">
    SELECT r.*,
           (SELECT COUNT(*) FROM t_replenisher_device rd WHERE rd.replenisher_id = r.id) AS bound_device_count
    FROM t_replenisher r
    <where>
        <if test="keyword != null and keyword != ''">
            AND (r.name LIKE CONCAT('%', #{keyword}, '%')
                 OR r.phone LIKE CONCAT('%', #{keyword}, '%')
                 OR r.employee_id LIKE CONCAT('%', #{keyword}, '%'))
        </if>
        <if test="status != null">
            AND r.status = #{status}
        </if>
    </where>
    ORDER BY r.create_time DESC
</select>
```

### 9.2 通过设备查询补货员

```sql
SELECT DISTINCT r.* FROM t_replenisher r
INNER JOIN t_replenisher_device rd ON r.id = rd.replenisher_id
INNER JOIN t_device d ON rd.device_id = d.device_id
WHERE d.shop_id = #{shopId}
ORDER BY r.create_time DESC
```

### 9.3 批量插入关联（忽略已存在）

```sql
INSERT IGNORE INTO t_replenisher_device (id, replenisher_id, device_id, source, create_time)
VALUES (?, ?, ?, ?, ?)
```
