2026-10-20 23:34:11 +08:00
import { DatePickerStub , TimePickerStub } from './temporal-picker.test-support'
2026-10-09 11:01:41 +08:00
import { flushPromises , mount } from '@vue/test-utils'
import { beforeEach , describe , expect , it , vi } from 'vitest'
2026-10-19 12:20:13 +08:00
import { onBeforeRouteLeave } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { ApiResponseError } from '@/shared/api/http'
2026-10-09 11:01:41 +08:00
import AppointmentCalendarView from './AppointmentCalendarView.vue'
2026-10-19 12:20:13 +08:00
import { getCalendarSources , getWarehouseArrivals , saveWarehouseArrival , type WarehouseArrival } from './scheduleApi'
2026-10-21 11:38:19 +08:00
import { listTripCalendar , type TripCalendarEntry } from './tripCalendar'
2026-10-19 12:20:13 +08:00
import type { ScheduleEvent } from './schedule'
const routing = vi . hoisted (() => ({ query : { date : '2026-10-19' } as Record < string , string >, push : vi.fn (), replace : vi.fn (), denied : new Set < string >() }))
2026-10-15 17:11:03 +08:00
vi . mock ( 'vue-router' , () => ({ useRoute : () => routing , useRouter : () => routing , onBeforeRouteLeave : vi.fn (), onBeforeRouteUpdate : vi.fn () }))
2026-10-19 12:20:13 +08:00
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 < typeof import ( './tripCalendar' ) >(), listTripCalendar : vi.fn () }))
vi . mock ( 'element-plus' , () => ({ ElMessage : { success : vi.fn () }, ElMessageBox : { confirm : vi.fn () } }))
2026-10-22 23:10:09 +08:00
const warehouse : WarehouseArrival = { sourceContainerBookingId : '1' , containerNo : 'TEST-CONT' , warehouseName : '美国仓' , warehouseCode : 'US-W' , plannedAt : null , plannedDate : '2026-10-21' , isDefault : true , actualAt : null , version : 0 , planCount : 0 , canEdit : true }
2026-10-19 12:20:13 +08:00
const row : ScheduleEvent = { key : 'warehouse-1' , type : 'warehouse' , date : '2026-10-21' , time : '' , title : 'TEST-CONT 计划到仓' , fields : [], warehouse , canEdit : true }
2026-10-21 11:38:19 +08:00
function dispatchEntry ( overrides : Partial < TripCalendarEntry > = {}) : TripCalendarEntry {
2026-10-22 18:03:45 +08:00
return { sourceType : 'DISPATCH' , dispatchNo : 'PC-CALENDAR-001' , containerNumbers : [ 'TSTU1234567' , 'TSTU7654321' ], cabinetNumbers : [], status : 'PLANNED' , originWarehouseId : '1' , originWarehouseCode : 'LAX' , originWarehouseName : '洛杉矶仓' , originCountryCode : 'US' , originTimezone : 'America/Los_Angeles' , originWarehouseStatus : 'ACTIVE' , plannedDepartureAt : '2026-10-22T16:00:00Z' , expectedDriverEndAt : null , departedAt : null , completedAt : null , stops : [{ name : '洛杉矶仓' }, { name : 'ONT8' }], incompleteReasons : [], ... overrides }
2026-10-21 11:38:19 +08:00
}
2026-10-19 12:20:13 +08:00
export function renderSchedule() {
return mount ( AppointmentCalendarView , { global : { stubs : {
2026-10-20 23:34:11 +08:00
ElDatePicker : DatePickerStub , ElTimePicker : TimePickerStub ,
2026-10-09 11:01:41 +08:00
AdminPage : { template : '<main><slot /></main>' },
2026-10-19 17:02:27 +08:00
AppointmentCalendarPanel : { name : 'AppointmentCalendarPanel' , props : [ 'events' , 'loading' , 'incomplete' , 'initialDate' , 'initialMode' , 'canCreate' ], template : '<section data-calendar />' },
2026-10-19 12:20:13 +08:00
AppDialog : { name : 'AppDialog' , props : [ 'modelValue' , 'beforeClose' , 'confirmDisabled' , 'confirmLoading' ], emits : [ 'confirm' ], template : '<section v-if="modelValue" data-dialog><slot /><button data-save :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button><button data-close @click="beforeClose(() => {})">关闭</button></section>' },
WorkDetailPanel : true , SourceAppointmentPanel : true , ApiErrorMeta : true ,
2026-10-20 10:11:31 +08:00
ElForm : { template : '<form><slot /></form>' },
ElFormItem : { props : [ 'label' ], template : '<label>{{ label }}<slot /></label>' },
ElInput : { props : [ 'modelValue' , 'disabled' ], emits : [ 'update:modelValue' ], template : '<input :value="modelValue" :disabled="disabled" @input="$emit(\'update:modelValue\', $event.target.value)">' },
2026-10-19 17:02:27 +08:00
AppointmentCreatePanel : { name : 'AppointmentCreatePanel' , props : [ 'canWrite' , 'canLeave' ], emits : [ 'created' ], template : '<section data-create-panel />' },
2026-10-19 12:20:13 +08:00
LoadErrorState : { props : [ 'title' , 'message' ], emits : [ 'retry' ], template : '<section role="alert">{{ title }} {{ message }}<slot name="details" /><button data-retry @click="$emit(\'retry\')">重试</button></section>' },
2026-10-09 11:01:41 +08:00
} } })
}
2026-10-19 12:20:13 +08:00
beforeEach (() => {
2026-10-22 23:10:09 +08:00
vi . resetAllMocks (); routing . query = { date : '2026-10-19' }; routing . denied . clear ()
2026-10-19 12:20:13 +08:00
vi . mocked ( getCalendarSources ). mockResolvedValue ({ containers : [{ containerBookingId : '1' , cabinetNo : 'D0001' , containerNo : 'TEST-CONT' , vesselName : null , voyageNo : null , destinationPortName : null , destinationWarehouseCode : null , arrivalAt : '2026-10-19T10:00:00' , arrivalType : 'ACTUAL' }], emptyReturns : [], emptyReturnsReadable : true })
vi . mocked ( getWarehouseArrivals ). mockResolvedValue ([ warehouse ])
vi . mocked ( listTripCalendar ). mockResolvedValue ({ records : [], unresolvedRecords : [] })
vi . mocked ( saveWarehouseArrival ). mockResolvedValue ({})
})
describe ( 'simplified appointment entry' , () => {
2026-10-19 17:02:27 +08:00
it ( 'opens creation lazily with the original read and write permissions and refreshes after creation' , async () => {
const wrapper = renderSchedule (); await flushPromises ()
expect ( wrapper . find ( '[data-create-panel]' ). exists ()). toBe ( false )
const calendar = wrapper . getComponent ({ name : 'AppointmentCalendarPanel' })
expect ( calendar . props ( 'canCreate' )). toBe ( true )
calendar . vm . $emit ( 'create' ); await flushPromises ()
const panel = wrapper . getComponent ({ name : 'AppointmentCreatePanel' })
expect ( panel . props ( 'canWrite' )). toBe ( true )
vi . mocked ( getCalendarSources ). mockClear ()
panel . vm . $emit ( 'created' ); await flushPromises ()
expect ( getCalendarSources ). toHaveBeenCalledTimes ( 1 )
await wrapper . get ( '[data-close]' ). trigger ( 'click' ); await flushPromises ()
expect ( wrapper . find ( '[data-create-panel]' ). exists ()). toBe ( false )
wrapper . unmount ()
})
it . each ([ 'platform:overseas-warehouse:inbound-order:fba-appointment' , 'platform:overseas-warehouse:last-mile-order:list' ])( 'does not open creation without %s' , async permission => {
routing . denied . add ( permission )
const wrapper = renderSchedule (); await flushPromises ()
const calendar = wrapper . getComponent ({ name : 'AppointmentCalendarPanel' })
expect ( calendar . props ( 'canCreate' )). toBe ( false )
calendar . vm . $emit ( 'create' ); await flushPromises ()
expect ( wrapper . find ( '[data-create-panel]' ). exists ()). toBe ( false )
wrapper . unmount ()
})
2026-10-19 12:20:13 +08:00
it ( 'keeps unsaved input when the operator declines to leave' , async () => {
vi . mocked ( ElMessageBox . confirm ). mockRejectedValueOnce ( 'cancel' )
const wrapper = renderSchedule (); await flushPromises ()
wrapper . getComponent ({ name : 'AppointmentCalendarPanel' }). vm . $emit ( 'edit' , row ); await flushPromises ()
await wrapper . get ( 'input' ). setValue ( '2026-10-22T09:30' )
await wrapper . get ( '[data-close]' ). trigger ( 'click' ); await flushPromises ()
expect ( ElMessageBox . confirm ). toHaveBeenCalled ()
expect ( wrapper . find ( '[data-dialog]' ). exists ()). toBe ( true )
expect (( wrapper . get ( 'input' ). element as HTMLInputElement ). value ). toBe ( '2026-10-22T09:30' )
2026-10-16 15:41:56 +08:00
wrapper . unmount ()
})
2026-10-19 12:20:13 +08:00
it ( 'loads original sources and real trips without loading work summaries or generated work items' , async () => {
const wrapper = renderSchedule (); await flushPromises ()
2026-10-19 17:02:27 +08:00
expect ( getWarehouseArrivals ). toHaveBeenCalledWith ({ from : '2026-09-28' , to : '2026-11-02' }, [ '1' ], expect . any ( AbortSignal ))
2026-10-19 12:20:13 +08:00
const panel = wrapper . getComponent ({ name : 'AppointmentCalendarPanel' })
expect ( panel . props ( 'events' ). map (( event : ScheduleEvent ) => [ event . type , event . date ])). toEqual ([[ 'arrival' , '2026-10-19' ], [ 'warehouse' , '2026-10-21' ]])
expect ( wrapper . text ()). not . toMatch ( /全局待办|跟进|复核/ )
2026-10-12 11:45:00 +08:00
wrapper . unmount ()
})
2026-10-19 12:20:13 +08:00
it ( 'keeps available arrivals while reporting a failed warehouse source instead of inventing defaults' , async () => {
vi . mocked ( getWarehouseArrivals ). mockRejectedValue ( new Error ( 'offline' ))
const wrapper = renderSchedule (); await flushPromises ()
expect ( wrapper . getComponent ({ name : 'AppointmentCalendarPanel' }). props ( 'events' ). map (( event : ScheduleEvent ) => event . type )). toEqual ([ 'arrival' ])
expect ( wrapper . text ()). toContain ( '计划到仓读取失败' )
expect ( wrapper . getComponent ({ name : 'AppointmentCalendarPanel' }). props ( 'incomplete' )). toBe ( true )
2026-10-15 19:23:16 +08:00
wrapper . unmount ()
})
2026-10-19 12:20:13 +08:00
it ( 'does not request unreadable domains and describes the incomplete scope' , async () => {
2026-10-20 16:45:09 +08:00
routing . denied . add ( 'platform:overseas-warehouse:dispatch:list' ); routing . denied . add ( 'platform:overseas-warehouse:last-mile-order:list' )
2026-10-19 12:20:13 +08:00
const wrapper = renderSchedule (); await flushPromises ()
expect ( listTripCalendar ). not . toHaveBeenCalled (); expect ( getWarehouseArrivals ). not . toHaveBeenCalled ()
2026-10-20 16:45:09 +08:00
expect ( wrapper . text ()). toContain ( '缺少排车读取权限' )
2026-10-19 12:20:13 +08:00
expect ( wrapper . text ()). toContain ( '缺少尾程订单读取权限' )
2026-10-15 19:23:16 +08:00
wrapper . unmount ()
})
2026-10-19 12:20:13 +08:00
it ( 'ignores stale calendar responses when the date range changes' , async () => {
let resolveOld !: ( value : Awaited < ReturnType < typeof getCalendarSources >>) => void
vi . mocked ( getCalendarSources ). mockImplementationOnce (() => new Promise ( resolve => { resolveOld = resolve }))
const wrapper = renderSchedule (); await flushPromises ()
wrapper . getComponent ({ name : 'AppointmentCalendarPanel' }). vm . $emit ( 'range' , { from : '2026-11-01' , to : '2026-11-08' }); await flushPromises ()
resolveOld ({ containers : [], emptyReturns : [], emptyReturnsReadable : false }); await flushPromises ()
expect ( wrapper . text ()). not . toContain ( '缺少还柜读取权限' )
expect ( wrapper . getComponent ({ name : 'AppointmentCalendarPanel' }). props ( 'events' )). toHaveLength ( 0 )
2026-10-16 15:41:56 +08:00
wrapper . unmount ()
})
2026-10-22 23:10:09 +08:00
it ( 'releases an unknown save and sends the edited date with a fresh key' , async () => {
2026-10-19 12:20:13 +08:00
vi . mocked ( saveWarehouseArrival ). mockRejectedValueOnce ( new Error ( 'network interrupted' )). mockResolvedValueOnce ({})
const wrapper = renderSchedule (); await flushPromises ()
wrapper . getComponent ({ name : 'AppointmentCalendarPanel' }). vm . $emit ( 'edit' , row ); await flushPromises ()
2026-10-22 23:10:09 +08:00
await wrapper . get ( 'input[data-temporal-picker]' ). setValue ( '2026-10-22T09:30' ); await wrapper . get ( '[data-save]' ). trigger ( 'click' ); await flushPromises ()
expect ( wrapper . get ( 'input' ). attributes ( 'disabled' )). toBeUndefined ()
await wrapper . get ( 'input[data-temporal-picker]' ). setValue ( '2026-10-23T09:30' ); await wrapper . get ( '[data-save]' ). trigger ( 'click' ); await flushPromises ()
const calls = vi . mocked ( saveWarehouseArrival ). mock . calls
expect ( calls [ 1 ] ! [ 2 ]). not . toBe ( calls [ 0 ] ! [ 2 ]); expect ( calls [ 1 ] ! [ 1 ]. estimatedWarehouseArrivalAt ). toBe ( '2026-10-23T09:30:00' )
expect ( wrapper . find ( '[data-dialog]' ). exists ()). toBe ( false ); wrapper . unmount ()
2026-10-16 15:41:56 +08:00
})
2026-10-22 23:10:09 +08:00
it ( 'allows editing after a conflict and preserves input across an optional manual refresh' , async () => {
2026-10-19 12:20:13 +08:00
vi . mocked ( saveWarehouseArrival ). mockRejectedValueOnce ( new ApiResponseError ( '版本已变化' , 'A0409' , 409 ))
const wrapper = renderSchedule (); await flushPromises ()
wrapper . getComponent ({ name : 'AppointmentCalendarPanel' }). vm . $emit ( 'edit' , row ); await flushPromises ()
await wrapper . get ( 'input' ). setValue ( '2026-10-22T09:30' )
await wrapper . get ( '[data-save]' ). trigger ( 'click' ); await flushPromises ()
2026-10-22 23:10:09 +08:00
expect ( wrapper . get ( '[data-save]' ). attributes ( 'disabled' )). toBeUndefined ()
2026-10-19 12:20:13 +08:00
vi . mocked ( getWarehouseArrivals ). mockResolvedValueOnce ([{ ... warehouse , version : 2 }])
await wrapper . get ( '[data-retry]' ). trigger ( 'click' ); await flushPromises ()
expect (( wrapper . get ( 'input' ). element as HTMLInputElement ). value ). toBe ( '2026-10-22T09:30' )
await wrapper . get ( '[data-save]' ). trigger ( 'click' ); await flushPromises ()
expect ( saveWarehouseArrival ). toHaveBeenLastCalledWith ( '1' , expect . objectContaining ({ expectedVersion : 2 }), expect . any ( String ))
2026-10-11 13:27:39 +08:00
wrapper . unmount ()
})
2026-10-19 12:20:13 +08:00
it ( 'keeps the selected date and view in the source return link' , async () => {
const wrapper = renderSchedule (); await flushPromises ()
const panel = wrapper . getComponent ({ name : 'AppointmentCalendarPanel' })
panel . vm . $emit ( 'select' , '2026-10-21' , 'week' )
panel . vm . $emit ( 'open' , { ... row , sourcePath : '/container-loading/overseas/1' }); await flushPromises ()
expect ( routing . push ). toHaveBeenCalledWith ({ path : '/container-loading/overseas/1' , query : { appointmentReturn : '/appointment-calendar?date=2026-10-21&mode=week' } })
2026-10-16 15:41:56 +08:00
wrapper . unmount ()
})
2026-10-20 16:45:09 +08:00
it ( 'opens the real dispatch detail with calendar context without requiring retired trip-list access' , async () => {
routing . denied . add ( 'platform:fleet:trip:list' )
2026-10-21 11:38:19 +08:00
vi . mocked ( listTripCalendar ). mockResolvedValueOnce ({ records : [ dispatchEntry ({ dispatchNo : 'DS/1' , containerNumbers : [], plannedDepartureAt : '2026-10-21T16:00:00Z' })], unresolvedRecords : [] })
2026-10-20 16:45:09 +08:00
const wrapper = renderSchedule (); await flushPromises ()
const panel = wrapper . getComponent ({ name : 'AppointmentCalendarPanel' })
const event = panel . props ( 'events' ). find (( value : ScheduleEvent ) => value . type === 'dispatch' )
expect ( listTripCalendar ). toHaveBeenCalledOnce ()
expect ( event . sourcePath ). toBe ( '/overseas-warehouse/dispatches/DS%2F1' )
panel . vm . $emit ( 'select' , '2026-10-21' , 'week' )
panel . vm . $emit ( 'open' , event ); await flushPromises ()
expect ( routing . push ). toHaveBeenCalledWith ({ path : '/overseas-warehouse/dispatches/DS%2F1' , query : { appointmentReturn : '/appointment-calendar?date=2026-10-21&mode=week' } })
wrapper . unmount ()
})
2026-10-21 17:22:25 +08:00
it ( 'retains a complete dispatch with its container search fields without showing an unresolved dispatch notice' , async () => {
2026-10-21 11:38:19 +08:00
const good = dispatchEntry ()
const unknown = dispatchEntry ({ dispatchNo : 'PC-TIMEZONE-MISSING' , containerNumbers : [ 'OTHER7654321' ], originTimezone : null , incompleteReasons : [ 'ORIGIN_TIMEZONE_UNKNOWN' ] })
vi . mocked ( listTripCalendar ). mockResolvedValueOnce ({ records : [ good , good , unknown ], unresolvedRecords : [] })
const wrapper = renderSchedule (); await flushPromises ()
const panel = wrapper . getComponent ({ name : 'AppointmentCalendarPanel' })
const dispatches = ( panel . props ( 'events' ) as ScheduleEvent []). filter ( event => event . type === 'dispatch' )
expect ( dispatches ). toHaveLength ( 1 )
expect ( dispatches [ 0 ]). toMatchObject ({ key : 'dispatch-PC-CALENDAR-001' , date : '2026-10-22' , time : '09:00' })
expect ( dispatches [ 0 ] ! . fields ). toContainEqual ({ label : '关联柜号' , value : 'TSTU1234567、TSTU7654321' })
expect ( dispatches [ 0 ] ! . fields ). toContainEqual ({ label : '计划出发(起运仓当地)' , value : '2026-10-22 09:00' })
expect ( panel . props ( 'incomplete' )). toBe ( true )
2026-10-21 17:22:25 +08:00
expect ( wrapper . find ( '.data-notice' ). exists ()). toBe ( false )
2026-10-21 11:38:19 +08:00
expect ( dispatches . some ( event => event . key === 'dispatch-PC-TIMEZONE-MISSING' )). toBe ( false )
expect ( listTripCalendar ). toHaveBeenCalledTimes ( 1 )
wrapper . unmount ()
})
2026-10-09 11:01:41 +08:00
})