feat: add inbound hold evidence controls
This commit is contained in:
@@ -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' },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<InboundHoldEvidenceUpload>, ApiEnvelope<InboundHoldEvidenceUpload>>(
|
||||
`${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<Blob, Blob>(
|
||||
`${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<Blob, Blob>(
|
||||
`${basePath}/${encodeURIComponent(inboundId)}/carton-label-pdf`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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: '<div />' }, InboundMeasurementDialog: { template: '<div />' }, MeasurementEvidenceThumbnail: { template: '<div />' },
|
||||
}))
|
||||
vi.mock('@/modules/unified-orders/workflows', () => ({ UnifiedOrderWorkflowPanel: { template: '<div />' } }))
|
||||
@@ -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<ReturnType<typeof getPlatformInboundHold>>) => 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<void> }).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<void>
|
||||
}
|
||||
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<ReturnType<typeof holdPlatformInbound>>) => 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<void>
|
||||
}
|
||||
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<void>
|
||||
}
|
||||
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<void>
|
||||
}
|
||||
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<void>
|
||||
}
|
||||
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<void>; 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<void>
|
||||
}
|
||||
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()
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch, type Component } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Download, Eye, GitBranch, History, Link2, ListChecks, MoreHorizontal, PackageOpen, Ruler, Send, ShieldAlert, SlidersHorizontal, StickyNote, Undo2, WalletCards } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||||
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Download, Eye, FileUp, GitBranch, History, Link2, ListChecks, MoreHorizontal, PackageOpen, Ruler, Send, ShieldAlert, SlidersHorizontal, StickyNote, Undo2, WalletCards } from '@lucide/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
getPlatformUnifiedOrder,
|
||||
@@ -63,8 +63,10 @@ import {
|
||||
getLatestInboundFee,
|
||||
getInboundMeasurement,
|
||||
getPlatformInboundHold,
|
||||
downloadPlatformInboundHoldEvidence,
|
||||
holdPlatformInbound,
|
||||
releasePlatformInbound,
|
||||
uploadPlatformInboundHoldEvidence,
|
||||
saveInboundMeasurementDraft,
|
||||
InboundFeePanel,
|
||||
InboundMeasurementDialog,
|
||||
@@ -174,6 +176,11 @@ const holdDialogOpen = ref(false)
|
||||
const holdSaving = ref(false)
|
||||
const holdAction = ref<'HOLD' | 'RELEASE'>('HOLD')
|
||||
const holdReason = ref('')
|
||||
const holdEvidenceFile = ref<File | null>(null)
|
||||
const holdEvidenceUploadKey = ref(0)
|
||||
const holdUploadedEvidenceFileId = ref('')
|
||||
const holdEvidenceError = ref<ApiErrorDetails | null>(null)
|
||||
const holdDownloadingFileId = ref('')
|
||||
const relationOpen = ref(false)
|
||||
const relationOrder = ref<UnifiedOrderResource | null>(null)
|
||||
const measurementDetail = ref<InboundMeasurementDetail | null>(null)
|
||||
@@ -1372,10 +1379,46 @@ function openHoldDialog(action: 'HOLD' | 'RELEASE') {
|
||||
if (!allowed || !holdDetail.value) return
|
||||
holdAction.value = action
|
||||
holdReason.value = ''
|
||||
holdEvidenceFile.value = null
|
||||
holdUploadedEvidenceFileId.value = ''
|
||||
holdEvidenceError.value = null
|
||||
holdEvidenceUploadKey.value += 1
|
||||
holdDialogOpen.value = true
|
||||
}
|
||||
|
||||
const holdEvidenceMaxBytes = 10 * 1024 * 1024
|
||||
const holdEvidenceExtensions = new Set(['txt', 'pdf', 'jpg', 'jpeg', 'png'])
|
||||
|
||||
function selectHoldEvidenceFile(upload: UploadFile) {
|
||||
const file = upload.raw || null
|
||||
holdEvidenceFile.value = null
|
||||
holdUploadedEvidenceFileId.value = ''
|
||||
holdEvidenceError.value = null
|
||||
if (!file) return
|
||||
const extension = file.name.split('.').pop()?.toLowerCase() || ''
|
||||
if (!holdEvidenceExtensions.has(extension)) {
|
||||
holdEvidenceError.value = { code: 'FILE_TYPE_INVALID', message: '关联文件仅支持 TXT、PDF、JPG、PNG' }
|
||||
holdEvidenceUploadKey.value += 1
|
||||
return
|
||||
}
|
||||
if (file.size <= 0 || file.size > holdEvidenceMaxBytes) {
|
||||
holdEvidenceError.value = { code: 'FILE_SIZE_INVALID', message: '关联文件不能为空且不能超过 10MB' }
|
||||
holdEvidenceUploadKey.value += 1
|
||||
return
|
||||
}
|
||||
holdEvidenceFile.value = file
|
||||
}
|
||||
|
||||
function removeHoldEvidenceFile() {
|
||||
holdEvidenceFile.value = null
|
||||
holdUploadedEvidenceFileId.value = ''
|
||||
holdEvidenceError.value = null
|
||||
}
|
||||
|
||||
async function submitHoldAction() {
|
||||
if (holdSaving.value) return
|
||||
const allowed = holdAction.value === 'HOLD' ? canHoldInbound.value : canReleaseInbound.value
|
||||
if (!allowed) return
|
||||
const reason = holdReason.value.trim()
|
||||
const inboundId = normalizedInboundId.value
|
||||
const tenantId = normalizedIdentifier(detail.value?.summary.tenantId)
|
||||
@@ -1384,9 +1427,28 @@ async function submitHoldAction() {
|
||||
ElMessage.warning(holdAction.value === 'HOLD' ? '请填写扣货原因' : '请填写解除原因')
|
||||
return
|
||||
}
|
||||
if (reason.length > 1000) {
|
||||
ElMessage.warning('原因不能超过 1000 个字符')
|
||||
return
|
||||
}
|
||||
holdSaving.value = true
|
||||
try {
|
||||
const command = { expectedVersion: holdDetail.value.version, reason }
|
||||
if (holdEvidenceFile.value && !holdUploadedEvidenceFileId.value) {
|
||||
try {
|
||||
holdEvidenceError.value = null
|
||||
const uploaded = await uploadPlatformInboundHoldEvidence(tenantId, inboundId, holdEvidenceFile.value)
|
||||
holdUploadedEvidenceFileId.value = uploaded.fileId
|
||||
} catch (error) {
|
||||
holdEvidenceError.value = getApiErrorDetails(error, '关联文件上传失败,请稍后重试')
|
||||
ElMessage.error(holdEvidenceError.value.message)
|
||||
return
|
||||
}
|
||||
}
|
||||
const command = {
|
||||
expectedVersion: holdDetail.value.version,
|
||||
reason,
|
||||
...(holdUploadedEvidenceFileId.value ? { evidenceFileId: holdUploadedEvidenceFileId.value } : {}),
|
||||
}
|
||||
holdDetail.value = holdAction.value === 'HOLD'
|
||||
? await holdPlatformInbound(tenantId, inboundId, command)
|
||||
: await releasePlatformInbound(tenantId, inboundId, command)
|
||||
@@ -1400,6 +1462,32 @@ async function submitHoldAction() {
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadHoldEvidence(fileId: string) {
|
||||
const inboundId = normalizedInboundId.value
|
||||
const tenantId = normalizedIdentifier(detail.value?.summary.tenantId)
|
||||
if (!fileId || !inboundId || !tenantId || holdDownloadingFileId.value || !can('platform:firstleg:inbound:list')) return
|
||||
holdDownloadingFileId.value = fileId
|
||||
try {
|
||||
const blob = await downloadPlatformInboundHoldEvidence(tenantId, inboundId, fileId)
|
||||
const extension = blob.type === 'application/pdf' ? '.pdf'
|
||||
: blob.type === 'image/png' ? '.png'
|
||||
: blob.type === 'image/jpeg' ? '.jpg'
|
||||
: blob.type === 'text/plain' ? '.txt' : ''
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = `扣货凭证${extension}`
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorDetails(error, '扣货凭证下载失败,请稍后重试').message)
|
||||
} finally {
|
||||
holdDownloadingFileId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
customerLoading.value = true
|
||||
customerLoadError.value = null
|
||||
@@ -2318,7 +2406,7 @@ onBeforeUnmount(() => {
|
||||
<template v-else>
|
||||
<p v-if="holdDetail?.actionBlockReason && !canHoldInbound && !canReleaseInbound" class="hold-block-reason">{{ holdDetail.actionBlockReason }}</p>
|
||||
<h3 class="hold-history-title">扣货历史</h3>
|
||||
<div v-loading="holdLoading" class="hold-history-table"><el-table :data="holdDetail?.history || []" border stripe empty-text="暂无扣货历史"><el-table-column label="操作" width="110"><template #default="{ row }"><el-tag :type="row.action === 'HOLD' ? 'danger' : 'success'" effect="plain">{{ row.action === 'HOLD' ? '扣货' : '解除扣货' }}</el-tag></template></el-table-column><el-table-column prop="reason" label="原因" min-width="260" /><el-table-column label="凭证" width="130"><template #default="{ row }">{{ row.evidenceFileId ? '已关联凭证' : '—' }}</template></el-table-column><el-table-column label="操作人" width="150"><template #default="{ row }">{{ row.operatorName || '系统' }}</template></el-table-column><el-table-column label="操作时间" width="180"><template #default="{ row }">{{ formatDate(row.operatedAt) }}</template></el-table-column></el-table></div>
|
||||
<div v-loading="holdLoading" class="hold-history-table"><el-table :data="holdDetail?.history || []" border stripe empty-text="暂无扣货历史"><el-table-column label="操作" width="110"><template #default="{ row }"><el-tag :type="row.action === 'HOLD' ? 'danger' : 'success'" effect="plain">{{ row.action === 'HOLD' ? '扣货' : '解除扣货' }}</el-tag></template></el-table-column><el-table-column prop="reason" label="原因" min-width="260" /><el-table-column label="凭证" width="150"><template #default="{ row }"><el-button v-if="row.evidenceFileId" link type="primary" :loading="holdDownloadingFileId === row.evidenceFileId" @click="downloadHoldEvidence(row.evidenceFileId)"><Download :size="14" />下载凭证</el-button><span v-else>—</span></template></el-table-column><el-table-column label="操作人" width="150"><template #default="{ row }">{{ row.operatorName || '系统' }}</template></el-table-column><el-table-column label="操作时间" width="180"><template #default="{ row }">{{ formatDate(row.operatedAt) }}</template></el-table-column></el-table></div>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
@@ -2502,9 +2590,9 @@ onBeforeUnmount(() => {
|
||||
<el-input v-model="noteContent" type="textarea" :rows="5" maxlength="2000" show-word-limit placeholder="请输入仅平台内部可见的备注" aria-label="内部备注内容" />
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="holdDialogOpen" :title="holdAction === 'HOLD' ? '确认扣货' : '确认解除扣货'" :confirm-text="holdAction === 'HOLD' ? '确认扣货' : '确认解除'" :confirm-type="holdAction === 'HOLD' ? 'danger' : 'primary'" :confirm-loading="holdSaving" width="min(600px, calc(100vw - 28px))" destroy-on-close @confirm="submitHoldAction">
|
||||
<AppDialog v-model="holdDialogOpen" :title="holdAction === 'HOLD' ? '确认扣货' : '确认解除扣货'" :confirm-text="holdAction === 'HOLD' ? '确认扣货' : '确认解除'" :confirm-type="holdAction === 'HOLD' ? 'danger' : 'primary'" :confirm-loading="holdSaving" :confirm-disabled="!holdReason.trim() || holdReason.trim().length > 1000" width="min(600px, calc(100vw - 28px))" destroy-on-close @confirm="submitHoldAction">
|
||||
<el-alert :title="holdAction === 'HOLD' ? '扣货后服务端将阻止后续实物履约操作。' : '解除后是否可继续履约仍以服务端状态为准。'" :type="holdAction === 'HOLD' ? 'warning' : 'info'" :closable="false" show-icon />
|
||||
<el-form label-position="top" class="hold-command-form"><el-form-item :label="holdAction === 'HOLD' ? '扣货原因' : '解除原因'" required><el-input v-model="holdReason" type="textarea" :rows="4" maxlength="1000" show-word-limit :placeholder="holdAction === 'HOLD' ? '请输入扣货原因' : '请输入解除扣货原因'" aria-label="扣货操作原因" /></el-form-item><p class="hold-evidence-hint">历史记录中的已有凭证可在扣货历史中查看关联状态。</p></el-form>
|
||||
<el-form label-position="top" class="hold-command-form"><el-form-item :label="holdAction === 'HOLD' ? '扣货原因' : '解除原因'" required><el-input v-model="holdReason" type="textarea" :rows="4" maxlength="1000" show-word-limit :placeholder="holdAction === 'HOLD' ? '请输入扣货原因' : '请输入解除扣货原因'" aria-label="扣货操作原因" /></el-form-item><el-form-item label="关联文件(选填)"><el-upload :key="holdEvidenceUploadKey" :auto-upload="false" :limit="1" accept=".txt,.pdf,.jpg,.jpeg,.png,text/plain,application/pdf,image/jpeg,image/png" :disabled="holdSaving" :on-change="selectHoldEvidenceFile" :on-remove="removeHoldEvidenceFile"><el-button :disabled="holdSaving"><FileUp :size="15" />选择文件</el-button><template #tip><div class="el-upload__tip">仅支持 TXT、PDF、JPG、PNG,单文件不超过 10MB;服务端将校验文件真实内容。</div></template></el-upload></el-form-item><LoadErrorState v-if="holdEvidenceError" compact title="关联文件处理失败" :message="holdEvidenceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="holdEvidenceError" /></template></LoadErrorState></el-form>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="approvalOpen" title="审核订单" confirm-text="审核通过" confirm-type="success" :confirm-loading="approvalSaving" destroy-on-close @confirm="approveOrder" @cancel="closeApproval">
|
||||
|
||||
Reference in New Issue
Block a user