import ElementPlus from 'element-plus' import { DOMWrapper, enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import AppDialog from '@/shared/components/AppDialog.vue' import LineClampText from '@/shared/components/LineClampText.vue' import AppointmentCalendarView from './AppointmentCalendarView.vue' import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type WarehouseArrival } from './scheduleApi' import { listTripCalendar } from './tripCalendar' import { createSourceAppointment, listPlanningCargo, previewPlanningBatch, resolvePlanningSource, type PlanningCargo, type PlanningBatchPreview } from './planningApi' import { batchContext } from './batch.test-support' import type { ScheduleEvent } from './schedule' import { getWorkDetail, type WorkAction } from './workApi' import { workDetail, workItem } from './work.test-support' const routing = vi.hoisted(() => ({ query: { date: '2026-10-19' } as Record, push: vi.fn(), replace: vi.fn(), denied: new Set() })) vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !routing.denied.has(permission) }) })) vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() })) vi.mock('./tripCalendar', async original => ({ ...await original(), listTripCalendar: vi.fn() })) vi.mock('./planningApi', async original => ({ ...await original(), listPlanningCargo: vi.fn(), previewPlanningBatch: vi.fn(), resolvePlanningSource: vi.fn(), createSourceAppointment: vi.fn() })) vi.mock('./workApi', async original => ({ ...await original(), getWorkDetail: vi.fn() })) enableAutoUnmount(afterEach) afterEach(() => { vi.unstubAllGlobals() }) const warehouse: WarehouseArrival = { sourceContainerBookingId: '9007199254740999', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 3, planCount: 0, canEdit: true } const event: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true } const cargo: PlanningCargo = { sourceKey: 'ORDER:9007199254740998', orderId: '9007199254740998', customerId: '9007199254740997', customerName: '预约长客户名称'.repeat(12), inboundCode: 'IN-TEST', containerBookingId: 'container-1', containerNo: 'TEST-CONT', originWarehouseId: 'origin-1', originWarehouseCode: 'US-W', originWarehouseName: '美国长仓库名称'.repeat(12), 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: cargo.originWarehouseName, destinationWarehouseCode: 'LAX9', destinationTimezone: 'America/Los_Angeles', sources: [source], missingFields: [] } beforeEach(() => { vi.clearAllMocks(); routing.denied.clear(); routing.query = { date: '2026-10-19' } vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }) vi.mocked(getCalendarSources).mockResolvedValue({ containers: [], emptyReturns: [], emptyReturnsReadable: true }) vi.mocked(getWarehouseArrivals).mockResolvedValue([]) vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] }) vi.mocked(saveWarehouseArrival).mockResolvedValue({}) vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], total: 1, page: 1, size: 20, pages: 1 }) 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(getWorkDetail).mockResolvedValue(workDetail()) }) async function open() { const wrapper = mount(AppointmentCalendarView, { global: { plugins: [ElementPlus], stubs: { AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', template: '
' }, } } }) await flushPromises() return wrapper } async function create(wrapper: Awaited>) { wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('create'); await vi.dynamicImportSettled(); await flushPromises() const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '新增FBA送仓预约')! const content = creationBody() await content.get('input[type="checkbox"]').setValue(true) await content.findAll('button').find(button => button.text().startsWith('下一步'))!.trigger('click'); await flushPromises() return dialog } function creationBody() { return new DOMWrapper(Array.from(document.querySelectorAll('.el-dialog')).find(item => item.textContent?.includes('新增FBA送仓预约'))!) } it('keeps the arrival context and typed local time across native fullscreen and submits the original version', async () => { const wrapper = await open() wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises() const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')! expect(dialog.props('width')).toBe('520px') expect(dialog.get('[aria-label="到仓业务信息"]').text()).toContain('TEST-CONT 计划到仓') expect(dialog.text()).not.toContain(warehouse.sourceContainerBookingId) const picker = dialog.getComponent({ name: 'ElDatePicker' }) expect(picker.props()).toMatchObject({ type: 'datetime', valueFormat: 'YYYY-MM-DDTHH:mm', format: 'YYYY-MM-DD HH:mm' }) expect(dialog.getComponent({ name: 'ElForm' }).props('labelWidth')).toBe('150px') picker.vm.$emit('update:modelValue', '2026-10-22T09:30'); await flushPromises() await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises() expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true) expect(picker.props('modelValue')).toBe('2026-10-22T09:30') await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises() expect(saveWarehouseArrival).toHaveBeenCalledExactlyOnceWith(warehouse.sourceContainerBookingId, { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 3 }, expect.any(String)) }) it('locks the real arrival input after an unknown outcome and reuses its immutable request', async () => { vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('connection lost')) const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises() const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')! dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-10-22T09:30'); await flushPromises() await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises() expect(dialog.get('input').attributes('disabled')).toBeDefined() expect(dialog.text()).toContain('上次提交结果尚未确认') expect(dialog.get('.el-dialog__footer .el-button--primary').text()).toBe('重试确认保存结果') await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises() expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0]) }) it('clears the Element arrival picker without saving an empty required time', async () => { const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises() const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')! const picker = dialog.getComponent({ name: 'ElDatePicker' }) expect(dialog.find('input[type="datetime-local"]').exists()).toBe(false) picker.vm.$emit('update:modelValue', '2026-10-22T09:30'); await flushPromises() expect(dialog.props('confirmDisabled')).toBe(false) picker.vm.$emit('update:modelValue', null); await flushPromises() expect(dialog.props('confirmDisabled')).toBe(true) expect(saveWarehouseArrival).not.toHaveBeenCalled() }) it('keeps complete long cargo names accessible in the selectable table', async () => { const wrapper = await open() wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('create'); await vi.dynamicImportSettled(); await flushPromises() const names = wrapper.findAllComponents(LineClampText).map(item => item.props('content')) expect(names).toContain(cargo.customerName); expect(names).toContain(cargo.originWarehouseName) expect(wrapper.getComponent({ name: 'DataTable' }).props('maxHeight')).toBe('') expect(wrapper.text()).not.toMatch(/900719925474099[7-9]/) expect(resolvePlanningSource).not.toHaveBeenCalled() }) it('preserves real FBA fields and entered values when expanding the create dialog', async () => { const wrapper = await open(); const dialog = await create(wrapper) const fields = dialog.getComponent({ name: 'FbaAppointmentFields' }) expect(fields.props('continuous')).toBe(true) const date = fields.findAllComponents({ name: 'ElDatePicker' })[0]! date.vm.$emit('update:modelValue', '2026-10-25'); await flushPromises() await creationBody().get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises() expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true) expect(date.props('modelValue')).toBe('2026-10-25') await creationBody().findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises() expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }], details: expect.objectContaining({ plannedDate: '2026-10-25', plannedAt: null, appointmentStatus: 'PENDING' }), idempotencyKey: expect.any(String) })) expect(creationBody().get('[aria-label="预约保存回执"]').findAll('input, textarea, select')).toHaveLength(0) expect(creationBody().text()).toContain('AP-001') }) it('shows every saved server field as readonly text including confirmed offsets and long remarks', async () => { const saved = { ...batchContext().appointment, plannedDate: '2026-10-26', plannedAt: '2026-10-26T08:00:00', plannedEndAt: '2026-10-26T18:00:00', timezone: 'America/Los_Angeles', appointmentStatus: 'SUCCESS', confirmationNo: 'CONFIRM-001', appointmentAt: '2026-11-01T01:30:00', appointmentEndAt: '2026-11-01T01:45:00', appointmentStartUtc: '2026-11-01T08:30:00Z', appointmentEndUtc: '2026-11-01T09:45:00Z', appointmentRemark: '完整预约备注'.repeat(30) } vi.mocked(createSourceAppointment).mockResolvedValue(saved) const wrapper = await open(); await create(wrapper) await creationBody().findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises() const receipt = creationBody().get('[aria-label="预约保存回执"]') for (const text of ['AP-001', saved.plannedDate, saved.plannedAt, saved.plannedEndAt, saved.timezone, '已取得对方正式确认', saved.confirmationNo, saved.appointmentAt, saved.appointmentEndAt, '-07:00', '-08:00', saved.appointmentRemark]) expect(receipt.text()).toContain(text) expect(receipt.findAll('input, textarea, select')).toHaveLength(0) expect(receipt.text()).not.toMatch(/item-1|customer-a|FBA_APPOINTMENT:1/) }) it('does not open or request creation data without its original write permission', async () => { routing.denied.add('platform:overseas-warehouse:inbound-order:fba-appointment') const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('create'); await flushPromises() expect(listPlanningCargo).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled() expect(wrapper.findAllComponents(AppDialog).some(dialog => dialog.props('title') === '新增FBA送仓预约')).toBe(false) }) it('uses the same real continuous form through the original work detail deep link', async () => { routing.query.workItem = 'work-delivery-1' const action: WorkAction = { code: 'SAVE_PLAN', label: '安排预约', targetWorkItemKey: null, target: { kind: 'FBA_APPOINTMENT', appointmentId: null, batchKey: null, sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey }] } } vi.mocked(getWorkDetail).mockResolvedValue(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) })) const wrapper = await open(); await vi.dynamicImportSettled(); await flushPromises() const content = new DOMWrapper(Array.from(document.querySelectorAll('.el-dialog')).find(item => item.textContent?.includes('原业务预约办理'))!) await content.get('.primary-next-action').trigger('click'); await flushPromises() expect(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('continuous')).toBe(true) expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith(action.target.kind === 'FBA_APPOINTMENT' ? action.target.sources : []) await content.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises() expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey }], details: expect.objectContaining({ plannedAt: null, appointmentStatus: 'PENDING' }) })) expect(content.text()).toContain('本次保存已完成') })