This commit is contained in:
2026-10-10 16:58:21 +08:00
parent 175d8936b9
commit c0155b8293
5 changed files with 57 additions and 27 deletions
@@ -52,7 +52,7 @@ export async function listReturnOutboundCandidates(customerId: string, warehouse
const response = await http.get<ApiEnvelope<ReturnOutboundCandidate[]>, ApiEnvelope<ReturnOutboundCandidate[]>>('/platform/v1/last-leg/outbound-orders/return/candidates', { params: { customerId, warehouseId } }) const response = await http.get<ApiEnvelope<ReturnOutboundCandidate[]>, ApiEnvelope<ReturnOutboundCandidate[]>>('/platform/v1/last-leg/outbound-orders/return/candidates', { params: { customerId, warehouseId } })
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
export async function createReturnOutbound(customerId: string, payload: { recipient: string; overseasWarehouseId: string; returnNo: string; cartonMarks: string[]; skuCode?: string; quantity?: number; unitType?: 'BOX' | 'PALLET'; remark?: string; idempotencyKey: string }) { export async function createReturnOutbound(customerId: string, payload: { recipient: string; overseasWarehouseId: string; returnNo: string; cartonMarks: string[]; skuCode?: string; quantity?: number; items?: Array<{ cartonMark: string; skuCode: string; quantity: number }>; unitType?: 'BOX' | 'PALLET'; remark?: string; idempotencyKey: string }) {
const response = await http.post<ApiEnvelope<OutboundRow>, ApiEnvelope<OutboundRow>>('/platform/v1/last-leg/outbound-orders/return', payload, { params: { customerId } }) const response = await http.post<ApiEnvelope<OutboundRow>, ApiEnvelope<OutboundRow>>('/platform/v1/last-leg/outbound-orders/return', payload, { params: { customerId } })
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
@@ -52,26 +52,41 @@ async function selectMarks(marks: string[]) {
await flushPromises() await flushPromises()
} }
describe('single SKU outbound source and piece quantity', () => { describe('single SKU outbound source and piece quantity', () => {
it('creates one outbound with multiple SKUs and independent quantities on source rows', async () => {
await open('SKU-A')
const left = wrapper.findAllComponents({ name: 'ElTable' }).filter(table => table.classes().includes('return-result-table'))[0]!
left.findAllComponents(ElCheckbox).find(check => check.element.closest('tr')?.textContent?.includes('SKU-B'))!.vm.$emit('change', true); await flushPromises()
expect(right().props('data')).toHaveLength(3)
await selectMarks(['LOOSE-A', 'PARCEL'])
const inputs = wrapper.findAllComponents(ElInputNumber)
inputs[0]!.vm.$emit('update:modelValue', 3)
inputs[2]!.vm.$emit('update:modelValue', 2)
await flushPromises()
await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises()
expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ items: [{ cartonMark: 'LOOSE-A', skuCode: 'SKU-A', quantity: 3 }, { cartonMark: 'PARCEL', skuCode: 'SKU-B', quantity: 2 }] }))
expect(api.create.mock.calls[0][1].skuCode).toBeUndefined()
expect(wrapper.text()).not.toContain('选择来源后填写件数')
})
it('selects only loose SKU sources scoped to the return order and excludes packed boxes', async () => { it('selects only loose SKU sources scoped to the return order and excludes packed boxes', async () => {
await open('SKU-A') await open('SKU-A')
expect(right().props('data').map((row: { cartonMark: string }) => row.cartonMark)).toEqual(['LOOSE-A', 'LOOSE-B']) expect(right().props('data').map((row: { cartonMark: string }) => row.cartonMark)).toEqual(['LOOSE-A', 'LOOSE-B'])
expect(wrapper.text()).not.toContain('OTHER-ORDER') expect(wrapper.text()).not.toContain('OTHER-ORDER')
await selectMarks(['LOOSE-A', 'LOOSE-B']) await selectMarks(['LOOSE-A', 'LOOSE-B'])
expect(wrapper.findComponent(ElInputNumber).props('modelValue')).toBe(10) expect(wrapper.findAllComponents(ElInputNumber).map(input => input.props('modelValue'))).toEqual([5, 5])
await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises() await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises()
expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ skuCode: 'SKU-A', quantity: 10, cartonMarks: ['LOOSE-A', 'LOOSE-B'] })) expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ items: [{ cartonMark: 'LOOSE-A', skuCode: 'SKU-A', quantity: 5 }, { cartonMark: 'LOOSE-B', skuCode: 'SKU-A', quantity: 5 }], cartonMarks: ['LOOSE-A', 'LOOSE-B'] }))
}) })
it('submits six pieces from two five-piece loose sources', async () => { it('submits six pieces from two five-piece loose sources', async () => {
await open('SKU-A'); await selectMarks(['LOOSE-A', 'LOOSE-B']) await open('SKU-A'); await selectMarks(['LOOSE-A', 'LOOSE-B'])
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', 6); await flushPromises() wrapper.findAllComponents(ElInputNumber)[1]!.vm.$emit('update:modelValue', 1); await flushPromises()
await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises() await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises()
expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ quantity: 6 })) expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ items: [{ cartonMark: 'LOOSE-A', skuCode: 'SKU-A', quantity: 5 }, { cartonMark: 'LOOSE-B', skuCode: 'SKU-A', quantity: 1 }] }))
}) })
it('allows loose PARCEL sources even when the box/pallet candidate endpoint excludes them', async () => { it('allows loose PARCEL sources even when the box/pallet candidate endpoint excludes them', async () => {
await open('SKU-B'); expect(right().props('data').map((row: { cartonMark: string }) => row.cartonMark)).toEqual(['PARCEL']) await open('SKU-B'); expect(right().props('data').map((row: { cartonMark: string }) => row.cartonMark)).toEqual(['PARCEL'])
await selectMarks(['PARCEL']); wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', 2); await flushPromises() await selectMarks(['PARCEL']); wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', 2); await flushPromises()
await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises() await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises()
expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ skuCode: 'SKU-B', quantity: 2, cartonMarks: ['PARCEL'] })) expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ items: [{ cartonMark: 'PARCEL', skuCode: 'SKU-B', quantity: 2 }], cartonMarks: ['PARCEL'] }))
}) })
it('never offers a parcel as a physical box in box/pallet mode', async () => { it('never offers a parcel as a physical box in box/pallet mode', async () => {
await open('SKU-B') await open('SKU-B')
@@ -80,11 +95,11 @@ describe('single SKU outbound source and piece quantity', () => {
expect(right().props('data').some((row: { sourceUnitType: string }) => row.sourceUnitType === 'PARCEL')).toBe(false) expect(right().props('data').some((row: { sourceUnitType: string }) => row.sourceUnitType === 'PARCEL')).toBe(false)
expect(right().text()).not.toContain('包裹散件') expect(right().text()).not.toContain('包裹散件')
}) })
it.each([[0,1],[11,10]])('clamps input %s to the allowed bound %s', async (quantity, expected) => { it.each([[0,1],[11,5]])('clamps input %s to the allowed bound %s', async (quantity, expected) => {
await open('SKU-A'); await selectMarks(['LOOSE-A', 'LOOSE-B']) await open('SKU-A'); await selectMarks(['LOOSE-A', 'LOOSE-B'])
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', quantity); await flushPromises() wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', quantity); await flushPromises()
await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises() await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises()
expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({quantity: expected})) expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({items: expect.arrayContaining([expect.objectContaining({cartonMark: 'LOOSE-A', quantity: expected})])}))
}) })
it.each([1.5])('blocks invalid quantity %s before creating an order', async quantity => { it.each([1.5])('blocks invalid quantity %s before creating an order', async quantity => {
await open('SKU-A'); await selectMarks(['LOOSE-A', 'LOOSE-B']) await open('SKU-A'); await selectMarks(['LOOSE-A', 'LOOSE-B'])
@@ -79,7 +79,7 @@ describe('outbound candidate search and selection', () => {
expect(sourceTable.findAll('tbody tr')).toHaveLength(1) expect(sourceTable.findAll('tbody tr')).toHaveLength(1)
expect(sourceTable.text()).toContain('SKU-A × 1') expect(sourceTable.text()).toContain('SKU-A × 1')
sourceTable.findAllComponents(ElCheckbox)[0]!.vm.$emit('change', true); await flushPromises() sourceTable.findAllComponents(ElCheckbox)[0]!.vm.$emit('change', true); await flushPromises()
expect(wrapper.text()).toContain('所选散件来源可用 1 件') expect(wrapper.findComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(1)
mode.vm.$emit('update:modelValue', 'BOX'); mode.vm.$emit('change'); await flushPromises() mode.vm.$emit('update:modelValue', 'BOX'); mode.vm.$emit('change'); await flushPromises()
expect(wrapper.findAll('.return-list-card')[1]!.findAll('tbody tr')).toHaveLength(3) expect(wrapper.findAll('.return-list-card')[1]!.findAll('tbody tr')).toHaveLength(3)
}) })
@@ -79,7 +79,7 @@ describe('OutboundsView permissions and navigation', () => {
expect(source).toContain('@click="searchReturnSkus"') expect(source).toContain('@click="searchReturnSkus"')
expect(source).toContain('@click="searchReturnCartons"') expect(source).toContain('@click="searchReturnCartons"')
expect(source).toContain(':page-sizes="[5, 10, 20, 50]"') expect(source).toContain(':page-sizes="[5, 10, 20, 50]"')
expect(source).toContain('width="min(1320px, calc(100vw - 32px))"') expect(source).toContain('width="min(1760px, calc(100vw - 32px))" fullscreenable')
}) })
it('resolves warehouse-transfer customer names from the platform customer list', () => { it('resolves warehouse-transfer customer names from the platform customer list', () => {
@@ -38,7 +38,7 @@ const returnStockRows = ref<ReturnStockRow[]>([])
const returnCartonRows = ref<ReturnCartonRow[]>([]) const returnCartonRows = ref<ReturnCartonRow[]>([])
const selectedReturnSkus = ref<string[]>([]) const selectedReturnSkus = ref<string[]>([])
const returnPickMode = ref<'BOX' | 'SKU'>('BOX') const returnPickMode = ref<'BOX' | 'SKU'>('BOX')
const returnQuantity = ref<number | undefined>() const returnSourceQuantities = ref<Record<string, number | undefined>>({})
const returnSkuQueryInput = ref(''); const returnSkuQuery = ref('') const returnSkuQueryInput = ref(''); const returnSkuQuery = ref('')
const returnCartonQueryInput = ref(''); const returnCartonQuery = ref('') const returnCartonQueryInput = ref(''); const returnCartonQuery = ref('')
const returnSkuPage = ref(1); const returnCartonPage = ref(1); const returnUnitType = ref<'BOX' | 'PALLET'>('BOX') const returnSkuPage = ref(1); const returnCartonPage = ref(1); const returnUnitType = ref<'BOX' | 'PALLET'>('BOX')
@@ -91,7 +91,7 @@ const filteredReturnCartons = computed(() => selectedReturnCartons.value.filter(
if (returnPickMode.value === 'SKU') { if (returnPickMode.value === 'SKU') {
if (item.sourceUnitType !== 'PARCEL') return false if (item.sourceUnitType !== 'PARCEL') return false
const detail = returnCartonDetails.value[item.cartonMark] const detail = returnCartonDetails.value[item.cartonMark]
if (!selectedReturnSkus.value.length || detail?.items?.length !== 1 || detail.items[0]?.skuCode !== selectedReturnSkus.value[0]) return false if (!selectedReturnSkus.value.length || detail?.items?.length !== 1 || !selectedReturnSkus.value.includes(detail.items[0]?.skuCode || '')) return false
} }
const detailSkus = returnCartonDetails.value[item.cartonMark]?.items?.map((detail) => detail.skuCode).join(',') || '' const detailSkus = returnCartonDetails.value[item.cartonMark]?.items?.map((detail) => detail.skuCode).join(',') || ''
return matchesAnyToken(`${item.cartonMark},${item.fbaCartonMark || ''},${detailSkus}`, returnCartonQuery.value) return matchesAnyToken(`${item.cartonMark},${item.fbaCartonMark || ''},${detailSkus}`, returnCartonQuery.value)
@@ -99,11 +99,13 @@ const filteredReturnCartons = computed(() => selectedReturnCartons.value.filter(
const pagedReturnStocks = computed(() => filteredReturnStocks.value.slice((returnSkuPage.value - 1) * returnSkuPageSize.value, returnSkuPage.value * returnSkuPageSize.value)) const pagedReturnStocks = computed(() => filteredReturnStocks.value.slice((returnSkuPage.value - 1) * returnSkuPageSize.value, returnSkuPage.value * returnSkuPageSize.value))
const pagedReturnCartons = computed(() => filteredReturnCartons.value.slice((returnCartonPage.value - 1) * returnCartonPageSize.value, returnCartonPage.value * returnCartonPageSize.value)) const pagedReturnCartons = computed(() => filteredReturnCartons.value.slice((returnCartonPage.value - 1) * returnCartonPageSize.value, returnCartonPage.value * returnCartonPageSize.value))
const effectiveReturnMarks = computed(() => [...new Set(selectedReturnMarks.value)].filter(Boolean) as string[]) const effectiveReturnMarks = computed(() => [...new Set(selectedReturnMarks.value)].filter(Boolean) as string[])
const selectedReturnQuantity = computed(() => effectiveReturnMarks.value.reduce((sum, mark) => { function returnSourceAvailable(mark: string) {
const line = returnCartonDetails.value[mark]?.items.find(item => item.skuCode === selectedReturnSkus.value[0]) const item = returnCartonDetails.value[mark]?.items[0]
return sum + Number(line?.availableQuantity ?? line?.quantity ?? 0) return Number(item?.availableQuantity ?? item?.quantity ?? 0)
}, 0)) }
watch(selectedReturnQuantity, quantity => { returnQuantity.value = quantity || undefined }) watch([selectedReturnNo, selectedCustomerId, selectedWarehouseId], () => {
selectedReturnSkus.value = []; selectedReturnMarks.value = []; returnSourceQuantities.value = {}
})
const returnMarksModel = computed({ get: () => selectedReturnMarks.value, set: (value: string[]) => { selectedReturnMarks.value = value } }) const returnMarksModel = computed({ get: () => selectedReturnMarks.value, set: (value: string[]) => { selectedReturnMarks.value = value } })
const filteredDropshipCandidates = computed(() => dropshipCandidates.value.filter(item => !dropshipQuery.value.trim() || [item.orderNo, item.inboundCode, item.customerName, item.palletNo, item.skuCode, item.productName].join(' ').toLowerCase().includes(dropshipQuery.value.trim().toLowerCase()))) const filteredDropshipCandidates = computed(() => dropshipCandidates.value.filter(item => !dropshipQuery.value.trim() || [item.orderNo, item.inboundCode, item.customerName, item.palletNo, item.skuCode, item.productName].join(' ').toLowerCase().includes(dropshipQuery.value.trim().toLowerCase())))
const pagedDropshipCandidates = computed(() => filteredDropshipCandidates.value.slice((dropshipPage.value - 1) * dropshipPageSize.value, dropshipPage.value * dropshipPageSize.value)) const pagedDropshipCandidates = computed(() => filteredDropshipCandidates.value.slice((dropshipPage.value - 1) * dropshipPageSize.value, dropshipPage.value * dropshipPageSize.value))
@@ -194,10 +196,16 @@ function resetDropshipCandidates() { dropshipQueryInput.value = ''; dropshipQuer
function normalizeBatchQuery(value: string) { return value.split(/[,,\n]+/).map((item) => item.replace(/\s+/g, '')).filter(Boolean).join(',') } function normalizeBatchQuery(value: string) { return value.split(/[,,\n]+/).map((item) => item.replace(/\s+/g, '')).filter(Boolean).join(',') }
function searchReturnSkus() { returnSkuQueryInput.value = normalizeBatchQuery(returnSkuQueryInput.value); returnSkuQuery.value = returnSkuQueryInput.value; returnSkuPage.value = 1 } function searchReturnSkus() { returnSkuQueryInput.value = normalizeBatchQuery(returnSkuQueryInput.value); returnSkuQuery.value = returnSkuQueryInput.value; returnSkuPage.value = 1 }
function searchReturnCartons() { returnCartonQueryInput.value = normalizeBatchQuery(returnCartonQueryInput.value); returnCartonQuery.value = returnCartonQueryInput.value; returnCartonPage.value = 1 } function searchReturnCartons() { returnCartonQueryInput.value = normalizeBatchQuery(returnCartonQueryInput.value); returnCartonQuery.value = returnCartonQueryInput.value; returnCartonPage.value = 1 }
function toggleReturnSku(sku: string, checked: boolean | string | number) { selectedReturnSkus.value = checked ? [sku] : []; selectedReturnMarks.value = [] } function toggleReturnSku(sku: string, checked: boolean | string | number) {
selectedReturnSkus.value = checked ? [...new Set([...selectedReturnSkus.value, sku])] : selectedReturnSkus.value.filter(value => value !== sku)
if (!checked) selectedReturnMarks.value = selectedReturnMarks.value.filter(mark => returnCartonDetails.value[mark]?.items[0]?.skuCode !== sku)
}
function changeReturnPickMode() { selectedReturnSkus.value = []; selectedReturnMarks.value = []; returnUnitType.value = 'BOX' } function changeReturnPickMode() { selectedReturnSkus.value = []; selectedReturnMarks.value = []; returnUnitType.value = 'BOX' }
function changeReturnUnitType() { selectedReturnMarks.value = [] } function changeReturnUnitType() { selectedReturnMarks.value = [] }
function toggleReturnCarton(cartonMark: string, checked: boolean | string | number) { selectedReturnMarks.value = checked ? [...new Set([...selectedReturnMarks.value, cartonMark])] : selectedReturnMarks.value.filter((value) => value !== cartonMark) } function toggleReturnCarton(cartonMark: string, checked: boolean | string | number) {
selectedReturnMarks.value = checked ? [...new Set([...selectedReturnMarks.value, cartonMark])] : selectedReturnMarks.value.filter(value => value !== cartonMark)
if (checked && returnPickMode.value === 'SKU') returnSourceQuantities.value[cartonMark] = returnSourceAvailable(cartonMark)
}
function toggleReturnPallet(palletCode: string, checked: boolean | string | number) { function toggleReturnPallet(palletCode: string, checked: boolean | string | number) {
const marks = returnCandidates.value.filter(item => item.palletCode === palletCode && item.cartonMark).map(item => item.cartonMark!) const marks = returnCandidates.value.filter(item => item.palletCode === palletCode && item.cartonMark).map(item => item.cartonMark!)
selectedReturnMarks.value = checked ? [...new Set([...selectedReturnMarks.value, ...marks])] : selectedReturnMarks.value.filter(mark => !marks.includes(mark)) selectedReturnMarks.value = checked ? [...new Set([...selectedReturnMarks.value, ...marks])] : selectedReturnMarks.value.filter(mark => !marks.includes(mark))
@@ -218,9 +226,16 @@ async function submit() {
} else if (outboundType.value === 'RETURN_OUTBOUND') { } else if (outboundType.value === 'RETURN_OUTBOUND') {
if (!recipient.value.trim()) { ElMessage.warning('请填写收件信息'); return } if (!recipient.value.trim()) { ElMessage.warning('请填写收件信息'); return }
if (!selectedReturnNo.value) { ElMessage.warning('请选择退货单'); return } if (!selectedReturnNo.value) { ElMessage.warning('请选择退货单'); return }
if (returnPickMode.value === 'SKU' && selectedReturnSkus.value.length !== 1) { ElMessage.warning('请选择一个 SKU'); return } if (returnPickMode.value === 'SKU') {
if (returnPickMode.value === 'SKU' && (!Number.isSafeInteger(returnQuantity.value) || Number(returnQuantity.value) <= 0 || Number(returnQuantity.value) > selectedReturnQuantity.value)) { if (!selectedReturnSkus.value.length) { ElMessage.warning('请至少选择一个 SKU'); return }
ElMessage.warning(`请填写 1-${selectedReturnQuantity.value} 件的出库数量`); return if (selectedReturnSkus.value.some(sku => !effectiveReturnMarks.value.some(mark => returnCartonDetails.value[mark]?.items[0]?.skuCode === sku))) {
ElMessage.warning('请为每个已选 SKU 选择散件来源并填写出库数量'); return
}
if (effectiveReturnMarks.value.some(mark => !filteredReturnCartons.value.some(row => row.cartonMark === mark)
|| !Number.isSafeInteger(returnSourceQuantities.value[mark]) || Number(returnSourceQuantities.value[mark]) <= 0
|| Number(returnSourceQuantities.value[mark]) > returnSourceAvailable(mark))) {
ElMessage.warning('请在明细中填写大于0且不超过来源可用数量的整数'); return
}
} }
if (!selectedReturnMarks.value.length) { if (!selectedReturnMarks.value.length) {
ElMessage.warning(returnPickMode.value === 'SKU' ElMessage.warning(returnPickMode.value === 'SKU'
@@ -228,7 +243,7 @@ async function submit() {
: '请选择出库箱号或托盘') : '请选择出库箱号或托盘')
return return
} }
await createReturnOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), overseasWarehouseId: selectedWarehouseId.value, returnNo: selectedReturnNo.value, cartonMarks: selectedReturnMarks.value, skuCode: returnPickMode.value === 'SKU' ? selectedReturnSkus.value[0] : undefined, quantity: returnPickMode.value === 'SKU' ? returnQuantity.value : undefined, unitType: returnPickMode.value === 'SKU' ? 'BOX' : returnUnitType.value, remark: remark.value.trim() || undefined, idempotencyKey: `web-return-${Date.now()}` }) await createReturnOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), overseasWarehouseId: selectedWarehouseId.value, returnNo: selectedReturnNo.value, cartonMarks: selectedReturnMarks.value, items: returnPickMode.value === 'SKU' ? effectiveReturnMarks.value.map(mark => ({ cartonMark: mark, skuCode: returnCartonDetails.value[mark]!.items[0]!.skuCode, quantity: Number(returnSourceQuantities.value[mark]) })) : undefined, unitType: returnPickMode.value === 'SKU' ? 'BOX' : returnUnitType.value, remark: remark.value.trim() || undefined, idempotencyKey: `web-return-${Date.now()}` })
ElMessage.success('退货出库单和待派送单已创建,作业已推送 PDA') ElMessage.success('退货出库单和待派送单已创建,作业已推送 PDA')
} else { } else {
const selected = dropshipCandidates.value.filter(item => selectedDropshipIds.value.includes(dropshipCandidateKey(item))) const selected = dropshipCandidates.value.filter(item => selectedDropshipIds.value.includes(dropshipCandidateKey(item)))
@@ -266,7 +281,7 @@ onMounted(load)
<template #status="{ row }"><StatusBadge :status="row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : row.status === 'WAIT_OUTBOUND' ? '待出库' : row.status === 'PICKING' ? '拣货中' : row.status === 'WAIT_PICK' ? '待拣货' : '未知状态'" /></template> <template #status="{ row }"><StatusBadge :status="row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : row.status === 'WAIT_OUTBOUND' ? '待出库' : row.status === 'PICKING' ? '拣货中' : row.status === 'WAIT_PICK' ? '待拣货' : '未知状态'" /></template>
</DataTable></section> </DataTable></section>
</ListPageLayout></AdminPage> </ListPageLayout></AdminPage>
<AppDialog class="admin-density-dialog" v-model="createOpen" title="新建出库单" width="min(1320px, calc(100vw - 32px))" :confirm-text="outboundType === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '绑定目的地并进入排车' : '生成出库单并推送PDA'" :confirm-loading="saving" @confirm="submit"> <AppDialog class="admin-density-dialog" v-model="createOpen" title="新建出库单" width="min(1760px, calc(100vw - 32px))" fullscreenable :confirm-text="outboundType === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '绑定目的地并进入排车' : '生成出库单并推送PDA'" :confirm-loading="saving" @confirm="submit">
<el-form class="create-outbound-form density-form" label-position="top"> <el-form class="create-outbound-form density-form" label-position="top">
<el-form-item label="出库类型" required> <el-form-item label="出库类型" required>
<el-radio-group v-model="outboundType" @change="changeOutboundType"> <el-radio-group v-model="outboundType" @change="changeOutboundType">
@@ -348,7 +363,7 @@ onMounted(load)
<el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item> <el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item>
<el-form-item label="海外仓" required><el-select v-model="selectedWarehouseId" :disabled="!selectedCustomerId" filterable placeholder="请选择海外仓" @change="loadReturnCandidates"><el-option v-for="item in warehouseOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item> <el-form-item label="海外仓" required><el-select v-model="selectedWarehouseId" :disabled="!selectedCustomerId" filterable placeholder="请选择海外仓" @change="loadReturnCandidates"><el-option v-for="item in warehouseOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item>
<el-form-item label="退货单" required><el-select v-model="selectedReturnNo" filterable placeholder="选择已签收入库的退货单" @change="selectedReturnMarks = []"><el-option v-for="no in returnOrderOptions" :key="no" :label="no" :value="no" /></el-select></el-form-item> <el-form-item label="退货单" required><el-select v-model="selectedReturnNo" filterable placeholder="选择已签收入库的退货单" @change="selectedReturnMarks = []"><el-option v-for="no in returnOrderOptions" :key="no" :label="no" :value="no" /></el-select></el-form-item>
<p v-if="returnPickMode === 'SKU'" class="candidate-hint">单 SKU 只展示该退货单的可用散件库存,可填写全部或部分件数,由 PDA 扫描 SKU 拣货。</p><el-form-item v-if="returnPickMode === 'SKU'" label="出库数量(件)" required><el-input-number v-model="returnQuantity" :min="1" :max="selectedReturnQuantity || 1" :precision="0" placeholder="选择来源后填写件数" /><span class="candidate-hint">所选散件来源可用 {{ selectedReturnQuantity }} 件</span></el-form-item><el-form-item label="出库维度"><el-radio-group v-model="returnPickMode" @change="changeReturnPickMode"><el-radio-button value="BOX">箱 / 托</el-radio-button><el-radio-button value="SKU">单 SKU</el-radio-button></el-radio-group></el-form-item> <p v-if="returnPickMode === 'SKU'" class="candidate-hint">单 SKU 只展示该退货单的可用散件库存,可填写全部或部分件数,由 PDA 扫描 SKU 拣货。</p><el-form-item label="出库维度"><el-radio-group v-model="returnPickMode" @change="changeReturnPickMode"><el-radio-button value="BOX">箱 / 托</el-radio-button><el-radio-button value="SKU">单 SKU</el-radio-button></el-radio-group></el-form-item>
<el-form-item v-if="returnPickMode === 'BOX'" label="箱托方式"><el-radio-group v-model="returnUnitType" @change="changeReturnUnitType"><el-radio-button value="BOX">按箱号出库</el-radio-button><el-radio-button value="PALLET">按托盘出库</el-radio-button></el-radio-group></el-form-item> <el-form-item v-if="returnPickMode === 'BOX'" label="箱托方式"><el-radio-group v-model="returnUnitType" @change="changeReturnUnitType"><el-radio-button value="BOX">按箱号出库</el-radio-button><el-radio-button value="PALLET">按托盘出库</el-radio-button></el-radio-group></el-form-item>
<el-form-item v-if="returnPickMode === 'BOX' && returnUnitType === 'PALLET' && returnPallets.length" label="整托选择"><div class="return-pallet-options"><el-checkbox v-for="code in returnPallets" :key="code" :model-value="returnCandidates.filter(item => item.palletCode === code && item.cartonMark).every(item => selectedReturnMarks.includes(item.cartonMark!))" @change="toggleReturnPallet(code, $event)">{{ code }}</el-checkbox></div></el-form-item> <el-form-item v-if="returnPickMode === 'BOX' && returnUnitType === 'PALLET' && returnPallets.length" label="整托选择"><div class="return-pallet-options"><el-checkbox v-for="code in returnPallets" :key="code" :model-value="returnCandidates.filter(item => item.palletCode === code && item.cartonMark).every(item => selectedReturnMarks.includes(item.cartonMark!))" @change="toggleReturnPallet(code, $event)">{{ code }}</el-checkbox></div></el-form-item>
<div class="return-lists"> <div class="return-lists">
@@ -361,7 +376,7 @@ onMounted(load)
<section class="return-list-card"> <section class="return-list-card">
<div class="list-title"><span>{{ returnPickMode === 'SKU' ? 'SKU 来源明细' : '箱号列表' }}</span><small>支持多个箱号或 SKU,用英文逗号分隔</small></div> <div class="list-title"><span>{{ returnPickMode === 'SKU' ? 'SKU 来源明细' : '箱号列表' }}</span><small>支持多个箱号或 SKU,用英文逗号分隔</small></div>
<div class="candidate-query"><el-input v-model="returnCartonQueryInput" clearable placeholder="如:RC001,RC002 或 SKU001,SKU002" /><el-button type="primary" @click="searchReturnCartons">查询</el-button></div> <div class="candidate-query"><el-input v-model="returnCartonQueryInput" clearable placeholder="如:RC001,RC002 或 SKU001,SKU002" /><el-button type="primary" @click="searchReturnCartons">查询</el-button></div>
<el-table :data="pagedReturnCartons" border stripe class="return-result-table" max-height="400"><el-table-column label="选择" width="60" align="center"><template #default="{ row }"><el-checkbox :model-value="selectedReturnMarks.includes(row.cartonMark)" @change="toggleReturnCarton(row.cartonMark, $event)" /></template></el-table-column><el-table-column :label="returnPickMode === 'SKU' ? '库存来源' : '箱号'" min-width="190" show-overflow-tooltip><template #default="{ row }">{{ row.sourceUnitType === 'PARCEL' ? '包裹散件(按 SKU 拣货)' : row.cartonMark }}</template></el-table-column><el-table-column label="托盘" min-width="150"><template #default="{ row }">{{ row.sourceUnitType === 'PARCEL' ? '无需托盘' : returnCandidateByMark[row.cartonMark]?.palletCode || '散箱' }}</template></el-table-column><el-table-column label="箱内SKU及数量" min-width="230"><template #default="{ row }">{{ returnCartonDetails[row.cartonMark]?.items?.map((item) => `${item.skuCode} × ${item.quantity}`).join(',') || '加载中' }}</template></el-table-column></el-table> <el-table :data="pagedReturnCartons" border stripe class="return-result-table" max-height="400"><el-table-column label="选择" width="60" align="center" fixed="left"><template #default="{ row }"><el-checkbox :model-value="selectedReturnMarks.includes(row.cartonMark)" @change="toggleReturnCarton(row.cartonMark, $event)" /></template></el-table-column><el-table-column :label="returnPickMode === 'SKU' ? '库存来源' : '箱号'" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ row.sourceUnitType === 'PARCEL' ? '包裹散件(按 SKU 拣货)' : row.cartonMark }}</template></el-table-column><el-table-column v-if="returnPickMode !== 'SKU'" label="托盘" min-width="150"><template #default="{ row }">{{ row.sourceUnitType === 'PARCEL' ? '无需托盘' : returnCandidateByMark[row.cartonMark]?.palletCode || '散箱' }}</template></el-table-column><el-table-column :label="returnPickMode === 'SKU' ? 'SKU及可用数量' : '箱内SKU及数量'" min-width="230"><template #default="{ row }">{{ returnCartonDetails[row.cartonMark]?.items?.map((item) => `${item.skuCode} × ${item.quantity}`).join(',') || '加载中' }}</template></el-table-column><el-table-column v-if="returnPickMode === 'SKU'" label="出库数量(件)" width="150" fixed="right"><template #default="{ row }"><el-input-number v-model="returnSourceQuantities[row.cartonMark]" :disabled="!selectedReturnMarks.includes(row.cartonMark)" :min="1" :max="returnSourceAvailable(row.cartonMark)" :precision="0" controls-position="right" size="small" placeholder="数量" style="width: 120px" :aria-label="`出库数量 ${returnCartonDetails[row.cartonMark]?.items[0]?.skuCode} ${row.cartonMark}`" /></template></el-table-column></el-table>
<div class="return-pagination"><span>已选择 {{ selectedReturnMarks.length }} 个{{ returnPickMode === 'SKU' ? '来源' : '箱号' }}</span><el-pagination v-model:current-page="returnCartonPage" v-model:page-size="returnCartonPageSize" :page-sizes="[5, 10, 20, 50]" :total="filteredReturnCartons.length" layout="total, sizes, prev, pager, next" @size-change="returnCartonPage = 1" /></div> <div class="return-pagination"><span>已选择 {{ selectedReturnMarks.length }} 个{{ returnPickMode === 'SKU' ? '来源' : '箱号' }}</span><el-pagination v-model:current-page="returnCartonPage" v-model:page-size="returnCartonPageSize" :page-sizes="[5, 10, 20, 50]" :total="filteredReturnCartons.length" layout="total, sizes, prev, pager, next" @size-change="returnCartonPage = 1" /></div>
</section> </section>
</div> </div>
@@ -394,7 +409,7 @@ onMounted(load)
.candidate-pagination :deep(.el-pagination__sizes), .return-pagination :deep(.el-pagination__sizes) { flex: 0 0 128px; } .candidate-pagination :deep(.el-pagination__sizes), .return-pagination :deep(.el-pagination__sizes) { flex: 0 0 128px; }
.candidate-pagination :deep(.el-pagination__sizes .el-select), .return-pagination :deep(.el-pagination__sizes .el-select) { width: 128px; } .candidate-pagination :deep(.el-pagination__sizes .el-select), .return-pagination :deep(.el-pagination__sizes .el-select) { width: 128px; }
.candidate-pagination :deep(.el-pagination__jump .el-input) { width: 56px; } .candidate-pagination :deep(.el-pagination__jump .el-input) { width: 56px; }
.return-lists { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--admin-space-5); width: 100%; margin: var(--admin-space-1) 0 var(--admin-space-5); } .return-lists { display: grid; grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr); gap: var(--admin-space-5); width: 100%; margin: var(--admin-space-1) 0 var(--admin-space-5); }
.return-pallet-options { display: flex; flex-wrap: wrap; gap: 8px 16px; } .return-pallet-options { display: flex; flex-wrap: wrap; gap: 8px 16px; }
.return-list-card { min-width: 0; padding: var(--admin-space-5); border: 1px solid var(--el-border-color-light); border-radius: var(--admin-radius); background: var(--el-fill-color-extra-light); } .return-list-card { min-width: 0; padding: var(--admin-space-5); border: 1px solid var(--el-border-color-light); border-radius: var(--admin-radius); background: var(--el-fill-color-extra-light); }
.list-title { display: flex; align-items: baseline; justify-content: space-between; gap: var(--admin-space-5); margin-bottom: var(--admin-space-5); } .list-title { display: flex; align-items: baseline; justify-content: space-between; gap: var(--admin-space-5); margin-bottom: var(--admin-space-5); }