# 商品出库 — 技术方案

> **依据：** 《商品出库功能需求.md》v1.0.1  
> **关联：** 《关联需求分析.md》v1.6；平台《订单管理功能需求》v1.0.1、《商品管理功能需求》v1.3.3；商家《商品列表功能需求》v1.4、《全部订单功能需求》、《商品入库技术方案》v1.0、《库存调整功能需求-草稿》、《库存查询功能需求-草稿》、《库存日志功能需求-草稿》、《售后管理功能需求》v1.0、《商家端店铺上下文接口说明》  
> **范围：** 商家端 **库存管理 · 商品出库** — 出库单列表/检索/详情、**手工出库**、**支付成功触发的自动出库（留痕）**、**删除商品触发的自动出库（留痕）**；**不含** 调整/库存查询页（兄弟模块复用 **`biz_stock_log`**）。  
> **原则：** `X-Shop-Id` 店铺隔离；**手工出库改库存**；**自动出库（下单扣减）仅留痕**（库存已在支付成功时扣减）；**一张订单一张出库单**；单据 **确认后不可改**。

---

## 1. 技术架构

| 项 | 选型 |
|----|------|
| 基础框架 | RuoYi **v3.9.2**（`springboot2` 分支） |
| 数据库 | **MySQL 5.7.39** |
| ORM / 权限 / 响应 | MyBatis；`@PreAuthorize`；`AjaxResult` / `TableDataInfo` |
| 店铺上下文 | `SellerShopContextInterceptor` + **`X-Shop-Id`** |
| 出库单号 | **OUT** + yyyyMMdd + 6 位序号（DB `MAX` 前缀，同 `StockSupport.nextInboundNo`） |
| 事务 | 手工出库：**单事务**（主表 + 明细 + 减库存 + 日志）；自动出库：**与支付/删除同事务** |
| 日志 | 手工出库 `@Log`；全链路写 **`biz_stock_log`**（与入库 **共用**，已建） |

### 1.1 与兄弟模块 / 平台边界

| 维度 | 本模块 | 协作模块 |
|------|--------|----------|
| 路径 | **`/agri/seller/stock/outbound`** | 入库 `/agri/seller/stock/inbound`（已有） |
| 改库存 | **手工出库** `stock -= qty` | 支付扣减 `deductStock`（已有）；调整模块（待建） |
| 自动出库 | **INSERT 出库单 + log**；**不减 stock** | **支付成功** 已 `deductStock` |
| 删除留痕 | **INSERT 出库单 + log**；变化量 **0** | **商品逻辑删除** 触发 |
| 库存流水 | 写 **`biz_stock_log`** | 入库/调整/关单回滚 **同表** |
| 关单回滚 | **不产生出库单** | 订单协作写 **入库类** 日志 |
| 平台端 | **无菜单** | — |

### 1.2 协作链

```text
【C 端支付成功】OrderPaySuccessServiceImpl.completeWeChatPay
    → deductStock（唯一写入口，支付场景）
    → StockOutboundFacade.createOrderDeductOutbound（一张单、多行）
    → INSERT biz_stock_outbound + item + biz_stock_log（不再 deductStock）
            ↓
【商品删除】GoodsServiceImpl.deleteSellerGoodsBatch
    → logicDeleteByIds
    → StockOutboundFacade.createDeleteSpecOutbound（一张单、多行；change_qty=0）
            ↓
【本模块】GET list / GET detail / POST create（手工）
    → 校验 → OUT 单号 → outbound + item
    → decrementStock（每条明细，手工才执行）
    → biz_stock_log
            ↓
【库存查询 / 库存日志】读 biz_goods.stock、biz_stock_log（待建页面）
```

