material_manage_system/docs/api.md

1266 lines
45 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 仓库物料管理系统 API 文档
**基础地址**:`/api`
---
## 阅读说明
### 认证
除 `POST /auth/login` 外,所有接口都要求携带 JWT:
| 请求头 | 值 |
|----------------|-------------------|
| Authorization | `Bearer <token>` |
缺少或失效的令牌返回 `401 Unauthorized`。标为「仅管理员」的接口由 `AdminOnly` 中间件拦截,非管理员返回 `403 Forbidden`。有效角色在每次请求时从数据库重载,JWT 中的旧角色快照不授予当前权限。
### 公司上下文(2026-10-09)
组织级接口必须同时携带 `Authorization: Bearer <token>` 和 `X-Organization-ID: <正整数公司ID>`。适用范围为物料、分类、单位、编码规则、伙伴、仓库/库位、库存/预警/流水、入出库、申请/审批和物料日志。超级管理员也必须选择公司;没有跨公司汇总或默认公司兜底。
登录、当前用户、用户管理、组织管理、角色/权限管理、上传及下面的公司初始化/切换接口不要求组织头。公司授权只限制数据范围,功能角色权限仍单独校验。
| HTTP 状态 | 公司隔离语义 |
| --- | --- |
| 400 | 组织头缺失、不是纯数字正整数、请求参数非法,或引用另一公司的分类/单位/物料等 |
| 403 | 当前公司不存在、停用或未授权,统一提示“当前公司不可访问”;功能权限不足也返回 403 |
| 404 | 在已授权的公司上下文内查询、更新或删除另一公司的记录,或记录不存在 |
| 409 | 仓库/库位缺失或停用导致库存写操作不可执行,或已有业务状态冲突 |
新建记录的归属取服务端验证的上下文,忽略请求体伪造的 `organization_id`。普通编辑不能迁移公司。列表、分页总数、搜索、编码查重、日志和状态变更均限制当前公司;不同公司允许相同物料编码。
### 公司授权、初始化与切换
`GET /auth/organizations` 返回当前用户可访问的启用公司及其仓库摘要;普通用户按显式授权过滤,超级管理员得到所有启用公司:
```json
{
"code": 0,
"message": "success",
"data": {
"organizations": [{
"id": 201, "code": "BEIJING", "name": "北京公司", "status": "active",
"warehouse": {"id": 201, "name": "北京仓库", "status": "active",
"location": {"id": 201, "name": "北京库位", "status": "active"}}
}],
"default_organization_id": 201
}
}
```
`warehouse` 或 `location` 未配置时为 `null`;普通用户可读取摘要,无需仓库维护权限。`default_organization_id` 可为 `null`,不能据此隐式使用 ID 1。
`POST /auth/organization-context` 请求 `{"organization_id":202,"previous_organization_id":201}`,返回 `data: {organization_id, organization, warehouse}`。服务端独立验证目标公司并写入切换审计;审计失败则切换失败。该调用不改变用户的默认公司,也不保存全局会话公司,请求仍须携带组织头。
`POST /users` 和 `PUT /users/:id` 支持 `organization_ids` 与 `default_organization_id`。普通用户的默认公司必须属于启用授权列表且唯一;更新时省略授权字段表示保留原设置,显式空授权列表配合 `null` 默认公司表示清空。授权变更与审计在同一事务中提交。超级管理员无需逐公司授权。
首次登录选择有效默认公司;没有有效默认但有授权公司时要求选择,没有授权时禁止组织级请求。前端以每账号 `sessionStorage` 保存标签页选择,刷新重新校验。切换清理列表、详情、筛选、分页、弹窗及缓存,并以公司 ID 和上下文版本屏蔽旧响应/错误;深链接回对应列表。脏表单先确认放弃,保存期间禁止切换,登出/换账号清空状态。
### 仓库、库存与单据事务
每个公司最多一个仓库,每个仓库最多一个库位,数据库唯一约束包括停用记录。页面只显示公司对应的仓库/库位摘要;服务端按公司解析库存维度,不信任客户端的仓库/库位归属。
| 配置状态 | 库存写操作提示(409) |
| --- | --- |
| 无仓库 | 请先配置本公司仓库 |
| 仓库停用 | 本公司仓库已停用 |
| 无库位 | 请先配置本公司仓库的库位 |
| 库位停用 | 本公司库位已停用 |
上述状态允许维护主数据、查询本公司历史库存/单据/流水,以及创建申请、驳回、取消等不扣库存操作。入出库创建/修改/删除、预警修改及自动出库审批要求启用维度,禁止借用另一公司的资源。所有启用且授权公司均可使用已有业务模块。
入出库表头及库存流水保存 `organization_id/warehouse_id/location_id`。修改/删除在事务内锁定当前公司原单据,先按原库存维度恢复原单据造成的库存变化,再处理新的本公司明细;失败整体回滚。库存余额锁定完整维度与物料 ID,条件扣减防止透支,多物料按 ID 排序锁定。流水结存只在本公司内计算,提交成功后才发布本公司事件。
### 申请与审批
`POST /applications` 在创建时绑定当前公司,明细只允许本公司有效物料;`GET /applications/my` 按公司和本人过滤,`GET /applications` 按公司及审批功能权限查询。审批、驳回和取消使用 `/applications/:id/approve`、`/reject`、`/cancel`;取消仍须本人,状态更新带公司、ID 和原状态条件。
审批请求支持 `{"auto_outbound":true}`。自动出库审批在同一事务中锁定本公司的 pending 申请,解析本公司启用仓库/库位,锁定并扣减库存,建立同公司出库单和流水,最后变更申请状态。重复审批不会重复出库;库存不足或中途故障回滚全部业务记录。非自动出库审批仍沿用现有库存检查规则。
### WebSocket 公司订阅
`POST /auth/websocket-ticket` 必须携带令牌及组织头,返回 `data: {ticket, expires_at}`(到期 Unix 秒)。随机票据一次性、30 秒有效,绑定用户、公司和当前会话。使用 `/ws?ticket=<票据>` 握手,不在 URL 放长期 JWT。
握手和每次投递重新检查有效用户、公司授权及会话;撤销授权/角色、停用公司或登出后,失效连接不再接收事件。库存事件含 `type: "stock_update"`、`organization_id`、`action`、`order_id`,仅投递同公司订阅者。前端切换/登出关闭旧连接,重新取票建连,消息同时校验公司与上下文版本。
数据检查、升级顺序、验证证据和回退限制见 [公司隔离验收与交付](company-master-data-isolation-acceptance.md)。
### 统一响应结构
所有接口共用同一个外层结构:
| 字段 | 类型 | 说明 |
|---------|---------|--------------------------------------------|
| code | integer | `0` 表示成功;失败时等于 HTTP 状态码 |
| message | string | 提示信息(后端硬编码,目前为英文) |
| data | object | 业务数据;无返回值的接口为 `null` |
### 分页结构
列表接口的 `data` **不是数组**,而是一个分页对象:
```json
{
"code": 0,
"message": "success",
"data": {
"items": [],
"limit": 20,
"offset": 0,
"total": 0,
"has_more": false
}
}
```
| 字段 | 类型 | 说明 |
|----------|---------|------------------------------------------|
| items | array | 当前页数据 |
| limit | integer | 每页条数,默认 20,**上限 100** |
| offset | integer | 偏移量,默认 0 |
| total | integer | 符合条件的总条数 |
| has_more | boolean | 是否还有下一页 |
`limit` 传超过 100 的值会被服务端截断为 100,而不是报错。
### 本文档覆盖范围
本文档详述**物料、入库、出库、库存**四个资源域,物料批量导入(`/material-import`)已并入 §1。以下分组已实现但未在本文档展开,需要时请直接查阅 `internal/handler/api.go` 的路由注册:
| 分组 | 路径 | 权限 |
|-------------------------|---------------------------------------------|--------|
| 认证 | `POST /auth/login`、`GET /auth/me`、`POST /auth/logout` | 登录接口公开,其余需登录 |
| 用户 | `/users` | 仅管理员 |
| 组织 | `/organizations` | 仅管理员 |
| 角色 / 权限 | `/roles`、`/permissions` | 仅管理员 |
| 主数据:物料分类 | `/material-categories` | 仅管理员 |
| 主数据:计量单位 | `/units` | 仅管理员 |
| 主数据:编码规则 | `/code-rules`、`GET /code-rules/next` | 仅管理员;`/next` 需登录 |
| 业务伙伴 | `/partners` | 仅管理员 |
| 仓库与库位 | `/warehouses`、`/warehouses/:id/locations` | 仅管理员 |
| 物料申请 | `/applications`、`/applications/my` | 需登录 |
| 操作日志 | `/logs`、`/logs/materials/:materialId` | 需登录 |
| 图片上传 | `POST /upload` | 需登录 |
| 物料批量导入 | `GET /material-import/template`、`POST /material-import` | 需登录,见 §1 |
---
## 1. 物料接口
物料是主数据,**不承载任何库存信息**。库存数量和预警阈值分别由库存接口和入库流程维护。
### GET /materials
分页查询物料列表。
**查询参数:**
| 参数 | 类型 | 必填 | 说明 |
|----------|---------|------|----------------------------|
| search | string | 否 | 按物料编码或名称模糊搜索 |
| status | string | 否 | `active`(默认)、`obsolete` 或 `all`;业务下拉应使用默认值,物料管理页使用 `all` |
| limit | integer | 否 | 每页条数,默认 20,上限 100 |
| offset | integer | 否 | 偏移量,默认 0 |
> `status` 默认 `active`,所以该接口默认只返回有效物料,已作废的物料需要显式传 `status=all` 或 `status=obsolete` 才能查到。业务单据的物料下拉正是靠这个默认值自然排除了作废物料;库存流水的检索框则要显式传 `status=all`,才能查到已作废物料的历史。
**响应**(`data` 为分页对象,此处只展示 `items` 内元素):
```json
{
"code": 0,
"message": "success",
"data": {
"items": [
{
"id": 1,
"organization_id": 1,
"material_code": "A01-0001",
"name": "内六角螺栓",
"material_image": "/uploads/bolt.png",
"spec": "M8x30",
"manufacturer_model": "GB/T 70.1",
"category_id": 12,
"base_unit_id": 3,
"unit": "个",
"status": "active",
"version": 1,
"created_at": "2026-01-01T08:00:00Z",
"updated_at": "2026-01-01T08:00:00Z"
}
],
"limit": 20,
"offset": 0,
"total": 1,
"has_more": false
}
}
```
`material_image`、`spec`、`manufacturer_model`、`category_id`、`base_unit_id`、`unit` 为空时**不会出现在响应里**(后端 `omitempty`),前端取值时需要做空值兜底。
**状态码:**
- `200 OK` — 成功
- `401 Unauthorized` — 未登录
---
### GET /materials/:id
查询单个物料。
**路径参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|---------|------|----------|
| id | integer | 是 | 物料 ID |
**响应:** `data` 为单个物料对象,字段同 `GET /materials`。
**状态码:**
- `200 OK` — 成功
- `400 Bad Request` — 物料 ID 非法
- `404 Not Found` — 物料不存在
---
### POST /materials
创建物料。
**请求体:**
```json
{
"material_code": "A01-0001",
"name": "内六角螺栓",
"spec": "M8x30",
"manufacturer_model": "GB/T 70.1",
"category_id": 12,
"base_unit_id": 3,
"unit": "个",
"material_image": "/uploads/bolt.png"
}
```
| 字段 | 类型 | 必填 | 说明 |
|-------------------|---------|------|--------------------------------------|
| material_code | string | 是 | 物料编码,组织内唯一 |
| name | string | 是 | 物料名称 |
| category_id | integer | 是 | 小类分类 ID,必须大于 0 且真实存在 |
| spec | string | 否 | 规格 |
| manufacturer_model| string | 否 | 厂家型号 |
| base_unit_id | integer | 否 | 基本计量单位 ID |
| unit | string | 否 | 单位编码(冗余展示字段) |
| material_image | string | 否 | 图片地址 |
| organization_id | integer | 否 | 传入会被忽略,服务端取当前用户的组织 |
**响应:** `201 Created`,`data` 为创建后的物料对象。
```json
{
"code": 0,
"message": "Material created successfully",
"data": { "id": 1, "material_code": "A01-0001", "name": "内六角螺栓", "status": "active" }
}
```
**状态码:**
- `201 Created` — 创建成功
- `400 Bad Request` — 请求体非法、物料编码已存在,或 `category_id` 不是有效的小类类型
---
### PUT /materials/:id
更新物料。
**路径参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|---------|------|----------|
| id | integer | 是 | 物料 ID |
**请求体:**
```json
{
"material_code": "A01-0001",
"name": "内六角螺栓(镀锌)",
"spec": "M8x35",
"category_id": 12,
"unit": "个"
}
```
| 字段 | 类型 | 必填 | 说明 |
|-------------------|---------|------|--------------------------|
| category_id | integer | **是** | 即使不修改分类也必须传 |
| material_code | string | 否 | 物料编码 |
| name | string | 否 | 物料名称 |
| spec | string | 否 | 规格 |
| manufacturer_model| string | 否 | 厂家型号 |
| base_unit_id | integer | 否 | 基本计量单位 ID |
| unit | string | 否 | 单位编码 |
| material_image | string | 否 | 图片地址 |
| status | string | 否 | 物料状态 |
> `category_id` 在更新接口里同样是必填项。只改名称而不传分类会返回 `400`。
**响应:** `200 OK`,`data` 为更新后的物料对象。
**状态码:**
- `200 OK` — 更新成功
- `400 Bad Request` — 请求体非法或缺少 `category_id`
- `404 Not Found` — 物料不存在
---
### POST /materials/:id/obsolete
作废物料。这是**软删除**:物料从库存视图(`GET /stock`、`GET /stock/warning`)中消失,但记录保留,可以恢复。
> `GET /materials` 默认仅返回有效物料;物料管理页应传 `status=all` 才会同时看到作废项并执行撤销。入库、出库和申领的服务端也会拒绝使用已作废物料。历史单据仍可读取作废物料的名称与编码。
**路径参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|---------|------|----------|
| id | integer | 是 | 物料 ID |
**请求体:** 无。
**响应:**
```json
{
"code": 0,
"message": "Material obsoleted successfully",
"data": null
}
```
**状态码:**
- `200 OK` — 作废成功
- `400 Bad Request` — 物料 ID 非法
- `404 Not Found` — 物料不存在
- `409 Conflict` — **仍有库存(在库、预留、锁定或在途数量不为 0)**,需要先出库清零再作废
> 不再补货的物料走作废退役。作废要求库存为零,所以典型流程是:出库清零 → 作废。
>
> 注意区分「作废」和「关掉预警」:作废是物料退出库存视野,不再出现在任何库存接口里;关掉预警(`warning_enabled: false`)只是不再提醒,物料和阈值都还在。另外,把阈值设成 0 不等于停用预警——0 是"库存降到 0 时预警",仍然会报警。
---
### POST /materials/:id/restore
撤销售废,把物料恢复为可用状态。
**路径参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|---------|------|----------|
| id | integer | 是 | 物料 ID |
**请求体:** 无。
**响应:**
```json
{
"code": 0,
"message": "Material restored successfully",
"data": null
}
```
**状态码:**
- `200 OK` — 恢复成功
- `400 Bad Request` — 物料 ID 非法
- `404 Not Found` — 物料不存在
---
### GET /material-import/template
下载批量导入用的 xlsx 模板。模板随代码实时生成、不落盘,所以下载到的列定义永远与当前校验规则一致。
**请求:** 无参数。
**响应:** xlsx 文件流,`Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`,文件名为 `物料导入模板.xlsx`。
模板含两张工作表,按以下顺序排列,打开时默认显示「填写说明」:
| 工作表 | 内容 |
|--------|------|
| 填写说明 | 字段规则、填写与上传步骤、容量限制、整批校验、重复导入提醒和参考示例 |
| 物料数据 | 第 1 行是冻结表头,从第 2 行填写;五列采用文本格式,数据页不放示例物料 |
`物料数据` 的表头是 5 列:`物料名称`、`规格`、`单位`、`大类`、`小类`。解析时**按列名定位,不按列位置**:
- 列顺序可以随意调整;表头允许带首尾空格和末尾的 `*` 必填标记;不认识的列忽略;
- 必填列 `物料名称`、`大类`、`小类` 缺任何一列,判为模板不合法并返回「模板格式不正确,请重新下载最新模板」;
- 选填列 `规格`、`单位` 可以整列删掉,取值按空处理。
分类**按名称**填写;名称之外也接受**编码**和界面上的「编码 名称」写法(界面一律显示成 `A 机械结构类`,用户整段照抄进模板是常事)。后端按「大类 + 小类」在本公司内组合匹配(小类名在一个公司内会重名,例如多个大类下都有「其他」,所以必须两级组合)。名称、编码、「编码 名称」三种写法同名或同码时以**名称**优先。单位按名称匹配,同时兼容按编码匹配(如 `kg` 与 `千克`)。
### POST /material-import
上传填好的 xlsx,**校验通过即在同一个请求内入库**——没有「预览确认」环节,也没有暂存任务 ID。
**请求:** `multipart/form-data`,文件字段名 `file`。仅支持 `.xlsx`,单文件不超过 1 MB,数据区不超过 10,000 行(不含表头)。
服务端在 multipart 解析前限制请求体为 1 MiB + 64 KiB(表单开销),文件本身仍限制为 1 MiB;超大请求在读到上限时提前拒绝。Excel 使用逐行迭代读取,发现第 10,001 条非空数据时立即停止,表头和空行不计数;较大的工作表 XML 使用临时文件,避免将整张表的所有行缓存到内存。
**逐行校验规则:**
| 列 | 规则 |
|----|------|
| 物料名称 | 必填,去首尾空格后非空,≤ 200 字 |
| 规格 | 选填,≤ 200 字 |
| 单位 | 选填;留空时默认「个」。非空时必须在当前公司已启用单位中按名称或编码匹配到 |
| 大类 | 必填,必须是当前公司已启用的顶层分类,按名称、编码或「编码 名称」匹配 |
| 小类 | 必填,必须是该大类下已启用的小类,同样认名称、编码或「编码 名称」 |
| 编码规则 | 该小类必须存在启用的编码规则(`code_rules.status = 'active'`) |
物料编码由系统按小类编码规则生成,**不接受模板里的编码列**。单位落库统一写该单位的**编码**——模板里填名称(如「千克」)时也会转成编码,与单条新增保持一致。同一行可能产生多条原因,用中文分号连接。
**成功响应 `201 Created`:**
```json
{
"code": 0,
"message": "成功导入 200 条物料",
"data": { "total_rows": 200, "success_rows": 200, "failed_rows": 0, "errors": [] }
}
```
**校验失败响应 `400 Bad Request`**(HTTP 状态是 400,但 `data` 里带完整明细):
```json
{
"code": 400,
"message": "有 3 行校验未通过,本次未导入任何数据",
"data": {
"total_rows": 200, "success_rows": 0, "failed_rows": 3,
"errors": [
{ "row": 3, "data": { "name": "", "spec": "M8x30", "unit": "个", "major": "机械结构类", "minor": "标准件" }, "reason": "物料名称不能为空" }
]
}
}
```
`row` 是 **Excel 里的真实行号**(从 1 开始、含表头行),用户可以直接对着 Excel 定位;`data` 是该行的原始内容。
**状态码:**
- `201 Created` — 全部行通过并入库
- `400 Bad Request` — 存在校验未通过的行(整批不入库);模板不合法(「模板格式不正确,请重新下载最新模板」)、行数超限(「导入行数超过 10000 行上限」)、未选文件(「请选择要导入的 xlsx 文件」)、文件超过 1 MB(「文件超过 1MB 上限」)
- `500 Internal Server Error` — 入库失败,事务已回滚,整批未落库
**行为说明:**
- **任意一行校验失败,整批不入库**,响应中返回全部逐行错误;重新上传前请先修正后重试。
- 整批在**一个事务**内完成:按小类锁编码规则 → 分配编码 → 分批插入物料(每批 500 行)→ 写审计日志;任一步失败整体回滚。
- **仅记录成功导入**:事务提交后写一条 `material_import_logs` 成功汇总,包含操作人、公司、时间、文件名、总行数和成功数,失败数为 0、状态为 `success`、失败原因为空;失败导入不保存历史记录。**逐行错误明细只随响应返回,不落库。** 成功新增的每件物料仍写 CREATE 日志及完整 `new_value` JSON 快照;现有日志页「查看详情」显示「批量导入创建:物料名称(物料编码)」,下方按行展示导入时的编码、名称、规格、分类、单位和状态;该详情是物料操作日志,不展示导入文件名或批次汇总。
- 首版只支持**新增**物料:不做更新,也不自动创建分类或单位。
- **不做重复检测**:同一份文件重复上传会真的导入两遍(生成不同的编码)。
---
## 2. 入库接口
入库是物料**进入库存视野**的唯一入口:物料的第一行库存记录和第一个预警阈值都在这里产生。入库成功后,服务端会通过 WebSocket 广播库存变更。
### GET /inbound
分页查询入库单列表。
**查询参数:**
| 参数 | 类型 | 必填 | 说明 |
|--------|---------|------|----------------------------|
| search | string | 否 | 按经手人或供应商模糊搜索 |
| limit | integer | 否 | 每页条数,默认 20,上限 100 |
| offset | integer | 否 | 偏移量,默认 0 |
**响应**(`data` 为分页对象,此处只展示 `items` 内元素):
```json
{
"code": 0,
"message": "success",
"data": {
"items": [
{
"id": 1,
"inbound_date": "2026-01-01T08:00:00Z",
"handler": "张三",
"supplier": "ABC 钢材",
"status": "completed",
"created_at": "2026-01-01T08:00:00Z",
"updated_at": "2026-01-01T08:00:00Z"
}
],
"limit": 20,
"offset": 0,
"total": 1,
"has_more": false
}
}
```
> 单据的 `status` 字段目前是服务端**硬编码的固定值 `"completed"`**(数据库里没有对应的状态列),所有入库单都返回同一个值,不能用于区分单据阶段。出库单同理。
**状态码:** `200 OK`、`401 Unauthorized`
---
### GET /inbound/:id
查询单个入库单,**包含明细行**。
**路径参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|---------|------|------------|
| id | integer | 是 | 入库单 ID |
**响应:**
```json
{
"code": 0,
"message": "success",
"data": {
"id": 1,
"inbound_date": "2026-01-01T08:00:00Z",
"handler": "张三",
"supplier": "ABC 钢材",
"status": "completed",
"created_at": "2026-01-01T08:00:00Z",
"updated_at": "2026-01-01T08:00:00Z",
"items": [
{
"id": 1,
"inbound_order_id": 1,
"material_id": 1,
"material_code": "A01-0001",
"material_name": "内六角螺栓",
"quantity": 500
}
]
}
}
```
**状态码:**
- `200 OK` — 成功
- `400 Bad Request` — 入库单 ID 非法
- `404 Not Found` — 入库单不存在
---
### POST /inbound
创建入库单。创建时库存立即增加,同一个事务内写入预警阈值。
**请求体:**
```json
{
"handler": "张三",
"supplier": "ABC 钢材",
"remark": "到货验收通过",
"items": [
{ "material_id": 1, "quantity": 500, "warning_stock": 100, "warning_enabled": true },
{ "material_id": 2, "quantity": 200, "warning_stock": 0, "warning_enabled": false },
{ "material_id": 3, "quantity": 80 }
]
}
```
| 字段 | 类型 | 必填 | 说明 |
|----------|--------|------|--------------------|
| handler | string | 是 | 经手人 |
| supplier | string | 是 | 供应商 |
| remark | string | 否 | 备注 |
| items | array | 是 | 明细,至少 1 行 |
**明细字段:**
| 字段 | 类型 | 必填 | 说明 |
|-----------------|---------|------|------------------------------------------------------------|
| material_id | integer | 是 | 物料 ID |
| quantity | integer | 是 | 入库数量,必须大于 0 |
| warning_stock | number | 否 | 为该物料设置的预警阈值,`0` 表示"库存降到 0 时预警";**不传表示不改动已存的阈值** |
| warning_enabled | boolean | 否 | 预警是否启用;**不传默认启用**,传 `false` 关闭预警。仅在同时传了 `warning_stock` 时生效 |
> `warning_stock` 与库存更新在同一个事务里落库,保证物料的第一个阈值和第一行库存同时生效。编辑旧入库单时**不传**这两个字段(而不是传 `0` 或 `false`),才不会把别处刚调整好的配置覆盖回去。
>
> 关闭预警而想保留原阈值时,把该物料当前的阈值连同 `warning_enabled: false` 一起传回即可(前端表单正是这么做的)。
>
`inbound_no` 由服务端按 `IN-` 加 8 位订单 ID 自动生成;`operator` 为创建该单据的当前登录用户。二者均由服务端维护,不能由请求体指定。
**响应:** `201 Created`,`data` 为创建后的入库单,结构同 `GET /inbound/:id`。
**状态码:**
- `201 Created` — 创建成功
- `400 Bad Request` — 请求体非法、物料不存在,或 `warning_stock` 为负数
---
### PUT /inbound/:id
更新入库单。服务端会先恢复原明细造成的库存变化,再按新明细重新入库,两步在同一事务内完成。
**路径参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|---------|------|----------|
| id | integer | 是 | 入库单 ID |
**请求体:** 字段同 `POST /inbound`。`warning_stock` 和 `warning_enabled` 同样遵循「不传即不改动」的语义。
**响应:** `200 OK`,`data` 为更新后的入库单。
**状态码:**
- `200 OK` — 更新成功
- `400 Bad Request` — 请求体非法、物料不存在,或 `warning_stock` 为负数
- `500 Internal Server Error` — 入库单不存在等错误
---
### DELETE /inbound/:id
删除入库单。同时**恢复该单增加的库存**并删除对应的库存流水。
**路径参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|---------|------|----------|
| id | integer | 是 | 入库单 ID |
**响应:**
```json
{
"code": 0,
"message": "Inbound order deleted successfully",
"data": null
}
```
**状态码:**
- `200 OK` — 删除成功
- `400 Bad Request` — 入库单 ID 非法
- `500 Internal Server Error` — 入库单不存在等错误
---
### GET /inbound/materials/:materialId
查询某个物料的入库记录。**该接口不分页**,`data` 直接是数组。
**路径参数:**
| 参数 | 类型 | 必填 | 说明 |
|------------|---------|------|----------|
| materialId | integer | 是 | 物料 ID |
**响应:**
```json
{
"code": 0,
"message": "success",
"data": [
{
"id": 1,
"inbound_id": 1,
"handler": "张三",
"supplier": "ABC 钢材",
"quantity": 500,
"created_at": "2026-01-01T08:00:00Z"
}
]
}
```
**状态码:** `200 OK`、`400 Bad Request`
---
## 3. 出库接口
出库会校验可用库存,**库存不足时整单失败**。出库成功后同样会广播 WebSocket 库存变更。
### GET /outbound
分页查询出库单列表。
**查询参数:**
| 参数 | 类型 | 必填 | 说明 |
|--------|---------|------|----------------------------|
| search | string | 否 | 按经手人或领用人模糊搜索 |
| limit | integer | 否 | 每页条数,默认 20,上限 100 |
| offset | integer | 否 | 偏移量,默认 0 |
**响应**(`data` 为分页对象,此处只展示 `items` 内元素):
```json
{
"code": 0,
"message": "success",
"data": {
"items": [
{
"id": 1,
"outbound_date": "2026-01-01T08:00:00Z",
"receiver": "装配车间",
"status": "completed",
"created_at": "2026-01-01T08:00:00Z",
"updated_at": "2026-01-01T08:00:00Z"
}
],
"limit": 20,
"offset": 0,
"total": 1,
"has_more": false
}
}
```
**状态码:** `200 OK`、`401 Unauthorized`
---
### GET /outbound/:id
查询单个出库单,**包含明细行**。
**路径参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|---------|------|------------|
| id | integer | 是 | 出库单 ID |
**响应:**
```json
{
"code": 0,
"message": "success",
"data": {
"id": 1,
"outbound_date": "2026-01-01T08:00:00Z",
"handler": "李四",
"receiver": "装配车间",
"status": "completed",
"created_at": "2026-01-01T08:00:00Z",
"updated_at": "2026-01-01T08:00:00Z",
"items": [
{
"id": 1,
"outbound_order_id": 1,
"material_id": 1,
"material_code": "A01-0001",
"material_name": "内六角螺栓",
"quantity": 100
}
]
}
}
```
**状态码:**
- `200 OK` — 成功
- `400 Bad Request` — 出库单 ID 非法
- `404 Not Found` — 出库单不存在
---
### POST /outbound
创建出库单。库存立即扣减,库存不足时整单失败。
**请求体:**
```json
{
"handler": "李四",
"receiver": "装配车间",
"remark": "急用",
"items": [
{ "material_id": 1, "quantity": 100 },
{ "material_id": 2, "quantity": 50 }
]
}
```
| 字段 | 类型 | 必填 | 说明 |
|----------|--------|------|------------------|
| receiver | string | 是 | 领用人 |
| remark | string | 否 | 备注 |
| items | array | 是 | 明细,至少 1 行 |
**明细字段:**
| 字段 | 类型 | 必填 | 说明 |
|-------------|---------|------|----------------------|
| material_id | integer | 是 | 物料 ID |
| quantity | integer | 是 | 出库数量,必须大于 0 |
`outbound_no` 由服务端按 `OUT-` 加 8 位订单 ID 自动生成;`operator` 与 `handler` 均由服务端设置为当前登录用户名,不能由请求体指定。
**响应:** `201 Created`,`data` 为创建后的出库单,结构同 `GET /outbound/:id`。
**状态码:**
- `201 Created` — 创建成功
- `400 Bad Request` — 请求体非法、物料不存在,或**库存不足**
---
### PUT /outbound/:id
更新出库单。服务端先恢复原明细扣减的库存,再按新明细重新扣减,两步在同一事务内完成。
**路径参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|---------|------|------------|
| id | integer | 是 | 出库单 ID |
**请求体:** 字段同 `POST /outbound`。
**响应:** `200 OK`,`data` 为更新后的出库单。
**状态码:**
- `200 OK` — 更新成功
- `400 Bad Request` — 请求体非法、物料不存在,或库存不足
- `500 Internal Server Error` — 出库单不存在等错误
---
### DELETE /outbound/:id
删除出库单。同时**把已扣减的库存加回来**并删除对应的库存流水。
**路径参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|---------|------|------------|
| id | integer | 是 | 出库单 ID |
**响应:**
```json
{
"code": 0,
"message": "Outbound order deleted successfully",
"data": null
}
```
**状态码:**
- `200 OK` — 删除成功
- `400 Bad Request` — 出库单 ID 非法
- `500 Internal Server Error` — 出库单不存在等错误
---
### GET /outbound/materials/:materialId
查询某个物料的出库记录。**该接口不分页**,`data` 直接是数组。
**路径参数:**
| 参数 | 类型 | 必填 | 说明 |
|------------|---------|------|----------|
| materialId | integer | 是 | 物料 ID |
**响应:**
```json
{
"code": 0,
"message": "success",
"data": [
{
"id": 1,
"outbound_id": 1,
"handler": "李四",
"receiver": "装配车间",
"quantity": 100,
"created_at": "2026-01-01T08:00:00Z"
}
]
}
```
**状态码:** `200 OK`、`400 Bad Request`
---
## 4. 库存接口
### 库存状态的判定规则
整套预警逻辑只有一句话:
```
参与预警 = 启用
报警 = 参与预警 AND 现有库存 <= 阈值
```
展开到接口行为上:
- **库存查询(`GET /stock`)只列「入过库」的物料**。判断依据是存在库存记录行,而不是库存数量大于 0——所以**已被领用到 0 的物料仍然会列出**,而只存在于主数据、从未入库的物料不会出现。已作废的物料也不列出。
- **阈值为 0 是合法配置**,含义是"库存降到 0 时预警"。
- **「不设置预警」等同于「不启用预警」**,两者是同一个状态。从没配过规则和手动关闭预警的物料,接口统一返回 `warning_enabled: false`,都不报警。需要区分这两者做表单预填时看 `warning_configured`(它只表示"配没配过规则",不参与判定)。
- **关闭预警时阈值本身保留**,随时可以重新启用。
| 是否入过库 | 是否配过规则 | 现有库存 | 阈值 | 启用 | 状态 |
|------------|--------------|----------|------|------|----------|
| 否 | — | 0 | — | — | 不在列表中 |
| 是 | 否 | 0 | — | false | 正常 |
| 是 | 是 | 5 | 0 | 是 | 正常 |
| 是 | 是 | 0 | 0 | 是 | 库存不足 |
| 是 | 是 | 0 | 10 | 是 | 库存不足 |
| 是 | 是 | 10 | 10 | 是 | 库存不足 |
| 是 | 是 | 15 | 10 | 是 | 正常 |
| 是 | 是 | 0 | 10 | 否 | 正常 |
### GET /stock
分页查询库存,只包含入过库且未作废的物料。
**查询参数:**
| 参数 | 类型 | 必填 | 说明 |
|--------|---------|------|----------------------------|
| search | string | 否 | 按物料编码或名称模糊搜索 |
| limit | integer | 否 | 每页条数,默认 20,上限 100 |
| offset | integer | 否 | 偏移量,默认 0 |
**响应**(`data` 为分页对象,此处只展示 `items` 内元素):
```json
{
"code": 0,
"message": "success",
"data": {
"items": [
{
"material_id": 1,
"material_code": "A01-0001",
"name": "内六角螺栓",
"spec": "M8x30",
"unit": "个",
"major_category": "A01",
"minor_category": "A01-01",
"total_on_hand": "500",
"warning_stock": "100",
"warning_enabled": true,
"warning_configured": true,
"stock_status": "normal"
}
],
"limit": 20,
"offset": 0,
"total": 1,
"has_more": false
}
}
```
| 字段 | 类型 | 说明 |
|--------------------|---------|------------------------------------------------------------|
| major_category | string | 大类分类编码 |
| minor_category | string | 小类分类编码 |
| total_on_hand | string | 现有库存合计,以字符串返回以免精度丢失 |
| warning_stock | string | 预警阈值,`"0"` 表示"库存降到 0 时预警" |
| warning_enabled | boolean | 预警是否启用。没配过规则和手动关闭都是 `false` |
| warning_configured | boolean | 是否配过预警规则。仅供表单预填区分"从没配过"和"已关闭",不参与判定 |
| stock_status | string | `low`(库存不足)或 `normal`(正常) |
**状态码:** `200 OK`、`401 Unauthorized`
---
### GET /stock/:materialId
查询单个物料的库存。与 `GET /stock` 不同,**这个接口对任何物料都会给出答案**,包括从未入库的物料(返回 `total_on_hand` 为 0)。物料申请和出库表单依赖这个特性逐个查询物料。
**路径参数:**
| 参数 | 类型 | 必填 | 说明 |
|------------|---------|------|----------|
| materialId | integer | 是 | 物料 ID |
**响应:**
```json
{
"code": 0,
"message": "success",
"data": {
"material_id": 1,
"material_code": "A01-0001",
"name": "内六角螺栓",
"spec": "M8x30",
"unit": "个",
"major_category": "A01",
"minor_category": "A01-01",
"total_on_hand": "500",
"warning_stock": "100",
"warning_enabled": true,
"warning_configured": true,
"stock_status": "normal"
}
}
```
> `PUT /stock/:materialId/warning` 的入库表单预填依赖 `warning_configured`:没配过规则的物料要按"默认启用 + 阈值 0"预填,配过但被关闭的物料必须保持关闭,否则入库会把别人刚关掉的预警悄悄重新打开。
**状态码:**
- `200 OK` — 成功
- `400 Bad Request` — 物料 ID 非法
- `404 Not Found` — 物料不存在
---
### GET /stock/:materialId/details
查询某个物料在各库存维度(组织 / 仓库 / 库位)上的明细。**该接口不分页**,`data` 直接是数组;物料存在但从没有过库存记录时返回空数组。
**路径参数:**
| 参数 | 类型 | 必填 | 说明 |
|------------|---------|------|----------|
| materialId | integer | 是 | 物料 ID |
**响应:**
```json
{
"code": 0,
"message": "success",
"data": [
{
"id": 1,
"organization_id": 1,
"organization": "默认组织",
"warehouse_id": 1,
"warehouse": "默认仓库",
"location_id": 1,
"location": "主库位",
"material_id": 1,
"on_hand": "500",
"reserved": "0",
"locked": "0",
"in_transit": "0",
"updated_at": "2026-01-01T08:00:00Z"
}
]
}
```
| 字段 | 类型 | 说明 |
|------------|---------|------------------------|
| on_hand | string | 现有库存 |
| reserved | string | 预留数量 |
| locked | string | 锁定数量 |
| in_transit | string | 在途数量 |
> `reserved`、`locked`、`in_transit` 三个维度当前没有任何业务写入,恒为 `0`;只有 `on_hand` 会随出入库变动。作废校验会把这四项一起纳入判断。
**状态码:**
- `200 OK` — 成功
- `400 Bad Request` — 物料 ID 非法
- `404 Not Found` — 物料不存在
---
### GET /stock/warning
分页查询需要关注的物料:预警处于启用状态,并且现有库存已触达阈值。阈值为 0 的物料在库存降到 0 时也会出现在这里。
**查询参数:** `limit`(默认 20,上限 100)、`offset`(默认 0)。
**响应**(`data` 为分页对象,此处只展示 `items` 内元素):
```json
{
"code": 0,
"message": "success",
"data": {
"items": [
{
"material_id": 2,
"material_code": "A02-0003",
"name": "铝棒",
"spec": "20mm x 3000mm",
"unit": "根",
"major_category": "A02",
"minor_category": "A02-01",
"total_on_hand": "30",
"warning_stock": "100",
"warning_enabled": true,
"warning_configured": true,
"stock_status": "low"
}
],
"limit": 20,
"offset": 0,
"total": 1,
"has_more": false
}
}
```
**状态码:** `200 OK`、`401 Unauthorized`
---
### PUT /stock/:materialId/warning
设置、调整或停用某个物料的预警阈值。
**路径参数:**
| 参数 | 类型 | 必填 | 说明 |
|------------|---------|------|----------|
| materialId | integer | 是 | 物料 ID |
**请求体:**
```json
{
"warning_stock": 100,
"enabled": true
}
```
| 字段 | 类型 | 必填 | 说明 |
|---------------|---------|------|------------------------------------------------------------|
| warning_stock | number | 是 | 阈值,必须大于等于 0。`0` 是合法值,表示"库存降到 0 时预警" |
| enabled | boolean | 否 | 默认 `true`;传 `false` 停用该物料的预警,阈值本身保留 |
**响应:**
```json
{
"code": 0,
"message": "Stock warning updated successfully",
"data": null
}
```
**状态码:**
- `200 OK` — 成功
- `400 Bad Request` — 物料 ID 非法,或 `warning_stock` 为负数
---
### GET /stock/logs
分页查询全部库存流水,按时间倒序。
**查询参数:** `limit`(默认 20,上限 100)、`offset`(默认 0)。
**响应**(`data` 为分页对象,此处只展示 `items` 内元素):
```json
{
"code": 0,
"message": "success",
"data": {
"items": [
{
"id": 2,
"material_id": 1,
"change_type": "outbound",
"change_quantity": "-100",
"before_quantity": "500",
"after_quantity": "400",
"reference_id": 1,
"reference_type": "outbound",
"created_at": "2026-01-01T10:00:00Z"
}
],
"limit": 20,
"offset": 0,
"total": 1,
"has_more": false
}
}
```
| 字段 | 类型 | 说明 |
|-----------------|--------|------------------------------------------------|
| change_type | string | `inbound`(入库)或 `outbound`(出库) |
| change_quantity | string | **带符号**的变动数量,出库为负 |
| before_quantity | string | 变动前库存 |
| after_quantity | string | 变动后库存 |
| reference_type | string | 来源单据类型,如 `inbound`、`outbound` |
| reference_id | integer| 来源单据 ID |
**状态码:** `200 OK`、`401 Unauthorized`
---
### GET /stock/logs/:materialId
分页查询某个物料的库存流水。
**路径参数:**
| 参数 | 类型 | 必填 | 说明 |
|------------|---------|------|----------|
| materialId | integer | 是 | 物料 ID |
**查询参数:** `limit`(默认 20,上限 100)、`offset`(默认 0)。
**响应**:结构与 `GET /stock/logs` 相同。`change_quantity` 采用带符号约定:入库为正数、出库为负数;`change_type` 仍用于展示入库/出库文字、样式和筛选。
**状态码:**
- `200 OK` — 成功
- `404 Not Found` — 物料不存在
---
## 状态码汇总
| 状态码 | 说明 |
|--------|--------------------------------------------------|
| 200 | 请求成功 |
| 201 | 资源创建成功 |
| 400 | 请求体或参数非法、物料不存在、库存不足 |
| 401 | 未登录或令牌失效 |
| 403 | 已登录但权限不足(如非管理员访问管理员接口) |
| 404 | 资源不存在 |
| 409 | 状态冲突:物料仍有库存时作废,或本公司仓库/库位缺失、停用而禁止库存写操作 |
| 500 | 服务端错误 |