feat: add inbound hold 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, 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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user