diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index d1e35f71..ffe1e655 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -768,7 +768,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) }) - + diff --git a/src/modules/overseas-warehouse/AGENTS.md b/src/modules/overseas-warehouse/AGENTS.md index 3cca352a..f7cb91c0 100644 --- a/src/modules/overseas-warehouse/AGENTS.md +++ b/src/modules/overseas-warehouse/AGENTS.md @@ -58,6 +58,8 @@ ## 验证 +- 排车调度/理货审核分区仅显示审核、详情;车辆图片、司机照片和驾驶证照片在维护车辆司机时通过既有本车附件接口上传,随资源编辑命令保存,司机照片使用 `driver.evidence.photoFileIds`。三类照片横向并排、名称显示在照片下方;审核弹框只读预览,合并展示资源照片及历史审核附件。维护权限与服务端 Gate 控制上传及保存,审核权限控制审核提交,列表查看权限控制读取。 + - 纯文案/纯展示只核对差异;业务逻辑变化选择直接对应测试,不因文档引用某测试就自动执行。 - 转运审核组件/请求逻辑变化:定向选择 `components/WarehouseTransferReviewDialog.test.ts`、`api/warehouseTransfers.test.ts`;入口、权限或状态变化再加 `pages/prototypeContract.test.ts`。 - 出库候选/选择变化选 `pages/OutboundsView.selection.test.ts`;状态/取消变化选 `pages/OutboundsView.test.ts`、`pages/OutboundDetailView.test.ts`。排车选择受影响组件或详情页测试,入口变化再加原型合同。 diff --git a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md index ca34d513..155fb6c8 100644 --- a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md @@ -417,17 +417,24 @@ | 页面 | 操作 | 依据与承载 | 本次变更 | 验收 | |---|---|---|---|---| | 排车列表:调度审核、理货员审核 | 行操作 | 用户本轮确认只保留审核、详情;沿现有列表 | 去掉维护车辆信息及更多菜单,仅显示本环节审核与详情 | 本轮定向回归通过;待人工截图确认 | -| 排车审核弹框 | 通过/驳回、上传附件 | 用户附件 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 项自动回归通过;真实页面待人工截图确认。 + ## 排车管理资料统一入口(2026-10-21) | 页面 | 操作 | 原型/确认依据 | 承载与实现 | 验收 | |---|---|---|---|---| | 排车列表:装车中、装车后、派送中、配送完成 | 管理资料 | 用户管理资料截图及同车/派送单资料共用确认;签收完成即可,其他可补 | 1100px居中可全屏弹框,签收文件/油耗图片/装车资料三个页签;装车中及装车后默认打开装车资料,可同步上传,确认装车且资料齐全后自动派送;按关联派送单切换,共用原派送资料编辑器;油耗沿本车附加照片保存 | 自动回归单独报告;1920×1080、100%待人工截图确认 | -| 仓库派送排车列表/详情 → 维护车辆信息 | 提交审核入口合并 | 2026-10-21 用户确认:隐藏调整卸货顺序,提交审核移入车辆维护弹框,不需要备注 | 沿用原居中弹框;底部保存/提交审核,提交先保存本次资料再按新版本送审;去掉维护和提交备注输入,保留审核驳回原因 | 定向自动检查单独报告;1920×1080、100%待人工截图确认 | +| 仓库派送排车列表 → 维护车辆 | 提交审核入口合并 | 2026-10-21 用户确认:隐藏调整卸货顺序,提交审核移入车辆维护弹框,不需要备注 | 沿用原居中弹框;底部保存/提交审核,提交先保存本次资料及三类照片再按新版本送审;去掉维护和提交备注输入,保留审核驳回原因;详情保持只读 | 定向自动检查单独报告;1920×1080、100%待人工截图确认 | diff --git a/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.test.ts b/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.test.ts index 2e4ed725..bef6dc4f 100644 --- a/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.test.ts +++ b/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.test.ts @@ -12,6 +12,28 @@ const options = { props: { modelValue: ['301'], dispatchNo: 'PC/1', kind: 'VEHIC const button = (wrapper: ReturnType, label: string) => wrapper.findAll('button').find(value => value.text() === label)! async function selectFile(wrapper: ReturnType, 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('uses the dialog body for the image and keeps loading until the browser decodes it', async () => { const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises() await button(wrapper, '预览').trigger('click'); await flushPromises() diff --git a/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.vue b/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.vue index 86b9aaf1..af312a78 100644 --- a/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.vue +++ b/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.vue @@ -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({ required: true }) const allIds = computed(() => [...new Set([...(props.referenceIds || []), ...ids.value])]) const emit = defineEmits<{ busy: [value: boolean] }>() const metadata = ref>({}), error = ref(null), uploading = ref(false), input = ref(null) const imageOpen = ref(false), imageUrl = ref(''), imageName = ref(''), previewLoading = ref(false) +const thumbnails = ref>({}) const previewId = ref(''), imageDecoding = ref(false), imageFailure = ref('') const previewBusy = computed(() => previewLoading.value || imageDecoding.value) const label = computed(() => props.label || (props.kind === 'VEHICLE' ? '本趟车辆证据' : props.kind === 'DRIVER_PHOTO' ? '司机照片' : '本趟司机证照')) @@ -42,11 +43,22 @@ function imageFailed(event: Event) { } 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) { @@ -65,6 +77,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 @@ -73,15 +96,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 { imageDecoding.value = true; imageUrl.value = url } @@ -92,10 +108,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) }) + diff --git a/src/modules/overseas-warehouse/components/TransportExecutionPanel.test.ts b/src/modules/overseas-warehouse/components/TransportExecutionPanel.test.ts new file mode 100644 index 00000000..9d63ad00 --- /dev/null +++ b/src/modules/overseas-warehouse/components/TransportExecutionPanel.test.ts @@ -0,0 +1,84 @@ +import { mount, flushPromises } from '@vue/test-utils' +import ElementPlus from 'element-plus' +import { beforeEach, expect, it, vi } from 'vitest' +import TransportExecutionPanel from './TransportExecutionPanel.vue' +import { commandTransport, getTransport, uploadTransport, type TransportDetail } from '../api/transportTasks' + +vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn() })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) +vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), pickupStatusLabels: { ASSIGNED: '待取货', PICKED: '已取货', ARRIVED: '已到仓' } })) +vi.mock('../api/transportTasks', () => ({ getTransport: vi.fn(), assignTransport: vi.fn(), transportOccupancy: vi.fn(), cancelTransport: vi.fn(), releaseTransport: vi.fn(), commandTransport: vi.fn(), uploadTransport: vi.fn(), downloadTransportEvidence: vi.fn(), transportLabels: { PICKUP: '到仓取货', LOCAL_TRUCK: '本土卡车' } })) +const facts = (arriving = false): TransportDetail => ({ dispatchNo: 'PC1', taskType: 'PICKUP', execution: { trip: { status: arriving ? 'IN_TRANSIT' : 'PLANNED', recordVersion: 4, warehouseName: '洛杉矶仓', vehicleId: '1', vehiclePlate: 'CA-1', driverId: '2', driverName: '司机甲', driverPhone: '555' } }, pickups: [{ customerId: 'C1', transferNo: 'WT1', packageCount: 5, actualPackages: arriving ? 5 : null, status: arriving ? 'PICKED' : 'ASSIGNED', evidence: [] }], completionEvidence: [] }) as unknown as TransportDetail +const options = { global: { plugins: [ElementPlus] } } +beforeEach(() => { + vi.clearAllMocks(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }) + vi.mocked(getTransport).mockResolvedValue(facts()) + vi.mocked(uploadTransport).mockResolvedValue({ fileId: '88', fileName: '现场.jpg', contentType: 'image/jpeg', sizeBytes: 10 }) +}) +it.each([false, true])('confirms the %s photo stage with its own evidence and current transport version', async arriving => { + vi.mocked(getTransport).mockResolvedValue(facts(arriving)) + 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() + 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: 3 }) })) + if (arriving) expect(vi.mocked(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(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 + value.execution.trip.driverId = null + vi.mocked(getTransport).mockResolvedValue(value) + const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true, readonly: true } }); await flushPromises() + expect(wrapper.text()).toContain('WT1') + expect(wrapper.text()).toContain('洛杉矶仓') + expect(wrapper.findAll('input, select, textarea')).toHaveLength(0) + expect(wrapper.findAll('button')).toHaveLength(0) + expect(wrapper.findAll('th').map(column => column.text())).not.toContain('操作') + 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() +}) diff --git a/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue b/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue index 0b53beb5..1d593f69 100644 --- a/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue +++ b/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue @@ -1,7 +1,10 @@ + + diff --git a/src/modules/overseas-warehouse/dispatchContracts.ts b/src/modules/overseas-warehouse/dispatchContracts.ts index 7d470107..915b7a96 100644 --- a/src/modules/overseas-warehouse/dispatchContracts.ts +++ b/src/modules/overseas-warehouse/dispatchContracts.ts @@ -11,7 +11,7 @@ export interface Gate { allowed: boolean; blockCode: string | null; blockReason: export type DispatchActions = Partial> 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 } diff --git a/src/modules/overseas-warehouse/dispatchPresentation.ts b/src/modules/overseas-warehouse/dispatchPresentation.ts index a5601b9e..7830d4b1 100644 --- a/src/modules/overseas-warehouse/dispatchPresentation.ts +++ b/src/modules/overseas-warehouse/dispatchPresentation.ts @@ -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] || [])])] diff --git a/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts b/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts index 22b1e52e..7dd1b295 100644 --- a/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts +++ b/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts @@ -61,19 +61,15 @@ describe('whole-dispatch four-tab detail', () => { const wrapper = mount(DispatchDetailView, options); await flushPromises() expect(wrapper.text()).toContain('已退回客服处理(车辆信息)') expect(wrapper.text()).toContain('司机资料需要更正') - vi.mocked(getDispatch).mockResolvedValue(detail({ recordVersion: 2 })) - wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).vm.$emit('saved'); await flushPromises() - expect(wrapper.text()).not.toContain('司机资料需要更正') + expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).exists()).toBe(false) wrapper.unmount() }) - it('keeps cargo additions and removals out of the detail even when authorized', async () => { + it('keeps every write action out of the detail even when authorized', async () => { can.mockReturnValue(true) vi.mocked(getDispatch).mockResolvedValueOnce(detail({ actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null }, REMOVE_CARGO: { allowed: true, blockCode: null, blockReason: null } } })) const wrapper = mount(DispatchDetailView, options); await flushPromises() - const actions = wrapper.get('[aria-label="排车操作"]').findAll('button').map(button => button.text()) - expect(actions).toContain('维护车辆') - expect(wrapper.findAll('button').map(button => button.text())).not.toContain('调整卸货顺序') - expect(wrapper.findAll('button').map(button => button.text())).not.toContain('提交调度审核') + const actions = wrapper.findAll('button').map(button => button.text()) + expect(actions).not.toContain('维护车辆') expect(actions).not.toContain('添加货物') expect(actions).not.toContain('移除货物') wrapper.unmount() @@ -82,19 +78,18 @@ describe('whole-dispatch four-tab detail', () => { vi.mocked(getDispatch).mockResolvedValueOnce({ ...detail(), taskType }) const wrapper = mount(DispatchDetailView, options); await flushPromises() expect(wrapper.get('transport-execution-panel-stub').attributes('dispatchno')).toBe('PC/1') + expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props('readonly')).toBe(true) expect(wrapper.findAll('[role="tab"]')).toHaveLength(0) expect(wrapper.find('[aria-label="排车操作"]').exists()).toBe(false); wrapper.unmount() }) - it('keeps return-to-review in the footer across tabs and opens the existing workflow', async () => { + it('keeps detail tabs read-only when return-to-review is available from the list', async () => { can.mockReturnValue(true) vi.mocked(getDispatch).mockResolvedValueOnce(detail({ stage: 'LOADING', actions: { RETURN_TO_REVIEW: { allowed: true, blockCode: null, blockReason: null } } })) const wrapper = mount(DispatchDetailView, options); await flushPromises() await wrapper.findAll('[role="tab"]')[1]!.trigger('click'); await flushPromises() - const footer = wrapper.get('footer[aria-label="排车操作"]') - expect(footer.isVisible()).toBe(true) - expect(wrapper.get('.el-tabs').element.compareDocumentPosition(footer.element) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() - await footer.get('button').trigger('click') - expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'RETURN_TO_REVIEW' }) + expect(wrapper.find('footer[aria-label="排车操作"]').exists()).toBe(false) + expect(wrapper.findAll('button').map(button => button.text())).not.toContain('退回装车前重审') + expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).exists()).toBe(false) wrapper.unmount() }) it('keeps dispatch labels and placeholders visible when no schedule or appointments are recorded', async () => { @@ -175,7 +170,7 @@ describe('whole-dispatch four-tab detail', () => { const wrapper = mount(DispatchDetailView, options); await flushPromises() expect(wrapper.text()).not.toContain('尚未安排派送'); expect(wrapper.findAll('button').some(button => button.text() === '确认发车')).toBe(false); wrapper.unmount() }) - it('keeps the allowed Admin action without presenting unrelated stage or PDA permission refusals as page warnings', async () => { + it('shows stage facts without execution actions or unrelated permission refusals', async () => { can.mockReturnValue(true) vi.mocked(getDispatch).mockResolvedValueOnce(detail({ stage: 'LOADING', actions: { LOADING_COMPLETE: { allowed: true, blockCode: null, blockReason: null }, @@ -187,8 +182,8 @@ describe('whole-dispatch four-tab detail', () => { expect(wrapper.text()).not.toContain('当前阶段或作业事实不满足操作条件') expect(wrapper.text()).not.toContain('当前无此操作权限') expect(wrapper.findAll('button').some(button => button.text() === '维护车辆')).toBe(false) - await wrapper.findAll('button').find(button => button.text() === '确认装车完成')!.trigger('click') - expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, dispatchNo: 'PC/1', action: 'LOADING_COMPLETE' }) + expect(wrapper.findAll('button').some(button => button.text() === '确认装车完成')).toBe(false) + expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).exists()).toBe(false) wrapper.unmount() }) it('retains a real attachment A0404 failure and its request diagnostics in the vehicle tab', async () => { @@ -212,45 +207,16 @@ describe('whole-dispatch four-tab detail', () => { Object.assign(query, { fleetOfferNo: 'FO-1', fleetBookingNo: 'FB-2' }); const wrapper = mount(DispatchDetailView, options); await flushPromises(); await wrapper.get('button.density-back').trigger('click') expect(push).toHaveBeenCalledWith({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'FO-1' }, query: { inquiry: 'FB-2' } }); wrapper.unmount() }) - it.each(['saved', 'rejected'] as const)('refreshes detail actions after the workflow %s event and retains a real rejection after closure', async event => { + it('does not mount review or mutation workflows for an authorized administrator', async () => { can.mockReturnValue(true) - vi.mocked(getDispatch).mockResolvedValueOnce(detail({ actions: { EDIT: { allowed: true, blockCode: null, blockReason: null } } })) + const allowed = { allowed: true, blockCode: null, blockReason: null } + vi.mocked(getDispatch).mockResolvedValueOnce(detail({ actions: { EDIT: allowed, CANCEL: allowed, SUBMIT_REVIEW: allowed, DISPATCH_REVIEW: allowed, LOADING_REVIEW: allowed } })) const wrapper = mount(DispatchDetailView, options); await flushPromises() - await wrapper.findAll('button').find(button => button.text() === '维护车辆')!.trigger('click') - vi.mocked(getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { EDIT: { allowed: false, blockCode: 'ALREADY_SUBMITTED', blockReason: '已提交' } } })) - const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' }) - if (event === 'rejected') dialog.vm.$emit('rejected', { message: '本轮审核已经提交', code: 'A0409', requestId: 'detail-reject-request' }) - else dialog.vm.$emit('saved') - dialog.vm.$emit('update:modelValue', false) - await flushPromises() - expect(getDispatch).toHaveBeenCalledTimes(2) - expect(wrapper.findAll('button').some(button => button.text() === '维护车辆')).toBe(false) + expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).exists()).toBe(false) + expect(wrapper.find('[aria-label="排车操作"]').exists()).toBe(false) + expect(wrapper.findAll('input, textarea')).toHaveLength(0) expect(wrapper.findAll('[role="tab"]')).toHaveLength(4) - if (event === 'rejected') { - expect(wrapper.text()).toContain('本轮审核已经提交') - expect(wrapper.text()).toContain('A0409') - expect(wrapper.text()).toContain('detail-reject-request') - } - expect(wrapper.text()).not.toContain('结果尚待核对') - wrapper.unmount() - }) - it('retains a workflow rejection when the parent detail refresh also fails', async () => { - const wrapper = mount(DispatchDetailView, options); await flushPromises() - vi.mocked(getDispatch).mockRejectedValueOnce(new Error('detail read failed')) - wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).vm.$emit('rejected', { message: '已提交,请刷新', code: 'A0409' }) - await flushPromises() - expect(wrapper.text()).toContain('已提交,请刷新') - expect(wrapper.text()).toContain('A0409') - expect(wrapper.text()).toContain('排车详情加载失败') - wrapper.unmount() - }) - it('clears an earlier input rejection after the corrected workflow succeeds', async () => { - const wrapper = mount(DispatchDetailView, options); await flushPromises() - const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' }) - dialog.vm.$emit('rejected', { message: '请补齐本趟资料', code: 'A0400' }); await flushPromises() - expect(wrapper.text()).toContain('请补齐本趟资料') - dialog.vm.$emit('saved'); await flushPromises() - expect(wrapper.text()).not.toContain('请补齐本趟资料') + expect(getDispatch).toHaveBeenCalledTimes(1) wrapper.unmount() }) }) diff --git a/src/modules/overseas-warehouse/pages/DispatchDetailView.vue b/src/modules/overseas-warehouse/pages/DispatchDetailView.vue index bbceebc6..0d463068 100644 --- a/src/modules/overseas-warehouse/pages/DispatchDetailView.vue +++ b/src/modules/overseas-warehouse/pages/DispatchDetailView.vue @@ -11,11 +11,10 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import { getDispatch } from '../api/dispatches' -import type { DispatchAction, DispatchDetail } from '../dispatchContracts' +import type { DispatchDetail } from '../dispatchContracts' import { stageLabel } from '../dispatchContracts' -import { dispatchExceptionLabels, dispatchReturnDirectionLabels, canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, displayDispatchValue as text } from '../dispatchPresentation' +import { dispatchExceptionLabels, dispatchReturnDirectionLabels, 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' import OverseasDetailShell from '../components/OverseasDetailShell.vue' import { formatOverseasDateTime as time } from '../lastMilePresentation' @@ -25,11 +24,6 @@ const route = useRoute(), router = useRouter(), access = useAccessPreviewStore() const dispatchNo = computed(() => String(route.params.dispatchNo || '')) const detail = ref(null), activeTab = ref('vehicle'), loading = ref(false), loadError = ref(null) const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value)) -const rejectionError = ref(null) -const operation = ref<'ROUTE' | undefined>() -const action = ref('EDIT'), actionOpen = ref(false) -const detailActions: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'CANCEL'] -const visibleActions = computed(() => detailActions.filter(value => (value !== 'SUBMIT_REVIEW' || detail.value?.taskType === 'PICKUP') && !(detail.value?.taskType === 'PICKUP' && value === 'EDIT') && canDispatchAction(detail.value?.actions, value, access.can))) const vehicleStatus = computed(() => { const status = detail.value?.vehicleInfo.vehicle?.vehicleStatus return status && Object.hasOwn(vehicleStatusLabels, status) ? vehicleStatusLabels[status as keyof typeof vehicleStatusLabels] : '—' @@ -72,19 +66,15 @@ async function load() { const appointmentReturn = computed(() => appointmentReturnTarget(route.query)) const backLabel = computed(() => appointmentReturn.value ? '返回预约日历' : inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回排车列表') function back() { void router.push(appointmentReturn.value || inquiryReturnTarget(route.query) || { name: 'overseas-dispatches' }) } -function openAction(value: DispatchAction, requestedOperation?: 'ROUTE') { if (value === 'ADD_CARGO' || value === 'REMOVE_CARGO') return; if (!canDispatchAction(detail.value?.actions, value, access.can)) return; rejectionError.value = null; operation.value = requestedOperation; action.value = value; actionOpen.value = true } -async function handleRejection(details: ApiErrorDetails) { rejectionError.value = details; await load() } -async function handleSaved() { rejectionError.value = null; await load() } watch(dispatchNo, load, { immediate: true }) onBeforeUnmount(() => { generation++ })