Compare commits
3 Commits
6a16edfa24
...
cf9301d3c1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf9301d3c1 | ||
|
|
6ac9f2cb3c | ||
|
|
b1c86593f0 |
30
DEPLOY.md
30
DEPLOY.md
|
|
@ -112,13 +112,13 @@ MySQL 官方镜像**只在数据目录为空时**才执行 `/docker-entrypoint-i
|
|||
Error 1054 (42S22): Unknown column 'inbound_no' in 'field list'
|
||||
```
|
||||
|
||||
**部署前先确认卷的年龄**:
|
||||
**部署前先确认现有表结构**:
|
||||
|
||||
```bash
|
||||
docker volume inspect warehouse-mms_mysql_data --format '{{.CreatedAt}}'
|
||||
docker compose exec db mysql -uroot -pwmms1234 -e "SHOW COLUMNS FROM inbound_orders; SHOW COLUMNS FROM outbound_orders; SHOW COLUMNS FROM material_categories;" wmms
|
||||
```
|
||||
|
||||
把结果和最近一次改 schema 的提交时间对比:卷比代码新就没问题,卷比代码旧就要按下节处理。
|
||||
将实际字段与当前 `scripts/init.sql` 对比。数据卷的创建时间只能作为排查线索,不能证明表结构是否一致。
|
||||
|
||||
**方式一:清空重建(推荐,最简单可靠)**
|
||||
|
||||
|
|
@ -132,23 +132,11 @@ docker compose down -v && docker compose up -d --build
|
|||
docker compose exec db mysqldump -uroot -pwmms1234 --default-character-set=utf8mb4 wmms > backup.sql
|
||||
```
|
||||
|
||||
**方式二:跑增量脚本(需要保留数据时)**
|
||||
**方式二:保留数据并更新结构**
|
||||
|
||||
`scripts/` 下有三个手工迁移脚本,按需执行:
|
||||
需要保留业务数据时,先备份数据库,再根据实际表结构与 `scripts/init.sql` 的差异编写并审核增量 SQL,在备份副本上验证后执行。
|
||||
|
||||
| 脚本 | 作用 | 是否破坏数据 |
|
||||
|------|------|--------------|
|
||||
| `add_order_display_fields.sql` | 出入库单补 `inbound_no` / `outbound_no` / `operator` / `remark` | 否,仅加列并回填单号 |
|
||||
| `update_material_categories.sql` | 重建分类树并写入编码规则 | **是**,会清空物料、单据、库存、流水 |
|
||||
| `update_warning_stock_zero.sql` | 清理阈值为 0 的历史预警规则 | **是**,会删除这些规则行 |
|
||||
|
||||
执行方式:
|
||||
|
||||
```bash
|
||||
docker compose exec -T db mysql -uroot -pwmms1234 --default-character-set=utf8mb4 wmms < scripts/xxx.sql
|
||||
```
|
||||
|
||||
执行前请先备份,并阅读脚本头部的说明。
|
||||
当前 `scripts/init.sql` 用于空库首次初始化,已包含出入库单号、操作人、备注、分类说明、6 个大类和 28 个子类,以及对应编码规则。库存预警阈值为 0 是有效配置,新库无需清理历史规则。不要将完整初始化脚本直接用于已有业务数据的数据库。
|
||||
|
||||
## 环境变量
|
||||
|
||||
|
|
@ -207,12 +195,12 @@ Error 1054 (42S22): Unknown column 'description' in 'field list'
|
|||
**确认**:
|
||||
|
||||
```bash
|
||||
docker volume inspect warehouse-mms_mysql_data --format '{{.CreatedAt}}'
|
||||
docker compose exec db mysql -uroot -pwmms1234 -e "SHOW COLUMNS FROM material_categories;" wmms
|
||||
```
|
||||
|
||||
如果卷的创建时间早于最近一次改 schema 的提交,就是这个原因。
|
||||
检查报错涉及的表是否缺少字段,并与当前 `scripts/init.sql` 对比。
|
||||
|
||||
**解决**:确认库里无业务数据后执行 `docker compose down -v && docker compose up -d --build` 重建;需要保留数据则改跑 `scripts/` 下的增量脚本。
|
||||
**解决**:确认数据库和上传文件均无需保留后,可执行 `docker compose down -v && docker compose up -d --build` 重建;需要保留数据时,先备份,再按实际结构差异准备并验证增量 SQL。
|
||||
|
||||
### 其他排查技巧
|
||||
|
||||
|
|
|
|||
|
|
@ -210,7 +210,7 @@
|
|||
- 列表顶部提供大类、小类、物料类型、状态和关键词筛选。
|
||||
- 关键词支持对物料编码、名称、规格和制造商型号进行模糊搜索。
|
||||
- 小类选项随所选大类联动;清除大类时同时清除无效的小类条件。
|
||||
- 点击物料进入详情页;技术人员可在详情页修改或作废,采购人员可在详情页维护供应商信息。
|
||||
- 点击列表中的“详情”先只读查看物料;有修改权限的人员可在详情中点击“编辑”进入编辑状态,保存后回到只读详情,取消编辑或关闭时丢弃未保存内容。采购人员可在详情页维护供应商信息。
|
||||
- 列表提供单个新增、批量导入和批量导出入口,并根据角色权限显示。
|
||||
|
||||
#### 故事 #MD-003:物料主数据修改
|
||||
|
|
@ -237,7 +237,7 @@
|
|||
验收标准:
|
||||
|
||||
- 作废物料不得用于新建普通入库、出库、调拨、预留、BOM 和制造订单。
|
||||
- 物料作废只允许在详情页执行,列表页不提供直接作废按钮。
|
||||
- 列表操作列保留“作废物料”或“撤销作废”按钮,以按钮文案区分当前状态;有权限的人员可直接操作,作废前必须二次确认。
|
||||
- 作废不影响历史查询和合法的撤销过账(冲销)。
|
||||
- 已存在账面、预留、锁定或在途库存时必须提示风险并禁止直接作废,处理完库存后方可作废。
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
## 1. 目标
|
||||
|
||||
为成品和半成品提供可追溯的多层 BOM 手工维护能力。第一版重点完成 BOM 的创建、草稿编辑、版本保留、生效与停用、结构合法性校验,以及单个 BOM 按指定数量递归展开的基础能力。
|
||||
为成品和半成品提供可追溯的多层 BOM 手工维护能力。第一版重点完成 BOM 的创建、草稿编辑、版本保留、生效与停用、结构合法性校验,以及按父项数量 `1` 查看单个 BOM 版本的完整层级。
|
||||
|
||||
第一版不延伸到制造执行和库存业务,BOM 操作不会生成领料申请、扣减库存或创建出入库单据。
|
||||
|
||||
|
|
@ -19,12 +19,12 @@
|
|||
- 通过树形界面手工维护 BOM。
|
||||
- 为成品或半成品建立多个 BOM 版本。
|
||||
- 创建首个 BOM 草稿。
|
||||
- 查看 BOM 列表、结构和历史版本。
|
||||
- 基于指定历史版本完整复制明细,生成新草稿。
|
||||
- 从物料列表进入指定物料的 BOM 页面,查看结构和历史版本。
|
||||
- 新建后续版本时,可基于指定历史版本完整复制明细,或创建空白草稿。
|
||||
- 编辑、保存和删除草稿。
|
||||
- 生效草稿或停用指定的生效版本。
|
||||
- 自动校验重复子项、自引用、循环引用、无效数量和最大层级。
|
||||
- 按指定产品数量递归展开 BOM,并汇总最底层物料需求。
|
||||
- 按父项数量 `1` 查看单个 BOM 版本的完整层级和各层用量。
|
||||
- 记录生效和停用等关键状态变化的审计信息。
|
||||
- 第一版页面和接口仅允许超级管理员访问。
|
||||
|
||||
|
|
@ -34,6 +34,7 @@
|
|||
- BOM 审批、驳回和申请流程。
|
||||
- 替代料。
|
||||
- 多 BOM 需求试算。
|
||||
- 修改查看数量、按计划数量计算需求,以及汇总最底层物料需求。
|
||||
- 制造订单。
|
||||
- 领料、退料和完工入库。
|
||||
- 库存预留、库存扣减和短缺计算。
|
||||
|
|
@ -45,11 +46,11 @@
|
|||
|
||||
BOM 版本仅使用以下三个状态:
|
||||
|
||||
| 状态 | 含义 | 允许操作 |
|
||||
| --- | --- | --- |
|
||||
| `draft` | 草稿 | 查看、编辑、保存、生效、删除 |
|
||||
| `effective` | 可用 | 查看、被新 BOM 选择、停用、基于该版本新建草稿 |
|
||||
| `obsolete` | 已停用 | 查看历史、基于该版本新建草稿;不能用于新 BOM |
|
||||
| 状态 | 含义 | 允许操作 |
|
||||
| ----------- | --- | ------------------------- |
|
||||
| `draft` | 草稿 | 查看、编辑、保存、生效、删除 |
|
||||
| `effective` | 可用 | 查看、被新 BOM 选择、停用、基于该版本新建草稿 |
|
||||
| `obsolete` | 已停用 | 查看历史、基于该版本新建草稿;不能用于新 BOM |
|
||||
|
||||
规则如下:
|
||||
|
||||
|
|
@ -57,7 +58,7 @@ BOM 版本仅使用以下三个状态:
|
|||
2. 同一父物料可以同时存在多个 `effective`(可用)版本。
|
||||
3. 默认版本是尚未停用且最近一次生效的版本,按生效时间排序;同一时间时按版本 ID 确定唯一结果。停用默认版本后,剩余可用版本中最近生效的版本成为默认版本;全部停用时无默认版本。
|
||||
4. 新版本生效不改变其他可用版本的状态。非默认的可用版本仍可被新建的上层 BOM 明确选择。
|
||||
5. 可用版本不可直接修改。修改可用或已停用版本时,必须先完整复制所选版本,生成新的草稿版本。
|
||||
5. 可用版本不可直接修改。变更结构时应创建新的草稿版本,可复制指定旧版,也可从空白开始。
|
||||
6. 已停用版本不能恢复为可用,但可以作为新版本的复制来源;它不能被新建的上层 BOM 选用。
|
||||
7. 草稿可删除;可用和已停用版本只保留、不物理删除。
|
||||
|
||||
|
|
@ -102,7 +103,7 @@ BOM 版本仅使用以下三个状态:
|
|||
- 添加子物料时,前端立即检查直接重复和自引用。
|
||||
- 保存草稿时,后端检查数量、重复、自引用、循环引用和最大层级。
|
||||
- 生效时,后端在同一事务中再次执行完整校验。
|
||||
- 递归展开时再次执行循环和最大深度保护,不能仅依赖保存时的校验。
|
||||
- 查询完整结构树时再次执行循环和最大深度保护,不能仅依赖保存时的校验。
|
||||
- 检测失败时返回完整问题路径,并让前端定位对应节点。
|
||||
|
||||
### 3.5 数量与损耗
|
||||
|
|
@ -115,21 +116,18 @@ BOM 版本仅使用以下三个状态:
|
|||
子项需求量 = 上层需求量 × 单位用量 ×(1 + 损耗率)
|
||||
```
|
||||
|
||||
- 递归计算过程中保留足够精度,最终结果按物料基础单位的精度规则向上取整。
|
||||
- 汇总结果按物料和基础单位合并相同的最底层物料。
|
||||
- 查看结构时,父项数量固定为 `1`,按上述公式逐层显示用量,计算过程中保留足够精度。
|
||||
- 第一版不输入计划数量,也不汇总不同分支中的最底层物料;相关计算留给后续“多 BOM 需求试算”。
|
||||
|
||||
## 4. 前端信息架构
|
||||
|
||||
左侧导航在“物料主数据”和“公司组织”之间增加独立大类:
|
||||
左侧导航沿用“物料主数据”入口。BOM 维护由物料列表逐行进入,不增加独立的“BOM 管理”菜单:
|
||||
|
||||
```text
|
||||
物料主数据
|
||||
├─ 物料
|
||||
└─ 基础配置
|
||||
|
||||
BOM 管理
|
||||
└─ BOM 维护
|
||||
|
||||
公司组织
|
||||
├─ 组织管理
|
||||
├─ 用户管理
|
||||
|
|
@ -144,16 +142,17 @@ BOM 管理
|
|||
|
||||
现有独立菜单“日志记录”改为左侧导航大类。现有物料日志页面归入“物料变更日志”,BOM 事件展示在新建的“BOM 记录”子页面。原有 `/logs` 地址应保持可访问,并跳转到物料变更日志页面,避免旧链接失效。
|
||||
|
||||
### 4.1 BOM 维护
|
||||
### 4.1 物料列表中的 BOM 入口
|
||||
|
||||
BOM 维护统一包含以下四类操作:
|
||||
现有物料列表继续负责搜索物料、查看详情、编辑和作废。在符合 BOM 父物料条件的成品或半成品行增加明显的“BOM”按钮,放在独立的窄列中,避免挤占现有操作列。点击后进入该物料的独立 BOM 页面。现有“物料详情”弹窗保持原有用途;BOM 页面显示当前物料的编码、名称、规格、基础单位和状态,并提供“返回物料列表”。已作废物料若已有 BOM,仍可进入页面查看历史版本,但不能新建或生效版本。
|
||||
|
||||
```text
|
||||
BOM 列表
|
||||
→ 查询、查看结构、查看历史版本、发起新版本
|
||||
物料主数据 → 物料列表 → 选择整机 A 所在行的 [BOM]
|
||||
↓
|
||||
整机 A 的 BOM 页面
|
||||
|
||||
新建 BOM
|
||||
→ 为尚未建立 BOM 的父物料创建第一个草稿版本
|
||||
→ 为当前物料创建第一个草稿版本
|
||||
|
||||
编辑草稿
|
||||
→ 编辑明细、保存草稿、删除草稿
|
||||
|
|
@ -166,107 +165,137 @@ BOM 列表
|
|||
|
||||
树形结构维护是编辑页面的交互方式,不作为独立功能入口。
|
||||
|
||||
### 4.2 BOM 列表
|
||||
### 4.2 单个物料的 BOM 页面
|
||||
|
||||
列表以父物料为主行,避免历史版本过多导致列表膨胀:
|
||||
页面只管理入口物料的 BOM,不再建立与物料列表重复的 BOM 总列表。顶部始终显示版本选择框:已有版本时列出该物料的全部草稿、可用和已停用版本,并在选项中标明状态和默认版本;选项按创建时间从新到旧排列。首次进入时优先选中默认可用版本,没有可用版本时选中最近创建的版本。没有任何版本时,版本框显示“无”且不可展开,旁边提供“新建 BOM”按钮:
|
||||
|
||||
```text
|
||||
父项物料 默认版本 可用版本数 草稿版本 操作
|
||||
整机 A V2 2 V3 查看结构|编辑草稿|历史版本
|
||||
电机组件 V1 1 无 查看结构|新建版本|历史版本
|
||||
← 返回物料列表 整机 A · A-001 有效
|
||||
|
||||
BOM 版本:[V2 · 可用·默认 ▼] [新建版本]
|
||||
当前版本状态:可用 [停用]
|
||||
|
||||
▼ 整机 A × 1
|
||||
├─ 外壳组件 × 1
|
||||
└─ 电机组件 × 2
|
||||
```
|
||||
|
||||
点击列表中的“历史版本”,弹出独立窗口展示该父物料的全部版本,不在列表行内展开,也不使用侧边抽屉。窗口内可查看版本结构,或选择指定版本发起新版本:
|
||||
选择草稿版本时,操作按钮显示在同一状态行的位置:
|
||||
|
||||
```text
|
||||
┌─ 整机 A · 历史版本 ─────────────────────────────── [关闭] ┐
|
||||
│ 版本 状态 操作 │
|
||||
│ V3 草稿 查看结构|编辑草稿 │
|
||||
│ V2 可用·默认 查看结构|新建版本|停用 │
|
||||
│ V1 可用 查看结构|新建版本|停用 │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
← 返回物料列表 整机 A · A-001 有效
|
||||
|
||||
BOM 版本:[V3 · 草稿 ▼] [新建版本]
|
||||
当前版本状态:草稿 [编辑] [生效] [删除草稿]
|
||||
|
||||
▼ 整机 A × 1
|
||||
├─ 外壳组件 × 1
|
||||
└─ 电机组件 × 2
|
||||
```
|
||||
|
||||
列表至少支持:
|
||||
无版本时沿用相同布局:
|
||||
|
||||
- 按父项物料编码或名称查询。
|
||||
- 按是否存在草稿、是否存在可用版本筛选。
|
||||
- 查看当前或历史版本结构。
|
||||
```text
|
||||
← 返回物料列表 整机 A · A-001 有效
|
||||
|
||||
BOM 版本:[无] [新建 BOM]
|
||||
|
||||
暂无 BOM 结构
|
||||
```
|
||||
|
||||
创建首个草稿后,版本选择框自动选中该草稿并显示其结构。
|
||||
|
||||
切换版本后,页面直接加载该版本的完整结构和节点明细,不再要求点击“查看结构”。选中草稿时,顶部状态行显示“编辑”“生效”“删除草稿”;点击“编辑”才进入草稿的树形编辑状态。选中可用版本时,该位置显示“停用”;已停用版本只读,不显示编辑、停用或生效按钮。切换前若草稿存在尚未保存的修改,应先提示保存或放弃,避免丢失内容。
|
||||
|
||||
物料列表及该物料 BOM 页面共同支持:
|
||||
|
||||
- 在现有物料列表按编码或名称查找父物料,并从该行进入 BOM 页面。
|
||||
- 通过版本选择框查看当前或历史版本结构。
|
||||
- 编辑已有草稿。
|
||||
- 从指定历史版本复制并发起新版本。
|
||||
- 发起新版本,并选择复制旧版或空白创建。
|
||||
- 执行当前状态允许的状态操作。
|
||||
|
||||
“查看结构”页面默认按父物料数量 `1` 展示完整层级。用户可以临时修改查看数量,页面随即递归计算各层需求,并展示最底层物料汇总。该能力只计算当前一个 BOM 版本,不单独设置菜单,也不保存试算记录。
|
||||
建议页面路由为 `/materials/:materialId/bom`。从树中的子物料进入其 BOM 时,也使用同一路由并传入该子物料 ID。物料详情弹窗无需承载多层 BOM 编辑。
|
||||
|
||||
版本选择框下方的结构区域固定按父物料数量 `1` 展示所选 BOM 版本的完整层级和各层用量,不提供数量输入或最底层物料汇总。
|
||||
|
||||
```text
|
||||
查看数量:10
|
||||
|
||||
[层级结构]
|
||||
整机 A × 10
|
||||
├─ 外壳组件 × 10
|
||||
│ └─ 螺丝 × 80
|
||||
└─ 电机组件 × 20
|
||||
└─ 螺丝 × 80
|
||||
|
||||
[底层物料汇总]
|
||||
螺丝 × 160
|
||||
整机 A × 1
|
||||
├─ 外壳组件 × 1
|
||||
│ └─ 螺丝 × 8
|
||||
└─ 电机组件 × 2
|
||||
└─ 螺丝 × 8
|
||||
```
|
||||
|
||||
同时选择多个 BOM 并合并需求,属于后续“多 BOM 需求试算”,不在第一版范围内。
|
||||
按计划数量计算、同时选择多个 BOM 并合并相同物料需求,属于后续独立页面“多 BOM 需求试算”。
|
||||
|
||||
### 4.3 新建 BOM
|
||||
|
||||
新建 BOM 只面向尚未建立 BOM 的成品或半成品。页面包含:
|
||||
新建 BOM 只面向尚未建立 BOM 的成品或半成品。入口物料已由物料列表确定,页面包含:
|
||||
|
||||
- 父项物料选择。
|
||||
- 当前父物料信息,只读展示,不再重复选择。
|
||||
- 初始版本号。
|
||||
- 直接子物料树或列表。
|
||||
- 子项单位用量、基础单位、损耗率和备注。
|
||||
- 保存草稿。
|
||||
|
||||
已存在 BOM 的父物料不能再次走“新建 BOM”,应从列表或历史版本选择来源并执行“新建版本”。
|
||||
已存在 BOM 的父物料不能再次走“新建 BOM”,应从该物料的 BOM 页面点击“新建版本”,再选择复制旧版或空白创建。
|
||||
|
||||
### 4.4 编辑草稿
|
||||
|
||||
编辑页采用左侧结构树、右侧节点明细的布局:
|
||||
编辑页采用左侧结构树、右侧节点明细的布局。点击任意物料节点,右侧先以只读方式展示该节点的物料信息、用量、损耗率、备注和下层 BOM 版本:
|
||||
|
||||
```text
|
||||
┌───────────────────────┬──────────────────────────────┐
|
||||
│ BOM 结构树 │ 当前节点明细 │
|
||||
│ │ │
|
||||
│ ▼ 整机 A │ 子物料:电机组件 │
|
||||
│ ├─ 外壳组件 × 1 │ 单位用量:2 │
|
||||
│ ├─ 电机组件 × 2 │ 基础单位:套 │
|
||||
│ └─ 控制箱 × 1 │ 损耗率:0% │
|
||||
│ │ 备注:安装位置说明 │
|
||||
│ [+ 添加子物料] │ │
|
||||
└───────────────────────┴──────────────────────────────┘
|
||||
┌────────────────────────┬─────────────────────────────┐
|
||||
│ BOM 结构树 │ 当前节点明细 │
|
||||
│ ▼ 整机 A │ 电机组件 │
|
||||
│ ├─ 外壳组件 × 1 │ 物料编码:DJ-001 │
|
||||
│ ├─ 电机组件 × 2 ← │ 单位用量:2 套 │
|
||||
│ │ └─ 螺丝 × 4 │ 损耗率:0% │
|
||||
│ └─ 控制箱 × 1 │ 下层 BOM:V2 │
|
||||
│ [+ 添加子物料] │ 备注:安装位置说明 │
|
||||
│ │ [编辑] │
|
||||
└────────────────────────┴─────────────────────────────┘
|
||||
```
|
||||
|
||||
“添加子物料”只显示在根物料的直接子项列表末尾,用来为当前 BOM 添加第二层节点;第三层及更深层节点不显示此按钮。下层子物料应进入对应子 BOM 的草稿页面维护,再由当前 BOM 选择和锁定该子 BOM 版本。
|
||||
|
||||
添加子物料时只能搜索并选择当前组织中已存在的物料。如果没有找到目标物料,页面提示先到“物料主数据 → 物料”新增,新增完成后再返回 BOM 草稿继续添加;BOM 页面不自动创建物料主数据。
|
||||
|
||||
选中当前 BOM 的直接子项且当前版本为草稿时,右侧显示“编辑”按钮。点击后,右侧明细进入编辑模式,显示可编辑的单位用量、损耗率、备注和下层 BOM 版本,并提供“删除该子物料”“取消”“保存”操作;未点击“编辑”前不修改字段,也不显示删除入口。删除前二次确认,只删除当前草稿中选中的这一条直接子项,不删除物料主数据或子物料自身的 BOM。选中第三层及更深层节点时,右侧只读展示明细,并提供“前往该物料 BOM”入口。可用和已停用版本中的所有节点均只读。
|
||||
|
||||
```text
|
||||
右侧明细:查看模式 [编辑]
|
||||
↓
|
||||
右侧明细:编辑模式 [删除该子物料] [取消] [保存]
|
||||
```
|
||||
|
||||
编辑草稿允许:
|
||||
|
||||
- 添加、修改、删除和排序直接子物料。
|
||||
- 添加、修改和排序直接子物料;删除直接子项必须先进入右侧明细的编辑模式。
|
||||
- 修改用量、损耗率和备注。
|
||||
- 保存草稿。
|
||||
- 删除整个草稿。
|
||||
|
||||
对于树中引用的下层 BOM,草稿编辑页提供可用版本选择器,并默认选择最近一次生效的版本。页面展示所选版本的结构,并提供跳转到该物料 BOM 的入口;不在上层 BOM 中直接修改下层 BOM。可用版本停用后,已锁定它的历史上层版本仍可查看和展开;尚未生效的上层草稿必须改选可用版本后才能生效。
|
||||
对于树中引用的下层 BOM,直接子项进入编辑模式后提供可用版本选择器,并默认选择最近一次生效的版本。页面展示所选版本的结构,并提供跳转到该物料 BOM 的入口;不在上层 BOM 中直接修改下层 BOM。可用版本停用后,已锁定它的历史上层版本仍可查看和展开;尚未生效的上层草稿必须改选可用版本后才能生效。
|
||||
|
||||
### 4.5 新建版本
|
||||
|
||||
“新建版本”从 BOM 列表或历史版本发起,不属于编辑草稿内部操作:
|
||||
“新建版本”从当前物料的 BOM 页面发起,不属于编辑草稿内部操作。点击后先弹出创建方式选择窗口:
|
||||
|
||||
```text
|
||||
选择一个历史版本
|
||||
↓
|
||||
完整复制该版本的 BOM 明细
|
||||
↓
|
||||
生成新的草稿版本
|
||||
↓
|
||||
进入草稿编辑页
|
||||
┌─ 新建整机 A 的 BOM 版本 ──────────────────────────┐
|
||||
│ │
|
||||
│ [复制旧版] 推荐:沿用现有结构,再修改差异 │
|
||||
│ 选择来源版本:[V2 · 可用 ▼] │
|
||||
│ │
|
||||
│ 空白创建 从零开始添加子物料 │
|
||||
│ │
|
||||
│ [取消] [创建草稿] │
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
每次只复制一个明确选中的来源版本,不复制该父物料的全部历史版本。
|
||||
“复制旧版”作为醒目的默认选项,其“来源版本”使用弹窗内的下拉框选择,列出该父物料全部已有版本并标明状态;默认预选当前 BOM 页面正在查看的版本,也允许在下拉框中改选其他版本,不再打开第二个版本选择窗口。创建时完整复制所选版本的明细,并记录来源版本。“空白创建”使用普通样式,不复制明细,来源版本为空。两种方式都生成新的草稿版本并进入草稿编辑页;每次只创建一个版本。
|
||||
|
||||
### 4.6 状态操作
|
||||
|
||||
|
|
@ -293,29 +322,17 @@ BOM 列表
|
|||
|
||||
## 5. 数据模型建议
|
||||
|
||||
第一版建议使用三个 BOM 核心表,以及独立的 BOM 操作记录表。现有“物料变更日志”依赖物料 ID 和物料变更字段,不能直接承载 BOM 版本事件。
|
||||
第一版使用现有 `materials` 作为父物料和子物料主数据,新增 `bom_versions`、`bom_items` 两个 BOM 核心表,以及独立的 BOM 操作记录表。现有“物料变更日志”依赖物料 ID 和物料变更字段,不能直接承载 BOM 版本事件。
|
||||
|
||||
### 5.1 `bom_headers`
|
||||
一个父物料由 `materials.id` 标识,版本表直接引用它。出现在 BOM 中的父物料和所有子物料必须先在 `materials` 表中存在;如果尚未录入,应先到物料主数据新增物料,再返回 BOM 页面选择。物料名称、编码、类别和基础单位仍由物料主数据维护,BOM 表不重复保存。
|
||||
|
||||
代表某个父物料的 BOM 主体,而不是具体版本。
|
||||
### 5.1 `bom_versions`
|
||||
|
||||
建议字段:
|
||||
|
||||
- `id`
|
||||
- `organization_id`
|
||||
- `parent_material_id`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
约束:同一组织下 `parent_material_id` 唯一。
|
||||
|
||||
### 5.2 `bom_versions`
|
||||
|
||||
建议字段:
|
||||
|
||||
- `id`
|
||||
- `organization_id`
|
||||
- `bom_header_id`
|
||||
- `parent_material_id`,引用 `materials.id`。
|
||||
- `version`
|
||||
- `status`
|
||||
- `source_version_id`,记录复制来源,可为空。
|
||||
|
|
@ -328,10 +345,10 @@ BOM 列表
|
|||
|
||||
约束:
|
||||
|
||||
- 同一 `bom_header_id` 下版本号唯一。
|
||||
- 为 `bom_header_id`、`status` 和 `effective_at` 建立查询索引。默认版本由可用版本的生效时间和版本 ID 确定,不单独保存可变的默认标记。
|
||||
- 同一组织、同一 `parent_material_id` 下版本号唯一;父物料必须属于同一组织,且为允许建立 BOM 的成品或半成品。
|
||||
- 为 `organization_id`、`parent_material_id`、`status` 和 `effective_at` 建立查询索引。默认版本由可用版本的生效时间和版本 ID 确定,不单独保存可变的默认标记。
|
||||
|
||||
### 5.3 `bom_items`
|
||||
### 5.2 `bom_items`
|
||||
|
||||
建议字段:
|
||||
|
||||
|
|
@ -343,16 +360,29 @@ BOM 列表
|
|||
- `unit_id`
|
||||
- `loss_rate`
|
||||
- `remark`
|
||||
- `locked_child_version_id`
|
||||
- `child_bom_version_id`,引用所选子物料本身的 BOM 版本;子物料本身无下层 BOM 时为空。
|
||||
- `sort_order`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
约束:同一 `bom_version_id` 下 `child_material_id` 唯一。
|
||||
约束:同一 `bom_version_id` 下 `child_material_id` 唯一。子物料引用 `materials.id`,必须与该 BOM 版本属于同一组织;`child_bom_version_id` 必须指向以该子物料为父物料的 BOM 版本。草稿可选择可用版本,草稿生效后该引用不可修改。
|
||||
|
||||
树形层级不直接存成固定层级字段。系统通过各父物料 BOM 版本之间的引用递归组成完整树,因此支持 10 层或 20 层时不需要改变表结构。
|
||||
|
||||
### 5.4 BOM 操作记录
|
||||
查询某个整机 BOM 的结构时,按以下关系逐层查找:
|
||||
|
||||
```text
|
||||
整机 materials.id
|
||||
→ 按 bom_versions.parent_material_id 查整机的版本,并选择要查看的版本
|
||||
→ 用该版本的 bom_versions.id 查 bom_items.bom_version_id,取得全部直接子项
|
||||
→ 用每条 bom_items.child_material_id 查 materials,取得子物料主数据
|
||||
→ 用该条 bom_items.child_bom_version_id 查已选定的下层 BOM 版本
|
||||
→ 用下层版本的 bom_versions.id 继续查它的 bom_items
|
||||
```
|
||||
|
||||
`child_bom_version_id` 为空时,该子项不再往下展开;有值时按这个固定版本继续查询。新增或编辑草稿时,系统才根据 `child_material_id` 查询该子物料的可用 BOM 版本,默认预选最近生效的版本,并允许用户选择其他可用版本。保存所选版本 ID 后,查看已保存的 BOM 不再重新选择默认版本,因此下层 BOM 后续推出新版本也不会改变当前 BOM 的结构。查看历史 BOM 时仍按当时保存的版本引用展开。
|
||||
|
||||
### 5.3 BOM 操作记录
|
||||
|
||||
独立记录每次 BOM 状态变化,至少保存经营组织、父物料、BOM 版本、操作类型、操作人、操作时间和说明。生效与停用各记录一次;默认版本随这些状态变化而自动重新计算,不生成额外的停用记录。
|
||||
|
||||
|
|
@ -362,13 +392,13 @@ BOM 列表
|
|||
|
||||
```text
|
||||
model
|
||||
→ 定义 BOM、版本、子项和展开结果
|
||||
→ 定义 BOM、版本、子项和树形结构结果
|
||||
|
||||
repository
|
||||
→ 负责数据库查询、事务和状态更新
|
||||
|
||||
service
|
||||
→ 负责版本规则、复制、校验、生效、停用和递归展开
|
||||
→ 负责版本规则、复制、校验、生效、停用和结构树查询
|
||||
|
||||
handler
|
||||
→ 负责 HTTP 参数、超级管理员鉴权和统一响应
|
||||
|
|
@ -376,19 +406,19 @@ handler
|
|||
|
||||
接口草案:
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `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 |
|
||||
| 方法 | 路径 | 用途 |
|
||||
| -------- | ---------------------------------------- | ---------------------- |
|
||||
| `POST` | `/api/boms` | 为指定父物料创建首个草稿版本 |
|
||||
| `GET` | `/api/boms/:materialId` | 按父物料 ID 查看 BOM 及默认版本摘要 |
|
||||
| `GET` | `/api/boms/:materialId/versions` | 按父物料 ID 查看全部版本 |
|
||||
| `POST` | `/api/boms/:materialId/versions` | 为已有 BOM 创建空白草稿版本 |
|
||||
| `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` | 停用生效版本 |
|
||||
| `GET` | `/api/bom-versions/:versionId/tree` | 按父项数量 `1` 查看完整 BOM 结构 |
|
||||
|
||||
接口命名在实施计划阶段可按现有路由风格进一步统一,但业务边界不应改变。
|
||||
|
||||
|
|
@ -396,12 +426,12 @@ handler
|
|||
|
||||
前端校验用于即时反馈,后端校验是最终可信规则。
|
||||
|
||||
| 时机 | 校验内容 |
|
||||
| --- | --- |
|
||||
| 选择子物料 | 当前父节点重复、自引用、物料状态 |
|
||||
| 保存草稿 | 数量、损耗率、基础单位、重复、自引用、循环、20 层限制 |
|
||||
| 生效 | 完整重复执行保存校验,并验证所选下层版本仍可用 |
|
||||
| 递归展开 | 循环保护、20 层限制、版本引用完整性 |
|
||||
| 时机 | 校验内容 |
|
||||
| ----- | ---------------------------- |
|
||||
| 选择子物料 | 当前组织物料存在、当前父节点重复、自引用、物料状态 |
|
||||
| 保存草稿 | 数量、损耗率、基础单位、重复、自引用、循环、20 层限制 |
|
||||
| 生效 | 完整重复执行保存校验,并验证所选下层版本仍可用 |
|
||||
| 结构树查询 | 循环保护、20 层限制、版本引用完整性 |
|
||||
|
||||
错误信息需要包含:
|
||||
|
||||
|
|
@ -444,6 +474,7 @@ handler
|
|||
### 9.1 后端
|
||||
|
||||
- 创建首个草稿和保存明细。
|
||||
- 父物料和子物料必须存在于物料主数据且属于同一组织;不存在的物料 ID 无法创建 BOM。
|
||||
- 同一父物料版本号重复。
|
||||
- 同一版本直接子项重复。
|
||||
- 父项直接引用自身。
|
||||
|
|
@ -455,21 +486,29 @@ handler
|
|||
- 停用默认版本后,默认版本按最近生效时间回退;无可用版本时默认版本为空。
|
||||
- 从指定版本完整复制为新草稿。
|
||||
- 下层版本锁定后不随新版本变化。
|
||||
- 多层需求数量、损耗率和底层物料汇总正确。
|
||||
- 单件多层结构、各层用量和损耗率计算正确,且不返回跨分支物料汇总。
|
||||
- 非超级管理员访问全部 BOM 接口均被拒绝。
|
||||
- 不同组织的数据不能相互读取或引用。
|
||||
- BOM 状态变化与对应操作记录同时提交。
|
||||
|
||||
### 9.2 前端
|
||||
|
||||
- 导航位置、超级管理员可见性和路由保护。
|
||||
- 列表查询、主行展示,以及点击“历史版本”后打开独立弹窗查看全部版本。
|
||||
- 新建 BOM 时只允许选择尚未建立 BOM 的成品或半成品。
|
||||
- 物料列表仅对符合条件的父物料显示“BOM”按钮;入口和 BOM 页面仅超级管理员可访问。
|
||||
- 从物料列表进入指定物料的独立 BOM 页面;版本选择框列出该物料全部版本并标明状态,切换后直接显示所选版本结构。
|
||||
- 没有版本时版本框显示“无”,结构区域显示空状态;创建首个草稿后自动选中该版本。
|
||||
- 版本选择框包含全部草稿、可用和已停用版本,约 50 个版本以内可滚动选择;切换后显示该版本结构与对应状态操作。
|
||||
- 只有符合条件且尚无版本的成品或半成品,才能从其 BOM 页面创建首个草稿;父物料由入口固定,不再重复选择。
|
||||
- 草稿可编辑,可用和已停用版本只读。
|
||||
- 选中草稿时,状态行显示“编辑”“生效”“删除草稿”;点击顶部“编辑”后才进入树形编辑状态,选中可用版本时仅显示“停用”。
|
||||
- 点击节点先在右侧查看明细;仅草稿的直接子项显示“编辑”按钮,点击后才进入编辑模式。
|
||||
- 删除子物料入口只在右侧编辑模式出现,删除前确认,且只影响当前草稿中的该条子项。
|
||||
- “添加子物料”只在根物料的第二层子项列表末尾出现;更深层节点通过对应子 BOM 维护。
|
||||
- 添加子物料时只允许选择当前组织已有物料;搜索不到时提示先新增物料主数据,再返回 BOM 草稿继续添加。
|
||||
- 添加重复子项和自引用时立即提示。
|
||||
- 后端返回循环或深度错误时定位并标记问题节点。
|
||||
- 生效、停用和删除草稿具有明确确认提示。
|
||||
- 从指定历史版本创建草稿后,明细完整复制且可继续编辑。
|
||||
- 点击“新建版本”先弹出方式选择窗口;“复制旧版”醒目且默认选中,来源版本通过弹窗内下拉框选择;“空白创建”可生成无明细草稿。
|
||||
- “日志记录”展开后显示两个子页面;原 `/logs` 链接仍能打开物料变更日志。
|
||||
- “BOM 记录”支持筛选与查看生效和停用的操作详情。
|
||||
|
||||
|
|
@ -480,9 +519,9 @@ handler
|
|||
3. 实现复制新版本及版本列表。
|
||||
4. 实现结构校验、20 层限制和完整循环路径反馈。
|
||||
5. 实现多版本可用、默认版本推导、生效和停用事务。
|
||||
6. 实现前端导航、BOM 列表和历史版本展示。
|
||||
6. 在物料列表增加 BOM 入口,实现单个物料的 BOM 页面和完整版本选择框。
|
||||
7. 实现新建及草稿树形编辑页面。
|
||||
8. 实现单 BOM 递归展开及底层物料汇总能力。
|
||||
8. 实现单个 BOM 版本按父项数量 `1` 的结构树查询和各层用量显示。
|
||||
9. 将现有日志入口改为双子页面,接入 BOM 操作记录和筛选。
|
||||
10. 补齐权限、自动化测试和真实数据副本验收。
|
||||
|
||||
|
|
@ -490,7 +529,7 @@ handler
|
|||
|
||||
第一版稳定后再分别设计和实现:
|
||||
|
||||
1. 多 BOM 需求试算,并在 `BOM 管理` 下增加独立菜单。
|
||||
1. 多 BOM 需求试算:在独立页面输入一个或多个 BOM 的计划数量,递归计算并汇总相同底层物料需求,再在“物料主数据”下增加菜单。
|
||||
2. CSV 层级导入和错误报告。
|
||||
3. 替代料管理。
|
||||
4. 面向技术人员等角色的细分权限。
|
||||
|
|
|
|||
218
docs/数据库表结构与关系说明.md
Normal file
218
docs/数据库表结构与关系说明.md
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
# 物料管理系统数据库表说明
|
||||
|
||||
更新时间:2026-10-08。数据库:当前本地 MySQL `wmms`,共 **30 张表**。
|
||||
|
||||
本文按照“表、保存什么、主要字段”介绍每张表的作用,后面单独列出表之间的关系。依据当前数据库结构、`scripts/init.sql` 和后端读写代码核对。
|
||||
|
||||
通用字段:`id` 为记录主键;`organization_id` 为本方经营组织 ID;`created_at`、`updated_at` 分别为创建、更新时间。表已创建与对应业务功能已完整实现是两件事,未接入当前业务流程的结构在第 9 节说明。
|
||||
|
||||
## 1. 本方组织、用户与权限
|
||||
|
||||
| 表 | 保存什么 | 主要字段 |
|
||||
|---|---|---|
|
||||
| `organizations` | 本方公司或核算主体,例如北京公司、武汉公司;用于确定业务数据归属。 | 组织ID(`id`)、组织编码(`code`)、组织名称(`name`)、状态(`status`)、默认时区(`default_timezone`)、`created_at`、`updated_at` |
|
||||
| `users` | 系统登录账号和用户说明。 | 用户ID(`id`)、用户名(`username`)、密码哈希(`password`)、用户说明(`description`)、`created_at` |
|
||||
| `roles` | 超级管理员、仓库管理员、采购、技术、生产、销售等角色定义。 | 角色ID(`id`)、角色编码(`code`)、角色名称(`name`)、是否启用(`enabled`)、`created_at`、`updated_at` |
|
||||
| `permissions` | 查看物料、维护供应商、维护出入库等可授权的操作能力。 | 权限ID(`id`)、权限编码(`code`)、权限名称(`name`)、`created_at` |
|
||||
| `user_roles` | 用户拥有哪些角色。 | 用户ID(`user_id`)、角色ID(`role_id`)、分配时间(`created_at`) |
|
||||
| `role_permissions` | 角色拥有哪些权限。 | 角色ID(`role_id`)、权限ID(`permission_id`)、授权时间(`created_at`) |
|
||||
| `user_organizations` | 用户可访问的组织范围及默认组织。 | 用户ID(`user_id`)、组织ID(`organization_id`)、默认组织标记(`is_default`)、`created_at` |
|
||||
| `user_warehouses` | 用户可访问的仓库范围。 | 用户ID(`user_id`)、仓库ID(`warehouse_id`)、`created_at` |
|
||||
| `revoked_tokens` | 被撤销令牌的登记结构,供令牌失效机制使用。 | 令牌标识(`jti`,主键)、用户ID(`user_id`)、原到期时间(`expires_at`)、撤销时间(`revoked_at`) |
|
||||
|
||||
## 2. 物料与基础字典
|
||||
|
||||
| 表 | 保存什么 | 主要字段 |
|
||||
|---|---|---|
|
||||
| `materials` | 物料基础资料,用于识别和描述物料;库存余额另存。 | 物料ID(`id`)、所属组织(`organization_id`)、物料编码(`material_code`)、名称(`name`)、图片(`material_image`)、规格(`spec`)、厂商型号(`manufacturer_model`)、分类ID(`category_id`)、基本单位ID(`base_unit_id`)、单位文本(`unit`)、状态(`status`)、资料版本(`version`)、`created_at`、`updated_at` |
|
||||
| `material_categories` | 物料大类、子类及分类定义,形成分类树。 | 分类ID(`id`)、所属组织(`organization_id`)、编码(`code`)、名称(`name`)、分类说明(`description`)、父分类ID(`parent_id`)、状态(`status`) |
|
||||
| `units` | 个、瓶、包、升等计量单位字典及数量精度。 | 单位ID(`id`)、所属组织(`organization_id`)、单位编码(`code`)、单位名称(`name`)、小数位数(`precision_scale`)、状态(`status`) |
|
||||
| `code_rules` | 按组织和物料分类配置的编码生成规则。 | 规则ID(`id`)、所属组织(`organization_id`)、分类ID(`category_id`)、编码前缀(`prefix`)、流水号长度(`sequence_length`)、下一个流水号(`next_sequence`)、状态(`status`) |
|
||||
| `material_unit_conversions` | 特定物料在两个计量单位之间的换算系数及有效期。 | 规则ID(`id`)、所属组织(`organization_id`)、物料ID(`material_id`)、来源单位ID(`from_unit_id`)、目标单位ID(`to_unit_id`)、换算系数(`conversion_rate`)、生效日期(`effective_from`)、失效日期(`effective_to`) |
|
||||
| `tags` | 物料关联的唯一码或标签,用于按标签查找物料。 | 标签ID(`id`)、物料ID(`material_id`)、标签编码(`tag_code`)、`created_at` |
|
||||
|
||||
## 3. 客户、供应商与供货关系
|
||||
|
||||
| 表 | 保存什么 | 主要字段 |
|
||||
|---|---|---|
|
||||
| `business_partners` | 企业基本资料,多个本方组织可以共用。 | 企业ID(`id`)、企业名称(`legal_name`)、统一社会信用代码(`unified_credit_code`)、联系人(`contact_name`)、电话(`contact_phone`)、地址(`address`)、`created_at`、`updated_at` |
|
||||
| `organization_partners` | 当前组织如何与这家企业合作。 | 关系ID(`id`)、所属组织(`organization_id`)、企业ID(`business_partner_id`)、伙伴编码(`partner_code`)、供应商身份(`is_supplier`)、客户身份(`is_customer`)、供应商状态(`supplier_status`)、客户状态(`customer_status`)、供应商分类(`supplier_categories`)、客户分类(`customer_categories`)、结算币种(`settlement_currency`)、付款条款(`payment_terms`)、`created_at`、`updated_at` |
|
||||
| `material_suppliers` | 某个物料由哪个供应商供货,以及报价和供货条件。 | 记录ID(`id`)、所属组织(`organization_id`)、物料ID(`material_id`)、组织伙伴关系ID(`organization_partner_id`)、价格(`price`)、币种(`currency`)、最小订购量(`minimum_order_quantity`)、供货周期天数(`lead_time_days`)、首选标记(`is_preferred`)、生效日期(`effective_from`)、失效日期(`effective_to`)、状态(`status`) |
|
||||
|
||||
同一外部企业可以与北京、武汉分别建立合作关系。企业名称、联系人、地址等基础资料共用;客户/供应商身份、状态和分类按组织分别保存。同一关系可同时具有客户和供应商身份。
|
||||
|
||||
`supplier_categories` 与 `customer_categories` 均支持多选:`material` 表示“物料级(生产)”,`finished_product` 表示“成品级(经营买卖)”;空集合表示未分类。
|
||||
|
||||
## 4. 仓库、库位与库存
|
||||
|
||||
| 表 | 保存什么 | 主要字段 |
|
||||
|---|---|---|
|
||||
| `warehouses` | 本方组织的仓库资料。 | 仓库ID(`id`)、所属组织(`organization_id`)、仓库编码(`code`)、名称(`name`)、状态(`status`)、`created_at`、`updated_at` |
|
||||
| `warehouse_locations` | 某个仓库内部的存放位置。 | 库位ID(`id`)、所属仓库(`warehouse_id`)、库位编码(`code`)、名称(`name`)、状态(`status`)、`created_at`、`updated_at` |
|
||||
| `inventory_balances` | 某物料在某组织、仓库、库位的当前库存余额。 | 余额ID(`id`)、组织ID(`organization_id`)、仓库ID(`warehouse_id`)、库位ID(`location_id`)、物料ID(`material_id`)、现存量(`on_hand`)、预留量(`reserved`)、锁定量(`locked`)、在途量(`in_transit`)、库存版本(`version`)、`updated_at` |
|
||||
| `stock_warning_rules` | 某组织、仓库、物料的库存预警规则。 | 规则ID(`id`)、组织ID(`organization_id`)、仓库ID(`warehouse_id`)、物料ID(`material_id`)、预警阈值(`minimum_available`)、是否启用(`enabled`)、`created_at`、`updated_at` |
|
||||
|
||||
库位通过所属仓库确定组织。库存余额按库位保存,预警规则按仓库保存。当前预警查询使用现存量口径,启用且现存量小于等于阈值时预警,阈值为 0 也有效。
|
||||
|
||||
## 5. 入库与出库单据
|
||||
|
||||
| 表 | 保存什么 | 主要字段 |
|
||||
|---|---|---|
|
||||
| `inbound_orders` | 一次入库操作的公共信息,即入库单表头。 | 单据ID(`id`)、所属组织(`organization_id`)、仓库ID(`warehouse_id`)、库位ID(`location_id`)、入库单号(`inbound_no`)、操作人(`operator`)、入库时间(`inbound_date`)、经手人(`handler`)、供应商文本(`supplier`)、备注(`remark`)、`created_at`、`updated_at` |
|
||||
| `inbound_items` | 入库单中逐项入库的物料和数量。 | 明细ID(`id`)、入库单ID(`inbound_order_id`)、物料ID(`material_id`)、数量(`quantity`)、`created_at` |
|
||||
| `outbound_orders` | 一次出库操作的公共信息,即出库单表头。 | 单据ID(`id`)、所属组织(`organization_id`)、仓库ID(`warehouse_id`)、库位ID(`location_id`)、出库单号(`outbound_no`)、操作人(`operator`)、出库时间(`outbound_date`)、经手人(`handler`)、接收方文本(`receiver`)、备注(`remark`)、旧库状态字段(`status`)、`created_at`、`updated_at` |
|
||||
| `outbound_items` | 出库单中逐项出库的物料和数量。 | 明细ID(`id`)、出库单ID(`outbound_order_id`)、物料ID(`material_id`)、数量(`quantity`)、`created_at` |
|
||||
|
||||
一张单据可以有多条物料明细;当前仓库、库位保存在表头,明细没有独立库位。`supplier`、`receiver`、`operator`、`handler` 均为文本,不是伙伴或用户表外键。
|
||||
|
||||
## 6. 物料申请与审批
|
||||
|
||||
| 表 | 保存什么 | 主要字段 |
|
||||
|---|---|---|
|
||||
| `material_applications` | 一次物料领用申请的申请人、用途和审批结果。 | 申请ID(`id`)、申请人ID(`user_id`)、用途(`purpose`)、申请状态(`status`)、驳回原因(`rejection_reason`)、审批人ID(`approved_by`)、`created_at`、`updated_at` |
|
||||
| `application_items` | 申请中需要领用的物料和数量。 | 明细ID(`id`)、申请ID(`application_id`)、物料ID(`material_id`)、申请数量(`quantity`) |
|
||||
|
||||
申请状态为 `pending`(待审批)、`approved`(已通过)、`rejected`(已驳回)、`cancelled`(已取消)。当前申请明细引用已有物料;新增物料生成编码属于另一条主数据流程。
|
||||
|
||||
审批选择自动出库时,当前代码生成出库单和明细、扣减库存并记录流水;仅审批模式只更新审批状态。申请表没有直接指向生成出库单的字段,也没有 `organization_id` 或个人库存字段。
|
||||
|
||||
## 7. 物料变更日志与库存流水
|
||||
|
||||
| 表 | 保存什么 | 主要字段 |
|
||||
|---|---|---|
|
||||
| `material_logs` | 物料资料新增、修改等操作及修改前后快照。 | 日志ID(`id`)、操作类型(`operation_type`)、物料ID(`material_id`)、旧值快照(`old_value`)、新值快照(`new_value`)、操作人(`operator`)、说明(`description`)、`created_at` |
|
||||
| `stock_logs` | 库存增减数量、方向和来源单据。 | 流水ID(`id`)、物料ID(`material_id`)、方向(`type`)、数量(`quantity`)、来源类型(`related_order_type`)、来源记录ID(`related_order_id`)、`created_at` |
|
||||
|
||||
`material_logs` 描述资料变化,`stock_logs` 描述数量变化。库存流水的 `type` 为 `in` 或 `out`;来源类型包含 `inbound`、`outbound`、`application`,分别指向入库单、出库单、申请主键。
|
||||
|
||||
## 8. 表之间的关系
|
||||
|
||||
### 8.1 关系如何理解
|
||||
|
||||
| 关系 | 保存方式 | 例子 |
|
||||
|---|---|---|
|
||||
| 一对多 | 多的一方保存主表ID。 | 一个入库单对应多个入库明细:`inbound_items.inbound_order_id → inbound_orders.id`。 |
|
||||
| 多对多 | 中间表同时保存两边的ID。 | 用户与角色通过 `user_roles` 关联;组织与企业通过 `organization_partners` 关联。 |
|
||||
| 自关联 | 记录引用同一张表的另一条记录。 | `material_categories.parent_id → material_categories.id`,表示分类父子关系。 |
|
||||
| 逻辑关联 | 代码按字段查询,数据库未设置外键。 | 申请人ID关联用户、库存流水按来源类型及ID关联单据。 |
|
||||
|
||||
### 8.2 当前实际存在的外键
|
||||
|
||||
以下按数据库元数据逐项列出。外键确保引用记录存在;记录是否同属一个组织,还需要业务层校验。
|
||||
|
||||
| 子表字段 | 引用主表字段 |
|
||||
|---|---|
|
||||
| `application_items.application_id` | `material_applications.id` |
|
||||
| `application_items.material_id` | `materials.id` |
|
||||
| `code_rules.category_id` | `material_categories.id` |
|
||||
| `code_rules.organization_id` | `organizations.id` |
|
||||
| `inbound_items.material_id` | `materials.id` |
|
||||
| `inbound_items.inbound_order_id` | `inbound_orders.id` |
|
||||
| `inbound_orders.location_id` | `warehouse_locations.id` |
|
||||
| `inbound_orders.organization_id` | `organizations.id` |
|
||||
| `inbound_orders.warehouse_id` | `warehouses.id` |
|
||||
| `inventory_balances.location_id` | `warehouse_locations.id` |
|
||||
| `inventory_balances.material_id` | `materials.id` |
|
||||
| `inventory_balances.organization_id` | `organizations.id` |
|
||||
| `inventory_balances.warehouse_id` | `warehouses.id` |
|
||||
| `material_categories.organization_id` | `organizations.id` |
|
||||
| `material_categories.parent_id` | `material_categories.id` |
|
||||
| `material_suppliers.material_id` | `materials.id` |
|
||||
| `material_suppliers.organization_id` | `organizations.id` |
|
||||
| `material_suppliers.organization_partner_id` | `organization_partners.id` |
|
||||
| `material_unit_conversions.from_unit_id` | `units.id` |
|
||||
| `material_unit_conversions.material_id` | `materials.id` |
|
||||
| `material_unit_conversions.organization_id` | `organizations.id` |
|
||||
| `material_unit_conversions.to_unit_id` | `units.id` |
|
||||
| `materials.base_unit_id` | `units.id` |
|
||||
| `materials.category_id` | `material_categories.id` |
|
||||
| `materials.organization_id` | `organizations.id` |
|
||||
| `organization_partners.organization_id` | `organizations.id` |
|
||||
| `organization_partners.business_partner_id` | `business_partners.id` |
|
||||
| `outbound_items.material_id` | `materials.id` |
|
||||
| `outbound_items.outbound_order_id` | `outbound_orders.id` |
|
||||
| `outbound_orders.location_id` | `warehouse_locations.id` |
|
||||
| `outbound_orders.organization_id` | `organizations.id` |
|
||||
| `outbound_orders.warehouse_id` | `warehouses.id` |
|
||||
| `revoked_tokens.user_id` | `users.id` |
|
||||
| `role_permissions.permission_id` | `permissions.id` |
|
||||
| `role_permissions.role_id` | `roles.id` |
|
||||
| `stock_logs.material_id` | `materials.id` |
|
||||
| `stock_warning_rules.material_id` | `materials.id` |
|
||||
| `stock_warning_rules.organization_id` | `organizations.id` |
|
||||
| `stock_warning_rules.warehouse_id` | `warehouses.id` |
|
||||
| `tags.material_id` | `materials.id` |
|
||||
| `units.organization_id` | `organizations.id` |
|
||||
| `user_organizations.organization_id` | `organizations.id` |
|
||||
| `user_organizations.user_id` | `users.id` |
|
||||
| `user_roles.role_id` | `roles.id` |
|
||||
| `user_roles.user_id` | `users.id` |
|
||||
| `user_warehouses.user_id` | `users.id` |
|
||||
| `user_warehouses.warehouse_id` | `warehouses.id` |
|
||||
| `warehouse_locations.warehouse_id` | `warehouses.id` |
|
||||
| `warehouses.organization_id` | `organizations.id` |
|
||||
|
||||
### 8.3 逻辑关联及间接关联
|
||||
|
||||
| 表之间的关系 | 关联字段 | 说明 |
|
||||
|---|---|---|
|
||||
| 申请 → 申请人 | `material_applications.user_id → users.id` | 当前实际库未设置用户外键,代码查询申请人资料。 |
|
||||
| 申请 → 审批人 | `material_applications.approved_by → users.id` | 当前无外键;未审批时通常为 0。 |
|
||||
| 物料日志 → 物料 | `material_logs.material_id → materials.id` | 当前实际库无外键。 |
|
||||
| 库存流水 → 入库单 | `related_order_type = inbound` 时,`related_order_id → inbound_orders.id` | 按类型和ID配对查询。 |
|
||||
| 库存流水 → 出库单 | `related_order_type = outbound` 时,`related_order_id → outbound_orders.id` | 按类型和ID配对查询。 |
|
||||
| 库存流水 → 申请 | `related_order_type = application` 时,`related_order_id → material_applications.id` | 自动出库流水保存的是申请ID,不是生成的出库单ID。 |
|
||||
| 库位 → 组织 | `warehouse_locations.warehouse_id → warehouses.organization_id` | 通过仓库间接确定组织。 |
|
||||
| 物料 → 编码规则 | 组织及分类对应 `code_rules` | 物料只保存生成的编码文本,没有编码规则ID字段。 |
|
||||
| 申请 → 自动生成的出库单 | 当前没有直接关联字段 | 属于业务生成过程,不能按现有表头字段直接定位关联单据。 |
|
||||
|
||||
### 8.4 主要唯一约束
|
||||
|
||||
| 表 | 唯一字段或组合 | 含义 |
|
||||
|---|---|---|
|
||||
| `organizations`、`roles`、`permissions` | `code` | 各自表内编码唯一。 |
|
||||
| `users` | `username` | 用户名唯一。 |
|
||||
| `business_partners` | `unified_credit_code` | 已填写的信用代码唯一,允许为空;企业名称没有唯一约束。 |
|
||||
| `organization_partners` | `(organization_id, business_partner_id)`;`(organization_id, partner_code)` | 同一组织与同一企业只有一条合作关系;伙伴编码在组织内唯一。 |
|
||||
| `materials` | `(organization_id, material_code)` | 物料编码在组织内唯一。 |
|
||||
| `material_categories`、`units`、`warehouses` | `(organization_id, code)` | 分类、单位、仓库编码分别在组织内唯一。 |
|
||||
| `warehouse_locations` | `(warehouse_id, code)` | 库位编码在仓库内唯一。 |
|
||||
| `code_rules` | `(organization_id, category_id)` | 同一组织同一分类只有一条编码规则。 |
|
||||
| `inventory_balances` | `(organization_id, warehouse_id, location_id, material_id)` | 同一库存维度只有一条余额。 |
|
||||
| `stock_warning_rules` | `(organization_id, warehouse_id, material_id)` | 同一仓库同一物料只有一条预警规则。 |
|
||||
| `tags` | `tag_code` | 标签编码全表唯一。 |
|
||||
| `inbound_orders`、`outbound_orders` | `inbound_no`、`outbound_no` | 各自单据表内单号唯一。 |
|
||||
| `user_roles` | `(user_id, role_id)` 联合主键 | 同一用户不会重复分配同一角色。 |
|
||||
| `role_permissions` | `(role_id, permission_id)` 联合主键 | 同一角色不会重复分配同一权限。 |
|
||||
| `user_organizations` | `(user_id, organization_id)` 联合主键 | 同一组织授权不会重复。 |
|
||||
| `user_warehouses` | `(user_id, warehouse_id)` 联合主键 | 同一仓库授权不会重复。 |
|
||||
|
||||
## 9. 当前结构与使用范围
|
||||
|
||||
| 项目 | 当前情况 |
|
||||
|---|---|
|
||||
| 令牌撤销 | `revoked_tokens` 结构已建,当前认证代码未查询该表;退出登录主要由客户端清除令牌。 |
|
||||
| 物料单位换算 | `material_unit_conversions` 结构已建,当前后端未见该表业务读写路径;库存余额支持小数,但出入库、申请明细及流水数量仍为 INT。 |
|
||||
| 物料供应商条件 | `material_suppliers` 结构已建,用于供货、报价等需求;当前后端未见完整维护路径。 |
|
||||
| 实物追踪 | `tags` 只有物料和标签关系,没有库位、单据明细或实物流转状态;物料表没有序列号开关。 |
|
||||
| 组织范围 | 多数业务表有组织字段;当前申请表、库存流水和物料日志没有直接组织字段。角色分配也没有按组织分别保存。 |
|
||||
| 伙伴资料共享 | 多个组织引用同一企业ID时,修改企业名称、联系人等基础资料会影响共享记录;合作身份、分类、状态分别保存。当前新建接口仍直接新增企业记录。 |
|
||||
| 库存口径 | 结构支持现存、预留、锁定、在途数量;当前主要出入库代码修改默认组织、默认仓库、主库位的现存量。 |
|
||||
| 可用库存 | 设计口径为 `on_hand - reserved - locked`;当前预警查询使用现存量,不能仅根据阈值字段名推断已使用可用量。 |
|
||||
| 日志口径 | 物料日志实际库快照为JSON、操作类型为ENUM;库存流水编辑单据时可能重建,不是不可修改的完整审计日志。 |
|
||||
| 出库状态 | 当前库保留 `outbound_orders.status`,但现有代码未按此列维护状态;初始化脚本没有该列。 |
|
||||
| 物料类型 | 初始化脚本另有 `material_types` 和 `materials.material_type_id`;当前实际库及当前物料模型没有这两项。该表未计入本文的30张现有表。 |
|
||||
| 申请单号 | 页面字段 `application_no` 由申请主键派生,申请表没有该列。 |
|
||||
| 后续模块 | 当前库没有BOM专用表、盘点单专用表、周期提醒专用表或申请人个人库存表。 |
|
||||
|
||||
## 10. 数据库初始化
|
||||
|
||||
| 文件 | 用途 |
|
||||
|---|---|
|
||||
| `scripts/init.sql` | 空库首次初始化,包含表结构、客户和供应商分类字段、物料分类及编码规则,以及明确标注的20家虚构企业示例数据。 |
|
||||
|
||||
初始化脚本直接建立分类说明字段、6 个大类和 28 个子类、父子分类关系,以及“子类编码 + 4 位流水号”的编码规则。出入库表直接包含单据编号、操作人和备注字段。
|
||||
|
||||
库存预警阈值为 0 是有效配置,表示库存降到 0 时预警;新库没有需要清理的历史预警规则。
|
||||
|
||||
已有数据库需要保留数据时,应先备份,再根据实际结构差异准备并验证增量 SQL;不要直接重复执行完整初始化脚本。
|
||||
|
|
@ -73,6 +73,10 @@ function codeInput(wrapper) {
|
|||
return wrapper.find('.material-code-field input')
|
||||
}
|
||||
|
||||
function inputWithValue(wrapper, value) {
|
||||
return wrapper.findAll('input').find(input => input.element.value === value)
|
||||
}
|
||||
|
||||
function setupState(wrapper) {
|
||||
return wrapper.vm.$.setupState
|
||||
}
|
||||
|
|
@ -285,10 +289,11 @@ describe('新增物料后列表与搜索框保持一致', () => {
|
|||
|
||||
await searchBy(wrapper, '螺丝')
|
||||
|
||||
setupState(wrapper).showEditModal({
|
||||
setupState(wrapper).showDetail({
|
||||
id: 7, category_id: 12, material_code: 'A010201', name: '螺丝'
|
||||
})
|
||||
await flushPromises()
|
||||
await findButton(wrapper, '编辑').trigger('click')
|
||||
await setupState(wrapper).handleSave()
|
||||
await flushPromises()
|
||||
|
||||
|
|
@ -347,9 +352,7 @@ describe('物料列表的作废入口文案', () => {
|
|||
return wrapper.findAll('button').find(button => button.text().trim() === text)
|
||||
}
|
||||
|
||||
// 这一页没有状态列(文案即状态),所以按钮文案本身得说清这条物料怎么了。
|
||||
// 「撤销」在中文 UI 里默认指撤销上一步(Ctrl+Z),看不出在撤销什么;「撤销作废」
|
||||
// 才读得出「这条被作废了,点这里撤销那次作废」。
|
||||
// 作废与恢复仍通过原操作按钮呈现,按钮文案本身说明当前状态。
|
||||
test('作废的那行显示「撤销作废」,有效的那行显示「作废物料」', async () => {
|
||||
const wrapper = mountMaterials()
|
||||
await flushPromises()
|
||||
|
|
@ -363,10 +366,14 @@ describe('物料列表的作废入口文案', () => {
|
|||
// 注意断言的是行归属,不是「页面上有没有这个词」——两个词都含「作废」。
|
||||
expect(restore.element.closest('tr').textContent).toContain('旧螺丝')
|
||||
expect(obsolete.element.closest('tr').textContent).toContain('新螺丝')
|
||||
expect(restore.element.closest('td').textContent).toContain('详情')
|
||||
expect(obsolete.element.closest('td').textContent).toContain('详情')
|
||||
expect(restore.element.closest('td').cellIndex).toBe(obsolete.element.closest('td').cellIndex)
|
||||
expect(wrapper.findAll('th').some(header => header.text().includes('作废状态'))).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
// 两个按钮轮流出现在同一列,宽度不一样会让「编辑」右边参差不齐。这里靠的是两边
|
||||
// 两个按钮轮流出现在操作列,宽度不一样会让按钮参差不齐。这里靠的是两边
|
||||
// 都是 4 个中文字,同字体同字号下自然等宽——jsdom 不做布局计算,量不了像素,但
|
||||
// 字数不等就一定会参差,所以把字数锁住。
|
||||
test('两个按钮的文案字数相同,保证宽度一致', async () => {
|
||||
|
|
@ -381,6 +388,91 @@ describe('物料列表的作废入口文案', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('详情内编辑物料', () => {
|
||||
const row = { id: 7, category_id: 12, category: 'A01', material_code: 'MAT-7', name: '原名称', status: 'active' }
|
||||
|
||||
beforeEach(() => {
|
||||
materialCategoriesApi.list.mockResolvedValue({ data: categories })
|
||||
unitsApi.list.mockResolvedValue({ data: [] })
|
||||
materialsStore.materials = [row]
|
||||
materialsStore.updateMaterial.mockReset().mockResolvedValue({ data: { ...row, name: '新名称' } })
|
||||
vi.spyOn(ElMessage, 'success').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
materialsStore.materials = []
|
||||
})
|
||||
|
||||
test('点击详情先只读展示,点击弹窗内编辑才开放输入', async () => {
|
||||
const wrapper = mountMaterials()
|
||||
await flushPromises()
|
||||
await findButton(wrapper, '详情').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.el-descriptions').text()).toContain('原名称')
|
||||
expect(inputWithValue(wrapper, '原名称')).toBeUndefined()
|
||||
expect(findButton(wrapper, '编辑')).toBeTruthy()
|
||||
|
||||
await findButton(wrapper, '编辑').trigger('click')
|
||||
await flushPromises()
|
||||
expect(inputWithValue(wrapper, '原名称')).toBeTruthy()
|
||||
expect(findButton(wrapper, '取消编辑')).toBeTruthy()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
test('取消编辑丢弃修改并回到只读详情', async () => {
|
||||
const wrapper = mountMaterials()
|
||||
await flushPromises()
|
||||
await findButton(wrapper, '详情').trigger('click')
|
||||
await findButton(wrapper, '编辑').trigger('click')
|
||||
await inputWithValue(wrapper, '原名称').setValue('临时名称')
|
||||
await findButton(wrapper, '取消编辑').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.el-descriptions').text()).toContain('原名称')
|
||||
expect(wrapper.find('.el-descriptions').text()).not.toContain('临时名称')
|
||||
await findButton(wrapper, '编辑').trigger('click')
|
||||
expect(inputWithValue(wrapper, '原名称')).toBeTruthy()
|
||||
expect(materialsStore.updateMaterial).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
test('关闭后重开详情不会带回未保存内容', async () => {
|
||||
const wrapper = mountMaterials()
|
||||
await flushPromises()
|
||||
await findButton(wrapper, '详情').trigger('click')
|
||||
await findButton(wrapper, '编辑').trigger('click')
|
||||
await inputWithValue(wrapper, '原名称').setValue('临时名称')
|
||||
setupState(wrapper).dialogVisible = false
|
||||
await flushPromises()
|
||||
await findButton(wrapper, '详情').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.el-descriptions').text()).toContain('原名称')
|
||||
expect(wrapper.find('.el-descriptions').text()).not.toContain('临时名称')
|
||||
await findButton(wrapper, '编辑').trigger('click')
|
||||
expect(inputWithValue(wrapper, '原名称')).toBeTruthy()
|
||||
expect(materialsStore.updateMaterial).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
test('保存后在同一弹窗只读展示最新内容', async () => {
|
||||
const wrapper = mountMaterials()
|
||||
await flushPromises()
|
||||
await findButton(wrapper, '详情').trigger('click')
|
||||
await findButton(wrapper, '编辑').trigger('click')
|
||||
await inputWithValue(wrapper, '原名称').setValue('新名称')
|
||||
await findButton(wrapper, '保存').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(materialsStore.updateMaterial).toHaveBeenCalledWith(7, expect.objectContaining({ name: '新名称' }))
|
||||
expect(wrapper.find('.el-descriptions').text()).toContain('新名称')
|
||||
expect(inputWithValue(wrapper, '新名称')).toBeUndefined()
|
||||
expect(findButton(wrapper, '编辑')).toBeTruthy()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('停用分类下的物料编辑', () => {
|
||||
test('保留原分类并显示停用状态,修改名称时不改变分类归属', async () => {
|
||||
materialCategoriesApi.list.mockResolvedValue({ data: [
|
||||
|
|
@ -401,7 +493,9 @@ describe('停用分类下的物料编辑', () => {
|
|||
expect(state.getMajorCategoryDisplay(row)).toBe('A 旧大类(已停用)')
|
||||
expect(state.getMinorCategoryDisplay(row)).toBe('旧小类')
|
||||
|
||||
state.showEditModal(row)
|
||||
state.showDetail(row)
|
||||
await flushPromises()
|
||||
await findButton(wrapper, '编辑').trigger('click')
|
||||
await flushPromises()
|
||||
expect(state.form.major_category_id).toBe(1)
|
||||
expect(state.form.category_id).toBe(12)
|
||||
|
|
|
|||
|
|
@ -60,15 +60,14 @@
|
|||
>
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="showDetail(row)">详情</el-button>
|
||||
<el-button v-if="authStore.isAdmin" size="small" type="primary" @click="showEditModal(row)">编辑</el-button>
|
||||
<el-button v-if="authStore.isAdmin && row.status !== 'obsolete'" size="small" type="danger" @click="handleDelete(row.id)">作废物料</el-button>
|
||||
<el-button v-if="authStore.isAdmin && row.status === 'obsolete'" size="small" type="success" @click="handleRestore(row.id)">撤销作废</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</BaseTable>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" @close="handleDialogClose">
|
||||
<el-form v-if="isCreating || isEditing" :model="form" label-width="100px">
|
||||
<el-form-item label="物料编码" required>
|
||||
<div class="material-code-field">
|
||||
<el-input
|
||||
|
|
@ -109,14 +108,7 @@
|
|||
<img v-if="imagePreview" :src="imagePreview" style="max-width:200px;margin-top:10px;" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saveLoading">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="detailVisible" title="物料详情" width="600px">
|
||||
<el-descriptions :column="2" border v-if="detailRow">
|
||||
<el-descriptions v-else-if="detailRow" :column="2" border>
|
||||
<el-descriptions-item label="物料编码">{{ detailRow.material_code }}</el-descriptions-item>
|
||||
<el-descriptions-item label="ID">{{ detailRow.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="名称">{{ detailRow.name }}</el-descriptions-item>
|
||||
|
|
@ -137,6 +129,20 @@
|
|||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer>
|
||||
<template v-if="isCreating">
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saveLoading">保存</el-button>
|
||||
</template>
|
||||
<template v-else-if="isEditing">
|
||||
<el-button @click="cancelEdit">取消编辑</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saveLoading">保存</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button @click="dialogVisible = false">关闭</el-button>
|
||||
<el-button v-if="authStore.isAdmin" type="primary" @click="startEdit">编辑</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<DeleteConfirm
|
||||
|
|
@ -181,9 +187,8 @@ const authStore = useAuthStore()
|
|||
const searchKeyword = ref('')
|
||||
const materialSearchText = ref('')
|
||||
const dialogVisible = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const dialogMode = ref('create')
|
||||
const deleteDialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增物料')
|
||||
const saveLoading = ref(false)
|
||||
const editingId = ref(null)
|
||||
const deleteId = ref(null)
|
||||
|
|
@ -196,7 +201,9 @@ const categories = ref([])
|
|||
const units = ref([])
|
||||
// 用户是否手改过编码:没改过时切分类要按新规则重算,改过就以用户的值为准。
|
||||
const codeEdited = ref(false)
|
||||
const isCreating = computed(() => dialogTitle.value === '新增物料')
|
||||
const isCreating = computed(() => dialogMode.value === 'create')
|
||||
const isEditing = computed(() => dialogMode.value === 'edit')
|
||||
const dialogTitle = computed(() => isCreating.value ? '新增物料' : '物料详情')
|
||||
// 小类还没选时编码框必然是空的,提示用户先把分类选完:选完才会自动填入编码,
|
||||
// 或者由"请手工填写"的提示接手。
|
||||
const codePlaceholder = computed(() => {
|
||||
|
|
@ -334,7 +341,8 @@ function handlePageChange(page) {
|
|||
|
||||
function showAddModal() {
|
||||
editingId.value = null
|
||||
dialogTitle.value = '新增物料'
|
||||
detailRow.value = null
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(form, createMaterialForm())
|
||||
codeEdited.value = false
|
||||
imagePreview.value = ''
|
||||
|
|
@ -344,21 +352,41 @@ function showAddModal() {
|
|||
|
||||
function showDetail(row) {
|
||||
detailRow.value = row
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
function showEditModal(row) {
|
||||
editingId.value = row.id
|
||||
dialogTitle.value = '编辑物料'
|
||||
Object.assign(form, createMaterialForm(row, categories.value))
|
||||
// 编辑时编码只读,标记成"已改过",避免切小类时被规则覆盖
|
||||
codeEdited.value = true
|
||||
editingId.value = null
|
||||
dialogMode.value = 'detail'
|
||||
Object.assign(form, createMaterialForm())
|
||||
imagePreview.value = ''
|
||||
if (imageInput.value) imageInput.value.value = ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
if (!detailRow.value) return
|
||||
editingId.value = detailRow.value.id
|
||||
Object.assign(form, createMaterialForm(detailRow.value, categories.value))
|
||||
// 编辑时编码只读,标记成"已改过",避免切小类时被规则覆盖
|
||||
codeEdited.value = true
|
||||
imagePreview.value = ''
|
||||
if (imageInput.value) imageInput.value.value = ''
|
||||
dialogMode.value = 'edit'
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
if (!isEditing.value) return
|
||||
Object.assign(form, createMaterialForm())
|
||||
editingId.value = null
|
||||
imagePreview.value = ''
|
||||
if (imageInput.value) imageInput.value.value = ''
|
||||
dialogMode.value = 'detail'
|
||||
}
|
||||
|
||||
function handleDialogClose() {
|
||||
cancelEdit()
|
||||
if (isCreating.value) Object.assign(form, createMaterialForm())
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!isCreating.value && !isEditing.value) return
|
||||
if (!form.major_category_id || !form.category_id) {
|
||||
ElMessage.warning('请选择大类类型和小类类型')
|
||||
return
|
||||
|
|
@ -393,7 +421,9 @@ async function handleSave() {
|
|||
formData.material_image = (data.data && data.data.url) ? data.data.url : ''
|
||||
}
|
||||
if (editingId.value) {
|
||||
await materialsStore.updateMaterial(editingId.value, formData)
|
||||
const response = await materialsStore.updateMaterial(editingId.value, formData)
|
||||
detailRow.value = { ...detailRow.value, ...formData, ...response?.data }
|
||||
cancelEdit()
|
||||
ElMessage.success('物料更新成功')
|
||||
} else {
|
||||
await materialsStore.createMaterial(formData)
|
||||
|
|
@ -401,8 +431,8 @@ async function handleSave() {
|
|||
// store 里新建后的那次刷新会清空筛选(好让刚建的物料出现在列表里),搜索框得
|
||||
// 跟着一起清空,否则会出现搜索框写着关键字、列表却是全量的表里不一。
|
||||
searchKeyword.value = ''
|
||||
dialogVisible.value = false
|
||||
}
|
||||
dialogVisible.value = false
|
||||
} catch (e) {
|
||||
// 接口失败的原因已由拦截器弹过中文提示,这里只兜住它覆盖不到的异常,
|
||||
// 避免出现"点了保存没反应"
|
||||
|
|
|
|||
|
|
@ -245,3 +245,93 @@ describe('伙伴列表分页', () => {
|
|||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('伙伴业务分类多选', () => {
|
||||
beforeEach(() => {
|
||||
partnersApi.list.mockReset().mockResolvedValue({ data: { items: [], total: 0 } })
|
||||
partnersApi.create.mockReset().mockResolvedValue({ data: {} })
|
||||
partnersApi.update.mockReset().mockResolvedValue({ data: {} })
|
||||
})
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
test.each([['supplier', '供应商'], ['customer', '客户']])('%s 可同时选择生产与成品分类', async (role, title) => {
|
||||
const wrapper = mountPartner(role, title)
|
||||
await flushPromises()
|
||||
await openCreateDialog(wrapper, title)
|
||||
await inputByLabel(wrapper, '伙伴编码').setValue('PARTNER-001')
|
||||
await inputByLabel(wrapper, '企业名称').setValue('示例公司')
|
||||
const item = wrapper.findAll('.el-form-item').find(node => node.text().includes('业务分类'))
|
||||
expect(item, '表单提供业务分类多选').toBeDefined()
|
||||
for (const checkbox of item.findAll('input[type="checkbox"]')) await checkbox.setValue(true)
|
||||
await clickSave(wrapper)
|
||||
expect(partnersApi.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
[`${role}_categories`]: ['material', 'finished_product']
|
||||
}))
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
test('列表按当前角色显示分类,历史空分类显示未分类', async () => {
|
||||
partnersApi.list.mockResolvedValue({ data: { items: [
|
||||
{ id: 1, partner_code: 'SUP-001', legal_name: '双角色公司', supplier_categories: ['material'], customer_categories: ['finished_product'] },
|
||||
{ id: 2, partner_code: 'SUP-002', legal_name: '历史公司' }
|
||||
], total: 2 } })
|
||||
const wrapper = mountPartner()
|
||||
await flushPromises()
|
||||
const rows = wrapper.findAll('.el-table__body tbody tr')
|
||||
expect(rows[0].text()).toContain('物料级(生产)')
|
||||
expect(rows[0].text()).not.toContain('成品级(经营买卖)')
|
||||
expect(rows[1].text()).toContain('未分类')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
test('修改供应商分类保留客户分类,取消编辑不改变列表数据', async () => {
|
||||
const row = { id: 1, partner_code: 'SUP-001', legal_name: '双角色公司', is_supplier: true, is_customer: true,
|
||||
supplier_categories: ['material'], customer_categories: ['finished_product'] }
|
||||
partnersApi.list.mockResolvedValue({ data: { items: [row], total: 1 } })
|
||||
const wrapper = mountPartner()
|
||||
await flushPromises()
|
||||
await findButton(wrapper, '编辑').trigger('click')
|
||||
await flushPromises()
|
||||
const item = wrapper.findAll('.el-form-item').find(node => node.text().includes('业务分类'))
|
||||
expect(item, '表单提供业务分类多选').toBeDefined()
|
||||
const checkboxes = item.findAll('input[type="checkbox"]')
|
||||
await checkboxes[0].setValue(false)
|
||||
await checkboxes[1].setValue(true)
|
||||
expect(row.supplier_categories).toEqual(['material'])
|
||||
await findButton(wrapper, '取消').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.el-table__body tbody tr').text()).toContain('物料级(生产)')
|
||||
expect(wrapper.find('.el-table__body tbody tr').text()).not.toContain('成品级(经营买卖)')
|
||||
await findButton(wrapper, '编辑').trigger('click')
|
||||
await flushPromises()
|
||||
const reopened = wrapper.findAll('.el-form-item').find(node => node.text().includes('业务分类')).findAll('input[type="checkbox"]')
|
||||
expect(reopened[0].element.checked).toBe(true)
|
||||
expect(reopened[1].element.checked).toBe(false)
|
||||
await reopened[0].setValue(false)
|
||||
await reopened[1].setValue(true)
|
||||
await clickSave(wrapper)
|
||||
expect(partnersApi.update).toHaveBeenCalledWith(1, expect.objectContaining({ supplier_categories: ['finished_product'], customer_categories: ['finished_product'] }))
|
||||
await openCreateDialog(wrapper, '供应商')
|
||||
expect(wrapper.findAll('input[type="checkbox"]').every(input => !input.element.checked)).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
test('分类筛选从第一页请求,切换角色清空筛选', async () => {
|
||||
partnersApi.list.mockResolvedValue({ data: { items: [], total: 40 } })
|
||||
const wrapper = mountPartner()
|
||||
await flushPromises()
|
||||
await wrapper.find('.btn-next').trigger('click')
|
||||
await flushPromises()
|
||||
const filter = wrapper.findAllComponents(SelectStub).find(select => select.props('placeholder') === '全部业务分类')
|
||||
expect(filter, '列表提供业务分类筛选').toBeDefined()
|
||||
filter.vm.$emit('update:modelValue', 'material')
|
||||
filter.vm.$emit('change', 'material')
|
||||
await flushPromises()
|
||||
expect(partnersApi.list).toHaveBeenLastCalledWith('supplier', { limit: 20, offset: 0, category: 'material' })
|
||||
await wrapper.setProps({ role: 'customer' })
|
||||
await flushPromises()
|
||||
expect(partnersApi.list).toHaveBeenLastCalledWith('customer', { limit: 20, offset: 0 })
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
<div><h2>{{ title }}</h2><p>业务伙伴独立于本公司的组织数据,可按合作组织配置{{ title }}身份和状态。</p></div>
|
||||
<el-button type="primary" @click="openCreate">新增{{ title }}</el-button>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<el-select v-model="categoryFilter" placeholder="全部业务分类" clearable @change="load(1)">
|
||||
<el-option v-for="(label, value) in CATEGORY_LABELS" :key="value" :label="label" :value="value" />
|
||||
<el-option label="未分类" value="unclassified" />
|
||||
</el-select>
|
||||
</div>
|
||||
<BaseTable
|
||||
:data="rows"
|
||||
:loading="loading"
|
||||
|
|
@ -15,6 +21,14 @@
|
|||
>
|
||||
<el-table-column prop="partner_code" label="伙伴编码" width="140" />
|
||||
<el-table-column prop="legal_name" label="企业名称" min-width="180" />
|
||||
<el-table-column label="业务分类" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row[categoriesField]?.length">
|
||||
<el-tag v-for="category in row[categoriesField]" :key="category" class="category-tag">{{ CATEGORY_LABELS[category] || category }}</el-tag>
|
||||
</template>
|
||||
<span v-else class="unclassified">未分类</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unified_credit_code" label="统一社会信用代码" min-width="180" />
|
||||
<el-table-column prop="contact_name" label="联系人" width="110" />
|
||||
<el-table-column prop="contact_phone" label="联系电话" width="140" />
|
||||
|
|
@ -26,6 +40,11 @@
|
|||
<el-form :model="form" label-width="130px">
|
||||
<el-form-item label="伙伴编码" required><el-input v-model="form.partner_code" :disabled="Boolean(editingId)" /></el-form-item>
|
||||
<el-form-item label="企业名称" required><el-input v-model="form.legal_name" /></el-form-item>
|
||||
<el-form-item label="业务分类">
|
||||
<el-checkbox-group v-model="form[categoriesField]">
|
||||
<el-checkbox v-for="(label, value) in CATEGORY_LABELS" :key="value" :value="value">{{ label }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="统一社会信用代码"><el-input v-model="form.unified_credit_code" /></el-form-item>
|
||||
<el-form-item label="联系人"><el-input v-model="form.contact_name" /></el-form-item>
|
||||
<el-form-item label="联系电话"><el-input v-model="form.contact_phone" /></el-form-item>
|
||||
|
|
@ -57,18 +76,23 @@ const editingId = ref(null)
|
|||
// 每页条数跟物料等列表页保持一致,避免同一个系统里两种翻页手感
|
||||
const pagination = reactive({ currentPage: 1, pageSize: 20, total: 0 })
|
||||
const statusField = computed(() => `${props.role}_status`)
|
||||
const categoriesField = computed(() => `${props.role}_categories`)
|
||||
const CATEGORY_LABELS = { material: '物料级(生产)', finished_product: '成品级(经营买卖)' }
|
||||
const categoryFilter = ref('')
|
||||
// 合作状态英文枚举到中文的对照。列表列与编辑弹窗共用这一份,避免两处各写一遍后失步。
|
||||
const STATUS_LABELS = { active: '启用', suspended: '暂停', eliminated: '淘汰', pending: '待审核', inactive: '停用' }
|
||||
// 未收录的状态原样显示,不伪装成某个已知状态。
|
||||
function statusFormatter(row, column, cellValue) { return STATUS_LABELS[cellValue] || cellValue }
|
||||
const form = reactive(emptyForm())
|
||||
|
||||
function emptyForm() { return { partner_code: '', legal_name: '', unified_credit_code: '', contact_name: '', contact_phone: '', address: '', status: 'active', is_supplier: false, is_customer: false } }
|
||||
function emptyForm() { return { partner_code: '', legal_name: '', unified_credit_code: '', contact_name: '', contact_phone: '', address: '', status: 'active', is_supplier: false, is_customer: false, supplier_categories: [], customer_categories: [] } }
|
||||
async function load(currentPage = pagination.currentPage) {
|
||||
loading.value = true
|
||||
try {
|
||||
const offset = (currentPage - 1) * pagination.pageSize
|
||||
const response = await partnersApi.list(props.role, { limit: pagination.pageSize, offset })
|
||||
const params = { limit: pagination.pageSize, offset }
|
||||
if (categoryFilter.value) params.category = categoryFilter.value
|
||||
const response = await partnersApi.list(props.role, params)
|
||||
rows.value = response.data?.items || []
|
||||
pagination.total = response.data?.total || 0
|
||||
pagination.currentPage = currentPage
|
||||
|
|
@ -77,11 +101,16 @@ async function load(currentPage = pagination.currentPage) {
|
|||
}
|
||||
}
|
||||
// 换角色等于换了一份数据集,页码必须归零,否则会停在上一个角色的第 N 页上
|
||||
watch(() => props.role, () => load(1))
|
||||
watch(() => props.role, () => { categoryFilter.value = ''; load(1) })
|
||||
// 用 replaceAllKeys 而不是 Object.assign:后者删不掉上次编辑残留的 id,
|
||||
// 新增时会把别人的 id 一起提交上去。
|
||||
function openCreate() { editingId.value = null; replaceAllKeys(form, emptyForm()); dialogVisible.value = true }
|
||||
function openEdit(row) { editingId.value = row.id; Object.assign(form, row, { status: row[statusField.value] || 'active' }); dialogVisible.value = true }
|
||||
function openEdit(row) {
|
||||
editingId.value = row.id
|
||||
replaceAllKeys(form, { ...emptyForm(), ...row, status: row[statusField.value] || 'active',
|
||||
supplier_categories: [...(row.supplier_categories || [])], customer_categories: [...(row.customer_categories || [])] })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
function payload() {
|
||||
return {
|
||||
...form,
|
||||
|
|
@ -104,4 +133,8 @@ onMounted(() => load(1))
|
|||
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
|
||||
.page-header h2 { margin: 0 0 6px; }
|
||||
.page-header p { margin: 0; color: #909399; }
|
||||
.filters { margin-bottom: 16px; }
|
||||
.filters .el-select { width: 240px; }
|
||||
.category-tag { margin-right: 6px; }
|
||||
.unclassified { color: #909399; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -300,7 +300,7 @@ func (h *Handler) ListPartners(c *gin.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
items, total, err := h.partnerSvc.List(orgID, c.Query("role"), limit, offset)
|
||||
items, total, err := h.partnerSvc.List(orgID, c.Query("role"), limit, offset, c.Query("category"))
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
|
|
|
|||
85
internal/handler/partner_categories_test.go
Normal file
85
internal/handler/partner_categories_test.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/wmms/wmms/pkg/database"
|
||||
)
|
||||
|
||||
func TestPartnerCategoryFilterUsesCurrentRoleAndOrganization(t *testing.T) {
|
||||
for _, tc := range []struct{ role, category, condition string }{
|
||||
{"supplier", "material", " AND FIND_IN_SET(?, op.supplier_categories) > 0"},
|
||||
{"customer", "finished_product", " AND FIND_IN_SET(?, op.customer_categories) > 0"},
|
||||
{"supplier", "unclassified", " AND op.supplier_categories = ''"},
|
||||
} {
|
||||
t.Run(tc.role+"/"+tc.category, func(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
database.DB = db
|
||||
t.Cleanup(func() { database.DB = nil; db.Close() })
|
||||
filter := `FROM organization_partners op JOIN business_partners bp ON bp.id = op.business_partner_id WHERE op.organization_id = ? AND op.is_` + tc.role + ` = TRUE` + tc.condition
|
||||
count := mock.ExpectQuery(regexp.QuoteMeta("SELECT COUNT(*) " + filter))
|
||||
list := "SELECT op.id.*" + regexp.QuoteMeta(filter) + ".*LIMIT \\? OFFSET \\?"
|
||||
if tc.category == "unclassified" {
|
||||
count.WithArgs(int64(2))
|
||||
} else {
|
||||
count.WithArgs(int64(2), tc.category)
|
||||
}
|
||||
count.WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(0))
|
||||
query := mock.ExpectQuery(list)
|
||||
if tc.category == "unclassified" {
|
||||
query.WithArgs(int64(2), 20, 20)
|
||||
} else {
|
||||
query.WithArgs(int64(2), tc.category, 20, 20)
|
||||
}
|
||||
query.WillReturnRows(sqlmock.NewRows([]string{"id"}))
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
h := NewHandler()
|
||||
router.GET("/partners", h.ListPartners)
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/partners?role="+tc.role+"&category="+tc.category+"&offset=20", nil)
|
||||
request.Header.Set("X-Organization-ID", "2")
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartnerCategoryFilterRejectsInvalidRoleOrCategory(t *testing.T) {
|
||||
for _, query := range []string{"role=supplier&category=invalid", "category=material", "role=invalid&category=material"} {
|
||||
t.Run(query, func(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
database.DB = db
|
||||
t.Cleanup(func() { database.DB = nil; db.Close() })
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
h := NewHandler()
|
||||
router.GET("/partners", h.ListPartners)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/partners?"+query, nil))
|
||||
if response.Code != http.StatusBadRequest || strings.Contains(response.Body.String(), "SELECT") {
|
||||
t.Fatalf("must reject invalid filter before querying: %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +1,21 @@
|
|||
package model
|
||||
|
||||
type BusinessPartner struct {
|
||||
ID int64 `json:"id"`
|
||||
OrganizationID int64 `json:"organization_id"`
|
||||
BusinessPartnerID int64 `json:"business_partner_id"`
|
||||
PartnerCode string `json:"partner_code"`
|
||||
LegalName string `json:"legal_name"`
|
||||
UnifiedCreditCode string `json:"unified_credit_code"`
|
||||
ContactName string `json:"contact_name"`
|
||||
ContactPhone string `json:"contact_phone"`
|
||||
Address string `json:"address"`
|
||||
IsSupplier bool `json:"is_supplier"`
|
||||
IsCustomer bool `json:"is_customer"`
|
||||
SupplierStatus string `json:"supplier_status"`
|
||||
CustomerStatus string `json:"customer_status"`
|
||||
SettlementCurrency string `json:"settlement_currency"`
|
||||
PaymentTerms string `json:"payment_terms"`
|
||||
ID int64 `json:"id"`
|
||||
OrganizationID int64 `json:"organization_id"`
|
||||
BusinessPartnerID int64 `json:"business_partner_id"`
|
||||
PartnerCode string `json:"partner_code"`
|
||||
LegalName string `json:"legal_name"`
|
||||
UnifiedCreditCode string `json:"unified_credit_code"`
|
||||
ContactName string `json:"contact_name"`
|
||||
ContactPhone string `json:"contact_phone"`
|
||||
Address string `json:"address"`
|
||||
IsSupplier bool `json:"is_supplier"`
|
||||
IsCustomer bool `json:"is_customer"`
|
||||
SupplierStatus string `json:"supplier_status"`
|
||||
CustomerStatus string `json:"customer_status"`
|
||||
SupplierCategories []string `json:"supplier_categories"`
|
||||
CustomerCategories []string `json:"customer_categories"`
|
||||
SettlementCurrency string `json:"settlement_currency"`
|
||||
PaymentTerms string `json:"payment_terms"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/wmms/wmms/internal/model"
|
||||
"github.com/wmms/wmms/pkg/database"
|
||||
|
|
@ -13,7 +14,7 @@ type PartnerRepository struct{}
|
|||
|
||||
func NewPartnerRepository() *PartnerRepository { return &PartnerRepository{} }
|
||||
|
||||
func (r *PartnerRepository) List(organizationID int64, role string, limit, offset int) ([]model.BusinessPartner, int64, error) {
|
||||
func (r *PartnerRepository) List(organizationID int64, role string, limit, offset int, category string) ([]model.BusinessPartner, int64, error) {
|
||||
// 角色过滤写成一份,由计数和取数共用:两处各写一遍,改一处漏一处就会让
|
||||
// 总数和列表对不上(总数算的是全部,列表却只有供应商)。
|
||||
filter := `FROM organization_partners op
|
||||
|
|
@ -26,6 +27,23 @@ func (r *PartnerRepository) List(organizationID int64, role string, limit, offse
|
|||
if role == "customer" {
|
||||
filter += ` AND op.is_customer = TRUE`
|
||||
}
|
||||
if category != "" {
|
||||
// 字段名仅由已知角色选择,筛选值始终使用参数绑定。
|
||||
field := "op.supplier_categories"
|
||||
if role == "customer" {
|
||||
field = "op.customer_categories"
|
||||
} else if role != "supplier" {
|
||||
return nil, 0, errors.New("业务分类筛选需要指定客户或供应商")
|
||||
}
|
||||
if category == "unclassified" {
|
||||
filter += ` AND ` + field + ` = ''`
|
||||
} else if category == "material" || category == "finished_product" {
|
||||
filter += ` AND FIND_IN_SET(?, ` + field + `) > 0`
|
||||
args = append(args, category)
|
||||
} else {
|
||||
return nil, 0, errors.New("invalid partner category")
|
||||
}
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := database.DB.QueryRow(`SELECT COUNT(*) `+filter, args...).Scan(&total); err != nil {
|
||||
|
|
@ -37,7 +55,7 @@ func (r *PartnerRepository) List(organizationID int64, role string, limit, offse
|
|||
COALESCE(bp.contact_phone, ''), COALESCE(bp.address, ''),
|
||||
op.is_supplier, op.is_customer, COALESCE(op.supplier_status, ''),
|
||||
COALESCE(op.customer_status, ''), COALESCE(op.settlement_currency, ''),
|
||||
COALESCE(op.payment_terms, '')
|
||||
COALESCE(op.payment_terms, ''), op.supplier_categories, op.customer_categories
|
||||
` + filter + `
|
||||
ORDER BY op.partner_code
|
||||
LIMIT ? OFFSET ?`
|
||||
|
|
@ -51,12 +69,15 @@ func (r *PartnerRepository) List(organizationID int64, role string, limit, offse
|
|||
items := make([]model.BusinessPartner, 0)
|
||||
for rows.Next() {
|
||||
var item model.BusinessPartner
|
||||
var supplierCategories, customerCategories string
|
||||
if err := rows.Scan(&item.ID, &item.OrganizationID, &item.BusinessPartnerID, &item.PartnerCode,
|
||||
&item.LegalName, &item.UnifiedCreditCode, &item.ContactName, &item.ContactPhone,
|
||||
&item.Address, &item.IsSupplier, &item.IsCustomer, &item.SupplierStatus,
|
||||
&item.CustomerStatus, &item.SettlementCurrency, &item.PaymentTerms); err != nil {
|
||||
&item.CustomerStatus, &item.SettlementCurrency, &item.PaymentTerms, &supplierCategories, &customerCategories); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
item.SupplierCategories = splitPartnerCategories(supplierCategories)
|
||||
item.CustomerCategories = splitPartnerCategories(customerCategories)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, total, rows.Err()
|
||||
|
|
@ -87,10 +108,11 @@ func (r *PartnerRepository) Create(item *model.BusinessPartner) (err error) {
|
|||
|
||||
result, err = tx.Exec(`INSERT INTO organization_partners
|
||||
(organization_id, business_partner_id, partner_code, is_supplier, is_customer,
|
||||
supplier_status, customer_status, settlement_currency, payment_terms)
|
||||
VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''))`,
|
||||
supplier_status, customer_status, settlement_currency, payment_terms, supplier_categories, customer_categories)
|
||||
VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), ?, ?)`,
|
||||
item.OrganizationID, item.BusinessPartnerID, item.PartnerCode, item.IsSupplier, item.IsCustomer,
|
||||
item.SupplierStatus, item.CustomerStatus, item.SettlementCurrency, item.PaymentTerms)
|
||||
item.SupplierStatus, item.CustomerStatus, item.SettlementCurrency, item.PaymentTerms,
|
||||
strings.Join(item.SupplierCategories, ","), strings.Join(item.CustomerCategories, ","))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -128,9 +150,12 @@ func (r *PartnerRepository) Update(item *model.BusinessPartner) (err error) {
|
|||
}
|
||||
if _, err = tx.Exec(`UPDATE organization_partners SET is_supplier = ?, is_customer = ?,
|
||||
supplier_status = NULLIF(?, ''), customer_status = NULLIF(?, ''),
|
||||
settlement_currency = NULLIF(?, ''), payment_terms = NULLIF(?, '') WHERE id = ? AND organization_id = ?`,
|
||||
settlement_currency = NULLIF(?, ''), payment_terms = NULLIF(?, ''),
|
||||
supplier_categories = COALESCE(?, supplier_categories), customer_categories = COALESCE(?, customer_categories)
|
||||
WHERE id = ? AND organization_id = ?`,
|
||||
item.IsSupplier, item.IsCustomer, item.SupplierStatus, item.CustomerStatus,
|
||||
item.SettlementCurrency, item.PaymentTerms, item.ID, item.OrganizationID); err != nil {
|
||||
item.SettlementCurrency, item.PaymentTerms, optionalPartnerCategories(item.SupplierCategories),
|
||||
optionalPartnerCategories(item.CustomerCategories), item.ID, item.OrganizationID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
|
|
@ -138,3 +163,17 @@ func (r *PartnerRepository) Update(item *model.BusinessPartner) (err error) {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitPartnerCategories(value string) []string {
|
||||
if value == "" {
|
||||
return []string{}
|
||||
}
|
||||
return strings.Split(value, ",")
|
||||
}
|
||||
|
||||
func optionalPartnerCategories(categories []string) interface{} {
|
||||
if categories == nil {
|
||||
return nil
|
||||
}
|
||||
return strings.Join(categories, ",")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
|
|
@ -24,6 +26,9 @@ func TestPartnerCreateSeparatesLegalEntityAndOrganizationRelationship(t *testing
|
|||
IsSupplier: true,
|
||||
SupplierStatus: "active",
|
||||
}
|
||||
if err := json.Unmarshal([]byte(`{"supplier_categories":["material","finished_product"],"customer_categories":[]}`), partner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO business_partners
|
||||
|
|
@ -33,9 +38,9 @@ func TestPartnerCreateSeparatesLegalEntityAndOrganizationRelationship(t *testing
|
|||
WillReturnResult(sqlmock.NewResult(9, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO organization_partners
|
||||
(organization_id, business_partner_id, partner_code, is_supplier, is_customer,
|
||||
supplier_status, customer_status, settlement_currency, payment_terms)
|
||||
VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''))`)).
|
||||
WithArgs(int64(1), int64(9), "SUP-001", true, false, "active", "", "", "").
|
||||
supplier_status, customer_status, settlement_currency, payment_terms, supplier_categories, customer_categories)
|
||||
VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), ?, ?)`)).
|
||||
WithArgs(int64(1), int64(9), "SUP-001", true, false, "active", "", "", "", "material,finished_product", "").
|
||||
WillReturnResult(sqlmock.NewResult(15, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
|
|
@ -49,3 +54,70 @@ func TestPartnerCreateSeparatesLegalEntityAndOrganizationRelationship(t *testing
|
|||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartnerListReturnsIndependentCategoriesAndEmptyArrays(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
database.DB = db
|
||||
t.Cleanup(func() { database.DB = nil; db.Close() })
|
||||
mock.ExpectQuery("SELECT COUNT").WithArgs(int64(1)).WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(2))
|
||||
mock.ExpectQuery("SELECT op.id").WithArgs(int64(1), 20, 0).WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "organization_id", "business_partner_id", "partner_code", "legal_name", "unified_credit_code", "contact_name", "contact_phone", "address",
|
||||
"is_supplier", "is_customer", "supplier_status", "customer_status", "settlement_currency", "payment_terms", "supplier_categories", "customer_categories",
|
||||
}).AddRow(1, 1, 10, "P-001", "公司", "", "", "", "", true, true, "active", "active", "", "", "material,finished_product", "finished_product").
|
||||
AddRow(2, 1, 11, "P-002", "历史公司", "", "", "", "", true, false, "active", "", "", "", "", ""))
|
||||
items, total, err := NewPartnerRepository().List(1, "supplier", 20, 0, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 2 || len(items) != 2 {
|
||||
t.Fatalf("unexpected list: %v / %v", items, total)
|
||||
}
|
||||
if !reflect.DeepEqual(items[0].SupplierCategories, []string{"material", "finished_product"}) || !reflect.DeepEqual(items[0].CustomerCategories, []string{"finished_product"}) {
|
||||
t.Fatalf("categories mixed or lost: %+v", items[0])
|
||||
}
|
||||
if items[1].SupplierCategories == nil || items[1].CustomerCategories == nil {
|
||||
t.Fatal("unclassified categories must be empty arrays")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartnerUpdatePreservesOmittedCategoriesAndAllowsClearing(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, body string
|
||||
supplier, customer interface{}
|
||||
}{
|
||||
{"omitted", `{}`, nil, nil},
|
||||
{"clear supplier", `{"supplier_categories":[]}`, "", nil},
|
||||
{"change customer", `{"customer_categories":["material","finished_product"]}`, nil, "material,finished_product"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
database.DB = db
|
||||
t.Cleanup(func() { database.DB = nil; db.Close() })
|
||||
item := model.BusinessPartner{ID: 12, OrganizationID: 2, LegalName: "公司", IsSupplier: true, IsCustomer: true}
|
||||
if err := json.Unmarshal([]byte(tc.body), &item); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT business_partner_id").WithArgs(int64(12), int64(2)).WillReturnRows(sqlmock.NewRows([]string{"business_partner_id"}).AddRow(7))
|
||||
mock.ExpectExec("UPDATE business_partners").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("UPDATE organization_partners SET.*supplier_categories = COALESCE.*customer_categories = COALESCE").
|
||||
WithArgs(true, true, "", "", "", "", tc.supplier, tc.customer, int64(12), int64(2)).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
if err := NewPartnerRepository().Update(&item); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -211,11 +211,11 @@ type PartnerService struct{ repo *repository.PartnerRepository }
|
|||
func NewPartnerService() *PartnerService {
|
||||
return &PartnerService{repo: repository.NewPartnerRepository()}
|
||||
}
|
||||
func (s *PartnerService) List(orgID int64, role string, limit, offset int) ([]model.BusinessPartner, int64, error) {
|
||||
func (s *PartnerService) List(orgID int64, role string, limit, offset int, category string) ([]model.BusinessPartner, int64, error) {
|
||||
if role != "" && role != "supplier" && role != "customer" {
|
||||
return nil, 0, errors.New("role must be supplier or customer")
|
||||
}
|
||||
return s.repo.List(orgID, role, limit, offset)
|
||||
return s.repo.List(orgID, role, limit, offset, category)
|
||||
}
|
||||
|
||||
// Create 新建伙伴。请求体可能带着前端表单残留的 id,这里不读取 item.ID,
|
||||
|
|
@ -255,6 +255,23 @@ func normalizePartner(item *model.BusinessPartner) error {
|
|||
if !item.IsSupplier && !item.IsCustomer {
|
||||
return errors.New("at least one partner role is required")
|
||||
}
|
||||
for _, categories := range []*[]string{&item.SupplierCategories, &item.CustomerCategories} {
|
||||
if *categories == nil {
|
||||
continue // 更新时未传分类保留原值;显式空数组表示清空。
|
||||
}
|
||||
normalized := make([]string, 0, 2)
|
||||
seen := make(map[string]bool)
|
||||
for _, category := range *categories {
|
||||
if category != "material" && category != "finished_product" {
|
||||
return errors.New("业务分类只能选择物料级(生产)或成品级(经营买卖)")
|
||||
}
|
||||
if !seen[category] {
|
||||
normalized = append(normalized, category)
|
||||
seen[category] = true
|
||||
}
|
||||
}
|
||||
*categories = normalized
|
||||
}
|
||||
if item.IsSupplier && item.SupplierStatus == "" {
|
||||
item.SupplierStatus = "active"
|
||||
}
|
||||
|
|
|
|||
47
internal/service/partner_categories_test.go
Normal file
47
internal/service/partner_categories_test.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/wmms/wmms/internal/model"
|
||||
)
|
||||
|
||||
func TestPartnerCategoriesNormalizeMultiSelection(t *testing.T) {
|
||||
var item model.BusinessPartner
|
||||
if err := json.Unmarshal([]byte(`{"is_supplier":true,"is_customer":true,"supplier_categories":["material","finished_product","material"],"customer_categories":["finished_product"]}`), &item); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := normalizePartner(&item); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(result["supplier_categories"], []interface{}{"material", "finished_product"}) {
|
||||
t.Fatalf("supplier categories lost or duplicated: %s", data)
|
||||
}
|
||||
if !reflect.DeepEqual(result["customer_categories"], []interface{}{"finished_product"}) {
|
||||
t.Fatalf("customer categories must remain independent: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartnerCategoriesRejectUnknownValues(t *testing.T) {
|
||||
for _, field := range []string{"supplier_categories", "customer_categories"} {
|
||||
t.Run(field, func(t *testing.T) {
|
||||
var item model.BusinessPartner
|
||||
if err := json.Unmarshal([]byte(`{"is_supplier":true,"is_customer":true,"`+field+`":["invalid"]}`), &item); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := normalizePartner(&item); err == nil {
|
||||
t.Fatal("unknown business category must be rejected")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
-- Apply once to databases created before inbound/outbound display fields existed.
|
||||
-- Existing documents receive a stable number derived from their primary key;
|
||||
-- their operator and remark remain NULL because that historical data was never recorded.
|
||||
|
||||
ALTER TABLE inbound_orders
|
||||
ADD COLUMN inbound_no VARCHAR(32) NULL UNIQUE AFTER location_id,
|
||||
ADD COLUMN operator VARCHAR(100) NULL AFTER inbound_no,
|
||||
ADD COLUMN remark TEXT NULL AFTER supplier;
|
||||
|
||||
UPDATE inbound_orders
|
||||
SET inbound_no = CONCAT('IN-', LPAD(id, 8, '0'))
|
||||
WHERE inbound_no IS NULL;
|
||||
|
||||
ALTER TABLE outbound_orders
|
||||
ADD COLUMN outbound_no VARCHAR(32) NULL UNIQUE AFTER location_id,
|
||||
ADD COLUMN operator VARCHAR(100) NULL AFTER outbound_no,
|
||||
ADD COLUMN remark TEXT NULL AFTER receiver;
|
||||
|
||||
UPDATE outbound_orders
|
||||
SET outbound_no = CONCAT('OUT-', LPAD(id, 8, '0'))
|
||||
WHERE outbound_no IS NULL;
|
||||
127
scripts/init.sql
127
scripts/init.sql
|
|
@ -21,7 +21,9 @@ CREATE TABLE code_rules (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id B
|
|||
CREATE TABLE material_unit_conversions (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, material_id BIGINT NOT NULL, from_unit_id BIGINT NOT NULL, to_unit_id BIGINT NOT NULL, conversion_rate DECIMAL(20,6) NOT NULL, effective_from DATE NOT NULL, effective_to DATE NULL, KEY idx_material_unit_conversions_material(organization_id,material_id), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(material_id) REFERENCES materials(id), FOREIGN KEY(from_unit_id) REFERENCES units(id), FOREIGN KEY(to_unit_id) REFERENCES units(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE tags (id BIGINT AUTO_INCREMENT PRIMARY KEY, material_id BIGINT NOT NULL, tag_code VARCHAR(100) NOT NULL UNIQUE, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_tags_material(material_id), FOREIGN KEY(material_id) REFERENCES materials(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE business_partners (id BIGINT AUTO_INCREMENT PRIMARY KEY, legal_name VARCHAR(200) NOT NULL, unified_credit_code VARCHAR(50) UNIQUE, contact_name VARCHAR(100), contact_phone VARCHAR(50), address VARCHAR(500), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE organization_partners (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, business_partner_id BIGINT NOT NULL, partner_code VARCHAR(50) NOT NULL, is_supplier BOOLEAN NOT NULL DEFAULT FALSE, is_customer BOOLEAN NOT NULL DEFAULT FALSE, supplier_status VARCHAR(20), customer_status VARCHAR(20), settlement_currency CHAR(3), payment_terms VARCHAR(200), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_organization_partners_org_partner(organization_id,business_partner_id), UNIQUE KEY uk_organization_partners_org_code(organization_id,partner_code), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(business_partner_id) REFERENCES business_partners(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
-- Partner categories support selecting material and finished_product together;
|
||||
-- an empty set means unclassified. Both fields are created with the table.
|
||||
CREATE TABLE organization_partners (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, business_partner_id BIGINT NOT NULL, partner_code VARCHAR(50) NOT NULL, is_supplier BOOLEAN NOT NULL DEFAULT FALSE, is_customer BOOLEAN NOT NULL DEFAULT FALSE, supplier_status VARCHAR(20), supplier_categories SET('material','finished_product') NOT NULL DEFAULT '', customer_status VARCHAR(20), customer_categories SET('material','finished_product') NOT NULL DEFAULT '', settlement_currency CHAR(3), payment_terms VARCHAR(200), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_organization_partners_org_partner(organization_id,business_partner_id), UNIQUE KEY uk_organization_partners_org_code(organization_id,partner_code), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(business_partner_id) REFERENCES business_partners(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE material_suppliers (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, material_id BIGINT NOT NULL, organization_partner_id BIGINT NOT NULL, price DECIMAL(20,6) NOT NULL, currency CHAR(3) NOT NULL, minimum_order_quantity DECIMAL(20,6) NOT NULL DEFAULT 0, lead_time_days INT NOT NULL DEFAULT 0, is_preferred BOOLEAN NOT NULL DEFAULT FALSE, effective_from DATE NOT NULL, effective_to DATE, status VARCHAR(20) NOT NULL DEFAULT 'active', KEY idx_material_suppliers_material(organization_id,material_id), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(material_id) REFERENCES materials(id), FOREIGN KEY(organization_partner_id) REFERENCES organization_partners(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE warehouses (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, code VARCHAR(50) NOT NULL, name VARCHAR(150) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_warehouses_org_code(organization_id,code), FOREIGN KEY(organization_id) REFERENCES organizations(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE warehouse_locations (id BIGINT AUTO_INCREMENT PRIMARY KEY, warehouse_id BIGINT NOT NULL, code VARCHAR(50) NOT NULL, name VARCHAR(150) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_warehouse_locations_warehouse_code(warehouse_id,code), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
|
@ -96,3 +98,126 @@ INSERT INTO warehouse_locations (id,warehouse_id,code,name,status) VALUES (1,1,'
|
|||
-- previous latin1/BINARY round-trip repair is gone: it only produced correct
|
||||
-- text when the client was latin1, and it corrupts (or destroys non-latin1
|
||||
-- characters such as "→") when the client is already utf8mb4.
|
||||
|
||||
-- ============================================================================
|
||||
-- 示例数据:客户、供应商。以下企业、联系人、电话及地址均为虚构,仅用于演示。
|
||||
-- 共 20 家企业:8 家仅供应商、8 家仅客户、4 家同时具有两种身份。
|
||||
-- 默认归属组织 ID 为 1;信用代码留空,伙伴编码以 DEMO- 开头。
|
||||
-- 本段示例数据会跳过已有记录;init.sql 整体仍仅用于空库首次初始化。
|
||||
-- ============================================================================
|
||||
SET @demo_organization_id = 1;
|
||||
|
||||
DROP TEMPORARY TABLE IF EXISTS tmp_demo_partners_20261008;
|
||||
CREATE TEMPORARY TABLE tmp_demo_partners_20261008 (
|
||||
partner_code VARCHAR(50) NOT NULL PRIMARY KEY,
|
||||
legal_name VARCHAR(200) NOT NULL,
|
||||
contact_name VARCHAR(100) NOT NULL,
|
||||
contact_phone VARCHAR(50) NOT NULL,
|
||||
address VARCHAR(500) NOT NULL,
|
||||
is_supplier BOOLEAN NOT NULL,
|
||||
is_customer BOOLEAN NOT NULL,
|
||||
supplier_status VARCHAR(20) NULL,
|
||||
customer_status VARCHAR(20) NULL,
|
||||
supplier_categories SET('material', 'finished_product') NOT NULL DEFAULT '',
|
||||
customer_categories SET('material', 'finished_product') NOT NULL DEFAULT '',
|
||||
payment_terms VARCHAR(200) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
INSERT INTO tmp_demo_partners_20261008
|
||||
(partner_code, legal_name, contact_name, contact_phone, address,
|
||||
is_supplier, is_customer, supplier_status, customer_status,
|
||||
supplier_categories, customer_categories, payment_terms)
|
||||
VALUES
|
||||
('DEMO-SUP-001', '示例·北京澄岳电子材料有限公司', '周明远', '010-0000-1001', '北京市海淀区示例路 18 号',
|
||||
TRUE, FALSE, 'active', NULL, 'material', '', '验收后 30 天付款'),
|
||||
('DEMO-SUP-002', '示例·武汉青岚精密部件有限公司', '林晓晴', '027-0000-1002', '武汉市洪山区示例大道 36 号',
|
||||
TRUE, FALSE, 'active', NULL, 'material', '', '验收后 15 天付款'),
|
||||
('DEMO-SUP-003', '示例·苏州星禾仪器贸易有限公司', '郑柏川', '0512-0000-1003', '苏州市工业园区示例街 52 号',
|
||||
TRUE, FALSE, 'active', NULL, 'finished_product', '', '款到发货'),
|
||||
('DEMO-SUP-004', '示例·深圳沐辰机电有限公司', '许知夏', '0755-0000-1004', '深圳市南山区示例路 9 号',
|
||||
TRUE, FALSE, 'active', NULL, 'material,finished_product', '', '月结 30 天'),
|
||||
('DEMO-SUP-005', '示例·成都锦栖包装材料有限公司', '唐思齐', '028-0000-1005', '成都市高新区示例街 27 号',
|
||||
TRUE, FALSE, 'suspended', NULL, 'material', '', '预付 30%,验收后付尾款'),
|
||||
('DEMO-SUP-006', '示例·南京云朔自动化设备有限公司', '宋景行', '025-0000-1006', '南京市江宁区示例大道 61 号',
|
||||
TRUE, FALSE, 'pending', NULL, 'finished_product', '', '验收后 45 天付款'),
|
||||
('DEMO-SUP-007', '示例·杭州珩远工业配套有限公司', '韩若溪', '0571-0000-1007', '杭州市滨江区示例路 43 号',
|
||||
TRUE, FALSE, 'active', NULL, 'material,finished_product', '', '月结 60 天'),
|
||||
('DEMO-SUP-008', '示例·天津启沅物资有限公司', '顾承宇', '022-0000-1008', '天津市西青区示例街 15 号',
|
||||
TRUE, FALSE, 'eliminated', NULL, '', '', '款到发货'),
|
||||
|
||||
('DEMO-CUS-001', '示例·北京云帆研发技术有限公司', '沈沐阳', '010-0000-2001', '北京市昌平区示例大道 29 号',
|
||||
FALSE, TRUE, NULL, 'active', '', 'material', '发货后 30 天收款'),
|
||||
('DEMO-CUS-002', '示例·武汉卓溪智能装备有限公司', '蒋书宁', '027-0000-2002', '武汉市江夏区示例路 48 号',
|
||||
FALSE, TRUE, NULL, 'active', '', 'finished_product', '预收 50%,交付后收尾款'),
|
||||
('DEMO-CUS-003', '示例·上海禾川工业贸易有限公司', '何清越', '021-0000-2003', '上海市闵行区示例街 11 号',
|
||||
FALSE, TRUE, NULL, 'active', '', 'material,finished_product', '月结 30 天'),
|
||||
('DEMO-CUS-004', '示例·西安辰澜实验科技有限公司', '邵文轩', '029-0000-2004', '西安市高新区示例路 67 号',
|
||||
FALSE, TRUE, NULL, 'active', '', 'material', '款到发货'),
|
||||
('DEMO-CUS-005', '示例·长沙栩航设备经营有限公司', '叶星遥', '0731-0000-2005', '长沙市岳麓区示例大道 23 号',
|
||||
FALSE, TRUE, NULL, 'suspended', '', 'finished_product', '验收后 30 天收款'),
|
||||
('DEMO-CUS-006', '示例·济南清屿制造有限公司', '杜泽安', '0531-0000-2006', '济南市历城区示例街 39 号',
|
||||
FALSE, TRUE, NULL, 'active', '', 'material,finished_product', '月结 45 天'),
|
||||
('DEMO-CUS-007', '示例·郑州景禾商贸有限公司', '梁初瑶', '0371-0000-2007', '郑州市中原区示例路 56 号',
|
||||
FALSE, TRUE, NULL, 'pending', '', 'finished_product', '预付全款'),
|
||||
('DEMO-CUS-008', '示例·青岛若川技术服务有限公司', '陆嘉树', '0532-0000-2008', '青岛市崂山区示例大道 14 号',
|
||||
FALSE, TRUE, NULL, 'inactive', '', '', '交付后 15 天收款'),
|
||||
|
||||
('DEMO-BOTH-001', '示例·北京知衡产业有限公司', '方予安', '010-0000-3001', '北京市大兴区示例路 72 号',
|
||||
TRUE, TRUE, 'active', 'active', 'material', 'finished_product', '采购月结 30 天;销售预收 30%'),
|
||||
('DEMO-BOTH-002', '示例·武汉观澜机电贸易有限公司', '程舒雅', '027-0000-3002', '武汉市东西湖区示例街 31 号',
|
||||
TRUE, TRUE, 'active', 'active', 'material,finished_product', 'material,finished_product', '采购、销售均月结 30 天'),
|
||||
('DEMO-BOTH-003', '示例·合肥映川智能科技有限公司', '贺景辰', '0551-0000-3003', '合肥市蜀山区示例大道 46 号',
|
||||
TRUE, TRUE, 'suspended', 'active', 'finished_product', 'material', '采购款到发货;销售月结 45 天'),
|
||||
('DEMO-BOTH-004', '示例·厦门沅禾工业服务有限公司', '白念慈', '0592-0000-3004', '厦门市集美区示例路 8 号',
|
||||
TRUE, TRUE, 'active', 'pending', 'material', '', '采购验收后 15 天付款;销售预付全款');
|
||||
|
||||
START TRANSACTION;
|
||||
|
||||
-- 仅在目标组织存在时插入企业,避免错误组织 ID 产生无关联数据。
|
||||
INSERT INTO business_partners
|
||||
(legal_name, unified_credit_code, contact_name, contact_phone, address)
|
||||
SELECT demo.legal_name, NULL, demo.contact_name, demo.contact_phone, demo.address
|
||||
FROM tmp_demo_partners_20261008 demo
|
||||
JOIN organizations org ON org.id = @demo_organization_id
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM business_partners existing WHERE existing.legal_name = demo.legal_name
|
||||
);
|
||||
|
||||
-- 不硬编码自增 ID。重复运行时保留原关系及已编辑的分类、状态等数据。
|
||||
INSERT INTO organization_partners
|
||||
(organization_id, business_partner_id, partner_code, is_supplier, is_customer,
|
||||
supplier_status, customer_status, supplier_categories, customer_categories,
|
||||
settlement_currency, payment_terms)
|
||||
SELECT org.id, partner.id, demo.partner_code, demo.is_supplier, demo.is_customer,
|
||||
demo.supplier_status, demo.customer_status, demo.supplier_categories, demo.customer_categories,
|
||||
'CNY', demo.payment_terms
|
||||
FROM tmp_demo_partners_20261008 demo
|
||||
JOIN organizations org ON org.id = @demo_organization_id
|
||||
JOIN (
|
||||
SELECT legal_name, MIN(id) AS id FROM business_partners GROUP BY legal_name
|
||||
) partner ON partner.legal_name = demo.legal_name
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM organization_partners existing
|
||||
WHERE existing.organization_id = org.id
|
||||
AND (existing.business_partner_id = partner.id OR existing.partner_code = demo.partner_code)
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- 检查导入结果;目标组织不存在时明确提示,并且不会插入任何数据。
|
||||
SELECT @demo_organization_id AS organization_id,
|
||||
CASE WHEN EXISTS (SELECT 1 FROM organizations WHERE id = @demo_organization_id)
|
||||
THEN '目标组织存在,请核对下面的导入结果'
|
||||
ELSE '目标组织不存在:未导入,请修改 @demo_organization_id 后重新执行'
|
||||
END AS import_message;
|
||||
|
||||
SELECT op.partner_code, bp.legal_name, op.is_supplier, op.is_customer,
|
||||
op.supplier_categories, op.customer_categories, op.supplier_status, op.customer_status
|
||||
FROM organization_partners op
|
||||
JOIN business_partners bp ON bp.id = op.business_partner_id
|
||||
JOIN tmp_demo_partners_20261008 demo
|
||||
ON demo.partner_code = op.partner_code AND demo.legal_name = bp.legal_name
|
||||
WHERE op.organization_id = @demo_organization_id
|
||||
ORDER BY op.partner_code;
|
||||
|
||||
DROP TEMPORARY TABLE tmp_demo_partners_20261008;
|
||||
|
|
|
|||
|
|
@ -1,151 +0,0 @@
|
|||
-- ============================================================================
|
||||
-- 物料分类重建脚本(material_categories + code_rules)
|
||||
--
|
||||
-- 用途:把 material_categories 全量替换为 6 个大类 + 28 个子类,新增
|
||||
-- description(说明)字段,并同步写入 code_rules 编码规则。
|
||||
--
|
||||
-- !! 破坏性操作,执行前请务必备份数据库 !!
|
||||
--
|
||||
-- materials.category_id 的外键是 ON DELETE RESTRICT,物料不先清理就无法
|
||||
-- 删除分类。因此本脚本会连带清空以下表:
|
||||
-- material_categories / materials / code_rules / inbound_orders /
|
||||
-- inbound_items / outbound_orders / outbound_items / inventory_balances /
|
||||
-- stock_logs / stock_warning_rules / material_logs / tags /
|
||||
-- material_suppliers / material_unit_conversions /
|
||||
-- material_applications / application_items / apply_orders / apply_items
|
||||
-- 物料、库存、出入库单据与流水将全部丢失,不可恢复。
|
||||
--
|
||||
-- 执行方式:
|
||||
-- mysql -uroot -p --default-character-set=utf8mb4 wmms < scripts/update_material_categories.sql
|
||||
--
|
||||
-- 字符集:统一 utf8mb4。脚本开头 SET NAMES utf8mb4 确保中文正确落库,
|
||||
-- 不依赖客户端默认字符集。
|
||||
-- 幂等性:可重复执行(建列有存在性判断,其余为清空后重建)。
|
||||
-- ============================================================================
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1. 新增说明字段 description
|
||||
-- ---------------------------------------------------------------------------
|
||||
SET @ddl := (SELECT IF(
|
||||
COUNT(*) = 0,
|
||||
'ALTER TABLE material_categories ADD COLUMN description VARCHAR(1000) NOT NULL DEFAULT '''' AFTER name',
|
||||
'SELECT 1'
|
||||
) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'material_categories'
|
||||
AND COLUMN_NAME = 'description');
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2. 清空存量数据
|
||||
-- 顺序:先清引用方,再清被引用方;分类表先删子再删父(自引用外键 RESTRICT)。
|
||||
-- ---------------------------------------------------------------------------
|
||||
DELETE FROM application_items;
|
||||
DELETE FROM apply_items;
|
||||
DELETE FROM inbound_items;
|
||||
DELETE FROM outbound_items;
|
||||
DELETE FROM material_suppliers;
|
||||
DELETE FROM material_unit_conversions;
|
||||
DELETE FROM stock_warning_rules;
|
||||
DELETE FROM inventory_balances;
|
||||
DELETE FROM stock_logs;
|
||||
DELETE FROM tags;
|
||||
DELETE FROM material_logs;
|
||||
|
||||
DELETE FROM materials;
|
||||
DELETE FROM inbound_orders;
|
||||
DELETE FROM outbound_orders;
|
||||
DELETE FROM material_applications;
|
||||
DELETE FROM apply_orders;
|
||||
|
||||
DELETE FROM code_rules;
|
||||
DELETE FROM material_categories WHERE parent_id IS NOT NULL;
|
||||
DELETE FROM material_categories WHERE parent_id IS NULL;
|
||||
|
||||
ALTER TABLE materials AUTO_INCREMENT = 1;
|
||||
ALTER TABLE material_categories AUTO_INCREMENT = 1;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3. 写入 6 个大类
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO material_categories (organization_id, code, name, description, parent_id, status) VALUES
|
||||
(1, 'A', '机械结构类', '遵循通用分级规则:零加工外购标准件 → 零加工外购成品件 → 外购原料简易加工 → 深度定制加工件 → 本类兜底。纯机械传动件纳入本类管理。', NULL, 'active'),
|
||||
(1, 'B', '电气元器件类', '遵循通用分级规则:零加工外购标准成品 → 外购原料简易加工 → 单项工艺定制加工 → 多工艺集成组装件 → 本类兜底。带电气接口、电控功能的电机、电动部件统一归入本类;外购卷材/长条电气件,现场裁剪使用的纳入简易加工子类。', NULL, 'active'),
|
||||
(1, 'C', '产品工艺耗材类', '按使用场景划分,仅收录装配在设备内部、随产品出厂的专用耗材,全部为外购成品;车间通用清洁、劳保、运维耗材不纳入本类。', NULL, 'active'),
|
||||
(1, 'D', '软件&固件&授权类', '收录设备内置运行、随产品交付的程序、授权介质、使用许可,属于无形资产及配套实体载体,无物理加工环节。', NULL, 'active'),
|
||||
(1, 'E', '半成品&成品类', '按产品装配阶段划分,为多类零部件组合而成的装配体,是BOM顶层节点;仅区分半成品、成品两类。', NULL, 'active'),
|
||||
(1, 'F', '包材类', '本类统一收纳不参与设备核心功能运行、不属于机身核心的物料,仅作为产品配套、合规、交付附属物件。包含附着机身的标识、随机资料、整套包装物料、交付工具等配套物资,全部随整机出厂交付,不影响设备核心功能实现。', NULL, 'active');
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 4. 写入 28 个子类(parent_id 先留空,第 5 步统一回填)
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO material_categories (organization_id, code, name, description, parent_id, status) VALUES
|
||||
(1, 'A01', '标准件', '1.核心定义:国家/行业通用标准化零件,型号固定,无需绘图、无需任何二次加工。 2.包含说明:设备通用紧固、锁紧、防松类标准配件,采用全行业通用规格,无非标改造。 3.现场举例:内六角螺钉、沉头螺钉、组合螺钉、六角螺母、平垫圈、弹簧垫圈、防松垫片、六角铜螺柱。 4.边界区分:国标通用紧固配件统一归此类;非标定制紧固结构件归入 A04。', NULL, 'active'),
|
||||
(1, 'A02', '成品件', '1.核心定义:工业标准成品件,整件采购到货,无需裁切、折弯、切削、焊接等二次加工,可直接装配使用。 2.包含说明:设备通用机械辅助成品、纯机械传动成品件,自带标准型号,无需图纸定制。 3.现场举例:脚轮、磁铁、设备防护罩、标准导轨、丝杆、轴承、同步轮、同步带。 4.边界区分:完整成品无需加工归A02;原材料需裁切加工归A03。', NULL, 'active'),
|
||||
(1, 'A03', '简易加工件', '1.核心定义:采购标准定尺长条原料,仅做断料、裁切、简单打孔,无车铣、折弯、焊接、异形精密深加工。 2.包含说明:机架、框架类基础原料件,仅做简单下料、简易开孔处理。 3.现场举例:工业铝型材、方管、圆管、槽钢、角钢、实心棒材裁切下料件。 4.边界区分:长条原料简易裁切归A03;成品整件直接使用=A02;异形精密切削成型=A04。', NULL, 'active'),
|
||||
(1, 'A04', '精密加工件', '1.核心定义:金属厚料基材,通过CNC车、铣、钻、镗、削精密成型的非标结构件,包含金属研发手板、非标传动结构件。 2.包含说明:异形结构、精密配合、运动连接、承重支撑类金属定制件。 3.现场举例:设备金属板、安装转接板、支撑柱、旋转支架、非标机械连杆、金属CNC手板。 4.边界区分:厚料、切削工艺归A04;金属薄板折弯、焊接成型归A05。', NULL, 'active'),
|
||||
(1, 'A05', '钣金件', '1.核心定义:金属薄板基材,通过折弯、冲压、焊接、开孔成型的板类结构件,包含钣金研发手板。 2.包含说明:设备外壳基材、层板、机架、屏蔽防护类钣金件。 3.现场举例:金属层板、屏蔽罩、钢架、侧板、钣金支架、薄板折弯手板。 4.边界区分:薄板基材、折弯/焊接/冲压为主工艺归A05;厚料异形精密切削工艺归A04。', NULL, 'active'),
|
||||
(1, 'A06', '塑胶件', '1.核心定义:纯塑胶材质结构件、外观件,涵盖注塑量产件、3D打印/CNC/复模研发手板。 2.包含说明:塑胶防护、外观、支撑、壳体类零件,不区分生产工艺与阶段。 3.现场举例:灯罩、灯带支座、设备外壳、扶手蒙皮、塑胶3D打印手板。 4.边界区分:所有纯塑胶结构件统一归此类;手板与量产件通过系统属性标注区分。', NULL, 'active'),
|
||||
(1, 'A07', '其他', '1.核心定义:以上子类无法精准归类的小众机械零件。 2.包含说明:冷门机械辅件、非标小连接件。 3.现场举例:碟形弹簧、小型非标结构件。 4.边界区分:A01-A06无法匹配的机械类物料,全部归入此类。', NULL, 'active'),
|
||||
(1, 'B01', '成品件', '1.核心定义:市面标准电气成品、模块、电动部件,整件采购,无需出图、加工、组装,可直接装配。 2.包含说明:开关、电源、端子、标准线材、工控模块、各类电机及电动执行件。 3.现场举例:急停按钮、船型开关、温控开关、磁环、接线端子、冷压端子、开关电源、UPS、触摸显示器、工控机、IO扩展模块、标准网线、串口线、伺服电机、步进电机。 4.边界区分:无定制、无加工的标准电气成品、电动件全部归此类;需现场裁剪的长条电气件归入B02。', NULL, 'active'),
|
||||
(1, 'B02', '简易加工件', '1.核心定义:采购长条、卷材类标准电气原料,到货后仅做裁剪、分段等简单加工,无焊接、改板、接线等深度作业。 2.包含说明:通用长条灯条、连续式灯带、长距离线缆等,按装配尺寸现场裁切使用。 3.现场举例:整机外购标准灯条、长条形灯带,根据安装长度裁剪分段。 4.边界区分:仅做裁剪、分段的长条/卷材电气原料归B02;完整成品电气件归B01;需制板、压接定制的物料归入后续子类。', NULL, 'active'),
|
||||
(1, 'B03', 'PCB硬板', '1.核心定义:依据我方PCB图纸,外协加工的硬质电路板,含贴片、插件成品板。 2.包含说明:自研主控板、驱动板、接口板等硬质功能板卡。 3.现场举例:主控灯带控制板、从控灯带控制板、各类自研PCB板卡。 4.边界区分:仅单纯PCB板加工,无外部其他器件装配。', NULL, 'active'),
|
||||
(1, 'B04', 'FPC软板', '1.核心定义:依据图纸,外协定制的FPC软板、柔性排线。 2.包含说明:设备活动连接、弯折部位专用柔性电路件。 3.现场举例:设备关节连接软板、显示模组柔性排线。 4.边界区分:柔性线路板归B04;硬质PCB板归入B03。', NULL, 'active'),
|
||||
(1, 'B05', '线束', '1.核心定义:依据线表、线序、长度要求,外协裁切、压接、编组的专用非标线束。 2.包含说明:设备专用成套线缆、定制线组。 3.现场举例:定制线缆、LM系列专用线束。 4.边界区分:纯线材外协压接编组加工,无板卡、器件组装。', NULL, 'active'),
|
||||
(1, 'B06', '组装组件', '1.核心定义:我方提供图纸、电路图、BOM、装配工艺,外协完成板卡、线束、电气件整体组装、接线、调试后整件来料的集成组件。 2.包含说明:PCB、软板、线材、电动件组合而成的集成电气装配体。 3.现场举例:PCB板+柔性线路板+电机+配套线材集成组件。 4.边界区分:多品类电气件集成装配体归此类;单一板卡、单一线束分别归入对应子类。', NULL, 'active'),
|
||||
(1, 'B07', '其他', '1.核心定义:以上子类无法归类的小众电气配件、特殊电子元件。 2.包含说明:冷门电气辅件、非主流功能元件。 3.现场举例:B01-B06无法匹配的电气类物料,统一归入此类。', NULL, 'active'),
|
||||
(1, 'C01', '连接耗材', '1.核心定义:设备接线、装配过程使用,最终留存于产品内部的辅材。 2.包含说明:线路防护、绝缘、粘接类耗材。 3.现场举例:热缩管、梅花管、绝缘护套、设备专用粘接剂。 4.边界区分:仅产品内置装配耗材归此类。', NULL, 'active'),
|
||||
(1, 'C02', '密封耗材', '1.核心定义:用于设备密封、防尘、防水,装配后随产品交付的耗材。 2.包含说明:各类密封垫圈、专用密封油脂、防护填充材料。 3.现场举例:设备密封胶圈、腔体密封脂。 4.边界区分:设备本体密封耗材归此类;生产设备运维耗材不纳入。', NULL, 'active'),
|
||||
(1, 'C03', '其他', '1.核心定义:以上子类无法归类、随产品出厂的小众工艺耗材。 2.包含说明:本类仅收纳产品内置耗材,通用车间耗材一律排除。', NULL, 'active'),
|
||||
(1, 'D01', '自研程序固件', '1.核心定义:企业自主开发,嵌入设备硬件、保障设备核心运行的嵌入式固件、上位机控制程序。 2.包含说明:设备下位机固件、定制控制程序、专用驱动程序。 3.现场举例:设备主控固件、人机界面程序、运动控制程序。 4.边界区分:自主研发、设备必备的程序归此类;办公、设计类软件不纳入。', NULL, 'active'),
|
||||
(1, 'D02', '外购软件授权', '1.核心定义:外部采购,设备运行必需的第三方商用软件、正版使用许可。 2.包含说明:设备配套第三方控制软件、功能组件、永久/阶段性使用授权。 3.现场举例:第三方算法软件、配套组态软件、软件使用授权证书。 4.边界区分:参与设备功能实现的外购软件及授权归此类。', NULL, 'active'),
|
||||
(1, 'D03', '授权介质', '1.核心定义:承载软件、设备授权的实物硬件,随整机配套交付。 2.包含说明:软件加密锁、设备身份密钥等硬件载体。 3.现场举例:软件加密狗、专用授权密钥硬件。 4.边界区分:仅产品配套的授权硬件归此类。', NULL, 'active'),
|
||||
(1, 'D04', '其他', '1.核心定义:以上子类无法归类的配套程序组件、临时授权、辅助文档。 2.包含说明:软件、授权相关杂项统一归入此类。', NULL, 'active'),
|
||||
(1, 'E01', '成品', '1.核心定义:完成全部装配、调试、检验,可直接对外交付的有源医疗器械整机。 2.包含说明:公司主营各类完整诊疗设备。 3.现场举例:眼科手术控制系统、主控制台、从控制台整机。 4.边界区分:最终交付整机产品归此类。', NULL, 'active'),
|
||||
(1, 'E02', '半成品', '1.核心定义:由多类零部件装配而成,未完成最终整机装配,可流转至下道工序继续加工的中间组件。 2.包含说明:工序级装配体、模块级组合组件。 3.现场举例:控制台外壳组件、底座组件、运动机构组件、电子集成组件。 4.边界区分:非最终整机的组合装配体归此类。', NULL, 'active'),
|
||||
(1, 'F01', '资料文档', '1.核心定义:随整机配套交付的纸质/合规类技术文档、资质文件。 2.包含说明:产品使用、安装、维修、合规备案所需配套资料。 3.现场举例:产品说明书、操作手册、安装手册、维修手册、出厂合格证、检测报告。 4.边界区分:仅产品随货交付的正式资料文件归入此类;内部研发工艺资料不纳入BOM。', NULL, 'active'),
|
||||
(1, 'F02', '标识标牌', '1.核心定义:附着、固定在设备机身表面,用于合规标示、参数说明、警示提示的标识类物件。 2.包含说明:设备铭牌、参数标签、警示标贴、功能标识、规格标牌。 3.现场举例:机身金属铭牌、设备参数贴纸、安全警示标签、接口标识贴、合规标识牌。 4.边界区分:所有贴附/固定在产品本体的标识类物件统一归入此类,不再归入机械类。', NULL, 'active'),
|
||||
(1, 'F03', '包装物料', '1.核心定义:产品整机交付使用的外包装箱体、外壳及缓冲防护、填充类配套物料。 2.包含说明:定制航空箱、外包装箱体、防护外箱、内衬框架、缓冲泡棉、填充棉、防护内衬、减震包装辅材。 3.现场举例:设备定制航空箱、整机外包装箱、成型泡棉内衬、定位缓冲泡棉、防震填充辅料。 4.边界区分:所有产品专属包装、防护、减震填充物料全部归入此类。', NULL, 'active'),
|
||||
(1, 'F04', '随机工具配件', '1.核心定义:随产品整机交付给客户,供客户安装、调试、日常运维、拆装使用的小型工具、配套配件。 2.包含说明:设备专属交付工具、配套小配件,非设备本体结构件。 3.现场举例:随机内六角扳手、专用拆装工具、配套备用小配件。 4.边界区分:随货交付客户的工具归入此类;车间生产/自检自用工具、工装治具不纳入本BOM体系。', NULL, 'active'),
|
||||
(1, 'F05', '其他', '1.核心定义:以上子类无法归类、随产品交付的其余附属配套物料。 2.包含说明:本大类无对应子类的产品配套杂项,统一兜底归入。', NULL, 'active');
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 5. 回填 parent_id
|
||||
-- 子类编码为 3 位(如 A01),首字母即所属大类编码;大类编码为 1 位。
|
||||
-- ---------------------------------------------------------------------------
|
||||
UPDATE material_categories AS c
|
||||
JOIN material_categories AS p
|
||||
ON p.organization_id = c.organization_id
|
||||
AND p.code = LEFT(c.code, 1)
|
||||
SET c.parent_id = p.id
|
||||
WHERE c.organization_id = 1
|
||||
AND CHAR_LENGTH(c.code) = 3
|
||||
AND c.parent_id IS NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 6. 编码规则:物料号 = 子类编码 + 4 位流水号
|
||||
-- next_sequence 取自业务给出的「当前最大流水号 + 1」,
|
||||
-- 故下一个物料号分别为 A010201、A020202、B010203,其余为 XNN0201。
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO code_rules (organization_id, category_id, prefix, sequence_length, next_sequence, status)
|
||||
SELECT 1, c.id, c.code, 4,
|
||||
CASE c.code WHEN 'A02' THEN 202 WHEN 'B01' THEN 203 ELSE 201 END,
|
||||
'active'
|
||||
FROM material_categories AS c
|
||||
WHERE c.organization_id = 1
|
||||
AND c.parent_id IS NOT NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 7. 校验
|
||||
-- ---------------------------------------------------------------------------
|
||||
SELECT '大类' AS 层级, COUNT(*) AS 数量 FROM material_categories WHERE organization_id = 1 AND parent_id IS NULL
|
||||
UNION ALL
|
||||
SELECT '子类', COUNT(*) FROM material_categories WHERE organization_id = 1 AND parent_id IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT '编码规则', COUNT(*) FROM code_rules WHERE organization_id = 1
|
||||
UNION ALL
|
||||
SELECT '残留物料', COUNT(*) FROM materials;
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
-- ============================================================================
|
||||
-- 预警阈值语义调整脚本(stock_warning_rules)
|
||||
--
|
||||
-- 用途:清理历史遗留的 minimum_available = 0 的预警规则行。
|
||||
--
|
||||
-- 背景:旧版判定规则是「参与预警 = 启用 AND 阈值 > 0」,阈值为 0 的行被 HAVING 条件
|
||||
-- 完全屏蔽,从来没有生效过。正因为它们一直是沉默的,无法从数据上区分"当年故意
|
||||
-- 设成 0"和"入库表单预填逻辑误写 0"。
|
||||
-- 新版把 0 定为合法的预警值(含义是"库存降到 0 时预警"),这批行会在升级后
|
||||
-- 立刻开始报警。因此一并删除,让它们回到"从未配置"(不预警)的状态——这与
|
||||
-- 升级前的实际行为一致。
|
||||
--
|
||||
-- !! 破坏性操作,执行前请务必备份数据库 !!
|
||||
--
|
||||
-- 字符集:统一 utf8mb4。脚本开头 SET NAMES utf8mb4 确保中文正确落库,
|
||||
-- 不依赖客户端默认字符集。
|
||||
-- 幂等性:可重复执行(删除条件固定,清空后再执行不会影响任何行)。
|
||||
--
|
||||
-- 执行方式:
|
||||
-- mysql -uroot -p --default-character-set=utf8mb4 wmms < scripts/update_warning_stock_zero.sql
|
||||
-- ============================================================================
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1. 执行前先看看要删掉哪些行
|
||||
-- 如果结果里有你确实想保留的物料,请先手工把它的阈值改成大于 0 的值。
|
||||
-- ---------------------------------------------------------------------------
|
||||
SELECT id, organization_id, warehouse_id, material_id, minimum_available, enabled, updated_at
|
||||
FROM stock_warning_rules
|
||||
WHERE minimum_available = 0;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2. 删除阈值为 0 的历史规则
|
||||
-- enabled 是 TRUE 还是 FALSE 都要删:前者是"沉默的启用"(升级后会突然报警),
|
||||
-- 后者本就等同于"从未配置"。
|
||||
-- ---------------------------------------------------------------------------
|
||||
DELETE FROM stock_warning_rules WHERE minimum_available = 0;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3. 确认清空,结果应为 0
|
||||
-- ---------------------------------------------------------------------------
|
||||
SELECT COUNT(*) AS remaining_zero_threshold_rules
|
||||
FROM stock_warning_rules
|
||||
WHERE minimum_available = 0;
|
||||
BIN
server.exe
BIN
server.exe
Binary file not shown.
Loading…
Reference in New Issue
Block a user