This commit is contained in:
2026-11-03 11:44:04 +08:00
parent 8857faf457
commit bab96743d7
5 changed files with 23 additions and 10 deletions
+1
View File
@@ -186,6 +186,7 @@ export function normalizeCargoDiscardTask(value) {
plannedPackages: count(sku?.plannedPackages),
actualPackages: sku?.actualPackages == null ? null : count(sku.actualPackages),
actualQuantityEditable: sku?.actualQuantityEditable === true,
allowedCartonMarks: asList(sku?.allowedCartonMarks).map(String),
status: String(sku?.status || 'PENDING')
}))
}
+1
View File
@@ -173,6 +173,7 @@
if ((this.discardCurrentTasks || []).some(task => task.palletNo === code)) { this.setError(new Error('请扫描箱唛,不能把托盘码作为箱唛'), '箱唛核对失败'); return }
const sku = this.discardQuantityLines.length === 1 ? this.discardQuantityLines[0] : this.discardQuantityLines.find(line => line.itemId === this.discardScanItemId)
if (!sku) { this.setError(new Error('请先选择当前扫描的货物'), '箱唛核对失败'); return }
if (!(sku.allowedCartonMarks || []).includes(code)) { this.setError(new Error('箱唛不属于本废弃单的原入仓库存'), '箱唛核对失败'); return }
if (this.discardUnboundScans.some(scan => scan.mark === code)) { this.discardCartonCode = ''; return }
if (code.length > 128 || this.discardUnboundScans.filter(scan => scan.itemId === sku.itemId).length >= sku.plannedPackages || this.discardUnboundScans.length >= (this.discardTask.remainingPackages ?? Infinity)) { this.setError(new Error('扫描箱数不能超过本托预留或申请剩余箱数'), '箱唛核对失败'); return }
this.discardUnboundScans = [...this.discardUnboundScans, { itemId: sku.itemId, mark: code }]
+5 -5
View File
@@ -28,10 +28,10 @@
<view class="work-card">
<text class="card-title">本次实物单元</text>
<view class="unit-tabs"><button v-for="item in unitOptions" :key="item.value" :class="['unit-tab', {active: unitType === item.value}]" @tap="selectUnitType(item.value)">{{ item.label }}</button></view>
<view v-if="unitType === 'PARCEL'"><ScanTargetInput name="unit" v-model="unitCode" label="本批快递单号(选填,只填写一次)" placeholder="扫描本批快递面单,可留空" :active="activeTarget === 'unit'" @activate="activeTarget = 'unit'" @submit="acceptUnit" /><text class="hint">本批共用一个快递单号;填写包裹数,再逐包裹录入 SKU 数量,各包裹单独生成仓库标签。</text></view>
<view v-if="unitType === 'PARCEL'"><ScanTargetInput name="unit" v-model="unitCode" label="本批快递单号(选填,只填写一次)" placeholder="扫描本批快递面单,可留空" :active="activeTarget === 'unit'" @activate="activeTarget = 'unit'" @submit="acceptUnit" /></view>
<template v-else>
<view><text class="field-label">托盘码({{ unitType === 'BOX' ? '选填' : '必填' }})</text><ScanTargetInput name="pallet" v-model="palletCode" label="托盘码" placeholder="扫描已有托盘或新托盘标签" :active="activeTarget === 'pallet'" @activate="activeTarget = 'pallet'" @submit="acceptPallet" /><text v-if="palletCheck" class="scan-hint">{{ palletCheck.empty ? '空托盘,可绑定当前退货单和 SKU' : `当前单托盘,已有 ${palletCheck.skuCodes.join('、')},共 ${palletCheck.quantity} 件` }}</text><button class="secondary pallet-generate" :disabled="busy" @tap="generatePallet">生成新托盘</button></view>
<view v-if="unitType === 'PALLET'" class="dimension-grid"><label><text>托盘长(cm)</text><input class="number-input" type="digit" v-model="palletLength" placeholder="请输入长度(cm)" /></label><label><view class="dimension-label"><text>托盘宽(cm)</text><text class="volume-text">体积 {{ palletVolume }} cm³</text></view><input class="number-input" type="digit" v-model="palletWidth" placeholder="请输入宽度(cm)" /></label><label><text>托盘高(cm)</text><input class="number-input" type="digit" v-model="palletHeight" placeholder="请输入高度(cm)" /></label><label><text>托盘重量(kg)</text><input class="number-input" type="digit" v-model="palletWeight" placeholder="请输入重量(kg)" /></label></view>
<view v-if="unitType === 'PALLET'" class="dimension-grid"><label><text>托盘长(cm)</text><input class="number-input" type="digit" v-model="palletLength" placeholder="请输入长度(cm)" /></label><label><view class="dimension-label"><text>托盘宽(cm)</text><text class="volume-text">体积 {{ palletVolume }} m³</text></view><input class="number-input" type="digit" v-model="palletWidth" placeholder="请输入宽度(cm)" /></label><label><text>托盘高(cm)</text><input class="number-input" type="digit" v-model="palletHeight" placeholder="请输入高度(cm)" /></label><label><text>托盘重量(kg)</text><input class="number-input" type="digit" v-model="palletWeight" placeholder="请输入重量(kg)" /></label></view>
<view><text class="field-label">快递单号(选填)</text><ScanTargetInput name="tracking" v-model="trackingNo" label="快递单号" placeholder="扫描快递面单" :active="activeTarget === 'tracking'" @activate="activeTarget = 'tracking'" @submit="acceptTracking" /></view>
</template>
</view>
@@ -45,7 +45,7 @@
<template v-if="box.confirmed"><text class="meta-line">{{ box.length }} × {{ box.width }} × {{ box.height }} cm · {{ box.weight }} kg · {{ box.originalPackaging ? '原包装' : '新包装' }}</text><text v-for="item in selectedItems.filter(item => Number(box.items[item.skuCode]) > 0)" :key="item.skuCode" class="meta-line">{{ item.skuCode }} · {{ box.items[item.skuCode] }} 件</text></template>
<template v-else>
<view class="dimension-grid unit-dimension-grid"><label><text>长(cm)</text><input class="number-input" type="digit" v-model="box.length" placeholder="长" :disabled="busy" /></label><label><text>宽(cm)</text><input class="number-input" type="digit" v-model="box.width" placeholder="宽" :disabled="busy" /></label><label><text>高(cm)</text><input class="number-input" type="digit" v-model="box.height" placeholder="高" :disabled="busy" /></label><label><text>重量(kg)</text><input class="number-input" type="digit" v-model="box.weight" placeholder="重量" :disabled="busy" /></label></view>
<view class="unit-volume"><text>体积</text><strong>{{ rowVolume(box) }}</strong><text>cm³</text></view>
<view class="unit-volume"><text>体积</text><strong>{{ rowVolume(box) }}</strong><text>m³</text></view>
<view class="packaging-choice"><text>是否使用原{{ unitType === 'PARCEL' ? '包装' : '箱' }}</text><button :class="['choice-button', {selected: box.originalPackaging === true}]" :disabled="busy" @tap="setOriginalPackaging(index, true)">是</button><button :class="['choice-button', {selected: box.originalPackaging === false}]" :disabled="busy" @tap="setOriginalPackaging(index, false)">否</button></view>
<view v-for="item in selectedItems" :key="item.skuCode" class="quantity-row"><text>{{ item.skuCode }} · 数量</text><input class="number-input unit-quantity-input" type="number" :value="box.items[item.skuCode] || ''" :placeholder="'剩余 ' + boxRemaining(index, item)" :disabled="busy" @input="setBoxQuantity(index, item.skuCode, $event.detail.value)" /></view>
<button v-if="boxRowValid(index)" class="primary" :disabled="busy" @tap="confirmBoxRow(index)">确认第 {{ index + 1 }} {{ unitType === 'PARCEL' ? '包裹' : '箱' }}</button>
@@ -94,7 +94,7 @@
canConfirm() { if (!this.current || !this.selectedItems.length || this.receiptQuantity <= 0) return false; if (this.unitType !== 'PALLET') return this.boxRows.length > 0 && this.boxRows.every(box => Object.values(box.items).some(quantity => Number(quantity) > 0) && (!('length' in box) || (Number(box.length) > 0 && Number(box.width) > 0 && Number(box.height) > 0 && Number(box.weight) > 0 && typeof box.originalPackaging === 'boolean'))) && this.selectedItems.every(item => this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) <= this.remaining(item)); return (this.palletCode.trim() && this.palletCheck && Number(this.palletLength) > 0 && Number(this.palletWidth) > 0 && Number(this.palletHeight) > 0 && Number(this.palletWeight) > 0 && this.palletBoxCount > 0) && this.selectedItems.every((item) => this.quantity(item.skuCode) <= this.remaining(item) && (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) : '—' }
,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 / 1000000).toFixed(6) : '—' }
},
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.entryMode = 'SKU'; if (this.receiptDomain === 'customer') this.startScanner(); if (this.receiptDomain === 'customer' && warehouseWork.returnReceipt.currentReturnNo && !this.current && !this.busy) this.openOrder(warehouseWork.returnReceipt.currentReturnNo, 'SKU', false) },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
@@ -104,7 +104,7 @@
selectUnitType(type) { if (this.busy) return; this.unitType = type; this.unitCode = ''; this.boxRows = [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }]; this.palletCheck = null; this.activeTarget = type === 'PALLET' ? 'pallet' : 'unit' },
resizeBoxRows(raw) { if (this.busy) return; const count = Math.floor(Number(raw) || 0); if (count < 1 || count > 100) return; this.boxRows = Array.from({ length: count }, (_, index) => this.boxRows[index] || { items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }) },
setOriginalPackaging(index, value) { if (this.busy) return; this.boxRows[index].originalPackaging = value; this.boxRows[index].confirmed = false },
rowVolume(box) { const v = Number(box?.length) * Number(box?.width) * Number(box?.height); return Number.isFinite(v) && v > 0 ? v.toFixed(2) : '—' },
rowVolume(box) { const v = Number(box?.length) * Number(box?.width) * Number(box?.height); return Number.isFinite(v) && v > 0 ? (v / 1000000).toFixed(6) : '—' },
setBoxQuantity(index, skuCode, raw) { if (this.busy) return; this.boxRows[index].items = { ...this.boxRows[index].items, [skuCode]: raw }; this.boxRows[index].confirmed = false },
boxRemaining(index, item) { const allocated = this.boxRows.reduce((sum, box, rowIndex) => sum + (rowIndex === index ? 0 : Math.max(0, Number(box.items[item.skuCode]) || 0)), 0); return Math.max(0, this.remaining(item) - allocated) },
boxRowValid(index) {
+6 -5
View File
@@ -189,7 +189,7 @@ test('无关联库存按现场箱唛去重计数,提交扫描清单而非手
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.discardTask = { palletNo: 'P-A', status: 'PENDING', scanMode: 'PALLET', cartonMarks: [], skus: [{ itemId: 'sku', plannedPackages: 10, actualQuantityEditable: true, allowedCartonMarks: ['FIELD-A','FIELD-B','FIELD-C','BOX-1','BOX-2',...Array.from({ length: 10 }, (_, i) => 'FIELD-'+i)] }] }
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)
@@ -204,7 +204,7 @@ test('无关联库存按现场箱唛去重计数,提交扫描清单而非手
})
test('无关联托盘扫码后从零开始,等待扫描现场箱唛', async () => {
const { vm } = page({ resolveCargoDiscardPallet: async () => ({ discardNo: 'CD-A', palletNo: 'P-A', cartonMarks: [], scanMode: 'PALLET', skus: [{ itemId: 'sku', plannedPackages: 10, actualQuantityEditable: true }] }) })
const { vm } = page({ resolveCargoDiscardPallet: async () => ({ discardNo: 'CD-A', palletNo: 'P-A', cartonMarks: [], scanMode: 'PALLET', skus: [{ itemId: 'sku', plannedPackages: 10, actualQuantityEditable: true, allowedCartonMarks: ['FIELD-A','FIELD-B','FIELD-C','BOX-1','BOX-2',...Array.from({ length: 10 }, (_, i) => 'FIELD-'+i)] }] }) })
vm.discardCurrent = { discardNo: 'CD-A' }
await vm.resolveDiscardPallet('P-A')
assert.equal(vm.discardActuals.sku, 0)
@@ -213,13 +213,14 @@ test('无关联托盘扫码后从零开始,等待扫描现场箱唛', async ()
test('无关联任务先扫托盘,再扫现场箱唛自动计数', async () => {
const { vm } = page()
vm.discardTasks = [{ discardNo: 'CD-NO-BOX', palletNo: 'P-1', status: 'PENDING', scanMode: 'PALLET', cartonMarks: [], skus: [{ itemId: 'sku', plannedPackages: 2, actualQuantityEditable: true }] }]
vm.discardTasks = [{ discardNo: 'CD-NO-BOX', palletNo: 'P-1', status: 'PENDING', scanMode: 'PALLET', cartonMarks: [], skus: [{ itemId: 'sku', plannedPackages: 2, actualQuantityEditable: true, allowedCartonMarks: ['FIELD-A','FIELD-B','FIELD-C','BOX-1','BOX-2',...Array.from({ length: 10 }, (_, i) => 'FIELD-'+i)] }] }]
vm.openDiscardOrder('CD-NO-BOX'); await Promise.resolve()
assert.equal(vm.discardTask, null)
await vm.resolveDiscardPallet('P-1')
assert.equal(vm.discardTask.palletNo, 'P-1')
assert.equal(vm.discardActuals.sku, 0)
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
await vm.scanDiscardCarton('RANDOM-FOREIGN-BOX'); assert.equal(vm.discardActuals.sku, 0); assert.match(vm.error.message, /不属于/);
await vm.scanDiscardCarton('FIELD-A'); await vm.scanDiscardCarton('FIELD-B')
assert.equal(vm.discardActuals.sku, 2); assert.equal(vm.canConfirmDiscard, true)
})
@@ -434,7 +435,7 @@ test('失败后刷新仍报错,保留草稿且不再发送出库', async () =>
test('无关联扫码显示备注,不能扫托盘或超过目标,失败刷新后保留现场箱唛再提交', async () => {
let attempts = 0
const task = { discardNo: 'CD-FIELD', palletNo: 'PALLET-FIELD', status: 'PENDING', scanMode: 'PALLET', cartonMarks: [], remark: '只废弃破损外箱', remainingPackages: 2, skus: [{ itemId: 'sku', plannedPackages: 8, actualQuantityEditable: true }] }
const task = { discardNo: 'CD-FIELD', palletNo: 'PALLET-FIELD', status: 'PENDING', scanMode: 'PALLET', cartonMarks: [], remark: '只废弃破损外箱', remainingPackages: 2, skus: [{ itemId: 'sku', plannedPackages: 8, actualQuantityEditable: true, allowedCartonMarks: ['FIELD-A','FIELD-B','FIELD-C','BOX-1','BOX-2',...Array.from({ length: 10 }, (_, i) => 'FIELD-'+i)] }] }
const { vm } = page({ confirmCargoDiscard: async () => { if (++attempts === 1) throw new Error('余额不足') }, listCargoDiscardTasks: async () => [] })
vm.discardTasks = [task]; vm.openDiscardOrder(task.discardNo); await vm.resolveDiscardPallet(task.palletNo)
assert.equal(vm.discardOrderRemark, '只废弃破损外箱')
@@ -450,7 +451,7 @@ test('无关联扫码显示备注,不能扫托盘或超过目标,失败刷
test('已扫描现场箱唛可移除并同步回减数量,确认只提交剩余箱唛', async () => {
let payload
const task = { discardNo: 'CD-REMOVE', palletNo: 'P-REMOVE', status: 'PENDING', scanMode: 'PALLET', cartonMarks: [], remainingPackages: 4, skus: [{ itemId: 'sku', plannedPackages: 8, actualQuantityEditable: true }] }
const task = { discardNo: 'CD-REMOVE', palletNo: 'P-REMOVE', status: 'PENDING', scanMode: 'PALLET', cartonMarks: [], remainingPackages: 4, skus: [{ itemId: 'sku', plannedPackages: 8, actualQuantityEditable: true, allowedCartonMarks: ['FIELD-A','FIELD-B','FIELD-C','BOX-1','BOX-2',...Array.from({ length: 10 }, (_, i) => 'FIELD-'+i)] }] }
const { vm } = page({ confirmCargoDiscard: async (...args) => { payload = args }, listCargoDiscardTasks: async () => [] })
vm.discardTasks = [task]; vm.openDiscardOrder(task.discardNo); await vm.resolveDiscardPallet(task.palletNo)
await vm.scanDiscardCarton('BOX-1'); await vm.scanDiscardCarton('BOX-2')
+10
View File
@@ -5,6 +5,15 @@ import vm from 'node:vm'
const physicalRow = items => ({ items, length: 10, width: 10, height: 10, weight: 1, originalPackaging: true, confirmed: true })
test('physical volume uses cubic metres while receipt dimensions remain centimetres', () => {
const page = setup(async () => ({}))
assert.equal(page.rowVolume({ length: 30, width: 20, height: 20 }), '0.012000')
assert.equal(page.rowVolume({ length: 1, width: 1, height: 1 }), '0.000001')
assert.equal(page.rowVolume({ length: '', width: 20, height: 20 }), '—')
page.palletLength = 120; page.palletWidth = 100; page.palletHeight = 150
assert.equal(page.palletVolume, '1.800000')
})
test('box and parcel confirmation opens the camera only after every valid row is confirmed', () => {
for (const type of ['BOX', 'PARCEL']) {
const cameras = []
@@ -129,6 +138,7 @@ function setup(confirm, saved = new Map(), scope = 'operator:warehouse', runtime
quantity: () => 2, boxCount: () => 1
})
Object.defineProperty(instance, 'allBoxRowsConfirmed', { get: () => component.computed.allBoxRowsConfirmed.call(instance) })
Object.defineProperty(instance, 'palletVolume', { get: () => component.computed.palletVolume.call(instance) })
return instance
}