diff --git a/src/modules/appointment-calendar/AppointmentCalendarView.test.ts b/src/modules/appointment-calendar/AppointmentCalendarView.test.ts
index 5740c46c..365c3e15 100644
--- a/src/modules/appointment-calendar/AppointmentCalendarView.test.ts
+++ b/src/modules/appointment-calendar/AppointmentCalendarView.test.ts
@@ -70,6 +70,18 @@ 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('uses the dedicated cancellation dialog for an undated appointment instead of editing its status', async () => {
+ const record = { version: 3, id: '1', appointmentNo: 'AP-UNDATED', originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 1, fbaWarehouseCode: 'ABE3', items: [] }
+ vi.mocked(listUnscheduledAppointments).mockResolvedValue({records: [record], total: 1})
+ vi.mocked(getFbaAppointment).mockResolvedValue(record)
+ const wrapper=render(); await flushPromises()
+ await wrapper.findAll('button').find(button=>button.text()==='查看 / 继续')!.trigger('click'); await flushPromises()
+ expect(wrapper.find('option[value="CANCELLED"]').exists()).toBe(false)
+ await wrapper.get('[data-cancel-editing]').trigger('click'); await flushPromises()
+ expect(wrapper.findComponent({name:'AppointmentCancellationDialog'}).attributes('appointment-no')).toBe('AP-UNDATED')
+ expect(wrapper.find('[data-save]').exists()).toBe(false)
+ expect(updateFbaAppointment).not.toHaveBeenCalled(); wrapper.unmount()
+ })
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)
diff --git a/src/modules/appointment-calendar/AppointmentCalendarView.vue b/src/modules/appointment-calendar/AppointmentCalendarView.vue
index b1015197..8dffe02a 100644
--- a/src/modules/appointment-calendar/AppointmentCalendarView.vue
+++ b/src/modules/appointment-calendar/AppointmentCalendarView.vue
@@ -354,6 +354,11 @@ 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
}
+function cancelEditing() {
+ if (!editing.value || editing.value.appointmentStatus === 'CANCELLED' || !access.can('platform:appointment:delete') || !canLeave()) return
+ cancellationTarget.value = editing.value
+ dialogOpen.value = false
+}
async function cancelled() {
cancellationTarget.value = null; ++cancellationsRevision.value
ElMessage.success('预约已取消,可在取消记录中查看原因与原记录')
@@ -439,6 +444,7 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
输入更完整的仓库编码或名称,缩小搜索范围。
+ 取消预约
{{ row.customerName?.trim() || '客户名称缺失' }}
diff --git a/src/modules/appointment-calendar/AppointmentImpactDialog.test.ts b/src/modules/appointment-calendar/AppointmentImpactDialog.test.ts
index b6bfef33..a408f60b 100644
--- a/src/modules/appointment-calendar/AppointmentImpactDialog.test.ts
+++ b/src/modules/appointment-calendar/AppointmentImpactDialog.test.ts
@@ -2,13 +2,15 @@ import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppointmentImpactDialog from './AppointmentImpactDialog.vue'
+import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import { getImpact, handleImpact, type ImpactDetail } from './impactApi'
vi.mock('./impactApi', async importOriginal => ({ ...await importOriginal(), getImpact: vi.fn(), handleImpact: vi.fn() }))
-vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
+const guards = vi.hoisted(() => ({ register: vi.fn(), unregister: vi.fn(), push: vi.fn() }))
+vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRouter: () => ({ push: guards.push }) }))
const detail: ImpactDetail = { impact: { containerBookingId: '1', impactNo: 'AI-TEST', version: 1, status: 'OPEN', reason: 'FORECAST_CHANGED', ownerId: null, ownerName: null, baseline: { estimatedAt: '2026-10-15T09:00:00', actualAt: null, observedAt: null, providerCode: 'carrier', sourceReference: 'ref' }, latest: { estimatedAt: '2026-10-17T09:00:00', actualAt: null, observedAt: null, providerCode: 'carrier', sourceReference: 'ref' }, forecastToken: 'current', updatedAt: '2026-10-10T12:00:00' }, cargo: [{ inboundCode: 'IN-A', customerName: '客户甲', packages: 10, destination: 'ONT8' }], appointments: [{ appointmentNo: 'AP-1', status: 'SUCCESS', destination: 'ONT8', confirmedAt: '2026-10-16T09:00:00', timezone: 'America/Los_Angeles' }], transport: null, history: { records: [], total: 0 } }
-const render = (canWrite = true) => mount(AppointmentImpactDialog, { props: { containerId: '1', canWrite }, global: { plugins: [ElementPlus], stubs: { AppDialog: { template: '' } } } })
+const render = (canWrite = true) => mount(AppointmentImpactDialog, { props: { containerId: '1', canWrite }, global: { provide: { 'appointment-register-leave-guard': guards.register }, plugins: [ElementPlus], stubs: { AppDialog: { name: 'AppDialog', props: ['beforeClose'], template: '' } } } })
describe('appointment impact handling', () => {
- beforeEach(() => { vi.resetAllMocks(); vi.mocked(getImpact).mockResolvedValue(detail) })
+ beforeEach(() => { vi.resetAllMocks(); guards.register.mockReturnValue(guards.unregister); vi.mocked(getImpact).mockResolvedValue(detail) })
it('shows separate forecasts, unchanged confirmed appointment and omitted transport permission', async () => {
const wrapper = render(false); await flushPromises()
expect(wrapper.text()).toContain('2026-10-15T09:00:00')
@@ -33,15 +35,22 @@ describe('appointment impact handling', () => {
})
it('freezes an uncertain command and retries exactly once with original version and key', async () => {
vi.mocked(handleImpact).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({ ...detail.impact, version: 2 })
+ vi.mocked(getImpact).mockResolvedValueOnce({...detail,transport:{trips:[{tripNo:'TRIP-1',status:'PLANNED',plannedDepartureAt:null}],sameTripBookings:[]}})
const wrapper = render(); await flushPromises()
await wrapper.findAll('button').find(b => b.text() === '认领跟进')!.trigger('click'); await flushPromises()
const original = vi.mocked(handleImpact).mock.calls[0]![1]
expect(original).toMatchObject({ action: 'CLAIM', expectedVersion: 1, forecastToken: 'current' })
await wrapper.get('[data-close]').trigger('click'); expect(wrapper.emitted('close')).toBeUndefined()
+ const done=vi.fn(); wrapper.findComponent({name:'AppDialog'}).props('beforeClose')(done); expect(done).not.toHaveBeenCalled()
+ expect(guards.register.mock.calls[0]![0]()).toBe(false)
+ await wrapper.findAll('button').find(b=>b.text()==='TRIP-1')!.trigger('click');expect(guards.push).not.toHaveBeenCalled()
+ for (const hook of [onBeforeRouteLeave,onBeforeRouteUpdate]) expect((vi.mocked(hook).mock.calls[0]![0] as () => boolean)()).toBe(false)
await wrapper.findAll('button').find(b => b.text() === '重试本次处理')!.trigger('click'); await flushPromises()
expect(handleImpact).toHaveBeenNthCalledWith(2, '1', original)
expect(wrapper.emitted('saved')).toHaveLength(1)
- expect(getImpact).toHaveBeenCalledTimes(2); wrapper.unmount()
+ expect(getImpact).toHaveBeenCalledTimes(2)
+ expect(guards.register.mock.calls[0]![0]()).toBe(true)
+ wrapper.unmount(); expect(guards.unregister).toHaveBeenCalledOnce()
})
it('does not resolve without an actual handling result', async () => {
const wrapper = render(); await flushPromises()
diff --git a/src/modules/appointment-calendar/AppointmentImpactDialog.vue b/src/modules/appointment-calendar/AppointmentImpactDialog.vue
index b5c3cc1a..fcc57bdc 100644
--- a/src/modules/appointment-calendar/AppointmentImpactDialog.vue
+++ b/src/modules/appointment-calendar/AppointmentImpactDialog.vue
@@ -1,6 +1,6 @@
-
+
{{ detail.impact.impactNo }} · {{ impactStatus[detail.impact.status] }} · 负责人:{{ detail.impact.ownerName || '未认领' }}
@@ -43,7 +51,7 @@ onMounted(load)
需复核的收货预约 {{ detail.appointments.length }} 张(确认时间不自动修改)
{{ row.appointmentNo }} · {{ row.destination }} · {{ row.confirmedAt || '尚未确认时段' }} {{ row.timezone || '时区待核实' }}
无车次查看权限,运输影响未加载。
- 关联车次 {{ detail.transport.trips.length }} 辆
{{ row.tripNo }} · 计划出发(UTC):{{ row.plannedDepartureAt || '待定' }}
+ 关联车次 {{ detail.transport.trips.length }} 辆
{{ row.tripNo }} · 计划出发(UTC):{{ row.plannedDepartureAt || '待定' }}
同车外接承诺 {{ detail.transport.sameTripBookings.length }} 票,仅提示运输安排复核
{{ row.bookingNo }} · {{ row.customerName || '—' }} · {{ row.tripNo }} · {{ row.destinationLabel || '—' }}
diff --git a/src/modules/overseas-master-data/capacityOfferTypes.ts b/src/modules/overseas-master-data/capacityOfferTypes.ts
index 69765dbf..ec2b7d0f 100644
--- a/src/modules/overseas-master-data/capacityOfferTypes.ts
+++ b/src/modules/overseas-master-data/capacityOfferTypes.ts
@@ -41,6 +41,7 @@ export interface InquiryResult extends OfferResult { inquiry: CapacityInquiry }
export interface OfferCustomerOption { id: string; code: string; name: string }
export const offerStatusLabels = { DRAFT: '发布草稿', OPEN: '接单中', PAUSED: '已暂停', CLOSED: '已关闭' }
export const inquiryStatusLabels = { DRAFT: '已登记', CONFIRMED: '已确认承运', RECEIVED: '已确认实收', BOUND: '已排入本车', CANCELLED: '已取消' }
+export const inquiryCargoLabel = (status?: CapacityInquiry['status']) => status === 'RECEIVED' || status === 'BOUND' ? '已核实' : status === 'CANCELLED' ? '记录' : '预报'
export const offerTime = (value: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
export const loadText = (value: CapacityLoad | null) => value ? `${value.palletSpaces} 托位 / ${value.weightKg} kg / ${value.volumeCbm} m³` : '待调度核对'
diff --git a/src/modules/overseas-master-data/components/CapacityInquiryDialog.vue b/src/modules/overseas-master-data/components/CapacityInquiryDialog.vue
index 539aa5db..f6e63a1e 100644
--- a/src/modules/overseas-master-data/components/CapacityInquiryDialog.vue
+++ b/src/modules/overseas-master-data/components/CapacityInquiryDialog.vue
@@ -8,7 +8,7 @@ import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/sh
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listOfferCustomers, listOfferQuotes, saveCapacityInquiry, saveAndConfirmInquiry } from '../api/capacityOffers'
import { fleetQuoteReference, type FleetQuoteReference, type FleetQuoteSourceSnapshot } from '../api/fleetQuoteCandidates'
-import { offerTime, appointmentCoverageLabels, appointmentMissingLabels } from '../capacityOfferTypes'
+import { offerTime, appointmentCoverageLabels, appointmentMissingLabels, inquiryCargoLabel } from '../capacityOfferTypes'
import type { CapacityOffer, CapacityInquiry, InquiryResult, InquiryWrite, OfferCustomerOption } from '../capacityOfferTypes'
import type { TripBookingCargo } from '../bookingTypes'
import { useFleetDraftGuard } from '../useFleetDraftGuard'
@@ -47,6 +47,7 @@ const target = computed(() => props.inquiry?.bookingNo || props.offer?.offerNo |
const writable = computed(() => !props.readonly && (access.can('platform:fleet:capacity-offer:manage') || access.can('platform:fleet:capacity-offer:list') && access.can(`platform:fleet:capacity-offer:inquiry-${props.inquiry ? 'edit' : 'create'}`)) && (props.inquiry ? props.inquiry.actions.canEdit : props.offer?.canCreateInquiry))
const canQuote = computed(() => access.can('platform:fleet:quote:list'))
const lock = computed(() => !writable.value || busy.value || !!pending.value)
+const cargoLabel = computed(() => inquiryCargoLabel(props.inquiry?.status))
const selectedCapacity = computed(() => props.offer?.capacityKnown ? props.offer.destinations.find(stop => stop.destinationKey === form.cargo.destinationKey)?.remaining : null)
const quoteReady = computed(() => !!form.customerId && form.cargo.destinationKey.startsWith('FBA:') && ['CNY', 'USD'].includes(form.cargo.currency || ''))
const guard = useFleetDraftGuard('capacity-inquiry-editor', open, form, busy, computed(() => !!pending.value))
@@ -127,12 +128,12 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
暂无可选客户
- 已知货量(可先填写,未知可留空)