249 lines
19 KiB
TypeScript
249 lines
19 KiB
TypeScript
import { flushPromises, mount } from '@vue/test-utils'
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||
import AppointmentCalendarView from './AppointmentCalendarView.vue'
|
||
import { createFbaAppointment, listFbaAppointmentCalendar, listInboundOrders } from '@/modules/overseas-warehouse'
|
||
import { listArrivalCalendar, listPickupCalendar } from './api'
|
||
import { listTripCalendar } from './tripCalendar'
|
||
vi.mock('./tripCalendar', async (original) => ({...(await original<typeof import('./tripCalendar')>()),listTripCalendar:vi.fn()}))
|
||
import { resolvePlanningSource, saveFollowup, listUnscheduledAppointments } from './planningApi'
|
||
const routing = vi.hoisted(() => ({ query: {} as Record<string,string>, canSourceRead: true, canPickup: true, canTrips: true }))
|
||
vi.mock('./planningApi', () => ({ resolvePlanningSource: vi.fn(), listPlanningCargo: vi.fn(), getFollowups: vi.fn(), saveFollowup: vi.fn(), listUnscheduledAppointments: vi.fn() }))
|
||
import { listPlatformWarehouses } from '@/modules/warehouses'
|
||
vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn() }))
|
||
|
||
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => ({ push: vi.fn() }) }))
|
||
|
||
vi.mock('./api', () => ({ listArrivalCalendar: vi.fn(), listPickupCalendar: vi.fn(), deleteAppointment: vi.fn() }))
|
||
|
||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => (permission !== 'source-read' || routing.canSourceRead) && (permission !== 'platform:firstleg:terminal-drayage:list' || routing.canPickup) && (permission !== 'platform:fleet:trip:list' || routing.canTrips) }) }))
|
||
vi.mock('@/modules/overseas-warehouse', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/modules/overseas-warehouse')>()),
|
||
createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(), listFbaAppointmentCalendar: vi.fn(),
|
||
listInboundOrders: vi.fn(), updateFbaAppointment: vi.fn(),
|
||
overseasWarehousePermissions: { inboundOrderAppointment: 'write', lastMileOrderList: 'source-read' },
|
||
}))
|
||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||
|
||
const candidates = [
|
||
{ lineId: '9007199254740993', customerId: 'A', overseasWarehouseId: '100', fbaWarehouseCode: 'ONT8', planId: '1', planNo: 'IN-A', orderId: '10' },
|
||
{ lineId: '9007199254740994', customerId: 'B', overseasWarehouseId: '100', fbaWarehouseCode: 'ONT8', planId: '2', planNo: 'IN-B', orderId: '20' },
|
||
]
|
||
function render() {
|
||
return mount(AppointmentCalendarView, { global: { directives: { loading: {} }, stubs: {
|
||
AdminPage: { template: '<main><slot /></main>' },
|
||
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', props: ['events', 'incomplete'], template: '<button data-create @click="$emit(\'create\', \'2026-10-09\')">新增预约</button>' },
|
||
AppDialog: { props: ['modelValue'], template: '<div v-if="modelValue"><slot /><button data-save @click="$emit(\'confirm\')">保存</button></div>' },
|
||
ElForm: { template: '<form><slot /></form>' },
|
||
ElFormItem: { template: '<label><slot /></label>' },
|
||
ElInput: { props: ['modelValue', 'placeholder'], template: '<input :placeholder="placeholder" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
|
||
ElDatePicker: { props: ['modelValue'], template: '<input data-date :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
|
||
ElButton: { template: '<button><slot /></button>' },
|
||
ElSelect: { name: 'ElSelect', emits: ['update:modelValue', 'change'], props: ['modelValue', 'remoteMethod'], template: '<select :value="modelValue" @change="$emit(\'update:modelValue\', $event.target.value); $emit(\'change\', $event.target.value)"><option value="">请选择</option><slot /></select>' },
|
||
ElOption: { props: ['value', 'label'], template: '<option :value="value">{{ label }}</option>' },
|
||
DataTable: { name: 'DataTable', props: ['data'], template: '<div><slot name="toolbar-actions" /><button data-select @click="$emit(\'selection-change\', data)">选择货物</button></div>' },
|
||
LoadErrorState: true, ApiErrorMeta: true, AppointmentFollowupDialog: true,
|
||
} } })
|
||
}
|
||
|
||
describe('appointment calendar data integration', () => {
|
||
beforeEach(() => {
|
||
vi.resetAllMocks()
|
||
routing.query = {}; routing.canSourceRead = true; routing.canPickup = true; routing.canTrips = true
|
||
vi.mocked(listPlatformWarehouses).mockResolvedValue({ records: ['ONT8', 'LAX9'].map(code => ({ id: code, tenantId: 0, warehouseCode: code, warehouseName: code, warehouseType: 'FBA' as const, status: 'ACTIVE' as const, internalCode: null, countryCode: 'US', timezone: 'America/Los_Angeles', contactName: null, contactPhone: null, contactEmail: null, remark: null })), page: 1, size: 50, pages: 1, total: 2 })
|
||
vi.mocked(listFbaAppointmentCalendar).mockResolvedValue([])
|
||
vi.mocked(listUnscheduledAppointments).mockResolvedValue({records: [], total: 0})
|
||
vi.mocked(listArrivalCalendar).mockResolvedValue([])
|
||
vi.mocked(listTripCalendar).mockResolvedValue({records:[],unresolvedRecords:[]})
|
||
vi.mocked(listPickupCalendar).mockResolvedValue({records: [], truncated: false, unclassifiedCount: 0, scopeBasis: 'US_DESTINATION_WAREHOUSE', timeBasis: 'SOURCE_LOCAL_UNSPECIFIED'})
|
||
vi.mocked(listInboundOrders).mockResolvedValue({ records: candidates, total: 2, page: 1, size: 10, pages: 1 })
|
||
vi.mocked(createFbaAppointment).mockResolvedValue({ version: 0, id: '1', appointmentNo: 'APT-AB', plannedDate: '2026-10-09', appointmentAt: null, originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] })
|
||
})
|
||
|
||
it('shows the original pickup time once without claiming a port time zone and retains partial-source warnings', async () => {
|
||
vi.mocked(listPickupCalendar).mockResolvedValueOnce({records: [{containerBookingId: '123',bookingNo: 'CB-TEST',containerNo: 'TEST-CONT',destinationPortName: 'Port',destinationWarehouseCode: 'US-W',fleetSupplierName: 'Fleet',pickupAppointmentAt: '2026-10-09T11:00:00',actualPickupAt: null}],truncated: false,unclassifiedCount: 1,scopeBasis: 'US_DESTINATION_WAREHOUSE',timeBasis: 'SOURCE_LOCAL_UNSPECIFIED'})
|
||
const wrapper=render(); await flushPromises()
|
||
const panel=wrapper.findComponent({name:'AppointmentCalendarPanel'})
|
||
expect(panel.props('events')).toEqual([expect.objectContaining({type:'pickup',date:'2026-10-09',time:'11:00',sourcePath:'/container-loading/overseas/123'})])
|
||
expect(panel.props('incomplete')).toBe(true)
|
||
expect(wrapper.text()).toContain('目的仓国家待核实')
|
||
expect(listPickupCalendar).toHaveBeenCalledTimes(1)
|
||
})
|
||
it('does not request pickup records without pickup list permission and discloses the omitted source', async () => {
|
||
routing.canPickup=false
|
||
const wrapper=render(); await flushPromises()
|
||
expect(listPickupCalendar).not.toHaveBeenCalled()
|
||
expect(wrapper.text()).toContain('无提柜查看权限')
|
||
expect(wrapper.findComponent({name:'AppointmentCalendarPanel'}).props('incomplete')).toBe(true)
|
||
})
|
||
it('keeps successful arrival data visible when the pickup source fails', async () => {
|
||
vi.mocked(listPickupCalendar).mockRejectedValueOnce(new Error('pickup unavailable'))
|
||
vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{containerBookingId:'1',bookingNo:'CB-1',arrivalAt:'2026-10-09T09:00:00',arrivalType:'ESTIMATED'}])
|
||
const wrapper=render(); await flushPromises()
|
||
const panel=wrapper.findComponent({name:'AppointmentCalendarPanel'})
|
||
expect(panel.props('events')).toEqual([expect.objectContaining({type:'arrival',key:'arrival-1'})])
|
||
expect(panel.props('incomplete')).toBe(true)
|
||
})
|
||
it('omits trip requests without trip list permission and keeps other sources usable', async () => {
|
||
routing.canTrips=false
|
||
const wrapper=render();await flushPromises()
|
||
expect(listTripCalendar).not.toHaveBeenCalled();expect(listArrivalCalendar).toHaveBeenCalledTimes(1)
|
||
expect(wrapper.text()).toContain('无车次查看权限')
|
||
})
|
||
it('keeps unresolved trip origins outside daily slots and clearly labels their UTC source time', async () => {
|
||
vi.mocked(listTripCalendar).mockResolvedValueOnce({records:[],unresolvedRecords:[{sourceType:'TRIP_PLAN',tripNo:'TR-UNKNOWN',status:'PLANNED',originWarehouseId:null,originWarehouseCode:null,originWarehouseName:null,originCountryCode:null,originTimezone:null,originWarehouseStatus:null,plannedDepartureAt:'2026-10-10T12:00:00Z',expectedDriverEndAt:null,departedAt:null,completedAt:null,stops:[],incompleteReasons:['ORIGIN_WAREHOUSE_UNKNOWN']}]})
|
||
const wrapper=render();await flushPromises()
|
||
expect(wrapper.findComponent({name:'AppointmentCalendarPanel'}).props('events')).toEqual([])
|
||
expect(wrapper.text()).toContain('待核实地点的车次(1)');expect(wrapper.text()).toContain('原计划时间保留 UTC')
|
||
expect(listTripCalendar).toHaveBeenCalledTimes(1)
|
||
})
|
||
it('creates one shared appointment for two customers without sending a fabricated header customer', async () => {
|
||
const wrapper = render()
|
||
await flushPromises()
|
||
expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(1)
|
||
expect(listArrivalCalendar).toHaveBeenCalledTimes(1)
|
||
expect(listInboundOrders).not.toHaveBeenCalled()
|
||
await wrapper.get('[data-create]').trigger('click')
|
||
await flushPromises()
|
||
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8')
|
||
await flushPromises()
|
||
await wrapper.get('[data-select]').trigger('click')
|
||
expect(wrapper.find('input[placeholder="填写仓库提供的预约编号"]').exists()).toBe(false)
|
||
await wrapper.get('[data-save]').trigger('click')
|
||
await flushPromises()
|
||
expect(createFbaAppointment).toHaveBeenCalledTimes(1)
|
||
expect(createFbaAppointment).toHaveBeenCalledWith(expect.objectContaining({
|
||
originWarehouseId: '100', fbaWarehouseCode: 'ONT8', lineIds: ['9007199254740993', '9007199254740994'], idempotencyKey: expect.any(String), details: expect.objectContaining({plannedDate: '2026-10-09', appointmentStatus: 'PENDING', appointmentAt: null, confirmationNo: null}),
|
||
}))
|
||
expect(vi.mocked(createFbaAppointment).mock.calls[0]![0]).not.toHaveProperty('customerId')
|
||
expect(listInboundOrders).toHaveBeenCalledTimes(1)
|
||
expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(2)
|
||
})
|
||
it('only searches active FBA warehouses and loads cargo automatically after selection', async () => {
|
||
const wrapper = render()
|
||
await wrapper.get('[data-create]').trigger('click')
|
||
await flushPromises()
|
||
expect(listPlatformWarehouses).toHaveBeenCalledWith(expect.objectContaining({ warehouseType: 'FBA', status: 'ACTIVE', size: 50 }))
|
||
expect(wrapper.find('input[placeholder="例如 ONT8"]').exists()).toBe(false)
|
||
expect(wrapper.text()).not.toContain('查询可预约货物')
|
||
expect(wrapper.find('details').attributes('open')).toBeUndefined()
|
||
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8')
|
||
await flushPromises()
|
||
expect(listInboundOrders).toHaveBeenLastCalledWith({ page: 1, size: 10, fbaWarehouseCode: 'ONT8' })
|
||
})
|
||
it('keeps a pending unknown save retry on the same immutable command', async () => {
|
||
vi.mocked(createFbaAppointment).mockRejectedValueOnce(new Error('response lost'))
|
||
const wrapper = render(); await flushPromises()
|
||
await wrapper.get('[data-create]').trigger('click'); await flushPromises()
|
||
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8'); await flushPromises()
|
||
await wrapper.get('[data-select]').trigger('click')
|
||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||
const first = vi.mocked(createFbaAppointment).mock.calls[0]![0]
|
||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||
expect(vi.mocked(createFbaAppointment).mock.calls[1]![0]).toEqual(first)
|
||
expect(first.details.appointmentAt).toBeNull()
|
||
expect(first.details.confirmationNo).toBeNull()
|
||
expect(first.idempotencyKey).toBeTruthy()
|
||
})
|
||
it('keeps undated arrangements outside calendar time slots', async () => {
|
||
vi.mocked(listUnscheduledAppointments).mockResolvedValueOnce({ records: [{ id:'1', version:0, appointmentNo:'AP-UNSCHEDULED', originWarehouseId:'100', appointmentStatus:'PENDING', selectedCount:2, fbaWarehouseCode:'ONT8', items:[] }], total:1 })
|
||
const wrapper=render(); await flushPromises()
|
||
expect(wrapper.text()).toContain('未定时安排(1)')
|
||
expect(wrapper.findComponent({name:'AppointmentCalendarPanel'}).props('events')).toEqual([])
|
||
expect(wrapper.findComponent({name:'DataTable'}).props('data')[0]).not.toHaveProperty('appointmentAt')
|
||
})
|
||
|
||
it('changing or clearing the warehouse clears prior cargo selection and blocks saving it', async () => {
|
||
const wrapper = render()
|
||
await wrapper.get('[data-create]').trigger('click')
|
||
await flushPromises()
|
||
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8')
|
||
await flushPromises()
|
||
await wrapper.get('[data-select]').trigger('click')
|
||
expect(wrapper.find('input[placeholder="填写仓库提供的预约编号"]').exists()).toBe(false)
|
||
await wrapper.get('select[aria-label="预约仓库"]').setValue('')
|
||
await wrapper.get('[data-save]').trigger('click')
|
||
expect(createFbaAppointment).not.toHaveBeenCalled()
|
||
expect(listInboundOrders).toHaveBeenCalledTimes(1)
|
||
expect(wrapper.find('[data-select]').exists()).toBe(false)
|
||
})
|
||
|
||
it('discards cargo responses from the previously selected warehouse', async () => {
|
||
let resolveOld!: (value: Awaited<ReturnType<typeof listInboundOrders>>) => void
|
||
vi.mocked(listInboundOrders).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
|
||
const wrapper = render()
|
||
await wrapper.get('[data-create]').trigger('click')
|
||
await flushPromises()
|
||
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8')
|
||
vi.mocked(listInboundOrders).mockResolvedValueOnce({ records: [], total: 0, page: 1, size: 10, pages: 0 })
|
||
await wrapper.get('select[aria-label="预约仓库"]').setValue('LAX9')
|
||
await flushPromises()
|
||
resolveOld({ records: candidates, total: 2, page: 1, size: 10, pages: 1 })
|
||
await flushPromises()
|
||
expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual([])
|
||
})
|
||
|
||
it('searches by keyword and exposes warehouse failures without accepting free text', async () => {
|
||
const wrapper = render()
|
||
await wrapper.get('[data-create]').trigger('click')
|
||
await flushPromises()
|
||
const select = wrapper.findAllComponents({ name: 'ElSelect' }).find(item => item.props('remoteMethod'))!
|
||
vi.mocked(listPlatformWarehouses).mockRejectedValueOnce(new Error('仓库暂不可用'))
|
||
await select.props('remoteMethod')(' LAX ')
|
||
await flushPromises()
|
||
expect(listPlatformWarehouses).toHaveBeenLastCalledWith(expect.objectContaining({ keyword: 'LAX', warehouseType: 'FBA', status: 'ACTIVE' }))
|
||
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('title')).toBe('仓库加载失败')
|
||
expect(wrapper.findAll('select[aria-label="预约仓库"] option')).toHaveLength(1)
|
||
expect(listInboundOrders).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('keeps available arrivals and marks the result incomplete when appointments fail', async () => {
|
||
vi.mocked(listFbaAppointmentCalendar).mockRejectedValueOnce(new Error('预约暂不可用'))
|
||
vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{ containerBookingId: '1', bookingNo: 'C-1', arrivalAt: '2026-10-09T09:00:00', arrivalType: 'ESTIMATED' }])
|
||
const wrapper = render()
|
||
await flushPromises()
|
||
const calendar = wrapper.findComponent({ name: 'AppointmentCalendarPanel' })
|
||
expect(calendar.props('incomplete')).toBe(true)
|
||
expect(calendar.props('events')).toHaveLength(1)
|
||
expect(calendar.props('events')[0].type).toBe('arrival')
|
||
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('title')).toBe('送仓预约加载失败')
|
||
})
|
||
|
||
it('keeps delivery appointments when the arrival source fails', async () => {
|
||
vi.mocked(listArrivalCalendar).mockRejectedValueOnce(new Error('到港暂不可用'))
|
||
vi.mocked(listFbaAppointmentCalendar).mockResolvedValueOnce([{ version: 0, id: '1', appointmentNo: 'APT-AB', plannedDate: '2026-10-09', appointmentAt: null, originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] }])
|
||
const wrapper = render()
|
||
await flushPromises()
|
||
const calendar = wrapper.findComponent({ name: 'AppointmentCalendarPanel' })
|
||
expect(calendar.props('incomplete')).toBe(true)
|
||
expect(calendar.props('events')[0].key).toBe('APT-AB')
|
||
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('title')).toBe('船舶到港加载失败')
|
||
})
|
||
|
||
it('resolves an external booking deep link through the backend before opening followup', async () => {
|
||
routing.query = { sourceKey: 'BOOKING:B-1', customerId: 'A', destinationKey: 'FBA:ONT8' }
|
||
vi.mocked(resolvePlanningSource).mockResolvedValueOnce({ source: { sourceKey: 'BOOKING:B-1', customerId: 'A', orderId: null, inboundCode: null, bookingNo: 'B-1', containerNo: null, originWarehouseId: '100', originWarehouseName: '美国仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: null, sourceVersion: 'BOOKING:1', canFollowup: true, blockReasons: [] }, coverage: { status: 'SOURCE_NOT_MATERIALIZED', appointments: [], missingFields: ['REAL_CARGO_MATERIALS'], changeReviewRequired: false } })
|
||
const wrapper = render(); await flushPromises()
|
||
expect(resolvePlanningSource).toHaveBeenCalledWith(routing.query)
|
||
const dialog = wrapper.findComponent({ name: 'AppointmentFollowupDialog' })
|
||
expect(dialog.props('source').bookingNo).toBe('B-1')
|
||
expect(dialog.props('source').orderId).toBeNull()
|
||
expect(dialog.props('coverage').status).toBe('SOURCE_NOT_MATERIALIZED')
|
||
expect(createFbaAppointment).not.toHaveBeenCalled(); expect(saveFollowup).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('does not trust stale destination query data or open followup after a source error', async () => {
|
||
routing.query = { sourceKey: 'BOOKING:B-1', customerId: 'A', destinationKey: 'FBA:OLD' }
|
||
vi.mocked(resolvePlanningSource).mockRejectedValueOnce(new Error('目的地已变化'))
|
||
const wrapper = render(); await flushPromises()
|
||
expect(wrapper.findComponent({ name: 'AppointmentFollowupDialog' }).exists()).toBe(false)
|
||
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('title')).toBe('预约货源核对失败')
|
||
})
|
||
|
||
it('does not request deep-link cargo without the underlying source permission', async () => {
|
||
routing.query = { sourceKey: 'BOOKING:B-1', customerId: 'A', destinationKey: 'FBA:ONT8' }; routing.canSourceRead = false
|
||
const wrapper = render(); await flushPromises()
|
||
expect(resolvePlanningSource).not.toHaveBeenCalled()
|
||
expect(wrapper.findComponent({ name: 'AppointmentFollowupDialog' }).exists()).toBe(false)
|
||
})
|
||
|
||
})
|