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'
|
||||
|
||||
@@ -73,11 +73,11 @@ describe('unified orders API contract', () => {
|
||||
|
||||
it('normalizes C02 row IDs and keeps inboundId for measurement routing', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: {
|
||||
records: [{ orderId: '90001', orderVersion: '7', tenantId: '208', inboundId: '77', inboundVersion: 3, mainStatus: 'WAIT_MEASURE', channelCode: ' NY_EXX ', channelName: '纽约渠道', locationCodes: 'A0-01, B2-03', estimatedVolumeCbm: '0.4', expectedVolumeCbm: '0.45', actualGrossWeightKg: '12.5', actualVolumeCbm: '0.46', totalPalletCount: '3', totalNetWeightKg: '11.2', chargeableVolumeCbm: '0.5', totalQuantity: '48', skuNos: ['SKU-1', ''], requestedPickupAt: '2026-08-18T09:30:00', customerRemark: '客户备注', latestInternalNote: '最新内部备注', relation: { role: 'PARENT', rootOrderId: 90001, parentOrderId: null, splitSequence: null, childCount: '2' }, measurement: null, preload: null, loading: null, delivery: null, transport: null }],
|
||||
records: [{ orderId: '90001', orderVersion: '7', tenantId: '208', inboundId: '77', inboundVersion: 3, mainStatus: 'WAIT_MEASURE', channelCode: ' NY_EXX ', channelName: '纽约渠道', holdFlag: true, currentHoldReason: '待核对货款', locationCodes: 'A0-01, B2-03', estimatedVolumeCbm: '0.4', expectedVolumeCbm: '0.45', actualGrossWeightKg: '12.5', actualVolumeCbm: '0.46', totalPalletCount: '3', totalNetWeightKg: '11.2', chargeableVolumeCbm: '0.5', totalQuantity: '48', skuNos: ['SKU-1', ''], requestedPickupAt: '2026-08-18T09:30:00', customerRemark: '客户备注', latestInternalNote: '最新内部备注', relation: { role: 'PARENT', rootOrderId: 90001, parentOrderId: null, splitSequence: null, childCount: '2' }, measurement: null, preload: null, loading: null, delivery: null, transport: null }],
|
||||
page: 1, size: 20, total: 1, pages: 1,
|
||||
} })
|
||||
await expect(listPlatformUnifiedOrders({ page: 1, size: 20 })).resolves.toMatchObject({
|
||||
records: [{ orderId: '90001', orderVersion: 7, tenantId: '208', inboundId: '77', inboundVersion: 3, channelCode: 'NY_EXX', locationCodes: 'A0-01, B2-03', estimatedVolumeCbm: 0.4, expectedVolumeCbm: 0.45, actualGrossWeightKg: 12.5, actualVolumeCbm: 0.46, totalPalletCount: 3, totalNetWeightKg: 11.2, chargeableVolumeCbm: 0.5, totalQuantity: 48, skuNos: ['SKU-1'], requestedPickupAt: '2026-08-18T09:30:00', customerRemark: '客户备注', latestInternalNote: '最新内部备注', relation: { role: 'PARENT', rootOrderId: '90001', childCount: 2 } }],
|
||||
records: [{ orderId: '90001', orderVersion: 7, tenantId: '208', inboundId: '77', inboundVersion: 3, channelCode: 'NY_EXX', holdFlag: true, currentHoldReason: '待核对货款', locationCodes: 'A0-01, B2-03', estimatedVolumeCbm: 0.4, expectedVolumeCbm: 0.45, actualGrossWeightKg: 12.5, actualVolumeCbm: 0.46, totalPalletCount: 3, totalNetWeightKg: 11.2, chargeableVolumeCbm: 0.5, totalQuantity: 48, skuNos: ['SKU-1'], requestedPickupAt: '2026-08-18T09:30:00', customerRemark: '客户备注', latestInternalNote: '最新内部备注', relation: { role: 'PARENT', rootOrderId: '90001', childCount: 2 } }],
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -119,8 +119,8 @@ export interface UnifiedOrderResource {
|
||||
updatedAt: string | null
|
||||
expectedArrivalAt: string | null
|
||||
feeReviewStatus: string | null
|
||||
holdCargo?: boolean | null
|
||||
holdReason?: string | null
|
||||
holdFlag: boolean | null
|
||||
currentHoldReason: string | null
|
||||
companyName?: string | null
|
||||
businessType?: string | null
|
||||
pickupMethod?: string | null
|
||||
@@ -413,6 +413,8 @@ function normalizeRow(raw: UnifiedOrderResource): UnifiedOrderResource {
|
||||
receivingWarehouseId: id(raw.receivingWarehouseId),
|
||||
destinationWarehouseId: id(raw.destinationWarehouseId),
|
||||
inboundId: id(raw.inboundId),
|
||||
holdFlag: raw.holdFlag === null || raw.holdFlag === undefined ? null : Boolean(raw.holdFlag),
|
||||
currentHoldReason: optionalText(raw.currentHoldReason),
|
||||
inboundVersion: nullableInteger(raw.inboundVersion),
|
||||
relation: raw.relation ? {
|
||||
...raw.relation,
|
||||
|
||||
@@ -26,6 +26,7 @@ const rows: UnifiedOrderResource[] = ['5001', '5002'].map((orderId, index) => ({
|
||||
relation: null, internalOrderStatus: 'WAIT_SPLIT', customerId: `600${index}`, customerName: `客户${index + 1}`,
|
||||
channelCode: 'NY_EXX', channelName: '纽约渠道', receivingWarehouseId: '8', receivingWarehouseName: '义乌仓', destinationWarehouseId: '9', destinationWarehouseName: 'ONT8',
|
||||
inboundId: `700${index}`, inboundVersion: 1, inboundCode: `IN-${index + 1}`, expectedShipDate: null, totalPackageCount: 5,
|
||||
holdFlag: false, currentHoldReason: null,
|
||||
locationCodes: null, actualGrossWeightKg: 10, actualVolumeCbm: 1, measurement: null, preload: null, loading: null, delivery: null, transport: null,
|
||||
createdAt: null, updatedAt: null, expectedArrivalAt: null, feeReviewStatus: null, requestedPickupAt: null, totalPalletCount: null,
|
||||
chargeableVolumeCbm: null, volumetricWeightKg: null, totalNetWeightKg: null, measurementCompletedAt: null, inboundCompletedAt: null,
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getLoadingAssociationSummary, getOverseasInboundCodeRelationship } from '@/shared/api/inboundCodeRelations'
|
||||
import { getPlatformUnifiedOrder } from '@/modules/unified-orders/api/unifiedOrders'
|
||||
import { getPlatformUnifiedOrder, listPlatformUnifiedOrders } from '@/modules/unified-orders/api/unifiedOrders'
|
||||
import { getPlatformInboundHold, holdPlatformInbound, releasePlatformInbound } from '@/modules/inbounds'
|
||||
import UnifiedOrdersView from './UnifiedOrdersView.vue'
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ params: { orderId: '5001' }, query: { tenantId: '6001' } }),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
const { canPermission } = vi.hoisted(() => ({ canPermission: vi.fn((..._permissions: string[]) => true) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) }))
|
||||
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(),
|
||||
InboundFeePanel: { template: '<div />' }, InboundMeasurementDialog: { template: '<div />' }, MeasurementEvidenceThumbnail: { template: '<div />' },
|
||||
}))
|
||||
vi.mock('@/modules/unified-orders/workflows', () => ({ UnifiedOrderWorkflowPanel: { template: '<div />' } }))
|
||||
@@ -34,6 +37,7 @@ const detail = {
|
||||
internalOrderStatus: 'WAIT_SPLIT', customerId: '6001', customerName: '测试客户', channelName: '纽约渠道',
|
||||
receivingWarehouseId: '8', receivingWarehouseName: '义乌仓', destinationWarehouseId: '9', destinationWarehouseName: 'ONT8',
|
||||
inboundId: '7001', inboundVersion: 1, inboundCode: 'NYEXX2608310001', expectedShipDate: null, totalPackageCount: 2,
|
||||
holdFlag: false, currentHoldReason: null,
|
||||
locationCodes: null, actualGrossWeightKg: null, actualVolumeCbm: null, measurement: null, preload: null, loading: null, delivery: null, transport: null,
|
||||
coLoadGroup: null, createdAt: null, updatedAt: null, expectedArrivalAt: null, feeReviewStatus: null, requestedPickupAt: null,
|
||||
totalPalletCount: null, chargeableVolumeCbm: null, volumetricWeightKg: null, totalNetWeightKg: null,
|
||||
@@ -56,6 +60,11 @@ function mountDetail() {
|
||||
ApiErrorMeta: true,
|
||||
StatusBadge: true,
|
||||
'el-button': { template: '<button @click="$emit(\'click\')"><slot /></button>' },
|
||||
'el-table': { props: ['data'], template: '<div><slot /></div>' },
|
||||
'el-table-column': { template: '<div />' },
|
||||
'el-tag': { template: '<span><slot /></span>' },
|
||||
'el-empty': { props: ['description'], template: '<div>{{ description }}</div>' },
|
||||
'el-alert': { props: ['title'], template: '<div>{{ title }}</div>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -64,7 +73,22 @@ function mountDetail() {
|
||||
describe('UnifiedOrdersView downstream fulfillment detail', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
canPermission.mockImplementation(() => true)
|
||||
vi.mocked(getPlatformUnifiedOrder).mockResolvedValue(detail as never)
|
||||
vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
|
||||
vi.mocked(getPlatformInboundHold).mockResolvedValue({
|
||||
inboundId: '7001', tenantId: '6001', holdFlag: false, currentHoldReason: null, version: 3,
|
||||
canHold: true, canRelease: false, actionBlockReason: null,
|
||||
history: [{ id: '8801', action: 'RELEASE', reason: '货款已核对', evidenceFileId: '9901', operatorName: '操作员A', operatedAt: '2026-09-07T10:00:00' }],
|
||||
})
|
||||
vi.mocked(holdPlatformInbound).mockResolvedValue({
|
||||
inboundId: '7001', tenantId: '6001', holdFlag: true, currentHoldReason: '等待客户补款', version: 4,
|
||||
canHold: false, canRelease: true, actionBlockReason: null, history: [],
|
||||
})
|
||||
vi.mocked(releasePlatformInbound).mockResolvedValue({
|
||||
inboundId: '7001', tenantId: '6001', holdFlag: false, currentHoldReason: null, version: 4,
|
||||
canHold: true, canRelease: false, actionBlockReason: null, history: [],
|
||||
})
|
||||
vi.mocked(getLoadingAssociationSummary).mockResolvedValue({
|
||||
inboundId: '7001', inboundCode: 'NYEXX2608310001', loadingPlanId: null, loadingPlanNo: null,
|
||||
containerBookingId: null, cabinetNo: null, containerNo: null, loadingStatus: null, taskId: null,
|
||||
@@ -112,4 +136,53 @@ describe('UnifiedOrdersView downstream fulfillment detail', () => {
|
||||
expect(wrapper.text()).not.toContain('9001')
|
||||
expect(wrapper.text()).not.toContain('9004')
|
||||
})
|
||||
|
||||
it('loads server hold capabilities and history only after opening the other tab', async () => {
|
||||
const wrapper = mountDetail()
|
||||
await flushPromises()
|
||||
expect(getPlatformInboundHold).not.toHaveBeenCalled()
|
||||
await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(getPlatformInboundHold).toHaveBeenCalledWith('6001', '7001')
|
||||
expect(wrapper.text()).toContain('未扣货')
|
||||
const loaded = (wrapper.vm as unknown as { holdDetail: { history: Array<{ reason: string; evidenceFileId: string }> } }).holdDetail
|
||||
expect(loaded.history).toEqual([expect.objectContaining({ reason: '货款已核对', evidenceFileId: '9901' })])
|
||||
expect(wrapper.findAll('button').map((button) => button.text())).toContain('扣货')
|
||||
})
|
||||
|
||||
it('submits the required reason and server version, then refreshes detail and list', 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()
|
||||
expect(holdPlatformInbound).toHaveBeenCalledWith('6001', '7001', { expectedVersion: 3, 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()
|
||||
await flushPromises()
|
||||
await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(getPlatformInboundHold).toHaveBeenCalledWith('6001', '7001')
|
||||
expect(wrapper.text()).toContain('未扣货')
|
||||
expect(wrapper.findAll('button').map((button) => button.text())).not.toEqual(expect.arrayContaining(['扣货', '解除扣货']))
|
||||
})
|
||||
|
||||
it('stays read-only and does not request hold detail before an inbound exists', async () => {
|
||||
vi.mocked(getPlatformUnifiedOrder).mockResolvedValue({ ...detail, summary: { ...detail.summary, inboundId: null, inboundCode: null, holdFlag: null, currentHoldReason: null } } as never)
|
||||
const wrapper = mountDetail()
|
||||
await flushPromises()
|
||||
await wrapper.findAll('.detail-tabs button').find((button) => button.text().includes('其他'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(getPlatformInboundHold).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('订单尚未生成入库单,扣货状态仅可查看')
|
||||
expect(wrapper.findAll('button').map((button) => button.text())).not.toEqual(expect.arrayContaining(['扣货', '解除扣货']))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -79,6 +79,19 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).toContain('fbaCode: trimmed(activeFilters.fbaCode)')
|
||||
})
|
||||
|
||||
it('keeps hold state in the other tab and consumes only frozen server capabilities', () => {
|
||||
for (const marker of [
|
||||
'summary.holdFlag', 'summary.currentHoldReason', 'getPlatformInboundHold',
|
||||
'holdDetail.value?.canHold', 'holdDetail.value?.canRelease', "can('platform:firstleg:inbound:hold')",
|
||||
'扣货历史', '已关联凭证', '订单尚未生成入库单,扣货状态仅可查看',
|
||||
]) 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"')
|
||||
})
|
||||
|
||||
it('uses tabs as the only order-status filter and keeps advanced controls inside QueryToolbar', () => {
|
||||
expect(source).toContain('mainStatus: selectedStatus.value || undefined')
|
||||
expect(source).toContain('<template #actions>')
|
||||
|
||||
@@ -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, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue'
|
||||
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 { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
getPlatformUnifiedOrder,
|
||||
@@ -62,12 +62,16 @@ import {
|
||||
createFeeActionIdempotencyKey,
|
||||
getLatestInboundFee,
|
||||
getInboundMeasurement,
|
||||
getPlatformInboundHold,
|
||||
holdPlatformInbound,
|
||||
releasePlatformInbound,
|
||||
saveInboundMeasurementDraft,
|
||||
InboundFeePanel,
|
||||
InboundMeasurementDialog,
|
||||
MeasurementEvidenceThumbnail,
|
||||
sendInboundFee,
|
||||
type InboundMeasurementDetail,
|
||||
type InboundHoldDetail,
|
||||
} from '@/modules/inbounds'
|
||||
import { listCustomerChargeRules, type CustomerChargeRule } from '@/modules/fba-pricing'
|
||||
import { getCustomerDimensionConfig, type CustomerDimensionConfig, type CustomerDimensionRule } from '@/modules/dimension-config'
|
||||
@@ -162,6 +166,14 @@ const noteOpen = ref(false)
|
||||
const noteSaving = ref(false)
|
||||
const noteContent = ref('')
|
||||
const noteOrder = ref<UnifiedOrderResource | null>(null)
|
||||
const holdDetail = ref<InboundHoldDetail | null>(null)
|
||||
const holdLoading = ref(false)
|
||||
const holdError = ref<ApiErrorDetails | null>(null)
|
||||
const holdLoadedKey = ref('')
|
||||
const holdDialogOpen = ref(false)
|
||||
const holdSaving = ref(false)
|
||||
const holdAction = ref<'HOLD' | 'RELEASE'>('HOLD')
|
||||
const holdReason = ref('')
|
||||
const relationOpen = ref(false)
|
||||
const relationOrder = ref<UnifiedOrderResource | null>(null)
|
||||
const measurementDetail = ref<InboundMeasurementDetail | null>(null)
|
||||
@@ -224,6 +236,7 @@ let detailAssociationRequestSerial = 0
|
||||
let measurementRequestSerial = 0
|
||||
let measurementPreviewRequestSerial = 0
|
||||
let subsequentFulfillmentRequestSerial = 0
|
||||
let holdRequestSerial = 0
|
||||
const showAdvancedFilters = ref(false)
|
||||
const filters = ref<FilterModel>(emptyFilters())
|
||||
const appliedFilters = ref<FilterModel>(emptyFilters())
|
||||
@@ -242,6 +255,18 @@ function normalizedIdentifier(value: unknown) {
|
||||
return identifier && identifier !== 'null' && identifier !== 'undefined' ? identifier : ''
|
||||
}
|
||||
const normalizedInboundId = computed(() => normalizedIdentifier(detail.value?.summary.inboundId))
|
||||
const currentHoldFlag = computed(() => holdDetail.value ? holdDetail.value.holdFlag : detail.value?.summary.holdFlag ?? null)
|
||||
const currentHoldReason = computed(() => holdDetail.value ? holdDetail.value.currentHoldReason : detail.value?.summary.currentHoldReason ?? null)
|
||||
const canHoldInbound = computed(() => Boolean(
|
||||
normalizedInboundId.value
|
||||
&& can('platform:firstleg:inbound:hold')
|
||||
&& holdDetail.value?.canHold,
|
||||
))
|
||||
const canReleaseInbound = computed(() => Boolean(
|
||||
normalizedInboundId.value
|
||||
&& can('platform:firstleg:inbound:hold')
|
||||
&& holdDetail.value?.canRelease,
|
||||
))
|
||||
|
||||
const detailTabs = [
|
||||
{ key: 'basic', label: '基本信息', icon: OrderBasicInfoIcon },
|
||||
@@ -1192,10 +1217,40 @@ async function loadMeasurementDetails() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHoldDetail(force = false) {
|
||||
const inboundId = normalizedInboundId.value
|
||||
const tenantId = normalizedIdentifier(detail.value?.summary.tenantId)
|
||||
const key = inboundId && tenantId ? `${tenantId}:${inboundId}` : ''
|
||||
if (!key || !can('platform:firstleg:inbound:list')) {
|
||||
holdDetail.value = null
|
||||
holdError.value = null
|
||||
holdLoadedKey.value = ''
|
||||
return
|
||||
}
|
||||
if (!force && (holdLoading.value || holdLoadedKey.value === key)) return
|
||||
const serial = ++holdRequestSerial
|
||||
holdLoading.value = true
|
||||
holdError.value = null
|
||||
try {
|
||||
const result = await getPlatformInboundHold(tenantId, inboundId)
|
||||
if (serial !== holdRequestSerial) return
|
||||
holdDetail.value = result
|
||||
holdLoadedKey.value = key
|
||||
} catch (error) {
|
||||
if (serial !== holdRequestSerial) return
|
||||
holdDetail.value = null
|
||||
holdLoadedKey.value = ''
|
||||
holdError.value = getApiErrorDetails(error, '扣货状态与历史加载失败')
|
||||
} finally {
|
||||
if (serial === holdRequestSerial) holdLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectDetailTab(key: string) {
|
||||
activeDetailTab.value = key
|
||||
if (key === 'measurement') void loadMeasurementDetails()
|
||||
if (key === 'fulfillment') void loadSubsequentFulfillment()
|
||||
if (key === 'other') void loadHoldDetail()
|
||||
}
|
||||
|
||||
const hasLoadingAssociation = computed(() => Boolean(loadingAssociation.value
|
||||
@@ -1304,6 +1359,39 @@ async function saveNote() {
|
||||
}
|
||||
}
|
||||
|
||||
function openHoldDialog(action: 'HOLD' | 'RELEASE') {
|
||||
const allowed = action === 'HOLD' ? canHoldInbound.value : canReleaseInbound.value
|
||||
if (!allowed || !holdDetail.value) return
|
||||
holdAction.value = action
|
||||
holdReason.value = ''
|
||||
holdDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function submitHoldAction() {
|
||||
const reason = holdReason.value.trim()
|
||||
const inboundId = normalizedInboundId.value
|
||||
const tenantId = normalizedIdentifier(detail.value?.summary.tenantId)
|
||||
if (!holdDetail.value || !inboundId || !tenantId) return
|
||||
if (!reason) {
|
||||
ElMessage.warning(holdAction.value === 'HOLD' ? '请填写扣货原因' : '请填写解除原因')
|
||||
return
|
||||
}
|
||||
holdSaving.value = true
|
||||
try {
|
||||
const command = { expectedVersion: holdDetail.value.version, reason }
|
||||
holdDetail.value = holdAction.value === 'HOLD'
|
||||
? await holdPlatformInbound(tenantId, inboundId, command)
|
||||
: await releasePlatformInbound(tenantId, inboundId, command)
|
||||
holdDialogOpen.value = false
|
||||
await Promise.all([loadDetail(true), loadOrders()])
|
||||
ElMessage.success(holdAction.value === 'HOLD' ? '扣货成功' : '解除扣货成功')
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorDetails(error, holdAction.value === 'HOLD' ? '扣货失败' : '解除扣货失败').message)
|
||||
} finally {
|
||||
holdSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
customerLoading.value = true
|
||||
customerLoadError.value = null
|
||||
@@ -1831,6 +1919,11 @@ async function loadDetail(force = false) {
|
||||
measurementDimensionConfig.value = null
|
||||
measurementDimensionConfigLoadedKey.value = ''
|
||||
measurementPreviewOpen.value = false
|
||||
holdRequestSerial += 1
|
||||
holdDetail.value = null
|
||||
holdError.value = null
|
||||
holdLoadedKey.value = ''
|
||||
holdLoading.value = false
|
||||
releaseMeasurementPreview()
|
||||
releaseCargoImagePreviews()
|
||||
detailLoading.value = true
|
||||
@@ -1840,6 +1933,7 @@ async function loadDetail(force = false) {
|
||||
if (serial !== detailRequestSerial || requestKey !== detailRouteKey.value) return
|
||||
detail.value = response
|
||||
await loadCargoImagePreviews(response, serial)
|
||||
if (activeDetailTab.value === 'other') await loadHoldDetail()
|
||||
if (requestedDetailTab.value === 'measurement') {
|
||||
activeDetailTab.value = 'measurement'
|
||||
await loadMeasurementDetails()
|
||||
@@ -1885,6 +1979,7 @@ onMounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
detailAssociationRequestSerial += 1
|
||||
measurementRequestSerial += 1
|
||||
holdRequestSerial += 1
|
||||
releaseMeasurementPreview()
|
||||
releaseCargoImagePreviews()
|
||||
})
|
||||
@@ -1964,8 +2059,6 @@ onBeforeUnmount(() => {
|
||||
<div><dt>贸易条款</dt><dd>{{ firstLegTradeTermLabel(detail.order.order?.tradeTerm) }}</dd></div>
|
||||
<div><dt>买单 / 退税</dt><dd>{{ firstLegTaxModeLabel(detail.order.order?.taxMode) }}</dd></div>
|
||||
<div><dt>费用审核状态</dt><dd>{{ feeReviewLabel(detail.summary.feeReviewStatus) }}</dd></div>
|
||||
<div><dt>是否扣货</dt><dd>{{ displayBoolean(detail.summary.holdCargo) }}</dd></div>
|
||||
<div><dt>扣货理由</dt><dd>{{ detail.summary.holdReason || '—' }}</dd></div>
|
||||
<div><dt>提货方式</dt><dd>{{ firstLegPickupMethodLabel(detail.order.order?.pickupMethod) }}</dd></div>
|
||||
<div><dt>提货时间</dt><dd>{{ formatDate(detail.summary.pickupAt) }}</dd></div>
|
||||
<div><dt>到仓时间</dt><dd>{{ detail.order.order?.receivingTime || '—' }}</dd></div>
|
||||
@@ -2204,9 +2297,28 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="panel-title"><div><h2>内部备注</h2><p>仅平台内部可见</p></div><el-button v-if="can('platform:order:note')" type="primary" size="small" @click="openNoteDialog()"><StickyNote :size="15" />添加备注</el-button></div>
|
||||
<el-empty v-if="detail.order.internalNotes.length === 0" description="暂无内部备注" :image-size="64" />
|
||||
<ul v-else class="notes-list" aria-label="内部备注"><li v-for="item in detail.order.internalNotes" :key="String(item.id)"><p>{{ item.content }}</p><span>操作人 {{ item.createdBy || '系统' }} · {{ formatDate(item.createdAt) }}</span></li></ul>
|
||||
<section class="hold-section" aria-label="订单扣货控制">
|
||||
<div class="panel-title"><div><h2>扣货控制</h2><p>扣货状态独立于快递、箱子计费和内部备注维护</p></div><ShieldAlert :size="19" /></div>
|
||||
<el-empty v-if="!normalizedInboundId" description="订单尚未生成入库单,扣货状态仅可查看" :image-size="56" />
|
||||
<template v-else>
|
||||
<div class="hold-summary">
|
||||
<dl><div><dt>当前状态</dt><dd><el-tag :type="currentHoldFlag === true ? 'danger' : currentHoldFlag === false ? 'success' : 'info'" effect="plain">{{ currentHoldFlag === true ? '已扣货' : currentHoldFlag === false ? '未扣货' : '状态未知' }}</el-tag></dd></div><div><dt>当前原因</dt><dd>{{ currentHoldReason || '—' }}</dd></div></dl>
|
||||
<div class="hold-actions"><el-button v-if="canHoldInbound" type="danger" :loading="holdSaving" @click="openHoldDialog('HOLD')"><ShieldAlert :size="15" />扣货</el-button><el-button v-if="canReleaseInbound" type="primary" plain :loading="holdSaving" @click="openHoldDialog('RELEASE')"><Undo2 :size="15" />解除扣货</el-button></div>
|
||||
</div>
|
||||
<el-alert v-if="!can('platform:firstleg:inbound:list')" title="当前账号只能查看订单返回的扣货状态,无法读取扣货历史" type="info" :closable="false" show-icon />
|
||||
<LoadErrorState v-else-if="holdError" title="扣货状态与历史加载失败" :message="holdError.message" :loading="holdLoading" @retry="loadHoldDetail(true)"><template #details><ApiErrorMeta :details="holdError" /></template></LoadErrorState>
|
||||
<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>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
<section class="other-notes-section" aria-label="内部备注">
|
||||
<div class="panel-title"><div><h2>内部备注</h2><p>仅平台内部可见,独立保存</p></div><el-button v-if="can('platform:order:note')" type="primary" size="small" @click="openNoteDialog()"><StickyNote :size="15" />添加备注</el-button></div>
|
||||
<el-empty v-if="detail.order.internalNotes.length === 0" description="暂无内部备注" :image-size="64" />
|
||||
<ul v-else class="notes-list" aria-label="内部备注"><li v-for="item in detail.order.internalNotes" :key="String(item.id)"><p>{{ item.content }}</p><span>操作人 {{ item.createdBy || '系统' }} · {{ formatDate(item.createdAt) }}</span></li></ul>
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
@@ -2382,6 +2494,11 @@ 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">
|
||||
<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>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="approvalOpen" title="审核订单" confirm-text="审核通过" confirm-type="success" :confirm-loading="approvalSaving" destroy-on-close @confirm="approveOrder" @cancel="closeApproval">
|
||||
<el-alert title="确认订单资料无误后审核通过,订单将进入待测量阶段。" type="info" :closable="false" show-icon />
|
||||
</AppDialog>
|
||||
@@ -2572,6 +2689,20 @@ onBeforeUnmount(() => {
|
||||
.weight-evidence-card dt { color: var(--cc-muted); font-size: 11px; }
|
||||
.weight-evidence-card dd { min-width: 0; margin: 0; color: var(--cc-text); font-size: 12px; font-weight: 650; text-align: right; overflow-wrap: anywhere; }
|
||||
.measurement-original-preview { display: block; width: 100%; max-height: min(72vh, 760px); object-fit: contain; border-radius: 8px; background: #f2f4f7; }
|
||||
.hold-section { padding-bottom: 18px; border-bottom: 1px solid var(--cc-border); }
|
||||
.other-notes-section { padding-top: 18px; }
|
||||
.hold-summary { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: #f8fafc; }
|
||||
.hold-summary dl { display: grid; min-width: 0; flex: 1; grid-template-columns: 150px minmax(280px, 1fr); gap: 18px; margin: 0; }
|
||||
.hold-summary dl > div { min-width: 0; }
|
||||
.hold-summary dt { margin-bottom: 5px; color: var(--cc-muted); font-size: 12px; }
|
||||
.hold-summary dd { margin: 0; color: var(--cc-text); font-size: 13px; overflow-wrap: anywhere; }
|
||||
.hold-actions { display: flex; flex: 0 0 auto; gap: 8px; }
|
||||
.hold-actions :deep(.el-button) { margin-left: 0; }
|
||||
.hold-block-reason { margin: 12px 0 0; padding: 9px 12px; border: 1px solid var(--el-color-warning-light-5); border-radius: 4px; background: var(--el-color-warning-light-9); color: var(--el-color-warning-dark-2); font-size: 12px; }
|
||||
.hold-history-title { margin: 16px 0 8px; color: #344054; font-size: 14px; }
|
||||
.hold-history-table { min-height: 90px; overflow-x: auto; }
|
||||
.hold-command-form { margin-top: 16px; }
|
||||
.hold-evidence-hint { margin: -4px 0 0; color: var(--cc-muted); font-size: 12px; }
|
||||
.history-list, .notes-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
|
||||
.history-list li { position: relative; display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: 12px; padding-bottom: 20px; }
|
||||
.history-list li:not(:last-child)::after { position: absolute; top: 15px; bottom: 0; left: 7px; width: 1px; background: var(--cc-border); content: ''; }
|
||||
|
||||
Reference in New Issue
Block a user