# 打包负责人物资申领

> 申领提交/撤销/审核 7 个新接口，含凭证字符串格式、canCancel/canAudit 显隐规则，以及出库权限按团队收敛、库房口径改剩余可用两处破坏性变更

- 类型：前端对接
- 分类：仓库配货 / 打包
- 端：梦花仓管小程序
- 后端服务：gallery
- 更新：2026-08-25
- 网页版：https://doc.xfun.fun/wms-material-apply-api

梦花仓管小程序 · 进销存

后端模块 `xm-bulky-mall-gallery`（与现有 `/supplier/wms/inventory/*` 同一服务）· 测试分支 `test-gallery` · 2026-08-25

## 通用约定

跟现有 `/supplier/wms/inventory/*` 一模一样：base `https://api.xunmengvip.cn/xm-bulky-mall-gallery`，全部 POST + JSON body，鉴权、`{ code, msg, data, success }` 包装（`success` 是布尔，等价于 `code === 0`；失败时 HTTP 状态码仍是 200，别用状态码判断成败）、`index`/ `size` 分页都照旧，直接复用现成封装。失败响应的 `msg` 是可以直接 toast 的中文，速查见 08 节。

> [!NOTE]
> **⚠️ 唯一的入参怪癖**
>
> `/apply/sku/stock`、`/apply/detail`、`/apply/cancel` 三个接口复用了通用的 `IdQo`，入参字段名都叫 `id`。其中 **`/apply/sku/stock` 的 `id` 传的是 skuId，不是申领单 ID**，另外两个才是申领单 ID。

## 角色与状态机

后端从两处判定当前账号的身份，不需要前端传角色来鉴权：

| 角色 | 判定依据 | 能做什么 |
|----|----|----|
| **仓库管理员** | 账号命中配置的仓管角色 | 看全部申领单、同意、拒绝。**不能提交申领**（除非同时也是负责人） |
| **打包负责人** | `xm_bulky_team.leader_id` = 当前账号，且团队为启用状态 | 提交申领、撤销自己提交的单、只看自己带的大区的单 |
| 两者都不是 | — | 调任意申领接口都会收到 `无物资申领权限` |

一个账号可以同时是仓管和负责人，这种情况下列表默认看全部；如果前端提供了「角色切换」，把当前视角作为 `roleName` 传给 `/apply/page` 即可（见 04 节）。

### 申领单状态

| status | 名称 | 说明 |
|----|----|----|
| 10 | 待审核 | 负责人提交后的初始态。此时**库存不动**，不占用、不冻结 |
| 20 | 已同意 | 仓管同意，库存此刻才从库房划到大区，`issuedQuantity` 有值 |
| 30 | 已拒绝 | 仓管拒绝，`rejectReason` 可能为空字符串 |
| 40 | 已撤销 | 申领人本人撤回，仅待审核态可撤 |

> [!NOTE]
> **待审核不占库存**
>
> 提交时只校验一次「申领数量 ≤ 库房剩余可用」，之后不做任何预占。所以两张待审核的单可能都显示能发，实际审批时第二张会因为库存不够被拒。**审批失败的 toast 要能正常展示，不要假设提交通过就一定能审批通过。**

## 新增接口 · 负责人侧

### POST /supplier/wms/apply/sku/stock · 查询物资库房剩余可用库存

**新增** · 调用方：负责人 / 仓管

选好物资后调用，用于在申领数量输入框旁边展示「当前可申领上限」。

| 请求字段 | 类型 | 必填 | 说明 |
|----|----|----|----|
| `id` | Long | 是 | **物资 SKU ID**（不是申领单 ID），取自 `/supplier/wms/inventory/sku/list` 的 `skuId` |

```text
// 响应 data
{
  "skuId": 1012,
  "skuName": "打包纸箱",
  "specification": "60×40×40",
  "unitName": "个",
  "availableStock": 320   // 库房剩余可用 = 全局实物 − 各大区已分配剩余
}
```

### POST /supplier/wms/apply/create · 提交物资申领

**新增** · 调用方：仅负责人

