Merge release/test and preserve pickup execution and photo workflows
This commit is contained in:
@@ -19,6 +19,28 @@ vi.mock('@/modules/overseas-master-data', async () => ({
|
||||
const options = { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub, DispatchWorkflowDialog: true, TransportExecutionPanel: true } } }
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getDispatch).mockResolvedValue(detail()); can.mockReturnValue(false); push.mockReset(); Object.keys(query).forEach(key => delete query[key]) })
|
||||
describe('whole-dispatch four-tab detail', () => {
|
||||
it.each(['omitted', 'null', 'invalid'] as const)('keeps schedule fields readable when exception records are %s and allows retry', async state => {
|
||||
const value = detail()
|
||||
if (state === 'omitted') Reflect.deleteProperty(value.dispatchInfo, 'exceptions')
|
||||
else Reflect.set(value.dispatchInfo, 'exceptions', state === 'null' ? null : {})
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(value)
|
||||
const renderError = vi.fn()
|
||||
const wrapper = mount(DispatchDetailView, { global: { ...options.global, config: { errorHandler: renderError } } })
|
||||
await flushPromises()
|
||||
await wrapper.get('#tab-dispatch').trigger('click'); await flushPromises()
|
||||
expect(renderError).not.toHaveBeenCalled()
|
||||
const pane = wrapper.get('#pane-dispatch')
|
||||
expect(pane.isVisible()).toBe(true)
|
||||
expect(pane.text()).toContain('LAX → ONT8')
|
||||
expect(pane.text()).toContain('派送异常记录读取失败')
|
||||
expect(pane.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间', 'FBA预约'])
|
||||
expect(pane.find('[aria-label="派送异常记录"]').exists()).toBe(false)
|
||||
await pane.get('button').trigger('click'); await flushPromises()
|
||||
expect(getDispatch).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.get('#pane-dispatch').isVisible()).toBe(true)
|
||||
expect(wrapper.get('#pane-dispatch').text()).not.toContain('派送异常记录读取失败')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('shows saved review attachments beside resource evidence without providing upload controls', async () => {
|
||||
const value = detail(); value.vehicleInfo.vehicle!.evidenceFileIds = ['301']
|
||||
value.reviews.dispatchReview.attachments = { vehicleFileIds: ['301', '302'], driverPhotoFileIds: ['303'], licenseFileIds: [] }
|
||||
@@ -60,6 +82,29 @@ describe('whole-dispatch four-tab detail', () => {
|
||||
expect(wrapper.findAll('[role="tab"]')).toHaveLength(0)
|
||||
expect(wrapper.find('[aria-label="排车操作"]').exists()).toBe(false); wrapper.unmount()
|
||||
})
|
||||
it('keeps detail tabs read-only when return-to-review is available from the list', async () => {
|
||||
can.mockReturnValue(true)
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ stage: 'LOADING', actions: { RETURN_TO_REVIEW: { allowed: true, blockCode: null, blockReason: null } } }))
|
||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||
await wrapper.findAll('[role="tab"]')[1]!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('footer[aria-label="排车操作"]').exists()).toBe(false)
|
||||
expect(wrapper.findAll('button').map(button => button.text())).not.toContain('退回装车前重审')
|
||||
expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps dispatch labels and placeholders visible when no schedule or appointments are recorded', async () => {
|
||||
const value = detail()
|
||||
value.dispatchInfo = { ...value.dispatchInfo, route: null, fbaAppointments: [] }
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(value)
|
||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||
for (const name of ['dispatch', 'documents', 'vehicle', 'dispatch']) {
|
||||
await wrapper.get(`#tab-${name}`).trigger('click'); await flushPromises()
|
||||
expect(wrapper.get(`#pane-${name}`).isVisible()).toBe(true)
|
||||
}
|
||||
expect(wrapper.get('#pane-dispatch').findAll('dd').map(field => field.text())).toEqual(['—', '—', '—', '—'])
|
||||
expect(wrapper.find('[aria-label="排车操作"]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('returns to the original appointment date and view from the dispatch detail', async () => {
|
||||
query.appointmentReturn = '/appointment-calendar?date=2026-10-21&mode=week'
|
||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||
@@ -86,6 +131,8 @@ describe('whole-dispatch four-tab detail', () => {
|
||||
await tabs[1]!.trigger('click'); await flushPromises()
|
||||
expect(tabs[1]!.attributes('aria-selected')).toBe('true')
|
||||
const dispatchInfo = wrapper.get(`#${tabs[1]!.attributes('aria-controls')}`)
|
||||
expect(dispatchInfo.isVisible()).toBe(true)
|
||||
expect(dispatchInfo.get('dl').isVisible()).toBe(true)
|
||||
expect(dispatchInfo.text()).toContain('LAX → ONT8'); expect(dispatchInfo.text()).toContain('FBA预约'); expect(dispatchInfo.text()).toContain('ONT8 · FBA-AP-1 · CONFIRMED')
|
||||
expect(dispatchInfo.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间', 'FBA预约'])
|
||||
expect(getDispatch).toHaveBeenCalledTimes(1); expect(wrapper.text()).toContain('装车前'); wrapper.unmount()
|
||||
|
||||
@@ -76,9 +76,11 @@ onBeforeUnmount(() => { generation++ })
|
||||
<div v-else v-loading="loading" class="dispatch-detail" :aria-busy="loading">
|
||||
<TransportExecutionPanel v-if="detail?.taskType && detail.taskType !== 'DELIVERY'" readonly :dispatch-no="dispatchNo" :pickup-approved="detail.reviewState === 'APPROVED'" :pickup-review-state="detail.reviewState" />
|
||||
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理(车辆信息),请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
|
||||
<el-tabs v-if="!detail?.taskType || detail.taskType === 'DELIVERY'" v-model="activeTab" class="density-element-tabs density-contained-tabs">
|
||||
<el-tab-pane name="vehicle"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span><span>车辆信息</span></template><dl class="detail-fields density-fields"><div v-for="field in vehicleFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" :model-value="reviewEvidence[field.key]" :dispatch-no="dispatchNo" :kind="field.kind" :label="field.label" readonly /></el-tab-pane>
|
||||
<el-tab-pane name="dispatch"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>排车信息</span></template><dl class="detail-fields density-fields"><div v-for="field in dispatchFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><section v-if="detail && detail.dispatchInfo.exceptions.length" class="detail-section" aria-label="派送异常记录">
|
||||
<el-tabs v-if="!detail?.taskType || detail.taskType === 'DELIVERY'" v-model="activeTab" class="density-element-tabs density-contained-tabs density-contained-tabs--flush">
|
||||
<el-tab-pane name="vehicle"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span><span>车辆信息</span></template><dl class="detail-fields density-fields vehicle-fields"><div v-for="field in vehicleFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" :model-value="reviewEvidence[field.key]" :dispatch-no="dispatchNo" :kind="field.kind" :label="field.label" readonly /></el-tab-pane>
|
||||
<el-tab-pane name="dispatch"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>排车信息</span></template><dl class="detail-fields density-fields"><div v-for="field in dispatchFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl>
|
||||
<LoadErrorState v-if="detail && !Array.isArray(detail.dispatchInfo.exceptions)" compact title="派送异常记录读取失败" message="排车详情返回的异常记录不完整,请重新加载。" :loading="loading" @retry="load" />
|
||||
<section v-else-if="detail && detail.dispatchInfo.exceptions.length" class="detail-section" aria-label="派送异常记录">
|
||||
<h3>派送异常记录</h3>
|
||||
<article v-for="(record, index) in detail.dispatchInfo.exceptions" :key="index">
|
||||
<dl class="density-fields"><div class="density-field"><dt>异常类型</dt><dd>{{ dispatchExceptionLabels[record.exceptionType] }}</dd></div><div class="density-field"><dt>登记时间</dt><dd>{{ time(record.occurredAt) }}</dd></div><div class="density-field"><dt>退货去向</dt><dd>{{ record.returnDirection ? dispatchReturnDirectionLabels[record.returnDirection] : '—' }}</dd></div><div class="density-field"><dt>异常说明</dt><dd>{{ record.description || '—' }}</dd></div></dl>
|
||||
@@ -86,7 +88,7 @@ onBeforeUnmount(() => { generation++ })
|
||||
</article>
|
||||
</section></el-tab-pane>
|
||||
<el-tab-pane name="documents"><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>单据信息</span></template>
|
||||
<dl class="detail-fields density-fields"><div v-for="field in documentFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div><div class="linked-documents"><dt>关联派送单</dt><dd><template v-for="delivery in detail?.documents.deliveries || []" :key="delivery.deliveryNo"><el-button v-if="access.can(permission.deliveryList)" link type="primary" @click="router.push({ name: 'overseas-delivery-order-detail', params: { deliveryNo: delivery.deliveryNo } })">{{ delivery.deliveryNo }}</el-button><span v-else>{{ text(delivery.deliveryNo) }}</span></template><span v-if="!detail?.documents.deliveries.length">—</span></dd></div></dl>
|
||||
<dl class="detail-fields density-fields"><div v-for="field in documentFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div><div class="linked-documents"><dt>关联派送单</dt><dd><template v-for="delivery in detail?.documents.deliveries || []" :key="delivery.deliveryNo"><el-button v-if="access.can(permission.deliveryList)" link type="primary" @click="router.push({ name: 'overseas-delivery-order-detail', params: { deliveryNo: delivery.deliveryNo }, query: { customerId: delivery.customerId } })">{{ delivery.deliveryNo }}</el-button><span v-else>{{ text(delivery.deliveryNo) }}</span></template><span v-if="!detail?.documents.deliveries.length">—</span></dd></div></dl>
|
||||
<div class="density-actions"><el-button v-for="outbound in detail?.documents.outbounds || []" v-show="access.can(permission.outboundList)" :key="outbound.outboundNo" link @click="router.push({ name: 'overseas-outbounds', query: { documentNo: outbound.outboundNo } })">查看出库 {{ outbound.outboundNo }}</el-button></div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane name="orders"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>关联订单明细</span></template><div class="workbench-table"><DataTable :data="relatedOrders" :columns="orderColumns" column-settings-mode="hide" row-key="sourceKey" empty-text="暂无关联订单明细" /></div></el-tab-pane>
|
||||
@@ -97,7 +99,9 @@ onBeforeUnmount(() => { generation++ })
|
||||
|
||||
<style scoped>
|
||||
.dispatch-detail { min-width: 0; min-height: 0; }
|
||||
.dispatch-detail .vehicle-fields { grid-template-columns: minmax(0, 1.3fr) minmax(0, 1.6fr) minmax(0, 1.2fr) minmax(0, 1.5fr) minmax(0, 0.8fr); }
|
||||
.dispatch-detail .vehicle-fields > div { grid-template-columns: max-content minmax(0, 1fr); }
|
||||
.linked-documents { grid-column: 1 / -1; }
|
||||
.linked-documents dd { display: flex; flex-wrap: wrap; gap: var(--admin-space-4); overflow-wrap: anywhere; }
|
||||
@media (max-width: 960px) { .detail-fields { grid-template-columns: 1fr; } .detail-fields > div { grid-template-columns: 132px minmax(0, 1fr); } }
|
||||
@media (max-width: 960px) { .dispatch-detail .detail-fields { grid-template-columns: 1fr; } .dispatch-detail .detail-fields > div { grid-template-columns: 132px minmax(0, 1fr); } }
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import ElementPlus, { ElMessageBox, ElSelect, ElTag } from 'element-plus'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import DispatchesView from './DispatchesView.vue'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import RowActions from '@/shared/components/RowActions.vue'
|
||||
import { getDispatch, getDispatchBolPdf, listDispatches } from '../api/dispatches'
|
||||
import { cancelTransport, getTransport } from '../api/transportTasks'
|
||||
@@ -79,7 +80,7 @@ it('never offers a second arrangement or departure after loading completion', as
|
||||
const gate = { allowed: true, blockCode: null, blockReason: null }
|
||||
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ stage: 'DELIVERING', actions: { ARRANGE_DELIVERY: gate, DEPART: gate } })]))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
expect(wrapper.findAll('[role="tab"]').some(tab => tab.text().startsWith('装车后'))).toBe(false)
|
||||
expect(wrapper.findAll('[role="tab"]').some(tab => tab.text().startsWith('装车后'))).toBe(true)
|
||||
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||
const keys = (row.props('actions') as Array<{ key: string }>).map(action => action.key)
|
||||
expect(keys).not.toContain('ARRANGE_DELIVERY'); expect(keys).not.toContain('DEPART')
|
||||
@@ -87,6 +88,22 @@ it('never offers a second arrangement or departure after loading completion', as
|
||||
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps return-to-review on the loading list with its service gate and permission', async () => {
|
||||
const allowed = { allowed: true, blockCode: null, blockReason: null }
|
||||
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ stage: 'LOADING', actions: { RETURN_TO_REVIEW: allowed } })]))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||
expect(row.props('actions')).toEqual(expect.arrayContaining([expect.objectContaining({ key: 'RETURN_TO_REVIEW' })]))
|
||||
row.vm.$emit('action', 'RETURN_TO_REVIEW'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'RETURN_TO_REVIEW', dispatchNo: 'PC/1' })
|
||||
wrapper.unmount(); can.mockReturnValue(false)
|
||||
const denied = mount(DispatchesView, options); await flushPromises()
|
||||
const deniedRow = denied.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||
expect(deniedRow.props('actions').map((item: { key: string }) => item.key)).not.toContain('RETURN_TO_REVIEW')
|
||||
deniedRow.vm.$emit('action', 'RETURN_TO_REVIEW'); await flushPromises()
|
||||
expect(denied.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
|
||||
denied.unmount()
|
||||
})
|
||||
it('offers recovery of the original pending command independently of the filtered list rows', async () => {
|
||||
const pending = useDispatchCommandRecovery(() => identity.id); pending.begin('PC-OTHER', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'original-key' })
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
@@ -156,15 +173,126 @@ it.each([1, 2])('keeps only details in audit tab %s without its review permissio
|
||||
expect((row.props('actions') as Array<{ key: string }>).map(item => item.key)).toEqual(['detail'])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('shows four working phases and uses server stage counts instead of current page length', async () => {
|
||||
it('shows five working phases and uses server stage counts instead of current page length', async () => {
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const tabs = wrapper.get('.data-table__toolbar-start .workbench-list-tabs[aria-label="排车执行阶段"]').findAll('button')
|
||||
expect(wrapper.find('.data-table__filters [aria-label="装车前审核分区"]').exists()).toBe(true)
|
||||
expect(tabs.map(tab => tab.text())).toEqual(['装车前 3', '装车中 1', '派送中 1', '派送完成 1'])
|
||||
expect(tabs.map(tab => tab.text())).toEqual(['装车前 3', '装车中 1', '装车后 1', '派送中 1', '派送完成 1'])
|
||||
expect(listDispatches).toHaveBeenCalledWith(expect.objectContaining({ stage: 'BEFORE_LOADING', status: 'ACTIVE', beforeLoadingSection: 'VEHICLE_INFO' }))
|
||||
await tabs[2]!.trigger('click'); await flushPromises()
|
||||
await tabs[3]!.trigger('click'); await flushPromises()
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'DELIVERING' })); expect(wrapper.find('[aria-label="装车前审核分区"]').exists()).toBe(false); wrapper.unmount()
|
||||
})
|
||||
it.each([0, 1, 2])('preserves section %s and its query after clearing the active dispatch filter', async index => {
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const sectionValues = ['VEHICLE_INFO', 'DISPATCH_REVIEW', 'LOADING_REVIEW']
|
||||
const sectionSelector = '[aria-label="装车前审核分区"]'
|
||||
await wrapper.get(sectionSelector).findAll('button')[index]!.trigger('click'); await flushPromises()
|
||||
const toolbar = wrapper.getComponent(QueryToolbar)
|
||||
await toolbar.findAll('button').find(button => button.text() === '高级筛选')!.trigger('click')
|
||||
toolbar.findAllComponents(ElSelect).find(select => select.props('placeholder') === '单据范围')!.vm.$emit('update:modelValue', '')
|
||||
await flushPromises()
|
||||
expect(wrapper.get(sectionSelector).findAll('button').map(button => button.text())).toEqual(['车辆信息', '调度审核', '理货员审核'])
|
||||
expect(wrapper.get(sectionSelector).findAll('button')[index]!.attributes('aria-selected')).toBe('true')
|
||||
await toolbar.findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ACTIVE', beforeLoadingSection: sectionValues[index] }))
|
||||
const nextIndex = (index + 1) % 3
|
||||
await wrapper.get(sectionSelector).findAll('button')[nextIndex]!.trigger('click'); await flushPromises()
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ACTIVE', beforeLoadingSection: sectionValues[nextIndex] }))
|
||||
await toolbar.findAll('button').find(button => button.text() === '重置')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.get(sectionSelector).findAll('button')).toHaveLength(3)
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ACTIVE', beforeLoadingSection: sectionValues[nextIndex] }))
|
||||
await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[1]!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.find(sectionSelector).exists()).toBe(false)
|
||||
expect(vi.mocked(listDispatches).mock.calls.at(-1)?.[0]).not.toHaveProperty('beforeLoadingSection')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each([
|
||||
['WAIT_DEPARTURE', 'BEFORE_LOADING', '装车前'],
|
||||
['DELIVERING', 'DELIVERING', '派送中'],
|
||||
['DELIVERED', 'DELIVERED', '派送完成'],
|
||||
] as const)('queries %s through server pagination and selects the corresponding stage', async (vehicleStatus, stage, label) => {
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[3]!.trigger('click'); await flushPromises()
|
||||
const table = wrapper.getComponent(DataTable)
|
||||
table.vm.$emit('update:page', 3); await flushPromises()
|
||||
const result = { ...page([listRow({ stage })]), total: 21, pages: 3 }
|
||||
vi.mocked(listDispatches).mockResolvedValueOnce(result)
|
||||
const toolbar = wrapper.getComponent(QueryToolbar)
|
||||
const select = toolbar.findAllComponents(ElSelect).find(item => item.props('placeholder') === '车辆状态')!
|
||||
select.vm.$emit('update:modelValue', vehicleStatus); select.vm.$emit('change', vehicleStatus); await flushPromises()
|
||||
await toolbar.findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 10, stage, status: 'ACTIVE' }))
|
||||
expect(vi.mocked(listDispatches).mock.calls.at(-1)?.[0]).not.toHaveProperty('vehicleStatus')
|
||||
expect(wrapper.get('[aria-label="排车执行阶段"] [aria-selected="true"]').text()).toContain(label)
|
||||
expect(table.props('total')).toBe(21)
|
||||
expect(table.props('data')).toEqual(result.records)
|
||||
expect(wrapper.find('[aria-label="装车前审核分区"]').exists()).toBe(stage === 'BEFORE_LOADING')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps waiting vehicles in the current loading phase and synchronizes an active status when changing phases', async () => {
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const tabs = wrapper.get('[aria-label="排车执行阶段"]').findAll('button')
|
||||
await tabs[1]!.trigger('click'); await flushPromises()
|
||||
const toolbar = wrapper.getComponent(QueryToolbar)
|
||||
toolbar.vm.$emit('search', { vehicleStatus: 'WAIT_DEPARTURE', status: 'ACTIVE' }); await flushPromises()
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'LOADING' }))
|
||||
expect(vi.mocked(listDispatches).mock.calls.at(-1)?.[0]).not.toHaveProperty('beforeLoadingSection')
|
||||
await tabs[3]!.trigger('click'); await flushPromises()
|
||||
expect(toolbar.props('modelValue').vehicleStatus).toBe('DELIVERING')
|
||||
await tabs[0]!.trigger('click'); await flushPromises()
|
||||
expect(toolbar.props('modelValue').vehicleStatus).toBe('WAIT_DEPARTURE')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('shows trip progress independently of master vehicle status and leaves cancelled vehicles without progress', async () => {
|
||||
const records = [
|
||||
listRow({ dispatchNo: 'PC-WAIT', stage: 'BEFORE_LOADING' }),
|
||||
listRow({ dispatchNo: 'PC-LOADING', stage: 'LOADING' }),
|
||||
listRow({ dispatchNo: 'PC-DELIVERING', stage: 'DELIVERING' }),
|
||||
listRow({ dispatchNo: 'PC-DELIVERED', stage: 'DELIVERED' }),
|
||||
listRow({ dispatchNo: 'PC-CANCELLED', status: 'CANCELLED' }),
|
||||
]
|
||||
for (const row of records) row.resources.vehicle!.vehicleStatus = 'MAINTENANCE'
|
||||
vi.mocked(listDispatches).mockResolvedValueOnce(page(records))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const toolbar = wrapper.getComponent(QueryToolbar)
|
||||
expect(toolbar.props('fields').map(field => field.key)).toEqual(['dispatchNo', 'vehiclePlate', 'vehicleStatus'])
|
||||
expect(toolbar.props('fields').find(field => field.key === 'vehicleStatus')?.options?.map(option => option.label)).toEqual(['待发车', '配送中', '已送达'])
|
||||
expect(wrapper.getComponent(DataTable).props('columns').some(column => column.key === 'vehicleStatus' && column.label === '车辆状态')).toBe(true)
|
||||
expect(wrapper.findAllComponents(ElTag).map(tag => tag.text())).toEqual(['待发车', '待发车', '配送中', '已送达'])
|
||||
expect(wrapper.text()).toContain('已取消')
|
||||
expect(wrapper.text()).not.toContain('维修中')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each(['CANCELLED', 'ALL'])('retains advanced %s lookup and resets its hidden value', async status => {
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const toolbar = wrapper.getComponent(QueryToolbar)
|
||||
toolbar.vm.$emit('search', { status: 'ACTIVE', vehicleStatus: 'WAIT_DEPARTURE' }); await flushPromises()
|
||||
await toolbar.findAll('button').find(button => button.text() === '高级筛选')!.trigger('click')
|
||||
const select = toolbar.findAllComponents(ElSelect).find(item => item.props('placeholder') === '单据范围')!
|
||||
select.vm.$emit('update:modelValue', status); select.vm.$emit('change', status); await flushPromises()
|
||||
await toolbar.findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
|
||||
expect(toolbar.props('modelValue').vehicleStatus).toBe('')
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status }))
|
||||
expect(vi.mocked(listDispatches).mock.calls.at(-1)?.[0]).not.toHaveProperty('beforeLoadingSection')
|
||||
await toolbar.findAll('button').find(button => button.text().startsWith('收起筛选'))!.trigger('click')
|
||||
expect(toolbar.props('fields').some(field => field.key === 'status')).toBe(false)
|
||||
expect(toolbar.text()).toContain(status === 'CANCELLED' ? '高级筛选 · 已取消' : '高级筛选 · 全部单据')
|
||||
await toolbar.findAll('button').find(button => button.text() === '重置')!.trigger('click'); await flushPromises()
|
||||
expect(toolbar.props('modelValue')).toMatchObject({ status: 'ACTIVE', vehicleStatus: '' })
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, status: 'ACTIVE', beforeLoadingSection: 'VEHICLE_INFO' }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('returns to active dispatches when selecting vehicle progress from cancelled lookup', async () => {
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const toolbar = wrapper.getComponent(QueryToolbar)
|
||||
toolbar.vm.$emit('search', { status: 'CANCELLED', vehicleStatus: '' }); await flushPromises()
|
||||
const select = toolbar.findAllComponents(ElSelect).find(item => item.props('placeholder') === '车辆状态')!
|
||||
select.vm.$emit('update:modelValue', 'DELIVERING'); select.vm.$emit('change', 'DELIVERING'); await flushPromises()
|
||||
await toolbar.findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ACTIVE', stage: 'DELIVERING' }))
|
||||
expect(toolbar.props('modelValue')).toMatchObject({ status: 'ACTIVE', vehicleStatus: 'DELIVERING' })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('queries the current review queue without offering historical result filters', async () => {
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
await wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[1]!.trigger('click'); await flushPromises()
|
||||
|
||||
@@ -27,6 +27,7 @@ import TransportExecutionPanel from '../components/TransportExecutionPanel.vue'
|
||||
import { cancelTransport, getTransport, transportLabels, type TransportType } from '../api/transportTasks'
|
||||
import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
|
||||
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
|
||||
import DispatchDocumentsDialog from '../components/DispatchDocumentsDialog.vue'
|
||||
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
|
||||
|
||||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
@@ -40,26 +41,40 @@ const transportEditTitle = ref('维护取货车辆信息'), transportResourceMod
|
||||
const activeTransport = computed(() => rows.value.find(row => row.dispatchNo === transportEditNo.value))
|
||||
const cancellingPickup = ref('')
|
||||
function createTransport(type: TransportType) { transportType.value = type; transportOpen.value = true }
|
||||
const documentsOpen = ref(false), documentsNo = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const stage = ref<DispatchStage>('BEFORE_LOADING'), section = ref<BeforeLoadingSection>('VEHICLE_INFO')
|
||||
const model = ref<QueryModel>({ dispatchNo: String(route.query.dispatchNo || ''), vehiclePlate: '', status: 'ACTIVE' })
|
||||
const model = ref<QueryModel>({ dispatchNo: String(route.query.dispatchNo || ''), vehiclePlate: '', vehicleStatus: '', status: 'ACTIVE' })
|
||||
const showAdvancedFilters = ref(false)
|
||||
const vehicleStatuses = [
|
||||
{ value: 'WAIT_DEPARTURE', label: '待发车', type: 'warning' },
|
||||
{ value: 'DELIVERING', label: '配送中', type: 'primary' },
|
||||
{ value: 'DELIVERED', label: '已送达', type: 'success' },
|
||||
] as const
|
||||
function vehicleStatusForStage(value: DispatchStage) {
|
||||
if (value === 'BEFORE_LOADING' || value === 'LOADING' || value === 'AFTER_LOADING') return vehicleStatuses[0]
|
||||
return vehicleStatuses.find(item => item.value === value)
|
||||
}
|
||||
const operation = ref<'ROUTE' | undefined>()
|
||||
const action = ref<DispatchAction>('EDIT'), actionNo = ref(''), actionOpen = ref(false), createOpen = ref(false), bolLoadingNo = ref('')
|
||||
const routeOpen = ref(false), routeOrigin = ref<RouteLocation>(routeLocation('')), routeDestinations = ref<RouteLocation[]>([])
|
||||
const sections: Array<{ value: BeforeLoadingSection; label: string }> = [{ value: 'VEHICLE_INFO', label: '车辆信息' }, { value: 'DISPATCH_REVIEW', label: '调度审核' }, { value: 'LOADING_REVIEW', label: '理货员审核' }]
|
||||
const showSections = computed(() => stage.value === 'BEFORE_LOADING' && model.value.status === 'ACTIVE')
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
const showSections = computed(() => stage.value === 'BEFORE_LOADING' && (model.value.status || 'ACTIVE') === 'ACTIVE')
|
||||
const availableFields = computed<QueryField[]>(() => [
|
||||
{ key: 'dispatchNo', label: '排车单号', type: 'input', width: 180, placeholder: '请输入排车单号' },
|
||||
{ key: 'vehiclePlate', label: '车牌号', type: 'input', width: 150, placeholder: '请输入车牌号' },
|
||||
{ key: 'status', label: '单据范围', type: 'select', width: 140, options: [{ value: 'ACTIVE', label: '有效排车' }, { value: 'CANCELLED', label: '已取消' }, { value: 'ALL', label: '全部单据' }] },
|
||||
{ key: 'vehicleStatus', label: '车辆状态', placeholder: '车辆状态', type: 'select', width: 140, options: [...vehicleStatuses], props: { 'aria-label': '车辆状态', onChange: (value: unknown) => { if (value) model.value.status = 'ACTIVE' } } },
|
||||
{ key: 'status', label: '单据范围', placeholder: '单据范围', type: 'select', width: 140, options: [{ value: 'ACTIVE', label: '有效排车' }, { value: 'CANCELLED', label: '已取消' }, { value: 'ALL', label: '全部单据' }], props: { 'aria-label': '单据范围', onChange: (value: unknown) => { if (value && value !== 'ACTIVE') model.value.vehicleStatus = '' } } },
|
||||
])
|
||||
const fields = computed(() => availableFields.value.filter(field => showAdvancedFilters.value || field.key !== 'status'))
|
||||
const columns = computed<DataTableColumn[]>(() => [
|
||||
{ key: 'actions', label: '操作', width: rows.value.some(row => row.taskType === 'PICKUP') ? 330 : 220, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'taskType', label: '排车类型', width: 115, slotName: 'taskType' },
|
||||
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 190 },
|
||||
{ key: 'vehicle', label: '车牌号', minWidth: 130, slotName: 'vehicle' }, { key: 'driver', label: '司机/联系电话', minWidth: 170, slotName: 'driver' },
|
||||
{ key: 'vehicleStatus', label: '车辆状态', minWidth: 110, slotName: 'vehicleStatus' },
|
||||
{ key: 'warehouseName', prop: 'warehouseName', label: '起运仓', minWidth: 160 },
|
||||
...(showSections.value && section.value === 'VEHICLE_INFO' ? [{ key: 'returnReason', label: '退回原因', minWidth: 240, slotName: 'returnReason' }] : []),
|
||||
...(showSections.value && section.value !== 'VEHICLE_INFO' ? [{ key: 'review', label: section.value === 'DISPATCH_REVIEW' ? '调度审核' : '理货员审核', minWidth: 210, slotName: 'review' }] : []),
|
||||
@@ -80,11 +95,27 @@ async function load() {
|
||||
} catch (cause) { if (current === sequence) { rows.value = []; total.value = 0; statistics.value = null; loadError.value = getApiErrorDetails(cause, '排车列表加载失败,请重试') } }
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
function search(value: QueryModel) { model.value = value; page.value = 1; void load() }
|
||||
function switchStage(value: DispatchStage) { stage.value = value; section.value = 'VEHICLE_INFO'; page.value = 1; void load() }
|
||||
function search(value: QueryModel) {
|
||||
model.value = { ...value, status: value.status || 'ACTIVE' }
|
||||
const selected = vehicleStatuses.find(item => item.value === model.value.vehicleStatus)
|
||||
if (model.value.status !== 'ACTIVE') model.value.vehicleStatus = ''
|
||||
else if (selected && vehicleStatusForStage(stage.value)?.value !== selected.value) {
|
||||
// 车辆运输进度与阶段联动,查询仍由服务端按阶段分页,避免交叉条件恒为空。
|
||||
stage.value = selected.value === 'WAIT_DEPARTURE' ? 'BEFORE_LOADING' : selected.value
|
||||
section.value = 'VEHICLE_INFO'
|
||||
}
|
||||
page.value = 1; void load()
|
||||
}
|
||||
function resetSearch() { search({ dispatchNo: '', vehiclePlate: '', vehicleStatus: '', status: 'ACTIVE' }) }
|
||||
function switchStage(value: DispatchStage) {
|
||||
stage.value = value
|
||||
if (model.value.vehicleStatus) model.value.vehicleStatus = vehicleStatusForStage(value)?.value || ''
|
||||
section.value = 'VEHICLE_INFO'; page.value = 1; void load()
|
||||
}
|
||||
function switchSection(value: BeforeLoadingSection) { section.value = value; page.value = 1; void load() }
|
||||
function openDetail(row: DispatchListRow) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) }
|
||||
const rowActionOrder: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'MARK_EXCEPTION', 'CANCEL']
|
||||
function canManageDocuments(row: DispatchListRow) { return (row.taskType || 'DELIVERY') === 'DELIVERY' && row.status === 'ACTIVE' && ['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(row.stage) }
|
||||
function rowActions(row: DispatchListRow): RowActionItem[] {
|
||||
if (showSections.value && section.value !== 'VEHICLE_INFO') {
|
||||
const review = section.value
|
||||
@@ -94,7 +125,7 @@ function rowActions(row: DispatchListRow): RowActionItem[] {
|
||||
const available = rowActionOrder.filter(value => canDispatchAction(row.actions, value, access.can))
|
||||
const primaryAction = available.find(value => value === 'EDIT') ?? available[0]
|
||||
const execution = pickupExecutionLabel(row)
|
||||
return [...(execution ? [{ key: 'transport', label: execution, priority: 650 }] : []), ...available.map((value, index) => ({
|
||||
return [...(execution ? [{ key: 'transport', label: execution, priority: 650 }] : []), ...(canManageDocuments(row) ? [{ key: 'documents', label: '管理资料', priority: 700 }] : []), ...available.map((value, index) => ({
|
||||
key: value,
|
||||
label: dispatchActionLabels[value],
|
||||
ariaLabel: dispatchActionLabels[value],
|
||||
@@ -119,6 +150,7 @@ function handleAction(value: string, row: DispatchListRow) {
|
||||
if (value === 'detail') return openDetail(row)
|
||||
if (showSections.value && section.value !== 'VEHICLE_INFO' && value !== section.value) return
|
||||
if (value === 'transport') { if (rowActions(row).some(item => item.key === value)) openTransport(row); return }
|
||||
if (value === 'documents') { if (canManageDocuments(row)) { documentsNo.value = row.dispatchNo; documentsOpen.value = true }; return }
|
||||
if (value === 'bol') return void openBol(row)
|
||||
rejectionError.value = null
|
||||
if (value === 'route-order') { if (!canDispatchAction(row.actions, 'EDIT', access.can)) return; operation.value = 'ROUTE'; action.value = 'EDIT'; actionNo.value = row.dispatchNo; actionOpen.value = true; return }
|
||||
@@ -174,11 +206,12 @@ onBeforeUnmount(() => { sequence++ })
|
||||
<div class="workbench-table is-page-surface"><DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="preferenceKey" row-key="dispatchNo" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-start><el-button v-if="appointmentReturnTarget(route.query)" link @click="router.push(appointmentReturnTarget(route.query)!)">返回预约工作</el-button><div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="排车执行阶段"><button v-for="item in dispatchStages" :key="item.value" role="tab" :aria-selected="stage === item.value" :class="{ 'is-active': stage === item.value }" @click="switchStage(item.value)">{{ item.label }} <span>{{ statistics?.stageCounts[item.value] ?? '—' }}</span></button></div></template>
|
||||
<template #toolbar-end><el-button v-if="access.can(dispatchActionPermissions.CREATE)" @click="createTransport('PICKUP')">安排到仓取货</el-button><el-button v-if="access.can(dispatchActionPermissions.CREATE)" @click="createTransport('LOCAL_TRUCK')">安排本土卡车</el-button><el-button v-if="access.can(dispatchActionPermissions.CREATE) || access.can(dispatchActionPermissions.ADD_CARGO)" type="primary" @click="createOpen = true">智能排车</el-button></template>
|
||||
<template #filters><div class="integrated-query-group"><div v-if="showSections" 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="switchSection(item.value)">{{ item.label }}</button></div><QueryToolbar v-model="model" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="search" /></div></template>
|
||||
<template #filters><div class="integrated-query-group"><div v-if="showSections" 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="switchSection(item.value)">{{ item.label }}</button></div><QueryToolbar id="dispatch-query-filters" v-model="model" :fields="fields" :available-fields="availableFields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="resetSearch"><template #actions><el-button :aria-expanded="showAdvancedFilters" aria-controls="dispatch-query-filters" @click="showAdvancedFilters = !showAdvancedFilters">{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}{{ model.status === 'CANCELLED' ? ' · 已取消' : model.status === 'ALL' ? ' · 全部单据' : '' }}</el-button></template></QueryToolbar></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 DispatchListRow)" :max-visible="row.taskType === 'PICKUP' ? 3 : 2" :more-label="`更多操作 · ${row.dispatchNo}`" @action="handleAction($event, row as DispatchListRow)" /><p v-if="!row.actions">操作资格未返回,请刷新核对</p></template>
|
||||
<template #taskType="{ row }">{{ transportLabels[(row.taskType || 'DELIVERY') as keyof typeof transportLabels] }}</template>
|
||||
<template #vehicle="{ row }">{{ row.resources.vehicle?.plate || '—' }}</template><template #driver="{ row }">{{ row.resources.driver?.name || '—' }}<br>{{ row.resources.driver?.phone || '—' }}</template>
|
||||
<template #vehicleStatus="{ row }"><el-tag v-if="row.status === 'ACTIVE' && vehicleStatusForStage(row.stage)" :type="vehicleStatusForStage(row.stage)?.type">{{ vehicleStatusForStage(row.stage)?.label }}</el-tag><span v-else>—</span></template>
|
||||
<template #review="{ row }"><template v-for="review in [section === 'DISPATCH_REVIEW' ? row.reviews.dispatchReview : row.reviews.loadingReview]" :key="review.reviewRound"><div>{{ reviewLabels[review.status as ReviewStatus] }}</div><div v-if="review.reason">{{ review.reason }}</div><div v-if="review.invalidatedReason">{{ review.invalidatedReason }}</div><div>{{ time(review.reviewedAt) }}</div></template></template>
|
||||
<template #returnReason="{ row }">{{ dispatchRejectionReason(row.reviews) || '—' }}</template>
|
||||
<template #counts="{ row }">{{ row.counts.palletCount }}托/{{ row.counts.packageCount }}箱/已拣{{ row.counts.pickedPackageCount }}箱</template><template #planned="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #departure="{ row }">{{ time(row.departedAt) }}</template><template #stage="{ row }">{{ row.status === 'CANCELLED' ? '已取消' : stageLabel(row.stage) }}</template><template #routeMap="{ row }"><el-button link @click="openRoute(row as DispatchListRow)">查看路线</el-button></template>
|
||||
@@ -188,5 +221,6 @@ onBeforeUnmount(() => { sequence++ })
|
||||
<AppDialog v-model="transportEditOpen" class="admin-density-dialog" :title="transportEditTitle" width="1000px" :show-confirm="false" cancel-text="关闭"><TransportExecutionPanel v-if="transportEditOpen" :dispatch-no="transportEditNo" :edit-resources="transportResourceMode" :pickup-approved="activeTransport?.reviewState === 'APPROVED'" :pickup-review-state="activeTransport?.reviewState" @saved="load" /></AppDialog>
|
||||
<SmartDispatchDialog v-model="createOpen" :orders="[]" @created="load" />
|
||||
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="actionNo" :action="action" :operation="operation" @saved="handleSaved" @rejected="handleRejection" />
|
||||
<DispatchDocumentsDialog v-model="documentsOpen" :dispatch-no="documentsNo" @saved="load" />
|
||||
<TransportRouteDialog v-model="routeOpen" :origin="routeOrigin" :destinations="routeDestinations" />
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user