feat(admin): allow late container number entry

This commit is contained in:
2026-09-01 15:24:37 +08:00
parent b4c29e2226
commit 0b676ddb74
23 changed files with 155 additions and 71 deletions
@@ -20,10 +20,23 @@ describe('truck appointment API', () => {
await expect(getTruckAppointment('9001')).resolves.toMatchObject({ id: '9001' })
await expect(createTruckAppointment({ carrierId: '200', oceanBookingId: '300', containerBookingId: '400', loadingPlanId: null, plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 })).resolves.toMatchObject({ id: '9001' })
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/truck-appointments', { carrierId: '200', oceanBookingId: '300', containerBookingId: '400', loadingPlanId: null, plannedLoadingAt: '2026-08-10T10:00:00', currency: 'RMB', expectedVersion: 0 })
await expect(performTruckAppointmentAction('9001', 'DISPATCH', 2)).resolves.toMatchObject({ status: 'DISPATCHED' })
await expect(performTruckAppointmentAction('9001', 'DISPATCH', { expectedVersion: 2 })).resolves.toMatchObject({ status: 'DISPATCHED' })
expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments/9001/dispatch', { expectedVersion: 2, reason: null }, { headers: { 'Idempotency-Key': expect.stringContaining('truck-appointment-dispatch-9001-') } })
})
it('sends the real ISO container number only with the pickup action', async () => {
vi.mocked(http.post).mockResolvedValue({ data: { id: '9001', status: 'PICKUP_IN_PROGRESS', containerNo: 'MSCU6639870' } })
await performTruckAppointmentAction('9001', 'PICKUP', { expectedVersion: 3, containerNo: 'MSCU6639870' })
expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments/9001/pickup', {
expectedVersion: 3, reason: null, containerNo: 'MSCU6639870',
}, { headers: { 'Idempotency-Key': expect.stringContaining('truck-appointment-pickup-9001-') } })
await performTruckAppointmentAction('9002', 'PICKUP', { expectedVersion: 1, containerNo: null })
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/truck-appointments/9002/pickup', {
expectedVersion: 1, reason: null,
}, { headers: { 'Idempotency-Key': expect.stringContaining('truck-appointment-pickup-9002-') } })
})
it('drops legacy customer fields instead of forwarding hidden compatibility data', async () => {
vi.mocked(http.post).mockResolvedValueOnce({ data: { id: '9001', status: 'DRAFT', attachmentCount: 0 } })
const payload = { carrierId: '200', oceanBookingId: '300', containerBookingId: '400', plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0, currency: 'EUR', receivableAmount: '999.00', customerId: 'legacy-customer', customerName: '旧客户快照' }
+8 -3
View File
@@ -5,6 +5,7 @@ import type {
TruckAppointmentPage,
TruckAppointmentQuery,
TruckAppointmentResource,
TruckAppointmentActionRequest,
TruckAppointmentStatusHistory,
TruckAttachmentReference,
TruckAttachmentType,
@@ -110,13 +111,17 @@ export type TruckAppointmentAction = 'SUBMIT' | 'DISPATCH' | 'PICKUP' | 'ARRIVE'
export async function performTruckAppointmentAction(
id: string | number,
action: TruckAppointmentAction,
expectedVersion: number,
reason?: string,
payload: TruckAppointmentActionRequest,
idempotencyKey?: string,
requestHash?: string,
) {
const operationKey = idempotencyKey || operationId(action, id)
const response = await http.post<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}/${action.toLowerCase()}`, { expectedVersion, reason: reason || null }, {
const body = {
expectedVersion: payload.expectedVersion,
reason: payload.reason || null,
...(payload.containerNo ? { containerNo: payload.containerNo } : {}),
}
const response = await http.post<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}/${action.toLowerCase()}`, body, {
headers: { 'Idempotency-Key': operationKey, ...(requestHash ? { 'X-Request-Hash': requestHash } : {}) },
})
return unwrapApiResponse(response)
@@ -35,7 +35,7 @@ function detail(id: string) {
containers: id === 'booking-1'
? [
{ containerBookingId: 'container-1', cabinetNo: 'CAB-1', containerNo: 'CBHU8888883' },
{ containerBookingId: 'container-invalid', cabinetNo: 'CAB-X', containerNo: '错误柜号' },
{ containerBookingId: 'container-pending', cabinetNo: 'CAB-X', containerNo: null, containerType: '40HC' },
]
: [{ containerBookingId: 'container-2', cabinetNo: 'CAB-2', containerNo: 'MSCU6639870' }],
}
@@ -48,7 +48,7 @@ describe('TruckAppointmentForm booking-only behavior', () => {
vi.mocked(getOceanBooking).mockImplementation(async (id) => detail(id) as never)
})
it('selects a released booking, exposes only valid real containers, and never emits customer fields', async () => {
it('selects a released booking, exposes pending-pickup internal containers, and never emits customer fields', async () => {
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
await flushPromises()
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
@@ -58,7 +58,7 @@ describe('TruckAppointmentForm booking-only behavior', () => {
expect(getOceanBooking).toHaveBeenCalledWith('booking-1')
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || ''))
expect(labels.some((label) => label.includes('CAB-1') && label.includes('CBHU8888883'))).toBe(true)
expect(labels.some((label) => label.includes('CAB-X'))).toBe(false)
expect(labels.some((label) => label.includes('CAB-X') && label.includes('40HC') && label.includes('待补录'))).toBe(true)
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
expect(emitted.oceanBookingId).toBe('booking-1')
expect(emitted.billOfLadingNo).toBe('BL-1')
@@ -6,22 +6,23 @@ const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/compone
const typesSource = readFileSync(resolve(process.cwd(), 'src/modules/trucking/types.ts'), 'utf8')
describe('TruckAppointmentForm interaction contract', () => {
it('requires a released booking before a real-numbered allocated container', () => {
it('requires a released booking before an allocated internal container', () => {
expect(source).toContain("listOceanBookings({ page: 1, size: 200, status: 'RELEASED' })")
expect(source).toContain("oceanBookingId: [{ required: true, message: '请选择已放舱订舱'")
expect(source).toContain("containerBookingId: [{ required: true, message: '请选择该订舱下已录真实集装箱号的柜次'")
expect(source.indexOf('label="订舱"')).toBeLessThan(source.indexOf('label="柜次(真实集装箱号)"'))
expect(source).toContain("containerBookingId: [{ required: true, message: '请选择该订舱下已分配的柜次'")
expect(source.indexOf('label="订舱"')).toBeLessThan(source.indexOf('label="柜次"'))
expect(source).toContain(':disabled="!draft.oceanBookingId || bookingContainersLoading"')
expect(source).not.toContain('关联订舱(可选)')
expect(source).not.toContain('关联柜次(可选)')
})
it('loads only containers allocated to the selected booking and filters missing or malformed physical numbers', () => {
it('loads every container allocated to the selected booking without requiring a physical number', () => {
expect(source).toContain('getOceanBooking(bookingId)')
expect(source).toContain('containers.value = booking.containers || []')
expect(source).toContain('bookingDocuments.value = booking.documents || []')
expect(source).toContain('isIso6346ContainerNumber(item.containerNo)')
expect(source).toContain('该订舱暂无已录真实集装箱号的柜次')
expect(source).toContain('const selectableContainers = computed(() => containers.value)')
expect(source).toContain('该订舱暂无已分配柜次')
expect(source).not.toContain('isIso6346ContainerNumber')
expect(source).not.toContain('listContainers')
})
@@ -54,9 +55,10 @@ describe('TruckAppointmentForm interaction contract', () => {
expect(source).toContain('请选择有效的合作中拖车供应商')
})
it('labels the generated cabinet reference and physical container number separately', () => {
it('labels the internal cabinet, type and pickup-time physical number separately', () => {
expect(source).toContain('柜次号:${item.cabinetNo')
expect(source).toContain('集装箱号:${item.containerNo}')
expect(source).toContain('柜型:${item.containerType')
expect(source).toContain("集装箱号:${item.containerNo || '待补录'}")
})
it('keeps the form usable at 390px', () => {
@@ -7,7 +7,6 @@ import { listSuppliers, type Supplier } from '@/modules/suppliers'
import { getOceanBooking, listOceanBookings, type OceanBookingAllocatedContainer, type OceanBookingDocument, type OceanBookingResource } from '@/modules/ocean-bookings'
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; submitBlocked?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [SaveTruckAppointmentRequest]; submit: []; attachments: [] }>()
@@ -37,7 +36,7 @@ const rules: FormRules = {
if (!draft.billOfLadingNo?.trim()) return callback(new Error('所选订舱缺少有效提单号'))
return callback(bookingDocumentReady.value ? undefined : new Error('所选订舱缺少有效的订舱确认文件或提单文件'))
}, trigger: 'change' }],
containerBookingId: [{ required: true, message: '请选择该订舱下已录真实集装箱号的柜次', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableContainers.value.some((item) => item.containerBookingId === String(value)) || (props.readonly && selectedContainerFallback.value?.id === String(value)) ? undefined : new Error('请选择该订舱下有效的真实柜号柜次')), trigger: 'change' }],
containerBookingId: [{ required: true, message: '请选择该订舱下已分配的柜次', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableContainers.value.some((item) => item.containerBookingId === String(value)) || (props.readonly && selectedContainerFallback.value?.id === String(value)) ? undefined : new Error('请选择该订舱下有效的已分配柜次')), trigger: 'change' }],
carrierId: [{ required: true, message: '请选择承运商', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableCarriers.value.some((item) => String(item.id) === String(value)) ? undefined : new Error('请选择有效的合作中拖车供应商')), trigger: 'change' }],
plannedLoadingAt: [{ required: true, message: '请选择预计装箱时间', trigger: 'change' }],
fleetPhone: [phoneRule('车队电话')], driverPhone: [phoneRule('司机电话')], storePhone: [phoneRule('门店电话')],
@@ -54,14 +53,17 @@ const selectableCarriers = computed(() => carriers.value.filter((item) => (
)))
const selectedReleasedBooking = computed(() => bookings.value.find((item) => item.id === draft.oceanBookingId && item.status === 'RELEASED') || null)
const bookingDocumentReady = computed(() => bookingDocuments.value.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType)))
const selectableContainers = computed(() => containers.value.filter((item) => isIso6346ContainerNumber(item.containerNo)))
const selectableContainers = computed(() => containers.value)
function containerOptionLabel(item: OceanBookingAllocatedContainer) {
return `柜次号:${item.cabinetNo || '待生成'} / 柜型:${item.containerType || '—'} / 集装箱号:${item.containerNo || '待补录'}`
}
const selectedContainerFallback = computed(() => {
const id = draft.containerBookingId
const snapshot = props.resource
if (!id || selectableContainers.value.some((item) => String(item.containerBookingId) === String(id)) || String(snapshot?.containerBookingId) !== String(id)) return null
return {
id: String(id),
label: `柜次号:${snapshot?.cabinetNo || '待生成'} / 集装箱号:${snapshot?.containerNo || '待录入'}`,
label: `柜次号:${snapshot?.cabinetNo || '待生成'} / 柜型:${snapshot?.relatedContainerType || '—'} / 集装箱号:${snapshot?.containerNo || '待补录'}`,
}
})
const selectedBookingFallback = computed(() => {
@@ -154,13 +156,13 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !bookings.value.len
<template>
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form">
<div class="relation-note"><span>拖车预约必须先选择已放舱订舱,再选择该订舱下已录入真实 ISO 集装箱号的柜次;仓库和提单号由订舱冻结资料带入,最终资格仍由服务端校验。</span><div v-if="$slots['relation-note-actions']" class="relation-note__actions"><slot name="relation-note-actions" /></div></div>
<div class="relation-note"><span>拖车预约必须先选择已放舱订舱,再选择该订舱下已分配的内部柜次;真实集装箱号可待司机提柜时录入,仓库和提单号由订舱冻结资料带入,最终资格仍由服务端校验。</span><div v-if="$slots['relation-note-actions']" class="relation-note__actions"><slot name="relation-note-actions" /></div></div>
<el-alert v-if="relationWarning" type="warning" :closable="false" show-icon :title="relationWarning" class="relation-alert" />
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError" class="relation-alert"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
<el-row :gutter="16">
<el-col :xs="24"><el-divider content-position="left">订舱与柜次(必填)</el-divider></el-col>
<el-col class="relation-column" :xs="24" :sm="12"><el-form-item label="订舱" prop="oceanBookingId"><el-select :model-value="draft.oceanBookingId" filterable placeholder="请先选择已放舱订舱" :loading="optionsLoading" no-data-text="暂无可用的已放舱订舱" style="width:100%" @update:model-value="selectBooking(String($event || ''))"><el-option v-if="selectedBookingFallback" :key="selectedBookingFallback.id" :label="selectedBookingFallback.label" :value="selectedBookingFallback.id" /><el-option v-for="item in bookings" :key="item.id" :label="`${item.bookingNo || '订舱号缺失'}${item.billOfLadingNo ? ` / 提单号:${item.billOfLadingNo}` : ''}`" :value="item.id" /></el-select></el-form-item></el-col>
<el-col class="relation-column" :xs="24" :sm="12"><el-form-item label="柜次(真实集装箱号)" prop="containerBookingId"><el-select :model-value="draft.containerBookingId" filterable :disabled="!draft.oceanBookingId || bookingContainersLoading" placeholder="请先选择订舱,再选择真实柜号柜次" :loading="bookingContainersLoading" :no-data-text="draft.oceanBookingId ? '该订舱暂无已录真实集装箱号的柜次' : '请先选择订舱'" style="width:100%" @update:model-value="selectContainer(String($event || ''))"><el-option v-if="selectedContainerFallback" :key="selectedContainerFallback.id" :label="selectedContainerFallback.label" :value="selectedContainerFallback.id" :disabled="!readonly" /><el-option v-for="item in selectableContainers" :key="item.containerBookingId" :label="`柜次号:${item.cabinetNo || '待生成'} / 集装箱号:${item.containerNo}`" :value="item.containerBookingId" /></el-select><div v-if="bookingContainersError" class="field-error"><span>{{ bookingContainersError.message }}</span><ApiErrorMeta :details="bookingContainersError" /><el-button link type="primary" :loading="bookingContainersLoading" @click="draft.oceanBookingId && loadBookingContainers(String(draft.oceanBookingId))">重新加载</el-button></div><div v-else-if="draft.oceanBookingId && !bookingContainersLoading && !selectableContainers.length" class="field-hint">只有已分配至该订舱且已录入真实 ISO 集装箱号的柜次可以预约拖车。</div></el-form-item></el-col>
<el-col class="relation-column" :xs="24" :sm="12"><el-form-item label="柜次" prop="containerBookingId"><el-select :model-value="draft.containerBookingId" filterable :disabled="!draft.oceanBookingId || bookingContainersLoading" placeholder="请先选择订舱,再选择已分配柜次" :loading="bookingContainersLoading" :no-data-text="draft.oceanBookingId ? '该订舱暂无已分配柜次' : '请先选择订舱'" style="width:100%" @update:model-value="selectContainer(String($event || ''))"><el-option v-if="selectedContainerFallback" :key="selectedContainerFallback.id" :label="selectedContainerFallback.label" :value="selectedContainerFallback.id" :disabled="!readonly" /><el-option v-for="item in selectableContainers" :key="item.containerBookingId" :label="containerOptionLabel(item)" :value="item.containerBookingId" /></el-select><div v-if="bookingContainersError" class="field-error"><span>{{ bookingContainersError.message }}</span><ApiErrorMeta :details="bookingContainersError" /><el-button link type="primary" :loading="bookingContainersLoading" @click="draft.oceanBookingId && loadBookingContainers(String(draft.oceanBookingId))">重新加载</el-button></div><div v-else-if="draft.oceanBookingId && !bookingContainersLoading && !selectableContainers.length" class="field-hint">只有已分配至该订舱的内部柜次可以预约拖车。</div></el-form-item></el-col>
<el-col :xs="24"><el-divider content-position="left">基本信息</el-divider></el-col>
<el-col v-if="draft.oceanBookingId && !bookingContainersLoading && !bookingContainersError && !draft.billOfLadingNo?.trim()" :xs="24"><el-alert title="所选订舱缺少有效提单号,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon /></el-col>
<el-col v-if="draft.oceanBookingId && !bookingContainersLoading && !bookingContainersError && !bookingDocumentReady" :xs="24"><el-alert title="所选订舱缺少有效的订舱确认文件或提单文件,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon /></el-col>
@@ -41,10 +41,10 @@ describe('TruckAppointmentDetailView status contract', () => {
expect(source).toContain("error.value = getApiErrorDetails(err, '保存失败,请稍后重试')")
})
it('blocks dispatch until the released booking, allocated real container and frozen bill number are present', () => {
it('blocks dispatch until the released booking, allocated internal container and frozen documents are present', () => {
expect(source).toContain("return '确认派车前必须关联已放舱订舱'")
expect(source).toContain("return '确认派车前必须关联该订舱下的柜次'")
expect(source).toContain("return '确认派车前关联柜次必须录入真实 ISO 集装箱号'")
expect(source).not.toContain("return '确认派车前关联柜次必须录入真实 ISO 集装箱号'")
expect(source).toContain("return '确认派车前所选订舱必须填写提单号'")
expect(source).toContain("return '确认派车前所选订舱必须上传有效的订舱确认文件或提单文件'")
expect(source).toContain('getOceanBooking(resource.value.oceanBookingId)')
@@ -52,6 +52,16 @@ describe('TruckAppointmentDetailView status contract', () => {
expect(source).not.toContain("item.attachmentType === 'BILL_OF_LADING'")
})
it('collects a real ISO container number only when dispatched work starts pickup', () => {
expect(source).toContain("resource.value?.status === 'DISPATCHED' && status === 'PICKUP_IN_PROGRESS'")
expect(source).toContain("ElMessageBox.prompt('提柜后可填写,也可稍后在预装补录。'")
expect(source).toContain('inputValue: normalizeIso6346ContainerNumber(resource.value.containerNo)')
expect(source).toContain("inputValidator: (value) => !normalizeIso6346ContainerNumber(value) || isIso6346ContainerNumber(value) || '请输入有效的 ISO 6346 集装箱号'")
expect(source).toContain('return { containerNo: normalizeIso6346ContainerNumber(value) || null }')
expect(source).toContain('performTruckAppointmentAction(id.value, action, { expectedVersion: form.expectedVersion || 0, ...actionPayload })')
expect(source).toContain('await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`')
})
it('keeps the main detail available when attachments or history fail', () => {
expect(source).toContain('Promise.allSettled([')
expect(source).toContain("attachments.status === 'fulfilled'")
@@ -14,7 +14,7 @@ import { getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointm
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
import { TRUCK_APPOINTMENT_SAVE_FIELDS, normalizeTruckAppointmentCurrency, truckAppointmentActionLabel, truckAppointmentStatusLabel, truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus, type TruckAppointmentStatusHistory } from '../types'
import { isIso6346ContainerNumber } from '../iso6346'
import { isIso6346ContainerNumber, normalizeIso6346ContainerNumber } from '../iso6346'
const route = useRoute(); const router = useRouter(); const loading = ref(false); const saving = ref(false); const statusSaving = ref(false); const attachmentOpen = ref(false); const resource = ref<TruckAppointmentResource | null>(null); const error = ref<ApiErrorDetails | null>(null); const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, carrierId: null, oceanBookingId: null, containerBookingId: null, plannedLoadingAt: null })
const history = ref<TruckAppointmentStatusHistory[]>([])
const bookingReadiness = ref<OceanBookingDetail | null>(null)
@@ -51,15 +51,27 @@ const dispatchBlockReason = computed(() => {
if (!form.oceanBookingId) return '确认派车前必须关联已放舱订舱'
if (bookingReadiness.value && bookingReadiness.value.status !== 'RELEASED') return '确认派车前必须关联已放舱订舱'
if (!form.containerBookingId) return '确认派车前必须关联该订舱下的柜次'
if (!isIso6346ContainerNumber(resource.value.containerNo)) return '确认派车前关联柜次必须录入真实 ISO 集装箱号'
if (!form.billOfLadingNo?.trim()) return '确认派车前所选订舱必须填写提单号'
if (bookingReadiness.value && !bookingReadiness.value.documents.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))) return '确认派车前所选订舱必须上传有效的订舱确认文件或提单文件'
return ''
})
async function confirmStatusChange(status: TruckAppointmentStatus) {
if (resource.value?.status === 'DISPATCHED' && status === 'PICKUP_IN_PROGRESS') {
const { value } = await ElMessageBox.prompt('提柜后可填写,也可稍后在预装补录。', '开始提柜', {
confirmButtonText: '确认提柜', cancelButtonText: '取消', type: 'warning',
inputValue: normalizeIso6346ContainerNumber(resource.value.containerNo),
inputPlaceholder: '选填,例如 MSCU6639870',
inputValidator: (value) => !normalizeIso6346ContainerNumber(value) || isIso6346ContainerNumber(value) || '请输入有效的 ISO 6346 集装箱号',
})
return { containerNo: normalizeIso6346ContainerNumber(value) || null }
}
await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`, '状态变更确认', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' })
return {}
}
async function changeStatus(status: TruckAppointmentStatus) {
if (!resource.value || status === resource.value.status || statusSaving.value) return
if (status === 'DISPATCHED' && dispatchBlockReason.value) { error.value = { message: dispatchBlockReason.value }; return }
try { await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`, '状态变更确认', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }); statusSaving.value = true; if (status === 'RESCHEDULED') { await rescheduleTruckAppointment(id.value, form.plannedLoadingAt || '', form.expectedVersion || 0, '后台改约') } else { const action = actionByTarget[status]; if (!action) throw new Error('当前状态没有可用动作'); await performTruckAppointmentAction(id.value, action, form.expectedVersion || 0) } ElMessage.success('状态已更新'); await load() } catch (err) { if (err !== 'cancel' && !(err instanceof Error && err.message === 'cancel')) error.value = getApiErrorDetails(err, '状态更新失败,请稍后重试') } finally { statusSaving.value = false }
try { const actionPayload = await confirmStatusChange(status); statusSaving.value = true; if (status === 'RESCHEDULED') { await rescheduleTruckAppointment(id.value, form.plannedLoadingAt || '', form.expectedVersion || 0, '后台改约') } else { const action = actionByTarget[status]; if (!action) throw new Error('当前状态没有可用动作'); await performTruckAppointmentAction(id.value, action, { expectedVersion: form.expectedVersion || 0, ...actionPayload }) } ElMessage.success('状态已更新'); await load() } catch (err) { if (err !== 'cancel' && !(err instanceof Error && err.message === 'cancel')) error.value = getApiErrorDetails(err, '状态更新失败,请稍后重试') } finally { statusSaving.value = false }
}
onMounted(load)
onActivated(() => {
@@ -129,6 +129,7 @@ describe('TruckAppointmentsView', () => {
expect(source.indexOf("key: 'payableAmount'")).toBeLessThan(source.indexOf("key: 'plannedLoadingAt'"))
expect(source).not.toContain('receivableAmount')
expect(source).not.toContain('应收拖车费')
expect(source).toContain("row.containerNo || '待补录'")
expect(source).toContain("toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })")
expect(source).toContain("key: 'attachmentCount'")
})
@@ -29,7 +29,7 @@ const columns: DataTableColumn[] = [
{ key: 'appointmentNo', prop: 'appointmentNo', label: '派车单号', minWidth: 170 },
{ key: 'oceanBookingNo', prop: 'oceanBookingNo', label: '订舱号', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.oceanBookingNo || '—' },
{ key: 'cabinetNo', prop: 'cabinetNo', label: '柜次号', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.cabinetNo || '—' },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145, formatter: (row: TruckAppointmentResource) => row.containerNo || '—' },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145, formatter: (row: TruckAppointmentResource) => row.containerNo || '待补录' },
{ key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.warehouseName || '—' },
{ key: 'shippingCompanyName', prop: 'shippingCompanyName', label: '船公司', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.shippingCompanyName || '—' },
{ key: 'bookingAgentName', prop: 'bookingAgentName', label: '订舱代理', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.bookingAgentName || '—' },
+6
View File
@@ -198,6 +198,12 @@ export interface TruckAppointmentQuery {
containerType?: string | ''
}
export interface TruckAppointmentActionRequest {
expectedVersion: number
reason?: string | null
containerNo?: string | null
}
export interface TruckAppointmentPage {
records: TruckAppointmentResource[]
page: number