feat(overseas): show return receipt and redispatch history
This commit is contained in:
@@ -5,15 +5,24 @@ import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation, ge
|
||||
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('preserves receipt and dispatch business numbers while normalizing all actual history quantities', () => {
|
||||
const raw = { order: { version: '2' }, returnSources: [], returnReceipts: [{ receiptNo: 'RSR-9007199254740993', receivedPackages: '2', receivedAt: '2026-10-03T23:24:13',
|
||||
redispatches: [{ dispatchNo: 'DS/1', status: 'DEPARTED', selectedPackages: '2', pickedPackages: '2', deliveries: [{ deliveryNo: 'DL/1', status: 'DELIVERED' }] }] }] } as unknown as ExceptionDetail
|
||||
const result = normalizeExceptionDetail(raw)
|
||||
expect(result.returnReceipts[0]).toMatchObject({ receiptNo: 'RSR-9007199254740993', receivedPackages: 2,
|
||||
redispatches: [{ dispatchNo: 'DS/1', selectedPackages: 2, pickedPackages: 2 }] })
|
||||
raw.returnReceipts[0]!.receivedPackages = null as unknown as number
|
||||
expect(() => normalizeExceptionDetail(raw)).toThrow('历史实收数量无效')
|
||||
})
|
||||
it('normalizes actual string versions and quantities without converting business references to numbers', () => {
|
||||
const raw = { order: { version: '7', evidenceFileIds: ['190000000000000099'] }, returnSources: [{ outboundPalletItemId: '190000000000000098', inventoryPositionId: '190000000000000097', maxReturnPackages: '2' }] } as unknown as ExceptionDetail
|
||||
const raw = { order: { version: '7', evidenceFileIds: ['190000000000000099'] }, returnSources: [{ outboundPalletItemId: '190000000000000098', inventoryPositionId: '190000000000000097', maxReturnPackages: '2' }], returnReceipts: [] } as unknown as ExceptionDetail
|
||||
const result = normalizeExceptionDetail(raw)
|
||||
expect(result.order.version).toBe(7); expect(result.returnSources[0]?.maxReturnPackages).toBe(2)
|
||||
expect(result.returnSources[0]?.outboundPalletItemId).toBe('190000000000000098')
|
||||
for (const version of ['', '-1', '1.5', '9007199254740993', undefined]) expect(() => normalizeExceptionDetail({ ...raw, order: { ...raw.order, version } } as unknown as ExceptionDetail)).toThrow()
|
||||
})
|
||||
it('loads authoritative detail and scopes every versioned command and evidence upload to its customer and business number', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { order: { version: '7', allowedActions: ['COMPLETE_EXCEPTION'] }, returnSources: [] } })
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { order: { version: '7', allowedActions: ['COMPLETE_EXCEPTION'] }, returnSources: [], returnReceipts: [] } })
|
||||
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' } })
|
||||
|
||||
@@ -14,7 +14,10 @@ export interface ExceptionOrderRow {
|
||||
}
|
||||
|
||||
export interface ExceptionReturnSource { outboundPalletItemId: string; inventoryPositionId: string; palletNo?: string | null; skuCode?: string | null; maxReturnPackages: number }
|
||||
export interface ExceptionDetail { order: ExceptionOrderRow; returnSources: ExceptionReturnSource[] }
|
||||
export interface ExceptionRelatedDelivery { deliveryNo: string; status: string }
|
||||
export interface ExceptionRedispatch { dispatchNo: string; status: string; selectedPackages: number; pickedPackages: number; deliveries: ExceptionRelatedDelivery[] }
|
||||
export interface ExceptionReturnReceipt { receiptNo: string; receivedPackages: number; receivedAt: string; redispatches: ExceptionRedispatch[] }
|
||||
export interface ExceptionDetail { order: ExceptionOrderRow; returnSources: ExceptionReturnSource[]; returnReceipts: ExceptionReturnReceipt[] }
|
||||
export interface ExceptionReceipt { outboundPalletItemId: string; inventoryPositionId: 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'
|
||||
@@ -26,7 +29,10 @@ function safeInteger(value: unknown, label: string) {
|
||||
}
|
||||
export function normalizeExceptionDetail(value: ExceptionDetail): ExceptionDetail {
|
||||
return { ...value, order: { ...value.order, version: safeInteger(value.order.version, '异常版本'), evidenceFileIds: value.order.evidenceFileIds?.map(String) },
|
||||
returnSources: value.returnSources.map(row => ({ ...row, outboundPalletItemId: String(row.outboundPalletItemId), inventoryPositionId: String(row.inventoryPositionId), maxReturnPackages: safeInteger(row.maxReturnPackages, '剩余可收数量') })) }
|
||||
returnSources: value.returnSources.map(row => ({ ...row, outboundPalletItemId: String(row.outboundPalletItemId), inventoryPositionId: String(row.inventoryPositionId), maxReturnPackages: safeInteger(row.maxReturnPackages, '剩余可收数量') })),
|
||||
returnReceipts: value.returnReceipts.map(receipt => ({ ...receipt, receivedPackages: safeInteger(receipt.receivedPackages, '历史实收数量'),
|
||||
redispatches: receipt.redispatches.map(dispatch => ({ ...dispatch, selectedPackages: safeInteger(dispatch.selectedPackages, '重派选定数量'),
|
||||
pickedPackages: safeInteger(dispatch.pickedPackages, '重派实拣数量') })) })) }
|
||||
}
|
||||
export async function getExceptionDetail(customerId: string, exceptionNo: string) {
|
||||
const response = await http.get<ApiEnvelope<ExceptionDetail>, ApiEnvelope<ExceptionDetail>>(exceptionPath(exceptionNo), { params: { customerId } })
|
||||
|
||||
@@ -23,11 +23,11 @@ function snapshot(number = 'EX-1', version = 1, remaining = 4): ExceptionDetail
|
||||
return normalizeExceptionDetail({ order: { id: '1', exceptionNo: number, customerId: 'C', exceptionType: 'RETURN', returnDirection: 'OVERSEAS_WAREHOUSE',
|
||||
overseasWarehouseId: '92', status: remaining ? 'PROCESSING' : 'COMPLETED', version: String(version), allowedActions: remaining ? ['COMPLETE_EXCEPTION'] : [],
|
||||
reason: '拒收', exceptionAt: '', updatedAt: '', evidenceFileIds: ['91'] },
|
||||
returnSources: remaining ? [{ outboundPalletItemId: '10', inventoryPositionId: '20', palletNo: 'ORIGINAL', skuCode: 'SKU', maxReturnPackages: String(remaining) }] : [] } as unknown as ExceptionDetail)
|
||||
returnSources: remaining ? [{ outboundPalletItemId: '10', inventoryPositionId: '20', palletNo: 'ORIGINAL', skuCode: 'SKU', maxReturnPackages: String(remaining) }] : [], returnReceipts: [] } as unknown as ExceptionDetail)
|
||||
}
|
||||
function open() { return mount(ExceptionProcessingPanel, { props: { customerId: 'C', exceptionNo: 'EX-1', active: true }, global: {
|
||||
directives: { loading: () => {} }, stubs: { ElDescriptions: shell, ElDescriptionsItem: shell, ElForm: shell, ElFormItem: shell,
|
||||
ElInput: field, ElInputNumber: quantity, ElSelect: field, ElOption: true, ElButton: { template: '<button><slot /></button>' } },
|
||||
ElInput: field, ElInputNumber: quantity, ElSelect: field, ElOption: true, RouterLink: { template: '<a><slot /></a>' }, ElButton: { template: '<button><slot /></button>' } },
|
||||
} }) }
|
||||
async function fill(wrapper: ReturnType<typeof open>, amount = 2) {
|
||||
if (wrapper.find('[data-test="history-0"]').exists()) await wrapper.get('[data-test="history-0"]').setValue(true)
|
||||
@@ -35,6 +35,40 @@ async function fill(wrapper: ReturnType<typeof open>, amount = 2) {
|
||||
await wrapper.get('[data-test="quantity-0"]').setValue(amount)
|
||||
await wrapper.get('[data-test="target-0"]').setValue('30'); await flushPromises()
|
||||
}
|
||||
|
||||
it('completed detail retains multiple receipt batches and redispatches as read-only history without commands', async () => {
|
||||
const completed = snapshot('EX-1', 7, 0)
|
||||
completed.returnReceipts = [
|
||||
{ receiptNo: 'RSR-1', receivedPackages: 2, receivedAt: '2026-10-03T23:24:13', redispatches: [
|
||||
{ dispatchNo: 'DS-1', status: 'DEPARTED', selectedPackages: 1, pickedPackages: 1, deliveries: [{ deliveryNo: 'DL-1', status: 'DELIVERED' }] },
|
||||
{ dispatchNo: 'DS-2', status: 'PENDING_DEPARTURE', selectedPackages: 1, pickedPackages: 0, deliveries: [] },
|
||||
] },
|
||||
{ receiptNo: 'RSR-2', receivedPackages: 1, receivedAt: '2026-10-03T23:25:13', redispatches: [] },
|
||||
]
|
||||
vi.mocked(getExceptionDetail).mockResolvedValue(completed)
|
||||
const wrapper = open(); await flushPromises()
|
||||
for (const text of ['RSR-1', 'RSR-2', 'DS-1', 'DS-2', 'DL-1', '尚未关联重派排车', '已发车', '待发车']) expect(wrapper.text()).toContain(text)
|
||||
expect(wrapper.findAll('.exception-return-batch')).toHaveLength(2)
|
||||
expect(wrapper.find('[data-test="start"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-test="complete"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-test="cancel"]').exists()).toBe(false)
|
||||
expect(commandException).not.toHaveBeenCalled(); expect(uploadExceptionEvidence).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('related business numbers remain visible without independent list permissions, but no navigation links are offered', async () => {
|
||||
can.mockReturnValue(false)
|
||||
const completed = snapshot('EX-1', 7, 0)
|
||||
completed.returnReceipts = [{ receiptNo: 'RSR-1', receivedPackages: 2, receivedAt: '', redispatches: [
|
||||
{ dispatchNo: 'DS-1', status: 'DEPARTED', selectedPackages: 2, pickedPackages: 2, deliveries: [{ deliveryNo: 'DL-1', status: 'DELIVERED' }] },
|
||||
] }]
|
||||
vi.mocked(getExceptionDetail).mockResolvedValue(completed)
|
||||
const wrapper = open(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('DS-1'); expect(wrapper.text()).toContain('DL-1')
|
||||
expect(wrapper.find('.exception-return-history a').exists()).toBe(false)
|
||||
expect(commandException).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks(); can.mockReturnValue(true)
|
||||
vi.mocked(getExceptionDetail).mockResolvedValue(snapshot())
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { ApiResponseError, getApiErrorDetails } from '@/shared/api/http'
|
||||
import { commandException, getExceptionDetail, uploadExceptionEvidence, type ExceptionAction, type ExceptionCommand, type ExceptionDetail, type ExceptionReturnSource } from '../api/exceptionOrders'
|
||||
import { getPallet, listPallets, type PalletDto } from '../api/pallets'
|
||||
import { getOverseasLocation } from '../api/locations'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
|
||||
const props = defineProps<{ customerId: string; exceptionNo: string; active: boolean }>()
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const canProcess = computed(() => access.can('platform:overseas-warehouse:exception-order:process'))
|
||||
const dispatchStatusLabels: Record<string, string> = { PENDING_DEPARTURE: '待发车', PICKING: '拣货中', READY_TO_DEPART: '待发车', DEPARTED: '已发车', IN_TRANSIT: '在途', COMPLETED: '已完成', CANCELLED: '已取消' }
|
||||
const dispatchStatusLabel = (status: string) => dispatchStatusLabels[status] || '—'
|
||||
const identity = computed(() => `${props.customerId}:${props.exceptionNo}`)
|
||||
const detail = ref<ExceptionDetail | null>(null), loading = ref(false), busy = ref(false), error = ref(''), message = ref('')
|
||||
type ReceiptInput = ExceptionReturnSource & { sourcePalletNo: string; quantity: number | undefined; target: string; palletNo: string; locationId: string; locationCode: string }
|
||||
@@ -171,6 +175,27 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
|
||||
<el-descriptions-item label="处理动作" :span="2">{{ detail.order.handlingAction || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处理结果" :span="2">{{ detail.order.handlingResult || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<section v-if="detail.returnReceipts.length" aria-label="退回实收与重派关联" class="exception-return-history">
|
||||
<h3>退回实收与重派关联</h3>
|
||||
<div v-for="receipt in detail.returnReceipts" :key="receipt.receiptNo" class="exception-return-batch">
|
||||
<el-descriptions :column="3" border>
|
||||
<el-descriptions-item label="退回收货号">{{ receipt.receiptNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="本批实收">{{ receipt.receivedPackages }} 箱</el-descriptions-item>
|
||||
<el-descriptions-item label="实收时间">{{ formatOverseasDateTime(receipt.receivedAt) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<p v-if="!receipt.redispatches.length">尚未关联重派排车</p>
|
||||
<table v-else class="exception-redispatch-table">
|
||||
<thead><tr><th>重派排车单</th><th>排车状态</th><th>选定箱数</th><th>实际拣货</th><th>重派派送单</th></tr></thead>
|
||||
<tbody><tr v-for="dispatch in receipt.redispatches" :key="dispatch.dispatchNo">
|
||||
<td><RouterLink v-if="access.can(permission.dispatchList)" :to="`/overseas-warehouse/dispatches/${encodeURIComponent(dispatch.dispatchNo)}`">{{ dispatch.dispatchNo }}</RouterLink><span v-else>{{ dispatch.dispatchNo }}</span></td>
|
||||
<td>{{ dispatchStatusLabel(dispatch.status) }}</td><td>{{ dispatch.selectedPackages }} 箱</td><td>{{ dispatch.pickedPackages }} 箱</td>
|
||||
<td><span v-if="!dispatch.deliveries.length">—</span><div v-for="delivery in dispatch.deliveries" :key="delivery.deliveryNo">
|
||||
<RouterLink v-if="access.can(permission.deliveryList)" :to="{ path: `/overseas-warehouse/delivery-orders/${encodeURIComponent(delivery.deliveryNo)}`, query: { customerId } }">{{ delivery.deliveryNo }}</RouterLink><span v-else>{{ delivery.deliveryNo }}</span>
|
||||
</div></td>
|
||||
</tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<p v-if="detail.order.evidenceFileIds?.length">已保存 {{ detail.order.evidenceFileIds.length }} 份历史证据;本次可按需选用,不自动全部提交。</p>
|
||||
<template v-if="canProcess">
|
||||
<el-form label-position="top" :disabled="locked">
|
||||
@@ -201,3 +226,12 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.exception-return-history { margin: 12px 0; }
|
||||
.exception-return-history h3 { margin: 0 0 8px; font-size: 14px; }
|
||||
.exception-return-batch + .exception-return-batch { margin-top: 12px; }
|
||||
.exception-redispatch-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.exception-redispatch-table th, .exception-redispatch-table td { padding: 6px 8px; border: 1px solid var(--el-border-color-lighter); text-align: left; }
|
||||
.exception-redispatch-table th { background: var(--el-fill-color-light); }
|
||||
</style>
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('ExceptionOrdersView permissions and creation', () => {
|
||||
beforeEach(() => {
|
||||
canPermission.mockReset().mockReturnValue(false)
|
||||
vi.mocked(getExceptionDetail).mockReset().mockResolvedValue({ order: { id: '19', exceptionNo: 'EXC-1', customerId: '100', customerName: '示例客户',
|
||||
exceptionType: 'DAMAGED', status: 'PROCESSING', reason: '包装破损', handlerName: '1000001', version: 1, allowedActions: ['COMPLETE_EXCEPTION'], exceptionAt: '', updatedAt: '' }, returnSources: [] })
|
||||
exceptionType: 'DAMAGED', status: 'PROCESSING', reason: '包装破损', handlerName: '1000001', version: 1, allowedActions: ['COMPLETE_EXCEPTION'], exceptionAt: '', updatedAt: '' }, returnSources: [], returnReceipts: [] })
|
||||
vi.mocked(listExceptionOrders).mockReset().mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 })
|
||||
vi.mocked(resolveExceptionRelation).mockReset().mockResolvedValue({ inboundOrderId: '9', inboundCode: 'IN-1', lastMileOrderId: '8', lastMileOrderNo: 'LM-1', customerId: '100' })
|
||||
vi.mocked(createExceptionOrder).mockReset().mockResolvedValue({ id: '19', exceptionNo: 'EXC-1', status: 'PENDING' })
|
||||
|
||||
Reference in New Issue
Block a user