From 0830d7a3a369bd8ea60b0462d16d4be58a80bea0 Mon Sep 17 00:00:00 2001
From: liuyihe <2513762445@qq.com>
Date: Wed, 14 Oct 2026 11:43:32 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
packing-acceptance.local.html | 16 ++++++++
.../api/cargoDiscards.test.ts | 15 +++++++
.../overseas-warehouse/api/cargoDiscards.ts | 7 +++-
.../overseas-warehouse/api/lastMileOrders.ts | 2 +
.../overseas-warehouse/api/relabelOrders.ts | 4 ++
.../pages/CargoDiscardsView.vue | 28 ++++++++++---
.../pages/LastMileOrderDetailView.vue | 22 +++++++++++
.../pages/OutboundsView.selection.test.ts | 39 ++++++++++++++++++-
.../pages/OutboundsView.vue | 17 +++++++-
.../pages/ReturnInventoryManagementView.vue | 39 +++++++++++++++++--
10 files changed, 177 insertions(+), 12 deletions(-)
create mode 100644 packing-acceptance.local.html
create mode 100644 src/modules/overseas-warehouse/api/cargoDiscards.test.ts
diff --git a/packing-acceptance.local.html b/packing-acceptance.local.html
new file mode 100644
index 00000000..cbb4fece
--- /dev/null
+++ b/packing-acceptance.local.html
@@ -0,0 +1,16 @@
+
本地合箱接口验收
+本地合箱接口验收
复用当前平台会话,只调用正式业务接口。
+
+
+
diff --git a/src/modules/overseas-warehouse/api/cargoDiscards.test.ts b/src/modules/overseas-warehouse/api/cargoDiscards.test.ts
new file mode 100644
index 00000000..2b1e73fb
--- /dev/null
+++ b/src/modules/overseas-warehouse/api/cargoDiscards.test.ts
@@ -0,0 +1,15 @@
+import { beforeEach, expect, it, vi } from 'vitest'
+import { http } from '@/shared/api/http'
+import { downloadCargoDiscardPhoto, getCargoDiscard } from './cargoDiscards'
+
+vi.mock('@/shared/api/http', async importOriginal => ({ ...(await importOriginal()), http: { get: vi.fn() } }))
+beforeEach(() => vi.mocked(http.get).mockReset())
+it('reads submitted photos from detail and downloads a blob in the explicit customer scope', async () => {
+ const photos = [{ fileId: '101', palletNo: 'PALLET-A', fileName: 'discard.png', createdAt: '2026-10-14T10:00:00', submittedAt: '2026-10-14T10:01:00' }]
+ vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { summary: { discardNo: 'CD/1', customerId: '7' }, photos, items: [], histories: [] } })
+ expect((await getCargoDiscard('CD/1', '7')).photos).toEqual(photos)
+ const blob = new Blob(['image'], { type: 'image/png' })
+ vi.mocked(http.get).mockResolvedValueOnce(blob)
+ expect(await downloadCargoDiscardPhoto('CD/1', '101', '7')).toBe(blob)
+ expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/cargo-discards/CD%2F1/photos/files/101/download', { params: { customerId: '7' }, responseType: 'blob' })
+})
diff --git a/src/modules/overseas-warehouse/api/cargoDiscards.ts b/src/modules/overseas-warehouse/api/cargoDiscards.ts
index 1883512d..1600a67f 100644
--- a/src/modules/overseas-warehouse/api/cargoDiscards.ts
+++ b/src/modules/overseas-warehouse/api/cargoDiscards.ts
@@ -4,7 +4,8 @@ import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrder
export interface CargoDiscardQuery { page: number; size: number; customerId?: string; discardNo?: string; inboundCode?: string; overseasWarehouseId?: string; status?: string }
export interface CargoDiscardSummary { id: string; customerId?: string | null; discardNo: string; inboundCodes?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; totalPackages?: number | null; totalFee?: number | string | null; currency?: string | null; status?: string | null; createdBy?: string | null; createdAt?: string | null; reviewedAt?: string | null }
export interface CargoDiscardItem { id: string; orderId?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackagesSnapshot?: number | null; discardPackages?: number | null }
-export interface CargoDiscardDetail { summary: CargoDiscardSummary; feePerPackage?: number | string | null; remark?: string | null; reviewedBy?: string | null; reviewRemark?: string | null; items: CargoDiscardItem[]; histories: { fromStatus?: string | null; toStatus?: string | null; operatorId?: string | null; remark?: string | null; createdAt?: string | null }[] }
+export interface CargoDiscardPhoto { fileId: string; palletNo: string; fileName: string; createdAt: string; submittedAt: string }
+export interface CargoDiscardDetail { photos: CargoDiscardPhoto[]; summary: CargoDiscardSummary; feePerPackage?: number | string | null; remark?: string | null; reviewedBy?: string | null; reviewRemark?: string | null; items: CargoDiscardItem[]; histories: { fromStatus?: string | null; toStatus?: string | null; operatorId?: string | null; remark?: string | null; createdAt?: string | null }[] }
export interface CargoDiscardCandidate { orderId?: string | null; sourceType: string; returnCartonId?: string | null; cartonMark?: string | null; inboundCode?: string | null; inboundOrderNo?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; availablePackages?: number; skus: { skuCode: string; productName?: string | null; availablePackages?: number }[] }
export async function listCargoDiscards(query: CargoDiscardQuery) {
@@ -24,3 +25,7 @@ export async function completeCargoDiscard(discardNo: string, customerId?: strin
return unwrapApiResponse(response)
}
+
+export function downloadCargoDiscardPhoto(discardNo: string, fileId: string, customerId: string) {
+ return http.get(`/platform/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/photos/files/${encodeURIComponent(fileId)}/download`, { params: { customerId }, responseType: 'blob' })
+}
diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.ts b/src/modules/overseas-warehouse/api/lastMileOrders.ts
index 096574a6..53c39ca1 100644
--- a/src/modules/overseas-warehouse/api/lastMileOrders.ts
+++ b/src/modules/overseas-warehouse/api/lastMileOrders.ts
@@ -360,6 +360,8 @@ export interface LastMileOrderDetailDto {
items?: Array<{ skuCode: string; productNameZh?: string; expectedQuantity: number; receivedQuantity: number }>
receipts: Array<{ receiptNo: string; unitType: string; unitCode?: string; palletCode?: string; receivedQuantity: number; boxCount: number; signedAt: string; length?: number | null; width?: number | null; height?: number | null; weight?: number | null }>
cartons?: Array<{ receiptNo: string; palletCode?: string; cartonMark: string; countStatus: string; countedAt?: string; items: Array<{ skuCode: string; productNameZh?: string; quantity: number }>; length?: number | null; width?: number | null; height?: number | null; weight?: number | null }>
+ packingDetails?: import('./returnInventory').ReturnCartonMergeHistory[]
+ currentCartons?: Array<{ carton: import('./returnInventory').ReturnCartonDetail; ownership: Array<{ returnNo: string; skuCode: string; quantity: number }>; orderQuantity: number; labelStatus: string; canRelabel: boolean; blockReason?: string }>
} | null
order: LastMileOrderDto
manualNodes?: ManualNodesResponse['nodes']
diff --git a/src/modules/overseas-warehouse/api/relabelOrders.ts b/src/modules/overseas-warehouse/api/relabelOrders.ts
index 6a68a983..b604f431 100644
--- a/src/modules/overseas-warehouse/api/relabelOrders.ts
+++ b/src/modules/overseas-warehouse/api/relabelOrders.ts
@@ -160,6 +160,10 @@ export interface ReturnPackingPlanDto extends Omit }>
+ completionCartons?: import('./returnInventory').ReturnCartonLabel[]
+ transfers?: import('./returnInventory').ReturnCartonMergeHistory[]
+ completedBy?: string
}
export async function createReturnPackingPlan(payload: ReturnPackingPlanPayload) {
diff --git a/src/modules/overseas-warehouse/pages/CargoDiscardsView.vue b/src/modules/overseas-warehouse/pages/CargoDiscardsView.vue
index fbe8d43c..1cb31117 100644
--- a/src/modules/overseas-warehouse/pages/CargoDiscardsView.vue
+++ b/src/modules/overseas-warehouse/pages/CargoDiscardsView.vue
@@ -1,6 +1,6 @@
废弃单
详情审核并推送 PDA共 {{ total }} 条废弃单
-{{ detail.summary.discardNo }}{{ statusLabels[String(detail.summary.status)] }}{{ detail.summary.overseasWarehouseName || '—' }}{{ detail.summary.inboundCodes || '—' }}{{ detail.remark || '—' }}废弃明细
状态流转
{{ statusLabels[item.toStatus || ''] || item.toStatus }} {{ item.remark ? `— ${item.remark}` : '' }}
+{{ detail.summary.discardNo }}{{ statusLabels[String(detail.summary.status)] }}{{ detail.summary.overseasWarehouseName || '—' }}{{ detail.summary.inboundCodes || '—' }}{{ detail.remark || '—' }}废弃明细
废弃现场照片
照片加载中…{{ photoErrors[photo.fileId] ? '加载失败,点击重试' : '加载照片' }}托盘:{{ photo.palletNo }} · {{ photo.submittedAt }}
暂无已提交的废弃现场照片
状态流转
{{ statusLabels[item.toStatus || ''] || item.toStatus }} {{ item.remark ? `— ${item.remark}` : '' }}
+
+
diff --git a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue
index 0633d7bb..7e3ecc76 100644
--- a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue
+++ b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue
@@ -274,6 +274,28 @@ onMounted(load)
+ 合箱记录
+
+
+
+
+
+
+
+ 当前关联实物箱
+
+
+ {{ current.carton.cartonMark }} · 整箱 {{ current.carton.totalQuantity }} 件 · 本单 {{ current.orderQuantity }} 件 · {{ current.carton.sourceReturnNo?.includes(',') ? '跨来源单合箱' : '单一来源' }}
+ 来源单:{{ current.carton.sourceReturnNo }} · 状态:{{ current.carton.status }} · 我方箱唛:{{ current.labelStatus === 'CONFIRMED' ? '已确认贴标' : '待贴标确认' }}
+
+
+
+
+
+ {{ current.canRelabel ? '后续贴标按此当前箱唛选择整箱;跨来源单同 SKU 的部分贴标须先现场分拣确认。' : current.blockReason }}
+
+
+
diff --git a/src/modules/overseas-warehouse/pages/OutboundsView.selection.test.ts b/src/modules/overseas-warehouse/pages/OutboundsView.selection.test.ts
index 6ad71fc8..18e19759 100644
--- a/src/modules/overseas-warehouse/pages/OutboundsView.selection.test.ts
+++ b/src/modules/overseas-warehouse/pages/OutboundsView.selection.test.ts
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import OutboundsView from './OutboundsView.vue'
import { listPlatformTenants } from '@/modules/iam'
-const api = vi.hoisted(() => ({ candidates: vi.fn(), create: vi.fn(), warehouses: vi.fn(), returnCandidates: vi.fn(), stock: vi.fn(), cartons: vi.fn(), carton: vi.fn() }))
+const api = vi.hoisted(() => ({ candidates: vi.fn(), create: vi.fn(), warehouses: vi.fn(), returnCandidates: vi.fn(), stock: vi.fn(), cartons: vi.fn(), carton: vi.fn(), transfers: vi.fn(), bindDestination: vi.fn() }))
vi.mock('../api/returnInventory', () => ({ listReturnStock: api.stock, listReturnCartons: api.cartons, getReturnCarton: api.carton }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
@@ -14,6 +14,7 @@ vi.mock('../api/outbounds', () => ({
listOutbounds: vi.fn().mockResolvedValue({ records: [], total: 0 }),
listOneClickOutboundCandidates: api.candidates,
createOneClickDropshipOutbound: api.create,
+ listWarehouseTransferOutboundCandidates: api.transfers, bindWarehouseTransferDestination: api.bindDestination,
cancelOutbound: vi.fn(), createReturnOutbound: vi.fn(), listReturnOutboundCandidates: api.returnCandidates,
}))
@@ -84,6 +85,8 @@ describe('outbound candidate search and selection', () => {
vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined, removeItem: () => undefined })
api.candidates.mockReset().mockResolvedValue(fixtures.map(item => ({ ...item })))
api.create.mockReset().mockResolvedValue({})
+ api.transfers.mockReset().mockResolvedValue([])
+ api.bindDestination.mockReset().mockResolvedValue([])
api.warehouses.mockReset().mockResolvedValue({ records: [
{ id: 'warehouse-a', warehouseName: '洛杉矶仓', warehouseCode: 'US-LAX-01', warehouseType: 'OVERSEAS', status: 'ACTIVE' },
{ id: 'warehouse-b', warehouseName: '纽约仓', warehouseCode: 'US-NY-01', warehouseType: 'OVERSEAS', status: 'ACTIVE' },
@@ -91,6 +94,40 @@ describe('outbound candidate search and selection', () => {
})
afterEach(() => { wrapper?.unmount(); vi.unstubAllGlobals() })
+ it('groups pallet candidates by customer and transfer and binds each selected transfer once', async () => {
+ const transfer = { customerId: 'customer-a', transferNo: 'WT-TEST', overseasWarehouseId: 'warehouse-a', transferStatus: 'WAIT_FBA_DESTINATION', needRelabel: false }
+ api.transfers.mockResolvedValue([
+ { ...transfer, packageCount: 4, volumeCbm: 0.15 },
+ { ...transfer, packageCount: 6, volumeCbm: 0.08 },
+ { ...transfer, customerId: 'customer-b', packageCount: 2, volumeCbm: 0.1 },
+ ])
+ api.warehouses.mockResolvedValue({ records: [
+ { id: 'warehouse-a', warehouseName: '洛杉矶仓', warehouseCode: 'US-LAX-01', warehouseType: 'OVERSEAS', status: 'ACTIVE' },
+ { id: 'fba-a', warehouseName: 'ABE5', warehouseCode: 'ABE5', warehouseType: 'FBA', status: 'ACTIVE' },
+ ] })
+ await openCreate()
+ const type = wrapper.findComponent({ name: 'ElRadioGroup' })
+ type.vm.$emit('update:modelValue', 'WAREHOUSE_TRANSFER_OUTBOUND')
+ type.vm.$emit('change')
+ await flushPromises()
+ const table = wrapper.findAllComponents({ name: 'ElTable' }).find(row => row.classes().includes('dropship-candidate-table'))!
+ expect(table.props('data')).toEqual([
+ expect.objectContaining({ customerId: 'customer-a', packageCount: 10, volumeCbm: 0.23 }),
+ expect.objectContaining({ customerId: 'customer-b', packageCount: 2, volumeCbm: 0.1 }),
+ ])
+ for (const checkbox of table.findAllComponents(ElCheckbox)) checkbox.vm.$emit('change', true)
+ const destination = wrapper.findAllComponents(ElSelect).find(row => row.props('placeholder') === '请选择FBA目的仓')!
+ destination.vm.$emit('update:modelValue', 'fba-a')
+ await flushPromises()
+ await wrapper.find('[data-test="submit-outbound"]').trigger('click')
+ await flushPromises()
+ expect(api.bindDestination).toHaveBeenCalledOnce()
+ expect(api.bindDestination).toHaveBeenCalledWith(expect.objectContaining({ transfers: [
+ { customerId: 'customer-a', transferNo: 'WT-TEST' },
+ { customerId: 'customer-b', transferNo: 'WT-TEST' },
+ ], destinationWarehouseCode: 'ABE5' }))
+ })
+
it('offers enabled customers from later pages when creating a return outbound', async () => {
vi.mocked(listPlatformTenants)
.mockResolvedValueOnce({ records: [{ id: 'customer-a', tenantName: '柏悦物流demo2', status: 'ACTIVE' }], page: 1, size: 200, total: 201, pages: 2 } as Awaited>)
diff --git a/src/modules/overseas-warehouse/pages/OutboundsView.vue b/src/modules/overseas-warehouse/pages/OutboundsView.vue
index 4adc9670..27fe7eaf 100644
--- a/src/modules/overseas-warehouse/pages/OutboundsView.vue
+++ b/src/modules/overseas-warehouse/pages/OutboundsView.vue
@@ -138,7 +138,19 @@ async function changeOutboundType() { selectedReturnNo.value = ''; selectedRetur
async function loadTransferCandidates() {
inventoryLoading.value = true
try {
- transferCandidates.value = await listWarehouseTransferOutboundCandidates({ customerId: selectedCustomerId.value || undefined, warehouseId: selectedWarehouseId.value || undefined })
+ const pallets = await listWarehouseTransferOutboundCandidates({ customerId: selectedCustomerId.value || undefined, warehouseId: selectedWarehouseId.value || undefined })
+ // The API returns pallet rows, but destination binding applies to the whole transfer.
+ const transfers = new Map()
+ for (const pallet of pallets) {
+ const key = transferSelectionKey(pallet)
+ const transfer = transfers.get(key)
+ if (transfer) {
+ transfer.packageCount += pallet.packageCount
+ transfer.grossWeightKg = sumTransferMeasurement(transfer.grossWeightKg, pallet.grossWeightKg)
+ transfer.volumeCbm = sumTransferMeasurement(transfer.volumeCbm, pallet.volumeCbm)
+ } else transfers.set(key, { ...pallet })
+ }
+ transferCandidates.value = [...transfers.values()]
const candidateKeys = new Set(transferCandidates.value.map(transferSelectionKey))
selectedTransferPalletIds.value = selectedTransferPalletIds.value.filter(key => candidateKeys.has(key))
if (transferCandidates.value.length === 1) selectedTransferPalletIds.value = [transferSelectionKey(transferCandidates.value[0])]
@@ -149,6 +161,9 @@ async function loadTransferCandidates() {
} finally { inventoryLoading.value = false }
}
function transferSelectionKey(row: WarehouseTransferOutboundCandidate) { return `${row.customerId}:${row.transferNo}` }
+function sumTransferMeasurement(left?: number | null, right?: number | null) {
+ return left == null && right == null ? null : Number(((left ?? 0) + (right ?? 0)).toFixed(6))
+}
function toggleTransferPallet(row: WarehouseTransferOutboundCandidate, checked: boolean | string | number) { const key = transferSelectionKey(row); selectedTransferPalletIds.value = checked ? [...new Set([...selectedTransferPalletIds.value, key])] : selectedTransferPalletIds.value.filter(id => id !== key) }
function transferDestinationLabel(row: WarehouseTransferOutboundCandidate) {
if (row.destinationType === 'PRIVATE' || row.privateDestinationAddress?.trim()) {
diff --git a/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue b/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue
index 89476278..1b9e77db 100644
--- a/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue
+++ b/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue
@@ -177,9 +177,18 @@ function openPlanDetail(row: Record) {
planDetail.value = planRows.value.find(plan => plan.planNo === row.planNo);
planDetailOpen.value = !!planDetail.value;
}
-const planCartonItemRows = computed(() => (planDetail.value?.resultCartons || []).flatMap(carton =>
+const planCartonItemRows = computed(() => (planDetail.value?.completionCartons || []).flatMap(carton =>
carton.items.map(item => ({ ...carton, skuCode: item.skuCode, quantity: item.quantity })),
));
+const planSourceRows = computed(() => (planDetail.value?.sources || []).flatMap(source => {
+ const original = source.originalItems?.length ? source.originalItems : source.items;
+ return original.map(item => {
+ const take = source.items.find(line => line.skuCode === item.skuCode)?.quantity || 0;
+ return { cartonMark: source.cartonMark, sourceReturnNo: source.sourceReturnNo, participation: source.participation,
+ skuCode: item.skuCode, originalQuantity: source.originalItems?.length ? item.quantity : null, take,
+ remaining: source.originalItems?.length ? item.quantity - take : null };
+ });
+}));
const plannedItemRows = computed(() => (planDetail.value?.boxes || []).flatMap((box, index) =>
(box.items || []).map(item => ({ boxNo: index + 1, skuCode: item.skuCode, quantity: item.quantity })),
));
@@ -858,12 +867,34 @@ const tableColumns = computed(() =>
>{{
planDetail.remark || "—"
}}
+ >
+ 计划来源箱明细
+
+
+
+ {{ row.participation === 'FULL' ? '整箱' : row.participation === 'PARTIAL' ? '部分' : '未留存' }}
+
+ {{ row.originalQuantity ?? '—' }}
+
+ {{ row.remaining ?? '—' }}
+
+
+ 实际合箱流向
+
+
+
+
+
+
+
+ 合箱完成时结果({{ planDetail.completedBy || '操作员未留存' }})
+ 结果数量为完成时快照;目标原箱内容包含在整箱数量中,本次合入数量见上表。
+
+
-
-
+ {{ row.length ?? '—' }} × {{ row.width ?? '—' }} × {{ row.height ?? '—' }} cm / {{ row.weight ?? '—' }} kg
{{ planDetail.completedAt ? dayjs(planDetail.completedAt).format('YYYY-MM-DD HH:mm:ss') : '—' }}
打印箱唛