diff --git a/frontend/src/views/Materials.test.js b/frontend/src/views/Materials.test.js
index 34e91943..115b86c2 100644
--- a/frontend/src/views/Materials.test.js
+++ b/frontend/src/views/Materials.test.js
@@ -42,7 +42,7 @@ const SelectStub = {
template: '
'
}
-const OptionStub = { props: ['label', 'value'], template: '' }
+const OptionStub = { props: ['label', 'value', 'disabled'], template: '' }
const categories = [
{ id: 1, code: 'A', name: '机械结构类', parent_id: null, status: 'active' },
@@ -380,3 +380,38 @@ describe('物料列表的作废入口文案', () => {
wrapper.unmount()
})
})
+
+describe('停用分类下的物料编辑', () => {
+ test('保留原分类并显示停用状态,修改名称时不改变分类归属', async () => {
+ materialCategoriesApi.list.mockResolvedValue({ data: [
+ { id: 1, code: 'A', name: '旧大类', parent_id: null, status: 'inactive' },
+ { id: 12, code: 'A01', name: '旧小类', parent_id: 1, status: 'inactive' },
+ { id: 2, code: 'B', name: '新大类', parent_id: null, status: 'active' },
+ { id: 21, code: 'B01', name: '新小类', parent_id: 2, status: 'active' }
+ ] })
+ unitsApi.list.mockResolvedValue({ data: [] })
+ materialsStore.updateMaterial.mockReset().mockResolvedValue({ data: {} })
+ vi.spyOn(ElMessage, 'success').mockImplementation(() => {})
+
+ const wrapper = mountMaterials()
+ await flushPromises()
+ const state = setupState(wrapper)
+ const row = { id: 7, category_id: 12, category: 'A01', material_code: 'MAT-7', name: '原名称' }
+
+ expect(state.getMajorCategoryDisplay(row)).toBe('A 旧大类')
+ expect(state.getMinorCategoryDisplay(row)).toBe('旧小类')
+
+ state.showEditModal(row)
+ await flushPromises()
+ expect(state.form.major_category_id).toBe(1)
+ expect(state.form.category_id).toBe(12)
+ expect(state.majorCategories.map(category => category.id)).toContain(1)
+ expect(state.minorCategories.map(category => category.id)).toContain(12)
+ expect(state.categoryLabel(state.minorCategories.find(category => category.id === 12))).toBe('旧小类(已停用)')
+
+ state.form.name = '新名称'
+ await state.handleSave()
+ expect(materialsStore.updateMaterial).toHaveBeenCalledWith(7, expect.objectContaining({ category_id: 12, name: '新名称' }))
+ wrapper.unmount()
+ })
+})
diff --git a/frontend/src/views/Materials.vue b/frontend/src/views/Materials.vue
index d9bbbe6e..40052798 100644
--- a/frontend/src/views/Materials.vue
+++ b/frontend/src/views/Materials.vue
@@ -88,12 +88,12 @@
-
+
-
+
@@ -203,8 +203,21 @@ const codePlaceholder = computed(() => {
if (!isCreating.value || form.category_id) return ''
return '请先选择大类类型与小类类型'
})
-const majorCategories = computed(() => getMajorCategories(categories.value))
-const minorCategories = computed(() => getMinorCategories(categories.value, form.major_category_id))
+const majorCategories = computed(() => {
+ const active = getMajorCategories(categories.value)
+ const selected = categories.value.find(item => item.id === form.major_category_id)
+ return selected && !active.some(item => item.id === selected.id) ? [...active, selected] : active
+})
+const minorCategories = computed(() => {
+ const active = getMinorCategories(categories.value, form.major_category_id)
+ const selected = categories.value.find(item => item.id === form.category_id && item.parent_id === form.major_category_id)
+ return selected && !active.some(item => item.id === selected.id) ? [...active, selected] : active
+})
+
+function categoryLabel(category, withCode = false) {
+ const name = withCode ? `${category.code} ${category.name}` : category.name
+ return category.status === 'active' ? name : `${name}(已停用)`
+}
function handleImageUpload(e) {
const file = e.target.files[0]
@@ -222,7 +235,7 @@ onMounted(async () => {
// 业务单据页(入库/出库/申请)仍用只有有效物料的 materialOptions。
materialsStore.fetchHistoricalMaterialOptions()
const [categoryResponse, unitResponse] = await Promise.all([materialCategoriesApi.list(), unitsApi.list()])
- categories.value = (categoryResponse.data || []).filter(item => item.status === 'active')
+ categories.value = categoryResponse.data || []
units.value = (unitResponse.data || []).filter(item => item.status === 'active')
})
diff --git a/internal/service/material.go b/internal/service/material.go
index e3a9d39a..7588d80e 100644
--- a/internal/service/material.go
+++ b/internal/service/material.go
@@ -137,8 +137,13 @@ func (s *MaterialService) Update(id int64, req *model.UpdateMaterialRequest, ope
if err != nil {
return nil, err
}
- if err := s.validateCategory(req.CategoryID, oldMaterial.OrganizationID); err != nil {
- return nil, err
+ if req.CategoryID == nil || *req.CategoryID <= 0 {
+ return nil, ErrInvalidMaterialCategory
+ }
+ if oldMaterial.CategoryID == nil || *req.CategoryID != *oldMaterial.CategoryID {
+ if err := s.validateCategory(req.CategoryID, oldMaterial.OrganizationID); err != nil {
+ return nil, err
+ }
}
err = s.repo.Update(id, req)
diff --git a/internal/service/material_test.go b/internal/service/material_test.go
index f4cdb8ad..61de398a 100644
--- a/internal/service/material_test.go
+++ b/internal/service/material_test.go
@@ -77,6 +77,35 @@ func TestMaterialServiceTreatsObsoleteMaterialAsInactive(t *testing.T) {
}
}
+func TestMaterialServiceUpdatesWithoutChangingDisabledCategory(t *testing.T) {
+ mock, cleanup := newMaterialServiceMock(t)
+ t.Cleanup(cleanup)
+ now := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC)
+ materialRows := func(name string) *sqlmock.Rows {
+ return sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
+ AddRow(7, 1, "MAT-007", name, nil, "", nil, 12, nil, "pcs", "active", 1, now, now)
+ }
+ mock.ExpectQuery(regexp.QuoteMeta(getMaterialQuery)).WithArgs(int64(7)).WillReturnRows(materialRows("原名称"))
+ mock.ExpectExec(regexp.QuoteMeta("UPDATE materials SET name = ?, category_id = ?, version = version + 1 WHERE id = ?")).
+ WithArgs("新名称", int64(12), int64(7)).WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectQuery(regexp.QuoteMeta(getMaterialQuery)).WithArgs(int64(7)).WillReturnRows(materialRows("新名称"))
+ mock.ExpectExec(regexp.QuoteMeta(materialLogStmt)).
+ WithArgs("UPDATE", int64(7), sqlmock.AnyArg(), sqlmock.AnyArg(), "tester", sqlmock.AnyArg()).
+ WillReturnResult(sqlmock.NewResult(1, 1))
+
+ categoryID := int64(12)
+ material, err := NewMaterialService().Update(7, &model.UpdateMaterialRequest{Name: "新名称", CategoryID: &categoryID}, "tester")
+ if err != nil {
+ t.Fatalf("update material with unchanged disabled category: %v", err)
+ }
+ if material.CategoryID == nil || *material.CategoryID != 12 || material.Name != "新名称" {
+ t.Fatalf("updated material = %+v, want category 12 and new name", material)
+ }
+ if err := mock.ExpectationsWereMet(); err != nil {
+ t.Fatal(err)
+ }
+}
+
// 以下常量必须与 repository 里的 SQL 字面量逐字一致,sqlmock 才匹配得上。
const (
activeSubcategoryQuery = `SELECT EXISTS(