import { flushPromises, mount, enableAutoUnmount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import ElementPlus from 'element-plus' import { ApiResponseError } from '@/shared/api/http' import WarehouseTransferMovementPanel from './WarehouseTransferMovementPanel.vue' import { getWarehouseTransfer, outboundWarehouseTransfer, receiveWarehouseTransfer, listTransferReceivingWarehouses, type WarehouseTransferDetailDto, type WarehouseTransferReceiptCommandDto } from '../api/warehouseTransfers' import { getPallet, listPallets } from '../api/pallets' import { getOverseasLocation } from '../api/locations' const { can } = vi.hoisted(() => ({ can: vi.fn() })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) })) vi.mock('../api/warehouseTransfers', () => ({ getWarehouseTransfer: vi.fn(), outboundWarehouseTransfer: vi.fn(), receiveWarehouseTransfer: vi.fn(), listTransferReceivingWarehouses: vi.fn() })) vi.mock('../api/pallets', () => ({ getPallet: vi.fn(), listPallets: vi.fn() })) vi.mock('../api/locations', () => ({ getOverseasLocation: vi.fn() })) enableAutoUnmount(afterEach) const detail = (status = 'ALLOCATED'): WarehouseTransferDetailDto => ({ id: 'hidden', transferNo: 'WT-1', packageCount: 1, status, needRelabel: false, fbaWarehouseCode: 'ABE4', destinationWarehouseCode: 'ABE4', items: [], cartons: [], pallets: [], relabelBindings: [], receiptCommands: [], allocations: [{ inventoryPositionId: 'hidden-source', inboundOrderId: 'hidden-inbound', inboundCode: 'IN-1', palletId: 'hidden-la', palletNo: 'LA-CODE', skuCode: 'NEW', packageCount: 1, status: status === 'ALLOCATED' ? 'RESERVED' : 'SHIPPED' }] }) const render = (value = detail()) => mount(WarehouseTransferMovementPanel, { props: { customerId: 'customer', detail: value }, global: { plugins: [ElementPlus] } }) const button = (wrapper: ReturnType, label: string) => wrapper.findAll('button').find(x => x.text() === label)! describe('transfer movement UI', () => { beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); can.mockReturnValue(true) }) it('uses the overseas resource warehouse permission and does not require warehouse master access', async () => { can.mockImplementation(code => code !== 'platform:warehouse:list') const permitted = render(detail('OUTBOUNDED')); expect(permitted.text()).toContain('登记目标仓实收') can.mockImplementation(code => code !== 'platform:overseas-warehouse:warehouse:list') const denied = render(detail('OUTBOUNDED')); expect(denied.text()).not.toContain('登记目标仓实收'); expect(listTransferReceivingWarehouses).not.toHaveBeenCalled() }) it.each([ { cause: new ApiResponseError('目标不可用', 'A0409', 200), recorded: false, canReselect: true }, { cause: new Error('response lost'), recorded: false, canReselect: false }, { cause: new ApiResponseError('目标不可用', 'A0409', 200), recorded: true, canReselect: false }, ])('allows target correction only after definite rejection plus authoritative no-command readback: $canReselect', async ({ cause, recorded, canReselect }) => { const body = { sourceInventoryPositionId: 'hidden-source', destinationOverseasWarehouseId: 'NY', destinationOverseasWarehouseCode: 'NY-CODE', destinationOverseasWarehouseName: '纽约', destinationWarehouseCode: 'ABE4', destinationPalletId: 'P-NY', destinationPalletNo: 'PALLET-NY', warehouseLocationId: 'L-NY', warehouseLocationCode: 'LOC-NY', batchNo: 'WT-1', idempotencyKey: 'original-key' } sessionStorage.setItem('warehouse-transfer-movement:customer:WT-1', JSON.stringify({ pending: { type: 'receipt', body } })) const fresh = detail('OUTBOUNDED') if (recorded) fresh.receiptCommands = [{ sourceInventoryPositionId: 'hidden-source', status: 'PREPARED', requestHash: 'hash', request: body, target: { warehouseId: 'NY', warehouseCode: 'NY-CODE', warehouseName: '纽约', palletId: 'P-NY', palletNo: 'PALLET-NY', locationId: 'L-NY', locationCode: 'LOC-NY', fbaCode: 'ABE4' } }] vi.mocked(receiveWarehouseTransfer).mockRejectedValue(cause); vi.mocked(getWarehouseTransfer).mockResolvedValue(fresh) const wrapper = render(detail('OUTBOUNDED')); await button(wrapper, '重试原操作').trigger('click'); await flushPromises() expect(wrapper.text()).not.toContain('重新选择目标') await button(wrapper, '只读核对当前状态').trigger('click'); await flushPromises() expect(wrapper.text().includes('重新选择目标')).toBe(canReselect) expect(receiveWarehouseTransfer).toHaveBeenCalledTimes(1) expect(JSON.parse(sessionStorage.getItem('warehouse-transfer-movement:customer:WT-1')!).pending.body).toEqual(body) if (canReselect) { vi.mocked(listTransferReceivingWarehouses).mockResolvedValue({ records: [] }) await button(wrapper, '重新选择目标').trigger('click'); await flushPromises() expect(JSON.parse(sessionStorage.getItem('warehouse-transfer-movement:customer:WT-1')!).pending).toBeNull() expect(wrapper.text()).toContain('上次目标选择被拒绝:目标不可用') expect(receiveWarehouseTransfer).toHaveBeenCalledTimes(1) } }) it('recovers a reserved original server command in a fresh browser and never queries arbitrary occupied resources', async () => { const command: WarehouseTransferReceiptCommandDto = { sourceInventoryPositionId: 'hidden-source', status: 'RESERVED', requestHash: 'server-hash', request: { sourceInventoryPositionId: 'hidden-source', destinationOverseasWarehouseId: 'NY', destinationOverseasWarehouseCode: 'NY-CODE', destinationOverseasWarehouseName: '纽约', destinationWarehouseCode: 'ABE4', destinationPalletId: 'P-NY', destinationPalletNo: 'PALLET-NY', warehouseLocationId: 'L-NY', warehouseLocationCode: 'LOC-NY', batchNo: 'WT-1', idempotencyKey: 'server-original' }, target: { warehouseId: 'NY', warehouseCode: 'NY-CODE', warehouseName: '纽约', palletId: 'P-NY', palletNo: 'PALLET-NY', locationId: 'L-NY', locationCode: 'LOC-NY', fbaCode: 'ABE4' } } const d = detail('OUTBOUNDED'); d.receiptCommands = [command] const completed = detail('RECEIVED'); completed.allocations![0]!.status = 'RECEIVED'; completed.receiptCommands = [{ ...command, status: 'COMPLETED' }] vi.mocked(receiveWarehouseTransfer).mockResolvedValue(completed) const wrapper = render(d); await flushPromises() expect(wrapper.text()).toContain('从本票服务端记录恢复'); expect(wrapper.text()).not.toContain('登记目标仓实收') await button(wrapper, '重试原操作').trigger('click'); await flushPromises() expect(receiveWarehouseTransfer).toHaveBeenCalledExactlyOnceWith('customer', 'WT-1', command.request) expect(wrapper.emitted('updated')?.[0]?.[0]).toMatchObject({ status: 'RECEIVED' }) expect(listPallets).not.toHaveBeenCalled(); expect(getPallet).not.toHaveBeenCalled() }) it('does not use a browser remembered occupied target when server receipt history is missing', async () => { sessionStorage.setItem('warehouse-transfer-movement:customer:WT-1', JSON.stringify({ pending: null, previous: { warehouse: { id: 'OTHER' }, pallet: { id: 'OTHER', status: 'OCCUPIED' } } })) const d = detail('PARTIALLY_RECEIVED'); delete d.receiptCommands const wrapper = render(d); expect(wrapper.text()).toContain('尚未返回服务端实收操作记录'); expect(wrapper.text()).not.toContain('登记目标仓实收'); expect(getPallet).not.toHaveBeenCalled() }) it('permits same occupied target from completed server history in a fresh browser', async () => { const d = detail('PARTIALLY_RECEIVED') const completed: WarehouseTransferReceiptCommandDto = { sourceInventoryPositionId: 'already-received', status: 'COMPLETED', requestHash: 'hash', request: { sourceInventoryPositionId: 'already-received', destinationOverseasWarehouseId: 'NY', destinationOverseasWarehouseCode: 'NY-CODE', destinationOverseasWarehouseName: '纽约', destinationWarehouseCode: 'ABE4', destinationPalletId: 'P-NY', destinationPalletNo: 'PALLET-NY', warehouseLocationId: 'L-NY', warehouseLocationCode: 'LOC-NY', batchNo: 'WT-1', idempotencyKey: 'first-key' }, target: { warehouseId: 'NY', warehouseCode: 'NY-CODE', warehouseName: '纽约', palletId: 'P-NY', palletNo: 'PALLET-NY', locationId: 'L-NY', locationCode: 'LOC-NY', fbaCode: 'ABE4' } } d.receiptCommands = [completed] vi.mocked(listTransferReceivingWarehouses).mockResolvedValue({ records: [{ id: 'NY', customerId: '0', warehouseCode: 'NY-CODE', warehouseName: '纽约', warehouseType: 'OVERSEAS', status: 'ACTIVE' }] }) vi.mocked(getPallet).mockResolvedValue({ id: 'P-NY', customerId: '0', warehouseId: 'NY', locationId: 'L-NY', palletCode: 'PALLET-NY', palletType: 'STANDARD', fbaWarehouseCode: 'ABE4', capacityCbm: 2, unitPrice: 0, status: 'OCCUPIED', version: 1 }) vi.mocked(getOverseasLocation).mockResolvedValue({ id: 'L-NY', warehouseId: 'NY', locationCode: 'LOC-NY', locationType: 'NORMAL', sharedOverflow: false, status: 'OCCUPIED', version: 1 }) const wrapper = render(d); await button(wrapper, '登记目标仓实收').trigger('click'); await flushPromises() expect(wrapper.text()).toContain('目标:NY-CODE / PALLET-NY / LOC-NY') expect(getPallet).toHaveBeenCalledExactlyOnceWith('NY', 'P-NY') expect(listPallets).not.toHaveBeenCalled() await wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'hidden-source'); await flushPromises() expect(button(wrapper, '确认本来源实收').attributes('disabled')).toBeUndefined() expect(receiveWarehouseTransfer).not.toHaveBeenCalled() }) it('requires permission and explicit actual-outbound confirmation; unknown outcome survives remount with same body', async () => { vi.mocked(outboundWarehouseTransfer).mockRejectedValueOnce(new Error('response lost')).mockResolvedValueOnce(detail('OUTBOUNDED')) const first = render(); await button(first, '确认本票出库').trigger('click'); expect(outboundWarehouseTransfer).not.toHaveBeenCalled() await button(first, '确认实际出库').trigger('click'); await flushPromises() const original = JSON.parse(JSON.stringify(vi.mocked(outboundWarehouseTransfer).mock.calls[0])) expect(first.text()).toContain('原操作内容已保留'); first.unmount() const second = render(detail('OUTBOUNDED')); await flushPromises() expect(second.text()).not.toContain('hidden-source'); expect(second.text()).toContain('重试原操作') await button(second, '重试原操作').trigger('click'); await flushPromises() expect(vi.mocked(outboundWarehouseTransfer).mock.calls[1]).toEqual(original) expect(second.emitted('updated')?.[0]?.[0]).toMatchObject({ status: 'OUTBOUNDED' }) can.mockReturnValue(false); const denied = render(); expect(denied.text()).not.toContain('确认本票出库') }) it('queries only selected warehouse page, reads exact bound location and keeps receipt request after response loss', async () => { vi.mocked(listTransferReceivingWarehouses).mockResolvedValue({ records: [{ id: 'NY', customerId: '0', warehouseCode: 'NY-CODE', warehouseName: '纽约', warehouseType: 'OVERSEAS', status: 'ACTIVE' }] }) const p = { id: 'P-NY', customerId: '0', warehouseId: 'NY', locationId: 'L-NY', palletCode: 'PALLET-NY', palletType: 'STANDARD', fbaWarehouseCode: 'ABE4', capacityCbm: 2, unitPrice: 0, status: 'IDLE', version: 0 } vi.mocked(listPallets).mockResolvedValue({ records: [p], page: 1, size: 20, total: 1, pages: 1 }) vi.mocked(getPallet).mockResolvedValue(p) vi.mocked(getOverseasLocation).mockResolvedValue({ id: 'L-NY', warehouseId: 'NY', locationCode: 'LOC-NY', locationType: 'NORMAL', sharedOverflow: false, status: 'IDLE', version: 0 }) vi.mocked(receiveWarehouseTransfer).mockRejectedValueOnce(new Error('response lost')).mockResolvedValueOnce(detail('RECEIVED')) const wrapper = render(detail('OUTBOUNDED')); await button(wrapper, '登记目标仓实收').trigger('click'); await flushPromises() expect(listPallets).not.toHaveBeenCalled() await wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'hidden-source'); await wrapper.findAllComponents({ name: 'ElSelect' })[1]!.vm.$emit('update:modelValue', 'NY') await wrapper.get('input[aria-label="目标托盘码查询"]').setValue('PALLET-NY'); await button(wrapper, '查询目标托盘').trigger('click'); await flushPromises() expect(listPallets).toHaveBeenCalledExactlyOnceWith('NY', { page: 1, size: 20, keyword: 'PALLET-NY', status: 'IDLE' }) wrapper.findAllComponents({ name: 'ElSelect' })[2]!.vm.$emit('update:modelValue', 'P-NY'); await flushPromises() expect(getOverseasLocation).toHaveBeenCalledExactlyOnceWith('NY', 'L-NY') await button(wrapper, '确认本来源实收').trigger('click'); await flushPromises() const original = JSON.parse(JSON.stringify(vi.mocked(receiveWarehouseTransfer).mock.calls[0])) expect(original[2]).toMatchObject({ sourceInventoryPositionId: 'hidden-source', destinationPalletNo: 'PALLET-NY', warehouseLocationCode: 'LOC-NY' }) await button(wrapper, '只读核对当前状态').trigger('click'); await flushPromises() expect(receiveWarehouseTransfer).toHaveBeenCalledTimes(1) await button(wrapper, '重试原操作').trigger('click'); await flushPromises() expect(vi.mocked(receiveWarehouseTransfer).mock.calls[1]).toEqual(original) expect(wrapper.text()).not.toContain('hidden-source') }) it('does not request resources when permission is absent or source already received', async () => { can.mockImplementation(code => code !== 'platform:overseas-warehouse:location:list') const denied = render(detail('OUTBOUNDED')); expect(denied.text()).not.toContain('登记目标仓实收'); expect(listTransferReceivingWarehouses).not.toHaveBeenCalled() can.mockReturnValue(true); const done = detail('RECEIVED'); done.allocations![0]!.status = 'RECEIVED'; const wrapper = render(done); expect(wrapper.text()).not.toContain('登记目标仓实收') }) })