新增伙伴业务分类,优化物料编辑并整理数据库脚本

This commit is contained in:
文睿 张 2026-10-08 11:54:51 +08:00
parent b1c86593f0
commit 6ac9f2cb3c
20 changed files with 1079 additions and 412 deletions

View File

@ -112,13 +112,13 @@ MySQL 官方镜像**只在数据目录为空时**才执行 `/docker-entrypoint-i
Error 1054 (42S22): Unknown column 'inbound_no' in 'field list' Error 1054 (42S22): Unknown column 'inbound_no' in 'field list'
``` ```
**部署前先确认卷的年龄**: **部署前先确认现有表结构**:
```bash ```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 docker compose exec db mysqldump -uroot -pwmms1234 --default-character-set=utf8mb4 wmms > backup.sql
``` ```
**方式二:跑增量脚本(需要保留数据时)** **方式二:保留数据并更新结构**
`scripts/` 下有三个手工迁移脚本,按需执行: 需要保留业务数据时,先备份数据库,再根据实际表结构与 `scripts/init.sql` 的差异编写并审核增量 SQL,在备份副本上验证后执行。
| 脚本 | 作用 | 是否破坏数据 | 当前 `scripts/init.sql` 用于空库首次初始化,已包含出入库单号、操作人、备注、分类说明、6 个大类和 28 个子类,以及对应编码规则。库存预警阈值为 0 是有效配置,新库无需清理历史规则。不要将完整初始化脚本直接用于已有业务数据的数据库。
|------|------|--------------|
| `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
```
执行前请先备份,并阅读脚本头部的说明。
## 环境变量 ## 环境变量
@ -207,12 +195,12 @@ Error 1054 (42S22): Unknown column 'description' in 'field list'
**确认**: **确认**:
```bash ```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。
### 其他排查技巧 ### 其他排查技巧

View File

@ -210,7 +210,7 @@
- 列表顶部提供大类、小类、物料类型、状态和关键词筛选。 - 列表顶部提供大类、小类、物料类型、状态和关键词筛选。
- 关键词支持对物料编码、名称、规格和制造商型号进行模糊搜索。 - 关键词支持对物料编码、名称、规格和制造商型号进行模糊搜索。
- 小类选项随所选大类联动;清除大类时同时清除无效的小类条件。 - 小类选项随所选大类联动;清除大类时同时清除无效的小类条件。
- 点击物料进入详情页;技术人员可在详情页修改或作废,采购人员可在详情页维护供应商信息。 - 点击列表中的“详情”先只读查看物料;有修改权限的人员可在详情中点击“编辑”进入编辑状态,保存后回到只读详情,取消编辑或关闭时丢弃未保存内容。采购人员可在详情页维护供应商信息。
- 列表提供单个新增、批量导入和批量导出入口,并根据角色权限显示。 - 列表提供单个新增、批量导入和批量导出入口,并根据角色权限显示。
#### 故事 #MD-003:物料主数据修改 #### 故事 #MD-003:物料主数据修改
@ -237,7 +237,7 @@
验收标准: 验收标准:
- 作废物料不得用于新建普通入库、出库、调拨、预留、BOM 和制造订单。 - 作废物料不得用于新建普通入库、出库、调拨、预留、BOM 和制造订单。
- 物料作废只允许在详情页执行,列表页不提供直接作废按钮。 - 列表操作列保留“作废物料”或“撤销作废”按钮,以按钮文案区分当前状态;有权限的人员可直接操作,作废前必须二次确认。
- 作废不影响历史查询和合法的撤销过账(冲销)。 - 作废不影响历史查询和合法的撤销过账(冲销)。
- 已存在账面、预留、锁定或在途库存时必须提示风险并禁止直接作废,处理完库存后方可作废。 - 已存在账面、预留、锁定或在途库存时必须提示风险并禁止直接作废,处理完库存后方可作废。

View File

@ -19,8 +19,8 @@
- 通过树形界面手工维护 BOM。 - 通过树形界面手工维护 BOM。
- 为成品或半成品建立多个 BOM 版本。 - 为成品或半成品建立多个 BOM 版本。
- 创建首个 BOM 草稿。 - 创建首个 BOM 草稿。
- 查看 BOM 列表、结构和历史版本。 - 从物料列表进入指定物料的 BOM 页面,查看结构和历史版本。
- 基于指定历史版本完整复制明细,生成新草稿。 - 新建后续版本时,可基于指定历史版本完整复制明细,或创建空白草稿。
- 编辑、保存和删除草稿。 - 编辑、保存和删除草稿。
- 生效草稿或停用指定的生效版本。 - 生效草稿或停用指定的生效版本。
- 自动校验重复子项、自引用、循环引用、无效数量和最大层级。 - 自动校验重复子项、自引用、循环引用、无效数量和最大层级。
@ -46,11 +46,11 @@
BOM 版本仅使用以下三个状态: BOM 版本仅使用以下三个状态:
| 状态 | 含义 | 允许操作 | | 状态 | 含义 | 允许操作 |
| --- | --- | --- | | ----------- | --- | ------------------------- |
| `draft` | 草稿 | 查看、编辑、保存、生效、删除 | | `draft` | 草稿 | 查看、编辑、保存、生效、删除 |
| `effective` | 可用 | 查看、被新 BOM 选择、停用、基于该版本新建草稿 | | `effective` | 可用 | 查看、被新 BOM 选择、停用、基于该版本新建草稿 |
| `obsolete` | 已停用 | 查看历史、基于该版本新建草稿;不能用于新 BOM | | `obsolete` | 已停用 | 查看历史、基于该版本新建草稿;不能用于新 BOM |
规则如下: 规则如下:
@ -58,7 +58,7 @@ BOM 版本仅使用以下三个状态:
2. 同一父物料可以同时存在多个 `effective`(可用)版本。 2. 同一父物料可以同时存在多个 `effective`(可用)版本。
3. 默认版本是尚未停用且最近一次生效的版本,按生效时间排序;同一时间时按版本 ID 确定唯一结果。停用默认版本后,剩余可用版本中最近生效的版本成为默认版本;全部停用时无默认版本。 3. 默认版本是尚未停用且最近一次生效的版本,按生效时间排序;同一时间时按版本 ID 确定唯一结果。停用默认版本后,剩余可用版本中最近生效的版本成为默认版本;全部停用时无默认版本。
4. 新版本生效不改变其他可用版本的状态。非默认的可用版本仍可被新建的上层 BOM 明确选择。 4. 新版本生效不改变其他可用版本的状态。非默认的可用版本仍可被新建的上层 BOM 明确选择。
5. 可用版本不可直接修改。修改可用或已停用版本时,必须先完整复制所选版本,生成新的草稿版本。 5. 可用版本不可直接修改。变更结构时应创建新的草稿版本,可复制指定旧版,也可从空白开始。
6. 已停用版本不能恢复为可用,但可以作为新版本的复制来源;它不能被新建的上层 BOM 选用。 6. 已停用版本不能恢复为可用,但可以作为新版本的复制来源;它不能被新建的上层 BOM 选用。
7. 草稿可删除;可用和已停用版本只保留、不物理删除。 7. 草稿可删除;可用和已停用版本只保留、不物理删除。
@ -121,16 +121,13 @@ BOM 版本仅使用以下三个状态:
## 4. 前端信息架构 ## 4. 前端信息架构
左侧导航在“物料主数据”和“公司组织”之间增加独立大类: 左侧导航沿用“物料主数据”入口。BOM 维护由物料列表逐行进入,不增加独立的“BOM 管理”菜单:
```text ```text
物料主数据 物料主数据
├─ 物料 ├─ 物料
└─ 基础配置 └─ 基础配置
BOM 管理
└─ BOM 维护
公司组织 公司组织
├─ 组织管理 ├─ 组织管理
├─ 用户管理 ├─ 用户管理
@ -145,16 +142,17 @@ BOM 管理
现有独立菜单“日志记录”改为左侧导航大类。现有物料日志页面归入“物料变更日志”,BOM 事件展示在新建的“BOM 记录”子页面。原有 `/logs` 地址应保持可访问,并跳转到物料变更日志页面,避免旧链接失效。 现有独立菜单“日志记录”改为左侧导航大类。现有物料日志页面归入“物料变更日志”,BOM 事件展示在新建的“BOM 记录”子页面。原有 `/logs` 地址应保持可访问,并跳转到物料变更日志页面,避免旧链接失效。
### 4.1 BOM 维护 ### 4.1 物料列表中的 BOM 入口
BOM 维护统一包含以下四类操作: 现有物料列表继续负责搜索物料、查看详情、编辑和作废。在符合 BOM 父物料条件的成品或半成品行增加明显的“BOM”按钮,放在独立的窄列中,避免挤占现有操作列。点击后进入该物料的独立 BOM 页面。现有“物料详情”弹窗保持原有用途;BOM 页面显示当前物料的编码、名称、规格、基础单位和状态,并提供“返回物料列表”。已作废物料若已有 BOM,仍可进入页面查看历史版本,但不能新建或生效版本。
```text ```text
BOM 列表 物料主数据 → 物料列表 → 选择整机 A 所在行的 [BOM]
→ 查询、查看结构、查看历史版本、发起新版本 ↓
整机 A 的 BOM 页面
新建 BOM 新建 BOM
→ 为尚未建立 BOM 的父物料创建第一个草稿版本 → 为当前物料创建第一个草稿版本
编辑草稿 编辑草稿
→ 编辑明细、保存草稿、删除草稿 → 编辑明细、保存草稿、删除草稿
@ -167,37 +165,59 @@ BOM 列表
树形结构维护是编辑页面的交互方式,不作为独立功能入口。 树形结构维护是编辑页面的交互方式,不作为独立功能入口。
### 4.2 BOM 列表 ### 4.2 单个物料的 BOM 页面
列表以父物料为主行,避免历史版本过多导致列表膨胀: 页面只管理入口物料的 BOM,不再建立与物料列表重复的 BOM 总列表。顶部始终显示版本选择框:已有版本时列出该物料的全部草稿、可用和已停用版本,并在选项中标明状态和默认版本;选项按创建时间从新到旧排列。首次进入时优先选中默认可用版本,没有可用版本时选中最近创建的版本。没有任何版本时,版本框显示“无”且不可展开,旁边提供“新建 BOM”按钮:
```text ```text
父项物料 默认版本 可用版本数 草稿版本 操作 ← 返回物料列表 整机 A · A-001 有效
整机 A V2 2 V3 查看结构|编辑草稿|历史版本
电机组件 V1 1 无 查看结构|新建版本|历史版本 BOM 版本:[V2 · 可用·默认 ▼] [新建版本]
当前版本状态:可用 [停用]
▼ 整机 A × 1
├─ 外壳组件 × 1
└─ 电机组件 × 2
``` ```
点击列表中的“历史版本”,弹出独立窗口展示该父物料的全部版本,不在列表行内展开,也不使用侧边抽屉。窗口内可查看版本结构,或选择指定版本发起新版本: 选择草稿版本时,操作按钮显示在同一状态行的位置:
```text ```text
┌─ 整机 A · 历史版本 ─────────────────────────────── [关闭] ┐ ← 返回物料列表 整机 A · A-001 有效
│ 版本 状态 操作 │
│ V3 草稿 查看结构|编辑草稿 │ BOM 版本:[V3 · 草稿 ▼] [新建版本]
│ V2 可用·默认 查看结构|新建版本|停用 │ 当前版本状态:草稿 [编辑] [生效] [删除草稿]
│ V1 可用 查看结构|新建版本|停用 │
└─────────────────────────────────────────────────────┘ ▼ 整机 A × 1
├─ 外壳组件 × 1
└─ 电机组件 × 2
``` ```
列表至少支持: 无版本时沿用相同布局:
- 按父项物料编码或名称查询。 ```text
- 按是否存在草稿、是否存在可用版本筛选。 ← 返回物料列表 整机 A · A-001 有效
- 查看当前或历史版本结构。
BOM 版本:[无] [新建 BOM]
暂无 BOM 结构
```
创建首个草稿后,版本选择框自动选中该草稿并显示其结构。
切换版本后,页面直接加载该版本的完整结构和节点明细,不再要求点击“查看结构”。选中草稿时,顶部状态行显示“编辑”“生效”“删除草稿”;点击“编辑”才进入草稿的树形编辑状态。选中可用版本时,该位置显示“停用”;已停用版本只读,不显示编辑、停用或生效按钮。切换前若草稿存在尚未保存的修改,应先提示保存或放弃,避免丢失内容。
物料列表及该物料 BOM 页面共同支持:
- 在现有物料列表按编码或名称查找父物料,并从该行进入 BOM 页面。
- 通过版本选择框查看当前或历史版本结构。
- 编辑已有草稿。 - 编辑已有草稿。
- 从指定历史版本复制并发起新版本。 - 发起新版本,并选择复制旧版或空白创建。
- 执行当前状态允许的状态操作。 - 执行当前状态允许的状态操作。
“查看结构”页面固定按父物料数量 `1` 展示当前选定 BOM 版本的完整层级和各层用量,不提供数量输入或最底层物料汇总。 建议页面路由为 `/materials/:materialId/bom`。从树中的子物料进入其 BOM 时,也使用同一路由并传入该子物料 ID。物料详情弹窗无需承载多层 BOM 编辑。
版本选择框下方的结构区域固定按父物料数量 `1` 展示所选 BOM 版本的完整层级和各层用量,不提供数量输入或最底层物料汇总。
```text ```text
整机 A × 1 整机 A × 1
@ -211,57 +231,71 @@ BOM 列表
### 4.3 新建 BOM ### 4.3 新建 BOM
新建 BOM 只面向尚未建立 BOM 的成品或半成品。页面包含: 新建 BOM 只面向尚未建立 BOM 的成品或半成品。入口物料已由物料列表确定,页面包含:
- 父项物料选择。 - 当前父物料信息,只读展示,不再重复选择。
- 初始版本号。 - 初始版本号。
- 直接子物料树或列表。 - 直接子物料树或列表。
- 子项单位用量、基础单位、损耗率和备注。 - 子项单位用量、基础单位、损耗率和备注。
- 保存草稿。 - 保存草稿。
已存在 BOM 的父物料不能再次走“新建 BOM”,应从列表或历史版本选择来源并执行“新建版本”。 已存在 BOM 的父物料不能再次走“新建 BOM”,应从该物料的 BOM 页面点击“新建版本”,再选择复制旧版或空白创建。
### 4.4 编辑草稿 ### 4.4 编辑草稿
编辑页采用左侧结构树、右侧节点明细的布局: 编辑页采用左侧结构树、右侧节点明细的布局。点击任意物料节点,右侧先以只读方式展示该节点的物料信息、用量、损耗率、备注和下层 BOM 版本:
```text ```text
┌───────────────────────┬──────────────────────────────┐ ┌────────────────────────┬─────────────────────────────┐
│ BOM 结构树 │ 当前节点明细 │ │ BOM 结构树 │ 当前节点明细 │
│ │ │ │ ▼ 整机 A │ 电机组件 │
│ ▼ 整机 A │ 子物料:电机组件 │ │ ├─ 外壳组件 × 1 │ 物料编码:DJ-001 │
│ ├─ 外壳组件 × 1 │ 单位用量:2 │ │ ├─ 电机组件 × 2 ← │ 单位用量:2 套 │
│ ├─ 电机组件 × 2 │ 基础单位:套 │ │ │ └─ 螺丝 × 4 │ 损耗率:0% │
│ └─ 控制箱 × 1 │ 损耗率: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 新建版本 ### 4.5 新建版本
“新建版本”从 BOM 列表或历史版本发起,不属于编辑草稿内部操作: “新建版本”从当前物料的 BOM 页面发起,不属于编辑草稿内部操作。点击后先弹出创建方式选择窗口:
```text ```text
选择一个历史版本 ┌─ 新建整机 A 的 BOM 版本 ──────────────────────────┐
↓ │ │
完整复制该版本的 BOM 明细 │ [复制旧版] 推荐:沿用现有结构,再修改差异 │
↓ │ 选择来源版本:[V2 · 可用 ▼] │
生成新的草稿版本 │ │
↓ │ 空白创建 从零开始添加子物料 │
进入草稿编辑页 │ │
│ [取消] [创建草稿] │
└───────────────────────────────────────────────────┘
``` ```
每次只复制一个明确选中的来源版本,不复制该父物料的全部历史版本。 “复制旧版”作为醒目的默认选项,其“来源版本”使用弹窗内的下拉框选择,列出该父物料全部已有版本并标明状态;默认预选当前 BOM 页面正在查看的版本,也允许在下拉框中改选其他版本,不再打开第二个版本选择窗口。创建时完整复制所选版本的明细,并记录来源版本。“空白创建”使用普通样式,不复制明细,来源版本为空。两种方式都生成新的草稿版本并进入草稿编辑页;每次只创建一个版本。
### 4.6 状态操作 ### 4.6 状态操作
@ -288,29 +322,17 @@ BOM 列表
## 5. 数据模型建议 ## 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` - `id`
- `organization_id` - `organization_id`
- `parent_material_id` - `parent_material_id`,引用 `materials.id`。
- `created_at`
- `updated_at`
约束:同一组织下 `parent_material_id` 唯一。
### 5.2 `bom_versions`
建议字段:
- `id`
- `organization_id`
- `bom_header_id`
- `version` - `version`
- `status` - `status`
- `source_version_id`,记录复制来源,可为空。 - `source_version_id`,记录复制来源,可为空。
@ -323,10 +345,10 @@ BOM 列表
约束: 约束:
- 同一 `bom_header_id` 下版本号唯一。 - 同一组织、同一 `parent_material_id` 下版本号唯一;父物料必须属于同一组织,且为允许建立 BOM 的成品或半成品。
- 为 `bom_header_id`、`status` 和 `effective_at` 建立查询索引。默认版本由可用版本的生效时间和版本 ID 确定,不单独保存可变的默认标记。 - 为 `organization_id`、`parent_material_id`、`status` 和 `effective_at` 建立查询索引。默认版本由可用版本的生效时间和版本 ID 确定,不单独保存可变的默认标记。
### 5.3 `bom_items` ### 5.2 `bom_items`
建议字段: 建议字段:
@ -338,16 +360,29 @@ BOM 列表
- `unit_id` - `unit_id`
- `loss_rate` - `loss_rate`
- `remark` - `remark`
- `locked_child_version_id` - `child_bom_version_id`,引用所选子物料本身的 BOM 版本;子物料本身无下层 BOM 时为空。
- `sort_order` - `sort_order`
- `created_at` - `created_at`
- `updated_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 层时不需要改变表结构。 树形层级不直接存成固定层级字段。系统通过各父物料 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 版本、操作类型、操作人、操作时间和说明。生效与停用各记录一次;默认版本随这些状态变化而自动重新计算,不生成额外的停用记录。 独立记录每次 BOM 状态变化,至少保存经营组织、父物料、BOM 版本、操作类型、操作人、操作时间和说明。生效与停用各记录一次;默认版本随这些状态变化而自动重新计算,不生成额外的停用记录。
@ -371,19 +406,19 @@ handler
接口草案: 接口草案:
| 方法 | 路径 | 用途 | | 方法 | 路径 | 用途 |
| --- | --- | --- | | -------- | ---------------------------------------- | ---------------------- |
| `GET` | `/api/boms` | 查询 BOM 主列表 | | `POST` | `/api/boms` | 为指定父物料创建首个草稿版本 |
| `POST` | `/api/boms` | 创建首个草稿版本 | | `GET` | `/api/boms/:materialId` | 按父物料 ID 查看 BOM 及默认版本摘要 |
| `GET` | `/api/boms/:id` | 查看 BOM 主体及当前版本摘要 | | `GET` | `/api/boms/:materialId/versions` | 按父物料 ID 查看全部版本 |
| `GET` | `/api/boms/:id/versions` | 查看历史版本 | | `POST` | `/api/boms/:materialId/versions` | 为已有 BOM 创建空白草稿版本 |
| `GET` | `/api/bom-versions/:versionId` | 查看指定版本和结构 | | `GET` | `/api/bom-versions/:versionId` | 查看指定版本和结构 |
| `PUT` | `/api/bom-versions/:versionId` | 保存草稿 | | `PUT` | `/api/bom-versions/:versionId` | 保存草稿 |
| `DELETE` | `/api/bom-versions/:versionId` | 删除草稿 | | `DELETE` | `/api/bom-versions/:versionId` | 删除草稿 |
| `POST` | `/api/bom-versions/:versionId/copy` | 从指定版本生成新草稿 | | `POST` | `/api/bom-versions/:versionId/copy` | 从指定版本生成新草稿 |
| `POST` | `/api/bom-versions/:versionId/effective` | 生效草稿 | | `POST` | `/api/bom-versions/:versionId/effective` | 生效草稿 |
| `POST` | `/api/bom-versions/:versionId/obsolete` | 停用生效版本 | | `POST` | `/api/bom-versions/:versionId/obsolete` | 停用生效版本 |
| `GET` | `/api/bom-versions/:versionId/tree` | 按父项数量 `1` 查看完整 BOM 结构 | | `GET` | `/api/bom-versions/:versionId/tree` | 按父项数量 `1` 查看完整 BOM 结构 |
接口命名在实施计划阶段可按现有路由风格进一步统一,但业务边界不应改变。 接口命名在实施计划阶段可按现有路由风格进一步统一,但业务边界不应改变。
@ -391,12 +426,12 @@ handler
前端校验用于即时反馈,后端校验是最终可信规则。 前端校验用于即时反馈,后端校验是最终可信规则。
| 时机 | 校验内容 | | 时机 | 校验内容 |
| --- | --- | | ----- | ---------------------------- |
| 选择子物料 | 当前父节点重复、自引用、物料状态 | | 选择子物料 | 当前组织物料存在、当前父节点重复、自引用、物料状态 |
| 保存草稿 | 数量、损耗率、基础单位、重复、自引用、循环、20 层限制 | | 保存草稿 | 数量、损耗率、基础单位、重复、自引用、循环、20 层限制 |
| 生效 | 完整重复执行保存校验,并验证所选下层版本仍可用 | | 生效 | 完整重复执行保存校验,并验证所选下层版本仍可用 |
| 结构树查询 | 循环保护、20 层限制、版本引用完整性 | | 结构树查询 | 循环保护、20 层限制、版本引用完整性 |
错误信息需要包含: 错误信息需要包含:
@ -439,6 +474,7 @@ handler
### 9.1 后端 ### 9.1 后端
- 创建首个草稿和保存明细。 - 创建首个草稿和保存明细。
- 父物料和子物料必须存在于物料主数据且属于同一组织;不存在的物料 ID 无法创建 BOM。
- 同一父物料版本号重复。 - 同一父物料版本号重复。
- 同一版本直接子项重复。 - 同一版本直接子项重复。
- 父项直接引用自身。 - 父项直接引用自身。
@ -457,14 +493,22 @@ handler
### 9.2 前端 ### 9.2 前端
- 导航位置、超级管理员可见性和路由保护。 - 物料列表仅对符合条件的父物料显示“BOM”按钮;入口和 BOM 页面仅超级管理员可访问。
- 列表查询、主行展示,以及点击“历史版本”后打开独立弹窗查看全部版本。 - 从物料列表进入指定物料的独立 BOM 页面;版本选择框列出该物料全部版本并标明状态,切换后直接显示所选版本结构。
- 新建 BOM 时只允许选择尚未建立 BOM 的成品或半成品。 - 没有版本时版本框显示“无”,结构区域显示空状态;创建首个草稿后自动选中该版本。
- 版本选择框包含全部草稿、可用和已停用版本,约 50 个版本以内可滚动选择;切换后显示该版本结构与对应状态操作。
- 只有符合条件且尚无版本的成品或半成品,才能从其 BOM 页面创建首个草稿;父物料由入口固定,不再重复选择。
- 草稿可编辑,可用和已停用版本只读。 - 草稿可编辑,可用和已停用版本只读。
- 选中草稿时,状态行显示“编辑”“生效”“删除草稿”;点击顶部“编辑”后才进入树形编辑状态,选中可用版本时仅显示“停用”。
- 点击节点先在右侧查看明细;仅草稿的直接子项显示“编辑”按钮,点击后才进入编辑模式。
- 删除子物料入口只在右侧编辑模式出现,删除前确认,且只影响当前草稿中的该条子项。
- “添加子物料”只在根物料的第二层子项列表末尾出现;更深层节点通过对应子 BOM 维护。
- 添加子物料时只允许选择当前组织已有物料;搜索不到时提示先新增物料主数据,再返回 BOM 草稿继续添加。
- 添加重复子项和自引用时立即提示。 - 添加重复子项和自引用时立即提示。
- 后端返回循环或深度错误时定位并标记问题节点。 - 后端返回循环或深度错误时定位并标记问题节点。
- 生效、停用和删除草稿具有明确确认提示。 - 生效、停用和删除草稿具有明确确认提示。
- 从指定历史版本创建草稿后,明细完整复制且可继续编辑。 - 从指定历史版本创建草稿后,明细完整复制且可继续编辑。
- 点击“新建版本”先弹出方式选择窗口;“复制旧版”醒目且默认选中,来源版本通过弹窗内下拉框选择;“空白创建”可生成无明细草稿。
- “日志记录”展开后显示两个子页面;原 `/logs` 链接仍能打开物料变更日志。 - “日志记录”展开后显示两个子页面;原 `/logs` 链接仍能打开物料变更日志。
- “BOM 记录”支持筛选与查看生效和停用的操作详情。 - “BOM 记录”支持筛选与查看生效和停用的操作详情。
@ -475,7 +519,7 @@ handler
3. 实现复制新版本及版本列表。 3. 实现复制新版本及版本列表。
4. 实现结构校验、20 层限制和完整循环路径反馈。 4. 实现结构校验、20 层限制和完整循环路径反馈。
5. 实现多版本可用、默认版本推导、生效和停用事务。 5. 实现多版本可用、默认版本推导、生效和停用事务。
6. 实现前端导航、BOM 列表和历史版本展示。 6. 在物料列表增加 BOM 入口,实现单个物料的 BOM 页面和完整版本选择框。
7. 实现新建及草稿树形编辑页面。 7. 实现新建及草稿树形编辑页面。
8. 实现单个 BOM 版本按父项数量 `1` 的结构树查询和各层用量显示。 8. 实现单个 BOM 版本按父项数量 `1` 的结构树查询和各层用量显示。
9. 将现有日志入口改为双子页面,接入 BOM 操作记录和筛选。 9. 将现有日志入口改为双子页面,接入 BOM 操作记录和筛选。
@ -485,7 +529,7 @@ handler
第一版稳定后再分别设计和实现: 第一版稳定后再分别设计和实现:
1. 多 BOM 需求试算:在独立页面输入一个或多个 BOM 的计划数量,递归计算并汇总相同底层物料需求,再在 `BOM 管理` 下增加菜单。 1. 多 BOM 需求试算:在独立页面输入一个或多个 BOM 的计划数量,递归计算并汇总相同底层物料需求,再在“物料主数据”下增加菜单。
2. CSV 层级导入和错误报告。 2. CSV 层级导入和错误报告。
3. 替代料管理。 3. 替代料管理。
4. 面向技术人员等角色的细分权限。 4. 面向技术人员等角色的细分权限。

View 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;不要直接重复执行完整初始化脚本。

View File

@ -73,6 +73,10 @@ function codeInput(wrapper) {
return wrapper.find('.material-code-field input') return wrapper.find('.material-code-field input')
} }
function inputWithValue(wrapper, value) {
return wrapper.findAll('input').find(input => input.element.value === value)
}
function setupState(wrapper) { function setupState(wrapper) {
return wrapper.vm.$.setupState return wrapper.vm.$.setupState
} }
@ -285,10 +289,11 @@ describe('新增物料后列表与搜索框保持一致', () => {
await searchBy(wrapper, '螺丝') await searchBy(wrapper, '螺丝')
setupState(wrapper).showEditModal({ setupState(wrapper).showDetail({
id: 7, category_id: 12, material_code: 'A010201', name: '螺丝' id: 7, category_id: 12, material_code: 'A010201', name: '螺丝'
}) })
await flushPromises() await flushPromises()
await findButton(wrapper, '编辑').trigger('click')
await setupState(wrapper).handleSave() await setupState(wrapper).handleSave()
await flushPromises() await flushPromises()
@ -347,9 +352,7 @@ describe('物料列表的作废入口文案', () => {
return wrapper.findAll('button').find(button => button.text().trim() === text) return wrapper.findAll('button').find(button => button.text().trim() === text)
} }
// 这一页没有状态列(文案即状态),所以按钮文案本身得说清这条物料怎么了。 // 作废与恢复仍通过原操作按钮呈现,按钮文案本身说明当前状态。
// 「撤销」在中文 UI 里默认指撤销上一步(Ctrl+Z),看不出在撤销什么;「撤销作废」
// 才读得出「这条被作废了,点这里撤销那次作废」。
test('作废的那行显示「撤销作废」,有效的那行显示「作废物料」', async () => { test('作废的那行显示「撤销作废」,有效的那行显示「作废物料」', async () => {
const wrapper = mountMaterials() const wrapper = mountMaterials()
await flushPromises() await flushPromises()
@ -363,10 +366,14 @@ describe('物料列表的作废入口文案', () => {
// 注意断言的是行归属,不是「页面上有没有这个词」——两个词都含「作废」。 // 注意断言的是行归属,不是「页面上有没有这个词」——两个词都含「作废」。
expect(restore.element.closest('tr').textContent).toContain('旧螺丝') expect(restore.element.closest('tr').textContent).toContain('旧螺丝')
expect(obsolete.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() wrapper.unmount()
}) })
// 两个按钮轮流出现在同一列,宽度不一样会让「编辑」右边参差不齐。这里靠的是两边 // 两个按钮轮流出现在操作列,宽度不一样会让按钮参差不齐。这里靠的是两边
// 都是 4 个中文字,同字体同字号下自然等宽——jsdom 不做布局计算,量不了像素,但 // 都是 4 个中文字,同字体同字号下自然等宽——jsdom 不做布局计算,量不了像素,但
// 字数不等就一定会参差,所以把字数锁住。 // 字数不等就一定会参差,所以把字数锁住。
test('两个按钮的文案字数相同,保证宽度一致', async () => { 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('停用分类下的物料编辑', () => { describe('停用分类下的物料编辑', () => {
test('保留原分类并显示停用状态,修改名称时不改变分类归属', async () => { test('保留原分类并显示停用状态,修改名称时不改变分类归属', async () => {
materialCategoriesApi.list.mockResolvedValue({ data: [ materialCategoriesApi.list.mockResolvedValue({ data: [
@ -401,7 +493,9 @@ describe('停用分类下的物料编辑', () => {
expect(state.getMajorCategoryDisplay(row)).toBe('A 旧大类(已停用)') expect(state.getMajorCategoryDisplay(row)).toBe('A 旧大类(已停用)')
expect(state.getMinorCategoryDisplay(row)).toBe('旧小类') expect(state.getMinorCategoryDisplay(row)).toBe('旧小类')
state.showEditModal(row) state.showDetail(row)
await flushPromises()
await findButton(wrapper, '编辑').trigger('click')
await flushPromises() await flushPromises()
expect(state.form.major_category_id).toBe(1) expect(state.form.major_category_id).toBe(1)
expect(state.form.category_id).toBe(12) expect(state.form.category_id).toBe(12)

View File

@ -60,15 +60,14 @@
> >
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" @click="showDetail(row)">详情</el-button> <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="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> <el-button v-if="authStore.isAdmin && row.status === 'obsolete'" size="small" type="success" @click="handleRestore(row.id)">撤销作废</el-button>
</template> </template>
</el-table-column> </el-table-column>
</BaseTable> </BaseTable>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px"> <el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" @close="handleDialogClose">
<el-form :model="form" label-width="100px"> <el-form v-if="isCreating || isEditing" :model="form" label-width="100px">
<el-form-item label="物料编码" required> <el-form-item label="物料编码" required>
<div class="material-code-field"> <div class="material-code-field">
<el-input <el-input
@ -109,14 +108,7 @@
<img v-if="imagePreview" :src="imagePreview" style="max-width:200px;margin-top:10px;" /> <img v-if="imagePreview" :src="imagePreview" style="max-width:200px;margin-top:10px;" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <el-descriptions v-else-if="detailRow" :column="2" border>
<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-item label="物料编码">{{ detailRow.material_code }}</el-descriptions-item> <el-descriptions-item label="物料编码">{{ detailRow.material_code }}</el-descriptions-item>
<el-descriptions-item label="ID">{{ detailRow.id }}</el-descriptions-item> <el-descriptions-item label="ID">{{ detailRow.id }}</el-descriptions-item>
<el-descriptions-item label="名称">{{ detailRow.name }}</el-descriptions-item> <el-descriptions-item label="名称">{{ detailRow.name }}</el-descriptions-item>
@ -137,6 +129,20 @@
<span v-else>-</span> <span v-else>-</span>
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </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> </el-dialog>
<DeleteConfirm <DeleteConfirm
@ -181,9 +187,8 @@ const authStore = useAuthStore()
const searchKeyword = ref('') const searchKeyword = ref('')
const materialSearchText = ref('') const materialSearchText = ref('')
const dialogVisible = ref(false) const dialogVisible = ref(false)
const detailVisible = ref(false) const dialogMode = ref('create')
const deleteDialogVisible = ref(false) const deleteDialogVisible = ref(false)
const dialogTitle = ref('新增物料')
const saveLoading = ref(false) const saveLoading = ref(false)
const editingId = ref(null) const editingId = ref(null)
const deleteId = ref(null) const deleteId = ref(null)
@ -196,7 +201,9 @@ const categories = ref([])
const units = ref([]) const units = ref([])
// 用户是否手改过编码:没改过时切分类要按新规则重算,改过就以用户的值为准。 // 用户是否手改过编码:没改过时切分类要按新规则重算,改过就以用户的值为准。
const codeEdited = ref(false) 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(() => { const codePlaceholder = computed(() => {
@ -334,7 +341,8 @@ function handlePageChange(page) {
function showAddModal() { function showAddModal() {
editingId.value = null editingId.value = null
dialogTitle.value = '新增物料' detailRow.value = null
dialogMode.value = 'create'
Object.assign(form, createMaterialForm()) Object.assign(form, createMaterialForm())
codeEdited.value = false codeEdited.value = false
imagePreview.value = '' imagePreview.value = ''
@ -344,21 +352,41 @@ function showAddModal() {
function showDetail(row) { function showDetail(row) {
detailRow.value = row detailRow.value = row
detailVisible.value = true editingId.value = null
} dialogMode.value = 'detail'
Object.assign(form, createMaterialForm())
function showEditModal(row) {
editingId.value = row.id
dialogTitle.value = '编辑物料'
Object.assign(form, createMaterialForm(row, categories.value))
// 编辑时编码只读,标记成"已改过",避免切小类时被规则覆盖
codeEdited.value = true
imagePreview.value = '' imagePreview.value = ''
if (imageInput.value) imageInput.value.value = '' if (imageInput.value) imageInput.value.value = ''
dialogVisible.value = true 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() { async function handleSave() {
if (!isCreating.value && !isEditing.value) return
if (!form.major_category_id || !form.category_id) { if (!form.major_category_id || !form.category_id) {
ElMessage.warning('请选择大类类型和小类类型') ElMessage.warning('请选择大类类型和小类类型')
return return
@ -393,7 +421,9 @@ async function handleSave() {
formData.material_image = (data.data && data.data.url) ? data.data.url : '' formData.material_image = (data.data && data.data.url) ? data.data.url : ''
} }
if (editingId.value) { 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('物料更新成功') ElMessage.success('物料更新成功')
} else { } else {
await materialsStore.createMaterial(formData) await materialsStore.createMaterial(formData)
@ -401,8 +431,8 @@ async function handleSave() {
// store 里新建后的那次刷新会清空筛选(好让刚建的物料出现在列表里),搜索框得 // store 里新建后的那次刷新会清空筛选(好让刚建的物料出现在列表里),搜索框得
// 跟着一起清空,否则会出现搜索框写着关键字、列表却是全量的表里不一。 // 跟着一起清空,否则会出现搜索框写着关键字、列表却是全量的表里不一。
searchKeyword.value = '' searchKeyword.value = ''
dialogVisible.value = false
} }
dialogVisible.value = false
} catch (e) { } catch (e) {
// 接口失败的原因已由拦截器弹过中文提示,这里只兜住它覆盖不到的异常, // 接口失败的原因已由拦截器弹过中文提示,这里只兜住它覆盖不到的异常,
// 避免出现"点了保存没反应" // 避免出现"点了保存没反应"

View File

@ -245,3 +245,93 @@ describe('伙伴列表分页', () => {
wrapper.unmount() 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()
})
})

View File

@ -4,6 +4,12 @@
<div><h2>{{ title }}</h2><p>业务伙伴独立于本公司的组织数据,可按合作组织配置{{ title }}身份和状态。</p></div> <div><h2>{{ title }}</h2><p>业务伙伴独立于本公司的组织数据,可按合作组织配置{{ title }}身份和状态。</p></div>
<el-button type="primary" @click="openCreate">新增{{ title }}</el-button> <el-button type="primary" @click="openCreate">新增{{ title }}</el-button>
</div> </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 <BaseTable
:data="rows" :data="rows"
:loading="loading" :loading="loading"
@ -15,6 +21,14 @@
> >
<el-table-column prop="partner_code" label="伙伴编码" width="140" /> <el-table-column prop="partner_code" label="伙伴编码" width="140" />
<el-table-column prop="legal_name" label="企业名称" min-width="180" /> <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="unified_credit_code" label="统一社会信用代码" min-width="180" />
<el-table-column prop="contact_name" label="联系人" width="110" /> <el-table-column prop="contact_name" label="联系人" width="110" />
<el-table-column prop="contact_phone" label="联系电话" width="140" /> <el-table-column prop="contact_phone" label="联系电话" width="140" />
@ -26,6 +40,11 @@
<el-form :model="form" label-width="130px"> <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.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="企业名称" 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.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_name" /></el-form-item>
<el-form-item label="联系电话"><el-input v-model="form.contact_phone" /></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 pagination = reactive({ currentPage: 1, pageSize: 20, total: 0 })
const statusField = computed(() => `${props.role}_status`) 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: '停用' } const STATUS_LABELS = { active: '启用', suspended: '暂停', eliminated: '淘汰', pending: '待审核', inactive: '停用' }
// 未收录的状态原样显示,不伪装成某个已知状态。 // 未收录的状态原样显示,不伪装成某个已知状态。
function statusFormatter(row, column, cellValue) { return STATUS_LABELS[cellValue] || cellValue } function statusFormatter(row, column, cellValue) { return STATUS_LABELS[cellValue] || cellValue }
const form = reactive(emptyForm()) 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) { async function load(currentPage = pagination.currentPage) {
loading.value = true loading.value = true
try { try {
const offset = (currentPage - 1) * pagination.pageSize 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 || [] rows.value = response.data?.items || []
pagination.total = response.data?.total || 0 pagination.total = response.data?.total || 0
pagination.currentPage = currentPage pagination.currentPage = currentPage
@ -77,11 +101,16 @@ async function load(currentPage = pagination.currentPage) {
} }
} }
// 换角色等于换了一份数据集,页码必须归零,否则会停在上一个角色的第 N 页上 // 换角色等于换了一份数据集,页码必须归零,否则会停在上一个角色的第 N 页上
watch(() => props.role, () => load(1)) watch(() => props.role, () => { categoryFilter.value = ''; load(1) })
// 用 replaceAllKeys 而不是 Object.assign:后者删不掉上次编辑残留的 id, // 用 replaceAllKeys 而不是 Object.assign:后者删不掉上次编辑残留的 id,
// 新增时会把别人的 id 一起提交上去。 // 新增时会把别人的 id 一起提交上去。
function openCreate() { editingId.value = null; replaceAllKeys(form, emptyForm()); dialogVisible.value = true } 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() { function payload() {
return { return {
...form, ...form,
@ -104,4 +133,8 @@ onMounted(() => load(1))
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; } .page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
.page-header h2 { margin: 0 0 6px; } .page-header h2 { margin: 0 0 6px; }
.page-header p { margin: 0; color: #909399; } .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> </style>

View File

@ -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 { if err != nil {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return

View 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)
}
})
}
}

View File

@ -1,19 +1,21 @@
package model package model
type BusinessPartner struct { type BusinessPartner struct {
ID int64 `json:"id"` ID int64 `json:"id"`
OrganizationID int64 `json:"organization_id"` OrganizationID int64 `json:"organization_id"`
BusinessPartnerID int64 `json:"business_partner_id"` BusinessPartnerID int64 `json:"business_partner_id"`
PartnerCode string `json:"partner_code"` PartnerCode string `json:"partner_code"`
LegalName string `json:"legal_name"` LegalName string `json:"legal_name"`
UnifiedCreditCode string `json:"unified_credit_code"` UnifiedCreditCode string `json:"unified_credit_code"`
ContactName string `json:"contact_name"` ContactName string `json:"contact_name"`
ContactPhone string `json:"contact_phone"` ContactPhone string `json:"contact_phone"`
Address string `json:"address"` Address string `json:"address"`
IsSupplier bool `json:"is_supplier"` IsSupplier bool `json:"is_supplier"`
IsCustomer bool `json:"is_customer"` IsCustomer bool `json:"is_customer"`
SupplierStatus string `json:"supplier_status"` SupplierStatus string `json:"supplier_status"`
CustomerStatus string `json:"customer_status"` CustomerStatus string `json:"customer_status"`
SettlementCurrency string `json:"settlement_currency"` SupplierCategories []string `json:"supplier_categories"`
PaymentTerms string `json:"payment_terms"` CustomerCategories []string `json:"customer_categories"`
SettlementCurrency string `json:"settlement_currency"`
PaymentTerms string `json:"payment_terms"`
} }

View File

@ -4,6 +4,7 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"strings"
"github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/pkg/database" "github.com/wmms/wmms/pkg/database"
@ -13,7 +14,7 @@ type PartnerRepository struct{}
func NewPartnerRepository() *PartnerRepository { return &PartnerRepository{} } 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 filter := `FROM organization_partners op
@ -26,6 +27,23 @@ func (r *PartnerRepository) List(organizationID int64, role string, limit, offse
if role == "customer" { if role == "customer" {
filter += ` AND op.is_customer = TRUE` 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 var total int64
if err := database.DB.QueryRow(`SELECT COUNT(*) `+filter, args...).Scan(&total); err != nil { 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, ''), COALESCE(bp.contact_phone, ''), COALESCE(bp.address, ''),
op.is_supplier, op.is_customer, COALESCE(op.supplier_status, ''), op.is_supplier, op.is_customer, COALESCE(op.supplier_status, ''),
COALESCE(op.customer_status, ''), COALESCE(op.settlement_currency, ''), COALESCE(op.customer_status, ''), COALESCE(op.settlement_currency, ''),
COALESCE(op.payment_terms, '') COALESCE(op.payment_terms, ''), op.supplier_categories, op.customer_categories
` + filter + ` ` + filter + `
ORDER BY op.partner_code ORDER BY op.partner_code
LIMIT ? OFFSET ?` LIMIT ? OFFSET ?`
@ -51,12 +69,15 @@ func (r *PartnerRepository) List(organizationID int64, role string, limit, offse
items := make([]model.BusinessPartner, 0) items := make([]model.BusinessPartner, 0)
for rows.Next() { for rows.Next() {
var item model.BusinessPartner var item model.BusinessPartner
var supplierCategories, customerCategories string
if err := rows.Scan(&item.ID, &item.OrganizationID, &item.BusinessPartnerID, &item.PartnerCode, if err := rows.Scan(&item.ID, &item.OrganizationID, &item.BusinessPartnerID, &item.PartnerCode,
&item.LegalName, &item.UnifiedCreditCode, &item.ContactName, &item.ContactPhone, &item.LegalName, &item.UnifiedCreditCode, &item.ContactName, &item.ContactPhone,
&item.Address, &item.IsSupplier, &item.IsCustomer, &item.SupplierStatus, &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 return nil, 0, err
} }
item.SupplierCategories = splitPartnerCategories(supplierCategories)
item.CustomerCategories = splitPartnerCategories(customerCategories)
items = append(items, item) items = append(items, item)
} }
return items, total, rows.Err() 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 result, err = tx.Exec(`INSERT INTO organization_partners
(organization_id, business_partner_id, partner_code, is_supplier, is_customer, (organization_id, business_partner_id, partner_code, is_supplier, is_customer,
supplier_status, customer_status, settlement_currency, payment_terms) supplier_status, customer_status, settlement_currency, payment_terms, supplier_categories, customer_categories)
VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''))`, VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), ?, ?)`,
item.OrganizationID, item.BusinessPartnerID, item.PartnerCode, item.IsSupplier, item.IsCustomer, 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 { if err != nil {
return err 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 = ?, if _, err = tx.Exec(`UPDATE organization_partners SET is_supplier = ?, is_customer = ?,
supplier_status = NULLIF(?, ''), customer_status = NULLIF(?, ''), 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.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 return err
} }
if err = tx.Commit(); err != nil { if err = tx.Commit(); err != nil {
@ -138,3 +163,17 @@ func (r *PartnerRepository) Update(item *model.BusinessPartner) (err error) {
} }
return nil 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, ",")
}

