From e556d750818398a81e2c2a0f9ff43fb2a34d8dbb Mon Sep 17 00:00:00 2001
From: liuyihe <2513762445@qq.com>
Date: Wed, 14 Oct 2026 17:41:11 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/api/fulfillment.js | 11 +++---
src/pages/outbound/outbound.vue | 33 ++++++++++--------
tests/outbound-lifecycle.test.mjs | 56 ++++++++++++++++++++++++++++++-
3 files changed, 81 insertions(+), 19 deletions(-)
diff --git a/src/api/fulfillment.js b/src/api/fulfillment.js
index 82cb71a..2aa4a04 100644
--- a/src/api/fulfillment.js
+++ b/src/api/fulfillment.js
@@ -153,7 +153,10 @@ export function normalizeCargoDiscardTask(value) {
return {
...task,
discardNo: String(task?.discardNo || ''),
+ outboundNo: String(task?.outboundNo || ''),
palletNo: String(task?.palletNo || ''),
+ scanMode: task?.scanMode === 'CARTON' ? 'CARTON' : 'PALLET',
+ cartonMarks: asList(task?.cartonMarks).map(String),
status: String(task?.status || 'PENDING'),
skus: asList(task?.skus).map((sku) => ({
...sku,
@@ -171,12 +174,12 @@ export async function listCargoDiscardTasks() {
return asList(await overseasRequest('GET', CARGO_DISCARD_BASE)).map(normalizeCargoDiscardTask)
}
-export async function resolveCargoDiscardPallet(palletNo) {
- return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/resolve`, { palletNo }))
+export async function resolveCargoDiscardPallet(palletNo, discardNo) {
+ return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/resolve`, { palletNo, ...(discardNo ? { discardNo } : {}) }))
}
-export async function confirmCargoDiscard(discardNo, palletNo, lines, photoFileIds) {
- return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, { lines, photoFileIds }))
+export async function confirmCargoDiscard(discardNo, palletNo, lines, photoFileIds, scannedCartonMarks = [], confirmAllCartons = false) {
+ return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, { lines, photoFileIds, scannedCartonMarks, confirmAllCartons }))
}
const discardPhotoPath = (discardNo, palletNo) => `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/photos/files`
diff --git a/src/pages/outbound/outbound.vue b/src/pages/outbound/outbound.vue
index 60097e7..65cdc72 100644
--- a/src/pages/outbound/outbound.vue
+++ b/src/pages/outbound/outbound.vue
@@ -7,7 +7,7 @@
当前任务出库作业详情
-
+
任务号直接恢复;退货整托出库扫托盘码,散箱出库扫箱唛。
@@ -20,7 +20,7 @@
暂无出库任务可刷新列表,或扫描任务号恢复未完成作业。
- 废弃订单{{ discardOrderCount }} 个订单
+ 废弃订单{{ discardOrderCount }} 个订单
@@ -44,9 +44,9 @@
- 废弃订单{{ discardCurrent.discardNo }}
- 扫描该废弃订单对应的托盘,再填写每个 SKU 的实际废弃数量。
- 托盘 {{ discardTask.palletNo }}{{ discardTask.status === 'COMPLETED' ? '已完成' : '待废弃' }}{{ sku.skuCode || '—' }}{{ sku.productName || '—' }} · 计划 {{ sku.plannedPackages }} 箱实际废弃数量最多 {{ sku.plannedPackages }} 箱废弃现场照片(至少 1 张)照片关联当前托盘,完成出库后可在废弃单详情查看。
+ 废弃订单{{ discardCurrent.discardNo }}出库单 {{ discardCurrent.outboundNo }}
+ 整托废弃扫描托盘号;部分箱子废弃或散箱作业,核对本单所选箱唛,也可一键废弃所选箱子。未选箱子保留库存。
+ {{ discardTask.scanMode === 'CARTON' ? (discardTask.palletNo === '散箱' ? '散箱废弃' : '按箱废弃 · ' + discardTask.palletNo) : '托盘 ' + discardTask.palletNo }}{{ discardTask.status === 'COMPLETED' ? '已完成' : '待废弃' }}{{ sku.skuCode || '—' }}{{ sku.productName || '—' }} · 计划 {{ sku.plannedPackages }} 箱本单所选废弃 {{ sku.plannedPackages }} 箱已核对 {{ discardScannedMarks.length }}/{{ discardTask.cartonMarks.length }} 箱{{ mark }}{{ discardAllCartons ? '全部废弃' : discardScannedMarks.includes(mark) ? '已扫描' : '待扫描' }}已确认本单所选箱子全部废弃,拍照后点击废弃出库完成。废弃现场照片(至少 1 张)照片关联当前废弃作业,完成出库后可在废弃单详情查看。
{{ successMessage }}
@@ -66,7 +66,7 @@
export default {
components: { PortalHeader, ScanTargetInput },
- data() { return { user: session.currentUser, tasks: [], discardTasks: [], current: null, discardCurrent: null, discardTask: null, discardActuals: {}, discardPhotos: [], discardPhotoUrls: {}, discardPhotoErrors: {}, discardPhotoBusy: false, discardPhotoReady: false, discardPalletCode: '', discardLoading: false, discardBusy: false, selectedItem: null, selectedPosition: null, selectedScanCode: '', scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '', bootstrapped: false } },
+ data() { return { user: session.currentUser, tasks: [], discardTasks: [], current: null, discardCurrent: null, discardTask: null, discardActuals: {}, discardScannedMarks: [], discardAllCartons: false, discardPhotos: [], discardPhotoUrls: {}, discardPhotoErrors: {}, discardPhotoBusy: false, discardPhotoReady: false, discardPalletCode: '', discardLoading: false, discardBusy: false, selectedItem: null, selectedPosition: null, selectedScanCode: '', scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '', bootstrapped: false } },
computed: {
displayItems() {
const items = this.current?.items || []
@@ -88,8 +88,8 @@
return rows
},
hasReturnPallet() { return this.current?.outboundType === 'RETURN_OUTBOUND' && (this.current.items || []).some(item => item.palletNo) },
- scanLabel() { return this.discardCurrent ? '废弃订单托盘码' : this.hasReturnPallet ? '扫描托盘号(退货整托出库)' : '任务号、出库单号、排车单号、托盘号或箱码' },
- scanPlaceholder() { return this.discardCurrent ? '扫描废弃订单对应托盘码' : this.hasReturnPallet ? '扫描托盘号;散箱扫描箱唛' : '扫描任务号 / 出库单号 / 排车单号 / 托盘号 / 箱码' },
+ scanLabel() { return this.discardCurrent ? (this.discardTask?.scanMode === 'CARTON' ? '废弃箱唛' : '废弃订单托盘码') : this.hasReturnPallet ? '扫描托盘号(退货整托出库)' : '任务号、出库单号、排车单号、托盘号或箱码' },
+ scanPlaceholder() { return this.discardCurrent ? (this.discardTask?.scanMode === 'CARTON' ? '扫描本单所选箱唛' : '扫描废弃订单对应托盘码') : this.hasReturnPallet ? '扫描托盘号;散箱扫描箱唛' : '扫描任务号 / 出库单号 / 排车单号 / 托盘号 / 箱码' },
recentTaskNo() { return fulfillment.outbound.currentTaskNo || '' },
packages() { return fulfillment.outbound.packages },
plannedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.plannedPackages, 0) },
@@ -99,9 +99,9 @@
taskOperable() { return ['PENDING', 'IN_PROGRESS'].includes(this.current?.status) },
canPick() { return this.taskOperable && this.selectedItem && this.selectedPosition && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining },
canComplete() { return this.plannedPackages > 0 && this.remainingPackages === 0 && this.taskOperable },
- discardOrders() { const groups = new Map(); for (const task of this.discardTasks) { const current = groups.get(task.discardNo) || { discardNo: task.discardNo, palletCount: 0, pendingPalletCount: 0 }; current.palletCount += 1; if (task.status !== 'COMPLETED') current.pendingPalletCount += 1; groups.set(task.discardNo, current) } return [...groups.values()] },
+ discardOrders() { const groups = new Map(); for (const task of this.discardTasks) { const current = groups.get(task.discardNo) || { discardNo: task.discardNo, outboundNo: task.outboundNo, palletCount: 0, cartonCount: 0, pendingPalletCount: 0 }; if (task.scanMode === 'CARTON') current.cartonCount += (task.cartonMarks || []).length; else current.palletCount += 1; if (task.status !== 'COMPLETED') current.pendingPalletCount += 1; groups.set(task.discardNo, current) } return [...groups.values()] },
discardOrderCount() { return this.discardOrders.length },
- canConfirmDiscard() { return Boolean(this.discardPhotoReady && this.discardPhotos.length > 0 && !this.discardPhotoBusy && !this.discardLoading && !this.discardBusy && this.discardTask && this.discardTask.status !== 'COMPLETED' && this.discardTask.skus.length && this.discardTask.skus.every((sku) => Number.isInteger(Number(this.discardActuals[sku.itemId])) && Number(this.discardActuals[sku.itemId]) >= 0 && Number(this.discardActuals[sku.itemId]) <= sku.plannedPackages)) }
+ canConfirmDiscard() { const task = this.discardTask; return Boolean(this.discardPhotoReady && this.discardPhotos.length > 0 && !this.discardPhotoBusy && !this.discardLoading && !this.discardBusy && task && task.status !== 'COMPLETED' && task.skus.length && task.skus.every(sku => Number(this.discardActuals[sku.itemId]) === sku.plannedPackages) && (task.scanMode !== 'CARTON' || (task.cartonMarks?.length && (this.discardAllCartons || task.cartonMarks.every(mark => this.discardScannedMarks.includes(mark)))))) }
},
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (this.discardPhotoBusy || this.discardBusy) return; if (this.discardCurrent) { if (this.discardTask) this.restoreDiscardPhotos(); return } if (!this.bootstrapped) { this.bootstrapped = true; this.scanCode = ''; this.loadTasks(); return } if (this.current?.taskNo) this.openTask(this.current.taskNo); else this.loadTasks() },
onHide() { this.stopScanner() },
@@ -129,19 +129,22 @@
}, true)
},
closeTask() { this.current = null; this.selectedItem = null; this.selectedPosition = null; this.error = null; this.successMessage = ''; updateFulfillmentDraft('outbound', { currentTaskNo: '', scanCode: '', packages: 1 }); this.loadTasks() },
- openDiscardOrder(discardNo) { if (this.discardPhotoBusy) return; this.resetDiscardPhotos(); const code = String(discardNo || '').trim(); if (!code || this.discardBusy || this.busy || this.loading) return; this.current = null; this.discardLoading = false; this.discardCurrent = { discardNo: code }; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.error = null },
+ openDiscardOrder(discardNo) { if (this.discardPhotoBusy) return; this.resetDiscardPhotos(); const code = String(discardNo || '').trim(); if (!code || this.discardBusy || this.busy || this.loading) return; this.current = null; this.discardLoading = false; this.discardCurrent = { discardNo: code, outboundNo: this.discardOrders.find(order => order.discardNo === code)?.outboundNo || '' }; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.discardScannedMarks = []; this.discardAllCartons = false; this.error = null; const loose = this.discardTasks.find(task => task.discardNo === code && task.scanMode === 'CARTON' && task.status !== 'COMPLETED'); if (loose) { this.discardTask = normalizeCargoDiscardTask(loose); this.discardActuals = Object.fromEntries(this.discardTask.skus.map(sku => [sku.itemId, sku.plannedPackages])); this.restoreDiscardPhotos() } },
closeDiscard() { if (this.discardBusy || this.discardPhotoBusy) return; this.resetDiscardPhotos(); this.discardLoading = false; this.discardCurrent = null; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.loadTasks() },
updateDiscardPalletCode(value) { this.discardPalletCode = value },
async resolveDiscardPallet(raw) {
const code = String(raw?.detail?.value ?? raw ?? this.discardPalletCode).trim()
if (!code || !this.discardCurrent || this.discardBusy || this.discardLoading || this.discardPhotoBusy) return
+ if (this.discardTask?.scanMode === 'CARTON' && this.discardTask.cartonMarks.includes(code)) { if (!this.discardScannedMarks.includes(code)) this.discardScannedMarks = [...this.discardScannedMarks, code]; this.error = null; playSuccessSound(); return }
+ if (this.discardTask?.scanMode === 'CARTON' && !this.discardTasks.some(task => task.discardNo === this.discardCurrent.discardNo && task.scanMode !== 'CARTON' && task.palletNo === code)) { this.setError(new Error('箱唛不属于当前废弃作业'), '箱唛核对失败'); return }
const order = this.discardCurrent
- this.resetDiscardPhotos(); this.discardPalletCode = code; this.discardLoading = true; this.discardTask = null; this.discardActuals = {}; this.error = null
+ this.resetDiscardPhotos(); this.discardScannedMarks = []; this.discardAllCartons = false; this.discardPalletCode = code; this.discardLoading = true; this.discardTask = null; this.discardActuals = {}; this.error = null
try {
- const task = await resolveCargoDiscardPallet(code)
+ const task = await resolveCargoDiscardPallet(code, order.discardNo)
if (this.discardCurrent !== order) return
if (task.discardNo !== order.discardNo) throw new Error('扫描的托盘不属于当前废弃订单')
this.discardTask = normalizeCargoDiscardTask(task)
+ if (this.discardTask.scanMode === 'CARTON' && this.discardTask.cartonMarks.includes(code)) this.discardScannedMarks = [code]
this.discardActuals = Object.fromEntries(this.discardTask.skus.map((sku) => [sku.itemId, sku.actualPackages == null ? sku.plannedPackages : sku.actualPackages])); await this.restoreDiscardPhotos()
} catch (error) { if (this.discardCurrent === order) this.setError(error, '废弃托盘读取失败') }
finally { if (this.discardCurrent === order) this.discardLoading = false }
@@ -181,11 +184,13 @@
}, fail: () => { this.discardPhotoBusy = false } })
},
updateDiscardActual(itemId, event) { const value = Math.max(0, Math.trunc(Number(event.detail.value || 0))); this.discardActuals = { ...this.discardActuals, [itemId]: value } },
- async confirmDiscard() { if (this.discardBusy || this.discardLoading || !this.canConfirmDiscard || !this.discardTask || !this.discardCurrent) return; this.discardBusy = true; try { const lines = this.discardTask.skus.map((sku) => ({ itemId: sku.itemId, actualPackages: Number(this.discardActuals[sku.itemId]) })); await confirmCargoDiscard(this.discardCurrent.discardNo, this.discardTask.palletNo, lines, this.discardPhotos.map(photo => String(photo.fileId))); this.successMessage = '废弃出库作业及照片已提交,等待仓库确认'; this.resetDiscardPhotos(); this.discardPalletCode = ''; this.discardTask = null; this.discardTasks = await listCargoDiscardTasks(); playSuccessSound() } catch (error) { this.setError(error, '废弃出库失败') } finally { this.discardBusy = false } },
+ async confirmDiscard() { if (this.discardBusy || this.discardLoading || !this.canConfirmDiscard || !this.discardTask || !this.discardCurrent) return; this.discardBusy = true; try { const lines = this.discardTask.skus.map((sku) => ({ itemId: sku.itemId, actualPackages: sku.plannedPackages })); await confirmCargoDiscard(this.discardCurrent.discardNo, this.discardTask.palletNo, lines, this.discardPhotos.map(photo => String(photo.fileId)), this.discardScannedMarks, this.discardAllCartons); this.successMessage = '废弃出库作业及照片已提交,已完成'; this.resetDiscardPhotos(); this.discardPalletCode = ''; this.discardTask = null; this.discardTasks = await listCargoDiscardTasks(); playSuccessSound() } catch (error) { this.setError(error, '废弃出库失败') } finally { this.discardBusy = false } },
handleScan(raw) {
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim(); if (!code || this.busy || this.loading || this.discardBusy || this.discardLoading) return
console.info('[Overseas PDA outbound] scan', { code, task: this.current?.taskNo || '', discard: this.discardCurrent?.discardNo || '' }); this.scanCode = code; updateFulfillmentDraft('outbound', { scanCode: code })
if (this.discardCurrent) return this.resolveDiscardPallet(code)
+ const discardOrder = !this.current && this.discardOrders.find(order => order.discardNo === code || order.outboundNo === code)
+ if (discardOrder) return this.openDiscardOrder(discardOrder.discardNo)
if (!this.current) return this.openScannedTask(code)
if (code === this.current.taskNo || code === this.current.outboundNo) return this.openTask(this.current.taskNo)
if (!this.taskOperable) return
diff --git a/tests/outbound-lifecycle.test.mjs b/tests/outbound-lifecycle.test.mjs
index 06c0425..787fee3 100644
--- a/tests/outbound-lifecycle.test.mjs
+++ b/tests/outbound-lifecycle.test.mjs
@@ -18,9 +18,29 @@ function page(overrides = {}) {
return { vm, calls, definition }
}
+test('scanning a discard outbound document opens the discard workflow with its linked number', () => {
+ const { vm } = page()
+ vm.discardTasks = [{ discardNo: 'CD-1', outboundNo: 'OB-1', palletNo: 'P-1', status: 'PENDING' }]
+ vm.openScannedTask = () => { throw new Error('must not enter regular inventory picking') }
+ vm.handleScan('OB-1')
+ assert.equal(vm.discardCurrent.discardNo, 'CD-1')
+ assert.equal(vm.discardCurrent.outboundNo, 'OB-1')
+})
+
+test('discard pallet resolution passes the selected discard number', async () => {
+ let received
+ const { vm } = page({ resolveCargoDiscardPallet: async (...args) => {
+ received = args
+ return { discardNo: 'CD-2', palletNo: 'P-1', skus: [] }
+ } })
+ vm.discardCurrent = { discardNo: 'CD-2' }
+ await vm.resolveDiscardPallet('P-1')
+ assert.deepEqual(received, ['P-1', 'CD-2'])
+})
+
test('discard requires restored photos and retains task while native camera returns', async () => {
const { vm, definition } = page({ requireSession: () => true, restoreFulfillmentDraft() {} })
- vm.discardCurrent = { discardNo: 'CD-1' }; vm.discardTask = { palletNo: 'PALLET', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 3 }] }; vm.discardActuals = { sku: 1 }
+ vm.discardCurrent = { discardNo: 'CD-1' }; vm.discardTask = { palletNo: 'PALLET', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 3 }] }; vm.discardActuals = { sku: 3 }
assert.equal(vm.canConfirmDiscard, false)
vm.discardPhotoReady = true; assert.equal(vm.canConfirmDiscard, false)
vm.discardPhotos = [{ fileId: '101' }]; assert.equal(vm.canConfirmDiscard, true)
@@ -129,3 +149,37 @@ test('closing or switching discard order ignores the previous resolve response',
pending.resolve({ discardNo: 'FIRST', palletNo: 'PALLET', skus: [] }); await first
assert.equal(vm.discardTask, null); assert.equal(vm.error, null)
})
+
+
+test('loose disposal opens all marks, blocks partial scans and accepts explicit bulk confirmation', async () => {
+ const { vm } = page()
+ vm.discardTasks = [{ discardNo: 'CD-L', palletNo: '散箱', scanMode: 'CARTON', cartonMarks: ['BOX-1', 'BOX-2'], status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 2 }] }]
+ vm.openDiscardOrder('CD-L')
+ await Promise.resolve()
+ assert.equal(vm.discardTask.scanMode, 'CARTON')
+ assert.deepEqual(vm.discardTask.cartonMarks, ['BOX-1', 'BOX-2'])
+ vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
+ assert.equal(vm.canConfirmDiscard, false)
+ await vm.resolveDiscardPallet('BOX-1'); await vm.resolveDiscardPallet('BOX-1')
+ assert.deepEqual(vm.discardScannedMarks, ['BOX-1'])
+ assert.equal(vm.canConfirmDiscard, false)
+ await vm.resolveDiscardPallet('BOX-2')
+ assert.equal(vm.canConfirmDiscard, true)
+ vm.discardActuals.sku = 1; assert.equal(vm.canConfirmDiscard, false)
+ vm.discardActuals.sku = 2; vm.discardScannedMarks = []; vm.discardAllCartons = true
+ assert.equal(vm.canConfirmDiscard, true)
+ vm.discardPhotos = []; assert.equal(vm.canConfirmDiscard, false)
+})
+
+
+test('partial bound pallet confirmation sends only selected marks and preserves all photo gates', async () => {
+ let payload
+ const { vm } = page({ confirmCargoDiscard: async (...args) => { payload = args }, listCargoDiscardTasks: async () => [] })
+ vm.discardTasks = [{ discardNo: 'CD-P', outboundNo: 'OB-P', palletNo: 'P-1', scanMode: 'CARTON', cartonMarks: ['BOX-2'], status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }]
+ vm.openDiscardOrder('CD-P'); await Promise.resolve()
+ vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
+ await vm.resolveDiscardPallet('BOX-2'); assert.equal(vm.canConfirmDiscard, true)
+ await vm.resolveDiscardPallet('BOX-OTHER'); assert.deepEqual(vm.discardScannedMarks, ['BOX-2']); assert.equal(vm.discardTask.palletNo, 'P-1')
+ await vm.confirmDiscard()
+ assert.deepEqual(payload, ['CD-P', 'P-1', [{ itemId: 'sku', actualPackages: 1 }], ['101'], ['BOX-2'], false])
+})