import ElementPlus from 'element-plus' import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import AppointmentCargoPanel from './AppointmentCargoPanel.vue' import AppointmentContainerPanel from './AppointmentContainerPanel.vue' import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue' import { listExternalAppointmentCandidates, getFollowups, listPlanningCargo, resolvePlanningSource, listPlanningContainers, resolvePlanningContainer, previewPlanningBatch, createSourceAppointment, type PlanningCargo, type PlanningSource } from './planningApi' vi.mock('./planningApi', () => ({ listExternalAppointmentCandidates: vi.fn(), listPlanningCargo: vi.fn(), resolvePlanningSource: vi.fn(), getFollowups: vi.fn(), saveFollowup: vi.fn(), listPlanningContainers: vi.fn(), resolvePlanningContainer: vi.fn(), previewPlanningBatch: vi.fn(), createSourceAppointment: vi.fn() })) vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) const source: PlanningSource = { sourceKey: 'ORDER:1', customerId: 'A', orderId: '1', inboundCode: 'IN-A', bookingNo: null, containerNo: 'CONTAINER-A', originWarehouseId: '100', originWarehouseName: '美国仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: 20, sourceVersion: 'ORDER:1', canFollowup: true, blockReasons: [] } const cargo: PlanningCargo = { sourceKey: source.sourceKey, customerId: 'A', customerName: '客户甲', orderId: '1', inboundCode: 'IN-A', containerBookingId: '2', containerNo: 'CONTAINER-A', originWarehouseId: '100', originWarehouseCode: 'US', originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', packages: 20, receivedPackages: null, shippedPackages: null, 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 coverage = { status: 'UNASSIGNED', appointments: [], missingFields: [], changeReviewRequired: false } describe('appointment tables use actual shared table slots', () => { beforeEach(() => vi.resetAllMocks()) it('discovers external candidates lazily and mixes original bookings with selected first-leg cargo', async () => { vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], page: 1, size: 20, total: 1, pages: 1 }) const external: PlanningSource = { ...source, sourceKey: 'BOOKING:B-1', customerId: 'B', orderId: null, inboundCode: null, bookingNo: 'B-1', packages: null } vi.mocked(listExternalAppointmentCandidates).mockResolvedValue({ records: [{ source: external, customerName: '客户乙', tripNo: 'TR-1', coverage: { status: 'UNSCHEDULED', appointments: [], missingFields: [], changeReviewRequired: false } }], total: 1 }) vi.mocked(previewPlanningBatch).mockResolvedValue({ group: { originWarehouseId: '100', destinationKey: 'FBA:ONT8', packages: null, sourceCount: 2, customerCount: 2, readyFrom: null, readyTo: null, readinessIncomplete: true, unknownQuantitySources: 1 }, originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', sources: [source, external], missingFields: [] }) const wrapper = mount(AppointmentCargoPanel, { global: { plugins: [ElementPlus], stubs: { AppDialog: { template: '
' } } } }) await flushPromises() expect(listExternalAppointmentCandidates).not.toHaveBeenCalled() await wrapper.findAll('button').find(b => b.text() === '选择本页可合批货物')!.trigger('click') await wrapper.findAll('button').find(b => b.text() === '外接货物')!.trigger('click'); await flushPromises() expect(listExternalAppointmentCandidates).toHaveBeenCalledTimes(1) expect(wrapper.text()).toContain('B-1') await wrapper.findAll('button').find(b => b.text() === '加入合批')!.trigger('click') await wrapper.findAll('button').find(b => b.text().startsWith('合批核对') && b.isVisible())!.trigger('click'); await flushPromises() expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith([{ sourceKey: 'ORDER:1', customerId: 'A', destinationKey: 'FBA:ONT8' }, { sourceKey: 'BOOKING:B-1', customerId: 'B', destinationKey: 'FBA:ONT8' }]) expect(resolvePlanningSource).not.toHaveBeenCalled() wrapper.unmount() }) it('combines an original external booking with first-leg cargo and retries the identical save command', async () => { const external: PlanningSource={...source,sourceKey:'BOOKING:B-1',customerId:'B',bookingNo:'B-1',orderId:null,inboundCode:null,packages:null} vi.mocked(listPlanningCargo).mockResolvedValue({records:[cargo],page:1,size:20,total:1,pages:1}) vi.mocked(previewPlanningBatch).mockResolvedValue({group:{originWarehouseId:'100',destinationKey:'FBA:ONT8',packages:null,sourceCount:2,customerCount:2,readyFrom:null,readyTo:null,readinessIncomplete:true,unknownQuantitySources:1},originWarehouseName:'美国仓',destinationWarehouseCode:'ONT8',sources:[external,source],missingFields:['CARGO_QUANTITY_REVIEW']}) vi.mocked(createSourceAppointment).mockRejectedValueOnce(new Error('network interrupted')).mockResolvedValueOnce({id:'1',version:0,appointmentNo:'AP-MIXED',appointmentStatus:'PENDING',originWarehouseId:'100',fbaWarehouseCode:'ONT8',selectedCount:2,items:[]}) const wrapper=mount(AppointmentCargoPanel,{props:{externalSource:external},global:{plugins:[ElementPlus],stubs:{AppDialog:{template:'
'}}}}) await flushPromises() await wrapper.findAll('button').find(b=>b.text()==='加入本次选择')!.trigger('click') await wrapper.findAll('button').find(b=>b.text()==='选择本页可合批货物')!.trigger('click') await wrapper.findAll('button').find(b=>b.text().startsWith('合批核对'))!.trigger('click');await flushPromises() expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith([{sourceKey:'BOOKING:B-1',customerId:'B',destinationKey:'FBA:ONT8'},{sourceKey:'ORDER:1',customerId:'A',destinationKey:'FBA:ONT8'}]) await wrapper.get('[data-save]').trigger('click');await flushPromises() const first=vi.mocked(createSourceAppointment).mock.calls[0]![0] expect(first).toMatchObject({originWarehouseId:'100',fbaWarehouseCode:'ONT8',details:{appointmentStatus:'PENDING',confirmationNo:null,appointmentAt:null}}) expect(first.sources[0]!.sourceKey).toBe('BOOKING:B-1') await wrapper.get('[data-save]').trigger('click');await flushPromises() expect(createSourceAppointment).toHaveBeenNthCalledWith(2,first);expect(wrapper.emitted('created')).toHaveLength(1) expect(resolvePlanningSource).not.toHaveBeenCalled();wrapper.unmount() }) it('retains the current selection when a linked source has a different origin', async () => { vi.mocked(listPlanningCargo).mockResolvedValue({records:[cargo],page:1,size:20,total:1,pages:1}) vi.mocked(previewPlanningBatch).mockRejectedValue(new Error('stop after observing request')) const wrapper=mount(AppointmentCargoPanel,{props:{externalSource:{...source,sourceKey:'BOOKING:B-2',bookingNo:'B-2',originWarehouseId:'200'}},global:{plugins:[ElementPlus]}}) await flushPromises();await wrapper.findAll('button').find(b=>b.text()==='选择本页可合批货物')!.trigger('click') await wrapper.findAll('button').find(b=>b.text()==='加入本次选择')!.trigger('click') expect(wrapper.emitted('sourceAccepted')).toBeUndefined() await wrapper.findAll('button').find(b=>b.text().startsWith('合批核对'))!.trigger('click');await flushPromises() expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith([{sourceKey:'ORDER:1',customerId:'A',destinationKey:'FBA:ONT8'}]);wrapper.unmount() }) it('selects multiple customers and makes one authoritative batch check without per-row resolution', async () => { vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo, { ...cargo, sourceKey: 'ORDER:2', orderId: '2', inboundCode: 'IN-B', customerId: 'B', customerName: '客户乙' }], page: 1, size: 20, total: 2, pages: 1 }) vi.mocked(previewPlanningBatch).mockResolvedValue({ group: { originWarehouseId: '100', destinationKey: 'FBA:ONT8', packages: 40, sourceCount: 2, customerCount: 2, readyFrom: null, readyTo: null, readinessIncomplete: true, unknownQuantitySources: 0 }, originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', sources: [source], missingFields: ['READY_WINDOW_UNVERIFIED'] }) const wrapper = mount(AppointmentCargoPanel, { global: { plugins: [ElementPlus], stubs: { AppDialog: { template: '
' } } } }) await flushPromises() await wrapper.findAll('button').find(button => button.text() === '选择本页可合批货物')!.trigger('click') await wrapper.findAll('button').find(button => button.text().startsWith('合批核对'))!.trigger('click') await flushPromises() expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith([{ sourceKey: 'ORDER:1', customerId: 'A', destinationKey: 'FBA:ONT8' }, { sourceKey: 'ORDER:2', customerId: 'B', destinationKey: 'FBA:ONT8' }]) expect(resolvePlanningSource).not.toHaveBeenCalled() expect(wrapper.text()).toContain('2 票 / 2 个客户 / 预报 40 箱') expect(wrapper.text()).toContain('保存前尚未占用货物') wrapper.unmount() }) it('renders one shared container and resolves each receiving warehouse without per-row requests', async () => { vi.mocked(listPlanningContainers).mockResolvedValue({ total: 1, records: [{ containerBookingId: 'C1', containerNo: 'CONTAINER-A', orderCount: 20, customerCount: 12, packages: null, unknownPackageOrders: 1, earliestArrivalAt: null, latestArrivalAt: null, standardNode: 'DEPARTED', sourceVersion: null, incompleteScope: false, warehouses: [{ warehouseId: '100', warehouseName: '洛杉矶仓', orderCount: 20, customerCount: 12 }] }] }) vi.mocked(resolvePlanningContainer).mockResolvedValue({ containerBookingId: 'C1', containerNo: 'CONTAINER-A', warehouseId: '100', warehouseName: '洛杉矶仓', canFollowup: true, blockReasons: [] }) const wrapper = mount(AppointmentContainerPanel, { global: { plugins: [ElementPlus], stubs: { AppointmentFollowupDialog: true } } }) await flushPromises() expect(listPlanningContainers).toHaveBeenCalledTimes(1) expect(resolvePlanningContainer).not.toHaveBeenCalled() const rows = wrapper.findAll('.el-table__body-wrapper .el-table__row') expect(rows).toHaveLength(1) expect(rows[0]!.text()).toContain('待核实') expect(rows[0]!.text()).toContain('洛杉矶仓') await rows[0]!.findAll('button').find(button => button.text() === '接仓跟进')!.trigger('click') await flushPromises() expect(resolvePlanningContainer).toHaveBeenCalledExactlyOnceWith('C1', '100') expect(wrapper.findComponent(AppointmentFollowupDialog).props('container')?.warehouseId).toBe('100') wrapper.unmount() }) it('renders working source actions and readable progress through DataTable and RowActions', async () => { vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], page: 1, size: 20, total: 1, pages: 1 }) vi.mocked(resolvePlanningSource).mockResolvedValue({ source, coverage }) const wrapper = mount(AppointmentCargoPanel, { global: { plugins: [ElementPlus], stubs: { AppointmentFollowupDialog: true } } }) await flushPromises() const row = wrapper.get('.el-table__body-wrapper .el-table__row') expect(row.text()).toContain('IN-A') expect(row.text()).toContain('尚未建入仓计划') const followup = row.findAll('button').find(button => button.text() === '跟进') expect(followup).toBeDefined() expect(row.findAll('button').some(button => button.text() === '查看来源')).toBe(true) await followup!.trigger('click') await flushPromises() expect(resolvePlanningSource).toHaveBeenCalledExactlyOnceWith({ sourceKey: 'ORDER:1', customerId: 'A', destinationKey: 'FBA:ONT8' }) expect(wrapper.findComponent(AppointmentFollowupDialog).exists()).toBe(true) wrapper.unmount() }) it('keeps arrangement permission independent from contact-record writes and avoids duplicate active appointments', async () => { vi.mocked(getFollowups).mockResolvedValue({history:{records:[],total:0,page:1,size:10,pages:0},versions:{DRAYAGE_CONTACT:0,TERMINAL_APPOINTMENT:0,WAREHOUSE_RECEIVING:0,FBA_RECEIVING:0}}) const wrapper=mount(AppointmentFollowupDialog,{props:{source,coverage,canWrite:false,canArrange:true},global:{plugins:[ElementPlus],stubs:{AppDialog:{template:'
'}}}}) await flushPromises();await wrapper.findAll('button').find(b=>b.text()==='加入预约合批')!.trigger('click') expect(wrapper.emitted('arrange')).toHaveLength(1) await wrapper.setProps({canArrange:false});expect(wrapper.findAll('button').some(b=>b.text()==='加入预约合批')).toBe(false) await wrapper.setProps({canArrange:true,coverage:{...coverage,appointments:[{appointmentNo:'AP-EXISTING',confirmationNo:null,status:'PENDING',confirmedAt:null,confirmedEndAt:null,timezone:null}]}}) expect(wrapper.findAll('button').some(b=>b.text()==='加入预约合批')).toBe(false);wrapper.unmount() }) it('renders followup stage names instead of blank table cells', async () => { vi.mocked(getFollowups).mockResolvedValue({ history: { records: [{ stage: 'FBA_RECEIVING', version: 1, contactedParty: null, result: '等待回复', nextAction: null, followupDate: null, operatorName: '运营', createdAt: '2026-10-09T12:00:00Z' }], total: 1, page: 1, size: 10, pages: 1 }, versions: { DRAYAGE_CONTACT: 0, TERMINAL_APPOINTMENT: 0, WAREHOUSE_RECEIVING: 0, FBA_RECEIVING: 1 } }) const wrapper = mount(AppointmentFollowupDialog, { props: { source, coverage, canWrite: false }, global: { plugins: [ElementPlus], stubs: { AppDialog: { template: '
' } } } }) await flushPromises() const row = wrapper.get('.el-table__body-wrapper .el-table__row') expect(row.text()).toContain('FBA收货跟进') expect(row.text()).toContain('等待回复') wrapper.unmount() }) })