fix(admin): reuse active truck appointments
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user