feat(admin): bind booking documents and truck containers

This commit is contained in:
2026-08-31 19:54:37 +08:00
parent 0f4f635686
commit a759eb1b6b
24 changed files with 691 additions and 179 deletions
@@ -18,8 +18,8 @@ describe('truck appointment API', () => {
vi.mocked(http.post).mockResolvedValue({ data: '9001' })
vi.mocked(http.post).mockResolvedValue({ data: { id: '9001', status: 'DISPATCHED' } })
await expect(getTruckAppointment('9001')).resolves.toMatchObject({ id: '9001' })
await expect(createTruckAppointment({ customerId: '100', carrierId: '200', oceanBookingId: null, containerBookingId: null, loadingPlanId: null, plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 })).resolves.toMatchObject({ id: '9001' })
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/truck-appointments', { customerId: '100', carrierId: '200', oceanBookingId: null, containerBookingId: null, loadingPlanId: null, plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 })
await expect(createTruckAppointment({ customerId: '100', 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', { customerId: '100', carrierId: '200', oceanBookingId: '300', containerBookingId: '400', loadingPlanId: null, plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 })
await expect(performTruckAppointmentAction('9001', 'DISPATCH', 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-') } })
})
+3 -2
View File
@@ -6,6 +6,7 @@ import type {
TruckAppointmentResource,
TruckAppointmentStatusHistory,
TruckAttachmentReference,
TruckAttachmentType,
} from '../types'
const basePath = '/platform/v1/truck-appointments'
@@ -71,12 +72,12 @@ export async function listTruckAppointmentAttachments(id: string | number) {
return unwrapApiResponse(response).map((item) => ({ ...item, id: item.id == null ? item.id : String(item.id), fileId: String(item.fileId), uploadedBy: item.uploadedBy == null ? item.uploadedBy : String(item.uploadedBy) }))
}
export async function referenceTruckAppointmentAttachment(id: string | number, payload: { fileId: string; attachmentType?: string | null; fileName?: string | null; fileHash?: string | null }) {
export async function referenceTruckAppointmentAttachment(id: string | number, payload: { fileId: string; attachmentType?: TruckAttachmentType | null; fileName?: string | null; fileHash?: string | null }) {
const response = await http.post<ApiEnvelope<TruckAttachmentReference>, ApiEnvelope<TruckAttachmentReference>>(`${basePath}/${id}/attachments`, payload)
return unwrapApiResponse(response)
}
export async function uploadTruckAppointmentAttachment(id: string | number, file: File, attachmentType?: string | null) {
export async function uploadTruckAppointmentAttachment(id: string | number, file: File, attachmentType?: TruckAttachmentType | null) {
const form = new FormData()
form.append('file', file)
if (attachmentType) form.append('attachmentType', attachmentType)
@@ -5,85 +5,52 @@ import { resolve } from 'node:path'
const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/components/TruckAppointmentForm.vue'), 'utf8')
describe('TruckAppointmentForm interaction contract', () => {
it('uses searchable business selectors for shared resources', () => {
expect(source).toContain('listPlatformTenants')
expect(source).toContain('listSuppliers')
expect(source).toContain('listContainers')
expect(source).toContain('listOceanBookings')
expect(source).toContain('listAllPlatformWarehouses')
expect(source).not.toContain('label="承运商 ID"')
expect(source).not.toContain('label="客户关联 ID"')
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
expect(source).toContain("listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' })")
expect(source).toContain('function customerLabel(customer: PlatformTenantResource) { return customer.tenantName }')
expect(source).not.toContain('customer.tenantCode')
it('requires a released booking before a real-numbered allocated 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(':disabled="!draft.oceanBookingId || bookingContainersLoading"')
expect(source).not.toContain('关联订舱(可选)')
expect(source).not.toContain('关联柜次(可选)')
})
it('synchronizes ids and names and recovers failed option loading', () => {
expect(source).toContain('selectCarrier')
expect(source).toContain("item.category === 'TRUCKING' && item.status === 'ACTIVE'")
expect(source).toContain('请选择有效的合作中拖车供应商')
expect(source).toContain('draft.carrierName')
expect(source).toContain('selectCustomer')
expect(source).toContain('draft.customerName')
expect(source).toContain('selectContainer')
expect(source).toContain('draft.loadingPlanId = item.planId || null')
expect(source).toContain('draft.loadingPlanId = null; draft.warehouseId = null')
expect(source).toContain("derivedCustomerSource.value === 'container'")
expect(source).toContain("derivedCustomerSource.value === 'booking'")
it('loads only containers allocated to the selected booking and filters missing or malformed physical numbers', () => {
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).not.toContain('listContainers')
})
it('clears an incompatible container whenever the booking changes', () => {
expect(source).toContain('draft.containerBookingId = null')
expect(source).toContain('draft.loadingPlanId = null')
expect(source).toContain('if (!selectableContainers.value.some((item) => item.containerBookingId === draft.containerBookingId))')
})
it('freezes customer, warehouse and bill of lading snapshots from the booking', () => {
expect(source).toContain('draft.customerId = item.customerId')
expect(source).toContain('draft.customerName = item.customerName')
expect(source).toContain('draft.warehouseId = item.warehouseId')
expect(source).toContain('draft.billOfLadingNo = item.billOfLadingNo')
expect(source).toContain('已从放舱订舱冻结资料带入')
expect(source).toContain(':model-value="draft.billOfLadingNo || \'\'" disabled')
})
it('keeps server-side validation authoritative and preserves recoverable option errors', () => {
expect(source).toContain('最终资格仍由服务端校验')
expect(source).toContain('所选订舱缺少有效的订舱确认文件或提单文件')
expect(source).toContain('bookingContainersError')
expect(source).toContain('重新加载选项')
})
it('keeps historical snapshots visible but requires active customer and carrier choices', () => {
expect(source).toContain("item.status === 'ACTIVE'")
expect(source).toContain("item.status === 'ACTIVE' || String(item.id) === String(draft.warehouseId)")
expect(source).toContain('v-for="item in selectableCustomers"')
expect(source).toContain('v-for="item in selectableWarehouses"')
expect(source).toContain("customerId: [{ required: true, message: '请选择客户'")
expect(source).toContain("carrierId: [{ required: true, message: '请选择承运商'")
expect(source).toContain("plannedLoadingAt: [{ required: true, message: '请选择预计装箱时间'")
expect(source).toContain("draft.customerName || '历史客户(需重新选择)'")
expect(source).toContain(':value="String(draft.customerId)" disabled')
expect(source).not.toContain('const relationText = computed')
})
it('loads readonly detail options and preserves relation snapshots outside the first page', () => {
expect(source).toContain('onMounted(() => { void loadOptions() })')
expect(source).toContain('const selectedContainerFallback = computed')
expect(source).toContain('const selectedBookingFallback = computed')
expect(source).toContain('const selectedWarehouseFallback = computed')
expect(source).toContain('v-if="selectedContainerFallback"')
expect(source).toContain('v-if="selectedBookingFallback"')
expect(source).toContain('v-if="selectedWarehouseFallback"')
})
it('keeps booking, cabinet and loading plan optional and only uses them for completion hints', () => {
expect(source).toContain('订舱、柜次和装柜计划均为可选关联,不影响独立开立拖车预约')
expect(source).toContain('class="relation-note"')
expect(source).toContain('<slot name="relation-note-actions" />')
expect(source).toContain('justify-content:space-between')
expect(source).not.toContain('class="relation-alert">历史关联快照')
expect(source).toContain('<el-divider content-position="left">可选关联</el-divider>')
expect(source).toContain('label="关联柜次(可选)"')
expect(source).toContain('label="关联订舱(可选)"')
expect(source).toContain("relationWarning.value = '所选关联资源与当前客户不一致")
expect(source).toContain("item.status === 'RELEASED'")
expect(source).toContain('已从放舱订舱回填并锁定')
expect(source).toContain(':disabled="Boolean(selectedReleasedBooking)"')
expect(source).toContain('草稿可暂不填写,派车前必填')
expect(source).not.toContain('尚未建立装柜计划')
})
it('gives editable fields clear placeholders', () => {
expect(source).toContain('placeholder="请输入司机姓名"')
expect(source).toContain('placeholder="请选择预计装箱时间"')
expect(source).toContain('placeholder="请输入目的门店地址"')
expect(source).toContain('placeholder="请输入拖车预约备注(可选)"')
expect(source).toContain('重新加载')
expect(source).toContain('请选择有效的合作中拖车供应商')
})
it('labels the generated cabinet reference and physical container number separately', () => {
expect(source).toContain('柜次号:${item.cabinetNo')
expect(source).toContain('集装箱号:${item.containerNo')
expect(source).toContain('集装箱号:${item.containerNo}')
})
it('keeps the form usable at 390px', () => {
@@ -3,18 +3,17 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { Eye, Save } from '@lucide/vue'
import type { SaveTruckAppointmentRequest, TruckAppointmentResource } from '../types'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listSuppliers, type Supplier } from '@/modules/suppliers'
import { listContainers, type ContainerBookingResource } from '@/modules/container-loading'
import { listOceanBookings, type OceanBookingResource } from '@/modules/ocean-bookings'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { getOceanBooking, listOceanBookings, type OceanBookingAllocatedContainer, type OceanBookingDocument, type OceanBookingResource } from '@/modules/ocean-bookings'
import { isIso6346ContainerNumber } from '../iso6346'
const props = defineProps<{ modelValue: SaveTruckAppointmentRequest; resource?: TruckAppointmentResource | null; readonly?: boolean; submitting?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [SaveTruckAppointmentRequest]; submit: []; attachments: [] }>()
const formRef = ref<FormInstance>()
const relationWarning = ref('')
const derivedCustomerSource = ref<'booking' | 'container' | null>(null)
const billOfLadingFromBooking = ref(false)
const bookingContainersLoading = ref(false)
const bookingContainersError = ref('')
const bookingDocuments = ref<OceanBookingDocument[]>([])
const defaults: Partial<SaveTruckAppointmentRequest> = { expectedVersion: 0, currency: 'RMB', payableAmount: '0.00', receivableAmount: '0.00', packageCount: 0, palletCount: 0, transitMinutes: 0 }
function normalizeDraft(value: SaveTruckAppointmentRequest): SaveTruckAppointmentRequest { return { ...defaults, ...value, currency: value.currency?.trim().toUpperCase() || 'RMB' } }
const draft = reactive<SaveTruckAppointmentRequest>(normalizeDraft(props.modelValue))
@@ -30,7 +29,13 @@ const phoneRule = (label: string) => ({ validator: (_rule: unknown, value: unkno
else callback(new Error(`${label}格式不正确`))
}, trigger: 'blur' })
const rules: FormRules = {
customerId: [{ required: true, message: '请选择客户', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableCustomers.value.some((item) => String(item.id) === String(value)) ? undefined : new Error('请选择有效的启用客户')), trigger: 'change' }],
oceanBookingId: [{ required: true, message: '请选择已放舱订舱', trigger: 'change' }, { validator: (_rule, value, callback) => {
if (!(bookings.value.some((item) => item.id === String(value) && item.status === 'RELEASED') || selectedBookingFallback.value?.id === String(value))) return callback(new Error('请选择有效的已放舱订舱'))
if (bookingContainersError.value) 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' }],
customerId: [{ required: true, message: '所选订舱缺少客户信息', 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('门店电话')],
@@ -38,25 +43,21 @@ const rules: FormRules = {
grossWeightKg: [nonNegative('毛重')], volumeCbm: [nonNegative('体积')], payableAmount: [nonNegative('应付拖车费')], receivableAmount: [nonNegative('应收拖车费')],
currency: [{ pattern: /^[A-Z]{3,8}$/, message: '币种请输入 3-8 位大写字母(如 RMB)', trigger: 'blur' }],
}
const customers = ref<PlatformTenantResource[]>([])
const carriers = ref<Supplier[]>([])
const containers = ref<ContainerBookingResource[]>([])
const containers = ref<OceanBookingAllocatedContainer[]>([])
const bookings = ref<OceanBookingResource[]>([])
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
const optionsLoading = ref(false)
const optionsError = ref('')
const selectableCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
const selectableWarehouses = computed(() => warehouses.value.filter((item) => (
item.status === 'ACTIVE' || String(item.id) === String(draft.warehouseId)
)))
const selectableCarriers = computed(() => carriers.value.filter((item) => (
item.status === 'ACTIVE'
)))
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 selectedContainerFallback = computed(() => {
const id = draft.containerBookingId
const snapshot = props.resource
if (!id || containers.value.some((item) => String(item.id) === String(id)) || String(snapshot?.containerBookingId) !== String(id)) return null
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 || '待录入'}`,
@@ -68,93 +69,96 @@ const selectedBookingFallback = computed(() => {
if (!id || bookings.value.some((item) => String(item.id) === String(id)) || String(snapshot?.oceanBookingId) !== String(id)) return null
return { id: String(id), label: snapshot?.oceanBookingNo || '订舱号缺失' }
})
const selectedWarehouseFallback = computed(() => {
const id = draft.warehouseId
const snapshot = props.resource
if (!id || selectableWarehouses.value.some((item) => String(item.id) === String(id)) || String(snapshot?.warehouseId) !== String(id)) return null
return { id: String(id), label: snapshot?.warehouseName || '当前仓库(需重新选择)' }
})
function customerLabel(customer: PlatformTenantResource) { return customer.tenantName }
function selectCustomer(customerId: string) { const customer = selectableCustomers.value.find((item) => String(item.id) === customerId); draft.customerId = customer?.id ? String(customer.id) : null; draft.customerName = customer ? customerLabel(customer) : null; derivedCustomerSource.value = null; relationWarning.value = '' }
function selectCarrier(carrierId: string) { const carrier = selectableCarriers.value.find((item) => String(item.id) === carrierId); draft.carrierId = carrier?.id || null; draft.carrierName = carrier?.name || null }
function applyRelatedCustomer(customerId: string | null | undefined, customerName: string | null | undefined, source: 'booking' | 'container') {
if (!customerId) return
if (!draft.customerId) { draft.customerId = String(customerId); draft.customerName = customerName || null; derivedCustomerSource.value = source; return }
if (String(draft.customerId) !== String(customerId)) relationWarning.value = '所选关联资源与当前客户不一致,请重新选择客户或清空该关联'
}
function selectContainer(containerId: string) {
const item = containers.value.find((entry) => entry.id === containerId)
draft.containerBookingId = item?.id || null
const item = selectableContainers.value.find((entry) => entry.containerBookingId === containerId)
draft.containerBookingId = item?.containerBookingId || null
relationWarning.value = ''
if (!item) {
draft.loadingPlanId = null; draft.warehouseId = null
if (derivedCustomerSource.value === 'container') { draft.customerId = null; draft.customerName = null; derivedCustomerSource.value = null }
draft.loadingPlanId = null
return
}
draft.loadingPlanId = item.planId || null; draft.warehouseId = item.warehouseId || null
applyRelatedCustomer(item.customerId, item.customerName, 'container')
draft.loadingPlanId = null
}
function selectBooking(bookingId: string) {
async function loadBookingContainers(bookingId: string) {
bookingContainersLoading.value = true
bookingContainersError.value = ''
try {
const booking = await getOceanBooking(bookingId)
if (!bookings.value.some((item) => item.id === booking.id)) bookings.value.push(booking)
containers.value = booking.containers || []
bookingDocuments.value = booking.documents || []
if (!selectableContainers.value.some((item) => item.containerBookingId === draft.containerBookingId)) {
draft.containerBookingId = null
draft.loadingPlanId = null
}
} catch {
containers.value = []
bookingDocuments.value = []
draft.containerBookingId = null
draft.loadingPlanId = null
bookingContainersError.value = '该订舱柜次加载失败,请重试'
} finally {
bookingContainersLoading.value = false
}
}
async function selectBooking(bookingId: string) {
const item = bookings.value.find((entry) => entry.id === bookingId)
const previouslyDerivedBillOfLading = billOfLadingFromBooking.value
draft.oceanBookingId = item?.id || null
draft.containerBookingId = null
draft.loadingPlanId = null
containers.value = []
bookingDocuments.value = []
relationWarning.value = ''
if (!item) {
if (billOfLadingFromBooking.value) draft.billOfLadingNo = null
billOfLadingFromBooking.value = false
if (derivedCustomerSource.value === 'booking') { draft.customerId = null; draft.customerName = null; derivedCustomerSource.value = null }
draft.customerId = null
draft.customerName = null
draft.warehouseId = null
draft.billOfLadingNo = null
return
}
applyRelatedCustomer(item.customerId, item.customerName, 'booking')
if (item.status === 'RELEASED') {
if (draft.billOfLadingNo && draft.billOfLadingNo !== item.billOfLadingNo) relationWarning.value = '已放舱订舱的提单号与当前填写值不一致,已使用订舱冻结快照'
draft.billOfLadingNo = item.billOfLadingNo || null
billOfLadingFromBooking.value = true
} else {
if (previouslyDerivedBillOfLading) draft.billOfLadingNo = null
billOfLadingFromBooking.value = false
}
draft.customerId = item.customerId
draft.customerName = item.customerName
draft.warehouseId = item.warehouseId
draft.billOfLadingNo = item.billOfLadingNo
await loadBookingContainers(item.id)
}
async function loadOptions() {
optionsLoading.value = true; optionsError.value = ''
const results = await Promise.allSettled([
listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }),
listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' }),
listContainers({ page: 1, size: 200 }),
listOceanBookings({ page: 1, size: 200 }),
listAllPlatformWarehouses(),
listOceanBookings({ page: 1, size: 200, status: 'RELEASED' }),
])
if (results[0].status === 'fulfilled') customers.value = results[0].value.records.filter((customer) => customer.status === 'ACTIVE')
if (results[1].status === 'fulfilled') carriers.value = results[1].value.records.filter((item) => item.category === 'TRUCKING' && item.status === 'ACTIVE')
if (results[2].status === 'fulfilled') containers.value = results[2].value.records
if (results[3].status === 'fulfilled') bookings.value = results[3].value.records
if (results[4].status === 'fulfilled') warehouses.value = results[4].value
const failed = ['客户', '承运商', '柜次', '订舱', '仓库'].filter((_, index) => results[index]?.status === 'rejected')
if (results[0].status === 'fulfilled') carriers.value = results[0].value.records.filter((item) => item.category === 'TRUCKING' && item.status === 'ACTIVE')
if (results[1].status === 'fulfilled') bookings.value = results[1].value.records.filter((item) => item.status === 'RELEASED')
const failed = ['承运商', '已放舱订舱'].filter((_, index) => results[index]?.status === 'rejected')
if (failed.length) optionsError.value = `${failed.join('、')}选项加载失败`
optionsLoading.value = false
if (draft.oceanBookingId) await loadBookingContainers(String(draft.oceanBookingId))
}
async function validate() { return formRef.value?.validate() }
async function submitForm() { if (await formRef.value?.validate()) emit('submit') }
defineExpose({ validate })
onMounted(() => { void loadOptions() })
watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.length) void loadOptions() })
watch(() => props.readonly, (readonly) => { if (!readonly && !bookings.value.length) void loadOptions() })
</script>
<template>
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form">
<div class="relation-note"><span>订舱、柜次和装柜计划均为可选关联,不影响独立开立拖车预约;选择后仅用于补全快照和一致性检查。</span><div v-if="$slots['relation-note-actions']" class="relation-note__actions"><slot name="relation-note-actions" /></div></div>
<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>
<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 :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 :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 }}</span><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 :xs="24"><el-divider content-position="left">基本信息</el-divider></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="客户" prop="customerId"><el-select :model-value="draft.customerId" filterable clearable placeholder="请选择客户" :loading="optionsLoading" no-data-text="暂无启用的客户" style="width:100%" @update:model-value="selectCustomer(String($event || ''))"><el-option v-if="draft.customerId && !selectableCustomers.some((item) => String(item.id) === String(draft.customerId))" :label="draft.customerName || '历史客户(需重新选择)'" :value="String(draft.customerId)" disabled /><el-option v-for="item in selectableCustomers" :key="String(item.id)" :label="customerLabel(item)" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col v-if="draft.oceanBookingId && !bookingContainersLoading && !bookingContainersError && !bookingDocumentReady" :xs="24"><el-alert title="所选订舱缺少有效的订舱确认文件或提单文件,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon /></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="客户" prop="customerId"><el-input :model-value="draft.customerName || ''" disabled placeholder="选择订舱后自动带入" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="承运商" prop="carrierId"><el-select :model-value="draft.carrierId" filterable clearable placeholder="请选择承运商" :loading="optionsLoading" no-data-text="暂无可用拖车供应商" style="width:100%" @update:model-value="selectCarrier(String($event || ''))"><el-option v-if="draft.carrierId && !selectableCarriers.some((item) => String(item.id) === String(draft.carrierId))" :label="draft.carrierName || '当前承运商(需重新选择)'" :value="String(draft.carrierId)" disabled /><el-option v-for="item in selectableCarriers" :key="String(item.id)" :label="item.name" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="预计装箱时间" prop="plannedLoadingAt"><el-date-picker v-model="draft.plannedLoadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计装箱时间" style="width:100%" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="提单号"><el-input v-model="draft.billOfLadingNo" :disabled="Boolean(selectedReleasedBooking)" maxlength="128" placeholder="草稿可暂不填写,派车前必填" /><div v-if="selectedReleasedBooking" class="field-hint">已从放舱订舱回填并锁定</div></el-form-item></el-col>
<el-col :xs="24"><el-divider content-position="left">可选关联</el-divider></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="关联柜次(可选)"><el-select :model-value="draft.containerBookingId" filterable clearable placeholder="可选择柜次辅助补全" :loading="optionsLoading" no-data-text="暂无可关联柜次" style="width:100%" @update:model-value="selectContainer(String($event || ''))"><el-option v-if="selectedContainerFallback" :key="selectedContainerFallback.id" :label="selectedContainerFallback.label" :value="selectedContainerFallback.id" /><el-option v-for="item in containers" :key="item.id" :label="`柜次号:${item.cabinetNo || item.bookingNo || '待生成'} / 集装箱号:${item.containerNo || '待录入'}`" :value="item.id" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="关联订舱(可选)"><el-select :model-value="draft.oceanBookingId" filterable clearable 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 || '订舱号缺失'" :value="item.id" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="仓库"><el-select v-model="draft.warehouseId" filterable clearable placeholder="请选择仓库" :loading="optionsLoading" style="width:100%"><el-option v-if="selectedWarehouseFallback" :key="selectedWarehouseFallback.id" :label="selectedWarehouseFallback.label" :value="selectedWarehouseFallback.id" /><el-option v-for="item in selectableWarehouses" :key="String(item.id)" :label="`${item.warehouseName}(${item.warehouseCode})`" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="提单号"><el-input :model-value="draft.billOfLadingNo || ''" disabled placeholder="选择订舱后自动带入" /><div v-if="selectedReleasedBooking" class="field-hint">已从放舱订舱冻结资料带入</div></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="仓库"><el-input :model-value="selectedReleasedBooking?.warehouseName || resource?.warehouseName || ''" disabled placeholder="选择订舱后自动带入" /></el-form-item></el-col>
<el-col v-if="resource" :xs="24" :sm="12"><el-form-item label="派车单号"><el-input :model-value="resource.appointmentNo || ''" disabled /></el-form-item></el-col>
<el-col :xs="24"><el-divider content-position="left">运输与时间计划</el-divider></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="车队联系人"><el-input v-model="draft.fleetContact" placeholder="请输入车队联系人" /></el-form-item></el-col>
@@ -198,7 +202,7 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.le
:deep(.el-divider) { margin: 14px 0 10px; }
:deep(.el-divider__text) { color: var(--el-text-color-primary); font-size: 14px; font-weight: 600; }
:deep(.el-input), :deep(.el-textarea), :deep(.el-date-editor) { max-width: 100%; }
.field-hint { margin-top:4px; color:var(--cc-muted); font-size:12px; }
.field-error,.field-hint { display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:4px;color:var(--cc-muted);font-size:12px;line-height:20px }.field-error{color:var(--el-color-danger)}
@media (max-width: 480px) {
.truck-form { padding-inline: 2px; }
.relation-note { align-items:flex-start; flex-direction:column; }
@@ -13,7 +13,8 @@ describe('TruckAttachmentDialog interaction governance', () => {
})
it('provides scoped upload and deletion without exposing manual file ids', () => {
expect(source).toContain("BILL_OF_LADING: '提单文件'")
expect(source).not.toContain("BILL_OF_LADING: '提单文件'")
expect(source).toContain("DISPATCH_ORDER: '派车单'")
expect(source).not.toContain('referenceTruckAppointmentAttachment')
expect(source).not.toContain('输入文件服务 ID')
expect(source).toContain('uploadTruckAppointmentAttachment')
@@ -3,14 +3,14 @@ import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { FileUp, Trash2 } from '@lucide/vue'
import { deleteTruckAppointmentAttachment, uploadTruckAppointmentAttachment } from '../api/platformTrucking'
import type { TruckAttachmentReference } from '../types'
import type { TruckAttachmentReference, TruckAttachmentType } from '../types'
const props = defineProps<{ modelValue: boolean; appointmentId?: string; attachments: TruckAttachmentReference[]; canManage?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [boolean]; changed: [] }>()
const busy = ref(false)
const selectedFile = ref<File | null>(null)
const selectedType = ref('OTHER')
const attachmentTypeLabels: Record<string, string> = { BILL_OF_LADING: '提单文件', DISPATCH_ORDER: '派车单', DRIVER_DOCUMENT: '司机证件', VEHICLE_DOCUMENT: '车辆证件', OTHER: '其他附件' }
function attachmentTypeLabel(value: string | null | undefined) { return value ? attachmentTypeLabels[value] || '业务附件' : '业务附件' }
const selectedType = ref<TruckAttachmentType>('OTHER')
const attachmentTypeLabels: Record<TruckAttachmentType, string> = { DISPATCH_ORDER: '派车单', DRIVER_DOCUMENT: '司机证件', VEHICLE_DOCUMENT: '车辆证件', OTHER: '其他附件' }
function attachmentTypeLabel(value: string | null | undefined) { return value && value in attachmentTypeLabels ? attachmentTypeLabels[value as TruckAttachmentType] : '业务附件' }
function selectFile(upload: { raw?: File }) { selectedFile.value = upload.raw || null }
async function upload() { if (!props.appointmentId || !selectedFile.value) return; busy.value = true; try { await uploadTruckAppointmentAttachment(props.appointmentId, selectedFile.value, selectedType.value); selectedFile.value = null; emit('changed'); ElMessage.success('附件上传成功') } catch { ElMessage.error('上传失败') } finally { busy.value = false } }
async function remove(item: TruckAttachmentReference) { if (!props.appointmentId || !item.id) return; await ElMessageBox.confirm('确认删除该附件引用?', '删除确认', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }); busy.value = true; try { await deleteTruckAppointmentAttachment(props.appointmentId, item.id); emit('changed'); ElMessage.success('附件引用已删除') } catch { ElMessage.error('删除失败') } finally { busy.value = false } }
+12
View File
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { isIso6346ContainerNumber, normalizeIso6346ContainerNumber } from './iso6346'
describe('ISO 6346 container numbers', () => {
it('normalizes whitespace and validates the equipment category and check digit', () => {
expect(normalizeIso6346ContainerNumber(' mscu 663987 0 ')).toBe('MSCU6639870')
expect(isIso6346ContainerNumber('MSCU6639870')).toBe(true)
expect(isIso6346ContainerNumber('MSCA6639870')).toBe(false)
expect(isIso6346ContainerNumber('MSCU6639871')).toBe(false)
expect(isIso6346ContainerNumber('MSCU663987')).toBe(false)
})
})
+17
View File
@@ -0,0 +1,17 @@
const letterValues = [10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38]
export function normalizeIso6346ContainerNumber(value: string | null | undefined) {
return value?.replace(/[\s\u00a0\u3000]+/g, '').toUpperCase() || ''
}
export function isIso6346ContainerNumber(value: string | null | undefined) {
const normalized = normalizeIso6346ContainerNumber(value)
if (!/^[A-Z]{3}[UJZ][0-9]{7}$/.test(normalized)) return false
let sum = 0
for (let index = 0; index < 10; index += 1) {
const current = normalized[index]!
const number = /[0-9]/.test(current) ? Number(current) : letterValues[current.charCodeAt(0) - 65]!
sum += number * (2 ** index)
}
return Number(normalized[10]) === (sum % 11) % 10
}
@@ -4,8 +4,8 @@ import { resolve } from 'node:path'
const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/pages/TruckAppointmentCreateView.vue'), 'utf8')
describe('TruckAppointmentCreateView independent creation contract', () => {
it('starts with optional associations cleared and keeps required business fields explicit', () => {
describe('TruckAppointmentCreateView booking-bound creation contract', () => {
it('starts with required booking and container choices empty until the user selects them', () => {
expect(source).toContain('customerId: null, carrierId: null')
expect(source).toContain('oceanBookingId: null, containerBookingId: null, loadingPlanId: null')
expect(source).toContain('plannedLoadingAt: null')
@@ -32,29 +32,32 @@ describe('TruckAppointmentDetailView status contract', () => {
expect(source).toContain("statuses.indexOf(status) === index")
})
it('initializes independently required fields without inventing associations', () => {
expect(source).toContain("customerId: null, carrierId: null, plannedLoadingAt: null")
it('initializes the required booking and container associations explicitly', () => {
expect(source).toContain("customerId: null, carrierId: null, oceanBookingId: null, containerBookingId: null, plannedLoadingAt: null")
expect(source).toContain("error.value = getApiErrorDetails(err, '保存失败,请稍后重试')")
})
it('blocks dispatch until a bill number and bill attachment are present', () => {
expect(source).toContain("return '确认派车前请先填写提单号'")
expect(source).toContain("item.attachmentType === 'BILL_OF_LADING'")
expect(source).toContain("return '确认派车前请上传“提单文件”附件'")
it('blocks dispatch until the released booking, allocated real container and frozen bill number are present', () => {
expect(source).toContain("return '确认派车前必须关联已放舱订舱'")
expect(source).toContain("return '确认派车前必须关联该订舱下的柜次'")
expect(source).toContain("return '确认派车前关联柜次必须录入真实 ISO 集装箱号'")
expect(source).toContain("return '确认派车前所选订舱必须填写提单号'")
expect(source).toContain("return '确认派车前所选订舱必须上传有效的订舱确认文件或提单文件'")
expect(source).toContain('getOceanBooking(resource.value.oceanBookingId)')
expect(source).toContain("status === 'DISPATCHED' && dispatchBlockReason.value")
expect(source).toContain('attachmentOpen.value = true')
expect(source).not.toContain("item.attachmentType === 'BILL_OF_LADING'")
})
it('keeps the main detail available when attachments or history fail', () => {
expect(source).toContain('Promise.allSettled([')
expect(source).toContain("attachments.status === 'fulfilled'")
expect(source).toContain("histories.status === 'fulfilled'")
expect(source).toContain('主详情数据不受影响')
expect(source).toContain('主详情数据不受影响;派车时仍由服务端最终校验')
expect(source).toContain('v-if="relatedError"')
})
it('reloads a cached detail after an error without showing stale resource data', () => {
expect(source).toContain("resource.value = null; history.value = []; relatedError.value = ''")
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()')
})
@@ -9,12 +9,15 @@ import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getOceanBooking, type OceanBookingDetail } from '@/modules/ocean-bookings'
import { getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentHistory, performTruckAppointmentAction, rescheduleTruckAppointment, updateTruckAppointment, type TruckAppointmentAction } from '../api/platformTrucking'
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
import { truckAppointmentActionLabel, truckAppointmentStatusLabel, truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus, type TruckAppointmentStatusHistory } from '../types'
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, customerId: null, carrierId: null, plannedLoadingAt: null })
import { isIso6346ContainerNumber } 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, customerId: null, carrierId: null, oceanBookingId: null, containerBookingId: null, plannedLoadingAt: null })
const history = ref<TruckAppointmentStatusHistory[]>([])
const bookingReadiness = ref<OceanBookingDetail | null>(null)
const relatedError = ref('')
let activatedOnce = false
const access = useAccessPreviewStore(); const editing = computed(() => route.query.edit === '1'); const id = computed(() => String(route.params.id)); const canEdit = computed(() => access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value?.status || '') && editing.value); const canChangeStatus = computed(() => access.can('platform:firstleg:truck-appointment:operate')); const canManageAttachments = computed(() => access.can('platform:firstleg:truck-appointment:edit'))
@@ -22,15 +25,17 @@ const relationSnapshot = computed(() => resource.value ? [resource.value.custome
async function loadRelated() {
if (!resource.value) return
relatedError.value = ''
const [attachments, histories] = await Promise.allSettled([
const [attachments, histories, booking] = await Promise.allSettled([
listTruckAppointmentAttachments(id.value),
listTruckAppointmentHistory(id.value),
resource.value.oceanBookingId ? getOceanBooking(resource.value.oceanBookingId) : Promise.reject(new Error('拖车预约缺少订舱关联')),
])
if (attachments.status === 'fulfilled') resource.value.attachments = attachments.value
if (histories.status === 'fulfilled') history.value = histories.value
if (attachments.status === 'rejected' || histories.status === 'rejected') relatedError.value = '附件或状态历史暂时未能加载,主详情数据不受影响,可稍后刷新重试。'
if (booking.status === 'fulfilled') bookingReadiness.value = booking.value
if (attachments.status === 'rejected' || histories.status === 'rejected' || booking.status === 'rejected') relatedError.value = '附件、状态历史或订舱资料状态暂时未能加载,主详情数据不受影响;派车时仍由服务端最终校验。'
}
async function load() { loading.value = true; error.value = null; resource.value = null; history.value = []; relatedError.value = ''; try { const result = await getTruckAppointment(id.value); resource.value = result; Object.assign(form, { ...result, expectedVersion: result.version ?? 0 }); await loadRelated() } catch (err) { error.value = getApiErrorDetails(err, '详情加载失败,请稍后重试') } finally { loading.value = false } }
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; Object.assign(form, { ...result, expectedVersion: result.version ?? 0 }); await loadRelated() } catch (err) { error.value = getApiErrorDetails(err, '详情加载失败,请稍后重试') } finally { loading.value = false } }
async function submit() { 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' }
@@ -43,13 +48,17 @@ const availableTargets = computed<TruckAppointmentStatus[]>(() => {
})
const dispatchBlockReason = computed(() => {
if (!resource.value || !['PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value.status)) return ''
if (!form.billOfLadingNo?.trim()) return '确认派车前请先填写提单号'
if (!(resource.value.attachments || []).some((item) => item.attachmentType === 'BILL_OF_LADING')) return '确认派车前请上传“提单文件”附件'
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 changeStatus(status: TruckAppointmentStatus) {
if (!resource.value || status === resource.value.status) return
if (status === 'DISPATCHED' && dispatchBlockReason.value) { error.value = { message: dispatchBlockReason.value }; if (!form.billOfLadingNo?.trim()) return; attachmentOpen.value = true; 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 }
}
onMounted(load)
+5 -3
View File
@@ -52,11 +52,13 @@ export function truckAppointmentActionLabel(action: string | null | undefined) {
return truckAppointmentActionLabels[action] || '状态更新'
}
export type TruckAttachmentType = 'DISPATCH_ORDER' | 'DRIVER_DOCUMENT' | 'VEHICLE_DOCUMENT' | 'OTHER'
export interface TruckAttachmentReference {
id?: string
fileId: string
fileName: string
attachmentType?: string | null
attachmentType?: TruckAttachmentType | null
fileHash?: string | null
visibility?: string | null
uploadedBy?: string | null
@@ -139,8 +141,8 @@ export interface TruckAppointmentResource {
export interface SaveTruckAppointmentRequest {
outboundOrderId?: string | null
oceanBookingId?: string | null
containerBookingId?: string | null
oceanBookingId: string | null
containerBookingId: string | null
loadingPlanId?: string | null
warehouseId?: string | null
customerId: string | null