diff --git a/DEPLOY.md b/DEPLOY.md index f7a1d629..d1089efd 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -112,13 +112,13 @@ MySQL 官方镜像**只在数据目录为空时**才执行 `/docker-entrypoint-i Error 1054 (42S22): Unknown column 'inbound_no' in 'field list' ``` -**部署前先确认卷的年龄**: +**部署前先确认现有表结构**: ```bash -docker volume inspect warehouse-mms_mysql_data --format '{{.CreatedAt}}' +docker compose exec db mysql -uroot -pwmms1234 -e "SHOW COLUMNS FROM inbound_orders; SHOW COLUMNS FROM outbound_orders; SHOW COLUMNS FROM material_categories;" wmms ``` -把结果和最近一次改 schema 的提交时间对比:卷比代码新就没问题,卷比代码旧就要按下节处理。 +将实际字段与当前 `scripts/init.sql` 对比。数据卷的创建时间只能作为排查线索,不能证明表结构是否一致。 **方式一:清空重建(推荐,最简单可靠)** @@ -132,23 +132,11 @@ docker compose down -v && docker compose up -d --build docker compose exec db mysqldump -uroot -pwmms1234 --default-character-set=utf8mb4 wmms > backup.sql ``` -**方式二:跑增量脚本(需要保留数据时)** +**方式二:保留数据并更新结构** -`scripts/` 下有三个手工迁移脚本,按需执行: +需要保留业务数据时,先备份数据库,再根据实际表结构与 `scripts/init.sql` 的差异编写并审核增量 SQL,在备份副本上验证后执行。 -| 脚本 | 作用 | 是否破坏数据 | -|------|------|--------------| -| `add_order_display_fields.sql` | 出入库单补 `inbound_no` / `outbound_no` / `operator` / `remark` | 否,仅加列并回填单号 | -| `update_material_categories.sql` | 重建分类树并写入编码规则 | **是**,会清空物料、单据、库存、流水 | -| `update_warning_stock_zero.sql` | 清理阈值为 0 的历史预警规则 | **是**,会删除这些规则行 | - -执行方式: - -```bash -docker compose exec -T db mysql -uroot -pwmms1234 --default-character-set=utf8mb4 wmms < scripts/xxx.sql -``` - -执行前请先备份,并阅读脚本头部的说明。 +当前 `scripts/init.sql` 用于空库首次初始化,已包含出入库单号、操作人、备注、分类说明、6 个大类和 28 个子类,以及对应编码规则。库存预警阈值为 0 是有效配置,新库无需清理历史规则。不要将完整初始化脚本直接用于已有业务数据的数据库。 ## 环境变量 @@ -207,12 +195,12 @@ Error 1054 (42S22): Unknown column 'description' in 'field list' **确认**: ```bash -docker volume inspect warehouse-mms_mysql_data --format '{{.CreatedAt}}' +docker compose exec db mysql -uroot -pwmms1234 -e "SHOW COLUMNS FROM material_categories;" wmms ``` -如果卷的创建时间早于最近一次改 schema 的提交,就是这个原因。 +检查报错涉及的表是否缺少字段,并与当前 `scripts/init.sql` 对比。 -**解决**:确认库里无业务数据后执行 `docker compose down -v && docker compose up -d --build` 重建;需要保留数据则改跑 `scripts/` 下的增量脚本。 +**解决**:确认数据库和上传文件均无需保留后,可执行 `docker compose down -v && docker compose up -d --build` 重建;需要保留数据时,先备份,再按实际结构差异准备并验证增量 SQL。 ### 其他排查技巧 diff --git a/docs/MMS-用户故事-v2.3.md b/docs/MMS-用户故事-v2.3.md index 780a91cc..d12db497 100644 --- a/docs/MMS-用户故事-v2.3.md +++ b/docs/MMS-用户故事-v2.3.md @@ -210,7 +210,7 @@ - 列表顶部提供大类、小类、物料类型、状态和关键词筛选。 - 关键词支持对物料编码、名称、规格和制造商型号进行模糊搜索。 - 小类选项随所选大类联动;清除大类时同时清除无效的小类条件。 -- 点击物料进入详情页;技术人员可在详情页修改或作废,采购人员可在详情页维护供应商信息。 +- 点击列表中的“详情”先只读查看物料;有修改权限的人员可在详情中点击“编辑”进入编辑状态,保存后回到只读详情,取消编辑或关闭时丢弃未保存内容。采购人员可在详情页维护供应商信息。 - 列表提供单个新增、批量导入和批量导出入口,并根据角色权限显示。 #### 故事 #MD-003:物料主数据修改 @@ -237,7 +237,7 @@ 验收标准: - 作废物料不得用于新建普通入库、出库、调拨、预留、BOM 和制造订单。 -- 物料作废只允许在详情页执行,列表页不提供直接作废按钮。 +- 列表操作列保留“作废物料”或“撤销作废”按钮,以按钮文案区分当前状态;有权限的人员可直接操作,作废前必须二次确认。 - 作废不影响历史查询和合法的撤销过账(冲销)。 - 已存在账面、预留、锁定或在途库存时必须提示风险并禁止直接作废,处理完库存后方可作废。 diff --git a/docs/superpowers/specs/2026-09-22-bom-management-design.md b/docs/superpowers/specs/2026-09-22-bom-management-design.md index 6fdefb02..c0318e1e 100644 --- a/docs/superpowers/specs/2026-09-22-bom-management-design.md +++ b/docs/superpowers/specs/2026-09-22-bom-management-design.md @@ -19,8 +19,8 @@ - 通过树形界面手工维护 BOM。 - 为成品或半成品建立多个 BOM 版本。 - 创建首个 BOM 草稿。 -- 查看 BOM 列表、结构和历史版本。 -- 基于指定历史版本完整复制明细,生成新草稿。 +- 从物料列表进入指定物料的 BOM 页面,查看结构和历史版本。 +- 新建后续版本时,可基于指定历史版本完整复制明细,或创建空白草稿。 - 编辑、保存和删除草稿。 - 生效草稿或停用指定的生效版本。 - 自动校验重复子项、自引用、循环引用、无效数量和最大层级。 @@ -46,11 +46,11 @@ BOM 版本仅使用以下三个状态: -| 状态 | 含义 | 允许操作 | -| --- | --- | --- | -| `draft` | 草稿 | 查看、编辑、保存、生效、删除 | -| `effective` | 可用 | 查看、被新 BOM 选择、停用、基于该版本新建草稿 | -| `obsolete` | 已停用 | 查看历史、基于该版本新建草稿;不能用于新 BOM | +| 状态 | 含义 | 允许操作 | +| ----------- | --- | ------------------------- | +| `draft` | 草稿 | 查看、编辑、保存、生效、删除 | +| `effective` | 可用 | 查看、被新 BOM 选择、停用、基于该版本新建草稿 | +| `obsolete` | 已停用 | 查看历史、基于该版本新建草稿;不能用于新 BOM | 规则如下: @@ -58,7 +58,7 @@ BOM 版本仅使用以下三个状态: 2. 同一父物料可以同时存在多个 `effective`(可用)版本。 3. 默认版本是尚未停用且最近一次生效的版本,按生效时间排序;同一时间时按版本 ID 确定唯一结果。停用默认版本后,剩余可用版本中最近生效的版本成为默认版本;全部停用时无默认版本。 4. 新版本生效不改变其他可用版本的状态。非默认的可用版本仍可被新建的上层 BOM 明确选择。 -5. 可用版本不可直接修改。修改可用或已停用版本时,必须先完整复制所选版本,生成新的草稿版本。 +5. 可用版本不可直接修改。变更结构时应创建新的草稿版本,可复制指定旧版,也可从空白开始。 6. 已停用版本不能恢复为可用,但可以作为新版本的复制来源;它不能被新建的上层 BOM 选用。 7. 草稿可删除;可用和已停用版本只保留、不物理删除。 @@ -121,16 +121,13 @@ BOM 版本仅使用以下三个状态: ## 4. 前端信息架构 -左侧导航在“物料主数据”和“公司组织”之间增加独立大类: +左侧导航沿用“物料主数据”入口。BOM 维护由物料列表逐行进入,不增加独立的“BOM 管理”菜单: ```text 物料主数据 ├─ 物料 └─ 基础配置 -BOM 管理 - └─ BOM 维护 - 公司组织 ├─ 组织管理 ├─ 用户管理 @@ -145,16 +142,17 @@ BOM 管理 现有独立菜单“日志记录”改为左侧导航大类。现有物料日志页面归入“物料变更日志”,BOM 事件展示在新建的“BOM 记录”子页面。原有 `/logs` 地址应保持可访问,并跳转到物料变更日志页面,避免旧链接失效。 -### 4.1 BOM 维护 +### 4.1 物料列表中的 BOM 入口 -BOM 维护统一包含以下四类操作: +现有物料列表继续负责搜索物料、查看详情、编辑和作废。在符合 BOM 父物料条件的成品或半成品行增加明显的“BOM”按钮,放在独立的窄列中,避免挤占现有操作列。点击后进入该物料的独立 BOM 页面。现有“物料详情”弹窗保持原有用途;BOM 页面显示当前物料的编码、名称、规格、基础单位和状态,并提供“返回物料列表”。已作废物料若已有 BOM,仍可进入页面查看历史版本,但不能新建或生效版本。 ```text -BOM 列表 - → 查询、查看结构、查看历史版本、发起新版本 +物料主数据 → 物料列表 → 选择整机 A 所在行的 [BOM] + ↓ + 整机 A 的 BOM 页面 新建 BOM - → 为尚未建立 BOM 的父物料创建第一个草稿版本 + → 为当前物料创建第一个草稿版本 编辑草稿 → 编辑明细、保存草稿、删除草稿 @@ -167,37 +165,59 @@ BOM 列表 树形结构维护是编辑页面的交互方式,不作为独立功能入口。 -### 4.2 BOM 列表 +### 4.2 单个物料的 BOM 页面 -列表以父物料为主行,避免历史版本过多导致列表膨胀: +页面只管理入口物料的 BOM,不再建立与物料列表重复的 BOM 总列表。顶部始终显示版本选择框:已有版本时列出该物料的全部草稿、可用和已停用版本,并在选项中标明状态和默认版本;选项按创建时间从新到旧排列。首次进入时优先选中默认可用版本,没有可用版本时选中最近创建的版本。没有任何版本时,版本框显示“无”且不可展开,旁边提供“新建 BOM”按钮: ```text -父项物料 默认版本 可用版本数 草稿版本 操作 -整机 A V2 2 V3 查看结构|编辑草稿|历史版本 -电机组件 V1 1 无 查看结构|新建版本|历史版本 +← 返回物料列表 整机 A · A-001 有效 + +BOM 版本:[V2 · 可用·默认 ▼] [新建版本] +当前版本状态:可用 [停用] + +▼ 整机 A × 1 + ├─ 外壳组件 × 1 + └─ 电机组件 × 2 ``` -点击列表中的“历史版本”,弹出独立窗口展示该父物料的全部版本,不在列表行内展开,也不使用侧边抽屉。窗口内可查看版本结构,或选择指定版本发起新版本: +选择草稿版本时,操作按钮显示在同一状态行的位置: ```text -┌─ 整机 A · 历史版本 ─────────────────────────────── [关闭] ┐ -│ 版本 状态 操作 │ -│ V3 草稿 查看结构|编辑草稿 │ -│ V2 可用·默认 查看结构|新建版本|停用 │ -│ V1 可用 查看结构|新建版本|停用 │ -└─────────────────────────────────────────────────────┘ +← 返回物料列表 整机 A · A-001 有效 + +BOM 版本:[V3 · 草稿 ▼] [新建版本] +当前版本状态:草稿 [编辑] [生效] [删除草稿] + +▼ 整机 A × 1 + ├─ 外壳组件 × 1 + └─ 电机组件 × 2 ``` -列表至少支持: +无版本时沿用相同布局: -- 按父项物料编码或名称查询。 -- 按是否存在草稿、是否存在可用版本筛选。 -- 查看当前或历史版本结构。 +```text +← 返回物料列表 整机 A · A-001 有效 + +BOM 版本:[无] [新建 BOM] + +暂无 BOM 结构 +``` + +创建首个草稿后,版本选择框自动选中该草稿并显示其结构。 + +切换版本后,页面直接加载该版本的完整结构和节点明细,不再要求点击“查看结构”。选中草稿时,顶部状态行显示“编辑”“生效”“删除草稿”;点击“编辑”才进入草稿的树形编辑状态。选中可用版本时,该位置显示“停用”;已停用版本只读,不显示编辑、停用或生效按钮。切换前若草稿存在尚未保存的修改,应先提示保存或放弃,避免丢失内容。 + +物料列表及该物料 BOM 页面共同支持: + +- 在现有物料列表按编码或名称查找父物料,并从该行进入 BOM 页面。 +- 通过版本选择框查看当前或历史版本结构。 - 编辑已有草稿。 -- 从指定历史版本复制并发起新版本。 +- 发起新版本,并选择复制旧版或空白创建。 - 执行当前状态允许的状态操作。 -“查看结构”页面固定按父物料数量 `1` 展示当前选定 BOM 版本的完整层级和各层用量,不提供数量输入或最底层物料汇总。 +建议页面路由为 `/materials/:materialId/bom`。从树中的子物料进入其 BOM 时,也使用同一路由并传入该子物料 ID。物料详情弹窗无需承载多层 BOM 编辑。 + +版本选择框下方的结构区域固定按父物料数量 `1` 展示所选 BOM 版本的完整层级和各层用量,不提供数量输入或最底层物料汇总。 ```text 整机 A × 1 @@ -211,57 +231,71 @@ BOM 列表 ### 4.3 新建 BOM -新建 BOM 只面向尚未建立 BOM 的成品或半成品。页面包含: +新建 BOM 只面向尚未建立 BOM 的成品或半成品。入口物料已由物料列表确定,页面包含: -- 父项物料选择。 +- 当前父物料信息,只读展示,不再重复选择。 - 初始版本号。 - 直接子物料树或列表。 - 子项单位用量、基础单位、损耗率和备注。 - 保存草稿。 -已存在 BOM 的父物料不能再次走“新建 BOM”,应从列表或历史版本选择来源并执行“新建版本”。 +已存在 BOM 的父物料不能再次走“新建 BOM”,应从该物料的 BOM 页面点击“新建版本”,再选择复制旧版或空白创建。 ### 4.4 编辑草稿 -编辑页采用左侧结构树、右侧节点明细的布局: +编辑页采用左侧结构树、右侧节点明细的布局。点击任意物料节点,右侧先以只读方式展示该节点的物料信息、用量、损耗率、备注和下层 BOM 版本: ```text -┌───────────────────────┬──────────────────────────────┐ -│ BOM 结构树 │ 当前节点明细 │ -│ │ │ -│ ▼ 整机 A │ 子物料:电机组件 │ -│ ├─ 外壳组件 × 1 │ 单位用量:2 │ -│ ├─ 电机组件 × 2 │ 基础单位:套 │ -│ └─ 控制箱 × 1 │ 损耗率:0% │ -│ │ 备注:安装位置说明 │ -│ [+ 添加子物料] │ │ -└───────────────────────┴──────────────────────────────┘ +┌────────────────────────┬─────────────────────────────┐ +│ BOM 结构树 │ 当前节点明细 │ +│ ▼ 整机 A │ 电机组件 │ +│ ├─ 外壳组件 × 1 │ 物料编码:DJ-001 │ +│ ├─ 电机组件 × 2 ← │ 单位用量:2 套 │ +│ │ └─ 螺丝 × 4 │ 损耗率:0% │ +│ └─ 控制箱 × 1 │ 下层 BOM:V2 │ +│ [+ 添加子物料] │ 备注:安装位置说明 │ +│ │ [编辑] │ +└────────────────────────┴─────────────────────────────┘ +``` + +“添加子物料”只显示在根物料的直接子项列表末尾,用来为当前 BOM 添加第二层节点;第三层及更深层节点不显示此按钮。下层子物料应进入对应子 BOM 的草稿页面维护,再由当前 BOM 选择和锁定该子 BOM 版本。 + +添加子物料时只能搜索并选择当前组织中已存在的物料。如果没有找到目标物料,页面提示先到“物料主数据 → 物料”新增,新增完成后再返回 BOM 草稿继续添加;BOM 页面不自动创建物料主数据。 + +选中当前 BOM 的直接子项且当前版本为草稿时,右侧显示“编辑”按钮。点击后,右侧明细进入编辑模式,显示可编辑的单位用量、损耗率、备注和下层 BOM 版本,并提供“删除该子物料”“取消”“保存”操作;未点击“编辑”前不修改字段,也不显示删除入口。删除前二次确认,只删除当前草稿中选中的这一条直接子项,不删除物料主数据或子物料自身的 BOM。选中第三层及更深层节点时,右侧只读展示明细,并提供“前往该物料 BOM”入口。可用和已停用版本中的所有节点均只读。 + +```text +右侧明细:查看模式 [编辑] + ↓ +右侧明细:编辑模式 [删除该子物料] [取消] [保存] ``` 编辑草稿允许: -- 添加、修改、删除和排序直接子物料。 +- 添加、修改和排序直接子物料;删除直接子项必须先进入右侧明细的编辑模式。 - 修改用量、损耗率和备注。 - 保存草稿。 - 删除整个草稿。 -对于树中引用的下层 BOM,草稿编辑页提供可用版本选择器,并默认选择最近一次生效的版本。页面展示所选版本的结构,并提供跳转到该物料 BOM 的入口;不在上层 BOM 中直接修改下层 BOM。可用版本停用后,已锁定它的历史上层版本仍可查看和展开;尚未生效的上层草稿必须改选可用版本后才能生效。 +对于树中引用的下层 BOM,直接子项进入编辑模式后提供可用版本选择器,并默认选择最近一次生效的版本。页面展示所选版本的结构,并提供跳转到该物料 BOM 的入口;不在上层 BOM 中直接修改下层 BOM。可用版本停用后,已锁定它的历史上层版本仍可查看和展开;尚未生效的上层草稿必须改选可用版本后才能生效。 ### 4.5 新建版本 -“新建版本”从 BOM 列表或历史版本发起,不属于编辑草稿内部操作: +“新建版本”从当前物料的 BOM 页面发起,不属于编辑草稿内部操作。点击后先弹出创建方式选择窗口: ```text -选择一个历史版本 - ↓ -完整复制该版本的 BOM 明细 - ↓ -生成新的草稿版本 - ↓ -进入草稿编辑页 +┌─ 新建整机 A 的 BOM 版本 ──────────────────────────┐ +│ │ +│ [复制旧版] 推荐:沿用现有结构,再修改差异 │ +│ 选择来源版本:[V2 · 可用 ▼] │ +│ │ +│ 空白创建 从零开始添加子物料 │ +│ │ +│ [取消] [创建草稿] │ +└───────────────────────────────────────────────────┘ ``` -每次只复制一个明确选中的来源版本,不复制该父物料的全部历史版本。 +“复制旧版”作为醒目的默认选项,其“来源版本”使用弹窗内的下拉框选择,列出该父物料全部已有版本并标明状态;默认预选当前 BOM 页面正在查看的版本,也允许在下拉框中改选其他版本,不再打开第二个版本选择窗口。创建时完整复制所选版本的明细,并记录来源版本。“空白创建”使用普通样式,不复制明细,来源版本为空。两种方式都生成新的草稿版本并进入草稿编辑页;每次只创建一个版本。 ### 4.6 状态操作 @@ -288,29 +322,17 @@ BOM 列表 ## 5. 数据模型建议 -第一版建议使用三个 BOM 核心表,以及独立的 BOM 操作记录表。现有“物料变更日志”依赖物料 ID 和物料变更字段,不能直接承载 BOM 版本事件。 +第一版使用现有 `materials` 作为父物料和子物料主数据,新增 `bom_versions`、`bom_items` 两个 BOM 核心表,以及独立的 BOM 操作记录表。现有“物料变更日志”依赖物料 ID 和物料变更字段,不能直接承载 BOM 版本事件。 -### 5.1 `bom_headers` +一个父物料由 `materials.id` 标识,版本表直接引用它。出现在 BOM 中的父物料和所有子物料必须先在 `materials` 表中存在;如果尚未录入,应先到物料主数据新增物料,再返回 BOM 页面选择。物料名称、编码、类别和基础单位仍由物料主数据维护,BOM 表不重复保存。 -代表某个父物料的 BOM 主体,而不是具体版本。 +### 5.1 `bom_versions` 建议字段: - `id` - `organization_id` -- `parent_material_id` -- `created_at` -- `updated_at` - -约束:同一组织下 `parent_material_id` 唯一。 - -### 5.2 `bom_versions` - -建议字段: - -- `id` -- `organization_id` -- `bom_header_id` +- `parent_material_id`,引用 `materials.id`。 - `version` - `status` - `source_version_id`,记录复制来源,可为空。 @@ -323,10 +345,10 @@ BOM 列表 约束: -- 同一 `bom_header_id` 下版本号唯一。 -- 为 `bom_header_id`、`status` 和 `effective_at` 建立查询索引。默认版本由可用版本的生效时间和版本 ID 确定,不单独保存可变的默认标记。 +- 同一组织、同一 `parent_material_id` 下版本号唯一;父物料必须属于同一组织,且为允许建立 BOM 的成品或半成品。 +- 为 `organization_id`、`parent_material_id`、`status` 和 `effective_at` 建立查询索引。默认版本由可用版本的生效时间和版本 ID 确定,不单独保存可变的默认标记。 -### 5.3 `bom_items` +### 5.2 `bom_items` 建议字段: @@ -338,16 +360,29 @@ BOM 列表 - `unit_id` - `loss_rate` - `remark` -- `locked_child_version_id` +- `child_bom_version_id`,引用所选子物料本身的 BOM 版本;子物料本身无下层 BOM 时为空。 - `sort_order` - `created_at` - `updated_at` -约束:同一 `bom_version_id` 下 `child_material_id` 唯一。 +约束:同一 `bom_version_id` 下 `child_material_id` 唯一。子物料引用 `materials.id`,必须与该 BOM 版本属于同一组织;`child_bom_version_id` 必须指向以该子物料为父物料的 BOM 版本。草稿可选择可用版本,草稿生效后该引用不可修改。 树形层级不直接存成固定层级字段。系统通过各父物料 BOM 版本之间的引用递归组成完整树,因此支持 10 层或 20 层时不需要改变表结构。 -### 5.4 BOM 操作记录 +查询某个整机 BOM 的结构时,按以下关系逐层查找: + +```text +整机 materials.id + → 按 bom_versions.parent_material_id 查整机的版本,并选择要查看的版本 + → 用该版本的 bom_versions.id 查 bom_items.bom_version_id,取得全部直接子项 + → 用每条 bom_items.child_material_id 查 materials,取得子物料主数据 + → 用该条 bom_items.child_bom_version_id 查已选定的下层 BOM 版本 + → 用下层版本的 bom_versions.id 继续查它的 bom_items +``` + +`child_bom_version_id` 为空时,该子项不再往下展开;有值时按这个固定版本继续查询。新增或编辑草稿时,系统才根据 `child_material_id` 查询该子物料的可用 BOM 版本,默认预选最近生效的版本,并允许用户选择其他可用版本。保存所选版本 ID 后,查看已保存的 BOM 不再重新选择默认版本,因此下层 BOM 后续推出新版本也不会改变当前 BOM 的结构。查看历史 BOM 时仍按当时保存的版本引用展开。 + +### 5.3 BOM 操作记录 独立记录每次 BOM 状态变化,至少保存经营组织、父物料、BOM 版本、操作类型、操作人、操作时间和说明。生效与停用各记录一次;默认版本随这些状态变化而自动重新计算,不生成额外的停用记录。 @@ -371,19 +406,19 @@ handler 接口草案: -| 方法 | 路径 | 用途 | -| --- | --- | --- | -| `GET` | `/api/boms` | 查询 BOM 主列表 | -| `POST` | `/api/boms` | 创建首个草稿版本 | -| `GET` | `/api/boms/:id` | 查看 BOM 主体及当前版本摘要 | -| `GET` | `/api/boms/:id/versions` | 查看历史版本 | -| `GET` | `/api/bom-versions/:versionId` | 查看指定版本和结构 | -| `PUT` | `/api/bom-versions/:versionId` | 保存草稿 | -| `DELETE` | `/api/bom-versions/:versionId` | 删除草稿 | -| `POST` | `/api/bom-versions/:versionId/copy` | 从指定版本生成新草稿 | -| `POST` | `/api/bom-versions/:versionId/effective` | 生效草稿 | -| `POST` | `/api/bom-versions/:versionId/obsolete` | 停用生效版本 | -| `GET` | `/api/bom-versions/:versionId/tree` | 按父项数量 `1` 查看完整 BOM 结构 | +| 方法 | 路径 | 用途 | +| -------- | ---------------------------------------- | ---------------------- | +| `POST` | `/api/boms` | 为指定父物料创建首个草稿版本 | +| `GET` | `/api/boms/:materialId` | 按父物料 ID 查看 BOM 及默认版本摘要 | +| `GET` | `/api/boms/:materialId/versions` | 按父物料 ID 查看全部版本 | +| `POST` | `/api/boms/:materialId/versions` | 为已有 BOM 创建空白草稿版本 | +| `GET` | `/api/bom-versions/:versionId` | 查看指定版本和结构 | +| `PUT` | `/api/bom-versions/:versionId` | 保存草稿 | +| `DELETE` | `/api/bom-versions/:versionId` | 删除草稿 | +| `POST` | `/api/bom-versions/:versionId/copy` | 从指定版本生成新草稿 | +| `POST` | `/api/bom-versions/:versionId/effective` | 生效草稿 | +| `POST` | `/api/bom-versions/:versionId/obsolete` | 停用生效版本 | +| `GET` | `/api/bom-versions/:versionId/tree` | 按父项数量 `1` 查看完整 BOM 结构 | 接口命名在实施计划阶段可按现有路由风格进一步统一,但业务边界不应改变。 @@ -391,12 +426,12 @@ handler 前端校验用于即时反馈,后端校验是最终可信规则。 -| 时机 | 校验内容 | -| --- | --- | -| 选择子物料 | 当前父节点重复、自引用、物料状态 | -| 保存草稿 | 数量、损耗率、基础单位、重复、自引用、循环、20 层限制 | -| 生效 | 完整重复执行保存校验,并验证所选下层版本仍可用 | -| 结构树查询 | 循环保护、20 层限制、版本引用完整性 | +| 时机 | 校验内容 | +| ----- | ---------------------------- | +| 选择子物料 | 当前组织物料存在、当前父节点重复、自引用、物料状态 | +| 保存草稿 | 数量、损耗率、基础单位、重复、自引用、循环、20 层限制 | +| 生效 | 完整重复执行保存校验,并验证所选下层版本仍可用 | +| 结构树查询 | 循环保护、20 层限制、版本引用完整性 | 错误信息需要包含: @@ -439,6 +474,7 @@ handler ### 9.1 后端 - 创建首个草稿和保存明细。 +- 父物料和子物料必须存在于物料主数据且属于同一组织;不存在的物料 ID 无法创建 BOM。 - 同一父物料版本号重复。 - 同一版本直接子项重复。 - 父项直接引用自身。 @@ -457,14 +493,22 @@ handler ### 9.2 前端 -- 导航位置、超级管理员可见性和路由保护。 -- 列表查询、主行展示,以及点击“历史版本”后打开独立弹窗查看全部版本。 -- 新建 BOM 时只允许选择尚未建立 BOM 的成品或半成品。 +- 物料列表仅对符合条件的父物料显示“BOM”按钮;入口和 BOM 页面仅超级管理员可访问。 +- 从物料列表进入指定物料的独立 BOM 页面;版本选择框列出该物料全部版本并标明状态,切换后直接显示所选版本结构。 +- 没有版本时版本框显示“无”,结构区域显示空状态;创建首个草稿后自动选中该版本。 +- 版本选择框包含全部草稿、可用和已停用版本,约 50 个版本以内可滚动选择;切换后显示该版本结构与对应状态操作。 +- 只有符合条件且尚无版本的成品或半成品,才能从其 BOM 页面创建首个草稿;父物料由入口固定,不再重复选择。 - 草稿可编辑,可用和已停用版本只读。 +- 选中草稿时,状态行显示“编辑”“生效”“删除草稿”;点击顶部“编辑”后才进入树形编辑状态,选中可用版本时仅显示“停用”。 +- 点击节点先在右侧查看明细;仅草稿的直接子项显示“编辑”按钮,点击后才进入编辑模式。 +- 删除子物料入口只在右侧编辑模式出现,删除前确认,且只影响当前草稿中的该条子项。 +- “添加子物料”只在根物料的第二层子项列表末尾出现;更深层节点通过对应子 BOM 维护。 +- 添加子物料时只允许选择当前组织已有物料;搜索不到时提示先新增物料主数据,再返回 BOM 草稿继续添加。 - 添加重复子项和自引用时立即提示。 - 后端返回循环或深度错误时定位并标记问题节点。 - 生效、停用和删除草稿具有明确确认提示。 - 从指定历史版本创建草稿后,明细完整复制且可继续编辑。 +- 点击“新建版本”先弹出方式选择窗口;“复制旧版”醒目且默认选中,来源版本通过弹窗内下拉框选择;“空白创建”可生成无明细草稿。 - “日志记录”展开后显示两个子页面;原 `/logs` 链接仍能打开物料变更日志。 - “BOM 记录”支持筛选与查看生效和停用的操作详情。 @@ -475,7 +519,7 @@ handler 3. 实现复制新版本及版本列表。 4. 实现结构校验、20 层限制和完整循环路径反馈。 5. 实现多版本可用、默认版本推导、生效和停用事务。 -6. 实现前端导航、BOM 列表和历史版本展示。 +6. 在物料列表增加 BOM 入口,实现单个物料的 BOM 页面和完整版本选择框。 7. 实现新建及草稿树形编辑页面。 8. 实现单个 BOM 版本按父项数量 `1` 的结构树查询和各层用量显示。 9. 将现有日志入口改为双子页面,接入 BOM 操作记录和筛选。 @@ -485,7 +529,7 @@ handler 第一版稳定后再分别设计和实现: -1. 多 BOM 需求试算:在独立页面输入一个或多个 BOM 的计划数量,递归计算并汇总相同底层物料需求,再在 `BOM 管理` 下增加菜单。 +1. 多 BOM 需求试算:在独立页面输入一个或多个 BOM 的计划数量,递归计算并汇总相同底层物料需求,再在“物料主数据”下增加菜单。 2. CSV 层级导入和错误报告。 3. 替代料管理。 4. 面向技术人员等角色的细分权限。 diff --git a/docs/数据库表结构与关系说明.md b/docs/数据库表结构与关系说明.md new file mode 100644 index 00000000..f5d4276e --- /dev/null +++ b/docs/数据库表结构与关系说明.md @@ -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;不要直接重复执行完整初始化脚本。 diff --git a/frontend/src/views/Materials.test.js b/frontend/src/views/Materials.test.js index 1ac2dc80..6f459c36 100644 --- a/frontend/src/views/Materials.test.js +++ b/frontend/src/views/Materials.test.js @@ -73,6 +73,10 @@ function codeInput(wrapper) { return wrapper.find('.material-code-field input') } +function inputWithValue(wrapper, value) { + return wrapper.findAll('input').find(input => input.element.value === value) +} + function setupState(wrapper) { return wrapper.vm.$.setupState } @@ -285,10 +289,11 @@ describe('新增物料后列表与搜索框保持一致', () => { await searchBy(wrapper, '螺丝') - setupState(wrapper).showEditModal({ + setupState(wrapper).showDetail({ id: 7, category_id: 12, material_code: 'A010201', name: '螺丝' }) await flushPromises() + await findButton(wrapper, '编辑').trigger('click') await setupState(wrapper).handleSave() await flushPromises() @@ -347,9 +352,7 @@ describe('物料列表的作废入口文案', () => { return wrapper.findAll('button').find(button => button.text().trim() === text) } - // 这一页没有状态列(文案即状态),所以按钮文案本身得说清这条物料怎么了。 - // 「撤销」在中文 UI 里默认指撤销上一步(Ctrl+Z),看不出在撤销什么;「撤销作废」 - // 才读得出「这条被作废了,点这里撤销那次作废」。 + // 作废与恢复仍通过原操作按钮呈现,按钮文案本身说明当前状态。 test('作废的那行显示「撤销作废」,有效的那行显示「作废物料」', async () => { const wrapper = mountMaterials() await flushPromises() @@ -363,10 +366,14 @@ describe('物料列表的作废入口文案', () => { // 注意断言的是行归属,不是「页面上有没有这个词」——两个词都含「作废」。 expect(restore.element.closest('tr').textContent).toContain('旧螺丝') expect(obsolete.element.closest('tr').textContent).toContain('新螺丝') + expect(restore.element.closest('td').textContent).toContain('详情') + expect(obsolete.element.closest('td').textContent).toContain('详情') + expect(restore.element.closest('td').cellIndex).toBe(obsolete.element.closest('td').cellIndex) + expect(wrapper.findAll('th').some(header => header.text().includes('作废状态'))).toBe(false) wrapper.unmount() }) - // 两个按钮轮流出现在同一列,宽度不一样会让「编辑」右边参差不齐。这里靠的是两边 + // 两个按钮轮流出现在操作列,宽度不一样会让按钮参差不齐。这里靠的是两边 // 都是 4 个中文字,同字体同字号下自然等宽——jsdom 不做布局计算,量不了像素,但 // 字数不等就一定会参差,所以把字数锁住。 test('两个按钮的文案字数相同,保证宽度一致', async () => { @@ -381,6 +388,91 @@ describe('物料列表的作废入口文案', () => { }) }) +describe('详情内编辑物料', () => { + const row = { id: 7, category_id: 12, category: 'A01', material_code: 'MAT-7', name: '原名称', status: 'active' } + + beforeEach(() => { + materialCategoriesApi.list.mockResolvedValue({ data: categories }) + unitsApi.list.mockResolvedValue({ data: [] }) + materialsStore.materials = [row] + materialsStore.updateMaterial.mockReset().mockResolvedValue({ data: { ...row, name: '新名称' } }) + vi.spyOn(ElMessage, 'success').mockImplementation(() => {}) + }) + + afterEach(() => { + materialsStore.materials = [] + }) + + test('点击详情先只读展示,点击弹窗内编辑才开放输入', async () => { + const wrapper = mountMaterials() + await flushPromises() + await findButton(wrapper, '详情').trigger('click') + await flushPromises() + + expect(wrapper.find('.el-descriptions').text()).toContain('原名称') + expect(inputWithValue(wrapper, '原名称')).toBeUndefined() + expect(findButton(wrapper, '编辑')).toBeTruthy() + + await findButton(wrapper, '编辑').trigger('click') + await flushPromises() + expect(inputWithValue(wrapper, '原名称')).toBeTruthy() + expect(findButton(wrapper, '取消编辑')).toBeTruthy() + wrapper.unmount() + }) + + test('取消编辑丢弃修改并回到只读详情', async () => { + const wrapper = mountMaterials() + await flushPromises() + await findButton(wrapper, '详情').trigger('click') + await findButton(wrapper, '编辑').trigger('click') + await inputWithValue(wrapper, '原名称').setValue('临时名称') + await findButton(wrapper, '取消编辑').trigger('click') + await flushPromises() + + expect(wrapper.find('.el-descriptions').text()).toContain('原名称') + expect(wrapper.find('.el-descriptions').text()).not.toContain('临时名称') + await findButton(wrapper, '编辑').trigger('click') + expect(inputWithValue(wrapper, '原名称')).toBeTruthy() + expect(materialsStore.updateMaterial).not.toHaveBeenCalled() + wrapper.unmount() + }) + + test('关闭后重开详情不会带回未保存内容', async () => { + const wrapper = mountMaterials() + await flushPromises() + await findButton(wrapper, '详情').trigger('click') + await findButton(wrapper, '编辑').trigger('click') + await inputWithValue(wrapper, '原名称').setValue('临时名称') + setupState(wrapper).dialogVisible = false + await flushPromises() + await findButton(wrapper, '详情').trigger('click') + await flushPromises() + + expect(wrapper.find('.el-descriptions').text()).toContain('原名称') + expect(wrapper.find('.el-descriptions').text()).not.toContain('临时名称') + await findButton(wrapper, '编辑').trigger('click') + expect(inputWithValue(wrapper, '原名称')).toBeTruthy() + expect(materialsStore.updateMaterial).not.toHaveBeenCalled() + wrapper.unmount() + }) + + test('保存后在同一弹窗只读展示最新内容', async () => { + const wrapper = mountMaterials() + await flushPromises() + await findButton(wrapper, '详情').trigger('click') + await findButton(wrapper, '编辑').trigger('click') + await inputWithValue(wrapper, '原名称').setValue('新名称') + await findButton(wrapper, '保存').trigger('click') + await flushPromises() + + expect(materialsStore.updateMaterial).toHaveBeenCalledWith(7, expect.objectContaining({ name: '新名称' })) + expect(wrapper.find('.el-descriptions').text()).toContain('新名称') + expect(inputWithValue(wrapper, '新名称')).toBeUndefined() + expect(findButton(wrapper, '编辑')).toBeTruthy() + wrapper.unmount() + }) +}) + describe('停用分类下的物料编辑', () => { test('保留原分类并显示停用状态,修改名称时不改变分类归属', async () => { materialCategoriesApi.list.mockResolvedValue({ data: [ @@ -401,7 +493,9 @@ describe('停用分类下的物料编辑', () => { expect(state.getMajorCategoryDisplay(row)).toBe('A 旧大类(已停用)') expect(state.getMinorCategoryDisplay(row)).toBe('旧小类') - state.showEditModal(row) + state.showDetail(row) + await flushPromises() + await findButton(wrapper, '编辑').trigger('click') await flushPromises() expect(state.form.major_category_id).toBe(1) expect(state.form.category_id).toBe(12) diff --git a/frontend/src/views/Materials.vue b/frontend/src/views/Materials.vue index eb5b8327..4571da55 100644 --- a/frontend/src/views/Materials.vue +++ b/frontend/src/views/Materials.vue @@ -60,15 +60,14 @@ > - - + +
- - - - - + {{ detailRow.material_code }} {{ detailRow.id }} {{ detailRow.name }} @@ -137,6 +129,20 @@ - + dialogTitle.value === '新增物料') +const isCreating = computed(() => dialogMode.value === 'create') +const isEditing = computed(() => dialogMode.value === 'edit') +const dialogTitle = computed(() => isCreating.value ? '新增物料' : '物料详情') // 小类还没选时编码框必然是空的,提示用户先把分类选完:选完才会自动填入编码, // 或者由"请手工填写"的提示接手。 const codePlaceholder = computed(() => { @@ -334,7 +341,8 @@ function handlePageChange(page) { function showAddModal() { editingId.value = null - dialogTitle.value = '新增物料' + detailRow.value = null + dialogMode.value = 'create' Object.assign(form, createMaterialForm()) codeEdited.value = false imagePreview.value = '' @@ -344,21 +352,41 @@ function showAddModal() { function showDetail(row) { detailRow.value = row - detailVisible.value = true -} - -function showEditModal(row) { - editingId.value = row.id - dialogTitle.value = '编辑物料' - Object.assign(form, createMaterialForm(row, categories.value)) - // 编辑时编码只读,标记成"已改过",避免切小类时被规则覆盖 - codeEdited.value = true + editingId.value = null + dialogMode.value = 'detail' + Object.assign(form, createMaterialForm()) imagePreview.value = '' if (imageInput.value) imageInput.value.value = '' dialogVisible.value = true } +function startEdit() { + if (!detailRow.value) return + editingId.value = detailRow.value.id + Object.assign(form, createMaterialForm(detailRow.value, categories.value)) + // 编辑时编码只读,标记成"已改过",避免切小类时被规则覆盖 + codeEdited.value = true + imagePreview.value = '' + if (imageInput.value) imageInput.value.value = '' + dialogMode.value = 'edit' +} + +function cancelEdit() { + if (!isEditing.value) return + Object.assign(form, createMaterialForm()) + editingId.value = null + imagePreview.value = '' + if (imageInput.value) imageInput.value.value = '' + dialogMode.value = 'detail' +} + +function handleDialogClose() { + cancelEdit() + if (isCreating.value) Object.assign(form, createMaterialForm()) +} + async function handleSave() { + if (!isCreating.value && !isEditing.value) return if (!form.major_category_id || !form.category_id) { ElMessage.warning('请选择大类类型和小类类型') return @@ -393,7 +421,9 @@ async function handleSave() { formData.material_image = (data.data && data.data.url) ? data.data.url : '' } if (editingId.value) { - await materialsStore.updateMaterial(editingId.value, formData) + const response = await materialsStore.updateMaterial(editingId.value, formData) + detailRow.value = { ...detailRow.value, ...formData, ...response?.data } + cancelEdit() ElMessage.success('物料更新成功') } else { await materialsStore.createMaterial(formData) @@ -401,8 +431,8 @@ async function handleSave() { // store 里新建后的那次刷新会清空筛选(好让刚建的物料出现在列表里),搜索框得 // 跟着一起清空,否则会出现搜索框写着关键字、列表却是全量的表里不一。 searchKeyword.value = '' + dialogVisible.value = false } - dialogVisible.value = false } catch (e) { // 接口失败的原因已由拦截器弹过中文提示,这里只兜住它覆盖不到的异常, // 避免出现"点了保存没反应" diff --git a/frontend/src/views/partners/PartnerList.test.js b/frontend/src/views/partners/PartnerList.test.js index 10f16b53..245215d2 100644 --- a/frontend/src/views/partners/PartnerList.test.js +++ b/frontend/src/views/partners/PartnerList.test.js @@ -245,3 +245,93 @@ describe('伙伴列表分页', () => { wrapper.unmount() }) }) + +describe('伙伴业务分类多选', () => { + beforeEach(() => { + partnersApi.list.mockReset().mockResolvedValue({ data: { items: [], total: 0 } }) + partnersApi.create.mockReset().mockResolvedValue({ data: {} }) + partnersApi.update.mockReset().mockResolvedValue({ data: {} }) + }) + + afterEach(() => { vi.restoreAllMocks() }) + + test.each([['supplier', '供应商'], ['customer', '客户']])('%s 可同时选择生产与成品分类', async (role, title) => { + const wrapper = mountPartner(role, title) + await flushPromises() + await openCreateDialog(wrapper, title) + await inputByLabel(wrapper, '伙伴编码').setValue('PARTNER-001') + await inputByLabel(wrapper, '企业名称').setValue('示例公司') + const item = wrapper.findAll('.el-form-item').find(node => node.text().includes('业务分类')) + expect(item, '表单提供业务分类多选').toBeDefined() + for (const checkbox of item.findAll('input[type="checkbox"]')) await checkbox.setValue(true) + await clickSave(wrapper) + expect(partnersApi.create).toHaveBeenCalledWith(expect.objectContaining({ + [`${role}_categories`]: ['material', 'finished_product'] + })) + wrapper.unmount() + }) + + test('列表按当前角色显示分类,历史空分类显示未分类', async () => { + partnersApi.list.mockResolvedValue({ data: { items: [ + { id: 1, partner_code: 'SUP-001', legal_name: '双角色公司', supplier_categories: ['material'], customer_categories: ['finished_product'] }, + { id: 2, partner_code: 'SUP-002', legal_name: '历史公司' } + ], total: 2 } }) + const wrapper = mountPartner() + await flushPromises() + const rows = wrapper.findAll('.el-table__body tbody tr') + expect(rows[0].text()).toContain('物料级(生产)') + expect(rows[0].text()).not.toContain('成品级(经营买卖)') + expect(rows[1].text()).toContain('未分类') + wrapper.unmount() + }) + + test('修改供应商分类保留客户分类,取消编辑不改变列表数据', async () => { + const row = { id: 1, partner_code: 'SUP-001', legal_name: '双角色公司', is_supplier: true, is_customer: true, + supplier_categories: ['material'], customer_categories: ['finished_product'] } + partnersApi.list.mockResolvedValue({ data: { items: [row], total: 1 } }) + const wrapper = mountPartner() + await flushPromises() + await findButton(wrapper, '编辑').trigger('click') + await flushPromises() + const item = wrapper.findAll('.el-form-item').find(node => node.text().includes('业务分类')) + expect(item, '表单提供业务分类多选').toBeDefined() + const checkboxes = item.findAll('input[type="checkbox"]') + await checkboxes[0].setValue(false) + await checkboxes[1].setValue(true) + expect(row.supplier_categories).toEqual(['material']) + await findButton(wrapper, '取消').trigger('click') + await flushPromises() + expect(wrapper.find('.el-table__body tbody tr').text()).toContain('物料级(生产)') + expect(wrapper.find('.el-table__body tbody tr').text()).not.toContain('成品级(经营买卖)') + await findButton(wrapper, '编辑').trigger('click') + await flushPromises() + const reopened = wrapper.findAll('.el-form-item').find(node => node.text().includes('业务分类')).findAll('input[type="checkbox"]') + expect(reopened[0].element.checked).toBe(true) + expect(reopened[1].element.checked).toBe(false) + await reopened[0].setValue(false) + await reopened[1].setValue(true) + await clickSave(wrapper) + expect(partnersApi.update).toHaveBeenCalledWith(1, expect.objectContaining({ supplier_categories: ['finished_product'], customer_categories: ['finished_product'] })) + await openCreateDialog(wrapper, '供应商') + expect(wrapper.findAll('input[type="checkbox"]').every(input => !input.element.checked)).toBe(true) + wrapper.unmount() + }) + + test('分类筛选从第一页请求,切换角色清空筛选', async () => { + partnersApi.list.mockResolvedValue({ data: { items: [], total: 40 } }) + const wrapper = mountPartner() + await flushPromises() + await wrapper.find('.btn-next').trigger('click') + await flushPromises() + const filter = wrapper.findAllComponents(SelectStub).find(select => select.props('placeholder') === '全部业务分类') + expect(filter, '列表提供业务分类筛选').toBeDefined() + filter.vm.$emit('update:modelValue', 'material') + filter.vm.$emit('change', 'material') + await flushPromises() + expect(partnersApi.list).toHaveBeenLastCalledWith('supplier', { limit: 20, offset: 0, category: 'material' }) + await wrapper.setProps({ role: 'customer' }) + await flushPromises() + expect(partnersApi.list).toHaveBeenLastCalledWith('customer', { limit: 20, offset: 0 }) + wrapper.unmount() + }) +}) diff --git a/frontend/src/views/partners/PartnerList.vue b/frontend/src/views/partners/PartnerList.vue index 6fef39f0..034c1795 100644 --- a/frontend/src/views/partners/PartnerList.vue +++ b/frontend/src/views/partners/PartnerList.vue @@ -4,6 +4,12 @@

