fix(fleet): align capacity availability and dispatch review navigation
This commit is contained in:
@@ -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('理货审核')
|
||||
})
|
||||
|
||||
@@ -9,8 +9,12 @@ export interface DispatchQuery {
|
||||
vehiclePlate?: string
|
||||
status?: string
|
||||
fbaAppointmentNo?: string
|
||||
stage?: 'BEFORE_LOADING' | 'LOADING' | 'AFTER_LOADING' | 'CANCELLED'
|
||||
}
|
||||
|
||||
export interface DispatchReview { status: 'PENDING' | 'APPROVED' | 'REJECTED'; reason: string | null; reviewedAt: string | null }
|
||||
export interface DispatchWorkflow { tripNo: string; dispatchReview: DispatchReview; loadingReview: DispatchReview }
|
||||
|
||||
export interface DispatchPalletCandidateDto {
|
||||
sourceType?: 'INBOUND_PLAN' | 'RETURN_RECEIPT'
|
||||
returnReceiptId?: string | null
|
||||
@@ -86,6 +90,7 @@ export interface PreArrangeDispatchDraftResult {
|
||||
}
|
||||
|
||||
export interface DispatchSummaryDto {
|
||||
workflow?: DispatchWorkflow | null
|
||||
id: string
|
||||
dispatchNo: string
|
||||
vehiclePlate?: string | null
|
||||
|
||||
@@ -10,6 +10,8 @@ import { cancelDispatch, getDispatchBolPdf, getDispatchExternalFreight, getDispa
|
||||
import DeliveryFeeCoveragePanel from '../components/DeliveryFeeCoveragePanel.vue'
|
||||
import DispatchesView from './DispatchesView.vue'
|
||||
|
||||
const push = vi.hoisted(() => vi.fn())
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push }) }))
|
||||
const canPermission = vi.hoisted(() => vi.fn<(permissionCode: string) => boolean>(() => true))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
|
||||
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), removeDispatchItem: vi.fn(), getDispatchBolPdf: vi.fn(), getDispatchExternalFreight: vi.fn(), getDispatchFeeCoverage: vi.fn(), updateDispatch: vi.fn(), cancelDispatch: vi.fn(), transferDispatchToDelivery: vi.fn() }))
|
||||
@@ -60,6 +62,50 @@ describe('DispatchesView', () => {
|
||||
})
|
||||
|
||||
|
||||
it('queries stages on the server and discards an older response after switching', async () => {
|
||||
let resolveOld!: (value: Awaited<ReturnType<typeof listDispatches>>) => void
|
||||
vi.mocked(listDispatches).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
|
||||
await wrapper.get('[role="tablist"]').findAll('button')[2]!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'LOADING', page: 1, status: undefined }))
|
||||
expect(wrapper.text()).toContain('PC-1')
|
||||
resolveOld({ records: [], total: 0, page: 1, size: 10, pages: 0 })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('PC-1')
|
||||
await wrapper.get('[role="tablist"]').findAll('button')[3]!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'AFTER_LOADING', page: 1 }))
|
||||
})
|
||||
|
||||
it('shows review facts and opens the matching trip section with read permission', async () => {
|
||||
const result = await listDispatches({ page: 1, size: 10 })
|
||||
result.records[0]!.workflow = { tripNo: 'TR-A', dispatchReview: { status: 'REJECTED', reason: '请核对货量', reviewedAt: null }, loadingReview: { status: 'PENDING', reason: null, reviewedAt: null } }
|
||||
vi.mocked(listDispatches).mockClear().mockResolvedValue(result)
|
||||
canPermission.mockImplementation(code => code === 'platform:fleet:trip:list')
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
const sections = wrapper.get('[aria-label="排车业务分区"]')
|
||||
expect(sections.findAll('button').map(button => button.text())).toEqual(['车辆信息', '调度审核', '理货员审核'])
|
||||
expect(wrapper.findAll('[role="tablist"]')).toHaveLength(2)
|
||||
expect(wrapper.text()).not.toContain('请核对货量')
|
||||
await sections.findAll('button')[1]!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('请核对货量')
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '打印BOL')).toBe(false)
|
||||
await wrapper.findAll('button').find(button => button.text() === '查看审核')!.trigger('click')
|
||||
expect(push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-A' }, query: { tab: 'overview' } })
|
||||
await sections.findAll('button')[2]!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('请核对货量')
|
||||
expect(wrapper.text()).toContain('待审核')
|
||||
const links = wrapper.findAll('button').filter(button => button.text() === '查看审核')
|
||||
await links[0]!.trigger('click')
|
||||
expect(push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-A' }, query: { tab: 'loading' } })
|
||||
expect(wrapper.text()).not.toContain('前往审核')
|
||||
expect(listDispatches).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[[], ['查看', '打印BOL'], false],
|
||||
[['edit'], ['查看', '打印BOL', '编辑'], false],
|
||||
|
||||
@@ -31,6 +31,31 @@ const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const stage = ref<'' | 'BEFORE_LOADING' | 'LOADING' | 'AFTER_LOADING' | 'CANCELLED'>('')
|
||||
const stages = [
|
||||
{ value: '', label: '全部' }, { value: 'BEFORE_LOADING', label: '装车前' },
|
||||
{ value: 'LOADING', label: '装车中' }, { value: 'AFTER_LOADING', label: '装车后' },
|
||||
{ value: 'CANCELLED', label: '已取消' },
|
||||
] as const
|
||||
const section = ref<'vehicle' | 'dispatchReview' | 'loadingReview'>('vehicle')
|
||||
const sections = [
|
||||
{ value: 'vehicle', label: '车辆信息' },
|
||||
{ value: 'dispatchReview', label: '调度审核' },
|
||||
{ value: 'loadingReview', label: '理货员审核' },
|
||||
] as const
|
||||
const reviewLabels = { PENDING: '待审核', APPROVED: '已通过', REJECTED: '已打回' }
|
||||
let loadSequence = 0
|
||||
function switchStage(value: typeof stage.value) {
|
||||
if (stage.value === value) return
|
||||
stage.value = value
|
||||
filters.value.status = ''
|
||||
page.value = 1
|
||||
void load()
|
||||
}
|
||||
function openTrip(row: DispatchListItem, tab: 'overview' | 'loading') {
|
||||
if (!row.workflow || !access.can('platform:fleet:trip:list')) return
|
||||
void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.workflow.tripNo }, query: { tab } })
|
||||
}
|
||||
const filters = ref<QueryModel>({
|
||||
dispatchNo: '',
|
||||
vehiclePlate: '',
|
||||
@@ -168,6 +193,9 @@ const fields = computed<QueryField[]>(() => [
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 380, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 170, fixed: 'left' },
|
||||
{ key: 'workflow', label: '关联车次', minWidth: 210, slotName: 'workflow' },
|
||||
{ key: 'dispatchReview', label: '调度审核', minWidth: 180, slotName: 'dispatchReview', singleLine: false },
|
||||
{ key: 'loadingReview', label: '理货审核(整车)', minWidth: 180, slotName: 'loadingReview', singleLine: false },
|
||||
{ key: 'vehiclePlate', prop: 'vehiclePlate', label: '车牌号', minWidth: 130 },
|
||||
{ key: 'driverName', prop: 'driverName', label: '司机', minWidth: 120 },
|
||||
{ key: 'driverPhone', prop: 'driverPhone', label: '联系电话', minWidth: 140 },
|
||||
@@ -228,6 +256,13 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 170 },
|
||||
]
|
||||
|
||||
const visibleColumns = computed(() => {
|
||||
if (section.value === 'vehicle') return columns.filter(column => !['dispatchReview', 'loadingReview'].includes(String(column.key)))
|
||||
const keys = ['actions', 'dispatchNo', 'workflow', section.value, 'vehiclePlate', 'driverName', 'route', 'palletCount', 'packageCount', 'status']
|
||||
return keys.map(key => columns.find(column => column.key === key)!)
|
||||
})
|
||||
const tablePreferenceKey = computed(() => section.value === 'vehicle' ? 'overseas-dispatches' : `overseas-dispatches-${section.value}`)
|
||||
|
||||
function text(value: unknown) {
|
||||
const normalized = String(value ?? '').trim()
|
||||
return normalized || '—'
|
||||
@@ -263,6 +298,7 @@ function appointmentLabel(numberValue: unknown, statusValue: unknown) {
|
||||
|
||||
function toListItem(dto: DispatchSummaryDto): DispatchListItem {
|
||||
return {
|
||||
workflow: dto.workflow,
|
||||
id: String(dto.id),
|
||||
dispatchNo: text(dto.dispatchNo),
|
||||
vehiclePlate: text(dto.vehiclePlate),
|
||||
@@ -295,6 +331,7 @@ function dispatchError(error: unknown) {
|
||||
}
|
||||
|
||||
async function load(model: QueryModel = filters.value) {
|
||||
const sequence = ++loadSequence
|
||||
Object.assign(filters.value, model)
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
@@ -302,21 +339,24 @@ async function load(model: QueryModel = filters.value) {
|
||||
const result = await listDispatches({
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
stage: stage.value || undefined,
|
||||
dispatchNo: String(filters.value.dispatchNo || '').trim() || undefined,
|
||||
vehiclePlate: String(filters.value.vehiclePlate || '').trim() || undefined,
|
||||
status: String(filters.value.status || '').trim() || undefined,
|
||||
fbaAppointmentNo: String(filters.value.fbaAppointmentNo || '').trim() || undefined,
|
||||
})
|
||||
if (sequence !== loadSequence) return
|
||||
dispatchSnapshots.clear()
|
||||
result.records.forEach((item) => dispatchSnapshots.set(String(item.dispatchNo), item))
|
||||
rows.value = result.records.map(toListItem)
|
||||
total.value = result.total
|
||||
} catch (error) {
|
||||
if (sequence !== loadSequence) return
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
loadError.value = dispatchError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (sequence === loadSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,6 +541,13 @@ async function cancel(row: DispatchListItem) {
|
||||
}
|
||||
}
|
||||
function rowActions(row: DispatchListItem): RowActionItem[] {
|
||||
if (section.value !== 'vehicle') {
|
||||
const reviewPermission = section.value === 'dispatchReview' ? 'platform:fleet:trip:dispatch-review' : 'platform:fleet:trip:loading-review'
|
||||
return [
|
||||
{ key: 'review', label: access.can(reviewPermission) ? '前往审核' : '查看审核', priority: 400, visible: !!row.workflow && access.can('platform:fleet:trip:list') },
|
||||
{ key: 'detail', label: '查看', priority: 300 },
|
||||
]
|
||||
}
|
||||
return [
|
||||
{ key: 'detail', label: '查看', priority: 300 },
|
||||
{ key: 'bol', label: '打印BOL', priority: 200, loading: bolLoadingNo.value === row.dispatchNo },
|
||||
@@ -510,7 +557,8 @@ function rowActions(row: DispatchListItem): RowActionItem[] {
|
||||
]
|
||||
}
|
||||
function handleRowAction(command: string, row: DispatchListItem) {
|
||||
if (command === 'detail') openDetail(row)
|
||||
if (command === 'review') openTrip(row, section.value === 'dispatchReview' ? 'overview' : 'loading')
|
||||
else if (command === 'detail') openDetail(row)
|
||||
else if (command === 'bol') void openBol(row)
|
||||
else if (command === 'edit') openEdit(row)
|
||||
else if (command === 'transfer') openTransfer(row)
|
||||
@@ -589,11 +637,30 @@ onMounted(load)
|
||||
<template>
|
||||
<AdminPage class="admin-density"><ListPageLayout aria-label="海外仓排车列表">
|
||||
<OperationErrorToast :details="mutationError" />
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-dispatches" row-key="id" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :stripe="false" :data="rows" :columns="visibleColumns" :loading="loading" :total="total" :preference-key="tablePreferenceKey" row-key="id" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-start><h1>排车列表</h1></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
|
||||
<template #filters><div class="integrated-query-group">
|
||||
<div class="density-status-tabs" role="tablist" aria-label="排车执行阶段">
|
||||
<button v-for="item in stages" :key="item.value" role="tab" :aria-selected="stage === item.value" :class="{ 'is-active': stage === item.value }" @click="switchStage(item.value)">{{ item.label }}</button>
|
||||
</div>
|
||||
<div class="density-status-tabs" role="tablist" aria-label="排车业务分区">
|
||||
<button v-for="item in sections" :key="item.value" role="tab" :aria-selected="section === item.value" :class="{ 'is-active': section === item.value }" @click="section = item.value">{{ item.label }}</button>
|
||||
</div>
|
||||
<QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="reset" /></div></template>
|
||||
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListItem)" :more-label="`更多操作 · ${(row as DispatchListItem).dispatchNo || '当前记录'}`" @action="handleRowAction($event, row as DispatchListItem)" /></template>
|
||||
<template #workflow="{ row }">
|
||||
<el-button v-if="row.workflow && access.can('platform:fleet:trip:list')" link type="primary" @click="openTrip(row as DispatchListItem, 'overview')">{{ row.workflow.tripNo }}</el-button>
|
||||
<span v-else>{{ row.workflow?.tripNo || '未关联车次' }}</span>
|
||||
</template>
|
||||
<template v-for="review in (['dispatchReview', 'loadingReview'] as const)" :key="review" #[review]="{ row }">
|
||||
<template v-if="(row as DispatchListItem).workflow">
|
||||
<div>{{ reviewLabels[(row as DispatchListItem).workflow![review].status] }}</div>
|
||||
<div v-if="row.workflow[review].status === 'REJECTED'">{{ row.workflow[review].reason || '—' }}</div>
|
||||
<div v-if="row.workflow[review].reviewedAt">{{ formatOverseasDateTime(row.workflow[review].reviewedAt) }}</div>
|
||||
</template>
|
||||
<span v-else>未关联车次</span>
|
||||
</template>
|
||||
<template #status="{ row }"><StatusBadge :status="String((row as DispatchListItem).status || '—')" /></template>
|
||||
<template #pagination-left>共 {{ total }} 条排车记录</template>
|
||||
</DataTable>
|
||||
|
||||
@@ -96,6 +96,7 @@ export interface InboundOrderListItem {
|
||||
}
|
||||
|
||||
export interface DispatchListItem {
|
||||
workflow?: import('./api/dispatches').DispatchWorkflow | null
|
||||
id: string
|
||||
dispatchNo: string
|
||||
vehiclePlate: string
|
||||
|
||||
@@ -527,7 +527,8 @@ describe('approved Admin density ownership', () => {
|
||||
expect(actionWeights.get('.admin-text-actions.el-popper .el-dropdown-menu__item')).toBe('400')
|
||||
const dispatches = read('modules/overseas-warehouse/pages/DispatchesView.vue')
|
||||
expect(dispatches).toContain('appearance="integrated"')
|
||||
expect(dispatches).toContain('<template #filters><QueryToolbar')
|
||||
expect(dispatches).toContain('<template #filters><div class="integrated-query-group">')
|
||||
expect(dispatches).toContain('class="density-status-tabs" role="tablist"')
|
||||
expect(dispatches).not.toContain('class="surface-panel table-panel"')
|
||||
const calendar = read('modules/sailing-calendar/pages/SailingCalendarView.vue')
|
||||
expect(calendar).toContain('appearance="integrated" :data="[]" :columns="[]" column-settings-mode="hide"')
|
||||
|
||||
Reference in New Issue
Block a user