优化
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import DispatchReturnCargo from './DispatchReturnCargo.vue'
|
||||
import { getDispatchReturnOptions } from '../api/dispatches'
|
||||
import type { DispatchReturnCandidate, DispatchReturnSelection } from '../dispatchContracts'
|
||||
|
||||
vi.mock('../api/dispatches', () => ({ getDispatchReturnOptions: vi.fn() }))
|
||||
|
||||
const candidates: DispatchReturnCandidate[] = [
|
||||
{ customerId: '101', customerName: '客户甲', deliveryNo: 'DL-1', lastMileOrderId: '11', lastMileOrderNo: '同号订单', sources: [
|
||||
{ outboundPalletItemId: '21', inventoryPositionId: '31', returnCartonId: null, palletNo: 'P-1', skuCode: 'SKU-A', cartonMark: 'BOX-A', maxReturnPackages: 3 },
|
||||
] },
|
||||
{ customerId: '102', customerName: '客户乙', deliveryNo: 'DL-2', lastMileOrderId: '12', lastMileOrderNo: '同号订单', sources: [
|
||||
{ outboundPalletItemId: '22', inventoryPositionId: '32', returnCartonId: null, palletNo: 'P-2', skuCode: 'SKU-B', cartonMark: 'BOX-B', maxReturnPackages: 5 },
|
||||
] },
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.mocked(getDispatchReturnOptions).mockResolvedValue(structuredClone(candidates))
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
})
|
||||
|
||||
it('selects multiple customer orders, preserves independent quantities and removes only the deselected order', async () => {
|
||||
const wrapper = mount(DispatchReturnCargo, { props: { dispatchNo: 'PC-1', disabled: false }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(getDispatchReturnOptions).toHaveBeenCalledExactlyOnceWith('PC-1')
|
||||
const select = wrapper.getComponent({ name: 'ElSelect' })
|
||||
expect(select.props('multiple')).toBe(true)
|
||||
const values = wrapper.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))
|
||||
expect(new Set(values).size).toBe(2)
|
||||
/** 模拟选择控件的真实双向绑定和 change,验证用户操作后的提交模型。 */
|
||||
async function choose(selected: unknown[]) {
|
||||
select.vm.$emit('update:modelValue', selected)
|
||||
select.vm.$emit('change', selected)
|
||||
await flushPromises()
|
||||
}
|
||||
/** 数量控件先更新绑定,再触发提交模型重算。 */
|
||||
async function count(index: number, value: number) {
|
||||
const input = wrapper.findAllComponents({ name: 'ElInputNumber' })[index]!
|
||||
input.vm.$emit('update:modelValue', value)
|
||||
input.vm.$emit('change', value)
|
||||
await flushPromises()
|
||||
}
|
||||
/** 只核对公开的 v-model 输出,不依赖组件内部状态。 */
|
||||
function selected() { return wrapper.emitted('update:modelValue')!.at(-1)![0] as DispatchReturnSelection[] }
|
||||
await choose([values[0]])
|
||||
await count(0, 1)
|
||||
await choose(values)
|
||||
expect(wrapper.findAllComponents({ name: 'ElInputNumber' }).map(input => input.props('modelValue'))).toEqual([1, undefined])
|
||||
await count(1, 2)
|
||||
expect(selected()).toEqual([
|
||||
{ deliveryNo: 'DL-1', lastMileOrderId: '11', items: [{ outboundPalletItemId: '21', inventoryPositionId: '31', returnCartonId: null, packageCount: 1 }] },
|
||||
{ deliveryNo: 'DL-2', lastMileOrderId: '12', items: [{ outboundPalletItemId: '22', inventoryPositionId: '32', returnCartonId: null, packageCount: 2 }] },
|
||||
])
|
||||
await choose([values[1]])
|
||||
expect(selected()).toEqual([{ deliveryNo: 'DL-2', lastMileOrderId: '12', items: [{ outboundPalletItemId: '22', inventoryPositionId: '32', returnCartonId: null, packageCount: 2 }] }])
|
||||
await choose(values)
|
||||
expect(wrapper.findAllComponents({ name: 'ElInputNumber' }).map(input => input.props('modelValue'))).toEqual([undefined, 2])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('clears stale selections and exposes a retry when return sources cannot be loaded', async () => {
|
||||
vi.mocked(getDispatchReturnOptions).mockRejectedValueOnce(new Error('来源读取失败'))
|
||||
const wrapper = mount(DispatchReturnCargo, { props: { dispatchNo: 'PC-1', disabled: false, modelValue: [{ deliveryNo: 'OLD', lastMileOrderId: 'OLD', items: [] }] }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain('来源货物加载失败')
|
||||
expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual([[]])
|
||||
expect(wrapper.findAllComponents({ name: 'ElOption' })).toHaveLength(0)
|
||||
await wrapper.get('[role="alert"] button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
expect(wrapper.findAllComponents({ name: 'ElOption' })).toHaveLength(2)
|
||||
expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual([[]])
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -6,20 +6,24 @@ import { getApiErrorDetails } from '@/shared/api/http'
|
||||
const props = defineProps<{ dispatchNo: string; disabled: boolean }>()
|
||||
const selections = defineModel<DispatchReturnSelection[]>({ default: () => [] })
|
||||
const candidates = ref<DispatchReturnCandidate[]>([]), loading = ref(false), error = ref('')
|
||||
const sourceId = ref(''), quantities = ref<number[]>([])
|
||||
const source = computed(() => candidates.value.find(row => row.lastMileOrderId === sourceId.value))
|
||||
/** 原单切换时清空旧货物,避免把另一单数量提交到当前来源。 */
|
||||
function choose() { quantities.value = []; selections.value = [] }
|
||||
/** 仅提交用户明确填写的原出库来源与退回箱数。 */
|
||||
const sourceIds = ref<string[]>([]), quantities = ref<Record<string, number[]>>({})
|
||||
/** 客户、派送单和原订单共同标识选项,避免同号货物串单。 */
|
||||
function sourceKey(row: DispatchReturnCandidate) { return JSON.stringify([row.customerId, row.deliveryNo, row.lastMileOrderId]) }
|
||||
const sources = computed(() => candidates.value.filter(row => sourceIds.value.includes(sourceKey(row))))
|
||||
/** 增选保留已填数量,取消选中的订单立即清除其退回数量与提交内容。 */
|
||||
function choose() {
|
||||
quantities.value = Object.fromEntries(sourceIds.value.map(key => [key, quantities.value[key] || []]))
|
||||
update()
|
||||
}
|
||||
/** 每单分别提交用户填写的原出库来源和退回箱数,不把其他订单数量带入本单。 */
|
||||
function update() {
|
||||
const row = source.value
|
||||
selections.value = row ? [{ deliveryNo: row.deliveryNo, lastMileOrderId: row.lastMileOrderId,
|
||||
items: row.sources.flatMap((item, index) => quantities.value[index]! > 0 ? [{ outboundPalletItemId: item.outboundPalletItemId,
|
||||
inventoryPositionId: item.inventoryPositionId, returnCartonId: item.returnCartonId, packageCount: quantities.value[index]! }] : []) }] : []
|
||||
selections.value = sources.value.map(row => ({ deliveryNo: row.deliveryNo, lastMileOrderId: row.lastMileOrderId,
|
||||
items: row.sources.flatMap((item, index) => quantities.value[sourceKey(row)]?.[index]! > 0 ? [{ outboundPalletItemId: item.outboundPalletItemId,
|
||||
inventoryPositionId: item.inventoryPositionId, returnCartonId: item.returnCartonId, packageCount: quantities.value[sourceKey(row)]![index]! }] : []) }))
|
||||
}
|
||||
/** 每次打开按本车最新派送事实加载候选,失败明确阻断退回提交。 */
|
||||
async function load() {
|
||||
loading.value = true; error.value = ''; selections.value = []
|
||||
loading.value = true; error.value = ''; candidates.value = []; sourceIds.value = []; quantities.value = {}; selections.value = []
|
||||
try { candidates.value = await getDispatchReturnOptions(props.dispatchNo) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '来源货物加载失败').message }
|
||||
finally { loading.value = false }
|
||||
@@ -29,11 +33,14 @@ onMounted(load)
|
||||
<template>
|
||||
<section v-loading="loading" aria-label="选择退回货物">
|
||||
<p v-if="error" role="alert">{{ error }} <el-button link @click="load">重试</el-button></p>
|
||||
<el-form-item label="来源订单" required><el-select v-model="sourceId" :disabled="disabled || loading" placeholder="请选择本车来源订单" @change="choose">
|
||||
<el-option v-for="row in candidates" :key="row.lastMileOrderId" :value="row.lastMileOrderId" :label="`${row.lastMileOrderNo} · ${row.customerName || row.customerId} · ${row.deliveryNo}`" />
|
||||
<el-form-item label="来源订单" required><el-select v-model="sourceIds" multiple :disabled="disabled || loading" placeholder="请选择本车来源订单(可多选)" @change="choose">
|
||||
<el-option v-for="row in candidates" :key="sourceKey(row)" :value="sourceKey(row)" :label="`${row.lastMileOrderNo} · ${row.customerName || row.customerId} · ${row.deliveryNo}`" />
|
||||
</el-select></el-form-item>
|
||||
<table v-if="source" class="density-table density-table--editable"><thead><tr><th>原托盘 / 箱唛</th><th>SKU</th><th>可退箱数</th><th>本次退回</th></tr></thead><tbody>
|
||||
<tr v-for="(item, index) in source.sources" :key="`${item.outboundPalletItemId}:${item.inventoryPositionId}:${item.returnCartonId}`"><td>{{ item.cartonMark || item.palletNo || '—' }}</td><td>{{ item.skuCode || '—' }}</td><td>{{ item.maxReturnPackages }}</td><td><el-input-number v-model="quantities[index]" :min="0" :max="item.maxReturnPackages" :precision="0" :disabled="disabled" @change="update" /></td></tr>
|
||||
</tbody></table>
|
||||
<section v-for="source in sources" :key="sourceKey(source)" :aria-label="`退回货物 ${source.lastMileOrderNo} ${source.deliveryNo}`">
|
||||
<h4>{{ source.lastMileOrderNo }} · {{ source.customerName || source.customerId }} · {{ source.deliveryNo }}</h4>
|
||||
<table class="density-table density-table--editable"><thead><tr><th>原托盘 / 箱唛</th><th>SKU</th><th>可退箱数</th><th>本次退回</th></tr></thead><tbody>
|
||||
<tr v-for="(item, index) in source.sources" :key="`${item.outboundPalletItemId}:${item.inventoryPositionId}:${item.returnCartonId}`"><td>{{ item.cartonMark || item.palletNo || '—' }}</td><td>{{ item.skuCode || '—' }}</td><td>{{ item.maxReturnPackages }}</td><td><el-input-number v-model="quantities[sourceKey(source)]![index]" :min="0" :max="item.maxReturnPackages" :precision="0" :disabled="disabled" @change="update" /></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -124,6 +124,18 @@ describe('dispatch workflow native layout', () => {
|
||||
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ maintenance: saved }))
|
||||
expect(vi.mocked(api.updateDispatch).mock.calls[0]![1]).not.toHaveProperty('departedAt')
|
||||
})
|
||||
it('shows the server grouped looseboard orders and their aggregated pallet and volume totals', async () => {
|
||||
const base = detail().orderDetails[0]!
|
||||
const orderDetails = [{ ...base, orderNo: 'LB202611050003', inboundCode: null, palletCount: 2, volumeCbm: 10, containerNo: null, sourceBusinessType: 'LOCAL_LOOSEBOARD', sourceBusinessNo: 'LB202611050003', sourceKeys: ['PLAN:11', 'PLAN:12'] },
|
||||
{ ...base, sourceKey: 'PLAN:13', orderNo: 'LB202611050002', inboundCode: null, palletCount: 1, volumeCbm: 4, containerNo: null, sourceBusinessType: 'LOCAL_LOOSEBOARD', sourceBusinessNo: 'LB202611050002', sourceKeys: ['PLAN:13'] }]
|
||||
const { dialog } = await render('EDIT', undefined, false, { orderDetails })
|
||||
expect(dialog.text()).toContain('关联订单 (2)')
|
||||
await dialog.findAll('[role="tab"]').find(tab => tab.text().includes('关联订单'))!.trigger('click')
|
||||
const rows = dialog.get('.maintenance-orders').findAll('tbody tr')
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]!.findAll('td').map(cell => cell.text())).toEqual(['客户甲', 'LB202611050003', '—', '—', '2', 'ONT8', '10', '—'])
|
||||
expect(rows[1]!.text()).toContain('LB202611050002')
|
||||
})
|
||||
it('edits one number per destination and can save an incomplete second destination', async () => {
|
||||
const destinations = ['ONT8', 'LGB8'].map(code => ({ batchKey: null, batchNo: null, customerId: null, appointmentNo: null, destinationKey: `FBA:${code}`, destinationWarehouseCode: code, status: 'PENDING', confirmedAt: null, blockReason: null, shipmentNumbers: [] }))
|
||||
const initial = detail()
|
||||
|
||||
Reference in New Issue
Block a user