Merge remote-tracking branch 'origin/release/test' into release/test
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; 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; 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)
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ export interface ReturnPackingPlanBox {
|
||||
|
||||
export interface ReturnPackingPlanPayload {
|
||||
returnNos: string[]
|
||||
mode: 'BOX' | 'SKU' | 'CUSTOMER_INSTRUCTION' | 'WAREHOUSE_RECOMMENDATION'
|
||||
mode: 'BOX' | 'SKU'
|
||||
boxes: ReturnPackingPlanBox[]
|
||||
remark?: string
|
||||
idempotencyKey: string
|
||||
|
||||
@@ -30,7 +30,8 @@ import LastMileOrdersView from './LastMileOrdersView.vue'
|
||||
const canAccess = vi.hoisted(() => vi.fn<(permissionCode: string) => boolean>(() => true))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canAccess }) }))
|
||||
vi.mock('@/shared/downloadCsv', () => ({ saveCsv: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
const routerPush = vi.hoisted(() => vi.fn())
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: routerPush }) }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('../api/lastMileOrders', () => ({
|
||||
listLastMileOrders: vi.fn(), listContainerPlanProgress: vi.fn(), getContainerPlanProgress: vi.fn(),
|
||||
@@ -1179,13 +1180,12 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
|
||||
it('keeps the return packing entry and excludes return orders from manual inbound generation', async () => {
|
||||
const wrapper = await mountView()
|
||||
await selectRows(wrapper, [selectedRow({ orderTypeCode: 'RETURN_RELABEL', currentStatusCode: 'IN_WAREHOUSE', sourceOrderNo: 'RETURN-1' })])
|
||||
await selectRows(wrapper, [selectedRow({ orderTypeCode: 'RETURN_RELABEL', currentStatusCode: 'INBOUND_COMPLETED', sourceOrderNo: 'RETURN-1' })])
|
||||
const actions = wrapper.get('.data-table__business-actions')
|
||||
expect(actions.text()).not.toContain('生成入库计划')
|
||||
const packing = actions.findAll('button').find(action => action.text().includes('创建退货合箱计划(1)'))!
|
||||
await packing.trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(candidate => candidate.props('title') === '退货合箱计划')!
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
expect(routerPush).toHaveBeenCalledWith({ name: 'overseas-return-inventory', query: expect.objectContaining({ packing: '1', returnNos: 'RETURN-1' }) })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,7 +21,7 @@ import { businessOrderLink } from '../businessOrderLink'
|
||||
import OrderReferenceText from '../components/OrderReferenceText.vue'
|
||||
import { reviewCargoDiscard } from '../api/lastMileOrders'
|
||||
import WarehouseTransferReviewDialog from '../components/WarehouseTransferReviewDialog.vue'
|
||||
import { confirmRelabelCompletion, createReturnPackingPlan, getRelabelReviewTask, returnRelabelForRework, type ReturnPackingPlanBox } from '../api/relabelOrders'
|
||||
import { confirmRelabelCompletion, getRelabelReviewTask, returnRelabelForRework } from '../api/relabelOrders'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { lastMileStatusLabel, packageDifferenceText, toLastMileOrderListItem } from '../lastMilePresentation'
|
||||
@@ -49,34 +49,20 @@ const containerDetail = ref<ContainerPlanProgressDetailDto | null>(null)
|
||||
const transferReviewOpen = ref(false)
|
||||
const transferReviewTarget = ref<LastMileOrderListItem | null>(null)
|
||||
const selectedRows = ref<LastMileOrderListItem[]>([])
|
||||
const packingPlanOpen = ref(false)
|
||||
const packingPlanLoading = ref(false)
|
||||
const packingPlanMode = ref<'CUSTOMER_INSTRUCTION' | 'WAREHOUSE_RECOMMENDATION'>('CUSTOMER_INSTRUCTION')
|
||||
const packingPlanRemark = ref('')
|
||||
const packingPlanBoxes = ref<ReturnPackingPlanBox[]>([])
|
||||
const selectedReturnRows = computed(() => selectedRows.value.filter((row) => row.orderTypeCode === 'RETURN_RELABEL' && ['COMPLETED', 'RECEIVED', 'IN_WAREHOUSE', '已完成入库'].includes(String(row.currentStatusCode || row.status || ''))))
|
||||
const selectedReturnRows = computed(() => selectedRows.value.filter((row) => row.orderTypeCode === 'RETURN_RELABEL' && ['INBOUND_COMPLETED', 'COMPLETED', 'RECEIVED', 'IN_WAREHOUSE', '已完成入库'].includes(String(row.currentStatusCode || row.status || ''))))
|
||||
function openReturnPackingPlan() {
|
||||
if (!selectedReturnRows.value.length) { ElMessage.warning('请勾选已入库的退货订单'); return }
|
||||
packingPlanMode.value = 'CUSTOMER_INSTRUCTION'; packingPlanRemark.value = ''
|
||||
packingPlanBoxes.value = [{ boxNo: 1, items: [{ skuCode: '', quantity: 0 }] }]
|
||||
packingPlanOpen.value = true
|
||||
const selected = selectedReturnRows.value
|
||||
if (!selected.length) { ElMessage.warning('请勾选已入库的退货订单'); return }
|
||||
if (new Set(selected.map(row => row.customerId)).size !== 1 || new Set(selected.map(row => row.overseasWarehouseId)).size !== 1) {
|
||||
ElMessage.warning('合箱请选择同一客户、同一海外仓的退货订单'); return
|
||||
}
|
||||
void router.push({ name: 'overseas-return-inventory', query: {
|
||||
packing: '1', customerId: selected[0]!.customerId, overseasWarehouseId: selected[0]!.overseasWarehouseId,
|
||||
returnNos: selected.map(row => row.sourceOrderNo || row.orderNo).join(','),
|
||||
} })
|
||||
}
|
||||
function addReturnPackingBox() { packingPlanBoxes.value.push({ boxNo: packingPlanBoxes.value.length + 1, items: [{ skuCode: '', quantity: 0 }] }) }
|
||||
function removeReturnPackingBox(index: number) { if (packingPlanBoxes.value.length > 1) packingPlanBoxes.value.splice(index, 1) }
|
||||
function addReturnPackingItem(box: ReturnPackingPlanBox) { box.items.push({ skuCode: '', quantity: 0 }) }
|
||||
function removeReturnPackingItem(box: ReturnPackingPlanBox, index: number) { if (box.items.length > 1) box.items.splice(index, 1) }
|
||||
async function submitReturnPackingPlan() {
|
||||
const boxes = packingPlanBoxes.value.map((box) => ({ ...box, items: box.items.filter((item) => item.skuCode.trim() && Number(item.quantity) > 0) })).filter((box) => box.items.length)
|
||||
if (!boxes.length) { ElMessage.warning('至少配置一个目标箱'); return }
|
||||
packingPlanLoading.value = true
|
||||
try {
|
||||
const result = await createReturnPackingPlan({ returnNos: selectedReturnRows.value.map((row) => String(row.sourceOrderNo || row.orderNo)).filter(Boolean), mode: packingPlanMode.value, boxes, remark: packingPlanRemark.value.trim() || undefined, idempotencyKey: globalThis.crypto?.randomUUID?.() || `return-packing-${Date.now()}` })
|
||||
ElMessage.success(`合箱计划 ${result.planNo} 已推送 PDA`); packingPlanOpen.value = false; selectedRows.value = []
|
||||
} catch (error) { ElMessage.error(getApiErrorDetails(error, '合箱计划推送失败,请重试').message) }
|
||||
finally { packingPlanLoading.value = false }
|
||||
}
|
||||
const actionLoading = ref(false)
|
||||
const exportLoading = ref(false)
|
||||
const actionLoading = ref(false)
|
||||
async function exportOrders() { exportLoading.value = true; try { await submitExport('LAST_MILE_ORDER_LIST', '尾程订单', orderQuery()) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '尾程订单导出失败,请重试').message) } finally { exportLoading.value = false } }
|
||||
const actionError = ref<ApiErrorDetails | null>(null)
|
||||
const actionTarget = ref<LastMileOrderListItem | null>(null)
|
||||
@@ -1132,7 +1118,6 @@ onMounted(() => { void load() })
|
||||
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
|
||||
</DataTable>
|
||||
</ListPageLayout></AdminPage>
|
||||
<AppDialog class="admin-density-dialog" v-model="packingPlanOpen" title="退货合箱计划" width="min(980px, calc(100vw - 28px))" confirm-text="推送 PDA" :confirm-loading="packingPlanLoading" @confirm="submitReturnPackingPlan"><el-radio-group v-model="packingPlanMode"><el-radio-button label="CUSTOMER_INSTRUCTION">客户合箱指令</el-radio-button><el-radio-button label="WAREHOUSE_RECOMMENDATION">仓库自主合箱</el-radio-button></el-radio-group><div v-for="(box, index) in packingPlanBoxes" :key="index" class="packing-box"><div class="packing-box-head"><strong>第 {{ index + 1 }} 箱</strong><el-button text type="danger" @click="removeReturnPackingBox(index)">删除箱</el-button></div><div v-for="(item, itemIndex) in box.items" :key="itemIndex" class="packing-item"><el-input v-model="item.skuCode" placeholder="SKU" /><el-input-number v-model="item.quantity" :min="0" :precision="0" placeholder="数量" /><el-button text type="danger" @click="removeReturnPackingItem(box, itemIndex)">移除</el-button></div><el-button text type="primary" @click="addReturnPackingItem(box)">+ 添加 SKU</el-button></div><el-button plain type="primary" @click="addReturnPackingBox">+ 添加箱</el-button><el-input v-model="packingPlanRemark" type="textarea" :rows="2" placeholder="合箱备注(选填)" /></AppDialog>
|
||||
<WarehouseTransferReviewDialog v-if="transferReviewTarget" v-model="transferReviewOpen" :customer-id="transferReviewTarget.customerId" :customer-name="transferReviewTarget.owner" :transfer-no="transferReviewTarget.orderNo" @reviewed="load()" />
|
||||
<AppDialog class="admin-density-dialog" v-model="transferOpen" title="转仓" confirm-text="确认转仓" :loading="transferWarehouseLoading" :confirm-loading="actionLoading" :confirm-disabled="!transferForm.warehouseId || Boolean(transferWarehouseError)" @confirm="submitTransfer">
|
||||
<LoadErrorState v-if="transferWarehouseError" title="FBA 仓候选加载失败" :message="transferWarehouseError.message" compact @retry="loadTransferWarehouseOptions">
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import ElementPlus, { ElCheckbox, ElInputNumber, ElSelect } from 'element-plus'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import OutboundsView from './OutboundsView.vue'
|
||||
const api = vi.hoisted(() => ({ create: vi.fn(), stock: vi.fn(), cartons: vi.fn(), detail: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [{ id: 'C1', tenantName: '客户一' }], total: 1, pages: 1 }) }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn().mockResolvedValue({ records: [{ id: 'W1', warehouseName: '洛杉矶仓', warehouseCode: 'US-LAX-01', warehouseType: 'OVERSEAS', status: 'ACTIVE' }] }) }))
|
||||
vi.mock('../api/outbounds', () => ({ listOutbounds: vi.fn().mockResolvedValue({ records: [], total: 0 }), listOneClickOutboundCandidates: vi.fn().mockResolvedValue([]), listReturnOutboundCandidates: vi.fn().mockResolvedValue([]), createReturnOutbound: api.create }))
|
||||
vi.mock('../api/returnInventory', () => ({ listReturnStock: api.stock, listReturnCartons: api.cartons, getReturnCarton: api.detail }))
|
||||
const sources = [
|
||||
{ cartonMark: 'BOX-A', sourceUnitType: 'BOX', sourceReturnNo: 'RT-1', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 5, availableQuantity: 5 }] },
|
||||
{ cartonMark: 'BOX-B', sourceUnitType: 'BOX', sourceReturnNo: 'RT-1', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 5, availableQuantity: 5 }] },
|
||||
{ cartonMark: 'PARCEL', sourceUnitType: 'PARCEL', sourceReturnNo: 'RT-1', status: 'AVAILABLE', items: [{ skuCode: 'SKU-B', quantity: 6, availableQuantity: 6 }] },
|
||||
{ cartonMark: 'MIXED', sourceUnitType: 'BOX', sourceReturnNo: 'RT-1', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 3 }, { skuCode: 'SKU-B', quantity: 2 }] },
|
||||
{ cartonMark: 'OTHER-ORDER', sourceUnitType: 'BOX', sourceReturnNo: 'RT-2', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 100 }] },
|
||||
]
|
||||
let wrapper: VueWrapper
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined, removeItem: () => undefined })
|
||||
api.stock.mockResolvedValue({ records: [{ skuCode: 'SKU-A', availableQuantity: 113 }, { skuCode: 'SKU-B', availableQuantity: 8 }], total: 2 })
|
||||
api.cartons.mockResolvedValue({ records: sources, total: sources.length })
|
||||
api.detail.mockImplementation(async mark => sources.find(row => row.cartonMark === mark))
|
||||
api.create.mockResolvedValue({})
|
||||
})
|
||||
afterEach(() => { wrapper?.unmount(); vi.unstubAllGlobals() })
|
||||
async function open(sku: string) {
|
||||
wrapper = mount(OutboundsView, { global: { plugins: [ElementPlus], stubs: { AppDialog: { props: ['modelValue'], emits: ['confirm'], template: '<section v-if="modelValue"><slot /><button data-test="submit" @click="$emit(\'confirm\')">提交</button></section>' } } } })
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text().includes('新建出库单'))!.trigger('click'); await flushPromises()
|
||||
const type = wrapper.findComponent({ name: 'ElRadioGroup' }); type.vm.$emit('update:modelValue', 'RETURN_OUTBOUND'); type.vm.$emit('change', 'RETURN_OUTBOUND'); await flushPromises()
|
||||
const customer = wrapper.findAllComponents(ElSelect).find(select => select.props('placeholder') === '请选择客户')!
|
||||
customer.vm.$emit('update:modelValue', 'C1'); customer.vm.$emit('change', 'C1'); await flushPromises()
|
||||
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[]) {
|
||||
for (const mark of marks) right().findAllComponents(ElCheckbox).find(check => check.element.closest('tr')?.textContent?.includes(mark === 'PARCEL' ? '包裹散件(按 SKU 拣货)' : mark))!.vm.$emit('change', true)
|
||||
await flushPromises()
|
||||
}
|
||||
describe('single SKU outbound source and piece quantity', () => {
|
||||
it('selects packed single-SKU boxes, scopes quantities to the return order and excludes mixed boxes', async () => {
|
||||
await open('SKU-A')
|
||||
expect(right().props('data').map((row: { cartonMark: string }) => row.cartonMark)).toEqual(['BOX-A', 'BOX-B'])
|
||||
expect(wrapper.text()).not.toContain('OTHER-ORDER')
|
||||
await selectMarks(['BOX-A', 'BOX-B'])
|
||||
expect(wrapper.findComponent(ElInputNumber).props('modelValue')).toBe(10)
|
||||
await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises()
|
||||
expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ skuCode: 'SKU-A', quantity: 10, cartonMarks: ['BOX-A', 'BOX-B'] }))
|
||||
})
|
||||
it('submits six pieces from two five-piece boxes', async () => {
|
||||
await open('SKU-A'); await selectMarks(['BOX-A', 'BOX-B'])
|
||||
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', 6); await flushPromises()
|
||||
await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises()
|
||||
expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ quantity: 6 }))
|
||||
})
|
||||
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({ 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,10]])('clamps input %s to the allowed bound %s', async (quantity, expected) => {
|
||||
await open('SKU-A'); await selectMarks(['BOX-A', 'BOX-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({quantity: expected}))
|
||||
})
|
||||
it.each([1.5])('blocks invalid quantity %s before creating an order', async quantity => {
|
||||
await open('SKU-A'); await selectMarks(['BOX-A', 'BOX-B'])
|
||||
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', quantity); await flushPromises()
|
||||
await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises()
|
||||
expect(api.create).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { Plus, Search } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -38,6 +38,7 @@ const returnStockRows = ref<ReturnStockRow[]>([])
|
||||
const returnCartonRows = ref<ReturnCartonRow[]>([])
|
||||
const selectedReturnSkus = ref<string[]>([])
|
||||
const returnPickMode = ref<'BOX' | 'SKU'>('BOX')
|
||||
const returnQuantity = ref<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')
|
||||
@@ -64,7 +65,15 @@ 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.filter((item) => Number(returnPickMode.value === 'SKU' ? item.looseAvailableQuantity || 0 : item.availableQuantity || 0) > 0))
|
||||
const selectedReturnStock = computed(() => returnStockRows.value.map((item) => {
|
||||
if (returnPickMode.value !== 'SKU') return item
|
||||
const quantity = selectedReturnCartons.value.reduce((sum, carton) => {
|
||||
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 selectedReturnCartons = computed(() => returnCartonRows.value.filter((item) => item.status === 'AVAILABLE'))
|
||||
const returnOrderOptions = computed(() => [...new Set([
|
||||
...returnCandidates.value.flatMap(item => (item.returnNo || '').split(',')),
|
||||
@@ -76,11 +85,11 @@ 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') {
|
||||
const detail = returnCartonDetails.value[item.cartonMark]
|
||||
if (!selectedReturnSkus.value.length || item.sourceUnitType !== 'PARCEL' || !returnCandidateByMark.value[item.cartonMark]
|
||||
|| detail?.items?.length !== 1 || detail.items[0]?.skuCode !== selectedReturnSkus.value[0]) return false
|
||||
if (!selectedReturnSkus.value.length || detail?.items?.length !== 1 || detail.items[0]?.skuCode !== selectedReturnSkus.value[0]) return false
|
||||
}
|
||||
const detailSkus = returnCartonDetails.value[item.cartonMark]?.items?.map((detail) => detail.skuCode).join(',') || ''
|
||||
return matchesAnyToken(`${item.cartonMark},${item.fbaCartonMark || ''},${detailSkus}`, returnCartonQuery.value)
|
||||
@@ -88,6 +97,11 @@ const filteredReturnCartons = computed(() => selectedReturnCartons.value.filter(
|
||||
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[])
|
||||
const selectedReturnQuantity = computed(() => effectiveReturnMarks.value.reduce((sum, mark) => {
|
||||
const line = returnCartonDetails.value[mark]?.items.find(item => item.skuCode === selectedReturnSkus.value[0])
|
||||
return sum + Number(line?.availableQuantity ?? line?.quantity ?? 0)
|
||||
}, 0))
|
||||
watch(selectedReturnQuantity, quantity => { returnQuantity.value = quantity || undefined })
|
||||
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 pagedDropshipCandidates = computed(() => filteredDropshipCandidates.value.slice((dropshipPage.value - 1) * dropshipPageSize.value, dropshipPage.value * dropshipPageSize.value))
|
||||
@@ -203,13 +217,16 @@ async function submit() {
|
||||
if (!recipient.value.trim()) { 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' && (!Number.isSafeInteger(returnQuantity.value) || Number(returnQuantity.value) <= 0 || Number(returnQuantity.value) > selectedReturnQuantity.value)) {
|
||||
ElMessage.warning(`请填写 1-${selectedReturnQuantity.value} 件的出库数量`); return
|
||||
}
|
||||
if (!selectedReturnMarks.value.length) {
|
||||
ElMessage.warning(returnPickMode.value === 'SKU'
|
||||
? filteredReturnCartons.value.length ? '请在右侧选择该 SKU 的散件来源记录' : '该退货单没有此 SKU 可出库的散件库存;已装箱货物请切换箱/托出库'
|
||||
? filteredReturnCartons.value.length ? '请在右侧选择该 SKU 的来源箱或散件记录' : '该退货单没有此 SKU 的可用单 SKU 箱或散件库存;混合箱请先拆箱'
|
||||
: '请选择出库箱号或托盘')
|
||||
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, 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, 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()}` })
|
||||
ElMessage.success('退货出库单和待派送单已创建,作业已推送 PDA')
|
||||
} else {
|
||||
const selected = dropshipCandidates.value.filter(item => selectedDropshipIds.value.includes(dropshipCandidateKey(item)))
|
||||
@@ -329,21 +346,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>
|
||||
<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 扫箱唛按计划件数拣货,剩余数量保留在原箱。</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>
|
||||
<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>
|
||||
<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="returnPickMode === 'SKU' ? '散件可用数量' : '可用数量'" width="110" align="right"><template #default="{ row }">{{ returnPickMode === 'SKU' ? row.looseAvailableQuantity : row.availableQuantity }}</template></el-table-column></el-table>
|
||||
<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>
|
||||
</section>
|
||||
<section class="return-list-card">
|
||||
<div class="list-title"><span>箱号列表</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>
|
||||
<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 prop="cartonMark" label="箱号" min-width="190" show-overflow-tooltip /><el-table-column label="托盘" min-width="150"><template #default="{ row }">{{ 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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
} from '../api/relabelOrders'
|
||||
import RelabelOrdersView from './RelabelOrdersView.vue'
|
||||
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
const routerPush = vi.hoisted(() => vi.fn())
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: routerPush }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('../api/relabelOrders', () => ({
|
||||
@@ -145,7 +146,7 @@ describe('RelabelOrdersView', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('keeps multi-selection connected to the packing-plan dialog in the integrated list', async () => {
|
||||
it('opens the inventory source selector instead of treating relabel package counts as SKU quantities', async () => {
|
||||
vi.mocked(listRelabelOrders).mockResolvedValueOnce({
|
||||
records: [{ id: '1', relabelNo: 'RL-1', customerOrderNo: 'RETURN-1', skuCodes: 'SKU-TEST', totalPackageCount: 2, status: 'PENDING_PROCESSING' }],
|
||||
page: 1, size: 10, total: 1, pages: 1,
|
||||
@@ -158,8 +159,6 @@ describe('RelabelOrdersView', () => {
|
||||
const createPlan = wrapper.get('.data-table__business-actions').findAll('button').find(button => button.text().includes('创建合箱计划(1)'))!
|
||||
await createPlan.trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(candidate => candidate.props('title') === '配置退货合箱计划')!
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
expect(dialog.findAllComponents({ name: 'ElInput' }).some(input => input.props('modelValue') === 'SKU-TEST')).toBe(true)
|
||||
expect(routerPush).toHaveBeenCalledWith({ name: 'overseas-return-inventory', query: { packing: '1' } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { changeRelabelOrderStatus, createRelabelOrder, createReturnPackingPlan, listRelabelOrders, listRelabelWarehouseOptions, type RelabelWarehouseOptionDto, type ReturnPackingPlanBox } from '../api/relabelOrders'
|
||||
import { changeRelabelOrderStatus, createRelabelOrder, listRelabelOrders, listRelabelWarehouseOptions, type RelabelWarehouseOptionDto } from '../api/relabelOrders'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { formatOverseasDateTime, relabelBusinessStatusLabel } from '../lastMilePresentation'
|
||||
import type { RelabelOrderListItem } from '../types'
|
||||
@@ -59,11 +59,6 @@ const editSourceStatus = ref('')
|
||||
const form = reactive({ orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined as number | undefined, budgetTotal: undefined as number | undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined as number | undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
|
||||
const createIdempotencyKey = ref('')
|
||||
const selectedRows = ref<RelabelOrderListItem[]>([])
|
||||
const packingDialogOpen = ref(false)
|
||||
const packingSubmitting = ref(false)
|
||||
const packingMode = ref<'CUSTOMER_INSTRUCTION' | 'WAREHOUSE_RECOMMENDATION'>('CUSTOMER_INSTRUCTION')
|
||||
const packingRemark = ref('')
|
||||
const packingBoxes = ref<ReturnPackingPlanBox[]>([])
|
||||
const allowedStatusOptions = computed(() => {
|
||||
if (editSourceStatus.value === 'PENDING_REVIEW') return ['PENDING_PROCESSING', 'REJECTED']
|
||||
if (editSourceStatus.value === 'PENDING_PROCESSING') return ['PROCESSING', 'REJECTED', 'CANCELLED']
|
||||
@@ -72,38 +67,11 @@ const allowedStatusOptions = computed(() => {
|
||||
})
|
||||
|
||||
function optionalNumber(value: unknown) { const number = Number(value); return value === '' || value == null || !Number.isFinite(number) ? undefined : number }
|
||||
function selectedSkus() {
|
||||
const values = new Map<string, number>()
|
||||
for (const row of selectedRows.value) {
|
||||
const skus = String(row.sku || '').split(',').map((value) => value.trim()).filter(Boolean)
|
||||
for (const sku of skus.length ? skus : ['未维护 SKU']) values.set(sku, (values.get(sku) || 0) + Math.max(0, Number(row.cartons || 0)))
|
||||
}
|
||||
return [...values.entries()].map(([skuCode, quantity]) => ({ skuCode, quantity }))
|
||||
}
|
||||
function openPackingPlan() {
|
||||
if (!selectedRows.value.length) { ElMessage.warning('请先勾选已入库退货订单'); return }
|
||||
packingMode.value = 'CUSTOMER_INSTRUCTION'
|
||||
packingRemark.value = ''
|
||||
packingBoxes.value = selectedSkus().map((item, index) => ({ boxNo: index + 1, items: [{ ...item }] }))
|
||||
packingDialogOpen.value = true
|
||||
}
|
||||
function addPackingBox() { packingBoxes.value.push({ boxNo: packingBoxes.value.length + 1, items: [{ skuCode: '', quantity: 0 }] }) }
|
||||
function removePackingBox(index: number) { if (packingBoxes.value.length > 1) packingBoxes.value.splice(index, 1) }
|
||||
function addBoxItem(box: ReturnPackingPlanBox) { box.items.push({ skuCode: '', quantity: 0 }) }
|
||||
function removeBoxItem(box: ReturnPackingPlanBox, index: number) { if (box.items.length > 1) box.items.splice(index, 1) }
|
||||
async function submitPackingPlan() {
|
||||
if (packingSubmitting.value) return
|
||||
const boxes = packingBoxes.value.map((box, index) => ({ ...box, boxNo: index + 1, items: box.items.filter((item) => item.skuCode.trim() && Number(item.quantity) > 0).map((item) => ({ skuCode: item.skuCode.trim(), quantity: Number(item.quantity) })) })).filter((box) => box.items.length)
|
||||
if (!boxes.length) { ElMessage.warning('至少配置一个箱和一条 SKU 数量'); return }
|
||||
packingSubmitting.value = true
|
||||
try {
|
||||
const result = await createReturnPackingPlan({ returnNos: selectedRows.value.map((row) => row.relatedOrderNo).filter((value) => value && value !== '—'), mode: packingMode.value, boxes, remark: packingRemark.value.trim() || undefined, idempotencyKey: globalThis.crypto?.randomUUID?.() || `packing-${Date.now()}` })
|
||||
ElMessage.success(`合箱计划 ${result.planNo} 已推送 PDA`)
|
||||
packingDialogOpen.value = false
|
||||
selectedRows.value = []
|
||||
await load()
|
||||
} catch (error) { ElMessage.error(getApiErrorDetails(error, '合箱计划推送失败,请重试').message) }
|
||||
finally { packingSubmitting.value = false }
|
||||
if (!selectedRows.value.length) { ElMessage.warning('请先勾选订单'); return }
|
||||
const customerIds = [...new Set(selectedRows.value.map(row => row.customerId).filter(Boolean))]
|
||||
if (customerIds.length > 1) { ElMessage.warning('合箱请选择同一客户的退货库存'); return }
|
||||
void router.push({ name: 'overseas-return-inventory', query: { packing: '1', ...(customerIds[0] ? { customerId: customerIds[0] } : {}) } })
|
||||
}
|
||||
function openForm(mode: 'create' | 'edit', source?: object) {
|
||||
const row = source as RelabelOrderListItem | undefined
|
||||
@@ -243,15 +211,7 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
|
||||
<template #pagination-left>共 {{ total }} 条换标单</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
<AppDialog class="admin-density-dialog" v-model="packingDialogOpen" title="配置退货合箱计划" width="min(980px, calc(100vw - 28px))" confirm-text="推送 PDA 合箱计划" :confirm-loading="packingSubmitting" @confirm="submitPackingPlan">
|
||||
<div class="packing-plan-dialog">
|
||||
<div class="packing-plan-summary"><span>已选订单:{{ selectedRows.length }} 个</span><span>SKU:{{ selectedSkus().length }} 种</span><span>默认:单 SKU 一箱,可调整为混箱</span></div>
|
||||
<el-radio-group v-model="packingMode" class="packing-mode"><el-radio-button label="CUSTOMER_INSTRUCTION">客户合箱指令</el-radio-button><el-radio-button label="WAREHOUSE_RECOMMENDATION">仓库自主合箱</el-radio-button></el-radio-group>
|
||||
<div v-for="(box, boxIndex) in packingBoxes" :key="boxIndex" class="packing-box"><div class="packing-box-head"><strong>第 {{ boxIndex + 1 }} 箱</strong><el-button text type="danger" :disabled="packingBoxes.length <= 1" @click="removePackingBox(boxIndex)">删除箱</el-button></div><div v-for="(item, itemIndex) in box.items" :key="itemIndex" class="packing-item"><el-input v-model="item.skuCode" placeholder="SKU" /><el-input-number v-model="item.quantity" :min="0" :precision="0" controls-position="right" placeholder="数量" /><el-button text type="danger" :disabled="box.items.length <= 1" @click="removeBoxItem(box, itemIndex)">移除</el-button></div><el-button text type="primary" @click="addBoxItem(box)">+ 添加 SKU</el-button><div class="packing-dimensions"><el-input-number v-model="box.length" :min="0" :precision="2" placeholder="长 cm" /><el-input-number v-model="box.width" :min="0" :precision="2" placeholder="宽 cm" /><el-input-number v-model="box.height" :min="0" :precision="2" placeholder="高 cm" /><el-input-number v-model="box.weight" :min="0" :precision="2" placeholder="重量 kg" /></div></div>
|
||||
<el-button plain type="primary" @click="addPackingBox">+ 添加箱</el-button>
|
||||
<el-input v-model="packingRemark" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="合箱备注(选填)" />
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog class="admin-density-dialog" v-model="dialogOpen" :title="dialogMode === 'create' ? '新增换标单' : '审核换标单'" width="min(760px, calc(100vw - 28px))" :confirm-text="dialogMode === 'create' ? '保存换标单' : '提交审核'" :confirm-loading="submitting" @confirm="submitForm">
|
||||
<el-form class="density-form" label-position="top"><h3>订单信息</h3><el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="换标单号"><el-input :model-value="dialogMode === 'create' ? '系统自动生成' : form.orderNo || '—'" readonly /></el-form-item></el-col>
|
||||
|
||||
@@ -8,6 +8,8 @@ import { getReturnCarton, listReturnCartons, listReturnInventoryMovements, listR
|
||||
import { listReturnPackingPlans } from '../api/relabelOrders'
|
||||
import ReturnInventoryManagementView from './ReturnInventoryManagementView.vue'
|
||||
import { listPlatformTenants } from '@/modules/iam'
|
||||
const routeQuery = vi.hoisted(() => ({} as Record<string, string>))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ name: 'overseas-return-inventory', query: routeQuery }) }))
|
||||
|
||||
vi.mock('@/modules/iam', () => ({
|
||||
listPlatformTenants: vi.fn().mockResolvedValue({ records: [{ id: 'customer-1', tenantName: '测试客户', status: 'ACTIVE' }], page: 1, size: 200, total: 1, pages: 1 }),
|
||||
@@ -36,6 +38,7 @@ async function mountView(mode: 'inventory' | 'cartons') {
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.keys(routeQuery).forEach(key => delete routeQuery[key])
|
||||
localStorage.clear()
|
||||
vi.mocked(listReturnStock).mockResolvedValue({ records: [stock], page: 1, size: 20, total: 45, pages: 3 })
|
||||
vi.mocked(listReturnCartons).mockResolvedValue({ records: [carton], page: 1, size: 20, total: 1, pages: 1 })
|
||||
@@ -53,6 +56,15 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('ReturnInventoryManagementView shared list', () => {
|
||||
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 })
|
||||
const wrapper = await mountView('inventory')
|
||||
expect(listReturnPackingSources).toHaveBeenCalledWith(expect.objectContaining({ customerId: 'customer-1', overseasWarehouseId: 'warehouse-1' }))
|
||||
const form = wrapper.findAllComponents({ name: 'ElForm' }).find(form => form.classes().includes('packing-form'))!
|
||||
expect(form.findAllComponents({ name: 'ElInput' }).some(input => input.props('modelValue') === 'RETURN-1')).toBe(true)
|
||||
expect(document.body.textContent).toContain('RETURN-BOX-1')
|
||||
})
|
||||
it('includes customers from later server pages in the packing customer selector', async () => {
|
||||
vi.mocked(listPlatformTenants)
|
||||
.mockResolvedValueOnce({ records: [{ id: 'customer-1', tenantName: '柏悦物流demo2', status: 'ACTIVE' }], page: 1, size: 200, total: 201, pages: 2 } as Awaited<ReturnType<typeof listPlatformTenants>>)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import "@/styles/adminDensity.css";
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import dayjs from "dayjs";
|
||||
import { useRoute } from "vue-router";
|
||||
import { Search, Plus } from "@lucide/vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import {
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
type ReturnPackingPlanDto,
|
||||
} from "../api/relabelOrders";
|
||||
const props = defineProps<{ mode: "inventory" | "cartons" }>();
|
||||
const route = useRoute();
|
||||
const loading = ref(false),
|
||||
total = ref(0),
|
||||
detailOpen = ref(false);
|
||||
@@ -385,10 +387,22 @@ watch(() => packingForm.returnNosInput, (value) => {
|
||||
packingForm.returnNos = [...new Set(value.split(",").map(item => item.trim()).filter(Boolean))];
|
||||
});
|
||||
watch(() => packingForm.returnNos, () => { packingForm.sourceCartonMarks = []; packingForm.skuQuantities = {}; updatePackingScope(); }, { deep: true });
|
||||
onMounted(() => {
|
||||
watch(() => [route.name, route.query.packing, route.query.customerId, route.query.overseasWarehouseId, route.query.returnNos], async () => {
|
||||
if (!['overseas-return-inventory', 'overseas-return-cartons'].includes(String(route.name))) return;
|
||||
const value = (key: string) => typeof route.query[key] === 'string' ? String(route.query[key]) : '';
|
||||
query.customerId = value('customerId');
|
||||
query.overseasWarehouseId = value('overseasWarehouseId');
|
||||
void load();
|
||||
void loadOptions();
|
||||
});
|
||||
if (value('packing') === '1') {
|
||||
packingForm.customerId = query.customerId;
|
||||
packingForm.overseasWarehouseId = query.overseasWarehouseId;
|
||||
packingForm.mode = 'BOX';
|
||||
packingDialogOpen.value = true;
|
||||
await loadPackingSources();
|
||||
packingForm.returnNosInput = value('returnNos');
|
||||
}
|
||||
}, { immediate: true });
|
||||
onMounted(() => { void loadOptions(); });
|
||||
const stockColumns: DataTableColumn[] = [
|
||||
{ key: "stock-0", label: "客户", minWidth: 180, slotName: "stock-0" },
|
||||
{ key: "stock-1", label: "海外仓", minWidth: 180, slotName: "stock-1" },
|
||||
|
||||
Reference in New Issue
Block a user