# 库存管理模块说明

> 本文档描述系统商品库存管理的完整架构，涵盖库存数据模型、服务层设计、库存变动流程、与订单/支付/退款模块的集成，以及幂等性保护机制。
> 帮助开发人员理解库存数据的生命周期和关键设计决策。

---

## 一、模块概述

### 1.1 库存管理范围

本系统的库存管理**完全在本地进行**，haha 平台不提供库存管理 API。两者的分工：

| 数据 | 管理方 | 说明 |
|------|--------|------|
| 商品信息（名称、条码、价格、图片） | haha 平台 → 本地同步 | `DataSyncService.syncProducts()` 定时拉取 |
| 设备列表 | haha 平台 → 本地同步 | `DataSyncService.syncDevices()` |
| 订单支付状态 | 本地 → haha 平台推送 | `notifyHahaOrderStatusChanged()` |
| **库存数量** | **纯本地管理** | haha 不存库存，不提供库存接口 |

### 1.2 库存维度

库存按 **设备 + 商品** 维度管理，即同一商品在不同设备上有独立的库存记录（`t_device_inventory` 表）。

### 1.3 为什么库存不放在 haha 平台？

haha 平台的职责是 AI 识别（开关门事件 + 商品识别结果）和设备管理。其 SDK 提供的 `GoodsApi` 和 `OrderApi` 均无库存查询/更新端点，商品 API 仅管理产品目录和上架状态。

### 1.4 库存初始化机制

库存数据有**两个入口**进入系统：

#### 入口一：人工上货（主要，创建实际库存）

```
管理员/补货员手动上货 → DeviceInventoryService.increaseStock()
    → 首次上货该商品 → 新建 t_device_inventory 记录（stock > 0）
    → 再次上货 → 累加 stock
```

人工上货创建的记录带有 **实际库存数量**（`stock > 0`），是唯一的物理库存来源。

#### 入口二：层模版同步（辅助，创建占位记录）

```
设备层模版同步 → syncFromHaha(deviceId) → initInventoryFromTemplate(deviceId, apiData)
    → 解析层模版中的商品编码
    → 查本地 Product 表确认商品存在
    → 查 t_device_inventory 确认记录不存在
    → 新建 t_device_inventory 记录（stock = 0）
```

层模版同步创建的记录 **stock 初始化为 0**，目的是：
- 让设备在库存列表中**提前可见**所有理论货道商品
- 为 `recalculateStandardStock` 提供记录载体（标准库存值写入已有记录）
- 已存在的人工上货记录**不受影响**

**创建条件**：

| 条件 | 行为 |
|------|------|
| 商品编码在本地 Product 表中不存在 | 跳过 |
| 该设备+商品已有库存记录 | 跳过（不覆盖已有 stock） |
| 该设备+商品无库存记录 | 新建，stock=0, warningThreshold=5 |

#### 不会创建库存的流程

| 流程 | 做了什么 | 为什么不创建库存 |
|------|---------|-----------------|
| 设备同步 | 拉取设备信息写入 `t_device` | haha 的 `DeviceInfo` 不含库存字段 |
| 商品同步 | 拉取商品目录写入 `t_product` | 商品信息不绑定设备和数量 |
| 商品上架 | 调用 haha API 设置可售卖商品 | 只是标记可售卖，不涉及数量 |

> **初始化策略总结**：人工上货是唯一写入实际库存（stock>0）的途径，层模版同步提供占位能力让运营平台提前看到设备货道商品列表。

---

### 1.5 标准库存重算（recalculateStandardStock）

层模版同步完成后，会根据层模版中的货道商品配置**重新计算**每台设备每个商品的 `standard_stock`（理论满柜库存量）。

**触发时机**：层模版创建 / 更新 / 删除 / 同步 操作完成后自动调用。

**计算逻辑**：

```
查询该设备所有层模版 (t_layer_template by deviceId)
  │
  ├─ 无层模版 → 所有库存记录的 standard_stock 置 0
  │
  └─ 有层模版 → 遍历每个 LayerTemplate:
        │
        ├─ 解析 leftFloors JSON → 遍历每层每个货道 → goodsItem.stock
        └─ 解析 rightFloors JSON → 遍历每层每个货道 → goodsItem.stock
              │
              └─ 按 productCode 累加 → standardMap.merge(code, stock, Integer::sum)
                 （同一商品跨层/跨门配置数量叠加）
        │
        └─ 遍历设备所有 t_device_inventory 记录:
              standard_stock = standardMap 中的累计值（无则为 0）
              → updateById()
```