| 关联 | 协作 |
|------|------|
| **订单支付** | `OrderPaySuccessServiceImpl` 扣库存成功后调 **`IStockOutboundFacade.createOrderDeductOutbound`**；**幂等**靠 `uk_ref_order_no` |
| **商品列表** | `GoodsServiceImpl.deleteSellerGoodsBatch` 成功后调 **`createDeleteSpecOutbound`** |
| **商品入库** | 对称模块；**退货入库** 不走本模块 |
| **发货** | **不调用** 本模块；**不重复** 扣库存 |
| **售后** | **不自动** 出库；验收后走 **入库 · 退货入库** |
| **库存调整** | 写 log（reason=库存编辑），**不产生** outbound 单 |

### 1.3 模块落位

**菜单：** 库存管理 → 商品出库  
**包路径：** `com.ruoyi.web.modules.stock`（与入库 **同包扩展**）

```text
baqing-shop/src/main/java/com/ruoyi/web/modules/stock/
├── controller/SellerStockOutboundController.java   # 新增
├── service/
│   ├── IStockOutboundService.java                  # 新增
│   ├── IStockOutboundFacade.java                   # 新增：供 order/goods 调自动出库
│   └── impl/
│       ├── StockOutboundServiceImpl.java           # 新增
│       └── StockOutboundFacadeImpl.java            # 新增
├── domain/
│   ├── BizStockOutbound.java                       # 新增
│   └── BizStockOutboundItem.java                   # 新增
├── dto/
│   ├── StockOutboundQuery.java
│   ├── StockOutboundCreateDTO.java
│   ├── OrderDeductOutboundCommand.java             # Facade 入参
│   └── DeleteSpecOutboundCommand.java
├── vo/ …（ListRow / Detail / Item / 复用 StockGoodsPickVO、StockOperatorVO）
├── mapper/
│   ├── BizStockOutboundMapper.java
│   └── BizStockOutboundItemMapper.java
├── constant/StockConstants.java                    # 扩展 outbound 枚举
└── support/StockSupport.java                       # + nextOutboundNo / outboundTypeText

sql/biz_stock_outbound.sql                          # 权威 DDL（outbound 两表）
sql/biz_stock_inbound.sql                           # 已有 biz_stock_log

# 协作改动
order/service/impl/OrderPaySuccessServiceImpl.java  # 支付成功后调 facade
goods/service/impl/GoodsServiceImpl.java            # 批量删除成功后调 facade
goods/mapper/BizGoodsMapper.java                    # + decrementStockForOutbound
```

**前端（建议）：**

| 项 | 路径 |
|----|------|
| 列表 | `ruoyi-ui/src/views/agri/seller/stock/outbound/index.vue` |
| 出库弹窗 | 同上或 `outbound/form.vue` |
| API | `ruoyi-ui/src/api/agri/seller/stock/outbound.js` |

---

## 2. 数据库设计

**新建表：** `biz_stock_outbound`、`biz_stock_outbound_item`；**复用** `biz_stock_log`（[`sql/biz_stock_inbound.sql`](../../../../sql/biz_stock_inbound.sql)）。  
**权威 DDL：** [`sql/biz_stock_outbound.sql`](../../../../sql/biz_stock_outbound.sql)

### 2.1 表 `biz_stock_outbound`（出库单主表）

| 字段 | 类型 | 说明 |
|------|------|------|
| outbound_id | bigint PK | 主键 |
| outbound_no | varchar(32) UK | 出库单号；**OUT**+yyyyMMdd+6 位 |
| shop_id | bigint | 店铺；**= X-Shop-Id** |
| outbound_type | char(1) | 见 §2.4 |
| ref_order_no | varchar(32) UK NULL | **关联 C 端订单号**；type=`0` **必填**；供 keyword 检索 |
| remark | varchar(200) | 备注；自动单可空 |
| operator_id | bigint | 经办人；系统单 **0** |
| operator_name | varchar(64) | 快照；系统单 **「系统」** |
| outbound_time | datetime | 出库时间（确认时刻） |
| create_by / create_time | | 审计 |

**索引：** `uk_outbound_no`；`uk_ref_order_no`（支付幂等）；`idx_shop_time`；`idx_shop_type`。

