import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, expect, it, vi } from 'vitest' import AppointmentCreatePanel from './AppointmentCreatePanel.vue' import { createSourceAppointment, listPlanningCargo, listPlanningDestinations, previewPlanningBatch, resolvePlanningSource, type PlanningCargo, type PlanningBatchPreview } from './planningApi' import type { QueryField } from '@/shared/components/QueryToolbar.vue' import { batchContext } from './batch.test-support' import type { LeaveGuard } from './useWorkDraft' vi.mock('./planningApi', async original => ({ ...await original(), listPlanningCargo: vi.fn(), listPlanningDestinations: vi.fn(), resolvePlanningSource: vi.fn(), previewPlanningBatch: vi.fn(), createSourceAppointment: vi.fn() })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn() } })) const cargo: PlanningCargo = { sourceKey: 'ORDER:9007199254740999', orderId: '9007199254740999', customerId: '9007199254740998', customerName: '测试客户', inboundCode: 'IN-TEST', containerBookingId: 'container-1', containerNo: 'TEST-CONT', originWarehouseId: 'origin-1', originWarehouseCode: 'US-W', originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', packages: 20, receivedPackages: 0, shippedPackages: 0, sourcePalletCount: null, volumeCbm: null, estimatedArrivalAt: null, estimatedWarehouseAt: null, orderVersion: 1, standardNode: 'DEPARTED', shippingVersion: null, shippingObservedAt: null, shippingProvider: null, hasInboundPlan: false, hasActiveAppointment: false, detained: false, mixedDestinations: false } const source = { sourceKey: cargo.sourceKey, customerId: cargo.customerId, orderId: cargo.orderId, inboundCode: cargo.inboundCode, bookingNo: null, containerNo: cargo.containerNo, originWarehouseId: cargo.originWarehouseId, originWarehouseName: cargo.originWarehouseName, destinationKey: 'FBA:LAX9', destinationWarehouseCode: 'LAX9', packages: 20, sourceVersion: 'v1', canFollowup: true, blockReasons: [] } const preview: PlanningBatchPreview = { group: { originWarehouseId: cargo.originWarehouseId, destinationKey: source.destinationKey, packages: 20, sourceCount: 1, customerCount: 1, readinessIncomplete: true, readyFrom: null, readyTo: null, unknownQuantitySources: 0 }, originWarehouseName: '美国仓', destinationWarehouseCode: 'LAX9', destinationTimezone: 'America/Los_Angeles', sources: [source], missingFields: [] } const guards = new Set() function render(canWrite = true) { return mount(AppointmentCreatePanel, { props: { canWrite, canLeave: async () => { for (const guard of guards) if (!await guard()) return false; return true } }, global: { provide: { 'appointment-work-leave-guard': (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) } }, stubs: { DataTable: { name: 'DataTable', props: ['data', 'total', 'resultsVisible'], emits: ['update:page'], template: '
{{ row.inboundCode }}
' }, QueryToolbar: { name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['search', 'update:modelValue'], template: '' }, ElCheckbox: { props: ['modelValue', 'disabled'], emits: ['change'], template: '' }, ElButton: { props: ['disabled', 'loading'], template: '' }, FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'disabled'], template: '' }, LoadErrorState: { props: ['message'], emits: ['retry'], template: '

{{ message }}

' }, ApiErrorMeta: true, }, } }) } async function selectAndPrepare(wrapper: ReturnType) { await wrapper.get('input[type="checkbox"]').setValue(true) await wrapper.findAll('button').find(button => button.text().startsWith('下一步'))!.trigger('click') await flushPromises() } beforeEach(() => { vi.clearAllMocks(); guards.clear() vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], total: 21, page: 1, size: 20, pages: 2 }) vi.mocked(resolvePlanningSource).mockResolvedValue({ source, coverage: { status: 'NONE', missingFields: [], changeReviewRequired: false, appointments: [] } }) vi.mocked(previewPlanningBatch).mockResolvedValue(preview) vi.mocked(createSourceAppointment).mockResolvedValue(batchContext().appointment) vi.mocked(listPlanningDestinations).mockResolvedValue(['ABE4','ONT8']) }) it('loads searchable destination options lazily and keeps the submitted filter across pages until reset', async () => { const wrapper = render(); await flushPromises() const toolbar = wrapper.getComponent({name:'QueryToolbar'}) const destination = () => (toolbar.props('fields') as QueryField[]).find(field=>field.key==='destination')! expect(destination().props?.filterable).toBe(true) expect(listPlanningDestinations).not.toHaveBeenCalled() const open = destination().props!.onVisibleChange as (visible:boolean)=>void open(true); await flushPromises() expect(destination().options).toEqual([{label:'ABE4',value:'ABE4'},{label:'ONT8',value:'ONT8'}]) open(true); await flushPromises(); expect(listPlanningDestinations).toHaveBeenCalledTimes(1) toolbar.vm.$emit('update:modelValue',{keyword:'IN-TEST',destination:'ABE4'}) await wrapper.get('[data-query]').trigger('click'); await flushPromises() expect(listPlanningCargo).toHaveBeenLastCalledWith({page:1,size:20,keyword:'IN-TEST',destination:'ABE4'},expect.any(AbortSignal)) await wrapper.get('[data-page]').trigger('click'); await flushPromises() expect(listPlanningCargo).toHaveBeenLastCalledWith({page:2,size:20,keyword:'IN-TEST',destination:'ABE4'},expect.any(AbortSignal)) toolbar.vm.$emit('update:modelValue',{keyword:'',destination:undefined}) await wrapper.get('[data-query]').trigger('click'); await flushPromises() expect(listPlanningCargo).toHaveBeenLastCalledWith({page:1,size:20,keyword:undefined},expect.any(AbortSignal)) wrapper.unmount() }) it('reports a destination directory failure and allows retry without hiding loaded cargo', async () => { vi.mocked(listPlanningDestinations).mockRejectedValueOnce(new Error('offline')) const wrapper = render(); await flushPromises() const field = (wrapper.getComponent({name:'QueryToolbar'}).props('fields') as QueryField[]).find(field=>field.key==='destination')! ;(field.props!.onVisibleChange as (visible:boolean)=>void)(true); await flushPromises() expect(wrapper.find('[role="alert"]').exists()).toBe(true) expect(wrapper.getComponent({name:'DataTable'}).props('resultsVisible')).toBe(true) await wrapper.get('[data-retry]').trigger('click'); await flushPromises() expect(listPlanningDestinations).toHaveBeenCalledTimes(2) expect(wrapper.find('[role="alert"]').exists()).toBe(false) wrapper.unmount() }) it('does not read or create per-row details until the operator selects cargo and proceeds', async () => { const wrapper = render(); await flushPromises() expect(listPlanningCargo).toHaveBeenCalledWith({ page: 1, size: 20, keyword: undefined }, expect.any(AbortSignal)) expect(resolvePlanningSource).not.toHaveBeenCalled(); expect(previewPlanningBatch).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled() await selectAndPrepare(wrapper) expect(resolvePlanningSource).toHaveBeenCalledWith({ sourceKey: cargo.sourceKey, customerId: cargo.customerId }) expect(previewPlanningBatch).toHaveBeenCalledWith([{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }]) expect(wrapper.text()).not.toContain(cargo.orderId) wrapper.unmount(); expect(guards.size).toBe(0) }) it('submits the real form, shows the saved appointment, and prevents another submit of the same completed form', async () => { const wrapper = render(); await flushPromises(); await selectAndPrepare(wrapper) await wrapper.get('[data-date]').setValue('2026-10-25') await wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises() expect(createSourceAppointment).toHaveBeenCalledWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }], details: expect.objectContaining({ plannedDate: '2026-10-25', plannedAt: null }), idempotencyKey: expect.any(String) })) expect(wrapper.emitted('created')).toEqual([[batchContext().appointment]]) expect(wrapper.get('[role="status"]').text()).toContain(batchContext().appointment.appointmentNo) expect(wrapper.findAll('button').some(button => button.text() === '保存预约安排')).toBe(false) expect(wrapper.get('[data-date]').attributes('disabled')).toBeDefined() wrapper.unmount() }) it('preserves the original request and blocks returning or closing after an unknown creation result', async () => { vi.mocked(createSourceAppointment).mockRejectedValueOnce(new Error('connection lost')) const wrapper = render(); await flushPromises(); await selectAndPrepare(wrapper) await wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises() await wrapper.findAll('button').find(button => button.text() === '返回选择货物')!.trigger('click'); await flushPromises() expect(wrapper.findComponent({ name: 'DataTable' }).exists()).toBe(false) expect((await Promise.all([...guards].map(guard => guard()))).includes(false)).toBe(true) await wrapper.findAll('button').find(button => button.text() === '重试本次创建')!.trigger('click'); await flushPromises() expect(vi.mocked(createSourceAppointment).mock.calls[1]).toEqual(vi.mocked(createSourceAppointment).mock.calls[0]) wrapper.unmount() }) it('disables blocked cargo and keeps selected cargo across pages', async () => { vi.mocked(listPlanningCargo).mockResolvedValueOnce({ records: [cargo, { ...cargo, sourceKey: 'ORDER:blocked', detained: true }], total: 21, page: 1, size: 20, pages: 2 }) const wrapper = render(); await flushPromises() expect(wrapper.findAll('input[type="checkbox"]')[1]!.attributes('disabled')).toBeDefined() await wrapper.findAll('input[type="checkbox"]')[0]!.setValue(true) await wrapper.get('[data-page]').trigger('click'); await flushPromises() expect(listPlanningCargo).toHaveBeenLastCalledWith({ page: 2, size: 20, keyword: undefined }, expect.any(AbortSignal)) expect(wrapper.findAll('button').some(button => button.text() === '下一步:填写预约(1)')).toBe(true) wrapper.unmount() }) it('reports source failures and does not offer a write path without permission', async () => { const readonly = render(false); await flushPromises() expect(listPlanningCargo).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled(); readonly.unmount() vi.mocked(listPlanningCargo).mockRejectedValueOnce(new Error('unavailable')) const wrapper = render(); await flushPromises() expect(wrapper.findComponent({ name: 'DataTable' }).props('resultsVisible')).toBe(false) expect(wrapper.find('[role="alert"]').exists()).toBe(true) wrapper.unmount() }) it('ignores a source resolution that arrives after the dialog is closed', async () => { let complete!: (value: Awaited>) => void vi.mocked(resolvePlanningSource).mockImplementationOnce(() => new Promise(resolve => { complete = resolve })) const wrapper = render(); await flushPromises(); await selectAndPrepare(wrapper) wrapper.unmount(); complete({ source, coverage: { status: 'NONE', missingFields: [], changeReviewRequired: false, appointments: [] } }); await flushPromises() expect(previewPlanningBatch).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled() })