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