Compare commits
7 Commits
aa017a32b4
...
d62d6777d1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d62d6777d1 | ||
|
|
e1401532f1 | ||
|
|
0336181832 | ||
|
|
6933cd82cc | ||
|
|
0c7da9d394 | ||
|
|
b75696f44a | ||
|
|
5d508cd2bb |
|
|
@ -405,6 +405,14 @@
|
|||
- 预警以可用库存而非账面库存判断。
|
||||
- 查询支持组织、仓库、分类、物料类型和处理状态筛选。
|
||||
|
||||
#### 当前实现记录(2026-09-23)
|
||||
|
||||
- “库存管理”菜单下保持“库存查询”“入库管理”“出库管理”三个入口。
|
||||
- 仓库和库位基础数据已可在“库存查询 → 仓库设置”中维护,但多仓库和多库位业务流程尚未完全实现。
|
||||
- 系统目前仍主要使用默认仓库 `DEFAULT` 和主库位 `MAIN` 处理入库、出库及库存相关数据。
|
||||
- 待实现:在“库存查询”页面的“仓库设置”旁增加“切换仓库”入口,将所选仓库作为全局当前仓库,并统一应用到库存查询、库存预警、库存流水、入库管理和出库管理。
|
||||
- 待进一步设计:多库位场景下入库、出库的库位选择规则,以及库存查询按库位筛选和展示的交互。
|
||||
|
||||
---
|
||||
|
||||
### 5.6 库存变动管理
|
||||
|
|
|
|||
498
docs/superpowers/specs/2026-09-22-bom-management-design.md
Normal file
498
docs/superpowers/specs/2026-09-22-bom-management-design.md
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
# BOM 管理第一版设计与初始规划
|
||||
|
||||
**日期:** 2026-09-22
|
||||
|
||||
**状态:** 初稿,待评审
|
||||
|
||||
**适用范围:** warehouse-mms 第一版 BOM 管理
|
||||
|
||||
## 1. 目标
|
||||
|
||||
为成品和半成品提供可追溯的多层 BOM 手工维护能力。第一版重点完成 BOM 的创建、草稿编辑、版本保留、生效与停用、结构合法性校验,以及单个 BOM 按指定数量递归展开的基础能力。
|
||||
|
||||
第一版不延伸到制造执行和库存业务,BOM 操作不会生成领料申请、扣减库存或创建出入库单据。
|
||||
|
||||
## 2. 第一版范围
|
||||
|
||||
### 2.1 包含
|
||||
|
||||
- 通过树形界面手工维护 BOM。
|
||||
- 为成品或半成品建立多个 BOM 版本。
|
||||
- 创建首个 BOM 草稿。
|
||||
- 查看 BOM 列表、结构和历史版本。
|
||||
- 基于指定历史版本完整复制明细,生成新草稿。
|
||||
- 编辑、保存和删除草稿。
|
||||
- 生效草稿或停用指定的生效版本。
|
||||
- 自动校验重复子项、自引用、循环引用、无效数量和最大层级。
|
||||
- 按指定产品数量递归展开 BOM,并汇总最底层物料需求。
|
||||
- 记录生效和停用等关键状态变化的审计信息。
|
||||
- 第一版页面和接口仅允许超级管理员访问。
|
||||
|
||||
### 2.2 不包含
|
||||
|
||||
- CSV 或 Excel 批量导入。
|
||||
- BOM 审批、驳回和申请流程。
|
||||
- 替代料。
|
||||
- 多 BOM 需求试算。
|
||||
- 制造订单。
|
||||
- 领料、退料和完工入库。
|
||||
- 库存预留、库存扣减和短缺计算。
|
||||
- 面向技术人员等其他角色的细分权限。
|
||||
|
||||
## 3. 核心业务规则
|
||||
|
||||
### 3.1 版本与状态
|
||||
|
||||
BOM 版本仅使用以下三个状态:
|
||||
|
||||
| 状态 | 含义 | 允许操作 |
|
||||
| --- | --- | --- |
|
||||
| `draft` | 草稿 | 查看、编辑、保存、生效、删除 |
|
||||
| `effective` | 可用 | 查看、被新 BOM 选择、停用、基于该版本新建草稿 |
|
||||
| `obsolete` | 已停用 | 查看历史、基于该版本新建草稿;不能用于新 BOM |
|
||||
|
||||
规则如下:
|
||||
|
||||
1. 同一父物料的版本号必须唯一。
|
||||
2. 同一父物料可以同时存在多个 `effective`(可用)版本。
|
||||
3. 默认版本是尚未停用且最近一次生效的版本,按生效时间排序;同一时间时按版本 ID 确定唯一结果。停用默认版本后,剩余可用版本中最近生效的版本成为默认版本;全部停用时无默认版本。
|
||||
4. 新版本生效不改变其他可用版本的状态。非默认的可用版本仍可被新建的上层 BOM 明确选择。
|
||||
5. 可用版本不可直接修改。修改可用或已停用版本时,必须先完整复制所选版本,生成新的草稿版本。
|
||||
6. 已停用版本不能恢复为可用,但可以作为新版本的复制来源;它不能被新建的上层 BOM 选用。
|
||||
7. 草稿可删除;可用和已停用版本只保留、不物理删除。
|
||||
|
||||
### 3.2 父子关系
|
||||
|
||||
- 每个 BOM 版本只保存该父物料的直接子物料。
|
||||
- 一个子物料可以被多个不同父物料引用。
|
||||
- 同一 BOM 版本、同一直接父节点下,相同子物料只能出现一行。
|
||||
- 同一物料可出现在整棵 BOM 树的不同父节点下。
|
||||
- 同一父节点下不同安装位置的相同物料合并数量,位置和用途由用户手动写入备注。第一版不自动生成备注。
|
||||
- 子项使用物料的基础单位,第一版不允许在 BOM 中选择其他单位或进行单位换算。
|
||||
|
||||
每个 BOM 子项至少记录:
|
||||
|
||||
- 子物料。
|
||||
- 单位用量。
|
||||
- 基础单位。
|
||||
- 损耗率,默认 `0%`。
|
||||
- 备注。
|
||||
- 锁定的下层 BOM 版本,可为空。
|
||||
- 排序序号。
|
||||
|
||||
### 3.3 下层版本锁定
|
||||
|
||||
当子物料存在可用 BOM 时,编辑上层草稿可选择其任意可用版本;页面默认预选最近一次生效的版本。上层版本生效时,锁定用户确认的下层 BOM 版本。下层 BOM 以后产生新版本,不改变已经生效的上层版本。
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
整机 A V3
|
||||
├─ 外壳组件 V2
|
||||
└─ 电机组件 V1
|
||||
```
|
||||
|
||||
即使电机组件 V2 后续生效,整机 A V3 仍按电机组件 V1 展开。电机组件 V1 即使后来停用,整机 A V3 的历史结构仍可查看和展开;但新建上层版本时不能再选择已停用的电机组件 V1。整机需要采用其他下层版本时,应创建整机 A 的新草稿版本。
|
||||
|
||||
### 3.4 层级与循环保护
|
||||
|
||||
- BOM 最多支持 20 层。
|
||||
- 父物料不能直接引用自身。
|
||||
- 禁止任意深度的循环引用,例如 `A → B → C → A`。
|
||||
- 添加子物料时,前端立即检查直接重复和自引用。
|
||||
- 保存草稿时,后端检查数量、重复、自引用、循环引用和最大层级。
|
||||
- 生效时,后端在同一事务中再次执行完整校验。
|
||||
- 递归展开时再次执行循环和最大深度保护,不能仅依赖保存时的校验。
|
||||
- 检测失败时返回完整问题路径,并让前端定位对应节点。
|
||||
|
||||
### 3.5 数量与损耗
|
||||
|
||||
- 单位用量必须大于零。
|
||||
- 损耗率不得小于零。
|
||||
- 每层需求量按以下规则计算:
|
||||
|
||||
```text
|
||||
子项需求量 = 上层需求量 × 单位用量 ×(1 + 损耗率)
|
||||
```
|
||||
|
||||
- 递归计算过程中保留足够精度,最终结果按物料基础单位的精度规则向上取整。
|
||||
- 汇总结果按物料和基础单位合并相同的最底层物料。
|
||||
|
||||
## 4. 前端信息架构
|
||||
|
||||
左侧导航在“物料主数据”和“公司组织”之间增加独立大类:
|
||||
|
||||
```text
|
||||
物料主数据
|
||||
├─ 物料
|
||||
└─ 基础配置
|
||||
|
||||
BOM 管理
|
||||
└─ BOM 维护
|
||||
|
||||
公司组织
|
||||
├─ 组织管理
|
||||
├─ 用户管理
|
||||
└─ 角色与权限
|
||||
|
||||
日志记录
|
||||
├─ 物料变更日志
|
||||
└─ BOM 记录
|
||||
```
|
||||
|
||||
“多 BOM 需求试算”属于后续能力,第一版不展示空菜单。
|
||||
|
||||
现有独立菜单“日志记录”改为左侧导航大类。现有物料日志页面归入“物料变更日志”,BOM 事件展示在新建的“BOM 记录”子页面。原有 `/logs` 地址应保持可访问,并跳转到物料变更日志页面,避免旧链接失效。
|
||||
|
||||
### 4.1 BOM 维护
|
||||
|
||||
BOM 维护统一包含以下四类操作:
|
||||
|
||||
```text
|
||||
BOM 列表
|
||||
→ 查询、查看结构、查看历史版本、发起新版本
|
||||
|
||||
新建 BOM
|
||||
→ 为尚未建立 BOM 的父物料创建第一个草稿版本
|
||||
|
||||
编辑草稿
|
||||
→ 编辑明细、保存草稿、删除草稿
|
||||
|
||||
状态操作
|
||||
→ 生效或停用
|
||||
```
|
||||
|
||||
结构校验不作为独立按钮或菜单。系统在添加、保存和生效过程中自动校验。
|
||||
|
||||
树形结构维护是编辑页面的交互方式,不作为独立功能入口。
|
||||
|
||||
### 4.2 BOM 列表
|
||||
|
||||
列表以父物料为主行,避免历史版本过多导致列表膨胀:
|
||||
|
||||
```text
|
||||
父项物料 默认版本 可用版本数 草稿版本 操作
|
||||
整机 A V2 2 V3 查看结构|编辑草稿|历史版本
|
||||
电机组件 V1 1 无 查看结构|新建版本|历史版本
|
||||
```
|
||||
|
||||
点击列表中的“历史版本”,弹出独立窗口展示该父物料的全部版本,不在列表行内展开,也不使用侧边抽屉。窗口内可查看版本结构,或选择指定版本发起新版本:
|
||||
|
||||
```text
|
||||
┌─ 整机 A · 历史版本 ─────────────────────────────── [关闭] ┐
|
||||
│ 版本 状态 操作 │
|
||||
│ V3 草稿 查看结构|编辑草稿 │
|
||||
│ V2 可用·默认 查看结构|新建版本|停用 │
|
||||
│ V1 可用 查看结构|新建版本|停用 │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
列表至少支持:
|
||||
|
||||
- 按父项物料编码或名称查询。
|
||||
- 按是否存在草稿、是否存在可用版本筛选。
|
||||
- 查看当前或历史版本结构。
|
||||
- 编辑已有草稿。
|
||||
- 从指定历史版本复制并发起新版本。
|
||||
- 执行当前状态允许的状态操作。
|
||||
|
||||
“查看结构”页面默认按父物料数量 `1` 展示完整层级。用户可以临时修改查看数量,页面随即递归计算各层需求,并展示最底层物料汇总。该能力只计算当前一个 BOM 版本,不单独设置菜单,也不保存试算记录。
|
||||
|
||||
```text
|
||||
查看数量:10
|
||||
|
||||
[层级结构]
|
||||
整机 A × 10
|
||||
├─ 外壳组件 × 10
|
||||
│ └─ 螺丝 × 80
|
||||
└─ 电机组件 × 20
|
||||
└─ 螺丝 × 80
|
||||
|
||||
[底层物料汇总]
|
||||
螺丝 × 160
|
||||
```
|
||||
|
||||
同时选择多个 BOM 并合并需求,属于后续“多 BOM 需求试算”,不在第一版范围内。
|
||||
|
||||
### 4.3 新建 BOM
|
||||
|
||||
新建 BOM 只面向尚未建立 BOM 的成品或半成品。页面包含:
|
||||
|
||||
- 父项物料选择。
|
||||
- 初始版本号。
|
||||
- 直接子物料树或列表。
|
||||
- 子项单位用量、基础单位、损耗率和备注。
|
||||
- 保存草稿。
|
||||
|
||||
已存在 BOM 的父物料不能再次走“新建 BOM”,应从列表或历史版本选择来源并执行“新建版本”。
|
||||
|
||||
### 4.4 编辑草稿
|
||||
|
||||
编辑页采用左侧结构树、右侧节点明细的布局:
|
||||
|
||||
```text
|
||||
┌───────────────────────┬──────────────────────────────┐
|
||||
│ BOM 结构树 │ 当前节点明细 │
|
||||
│ │ │
|
||||
│ ▼ 整机 A │ 子物料:电机组件 │
|
||||
│ ├─ 外壳组件 × 1 │ 单位用量:2 │
|
||||
│ ├─ 电机组件 × 2 │ 基础单位:套 │
|
||||
│ └─ 控制箱 × 1 │ 损耗率:0% │
|
||||
│ │ 备注:安装位置说明 │
|
||||
│ [+ 添加子物料] │ │
|
||||
└───────────────────────┴──────────────────────────────┘
|
||||
```
|
||||
|
||||
编辑草稿允许:
|
||||
|
||||
- 添加、修改、删除和排序直接子物料。
|
||||
- 修改用量、损耗率和备注。
|
||||
- 保存草稿。
|
||||
- 删除整个草稿。
|
||||
|
||||
对于树中引用的下层 BOM,草稿编辑页提供可用版本选择器,并默认选择最近一次生效的版本。页面展示所选版本的结构,并提供跳转到该物料 BOM 的入口;不在上层 BOM 中直接修改下层 BOM。可用版本停用后,已锁定它的历史上层版本仍可查看和展开;尚未生效的上层草稿必须改选可用版本后才能生效。
|
||||
|
||||
### 4.5 新建版本
|
||||
|
||||
“新建版本”从 BOM 列表或历史版本发起,不属于编辑草稿内部操作:
|
||||
|
||||
```text
|
||||
选择一个历史版本
|
||||
↓
|
||||
完整复制该版本的 BOM 明细
|
||||
↓
|
||||
生成新的草稿版本
|
||||
↓
|
||||
进入草稿编辑页
|
||||
```
|
||||
|
||||
每次只复制一个明确选中的来源版本,不复制该父物料的全部历史版本。
|
||||
|
||||
### 4.6 状态操作
|
||||
|
||||
页面根据状态显示操作:
|
||||
|
||||
```text
|
||||
草稿
|
||||
├─ 查看
|
||||
├─ 编辑草稿
|
||||
├─ 生效
|
||||
└─ 删除草稿
|
||||
|
||||
可用
|
||||
├─ 查看
|
||||
├─ 停用
|
||||
└─ 新建版本
|
||||
|
||||
已停用
|
||||
├─ 查看
|
||||
└─ 新建版本
|
||||
```
|
||||
|
||||
生效和停用都需要二次确认。新版本生效后成为默认版本,其他可用版本保持可用。停用默认版本时,确认提示应说明哪个剩余可用版本会成为新默认版本;没有剩余可用版本时提示该父物料将暂无默认版本。
|
||||
|
||||
## 5. 数据模型建议
|
||||
|
||||
第一版建议使用三个 BOM 核心表,以及独立的 BOM 操作记录表。现有“物料变更日志”依赖物料 ID 和物料变更字段,不能直接承载 BOM 版本事件。
|
||||
|
||||
### 5.1 `bom_headers`
|
||||
|
||||
代表某个父物料的 BOM 主体,而不是具体版本。
|
||||
|
||||
建议字段:
|
||||
|
||||
- `id`
|
||||
- `organization_id`
|
||||
- `parent_material_id`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
约束:同一组织下 `parent_material_id` 唯一。
|
||||
|
||||
### 5.2 `bom_versions`
|
||||
|
||||
建议字段:
|
||||
|
||||
- `id`
|
||||
- `organization_id`
|
||||
- `bom_header_id`
|
||||
- `version`
|
||||
- `status`
|
||||
- `source_version_id`,记录复制来源,可为空。
|
||||
- `effective_at`
|
||||
- `obsoleted_at`
|
||||
- `created_by`
|
||||
- `updated_by`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
约束:
|
||||
|
||||
- 同一 `bom_header_id` 下版本号唯一。
|
||||
- 为 `bom_header_id`、`status` 和 `effective_at` 建立查询索引。默认版本由可用版本的生效时间和版本 ID 确定,不单独保存可变的默认标记。
|
||||
|
||||
### 5.3 `bom_items`
|
||||
|
||||
建议字段:
|
||||
|
||||
- `id`
|
||||
- `organization_id`
|
||||
- `bom_version_id`
|
||||
- `child_material_id`
|
||||
- `quantity`
|
||||
- `unit_id`
|
||||
- `loss_rate`
|
||||
- `remark`
|
||||
- `locked_child_version_id`
|
||||
- `sort_order`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
约束:同一 `bom_version_id` 下 `child_material_id` 唯一。
|
||||
|
||||
树形层级不直接存成固定层级字段。系统通过各父物料 BOM 版本之间的引用递归组成完整树,因此支持 10 层或 20 层时不需要改变表结构。
|
||||
|
||||
### 5.4 BOM 操作记录
|
||||
|
||||
独立记录每次 BOM 状态变化,至少保存经营组织、父物料、BOM 版本、操作类型、操作人、操作时间和说明。生效与停用各记录一次;默认版本随这些状态变化而自动重新计算,不生成额外的停用记录。
|
||||
|
||||
## 6. 后端职责与接口草案
|
||||
|
||||
后端延续现有 Go 分层结构:
|
||||
|
||||
```text
|
||||
model
|
||||
→ 定义 BOM、版本、子项和展开结果
|
||||
|
||||
repository
|
||||
→ 负责数据库查询、事务和状态更新
|
||||
|
||||
service
|
||||
→ 负责版本规则、复制、校验、生效、停用和递归展开
|
||||
|
||||
handler
|
||||
→ 负责 HTTP 参数、超级管理员鉴权和统一响应
|
||||
```
|
||||
|
||||
接口草案:
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/boms` | 查询 BOM 主列表 |
|
||||
| `POST` | `/api/boms` | 创建首个草稿版本 |
|
||||
| `GET` | `/api/boms/:id` | 查看 BOM 主体及当前版本摘要 |
|
||||
| `GET` | `/api/boms/:id/versions` | 查看历史版本 |
|
||||
| `GET` | `/api/bom-versions/:versionId` | 查看指定版本和结构 |
|
||||
| `PUT` | `/api/bom-versions/:versionId` | 保存草稿 |
|
||||
| `DELETE` | `/api/bom-versions/:versionId` | 删除草稿 |
|
||||
| `POST` | `/api/bom-versions/:versionId/copy` | 从指定版本生成新草稿 |
|
||||
| `POST` | `/api/bom-versions/:versionId/effective` | 生效草稿 |
|
||||
| `POST` | `/api/bom-versions/:versionId/obsolete` | 停用生效版本 |
|
||||
| `POST` | `/api/bom-versions/:versionId/explode` | 按指定数量递归展开单个 BOM |
|
||||
|
||||
接口命名在实施计划阶段可按现有路由风格进一步统一,但业务边界不应改变。
|
||||
|
||||
## 7. 自动校验与错误反馈
|
||||
|
||||
前端校验用于即时反馈,后端校验是最终可信规则。
|
||||
|
||||
| 时机 | 校验内容 |
|
||||
| --- | --- |
|
||||
| 选择子物料 | 当前父节点重复、自引用、物料状态 |
|
||||
| 保存草稿 | 数量、损耗率、基础单位、重复、自引用、循环、20 层限制 |
|
||||
| 生效 | 完整重复执行保存校验,并验证所选下层版本仍可用 |
|
||||
| 递归展开 | 循环保护、20 层限制、版本引用完整性 |
|
||||
|
||||
错误信息需要包含:
|
||||
|
||||
- 错误类型。
|
||||
- 父物料和子物料编码。
|
||||
- 完整问题路径。
|
||||
- 用户可执行的修改建议。
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
无法保存:检测到循环引用。
|
||||
问题路径:整机 A → 电机组件 → 控制模块 → 整机 A。
|
||||
请删除导致循环的子物料关系。
|
||||
```
|
||||
|
||||
## 8. 权限与审计
|
||||
|
||||
- 第一版菜单、页面和全部 BOM 接口仅超级管理员可访问。
|
||||
- 不能只通过前端隐藏菜单实现权限控制,后端接口必须再次鉴权。
|
||||
- 预留独立 BOM 权限边界,后续权限系统完善后再拆分查看、维护和状态操作权限。
|
||||
- 创建、复制、生效、停用和删除草稿记录操作者、时间、目标版本和结果。
|
||||
|
||||
### 8.1 日志记录页面
|
||||
|
||||
左侧“日志记录”展开后显示两个子页面:
|
||||
|
||||
```text
|
||||
日志记录
|
||||
├─ 物料变更日志 → 现有物料新增、修改、删除记录
|
||||
└─ BOM 记录 → BOM 创建、复制、生效、停用、删除草稿
|
||||
```
|
||||
|
||||
“BOM 记录”页面提供父物料编码或名称、版本、操作类型和时间范围筛选。列表显示操作时间、父物料、版本、操作、操作人和说明;点击记录可查看状态变化。停用记录保留操作者及原因;若停用的是默认版本,详情展示停用后接替的默认版本或“暂无默认版本”。
|
||||
|
||||
建议路由为 `/logs/materials` 和 `/logs/boms`,原 `/logs` 跳转到 `/logs/materials`。两个子页面均仅超级管理员可访问。
|
||||
|
||||
## 9. 测试与验收重点
|
||||
|
||||
### 9.1 后端
|
||||
|
||||
- 创建首个草稿和保存明细。
|
||||
- 同一父物料版本号重复。
|
||||
- 同一版本直接子项重复。
|
||||
- 父项直接引用自身。
|
||||
- 三层及更深循环引用,并返回完整路径。
|
||||
- 第 20 层允许,第 21 层拒绝。
|
||||
- 零数量、负数量和负损耗率拒绝。
|
||||
- 生效版本禁止修改和删除。
|
||||
- 多个版本可同时可用,新版本生效后成为默认版本,旧可用版本状态不变。
|
||||
- 停用默认版本后,默认版本按最近生效时间回退;无可用版本时默认版本为空。
|
||||
- 从指定版本完整复制为新草稿。
|
||||
- 下层版本锁定后不随新版本变化。
|
||||
- 多层需求数量、损耗率和底层物料汇总正确。
|
||||
- 非超级管理员访问全部 BOM 接口均被拒绝。
|
||||
- 不同组织的数据不能相互读取或引用。
|
||||
- BOM 状态变化与对应操作记录同时提交。
|
||||
|
||||
### 9.2 前端
|
||||
|
||||
- 导航位置、超级管理员可见性和路由保护。
|
||||
- 列表查询、主行展示,以及点击“历史版本”后打开独立弹窗查看全部版本。
|
||||
- 新建 BOM 时只允许选择尚未建立 BOM 的成品或半成品。
|
||||
- 草稿可编辑,可用和已停用版本只读。
|
||||
- 添加重复子项和自引用时立即提示。
|
||||
- 后端返回循环或深度错误时定位并标记问题节点。
|
||||
- 生效、停用和删除草稿具有明确确认提示。
|
||||
- 从指定历史版本创建草稿后,明细完整复制且可继续编辑。
|
||||
- “日志记录”展开后显示两个子页面;原 `/logs` 链接仍能打开物料变更日志。
|
||||
- “BOM 记录”支持筛选与查看生效和停用的操作详情。
|
||||
|
||||
## 10. 建议实施顺序
|
||||
|
||||
1. 建立 BOM 数据表、模型和仓储层。
|
||||
2. 实现草稿创建、查询、编辑和删除。
|
||||
3. 实现复制新版本及版本列表。
|
||||
4. 实现结构校验、20 层限制和完整循环路径反馈。
|
||||
5. 实现多版本可用、默认版本推导、生效和停用事务。
|
||||
6. 实现前端导航、BOM 列表和历史版本展示。
|
||||
7. 实现新建及草稿树形编辑页面。
|
||||
8. 实现单 BOM 递归展开及底层物料汇总能力。
|
||||
9. 将现有日志入口改为双子页面,接入 BOM 操作记录和筛选。
|
||||
10. 补齐权限、自动化测试和真实数据副本验收。
|
||||
|
||||
## 11. 后续阶段
|
||||
|
||||
第一版稳定后再分别设计和实现:
|
||||
|
||||
1. 多 BOM 需求试算,并在 `BOM 管理` 下增加独立菜单。
|
||||
2. CSV 层级导入和错误报告。
|
||||
3. 替代料管理。
|
||||
4. 面向技术人员等角色的细分权限。
|
||||
5. 制造订单、领料、完工和库存联动。
|
||||
6. 增加结构化的“使用位置明细”:在同一 BOM 子项下分别填写安装位置、用途和用量,系统自动合计该子项的总用量,并在可展开的 BOM 树节点下展示各位置明细。可根据这些明细自动生成备注,且允许人工修改备注;同一直接父节点下仍只保留一条该物料的 BOM 子项。
|
||||
50
frontend/src/components/business/BasePagination.test.js
Normal file
50
frontend/src/components/business/BasePagination.test.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { mount } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import BasePagination from './BasePagination.vue'
|
||||
|
||||
function mountPagination(props) {
|
||||
return mount(BasePagination, {
|
||||
props,
|
||||
global: { plugins: [ElementPlus] }
|
||||
})
|
||||
}
|
||||
|
||||
function infoText(wrapper) {
|
||||
return wrapper.find('.page-info').text()
|
||||
}
|
||||
|
||||
describe('分页文案', () => {
|
||||
test('中间页显示当前区间', () => {
|
||||
const wrapper = mountPagination({ currentPage: 2, pageSize: 20, total: 41 })
|
||||
expect(infoText(wrapper)).toBe('显示 21-40 条,共 41 条')
|
||||
})
|
||||
|
||||
test('末页不足一页时按实际条数收尾', () => {
|
||||
const wrapper = mountPagination({ currentPage: 3, pageSize: 20, total: 41 })
|
||||
expect(infoText(wrapper)).toBe('显示 41-41 条,共 41 条')
|
||||
})
|
||||
|
||||
test('空列表显示 0-0,不显示 1-0', () => {
|
||||
const wrapper = mountPagination({ currentPage: 1, pageSize: 20, total: 0 })
|
||||
expect(infoText(wrapper)).toBe('显示 0-0 条,共 0 条')
|
||||
})
|
||||
|
||||
// 删掉末页最后一条:total 已经缩到 40,父组件的 currentPage 还停在 3,
|
||||
// 文案得按收敛后的第 2 页算,不能出现「显示 41-40 条」
|
||||
test('total 缩减导致当前页超界时按收敛后的页码算', async () => {
|
||||
const wrapper = mountPagination({ currentPage: 3, pageSize: 20, total: 41 })
|
||||
await wrapper.setProps({ total: 40 })
|
||||
expect(infoText(wrapper)).toBe('显示 21-40 条,共 40 条')
|
||||
})
|
||||
|
||||
test('当前页远超总页数时收敛到末页', () => {
|
||||
const wrapper = mountPagination({ currentPage: 99, pageSize: 20, total: 41 })
|
||||
expect(infoText(wrapper)).toBe('显示 41-41 条,共 41 条')
|
||||
})
|
||||
|
||||
test('pageSize 缺省时按 10 兜底,不出现 NaN', () => {
|
||||
const wrapper = mountPagination({ currentPage: 1, total: 5 })
|
||||
expect(infoText(wrapper)).toBe('显示 1-5 条,共 5 条')
|
||||
})
|
||||
})
|
||||
|
|
@ -28,9 +28,20 @@ const currentPage = computed({
|
|||
set: (val) => emit('update:currentPage', val)
|
||||
})
|
||||
|
||||
// el-pagination 的 pageSize 缺省是 10,这里跟着兜底,免得算出 NaN
|
||||
const pageSize = computed(() => props.pageSize || 10)
|
||||
// 总页数至少 1,和 el-pagination 内部 pageCountBridge 的算法保持一致
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil((props.total || 0) / pageSize.value)))
|
||||
// 页码可能超出总页数:删掉末页最后一条时父组件还没把 currentPage 收敛下来。
|
||||
// el-pagination 自己会收敛并 emit change 让父组件重拉,但那之间会闪一帧倒挂文案
|
||||
// (「显示 41-40 条」),父组件那次请求失败的话更是永久留在页面上。
|
||||
// 这里按收敛后的页码算区间,让文案和 el-pagination 实际渲染出的页码一致。
|
||||
const safePage = computed(() => Math.min(Math.max(1, props.currentPage || 1), totalPages.value))
|
||||
|
||||
const pageInfo = computed(() => {
|
||||
const start = (props.currentPage - 1) * props.pageSize + 1
|
||||
const end = Math.min(props.currentPage * props.pageSize, props.total)
|
||||
if (!props.total) return '显示 0-0 条,共 0 条'
|
||||
const start = (safePage.value - 1) * pageSize.value + 1
|
||||
const end = Math.min(safePage.value * pageSize.value, props.total)
|
||||
return `显示 ${start}-${end} 条,共 ${props.total} 条`
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -355,6 +355,22 @@ func (r *StockRepo) GetStock(materialID int64) (decimal.Decimal, error) {
|
|||
return stock, err
|
||||
}
|
||||
|
||||
// GetDefaultStock reads the balance that legacy outbound orders actually deduct.
|
||||
func (r *StockRepo) GetDefaultStock(materialID int64) (decimal.Decimal, error) {
|
||||
query := `SELECT ib.on_hand
|
||||
FROM inventory_balances ib
|
||||
JOIN organizations o ON o.id = ib.organization_id AND o.code = 'DEFAULT'
|
||||
JOIN warehouses w ON w.id = ib.warehouse_id AND w.code = 'DEFAULT'
|
||||
JOIN warehouse_locations l ON l.id = ib.location_id AND l.code = 'MAIN'
|
||||
WHERE ib.material_id = ?`
|
||||
var stock decimal.Decimal
|
||||
err := database.DB.QueryRow(query, materialID).Scan(&stock)
|
||||
if err == sql.ErrNoRows {
|
||||
return decimal.Zero, nil
|
||||
}
|
||||
return stock, err
|
||||
}
|
||||
|
||||
// GetStockForUpdateTx locks the legacy default location balance used by current inbound/outbound flows.
|
||||
func (r *StockRepo) GetStockForUpdateTx(tx *sql.Tx, materialID int64) (decimal.Decimal, error) {
|
||||
query := `SELECT ib.on_hand
|
||||
|
|
|
|||
|
|
@ -2,11 +2,17 @@ package repository
|
|||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"github.com/wmms/wmms/internal/model"
|
||||
"github.com/wmms/wmms/pkg/database"
|
||||
)
|
||||
|
||||
// ErrWarehouseNotFound 表示仓库 id 不存在,或这条仓库不属于当前组织。
|
||||
// 建库位时的 INSERT...SELECT 影响 0 行就是这种情况,接口据此回错,
|
||||
// 而不是把「没写成」报成"创建成功"。
|
||||
var ErrWarehouseNotFound = errors.New("仓库不存在,可能已被删除或不属于当前组织")
|
||||
|
||||
type WarehouseRepository struct{}
|
||||
|
||||
func NewWarehouseRepository() *WarehouseRepository { return &WarehouseRepository{} }
|
||||
|
|
@ -58,11 +64,25 @@ func (r *WarehouseRepository) Update(item *model.Warehouse) error {
|
|||
}
|
||||
|
||||
func (r *WarehouseRepository) CreateLocation(item *model.WarehouseLocation, organizationID int64) error {
|
||||
// 仓库归属条件写在 SELECT 里,避免「先查后插」之间的竞态。
|
||||
//
|
||||
// 匹配不到仓库时这条语句影响 0 行,但 MySQL 不报错:err 为 nil,LastInsertId()
|
||||
// 返回 0。此前只看 err,接口回 201「创建成功」而库里一行都没有(实测请求前后
|
||||
// warehouse_locations 行数都是 1,刷新后库位消失)。所以这里和按主键更新一样,
|
||||
// 以 RowsAffected 为准——INSERT 的 RowsAffected 就是实际插入行数,不受
|
||||
// clientFoundRows 影响。
|
||||
result, err := database.DB.Exec(`INSERT INTO warehouse_locations (warehouse_id, code, name, status)
|
||||
SELECT id, ?, ?, ? FROM warehouses WHERE id = ? AND organization_id = ?`, item.Code, item.Name, item.Status, item.WarehouseID, organizationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return ErrWarehouseNotFound
|
||||
}
|
||||
item.ID, err = result.LastInsertId()
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
68
internal/repository/warehouse_test.go
Normal file
68
internal/repository/warehouse_test.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/wmms/wmms/internal/model"
|
||||
"github.com/wmms/wmms/pkg/database"
|
||||
)
|
||||
|
||||
// 与 warehouse.go 里的语句逐字一致:换行和缩进都参与匹配。
|
||||
const createLocationSQL = `INSERT INTO warehouse_locations (warehouse_id, code, name, status)
|
||||
SELECT id, ?, ?, ? FROM warehouses WHERE id = ? AND organization_id = ?`
|
||||
|
||||
func newWarehouseMock(t *testing.T) sqlmock.Sqlmock {
|
||||
t.Helper()
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("open sql mock: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { database.DB = nil; db.Close() })
|
||||
database.DB = db
|
||||
return mock
|
||||
}
|
||||
|
||||
// 仓库不存在时(id 打错、仓库已被删、或这条仓库属于别的组织)这条 INSERT...SELECT
|
||||
// 影响 0 行,而 MySQL 既不报错、LastInsertId() 也只回 0。此前只看 err,接口会回
|
||||
// 201「Location created successfully」,库里却没有新行,刷新后库位消失、重试还是
|
||||
// "成功"(实测请求前后 warehouse_locations 行数都是 1)。
|
||||
func TestCreateLocationReportsMissingWarehouse(t *testing.T) {
|
||||
mock := newWarehouseMock(t)
|
||||
mock.ExpectExec(regexp.QuoteMeta(createLocationSQL)).
|
||||
WithArgs("L1", "库位一", "active", int64(999), int64(1)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
|
||||
item := &model.WarehouseLocation{WarehouseID: 999, Code: "L1", Name: "库位一", Status: "active"}
|
||||
err := NewWarehouseRepository().CreateLocation(item, 1)
|
||||
if !errors.Is(err, ErrWarehouseNotFound) {
|
||||
t.Fatalf("影响 0 行应当返回 ErrWarehouseNotFound,实际 %v", err)
|
||||
}
|
||||
if item.ID != 0 {
|
||||
t.Fatalf("没插进去就不该有 id,实际 %d", item.ID)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 反面:真的插进去了就必须回填 id,别把成功路径也一起判成失败。
|
||||
func TestCreateLocationReturnsInsertedID(t *testing.T) {
|
||||
mock := newWarehouseMock(t)
|
||||
mock.ExpectExec(regexp.QuoteMeta(createLocationSQL)).
|
||||
WithArgs("L1", "库位一", "active", int64(3), int64(1)).
|
||||
WillReturnResult(sqlmock.NewResult(7, 1))
|
||||
|
||||
item := &model.WarehouseLocation{WarehouseID: 3, Code: "L1", Name: "库位一", Status: "active"}
|
||||
if err := NewWarehouseRepository().CreateLocation(item, 1); err != nil {
|
||||
t.Fatalf("插入成功不应报错,实际 %v", err)
|
||||
}
|
||||
if item.ID != 7 {
|
||||
t.Fatalf("应当回填自增 id 7,实际 %d", item.ID)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -52,7 +52,7 @@ func (s *OutboundService) Create(req *model.CreateOutboundRequest, operator stri
|
|||
return nil, ErrMaterialObsolete
|
||||
}
|
||||
|
||||
currentStock, err := s.stockRepo.GetStock(item.MaterialID)
|
||||
currentStock, err := s.stockRepo.GetDefaultStock(item.MaterialID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -71,11 +71,7 @@ func (s *OutboundService) Create(req *model.CreateOutboundRequest, operator stri
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
defer tx.Rollback()
|
||||
|
||||
// Re-check stock with row lock (SELECT FOR UPDATE) to prevent overselling
|
||||
for i := range order.Items {
|
||||
|
|
@ -159,11 +155,7 @@ func (s *OutboundService) Delete(id int64) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
defer tx.Rollback()
|
||||
|
||||
// Reverse stock for each item (add back) within transaction
|
||||
for _, item := range order.Items {
|
||||
|
|
@ -217,7 +209,7 @@ func (s *OutboundService) Update(id int64, req *model.UpdateOutboundRequest, ope
|
|||
return nil, ErrMaterialObsolete
|
||||
}
|
||||
|
||||
currentStock, err := s.stockRepo.GetStock(item.MaterialID)
|
||||
currentStock, err := s.stockRepo.GetDefaultStock(item.MaterialID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -240,11 +232,7 @@ func (s *OutboundService) Update(id int64, req *model.UpdateOutboundRequest, ope
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
defer tx.Rollback()
|
||||
|
||||
// Reverse stock for old items (add back what was previously subtracted)
|
||||
for _, item := range oldOrder.Items {
|
||||
|
|
@ -260,15 +248,8 @@ func (s *OutboundService) Update(id int64, req *model.UpdateOutboundRequest, ope
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
oldQty := 0
|
||||
for _, oldItem := range oldOrder.Items {
|
||||
if oldItem.MaterialID == item.MaterialID {
|
||||
oldQty = oldItem.Quantity
|
||||
break
|
||||
}
|
||||
}
|
||||
effectiveStock := lockedStock.Add(decimal.NewFromInt(int64(oldQty)))
|
||||
if effectiveStock.LessThan(decimal.NewFromInt(int64(item.Quantity))) {
|
||||
// Old quantities have already been restored above.
|
||||
if lockedStock.LessThan(decimal.NewFromInt(int64(item.Quantity))) {
|
||||
err = ErrInsufficientStock
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
116
internal/service/outbound_test.go
Normal file
116
internal/service/outbound_test.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/wmms/wmms/internal/model"
|
||||
"github.com/wmms/wmms/pkg/database"
|
||||
)
|
||||
|
||||
func outboundMock(t *testing.T) sqlmock.Sqlmock {
|
||||
t.Helper()
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous := database.DB
|
||||
database.DB = db
|
||||
t.Cleanup(func() { database.DB = previous; db.Close() })
|
||||
return mock
|
||||
}
|
||||
|
||||
func expectActiveMaterial(mock sqlmock.Sqlmock, id int64) {
|
||||
for i := 0; i < 2; i++ {
|
||||
mock.ExpectQuery("SELECT id, organization_id, material_code, name, material_image").WithArgs(id).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
|
||||
AddRow(id, 1, "M-1", "Material", nil, "", nil, 1, 1, "pc", "active", 1, time.Now(), time.Now()))
|
||||
}
|
||||
}
|
||||
|
||||
func createOutboundRequest() *model.CreateOutboundRequest {
|
||||
req := &model.CreateOutboundRequest{Receiver: "receiver"}
|
||||
req.Items = append(req.Items, struct {
|
||||
MaterialID int64 `json:"material_id" binding:"required"`
|
||||
Quantity int `json:"quantity" binding:"required,gt=0"`
|
||||
}{MaterialID: 7, Quantity: 2})
|
||||
return req
|
||||
}
|
||||
|
||||
func TestOutboundCreateRollsBackWhenLockedStockRunsOut(t *testing.T) {
|
||||
mock := outboundMock(t)
|
||||
expectActiveMaterial(mock, 7)
|
||||
mock.ExpectQuery("SELECT ib.on_hand").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("2"))
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT ib.on_hand").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("1"))
|
||||
mock.ExpectRollback()
|
||||
_, err := NewOutboundService().Create(createOutboundRequest(), "operator")
|
||||
if !errors.Is(err, ErrInsufficientStock) {
|
||||
t.Fatalf("got %v, want insufficient stock", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboundCreateChecksDefaultLocationBeforeTransaction(t *testing.T) {
|
||||
mock := outboundMock(t)
|
||||
expectActiveMaterial(mock, 7)
|
||||
mock.ExpectQuery("SELECT ib.on_hand.*FROM inventory_balances").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("1"))
|
||||
_, err := NewOutboundService().Create(createOutboundRequest(), "operator")
|
||||
if !errors.Is(err, ErrInsufficientStock) {
|
||||
t.Fatalf("got %v, want insufficient stock", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboundCreateRollsBackWhenMaterialReadFailsAfterOrderInsert(t *testing.T) {
|
||||
mock := outboundMock(t)
|
||||
expectActiveMaterial(mock, 7)
|
||||
mock.ExpectQuery("SELECT ib.on_hand").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("2"))
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT ib.on_hand").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("2"))
|
||||
mock.ExpectExec("INSERT INTO outbound_orders").WillReturnResult(sqlmock.NewResult(11, 1))
|
||||
mock.ExpectExec("UPDATE outbound_orders").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO outbound_items").WillReturnResult(sqlmock.NewResult(12, 1))
|
||||
mock.ExpectQuery("SELECT id, organization_id, material_code, name, material_image").WithArgs(int64(7)).WillReturnError(errors.New("material read failed"))
|
||||
mock.ExpectRollback()
|
||||
_, err := NewOutboundService().Create(createOutboundRequest(), "operator")
|
||||
if err == nil || err.Error() != "material read failed" {
|
||||
t.Fatalf("got %v, want material read failure", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboundUpdateDoesNotCountRestoredStockTwice(t *testing.T) {
|
||||
mock := outboundMock(t)
|
||||
now := time.Now()
|
||||
mock.ExpectQuery("SELECT id, COALESCE\\(outbound_no").WithArgs(int64(11)).WillReturnRows(
|
||||
sqlmock.NewRows([]string{"id", "outbound_no", "operator", "outbound_date", "handler", "receiver", "remark", "created_at", "updated_at"}).AddRow(11, "OUT-11", "operator", now, "operator", "receiver", "", now, now))
|
||||
mock.ExpectQuery("SELECT oi.id, oi.outbound_order_id").WithArgs(int64(11)).WillReturnRows(
|
||||
sqlmock.NewRows([]string{"id", "outbound_order_id", "material_id", "quantity", "material_code", "name", "status"}).AddRow(12, 11, 7, 2, "M-1", "Material", "active"))
|
||||
expectActiveMaterial(mock, 7)
|
||||
mock.ExpectQuery("SELECT ib.on_hand").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("2"))
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO inventory_balances").WithArgs(int64(7), "2").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery("SELECT ib.on_hand").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("3"))
|
||||
mock.ExpectRollback()
|
||||
req := &model.UpdateOutboundRequest{Receiver: "receiver"}
|
||||
req.Items = append(req.Items, struct {
|
||||
MaterialID int64 `json:"material_id" binding:"required"`
|
||||
Quantity int `json:"quantity" binding:"required,gt=0"`
|
||||
}{MaterialID: 7, Quantity: 4})
|
||||
_, err := NewOutboundService().Update(11, req, "operator")
|
||||
if !errors.Is(err, ErrInsufficientStock) {
|
||||
t.Fatalf("got %v, want insufficient stock", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user