优化
This commit is contained in:
@@ -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 } })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
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 }) {
|
||||
export async function createReturnOutbound(customerId: string, payload: { recipient: string; overseasWarehouseId: string; returnNo: string; cartonMarks: string[]; 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 } })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
@@ -142,6 +142,7 @@ export interface ReturnPackingPlanPayload {
|
||||
remark?: string
|
||||
idempotencyKey: string
|
||||
sourceCartonMarks?: string[]
|
||||
sourceDeductions?: Array<{ cartonMark: string; skuCode: string; quantity: number }>
|
||||
}
|
||||
|
||||
export interface ReturnPackingPlanDto extends Omit<ReturnPackingPlanPayload, 'remark'> {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
|
||||
|
||||
export interface ReturnStockRow { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; totalQuantity: number; availableQuantity: number; looseAvailableQuantity?: number; cartonQuantity: number; updatedAt: string }
|
||||
export interface ReturnCartonRow { customerId: string; overseasWarehouseId: string; cartonMark: string; sourceReturnNo?: string; sourceUnitType?: 'PALLET' | 'BOX' | 'PARCEL' | string; sourceBatchNo?: string; status: string; fbaCartonMark?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string; relabelNo?: string; relabelStatus?: string; skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; createdAt: string }
|
||||
export interface ReturnCartonRow { customerId: string; overseasWarehouseId: string; cartonMark: string; sourceReturnNo?: string; sourceUnitType?: 'PALLET' | 'BOX' | 'PARCEL' | string; sourceBatchNo?: string; status: string; fbaCartonMark?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string; relabelNo?: string; relabelStatus?: string; relabelledQuantity?: number; relabelReservedQuantity?: number; skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; createdAt: string }
|
||||
export interface ReturnCartonMergeHistory { planNo?: string; sourceCartonMark: string; targetCartonMark: string; returnNo: string; skuCode: string; quantity: number; createdAt: string }
|
||||
export interface ReturnCartonDetail extends ReturnCartonRow { customerId: string; items: Array<{ skuCode: string; productNameZh?: string; quantity: number; availableQuantity?: number }>; mergeHistory?: ReturnCartonMergeHistory[] }
|
||||
export interface ReturnCartonLabel extends ReturnCartonDetail { printableHtml: string }
|
||||
|
||||
@@ -38,10 +38,7 @@ async function open(sku: string) {
|
||||
const order = wrapper.findAllComponents(ElSelect).find(select => select.props('placeholder') === '选择已签收入库的退货单')!
|
||||
order.vm.$emit('update:modelValue', 'RT-1'); order.vm.$emit('change', 'RT-1'); await flushPromises()
|
||||
await wrapper.get('input[placeholder="请输入收件人、电话和地址"]').setValue('本地测试收件信息')
|
||||
const mode = wrapper.findAllComponents({ name: 'ElRadioGroup' }).find(group => group.text().includes('单 SKU'))!
|
||||
mode.vm.$emit('update:modelValue', 'SKU'); mode.vm.$emit('change', 'SKU'); await flushPromises()
|
||||
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))!.vm.$emit('change', true); await flushPromises()
|
||||
|
||||
}
|
||||
function right() { return wrapper.findAllComponents({ name: 'ElTable' }).filter(table => table.classes().includes('return-result-table'))[1]! }
|
||||
async function selectMarks(marks: string[]) {
|
||||
@@ -51,59 +48,24 @@ async function selectMarks(marks: string[]) {
|
||||
}
|
||||
await flushPromises()
|
||||
}
|
||||
describe('single SKU outbound source and piece quantity', () => {
|
||||
it('creates one outbound with multiple SKUs and independent quantities on source rows', async () => {
|
||||
describe('physical return outbound', () => {
|
||||
it('submits whole multi-SKU parcels and boxes without a direct partial payload', 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()
|
||||
expect(wrapper.findAllComponents(ElInputNumber)).toHaveLength(0)
|
||||
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('选择来源后填写件数')
|
||||
expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ cartonMarks: ['LOOSE-A', 'PARCEL'], unitType: 'BOX' }))
|
||||
const request = api.create.mock.calls[0][1]
|
||||
expect(request.items).toBeUndefined(); expect(request.skuCode).toBeUndefined(); expect(request.quantity).toBeUndefined()
|
||||
})
|
||||
it('selects only loose SKU sources scoped to the return order and excludes packed boxes', async () => {
|
||||
it('includes physical parcels while excluding other return orders', async () => {
|
||||
await open('SKU-A')
|
||||
expect(right().props('data').map((row: { cartonMark: string }) => row.cartonMark)).toEqual(['LOOSE-A', 'LOOSE-B'])
|
||||
expect(wrapper.text()).not.toContain('OTHER-ORDER')
|
||||
await selectMarks(['LOOSE-A', 'LOOSE-B'])
|
||||
expect(wrapper.findAllComponents(ElInputNumber).map(input => input.props('modelValue'))).toEqual([5, 5])
|
||||
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: 5 }, { cartonMark: 'LOOSE-B', skuCode: 'SKU-A', quantity: 5 }], cartonMarks: ['LOOSE-A', 'LOOSE-B'] }))
|
||||
const marks = right().props('data').map((row: { cartonMark: string }) => row.cartonMark)
|
||||
expect(marks).toContain('PARCEL'); expect(marks).toContain('LOOSE-A'); expect(marks).not.toContain('OTHER-ORDER')
|
||||
expect(wrapper.text()).toContain('合箱')
|
||||
})
|
||||
it('submits six pieces from two five-piece loose sources', async () => {
|
||||
await open('SKU-A'); await selectMarks(['LOOSE-A', 'LOOSE-B'])
|
||||
wrapper.findAllComponents(ElInputNumber)[1]!.vm.$emit('update:modelValue', 1); 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: 5 }, { cartonMark: 'LOOSE-B', skuCode: 'SKU-A', quantity: 1 }] }))
|
||||
})
|
||||
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 selectMarks(['PARCEL']); wrapper.findComponent(ElInputNumber).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: 'PARCEL', skuCode: 'SKU-B', quantity: 2 }], cartonMarks: ['PARCEL'] }))
|
||||
})
|
||||
it('never offers a parcel as a physical box in box/pallet mode', async () => {
|
||||
await open('SKU-B')
|
||||
const mode = wrapper.findAllComponents({ name: 'ElRadioGroup' }).find(group => group.text().includes('单 SKU'))!
|
||||
mode.vm.$emit('update:modelValue', 'BOX'); mode.vm.$emit('change', 'BOX'); await flushPromises()
|
||||
expect(right().props('data').some((row: { sourceUnitType: string }) => row.sourceUnitType === 'PARCEL')).toBe(false)
|
||||
expect(right().text()).not.toContain('包裹散件')
|
||||
})
|
||||
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'])
|
||||
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', quantity); await flushPromises()
|
||||
await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises()
|
||||
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 => {
|
||||
await open('SKU-A'); await selectMarks(['LOOSE-A', 'LOOSE-B'])
|
||||
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', quantity); await flushPromises()
|
||||
it('blocks submission without a physical parcel or carton', async () => {
|
||||
await open('SKU-A')
|
||||
await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises()
|
||||
expect(api.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -49,7 +49,7 @@ async function selectBothPages() {
|
||||
}
|
||||
|
||||
describe('outbound candidate search and selection', () => {
|
||||
it('limits single SKU quantities and selectable sources to loose stock of the selected return, excluding pallet and box contents', async () => {
|
||||
it('offers whole physical parcels and boxes scoped to the selected return', async () => {
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ id: 'customer-a', tenantName: '客户甲', status: 'ACTIVE' }], page: 1, size: 200, total: 1, pages: 1 } as Awaited<ReturnType<typeof listPlatformTenants>>)
|
||||
const sources = [
|
||||
{ cartonMark: 'P-A', sourceUnitType: 'PALLET', skuCode: 'SKU-A', quantity: 5 },
|
||||
@@ -70,18 +70,13 @@ describe('outbound candidate search and selection', () => {
|
||||
customer.vm.$emit('update:modelValue', 'customer-a'); customer.vm.$emit('change'); await flushPromises()
|
||||
const order = wrapper.findAllComponents(ElSelect).find(row => row.props('placeholder') === '选择已签收入库的退货单')!
|
||||
order.vm.$emit('update:modelValue', 'RT-MIXED'); order.vm.$emit('change'); await flushPromises()
|
||||
const mode = wrapper.findAllComponents({ name: 'ElRadioGroup' }).find(row => row.text().includes('单 SKU'))!
|
||||
mode.vm.$emit('update:modelValue', 'SKU'); mode.vm.$emit('change'); await flushPromises()
|
||||
const stock = wrapper.findAll('.return-list-card')[0]!
|
||||
expect(stock.findAll('tbody tr').map(row => row.text().replace(/\s/g, ''))).toEqual(['SKU-A1', 'SKU-B5'])
|
||||
stock.findAllComponents(ElCheckbox)[0]!.vm.$emit('change', true); await flushPromises()
|
||||
const sourceTable = wrapper.findAll('.return-list-card')[1]!
|
||||
expect(sourceTable.findAll('tbody tr')).toHaveLength(1)
|
||||
expect(sourceTable.text()).toContain('SKU-A × 1')
|
||||
sourceTable.findAllComponents(ElCheckbox)[0]!.vm.$emit('change', true); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(1)
|
||||
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(sourceTable.findAll('tbody tr')).toHaveLength(5)
|
||||
expect(sourceTable.text()).toContain('SP-A')
|
||||
expect(sourceTable.text()).toContain('BOX-A')
|
||||
expect(sourceTable.text()).not.toContain('SP-OTHER')
|
||||
expect(wrapper.findComponent({ name: 'ElInputNumber' }).exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('合箱')
|
||||
})
|
||||
beforeEach(() => {
|
||||
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
|
||||
|
||||
@@ -36,9 +36,6 @@ const selectedDestinationWarehouseId = ref('')
|
||||
const deliveryAddress = ref(''); const postalCode = ref(''); const contactName = ref(''); const contactPhone = ref('')
|
||||
const returnStockRows = ref<ReturnStockRow[]>([])
|
||||
const returnCartonRows = ref<ReturnCartonRow[]>([])
|
||||
const selectedReturnSkus = ref<string[]>([])
|
||||
const returnPickMode = ref<'BOX' | 'SKU'>('BOX')
|
||||
const returnSourceQuantities = ref<Record<string, number | undefined>>({})
|
||||
const returnSkuQueryInput = ref(''); const returnSkuQuery = ref('')
|
||||
const returnCartonQueryInput = ref(''); const returnCartonQuery = ref('')
|
||||
const returnSkuPage = ref(1); const returnCartonPage = ref(1); const returnUnitType = ref<'BOX' | 'PALLET'>('BOX')
|
||||
@@ -65,16 +62,7 @@ function transferCustomerName(row: WarehouseTransferOutboundCandidate) {
|
||||
const customer = tenantOptions.value.find(item => String(item.id) === String(row.customerId))
|
||||
return customer?.legalName?.trim() || customer?.tenantName?.trim() || `客户 ${row.customerId}`
|
||||
}
|
||||
const selectedReturnStock = computed(() => returnStockRows.value.map((item) => {
|
||||
if (returnPickMode.value !== 'SKU') return item
|
||||
const quantity = selectedReturnCartons.value.reduce((sum, carton) => {
|
||||
if (carton.sourceUnitType !== 'PARCEL') return sum
|
||||
if (selectedReturnNo.value && !(carton.sourceReturnNo || '').split(',').includes(selectedReturnNo.value)) return sum
|
||||
const items = returnCartonDetails.value[carton.cartonMark]?.items
|
||||
return items?.length === 1 && items[0]?.skuCode === item.skuCode ? sum + Number(items[0].availableQuantity ?? items[0].quantity) : sum
|
||||
}, 0)
|
||||
return { ...item, availableQuantity: quantity }
|
||||
}).filter(item => Number(item.availableQuantity || 0) > 0))
|
||||
const selectedReturnStock = computed(() => returnStockRows.value.filter(item => Number(item.availableQuantity || 0) > 0))
|
||||
const selectedReturnCartons = computed(() => returnCartonRows.value.filter((item) => item.status === 'AVAILABLE'))
|
||||
const returnOrderOptions = computed(() => [...new Set([
|
||||
...returnCandidates.value.flatMap(item => (item.returnNo || '').split(',')),
|
||||
@@ -86,25 +74,16 @@ const queryTokens = (value: string) => value.split(/[,,\n]+/).map((item) => it
|
||||
const matchesAnyToken = (value: string, query: string) => { const tokens = queryTokens(query); const normalized = value.replace(/\s+/g, '').toLowerCase(); return !tokens.length || tokens.some((token) => normalized.includes(token)) }
|
||||
const filteredReturnStocks = computed(() => selectedReturnStock.value.filter((item) => matchesAnyToken(`${item.skuCode},${item.productNameZh || ''}`, returnSkuQuery.value)))
|
||||
const filteredReturnCartons = computed(() => selectedReturnCartons.value.filter((item) => {
|
||||
if (returnPickMode.value === 'BOX' && item.sourceUnitType === 'PARCEL') return false
|
||||
if (selectedReturnNo.value && !(item.sourceReturnNo || returnCandidateByMark.value[item.cartonMark]?.returnNo || '').split(',').includes(selectedReturnNo.value)) return false
|
||||
if (returnPickMode.value === 'SKU') {
|
||||
if (item.sourceUnitType !== 'PARCEL') return false
|
||||
const detail = returnCartonDetails.value[item.cartonMark]
|
||||
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(',') || ''
|
||||
return matchesAnyToken(`${item.cartonMark},${item.fbaCartonMark || ''},${detailSkus}`, returnCartonQuery.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 effectiveReturnMarks = computed(() => [...new Set(selectedReturnMarks.value)].filter(Boolean) as string[])
|
||||
function returnSourceAvailable(mark: string) {
|
||||
const item = returnCartonDetails.value[mark]?.items[0]
|
||||
return Number(item?.availableQuantity ?? item?.quantity ?? 0)
|
||||
}
|
||||
|
||||
watch([selectedReturnNo, selectedCustomerId, selectedWarehouseId], () => {
|
||||
selectedReturnSkus.value = []; selectedReturnMarks.value = []; returnSourceQuantities.value = {}
|
||||
selectedReturnMarks.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())))
|
||||
@@ -154,8 +133,8 @@ async function loadInventoryOptions() {
|
||||
async function openCreate() { createOpen.value = true; outboundType.value = 'ONE_CLICK_DROPSHIP'; selectedCustomerId.value = ''; selectedWarehouseId.value = ''; selectedDropshipIds.value = []; selectedTransferPalletIds.value = []; transferCandidates.value = []; destinationType.value = 'FBA'; selectedDestinationWarehouseId.value = ''; deliveryAddress.value = ''; postalCode.value = ''; contactName.value = ''; contactPhone.value = ''; dropshipCandidates.value = []; dropshipQueryInput.value = ''; dropshipQuery.value = ''; dropshipPage.value = 1; returnSkuQueryInput.value = ''; returnSkuQuery.value = ''; returnCartonQueryInput.value = ''; returnCartonQuery.value = ''; returnSkuPage.value = 1; returnCartonPage.value = 1; await loadInventoryOptions(); await loadDropshipCandidates() }
|
||||
async function loadDropshipCandidates() { if (!selectedWarehouseId.value) { dropshipCandidates.value = []; return }; dropshipPage.value = 1; inventoryLoading.value = true; try { dropshipCandidates.value = await listOneClickOutboundCandidates(selectedCustomerId.value || undefined, { warehouseId: selectedWarehouseId.value }) } catch (cause) { dropshipCandidates.value = []; ElMessage.error(getApiErrorDetails(cause, '一件代发订单加载失败').message) } finally { inventoryLoading.value = false } }
|
||||
async function changeDropshipWarehouse() { selectedDropshipIds.value = []; await loadDropshipCandidates() }
|
||||
async function selectCustomer() { selectedPositionId.value = ''; selectedDropshipIds.value = []; selectedReturnNo.value = ''; selectedReturnMarks.value = []; selectedReturnSkus.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; dropshipCandidates.value = []; dropshipPage.value = 1; if (outboundType.value === 'RETURN_OUTBOUND') { if (selectedCustomerId.value && selectedWarehouseId.value) await loadReturnCandidates(); return }; await loadDropshipCandidates() }
|
||||
async function changeOutboundType() { selectedReturnNo.value = ''; selectedReturnMarks.value = []; selectedReturnSkus.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; selectedDropshipIds.value = []; selectedTransferPalletIds.value = []; dropshipCandidates.value = []; transferCandidates.value = []; if (outboundType.value === 'ONE_CLICK_DROPSHIP') await loadDropshipCandidates(); if (outboundType.value === 'WAREHOUSE_TRANSFER_OUTBOUND') await loadTransferCandidates() }
|
||||
async function selectCustomer() { selectedPositionId.value = ''; selectedDropshipIds.value = []; selectedReturnNo.value = ''; selectedReturnMarks.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; dropshipCandidates.value = []; dropshipPage.value = 1; if (outboundType.value === 'RETURN_OUTBOUND') { if (selectedCustomerId.value && selectedWarehouseId.value) await loadReturnCandidates(); return }; await loadDropshipCandidates() }
|
||||
async function changeOutboundType() { selectedReturnNo.value = ''; selectedReturnMarks.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; selectedDropshipIds.value = []; selectedTransferPalletIds.value = []; dropshipCandidates.value = []; transferCandidates.value = []; if (outboundType.value === 'ONE_CLICK_DROPSHIP') await loadDropshipCandidates(); if (outboundType.value === 'WAREHOUSE_TRANSFER_OUTBOUND') await loadTransferCandidates() }
|
||||
async function loadTransferCandidates() {
|
||||
inventoryLoading.value = true
|
||||
try {
|
||||
@@ -181,7 +160,7 @@ function transferDestinationLabel(row: WarehouseTransferOutboundCandidate) {
|
||||
function transferHasDestination(row: WarehouseTransferOutboundCandidate) {
|
||||
return Boolean(row.destinationWarehouseCode?.trim() || row.privateDestinationAddress?.trim())
|
||||
}
|
||||
async function loadReturnCandidates() { if (!selectedCustomerId.value || !selectedWarehouseId.value) return; inventoryLoading.value = true; try { const [candidates, stock, cartons] = await Promise.all([listReturnOutboundCandidates(selectedCustomerId.value, selectedWarehouseId.value), listReturnStock({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' }), listReturnCartons({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' })]); returnCandidates.value = candidates; returnStockRows.value = stock.records; returnCartonRows.value = cartons.records; const details = await Promise.all(cartons.records.map(async (row) => [row.cartonMark, await getReturnCarton(row.cartonMark)] as const)); returnCartonDetails.value = Object.fromEntries(details); selectedReturnSkus.value = []; selectedReturnMarks.value = [] } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '客户库存加载失败').message) } finally { inventoryLoading.value = false } }
|
||||
async function loadReturnCandidates() { if (!selectedCustomerId.value || !selectedWarehouseId.value) return; inventoryLoading.value = true; try { const [candidates, stock, cartons] = await Promise.all([listReturnOutboundCandidates(selectedCustomerId.value, selectedWarehouseId.value), listReturnStock({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' }), listReturnCartons({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' })]); returnCandidates.value = candidates; returnStockRows.value = stock.records; returnCartonRows.value = cartons.records; const details = await Promise.all(cartons.records.map(async (row) => [row.cartonMark, await getReturnCarton(row.cartonMark)] as const)); returnCartonDetails.value = Object.fromEntries(details); selectedReturnMarks.value = [] } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '客户库存加载失败').message) } finally { inventoryLoading.value = false } }
|
||||
function dropshipCandidateKey(item: OneClickOutboundCandidate) { return `${item.inventoryPositionId}:${item.skuCode || ''}` }
|
||||
function isDropshipSelected(item: OneClickOutboundCandidate) { return selectedDropshipIds.value.includes(dropshipCandidateKey(item)) }
|
||||
function toggleDropshipCandidate(item: OneClickOutboundCandidate, checked: boolean | string | number) { const key = dropshipCandidateKey(item); selectedDropshipIds.value = checked ? [...new Set([...selectedDropshipIds.value, key])] : selectedDropshipIds.value.filter(value => value !== key) }
|
||||
@@ -196,15 +175,9 @@ function resetDropshipCandidates() { dropshipQueryInput.value = ''; dropshipQuer
|
||||
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 searchReturnCartons() { returnCartonQueryInput.value = normalizeBatchQuery(returnCartonQueryInput.value); returnCartonQuery.value = returnCartonQueryInput.value; returnCartonPage.value = 1 }
|
||||
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 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)
|
||||
if (checked && returnPickMode.value === 'SKU') returnSourceQuantities.value[cartonMark] = returnSourceAvailable(cartonMark)
|
||||
}
|
||||
function toggleReturnPallet(palletCode: string, checked: boolean | string | number) {
|
||||
const marks = returnCandidates.value.filter(item => item.palletCode === palletCode && item.cartonMark).map(item => item.cartonMark!)
|
||||
@@ -226,24 +199,13 @@ async function submit() {
|
||||
} else if (outboundType.value === 'RETURN_OUTBOUND') {
|
||||
if (!recipient.value.trim()) { ElMessage.warning('请填写收件信息'); return }
|
||||
if (!selectedReturnNo.value) { ElMessage.warning('请选择退货单'); return }
|
||||
if (returnPickMode.value === 'SKU') {
|
||||
if (!selectedReturnSkus.value.length) { ElMessage.warning('请至少选择一个 SKU'); 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
|
||||
}
|
||||
}
|
||||
// Return outbound is physical-unit based. Partial SKU quantities must go
|
||||
// through the PDA packing flow and produce a new labelled carton first.
|
||||
if (!selectedReturnMarks.value.length) {
|
||||
ElMessage.warning(returnPickMode.value === 'SKU'
|
||||
? filteredReturnCartons.value.length ? '请在右侧选择该 SKU 的散件来源记录' : '该退货单没有此 SKU 的可用散件库存;箱内库存请按箱/托出库'
|
||||
: '请选择出库箱号或托盘')
|
||||
ElMessage.warning('请选择出库包裹/箱号或托盘')
|
||||
return
|
||||
}
|
||||
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()}` })
|
||||
await createReturnOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), overseasWarehouseId: selectedWarehouseId.value, returnNo: selectedReturnNo.value, cartonMarks: selectedReturnMarks.value, unitType: returnUnitType.value, remark: remark.value.trim() || undefined, idempotencyKey: `web-return-${Date.now()}` })
|
||||
ElMessage.success('退货出库单和待派送单已创建,作业已推送 PDA')
|
||||
} else {
|
||||
const selected = dropshipCandidates.value.filter(item => selectedDropshipIds.value.includes(dropshipCandidateKey(item)))
|
||||
@@ -363,21 +325,21 @@ onMounted(load)
|
||||
<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="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 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' && 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>
|
||||
<p class="candidate-hint">整包裹、整箱或整托出库。包裹或箱子只出部分商品时,请先在退货合箱作业生成新箱并贴标,再选择新箱出库。</p>
|
||||
<el-form-item 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="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">
|
||||
<section class="return-list-card">
|
||||
<div class="list-title"><span>SKU库存</span><small>按 SKU 查询</small></div>
|
||||
<div class="candidate-query"><el-input v-model="returnSkuQueryInput" clearable placeholder="如:SKU001,SKU002" /><el-button type="primary" @click="searchReturnSkus">查询</el-button></div>
|
||||
<el-table :data="pagedReturnStocks" border stripe class="return-result-table" max-height="400"><el-table-column v-if="returnPickMode === 'SKU'" label="选择" width="60" align="center"><template #default="{ row }"><el-checkbox :model-value="selectedReturnSkus.includes(row.skuCode)" @change="toggleReturnSku(row.skuCode, $event)" /></template></el-table-column><el-table-column prop="skuCode" label="SKU" min-width="150" show-overflow-tooltip /><el-table-column prop="productNameZh" label="品名" min-width="150" show-overflow-tooltip /><el-table-column label="可用数量" width="110" align="right"><template #default="{ row }">{{ row.availableQuantity }}</template></el-table-column></el-table>
|
||||
<div class="return-pagination"><span>{{ returnPickMode === 'SKU' ? `已选择 ${selectedReturnSkus.length} 个 SKU` : 'SKU 库存' }}</span><el-pagination v-model:current-page="returnSkuPage" v-model:page-size="returnSkuPageSize" :page-sizes="[5, 10, 20, 50]" :total="filteredReturnStocks.length" layout="total, sizes, prev, pager, next" @size-change="returnSkuPage = 1" /></div>
|
||||
<el-table :data="pagedReturnStocks" border stripe class="return-result-table" max-height="400"><el-table-column prop="skuCode" label="SKU" min-width="150" show-overflow-tooltip /><el-table-column prop="productNameZh" label="品名" min-width="150" show-overflow-tooltip /><el-table-column label="可用数量" width="110" align="right"><template #default="{ row }">{{ row.availableQuantity }}</template></el-table-column></el-table>
|
||||
<div class="return-pagination"><span>SKU 库存</span><el-pagination v-model:current-page="returnSkuPage" v-model:page-size="returnSkuPageSize" :page-sizes="[5, 10, 20, 50]" :total="filteredReturnStocks.length" layout="total, sizes, prev, pager, next" @size-change="returnSkuPage = 1" /></div>
|
||||
</section>
|
||||
<section class="return-list-card">
|
||||
<div class="list-title"><span>{{ returnPickMode === 'SKU' ? 'SKU 来源明细' : '箱号列表' }}</span><small>支持多个箱号或 SKU,用英文逗号分隔</small></div>
|
||||
<div class="list-title"><span>包裹 / 箱号列表</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>
|
||||
<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>
|
||||
<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="包裹 / 箱号" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ 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>
|
||||
<div class="return-pagination"><span>已选择 {{ selectedReturnMarks.length }} 个包裹 / 箱</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -5,7 +5,7 @@ import ElementPlus from 'element-plus'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import { UI_PREFERENCE_SCOPE, preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
|
||||
import { getReturnCarton, listReturnCartons, listReturnInventoryMovements, listReturnStock, listReturnPackingSources } from '../api/returnInventory'
|
||||
import { listReturnPackingPlans } from '../api/relabelOrders'
|
||||
import { createReturnPackingPlan, listReturnPackingPlans } from '../api/relabelOrders'
|
||||
import ReturnInventoryManagementView from './ReturnInventoryManagementView.vue'
|
||||
import { listPlatformTenants } from '@/modules/iam'
|
||||
const routeQuery = vi.hoisted(() => ({} as Record<string, string>))
|
||||
@@ -56,6 +56,29 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('ReturnInventoryManagementView shared list', () => {
|
||||
it('keeps partial quantities bound to each physical parcel and box when they share a SKU', async () => {
|
||||
vi.mocked(listReturnPackingSources).mockResolvedValue({ records: [
|
||||
{ ...carton, cartonMark: 'PARCEL-A', sourceUnitType: 'PARCEL', sourceReturnNo: 'RETURN-1', items: [{ skuCode: 'SKU-A', quantity: 5, availableQuantity: 5 }] },
|
||||
{ ...carton, cartonMark: 'BOX-A', sourceUnitType: 'BOX', sourceReturnNo: 'RETURN-1', items: [{ skuCode: 'SKU-A', quantity: 4, availableQuantity: 4 }] },
|
||||
], page: 1, size: 200, total: 2, pages: 1 })
|
||||
const wrapper = await mountView('inventory')
|
||||
await wrapper.findAll('button').find(button => button.text().includes('创建合箱计划'))!.trigger('click'); await flushPromises()
|
||||
const form = wrapper.findAllComponents({ name: 'ElForm' }).find(form => form.classes().includes('packing-form'))!
|
||||
const customer = form.getComponent({ name: 'ElSelect' })
|
||||
customer.vm.$emit('update:modelValue', 'customer-1'); customer.vm.$emit('change', 'customer-1'); await flushPromises()
|
||||
const mode = form.getComponent({ name: 'ElRadioGroup' })
|
||||
mode.vm.$emit('update:modelValue', 'SKU'); mode.vm.$emit('change', 'SKU'); await flushPromises()
|
||||
const rows = form.findAll('.packing-sku-row')
|
||||
expect(rows).toHaveLength(2)
|
||||
rows.forEach(row => row.getComponent({ name: 'ElCheckbox' }).vm.$emit('change', true)); await flushPromises()
|
||||
rows[1]!.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2); await flushPromises()
|
||||
wrapper.findAllComponents({ name: 'AppDialog' }).find(dialog => dialog.props('modelValue'))!.vm.$emit('confirm'); await flushPromises()
|
||||
expect(createReturnPackingPlan).toHaveBeenCalledWith(expect.objectContaining({
|
||||
mode: 'SKU', sourceCartonMarks: ['PARCEL-A', 'BOX-A'],
|
||||
sourceDeductions: [{ cartonMark: 'PARCEL-A', skuCode: 'SKU-A', quantity: 1 }, { cartonMark: 'BOX-A', skuCode: 'SKU-A', quantity: 2 }],
|
||||
boxes: [{ items: [{ skuCode: 'SKU-A', quantity: 3 }] }],
|
||||
}))
|
||||
})
|
||||
it('prefills the selected return orders after loading sources from the tail-order entry', async () => {
|
||||
Object.assign(routeQuery, { packing: '1', customerId: 'customer-1', overseasWarehouseId: 'warehouse-1', returnNos: 'RETURN-1' })
|
||||
vi.mocked(listReturnPackingSources).mockResolvedValue({ records: [{ ...carton, sourceReturnNo: 'RETURN-1', sourceUnitType: 'BOX', items: [{ skuCode: 'SKU-TEST', quantity: 8, availableQuantity: 8 }] }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
@@ -248,3 +271,11 @@ describe('ReturnInventoryManagementView shared list', () => {
|
||||
expect(document.body.textContent).toContain('SKU-TEST')
|
||||
})
|
||||
})
|
||||
|
||||
it('箱列表分别显示后端实际完成量与未完成申请占用量', async () => {
|
||||
vi.mocked(listReturnCartons).mockResolvedValue({ records: [{ ...carton, relabelStatus: 'PARTIALLY_COMPLETED', relabelledQuantity: 3, relabelReservedQuantity: 2 }], page: 1, size: 20, total: 1, pages: 1 })
|
||||
const wrapper = await mountView('cartons')
|
||||
expect(wrapper.text()).toContain('部分完成')
|
||||
expect(wrapper.text()).toContain('实际完成 3 件')
|
||||
expect(wrapper.text()).toContain('处理中占用 2 件')
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { returnRelabelStatusLabel } from "../utils/returnRelabelProgress";
|
||||
import "@/styles/adminDensity.css";
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import dayjs from "dayjs";
|
||||
@@ -54,7 +55,7 @@ const warehouses = ref<Array<{ id: string; warehouseName: string }>>([]);
|
||||
const optionsLoading = ref(false),
|
||||
packingDialogOpen = ref(false),
|
||||
packingSaving = ref(false);
|
||||
const packingSkuRows = ref<Array<Pick<ReturnStockRow, 'skuCode' | 'availableQuantity'>>>([]),
|
||||
const packingSkuRows = ref<Array<{ cartonMark: string; skuCode: string; availableQuantity: number; key: string }>>([]),
|
||||
packingReturnOptions = ref<
|
||||
Array<{ returnNo: string; unitTypes: string[]; cartonCount: number }>
|
||||
>([]);
|
||||
@@ -311,7 +312,7 @@ async function loadPackingSources() {
|
||||
}
|
||||
function sourceReturnNos(source: ReturnPackingSource) { return (source.sourceReturnNo || '').split(',').filter(Boolean); }
|
||||
function updatePackingScope() {
|
||||
const sources = packingSources.value.filter(row => packingForm.mode === 'SKU' ? row.sourceUnitType === 'PARCEL' : row.sourceUnitType !== 'PARCEL');
|
||||
const sources = packingSources.value;
|
||||
const options = new Map<string, { returnNo: string; unitTypes: string[]; cartonCount: number }>();
|
||||
for (const source of sources) for (const returnNo of sourceReturnNos(source)) {
|
||||
const option = options.get(returnNo) || { returnNo, unitTypes: [], cartonCount: 0 };
|
||||
@@ -320,13 +321,11 @@ function updatePackingScope() {
|
||||
}
|
||||
packingReturnOptions.value = [...options.values()];
|
||||
const scoped = sources.filter(row => !packingForm.returnNos.length || sourceReturnNos(row).every(no => packingForm.returnNos.includes(no)));
|
||||
packingBoxRows.value = scoped.filter(row => row.sourceUnitType !== 'PARCEL').map(row => ({ cartonMark: row.cartonMark, returnNo: row.sourceReturnNo || '', unitType: row.sourceUnitType || 'BOX', items: row.items }));
|
||||
const quantities = new Map<string, number>();
|
||||
for (const source of scoped.filter(row => row.sourceUnitType === 'PARCEL')) for (const item of source.items)
|
||||
quantities.set(item.skuCode, (quantities.get(item.skuCode) || 0) + Number(item.availableQuantity ?? item.quantity));
|
||||
packingSkuRows.value = [...quantities].filter(([, quantity]) => quantity > 0).map(([skuCode, quantity]) => ({
|
||||
skuCode, availableQuantity: quantity,
|
||||
}));
|
||||
packingBoxRows.value = scoped.map(row => ({ cartonMark: row.cartonMark, returnNo: row.sourceReturnNo || '', unitType: row.sourceUnitType || 'BOX', items: row.items }));
|
||||
packingSkuRows.value = scoped.flatMap(source => source.items.map(item => ({
|
||||
cartonMark: source.cartonMark, skuCode: item.skuCode,
|
||||
availableQuantity: Number(item.availableQuantity ?? item.quantity), key: `${source.cartonMark}|${item.skuCode}`,
|
||||
}))).filter(item => item.availableQuantity > 0);
|
||||
}
|
||||
function togglePackingSku(sku: string, checked: boolean) {
|
||||
if (checked) packingForm.skuQuantities[sku] = 1;
|
||||
@@ -341,12 +340,14 @@ function changePackingMode() {
|
||||
updatePackingScope();
|
||||
}
|
||||
async function submitPackingPlan() {
|
||||
const selectedBoxes = packingSources.value.filter(row => packingForm.sourceCartonMarks.includes(row.cartonMark));
|
||||
const selectedSources = packingForm.mode === 'BOX' ? selectedBoxes : packingSources.value.filter(row => row.sourceUnitType === 'PARCEL' && (!packingForm.returnNos.length || sourceReturnNos(row).every(no => packingForm.returnNos.includes(no))));
|
||||
const returnNos = [...new Set(selectedSources.flatMap(sourceReturnNos))];
|
||||
const items = Object.entries(packingForm.skuQuantities)
|
||||
.filter(([, quantity]) => Number(quantity) > 0)
|
||||
.map(([skuCode, quantity]) => ({ skuCode, quantity: Number(quantity) }));
|
||||
const sourceDeductions = packingSkuRows.value.filter(row => Number(packingForm.skuQuantities[row.key]) > 0)
|
||||
.map(row => ({ cartonMark: row.cartonMark, skuCode: row.skuCode, quantity: Number(packingForm.skuQuantities[row.key]) }));
|
||||
const selectedMarks = packingForm.mode === 'BOX' ? packingForm.sourceCartonMarks : [...new Set(sourceDeductions.map(row => row.cartonMark))];
|
||||
const selectedBoxes = packingSources.value.filter(row => selectedMarks.includes(row.cartonMark));
|
||||
const returnNos = [...new Set(selectedBoxes.flatMap(sourceReturnNos))];
|
||||
const quantities = new Map<string, number>();
|
||||
sourceDeductions.forEach(row => quantities.set(row.skuCode, (quantities.get(row.skuCode) || 0) + row.quantity));
|
||||
const items = [...quantities].map(([skuCode, quantity]) => ({ skuCode, quantity }));
|
||||
const boxes = [{ items }];
|
||||
if (
|
||||
!packingForm.customerId || !packingForm.overseasWarehouseId ||
|
||||
@@ -368,7 +369,8 @@ async function submitPackingPlan() {
|
||||
returnNos,
|
||||
mode: packingForm.mode,
|
||||
boxes,
|
||||
sourceCartonMarks: packingForm.mode === 'BOX' ? packingForm.sourceCartonMarks : [],
|
||||
sourceCartonMarks: selectedMarks,
|
||||
sourceDeductions: packingForm.mode === 'SKU' ? sourceDeductions : undefined,
|
||||
remark: packingForm.remark.trim() || undefined,
|
||||
idempotencyKey:
|
||||
globalThis.crypto?.randomUUID?.() || `return-packing-${Date.now()}`,
|
||||
@@ -488,7 +490,7 @@ const cartonColumns: DataTableColumn[] = [
|
||||
},
|
||||
{ key: "carton-6", label: "目的仓", minWidth: 180, slotName: "carton-6" },
|
||||
{ key: "relabelNo", label: "换标单号", prop: "relabelNo", minWidth: 200 },
|
||||
{ key: "carton-8", label: "换标状态", width: 110, slotName: "carton-8" },
|
||||
{ key: "carton-8", label: "换标进度", minWidth: 290, slotName: "carton-8" },
|
||||
{ key: "carton-9", label: "状态", width: 100, slotName: "carton-9" },
|
||||
{ key: "skuTypes", label: "SKU种类", prop: "skuTypes", align: "right" },
|
||||
{
|
||||
@@ -679,9 +681,9 @@ const tableColumns = computed(() =>
|
||||
}}</template
|
||||
><template #plan-mode="{ row }">{{
|
||||
row.mode === "SKU"
|
||||
? "按 SKU 合箱"
|
||||
? "部分包裹/箱合箱"
|
||||
: row.mode === "BOX"
|
||||
? "按箱合箱"
|
||||
? "整包裹/箱合箱"
|
||||
: row.mode
|
||||
}}</template
|
||||
><template #plan-status="{ row }">{{
|
||||
@@ -733,12 +735,8 @@ const tableColumns = computed(() =>
|
||||
row.destinationWarehouseCode || row.destinationWarehouseName || "—"
|
||||
}}</template>
|
||||
<template #carton-8="{ row }">{{
|
||||
row.relabelStatus === "COMPLETED"
|
||||
? "已完成"
|
||||
: row.relabelStatus === "BOUND"
|
||||
? "已绑定"
|
||||
: row.relabelStatus || "未绑定"
|
||||
}}</template>
|
||||
returnRelabelStatusLabel(row.relabelStatus)
|
||||
}}|实际完成 {{ row.relabelledQuantity ?? "—" }} 件|处理中占用 {{ row.relabelReservedQuantity ?? "—" }} 件</template>
|
||||
<template #carton-9="{ row }">{{ statusLabel(row.status) }}</template>
|
||||
<template #carton-12="{ row }">{{
|
||||
row.length ? `${row.length}×${row.width}×${row.height} cm` : "—"
|
||||
@@ -767,9 +765,9 @@ const tableColumns = computed(() =>
|
||||
:value="item.value" /></el-select></el-form-item
|
||||
><el-form-item label="海外仓"><el-select v-model="packingForm.overseasWarehouseId" style="width:100%" @change="loadPackingSources"><el-option v-for="warehouse in warehouseOptions" :key="warehouse.value" :label="warehouse.label" :value="warehouse.value" /></el-select></el-form-item><el-form-item label="合箱方式"
|
||||
><el-radio-group v-model="packingForm.mode" class="packing-mode" @change="changePackingMode"
|
||||
><el-radio-button label="BOX">按箱合箱</el-radio-button
|
||||
><el-radio-button label="BOX">整包裹/箱合箱</el-radio-button
|
||||
><el-radio-button label="SKU"
|
||||
>按 SKU 合箱</el-radio-button
|
||||
>部分包裹/箱合箱</el-radio-button
|
||||
></el-radio-group
|
||||
></el-form-item
|
||||
><el-form-item label="来源退货单筛选(可选,后台自动追溯)"
|
||||
@@ -779,7 +777,7 @@ const tableColumns = computed(() =>
|
||||
placeholder="默认加载该客户全部可用库存;多个退货单号请用英文逗号分隔"
|
||||
style="width: 100%"
|
||||
><template #suffix><span v-if="packingForm.returnNos.length" class="return-nos-count">已识别 {{ packingForm.returnNos.length }} 个</span></template></el-input></el-form-item
|
||||
><el-form-item v-if="packingForm.mode === 'SKU'" label="合箱 SKU"
|
||||
><el-form-item v-if="packingForm.mode === 'SKU'" label="来源包裹/箱和合箱数量"
|
||||
><div class="packing-sku-list-wrap">
|
||||
<el-input
|
||||
v-model="packingForm.skuSearchInput"
|
||||
@@ -791,19 +789,19 @@ const tableColumns = computed(() =>
|
||||
<div class="packing-sku-list-head"><span>选择 / SKU</span><span>可用数量</span><span>合箱数量</span></div>
|
||||
<div
|
||||
v-for="item in visiblePackingSkuRows"
|
||||
:key="item.skuCode"
|
||||
:key="item.key"
|
||||
class="packing-sku-row"
|
||||
>
|
||||
<el-checkbox
|
||||
:model-value="
|
||||
packingForm.skuQuantities[item.skuCode] !== undefined
|
||||
packingForm.skuQuantities[item.key] !== undefined
|
||||
"
|
||||
@change="togglePackingSku(item.skuCode, Boolean($event))"
|
||||
>{{ item.skuCode }}</el-checkbox
|
||||
@change="togglePackingSku(item.key, Boolean($event))"
|
||||
>{{ item.cartonMark }} · {{ item.skuCode }}</el-checkbox
|
||||
><span>可用 {{ item.availableQuantity }}</span
|
||||
><el-input-number
|
||||
v-if="packingForm.skuQuantities[item.skuCode] !== undefined"
|
||||
v-model="packingForm.skuQuantities[item.skuCode]"
|
||||
v-if="packingForm.skuQuantities[item.key] !== undefined"
|
||||
v-model="packingForm.skuQuantities[item.key]"
|
||||
:min="1"
|
||||
:max="item.availableQuantity"
|
||||
:controls="false"
|
||||
@@ -812,7 +810,7 @@ const tableColumns = computed(() =>
|
||||
</div>
|
||||
<el-empty
|
||||
v-if="!visiblePackingSkuRows.length"
|
||||
:description="packingForm.skuSearchInput.trim() || packingForm.returnNos.length ? '没有符合查询条件的可用 SKU' : '该客户暂无包裹签收的单 SKU 库存'"
|
||||
:description="packingForm.skuSearchInput.trim() || packingForm.returnNos.length ? '没有符合查询条件的可用 SKU' : '该客户暂无可用包裹/箱库存'"
|
||||
/></div></div></el-form-item
|
||||
><el-form-item v-if="packingForm.mode === 'BOX'" label="箱维度明细"
|
||||
><div class="packing-sku-list-wrap">
|
||||
@@ -821,14 +819,14 @@ const tableColumns = computed(() =>
|
||||
<div class="packing-box-list-head"><span>选择</span><span>箱唛</span><span>来源退货单</span><span>类型</span><span>SKU 明细</span></div>
|
||||
<div v-for="box in visiblePackingBoxRows" :key="box.cartonMark" class="packing-box-list-row">
|
||||
<el-checkbox-group v-model="packingForm.sourceCartonMarks"><el-checkbox :value="box.cartonMark" :aria-label="`选择箱唛 ${box.cartonMark}`"><span /></el-checkbox></el-checkbox-group>
|
||||
<strong>{{ box.cartonMark }}</strong><span>{{ box.returnNo || '—' }}</span><span>{{ box.unitType === 'PALLET' ? '托盘载体' : '箱子' }}</span><span class="packing-box-items"><span v-for="item in box.items" :key="item.skuCode">{{ item.skuCode }} × {{ item.quantity }}</span></span>
|
||||
<strong>{{ box.cartonMark }}</strong><span>{{ box.returnNo || '—' }}</span><span>{{ box.unitType === 'PALLET' ? '托盘载体' : box.unitType === 'PARCEL' ? '包裹' : '箱子' }}</span><span class="packing-box-items"><span v-for="item in box.items" :key="item.skuCode">{{ item.skuCode }} × {{ item.quantity }}</span></span>
|
||||
</div>
|
||||
<el-empty v-if="!visiblePackingBoxRows.length" :description="packingForm.skuSearchInput.trim() ? '没有包含该 SKU 的可用箱子' : '所选客户暂无箱维度库存'" />
|
||||
</div></div></el-form-item
|
||||
><el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
title="散 SKU 创建新箱,箱规由 PDA 填写;按箱合并由 PDA 扫描已有目标箱。提交即锁定来源库存,取消计划可释放。" /><el-form-item
|
||||
title="部分包裹/箱必须生成独立新箱并贴标签;整包裹/箱合并可使用已有目标箱。箱规由 PDA 填写。提交即锁定来源库存,取消计划可释放。" /><el-form-item
|
||||
label="合箱备注(可选)"
|
||||
><el-input
|
||||
v-model="packingForm.remark"
|
||||
@@ -854,7 +852,7 @@ const tableColumns = computed(() =>
|
||||
customerName(planDetail.customerId)
|
||||
}}</el-descriptions-item
|
||||
><el-descriptions-item label="合箱方式">{{
|
||||
planDetail.mode === "SKU" ? "按 SKU 合箱" : "按箱合箱"
|
||||
planDetail.mode === "SKU" ? "部分包裹/箱合箱" : "整包裹/箱合箱"
|
||||
}}</el-descriptions-item
|
||||
><el-descriptions-item label="状态">{{
|
||||
planDetail.status === 'COMPLETED' ? '已完成' : planDetail.status === 'READY' ? '待 PDA 作业' : planDetail.status === 'CANCELLED' ? '已取消' : '待审核'
|
||||
@@ -912,12 +910,8 @@ const tableColumns = computed(() =>
|
||||
detail.relabelNo || "—"
|
||||
}}</el-descriptions-item
|
||||
><el-descriptions-item label="换标状态">{{
|
||||
detail.relabelStatus === "COMPLETED"
|
||||
? "已完成"
|
||||
: detail.relabelStatus === "BOUND"
|
||||
? "已绑定"
|
||||
: detail.relabelStatus || "未绑定"
|
||||
}}</el-descriptions-item
|
||||
returnRelabelStatusLabel(detail.relabelStatus)
|
||||
}}|实际完成 {{ detail.relabelledQuantity ?? "—" }} 件|处理中占用 {{ detail.relabelReservedQuantity ?? "—" }} 件</el-descriptions-item
|
||||
><el-descriptions-item label="状态">{{
|
||||
statusLabel(detail.status)
|
||||
}}</el-descriptions-item
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { returnRelabelStatusLabel } from './returnRelabelProgress'
|
||||
describe('退货箱换标状态', () => {
|
||||
it('申请绑定不表示完成,部分和全部完成分别显示', () => {
|
||||
expect(returnRelabelStatusLabel('BOUND')).toBe('处理中')
|
||||
expect(returnRelabelStatusLabel('PROCESSING')).toBe('处理中')
|
||||
expect(returnRelabelStatusLabel('PARTIALLY_COMPLETED')).toBe('部分完成')
|
||||
expect(returnRelabelStatusLabel('COMPLETED')).toBe('全部完成')
|
||||
expect(returnRelabelStatusLabel()).toBe('—')
|
||||
expect(returnRelabelStatusLabel('UNLABELLED')).toBe('未换标')
|
||||
expect(returnRelabelStatusLabel('UNKNOWN')).toBe('—')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
export function returnRelabelStatusLabel(status?: string) {
|
||||
const labels: Record<string, string> = {
|
||||
UNLABELLED: '未换标', BOUND: '处理中', PROCESSING: '处理中',
|
||||
PARTIALLY_COMPLETED: '部分完成', COMPLETED: '全部完成',
|
||||
}
|
||||
return status ? labels[status] || '—' : '—'
|
||||
}
|
||||
Reference in New Issue
Block a user