From 19a618c9d26848df43b6fb8b75df2d029dc5ec52 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 30 Oct 2026 18:47:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/exceptionOrders.test.ts | 10 +++- .../overseas-warehouse/api/exceptionOrders.ts | 2 +- .../ExceptionProcessingPanel.test.ts | 54 ++++++++++++++++++- .../components/ExceptionProcessingPanel.vue | 41 +++++++++++--- 4 files changed, 94 insertions(+), 13 deletions(-) diff --git a/src/modules/overseas-warehouse/api/exceptionOrders.test.ts b/src/modules/overseas-warehouse/api/exceptionOrders.test.ts index e093cdc5..87a3b08f 100644 --- a/src/modules/overseas-warehouse/api/exceptionOrders.test.ts +++ b/src/modules/overseas-warehouse/api/exceptionOrders.test.ts @@ -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()), 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' } }) diff --git a/src/modules/overseas-warehouse/api/exceptionOrders.ts b/src/modules/overseas-warehouse/api/exceptionOrders.ts index 8f51fbac..e677941f 100644 --- a/src/modules/overseas-warehouse/api/exceptionOrders.ts +++ b/src/modules/overseas-warehouse/api/exceptionOrders.ts @@ -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) { diff --git a/src/modules/overseas-warehouse/components/ExceptionProcessingPanel.test.ts b/src/modules/overseas-warehouse/components/ExceptionProcessingPanel.test.ts index fd1a45f0..9d1b0ac8 100644 --- a/src/modules/overseas-warehouse/components/ExceptionProcessingPanel.test.ts +++ b/src/modules/overseas-warehouse/components/ExceptionProcessingPanel.test.ts @@ -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(), getExceptionDetail: vi.fn(), commandException: vi.fn(), uploadExceptionEvidence: vi.fn() })) +vi.mock('../api/exceptionOrders', async original => ({ ...await original(), 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>) vi.mocked(getOverseasLocation).mockResolvedValue({ id: '40', warehouseId: '92', locationCode: 'LOC' } as Awaited>) }) +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() }) diff --git a/src/modules/overseas-warehouse/components/ExceptionProcessingPanel.vue b/src/modules/overseas-warehouse/components/ExceptionProcessingPanel.vue index fdbf61b8..cd69fb86 100644 --- a/src/modules/overseas-warehouse/components/ExceptionProcessingPanel.vue +++ b/src/modules/overseas-warehouse/components/ExceptionProcessingPanel.vue @@ -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() 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

已保存 {{ detail.order.evidenceFileIds.length }} 份历史证据;本次可按需选用,不自动全部提交。