diff --git a/src/api/fulfillment.js b/src/api/fulfillment.js
index acd177a..9d9c5d5 100644
--- a/src/api/fulfillment.js
+++ b/src/api/fulfillment.js
@@ -162,6 +162,7 @@ export function normalizeCargoDiscardTask(value) {
quantityUnit: sku?.quantityUnit === '件' ? '件' : '箱',
plannedPackages: count(sku?.plannedPackages),
actualPackages: sku?.actualPackages == null ? null : count(sku.actualPackages),
+ actualQuantityEditable: sku?.actualQuantityEditable === true,
status: String(sku?.status || 'PENDING')
}))
}
diff --git a/src/pages/outbound/outbound.vue b/src/pages/outbound/outbound.vue
index bca0969..439cf9d 100644
--- a/src/pages/outbound/outbound.vue
+++ b/src/pages/outbound/outbound.vue
@@ -45,9 +45,9 @@
废弃订单{{ discardCurrent.discardNo }}出库单 {{ discardCurrent.outboundNo }}
- 本单废弃范围{{ task.palletNo === '散箱' ? '散箱' : task.palletNo }}{{ task.status === 'COMPLETED' ? '已完成' : '待废弃' }}{{ mark }}整托废弃,请核对托盘及计划数量。
+ 本单相关托盘及废弃范围{{ task.palletNo === '散箱' ? '散箱' : task.palletNo }}{{ task.status === 'COMPLETED' ? '已完成' : '待废弃' }}{{ mark }}缺少箱唛与托盘对应关系,请核对相关托盘,以仓库实际废弃箱数为准。
- {{ discardTask.scanMode === 'CARTON' ? (discardTask.palletNo === '散箱' ? '散箱废弃' : '按箱废弃 · ' + discardTask.palletNo) : '托盘 ' + discardTask.palletNo }}{{ discardTask.status === 'COMPLETED' ? '已完成' : '待废弃' }}本次废弃 {{ discardQuantitySummary }}{{ sku.skuCode || '—' }}{{ sku.productName || '—' }} · {{ sku.plannedPackages }} {{ sku.quantityUnit || '箱' }}已核对 {{ discardScannedMarks.length }}/{{ discardTask.cartonMarks.length }} 箱{{ mark }}{{ discardAllCartons ? '全部废弃' : discardScannedMarks.includes(mark) ? '已扫描' : '待扫描' }}已确认本单所选箱子全部废弃,拍照后点击废弃出库完成。废弃现场照片(至少 1 张)照片关联当前废弃作业,完成出库后可在废弃单详情查看。
+ {{ discardTask.scanMode === 'CARTON' ? (discardTask.palletNo === '散箱' ? '散箱废弃' : '按箱废弃 · ' + discardTask.palletNo) : '托盘 ' + discardTask.palletNo }}{{ discardTask.status === 'COMPLETED' ? '已完成' : '待废弃' }}本次废弃 {{ discardQuantitySummary }}{{ sku.skuCode || '—' }}{{ sku.productName || '—' }} · 申请 {{ sku.plannedPackages }} {{ sku.quantityUnit || '箱' }}实际废弃箱数箱(0–{{ sku.plannedPackages }})实际废弃 {{ sku.actualPackages }} {{ sku.quantityUnit || '箱' }}按现场实点填写,未废弃的预留库存将释放。已核对 {{ discardScannedMarks.length }}/{{ discardTask.cartonMarks.length }} 箱{{ mark }}{{ discardAllCartons ? '全部废弃' : discardScannedMarks.includes(mark) ? '已扫描' : '待扫描' }}已确认本单所选箱子全部废弃,拍照后点击废弃出库完成。废弃现场照片(至少 1 张)照片关联当前废弃作业,完成出库后可在废弃单详情查看。
{{ successMessage }}
@@ -110,7 +110,7 @@
const boxes = new Set(task.cartonMarks || []).size || task.skus.filter(sku => sku.quantityUnit !== '件').reduce((total, sku) => total + sku.plannedPackages, 0)
return `${boxes} 箱${pieces ? ` · ${pieces} 件` : ''}`
},
- 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)))))) }
+ 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 => this.validDiscardActual(sku)) && (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() },
@@ -155,7 +155,7 @@
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()
+ this.discardActuals = Object.fromEntries(this.discardTask.skus.map((sku) => [sku.itemId, sku.actualPackages == null ? (sku.actualQuantityEditable ? '' : sku.plannedPackages) : sku.actualPackages])); await this.restoreDiscardPhotos()
} catch (error) { if (this.discardCurrent === order) this.setError(error, '废弃托盘读取失败') }
finally { if (this.discardCurrent === order) this.discardLoading = false }
},
@@ -193,8 +193,10 @@
finally { this.discardPhotoBusy = false }
}, 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: 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 } },
+ // 未填写、负数、小数或超出预留数均阻止提交,保留输入供仓库修正。
+ validDiscardActual(sku) { const raw = this.discardActuals[sku.itemId]; if (raw == null || String(raw).trim() === '') return false; const actual = Number(raw); return Number.isSafeInteger(actual) && actual >= 0 && (sku.actualQuantityEditable === true ? actual <= sku.plannedPackages : actual === sku.plannedPackages) },
+ updateDiscardActual(itemId, event) { this.discardActuals = { ...this.discardActuals, [itemId]: event.detail.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.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 })
diff --git a/tests/outbound-lifecycle.test.mjs b/tests/outbound-lifecycle.test.mjs
index d7d92df..1a6f0e4 100644
--- a/tests/outbound-lifecycle.test.mjs
+++ b/tests/outbound-lifecycle.test.mjs
@@ -182,6 +182,30 @@ test('discard confirmation is guarded against double submission', async () => {
pending.resolve(); await Promise.all([first, second]); assert.equal(submits, 1)
})
+test('无精确箱唛对应的托盘必须填写实际数并按实点提交,不能超量或用空值代替零', async () => {
+ let payload
+ const { vm } = page({ confirmCargoDiscard: async (...args) => { payload = args }, listCargoDiscardTasks: async () => [] })
+ vm.discardCurrent = { discardNo: 'CD-A' }
+ vm.discardTask = { palletNo: 'P-A', status: 'PENDING', scanMode: 'PALLET', cartonMarks: [], skus: [{ itemId: 'sku', plannedPackages: 10, actualQuantityEditable: true }] }
+ vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
+ for (const value of ['', ' ', null, -1, 11, 1.5, 'abc']) {
+ vm.discardActuals = { sku: value }; assert.equal(vm.canConfirmDiscard, false)
+ }
+ vm.discardActuals = { sku: '0' }; assert.equal(vm.canConfirmDiscard, true)
+ vm.discardActuals = { sku: '6' }; assert.equal(vm.canConfirmDiscard, true)
+ await vm.confirmDiscard()
+ assert.equal(payload[0], 'CD-A'); assert.equal(payload[1], 'P-A')
+ assert.deepEqual(payload[2], [{ itemId: 'sku', actualPackages: 6 }])
+})
+
+test('无箱唛对应托盘扫码后实际数保持未填,必须由仓库实点填写', async () => {
+ const { vm } = page({ resolveCargoDiscardPallet: async () => ({ discardNo: 'CD-A', palletNo: 'P-A', cartonMarks: [], scanMode: 'PALLET', skus: [{ itemId: 'sku', plannedPackages: 10, actualQuantityEditable: true }] }) })
+ vm.discardCurrent = { discardNo: 'CD-A' }
+ await vm.resolveDiscardPallet('P-A')
+ assert.equal(vm.discardActuals.sku, '')
+ assert.equal(vm.canConfirmDiscard, false)
+})
+
test('discard retry stays in the discard workflow instead of restoring an old outbound task', async () => {
const { vm } = page(); const calls = []
vm.discardCurrent = { discardNo: 'DISCARD' }; vm.discardPalletCode = 'PALLET'