feat: 仓库与库位补上编辑与停用/启用入口

后端 PUT /warehouses/:id 与 PUT /warehouses/:warehouseId/locations/:locationId
本就接受 name + status(service/repository 都只改这两列),api 层的 update/updateLocation
也早已封装好,缺的只是前端入口——页面此前只有「新增」,无法改状态。

- 仓库标题栏与库位行各加「编辑」按钮,弹窗内改名称 + 启用/停用
- 标题栏按钮必须 @click.stop:el-collapse-item 整条 header 都挂着展开/收起监听
- 库位状态列从直接显示英文 inactive 改为中文 el-tag,与仓库及主数据页统一
- 编辑态编码只读且不参与必填校验(code 不可变,后端 Update 也不接收)
- 表单重置走已有的 replaceAllKeys,编辑只灌白名单字段,避免残留 id 让新增变成覆盖更新
- 库位所属仓库只存 id 不存对象,load() 整体替换列表后不会读到旧数据
- save() 显式吞掉错误:提示由 axios 响应拦截器统一负责,同时避免 @click 经
  el-button 透传到原生 button、绕过 Vue 异步错误包装而产生的 unhandled rejection
- 测试补 8 条:update/updateLocation 的参数与顺序、编辑态不误报编码、
  改完再新增仍走 create、状态列中文、编辑按钮不收起面板、失败不关窗
This commit is contained in:
文睿 张 2026-09-21 17:49:49 +08:00
parent 696e90893b
commit 4905e60f8c
2 changed files with 293 additions and 27 deletions

View File

