优化
This commit is contained in:
@@ -44,6 +44,8 @@
|
||||
|
||||
## 验证
|
||||
|
||||
- 排车调度/理货审核分区仅显示审核、详情;审核弹框的车辆图片、司机照片和驾驶证照片通过既有本车附件接口上传,随 `ReviewCommand.attachments` 保存,不调用资源编辑命令。已有资源与审核附件只读预览,详情合并展示;当前审核权限与服务端 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 与审核入口权限、状态。
|
||||
- 装车完成通过`completeDispatchLoading`一次提交,结果仅接受`DELIVERING`;原请求恢复不重建单据。未知费用保持未确认,由既有后台费用管理维护,不按金额推断覆盖或重复记费。已有排车加货和移货仅从`SmartDispatchDialog.vue`内的`SmartDispatchExistingCargo.vue`进入;移货必须使用`GET items`返回的真实dispatchItemId及逐项remove Gate;路线仅重排`GET route`返回的既有全部目的地,包含外接点,不能新增或猜测目的地。附件沿`DispatchEvidenceFiles.vue`的本车鉴权上传/读取,上传与主单引用保存分开。
|
||||
|
||||
@@ -6,9 +6,14 @@
|
||||
|
||||
| 页面/操作 | 依据与问题 | 修复目标 | 验收 |
|
||||
|---|---|---|---|
|
||||
| 新建排车/查询与设置 | 原型 `modal-dispatch-schedule` 查询在三栏之前,现状设置占据顶部 | 起运仓进入首个查询面板;建单方式、草稿恢复按需展开;保留区域按钮与现行真实字段 | 待验证 |
|
||||
| 两模式/选订单、托盘与装车 | 截图中中栏固定偏移、已有货物栏替换整个布局 | 三栏共享标题行和内容行,右栏插槽只更换标题与内容,不再自行定位;长标题换行一起撑高标题行 | 待验证 |
|
||||
| 已有排车/查询、加货与移除 | 两行裸露查询,货物查询被重复单号推偏 | 同一个查询面板、统一标签和输入宽度;单号及仓库移入右栏标题;保留即时保存与移除确认 | 待验证 |
|
||||
| 新建排车/查询与设置 | 原型 `modal-dispatch-schedule` 查询在三栏之前,现状设置占据顶部 | 起运仓进入首个查询面板;建单方式、草稿恢复按需展开;保留区域按钮与现行真实字段 | 定向179项通过;待人工截图确认 |
|
||||
| 两模式/选订单、托盘与装车 | 截图中中栏固定偏移、已有货物栏替换整个布局 | 三栏共享标题行和内容行,右栏插槽只更换标题与内容,不再自行定位;长标题换行一起撑高标题行 | 定向179项通过;待人工截图确认 |
|
||||
| 已有排车/查询、加货与移除 | 两行裸露查询,货物查询被重复单号推偏 | 同一个查询面板、统一标签和输入宽度;单号及仓库移入右栏标题;保留即时保存与移除确认 | 定向179项通过;待人工截图确认 |
|
||||
| 新建排车/搜索标签与分页 | 最新截图 `codex-clipboard-06b92e79-d28a-4eec-8d63-f0f856d1f380.png` 三栏已对齐,但起运仓换行、分页提示多余;用户要求仅做样式定向检查 | 按最新确认删除起运仓可见标签并默认首个可用仓;删除分页提示及对应说明入口;分页与订单表左边缘对齐 | 后续合并定向161项通过;待截图确认 |
|
||||
|
||||
本轮追加:用户要求打开时默认第一个起运仓并删除标签;无已有选择、草稿或订单指定仓时采用目录首项并读取候选,空目录保持空值。已有排车按新截图 `codex-clipboard-43654ee8-6c76-4141-bca5-4d108d45b828.png` 合并为可搜索单号的选择器,分页进入下拉底部、货物查询同排,删除重复标签和介绍文字。沿用当前查询API、明确选择后读取清单,不自动选择已有排车。6个文件161项定向回归通过(18.32秒),覆盖默认仓自动加载、空目录、草稿仓保留、下拉搜索分页及原有加货/移除;本轮不跑全量测试。应用类型检查与差异检查通过,真实样式待截图确认。
|
||||
|
||||
自动检查:7个文件179项定向回归通过;`pnpm build`(含应用类型检查)通过。标题共享网格行,三栏内容共同起始,不再使用中栏固定顶部偏移;已有排车自定义内容仍留在同一网格列内。本轮修改的4个测试文件及其组件依赖定向类型检查通过;新增查询断言修正后已有排车23项再次通过。额外全仓`typecheck:all`触及统一入口2GB堆上限,未提高资源限制;扩大到未改动的规划测试时存在既有`VueWrapper`类型报错,未据此宣称全仓测试代码类型检查通过。全量`pnpm test`通过:470文件、4336项(14:34启动,591.74秒)。验证期间三个生产组件哈希未变;测试断言查询方法的最后修正已另跑23项通过。差异空白检查通过。只调整本模块模板和布局,保留蓝白配色及现行排车契约;未部署,视觉验收待1920×1080、100%人工截图确认。
|
||||
|
||||
## 2026-10-21 智能排车截图复核与样式纠偏
|
||||
|
||||
@@ -390,3 +395,13 @@
|
||||
| 预约列表 | 新增 / 编辑 FBA 预约 | 用户截图 Image 2 与现有 WorkCreateAppointmentForm / WorkAppointmentForm 居中弹框 | 共用逐票资料补充;已有安排明确指向原预约编辑,保留原占用规则 | 定向回归通过;待人工截图确认 |
|
||||
|
||||
自动验证:本轮 Admin 全量初跑 469 文件、4333 项,4315 项通过;2 个关联文件的 18 项失败在修复后,由包含这两文件的 45 文件、426 项定向回归全部通过。最终补充文案与表单另行复测 11 项通过;最终 `pnpm build`(含应用类型检查)通过。未触发部署、未写测试环境数据;真实页面仍待人工截图确认。
|
||||
|
||||
### 2026-10-21 排车审核入口与附件
|
||||
|
||||
| 页面 | 操作 | 依据与承载 | 本次变更 | 验收 |
|
||||
|---|---|---|---|---|
|
||||
| 排车列表:调度审核、理货员审核 | 行操作 | 用户本轮确认只保留审核、详情;沿现有列表 | 去掉维护车辆信息及更多菜单,仅显示本环节审核与详情 | 本轮定向回归通过;待人工截图确认 |
|
||||
| 排车审核弹框 | 通过/驳回、上传附件 | 用户附件 codex-clipboard-249614d2-efac-4494-af47-090980981900.png;沿既有弹框 | 车辆图片、司机照片、驾驶证照片三类,原证据只读,本次附件随审核结果保存;未知结果重试原请求 | 本轮定向回归通过;待人工 1920×1080、100% 截图确认 |
|
||||
| 排车详情:车辆信息 | 查看审核附件 | 复用既有附件预览 | 汇总原资源与两级审核已保存证据,去重并只读预览 | 本轮定向回归通过;待人工截图确认 |
|
||||
|
||||
本轮自动验证:Admin 5 个定向文件 96 项通过,`pnpm typecheck` 通过;Lastleg/File 8 个定向类 63 项通过,API 路由合同 5 项通过,AGENTS 索引 1232 项通过。未部署,未进行真实页面操作;上线本次变更须包含 Admin、lastleg-service 和 file(新增司机照片文件分类)。
|
||||
|
||||
@@ -12,6 +12,15 @@ 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('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(['预览'])
|
||||
await selectFile(wrapper, new File(['pdf'], 'photo.pdf', { type: 'application/pdf' })); expect(api.uploadDispatchEvidence).not.toHaveBeenCalled()
|
||||
await selectFile(wrapper, new File(['png'], 'photo.png', { type: 'image/png' }))
|
||||
expect(api.uploadDispatchEvidence).toHaveBeenCalledWith('PC/1', 'DRIVER_PHOTO', expect.any(File))
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([['302']])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('previews and replaces real string-size metadata through the JSON parser and API boundary', async () => {
|
||||
const actual = await vi.importActual<typeof import('../api/dispatches')>('../api/dispatches')
|
||||
const envelope = (fileId: string) => parseApiJsonResponse(JSON.stringify({ code: '200', data: { ...metadata(fileId), sizeBytes: '3' } }))
|
||||
|
||||
@@ -10,13 +10,16 @@ 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 }>()
|
||||
const props = defineProps<{ dispatchNo: string; kind: DispatchEvidenceKind; disabled?: boolean; readonly?: 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 label = computed(() => props.kind === 'VEHICLE' ? '本趟车辆证据' : '本趟司机证照')
|
||||
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(() => props.kind === 'DRIVER_PHOTO' ? types.slice(1) : types)
|
||||
const uploadFormats = computed(() => props.kind === 'DRIVER_PHOTO' ? 'JPG、PNG' : 'PDF、JPG、PNG')
|
||||
let generation = 0, previewGeneration = 0, replacing: string | null = null, pendingPopup: Window | null = null
|
||||
function valid(value: DispatchEvidenceFile, expected?: string) {
|
||||
if (!value || !/^[1-9]\d*$/.test(String(value.fileId)) || typeof value.fileId === 'number' && !Number.isSafeInteger(value.fileId) || expected && String(value.fileId) !== expected || !value.fileName?.trim() || !types.includes(value.contentType) || !Number.isSafeInteger(value.sizeBytes) || value.sizeBytes <= 0 || value.sizeBytes > maximum) throw new DispatchInputError('附件元数据未确认本车有效文件,请重新读取')
|
||||
@@ -25,7 +28,7 @@ function valid(value: DispatchEvidenceFile, expected?: string) {
|
||||
function fail(cause: unknown, fallback: string) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : fallback) }
|
||||
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 = [...ids.value]
|
||||
const current = ++generation, no = props.dispatchNo, kind = props.kind, values = [...allIds.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)))
|
||||
@@ -37,7 +40,7 @@ async function upload(event: Event) {
|
||||
const element = event.target as HTMLInputElement, file = element.files?.[0]; element.value = ''
|
||||
if (!file || props.readonly || props.disabled || uploading.value) return
|
||||
error.value = null
|
||||
if (!types.includes(file.type) || file.size <= 0 || file.size > maximum) { fail(new DispatchInputError('请选择10MiB以内的PDF、JPG或PNG文件'), '附件不符合要求'); return }
|
||||
if (!uploadTypes.value.includes(file.type) || file.size <= 0 || file.size > maximum) { fail(new DispatchInputError(`请选择10MiB以内的${uploadFormats.value}文件`), '附件不符合要求'); return }
|
||||
const current = generation, no = props.dispatchNo, kind = props.kind, replaceId = replacing
|
||||
uploading.value = true; emit('busy', true)
|
||||
try {
|
||||
@@ -71,15 +74,15 @@ async function preview(id: string) {
|
||||
} catch (cause) { if (current === previewGeneration) { closePreview(); fail(cause, '本车附件预览失败,原引用已保留') } }
|
||||
finally { if (current === previewGeneration) previewLoading.value = false }
|
||||
}
|
||||
watch(() => JSON.stringify([props.dispatchNo, props.kind, ids.value]), load, { immediate: true })
|
||||
watch(() => JSON.stringify([props.dispatchNo, props.kind, allIds.value]), load, { immediate: true })
|
||||
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>PDF、JPG、PNG,单文件最大10MiB;上传后保存车辆信息才完成本车引用。</p></InlineHelp></h4>
|
||||
<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>
|
||||
<LoadErrorState v-if="error" title="本趟附件操作未完成" :message="error.message" compact retry-text="重新读取资料" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<table v-if="ids.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 ids" :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"><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>
|
||||
<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 v-if="!readonly"><input ref="input" class="evidence-input" type="file" accept="application/pdf,image/jpeg,image/png" :aria-label="'上传' + label" :disabled="disabled || uploading || !dispatchNo" @change="upload"><el-button :disabled="disabled || uploading || !dispatchNo" :loading="uploading" @click="choose()">上传{{ label }}</el-button></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>
|
||||
</AppDialog>
|
||||
|
||||
@@ -92,7 +92,37 @@ describe('dispatch workflow native layout', () => {
|
||||
dialog.vm.$emit('confirm'); await flushPromises(); expect(review).not.toHaveBeenCalled()
|
||||
await dialog.get('textarea').setValue(' 证据不完整 '); dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(review).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), decision: 'REJECTED', reason: '证据不完整' })
|
||||
expect(dialog.findAll('input[type="file"]')).toHaveLength(0)
|
||||
expect(dialog.findAll('input[type="file"]')).toHaveLength(3)
|
||||
})
|
||||
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'))
|
||||
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)
|
||||
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)
|
||||
for (const [index, files] of fields.entries()) {
|
||||
let resolve!: (value: DispatchEvidenceFile) => void
|
||||
vi.mocked(api.uploadDispatchEvidence).mockReturnValueOnce(new Promise(done => { resolve = done }))
|
||||
const input = files.get('input[type="file"]'), file = new File(['png'], 'audit.png', { type: 'image/png' })
|
||||
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()
|
||||
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'] } })
|
||||
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()
|
||||
})
|
||||
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)
|
||||
|
||||
@@ -13,7 +13,7 @@ import * as api from '../api/dispatches'
|
||||
import type { 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 { canDispatchAction, dispatchActionLabels, dispatchRejectionReason } from '../dispatchPresentation'
|
||||
import { canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, emptyReviewAttachments } from '../dispatchPresentation'
|
||||
import { isDefiniteDispatchRejection, useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
|
||||
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||
@@ -36,6 +36,8 @@ 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 reviewEvidence = computed(() => dispatchReviewEvidence(detail.value))
|
||||
const form = reactive({ plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
|
||||
let generation = 0
|
||||
let editorRequestKey = ''
|
||||
@@ -44,8 +46,8 @@ const cargoEntryMessage = '货物安排统一在智能排车中修改;如有
|
||||
const allowed = computed(() => !cargoAction.value && !factsStale.value && !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true))
|
||||
const width = computed(() => ['EDIT', 'LOADING_COMPLETE'].includes(currentAction.value) ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction.value) ? '760px' : '560px')
|
||||
const frozen = computed(() => busy.value || Boolean(pending.value))
|
||||
const evidenceBusy = reactive({ VEHICLE: false, DRIVER: false })
|
||||
const uploadingEvidence = computed(() => evidenceBusy.VEHICLE || evidenceBusy.DRIVER)
|
||||
const evidenceBusy = reactive({ VEHICLE: false, DRIVER_PHOTO: false, DRIVER: false })
|
||||
const uploadingEvidence = computed(() => Object.values(evidenceBusy).some(Boolean))
|
||||
|
||||
async function load() {
|
||||
const sequence = ++generation, no = currentNo.value, action = currentAction.value
|
||||
@@ -59,6 +61,7 @@ async function load() {
|
||||
if (sequence !== generation) return
|
||||
if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开')
|
||||
detail.value = value
|
||||
reviewAttachments.value = emptyReviewAttachments()
|
||||
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()
|
||||
driver.value = value.vehicleInfo.driver ? structuredClone(value.vehicleInfo.driver) : blankDriver()
|
||||
@@ -98,7 +101,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 } satisfies ReviewCommand
|
||||
return { ...common, decision: form.decision, reason: form.reason.trim() || null, ...(Object.values(reviewAttachments.value).some(ids => ids.length) ? { attachments: reviewAttachments.value } : {}) } satisfies ReviewCommand
|
||||
case 'CANCEL': case 'RETURN_TO_REVIEW':
|
||||
if (!form.reason.trim()) throw new DispatchInputError('请填写操作原因')
|
||||
return { ...common, reason: form.reason.trim() } satisfies ReasonCommand
|
||||
@@ -207,9 +210,12 @@ 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' && ['EDIT', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)">
|
||||
<DispatchEvidenceFiles v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" :readonly="currentAction !== 'EDIT'" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
|
||||
<DispatchEvidenceFiles v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" :readonly="currentAction !== 'EDIT'" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
|
||||
<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>
|
||||
<section v-else 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>
|
||||
|
||||
@@ -135,7 +135,7 @@ describe('smart dispatch three-column cargo board', () => {
|
||||
expect(wrapper.emitted('changePage')).toEqual([[2]])
|
||||
expect(wrapper.get('[aria-label="托盘明细"]').text()).toContain('请选择左侧订单')
|
||||
await wrapper.setProps({ page: 2, candidates: [candidate({ orderNo: 'NEXT-PAGE', palletNo: 'NEXT-PALLET' })] })
|
||||
expect(wrapper.get('[aria-label="待排订单"]').text()).toContain('货物分页,同一订单可能分布在多页')
|
||||
expect(wrapper.get('[aria-label="待排订单"]').text()).not.toContain('货物分页,同一订单可能分布在多页')
|
||||
expect(wrapper.get('[aria-label="第 1 辆车"]').text()).toContain('PALLET-1')
|
||||
expect(wrapper.findAll('[aria-label="车辆装载"] article')).toHaveLength(2)
|
||||
})
|
||||
@@ -174,7 +174,8 @@ describe('smart dispatch three-column cargo board', () => {
|
||||
slots: { vehicles: ({ dragOver, drop }: { dragOver: (event: DragEvent, id: string) => void; drop: (event: DragEvent, id: string) => void }) => h('section', { 'aria-label': '真实本车清单', onDragover: (event: DragEvent) => dragOver(event, 'PC/1'), onDrop: (event: DragEvent) => drop(event, 'PC/1') }, 'PALLET-EXISTING') },
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
expect(wrapper.find('[aria-label="车辆装载"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[aria-label="车辆装载"]').exists()).toBe(true)
|
||||
expect(wrapper.get('[aria-label="车辆装载"]').text()).not.toContain('添加车辆')
|
||||
expect(wrapper.text()).toContain('PALLET-EXISTING')
|
||||
await select(wrapper)
|
||||
const dataTransfer = transfer()
|
||||
|
||||
@@ -161,7 +161,7 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
|
||||
<template>
|
||||
<div class="dispatch-cargo-board" aria-label="智能排车三栏选货">
|
||||
<section class="cargo-board-panel cargo-board-orders" aria-label="待排订单">
|
||||
<header class="cargo-board-heading"><h3>待排订单</h3><span class="cargo-board-muted">本页 {{ orders.length }} 单</span><InlineHelp title="货物分页说明"><p>货物分页,同一订单可能分布在多页。点击订单查看本页货物关联托盘;关联托数包含必须同车的完整托盘组。</p></InlineHelp></header>
|
||||
<header class="cargo-board-heading"><h3>待排订单</h3><span class="cargo-board-muted">本页 {{ orders.length }} 单</span></header>
|
||||
<div class="cargo-board-order-content">
|
||||
<div class="cargo-board-table-wrap">
|
||||
<table class="density-table cargo-board-table">
|
||||
@@ -178,7 +178,7 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<footer class="cargo-board-pagination"><span class="cargo-board-muted">货物分页,同一订单可能分布在多页</span><el-pagination aria-label="候选货物分页" :current-page="page" :page-size="size" :page-sizes="[10, 20, 50]" :total="total" :disabled="disabled" layout="total, sizes, prev, pager, next" @update:current-page="changePage" @update:page-size="changeSize" /></footer>
|
||||
<footer class="cargo-board-pagination"><el-pagination aria-label="候选货物分页" :current-page="page" :page-size="size" :page-sizes="[10, 20, 50]" :total="total" :disabled="disabled" layout="total, sizes, prev, pager, next" @update:current-page="changePage" @update:page-size="changeSize" /></footer>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -206,7 +206,7 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
|
||||
</section>
|
||||
|
||||
<section class="cargo-board-panel cargo-board-vehicles" aria-label="车辆装载">
|
||||
<header class="cargo-board-heading"><slot name="vehicle-heading"><h3>装车托盘</h3><InlineHelp title="车辆装载说明"><p>将中间托盘拖入任意车辆,或选中车辆后点击“加入这辆车”。已装货物需先移除才能加入其他车辆。计划托位仅用于新车辆预排,不代表真实载重或容积;体积、重量以装车核对为准。</p></InlineHelp><div class="cargo-board-vehicle-tools"><slot name="vehicle-controls" /><el-button type="primary" :disabled="disabled || !warehouseSelected" @click="emit('addVehicle')">添加车辆</el-button></div></slot></header>
|
||||
<header class="cargo-board-heading"><slot name="vehicle-heading"><template v-if="!slots.vehicles"><h3>装车托盘</h3><InlineHelp title="车辆装载说明"><p>将中间托盘拖入任意车辆,或选中车辆后点击“加入这辆车”。已装货物需先移除才能加入其他车辆。计划托位仅用于新车辆预排,不代表真实载重或容积;体积、重量以装车核对为准。</p></InlineHelp><div class="cargo-board-vehicle-tools"><slot name="vehicle-controls" /><el-button type="primary" :disabled="disabled || !warehouseSelected" @click="emit('addVehicle')">添加车辆</el-button></div></template></slot></header>
|
||||
<div class="cargo-board-vehicle-content">
|
||||
<slot name="vehicles" :drag-over="dragOver" :drop="drop" :drop-target="dropTarget">
|
||||
<div class="cargo-board-vehicle-list">
|
||||
@@ -230,13 +230,13 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dispatch-cargo-board { display: grid; grid-template-columns: minmax(0, 44fr) minmax(0, 22fr) minmax(0, 34fr); grid-template-rows: auto auto; align-items: start; gap: var(--admin-space-5) var(--admin-space-6); }
|
||||
.cargo-board-panel { display: grid; grid-row: 1 / span 2; grid-template-rows: subgrid; min-width: 0; background: var(--admin-surface); }
|
||||
.dispatch-cargo-board { display: grid; grid-template-columns: minmax(0, 44fr) minmax(0, 22fr) minmax(0, 34fr); grid-template-rows: auto auto; align-items: stretch; gap: var(--admin-space-5) var(--admin-space-6); }
|
||||
.cargo-board-panel { display: grid; grid-row: 1 / span 2; grid-template-rows: subgrid; align-items: start; min-width: 0; background: var(--admin-surface); }
|
||||
.cargo-board-orders { grid-column: 1; }
|
||||
.cargo-board-pallets { grid-column: 2; }
|
||||
.cargo-board-vehicles { grid-column: 3; }
|
||||
.cargo-board-order-content, .cargo-board-vehicle-content { min-width: 0; }
|
||||
.cargo-board-heading { display: flex; align-items: center; gap: var(--admin-space-3); min-height: var(--admin-control-height); box-sizing: border-box; margin: 0; flex-wrap: wrap; }
|
||||
.cargo-board-heading { align-self: stretch; display: flex; align-items: center; gap: var(--admin-space-3); min-height: var(--admin-control-height); box-sizing: border-box; margin: 0; flex-wrap: wrap; }
|
||||
.cargo-board-heading h3 { margin: 0; font-size: var(--admin-font-section); }
|
||||
.cargo-board-vehicle-tools { display: flex; align-items: center; gap: var(--admin-space-3); margin-left: auto; flex-wrap: wrap; }
|
||||
.cargo-board-vehicle-tools > .el-button { margin-left: 0; }
|
||||
@@ -251,7 +251,7 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
|
||||
.cargo-board-table tbody tr { cursor: pointer; }
|
||||
.cargo-board-table tr.is-selected td { background: var(--admin-action-primary-hover); }
|
||||
.cargo-board-business-no { font-weight: 600; }
|
||||
.cargo-board-pagination { display: flex; flex-direction: column; align-items: flex-start; gap: var(--admin-space-3); padding: var(--admin-space-4); overflow-x: auto; }
|
||||
.cargo-board-pagination { display: flex; align-items: center; padding-top: var(--admin-space-5); overflow-x: auto; }
|
||||
.cargo-board-order-caption { margin-left: auto; max-width: 65%; overflow-wrap: anywhere; color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.cargo-board-order-caption small { display: block; }
|
||||
.cargo-board-empty { padding: var(--admin-space-7); color: var(--cc-muted); text-align: center; margin: 0; }
|
||||
@@ -266,7 +266,6 @@ watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeC
|
||||
.cargo-board-group-details summary { cursor: pointer; }
|
||||
.cargo-board-group-details ul { margin: var(--admin-space-2) 0; padding-left: var(--admin-space-6); }
|
||||
.cargo-board-block { color: var(--admin-action-danger); font-size: var(--admin-font-caption); margin: var(--admin-space-2) 0; }
|
||||
.cargo-board-vehicles { min-width: 0; }
|
||||
.cargo-board-vehicle-list { display: flex; flex-direction: column; gap: var(--admin-space-5); max-height: 58vh; overflow-y: auto; }
|
||||
.cargo-board-vehicle { border: 2px dashed var(--admin-table-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-surface); }
|
||||
.cargo-board-vehicle.is-current { border-color: var(--admin-action-primary-border); }
|
||||
|
||||
@@ -46,14 +46,47 @@ beforeEach(() => {
|
||||
async function render() { const wrapper = mount(SmartDispatchDialog, { attachTo: document.body, props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } }); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
|
||||
function view(dialog: VueWrapper) { return dialog.getComponent({ name: 'ElDialogContent' }) }
|
||||
function field(dialog: VueWrapper, label: string) { return dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
|
||||
async function selectWarehouse(dialog: VueWrapper) { const select = field(dialog, '起运仓').getComponent({ name: 'ElSelect' }); select.vm.$emit('update:modelValue', '321'); select.vm.$emit('change', '321'); await flushPromises() }
|
||||
async function selectWarehouse(dialog: VueWrapper) { const select = dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择起运仓')!; select.vm.$emit('update:modelValue', '321'); select.vm.$emit('change', '321'); await flushPromises() }
|
||||
async function emptyMode(dialog: VueWrapper) { field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'empty'); await flushPromises() }
|
||||
async function restore(dialog: VueWrapper) { dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '继续待完成排车')!.vm.$emit('change', 'server-token'); await flushPromises() }
|
||||
async function selectFirstOrder(dialog: VueWrapper) { await view(dialog).get('[aria-label="待排订单"] tbody tr button').trigger('click'); await flushPromises() }
|
||||
describe('new dispatch native layout', () => {
|
||||
it('selects the first warehouse and loads its cargo immediately without creating a draft', async () => {
|
||||
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([warehouse, { ...warehouse, id: '654', warehouseName: '纽约仓' }])
|
||||
const { dialog } = await render()
|
||||
const picker = dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择起运仓')!
|
||||
expect(picker.props('modelValue')).toBe('321')
|
||||
expect(api.listDispatchCandidates).toHaveBeenCalledWith(expect.objectContaining({ warehouseId: '321', page: 1 }))
|
||||
expect(api.createDispatchDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
it('keeps the warehouse empty and skips cargo loading when no warehouse is available', async () => {
|
||||
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([])
|
||||
const { dialog } = await render()
|
||||
expect(api.listDispatchCandidates).not.toHaveBeenCalled()
|
||||
expect(view(dialog).text()).toContain('请先选择起运仓')
|
||||
})
|
||||
it('retains the restored draft warehouse instead of selecting the first warehouse on reopen', async () => {
|
||||
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([warehouse, { ...warehouse, id: '654', warehouseName: '纽约仓' }])
|
||||
vi.mocked(api.listDispatchDrafts).mockResolvedValue([{ ...draft(), warehouseId: '654', sources: [], containers: [] }])
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 0, page: 1, size: 20, pages: 0, candidates: [], complete: true, snapshotAt: '' })
|
||||
const { wrapper, dialog } = await render()
|
||||
await restore(dialog)
|
||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ warehouseId: '654' }))
|
||||
})
|
||||
|
||||
it('keeps the restored workbench controls together and shows the selected warehouse tab', async () => {
|
||||
const { dialog } = await render()
|
||||
const content = view(dialog)
|
||||
const searchPanel = content.get('[aria-label="候选货物查询"]')
|
||||
expect(searchPanel.find('.el-form-item__label').exists()).toBe(false)
|
||||
expect(searchPanel.find('[aria-label="起运仓"]').exists()).toBe(true)
|
||||
expect(searchPanel.find('input[placeholder="订单号/入仓码"]').exists()).toBe(true)
|
||||
const settingsToggle = searchPanel.findAll('button').find(button => button.text() === '排车设置')!
|
||||
expect(content.get('[aria-label="排车设置"]').isVisible()).toBe(false)
|
||||
await settingsToggle.trigger('click')
|
||||
expect(settingsToggle.attributes('aria-expanded')).toBe('true')
|
||||
expect(content.get('[aria-label="排车设置"]').isVisible()).toBe(true)
|
||||
expect(content.get('[aria-label="排车设置"]').text()).toContain('建单方式')
|
||||
expect(content.get('[aria-label="排车设置"]').find('[aria-label="车辆选货安排"]').exists()).toBe(true)
|
||||
expect(content.text()).not.toContain('1. 排车设置')
|
||||
|
||||
@@ -89,7 +89,7 @@ async function selectOrder(dialog: VueWrapper, palletNo: string) {
|
||||
await row.get('button').trigger('click'); await flushPromises()
|
||||
}
|
||||
async function warehouseSelected(dialog: VueWrapper) {
|
||||
const picker = field(dialog, '起运仓').getComponent({ name: 'ElSelect' })
|
||||
const picker = dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择起运仓')!
|
||||
picker.vm.$emit('update:modelValue', '321'); picker.vm.$emit('change', '321'); await flushPromises()
|
||||
}
|
||||
async function join(dialog: VueWrapper, palletNo: string) { await selectOrder(dialog, palletNo); await cargoRow(dialog, palletNo).findAll('button').find(row => row.text() === '加入这辆车')!.trigger('click'); await flushPromises() }
|
||||
|
||||
@@ -94,7 +94,8 @@ it.each([undefined, null, 'bad', 9007199254740992])('requires rereading a restor
|
||||
expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).toContain('预约批次版本缺失或无效,请重新读取候选')
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
it('requires an explicit warehouse and allows saving a true empty dispatch with no fake cargo', async () => {
|
||||
it('requires a warehouse when the directory is empty and allows saving a true empty dispatch with no fake cargo', async () => {
|
||||
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([])
|
||||
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
|
||||
const state = wrapper.vm as unknown as { mode: string; warehouseId: string; submit: () => Promise<void> }
|
||||
state.mode = 'empty'; await wrapper.getComponent(AppDialog).vm.$emit('confirm'); await flushPromises(); expect(api.createDispatch).not.toHaveBeenCalled()
|
||||
|
||||
@@ -117,6 +117,7 @@ async function init() {
|
||||
if (!warehouseId.value) {
|
||||
const selectedWarehouses = new Set(props.orders.map(row => row.originWarehouseId).filter(Boolean))
|
||||
if (selectedWarehouses.size === 1 && directory.some(row => String(row.id) === [...selectedWarehouses][0])) warehouseId.value = [...selectedWarehouses][0]!
|
||||
else if (directory[0]) warehouseId.value = String(directory[0].id)
|
||||
}
|
||||
if (warehouseId.value) await loadCandidates(sequence)
|
||||
} catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车候选或草稿恢复失败,请保留原选择重试') }
|
||||
@@ -240,15 +241,13 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
|
||||
</div>
|
||||
<SmartDispatchExistingCargo v-if="workspace === 'existing' && modelValue" @saved="emit('created')" @busy="existingBusy = $event" />
|
||||
<template v-else-if="workspace === 'create'">
|
||||
<div class="density-dialog-sections">
|
||||
<div class="density-dialog-sections dispatch-workspace">
|
||||
<LoadErrorState v-if="error" title="排车未完成" :message="error.message" :loading="loading || saving || recovery.busy.value" @retry="pending || recovery.pending.value ? submit() : init()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-alert v-if="pending || recovery.pending.value" role="status" title="原请求结果尚待核对,请勿更改选择、版本或重新建单。" type="warning" :closable="false" show-icon />
|
||||
<el-button v-if="pending?.rejected" :disabled="saving || loading" @click="pending = null; persist(); init()">按明确驳回结果重新核对</el-button>
|
||||
<section class="density-dialog-section dispatch-search" aria-label="候选货物查询">
|
||||
<div class="dispatch-candidate-query">
|
||||
<el-form inline label-position="left" label-width="60px" class="density-form dispatch-origin-field">
|
||||
<el-form-item class="dispatch-warehouse-field" label="起运仓" required><el-select class="dispatch-warehouse-picker" v-model="warehouseId" :disabled="frozen || loading || Boolean(draft)" placeholder="请选择起运仓" @change="changeWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :value="String(warehouse.id)" :label="warehouse.warehouseName" /></el-select></el-form-item>
|
||||
</el-form>
|
||||
<el-select class="dispatch-warehouse-picker" aria-label="起运仓" v-model="warehouseId" :disabled="frozen || loading || Boolean(draft)" placeholder="请选择起运仓" @change="changeWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :value="String(warehouse.id)" :label="warehouse.warehouseName" /></el-select>
|
||||
<template v-if="mode === 'cargo'"><el-input v-model="query.orderNo" :disabled="frozen || loading" placeholder="订单号/入仓码" /><el-input v-model="query.destinationWarehouseCode" :disabled="frozen || loading" placeholder="目的仓码" /><el-select v-model="query.sourceStage" :disabled="frozen || loading" clearable placeholder="全部来源"><el-option label="在途预排(已离港)" value="PLAN" /><el-option label="已收货实物" value="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button type="primary" :disabled="frozen || loading || !warehouseId" @click="search">查询</el-button><el-button :disabled="frozen || loading || !warehouseId" @click="resetSearch">重置</el-button></template><el-button :aria-expanded="settingsExpanded" aria-controls="smart-dispatch-settings" @click="settingsExpanded = !settingsExpanded">排车设置</el-button></div>
|
||||
</section>
|
||||
<section v-show="settingsExpanded" id="smart-dispatch-settings" class="dispatch-context-toolbar" aria-label="排车设置">
|
||||
@@ -290,10 +289,6 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
|
||||
.dispatch-context-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3) var(--admin-space-6); }
|
||||
.dispatch-setup-fields { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-4) var(--admin-space-6); }
|
||||
.dispatch-setup-fields :deep(.el-form-item) { margin: 0; }
|
||||
.dispatch-origin-field { width: 260px; }
|
||||
.dispatch-origin-field :deep(.el-form-item) { margin: 0; width: 100%; }
|
||||
.dispatch-origin-field :deep(.el-form-item__content) { min-width: 0; }
|
||||
.dispatch-warehouse-picker { width: 100%; }
|
||||
.dispatch-draft-tools { flex-wrap: wrap; align-items: center; }
|
||||
.dispatch-draft-picker { width: 240px; }
|
||||
.dispatch-capacity-field { display: inline-flex; align-items: center; gap: var(--admin-space-3); margin: 0; }
|
||||
@@ -301,6 +296,7 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
|
||||
.dispatch-candidate-query { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.dispatch-candidate-query > .el-input { width: 180px; }
|
||||
.dispatch-candidate-query > .el-select { width: 170px; }
|
||||
.dispatch-candidate-query > .dispatch-warehouse-picker { width: 200px; }
|
||||
.dispatch-candidate-query > .el-button { margin-left: 0; }
|
||||
.dispatch-search { margin-top: var(--admin-space-5); }
|
||||
.dispatch-workspace { margin-top: var(--admin-space-5); }
|
||||
</style>
|
||||
|
||||
@@ -35,6 +35,20 @@ beforeEach(() => {
|
||||
vi.mocked(api.listDispatchCandidates).mockImplementation(async query => ({ candidates: [candidate()], page: query.page || 1, size: query.size || 20, total: 1, pages: 1, complete: true, snapshotAt: '' }))
|
||||
})
|
||||
|
||||
it('keeps both searches in one panel and the manifest inside the shared board column', async () => {
|
||||
const wrapper = await open()
|
||||
const search = wrapper.get('[aria-label="已有排车查询"]')
|
||||
expect(search.find('[aria-label="选择已有排车"]').exists()).toBe(true)
|
||||
expect(search.text()).not.toContain('查询排车')
|
||||
expect(search.find('input[aria-label="查找可排货物"]').exists()).toBe(true)
|
||||
expect(search.findAll('.existing-cargo-query > label')).toHaveLength(0)
|
||||
const column = wrapper.get('[aria-label="车辆装载"]')
|
||||
expect(column.get(':scope > .cargo-board-heading').text()).toContain('本车货物 · PC/1')
|
||||
expect(column.find(':scope > .cargo-board-vehicle-content [aria-label="当前排车货物"]').exists()).toBe(true)
|
||||
expect(wrapper.get('.dispatch-cargo-board').findAll(':scope > section > .cargo-board-heading')).toHaveLength(3)
|
||||
expect(column.text()).not.toContain('添加车辆')
|
||||
})
|
||||
|
||||
it('adds the complete hidden shared-pallet group through real board buttons, then reloads current versions', async () => {
|
||||
const [first, second] = sharedPalletCandidates()
|
||||
vi.mocked(api.listDispatchCandidates).mockImplementation(async query => ({ candidates: query.size === 100 ? [first!, second!] : [first!], page: query.page!, size: query.size!, total: 2, pages: 1, complete: true, snapshotAt: '' }))
|
||||
@@ -177,15 +191,17 @@ it('does not let a late previous dispatch read overwrite the selected dispatch',
|
||||
const wrapper = await open()
|
||||
await wrapper.setProps({ dispatchNo: 'PC/2' }); await flushPromises()
|
||||
resolve(current()); await flushPromises()
|
||||
expect(wrapper.get('[aria-label="当前排车货物"]').text()).toContain('PC/2')
|
||||
expect(wrapper.get('[aria-label="当前排车货物"]').text()).not.toContain('PC/1')
|
||||
expect(wrapper.get('[aria-label="车辆装载"] > .cargo-board-heading').text()).toContain('PC/2')
|
||||
expect(wrapper.get('[aria-label="车辆装载"] > .cargo-board-heading').text()).not.toContain('PC/1')
|
||||
})
|
||||
|
||||
it('queries and paginates active before-loading dispatches using the entered business number', async () => {
|
||||
const wrapper = await open(null)
|
||||
vi.mocked(api.listDispatches).mockImplementation(async query => ({ ...page([listRow({ taskType: 'DELIVERY' })]), page: query.page, size: query.size, total: 22, pages: 3 }))
|
||||
await wrapper.get('input[aria-label="查找已有排车"]').setValue('PC2026')
|
||||
await button(wrapper, '查询排车').trigger('click'); await flushPromises()
|
||||
await wrapper.get('[aria-label="已有排车分页"] button.btn-next').trigger('click'); await flushPromises()
|
||||
const picker = wrapper.getComponent({ name: 'ElSelect' })
|
||||
expect(picker.props('remote')).toBe(true)
|
||||
await picker.get('.el-select__wrapper').trigger('click'); await flushPromises()
|
||||
await picker.props('remoteMethod')('PC2026'); await flushPromises()
|
||||
await wrapper.getComponent({ name: 'ElPagination' }).get('button.btn-next').trigger('click'); await flushPromises()
|
||||
expect(api.listDispatches).toHaveBeenLastCalledWith({ page: 2, size: 10, status: 'ACTIVE', stage: 'BEFORE_LOADING', dispatchNo: 'PC2026' })
|
||||
})
|
||||
|
||||
@@ -24,7 +24,7 @@ const error = ref<ApiErrorDetails | null>(null)
|
||||
const candidatePage = useDispatchCandidates(error)
|
||||
const { candidates, page: cargoPage, size: cargoSize, total: cargoTotal } = candidatePage
|
||||
const rows = ref<DispatchListRow[]>([]), page = ref(1), size = ref(10), total = ref(0)
|
||||
const keyword = ref(''), queriedKeyword = ref(''), cargoKeyword = ref(''), queriedCargoKeyword = ref('')
|
||||
const queriedKeyword = ref(''), cargoKeyword = ref(''), queriedCargoKeyword = ref('')
|
||||
const selectedNo = ref(''), warehouseId = ref(''), warehouseName = ref('')
|
||||
const detail = ref<DispatchDetail | null>(null), items = ref<DispatchItems | null>(null)
|
||||
const reading = ref(false), listing = ref(false), preparing = ref(false), stale = ref(true)
|
||||
@@ -54,9 +54,9 @@ async function loadList() {
|
||||
} catch (cause) { if (alive && sequence === listGeneration) { rows.value = []; report(cause, '排车列表读取失败') } }
|
||||
finally { if (alive && sequence === listGeneration) listing.value = false }
|
||||
}
|
||||
async function searchDispatches() {
|
||||
async function searchDispatches(value: string) {
|
||||
if (frozen.value) return
|
||||
queriedKeyword.value = keyword.value.trim(); page.value = 1
|
||||
queriedKeyword.value = value.trim(); page.value = 1
|
||||
await loadList()
|
||||
}
|
||||
async function changePage(value: number) { if (!frozen.value) { page.value = value; await loadList() } }
|
||||
@@ -205,18 +205,16 @@ onBeforeUnmount(() => { alive = false; generation++; listGeneration++; candidate
|
||||
<LoadErrorState v-if="error" title="排车货物资料待核对" :message="error.message" :loading="loading || busy" retry-text="重新核对" @retry="retry"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<section class="density-dialog-section existing-cargo-search" aria-label="已有排车查询">
|
||||
<div class="existing-cargo-query">
|
||||
<label for="existing-dispatch-number">排车单号</label>
|
||||
<el-input id="existing-dispatch-number" v-model="keyword" aria-label="查找已有排车" placeholder="排车单号" :disabled="frozen || listing" @keyup.enter="searchDispatches" />
|
||||
<el-button type="primary" :disabled="frozen || listing" @click="searchDispatches">查询排车</el-button>
|
||||
<el-select :model-value="selectedNo" aria-label="选择已有排车" placeholder="选择装车前排车" :disabled="frozen || listing" @update:model-value="(value: string) => selectDispatch(value, rows.find(row => row.dispatchNo === value))">
|
||||
<el-select :model-value="selectedNo" aria-label="选择已有排车" placeholder="输入单号搜索并选择排车" filterable remote :remote-method="searchDispatches" :loading="listing" :disabled="frozen" @update:model-value="(value: string) => selectDispatch(value, rows.find(row => row.dispatchNo === value))">
|
||||
<el-option v-for="row in rows" :key="row.dispatchNo" :value="row.dispatchNo" :label="`${row.dispatchNo} · ${row.warehouseName || row.warehouseCode || '—'}`" :disabled="row.taskType !== 'DELIVERY' || row.status !== 'ACTIVE' || row.stage !== 'BEFORE_LOADING'" />
|
||||
<el-option v-if="selectedNo && !rows.some(row => row.dispatchNo === selectedNo)" :value="selectedNo" :label="selectedNo" disabled />
|
||||
<template #footer><el-pagination aria-label="已有排车分页" :current-page="page" :page-size="size" :total="total" :disabled="frozen || listing" layout="total, prev, pager, next" @update:current-page="changePage" /></template>
|
||||
</el-select>
|
||||
<el-pagination aria-label="已有排车分页" :current-page="page" :page-size="size" :total="total" :disabled="frozen || listing" layout="total, prev, pager, next" @update:current-page="changePage" />
|
||||
|
||||
<template v-if="selectedNo"><el-input id="existing-cargo-keyword" v-model="cargoKeyword" aria-label="查找可排货物" placeholder="订单号/入仓码" :disabled="locked || !canAdd" @keyup.enter="searchCargo" /><el-button type="primary" :disabled="locked || !canAdd" @click="searchCargo">查询货物</el-button><el-button :disabled="locked" @click="selectDispatch(selectedNo)">刷新本车</el-button></template>
|
||||
</div>
|
||||
<div v-if="selectedNo" class="existing-cargo-query existing-cargo-filter"><label for="existing-cargo-keyword">货物查询</label><el-input id="existing-cargo-keyword" v-model="cargoKeyword" aria-label="查找可排货物" placeholder="订单号/入仓码" :disabled="locked || !canAdd" @keyup.enter="searchCargo" /><el-button type="primary" :disabled="locked || !canAdd" @click="searchCargo">查询货物</el-button><el-button :disabled="locked" @click="selectDispatch(selectedNo)">刷新本车</el-button></div>
|
||||
|
||||
</section>
|
||||
<p v-if="!selectedNo" class="density-dialog-note">选择已有排车后,在这里统一增减本车计划货物。</p>
|
||||
<template v-if="selectedNo">
|
||||
|
||||
<p v-if="detail && !editable" class="density-dialog-note">仅有效、装车前的仓库派送排车允许调整计划货物。真实拣货仍在 PDA 完成。</p>
|
||||
@@ -243,9 +241,9 @@ onBeforeUnmount(() => { alive = false; generation++; listGeneration++; candidate
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.existing-cargo-search { display: flex; flex-direction: column; gap: var(--admin-space-5); margin-top: var(--admin-space-5); }
|
||||
.existing-cargo { margin-top: var(--admin-space-5); }
|
||||
.existing-cargo-search { display: flex; flex-direction: column; gap: var(--admin-space-5); }
|
||||
.existing-cargo-query { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.existing-cargo-query > label { width: 64px; flex: none; color: var(--cc-muted); }
|
||||
.existing-cargo-query > .el-button { margin-left: 0; }
|
||||
.existing-cargo-query > .el-input { width: 240px; }
|
||||
.existing-cargo-query > .el-select { width: 350px; }
|
||||
|
||||
@@ -17,7 +17,8 @@ export interface Driver { mode: 'MASTER' | 'EXTERNAL'; driverId: string | null;
|
||||
export interface VehicleSnapshot extends Vehicle { vehicleStatus: string | null }
|
||||
export interface Resources { vehicle: VehicleSnapshot | null; driver: Driver | null }
|
||||
export interface Destination { key: string; type: string; warehouseId: string | null; warehouseCode: string | null; warehouseName: string | null; address: string | null; postalCode: string | null; contactName: string | null; contactPhone: string | null }
|
||||
export interface Review { status: ReviewStatus; reason: string | null; reviewerId: string | null; reviewedAt: string | null; reviewRound: Version; basisHash: string | null; invalidatedReason: string | null }
|
||||
export interface ReviewAttachments { vehicleFileIds: string[]; driverPhotoFileIds: string[]; licenseFileIds: string[] }
|
||||
export interface Review { status: ReviewStatus; reason: string | null; reviewerId: string | null; reviewedAt: string | null; reviewRound: Version; basisHash: string | null; invalidatedReason: string | null; attachments?: ReviewAttachments }
|
||||
export interface Reviews { dispatchReview: Review; loadingReview: Review }
|
||||
export interface DispatchBatchReference { batchKey: string; batchNo: string | null; customerId: string; sources: Array<{ sourceKey: string; destinationKey: string; packageCount: number }>; recordVersion: Version }
|
||||
export interface DispatchBatchAllocation { planPalletId: string; batchKey: string; customerId: string; sourceKey: string; destinationKey: string; packageCount: number; recordVersion: Version }
|
||||
@@ -37,7 +38,7 @@ export interface VersionCommand { recordVersion: Version; idempotencyKey: string
|
||||
export interface UpdateDispatch extends VersionCommand { vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null }
|
||||
export interface AddCargo extends VersionCommand { cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] }
|
||||
export interface ReasonCommand extends VersionCommand { reason: string }
|
||||
export interface ReviewCommand extends VersionCommand { decision: 'APPROVED' | 'REJECTED'; reason: string | null }
|
||||
export interface ReviewCommand extends VersionCommand { decision: 'APPROVED' | 'REJECTED'; reason: string | null; attachments?: ReviewAttachments }
|
||||
export interface LoadingMeasurement { warehousePalletId: string; palletSpaces: number; weightKg: number; volumeCbm: number; deliveryIndex: number }
|
||||
export interface ConfirmedLoad { manifestHash: string; pallets: LoadingMeasurement[]; operatorId: string; confirmedAt: string }
|
||||
export interface LoadingComplete extends VersionCommand { manifestHash: string; measurements: LoadingMeasurement[]; remark: string | null }
|
||||
@@ -52,7 +53,7 @@ export interface DispatchItems { dispatchNo: string; recordVersion: Version; sta
|
||||
export interface DispatchRouteStop { index: number; name: string; address: string | null; destinationKey: string | null }
|
||||
export interface DispatchRoute { dispatchNo: string; recordVersion: Version; stops: DispatchRouteStop[]; edit: Gate }
|
||||
export interface DispatchRouteCommand extends VersionCommand { destinationKeys: string[]; reason: string }
|
||||
export type DispatchEvidenceKind = 'VEHICLE' | 'DRIVER'
|
||||
export type DispatchEvidenceKind = 'VEHICLE' | 'DRIVER_PHOTO' | 'DRIVER'
|
||||
export interface DispatchEvidenceFile { fileId: string; fileName: string; contentType: string; sizeBytes: number }
|
||||
export interface CandidateList { candidates: Candidate[]; snapshotAt: string; complete: boolean; total: number; page: number; size: number; pages: number }
|
||||
export interface DraftContainer { draftContainerId: string; sequenceNo: number; capacity: number; sourceKeys: string[] }
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
import type { DispatchAction, DispatchActions, Reviews } from './dispatchContracts'
|
||||
import type { DispatchAction, DispatchActions, DispatchDetail, ReviewAttachments, Reviews } from './dispatchContracts'
|
||||
export const dispatchReviewAttachmentFields = [
|
||||
{ key: 'vehicleFileIds', kind: 'VEHICLE', label: '车辆图片' },
|
||||
{ key: 'driverPhotoFileIds', kind: 'DRIVER_PHOTO', label: '司机照片' },
|
||||
{ key: 'licenseFileIds', kind: 'DRIVER', label: '驾驶证照片' },
|
||||
] as const
|
||||
export const emptyReviewAttachments = (): ReviewAttachments => ({ vehicleFileIds: [], driverPhotoFileIds: [], licenseFileIds: [] })
|
||||
export function dispatchReviewEvidence(detail: DispatchDetail | null): ReviewAttachments {
|
||||
const result = emptyReviewAttachments()
|
||||
if (!detail) return result
|
||||
result.vehicleFileIds = [...(detail.vehicleInfo.vehicle?.evidenceFileIds || [])]
|
||||
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] || [])])]
|
||||
}
|
||||
return result
|
||||
}
|
||||
export const dispatchActionLabels: Record<DispatchAction, string> = {
|
||||
CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', CANCEL: '取消排车',
|
||||
SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核',
|
||||
|
||||
@@ -19,6 +19,19 @@ vi.mock('@/modules/overseas-master-data', async () => ({
|
||||
const options = { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub, DispatchWorkflowDialog: true, TransportExecutionPanel: true } } }
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getDispatch).mockResolvedValue(detail()); can.mockReturnValue(false); push.mockReset(); Object.keys(query).forEach(key => delete query[key]) })
|
||||
describe('whole-dispatch four-tab detail', () => {
|
||||
it('shows saved review attachments beside resource evidence without providing upload controls', async () => {
|
||||
const value = detail(); value.vehicleInfo.vehicle!.evidenceFileIds = ['301']
|
||||
value.reviews.dispatchReview.attachments = { vehicleFileIds: ['301', '302'], driverPhotoFileIds: ['303'], licenseFileIds: [] }
|
||||
value.reviews.loadingReview.attachments = { vehicleFileIds: [], driverPhotoFileIds: [], licenseFileIds: ['304'] }
|
||||
vi.mocked(getDispatch).mockResolvedValue(value)
|
||||
vi.mocked(getDispatchEvidence).mockImplementation(async (_, __, fileId) => ({ fileId, fileName: `审核附件${fileId}.png`, contentType: 'image/png', sizeBytes: 3 }))
|
||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||
expect(vi.mocked(getDispatchEvidence).mock.calls).toEqual([['PC/1', 'VEHICLE', '301'], ['PC/1', 'VEHICLE', '302'], ['PC/1', 'DRIVER_PHOTO', '303'], ['PC/1', 'DRIVER', '304']])
|
||||
expect(wrapper.findAll('input[type="file"]')).toHaveLength(0)
|
||||
expect(wrapper.text()).toContain('司机照片')
|
||||
expect(wrapper.text()).toContain('审核附件304.png')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each(['dispatchReview', 'loadingReview'] as const)('keeps the %s rejection readable in the detail', async reviewKey => {
|
||||
const value = detail()
|
||||
value.reviews[reviewKey] = { ...value.reviews[reviewKey], status: 'REJECTED', reason: '司机资料需要更正', invalidatedReason: '资源已修改,待重新提交' }
|
||||
|
||||
@@ -13,7 +13,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getDispatch } from '../api/dispatches'
|
||||
import type { DispatchAction, DispatchDetail } from '../dispatchContracts'
|
||||
import { stageLabel } from '../dispatchContracts'
|
||||
import { canDispatchAction, dispatchActionLabels, dispatchRejectionReason, displayDispatchValue as text } from '../dispatchPresentation'
|
||||
import { canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, displayDispatchValue as text } from '../dispatchPresentation'
|
||||
import TransportExecutionPanel from '../components/TransportExecutionPanel.vue'
|
||||
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
|
||||
import DispatchEvidenceFiles from '../components/DispatchEvidenceFiles.vue'
|
||||
@@ -24,6 +24,7 @@ import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const dispatchNo = computed(() => String(route.params.dispatchNo || ''))
|
||||
const detail = ref<DispatchDetail | null>(null), activeTab = ref('vehicle'), loading = ref(false), loadError = ref<ApiErrorDetails | null>(null)
|
||||
const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value))
|
||||
const rejectionError = ref<ApiErrorDetails | null>(null)
|
||||
const operation = ref<'ROUTE' | undefined>()
|
||||
const action = ref<DispatchAction>('EDIT'), actionOpen = ref(false)
|
||||
@@ -87,7 +88,7 @@ onBeforeUnmount(() => { generation++ })
|
||||
<section v-else-if="detail" class="detail-section" aria-label="排车操作"><div class="density-actions"><el-button v-for="value in visibleActions" :key="value" class="density-action" @click="openAction(value)">{{ dispatchActionLabels[value] }}</el-button><el-button v-if="canDispatchAction(detail?.actions, 'EDIT', access.can)" @click="openAction('EDIT', 'ROUTE')">调整卸货顺序</el-button></div></section>
|
||||
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理(车辆信息),请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
|
||||
<el-tabs v-if="!detail?.taskType || detail.taskType === 'DELIVERY'" v-model="activeTab" class="density-element-tabs density-contained-tabs">
|
||||
<el-tab-pane name="vehicle"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span><span>车辆信息</span></template><dl class="detail-fields density-fields"><div v-for="field in vehicleFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchEvidenceFiles :model-value="detail?.vehicleInfo.vehicle?.evidenceFileIds || []" :dispatch-no="dispatchNo" kind="VEHICLE" readonly /><DispatchEvidenceFiles :model-value="detail?.vehicleInfo.driver?.evidence?.fileIds || []" :dispatch-no="dispatchNo" kind="DRIVER" 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"><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></el-tab-pane>
|
||||
<el-tab-pane name="documents"><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>单据信息</span></template>
|
||||
<dl class="detail-fields density-fields"><div v-for="field in documentFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div><div class="linked-documents"><dt>关联派送单</dt><dd><template v-for="delivery in detail?.documents.deliveries || []" :key="delivery.deliveryNo"><el-button v-if="access.can(permission.deliveryList)" link type="primary" @click="router.push({ name: 'overseas-delivery-order-detail', params: { deliveryNo: delivery.deliveryNo } })">{{ delivery.deliveryNo }}</el-button><span v-else>{{ text(delivery.deliveryNo) }}</span></template><span v-if="!detail?.documents.deliveries.length">—</span></dd></div></dl>
|
||||
|
||||
@@ -68,11 +68,17 @@ it('keeps vehicle maintenance and detail visible while submitting review remains
|
||||
})
|
||||
it.each([['DISPATCH_REVIEW', 1, '调度审核'], ['LOADING_REVIEW', 2, '理货员审核']] as const)('prioritizes the current %s queue action with a short label', async (action, index, label) => {
|
||||
const allowed = { allowed: true, blockCode: null, blockReason: null }
|
||||
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ actions: { EDIT: allowed, [action]: allowed } })]))
|
||||
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ actions: { EDIT: allowed, CANCEL: allowed, SUBMIT_REVIEW: allowed, DISPATCH_REVIEW: allowed, LOADING_REVIEW: allowed } })]))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
await wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[index]!.trigger('click'); await flushPromises()
|
||||
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||
expect(row.findAll('button').map(button => button.text())).toEqual(['审核', '详情', '更多'])
|
||||
expect(row.findAll('button').map(button => button.text())).toEqual(['审核', '详情'])
|
||||
expect((row.props('actions') as Array<{ key: string }>).map(item => item.key)).toEqual([action, 'detail'])
|
||||
for (const key of ['EDIT', 'CANCEL', 'SUBMIT_REVIEW', 'route-order', 'bol']) {
|
||||
row.vm.$emit('action', key); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
|
||||
}
|
||||
expect(getDispatchBolPdf).not.toHaveBeenCalled()
|
||||
await row.get(`button[aria-label="${label}"]`).trigger('click'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action, dispatchNo: 'PC/1' })
|
||||
wrapper.unmount()
|
||||
@@ -85,6 +91,16 @@ it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('recovers %s only inside smart d
|
||||
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each([1, 2])('keeps only details in audit tab %s without its review permission', async index => {
|
||||
can.mockReturnValue(false)
|
||||
const allowed = { allowed: true, blockCode: null, blockReason: null }
|
||||
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ actions: { EDIT: allowed, DISPATCH_REVIEW: allowed, LOADING_REVIEW: allowed } })]))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
await wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[index]!.trigger('click'); await flushPromises()
|
||||
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||
expect((row.props('actions') as Array<{ key: string }>).map(item => item.key)).toEqual(['detail'])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('shows four working phases and uses server stage counts instead of current page length', async () => {
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const tabs = wrapper.get('.data-table__toolbar-start .workbench-list-tabs[aria-label="排车执行阶段"]').findAll('button')
|
||||
|
||||
@@ -80,13 +80,16 @@ function switchSection(value: BeforeLoadingSection) { section.value = value; pag
|
||||
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', 'CANCEL']
|
||||
function rowActions(row: DispatchListRow): RowActionItem[] {
|
||||
if (showSections.value && section.value !== 'VEHICLE_INFO') {
|
||||
const review = section.value
|
||||
return [...(canDispatchAction(row.actions, review, access.can) ? [{ key: review, label: '审核', ariaLabel: dispatchActionLabels[review], priority: 600 }] : []), { key: 'detail', label: '详情', priority: 500 }]
|
||||
}
|
||||
if (row.taskType && row.taskType !== 'DELIVERY') return [{ key: 'detail', label: '运输操作', priority: 500 }]
|
||||
const available = rowActionOrder.filter(value => canDispatchAction(row.actions, value, access.can))
|
||||
const sectionAction = showSections.value && section.value !== 'VEHICLE_INFO' ? section.value : 'EDIT'
|
||||
const primaryAction = available.find(value => value === sectionAction) ?? available[0]
|
||||
const primaryAction = available.find(value => value === 'EDIT') ?? available[0]
|
||||
return [...available.map((value, index) => ({
|
||||
key: value,
|
||||
label: showSections.value && value === section.value ? '审核' : dispatchActionLabels[value],
|
||||
label: dispatchActionLabels[value],
|
||||
ariaLabel: dispatchActionLabels[value],
|
||||
priority: value === primaryAction ? 600 : 400 - index * 10,
|
||||
tone: value === 'CANCEL' ? 'danger' as const : undefined,
|
||||
@@ -94,6 +97,7 @@ function rowActions(row: DispatchListRow): RowActionItem[] {
|
||||
}
|
||||
function handleAction(value: string, row: DispatchListRow) {
|
||||
if (value === 'detail') return openDetail(row)
|
||||
if (showSections.value && section.value !== 'VEHICLE_INFO' && value !== section.value) return
|
||||
if (value === 'bol') return void openBol(row)
|
||||
rejectionError.value = null
|
||||
if (value === 'route-order') { if (!canDispatchAction(row.actions, 'EDIT', access.can)) return; operation.value = 'ROUTE'; action.value = 'EDIT'; actionNo.value = row.dispatchNo; actionOpen.value = true; return }
|
||||
|
||||
@@ -139,10 +139,11 @@ describe('inbound appointment native dialogs', () => {
|
||||
})
|
||||
|
||||
describe('inbound plan and pallet move native dialogs', () => {
|
||||
it('shows plan identity and version as text and saves the selected master warehouse through fullscreen', async () => {
|
||||
it('shows plan identity and saves the selected master warehouse with the internal version through fullscreen', async () => {
|
||||
const { dialog } = await openEditPlan()
|
||||
expect(dialog.get('[aria-label="入库计划来源"]').findAll('input')).toHaveLength(0)
|
||||
expect(dialog.get('[aria-label="入库计划来源"]').text()).toContain('版本0')
|
||||
expect(dialog.get('[aria-label="入库计划来源"]').text()).toContain('入库单号')
|
||||
expect(dialog.get('[aria-label="入库计划来源"]').text()).not.toContain('版本')
|
||||
const select = dialog.findComponent({ name: 'ElSelect' })
|
||||
select.vm.$emit('update:modelValue', warehouse.id); select.vm.$emit('change', warehouse.id); await flushPromises()
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||
|
||||
@@ -264,7 +264,7 @@ onMounted(() => { void load() })
|
||||
</AppDialog>
|
||||
<SmartDispatchDialog v-model="smartDispatchOpen" :orders="selectedRows" @created="selectedRows = []; load()" />
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="planEditOpen" :title="`编辑入库单 · ${planEditTarget?.orderNo || '—'}`" fullscreenable confirm-text="保存" :loading="planEditLoading" :confirm-disabled="planEditLoading || !planEditForm.warehouseId" :confirm-loading="planEditSaving" @confirm="submitPlanEdit"><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>{{ planEditTarget?.orderNo || '—' }}</td><th scope="row">版本</th><td>{{ planEditForm.version }}</td></tr></tbody></table></section>
|
||||
<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>{{ planEditTarget?.orderNo || '—' }}</td></tr></tbody></table></section>
|
||||
<LoadErrorState v-if="mutationError" compact title="入库单保存失败" :message="mutationError.message" :loading="planEditSaving" retry-text="重试保存" @retry="submitPlanEdit"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="目的仓登记"><h3 class="density-dialog-section__title">目的仓登记</h3><el-form class="density-form" label-position="left" label-width="100px" :disabled="planEditLoading || planEditSaving"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="目的仓" required><el-select v-model="planEditForm.warehouseId" filterable placeholder="请选择目的仓" @change="(id: string) => { const w = warehouses.find((item) => item.id === id); planEditForm.warehouseCode = w?.warehouseCode || ''; planEditForm.warehouseName = w?.warehouseName || '' }"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.id" /></el-select></el-form-item></td></tr></tbody></table></el-form></section>
|
||||
</div></AppDialog>
|
||||
|
||||
@@ -1301,6 +1301,8 @@ describe('approved Admin density ownership', () => {
|
||||
expect(source).not.toContain('class="density-dialog-section dispatch-draft-toolbar"')
|
||||
expect(source).toContain('<template #vehicle-controls>')
|
||||
expect(source).toContain('class="density-form dispatch-setup-fields"')
|
||||
expect(source).toContain('class="dispatch-warehouse-picker" aria-label="起运仓"')
|
||||
expect(source).not.toContain('class="dispatch-warehouse-field" label="起运仓"')
|
||||
expect(source).not.toContain('data-tone="work"')
|
||||
expect(source).not.toContain('data-tone="summary"')
|
||||
expect(source).not.toContain('data-tone="source"')
|
||||
@@ -1314,6 +1316,10 @@ describe('approved Admin density ownership', () => {
|
||||
expect(viewport).not.toMatch(/(?:^|;)\s*height:/)
|
||||
}
|
||||
expect(boardCss).not.toContain('min-height: 74px')
|
||||
expect(boardCss).toContain('grid-template-rows: subgrid')
|
||||
expect(boardCss).toContain('grid-row: 1 / span 2')
|
||||
expect(boardCss).not.toMatch(/\.cargo-board-pallets\s*\{[^}]*margin-top:/)
|
||||
expect(board).toContain('<slot name="vehicle-heading">')
|
||||
expect(source).not.toContain('admin-density-workspace')
|
||||
|
||||
expect(board).toContain('aria-label="待排订单"')
|
||||
|
||||
Reference in New Issue
Block a user