{{ title }}

业务伙伴独立于本公司的组织数据,可按合作组织配置{{ title }}身份和状态。

新增{{ title }}
+
+ + + + +
+ + + @@ -26,6 +40,11 @@ + + + {{ label }} + + @@ -57,18 +76,23 @@ const editingId = ref(null) // 每页条数跟物料等列表页保持一致,避免同一个系统里两种翻页手感 const pagination = reactive({ currentPage: 1, pageSize: 20, total: 0 }) const statusField = computed(() => `${props.role}_status`) +const categoriesField = computed(() => `${props.role}_categories`) +const CATEGORY_LABELS = { material: '物料级(生产)', finished_product: '成品级(经营买卖)' } +const categoryFilter = ref('') // 合作状态英文枚举到中文的对照。列表列与编辑弹窗共用这一份,避免两处各写一遍后失步。 const STATUS_LABELS = { active: '启用', suspended: '暂停', eliminated: '淘汰', pending: '待审核', inactive: '停用' } // 未收录的状态原样显示,不伪装成某个已知状态。 function statusFormatter(row, column, cellValue) { return STATUS_LABELS[cellValue] || cellValue } const form = reactive(emptyForm()) -function emptyForm() { return { partner_code: '', legal_name: '', unified_credit_code: '', contact_name: '', contact_phone: '', address: '', status: 'active', is_supplier: false, is_customer: false } } +function emptyForm() { return { partner_code: '', legal_name: '', unified_credit_code: '', contact_name: '', contact_phone: '', address: '', status: 'active', is_supplier: false, is_customer: false, supplier_categories: [], customer_categories: [] } } async function load(currentPage = pagination.currentPage) { loading.value = true try { const offset = (currentPage - 1) * pagination.pageSize - const response = await partnersApi.list(props.role, { limit: pagination.pageSize, offset }) + const params = { limit: pagination.pageSize, offset } + if (categoryFilter.value) params.category = categoryFilter.value + const response = await partnersApi.list(props.role, params) rows.value = response.data?.items || [] pagination.total = response.data?.total || 0 pagination.currentPage = currentPage @@ -77,11 +101,16 @@ async function load(currentPage = pagination.currentPage) { } } // 换角色等于换了一份数据集,页码必须归零,否则会停在上一个角色的第 N 页上 -watch(() => props.role, () => load(1)) +watch(() => props.role, () => { categoryFilter.value = ''; load(1) }) // 用 replaceAllKeys 而不是 Object.assign:后者删不掉上次编辑残留的 id, // 新增时会把别人的 id 一起提交上去。 function openCreate() { editingId.value = null; replaceAllKeys(form, emptyForm()); dialogVisible.value = true } -function openEdit(row) { editingId.value = row.id; Object.assign(form, row, { status: row[statusField.value] || 'active' }); dialogVisible.value = true } +function openEdit(row) { + editingId.value = row.id + replaceAllKeys(form, { ...emptyForm(), ...row, status: row[statusField.value] || 'active', + supplier_categories: [...(row.supplier_categories || [])], customer_categories: [...(row.customer_categories || [])] }) + dialogVisible.value = true +} function payload() { return { ...form, @@ -104,4 +133,8 @@ onMounted(() => load(1)) .page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; } .page-header h2 { margin: 0 0 6px; } .page-header p { margin: 0; color: #909399; } +.filters { margin-bottom: 16px; } +.filters .el-select { width: 240px; } +.category-tag { margin-right: 6px; } +.unclassified { color: #909399; } diff --git a/internal/handler/admin_master.go b/internal/handler/admin_master.go index 3f3ec498..678646bb 100644 --- a/internal/handler/admin_master.go +++ b/internal/handler/admin_master.go @@ -300,7 +300,7 @@ func (h *Handler) ListPartners(c *gin.Context) { } } - items, total, err := h.partnerSvc.List(orgID, c.Query("role"), limit, offset) + items, total, err := h.partnerSvc.List(orgID, c.Query("role"), limit, offset, c.Query("category")) if err != nil { response.BadRequest(c, err.Error()) return diff --git a/internal/handler/partner_categories_test.go b/internal/handler/partner_categories_test.go new file mode 100644 index 00000000..e8b4953c --- /dev/null +++ b/internal/handler/partner_categories_test.go @@ -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) + } + }) + } +} diff --git a/internal/model/partner.go b/internal/model/partner.go index d9a4ed24..1b6f408b 100644 --- a/internal/model/partner.go +++ b/internal/model/partner.go @@ -1,19 +1,21 @@ package model type BusinessPartner struct { - ID int64 `json:"id"` - OrganizationID int64 `json:"organization_id"` - BusinessPartnerID int64 `json:"business_partner_id"` - PartnerCode string `json:"partner_code"` - LegalName string `json:"legal_name"` - UnifiedCreditCode string `json:"unified_credit_code"` - ContactName string `json:"contact_name"` - ContactPhone string `json:"contact_phone"` - Address string `json:"address"` - IsSupplier bool `json:"is_supplier"` - IsCustomer bool `json:"is_customer"` - SupplierStatus string `json:"supplier_status"` - CustomerStatus string `json:"customer_status"` - SettlementCurrency string `json:"settlement_currency"` - PaymentTerms string `json:"payment_terms"` + ID int64 `json:"id"` + OrganizationID int64 `json:"organization_id"` + BusinessPartnerID int64 `json:"business_partner_id"` + PartnerCode string `json:"partner_code"` + LegalName string `json:"legal_name"` + UnifiedCreditCode string `json:"unified_credit_code"` + ContactName string `json:"contact_name"` + ContactPhone string `json:"contact_phone"` + Address string `json:"address"` + IsSupplier bool `json:"is_supplier"` + IsCustomer bool `json:"is_customer"` + SupplierStatus string `json:"supplier_status"` + CustomerStatus string `json:"customer_status"` + SupplierCategories []string `json:"supplier_categories"` + CustomerCategories []string `json:"customer_categories"` + SettlementCurrency string `json:"settlement_currency"` + PaymentTerms string `json:"payment_terms"` } diff --git a/internal/repository/partner.go b/internal/repository/partner.go index 03da1eb5..a4839fa2 100644 --- a/internal/repository/partner.go +++ b/internal/repository/partner.go @@ -4,6 +4,7 @@ import ( "database/sql" "errors" "fmt" + "strings" "github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/pkg/database" @@ -13,7 +14,7 @@ type PartnerRepository struct{} func NewPartnerRepository() *PartnerRepository { return &PartnerRepository{} } -func (r *PartnerRepository) List(organizationID int64, role string, limit, offset int) ([]model.BusinessPartner, int64, error) { +func (r *PartnerRepository) List(organizationID int64, role string, limit, offset int, category string) ([]model.BusinessPartner, int64, error) { // 角色过滤写成一份,由计数和取数共用:两处各写一遍,改一处漏一处就会让 // 总数和列表对不上(总数算的是全部,列表却只有供应商)。 filter := `FROM organization_partners op @@ -26,6 +27,23 @@ func (r *PartnerRepository) List(organizationID int64, role string, limit, offse if role == "customer" { filter += ` AND op.is_customer = TRUE` } + if category != "" { + // 字段名仅由已知角色选择,筛选值始终使用参数绑定。 + field := "op.supplier_categories" + if role == "customer" { + field = "op.customer_categories" + } else if role != "supplier" { + return nil, 0, errors.New("业务分类筛选需要指定客户或供应商") + } + if category == "unclassified" { + filter += ` AND ` + field + ` = ''` + } else if category == "material" || category == "finished_product" { + filter += ` AND FIND_IN_SET(?, ` + field + `) > 0` + args = append(args, category) + } else { + return nil, 0, errors.New("invalid partner category") + } + } var total int64 if err := database.DB.QueryRow(`SELECT COUNT(*) `+filter, args...).Scan(&total); err != nil { @@ -37,7 +55,7 @@ func (r *PartnerRepository) List(organizationID int64, role string, limit, offse COALESCE(bp.contact_phone, ''), COALESCE(bp.address, ''), op.is_supplier, op.is_customer, COALESCE(op.supplier_status, ''), COALESCE(op.customer_status, ''), COALESCE(op.settlement_currency, ''), - COALESCE(op.payment_terms, '') + COALESCE(op.payment_terms, ''), op.supplier_categories, op.customer_categories ` + filter + ` ORDER BY op.partner_code LIMIT ? OFFSET ?` @@ -51,12 +69,15 @@ func (r *PartnerRepository) List(organizationID int64, role string, limit, offse items := make([]model.BusinessPartner, 0) for rows.Next() { var item model.BusinessPartner + var supplierCategories, customerCategories string if err := rows.Scan(&item.ID, &item.OrganizationID, &item.BusinessPartnerID, &item.PartnerCode, &item.LegalName, &item.UnifiedCreditCode, &item.ContactName, &item.ContactPhone, &item.Address, &item.IsSupplier, &item.IsCustomer, &item.SupplierStatus, - &item.CustomerStatus, &item.SettlementCurrency, &item.PaymentTerms); err != nil { + &item.CustomerStatus, &item.SettlementCurrency, &item.PaymentTerms, &supplierCategories, &customerCategories); err != nil { return nil, 0, err } + item.SupplierCategories = splitPartnerCategories(supplierCategories) + item.CustomerCategories = splitPartnerCategories(customerCategories) items = append(items, item) } return items, total, rows.Err() @@ -87,10 +108,11 @@ func (r *PartnerRepository) Create(item *model.BusinessPartner) (err error) { result, err = tx.Exec(`INSERT INTO organization_partners (organization_id, business_partner_id, partner_code, is_supplier, is_customer, - supplier_status, customer_status, settlement_currency, payment_terms) - VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''))`, + supplier_status, customer_status, settlement_currency, payment_terms, supplier_categories, customer_categories) + VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), ?, ?)`, item.OrganizationID, item.BusinessPartnerID, item.PartnerCode, item.IsSupplier, item.IsCustomer, - item.SupplierStatus, item.CustomerStatus, item.SettlementCurrency, item.PaymentTerms) + item.SupplierStatus, item.CustomerStatus, item.SettlementCurrency, item.PaymentTerms, + strings.Join(item.SupplierCategories, ","), strings.Join(item.CustomerCategories, ",")) if err != nil { return err } @@ -128,9 +150,12 @@ func (r *PartnerRepository) Update(item *model.BusinessPartner) (err error) { } if _, err = tx.Exec(`UPDATE organization_partners SET is_supplier = ?, is_customer = ?, supplier_status = NULLIF(?, ''), customer_status = NULLIF(?, ''), - settlement_currency = NULLIF(?, ''), payment_terms = NULLIF(?, '') WHERE id = ? AND organization_id = ?`, + settlement_currency = NULLIF(?, ''), payment_terms = NULLIF(?, ''), + supplier_categories = COALESCE(?, supplier_categories), customer_categories = COALESCE(?, customer_categories) + WHERE id = ? AND organization_id = ?`, item.IsSupplier, item.IsCustomer, item.SupplierStatus, item.CustomerStatus, - item.SettlementCurrency, item.PaymentTerms, item.ID, item.OrganizationID); err != nil { + item.SettlementCurrency, item.PaymentTerms, optionalPartnerCategories(item.SupplierCategories), + optionalPartnerCategories(item.CustomerCategories), item.ID, item.OrganizationID); err != nil { return err } if err = tx.Commit(); err != nil { @@ -138,3 +163,17 @@ func (r *PartnerRepository) Update(item *model.BusinessPartner) (err error) { } return nil } + +func splitPartnerCategories(value string) []string { + if value == "" { + return []string{} + } + return strings.Split(value, ",") +} + +func optionalPartnerCategories(categories []string) interface{} { + if categories == nil { + return nil + } + return strings.Join(categories, ",") +} diff --git a/internal/repository/partner_test.go b/internal/repository/partner_test.go index a0338c6b..b0d9b4d8 100644 --- a/internal/repository/partner_test.go +++ b/internal/repository/partner_test.go @@ -1,6 +1,8 @@ package repository import ( + "encoding/json" + "reflect" "regexp" "testing" @@ -24,6 +26,9 @@ func TestPartnerCreateSeparatesLegalEntityAndOrganizationRelationship(t *testing IsSupplier: true, SupplierStatus: "active", } + if err := json.Unmarshal([]byte(`{"supplier_categories":["material","finished_product"],"customer_categories":[]}`), partner); err != nil { + t.Fatal(err) + } mock.ExpectBegin() mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO business_partners @@ -33,9 +38,9 @@ func TestPartnerCreateSeparatesLegalEntityAndOrganizationRelationship(t *testing WillReturnResult(sqlmock.NewResult(9, 1)) mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO organization_partners (organization_id, business_partner_id, partner_code, is_supplier, is_customer, - supplier_status, customer_status, settlement_currency, payment_terms) - VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''))`)). - WithArgs(int64(1), int64(9), "SUP-001", true, false, "active", "", "", ""). + supplier_status, customer_status, settlement_currency, payment_terms, supplier_categories, customer_categories) + VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), ?, ?)`)). + WithArgs(int64(1), int64(9), "SUP-001", true, false, "active", "", "", "", "material,finished_product", ""). WillReturnResult(sqlmock.NewResult(15, 1)) mock.ExpectCommit() @@ -49,3 +54,70 @@ func TestPartnerCreateSeparatesLegalEntityAndOrganizationRelationship(t *testing t.Fatal(err) } } + +func TestPartnerListReturnsIndependentCategoriesAndEmptyArrays(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + database.DB = db + t.Cleanup(func() { database.DB = nil; db.Close() }) + mock.ExpectQuery("SELECT COUNT").WithArgs(int64(1)).WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(2)) + mock.ExpectQuery("SELECT op.id").WithArgs(int64(1), 20, 0).WillReturnRows(sqlmock.NewRows([]string{ + "id", "organization_id", "business_partner_id", "partner_code", "legal_name", "unified_credit_code", "contact_name", "contact_phone", "address", + "is_supplier", "is_customer", "supplier_status", "customer_status", "settlement_currency", "payment_terms", "supplier_categories", "customer_categories", + }).AddRow(1, 1, 10, "P-001", "公司", "", "", "", "", true, true, "active", "active", "", "", "material,finished_product", "finished_product"). + AddRow(2, 1, 11, "P-002", "历史公司", "", "", "", "", true, false, "active", "", "", "", "", "")) + items, total, err := NewPartnerRepository().List(1, "supplier", 20, 0, "") + if err != nil { + t.Fatal(err) + } + if total != 2 || len(items) != 2 { + t.Fatalf("unexpected list: %v / %v", items, total) + } + if !reflect.DeepEqual(items[0].SupplierCategories, []string{"material", "finished_product"}) || !reflect.DeepEqual(items[0].CustomerCategories, []string{"finished_product"}) { + t.Fatalf("categories mixed or lost: %+v", items[0]) + } + if items[1].SupplierCategories == nil || items[1].CustomerCategories == nil { + t.Fatal("unclassified categories must be empty arrays") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestPartnerUpdatePreservesOmittedCategoriesAndAllowsClearing(t *testing.T) { + for _, tc := range []struct { + name, body string + supplier, customer interface{} + }{ + {"omitted", `{}`, nil, nil}, + {"clear supplier", `{"supplier_categories":[]}`, "", nil}, + {"change customer", `{"customer_categories":["material","finished_product"]}`, nil, "material,finished_product"}, + } { + t.Run(tc.name, func(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + database.DB = db + t.Cleanup(func() { database.DB = nil; db.Close() }) + item := model.BusinessPartner{ID: 12, OrganizationID: 2, LegalName: "公司", IsSupplier: true, IsCustomer: true} + if err := json.Unmarshal([]byte(tc.body), &item); err != nil { + t.Fatal(err) + } + mock.ExpectBegin() + mock.ExpectQuery("SELECT business_partner_id").WithArgs(int64(12), int64(2)).WillReturnRows(sqlmock.NewRows([]string{"business_partner_id"}).AddRow(7)) + mock.ExpectExec("UPDATE business_partners").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("UPDATE organization_partners SET.*supplier_categories = COALESCE.*customer_categories = COALESCE"). + WithArgs(true, true, "", "", "", "", tc.supplier, tc.customer, int64(12), int64(2)).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + if err := NewPartnerRepository().Update(&item); err != nil { + t.Fatal(err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/internal/service/admin_master.go b/internal/service/admin_master.go index 0fdc2b73..0e5b4e4b 100644 --- a/internal/service/admin_master.go +++ b/internal/service/admin_master.go @@ -211,11 +211,11 @@ type PartnerService struct{ repo *repository.PartnerRepository } func NewPartnerService() *PartnerService { return &PartnerService{repo: repository.NewPartnerRepository()} } -func (s *PartnerService) List(orgID int64, role string, limit, offset int) ([]model.BusinessPartner, int64, error) { +func (s *PartnerService) List(orgID int64, role string, limit, offset int, category string) ([]model.BusinessPartner, int64, error) { if role != "" && role != "supplier" && role != "customer" { return nil, 0, errors.New("role must be supplier or customer") } - return s.repo.List(orgID, role, limit, offset) + return s.repo.List(orgID, role, limit, offset, category) } // Create 新建伙伴。请求体可能带着前端表单残留的 id,这里不读取 item.ID, @@ -255,6 +255,23 @@ func normalizePartner(item *model.BusinessPartner) error { if !item.IsSupplier && !item.IsCustomer { return errors.New("at least one partner role is required") } + for _, categories := range []*[]string{&item.SupplierCategories, &item.CustomerCategories} { + if *categories == nil { + continue // 更新时未传分类保留原值;显式空数组表示清空。 + } + normalized := make([]string, 0, 2) + seen := make(map[string]bool) + for _, category := range *categories { + if category != "material" && category != "finished_product" { + return errors.New("业务分类只能选择物料级(生产)或成品级(经营买卖)") + } + if !seen[category] { + normalized = append(normalized, category) + seen[category] = true + } + } + *categories = normalized + } if item.IsSupplier && item.SupplierStatus == "" { item.SupplierStatus = "active" } diff --git a/internal/service/partner_categories_test.go b/internal/service/partner_categories_test.go new file mode 100644 index 00000000..d6599d2a --- /dev/null +++ b/internal/service/partner_categories_test.go @@ -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") + } + }) + } +} diff --git a/scripts/add_order_display_fields.sql b/scripts/add_order_display_fields.sql deleted file mode 100644 index bd6afa80..00000000 --- a/scripts/add_order_display_fields.sql +++ /dev/null @@ -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; diff --git a/scripts/init.sql b/scripts/init.sql index a7b87f40..b0255f4d 100644 --- a/scripts/init.sql +++ b/scripts/init.sql @@ -21,7 +21,9 @@ CREATE TABLE code_rules (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id B CREATE TABLE material_unit_conversions (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, material_id BIGINT NOT NULL, from_unit_id BIGINT NOT NULL, to_unit_id BIGINT NOT NULL, conversion_rate DECIMAL(20,6) NOT NULL, effective_from DATE NOT NULL, effective_to DATE NULL, KEY idx_material_unit_conversions_material(organization_id,material_id), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(material_id) REFERENCES materials(id), FOREIGN KEY(from_unit_id) REFERENCES units(id), FOREIGN KEY(to_unit_id) REFERENCES units(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE tags (id BIGINT AUTO_INCREMENT PRIMARY KEY, material_id BIGINT NOT NULL, tag_code VARCHAR(100) NOT NULL UNIQUE, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_tags_material(material_id), FOREIGN KEY(material_id) REFERENCES materials(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE business_partners (id BIGINT AUTO_INCREMENT PRIMARY KEY, legal_name VARCHAR(200) NOT NULL, unified_credit_code VARCHAR(50) UNIQUE, contact_name VARCHAR(100), contact_phone VARCHAR(50), address VARCHAR(500), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -CREATE TABLE organization_partners (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, business_partner_id BIGINT NOT NULL, partner_code VARCHAR(50) NOT NULL, is_supplier BOOLEAN NOT NULL DEFAULT FALSE, is_customer BOOLEAN NOT NULL DEFAULT FALSE, supplier_status VARCHAR(20), customer_status VARCHAR(20), settlement_currency CHAR(3), payment_terms VARCHAR(200), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_organization_partners_org_partner(organization_id,business_partner_id), UNIQUE KEY uk_organization_partners_org_code(organization_id,partner_code), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(business_partner_id) REFERENCES business_partners(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- Partner categories support selecting material and finished_product together; +-- an empty set means unclassified. Both fields are created with the table. +CREATE TABLE organization_partners (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, business_partner_id BIGINT NOT NULL, partner_code VARCHAR(50) NOT NULL, is_supplier BOOLEAN NOT NULL DEFAULT FALSE, is_customer BOOLEAN NOT NULL DEFAULT FALSE, supplier_status VARCHAR(20), supplier_categories SET('material','finished_product') NOT NULL DEFAULT '', customer_status VARCHAR(20), customer_categories SET('material','finished_product') NOT NULL DEFAULT '', settlement_currency CHAR(3), payment_terms VARCHAR(200), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_organization_partners_org_partner(organization_id,business_partner_id), UNIQUE KEY uk_organization_partners_org_code(organization_id,partner_code), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(business_partner_id) REFERENCES business_partners(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE material_suppliers (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, material_id BIGINT NOT NULL, organization_partner_id BIGINT NOT NULL, price DECIMAL(20,6) NOT NULL, currency CHAR(3) NOT NULL, minimum_order_quantity DECIMAL(20,6) NOT NULL DEFAULT 0, lead_time_days INT NOT NULL DEFAULT 0, is_preferred BOOLEAN NOT NULL DEFAULT FALSE, effective_from DATE NOT NULL, effective_to DATE, status VARCHAR(20) NOT NULL DEFAULT 'active', KEY idx_material_suppliers_material(organization_id,material_id), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(material_id) REFERENCES materials(id), FOREIGN KEY(organization_partner_id) REFERENCES organization_partners(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE warehouses (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, code VARCHAR(50) NOT NULL, name VARCHAR(150) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_warehouses_org_code(organization_id,code), FOREIGN KEY(organization_id) REFERENCES organizations(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE warehouse_locations (id BIGINT AUTO_INCREMENT PRIMARY KEY, warehouse_id BIGINT NOT NULL, code VARCHAR(50) NOT NULL, name VARCHAR(150) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_warehouse_locations_warehouse_code(warehouse_id,code), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; @@ -96,3 +98,126 @@ INSERT INTO warehouse_locations (id,warehouse_id,code,name,status) VALUES (1,1,' -- previous latin1/BINARY round-trip repair is gone: it only produced correct -- text when the client was latin1, and it corrupts (or destroys non-latin1 -- characters such as "→") when the client is already utf8mb4. + +-- ============================================================================ +-- 示例数据:客户、供应商。以下企业、联系人、电话及地址均为虚构,仅用于演示。 +-- 共 20 家企业:8 家仅供应商、8 家仅客户、4 家同时具有两种身份。 +-- 默认归属组织 ID 为 1;信用代码留空,伙伴编码以 DEMO- 开头。 +-- 本段示例数据会跳过已有记录;init.sql 整体仍仅用于空库首次初始化。 +-- ============================================================================ +SET @demo_organization_id = 1; + +DROP TEMPORARY TABLE IF EXISTS tmp_demo_partners_20261008; +CREATE TEMPORARY TABLE tmp_demo_partners_20261008 ( + partner_code VARCHAR(50) NOT NULL PRIMARY KEY, + legal_name VARCHAR(200) NOT NULL, + contact_name VARCHAR(100) NOT NULL, + contact_phone VARCHAR(50) NOT NULL, + address VARCHAR(500) NOT NULL, + is_supplier BOOLEAN NOT NULL, + is_customer BOOLEAN NOT NULL, + supplier_status VARCHAR(20) NULL, + customer_status VARCHAR(20) NULL, + supplier_categories SET('material', 'finished_product') NOT NULL DEFAULT '', + customer_categories SET('material', 'finished_product') NOT NULL DEFAULT '', + payment_terms VARCHAR(200) NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO tmp_demo_partners_20261008 + (partner_code, legal_name, contact_name, contact_phone, address, + is_supplier, is_customer, supplier_status, customer_status, + supplier_categories, customer_categories, payment_terms) +VALUES + ('DEMO-SUP-001', '示例·北京澄岳电子材料有限公司', '周明远', '010-0000-1001', '北京市海淀区示例路 18 号', + TRUE, FALSE, 'active', NULL, 'material', '', '验收后 30 天付款'), + ('DEMO-SUP-002', '示例·武汉青岚精密部件有限公司', '林晓晴', '027-0000-1002', '武汉市洪山区示例大道 36 号', + TRUE, FALSE, 'active', NULL, 'material', '', '验收后 15 天付款'), + ('DEMO-SUP-003', '示例·苏州星禾仪器贸易有限公司', '郑柏川', '0512-0000-1003', '苏州市工业园区示例街 52 号', + TRUE, FALSE, 'active', NULL, 'finished_product', '', '款到发货'), + ('DEMO-SUP-004', '示例·深圳沐辰机电有限公司', '许知夏', '0755-0000-1004', '深圳市南山区示例路 9 号', + TRUE, FALSE, 'active', NULL, 'material,finished_product', '', '月结 30 天'), + ('DEMO-SUP-005', '示例·成都锦栖包装材料有限公司', '唐思齐', '028-0000-1005', '成都市高新区示例街 27 号', + TRUE, FALSE, 'suspended', NULL, 'material', '', '预付 30%,验收后付尾款'), + ('DEMO-SUP-006', '示例·南京云朔自动化设备有限公司', '宋景行', '025-0000-1006', '南京市江宁区示例大道 61 号', + TRUE, FALSE, 'pending', NULL, 'finished_product', '', '验收后 45 天付款'), + ('DEMO-SUP-007', '示例·杭州珩远工业配套有限公司', '韩若溪', '0571-0000-1007', '杭州市滨江区示例路 43 号', + TRUE, FALSE, 'active', NULL, 'material,finished_product', '', '月结 60 天'), + ('DEMO-SUP-008', '示例·天津启沅物资有限公司', '顾承宇', '022-0000-1008', '天津市西青区示例街 15 号', + TRUE, FALSE, 'eliminated', NULL, '', '', '款到发货'), + + ('DEMO-CUS-001', '示例·北京云帆研发技术有限公司', '沈沐阳', '010-0000-2001', '北京市昌平区示例大道 29 号', + FALSE, TRUE, NULL, 'active', '', 'material', '发货后 30 天收款'), + ('DEMO-CUS-002', '示例·武汉卓溪智能装备有限公司', '蒋书宁', '027-0000-2002', '武汉市江夏区示例路 48 号', + FALSE, TRUE, NULL, 'active', '', 'finished_product', '预收 50%,交付后收尾款'), + ('DEMO-CUS-003', '示例·上海禾川工业贸易有限公司', '何清越', '021-0000-2003', '上海市闵行区示例街 11 号', + FALSE, TRUE, NULL, 'active', '', 'material,finished_product', '月结 30 天'), + ('DEMO-CUS-004', '示例·西安辰澜实验科技有限公司', '邵文轩', '029-0000-2004', '西安市高新区示例路 67 号', + FALSE, TRUE, NULL, 'active', '', 'material', '款到发货'), + ('DEMO-CUS-005', '示例·长沙栩航设备经营有限公司', '叶星遥', '0731-0000-2005', '长沙市岳麓区示例大道 23 号', + FALSE, TRUE, NULL, 'suspended', '', 'finished_product', '验收后 30 天收款'), + ('DEMO-CUS-006', '示例·济南清屿制造有限公司', '杜泽安', '0531-0000-2006', '济南市历城区示例街 39 号', + FALSE, TRUE, NULL, 'active', '', 'material,finished_product', '月结 45 天'), + ('DEMO-CUS-007', '示例·郑州景禾商贸有限公司', '梁初瑶', '0371-0000-2007', '郑州市中原区示例路 56 号', + FALSE, TRUE, NULL, 'pending', '', 'finished_product', '预付全款'), + ('DEMO-CUS-008', '示例·青岛若川技术服务有限公司', '陆嘉树', '0532-0000-2008', '青岛市崂山区示例大道 14 号', + FALSE, TRUE, NULL, 'inactive', '', '', '交付后 15 天收款'), + + ('DEMO-BOTH-001', '示例·北京知衡产业有限公司', '方予安', '010-0000-3001', '北京市大兴区示例路 72 号', + TRUE, TRUE, 'active', 'active', 'material', 'finished_product', '采购月结 30 天;销售预收 30%'), + ('DEMO-BOTH-002', '示例·武汉观澜机电贸易有限公司', '程舒雅', '027-0000-3002', '武汉市东西湖区示例街 31 号', + TRUE, TRUE, 'active', 'active', 'material,finished_product', 'material,finished_product', '采购、销售均月结 30 天'), + ('DEMO-BOTH-003', '示例·合肥映川智能科技有限公司', '贺景辰', '0551-0000-3003', '合肥市蜀山区示例大道 46 号', + TRUE, TRUE, 'suspended', 'active', 'finished_product', 'material', '采购款到发货;销售月结 45 天'), + ('DEMO-BOTH-004', '示例·厦门沅禾工业服务有限公司', '白念慈', '0592-0000-3004', '厦门市集美区示例路 8 号', + TRUE, TRUE, 'active', 'pending', 'material', '', '采购验收后 15 天付款;销售预付全款'); + +START TRANSACTION; + +-- 仅在目标组织存在时插入企业,避免错误组织 ID 产生无关联数据。 +INSERT INTO business_partners + (legal_name, unified_credit_code, contact_name, contact_phone, address) +SELECT demo.legal_name, NULL, demo.contact_name, demo.contact_phone, demo.address +FROM tmp_demo_partners_20261008 demo +JOIN organizations org ON org.id = @demo_organization_id +WHERE NOT EXISTS ( + SELECT 1 FROM business_partners existing WHERE existing.legal_name = demo.legal_name +); + +-- 不硬编码自增 ID。重复运行时保留原关系及已编辑的分类、状态等数据。 +INSERT INTO organization_partners + (organization_id, business_partner_id, partner_code, is_supplier, is_customer, + supplier_status, customer_status, supplier_categories, customer_categories, + settlement_currency, payment_terms) +SELECT org.id, partner.id, demo.partner_code, demo.is_supplier, demo.is_customer, + demo.supplier_status, demo.customer_status, demo.supplier_categories, demo.customer_categories, + 'CNY', demo.payment_terms +FROM tmp_demo_partners_20261008 demo +JOIN organizations org ON org.id = @demo_organization_id +JOIN ( + SELECT legal_name, MIN(id) AS id FROM business_partners GROUP BY legal_name +) partner ON partner.legal_name = demo.legal_name +WHERE NOT EXISTS ( + SELECT 1 FROM organization_partners existing + WHERE existing.organization_id = org.id + AND (existing.business_partner_id = partner.id OR existing.partner_code = demo.partner_code) +); + +COMMIT; + +-- 检查导入结果;目标组织不存在时明确提示,并且不会插入任何数据。 +SELECT @demo_organization_id AS organization_id, + CASE WHEN EXISTS (SELECT 1 FROM organizations WHERE id = @demo_organization_id) + THEN '目标组织存在,请核对下面的导入结果' + ELSE '目标组织不存在:未导入,请修改 @demo_organization_id 后重新执行' + END AS import_message; + +SELECT op.partner_code, bp.legal_name, op.is_supplier, op.is_customer, + op.supplier_categories, op.customer_categories, op.supplier_status, op.customer_status +FROM organization_partners op +JOIN business_partners bp ON bp.id = op.business_partner_id +JOIN tmp_demo_partners_20261008 demo + ON demo.partner_code = op.partner_code AND demo.legal_name = bp.legal_name +WHERE op.organization_id = @demo_organization_id +ORDER BY op.partner_code; + +DROP TEMPORARY TABLE tmp_demo_partners_20261008; diff --git a/scripts/update_material_categories.sql b/scripts/update_material_categories.sql deleted file mode 100644 index cac61fd0..00000000 --- a/scripts/update_material_categories.sql +++ /dev/null @@ -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; diff --git a/scripts/update_warning_stock_zero.sql b/scripts/update_warning_stock_zero.sql deleted file mode 100644 index 388324e0..00000000 --- a/scripts/update_warning_stock_zero.sql +++ /dev/null @@ -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; diff --git a/server.exe b/server.exe index a9d061e8..462c83dc 100644 Binary files a/server.exe and b/server.exe differ