From 6d0eb2a5f39d36f5d098f72c07c1ab923949dac9 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 12 Oct 2026 13:43:29 +0800 Subject: [PATCH 01/59] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../overseas-warehouse/api/returnInventory.ts | 4 +- .../api/warehouseServices.ts | 2 +- .../utils/returnReceiptDisplay.test.mjs | 7 ++ .../utils/returnReceiptDisplay.ts | 7 +- .../utils/returnRelabelProgress.ts | 4 + .../views/ReturnInventoryView.vue | 5 +- .../views/ReturnOrdersView.test.mjs | 14 ++- .../views/ReturnOrdersView.vue | 58 ++++++++---- .../views/WarehouseServiceView.vue | 88 +++++++++---------- .../views/relabelAttachments.test.mjs | 6 +- .../views/returnRelabelSources.test.mjs | 63 +++++++++++++ 11 files changed, 184 insertions(+), 74 deletions(-) create mode 100644 src/modules/overseas-warehouse/utils/returnRelabelProgress.ts create mode 100644 src/modules/overseas-warehouse/views/returnRelabelSources.test.mjs diff --git a/src/modules/overseas-warehouse/api/returnInventory.ts b/src/modules/overseas-warehouse/api/returnInventory.ts index f0d9415..8daf616 100644 --- a/src/modules/overseas-warehouse/api/returnInventory.ts +++ b/src/modules/overseas-warehouse/api/returnInventory.ts @@ -14,7 +14,7 @@ export interface ReturnInventorySummaryItem { export interface ReturnInventoryCartonItem { skuCode: string; productNameZh?: string; quantity: number } export interface ReturnInventoryCartonSummary { - cartonMark: string; overseasWarehouseId: string; sourceBatchNo?: string; sourceReturnNo?: string; status: string + cartonMark: string; overseasWarehouseId: string; sourceBatchNo?: string; sourceReturnNo?: string; relabelStatus?: string; relabelledQuantity?: number; relabelReservedQuantity?: number; status: string skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; createdAt: string } @@ -23,7 +23,7 @@ export function listReturnInventoryCartons(params: Record) { } export interface ReturnInventoryCartonDetail { - cartonMark: string; overseasWarehouseId: string; sourceBatchNo?: string; sourceReturnNo?: string; status: string + cartonMark: string; overseasWarehouseId: string; sourceBatchNo?: string; sourceReturnNo?: string; relabelStatus?: string; relabelledQuantity?: number; relabelReservedQuantity?: number; status: string skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; receivedAt: string; items: ReturnInventoryCartonItem[] } diff --git a/src/modules/overseas-warehouse/api/warehouseServices.ts b/src/modules/overseas-warehouse/api/warehouseServices.ts index 16c3bb7..081a4bd 100644 --- a/src/modules/overseas-warehouse/api/warehouseServices.ts +++ b/src/modules/overseas-warehouse/api/warehouseServices.ts @@ -34,7 +34,7 @@ export interface RelabelInventoryCandidate { availablePackages: number | string; version: number positions: Array<{ positionId: string; palletNo?: string; batchNo?: string; warehouseLocationCode?: string; availablePackages: number }> sourceType?: 'INBOUND' | 'RETURN_CARTON' | 'RETURN_PARCEL'; returnCartonId?: string; cartonMark?: string - skuItems?: Array<{ skuCode: string; newSkuCode?: string; quantity: number; remark?: string }>; relabelledPackages?: number + skuItems?: Array<{ skuCode: string; newSkuCode?: string; quantity: number; remark?: string }>; relabelledPackages?: number; relabelledQuantity?: number; relabelReservedQuantity?: number; relabelStatus?: string } export interface TransferCreate { diff --git a/src/modules/overseas-warehouse/utils/returnReceiptDisplay.test.mjs b/src/modules/overseas-warehouse/utils/returnReceiptDisplay.test.mjs index 3643b8e..34d1957 100644 --- a/src/modules/overseas-warehouse/utils/returnReceiptDisplay.test.mjs +++ b/src/modules/overseas-warehouse/utils/returnReceiptDisplay.test.mjs @@ -8,6 +8,13 @@ const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.Scri const exports = {} new Function('exports', compiled)(exports) +test('签收箱号只包含箱子和托盘签收,包裹实物记录不重复展示为箱子', () => { + const receipts = [{ receiptNo: 'PARCEL-1', unitType: 'PARCEL' }, { receiptNo: 'BOX-1', unitType: 'BOX' }, { receiptNo: 'PALLET-1', unitType: 'PALLET' }] + const cartons = [{ receiptNo: 'PARCEL-1', cartonMark: 'RP-1' }, { receiptNo: 'BOX-1', cartonMark: 'RC-1' }, { receiptNo: 'PALLET-1', cartonMark: 'RC-2' }, { receiptNo: 'UNKNOWN', cartonMark: 'RP-UNKNOWN' }] + assert.deepEqual(exports.receiptBoxDetails(receipts, cartons).map(item => item.cartonMark), ['RC-1', 'RC-2']) + assert.deepEqual(exports.receiptBoxDetails(receipts.slice(0, 1), cartons), []) +}) + test('volume rounds upward to exactly three decimal places without increasing exact thousandths', () => { for (const [input, expected] of [[0.186624, '0.187'], [0.15, '0.150'], [1.001, '1.001'], [1.00100001, '1.002'], [0.0000001, '0.001'], [0, '0.000'], [null, '—'], [NaN, '—']]) { assert.equal(exports.formatReturnVolume(input), expected) diff --git a/src/modules/overseas-warehouse/utils/returnReceiptDisplay.ts b/src/modules/overseas-warehouse/utils/returnReceiptDisplay.ts index 57e1087..4dcddc2 100644 --- a/src/modules/overseas-warehouse/utils/returnReceiptDisplay.ts +++ b/src/modules/overseas-warehouse/utils/returnReceiptDisplay.ts @@ -1,4 +1,9 @@ -import type { ReturnReceipt } from '../api/returnOrders' +import type { ReturnReceipt, ReturnReceiptCarton } from '../api/returnOrders' + +export function receiptBoxDetails(receipts: Pick[], cartons: ReturnReceiptCarton[]) { + const boxReceiptNos = new Set(receipts.filter(receipt => receipt.unitType === 'BOX' || receipt.unitType === 'PALLET').map(receipt => receipt.receiptNo)) + return cartons.filter(carton => boxReceiptNos.has(carton.receiptNo)) +} export function receiptTrackingNo(receipt: Pick | Record) { const trackingNo = typeof receipt.trackingNo === 'string' ? receipt.trackingNo.trim() : '' diff --git a/src/modules/overseas-warehouse/utils/returnRelabelProgress.ts b/src/modules/overseas-warehouse/utils/returnRelabelProgress.ts new file mode 100644 index 0000000..8289882 --- /dev/null +++ b/src/modules/overseas-warehouse/utils/returnRelabelProgress.ts @@ -0,0 +1,4 @@ +export function returnRelabelStatusLabel(status?: string) { + const labels: Record = { UNLABELLED: '未换标', BOUND: '处理中', PROCESSING: '处理中', PARTIALLY_COMPLETED: '部分完成', COMPLETED: '全部完成' } + return status ? labels[status] || '—' : '—' +} diff --git a/src/modules/overseas-warehouse/views/ReturnInventoryView.vue b/src/modules/overseas-warehouse/views/ReturnInventoryView.vue index b76a16f..7a936de 100644 --- a/src/modules/overseas-warehouse/views/ReturnInventoryView.vue +++ b/src/modules/overseas-warehouse/views/ReturnInventoryView.vue @@ -1,4 +1,5 @@ ')) + const compiledBinding = ts.transpileModule(binding, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText + const cargoRows = { value: [] } + const bind = new Function('labelingMode', 'cartonDetailRows', 'cargoRows', 'selectedReturnOrderNo', 'addReturnSkuBinding', 'let cargoSequence = 0; ' + compiledBinding + '; return addReturnBoxBinding')({ value: 'carton' }, { value: cargoRows.value }, cargoRows, { value: 'RETURN-1' }, () => assert.fail('箱唛模式不添加SKU行')) + const parcel = { returnCartonId: 'PARCEL-ID', sourceType: 'RETURN_PARCEL', cartonMark: 'RP-1', skuItems: [{ skuCode: 'SKU-A', quantity: 80 }] } + bind(parcel) + bind(parcel) + assert.equal(cargoRows.value.length, 1) + assert.equal(cargoRows.value[0].returnCartonId, 'PARCEL-ID') + assert.equal(cargoRows.value[0].quantity, 1) + assert.doesNotMatch(view, /returnSkuOnly|散件库存只能贴 SKU/) + assert.match(view, /labelingMode === 'sku' \? addReturnSkuBinding\(source, sku.skuCode\) : addReturnBoxBinding\(source\)/) +}) -- 2.54.0 From 6cb9b914358c9d483a44e6cfe4543155257a90b0 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 12 Oct 2026 14:33:17 +0800 Subject: [PATCH 02/59] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/overseas-warehouse/views/WarehouseServiceView.vue | 4 ++-- .../overseas-warehouse/views/returnRelabelSources.test.mjs | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue index c26ea40..dd71e04 100644 --- a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue +++ b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue @@ -1402,7 +1402,7 @@ function selectRelabelInventory(inboundOrderId: string) { labelingMode.value = 'sku' for (const candidate of selectedCandidates) rows.push({ id: ++cargoSequence, sku: candidate.skuItems?.[0]?.skuCode || candidate.skuCode || '', - name: candidate.productNameZh || '退货库存', included: false, quantity: 1, availableQuantity: 1, + name: candidate.productNameZh || '', included: false, quantity: 1, availableQuantity: 1, returnCartonId: String(candidate.returnCartonId), sourceInboundOrderNo: candidate.inboundOrderNo, fnsku: '', shipmentNo: candidate.inboundOrderNo || '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', cartonMark: candidate.cartonMark || '', fbaCartonMark: '', destinationWarehouseId: '', remark: '', @@ -1442,7 +1442,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) { return } if (cartonDetailRows.value.some(item => item.cartonMark === source.cartonMark)) return - cargoRows.value.push({ id: ++cargoSequence, included: true, sku: source.skuItems?.map(item => item.skuCode).join(',') || '', name: source.productNameZh || '退货箱', shipmentNo: selectedReturnOrderNo.value, cartonMark: source.cartonMark || '', fbaCartonMark: '', remark: '', sourceInventoryBalanceId: undefined, sourceInventoryPositionId: undefined, sourceInboundOrderId: undefined, returnCartonId: String(source.returnCartonId), quantity: 1, fnsku: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', destinationWarehouseId: '' }) + cargoRows.value.push({ id: ++cargoSequence, included: true, sku: source.skuItems?.map(item => item.skuCode).join(',') || '', name: source.productNameZh || '', shipmentNo: '', cartonMark: source.cartonMark || '', fbaCartonMark: '', remark: '', sourceInventoryBalanceId: undefined, sourceInventoryPositionId: undefined, sourceInboundOrderId: undefined, returnCartonId: String(source.returnCartonId), quantity: 1, fnsku: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', destinationWarehouseId: '' }) } diff --git a/src/modules/overseas-warehouse/views/returnRelabelSources.test.mjs b/src/modules/overseas-warehouse/views/returnRelabelSources.test.mjs index 783d3f7..481f117 100644 --- a/src/modules/overseas-warehouse/views/returnRelabelSources.test.mjs +++ b/src/modules/overseas-warehouse/views/returnRelabelSources.test.mjs @@ -52,12 +52,14 @@ test('包裹箱唛添加保持一个实物并去重,三种类型不再禁用', const compiledBinding = ts.transpileModule(binding, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText const cargoRows = { value: [] } const bind = new Function('labelingMode', 'cartonDetailRows', 'cargoRows', 'selectedReturnOrderNo', 'addReturnSkuBinding', 'let cargoSequence = 0; ' + compiledBinding + '; return addReturnBoxBinding')({ value: 'carton' }, { value: cargoRows.value }, cargoRows, { value: 'RETURN-1' }, () => assert.fail('箱唛模式不添加SKU行')) - const parcel = { returnCartonId: 'PARCEL-ID', sourceType: 'RETURN_PARCEL', cartonMark: 'RP-1', skuItems: [{ skuCode: 'SKU-A', quantity: 80 }] } + const parcel = { returnCartonId: 'PARCEL-ID', sourceType: 'RETURN_PARCEL', cartonMark: 'RP-1', productNameZh: '测试商品中文名', skuItems: [{ skuCode: 'SKU-A', quantity: 80 }] } bind(parcel) bind(parcel) assert.equal(cargoRows.value.length, 1) assert.equal(cargoRows.value[0].returnCartonId, 'PARCEL-ID') assert.equal(cargoRows.value[0].quantity, 1) + assert.equal(cargoRows.value[0].shipmentNo, '') + assert.equal(cargoRows.value[0].name, '测试商品中文名') assert.doesNotMatch(view, /returnSkuOnly|散件库存只能贴 SKU/) assert.match(view, /labelingMode === 'sku' \? addReturnSkuBinding\(source, sku.skuCode\) : addReturnBoxBinding\(source\)/) }) -- 2.54.0 From 06f91c8b915515a08ff559706ecbed43a912a10b Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 12 Oct 2026 15:56:07 +0800 Subject: [PATCH 03/59] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/warehouseServices.ts | 1 + .../views/WarehouseServiceView.vue | 34 +++++++++--- .../views/relabelAttachments.test.mjs | 4 +- .../views/returnStockDisplay.test.mjs | 54 +++++++++++++++++++ 4 files changed, 83 insertions(+), 10 deletions(-) create mode 100644 src/modules/overseas-warehouse/views/returnStockDisplay.test.mjs diff --git a/src/modules/overseas-warehouse/api/warehouseServices.ts b/src/modules/overseas-warehouse/api/warehouseServices.ts index 081a4bd..3a78ddb 100644 --- a/src/modules/overseas-warehouse/api/warehouseServices.ts +++ b/src/modules/overseas-warehouse/api/warehouseServices.ts @@ -28,6 +28,7 @@ export interface RelabelDetail { export interface DiscardSummary { id: string; discardNo: string; outboundNo?: string; inboundCodes: string; overseasWarehouseId: string; overseasWarehouseName: string; totalPackages: number; totalFee: number; currency: string; status: string; createdAt: string; completedAt?: string } export interface DiscardCandidate { orderId: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON'; returnCartonId?: string; cartonMark?: string; inboundCode: string; inboundOrderNo: string; overseasWarehouseId: string; overseasWarehouseName: string; availablePackages: number; skus: Array<{ skuCode: string; productName: string; availablePackages: number }> } export interface RelabelInventoryCandidate { + stockQuantity?: number; inventoryStatus?: string; occupiedRelabelNo?: string; mergedIntoCartonMarks?: string[]; hasOutbound?: boolean inventoryBalanceId: string; inboundOrderId: string; inboundOrderNo?: string; inboundCode?: string overseasWarehouseId: string; overseasWarehouseCode?: string; overseasWarehouseName?: string destinationFbaWarehouseCode?: string; destinationFbaWarehouseName?: string; skuCode?: string; productNameZh?: string diff --git a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue index dd71e04..915bf02 100644 --- a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue +++ b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue @@ -199,13 +199,15 @@ const selectedTransferSourceOrderIds = ref([]) const isReturnRelabelSource = computed(() => props.service === 'labeling' && selectedRelabelInboundOrderId.value.startsWith('RETURN_ORDER:')) const selectedReturnOrderNo = computed(() => isReturnRelabelSource.value ? selectedRelabelInboundOrderId.value.slice('RETURN_ORDER:'.length) : '') const selectedReturnCandidates = computed(() => relabelCandidates.value.filter(item => Boolean(item.returnCartonId) && returnSourceNos(item).includes(selectedReturnOrderNo.value))) -const returnBoxCandidates = computed(() => selectedReturnCandidates.value.filter(item => item.sourceType === 'RETURN_CARTON')) -const returnLooseCandidates = computed(() => selectedReturnCandidates.value.filter(item => item.sourceType === 'RETURN_PARCEL')) +const availableReturnCandidates = computed(() => selectedReturnCandidates.value.filter(item => inventoryQuantity(item.availablePackages) > 0).map(item => ({ ...item, skuItems: (item.skuItems || []).filter(sku => inventoryQuantity(sku.quantity) > 0) }))) +const returnBoxCandidates = computed(() => availableReturnCandidates.value.filter(item => item.sourceType === 'RETURN_CARTON')) +const returnLooseCandidates = computed(() => availableReturnCandidates.value.filter(item => item.sourceType === 'RETURN_PARCEL')) const requiresBothTotalFiles = computed(() => (props.service === 'labeling' || (props.service === 'transfer' && form.relabel)) && labelingMode.value === 'carton_and_sku') function inventoryQuantity(value: number | string | null | undefined) { const quantity = Number(value) return Number.isFinite(quantity) && quantity > 0 ? quantity : 0 } +const relabelInventorySearch = ref('') function returnSourceNos(candidate: RelabelInventoryCandidate): string[] { return [...new Set((candidate.inboundOrderNo || '').split(',').map(value => value.trim()).filter(Boolean))] } @@ -213,24 +215,40 @@ function returnRelabelProgress(candidate: RelabelInventoryCandidate) { const labels: Record = { UNLABELLED: '未换标', BOUND: '处理中', PROCESSING: '处理中', PARTIALLY_COMPLETED: '部分完成', COMPLETED: '全部完成' } return [labels[candidate.relabelStatus || ''] || '—', '实际完成 ' + (candidate.relabelledQuantity ?? '—') + ' 件', '处理中占用 ' + (candidate.relabelReservedQuantity ?? '—') + ' 件'].join('|') } +function returnInventoryState(candidate: RelabelInventoryCandidate) { + const labels: Record = { AVAILABLE: '可选库存', RELABEL_PENDING: '换标占用', PACKING_RESERVED: '合箱计划预留', OUTBOUND_PENDING: '出库占用', DISCARD_PENDING: '废弃申请占用', OUTBOUNDED: '已出库', MERGED: '已合箱' } + return [...(candidate.mergedIntoCartonMarks?.length ? ['历史部分转入 ' + candidate.mergedIntoCartonMarks.join('、') + '(不代表当前库存位置)'] : []), labels[candidate.inventoryStatus || ''] || (inventoryQuantity(candidate.availablePackages) > 0 ? '可选库存' : '当前不可选'), candidate.occupiedRelabelNo].filter(Boolean).join(' · ') +} const relabelInboundOptions = computed(() => { - const grouped = new Map() + const grouped = new Map() for (const candidate of relabelCandidates.value) { const sources = candidate.returnCartonId ? returnSourceNos(candidate) : [String(candidate.inboundOrderId)] for (const sourceNo of sources) { const value = candidate.returnCartonId ? 'RETURN_ORDER:' + sourceNo : sourceNo const current = grouped.get(value) - if (current) current.availablePackages += inventoryQuantity(candidate.availablePackages) + if (current) { + current.hasOutbound ||= candidate.hasOutbound === true || candidate.inventoryStatus === 'OUTBOUNDED' + current.availablePackages += inventoryQuantity(candidate.availablePackages) + current.units += candidate.returnCartonId && candidate.stockQuantity === 0 ? 0 : 1 + current.stockQuantity = current.stockQuantity == null || candidate.stockQuantity == null ? null : current.stockQuantity + inventoryQuantity(candidate.stockQuantity) + if (candidate.returnCartonId) current.reasons.push(returnInventoryState(candidate)) + } else grouped.set(value, { value, inboundOrderNo: candidate.returnCartonId ? '退货单 ' + sourceNo : (candidate.inboundOrderNo || '—'), inboundCode: candidate.returnCartonId ? '已签收退货库存(含合箱)' : (candidate.inboundCode || '—'), availablePackages: inventoryQuantity(candidate.availablePackages), quantityUnit: candidate.returnCartonId ? '件' : '箱', + stockQuantity: candidate.stockQuantity == null ? null : inventoryQuantity(candidate.stockQuantity), units: candidate.returnCartonId && candidate.stockQuantity === 0 ? 0 : 1, hasOutbound: candidate.hasOutbound === true || candidate.inventoryStatus === 'OUTBOUNDED', reasons: candidate.returnCartonId ? [returnInventoryState(candidate)] : [], }) } } - return [...grouped.values()] + return [...grouped.values()].map(option => ({ ...option, reasons: [...new Set(option.reasons)] })) }) +const visibleRelabelInboundOptions = computed(() => relabelInboundOptions.value.filter(option => option.availablePackages > 0)) +function relabelOptionLabel(option: typeof relabelInboundOptions.value[number]) { + if (option.quantityUnit === '箱') return option.inboundOrderNo + '|' + option.inboundCode + '|可选 ' + option.availablePackages + ' 箱' + return option.inboundOrderNo + '|在仓 ' + option.units + ' 包裹/箱 · ' + (option.stockQuantity ?? '待查询') + ' 件' + (option.hasOutbound && Number(option.stockQuantity) > 0 ? '|部分出库' : '') +} const loading = ref(false) const saving = ref(false) const cartonTemplateInput = ref(null) @@ -1480,7 +1498,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) { - +
*入仓方式仓库上门提货客户送货到仓
@@ -1548,11 +1566,11 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
diff --git a/src/modules/overseas-warehouse/views/relabelAttachments.test.mjs b/src/modules/overseas-warehouse/views/relabelAttachments.test.mjs index 070cdc1..f789374 100644 --- a/src/modules/overseas-warehouse/views/relabelAttachments.test.mjs +++ b/src/modules/overseas-warehouse/views/relabelAttachments.test.mjs @@ -40,9 +40,9 @@ test('贴标申请移除操作项下方的解释文字,保留必要状态', () test('贴标申请可从当前客户仍有库存的入仓单或退货单选择来源', () => { assert.match(view, /海外操作仓(中转仓)/) - assert.match(view, /选择有库存的入仓单或退货单/) + assert.match(view, /选择入仓单或退货单/) assert.match(view, /quantityUnit: candidate\.returnCartonId \? '件' : '箱'/) - assert.match(view, /当前中转仓暂无可用库存/) + assert.match(view, /暂无匹配的可选库存/) assert.match(view, /relabelInboundOptions/) assert.match(view, /listRelabelInventoryCandidates\(form\.warehouse\)/) assert.match(api, /relabel-applications\/inventory-candidates/) diff --git a/src/modules/overseas-warehouse/views/returnStockDisplay.test.mjs b/src/modules/overseas-warehouse/views/returnStockDisplay.test.mjs new file mode 100644 index 0000000..f954180 --- /dev/null +++ b/src/modules/overseas-warehouse/views/returnStockDisplay.test.mjs @@ -0,0 +1,54 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import ts from 'typescript' +const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8') +const source = view.slice(view.indexOf('function returnSourceNos'), view.indexOf('const loading = ref(false)')) +function setup(candidates) { + const output = ts.transpileModule(source + '\nexports.options = relabelInboundOptions; exports.visible = visibleRelabelInboundOptions; exports.label = relabelOptionLabel', { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText + const exports = {}, show = { value: false }, search = { value: '' }, selected = { value: '' } + new Function('exports', 'computed', 'relabelCandidates', 'showOccupiedRelabelInventory', 'relabelInventorySearch', 'selectedRelabelInboundOrderId', 'inventoryQuantity', output)(exports, fn => ({ get value() { return fn() } }), { value: candidates }, show, search, selected, value => Math.max(0, Number(value) || 0)) + return { ...exports, show, search, selected } +} +test('occupied return keeps stock facts but remains hidden when searched', () => { + const context=setup([{ returnCartonId:'C', inboundOrderNo:'RT-1', availablePackages:0, stockQuantity:10, inventoryStatus:'RELABEL_PENDING', occupiedRelabelNo:'RL-1' }]) + assert.equal(context.visible.value.length,0) + context.search.value='RT-1'; assert.equal(context.visible.value.length,0) + const label=context.label(context.options.value[0]); assert.match(label,/在仓 1 包裹\/箱 · 10 件/); assert.equal(label,'退货单 RT-1|在仓 1 包裹/箱 · 10 件'); assert.match(context.options.value[0].reasons.join(';'),/换标占用 · RL-1/) + context.search.value=''; assert.equal(context.visible.value.length,0) +}) +test('merged empty source adds history reason without inflating physical stock count', () => { + const context=setup([{ returnCartonId:'OLD', inboundOrderNo:'RT-1', availablePackages:0, stockQuantity:0, inventoryStatus:'MERGED', mergedIntoCartonMarks:['NEW'] },{ returnCartonId:'NEW', inboundOrderNo:'RT-1', availablePackages:10, stockQuantity:10, inventoryStatus:'AVAILABLE' }]) + assert.equal(context.options.value[0].units,1); assert.equal(context.options.value[0].stockQuantity,10); assert.equal(context.options.value[0].availablePackages,10) + assert.match(context.options.value[0].reasons.join(';'),/历史部分转入 NEW(不代表当前库存位置)/) +}) +test('missing stock projection stays unknown and outbound history does not count in-warehouse units', () => { + const context=setup([{returnCartonId:'C',inboundOrderNo:'RT-1',availablePackages:0},{returnCartonId:'D',inboundOrderNo:'RT-2',availablePackages:0,stockQuantity:0,inventoryStatus:'OUTBOUNDED'}]) + assert.match(context.label(context.options.value[0]),/待查询/) + assert.equal(context.options.value[1].units,0);assert.match(context.options.value[1].reasons.join(';'),/已出库/) +}) + +test('fully outbound orders stay hidden even in history search; mixed remaining stock marks partial outbound', () => { + const context=setup([{returnCartonId:'D',inboundOrderNo:'RT-D',availablePackages:0,stockQuantity:0,inventoryStatus:'OUTBOUNDED'}, {returnCartonId:'A',inboundOrderNo:'RT-P',availablePackages:0,stockQuantity:0,inventoryStatus:'OUTBOUNDED'}, {returnCartonId:'B',inboundOrderNo:'RT-P',availablePackages:2,stockQuantity:2,inventoryStatus:'AVAILABLE'}]) + context.show.value=true + assert.deepEqual(context.visible.value.map(item=>item.inboundOrderNo),['退货单 RT-P']) + context.search.value='RT-D';assert.equal(context.visible.value.some(item=>item.inboundOrderNo==='退货单 RT-D'),false) + assert.equal(context.label(context.options.value[1]),'退货单 RT-P|在仓 1 包裹/箱 · 2 件|部分出库') +}) + +test('partially shipped contents within one physical box show partial outbound', () => { + const context=setup([{returnCartonId:'B',inboundOrderNo:'RT-P',availablePackages:3,stockQuantity:3,inventoryStatus:'AVAILABLE',hasOutbound:true}]) + assert.match(context.label(context.options.value[0]),/3 件|部分出库$/) +}) + +test('available box and parcel lists remove zero-quantity sources and zero SKU rows', () => { + const block=view.slice(view.indexOf('const availableReturnCandidates'),view.indexOf('const requiresBothTotalFiles')) + const output=ts.transpileModule(block+'\nexports.boxes=returnBoxCandidates; exports.parcels=returnLooseCandidates',{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.CommonJS}}).outputText + const exports={} + const sources=[{sourceType:'RETURN_CARTON',cartonMark:'EMPTY',availablePackages:0,skuItems:[{skuCode:'A',quantity:0}]},{sourceType:'RETURN_CARTON',cartonMark:'BOX',availablePackages:3,skuItems:[{skuCode:'A',quantity:0},{skuCode:'B',quantity:3}]},{sourceType:'RETURN_PARCEL',cartonMark:'P0',availablePackages:0},{sourceType:'RETURN_PARCEL',cartonMark:'P1',availablePackages:2,skuItems:[{skuCode:'C',quantity:2}]}] + new Function('exports','computed','selectedReturnCandidates','inventoryQuantity',output)(exports,fn=>({get value(){return fn()}}),{value:sources},value=>Math.max(0,Number(value)||0)) + assert.deepEqual(exports.boxes.value.map(item=>item.cartonMark),['BOX']) + assert.deepEqual(exports.boxes.value[0].skuItems,[{skuCode:'B',quantity:3}]) + assert.deepEqual(exports.parcels.value.map(item=>item.cartonMark),['P1']) + assert.doesNotMatch(view,/显示占用及历史单据|showOccupiedRelabelInventory/) +}) -- 2.54.0 From 700074e983ad012f2e68214fafca242a66fd3be3 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 12 Oct 2026 21:19:13 +0800 Subject: [PATCH 04/59] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../overseas-warehouse/api/returnInventory.ts | 13 +++++++++-- .../api/warehouseServices.ts | 1 + .../views/ReturnInventoryView.vue | 22 ++++++++++++++++++- .../views/WarehouseServiceView.vue | 6 ++--- .../views/returnRelabelSources.test.mjs | 17 ++++++++++++-- 5 files changed, 51 insertions(+), 8 deletions(-) diff --git a/src/modules/overseas-warehouse/api/returnInventory.ts b/src/modules/overseas-warehouse/api/returnInventory.ts index 8daf616..4a0ecd7 100644 --- a/src/modules/overseas-warehouse/api/returnInventory.ts +++ b/src/modules/overseas-warehouse/api/returnInventory.ts @@ -4,14 +4,22 @@ import type { PageResult } from './warehouseServices' export interface ReturnInventoryItem { overseasWarehouseId: string; cartonMark: string; sourceBatchNo?: string; status: string; skuCode: string productNameZh?: string; quantity: number; cartonSkuTypes: number; cartonTotalQuantity: number; receivedAt: string + relabelledQuantity: number; remainingRelabelQuantity: number } export interface ReturnInventorySummaryItem { overseasWarehouseId: string; skuCode: string; productNameZh?: string - availableQuantity: number; cartonQuantity: number; updatedAt: string + totalQuantity: number; availableQuantity: number; relabelLockedQuantity: number; cartonQuantity: number; updatedAt: string } -export interface ReturnInventoryCartonItem { skuCode: string; productNameZh?: string; quantity: number } +export interface ReturnSkuConversion { + relabelNo: string; cartonMark: string; oldSkuCode: string; newSkuCode: string; quantity: number + status: 'APPLIED' | 'CONFIRMED' | 'REVERSED'; completedAt: string +} +export interface ReturnInventoryCartonItem { + skuCode: string; productNameZh?: string; quantity: number; availableQuantity: number + relabelledQuantity: number; remainingRelabelQuantity: number +} export interface ReturnInventoryCartonSummary { cartonMark: string; overseasWarehouseId: string; sourceBatchNo?: string; sourceReturnNo?: string; relabelStatus?: string; relabelledQuantity?: number; relabelReservedQuantity?: number; status: string @@ -23,6 +31,7 @@ export function listReturnInventoryCartons(params: Record) { } export interface ReturnInventoryCartonDetail { + skuConversions: ReturnSkuConversion[] cartonMark: string; overseasWarehouseId: string; sourceBatchNo?: string; sourceReturnNo?: string; relabelStatus?: string; relabelledQuantity?: number; relabelReservedQuantity?: number; status: string skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; receivedAt: string; items: ReturnInventoryCartonItem[] } diff --git a/src/modules/overseas-warehouse/api/warehouseServices.ts b/src/modules/overseas-warehouse/api/warehouseServices.ts index 3a78ddb..b9c35da 100644 --- a/src/modules/overseas-warehouse/api/warehouseServices.ts +++ b/src/modules/overseas-warehouse/api/warehouseServices.ts @@ -28,6 +28,7 @@ export interface RelabelDetail { export interface DiscardSummary { id: string; discardNo: string; outboundNo?: string; inboundCodes: string; overseasWarehouseId: string; overseasWarehouseName: string; totalPackages: number; totalFee: number; currency: string; status: string; createdAt: string; completedAt?: string } export interface DiscardCandidate { orderId: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON'; returnCartonId?: string; cartonMark?: string; inboundCode: string; inboundOrderNo: string; overseasWarehouseId: string; overseasWarehouseName: string; availablePackages: number; skus: Array<{ skuCode: string; productName: string; availablePackages: number }> } export interface RelabelInventoryCandidate { + skuConversions?: import('./returnInventory').ReturnSkuConversion[] stockQuantity?: number; inventoryStatus?: string; occupiedRelabelNo?: string; mergedIntoCartonMarks?: string[]; hasOutbound?: boolean inventoryBalanceId: string; inboundOrderId: string; inboundOrderNo?: string; inboundCode?: string overseasWarehouseId: string; overseasWarehouseCode?: string; overseasWarehouseName?: string diff --git a/src/modules/overseas-warehouse/views/ReturnInventoryView.vue b/src/modules/overseas-warehouse/views/ReturnInventoryView.vue index 7a936de..6148b41 100644 --- a/src/modules/overseas-warehouse/views/ReturnInventoryView.vue +++ b/src/modules/overseas-warehouse/views/ReturnInventoryView.vue @@ -90,14 +90,31 @@ const summaryColumns: DataTableColumn[] = [ { key: 'actions', label: '操作', slotName: 'actions', width: 110, fixed: 'left' }, { key: 'warehouse', label: '海外仓', slotName: 'warehouse', minWidth: 190 }, { prop: 'skuCode', label: 'SKU', minWidth: 190 }, { prop: 'productNameZh', label: '中文品名', minWidth: 190 }, - { prop: 'availableQuantity', label: '可用库存', width: 110, align: 'right' }, { prop: 'cartonQuantity', label: '所在箱数', width: 110, align: 'right' }, + { prop: 'totalQuantity', label: '在仓总库存(件)', width: 150, align: 'right' }, + { prop: 'availableQuantity', label: '可用库存', width: 110, align: 'right' }, + { prop: 'relabelLockedQuantity', label: '换标占用(件)', width: 140, align: 'right' }, + { prop: 'cartonQuantity', label: '所在箱数', width: 110, align: 'right' }, { key: 'time', label: '库存更新时间', slotName: 'time', width: 180 }, ] const cartonItemColumns: DataTableColumn[] = [ { prop: 'skuCode', label: 'SKU', minWidth: 200 }, { prop: 'productNameZh', label: '中文品名', minWidth: 240 }, { prop: 'quantity', label: '数量', width: 110, align: 'right', headerAlign: 'right' }, + { prop: 'availableQuantity', label: '可用数量(件)', width: 150, align: 'right' }, + { prop: 'relabelledQuantity', label: '已换标数量(件)', width: 160, align: 'right' }, + { prop: 'remainingRelabelQuantity', label: '剩余可换(件)', width: 150, align: 'right' }, ] +const conversionColumns: DataTableColumn[] = [ + { prop: 'cartonMark', label: '包裹 / 箱识别码', minWidth: 220 }, + { prop: 'relabelNo', label: '换标单号', minWidth: 200 }, + { prop: 'oldSkuCode', label: '旧 SKU', minWidth: 180 }, + { prop: 'newSkuCode', label: '新 SKU', minWidth: 180 }, + { prop: 'quantity', label: '实际换标数量(件)', width: 170, align: 'right' }, + { key: 'conversionStatus', label: '状态', slotName: 'conversionStatus', width: 140 }, +] +function conversionStatusLabel(status: string) { + return ({ APPLIED: '已换标,待确认', CONFIRMED: '已确认', REVERSED: '已退回冲回' } as Record)[status] || '—' +} const cartonSummaryColumns: DataTableColumn[] = [ { key: 'actions', label: '操作', slotName: 'actions', width: 150, fixed: 'left' }, { prop: 'cartonMark', label: '箱号', minWidth: 220 }, { key: 'warehouse', label: '海外仓', slotName: 'warehouse', minWidth: 190 }, { prop: 'skuTypes', label: 'SKU 种类', width: 110, align: 'right' }, @@ -111,6 +128,8 @@ const cartonColumns: DataTableColumn[] = [ { key: 'warehouse', label: '海外仓', slotName: 'warehouse', minWidth: 190 }, { prop: 'skuCode', label: 'SKU', minWidth: 180 }, { prop: 'productNameZh', label: '中文品名', minWidth: 180 }, { prop: 'quantity', label: '本箱数量', width: 110, align: 'right' }, { prop: 'cartonSkuTypes', label: 'SKU 种类', width: 110, align: 'right' }, { prop: 'cartonTotalQuantity', label: '总件数', width: 100, align: 'right' }, + { prop: 'relabelledQuantity', label: '已换标数量(件)', width: 160, align: 'right' }, + { prop: 'remainingRelabelQuantity', label: '剩余可换(件)', width: 150, align: 'right' }, { key: 'status', label: '箱状态', slotName: 'status', width: 100 }, { prop: 'sourceBatchNo', label: '签收批次', minWidth: 180 }, { key: 'time', label: '生成时间', slotName: 'time', width: 180 }, ] @@ -273,6 +292,7 @@ onMounted(() => { if (props.detailPage) void Promise.all([loadCartonDetail(), lo
{{ cartonDetail.cartonMark }}{{ warehouseLabel(cartonDetail.overseasWarehouseId) }} · 退货箱明细
{{ cartonStatusLabel(cartonDetail.status) }}打印箱唛

箱号概览

来源退货单
{{ cartonDetail.sourceReturnNo || '—' }}
签收批次
{{ cartonDetail.sourceBatchNo || '—' }}
SKU 种类
{{ cartonDetail.skuTypes }}
箱内总件数
{{ cartonDetail.totalQuantity }}
箱规
{{ cartonDetail.length && cartonDetail.width && cartonDetail.height ? `${cartonDetail.length} × ${cartonDetail.width} × ${cartonDetail.height} cm` : '—' }}
重量
{{ cartonDetail.weight ? `${cartonDetail.weight} kg` : '—' }}
换标进度
{{ returnRelabelStatusLabel(cartonDetail.relabelStatus) }}
实际完成(件)
{{ cartonDetail.relabelledQuantity ?? '—' }}
处理中占用(件)
{{ cartonDetail.relabelReservedQuantity ?? '—' }}
签收时间
{{ formatInventoryTime(cartonDetail.receivedAt) }}

箱内 SKU

+

SKU 换标明细

diff --git a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue index 915bf02..0117fcb 100644 --- a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue +++ b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue @@ -1562,15 +1562,15 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
-

仓库识别码用于定位实物,不代表商品已换标。部分数量换标须由仓库合箱为独立新箱,源包裹/箱余量保留,下游按新箱出库;整包裹可未确认仓库识别标签即出库。

+

仓库识别码用于定位实物,不代表商品已换标。SKU 换标完成后扣减旧 SKU、增加新 SKU,包裹/箱总件数不变;已换标件数不可重复申请,剩余未换标件数可继续选择。客户确认前新 SKU 保持占用。

diff --git a/src/modules/overseas-warehouse/views/returnRelabelSources.test.mjs b/src/modules/overseas-warehouse/views/returnRelabelSources.test.mjs index 481f117..ef3546c 100644 --- a/src/modules/overseas-warehouse/views/returnRelabelSources.test.mjs +++ b/src/modules/overseas-warehouse/views/returnRelabelSources.test.mjs @@ -18,11 +18,11 @@ test('实际完成和处理中占用分别展示,缺失投影保持未知', () assert.equal(exports.progress({}), '—|实际完成 — 件|处理中占用 — 件') assert.equal(exports.progress({ relabelStatus: 'PROCESSING', relabelledQuantity: 0, relabelReservedQuantity: 4 }), '处理中|实际完成 0 件|处理中占用 4 件') }) -test('退货 SKU 申请按实物箱选择,来源标识只读,部分数量独立合箱', () => { +test('退货 SKU 申请按实物箱选择,来源标识只读,部分数量转换库存', () => { assert.match(view, /item.id === rows\[0\].sourceRowId/) assert.match(view, /row.sourceRowId !== source\?\.id/) assert.match(view, /:readonly="isReturnRelabelSource"/) - assert.match(view, /部分数量换标须由仓库合箱为独立新箱/) + assert.match(view, /SKU 换标完成后扣减旧 SKU、增加新 SKU/) assert.match(view, /labelingMode.value === 'sku' \? !skuTotalFile.value : !cartonTotalFile.value/) }) @@ -63,3 +63,16 @@ test('包裹箱唛添加保持一个实物并去重,三种类型不再禁用', assert.doesNotMatch(view, /returnSkuOnly|散件库存只能贴 SKU/) assert.match(view, /labelingMode === 'sku' \? addReturnSkuBinding\(source, sku.skuCode\) : addReturnBoxBinding\(source\)/) }) + +test('部分换标明细显示实物来源、旧新SKU、实际件数和待确认状态', () => { + for (const id of ['return-available-skus', 'return-available-boxes']) { + const start = view.indexOf('id="' + id + '"') + const table = view.slice(start, view.indexOf('', start)) + assert.match(table, /SKU 换标结果/) + assert.match(table, /剩余可换(件)/) + assert.match(table, /conversion.oldSkuCode/) + assert.match(table, /conversion.newSkuCode/) + assert.match(table, /conversion.quantity/) + assert.match(table, /item.status !== 'REVERSED'/) + } +}) -- 2.54.0 From aaab5041f9866c10ca082958481942381be61607 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 12 Oct 2026 21:48:28 +0800 Subject: [PATCH 05/59] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../utils/returnReceiptDisplay.test.mjs | 10 ++++++ .../utils/returnReceiptDisplay.ts | 7 +++- .../views/ReturnOrdersView.test.mjs | 22 ++++++++++++ .../views/ReturnOrdersView.vue | 36 +++++++++++-------- .../views/WarehouseServiceView.vue | 21 ++++++----- .../views/returnRelabelSources.test.mjs | 16 +++++++-- 6 files changed, 87 insertions(+), 25 deletions(-) diff --git a/src/modules/overseas-warehouse/utils/returnReceiptDisplay.test.mjs b/src/modules/overseas-warehouse/utils/returnReceiptDisplay.test.mjs index 34d1957..8e6d370 100644 --- a/src/modules/overseas-warehouse/utils/returnReceiptDisplay.test.mjs +++ b/src/modules/overseas-warehouse/utils/returnReceiptDisplay.test.mjs @@ -21,6 +21,16 @@ test('volume rounds upward to exactly three decimal places without increasing ex } }) +test('箱子在签收箱号明细展示一次,真实托盘和缺少箱号明细的实物仍保留', () => { + const boxes = Array.from({ length: 6 }, (_, index) => ({ cartonMark: `RC-${index}` })) + const units = boxes.map(box => ({ unitType: 'BOX', unitCode: box.cartonMark, palletCode: 'PLT-SHARED' })) + assert.deepEqual(exports.receiptPhysicalDetails(units, boxes), []) + const pallet = { unitType: 'PALLET', unitCode: 'PLT-SHARED', palletCode: 'PLT-SHARED' } + const unmatchedBox = { unitType: 'BOX', unitCode: 'RC-UNKNOWN', palletCode: 'PLT-SHARED' } + assert.deepEqual(exports.receiptPhysicalDetails([...units, pallet, unmatchedBox], boxes), [pallet, unmatchedBox]) + assert.deepEqual(exports.receiptPhysicalDetails(units, []), units) +}) + test('parcel waybills appear as tracking numbers while generated parcel codes remain physical codes', () => { assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'sf561626' }), 'sf561626') assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'PK0123456789ABCDEF' }), '') diff --git a/src/modules/overseas-warehouse/utils/returnReceiptDisplay.ts b/src/modules/overseas-warehouse/utils/returnReceiptDisplay.ts index 4dcddc2..48e90eb 100644 --- a/src/modules/overseas-warehouse/utils/returnReceiptDisplay.ts +++ b/src/modules/overseas-warehouse/utils/returnReceiptDisplay.ts @@ -1,4 +1,9 @@ -import type { ReturnReceipt, ReturnReceiptCarton } from '../api/returnOrders' +import type { ReturnPallet, ReturnReceipt, ReturnReceiptCarton } from '../api/returnOrders' + +export function receiptPhysicalDetails(pallets: ReturnPallet[], boxes: ReturnReceiptCarton[]) { + const signedBoxMarks = new Set(boxes.map(box => box.cartonMark)) + return pallets.filter(unit => unit.unitType !== 'BOX' || !signedBoxMarks.has(unit.unitCode || unit.palletCode)) +} export function receiptBoxDetails(receipts: Pick[], cartons: ReturnReceiptCarton[]) { const boxReceiptNos = new Set(receipts.filter(receipt => receipt.unitType === 'BOX' || receipt.unitType === 'PALLET').map(receipt => receipt.receiptNo)) diff --git a/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs b/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs index 5cd3765..3def722 100644 --- a/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs +++ b/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs @@ -144,6 +144,28 @@ function printHarness() { return { print, calls } } +test('包裹唛头可按实物识别码打印,不把快递单号或商品件数当作唛头', async () => { + const { print, calls } = printHarness() + await print({ unitType: 'PARCEL', unitCode: 'PK-PARCEL-1', trackingNo: 'SF-WAYBILL', receivedQuantity: 20 }) + assert.deepEqual(calls.fetched, ['PK-PARCEL-1']) + assert.ok(calls.html.includes('data-barcode="PK-PARCEL-1"')) + assert.equal((calls.html.match(/
/g) || []).length, 1) + assert.equal(calls.printed, 1) + assert.deepEqual(calls.errors, []) +}) + +test('批量包裹唛头按包裹分页、去重并保留服务端条码模板', async () => { + const { print, calls } = printHarness() + const parcels = ['PK-PARCEL-1', 'PK-PARCEL-2'].map(mark => ({ unitType: 'PARCEL', unitCode: mark, + receivedQuantity: 3, labels: [{ cartonMark: mark, printableHtml: labelHtml(mark) }] })) + await print([...parcels, parcels[0]]) + assert.equal((calls.html.match(/
/g) || []).length, 2) + assert.ok(calls.html.includes('data-barcode="PK-PARCEL-1"')) + assert.ok(calls.html.includes('data-barcode="PK-PARCEL-2"')) + assert.deepEqual(calls.fetched, []) + assert.equal(calls.printed, 1) +}) + test('退货箱唛打印保留服务端整份模板,不另行覆盖纸张或条码', async () => { const { print, calls } = printHarness() const printableHtml = labelHtml('RETURN-TEST-1') diff --git a/src/modules/overseas-warehouse/views/ReturnOrdersView.vue b/src/modules/overseas-warehouse/views/ReturnOrdersView.vue index 99afafb..34380cf 100644 --- a/src/modules/overseas-warehouse/views/ReturnOrdersView.vue +++ b/src/modules/overseas-warehouse/views/ReturnOrdersView.vue @@ -11,7 +11,7 @@ import { getMerchantWarehouses, type MerchantWarehouse } from '@/modules/first-l import { createIdempotencyKey } from '@/utils/idempotencyKey' import { createReturnOrder, downloadReturnPhoto, getReturnOrder, listReturnOrders, updateReturnOrder, type ReturnOrderDetail, type ReturnOrderSummary, type ReturnReceipt } from '../api/returnOrders' import { getReturnInventoryLabel } from '../api/returnInventory' -import { formatReturnVolume, receiptBoxDetails, receiptTrackingNo } from '../utils/returnReceiptDisplay' +import { formatReturnVolume, receiptBoxDetails, receiptPhysicalDetails, receiptTrackingNo } from '../utils/returnReceiptDisplay' const props = withDefaults(defineProps<{ createPage?: boolean; detailPage?: boolean; returnNo?: string }>(), { createPage: false, detailPage: false, returnNo: '' }) const router = useRouter() @@ -28,8 +28,13 @@ const selected = ref() const showCartons = ref(false) const parcelDetailsExpanded = ref(true) const receiptBatchesExpanded = ref(true) -const signedBoxDetails = computed(() => receiptBoxDetails(selected.value?.receipts || [], selected.value?.cartons || [])) +const signedBoxDetails = computed(() => receiptBoxDetails(selected.value?.receipts || [], selected.value?.cartons || []).map(box => ({ + ...box, + measurements: selected.value?.pallets.find(unit => unit.unitType === 'BOX' && (unit.unitCode || unit.palletCode) === box.cartonMark), +}))) +const physicalDetails = computed(() => receiptPhysicalDetails(selected.value?.pallets || [], signedBoxDetails.value)) const printableReceipts = computed(() => selected.value?.receipts.filter(item => item.unitType !== 'PARCEL' && (item.cartonMark || item.labels?.length)) || []) +const parcelReceipts = computed(() => selected.value?.receipts.filter(item => item.unitType === 'PARCEL') || []) const printableBoxCount = computed(() => printableReceipts.value.reduce((total, receipt) => total + (receipt.labels?.length || receipt.boxCount || 1), 0)) const photoUrls = reactive>({}) const rows = ref([]) @@ -190,11 +195,13 @@ async function printReturnLabel(receipt: ReturnReceipt | ReturnReceipt[]) { if (!popup) { ElMessage.warning('请允许浏览器打开打印窗口'); return } popup.opener = null try { - const groups = await Promise.all(receipts.map(async item => item.labels?.length - ? await Promise.all(item.labels.map(async label => ({ ...label, printableHtml: label.printableHtml || (await getReturnInventoryLabel(label.cartonMark)).printableHtml }))) - : item.cartonMark ? [{ cartonMark: item.cartonMark, printableHtml: item.printableHtml || (await getReturnInventoryLabel(item.cartonMark)).printableHtml }] : [])) + const groups = await Promise.all(receipts.map(async item => { + if (item.labels?.length) return await Promise.all(item.labels.map(async label => ({ ...label, printableHtml: label.printableHtml || (await getReturnInventoryLabel(label.cartonMark)).printableHtml }))) + const mark = item.cartonMark || (item.unitType === 'PARCEL' ? item.unitCode : undefined) + return mark ? [{ cartonMark: mark, printableHtml: item.printableHtml || (await getReturnInventoryLabel(mark)).printableHtml }] : [] + })) const labels = [...new Map(groups.flat().map(label => [label.cartonMark, label])).values()] - if (!labels.length) { popup.close(); ElMessage.warning('暂无可打印的箱唛'); return } + if (!labels.length) { popup.close(); ElMessage.warning('暂无可打印的唛头'); return } const printStyles = [...new Set(labels.flatMap(label => label.printableHtml.match(/]*>[\s\S]*?<\/style>/gi) || []))].join('') const printableHtml = (receipts.length === 1 ? receipts[0]?.printableHtml : undefined) || `退货箱唛${printStyles}${labels.map((label) => { const html = label.printableHtml || '' @@ -211,7 +218,7 @@ async function printReturnLabel(receipt: ReturnReceipt | ReturnReceipt[]) { popup.print() } catch (error) { popup.close() - ElMessage.error(error instanceof Error ? error.message : '箱唛加载失败') + ElMessage.error(error instanceof Error ? error.message : '唛头加载失败') } } function openDetail(returnNo: string) { void router.push({ name: 'overseas-return-relabel-detail', params: { returnNo } }) } @@ -383,18 +390,18 @@ onBeforeUnmount(clearPhotoUrls)
-

包裹签收明细{{ parcelDetailsExpanded ? '收起明细' : '展开明细' }}

+

包裹签收明细打印全部包裹唛头({{ parcelReceipts.length }} 个){{ parcelDetailsExpanded ? '收起明细' : '展开明细' }}

-
+
包裹 {{ receipt.unitCode || receipt.trackingNo || receipt.receiptNo }}{{ receipt.receivedQuantity }} 件
{{ item.skuCode }}{{ item.productNameZh || '中文品名未填写' }}{{ item.quantity }} 件
-
-

实物明细({{ selected.pallets?.length || 0 }} 个托盘/箱)打印全部箱唛({{ printableBoxCount }} 箱)

- - +
+

实物明细({{ physicalDetails.length }} 个托盘/箱)打印全部箱唛({{ printableBoxCount }} 箱)

+ +
长 {{ pallet.length ?? '—' }} cm · 宽 {{ pallet.width ?? '—' }} cm · 高 {{ pallet.height ?? '—' }} cm · 重量 {{ pallet.weight ?? '—' }} kg
@@ -408,13 +415,14 @@ onBeforeUnmount(clearPhotoUrls)
-

签收箱号明细({{ signedBoxDetails.length }} 箱)查看箱号明细

+

签收箱号明细({{ signedBoxDetails.length }} 箱)打印全部箱唛({{ printableBoxCount }} 箱)查看箱号明细

签收单 {{ box.receiptNo }}
+
长 {{ box.measurements.length ?? '—' }} cm · 宽 {{ box.measurements.width ?? '—' }} cm · 高 {{ box.measurements.height ?? '—' }} cm · 重量 {{ box.measurements.weight ?? '—' }} kg · 体积 {{ formatReturnVolume(box.measurements.volumeCbm) }} m³
diff --git a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue index 0117fcb..3430059 100644 --- a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue +++ b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue @@ -32,7 +32,7 @@ type PageMode = 'list' | 'create' | 'edit' | 'detail' type LabelingMode = 'carton' | 'sku' | 'carton_and_sku' type RelabelTemplateType = 'carton' | 'sku' | 'mixed' interface CargoRow { id: number; included?: boolean; sku: string; name: string; quantity: number; availableQuantity?: number; sourceOrderId?: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON' | 'RETURN_PARCEL'; sourceInventoryBalanceId?: string; sourceInventoryPositionId?: string; sourceInboundOrderId?: string; returnCartonId?: string; sourceInboundOrderNo?: string; sourceInboundCode?: string; sourceWarehouseName?: string; palletNo?: string; warehouseLocationCode?: string; fnsku: string; shipmentNo: string; newSkuCode: string; shippingMark: string; customerCartonMarks: string; cartonMark: string; fbaCartonMark: string; destinationWarehouseId: string; remark: string } -interface SkuRelabelRow { id: number; sourceRowId: number | null; cartonMark: string; originalSku: string; newSku: string; quantity: number; fbaCartonMark: string; destinationWarehouseId: string; remark: string } +interface SkuRelabelRow { id: number; sourceRowId: number | null; cartonMark: string; originalSku: string; newSku: string; quantity: number; fbaCartonMark: string; shipmentNo: string; destinationWarehouseId: string; remark: string } interface ImportedRelabelBinding extends SkuRelabelRow { templateType: RelabelTemplateType; selected: boolean; shipmentNo: string } interface ServiceConfig { title: string; createTitle: string; numberLabel: string; emptyText: string; columns: string[] } interface TransferCarton { cartonMark: string; customerCartonMark?: string; cartonIndex: number; totalCartons: number; status: string } @@ -522,7 +522,7 @@ function addSkuRelabelRow() { const source = cargoRows.value[Math.min(skuRelabelRows.value.length, Math.max(0, cargoRows.value.length - 1))] skuRelabelRows.value.push({ id: ++skuRelabelSequence, sourceRowId: source?.id || null, cartonMark: '', originalSku: source?.sku || '', newSku: '', quantity: 1, - fbaCartonMark: '', destinationWarehouseId: form.destination, remark: '' }) + fbaCartonMark: '', shipmentNo: '', destinationWarehouseId: form.destination, remark: '' }) } function removeSkuRelabelRow(id: number) { skuRelabelRows.value = skuRelabelRows.value.filter((row) => row.id !== id) } function applyExistingSkuBinding(row: SkuRelabelRow) { @@ -539,6 +539,7 @@ function applyExistingCartonBinding(row: SkuRelabelRow) { && item.cartonMark.trim().toUpperCase() === cartonMark && item.fbaCartonMark.trim()) if (!mapping) return if (!row.fbaCartonMark.trim()) row.fbaCartonMark = mapping.fbaCartonMark + if (!row.shipmentNo.trim()) row.shipmentNo = mapping.shipmentNo if (!row.destinationWarehouseId) row.destinationWarehouseId = mapping.destinationWarehouseId } function selectShippingLabel(event: Event) { shippingLabelFile.value = (event.target as HTMLInputElement).files?.[0] || null } @@ -597,10 +598,14 @@ function buildSkuRelabelItems(): RelabelCreate['items'] { const cartonMark = rows[0].cartonMark.trim() const changeCarton = labelingMode.value === 'carton_and_sku' const fbaCartonMark = rows[0].fbaCartonMark.trim() + const shipmentNo = rows[0].shipmentNo.trim() if (changeCarton && (!fbaCartonMark || !form.destination || rows.some((row) => row.fbaCartonMark.trim() !== fbaCartonMark))) { throw new Error('同一我方箱唛的组合换标必须使用相同的新 FBA 箱唛,并选择整票 FBA 目的仓') } + if (changeCarton && (!shipmentNo || rows.some(row => row.shipmentNo.trim() !== shipmentNo))) { + throw new Error('同一我方箱唛的组合换标必须填写相同的 FBA 货件编号') + } return { sourceInventoryBalanceId: source.sourceInventoryBalanceId, sourceInventoryPositionId: source.sourceInventoryPositionId, @@ -610,7 +615,7 @@ function buildSkuRelabelItems(): RelabelCreate['items'] { productNameZh: source.name, totalPackageCount: 1, notifiedPackageCount: 1, - shipmentNo: source.shipmentNo || cartonMark, + shipmentNo: changeCarton ? shipmentNo : source.shipmentNo || cartonMark, trackingCode: form.trackingNo || undefined, newSkuCode: rows[0].newSku, labelRequirement: form.requirement || undefined, @@ -818,7 +823,7 @@ function importSelectedBindingsToDetails() { } labelingMode.value = 'carton' } else { - const importedRows = selected.map(({ templateType: _, selected: __, shipmentNo, ...item }) => item) + const importedRows = selected.map(({ templateType: _, selected: __, ...item }) => item) for (const item of importedRows) { skuRelabelRows.value = skuRelabelRows.value.filter((row) => !(row.cartonMark.trim().toUpperCase() === item.cartonMark.trim().toUpperCase() && row.originalSku.trim().toUpperCase() === item.originalSku.trim().toUpperCase())) @@ -1005,7 +1010,7 @@ async function submit(action: 'draft' | 'submit' = 'submit') { || cargoRows.value.some((item) => item.quantity < 0 || item.quantity > Number(item.availableQuantity || 0)) : (props.service === 'labeling' || props.service === 'relabel') && (relabelRows.length === 0 || (props.service === 'labeling' && labelingMode.value !== 'carton' ? skuRelabelRows.value.some((item) => !item.cartonMark || !item.originalSku || !item.newSku || item.quantity < 1 - || (labelingMode.value === 'carton_and_sku' && !item.fbaCartonMark)) + || (labelingMode.value === 'carton_and_sku' && (!item.fbaCartonMark || !item.shipmentNo.trim()))) : cargoRows.value.some((item) => (props.service !== 'labeling' && !item.sku) || !item.name || (props.service !== 'labeling' && item.quantity < 1)))) if ((props.service === 'relabel' && !form.inboundNo) || !primaryWarehouse || invalidCargo) { @@ -1452,7 +1457,7 @@ function addReturnSkuBinding(source: RelabelInventoryCandidate, skuCode: string) const row = skuRelabelRows.value.find(item => item.cartonMark === source.cartonMark && item.originalSku === skuCode) if (row) return const sourceRow = cargoRows.value.find(item => item.returnCartonId === String(source.returnCartonId)) - skuRelabelRows.value.push({ id: ++skuRelabelSequence, sourceRowId: sourceRow?.id || null, cartonMark: source.cartonMark || '', originalSku: skuCode, newSku: '', quantity: Math.max(1, Number(sku.quantity)), fbaCartonMark: '', destinationWarehouseId: '', remark: '' }) + skuRelabelRows.value.push({ id: ++skuRelabelSequence, sourceRowId: sourceRow?.id || null, cartonMark: source.cartonMark || '', originalSku: skuCode, newSku: '', quantity: Math.max(1, Number(sku.quantity)), fbaCartonMark: '', shipmentNo: '', destinationWarehouseId: '', remark: '' }) } function addReturnBoxBinding(source: RelabelInventoryCandidate) { if (labelingMode.value !== 'carton') { @@ -1500,7 +1505,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) { - +
*入仓方式仓库上门提货客户送货到仓
@@ -1591,7 +1596,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
添加 SKU 明细清空
商品 SKU中文品名 *FBA 货件编号 *箱数仓库唛头 *新 FBA 箱唛 *备注操作
1 箱
暂无本次换标明细,请上传关系后导入或手动添加
-
仓库唛头 *旧 SKU *新 SKU *换标数量(件)*新 FBA 箱唛 *备注操作
暂无绑定明细,请点击“添加 SKU 明细”
+
仓库唛头 *旧 SKU *新 SKU *换标数量(件)*新 FBA 箱唛 *FBA 货件编号 *备注操作
暂无绑定明细,请点击“添加 SKU 明细”
${labels.map((label) => { + const printableHtml = (receipts.length === 1 && !receipts[0]?.labels?.length ? receipts[0]?.printableHtml : undefined) || `退货箱唛${printStyles}${labels.map((label) => { const html = label.printableHtml || '' const bodyStart = html.indexOf('', bodyStart) + 1 -- 2.54.0 From d67da9af823253da894d6058e5fa2f8f3ddcd8b6 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 14 Oct 2026 11:43:38 +0800 Subject: [PATCH 17/59] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../overseas-warehouse/api/returnOrders.ts | 5 +-- .../api/warehouseServices.ts | 5 +++ .../views/ReturnOrdersView.test.mjs | 4 ++- .../views/ReturnOrdersView.vue | 30 ++++++++++++++-- .../views/WarehouseServiceView.vue | 35 ++++++++++++++++--- .../views/discardPhotos.test.mjs | 29 +++++++++++++++ .../views/warehouseTransferForm.test.mjs | 2 +- 7 files changed, 99 insertions(+), 11 deletions(-) create mode 100644 src/modules/overseas-warehouse/views/discardPhotos.test.mjs diff --git a/src/modules/overseas-warehouse/api/returnOrders.ts b/src/modules/overseas-warehouse/api/returnOrders.ts index 521852b..e08bfca 100644 --- a/src/modules/overseas-warehouse/api/returnOrders.ts +++ b/src/modules/overseas-warehouse/api/returnOrders.ts @@ -18,9 +18,10 @@ export interface ReturnReceipt { } export interface ReturnPallet { palletCode: string; unitType: 'PALLET' | 'BOX'; unitCode: string; skuTypes: number; totalQuantity: number; totalBoxCount: number; length?: number; width?: number; height?: number; weight?: number; volumeCbm?: number; items: Array<{ skuCode: string; productNameZh?: string; quantity: number; boxCount: number }> } export interface ReturnPhoto { fileId: number; fileName: string; contentType: string; sizeBytes: number } -export interface ReturnPackingDetail { sourceCartonMark: string; targetCartonMark: string; returnNo: string; skuCode: string; quantity: number; createdAt: string } +export interface ReturnPackingDetail { planNo?: string; sourceCartonMark: string; targetCartonMark: string; returnNo: string; skuCode: string; quantity: number; createdAt: string } +export interface CurrentReturnCarton { carton: import('./returnInventory').ReturnInventoryCartonDetail; ownership: Array<{ returnNo: string; skuCode: string; quantity: number }>; orderQuantity: number; labelStatus: string; canRelabel: boolean; blockReason?: string } export interface ReturnReceiptCarton { receiptNo: string; palletCode?: string; cartonMark: string; countStatus: 'PENDING' | 'CONFIRMED' | 'UNRECORDED'; countedAt?: string; items: ReturnReceipt['items']; length?: number | null; width?: number | null; height?: number | null; weight?: number | null } -export interface ReturnOrderDetail { order: ReturnOrderSummary; remark?: string; items: ReturnOrderItem[]; receipts: ReturnReceipt[]; pallets: ReturnPallet[]; photos: ReturnPhoto[]; packingDetails?: ReturnPackingDetail[]; cartons?: ReturnReceiptCarton[] } +export interface ReturnOrderDetail { order: ReturnOrderSummary; remark?: string; items: ReturnOrderItem[]; receipts: ReturnReceipt[]; pallets: ReturnPallet[]; photos: ReturnPhoto[]; packingDetails?: ReturnPackingDetail[]; cartons?: ReturnReceiptCarton[]; currentCartons?: CurrentReturnCarton[] } export interface ReturnOrderCreate { overseasWarehouseId: string; warehouseContactName: string; warehouseContactPhone: string; customerReferenceNo?: string; remark?: string; needRelabel: boolean; idempotencyKey: string items: Array<{ skuCode: string; productNameZh?: string; expectedQuantity: number }> diff --git a/src/modules/overseas-warehouse/api/warehouseServices.ts b/src/modules/overseas-warehouse/api/warehouseServices.ts index 6381c10..6a3ca8f 100644 --- a/src/modules/overseas-warehouse/api/warehouseServices.ts +++ b/src/modules/overseas-warehouse/api/warehouseServices.ts @@ -133,3 +133,8 @@ export function createDiscard(payload: { items: Array<{ orderId?: string; return export function cancelDiscard(discardNo: string) { return apiRequest>({ method: 'POST', url: `/merchant/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/cancel` }) } + +export interface DiscardPhoto { fileId: string; palletNo: string; fileName: string; createdAt: string; submittedAt: string } +export function downloadDiscardPhoto(discardNo: string, fileId: string) { + return apiBlobRequest({ method: 'GET', url: `/merchant/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/photos/files/${encodeURIComponent(fileId)}/download` }) +} diff --git a/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs b/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs index 0a515fa..a776c14 100644 --- a/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs +++ b/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs @@ -252,7 +252,9 @@ test('待审核退货可编辑,完成入库后从独立新增页发起贴标', assert.match(view, /else await createReturnOrder\(payload\)/) assert.match(view, /order\.status === 'COMPLETED' && order\.needRelabel === true/) assert.match(view, /name: 'overseas-labeling-create'/) - assert.match(view, /query: \{ returnNo: order\.returnNo, warehouseId: String\(order\.overseasWarehouseId\) \}/) + assert.match(view, /query: \{ returnNo: order\.returnNo, warehouseId: String\(order\.overseasWarehouseId\)/) + assert.match(view, /cartonMark \? \{ cartonMark \}/) + assert.match(view, /openRelabel\(selected\.order, current\.carton\.cartonMark\)/) assert.match(view, /v-for="application in row\.relabelApplications \|\| \[\]"/) assert.match(view, /relabelStatusLabel\(application\.status\)/) }) diff --git a/src/modules/overseas-warehouse/views/ReturnOrdersView.vue b/src/modules/overseas-warehouse/views/ReturnOrdersView.vue index 38527ae..12066c6 100644 --- a/src/modules/overseas-warehouse/views/ReturnOrdersView.vue +++ b/src/modules/overseas-warehouse/views/ReturnOrdersView.vue @@ -226,8 +226,8 @@ function openDetail(returnNo: string) { void router.push({ name: 'overseas-retur function returnToList() { void router.push({ name: 'overseas-return-relabel' }) } function cancelCreate() { resetForm(); void router.push({ name: 'overseas-return-relabel' }) } function canApplyRelabel(order: { status?: unknown; needRelabel?: unknown; relabelNo?: unknown }) { return order.status === 'COMPLETED' && order.needRelabel === true && !order.relabelNo } -function openRelabel(order: Pick) { - void router.push({ name: 'overseas-labeling-create', query: { returnNo: order.returnNo, warehouseId: String(order.overseasWarehouseId) } }) +function openRelabel(order: Pick, cartonMark?: string) { + void router.push({ name: 'overseas-labeling-create', query: { returnNo: order.returnNo, warehouseId: String(order.overseasWarehouseId), ...(cartonMark ? { cartonMark } : {}) } }) } async function submit() { const items = form.items.map(({ skuCode, productNameZh, expectedQuantity }) => ({ skuCode: skuCode.trim(), productNameZh: productNameZh.trim(), expectedQuantity })) @@ -442,6 +442,32 @@ onBeforeUnmount(clearPhotoUrls)
+
+

合箱记录

+ + + + + + + +

当前关联实物箱

+ + + +

来源退货单:{{ current.carton.sourceReturnNo }} · 我方箱唛:{{ current.labelStatus === 'CONFIRMED' ? '已确认贴标' : '待贴标确认' }}

+ + + + + +

换箱唛以当前实物箱为单位;跨来源单同 SKU 的部分贴标须先现场分拣确认。

+ 选择此箱贴标 +

{{ current.blockReason }}

+
+
+ +

退货打托照片({{ selected.photos?.length || 0 }} 张)

diff --git a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue index 5180e43..53b9021 100644 --- a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue +++ b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue @@ -11,7 +11,7 @@ import { returnRelabelCandidate } from '../utils/returnRelabelCandidates' import { createDiscard, createRelabel, createTransfer, getDiscard, getRelabel, getTransfer, resubmitRelabel, getTransferCartonLabel, submitTransfer, updateTransfer, - confirmRelabelCompletion, confirmTransferMeasurement, downloadRelabelCompletionPhoto, downloadTransferMeasurementPhoto, uploadRelabelShippingLabel, + confirmRelabelCompletion, confirmTransferMeasurement, downloadRelabelCompletionPhoto, downloadTransferMeasurementPhoto, downloadDiscardPhoto, type DiscardPhoto, uploadRelabelShippingLabel, uploadRelabelFile, listDiscards, listRelabelInventoryCandidates, listRelabelHistoricalOrders, listRelabels, listTransfers, cancelDiscard, type RelabelInventoryCandidate, @@ -119,6 +119,7 @@ const editingTransferNo = ref('') interface TransferPallet { id?: string | number; sequenceNo?: number; palletNo?: string; warehouseLocationCode?: string; cartonCount?: number; lengthCm?: number; widthCm?: number; heightCm?: number; grossWeightKg?: number; volumeCbm?: number; status?: string; completedAt?: string } interface TransferMeasurementPhoto { fileId: string | number; fileName?: string; contentType?: string; sizeBytes?: number; uploadedAt?: string } interface ServiceDetail extends Record { + discardNo?: string; photos?: DiscardPhoto[] transferNo?: string; status?: string; operatingWarehouseId?: string; fbaWarehouseCode?: string destinationType?: 'NONE' | 'FBA' | 'PRIVATE'; destinationWarehouseCode?: string; destinationWarehouseName?: string privateDestinationAddress?: string; privateDestinationPostalCode?: string; privateDestinationContactName?: string; privateDestinationContactPhone?: string @@ -146,6 +147,21 @@ const measurementPhotoLoading = reactive>({}) const measurementPhotoErrors = reactive>({}) const measurementPhotoPreviewUrls = computed(() => (selectedDetail.value?.measurementPhotos || []) .map((photo) => measurementPhotoUrls[photo.fileId]).filter(Boolean)) +const discardPhotoUrls = reactive>({}) +const discardPhotoLoading = reactive>({}) +const discardPhotoErrors = reactive>({}) +const discardPhotoPreviewUrls = computed(() => (selectedDetail.value?.photos || []).map(photo => discardPhotoUrls[photo.fileId]).filter(Boolean)) +let discardPhotoGeneration = 0 +function clearDiscardPhotos() { discardPhotoGeneration++; Object.values(discardPhotoUrls).forEach(URL.revokeObjectURL); for (const map of [discardPhotoUrls, discardPhotoLoading, discardPhotoErrors]) Object.keys(map).forEach(key => delete map[key]) } +async function loadDiscardPhoto(fileId: string) { + const current = selectedDetail.value; const generation = discardPhotoGeneration + if (!current?.discardNo || discardPhotoLoading[fileId]) return + discardPhotoLoading[fileId] = true; discardPhotoErrors[fileId] = false + try { const blob = await downloadDiscardPhoto(current.discardNo, fileId); if (generation !== discardPhotoGeneration || selectedDetail.value !== current || mode.value !== 'detail') return; if (discardPhotoUrls[fileId]) URL.revokeObjectURL(discardPhotoUrls[fileId]); discardPhotoUrls[fileId] = URL.createObjectURL(blob) } + catch { if (generation === discardPhotoGeneration) discardPhotoErrors[fileId] = true } + finally { if (generation === discardPhotoGeneration) discardPhotoLoading[fileId] = false } +} +async function loadDiscardPhotos() { clearDiscardPhotos(); await Promise.all((selectedDetail.value?.photos || []).map(photo => loadDiscardPhoto(photo.fileId))) } const fbaWarehouses = ref([]) const operationWarehouses = ref([]) const fbaWarehousesLoading = ref(false) @@ -202,7 +218,10 @@ const selectedTransferSourceOrderIds = ref([]) const isReturnRelabelSource = computed(() => props.service === 'labeling' && selectedRelabelInboundOrderId.value.startsWith('RETURN_ORDER:')) const selectedReturnOrderNo = computed(() => isReturnRelabelSource.value ? selectedRelabelInboundOrderId.value.slice('RETURN_ORDER:'.length) : '') const currentRelabelCandidates = computed(() => relabelCandidates.value.map(item => returnRelabelCandidate(item, labelingMode.value))) -const selectedReturnCandidates = computed(() => currentRelabelCandidates.value.filter(item => Boolean(item.returnCartonId) && returnSourceNos(item).includes(selectedReturnOrderNo.value))) +const returnCartonQuery = ref(String(route.query.cartonMark || '')) +const selectedReturnCandidates = computed(() => currentRelabelCandidates.value.filter(item => Boolean(item.returnCartonId) + && returnSourceNos(item).includes(selectedReturnOrderNo.value) + && (!returnCartonQuery.value.trim() || (item.cartonMark || '').toUpperCase().includes(returnCartonQuery.value.trim().toUpperCase())))) const availableReturnCandidates = computed(() => selectedReturnCandidates.value.filter(item => inventoryQuantity(item.availablePackages) > 0).map(item => ({ ...item, skuItems: (item.skuItems || []).filter(sku => inventoryQuantity(sku.quantity) > 0) }))) const returnBoxCandidates = computed(() => availableReturnCandidates.value.filter(item => item.sourceType === 'RETURN_CARTON')) const returnLooseCandidates = computed(() => availableReturnCandidates.value.filter(item => item.sourceType === 'RETURN_PARCEL')) @@ -221,7 +240,7 @@ function returnRelabelProgress(candidate: RelabelInventoryCandidate) { } function returnInventoryState(candidate: RelabelInventoryCandidate) { const labels: Record = { AVAILABLE: '可选库存', RELABEL_PENDING: '换标占用', PACKING_RESERVED: '合箱计划预留', OUTBOUND_PENDING: '出库占用', DISCARD_PENDING: '废弃申请占用', OUTBOUNDED: '已出库', MERGED: '已合箱' } - return [...(candidate.mergedIntoCartonMarks?.length ? ['历史部分转入 ' + candidate.mergedIntoCartonMarks.join('、') + '(不代表当前库存位置)'] : []), labels[candidate.inventoryStatus || ''] || (inventoryQuantity(candidate.availablePackages) > 0 ? '可选库存' : '当前不可选'), candidate.occupiedRelabelNo].filter(Boolean).join(' · ') + return [...(returnSourceNos(candidate).length > 1 ? ['跨来源单合箱,数量为整箱内容'] : []), ...(candidate.mergedIntoCartonMarks?.length ? ['历史部分转入 ' + candidate.mergedIntoCartonMarks.join('、') + '(不代表当前库存位置)'] : []), labels[candidate.inventoryStatus || ''] || (inventoryQuantity(candidate.availablePackages) > 0 ? '可选库存' : '当前不可选'), candidate.occupiedRelabelNo].filter(Boolean).join(' · ') } const relabelInboundOptions = computed(() => { const grouped = new Map() @@ -278,7 +297,7 @@ watch(() => [props.service, props.createPage, props.detailPage, detailNumber.val else if (props.detailPage && detailNumber.value) void loadDetailPage() else void loadPage() }) -watch(mode, (value) => { if (value !== 'detail') { clearCompletionPhotos(); clearMeasurementPhotos() } }) +watch(mode, (value) => { if (value !== 'detail') { clearCompletionPhotos(); clearMeasurementPhotos(); clearDiscardPhotos() } }) watch(() => form.relabel, (value) => { if (props.service === 'transfer' && value) { labelingMode.value = 'carton_and_sku' @@ -321,7 +340,7 @@ onMounted(() => { else void loadPage() void nextTick(refreshWarehouseMarkLabels) }) -onBeforeUnmount(() => { clearCompletionPhotos(); clearMeasurementPhotos() }) +onBeforeUnmount(() => { clearCompletionPhotos(); clearMeasurementPhotos(); clearDiscardPhotos() }) function resetFilters() { Object.assign(filters, { number: '', inboundNo: '', warehouse: '', destination: '', status: '', start: '', end: '', reviewStart: '', reviewEnd: '', completedStart: '', completedEnd: '', measuredStart: '', measuredEnd: '', outboundStart: '', outboundEnd: '' }) } function resetForm() { orderInfoCollapsed.value = false @@ -1186,6 +1205,7 @@ async function openDetail(value: Record) { mode.value = 'detail' if (props.service === 'labeling') void loadCompletionPhotos() if (props.service === 'transfer') void loadMeasurementPhotos() + if (props.service === 'disposal') void loadDiscardPhotos() } catch (error) { selectedDetail.value = null; selectedRelabelDetail.value = null; detailError.value = error instanceof Error ? error.message : '详情加载失败'; mode.value = 'detail'; ElMessage.error(detailError.value) } } async function loadDetailPage() { @@ -1587,6 +1607,8 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {

仓库识别码用于定位实物,不代表商品已换标。SKU 换标完成后扣减旧 SKU、增加新 SKU,包裹/箱总件数不变;已换标件数不可重复申请,剩余未换标件数可继续选择。客户确认前新 SKU 保持占用。

+ +

来源退货单用于查找;贴标选择当前实物箱。跨来源单箱内数量为整箱内容,同 SKU 部分贴标请先现场分拣确认。

2换标申请

-
+
换标类型只换箱唛只换 SKU 标签SKU 和箱唛都换
@@ -1682,6 +1714,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {

订单概览

操作仓{{ warehouseDisplay(selectedRelabelDetail?.application.overseasWarehouseId) }}
客户参考号{{ selectedRelabelDetail?.application.customerOrderNo || '—' }}
SKU 数(种){{ selectedRelabelDetail ? relabelSkuCount(selectedRelabelDetail.items) : '—' }}
总箱数{{ selectedRelabelDetail?.application.totalPackageCount ?? '—' }}
贴标截止时间{{ formatDateTime(selectedRelabelDetail?.application.labelDeadline) }}
仓库联系人{{ selectedRelabelDetail?.application.contactName || '—' }}
联系电话{{ selectedRelabelDetail?.application.contactPhone || '—' }}
创建时间{{ formatDateTime(selectedRelabelDetail?.application.createdAt) }}
状态{{ statusLabel(selectedDetail?.status || '') }}
目的去向{{ destinationTypeLabel(selectedDetail?.destinationType, selectedDetail?.fbaWarehouseCode) }}
FBA 目的仓{{ selectedDetail?.destinationWarehouseName || warehouseDisplay(selectedDetail?.destinationWarehouseCode || selectedDetail?.fbaWarehouseCode) }}
FBA 货件号{{ selectedDetail?.fbaShipmentNo || '—' }}
私人仓地址{{ selectedDetail?.privateDestinationAddress || '—' }}
邮编{{ selectedDetail?.privateDestinationPostalCode || '—' }}
私人仓联系人{{ selectedDetail?.privateDestinationContactName || '—' }}
联系电话{{ selectedDetail?.privateDestinationContactPhone || '—' }}
海外仓{{ warehouseDisplay(selectedDetail?.operatingWarehouseId) }}
箱数{{ selectedDetail?.packageCount ?? '—' }}
入仓方式{{ deliveryMethodLabel(selectedDetail?.deliveryMethod || '') }}
期望发货时间{{ formatDateTime(selectedDetail?.estimatedShipAt) }}
实际发货时间{{ formatDateTime(selectedDetail?.actualShipAt) }}
是否换标{{ selectedDetail?.needRelabel ? '是' : '否' }}
换标状态{{ transferRelabelStatus(selectedDetail || {}).label }}
换标单号{{ selectedDetail?.relabelNo || '—' }}
创建时间{{ formatDateTime(selectedDetail?.createdAt) }}
转运单号{{ selectedDetail?.transferNo || '—' }}
{{ column }}{{ selectedDetail?.[String(index)] || '—' }}
+

本次废弃箱子

入仓码SKU品名申请数量所选箱唛
{{ item.inboundCode || '—' }}{{ item.skuCode || '—' }}{{ item.productName || '—' }}{{ item.discardPackages ?? '—' }}{{ item.cartonMarks?.join('、') || '全部库存(历史记录无箱唛快照)' }}

废弃现场照片

照片加载中…
{{ formatDateTime(photo.submittedAt) }}
暂无已提交的废弃现场照片
@@ -1897,3 +1930,9 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) { .warehouse-transfer-form .warehouse-choice-field--relabel { grid-template-columns: 1fr; align-items: start; } } + + diff --git a/src/modules/overseas-warehouse/views/relabelAttachments.test.mjs b/src/modules/overseas-warehouse/views/relabelAttachments.test.mjs index f789374..145fc2c 100644 --- a/src/modules/overseas-warehouse/views/relabelAttachments.test.mjs +++ b/src/modules/overseas-warehouse/views/relabelAttachments.test.mjs @@ -129,9 +129,9 @@ test('贴标货物明细通过单一换标类型选择三种模式', () => { test('箱唛相关换标必须选择四选一派送方式', () => { assert.match(view, /label="卡车派送" value="TRUCK"/) assert.match(view, /label="快递派送" value="EXPRESS"/) - assert.match(view, /label="卡车自提" value="TRUCK_PICKUP"/) - assert.match(view, /label="快递自提" value="EXPRESS_PICKUP"/) - assert.match(api, /RelabelDeliveryMethod = 'TRUCK' \| 'EXPRESS' \| 'TRUCK_PICKUP' \| 'EXPRESS_PICKUP'/) + assert.match(view, /label="亚马逊自提" value="AMAZON_PICKUP"/) + assert.match(view, /label="客户自提" value="CUSTOMER_PICKUP"/) + assert.match(api, /RelabelDeliveryMethod = 'TRUCK' \| 'EXPRESS' \| 'AMAZON_PICKUP' \| 'CUSTOMER_PICKUP'/) assert.match(view, /labelingMode\.value !== 'sku' && !form\.deliveryMethod/) }) diff --git a/src/modules/overseas-warehouse/views/warehouseDisposalForm.test.mjs b/src/modules/overseas-warehouse/views/warehouseDisposalForm.test.mjs index 678ee90..b5be9fb 100644 --- a/src/modules/overseas-warehouse/views/warehouseDisposalForm.test.mjs +++ b/src/modules/overseas-warehouse/views/warehouseDisposalForm.test.mjs @@ -7,6 +7,43 @@ const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url) test('货物废弃先选择海外操作仓,再加载并选择该仓入库单', () => { assert.match(view, /props\.service === 'disposal'.*操作仓.*v-model="form\.warehouse".*请选择海外仓.*@change="selectDiscardWarehouse"/s) assert.match(view, /props\.service === 'disposal'.*选择入库单.*:disabled="!form\.warehouse".*请先选择操作仓/s) - assert.match(view, /async function selectDiscardWarehouse\(\).*selectedDiscardOrderId\.value = ''.*cargoRows\.value = \[\].*listRelabelInventoryCandidates\(form\.warehouse\).*grouped/s) + assert.match(view, /async function selectDiscardWarehouse\(\).*selectedDiscardOrderId\.value = ''.*cargoRows\.value = \[\].*listDiscardCandidates\(\)/s) assert.match(view, /\|\| !primaryWarehouse \|\| invalidCargo/) }) + + +test('废弃来源包含通用仓位,选择后带出全部库存且不把选择键提交为订单ID', () => { + const body = view.match(/function selectDiscardInbound\(orderId: string\) \{([\s\S]*?)\n\}/)[1] + const select = new Function('discardSelectedBoxKeys', 'discardBoxSearch', 'selectAllDiscardBoxes', 'discardCandidates', 'cargoRows', 'form', 'cargoSequence', 'orderId', body) + const cargoRows = { value: [] }; const form = {} + select({ value: [] }, { value: '' }, () => {}, { value: [{ orderId: 'inventory:42', sourceType: 'INVENTORY', inventoryPositionId: '42', overseasWarehouseId: '99', inboundCode: 'WT-1', skus: [{ skuCode: 'A', productName: '商品', availablePackages: 100 }] }] }, cargoRows, form, 0, 'inventory:42') + assert.equal(cargoRows.value[0].quantity, 100) + assert.equal(cargoRows.value[0].sourceInventoryPositionId, '42') + assert.equal(form.inboundNo, 'WT-1') + assert.match(view, /orderId: item.sourceType === 'DROPSHIP' \? item.sourceOrderId : undefined/) + assert.match(view, /inventoryPositionId: item.sourceType === 'INVENTORY'/) +}) + +function selectionRows(boxes, selectedKeys) { + const body = view.match(/function syncDiscardSelection\(\) \{([\s\S]*?)\n\}/)[1].replace('new Map()', 'new Map()') + const rows = { value: [] } + new Function('discardSelectableBoxes', 'discardSelectedBoxKeys', 'cargoRows', 'createCargoRow', body)({ value: boxes }, { value: selectedKeys }, rows, () => ({})) + return rows.value +} +test('部分废弃只提交勾选的箱子,数量由箱子汇总,清空后无明细', () => { + const source = { orderId: 'inventory:42', inventoryPositionId: '42', sourceType: 'INVENTORY', availablePackages: 3, skus: [{ skuCode: 'SKU', productName: '商品' }] } + const boxes = ['1', '2', '3'].map(id => ({ key: `INVENTORY:${id}`, cartonId: id, cartonMark: `BOX-${id}`, source, skus: source.skus })) + const rows = selectionRows(boxes, ['INVENTORY:2']) + assert.equal(rows.length, 1); assert.equal(rows[0].quantity, 1) + assert.deepEqual(rows[0].discardCartonIds, ['2']); assert.equal(rows[0].sourceInventoryPositionId, '42') + assert.equal(selectionRows(boxes, []).length, 0) +}) +test('混合SKU箱按实物箱只计一次,退货箱仍整箱携带全部SKU', () => { + const skus = [{ skuCode: 'A', availablePackages: 2 }, { skuCode: 'B', availablePackages: 3 }] + const dropship = { orderId: '42', sourceType: 'DROPSHIP', availablePackages: 1, skus } + const rows = selectionRows([{ key: 'DROPSHIP:1', cartonId: '1', source: dropship, skus }], ['DROPSHIP:1']) + assert.equal(rows.length, 1); assert.equal(rows[0].quantity, 1); assert.deepEqual(rows[0].discardCartonIds, ['1']) + const returned = { orderId: 'return:2', returnCartonId: '2', sourceType: 'RETURN_CARTON', availablePackages: 5, skus } + const returnRows = selectionRows([{ key: 'RETURN_CARTON:2', cartonId: '2', source: returned, skus }], ['RETURN_CARTON:2']) + assert.deepEqual(returnRows.map(row => row.quantity), [2, 3]); assert.ok(returnRows.every(row => row.returnCartonId === '2' && row.discardCartonIds === undefined)) +}) -- 2.54.0 From 5b320a3293c13444e96ceac9105cfa797133cf09 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 15 Oct 2026 15:45:22 +0800 Subject: [PATCH 20/59] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/warehouseServices.ts | 9 +- .../CompletionConfirmationDialog.test.mjs | 81 +++++++ .../CompletionConfirmationDialog.vue | 105 +++++++++ .../views/WarehouseServiceView.vue | 212 +++++++++++++----- .../views/returnRelabelSources.test.mjs | 20 ++ .../views/warehouseTransferForm.test.mjs | 64 +++++- src/modules/tenant-management/contracts.ts | 2 + 7 files changed, 430 insertions(+), 63 deletions(-) create mode 100644 src/modules/overseas-warehouse/components/CompletionConfirmationDialog.test.mjs create mode 100644 src/modules/overseas-warehouse/components/CompletionConfirmationDialog.vue diff --git a/src/modules/overseas-warehouse/api/warehouseServices.ts b/src/modules/overseas-warehouse/api/warehouseServices.ts index d8eaf02..2c86df0 100644 --- a/src/modules/overseas-warehouse/api/warehouseServices.ts +++ b/src/modules/overseas-warehouse/api/warehouseServices.ts @@ -23,9 +23,9 @@ export interface RelabelDetail { remark?: string; items: RelabelItemDetail[] history: Array<{ fromStatus?: string; toStatus?: string; operatorId?: string; remark?: string; createdAt: string }> tailOrders?: Array<{ id: string; orderNo: string; overseasWarehouseId: string; packageCount: number; status: string }> - completionEvidence?: { taskId: string; photoFileIds: string[]; completionRemark?: string; reviewerName?: string; reviewRemark?: string; completedAt?: string } + completionEvidence?: { taskId: string; photoFileIds: string[]; completionRemark?: string; reviewerName?: string; reviewRemark?: string; completedAt?: string; customerConfirmedAt?: string } } -export interface DiscardSummary { id: string; discardNo: string; outboundNo?: string; inboundCodes: string; overseasWarehouseId: string; overseasWarehouseName: string; totalPackages: number; totalFee: number; currency: string; status: string; createdAt: string; completedAt?: string } +export interface DiscardSummary { id: string; discardNo: string; outboundNo?: string; inboundCodes: string; overseasWarehouseId: string; overseasWarehouseName: string; totalPackages: number; totalFee: number; currency: string; status: string; createdAt: string; completedAt?: string; customerConfirmedAt?: string } export interface DiscardBox { cartonId: string; cartonMark: string; palletNo?: string; skus: Array<{ skuCode: string; productName: string; availablePackages: number }> } export interface DiscardCandidate { cartons?: DiscardBox[]; cartonSelectionSupported?: boolean; cartonSelectionBlockReason?: string; orderId: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON' | 'INVENTORY'; inventoryPositionId?: string; palletNo?: string; returnCartonId?: string; cartonMark?: string; inboundCode: string; inboundOrderNo: string; overseasWarehouseId: string; overseasWarehouseName: string; availablePackages: number; skus: Array<{ skuCode: string; productName: string; availablePackages: number }> } export interface RelabelInventoryCandidate { @@ -48,7 +48,7 @@ export interface TransferCreate { deliveryAddress: string; packageCount: number; volumeCbm: number; deliveryMethod: string; estimatedArrivalAt: string; estimatedShipAt?: string fbaShipmentNo?: string; trackingNo?: string; fbaBoxNos?: string; remark?: string; needRelabel: boolean; idempotencyKey: string labelDeadline?: string; pickupContactName?: string; pickupContactPhone?: string; relabelContactName?: string; relabelContactPhone?: string; relabelChargeAmount?: number; relabelDeliveryMethod?: RelabelDeliveryMethod - items: Array<{ skuCode?: string; productNameZh?: string; packageCount: number; shippingMark?: string }> + items: Array<{ skuCode?: string; productNameZh?: string; quantity: number; packageCount: number; shippingMark?: string }> relabelItems?: Array<{ operationType: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU'; customerCartonMarks: string[]; originalSku?: string; newSku?: string; quantity: number; fbaCartonMark?: string; destinationWarehouseCode?: string; remark?: string }> } export interface RelabelCreate { @@ -134,6 +134,9 @@ export function createDiscard(payload: { items: Array<{ orderId?: string; return export function cancelDiscard(discardNo: string) { return apiRequest>({ method: 'POST', url: `/merchant/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/cancel` }) } +export function confirmDiscardCompletion(discardNo: string) { + return apiRequest>({ method: 'POST', url: `/merchant/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/confirm-completion` }) +} export interface DiscardPhoto { fileId: string; palletNo: string; fileName: string; createdAt: string; submittedAt: string } export function downloadDiscardPhoto(discardNo: string, fileId: string) { diff --git a/src/modules/overseas-warehouse/components/CompletionConfirmationDialog.test.mjs b/src/modules/overseas-warehouse/components/CompletionConfirmationDialog.test.mjs new file mode 100644 index 0000000..bc8c078 --- /dev/null +++ b/src/modules/overseas-warehouse/components/CompletionConfirmationDialog.test.mjs @@ -0,0 +1,81 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import ts from 'typescript' + +const source = readFileSync(new URL('./CompletionConfirmationDialog.vue', import.meta.url), 'utf8') +const script = source.match(/ + + + + diff --git a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue index 375f4a3..ab41c46 100644 --- a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue +++ b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue @@ -2,16 +2,17 @@ import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ElMessage, ElMessageBox } from 'element-plus' -import { ArrowLeft, Download, Plus, Printer, Trash2, Upload, X } from '@lucide/vue' +import { ArrowLeft, ChevronDown, Download, Plus, Printer, Trash2, Upload, X } from '@lucide/vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue' import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue' import type { TableAction } from '@/components/common/tableActions' import { useAuthStore } from '@/modules/auth/public' +import CompletionConfirmationDialog from '../components/CompletionConfirmationDialog.vue' import { returnRelabelCandidate } from '../utils/returnRelabelCandidates' import { createDiscard, createRelabel, createTransfer, getDiscard, getRelabel, getTransfer, resubmitRelabel, getTransferCartonLabel, submitTransfer, updateTransfer, - confirmRelabelCompletion, confirmTransferMeasurement, downloadRelabelCompletionPhoto, downloadTransferMeasurementPhoto, downloadDiscardPhoto, type DiscardPhoto, uploadRelabelShippingLabel, + confirmTransferMeasurement, downloadRelabelCompletionPhoto, downloadTransferMeasurementPhoto, downloadDiscardPhoto, type DiscardPhoto, uploadRelabelShippingLabel, uploadRelabelFile, listDiscards, listDiscardCandidates, listRelabelInventoryCandidates, listRelabelHistoricalOrders, listRelabels, listTransfers, cancelDiscard, type RelabelInventoryCandidate, @@ -33,11 +34,11 @@ type ServiceType = 'labeling' | 'transfer' | 'disposal' | 'relabel' type PageMode = 'list' | 'create' | 'edit' | 'detail' type LabelingMode = 'carton' | 'sku' | 'carton_and_sku' type RelabelTemplateType = 'carton' | 'sku' | 'mixed' -interface CargoRow { discardCartonIds?: string[]; id: number; included?: boolean; sku: string; name: string; quantity: number; availableQuantity?: number; sourceOrderId?: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON' | 'RETURN_PARCEL' | 'INVENTORY'; sourceInventoryBalanceId?: string; sourceInventoryPositionId?: string; sourceInboundOrderId?: string; returnCartonId?: string; sourceInboundOrderNo?: string; sourceInboundCode?: string; sourceWarehouseName?: string; palletNo?: string; warehouseLocationCode?: string; fnsku: string; shipmentNo: string; newSkuCode: string; shippingMark: string; customerCartonMarks: string; cartonMark: string; fbaCartonMark: string; destinationWarehouseId: string; remark: string } +interface CargoRow { discardCartonIds?: string[]; id: number; included?: boolean; sku: string; name: string; quantity: number; expectedPackageCount?: number; availableQuantity?: number; sourceOrderId?: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON' | 'RETURN_PARCEL' | 'INVENTORY'; sourceInventoryBalanceId?: string; sourceInventoryPositionId?: string; sourceInboundOrderId?: string; returnCartonId?: string; sourceInboundOrderNo?: string; sourceInboundCode?: string; sourceWarehouseName?: string; palletNo?: string; warehouseLocationCode?: string; fnsku: string; shipmentNo: string; newSkuCode: string; shippingMark: string; customerCartonMarks: string; cartonMark: string; fbaCartonMark: string; destinationWarehouseId: string; remark: string } interface SkuRelabelRow { id: number; sourceRowId: number | null; cartonMark: string; originalSku: string; newSku: string; quantity: number; fbaCartonMark: string; shipmentNo: string; destinationWarehouseId: string; remark: string } interface ImportedRelabelBinding extends SkuRelabelRow { templateType: RelabelTemplateType; selected: boolean; shipmentNo: string } interface ServiceConfig { title: string; createTitle: string; numberLabel: string; emptyText: string; columns: string[] } -interface TransferCarton { cartonMark: string; customerCartonMark?: string; cartonIndex: number; totalCartons: number; status: string } +interface TransferCarton { cartonMark: string; customerCartonMark?: string; cartonIndex: number; totalCartons: number; status: string; transferItemId?: string | number; transferPalletId?: string | number; length?: number; width?: number; height?: number; weight?: number; quantity?: number; discrepancyReason?: string; countedAt?: string } const props = withDefaults(defineProps<{ service: ServiceType; createPage?: boolean; detailPage?: boolean; detailNo?: string }>(), { createPage: false, detailPage: false, detailNo: '' }) const auth = useAuthStore() @@ -53,10 +54,22 @@ const canCreate = computed(() => auth.hasPermission(({ const canManageRelabelFiles = computed(() => auth.hasPermission('merchant:overseas-warehouse:relabel:files')) const canConfirmRelabel = computed(() => auth.hasPermission('merchant:overseas-warehouse:relabel:confirm')) const canConfirmTransfer = computed(() => auth.hasPermission('merchant:overseas-warehouse:transfer:confirm')) +const canConfirmDiscard = computed(() => auth.hasPermission('merchant:overseas-warehouse:discard:confirm')) +const completionConfirmOpen = ref(false) +const completionConfirmNo = ref('') +const completionConfirmKind = ref<'disposal' | 'labeling'>('disposal') +function openCompletionConfirmation(no: string, kind: 'disposal' | 'labeling') { + if (!no) return + completionConfirmNo.value = no; completionConfirmKind.value = kind; completionConfirmOpen.value = true +} +async function refreshAfterCompletion(no: string) { + if (mode.value === 'detail') await openDetail({ '0': no }) + else await load() +} const canCancelDiscard = computed(() => auth.hasPermission('merchant:overseas-warehouse:discard:cancel')) const configs: Record = { labeling: { title: '贴标申请', createTitle: '新增贴标申请', numberLabel: '贴标申请单号', emptyText: '暂无贴标申请', columns: ['操作', '贴标申请单号', '关联业务单号', '海外操作仓', 'SKU 数(种)', '总箱数/包裹', '换标类型', '状态', '审核时间', '贴标完成时间', '贴标出库时间', '创建时间'] }, - transfer: { title: '仓储转运单', createTitle: '新增仓储转运单', numberLabel: '转运单号', emptyText: '暂无仓储转运订单', columns: ['操作', '转运单号', '箱数', '海外仓', 'FBA 目的仓', '入仓方式', '是否换标', '换标单号', '换标状态', '状态', '审核时间', '测量时间', '出库时间', '创建时间'] }, + transfer: { title: '仓储转运单', createTitle: '新增仓储转运单', numberLabel: '转运单号', emptyText: '暂无仓储转运订单', columns: ['操作', '转运单号', '预计箱数', '海外仓', 'FBA 目的仓', '入仓方式', '是否换标', '换标单号', '换标状态', '状态', '审核时间', '测量时间', '出库时间', '创建时间'] }, disposal: { title: '货物废弃', createTitle: '新增货物废弃申请', numberLabel: '废弃申请单号', emptyText: '暂无货物废弃申请', columns: ['操作', '废弃申请单号', '废弃出库单号', '进仓编号', '操作仓', 'SKU 汇总', '废弃数量', '预估费用', '状态', '废弃完成时间', '创建时间'] }, relabel: { title: '退货换标', createTitle: '新建退货换标单', numberLabel: '退货换标单号', emptyText: '暂无退货换标订单', columns: ['操作', '退货换标单号', '来源 FBA 仓', '目的 FBA 仓', 'SKU 汇总', '退件总数', '是否换标', '换标状态', '尾程状态', '审核时间', '测量时间', '出库时间', '创建时间'] }, } @@ -81,7 +94,7 @@ const listQueryModel = computed({ const statusOptions = computed(() => props.service === 'labeling' ? [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '换标完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }] : props.service === 'disposal' - ? [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待 PDA 废弃', value: 'APPROVED' }, { label: '待仓库确认完成', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已拒绝', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] + ? [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待 PDA 废弃', value: 'APPROVED' }, { label: '待客户确认', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已拒绝', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] : props.service === 'transfer' ? [{ label: '草稿', value: 'DRAFT' }, { label: '待审核', value: 'PENDING_REVIEW' }, { label: '待PDA收货', value: 'WAIT_PDA_RECEIVING' }, { label: '入库测量中', value: 'INBOUND_MEASURING' }, @@ -130,11 +143,35 @@ interface ServiceDetail extends Record { relabelContactPhone?: string; relabelDeliveryMethod?: string; relabelNo?: string relabelStatus?: string; relabelFulfillmentStatus?: string; createdAt?: string inboundCode?: string; sourceOrders?: Array<{ lastlegOrderId?: string | number }> - items?: Array<{ cartonMarks?: string[]; inboundCode?: string; productName?: string; discardPackages?: number; skuCode?: string; productNameZh?: string; packageCount?: number; shippingMark?: string }> + items?: Array<{ id?: string | number; cartonMarks?: string[]; inboundCode?: string; productName?: string; discardPackages?: number; skuCode?: string; productNameZh?: string; quantity?: number | null; packageCount?: number; shippingMark?: string }> relabelBindings?: Array<{ operationType?: string; customerCartonMark?: string; originalSku?: string; newSku?: string; quantity?: number; fbaCartonMark?: string; destinationWarehouseCode?: string; remark?: string }> cartons?: TransferCarton[]; pallets?: TransferPallet[]; measurementPhotos?: TransferMeasurementPhoto[]; customerConfirmedAt?: string } const selectedDetail = ref(null) +const showTransferCartons = ref(false) +function transferCartonItem(carton: TransferCarton) { + return selectedDetail.value?.items?.find((item) => carton.transferItemId != null && String(item.id) === String(carton.transferItemId)) +} +function palletCartons(pallet: TransferPallet) { + return (selectedDetail.value?.cartons || []).filter((carton) => carton.status === 'COUNTED' && pallet.id != null && carton.transferPalletId != null && String(carton.transferPalletId) === String(pallet.id)) +} +function palletSkuRows(pallet: TransferPallet) { + const summary = new Map() + for (const carton of palletCartons(pallet)) { + const item = transferCartonItem(carton); const sku = item?.skuCode || '未标记 SKU' + summary.set(sku, (summary.get(sku) || 0) + Number(carton.quantity || 0)) + } + return [...summary.entries()].map(([sku, quantity]) => ({ sku, quantity })) +} +function palletQuantity(pallet: TransferPallet) { return palletCartons(pallet).reduce((sum, carton) => sum + Number(carton.quantity || 0), 0) } +function countedTransferCartons() { return (selectedDetail.value?.cartons || []).filter((carton) => carton.status === 'COUNTED') } +function receivedItemCartons(itemId: string | number | undefined) { + return countedTransferCartons().filter(carton => itemId != null && carton.transferItemId != null && String(carton.transferItemId) === String(itemId)) +} +function receivedItemQuantity(itemId: string | number | undefined) { + return receivedItemCartons(itemId).reduce((sum, carton) => sum + Number(carton.quantity || 0), 0) +} + const detailError = ref('') const selectedRelabelDetail = ref(null) const completionPhotoUrls = reactive>({}) @@ -463,7 +500,7 @@ async function openEdit(transferNo: string) { transferDestinationType.value = detail.destinationType || (detail.fbaWarehouseCode ? 'FBA' : 'NONE') cargoRows.value = (detail.items || []).map((item) => ({ ...createCargoRow(), sku: item.skuCode || '', name: item.productNameZh || '', - quantity: Number(item.packageCount || 0), shippingMark: item.shippingMark || '', + quantity: Number(item.quantity || 0), expectedPackageCount: Number(item.packageCount || 0), shippingMark: item.shippingMark || '', customerCartonMarks: item.shippingMark || '', })) const bindings = detail.relabelBindings || [] @@ -520,26 +557,26 @@ async function selectDiscardWarehouse() { } catch (error) { ElMessage.error(error instanceof Error ? error.message : '可废弃入库单加载失败') } finally { discardCandidatesLoading.value = false } } -function createCargoRow(): CargoRow { return { id: ++cargoSequence, sku: '', name: '', quantity: 1, fnsku: '', shipmentNo: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', cartonMark: '', fbaCartonMark: '', destinationWarehouseId: '', remark: '' } } +function createCargoRow(): CargoRow { return { id: ++cargoSequence, sku: '', name: '', quantity: 1, expectedPackageCount: 1, fnsku: '', shipmentNo: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', cartonMark: '', fbaCartonMark: '', destinationWarehouseId: '', remark: '' } } function addCargoRow() { cargoRows.value.push(createCargoRow()) } function escapePrintHtml(value: string) { return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''') } function printTransferDraftCartonMarks() { if (props.service !== 'transfer') return - const validRows = cargoRows.value.filter((row) => row.sku.trim() && Number(row.quantity) > 0) - if (!validRows.length || validRows.some((row) => !Number.isInteger(Number(row.quantity)))) { + const validRows = cargoRows.value.filter((row) => row.sku.trim() && Number(row.expectedPackageCount) > 0) + if (!validRows.length || validRows.some((row) => !Number.isInteger(Number(row.expectedPackageCount)))) { ElMessage.warning('请先填写商品 SKU 和对应的整数箱数'); return } - const totalCartons = validRows.reduce((sum, row) => sum + Number(row.quantity), 0) + const totalCartons = validRows.reduce((sum, row) => sum + Number(row.expectedPackageCount), 0) if (totalCartons > 500) { ElMessage.warning('单次最多打印 500 张箱唛'); return } const batch = new Date().toISOString().replace(/\D/g, '').slice(2, 14) const labels: Array<{ mark: string; sku: string; index: number }> = [] let cartonIndex = 0 for (const [rowIndex, row] of validRows.entries()) { let marks = transferCustomerMarks(row) - if (marks.length !== Number(row.quantity)) { - marks = Array.from({ length: Number(row.quantity) }, (_, index) => `CT-${batch}-${String(rowIndex + 1).padStart(2, '0')}-${String(index + 1).padStart(3, '0')}`) + if (marks.length !== Number(row.expectedPackageCount)) { + marks = Array.from({ length: Number(row.expectedPackageCount) }, (_, index) => `CT-${batch}-${String(rowIndex + 1).padStart(2, '0')}-${String(index + 1).padStart(3, '0')}`) row.customerCartonMarks = marks.join('\n') } for (const mark of marks) labels.push({ mark, sku: row.sku.trim(), index: ++cartonIndex }) @@ -696,7 +733,7 @@ function downloadTransferCartonMarkTemplate() { ElMessage.warning('请先填写完整的 SKU 明细'); return } const header = ['SKU', '客户箱唛号'] - const rows = cargoRows.value.flatMap((row) => Array.from({ length: row.quantity }, () => [row.sku, ''])) + const rows = cargoRows.value.flatMap((row) => Array.from({ length: Number(row.expectedPackageCount || 0) }, () => [row.sku, ''])) const blob = new Blob([`\uFEFF${[header, ...rows].map((row) => row.map(csvCell).join(',')).join('\r\n')}\r\n`], { type: 'text/csv;charset=utf-8' }) const url = URL.createObjectURL(blob); const anchor = document.createElement('a') anchor.href = url; anchor.download = '仓储转运-客户箱唛模板.csv'; anchor.click(); URL.revokeObjectURL(url) @@ -932,7 +969,7 @@ function statusLabel(value: string) { PENDING_PROCESSING: '待处理', PROCESSING: '处理中', CUSTOMER_CONFIRM_PENDING: '待客户确认', READY_FOR_OUTBOUND: '换标完成', COMPLETED: '已完成', CANCELLED: '已取消', - DRAFT: '草稿', PENDING_REVIEW: '待审核', APPROVED: '待 PDA 废弃', PDA_COMPLETED: '待仓库确认完成', REJECTED: '已拒绝', PENDING_ASSIGNMENT: '待分配', + DRAFT: '草稿', PENDING_REVIEW: '待审核', APPROVED: '待 PDA 废弃', PDA_COMPLETED: '待客户确认', REJECTED: '已拒绝', PENDING_ASSIGNMENT: '待分配', WAIT_PDA_RECEIVING: '待PDA收货', WAIT_CUSTOMER_CONFIRMATION: '待客户确认实测', WAIT_FBA_DESTINATION: '待确认目的地', WAIT_RELABEL: '待换标', READY_FOR_DISPATCH: '待排车', WAIT_OUTBOUND: '待出库', ALLOCATED: '已分配', OUTBOUNDED: '已出库', PARTIALLY_RECEIVED: '部分收货', RECEIVED: '已收货', INBOUND_COMPLETED: '入库完成', PENDING_DELIVERY: '待派送', DELIVERING: '派送中', DELIVERED: '派送完成', @@ -1031,7 +1068,7 @@ async function load() { } else if (props.service === 'disposal') { const result = await listDiscards({ page: page.value, size: pageSize.value, discardNo: filters.number || undefined, inboundCode: filters.inboundNo || undefined, overseasWarehouseId: filters.warehouse || undefined, status: filters.status || undefined, createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true), completedFrom: dateTime(filters.completedStart), completedTo: dateTime(filters.completedEnd, true) }) total.value = Number.isFinite(Number(result.total)) ? Number(result.total) : result.records.length - submittedRows.value = result.records.map((item) => row([item.discardNo, item.outboundNo || '—', item.inboundCodes, item.overseasWarehouseName || warehouseDisplay(item.overseasWarehouseId) || '—', '—', item.totalPackages, `${item.currency} ${item.totalFee}`, statusLabel(item.status), formatDateTime(item.completedAt), formatDateTime(item.createdAt)])) + submittedRows.value = result.records.map((item) => ({ ...row([item.discardNo, item.outboundNo || '—', item.inboundCodes, item.overseasWarehouseName || warehouseDisplay(item.overseasWarehouseId) || '—', '—', item.totalPackages, `${item.currency} ${item.totalFee}`, statusLabel(item.status), formatDateTime(item.customerConfirmedAt), formatDateTime(item.createdAt)]), __status: item.status })) } else { const result = await listTransfers({ page: page.value, size: pageSize.value, transferNo: filters.number || undefined, inboundCode: filters.inboundNo || undefined, operatingWarehouseId: filters.warehouse || undefined, fbaWarehouseCode: filters.destination || undefined, status: filters.status || undefined, ...(props.service === 'relabel' ? { needRelabel: true } : {}), createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true), reviewedFrom: dateTime(filters.reviewStart), reviewedTo: dateTime(filters.reviewEnd, true), measuredFrom: props.service === 'relabel' ? dateTime(filters.measuredStart) : dateTime(filters.completedStart), measuredTo: props.service === 'relabel' ? dateTime(filters.measuredEnd, true) : dateTime(filters.completedEnd, true), outboundFrom: dateTime(filters.outboundStart), outboundTo: dateTime(filters.outboundEnd, true) }) total.value = Number.isFinite(Number(result.total)) ? Number(result.total) : result.records.length @@ -1054,6 +1091,7 @@ function listRowActions(candidate: object): TableAction[] { if (canCreate.value && props.service === 'transfer' && ['DRAFT', 'REJECTED'].includes(item.__status)) actions.push({ key: 'edit', label: '编辑', run: () => openEdit(item['0']) }) if (canConfirmTransfer.value && props.service === 'transfer' && item.__status === 'WAIT_CUSTOMER_CONFIRMATION') actions.push({ key: 'confirm-measurement', label: '确认测量', run: () => confirmTransferFromList(item['0']) }) if (canConfirmRelabel.value && props.service === 'labeling' && item.__status === 'CUSTOMER_CONFIRM_PENDING' && !item.__confirmedAt) actions.push({ key: 'confirm-relabel', label: '确认换标', run: () => confirmRelabelFromList(item['0']) }) + if (canConfirmDiscard.value && props.service === 'disposal' && item.__status === 'PDA_COMPLETED') actions.push({ key: 'confirm-discard', label: '废弃确认', run: () => openCompletionConfirmation(item['0'], 'disposal') }) return actions } watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => { @@ -1069,7 +1107,7 @@ async function submit(action: 'draft' | 'submit' = 'submit') { : (props.service === 'labeling' || props.service === 'relabel') && (relabelRows.length === 0 || (props.service === 'labeling' && labelingMode.value !== 'carton' ? skuRelabelRows.value.some((item) => !item.cartonMark || !item.originalSku || !item.newSku || item.quantity < 1 || (labelingMode.value === 'carton_and_sku' && (!item.fbaCartonMark || !item.shipmentNo.trim()))) - : cargoRows.value.some((item) => (props.service !== 'labeling' && !item.sku) || !item.name + : (props.service === 'labeling' ? cartonDetailRows.value : cargoRows.value).some((item) => (props.service !== 'labeling' && !item.sku) || !item.name || (props.service !== 'labeling' && item.quantity < 1)))) if ((props.service === 'relabel' && !form.inboundNo) || !primaryWarehouse || invalidCargo) { ElMessage.warning('请填写必填信息和完整的货物明细'); return @@ -1105,10 +1143,14 @@ async function submit(action: 'draft' | 'submit' = 'submit') { if (props.service === 'relabel' && (!form.destination || !form.address || !form.deliveryMethod || !form.estimatedArrivalAt || !form.fbaShipmentNo || form.volumeCbm <= 0)) { ElMessage.warning('请填写完整的派送信息、体积、预计到达时间和 FBA 货件号'); return } - if (props.service === 'transfer' && (!form.warehouse || !form.deliveryMethod || !form.address || !form.estimatedArrivalAt || form.volumeCbm < 0 || (form.deliveryMethod === 'WAREHOUSE_PICKUP' && (!form.pickupContact || !form.pickupPhone)))) { - const missing = [!form.warehouse && '海外仓', !form.deliveryMethod && '送货方式', !form.address && '地址', form.deliveryMethod === 'WAREHOUSE_PICKUP' && !form.pickupContact && '提货联系人', form.deliveryMethod === 'WAREHOUSE_PICKUP' && !form.pickupPhone && '联系人电话', !form.estimatedArrivalAt && '预计入仓时间', form.volumeCbm < 0 && '预估总体积'].filter(Boolean).join('、') + if (props.service === 'transfer' && (!form.warehouse || !form.deliveryMethod || !form.address || !form.estimatedArrivalAt || (form.deliveryMethod === 'WAREHOUSE_PICKUP' && (!form.pickupContact || !form.pickupPhone)))) { + const missing = [!form.warehouse && '海外仓', !form.deliveryMethod && '送货方式', !form.address && '地址', form.deliveryMethod === 'WAREHOUSE_PICKUP' && !form.pickupContact && '提货联系人', form.deliveryMethod === 'WAREHOUSE_PICKUP' && !form.pickupPhone && '联系人电话', !form.estimatedArrivalAt && '预计入仓时间'].filter(Boolean).join('、') ElMessage.warning(`请填写:${missing || '必填信息'}`); return } + if (props.service === 'transfer' && (!cargoRows.value.length || cargoRows.value.some((row) => + !Number.isSafeInteger(row.quantity) || row.quantity < 1 || !Number.isSafeInteger(row.expectedPackageCount) || Number(row.expectedPackageCount) < 1))) { + ElMessage.warning('请填写每个 SKU 的数量(件)和预计箱数,均须为正整数'); return + } if (props.service === 'transfer' && transferDestinationType.value === 'FBA' && !form.destination) { ElMessage.warning('请选择 FBA 目的仓'); return } @@ -1169,12 +1211,12 @@ async function submit(action: 'draft' | 'submit' = 'submit') { }) await createDiscard({ items, idempotencyKey, remark: [form.reason, form.remark].filter(Boolean).join(';') || undefined }) } else { - const packageCount = cargoRows.value.reduce((sum, item) => sum + item.quantity, 0) + const packageCount = cargoRows.value.reduce((sum, item) => sum + Number(item.expectedPackageCount || 0), 0) const customerCartonMarks = (form.relabel ? transferRelabelRows.value : cargoRows.value).flatMap(transferCustomerMarks) const transferRemark = [form.remark, form.relabel && form.requirement ? `换标要求:${form.requirement}` : ''].filter(Boolean).join(';') const operationType = labelingMode.value === 'carton' ? 'CARTON_LABEL' : labelingMode.value === 'sku' ? 'SKU_LABEL' : 'CARTON_AND_SKU' const selectedFba = fbaWarehouses.value.find((warehouse) => warehouse.warehouseCode === form.destination) - const payload: TransferCreate = { operatingWarehouseId: primaryWarehouse, destinationType: transferDestinationType.value, fbaWarehouseCode: transferDestinationType.value === 'FBA' ? form.destination || undefined : undefined, destinationWarehouseId: transferDestinationType.value === 'FBA' ? String(selectedFba?.id || '') || undefined : undefined, destinationWarehouseCode: transferDestinationType.value === 'FBA' ? form.destination || undefined : undefined, destinationWarehouseName: transferDestinationType.value === 'FBA' ? selectedFba?.warehouseName : undefined, privateDestinationAddress: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationAddress : undefined, privateDestinationPostalCode: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationPostalCode : undefined, privateDestinationContactName: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationContact : undefined, privateDestinationContactPhone: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationPhone : undefined, deliveryAddress: form.address, packageCount, volumeCbm: form.volumeCbm, deliveryMethod: form.deliveryMethod, estimatedArrivalAt: form.estimatedArrivalAt, estimatedShipAt: form.estimatedShipAt || undefined, fbaShipmentNo: transferDestinationType.value === 'FBA' ? form.fbaShipmentNo || undefined : undefined, trackingNo: form.deliveryMethod === 'CUSTOMER_DELIVERY' ? form.trackingNo || undefined : undefined, fbaBoxNos: customerCartonMarks.length ? customerCartonMarks.join(',') : undefined, remark: transferRemark || undefined, needRelabel: props.service === 'relabel' || form.relabel, labelDeadline: form.relabel ? form.labelDeadline : undefined, pickupContactName: form.deliveryMethod === 'WAREHOUSE_PICKUP' ? form.pickupContact : undefined, pickupContactPhone: form.deliveryMethod === 'WAREHOUSE_PICKUP' ? form.pickupPhone : undefined, relabelContactName: form.relabel ? form.relabelContact : undefined, relabelContactPhone: form.relabel ? form.relabelPhone : undefined, relabelDeliveryMethod: form.relabel && labelingMode.value !== 'sku' ? form.relabelDeliveryMethod as 'TRUCK' | 'EXPRESS' | 'AMAZON_PICKUP' | 'CUSTOMER_PICKUP' : undefined, idempotencyKey, items: cargoRows.value.map((item) => ({ skuCode: item.sku, productNameZh: item.name, packageCount: item.quantity, shippingMark: transferCustomerMarks(item).join(',') || undefined })), relabelItems: form.relabel ? transferRelabelRows.value.map((item) => ({ operationType, customerCartonMarks: transferCustomerMarks(item), originalSku: item.sku, newSku: item.newSkuCode || undefined, quantity: item.quantity, fbaCartonMark: item.fbaCartonMark || undefined, destinationWarehouseCode: transferRelabelDefaultDestination.value || undefined, remark: item.remark || undefined })) : undefined } + const payload: TransferCreate = { operatingWarehouseId: primaryWarehouse, destinationType: transferDestinationType.value, fbaWarehouseCode: transferDestinationType.value === 'FBA' ? form.destination || undefined : undefined, destinationWarehouseId: transferDestinationType.value === 'FBA' ? String(selectedFba?.id || '') || undefined : undefined, destinationWarehouseCode: transferDestinationType.value === 'FBA' ? form.destination || undefined : undefined, destinationWarehouseName: transferDestinationType.value === 'FBA' ? selectedFba?.warehouseName : undefined, privateDestinationAddress: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationAddress : undefined, privateDestinationPostalCode: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationPostalCode : undefined, privateDestinationContactName: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationContact : undefined, privateDestinationContactPhone: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationPhone : undefined, deliveryAddress: form.address, packageCount, volumeCbm: 0, deliveryMethod: form.deliveryMethod, estimatedArrivalAt: form.estimatedArrivalAt, estimatedShipAt: form.estimatedShipAt || undefined, fbaShipmentNo: transferDestinationType.value === 'FBA' ? form.fbaShipmentNo || undefined : undefined, trackingNo: form.deliveryMethod === 'CUSTOMER_DELIVERY' ? form.trackingNo || undefined : undefined, fbaBoxNos: customerCartonMarks.length ? customerCartonMarks.join(',') : undefined, remark: transferRemark || undefined, needRelabel: props.service === 'relabel' || form.relabel, labelDeadline: form.relabel ? form.labelDeadline : undefined, pickupContactName: form.deliveryMethod === 'WAREHOUSE_PICKUP' ? form.pickupContact : undefined, pickupContactPhone: form.deliveryMethod === 'WAREHOUSE_PICKUP' ? form.pickupPhone : undefined, relabelContactName: form.relabel ? form.relabelContact : undefined, relabelContactPhone: form.relabel ? form.relabelPhone : undefined, relabelDeliveryMethod: form.relabel && labelingMode.value !== 'sku' ? form.relabelDeliveryMethod as 'TRUCK' | 'EXPRESS' | 'AMAZON_PICKUP' | 'CUSTOMER_PICKUP' : undefined, idempotencyKey, items: cargoRows.value.map((item) => ({ skuCode: item.sku, productNameZh: item.name, quantity: item.quantity, packageCount: Number(item.expectedPackageCount), shippingMark: transferCustomerMarks(item).join(',') || undefined })), relabelItems: form.relabel ? transferRelabelRows.value.map((item) => ({ operationType, customerCartonMarks: transferCustomerMarks(item), originalSku: item.sku, newSku: item.newSkuCode || undefined, quantity: item.quantity, fbaCartonMark: item.fbaCartonMark || undefined, destinationWarehouseCode: transferRelabelDefaultDestination.value || undefined, remark: item.remark || undefined })) : undefined } if (props.service === 'transfer' && selectedTransferInventory.value) { ;(payload as TransferCreate).inboundCode = selectedTransferInventory.value ;(payload as TransferCreate).sourceLastlegOrderIds = selectedTransferSourceOrderIds.value @@ -1223,6 +1265,7 @@ async function openDetail(value: Record) { try { const detail = props.service === 'labeling' ? await getRelabel(value['0']) : props.service === 'disposal' ? await getDiscard(value['0']) : await getTransfer(value['0']) const discardSummary = props.service === 'disposal' ? (detail as { summary?: ServiceDetail }).summary : undefined + showTransferCartons.value = false selectedDetail.value = { ...value, ...(detail as ServiceDetail), ...(discardSummary || {}) } selectedRelabelDetail.value = props.service === 'labeling' ? detail as RelabelDetail : null mode.value = 'detail' @@ -1307,12 +1350,7 @@ async function loadMeasurementPhotos() { await Promise.all((selectedDetail.value?.measurementPhotos || []).map((photo) => loadMeasurementPhoto(photo.fileId))) } async function confirmRelabelFromList(relabelNo: string) { - try { - await ElMessageBox.confirm('确认仓库上传的换标结果无误并继续出库流程吗?', '换标完成确认', { confirmButtonText: '确认并继续', cancelButtonText: '取消' }) - await confirmRelabelCompletion(relabelNo) - ElMessage.success('已确认换标完成,订单已进入后续出库流程') - await load() - } catch (error) { if (error !== 'cancel') ElMessage.error(error instanceof Error ? error.message : '确认失败') } + openCompletionConfirmation(relabelNo, 'labeling') } async function cancelSelectedDiscard() { const no = String(selectedDetail.value?.discardNo || selectedDetail.value?.['0'] || '') @@ -1386,13 +1424,7 @@ async function selectTransferWarehouse() { } async function confirmSelectedRelabel() { const relabelNo = selectedRelabelDetail.value?.application.relabelNo - if (!relabelNo) return - try { - await ElMessageBox.confirm('确认仓库上传的换标结果无误并继续出库流程吗?', '换标完成确认', { confirmButtonText: '确认并继续', cancelButtonText: '取消' }) - await confirmRelabelCompletion(relabelNo) - ElMessage.success('已确认换标完成,订单已进入后续出库流程') - await openDetail({ '0': relabelNo }) - } catch (error) { if (error !== 'cancel') ElMessage.error(error instanceof Error ? error.message : '确认失败') } + if (relabelNo) openCompletionConfirmation(relabelNo, 'labeling') } async function resubmitRejectedRelabel() { const application = selectedRelabelDetail.value?.application @@ -1524,12 +1556,12 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) { return } if (cartonDetailRows.value.some(item => item.cartonMark === source.cartonMark)) return - cargoRows.value.push({ id: ++cargoSequence, included: true, sku: source.skuItems?.map(item => item.skuCode).join(',') || '', name: source.productNameZh || '', shipmentNo: '', cartonMark: source.cartonMark || '', fbaCartonMark: '', remark: '', sourceInventoryBalanceId: undefined, sourceInventoryPositionId: undefined, sourceInboundOrderId: undefined, returnCartonId: String(source.returnCartonId), quantity: 1, fnsku: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', destinationWarehouseId: '' }) + cargoRows.value.push({ id: ++cargoSequence, included: true, sku: source.skuItems?.map(item => item.skuCode).join(',') || '', name: source.productNameZh || '', shipmentNo: '', cartonMark: source.cartonMark || '', fbaCartonMark: '', remark: '', sourceInventoryBalanceId: undefined, sourceInventoryPositionId: undefined, sourceInboundOrderId: undefined, returnCartonId: String(source.returnCartonId), quantity: 1, expectedPackageCount: 1, fnsku: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', destinationWarehouseId: '' }) }
@@ -1708,24 +1740,57 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
{{ props.service === 'labeling' ? selectedRelabelDetail?.application.relabelNo : selectedDetail?.transferNo || selectedDetail?.['0'] || `${config.title}详情` }}{{ config.title }}详情
-
修改后重新提交确认换标完成取消申请编辑确认测量数据{{ statusLabel(props.service === 'labeling' ? selectedRelabelDetail?.application.status || '' : String(selectedDetail?.status || '')) }}
+
修改后重新提交确认换标完成废弃确认取消申请编辑确认测量数据{{ statusLabel(props.service === 'labeling' ? selectedRelabelDetail?.application.status || '' : String(selectedDetail?.status || '')) }}
-

订单概览

操作仓{{ warehouseDisplay(selectedRelabelDetail?.application.overseasWarehouseId) }}
客户参考号{{ selectedRelabelDetail?.application.customerOrderNo || '—' }}
SKU 数(种){{ selectedRelabelDetail ? relabelSkuCount(selectedRelabelDetail.items) : '—' }}
总箱数{{ selectedRelabelDetail?.application.totalPackageCount ?? '—' }}
贴标截止时间{{ formatDateTime(selectedRelabelDetail?.application.labelDeadline) }}
仓库联系人{{ selectedRelabelDetail?.application.contactName || '—' }}
联系电话{{ selectedRelabelDetail?.application.contactPhone || '—' }}
创建时间{{ formatDateTime(selectedRelabelDetail?.application.createdAt) }}
状态{{ statusLabel(selectedDetail?.status || '') }}
目的去向{{ destinationTypeLabel(selectedDetail?.destinationType, selectedDetail?.fbaWarehouseCode) }}
FBA 目的仓{{ selectedDetail?.destinationWarehouseName || warehouseDisplay(selectedDetail?.destinationWarehouseCode || selectedDetail?.fbaWarehouseCode) }}
FBA 货件号{{ selectedDetail?.fbaShipmentNo || '—' }}
私人仓地址{{ selectedDetail?.privateDestinationAddress || '—' }}
邮编{{ selectedDetail?.privateDestinationPostalCode || '—' }}
私人仓联系人{{ selectedDetail?.privateDestinationContactName || '—' }}
联系电话{{ selectedDetail?.privateDestinationContactPhone || '—' }}
海外仓{{ warehouseDisplay(selectedDetail?.operatingWarehouseId) }}
箱数{{ selectedDetail?.packageCount ?? '—' }}
入仓方式{{ deliveryMethodLabel(selectedDetail?.deliveryMethod || '') }}
期望发货时间{{ formatDateTime(selectedDetail?.estimatedShipAt) }}
实际发货时间{{ formatDateTime(selectedDetail?.actualShipAt) }}
是否换标{{ selectedDetail?.needRelabel ? '是' : '否' }}
换标状态{{ transferRelabelStatus(selectedDetail || {}).label }}
换标单号{{ selectedDetail?.relabelNo || '—' }}
创建时间{{ formatDateTime(selectedDetail?.createdAt) }}
转运单号{{ selectedDetail?.transferNo || '—' }}
{{ column }}{{ selectedDetail?.[String(index)] || '—' }}
+

订单概览

操作仓{{ warehouseDisplay(selectedRelabelDetail?.application.overseasWarehouseId) }}
客户参考号{{ selectedRelabelDetail?.application.customerOrderNo || '—' }}
SKU 数(种){{ selectedRelabelDetail ? relabelSkuCount(selectedRelabelDetail.items) : '—' }}
总箱数{{ selectedRelabelDetail?.application.totalPackageCount ?? '—' }}
贴标截止时间{{ formatDateTime(selectedRelabelDetail?.application.labelDeadline) }}
仓库联系人{{ selectedRelabelDetail?.application.contactName || '—' }}
联系电话{{ selectedRelabelDetail?.application.contactPhone || '—' }}
创建时间{{ formatDateTime(selectedRelabelDetail?.application.createdAt) }}
状态{{ statusLabel(selectedDetail?.status || '') }}
目的去向{{ destinationTypeLabel(selectedDetail?.destinationType, selectedDetail?.fbaWarehouseCode) }}
FBA 目的仓{{ selectedDetail?.destinationWarehouseName || warehouseDisplay(selectedDetail?.destinationWarehouseCode || selectedDetail?.fbaWarehouseCode) }}
FBA 货件号{{ selectedDetail?.fbaShipmentNo || '—' }}
私人仓地址{{ selectedDetail?.privateDestinationAddress || '—' }}
邮编{{ selectedDetail?.privateDestinationPostalCode || '—' }}
私人仓联系人{{ selectedDetail?.privateDestinationContactName || '—' }}
联系电话{{ selectedDetail?.privateDestinationContactPhone || '—' }}
海外仓{{ warehouseDisplay(selectedDetail?.operatingWarehouseId) }}
预报 SKU 种数{{ new Set((selectedDetail?.items || []).map(item => item.skuCode).filter(Boolean)).size }}
预计箱数{{ selectedDetail?.packageCount ?? '—' }}
实际签收箱数{{ selectedDetail?.cartons?.filter((carton) => carton.status === 'COUNTED').length ?? '—' }}
入仓方式{{ deliveryMethodLabel(selectedDetail?.deliveryMethod || '') }}
期望发货时间{{ formatDateTime(selectedDetail?.estimatedShipAt) }}
实际发货时间{{ formatDateTime(selectedDetail?.actualShipAt) }}
是否换标{{ selectedDetail?.needRelabel ? '是' : '否' }}
换标状态{{ transferRelabelStatus(selectedDetail || {}).label }}
换标单号{{ selectedDetail?.relabelNo || '—' }}
创建时间{{ formatDateTime(selectedDetail?.createdAt) }}
{{ column }}{{ selectedDetail?.[String(index)] || '—' }}

本次废弃箱子

入仓码SKU品名申请数量所选箱唛
{{ item.inboundCode || '—' }}{{ item.skuCode || '—' }}{{ item.productName || '—' }}{{ item.discardPackages ?? '—' }}{{ item.cartonMarks?.join('、') || '全部库存(历史记录无箱唛快照)' }}

废弃现场照片

照片加载中…
{{ formatDateTime(photo.submittedAt) }}
暂无已提交的废弃现场照片
-
-

PDA 打托实测数据

PDA 作业完成即显示入库完成,客户可确认仓库实测数据
-
托盘号库位箱数长 × 宽 × 高(cm)毛重(kg)体积(m³)状态完成时间
{{ pallet.palletNo || `第${pallet.sequenceNo || '—'}托` }}{{ pallet.warehouseLocationCode || '—' }}{{ pallet.cartonCount ?? '—' }}{{ [pallet.lengthCm, pallet.widthCm, pallet.heightCm].every((value) => value != null) ? `${pallet.lengthCm} × ${pallet.widthCm} × ${pallet.heightCm}` : '—' }}{{ pallet.grossWeightKg ?? '—' }}{{ pallet.volumeCbm ?? '—' }}{{ statusLabel(pallet.status || '') }}{{ formatDateTime(pallet.completedAt) }}
-
暂无 PDA 打托测量数据
+
+

预报与实际签收

+
商品 SKU中文品名预报数量(件)预计箱数实际签收件数实际签收箱数
{{ item.skuCode || '—' }}{{ item.productNameZh || '—' }}{{ item.quantity ?? '—' }}{{ item.packageCount ?? '—' }}{{ receivedItemQuantity(item.id) }}{{ receivedItemCartons(item.id).length }}
暂无货物明细
+
+

PDA 打托实测数据

{{ selectedDetail?.pallets?.length || 0 }} 托展开托盘收起托盘
+
+
+
+ 托盘号 {{ pallet.palletNo || `第${pallet.sequenceNo || '—'}托` }} +
+
箱数
{{ palletCartons(pallet).length }} 箱
件数
{{ palletQuantity(pallet) }} 件
SKU 种数
{{ palletSkuRows(pallet).length }} 种
毛重(kg)
{{ pallet.grossWeightKg ?? '—' }}
体积(m³)
{{ pallet.volumeCbm ?? '—' }}
+
+
+
SKU 汇总
{{ item.sku }}{{ item.quantity }} 件
暂无已清点 SKU
+
库位:{{ pallet.warehouseLocationCode || '—' }}完成时间:{{ formatDateTime(pallet.completedAt) }}
+
+
+
暂无 PDA 打托测量数据
+
测量打托现场照片
照片 {{ index + 1 }} 加载中…
仓库尚未上传测量打托照片
-
-

货物明细

打印箱唛
-
商品 SKU中文品名预计箱数客户箱唛号
{{ item.skuCode || '—' }}{{ item.productNameZh || '—' }}{{ item.packageCount ?? '—' }}{{ item.shippingMark || '—' }}
暂无货物明细
+
+

签收箱号明细({{ countedTransferCartons().length }} 箱)

打印箱唛查看箱号明细
+
暂无实际签收箱,等待 PDA 清点。
+ +
+ + + +
所属托盘:{{ selectedDetail?.pallets?.find((pallet) => carton.transferPalletId != null && String(pallet.id) === String(carton.transferPalletId))?.palletNo || '散箱' }} · 清点时间:{{ formatDateTime(carton.countedAt) }}
+
长 {{ carton.length ?? '—' }} cm · 宽 {{ carton.width ?? '—' }} cm · 高 {{ carton.height ?? '—' }} cm · 重量 {{ carton.weight ?? '—' }} kg · 体积 {{ [carton.length, carton.width, carton.height].every(value => value != null) ? (Number(carton.length) * Number(carton.width) * Number(carton.height) / 1000000).toFixed(6) : '—' }} m³
+ + + + + +

差异说明:{{ carton.discrepancyReason }}

+
+
+
+ +

换标箱级去向

@@ -1758,6 +1823,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
+ diff --git a/src/modules/overseas-warehouse/views/returnRelabelSources.test.mjs b/src/modules/overseas-warehouse/views/returnRelabelSources.test.mjs index 7a15e48..9772d92 100644 --- a/src/modules/overseas-warehouse/views/returnRelabelSources.test.mjs +++ b/src/modules/overseas-warehouse/views/returnRelabelSources.test.mjs @@ -5,6 +5,26 @@ import ts from 'typescript' const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8') const source = view.slice(view.indexOf('function returnSourceNos'), view.indexOf('const relabelInboundOptions')) const compiled = ts.transpileModule(source + '\nexports.sourceNos = returnSourceNos; exports.progress = returnRelabelProgress', { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText +test('carton validation ignores unselected stock after SKU relabel and consolidation', () => { + const start = view.indexOf('async function submit(') + const validation = view.slice(view.indexOf(' const invalidCargo =', start), view.indexOf(" if ((props.service === 'relabel'", start)) + const compiledValidation = ts.transpileModule(validation, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText + const validate = new Function('props', 'labelingMode', 'relabelRows', 'cartonDetailRows', 'cargoRows', 'skuRelabelRows', compiledValidation + '; return invalidCargo') + const stockRows = [ + { included: false, name: '', sku: 'OLD-SKU', quantity: 1 }, + { included: false, name: '', sku: 'OLD-SKU,NEW-SKU', quantity: 1 }, + ] + const selected = { included: true, name: 'Product', sku: 'OLD-SKU,NEW-SKU', quantity: 1 } + function invalid(details) { + return validate({ service: 'labeling' }, { value: 'carton' }, details, + { value: details }, { value: [...stockRows, ...details] }, { value: [] }) + } + assert.equal(invalid([selected]), false) + assert.equal(invalid([{ ...selected, name: '' }]), true) + assert.equal(invalid([]), true) + assert.equal(invalid([{ ...selected, sku: '' }]), false, 'SKU is optional for carton labels') +}) + const exports = {} new Function('exports', compiled)(exports) test('合箱来源按单号集合精确匹配并去重,不能用子串匹配', () => { diff --git a/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs b/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs index 6ba891f..ed9399d 100644 --- a/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs +++ b/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs @@ -44,7 +44,8 @@ test('仓储转运支持无目的地、FBA 仓和私人仓并显式选择是否 test('换标详情待客户确认时提供确认入口并刷新详情', () => { assert.match(view, /application\.status === 'CUSTOMER_CONFIRM_PENDING'[\s\S]*@click="confirmSelectedRelabel">确认换标完成/) - assert.match(view, /async function confirmSelectedRelabel\(\)[\s\S]*await confirmRelabelCompletion\(relabelNo\)[\s\S]*await openDetail\(\{ '0': relabelNo \}\)/) + assert.match(view, /async function confirmSelectedRelabel\(\)[\s\S]*openCompletionConfirmation\(relabelNo, 'labeling'\)/) + assert.match(view, /async function refreshAfterCompletion\(no: string\)[\s\S]*await openDetail\(\{ '0': no \}\)/) }) test('仓储转运换标绑定明细自动同步只读的旧 SKU', () => { @@ -104,7 +105,7 @@ test('仓储转运不要求客户填写内部入仓号和我方箱唛,但换 test('仓储转运货物明细可按 SKU 和箱数打印客户预贴箱唛', () => { assert.match(view, /printTransferDraftCartonMarks/) assert.match(view, /请先填写商品 SKU 和对应的整数箱数/) - assert.match(view, /Array\.from\(\{ length: Number\(row\.quantity\) \}/) + assert.match(view, /Array\.from\(\{ length: Number\(row\.expectedPackageCount\) \}/) assert.match(view, /row\.customerCartonMarks = marks\.join\('\\n'\)/) assert.match(view, /仓储转运客户箱唛/) assert.match(view, /@click="printTransferDraftCartonMarks"/) @@ -210,7 +211,7 @@ test('仓储转运列表展示换标单号、换标状态并为两类状态着 test('仓储转运详情订单概览使用详情接口命名字段回填', () => { assert.match(view, /v-else-if="props\.service === 'transfer'" class="warehouse-detail-grid warehouse-transfer-overview-grid"/) assert.doesNotMatch(view, /入库箱数/) - assert.match(view, /'转运单号', '箱数', '海外仓'/) + assert.match(view, /'转运单号', '预计箱数', '海外仓'/) for (const field of ['transferNo', 'packageCount', 'operatingWarehouseId', 'deliveryMethod', 'needRelabel', 'relabelNo', 'status', 'createdAt']) { assert.match(view, new RegExp(`selectedDetail\\?\\.${field}`)) } @@ -258,7 +259,8 @@ test('贴标申请详情使用紧凑订单概览并中文展示换标状态', () test('仓储转运详情按接口具名字段展示订单概览', () => { const overview = view.split('

订单概览

')[1]?.split('warehouse-transfer-cargo-card')[0] || '' assert.match(overview, /v-if="props\.service === 'transfer'"/) - assert.match(overview, /selectedDetail\?\.transferNo/) + assert.doesNotMatch(overview, /转运单号<\/span>/) + assert.match(view.split('
')[1]?.split('
')[0] || '', /selectedDetail\?\.transferNo/) assert.match(overview, /selectedDetail\?\.packageCount \?\? '—'/) assert.match(overview, /warehouseDisplay\(selectedDetail\?\.operatingWarehouseId\)/) assert.match(overview, /selectedDetail\?\.destinationWarehouseName \|\| warehouseDisplay\(selectedDetail\?\.destinationWarehouseCode \|\| selectedDetail\?\.fbaWarehouseCode\)/) @@ -272,26 +274,26 @@ test('PDA 打托完成后客户可从列表或详情确认并查看实测数据' assert.match(view, /confirmTransferFromList\(item\['0'\]\)/) assert.match(view, /selectedDetail\?\.status === 'WAIT_CUSTOMER_CONFIRMATION'/) assert.match(view, /PDA 打托实测数据/) - for (const label of ['托盘号', '库位', '箱数', '长 × 宽 × 高(cm)', '毛重(kg)', '体积(m³)', '完成时间']) assert.equal(view.includes(label), true) + for (const label of ['托盘号', '库位', '箱数', 'SKU 汇总', '毛重(kg)', '体积(m³)', '完成时间']) assert.equal(view.includes(label), true) assert.match(view, /pallet\.warehouseLocationCode/) - assert.match(view, /pallet\.cartonCount/) + assert.match(view, /palletCartons\(pallet\)\.length/) assert.match(view, /pallet\.grossWeightKg/) assert.match(view, /pallet\.volumeCbm/) assert.doesNotMatch(view, /pallet\.items/) }) -test('仓储转运详情按订单概览、PDA 实测数据、货物明细排序并放大测量照片', () => { +test('仓储转运详情按订单概览、货物预报、PDA 实测数据排序并放大测量照片', () => { const detailStart = view.indexOf('
') const detailSource = view.slice(detailStart) const overviewIndex = detailSource.indexOf('

订单概览

') const measurementIndex = detailSource.indexOf('

PDA 打托实测数据

') - const cargoIndex = detailSource.indexOf('

货物明细

') + const cargoIndex = detailSource.indexOf('

预报与实际签收

') assert.ok(overviewIndex >= 0 && overviewIndex < measurementIndex) - assert.ok(measurementIndex < cargoIndex) + assert.ok(cargoIndex < measurementIndex) assert.match(view, /class="warehouse-photo-grid warehouse-measurement-photo-grid"/) assert.match(view, /class="warehouse-photo-item warehouse-measurement-photo-item"/) assert.match(view, /warehouse-measurement-photo-grid\{flex:1;grid-template-columns:repeat\(auto-fill,minmax\(240px,1fr\)\)\}/) - assert.match(view, /warehouse-measurement-photo-item\{width:100%;height:180px\}/) + assert.match(view, /warehouse-measurement-photo-item\{width:100%;height:120px\}/) }) test('仓储转运详情自动加载 PDA 测量照片并释放 Blob URL', () => { @@ -367,3 +369,45 @@ test('仓储转运基础货物不强制 SKU 和中文品名,只换箱唛不强 assert.match(api, /skuCode\?: string; productNameZh\?: string/) assert.match(api, /originalSku\?: string/) }) + + test('SKU 件数和预计箱数独立提交,签收展示实际清点箱数', () => { + assert.match(view, /quantity: item.quantity, packageCount: Number\(item.expectedPackageCount\)/) + assert.match(view, /sum \+ Number\(item.expectedPackageCount \|\| 0\)/) + assert.match(view, /v-model="row.expectedPackageCount"/) + assert.match(view, /Number.isSafeInteger\(row.quantity\)/) + assert.match(view, /Number.isSafeInteger\(row.expectedPackageCount\)/) + assert.match(view, /carton.status === 'COUNTED'/) + assert.match(view, /实际签收箱数/) +}) + +test('转运客户详情保留预报并显示实际签收箱内明细和托盘汇总', () => { + for (const label of ['预报数量(件)', '预报 SKU 种数', '签收箱号明细', '查看箱号明细', 'SKU 汇总', '毛重(kg)', '体积(m³)']) assert.ok(view.includes(label), label) + assert.match(view, /carton.status === 'COUNTED' && pallet.id != null/) + assert.match(view, /String\(carton.transferPalletId\) === String\(pallet.id\)/) + assert.match(view, /carton.transferItemId != null && String\(item.id\) === String\(carton.transferItemId\)/) + assert.match(view, /@click="showTransferCartons = true"/) + assert.match(view, /')), view.indexOf('v-if="props.service === \'transfer\' && selectedDetail?.needRelabel"')) + assert.doesNotMatch(receiptSection.split('\{\{ \[pallet.lengthCm/) +}) + + +test('预报实收同区对照、托盘卡片换行、签收按钮紧邻标题', () => { + const start = view.indexOf('class="warehouse-card warehouse-form-section warehouse-transfer-cargo-card"', view.indexOf('
')) + const section = view.slice(start, view.indexOf('warehouse-transfer-received-card', start)) + assert.ok(section.includes('预报与实际签收')) + assert.ok(section.includes('PDA 打托实测数据')) + assert.ok(section.includes('receivedItemQuantity(item.id)')) + assert.ok(section.includes('receivedItemCartons(item.id).length')) + assert.ok(section.includes('class="warehouse-transfer-pallet-card"')) + assert.ok(section.includes('class="warehouse-transfer-pallet-sku"')) + assert.doesNotMatch(section, /打印箱唛/) + const receipt = view.slice(view.indexOf('warehouse-transfer-received-card', start)).split(' Date: Thu, 15 Oct 2026 18:25:32 +0800 Subject: [PATCH 21/59] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/ReturnOrdersView.vue | 7 ++- .../views/WarehouseServiceView.vue | 48 ++++++++++++------- .../warehouseTransferCartonDisplay.test.mjs | 21 ++++++++ 3 files changed, 59 insertions(+), 17 deletions(-) create mode 100644 src/modules/overseas-warehouse/views/warehouseTransferCartonDisplay.test.mjs diff --git a/src/modules/overseas-warehouse/views/ReturnOrdersView.vue b/src/modules/overseas-warehouse/views/ReturnOrdersView.vue index 319ea63..c44539e 100644 --- a/src/modules/overseas-warehouse/views/ReturnOrdersView.vue +++ b/src/modules/overseas-warehouse/views/ReturnOrdersView.vue @@ -1,5 +1,5 @@ +