From 8d195c2d517252230459a2947119c0a58e69e391 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=87=E7=9D=BF=20=E5=BC=A0?= Date: Mon, 12 Oct 2026 11:51:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(material-import):=20=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E8=90=BD=E5=BA=93=E7=9A=84=E5=8D=95=E4=BD=8D=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=86=99=E7=BC=96=E7=A0=81=EF=BC=8C=E4=B8=8E=E5=8D=95=E6=9D=A1?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模板里填单位名称(如「千克」)时原本把名称原样存进 materials.unit,而单条新增 经 syncUnit 提交的是单位编码 kg。同一个单位按录入路径留下两种写法,库存页直接 显示该字段(Stock.vue 的 unit 列)会出现「千克」与「kg」并存。 - material_import.go:Unit 一律取匹配到的单位 Code - 补回归用例:模板填名称、填编码两条路径都断言落库为 kg - 同步计划文档的代码与测试片段、设计约定 4 的单位一行 - 订正 api.md 中 unit 字段「单位名称」的错误描述(单条新增提交的本就是编码) --- docs/api.md | 6 +-- .../plans/2026-10-10-material-bulk-import.md | 51 ++++++++++++++++++- internal/service/material_import.go | 5 +- internal/service/material_import_test.go | 44 ++++++++++++++++ 4 files changed, 100 insertions(+), 6 deletions(-) diff --git a/docs/api.md b/docs/api.md index f30fbe08..c12ce7f9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -255,7 +255,7 @@ | spec | string | 否 | 规格 | | manufacturer_model| string | 否 | 厂家型号 | | base_unit_id | integer | 否 | 基本计量单位 ID | -| unit | string | 否 | 单位名称(冗余展示字段) | +| unit | string | 否 | 单位编码(冗余展示字段) | | material_image | string | 否 | 图片地址 | | organization_id | integer | 否 | 传入会被忽略,服务端取当前用户的组织 | @@ -306,7 +306,7 @@ | spec | string | 否 | 规格 | | manufacturer_model| string | 否 | 厂家型号 | | base_unit_id | integer | 否 | 基本计量单位 ID | -| unit | string | 否 | 单位名称 | +| unit | string | 否 | 单位编码 | | material_image | string | 否 | 图片地址 | | status | string | 否 | 物料状态 | @@ -431,7 +431,7 @@ | 小类 | 必填,必须是该大类下已启用的小类,同样认名称、编码或「编码 名称」 | | 编码规则 | 该小类必须存在启用的编码规则(`code_rules.status = 'active'`) | -物料编码由系统按小类编码规则生成,**不接受模板里的编码列**。同一行可能产生多条原因,用中文分号连接。 +物料编码由系统按小类编码规则生成,**不接受模板里的编码列**。单位落库统一写该单位的**编码**——模板里填名称(如「千克」)时也会转成编码,与单条新增保持一致。同一行可能产生多条原因,用中文分号连接。 **成功响应 `201 Created`:** diff --git a/docs/superpowers/plans/2026-10-10-material-bulk-import.md b/docs/superpowers/plans/2026-10-10-material-bulk-import.md index 9566ec64..730213bf 100644 --- a/docs/superpowers/plans/2026-10-10-material-bulk-import.md +++ b/docs/superpowers/plans/2026-10-10-material-bulk-import.md @@ -91,7 +91,7 @@ | ---- | ------------------------------------------- | | 物料名称 | 必填,去首尾空格后非空,≤ 200 字 | | 规格 | 选填,≤ 200 字 | -| 单位 | 选填;为空时默认「个」。非空时必须在当前公司已启用单位中按名称或编码匹配到 | +| 单位 | 选填;为空时默认「个」。非空时必须在当前公司已启用单位中按名称或编码匹配到;落库统一写单位**编码**(与单条新增一致) | | 大类 | 必填,必须是当前公司已启用的顶层分类:名称、编码或「编码 名称」写法,匹配前归一化空白 | | 小类 | 必填,必须是该大类下已启用的小类:名称、编码或「编码 名称」写法(按大类 ID + 小类写法组合定位) | | 编码规则 | 该小类必须存在 `status='active'` 的 `code_rules` 记录 | @@ -1349,6 +1349,7 @@ import ( "testing" "github.com/DATA-DOG/go-sqlmock" + "github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/pkg/database" ) @@ -1714,6 +1715,49 @@ func TestMaterialImportServiceAcceptsWhitespaceVariantsInCategoryNames(t *testin }) } } + +// 物料表的 unit 只存一种写法:单位编码。模板里填名称(界面显示的是「千克」这类 +// 显示名)时也得落成 kg,否则同一单位会按录入路径留下两种值——库存页直接显示该 +// 字段(Stock.vue 的 unit 列),会同时出现「千克」和「kg」。 +func TestMaterialImportServiceStoresUnitCodeWhenTemplateUsesUnitName(t *testing.T) { + mock, cleanup := newImportServiceMock(t) + defer cleanup() + + mock.ExpectQuery(regexp.QuoteMeta(importCategoryQuery)).WithArgs(int64(1)). + WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "description", "parent_id", "status"}). + AddRow(3, 1, "A", "机械结构类", "", nil, "active"). + AddRow(12, 1, "A01", "标准件", "", 3, "active")) + // 计量类单位:编码 kg、名称千克(计数类单位 code 与 name 相同,测不出差异) + mock.ExpectQuery(regexp.QuoteMeta(importUnitQuery)).WithArgs(int64(1)). + WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "precision_scale", "status"}). + AddRow(7, 1, "kg", "千克", 3, "active")) + mock.ExpectQuery(regexp.QuoteMeta(importRuleQuery)).WithArgs(int64(1), int64(12)). + WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence", "status"}). + AddRow(5, 1, 12, "A01", 4, 201, "active")) + + rows := []model.MaterialImportRow{ + {Row: 2, Name: "内六角螺栓", Spec: "M8x30", Unit: "千克", MajorName: "机械结构类", MinorName: "标准件"}, + {Row: 3, Name: "垫圈", Spec: "M8", Unit: "kg", MajorName: "机械结构类", MinorName: "标准件"}, + } + items, importErrors, err := NewMaterialImportService().validateRows(1, rows) + if err != nil { + t.Fatalf("validateRows: %v", err) + } + if len(importErrors) != 0 || len(items) != 2 { + t.Fatalf("items = %d, importErrors = %+v", len(items), importErrors) + } + for i, item := range items { + if item.Unit != "kg" { + t.Fatalf("items[%d].Unit = %q, want 单位编码 %q", i, item.Unit, "kg") + } + } + if items[0].BaseUnitID == nil || *items[0].BaseUnitID != 7 { + t.Fatalf("baseUnitID = %v, want 7", items[0].BaseUnitID) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} ``` - [ ] **Step 2: 跑测试确认失败** @@ -1947,13 +1991,16 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model. baseUnitID = &id } categoryID := minor.ID + // 模板里填的是名称时也落编码:编码是单位表的唯一键,名称是可变的显示名, + // 物料表的 unit 只该有一种写法(与单条新增一致),否则库存页直接显示该 + // 字段的地方会出现「千克」「kg」并存。 materials = append(materials, &materialImportItem{Material: &model.Material{ OrganizationID: organizationID, Name: name, Spec: row.Spec, CategoryID: &categoryID, BaseUnitID: baseUnitID, - Unit: unitName, + Unit: unit.Code, Status: "active", Version: 1, }, categoryLabel: major.Name + " / " + minor.Name, unitLabel: unit.Name}) diff --git a/internal/service/material_import.go b/internal/service/material_import.go index a4e6ddbf..4f45ee49 100644 --- a/internal/service/material_import.go +++ b/internal/service/material_import.go @@ -219,13 +219,16 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model. baseUnitID = &id } categoryID := minor.ID + // 模板里填的是名称时也落编码:编码是单位表的唯一键,名称是可变的显示名, + // 物料表的 unit 只该有一种写法(与单条新增一致),否则库存页直接显示该 + // 字段的地方会出现「千克」「kg」并存。 materials = append(materials, &materialImportItem{Material: &model.Material{ OrganizationID: organizationID, Name: name, Spec: row.Spec, CategoryID: &categoryID, BaseUnitID: baseUnitID, - Unit: unitName, + Unit: unit.Code, Status: "active", Version: 1, }, categoryLabel: major.Name + " / " + minor.Name, unitLabel: unit.Name}) diff --git a/internal/service/material_import_test.go b/internal/service/material_import_test.go index 24765725..9c6f5a3c 100644 --- a/internal/service/material_import_test.go +++ b/internal/service/material_import_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/DATA-DOG/go-sqlmock" + "github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/pkg/database" ) @@ -375,3 +376,46 @@ func TestMaterialImportServiceAcceptsWhitespaceVariantsInCategoryNames(t *testin }) } } + +// 物料表的 unit 只存一种写法:单位编码。模板里填名称(界面显示的是「千克」这类 +// 显示名)时也得落成 kg,否则同一单位会按录入路径留下两种值——库存页直接显示该 +// 字段(Stock.vue 的 unit 列),会同时出现「千克」和「kg」。 +func TestMaterialImportServiceStoresUnitCodeWhenTemplateUsesUnitName(t *testing.T) { + mock, cleanup := newImportServiceMock(t) + defer cleanup() + + mock.ExpectQuery(regexp.QuoteMeta(importCategoryQuery)).WithArgs(int64(1)). + WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "description", "parent_id", "status"}). + AddRow(3, 1, "A", "机械结构类", "", nil, "active"). + AddRow(12, 1, "A01", "标准件", "", 3, "active")) + // 计量类单位:编码 kg、名称千克(计数类单位 code 与 name 相同,测不出差异) + mock.ExpectQuery(regexp.QuoteMeta(importUnitQuery)).WithArgs(int64(1)). + WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "precision_scale", "status"}). + AddRow(7, 1, "kg", "千克", 3, "active")) + mock.ExpectQuery(regexp.QuoteMeta(importRuleQuery)).WithArgs(int64(1), int64(12)). + WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence", "status"}). + AddRow(5, 1, 12, "A01", 4, 201, "active")) + + rows := []model.MaterialImportRow{ + {Row: 2, Name: "内六角螺栓", Spec: "M8x30", Unit: "千克", MajorName: "机械结构类", MinorName: "标准件"}, + {Row: 3, Name: "垫圈", Spec: "M8", Unit: "kg", MajorName: "机械结构类", MinorName: "标准件"}, + } + items, importErrors, err := NewMaterialImportService().validateRows(1, rows) + if err != nil { + t.Fatalf("validateRows: %v", err) + } + if len(importErrors) != 0 || len(items) != 2 { + t.Fatalf("items = %d, importErrors = %+v", len(items), importErrors) + } + for i, item := range items { + if item.Unit != "kg" { + t.Fatalf("items[%d].Unit = %q, want 单位编码 %q", i, item.Unit, "kg") + } + } + if items[0].BaseUnitID == nil || *items[0].BaseUnitID != 7 { + t.Fatalf("baseUnitID = %v, want 7", items[0].BaseUnitID) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +}