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
@@ -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>
+1
View File
@@ -96,6 +96,7 @@ export interface InboundOrderListItem {
}
export interface DispatchListItem {
workflow?: import('./api/dispatches').DispatchWorkflow | null
id: string
dispatchNo: string
vehiclePlate: string