| 请求字段 | 类型 | 必填 | 说明 |
|----|----|----|----|
| `skuId` | Long | 是 | 物资 SKU ID |
| `teamId` | Integer | 条件 | 申领大区。**负责人只带 1 个大区时可以不传**；带多个大区必传，不传报「请选择申领大区」。传了非自己带的大区报「无权为该大区申领」 |
| `quantity` | Integer | 是 | 申领数量，1 ~ 10000，且不能超过 `availableStock` |
| `voucherUrls` | String | 是 | **JSON 数组的字符串**，不是数组对象。1 ~ 5 个元素，整串 ≤ 512 字符 |
| `remark` | String | 否 | 备注，≤ 255 字符 |

> [!NOTE]
> **🔴 voucherUrls 是字符串，不是数组**
>
> 后端字段类型是 `String`，前端要自己 `JSON.stringify` 后再放进 body：
>
> 
```text
// ✅ 正确
{ "skuId": 1012, "quantity": 50, "voucherUrls": "[\"https://oss/a.jpg\",\"https://oss/b.jpg\"]" }

// ❌ 会报「凭证图片格式错误」
{ "skuId": 1012, "quantity": 50, "voucherUrls": ["https://oss/a.jpg"] }
```

>
> **512 字符是整串长度**，包含引号和逗号。OSS 地址长的话 5 张可能放不下 —— 建议前端上传后存相对路径或短地址，并在提交前自己先量一次 `voucherUrls.length`，超了就地提示，别等后端报错。

响应 `data` 为 `null`，成功即可返回列表页刷新。**接口不返回新建的申领单 ID**，需要跳详情的话得先刷列表拿。

### POST /supplier/wms/apply/cancel · 撤销物资申领

**新增** · 调用方：仅申领人本人

入参 `{ "id": 申领单ID }`，响应 `data` 为 `null`。

只有 **申领人本人**且单据处于 **待审核**时才能撤销。不满足条件统一返回 `申领单状态已变更，请刷新后重试`（不区分「不是你的单」和「已经被审了」，避免泄露他人单据）。收到这个提示应当刷新列表。

## 新增接口 · 列表与详情

### POST /supplier/wms/apply/page · 申领列表

**新增** · 调用方：负责人 / 仓管

| 请求字段 | 类型 | 必填 | 说明 |
|----|----|----|----|
| `index` | Integer | 否 | 页码，默认 1 |
| `size` | Integer | 否 | 页大小，默认 10 |
| `status` | Integer | 否 | 10/20/30/40，不传查全部。传其他值报「申领状态不合法」 |
| `roleName` | String | 否 | 当前视角角色名。传 `"打包负责人"` 时强制只看自己带的大区 |

> [!NOTE]
> **roleName 的作用范围**
>
> - **非仓管账号**：无论传不传 `roleName`，永远只能看到自己带的大区的单。
> - **仓管账号**：默认看全部；只有当它 **同时也是负责人**、且传了 `roleName="打包负责人"` 时，才收窄到自己带的大区。
> - 字符串必须 **精确等于 `打包负责人`**，拼错就当没传。这和现有 `/supplier/wms/inventory/team/list` 的 `roleName` 是同一套约定，小程序当前传的 `"仓库管理员"` 走的就是默认（不收窄）分支，角色切换器的取值直接复用即可。

响应 `records` 单项：

```json
{
  "id": 88,
  "applyNo": "SL20260825001",     // SL + yyyyMMdd + 3位序列
  "teamName": "昆明一区",
  "skuName": "打包纸箱",           // 即「物资类别」
  "specification": "60×40×40",
  "unitName": "个",
  "applyQuantity": 50,
  "issuedQuantity": 45,          // 仅「已同意」有值，其余状态恒为 null
  "status": 20,
  "statusDesc": "已同意",          // 后端已给中文，直接展示
  "createTime": "2026-08-25 09:12:33",
  "auditTime":  "2026-08-25 10:04:51",
  "canCancel": false,            // 撤销按钮显隐
  "canAudit": false             // 同意/拒绝按钮显隐
}
```

> [!NOTE]
> **✅ 按钮显隐直接用 canCancel / canAudit**
>
> 这两个布尔是后端按「状态 + 当前账号」算好的，**不要在前端用 `status===10 && applicantId===me` 自己推**，列表里也没返回 `applicantId`。同一条单据对不同人返回的值不同，这是预期的。

