diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index f860f2c4..91290fbc 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -1,31 +1,43 @@ package main import ( + "io/fs" "net/http" "net/http/httptest" - "os" - "path/filepath" + "strings" "testing" "github.com/gin-gonic/gin" + "github.com/wmms/wmms/internal/web" ) func TestFrontendRoutesServeBuiltAssetsAtRootPath(t *testing.T) { gin.SetMode(gin.TestMode) - distDir := t.TempDir() - assetsDir := filepath.Join(distDir, "assets") - if err := os.Mkdir(assetsDir, 0o755); err != nil { - t.Fatalf("create assets directory: %v", err) + dist, err := web.DistFS() + if err != nil { + t.Fatal(err) } - if err := os.WriteFile(filepath.Join(assetsDir, "app.js"), []byte("console.log('ok')"), 0o600); err != nil { - t.Fatalf("write asset: %v", err) + assetPath := "" + if err := fs.WalkDir(dist, ".", func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if !entry.IsDir() && strings.HasPrefix(path, "assets/") { + assetPath = path + return fs.SkipAll + } + return nil + }); err != nil { + t.Fatal(err) + } + if assetPath == "" { + t.Fatal("embedded frontend has no assets") } - router := gin.New() - registerFrontendRoutes(router, distDir) + registerFrontendRoutes(router) response := httptest.NewRecorder() - router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/assets/app.js", nil)) + router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/"+assetPath, nil)) if response.Code != http.StatusOK { t.Fatalf("asset status = %d, want %d", response.Code, http.StatusOK) @@ -34,12 +46,8 @@ func TestFrontendRoutesServeBuiltAssetsAtRootPath(t *testing.T) { func TestFrontendRefreshFallsBackWithoutMaskingAPIOrAssets(t *testing.T) { gin.SetMode(gin.TestMode) - distDir := t.TempDir() - if err := os.WriteFile(filepath.Join(distDir, "index.html"), []byte("company app"), 0o600); err != nil { - t.Fatal(err) - } r := gin.New() - registerFrontendRoutes(r, distDir) + registerFrontendRoutes(r) for _, path := range []string{"/stock", "/login", "/master-data/settings", "/api/missing", "/ws", "/uploads/missing", "/assets/missing.js"} { t.Run(path, func(t *testing.T) { w := httptest.NewRecorder() diff --git a/docs/api.md b/docs/api.md index 325f35ac..1c514047 100644 --- a/docs/api.md +++ b/docs/api.md @@ -182,6 +182,10 @@ "material_image": "/uploads/bolt.png", "spec": "M8x30", "manufacturer_model": "GB/T 70.1", + "supplier_id": 21, + "manufacturer_id": 22, + "supplier_name": "示例供应商", + "manufacturer_name": "示例制造商", "category_id": 12, "base_unit_id": 3, "unit": "个", @@ -199,7 +203,7 @@ } ``` -`material_image`、`spec`、`manufacturer_model`、`category_id`、`base_unit_id`、`unit` 为空时**不会出现在响应里**(后端 `omitempty`),前端取值时需要做空值兜底。 +`material_image`、`spec`、`manufacturer_model`、`supplier_id`、`manufacturer_id`、`category_id`、`base_unit_id`、`unit` 为空时**不会出现在响应里**(后端 `omitempty`),前端取值时需要做空值兜底。`supplier_name` 和 `manufacturer_name` 是伙伴显示名称。 **状态码:** @@ -243,7 +247,9 @@ "category_id": 12, "base_unit_id": 3, "unit": "个", - "material_image": "/uploads/bolt.png" + "material_image": "/uploads/bolt.png", + "supplier_id": 21, + "manufacturer_id": 22 } ``` @@ -257,6 +263,9 @@ | base_unit_id | integer | 否 | 基本计量单位 ID | | unit | string | 否 | 单位编码(冗余展示字段) | | material_image | string | 否 | 图片地址 | +| supplier_id | integer | 否 | 当前公司内具有供应商身份的伙伴 ID | +| manufacturer_id | integer | 否 | 当前公司内具有制造商身份的伙伴 ID | +| allow_duplicate | boolean | 否 | 重复提示后用户确认继续时传 `true` | | organization_id | integer | 否 | 传入会被忽略,服务端取当前用户的组织 | **响应:** `201 Created`,`data` 为创建后的物料对象。 @@ -274,6 +283,14 @@ - `201 Created` — 创建成功 - `400 Bad Request` — 请求体非法、物料编码已存在,或 `category_id` 不是有效的小类类型 +### GET /materials/partners?role=supplier|manufacturer + +返回当前公司内可选的伙伴编号和名称,供物料表单选择使用;支持 `limit`(最大 100)和 `offset` 分页。 + +### POST /materials/duplicate-check + +提交 `name`、`spec`、`unit`、`base_unit_id`、`supplier_id`、`manufacturer_id` 检查本公司已有物料。仅上述五个物料属性全部相同的记录会返回;`exclude_id` 可在编辑检查时排除自身。 + --- ### PUT /materials/:id @@ -308,6 +325,8 @@ | base_unit_id | integer | 否 | 基本计量单位 ID | | unit | string | 否 | 单位编码 | | material_image | string | 否 | 图片地址 | +| supplier_id | integer | 否 | 当前公司内供应商伙伴 ID;传 0 清空 | +| manufacturer_id | integer | 否 | 当前公司内制造商伙伴 ID;传 0 清空 | | status | string | 否 | 物料状态 | > `category_id` 在更新接口里同样是必填项。只改名称而不传分类会返回 `400`。 @@ -401,14 +420,14 @@ | 工作表 | 内容 | |--------|------| -| 填写说明 | 字段规则、填写与上传步骤、容量限制、整批校验、重复导入提醒和参考示例 | -| 物料数据 | 第 1 行是冻结表头,从第 2 行填写;五列采用文本格式,数据页不放示例物料 | +| 填写说明 | 字段规则、填写与上传步骤、容量限制、整批校验、重复检测规则和参考示例 | +| 物料数据 | 第 1 行是冻结表头,从第 2 行填写;七列采用文本格式,数据页不放示例物料 | -`物料数据` 的表头是 5 列:`物料名称`、`规格`、`单位`、`大类`、`小类`。解析时**按列名定位,不按列位置**: +`物料数据` 的表头是 7 列:`物料名称`、`规格`、`单位`、`供应商`、`制造商`、`大类`、`小类`。解析时**按列名定位,不按列位置**: - 列顺序可以随意调整;表头允许带首尾空格和末尾的 `*` 必填标记;不认识的列忽略; - 必填列 `物料名称`、`大类`、`小类` 缺任何一列,判为模板不合法并返回「模板格式不正确,请重新下载最新模板」; -- 选填列 `规格`、`单位` 可以整列删掉,取值按空处理。 +- 选填列 `规格`、`单位`、`供应商`、`制造商` 可以整列删掉,取值按空处理。 分类**按名称**填写;名称之外也接受**编码**和界面上的「编码 名称」写法(界面一律显示成 `A 机械结构类`,用户整段照抄进模板是常事)。后端按「大类 + 小类」在本公司内组合匹配(小类名在一个公司内会重名,例如多个大类下都有「其他」,所以必须两级组合)。名称、编码、「编码 名称」三种写法同名或同码时以**名称**优先。单位按名称匹配,同时兼容按编码匹配(如 `kg` 与 `千克`)。 @@ -427,12 +446,16 @@ | 物料名称 | 必填,去首尾空格后非空,≤ 200 字 | | 规格 | 选填,≤ 200 字 | | 单位 | 选填;留空时默认「个」。非空时必须在当前公司已启用单位中按名称或编码匹配到 | +| 供应商 | 选填;按当前公司供应商名称或伙伴编码匹配,留空表示不指定 | +| 制造商 | 选填;按当前公司制造商名称或伙伴编码匹配,留空表示不指定 | | 大类 | 必填,必须是当前公司已启用的顶层分类,按名称、编码或「编码 名称」匹配 | | 小类 | 必填,必须是该大类下已启用的小类,同样认名称、编码或「编码 名称」 | | 编码规则 | 该小类必须存在启用的编码规则(`code_rules.status = 'active'`) | 物料编码由系统按小类编码规则生成,**不接受模板里的编码列**。单位落库统一写该单位的**编码**——模板里填名称(如「千克」)时也会转成编码,与单条新增保持一致。同一行可能产生多条原因,用中文分号连接。 +单条新增会检查名称、规格、单位、供应商和制造商是否与现有物料全部相同;发现匹配项会提示并允许用户取消或继续创建。 + **成功响应 `201 Created`:** ```json @@ -472,7 +495,7 @@ - 整批在**一个事务**内完成:按小类锁编码规则 → 分配编码 → 分批插入物料(每批 500 行)→ 写审计日志;任一步失败整体回滚。 - **仅记录成功导入**:事务提交后写一条 `material_import_logs` 成功汇总,包含操作人、公司、时间、文件名、总行数和成功数,失败数为 0、状态为 `success`、失败原因为空;失败导入不保存历史记录。**逐行错误明细只随响应返回,不落库。** 成功新增的每件物料仍写 CREATE 日志及完整 `new_value` JSON 快照;现有日志页「查看详情」显示「批量导入创建:物料名称(物料编码)」,下方按行展示导入时的编码、名称、规格、分类、单位和状态;该详情是物料操作日志,不展示导入文件名或批次汇总。 - 首版只支持**新增**物料:不做更新,也不自动创建分类或单位。 -- **不做重复检测**:同一份文件重复上传会真的导入两遍(生成不同的编码)。 +- **重复检测**:物料名称、规格、单位、供应商和制造商全部相同才判为重复;批量导入也会检查已有物料和本文件前序行,发现重复行时整批不写入,并返回行号和原因。供应商、制造商可留空;填写时使用当前公司的伙伴名称或伙伴编码。 --- diff --git a/docs/superpowers/plans/2026-10-13-material-duplicate-check.md b/docs/superpowers/plans/2026-10-13-material-duplicate-check.md new file mode 100644 index 00000000..b6ec4bb2 --- /dev/null +++ b/docs/superpowers/plans/2026-10-13-material-duplicate-check.md @@ -0,0 +1,80 @@ +# 物料供应商、制造商与重复检查实施计划 + +> **执行说明:** 按任务逐项实施;本次在当前会话中直接执行。 + +**目标:** 为物料新增/编辑及 Excel 导入增加可选供应商和制造商,并检查物料名称、规格、单位、供应商、制造商五项完全相同的记录。 + +**实现方式:** 复用 `materials.supplier_id` 和 `materials.manufacturer_id` 关联当前公司的伙伴记录。扩展物料请求、读取和保存流程,并在服务端校验伙伴所属公司和角色。单条新增发现重复时由界面提示确认;批量导入发现数据库或同一文件中的重复行时,按现有整批校验规则返回行号并取消整批写入。 + +**技术栈:** Go、MySQL、Vue 3、Element Plus、Excelize。 + +**需求依据:** 2026-10-13 会话中用户确认的规则和字段范围。 + +## 全局约束 + +- 供应商和制造商均为选填。 +- 仅当名称、规格、单位、供应商、制造商全部相同时,判定为重复。 +- 伙伴必须属于当前公司,并具有对应的供应商或制造商身份。 +- 保留工作区中已有的未提交修改,不覆盖无关内容。 +- 按本会话要求,不新增或运行测试。 + +--- + +### 任务一:保存并返回物料伙伴关联 + +**文件:** +- 修改:`internal/model/model.go` +- 修改:`internal/service/material.go` +- 修改:`internal/repository/material.go` +- 修改:`internal/handler/api.go` + +**接口约定:** +- 物料 JSON 使用可空的 `supplier_id` 和 `manufacturer_id` 伙伴 ID。 +- 新增与更新时,服务端校验伙伴属于当前公司且具备相应身份。 + +- [ ] 在物料实体和新增/更新请求中加入两个可选 ID。 +- [ ] 在新增、查询、列表、更新 SQL 中读写数据库已有的两个字段。 +- [ ] 增加公司范围和伙伴身份校验,并将无效选择作为请求错误返回。 + +### 任务二:完善单条物料表单和重复提示 + +**文件:** +- 修改:`frontend/src/views/materialForm.js` +- 修改:`frontend/src/views/Materials.vue` +- 修改:`frontend/src/api/materials.js` +- 修改:`frontend/src/api/partners.js` +- 修改:`internal/repository/material.go` +- 修改:`internal/service/material.go` +- 修改:`internal/handler/api.go` + +**接口约定:** +- 判重请求传入名称、规格、单位及供应商/制造商 ID,返回当前公司的匹配物料。 +- 新增表单显示匹配记录,并允许用户取消或确认继续新增。 + +- [ ] 增加供应商、制造商选择框,从当前公司按伙伴身份加载选项。 +- [ ] 增加重复检查接口,并按确认的五项字段匹配。 +- [ ] 新增物料前检查重复项;命中时展示确认提示。 + +### 任务三:完善 Excel 模板和批量重复校验 + +**文件:** +- 修改:`internal/model/material_import.go` +- 修改:`internal/service/material_import_excel.go` +- 修改:`internal/service/material_import.go` +- 修改:`internal/repository/material.go` +- 修改:`frontend/src/components/business/MaterialImportResult.vue` +- 修改:`docs/api.md` + +**接口约定:** +- 模板中的 `供应商`、`制造商` 列均为选填;按当前公司伙伴名称或编码解析。 +- 与已有物料或本文件较早行完全重复时,返回对应行号;任一行出错则整批不写入。 + +- [ ] 更新模板表头、填写说明、示例行、解析器和错误数据。 +- [ ] 将伙伴名称或编码解析为当前公司的伙伴 ID,并报告无效或身份不符的输入。 +- [ ] 批量读取已有物料判重,并检查本文件内前序行。 +- [ ] 更新错误明细页面和 API 文档。 + +## 完成检查 + +- [ ] 检查差异,确认工作区既有的无关修改仍保留。 +- [ ] 不运行测试或构建,遵循本会话要求。 diff --git a/docs/回归测试用例.md b/docs/回归测试用例.md index f46f030a..8a144d59 100644 --- a/docs/回归测试用例.md +++ b/docs/回归测试用例.md @@ -196,24 +196,24 @@ **操作步骤:** 在「物料主数据 → 基础配置」依次新增一个大类、其下的小类、一个计量单位,并为小类配置启用的编码规则(前缀按约定取该小类编码,如小类编码为 `A01` 时前缀填 `A01`;流水号 4 位、下一流水号 1);进入「物料」新增物料,选择该大类、小类和单位,保存后再次新增一件同分类物料。 -**判断指标:** 配置项保存并在列表显示正确;新增物料可选到对应分类和单位;系统按规则生成编码,为小类编码加 4 位流水号(如小类 `A01` 时为 `A010001`、`A010002`),编码不重复,物料详情显示所选分类和单位。 +**判断指标:** 配置项保存并在列表显示正确;新增物料可选到对应分类、单位、供应商和制造商;供应商和制造商可以留空。系统按规则生成编码,为小类编码加 4 位流水号(如小类 `A01` 时为 `A010001`、`A010002`),编码不重复,物料列表和详情显示所选分类、单位、供应商和制造商。 ### TC-19 供应商和客户资料维护 -**测试内容:** 验证业务伙伴新增、编辑、分类筛选及供应商/客户身份页面数据正确。 +**测试内容:** 验证业务伙伴新增、编辑、分类筛选及供应商/制造商/客户身份页面数据正确,并可供物料选用。 -**前置数据:** 准备唯一伙伴编码及未使用的测试企业名称;供应商与客户页面均可访问。 +**前置数据:** 准备唯一伙伴编码及未使用的测试企业名称;供应商、制造商与客户页面均可访问。 -**操作步骤:** 在「业务伙伴 → 供应商」新增一条供应商资料,填写伙伴编码、企业名称、联系人和电话,并选择一个业务分类;保存后按分类筛选并打开详情;编辑联系人信息并保存。再到「客户」页面新增一条客户资料,检查列表和详情。 +**操作步骤:** 在「业务伙伴 → 供应商」新增一条供应商资料,填写伙伴编码、企业名称、联系人和电话,并选择一个业务分类;保存后按分类筛选并打开详情;编辑联系人信息并保存。再到「制造商」页面新增一条制造商资料,到「客户」页面新增一条客户资料,分别检查列表和详情。进入物料新增表单,确认供应商和制造商下拉项分别只显示具备对应身份的本组织伙伴。 -**判断指标:** 两类资料均保存成功且字段显示正确;供应商分类筛选结果匹配;编辑后联系人信息更新;供应商资料显示在供应商页面,客户资料显示在客户页面,页面间无错误混列。 +**判断指标:** 三类资料均保存成功且字段显示正确;供应商分类筛选结果匹配;编辑后联系人信息更新;供应商、制造商和客户资料分别显示在对应页面,页面间无错误混列;物料表单可分别选择本组织供应商和制造商。 ## 物料批量导入专项回归(TC-20到TC-27) ### 测试准备 - 使用测试环境和可操作物料的账号,准备可切换的组织 A、B。 -- 在组织 A 准备启用的大类、小类、单位「个」及编码为 `kg` 的单位,为小类配置启用的编码规则;另准备停用分类、停用单位和未配置规则的小类。 +- 在组织 A 准备启用的大类、小类、单位「个」及编码为 `kg` 的单位,为小类配置启用的编码规则;另准备停用分类、停用单位和未配置规则的小类。准备至少一个仅具供应商身份的伙伴和一个仅具制造商身份的伙伴,并记录其名称及伙伴编码;另准备组织 B 专属伙伴。 - 每个文件使用独立的测试物料名称,执行前记录物料列表和规则下一流水号。以下操作均通过页面和 Excel 手动完成,结果填写下方记录表。 ### TC-20 下载导入模板 @@ -222,7 +222,7 @@ **操作步骤:** 进入「物料」页面,点击「下载模板」,用 Excel 打开下载文件,查看工作表、表头和填写说明。 -**判断指标:** 文件可正常打开,第一个工作表为「填写说明」且默认打开,第二个为「物料数据」。说明页包含字段规则、操作步骤、容量限制、整批失败和重复导入提醒及参考示例;数据页保持空白,第 1 行表头为「物料名称、规格、单位、大类、小类」,从第 2 行填写,滚动时表头保持可见。名称、大类、小类必填,规格和单位选填;单位留空默认「个」,物料编码由系统生成;五列按文本格式填写,前导零不会因数字格式被去掉。 +**判断指标:** 文件可正常打开,第一个工作表为「填写说明」且默认打开,第二个为「物料数据」。说明页包含字段规则、操作步骤、容量限制、整批失败和重复检查提醒及参考示例;数据页保持空白,第 1 行表头为「物料名称、规格、单位、供应商、制造商、大类、小类」,从第 2 行填写,滚动时表头保持可见。名称、大类、小类必填;规格、单位、供应商和制造商选填;单位留空默认「个」,供应商和制造商可填写本组织伙伴名称或编码,物料编码由系统生成;各列按文本格式填写,前导零不会因数字格式被去掉。 ### TC-21 合法数据导入及编码生成 @@ -232,23 +232,23 @@ **操作步骤:** 在模板中填写两行不同名称的合法物料,保存后点击「批量导入」上传;查看提示、物料列表、详情和物料日志。刷新页面,再手动新增一件同小类物料,确认新编码与导入结果接续。 -**判断指标:** 提示成功导入 2 条,列表刷新,名称、规格、分类和单位正确;两条编码均按该小类规则生成,由小类编码加 4 位补零流水号组成(如小类 `A01` 时为 `A010001`、`A010002`),日志详情首行为「批量导入创建:物料名称(物料编码)」,下方按行显示导入时的物料编码、名称、规格、分类、单位和状态,同时保存新增内容快照及正确操作人;后续修改物料不应改变这条历史日志;随后单条新增编码为同小类编码加 `0003`(如 `A010003`),与导入的两条连续。导入结果刷新后仍可查询。 +**判断指标:** 提示成功导入 2 条,列表刷新,名称、规格、分类、单位、供应商和制造商正确;两条编码均按该小类规则生成,由小类编码加 4 位补零流水号组成(如小类 `A01` 时为 `A010001`、`A010002`),日志详情首行为「批量导入创建:物料名称(物料编码)」,下方按行显示导入时的物料编码、名称、规格、分类、单位和状态,同时保存新增内容快照及正确操作人;后续修改物料不应改变这条历史日志;随后单条新增编码为同小类编码加 `0003`(如 `A010003`),与导入的两条连续。导入结果刷新后仍可查询。 ### TC-22 分类写法与选填字段兼容 **测试内容:** 验证分类、单位的不同填写方式及空行处理。 -**操作步骤:** 分别使用分类名称、分类编码和页面显示的「编码 名称」填写大类、小类,给分类文本加首尾空格;单位分别填名称、编码 `kg` 或留空,规格留空。在文件中插入全空行;另存一份删除规格和单位列、调整剩余列顺序的文件,分别上传。 +**操作步骤:** 分别使用分类名称、分类编码和页面显示的「编码 名称」填写大类、小类,给分类文本加首尾空格;单位分别填名称、编码 `kg` 或留空,规格留空。供应商和制造商分别使用本组织伙伴名称、伙伴编码填写,并另测两列均留空。在文件中插入全空行;另存一份删除规格、单位、供应商和制造商列并调整剩余列顺序的文件,分别上传。 -**判断指标:** 合法写法均能导入并匹配正确分类和单位;规格可为空,单位留空或删列时默认「个」;列顺序变化不导致字段错位,全空行不计入导入数量。 +**判断指标:** 合法写法均能导入并匹配正确分类、单位、供应商和制造商;供应商和制造商留空或删列均可导入,单位留空或删列时默认「个」;列顺序变化不导致字段错位,全空行不计入导入数量。 ### TC-23 错误数据校验与整批拒绝 **测试内容:** 验证字段错误提示以及合法行不会被部分导入。 -**操作步骤:** 准备一份含合法行和错误行的文件,错误分别为名称/大类/小类为空、分类或单位不存在/停用、小类不属于所填大类、小类未配置启用规则、名称或规格超过 200 字。插入一个全空行后上传,检查错误明细,并按名称查询本次所有物料;对照编码规则的下一流水号。将超长字段改为 200 字且修正其余错误后重传。 +**操作步骤:** 准备一份含合法行和错误行的文件,错误分别为名称/大类/小类为空、分类或单位不存在/停用、小类不属于所填大类、小类未配置启用规则、名称或规格超过 200 字、供应商或制造商在本组织不存在/不具备对应身份;再准备跨组织伙伴作为错误值。插入一个全空行后上传,检查错误明细,并按名称查询本次所有物料;对照编码规则的下一流水号。将超长字段改为 200 字、修正其余错误并重传。 -**判断指标:** 首次提示整批未导入,错误原因可定位,行号与 Excel 实际行号一致,空行不会压缩行号;一行多个错误只计一条错误行,合法行也不新增。编码规则的下一流水号不变;全部修正后可成功导入,200 字的名称和规格可接受。 +**判断指标:** 首次提示整批未导入,错误原因可定位(包含供应商/制造商身份错误),行号与 Excel 实际行号一致,空行不会压缩行号;一行多个错误只计一条错误行,合法行也不新增。编码规则的下一流水号不变;全部修正后可成功导入,200 字的名称和规格可接受。 ### TC-24 错误分页、导出与修正重传 @@ -256,7 +256,7 @@ **操作步骤:** 上传含 21 条错误行的文件,其中字段包含中文、逗号或双引号;切换错误表第二页,点击「导出错误明细」,用 Excel 打开 CSV。关闭弹窗,在原 `.xlsx` 中修正全部错误,再选择同一个文件上传。 -**判断指标:** 第一页显示 20 条、第二页 1 条;CSV 包含全部 21 条错误及行号、原始字段、失败原因,中文无乱码、字段不串列。关闭后无旧错误残留,同一文件能重新选择并提交,修正后成功导入 21 条。 +**判断指标:** 第一页显示 20 条、第二页 1 条;CSV 包含全部 21 条错误及行号、名称、规格、单位、供应商、制造商、大类、小类和失败原因,中文无乱码、字段不串列。关闭后无旧错误残留,同一文件能重新选择并提交,修正后成功导入 21 条。 ### TC-25 文件格式、模板及容量限制 @@ -266,21 +266,21 @@ **判断指标:** 不支持的格式、超限大小和错误模板不能新增物料,并有可理解的提示;取消选择无新增。10000 行在允许范围内,应完整导入;10001 行被整批拒绝,表头和空行不计入数量。若出现超时,记录实际提示并刷新查询是否已导入,确认结果后再决定是否重传,避免重复新增。 -### TC-26 重复数据与重复上传 +### TC-26 单条新增及批量导入重复检查 -**测试内容:** 验证当前重复导入的实际行为。 +**测试内容:** 验证单条新增按名称、规格、单位、供应商和制造商五项完全相同提示确认;批量导入检查已有物料及同一文件内的重复行。 -**操作步骤:** 在同一文件中填写两条同名同规格的合法物料并上传;完成后再次选择同一个文件上传,查询物料及编码。 +**操作步骤:** 先新增一件物料,填写规格、单位、供应商和制造商。再次新增五项完全相同的物料,分别选择取消和确认;再逐项改变规格、单位、供应商或制造商,每次保存名称相同但其他字段不同的物料。随后准备批量导入文件:一行与现有物料五项完全相同,一行与前一行五项完全相同;另准备一份含有效且仅一项不同的合法物料文件。上传并查看错误明细、列表和编码规则流水号;修正重复值后重传。 -**判断指标:** 按当前功能,两次均成功,每次新增两条,合计四条独立物料,编码互不重复;原有物料未被覆盖。当前不做重复检测,重复上传会再次新增。 +**判断指标:** 单条新增五项完全相同会列出已有物料并提示确认;取消后不新增,确认后允许新增。名称相同但规格、单位、供应商或制造商任一项不同均不应误报重复。批量文件与已有物料重复或文件内重复时返回相应行号和原因,整批不新增且编码流水号不推进;有效且仅一项不同的行可成功导入,修正重复行后可重传成功。重复上传已成功导入的相同文件时,按当前记录仍重复的物料五项均相同,应提示重复并阻止整批再次导入。 ### TC-27 组织隔离与导入期间操作限制 **测试内容:** 验证主数据归属和导入期间的操作限制。 -**操作步骤:** 在组织 A 上传使用 B 专属分类或单位的文件;随后在 A 上传较多行的合法文件,在导入请求尚未完成时尝试切换组织及再次点击「批量导入」。完成后切到 B 查询本次测试物料,再切回 A 查询。 +**操作步骤:** 在组织 A 上传使用 B 专属分类、单位、供应商或制造商的文件;随后在 A 上传较多行的合法文件,在导入请求尚未完成时尝试切换组织及再次点击「批量导入」。完成后切到 B 查询本次测试物料,再切回 A 查询。 -**判断指标:** B 专属分类或单位在 A 被判不存在,整批拒绝;请求进行中组织切换被阻止,再次点击不发起重复导入。完成后可正常切换;新物料仅在 A 可查,B 的物料和编码规则不受影响,切回 A 后结果正确。 +**判断指标:** B 专属分类、单位或伙伴在 A 被判不存在或不具备对应身份,整批拒绝;请求进行中组织切换被阻止,再次点击不发起重复导入。完成后可正常切换;新物料仅在 A 可查,B 的物料和编码规则不受影响,切回 A 后结果正确。 ## 执行结果记录 diff --git a/docs/数据库表结构与关系说明.md b/docs/数据库表结构与关系说明.md index 22181bcb..c51112bf 100644 --- a/docs/数据库表结构与关系说明.md +++ b/docs/数据库表结构与关系说明.md @@ -1,6 +1,6 @@ # 物料管理系统数据库表说明 -更新时间:2026-10-09。本文前半部分保留已有库的结构说明;第 11 节说明公司隔离升级后的运行结构。表数量随升级增加审计、迁移版本及对账表,以目标库 `information_schema` 查询为准。 +更新时间:2026-10-12。本文前半部分保留已有库的结构说明;第 11 节说明公司隔离升级后的运行结构。表数量随升级增加审计、迁移版本及对账表,以目标库 `information_schema` 查询为准。 本文按照“表、保存什么、主要字段”介绍每张表的作用,后面单独列出表之间的关系。公司隔离发布使用 `scripts/init.sql`(空库)或 `scripts/upgrades/2026-10-08-company-master-data-isolation.sql`(已有库),不以旧库快照代替升级后的结构。 @@ -24,7 +24,7 @@ | 表 | 保存什么 | 主要字段 | | --------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `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` | +| `materials` | 物料基础资料,用于识别和描述物料;库存余额另存。 | 物料ID(`id`)、所属组织(`organization_id`)、物料编码(`material_code`)、名称(`name`)、图片(`material_image`)、规格(`spec`)、厂商型号(`manufacturer_model`)、默认供应商关联ID(`supplier_id`)、制造商关联ID(`manufacturer_id`)、分类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`) | @@ -36,7 +36,7 @@ | 表 | 保存什么 | 主要字段 | | ----------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `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` | +| `organization_partners` | 当前组织如何与这家企业合作。 | 关系ID(`id`)、所属组织(`organization_id`)、企业ID(`business_partner_id`)、伙伴编码(`partner_code`)、供应商身份(`is_supplier`)、制造商身份(`is_manufacturer`)、客户身份(`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`) | 同一外部企业可以与北京、武汉分别建立合作关系。企业名称、联系人、地址等基础资料共用;客户/供应商身份、状态和分类按组织分别保存。同一关系可同时具有客户和供应商身份。 @@ -243,3 +243,13 @@ 升级发现多个仓库/库位、多个默认公司、跨组织引用、未确认授权或无法确定流水来源时先阻断,禁止自动删除、合并或猜测归属。授权清单经业务确认后显式回填,不自动给普通用户授权所有公司。新公司分类/单位/规则默认为空,独立配置。 武汉目前没有历史数据(2026-10-09 用户确认),本次没有武汉历史副本检查或授权回填对象;空库初始化及合成历史夹具升级分别验证。部署、验收证据及回退限制见 [公司隔离验收与交付](company-master-data-isolation-acceptance.md)。 + +## 12. 物料供应商与制造商(2026-10-12) + +企业资料继续共用 `business_partners`,供应商、制造商、客户身份保存在 `organization_partners`。新增 `is_manufacturer BOOLEAN NOT NULL DEFAULT FALSE`,三个身份可同时存在;旧数据不会自动变成制造商。 + +`materials.supplier_id` 表示默认供应商,`materials.manufacturer_id` 表示制造商,两个字段都是可空 BIGINT,引用 `organization_partners.id`,不是 `business_partners.id`。`manufacturer_model` 继续表示厂家型号。 + +组织关联表增加唯一键 `(organization_id,id)`;物料通过 `(organization_id,supplier_id)` 和 `(organization_id,manufacturer_id)` 复合外键保证引用属于同一组织,删除或修改被引用的关联键会被拒绝。角色匹配需要由业务接口校验,外键本身不验证 `is_supplier` / `is_manufacturer`。 + +现有 `material_suppliers` 继续保存多个供应商的价格与供货条件;物料的 `supplier_id` 只表示默认选择。已有库使用 `scripts/upgrades/2026-10-12-material-partner-roles.sql` 升级,空库使用 `scripts/init.sql`。 diff --git a/frontend/src/api/materials.js b/frontend/src/api/materials.js index 762efa5e..188ed347 100644 --- a/frontend/src/api/materials.js +++ b/frontend/src/api/materials.js @@ -19,6 +19,9 @@ export const materialsApi = { getById(id) { return request.get(`/materials/${id}`) }, + checkDuplicates(data) { + return request.post('/materials/duplicate-check', data) + }, create(data) { return request.post('/materials', data) }, diff --git a/frontend/src/api/partners.js b/frontend/src/api/partners.js index 23db3922..efde8844 100644 --- a/frontend/src/api/partners.js +++ b/frontend/src/api/partners.js @@ -4,6 +4,9 @@ export const partnersApi = { list(role, params = {}) { return request.get('/partners', { params: { role, ...params } }) }, + listForMaterial(role, params = {}) { + return request.get('/materials/partners', { params: { role, ...params } }) + }, create(data) { return request.post('/partners', data) }, diff --git a/frontend/src/components/business/MaterialImportResult.vue b/frontend/src/components/business/MaterialImportResult.vue index 3fb48884..19d02be1 100644 --- a/frontend/src/components/business/MaterialImportResult.vue +++ b/frontend/src/components/business/MaterialImportResult.vue @@ -37,6 +37,12 @@ + + + + + + @@ -86,13 +92,15 @@ function handlePageChange(page) { } function exportErrors() { - const rows = [['行号', '物料名称', '规格', '单位', '大类', '小类', '失败原因']] + const rows = [['行号', '物料名称', '规格', '单位', '供应商', '制造商', '大类', '小类', '失败原因']] for (const item of props.result.errors) { rows.push([ item.row, item.data?.name || '', item.data?.spec || '', item.data?.unit || '', + item.data?.supplier || '', + item.data?.manufacturer || '', item.data?.major || '', item.data?.minor || '', item.reason diff --git a/frontend/src/navigation/menu.js b/frontend/src/navigation/menu.js index 756e1918..a8e59c96 100644 --- a/frontend/src/navigation/menu.js +++ b/frontend/src/navigation/menu.js @@ -24,6 +24,7 @@ const sections = [ adminOnly: true, items: [ { path: '/partners/suppliers', label: '供应商', adminOnly: true }, + { path: '/partners/manufacturers', label: '制造商', adminOnly: true }, { path: '/partners/customers', label: '客户', adminOnly: true } ] }, diff --git a/frontend/src/navigation/menu.test.js b/frontend/src/navigation/menu.test.js index cdd46c07..e7d8cfd2 100644 --- a/frontend/src/navigation/menu.test.js +++ b/frontend/src/navigation/menu.test.js @@ -11,13 +11,13 @@ describe('getNavigationMenu', () => { ])).toEqual([ ['物料主数据', ['物料', '基础配置']], ['公司组织', ['组织管理', '用户管理', '角色与权限']], - ['业务伙伴', ['供应商', '客户']], + ['业务伙伴', ['供应商', '制造商', '客户']], ['库存管理', ['库存查询', '入库管理', '出库管理']] ]) expect(menu.sections.flatMap(section => section.items).map(item => item.path)).toEqual([ '/materials', '/master-data/settings', '/organizations', '/users', '/roles', - '/partners/suppliers', '/partners/customers', + '/partners/suppliers', '/partners/manufacturers', '/partners/customers', '/stock', '/inbound', '/outbound' ]) diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 829665b6..e3cbc8be 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -45,6 +45,12 @@ export const routes = [ component: () => import('../views/partners/Suppliers.vue'), meta: { requiresAuth: true, adminOnly: true, organizationScoped: true } }, + { + path: '/partners/manufacturers', + name: 'Manufacturers', + component: () => import('../views/partners/Manufacturers.vue'), + meta: { requiresAuth: true, adminOnly: true, organizationScoped: true } + }, { path: '/partners/customers', name: 'Customers', diff --git a/frontend/src/router/routes.test.js b/frontend/src/router/routes.test.js index 33263a55..67a8b326 100644 --- a/frontend/src/router/routes.test.js +++ b/frontend/src/router/routes.test.js @@ -10,6 +10,7 @@ describe('phase-one routes', () => { '/organizations', '/roles', '/partners/suppliers', + '/partners/manufacturers', '/partners/customers' ])) }) diff --git a/frontend/src/views/Materials.test.js b/frontend/src/views/Materials.test.js index 1fa5195c..669395f5 100644 --- a/frontend/src/views/Materials.test.js +++ b/frontend/src/views/Materials.test.js @@ -22,8 +22,10 @@ const materialsStore = vi.hoisted(() => ({ const materialsApi = vi.hoisted(() => ({ importFile: vi.fn(), - downloadImportTemplate: vi.fn() + downloadImportTemplate: vi.fn(), + checkDuplicates: vi.fn() })) +const partnersApi = vi.hoisted(() => ({ listForMaterial: vi.fn() })) vi.mock('../stores/materials', () => ({ useMaterialsStore: () => materialsStore })) vi.mock('../stores/auth', () => ({ useAuthStore: () => ({ isAdmin: true }) })) @@ -41,6 +43,8 @@ vi.mock('../composables/useSavingGuard', () => ({ beforeEach(() => { // restoreAllMocks 会清掉 vi.fn 的实现,这里每个用例重新装上直通实现。 savingGuard.runWithSaving = vi.fn(task => task()) + partnersApi.listForMaterial.mockResolvedValue({ data: { items: [], total: 0 } }) + materialsApi.checkDuplicates.mockResolvedValue({ data: [] }) }) vi.mock('../api/masterData', () => ({ materialCategoriesApi: { list: vi.fn() }, @@ -48,6 +52,7 @@ vi.mock('../api/masterData', () => ({ codeRulesApi: { next: vi.fn() } })) vi.mock('../api/materials', () => ({ materialsApi })) +vi.mock('../api/partners', () => ({ partnersApi })) // 真实的 ElSelect 在 jsdom 下会因自身更新报 "Maximum recursive updates exceeded", // 这里用替身直接派发 change,模拟用户选中某个分类。 diff --git a/frontend/src/views/Materials.vue b/frontend/src/views/Materials.vue index 3454f990..5b91df42 100644 --- a/frontend/src/views/Materials.vue +++ b/frontend/src/views/Materials.vue @@ -62,6 +62,8 @@ {{ getUnitDisplay(row) }} + + + + + + + + + + + + @@ -136,6 +148,8 @@ {{ getMajorCategoryDisplay(detailRow) }} {{ getMinorCategoryDisplay(detailRow) }} {{ getUnitDisplay(detailRow) }} + {{ detailRow.supplier_name || '-' }} + {{ detailRow.manufacturer_name || '-' }} {{ formatDate(detailRow.created_at, MATERIAL_DATE_OPTIONS) }} {{ formatDate(detailRow.updated_at, MATERIAL_DATE_OPTIONS) }} @@ -195,6 +209,7 @@ import { ElMessage, ElMessageBox } from 'element-plus' import { createMaterialForm, toMaterialPayload } from './materialForm' import { codeRulesApi, materialCategoriesApi, unitsApi } from '../api/masterData' import { materialsApi } from '../api/materials' +import { partnersApi } from '../api/partners' import { getMajorCategories, getMinorCategories, resolveCategoryPath } from '../utils/materialCategories' import { searchMaterialOptions } from '../utils/materialSearch' import { errorText } from '../utils/errorText' @@ -235,6 +250,8 @@ const importResult = ref(null) const importing = ref(false) const categories = ref([]) const units = ref([]) +const suppliers = ref([]) +const manufacturers = ref([]) // 用户是否手改过编码:没改过时切分类要按新规则重算,改过就以用户的值为准。 const codeEdited = ref(false) const isCreating = computed(() => dialogMode.value === 'create') @@ -355,11 +372,33 @@ onMounted(async () => { // 检索框却只给有效物料的话,作废的那些就搜不出来(而这一页正是恢复它们的入口)。 // 业务单据页(入库/出库/申请)仍用只有有效物料的 materialOptions。 materialsStore.fetchHistoricalMaterialOptions() - const [categoryResponse, unitResponse] = await Promise.all([materialCategoriesApi.list(), unitsApi.list()]) + const [categoryResponse, unitResponse, supplierResponse, manufacturerResponse] = await Promise.all([ + materialCategoriesApi.list(), + unitsApi.list(), + loadPartners('supplier'), + loadPartners('manufacturer') + ]) categories.value = categoryResponse.data || [] units.value = (unitResponse.data || []).filter(item => item.status === 'active') + suppliers.value = supplierResponse + manufacturers.value = manufacturerResponse }) +async function loadPartners(role) { + const items = [] + const limit = 100 + let offset = 0 + let total = 0 + do { + const response = await partnersApi.listForMaterial(role, { limit, offset }) + const pageItems = response.data?.items || [] + items.push(...pageItems) + total = response.data?.total ?? items.length + offset += pageItems.length + } while (offset < total) + return items +} + function syncCategory(id) { form.category = categories.value.find(item => item.id === id)?.code || '' // 手改过的编码保留用户的输入,没改过才跟着新分类的规则重算 @@ -521,6 +560,24 @@ async function handleSave() { saveLoading.value = true try { const formData = toMaterialPayload(form) + if (isCreating.value) { + const duplicateResponse = await materialsApi.checkDuplicates(formData) + const matches = duplicateResponse.data || [] + formData.allow_duplicate = false + if (matches.length > 0) { + const details = matches.map(item => `${item.material_code} · ${item.name}`).join('、') + try { + await ElMessageBox.confirm(`发现名称、规格、单位、供应商和制造商均相同的物料:${details}。仍要创建吗?`, '发现重复物料', { + confirmButtonText: '仍然创建', + cancelButtonText: '取消', + type: 'warning' + }) + formData.allow_duplicate = true + } catch { + return + } + } + } const file = imageInput.value?.files[0] if (file) { const uploadForm = new FormData() diff --git a/frontend/src/views/MaterialsOrganizationLifecycle.test.js b/frontend/src/views/MaterialsOrganizationLifecycle.test.js index 748905ea..a42a197f 100644 --- a/frontend/src/views/MaterialsOrganizationLifecycle.test.js +++ b/frontend/src/views/MaterialsOrganizationLifecycle.test.js @@ -10,6 +10,7 @@ import { createPinia, setActivePinia } from 'pinia' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import Materials from './Materials.vue' import { materialsApi } from '../api/materials' +import { partnersApi } from '../api/partners' import { materialCategoriesApi, unitsApi } from '../api/masterData' import { authApi } from '../api/auth' import { useOrganizationStore } from '../stores/organization' @@ -22,9 +23,11 @@ vi.mock('../api/materials', () => ({ create: vi.fn(), update: vi.fn(), obsolete: vi.fn(), - restore: vi.fn() + restore: vi.fn(), + checkDuplicates: vi.fn() } })) +vi.mock('../api/partners', () => ({ partnersApi: { listForMaterial: vi.fn() } })) vi.mock('../api/masterData', () => ({ materialCategoriesApi: { list: vi.fn() }, unitsApi: { list: vi.fn() }, @@ -78,6 +81,8 @@ describe('物料页在公司切换下的生命周期', () => { setActivePinia(createPinia()) vi.clearAllMocks() materialsApi.getAll.mockResolvedValue({ data: { items: [], total: 0 } }) + materialsApi.checkDuplicates.mockResolvedValue({ data: [] }) + partnersApi.listForMaterial.mockResolvedValue({ data: { items: [], total: 0 } }) materialCategoriesApi.list.mockResolvedValue({ data: [] }) unitsApi.list.mockResolvedValue({ data: [] }) authApi.listOrganizations.mockReset() diff --git a/frontend/src/views/materialForm.js b/frontend/src/views/materialForm.js index cfd2fcbc..148d8d77 100644 --- a/frontend/src/views/materialForm.js +++ b/frontend/src/views/materialForm.js @@ -8,7 +8,9 @@ const materialKeys = [ 'category_id', 'base_unit_id', 'unit', - 'material_image' + 'material_image', + 'supplier_id', + 'manufacturer_id' ] export function createMaterialForm(source = {}, categories = []) { @@ -22,14 +24,18 @@ export function createMaterialForm(source = {}, categories = []) { material_image: source.material_image || '', major_category_id: source.major_category_id ?? major?.id ?? null, category_id: source.category_id ?? null, - base_unit_id: source.base_unit_id ?? null + base_unit_id: source.base_unit_id ?? null, + supplier_id: source.supplier_id ?? null, + manufacturer_id: source.manufacturer_id ?? null } } export function toMaterialPayload(source) { const payload = {} for (const key of materialKeys) { - payload[key] = key.endsWith('_id') ? source[key] ?? null : source[key] || '' + payload[key] = key === 'supplier_id' || key === 'manufacturer_id' + ? source[key] ?? 0 + : key.endsWith('_id') ? source[key] ?? null : source[key] || '' } if (!payload.unit) payload.unit = '个' return payload diff --git a/frontend/src/views/materialForm.test.js b/frontend/src/views/materialForm.test.js index 778fd46a..08d519bb 100644 --- a/frontend/src/views/materialForm.test.js +++ b/frontend/src/views/materialForm.test.js @@ -17,7 +17,9 @@ describe('material master-data form', () => { material_image: '', major_category_id: null, category_id: null, - base_unit_id: null + base_unit_id: null, + supplier_id: null, + manufacturer_id: null }) }) @@ -29,6 +31,8 @@ describe('material master-data form', () => { category: 'A01-01', category_id: 12, base_unit_id: 3, + supplier_id: 7, + manufacturer_id: 8, unit: '件', material_image: '', total_on_hand: 99, @@ -45,7 +49,9 @@ describe('material master-data form', () => { category_id: 12, base_unit_id: 3, unit: '件', - material_image: '' + material_image: '', + supplier_id: 7, + manufacturer_id: 8 }) }) }) diff --git a/frontend/src/views/partners/Manufacturers.vue b/frontend/src/views/partners/Manufacturers.vue new file mode 100644 index 00000000..13aa1edb --- /dev/null +++ b/frontend/src/views/partners/Manufacturers.vue @@ -0,0 +1,2 @@ + + diff --git a/frontend/src/views/partners/PartnerList.test.js b/frontend/src/views/partners/PartnerList.test.js index af1aa6fa..1d549e2c 100644 --- a/frontend/src/views/partners/PartnerList.test.js +++ b/frontend/src/views/partners/PartnerList.test.js @@ -90,6 +90,37 @@ describe('伙伴客户和供应商身份多选', () => { }) afterEach(() => { vi.restoreAllMocks() }) + test('制造商页默认制造商身份并支持独立保存', async () => { + const wrapper = mountPartner('manufacturer', '制造商') + await flushPromises() + expect(partnersApi.list).toHaveBeenCalledWith('manufacturer', { limit: 20, offset: 0 }) + await openCreateDialog(wrapper, '制造商') + const checkboxes = businessRoleCheckboxes(wrapper) + expect(checkboxes).toHaveLength(3) + expect(checkboxes[2].element.checked).toBe(true) + expect(checkboxes[1].element.checked).toBe(false) + await inputByLabel(wrapper, '伙伴编码').setValue('MFG-001') + await inputByLabel(wrapper, '企业名称').setValue('制造企业') + await clickSave(wrapper) + expect(partnersApi.create).toHaveBeenCalledWith(expect.objectContaining({ is_manufacturer: true, is_supplier: false, is_customer: false })) + wrapper.unmount() + }) + + test('供应商编辑回显制造商身份,保存时保留三个角色及其他合作资料', async () => { + const row = { id: 7, partner_code: 'ALL-001', legal_name: '多角色企业', is_customer: true, is_supplier: true, is_manufacturer: true, + customer_status: 'suspended', supplier_status: 'active', customer_categories: ['material'], supplier_categories: ['finished_product'] } + partnersApi.list.mockResolvedValue({ data: { items: [row], total: 1 } }) + const wrapper = mountPartner() + await flushPromises() + await findButton(wrapper, '编辑').trigger('click') + await flushPromises() + expect(businessRoleCheckboxes(wrapper).every(checkbox => checkbox.element.checked)).toBe(true) + await clickSave(wrapper) + expect(partnersApi.update).toHaveBeenCalledWith(7, expect.objectContaining({ is_supplier: true, is_customer: true, is_manufacturer: true, + customer_status: 'suspended', customer_categories: ['material'], supplier_categories: ['finished_product'] })) + wrapper.unmount() + }) + test.each([['supplier', '供应商', false, true], ['customer', '客户', true, false]])( '%s 新增默认当前身份,可双选并提交两个身份', async (role, title, customer, supplier) => { const wrapper = mountPartner(role, title) @@ -117,7 +148,8 @@ describe('伙伴客户和供应商身份多选', () => { await findButton(wrapper, '编辑').trigger('click') await flushPromises() const checkboxes = businessRoleCheckboxes(wrapper) - expect(checkboxes.every(checkbox => checkbox.element.checked)).toBe(true) + expect(checkboxes.slice(0, 2).every(checkbox => checkbox.element.checked)).toBe(true) + expect(checkboxes[2].element.checked).toBe(false) await checkboxes[0].setValue(customer) await checkboxes[1].setValue(supplier) expect(row.is_customer).toBe(true) diff --git a/frontend/src/views/partners/PartnerList.vue b/frontend/src/views/partners/PartnerList.vue index 4ab2108a..f6c353b6 100644 --- a/frontend/src/views/partners/PartnerList.vue +++ b/frontend/src/views/partners/PartnerList.vue @@ -4,7 +4,7 @@

{{ title }}

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

新增{{ title }} -
+
@@ -21,7 +21,12 @@ > - + + + +