feat(admin): streamline appointments and fleet intake

This commit is contained in:
2026-10-09 22:05:51 +08:00
parent 88dc4852c6
commit e1457a8e57
39 changed files with 1215 additions and 165 deletions
@@ -40,8 +40,8 @@ it.each(['inquiry', 'settings'] as const)('keeps the real %s dialog and edited i
const { wrapper, pinia } = mountDialog(kind)
if (kind === 'settings') await wrapper.setProps({ modelValue: true })
await flushPromises()
if (kind === 'settings') await wrapper.get('details summary').trigger('click')
const input = wrapper.find<HTMLInputElement>(kind === 'inquiry' ? 'input[placeholder="选填"]' : 'input[placeholder="填写公开给销售的联系人(选填)"]')
await wrapper.get('details summary').trigger('click')
const input = wrapper.find<HTMLInputElement>(kind === 'inquiry' ? 'input[placeholder="填写货物名称(确认承运前必填)"]' : 'input[placeholder="填写公开给销售的联系人(选填)"]')
expect(input.exists()).toBe(true)
await input.setValue('UI-未保存检查'); await flushPromises()
const assertPreserved = () => {
@@ -81,8 +81,9 @@ it('blocks browser back with the real router, cached detail and native dialog, w
await router.push('/pool'); await router.isReady()
const wrapper = mount(Host, { attachTo: document.body, global: { plugins: [pinia, router, ElementPlus] } }); wrappers.push(wrapper)
await router.push('/pool/CO-CLOSE'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '登记意向')!.trigger('click'); await flushPromises()
const input = wrapper.get<HTMLInputElement>('input[placeholder="选填"]')
await wrapper.findAll('button').find(button => button.text() === '登记客户货物')!.trigger('click'); await flushPromises()
await wrapper.get('details summary').trigger('click')
const input = wrapper.get<HTMLInputElement>('input[placeholder="填写货物名称(确认承运前必填)"]')
await input.setValue('UI-修复后保留输入'); await flushPromises()
input.element.focus(); await input.trigger('keydown', { key: 'Escape', code: 'Escape' }); await flushPromises(); await continueEditing()
confirms.mockClear()
@@ -99,8 +100,8 @@ it('blocks browser back with the real router, cached detail and native dialog, w
expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offers')
expect(confirms).toHaveBeenCalledOnce(); expect(getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-CLOSE')
await new Promise(resolve => setTimeout(resolve, 50)); await router.push('/pool/CO-CLOSE'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '登记意向')!.trigger('click'); await flushPromises()
await wrapper.get<HTMLInputElement>('input[placeholder="选填"]').setValue('另一次修改'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '登记客户货物')!.trigger('click'); await flushPromises()
await wrapper.get<HTMLInputElement>('input[placeholder="填写货物名称(确认承运前必填)"]').setValue('另一次修改'); await flushPromises()
confirms.mockClear(); const navigation = router.push('/outside'); await flushPromises()
expect(confirms).toHaveBeenCalledOnce(); expect(document.body.textContent).toContain('全局离开确认')
await continueEditing(); await navigation
@@ -31,7 +31,7 @@ it.each(scenarios)('uses explicit offer quote queries, pagination and negotiated
const source: FleetQuoteSourceSnapshot = { quoteNo: 'FQ-PAGE-2', recordVersion: '9007199254740993', priceItem: scenario.priceBasis === 'ORDER' ? 'FULL_DELIVERY' : 'LOOSE_DELIVERY', customerId: '19', customerName: '报价测试客户', originCity: 'LA', destinationCode: 'ONT8', distanceMiles: 35, currency: 'CNY', priceBasis: scenario.priceBasis, quotedUnitPrice: 25.5, validFrom: '2026-10-01', validTo: '2026-10-31', effectiveOn: '2026-10-20' }
vi.mocked(listOfferQuotes).mockResolvedValueOnce({ records: Array.from({ length: 20 }, (_, index) => ({ ...source, quoteNo: `FQ-PAGE-1-${index + 1}` })), total: 21, page: 1, size: 20, pages: 2 }).mockResolvedValueOnce({ records: [source], total: 21, page: 2, size: 20, pages: 2 })
vi.mocked(saveCapacityInquiry).mockImplementation(async (_offerNo, _bookingNo, body) => {
const inquiry: CapacityInquiry = { bookingNo: 'FB-QUOTE-SAVED', offerNo: offer.offerNo, tripNo: offer.tripNo, status: 'DRAFT', recordVersion: 1, offerRecordVersion: offer.recordVersion, tripRecordVersion: offer.tripRecordVersion, salesOwnerUserId: '17', customerId: body.customerId, customerCode: 'C-19', customerName: '报价测试客户', businessType: body.businessType, deliveryMode: body.deliveryMode, estimatedArrivalAt: body.estimatedArrivalAt, expectedPalletCount: body.expectedPalletCount, cargo: { ...body.cargo }, destinationLabel: 'ONT8', agreedAmount: null, quoteSource: source, businessOrderNo: null, incomingNo: null, dispatchNo: null, updatedAt: '2026-10-07T00:00:00Z', actions: { canEdit: true, canConfirm: false, canCancel: true, blockReason: null } }
const inquiry: CapacityInquiry = { bookingNo: 'FB-QUOTE-SAVED', offerNo: offer.offerNo, tripNo: offer.tripNo, status: 'DRAFT', recordVersion: 1, offerRecordVersion: offer.recordVersion, tripRecordVersion: offer.tripRecordVersion, salesOwnerUserId: '17', customerId: body.customerId, customerCode: 'C-19', customerName: '报价测试客户', businessType: body.businessType, deliveryMode: body.deliveryMode, estimatedArrivalAt: body.estimatedArrivalAt, expectedPalletCount: body.expectedPalletCount, cargo: { ...body.cargo }, destinationLabel: 'ONT8', agreedAmount: null, quoteSource: source, businessOrderNo: null, incomingNo: null, dispatchNo: null, updatedAt: '2026-10-07T00:00:00Z', actions: { canEdit: true, canConfirm: false, canCancel: true, blockReason: null }, appointmentCoverage: null, progress: { stage: 'NEEDS_DETAILS', label: '待补资料', reservation: '尚未保位', owner: '销售', primaryAction: 'EDIT', message: '确认前还需补充资料', missingFields: ['预计重量'] } }
return { offer, inquiry, replayed: false }
})
const wrapper = mount(CapacityInquiryDialog, { attachTo: document.body, props: { modelValue: true, offer, inquiry: null, readonly: false }, global: { plugins: [createPinia(), ElementPlus] } }); wrappers.push(wrapper)
@@ -54,7 +54,7 @@ it.each(scenarios)('uses explicit offer quote queries, pagination and negotiated
await price.get('input').setValue('21.75'); await flushPromises()
expect(price.props('modelValue')).toBe(21.75)
expect(quotePanel.text()).toContain('来源报价 FQ-PAGE-2 · 25.50 CNY')
await wrapper.findAll('.app-dialog__footer button').find(button => button.text() === '保存意向')!.trigger('click'); await flushPromises()
await wrapper.findAll('.app-dialog__footer button').find(button => button.text() === '保存登记')!.trigger('click'); await flushPromises()
expect(saveCapacityInquiry).toHaveBeenCalledExactlyOnceWith(offer.offerNo, null, expect.objectContaining({ customerId: '19', offerRecordVersion: 4, tripRecordVersion: 7, recordVersion: null, quoteRef: reference, idempotencyKey: expect.any(String), cargo: expect.objectContaining({ destinationKey: 'FBA:ONT8', currency: 'CNY', priceBasis: scenario.priceBasis, unitPrice: 21.75 }) }))
expect(listOfferQuotes).toHaveBeenCalledTimes(2)
expect(wrapper.emitted('saved')).toHaveLength(1)
@@ -6,21 +6,22 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listOfferCustomers, listOfferQuotes, saveCapacityInquiry } from '../api/capacityOffers'
import { listOfferCustomers, listOfferQuotes, saveCapacityInquiry, saveAndConfirmInquiry } from '../api/capacityOffers'
import { fleetQuoteReference, type FleetQuoteReference, type FleetQuoteSourceSnapshot } from '../api/fleetQuoteCandidates'
import { offerTime, appointmentCoverageLabels, appointmentMissingLabels } from '../capacityOfferTypes'
import type { CapacityOffer, CapacityInquiry, InquiryResult, InquiryWrite, OfferCustomerOption } from '../capacityOfferTypes'
import type { TripBookingCargo } from '../bookingTypes'
import { useFleetDraftGuard } from '../useFleetDraftGuard'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ offer: CapacityOffer | null; inquiry: CapacityInquiry | null; readonly: boolean }>()
const emit = defineEmits<{ saved: [value: InquiryResult]; refresh: [] }>()
const props = defineProps<{ offer: CapacityOffer | null; inquiry: CapacityInquiry | null; readonly: boolean; confirmMode?: boolean }>()
const emit = defineEmits<{ saved: [value: InquiryResult]; refresh: []; action: [key: string, row: CapacityInquiry] }>()
const access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
const blankCargo = (): TripBookingCargo => ({ destinationKey: '', cargoName: null, packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null })
const form = reactive({ customerId: '', businessType: 'LOCAL_LOOSEBOARD' as CapacityInquiry['businessType'], deliveryMode: 'SELF_DELIVERY' as CapacityInquiry['deliveryMode'], estimatedArrivalAt: null as string | null, expectedPalletCount: null as number | null, cargo: blankCargo(), quoteRef: null as FleetQuoteReference | null })
const quoteSource = ref<FleetQuoteSourceSnapshot | null>(null), customers = ref<OfferCustomerOption[]>([]), customerLoading = ref(false)
const customerError = ref<ApiErrorDetails | null>(null), customerSearched = ref(false)
const quoteRows = ref<FleetQuoteSourceSnapshot[]>([]), quoteLoading = ref(false), quoteError = ref<ApiErrorDetails | null>(null), quoteSearched = ref(false), quotePage = ref(1), quotePages = ref(0)
const pending = ref<{ target: string; body: InquiryWrite } | null>(null)
const pending = ref<{ target: string; confirm: boolean; body: InquiryWrite } | null>(null)
const target = computed(() => props.inquiry?.bookingNo || props.offer?.offerNo || '')
const writable = computed(() => !props.readonly && (access.can('platform:fleet:capacity-offer:manage') || access.can('platform:fleet:capacity-offer:list') && access.can(`platform:fleet:capacity-offer:inquiry-${props.inquiry ? 'edit' : 'create'}`)) && (props.inquiry ? props.inquiry.actions.canEdit : props.offer?.canCreateInquiry))
const canQuote = computed(() => access.can('platform:fleet:quote:list'))
@@ -40,6 +41,7 @@ watch(() => [open.value, target.value], async () => {
else {
const row = props.inquiry
Object.assign(form, { customerId: row?.customerId || '', businessType: row?.businessType || 'LOCAL_LOOSEBOARD', deliveryMode: row?.deliveryMode || 'SELF_DELIVERY', estimatedArrivalAt: row?.estimatedArrivalAt || null, expectedPalletCount: row?.expectedPalletCount ?? null, cargo: row ? { ...row.cargo } : blankCargo() })
if (!row && props.offer?.destinations.length === 1) form.cargo.destinationKey = props.offer.destinations[0]!.destinationKey
quoteSource.value = row?.quoteSource || null; form.quoteRef = row?.quoteSource ? fleetQuoteReference(row.quoteSource) : null
}
await nextTick(); guard.capture()
@@ -69,30 +71,35 @@ async function save() {
if (form.quoteRef && !canQuote.value) { ElMessage.warning('修改带报价来源的意向需要报价读取权限'); return }
const current = generation; busy.value = true; error.value = null
try {
pending.value ||= { target: target.value, body: { tripRecordVersion: props.offer.tripRecordVersion, offerRecordVersion: props.offer.recordVersion, recordVersion: props.inquiry?.recordVersion ?? null, customerId: form.customerId, businessType: form.businessType, deliveryMode: form.deliveryMode, estimatedArrivalAt: form.estimatedArrivalAt || null, expectedPalletCount: form.expectedPalletCount ?? null, cargo: { ...form.cargo, packageCount: form.cargo.packageCount ?? null, palletSpaces: form.cargo.palletSpaces ?? null, weightKg: form.cargo.weightKg ?? null, volumeCbm: form.cargo.volumeCbm ?? null, unitPrice: form.cargo.unitPrice ?? null }, quoteRef: form.quoteRef ? { ...form.quoteRef } : null, idempotencyKey: crypto.randomUUID() } }
const result = await saveCapacityInquiry(props.offer.offerNo, props.inquiry?.bookingNo || null, pending.value.body)
if (current === generation) { pending.value = null; guard.capture(); emit('saved', result); open.value = false; ElMessage.success('意向已保存,尚未占用运力') }
pending.value ||= { target: target.value, confirm: !!props.confirmMode, body: { tripRecordVersion: props.offer.tripRecordVersion, offerRecordVersion: props.offer.recordVersion, recordVersion: props.inquiry?.recordVersion ?? null, customerId: form.customerId, businessType: form.businessType, deliveryMode: form.deliveryMode, estimatedArrivalAt: form.estimatedArrivalAt || null, expectedPalletCount: form.expectedPalletCount ?? null, cargo: { ...form.cargo, packageCount: form.cargo.packageCount ?? null, palletSpaces: form.cargo.palletSpaces ?? null, weightKg: form.cargo.weightKg ?? null, volumeCbm: form.cargo.volumeCbm ?? null, unitPrice: form.cargo.unitPrice ?? null }, quoteRef: form.quoteRef ? { ...form.quoteRef } : null, idempotencyKey: crypto.randomUUID() } }
const result = pending.value.confirm && props.inquiry ? await saveAndConfirmInquiry(props.inquiry.bookingNo, pending.value.body) : await saveCapacityInquiry(props.offer.offerNo, props.inquiry?.bookingNo || null, pending.value.body)
if (current === generation) { pending.value = null; guard.capture(); emit('saved', result); open.value = false; ElMessage.success(result.inquiry.status === 'CONFIRMED' ? '已保留货位并关联本土订单,尚未安排来货' : '登记已保存,调度可见,尚未保留货位') }
} catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '意向保存未完成'); if (cause instanceof ApiResponseError) pending.value = null } }
finally { if (current === generation) busy.value = false }
}
onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clearTimeout(timer) })
</script>
<template>
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" destroy-on-close :title="readonly ? '拼车意向详情' : inquiry ? '编辑拼车意向' : '登记拼车意向'" width="1080px" :show-confirm="writable" :confirm-text="pending ? '重试原请求' : '保存意向'" :confirm-loading="busy" @confirm="save">
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" destroy-on-close :title="readonly ? '客户货物跟进' : inquiry ? '补充客户货物资料' : '登记客户货物'" width="1080px" :show-confirm="writable" :confirm-text="pending ? '重试原请求' : confirmMode ? '确认承运并保位' : '保存登记'" :confirm-loading="busy" @confirm="save">
<LoadErrorState v-if="error" title="意向未能保存" :message="error.message" @retry="pending ? save() : emit('refresh')"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="confirmMode">核对本票货量、价格与交货资料后确认承运。成功将保留货位并创建关联本土订单,不自动安排仓库收货。</p>
<p v-if="pending" role="status">上次提交结果尚未确定,请保留原内容重试或核对。</p>
<p v-if="offer">{{ offer.offerNo }} · {{ offer.warehouseName }} · {{ offer.blockReason || '询价不占位,调度确认承运后占位。' }}</p>
<section v-if="inquiry" aria-label="本票处理进度"><strong>{{ inquiry.progress.label }} · {{ inquiry.progress.reservation }}</strong><p>下一步负责人:{{ inquiry.progress.owner }} · {{ inquiry.progress.message }}</p></section>
<p v-else>保存后调度可见,尚不保位。已知货量可先填,未知可留空,确认承运前再补齐。</p>
<p v-if="offer">计划发车:{{ offerTime(offer.plannedDepartureAt) }} · 最晚送仓:{{ offerTime(offer.cutoffAt) }}</p>
<p v-if="inquiry?.actions.blockReason">{{ inquiry.actions.blockReason }}</p>
<el-form label-position="top" class="density-form capacity-inquiry-form" :disabled="lock">
<el-form-item label="委托客户" required><el-select v-model="form.customerId" filterable remote :remote-method="searchCustomers" :loading="customerLoading" :disabled="!!inquiry" placeholder="按客户编号或名称搜索" @focus="searchCustomers()"><el-option v-if="inquiry" :value="inquiry.customerId" :label="`${inquiry.customerCode} · ${inquiry.customerName}`" /><el-option v-for="customer in customers" :key="customer.id" :value="customer.id" :label="`${customer.code} · ${customer.name}`" /></el-select><span v-if="customerSearched && !customers.length">暂无可选客户</span></el-form-item>
<el-form-item label="本土业务类型"><el-select v-model="form.businessType"><el-option value="LOCAL_LOOSEBOARD" label="本土散板" /><el-option value="US_TRUCK" label="本土卡车" /></el-select></el-form-item>
<el-form-item label="本车卸货站" required><el-select v-model="form.cargo.destinationKey" placeholder="选择本车既有卸货站"><el-option v-for="stop in offer?.destinations || []" :key="stop.destinationKey" :value="stop.destinationKey" :label="stop.name" /><el-option v-if="!offer && inquiry" :value="inquiry.cargo.destinationKey" :label="inquiry.destinationLabel" /></el-select></el-form-item>
<el-form-item label="货物名称"><el-input v-model="form.cargo.cargoName" placeholder="选填" /></el-form-item>
<el-form-item label="本车卸货站" required><el-select v-model="form.cargo.destinationKey" :disabled="offer?.destinations.length === 1" placeholder="选择本车既有卸货站"><el-option v-for="stop in offer?.destinations || []" :key="stop.destinationKey" :value="stop.destinationKey" :label="stop.name" /><el-option v-if="!offer && inquiry" :value="inquiry.cargo.destinationKey" :label="inquiry.destinationLabel" /></el-select></el-form-item>
<el-form-item label="预计箱数"><el-input-number v-model="form.cargo.packageCount" :min="1" :precision="0" placeholder="未知可留空" /></el-form-item>
<el-form-item label="预计实托数"><el-input-number v-model="form.expectedPalletCount" :min="1" :precision="0" placeholder="卡车确认承运前必填" /></el-form-item>
<el-form-item label="预计标准托位"><el-input-number v-model="form.cargo.palletSpaces" :min="1" :precision="0" placeholder="确认承运前填写" /></el-form-item>
<el-form-item label="预计重量(kg)"><el-input-number v-model="form.cargo.weightKg" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
<el-form-item label="预计体积(m³)"><el-input-number v-model="form.cargo.volumeCbm" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
<details class="capacity-inquiry-wide" :open="readonly || confirmMode"><summary>确认承运前待补资料{{ inquiry?.progress.missingFields.length ? `(还需:${inquiry.progress.missingFields.join('、')})` : '(货名、价格、到仓及联系信息)' }}</summary><div class="capacity-inquiry-form">
<el-form-item label="货物名称"><el-input v-model="form.cargo.cargoName" placeholder="填写货物名称(确认承运前必填)" /></el-form-item>
<el-form-item label="交货方式"><el-select v-model="form.deliveryMode"><el-option value="SELF_DELIVERY" label="客户自行送至我仓" /><el-option value="MANUAL_PICKUP_TO_WAREHOUSE" label="线下安排提货回我仓" /></el-select></el-form-item>
<el-form-item label="预计到仓"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="计价方式"><el-select v-model="form.cargo.priceBasis"><el-option value="PALLET" label="按托位" /><el-option value="ORDER" label="整单" /></el-select></el-form-item>
@@ -100,14 +107,31 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
<el-form-item label="币种"><el-select v-model="form.cargo.currency" filterable allow-create><el-option v-for="currency in ['USD', 'CNY', 'CAD', 'EUR', 'GBP']" :key="currency" :value="currency" :label="currency" /></el-select></el-form-item>
<el-form-item label="联系人"><el-input v-model="form.cargo.contactName" placeholder="确认承运前补充" /></el-form-item>
<el-form-item label="联系方式"><el-input v-model="form.cargo.contactPhone" placeholder="确认承运前补充" /></el-form-item>
<el-form-item label="交货说明与备注" class="capacity-inquiry-wide"><el-input v-model="form.cargo.remark" type="textarea" :rows="2" placeholder="线下提货安排或其他交货说明;系统收货从实际到仓开始" /></el-form-item>
</div></details>
<details class="capacity-inquiry-wide" :open="readonly || confirmMode"><summary>其他说明(选填)</summary>
<el-form-item label="交货说明与备注" class="capacity-inquiry-wide"><el-input v-model="form.cargo.remark" type="textarea" :rows="2" placeholder="线下提货安排或其他交货说明;系统收货从实际到仓开始" /></el-form-item></details>
</el-form>
<LoadErrorState v-if="customerError" title="客户候选读取失败" :message="customerError.message" @retry="searchCustomers()"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState>
<section aria-label="报价来源"><p v-if="quoteSource">来源报价 {{ quoteSource.quoteNo }} · {{ quoteSource.quotedUnitPrice.toFixed(2) }} {{ quoteSource.currency }};实际约定价格单独保存。</p><p v-else>未引用报价库,可手工询价。约定运费不代表应收或实收。</p>
<details><summary>参考报价(可选)</summary><section aria-label="报价来源"><p v-if="quoteSource">来源报价 {{ quoteSource.quoteNo }} · {{ quoteSource.quotedUnitPrice.toFixed(2) }} {{ quoteSource.currency }};实际约定价格单独保存。</p><p v-else>未引用报价库,可手工询价。约定运费不代表应收或实收。</p>
<template v-if="canQuote && writable"><el-button :disabled="lock || !quoteReady" :loading="quoteLoading" @click="queryQuotes()">查询报价库</el-button><el-button v-if="form.quoteRef" :disabled="lock" @click="form.quoteRef = null; quoteSource = null">改为手工询价</el-button>
<el-select v-if="quoteRows.length" :model-value="form.quoteRef ? sourceKey(form.quoteRef) : ''" :disabled="lock" placeholder="选择报价,可另行议价" @update:model-value="selectQuote"><el-option v-for="quote in quoteRows" :key="sourceKey(quote)" :value="sourceKey(quote)" :label="`${quote.quoteNo} · ${quote.quotedUnitPrice} ${quote.currency}`" /></el-select>
<p v-if="quoteSearched && !quoteRows.length">暂无适用报价,可继续手工询价。</p><div v-if="quotePages > 1"><el-button :disabled="quoteLoading || quotePage <= 1" @click="queryQuotes(quotePage - 1)">上一页</el-button> {{ quotePage }} / {{ quotePages }} <el-button :disabled="quoteLoading || quotePage >= quotePages" @click="queryQuotes(quotePage + 1)">下一页</el-button></div>
</template><p v-if="quoteError" role="alert">{{ quoteError.message }}<ApiErrorMeta :details="quoteError" /></p>
</section></details>
<section v-if="inquiry && readonly" aria-label="继续处理本票货物" class="density-actions">
<el-button v-if="inquiry.actions.canEdit" @click="emit('action', 'edit', inquiry)">补充资料</el-button>
<el-button v-if="access.can('platform:fleet:capacity-offer:manage') && (inquiry.actions.canEdit || inquiry.actions.canConfirm)" @click="emit('action', 'confirm', inquiry)">核对并确认</el-button>
<el-button v-if="inquiry.progress.primaryAction === 'INCOMING' && access.can('platform:fleet:capacity-offer:manage') && access.can('platform:fleet:trip:booking-edit')" @click="emit('action', 'incoming', inquiry)">安排来货</el-button>
<el-button v-if="inquiry.progress.primaryAction === 'RECEIPT' && access.can('platform:fleet:capacity-offer:manage') && access.can('platform:fleet:trip:booking-confirm')" @click="emit('action', 'receipt', inquiry)">核对实收与运费</el-button>
<el-button v-if="inquiry.progress.primaryAction === 'BIND' && access.can('platform:fleet:capacity-offer:manage') && access.can('platform:fleet:trip:booking-edit') && access.can('platform:fleet:trip:edit') && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch')" @click="emit('action', 'bind', inquiry)">确认排入本车</el-button>
<el-button v-if="inquiry.incomingNo" @click="emit('action', 'fulfillment', inquiry)">查看收货进度</el-button>
<el-button v-if="inquiry.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')" @click="emit('action', 'dispatch', inquiry)">查看排车与审核</el-button>
</section>
<section v-if="inquiry?.appointmentCoverage" aria-label="收货预约覆盖">
<h3>收货预约覆盖</h3><el-button v-if="access.can('platform:appointment:list') && access.can('platform:overseas-warehouse:last-mile-order:list')" @click="emit('action', 'appointment', inquiry)">预约跟进</el-button><p>{{ appointmentCoverageLabels[inquiry.appointmentCoverage.status] || '预约结果待核对' }}。承运保位不代表收货方已接受本票货物。</p>
<p v-if="inquiry.appointmentCoverage.missingFields.length">待处理:{{ inquiry.appointmentCoverage.missingFields.map(key => appointmentMissingLabels[key] || '请预约人员核对来源资料').join('、') }}</p>
<p v-for="appointment in inquiry.appointmentCoverage.appointments" :key="appointment.appointmentNo">预约:{{ appointment.appointmentNo }} · 外部确认号:{{ appointment.confirmationNo || '尚未确认' }}</p>
<p v-if="inquiry.appointmentCoverage.changeReviewRequired">货源或数量发生变化,请负责预约的人员重新核对覆盖。</p>
</section>
<p v-if="inquiry">本土订单:{{ inquiry.businessOrderNo || '尚未成交' }} · 来货:{{ inquiry.incomingNo || '尚未登记' }} · 排车:{{ inquiry.dispatchNo || '尚未关联' }}</p>
</AppDialog>
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { ref, watch, onBeforeUnmount } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getInquiryFulfillment } from '../api/capacityOffers'
import type { CapacityInquiry } from '../capacityOfferTypes'
import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue'
const dialog = ref<InstanceType<typeof TripBookingFulfillmentDialog> | null>(null)
const activeMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view')
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ inquiry: CapacityInquiry | null; mode: 'incoming' | 'receipt' | 'bind' | 'view' }>()
const emit = defineEmits<{ saved: [] }>()
const loaded = ref<Awaited<ReturnType<typeof getInquiryFulfillment>> | null>(null)
const error = ref<ApiErrorDetails | null>(null), loading = ref(false)
let generation = 0
async function load() {
if (!props.inquiry || !open.value) return
const current = ++generation; loading.value = true; error.value = null
try { const value = await getInquiryFulfillment(props.inquiry.bookingNo); if (current === generation) loaded.value = value }
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '本票履约进度读取失败') }
finally { if (current === generation) loading.value = false }
}
watch(() => [open.value, props.inquiry?.bookingNo, props.mode], () => { generation++; if (!open.value || dialog.value?.hasPending) return; activeMode.value = props.mode; loaded.value = null; void load() }, { immediate: true })
onBeforeUnmount(() => { generation++ })
</script>
<template>
<AppDialog class="admin-density-dialog" v-if="!loaded" v-model="open" title="读取本票处理进度" width="1080px" :show-confirm="false">
<p v-if="loading">正在读取本票真实履约资料…</p>
<LoadErrorState v-if="error" title="读取未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
</AppDialog>
<TripBookingFulfillmentDialog v-else ref="dialog" v-model="open" pool :booking="loaded.booking" :initial-preview="loaded.preview" :mode="activeMode"
:arrival-at="inquiry?.estimatedArrivalAt" :instructions="inquiry?.cargo.remark"
:trip="{ tripNo: loaded.booking.tripNo, recordVersion: loaded.preview.tripRecordVersion, plannedDepartureAt: loaded.plannedDepartureAt }"
@saved="emit('saved')" />
</template>
@@ -10,16 +10,20 @@ const access = useAccessPreviewStore()
function actions(row: CapacityInquiry, busy: boolean): RowActionItem[] {
const manage = access.can('platform:fleet:capacity-offer:manage')
const write = (kind: string) => manage || access.can('platform:fleet:capacity-offer:list') && access.can(`platform:fleet:capacity-offer:inquiry-${kind}`)
return [{ key: 'view', label: '详情', priority: 10 },
{ key: 'edit', label: '编辑', visible: row.actions.canEdit && write('edit'), disabled: busy },
{ key: 'confirm', label: '确认承运', visible: row.actions.canConfirm && manage, disabled: busy },
return [{ key: 'view', label: '查看进度', priority: 10 },
{ key: 'incoming', label: '安排来货', visible: manage && row.progress.primaryAction === 'INCOMING' && access.can('platform:fleet:trip:booking-edit'), disabled: busy },
{ key: 'receipt', label: '查看收货并核量', visible: manage && row.progress.primaryAction === 'RECEIPT' && access.can('platform:fleet:trip:booking-confirm'), disabled: busy },
{ key: 'bind', label: '确认排入本车', visible: manage && row.progress.primaryAction === 'BIND' && access.can('platform:fleet:trip:booking-edit') && access.can('platform:fleet:trip:edit') && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch'), disabled: busy },
{ key: 'fulfillment', label: '收货进度', visible: !!row.incomingNo, disabled: busy },
{ key: 'edit', label: '补充资料', visible: row.actions.canEdit && write('edit'), disabled: busy },
{ key: 'confirm', label: '核对并确认', visible: (row.actions.canConfirm || row.actions.canEdit) && manage, disabled: busy },
{ key: 'cancel', label: '取消意向', visible: row.actions.canCancel && write('cancel'), disabled: busy, tone: 'danger' }]
}
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 230, fixed: 'left', slotName: 'actions' },
{ key: 'bookingNo', label: '意向 / 预订号', minWidth: 210 }, { key: 'customerName', label: '客户', minWidth: 155 },
{ key: 'tripNo', label: '车次号', minWidth: 210 }, { key: 'destinationLabel', label: '卸货站', minWidth: 130 },
{ key: 'status', label: '状态', width: 125, slotName: 'status' }, { key: 'cargo', label: '预计货量', minWidth: 270, slotName: 'cargo' },
{ key: 'status', label: '当前进度 / 下一步', minWidth: 270, slotName: 'status', singleLine: false }, { key: 'cargo', label: '预计货量', minWidth: 270, slotName: 'cargo' },
{ key: 'agreedAmount', label: '约定运费(非实收)', minWidth: 165, slotName: 'amount' }, { key: 'businessOrderNo', label: '本土订单号', minWidth: 210 },
{ key: 'updatedAt', label: '更新时间', minWidth: 180, slotName: 'time' },
]
@@ -29,7 +33,7 @@ const columns: DataTableColumn[] = [
<template v-if="$slots.filters" #filters><slot name="filters" /></template>
<template v-if="$slots.notices" #notices><slot name="notices" /></template>
<template #actions="{ row }"><RowActions :actions="actions(row as CapacityInquiry, busy)" text-actions @action="emit('action', $event, row as CapacityInquiry)" /></template>
<template #status="{ row }">{{ inquiryStatusLabels[(row as CapacityInquiry).status] }}<small v-if="row.actions.blockReason" class="inquiry-reason">{{ row.actions.blockReason }}</small></template>
<template #status="{ row }"><strong>{{ row.progress.label }} · {{ row.progress.reservation }}</strong><div>下一步:{{ row.progress.owner }}</div><small class="inquiry-reason">{{ row.progress.message }}</small></template>
<template #cargo="{ row }">{{ row.cargo.packageCount ?? '—' }} 箱 · {{ row.expectedPalletCount ?? '—' }} 实托 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</template>
<template #amount="{ row }">{{ row.agreedAmount == null ? '待报价' : `${Number(row.agreedAmount).toFixed(2)} ${row.cargo.currency || ''}` }}</template>
<template #time="{ row }">{{ offerTime(row.updatedAt) }}</template>
@@ -1,3 +1,4 @@
import { createPinia } from 'pinia'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue'
@@ -14,7 +15,7 @@ const preview: TripIncomingPreview = { tripRecordVersion: 5, recordVersion: 2, i
const wrappers: VueWrapper[] = []
function mount(mode: 'incoming' | 'receipt' | 'bind' | 'view') {
const field = { props: ['modelValue'], emits: ['update:modelValue'], template: '<input />' }
const wrapper = shallowMount(TripBookingFulfillmentDialog, { props: { modelValue: true, trip, booking, mode }, global: { directives: { loading: () => undefined }, stubs: {
const wrapper = shallowMount(TripBookingFulfillmentDialog, { props: { modelValue: true, trip, booking, mode, arrivalAt: '2026-10-03T08:00:00Z' }, global: { plugins: [createPinia()], directives: { loading: () => undefined }, stubs: {
AppDialog: { name: 'AppDialog', props: ['modelValue', 'confirmDisabled'], emits: ['confirm'], template: '<div><slot/></div>' },
DataTable: { props: ['data'], template: '<div><div v-for="row in data" :key="row.warehousePalletId"><slot name="spaces" :row="row"/></div></div>' },
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElInputNumber: { ...field, name: 'ElInputNumber' }, ElDatePicker: field, ElInput: field,
@@ -43,3 +44,15 @@ it('retries the same incoming registration after an uncertain result without cre
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.startTripIncoming).toHaveBeenNthCalledWith(2, 'TR-1', 'FB-1', first); expect(api.getTripIncoming).not.toHaveBeenCalled()
})
it('keeps an uncertain receipt command retryable after closing and reopening without reloading or changing its hash', async () => {
vi.mocked(api.acceptTripIncoming).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: true })
const wrapper=mount('receipt'); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
const original=vi.mocked(api.acceptTripIncoming).mock.calls[0]![2]
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect(wrapper.findComponent({ name: 'AppDialog' }).props('confirmDisabled')).toBe(false)
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.acceptTripIncoming).toHaveBeenNthCalledWith(2, 'TR-1', 'FB-1', original)
expect(api.getTripIncoming).toHaveBeenCalledOnce()
})
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
@@ -7,22 +7,26 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getTripIncoming, startTripIncoming, acceptTripIncoming, bindTripBookingDispatch } from '../api/tripBookings'
import { getInquiryFulfillment, commandInquiryFulfillment } from '../api/capacityOffers'
import { useFleetDraftGuard } from '../useFleetDraftGuard'
import type { FleetTrip } from '../tripTypes'
import type { TripBooking, TripBookingResult, TripIncomingPreview, TripIncomingStart, TripIncomingAccept, TripBookingBind } from '../bookingTypes'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ trip: FleetTrip; booking: TripBooking | null; mode: 'incoming' | 'receipt' | 'bind' | 'view' }>()
const props = defineProps<{ trip: Pick<FleetTrip, 'tripNo' | 'recordVersion' | 'plannedDepartureAt'>; initialPreview?: TripIncomingPreview | null; pool?: boolean; arrivalAt?: string | null; instructions?: string | null; booking: TripBooking | null; mode: 'incoming' | 'receipt' | 'bind' | 'view' }>()
const emit = defineEmits<{ saved: [value: TripBookingResult] }>()
const loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null), preview = ref<TripIncomingPreview | null>(null)
const spaces = reactive<Record<string, number>>({}), unitPrice = ref<number | null>(null), dispatchNo = ref('')
const form = reactive({ estimatedArrivalAt: '', deliveryInstructions: '', privateAddress: '', postalCode: '', recipientName: '', recipientPhone: '' })
const pending = ref<{ target: string; body: TripIncomingStart | TripIncomingAccept | TripBookingBind } | null>(null)
const guard = useFleetDraftGuard('booking-fulfillment', open, reactive({ form, spaces, unitPrice, dispatchNo }), busy, computed(() => !!pending.value))
defineExpose({ hasPending: computed(() => !!pending.value) })
let generation = 0
const target = computed(() => `${props.trip.tripNo}:${props.booking?.bookingNo}:${props.mode}`)
const titles = { incoming: '登记外接来货', receipt: '核对外接实收与运费', bind: '关联外接排车单', view: '外接来货详情' }
const titles = { incoming: '登记外接来货', receipt: '核对外接实收与运费', bind: '确认排入本车', view: '外接来货详情' }
const ready = computed(() => preview.value?.incoming && ['WAIT_CUSTOMER_CONFIRMATION', 'READY_FOR_DISPATCH'].includes(preview.value.incoming.status))
const totalSpaces = computed(() => Object.values(spaces).reduce((a,b) => a + (b || 0), 0))
const amount = computed(() => unitPrice.value == null ? null : Math.round(unitPrice.value * (props.booking?.cargo.priceBasis === 'ORDER' ? 1 : totalSpaces.value) * 100) / 100)
const statuses: Record<string,string> = { WAIT_PDA_RECEIVING: '等待仓库收货', WAIT_CUSTOMER_CONFIRMATION: '实收完成,待确认', READY_FOR_DISPATCH: '已确认实收,可安排排车', OUTBOUNDED: '已出库' }
const statuses: Record<string,string> = { WAIT_PDA_RECEIVING: '等待仓库收货', RECEIVING: '仓库收货中', WAIT_CUSTOMER_CONFIRMATION: '实收完成,待确认', READY_FOR_DISPATCH: '已确认实收,可安排排车', OUTBOUNDED: '已出库' }
const columns = computed<DataTableColumn[]>(() => [{ key: 'palletNo', prop: 'palletNo', label: '实收托盘', minWidth: 180 }, { key: 'packages', prop: 'packages', label: '箱数', width: 95 },
{ key: 'weightKg', prop: 'weightKg', label: '实测重量(kg)', minWidth: 150 }, { key: 'volumeCbm', prop: 'volumeCbm', label: '实测体积(m³)', minWidth: 150 },
...(props.mode === 'receipt' ? [{ key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 170 }] : [])])
@@ -30,29 +34,36 @@ async function load() {
if (!props.booking) return
const current = generation; loading.value = true; error.value = null
try {
const value = await getTripIncoming(props.trip.tripNo, props.booking.bookingNo)
const value = props.pool ? (await getInquiryFulfillment(props.booking.bookingNo)).preview : await getTripIncoming(props.trip.tripNo, props.booking.bookingNo)
if (current !== generation) return
preview.value = value; Object.keys(spaces).forEach(key => delete spaces[key])
for (const pallet of value.incoming?.pallets || []) spaces[pallet.warehousePalletId] = props.booking.receipt?.pallets.find(row => row.warehousePalletId === pallet.warehousePalletId)?.palletSpaces || 1
await nextTick(); guard.capture()
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '来货信息读取失败') }
finally { if (current === generation) loading.value = false }
}
watch(() => [open.value, target.value], () => {
generation++; busy.value = false; error.value = null; preview.value = null
if (!open.value || !props.booking) return
if (pending.value) return
unitPrice.value = props.booking.cargo.unitPrice; dispatchNo.value = props.booking.dispatchNo || ''
Object.assign(form, { estimatedArrivalAt: new Date(new Date(props.trip.plannedDepartureAt).getTime() - 7200000).toISOString(), deliveryInstructions: '', privateAddress: '', postalCode: '', recipientName: '', recipientPhone: '' })
if (pending.value?.target !== target.value) pending.value = null
if (props.mode === 'receipt' || props.mode === 'view') void load()
Object.assign(form, { estimatedArrivalAt: props.arrivalAt || '', deliveryInstructions: props.instructions || props.booking.cargo.remark || '', privateAddress: '', postalCode: '', recipientName: '', recipientPhone: '' })
if (props.initialPreview && (props.mode === 'receipt' || props.mode === 'view')) {
preview.value = props.initialPreview; Object.keys(spaces).forEach(key => delete spaces[key])
for (const pallet of props.initialPreview.incoming?.pallets || []) spaces[pallet.warehousePalletId] = props.booking.receipt?.pallets.find(row => row.warehousePalletId === pallet.warehousePalletId)?.palletSpaces || 1
void nextTick(() => guard.capture())
} else if (props.mode === 'receipt' || props.mode === 'view') void load()
else void nextTick(() => guard.capture())
}, { immediate: true })
async function save() {
if (busy.value || loading.value || props.mode === 'view' || !props.booking) return
const current = generation, booking = props.booking, mode = props.mode
if (!pending.value) {
const versions = { tripRecordVersion: preview.value?.tripRecordVersion ?? props.trip.recordVersion, recordVersion: preview.value?.recordVersion ?? booking.recordVersion, idempotencyKey: crypto.randomUUID() }
if (mode === 'incoming' && !form.estimatedArrivalAt) { ElMessage.warning('请确认预计到仓时间'); return }
if (mode === 'incoming') pending.value = { target: target.value, body: { ...versions, ...form, estimatedArrivalAt: form.estimatedArrivalAt || null } }
else if (mode === 'bind') {
if (!dispatchNo.value.trim()) { ElMessage.warning('请输入已预排的客户排车单号'); return }
if (!props.pool && !dispatchNo.value.trim()) { ElMessage.warning('请输入已预排的客户排车单号'); return }
pending.value = { target: target.value, body: { ...versions, dispatchNo: dispatchNo.value.trim() } }
} else {
if (!ready.value || !preview.value?.incoming?.pallets.length || unitPrice.value == null || totalSpaces.value < 1) { ElMessage.warning('请等待实际收货完成,并核对托位及约定价格'); return }
@@ -63,32 +74,32 @@ async function save() {
busy.value = true; error.value = null
try {
const body = pending.value.body
const value = mode === 'incoming' ? await startTripIncoming(props.trip.tripNo, booking.bookingNo, body as TripIncomingStart)
const value = props.pool ? await commandInquiryFulfillment(booking.bookingNo, mode === 'incoming' ? 'incoming' : mode === 'receipt' ? 'receipt-confirmation' : 'arrange-dispatch', mode === 'bind' ? { tripRecordVersion: (body as TripBookingBind).tripRecordVersion, recordVersion: (body as TripBookingBind).recordVersion, idempotencyKey: (body as TripBookingBind).idempotencyKey } : body) : mode === 'incoming' ? await startTripIncoming(props.trip.tripNo, booking.bookingNo, body as TripIncomingStart)
: mode === 'bind' ? await bindTripBookingDispatch(props.trip.tripNo, booking.bookingNo, body as TripBookingBind)
: await acceptTripIncoming(props.trip.tripNo, booking.bookingNo, body as TripIncomingAccept)
if (current === generation) { emit('saved', value); pending.value = null; open.value = false; ElMessage.success('外接履约记录已更新') }
if (current === generation) { pending.value = null; guard.capture(); emit('saved', value); open.value = false; ElMessage.success('外接履约记录已更新') }
} catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '外接履约操作未完成'); if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) pending.value = null } }
finally { busy.value = false }
}
onBeforeUnmount(() => { generation++ })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog" :title="titles[mode]" width="1080px" :show-confirm="mode !== 'view'" :confirm-text="pending ? '重试原请求' : mode === 'receipt' ? '确认实收与运费' : mode === 'bind' ? '关联排车单' : '生成来货单'" :confirm-disabled="loading || (mode === 'receipt' && !ready)" :confirm-loading="busy" @confirm="save">
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" :title="titles[mode]" width="1080px" :show-confirm="mode !== 'view'" :confirm-text="pending ? '重试原请求' : mode === 'receipt' ? '确认实收与运费' : mode === 'bind' ? '确认排入本车' : '生成来货单'" :confirm-disabled="loading || (!pending && mode === 'receipt' && !ready)" :confirm-loading="busy" @confirm="save">
<LoadErrorState v-if="error" title="操作未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-if="booking" v-loading="loading">
<p>{{ booking.bookingNo }} · {{ booking.customerName }} · {{ booking.destinationLabel }}</p>
<p>{{ booking.bookingNo }} · {{ booking.customerName }} · {{ booking.destinationLabel }}</p><p v-if="pending" role="status">本票上次提交结果尚未确定,保留原内容与请求键重试;不会改为处理另一票货物。</p>
<el-form v-if="mode === 'incoming'" label-position="top" class="density-form incoming-form" :disabled="busy || !!pending">
<el-form-item label="预计到仓时间"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="交货说明"><el-input v-model="form.deliveryInstructions" placeholder="选填,默认客户自送至本起运仓" /></el-form-item>
<template v-if="booking.cargo.destinationKey.startsWith('PRIVATE:')"><el-form-item label="收件地址"><el-input v-model="form.privateAddress" placeholder="填写约定卸货地址" /></el-form-item><el-form-item label="邮编"><el-input v-model="form.postalCode" placeholder="填写收件邮编" /></el-form-item><el-form-item label="收件人"><el-input v-model="form.recipientName" placeholder="填写收件人" /></el-form-item><el-form-item label="收件电话"><el-input v-model="form.recipientPhone" placeholder="填写收件电话" /></el-form-item></template>
</el-form>
<template v-if="mode === 'receipt' || mode === 'view'">
<p>来货单:{{ preview?.incoming?.incomingNo || '—' }} · {{ statuses[preview?.incoming?.status || ''] || '等待读取' }}</p>
<el-button :loading="loading" :disabled="busy || !!pending" @click="load">刷新收货进度</el-button><p>来货单:{{ preview?.incoming?.incomingNo || '—' }} · {{ statuses[preview?.incoming?.status || ''] || '等待读取' }}</p>
<DataTable :data="preview?.incoming?.pallets || []" :columns="columns" row-key="warehousePalletId" empty-text="仓库尚未完成实际打托"><template #spaces="{ row }"><el-input-number v-model="spaces[row.warehousePalletId]" :min="1" :precision="0" :disabled="busy || !!pending" placeholder="实际占用托位" /></template></DataTable>
<el-form v-if="mode === 'receipt'" label-position="top" class="density-form incoming-form" :disabled="busy || !!pending"><el-form-item :label="booking.cargo.priceBasis === 'ORDER' ? '确认整单价格' : '确认每托位价格'"><el-input-number v-model="unitPrice" :min="0" :precision="2" placeholder="核对约定价格" /></el-form-item></el-form>
<p v-if="mode === 'receipt'">原约定 {{ booking.agreedAmount?.toFixed(2) }} {{ booking.cargo.currency }};本次实收 {{ preview?.incoming?.receivedPackages || 0 }} 箱、{{ totalSpaces }} 托位,确认金额 {{ amount?.toFixed(2) || '—' }} {{ booking.cargo.currency }}。转派送时按本次确认报价登记应收。</p>
</template>
<el-form v-if="mode === 'bind'" label-position="top" class="density-form" :disabled="busy || !!pending"><p>先将来货库存预排为客户排车单,再填写排车单号。关联后重新核对整车装载并完成审核。</p><el-form-item label="排车单号"><el-input v-model="dispatchNo" placeholder="输入包含本次实收货物的排车单号" /></el-form-item></el-form>
<el-form v-if="mode === 'bind'" label-position="top" class="density-form" :disabled="busy || !!pending"><p v-if="pool">将本票全部实收货物排入原车次。系统复用合格排车单,没有时生成并关联;来源冲突或货量不完整时停止。成功后仍需核对容量并完成审核,不代表已装车或发车。</p><p v-else>先将来货库存预排为客户排车单,再填写排车单号。关联后重新核对整车装载并完成审核。</p><el-form-item v-if="!pool" label="排车单号"><el-input v-model="dispatchNo" placeholder="输入包含本次实收货物的排车单号" /></el-form-item></el-form>
</div>
</AppDialog>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { ref, watch, onBeforeUnmount } from 'vue'
import { listTripDispatchCandidates, type DispatchCandidate } from '../api/trips'
import { getApiErrorDetails } from '@/shared/api/http'
const value = defineModel<string>({ default: '' })
const props = defineProps<{ tripNo: string; disabled?: boolean }>()
const rows = ref<DispatchCandidate[]>([]), loading = ref(false), error = ref(''), total = ref(0)
let sequence = 0, timer: ReturnType<typeof setTimeout> | undefined
function search(keyword = '') {
clearTimeout(timer); const current = ++sequence
timer = setTimeout(async () => {
loading.value = true; error.value = ''
try { const result = await listTripDispatchCandidates(props.tripNo, keyword.trim() || undefined); if (current === sequence) { rows.value = result.records; total.value = result.total } }
catch (cause) { if (current === sequence) { rows.value = []; error.value = getApiErrorDetails(cause, '待运货物加载失败').message } }
finally { if (current === sequence) loading.value = false }
}, 250)
}
watch(() => props.tripNo, () => { sequence++; clearTimeout(timer); rows.value = []; value.value = ''; loading.value = false })
onBeforeUnmount(() => { sequence++; clearTimeout(timer) })
</script>
<template><div><el-select v-model="value" aria-label="选择本仓待运排车单" filterable remote :remote-method="search" :loading="loading" :disabled="disabled" placeholder="按排车单号或目的地搜索" @focus="search()"><el-option v-for="row in rows" :key="row.dispatchNo" :label="`${row.dispatchNo} · ${row.route || '路线待核对'} · ${row.packageCount}箱`" :value="row.dispatchNo" /></el-select><p v-if="error" role="alert">{{ error }} <el-button link @click="search()">重试</el-button></p><small v-if="total > rows.length">候选较多,请输入排车单号缩小范围。</small></div></template>
@@ -0,0 +1,41 @@
import { createPinia } from 'pinia'
import { shallowMount, flushPromises } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import TripPreparationDialog from './TripPreparationDialog.vue'
import { listTrips, prepareTrip } from '../api/trips'
import type { TripDetail, FleetTrip } from '../tripTypes'
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('../api/trips', () => ({ listTrips: vi.fn(), prepareTrip: vi.fn() }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() } }))
const trip = { tripNo: 'TR-READY', recordVersion: 4, warehouseId: '9007199254740993', stops: [{ name: '起运仓' }, { name: 'ACY2' }] } as FleetTrip
beforeEach(() => { vi.clearAllMocks(); vi.mocked(listTrips).mockResolvedValue({ records: [trip], page: 1, size: 100, total: 1, pages: 1 }) })
function render() {
const field = { props: ['modelValue'], emits: ['update:modelValue', 'focus'], template: '<div><slot/></div>' }
return shallowMount(TripPreparationDialog, { props: { modelValue: true, warehouseId: trip.warehouseId, warehouseName: '本仓', dispatchNos: ['DS-ONE', 'DS-TWO'] }, global: { plugins: [createPinia()], stubs: {
AppDialog: { name: 'AppDialog', props: ['showConfirm'], emits: ['confirm'], template: '<div><slot/></div>' },
ElForm: field, ElFormItem: field, ElSelect: { ...field, name: 'ElSelect' }, ElOption: true,
ElRadioGroup: { ...field, name: 'ElRadioGroup' }, ElRadio: true, ElDatePicker: field,
ElButton: { template: '<button><slot/></button>' }, CapacityOfferSettingsDialog: true,
} } })
}
it('loads same-warehouse trips on demand and retries one immutable atomic preparation', async () => {
const wrapper=render(); await flushPromises()
expect(listTrips).not.toHaveBeenCalled()
const select=wrapper.findComponent({ name: 'ElSelect' })
select.vm.$emit('focus'); await flushPromises()
expect(listTrips).toHaveBeenCalledWith(expect.objectContaining({ warehouseId: '9007199254740993', status: 'PLANNED' }))
select.vm.$emit('update:modelValue', trip.tripNo); await flushPromises()
vi.mocked(prepareTrip).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ trip, dispatches: [], driverQualification: null, loadingReview: { status: 'PENDING', reason: null, reviewerId: null, reviewedAt: null, recordVersion: null } } as TripDetail)
const dialog=wrapper.findComponent({ name: 'AppDialog' })
dialog.vm.$emit('confirm'); await flushPromises()
const original=vi.mocked(prepareTrip).mock.calls[0]![0]
expect(original).toMatchObject({ tripNo: 'TR-READY', recordVersion: 4, dispatchNos: ['DS-ONE','DS-TWO'], plan: null, vehicleId: null, driverId: null })
expect(wrapper.emitted('saved')).toBeUndefined()
dialog.vm.$emit('confirm'); await flushPromises()
expect(prepareTrip).toHaveBeenNthCalledWith(2, original)
expect(wrapper.emitted('saved')).toHaveLength(1)
expect(wrapper.text()).toContain('核对容量并发布余位')
expect(dialog.props('showConfirm')).toBe(false)
wrapper.unmount()
})
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { listTrips, prepareTrip, type TripPreparation } from '../api/trips'
import { listVehicles } from '../api/overseasMasterData'
import { listDrivers } from '../api/drivers'
import type { FleetTrip, TripDetail } from '../tripTypes'
import type { VehicleDetail } from '../types'
import type { DriverDetail } from '../driverTypes'
import { useFleetDraftGuard } from '../useFleetDraftGuard'
import CapacityOfferSettingsDialog from './CapacityOfferSettingsDialog.vue'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ warehouseId: string; warehouseName: string; dispatchNos: string[] }>()
const emit = defineEmits<{ saved: [value: TripDetail] }>()
const access = useAccessPreviewStore(), router = useRouter()
const form = reactive({ mode: 'existing', tripNo: '', vehicleId: '', driverId: '', departure: '', end: '' })
const trips = ref<FleetTrip[]>([]), vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([])
const busy = ref(false), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const submittedSummary = ref<{ warehouseName: string; dispatchNos: string[] } | null>(null)
const pending = ref<TripPreparation | null>(null), result = ref<TripDetail | null>(null), publishOpen = ref(false)
const canCreate = computed(() => access.can('platform:fleet:trip:create') && access.can('platform:fleet:trip:assign'))
const guard = useFleetDraftGuard('trip-preparation', open, form, busy, computed(() => !!pending.value))
let generation = 0, searchSequence = 0
watch(open, async visible => {
generation++; searchSequence++
if (!visible || pending.value) return
Object.assign(form, { mode: 'existing', tripNo: '', vehicleId: '', driverId: '', departure: '', end: '' })
result.value = null; trips.value = []; vehicles.value = []; drivers.value = []; error.value = null
await nextTick(); guard.capture()
})
async function search(kind: 'trip' | 'vehicle' | 'driver', keyword = '') {
const current = ++searchSequence; loading.value = true; error.value = null
try {
if (kind === 'trip') { const value = await listTrips({ page: 1, size: 100, warehouseId: props.warehouseId, status: 'PLANNED', keyword: keyword || undefined }); if (current === searchSequence) trips.value = value.records }
if (kind === 'vehicle') { const value = await listVehicles({ page: 1, size: 100, region: 'OVERSEAS', status: 'IN_SERVICE', keyword: keyword || undefined }); if (current === searchSequence) vehicles.value = value.records }
if (kind === 'driver') { const value = await listDrivers({ page: 1, size: 100, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: form.end || undefined, keyword: keyword || undefined }); if (current === searchSequence) drivers.value = value.records }
} catch (cause) { if (current === searchSequence) error.value = getApiErrorDetails(cause, '候选读取失败') }
finally { if (current === searchSequence) loading.value = false }
}
async function save() {
if (busy.value || !access.can('platform:fleet:trip:edit')) return
if (!pending.value) {
const creating = form.mode === 'new', selected = trips.value.find(t => t.tripNo === form.tripNo)
if (!props.warehouseId || !props.dispatchNos.length) { ElMessage.warning('请选择同仓待运排车单'); return }
if (creating ? !canCreate.value || !form.vehicleId || !form.driverId || !form.departure || !form.end : !selected) { ElMessage.warning('请完整选择车次或新车次资源和时间'); return }
submittedSummary.value = { warehouseName: props.warehouseName, dispatchNos: [...props.dispatchNos] }
const key = crypto.randomUUID()
pending.value = { tripNo: creating ? null : selected!.tripNo, recordVersion: creating ? null : selected!.recordVersion,
vehicleId: creating ? form.vehicleId : null, driverId: creating ? form.driverId : null, dispatchNos: [...props.dispatchNos], idempotencyKey: key,
plan: creating ? { warehouseId: props.warehouseId, plannedDepartureAt: form.departure, expectedDriverEndAt: form.end, stops: [{ name: '起运仓' }, { name: '待确定卸货点' }], idempotencyKey: key } : null }
}
const current = generation; busy.value = true; error.value = null
try {
const value = await prepareTrip(pending.value)
if (current === generation) { result.value = value; pending.value = null; guard.capture(); emit('saved', value); ElMessage.success('已安排车次,请核对容量后发布余位') }
} catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '安排车次未完成'); if (cause instanceof ApiResponseError) pending.value = null } }
finally { if (current === generation) busy.value = false }
}
onBeforeUnmount(() => { generation++; searchSequence++ })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog" title="安排车次" width="1080px" :before-close="guard.beforeClose" :show-confirm="!result" :confirm-text="pending ? '重试原请求' : '确认安排'" :confirm-loading="busy" @confirm="save">
<LoadErrorState v-if="error" title="安排未完成" :message="error.message" @retry="save"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p>{{ pending ? submittedSummary?.warehouseName : warehouseName }} · 已选 {{ (pending ? submittedSummary?.dispatchNos : dispatchNos)?.length }} 张排车单:{{ (pending ? submittedSummary?.dispatchNos : dispatchNos)?.join('、') }}</p>
<p v-if="pending">提交结果尚未确定,请保留原请求重试,不另建车次。</p>
<template v-if="result"><p>已安排到 {{ result.trip.tripNo }} · {{ result.trip.stops.map(s => s.name).join(' → ') }}</p><p>多目的地请先在车次中确认行驶顺序,再核对容量发布余位。安排成功不代表已审核或发车。</p><el-button v-if="access.can('platform:fleet:trip:list')" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: result.trip.tripNo } })">查看车次</el-button><el-button v-if="access.can('platform:fleet:capacity-offer:manage') && access.can('platform:fleet:trip:list')" type="primary" @click="publishOpen = true">核对容量并发布余位</el-button></template>
<el-form v-else label-position="top" class="density-form preparation-form" :disabled="busy || !!pending">
<el-form-item label="安排方式"><el-radio-group v-model="form.mode"><el-radio value="existing">加入已有车次</el-radio><el-radio v-if="canCreate" value="new">新建车次</el-radio></el-radio-group></el-form-item>
<el-form-item v-if="form.mode === 'existing'" label="本仓计划中车次" required><el-select v-model="form.tripNo" filterable remote :remote-method="(k: string) => search('trip', k)" :loading="loading" placeholder="按车次、车牌或司机搜索" @focus="search('trip')"><el-option v-for="t in trips" :key="t.tripNo" :value="t.tripNo" :label="`${t.tripNo} · ${t.vehiclePlate || '待分配车辆'} · ${t.stops.map(s => s.name).join(' → ')}`" /></el-select></el-form-item>
<template v-else>
<el-form-item label="计划发车时间" required><el-date-picker v-model="form.departure" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="司机预计任务结束" required><el-date-picker v-model="form.end" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="包括返程或转场" /></el-form-item>
<el-form-item label="车辆" required><el-select v-model="form.vehicleId" placeholder="按车牌搜索" filterable remote :remote-method="(k: string) => search('vehicle', k)" :loading="loading" @focus="search('vehicle')"><el-option v-for="v in vehicles" :key="v.id" :value="v.id" :label="`${v.plateNumber} · ${v.maxPalletSpaces ?? '待核对'}托位`" /></el-select></el-form-item>
<el-form-item label="司机" required><el-select v-model="form.driverId" placeholder="按姓名搜索" filterable remote :remote-method="(k: string) => search('driver', k)" :loading="loading" @focus="search('driver')"><el-option v-for="d in drivers" :key="d.id" :value="d.id" :label="d.details.name" /></el-select></el-form-item>
</template>
</el-form>
</AppDialog>
<CapacityOfferSettingsDialog v-if="result" v-model="publishOpen" :trip="result.trip" @trip-updated="result.trip = $event" />
</template>
<style scoped>.preparation-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.preparation-form :deep(.el-select),.preparation-form :deep(.el-date-editor){width:100%}</style>