fix(material-import): 导入落库的单位统一写编码,与单条新增一致

模板里填单位名称(如「千克」)时原本把名称原样存进 materials.unit,而单条新增
经 syncUnit 提交的是单位编码 kg。同一个单位按录入路径留下两种写法,库存页直接
显示该字段(Stock.vue 的 unit 列)会出现「千克」与「kg」并存。

- material_import.go:Unit 一律取匹配到的单位 Code
- 补回归用例:模板填名称、填编码两条路径都断言落库为 kg
- 同步计划文档的代码与测试片段、设计约定 4 的单位一行
- 订正 api.md 中 unit 字段「单位名称」的错误描述(单条新增提交的本就是编码)
This commit is contained in:
文睿 张 2026-10-12 11:51:44 +08:00
parent 0ddda5363e
commit 8d195c2d51
4 changed files with 100 additions and 6 deletions

View File

@ -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`:**

View File

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

View File

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

View File

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