发布:将已验 TEST 功能更新至既有生产灰度(2026-10-23) #21
@@ -44,7 +44,7 @@
|
||||
|
||||
## 验证
|
||||
|
||||
- 排车调度/理货审核分区仅显示审核、详情;审核弹框的车辆图片、司机照片和驾驶证照片通过既有本车附件接口上传,随 `ReviewCommand.attachments` 保存,不调用资源编辑命令。已有资源与审核附件只读预览,详情合并展示;当前审核权限与服务端 Gate 控制上传及提交,列表查看权限控制读取。
|
||||
- 排车调度/理货审核分区仅显示审核、详情;车辆图片、司机照片和驾驶证照片在维护车辆司机时通过既有本车附件接口上传,随资源编辑命令保存,司机照片使用 `driver.evidence.photoFileIds`。三类照片横向并排、名称显示在照片下方;审核弹框只读预览,合并展示资源照片及历史审核附件。维护权限与服务端 Gate 控制上传及保存,审核权限控制审核提交,列表查看权限控制读取。
|
||||
|
||||
- 一件代发出库候选选择:`pages/OutboundsView.vue` 按 `inventoryPositionId + skuCode` 保留跨页选择,只提供本页全选;查询/重置回到第一页,切换海外仓清空选择。提交仍校验同客户、同海外仓,数量取所选行的 `availablePackages`(箱数),不能按 SKU 件数替代;候选同时展示当前可出箱数对应的毛重、材积重和体积,创建出库单时服务端按本次出库箱数占实际入库箱数的比例冻结三项测量快照。候选交互回归见 `pages/OutboundsView.selection.test.ts`;状态展示与取消门禁见 `pages/OutboundsView.test.ts`、`pages/OutboundDetailView.test.ts`。
|
||||
- 转运审核定向测试:从应用根运行 `pnpm test src/modules/overseas-warehouse/api/warehouseTransfers.test.ts src/modules/overseas-warehouse/pages/prototypeContract.test.ts`,分别核对客户作用域/API 与审核入口权限、状态。
|
||||
|
||||
@@ -405,9 +405,16 @@
|
||||
| 页面 | 操作 | 依据与承载 | 本次变更 | 验收 |
|
||||
|---|---|---|---|---|
|
||||
| 排车列表:调度审核、理货员审核 | 行操作 | 用户本轮确认只保留审核、详情;沿现有列表 | 去掉维护车辆信息及更多菜单,仅显示本环节审核与详情 | 本轮定向回归通过;待人工截图确认 |
|
||||
| 排车审核弹框 | 通过/驳回、上传附件 | 用户附件 codex-clipboard-249614d2-efac-4494-af47-090980981900.png;沿既有弹框 | 车辆图片、司机照片、驾驶证照片三类,原证据只读,本次附件随审核结果保存;未知结果重试原请求 | 本轮定向回归通过;待人工 1920×1080、100% 截图确认 |
|
||||
| 排车审核弹框 | 通过/驳回、预览照片 | 用户附件 codex-clipboard-7867e8a0-a3ae-4a16-bcd5-35291cf12e63.png、codex-clipboard-6baaaa9b-89bd-49fa-ac11-a26b20be7501.png;沿既有弹框 | 三类照片等宽等高区域并排、下方名称、点击预览;底部直接驳回/审核通过,驳回原因必填,去掉结果单选框;未知结果仅重试原请求 | 定向回归通过;待人工 1920×1080、100% 截图确认 |
|
||||
| 排车维护车辆信息 | 上传车辆、司机和驾驶证照片 | 用户确认上传入口属于维护车辆司机 | 三类照片通过资源编辑保存,司机照片独立字段;审核只读,保留历史审核证据 | 定向回归通过;待人工截图确认 |
|
||||
| 排车详情:车辆信息 | 查看审核附件 | 复用既有附件预览 | 汇总原资源与两级审核已保存证据,去重并只读预览 | 本轮定向回归通过;待人工截图确认 |
|
||||
|
||||
本轮自动验证: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 照片维护与审核按钮调整:Admin 4 个业务定向文件共 78 项通过,公共密度样式 70 项通过,类型检查通过;前两次回归发现的提示渲染时序与局部背景样式问题已修正并定向复测。指引索引检查因本地缺少 `D:/project/docs/业务快速索引.md` 无法完成。未部署,真实页面待人工截图确认。
|
||||
|
||||
- 2026-10-21 取货详情:按用户截图在转运单号后展示对应取货地址、联系人和联系电话,缺失值显示“—”;沿现有取货明细接口读取,不跨行复用联系人。`TransportExecutionPanel.test.ts` 6 项通过,含两张转运单地址和联系人分别展示的回归;真实页面待人工截图确认。
|
||||
|
||||
- 2026-10-21 取货装货留痕:列表及执行面板统一使用“上传装货照片”,沿居中弹框展示预报箱数并要求手填实际取货箱数(1—5000),未填数量或未上传照片不能确认;到仓仅展示已保存取货数量,不改写取货事实。数量与照片通过既有版本化命令同次确认,复用服务端操作人和时间历史。执行面板 7 项自动回归通过;真实页面待人工截图确认。
|
||||
|
||||
@@ -12,6 +12,28 @@ const options = { props: { modelValue: ['301'], dispatchNo: 'PC/1', kind: 'VEHIC
|
||||
const button = (wrapper: ReturnType<typeof mount>, label: string) => wrapper.findAll('button').find(value => value.text() === label)!
|
||||
async function selectFile(wrapper: ReturnType<typeof mount>, file: File) { const input = wrapper.get('input[type="file"]'); Object.defineProperty(input.element, 'files', { configurable: true, value: [file] }); await input.trigger('change'); await flushPromises() }
|
||||
beforeEach(() => { vi.resetAllMocks(); vi.mocked(api.getDispatchEvidence).mockResolvedValue(metadata()); vi.mocked(api.uploadDispatchEvidence).mockResolvedValue(metadata('302')); vi.mocked(api.getDispatchEvidenceContent).mockResolvedValue(new Blob(['png'], { type: 'image/png' })); URL.createObjectURL = vi.fn().mockReturnValue('blob:authenticated'); URL.revokeObjectURL = vi.fn() })
|
||||
it('renders authenticated photos above their caption and releases thumbnails on unmount', async () => {
|
||||
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, photoCard: true, readonly: true, label: '车辆图片' } }); await flushPromises()
|
||||
expect(api.getDispatchEvidenceContent).toHaveBeenCalledWith('PC/1', 'VEHICLE', '301')
|
||||
const figure = wrapper.get('figure')
|
||||
expect(figure.get('img').attributes('src')).toBe('blob:authenticated')
|
||||
expect(figure.get('figcaption').text()).toBe('车辆图片')
|
||||
expect(figure.element.lastElementChild?.tagName).toBe('FIGCAPTION')
|
||||
expect(wrapper.find('input[type="file"]').exists()).toBe(false)
|
||||
wrapper.unmount(); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:authenticated')
|
||||
})
|
||||
it('does not render a late thumbnail from the previous dispatch or an invalid image response', async () => {
|
||||
let resolve!: (value: Blob) => void
|
||||
vi.mocked(api.getDispatchEvidenceContent).mockImplementationOnce(() => new Promise(done => { resolve = done }))
|
||||
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, photoCard: true } }); await flushPromises()
|
||||
await wrapper.setProps({ dispatchNo: 'PC/2', modelValue: [] })
|
||||
resolve(new Blob(['png'], { type: 'image/png' })); await flushPromises()
|
||||
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
||||
vi.mocked(api.getDispatchEvidenceContent).mockResolvedValueOnce(new Blob(['bad'], { type: 'text/html' }))
|
||||
await wrapper.setProps({ modelValue: ['301'] }); await flushPromises()
|
||||
expect(URL.createObjectURL).not.toHaveBeenCalled(); expect(wrapper.find('img').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps previously provided references read-only and only submits newly uploaded audit files', async () => {
|
||||
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, modelValue: [], referenceIds: ['301'], kind: 'DRIVER_PHOTO', label: '司机照片' } }); await flushPromises()
|
||||
expect(wrapper.findAll('tbody button').map(item => item.text())).toEqual(['预览'])
|
||||
|
||||
@@ -10,12 +10,13 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getDispatchEvidence, getDispatchEvidenceContent, uploadDispatchEvidence } from '../api/dispatches'
|
||||
import { DispatchInputError, type DispatchEvidenceFile, type DispatchEvidenceKind } from '../dispatchContracts'
|
||||
const props = defineProps<{ dispatchNo: string; kind: DispatchEvidenceKind; disabled?: boolean; readonly?: boolean; label?: string; saveHint?: string; referenceIds?: string[] }>()
|
||||
const props = defineProps<{ dispatchNo: string; kind: DispatchEvidenceKind; disabled?: boolean; readonly?: boolean; photoCard?: boolean; label?: string; saveHint?: string; referenceIds?: string[] }>()
|
||||
const ids = defineModel<string[]>({ required: true })
|
||||
const allIds = computed(() => [...new Set([...(props.referenceIds || []), ...ids.value])])
|
||||
const emit = defineEmits<{ busy: [value: boolean] }>()
|
||||
const metadata = ref<Record<string, DispatchEvidenceFile>>({}), error = ref<ApiErrorDetails | null>(null), uploading = ref(false), input = ref<HTMLInputElement | null>(null)
|
||||
const imageOpen = ref(false), imageUrl = ref(''), imageName = ref(''), previewLoading = ref(false)
|
||||
const thumbnails = ref<Record<string, string>>({})
|
||||
const label = computed(() => props.label || (props.kind === 'VEHICLE' ? '本趟车辆证据' : props.kind === 'DRIVER_PHOTO' ? '司机照片' : '本趟司机证照'))
|
||||
const types = ['application/pdf', 'image/jpeg', 'image/png'], maximum = 10 * 1024 * 1024, urls = new Set<string>()
|
||||
const uploadTypes = computed(() => ['DRIVER_PHOTO', 'EXCEPTION'].includes(props.kind) ? types.slice(1) : types)
|
||||
@@ -29,11 +30,22 @@ function fail(cause: unknown, fallback: string) { error.value = getApiErrorDetai
|
||||
function closePreview() { previewGeneration++; pendingPopup?.close(); pendingPopup = null; imageOpen.value = false; previewLoading.value = false; if (imageUrl.value) { URL.revokeObjectURL(imageUrl.value); urls.delete(imageUrl.value); imageUrl.value = '' } }
|
||||
async function load() {
|
||||
const current = ++generation, no = props.dispatchNo, kind = props.kind, values = [...allIds.value]
|
||||
for (const url of Object.values(thumbnails.value)) { URL.revokeObjectURL(url); urls.delete(url) }
|
||||
thumbnails.value = {}
|
||||
closePreview(); metadata.value = {}; error.value = null
|
||||
if (!no) return
|
||||
const results = await Promise.allSettled(values.map(async id => valid(await getDispatchEvidence(no, kind, id), id)))
|
||||
if (current !== generation) return
|
||||
results.forEach(result => { if (result.status === 'fulfilled') metadata.value[result.value.fileId] = result.value; else fail(result.reason, '附件资料读取失败,请重试;原引用已保留') })
|
||||
if (props.photoCard) await Promise.all(values.map(async id => {
|
||||
const file = metadata.value[id]
|
||||
if (!file || !file.contentType.startsWith('image/')) return
|
||||
try {
|
||||
const blob = await readContent(no, kind, id, file)
|
||||
if (current !== generation) return
|
||||
const url = URL.createObjectURL(blob); urls.add(url); thumbnails.value[id] = url
|
||||
} catch (cause) { if (current === generation) fail(cause, '照片读取失败,请重试') }
|
||||
}))
|
||||
}
|
||||
function choose(id: string | null = null) { if (props.readonly || props.disabled || uploading.value || !props.dispatchNo) return; replacing = id; input.value?.click() }
|
||||
async function upload(event: Event) {
|
||||
@@ -52,6 +64,17 @@ async function upload(event: Event) {
|
||||
finally { uploading.value = false; emit('busy', false) }
|
||||
}
|
||||
function remove(id: string) { if (!props.readonly && !props.disabled && !uploading.value) ids.value = ids.value.filter(value => value !== id) }
|
||||
async function readContent(no: string, kind: DispatchEvidenceKind, id: string, file: DispatchEvidenceFile) {
|
||||
const blob = await getDispatchEvidenceContent(no, kind, id)
|
||||
const type = blob.type.split(';')[0]?.toLowerCase()
|
||||
if (type?.includes('json')) {
|
||||
let body: Partial<{ code: string; message: string; requestId: string; traceId: string }>
|
||||
try { body = JSON.parse(await blob.text()) as typeof body } catch { throw new DispatchInputError('附件内容返回错误信息,请重新读取') }
|
||||
throw new ApiResponseError(body.message || '附件读取失败', body.code || 'INVALID_FILE_CONTENT', undefined, undefined, body.requestId, body.traceId)
|
||||
}
|
||||
if (type !== file.contentType || blob.size <= 0 || blob.size > maximum || blob.size !== file.sizeBytes) throw new DispatchInputError('附件内容类型或大小与本车元数据不一致')
|
||||
return blob
|
||||
}
|
||||
async function preview(id: string) {
|
||||
const file = metadata.value[id]; if (!file || previewLoading.value) return
|
||||
closePreview(); const current = ++previewGeneration, scope = generation
|
||||
@@ -59,15 +82,8 @@ async function preview(id: string) {
|
||||
if (file.contentType === 'application/pdf') { pendingPopup = window.open('', '_blank'); if (!pendingPopup) { previewLoading.value = false; fail(new DispatchInputError('请允许本站打开PDF预览窗口'), 'PDF预览未打开'); return } }
|
||||
else { imageName.value = file.fileName; imageOpen.value = true }
|
||||
try {
|
||||
const blob = await getDispatchEvidenceContent(props.dispatchNo, props.kind, id)
|
||||
const blob = await readContent(props.dispatchNo, props.kind, id, file)
|
||||
if (current !== previewGeneration || scope !== generation) return
|
||||
const type = blob.type.split(';')[0]?.toLowerCase()
|
||||
if (type?.includes('json')) {
|
||||
let body: Partial<{ code: string; message: string; requestId: string; traceId: string }>
|
||||
try { body = JSON.parse(await blob.text()) as typeof body } catch { throw new DispatchInputError('附件内容返回错误信息,请重新读取') }
|
||||
throw new ApiResponseError(body.message || '附件读取失败', body.code || 'INVALID_FILE_CONTENT', undefined, undefined, body.requestId, body.traceId)
|
||||
}
|
||||
if (type !== file.contentType || blob.size <= 0 || blob.size > maximum || blob.size !== file.sizeBytes) throw new DispatchInputError('附件内容类型或大小与本车元数据不一致')
|
||||
const url = URL.createObjectURL(blob); urls.add(url)
|
||||
if (file.contentType === 'application/pdf') { pendingPopup!.location.href = url; pendingPopup = null; window.setTimeout(() => { URL.revokeObjectURL(url); urls.delete(url) }, 60_000) }
|
||||
else imageUrl.value = url
|
||||
@@ -78,10 +94,22 @@ watch(() => JSON.stringify([props.dispatchNo, props.kind, allIds.value]), load,
|
||||
onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) URL.revokeObjectURL(url); urls.clear(); emit('busy', false) })
|
||||
</script>
|
||||
<template>
|
||||
<section class="dispatch-evidence density-dialog-section" data-tone="source" :class="{ 'dispatch-evidence--readonly': readonly }" :aria-label="label"><h4 class="density-dialog-section__title">{{ label }}<InlineHelp title="附件上传说明"><p>{{ uploadFormats }},单文件最大10MiB;{{ saveHint || '上传后保存车辆信息才完成本车引用。' }}</p></InlineHelp></h4>
|
||||
<section class="dispatch-evidence density-dialog-section" data-tone="source" :class="{ 'dispatch-evidence--readonly': readonly }" :aria-label="label"><h4 v-if="!photoCard" class="density-dialog-section__title">{{ label }}<InlineHelp title="附件上传说明"><p>{{ uploadFormats }},单文件最大10MiB;{{ saveHint || '上传后保存车辆信息才完成本车引用。' }}</p></InlineHelp></h4>
|
||||
<LoadErrorState v-if="error" title="本趟附件操作未完成" :message="error.message" compact retry-text="重新读取资料" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<table v-if="allIds.length" class="density-table dispatch-evidence-table"><thead><tr><th class="dispatch-evidence__actions">操作</th><th>附件名称</th></tr></thead><tbody><tr v-for="id in allIds" :key="id"><td class="dispatch-evidence__actions"><div class="density-actions"><el-button link :disabled="!metadata[id] || previewLoading" @click="preview(id)">预览</el-button><template v-if="!readonly && ids.includes(id)"><el-button link :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button link :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></template></div></td><td><LineClampText :content="metadata[id]?.fileName || '附件资料待核实'" /></td></tr></tbody></table>
|
||||
<p v-else class="density-dialog-note">暂无本趟附件。</p>
|
||||
<figure v-if="photoCard" class="evidence-photo-card">
|
||||
<div v-if="allIds.length" class="evidence-photo-list">
|
||||
<div v-for="id in allIds" :key="id">
|
||||
<button type="button" class="evidence-photo-preview" :aria-label="'预览' + label + ':' + (metadata[id]?.fileName || id)" :disabled="!metadata[id] || previewLoading" @click="preview(id)"><img v-if="thumbnails[id]" :src="thumbnails[id]" :alt="label + ':' + metadata[id]?.fileName"><span v-else>{{ metadata[id]?.contentType === 'application/pdf' ? '查看 PDF' : '照片待加载' }}</span></button>
|
||||
<div v-if="!readonly && ids.includes(id)" class="density-actions"><el-button link :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button link :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="evidence-photo-empty">暂无照片</div>
|
||||
<figcaption>{{ label }}<InlineHelp v-if="!readonly" title="附件上传说明"><p>{{ uploadFormats }},单文件最大10MiB;{{ saveHint || '上传后保存车辆信息才完成本车引用。' }}</p></InlineHelp></figcaption>
|
||||
</figure>
|
||||
<template v-else>
|
||||
<table v-if="allIds.length" class="density-table dispatch-evidence-table"><thead><tr><th class="dispatch-evidence__actions">操作</th><th>附件名称</th></tr></thead><tbody><tr v-for="id in allIds" :key="id"><td class="dispatch-evidence__actions"><div class="density-actions"><el-button link :disabled="!metadata[id] || previewLoading" @click="preview(id)">预览</el-button><template v-if="!readonly && ids.includes(id)"><el-button link :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button link :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></template></div></td><td><LineClampText :content="metadata[id]?.fileName || '附件资料待核实'" /></td></tr></tbody></table>
|
||||
<p v-else class="density-dialog-note">暂无本趟附件。</p>
|
||||
</template>
|
||||
<template v-if="!readonly"><input ref="input" class="evidence-input" type="file" :accept="uploadTypes.join(',')" :aria-label="'上传' + label" :disabled="disabled || uploading || !dispatchNo" @change="upload"><el-button :disabled="disabled || uploading || !dispatchNo" :loading="uploading" @click="choose()">上传{{ label }}</el-button></template>
|
||||
<AppDialog append-to-body class="admin-density-dialog admin-workbench is-dialog" :model-value="imageOpen" :title="imageName || '附件预览'" width="min(900px, calc(100vw - 28px))" fullscreenable :loading="previewLoading" :show-confirm="false" cancel-text="关闭" @update:model-value="closePreview">
|
||||
<div class="density-dialog-sections"><section class="density-dialog-section" data-tone="source" aria-label="本车附件预览信息"><h3 class="density-dialog-section__title">附件信息</h3><table class="density-table"><tbody><tr><th scope="row">排车单号</th><td>{{ dispatchNo || '—' }}</td></tr><tr><th scope="row">附件名称</th><td>{{ imageName || '—' }}</td></tr></tbody></table></section><p v-if="previewLoading" class="density-dialog-note" role="status">正在读取本车附件…</p><img v-if="imageUrl" class="evidence-image" :src="imageUrl" :alt="imageName"></div>
|
||||
@@ -95,4 +123,10 @@ onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) UR
|
||||
.dispatch-evidence > .el-button { margin-top: var(--admin-space-3); }
|
||||
.evidence-input { display: none; }
|
||||
.evidence-image { display: block; max-width: 100%; max-height: 72vh; margin: auto; object-fit: contain; }
|
||||
.evidence-photo-card { margin: 0; text-align: center; }
|
||||
.evidence-photo-list { display: grid; gap: var(--admin-space-3); }
|
||||
.evidence-photo-preview, .evidence-photo-empty { box-sizing: border-box; width: 100%; height: 150px; display: flex; align-items: center; justify-content: center; border: 1px solid var(--el-border-color-lighter); border-radius: var(--el-border-radius-base); color: var(--el-text-color-secondary); }
|
||||
.evidence-photo-preview { padding: 0; cursor: pointer; }
|
||||
.evidence-photo-preview img { width: 100%; height: 100%; object-fit: contain; }
|
||||
.evidence-photo-card figcaption { margin-top: var(--admin-space-3); font-weight: 600; }
|
||||
</style>
|
||||
|
||||
@@ -86,23 +86,54 @@ describe('dispatch workflow native layout', () => {
|
||||
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('keeps %s facts read-only and requires a rejection reason', async action => {
|
||||
const { dialog } = await render(action)
|
||||
expect(dialog.get('[aria-label="本车审核依据"]').findAll('input')).toHaveLength(0)
|
||||
expect(dialog.text()).toContain('审核通过不代替后续现场扫码')
|
||||
field(dialog, '审核结果').getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'REJECTED'); await flushPromises()
|
||||
expect(dialog.get('[aria-label="本车审核依据"]').find('h3').exists()).toBe(false)
|
||||
expect(dialog.text()).not.toContain('请核对本车资源与本趟证据')
|
||||
expect(dialog.findAll('button').some(button => button.text() === '取消')).toBe(false)
|
||||
expect(dialog.findComponent({ name: 'ElRadioGroup' }).exists()).toBe(false)
|
||||
expect(dialog.findAll('button').some(button => button.text() === '确认提交')).toBe(false)
|
||||
const reject = dialog.findAll('button').find(button => button.text() === '驳回')!
|
||||
const review = action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading
|
||||
dialog.vm.$emit('confirm'); await flushPromises(); expect(review).not.toHaveBeenCalled()
|
||||
await dialog.get('textarea').setValue(' 证据不完整 '); dialog.vm.$emit('confirm'); await flushPromises()
|
||||
await reject.trigger('click'); await flushPromises(); expect(review).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => expect(field(dialog, '驳回原因(必填)').text()).toContain('请填写驳回原因'))
|
||||
await dialog.get('textarea').setValue(' '); await reject.trigger('click'); await flushPromises(); expect(review).not.toHaveBeenCalled()
|
||||
await dialog.get('textarea').setValue(' 证据不完整 '); await reject.trigger('click'); await flushPromises()
|
||||
expect(review).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), decision: 'REJECTED', reason: '证据不完整' })
|
||||
expect(dialog.findAll('input[type="file"]')).toHaveLength(3)
|
||||
expect(dialog.findAll('input[type="file"]')).toHaveLength(0)
|
||||
})
|
||||
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('uploads three audit attachments with only %s permission and retries the same rejected review', async action => {
|
||||
can.mockImplementation(code => code === (action === 'DISPATCH_REVIEW' ? 'platform:fleet:trip:dispatch-review' : 'platform:fleet:trip:loading-review'))
|
||||
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('approves %s directly and keeps an unknown result on the original request', async action => {
|
||||
const review = action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading
|
||||
vi.mocked(review).mockRejectedValueOnce(new Error('unknown review')).mockImplementationOnce(async (_, body) => command(action, body.idempotencyKey))
|
||||
const { wrapper, dialog } = await render(action, undefined, true)
|
||||
vi.mocked(review).mockRejectedValueOnce(new Error('结果未知'))
|
||||
const { dialog } = await render(action)
|
||||
await dialog.findAll('button').find(button => button.text() === '审核通过')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(review).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), decision: 'APPROVED', reason: null })
|
||||
const original = structuredClone(vi.mocked(review).mock.calls[0]![1])
|
||||
expect(dialog.props('showCancel')).toBe(false)
|
||||
expect(dialog.findAll('button').some(button => button.text() === '取消')).toBe(false)
|
||||
expect(dialog.findAll('button').some(button => button.text() === '驳回')).toBe(false)
|
||||
await dialog.findAll('button').find(button => button.text() === '核对并重试原请求')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(vi.mocked(review).mock.calls[1]![1]).toEqual(original)
|
||||
})
|
||||
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('disables both %s decisions when write permission is missing', async action => {
|
||||
can.mockReturnValue(false)
|
||||
const { dialog } = await render(action)
|
||||
for (const label of ['驳回', '审核通过']) {
|
||||
const button = dialog.findAll('button').find(item => item.text() === label)!
|
||||
expect(button.attributes('disabled')).toBeDefined()
|
||||
await button.trigger('click')
|
||||
}
|
||||
expect(api.reviewDispatch).not.toHaveBeenCalled()
|
||||
expect(api.reviewDispatchLoading).not.toHaveBeenCalled()
|
||||
})
|
||||
it('uploads all three photos during resource maintenance and retries the exact original edit', async () => {
|
||||
can.mockImplementation(code => code === 'platform:overseas-warehouse:dispatch:edit')
|
||||
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new Error('unknown edit')).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
|
||||
const { wrapper, dialog } = await render('EDIT', undefined, true)
|
||||
const fields = dialog.findAllComponents(DispatchEvidenceFiles)
|
||||
expect(fields.map(item => item.props('label'))).toEqual(['车辆图片', '司机照片', '驾驶证照片'])
|
||||
expect(fields[0]!.props('referenceIds')).toEqual(['301'])
|
||||
expect(fields[0]!.findAll('button').some(button => button.text() === '替换')).toBe(false)
|
||||
expect(fields[0]!.props('modelValue')).toEqual(['301'])
|
||||
expect(dialog.get('.workflow-photo-grid').findAll('figure')).toHaveLength(3)
|
||||
for (const [index, files] of fields.entries()) {
|
||||
let resolve!: (value: DispatchEvidenceFile) => void
|
||||
vi.mocked(api.uploadDispatchEvidence).mockReturnValueOnce(new Promise(done => { resolve = done }))
|
||||
@@ -110,19 +141,32 @@ describe('dispatch workflow native layout', () => {
|
||||
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] })
|
||||
await input.trigger('change'); await flushPromises()
|
||||
expect(dialog.props('confirmLoading')).toBe(true)
|
||||
dialog.vm.$emit('confirm'); await flushPromises(); expect(review).not.toHaveBeenCalled()
|
||||
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.updateDispatch).not.toHaveBeenCalled()
|
||||
resolve(evidence(String(401 + index))); await flushPromises()
|
||||
}
|
||||
expect(vi.mocked(api.uploadDispatchEvidence).mock.calls.map(call => call[1])).toEqual(['VEHICLE', 'DRIVER_PHOTO', 'DRIVER'])
|
||||
field(dialog, '审核结果').getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'REJECTED'); await flushPromises()
|
||||
await dialog.get('textarea').setValue('需重新核对车牌')
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
const original = structuredClone(vi.mocked(review).mock.calls[0]![1])
|
||||
expect(original).toMatchObject({ decision: 'REJECTED', reason: '需重新核对车牌', attachments: { vehicleFileIds: ['401'], driverPhotoFileIds: ['402'], licenseFileIds: ['403'] } })
|
||||
const original = structuredClone(vi.mocked(api.updateDispatch).mock.calls[0]![1])
|
||||
expect(original).toMatchObject({ vehicle: { evidenceFileIds: ['301', '401'] }, driver: { evidence: { photoFileIds: ['402'], fileIds: expect.arrayContaining(['403']) } } })
|
||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(vi.mocked(review).mock.calls[1]![1]).toEqual(original)
|
||||
expect(api.updateDispatch).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(api.updateDispatch).mock.calls[1]![1]).toEqual(original)
|
||||
expect(api.reviewDispatch).not.toHaveBeenCalled()
|
||||
expect(api.reviewDispatchLoading).not.toHaveBeenCalled()
|
||||
})
|
||||
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('shows saved photos and old audit evidence read-only in %s', async action => {
|
||||
const { wrapper, dialog } = await render(action)
|
||||
const value = detail({ actions })
|
||||
value.vehicleInfo.driver!.evidence!.photoFileIds = ['402']
|
||||
value.reviews.dispatchReview.attachments = { vehicleFileIds: [], driverPhotoFileIds: ['403'], licenseFileIds: [] }
|
||||
vi.mocked(api.getDispatch).mockResolvedValueOnce(value)
|
||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
const fields = dialog.findAllComponents(DispatchEvidenceFiles)
|
||||
expect(fields.map(item => item.props('label'))).toEqual(['车辆图片', '司机照片', '驾驶证照片'])
|
||||
expect(fields.every(item => item.props('readonly') && item.props('photoCard'))).toBe(true)
|
||||
expect(fields[1]!.props('modelValue')).toEqual(['402', '403'])
|
||||
expect(dialog.findAll('input[type="file"]')).toHaveLength(0)
|
||||
expect(api.uploadDispatchEvidence).not.toHaveBeenCalled()
|
||||
})
|
||||
it.each(['CANCEL', 'RETURN_TO_REVIEW', 'SUBMIT_REVIEW'] as const)('preserves the %s confirmation branch and its exact reason or remark', async action => {
|
||||
const { dialog } = await render(action)
|
||||
@@ -170,7 +214,7 @@ describe('dispatch workflow native layout', () => {
|
||||
vi.mocked(api.uploadDispatchEvidence).mockReturnValue(new Promise(done => { resolve = done }))
|
||||
const { dialog } = await render('EDIT', undefined, true)
|
||||
const files = dialog.findAllComponents(DispatchEvidenceFiles)[0]!
|
||||
await files.findAll('button').find(button => button.text() === '预览')!.trigger('click'); await flushPromises()
|
||||
await files.get('button.evidence-photo-preview').trigger('click'); await flushPromises()
|
||||
const image = files.findComponent(AppDialog)
|
||||
expect(image.props('modelValue')).toBe(true)
|
||||
const preview = new DOMWrapper(document.querySelector('[aria-label="本车附件预览信息"]')!.closest('.el-dialog')!)
|
||||
|
||||
@@ -13,7 +13,7 @@ import * as api from '../api/dispatches'
|
||||
import type { MarkDispatchException, DispatchExceptionType, DispatchReturnDirection, CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
|
||||
import type { DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../dispatchContracts'
|
||||
import { DispatchInputError, requireVersion } from '../dispatchContracts'
|
||||
import { dispatchExceptionLabels, dispatchReturnDirectionLabels, canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, emptyReviewAttachments } from '../dispatchPresentation'
|
||||
import { dispatchExceptionLabels, dispatchReturnDirectionLabels, canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence } from '../dispatchPresentation'
|
||||
import { isDefiniteDispatchRejection, useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
|
||||
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||
@@ -36,8 +36,9 @@ const measurements = ref<Array<{ warehousePalletId: string; palletNo: string; pa
|
||||
const blankVehicle = (): Vehicle => ({ mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] })
|
||||
const blankDriver = (): Driver => ({ mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } })
|
||||
const vehicle = ref<Vehicle>(blankVehicle()), driver = ref<Driver>(blankDriver())
|
||||
const reviewAttachments = ref(emptyReviewAttachments())
|
||||
const driverPhotos = computed({ get: () => driver.value.evidence?.photoFileIds || [], set: (ids: string[]) => { if (driver.value.evidence) driver.value.evidence.photoFileIds = ids } })
|
||||
const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value))
|
||||
const isReview = computed(() => ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction.value))
|
||||
const exception = reactive({ exceptionType: '' as DispatchExceptionType | '', returnDirection: '' as DispatchReturnDirection | '', description: '', imageFileIds: [] as string[] })
|
||||
const form = reactive({ plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
|
||||
let generation = 0
|
||||
@@ -62,7 +63,6 @@ async function load() {
|
||||
if (sequence !== generation) return
|
||||
if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开')
|
||||
detail.value = value
|
||||
reviewAttachments.value = emptyReviewAttachments()
|
||||
Object.assign(exception, { exceptionType: '', returnDirection: '', description: '', imageFileIds: [] })
|
||||
const oldVehicle = value.vehicleInfo.vehicle
|
||||
vehicle.value = oldVehicle ? { mode: oldVehicle.mode, vehicleId: oldVehicle.vehicleId, plate: oldVehicle.plate, carrierName: oldVehicle.carrierName, capacity: oldVehicle.capacity ? { ...oldVehicle.capacity } : null, evidenceFileIds: [...oldVehicle.evidenceFileIds] } : blankVehicle()
|
||||
@@ -106,7 +106,7 @@ function payload(): object {
|
||||
case 'EDIT': return { ...common, vehicle: vehicle.value, driver: driver.value, plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
|
||||
case 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
|
||||
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
|
||||
return { ...common, decision: form.decision, reason: form.reason.trim() || null, ...(Object.values(reviewAttachments.value).some(ids => ids.length) ? { attachments: reviewAttachments.value } : {}) } satisfies ReviewCommand
|
||||
return { ...common, decision: form.decision, reason: form.reason.trim() || null } satisfies ReviewCommand
|
||||
case 'CANCEL': case 'RETURN_TO_REVIEW':
|
||||
if (!form.reason.trim()) throw new DispatchInputError('请填写操作原因')
|
||||
return { ...common, reason: form.reason.trim() } satisfies ReasonCommand
|
||||
@@ -133,6 +133,11 @@ async function send(command: PendingDispatchCommand): Promise<CommandResult> {
|
||||
default: throw new DispatchInputError('此操作不属于当前后台作业入口')
|
||||
}
|
||||
}
|
||||
async function submitReview(decision: 'APPROVED' | 'REJECTED') {
|
||||
if (frozen.value || loading.value || uploadingEvidence.value || !allowed.value) return
|
||||
form.decision = decision
|
||||
await submit()
|
||||
}
|
||||
async function submit() {
|
||||
if (busy.value || loading.value || uploadingEvidence.value) return
|
||||
error.value = null
|
||||
@@ -185,7 +190,7 @@ function moveStop(index: number, offset: number) { const target = index + offset
|
||||
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else { generation++ } }, { immediate: true })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :loading="loading" :confirm-loading="busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '请从智能排车修改' : pending?.result ? '刷新页面资料' : pending ? '核对并重试原请求' : currentAction === 'MARK_EXCEPTION' ? '保存' : '确认提交'" @confirm="submit">
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :show-cancel="!isReview" :loading="loading" :confirm-loading="busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '请从智能排车修改' : pending?.result ? '刷新页面资料' : pending ? '核对并重试原请求' : currentAction === 'MARK_EXCEPTION' ? '保存' : '确认提交'" @confirm="submit">
|
||||
<div class="density-dialog-sections">
|
||||
<el-alert v-if="cargoAction" :title="cargoEntryMessage" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理,请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
|
||||
@@ -209,11 +214,9 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="本车时间登记"><h3 class="density-dialog-section__title">排车时间</h3><table class="density-table density-table--editable workflow-editor-table"><tbody><tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择计划发车时间" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间" /></el-form-item></td></tr></tbody></table></section>
|
||||
</template>
|
||||
<template v-else-if="currentAction === 'DISPATCH_REVIEW' || currentAction === 'LOADING_REVIEW'">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="本车审核依据"><h3 class="density-dialog-section__title">审核依据</h3><table class="density-table"><tbody><tr><th scope="row">车牌</th><td>{{ detail.vehicleInfo.vehicle?.plate || '—' }}</td><th scope="row">司机</th><td>{{ detail.vehicleInfo.driver?.name || '—' }}</td></tr><tr><th scope="row">托盘数</th><td>{{ detail.counts.palletCount }} 托</td><th scope="row">箱数</th><td>{{ detail.counts.packageCount }} 箱</td></tr></tbody></table>
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="本车审核依据"><table class="density-table"><tbody><tr><th scope="row">车牌</th><td>{{ detail.vehicleInfo.vehicle?.plate || '—' }}</td><th scope="row">司机</th><td>{{ detail.vehicleInfo.driver?.name || '—' }}</td></tr><tr><th scope="row">托盘数</th><td>{{ detail.counts.palletCount }} 托</td><th scope="row">箱数</th><td>{{ detail.counts.packageCount }} 箱</td></tr></tbody></table>
|
||||
<table v-if="detail.dispatchInfo.fbaAppointments.length" class="density-table"><thead><tr><th>目的仓</th><th>预约单号</th><th>预约状态</th></tr></thead><tbody><tr v-for="appointment in detail.dispatchInfo.fbaAppointments" :key="appointment.batchKey"><td>{{ appointment.destinationWarehouseCode }}</td><td>{{ appointment.appointmentNo || '—' }}</td><td>{{ appointment.status }}</td></tr></tbody></table>
|
||||
<el-alert title="请核对本车资源与本趟证据;审核通过不代替后续现场扫码。驳回必须填写原因,并统一退回客服处理(车辆信息)。" type="info" :closable="false" show-icon />
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="审核结果登记"><h3 class="density-dialog-section__title">审核结果</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="审核结果"><el-radio-group v-model="form.decision" :disabled="frozen || loading || uploadingEvidence || !allowed"><el-radio value="APPROVED">审核通过</el-radio><el-radio value="REJECTED">驳回</el-radio></el-radio-group></el-form-item></td></tr></tbody></table></section>
|
||||
</template>
|
||||
<template v-else-if="currentAction === 'LOADING_COMPLETE' && preview">
|
||||
<el-alert :title="preview.loadingComplete.blockReason || 'PDA真实拣货已核平,请核对每托实际装载。'" type="info" :closable="false" show-icon />
|
||||
@@ -222,21 +225,31 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
</template>
|
||||
<el-alert v-else-if="currentAction === 'RETURN_TO_REVIEW'" title="仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-else-if="currentAction === 'CANCEL'" title="确认取消本排车单;服务端将核对真实作业和来源占用,不自动回库。" type="warning" :closable="false" show-icon />
|
||||
<template v-if="currentOperation !== 'ROUTE' && currentAction === 'EDIT'">
|
||||
<DispatchEvidenceFiles v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
|
||||
<DispatchEvidenceFiles v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
|
||||
</template>
|
||||
<template v-else-if="['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)">
|
||||
<DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" v-model="reviewAttachments[field.key]" :reference-ids="reviewEvidence[field.key]" :dispatch-no="currentNo" :kind="field.kind" :label="field.label" save-hint="上传后提交审核,附件与本次审核结果一起保存。" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy[field.kind] = $event" />
|
||||
</template>
|
||||
<section v-if="currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">操作说明</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="currentOperation === 'ROUTE' || form.decision === 'REJECTED' || ['CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
|
||||
<div v-if="currentOperation !== 'ROUTE' && currentAction === 'EDIT'" class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||
<DispatchEvidenceFiles photo-card v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" label="车辆图片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="currentNo" kind="DRIVER_PHOTO" label="司机照片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER_PHOTO = $event" />
|
||||
<DispatchEvidenceFiles photo-card v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" label="驾驶证照片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
|
||||
</div>
|
||||
<div v-else-if="isReview" class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||
<DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" photo-card readonly :model-value="reviewEvidence[field.key]" :dispatch-no="currentNo" :kind="field.kind" :label="field.label" />
|
||||
</div>
|
||||
<section v-if="currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">操作说明</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="isReview && form.decision === 'REJECTED' ? '驳回原因(必填)' : currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'" :error="isReview && form.decision === 'REJECTED' && !form.reason.trim() ? '请填写驳回原因' : ''"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" :placeholder="isReview ? '驳回时必须填写原因,审核通过时选填' : ''" /></el-form-item></td></tr></tbody></table></section>
|
||||
<section v-else-if="currentAction !== 'MARK_EXCEPTION'" class="density-dialog-section" data-tone="work" aria-label="本次操作备注"><h3 class="density-dialog-section__title">本次备注</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="备注"><el-input v-model="form.remark" aria-label="本次操作备注" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
|
||||
</el-form>
|
||||
</div>
|
||||
<template v-if="isReview && !pending" #footer>
|
||||
<div class="workflow-review-actions">
|
||||
<el-button type="danger" :disabled="frozen || loading || uploadingEvidence || !allowed" @click="submitReview('REJECTED')">驳回</el-button>
|
||||
<el-button type="primary" :disabled="frozen || loading || uploadingEvidence || !allowed" @click="submitReview('APPROVED')">审核通过</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
<style scoped>
|
||||
.workflow-editor-table { table-layout: fixed; }
|
||||
.workflow-editor-table td { width: 50%; }
|
||||
.workflow-order-actions { width: 160px; white-space: nowrap; }
|
||||
.workflow-photo-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); }
|
||||
.workflow-review-actions { display: flex; justify-content: flex-end; gap: var(--admin-space-3); }
|
||||
.workflow-review-actions > .el-button { margin-left: 0; }
|
||||
</style>
|
||||
|
||||
@@ -20,19 +20,37 @@ it.each([false, true])('confirms the %s photo stage with its own evidence and cu
|
||||
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true } }); await flushPromises()
|
||||
expect(wrapper.text()).toContain(arriving ? '派送中' : '装货中')
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '确认发车')).toBe(false)
|
||||
await wrapper.findAll('button').find(button => button.text() === (arriving ? '上传到仓照片' : '上传取货照片'))!.trigger('click'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === (arriving ? '上传到仓照片' : '上传装货照片'))!.trigger('click'); await flushPromises()
|
||||
if (!arriving) {
|
||||
const quantity = wrapper.get('input[role="spinbutton"]')
|
||||
expect((quantity.element as HTMLInputElement).value).toBe('')
|
||||
await quantity.setValue('3'); await quantity.trigger('change'); await flushPromises()
|
||||
}
|
||||
const input = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(input.element, 'files', { value: [new File(['photo'], '现场.jpg', { type: 'image/jpeg' })], configurable: true })
|
||||
await input.trigger('change'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === (arriving ? '确认到仓' : '确认取货'))!.trigger('click'); await flushPromises()
|
||||
expect(commandTransport).toHaveBeenCalledWith('PC1', `pickup/C1/WT1/${arriving ? 'arrive' : 'picked'}`, expect.objectContaining({ recordVersion: 4, evidenceFileIds: ['88'], ...(arriving ? { reason: '上传到仓照片,确认取货派送完成' } : { actualPackages: 5 }) }))
|
||||
expect(commandTransport).toHaveBeenCalledWith('PC1', `pickup/C1/WT1/${arriving ? 'arrive' : 'picked'}`, expect.objectContaining({ recordVersion: 4, evidenceFileIds: ['88'], ...(arriving ? { reason: '上传到仓照片,确认取货派送完成' } : { actualPackages: 3 }) }))
|
||||
if (arriving) expect(commandTransport.mock.calls[0]![2]).not.toHaveProperty('actualPackages')
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('keeps photo confirmation unavailable until both reviews are approved', async () => {
|
||||
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: false } }); await flushPromises()
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '上传取货照片')).toBe(false)
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '上传装货照片')).toBe(false)
|
||||
expect(commandTransport).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('requires an explicitly entered pickup quantity even after a photo has uploaded', async () => {
|
||||
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true } }); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '上传装货照片')!.trigger('click'); await flushPromises()
|
||||
const input = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(input.element, 'files', { value: [new File(['photo'], '现场.jpg', { type: 'image/jpeg' })], configurable: true })
|
||||
await input.trigger('change'); await flushPromises()
|
||||
const confirm = wrapper.findAll('button').find(button => button.text() === '确认取货')!
|
||||
expect(confirm.attributes('disabled')).toBeDefined()
|
||||
await confirm.trigger('click')
|
||||
expect(commandTransport).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each([false, true])('renders read-only transport facts without resource editing or execution controls (%s)', async arriving => {
|
||||
const value = facts(arriving)
|
||||
value.execution.trip.vehicleId = null
|
||||
@@ -47,3 +65,20 @@ it.each([false, true])('renders read-only transport facts without resource editi
|
||||
expect(commandTransport).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps pickup addresses and contacts on their own transfer rows', async () => {
|
||||
const value = facts()
|
||||
value.pickups[0] = { ...value.pickups[0]!, pickupAddress: '100 Test Pickup Avenue', contactName: '取货联系人甲', contactPhone: '0000000001' }
|
||||
value.pickups.push({ ...value.pickups[0]!, transferNo: 'WT2', customerId: 'C2', pickupAddress: '200 Test Pickup Road', contactName: '取货联系人乙', contactPhone: '0000000002' })
|
||||
vi.mocked(getTransport).mockResolvedValue(value)
|
||||
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', readonly: true } }); await flushPromises()
|
||||
const rows = wrapper.get('[aria-label="取货明细"]').findAll('tbody tr')
|
||||
expect(rows[0]!.text()).toContain('100 Test Pickup Avenue')
|
||||
expect(rows[0]!.text()).toContain('取货联系人甲')
|
||||
expect(rows[0]!.text()).toContain('0000000001')
|
||||
expect(rows[0]!.text()).not.toContain('200 Test Pickup Road')
|
||||
expect(rows[1]!.text()).toContain('200 Test Pickup Road')
|
||||
expect(rows[1]!.text()).toContain('取货联系人乙')
|
||||
expect(rows[1]!.text()).toContain('0000000002')
|
||||
expect(wrapper.findAll('button')).toHaveLength(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -19,8 +19,9 @@ const props = defineProps<{ dispatchNo: string; pickupApproved?: boolean; pickup
|
||||
const access = useAccessPreviewStore(), detail = ref<TransportDetail | null>(null), error = ref<ApiErrorDetails | null>(null), busy = ref(false)
|
||||
const vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([]), vehicleId = ref(''), driverId = ref(''), reason = ref('')
|
||||
const occupancy = ref<Record<string, boolean>>({}), candidateBusy = ref(false)
|
||||
const selected = ref<PickupTask | null>(null), pickupOpen = ref(false), actual = ref(1), evidence = ref<PickupEvidence[]>([]), truckEvidence = ref<PickupEvidence[]>([])
|
||||
const selected = ref<PickupTask | null>(null), pickupOpen = ref(false), actual = ref<number>(), evidence = ref<PickupEvidence[]>([]), truckEvidence = ref<PickupEvidence[]>([])
|
||||
const arrivalMode = ref(false)
|
||||
const validPickupQuantity = computed(() => typeof actual.value === 'number' && Number.isInteger(actual.value) && actual.value >= 1 && actual.value <= 5000)
|
||||
const editingResources = ref(props.editResources === true)
|
||||
const pickupPhotos = (row: PickupTask, phase: 'PICKUP' | 'ARRIVAL') => row.evidence.filter(file => ((file as PickupEvidence & { phase?: string }).phase || 'PICKUP') === phase)
|
||||
const pickupStage = computed(() => trip.value?.status === 'COMPLETED' ? '取货派送完成' : trip.value?.status === 'CANCELLED' ? '已取消' : detail.value?.pickups.some(row => row.status === 'ASSIGNED') ? props.pickupApproved ? '装货中' : ({ DRAFT: '待提交审核', DISPATCH_PENDING: '待调度审核', LOADING_PENDING: '待理货审核', REJECTED: '审核退回' }[props.pickupReviewState || 'DRAFT'] || '待审核') : '派送中')
|
||||
@@ -76,7 +77,8 @@ async function command(action: 'depart' | 'complete' | 'arrive' | 'cancel', row?
|
||||
pending.value = { suffix, kind: action === 'cancel' ? 'cancel' : undefined, body: { recordVersion: trip.value.recordVersion, idempotencyKey: crypto.randomUUID(), reason: reason.value.trim() || message, ...(action === 'complete' && prefix === 'truck' ? { evidenceFileIds: truckEvidence.value.map(file => file.fileId) } : {}) } }
|
||||
await execute()
|
||||
}
|
||||
function editPickup(row: PickupTask, arrival = false) { selected.value = row; arrivalMode.value = arrival; actual.value = Number(row.actualPackages ?? row.packageCount); evidence.value = []; pickupOpen.value = true }
|
||||
/** 实际取货数量由现场填写,预报箱数不能自动作为实提事实。 */
|
||||
function editPickup(row: PickupTask, arrival = false) { selected.value = row; arrivalMode.value = arrival; actual.value = undefined; evidence.value = []; pickupOpen.value = true }
|
||||
async function download(file: PickupEvidence, row?: PickupTask) {
|
||||
try { const blob = await downloadTransportEvidence(props.dispatchNo, file.fileId, row); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file.fileName; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '凭证下载失败') }
|
||||
@@ -91,7 +93,7 @@ async function upload(event: Event, pickup: boolean) {
|
||||
finally { busy.value = false; input.value = '' }
|
||||
}
|
||||
async function picked() {
|
||||
if (!selected.value || !trip.value || !Number.isInteger(actual.value) || actual.value <= 0 || !evidence.value.length || locked.value) return
|
||||
if (!selected.value || !trip.value || (!arrivalMode.value && !validPickupQuantity.value) || !evidence.value.length || locked.value) return
|
||||
const row = selected.value
|
||||
const common = { recordVersion: trip.value.recordVersion, idempotencyKey: crypto.randomUUID(), evidenceFileIds: evidence.value.map(file => file.fileId) }
|
||||
pending.value = { suffix: `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}/${arrivalMode.value ? 'arrive' : 'picked'}`, body: arrivalMode.value ? { ...common, reason: reason.value.trim() || '上传到仓照片,确认取货派送完成' } : { ...common, actualPackages: actual.value, remark: reason.value.trim() } }; await execute()
|
||||
@@ -138,10 +140,13 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输请求尚待核
|
||||
<section v-if="detail.taskType === 'PICKUP'" class="transport-pickups" aria-label="取货明细">
|
||||
<div class="transport-section-heading"><h2 class="workbench-section-title"><Package :size="16" aria-hidden="true" />取货明细</h2><span class="workbench-meta">{{ detail.pickups.length }} 单 · 预报 {{ detail.pickups.reduce((total, row) => total + row.packageCount, 0) }} 箱</span></div>
|
||||
<el-table :data="detail.pickups" size="small" class="density-table" empty-text="暂无取货明细">
|
||||
<el-table-column v-if="!readonly" label="操作" width="160"><template #default="{ row }"><template v-if="editable && props.pickupApproved && ['PLANNED', 'IN_TRANSIT'].includes(trip.status) && ['ASSIGNED', 'PICKED'].includes(row.status)"><el-button v-if="row.status === 'ASSIGNED'" link type="primary" :disabled="locked" @click="editPickup(row as PickupTask)">上传取货照片</el-button><el-button v-if="row.status === 'PICKED' && trip.status === 'IN_TRANSIT'" link type="primary" :disabled="locked" @click="editPickup(row as PickupTask, true)">上传到仓照片</el-button></template><span v-else class="workbench-meta">—</span></template></el-table-column>
|
||||
<el-table-column v-if="!readonly" label="操作" width="160"><template #default="{ row }"><template v-if="editable && props.pickupApproved && ['PLANNED', 'IN_TRANSIT'].includes(trip.status) && ['ASSIGNED', 'PICKED'].includes(row.status)"><el-button v-if="row.status === 'ASSIGNED'" link type="primary" :disabled="locked" @click="editPickup(row as PickupTask)">上传装货照片</el-button><el-button v-if="row.status === 'PICKED' && trip.status === 'IN_TRANSIT'" link type="primary" :disabled="locked" @click="editPickup(row as PickupTask, true)">上传到仓照片</el-button></template><span v-else class="workbench-meta">—</span></template></el-table-column>
|
||||
<el-table-column prop="transferNo" label="转运单号" width="210" />
|
||||
<el-table-column label="取货地址" min-width="280"><template #default="{ row }">{{ row.pickupAddress || '—' }}</template></el-table-column>
|
||||
<el-table-column label="联系人" min-width="120"><template #default="{ row }">{{ row.contactName || '—' }}</template></el-table-column>
|
||||
<el-table-column label="联系电话" min-width="160"><template #default="{ row }">{{ row.contactPhone || '—' }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="trip.status === 'CANCELLED' ? 'info' : row.status === 'ARRIVED' ? 'success' : 'primary'">{{ trip.status === 'CANCELLED' ? '已取消' : pickupStatusLabels[row.status] }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="预报/实提(箱)" width="145" align="right"><template #default="{ row }">{{ row.packageCount }} / {{ row.actualPackages ?? '—' }}</template></el-table-column>
|
||||
<el-table-column label="预报/实际取货(箱)" width="180" align="right"><template #default="{ row }">{{ row.packageCount }} / {{ row.actualPackages ?? '—' }}</template></el-table-column>
|
||||
<el-table-column label="取货照片" min-width="110"><template #default="{ row }"><el-button v-for="(file, index) in pickupPhotos(row as PickupTask, 'PICKUP')" :key="file.fileId" link @click="download(file, row as PickupTask)">照片{{ index + 1 }}</el-button><span v-if="!pickupPhotos(row as PickupTask, 'PICKUP').length">—</span></template></el-table-column>
|
||||
<el-table-column label="到仓照片" min-width="110"><template #default="{ row }"><el-button v-for="(file, index) in pickupPhotos(row as PickupTask, 'ARRIVAL')" :key="file.fileId" link @click="download(file, row as PickupTask)">照片{{ index + 1 }}</el-button><span v-if="!pickupPhotos(row as PickupTask, 'ARRIVAL').length">—</span></template></el-table-column>
|
||||
</el-table>
|
||||
@@ -149,8 +154,18 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输请求尚待核
|
||||
<div v-if="detail.taskType === 'LOCAL_TRUCK' && trip.status === 'IN_TRANSIT' && editable"><label>完成凭证 <input type="file" :disabled="locked" @change="upload($event, false)" /></label><p v-for="file in truckEvidence" :key="file.fileId">{{ file.fileName }}</p></div>
|
||||
<el-button v-for="file in detail.completionEvidence || []" :key="file.fileId" link @click="download(file)">{{ file.fileName }}</el-button>
|
||||
</template>
|
||||
<AppDialog class="admin-density-dialog" v-model="pickupOpen" :title="arrivalMode ? '上传到仓照片' : '上传取货照片'" width="560px" :show-close="!locked" :close-on-click-modal="!locked" :close-on-press-escape="!locked">
|
||||
<p>{{ selected?.transferNo }}</p><el-form label-position="left" label-width="85px" :disabled="locked"><el-form-item v-if="!arrivalMode" label="实提箱数" required><el-input-number v-model="actual" :min="1" :precision="0" /></el-form-item><el-form-item label="照片" required><input type="file" accept="image/*" :disabled="locked" @change="upload($event, true)" /></el-form-item><p v-for="file in evidence" :key="file.fileId">{{ file.fileName }}</p></el-form><template #footer><el-button type="primary" :disabled="locked || !evidence.length" @click="picked">{{ arrivalMode ? '确认到仓' : '确认取货' }}</el-button></template>
|
||||
<AppDialog class="admin-density-dialog" v-model="pickupOpen" :title="arrivalMode ? '上传到仓照片' : '上传装货照片'" width="560px" :show-close="!locked" :close-on-click-modal="!locked" :close-on-press-escape="!locked">
|
||||
<p>{{ selected?.transferNo }}</p>
|
||||
<el-form label-position="left" label-width="150px" :disabled="locked">
|
||||
<el-form-item label="预报数量(箱)">{{ selected?.packageCount ?? '—' }}</el-form-item>
|
||||
<el-form-item label="实际取货数量(箱)" :required="!arrivalMode">
|
||||
<span v-if="arrivalMode">{{ selected?.actualPackages ?? '—' }}</span>
|
||||
<el-input-number v-else v-model="actual" :min="1" :max="5000" :precision="0" placeholder="填写实际取货箱数" aria-label="实际取货数量(箱)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="照片" required><input type="file" accept="image/*" :disabled="locked" @change="upload($event, true)" /></el-form-item>
|
||||
<p v-for="file in evidence" :key="file.fileId">{{ file.fileName }}</p>
|
||||
</el-form>
|
||||
<template #footer><el-button type="primary" :disabled="locked || !evidence.length || (!arrivalMode && !validPickupQuantity)" @click="picked">{{ arrivalMode ? '确认到仓' : '确认取货' }}</el-button></template>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="releaseOpen" :title="releaseKind === 'VEHICLE' ? '结束车辆任务' : '结束司机任务'" width="560px" :show-close="!locked" :close-on-click-modal="!locked"><el-form label-position="left" label-width="100px" :disabled="locked"><el-form-item label="实际结束时间" required><el-date-picker v-model="actualEnd" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item><el-form-item label="结束地点" required><el-input v-model="endLocation" /></el-form-item></el-form><template #footer><el-button type="primary" :disabled="locked || !actualEnd || !endLocation.trim()" @click="release">确认结束</el-button></template></AppDialog>
|
||||
</section>
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface Gate { allowed: boolean; blockCode: string | null; blockReason:
|
||||
export type DispatchActions = Partial<Record<DispatchAction, Gate>>
|
||||
export interface Counts { itemCount: number; palletCount: number; packageCount: number; pickedPackageCount: number; shippedPackageCount: number }
|
||||
export interface Capacity { palletSpaces: number | null; maxLoadKg: number | null; maxVolumeCbm: number | null }
|
||||
export interface DriverEvidence { licenseNo: string | null; licenseExpiresOn: string | null; fileIds: string[]; expectedTaskEndAt: string | null }
|
||||
export interface DriverEvidence { licenseNo: string | null; licenseExpiresOn: string | null; fileIds: string[]; expectedTaskEndAt: string | null; photoFileIds?: string[] }
|
||||
export interface Vehicle { mode: 'MASTER' | 'EXTERNAL'; vehicleId: string | null; plate: string | null; carrierName: string | null; capacity: Capacity | null; evidenceFileIds: string[] }
|
||||
export interface Driver { mode: 'MASTER' | 'EXTERNAL'; driverId: string | null; name: string | null; phone: string | null; evidence: DriverEvidence | null }
|
||||
export interface VehicleSnapshot extends Vehicle { vehicleStatus: string | null }
|
||||
|
||||
@@ -9,6 +9,7 @@ export function dispatchReviewEvidence(detail: DispatchDetail | null): ReviewAtt
|
||||
const result = emptyReviewAttachments()
|
||||
if (!detail) return result
|
||||
result.vehicleFileIds = [...(detail.vehicleInfo.vehicle?.evidenceFileIds || [])]
|
||||
result.driverPhotoFileIds = [...(detail.vehicleInfo.driver?.evidence?.photoFileIds || [])]
|
||||
result.licenseFileIds = [...(detail.vehicleInfo.driver?.evidence?.fileIds || [])]
|
||||
for (const { key } of dispatchReviewAttachmentFields) {
|
||||
result[key] = [...new Set([...result[key], ...(detail.reviews.dispatchReview.attachments?.[key] || []), ...(detail.reviews.loadingReview.attachments?.[key] || [])])]
|
||||
|
||||
@@ -51,7 +51,7 @@ it('confirms cancelling pickup and linked transfers using the transport version'
|
||||
expect(listDispatches).toHaveBeenCalledTimes(2)
|
||||
wrapper.unmount(); confirm.mockRestore()
|
||||
})
|
||||
it.each([['LOADING', '上传取货照片'], ['DELIVERING', '上传到仓照片'], ['DELIVERED', '结束资源任务']] as const)('opens pickup %s work from the list while detail stays separate', async (stage, label) => {
|
||||
it.each([['LOADING', '上传装货照片'], ['DELIVERING', '上传到仓照片'], ['DELIVERED', '结束资源任务']] as const)('opens pickup %s work from the list while detail stays separate', async (stage, label) => {
|
||||
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'PICKUP', stage, reviewState: 'APPROVED', actions: {} })]))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||
|
||||
@@ -108,7 +108,7 @@ function pickupExecutionLabel(row: DispatchListRow) {
|
||||
if (row.taskType !== 'PICKUP' || row.status !== 'ACTIVE' || !access.can(dispatchActionPermissions.EDIT)) return ''
|
||||
if (row.stage === 'DELIVERED') return '结束资源任务'
|
||||
if (row.reviewState !== 'APPROVED') return ''
|
||||
return row.stage === 'LOADING' ? '上传取货照片' : row.stage === 'DELIVERING' ? '上传到仓照片' : ''
|
||||
return row.stage === 'LOADING' ? '上传装货照片' : row.stage === 'DELIVERING' ? '上传到仓照片' : ''
|
||||
}
|
||||
function openTransport(row: DispatchListRow, resources = false) {
|
||||
transportResourceMode.value = resources; transportEditNo.value = row.dispatchNo
|
||||
|
||||
Reference in New Issue
Block a user