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
+
查看待核实车次({{ unresolvedTrips.length }})
@@ -423,7 +424,8 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
{{ tripIssues(row as TripCalendarEntry) }}
-
+
+
@@ -436,7 +438,7 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
输入更完整的仓库编码或名称,缩小搜索范围。
-
+
{{ row.customerName?.trim() || '客户名称缺失' }}
@@ -448,6 +450,7 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
{{ editing.selectedCount }} 票货物
+
取消历史
diff --git a/src/modules/appointment-calendar/AppointmentCancellation.test.ts b/src/modules/appointment-calendar/AppointmentCancellation.test.ts
new file mode 100644
index 00000000..d5f6f892
--- /dev/null
+++ b/src/modules/appointment-calendar/AppointmentCancellation.test.ts
@@ -0,0 +1,53 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import ElementPlus from 'element-plus'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import AppointmentCancellationDialog from './AppointmentCancellationDialog.vue'
+import AppointmentCancellationHistory from './AppointmentCancellationHistory.vue'
+import AppointmentCancellationRecords from './AppointmentCancellationRecords.vue'
+import { deleteAppointment, listAppointmentCancellations, listAppointmentCancellationRecords } from './api'
+const access = vi.hoisted(() => ({ canCancel: true }))
+vi.mock('@/core/access/accessPreview', () => ({useAccessPreviewStore: () => ({can: () => access.canCancel})}))
+vi.mock('vue-router', () => ({onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn()}))
+vi.mock('./api', () => ({deleteAppointment: vi.fn(),listAppointmentCancellations: vi.fn(),listAppointmentCancellationRecords: vi.fn()}))
+const record = {appointmentNo:'AP-CANCEL',version:1,cancelReason:null,cancelledAt:'2026-10-11T01:00:00',snapshot:{fbaWarehouseCode:'ABE3',appointmentStatus:'CANCELLED',appointmentRemark:'原备注保留',plannedDate:null,plannedAt:null,plannedEndAt:null,confirmationNo:'EXT-1',appointmentAt:'2026-10-15T09:00:00',appointmentEndAt:null,timezone:'America/New_York',selectedCount:1}}
+function dialog() {
+ return mount(AppointmentCancellationDialog,{props:{appointmentNo:'AP-CANCEL',version:0,selectedCount:1},global:{plugins:[ElementPlus],stubs:{AppDialog:{name:'AppDialog',props:['beforeClose','showConfirm'],template:''}}}})
+}
+describe('appointment cancellation',()=>{
+ beforeEach(()=>{vi.resetAllMocks();access.canCancel=true})
+ it('requires a reason and retries the same reason and version after an unknown result',async()=>{
+ vi.mocked(deleteAppointment).mockRejectedValueOnce(new Error('lost')).mockResolvedValueOnce(undefined)
+ const wrapper=dialog();await flushPromises()
+ await wrapper.get('[data-save]').trigger('click');expect(deleteAppointment).not.toHaveBeenCalled()
+ await wrapper.get('textarea').setValue(' 仓库取消时段 ')
+ await wrapper.get('[data-save]').trigger('click');await flushPromises()
+ expect(deleteAppointment).toHaveBeenCalledWith('AP-CANCEL',0,'仓库取消时段')
+ const done=vi.fn();wrapper.getComponent({name:'AppDialog'}).props('beforeClose')(done);expect(done).not.toHaveBeenCalled()
+ expect(wrapper.get('textarea').attributes('disabled')).toBeDefined()
+ await wrapper.get('[data-save]').trigger('click');await flushPromises()
+ expect(deleteAppointment).toHaveBeenNthCalledWith(2,'AP-CANCEL',0,'仓库取消时段')
+ expect(wrapper.emitted('cancelled')).toHaveLength(1);wrapper.unmount()
+ })
+ it('does not expose or invoke cancellation without its independent permission',async()=>{
+ access.canCancel=false;const wrapper=dialog();await flushPromises()
+ expect(wrapper.find('[data-save]').exists()).toBe(false)
+ wrapper.getComponent({name:'AppDialog'}).vm.$emit('confirm');await flushPromises()
+ expect(deleteAppointment).not.toHaveBeenCalled();wrapper.unmount()
+ })
+ it('reads history once per page and keeps missing historical reason explicit',async()=>{
+ vi.mocked(listAppointmentCancellations).mockResolvedValue({records:[record],total:11})
+ const wrapper=mount(AppointmentCancellationHistory,{props:{appointmentNo:'AP-CANCEL'},global:{plugins:[ElementPlus]}});await flushPromises()
+ expect(listAppointmentCancellations).toHaveBeenCalledExactlyOnceWith('AP-CANCEL',1,10)
+ expect(wrapper.text()).toContain('历史原因未记录');expect(wrapper.text()).toContain('原备注保留')
+ wrapper.getComponent({name:'DataTable'}).vm.$emit('update:page',2);await flushPromises()
+ expect(listAppointmentCancellations).toHaveBeenLastCalledWith('AP-CANCEL',2,10);wrapper.unmount()
+ })
+ it('discovers persisted cancellations with one server page and opens by business number',async()=>{
+ vi.mocked(listAppointmentCancellationRecords).mockResolvedValue({records:[{appointmentNo:'AP-CANCEL',version:1,cancelReason:'实际原因',cancelledAt:record.cancelledAt,fbaWarehouseCode:'ABE3',selectedCount:1}],total:1})
+ const wrapper=mount(AppointmentCancellationRecords,{global:{plugins:[ElementPlus]}});await flushPromises()
+ expect(listAppointmentCancellationRecords).toHaveBeenCalledExactlyOnceWith(1,10)
+ expect(listAppointmentCancellations).not.toHaveBeenCalled()
+ await wrapper.get('.el-table__body-wrapper .el-table__row').findAll('button').find(b=>b.text()==='查看')!.trigger('click')
+ expect(wrapper.emitted('view')).toEqual([['AP-CANCEL']]);wrapper.unmount()
+ })
+})
diff --git a/src/modules/appointment-calendar/AppointmentCancellationApi.test.ts b/src/modules/appointment-calendar/AppointmentCancellationApi.test.ts
new file mode 100644
index 00000000..61266631
--- /dev/null
+++ b/src/modules/appointment-calendar/AppointmentCancellationApi.test.ts
@@ -0,0 +1,17 @@
+import { beforeEach, expect, it, vi } from 'vitest'
+import { http } from '@/shared/api/http'
+import { deleteAppointment, listAppointmentCancellations, listAppointmentCancellationRecords } from './api'
+vi.mock('@/shared/api/http',()=>({http:{get:vi.fn(),delete:vi.fn()},unwrapApiResponse:(result:{data:unknown})=>result.data}))
+beforeEach(()=>vi.resetAllMocks())
+it('sends the required cancellation body without the old reasonless query shape',async()=>{
+ vi.mocked(http.delete).mockResolvedValue({data:undefined})
+ await deleteAppointment('AP-TEST',3,'仓库取消时段')
+ expect(http.delete).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/inbound-plans/fba-appointments/AP-TEST',{data:{version:3,cancelReason:'仓库取消时段'}})
+})
+it('uses paged cancellation reads and the original business number for detail history',async()=>{
+ vi.mocked(http.get).mockResolvedValue({data:{records:[],total:'12'}})
+ expect((await listAppointmentCancellationRecords(2,10)).total).toBe(12)
+ expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/cancellations',{params:{page:2,size:10}})
+ await listAppointmentCancellations('AP/TEST',1,10)
+ expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/AP%2FTEST/cancellations',{params:{page:1,size:10}})
+})
diff --git a/src/modules/appointment-calendar/AppointmentCancellationDialog.vue b/src/modules/appointment-calendar/AppointmentCancellationDialog.vue
new file mode 100644
index 00000000..06a6153a
--- /dev/null
+++ b/src/modules/appointment-calendar/AppointmentCancellationDialog.vue
@@ -0,0 +1,49 @@
+
+
+
+ {{ appointmentNo }} · {{ selectedCount }} 票货物
+ 取消后释放货物占用,保留原备注、时间及取消历史;不会回退库存或取消车次。
+
+
+
+
+
+
diff --git a/src/modules/appointment-calendar/AppointmentCancellationHistory.vue b/src/modules/appointment-calendar/AppointmentCancellationHistory.vue
new file mode 100644
index 00000000..cc5c599d
--- /dev/null
+++ b/src/modules/appointment-calendar/AppointmentCancellationHistory.vue
@@ -0,0 +1,32 @@
+
+
+
+
+ {{ row.cancelledAt?.replace('T',' ') || '—' }}
+ {{ row.cancelReason || '历史原因未记录' }}
+ {{ row.snapshot.fbaWarehouseCode }} · {{ row.snapshot.selectedCount }} 票 · 查看取消时记录
原业务备注:{{ row.snapshot.appointmentRemark || '—' }}
原计划:{{ row.snapshot.plannedAt || row.snapshot.plannedDate || '待定' }} — {{ row.snapshot.plannedEndAt }}
原确认:{{ row.snapshot.appointmentAt || '未确认' }} — {{ row.snapshot.appointmentEndAt }} · {{ row.snapshot.timezone || '时区待核实' }}
外部确认号:{{ row.snapshot.confirmationNo || '—' }}
+
+
diff --git a/src/modules/appointment-calendar/AppointmentCancellationRecords.vue b/src/modules/appointment-calendar/AppointmentCancellationRecords.vue
new file mode 100644
index 00000000..385ce66a
--- /dev/null
+++ b/src/modules/appointment-calendar/AppointmentCancellationRecords.vue
@@ -0,0 +1,35 @@
+
+
+
+
+ 查看
+ {{ row.cancelledAt?.replace('T',' ') || '—' }}
+ {{ row.cancelReason || '历史原因未记录' }}
+
+
diff --git a/src/modules/appointment-calendar/api.ts b/src/modules/appointment-calendar/api.ts
index fd70b7d9..d8aa7fdb 100644
--- a/src/modules/appointment-calendar/api.ts
+++ b/src/modules/appointment-calendar/api.ts
@@ -42,10 +42,32 @@ export async function listArrivalCalendar(query: { from: string; to: string }, s
return unwrapApiResponse(response)
}
-export async function deleteAppointment(appointmentNo: string, version: number) {
+export async function deleteAppointment(appointmentNo: string, version: number, cancelReason: string) {
const response = await http.delete, ApiEnvelope>(
`/platform/v1/last-leg/inbound-plans/fba-appointments/${encodeURIComponent(appointmentNo)}`,
- { params: { version } },
+ { data: { version, cancelReason } },
)
return unwrapApiResponse(response)
}
+
+export interface AppointmentCancellation {
+ appointmentNo: string; version: number; cancelReason: string | null; cancelledAt: string
+ snapshot: { fbaWarehouseCode: string; appointmentStatus: string; appointmentRemark: string | null;
+ plannedDate: string | null; plannedAt: string | null; plannedEndAt: string | null;
+ confirmationNo: string | null; appointmentAt: string | null; appointmentEndAt: string | null;
+ timezone: string | null; selectedCount: number }
+}
+export async function listAppointmentCancellations(appointmentNo: string, page: number, size: number) {
+ const result = unwrapApiResponse(await http.get, ApiEnvelope<{records: AppointmentCancellation[]; total: number}>>(
+ `/platform/v1/last-leg/inbound-plans/fba-appointments/${encodeURIComponent(appointmentNo)}/cancellations`, {params: {page, size}},
+ ))
+ return {...result, total: Number(result.total)}
+}
+
+export type AppointmentCancellationSummary = Omit & {fbaWarehouseCode: string; selectedCount: number}
+export async function listAppointmentCancellationRecords(page: number, size: number) {
+ const result = unwrapApiResponse(await http.get, ApiEnvelope<{records: AppointmentCancellationSummary[]; total: number}>>(
+ '/platform/v1/last-leg/inbound-plans/fba-appointments/cancellations', {params: {page, size}},
+ ))
+ return {...result, total: Number(result.total)}
+}