diff --git a/src/modules/overseas-warehouse/api/relabelOrders.ts b/src/modules/overseas-warehouse/api/relabelOrders.ts index 0b7b24ab..a93e0cf0 100644 --- a/src/modules/overseas-warehouse/api/relabelOrders.ts +++ b/src/modules/overseas-warehouse/api/relabelOrders.ts @@ -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 diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts index 94db1bc1..2cd7ef0d 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts @@ -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' }) }) }) }) diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue index 0f1231e6..f73473c0 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue @@ -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(null) const transferReviewOpen = ref(false) const transferReviewTarget = ref(null) const selectedRows = ref([]) -const packingPlanOpen = ref(false) -const packingPlanLoading = ref(false) -const packingPlanMode = ref<'CUSTOMER_INSTRUCTION' | 'WAREHOUSE_RECOMMENDATION'>('CUSTOMER_INSTRUCTION') -const packingPlanRemark = ref('') -const packingPlanBoxes = ref([]) -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(null) const actionTarget = ref(null) @@ -1132,7 +1118,6 @@ onMounted(() => { void load() }) - 客户合箱指令仓库自主合箱
第 {{ index + 1 }} 箱删除箱
移除
+ 添加 SKU
+ 添加箱
diff --git a/src/modules/overseas-warehouse/pages/RelabelOrdersView.test.ts b/src/modules/overseas-warehouse/pages/RelabelOrdersView.test.ts index 125962cd..74a225ac 100644 --- a/src/modules/overseas-warehouse/pages/RelabelOrdersView.test.ts +++ b/src/modules/overseas-warehouse/pages/RelabelOrdersView.test.ts @@ -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' } }) }) }) diff --git a/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue b/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue index 6f7a247b..845f4d8c 100644 --- a/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue @@ -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([]) -const packingDialogOpen = ref(false) -const packingSubmitting = ref(false) -const packingMode = ref<'CUSTOMER_INSTRUCTION' | 'WAREHOUSE_RECOMMENDATION'>('CUSTOMER_INSTRUCTION') -const packingRemark = ref('') -const packingBoxes = ref([]) 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() - 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 = '' }) - -
-
已选订单:{{ selectedRows.length }} 个SKU:{{ selectedSkus().length }} 种默认:单 SKU 一箱,可调整为混箱
- 客户合箱指令仓库自主合箱 -
第 {{ boxIndex + 1 }} 箱删除箱
移除
+ 添加 SKU
- + 添加箱 - -
-
+

订单信息

diff --git a/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.test.ts b/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.test.ts index d317cca9..ccb7ddd6 100644 --- a/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.test.ts +++ b/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.test.ts @@ -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)) +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>) diff --git a/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue b/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue index 861b35a8..75969f9b 100644 --- a/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue +++ b/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue @@ -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" },