From 189d1eae11c68b7f9ada02f4db27dd1d55ef5d1a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 7 Sep 2026 12:21:44 +0800 Subject: [PATCH] feat: add inbound hold evidence controls --- src/modules/inbounds/core/api.test.ts | 25 +- src/modules/inbounds/core/api.ts | 25 ++ src/modules/inbounds/core/types.ts | 7 + src/modules/inbounds/index.ts | 4 +- .../UnifiedOrdersView.fulfillment.test.ts | 228 +++++++++++++++++- .../pages/UnifiedOrdersView.test.ts | 7 +- .../pages/UnifiedOrdersView.vue | 100 +++++++- 7 files changed, 382 insertions(+), 14 deletions(-) diff --git a/src/modules/inbounds/core/api.test.ts b/src/modules/inbounds/core/api.test.ts index 26f5529f..79746e4d 100644 --- a/src/modules/inbounds/core/api.test.ts +++ b/src/modules/inbounds/core/api.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { downloadInboundCartonLabels, getPlatformInbound, getPlatformInboundHold, holdPlatformInbound, listPlatformInbounds, releasePlatformInbound } from './api' +import { downloadInboundCartonLabels, downloadPlatformInboundHoldEvidence, getPlatformInbound, getPlatformInboundHold, holdPlatformInbound, listPlatformInbounds, releasePlatformInbound, uploadPlatformInboundHoldEvidence } from './api' vi.mock('@/shared/api/http', () => ({ ApiResponseError: class ApiResponseError extends Error { @@ -146,4 +146,27 @@ describe('platform inbound query API', () => { params: { tenantId: '10002' }, headers: { 'Idempotency-Key': 'release-key' }, }) }) + + it('uploads and downloads tenant-scoped hold evidence without narrowing long IDs', async () => { + const file = new File(['evidence'], '扣货说明.pdf', { type: 'application/pdf' }) + vi.mocked(http.post).mockResolvedValueOnce({ data: { + fileId: '9007199254740999', fileName: '扣货说明.pdf', contentType: 'application/pdf', sizeBytes: '8', + } }) + await expect(uploadPlatformInboundHoldEvidence('10002', '19001', file)).resolves.toEqual({ + fileId: '9007199254740999', fileName: '扣货说明.pdf', contentType: 'application/pdf', sizeBytes: 8, + }) + const [path, body, options] = vi.mocked(http.post).mock.calls[0]! + expect(path).toBe('/platform/inbounds/19001/hold/evidence') + expect(body).toBeInstanceOf(FormData) + expect((body as FormData).get('file')).toBe(file) + expect(options).toEqual({ params: { tenantId: '10002' }, headers: { 'Content-Type': 'multipart/form-data' } }) + + const blob = new Blob(['evidence'], { type: 'application/pdf' }) + vi.mocked(http.get).mockResolvedValueOnce(blob) + await expect(downloadPlatformInboundHoldEvidence('10002', '19001', '9007199254740999')).resolves.toBe(blob) + expect(http.get).toHaveBeenLastCalledWith( + '/platform/inbounds/19001/hold/evidence/9007199254740999/download', + { params: { tenantId: '10002' }, responseType: 'blob' }, + ) + }) }) diff --git a/src/modules/inbounds/core/api.ts b/src/modules/inbounds/core/api.ts index f97c6342..5709bcf8 100644 --- a/src/modules/inbounds/core/api.ts +++ b/src/modules/inbounds/core/api.ts @@ -4,6 +4,7 @@ import type { InboundInternalNote, InboundHoldCommand, InboundHoldDetail, + InboundHoldEvidenceUpload, InboundListQuery, InboundOrderDetail, InboundOrderSummary, @@ -192,6 +193,30 @@ export function releasePlatformInbound(tenantId: string, inboundId: string, payl return postPlatformInboundHold(tenantId, inboundId, 'release', payload, idempotencyKey) } +export async function uploadPlatformInboundHoldEvidence(tenantId: string, inboundId: string, file: File) { + const body = new FormData() + body.append('file', file) + const response = await http.post, ApiEnvelope>( + `${basePath}/${encodeURIComponent(inboundId)}/hold/evidence`, + body, + { params: { tenantId }, headers: { 'Content-Type': 'multipart/form-data' } }, + ) + const raw = unwrapApiResponse(response) + return { + fileId: String(raw.fileId), + fileName: String(raw.fileName || file.name), + contentType: String(raw.contentType || file.type || 'application/octet-stream'), + sizeBytes: numberValue(raw.sizeBytes) || 0, + } satisfies InboundHoldEvidenceUpload +} + +export function downloadPlatformInboundHoldEvidence(tenantId: string, inboundId: string, fileId: string) { + return http.get( + `${basePath}/${encodeURIComponent(inboundId)}/hold/evidence/${encodeURIComponent(fileId)}/download`, + { params: { tenantId }, responseType: 'blob' }, + ) +} + export async function downloadInboundCartonLabels(tenantId: string, inboundId: string, cartonIndexes?: number[]) { const response = await http.post( `${basePath}/${encodeURIComponent(inboundId)}/carton-label-pdf`, diff --git a/src/modules/inbounds/core/types.ts b/src/modules/inbounds/core/types.ts index a6294d0a..b9fb3fe1 100644 --- a/src/modules/inbounds/core/types.ts +++ b/src/modules/inbounds/core/types.ts @@ -151,6 +151,13 @@ export interface InboundHoldCommand { evidenceFileId?: string } +export interface InboundHoldEvidenceUpload { + fileId: string + fileName: string + contentType: string + sizeBytes: number +} + export interface InboundDropshipPallet { palletId: string palletNo: string | null diff --git a/src/modules/inbounds/index.ts b/src/modules/inbounds/index.ts index 981896c7..0e89cc3a 100644 --- a/src/modules/inbounds/index.ts +++ b/src/modules/inbounds/index.ts @@ -1,5 +1,5 @@ export { getInboundMeasurement, saveInboundMeasurementDraft } from './measurement/api' -export { createInboundHoldIdempotencyKey, getPlatformInboundHold, holdPlatformInbound, releasePlatformInbound } from './core/api' +export { createInboundHoldIdempotencyKey, downloadPlatformInboundHoldEvidence, getPlatformInboundHold, holdPlatformInbound, releasePlatformInbound, uploadPlatformInboundHoldEvidence } from './core/api' export { default as InboundMeasurementPanel } from './measurement/InboundMeasurementPanel.vue' export { default as InboundMeasurementDialog } from './measurement/InboundMeasurementDialog.vue' export { default as MeasurementEvidenceThumbnail } from './measurement/components/MeasurementEvidenceThumbnail.vue' @@ -14,4 +14,4 @@ export { export type { InboundFeeSnapshot } from './fee/types' export { downloadWeightImage } from './weight/api/weightOcr' export type { InboundMeasurementDetail } from './measurement/types' -export type { InboundHoldCommand, InboundHoldDetail, InboundHoldHistoryEntry } from './core/types' +export type { InboundHoldCommand, InboundHoldDetail, InboundHoldEvidenceUpload, InboundHoldHistoryEntry } from './core/types' diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.fulfillment.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.fulfillment.test.ts index b5555066..3802f3c8 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.fulfillment.test.ts +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.fulfillment.test.ts @@ -2,7 +2,7 @@ import { flushPromises, shallowMount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import { getLoadingAssociationSummary, getOverseasInboundCodeRelationship } from '@/shared/api/inboundCodeRelations' import { downloadPlatformProductImage, getPlatformUnifiedOrder, listPlatformUnifiedOrders } from '@/modules/unified-orders/api/unifiedOrders' -import { getPlatformInboundHold, holdPlatformInbound, releasePlatformInbound } from '@/modules/inbounds' +import { downloadPlatformInboundHoldEvidence, getPlatformInboundHold, holdPlatformInbound, releasePlatformInbound, uploadPlatformInboundHoldEvidence } from '@/modules/inbounds' import UnifiedOrdersView from './UnifiedOrdersView.vue' vi.mock('vue-router', () => ({ @@ -15,7 +15,7 @@ vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mockResolvedValue([]) })) vi.mock('@/modules/inbounds', () => ({ downloadWeightImage: vi.fn(), getInboundMeasurement: vi.fn(), - getPlatformInboundHold: vi.fn(), holdPlatformInbound: vi.fn(), releasePlatformInbound: vi.fn(), + downloadPlatformInboundHoldEvidence: vi.fn(), getPlatformInboundHold: vi.fn(), holdPlatformInbound: vi.fn(), releasePlatformInbound: vi.fn(), uploadPlatformInboundHoldEvidence: vi.fn(), InboundFeePanel: { template: '
' }, InboundMeasurementDialog: { template: '
' }, MeasurementEvidenceThumbnail: { template: '
' }, })) vi.mock('@/modules/unified-orders/workflows', () => ({ UnifiedOrderWorkflowPanel: { template: '
' } })) @@ -78,6 +78,10 @@ describe('UnifiedOrdersView downstream fulfillment detail', () => { vi.mocked(getPlatformUnifiedOrder).mockResolvedValue(detail as never) vi.mocked(downloadPlatformProductImage).mockResolvedValue(new Blob(['image'], { type: 'image/png' })) vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 }) + vi.mocked(uploadPlatformInboundHoldEvidence).mockResolvedValue({ + fileId: '9007199254740999', fileName: '扣货说明.pdf', contentType: 'application/pdf', sizeBytes: 8, + }) + vi.mocked(downloadPlatformInboundHoldEvidence).mockResolvedValue(new Blob(['evidence'], { type: 'application/pdf' })) vi.mocked(getPlatformInboundHold).mockResolvedValue({ inboundId: '7001', tenantId: '6001', holdFlag: false, currentHoldReason: null, version: 3, canHold: true, canRelease: false, actionBlockReason: null, @@ -180,6 +184,226 @@ describe('UnifiedOrdersView downstream fulfillment detail', () => { expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(1) }) + it('exposes loading, empty and error states for the lazy hold request', async () => { + let resolveHold!: (value: Awaited>) => void + vi.mocked(getPlatformInboundHold).mockImplementationOnce(() => new Promise((resolve) => { resolveHold = resolve })) + const wrapper = mountDetail() + await flushPromises() + await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click') + expect((wrapper.vm as unknown as { holdLoading: boolean }).holdLoading).toBe(true) + resolveHold({ + inboundId: '7001', tenantId: '6001', holdFlag: false, currentHoldReason: null, version: 3, + canHold: true, canRelease: false, actionBlockReason: null, history: [], + }) + await flushPromises() + const loadedVm = wrapper.vm as unknown as { holdLoading: boolean; holdDetail: { history: unknown[] } } + expect(loadedVm.holdLoading).toBe(false) + expect(loadedVm.holdDetail.history).toEqual([]) + expect(wrapper.text()).toContain('扣货历史') + + vi.mocked(getPlatformInboundHold).mockRejectedValueOnce(new Error('网络断开')) + await (wrapper.vm as unknown as { loadHoldDetail(force: boolean): Promise }).loadHoldDetail(true) + expect(wrapper.text()).toContain('扣货状态与历史加载失败') + expect((wrapper.vm as unknown as { holdError: { message: string } }).holdError.message).toBe('扣货状态与历史加载失败') + }) + + it('rejects blank and over-limit reasons while accepting exactly 1000 characters', async () => { + const wrapper = mountDetail() + await flushPromises() + await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click') + await flushPromises() + const vm = wrapper.vm as unknown as { + holdReason: string + openHoldDialog(action: 'HOLD'): void + submitHoldAction(): Promise + } + vm.openHoldDialog('HOLD') + vm.holdReason = ' ' + await vm.submitHoldAction() + vm.holdReason = 'a'.repeat(1001) + await vm.submitHoldAction() + expect(holdPlatformInbound).not.toHaveBeenCalled() + + vm.holdReason = 'a'.repeat(1000) + await vm.submitHoldAction() + expect(holdPlatformInbound).toHaveBeenCalledOnce() + expect(holdPlatformInbound).toHaveBeenCalledWith('6001', '7001', { + expectedVersion: 3, + reason: 'a'.repeat(1000), + }) + }) + + it('prevents duplicate submissions while the hold request is pending', async () => { + let resolveCommand!: (value: Awaited>) => void + vi.mocked(holdPlatformInbound).mockImplementationOnce(() => new Promise((resolve) => { resolveCommand = resolve })) + const wrapper = mountDetail() + await flushPromises() + await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click') + await flushPromises() + const vm = wrapper.vm as unknown as { + holdReason: string + holdSaving: boolean + openHoldDialog(action: 'HOLD'): void + submitHoldAction(): Promise + } + vm.openHoldDialog('HOLD') + vm.holdReason = '等待客户补款' + const first = vm.submitHoldAction() + const duplicate = vm.submitHoldAction() + await duplicate + expect(vm.holdSaving).toBe(true) + expect(holdPlatformInbound).toHaveBeenCalledOnce() + resolveCommand({ + inboundId: '7001', tenantId: '6001', holdFlag: true, currentHoldReason: '等待客户补款', version: 4, + canHold: false, canRelease: true, actionBlockReason: null, history: [], + }) + await first + expect(vm.holdSaving).toBe(false) + }) + + it('keeps the dialog and reason when a stale-version submission fails', async () => { + vi.mocked(holdPlatformInbound).mockRejectedValueOnce(Object.assign(new Error('扣货状态已变化,请刷新后重试'), { code: 'A0409' })) + const wrapper = mountDetail() + await flushPromises() + await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click') + await flushPromises() + const vm = wrapper.vm as unknown as { + holdReason: string + holdDialogOpen: boolean + openHoldDialog(action: 'HOLD'): void + submitHoldAction(): Promise + } + vm.openHoldDialog('HOLD') + vm.holdReason = '等待客户补款' + await vm.submitHoldAction() + expect(holdPlatformInbound).toHaveBeenCalledWith('6001', '7001', { expectedVersion: 3, reason: '等待客户补款' }) + expect(vm.holdDialogOpen).toBe(true) + expect(vm.holdReason).toBe('等待客户补款') + expect(getPlatformUnifiedOrder).toHaveBeenCalledOnce() + expect(listPlatformUnifiedOrders).not.toHaveBeenCalled() + }) + + it('does not submit the command when evidence upload fails and preserves the form', async () => { + vi.mocked(uploadPlatformInboundHoldEvidence).mockRejectedValueOnce(new Error('文件服务不可用')) + const wrapper = mountDetail() + await flushPromises() + await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click') + await flushPromises() + const evidence = new File(['evidence'], '扣货说明.pdf', { type: 'application/pdf' }) + const vm = wrapper.vm as unknown as { + holdReason: string + holdDialogOpen: boolean + holdEvidenceFile: File | null + holdEvidenceError: { message: string } | null + openHoldDialog(action: 'HOLD'): void + selectHoldEvidenceFile(upload: { raw: File }): void + submitHoldAction(): Promise + } + vm.openHoldDialog('HOLD') + vm.holdReason = '等待客户补款' + vm.selectHoldEvidenceFile({ raw: evidence }) + await vm.submitHoldAction() + expect(uploadPlatformInboundHoldEvidence).toHaveBeenCalledWith('6001', '7001', evidence) + expect(holdPlatformInbound).not.toHaveBeenCalled() + expect(vm.holdDialogOpen).toBe(true) + expect(vm.holdReason).toBe('等待客户补款') + expect(vm.holdEvidenceFile).toBe(evidence) + expect(vm.holdEvidenceError?.message).toBe('关联文件上传失败,请稍后重试') + }) + + it('uploads optional evidence once and submits its long identifier', async () => { + const wrapper = mountDetail() + await flushPromises() + await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click') + await flushPromises() + const evidence = new File(['evidence'], '扣货说明.pdf', { type: 'application/pdf' }) + const vm = wrapper.vm as unknown as { + holdReason: string + openHoldDialog(action: 'HOLD'): void + selectHoldEvidenceFile(upload: { raw: File }): void + submitHoldAction(): Promise + } + vm.openHoldDialog('HOLD') + vm.holdReason = '等待客户补款' + vm.selectHoldEvidenceFile({ raw: evidence }) + await vm.submitHoldAction() + expect(uploadPlatformInboundHoldEvidence).toHaveBeenCalledOnce() + expect(holdPlatformInbound).toHaveBeenCalledWith('6001', '7001', { + expectedVersion: 3, + reason: '等待客户补款', + evidenceFileId: '9007199254740999', + }) + }) + + it('rejects unsafe evidence extensions, empty files and files over 10MB', async () => { + const wrapper = mountDetail() + await flushPromises() + await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click') + await flushPromises() + const vm = wrapper.vm as unknown as { + holdEvidenceFile: File | null + holdEvidenceError: { code: string } | null + openHoldDialog(action: 'HOLD'): void + selectHoldEvidenceFile(upload: { raw: File }): void + } + vm.openHoldDialog('HOLD') + vm.selectHoldEvidenceFile({ raw: new File(['run'], '恶意程序.exe') }) + expect(vm.holdEvidenceFile).toBeNull() + expect(vm.holdEvidenceError?.code).toBe('FILE_TYPE_INVALID') + + vm.selectHoldEvidenceFile({ raw: new File([], '空文件.txt', { type: 'text/plain' }) }) + expect(vm.holdEvidenceError?.code).toBe('FILE_SIZE_INVALID') + const oversized = new File(['x'], '过大文件.png', { type: 'image/png' }) + Object.defineProperty(oversized, 'size', { value: 10 * 1024 * 1024 + 1 }) + vm.selectHoldEvidenceFile({ raw: oversized }) + expect(vm.holdEvidenceError?.code).toBe('FILE_SIZE_INVALID') + expect(uploadPlatformInboundHoldEvidence).not.toHaveBeenCalled() + }) + + it('downloads associated evidence once while a repeated click is pending', async () => { + let resolveDownload!: (value: Blob) => void + vi.mocked(downloadPlatformInboundHoldEvidence).mockImplementationOnce(() => new Promise((resolve) => { resolveDownload = resolve })) + Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:hold-evidence') }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }) + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined) + const wrapper = mountDetail() + await flushPromises() + const vm = wrapper.vm as unknown as { downloadHoldEvidence(fileId: string): Promise; holdDownloadingFileId: string } + const first = vm.downloadHoldEvidence('9007199254740999') + const duplicate = vm.downloadHoldEvidence('9007199254740999') + await duplicate + expect(downloadPlatformInboundHoldEvidence).toHaveBeenCalledOnce() + expect(vm.holdDownloadingFileId).toBe('9007199254740999') + resolveDownload(new Blob(['evidence'], { type: 'application/pdf' })) + await first + expect(downloadPlatformInboundHoldEvidence).toHaveBeenCalledWith('6001', '7001', '9007199254740999') + expect(click).toHaveBeenCalledOnce() + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:hold-evidence') + expect(vm.holdDownloadingFileId).toBe('') + }) + + it('submits release with the current server version and refreshes detail and list', async () => { + vi.mocked(getPlatformInboundHold).mockResolvedValueOnce({ + inboundId: '7001', tenantId: '6001', holdFlag: true, currentHoldReason: '等待客户补款', version: 8, + canHold: false, canRelease: true, actionBlockReason: null, history: [], + }) + const wrapper = mountDetail() + await flushPromises() + await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click') + await flushPromises() + const vm = wrapper.vm as unknown as { + holdReason: string + openHoldDialog(action: 'RELEASE'): void + submitHoldAction(): Promise + } + vm.openHoldDialog('RELEASE') + vm.holdReason = '货款已核对' + await vm.submitHoldAction() + expect(releasePlatformInbound).toHaveBeenCalledWith('6001', '7001', { expectedVersion: 8, reason: '货款已核对' }) + expect(getPlatformUnifiedOrder).toHaveBeenCalledTimes(2) + expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(1) + }) + it('stays read-only without the independent hold permission', async () => { canPermission.mockImplementation((...permissions: string[]) => permissions[0] !== 'platform:firstleg:inbound:hold') const wrapper = mountDetail() diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts index e0c4aad8..8bd4338e 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts @@ -83,13 +83,14 @@ describe('UnifiedOrdersView contract', () => { for (const marker of [ 'summary.holdFlag', 'summary.currentHoldReason', 'getPlatformInboundHold', 'holdDetail.value?.canHold', 'holdDetail.value?.canRelease', "can('platform:firstleg:inbound:hold')", - '扣货历史', '已关联凭证', '订单尚未生成入库单,扣货状态仅可查看', + '扣货历史', '下载凭证', '订单尚未生成入库单,扣货状态仅可查看', + 'uploadPlatformInboundHoldEvidence', 'downloadPlatformInboundHoldEvidence', + '仅支持 TXT、PDF、JPG、PNG,单文件不超过 10MB', ]) expect(source).toContain(marker) expect(source).toContain("await Promise.all([loadDetail(true), loadOrders()])") expect(source).not.toContain('detail.summary.holdCargo') expect(source).not.toContain('detail.summary.holdReason') - expect(source).not.toContain('evidenceFileId: hold') - expect(source).not.toContain('type="file"') + expect(source).toContain('evidenceFileId: holdUploadedEvidenceFileId.value') }) it('uses tabs as the only order-status filter and keeps advanced controls inside QueryToolbar', () => { diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.vue b/src/modules/unified-orders/pages/UnifiedOrdersView.vue index 861108e7..3ac9862d 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.vue +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.vue @@ -1,7 +1,7 @@