优化
This commit is contained in:
@@ -1,10 +1,16 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation, getExceptionDetail, commandException, uploadExceptionEvidence, normalizeExceptionDetail, type ExceptionDetail } from './exceptionOrders'
|
||||
import { generateExceptionReturn, createExceptionOrder, listExceptionOrders, resolveExceptionRelation, getExceptionDetail, commandException, uploadExceptionEvidence, normalizeExceptionDetail, type ExceptionDetail } from './exceptionOrders'
|
||||
|
||||
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn() } }))
|
||||
|
||||
describe('exception order API', () => {
|
||||
it('generates the return order through the dedicated endpoint without evidence or receipt data', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { orderNo: 'WC-RETURN-1' } })
|
||||
expect(await generateExceptionReturn('100', 'EX/1', 7)).toEqual({ orderNo: 'WC-RETURN-1' })
|
||||
expect(http.post).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/exception-orders/EX%2F1/generate-return-inbound',
|
||||
{ expectedVersion: 7, idempotencyKey: expect.any(String) }, { params: { customerId: '100' } })
|
||||
})
|
||||
it('normalizes native carton sources and rejects missing, ambiguous or unsafe source identities', () => {
|
||||
const raw = { order: { version: '1' }, returnSources: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: null, returnCartonId: '9007199254740997', cartonMark: 'RTN-1', maxReturnPackages: '1' }], returnReceipts: [] } as unknown as ExceptionDetail
|
||||
expect(normalizeExceptionDetail(raw).returnSources[0]).toMatchObject({ inventoryPositionId: null, returnCartonId: '9007199254740997', cartonMark: 'RTN-1', maxReturnPackages: 1 })
|
||||
@@ -33,7 +39,7 @@ describe('exception order API', () => {
|
||||
await getExceptionDetail('100', 'EX/1')
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/exception-orders/EX%2F1', { params: { customerId: '100' } })
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { exceptionNo: 'EX/1', version: '8', restoredPackages: '0' } })
|
||||
for (const action of ['start-processing', 'cancel', 'complete'] as const) {
|
||||
for (const action of ['start-processing', 'cancel', 'complete', 'force-close'] as const) {
|
||||
const body = { expectedVersion: 7, idempotencyKey: `original-${action}` }
|
||||
await commandException('100', 'EX/1', action, body)
|
||||
expect(http.post).toHaveBeenLastCalledWith(`/platform/v1/last-leg/exception-orders/EX%2F1/${action}`, body, { params: { customerId: '100' } })
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface ExceptionReturnReceipt { receiptNo: string; receivedPackages: n
|
||||
export interface ExceptionDetail { order: ExceptionOrderRow; returnSources: ExceptionReturnSource[]; returnReceipts: ExceptionReturnReceipt[]; returnOrderNo?: string | null }
|
||||
export interface ExceptionReceipt extends ReturnSourceIdentity { outboundPalletItemId: string; receivedPackages: number; targetPalletId: string; targetPalletNo: string; targetLocationId: string; targetLocationCode: string }
|
||||
export interface ExceptionCommand { expectedVersion: number; idempotencyKey: string; handlingAction?: string; reason?: string; handlingResult?: string; evidenceFileIds?: string[]; receipts?: ExceptionReceipt[] }
|
||||
export type ExceptionAction = 'start-processing' | 'complete' | 'cancel'
|
||||
export type ExceptionAction = 'start-processing' | 'complete' | 'cancel' | 'force-close'
|
||||
export interface ExceptionCommandResult { exceptionNo: string; status: string; version: number; restoredPackages: number; replayed: boolean }
|
||||
const exceptionPath = (number: string) => `/platform/v1/last-leg/exception-orders/${encodeURIComponent(number)}`
|
||||
function safeInteger(value: unknown, label: string) {
|
||||
|
||||
@@ -4,13 +4,13 @@ import { ElForm, ElFormItem, ElInput, ElInputNumber } from 'element-plus'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import ExceptionProcessingPanel from './ExceptionProcessingPanel.vue'
|
||||
import { getExceptionDetail, commandException, uploadExceptionEvidence, type ExceptionDetail, normalizeExceptionDetail } from '../api/exceptionOrders'
|
||||
import { generateExceptionReturn, getExceptionDetail, commandException, uploadExceptionEvidence, type ExceptionDetail, normalizeExceptionDetail } from '../api/exceptionOrders'
|
||||
import { getPallet } from '../api/pallets'
|
||||
import { getOverseasLocation } from '../api/locations'
|
||||
|
||||
const can = vi.hoisted(() => vi.fn(() => true))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('../api/exceptionOrders', async original => ({ ...await original<typeof import('../api/exceptionOrders')>(), getExceptionDetail: vi.fn(), commandException: vi.fn(), uploadExceptionEvidence: vi.fn() }))
|
||||
vi.mock('../api/exceptionOrders', async original => ({ ...await original<typeof import('../api/exceptionOrders')>(), generateExceptionReturn: vi.fn(), getExceptionDetail: vi.fn(), commandException: vi.fn(), uploadExceptionEvidence: vi.fn() }))
|
||||
vi.mock('../api/pallets', () => ({ getPallet: vi.fn(), listPallets: vi.fn() }))
|
||||
vi.mock('../api/locations', () => ({ getOverseasLocation: vi.fn() }))
|
||||
const shell = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
|
||||
@@ -77,6 +77,53 @@ beforeEach(() => {
|
||||
vi.mocked(getPallet).mockResolvedValue({ id: '30', warehouseId: '92', locationId: '40', palletCode: 'TARGET' } as Awaited<ReturnType<typeof getPallet>>)
|
||||
vi.mocked(getOverseasLocation).mockResolvedValue({ id: '40', warehouseId: '92', locationCode: 'LOC' } as Awaited<ReturnType<typeof getOverseasLocation>>)
|
||||
})
|
||||
it.each(['PENDING', 'PROCESSING'])('generates a return order without evidence or receipt inputs in %s', async status => {
|
||||
const state = snapshot('EX-1', 4)
|
||||
state.order.status = status; state.order.evidenceFileIds = []
|
||||
state.order.allowedActions = ['GENERATE_RETURN_INBOUND', 'COMPLETE_EXCEPTION', 'FORCE_CLOSE']
|
||||
const generated = { ...state, returnOrderNo: 'WC-RETURN-1', order: { ...state.order, status: 'PROCESSING', allowedActions: ['COMPLETE_EXCEPTION'] } }
|
||||
vi.mocked(getExceptionDetail).mockResolvedValueOnce(state).mockResolvedValue(generated)
|
||||
vi.mocked(generateExceptionReturn).mockResolvedValue({ orderNo: 'WC-RETURN-1' })
|
||||
const wrapper = open()
|
||||
try {
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-test="evidence"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-test="quantity-0"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-test="complete"]').exists()).toBe(false)
|
||||
await wrapper.get('[data-test="generate-return"]').trigger('click'); await flushPromises()
|
||||
expect(generateExceptionReturn).toHaveBeenCalledExactlyOnceWith('C', 'EX-1', 4)
|
||||
expect(commandException).not.toHaveBeenCalled(); expect(uploadExceptionEvidence).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('WC-RETURN-1')
|
||||
expect(wrapper.text()).toContain('请在 PDA 退货入库中验收')
|
||||
expect(wrapper.find('[data-test="generate-return"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-test="complete"]').exists()).toBe(false)
|
||||
expect(wrapper.emitted('changed')).toHaveLength(1)
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
it.each(['RETURN', 'DAMAGED', 'REJECT', 'OTHER'])('offers other handling for %s and closes with a required reason only', async exceptionType => {
|
||||
const state = snapshot('EX-1', 4)
|
||||
state.order.exceptionType = exceptionType; state.order.allowedActions = ['FORCE_CLOSE', 'COMPLETE_EXCEPTION']; state.order.evidenceFileIds = []
|
||||
state.returnOrderNo = exceptionType === 'RETURN' ? 'WC-RETURN-1' : null
|
||||
vi.mocked(getExceptionDetail).mockResolvedValueOnce(state).mockResolvedValue(snapshot('EX-1', 5, 0))
|
||||
vi.mocked(commandException).mockResolvedValue({ exceptionNo: 'EX-1', status: 'COMPLETED', version: 5, restoredPackages: 0, replayed: false })
|
||||
const wrapper = open()
|
||||
try {
|
||||
await flushPromises(); await wrapper.get('[data-test="processing-method"]').setValue('other'); await flushPromises()
|
||||
expect(wrapper.get('[data-test="notes"]').attributes('placeholder')).toBe('请填写其他处理方式及完结原因')
|
||||
expect(wrapper.find('[data-test="evidence"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-test="complete"]').exists()).toBe(false)
|
||||
await wrapper.get('[data-test="notes"]').setValue(' ')
|
||||
await wrapper.get('[data-test="force-close"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('请填写其他处理方式的完结原因'); expect(commandException).not.toHaveBeenCalled()
|
||||
await wrapper.get('[data-test="notes"]').setValue(' 客户沟通后结束处理 ')
|
||||
await wrapper.get('[data-test="force-close"]').trigger('click'); await flushPromises()
|
||||
expect(commandException).toHaveBeenCalledExactlyOnceWith('C', 'EX-1', 'force-close', {
|
||||
expectedVersion: 4, idempotencyKey: expect.any(String), reason: '客户沟通后结束处理',
|
||||
})
|
||||
expect(uploadExceptionEvidence).not.toHaveBeenCalled(); expect(generateExceptionReturn).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('[data-test="force-close"]').exists()).toBe(false)
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
it('real receipt quantity control becomes accessible after starting processing', async () => {
|
||||
const pending = snapshot('EX-1', 0, 2)
|
||||
pending.order.status = 'PENDING'; pending.order.allowedActions = ['START_PROCESSING']; pending.returnSources = []
|
||||
@@ -166,7 +213,10 @@ it('keeps a conflict visible and reloads when the operator reopens the detail',
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('has no processing or upload controls without independent permission', async () => {
|
||||
const state = snapshot(); state.order.allowedActions = ['COMPLETE_EXCEPTION', 'FORCE_CLOSE']
|
||||
vi.mocked(getExceptionDetail).mockResolvedValue(state)
|
||||
can.mockReturnValue(false); const wrapper = open(); await flushPromises()
|
||||
expect(wrapper.find('[data-test="processing-method"]').exists()).toBe(false); expect(wrapper.find('[data-test="force-close"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-test="complete"]').exists()).toBe(false); expect(wrapper.find('[data-test="evidence"]').exists()).toBe(false)
|
||||
expect(commandException).not.toHaveBeenCalled(); expect(uploadExceptionEvidence).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -30,12 +30,17 @@ const pending = computed(() => pendingByScope.get(identity.value))
|
||||
const locked = computed(() => busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || !!pending.value)
|
||||
const warehouseReturn = computed(() => detail.value?.order.exceptionType === 'RETURN' && detail.value.order.returnDirection === 'OVERSEAS_WAREHOUSE')
|
||||
const allowed = (action: string) => canProcess.value && detail.value?.order.allowedActions?.includes(action)
|
||||
const processingMethod = ref('standard')
|
||||
const otherMethod = computed(() => processingMethod.value === 'other' && allowed('FORCE_CLOSE'))
|
||||
// 已生成的退回单由 PDA 验收;生成入口与后台完成入口不能同时开放。
|
||||
const canComplete = computed(() => allowed('COMPLETE_EXCEPTION') && !detail.value?.returnOrderNo && !allowed('GENERATE_RETURN_INBOUND'))
|
||||
let sequence = 0
|
||||
const targetSequences = new Map<number, number>()
|
||||
const current = (ticket: number) => props.active && ticket === sequence
|
||||
function definite(cause: unknown) { return cause instanceof ApiResponseError && /^A0[34]\d{2}$/.test(cause.code) && (!cause.status || cause.status < 500) }
|
||||
async function load() {
|
||||
const ticket = ++sequence; loading.value = true; error.value = ''; detail.value = null; receipts.value = []; targets.value = []; evidence.value = []; selectedHistory.value = []; notes.value = ''; targetSequences.clear()
|
||||
processingMethod.value = 'standard'
|
||||
try {
|
||||
const result = await getExceptionDetail(props.customerId, props.exceptionNo)
|
||||
if (!current(ticket)) return
|
||||
@@ -94,10 +99,12 @@ async function upload(event: Event) {
|
||||
} finally { uploadingScopes.delete(scope); if (current(ticket)) busy.value = false; input.value = '' }
|
||||
}
|
||||
async function execute(action: ExceptionAction) {
|
||||
const actionName = { 'start-processing': 'START_PROCESSING', complete: 'COMPLETE_EXCEPTION', cancel: 'CANCEL_EXCEPTION' }[action]
|
||||
if (action === 'complete' && !canComplete.value) return
|
||||
const actionName = { 'start-processing': 'START_PROCESSING', complete: 'COMPLETE_EXCEPTION', cancel: 'CANCEL_EXCEPTION', 'force-close': 'FORCE_CLOSE' }[action]
|
||||
if (busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || loading.value || !canProcess.value || (!pending.value && !allowed(actionName))) return
|
||||
if (pending.value && pending.value.action !== action) return
|
||||
if (!pending.value) {
|
||||
if (action === 'force-close' && !notes.value.trim()) { error.value = '请填写其他处理方式的完结原因'; return }
|
||||
const selectedFiles = [...new Set([...evidence.value.map(row => row.fileId), ...selectedHistory.value])]
|
||||
if (action === 'complete' && (selectedFiles.length < 1 || selectedFiles.length > 20)) { error.value = '本次请选择或上传1–20份处理证据'; return }
|
||||
const selected = receipts.value.filter(row => Number(row.quantity) > 0)
|
||||
@@ -110,6 +117,7 @@ async function execute(action: ExceptionAction) {
|
||||
const payload: ExceptionCommand = { expectedVersion: detail.value!.order.version!, idempotencyKey: crypto.randomUUID() }
|
||||
if (action === 'start-processing') payload.handlingAction = notes.value.trim() || undefined
|
||||
if (action === 'cancel') payload.reason = notes.value.trim() || '取消异常处理'
|
||||
if (action === 'force-close') payload.reason = notes.value.trim()
|
||||
if (action === 'complete') {
|
||||
payload.handlingResult = notes.value.trim() || (warehouseReturn.value ? '确认本次退回入库' : '确认异常处理完成'); payload.evidenceFileIds = selectedFiles
|
||||
payload.receipts = warehouseReturn.value ? selected.map(row => ({ outboundPalletItemId: row.outboundPalletItemId, ...returnSourceReference(row), receivedPackages: row.quantity!, targetPalletId: row.target, targetPalletNo: row.palletNo, targetLocationId: row.locationId, targetLocationCode: row.locationCode })) : []
|
||||
@@ -124,7 +132,7 @@ async function execute(action: ExceptionAction) {
|
||||
busy.value = true; error.value = ''; message.value = ''
|
||||
try {
|
||||
const result = await commandException(customer, number, attempt.action, attempt.payload)
|
||||
const statuses = attempt.action === 'complete' ? ['PROCESSING', 'COMPLETED'] : [attempt.action === 'cancel' ? 'CANCELLED' : 'PROCESSING']
|
||||
const statuses = attempt.action === 'complete' ? ['PROCESSING', 'COMPLETED'] : [attempt.action === 'force-close' ? 'COMPLETED' : attempt.action === 'cancel' ? 'CANCELLED' : 'PROCESSING']
|
||||
if (!result || result.exceptionNo !== number || Number(result.version) !== attempt.payload.expectedVersion + 1 || !statuses.includes(result.status))
|
||||
throw new Error('处理结果无法核对,请刷新当前记录')
|
||||
if (sameAttempt()) { pendingByScope.delete(scope); savedEvidence.delete(scope) }
|
||||
@@ -149,9 +157,16 @@ function selectHistory(fileId: string, event: Event) {
|
||||
/** 生成动作只建单,实物入账由PDA完成。 */
|
||||
async function generateReturn() {
|
||||
if (locked.value || !allowed('GENERATE_RETURN_INBOUND') || !detail.value) return
|
||||
const ticket = sequence
|
||||
const ticket = sequence, scope = identity.value
|
||||
busy.value = true; error.value = ''
|
||||
try { await generateExceptionReturn(props.customerId, props.exceptionNo, detail.value.order.version!); if (current(ticket)) { busy.value = false; emit('changed'); await load() } }
|
||||
try {
|
||||
const result = await generateExceptionReturn(props.customerId, props.exceptionNo, detail.value.order.version!)
|
||||
if (!result?.orderNo) throw new Error('生成结果无法核对,请重新打开详情')
|
||||
if (current(ticket)) {
|
||||
busy.value = false; emit('changed'); await load()
|
||||
if (props.active && identity.value === scope) message.value = `已生成退回尾程单 ${result.orderNo},请在 PDA 退货入库中验收`
|
||||
}
|
||||
}
|
||||
catch (cause) { if (current(ticket)) error.value = getApiErrorDetails(cause, '生成退回入库单失败').message }
|
||||
finally { if (current(ticket)) busy.value = false }
|
||||
}
|
||||
@@ -210,8 +225,14 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
|
||||
<p v-if="detail.order.evidenceFileIds?.length">已保存 {{ detail.order.evidenceFileIds.length }} 份历史证据;本次可按需选用,不自动全部提交。</p>
|
||||
<template v-if="canProcess">
|
||||
<el-form class="density-form density-form--outlined exception-processing-form" label-position="left" label-width="140px" :disabled="locked">
|
||||
<el-form-item label="处理说明 / 取消原因(选填)" label-width="220px"><el-input v-model="notes" data-test="notes" type="textarea" :rows="2" maxlength="1000" placeholder="请输入处理说明或取消原因(选填)" /></el-form-item>
|
||||
<template v-if="allowed('COMPLETE_EXCEPTION') && !detail.returnOrderNo">
|
||||
<el-form-item v-if="allowed('FORCE_CLOSE')" label="处理方式">
|
||||
<el-select v-model="processingMethod" data-test="processing-method" :disabled="locked">
|
||||
<el-option value="standard" :label="allowed('GENERATE_RETURN_INBOUND') ? '退回海外仓' : '按流程处理'" />
|
||||
<el-option value="other" label="其他" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="otherMethod ? '完结原因(必填)' : '处理说明 / 取消原因(选填)'" :required="!!otherMethod" label-width="220px"><el-input v-model="notes" data-test="notes" type="textarea" :rows="2" maxlength="1000" :placeholder="otherMethod ? '请填写其他处理方式及完结原因' : '请输入处理说明或取消原因(选填)'" /></el-form-item>
|
||||
<template v-if="canComplete && !otherMethod">
|
||||
<label>处理证据 <input data-test="evidence" type="file" :disabled="locked" @change="upload" /></label>
|
||||
<div v-for="(fileId, index) in detail.order.evidenceFileIds || []" :key="fileId"><label><input :data-test="`history-${index}`" type="checkbox" :checked="selectedHistory.includes(fileId)" :disabled="locked" @change="selectHistory(fileId, $event)" />选用历史证据 {{ index + 1 }}</label></div>
|
||||
<ul><li v-for="(file, index) in evidence" :key="index">{{ file.fileName }} <el-button :disabled="locked" @click="removeEvidence(index)">移出本次提交</el-button></li></ul>
|
||||
@@ -227,13 +248,17 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
|
||||
</template>
|
||||
<p v-else-if="detail.order.exceptionType === 'RETURN'">关闭此异常只记录处理结果,不代表货物交接完成,也不会释放车辆或司机。</p>
|
||||
</template>
|
||||
<p v-if="otherMethod">填写原因后直接完结此异常单,无需上传资料。关联退回单及货物实收仍按实际业务处理。</p>
|
||||
</el-form>
|
||||
<Teleport :to="actionsTarget || 'body'" :disabled="!actionsTarget" defer>
|
||||
<div class="density-actions density-actions--footer">
|
||||
<el-button v-if="allowed('GENERATE_RETURN_INBOUND')" type="primary" :disabled="locked" @click="generateReturn">生成退回入库单</el-button>
|
||||
<el-button v-if="otherMethod" data-test="force-close" type="primary" :disabled="locked" @click="execute('force-close')">完结异常处理</el-button>
|
||||
<template v-else>
|
||||
<el-button v-if="allowed('GENERATE_RETURN_INBOUND') && !detail.returnOrderNo" data-test="generate-return" type="primary" :disabled="locked" @click="generateReturn">生成退回入库单</el-button>
|
||||
<el-button v-if="allowed('START_PROCESSING')" data-test="start" type="primary" :disabled="busy || (!!pending && pending.action !== 'start-processing')" @click="execute('start-processing')">{{ '开始处理' }}</el-button>
|
||||
<el-button v-if="allowed('CANCEL_EXCEPTION')" data-test="cancel" type="danger" plain :disabled="busy || (!!pending && pending.action !== 'cancel')" @click="execute('cancel')">{{ '取消异常' }}</el-button>
|
||||
<el-button v-if="allowed('COMPLETE_EXCEPTION')" data-test="complete" type="primary" :disabled="busy || (!!pending && pending.action !== 'complete')" @click="execute('complete')">{{ warehouseReturn ? '提交本批实收' : '完成处理' }}</el-button>
|
||||
<el-button v-if="canComplete" data-test="complete" type="primary" :disabled="busy || (!!pending && pending.action !== 'complete')" @click="execute('complete')">{{ warehouseReturn ? '提交本批实收' : '完成处理' }}</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user