diff --git a/frontend/src/views/inventory/WarehouseSettings.test.js b/frontend/src/views/inventory/WarehouseSettings.test.js index fd017e3e..db45ef69 100644 --- a/frontend/src/views/inventory/WarehouseSettings.test.js +++ b/frontend/src/views/inventory/WarehouseSettings.test.js @@ -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: '
' +} + +const OptionStub = { + props: ['label', 'value'], + template: '{{ label }}' +} + // 表单项没有 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() + }) +}) diff --git a/frontend/src/views/inventory/WarehouseSettings.vue b/frontend/src/views/inventory/WarehouseSettings.vue index 15530344..7e989ed8 100644 --- a/frontend/src/views/inventory/WarehouseSettings.vue +++ b/frontend/src/views/inventory/WarehouseSettings.vue @@ -3,19 +3,41 @@
仓库与库位属于库存域,不在物料主数据中维护。新增仓库
- -
库位新增库位
+ +
库位新增库位
- + + + + + + + +
- + - + + + + + + + @@ -23,25 +45,83 @@