View File

@ -1,6 +1,8 @@
package repository package repository
import ( import (
"encoding/json"
"reflect"
"regexp" "regexp"
"testing" "testing"
@ -24,6 +26,9 @@ func TestPartnerCreateSeparatesLegalEntityAndOrganizationRelationship(t *testing
IsSupplier: true, IsSupplier: true,
SupplierStatus: "active", SupplierStatus: "active",
} }
if err := json.Unmarshal([]byte(`{"supplier_categories":["material","finished_product"],"customer_categories":[]}`), partner); err != nil {
t.Fatal(err)
}
mock.ExpectBegin() mock.ExpectBegin()
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO business_partners mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO business_partners
@ -33,9 +38,9 @@ func TestPartnerCreateSeparatesLegalEntityAndOrganizationRelationship(t *testing
WillReturnResult(sqlmock.NewResult(9, 1)) WillReturnResult(sqlmock.NewResult(9, 1))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO organization_partners mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO organization_partners
(organization_id, business_partner_id, partner_code, is_supplier, is_customer, (organization_id, business_partner_id, partner_code, is_supplier, is_customer,
supplier_status, customer_status, settlement_currency, payment_terms) supplier_status, customer_status, settlement_currency, payment_terms, supplier_categories, customer_categories)
VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''))`)). VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), ?, ?)`)).
WithArgs(int64(1), int64(9), "SUP-001", true, false, "active", "", "", ""). WithArgs(int64(1), int64(9), "SUP-001", true, false, "active", "", "", "", "material,finished_product", "").
WillReturnResult(sqlmock.NewResult(15, 1)) WillReturnResult(sqlmock.NewResult(15, 1))
mock.ExpectCommit() mock.ExpectCommit()
@ -49,3 +54,70 @@ func TestPartnerCreateSeparatesLegalEntityAndOrganizationRelationship(t *testing
t.Fatal(err) 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)
}
})
}
}

View File

@ -211,11 +211,11 @@ type PartnerService struct{ repo *repository.PartnerRepository }
func NewPartnerService() *PartnerService { func NewPartnerService() *PartnerService {
return &PartnerService{repo: repository.NewPartnerRepository()} 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" { if role != "" && role != "supplier" && role != "customer" {
return nil, 0, errors.New("role must be supplier or 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, // Create 新建伙伴。请求体可能带着前端表单残留的 id,这里不读取 item.ID,
@ -255,6 +255,23 @@ func normalizePartner(item *model.BusinessPartner) error {
if !item.IsSupplier && !item.IsCustomer { if !item.IsSupplier && !item.IsCustomer {
return errors.New("at least one partner role is required") 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 == "" { if item.IsSupplier && item.SupplierStatus == "" {
item.SupplierStatus = "active" item.SupplierStatus = "active"
} }

View 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")
}
})
}
}

View File

@ -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;

View File

@ -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 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 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 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 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 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; 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 -- 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 -- text when the client was latin1, and it corrupts (or destroys non-latin1
-- characters such as "→") when the client is already utf8mb4. -- 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;

View File

@ -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;

View File

@ -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;

Binary file not shown.