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: '
' } })) 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