feat: add inbound hold controls

This commit is contained in:
2026-09-07 11:20:43 +08:00
parent 116f5dc494
commit 3b15c4b597
10 changed files with 352 additions and 13 deletions
+31 -1
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { downloadInboundCartonLabels, getPlatformInbound, listPlatformInbounds } from './api'
import { downloadInboundCartonLabels, getPlatformInbound, getPlatformInboundHold, holdPlatformInbound, listPlatformInbounds, releasePlatformInbound } from './api'
vi.mock('@/shared/api/http', () => ({
ApiResponseError: class ApiResponseError extends Error {
@@ -116,4 +116,34 @@ describe('platform inbound query API', () => {
message: '箱号映射不完整', code: 'A0422',
})
})
it('loads and normalizes the tenant-scoped hold state and immutable history', async () => {
vi.mocked(http.get).mockResolvedValue({ data: {
inboundId: 19001, tenantId: 10002, holdFlag: true, currentHoldReason: ' 待核对货款 ', version: '4',
canHold: false, canRelease: true, actionBlockReason: null,
history: [{ id: 88001, action: 'HOLD', reason: ' 待核对货款 ', evidenceFileId: 99001, operatorName: ' 财务A ', operatedAt: '2026-09-07T10:00:00' }],
} })
await expect(getPlatformInboundHold('10002', '19001')).resolves.toEqual({
inboundId: '19001', tenantId: '10002', holdFlag: true, currentHoldReason: '待核对货款', version: 4,
canHold: false, canRelease: true, actionBlockReason: null,
history: [{ id: '88001', action: 'HOLD', reason: '待核对货款', evidenceFileId: '99001', operatorName: '财务A', operatedAt: '2026-09-07T10:00:00' }],
})
expect(http.get).toHaveBeenCalledWith('/platform/inbounds/19001/hold', { params: { tenantId: '10002' } })
})
it('posts hold and release commands with versions, reasons and independent idempotency keys', async () => {
const result = { data: { inboundId: '19001', tenantId: '10002', holdFlag: true, currentHoldReason: '待核对货款', version: 4, canHold: false, canRelease: true, actionBlockReason: null, history: [] } }
vi.mocked(http.post).mockResolvedValue(result)
await holdPlatformInbound('10002', '19001', { expectedVersion: 3, reason: '待核对货款' }, 'hold-key')
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/inbounds/19001/hold', { expectedVersion: 3, reason: '待核对货款' }, {
params: { tenantId: '10002' }, headers: { 'Idempotency-Key': 'hold-key' },
})
await releasePlatformInbound('10002', '19001', { expectedVersion: 4, reason: '货款已核对' }, 'release-key')
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/inbounds/19001/hold/release', { expectedVersion: 4, reason: '货款已核对' }, {
params: { tenantId: '10002' }, headers: { 'Idempotency-Key': 'release-key' },
})
})
})
+60
View File
@@ -2,6 +2,8 @@ import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/s
import type {
InboundFileSummary,
InboundInternalNote,
InboundHoldCommand,
InboundHoldDetail,
InboundListQuery,
InboundOrderDetail,
InboundOrderSummary,
@@ -111,6 +113,32 @@ function normalizeDetail(raw: InboundOrderDetail): InboundOrderDetail {
}
}
function normalizeHoldDetail(raw: InboundHoldDetail): InboundHoldDetail {
return {
...raw,
inboundId: String(raw.inboundId),
tenantId: String(raw.tenantId),
holdFlag: Boolean(raw.holdFlag),
currentHoldReason: raw.currentHoldReason?.trim() || null,
version: integerValue(raw.version) || 0,
canHold: Boolean(raw.canHold),
canRelease: Boolean(raw.canRelease),
actionBlockReason: raw.actionBlockReason?.trim() || null,
history: (raw.history || []).map((item) => ({
...item,
id: String(item.id),
reason: item.reason?.trim() || '',
evidenceFileId: id(item.evidenceFileId),
operatorName: item.operatorName?.trim() || null,
})),
}
}
export function createInboundHoldIdempotencyKey(action: 'hold' | 'release') {
const uuid = globalThis.crypto?.randomUUID?.()
return uuid ? `inbound-${action}-${uuid}` : `inbound-${action}-${Date.now()}-${Math.random().toString(36).slice(2)}`
}
function queryParams(tenantId: string | undefined, query: InboundListQuery) {
return Object.fromEntries(
Object.entries({ tenantId, ...query }).filter(([, value]) => value !== undefined && value !== ''),
@@ -132,6 +160,38 @@ export async function getPlatformInbound(tenantId: string, inboundId: string) {
return normalizeDetail(unwrapApiResponse(response))
}
export async function getPlatformInboundHold(tenantId: string, inboundId: string) {
const response = await http.get<ApiEnvelope<InboundHoldDetail>, ApiEnvelope<InboundHoldDetail>>(
`${basePath}/${encodeURIComponent(inboundId)}/hold`,
{ params: { tenantId } },
)
return normalizeHoldDetail(unwrapApiResponse(response))
}
async function postPlatformInboundHold(
tenantId: string,
inboundId: string,
action: 'hold' | 'release',
payload: InboundHoldCommand,
idempotencyKey = createInboundHoldIdempotencyKey(action),
) {
const suffix = action === 'release' ? '/release' : ''
const response = await http.post<ApiEnvelope<InboundHoldDetail>, ApiEnvelope<InboundHoldDetail>>(
`${basePath}/${encodeURIComponent(inboundId)}/hold${suffix}`,
payload,
{ params: { tenantId }, headers: { 'Idempotency-Key': idempotencyKey } },
)
return normalizeHoldDetail(unwrapApiResponse(response))
}
export function holdPlatformInbound(tenantId: string, inboundId: string, payload: InboundHoldCommand, idempotencyKey?: string) {
return postPlatformInboundHold(tenantId, inboundId, 'hold', payload, idempotencyKey)
}
export function releasePlatformInbound(tenantId: string, inboundId: string, payload: InboundHoldCommand, idempotencyKey?: string) {
return postPlatformInboundHold(tenantId, inboundId, 'release', payload, idempotencyKey)
}
export async function downloadInboundCartonLabels(tenantId: string, inboundId: string, cartonIndexes?: number[]) {
const response = await http.post<Blob, Blob>(
`${basePath}/${encodeURIComponent(inboundId)}/carton-label-pdf`,
+27
View File
@@ -124,6 +124,33 @@ export interface InboundOrderDetail {
statusHistory: InboundStatusHistory[]
}
export interface InboundHoldHistoryEntry {
id: string
action: 'HOLD' | 'RELEASE'
reason: string
evidenceFileId: string | null
operatorName: string | null
operatedAt: string
}
export interface InboundHoldDetail {
inboundId: string
tenantId: string
holdFlag: boolean
currentHoldReason: string | null
version: number
canHold: boolean
canRelease: boolean
actionBlockReason: string | null
history: InboundHoldHistoryEntry[]
}
export interface InboundHoldCommand {
expectedVersion: number
reason: string
evidenceFileId?: string
}
export interface InboundDropshipPallet {
palletId: string
palletNo: string | null
+2
View File
@@ -1,4 +1,5 @@
export { getInboundMeasurement, saveInboundMeasurementDraft } from './measurement/api'
export { createInboundHoldIdempotencyKey, getPlatformInboundHold, holdPlatformInbound, releasePlatformInbound } 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'
@@ -13,3 +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'