优化
This commit is contained in:
@@ -423,3 +423,9 @@
|
|||||||
本轮自动验证:Admin 5 个定向文件 96 项通过,`pnpm typecheck` 通过;Lastleg/File 8 个定向类 63 项通过,API 路由合同 5 项通过,AGENTS 索引 1232 项通过。未部署,未进行真实页面操作;上线本次变更须包含 Admin、lastleg-service 和 file(新增司机照片文件分类)。
|
本轮自动验证:Admin 5 个定向文件 96 项通过,`pnpm typecheck` 通过;Lastleg/File 8 个定向类 63 项通过,API 路由合同 5 项通过,AGENTS 索引 1232 项通过。未部署,未进行真实页面操作;上线本次变更须包含 Admin、lastleg-service 和 file(新增司机照片文件分类)。
|
||||||
|
|
||||||
- 2026-10-21 排车“派送中 → 标记异常”:按用户提供的三类异常、两类退货去向截图补齐选项,沿用居中作业弹框,说明与图片选填,保存后仍派送中;现有排车信息页签回看。自动回归单独报告,1920×1080/100%真实页面待人工截图确认。
|
- 2026-10-21 排车“派送中 → 标记异常”:按用户提供的三类异常、两类退货去向截图补齐选项,沿用居中作业弹框,说明与图片选填,保存后仍派送中;现有排车信息页签回看。自动回归单独报告,1920×1080/100%真实页面待人工截图确认。
|
||||||
|
|
||||||
|
## 排车管理资料统一入口(2026-10-21)
|
||||||
|
|
||||||
|
| 页面 | 操作 | 原型/确认依据 | 承载与实现 | 验收 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 排车列表:派送中、配送完成 | 管理资料 | 用户管理资料截图及同车/派送单资料共用确认;签收完成即可,其他可补 | 1100px居中可全屏弹框,签收文件/油耗图片/装车资料三个页签;按关联派送单切换,共用原派送资料编辑器;油耗沿本车附加照片保存 | 自动回归单独报告;1920×1080、100%待人工截图确认 |
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||||
|
import type { DispatchStage } from '../dispatchContracts'
|
||||||
|
|
||||||
|
export const loadingPhotoSlots = ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT'] as const
|
||||||
|
export type LoadingPhotoSlot = typeof loadingPhotoSlots[number]
|
||||||
|
export interface LoadingPhoto { slot: LoadingPhotoSlot; fileId: string; fileName: string; contentType: string; savedAt: string | null }
|
||||||
|
export interface LoadingDocuments { dispatchNo: string; recordVersion: string; dispatchRecordVersion: string; stage: DispatchStage; editable: boolean; complete: boolean; photos: LoadingPhoto[] }
|
||||||
|
export interface BindLoadingPhoto { recordVersion: string; idempotencyKey: string; fileId: string }
|
||||||
|
const path = (no: string) => `/platform/v1/last-leg/dispatches/${encodeURIComponent(no)}/loading-documents`
|
||||||
|
export async function getLoadingDocuments(no: string) {
|
||||||
|
return unwrapApiResponse(await http.get<ApiEnvelope<LoadingDocuments>, ApiEnvelope<LoadingDocuments>>(path(no)))
|
||||||
|
}
|
||||||
|
export async function uploadLoadingPhoto(no: string, slot: LoadingPhotoSlot, file: File, key: string) {
|
||||||
|
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key)
|
||||||
|
return unwrapApiResponse(await http.post<ApiEnvelope<LoadingPhoto>, ApiEnvelope<LoadingPhoto>>(`${path(no)}/${slot}/uploads`, form))
|
||||||
|
}
|
||||||
|
export async function recoverLoadingPhoto(no: string, key: string) {
|
||||||
|
return unwrapApiResponse(await http.get<ApiEnvelope<LoadingPhoto>, ApiEnvelope<LoadingPhoto>>(`${path(no)}/uploads/${encodeURIComponent(key)}`))
|
||||||
|
}
|
||||||
|
export async function bindLoadingPhoto(no: string, slot: LoadingPhotoSlot, body: BindLoadingPhoto) {
|
||||||
|
return unwrapApiResponse(await http.put<ApiEnvelope<LoadingDocuments>, ApiEnvelope<LoadingDocuments>>(`${path(no)}/${slot}`, body))
|
||||||
|
}
|
||||||
|
export async function readLoadingPhoto(no: string, photo: LoadingPhoto) {
|
||||||
|
const blob = await http.get<Blob, Blob>(`${path(no)}/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob' })
|
||||||
|
if (!(blob instanceof Blob) || !['image/jpeg', 'image/png'].includes(blob.type) || !blob.size) throw new Error('图片读取失败,请重新核对')
|
||||||
|
return blob
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import '@/styles/adminWorkbench.css'
|
import '@/styles/adminWorkbench.css'
|
||||||
import '@/styles/adminDensity.css'
|
import '@/styles/adminDensity.css'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
import LineClampText from '@/shared/components/LineClampText.vue'
|
import LineClampText from '@/shared/components/LineClampText.vue'
|
||||||
@@ -33,6 +33,7 @@ const visibleGroups = computed(() => draft.value.filter(item => !props.categorie
|
|||||||
const dirty = computed(() => Boolean(snapshot.value) && (bolNo.value !== (snapshot.value?.bolNo || '') || draft.value.some(group => JSON.stringify(group.files.map(file => file.fileId)) !== JSON.stringify(snapshot.value?.categories.find(item => item.category === group.category)?.files.map(file => file.fileId) || []))))
|
const dirty = computed(() => Boolean(snapshot.value) && (bolNo.value !== (snapshot.value?.bolNo || '') || draft.value.some(group => JSON.stringify(group.files.map(file => file.fileId)) !== JSON.stringify(snapshot.value?.categories.find(item => item.category === group.category)?.files.map(file => file.fileId) || []))))
|
||||||
defineExpose({ busy, dirty, canEdit, loading, conflict, save })
|
defineExpose({ busy, dirty, canEdit, loading, conflict, save })
|
||||||
let sequence = 0
|
let sequence = 0
|
||||||
|
onBeforeUnmount(() => { sequence++ })
|
||||||
function isCurrent(current: number, deliveryNo: string, customerId: string) { return current === sequence && open.value && props.deliveryNo === deliveryNo && props.customerId === customerId }
|
function isCurrent(current: number, deliveryNo: string, customerId: string) { return current === sequence && open.value && props.deliveryNo === deliveryNo && props.customerId === customerId }
|
||||||
let attempt: { body: string; key: string } | null = null
|
let attempt: { body: string; key: string } | null = null
|
||||||
async function load() {
|
async function load() {
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||||
|
import { defineComponent, h } from 'vue'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { beforeEach, expect, it, vi } from 'vitest'
|
||||||
|
import DispatchDocumentsDialog from './DispatchDocumentsDialog.vue'
|
||||||
|
import DeliveryDocumentsDialog from './DeliveryDocumentsDialog.vue'
|
||||||
|
import { detail } from '../dispatchTestFixtures'
|
||||||
|
import { getDispatch } from '../api/dispatches'
|
||||||
|
import { deliveryDocumentCategories, getDeliveryDocuments, saveDeliveryDocuments, type DeliveryDocuments } from '../api/deliveryDocuments'
|
||||||
|
const can = vi.hoisted(() => vi.fn(() => true))
|
||||||
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||||
|
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn() }))
|
||||||
|
vi.mock('../api/deliveryDocuments', async original => ({ ...await original<typeof import('../api/deliveryDocuments')>(), getDeliveryDocuments: vi.fn(), saveDeliveryDocuments: vi.fn() }))
|
||||||
|
const dialogStub = defineComponent({ props: ['modelValue', 'showConfirm'], emits: ['confirm'], setup(props, { slots, emit }) { return () => props.modelValue ? h('section', [slots.default?.(), props.showConfirm ? h('button', { class: 'save', onClick: () => emit('confirm') }, '保存资料') : null]) : null } })
|
||||||
|
function documents(no = 'DL-1', customerId = '101'): DeliveryDocuments {
|
||||||
|
return { deliveryNo: no, customerId, version: 1, bolNo: 'BOL-ORIGINAL', uploadedCategoryCount: 1, totalCategoryCount: 3, allowedActions: ['MANAGE_DOCUMENTS'], replayed: false, categories: deliveryDocumentCategories.map(item => ({ category: item.category, status: item.category === 'RECEIPT' ? 'UPLOADED' : 'MISSING', fileCount: item.category === 'RECEIPT' ? 1 : 0, files: item.category === 'RECEIPT' ? [{ fileId: '99', fileName: 'existing-pod.pdf', contentType: 'application/pdf', sizeBytes: 10, uploadedAt: '2026-10-21T00:00:00' }] : [] })) }
|
||||||
|
}
|
||||||
|
function setup() { return mount(DispatchDocumentsDialog, { props: { modelValue: true, dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus], stubs: { AppDialog: dialogStub, DispatchVehicleDocuments: true, DispatchLoadingDocuments: true, DispatchEvidenceFiles: true } } }) }
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks(); can.mockReturnValue(true)
|
||||||
|
const value = detail(); value.stage = 'DELIVERING'
|
||||||
|
value.documents.deliveries = [
|
||||||
|
{ deliveryNo: 'DL-1', customerId: '101', customerName: '客户甲', groupKey: 'A', outboundNo: 'OB-1', destination: { key: 'FBA:A', type: 'FBA', warehouseId: null, warehouseCode: 'A', warehouseName: null, address: null, postalCode: null, contactName: null, contactPhone: null }, status: 'DELIVERING', packageCount: 1, palletCount: 1 },
|
||||||
|
{ deliveryNo: 'DL-2', customerId: '102', customerName: '客户乙', groupKey: 'B', outboundNo: 'OB-2', destination: { key: 'FBA:B', type: 'FBA', warehouseId: null, warehouseCode: 'B', warehouseName: null, address: null, postalCode: null, contactName: null, contactPhone: null }, status: 'DELIVERING', packageCount: 1, palletCount: 1 },
|
||||||
|
]
|
||||||
|
vi.mocked(getDispatch).mockResolvedValue(value)
|
||||||
|
vi.mocked(getDeliveryDocuments).mockImplementation(async (no, customer) => documents(no, customer))
|
||||||
|
vi.mocked(saveDeliveryDocuments).mockResolvedValue({ ...documents(), version: 2 })
|
||||||
|
})
|
||||||
|
it('reads existing delivery files in one dialog and saves receipt without requiring loading or BOL files', async () => {
|
||||||
|
const wrapper = setup(); await flushPromises()
|
||||||
|
expect(getDeliveryDocuments).toHaveBeenCalledWith('DL-1', '101')
|
||||||
|
expect(wrapper.text()).toContain('existing-pod.pdf')
|
||||||
|
expect(wrapper.findAllComponents(DeliveryDocumentsDialog)).toHaveLength(1)
|
||||||
|
await wrapper.get('.save').trigger('click'); await flushPromises()
|
||||||
|
expect(saveDeliveryDocuments).toHaveBeenCalledWith('DL-1', '101', expect.objectContaining({ bolNo: 'BOL-ORIGINAL', documents: [{ category: 'LOADING_PHOTO', fileIds: [] }, { category: 'BOL', fileIds: [] }, { category: 'RECEIPT', fileIds: ['99'] }] }))
|
||||||
|
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||||
|
expect(wrapper.getComponent(DeliveryDocumentsDialog).props('modelValue')).toBe(true)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('switches customer scope exactly and keeps unsaved delivery edits when switching tabs', async () => {
|
||||||
|
const wrapper = setup(); await flushPromises()
|
||||||
|
await wrapper.findAll('[role="tab"]').find(tab => tab.text() === '装车资料')!.trigger('click')
|
||||||
|
await wrapper.getComponent(DeliveryDocumentsDialog).find('input').setValue('BOL-CHANGED')
|
||||||
|
await wrapper.findAll('[role="tab"]').find(tab => tab.text() === '油耗图片')!.trigger('click')
|
||||||
|
expect(getDeliveryDocuments).toHaveBeenCalledTimes(1)
|
||||||
|
const confirmation = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValue('cancel')
|
||||||
|
wrapper.getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'DL-2'); await flushPromises()
|
||||||
|
expect(getDeliveryDocuments).toHaveBeenCalledTimes(1)
|
||||||
|
confirmation.mockResolvedValue('confirm' as Awaited<ReturnType<typeof ElMessageBox.confirm>>)
|
||||||
|
wrapper.getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'DL-2'); await flushPromises()
|
||||||
|
expect(getDeliveryDocuments).toHaveBeenLastCalledWith('DL-2', '102')
|
||||||
|
expect(saveDeliveryDocuments).not.toHaveBeenCalled()
|
||||||
|
confirmation.mockRestore(); wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('does not request delivery documents without delivery list permission', async () => {
|
||||||
|
can.mockImplementation((value?: string) => value !== 'platform:overseas-warehouse:delivery:list')
|
||||||
|
const wrapper = setup(); await flushPromises()
|
||||||
|
expect(getDeliveryDocuments).not.toHaveBeenCalled()
|
||||||
|
expect(wrapper.text()).toContain('当前账号无派送资料查看权限')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import '@/styles/adminDensity.css'
|
import '@/styles/adminDensity.css'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import { ElMessageBox } from 'element-plus'
|
import { ElMessageBox } from 'element-plus'
|
||||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
@@ -13,6 +13,7 @@ import type { DispatchDetail } from '../dispatchContracts'
|
|||||||
import { dispatchReviewAttachmentFields, dispatchReviewEvidence } from '../dispatchPresentation'
|
import { dispatchReviewAttachmentFields, dispatchReviewEvidence } from '../dispatchPresentation'
|
||||||
import DeliveryDocumentsDialog from './DeliveryDocumentsDialog.vue'
|
import DeliveryDocumentsDialog from './DeliveryDocumentsDialog.vue'
|
||||||
import DispatchVehicleDocuments from './DispatchVehicleDocuments.vue'
|
import DispatchVehicleDocuments from './DispatchVehicleDocuments.vue'
|
||||||
|
import DispatchLoadingDocuments from './DispatchLoadingDocuments.vue'
|
||||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||||
|
|
||||||
const open = defineModel<boolean>({ default: false })
|
const open = defineModel<boolean>({ default: false })
|
||||||
@@ -22,18 +23,21 @@ const access = useAccessPreviewStore()
|
|||||||
const detail = ref<DispatchDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
const detail = ref<DispatchDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||||
const tab = ref('RECEIPT'), selected = ref(''), fuelVisited = ref(false)
|
const tab = ref('RECEIPT'), selected = ref(''), fuelVisited = ref(false)
|
||||||
const editor = ref<InstanceType<typeof DeliveryDocumentsDialog>>(), vehicleEditor = ref<InstanceType<typeof DispatchVehicleDocuments>>()
|
const editor = ref<InstanceType<typeof DeliveryDocumentsDialog>>(), vehicleEditor = ref<InstanceType<typeof DispatchVehicleDocuments>>()
|
||||||
|
const loadingEditor = ref<InstanceType<typeof DispatchLoadingDocuments>>()
|
||||||
const deliveries = computed(() => detail.value?.documents.deliveries || [])
|
const deliveries = computed(() => detail.value?.documents.deliveries || [])
|
||||||
const delivery = computed(() => deliveries.value.find(item => item.deliveryNo === selected.value))
|
const delivery = computed(() => deliveries.value.find(item => item.deliveryNo === selected.value))
|
||||||
const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value))
|
const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value))
|
||||||
const busy = computed(() => Boolean(editor.value?.busy || vehicleEditor.value?.busy))
|
const busy = computed(() => Boolean(editor.value?.busy || vehicleEditor.value?.busy || loadingEditor.value?.busy))
|
||||||
let sequence = 0
|
let sequence = 0
|
||||||
|
onBeforeUnmount(() => { sequence++ })
|
||||||
async function load() {
|
async function load() {
|
||||||
const current = ++sequence; loading.value = true; error.value = null
|
const current = ++sequence; loading.value = true; error.value = null
|
||||||
try {
|
try {
|
||||||
const value = await getDispatch(props.dispatchNo)
|
const value = await getDispatch(props.dispatchNo)
|
||||||
if (current !== sequence || !open.value) return
|
if (current !== sequence || !open.value) return
|
||||||
if (value.dispatchNo !== props.dispatchNo) throw new Error('资料与当前排车单不一致')
|
if (value.dispatchNo !== props.dispatchNo) throw new Error('资料与当前排车单不一致')
|
||||||
if (value.status !== 'ACTIVE' || !['DELIVERING', 'DELIVERED'].includes(value.stage)) throw new Error('仅派送中和配送完成可管理资料')
|
if (value.status !== 'ACTIVE' || !['AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(value.stage)) throw new Error('仅装车后、派送中和配送完成可管理资料')
|
||||||
|
if (!detail.value && value.stage === 'AFTER_LOADING') tab.value = 'LOADING'
|
||||||
detail.value = value
|
detail.value = value
|
||||||
if (!value.documents.deliveries.some(item => item.deliveryNo === selected.value)) selected.value = value.documents.deliveries[0]?.deliveryNo || ''
|
if (!value.documents.deliveries.some(item => item.deliveryNo === selected.value)) selected.value = value.documents.deliveries[0]?.deliveryNo || ''
|
||||||
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '排车资料读取失败') }
|
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '排车资料读取失败') }
|
||||||
@@ -48,6 +52,7 @@ async function selectDelivery(value: string) { if (value !== selected.value && a
|
|||||||
async function beforeClose(done: () => void) { if (await canLeave()) done() }
|
async function beforeClose(done: () => void) { if (await canLeave()) done() }
|
||||||
function selectTab(value: string) { if (!busy.value) { tab.value = value; if (value === 'FUEL') fuelVisited.value = true } }
|
function selectTab(value: string) { if (!busy.value) { tab.value = value; if (value === 'FUEL') fuelVisited.value = true } }
|
||||||
function saved() { emit('saved') }
|
function saved() { emit('saved') }
|
||||||
|
async function loadingSaved() { emit('saved'); await load() }
|
||||||
watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
|
watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
|
||||||
sequence++; detail.value = null; selected.value = ''; tab.value = 'RECEIPT'; fuelVisited.value = false; error.value = null
|
sequence++; detail.value = null; selected.value = ''; tab.value = 'RECEIPT'; fuelVisited.value = false; error.value = null
|
||||||
if (visible && props.dispatchNo) void load()
|
if (visible && props.dispatchNo) void load()
|
||||||
@@ -58,12 +63,14 @@ watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
|
|||||||
<LoadErrorState v-if="error" compact title="排车资料读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
<LoadErrorState v-if="error" compact title="排车资料读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||||
<template v-if="detail">
|
<template v-if="detail">
|
||||||
<div class="density-status-tabs" role="tablist" aria-label="排车资料类别"><button v-for="item in [{ key: 'RECEIPT', label: '签收文件' }, { key: 'FUEL', label: '油耗图片' }, { key: 'LOADING', label: '装车资料' }]" :key="item.key" role="tab" :aria-selected="tab === item.key" :disabled="busy" :class="{ 'is-active': tab === item.key }" @click="selectTab(item.key)">{{ item.label }}</button></div>
|
<div class="density-status-tabs" role="tablist" aria-label="排车资料类别"><button v-for="item in [{ key: 'RECEIPT', label: '签收文件' }, { key: 'FUEL', label: '油耗图片' }, { key: 'LOADING', label: '装车资料' }]" :key="item.key" role="tab" :aria-selected="tab === item.key" :disabled="busy" :class="{ 'is-active': tab === item.key }" @click="selectTab(item.key)">{{ item.label }}</button></div>
|
||||||
<div v-show="tab !== 'FUEL'">
|
<DispatchLoadingDocuments v-if="tab === 'LOADING'" ref="loadingEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @saved="loadingSaved" />
|
||||||
|
<div v-show="tab !== 'FUEL'" v-if="detail.stage !== 'AFTER_LOADING'">
|
||||||
<el-form class="density-form" label-position="left" label-width="80px"><el-form-item label="派送单"><el-select :model-value="selected" :disabled="busy || !access.can(permission.deliveryList)" aria-label="选择派送单" @update:model-value="selectDelivery"><el-option v-for="item in deliveries" :key="item.deliveryNo" :value="item.deliveryNo" :label="item.deliveryNo" /></el-select></el-form-item></el-form>
|
<el-form class="density-form" label-position="left" label-width="80px"><el-form-item label="派送单"><el-select :model-value="selected" :disabled="busy || !access.can(permission.deliveryList)" aria-label="选择派送单" @update:model-value="selectDelivery"><el-option v-for="item in deliveries" :key="item.deliveryNo" :value="item.deliveryNo" :label="item.deliveryNo" /></el-select></el-form-item></el-form>
|
||||||
<DeliveryDocumentsDialog v-if="delivery && access.can(permission.deliveryList)" ref="editor" :key="delivery.customerId + ':' + delivery.deliveryNo" :model-value="open" embedded :delivery-no="delivery.deliveryNo" :customer-id="delivery.customerId" :categories="tab === 'RECEIPT' ? ['RECEIPT'] : ['LOADING_PHOTO', 'BOL']" @saved="saved" />
|
<DeliveryDocumentsDialog v-if="delivery && access.can(permission.deliveryList)" ref="editor" :key="delivery.customerId + ':' + delivery.deliveryNo" :model-value="open" embedded :delivery-no="delivery.deliveryNo" :customer-id="delivery.customerId" :categories="tab === 'RECEIPT' ? ['RECEIPT'] : ['BOL']" @saved="saved" />
|
||||||
<p v-else>{{ access.can(permission.deliveryList) ? '暂无关联派送单' : '当前账号无派送资料查看权限' }}</p>
|
<p v-else>{{ access.can(permission.deliveryList) ? '暂无关联派送单' : '当前账号无派送资料查看权限' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<DispatchVehicleDocuments v-if="fuelVisited" v-show="tab === 'FUEL'" ref="vehicleEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @saved="saved" />
|
<p v-if="detail.stage === 'AFTER_LOADING' && tab !== 'LOADING'">请先补齐装车资料,进入派送中后管理{{ tab === 'FUEL' ? '油耗图片' : '签收文件' }}。</p>
|
||||||
|
<DispatchVehicleDocuments v-if="fuelVisited && detail.stage !== 'AFTER_LOADING'" v-show="tab === 'FUEL'" ref="vehicleEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @saved="saved" />
|
||||||
<section v-if="tab === 'LOADING'" class="density-dialog-section"><h3 class="density-dialog-section__title">车辆及司机附件</h3><DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" :model-value="reviewEvidence[field.key]" :dispatch-no="dispatchNo" :kind="field.kind" :label="field.label" readonly /></section>
|
<section v-if="tab === 'LOADING'" class="density-dialog-section"><h3 class="density-dialog-section__title">车辆及司机附件</h3><DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" :model-value="reviewEvidence[field.key]" :dispatch-no="dispatchNo" :kind="field.kind" :label="field.label" readonly /></section>
|
||||||
</template>
|
</template>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useAuthStore } from '@/core/auth/auth'
|
||||||
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
|
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||||
|
import { bindLoadingPhoto, getLoadingDocuments, loadingPhotoSlots, readLoadingPhoto, recoverLoadingPhoto, uploadLoadingPhoto, type BindLoadingPhoto, type LoadingDocuments, type LoadingPhoto, type LoadingPhotoSlot } from '../api/dispatchLoadingDocuments'
|
||||||
|
|
||||||
|
const props = defineProps<{ dispatchNo: string }>()
|
||||||
|
const emit = defineEmits<{ saved: [] }>()
|
||||||
|
const auth = useAuthStore(), access = useAccessPreviewStore()
|
||||||
|
const snapshot = ref<LoadingDocuments | null>(null), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||||
|
interface Pending { slot: LoadingPhotoSlot; key: string; photo?: LoadingPhoto; binding?: BindLoadingPhoto; conflict?: boolean }
|
||||||
|
const pending = ref<Pending | null>(null), corrupted = ref(false), images = ref<Record<string, string>>({})
|
||||||
|
const actor = auth.user?.id || '', number = props.dispatchNo, storageKey = `dispatch-loading-documents:${actor}:${number}`
|
||||||
|
const editable = computed(() => !corrupted.value && access.can(permission.dispatchEdit) && snapshot.value?.editable === true)
|
||||||
|
let active = true
|
||||||
|
function ensureScope() { if (!active || auth.user?.id !== actor || props.dispatchNo !== number) throw new Error('资料操作身份已变化,请重新打开') }
|
||||||
|
function persist() { if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey) }
|
||||||
|
function accept(value: LoadingDocuments) { ensureScope(); if (value.dispatchNo !== number) throw new Error('装车资料与排车单不一致'); snapshot.value = value }
|
||||||
|
async function previews() {
|
||||||
|
const photos = snapshot.value?.photos || []
|
||||||
|
await Promise.allSettled(photos.map(async photo => {
|
||||||
|
if (images.value[photo.fileId]) return
|
||||||
|
const blob = await readLoadingPhoto(number, photo); ensureScope(); images.value[photo.fileId] = URL.createObjectURL(blob)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
async function load() {
|
||||||
|
busy.value = true; error.value = null
|
||||||
|
try { accept(await getLoadingDocuments(number)); await previews() }
|
||||||
|
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '装车资料读取失败') }
|
||||||
|
finally { if (active) busy.value = false }
|
||||||
|
}
|
||||||
|
async function bind() {
|
||||||
|
const item = pending.value
|
||||||
|
if (!item?.photo || !snapshot.value) return
|
||||||
|
if (item.photo.slot !== item.slot) throw new Error('上传结果与装车照片框不一致')
|
||||||
|
if (!item.binding) { item.binding = { recordVersion: snapshot.value.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: item.photo.fileId }; persist() }
|
||||||
|
try {
|
||||||
|
const result = await bindLoadingPhoto(number, item.slot, item.binding)
|
||||||
|
accept(result); pending.value = null; persist(); emit('saved')
|
||||||
|
ElMessage.success(result.stage === 'DELIVERING' && result.complete ? '装车资料已齐全,本车已进入派送中' : '装车照片已保存')
|
||||||
|
await previews()
|
||||||
|
} catch (cause) {
|
||||||
|
const failure = getApiErrorDetails(cause, '装车照片保存失败')
|
||||||
|
if (pending.value && /^(?:A0|HTTP_)?409$/.test(String(failure.code))) { pending.value.conflict = true; persist() }
|
||||||
|
throw cause
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function recover() {
|
||||||
|
if (busy.value || !editable.value || !pending.value) return
|
||||||
|
busy.value = true; error.value = null
|
||||||
|
try {
|
||||||
|
ensureScope()
|
||||||
|
if (pending.value.conflict) { accept(await getLoadingDocuments(number)); pending.value.binding = undefined; pending.value.conflict = false; persist() }
|
||||||
|
if (!pending.value.photo) { pending.value.photo = await recoverLoadingPhoto(number, pending.value.key); ensureScope(); persist() }
|
||||||
|
await bind()
|
||||||
|
} catch (cause) { if (active) error.value = getApiErrorDetails(cause, '原上传或保存结果尚未确认') }
|
||||||
|
finally { if (active) busy.value = false }
|
||||||
|
}
|
||||||
|
async function upload(slot: LoadingPhotoSlot, event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
|
||||||
|
if (!file || busy.value || pending.value || !editable.value) return
|
||||||
|
if (!['image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) { ElMessage.warning('请选择不超过10MiB的JPG或PNG图片'); return }
|
||||||
|
busy.value = true; error.value = null
|
||||||
|
try {
|
||||||
|
ensureScope(); pending.value = { slot, key: crypto.randomUUID() }; persist()
|
||||||
|
pending.value.photo = await uploadLoadingPhoto(number, slot, file, pending.value.key); ensureScope(); persist(); await bind()
|
||||||
|
} catch (cause) { if (active) error.value = getApiErrorDetails(cause, '装车照片上传或保存失败,请核对原请求') }
|
||||||
|
finally { if (active) busy.value = false }
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(storageKey)
|
||||||
|
if (raw) { const value = JSON.parse(raw) as Pending; if (!loadingPhotoSlots.includes(value.slot) || typeof value.key !== 'string' || !value.key) throw new Error('原装车照片请求记录无法读取'); pending.value = value }
|
||||||
|
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '原装车照片请求记录无法读取') }
|
||||||
|
if (!corrupted.value) void load()
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => { active = false; Object.values(images.value).forEach(URL.revokeObjectURL) })
|
||||||
|
defineExpose({ busy })
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div v-loading="busy" class="density-dialog-sections">
|
||||||
|
<p v-if="snapshot?.stage === 'AFTER_LOADING'">请上传 5 张装车照片,全部保存成功后自动进入派送中。</p>
|
||||||
|
<LoadErrorState v-if="error" compact title="装车资料操作失败" :message="error.message" :show-retry="!pending && !corrupted" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||||
|
<div v-if="pending" role="status">有装车照片尚未确认保存。<el-button :disabled="busy || !editable" @click="recover">{{ pending.conflict ? '刷新并重试保存' : '核对原请求并继续保存' }}</el-button></div>
|
||||||
|
<div class="loading-photo-grid">
|
||||||
|
<section v-for="(slot, index) in loadingPhotoSlots" :key="slot" class="density-dialog-section loading-photo-card">
|
||||||
|
<h3 class="density-dialog-section__title">装车照片 {{ index + 1 }}</h3>
|
||||||
|
<template v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">
|
||||||
|
<el-image v-if="images[photo.fileId]" :src="images[photo.fileId]" :preview-src-list="[images[photo.fileId]!]" fit="contain" class="loading-photo-image" preview-teleported />
|
||||||
|
<el-button v-else link :disabled="busy" @click="load">图片读取失败,点击重试</el-button>
|
||||||
|
</template>
|
||||||
|
<div v-if="!snapshot?.photos.some(item => item.slot === slot)" class="loading-photo-empty">未上传</div>
|
||||||
|
<label v-if="editable">上传/替换图片<input type="file" accept="image/jpeg,image/png" :aria-label="`装车照片 ${index + 1}`" :disabled="busy || Boolean(pending)" @change="upload(slot, $event)" /></label>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.loading-photo-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-4); }
|
||||||
|
.loading-photo-image, .loading-photo-empty { width: 100%; height: 150px; margin-bottom: var(--admin-space-3); }
|
||||||
|
.loading-photo-empty { display: grid; place-items: center; background: var(--cc-bg-soft, #f3f7ff); color: var(--cc-text-secondary, #64748b); }
|
||||||
|
.loading-photo-card input { display: block; margin-top: var(--admin-space-2); max-width: 100%; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { beforeEach, expect, it, vi } from 'vitest'
|
||||||
|
import { ApiResponseError } from '@/shared/api/http'
|
||||||
|
import DispatchVehicleDocuments from './DispatchVehicleDocuments.vue'
|
||||||
|
import { bindVehiclePhoto, getVehicleDocuments, recoverVehiclePhoto, uploadVehiclePhoto, type VehicleDocuments, type VehiclePhoto } from '../api/dispatchVehicleDocuments'
|
||||||
|
const can = vi.hoisted(() => vi.fn(() => true))
|
||||||
|
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: { id: 'operator' } }) }))
|
||||||
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||||
|
vi.mock('../api/dispatchVehicleDocuments', () => ({ bindVehiclePhoto: vi.fn(), getVehicleDocuments: vi.fn(), recoverVehiclePhoto: vi.fn(), uploadVehiclePhoto: vi.fn(), readVehiclePhoto: vi.fn() }))
|
||||||
|
const photo: VehiclePhoto = { slot: 'FUEL_START', fileId: '9007199254740995', fileName: 'fuel.png', contentType: 'image/png', savedAt: null }
|
||||||
|
function response(version = '3'): VehicleDocuments { return { dispatchNo: 'PC/1', recordVersion: version, editable: true, photos: [] } }
|
||||||
|
function setup() { return mount(DispatchVehicleDocuments, { props: { dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus] } }) }
|
||||||
|
async function upload(wrapper: ReturnType<typeof setup>) { const input = wrapper.get('input'); Object.defineProperty(input.element, 'files', { value: [new File(['image'], 'fuel.png', { type: 'image/png' })], configurable: true }); await input.trigger('change'); await flushPromises() }
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks(); sessionStorage.clear(); can.mockReturnValue(true)
|
||||||
|
vi.mocked(getVehicleDocuments).mockResolvedValue(response())
|
||||||
|
vi.mocked(uploadVehiclePhoto).mockResolvedValue(photo)
|
||||||
|
vi.mocked(recoverVehiclePhoto).mockResolvedValue(photo)
|
||||||
|
vi.mocked(bindVehiclePhoto).mockResolvedValue({ ...response('4'), photos: [{ ...photo, savedAt: '2026-10-21T00:00:00Z' }] })
|
||||||
|
})
|
||||||
|
it('binds the uploaded file to the car and restores the original binding after reopening without uploading twice', async () => {
|
||||||
|
vi.mocked(bindVehiclePhoto).mockRejectedValueOnce(new Error('network timeout'))
|
||||||
|
const first = setup(); await flushPromises(); await upload(first)
|
||||||
|
const original = vi.mocked(bindVehiclePhoto).mock.calls[0]![2]
|
||||||
|
expect(original).toMatchObject({ recordVersion: '3', fileId: photo.fileId })
|
||||||
|
first.unmount()
|
||||||
|
const reopened = setup(); await flushPromises()
|
||||||
|
await reopened.findAll('button').find(button => button.text() === '核对原请求并继续保存')!.trigger('click'); await flushPromises()
|
||||||
|
expect(bindVehiclePhoto).toHaveBeenLastCalledWith('PC/1', 'FUEL_START', original)
|
||||||
|
expect(uploadVehiclePhoto).toHaveBeenCalledTimes(1)
|
||||||
|
expect(reopened.text()).toContain('fuel.png')
|
||||||
|
expect(sessionStorage.length).toBe(0)
|
||||||
|
reopened.unmount()
|
||||||
|
})
|
||||||
|
it('keeps uncertain uploads for original-key recovery and never resubmits the file', async () => {
|
||||||
|
vi.mocked(uploadVehiclePhoto).mockRejectedValueOnce(new Error('connection lost'))
|
||||||
|
const wrapper = setup(); await flushPromises(); await upload(wrapper)
|
||||||
|
const originalKey = vi.mocked(uploadVehiclePhoto).mock.calls[0]![3]
|
||||||
|
await wrapper.findAll('button').find(button => button.text() === '核对原请求并继续保存')!.trigger('click'); await flushPromises()
|
||||||
|
expect(recoverVehiclePhoto).toHaveBeenCalledWith('PC/1', originalKey)
|
||||||
|
expect(uploadVehiclePhoto).toHaveBeenCalledTimes(1)
|
||||||
|
expect(bindVehiclePhoto).toHaveBeenCalledTimes(1)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('refreshes only a known version conflict and reuses the uploaded file', async () => {
|
||||||
|
vi.mocked(bindVehiclePhoto).mockRejectedValueOnce(new ApiResponseError('运营记录版本已变化,请刷新后重试', 'A0409', 409))
|
||||||
|
const wrapper = setup(); await flushPromises(); await upload(wrapper)
|
||||||
|
const originalKey = vi.mocked(bindVehiclePhoto).mock.calls[0]![2].idempotencyKey
|
||||||
|
vi.mocked(getVehicleDocuments).mockResolvedValue(response('8'))
|
||||||
|
await wrapper.findAll('button').find(button => button.text() === '刷新版本并保存已上传图片')!.trigger('click'); await flushPromises()
|
||||||
|
expect(bindVehiclePhoto).toHaveBeenLastCalledWith('PC/1', 'FUEL_START', { recordVersion: '8', fileId: photo.fileId, idempotencyKey: expect.any(String) })
|
||||||
|
expect(vi.mocked(bindVehiclePhoto).mock.calls[1]![2].idempotencyKey).not.toBe(originalKey)
|
||||||
|
expect(uploadVehiclePhoto).toHaveBeenCalledTimes(1)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('does not offer upload with list permission alone', async () => {
|
||||||
|
can.mockReturnValue(false)
|
||||||
|
const wrapper = setup(); await flushPromises()
|
||||||
|
expect(wrapper.find('input[type="file"]').exists()).toBe(false)
|
||||||
|
expect(uploadVehiclePhoto).not.toHaveBeenCalled()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
@@ -13,7 +13,7 @@ const props = defineProps<{ dispatchNo: string }>()
|
|||||||
const auth = useAuthStore(), access = useAccessPreviewStore()
|
const auth = useAuthStore(), access = useAccessPreviewStore()
|
||||||
const emit = defineEmits<{ saved: [] }>()
|
const emit = defineEmits<{ saved: [] }>()
|
||||||
const snapshot = ref<VehicleDocuments | null>(null), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
const snapshot = ref<VehicleDocuments | null>(null), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||||
interface Pending { slot: FuelSlot; key: string; photo?: VehiclePhoto; binding?: BindVehiclePhoto }
|
interface Pending { slot: FuelSlot; key: string; photo?: VehiclePhoto; binding?: BindVehiclePhoto; versionConflict?: boolean }
|
||||||
const pending = ref<Pending | null>(null), preview = ref(''), corrupted = ref(false)
|
const pending = ref<Pending | null>(null), preview = ref(''), corrupted = ref(false)
|
||||||
const actor = auth.user?.id || '', number = props.dispatchNo
|
const actor = auth.user?.id || '', number = props.dispatchNo
|
||||||
const storageKey = `dispatch-vehicle-documents:${actor}:${number}`
|
const storageKey = `dispatch-vehicle-documents:${actor}:${number}`
|
||||||
@@ -33,7 +33,13 @@ async function bind() {
|
|||||||
if (!item?.photo || !snapshot.value) return
|
if (!item?.photo || !snapshot.value) return
|
||||||
if (item.photo.slot !== item.slot) throw new Error('上传结果与图片分类不一致')
|
if (item.photo.slot !== item.slot) throw new Error('上传结果与图片分类不一致')
|
||||||
if (!item.binding) { item.binding = { recordVersion: snapshot.value.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: item.photo.fileId }; persist() }
|
if (!item.binding) { item.binding = { recordVersion: snapshot.value.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: item.photo.fileId }; persist() }
|
||||||
const result = await bindVehiclePhoto(number, item.slot, item.binding)
|
let result: VehicleDocuments
|
||||||
|
try { result = await bindVehiclePhoto(number, item.slot, item.binding) }
|
||||||
|
catch (cause) {
|
||||||
|
const details = getApiErrorDetails(cause, '图片保存失败')
|
||||||
|
if (details.code === 'A0409' && /记录版本已变化|记录版本竞争失败/.test(details.message)) { item.versionConflict = true; persist() }
|
||||||
|
throw cause
|
||||||
|
}
|
||||||
ensureScope(); snapshot.value = result; pending.value = null; persist(); emit('saved'); ElMessage.success('油耗图片已保存')
|
ensureScope(); snapshot.value = result; pending.value = null; persist(); emit('saved'); ElMessage.success('油耗图片已保存')
|
||||||
}
|
}
|
||||||
async function recover() {
|
async function recover() {
|
||||||
@@ -43,6 +49,13 @@ async function recover() {
|
|||||||
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '原上传或保存结果尚未确认') }
|
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '原上传或保存结果尚未确认') }
|
||||||
finally { if (active) busy.value = false }
|
finally { if (active) busy.value = false }
|
||||||
}
|
}
|
||||||
|
async function refreshBinding() {
|
||||||
|
if (busy.value || !editable.value || !pending.value?.versionConflict) return
|
||||||
|
await load()
|
||||||
|
if (error.value || !pending.value) return
|
||||||
|
pending.value.binding = undefined; pending.value.versionConflict = false; persist()
|
||||||
|
await recover()
|
||||||
|
}
|
||||||
async function upload(slot: FuelSlot, event: Event) {
|
async function upload(slot: FuelSlot, event: Event) {
|
||||||
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
|
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
|
||||||
if (!file || busy.value || pending.value || !editable.value) return
|
if (!file || busy.value || pending.value || !editable.value) return
|
||||||
@@ -51,7 +64,12 @@ async function upload(slot: FuelSlot, event: Event) {
|
|||||||
try {
|
try {
|
||||||
ensureScope(); pending.value = { slot, key: crypto.randomUUID() }; persist()
|
ensureScope(); pending.value = { slot, key: crypto.randomUUID() }; persist()
|
||||||
pending.value.photo = await uploadVehiclePhoto(number, slot, file, pending.value.key); ensureScope(); persist(); await bind()
|
pending.value.photo = await uploadVehiclePhoto(number, slot, file, pending.value.key); ensureScope(); persist(); await bind()
|
||||||
} catch (cause) { if (active) error.value = getApiErrorDetails(cause, '图片上传或保存失败,请核对原请求') }
|
} catch (cause) {
|
||||||
|
if (active) {
|
||||||
|
error.value = getApiErrorDetails(cause, '图片上传或保存失败,请核对原请求')
|
||||||
|
if (!pending.value?.photo && ['A0400', 'A0403'].includes(error.value.code || '')) { pending.value = null; persist() }
|
||||||
|
}
|
||||||
|
}
|
||||||
finally { if (active) busy.value = false }
|
finally { if (active) busy.value = false }
|
||||||
}
|
}
|
||||||
async function view(photo: VehiclePhoto) {
|
async function view(photo: VehiclePhoto) {
|
||||||
@@ -74,7 +92,7 @@ defineExpose({ busy })
|
|||||||
<template>
|
<template>
|
||||||
<div v-loading="busy" class="density-dialog-sections">
|
<div v-loading="busy" class="density-dialog-sections">
|
||||||
<LoadErrorState v-if="error" compact title="车辆资料操作失败" :message="error.message" :show-retry="!pending && !corrupted" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
<LoadErrorState v-if="error" compact title="车辆资料操作失败" :message="error.message" :show-retry="!pending && !corrupted" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||||
<div v-if="pending" role="status">有图片尚未确认保存。<el-button :disabled="busy || !editable" @click="recover">核对原请求并继续保存</el-button></div>
|
<div v-if="pending" role="status">有图片尚未确认保存。<el-button v-if="pending.versionConflict" :disabled="busy || !editable" @click="refreshBinding">刷新版本并保存已上传图片</el-button><el-button v-else :disabled="busy || !editable" @click="recover">核对原请求并继续保存</el-button></div>
|
||||||
<section v-for="role in roles" :key="role.slot" class="density-dialog-section">
|
<section v-for="role in roles" :key="role.slot" class="density-dialog-section">
|
||||||
<h3 class="density-dialog-section__title">{{ role.label }}</h3>
|
<h3 class="density-dialog-section__title">{{ role.label }}</h3>
|
||||||
<div v-for="photo in snapshot?.photos.filter(item => item.slot === role.slot) || []" :key="photo.fileId"><el-button link :disabled="busy" @click="view(photo)">{{ photo.fileName }}</el-button></div>
|
<div v-for="photo in snapshot?.photos.filter(item => item.slot === role.slot) || []" :key="photo.fileId"><el-button link :disabled="busy" @click="view(photo)">{{ photo.fileName }}</el-button></div>
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ async function submit() {
|
|||||||
const reviewReturned = ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(pending.value?.action || '') && pending.value?.body.decision === 'REJECTED'
|
const reviewReturned = ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(pending.value?.action || '') && pending.value?.body.decision === 'REJECTED'
|
||||||
const result = await recovery.execute(send, api.getDispatch, api.getDispatchCommand)
|
const result = await recovery.execute(send, api.getDispatch, api.getDispatchCommand)
|
||||||
if (!result) return
|
if (!result) return
|
||||||
ElMessage.success(reviewReturned ? '已退回客服处理(车辆信息),修改后需重新提交调度审核' : result.action === 'LOADING_COMPLETE' ? '装车完成,已进入派送中' : routeCommand ? '卸货顺序调整已确认,请重新核对两级审核' : result.alreadyArranged ? '已引用本车原派送安排,未重复生成单据和费用' : result.replayed ? '原操作结果已核对' : `${dispatchActionLabels[result.action]}已确认`)
|
ElMessage.success(reviewReturned ? '已退回客服处理(车辆信息),修改后需重新提交调度审核' : result.action === 'LOADING_COMPLETE' ? '装车完成,请在装车后补齐装车资料' : routeCommand ? '卸货顺序调整已确认,请重新核对两级审核' : result.alreadyArranged ? '已引用本车原派送安排,未重复生成单据和费用' : result.replayed ? '原操作结果已核对' : `${dispatchActionLabels[result.action]}已确认`)
|
||||||
emit('saved'); open.value = false
|
emit('saved'); open.value = false
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : pending.value?.result ? '操作已完成,页面资料待刷新' : pending.value ? '操作结果尚未确认,请保留原请求核对' : '本次操作未通过,请核对后再提交')
|
error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : pending.value?.result ? '操作已完成,页面资料待刷新' : pending.value ? '操作结果尚未确认,请保留原请求核对' : '本次操作未通过,请核对后再提交')
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function validateCommandResult(result: CommandResult, pending: PendingDis
|
|||||||
|| result.action !== pending.action || result.idempotencyKey !== pending.body.idempotencyKey
|
|| result.action !== pending.action || result.idempotencyKey !== pending.body.idempotencyKey
|
||||||
|| !result.dispatchNo || !result.result) throw new Error('操作结果与原排车请求不一致,请保留原请求核对')
|
|| !result.dispatchNo || !result.result) throw new Error('操作结果与原排车请求不一致,请保留原请求核对')
|
||||||
requireVersion(result.recordVersion)
|
requireVersion(result.recordVersion)
|
||||||
if (pending.action === 'LOADING_COMPLETE' && result.stage !== 'DELIVERING') throw new Error('装车结果未确认派送中,请核对原请求')
|
if (pending.action === 'LOADING_COMPLETE' && result.stage !== 'AFTER_LOADING') throw new Error('装车结果未确认装车后,请核对原请求')
|
||||||
}
|
}
|
||||||
export function validateCurrentDispatch(value: unknown, result: CommandResult) {
|
export function validateCurrentDispatch(value: unknown, result: CommandResult) {
|
||||||
const current = value as { dispatchNo?: string; recordVersion?: unknown } | null
|
const current = value as { dispatchNo?: string; recordVersion?: unknown } | null
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export type CommandResult = CommandBase & (
|
|||||||
|
|
||||||
export const dispatchStages: Array<{ value: DispatchStage; label: string }> = [
|
export const dispatchStages: Array<{ value: DispatchStage; label: string }> = [
|
||||||
{ value: 'BEFORE_LOADING', label: '装车前' }, { value: 'LOADING', label: '装车中' },
|
{ value: 'BEFORE_LOADING', label: '装车前' }, { value: 'LOADING', label: '装车中' },
|
||||||
|
{ value: 'AFTER_LOADING', label: '装车后' },
|
||||||
{ value: 'DELIVERING', label: '派送中' }, { value: 'DELIVERED', label: '派送完成' },
|
{ value: 'DELIVERING', label: '派送中' }, { value: 'DELIVERED', label: '派送完成' },
|
||||||
]
|
]
|
||||||
export const stageLabel = (stage: DispatchStage) => dispatchStages.find(row => row.value === stage)?.label || '—'
|
export const stageLabel = (stage: DispatchStage) => dispatchStages.find(row => row.value === stage)?.label || '—'
|
||||||
|
|||||||
@@ -19,6 +19,28 @@ vi.mock('@/modules/overseas-master-data', async () => ({
|
|||||||
const options = { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub, DispatchWorkflowDialog: true, TransportExecutionPanel: true } } }
|
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]) })
|
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', () => {
|
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 () => {
|
it('shows saved review attachments beside resource evidence without providing upload controls', async () => {
|
||||||
const value = detail(); value.vehicleInfo.vehicle!.evidenceFileIds = ['301']
|
const value = detail(); value.vehicleInfo.vehicle!.evidenceFileIds = ['301']
|
||||||
value.reviews.dispatchReview.attachments = { vehicleFileIds: ['301', '302'], driverPhotoFileIds: ['303'], licenseFileIds: [] }
|
value.reviews.dispatchReview.attachments = { vehicleFileIds: ['301', '302'], driverPhotoFileIds: ['303'], licenseFileIds: [] }
|
||||||
|
|||||||
@@ -88,7 +88,9 @@ onBeforeUnmount(() => { generation++ })
|
|||||||
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理(车辆信息),请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
|
<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 density-contained-tabs--flush">
|
<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="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><section v-if="detail && detail.dispatchInfo.exceptions.length" class="detail-section" aria-label="派送异常记录">
|
<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>
|
<h3>派送异常记录</h3>
|
||||||
<article v-for="(record, index) in detail.dispatchInfo.exceptions" :key="index">
|
<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>
|
<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>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import ElementPlus from 'element-plus'
|
import ElementPlus, { ElSelect, ElTag } from 'element-plus'
|
||||||
import { flushPromises, mount } from '@vue/test-utils'
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
import { beforeEach, expect, it, vi } from 'vitest'
|
import { beforeEach, expect, it, vi } from 'vitest'
|
||||||
import DispatchesView from './DispatchesView.vue'
|
import DispatchesView from './DispatchesView.vue'
|
||||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||||
|
import DataTable from '@/shared/components/DataTable.vue'
|
||||||
import RowActions from '@/shared/components/RowActions.vue'
|
import RowActions from '@/shared/components/RowActions.vue'
|
||||||
import { getDispatch, getDispatchBolPdf, listDispatches } from '../api/dispatches'
|
import { getDispatch, getDispatchBolPdf, listDispatches } from '../api/dispatches'
|
||||||
import { command, detail, listRow, page } from '../dispatchTestFixtures'
|
import { command, detail, listRow, page } from '../dispatchTestFixtures'
|
||||||
@@ -110,6 +111,117 @@ it('shows four working phases and uses server stage counts instead of current pa
|
|||||||
await tabs[2]!.trigger('click'); await flushPromises()
|
await tabs[2]!.trigger('click'); await flushPromises()
|
||||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'DELIVERING' })); expect(wrapper.find('[aria-label="装车前审核分区"]').exists()).toBe(false); wrapper.unmount()
|
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[2]!.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 () => {
|
it('queries the current review queue without offering historical result filters', async () => {
|
||||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||||
await wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[1]!.trigger('click'); await flushPromises()
|
await wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[1]!.trigger('click'); await flushPromises()
|
||||||
|
|||||||
@@ -43,22 +43,35 @@ const page = ref(1)
|
|||||||
const pageSize = ref(10)
|
const pageSize = ref(10)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const stage = ref<DispatchStage>('BEFORE_LOADING'), section = ref<BeforeLoadingSection>('VEHICLE_INFO')
|
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') return vehicleStatuses[0]
|
||||||
|
return vehicleStatuses.find(item => item.value === value)
|
||||||
|
}
|
||||||
const operation = ref<'ROUTE' | undefined>()
|
const operation = ref<'ROUTE' | undefined>()
|
||||||
const action = ref<DispatchAction>('EDIT'), actionNo = ref(''), actionOpen = ref(false), createOpen = ref(false), bolLoadingNo = ref('')
|
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 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 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 showSections = computed(() => stage.value === 'BEFORE_LOADING' && (model.value.status || 'ACTIVE') === 'ACTIVE')
|
||||||
const fields = computed<QueryField[]>(() => [
|
const availableFields = computed<QueryField[]>(() => [
|
||||||
{ key: 'dispatchNo', label: '排车单号', type: 'input', width: 180, placeholder: '请输入排车单号' },
|
{ key: 'dispatchNo', label: '排车单号', type: 'input', width: 180, placeholder: '请输入排车单号' },
|
||||||
{ key: 'vehiclePlate', label: '车牌号', type: 'input', width: 150, 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[]>(() => [
|
const columns = computed<DataTableColumn[]>(() => [
|
||||||
{ key: 'actions', label: '操作', width: 220, fixed: 'left', slotName: 'actions' },
|
{ key: 'actions', label: '操作', width: 220, fixed: 'left', slotName: 'actions' },
|
||||||
{ key: 'taskType', label: '排车类型', width: 115, slotName: 'taskType' },
|
{ key: 'taskType', label: '排车类型', width: 115, slotName: 'taskType' },
|
||||||
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 190 },
|
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 190 },
|
||||||
{ key: 'vehicle', label: '车牌号', minWidth: 130, slotName: 'vehicle' }, { key: 'driver', label: '司机/联系电话', minWidth: 170, slotName: 'driver' },
|
{ 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 },
|
{ 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: 'returnReason', label: '退回原因', minWidth: 240, slotName: 'returnReason' }] : []),
|
||||||
...(showSections.value && section.value !== 'VEHICLE_INFO' ? [{ key: 'review', label: section.value === 'DISPATCH_REVIEW' ? '调度审核' : '理货员审核', minWidth: 210, slotName: 'review' }] : []),
|
...(showSections.value && section.value !== 'VEHICLE_INFO' ? [{ key: 'review', label: section.value === 'DISPATCH_REVIEW' ? '调度审核' : '理货员审核', minWidth: 210, slotName: 'review' }] : []),
|
||||||
@@ -79,12 +92,27 @@ async function load() {
|
|||||||
} catch (cause) { if (current === sequence) { rows.value = []; total.value = 0; statistics.value = null; loadError.value = getApiErrorDetails(cause, '排车列表加载失败,请重试') } }
|
} catch (cause) { if (current === sequence) { rows.value = []; total.value = 0; statistics.value = null; loadError.value = getApiErrorDetails(cause, '排车列表加载失败,请重试') } }
|
||||||
finally { if (current === sequence) loading.value = false }
|
finally { if (current === sequence) loading.value = false }
|
||||||
}
|
}
|
||||||
function search(value: QueryModel) { model.value = value; page.value = 1; void load() }
|
function search(value: QueryModel) {
|
||||||
function switchStage(value: DispatchStage) { stage.value = value; section.value = 'VEHICLE_INFO'; page.value = 1; void load() }
|
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 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 } }) }
|
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']
|
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' && ['DELIVERING', 'DELIVERED'].includes(row.stage) }
|
function canManageDocuments(row: DispatchListRow) { return (row.taskType || 'DELIVERY') === 'DELIVERY' && row.status === 'ACTIVE' && ['AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(row.stage) }
|
||||||
function rowActions(row: DispatchListRow): RowActionItem[] {
|
function rowActions(row: DispatchListRow): RowActionItem[] {
|
||||||
if (showSections.value && section.value !== 'VEHICLE_INFO') {
|
if (showSections.value && section.value !== 'VEHICLE_INFO') {
|
||||||
const review = section.value
|
const review = section.value
|
||||||
@@ -147,11 +175,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()">
|
<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-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 #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 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="2" :more-label="`更多操作 · ${row.dispatchNo}`" @action="handleAction($event, row as DispatchListRow)" /><p v-if="!row.actions">操作资格未返回,请刷新核对</p></template>
|
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListRow)" :max-visible="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 #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 #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 #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 #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>
|
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user