### POST /supplier/wms/apply/detail · 申领详情

**新增** · 调用方：仓管 / 本大区负责人

入参 `{ "id": 申领单ID }`。返回值是列表项的 **超集**，在上面所有字段之外多出：

| 字段 | 类型 | 说明 |
|----|----|----|
| `remark` | String | 申领备注，无备注时是空字符串 |
| `voucherInfo` | String\[\] | **这里是真数组**，可直接喂给图片预览组件（提交时是字符串，返回时是数组，注意不对称） |
| `applicantName` | String | 申领人姓名快照 |
| `auditorName` | String | 审核人姓名，未审核时是空字符串 |
| `rejectReason` | String | 拒绝原因，未拒绝或仓管没填时是空字符串 |
| `cancelTime` | DateTime | 撤销时间，未撤销时为 null |

非仓管账号查非自己大区的单，返回 `申领记录不存在`（不是无权限），前端按「记录不存在」处理即可。

## 新增接口 · 仓管审核

### POST /supplier/wms/apply/approve · 同意申领

**新增** · 调用方：仅仓管

| 请求字段 | 类型 | 必填 | 说明 |
|----|----|----|----|
| `id` | Long | 是 | 申领单 ID |
| `issuedQuantity` | Integer | 是 | **实际出库数量**，可以少发但不能多发：必须 ≥1、≤ `applyQuantity`、≤ 当前库房剩余可用 |

审批弹窗建议把 `issuedQuantity` 默认填成 `applyQuantity`，允许仓管改小。响应 `data` 为 `null`。

### POST /supplier/wms/apply/reject · 拒绝申领

**新增** · 调用方：仅仓管

入参 `{ "id": 申领单ID, "rejectReason": "库存留给昆明二区" }`。`rejectReason` 后端 **不强制**（≤255 字符），如果产品要求必填请前端自己拦。响应 `data` 为 `null`。

> [!NOTE]
> **并发下的统一反馈**
>
> 同意/拒绝/撤销都是 CAS 更新，只有单据仍是「待审核」才会成功。两个仓管同时点，后点的那个会拿到 `申领单状态已变更，请刷新后重试`。**收到这条文案时请自动刷新列表并关闭弹窗**，否则用户会一直点一直报错。

## 既有接口的行为变化 · 必须跟进

这三处不是新接口，是本次一并收口的老接口。**前端不跟会出报错或数字对不上。**

### POST /supplier/wms/inventory/inbound/create · 新增入库补上了仓管校验

**变更**

该接口此前 **完全没有角色校验**，任何登录账号都能调。现在非仓管调用会返回 `非仓库管理员，无进销存操作权限`。

**团队选择逻辑不变**，仓管依旧可以选库房或任意大区入库。打包负责人此前若能进到这个页面，现在会被拦下，入口按角色隐藏即可。

### POST /supplier/wms/inventory/outbound/create · 新增出库按来源团队收敛权限 **破坏性变更**

| 出库来源 | 谁能操作 | 不满足时的报错 |
|----|----|----|
| `teamId = 0`（库房） | 仅仓库管理员 | `非仓库管理员，无进销存操作权限` |
| `teamId > 0`（大区） | 仓库管理员，或 **该大区的打包负责人** | `无该大区出库权限` |

打包负责人登记本大区物资消耗的能力 **保留**，但不能再从库房直接出货。出库页的团队选择器建议按角色渲染：仓管给全量，负责人只给 `/supplier/wms/inventory/team/list` 传 `roleName="打包负责人"` 后返回的那几个大区。

### POST /supplier/wms/inventory/stock/page · 库存页「库房」一行的口径变了 **破坏性变更**

`teamId = 0` 那一行的 `teamStock`，从「全局实物库存」改成了「**库房剩余可用**」：

```text
库房剩余可用 = 全局实物库存 − Σ(各大区批次剩余)
```

大区行的 `teamStock` 语义不变（该大区剩余）。响应额外带一个 `availableStock` 字段，值同库房剩余可用，各行都有。

