Files
admin-web/src/modules/appointment-calendar/AppointmentCalendarView.test.ts
T

254 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, AppointmentCargoPanel: { name: 'AppointmentCargoPanel', props: ['externalSource'], template: '<div>合批工作区</div>' },
} } })
}
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()
expect(dialog.props('canArrange')).toBe(true)
dialog.vm.$emit('arrange');await flushPromises()
expect(wrapper.findComponent({name:'AppointmentFollowupDialog'}).exists()).toBe(false)
expect(wrapper.findComponent({name:'AppointmentCargoPanel'}).props('externalSource')).toMatchObject({sourceKey:'BOOKING:B-1',bookingNo:'B-1',orderId:null})
})
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)
})
})