This commit is contained in:
2026-09-14 20:03:27 +08:00
parent 66f49b97e5
commit 349e8f60df
44 changed files with 658 additions and 459 deletions
+1 -1
View File
@@ -142,7 +142,7 @@ export async function updateTruckAppointment(id: string | number, payload: SaveT
return unwrapApiResponse(response)
}
export type TruckAppointmentAction = 'SUBMIT' | 'DISPATCH' | 'PICKUP' | 'ARRIVE' | 'LOAD' | 'DEPART' | 'COMPLETE' | 'EXCEPTION' | 'CANCEL'
export type TruckAppointmentAction = 'SUBMIT' | 'DISPATCH' | 'PICKUP' | 'ARRIVE' | 'LOAD' | 'EXCEPTION' | 'CANCEL'
export async function performTruckAppointmentAction(
id: string | number,
@@ -156,20 +156,15 @@ describe('TruckAppointmentForm booking-only behavior', () => {
wrapper.unmount()
})
it('switches the payable currency between RMB and USD and normalizes an illegal legacy value', async () => {
it('offers only RMB for domestic trucking payables', async () => {
const wrapper = mount(TruckAppointmentForm, {
props: { modelValue: { ...model, payableAmount: '88.00', currency: 'EUR' as never } },
props: { modelValue: { ...model, payableAmount: '88.00', currency: 'RMB' } },
global: { plugins: [ElementPlus] },
})
await flushPromises()
const currencySelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((select) => select.props('placeholder') === '请选择币种')!
expect(currencySelect.props('modelValue')).toBe('RMB')
currencySelect.vm.$emit('update:modelValue', 'USD')
await flushPromises()
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
expect(emitted.currency).toBe('USD')
expect(wrapper.text()).toContain('USD')
expect(emitted).not.toHaveProperty('receivableAmount')
expect(currencySelect.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['RMB'])
wrapper.unmount()
})
})
@@ -87,7 +87,7 @@ describe('TruckAppointmentForm interaction contract', () => {
})
it('uses payable-only fees with a fixed dynamic currency selector', () => {
expect(typesSource).toContain("TRUCK_APPOINTMENT_CURRENCIES = ['RMB', 'USD']")
expect(typesSource).toContain("TRUCK_APPOINTMENT_CURRENCIES = ['RMB']")
expect(source).toContain('<template #append>{{ draft.currency }}</template>')
expect(source).toContain('v-for="currency in TRUCK_APPOINTMENT_CURRENCIES"')
expect(source).toContain('placeholder="请选择币种"')
@@ -54,7 +54,7 @@ describe('TruckAppointmentCreateView existing appointment reuse', () => {
wrapper.unmount()
})
it.each(['COMPLETED', 'CANCELLED', 'EXCEPTION'] as const)('allows a new appointment after terminal status %s', async (status) => {
it.each(['CANCELLED', 'EXCEPTION'] as const)('allows a new appointment after terminal status %s', async (status) => {
Object.assign(routing.query, { oceanBookingId: 'booking-1' })
vi.mocked(listTruckAppointments).mockResolvedValue(page([existing(status)]))
const wrapper = mountView()
@@ -64,6 +64,17 @@ describe('TruckAppointmentCreateView existing appointment reuse', () => {
wrapper.unmount()
})
it('opens a completed appointment without allowing another charge', async () => {
Object.assign(routing.query, { oceanBookingId: 'booking-1' })
vi.mocked(listTruckAppointments).mockResolvedValue(page([existing('COMPLETED')]))
const wrapper = mountView()
await flushPromises()
await (wrapper.vm as unknown as ViewModel).submit()
expect(createTruckAppointment).not.toHaveBeenCalled()
expect(routing.replace).toHaveBeenCalledWith(expect.objectContaining({ name: 'trucking-detail', query: undefined }))
wrapper.unmount()
})
it('keeps request evidence and retry state when existing-appointment lookup fails', async () => {
Object.assign(routing.query, { oceanBookingId: 'booking-1' })
vi.mocked(listTruckAppointments).mockRejectedValue(new ApiResponseError('预约查询失败', 'A0500', 500, undefined, 'req-existing', 'trace-existing'))
@@ -29,7 +29,7 @@ describe('TruckAppointmentCreateView booking-bound creation contract', () => {
it('reuses non-terminal appointments by booking and separates lookup failures from booking-file errors', () => {
expect(source).toContain("listTruckAppointments({ oceanBookingId: normalizedId, page: 1, size: 20 })")
expect(source).toContain("new Set(['COMPLETED', 'CANCELLED', 'EXCEPTION'])")
expect(source).toContain("new Set(['CANCELLED', 'EXCEPTION'])")
expect(source).toContain('该订舱已有拖车单')
expect(source).toContain("getApiErrorDetails(cause, '已有拖车预约查询失败,请稍后重试')")
expect(source).toContain('<ApiErrorMeta :details="existingLookupError" />')
@@ -22,7 +22,7 @@ const existingLookupError = ref<ApiErrorDetails | null>(null)
const checkedOceanBookingId = ref('')
const navigatingToExisting = ref(false)
let lookupSerial = 0
const terminalStatuses = new Set(['COMPLETED', 'CANCELLED', 'EXCEPTION'])
const replaceableStatuses = new Set(['CANCELLED', 'EXCEPTION'])
const existingCanEdit = computed(() => ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(existingAppointment.value?.status || ''))
const submitBlocked = computed(() => existingLookupLoading.value || Boolean(existingLookupError.value) || Boolean(existingAppointment.value))
function updateForm(value: SaveTruckAppointmentRequest) { Object.assign(form, value) }
@@ -45,7 +45,7 @@ async function lookupExistingAppointment(oceanBookingId: string, autoNavigate =
const result = await listTruckAppointments({ oceanBookingId: normalizedId, page: 1, size: 20 })
if (serial !== lookupSerial || String(form.oceanBookingId || '') !== normalizedId) return
checkedOceanBookingId.value = normalizedId
existingAppointment.value = result.records.find((item) => !terminalStatuses.has(item.status)) || null
existingAppointment.value = result.records.find((item) => !replaceableStatuses.has(item.status)) || null
if (autoNavigate && existingAppointment.value) await openExisting()
} catch (cause) {
if (serial !== lookupSerial || String(form.oceanBookingId || '') !== normalizedId) return
@@ -72,7 +72,7 @@ defineExpose({ form, submit, lookupExistingAppointment, existingAppointment, exi
<AdminPage as="div" class="truck-editor-page admin-density">
<DetailPageLayout aria-label="新增拖车预约"><template #header><header class="create-heading surface-panel"><el-button link class="density-back" @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button><h1>新增拖车预约</h1></header></template>
<LoadErrorState v-if="error" title="拖车预约创建失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-alert v-if="existingAppointment" type="warning" :closable="false" show-icon><template #title>该订舱已有拖车单 {{ existingAppointment.appointmentNo || existingAppointment.id }}({{ truckAppointmentStatusLabel(existingAppointment.status) }})</template><el-button type="primary" link :loading="navigatingToExisting" @click="openExisting">{{ existingCanEdit ? '继续编辑' : '查看拖车单' }}</el-button></el-alert>
<el-alert v-if="existingAppointment" type="warning" :closable="false" show-icon><template #title>该订舱已有拖车单 {{ existingAppointment.appointmentNo || '—' }}({{ truckAppointmentStatusLabel(existingAppointment.status) }})</template><el-button type="primary" link :loading="navigatingToExisting" @click="openExisting">{{ existingCanEdit ? '继续编辑' : '查看拖车单' }}</el-button></el-alert>
<LoadErrorState v-if="existingLookupError" title="已有拖车单查询失败" :message="existingLookupError.message" compact @retry="lookupExistingAppointment(String(form.oceanBookingId || ''))"><template #details><ApiErrorMeta :details="existingLookupError" /></template></LoadErrorState>
<section class="surface-panel form-panel"><TruckAppointmentForm :model-value="form" :submitting="saving" :submit-blocked="submitBlocked" @update:model-value="updateForm" @submit="submit" @attachments="attachmentOpen = true"></TruckAppointmentForm></section>
<TruckAttachmentDialog v-model="attachmentOpen" :attachments="[]" />
@@ -24,6 +24,12 @@ describe('TruckAppointmentDetailView status contract', () => {
expect(source).toContain('.history-section { padding:var(--admin-space-5); }')
})
it('uses domestic departure completion instead of manual departure and completion buttons', () => {
expect(source).not.toContain("LOADING: 'DEPARTED'")
expect(source).not.toContain("DEPARTED: 'COMPLETED'")
expect(source).toContain('订舱全部货柜确认国内离仓后,自动完成拖车并生成应付费用')
})
it('matches the backend edit lifecycle and avoids a self-transition action', () => {
expect(source).toContain('const targets: TruckAppointmentStatus[] =')
expect(source).not.toContain('const targets: string[] =')
@@ -78,6 +84,6 @@ describe('TruckAppointmentDetailView status contract', () => {
it('reloads a cached detail after an error without showing stale resource data', () => {
expect(source).toContain("resource.value = null; bookingReadiness.value = null; history.value = []; relatedError.value = ''")
expect(source).toContain('onActivated(() => {')
expect(source).toContain('if (error.value || resource.value?.id !== id.value) void load()')
expect(source).toMatch(/if \(activatedOnce\) \{\s+void load\(\)/)
})
})
@@ -37,8 +37,8 @@ async function loadRelated() {
}
async function load() { loading.value = true; error.value = null; resource.value = null; bookingReadiness.value = null; history.value = []; relatedError.value = ''; try { const result = await getTruckAppointment(id.value); resource.value = result; const draft = Object.fromEntries(TRUCK_APPOINTMENT_SAVE_FIELDS.map((key) => [key, key === 'expectedVersion' ? result.version ?? 0 : key === 'currency' ? normalizeTruckAppointmentCurrency(result.currency) : result[key as keyof TruckAppointmentResource]])); Object.assign(form, draft); await loadRelated() } catch (err) { error.value = getApiErrorDetails(err, '详情加载失败,请稍后重试') } finally { loading.value = false } }
async function submit() { if (saving.value) return; saving.value = true; error.value = null; try { await updateTruckAppointment(id.value, form); ElMessage.success('拖车预约已保存'); await load() } catch (err) { error.value = getApiErrorDetails(err, '保存失败,请稍后重试') } finally { saving.value = false } }
const actionByTarget: Partial<Record<TruckAppointmentStatus, TruckAppointmentAction>> = { PENDING_DISPATCH: 'SUBMIT', DISPATCHED: 'DISPATCH', PICKUP_IN_PROGRESS: 'PICKUP', ARRIVED_WAREHOUSE: 'ARRIVE', LOADING: 'LOAD', DEPARTED: 'DEPART', COMPLETED: 'COMPLETE', EXCEPTION: 'EXCEPTION', CANCELLED: 'CANCEL' }
const primaryTargetByStatus: Partial<Record<TruckAppointmentStatus, TruckAppointmentStatus>> = { DRAFT: 'PENDING_DISPATCH', PENDING_DISPATCH: 'DISPATCHED', RESCHEDULED: 'DISPATCHED', DISPATCHED: 'PICKUP_IN_PROGRESS', PICKUP_IN_PROGRESS: 'ARRIVED_WAREHOUSE', ARRIVED_WAREHOUSE: 'LOADING', LOADING: 'DEPARTED', DEPARTED: 'COMPLETED' }
const actionByTarget: Partial<Record<TruckAppointmentStatus, TruckAppointmentAction>> = { PENDING_DISPATCH: 'SUBMIT', DISPATCHED: 'DISPATCH', PICKUP_IN_PROGRESS: 'PICKUP', ARRIVED_WAREHOUSE: 'ARRIVE', LOADING: 'LOAD', EXCEPTION: 'EXCEPTION', CANCELLED: 'CANCEL' }
const primaryTargetByStatus: Partial<Record<TruckAppointmentStatus, TruckAppointmentStatus>> = { DRAFT: 'PENDING_DISPATCH', PENDING_DISPATCH: 'DISPATCHED', RESCHEDULED: 'DISPATCHED', DISPATCHED: 'PICKUP_IN_PROGRESS', PICKUP_IN_PROGRESS: 'ARRIVED_WAREHOUSE', ARRIVED_WAREHOUSE: 'LOADING' }
const availableTargets = computed<TruckAppointmentStatus[]>(() => {
if (!resource.value || ['COMPLETED', 'CANCELLED'].includes(resource.value.status)) return []
const primary = primaryTargetByStatus[resource.value.status]
@@ -65,7 +65,7 @@ async function changeStatus(status: TruckAppointmentStatus) {
onMounted(load)
onActivated(() => {
if (activatedOnce) {
if (error.value || resource.value?.id !== id.value) void load()
void load()
return
}
activatedOnce = true
@@ -83,7 +83,7 @@ onActivated(() => {
<div v-if="resource && canChangeStatus" class="status-actions"><el-button v-for="status in availableTargets" :key="status" size="small" :type="status === 'CANCELLED' ? 'danger' : status === 'EXCEPTION' ? 'warning' : 'primary'" plain :loading="statusSaving" @click="changeStatus(status)">{{ truckAppointmentStatusLabels[status] }}</el-button></div>
</div>
<template v-if="resource">
<p class="relation-snapshot"><strong>历史关联快照:</strong>{{ relationSnapshot }}</p>
<p class="relation-snapshot"><strong>历史关联快照:</strong>{{ relationSnapshot }} · 订舱全部货柜确认国内离仓后,自动完成拖车并生成应付费用。</p>
<el-alert v-if="dispatchBlockReason" :title="dispatchBlockReason" type="warning" :closable="false" show-icon />
<el-alert v-if="relatedError" :title="relatedError" type="warning" :closable="false" show-icon />
<ApiErrorMeta v-if="error" :details="error" />
+2 -5
View File
@@ -5,7 +5,6 @@ export const TRUCK_APPOINTMENT_STATUSES = [
'PICKUP_IN_PROGRESS',
'ARRIVED_WAREHOUSE',
'LOADING',
'DEPARTED',
'COMPLETED',
'RESCHEDULED',
'EXCEPTION',
@@ -21,7 +20,6 @@ export const truckAppointmentStatusLabels: Record<TruckAppointmentStatus, string
PICKUP_IN_PROGRESS: '提柜中',
ARRIVED_WAREHOUSE: '已到仓',
LOADING: '装柜中',
DEPARTED: '已出发',
COMPLETED: '已完成',
RESCHEDULED: '已改期',
EXCEPTION: '异常',
@@ -40,8 +38,7 @@ export const truckAppointmentActionLabels: Record<string, string> = {
PICKUP: '开始提柜',
ARRIVE: '到达仓库',
LOAD: '开始装柜',
DEPART: '离开仓库',
COMPLETE: '完成预约',
AUTO_COMPLETE: '国内离仓自动完成',
RESCHEDULE: '预约改期',
EXCEPTION: '标记异常',
CANCEL: '取消预约',
@@ -57,7 +54,7 @@ export type TruckAttachmentType = typeof TRUCK_ATTACHMENT_TYPES[number]
export function isTruckAttachmentType(value: unknown): value is TruckAttachmentType {
return TRUCK_ATTACHMENT_TYPES.includes(value as TruckAttachmentType)
}
export const TRUCK_APPOINTMENT_CURRENCIES = ['RMB', 'USD'] as const
export const TRUCK_APPOINTMENT_CURRENCIES = ['RMB'] as const
export type TruckAppointmentCurrency = typeof TRUCK_APPOINTMENT_CURRENCIES[number]
export function normalizeTruckAppointmentCurrency(value: unknown): TruckAppointmentCurrency {
const normalized = String(value ?? '').trim().toUpperCase()