feat: 实现物料编码按分类规则自动生成、手工修改及并发安全的流水号管理
This commit is contained in:
parent
84d775bc90
commit
a7c7936e0b
|
|
@ -66,7 +66,7 @@
|
||||||
| 角色 / 权限 | `/roles`、`/permissions` | 仅管理员 |
|
| 角色 / 权限 | `/roles`、`/permissions` | 仅管理员 |
|
||||||
| 主数据:物料分类 | `/material-categories` | 仅管理员 |
|
| 主数据:物料分类 | `/material-categories` | 仅管理员 |
|
||||||
| 主数据:计量单位 | `/units` | 仅管理员 |
|
| 主数据:计量单位 | `/units` | 仅管理员 |
|
||||||
| 主数据:编码规则 | `/code-rules` | 仅管理员 |
|
| 主数据:编码规则 | `/code-rules`、`GET /code-rules/next` | 仅管理员;`/next` 需登录 |
|
||||||
| 业务伙伴 | `/partners` | 仅管理员 |
|
| 业务伙伴 | `/partners` | 仅管理员 |
|
||||||
| 仓库与库位 | `/warehouses`、`/warehouses/:id/locations` | 仅管理员 |
|
| 仓库与库位 | `/warehouses`、`/warehouses/:id/locations` | 仅管理员 |
|
||||||
| 物料申请 | `/applications`、`/applications/my` | 需登录 |
|
| 物料申请 | `/applications`、`/applications/my` | 需登录 |
|
||||||
|
|
|
||||||
|
|
@ -16,4 +16,11 @@ function resource(path) {
|
||||||
|
|
||||||
export const materialCategoriesApi = resource('/material-categories')
|
export const materialCategoriesApi = resource('/material-categories')
|
||||||
export const unitsApi = resource('/units')
|
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-dialog v-model="dialogVisible" :title="dialogTitle" width="600px">
|
||||||
<el-form :model="form" label-width="100px">
|
<el-form :model="form" label-width="100px">
|
||||||
<el-form-item label="物料编码">
|
<el-form-item label="物料编码" required>
|
||||||
<el-input v-model="form.material_code" :readonly="dialogTitle !== '新增物料'" />
|
<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>
|
||||||
<el-form-item label="名称">
|
<el-form-item label="名称">
|
||||||
<el-input v-model="form.name" />
|
<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 AlertTip from '../components/business/AlertTip.vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { createMaterialForm, toMaterialPayload } from './materialForm'
|
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 { getMajorCategories, getMinorCategories, resolveCategoryPath } from '../utils/materialCategories'
|
||||||
import { searchMaterialOptions } from '../utils/materialSearch'
|
import { searchMaterialOptions } from '../utils/materialSearch'
|
||||||
|
|
||||||
|
|
@ -175,6 +184,16 @@ const imagePreview = ref('')
|
||||||
const imageInput = ref(null)
|
const imageInput = ref(null)
|
||||||
const categories = ref([])
|
const categories = ref([])
|
||||||
const units = 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 majorCategories = computed(() => getMajorCategories(categories.value))
|
||||||
const minorCategories = computed(() => getMinorCategories(categories.value, form.major_category_id))
|
const minorCategories = computed(() => getMinorCategories(categories.value, form.major_category_id))
|
||||||
|
|
||||||
|
|
@ -197,11 +216,53 @@ onMounted(async () => {
|
||||||
|
|
||||||
function syncCategory(id) {
|
function syncCategory(id) {
|
||||||
form.category = categories.value.find(item => item.id === id)?.code || ''
|
form.category = categories.value.find(item => item.id === id)?.code || ''
|
||||||
|
// 手改过的编码保留用户的输入,没改过才跟着新分类的规则重算
|
||||||
|
if (!codeEdited.value) generateMaterialCode()
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncMajorCategory() {
|
function syncMajorCategory() {
|
||||||
form.category_id = null
|
form.category_id = null
|
||||||
form.category = ''
|
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) {
|
function getMajorCategoryDisplay(row) {
|
||||||
|
|
@ -242,6 +303,8 @@ function showAddModal() {
|
||||||
editingId.value = null
|
editingId.value = null
|
||||||
dialogTitle.value = '新增物料'
|
dialogTitle.value = '新增物料'
|
||||||
Object.assign(form, createMaterialForm())
|
Object.assign(form, createMaterialForm())
|
||||||
|
codeEdited.value = false
|
||||||
|
codeHint.value = ''
|
||||||
imagePreview.value = ''
|
imagePreview.value = ''
|
||||||
if (imageInput.value) imageInput.value.value = ''
|
if (imageInput.value) imageInput.value.value = ''
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
|
|
@ -256,6 +319,9 @@ function showEditModal(row) {
|
||||||
editingId.value = row.id
|
editingId.value = row.id
|
||||||
dialogTitle.value = '编辑物料'
|
dialogTitle.value = '编辑物料'
|
||||||
Object.assign(form, createMaterialForm(row, categories.value))
|
Object.assign(form, createMaterialForm(row, categories.value))
|
||||||
|
// 编辑时编码只读,标记成"已改过",避免切小类时被规则覆盖
|
||||||
|
codeEdited.value = true
|
||||||
|
codeHint.value = ''
|
||||||
imagePreview.value = ''
|
imagePreview.value = ''
|
||||||
if (imageInput.value) imageInput.value.value = ''
|
if (imageInput.value) imageInput.value.value = ''
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
|
|
@ -266,6 +332,11 @@ async function handleSave() {
|
||||||
ElMessage.warning('请选择大类类型和小类类型')
|
ElMessage.warning('请选择大类类型和小类类型')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 小类没有编码规则时编码框是空的,得让用户先补上
|
||||||
|
if (!form.material_code) {
|
||||||
|
ElMessage.warning('请填写物料编码')
|
||||||
|
return
|
||||||
|
}
|
||||||
saveLoading.value = true
|
saveLoading.value = true
|
||||||
try {
|
try {
|
||||||
const formData = toMaterialPayload(form)
|
const formData = toMaterialPayload(form)
|
||||||
|
|
@ -364,4 +435,21 @@ function formatDate(dateStr) {
|
||||||
:deep(.el-table__cell.operation-column) {
|
:deep(.el-table__cell.operation-column) {
|
||||||
border-left: 1px solid var(--el-table-border-color) !important;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,9 @@
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="编码前缀"><el-input v-model="form.prefix" /></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.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>
|
</template>
|
||||||
<el-form-item label="状态">
|
<el-form-item label="状态">
|
||||||
<el-select v-model="form.status" style="width: 100%">
|
<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())
|
const form = reactive(defaultForm())
|
||||||
|
|
||||||
function 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) {
|
function parentCategoryLabel(parentId) {
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,26 @@ func (h *Handler) ListCodeRules(c *gin.Context) {
|
||||||
}
|
}
|
||||||
response.Success(c, items)
|
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) {
|
func (h *Handler) CreateCodeRule(c *gin.Context) {
|
||||||
orgID, ok := organizationID(c)
|
orgID, ok := organizationID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,14 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) {
|
||||||
codeRules.PUT("/:id", h.UpdateCodeRule)
|
codeRules.PUT("/:id", h.UpdateCodeRule)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 编码预览单独放开给普通用户:新增物料时要靠它得到自动编码,
|
||||||
|
// 而编码规则本身的增删改仍然只有管理员能做。
|
||||||
|
codeRulePreview := api.Group("/code-rules")
|
||||||
|
codeRulePreview.Use(middleware.AuthMiddleware())
|
||||||
|
{
|
||||||
|
codeRulePreview.GET("/next", h.GetNextMaterialCode)
|
||||||
|
}
|
||||||
|
|
||||||
partners := api.Group("/partners")
|
partners := api.Group("/partners")
|
||||||
partners.Use(middleware.AuthMiddleware(), middleware.AdminOnly())
|
partners.Use(middleware.AuthMiddleware(), middleware.AdminOnly())
|
||||||
{
|
{
|
||||||
|
|
@ -301,7 +309,7 @@ func (h *Handler) CreateMaterial(c *gin.Context) {
|
||||||
response.BadRequest(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err.Error() == "material code already exists" {
|
if errors.Is(err, repository.ErrDuplicateMaterialCode) {
|
||||||
response.BadRequest(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
return
|
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/material-categories", "POST /api/material-categories", "PUT /api/material-categories/:id",
|
||||||
"GET /api/units", "POST /api/units", "PUT /api/units/: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", "POST /api/code-rules", "PUT /api/code-rules/:id",
|
||||||
|
"GET /api/code-rules/next",
|
||||||
"GET /api/organizations", "GET /api/roles",
|
"GET /api/organizations", "GET /api/roles",
|
||||||
"PUT /api/roles/:id", "GET /api/permissions",
|
"PUT /api/roles/:id", "GET /api/permissions",
|
||||||
"GET /api/partners", "POST /api/partners", "PUT /api/partners/:id",
|
"GET /api/partners", "POST /api/partners", "PUT /api/partners/:id",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package model
|
package model
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
type MaterialCategory struct {
|
type MaterialCategory struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
OrganizationID int64 `json:"organization_id"`
|
OrganizationID int64 `json:"organization_id"`
|
||||||
|
|
@ -30,3 +32,16 @@ type CodeRule struct {
|
||||||
NextSequence int64 `json:"next_sequence"`
|
NextSequence int64 `json:"next_sequence"`
|
||||||
Status string `json:"status"`
|
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
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
"github.com/wmms/wmms/internal/model"
|
"github.com/wmms/wmms/internal/model"
|
||||||
"github.com/wmms/wmms/pkg/database"
|
"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 {
|
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
|
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 (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
|
@ -9,11 +10,18 @@ import (
|
||||||
"github.com/wmms/wmms/pkg/database"
|
"github.com/wmms/wmms/pkg/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrDuplicateMaterialCode 表示同组织下物料编码已被占用。
|
||||||
|
var ErrDuplicateMaterialCode = errors.New("material code already exists")
|
||||||
|
|
||||||
// BeginTx starts a new database transaction
|
// BeginTx starts a new database transaction
|
||||||
func BeginTx() (*sql.Tx, error) {
|
func BeginTx() (*sql.Tx, error) {
|
||||||
return database.DB.Begin()
|
return database.DB.Begin()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// probeLimit 限制自动编码撞号后向后探测的次数:号段被大面积占用时宁可报错,
|
||||||
|
// 也不要让一次创建长时间空转。
|
||||||
|
const probeLimit = 1000
|
||||||
|
|
||||||
type MaterialRepo struct{}
|
type MaterialRepo struct{}
|
||||||
|
|
||||||
func NewMaterialRepo() *MaterialRepo {
|
func NewMaterialRepo() *MaterialRepo {
|
||||||
|
|
@ -30,15 +38,20 @@ func (r *MaterialRepo) IsActiveSubcategory(categoryID, organizationID int64) (bo
|
||||||
return exists, err
|
return exists, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *MaterialRepo) Create(m *model.Material) error {
|
// sqlExecer 让插入语句既能直接走连接池,也能跑在事务里。
|
||||||
query := `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model,
|
type sqlExecer interface {
|
||||||
category_id, base_unit_id, unit, status, version)
|
Exec(query string, args ...interface{}) (sql.Result, error)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
}
|
||||||
result, err := database.DB.Exec(query, m.OrganizationID, m.MaterialCode, m.Name, m.MaterialImage, m.Spec,
|
|
||||||
|
// 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)
|
m.ManufacturerModel, m.CategoryID, m.BaseUnitID, m.Unit, m.Status, m.Version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "Duplicate entry") {
|
if strings.Contains(err.Error(), "Duplicate entry") {
|
||||||
return fmt.Errorf("material code already exists")
|
return ErrDuplicateMaterialCode
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -47,6 +60,88 @@ func (r *MaterialRepo) Create(m *model.Material) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m.ID = id
|
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)
|
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) {
|
func (s *MasterDataService) ListCodeRules(orgID int64) ([]model.CodeRule, error) {
|
||||||
return s.repo.ListCodeRules(orgID)
|
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 {
|
func (s *MasterDataService) SaveCodeRule(item *model.CodeRule) error {
|
||||||
item.Prefix = strings.TrimSpace(item.Prefix)
|
item.Prefix = strings.TrimSpace(item.Prefix)
|
||||||
if item.CategoryID <= 0 || 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
|
m.ManufacturerModel = req.ManufacturerModel
|
||||||
}
|
}
|
||||||
|
|
||||||
err := s.repo.Create(m)
|
// 编码由提交值和编码规则共同决定:值仍等于规则产物说明用户没改过,落库时
|
||||||
|
// 顺延号段;改过(或者小类没有规则)则原样保存,号段不动。
|
||||||
|
err := s.repo.CreateWithCodeRule(m, req.MaterialCode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
|
|
||||||
"github.com/DATA-DOG/go-sqlmock"
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
"github.com/wmms/wmms/internal/model"
|
"github.com/wmms/wmms/internal/model"
|
||||||
|
"github.com/wmms/wmms/internal/repository"
|
||||||
"github.com/wmms/wmms/pkg/database"
|
"github.com/wmms/wmms/pkg/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -75,3 +76,201 @@ func TestMaterialServiceTreatsObsoleteMaterialAsInactive(t *testing.T) {
|
||||||
t.Fatal(err)
|
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