This commit is contained in:
2026-10-19 16:40:39 +08:00
parent b8c9fddf8e
commit 51ecbee653
3 changed files with 64 additions and 4 deletions
+1
View File
@@ -163,6 +163,7 @@ export function normalizeCargoDiscardTask(value) {
itemId: String(sku?.itemId || ''),
skuCode: String(sku?.skuCode || ''),
productName: String(sku?.productName || ''),
quantityUnit: sku?.quantityUnit === '件' ? '件' : '箱',
plannedPackages: count(sku?.plannedPackages),
actualPackages: sku?.actualPackages == null ? null : count(sku.actualPackages),
status: String(sku?.status || 'PENDING')
+14 -4
View File
@@ -45,8 +45,9 @@
<template v-else-if="discardCurrent">
<view class="detail-toolbar"><view><text class="detail-kicker">废弃订单</text><text class="detail-title">{{ discardCurrent.discardNo }}</text><text v-if="discardCurrent.outboundNo" class="task-meta">出库单 {{ discardCurrent.outboundNo }}</text></view><button :disabled="discardBusy" @tap="closeDiscard">‹ 返回列表</button></view>
<view class="fulfillment-card"><ScanTargetInput name="cargo-discard" :model-value="discardPalletCode" :label="discardTask?.scanMode === 'CARTON' ? '废弃箱唛' : '废弃托盘码'" :placeholder="discardTask?.scanMode === 'CARTON' ? '扫描本单所选箱唛' : '扫描对应托盘码'" :active="true" @update:modelValue="updateDiscardPalletCode" @submit="resolveDiscardPallet" /><button class="secondary-button" :disabled="!discardPalletCode || discardLoading || discardBusy" @tap="resolveDiscardPallet">{{ discardLoading ? '读取中…' : discardTask?.scanMode === 'CARTON' ? '核对箱唛' : '打开托盘废弃作业' }}</button><text class="scan-help">整托废弃扫描托盘号;部分箱子废弃或散箱作业,核对本单所选箱唛,也可一键废弃所选箱子。未选箱子保留库存。</text></view>
<view v-if="discardTask" class="fulfillment-card item-card"><view class="task-head"><text class="business-code">{{ discardTask.scanMode === 'CARTON' ? (discardTask.palletNo === '散箱' ? '散箱废弃' : '按箱废弃 · ' + discardTask.palletNo) : '托盘 ' + discardTask.palletNo }}</text><text class="status-badge">{{ discardTask.status === 'COMPLETED' ? '已完成' : '待废弃' }}</text></view><view v-for="sku in discardTask.skus" :key="sku.itemId" class="outbound-line"><text class="line-code">{{ sku.skuCode || '—' }}</text><text class="line-meta">{{ sku.productName || '—' }} · 计划 {{ sku.plannedPackages }} 箱</text><view class="count-row"><text>本单所选废弃 {{ sku.plannedPackages }} 箱</text></view></view><view v-if="discardTask.scanMode === 'CARTON'"><text class="scan-help">已核对 {{ discardScannedMarks.length }}/{{ discardTask.cartonMarks.length }} 箱</text><view v-for="mark in discardTask.cartonMarks" :key="mark" class="outbound-line"><text>{{ mark }}</text><text>{{ discardAllCartons ? '全部废弃' : discardScannedMarks.includes(mark) ? '已扫描' : '待扫描' }}</text></view><button v-if="discardTask.status !== 'COMPLETED'" class="secondary-button" :disabled="discardBusy || discardLoading || discardPhotoBusy" @tap="discardAllCartons = true">一键废弃本单所选箱子({{ discardTask.cartonMarks.length }} 箱)</button><text v-if="discardAllCartons" class="scan-help">已确认本单所选箱子全部废弃,拍照后点击废弃出库完成。</text></view><view class="discard-photo-section"><text class="business-code">废弃现场照片(至少 1 张)</text><text class="scan-help">照片关联当前废弃作业,完成出库后可在废弃单详情查看。</text><view class="discard-photo-grid"><view v-for="photo in discardPhotos" :key="photo.fileId" class="discard-photo-tile"><image v-if="discardPhotoUrls[photo.fileId]" :src="discardPhotoUrls[photo.fileId]" mode="aspectFill" @tap="previewDiscardPhoto(photo.fileId)" /><button v-else class="secondary-button" @tap="loadDiscardPhoto(photo.fileId)">{{ discardPhotoErrors[photo.fileId] ? '加载失败,点击重试' : '加载照片' }}</button></view></view><button v-if="discardTask.status !== 'COMPLETED'" class="secondary-button" :disabled="discardLoading || discardBusy || discardPhotoBusy || !discardPhotoReady || discardPhotos.length >= 20" @tap="takeDiscardPhoto">{{ discardPhotoBusy ? '照片上传中…' : '拍摄废弃现场照片' }}({{ discardPhotos.length }}/20)</button><button v-if="!discardPhotoReady" class="secondary-button" :disabled="discardLoading || discardPhotoBusy" @tap="restoreDiscardPhotos">重试读取照片</button></view><button class="primary-button" :disabled="!canConfirmDiscard || discardBusy" @tap="confirmDiscard">{{ discardBusy ? '提交中…' : '废弃出库' }}</button></view>
<view class="fulfillment-card"><text class="card-title">本单废弃范围</text><view v-for="task in discardCurrentTasks" :key="task.palletNo" class="outbound-line"><view class="task-head"><text class="business-code">{{ task.palletNo === '散箱' ? '散箱' : task.palletNo }}</text><text>{{ task.status === 'COMPLETED' ? '已完成' : '待废弃' }}</text></view><text v-for="mark in task.cartonMarks" :key="mark" class="line-code">{{ mark }}</text><text v-if="!task.cartonMarks.length" class="scan-help">整托废弃,请核对托盘及计划数量。</text></view></view>
<view class="fulfillment-card"><ScanTargetInput name="cargo-discard" :model-value="discardPalletCode" :label="discardTask?.scanMode === 'CARTON' ? '废弃箱唛' : '废弃托盘码'" :placeholder="discardTask?.scanMode === 'CARTON' ? '扫描本单所选箱唛' : '扫描对应托盘码'" :active="true" @update:modelValue="updateDiscardPalletCode" @submit="resolveDiscardPallet" /></view>
<view v-if="discardTask" class="fulfillment-card item-card"><view class="task-head"><text class="business-code">{{ discardTask.scanMode === 'CARTON' ? (discardTask.palletNo === '散箱' ? '散箱废弃' : '按箱废弃 · ' + discardTask.palletNo) : '托盘 ' + discardTask.palletNo }}</text><text class="status-badge">{{ discardTask.status === 'COMPLETED' ? '已完成' : '待废弃' }}</text></view><text class="task-meta">本次废弃 {{ discardQuantitySummary }}</text><view v-for="sku in discardTask.skus" :key="sku.itemId" class="outbound-line"><text class="line-code">{{ sku.skuCode || '—' }}</text><text class="line-meta">{{ sku.productName || '—' }} · {{ sku.plannedPackages }} {{ sku.quantityUnit || '箱' }}</text></view><view v-if="discardTask.scanMode === 'CARTON'"><text class="scan-help">已核对 {{ discardScannedMarks.length }}/{{ discardTask.cartonMarks.length }} 箱</text><view v-for="mark in discardTask.cartonMarks" :key="mark" class="outbound-line"><text>{{ mark }}</text><text>{{ discardAllCartons ? '全部废弃' : discardScannedMarks.includes(mark) ? '已扫描' : '待扫描' }}</text></view><button v-if="discardTask.status !== 'COMPLETED'" class="secondary-button" :disabled="discardBusy || discardLoading || discardPhotoBusy" @tap="discardAllCartons = true">一键废弃本单所选箱子({{ discardTask.cartonMarks.length }} 箱)</button><text v-if="discardAllCartons" class="scan-help">已确认本单所选箱子全部废弃,拍照后点击废弃出库完成。</text></view><view class="discard-photo-section"><text class="business-code">废弃现场照片(至少 1 张)</text><text class="scan-help">照片关联当前废弃作业,完成出库后可在废弃单详情查看。</text><view class="discard-photo-grid"><view v-for="photo in discardPhotos" :key="photo.fileId" class="discard-photo-tile"><image v-if="discardPhotoUrls[photo.fileId]" :src="discardPhotoUrls[photo.fileId]" mode="aspectFill" @tap="previewDiscardPhoto(photo.fileId)" /><button v-else class="secondary-button" @tap="loadDiscardPhoto(photo.fileId)">{{ discardPhotoErrors[photo.fileId] ? '加载失败,点击重试' : '加载照片' }}</button></view></view><button v-if="discardTask.status !== 'COMPLETED'" class="secondary-button" :disabled="discardLoading || discardBusy || discardPhotoBusy || !discardPhotoReady || discardPhotos.length >= 20" @tap="takeDiscardPhoto">{{ discardPhotoBusy ? '照片上传中…' : '拍摄废弃现场照片' }}({{ discardPhotos.length }}/20)</button><button v-if="!discardPhotoReady" class="secondary-button" :disabled="discardLoading || discardPhotoBusy" @tap="restoreDiscardPhotos">重试读取照片</button></view><button class="primary-button" :disabled="!canConfirmDiscard || discardBusy" @tap="confirmDiscard">{{ discardBusy ? '提交中…' : '废弃出库' }}</button></view>
</template>
<view v-if="successMessage" class="fulfillment-success" role="status"><text>{{ successMessage }}</text></view>
@@ -101,6 +102,14 @@
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, 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 },
discardCurrentTasks() { return this.discardTasks.filter(task => task.discardNo === this.discardCurrent?.discardNo) },
discardQuantitySummary() {
const task = this.discardTask
if (!task) return ''
const pieces = task.skus.filter(sku => sku.quantityUnit === '件').reduce((total, sku) => total + sku.plannedPackages, 0)
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)))))) }
},
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() },
@@ -133,10 +142,11 @@
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()
const code = String(typeof raw === 'string' ? raw : raw?.detail?.value ?? 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 }
// 当前托盘可重扫,同一废弃单内也可通过托盘或所选箱唛切换任务。
if (this.discardTask?.scanMode === 'CARTON' && !this.discardTasks.some(task => task.discardNo === this.discardCurrent.discardNo && (task.palletNo === code || (task.cartonMarks || []).includes(code)))) { this.setError(new Error('托盘或箱唛不属于当前废弃单所选货物'), '箱唛核对失败'); return }
const order = this.discardCurrent
this.resetDiscardPhotos(); this.discardScannedMarks = []; this.discardAllCartons = false; this.discardPalletCode = code; this.discardLoading = true; this.discardTask = null; this.discardActuals = {}; this.error = null
try {
+49
View File
@@ -27,6 +27,16 @@ test('scanning a discard outbound document opens the discard workflow with its l
assert.equal(vm.discardCurrent.outboundNo, 'OB-1')
})
test('退货废弃一箱五件分别展示,混装SKU按箱唛去重', () => {
const { vm } = page()
vm.discardTask = { cartonMarks: ['RETURN-BOX-1'], skus: [{ quantityUnit: '件', plannedPackages: 5 }] }
assert.equal(vm.discardQuantitySummary, '1 箱 · 5 件')
vm.discardTask.skus = [{ quantityUnit: '件', plannedPackages: 2 }, { quantityUnit: '件', plannedPackages: 3 }]
assert.equal(vm.discardQuantitySummary, '1 箱 · 5 件')
vm.discardTask = { cartonMarks: ['BOX-1', 'BOX-1'], skus: [{ quantityUnit: '箱', plannedPackages: 1 }] }
assert.equal(vm.discardQuantitySummary, '1 箱')
})
test('discard pallet resolution passes the selected discard number', async () => {
let received
const { vm } = page({ resolveCargoDiscardPallet: async (...args) => {
@@ -38,6 +48,45 @@ test('discard pallet resolution passes the selected discard number', async () =>
assert.deepEqual(received, ['P-1', 'CD-2'])
})
test('点击打开废弃作业使用输入框值,不把 tap 事件当作托盘号', async () => {
let received
const { vm } = page({ resolveCargoDiscardPallet: async (...args) => {
received = args
return { discardNo: 'CD-2', palletNo: 'P-1', skus: [] }
} })
vm.discardCurrent = { discardNo: 'CD-2' }
vm.discardPalletCode = ' P-1 '
await vm.resolveDiscardPallet({ type: 'tap', detail: { x: 10, y: 20 } })
assert.deepEqual(received, ['P-1', 'CD-2'])
assert.equal(vm.discardPalletCode, 'P-1')
})
test('同单按箱废弃允许扫描当前托盘及其他托盘所选箱唛,范围不扩展到其他单据', async () => {
const requests = []
const tasks = [
{ discardNo: 'CD-1', palletNo: 'P-1', scanMode: 'CARTON', cartonMarks: ['BOX-1'], skus: [], status: 'PENDING' },
{ discardNo: 'CD-1', palletNo: 'P-2', scanMode: 'CARTON', cartonMarks: ['BOX-2'], skus: [], status: 'PENDING' },
{ discardNo: 'CD-OTHER', palletNo: 'P-3', scanMode: 'CARTON', cartonMarks: ['BOX-3'], skus: [], status: 'PENDING' },
]
const { vm } = page({ resolveCargoDiscardPallet: async code => {
requests.push(code)
return tasks.find(task => task.palletNo === code || task.cartonMarks.includes(code))
} })
vm.discardTasks = tasks
vm.openDiscardOrder('CD-1')
await Promise.resolve()
assert.equal(vm.discardCurrentTasks.length, 2)
await vm.resolveDiscardPallet('P-1')
assert.equal(vm.error, null)
await vm.resolveDiscardPallet('BOX-2')
assert.equal(vm.discardTask.palletNo, 'P-2')
assert.deepEqual(vm.discardScannedMarks, ['BOX-2'])
await vm.resolveDiscardPallet('BOX-3')
assert.deepEqual(requests, ['P-1', 'BOX-2'])
assert.equal(vm.discardTask.palletNo, 'P-2')
assert.deepEqual(vm.discardScannedMarks, ['BOX-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: 3 }