**关键规则**：
- 同一商品在多个楼层或左右门都有配置 → **累加**各货道的摆放数量
- 层模版有但库存表没有的商品 → 不处理（由 `initInventoryFromTemplate` 预先建好记录）
- 库存表有但层模版没有的商品 → `standard_stock = 0`
- 每次全量覆盖，非增量更新

**用途**：standard_stock 作为理论满柜值，用于库存管理页面展示库存占比（`stock / standard_stock`），帮助运营判断缺货程度。

> **为什么不在同步时直接写入 stock？** 物理库存必须由实际补货操作确认，层模版是"应该放什么商品"的配置，不代表当前柜内真实存量。standard_stock 记录的是"满柜时应该有多少"，与实际 stock 分离。

---

## 二、数据库设计

### 2.1 核心表

#### `t_device_inventory` — 设备库存表

每台设备上每个商品一条记录，使用乐观锁保证并发安全。

| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | BIGINT | 主键，雪花算法 |
| `device_id` | VARCHAR(64) | 设备 SN |
| `product_id` | BIGINT | 商品 ID（关联 `t_product`） |
| `product_code` | VARCHAR(100) | haha 平台商品编码 |
| `product_name` | VARCHAR(200) | 商品名称快照 |
| `stock` | INT | **当前库存数量** |
| `standard_stock` | INT | **标准库存**（满柜理论值，由层模版同步自动重算） |
| `shelf_num` | INT | 货架层位置 |
| `position` | VARCHAR(50) | 通道位置（left/right） |
| `warning_threshold` | INT | 低库存预警阈值（默认 5） |
| `last_restock_time` | DATETIME | 最近补货时间 |
| `version` | INT | **乐观锁版本号**（MyBatis-Plus @Version） |
| `create_time` | DATETIME | 创建时间 |
| `update_time` | DATETIME | 更新时间 |

唯一约束：`(device_id, product_id)`

#### `t_inventory_log` — 库存变动日志表

记录每一次库存变动的审计跟踪，用于溯源和统计。

| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | BIGINT | 主键 |
| `device_id` | VARCHAR(64) | 设备 ID |
| `product_id` | BIGINT | 商品 ID |
| `product_code` | VARCHAR(100) | 商品编码 |
| `product_name` | VARCHAR(200) | 商品名称 |
| `change_type` | INT | 变动类型（见下表） |
| `change_quantity` | INT | 变动数量（正数增加，负数减少） |
| `before_stock` | INT | 变动前库存 |
| `after_stock` | INT | 变动后库存 |
| `order_id` | VARCHAR(64) | 关联订单号（销售时） |
| `activity_id` | VARCHAR(64) | 关联活动号 |
| `operator_id` | BIGINT | 操作人 ID |
| `operator_name` | VARCHAR(50) | 操作人名称 |
| `remark` | VARCHAR(500) | 备注 |
| `create_time` | DATETIME | 创建时间 |

变动类型常量：

| 常量 | 值 | 说明 |
|------|---|------|
| `TYPE_RESTOCK` | 1 | 上货增加 |
| `TYPE_SALE` | 2 | 销售减少 |
| `TYPE_ADJUST_ADD` | 3 | 调整增加 |
| `TYPE_ADJUST_SUB` | 4 | 调整减少 |
| `TYPE_INVENTORY` | 5 | 盘点调整 |

#### `t_stock_record` — 上货记录表（补货任务）

| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | BIGINT | 主键 |
| `device_id` | VARCHAR(64) | 设备 ID |
| `type` | INT | 类型：1=首次上货，2=补货 |
| `status` | INT | 状态：1=进行中，2=已完成，3=已取消 |
| `operator_id` | BIGINT | 操作人 ID |
| `activity_id` | VARCHAR(64) | 活动号 |
| `create_time` | DATETIME | 创建时间 |

#### `t_stock_record_item` — 上货记录商品明细

| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | BIGINT | 主键 |
| `stock_record_id` | BIGINT | 上货记录 ID |
| `product_id` | BIGINT | 商品 ID |
| `product_code` | VARCHAR(100) | 商品编码 |
| `product_name` | VARCHAR(200) | 商品名称 |
| `quantity` | INT | 上货数量 |
| `before_stock` | INT | 上货前库存 |
| `after_stock` | INT | 上货后库存 |