> [!NOTE]
> **数字会变小，这是预期的**
>
> 只要有物资发到过大区，库房那一行的数就会比改版前小。这正是需求要的「发放给大区的部分不再在库房重复展示」。**文案上建议把库房行标成「库房剩余可用」，避免仓管以为库存丢了。**
>
> 注意 `physicalStock`（全局实物）字段在手机端接口被 `@JsonIgnore` 掉了，响应里没有，前端拿不到也不需要拿。

## 搭页面时会用到的既有接口

申领相关页面不需要新的下拉数据接口，直接复用现成的两个：

| 用途 | 接口 | 说明 |
|----|----|----|
| 选申领大区 | `/supplier/wms/inventory/team/list` | 传 `{"roleName":"打包负责人"}` 拿到「库房 + 自己带的大区」。**申领页要把 `teamId=0` 的库房项过滤掉**，只留大区。返回只有 1 个大区时可以隐藏选择器、`create` 不传 `teamId` |
| 选物资 | `/supplier/wms/inventory/sku/list` | 传 `{"skuName":"纸箱"}` 或 `{"specification":"60"}` 模糊搜。返回 `skuId / skuName / specification / unitName`。**「物资类别」对应 `skuName`，「物资规格」对应 `specification`** |

### 推荐的提交页交互顺序

1.  `team/list`（带 roleName）→ 过滤掉库房 → 大区下拉；只有一个就选中并隐藏
2.  `sku/list` → 物资类别 / 规格下拉，选中拿到 `skuId`
3.  `apply/sku/stock`（`id` 传 skuId）→ 展示「库房剩余可用 N 个」，作为数量输入框的 max
4.  上传凭证 → `JSON.stringify(urls)`，本地先校验张数 ≤5、长度 ≤512
5.  `apply/create` → 成功后回列表页刷新

## 报错文案速查

所有失败响应的 `msg` 都是可以直接 toast 的中文，无需前端翻译。下表用于确认交互分支：

| msg | 出现在 | 建议处理 |
|----|----|----|
| `无物资申领权限` | 全部申领接口 | 既不是仓管也不是负责人，隐藏整个申领入口 |
| `非仓库管理员，无进销存操作权限` | approve / reject / 库房出入库 | 隐藏审核按钮 |
| `无该大区出库权限` | outbound/create | 出库页团队下拉按角色收窄 |
| `请选择申领大区` | create | 负责人带多个大区却没传 teamId，前端应设为必填 |
| `无权为该大区申领` | create | teamId 不在自己带的大区里，一般是前端串了数据 |
| `物资不存在或已停用` | sku/stock、create | 刷新物资下拉 |
| `申领数量超过库房剩余可用库存` | create | 重新拉 sku/stock 刷新上限 |
| `申领数量不能大于10000` | create | 前端 max 设 10000 |
| `请至少上传1张凭证` | create | 凭证必填 |
| `凭证图片最多上传5张` | create | 上传组件限 5 张 |
| `凭证图片URL文本不能超过512个字符` | create | 见 03 节说明，提交前本地量长度 |
| `凭证图片格式错误` | create | 大概率是传了数组而不是字符串 |
| `申领状态不合法` | page | status 只能传 10/20/30/40 |
| `申领记录不存在` | detail | 越权查看或单据已删，回列表 |
| `申领单状态已变更，请刷新后重试` | cancel / approve / reject | **自动刷新列表并关弹窗** |
| `实际出库数量不能超过申领数量` | approve | 审批弹窗 max = applyQuantity |
| `实际出库数量超过库房剩余可用库存` | approve | 刷新后重审 |

## 联调注意

- **测试账号要准备两个**：一个是某大区的 `leader_id`（负责人），一个命中仓管角色。同一个账号两种身份都有时，很多分支测不出来。
- **时间字段格式**与现有进销存接口一致，`createTime` / `auditTime` / `cancelTime` 都可能为 null，渲染前判空。
- **提交接口不回 ID**：`create` 的 `data` 是 `null`，需要「提交成功后跳详情」的话，得先刷列表取第一条。
- **voucherInfo 进出不对称**：提交传字符串，详情返回数组。复用同一个表单模型时注意转换。
- **先看库存页再对申领**：库房口径变了，联调时先确认 `stock/page` 库房行的数和你预期的「剩余可用」一致，再测申领链路，否则数字对不上会误判成申领的 bug。

