feat(appointment): complete customer receiving and trip continuation workflows

This commit is contained in:
2026-10-16 15:41:56 +08:00
parent 148085286d
commit 45f662ecf2
45 changed files with 2042 additions and 131 deletions
@@ -1,6 +1,7 @@
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 AppointmentCalendarView from './AppointmentCalendarView.vue'
import { getWorkCalendar, getWorkItems, getWorkSummary } from './workApi'
@@ -11,10 +12,13 @@ vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing
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() {
return mount(AppointmentCalendarView, { global: { stubs: {
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>' },
AdminPage: { template: '<main><slot /></main>' },
ElButton: { template: '<button><slot /></button>' },
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')!
@@ -35,6 +39,21 @@ describe('appointment work calendar', () => {
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))
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('预约工作日历')
@@ -65,8 +84,8 @@ describe('appointment work calendar', () => {
await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
const detail = wrapper.getComponent({ name: 'WorkDetailPanel' }).vm
routing.allowLeave = false
await wrapper.get('.panel-tools button:first-child').trigger('click')
expect(wrapper.get('.work-panel').classes()).toContain('is-expanded')
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)
@@ -75,6 +94,45 @@ describe('appointment work calendar', () => {
expect(wrapper.find('.work-panel').exists()).toBe(false)
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)
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)
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()
@@ -83,6 +141,18 @@ describe('appointment work calendar', () => {
expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-master-data/trips/TR-001', query: { tab: 'loading', appointmentReturn: expect.stringContaining('/appointment-calendar?') } })
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))
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()