---

## 三、服务层设计

### 3.1 服务职责

| 服务 | 职责 |
|------|------|
| `DeviceInventoryService` | 库存核心 CRUD：增、减、调整、查询、统计 |
| `InventoryLogService` | 记录并查询库存变动日志，提供幂等性检查 |
| `StockRecordService` | 管理上货任务（创建、完成、取消） |
| `OrderInventoryService` | **桥梁服务**：连接订单/支付/退款与库存操作 |

### 3.2 核心方法

#### DeviceInventoryService

```
increaseStock()   → 补货入库，首次自动创建库存记录
decreaseStock()   → 销售出库，库存不足抛 BusinessException
adjustStock()     → 直接设置库存值，用于盘点/退款恢复
getByDeviceAndProduct() → 查询单条库存记录
getStatistics()   → 聚合统计（总记录数/总库存/低库存/零库存）
```

#### OrderInventoryService（桥梁服务）

```
decreaseStockOnPaid(Order)           → 扣减库存（幂等，识别时和支付时均可调用，先到达者生效）
restoreStockOnRefund(Order, List)    → 退款成功后恢复库存（支持部分退款，受 restoreStock 标志控制）
checkStockAvailable(deviceId, items) → 下单前校验库存是否充足
```

---

## 四、库存变动流程

### 4.1 补货入库流程

```
管理员/补货员 → POST /inventory/increase 或 POST /replenisher/stock/replenish
    → DeviceInventoryService.increaseStock()
        → 查询现有库存记录
        → 不存在则新建，存在则累加
        → InventoryLogService.logChange(TYPE_RESTOCK)
```

**特性**：首次为设备添加商品时自动创建库存记录；后续补货直接累加数量。

### 4.2 销售出库流程（AI 识别后立即扣减）

**核心原则**：用户关门那一刻，商品已物理离开柜子——库存应立即扣减，不等支付。支付是独立的财务环节，可能延迟甚至失败。

**主流程**（识别时触发）：

```
用户关门 → AI 识别完成 → handleOrcResult
    → handleConsume (OUT 类型，正常取出)
        → updateOrderFromRecognition(设置 items)
        → decreaseStockOnPaid(order) ← 立即扣减
            → 幂等性检查：查询 t_inventory_log 是否已有 TYPE_SALE 记录
            → 解析订单 items JSON，遍历商品
            → DeviceInventoryService.decreaseStock()
                → 扣减并记录 InventoryLog(TYPE_SALE)
    → IN 类型（异物放入）跳过，不扣库存
```

**兜底流程**（支付时触发，幂等性保证不会重复扣减）：

```
用户支付成功
    → 支付回调 / 支付分回调
        → decreaseStockOnPaid(order) ← 若识别时已扣，幂等检查跳过
```

**触发点汇总**：

| 阶段 | 触发路径 | 服务 | 方法 |
|------|---------|------|------|
| **识别时**（主） | AI 识别为 OUT 类型 | `HahaCallbackServiceImpl` | `handleConsume()` |
| 支付时（兜底） | 微信支付 V3 回调 | `PaymentServiceImpl` | `handleCallback()` |
| 支付时（兜底） | 支付分完结扣款成功 | `PayScoreServiceImpl` | `completeServiceOrder()` |
| 支付时（兜底） | 支付分 USER_PAID 回调 | `PayScoreServiceImpl` | `handleCallback()` |
| 支付时（兜底） | 支付分状态同步为 DONE | `PayScoreServiceImpl` | `syncPayScoreStatus()` |
| 支付时（兜底） | 支付分同步支付信息 | `PayScoreServiceImpl` | `syncOrderPaid()` |
| 支付时（兜底） | 订单回调中支付分扣费成功 | `HahaCallbackServiceImpl` | `processPayScorePayment()` |
| 支付时（兜底） | 直接完成订单（无支付分） | `OrderServiceImpl` | `completeOrderWithPayScore()` |

### 4.3 退款恢复库存流程（区分场景）

**核心原则**：库存代表柜内物理商品数量。只有商品**没被拿走**时才恢复库存（如 AI 识别多扣）；商品已被取走但退款（如质量问题）不应恢复。

通过 `t_refund.restore_stock` 字段控制（默认 1=恢复）：

