From fde16235eaa06c10dd82b626f72f3ab09d2f8750 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 21 Oct 2026 17:28:04 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/overseas-warehouse/AGENTS.md | 2 +- .../overseas-warehouse/PROTOTYPE_MATRIX.md | 9 +- .../components/DispatchEvidenceFiles.test.ts | 22 +++++ .../components/DispatchEvidenceFiles.vue | 58 ++++++++++--- .../DispatchWorkflowDialog.layout.test.ts | 82 ++++++++++++++----- .../components/DispatchWorkflowDialog.vue | 45 ++++++---- .../TransportExecutionPanel.test.ts | 41 +++++++++- .../components/TransportExecutionPanel.vue | 29 +++++-- .../overseas-warehouse/dispatchContracts.ts | 2 +- .../dispatchPresentation.ts | 1 + .../pages/DispatchesView.test.ts | 2 +- .../pages/DispatchesView.vue | 2 +- 12 files changed, 233 insertions(+), 62 deletions(-) diff --git a/src/modules/overseas-warehouse/AGENTS.md b/src/modules/overseas-warehouse/AGENTS.md index 2ac9e69d..ad87ff55 100644 --- a/src/modules/overseas-warehouse/AGENTS.md +++ b/src/modules/overseas-warehouse/AGENTS.md @@ -44,7 +44,7 @@ ## 验证 -- 排车调度/理货审核分区仅显示审核、详情;审核弹框的车辆图片、司机照片和驾驶证照片通过既有本车附件接口上传,随 `ReviewCommand.attachments` 保存,不调用资源编辑命令。已有资源与审核附件只读预览,详情合并展示;当前审核权限与服务端 Gate 控制上传及提交,列表查看权限控制读取。 +- 排车调度/理货审核分区仅显示审核、详情;车辆图片、司机照片和驾驶证照片在维护车辆司机时通过既有本车附件接口上传,随资源编辑命令保存,司机照片使用 `driver.evidence.photoFileIds`。三类照片横向并排、名称显示在照片下方;审核弹框只读预览,合并展示资源照片及历史审核附件。维护权限与服务端 Gate 控制上传及保存,审核权限控制审核提交,列表查看权限控制读取。 - 一件代发出库候选选择:`pages/OutboundsView.vue` 按 `inventoryPositionId + skuCode` 保留跨页选择,只提供本页全选;查询/重置回到第一页,切换海外仓清空选择。提交仍校验同客户、同海外仓,数量取所选行的 `availablePackages`(箱数),不能按 SKU 件数替代;候选同时展示当前可出箱数对应的毛重、材积重和体积,创建出库单时服务端按本次出库箱数占实际入库箱数的比例冻结三项测量快照。候选交互回归见 `pages/OutboundsView.selection.test.ts`;状态展示与取消门禁见 `pages/OutboundsView.test.ts`、`pages/OutboundDetailView.test.ts`。 - 转运审核定向测试:从应用根运行 `pnpm test src/modules/overseas-warehouse/api/warehouseTransfers.test.ts src/modules/overseas-warehouse/pages/prototypeContract.test.ts`,分别核对客户作用域/API 与审核入口权限、状态。 diff --git a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md index ddfcb7b2..81c8f32e 100644 --- a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md @@ -405,9 +405,16 @@ | 页面 | 操作 | 依据与承载 | 本次变更 | 验收 | |---|---|---|---|---| | 排车列表:调度审核、理货员审核 | 行操作 | 用户本轮确认只保留审核、详情;沿现有列表 | 去掉维护车辆信息及更多菜单,仅显示本环节审核与详情 | 本轮定向回归通过;待人工截图确认 | -| 排车审核弹框 | 通过/驳回、上传附件 | 用户附件 codex-clipboard-249614d2-efac-4494-af47-090980981900.png;沿既有弹框 | 车辆图片、司机照片、驾驶证照片三类,原证据只读,本次附件随审核结果保存;未知结果重试原请求 | 本轮定向回归通过;待人工 1920×1080、100% 截图确认 | +| 排车审核弹框 | 通过/驳回、预览照片 | 用户附件 codex-clipboard-7867e8a0-a3ae-4a16-bcd5-35291cf12e63.png、codex-clipboard-6baaaa9b-89bd-49fa-ac11-a26b20be7501.png;沿既有弹框 | 三类照片等宽等高区域并排、下方名称、点击预览;底部直接驳回/审核通过,驳回原因必填,去掉结果单选框;未知结果仅重试原请求 | 定向回归通过;待人工 1920×1080、100% 截图确认 | +| 排车维护车辆信息 | 上传车辆、司机和驾驶证照片 | 用户确认上传入口属于维护车辆司机 | 三类照片通过资源编辑保存,司机照片独立字段;审核只读,保留历史审核证据 | 定向回归通过;待人工截图确认 | | 排车详情:车辆信息 | 查看审核附件 | 复用既有附件预览 | 汇总原资源与两级审核已保存证据,去重并只读预览 | 本轮定向回归通过;待人工截图确认 | 本轮自动验证:Admin 5 个定向文件 96 项通过,`pnpm typecheck` 通过;Lastleg/File 8 个定向类 63 项通过,API 路由合同 5 项通过,AGENTS 索引 1232 项通过。未部署,未进行真实页面操作;上线本次变更须包含 Admin、lastleg-service 和 file(新增司机照片文件分类)。 - 2026-10-21 排车“派送中 → 标记异常”:按用户提供的三类异常、两类退货去向截图补齐选项,沿用居中作业弹框,说明与图片选填,保存后仍派送中;现有排车信息页签回看。自动回归单独报告,1920×1080/100%真实页面待人工截图确认。 + +- 2026-10-21 照片维护与审核按钮调整:Admin 4 个业务定向文件共 78 项通过,公共密度样式 70 项通过,类型检查通过;前两次回归发现的提示渲染时序与局部背景样式问题已修正并定向复测。指引索引检查因本地缺少 `D:/project/docs/业务快速索引.md` 无法完成。未部署,真实页面待人工截图确认。 + +- 2026-10-21 取货详情:按用户截图在转运单号后展示对应取货地址、联系人和联系电话,缺失值显示“—”;沿现有取货明细接口读取,不跨行复用联系人。`TransportExecutionPanel.test.ts` 6 项通过,含两张转运单地址和联系人分别展示的回归;真实页面待人工截图确认。 + +- 2026-10-21 取货装货留痕:列表及执行面板统一使用“上传装货照片”,沿居中弹框展示预报箱数并要求手填实际取货箱数(1—5000),未填数量或未上传照片不能确认;到仓仅展示已保存取货数量,不改写取货事实。数量与照片通过既有版本化命令同次确认,复用服务端操作人和时间历史。执行面板 7 项自动回归通过;真实页面待人工截图确认。 diff --git a/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.test.ts b/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.test.ts index caef2797..964f3185 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('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(['预览']) diff --git a/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.vue b/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.vue index be8194ca..2d5241ce 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 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() const uploadTypes = computed(() => ['DRIVER_PHOTO', 'EXCEPTION'].includes(props.kind) ? types.slice(1) : types) @@ -29,11 +30,22 @@ function fail(cause: unknown, fallback: string) { error.value = getApiErrorDetai function closePreview() { previewGeneration++; pendingPopup?.close(); pendingPopup = null; imageOpen.value = false; previewLoading.value = false; if (imageUrl.value) { URL.revokeObjectURL(imageUrl.value); urls.delete(imageUrl.value); imageUrl.value = '' } } async function load() { const current = ++generation, no = props.dispatchNo, kind = props.kind, values = [...allIds.value] + for (const url of Object.values(thumbnails.value)) { URL.revokeObjectURL(url); urls.delete(url) } + thumbnails.value = {} closePreview(); metadata.value = {}; error.value = null if (!no) return const results = await Promise.allSettled(values.map(async id => valid(await getDispatchEvidence(no, kind, id), id))) if (current !== generation) return results.forEach(result => { if (result.status === 'fulfilled') metadata.value[result.value.fileId] = result.value; else fail(result.reason, '附件资料读取失败,请重试;原引用已保留') }) + if (props.photoCard) await Promise.all(values.map(async id => { + const file = metadata.value[id] + if (!file || !file.contentType.startsWith('image/')) return + try { + const blob = await readContent(no, kind, id, file) + if (current !== generation) return + const url = URL.createObjectURL(blob); urls.add(url); thumbnails.value[id] = url + } catch (cause) { if (current === generation) fail(cause, '照片读取失败,请重试') } + })) } function choose(id: string | null = null) { if (props.readonly || props.disabled || uploading.value || !props.dispatchNo) return; replacing = id; input.value?.click() } async function upload(event: Event) { @@ -52,6 +64,17 @@ async function upload(event: Event) { finally { uploading.value = false; emit('busy', false) } } function remove(id: string) { if (!props.readonly && !props.disabled && !uploading.value) ids.value = ids.value.filter(value => value !== id) } +async function readContent(no: string, kind: DispatchEvidenceKind, id: string, file: DispatchEvidenceFile) { + const blob = await getDispatchEvidenceContent(no, kind, id) + const type = blob.type.split(';')[0]?.toLowerCase() + if (type?.includes('json')) { + let body: Partial<{ code: string; message: string; requestId: string; traceId: string }> + try { body = JSON.parse(await blob.text()) as typeof body } catch { throw new DispatchInputError('附件内容返回错误信息,请重新读取') } + throw new ApiResponseError(body.message || '附件读取失败', body.code || 'INVALID_FILE_CONTENT', undefined, undefined, body.requestId, body.traceId) + } + if (type !== file.contentType || blob.size <= 0 || blob.size > maximum || blob.size !== file.sizeBytes) throw new DispatchInputError('附件内容类型或大小与本车元数据不一致') + return blob +} async function preview(id: string) { const file = metadata.value[id]; if (!file || previewLoading.value) return closePreview(); const current = ++previewGeneration, scope = generation @@ -59,15 +82,8 @@ async function preview(id: string) { if (file.contentType === 'application/pdf') { pendingPopup = window.open('', '_blank'); if (!pendingPopup) { previewLoading.value = false; fail(new DispatchInputError('请允许本站打开PDF预览窗口'), 'PDF预览未打开'); return } } else { imageName.value = file.fileName; imageOpen.value = true } try { - const blob = await getDispatchEvidenceContent(props.dispatchNo, props.kind, id) + const blob = await readContent(props.dispatchNo, props.kind, id, file) if (current !== previewGeneration || scope !== generation) return - const type = blob.type.split(';')[0]?.toLowerCase() - if (type?.includes('json')) { - let body: Partial<{ code: string; message: string; requestId: string; traceId: string }> - try { body = JSON.parse(await blob.text()) as typeof body } catch { throw new DispatchInputError('附件内容返回错误信息,请重新读取') } - throw new ApiResponseError(body.message || '附件读取失败', body.code || 'INVALID_FILE_CONTENT', undefined, undefined, body.requestId, body.traceId) - } - if (type !== file.contentType || blob.size <= 0 || blob.size > maximum || blob.size !== file.sizeBytes) throw new DispatchInputError('附件内容类型或大小与本车元数据不一致') const url = URL.createObjectURL(blob); urls.add(url) if (file.contentType === 'application/pdf') { pendingPopup!.location.href = url; pendingPopup = null; window.setTimeout(() => { URL.revokeObjectURL(url); urls.delete(url) }, 60_000) } else imageUrl.value = url @@ -78,10 +94,22 @@ watch(() => JSON.stringify([props.dispatchNo, props.kind, allIds.value]), load, onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) URL.revokeObjectURL(url); urls.clear(); emit('busy', false) }) diff --git a/src/modules/overseas-warehouse/components/TransportExecutionPanel.test.ts b/src/modules/overseas-warehouse/components/TransportExecutionPanel.test.ts index 3f252ea4..e2dbdbf1 100644 --- a/src/modules/overseas-warehouse/components/TransportExecutionPanel.test.ts +++ b/src/modules/overseas-warehouse/components/TransportExecutionPanel.test.ts @@ -20,19 +20,37 @@ it.each([false, true])('confirms the %s photo stage with its own evidence and cu const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true } }); await flushPromises() expect(wrapper.text()).toContain(arriving ? '派送中' : '装货中') expect(wrapper.findAll('button').some(button => button.text() === '确认发车')).toBe(false) - await wrapper.findAll('button').find(button => button.text() === (arriving ? '上传到仓照片' : '上传取货照片'))!.trigger('click'); await flushPromises() + await wrapper.findAll('button').find(button => button.text() === (arriving ? '上传到仓照片' : '上传装货照片'))!.trigger('click'); await flushPromises() + if (!arriving) { + const quantity = wrapper.get('input[role="spinbutton"]') + expect((quantity.element as HTMLInputElement).value).toBe('') + await quantity.setValue('3'); await quantity.trigger('change'); await flushPromises() + } const input = wrapper.get('input[type="file"]') Object.defineProperty(input.element, 'files', { value: [new File(['photo'], '现场.jpg', { type: 'image/jpeg' })], configurable: true }) await input.trigger('change'); await flushPromises() await wrapper.findAll('button').find(button => button.text() === (arriving ? '确认到仓' : '确认取货'))!.trigger('click'); await flushPromises() - expect(commandTransport).toHaveBeenCalledWith('PC1', `pickup/C1/WT1/${arriving ? 'arrive' : 'picked'}`, expect.objectContaining({ recordVersion: 4, evidenceFileIds: ['88'], ...(arriving ? { reason: '上传到仓照片,确认取货派送完成' } : { actualPackages: 5 }) })) + expect(commandTransport).toHaveBeenCalledWith('PC1', `pickup/C1/WT1/${arriving ? 'arrive' : 'picked'}`, expect.objectContaining({ recordVersion: 4, evidenceFileIds: ['88'], ...(arriving ? { reason: '上传到仓照片,确认取货派送完成' } : { actualPackages: 3 }) })) + if (arriving) expect(commandTransport.mock.calls[0]![2]).not.toHaveProperty('actualPackages') expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount() }) it('keeps photo confirmation unavailable until both reviews are approved', async () => { const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: false } }); await flushPromises() - expect(wrapper.findAll('button').some(button => button.text() === '上传取货照片')).toBe(false) + expect(wrapper.findAll('button').some(button => button.text() === '上传装货照片')).toBe(false) expect(commandTransport).not.toHaveBeenCalled(); wrapper.unmount() }) +it('requires an explicitly entered pickup quantity even after a photo has uploaded', async () => { + const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true } }); await flushPromises() + await wrapper.findAll('button').find(button => button.text() === '上传装货照片')!.trigger('click'); await flushPromises() + const input = wrapper.get('input[type="file"]') + Object.defineProperty(input.element, 'files', { value: [new File(['photo'], '现场.jpg', { type: 'image/jpeg' })], configurable: true }) + await input.trigger('change'); await flushPromises() + const confirm = wrapper.findAll('button').find(button => button.text() === '确认取货')! + expect(confirm.attributes('disabled')).toBeDefined() + await confirm.trigger('click') + expect(commandTransport).not.toHaveBeenCalled() + wrapper.unmount() +}) it.each([false, true])('renders read-only transport facts without resource editing or execution controls (%s)', async arriving => { const value = facts(arriving) value.execution.trip.vehicleId = null @@ -47,3 +65,20 @@ it.each([false, true])('renders read-only transport facts without resource editi expect(commandTransport).not.toHaveBeenCalled() wrapper.unmount() }) +it('keeps pickup addresses and contacts on their own transfer rows', async () => { + const value = facts() + value.pickups[0] = { ...value.pickups[0]!, pickupAddress: '100 Test Pickup Avenue', contactName: '取货联系人甲', contactPhone: '0000000001' } + value.pickups.push({ ...value.pickups[0]!, transferNo: 'WT2', customerId: 'C2', pickupAddress: '200 Test Pickup Road', contactName: '取货联系人乙', contactPhone: '0000000002' }) + vi.mocked(getTransport).mockResolvedValue(value) + const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', readonly: true } }); await flushPromises() + const rows = wrapper.get('[aria-label="取货明细"]').findAll('tbody tr') + expect(rows[0]!.text()).toContain('100 Test Pickup Avenue') + expect(rows[0]!.text()).toContain('取货联系人甲') + expect(rows[0]!.text()).toContain('0000000001') + expect(rows[0]!.text()).not.toContain('200 Test Pickup Road') + expect(rows[1]!.text()).toContain('200 Test Pickup Road') + expect(rows[1]!.text()).toContain('取货联系人乙') + expect(rows[1]!.text()).toContain('0000000002') + expect(wrapper.findAll('button')).toHaveLength(0) + wrapper.unmount() +}) diff --git a/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue b/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue index e3aba57d..1d593f69 100644 --- a/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue +++ b/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue @@ -19,8 +19,9 @@ const props = defineProps<{ dispatchNo: string; pickupApproved?: boolean; pickup const access = useAccessPreviewStore(), detail = ref(null), error = ref(null), busy = ref(false) const vehicles = ref([]), drivers = ref([]), vehicleId = ref(''), driverId = ref(''), reason = ref('') const occupancy = ref>({}), candidateBusy = ref(false) -const selected = ref(null), pickupOpen = ref(false), actual = ref(1), evidence = ref([]), truckEvidence = ref([]) +const selected = ref(null), pickupOpen = ref(false), actual = ref(), evidence = ref([]), truckEvidence = ref([]) const arrivalMode = ref(false) +const validPickupQuantity = computed(() => typeof actual.value === 'number' && Number.isInteger(actual.value) && actual.value >= 1 && actual.value <= 5000) const editingResources = ref(props.editResources === true) const pickupPhotos = (row: PickupTask, phase: 'PICKUP' | 'ARRIVAL') => row.evidence.filter(file => ((file as PickupEvidence & { phase?: string }).phase || 'PICKUP') === phase) const pickupStage = computed(() => trip.value?.status === 'COMPLETED' ? '取货派送完成' : trip.value?.status === 'CANCELLED' ? '已取消' : detail.value?.pickups.some(row => row.status === 'ASSIGNED') ? props.pickupApproved ? '装货中' : ({ DRAFT: '待提交审核', DISPATCH_PENDING: '待调度审核', LOADING_PENDING: '待理货审核', REJECTED: '审核退回' }[props.pickupReviewState || 'DRAFT'] || '待审核') : '派送中') @@ -76,7 +77,8 @@ async function command(action: 'depart' | 'complete' | 'arrive' | 'cancel', row? pending.value = { suffix, kind: action === 'cancel' ? 'cancel' : undefined, body: { recordVersion: trip.value.recordVersion, idempotencyKey: crypto.randomUUID(), reason: reason.value.trim() || message, ...(action === 'complete' && prefix === 'truck' ? { evidenceFileIds: truckEvidence.value.map(file => file.fileId) } : {}) } } await execute() } -function editPickup(row: PickupTask, arrival = false) { selected.value = row; arrivalMode.value = arrival; actual.value = Number(row.actualPackages ?? row.packageCount); evidence.value = []; pickupOpen.value = true } +/** 实际取货数量由现场填写,预报箱数不能自动作为实提事实。 */ +function editPickup(row: PickupTask, arrival = false) { selected.value = row; arrivalMode.value = arrival; actual.value = undefined; evidence.value = []; pickupOpen.value = true } async function download(file: PickupEvidence, row?: PickupTask) { try { const blob = await downloadTransportEvidence(props.dispatchNo, file.fileId, row); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file.fileName; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) } catch (cause) { error.value = getApiErrorDetails(cause, '凭证下载失败') } @@ -91,7 +93,7 @@ async function upload(event: Event, pickup: boolean) { finally { busy.value = false; input.value = '' } } async function picked() { - if (!selected.value || !trip.value || !Number.isInteger(actual.value) || actual.value <= 0 || !evidence.value.length || locked.value) return + if (!selected.value || !trip.value || (!arrivalMode.value && !validPickupQuantity.value) || !evidence.value.length || locked.value) return const row = selected.value const common = { recordVersion: trip.value.recordVersion, idempotencyKey: crypto.randomUUID(), evidenceFileIds: evidence.value.map(file => file.fileId) } pending.value = { suffix: `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}/${arrivalMode.value ? 'arrive' : 'picked'}`, body: arrivalMode.value ? { ...common, reason: reason.value.trim() || '上传到仓照片,确认取货派送完成' } : { ...common, actualPackages: actual.value, remark: reason.value.trim() } }; await execute() @@ -138,10 +140,13 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输请求尚待核

