feat(admin): remove customer from truck appointments

This commit is contained in:
2026-08-31 21:05:03 +08:00
parent 49babd731a
commit 66c6dfdadc
14 changed files with 234 additions and 51 deletions
@@ -18,12 +18,21 @@ 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: '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(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', 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-') } })
})
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, customerId: 'legacy-customer', customerName: '旧客户快照' }
await createTruckAppointment(payload as never)
expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments', {
carrierId: '200', oceanBookingId: '300', containerBookingId: '400', plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0,
})
})
it('sends the required idempotency header for rescheduling', async () => {
vi.mocked(http.post).mockResolvedValue({ data: { id: '9001', status: 'RESCHEDULED' } })
await expect(rescheduleTruckAppointment('9001', '2026-08-20T10:00:00', 2, '调整时间')).resolves.toMatchObject({ status: 'RESCHEDULED' })
+6 -2
View File
@@ -1,4 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { TRUCK_APPOINTMENT_SAVE_FIELDS } from '../types'
import type {
SaveTruckAppointmentRequest,
TruckAppointmentPage,
@@ -15,7 +16,10 @@ function operationId(action: string, id: string | number) {
}
function dtoPayload(payload: SaveTruckAppointmentRequest) {
return Object.fromEntries(Object.entries(payload).map(([key, value]) => [key, value === '' ? null : value])) as SaveTruckAppointmentRequest
return Object.fromEntries(TRUCK_APPOINTMENT_SAVE_FIELDS.flatMap((key) => {
const value = payload[key]
return value === undefined ? [] : [[key, value === '' ? null : value]]
})) as unknown as SaveTruckAppointmentRequest
}
type PagePayload = Omit<TruckAppointmentPage, 'page' | 'size' | 'total' | 'pages'> & {
@@ -33,7 +37,7 @@ function normalizePage(payload: PagePayload): TruckAppointmentPage {
}
function normalizeResource(resource: TruckAppointmentResource): TruckAppointmentResource {
const idKeys = ['id', 'outboundOrderId', 'oceanBookingId', 'containerBookingId', 'loadingPlanId', 'warehouseId', 'customerId', 'shippingCompanyId', 'bookingAgentId', 'carrierId']
const idKeys = ['id', 'outboundOrderId', 'oceanBookingId', 'containerBookingId', 'loadingPlanId', 'warehouseId', 'shippingCompanyId', 'bookingAgentId', 'carrierId']
const normalized = { ...resource } as Record<string, unknown>
for (const key of idKeys) {
if (normalized[key] !== null && normalized[key] !== undefined) normalized[key] = String(normalized[key])
@@ -0,0 +1,125 @@
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 TruckAppointmentForm from './TruckAppointmentForm.vue'
import type { SaveTruckAppointmentRequest } from '../types'
import { getOceanBooking, listOceanBookings } from '@/modules/ocean-bookings'
vi.mock('@/modules/suppliers', () => ({
listSuppliers: vi.fn().mockResolvedValue({ records: [{ id: 'carrier-1', name: '平台拖车公司', category: 'TRUCKING', status: 'ACTIVE' }] }),
}))
vi.mock('@/modules/ocean-bookings', () => ({
listOceanBookings: vi.fn(),
getOceanBooking: vi.fn(),
}))
const model: SaveTruckAppointmentRequest = {
expectedVersion: 0,
oceanBookingId: null,
containerBookingId: null,
loadingPlanId: null,
carrierId: null,
plannedLoadingAt: null,
}
const bookings = [
{ id: 'booking-1', bookingNo: 'OB-1', status: 'RELEASED', billOfLadingNo: 'BL-1', warehouseId: 'WH-1', warehouseName: '义乌仓' },
{ id: 'booking-2', bookingNo: 'OB-2', status: 'RELEASED', billOfLadingNo: 'BL-2', warehouseId: 'WH-2', warehouseName: '东莞仓' },
{ id: 'booking-missing-bill', bookingNo: 'OB-3', status: 'RELEASED', billOfLadingNo: null, warehouseId: 'WH-3', warehouseName: '宁波仓' },
]
function detail(id: string) {
const booking = bookings.find((item) => item.id === id)!
return {
...booking,
documents: [{ id: `doc-${id}`, documentType: 'BILL_OF_LADING', valid: true }],
containers: id === 'booking-1'
? [
{ containerBookingId: 'container-1', cabinetNo: 'CAB-1', containerNo: 'CBHU8888883' },
{ containerBookingId: 'container-invalid', cabinetNo: 'CAB-X', containerNo: '错误柜号' },
]
: [{ containerBookingId: 'container-2', cabinetNo: 'CAB-2', containerNo: 'MSCU6639870' }],
}
}
describe('TruckAppointmentForm booking-only behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(listOceanBookings).mockResolvedValue({ records: bookings } as never)
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 () => {
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
await flushPromises()
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
selects[0]!.vm.$emit('update:modelValue', 'booking-1')
await flushPromises()
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)
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
expect(emitted.oceanBookingId).toBe('booking-1')
expect(emitted.billOfLadingNo).toBe('BL-1')
expect(emitted).not.toHaveProperty('customerId')
expect(emitted).not.toHaveProperty('customerName')
wrapper.unmount()
})
it('clears a selected container when switching to another booking', async () => {
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
await flushPromises()
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
selects[0]!.vm.$emit('update:modelValue', 'booking-1')
await flushPromises()
selects[1]!.vm.$emit('update:modelValue', 'container-1')
await flushPromises()
expect((wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest).containerBookingId).toBe('container-1')
selects[0]!.vm.$emit('update:modelValue', 'booking-2')
await flushPromises()
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
expect(emitted.oceanBookingId).toBe('booking-2')
expect(emitted.containerBookingId).toBeNull()
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || ''))
expect(labels.some((label) => label.includes('CAB-2'))).toBe(true)
wrapper.unmount()
})
it('preserves the business code and request trace when booking readiness fails', async () => {
vi.mocked(getOceanBooking).mockRejectedValueOnce(new ApiResponseError('订舱版本冲突', 'A0409', 409, undefined, 'req-booking', 'trace-booking'))
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
await flushPromises()
wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'booking-1')
await flushPromises()
expect(wrapper.text()).toContain('订舱版本冲突')
expect(wrapper.text()).toContain('A0409')
expect(wrapper.text()).toContain('req-booking')
expect(wrapper.text()).toContain('trace-booking')
wrapper.unmount()
})
it('blocks a released booking that has no bill of lading number', async () => {
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
await flushPromises()
wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'booking-missing-bill')
await flushPromises()
expect(wrapper.text()).toContain('所选订舱缺少有效提单号')
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
expect(emitted.billOfLadingNo).toBeNull()
wrapper.unmount()
})
it('rejects a booking that becomes unreleased before its detail is loaded', async () => {
vi.mocked(getOceanBooking).mockResolvedValueOnce({ ...detail('booking-1'), status: 'DRAFT' } as never)
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
await flushPromises()
wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'booking-1')
await flushPromises()
expect(wrapper.text()).toContain('所选订舱尚未放舱')
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
expect(emitted.containerBookingId).toBeNull()
wrapper.unmount()
})
})
@@ -30,18 +30,21 @@ describe('TruckAppointmentForm interaction contract', () => {
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')
it('freezes warehouse and bill of lading snapshots without retaining customer data', () => {
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')
expect(source).not.toContain('customerId')
expect(source).not.toContain('customerName')
expect(source).not.toContain('label="客户"')
})
it('keeps server-side validation authoritative and preserves recoverable option errors', () => {
expect(source).toContain('最终资格仍由服务端校验')
expect(source).toContain('所选订舱缺少有效的订舱确认文件或提单文件')
expect(source).toContain('所选订舱缺少有效提单号')
expect(source).toContain("booking.status !== 'RELEASED'")
expect(source).toContain('bookingContainersError')
expect(source).toContain("getApiErrorDetails(cause, '该订舱柜次加载失败,请重试')")
expect(source).toContain('<ApiErrorMeta :details="bookingContainersError" />')
@@ -59,4 +62,10 @@ describe('TruckAppointmentForm interaction contract', () => {
expect(source).toContain('@media (max-width: 480px)')
expect(source).toContain('min-height: 40px')
})
it('suppresses duplicate submits and truncates long booking or container labels', () => {
expect(source).toContain('if (props.submitting) return')
expect(source).toContain(':deep(.el-select__selected-item)')
expect(source).toContain('text-overflow:ellipsis')
})
})
@@ -34,10 +34,10 @@ const rules: FormRules = {
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('订舱资料状态加载失败,请重试'))
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' }],
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('门店电话')],
@@ -87,7 +87,19 @@ async function loadBookingContainers(bookingId: string) {
bookingContainersError.value = null
try {
const booking = await getOceanBooking(bookingId)
if (!bookings.value.some((item) => item.id === booking.id)) bookings.value.push(booking)
if (booking.status !== 'RELEASED') {
containers.value = []
bookingDocuments.value = []
draft.containerBookingId = null
draft.loadingPlanId = null
bookingContainersError.value = { message: '所选订舱尚未放舱,不能创建拖车预约' }
return
}
const bookingIndex = bookings.value.findIndex((item) => item.id === booking.id)
if (bookingIndex >= 0) bookings.value[bookingIndex] = booking
else bookings.value.push(booking)
draft.warehouseId = booking.warehouseId
draft.billOfLadingNo = booking.billOfLadingNo
containers.value = booking.containers || []
bookingDocuments.value = booking.documents || []
if (!selectableContainers.value.some((item) => item.containerBookingId === draft.containerBookingId)) {
@@ -113,14 +125,10 @@ async function selectBooking(bookingId: string) {
bookingDocuments.value = []
relationWarning.value = ''
if (!item) {
draft.customerId = null
draft.customerName = null
draft.warehouseId = null
draft.billOfLadingNo = null
return
}
draft.customerId = item.customerId
draft.customerName = item.customerName
draft.warehouseId = item.warehouseId
draft.billOfLadingNo = item.billOfLadingNo
await loadBookingContainers(item.id)
@@ -139,7 +147,7 @@ async function loadOptions() {
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') }
async function submitForm() { if (props.submitting) return; if (await formRef.value?.validate()) emit('submit') }
defineExpose({ validate })
onMounted(() => { void loadOptions() })
watch(() => props.readonly, (readonly) => { if (!readonly && !bookings.value.length) void loadOptions() })
@@ -147,7 +155,7 @@ 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>拖车预约必须先选择已放舱订舱,再选择该订舱下已录入真实 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">
@@ -155,8 +163,8 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !bookings.value.len
<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.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 :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>
<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 :model-value="draft.billOfLadingNo || ''" disabled placeholder="选择订舱后自动带入" /><div v-if="selectedReleasedBooking" class="field-hint">已从放舱订舱冻结资料带入</div></el-form-item></el-col>
@@ -204,6 +212,7 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !bookings.value.len
: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%; }
:deep(.el-select__selected-item), :deep(.el-select-dropdown__item) { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.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; }
@@ -25,4 +25,19 @@ describe('TruckAttachmentDialog interaction governance', () => {
expect(source).toContain('size="small" type="danger" plain')
expect(source).not.toContain(' text type="danger"')
})
it('keeps list-only access read-only and preserves traced mutation failures', () => {
expect(source).toContain('v-if="props.canManage" class="upload-panel"')
expect(source).toContain('props.appointmentId && props.canManage && item.id')
expect(source).toContain('if (busy.value || !props.appointmentId || !selectedFile.value || !props.canManage) return')
expect(source).toContain('if (busy.value || !props.appointmentId || !item.id || !props.canManage) return')
expect(source).toContain("getApiErrorDetails(cause, '附件上传失败,请稍后重试')")
expect(source).toContain("getApiErrorDetails(cause, '附件删除失败,请稍后重试')")
expect(source).toContain('<ApiErrorMeta :details="error" />')
})
it('wraps long Unicode file names without hiding the actions', () => {
expect(source).toContain('overflow-wrap:anywhere')
expect(source).toContain('.attachment-row>div{min-width:0}')
})
})
@@ -2,21 +2,25 @@
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { FileUp, Trash2 } from '@lucide/vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { deleteTruckAppointmentAttachment, uploadTruckAppointmentAttachment } from '../api/platformTrucking'
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 error = ref<ApiErrorDetails | null>(null)
const selectedFile = ref<File | null>(null)
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 } }
async function upload() { if (busy.value || !props.appointmentId || !selectedFile.value || !props.canManage) return; busy.value = true; error.value = null; try { await uploadTruckAppointmentAttachment(props.appointmentId, selectedFile.value, selectedType.value); selectedFile.value = null; emit('changed'); ElMessage.success('附件上传成功') } catch (cause) { error.value = getApiErrorDetails(cause, '附件上传失败,请稍后重试') } finally { busy.value = false } }
async function remove(item: TruckAttachmentReference) { if (busy.value || !props.appointmentId || !item.id || !props.canManage) return; busy.value = true; error.value = null; try { await ElMessageBox.confirm('确认删除该附件引用?', '删除确认', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }); await deleteTruckAppointmentAttachment(props.appointmentId, item.id); emit('changed'); ElMessage.success('附件引用已删除') } catch (cause) { if (cause === 'cancel' || cause === 'close') return; error.value = getApiErrorDetails(cause, '附件删除失败,请稍后重试') } finally { busy.value = false } }
</script>
<template>
<el-dialog :model-value="props.modelValue" title="预约附件" width="min(640px, 94vw)" @update:model-value="emit('update:modelValue', $event)">
<el-alert v-if="error" type="error" :closable="true" show-icon @close="error = null"><template #title>{{ error.message }}</template><ApiErrorMeta :details="error" /></el-alert>
<div v-if="props.canManage" class="upload-panel"><el-select v-model="selectedType" aria-label="附件类型" style="width:160px"><el-option v-for="(label, type) in attachmentTypeLabels" :key="type" :label="label" :value="type" /></el-select><el-upload :auto-upload="false" :limit="1" :show-file-list="true" :on-change="selectFile" :on-remove="() => { selectedFile = null }"><el-button><FileUp :size="15" />选择附件</el-button></el-upload><el-button type="primary" :loading="busy" :disabled="!selectedFile" @click="upload">上传并关联</el-button></div>
<el-empty v-if="attachments.length === 0" description="暂无预约附件" :image-size="72" />
<div v-for="item in attachments" :key="item.fileId" class="attachment-row">
@@ -29,5 +33,6 @@ async function remove(item: TruckAttachmentReference) { if (!props.appointmentId
<style scoped>
.upload-panel { display:flex; align-items:center; flex-wrap:wrap; gap:8px; margin-bottom:16px; }
.attachment-row { display:flex; justify-content:space-between; gap:12px; margin:14px 0; padding:12px; border:1px solid var(--el-border-color-lighter); border-radius:6px; overflow-wrap:anywhere; }.attachment-row small { display:block; margin-top:4px; color:var(--el-text-color-secondary); }
.attachment-row>div{min-width:0}.attachment-row strong{overflow-wrap:anywhere}
@media (max-width: 560px) { .attachment-row { flex-direction:column; } }
</style>
@@ -6,7 +6,8 @@ const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/pages/T
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('carrierId: null')
expect(source).not.toContain('customerId')
expect(source).toContain('oceanBookingId: null, containerBookingId: null, loadingPlanId: null')
expect(source).toContain('plannedLoadingAt: null')
expect(source).toContain('createTruckAppointment({ ...form })')
@@ -18,6 +19,10 @@ describe('TruckAppointmentCreateView booking-bound creation contract', () => {
expect(source).toContain('<TruckAppointmentForm v-model="form"')
})
it('suppresses duplicate submits while the first create request is pending', () => {
expect(source).toContain('if (saving.value) return')
})
it('places the return action beside the relation note instead of in a separate header row', () => {
expect(source).toContain('<template #relation-note-actions>')
expect(source).toContain('<ArrowLeft :size="16" />返回列表')
@@ -13,8 +13,8 @@ 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, customerId: null, carrierId: null, oceanBookingId: null, containerBookingId: null, loadingPlanId: null, plannedLoadingAt: null, payableAmount: '0.00', receivableAmount: '0.00', currency: 'RMB' })
async function submit() { 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 } }
const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, carrierId: null, oceanBookingId: null, containerBookingId: null, loadingPlanId: null, plannedLoadingAt: null, payableAmount: '0.00', receivableAmount: '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 } }
</script>
<template>
<AdminPage as="div" class="truck-editor-page">
@@ -33,7 +33,10 @@ describe('TruckAppointmentDetailView status contract', () => {
})
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("carrierId: null, oceanBookingId: null, containerBookingId: null, plannedLoadingAt: null")
expect(source).not.toContain('customerId')
expect(source).toContain('TRUCK_APPOINTMENT_SAVE_FIELDS.map')
expect(source).not.toContain('Object.assign(form, { ...result')
expect(source).toContain("error.value = getApiErrorDetails(err, '保存失败,请稍后重试')")
})
@@ -13,15 +13,15 @@ import { getOceanBooking, type OceanBookingDetail } from '@/modules/ocean-bookin
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'
import { TRUCK_APPOINTMENT_SAVE_FIELDS, truckAppointmentActionLabel, truckAppointmentStatusLabel, truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus, type TruckAppointmentStatusHistory } from '../types'
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 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)
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'))
const relationSnapshot = computed(() => resource.value ? [resource.value.customerName, resource.value.oceanBookingNo, resource.value.cabinetNo, resource.value.containerNo, resource.value.warehouseName].filter(Boolean).join(' / ') || '无历史关联' : '')
const relationSnapshot = computed(() => resource.value ? [resource.value.oceanBookingNo, resource.value.cabinetNo, resource.value.containerNo, resource.value.warehouseName].filter(Boolean).join(' / ') || '无历史关联' : '')
async function loadRelated() {
if (!resource.value) return
relatedError.value = ''
@@ -35,8 +35,8 @@ async function loadRelated() {
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; 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 } }
async function load() { loading.value = true; error.value = null; resource.value = null; bookingReadiness.value = null; history.value = []; relatedError.value = ''; try { const result = await getTruckAppointment(id.value); resource.value = result; const draft = Object.fromEntries(TRUCK_APPOINTMENT_SAVE_FIELDS.map((key) => [key, key === 'expectedVersion' ? result.version ?? 0 : result[key as keyof TruckAppointmentResource]])); Object.assign(form, draft); await loadRelated() } catch (err) { error.value = getApiErrorDetails(err, '详情加载失败,请稍后重试') } finally { loading.value = false } }
async function submit() { if (saving.value) return; saving.value = true; error.value = null; try { await updateTruckAppointment(id.value, form); ElMessage.success('拖车预约已保存'); await load() } catch (err) { error.value = getApiErrorDetails(err, '保存失败,请稍后重试') } finally { saving.value = false } }
const actionByTarget: Partial<Record<TruckAppointmentStatus, TruckAppointmentAction>> = { PENDING_DISPATCH: 'SUBMIT', DISPATCHED: 'DISPATCH', PICKUP_IN_PROGRESS: 'PICKUP', ARRIVED_WAREHOUSE: 'ARRIVE', LOADING: 'LOAD', DEPARTED: 'DEPART', COMPLETED: 'COMPLETE', EXCEPTION: 'EXCEPTION', CANCELLED: 'CANCEL' }
const primaryTargetByStatus: Partial<Record<TruckAppointmentStatus, TruckAppointmentStatus>> = { DRAFT: 'PENDING_DISPATCH', PENDING_DISPATCH: 'DISPATCHED', RESCHEDULED: 'DISPATCHED', DISPATCHED: 'PICKUP_IN_PROGRESS', PICKUP_IN_PROGRESS: 'ARRIVED_WAREHOUSE', ARRIVED_WAREHOUSE: 'LOADING', LOADING: 'DEPARTED', DEPARTED: 'COMPLETED' }
const availableTargets = computed<TruckAppointmentStatus[]>(() => {
@@ -57,7 +57,7 @@ const dispatchBlockReason = computed(() => {
return ''
})
async function changeStatus(status: TruckAppointmentStatus) {
if (!resource.value || status === resource.value.status) return
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 }
}
@@ -77,7 +77,7 @@ onActivated(() => {
<section class="truck-overview surface-panel" aria-label="拖车预约概览">
<div class="truck-overview__heading">
<el-button link type="primary" @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button>
<div class="truck-overview__identity"><h1>{{ resource?.appointmentNo || '拖车预约详情' }}</h1><p>{{ resource ? [resource.customerName, resource.orderNo].filter(Boolean).join(' · ') || '客户信息缺失' : '查看拖车预约详情' }}</p></div>
<div class="truck-overview__identity"><h1>{{ resource?.appointmentNo || '拖车预约详情' }}</h1><p>{{ resource ? [resource.oceanBookingNo, resource.containerNo, resource.orderNo].filter(Boolean).join(' · ') || '订舱与柜次信息缺失' : '查看拖车预约详情' }}</p></div>
<span v-if="resource" class="truck-overview__status">当前状态:<strong>{{ truckAppointmentStatusLabel(resource.status) }}</strong></span>
<div v-if="resource && canChangeStatus" class="status-actions"><el-button v-for="status in availableTargets" :key="status" size="small" :type="status === 'CANCELLED' ? 'danger' : status === 'EXCEPTION' ? 'warning' : 'primary'" plain :loading="statusSaving" @click="changeStatus(status)">{{ truckAppointmentStatusLabels[status] }}</el-button></div>
</div>
@@ -19,7 +19,7 @@ vi.mock('@/modules/iam', () => ({
}))
const row: TruckAppointmentResource = {
id: '9001', appointmentNo: 'TA-9001', status: 'DISPATCHED', customerName: '示例企业', orderNo: 'ORD-1', oceanBookingNo: 'OB-1', containerBookingId: '77', cabinetNo: 'CAB-1',
id: '9001', appointmentNo: 'TA-9001', status: 'DISPATCHED', orderNo: 'ORD-1', oceanBookingNo: 'OB-1', containerBookingId: '77', cabinetNo: 'CAB-1',
containerNo: 'CONT-1', warehouseName: '义乌仓', shippingCompanyName: '船公司A', bookingAgentName: '代理A', carrierName: '宁波运输', driverName: '王强', vehicleNo: '浙B12345',
plannedLoadingAt: '2026-08-10T10:00:00', payableAmount: '3200.00', receivableAmount: '3800.00', attachmentCount: 2, attachments: [],
}
@@ -37,7 +37,7 @@ describe('TruckAppointmentsView', () => {
it('loads the real list and renders relationship snapshots', async () => {
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(listTruckAppointments).toHaveBeenCalledWith({ page: 1, size: 20, customerId: '', keyword: '', status: '', containerBookingId: '', containerType: '' })
expect(listTruckAppointments).toHaveBeenCalledWith({ page: 1, size: 20, keyword: '', status: '', containerBookingId: '', containerType: '' })
expect(wrapper.text()).toContain('TA-9001')
expect(wrapper.text()).toContain('CONT-1')
expect(wrapper.text()).toContain('CAB-1')
@@ -122,7 +122,7 @@ describe('TruckAppointmentsView', () => {
})
it('shows the prototype-backed trucking fee snapshots in the list', () => {
expect(source).toContain("key: 'customerName'")
expect(source).not.toContain("key: 'customerName'")
expect(source).toContain("key: 'updatedAt'")
expect(source).toContain("key: 'payableAmount', label: '应付拖车费'")
expect(source).toContain("key: 'receivableAmount', label: '应收拖车费'")
@@ -132,12 +132,12 @@ describe('TruckAppointmentsView', () => {
expect(source).toContain("toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })")
expect(source).toContain("key: 'attachmentCount'")
})
it('filters with an active name-only customer selector and stable customerId', () => {
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
expect(source).toContain("customer.status === 'ACTIVE'")
expect(source).toContain("label: customer.tenantName")
expect(source).toContain("customerId: String(model.customerId || '')")
expect(source).not.toContain('customer.tenantCode')
it('does not expose customer filtering or snapshots', () => {
expect(source).not.toContain('listPlatformTenants')
expect(source).not.toContain("key: 'customerId'")
expect(source).not.toContain("label: '客户'")
expect(source).not.toContain('customerId:')
expect(source).not.toContain('customerName')
})
it('uses bordered list actions instead of text links', () => {
expect(source).toContain('size="small" type="primary" plain')
@@ -11,16 +11,13 @@ import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listTruckAppointments } from '../api/platformTrucking'
import { TRUCK_APPOINTMENT_STATUSES, truckAppointmentStatusLabel, truckAppointmentStatusLabels, type TruckAppointmentQuery, type TruckAppointmentResource, type TruckAppointmentStatus } from '../types'
const router = useRouter(); const rows = ref<TruckAppointmentResource[]>([]); const loading = ref(false); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loadError = ref<ApiErrorDetails | null>(null)
const access = useAccessPreviewStore()
const customers = ref<PlatformTenantResource[]>([]); const customersLoading = ref(false)
const filters = ref({ customerId: '', keyword: '', status: '' as TruckAppointmentStatus | '', containerBookingId: '', containerType: '' }); const applied = ref({ ...filters.value })
const filters = ref({ keyword: '', status: '' as TruckAppointmentStatus | '', containerBookingId: '', containerType: '' }); const applied = ref({ ...filters.value })
const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '派车单号、车牌号、运输公司' },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: TRUCK_APPOINTMENT_STATUSES.map((value) => ({ value, label: truckAppointmentStatusLabels[value] })) },
{ key: 'containerType', type: 'select', label: '柜型', placeholder: '全部柜型', options: [
@@ -33,7 +30,6 @@ const columns: DataTableColumn[] = [
{ 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: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.customerName || '客户信息缺失' },
{ 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 || '—' },
@@ -65,9 +61,8 @@ function statusLabel(value: unknown) {
return truckAppointmentStatusLabel(typeof value === 'string' ? value : null)
}
async function load() { loading.value = true; loadError.value = null; try { const result = await listTruckAppointments({ page: page.value, size: pageSize.value, ...applied.value } as TruckAppointmentQuery); rows.value = result.records; total.value = result.total } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '拖车预约加载失败,请稍后重试') } finally { loading.value = false } }
function search(model: QueryModel) { applied.value = { customerId: String(model.customerId || ''), keyword: String(model.keyword || ''), status: (model.status || '') as TruckAppointmentStatus | '', containerBookingId: '', containerType: String(model.containerType || '') }; if (page.value === 1) void load(); else page.value = 1 }
async function loadCustomers() { customersLoading.value = true; try { const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }); customers.value = result.records.filter((customer) => customer.status === 'ACTIVE') } catch { customers.value = [] } finally { customersLoading.value = false } }
onMounted(() => { void load(); void loadCustomers() })
function search(model: QueryModel) { applied.value = { keyword: String(model.keyword || ''), status: (model.status || '') as TruckAppointmentStatus | '', containerBookingId: '', containerType: String(model.containerType || '') }; if (page.value === 1) void load(); else page.value = 1 }
onMounted(() => { void load() })
function openDetail(row: TruckAppointmentResource, edit = false) { router.push({ name: 'trucking-detail', params: { id: String(row.id) }, query: edit ? { edit: '1' } : undefined }) }
</script>
<template>
+9 -5
View File
@@ -94,8 +94,6 @@ export interface TruckAppointmentResource {
loadingPlanId?: string | null
warehouseId?: string | null
warehouseName?: string | null
customerId?: string | null
customerName?: string | null
shippingCompanyId?: string | null
shippingCompanyName?: string | null
bookingAgentId?: string | null
@@ -145,8 +143,6 @@ export interface SaveTruckAppointmentRequest {
containerBookingId: string | null
loadingPlanId?: string | null
warehouseId?: string | null
customerId: string | null
customerName?: string | null
carrierId: string | null
carrierName?: string | null
billOfLadingNo?: string | null
@@ -176,10 +172,18 @@ export interface SaveTruckAppointmentRequest {
expectedVersion: number
}
export const TRUCK_APPOINTMENT_SAVE_FIELDS = [
'outboundOrderId', 'oceanBookingId', 'containerBookingId', 'loadingPlanId', 'warehouseId',
'carrierId', 'carrierName', 'billOfLadingNo', 'fleetContact', 'fleetPhone', 'driverName',
'driverPhone', 'driverIdCard', 'vehicleNo', 'storeName', 'storeContact', 'storePhone',
'storeAddress', 'storeCity', 'storeZipcode', 'plannedLoadingAt', 'transitMinutes', 'packageCount',
'grossWeightKg', 'volumeCbm', 'palletCount', 'payableAmount', 'receivableAmount', 'currency',
'poNo', 'remark', 'expectedVersion',
] as const satisfies readonly (keyof SaveTruckAppointmentRequest)[]
export interface TruckAppointmentQuery {
page?: number
size?: number
customerId?: string | ''
keyword?: string
status?: TruckAppointmentStatus | ''
containerBookingId?: string | ''