| 场景 | 举例 | `restoreStock` | 行为 |
|------|------|----------------|------|
| **识别多扣** | 拿了 1 瓶水，AI 识别成 2 瓶 | 1（默认） | 退钱 + **恢复库存** |
| **订单错误** | 重复扣款、金额计算错误 | 1（默认） | 退钱 + **恢复库存**（用户实际没取走多余） |
| **质量问题** | 拿了商品，发现有质量问题要退款 | 0 | 只退钱，**不恢复库存**（商品已取走） |
| **用户不满** | 拿了商品，不满意要退款 | 0 | 只退钱，**不恢复库存**（商品已取走） |

```
管理员审核通过 / 直接退款成功
    → RefundServiceImpl.approveRefund() 或 markRefunded()
        → restoreStockForRefund(refund)
            → 检查 refund.restoreStock：
                → 0（不恢复）→ 跳过，仅记录日志
                → 1 或 null（恢复）→ 继续
            → 获取订单 + 退款商品明细（RefundItem 列表）
            → OrderInventoryService.restoreStockOnRefund(order, refundItems)
                → 逐商品 adjustStock(current + quantity)
                → 记录 InventoryLog(TYPE_ADJUST_ADD)
```

**支持部分退款**：按 RefundItem 明细逐商品恢复，只恢复退款的那部分数量。

---

## 五、幂等性保护

### 5.1 库存扣减幂等性

`decreaseStockOnPaid()` 在识别时和支付时都会被调用，通过查询 `t_inventory_log` 表保证只扣一次：

```java
long deducted = inventoryLogService.countByOrderIdAndType(orderNo, TYPE_SALE);
if (deducted > 0) {
    log.info("订单库存已扣减，跳过重复扣减: orderNo={}", orderNo);
    return;
}
```

**保护场景**：
- 识别时已扣减（主路径），支付回调到来时幂等跳过（兜底）
- 微信支付回调可能因网络重试多次推送
- 支付分回调与主动查询可能并发触发

**注意**：扣减失败不阻断主流程（外部 try-catch 包裹），确保库存问题不影响用户支付体验。

### 5.2 库存恢复幂等性

退款恢复依赖 `restoreStock` 标志控制是否执行。如果执行恢复，按 `RefundItem` 精确的商品明细操作，每次调用只恢复本次退款的商品数量。

**当前策略**：退款为管理员审核操作，重复调用概率极低。如需严格幂等，可在 `InventoryLog.remark` 中记录 `refundId` 实现精准去重。

---

## 六、API 端点

### 6.1 管理后台（InventoryController）

| 端点 | 方法 | 用途 | 权限 |
|------|------|------|------|
| `/inventory/device-stats` | GET | 按设备分组的库存统计 | `inventory:view` |
| `/inventory/list` | GET | 库存列表（分页，可筛选） | `inventory:view` |
| `/inventory/device/{deviceId}` | GET | 单台设备库存详情 | `inventory:view` |
| `/inventory/low-stock` | GET | 低库存商品列表 | `inventory:view` |
| `/inventory/statistics` | GET | 汇总统计 | `inventory:view` |
| `/inventory/increase` | POST | 增加库存（上货） | `inventory:update` |
| `/inventory/adjust` | POST | 调整库存 | `inventory:update` |
| `/inventory/logs` | GET | 库存变动日志 | `inventory:view` |
| `/inventory/logs/statistics` | GET | 库存变动统计 | `inventory:view` |
| `/inventory/records` | GET | 上货记录列表 | `inventory:view` |
| `/inventory/records/{id}` | GET | 上货记录详情 | `inventory:view` |
| `/inventory/records` | POST | 创建上货记录（V1） | `inventory:update` |
| `/inventory/records/v2` | POST | 创建上货记录（V2） | `inventory:update` |
| `/inventory/records/{id}/complete` | PUT | 完成上货记录 | `inventory:update` |
| `/inventory/records/{id}/cancel` | PUT | 取消上货记录 | `inventory:update` |
| `/inventory/records/stocker-statistics` | GET | 补货员统计 | `inventory:view` |

### 6.2 补货员端（ReplenisherOperationController）

| 端点 | 方法 | 用途 |
|------|------|------|
| `/replenisher/my-info` | GET | 当前补货员信息 |
| `/replenisher/device/list` | GET | 补货员管辖的设备列表（含库存概览） |
| `/replenisher/device/inventory/{deviceId}` | GET | 单台设备库存详情 |
| `/replenisher/stock/replenish` | POST | 执行补货操作 |

---

## 七、低库存预警

