feat(appointments): restore simplified four-type calendar

This commit is contained in:
2026-10-19 12:20:13 +08:00
parent 8ae1e5d0d4
commit 05cf82dbbc
16 changed files with 622 additions and 860 deletions
@@ -1,235 +1,118 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { inject, onBeforeUnmount } from 'vue'
import { ElDialog } from 'element-plus'
import type { LeaveGuard } from './useWorkDraft'
import { onBeforeRouteLeave } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { ApiResponseError } from '@/shared/api/http'
import AppointmentCalendarView from './AppointmentCalendarView.vue'
import { getWorkCalendar, getWorkItems, getWorkSummary } from './workApi'
import { listArrivalCalendar } from './api'
import { workCalendar, workItem, workSummary, followAction } from './work.test-support'
const routing = vi.hoisted(() => ({ query: {} as Record<string, string>, push: vi.fn(), replace: vi.fn(), canReadSource: true, allowLeave: true }))
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type WarehouseArrival } from './scheduleApi'
import { listTripCalendar } from './tripCalendar'
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>() }))
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission !== 'platform:overseas-warehouse:last-mile-order:list' || routing.canReadSource }) }))
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkCalendar: vi.fn(), getWorkItems: vi.fn(), getWorkSummary: vi.fn() }))
vi.mock('./api', async original => ({ ...await original<typeof import('./api')>(), listArrivalCalendar: vi.fn() }))
function render(realDialog = false) {
return mount(AppointmentCalendarView, { attachTo: document.body, global: { components: { ElDialog }, directives: { loading: {} }, stubs: {
teleport: true,
ElTooltip: { template: '<slot />' },
ElDialog: realDialog ? ElDialog : { name: 'ElDialog', props: ['modelValue', 'title', 'fullscreen', 'beforeClose', 'closeOnClickModal', 'closeOnPressEscape'], emits: ['update:modelValue'], template: '<div v-if="modelValue" class="el-dialog" :class="{ \'is-fullscreen\': fullscreen }"><button aria-label="关闭工作面板" @click="beforeClose(() => $emit(\'update:modelValue\', false))">关闭</button><slot name="header" /><slot /><slot name="footer" /></div>' },
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() } }))
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true }
const row: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true }
export function renderSchedule() {
return mount(AppointmentCalendarView, { global: { stubs: {
AdminPage: { template: '<main><slot /></main>' },
ElButton: { props: ['type'], template: '<button type="button" :data-variant="type"><slot /></button>' },
WorkCalendarPanel: { name: 'WorkCalendarPanel', props: ['anchor', 'mode', 'flow', 'types', 'calendar', 'failed', 'reference', 'selectedDate', 'selectedType'], template: '<div data-calendar><slot name="inbox" /></div>' },
WorkDetailPanel: { name: 'WorkDetailPanel', props: ['itemKey', 'initialAction', 'canLeave'], setup() {
const register = inject<(guard: LeaveGuard) => () => void>('appointment-work-leave-guard')!
const unregister = register(async () => routing.allowLeave)
onBeforeUnmount(unregister)
}, template: '<div data-detail>{{ itemKey }}</div>' },
SourceAppointmentPanel: { name: 'SourceAppointmentPanel', props: ['source'], template: '<div data-cargo />' },
LoadErrorState: { props: ['title', 'message'], template: '<section role="alert">{{ title }} {{ message }}<slot name="details" /><button @click="$emit(\'retry\')">重试</button></section>' },
ApiErrorMeta: true,
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', props: ['events', 'loading', 'incomplete', 'initialDate', 'initialMode'], template: '<section data-calendar />' },
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,
LoadErrorState: { props: ['title', 'message'], emits: ['retry'], template: '<section role="alert">{{ title }} {{ message }}<slot name="details" /><button data-retry @click="$emit(\'retry\')">重试</button></section>' },
} } })
}
describe('appointment work calendar', () => {
beforeEach(() => {
vi.clearAllMocks(); routing.query = { workAnchor: '2026-10-15' }; routing.canReadSource = true; routing.allowLeave = true
routing.replace.mockResolvedValue(undefined); routing.push.mockResolvedValue(undefined)
vi.mocked(getWorkCalendar).mockResolvedValue(workCalendar)
vi.mocked(getWorkSummary).mockResolvedValue(workSummary)
vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem()], page: 1, size: 8, total: 1, pages: 1 })
vi.mocked(listArrivalCalendar).mockResolvedValue([])
})
it('retains the old batch and page when a resolved later batch continues into its vehicle work', async () => {
routing.query = { workAnchor: '2026-10-15', workScope: 'global', workView: 'UNDATED', workPage: '3', workItem: 'old-batch-work' }
const wrapper = render(); await flushPromises()
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('open', 'later-batch-work'); await flushPromises()
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('later-batch-work')
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('open', 'later-vehicle-work', followAction); await flushPromises()
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('later-vehicle-work')
await wrapper.get('.panel-nav button').trigger('click'); await flushPromises()
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('later-batch-work')
await wrapper.get('.panel-nav button').trigger('click'); await flushPromises()
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('old-batch-work')
await wrapper.get('.panel-nav button').trigger('click'); await flushPromises()
expect(getWorkItems).toHaveBeenLastCalledWith(expect.objectContaining({ view: 'UNDATED', page: 3 }), expect.any(AbortSignal))
beforeEach(() => {
vi.clearAllMocks(); routing.query = { date: '2026-10-19' }; routing.denied.clear()
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', () => {
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')
wrapper.unmount()
})
it('opens the real work calendar by default and converts its half-open helper range to the inclusive API contract', async () => {
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('预约工作日历')
expect(wrapper.get('[data-calendar] .work-inbox').attributes('aria-label')).toBe('全局待办')
expect(getWorkCalendar).toHaveBeenCalledWith({ from: '2026-09-28', to: '2026-11-08', flow: undefined, types: ['PICKUP', 'WAREHOUSE_RECEIPT', 'DELIVERY', 'TRANSPORT', 'EMPTY_RETURN'] }, expect.any(AbortSignal))
expect(getWorkSummary).toHaveBeenCalledTimes(1); expect(getWorkItems).not.toHaveBeenCalled(); expect(listArrivalCalendar).not.toHaveBeenCalled()
it('loads original sources and real trips without loading work summaries or generated work items', async () => {
const wrapper = renderSchedule(); await flushPromises()
expect(getWarehouseArrivals).toHaveBeenCalledWith({ from: '2026-09-28', to: '2026-11-09' }, ['1'], expect.any(AbortSignal))
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(/全局待办|跟进|复核/)
wrapper.unmount()
})
it('keeps day/type selection and pagination context in the overlay and clears selection on close', async () => {
vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem()], page: 1, size: 8, total: 30, pages: 4 })
const wrapper = render(); await flushPromises()
const calendar = wrapper.getComponent({ name: 'WorkCalendarPanel' })
calendar.vm.$emit('day', '2026-10-15', 'PICKUP'); await flushPromises()
expect(calendar.props()).toMatchObject({ selectedDate: '2026-10-15', selectedType: 'PICKUP' })
expect(wrapper.get('.panel-header').text()).toContain('2026-10-15 · 提柜')
expect(wrapper.find('.panel-body .work-pagination').exists()).toBe(false)
expect(wrapper.get('.panel-footer').text()).toContain('共 30 项')
await wrapper.get('.panel-footer .work-pagination button:last-child').trigger('click'); await flushPromises()
expect(getWorkItems).toHaveBeenLastCalledWith(expect.objectContaining({ date: '2026-10-15', types: ['PICKUP'], page: 2 }), expect.any(AbortSignal))
await wrapper.get('[aria-label="关闭工作面板"]').trigger('click'); await flushPromises()
expect(wrapper.find('.work-panel').exists()).toBe(false)
expect(calendar.props('selectedDate')).toBeUndefined()
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)
wrapper.unmount()
})
it('expands the existing detail without remounting or bypassing the unsaved close guard', async () => {
const wrapper = render(); await flushPromises()
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
const detail = wrapper.getComponent({ name: 'WorkDetailPanel' }).vm
routing.allowLeave = false
await wrapper.get('[aria-label="展开全屏"]').trigger('click')
expect(wrapper.get('.el-dialog').classes()).toContain('is-fullscreen')
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).vm).toBe(detail)
await wrapper.get('[aria-label="关闭工作面板"]').trigger('click'); await flushPromises()
expect(wrapper.find('.work-panel').exists()).toBe(true)
routing.allowLeave = true
await wrapper.get('[aria-label="关闭工作面板"]').trigger('click'); await flushPromises()
expect(wrapper.find('.work-panel').exists()).toBe(false)
it('does not request unreadable domains and describes the incomplete scope', async () => {
routing.denied.add('platform:fleet:trip:list'); routing.denied.add('platform:overseas-warehouse:last-mile-order:list')
const wrapper = renderSchedule(); await flushPromises()
expect(listTripCalendar).not.toHaveBeenCalled(); expect(getWarehouseArrivals).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('缺少车次读取权限')
expect(wrapper.text()).toContain('缺少尾程订单读取权限')
wrapper.unmount()
})
it.each(['mask', 'escape'] as const)('closes from %s through the real dialog without bypassing draft protection', async method => {
const wrapper = render(true); await flushPromises()
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
await wrapper.get('.panel-body').trigger('click')
expect(wrapper.find('[data-detail]').exists()).toBe(true)
const dismiss = async () => {
if (method === 'mask') {
const mask = wrapper.get('.el-overlay-dialog')
await mask.trigger('mousedown'); await mask.trigger('mouseup'); await mask.trigger('click')
} else {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true }))
}
await flushPromises()
}
routing.allowLeave = false
await dismiss()
expect(wrapper.find('[data-detail]').exists()).toBe(true)
routing.allowLeave = true
await dismiss()
expect(wrapper.find('.work-panel').exists()).toBe(false)
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)
wrapper.unmount()
})
it.each(['VIEW_DETAIL', 'VIEW_HISTORY', 'SAVE_FOLLOWUP'] as const)('keeps %s navigation intact while distinguishing read-only actions', async code => {
const nextAction = { ...followAction, code, label: '服务端动作文案' }
vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem({ nextAction })], page: 1, size: 8, total: 1, pages: 1 })
const wrapper = render(); await flushPromises()
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
const button = wrapper.get('.work-row-action button')
expect(button.attributes('data-variant')).toBe(code === 'SAVE_FOLLOWUP' ? 'primary' : 'default')
expect(wrapper.get('.item-schedule').text()).toContain('作业日期未定')
expect(wrapper.get('.item-schedule').text()).toContain('America/Los_Angeles')
expect(wrapper.get('.item-context').text()).toContain('地点:ONT8')
expect(wrapper.get('.item-context').text()).toContain('负责人:测试调度')
expect(wrapper.get('.item-context').text()).toContain('跟进:2026-10-15')
await button.trigger('click'); await flushPromises()
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('initialAction')).toEqual(nextAction)
it('retries an unknown save with the same payload and key, then only reloads reads', async () => {
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('network interrupted')).mockResolvedValueOnce({})
const wrapper = renderSchedule(); await flushPromises()
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
await wrapper.get('input[type="datetime-local"]').setValue('2026-10-22T09:30')
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('提交结果尚未确认')
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
const guard = vi.mocked(onBeforeRouteLeave).mock.calls.at(-1)![0] as () => Promise<boolean>
expect(await guard()).toBe(false)
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0])
expect(saveWarehouseArrival).toHaveBeenCalledWith('1', { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 0 }, expect.any(String))
expect(wrapper.find('[data-dialog]').exists()).toBe(false)
wrapper.unmount()
})
it('sends an explicit safe return context with external business navigation', async () => {
routing.query = { workAnchor: '2026-10-15', workItem: 'work:1', workScope: 'global', workView: 'UNDATED', workPage: '3', workOrigins: '["work:0"]' }
const wrapper = render(); await flushPromises()
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('external', '/overseas-master-data/trips/TR-001?tab=loading'); await flushPromises()
expect(routing.replace).toHaveBeenCalledWith({ query: expect.objectContaining({ workItem: 'work:1', workOrigins: '["work:0"]', workPage: '3' }) })
expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-master-data/trips/TR-001', query: { tab: 'loading', appointmentReturn: expect.stringContaining('/appointment-calendar?') } })
it('requires an explicit reread after a conflict and retains the entered date', async () => {
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()
expect(wrapper.get('[data-save]').attributes('disabled')).toBeDefined()
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))
wrapper.unmount()
})
it('keeps a successful save in the current detail and retains the original global page on return', async () => {
routing.query = { workAnchor: '2026-10-15', workItem: 'work:1', workScope: 'global', workView: 'UNDATED', workPage: '3' }
const wrapper = render(); await flushPromises()
const detail = wrapper.getComponent({ name: 'WorkDetailPanel' }).vm
detail.$emit('saved', { item: workItem(), affectedWorkItemKeys: [] }, 'SAVE_FOLLOWUP'); await flushPromises()
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).vm).toBe(detail)
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 3, size: 8 }, expect.any(AbortSignal))
await wrapper.get('.panel-nav button').trigger('click'); await flushPromises()
expect(wrapper.find('[data-detail]').exists()).toBe(false)
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 3, size: 8 }, expect.any(AbortSignal))
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' } })
wrapper.unmount()
})
it('retains all global counts and unfiltered global detail through month, flow and all-off layer changes', async () => {
const wrapper = render(); await flushPromises()
await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises()
const calendar = wrapper.getComponent({ name: 'WorkCalendarPanel' })
calendar.vm.$emit('anchor', 1); await flushPromises()
calendar.vm.$emit('flow', 'DIRECT'); await flushPromises()
const calls = vi.mocked(getWorkCalendar).mock.calls.length
calendar.vm.$emit('types', []); await flushPromises()
expect(getWorkCalendar).toHaveBeenCalledTimes(calls)
expect(getWorkSummary).toHaveBeenCalledTimes(1)
expect(wrapper.get('[data-view="UNDATED"]').text()).toContain('7')
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 1, size: 8 }, expect.any(AbortSignal))
expect(wrapper.text()).toContain('作业日期未定'); expect(wrapper.text()).toContain('跨月份、流程与类型')
wrapper.unmount()
})
it('uses explicit day and type filters only for date details, and includes completed work through the server bucket', async () => {
const wrapper = render(); await flushPromises()
wrapper.getComponent({ name: 'WorkCalendarPanel' }).vm.$emit('day', '2026-10-15', 'PICKUP'); await flushPromises()
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8, date: '2026-10-15', types: ['PICKUP'], flow: undefined, bucket: undefined }, expect.any(AbortSignal))
await wrapper.findAll('.bucket-buttons button').find(button => button.text() === '已完成')!.trigger('click'); await flushPromises()
expect(vi.mocked(getWorkItems).mock.calls.at(-1)![0].bucket).toBe('COMPLETED')
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8 }, expect.any(AbortSignal))
wrapper.unmount()
})
it('shows a failed source as an error, not zero work or all complete', async () => {
vi.mocked(getWorkSummary).mockRejectedValue(new Error('source unavailable'))
vi.mocked(getWorkCalendar).mockRejectedValue(new Error('source unavailable'))
vi.mocked(getWorkItems).mockRejectedValue(new Error('source unavailable'))
const wrapper = render(); await flushPromises()
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
expect(wrapper.get('[data-view="ALL"]').text()).toContain('加载失败')
expect(wrapper.getComponent({ name: 'WorkCalendarPanel' }).props('failed')).toBe(true)
expect(wrapper.text()).toContain('工作明细读取失败'); expect(wrapper.text()).not.toContain('当前范围没有工作事项')
wrapper.unmount()
})
it('ignores a late response after the global scope changes', async () => {
let resolveOld!: (value: Awaited<ReturnType<typeof getWorkItems>>) => void
vi.mocked(getWorkItems).mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve }))
const wrapper = render(); await flushPromises()
await wrapper.get('[data-view="OVERDUE"]').trigger('click')
await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises()
resolveOld({ records: [workItem({ title: '过期响应不得覆盖' })], page: 1, size: 8, total: 1, pages: 1 }); await flushPromises()
expect(wrapper.text()).not.toContain('过期响应不得覆盖'); expect(wrapper.text()).toContain('批次 SD-001')
wrapper.unmount()
})
it('returns from a prerequisite to the original item and preserves the original undated list context', async () => {
const wrapper = render(); await flushPromises()
await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises()
await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('initialAction')).toEqual(followAction)
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('open', 'work-pickup-1', followAction); await flushPromises()
expect(wrapper.text()).toContain('返回原事项')
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('saved'); await flushPromises()
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('work-delivery-1')
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 1, size: 8 }, expect.any(AbortSignal))
wrapper.unmount()
})
it('goes directly to the current container unfinished list after route confirmation', async () => {
const wrapper = render(); await flushPromises()
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('saved', { item: workItem(), affectedWorkItemKeys: ['pickup', 'receipt'] }, 'CONFIRM_ROUTE'); await flushPromises()
expect(wrapper.findComponent({ name: 'WorkDetailPanel' }).exists()).toBe(false)
expect(wrapper.text()).toContain('TEST-CN-001 · 本柜待办')
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8, containerBookingId: '9007199254740993' }, expect.any(AbortSignal))
expect(getWorkSummary).toHaveBeenCalledTimes(2)
wrapper.unmount()
})
it('opens explicit source verification without forging an appointment or guessing a customer', async () => {
routing.query = { ...routing.query, sourceKey: 'BOOKING:B-1', customerId: 'customer-a', destinationKey: 'FBA:ONT8' }
const wrapper = render(); await flushPromises()
expect(wrapper.getComponent({ name: 'SourceAppointmentPanel' }).props('source')).toEqual({ sourceKey: 'BOOKING:B-1', customerId: 'customer-a', destinationKey: 'FBA:ONT8' })
wrapper.unmount()
routing.canReadSource = false
const denied = render(); await flushPromises()
expect(denied.findComponent({ name: 'SourceAppointmentPanel' }).exists()).toBe(false)
expect(denied.text()).toContain('没有货源读取权限'); denied.unmount()
})
})