> **无 del_flag / 无业务 update**：确认后只读。

### 2.2 表 `biz_stock_outbound_item`（出库明细）

| 字段 | 类型 | 说明 |
|------|------|------|
| item_id | bigint PK | |
| outbound_id | bigint | FK → outbound |
| goods_id | bigint | |
| sku_id | bigint NULL | 多规格预留；首期 **NULL** |
| goods_name | varchar(200) | 快照 |
| main_pic | varchar(512) | 快照 |
| spec_text | varchar(256) | 单规格 **空或「—」** |
| quantity | int | 出库数量；**删除规格 type=1 为 0** |
| stock_before | int | 该行确认前可用库存 |
| stock_after | int | 该行确认后可用库存 |

**索引：** `idx_outbound_id`；`idx_goods_id (goods_id, outbound_id)`。

### 2.3 表 `biz_stock_log`（复用 · 出库写入）

与入库方案一致；本模块写入 **`direction=2`**，`ref_type=OUTBOUND`，`ref_id=outbound_id`，`ref_no=outbound_no`。

| change_reason | direction | 触发 |
|---------------|-----------|------|
| `ORDER_DEDUCT` | 2 | 支付自动出库（留痕） |
| `DELETE_SPEC` | 2 | 删除商品自动出库（**change_qty=0**） |
| `MANUAL_ORDER_OUT` | 2 | 手工 · 订单出库 |
| `EXPIRED_OUT` | 2 | 手工 · 过期出库 |
| `OTHER_OUT` | 2 | 手工 · 其他出库 |

### 2.4 枚举

**outbound_type（主表 & 列表展示）：**

| 值 | 含义 | 来源 |
|----|------|------|
| `0` | 下单扣减 | 支付成功自动 |
| `1` | 删除产品规格 | 商品删除自动 |
| `2` | 订单出库 | 手工（线下） |
| `3` | 过期出库 | 手工 |
| `4` | 其他出库 | 手工 |

**手工 POST body：** `outboundType` 传 **`2`/`3`/`4`** 之一。

### 2.5 与现有 `biz_goods` 关系

| 项 | 定稿 |
|----|------|
| 库存字段 | **`biz_goods.stock`**（首期单规格） |
| 支付扣库存 | 已有 **`deductStock`**（`OrderPaySuccessServiceImpl`） |
| 手工减库存 | 新增 **`decrementStockForOutbound(goodsId, shopId, qty)`**：`stock >= qty` 且 `del_flag='0'` |
| 条码 | 首期按 **`goods_sn`** 精确匹配 |
| 规格文案 | 复用 **`StockSupport.buildSpecText(goodsId)`** |

### 2.6 首期触发口径

| 场景 | 是否自动出库 | 是否改 stock |
|------|--------------|--------------|
| **支付成功** | **是**（type=`0`，**仅留痕**） | **否**（已在 `deductStock`） |
| **逻辑删除商品**（批量一次一张单） | **是**（type=`1`，变化量 0） | **否** |
| **手工出库** | **是**（type=`2`/`3`/`4`） | **是** |

---

## 3. 接口设计

**基路径：** `/agri/seller/stock/outbound`  
**上下文：** **`X-Shop-Id`** 必填（`SellerShopContext.getShopId()`）

### 3.1 接口一览

| 方法 | 路径 | 权限 | 说明 |
|------|------|------|------|
| GET | `/list` | `agri:seller:stock:outbound:list` | 分页列表 |
| GET | `/{outboundId}` | `agri:seller:stock:outbound:query` | 详情 |
| POST | `/` | `agri:seller:stock:outbound:add` | **手工出库** |
| GET | `/operators` | `agri:seller:stock:outbound:list` | 经办人下拉 |
| GET | `/goods/options` | `agri:seller:stock:outbound:add` | 商品选择器（**stock>0**） |
| GET | `/goods/byBarcode` | `agri:seller:stock:outbound:add` | 条码录入 |