系统支持低库存预警机制：

- 每个库存记录的 `warning_threshold` 字段（默认值 5）
- 当 `stock <= warning_threshold` 时，该商品出现在低库存列表中
- 管理后台 `/inventory/low-stock` 和统计面板可查看低库存情况
- 设备库存统计页按设备汇总：正常 / 低库存 / 缺货 三种状态

---

## 八、关键设计决策

| 决策 | 原因 |
|------|------|
| **识别时立即扣减** | 关门时商品已物理离开柜子，库存应即时反映；不等支付（可能延迟/失败） |
| 支付时兜底扣减 | 若识别时未扣（如异常跳过），支付回调仍会触发；幂等性保证不重复 |
| 库存扣减不阻断主流程 | 库存问题不应影响用户支付/取货体验；异常仅记录日志 |
| **退款恢复区分场景** | `restore_stock` 标志区分"识别多扣退库存"和"质量问题不退库存"，避免恢复不该恢复的商品 |
| 乐观锁而非悲观锁 | `t_device_inventory` 有 `@Version` 字段，MyBatis-Plus 自动处理并发冲突 |
| 幂等性通过日志表实现 | 不在订单表增加 `stock_deducted` 字段，避免侵入；日志表本身就是审计需求 |
| 退款恢复用 `adjustStock` | 相比 `increaseStock`，不需要补货所需的 shelf_num/position 等字段 |
| 库存纯本地管理 | haha 平台不提供库存 API，且本地管理可灵活控制预警阈值、统计维度 |
| **层模版同步自动初始化** | 层模版同步时为设备中配置的商品自动创建 stock=0 的库存占位记录，已存在的人工上货记录不受影响 |
| **标准库存自动重算** | 层模版创建/更新/同步后自动调用 `recalculateStandardStock`，根据货道配置重算每个商品的理论满柜库存值 |
| **哈哈库存首次导入策略** | 如哈哈后续提供设备库存数据：首次同步做初始化，后续同步丢弃以本地为准 |

---

## 九、相关文件索引

### 实体
- `haha-entity/.../entity/DeviceInventory.java`
- `haha-entity/.../entity/InventoryLog.java`
- `haha-entity/.../entity/StockRecord.java`
- `haha-entity/.../entity/StockRecordItem.java`
- `haha-entity/.../entity/Refund.java` — 含 `restoreStock` 标志
- `haha-entity/.../entity/RefundItem.java`

### 服务
- `haha-service/.../service/DeviceInventoryService.java`
- `haha-service/.../service/impl/DeviceInventoryServiceImpl.java`
- `haha-service/.../service/InventoryLogService.java`
- `haha-service/.../service/impl/InventoryLogServiceImpl.java`
- `haha-service/.../service/OrderInventoryService.java`
- `haha-service/.../service/impl/OrderInventoryServiceImpl.java`
- `haha-service/.../service/StockRecordService.java`
- `haha-service/.../service/impl/StockRecordServiceImpl.java`
- `haha-service/.../service/RefundService.java`
- `haha-service/.../service/impl/RefundServiceImpl.java`

### 控制器
- `haha-admin/.../controller/InventoryController.java`
- `haha-admin/.../controller/ReplenisherOperationController.java`

### SQL
- `haha-admin/.../sql/inventory.sql`
- `haha-admin/.../sql/inventory_test_data.sql`
- `haha-admin/.../db/migration/V5__create_refund_tables.sql`
- `haha-admin/.../db/migration/V6__add_refund_restore_stock.sql`

### 集成点
- `haha-service/.../service/impl/HahaCallbackServiceImpl.java` — **主路径**：AI 识别后立即扣减库存 + 支付分扣费兜底
- `haha-service/.../service/payment/impl/PaymentServiceImpl.java` — 微信支付回调兜底扣减
- `haha-service/.../service/payment/payscore/impl/PayScoreServiceImpl.java` — 支付分 4 个路径兜底扣减
- `haha-service/.../service/impl/OrderServiceImpl.java` — 直接完成订单兜底扣减
- `haha-service/.../service/impl/RefundServiceImpl.java` — 退款恢复库存（受 restoreStock 标志控制）

### DTO / VO
- `haha-entity/.../dto/RefundDTO.java` — 管理员退款请求，含 `restoreStock` 字段
- `haha-common/.../vo/RefundApplicationVO.java` — 退款详情响应，含 `restoreStock` 字段
