From c4d43542f41c2312c24acbcab12eb48b6d325dc8 Mon Sep 17 00:00:00 2001
From: liuyihe <2513762445@qq.com>
Date: Fri, 9 Oct 2026 20:39:46 +0800
Subject: [PATCH 1/2] =?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/api/fulfillment.js | 1 +
src/pages/outbound/outbound.vue | 2 +-
src/pages/return-receipt/return-receipt.vue | 6 +++---
tests/fulfillment-workflows.test.mjs | 8 ++++++++
tests/return-receipt-page.test.mjs | 17 +++++++++++++++++
5 files changed, 30 insertions(+), 4 deletions(-)
diff --git a/src/api/fulfillment.js b/src/api/fulfillment.js
index eb2eeba..a8b4f03 100644
--- a/src/api/fulfillment.js
+++ b/src/api/fulfillment.js
@@ -138,6 +138,7 @@ export function outboundPickPayload(item, position, packages, scannedCode = '')
outboundPalletItemId: item.outboundPalletItemId,
inventoryPositionId: position.inventoryPositionId || null,
packages: Number(packages),
+ ...(item.returnSkuQuantity != null ? { productQuantity: Number(item.returnSkuQuantity) } : {}),
cartonMarks: skuScan ? [] : position.cartonMark ? [position.cartonMark] : [],
...(skuScan ? { skuCode: item.skuCode } : {})
}
diff --git a/src/pages/outbound/outbound.vue b/src/pages/outbound/outbound.vue
index 7b120ea..1deda49 100644
--- a/src/pages/outbound/outbound.vue
+++ b/src/pages/outbound/outbound.vue
@@ -38,7 +38,7 @@
任务详情尚无可拣货明细请刷新;若持续为空,请联系平台确认出库货物是否已下发。
- 本次拣货:{{ lineBusinessCode(selectedPosition) }}拣货箱数最多 {{ selectedRemaining }} 箱
+ 本次拣货:{{ lineBusinessCode(selectedPosition) }}本来源计划出库 {{ selectedItem.returnSkuQuantity }} 件,请清点这些件数后确认;余数保留原箱。拣货箱数最多 {{ selectedRemaining }} 箱
完成出库任务尚有 {{ remainingPackages }} 箱未拣,不能完成出库。出库任务已完成,不可重复提交。全部计划货物已扫描核平,服务端将再次校验库存门禁。
diff --git a/src/pages/return-receipt/return-receipt.vue b/src/pages/return-receipt/return-receipt.vue
index 2ba4ebf..282835e 100644
--- a/src/pages/return-receipt/return-receipt.vue
+++ b/src/pages/return-receipt/return-receipt.vue
@@ -21,7 +21,7 @@
本次实物单元
- 包裹码/快递单号
+ 包裹码/快递单号(选填)
托盘码({{ unitType === 'BOX' ? '选填' : '必填' }}){{ palletCheck.empty ? '空托盘,可绑定当前退货单和 SKU' : `当前单托盘,已有 ${palletCheck.skuCodes.join('、')},共 ${palletCheck.quantity} 件` }}
@@ -30,7 +30,7 @@
{{ unitType === 'PARCEL' ? '本次实收数量' : (unitType === 'PALLET' ? '本托实收数量' : '逐箱实收数量') }}本托箱数合计{{ palletBoxCount }}本次箱数第 {{ index + 1 }} 箱{{ item.skuCode }} · 数量{{ item.skuCode }}{{ item.productNameZh || '商品名称未填写' }}剩余 {{ remaining(item) }}数量SKU 箱数
- 本托合计{{ receiptQuantity }}
+ {{ unitType === 'PARCEL' ? '本次合计' : '本托合计' }}{{ receiptQuantity }}
退货打托照片签收过程中可随时拍照{{ photoFileIds.length }} / 20 张{{ photoNames[index] || '退货打托照片' }}
本单已完成打托{{ pallet.palletCode }}{{ pallet.skuCode }} · {{ pallet.quantity }} 件签收批次:{{ pallet.receiptBatchNo }}
@@ -66,7 +66,7 @@
palletBoxCount() { return this.selectedItems.reduce((sum, item) => sum + this.boxCount(item.skuCode), 0) },
activeSkuCode() { return this.selectedItems.find(item => item.skuCode === this.selectedSku)?.skuCode || this.selectedItems[0]?.skuCode || '' },
receiptQuantity() { return this.unitType === 'BOX' ? this.boxRows.reduce((sum, box) => sum + Object.values(box.items).reduce((n, quantity) => n + (Number(quantity) || 0), 0), 0) : Object.values(warehouseWork.returnReceipt.quantities || {}).reduce((total, value) => total + (Number(value) || 0), 0) },
- canConfirm() { if (!this.current || !this.selectedItems.length || this.receiptQuantity <= 0) return false; if (this.unitType === 'BOX') return this.boxRows.length > 0 && this.boxRows.every(box => Object.values(box.items).some(quantity => Number(quantity) > 0)) && this.selectedItems.every(item => this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) <= this.remaining(item)); return (this.unitType === 'PARCEL' ? this.unitCode.trim() : this.palletCode.trim() && this.palletCheck && Number(this.palletLength) > 0 && Number(this.palletWidth) > 0 && Number(this.palletHeight) > 0 && Number(this.palletWeight) > 0 && (this.unitType !== 'PALLET' || this.palletBoxCount > 0)) && this.selectedItems.every((item) => this.quantity(item.skuCode) <= this.remaining(item) && (this.unitType !== 'PALLET' || this.quantity(item.skuCode) === 0 || this.boxCount(item.skuCode) > 0)); },
+ canConfirm() { if (!this.current || !this.selectedItems.length || this.receiptQuantity <= 0) return false; if (this.unitType === 'BOX') return this.boxRows.length > 0 && this.boxRows.every(box => Object.values(box.items).some(quantity => Number(quantity) > 0)) && this.selectedItems.every(item => this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) <= this.remaining(item)); return (this.unitType === 'PARCEL' || (this.palletCode.trim() && this.palletCheck && Number(this.palletLength) > 0 && Number(this.palletWidth) > 0 && Number(this.palletHeight) > 0 && Number(this.palletWeight) > 0 && (this.unitType !== 'PALLET' || this.palletBoxCount > 0))) && this.selectedItems.every((item) => this.quantity(item.skuCode) <= this.remaining(item) && (this.unitType !== 'PALLET' || this.quantity(item.skuCode) === 0 || this.boxCount(item.skuCode) > 0)); },
unitLabel() { return this.unitType === 'PALLET' ? '托盘码' : this.unitType === 'BOX' ? '箱号/箱唛' : '包裹码/快递单号' },
unitPlaceholder() { return this.unitType === 'PALLET' ? '扫描托盘标签' : this.unitType === 'BOX' ? '扫描箱唛或录入箱号' : '扫描包裹面单或录入包裹码' }
,palletVolume() { const l = Number(this.palletLength) || 0; const w = Number(this.palletWidth) || 0; const h = Number(this.palletHeight) || 0; return l > 0 && w > 0 && h > 0 ? (l * w * h).toFixed(2) : '—' }
diff --git a/tests/fulfillment-workflows.test.mjs b/tests/fulfillment-workflows.test.mjs
index 2c65cc7..a219a76 100644
--- a/tests/fulfillment-workflows.test.mjs
+++ b/tests/fulfillment-workflows.test.mjs
@@ -338,3 +338,11 @@ test('dispatch completed or ambiguous pallet scan never silently selects a row',
const shared = await dispatchScanHarness(['A','B'].map(x => ({ palletNo: 'PALLET-SHARED', dispatchItemId: x, scheduledPackages: 1, pickedPackages: 0 })))
shared.scan('PALLET-SHARED'); assert.match(shared.state.error.message, /多条待拣明细/); assert.equal(shared.draft.dispatchItemId, ''); assert.equal(shared.sounds(), 0)
})
+
+test('single SKU piece plan is preserved for packed carton and loose SKU scans', () => {
+ const item={outboundPalletItemId:'12',returnSkuQuantity:'2'};
+ const pos={cartonMark:'RC-1',inventoryPositionId:null};
+ assert.deepEqual(outboundPickPayload(item,pos,1,'RC-1'),{outboundPalletItemId:'12',inventoryPositionId:null,packages:1,productQuantity:2,cartonMarks:['RC-1']});
+ assert.deepEqual(outboundPickPayload({...item,skuCode:'SKU-A'},pos,1,'SKU-A'),{outboundPalletItemId:'12',inventoryPositionId:null,packages:1,productQuantity:2,cartonMarks:[],skuCode:'SKU-A'});
+ assert.equal('productQuantity' in outboundPickPayload({outboundPalletItemId:'12'},pos,1),false);
+});
diff --git a/tests/return-receipt-page.test.mjs b/tests/return-receipt-page.test.mjs
index 2b0e954..224b108 100644
--- a/tests/return-receipt-page.test.mjs
+++ b/tests/return-receipt-page.test.mjs
@@ -57,6 +57,23 @@ test('PALLET page submits the required palletCode through the actual confirmatio
assert.equal(page.current, null)
})
+test('parcel signing allows an empty code but still rejects quantities above the remaining amount', async () => {
+ const source = readFileSync(new URL('../src/pages/return-receipt/return-receipt.vue', import.meta.url), 'utf8')
+ const component = vm.runInNewContext(source.match(/
-
+.destination-summary{display:flex;align-items:center;flex-wrap:wrap;gap:5px;margin-top:6px}.destination-summary .meta-line{margin:0}.destination-tag{display:inline-block;padding:3px 7px;border-radius:5px;background:#edf4ff;color:#285d9f;font-size:10px;line-height:14px}
+.carton-label-list{margin-top:10px;gap:6px}.carton-label-item{display:flex;align-items:center;flex-wrap:nowrap;gap:6px;padding:7px 0;min-width:0}.carton-label{display:flex;flex:1;flex-direction:column;gap:2px;box-sizing:border-box;min-width:0;overflow:hidden;padding:5px 7px;border:1px solid #dce5ef;border-radius:6px;background:#f6f8fb}.fba-label{border-color:#cddff9;background:#eff5ff}.label-caption{color:#718095;font-size:9px;line-height:13px}.label-code{display:block;color:#253e5b;font-size:10px;font-weight:600;line-height:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.label-arrow{flex-shrink:0;color:#8090a3;font-size:12px}.carton-label-item>.destination-tag{flex-shrink:0}.sku-detail-card{padding:8px 10px}.sku-detail-card .business-code{font-size:12px;font-weight:600;line-height:18px;overflow-wrap:anywhere}.sku-detail-card .meta-line{font-size:9px;line-height:14px}.sku-detail-card .link-button{height:26px;font-size:9px;line-height:26px}
+.summary-metrics{display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:4px;margin-left:auto;font-size:9px;line-height:18px}.summary-metric{display:inline-block;padding:1px 4px;border-radius:4px;background:#dcfce7;color:#16a34a;font-weight:700;white-space:nowrap}
+.candidate-heading{display:flex;align-items:baseline;flex-wrap:wrap;gap:4px 8px}.candidate-customer{color:#52647c;font-size:10px;line-height:16px;overflow-wrap:anywhere}
diff --git a/src/pages/return-receipt/return-receipt.vue b/src/pages/return-receipt/return-receipt.vue
index 282835e..5ec3f47 100644
--- a/src/pages/return-receipt/return-receipt.vue
+++ b/src/pages/return-receipt/return-receipt.vue
@@ -21,7 +21,7 @@
本次实物单元
- 包裹码/快递单号(选填)
+ 快递单号(选填)
托盘码({{ unitType === 'BOX' ? '选填' : '必填' }}){{ palletCheck.empty ? '空托盘,可绑定当前退货单和 SKU' : `当前单托盘,已有 ${palletCheck.skuCodes.join('、')},共 ${palletCheck.quantity} 件` }}
@@ -103,7 +103,7 @@
await this.run(async () => {
const rows = this.unitType === 'BOX' ? this.boxRows : [null]
const entries = rows.map((box, index) => {
- const payload = { returnNo: this.current.returnNo, trackingNo: this.trackingNo.trim() || undefined, startedBy: this.startedBy, unitType: this.unitType, unitCode: this.unitType === 'PARCEL' ? this.unitCode.trim() : undefined, palletCode: this.unitType === 'PALLET' || this.unitType === 'BOX' ? this.palletCode.trim() || undefined : undefined, length: this.unitType === 'PALLET' ? Number(this.palletLength) : undefined, width: this.unitType === 'PALLET' ? Number(this.palletWidth) : undefined, height: this.unitType === 'PALLET' ? Number(this.palletHeight) : undefined, weight: this.unitType === 'PALLET' ? Number(this.palletWeight) : undefined, totalBoxCount: this.unitType === 'PALLET' ? totalBoxCount : undefined, items: (this.unitType === 'BOX' ? this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: Number(box.items[item.skuCode]) || 0, boxCount: 1 })) : this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: this.quantity(item.skuCode), boxCount: this.unitType === 'PALLET' ? this.boxCount(item.skuCode) : undefined }))).filter((item) => item.receivedQuantity > 0) }
+ const payload = { returnNo: this.current.returnNo, trackingNo: (this.unitType === 'PARCEL' ? this.unitCode.trim() : this.trackingNo.trim()) || undefined, startedBy: this.startedBy, unitType: this.unitType, unitCode: undefined, palletCode: this.unitType === 'PALLET' || this.unitType === 'BOX' ? this.palletCode.trim() || undefined : undefined, length: this.unitType === 'PALLET' ? Number(this.palletLength) : undefined, width: this.unitType === 'PALLET' ? Number(this.palletWidth) : undefined, height: this.unitType === 'PALLET' ? Number(this.palletHeight) : undefined, weight: this.unitType === 'PALLET' ? Number(this.palletWeight) : undefined, totalBoxCount: this.unitType === 'PALLET' ? totalBoxCount : undefined, items: (this.unitType === 'BOX' ? this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: Number(box.items[item.skuCode]) || 0, boxCount: 1 })) : this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: this.quantity(item.skuCode), boxCount: this.unitType === 'PALLET' ? this.boxCount(item.skuCode) : undefined }))).filter((item) => item.receivedQuantity > 0) }
const key = warehouseIdempotencyKey('returnReceipt', `confirm-${index}`, payload)
return { request: { ...payload, idempotencyKey: key.value }, result: null }
})
diff --git a/src/utils/relabel-error.js b/src/utils/relabel-error.js
new file mode 100644
index 0000000..302c7f3
--- /dev/null
+++ b/src/utils/relabel-error.js
@@ -0,0 +1,8 @@
+export function isRelabelFundsBlocked(error) {
+ return /Relabel funds must be frozen before processing|换标资金尚未冻结/.test(String(error?.message || ''))
+}
+
+export function relabelErrorMessage(error, fallback = '换标作业失败') {
+ if (isRelabelFundsBlocked(error)) return '换标资金尚未冻结,暂时不能认领。请后台检查客户余额或授信及资金冻结结果,处理后再认领。'
+ return error?.message || fallback
+}
diff --git a/tests/relabel-destination.test.mjs b/tests/relabel-destination.test.mjs
index 2884823..0a73723 100644
--- a/tests/relabel-destination.test.mjs
+++ b/tests/relabel-destination.test.mjs
@@ -6,7 +6,8 @@ const page = readFileSync(new URL('../src/pages/relabel/relabel.vue', import.met
test('PDA 箱唛换标明确展示每箱的新标签和 FBA 目的仓', () => {
assert.match(page, /item\.cartonMark[^\n]+item\.fbaCartonMark/)
- assert.match(page, /FBA 目的仓:\{\{ item\.destinationFbaWarehouseCode/)
+ assert.match(page, /class="destination-summary"[^\n]+FBA 目的仓:[^\n]+v-for="destination in fbaDestinations"/)
+ assert.match(page, /v-if="fbaDestinations.length > 1"[^\n]+item\.destinationFbaWarehouseCode/)
assert.match(page, /confirmedItem\?\.destinationFbaWarehouseCode \|\| item\.destinationFbaWarehouseCode/)
assert.match(page, /请贴 FBA 箱唛[^`]+FBA 目的仓:\$\{this\.lastCarton\.destinationFbaWarehouseCode/s)
})
diff --git a/tests/relabel-error.test.mjs b/tests/relabel-error.test.mjs
new file mode 100644
index 0000000..2abf2af
--- /dev/null
+++ b/tests/relabel-error.test.mjs
@@ -0,0 +1,12 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { isRelabelFundsBlocked, relabelErrorMessage } from '../src/utils/relabel-error.js'
+
+test('wallet block receives actionable Chinese guidance without version-conflict recovery', () => {
+ const error = { code: 'A0409', message: 'Relabel funds must be frozen before processing' }
+ assert.equal(isRelabelFundsBlocked(error), true)
+ assert.match(relabelErrorMessage(error), /后台检查客户余额或授信/)
+ assert.doesNotMatch(relabelErrorMessage(error), /刷新任务版本/)
+ assert.equal(isRelabelFundsBlocked({ code: 'A0409', message: 'Task version changed' }), false)
+ assert.equal(relabelErrorMessage({ message: 'Task version changed' }), 'Task version changed')
+})
diff --git a/tests/relabel-pallet-work.test.mjs b/tests/relabel-pallet-work.test.mjs
index ca1f068..192b7d3 100644
--- a/tests/relabel-pallet-work.test.mjs
+++ b/tests/relabel-pallet-work.test.mjs
@@ -14,6 +14,7 @@ function context() {
] } }
for (const [key, method] of Object.entries(component.methods)) value[key] = method.bind(value)
for (const [key, method] of Object.entries(component.computed)) Object.defineProperty(value, key, { get: method.bind(value) })
+ value.confirmBindingChoice = async () => true
value.selectSource({ positionId: '1', palletId: '20', palletCode: 'SOURCE', movablePackages: 2, cartonMarks: ['A', 'B', 'C'] })
return value
}
@@ -58,7 +59,7 @@ test('pallet comparison tolerates mixed API versions and canonical code formatti
test('quantity disassembly has no extra carton scanner', () => {
assert.doesNotMatch(source, /relabel-move-carton/)
assert.match(source, /actualSource:/)
- assert.match(source, /仍归属/)
+ assert.match(source, /this\.message = `托盘 \$\{this\.target\.palletCode\} 已绑定`/)
})
test('source pallet list is concise and hides rows with no movable packages', () => {
@@ -70,7 +71,8 @@ test('source pallet list is concise and hides rows with no movable packages', ()
assert.deepEqual(value.operableSourceRows.map(row => row.palletCode), ['PALLET-1'])
assert.match(source, />托盘)
assert.match(source, />刷新)
- assert.match(source, /托盘号:\{\{ row\.palletCode \}\} · 入仓码:\{\{ row\.inboundCode \}\}/)
+ assert.match(source, /class="source-caption">托盘号<\/text>\{\{ row\.palletCode \}\}/)
+ assert.doesNotMatch(template, /入仓码|row\.inboundCode/)
assert.match(source, /可拆\{\{ isReturnBulk \? '件数' : '箱数' \}\}:\{\{ row\.movablePackages \}\}/)
assert.doesNotMatch(template, /刷新来源托盘/)
assert.doesNotMatch(template, /按原入仓单拆托/)
@@ -104,10 +106,55 @@ test('target confirmation submits binding directly, with no second submit button
value.bind = async () => { binds++ }
await value.confirmTargetAndBind()
assert.equal(binds, 1)
- assert.match(source, /@tap="confirmTargetAndBind"/)
+ assert.match(source, /@submit="confirmTargetAndBind"/)
assert.doesNotMatch(source, /@tap="bind"/)
})
+test('source scan asks before accepting a listed pallet and rejects unknown pallets', async () => {
+ const value = context()
+ value.sourceRows = [{ positionId: '1', palletCode: 'SOURCE', movablePackages: 2 }]
+ value.sourceCode = 'SOURCE'
+ let prompts = 0
+ value.resolve = async () => { value.source = { palletCode: 'SOURCE' } }
+ value.confirmBindingChoice = async () => { prompts++; return false }
+ await value.confirmSourceScan()
+ assert.equal(value.source, null)
+ value.confirmBindingChoice = async () => { prompts++; return true }
+ await value.confirmSourceScan()
+ assert.equal(value.source.palletCode, 'SOURCE')
+ value.sourceCode = 'OTHER'
+ value.showBindingMessage = () => {}
+ value.resolve = async () => assert.fail('unknown pallet must not resolve')
+ await value.confirmSourceScan()
+ assert.equal(value.source, null)
+ assert.equal(prompts, 2)
+})
+
+test('declining target confirmation does not move stock', async () => {
+ const value = context()
+ value.quantity = '1'; value.source = { palletId: '20', palletCode: 'SOURCE' }; value.targetCode = 'TARGET'
+ value.resolve = async () => { value.target = { palletId: '30', palletCode: 'TARGET' } }
+ value.confirmBindingChoice = async () => false
+ value.bind = async () => assert.fail('cancelled target must not bind')
+ await value.confirmTargetAndBind()
+ assert.equal(value.target, null)
+})
+
+test('simplified split automatically fills a batch and counts saved bindings without repeats', async () => {
+ const value = context()
+ value.task.items = ['A', 'B', 'C'].map(cartonMark => ({ cartonMark, operationType: 'CARTON_LABEL', expectedPackages: 1, relabelledPackages: 1, destinationFbaWarehouseCode: 'ONT8' }))
+ value.task.history = [{ action: 'PALLET_BIND', detail: JSON.stringify({ cartonMarks: ['A'], targetPallets: ['OLD-TARGET'] }) }]
+ assert.equal(value.requiredSplitPackages, 3)
+ assert.equal(value.splitPackages, 1)
+ value.quantity = '1'; value.marks = []; value.source = { palletId: '20', palletCode: 'SOURCE' }; value.targetCode = 'TARGET'
+ value.resolve = async () => { value.target = { palletId: '30', palletCode: 'TARGET' } }
+ value.bind = async () => { assert.deepEqual(value.marks, ['B']); assert.equal(value.readyToBind, true) }
+ await value.confirmTargetAndBind()
+ assert.doesNotMatch(template, /按箱数填入本批换标箱|选择本批新目的仓|本批确认|@tap="toggleMark/)
+ value.task.history.push({ action: 'REWORK_ARCHIVE', detail: '{}' })
+ assert.equal(value.splitPackages, 0)
+})
+
test('missing source scan shows modal and never submits a binding', async () => {
const value = context(); value.quantity = '1'; value.fillBatch(); value.targetCode = 'TARGET'
let message = ''
diff --git a/tests/return-receipt-page.test.mjs b/tests/return-receipt-page.test.mjs
index 224b108..3bf41e6 100644
--- a/tests/return-receipt-page.test.mjs
+++ b/tests/return-receipt-page.test.mjs
@@ -70,7 +70,17 @@ test('parcel signing allows an empty code but still rejects quantities above the
await page.confirmReceipt()
assert.equal(calls.length, 1)
assert.equal(calls[0].trackingNo, undefined)
- assert.equal(calls[0].unitCode, '')
+ assert.equal(calls[0].unitCode, undefined)
+ assert.equal(calls[0].palletCode, undefined)
+})
+
+test('parcel waybill is submitted as trackingNo and leaves unitCode for the server', async () => {
+ const calls = []
+ const page = setup(async payload => { calls.push(payload); return { unitType: 'PARCEL' } })
+ page.unitType = 'PARCEL'; page.unitCode = ' sf561626 '; page.trackingNo = 'stale-from-pallet'
+ await page.confirmReceipt()
+ assert.equal(calls[0].trackingNo, 'sf561626')
+ assert.equal(calls[0].unitCode, undefined)
assert.equal(calls[0].palletCode, undefined)
})