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(