{{ detail.pickups.length }} 单 · 预报 {{ detail.pickups.reduce((total, row) => total + row.packageCount, 0) }} 箱
- + + + + - + @@ -149,8 +154,18 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输请求尚待核

{{ file.fileName }}

{{ file.fileName }} - -

{{ selected?.transferNo }}

{{ file.fileName }}

+ +

{{ selected?.transferNo }}

+ + {{ selected?.packageCount ?? '—' }} + + {{ selected?.actualPackages ?? '—' }} + + + +

{{ file.fileName }}

+
+
diff --git a/src/modules/overseas-warehouse/dispatchContracts.ts b/src/modules/overseas-warehouse/dispatchContracts.ts index e4d75634..ee83adad 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 99db60bc..18d4935e 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/DispatchesView.test.ts b/src/modules/overseas-warehouse/pages/DispatchesView.test.ts index c7d7a218..f0b54586 100644 --- a/src/modules/overseas-warehouse/pages/DispatchesView.test.ts +++ b/src/modules/overseas-warehouse/pages/DispatchesView.test.ts @@ -51,7 +51,7 @@ it('confirms cancelling pickup and linked transfers using the transport version' expect(listDispatches).toHaveBeenCalledTimes(2) wrapper.unmount(); confirm.mockRestore() }) -it.each([['LOADING', '上传取货照片'], ['DELIVERING', '上传到仓照片'], ['DELIVERED', '结束资源任务']] as const)('opens pickup %s work from the list while detail stays separate', async (stage, label) => { +it.each([['LOADING', '上传装货照片'], ['DELIVERING', '上传到仓照片'], ['DELIVERED', '结束资源任务']] as const)('opens pickup %s work from the list while detail stays separate', async (stage, label) => { vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'PICKUP', stage, reviewState: 'APPROVED', actions: {} })])) const wrapper = mount(DispatchesView, options); await flushPromises() const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')! diff --git a/src/modules/overseas-warehouse/pages/DispatchesView.vue b/src/modules/overseas-warehouse/pages/DispatchesView.vue index f9b2371c..f2a559d3 100644 --- a/src/modules/overseas-warehouse/pages/DispatchesView.vue +++ b/src/modules/overseas-warehouse/pages/DispatchesView.vue @@ -108,7 +108,7 @@ function pickupExecutionLabel(row: DispatchListRow) { if (row.taskType !== 'PICKUP' || row.status !== 'ACTIVE' || !access.can(dispatchActionPermissions.EDIT)) return '' if (row.stage === 'DELIVERED') return '结束资源任务' if (row.reviewState !== 'APPROVED') return '' - return row.stage === 'LOADING' ? '上传取货照片' : row.stage === 'DELIVERING' ? '上传到仓照片' : '' + return row.stage === 'LOADING' ? '上传装货照片' : row.stage === 'DELIVERING' ? '上传到仓照片' : '' } function openTransport(row: DispatchListRow, resources = false) { transportResourceMode.value = resources; transportEditNo.value = row.dispatchNo