fix(admin): reuse active truck appointments

This commit is contained in:
2026-09-01 11:31:15 +08:00
parent e3ecba7133
commit 041452f2ff
4 changed files with 196 additions and 13 deletions
@@ -9,7 +9,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { isIso6346ContainerNumber } from '../iso6346'
const props = defineProps<{ modelValue: SaveTruckAppointmentRequest; resource?: TruckAppointmentResource | null; readonly?: boolean; submitting?: boolean }>()
const props = defineProps<{ modelValue: SaveTruckAppointmentRequest; resource?: TruckAppointmentResource | null; readonly?: boolean; submitting?: boolean; submitBlocked?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [SaveTruckAppointmentRequest]; submit: []; attachments: [] }>()
const formRef = ref<FormInstance>()
const relationWarning = ref('')
@@ -195,7 +195,7 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !bookings.value.len
<el-col class="wide-column" :xs="24" :sm="12"><el-form-item label="PO"><el-input v-model="draft.poNo" placeholder="请输入采购订单号(可选)" /></el-form-item></el-col>
<el-col :xs="24"><el-form-item label="备注"><el-input v-model="draft.remark" type="textarea" :rows="3" placeholder="请输入拖车预约备注(可选)" /></el-form-item></el-col>
</el-row>
<div class="form-actions"><el-button :disabled="submitting" @click="emit('attachments')"><Eye :size="16" />查看附件引用</el-button><el-button v-if="!readonly" type="primary" :loading="submitting" @click="submitForm"><Save :size="16" />保存</el-button></div>
<div class="form-actions"><el-button :disabled="submitting" @click="emit('attachments')"><Eye :size="16" />查看附件引用</el-button><el-button v-if="!readonly" type="primary" :loading="submitting" :disabled="submitBlocked" @click="submitForm"><Save :size="16" />保存</el-button></div>
</el-form>
</template>
@@ -0,0 +1,118 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import TruckAppointmentCreateView from './TruckAppointmentCreateView.vue'
import { createTruckAppointment, listTruckAppointments } from '../api/platformTrucking'
import type { SaveTruckAppointmentRequest, TruckAppointmentResource } from '../types'
const routing = vi.hoisted(() => ({ query: {} as Record<string, string>, replace: vi.fn(), push: vi.fn() }))
vi.mock('vue-router', () => ({ useRoute: () => ({ query: routing.query }), useRouter: () => ({ replace: routing.replace, push: routing.push }) }))
vi.mock('../api/platformTrucking', () => ({ createTruckAppointment: vi.fn(), listTruckAppointments: vi.fn() }))
const existing = (status: TruckAppointmentResource['status']): TruckAppointmentResource => ({
id: 'appointment-1', appointmentNo: 'TA-260901-0003', status, containerBookingId: 'container-1', attachments: [],
})
const page = (records: TruckAppointmentResource[]) => ({ records, page: 1, size: 20, total: records.length, pages: 1 })
const formStub = {
name: 'TruckAppointmentForm',
props: ['modelValue', 'submitting', 'submitBlocked'],
emits: ['update:modelValue', 'submit', 'attachments'],
template: '<div data-form :data-blocked="String(submitBlocked)"><button data-submit @click="$emit(\'submit\')">保存</button></div>',
}
type ViewModel = {
form: SaveTruckAppointmentRequest
submit: () => Promise<void>
lookupExistingAppointment: (id: string, autoNavigate?: boolean) => Promise<void>
existingAppointment: TruckAppointmentResource | null
existingLookupError: { message: string } | null
checkedContainerBookingId: string
}
function mountView() {
return mount(TruckAppointmentCreateView, { global: { plugins: [ElementPlus], stubs: { TruckAppointmentForm: formStub, TruckAttachmentDialog: true } } })
}
describe('TruckAppointmentCreateView existing appointment reuse', () => {
beforeEach(() => {
vi.clearAllMocks()
for (const key of Object.keys(routing.query)) delete routing.query[key]
routing.replace.mockResolvedValue(undefined)
routing.push.mockResolvedValue(undefined)
vi.mocked(listTruckAppointments).mockResolvedValue(page([]))
vi.mocked(createTruckAppointment).mockResolvedValue(existing('DRAFT'))
})
it('automatically reuses a DRAFT appointment for a query-prefilled container', async () => {
Object.assign(routing.query, { oceanBookingId: 'booking-1', containerBookingId: 'container-1' })
vi.mocked(listTruckAppointments).mockResolvedValue(page([existing('DRAFT')]))
const wrapper = mountView()
await flushPromises()
expect(listTruckAppointments).toHaveBeenCalledWith({ containerBookingId: 'container-1', page: 1, size: 20 })
expect(routing.replace).toHaveBeenCalledWith({ name: 'trucking-detail', params: { id: 'appointment-1' }, query: { edit: '1' } })
expect(createTruckAppointment).not.toHaveBeenCalled()
wrapper.unmount()
})
it.each(['COMPLETED', 'CANCELLED', 'EXCEPTION'] as const)('allows a new appointment after terminal status %s', async (status) => {
Object.assign(routing.query, { containerBookingId: 'container-1' })
vi.mocked(listTruckAppointments).mockResolvedValue(page([existing(status)]))
const wrapper = mountView()
await flushPromises()
await (wrapper.vm as unknown as ViewModel).submit()
expect(createTruckAppointment).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('keeps request evidence and retry state when existing-appointment lookup fails', async () => {
Object.assign(routing.query, { containerBookingId: 'container-1' })
vi.mocked(listTruckAppointments).mockRejectedValue(new ApiResponseError('预约查询失败', 'A0500', 500, undefined, 'req-existing', 'trace-existing'))
const wrapper = mountView()
await flushPromises()
expect(wrapper.text()).toContain('预约查询失败')
expect(wrapper.text()).toContain('req-existing')
expect(wrapper.text()).toContain('trace-existing')
await (wrapper.vm as unknown as ViewModel).submit()
expect(createTruckAppointment).not.toHaveBeenCalled()
wrapper.unmount()
})
it('ignores a stale lookup response after the selected container changes', async () => {
let resolveFirst!: (value: ReturnType<typeof page>) => void
let resolveSecond!: (value: ReturnType<typeof page>) => void
vi.mocked(listTruckAppointments).mockImplementation((query = {}) => new Promise((resolve) => {
const { containerBookingId } = query
if (containerBookingId === 'container-1') resolveFirst = resolve
else resolveSecond = resolve
}))
const wrapper = mountView()
const vm = wrapper.vm as unknown as ViewModel
vm.form.containerBookingId = 'container-1'
await wrapper.vm.$nextTick()
vm.form.containerBookingId = 'container-2'
await wrapper.vm.$nextTick()
resolveSecond(page([]))
await flushPromises()
resolveFirst(page([existing('DRAFT')]))
await flushPromises()
expect(vm.checkedContainerBookingId).toBe('container-2')
expect(vm.existingAppointment).toBeNull()
expect(routing.replace).not.toHaveBeenCalled()
wrapper.unmount()
})
it('suppresses duplicate create clicks while the first request is pending', async () => {
Object.assign(routing.query, { containerBookingId: 'container-1' })
let resolveCreate!: (value: TruckAppointmentResource) => void
vi.mocked(createTruckAppointment).mockImplementation(() => new Promise((resolve) => { resolveCreate = resolve }))
const wrapper = mountView()
await flushPromises()
const vm = wrapper.vm as unknown as ViewModel
const first = vm.submit()
const second = vm.submit()
expect(createTruckAppointment).toHaveBeenCalledTimes(1)
resolveCreate(existing('DRAFT'))
await Promise.all([first, second])
wrapper.unmount()
})
})
@@ -8,7 +8,7 @@ describe('TruckAppointmentCreateView booking-bound creation contract', () => {
it('starts with required booking and container choices empty until the user selects them', () => {
expect(source).toContain('carrierId: null')
expect(source).not.toContain('customerId')
expect(source).toContain('oceanBookingId: null, containerBookingId: null, loadingPlanId: null')
expect(source).toContain('oceanBookingId: initialOceanBookingId || null, containerBookingId: initialContainerBookingId || null, loadingPlanId: null')
expect(source).toContain('plannedLoadingAt: null')
expect(source).toContain("payableAmount: '0.00', currency: 'RMB'")
expect(source).not.toContain('receivableAmount')
@@ -18,11 +18,22 @@ describe('TruckAppointmentCreateView booking-bound creation contract', () => {
it('keeps the form visible and exposes the real backend error details', () => {
expect(source).toContain("getApiErrorDetails(err, '创建失败,请稍后重试')")
expect(source).toContain('<ApiErrorMeta :details="error" />')
expect(source).toContain('<TruckAppointmentForm v-model="form"')
expect(source).toContain('<TruckAppointmentForm :model-value="form"')
expect(source).toContain('@update:model-value="updateForm"')
})
it('suppresses duplicate submits while the first create request is pending', () => {
expect(source).toContain('if (saving.value) return')
expect(source).toContain('if (saving.value || existingLookupLoading.value) return')
})
it('reuses non-terminal appointments by container and separates lookup failures from booking-file errors', () => {
expect(source).toContain("listTruckAppointments({ containerBookingId: normalizedId, page: 1, size: 20 })")
expect(source).toContain("new Set(['COMPLETED', 'CANCELLED', 'EXCEPTION'])")
expect(source).toContain('该柜已有拖车预约')
expect(source).toContain("getApiErrorDetails(cause, '已有拖车预约查询失败,请稍后重试')")
expect(source).toContain('<ApiErrorMeta :details="existingLookupError" />')
expect(source).toContain("router.replace({ name: 'trucking-detail'")
expect(source).toContain('serial !== lookupSerial')
})
it('places the return action beside the relation note instead of in a separate header row', () => {
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { computed, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft } from '@lucide/vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -10,17 +10,71 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
import { createTruckAppointment } from '../api/platformTrucking'
import type { SaveTruckAppointmentRequest } from '../types'
const router = useRouter(); const saving = ref(false); const attachmentOpen = ref(false); const error = ref<ApiErrorDetails | null>(null)
const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, carrierId: null, oceanBookingId: null, containerBookingId: null, loadingPlanId: null, plannedLoadingAt: null, payableAmount: '0.00', currency: 'RMB' })
async function submit() { if (saving.value) return; saving.value = true; error.value = null; try { await createTruckAppointment({ ...form }); ElMessage.success('拖车预约已创建'); router.push({ name: 'trucking' }) } catch (err) { error.value = getApiErrorDetails(err, '创建失败,请稍后重试') } finally { saving.value = false } }
import { createTruckAppointment, listTruckAppointments } from '../api/platformTrucking'
import { truckAppointmentStatusLabel, type SaveTruckAppointmentRequest, type TruckAppointmentResource } from '../types'
const route = useRoute(); const router = useRouter(); const saving = ref(false); const attachmentOpen = ref(false); const error = ref<ApiErrorDetails | null>(null)
const initialContainerBookingId = String(route.query.containerBookingId || '').trim()
const initialOceanBookingId = String(route.query.oceanBookingId || '').trim()
const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, carrierId: null, oceanBookingId: initialOceanBookingId || null, containerBookingId: initialContainerBookingId || null, loadingPlanId: null, plannedLoadingAt: null, payableAmount: '0.00', currency: 'RMB' })
const existingAppointment = ref<TruckAppointmentResource | null>(null)
const existingLookupLoading = ref(false)
const existingLookupError = ref<ApiErrorDetails | null>(null)
const checkedContainerBookingId = ref('')
const navigatingToExisting = ref(false)
let lookupSerial = 0
const terminalStatuses = new Set(['COMPLETED', '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) }
async function openExisting() {
if (!existingAppointment.value || navigatingToExisting.value) return
navigatingToExisting.value = true
try { await router.replace({ name: 'trucking-detail', params: { id: existingAppointment.value.id }, query: existingCanEdit.value ? { edit: '1' } : undefined }) }
finally { navigatingToExisting.value = false }
}
async function lookupExistingAppointment(containerBookingId: string, autoNavigate = false) {
const normalizedId = containerBookingId.trim()
const serial = ++lookupSerial
existingAppointment.value = null
existingLookupError.value = null
checkedContainerBookingId.value = ''
if (!normalizedId) { existingLookupLoading.value = false; return }
existingLookupLoading.value = true
try {
const result = await listTruckAppointments({ containerBookingId: normalizedId, page: 1, size: 20 })
if (serial !== lookupSerial || String(form.containerBookingId || '') !== normalizedId) return
checkedContainerBookingId.value = normalizedId
existingAppointment.value = result.records.find((item) => !terminalStatuses.has(item.status)) || null
if (autoNavigate && existingAppointment.value) await openExisting()
} catch (cause) {
if (serial !== lookupSerial || String(form.containerBookingId || '') !== normalizedId) return
checkedContainerBookingId.value = normalizedId
existingLookupError.value = getApiErrorDetails(cause, '已有拖车预约查询失败,请稍后重试')
} finally {
if (serial === lookupSerial) existingLookupLoading.value = false
}
}
async function submit() {
if (saving.value || existingLookupLoading.value) return
const containerBookingId = String(form.containerBookingId || '')
if (containerBookingId && checkedContainerBookingId.value !== containerBookingId) await lookupExistingAppointment(containerBookingId)
if (String(form.containerBookingId || '') !== containerBookingId || existingLookupLoading.value || existingAppointment.value || existingLookupError.value) return
saving.value = true; error.value = null
try { await createTruckAppointment({ ...form }); ElMessage.success('拖车预约已创建'); router.push({ name: 'trucking' }) }
catch (err) { error.value = getApiErrorDetails(err, '创建失败,请稍后重试') }
finally { saving.value = false }
}
watch(() => form.containerBookingId, (value) => { void lookupExistingAppointment(String(value || ''), Boolean(initialContainerBookingId && String(value || '') === initialContainerBookingId)) }, { immediate: true })
defineExpose({ form, submit, lookupExistingAppointment, existingAppointment, existingLookupError, checkedContainerBookingId })
</script>
<template>
<AdminPage as="div" class="truck-editor-page">
<DetailPageLayout aria-label="新增拖车预约">
<LoadErrorState v-if="error" title="拖车预约创建失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<section class="surface-panel form-panel"><TruckAppointmentForm v-model="form" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true"><template #relation-note-actions><el-button @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button></template></TruckAppointmentForm></section>
<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>
<LoadErrorState v-if="existingLookupError" title="已有拖车预约查询失败" :message="existingLookupError.message" compact @retry="lookupExistingAppointment(String(form.containerBookingId || ''))"><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"><template #relation-note-actions><el-button @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button></template></TruckAppointmentForm></section>
<TruckAttachmentDialog v-model="attachmentOpen" :attachments="[]" />
</DetailPageLayout>
</AdminPage>