feat: add inbound hold evidence controls

This commit is contained in:
2026-09-07 12:21:44 +08:00
parent 60f430ced8
commit 189d1eae11
7 changed files with 382 additions and 14 deletions
+24 -1
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http' 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', () => ({ vi.mock('@/shared/api/http', () => ({
ApiResponseError: class ApiResponseError extends Error { ApiResponseError: class ApiResponseError extends Error {
@@ -146,4 +146,27 @@ describe('platform inbound query API', () => {
params: { tenantId: '10002' }, headers: { 'Idempotency-Key': 'release-key' }, 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' },
)
})
}) })
+25
View File
@@ -4,6 +4,7 @@ import type {
InboundInternalNote, InboundInternalNote,
InboundHoldCommand, InboundHoldCommand,
InboundHoldDetail, InboundHoldDetail,
InboundHoldEvidenceUpload,
InboundListQuery, InboundListQuery,
InboundOrderDetail, InboundOrderDetail,
InboundOrderSummary, InboundOrderSummary,
@@ -192,6 +193,30 @@ export function releasePlatformInbound(tenantId: string, inboundId: string, payl
return postPlatformInboundHold(tenantId, inboundId, 'release', payload, idempotencyKey) 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[]) { export async function downloadInboundCartonLabels(tenantId: string, inboundId: string, cartonIndexes?: number[]) {
const response = await http.post<Blob, Blob>( const response = await http.post<Blob, Blob>(
`${basePath}/${encodeURIComponent(inboundId)}/carton-label-pdf`, `${basePath}/${encodeURIComponent(inboundId)}/carton-label-pdf`,
+7
View File
@@ -151,6 +151,13 @@ export interface InboundHoldCommand {
evidenceFileId?: string evidenceFileId?: string
} }
export interface InboundHoldEvidenceUpload {
fileId: string
fileName: string
contentType: string
sizeBytes: number
}
export interface InboundDropshipPallet { export interface InboundDropshipPallet {
palletId: string palletId: string
palletNo: string | null palletNo: string | null
+2 -2
View File
@@ -1,5 +1,5 @@
export { getInboundMeasurement, saveInboundMeasurementDraft } from './measurement/api' 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 InboundMeasurementPanel } from './measurement/InboundMeasurementPanel.vue'
export { default as InboundMeasurementDialog } from './measurement/InboundMeasurementDialog.vue' export { default as InboundMeasurementDialog } from './measurement/InboundMeasurementDialog.vue'
export { default as MeasurementEvidenceThumbnail } from './measurement/components/MeasurementEvidenceThumbnail.vue' export { default as MeasurementEvidenceThumbnail } from './measurement/components/MeasurementEvidenceThumbnail.vue'
@@ -14,4 +14,4 @@ export {
export type { InboundFeeSnapshot } from './fee/types' export type { InboundFeeSnapshot } from './fee/types'
export { downloadWeightImage } from './weight/api/weightOcr' export { downloadWeightImage } from './weight/api/weightOcr'
export type { InboundMeasurementDetail } from './measurement/types' 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 { beforeEach, describe, expect, it, vi } from 'vitest'
import { getLoadingAssociationSummary, getOverseasInboundCodeRelationship } from '@/shared/api/inboundCodeRelations' import { getLoadingAssociationSummary, getOverseasInboundCodeRelationship } from '@/shared/api/inboundCodeRelations'
import { downloadPlatformProductImage, getPlatformUnifiedOrder, listPlatformUnifiedOrders } from '@/modules/unified-orders/api/unifiedOrders' 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' import UnifiedOrdersView from './UnifiedOrdersView.vue'
vi.mock('vue-router', () => ({ 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/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mockResolvedValue([]) }))
vi.mock('@/modules/inbounds', () => ({ vi.mock('@/modules/inbounds', () => ({
downloadWeightImage: vi.fn(), getInboundMeasurement: vi.fn(), 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 />' }, InboundFeePanel: { template: '<div />' }, InboundMeasurementDialog: { template: '<div />' }, MeasurementEvidenceThumbnail: { template: '<div />' },
})) }))
vi.mock('@/modules/unified-orders/workflows', () => ({ UnifiedOrderWorkflowPanel: { 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(getPlatformUnifiedOrder).mockResolvedValue(detail as never)
vi.mocked(downloadPlatformProductImage).mockResolvedValue(new Blob(['image'], { type: 'image/png' })) 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(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({ vi.mocked(getPlatformInboundHold).mockResolvedValue({
inboundId: '7001', tenantId: '6001', holdFlag: false, currentHoldReason: null, version: 3, inboundId: '7001', tenantId: '6001', holdFlag: false, currentHoldReason: null, version: 3,
canHold: true, canRelease: false, actionBlockReason: null, canHold: true, canRelease: false, actionBlockReason: null,
@@ -180,6 +184,226 @@ describe('UnifiedOrdersView downstream fulfillment detail', () => {
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(1) 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 () => { it('stays read-only without the independent hold permission', async () => {
canPermission.mockImplementation((...permissions: string[]) => permissions[0] !== 'platform:firstleg:inbound:hold') canPermission.mockImplementation((...permissions: string[]) => permissions[0] !== 'platform:firstleg:inbound:hold')
const wrapper = mountDetail() const wrapper = mountDetail()
@@ -83,13 +83,14 @@ describe('UnifiedOrdersView contract', () => {
for (const marker of [ for (const marker of [
'summary.holdFlag', 'summary.currentHoldReason', 'getPlatformInboundHold', 'summary.holdFlag', 'summary.currentHoldReason', 'getPlatformInboundHold',
'holdDetail.value?.canHold', 'holdDetail.value?.canRelease', "can('platform:firstleg:inbound:hold')", '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(marker)
expect(source).toContain("await Promise.all([loadDetail(true), loadOrders()])") expect(source).toContain("await Promise.all([loadDetail(true), loadOrders()])")
expect(source).not.toContain('detail.summary.holdCargo') expect(source).not.toContain('detail.summary.holdCargo')
expect(source).not.toContain('detail.summary.holdReason') expect(source).not.toContain('detail.summary.holdReason')
expect(source).not.toContain('evidenceFileId: hold') expect(source).toContain('evidenceFileId: holdUploadedEvidenceFileId.value')
expect(source).not.toContain('type="file"')
}) })
it('uses tabs as the only order-status filter and keeps advanced controls inside QueryToolbar', () => { it('uses tabs as the only order-status filter and keeps advanced controls inside QueryToolbar', () => {
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch, type Component } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch, type Component } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox, type UploadFile } 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 { 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 { useRoute, useRouter } from 'vue-router'
import { import {
getPlatformUnifiedOrder, getPlatformUnifiedOrder,
@@ -63,8 +63,10 @@ import {
getLatestInboundFee, getLatestInboundFee,
getInboundMeasurement, getInboundMeasurement,
getPlatformInboundHold, getPlatformInboundHold,
downloadPlatformInboundHoldEvidence,
holdPlatformInbound, holdPlatformInbound,
releasePlatformInbound, releasePlatformInbound,
uploadPlatformInboundHoldEvidence,
saveInboundMeasurementDraft, saveInboundMeasurementDraft,
InboundFeePanel, InboundFeePanel,
InboundMeasurementDialog, InboundMeasurementDialog,
@@ -174,6 +176,11 @@ const holdDialogOpen = ref(false)
const holdSaving = ref(false) const holdSaving = ref(false)
const holdAction = ref<'HOLD' | 'RELEASE'>('HOLD') const holdAction = ref<'HOLD' | 'RELEASE'>('HOLD')
const holdReason = ref('') 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 relationOpen = ref(false)
const relationOrder = ref<UnifiedOrderResource | null>(null) const relationOrder = ref<UnifiedOrderResource | null>(null)
const measurementDetail = ref<InboundMeasurementDetail | null>(null) const measurementDetail = ref<InboundMeasurementDetail | null>(null)
@@ -1372,10 +1379,46 @@ function openHoldDialog(action: 'HOLD' | 'RELEASE') {
if (!allowed || !holdDetail.value) return if (!allowed || !holdDetail.value) return
holdAction.value = action holdAction.value = action
holdReason.value = '' holdReason.value = ''
holdEvidenceFile.value = null
holdUploadedEvidenceFileId.value = ''
holdEvidenceError.value = null
holdEvidenceUploadKey.value += 1
holdDialogOpen.value = true 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() { 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 reason = holdReason.value.trim()
const inboundId = normalizedInboundId.value const inboundId = normalizedInboundId.value
const tenantId = normalizedIdentifier(detail.value?.summary.tenantId) const tenantId = normalizedIdentifier(detail.value?.summary.tenantId)
@@ -1384,9 +1427,28 @@ async function submitHoldAction() {
ElMessage.warning(holdAction.value === 'HOLD' ? '请填写扣货原因' : '请填写解除原因') ElMessage.warning(holdAction.value === 'HOLD' ? '请填写扣货原因' : '请填写解除原因')
return return
} }
if (reason.length > 1000) {
ElMessage.warning('原因不能超过 1000 个字符')
return
}
holdSaving.value = true holdSaving.value = true
try { 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' holdDetail.value = holdAction.value === 'HOLD'
? await holdPlatformInbound(tenantId, inboundId, command) ? await holdPlatformInbound(tenantId, inboundId, command)
: await releasePlatformInbound(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() { async function loadCustomers() {
customerLoading.value = true customerLoading.value = true
customerLoadError.value = null customerLoadError.value = null
@@ -2318,7 +2406,7 @@ onBeforeUnmount(() => {
<template v-else> <template v-else>
<p v-if="holdDetail?.actionBlockReason && !canHoldInbound && !canReleaseInbound" class="hold-block-reason">{{ holdDetail.actionBlockReason }}</p> <p v-if="holdDetail?.actionBlockReason && !canHoldInbound && !canReleaseInbound" class="hold-block-reason">{{ holdDetail.actionBlockReason }}</p>
<h3 class="hold-history-title">扣货历史</h3> <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>
</template> </template>
</section> </section>
@@ -2502,9 +2590,9 @@ onBeforeUnmount(() => {
<el-input v-model="noteContent" type="textarea" :rows="5" maxlength="2000" show-word-limit placeholder="请输入仅平台内部可见的备注" aria-label="内部备注内容" /> <el-input v-model="noteContent" type="textarea" :rows="5" maxlength="2000" show-word-limit placeholder="请输入仅平台内部可见的备注" aria-label="内部备注内容" />
</AppDialog> </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-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>
<AppDialog v-model="approvalOpen" title="审核订单" confirm-text="审核通过" confirm-type="success" :confirm-loading="approvalSaving" destroy-on-close @confirm="approveOrder" @cancel="closeApproval"> <AppDialog v-model="approvalOpen" title="审核订单" confirm-text="审核通过" confirm-type="success" :confirm-loading="approvalSaving" destroy-on-close @confirm="approveOrder" @cancel="closeApproval">