feat: 实现物料编码按分类规则自动生成、手工修改及并发安全的流水号管理
This commit is contained in:
parent
84d775bc90
commit
a7c7936e0b
|
|
@ -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` | 需登录 |
|
||||
|
|
|
|||
|
|
@ -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 } })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
211
frontend/src/views/Materials.test.js
Normal file
211
frontend/src/views/Materials.test.js
Normal file
|
|
@ -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: '<div class="select-stub"></div>'
|
||||
}
|
||||
|
||||
const OptionStub = { props: ['label', 'value'], template: '<span />' }
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
@ -69,8 +69,17 @@
|
|||
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="物料编码">
|
||||
<el-input v-model="form.material_code" :readonly="dialogTitle !== '新增物料'" />
|
||||
<el-form-item label="物料编码" required>
|
||||
<div class="material-code-field">
|
||||
<el-input
|
||||
v-model="form.material_code"
|
||||
:readonly="!isCreating"
|
||||
:placeholder="codePlaceholder"
|
||||
@input="markCodeEdited"
|
||||
/>
|
||||
<el-button v-if="isCreating" link type="primary" @click="regenerateMaterialCode">重新生成</el-button>
|
||||
</div>
|
||||
<p v-if="isCreating && codeHint" class="material-code-hint">{{ codeHint }}</p>
|
||||
</el-form-item>
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="form.name" />
|
||||
|
|
@ -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;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -75,6 +75,9 @@
|
|||
</el-form-item>
|
||||
<el-form-item label="编码前缀"><el-input v-model="form.prefix" /></el-form-item>
|
||||
<el-form-item label="流水号位数"><el-input-number v-model="form.sequence_length" :min="1" :max="12" /></el-form-item>
|
||||
<el-form-item label="下一流水号">
|
||||
<el-input-number v-model="form.next_sequence" :min="1" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="form.status" style="width: 100%">
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user