fix(fleet): align capacity availability and dispatch review navigation

This commit is contained in:
2026-10-08 14:11:54 +08:00
parent f24b592705
commit 6551b703cb
12 changed files with 184 additions and 19 deletions
@@ -22,7 +22,7 @@ export interface CapacityInquiry {
actions: { canEdit: boolean; canConfirm: boolean; canCancel: boolean; blockReason: string | null }
}
export interface CapacityOfferDetail { offer: CapacityOffer; inquiries: OfferPage<CapacityInquiry> }
export interface OfferQuery { page?: number; size?: number; keyword?: string; origin?: string; destination?: string; departureFrom?: string; departureTo?: string; status?: string; canConfirm?: boolean; tripNo?: string }
export interface OfferQuery { scope?: 'AVAILABLE' | 'ALL'; page?: number; size?: number; keyword?: string; origin?: string; destination?: string; departureFrom?: string; departureTo?: string; status?: string; canConfirm?: boolean; tripNo?: string }
export interface InquiryQuery { page?: number; size?: number; keyword?: string; offerNo?: string; status?: string; scope?: 'MINE' | 'ALL' }
export interface OfferWrite { tripNo: string; tripRecordVersion: number; offerRecordVersion: number | null; cutoffAt: string; contactName: string | null; contactPhone: string | null; remark: string | null; idempotencyKey: string }
export interface OfferCommand { tripRecordVersion: number; offerRecordVersion: number; idempotencyKey: string; reason?: string }
@@ -23,7 +23,7 @@ const guard = useFleetDraftGuard('capacity-offer-settings', open, form, busy, co
async function load() {
if (!writable.value || !open.value || pending.value) return
const current = ++generation; loading.value = true; error.value = null; loaded.value = false
try { const value = await listCapacityOffers({ tripNo: props.trip.tripNo, page: 1, size: 1 }); if (current !== generation) return; offer.value = value.records[0] || null; loaded.value = true; Object.assign(form, { cutoffAt: offer.value?.cutoffAt || '', contactName: offer.value?.contactName || '', contactPhone: offer.value?.contactPhone || '', remark: offer.value?.remark || '' }); await nextTick(); guard.capture() }
try { const value = await listCapacityOffers({ scope: 'ALL', tripNo: props.trip.tripNo, page: 1, size: 1 }); if (current !== generation) return; offer.value = value.records[0] || null; loaded.value = true; Object.assign(form, { cutoffAt: offer.value?.cutoffAt || '', contactName: offer.value?.contactName || '', contactPhone: offer.value?.contactPhone || '', remark: offer.value?.remark || '' }); await nextTick(); guard.capture() }
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '发布设置读取失败') }
finally { if (current === generation) loading.value = false }
}
@@ -18,7 +18,8 @@ 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)
let sequence = 0, inquirySequence = 0, resetting = false
const canCreate = computed(() => detail.value?.offer.canCreateInquiry && (access.can('platform:fleet:capacity-offer:manage') || access.can('platform:fleet:capacity-offer:list') && access.can('platform:fleet:capacity-offer:inquiry-create')))
const hasCreatePermission = computed(() => (access.can('platform:fleet:capacity-offer:manage') || access.can('platform:fleet:capacity-offer:list') && access.can('platform:fleet:capacity-offer:inquiry-create')))
const canCreate = computed(() => detail.value?.offer.canCreateInquiry && hasCreatePermission.value)
const columns: DataTableColumn[] = [{ key: 'fromName', label: '起始站', minWidth: 150 }, { key: 'toName', label: '到达站', minWidth: 150 }, { key: 'planned', label: '已关联货物计划占用', minWidth: 260, slotName: 'planned' }, { key: 'reserved', label: '未关联外接预留', minWidth: 260, slotName: 'reserved' }, { key: 'remaining', label: '可接托位 / 重量 / 体积', minWidth: 270, slotName: 'remaining' }]
async function load() {
const current = ++sequence; inquirySequence++; loading.value = true; error.value = null
@@ -58,11 +59,12 @@ watch([page, size], loadInquiries)
onBeforeUnmount(() => { 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="canCreate" type="primary" :disabled="loading" @click="create">登记意向</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({ 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>
<LoadErrorState v-if="error" title="发布详情读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-loading="loading" :aria-busy="loading"><template v-if="detail">
<dl class="density-fields"><div><dt>车次号</dt><dd><el-button v-if="access.can('platform:fleet:trip:list')" link type="primary" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: detail.offer.tripNo } })">{{ detail.offer.tripNo }}</el-button><span v-else>{{ detail.offer.tripNo }}</span></dd></div><div><dt>起运仓</dt><dd>{{ detail.offer.warehouseName }}</dd></div><div><dt>计划发车</dt><dd>{{ offerTime(detail.offer.plannedDepartureAt) }}</dd></div><div><dt>交货截止</dt><dd>{{ offerTime(detail.offer.cutoffAt) }}</dd></div><div><dt>调度联系人</dt><dd>{{ detail.offer.contactName || '—' }}</dd></div><div><dt>调度联系方式</dt><dd>{{ detail.offer.contactPhone || '—' }}</dd></div></dl>
<p>{{ detail.offer.blockReason || '按所选卸货站核对每段余位;登记意向不占位,调度确认后占位。' }}</p><p v-if="detail.offer.remark">{{ detail.offer.remark }}</p>
<p v-if="hasCreatePermission && !detail.offer.canCreateInquiry" role="status">无法登记意向:{{ detail.offer.blockReason || '当前发布不接受新意向' }}</p>
<p v-else>{{ detail.offer.blockReason || '按所选卸货站核对每段余位;登记意向不占位,调度确认后占位。' }}</p><p v-if="detail.offer.remark">{{ detail.offer.remark }}</p>
<h2 class="capacity-section-title">分段运力</h2><DataTable :data="detail.offer.segments" :columns="columns" :stripe="false" row-key="fromIndex" empty-text="容量待调度核对"><template #planned="{ row }">{{ loadText(row.planned) }}</template><template #reserved="{ row }">{{ loadText(row.reserved) }}</template><template #remaining="{ row }">{{ detail.offer.capacityKnown ? loadText(row.remaining) : '待调度核对' }}</template></DataTable>
<h2 class="capacity-section-title">{{ access.can('platform:fleet:capacity-offer:manage') ? '本发布全部意向' : '我的意向' }}</h2>
<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>
@@ -108,7 +108,7 @@ it('loads publishing settings only when explicitly opened, not with the trip pag
state.manage = true
const { wrapper } = mount(CapacityOfferSettingsDialog, { modelValue: false, trip: { tripNo: 'TR-1', recordVersion: 5 } as FleetTrip }); await flushPromises()
expect(api.listCapacityOffers).not.toHaveBeenCalled(); await (wrapper as VueWrapper<Record<string, unknown>>).setProps({ modelValue: true }); await flushPromises()
expect(api.listCapacityOffers).toHaveBeenCalledExactlyOnceWith({ tripNo: 'TR-1', page: 1, size: 1 })
expect(api.listCapacityOffers).toHaveBeenCalledExactlyOnceWith({ scope: 'ALL', tripNo: 'TR-1', page: 1, size: 1 })
})
it('retries an uncertain dispatch confirmation with the same command and no second confirmation box', async () => {
state.manage = true
@@ -121,3 +121,24 @@ it('retries an uncertain dispatch confirmation with the same command and no seco
expect(api.commandCapacityInquiry).toHaveBeenNthCalledWith(2, ...first); expect(ElMessageBox.confirm).toHaveBeenCalledOnce()
expect(api.listCapacityInquiries).toHaveBeenCalledOnce()
})
it('keeps the create button disabled with a reason after cutoff, while hiding it without write permission', async () => {
vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer: { ...offer, canCreateInquiry: false, blockReason: '交货截止已过或不符合当前发车时间' }, inquiries: pageOf([]) })
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
expect(button(wrapper, '登记意向').attributes('disabled')).toBeDefined()
expect(wrapper.text()).toContain('无法登记意向:交货截止已过')
expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('modelValue')).toBe(false)
state.write = false
const readonly = mount(CapacityOfferDetailView); await flushPromises()
expect(readonly.wrapper.findAll('button').some(node => node.text() === '登记意向')).toBe(false)
})
it('defaults administrators to available offers and requires an explicit history filter', async () => {
state.manage = true
const { wrapper } = mount(CapacityOffersView); await flushPromises()
expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'AVAILABLE' }))
const query = wrapper.findComponent({ name: 'QueryToolbar' })
query.vm.$emit('update:modelValue', { scope: 'ALL' }); await flushPromises(); query.vm.$emit('search'); await flushPromises()
expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'ALL' }))
query.vm.$emit('update:modelValue', {}); await flushPromises(); query.vm.$emit('search'); await flushPromises()
expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'AVAILABLE' }))
})
@@ -26,7 +26,7 @@ const fields = computed<QueryField[]>(() => tab.value === 'offers' ? [
{ key: 'origin', label: '起运仓', type: 'input', placeholder: '起运仓名称', width: 170 }, { key: 'destination', label: '目的站', type: 'input', placeholder: '目的站名称', width: 170 },
{ key: 'departureFrom', label: '发车起日', type: 'date', placeholder: '发车起日', width: 170 }, { key: 'departureTo', label: '发车止日', type: 'date', placeholder: '发车止日', width: 170 },
{ key: 'canConfirm', label: '接单资格', type: 'select', placeholder: '全部接单资格', width: 150, options: [{ value: 'true', label: '可确认承运' }, { value: 'false', label: '暂不可确认' }] },
...(manage.value ? [{ key: 'status', label: '发布状态', type: 'select', placeholder: '全部发布状态', width: 140, options: Object.entries(offerStatusLabels).map(([value, label]) => ({ value, label })) } as QueryField] : []),
...(manage.value ? [{ key: 'scope', label: '发布范围', type: 'select', placeholder: '可登记意向', width: 180, options: [{ value: 'AVAILABLE', label: '可登记意向' }, { value: 'ALL', label: '全部发布(含历史)' }] } as QueryField, { key: 'status', label: '发布状态', type: 'select', placeholder: '全部发布状态', width: 140, options: Object.entries(offerStatusLabels).map(([value, label]) => ({ value, label })) } as QueryField] : []),
] : [
{ key: 'keyword', label: '意向关键词', type: 'input', placeholder: '预订号 / 客户 / 车次', width: 260 },
{ key: 'status', label: '意向状态', type: 'select', placeholder: '全部状态', width: 150, options: Object.entries(inquiryStatusLabels).map(([value, label]) => ({ value, label })) },
@@ -45,7 +45,7 @@ async function load() {
try {
const q = applied.value
if (tab.value === 'offers') {
const query: OfferQuery = { page: page.value, size: size.value, origin: String(q.origin || '') || undefined, destination: String(q.destination || '') || undefined, departureFrom: String(q.departureFrom || '') || undefined, departureTo: String(q.departureTo || '') || undefined, status: manage.value ? String(q.status || '') || undefined : undefined, canConfirm: q.canConfirm === 'true' ? true : q.canConfirm === 'false' ? false : undefined }
const query: OfferQuery = { scope: manage.value && q.scope === 'ALL' ? 'ALL' : 'AVAILABLE', page: page.value, size: size.value, origin: String(q.origin || '') || undefined, destination: String(q.destination || '') || undefined, departureFrom: String(q.departureFrom || '') || undefined, departureTo: String(q.departureTo || '') || undefined, status: manage.value ? String(q.status || '') || undefined : undefined, canConfirm: q.canConfirm === 'true' ? true : q.canConfirm === 'false' ? false : undefined }
const value = await listCapacityOffers(query); if (current === sequence) { offers.value = value.records; total.value = value.total }
} else {
const query: InquiryQuery = { page: page.value, size: size.value, keyword: String(q.keyword || '') || undefined, status: String(q.status || '') || undefined, scope: manage.value && q.scope === 'ALL' ? 'ALL' : 'MINE' }
@@ -83,9 +83,14 @@ async function recheckEditor() {
watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++; editorSequence++ })
</script>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="拼车需求池">
<div class="density-status-tabs" role="tablist" aria-label="拼车需求池页签"><button role="tab" :aria-selected="tab === 'offers'" :class="{ 'is-active': tab === 'offers' }" @click="switchTab('offers')">可接运力</button><button role="tab" :aria-selected="tab === 'inquiries'" :class="{ 'is-active': tab === 'inquiries' }" @click="switchTab('inquiries')">我的意向</button></div>
<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) ? '当前筛选条件下暂无运力发布' : '暂无获准查看的运力发布'">
<template #toolbar-start><h1>拼车需求池</h1></template><template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="可接运力查询" :loading="loading" @search="search" /></template>
<template #toolbar-start><h1>拼车需求池</h1></template>
<template #filters>
<div class="integrated-query-group">
<div class="density-status-tabs" role="tablist" aria-label="拼车需求池页签"><button role="tab" aria-selected="true" class="is-active" @click="switchTab('offers')">可接运力</button><button role="tab" aria-selected="false" @click="switchTab('inquiries')">我的意向</button></div>
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="可接运力查询" :loading="loading" @search="search" />
</div>
</template>
<template #notices><LoadErrorState v-if="error" title="运力列表读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" @click="router.push({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: row.offerNo } })">详情</el-button></div></template>
<template #identity="{ row }"><div>{{ row.offerNo }}</div><div>{{ row.tripNo }}</div></template>
@@ -96,7 +101,13 @@ watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { sequenc
<template #contact="{ row }"><div>{{ row.contactName || '—' }}</div><div>{{ row.contactPhone || '—' }}</div></template>
</DataTable>
<CapacityInquiryTable v-else integrated v-model:page="page" v-model:size="size" :rows="inquiries" :total="total" :loading="loading" :failed="!!error" :busy="commands.busy.value" @action="inquiryAction">
<template #toolbar-start><h1>{{ applied.scope === 'ALL' ? '全部意向' : '我的意向' }}</h1></template><template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="拼车意向查询" :loading="loading" @search="search" /></template>
<template #toolbar-start><h1>{{ applied.scope === 'ALL' ? '全部意向' : '我的意向' }}</h1></template>
<template #filters>
<div class="integrated-query-group">
<div class="density-status-tabs" role="tablist" aria-label="拼车需求池页签"><button role="tab" aria-selected="false" @click="switchTab('offers')">可接运力</button><button role="tab" aria-selected="true" class="is-active" @click="switchTab('inquiries')">我的意向</button></div>
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="拼车意向查询" :loading="loading" @search="search" />
</div>
</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>
<CapacityInquiryDialog v-model="editorOpen" :offer="selectedOffer" :inquiry="editing" :readonly="readonly" @saved="accept" @refresh="recheckEditor" />
@@ -63,7 +63,7 @@ function accept(value: TripDetail) {
}
async function load() {
const current = ++generation; inspectionSequence++; historySequence++
loading.value = true; error.value = null; detail.value = null; operationError.value = null; activeTab.value = 'overview'
loading.value = true; error.value = null; detail.value = null; operationError.value = null; activeTab.value = route.query.tab === 'loading' ? 'loading' : 'overview'
editorOpen.value = false; busy.value = ''; dispatchNo.value = ''; inspection.value = null; history.value = []; historyPage.value = 1
completionRequest.value = null; releaseRequest.value = null; endLocation.value = ''
try { const result = await getTrip(String(route.params.tripNo)); if (current === generation) accept(result) }
@@ -177,6 +177,7 @@ async function loadHistory() {
finally { if (current === historySequence) historyLoading.value = false }
}
watch(() => route.params.tripNo, load, { immediate: true })
watch(() => route.query.tab, tab => { activeTab.value = tab === 'loading' ? 'loading' : 'overview' })
watch(activeTab, tab => { if (tab === 'completion') void inspect(); if (tab === 'history') void loadHistory() })
watch(historyPage, () => { if (activeTab.value === 'history') void loadHistory() })
onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
@@ -1,3 +1,4 @@
const tripQuery = vi.hoisted(() => ({ tab: '' }))
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -9,7 +10,7 @@ import * as api from '../api/trips'
import type { FleetTrip, TripDetail, TripLoading } from '../tripTypes'
const state = vi.hoisted(() => ({ writable: true, push: vi.fn() }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { tripNo: 'TR-TEST' } }), useRouter: () => ({ push: state.push }) }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { tripNo: 'TR-TEST' }, query: tripQuery }), useRouter: () => ({ push: state.push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (code: string) => code.endsWith(':list') || state.writable }) }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined), prompt: vi.fn().mockResolvedValue({ value: '请核对预约时间' }) } }))
vi.mock('../api/drivers', () => ({ listDriverWarehouses: vi.fn().mockResolvedValue([{ id: '92', name: '海外仓', code: 'WH-US' }]) }))
@@ -39,7 +40,7 @@ function mount(component: object, props: Record<string, unknown> = {}): VueWrapp
}
function button(wrapper: VueWrapper, text: string) { return wrapper.findAll('button').find(node => node.text() === text)! }
beforeEach(() => {
vi.clearAllMocks(); state.writable = true
vi.clearAllMocks(); state.writable = true; tripQuery.tab = ''
vi.mocked(api.listTrips).mockResolvedValue({ records: [structuredClone(trip)], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(api.getTrip).mockResolvedValue(structuredClone(detail))
vi.mocked(api.listTripHistory).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
@@ -166,3 +167,12 @@ it('refreshes trip members and loading review after external dispatch binding wi
expect(api.getTrip).toHaveBeenCalledTimes(2)
expect(wrapper.findComponent({ name: 'ElTabs' }).props('modelValue')).toBe('bookings')
})
it('opens the loading review section from the dispatch list deep link', async () => {
tripQuery.tab = 'loading'
const wrapper = mount(TripDetailView)
await flushPromises()
expect(wrapper.findComponent({ name: 'ElTabs' }).props('modelValue')).toBe('loading')
expect(wrapper.findComponent({ name: 'TripLoadingPanel' }).exists()).toBe(true)
expect(wrapper.text()).toContain('理货审核')
})