fix: align appointment viewing and fleet follow-up navigation

This commit is contained in:
2026-10-11 15:33:09 +08:00
parent ee174f8265
commit 315cb8e663
21 changed files with 232 additions and 41 deletions
@@ -12,6 +12,7 @@ import { offerTime, appointmentCoverageLabels, appointmentMissingLabels, inquiry
import type { CapacityOffer, CapacityInquiry, InquiryResult, InquiryWrite, OfferCustomerOption } from '../capacityOfferTypes'
import type { TripBookingCargo } from '../bookingTypes'
import { useFleetDraftGuard } from '../useFleetDraftGuard'
import { inquiryOrderTarget } from '../inquiryNavigation'
const open = defineModel<boolean>({ default: false })
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] }>()
@@ -37,6 +38,7 @@ const primaryFollowup = computed(() => {
return followupActions.value.find(action => action.key.toUpperCase() === key)
})
const secondaryFollowups = computed(() => followupActions.value.filter(action => action !== primaryFollowup.value))
const canReadOrder = computed(() => props.inquiry && inquiryOrderTarget(props.inquiry, access.can))
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)
@@ -114,7 +116,7 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
</section>
<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>
<section v-if="inquiry" aria-label="本票处理进度"><strong>{{ inquiry.customerName }} · {{ inquiry.destinationLabel }}</strong><p>车次:{{ inquiry.tripNo }} · 登记:{{ inquiry.bookingNo }}</p><strong>{{ inquiry.progress.label }} · {{ inquiry.progress.reservation }}</strong><p>下一步负责人:{{ inquiry.progress.owner }} · {{ inquiry.progress.message }}</p></section>
<p v-else>先选客户即可登记;多站车次需选择卸货站。保存后调度可见,尚不保位。</p>
<p v-if="!inquiry">业务类型:{{ form.businessType === 'US_TRUCK' ? '本土卡车' : '本土散板' }},可在“确认承运前待补资料”中调整。</p>
<p v-if="offer">计划发车:{{ offerTime(offer.plannedDepartureAt) }} · 最晚送仓:{{ offerTime(offer.cutoffAt) }}</p>
@@ -163,7 +165,7 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
<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>
<p v-if="inquiry">本土订单:<el-button v-if="canReadOrder" link @click="emit('action', 'business-order', inquiry)">{{ inquiry.businessOrderNo }}</el-button><span v-else>{{ inquiry.businessOrderNo || '尚未成交' }}</span> · 来货:{{ inquiry.incomingNo || '尚未登记' }} · 排车:{{ inquiry.dispatchNo || '尚未关联' }}</p>
</AppDialog>
</template>
<style scoped>.capacity-inquiry-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.capacity-inquiry-form :deep(.el-select),.capacity-inquiry-form :deep(.el-input-number),.capacity-inquiry-form :deep(.el-date-editor){width:100%}.capacity-inquiry-wide{grid-column:1/-1}</style>
@@ -1,3 +1,4 @@
import { ElMessage } from 'element-plus'
import { ElTable, ElTableColumn } from 'element-plus/es/components/table/index'
import { ElInputNumber } from 'element-plus/es/components/input-number/index'
import { ElInput } from 'element-plus/es/components/input/index'
@@ -35,6 +36,7 @@ it('uses server actual pallets and preview versions while requiring explicit pri
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.acceptTripIncoming).toHaveBeenCalledWith('TR-1', 'FB-1', expect.objectContaining({ tripRecordVersion: 5, recordVersion: 2, receiptHash: 'receipt-hash', unitPrice: 55, pallets: [{ warehousePalletId: '902', palletSpaces: 2 }] }))
expect(api.startTripIncoming).not.toHaveBeenCalled(); expect(api.bindTripBookingDispatch).not.toHaveBeenCalled()
expect(ElMessage.success).toHaveBeenCalledWith('实收货量及约定运费已核实')
})
it('does not accept a receipt while the warehouse is still waiting for actual goods', async () => {
vi.mocked(api.getTripIncoming).mockResolvedValue({ ...preview, incoming: { ...preview.incoming!, status: 'WAIT_PDA_RECEIVING', pallets: [], receivedPackages: 0 } })
@@ -98,7 +98,7 @@ async function save() {
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) { pending.value = null; guard.capture(); emit('saved', value); open.value = false; ElMessage.success('外接履约记录已更新') }
if (current === generation) { pending.value = null; guard.capture(); emit('saved', value); open.value = false; ElMessage.success(value.booking.status === 'BOUND' ? '已排入本车,尚未装车或发车' : value.booking.status === 'RECEIVED' ? '实收货量及约定运费已核实' : value.booking.incomingNo ? '已安排来货,等待仓库实际收货' : '履约命令已完成,请查看最新进度') }
} catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '外接履约操作未完成'); if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) pending.value = null } }
finally { busy.value = false }
}
@@ -1,4 +1,5 @@
export { overseasMasterDataRoutes } from './routes'
export { inquiryReturnTarget } from './inquiryNavigation'
export { overseasMasterDataPermissions } from './permissions'
export type * from './types'
export { default as TransportRouteDialog } from './components/TransportRouteDialog.vue'
@@ -0,0 +1,16 @@
import { expect, it } from 'vitest'
import { inquiryReturnTarget, inquirySourceQuery, inquiryOrderTarget } from './inquiryNavigation'
import type { CapacityInquiry } from './capacityOfferTypes'
it('returns only to a fixed internal inquiry route and rejects malformed or repeated values', () => {
expect(inquiryReturnTarget(inquirySourceQuery({ offerNo: 'FO-1', bookingNo: 'FB-2' }))).toEqual({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'FO-1' }, query: { inquiry: 'FB-2' } })
for (const value of ['https://example.com', '../FB-2', ['FB-2'], '', null]) expect(inquiryReturnTarget({ fleetSource: 'inquiry', fleetOfferNo: 'FO-1', fleetBookingNo: value })).toBeNull()
expect(inquiryReturnTarget({ returnTo: 'https://example.com' })).toBeNull()
})
it.each(['US_TRUCK', 'LOCAL_LOOSEBOARD'] as const)('opens %s using only its list permission and the business number', businessType => {
const row = { businessType, businessOrderNo: 'ORDER-1', offerNo: 'FO-1', bookingNo: 'FB-2' } as CapacityInquiry
const domain = businessType === 'US_TRUCK' ? 'truck' : 'looseboard'
expect(inquiryOrderTarget(row, permission => permission === `platform:overseas-business:${domain}:list`)).toMatchObject({ query: { orderNo: 'ORDER-1', fleetBookingNo: 'FB-2' } })
expect(inquiryOrderTarget(row, permission => permission.endsWith(':edit'))).toBeNull()
expect(inquiryOrderTarget({ ...row, businessOrderNo: null }, () => true)).toBeNull()
})
@@ -0,0 +1,16 @@
import type { LocationQuery, RouteLocationRaw } from 'vue-router'
import type { CapacityInquiry } from './capacityOfferTypes'
const businessNo = (value: unknown): value is string => typeof value === 'string' && /^[A-Z][A-Z0-9-]{1,63}$/.test(value)
export function inquirySourceQuery(row: Pick<CapacityInquiry, 'offerNo' | 'bookingNo'>) {
return { fleetSource: 'inquiry', fleetOfferNo: row.offerNo, fleetBookingNo: row.bookingNo }
}
export function inquiryReturnTarget(query: LocationQuery = {}): RouteLocationRaw | null {
if (query.fleetSource !== 'inquiry' || !businessNo(query.fleetOfferNo) || !businessNo(query.fleetBookingNo)) return null
return { name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: query.fleetOfferNo }, query: { inquiry: query.fleetBookingNo } }
}
export function inquiryOrderTarget(row: CapacityInquiry, can: (permission: string) => boolean): RouteLocationRaw | null {
const domain = row.businessType === 'US_TRUCK' ? 'truck' : 'looseboard'
if (!row.businessOrderNo || !can(`platform:overseas-business:${domain}:list`)) return null
return { name: domain === 'truck' ? 'overseas-business-trucks' : 'overseas-business-looseboards', query: { orderNo: row.businessOrderNo, ...inquirySourceQuery(row) } }
}
@@ -14,16 +14,30 @@ import CapacityInquiryFulfillment from '../components/CapacityInquiryFulfillment
import CapacityInquiryDialog from '../components/CapacityInquiryDialog.vue'
import { getCapacityOffer, getCapacityInquiry, listCapacityInquiries } from '../api/capacityOffers'
import { offerStatusLabels, offerTime, loadText, type CapacityOfferDetail, type CapacityInquiry, type InquiryResult } from '../capacityOfferTypes'
import { inquirySourceQuery, inquiryOrderTarget } from '../inquiryNavigation'
import { useCapacityInquiryCommands } from '../useCapacityInquiryCommands'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const detail = ref<CapacityOfferDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), inquiryError = ref<ApiErrorDetails | null>(null)
const page = ref(1), size = ref(20), inquiryLoading = ref(false), editorOpen = ref(false), editing = ref<CapacityInquiry | null>(null), readonly = ref(false)
const confirmMode = ref(false)
const fulfillmentOpen = ref(false), fulfillmentRow = ref<CapacityInquiry | null>(null), fulfillmentMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view')
async function fulfillmentSaved() {
const row = fulfillmentRow.value
fulfillmentOpen.value = false
if (!row) return
const current = ++followupSequence
await load()
try {
const updated = await getCapacityInquiry(row.bookingNo)
if (current === followupSequence) await action('view', updated)
} catch (cause) { if (current === followupSequence) error.value = getApiErrorDetails(cause, '操作已完成,下一步进度读取失败,请刷新原登记') }
}
let followupSequence = 0
function openFulfillment(key: string, row: CapacityInquiry) {
if (key === 'business-order') { const target = inquiryOrderTarget(row, access.can); if (target) void router.push(target); return true }
if (key === 'appointment' && access.can('platform:appointment:list') && access.can('platform:overseas-warehouse:last-mile-order:list')) { void router.push({ name: 'appointment-calendar', query: { sourceKey: `BOOKING:${row.bookingNo}`, customerId: row.customerId, destinationKey: row.cargo.destinationKey } }); return true }
if (key === 'dispatch' && row.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }); return true }
if (key === 'trip' && row.dispatchNo && access.can('platform:fleet:trip:list')) { void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo } }); return true }
if (key === 'dispatch' && row.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo }, query: inquirySourceQuery(row) }); return true }
if (key === 'trip' && row.dispatchNo && access.can('platform:fleet:trip:list')) { void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo }, query: inquirySourceQuery(row) }); return true }
if (!['incoming', 'receipt', 'bind', 'fulfillment'].includes(key)) return false
editorOpen.value = false
fulfillmentRow.value = row; fulfillmentMode.value = key === 'fulfillment' ? 'view' : key as 'incoming' | 'receipt' | 'bind'; fulfillmentOpen.value = true
@@ -39,6 +53,11 @@ async function load() {
const value = await getCapacityOffer(String(route.params.offerNo))
let checked: CapacityInquiry | null = null
if (editing.value && editorOpen.value) checked = value.inquiries.records.find(row => row.bookingNo === editing.value?.bookingNo) || await getCapacityInquiry(editing.value.bookingNo)
if (typeof route.query?.inquiry === 'string' && /^[A-Z][A-Z0-9-]{1,63}$/.test(route.query.inquiry)) {
const returned = await getCapacityInquiry(route.query.inquiry)
if (returned.offerNo !== value.offer.offerNo) throw new Error('登记不属于当前发布')
if (current === sequence) { checked = returned; readonly.value = true; confirmMode.value = false; editorOpen.value = true }
}
if (current === sequence) { resetting = true; detail.value = value; if (checked) editing.value = checked; page.value = value.inquiries.page; size.value = value.inquiries.size; queueMicrotask(() => { resetting = false }) }
}
catch (cause) { if (current === sequence) { detail.value = null; error.value = getApiErrorDetails(cause, '发布详情读取失败') } }
@@ -61,14 +80,14 @@ function accept(value: InquiryResult) {
for (const row of rows) { row.offerRecordVersion = value.offer.recordVersion; row.tripRecordVersion = value.offer.tripRecordVersion }
}
const commands = useCapacityInquiryCommands('capacity-offer-command', accept)
async function action(key: string, row: CapacityInquiry) { if (openFulfillment(key, row)) return; if (key === 'cancel') await commands.run(row, key); else { if (key === 'confirm' && !access.can('platform:fleet:capacity-offer:manage')) return; confirmMode.value = key === 'confirm'; editing.value = row; readonly.value = key !== 'edit' && key !== 'confirm'; editorOpen.value = true } }
async function action(key: string, row: CapacityInquiry) { followupSequence++; if (openFulfillment(key, row)) return; if (key === 'cancel') await commands.run(row, key); else { if (key === 'confirm' && !access.can('platform:fleet:capacity-offer:manage')) return; confirmMode.value = key === 'confirm'; editing.value = row; readonly.value = key !== 'edit' && key !== 'confirm'; editorOpen.value = true } }
function create() { if (!canCreate.value) return; confirmMode.value = false; editing.value = null; readonly.value = false; editorOpen.value = true }
watch(() => [route.name, route.params.offerNo], ([name, offerNo]) => {
watch(() => [route.name, route.params.offerNo, route.query?.inquiry], ([name, offerNo]) => {
if (name !== 'overseas-master-data-capacity-offer-detail' || typeof offerNo !== 'string' || !offerNo) return
detail.value = null; editorOpen.value = false; void load()
followupSequence++; detail.value = null; editorOpen.value = false; void load()
}, { immediate: true })
watch([page, size], loadInquiries)
onBeforeUnmount(() => { sequence++; inquirySequence++ })
onBeforeUnmount(() => { followupSequence++; sequence++; inquirySequence++ })
</script>
<template><AdminPage class="admin-density"><DetailPageLayout class="density-detail-layout surface-panel" aria-label="运力发布详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-capacity-offers' })">返回拼车接货</el-button><h1 class="density-detail-title">{{ detail?.offer.offerNo || '运力发布详情' }}</h1><span v-if="detail">{{ offerStatusLabels[detail.offer.status] }}</span></div><div class="density-actions"><el-button :loading="loading" @click="load">重新核对</el-button><el-button v-if="hasCreatePermission" type="primary" :disabled="loading || !canCreate" @click="create">登记客户货物</el-button></div></div></template>
@@ -82,7 +101,7 @@ onBeforeUnmount(() => { sequence++; inquirySequence++ })
<LoadErrorState v-if="inquiryError || commands.error.value" title="意向操作未完成" :message="(inquiryError || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : loadInquiries()"><template #details><ApiErrorMeta :details="(inquiryError || commands.error.value)!" /></template></LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button>
<CapacityInquiryTable v-model:page="page" v-model:size="size" :rows="detail.inquiries.records" :total="detail.inquiries.total" :loading="inquiryLoading" :failed="!!inquiryError" :busy="commands.busy.value" @action="action" />
</template></div>
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="load" />
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="fulfillmentSaved" />
<CapacityInquiryDialog v-model="editorOpen" :offer="detail?.offer || null" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="action" @saved="accept" @refresh="load" />
</DetailPageLayout></AdminPage></template>
<style scoped>.capacity-section-title{font-size:var(--admin-font-section);margin:var(--admin-space-6) 0 var(--admin-space-3)}</style>
@@ -11,7 +11,7 @@ import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
import * as api from '../api/capacityOffers'
import type { CapacityOffer, CapacityInquiry } from '../capacityOfferTypes'
import type { FleetTrip } from '../tripTypes'
const state = vi.hoisted(() => ({ manage: false, write: true, quote: false, links: new Set<string>(), route: { name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'CO-1' } }, push: vi.fn() }))
const state = vi.hoisted(() => ({ manage: false, write: true, quote: false, links: new Set<string>(), route: { query: {} as Record<string, string>, name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'CO-1' } }, push: vi.fn() }))
vi.mock('vue-router', () => ({ useRoute: () => state.route, useRouter: () => ({ push: state.push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (p: string) => state.links.has(p) || (p === 'platform:fleet:capacity-offer:manage' ? state.manage : p === 'platform:fleet:quote:list' ? state.quote : p === 'platform:fleet:capacity-offer:list' || p.includes(':inquiry-') && state.write) }) }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) } }))
@@ -36,7 +36,7 @@ function mount(component: object, props: Record<string, unknown> = {}): { wrappe
} } }); wrappers.push(wrapper); return { wrapper, pinia }
}
const button = (w: VueWrapper, label: string) => w.findAll('button').find(b => b.text() === label)!
beforeEach(() => { vi.clearAllMocks(); state.manage = false; state.write = true; state.quote = false; state.links.clear(); vi.mocked(ElMessageBox.confirm).mockImplementation(vi.fn().mockResolvedValue(undefined)); vi.mocked(api.listCapacityOffers).mockResolvedValue(pageOf([structuredClone(offer)], 41)); vi.mocked(api.listCapacityInquiries).mockResolvedValue(pageOf([structuredClone(inquiry)])); vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer: structuredClone(offer), inquiries: pageOf([structuredClone(inquiry)]) }) })
beforeEach(() => { state.route.query = {}; vi.clearAllMocks(); state.manage = false; state.write = true; state.quote = false; state.links.clear(); vi.mocked(ElMessageBox.confirm).mockImplementation(vi.fn().mockResolvedValue(undefined)); vi.mocked(api.listCapacityOffers).mockResolvedValue(pageOf([structuredClone(offer)], 41)); vi.mocked(api.listCapacityInquiries).mockResolvedValue(pageOf([structuredClone(inquiry)])); vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer: structuredClone(offer), inquiries: pageOf([structuredClone(inquiry)]) }) })
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.useRealTimers() })
it.each(['DRAFT', 'CONFIRMED', 'RECEIVED', 'BOUND', 'CANCELLED'] as const)('labels %s cargo consistently in the list and detail without treating estimated pallets as actual', async status => {
const row: CapacityInquiry = { ...inquiry, status, expectedPalletCount: 7, cargo: { ...inquiry.cargo, packageCount: 12, palletSpaces: 3, weightKg: 80, volumeCbm: 2 } }
@@ -195,9 +195,9 @@ it.each([CapacityOffersView, CapacityOfferDetailView])('opens the bound trip sep
const { wrapper } = mount(component); await flushPromises()
const dialog = wrapper.findComponent(CapacityInquiryDialog)
dialog.vm.$emit('action', 'dispatch', row); await flushPromises()
expect(state.push).toHaveBeenLastCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'DS-BOUND' } })
expect(state.push).toHaveBeenLastCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'DS-BOUND' }, query: { fleetSource: 'inquiry', fleetOfferNo: row.offerNo, fleetBookingNo: row.bookingNo } })
dialog.vm.$emit('action', 'trip', row); await flushPromises()
expect(state.push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo } })
expect(state.push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo }, query: { fleetSource: 'inquiry', fleetOfferNo: row.offerNo, fleetBookingNo: row.bookingNo } })
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
state.links.clear(); state.push.mockClear()
dialog.vm.$emit('action', 'trip', row); dialog.vm.$emit('action', 'dispatch', row); await flushPromises()
@@ -279,3 +279,42 @@ it('prioritizes the allowed current list action and keeps other actions in the o
await wrapper.setProps({ rows: [{ ...inquiry, progress: { ...inquiry.progress, primaryAction: 'INCOMING' } }] })
expect(primary()).toBe('incoming')
})
it('shows the business identity and exposes the order link only with its list permission', async () => {
const row = { ...inquiry, businessOrderNo: 'ORDER-1' }
const { wrapper } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: row, readonly: true })
await flushPromises()
const summary = wrapper.get('[aria-label="本票处理进度"]').text()
expect(summary).toContain(row.tripNo); expect(summary).toContain(row.bookingNo)
expect(summary).toContain(row.customerName); expect(summary).toContain(row.destinationLabel)
expect(button(wrapper, 'ORDER-1')).toBeUndefined()
state.links.add('platform:overseas-business:truck:list')
await wrapper.setProps({ inquiry: { ...row } }); await flushPromises()
await button(wrapper, 'ORDER-1').trigger('click')
expect(wrapper.emitted('action')?.[0]).toEqual(['business-order', row])
})
it('reopens a returned inquiry through an authorized fetch and refuses a different offer', async () => {
state.route.query = { inquiry: inquiry.bookingNo }
vi.mocked(api.getCapacityInquiry).mockResolvedValue(inquiry)
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
const editor = wrapper.findComponent({ name: 'CapacityInquiryDialog' })
expect(editor.props('modelValue')).toBe(true); expect(editor.props('readonly')).toBe(true)
expect(editor.props('inquiry').bookingNo).toBe(inquiry.bookingNo)
wrapper.unmount()
vi.mocked(api.getCapacityInquiry).mockResolvedValue({ ...inquiry, offerNo: 'FO-OTHER' })
const rejected = mount(CapacityOfferDetailView).wrapper; await flushPromises()
expect(rejected.findComponent({ name: 'CapacityInquiryDialog' }).props('modelValue')).toBe(false)
expect(rejected.findComponent({ name: 'LoadErrorState' }).exists()).toBe(true)
})
it('opens refreshed server guidance after fulfillment succeeds without replaying a write', async () => {
state.manage = true
const { wrapper } = mount(CapacityOffersView); await flushPromises()
const updated = { ...inquiry, status: 'BOUND' as const, progress: { ...inquiry.progress, primaryAction: 'VIEW', message: '服务端下一待办', owner: '审核 / 仓库' } }
vi.mocked(api.getCapacityInquiry).mockResolvedValue(updated)
wrapper.findComponent({ name: 'CapacityInquiryTable' }).vm.$emit('action', 'bind', inquiry); await flushPromises()
wrapper.findComponent({ name: 'CapacityInquiryFulfillment' }).vm.$emit('saved'); await flushPromises()
const editor = wrapper.findComponent({ name: 'CapacityInquiryDialog' })
expect(editor.props('modelValue')).toBe(true); expect(editor.props('readonly')).toBe(true)
expect(editor.props('inquiry').progress.message).toBe('服务端下一待办')
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
})
@@ -15,6 +15,7 @@ import CapacityInquiryFulfillment from '../components/CapacityInquiryFulfillment
import CapacityInquiryDialog from '../components/CapacityInquiryDialog.vue'
import { listCapacityOffers, listCapacityInquiries, getCapacityOffer, getCapacityInquiry } from '../api/capacityOffers'
import { offerStatusLabels, inquiryStatusLabels, offerTime, loadText, type CapacityOffer, type CapacityInquiry, type InquiryResult, type OfferQuery, type InquiryQuery } from '../capacityOfferTypes'
import { inquirySourceQuery, inquiryOrderTarget } from '../inquiryNavigation'
import { useCapacityInquiryCommands } from '../useCapacityInquiryCommands'
const router = useRouter(), access = useAccessPreviewStore()
const manage = computed(() => access.can('platform:fleet:capacity-offer:manage'))
@@ -25,10 +26,23 @@ const offers = ref<CapacityOffer[]>([]), inquiries = ref<CapacityInquiry[]>([])
const editorOpen = ref(false), editing = ref<CapacityInquiry | null>(null), selectedOffer = ref<CapacityOffer | null>(null), readonly = ref(true)
const confirmMode = ref(false)
const fulfillmentOpen = ref(false), fulfillmentRow = ref<CapacityInquiry | null>(null), fulfillmentMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view')
async function fulfillmentSaved() {
const row = fulfillmentRow.value
fulfillmentOpen.value = false
if (!row) return
const current = ++followupSequence
await load()
try {
const updated = await getCapacityInquiry(row.bookingNo)
if (current === followupSequence) await inquiryAction('view', updated)
} catch (cause) { if (current === followupSequence) error.value = getApiErrorDetails(cause, '操作已完成,下一步进度读取失败,请刷新原登记') }
}
let followupSequence = 0
function openFulfillment(key: string, row: CapacityInquiry) {
if (key === 'business-order') { const target = inquiryOrderTarget(row, access.can); if (target) void router.push(target); return true }
if (key === 'appointment' && access.can('platform:appointment:list') && access.can('platform:overseas-warehouse:last-mile-order:list')) { void router.push({ name: 'appointment-calendar', query: { sourceKey: `BOOKING:${row.bookingNo}`, customerId: row.customerId, destinationKey: row.cargo.destinationKey } }); return true }
if (key === 'dispatch' && row.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }); return true }
if (key === 'trip' && row.dispatchNo && access.can('platform:fleet:trip:list')) { void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo } }); return true }
if (key === 'dispatch' && row.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo }, query: inquirySourceQuery(row) }); return true }
if (key === 'trip' && row.dispatchNo && access.can('platform:fleet:trip:list')) { void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo }, query: inquirySourceQuery(row) }); return true }
if (!['incoming', 'receipt', 'bind', 'fulfillment'].includes(key)) return false
editorOpen.value = false
fulfillmentRow.value = row; fulfillmentMode.value = key === 'fulfillment' ? 'view' : key as 'incoming' | 'receipt' | 'bind'; fulfillmentOpen.value = true
@@ -83,6 +97,7 @@ function accept(value: InquiryResult) {
}
const commands = useCapacityInquiryCommands('capacity-pool-command', accept)
async function inquiryAction(key: string, row: CapacityInquiry) {
followupSequence++
if (openFulfillment(key, row)) return
if (key === 'cancel') { await commands.run(row, key); return }
if (key === 'confirm' && !manage.value) return
@@ -103,7 +118,7 @@ async function recheckEditor() {
if (current === editorSequence) { selectedOffer.value = detail.offer; editing.value = checked }
} catch (cause) { if (current === editorSequence) error.value = getApiErrorDetails(cause, '最新意向核对失败') }
}
watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++; editorSequence++ })
watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { followupSequence++; sequence++; editorSequence++ })
</script>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="拼车接货">
<DataTable v-if="tab === 'offers'" appearance="integrated" :stripe="false" preference-key="fleet-capacity-offers" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="offers" :columns="columns" :loading="loading" :total="total" row-key="offerNo" :empty-text="Object.values(applied).some(Boolean) ? '当前筛选条件下暂无运力发布' : '暂无获准查看的运力发布'">
@@ -133,6 +148,6 @@ watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { sequenc
</template>
<template #notices><LoadErrorState v-if="error || commands.error.value" title="意向操作未完成" :message="(error || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : load()"><template #details><ApiErrorMeta :details="(error || commands.error.value)!" /></template></LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button></template>
</CapacityInquiryTable>
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="load" />
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="fulfillmentSaved" />
<CapacityInquiryDialog v-model="editorOpen" :offer="selectedOffer" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="inquiryAction" @saved="accept" @refresh="recheckEditor" />
</ListPageLayout></AdminPage></template>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { inquiryReturnTarget } from '../inquiryNavigation'
import TripDispatchSelector from '../components/TripDispatchSelector.vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -185,7 +186,7 @@ onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
</script>
<template>
<AdminPage class="admin-density"><DetailPageLayout class="density-detail-layout surface-panel" aria-label="车次详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-trips' })">返回车次列表</el-button><h1 class="density-detail-title">{{ detail?.trip.tripNo || '车次详情' }}</h1><span v-if="detail">{{ tripStatusLabels[detail.trip.status] }}</span></div><div class="density-actions"><el-button :disabled="!!busy" @click="load">刷新车次</el-button><el-button v-if="access.can('platform:fleet:capacity-offer:manage')" @click="offerSettingsOpen = true">发布拼车余位</el-button><el-button v-if="editable" :disabled="!!busy" @click="editorOpen = true">编辑计划</el-button><el-button v-if="planned && access.can(permission.tripCancel)" type="danger" plain :disabled="!!busy || !!detail?.dispatches.length" @click="cancel">取消车次</el-button></div></div></template>
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push(inquiryReturnTarget(route.query) || { name: 'overseas-master-data-trips' })">{{ inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回车次列表' }}</el-button><h1 class="density-detail-title">{{ detail?.trip.tripNo || '车次详情' }}</h1><span v-if="detail">{{ tripStatusLabels[detail.trip.status] }}</span></div><div class="density-actions"><el-button :disabled="!!busy" @click="load">刷新车次</el-button><el-button v-if="access.can('platform:fleet:capacity-offer:manage')" @click="offerSettingsOpen = true">发布拼车余位</el-button><el-button v-if="editable" :disabled="!!busy" @click="editorOpen = true">编辑计划</el-button><el-button v-if="planned && access.can(permission.tripCancel)" type="danger" plain :disabled="!!busy || !!detail?.dispatches.length" @click="cancel">取消车次</el-button></div></div></template>
<LoadErrorState v-if="error" title="车次详情加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="operationError" title="操作未完成" :message="operationError.message" @retry="load"><template #details><ApiErrorMeta :details="operationError" /></template></LoadErrorState>
<div v-loading="loading"><template v-if="detail">