优化
This commit is contained in:
@@ -12,7 +12,7 @@ import { availableTruckCommands, truckCommandHints, truckCommandLabels, type Tru
|
||||
import type { LocalTransportDetail } from '../types'
|
||||
|
||||
const props = defineProps<{ detail: LocalTransportDetail }>()
|
||||
const emit = defineEmits<{ updated: [detail: LocalTransportDetail] }>()
|
||||
const emit = defineEmits<{ updated: [detail: LocalTransportDetail]; busy: [value: boolean] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const commands = computed(() => access.can(overseasBusinessPermissions.truckStatus) ? availableTruckCommands(props.detail) : [])
|
||||
const open = ref(false), saving = ref(false)
|
||||
@@ -23,7 +23,8 @@ const pending = ref<TruckOperation | null>(null)
|
||||
const form = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', actualPalletCount: 1, packageCount: undefined as number | undefined, palletIds: [] as string[], remark: '' })
|
||||
const selectablePallets = computed(() => props.detail.pallets.filter(pallet => pallet.status === 'RECEIVED'))
|
||||
const review = computed(() => active.value === 'review' || active.value === 'review-pallet-overage')
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'truck-order-command', label: computed(() => active.value ? truckCommandLabels[active.value] : '卡车单据操作'), isOpen: open, model: form, requiredPermission: computed(() => overseasBusinessPermissions.truckStatus) })
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'truck-order-command', label: computed(() => active.value ? truckCommandLabels[active.value] : '卡车单据操作'), isOpen: computed(() => open.value || !!pending.value), model: reactive({ form, pending }), requiredPermission: computed(() => overseasBusinessPermissions.truckStatus) })
|
||||
watch(saving, value => emit('busy', value))
|
||||
|
||||
watch(() => [props.detail.customerId, props.detail.orderNo, props.detail.version], () => {
|
||||
pending.value = null; open.value = false; active.value = null; error.value = null
|
||||
@@ -42,7 +43,7 @@ function start(command: TruckCommand) {
|
||||
|
||||
function buildOperation(): TruckOperation | null {
|
||||
if (!active.value) return null
|
||||
const base = { version: props.detail.version, idempotencyKey: crypto.randomUUID(), remark: form.remark.trim() || undefined }
|
||||
const base = { version: props.detail.version, idempotencyKey: globalThis.crypto?.randomUUID?.() || `truck-${Date.now()}-${Math.random().toString(36).slice(2)}`, remark: form.remark.trim() || undefined }
|
||||
if ((active.value === 'cancel' || (review.value && form.decision === 'REJECT')) && !base.remark) {
|
||||
validationError.value = '请填写拒绝或取消原因'; return null
|
||||
}
|
||||
@@ -57,8 +58,8 @@ function buildOperation(): TruckOperation | null {
|
||||
}
|
||||
return { command: active.value, payload: { ...base, actualPalletCount: form.actualPalletCount, ...(form.packageCount == null ? {} : { packageCount: form.packageCount }) } }
|
||||
case 'allocate-pallets':
|
||||
if (!form.palletIds.length || form.palletIds.some(id => !selectablePallets.value.some(pallet => pallet.id === id))) {
|
||||
validationError.value = '请至少选择一个已实收且未分配的托盘'; return null
|
||||
if (!form.palletIds.length || form.palletIds.length > 500 || form.palletIds.some(id => !selectablePallets.value.some(pallet => pallet.id === id))) {
|
||||
validationError.value = '请选择本单已实收且未分配的托盘,每次最多分配 500 托'; return null
|
||||
}
|
||||
return { command: active.value, payload: { ...base, palletIds: [...form.palletIds] } }
|
||||
case 'cancel': return { command: active.value, payload: { ...base, status: 'CANCELLED' } }
|
||||
@@ -98,14 +99,14 @@ async function submit() {
|
||||
<el-form :model="form" label-width="130px" :disabled="saving || !!pending" @submit.prevent="submit">
|
||||
<el-form-item v-if="review" label="审核结果"><el-radio-group v-model="form.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">拒绝</el-radio></el-radio-group></el-form-item>
|
||||
<template v-if="active === 'receive-pallets'">
|
||||
<el-form-item label="累计实收托数" required><el-input-number v-model="form.actualPalletCount" :min="Math.max(1, detail.receivedPalletCount)" :precision="0" /></el-form-item>
|
||||
<el-form-item label="累计实收托数" required><el-input-number v-model="form.actualPalletCount" :min="Math.max(1, detail.receivedPalletCount)" :precision="0" placeholder="请输入累计实收托数" /></el-form-item>
|
||||
<el-form-item label="实收件数"><el-input-number v-model="form.packageCount" :min="0" :precision="0" placeholder="选填" /></el-form-item>
|
||||
</template>
|
||||
<el-form-item v-if="active === 'allocate-pallets'" label="分配托盘" required>
|
||||
<el-checkbox-group v-model="form.palletIds"><el-checkbox v-for="pallet in selectablePallets" :key="pallet.id" :value="pallet.id">{{ pallet.palletNo }}({{ pallet.packageCount ?? '未登记' }} 件)</el-checkbox></el-checkbox-group>
|
||||
<p v-if="!selectablePallets.length">暂无可分配托盘,请刷新核对实收记录。</p>
|
||||
</el-form-item>
|
||||
<el-form-item :label="review ? '审核意见' : '操作说明'" :required="active === 'cancel' || (review && form.decision === 'REJECT')"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
|
||||
<el-form-item :label="review ? '审核意见' : '操作说明'" :required="active === 'cancel' || (review && form.decision === 'REJECT')"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" placeholder="请输入审核意见或操作说明,拒绝或取消时必填" show-word-limit /></el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
@@ -295,6 +295,14 @@ describe('overseas business mounted pages', () => {
|
||||
})
|
||||
|
||||
it('mounts truck list with enabled create and supported types', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(listUsTruckOrders).toHaveBeenCalled(); expect(wrapper.text()).toContain('TK-200'); expect(wrapper.text()).toContain('15拖'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(wrapper.findAll('button').find((button) => button.text().includes('新增订单'))?.attributes('disabled')).toBeUndefined() })
|
||||
it('opens the requested truck review from the list using its customer scope', async () => {
|
||||
const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises()
|
||||
const auth = useAuthStore(); auth.permissions.push('platform:overseas-business:truck:status')
|
||||
await wrapper.vm.$nextTick()
|
||||
const review = wrapper.findAll('button').find(button => button.text() === '审核')
|
||||
expect(review).toBeDefined(); await review!.trigger('click'); await flushPromises()
|
||||
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-truck-detail', params: { orderId: 'TK-200' }, query: { customerId: '10002' } })
|
||||
})
|
||||
it('renders truck load failure without samples', async () => { vi.mocked(listUsTruckOrders).mockRejectedValue(new Error('hidden')); const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(wrapper.text()).toContain('本土卡车订单加载失败'); expect(wrapper.text()).not.toContain('TK-200') })
|
||||
it('loads and submits pending-review truck edit', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002'); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, vehicleType: 'PALLET_15', originContactName: 'Bob', destinationContactName: 'Alice', pickupAt: '2026-08-28T09:30:00' })) })
|
||||
it('keeps truck edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; vi.mocked(updateUsTruckOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('Ontario-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, destination: 'Ontario-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('Ontario-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() })
|
||||
|
||||
@@ -83,7 +83,7 @@ describe('truck review and fulfillment', () => {
|
||||
await click(wrapper, '登记实收'); Object.assign(formModel(wrapper), { actualPalletCount: 2, packageCount: 20 }); await confirm(wrapper)
|
||||
await click(wrapper, '分配托盘'); formModel(wrapper).palletIds = current.pallets.map(row => row.id); await confirm(wrapper)
|
||||
for (const label of ['确认待派车', '确认已派车', '确认发运', '确认完成']) { await click(wrapper, label); await confirm(wrapper) }
|
||||
expect(wrapper.findComponent(TruckOrderActions).findAll('button')).toHaveLength(0)
|
||||
expect(wrapper.find('.truck-order-actions').exists()).toBe(false)
|
||||
expect(current.status).toBe('COMPLETED')
|
||||
expect(vi.mocked(executeUsTruckCommand).mock.calls.map(call => call[2].payload.version)).toEqual([1, 2, 3, 4, 5, 6, 7])
|
||||
expect(vi.mocked(executeUsTruckCommand).mock.calls[2][2]).toMatchObject({ command: 'allocate-pallets', payload: { palletIds: ['9007199254741001', '9007199254741002'] } })
|
||||
@@ -97,16 +97,16 @@ describe('truck review and fulfillment', () => {
|
||||
vi.mocked(executeUsTruckCommand).mockResolvedValue(order({ status: 'REJECTED', version: 2 }))
|
||||
await confirm(wrapper)
|
||||
expect(executeUsTruckCommand).toHaveBeenCalledWith('TR-TEST', '9007199254741999', expect.objectContaining({ command: 'review', payload: expect.objectContaining({ decision: 'REJECT', remark: '取送时间需重新确认', version: 1 }) }))
|
||||
expect(wrapper.findComponent(TruckOrderActions).findAll('button')).toHaveLength(0)
|
||||
expect(wrapper.find('.truck-order-actions').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves one immutable request and retries it after an ambiguous failure, then refreshes the detail', async () => {
|
||||
vi.mocked(executeUsTruckCommand).mockRejectedValueOnce(new ApiResponseError('订单版本冲突', 'A0409', 409, 'TRUCK-409')).mockResolvedValueOnce(order({ status: 'WAITING_INBOUND', version: 2 }))
|
||||
vi.mocked(executeUsTruckCommand).mockRejectedValueOnce(new ApiResponseError('订单版本冲突', 'A0409', 409, undefined, 'TRUCK-409')).mockResolvedValueOnce(order({ status: 'WAITING_INBOUND', version: 2 }))
|
||||
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核')
|
||||
formModel(wrapper).remark = '核对完成'; await confirm(wrapper)
|
||||
expect(wrapper.text()).toContain('订单版本冲突')
|
||||
expect(wrapper.findComponent({ name: 'ElForm' }).props('disabled')).toBe(true)
|
||||
const original = structuredClone(vi.mocked(executeUsTruckCommand).mock.calls[0][2])
|
||||
const original = JSON.parse(JSON.stringify(vi.mocked(executeUsTruckCommand).mock.calls[0][2]))
|
||||
wrapper.findComponent(AppDialog).vm.$emit('update:modelValue', false); await flushPromises()
|
||||
await click(wrapper, '审核'); await confirm(wrapper)
|
||||
expect(vi.mocked(executeUsTruckCommand).mock.calls[1][2]).toEqual(original)
|
||||
@@ -185,7 +185,7 @@ describe('truck review and fulfillment', () => {
|
||||
})
|
||||
|
||||
it('retains error metadata and retries a failed detail read', async () => {
|
||||
vi.mocked(getUsTruckOrder).mockRejectedValueOnce(new ApiResponseError('读取失败', 'A0409', 409, 'DETAIL-409')).mockResolvedValueOnce(order())
|
||||
vi.mocked(getUsTruckOrder).mockRejectedValueOnce(new ApiResponseError('读取失败', 'A0409', 409, undefined, 'DETAIL-409')).mockResolvedValueOnce(order())
|
||||
const wrapper = mountDetail(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('读取失败'); expect(wrapper.text()).toContain('DETAIL-409')
|
||||
await wrapper.findComponent({ name: 'LoadErrorState' }).vm.$emit('retry'); await flushPromises()
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { LocalTransportDetail, LocalTransportPallet, OverseasBusinessHistor
|
||||
const route = useRoute(), router = useRouter()
|
||||
const orderNo = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
|
||||
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
|
||||
const detail = ref<LocalTransportDetail | null>(null), loading = ref(false)
|
||||
const detail = ref<LocalTransportDetail | null>(null), loading = ref(false), saving = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
let requestSequence = 0
|
||||
const dateTime = (value: string | null | undefined) => value ? value.replace('T', ' ').slice(0, 16) : '—'
|
||||
@@ -33,7 +33,7 @@ const fields = computed(() => detail.value ? [
|
||||
['已确认托数', detail.value.palletCount], ['累计实收托数', detail.value.receivedPalletCount], ['已分配托数', detail.value.allocatedPalletCount],
|
||||
['创建时间', dateTime(detail.value.createdAt)], ['备注', detail.value.remark],
|
||||
] : [])
|
||||
const palletLabels: Record<string, string> = { PLANNED: '待实收', RECEIVED: '已实收', ALLOCATED: '已分配', OVERAGE_PENDING_REVIEW: '超托待审', OVERAGE_REJECTED: '超托拒收' }
|
||||
const palletLabels: Record<string, string> = { EXPECTED: '待实收', PLANNED: '待实收', RECEIVED: '已实收', ALLOCATED: '已分配', OVERAGE_PENDING_REVIEW: '超托待审', OVERAGE_REJECTED: '超托拒收' }
|
||||
const palletColumns: DataTableColumn[] = [
|
||||
{ key: 'palletNo', prop: 'palletNo', label: '托盘编号', minWidth: 180 },
|
||||
{ key: 'status', label: '状态', width: 130, formatter: (row: LocalTransportPallet) => palletLabels[row.status] || row.status },
|
||||
@@ -49,6 +49,7 @@ const historyColumns: DataTableColumn[] = [
|
||||
{ key: 'operatorId', prop: 'operatorId', label: '操作人', minWidth: 140 }, { key: 'remark', prop: 'remark', label: '审核意见/操作说明', minWidth: 240 },
|
||||
]
|
||||
async function load() {
|
||||
if (saving.value) return
|
||||
const sequence = ++requestSequence
|
||||
detail.value = null; error.value = null
|
||||
if (!orderNo.value || !customerId.value || customerId.value === '0') {
|
||||
@@ -64,13 +65,13 @@ watch([orderNo, customerId], load, { immediate: true })
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density"><DetailPageLayout aria-label="本土卡车订单详情">
|
||||
<template #header><div class="truck-detail-header"><el-button @click="router.push({ name: 'overseas-business-trucks' })"><ArrowLeft :size="16" />返回列表</el-button><h1>本土卡车订单 {{ detail?.orderNo || orderNo }}</h1></div></template>
|
||||
<template #header><div class="truck-detail-header"><el-button :disabled="saving" @click="router.push({ name: 'overseas-business-trucks' })"><ArrowLeft :size="16" />返回列表</el-button><h1>本土卡车订单 {{ detail?.orderNo || orderNo }}</h1></div></template>
|
||||
<LoadErrorState v-if="error" title="本土卡车订单暂时无法加载" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-skeleton v-else-if="loading" :rows="8" animated />
|
||||
<template v-else-if="detail">
|
||||
<section class="truck-detail-section"><div class="truck-detail-header"><h2>{{ statusLabel(detail.status) }}</h2><el-button @click="load">刷新订单</el-button></div>
|
||||
<section class="truck-detail-section"><div class="truck-detail-header"><h2>{{ statusLabel(detail.status) }}</h2><el-button :disabled="saving" @click="load">刷新订单</el-button></div>
|
||||
<FleetArrangementSummary v-if="detail.fleetArrangement" :arrangement="detail.fleetArrangement" />
|
||||
<template v-else><p>审核通过 → 实收托盘 → 分配托盘 → 待派车 → 已派车 → 运输中 → 完成</p><TruckOrderActions :detail="detail" @updated="detail = $event" /></template>
|
||||
<template v-else><p>审核通过 → 实收托盘 → 分配托盘 → 待派车 → 已派车 → 运输中 → 完成</p><TruckOrderActions :detail="detail" @updated="detail = $event" @busy="saving = $event" /></template>
|
||||
</section>
|
||||
<section class="truck-detail-section"><h2>订单资料</h2><el-descriptions :column="3" border><el-descriptions-item v-for="[label, value] in fields" :key="String(label)" :label="String(label)">{{ value ?? '—' }}</el-descriptions-item></el-descriptions></section>
|
||||
<section class="truck-detail-section"><h2>托盘记录</h2><DataTable :data="detail.pallets" :columns="palletColumns" empty-text="暂无托盘记录,独立订单首次实收后生成托盘" /></section>
|
||||
|
||||
Reference in New Issue
Block a user