> 自动出库 **无 HTTP 接口**，由 **`IStockOutboundFacade`** 内部调用。

### 3.2 列表 `GET /list`

**Query（StockOutboundQuery）：**

| 参数 | 说明 |
|------|------|
| pageNum, pageSize | 分页 |
| keyword | **出库单号** / **明细商品名称** / **关联订单号**（模糊，v1.0.1） |
| outboundType | 空=全部；`0`～`4` |
| operatorId | 经办人 user_id |
| beginTime, endTime | 出库时间 `yyyy-MM-dd` |

**固定条件：** `shop_id = requireShopId()`。  
**排序：** `outbound_time DESC, outbound_id DESC`。

**Row（StockOutboundListRowVO）：** outboundId、outboundNo、outboundType、outboundTypeText、outboundTime、remark、operatorName。

### 3.3 详情 `GET /{outboundId}`

**StockOutboundDetailVO：** 主表字段 + **refOrderNo**（type=`0` 有值）+ `items[]`（goodsId、goodsName、mainPic、specText、quantity、stockBefore、stockAfter）。

### 3.4 手工出库 `POST /`

**Body（StockOutboundCreateDTO）：**

```json
{
  "outboundType": "3",
  "remark": "optional",
  "items": [
    { "goodsId": 1001, "skuId": null, "quantity": 5 }
  ]
}
```

| 字段 | 校验 |
|------|------|
| outboundType | 必填；**`2`/`3`/`4`** |
| remark | ≤200 |
| items | 至少 1 行；goodsId **合并 quantity** |
| items[].quantity | 整数 ≥1 且 **≤ 当前可用库存** |

**处理流程（单事务）：**

```text
1. requireShopId()
2. 校验 OUT1～OUT7
3. outboundNo = StockSupport.nextOutboundNo()
4. INSERT biz_stock_outbound
5. FOR each item（goodsId 升序，防死锁）:
     SELECT stock FOR UPDATE
     stock_before = stock
     decrementStockForOutbound(goodsId, shopId, qty)   -- 手工才执行
     stock_after = stock_before - qty
     INSERT biz_stock_outbound_item
     INSERT biz_stock_log（direction=2, reason=…, ref_type=OUTBOUND）
6. 返回 { outboundId, outboundNo }
```

### 3.5 经办人 `GET /operators`

当前 `shop_id` 历史出库单 **去重** `operator_id + operator_name`（含 **系统**）。

### 3.6 商品选择 `GET /goods/options`

| 参数 | 说明 |
|------|------|
| keyword | 商品名称 / 编号模糊 |
| pageNum, pageSize | 分页 |

**条件：** `shop_id`、`del_flag='0'`、**`stock > 0`**（与入库选品区别）。  
**返回：** 复用 `StockGoodsPickVO`。

### 3.7 条码 `GET /goods/byBarcode`

逻辑同入库；额外校验 **stock > 0**，否则业务错误「该商品无可用库存」。

---

## 4. 自动出库（Facade）

### 4.1 支付成功 · 下单扣减

**接口：** `IStockOutboundFacade.createOrderDeductOutbound(OrderDeductOutboundCommand cmd)`

**调用点：** `OrderPaySuccessServiceImpl.completeWeChatPay` — **`deductStock` 循环成功之后**、**同一事务内**。

**OrderDeductOutboundCommand：**

| 字段 | 说明 |
|------|------|
| shopId | 订单店铺 |
| orderId / orderNo | 订单主键 / 编号（**orderNo → ref_order_no**） |
| outboundTime | 支付成功时间 |
| lines | `[{ goodsId, goodsName, mainPic, specText, quantity, stockBefore, stockAfter }]` |

**规则：**