@ -1,5 +1,5 @@
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus, { ElMessage } from 'element-plus'
import ElementPlus, { ElDialog, ElMessage } from 'element-plus'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import WarehouseSettings from './WarehouseSettings.vue'
import { warehousesApi } from '../../api/warehouses'
@ -8,10 +8,26 @@ vi.mock('../../api/warehouses', () => ({
warehousesApi: {
list: vi.fn(),
create: vi.fn(),
createLocation: vi.fn()
update: vi.fn(),
createLocation: vi.fn(),
updateLocation: vi.fn()
}
}))
// 真实的 ElSelect 在 jsdom 下会因状态双向绑定反复触发自身更新而报
// "Maximum recursive updates exceeded"。这里只关心状态下拉提交的内容,用轻量替身绕开。
// 与 MasterDataSettings.test.js 里的同款处理保持一致。
const SelectStub = {
props: ['modelValue', 'placeholder'],
emits: ['update:modelValue', 'change'],
template: '<div data-test="select"><slot /></div>'
}
const OptionStub = {
props: ['label', 'value'],
template: '<span class="option">{{ label }}</span>'
}
// 表单项没有 placeholder,按 label 文本定位输入框。
function inputByLabel(wrapper, label) {
const item = wrapper.findAll('.el-form-item').find(
@ -24,19 +40,47 @@ function findButton(wrapper, text) {
return wrapper.findAll('button').find(button => button.text().includes(text))
}
// 页面改造后「编辑」按钮会有多个(每个仓库一个、每个库位一个),
// 必须把查找收窄到作用域里,不能再用全局的 findButton 取首个。
function findButtonIn(scope, text) {
return scope.findAll('button').find(button => button.text().includes(text))
}
async function clickSave(wrapper) {
await findButton(wrapper, '保存').trigger('click')
await flushPromises()
}
const WAREHOUSE_WITH_LOCATION = {
data: [{
id: 1,
code: 'DEFAULT',
name: '默认仓库',
status: 'active',
locations: [{ id: 9, code: 'MAIN', name: '主库位', status: 'active' }]
}]
}
function mountPage() {
return mount(WarehouseSettings, {
global: {
plugins: [ElementPlus],
stubs: { teleport: true, ElSelect: SelectStub, ElOption: OptionStub }
}
})
}
describe('仓库与库位表单必填校验', () => {
let warnSpy
beforeEach(() => {
warnSpy = vi.spyOn(ElMessage, 'warning').mockImplementation(() => {})
vi.spyOn(ElMessage, 'success').mockImplementation(() => {})
warehousesApi.list.mockReset().mockResolvedValue({ data: [] })
warehousesApi.create.mockReset().mockResolvedValue({ data: {} })
warehousesApi.update.mockReset().mockResolvedValue({ data: {} })
warehousesApi.createLocation.mockReset().mockResolvedValue({ data: {} })
warehousesApi.updateLocation.mockReset().mockResolvedValue({ data: {} })
})
afterEach(() => {
@ -44,9 +88,7 @@ describe('仓库与库位表单必填校验', () => {
})
test('新增仓库时编码留空被拦下', async () => {
const wrapper = mount(WarehouseSettings, {
global: { plugins: [ElementPlus], stubs: { teleport: true } }
})
const wrapper = mountPage()
await flushPromises()
await findButton(wrapper, '新增仓库').trigger('click')
@ -59,9 +101,7 @@ describe('仓库与库位表单必填校验', () => {
})
test('只填了编码时提示名称', async () => {
const wrapper = mount(WarehouseSettings, {
global: { plugins: [ElementPlus], stubs: { teleport: true } }
})
const wrapper = mountPage()
await flushPromises()
await findButton(wrapper, '新增仓库').trigger('click')
@ -78,9 +118,7 @@ describe('仓库与库位表单必填校验', () => {
warehousesApi.list.mockResolvedValue({
data: [{ id: 1, code: 'DEFAULT', name: '默认仓库', status: 'active', locations: [] }]
})
const wrapper = mount(WarehouseSettings, {
global: { plugins: [ElementPlus], stubs: { teleport: true } }
})
const wrapper = mountPage()
await flushPromises()
await findButton(wrapper, '新增库位').trigger('click')
@ -93,9 +131,7 @@ describe('仓库与库位表单必填校验', () => {
})
test('编码和名称都填好后正常提交', async () => {
const wrapper = mount(WarehouseSettings, {
global: { plugins: [ElementPlus], stubs: { teleport: true } }
})
const wrapper = mountPage()
await flushPromises()
await findButton(wrapper, '新增仓库').trigger('click')
@ -109,3 +145,153 @@ describe('仓库与库位表单必填校验', () => {
wrapper.unmount()
})
})
describe('仓库与库位停用/启用', () => {
let warnSpy
beforeEach(() => {
warnSpy = vi.spyOn(ElMessage, 'warning').mockImplementation(() => {})
vi.spyOn(ElMessage, 'success').mockImplementation(() => {})
warehousesApi.list.mockReset().mockResolvedValue(WAREHOUSE_WITH_LOCATION)
warehousesApi.create.mockReset().mockResolvedValue({ data: {} })
warehousesApi.update.mockReset().mockResolvedValue({ data: {} })
warehousesApi.createLocation.mockReset().mockResolvedValue({ data: {} })
warehousesApi.updateLocation.mockReset().mockResolvedValue({ data: {} })
})
afterEach(() => {
vi.restoreAllMocks()
})
test('编辑仓库改状态走 update,不走 create', async () => {
const wrapper = mountPage()
await flushPromises()
const header = wrapper.find('.el-collapse-item__header')
await findButtonIn(header, '编辑').trigger('click')
await flushPromises()
await inputByLabel(wrapper, '名称').setValue('一号仓')
await clickSave(wrapper)
// 编辑只发 name + status:code 不可变,后端 Update 也不接收
expect(warehousesApi.update).toHaveBeenCalledWith(1, { name: '一号仓', status: 'active' })
expect(warehousesApi.create).not.toHaveBeenCalled()
wrapper.unmount()
})
test('编辑态下把状态改成停用能提交', async () => {
const wrapper = mountPage()
await flushPromises()
await findButtonIn(wrapper.find('.el-collapse-item__header'), '编辑').trigger('click')
await flushPromises()
// 下拉里选「停用」:emit v-model 事件,跳过 popper 在 jsdom 下的定位问题
wrapper.findComponent(SelectStub).vm.$emit('update:modelValue', 'inactive')
await flushPromises()
await clickSave(wrapper)
expect(warehousesApi.update).toHaveBeenCalledWith(1, { name: '默认仓库', status: 'inactive' })
wrapper.unmount()
})
test('编辑态一个字段都不改也能直接保存,且不误报编码', async () => {
const wrapper = mountPage()
await flushPromises()
await findButtonIn(wrapper.find('.el-collapse-item__header'), '编辑').trigger('click')
await flushPromises()
await clickSave(wrapper)
// 编码在编辑态是只读且不参与校验,不该被「请填写编码」拦下
expect(warnSpy).not.toHaveBeenCalled()
expect(warehousesApi.update).toHaveBeenCalledWith(1, { name: '默认仓库', status: 'active' })
wrapper.unmount()
})
test('编辑过之后再点新增,走的仍是 create', async () => {
const wrapper = mountPage()
await flushPromises()
await findButtonIn(wrapper.find('.el-collapse-item__header'), '编辑').trigger('click')
await flushPromises()
await clickSave(wrapper)
expect(warehousesApi.update).toHaveBeenCalledTimes(1)
await findButton(wrapper, '新增仓库').trigger('click')
await flushPromises()
await inputByLabel(wrapper, '编码').setValue('WH-004')
await inputByLabel(wrapper, '名称').setValue('四号仓')
await clickSave(wrapper)
// 编辑残留的 id 若漏进 form,这里就会变成第二次 update(覆盖别人的记录)
expect(warehousesApi.create).toHaveBeenCalledWith({ code: 'WH-004', name: '四号仓', status: 'active' })
expect(warehousesApi.update).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
test('编辑库位的参数顺序是 (仓库 id, 库位 id, 内容)', async () => {
const wrapper = mountPage()
await flushPromises()
const row = wrapper.find('.el-table__row')
expect(row.exists()).toBe(true)
await findButtonIn(row, '编辑').trigger('click')
await flushPromises()
await inputByLabel(wrapper, '名称').setValue('主库位A')
await clickSave(wrapper)
// 顺序写反会打到 /warehouses/{库位id}/locations/undefined
expect(warehousesApi.updateLocation).toHaveBeenCalledWith(1, 9, { name: '主库位A', status: 'active' })
wrapper.unmount()
})
test('库位状态列显示中文而不是英文原值', async () => {
warehousesApi.list.mockResolvedValue({
data: [{
id: 1,
code: 'DEFAULT',
name: '默认仓库',
status: 'inactive',
locations: [{ id: 9, code: 'MAIN', name: '主库位', status: 'inactive' }]
}]
})
const wrapper = mountPage()
await flushPromises()
const text = wrapper.text()
// 先确认库位行真的渲染出来了,否则下面的断言会假通过
expect(text).toContain('主库位')
expect(text).toContain('停用')
expect(text).not.toContain('inactive')
wrapper.unmount()
})
test('点标题栏编辑不会连带把面板收起来', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('.el-collapse-item__header').trigger('click')
await flushPromises()
expect(wrapper.find('.el-collapse-item__header').classes()).toContain('is-active')
await findButtonIn(wrapper.find('.el-collapse-item__header'), '编辑').trigger('click')
await flushPromises()
expect(wrapper.find('.el-collapse-item__header').classes()).toContain('is-active')
wrapper.unmount()
})
test('保存失败时弹窗不自动关闭', async () => {
warehousesApi.update.mockRejectedValueOnce(new Error('接口炸了'))
const wrapper = mountPage()
await flushPromises()
await findButtonIn(wrapper.find('.el-collapse-item__header'), '编辑').trigger('click')
await flushPromises()
await clickSave(wrapper)
expect(warehousesApi.update).toHaveBeenCalledTimes(1)
expect(wrapper.findComponent(ElDialog).props('modelValue')).toBe(true)
wrapper.unmount()
})
})

View File

@ -3,19 +3,41 @@
<div class="toolbar"><span>仓库与库位属于库存域,不在物料主数据中维护。</span><el-button type="primary" @click="openWarehouse">新增仓库</el-button></div>
<el-collapse v-loading="loading">
<el-collapse-item v-for="warehouse in warehouses" :key="warehouse.id" :name="warehouse.id">
<template #title><strong>{{ warehouse.code }} - {{ warehouse.name }}</strong><el-tag class="status" :type="warehouse.status === 'active' ? 'success' : 'info'">{{ warehouse.status === 'active' ? '启用' : '停用' }}</el-tag></template>
<div class="location-header"><span>库位</span><el-button link type="primary" @click.stop="openLocation(warehouse)">新增库位</el-button></div>
<template #title>
<strong>{{ warehouse.code }} - {{ warehouse.name }}</strong>
<el-tag class="status" :type="warehouse.status === 'active' ? 'success' : 'info'">{{ warehouse.status === 'active' ? '启用' : '停用' }}</el-tag>
<!-- 整条 header 都挂着展开/收起的监听,按钮不 .stop 会连带把面板收起来 -->
<el-button link type="primary" class="status" @click.stop="openWarehouseEdit(warehouse.id)">编辑</el-button>
</template>
<div class="location-header"><span>库位</span><el-button link type="primary" @click.stop="openLocation(warehouse.id)">新增库位</el-button></div>
<el-table :data="warehouse.locations || []" size="small">
<el-table-column prop="code" label="库位编码" /><el-table-column prop="name" label="库位名称" /><el-table-column prop="status" label="状态" />
<el-table-column prop="code" label="库位编码" />
<el-table-column prop="name" label="库位名称" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'">{{ row.status === 'active' ? '启用' : '停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ row }">
<el-button link type="primary" @click="openLocationEdit(warehouse.id, row.id)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</el-collapse-item>
</el-collapse>
<el-empty v-if="!loading && warehouses.length === 0" description="暂无仓库" />
<el-dialog v-model="formVisible" :title="formType === 'warehouse' ? '新增仓库' : `新增库位 - ${selectedWarehouse?.name || ''}`" width="420px" append-to-body>
<el-dialog v-model="formVisible" :title="formTitle" width="420px" append-to-body>
<el-form :model="form" label-width="90px">
<el-form-item label="编码" required><el-input v-model="form.code" /></el-form-item>
<el-form-item label="编码" :required="!isEdit"><el-input v-model="form.code" :disabled="isEdit" /></el-form-item>
<el-form-item label="名称" required><el-input v-model="form.name" /></el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status" style="width: 100%">
<el-option label="启用" value="active" />
<el-option label="停用" value="inactive" />
</el-select>
</el-form-item>
</el-form>
<template #footer><el-button @click="formVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
</el-dialog>
@ -23,25 +45,83 @@
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { warehousesApi } from '../../api/warehouses'
import { replaceAllKeys } from '../../utils/replaceAllKeys'
const warehouses = ref([])
const loading = ref(false)
const saving = ref(false)
const formVisible = ref(false)
const formType = ref('warehouse')
const selectedWarehouse = ref(null)
const form = reactive({ code: '', name: '', status: 'active' })
// 编辑目标的 id,null 表示新增。刻意单独存放、不进 form:
// form 上残留的 id 靠 Object.assign 是删不掉的,会导致「点新增却变成覆盖更新」。
const editingId = ref(null)
// 库位所属仓库只记 id,不记对象:load() 会整体替换 warehouses,
// 存下来的对象在保存后会变成改动前的旧数据(弹窗标题会显示旧仓库名)。
const selectedWarehouseId = ref(null)
const selectedWarehouse = computed(() => warehouses.value.find(item => item.id === selectedWarehouseId.value) || null)
const isEdit = computed(() => editingId.value !== null)
const form = reactive(emptyForm())
const formTitle = computed(() => {
if (isEdit.value) return formType.value === 'warehouse' ? '编辑仓库' : '编辑库位'
return formType.value === 'location' ? `新增库位 - ${selectedWarehouse.value?.name || ''}` : '新增仓库'
})
function emptyForm() { return { code: '', name: '', status: 'active' } }
async function load() { loading.value = true; try { const response = await warehousesApi.list(); warehouses.value = response.data || [] } finally { loading.value = false } }
function resetForm() { Object.assign(form, { code: '', name: '', status: 'active' }) }
function openWarehouse() { formType.value = 'warehouse'; selectedWarehouse.value = null; resetForm(); formVisible.value = true }
function openLocation(warehouse) { formType.value = 'location'; selectedWarehouse.value = warehouse; resetForm(); formVisible.value = true }
// 只灌表单认得的字段,不写 Object.assign(form, row):
// 这样 row 上的 id / organization_id / locations 在结构上就进不了 form。
// status 兜底成 active,否则空值提交上去会被后端 normalize 静默改回启用。
function fillForm(row) { replaceAllKeys(form, { code: row.code, name: row.name, status: row.status || 'active' }) }
function resetForm() { replaceAllKeys(form, emptyForm()) }
function openWarehouse() { formType.value = 'warehouse'; editingId.value = null; selectedWarehouseId.value = null; resetForm(); formVisible.value = true }
function openWarehouseEdit(warehouseId) {
const warehouse = warehouses.value.find(item => item.id === warehouseId)
if (!warehouse) return
formType.value = 'warehouse'; editingId.value = warehouseId; selectedWarehouseId.value = null; fillForm(warehouse); formVisible.value = true
}
function openLocation(warehouseId) { formType.value = 'location'; editingId.value = null; selectedWarehouseId.value = warehouseId; resetForm(); formVisible.value = true }
function openLocationEdit(warehouseId, locationId) {
const warehouse = warehouses.value.find(item => item.id === warehouseId)
const location = warehouse?.locations?.find(item => item.id === locationId)
if (!location) return
formType.value = 'location'; editingId.value = locationId; selectedWarehouseId.value = warehouseId; fillForm(location); formVisible.value = true
}
async function save() {
if (!form.code.trim()) { ElMessage.warning('请填写编码'); return }
if (saving.value) return
// 编辑态不校验编码:code 不可变,后端 Update 也不接收这个字段
if (!isEdit.value && !form.code.trim()) { ElMessage.warning('请填写编码'); return }
if (!form.name.trim()) { ElMessage.warning('请填写名称'); return }
// 编辑只发 name + status:带上 code 只会让人误以为编码能改
const payload = isEdit.value
? { name: form.name.trim(), status: form.status }
: { code: form.code.trim(), name: form.name.trim(), status: form.status }
saving.value = true
try { if (formType.value === 'warehouse') await warehousesApi.create(form); else await warehousesApi.createLocation(selectedWarehouse.value.id, form); ElMessage.success('保存成功'); formVisible.value = false; await load() } finally { saving.value = false }
try {
if (formType.value === 'warehouse') {
if (isEdit.value) await warehousesApi.update(editingId.value, payload)
else await warehousesApi.create(payload)
} else if (isEdit.value) {
await warehousesApi.updateLocation(selectedWarehouseId.value, editingId.value, payload)
} else {
await warehousesApi.createLocation(selectedWarehouseId.value, payload)
}
ElMessage.success('保存成功')
// 关窗放在 await 成功之后:失败时弹窗留着,用户能改完重试
formVisible.value = false
await load()
} catch {
// 错误提示由 axios 响应拦截器统一负责,这里不再重复弹一次;
// 显式吞掉还避免 save 的 rejection 冒到控制台(@click 经 el-button 透传到原生
// button 后不再走 Vue 的异步处理器包装,rejection 会变成 unhandled)。
} finally { saving.value = false }
}
onMounted(load)
</script>