From 240aa5d6d06a24aeb6701af595c7402d8692b75b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 11 Oct 2026 13:27:39 +0800 Subject: [PATCH] fix: require appointment cancellation reason and expose history --- .../AppointmentCalendarView.test.ts | 37 ++++++++++++- .../AppointmentCalendarView.vue | 49 +++++++++-------- .../AppointmentCancellation.test.ts | 53 +++++++++++++++++++ .../AppointmentCancellationApi.test.ts | 17 ++++++ .../AppointmentCancellationDialog.vue | 49 +++++++++++++++++ .../AppointmentCancellationHistory.vue | 32 +++++++++++ .../AppointmentCancellationRecords.vue | 35 ++++++++++++ src/modules/appointment-calendar/api.ts | 26 ++++++++- 8 files changed, 272 insertions(+), 26 deletions(-) create mode 100644 src/modules/appointment-calendar/AppointmentCancellation.test.ts create mode 100644 src/modules/appointment-calendar/AppointmentCancellationApi.test.ts create mode 100644 src/modules/appointment-calendar/AppointmentCancellationDialog.vue create mode 100644 src/modules/appointment-calendar/AppointmentCancellationHistory.vue create mode 100644 src/modules/appointment-calendar/AppointmentCancellationRecords.vue diff --git a/src/modules/appointment-calendar/AppointmentCalendarView.test.ts b/src/modules/appointment-calendar/AppointmentCalendarView.test.ts index b03f0205..5740c46c 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarView.test.ts +++ b/src/modules/appointment-calendar/AppointmentCalendarView.test.ts @@ -31,6 +31,9 @@ const candidates = [ ] function render(openCalendar = true) { const wrapper = mount(AppointmentCalendarView, { global: { directives: { loading: {} }, stubs: { + AppointmentCancellationDialog: {name:'AppointmentCancellationDialog',template:'
取消弹窗
'}, + AppointmentCancellationHistory: {name:'AppointmentCancellationHistory',template:'
取消历史详情
'}, + AppointmentCancellationRecords: {name:'AppointmentCancellationRecords',template:''}, AdminPage: { template: '
' }, AppointmentContainerPanel: { name: 'AppointmentContainerPanel', setup() { const register = inject<(guard: () => boolean) => () => void>('appointment-register-leave-guard')! @@ -67,6 +70,38 @@ describe('appointment calendar data integration', () => { 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('loads cancellation records only after expansion and opens cancelled appointments read-only', async () => { + const record = { version: 1, id: '1', appointmentNo: 'AP-CANCEL', originWarehouseId: '100', appointmentStatus: 'CANCELLED', selectedCount: 1, fbaWarehouseCode: 'ABE3', items: [] } + vi.mocked(getFbaAppointment).mockResolvedValue(record) + const wrapper=render();await flushPromises() + expect(wrapper.findComponent({name:'AppointmentCancellationRecords'}).exists()).toBe(false) + const records=wrapper.findAll('details').find(d=>d.find('summary').text()==='取消记录')! + ;(records.element as HTMLDetailsElement).open=true;await records.trigger('toggle') + await wrapper.get('[data-history-view]').trigger('click');await flushPromises() + expect(getFbaAppointment).toHaveBeenCalledWith('AP-CANCEL') + expect(wrapper.find('[data-save]').exists()).toBe(false) + expect(wrapper.findComponent({name:'FbaAppointmentFields'}).props('disabled')).toBe(true) + expect(wrapper.findComponent({name:'AppointmentCancellationHistory'}).exists()).toBe(false) + const history=wrapper.findAll('details').find(d=>d.find('summary').text()==='取消历史')! + ;(history.element as HTMLDetailsElement).open=true;await history.trigger('toggle') + expect(wrapper.findComponent({name:'AppointmentCancellationHistory'}).exists()).toBe(true) + wrapper.findAllComponents({name:'AppDialog'}).at(-1)!.vm.$emit('confirm');await flushPromises() + expect(updateFbaAppointment).not.toHaveBeenCalled();wrapper.unmount() + }) + + it('refreshes an expanded cancellation list after a successful cancellation', async () => { + vi.mocked(listFbaAppointmentCalendar).mockResolvedValue([{version:0,id:'1',appointmentNo:'AP-CANCEL',originWarehouseId:'100',appointmentStatus:'PENDING',selectedCount:1,fbaWarehouseCode:'ABE3',items:[],plannedDate:'2026-10-15'}]) + const wrapper=render();await flushPromises() + const records=wrapper.findAll('details').find(d=>d.find('summary').text()==='取消记录')! + ;(records.element as HTMLDetailsElement).open=true;await records.trigger('toggle') + const previous=wrapper.getComponent({name:'AppointmentCancellationRecords'}).vm + wrapper.getComponent({name:'AppointmentCalendarPanel'}).vm.$emit('delete',{key:'AP-CANCEL'});await flushPromises() + wrapper.getComponent({name:'AppointmentCancellationDialog'}).vm.$emit('cancelled');await flushPromises() + expect(wrapper.getComponent({name:'AppointmentCancellationRecords'}).vm).not.toBe(previous) + expect(wrapper.findComponent({name:'AppointmentCancellationDialog'}).exists()).toBe(false) + wrapper.unmount() + }) + it('keeps the container view mounted while a child has an unresolved command', async () => { const wrapper = render(); await flushPromises() const tabs = wrapper.get('[aria-label="预约工作视图"]').findAll('button') @@ -150,7 +185,7 @@ describe('appointment calendar data integration', () => { 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.find('.appointment-extra').exists()).toBe(false) + expect(wrapper.findAll('.appointment-extra summary').map(node => node.text())).not.toContain('待核实地点的车次(1)') expect(wrapper.text()).not.toContain('原计划时间保留 UTC') await wrapper.findAll('button').find(button => button.text() === '查看待核实车次(1)')!.trigger('click') expect(wrapper.text()).toContain('原计划时间保留 UTC') diff --git a/src/modules/appointment-calendar/AppointmentCalendarView.vue b/src/modules/appointment-calendar/AppointmentCalendarView.vue index fbfeb373..b1015197 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarView.vue +++ b/src/modules/appointment-calendar/AppointmentCalendarView.vue @@ -4,7 +4,7 @@ import { computed, provide, onBeforeUnmount, onMounted, reactive, ref, watch } f import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router' import { resolvePlanningSource, listUnscheduledAppointments, type ResolvedPlanningSource, type PlanningSource } from './planningApi' import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue' -import { ElMessage, ElMessageBox } from 'element-plus' +import { ElMessage } from 'element-plus' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import AdminPage from '@/shared/components/AdminPage.vue' @@ -21,12 +21,18 @@ import { import { listPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses' import AppointmentCalendarPanel, { type CalendarEvent } from './AppointmentCalendarPanel.vue' import AppointmentInboundCodes from './AppointmentInboundCodes.vue' +import AppointmentCancellationDialog from './AppointmentCancellationDialog.vue' +import AppointmentCancellationHistory from './AppointmentCancellationHistory.vue' +import AppointmentCancellationRecords from './AppointmentCancellationRecords.vue' import AppointmentCargoPanel from './AppointmentCargoPanel.vue' import AppointmentContainerPanel from './AppointmentContainerPanel.vue' import { calendarRange, dateKey } from './calendar' -import { deleteAppointment, listArrivalCalendar, listPickupCalendar, type ArrivalCalendarEntry, type PickupCalendarEntry } from './api' +import { listArrivalCalendar, listPickupCalendar, type ArrivalCalendarEntry, type PickupCalendarEntry } from './api' import { listTripCalendar, projectTripCalendar, tripIssues, type TripCalendarEntry } from './tripCalendar' +const cancellationTarget = ref(null) +const cancellationsOpen = ref(false), cancellationsRevision = ref(0), cancellationHistoryOpen = ref(false), forceReadOnly = ref(false) +const appointmentReadOnly = computed(() => forceReadOnly.value || editing.value?.appointmentStatus === 'CANCELLED' || !access.can(permission.inboundOrderAppointment)) const access = useAccessPreviewStore() const route = useRoute() const router = useRouter() @@ -221,6 +227,7 @@ onBeforeRouteLeave(canLeave) onBeforeRouteUpdate(canLeave) function openCreate(date: string) { if (!access.can(permission.inboundOrderAppointment) || !canLeave()) return + forceReadOnly.value = false; cancellationHistoryOpen.value = false ++candidateRequest ++detailRequest detailLoading.value = false @@ -241,13 +248,13 @@ function openCreate(date: string) { void searchWarehouses('') } -async function openEdit(event: Pick) { +async function openEdit(event: Pick, readOnly = false) { if (!canLeave()) return + forceReadOnly.value = readOnly; cancellationHistoryOpen.value = false const request = ++detailRequest dialogError.value = null editing.value = [...appointments.value, ...unscheduled.value].find(item => item.appointmentNo === event.key) || null pendingSave.value = null - if (!editing.value) return dialogOpen.value = true detailLoading.value = true detailReady.value = false @@ -312,7 +319,7 @@ async function loadCandidates() { } async function save() { - if (!access.can(permission.inboundOrderAppointment) || saving.value || detailLoading.value || !detailReady.value) return + if (appointmentReadOnly.value || saving.value || detailLoading.value || !detailReady.value) return const validation = appointmentFormError(form) if (!pendingSave.value && validation) { ElMessage.warning(validation); return } if (!editing.value) { @@ -343,23 +350,16 @@ function selectCandidates(rows: object[]) { selected.value = rows as InboundOrderSummaryDto[] } -async function remove(event: CalendarEvent) { - const appointment = appointments.value.find(item => item.appointmentNo === event.key) - if (!appointment || loading.value) return - try { - await ElMessageBox.confirm(`确认取消预约 ${appointment.appointmentNo}?将释放全部 ${appointment.selectedCount} 票货物的占用,历史仍保留。`, '取消预约', - { type: 'warning', confirmButtonText: '确认取消预约', cancelButtonText: '返回', customClass: 'admin-density-confirm' }) - } catch { return } - loading.value = true - try { - await deleteAppointment(appointment.appointmentNo, appointment.version) - ElMessage.success('预约已取消,历史仍保留') - await load() - } catch (error) { - errorTitle.value = '预约取消失败' - loadError.value = getApiErrorDetails(error, '预约取消失败') - } finally { loading.value = false } +function remove(event: CalendarEvent) { + if (!access.can('platform:appointment:delete') || loading.value || !canLeave()) return + cancellationTarget.value = appointments.value.find(item => item.appointmentNo === event.key) || null } +async function cancelled() { + cancellationTarget.value = null; ++cancellationsRevision.value + ElMessage.success('预约已取消,可在取消记录中查看原因与原记录') + await Promise.all([load(),loadUnscheduled()]) +} + watch(dialogOpen, open => { if (!open) { ++candidateRequest; ++detailRequest; ++warehouseRequest; candidateLoading.value = false; warehouseLoading.value = false; detailLoading.value = false; detailReady.value = false } @@ -412,6 +412,7 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
未定时安排({{ unscheduledTotal }})
+
取消记录
@@ -423,7 +424,8 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ - + +
@@ -436,7 +438,7 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ

输入更完整的仓库编码或名称,缩小搜索范围。

- +