- **一张订单一张出库单**；多明细 → 多行 `biz_stock_outbound_item`。
- **不调用** `decrementStockForOutbound`（库存已在 `deductStock` 写入）。
- `stock_before/stock_after` 须与 **扣减前后** 实际库存一致（支付循环内读取或按 `deductStock` 返回值推算）。
- 写 `biz_stock_log`（reason=`ORDER_DEDUCT`，direction=`2`）。
- **幂等：** 若 `ref_order_no` 已存在 → **直接 return**（配合支付回调重入）。

### 4.2 删除产品规格

**接口：** `IStockOutboundFacade.createDeleteSpecOutbound(DeleteSpecOutboundCommand cmd)`

**调用点：** `GoodsServiceImpl.deleteSellerGoodsBatch` — **logicDelete 成功之后**、**同一事务内**。

**规则：**

- 同次批量删除 → **一张出库单、多行明细**。
- **quantity=0**；log **change_qty=0**；`stock_before` 取删除前快照，`stock_after=stock_before`。
- **不减 stock**（商品已逻辑删除）。
- type=`1`；reason=`DELETE_SPEC`。

---

## 5. 核心 SQL 片段

**列表 keyword（v1.0.1）：**

```sql
WHERE b.shop_id = #{shopId}
  AND (#{keyword} IS NULL OR b.outbound_no LIKE CONCAT('%',#{keyword},'%')
       OR b.ref_order_no LIKE CONCAT('%',#{keyword},'%')
       OR EXISTS (
         SELECT 1 FROM biz_stock_outbound_item i
         WHERE i.outbound_id = b.outbound_id
           AND i.goods_name LIKE CONCAT('%',#{keyword},'%')
       ))
```

**decrementStockForOutbound：**

```sql
UPDATE biz_goods
SET stock = stock - #{quantity}, update_time = sysdate()
WHERE goods_id = #{goodsId} AND shop_id = #{shopId}
  AND stock >= #{quantity} AND del_flag = '0'
```

**支付幂等：**

```sql
SELECT outbound_id FROM biz_stock_outbound WHERE ref_order_no = #{orderNo} LIMIT 1
```

---

## 6. 权限与菜单（SQL 种子建议）

| 权限标识 | 说明 |
|----------|------|
| `agri:seller:stock:outbound:list` | 列表、经办人下拉 |
| `agri:seller:stock:outbound:query` | 详情 |
| `agri:seller:stock:outbound:add` | 手工出库、选品、条码 |

菜单：**库存管理 → 商品出库**（`parent_id=6500`，`menu_id=6510` 段，`path=outbound`）。  
建议脚本：`sql/biz_stock_outbound_menu.sql`（与入库 `6500` 目录并列）。

---

## 7. 实现清单

| # | 项 | 状态 |
|---|-----|------|
| 1 | DDL `sql/biz_stock_outbound.sql` | 待执行 |
| 2 | Domain / Mapper / XML | 待建 |
| 3 | `StockSupport.nextOutboundNo` + 常量扩展 | 待建 |
| 4 | `StockOutboundServiceImpl` 手工出库 | 待建 |
| 5 | `StockOutboundFacadeImpl` 自动出库 | 待建 |
| 6 | `OrderPaySuccessServiceImpl` 挂钩 | 待改 |
| 7 | `GoodsServiceImpl.deleteSellerGoodsBatch` 挂钩 | 待改 |
| 8 | `BizGoodsMapper.decrementStockForOutbound` | 待建 |
| 9 | `SellerStockOutboundController` | 待建 |
| 10 | 单元测试 Service + Controller | 待建 |
| 11 | ruoyi-ui 列表/出库弹窗 | 待建 |

---

## 8. 版本记录

| 版本 | 说明 |
|------|------|
| **v1.0** | 首版：DDL、接口、手工/自动出库分工、与 order/goods/inbound 协作；对齐需求 v1.0.1 **支付扣减留痕**、**keyword 含订单号**、**下单扣减与订单出库命名分离** |

---

*文档版本：v1.0 · 依据《商品出库功能需求.md》v1.0.1*
