Files
admin-web/src/modules/overseas-warehouse/pages/InboundDialogs.behavior.test.ts
T

240 lines
21 KiB
TypeScript

import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { ApiResponseError } from '@/shared/api/http'
import InboundOrdersView from './InboundOrdersView.vue'
import InboundOrderDetailView from './InboundOrderDetailView.vue'
import FbaAppointmentFields from '../components/FbaAppointmentFields.vue'
import { createAppointmentForm, type AppointmentForm } from '../appointmentForm'
import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment, updateInboundPlan, type FbaAppointmentDto, type InboundOrderSummaryDto } from '../api/inboundOrders'
import { getInboundPlan, moveInboundPallet, type InboundPlanDetailDto } from '../api/inboundPlans'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { listOverseasLocations, type OverseasLocationDto } from '../api/locations'
import { overseasWarehousePermissions as permission } from '../permissions'
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'IP-1' }, query: { customerId: 'C-1' } }), useRouter: () => ({ push: vi.fn() }), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null }))
vi.mock('../components/SmartDispatchDialog.vue', () => ({ default: { name: 'SmartDispatchDialog', template: '<div />' } }))
vi.mock('../api/inboundOrders', () => ({ createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(), listInboundOrders: vi.fn(), updateFbaAppointment: vi.fn(), updateInboundPlan: vi.fn() }))
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn(), moveInboundPallet: vi.fn() }))
vi.mock('../api/pallets', () => ({ listReceivingOverseasWarehouses: vi.fn() }))
vi.mock('../api/locations', () => ({ listOverseasLocations: vi.fn() }))
enableAutoUnmount(afterEach)
const order: InboundOrderSummaryDto = { lineId: '9223372036854775001', planId: 'internal-plan', planNo: 'IP-1', customerId: 'C-1', orderId: 'internal-order', status: 'PENDING_ASSIGNMENT', overseasWarehouseId: 'WH-1', overseasWarehouseCode: 'LA1', overseasWarehouseName: '洛杉矶一仓', warehouseId: 'WH-1', warehouseCode: 'LA1', warehouseName: '洛杉矶一仓', fbaWarehouseCode: 'ONT8', appointmentNo: 'AP-1', inboundCode: 'IN-1', version: 0 }
const warehouse: ReceivingOverseasWarehouseDto = { id: '9223372036854775002', customerId: '0', warehouseCode: 'LA2', warehouseName: '洛杉矶二仓', warehouseType: 'OVERSEAS', status: 'ACTIVE' }
const appointment: FbaAppointmentDto = { id: 'internal-appointment', appointmentNo: 'AP-1', version: 0, originWarehouseId: 'WH-1', fbaWarehouseCode: 'ONT8', appointmentStatus: 'PENDING', selectedCount: 1, timezone: 'America/Los_Angeles', items: [] }
const locations: OverseasLocationDto[] = [
{ id: 'normal', warehouseId: 'WH-1', locationCode: 'N01', locationType: 'NORMAL', sharedOverflow: false, status: 'ACTIVE', version: 1 },
{ id: 'overflow', warehouseId: 'WH-1', locationCode: 'A0', locationType: 'SHARED_OVERFLOW', sharedOverflow: true, status: 'ACTIVE', version: 0 },
{ id: 'virtual', warehouseId: 'WH-1', locationCode: 'VIRTUAL', locationType: 'VIRTUAL', sharedOverflow: false, status: 'ACTIVE', version: 1 },
{ id: 'disabled', warehouseId: 'WH-1', locationCode: 'DISABLED', locationType: 'NORMAL', sharedOverflow: false, status: 'DISABLED', version: 1 },
]
function plan(): InboundPlanDetailDto {
return { id: 'internal-plan', planNo: 'IP-1', customerId: 'C-1', sourceOrderId: 'internal-order', overseasWarehouseId: 'WH-1', overseasWarehouseCode: 'LA1', overseasWarehouseName: '洛杉矶一仓', lineCount: 1, palletCount: 1, totalGrossWeightKg: 10, totalNetWeightKg: 8, totalVolumeCbm: 1,
lines: [{ id: 'line', orderId: 'internal-order', inboundCode: 'IN-1', requiredPackageCount: 2, plannedPalletCount: 1 }],
pallets: [{ id: 'pallet', palletNo: 'PALLET-1', sequenceNo: 1, warehouseLocationCode: 'OLD', actualPackageCount: 2, version: 0 }],
allocations: [{ lineId: 'line', palletId: 'pallet', palletNo: 'PALLET-1', palletOwnerPlanNo: 'IP-1', palletOwnedByCurrentPlan: true, plannedPackageCount: 2, countedPackageCount: 2, pickedPackageCount: 2, version: 0 }],
}
}
beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true)
vi.mocked(listInboundOrders).mockResolvedValue({ records: [{ ...order }], total: 1, page: 1, size: 10, pages: 1 })
vi.mocked(getFbaAppointment).mockResolvedValue({ ...appointment })
vi.mocked(createFbaAppointment).mockResolvedValue({ ...appointment })
vi.mocked(updateFbaAppointment).mockResolvedValue({ ...appointment })
vi.mocked(updateInboundPlan).mockResolvedValue({ ...order })
vi.mocked(listReceivingOverseasWarehouses).mockResolvedValue({ records: [warehouse], total: 1, page: 1, size: 200, pages: 1 })
vi.mocked(getInboundPlan).mockResolvedValue(plan())
vi.mocked(listOverseasLocations).mockResolvedValue({ records: locations, total: 4, page: 1, size: 200, pages: 1 })
vi.mocked(moveInboundPallet).mockResolvedValue({ commandId: 'command', status: 'COMPLETED', canMove: true })
})
async function listPage() { const wrapper = mount(InboundOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } }); await flushPromises(); return wrapper }
function popup(wrapper: VueWrapper, title: string) { return wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title').startsWith(title))! }
async function openEditPlan() { const wrapper = await listPage(); await wrapper.findAll('.el-table__body-wrapper button').find(button => button.text() === '编辑')!.trigger('click'); await flushPromises(); return { wrapper, dialog: popup(wrapper, '编辑入库单') } }
async function openEditAppointment() { const wrapper = await listPage(); await wrapper.findAll('.el-table__body-wrapper button').find(button => button.text() === '预约')!.trigger('click'); await flushPromises(); return { wrapper, dialog: popup(wrapper, '编辑预约') } }
async function openMove() { const wrapper = mount(InboundOrderDetailView, { attachTo: document.body, global: { plugins: [ElementPlus] } }); await flushPromises(); await wrapper.findAll('.el-table__body-wrapper button').find(button => button.text() === '移库')!.trigger('click'); await flushPromises(); return { wrapper, dialog: popup(wrapper, '托盘移库') } }
describe('inbound appointment native dialogs', () => {
it('keeps every confirmed appointment field through fullscreen and submits the exact selected origin and lines', async () => {
vi.mocked(listInboundOrders).mockResolvedValue({ records: [{ ...order, appointmentNo: null }], total: 1, page: 1, size: 10, pages: 1 })
const wrapper = await listPage()
const table = wrapper.findComponent(DataTable)
table.vm.$emit('selection-change', table.props('data')); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '预约')!.trigger('click'); await flushPromises()
const dialog = popup(wrapper, '预约 ·')
const fields = dialog.getComponent(FbaAppointmentFields)
const model = fields.props('model')
model.appointmentStatus = 'SUCCESS'; await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises(); expect(createFbaAppointment).not.toHaveBeenCalled()
Object.assign(model, { plannedDate: '2026-11-01', plannedAt: '2026-11-01T09:00:00', plannedEndAt: '2026-11-01T11:00:00', confirmationNo: ' EXT-1 ', appointmentAt: '2026-11-01T01:00:00', appointmentEndAt: '2026-11-01T01:30:00', appointmentRemark: ' 约仓确认 ', startOffset: '-07:00', endOffset: '-08:00' })
await flushPromises(); await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(fields.props('continuous')).toBe(true)
expect(dialog.get('[aria-label="预约来源信息"]').text()).toContain('保存后生成')
expect(fields.findAll('details')).toHaveLength(1)
dialog.vm.$emit('confirm'); await flushPromises()
expect(createFbaAppointment).toHaveBeenCalledExactlyOnceWith({ originWarehouseId: 'WH-1', fbaWarehouseCode: 'ONT8', lineIds: [order.lineId], idempotencyKey: expect.any(String), details: {
plannedDate: '2026-11-01', plannedAt: '2026-11-01T09:00:00', plannedEndAt: '2026-11-01T11:00:00', confirmationNo: 'EXT-1', appointmentAt: '2026-11-01T01:00:00', appointmentEndAt: '2026-11-01T01:30:00', appointmentStatus: 'SUCCESS', appointmentRemark: '约仓确认', startOffset: '-07:00', endOffset: '-08:00',
} })
})
it('locks an uncertain edit to the original zero-version request and keeps close and route guards intact', async () => {
let reject!: (reason: unknown) => void
vi.mocked(updateFbaAppointment).mockReturnValueOnce(new Promise((_, no) => { reject = no })).mockResolvedValueOnce({ ...appointment })
const { dialog } = await openEditAppointment()
const fields = dialog.getComponent(FbaAppointmentFields)
fields.props('model').appointmentRemark = '交接备注'; await flushPromises()
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(updateFbaAppointment).toHaveBeenCalledTimes(1)
const original = vi.mocked(updateFbaAppointment).mock.calls[0]!
expect(original[1]).toMatchObject({ version: 0, idempotencyKey: expect.any(String) })
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
reject(new ApiResponseError('结果暂未确认', 'A0503', 503, null, 'appointment-popup-request')); await flushPromises()
expect(fields.props('disabled')).toBe(true)
expect(dialog.text()).toContain('appointment-popup-request')
await dialog.findAll('button').find(button => button.text() === '取消')!.trigger('click'); await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
for (const hook of [onBeforeRouteLeave, onBeforeRouteUpdate]) expect((vi.mocked(hook).mock.calls[0]![0] as () => boolean)()).toBe(false)
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(vi.mocked(updateFbaAppointment).mock.calls[1]).toEqual(original)
expect(dialog.props('modelValue')).toBe(false)
})
it.each(['permission', 'replacement', 'cancelled'] as const)('keeps %s appointments read-only in the real continuous form', async mode => {
if (mode === 'permission') can.mockImplementation(code => code !== permission.inboundOrderAppointment)
vi.mocked(getFbaAppointment).mockResolvedValue({ ...appointment, appointmentStatus: mode === 'cancelled' ? 'CANCELLED' : 'PENDING', replacementAppointmentNos: mode === 'replacement' ? ['AP-NEW'] : [] })
const wrapper = await listPage()
await wrapper.findAll('.el-table__body-wrapper button').find(button => button.text() === (mode === 'permission' ? '查看预约' : '预约'))!.trigger('click'); await flushPromises()
const dialog = popup(wrapper, '查看预约')
expect(dialog.getComponent(FbaAppointmentFields).props('disabled')).toBe(true)
expect(dialog.get('input[placeholder="有需交接的事项时填写"]').attributes('disabled')).toBeDefined()
expect(dialog.props('showConfirm')).toBe(false)
if (mode === 'replacement') expect(dialog.text()).toContain('替代预约:AP-NEW')
dialog.vm.$emit('confirm'); await flushPromises()
expect(updateFbaAppointment).not.toHaveBeenCalled()
})
it('keeps the failed-appointment reason required and clears offsets after a confirmed-time change', async () => {
const { dialog } = await openEditAppointment()
const fields = dialog.getComponent(FbaAppointmentFields), model = fields.props('model')
model.appointmentStatus = 'FAILED'; model.appointmentRemark = ' '; await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises(); expect(updateFbaAppointment).not.toHaveBeenCalled()
model.appointmentStatus = 'SUCCESS'; model.startOffset = '-07:00'; model.endOffset = '-08:00'; await flushPromises()
const dates = fields.findAllComponents({ name: 'ElDatePicker' })
dates.find(date => date.props('placeholder') === '目的仓当地时间')!.vm.$emit('change', '2026-11-01T10:00:00')
dates.find(date => date.props('placeholder') === '未明确可留空')!.vm.$emit('change', '2026-11-01T11:00:00'); await flushPromises()
expect(model.startOffset).toBe(''); expect(model.endOffset).toBe('')
})
it('keeps the shared default layout and all model fields available to existing consumers', () => {
const model: AppointmentForm = createAppointmentForm({ ...appointment, appointmentStatus: 'SUCCESS', confirmationNo: 'EXT-1', appointmentAt: '2026-10-21T10:00:00' })
const wrapper = mount(FbaAppointmentFields, { props: { model, appointmentNo: 'AP-1' }, global: { plugins: [ElementPlus] } })
expect(wrapper.find('.appointment-fields').exists()).toBe(true)
expect(wrapper.find('.appointment-editor-table').exists()).toBe(false)
expect(wrapper.findAllComponents({ name: 'ElDatePicker' })).toHaveLength(5)
expect(wrapper.findAll('input').some(input => input.element.value === 'EXT-1')).toBe(true)
})
})
describe('inbound plan and pallet move native dialogs', () => {
it('shows plan identity and version as text and saves the selected master warehouse through fullscreen', async () => {
const { dialog } = await openEditPlan()
expect(dialog.get('[aria-label="入库计划来源"]').findAll('input')).toHaveLength(0)
expect(dialog.get('[aria-label="入库计划来源"]').text()).toContain('版本0')
const select = dialog.findComponent({ name: 'ElSelect' })
select.vm.$emit('update:modelValue', warehouse.id); select.vm.$emit('change', warehouse.id); await flushPromises()
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(updateInboundPlan).toHaveBeenCalledExactlyOnceWith('IP-1', { customerId: 'C-1', overseasWarehouseId: warehouse.id, warehouseCode: 'LA2', warehouseName: '洛杉矶二仓', version: 0 })
})
it('locks a pending plan edit and keeps the warehouse and diagnostic context after failure', async () => {
let reject!: (reason: unknown) => void
vi.mocked(updateInboundPlan).mockReturnValueOnce(new Promise((_, no) => { reject = no }))
const { dialog } = await openEditPlan()
const select = dialog.findComponent({ name: 'ElSelect' })
select.vm.$emit('update:modelValue', warehouse.id); select.vm.$emit('change', warehouse.id); await flushPromises()
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(updateInboundPlan).toHaveBeenCalledTimes(1)
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(select.find('input').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('版本已变化', 'A0409', 409, null, 'plan-popup-request')); await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
expect(select.props('modelValue')).toBe(warehouse.id)
expect(dialog.text()).toContain('plan-popup-request')
})
it('does not save while the plan warehouse candidates are loading', async () => {
let resolve!: (value: Awaited<ReturnType<typeof listReceivingOverseasWarehouses>>) => void
vi.mocked(listReceivingOverseasWarehouses).mockReturnValue(new Promise(done => { resolve = done }))
const { dialog } = await openEditPlan()
expect(dialog.props('loading')).toBe(true)
dialog.vm.$emit('confirm'); await flushPromises(); expect(updateInboundPlan).not.toHaveBeenCalled()
resolve({ records: [warehouse], page: 1, size: 200, total: 1, pages: 1 }); await flushPromises()
expect(dialog.props('loading')).toBe(false)
})
it('loads only physical usable locations and preserves a valid zero pallet version in the command', async () => {
const { dialog } = await openMove()
expect(getInboundPlan).toHaveBeenCalledExactlyOnceWith('IP-1', 'C-1')
expect(listOverseasLocations).toHaveBeenCalledExactlyOnceWith('WH-1', { page: 1, size: 200 })
expect(dialog.get('[aria-label="移库来源"]').findAll('input')).toHaveLength(0)
expect(dialog.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['N01', 'A0'])
dialog.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'A0'); await dialog.get('input[placeholder="请输入移库原因"]').setValue(' 上架到溢出位 ')
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(moveInboundPallet).toHaveBeenCalledExactlyOnceWith({ overseasWarehouseId: 'WH-1', sourcePalletCode: 'PALLET-1', targetLocationCode: 'A0', expectedPalletVersion: 0, reason: '上架到溢出位', idempotencyKey: expect.stringMatching(/^admin-inbound-move-PALLET-1-\d+$/) })
expect(dialog.props('modelValue')).toBe(false)
})
it('locks pending move inputs and retains the chosen location, reason and API error after failure', async () => {
let reject!: (reason: unknown) => void
vi.mocked(moveInboundPallet).mockReturnValue(new Promise((_, no) => { reject = no }))
const { dialog } = await openMove()
dialog.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'N01'); await dialog.get('input[placeholder="请输入移库原因"]').setValue('现场上架')
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(moveInboundPallet).toHaveBeenCalledTimes(1)
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(dialog.get('input[placeholder="请输入移库原因"]').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('移库未完成', 'A0409', 409, null, 'move-popup-request')); await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
expect(dialog.findComponent({ name: 'ElSelect' }).props('modelValue')).toBe('N01')
expect((dialog.get('input[placeholder="请输入移库原因"]').element as HTMLInputElement).value).toBe('现场上架')
expect(dialog.text()).toContain('move-popup-request')
})
it('keeps failed location queries explicit and retries without losing the move reason', async () => {
vi.mocked(listOverseasLocations).mockRejectedValueOnce(new ApiResponseError('库位读取失败', 'A0503', 503, null, 'location-popup-request')).mockResolvedValueOnce({ records: locations, page: 1, size: 200, total: 4, pages: 1 })
const { dialog } = await openMove()
expect(dialog.props('confirmDisabled')).toBe(true)
expect(dialog.text()).toContain('location-popup-request')
await dialog.get('input[placeholder="请输入移库原因"]').setValue('保留原因')
dialog.vm.$emit('confirm'); await flushPromises(); expect(moveInboundPallet).not.toHaveBeenCalled()
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(listOverseasLocations).toHaveBeenCalledTimes(2)
expect((dialog.get('input[placeholder="请输入移库原因"]').element as HTMLInputElement).value).toBe('保留原因')
expect(dialog.findAllComponents({ name: 'ElOption' })).toHaveLength(2)
})
it('keeps server move blockers visible and does not close the form or refresh as success', async () => {
vi.mocked(moveInboundPallet).mockResolvedValue({ commandId: 'blocked', status: 'REJECTED', canMove: false, blockReason: '托盘正在作业,禁止移库' })
const { dialog } = await openMove()
dialog.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'N01'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
expect(dialog.text()).toContain('托盘正在作业,禁止移库')
expect(getInboundPlan).toHaveBeenCalledTimes(1)
})
it('shows empty physical locations and rejects a move without a target or reason', async () => {
vi.mocked(listOverseasLocations).mockResolvedValue({ records: locations.filter(location => location.locationType === 'VIRTUAL' || location.status === 'DISABLED'), page: 1, size: 200, total: 2, pages: 1 })
const { dialog } = await openMove()
expect(dialog.text()).toContain('暂无可用普通库位或 A0')
expect(dialog.props('confirmDisabled')).toBe(true)
dialog.vm.$emit('confirm'); await flushPromises(); expect(moveInboundPallet).not.toHaveBeenCalled()
})
it('keeps missing pallet versions unavailable instead of treating them as valid zero versions', async () => {
const value = plan(); delete value.pallets[0]!.version
vi.mocked(getInboundPlan).mockResolvedValue(value)
const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises()
const button = wrapper.findAll('.el-table__body-wrapper button').find(button => button.text() === '移库')!
expect(button.attributes('disabled')).toBeDefined()
expect(listOverseasLocations).not.toHaveBeenCalled()
})
})