From a7c7936e0b402b3cd6c5b164a0b2486255b224f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=87=E7=9D=BF=20=E5=BC=A0?= Date: Sun, 20 Sep 2026 16:48:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E7=89=A9=E6=96=99?= =?UTF-8?q?=E7=BC=96=E7=A0=81=E6=8C=89=E5=88=86=E7=B1=BB=E8=A7=84=E5=88=99?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E7=94=9F=E6=88=90=E3=80=81=E6=89=8B=E5=B7=A5?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=8F=8A=E5=B9=B6=E5=8F=91=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E7=9A=84=E6=B5=81=E6=B0=B4=E5=8F=B7=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api.md | 2 +- frontend/src/api/masterData.js | 9 +- frontend/src/views/Materials.test.js | 211 ++++++++++++++++++ frontend/src/views/Materials.vue | 94 +++++++- .../views/master-data/MasterDataSettings.vue | 5 +- internal/handler/admin_master.go | 20 ++ internal/handler/api.go | 10 +- internal/handler/routes_test.go | 1 + internal/model/master_data.go | 15 ++ internal/repository/master_data.go | 20 +- internal/repository/material.go | 107 ++++++++- internal/service/admin_master.go | 13 ++ internal/service/material.go | 4 +- internal/service/material_test.go | 199 +++++++++++++++++ 14 files changed, 695 insertions(+), 15 deletions(-) create mode 100644 frontend/src/views/Materials.test.js diff --git a/docs/api.md b/docs/api.md index d0a9a0d9..5c8673bf 100644 --- a/docs/api.md +++ b/docs/api.md @@ -66,7 +66,7 @@ | 角色 / 权限 | `/roles`、`/permissions` | 仅管理员 | | 主数据:物料分类 | `/material-categories` | 仅管理员 | | 主数据:计量单位 | `/units` | 仅管理员 | -| 主数据:编码规则 | `/code-rules` | 仅管理员 | +| 主数据:编码规则 | `/code-rules`、`GET /code-rules/next` | 仅管理员;`/next` 需登录 | | 业务伙伴 | `/partners` | 仅管理员 | | 仓库与库位 | `/warehouses`、`/warehouses/:id/locations` | 仅管理员 | | 物料申请 | `/applications`、`/applications/my` | 需登录 | diff --git a/frontend/src/api/masterData.js b/frontend/src/api/masterData.js index d0b62a71..a6a6d932 100644 --- a/frontend/src/api/masterData.js +++ b/frontend/src/api/masterData.js @@ -16,4 +16,11 @@ function resource(path) { export const materialCategoriesApi = resource('/material-categories') export const unitsApi = resource('/units') -export const codeRulesApi = resource('/code-rules') +export const codeRulesApi = { + ...resource('/code-rules'), + // 按小类规则预览下一个物料编码,供新增物料时自动填入;available 为 false + // 表示该小类没有规则,编码得手工填。 + next(categoryId) { + return request.get('/code-rules/next', { params: { category_id: categoryId } }) + } +} diff --git a/frontend/src/views/Materials.test.js b/frontend/src/views/Materials.test.js new file mode 100644 index 00000000..f1ea8586 --- /dev/null +++ b/frontend/src/views/Materials.test.js @@ -0,0 +1,211 @@ +import { flushPromises, mount } from '@vue/test-utils' +import ElementPlus, { ElMessage } from 'element-plus' +import { beforeEach, describe, expect, test, vi } from 'vitest' +import Materials from './Materials.vue' +import { codeRulesApi, materialCategoriesApi, unitsApi } from '../api/masterData' + +const materialsStore = vi.hoisted(() => ({ + materials: [], + materialOptions: [], + loading: false, + pagination: { currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }, + fetchMaterials: vi.fn(), + fetchMaterialOptions: vi.fn(), + createMaterial: vi.fn(), + updateMaterial: vi.fn(), + obsoleteMaterial: vi.fn(), + restoreMaterial: vi.fn(), + getCategoryDisplay: vi.fn(() => '-') +})) + +vi.mock('../stores/materials', () => ({ useMaterialsStore: () => materialsStore })) +vi.mock('../stores/auth', () => ({ useAuthStore: () => ({ isAdmin: true }) })) +vi.mock('../api/masterData', () => ({ + materialCategoriesApi: { list: vi.fn() }, + unitsApi: { list: vi.fn() }, + codeRulesApi: { next: vi.fn() } +})) + +// 真实的 ElSelect 在 jsdom 下会因自身更新报 "Maximum recursive updates exceeded", +// 这里用替身直接派发 change,模拟用户选中某个分类。 +const SelectStub = { + props: ['modelValue', 'placeholder', 'filterable', 'filterMethod', 'noDataText', 'disabled', 'clearable'], + emits: ['update:modelValue', 'change'], + methods: { + pick(value) { + this.$emit('update:modelValue', value) + this.$emit('change', value) + } + }, + template: '
' +} + +const OptionStub = { props: ['label', 'value'], template: '' } + +const categories = [ + { id: 1, code: 'A', name: '机械结构类', parent_id: null, status: 'active' }, + { id: 12, code: 'A01', name: '标准件', parent_id: 1, status: 'active' }, + { id: 13, code: 'A02', name: '成品件', parent_id: 1, status: 'active' } +] + +function mountMaterials() { + return mount(Materials, { + global: { + plugins: [ElementPlus], + stubs: { teleport: true, ElSelect: SelectStub, ElOption: OptionStub } + } + }) +} + +function findButton(wrapper, text) { + return wrapper.findAll('button').find(button => button.text().includes(text)) +} + +// 页面上的物料搜索框也用了 ElSelect,弹窗里的三个依次是大类、小类、单位。 +function formSelects(wrapper) { + const selects = wrapper.findAllComponents(SelectStub) + return { major: selects[1], minor: selects[2], unit: selects[3] } +} + +function codeInput(wrapper) { + return wrapper.find('.material-code-field input') +} + +async function openAddDialog(wrapper) { + await findButton(wrapper, '新增物料').trigger('click') + await flushPromises() +} + +async function pickCategories(wrapper, { major = 1, minor = 12 } = {}) { + await formSelects(wrapper).major.vm.pick(major) + await formSelects(wrapper).minor.vm.pick(minor) + await flushPromises() +} + +describe('新增物料时的编码联动', () => { + let warnSpy + + beforeEach(() => { + materialCategoriesApi.list.mockResolvedValue({ data: categories }) + unitsApi.list.mockResolvedValue({ data: [] }) + codeRulesApi.next.mockReset() + materialsStore.createMaterial.mockReset() + materialsStore.updateMaterial.mockReset() + warnSpy = vi.spyOn(ElMessage, 'warning').mockImplementation(() => {}) + }) + + test('还没选小类时,编码框提示先选分类', async () => { + codeRulesApi.next.mockResolvedValue({ data: { available: true, code: 'A010201' } }) + + const wrapper = mountMaterials() + await flushPromises() + await openAddDialog(wrapper) + + expect(codeInput(wrapper).attributes('placeholder')).toBe('请先选择大类类型与小类类型') + + await pickCategories(wrapper) + + expect(codeInput(wrapper).attributes('placeholder')).toBe('') + wrapper.unmount() + }) + + test('选完小类后自动填入按规则算出的编码', async () => { + codeRulesApi.next.mockResolvedValue({ data: { available: true, code: 'A010201' } }) + + const wrapper = mountMaterials() + await flushPromises() + await openAddDialog(wrapper) + await pickCategories(wrapper) + + expect(codeRulesApi.next).toHaveBeenCalledWith(12) + expect(codeInput(wrapper).element.value).toBe('A010201') + expect(wrapper.find('.material-code-hint').exists()).toBe(false) + wrapper.unmount() + }) + + test('小类没有规则时清空编码并提示手工填写', async () => { + codeRulesApi.next.mockResolvedValue({ data: { available: false, code: '' } }) + + const wrapper = mountMaterials() + await flushPromises() + await openAddDialog(wrapper) + await pickCategories(wrapper) + + expect(codeInput(wrapper).element.value).toBe('') + expect(wrapper.find('.material-code-hint').text()).toContain('请手工填写') + wrapper.unmount() + }) + + test('没手改过编码时,换小类会按新分类的规则覆盖', async () => { + codeRulesApi.next + .mockResolvedValueOnce({ data: { available: true, code: 'A010201' } }) + .mockResolvedValueOnce({ data: { available: true, code: 'A020202' } }) + + const wrapper = mountMaterials() + await flushPromises() + await openAddDialog(wrapper) + await pickCategories(wrapper) + expect(codeInput(wrapper).element.value).toBe('A010201') + + await formSelects(wrapper).minor.vm.pick(13) + await flushPromises() + + expect(codeRulesApi.next).toHaveBeenLastCalledWith(13) + expect(codeInput(wrapper).element.value).toBe('A020202') + wrapper.unmount() + }) + + test('手改过编码后,换小类保留用户填的值', async () => { + codeRulesApi.next.mockResolvedValue({ data: { available: true, code: 'A010201' } }) + + const wrapper = mountMaterials() + await flushPromises() + await openAddDialog(wrapper) + await pickCategories(wrapper) + await codeInput(wrapper).setValue('MY-001') + await flushPromises() + + await formSelects(wrapper).minor.vm.pick(13) + await flushPromises() + + expect(codeInput(wrapper).element.value).toBe('MY-001') + // 手改过就不再白白请求一次预览接口 + expect(codeRulesApi.next).toHaveBeenCalledTimes(1) + wrapper.unmount() + }) + + test('点「重新生成」把编码交回当前分类的规则', async () => { + codeRulesApi.next + .mockResolvedValueOnce({ data: { available: true, code: 'A010201' } }) + .mockResolvedValueOnce({ data: { available: true, code: 'A010299' } }) + + const wrapper = mountMaterials() + await flushPromises() + await openAddDialog(wrapper) + await pickCategories(wrapper) + await codeInput(wrapper).setValue('MY-001') + await flushPromises() + + await findButton(wrapper, '重新生成').trigger('click') + await flushPromises() + + expect(codeInput(wrapper).element.value).toBe('A010299') + wrapper.unmount() + }) + + test('小类没有规则且没填编码时,保存被拦下并提示补编码', async () => { + codeRulesApi.next.mockResolvedValue({ data: { available: false, code: '' } }) + + const wrapper = mountMaterials() + await flushPromises() + await openAddDialog(wrapper) + await pickCategories(wrapper) + + await findButton(wrapper, '保存').trigger('click') + await flushPromises() + + expect(materialsStore.createMaterial).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalledWith('请填写物料编码') + wrapper.unmount() + }) +}) diff --git a/frontend/src/views/Materials.vue b/frontend/src/views/Materials.vue index b43d6bc5..0acf22b5 100644 --- a/frontend/src/views/Materials.vue +++ b/frontend/src/views/Materials.vue @@ -69,8 +69,17 @@ - - + +
+ + 重新生成 +
+

{{ codeHint }}

@@ -152,7 +161,7 @@ import DeleteConfirm from '../components/business/DeleteConfirm.vue' import AlertTip from '../components/business/AlertTip.vue' import { ElMessage } from 'element-plus' import { createMaterialForm, toMaterialPayload } from './materialForm' -import { materialCategoriesApi, unitsApi } from '../api/masterData' +import { codeRulesApi, materialCategoriesApi, unitsApi } from '../api/masterData' import { getMajorCategories, getMinorCategories, resolveCategoryPath } from '../utils/materialCategories' import { searchMaterialOptions } from '../utils/materialSearch' @@ -175,6 +184,16 @@ const imagePreview = ref('') const imageInput = ref(null) const categories = ref([]) const units = ref([]) +// 用户是否手改过编码:没改过时切分类要按新规则重算,改过就以用户的值为准。 +const codeEdited = ref(false) +const codeHint = ref('') +const isCreating = computed(() => dialogTitle.value === '新增物料') +// 小类还没选时编码框必然是空的,提示用户先把分类选完:选完才会自动填入编码, +// 或者由"请手工填写"的提示接手。 +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)) @@ -197,11 +216,53 @@ onMounted(async () => { function syncCategory(id) { form.category = categories.value.find(item => item.id === id)?.code || '' + // 手改过的编码保留用户的输入,没改过才跟着新分类的规则重算 + if (!codeEdited.value) generateMaterialCode() } function syncMajorCategory() { form.category_id = null form.category = '' + // 大类换了,原来的小类规则不再适用;同样不动用户手改过的编码 + if (!codeEdited.value) { + form.material_code = '' + codeHint.value = '' + } +} + +// 按当前小类的编码规则填编码:有规则自动填,没规则就清空并提示手工填写。 +async function generateMaterialCode() { + if (!isCreating.value) return + if (!form.category_id) { + form.material_code = '' + codeHint.value = '' + return + } + try { + const response = await codeRulesApi.next(form.category_id) + const { available, code } = response.data || {} + if (available) { + form.material_code = code + codeHint.value = '' + } else { + form.material_code = '' + codeHint.value = '该小类未配置编码规则,请手工填写物料编码' + } + } catch (e) { + // 预览失败不该挡住填表:留空让用户手工填写,接口层已经弹过错误提示 + form.material_code = '' + codeHint.value = '未能获取编码规则,请手工填写物料编码' + } +} + +// 「重新生成」:把编码交回自动生成,按当前分类规则重算 +function regenerateMaterialCode() { + codeEdited.value = false + generateMaterialCode() +} + +function markCodeEdited() { + if (isCreating.value) codeEdited.value = true } function getMajorCategoryDisplay(row) { @@ -242,6 +303,8 @@ function showAddModal() { editingId.value = null dialogTitle.value = '新增物料' Object.assign(form, createMaterialForm()) + codeEdited.value = false + codeHint.value = '' imagePreview.value = '' if (imageInput.value) imageInput.value.value = '' dialogVisible.value = true @@ -256,6 +319,9 @@ function showEditModal(row) { editingId.value = row.id dialogTitle.value = '编辑物料' Object.assign(form, createMaterialForm(row, categories.value)) + // 编辑时编码只读,标记成"已改过",避免切小类时被规则覆盖 + codeEdited.value = true + codeHint.value = '' imagePreview.value = '' if (imageInput.value) imageInput.value.value = '' dialogVisible.value = true @@ -266,6 +332,11 @@ async function handleSave() { ElMessage.warning('请选择大类类型和小类类型') return } + // 小类没有编码规则时编码框是空的,得让用户先补上 + if (!form.material_code) { + ElMessage.warning('请填写物料编码') + return + } saveLoading.value = true try { const formData = toMaterialPayload(form) @@ -364,4 +435,21 @@ function formatDate(dateStr) { :deep(.el-table__cell.operation-column) { border-left: 1px solid var(--el-table-border-color) !important; } +.material-code-field { + display: flex; + align-items: center; + gap: 8px; +} +/* 不设 flex 时这一行会收缩到 el-input 的固有宽度(约 177px),比表单里其它 + 字段窄一截;换成 flex:1 后输入框占满整行剩余宽度,与其它字段对齐。 */ +.material-code-field { + flex: 1; + min-width: 0; +} +.material-code-hint { + margin: 4px 0 0; + color: #e6a23c; + font-size: 12px; + line-height: 1.5; +} diff --git a/frontend/src/views/master-data/MasterDataSettings.vue b/frontend/src/views/master-data/MasterDataSettings.vue index 5baf1583..4ccc52ce 100644 --- a/frontend/src/views/master-data/MasterDataSettings.vue +++ b/frontend/src/views/master-data/MasterDataSettings.vue @@ -75,6 +75,9 @@ + + + @@ -121,7 +124,7 @@ const rootCategories = computed(() => categories.value.filter(item => !item.pare const form = reactive(defaultForm()) function defaultForm() { - return { code: '', name: '', description: '', parent_id: null, category_id: null, prefix: '', sequence_length: 4, precision_scale: 0, status: 'active' } + return { code: '', name: '', description: '', parent_id: null, category_id: null, prefix: '', sequence_length: 4, next_sequence: 1, precision_scale: 0, status: 'active' } } function parentCategoryLabel(parentId) { diff --git a/internal/handler/admin_master.go b/internal/handler/admin_master.go index 14ba18ca..b20d3266 100644 --- a/internal/handler/admin_master.go +++ b/internal/handler/admin_master.go @@ -221,6 +221,26 @@ func (h *Handler) ListCodeRules(c *gin.Context) { } response.Success(c, items) } + +// GetNextMaterialCode 预览按小类规则自动生成的物料编码。新增物料的人要用它 +// 填表,所以只要求登录,不要求管理员。 +func (h *Handler) GetNextMaterialCode(c *gin.Context) { + orgID, ok := organizationID(c) + if !ok { + return + } + categoryID, err := strconv.ParseInt(c.Query("category_id"), 10, 64) + if err != nil || categoryID <= 0 { + response.BadRequest(c, "invalid category_id") + return + } + next, err := h.masterDataSvc.NextMaterialCode(orgID, categoryID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, next) +} func (h *Handler) CreateCodeRule(c *gin.Context) { orgID, ok := organizationID(c) if !ok { diff --git a/internal/handler/api.go b/internal/handler/api.go index 486e9f95..d7b9a94c 100644 --- a/internal/handler/api.go +++ b/internal/handler/api.go @@ -182,6 +182,14 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) { codeRules.PUT("/:id", h.UpdateCodeRule) } + // 编码预览单独放开给普通用户:新增物料时要靠它得到自动编码, + // 而编码规则本身的增删改仍然只有管理员能做。 + codeRulePreview := api.Group("/code-rules") + codeRulePreview.Use(middleware.AuthMiddleware()) + { + codeRulePreview.GET("/next", h.GetNextMaterialCode) + } + partners := api.Group("/partners") partners.Use(middleware.AuthMiddleware(), middleware.AdminOnly()) { @@ -301,7 +309,7 @@ func (h *Handler) CreateMaterial(c *gin.Context) { response.BadRequest(c, err.Error()) return } - if err.Error() == "material code already exists" { + if errors.Is(err, repository.ErrDuplicateMaterialCode) { response.BadRequest(c, err.Error()) return } diff --git a/internal/handler/routes_test.go b/internal/handler/routes_test.go index cbabe414..63d2397a 100644 --- a/internal/handler/routes_test.go +++ b/internal/handler/routes_test.go @@ -19,6 +19,7 @@ func TestPhaseOneAndLegacyRoutesAreRegistered(t *testing.T) { "GET /api/material-categories", "POST /api/material-categories", "PUT /api/material-categories/:id", "GET /api/units", "POST /api/units", "PUT /api/units/:id", "GET /api/code-rules", "POST /api/code-rules", "PUT /api/code-rules/:id", + "GET /api/code-rules/next", "GET /api/organizations", "GET /api/roles", "PUT /api/roles/:id", "GET /api/permissions", "GET /api/partners", "POST /api/partners", "PUT /api/partners/:id", diff --git a/internal/model/master_data.go b/internal/model/master_data.go index 5cb1d582..cde73bcf 100644 --- a/internal/model/master_data.go +++ b/internal/model/master_data.go @@ -1,5 +1,7 @@ package model +import "fmt" + type MaterialCategory struct { ID int64 `json:"id"` OrganizationID int64 `json:"organization_id"` @@ -30,3 +32,16 @@ type CodeRule struct { NextSequence int64 `json:"next_sequence"` Status string `json:"status"` } + +// FormatCode 按「前缀 + 左侧补零的流水号」拼出物料编码。流水号位数超过 +// sequence_length 时不截断而是按实际长度输出,否则号段涨过位数后会算出重复编码。 +func (r *CodeRule) FormatCode(sequence int64) string { + return fmt.Sprintf("%s%0*d", r.Prefix, r.SequenceLength, sequence) +} + +// NextMaterialCode 是新增物料时按小类编码规则预览出的编码。Available 为 false +// 表示该小类没有启用中的规则,需要用户手工填写编码。 +type NextMaterialCode struct { + Available bool `json:"available"` + Code string `json:"code"` +} diff --git a/internal/repository/master_data.go b/internal/repository/master_data.go index a393d02a..70446945 100644 --- a/internal/repository/master_data.go +++ b/internal/repository/master_data.go @@ -1,6 +1,8 @@ package repository import ( + "database/sql" + "github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/pkg/database" ) @@ -100,6 +102,22 @@ func (r *MasterDataRepository) CreateCodeRule(item *model.CodeRule) error { } func (r *MasterDataRepository) UpdateCodeRule(item *model.CodeRule) error { - _, err := database.DB.Exec(`UPDATE code_rules SET prefix = ?, sequence_length = ?, status = ? WHERE id = ? AND organization_id = ?`, item.Prefix, item.SequenceLength, item.Status, item.ID, item.OrganizationID) + _, err := database.DB.Exec(`UPDATE code_rules SET prefix = ?, sequence_length = ?, next_sequence = ?, status = ? WHERE id = ? AND organization_id = ?`, item.Prefix, item.SequenceLength, item.NextSequence, item.Status, item.ID, item.OrganizationID) return err } + +// FindActiveCodeRule 取小类当前启用的编码规则,没有规则时返回 (nil, nil)。 +// 只读,不加锁:占号发生在创建物料的事务里。 +func (r *MasterDataRepository) FindActiveCodeRule(organizationID, categoryID int64) (*model.CodeRule, error) { + query := `SELECT id, organization_id, category_id, prefix, sequence_length, next_sequence, status FROM code_rules WHERE organization_id = ? AND category_id = ? AND status = 'active'` + rule := &model.CodeRule{} + err := database.DB.QueryRow(query, organizationID, categoryID).Scan(&rule.ID, &rule.OrganizationID, + &rule.CategoryID, &rule.Prefix, &rule.SequenceLength, &rule.NextSequence, &rule.Status) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return rule, nil +} diff --git a/internal/repository/material.go b/internal/repository/material.go index e6b809a2..6d1172e6 100644 --- a/internal/repository/material.go +++ b/internal/repository/material.go @@ -2,6 +2,7 @@ package repository import ( "database/sql" + "errors" "fmt" "strings" @@ -9,11 +10,18 @@ import ( "github.com/wmms/wmms/pkg/database" ) +// ErrDuplicateMaterialCode 表示同组织下物料编码已被占用。 +var ErrDuplicateMaterialCode = errors.New("material code already exists") + // BeginTx starts a new database transaction func BeginTx() (*sql.Tx, error) { return database.DB.Begin() } +// probeLimit 限制自动编码撞号后向后探测的次数:号段被大面积占用时宁可报错, +// 也不要让一次创建长时间空转。 +const probeLimit = 1000 + type MaterialRepo struct{} func NewMaterialRepo() *MaterialRepo { @@ -30,15 +38,20 @@ func (r *MaterialRepo) IsActiveSubcategory(categoryID, organizationID int64) (bo return exists, err } -func (r *MaterialRepo) Create(m *model.Material) error { - query := `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, - category_id, base_unit_id, unit, status, version) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - result, err := database.DB.Exec(query, m.OrganizationID, m.MaterialCode, m.Name, m.MaterialImage, m.Spec, +// sqlExecer 让插入语句既能直接走连接池,也能跑在事务里。 +type sqlExecer interface { + Exec(query string, args ...interface{}) (sql.Result, error) +} + +// insertMaterial 写入物料并回填自增主键,唯一键冲突统一翻译成 +// ErrDuplicateMaterialCode。 +func insertMaterial(execer sqlExecer, m *model.Material) error { + query := `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + result, err := execer.Exec(query, m.OrganizationID, m.MaterialCode, m.Name, m.MaterialImage, m.Spec, m.ManufacturerModel, m.CategoryID, m.BaseUnitID, m.Unit, m.Status, m.Version) if err != nil { if strings.Contains(err.Error(), "Duplicate entry") { - return fmt.Errorf("material code already exists") + return ErrDuplicateMaterialCode } return err } @@ -47,6 +60,88 @@ func (r *MaterialRepo) Create(m *model.Material) error { return err } m.ID = id + return nil +} + +// lockCodeRule 锁住小类当前启用的编码规则行,返回 nil 表示该小类没有规则。 +// FOR UPDATE 让同一个小类的并发创建串行化,避免两条记录抢到同一个流水号。 +func lockCodeRule(tx *sql.Tx, organizationID, categoryID int64) (*model.CodeRule, error) { + query := `SELECT id, organization_id, category_id, prefix, sequence_length, next_sequence FROM code_rules WHERE organization_id = ? AND category_id = ? AND status = 'active' FOR UPDATE` + rule := &model.CodeRule{} + err := tx.QueryRow(query, organizationID, categoryID).Scan(&rule.ID, &rule.OrganizationID, + &rule.CategoryID, &rule.Prefix, &rule.SequenceLength, &rule.NextSequence) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return rule, nil +} + +// isMaterialCodeTaken 判断同组织下某个物料编码是否已被占用。 +func isMaterialCodeTaken(tx *sql.Tx, organizationID int64, code string) (bool, error) { + query := `SELECT EXISTS(SELECT 1 FROM materials WHERE organization_id = ? AND material_code = ?)` + var exists bool + err := tx.QueryRow(query, organizationID, code).Scan(&exists) + return exists, err +} + +// CreateWithCodeRule 在事务内插入物料,并根据提交的编码决定是否推进号段。 +// +// 提交的编码是不是「规则当前的产物」决定了走哪条分支:相等说明这个编码来自自动 +// 生成、用户没有改过,于是沿着号段向后探测出第一个没被占用的流水号,插入成功后 +// 把 next_sequence 推进到它之后;不相等说明用户手改了编码、或者该小类根本没有 +// 规则,此时原样插入、号段保持不变,撞唯一键就报编码已存在。 +func (r *MaterialRepo) CreateWithCodeRule(m *model.Material, submittedCode string) error { + tx, err := BeginTx() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + var categoryID int64 + if m.CategoryID != nil { + categoryID = *m.CategoryID + } + rule, err := lockCodeRule(tx, m.OrganizationID, categoryID) + if err != nil { + return err + } + + code := submittedCode + var nextSequence int64 + if rule != nil && code == rule.FormatCode(rule.NextSequence) { + sequence := rule.NextSequence + for probed := 0; probed < probeLimit; probed++ { + code = rule.FormatCode(sequence) + taken, err := isMaterialCodeTaken(tx, m.OrganizationID, code) + if err != nil { + return err + } + if !taken { + nextSequence = sequence + 1 + break + } + sequence++ + } + if nextSequence == 0 { + return fmt.Errorf("编码规则 %s 的可用流水号已用尽", rule.Prefix) + } + } + + m.MaterialCode = code + if err := insertMaterial(tx, m); err != nil { + return err + } + if nextSequence > 0 { + if _, err := tx.Exec(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`, nextSequence, rule.ID); err != nil { + return err + } + } + if err := tx.Commit(); err != nil { + return err + } return r.GetByID(m.ID, m) } diff --git a/internal/service/admin_master.go b/internal/service/admin_master.go index cf5fea7b..30233284 100644 --- a/internal/service/admin_master.go +++ b/internal/service/admin_master.go @@ -105,6 +105,19 @@ func (s *MasterDataService) SaveUnit(item *model.Unit) error { func (s *MasterDataService) ListCodeRules(orgID int64) ([]model.CodeRule, error) { return s.repo.ListCodeRules(orgID) } + +// NextMaterialCode 预览新增物料时按小类规则自动生成的编码。这里只读不落库, +// 返回的编码只是候选值;真正占号发生在创建物料的事务里。 +func (s *MasterDataService) NextMaterialCode(orgID, categoryID int64) (*model.NextMaterialCode, error) { + rule, err := s.repo.FindActiveCodeRule(orgID, categoryID) + if err != nil { + return nil, err + } + if rule == nil { + return &model.NextMaterialCode{Available: false}, nil + } + return &model.NextMaterialCode{Available: true, Code: rule.FormatCode(rule.NextSequence)}, nil +} func (s *MasterDataService) SaveCodeRule(item *model.CodeRule) error { item.Prefix = strings.TrimSpace(item.Prefix) if item.CategoryID <= 0 || item.Prefix == "" { diff --git a/internal/service/material.go b/internal/service/material.go index 69b226ce..e3a9d39a 100644 --- a/internal/service/material.go +++ b/internal/service/material.go @@ -71,7 +71,9 @@ func (s *MaterialService) Create(req *model.CreateMaterialRequest, operator stri m.ManufacturerModel = req.ManufacturerModel } - err := s.repo.Create(m) + // 编码由提交值和编码规则共同决定:值仍等于规则产物说明用户没改过,落库时 + // 顺延号段;改过(或者小类没有规则)则原样保存,号段不动。 + err := s.repo.CreateWithCodeRule(m, req.MaterialCode) if err != nil { return nil, err } diff --git a/internal/service/material_test.go b/internal/service/material_test.go index da55b61d..f4cdb8ad 100644 --- a/internal/service/material_test.go +++ b/internal/service/material_test.go @@ -8,6 +8,7 @@ import ( "github.com/DATA-DOG/go-sqlmock" "github.com/wmms/wmms/internal/model" + "github.com/wmms/wmms/internal/repository" "github.com/wmms/wmms/pkg/database" ) @@ -75,3 +76,201 @@ func TestMaterialServiceTreatsObsoleteMaterialAsInactive(t *testing.T) { t.Fatal(err) } } + +// 以下常量必须与 repository 里的 SQL 字面量逐字一致,sqlmock 才匹配得上。 +const ( + activeSubcategoryQuery = `SELECT EXISTS( + SELECT 1 FROM material_categories + WHERE id = ? AND organization_id = ? AND parent_id IS NOT NULL AND status = 'active' + )` + lockCodeRuleQuery = `SELECT id, organization_id, category_id, prefix, sequence_length, next_sequence FROM code_rules WHERE organization_id = ? AND category_id = ? AND status = 'active' FOR UPDATE` + codeTakenQuery = `SELECT EXISTS(SELECT 1 FROM materials WHERE organization_id = ? AND material_code = ?)` + insertMaterialStmt = `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + advanceSequenceStmt = `UPDATE code_rules SET next_sequence = ? WHERE id = ?` + materialLogStmt = `INSERT INTO material_logs (operation_type, material_id, old_value, new_value, operator, description) VALUES (?, ?, ?, ?, ?, ?)` + getMaterialQuery = `SELECT id, organization_id, material_code, name, material_image, spec, manufacturer_model, + category_id, base_unit_id, unit, status, version, created_at, updated_at + FROM materials WHERE id = ?` +) + +func newMaterialServiceMock(t *testing.T) (sqlmock.Sqlmock, func()) { + t.Helper() + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("open sql mock: %v", err) + } + database.DB = db + return mock, func() { + database.DB = nil + db.Close() + } +} + +func expectActiveSubcategory(mock sqlmock.Sqlmock) { + mock.ExpectQuery(regexp.QuoteMeta(activeSubcategoryQuery)).WithArgs(int64(12), int64(1)). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) +} + +// expectLockedCodeRule 开始事务并锁住小类 12 上的编码规则;nextSequence 为 nil +// 表示该小类没有规则。 +func expectLockedCodeRule(mock sqlmock.Sqlmock, nextSequence *int64) { + mock.ExpectBegin() + rows := sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}) + if nextSequence != nil { + rows.AddRow(5, 1, 12, "A01", 4, *nextSequence) + } + mock.ExpectQuery(regexp.QuoteMeta(lockCodeRuleQuery)).WithArgs(int64(1), int64(12)).WillReturnRows(rows) +} + +func expectCodeTaken(mock sqlmock.Sqlmock, code string, taken bool) { + mock.ExpectQuery(regexp.QuoteMeta(codeTakenQuery)).WithArgs(int64(1), code). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(taken)) +} + +func expectMaterialInsert(mock sqlmock.Sqlmock, code string) { + mock.ExpectExec(regexp.QuoteMeta(insertMaterialStmt)). + WithArgs(int64(1), code, "轴承", nil, "", nil, int64(12), nil, "pcs", "active", 1). + WillReturnResult(sqlmock.NewResult(7, 1)) +} + +func expectCreatedMaterial(mock sqlmock.Sqlmock, code string) { + now := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC) + mock.ExpectQuery(regexp.QuoteMeta(getMaterialQuery)).WithArgs(int64(7)).WillReturnRows( + 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, code, "轴承", nil, "", nil, 12, nil, "pcs", "active", 1, now, now), + ) + mock.ExpectExec(regexp.QuoteMeta(materialLogStmt)). + WithArgs("CREATE", int64(7), "", sqlmock.AnyArg(), "tester", sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(1, 1)) +} + +func createMaterialWithCode(t *testing.T, code string) (*model.Material, error) { + t.Helper() + categoryID := int64(12) + return NewMaterialService().Create(&model.CreateMaterialRequest{ + OrganizationID: 1, + MaterialCode: code, + Name: "轴承", + CategoryID: &categoryID, + }, "tester") +} + +func TestMaterialServiceAdvancesSequenceForAutoGeneratedCode(t *testing.T) { + mock, cleanup := newMaterialServiceMock(t) + t.Cleanup(cleanup) + + next := int64(201) + expectActiveSubcategory(mock) + expectLockedCodeRule(mock, &next) + expectCodeTaken(mock, "A010201", false) + expectMaterialInsert(mock, "A010201") + mock.ExpectExec(regexp.QuoteMeta(advanceSequenceStmt)).WithArgs(int64(202), int64(5)). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + expectCreatedMaterial(mock, "A010201") + + material, err := createMaterialWithCode(t, "A010201") + if err != nil { + t.Fatalf("create material: %v", err) + } + if material.MaterialCode != "A010201" { + t.Fatalf("material code = %s, want A010201", material.MaterialCode) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestMaterialServiceSkipsTakenSequenceNumbers(t *testing.T) { + mock, cleanup := newMaterialServiceMock(t) + t.Cleanup(cleanup) + + next := int64(201) + expectActiveSubcategory(mock) + expectLockedCodeRule(mock, &next) + expectCodeTaken(mock, "A010201", true) + expectCodeTaken(mock, "A010202", false) + expectMaterialInsert(mock, "A010202") + mock.ExpectExec(regexp.QuoteMeta(advanceSequenceStmt)).WithArgs(int64(203), int64(5)). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + expectCreatedMaterial(mock, "A010202") + + material, err := createMaterialWithCode(t, "A010201") + if err != nil { + t.Fatalf("create material: %v", err) + } + if material.MaterialCode != "A010202" { + t.Fatalf("material code = %s, want A010202", material.MaterialCode) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestMaterialServiceKeepsManuallyEditedCodeWithoutAdvancingSequence(t *testing.T) { + mock, cleanup := newMaterialServiceMock(t) + t.Cleanup(cleanup) + + next := int64(201) + expectActiveSubcategory(mock) + expectLockedCodeRule(mock, &next) + // 手改过的编码不等于规则产物,既不该探测占用,也不该推进号段。 + expectMaterialInsert(mock, "CUSTOM-001") + mock.ExpectCommit() + expectCreatedMaterial(mock, "CUSTOM-001") + + material, err := createMaterialWithCode(t, "CUSTOM-001") + if err != nil { + t.Fatalf("create material: %v", err) + } + if material.MaterialCode != "CUSTOM-001" { + t.Fatalf("material code = %s, want CUSTOM-001", material.MaterialCode) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestMaterialServiceCreatesMaterialWhenCategoryHasNoCodeRule(t *testing.T) { + mock, cleanup := newMaterialServiceMock(t) + t.Cleanup(cleanup) + + expectActiveSubcategory(mock) + expectLockedCodeRule(mock, nil) + expectMaterialInsert(mock, "MANUAL-009") + mock.ExpectCommit() + expectCreatedMaterial(mock, "MANUAL-009") + + material, err := createMaterialWithCode(t, "MANUAL-009") + if err != nil { + t.Fatalf("create material: %v", err) + } + if material.MaterialCode != "MANUAL-009" { + t.Fatalf("material code = %s, want MANUAL-009", material.MaterialCode) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestMaterialServiceReportsDuplicateManuallyEditedCode(t *testing.T) { + mock, cleanup := newMaterialServiceMock(t) + t.Cleanup(cleanup) + + next := int64(201) + expectActiveSubcategory(mock) + expectLockedCodeRule(mock, &next) + mock.ExpectExec(regexp.QuoteMeta(insertMaterialStmt)). + WithArgs(int64(1), "CUSTOM-001", "轴承", nil, "", nil, int64(12), nil, "pcs", "active", 1). + WillReturnError(errors.New("Error 1062: Duplicate entry 'CUSTOM-001' for key 'uk_materials_org_code'")) + mock.ExpectRollback() + + _, err := createMaterialWithCode(t, "CUSTOM-001") + if !errors.Is(err, repository.ErrDuplicateMaterialCode) { + t.Fatalf("create error = %v, want ErrDuplicateMaterialCode", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +}