Release/test 合并上生产 #2

Merged
admin merged 736 commits from release/test into main 2026-09-17 11:50:56 +08:00
12 changed files with 74 additions and 30 deletions
Showing only changes of commit 0cf4a93f9c - Show all commits
@@ -19,17 +19,17 @@ describe('truck appointment API', () => {
vi.mocked(http.post).mockResolvedValue({ data: { id: '9001', status: 'DISPATCHED' } })
await expect(getTruckAppointment('9001')).resolves.toMatchObject({ id: '9001' })
await expect(createTruckAppointment({ carrierId: '200', oceanBookingId: '300', containerBookingId: '400', loadingPlanId: null, plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 })).resolves.toMatchObject({ id: '9001' })
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/truck-appointments', { carrierId: '200', oceanBookingId: '300', containerBookingId: '400', loadingPlanId: null, plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 })
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/truck-appointments', { carrierId: '200', oceanBookingId: '300', containerBookingId: '400', loadingPlanId: null, plannedLoadingAt: '2026-08-10T10:00:00', currency: 'RMB', expectedVersion: 0 })
await expect(performTruckAppointmentAction('9001', 'DISPATCH', 2)).resolves.toMatchObject({ status: 'DISPATCHED' })
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: '旧客户快照' }
const payload = { carrierId: '200', oceanBookingId: '300', containerBookingId: '400', plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0, currency: 'EUR', receivableAmount: '999.00', customerId: 'legacy-customer', customerName: '旧客户快照' }
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,
carrierId: '200', oceanBookingId: '300', containerBookingId: '400', plannedLoadingAt: '2026-08-10T10:00:00', currency: 'RMB', expectedVersion: 0,
})
})
+2 -2
View File
@@ -1,5 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { TRUCK_APPOINTMENT_SAVE_FIELDS } from '../types'
import { TRUCK_APPOINTMENT_SAVE_FIELDS, normalizeTruckAppointmentCurrency } from '../types'
import type {
SaveTruckAppointmentRequest,
TruckAppointmentPage,
@@ -17,7 +17,7 @@ function operationId(action: string, id: string | number) {
function dtoPayload(payload: SaveTruckAppointmentRequest) {
return Object.fromEntries(TRUCK_APPOINTMENT_SAVE_FIELDS.flatMap((key) => {
const value = payload[key]
const value = key === 'currency' ? normalizeTruckAppointmentCurrency(payload[key]) : payload[key]
return value === undefined ? [] : [[key, value === '' ? null : value]]
})) as unknown as SaveTruckAppointmentRequest
}
@@ -122,4 +122,21 @@ describe('TruckAppointmentForm booking-only behavior', () => {
expect(emitted.containerBookingId).toBeNull()
wrapper.unmount()
})
it('switches the payable currency between RMB and USD and normalizes an illegal legacy value', async () => {
const wrapper = mount(TruckAppointmentForm, {
props: { modelValue: { ...model, payableAmount: '88.00', currency: 'EUR' as never } },
global: { plugins: [ElementPlus] },
})
await flushPromises()
const currencySelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((select) => select.props('placeholder') === '请选择币种')!
expect(currencySelect.props('modelValue')).toBe('RMB')
currencySelect.vm.$emit('update:modelValue', 'USD')
await flushPromises()
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
expect(emitted.currency).toBe('USD')
expect(wrapper.text()).toContain('USD')
expect(emitted).not.toHaveProperty('receivableAmount')
wrapper.unmount()
})
})
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/components/TruckAppointmentForm.vue'), 'utf8')
const typesSource = readFileSync(resolve(process.cwd(), 'src/modules/trucking/types.ts'), 'utf8')
describe('TruckAppointmentForm interaction contract', () => {
it('requires a released booking before a real-numbered allocated container', () => {
@@ -68,4 +69,22 @@ describe('TruckAppointmentForm interaction contract', () => {
expect(source).toContain(':deep(.el-select__selected-item)')
expect(source).toContain('text-overflow:ellipsis')
})
it('uses payable-only fees with a fixed dynamic currency selector', () => {
expect(typesSource).toContain("TRUCK_APPOINTMENT_CURRENCIES = ['RMB', 'USD']")
expect(source).toContain('<template #append>{{ draft.currency }}</template>')
expect(source).toContain('v-for="currency in TRUCK_APPOINTMENT_CURRENCIES"')
expect(source).toContain('placeholder="请选择币种"')
expect(source).not.toContain('receivableAmount')
expect(source).not.toContain('应收拖车费')
})
it('uses four compact desktop columns while retaining two-column relations and wide text fields', () => {
expect(source).toContain('@media (min-width: 1200px)')
expect(source).toContain('max-width:25%; flex:0 0 25%')
expect(source).toContain('class="relation-column"')
expect(source).toContain('class="wide-column"')
expect(source).toContain('max-width:50%; flex-basis:50%')
expect(source).not.toContain('overflow-x:auto')
})
})
@@ -2,7 +2,7 @@
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 { TRUCK_APPOINTMENT_CURRENCIES, normalizeTruckAppointmentCurrency, type SaveTruckAppointmentRequest, type TruckAppointmentResource } from '../types'
import { listSuppliers, type Supplier } from '@/modules/suppliers'
import { getOceanBooking, listOceanBookings, type OceanBookingAllocatedContainer, type OceanBookingDocument, type OceanBookingResource } from '@/modules/ocean-bookings'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -16,8 +16,8 @@ const relationWarning = ref('')
const bookingContainersLoading = ref(false)
const bookingContainersError = ref<ApiErrorDetails | null>(null)
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 defaults: Partial<SaveTruckAppointmentRequest> = { expectedVersion: 0, currency: 'RMB', payableAmount: '0.00', packageCount: 0, palletCount: 0, transitMinutes: 0 }
function normalizeDraft(value: SaveTruckAppointmentRequest): SaveTruckAppointmentRequest { return { ...defaults, ...value, currency: normalizeTruckAppointmentCurrency(value.currency) } }
const draft = reactive<SaveTruckAppointmentRequest>(normalizeDraft(props.modelValue))
watch(() => props.modelValue, (value) => Object.assign(draft, normalizeDraft(value)), { deep: true })
watch(draft, (value) => emit('update:modelValue', { ...value }), { deep: true })
@@ -42,8 +42,7 @@ const rules: FormRules = {
plannedLoadingAt: [{ required: true, message: '请选择预计装箱时间', trigger: 'change' }],
fleetPhone: [phoneRule('车队电话')], driverPhone: [phoneRule('司机电话')], storePhone: [phoneRule('门店电话')],
packageCount: [nonNegative('件数', true)], palletCount: [nonNegative('托盘数量', true)], transitMinutes: [nonNegative('在途分钟', true)],
grossWeightKg: [nonNegative('毛重')], volumeCbm: [nonNegative('体积')], payableAmount: [nonNegative('应付拖车费')], receivableAmount: [nonNegative('应收拖车费')],
currency: [{ pattern: /^[A-Z]{3,8}$/, message: '币种请输入 3-8 位大写字母(如 RMB)', trigger: 'blur' }],
grossWeightKg: [nonNegative('毛重')], volumeCbm: [nonNegative('体积')], payableAmount: [nonNegative('应付拖车费')],
}
const carriers = ref<Supplier[]>([])
const containers = ref<OceanBookingAllocatedContainer[]>([])
@@ -160,8 +159,8 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !bookings.value.len
<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.message }}</span><ApiErrorMeta :details="bookingContainersError" /><el-button link type="primary" :loading="bookingContainersLoading" @click="draft.oceanBookingId && loadBookingContainers(String(draft.oceanBookingId))">重新加载</el-button></div><div v-else-if="draft.oceanBookingId && !bookingContainersLoading && !selectableContainers.length" class="field-hint">只有已分配至该订舱且已录入真实 ISO 集装箱号的柜次可以预约拖车。</div></el-form-item></el-col>
<el-col class="relation-column" :xs="24" :sm="12"><el-form-item label="订舱" prop="oceanBookingId"><el-select :model-value="draft.oceanBookingId" filterable placeholder="请先选择已放舱订舱" :loading="optionsLoading" no-data-text="暂无可用的已放舱订舱" style="width:100%" @update:model-value="selectBooking(String($event || ''))"><el-option v-if="selectedBookingFallback" :key="selectedBookingFallback.id" :label="selectedBookingFallback.label" :value="selectedBookingFallback.id" /><el-option v-for="item in bookings" :key="item.id" :label="`${item.bookingNo || '订舱号缺失'}${item.billOfLadingNo ? ` / 提单号:${item.billOfLadingNo}` : ''}`" :value="item.id" /></el-select></el-form-item></el-col>
<el-col class="relation-column" :xs="24" :sm="12"><el-form-item label="柜次(真实集装箱号)" prop="containerBookingId"><el-select :model-value="draft.containerBookingId" filterable :disabled="!draft.oceanBookingId || bookingContainersLoading" placeholder="请先选择订舱,再选择真实柜号柜次" :loading="bookingContainersLoading" :no-data-text="draft.oceanBookingId ? '该订舱暂无已录真实集装箱号的柜次' : '请先选择订舱'" style="width:100%" @update:model-value="selectContainer(String($event || ''))"><el-option v-if="selectedContainerFallback" :key="selectedContainerFallback.id" :label="selectedContainerFallback.label" :value="selectedContainerFallback.id" :disabled="!readonly" /><el-option v-for="item in selectableContainers" :key="item.containerBookingId" :label="`柜次号:${item.cabinetNo || '待生成'} / 集装箱号:${item.containerNo}`" :value="item.containerBookingId" /></el-select><div v-if="bookingContainersError" class="field-error"><span>{{ bookingContainersError.message }}</span><ApiErrorMeta :details="bookingContainersError" /><el-button link type="primary" :loading="bookingContainersLoading" @click="draft.oceanBookingId && loadBookingContainers(String(draft.oceanBookingId))">重新加载</el-button></div><div v-else-if="draft.oceanBookingId && !bookingContainersLoading && !selectableContainers.length" class="field-hint">只有已分配至该订舱且已录入真实 ISO 集装箱号的柜次可以预约拖车。</div></el-form-item></el-col>
<el-col :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>
@@ -184,17 +183,16 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !bookings.value.len
<el-col :xs="24" :sm="8"><el-form-item label="托盘数量" prop="palletCount"><el-input v-model="draft.palletCount" inputmode="numeric" placeholder="请输入托盘数量" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="在途分钟" prop="transitMinutes"><el-input v-model="draft.transitMinutes" inputmode="numeric" placeholder="请输入预计在途分钟" /></el-form-item></el-col>
<el-col :xs="24"><el-divider content-position="left">费用信息</el-divider></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="应付拖车费" prop="payableAmount"><el-input v-model="draft.payableAmount" placeholder="请输入应付金额"><template #append>RMB</template></el-input></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="应收拖车费" prop="receivableAmount"><el-input v-model="draft.receivableAmount" placeholder="请输入应收金额"><template #append>RMB</template></el-input></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="币种" prop="currency"><el-input v-model="draft.currency" placeholder="例如:RMB" maxlength="8" @blur="draft.currency = draft.currency?.toUpperCase()" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="应付拖车费" prop="payableAmount"><el-input v-model="draft.payableAmount" placeholder="请输入应付金额"><template #append>{{ draft.currency }}</template></el-input></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="币种" prop="currency"><el-select v-model="draft.currency" aria-label="币种" placeholder="请选择币种" style="width:100%"><el-option v-for="currency in TRUCK_APPOINTMENT_CURRENCIES" :key="currency" :label="currency" :value="currency" /></el-select></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.storeName" placeholder="请输入目的门店名称" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店地址"><el-input v-model="draft.storeAddress" placeholder="请输入目的门店地址" /></el-form-item></el-col>
<el-col class="wide-column" :xs="24" :sm="12"><el-form-item label="门店地址"><el-input v-model="draft.storeAddress" placeholder="请输入目的门店地址" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店联系人"><el-input v-model="draft.storeContact" placeholder="请输入门店联系人" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店电话" prop="storePhone"><el-input v-model="draft.storePhone" placeholder="请输入门店联系电话" maxlength="32" show-word-limit /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="城市"><el-input v-model="draft.storeCity" placeholder="请输入城市" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="邮编"><el-input v-model="draft.storeZipcode" placeholder="请输入邮政编码" /></el-form-item></el-col>
<el-col :xs="24"><el-form-item label="PO"><el-input v-model="draft.poNo" placeholder="请输入采购订单号(可选)" /></el-form-item></el-col>
<el-col class="wide-column" :xs="24" :sm="12"><el-form-item label="PO"><el-input v-model="draft.poNo" placeholder="请输入采购订单号(可选)" /></el-form-item></el-col>
<el-col :xs="24"><el-form-item label="备注"><el-input v-model="draft.remark" type="textarea" :rows="3" placeholder="请输入拖车预约备注(可选)" /></el-form-item></el-col>
</el-row>
<div class="form-actions"><el-button :disabled="submitting" @click="emit('attachments')"><Eye :size="16" />查看附件引用</el-button><el-button v-if="!readonly" type="primary" :loading="submitting" @click="submitForm"><Save :size="16" />保存</el-button></div>
@@ -214,6 +212,10 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !bookings.value.len
: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 (min-width: 1200px) {
.truck-form :deep(.el-col-sm-12), .truck-form :deep(.el-col-sm-8) { max-width:25%; flex:0 0 25%; }
.truck-form :deep(.relation-column), .truck-form :deep(.wide-column) { max-width:50%; flex-basis:50%; }
}
@media (max-width: 480px) {
.truck-form { padding-inline: 2px; }
.relation-note { align-items:flex-start; flex-direction:column; }
@@ -10,6 +10,8 @@ describe('TruckAppointmentCreateView booking-bound creation contract', () => {
expect(source).not.toContain('customerId')
expect(source).toContain('oceanBookingId: null, containerBookingId: null, loadingPlanId: null')
expect(source).toContain('plannedLoadingAt: null')
expect(source).toContain("payableAmount: '0.00', currency: 'RMB'")
expect(source).not.toContain('receivableAmount')
expect(source).toContain('createTruckAppointment({ ...form })')
})
@@ -13,7 +13,7 @@ import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
import { createTruckAppointment } from '../api/platformTrucking'
import type { SaveTruckAppointmentRequest } from '../types'
const router = useRouter(); const saving = ref(false); const attachmentOpen = ref(false); const error = ref<ApiErrorDetails | null>(null)
const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, carrierId: null, oceanBookingId: null, containerBookingId: null, loadingPlanId: null, plannedLoadingAt: null, payableAmount: '0.00', receivableAmount: '0.00', currency: 'RMB' })
const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, carrierId: null, oceanBookingId: null, containerBookingId: null, loadingPlanId: null, plannedLoadingAt: null, payableAmount: '0.00', currency: 'RMB' })
async function submit() { if (saving.value) return; saving.value = true; error.value = null; try { await createTruckAppointment({ ...form }); ElMessage.success('拖车预约已创建'); router.push({ name: 'trucking' }) } catch (err) { error.value = getApiErrorDetails(err, '创建失败,请稍后重试') } finally { saving.value = false } }
</script>
<template>
@@ -36,6 +36,7 @@ describe('TruckAppointmentDetailView status contract', () => {
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).toContain('normalizeTruckAppointmentCurrency(result.currency)')
expect(source).not.toContain('Object.assign(form, { ...result')
expect(source).toContain("error.value = getApiErrorDetails(err, '保存失败,请稍后重试')")
})
@@ -13,7 +13,7 @@ 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 { TRUCK_APPOINTMENT_SAVE_FIELDS, truckAppointmentActionLabel, truckAppointmentStatusLabel, truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus, type TruckAppointmentStatusHistory } from '../types'
import { TRUCK_APPOINTMENT_SAVE_FIELDS, normalizeTruckAppointmentCurrency, 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, carrierId: null, oceanBookingId: null, containerBookingId: null, plannedLoadingAt: null })
const history = ref<TruckAppointmentStatusHistory[]>([])
@@ -35,7 +35,7 @@ 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; 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 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 : key === 'currency' ? normalizeTruckAppointmentCurrency(result.currency) : 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' }
@@ -21,7 +21,7 @@ vi.mock('@/modules/iam', () => ({
const row: TruckAppointmentResource = {
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: [],
plannedLoadingAt: '2026-08-10T10:00:00', payableAmount: '3200.00', currency: 'RMB', attachmentCount: 2, attachments: [],
}
describe('TruckAppointmentsView', () => {
@@ -125,10 +125,10 @@ describe('TruckAppointmentsView', () => {
expect(source).not.toContain("key: 'customerName'")
expect(source).toContain("key: 'updatedAt'")
expect(source).toContain("key: 'payableAmount', label: '应付拖车费'")
expect(source).toContain("key: 'receivableAmount', label: '应收拖车费'")
expect(source.indexOf("key: 'store'")).toBeLessThan(source.indexOf("key: 'payableAmount'"))
expect(source.indexOf("key: 'payableAmount'")).toBeLessThan(source.indexOf("key: 'receivableAmount'"))
expect(source.indexOf("key: 'receivableAmount'")).toBeLessThan(source.indexOf("key: 'plannedLoadingAt'"))
expect(source.indexOf("key: 'payableAmount'")).toBeLessThan(source.indexOf("key: 'plannedLoadingAt'"))
expect(source).not.toContain('receivableAmount')
expect(source).not.toContain('应收拖车费')
expect(source).toContain("toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })")
expect(source).toContain("key: 'attachmentCount'")
})
@@ -38,7 +38,6 @@ const columns: DataTableColumn[] = [
{ key: 'vehicleNo', prop: 'vehicleNo', label: '车头号', minWidth: 120, formatter: (row: TruckAppointmentResource) => row.vehicleNo || '—' },
{ key: 'store', label: '门店', minWidth: 140, slotName: 'store' },
{ key: 'payableAmount', label: '应付拖车费', minWidth: 130, align: 'right', formatter: (row: TruckAppointmentResource) => formatAmount(row.payableAmount, row.currency) },
{ key: 'receivableAmount', label: '应收拖车费', minWidth: 130, align: 'right', formatter: (row: TruckAppointmentResource) => formatAmount(row.receivableAmount, row.currency) },
{ key: 'plannedLoadingAt', label: '预计装箱日期', minWidth: 170, formatter: (row: TruckAppointmentResource) => formatDateTime(row.plannedLoadingAt) },
{ key: 'status', label: '状态', width: 130, slotName: 'status' },
{ key: 'attachmentCount', label: '附件', width: 90, align: 'right', formatter: (row: TruckAppointmentResource) => String(Number(row.attachmentCount ?? 0)) },
+9 -5
View File
@@ -53,6 +53,12 @@ export function truckAppointmentActionLabel(action: string | null | undefined) {
}
export type TruckAttachmentType = 'DISPATCH_ORDER' | 'DRIVER_DOCUMENT' | 'VEHICLE_DOCUMENT' | 'OTHER'
export const TRUCK_APPOINTMENT_CURRENCIES = ['RMB', 'USD'] as const
export type TruckAppointmentCurrency = typeof TRUCK_APPOINTMENT_CURRENCIES[number]
export function normalizeTruckAppointmentCurrency(value: unknown): TruckAppointmentCurrency {
const normalized = String(value ?? '').trim().toUpperCase()
return TRUCK_APPOINTMENT_CURRENCIES.includes(normalized as TruckAppointmentCurrency) ? normalized as TruckAppointmentCurrency : 'RMB'
}
export interface TruckAttachmentReference {
id?: string
@@ -121,8 +127,7 @@ export interface TruckAppointmentResource {
volumeCbm?: string | null
palletCount?: number | string | null
payableAmount?: string | null
receivableAmount?: string | null
currency?: string | null
currency?: TruckAppointmentCurrency | null
poNo?: string | null
remark?: string | null
orderId?: string | null
@@ -165,8 +170,7 @@ export interface SaveTruckAppointmentRequest {
volumeCbm?: string | null
palletCount?: number | string | null
payableAmount?: string | null
receivableAmount?: string | null
currency?: string | null
currency?: TruckAppointmentCurrency | null
poNo?: string | null
remark?: string | null
expectedVersion: number
@@ -177,7 +181,7 @@ export const TRUCK_APPOINTMENT_SAVE_FIELDS = [
'carrierId', 'carrierName', 'billOfLadingNo', 'fleetContact', 'fleetPhone', 'driverName',
'driverPhone', 'driverIdCard', 'vehicleNo', 'storeName', 'storeContact', 'storePhone',
'storeAddress', 'storeCity', 'storeZipcode', 'plannedLoadingAt', 'transitMinutes', 'packageCount',
'grossWeightKg', 'volumeCbm', 'palletCount', 'payableAmount', 'receivableAmount', 'currency',
'grossWeightKg', 'volumeCbm', 'palletCount', 'payableAmount', 'currency',
'poNo', 'remark', 'expectedVersion',
] as const satisfies readonly (keyof SaveTruckAppointmentRequest)[]