Merge pull request '发布采购、拼车、预约及匹配端侧修复至灰度' (#7) from release/test into main
This commit was merged in pull request #7.
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"base": "3133aac01953a2b7034517171cb098148407cd6e",
|
||||
"commit": "a7388914cdd1ae654efa08867edb51fc4e0b6e5c",
|
||||
"files": [
|
||||
"src/pages/dispatch/dispatch.vue",
|
||||
"src/utils/dispatch-error.js",
|
||||
"tests/fulfillment-workflows.test.mjs"
|
||||
],
|
||||
"supersedes": "cf984ab4812d64b33e1ad2c98bc2804a272c6c77",
|
||||
"deployed": false,
|
||||
"checks": {
|
||||
"tests": 215,
|
||||
"h5Build": "PASS",
|
||||
"browser": "local corrected H5 with gray API"
|
||||
}
|
||||
}
|
||||
@@ -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 } : {})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { overseasRequest } from './overseas.js'
|
||||
const BASE = '/pda/v1/last-leg/return-inventory/counting'
|
||||
export function getCurrentReturnCountTask() {
|
||||
return overseasRequest('GET', `${BASE}/current`)
|
||||
}
|
||||
export function beginReturnCountTask(receiptNo) {
|
||||
return overseasRequest('POST', `${BASE}/receipts/${encodeURIComponent(receiptNo)}/begin`)
|
||||
}
|
||||
export function scanReturnCountTasks(code) {
|
||||
return overseasRequest('GET', `${BASE}/scan/${encodeURIComponent(String(code).trim())}`)
|
||||
}
|
||||
export function listReturnCountTasks({ page = 1, size = 10, keyword = '' } = {}) {
|
||||
return overseasRequest('GET', `${BASE}/tasks?page=${page}&size=${size}&keyword=${encodeURIComponent(keyword.trim())}`)
|
||||
}
|
||||
export function scanReturnCountCarton(mark) {
|
||||
return overseasRequest('GET', `${BASE}/cartons/${encodeURIComponent(String(mark).trim())}`)
|
||||
}
|
||||
export function confirmReturnCount(receiptNo, request) {
|
||||
return overseasRequest('POST', `${BASE}/receipts/${encodeURIComponent(receiptNo)}`, request)
|
||||
}
|
||||
@@ -6,20 +6,18 @@
|
||||
</view>
|
||||
<view v-if="currentBindings.length">
|
||||
<text class="hint">已绑定箱唛:选择后扫描新目标托盘可改绑(已排车或出库不可直接改绑)。</text>
|
||||
<button v-for="binding in currentBindings" :key="binding.mark" :disabled="busy" @tap="selectRebind(binding)">{{ binding.mark }} → {{ binding.pallet }} · 重新绑定</button>
|
||||
<button v-for="binding in currentBindings" :key="binding.mark" class="binding-row" :disabled="busy" @tap="selectRebind(binding)"><text class="binding-mark">{{ binding.mark }}</text><text class="binding-arrow">→</text><text class="binding-pallet">{{ binding.pallet }}</text><text class="binding-action">重新绑定</text></button>
|
||||
</view>
|
||||
<view v-if="rebindSelection"><text class="hint">正在改绑:{{ rebindSelection.mark }},当前托盘:{{ rebindSelection.pallet }}</text><button :disabled="busy" @tap="rebindSelection = null; targetCode = ''; target = null">取消改绑</button></view>
|
||||
<view v-for="(entry, index) in bindingHistory" :key="index" class="hint">已记录组托:{{ entry.cartonMarks.join('、') }} → {{ entry.targetPallets.join('、') }}(含历史轮次)</view>
|
||||
<view v-for="row in operableSourceRows" :key="row.positionId" class="source-row" :class="{ selected: selectedSource?.positionId === row.positionId }" @tap="selectSource(row)">
|
||||
<text>托盘号:{{ row.palletCode }} · 入仓码:{{ row.inboundCode }}</text>
|
||||
<text class="hint">{{ isReturnBulk ? '件数' : '箱数' }}:{{ row.stockPackages }} · 可拆{{ isReturnBulk ? '件数' : '箱数' }}:{{ row.movablePackages }}</text>
|
||||
<view class="source-identifiers"><view class="source-identifier"><text class="source-caption">托盘号</text><text class="source-code">{{ row.palletCode }}</text></view></view>
|
||||
<text class="source-quantity">{{ isReturnBulk ? '件数' : '箱数' }}:{{ row.stockPackages }} · 可拆{{ isReturnBulk ? '件数' : '箱数' }}:{{ row.movablePackages }}</text>
|
||||
</view>
|
||||
<text v-if="!busy && !operableSourceRows.length" class="hint">当前没有可操作的来源托盘</text>
|
||||
<ScanTargetInput v-if="!isReturnBulk" v-model="sourceCode" name="relabel-source" label="实际来源托盘" placeholder="扫描实际来源托盘" :active="active === 'source'" @activate="activate('source')" @submit="resolve('source')" />
|
||||
<button v-if="!isReturnBulk" :disabled="busy || !sourceCode.trim()" @tap="resolve('source')">确认来源托盘</button>
|
||||
<ScanTargetInput v-if="!isReturnBulk" v-model="sourceCode" name="relabel-source" label="实际来源托盘" placeholder="扫描实际来源托盘" :active="active === 'source'" @activate="activate('source')" @submit="confirmSourceScan" />
|
||||
<text v-if="source" class="hint">已确认:{{ source.palletCode }}</text>
|
||||
<view v-if="selectedSource">
|
||||
<text class="hint">本次{{ isReturnBulk && wholeSource ? '整托换标数量' : `拆出${isReturnBulk ? '件数' : '箱数'}` }}(可分多个来源托盘,逐批提交)</text>
|
||||
<text v-if="isReturnBulk" class="hint">本次{{ wholeSource ? '整托换标数量' : '拆出件数' }}(可分多个来源托盘,逐批提交)</text>
|
||||
<template v-if="isReturnBulk">
|
||||
<text class="return-pallet-summary">{{ selectedSource.palletCode }}:{{ wholeSource ? '整托换标' : '部分拆箱' }} {{ quantity || 0 }} 件 → {{ selectedDestination || '目的仓待维护' }}</text>
|
||||
<text v-if="wholeSource" class="hint">贴标数量等于本托盘全部 {{ selectedSource.stockPackages }} 件,无需填写拆箱数量;直接新增带目的仓的托盘标签并绑定。</text>
|
||||
@@ -27,12 +25,12 @@
|
||||
<text v-else class="hint">退货换标按整托绑定,不支持拆箱;托盘仍会展示用于核对。</text>
|
||||
<text class="hint">退货换标无需扫描箱唛。</text>
|
||||
</template>
|
||||
<template v-else><input v-model="quantity" type="number" :disabled="busy" placeholder="输入实际拆出箱数" @input="marks = []" /><text class="hint">选择本批新目的仓</text><view class="line"><button v-for="dest in destinations" :key="dest" :disabled="busy" @tap="selectedDestination = dest; marks = []">{{ selectedDestination === dest ? '✓ ' : '' }}{{ dest }}</button></view><button :disabled="busy" @tap="fillBatch">按箱数填入本批换标箱</button><text class="hint">请核对下面勾选的箱与现场搬动一致;数量不符时不能提交。此处确认的是新目标托盘绑定,不推断旧箱唛位置。</text><view v-for="item in eligibleItems" :key="item.cartonMark" class="line" @tap="toggleMark(item.cartonMark)"><text>{{ marks.includes(item.cartonMark) ? '☑' : '☐' }} {{ item.cartonMark }} → {{ item.fbaCartonMark || item.destinationFbaWarehouseCode }}</text></view><text class="hint">本批确认 {{ marks.length }} 个箱唛 / 拆出 {{ quantity || 0 }} 箱</text></template>
|
||||
<template v-else><view class="split-quantity-row"><text>本次拆出箱数</text><input v-model="quantity" type="number" :disabled="busy" placeholder="箱数" @input="marks = []" /><text>箱</text></view></template>
|
||||
</view>
|
||||
<view class="split-progress"><template v-if="!isReturnBulk"><text>本次需换标拆 <strong>{{ requiredSplitPackages }}</strong> 箱</text><text>已拆 <strong>{{ splitPackages }}</strong> 箱</text></template><button class="create-pallet-button" :disabled="busy || !!newPallet" @tap="createPallet">新增托盘号</button></view>
|
||||
<ScanTargetInput v-model="targetCode" name="relabel-target" label="目标托盘" placeholder="扫描同目的仓目标托盘或新托盘号" :active="active === 'target'" @activate="activate('target')" @submit="confirmTargetAndBind" />
|
||||
<view class="line"><button :disabled="busy || !targetCode.trim()" @tap="confirmTargetAndBind">确认目标托盘并绑定</button><button :disabled="busy || !!newPallet" @tap="createPallet">新增托盘号</button></view>
|
||||
<text v-if="target" class="hint">已识别目标:{{ target.palletCode }},尚未绑定成功</text>
|
||||
<view v-if="newPallet" class="hint"><text>新托盘号:{{ newPallet.palletCode }},请贴好托盘标签后扫描确认。</text><button @tap="printPallet">查看 / 打印托盘标签</button></view>
|
||||
<view v-if="newPallet" class="hint"><text>托盘 {{ newPallet.palletCode }} 待绑定</text><button @tap="printPallet">查看 / 打印托盘标签</button></view>
|
||||
<text v-if="message" class="hint">{{ message }}</text><text v-if="error" class="error">{{ error }}</text>
|
||||
</view>
|
||||
</template>
|
||||
@@ -47,6 +45,8 @@ export default {
|
||||
data() { return { rebindSelection: null, active: 'source', sourceCode: '', targetCode: '', source: null, target: null, marks: [], busy: false, error: '', message: '', newPallet: null, sourceRows: [], selectedSource: null, quantity: '', selectedDestination: '' } },
|
||||
mounted() { this.loadSources() },
|
||||
computed: {
|
||||
requiredSplitPackages() { return (this.task.items || []).filter(item => ['CARTON_LABEL', 'CARTON_AND_SKU'].includes(item.operationType)).reduce((sum, item) => sum + Number(item.expectedPackages || 0), 0) },
|
||||
splitPackages() { const bound = new Set(this.currentBindings.map(binding => binding.mark)); return (this.task.items || []).filter(item => ['CARTON_LABEL', 'CARTON_AND_SKU'].includes(item.operationType) && bound.has(item.cartonMark)).reduce((sum, item) => sum + Number(item.expectedPackages || 0), 0) },
|
||||
currentBindings() {
|
||||
const latest = new Map()
|
||||
for (const entry of this.task.history || []) {
|
||||
@@ -60,7 +60,6 @@ export default {
|
||||
}
|
||||
return [...latest.values()]
|
||||
},
|
||||
bindingHistory() { return (this.task.history || []).filter(entry => entry.action === 'PALLET_BIND').flatMap(entry => { try { const data = JSON.parse(entry.detail); return Array.isArray(data.cartonMarks) && Array.isArray(data.targetPallets) ? [data] : [] } catch { return [] } }) },
|
||||
destinations() { return [...new Set((this.isReturnBulk ? this.sourceItems() : this.completedItems()).map(item => item.destinationFbaWarehouseCode).filter(Boolean))] },
|
||||
eligibleItems() { return (this.isReturnBulk ? this.sourceItems() : this.completedItems()).filter(item => item.destinationFbaWarehouseCode === this.selectedDestination) },
|
||||
isReturnBulk() { return this.task.operationType === 'RETURN_BULK_SKU_LABEL' },
|
||||
@@ -99,7 +98,7 @@ export default {
|
||||
if (!binding || !targetCode || this.busy) return
|
||||
if (targetCode === binding.pallet.toUpperCase()) { this.showBindingMessage('无需改绑', '该箱唛已经绑定此托盘,不会重复扣库存。'); return }
|
||||
this.busy = true; this.$emit('busy', true)
|
||||
const confirmed = await new Promise(resolve => uni.showModal({ title: '确认改绑', content: `将 ${binding.mark} 从 ${binding.pallet} 移至 ${targetCode}?旧托盘扣箱,新托盘加箱。`, confirmText: '确认改绑', cancelText: '取消', success: result => resolve(result.confirm), fail: () => resolve(false) }))
|
||||
const confirmed = await new Promise(resolve => uni.showModal({ title: '确认改绑', content: `将 ${binding.mark} 从 ${binding.pallet} 移至 ${targetCode}?旧托盘扣箱,新托盘加箱。`, confirmText: '是', cancelText: '否', success: result => resolve(result.confirm), fail: () => resolve(false) }))
|
||||
this.busy = false; this.$emit('busy', false)
|
||||
if (!confirmed) return
|
||||
await this.run(async () => {
|
||||
@@ -111,17 +110,17 @@ export default {
|
||||
releaseWarehouseKey('relabel', key.signature)
|
||||
this.rebindSelection = null; this.targetCode = ''; this.target = null
|
||||
this.$emit('saved')
|
||||
this.showBindingMessage('绑定成功', `${binding.mark} 已从 ${binding.pallet} 移至 ${target.palletCode}`)
|
||||
this.showBindingMessage('绑定成功', `托盘 ${target.palletCode} 已绑定`)
|
||||
})
|
||||
if (this.error) this.showBindingMessage('改绑失败', this.error)
|
||||
},
|
||||
sourceItems() { return (this.task.items || []).filter(item => this.selectedSource?.cartonMarks.includes(item.cartonMark)) },
|
||||
completedItems() { return this.sourceItems().filter(item => this.isReturnBulk ? Number(item.expectedPackages) > 0 && Number(item.relabelledPackages) >= Number(item.expectedPackages) : Number(item.relabelledPackages) >= 1) },
|
||||
completedItems() { const bound = new Set(this.currentBindings.map(binding => binding.mark)); return this.sourceItems().filter(item => this.isReturnBulk ? Number(item.expectedPackages) > 0 && Number(item.relabelledPackages) >= Number(item.expectedPackages) : Number(item.relabelledPackages) >= 1 && !bound.has(item.cartonMark)) },
|
||||
async loadSources() { await this.run(async () => { this.sourceRows = await findRelabelSources(this.task.relabelNo); this.selectedSource = null; this.source = null; this.marks = []; this.quantity = '' }) },
|
||||
async selectSource(row) {
|
||||
if (this.busy) return
|
||||
this.rebindSelection = null; this.selectedSource = row; this.source = null; this.sourceCode = ''; this.quantity = ''; this.marks = []
|
||||
this.selectedDestination = this.sourceItems()[0]?.destinationFbaWarehouseCode || ''
|
||||
this.selectedDestination = (this.isReturnBulk ? this.sourceItems() : this.completedItems())[0]?.destinationFbaWarehouseCode || ''
|
||||
if (this.isReturnBulk) {
|
||||
const items = this.eligibleItems
|
||||
this.marks = items.map(item => item.cartonMark)
|
||||
@@ -144,23 +143,45 @@ export default {
|
||||
fillBatch() { const count = Number(this.quantity); const packages = this.eligibleItems.reduce((sum, item) => sum + Number(item.expectedPackages || 0), 0); const invalid = !Number.isSafeInteger(count) || count <= 0 || count > this.selectedSource.movablePackages || (this.isReturnBulk ? count !== packages : count > this.eligibleItems.length); if (invalid) { this.error = this.isReturnBulk ? '拆出件数必须等于本批已完成贴标件数,且不能超过来源托盘可拆数量' : '拆出数量必须为正整数,且不超过来源托盘可拆箱数和已完成换标箱数'; return } this.marks = (this.isReturnBulk ? this.eligibleItems : this.eligibleItems.slice(0, count)).map(item => item.cartonMark); this.error = '' },
|
||||
toggleMark(mark) { if (this.busy) return; this.marks = this.marks.includes(mark) ? this.marks.filter(value => value !== mark) : [...this.marks, mark] },
|
||||
activate(value) { this.active = value; this.$emit('activate') },
|
||||
handleScan(value) { if (this.busy) return; this[this.active + 'Code'] = value; return this.active === 'target' ? this.confirmTargetAndBind() : this.resolve(this.active) },
|
||||
handleScan(value) { if (this.busy) return; this[this.active + 'Code'] = value; return this.active === 'target' ? this.confirmTargetAndBind() : this.confirmSourceScan() },
|
||||
async confirmBindingChoice(content) {
|
||||
this.busy = true; this.$emit('busy', true)
|
||||
try { return await new Promise(resolve => uni.showModal({ title: '是否绑定', content, confirmText: '是', cancelText: '否', success: result => resolve(!!result.confirm), fail: () => resolve(false) })) }
|
||||
finally { this.busy = false; this.$emit('busy', false) }
|
||||
},
|
||||
async confirmSourceScan() {
|
||||
if (this.busy || !this.sourceCode.trim()) return
|
||||
const matched = this.operableSourceRows.find(row => this.samePallet(row, { palletCode: this.sourceCode }))
|
||||
this.source = null
|
||||
if (!matched) { this.error = '该托盘不在本单可操作的来源托盘列表中'; this.showBindingMessage('来源托盘不匹配', this.error); return }
|
||||
await this.resolve('source')
|
||||
if (this.error || !this.source) return
|
||||
const source = this.source; this.source = null
|
||||
if (await this.confirmBindingChoice(`是否绑定来源托盘 ${source.palletCode}?`)) this.source = source
|
||||
},
|
||||
showBindingMessage(title, content) { uni.showModal({ title, content, showCancel: false, confirmText: '知道了' }) },
|
||||
async confirmTargetAndBind() {
|
||||
if (this.busy) return
|
||||
if (this.rebindSelection) return this.submitRebind()
|
||||
if (!this.isReturnBulk && this.selectedSource && this.samePallet(this.source, this.selectedSource)) {
|
||||
this.selectedDestination = this.completedItems()[0]?.destinationFbaWarehouseCode || ''
|
||||
this.marks = []
|
||||
this.fillBatch()
|
||||
if (this.error) { this.showBindingMessage('尚未绑定', this.error); return }
|
||||
}
|
||||
this.message = ''
|
||||
let reason = ''
|
||||
if (!this.selectedSource) reason = '请先选择实际拆出货物的来源托盘。'
|
||||
else if (!this.samePallet(this.source, this.selectedSource)) reason = this.isReturnBulk ? '来源托盘识别失败,请重新选择。' : '请先扫描并确认实际来源托盘;选择来源列表不等于扫描确认。'
|
||||
else if (this.isReturnBulk && this.completedItems().length !== this.eligibleItems.length) reason = '请先完成本托盘的 SKU 贴标登记,再绑定新托盘;整托无需填写拆箱数量。'
|
||||
else if (!Number.isSafeInteger(Number(this.quantity)) || Number(this.quantity) <= 0 || Number(this.quantity) > this.selectedSource.movablePackages) reason = this.isReturnBulk ? '本托盘可操作件数不足,请刷新来源托盘。' : '请填写有效的拆出箱数,不能超过来源托盘可拆数量。'
|
||||
else if (!this.selectedDestination || !this.bindingSelectionValid()) reason = this.isReturnBulk ? `本托盘拆箱数量必须等于贴标预报分配数量 ${this.plannedPackages} 件。` : '请选择本批目的仓和对应箱唛,勾选箱数必须等于拆出箱数。'
|
||||
else if (!this.selectedDestination || !this.bindingSelectionValid()) reason = this.isReturnBulk ? `本托盘拆箱数量必须等于贴标预报分配数量 ${this.plannedPackages} 件。` : '本次拆出箱数必须对应已完成换标且尚未拆出的同目的仓箱唛。'
|
||||
else if (!this.targetCode.trim()) reason = '请扫描或输入目标托盘号。'
|
||||
if (reason) { this.error = reason; this.showBindingMessage('尚未绑定', reason); return }
|
||||
await this.resolve('target')
|
||||
if (this.error) { this.showBindingMessage('绑定失败', this.error); return }
|
||||
if (!this.readyToBind) { this.error = '来源和目标托盘不能相同,请核对后重新确认。'; this.showBindingMessage('尚未绑定', this.error); return }
|
||||
if (!await this.confirmBindingChoice(`是否绑定目标托盘 ${this.target.palletCode}(${this.quantity} ${this.isReturnBulk ? '件' : '箱'})?`)) { this.target = null; return }
|
||||
await this.bind()
|
||||
},
|
||||
destination(mark) { return this.task.items.find(item => item.cartonMark === mark)?.destinationFbaWarehouseCode || '目的仓待维护' },
|
||||
@@ -198,7 +219,7 @@ export default {
|
||||
const payload = { relabelNo: this.task.relabelNo, source: expectation(this.source), target: expectation(this.target), cartonMarks: [...this.marks], actualSource: { positionId: this.selectedSource.positionId, expectedPositionVersion: this.selectedSource.positionVersion, packages: Number(this.quantity) }, reason: 'PDA 按原入仓单箱数拆托并按新目的仓合托' }
|
||||
const key = warehouseIdempotencyKey('relabel', 'bind-pallet', payload)
|
||||
let saved = false
|
||||
await this.run(async () => { await bindRelabelPallet({ ...payload, idempotencyKey: key.value }); saved = true; releaseWarehouseKey('relabel', key.signature); const createdKey = warehouseIdempotencyKey('relabel', 'new-pallet', { relabelNo: this.task.relabelNo }); releaseWarehouseKey('relabel', createdKey.signature); this.message = `${this.marks.length} 箱已从 ${this.source.palletCode} 合入 ${this.target.palletCode},仍归属 ${this.selectedSource.inboundCode}`; this.marks = []; this.source = null; this.target = null; this.newPallet = null; this.sourceCode = ''; this.targetCode = ''; this.$emit('saved'); this.activate('source') })
|
||||
await this.run(async () => { await bindRelabelPallet({ ...payload, idempotencyKey: key.value }); saved = true; releaseWarehouseKey('relabel', key.signature); const createdKey = warehouseIdempotencyKey('relabel', 'new-pallet', { relabelNo: this.task.relabelNo }); releaseWarehouseKey('relabel', createdKey.signature); this.message = `托盘 ${this.target.palletCode} 已绑定`; this.marks = []; this.source = null; this.target = null; this.newPallet = null; this.sourceCode = ''; this.targetCode = ''; this.$emit('saved'); this.activate('source') })
|
||||
if (saved) { this.showBindingMessage('绑定成功', this.message); await this.loadSources() }
|
||||
else if (this.error) this.showBindingMessage('绑定失败', this.error)
|
||||
},
|
||||
@@ -219,8 +240,16 @@ export default {
|
||||
.heading{display:block;font-size:18px;font-weight:700}
|
||||
.refresh-button{flex:none;margin:0;padding:6px 14px;font-size:13px;line-height:1.5}
|
||||
.hint{display:block;font-size:13px;color:#52647c;margin:10px 0;white-space:normal}
|
||||
.source-row{padding:12px;margin:10px 0;border:1px solid #d7e1ef;border-radius:8px}
|
||||
.source-row{display:flex;align-items:center;gap:8px;padding:8px;margin:8px 0;border:1px solid #d7e1ef;border-radius:8px;white-space:nowrap}
|
||||
.source-identifiers{flex:1;min-width:0}
|
||||
.source-identifier{display:flex;align-items:baseline;gap:5px;max-width:100%;min-width:0}
|
||||
.source-caption{flex-shrink:0;color:#718095;font-size:10px}
|
||||
.source-code{min-width:0;color:#243951;font-size:11px;line-height:18px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.source-quantity{flex-shrink:0;color:#52647c;font-size:9px;line-height:16px}
|
||||
.split-quantity-row{display:flex;align-items:center;gap:8px;margin:10px 0;color:#52647c;font-size:12px}.split-quantity-row input{box-sizing:border-box;width:72px;height:34px;padding:6px 8px;text-align:center;font-size:13px}
|
||||
.split-progress{display:flex;align-items:center;flex-wrap:wrap;gap:8px 12px;margin:10px 0;color:#52647c;font-size:12px;line-height:20px}.split-progress strong{color:#1f5fca;font-weight:700}.split-progress .create-pallet-button{margin:0 0 0 auto;padding:0 10px;border-radius:6px;background:#22c55e;color:#fff;font-size:10px;font-weight:700;line-height:28px}.split-progress .create-pallet-button[disabled]{background:#bbf7d0;color:#6b987b}.split-progress .create-pallet-button::after{border:none}
|
||||
.source-row.selected{border-color:#2674d9;background:#eff6ff}
|
||||
.binding-row{display:flex;align-items:center;gap:6px;box-sizing:border-box;width:100%;min-width:0;padding:8px 9px;font-size:11px;line-height:20px;text-align:left;white-space:nowrap}.binding-mark{max-width:25%;overflow:hidden;text-overflow:ellipsis}.binding-arrow,.binding-action{flex-shrink:0}.binding-pallet{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}.binding-action{color:#1f5fca;font-size:10px}
|
||||
.line{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin:8px 0}.line text{flex:1;overflow-wrap:anywhere}
|
||||
input{padding:12px;background:#f3f6fa;border-radius:6px}button{font-size:13px;margin:8px 0}
|
||||
.error{display:block;color:#bc3030;font-size:13px}
|
||||
|
||||
@@ -4,7 +4,8 @@ export const MODULES = [
|
||||
{ key: 'warehouseTransferList', name: '仓储转运测量列表', nameKey: 'modules.warehouseTransferList.name', icon: '列', iconType: 'list', description: '查看待测量的仓储转运单', descriptionKey: 'modules.warehouseTransferList.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer?mode=list' },
|
||||
{ key: 'warehouseTransferWork', name: '转运测量作业', nameKey: 'modules.warehouseTransferWork.name', icon: '转', iconType: 'compose', description: '进入转运单详情完成测量、打托和拍照', descriptionKey: 'modules.warehouseTransferWork.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer?mode=work' },
|
||||
{ key: 'returnReceipt', name: '退货入库', nameKey: 'modules.returnReceipt.name', icon: '退', iconType: 'undo', description: '扫描退货单或 SKU,按本包实收数量生成退货库存', descriptionKey: 'modules.returnReceipt.description', available: true, route: '/pages/return-receipt/return-receipt' },
|
||||
{ key: 'returnPacking', name: '退货合箱', nameKey: 'modules.returnPacking.name', icon: '合', iconType: 'folder-add', description: '选择同客户已签收退货单,按 SKU 数量生成新箱', descriptionKey: 'modules.returnPacking.description', available: true, route: '/pages/return-packing/return-packing' }
|
||||
{ key: 'returnPacking', name: '退货合箱', nameKey: 'modules.returnPacking.name', icon: '合', iconType: 'folder-add', description: '选择同客户已签收退货单,按 SKU 数量生成新箱', descriptionKey: 'modules.returnPacking.description', available: true, route: '/pages/return-packing/return-packing' },
|
||||
{ key: 'returnCounting', name: '退货箱内清点', icon: '清', iconType: 'list', description: '逐箱清点托盘签收货物,核对 SKU 和数量', available: true, route: '/pages/return-counting/return-counting' }
|
||||
] },
|
||||
{ key: 'outbound', name: '出库履约', nameKey: 'modules.outbound.name', icon: '出', description: '排车拣货、出库任务与发车闭环', descriptionKey: 'modules.outbound.description', operations: [
|
||||
{ key: 'dispatchPicking', name: '排车拣货', nameKey: 'modules.dispatchPicking.name', icon: '车', iconType: 'paperplane', description: '按排车任务扫描托盘拣货,完成门禁后执行发车', descriptionKey: 'modules.dispatchPicking.description', available: true, route: '/pages/dispatch/dispatch' },
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
{ "path": "pages/operation/operation", "style": { "navigationStyle": "custom", "navigationBarTitleText": "柜号入库与现场作业", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/warehouse-transfer/warehouse-transfer", "style": { "navigationStyle": "custom", "navigationBarTitleText": "仓储转运测量", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/return-receipt/return-receipt", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货入库", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/return-counting/return-counting", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货箱内清点", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/return-packing/return-packing", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货合箱", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/pallet/pallet", "style": { "navigationStyle": "custom", "navigationBarTitleText": "托盘管理", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/stocktake/stocktake", "style": { "navigationStyle": "custom", "navigationBarTitleText": "库存盘点", "backgroundColor": "#F5F7FA" } },
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
<view class="portal-content fulfillment-content">
|
||||
<view class="fulfillment-heading">
|
||||
<text class="fulfillment-title">排车拣货与发车</text>
|
||||
<text class="fulfillment-subtitle">客户与当前海外仓由登录令牌和服务端确定;扫描排车单号或任务中的托盘码开始作业</text>
|
||||
<text class="fulfillment-subtitle">扫描排车单号打开任务,再扫描任务中的托盘码进行拣货</text>
|
||||
</view>
|
||||
<view v-if="current" class="detail-toolbar"><view><text class="detail-kicker">当前任务</text><text class="detail-title">排车拣货详情</text></view><button :disabled="busy" @tap="closeTask">‹ 返回列表</button></view>
|
||||
|
||||
<view class="fulfillment-card">
|
||||
<ScanTargetInput name="dispatch" :model-value="scanCode" label="排车单号 / 托盘码 / 来源箱唛" placeholder="扫描排车单号、托盘码或退货箱唛" :active="true" @update:modelValue="updateScanCode" @submit="handleScan" />
|
||||
<button class="secondary-button" :disabled="!scanCode || loading || busy" @tap="handleScan(scanCode)">{{ current ? '匹配托盘' : '打开排车任务' }}</button>
|
||||
<text class="scan-help">网络失败不会清空草稿,再次提交会沿用同一幂等键。</text>
|
||||
<text class="scan-help">网络中断会保留当前作业,恢复后可重试。</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="fulfillment-state"><text>正在加载排车任务…</text></view>
|
||||
@@ -21,7 +21,7 @@
|
||||
<text v-if="error.code">错误码:{{ error.code }}</text>
|
||||
<text v-if="error.requestId">requestId:{{ error.requestId }}</text>
|
||||
<text v-if="error.traceId">traceId:{{ error.traceId }}</text>
|
||||
<text v-if="error.network" class="offline-note">网络连接失败,未完成作业与幂等键已保留。</text>
|
||||
<text v-if="error.network" class="offline-note">网络连接失败,当前作业已保留,请恢复网络后重试。</text>
|
||||
<button class="secondary-button" :disabled="loading || busy" @tap="retry">重试</button>
|
||||
</view>
|
||||
|
||||
@@ -154,17 +154,21 @@
|
||||
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim(); if (!code || this.busy || this.loading) return
|
||||
console.info('[Overseas PDA dispatch] scan', { code, task: this.current?.dispatchNo || '' })
|
||||
this.scanCode = code
|
||||
this.error = null; this.successMessage = ''
|
||||
updateFulfillmentDraft('dispatch', { palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1 })
|
||||
if (!this.current || code === this.current.dispatchNo || code === this.current.taskNo) return this.openTask(code === this.current?.taskNo ? this.current.dispatchNo : code)
|
||||
const cartonItem = this.current.items.find((item) => item.returnCartons?.some((carton) => carton.cartonMark === code))
|
||||
if (cartonItem) {
|
||||
if (cartonItem.returnCartons.find((carton) => carton.cartonMark === code)?.status !== 'OUTBOUND_PENDING') return this.setError(new Error('该箱已拣货或不可出库'), '扫码未匹配')
|
||||
this.selectPallet(cartonItem); updateFulfillmentDraft('dispatch', { cartonCode: code, packages: 1 }); this.scanCode = ''; playSuccessSound(); return
|
||||
}
|
||||
const item = this.current.items.find((value) => [value.palletNo, value.fbaWarehouseCode].includes(code))
|
||||
if (!item) return this.setError(new Error('扫码未匹配当前排车任务的托盘或稳定业务码'), '扫码未匹配')
|
||||
const matches = this.current.items.filter((value) => value.palletNo === code && value.scheduledPackages > value.pickedPackages)
|
||||
if (matches.length > 1) return this.setError(new Error('此托盘包含多条待拣明细,请选择对应货物卡片'), '请选择拣货明细')
|
||||
const item = matches[0]
|
||||
if (!item) return this.setError(new Error(this.current.items.some((value) => value.palletNo === code) ? '该托盘已完成拣货,请勿重复操作' : '扫码未匹配当前任务的托盘码或来源箱唛'), '扫码未匹配')
|
||||
this.selectPallet(item); this.scanCode = ''; playSuccessSound()
|
||||
},
|
||||
selectPallet(item) { if (!item?.palletNo || item.scheduledPackages <= item.pickedPackages) return; updateFulfillmentDraft('dispatch', { palletCode: item.palletNo, dispatchItemId: item.dispatchItemId, cartonCode: '', packages: Math.max(1, Math.min(item.scheduledPackages - item.pickedPackages, item.positions?.[0]?.availablePackages || 1)) }) },
|
||||
selectPallet(item) { if (!item?.palletNo || item.scheduledPackages <= item.pickedPackages) return; this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { palletCode: item.palletNo, dispatchItemId: item.dispatchItemId, cartonCode: '', packages: Math.max(1, Math.min(item.scheduledPackages - item.pickedPackages, item.positions?.[0]?.availablePackages || 1)) }) },
|
||||
async submitPick() {
|
||||
if (!this.canPick) return
|
||||
const pick = this.selectedReturnCarton ? { positionId: null, cartonMark: this.selectedReturnCarton.cartonMark, packages: 1, productQuantity: this.selectedReturnCarton.productQuantity } : { positionId: this.selectedPosition.id, packages: Number(this.packages), productQuantity: 0 }
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<view class="fulfillment-heading"><text class="fulfillment-title">出库任务</text><text class="fulfillment-subtitle">整托扫描托盘码,散箱扫描箱唛;扫描排车业务号会转入“排车拣货”</text></view>
|
||||
<view v-if="current" class="detail-toolbar"><view><text class="detail-kicker">当前任务</text><text class="detail-title">出库作业详情</text></view><button :disabled="busy" @tap="closeTask">‹ 返回列表</button></view>
|
||||
<view class="fulfillment-card">
|
||||
<ScanTargetInput name="outbound" :model-value="scanCode" :label="discardCurrent ? '废弃订单托盘码' : '任务号、出库单号、排车单号、托盘码或箱码'" :placeholder="discardCurrent ? '扫描废弃订单对应托盘码' : '扫描任务号 / 出库单号 / 排车单号 / 托盘码 / 箱码'" :active="true" @update:modelValue="updateScanCode" @submit="handleScan" />
|
||||
<ScanTargetInput name="outbound" :model-value="scanCode" :label="scanLabel" :placeholder="scanPlaceholder" :active="true" @update:modelValue="updateScanCode" @submit="handleScan" />
|
||||
<button class="secondary-button" :disabled="!scanCode || loading || busy || discardLoading || discardBusy" @tap="handleScan(scanCode)">{{ discardCurrent ? '打开废弃托盘' : current ? '匹配出库货物' : '打开出库任务' }}</button>
|
||||
<text class="scan-help">任务号直接恢复;退货整托出库扫托盘码,散箱出库扫箱唛。</text>
|
||||
</view>
|
||||
@@ -27,18 +27,18 @@
|
||||
<view class="fulfillment-card summary-card"><view class="task-head"><text class="business-code">{{ current.taskNo || '—' }}</text><text class="status-badge">{{ statusLabel(current.status) }}</text></view><text class="task-meta">出库单 {{ current.outboundNo || '—' }} · {{ taskType(current) }}</text><view class="metrics"><view><text class="metric-value">{{ plannedPackages }}</text><text>计划箱数</text></view><view><text class="metric-value success">{{ pickedPackages }}</text><text>已拣箱数</text></view><view><text class="metric-value warning">{{ remainingPackages }}</text><text>待拣箱数</text></view></view></view>
|
||||
|
||||
<view v-if="current.items.length" class="item-list">
|
||||
<view v-for="item in current.items" :key="item.outboundPalletItemId" class="fulfillment-card item-card">
|
||||
<view v-for="item in displayItems" :key="item.displayKey || item.outboundPalletItemId" class="fulfillment-card item-card">
|
||||
<view class="task-head"><text class="business-code">{{ item.palletNo || '散件出库' }}</text><text>{{ item.pickedPackages }}/{{ item.plannedPackages }} 箱</text></view>
|
||||
<view v-for="position in current.outboundType === 'RETURN_OUTBOUND' && item.palletNo ? [] : item.positions" :key="businessLineKey(position)" class="outbound-line" :class="{ selected: selectedPosition === position }" @tap="selectPosition(item, position)">
|
||||
<text class="line-code">{{ lineBusinessCode(position) }}</text><text class="line-meta">{{ position.inboundCode || '无入仓码' }} · 已拣 {{ position.pickedPackages }} 箱 · 可拣 {{ position.availablePackages }} 箱</text>
|
||||
</view>
|
||||
<text v-if="current.outboundType === 'RETURN_OUTBOUND' && item.palletNo && item.pickedPackages < item.plannedPackages" class="scan-help">整托出库请扫描托盘码 {{ item.palletNo }}</text>
|
||||
<text v-if="current.outboundType === 'RETURN_OUTBOUND' && item.palletNo && item.pickedPackages < item.plannedPackages" class="scan-help">整托出库请扫描托盘号 {{ item.palletNo }}</text>
|
||||
<text v-if="!item.positions.length && item.pickedPackages < item.plannedPackages" class="missing-scan-contract">当前明细缺少可扫码库存业务码,已禁止拣货;请刷新或联系平台确认任务下发。</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="fulfillment-state"><text class="state-title">任务详情尚无可拣货明细</text><text>请刷新;若持续为空,请联系平台确认出库货物是否已下发。</text></view>
|
||||
|
||||
<view v-if="selectedPosition && selectedRemaining > 0" class="fulfillment-card"><text class="card-title">本次拣货:{{ lineBusinessCode(selectedPosition) }}</text><view class="count-row"><text>拣货箱数</text><input type="number" :value="packages" :placeholder="`请输入 1-${selectedRemaining} 箱`" @input="updatePackages" /><text>最多 {{ selectedRemaining }} 箱</text></view><button class="primary-button" :disabled="!canPick || busy" @tap="submitPick">{{ busyAction === 'pick' ? '提交中…' : '确认出库拣货' }}</button></view>
|
||||
<view v-if="selectedPosition && selectedRemaining > 0" class="fulfillment-card"><text class="card-title">本次拣货:{{ lineBusinessCode(selectedPosition) }}</text><text v-if="selectedItem.returnSkuQuantity != null" class="scan-help">本来源计划出库 {{ selectedItem.returnSkuQuantity }} 件,请清点这些件数后确认;余数保留原箱。</text><view class="count-row"><text>拣货箱数</text><input type="number" :value="packages" :placeholder="`请输入 1-${selectedRemaining} 箱`" @input="updatePackages" /><text>最多 {{ selectedRemaining }} 箱</text></view><button class="primary-button" :disabled="!canPick || busy" @tap="submitPick">{{ busyAction === 'pick' ? '提交中…' : '确认出库拣货' }}</button></view>
|
||||
|
||||
<view class="fulfillment-card gate-card"><text class="card-title">完成出库任务</text><text v-if="remainingPackages > 0" class="gate-warning">尚有 {{ remainingPackages }} 箱未拣,不能完成出库。</text><text v-else-if="current.status === 'COMPLETED'" class="gate-success">出库任务已完成,不可重复提交。</text><text v-else class="gate-note">全部计划货物已扫描核平,服务端将再次校验库存门禁。</text><button v-if="current.status !== 'COMPLETED'" class="primary-button" :disabled="!canComplete || busy" @tap="completeTask">{{ busyAction === 'complete' ? '完成中…' : '完成出库任务' }}</button></view>
|
||||
</template>
|
||||
@@ -68,6 +68,28 @@
|
||||
components: { PortalHeader, ScanTargetInput },
|
||||
data() { return { user: session.currentUser, tasks: [], discardTasks: [], current: null, discardCurrent: null, discardTask: null, discardActuals: {}, 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 || []
|
||||
if (this.current?.outboundType !== 'RETURN_OUTBOUND') return items
|
||||
const pallets = new Map()
|
||||
const rows = []
|
||||
for (const item of items) {
|
||||
if (!item.palletNo) { rows.push(item); continue }
|
||||
let pallet = pallets.get(item.palletNo)
|
||||
if (!pallet) {
|
||||
pallet = { ...item, displayKey: `pallet:${item.palletNo}`, plannedPackages: 0, pickedPackages: 0, positions: [] }
|
||||
pallets.set(item.palletNo, pallet)
|
||||
rows.push(pallet)
|
||||
}
|
||||
pallet.plannedPackages += Number(item.plannedPackages || 0)
|
||||
pallet.pickedPackages += Number(item.pickedPackages || 0)
|
||||
pallet.positions.push(...(item.positions || []))
|
||||
}
|
||||
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 ? '扫描托盘号;散箱扫描箱唛' : '扫描任务号 / 出库单号 / 排车单号 / 托盘号 / 箱码' },
|
||||
recentTaskNo() { return fulfillment.outbound.currentTaskNo || '' },
|
||||
packages() { return fulfillment.outbound.packages },
|
||||
plannedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.plannedPackages, 0) },
|
||||
|
||||
@@ -2,27 +2,27 @@
|
||||
<view v-if="user" class="page-shell portal-page work-page">
|
||||
<PortalHeader :title="title" :user="user" fallback-url="/pages/module/module?key=relabel" />
|
||||
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
|
||||
<view class="work-heading"><text class="work-title">{{ title }}</text><text class="work-subtitle">{{ stageSubtitle }}</text></view>
|
||||
<view v-if="showCurrent" class="detail-toolbar"><view><text class="detail-kicker">当前任务</text><text class="detail-title">{{ stage === 'review' ? '换标复核详情' : '换标执行详情' }}</text></view><button :disabled="busy" @tap="closeCurrent">‹ 返回列表</button></view>
|
||||
<view v-if="!showCurrent" class="work-heading"><text class="work-title">{{ title }}</text><text class="work-subtitle">{{ stageSubtitle }}</text></view>
|
||||
<view v-if="showCurrent" class="detail-toolbar"><button :disabled="busy" @tap="closeCurrent">‹ 返回列表</button></view>
|
||||
|
||||
<template v-if="!showCurrent">
|
||||
<view class="row-head"><text class="card-title">{{ candidateHeading }}</text><button v-if="stage !== 'review'" class="link-button" :disabled="loading" @tap="bootstrap">{{ loading ? '刷新中…' : '刷新' }}</button></view>
|
||||
<view v-if="loading && !visibleCandidates.length" class="empty-box"><uni-load-more status="loading" /><text class="empty-copy">正在加载换标任务…</text></view>
|
||||
<view v-else-if="!visibleCandidates.length" class="empty-box"><uni-icons type="info" color="#8090a3" size="32" /><text class="empty-copy">{{ emptyText }}</text></view>
|
||||
<view v-else-if="stage !== 'review'" class="task-list"><view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card" :class="{ current: current?.relabelNo === item.relabelNo, 'rework-card': item.reviewDecision === 'REWORK' }" @tap="openExecution(item)"><view v-if="item.reviewDecision === 'REWORK'" class="rework-banner"><text class="rework-tag">退回重做</text><text class="rework-priority">优先处理</text></view><view v-if="item.reviewDecision === 'REWORK' && item.reviewRemark" class="rework-remark">退回备注:{{ item.reviewRemark }}</view><view class="row-head"><view><text class="business-code">{{ item.relabelNo }}</text><text class="meta-line">客户订单:{{ item.customerOrderNo || '—' }}</text><text class="meta-line">换标类型:{{ relabelTypeName(item.operationType) }}</text></view><text class="status-pill">{{ statusLabel(item.taskStatus || item.applicationStatus) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ item.expectedPackages }}</strong><text>应处理</text></view><view class="metric"><strong>{{ item.relabelledPackages }}</strong><text>已换标</text></view><view class="metric"><strong>{{ item.skippedPackages + item.exceptionPackages }}</strong><text>跳过/异常</text></view><text class="link-button">{{ item.reviewDecision === 'REWORK' ? '优先重做' : '查看并执行' }} ›</text></view></view></view>
|
||||
<view v-else class="task-list"><view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card" @tap="openReview(item)"><view class="row-head"><view><text class="business-code">{{ item.relabelNo }}</text><text class="meta-line">客户订单:{{ item.customerOrderNo || '—' }}</text><text class="meta-line">换标类型:{{ relabelTypeName(item.operationType) }}</text></view><text class="status-pill">{{ statusLabel(item.taskStatus || item.applicationStatus) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ item.expectedPackages }}</strong><text>应处理</text></view><view class="metric"><strong>{{ item.relabelledPackages }}</strong><text>已换标</text></view><view class="metric"><strong>{{ item.skippedPackages + item.exceptionPackages }}</strong><text>跳过/异常</text></view><text class="link-button">查看并复核 ›</text></view></view></view>
|
||||
<view v-else-if="stage !== 'review'" class="task-list"><view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card" :class="{ current: current?.relabelNo === item.relabelNo, 'rework-card': item.reviewDecision === 'REWORK' }" @tap="openExecution(item)"><view v-if="item.reviewDecision === 'REWORK'" class="rework-banner"><text class="rework-tag">退回重做</text><text class="rework-priority">优先处理</text></view><view v-if="item.reviewDecision === 'REWORK' && item.reviewRemark" class="rework-remark">退回备注:{{ item.reviewRemark }}</view><view class="row-head"><view><view class="candidate-heading"><text class="business-code">{{ item.relabelNo }}</text><text class="candidate-customer">所属公司:{{ item.customerName || '未提供' }}</text></view><text class="meta-line">客户订单:{{ item.customerOrderNo || '—' }}</text><text class="meta-line">换标类型:{{ relabelTypeName(item.operationType) }}</text></view><text class="status-pill">{{ statusLabel(item.taskStatus || item.applicationStatus) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ item.expectedPackages }}</strong><text>应处理</text></view><view class="metric"><strong>{{ item.relabelledPackages }}</strong><text>已换标</text></view><view class="metric"><strong>{{ item.skippedPackages + item.exceptionPackages }}</strong><text>跳过/异常</text></view><text class="link-button">{{ item.reviewDecision === 'REWORK' ? '优先重做' : '查看并执行' }} ›</text></view></view></view>
|
||||
<view v-else class="task-list"><view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card" @tap="openReview(item)"><view class="row-head"><view><view class="candidate-heading"><text class="business-code">{{ item.relabelNo }}</text><text class="candidate-customer">所属公司:{{ item.customerName || '未提供' }}</text></view><text class="meta-line">客户订单:{{ item.customerOrderNo || '—' }}</text><text class="meta-line">换标类型:{{ relabelTypeName(item.operationType) }}</text></view><text class="status-pill">{{ statusLabel(item.taskStatus || item.applicationStatus) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ item.expectedPackages }}</strong><text>应处理</text></view><view class="metric"><strong>{{ item.relabelledPackages }}</strong><text>已换标</text></view><view class="metric"><strong>{{ item.skippedPackages + item.exceptionPackages }}</strong><text>跳过/异常</text></view><text class="link-button">查看并复核 ›</text></view></view></view>
|
||||
|
||||
</template>
|
||||
<view v-if="showCurrent" class="work-card">
|
||||
<text class="status-pill">换标类型:{{ relabelTypeName(current.operationType) }}</text>
|
||||
<view class="row-head"><view><text class="business-code">{{ current.relabelNo }}</text><text class="meta-line">客户订单:{{ current.customerOrderNo || '—' }}</text><text class="meta-line">标签截止:{{ formatTime(current.labelDeadline) }}</text></view><text class="status-pill">{{ statusLabel(current.taskStatus || current.applicationStatus) }}</text></view>
|
||||
<view class="metric-row"><view class="metric"><strong>{{ current.expectedPackages }}</strong><text>应处理</text></view><view class="metric"><strong>{{ current.relabelledPackages }}</strong><text>已换标</text></view><view class="metric"><strong>{{ current.skippedPackages }}</strong><text>跳过</text></view><view class="metric"><strong>{{ current.exceptionPackages }}</strong><text>异常</text></view></view>
|
||||
<view class="destination-summary"><text class="meta-line">FBA 目的仓:</text><text v-for="destination in fbaDestinations" :key="destination" class="destination-tag">{{ destination }}</text><text v-if="!fbaDestinations.length" class="meta-line">待维护</text><view class="summary-metrics"><text class="summary-metric">应处理 {{ current.expectedPackages }}</text><text class="summary-metric">已换标 {{ current.relabelledPackages }}</text><text class="summary-metric">跳过 {{ current.skippedPackages }}</text><text class="summary-metric">异常 {{ current.exceptionPackages }}</text></view></view>
|
||||
<view class="section-gap"><view class="row-head"><text class="card-title">标签文件</text><button class="link-button" :disabled="busy" @tap="loadDocuments">刷新文件</button></view><text v-if="!documents.length" class="meta-line">暂无标签文件</text><view v-for="file in documents" :key="`${file.scope}:${file.fileId}`" class="row-head"><text class="meta-line">{{ file.scope === 'CARTON_TOTAL' ? '总箱唛' : file.scope === 'SKU_TOTAL' ? '总 SKU 标签' : '明细标签' }}:{{ file.fileName }}</text><button class="secondary" :disabled="busy" @tap="printDocument(file)">查看 / 打印</button></view></view>
|
||||
<template v-if="stage === 'execute'">
|
||||
<view v-if="current.reviewDecision === 'REWORK' && current.reviewRemark" class="error-box section-gap"><text class="error-title">退回重新贴标</text><text>{{ current.reviewRemark }}</text></view>
|
||||
<view v-if="current.taskStatus !== 'PROCESSING'" class="empty-box section-gap"><text class="empty-copy">当前任务不是处理中状态,请返回换标列表选择可执行任务,或等待复核退回。</text></view>
|
||||
<template v-else>
|
||||
<view v-if="availableTabs.length" class="tab-row" role="tablist"><button v-for="tab in availableTabs" :key="tab.key" class="tab-button" role="tab" :aria-selected="taskTab === tab.key" :class="{ active: taskTab === tab.key }" :disabled="busy" @tap="changeTab(tab.key)">{{ tab.name }}</button></view>
|
||||
<view v-if="availableTabs.length > 1" class="tab-row" role="tablist"><button v-for="tab in availableTabs" :key="tab.key" class="tab-button" role="tab" :aria-selected="taskTab === tab.key" :class="{ active: taskTab === tab.key }" :disabled="busy" @tap="changeTab(tab.key)">{{ tab.name }}</button></view>
|
||||
<text class="card-title section-gap">{{ isReturnBulk ? '退货贴标 SKU 明细' : taskTab === 'sku' ? 'SKU 换标明细' : '箱唛换标明细' }}</text>
|
||||
<view v-if="isReturnBulk" class="section-gap"><text class="meta-line">先扫描实际贴标的 SKU,核对数量后登记;无需扫描箱唛。</text><ScanTargetInput name="returnSku" v-model="returnSkuCode" label="扫描退货 SKU" placeholder="扫描或输入待贴标 SKU" :active="activeTarget === 'return-sku'" @activate="activeTarget = 'return-sku'" @submit="verifyReturnSku" /><button class="secondary" :disabled="busy || !returnSkuCode.trim()" @tap="verifyReturnSku(returnSkuCode)">识别 SKU</button><text v-if="verifiedReturnSkuKey" class="success-title">SKU 已识别,请确认实际贴标数量后登记</text></view>
|
||||
<view v-if="isReturnBulk" class="item-list section-gap"><view v-for="group in returnBulkSkuGroups" :key="group.key" class="item-card" :class="{ done: group.remaining === 0 }">
|
||||
@@ -31,17 +31,16 @@
|
||||
<view class="source-allocation"><text v-if="isReturnBulk" class="source-title">来源托盘换标数量</text><text v-else class="source-title">来源托盘拆箱数量</text><view v-for="source in group.sources" :key="source.itemId" class="source-row"><text>{{ source.palletCode || '未记录托盘' }}</text><strong>{{ isReturnBulk ? '换标' : '拆箱' }} {{ source.expectedPackages }} 件</strong></view><view class="source-total"><text>合计{{ isReturnBulk ? '换标' : '拆箱' }}</text><strong>{{ group.expectedPackages }} 件</strong></view></view>
|
||||
<view v-if="group.remaining > 0"><input v-model="bulkQuantities[group.key]" class="bulk-quantity" type="number" :placeholder="`本次贴标数量(默认剩余 ${group.remaining} 件)`" /><button class="primary bulk-total-button" :disabled="busy || verifiedReturnSkuKey !== group.key || returnSkuCode.trim().toUpperCase() !== group.skuCode.toUpperCase()" @tap="completeBulkSku(group)">{{ busyAction === 'return-bulk-sku' ? '提交中…' : `登记本次贴标(默认 ${group.remaining} 件)` }}</button></view>
|
||||
</view></view>
|
||||
<view v-if="taskTab === 'carton' && !isReturnBulk" class="section-gap"><ScanTargetInput name="quickCarton" v-model="quickCarton" label="仓库唛头" placeholder="扫描或输入箱唛" :active="activeTarget === 'quick-carton'" @activate="activeTarget = 'quick-carton'" @submit="submitCarton" /><button class="primary" :disabled="busy || !quickCarton.trim()" @tap="submitCarton(quickCarton)">确认本箱</button></view>
|
||||
<view v-if="taskTab === 'carton' && !isReturnBulk" class="section-gap"><ScanTargetInput name="quickCarton" v-model="quickCarton" label="仓库唛头" placeholder="扫描箱唛自动确认,手输后按完成" :active="activeTarget === 'quick-carton'" @activate="activeTarget = 'quick-carton'" @submit="submitCarton" /></view>
|
||||
<view v-if="taskTab === 'sku'" class="section-gap">
|
||||
<ScanTargetInput name="quickSku" v-model="quickSku" label="旧 SKU 标签" placeholder="扫描或输入旧 SKU 标签" :active="activeTarget === 'quick-sku'" @activate="activeTarget = 'quick-sku'" @submit="submitSku" />
|
||||
<button class="primary" :disabled="busy || !quickSku.trim()" @tap="submitSku(quickSku)">确认 SKU</button>
|
||||
<ScanTargetInput name="quickSku" v-model="quickSku" label="旧 SKU 标签" placeholder="扫描旧 SKU 自动确认" :active="activeTarget === 'quick-sku'" @activate="activeTarget = 'quick-sku'" @submit="submitSku" />
|
||||
<text v-if="skuChoices.length" class="hint">同一旧 SKU 有多条明细,请核对实际箱选择本次作业:</text>
|
||||
<button v-for="row in skuChoices" :key="`${row.item.itemId}:${row.index}`" :disabled="busy" @tap="commitSku(row)">{{ row.item.cartonMark }}:{{ row.sku.skuCode }} 换标 {{ row.sku.newSkuCode }} · {{ row.sku.quantity }} 件</button>
|
||||
<view v-for="row in pendingSkuRows" :key="`${row.item.itemId}:${row.index}`" class="item-card"><text class="business-code">{{ row.sku.skuCode }} 换标 {{ row.sku.newSkuCode }}</text><text class="meta-line">{{ row.item.cartonMark }} · {{ row.sku.quantity }} 件</text><button class="link-button" @tap="selectItem(row.item); changeAction('EXCEPTION')">异常 / 跳过</button></view>
|
||||
<view v-for="row in pendingSkuRows" :key="`${row.item.itemId}:${row.index}`" class="item-card sku-detail-card"><text class="business-code">{{ row.sku.skuCode }} 换标 {{ row.sku.newSkuCode }}</text><text class="meta-line">{{ row.item.cartonMark }} · {{ row.sku.quantity }} 件</text><button class="link-button" @tap="selectItem(row.item); changeAction('EXCEPTION')">异常 / 跳过</button></view>
|
||||
</view>
|
||||
<view v-if="taskTab === 'carton'" class="item-list"><view v-for="item in visibleItems" :key="item.itemId" class="item-card" :class="{ done: remaining(item) === 0, current: selectedItem?.itemId === item.itemId }" @tap="selectItem(item)">
|
||||
<view class="row-head"><text class="business-code">{{ item.cartonMark || '—' }} → {{ item.fbaCartonMark || '—' }}</text></view>
|
||||
<text class="meta-line">FBA 目的仓:{{ item.destinationFbaWarehouseCode || '待维护' }}</text>
|
||||
<view v-if="taskTab === 'carton'" class="item-list carton-label-list"><view v-for="item in visibleItems" :key="item.itemId" class="carton-label-item" :class="{ done: remaining(item) === 0, current: selectedItem?.itemId === item.itemId }" @tap="selectItem(item)">
|
||||
<view class="carton-label"><text class="label-caption">仓库箱唛</text><text class="label-code">{{ item.cartonMark || '—' }}</text></view><text class="label-arrow">→</text><view class="carton-label fba-label"><text class="label-caption">FBA 箱唛</text><text class="label-code">{{ item.fbaCartonMark || '—' }}</text></view>
|
||||
<text v-if="fbaDestinations.length > 1" class="destination-tag">{{ item.destinationFbaWarehouseCode || '待维护' }}</text>
|
||||
</view></view>
|
||||
<template v-if="taskTab === 'sku' && selectedItem && action !== 'RELABEL' && remaining(selectedItem) > 0">
|
||||
<view class="mode-row section-gap"><button v-for="mode in actionModes" :key="mode.key" class="mode-button" :class="{ active: action === mode.key }" @tap="changeAction(mode.key)">{{ mode.name }}</button></view>
|
||||
@@ -73,7 +72,6 @@
|
||||
|
||||
</view>
|
||||
|
||||
<view v-if="error" class="error-box" role="alert"><text class="error-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><button class="secondary" @tap="retry">重新读取任务</button></view>
|
||||
<view v-if="successMessage" class="success-box" role="status"><text class="success-title">{{ successMessage }}</text></view>
|
||||
</view></scroll-view>
|
||||
</view>
|
||||
@@ -89,6 +87,7 @@
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
import { scanRelabelSku, scanRelabelCarton, completeReturnRelabelSkuTotal, listRelabelDocuments, downloadRelabelDocument, downloadRelabelPhoto } from '@/api/relabel.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
import { isRelabelFundsBlocked, relabelErrorMessage } from '@/utils/relabel-error.js'
|
||||
|
||||
const STAGES = { relabelReceive: 'receive', relabelExecute: 'execute', relabelReview: 'review' }
|
||||
const isConflict = (error) => ['409', 'A0409', 'B0409'].includes(String(error?.code || ''))
|
||||
@@ -96,6 +95,7 @@
|
||||
components: { PortalHeader, ScanTargetInput, RelabelPalletWork },
|
||||
data() { return { user: session.currentUser, stage: 'receive', entryTaskNo: '', unitDrafts: {}, taskTab: 'sku', candidates: [], current: null, selectedItem: null, returnSkuCode: '', verifiedReturnSkuKey: '', bulkQuantities: {}, activeTarget: 'task', loading: false, busy: false, busyAction: '', error: null, successMessage: '', exceptionCode: 'OTHER', actionModes: [{ key: 'RELABEL', name: '正常换标' }, { key: 'SKIP', name: '跳过' }, { key: 'EXCEPTION', name: '异常' }], exceptionCodes: [{ key: 'DAMAGED', name: '破损' }, { key: 'MISSING_LABEL', name: '缺标签' }, { key: 'LABEL_MISMATCH', name: '标签不符' }, { key: 'UNREADABLE', name: '无法识别' }, { key: 'OTHER', name: '其他' }] } },
|
||||
computed: {
|
||||
fbaDestinations() { return [...new Set((this.current?.items || []).map(item => item.destinationFbaWarehouseCode).filter(Boolean))] },
|
||||
isReturnBulk() { return this.current?.operationType === 'RETURN_BULK_SKU_LABEL' },
|
||||
returnBulkSkuGroups() {
|
||||
const groups = new Map()
|
||||
@@ -171,7 +171,10 @@
|
||||
this.quickSku = oldSku
|
||||
const matches = this.pendingSkuRows.filter(row => row.sku.skuCode === oldSku)
|
||||
this.skuChoices = []
|
||||
if (!matches.length) return this.setError(new Error('该旧 SKU 不在待处理明细中,或已处理完成'))
|
||||
if (!matches.length) {
|
||||
const exists = (this.current.items || []).some(item => this.skuDetails(item).some(sku => sku.skuCode === oldSku))
|
||||
return this.setError(new Error(exists ? '该 SKU 已处理完成,不需要重复换标' : '本单没有该 SKU,不需要换标'))
|
||||
}
|
||||
if (matches.length > 1) { this.skuChoices = matches; return }
|
||||
await this.commitSku(matches[0])
|
||||
},
|
||||
@@ -330,14 +333,22 @@
|
||||
async review(decision) { if (!this.current || this.current.taskStatus !== 'WAIT_REVIEW') return; const payload = { relabelNo: this.current.relabelNo, decision, expectedVersion: this.current.version, remark: this.remark.trim() }; const key = warehouseIdempotencyKey('relabel', 'review', payload); await this.run('review', async () => { this.applyCurrent(await reviewRelabelTask(this.current.relabelNo, { decision, expectedVersion: this.current.version, idempotencyKey: key.value, remark: this.remark.trim() || undefined })); releaseWarehouseKey('relabel', key.signature); this.remark = ''; this.successMessage = decision === 'APPROVE' ? '换标复核已通过' : '换标任务已退回重做'; await this.loadCandidates() }, true, key) },
|
||||
async retry() { if (this.current?.relabelNo) await this.openTask(this.current.relabelNo, false); else await this.loadCandidates() },
|
||||
async reloadCurrent() { if (this.current?.relabelNo) this.applyCurrent(await getRelabelTask(this.current.relabelNo)) },
|
||||
async run(action, work, refreshOnConflict = false, key = null) { if (this.busy) return; this.busy = true; this.busyAction = action; this.error = null; this.successMessage = ''; try { await work(); if (!['detail'].includes(action)) playSuccessSound() } catch (error) { this.setError(error, `${this.title}失败`); if (refreshOnConflict && isConflict(error)) { if (key) releaseWarehouseKey('relabel', key.signature); try { await this.reloadCurrent(); this.error.message += ',已刷新任务版本,请重新确认' } catch (_) {} } } finally { this.busy = false; this.busyAction = '' } },
|
||||
setError(error, fallback = '换标作业失败') { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
|
||||
async run(action, work, refreshOnConflict = false, key = null) { if (this.busy) return; this.busy = true; this.busyAction = action; this.error = null; this.successMessage = ''; try { await work(); if (!['detail'].includes(action)) playSuccessSound() } catch (error) { this.setError(error, `${this.title}失败`); if (refreshOnConflict && isConflict(error) && !isRelabelFundsBlocked(error)) { if (key) releaseWarehouseKey('relabel', key.signature); try { await this.reloadCurrent(); this.error.message += ',已刷新任务版本,请重新确认' } catch (_) {} } } finally { this.busy = false; this.busyAction = '' } },
|
||||
setError(error, fallback = '换标作业失败') {
|
||||
this.error = { message: relabelErrorMessage(error, fallback), code: error?.code, requestId: error?.requestId, traceId: error?.traceId }
|
||||
playErrorSound()
|
||||
const content = [this.error.message, this.error.code && `错误码:${this.error.code}`, this.error.requestId && `requestId:${this.error.requestId}`, this.error.traceId && `traceId:${this.error.traceId}`].filter(Boolean).join('\n')
|
||||
uni.showModal({ title: '操作提示', content, showCancel: false, confirmText: '知道了' })
|
||||
},
|
||||
statusLabel(value) { return ({ AVAILABLE: '待认领', PENDING_PROCESSING: '待认领', PROCESSING: '处理中', WAIT_REVIEW: '待客户/平台处理', COMPLETED: '已完成' })[value] || value || '待认领' },
|
||||
formatTime(value) { return value ? String(value).replace('T', ' ').slice(0, 16) : '—' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">@import '../warehouse/operations.scss';.action-row>.danger,.action-row>.primary{width:50%}.item-card.current{border-color:#6ea3ee;background:#f3f8ff}.task-card.rework-card{border:2px solid #f29b38;background:#fffaf2;box-shadow:0 5px 16px rgba(221,116,20,.13)}.rework-banner{display:flex;align-items:center;gap:8px;margin-bottom:9px}.rework-tag,.rework-priority{display:inline-flex;padding:4px 9px;border-radius:999px;font-size:11px;font-weight:800}.rework-tag{background:#d94343;color:#fff}.rework-priority{background:#fff0d7;color:#b55a0b}.rework-remark{margin-bottom:11px;padding:9px 10px;border-left:3px solid #e2761b;border-radius:6px;background:#fff3e4;color:#8e4308;font-size:12px;line-height:1.55}.detail-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:4px 0 10px;padding:10px 12px;border:1px solid #bed4f6;border-radius:11px;background:#eef5ff}.detail-kicker,.detail-title{display:block}.detail-kicker{color:#6580a4;font-size:9px}.detail-title{margin-top:2px;color:#153d74;font-size:14px;font-weight:800}.detail-toolbar button{flex-shrink:0;min-height:34px;margin:0;padding:0 11px;border-radius:8px;color:#135ee8;font-size:10px;line-height:34px;background:#fff}.tab-row{display:flex;gap:10px;margin:14px 0 10px}.tab-button{flex:1;border:1px solid #d7e1ef;border-radius:10px;background:#fff;color:#52647c;font-size:15px;padding:10px 8px}.tab-button.active{border-color:#2167e8;background:#2167e8;color:#fff;font-weight:600}
|
||||
<style scoped lang="scss">@import '../warehouse/operations.scss';.action-row>.danger,.action-row>.primary{width:50%}.item-card.current{border-color:#6ea3ee;background:#f3f8ff}.task-card.rework-card{border:2px solid #f29b38;background:#fffaf2;box-shadow:0 5px 16px rgba(221,116,20,.13)}.rework-banner{display:flex;align-items:center;gap:8px;margin-bottom:9px}.rework-tag,.rework-priority{display:inline-flex;padding:4px 9px;border-radius:999px;font-size:11px;font-weight:800}.rework-tag{background:#d94343;color:#fff}.rework-priority{background:#fff0d7;color:#b55a0b}.rework-remark{margin-bottom:11px;padding:9px 10px;border-left:3px solid #e2761b;border-radius:6px;background:#fff3e4;color:#8e4308;font-size:12px;line-height:1.55}.detail-toolbar{display:flex;align-items:center;justify-content:flex-end;margin:0 0 10px}.detail-toolbar button{flex-shrink:0;min-height:34px;margin:0;padding:0 11px;border-radius:8px;color:#135ee8;font-size:10px;line-height:34px;background:#fff}.tab-row{display:flex;gap:8px;margin:12px 0 8px}.tab-button{flex:none;width:auto;margin:0;border:1px solid #d7e1ef;border-radius:7px;background:#fff;color:#52647c;font-size:12px;line-height:30px;padding:0 14px}.tab-button.active{border-color:#2167e8;background:#2167e8;color:#fff;font-weight:600}
|
||||
.photo-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:12px}.photo-tile{min-width:0;overflow:hidden;border:1px solid #d7e1ef;border-radius:8px}.photo-thumbnail{display:block;width:100%;height:140px;background:#f1f4f8}.photo-placeholder{margin:0;border:0;border-radius:0;font-size:13px;white-space:normal;line-height:1.5;display:flex;align-items:center;justify-content:center}.photo-name{display:block;padding:6px 8px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:12px;color:#52647c}.photo-actions{display:flex;justify-content:space-around;padding:4px}.photo-actions .link-button{margin:0}.bulk-total-button{margin-top:14px}.bulk-quantity{box-sizing:border-box;width:100%;padding:12px;border:1px solid #d7e1ef;border-radius:8px;background:#fff}.source-allocation{margin-top:14px;padding:12px;border-radius:10px;background:#f6f8fb}.source-title{display:block;font-weight:700;color:#24364b;margin-bottom:8px}.source-row,.source-total{display:flex;justify-content:space-between;gap:12px;padding:8px 0;color:#52647c}.source-row+ .source-row{border-top:1px solid #e4eaf2}.source-row text{min-width:0;overflow-wrap:anywhere}.source-row strong,.source-total strong{white-space:nowrap;color:#1f5fca}.source-total{border-top:1px solid #d7e1ef;font-weight:700}
|
||||
</style>
|
||||
.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}</style>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<view v-if="user" class="page-shell portal-page">
|
||||
<PortalHeader title="退货箱内清点" :user="user" fallback-url="/pages/module/module?key=inbound" />
|
||||
<view class="work-content counting-content">
|
||||
<view class="work-card"><ScanTargetInput name="carton" v-model="scanCode" label="箱号或托盘号" placeholder="扫描箱号或托盘号" :active="true" @submit="scan" /><view class="actions"><button v-if="task || scanResult" class="secondary small" :disabled="busy" @tap="back">返回任务</button></view></view>
|
||||
<view v-if="error" class="work-card error-card"><text>{{ error.message }}</text><text v-if="error.code" class="hint">错误码:{{ error.code }}</text><text v-if="error.requestId" class="hint">请求号:{{ error.requestId }}</text><text v-if="error.traceId" class="hint">跟踪号:{{ error.traceId }}</text></view>
|
||||
<template v-if="!task">
|
||||
<view v-if="activeTask" class="work-card"><text class="card-title">有未完成的清点任务</text><text class="hint">{{ activeTask.returnNo }} · {{ activeTask.palletCode }}</text><button class="secondary small" :disabled="busy" @tap="choose(activeTask)">继续未完成清点</button></view>
|
||||
<view v-for="row in tasks" :key="row.receiptNo" class="work-card" @tap="choose(row)"><text class="card-title">{{ row.palletCode }}</text><text class="hint">{{ row.returnNo }}</text><text class="hint">{{ row.boxes.length }} 箱 · {{ row.status === 'COUNTING' ? '清点中' : '待清点' }}</text><text class="entry">{{ row.status === 'COUNTING' ? '继续清点' : '开始清点' }} ›</text></view>
|
||||
<view v-if="!error && !busy && !tasks.length" class="work-card hint">{{ scanResult ? '无需清点' : '暂无待清点的托盘签收任务' }}</view>
|
||||
<view v-if="!scanResult && total > 10" class="actions pager"><button class="secondary small" :disabled="busy || page <= 1" @tap="changePage(-1)">上一页</button><text>{{ page }} / {{ Math.max(1, Math.ceil(total / 10)) }}</text><button class="secondary small" :disabled="busy || page * 10 >= total" @tap="changePage(1)">下一页</button></view>
|
||||
</template>
|
||||
<template v-else>
|
||||
<view class="work-card"><text class="card-title">{{ task.palletCode }}</text><text class="hint">{{ task.status === 'COUNTING' ? '清点中' : '已完成' }}</text><text class="hint">{{ task.returnNo }} · {{ task.boxes.length }} 箱</text><view v-for="row in totals" :key="row.skuCode" class="total-row" :class="{ matched: row.actual === row.quantity }"><text>{{ row.skuCode }}</text><text>签收 {{ row.quantity }} / 已填 {{ row.actual }}</text></view></view>
|
||||
<view v-for="(box, index) in task.boxes" :id="`box-${index}`" :key="box.cartonMark" class="work-card box-card" :class="{ selected: box.cartonMark === selectedMark }">
|
||||
<view class="row-head"><text class="card-title">第 {{ index + 1 }} 箱</text><button v-if="task.status === 'COUNTING'" class="link-button" :disabled="busy" @tap="addSku(box)">添加 SKU</button></view><text class="box-mark">{{ box.cartonMark }}</text>
|
||||
<view v-for="(line, lineIndex) in box.items" :key="lineIndex" class="count-line"><picker class="sku-picker" :disabled="busy || task.status !== 'COUNTING'" :range="skuCodes" :value="Math.max(0, skuCodes.indexOf(line.skuCode))" @change="line.skuCode = skuCodes[Number($event.detail.value)]"><text>{{ line.skuCode || '选择 SKU' }} ▾</text></picker><input v-model="line.quantity" type="number" class="number-input quantity" :disabled="busy || task.status !== 'COUNTING'" placeholder="数量" /><button v-if="task.status === 'COUNTING' && box.items.length > 1" class="link-button" :disabled="busy" @tap="box.items.splice(lineIndex, 1)">删除</button></view>
|
||||
</view>
|
||||
<view class="work-card"><text class="hint">各箱 SKU 数量汇总必须与本托签收数量一致,确认后这些箱子才能换标、合箱或创建出库单。</text><button v-if="task.status === 'COUNTING'" class="primary" :disabled="busy || !ready" @tap="confirm">{{ busy ? '提交中…' : '确认全部箱内清点' }}</button><text v-else class="success">箱内清点已完成,可以继续换标或出库。</text></view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
import { listReturnCountTasks, scanReturnCountTasks, getCurrentReturnCountTask, beginReturnCountTask, confirmReturnCount } from '@/api/return-counting.js'
|
||||
export default {
|
||||
components: { PortalHeader, ScanTargetInput },
|
||||
data() { return { user: session.currentUser, busy: false, error: null, scanCode: '', scanResult: '', page: 1, total: 0, tasks: [], task: null, activeTask: null, selectedMark: '', requestKey: '', signature: '', scannerStop: null } },
|
||||
computed: {
|
||||
skuCodes() { return this.task?.expectedItems.map(row => row.skuCode) || [] },
|
||||
totals() { return (this.task?.expectedItems || []).map(row => ({ ...row, quantity: Number(row.quantity), actual: this.task.boxes.reduce((sum, box) => sum + box.items.filter(line => line.skuCode === row.skuCode).reduce((value, line) => value + (Number(line.quantity) || 0), 0), 0) })) },
|
||||
ready() { return this.task?.status === 'COUNTING' && this.totals.length > 0 && this.totals.every(row => row.actual === row.quantity) && this.task.boxes.every(box => box.items.length && box.items.every(line => this.skuCodes.includes(line.skuCode) && Number.isSafeInteger(Number(line.quantity)) && Number(line.quantity) > 0) && new Set(box.items.map(line => line.skuCode)).size === box.items.length) },
|
||||
draftKey() { return this.task ? `return-count:${session.apiBase}:${this.user?.id}:${this.task.receiptNo}` : '' }
|
||||
},
|
||||
watch: { task: { deep: true, handler() { if (this.task?.status === 'COUNTING') uni.setStorageSync(this.draftKey, { boxes: this.task.boxes, requestKey: this.requestKey, signature: this.signature }) } } },
|
||||
onLoad(options) { this.scanCode = String(options?.cartonMark || '') },
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.scannerStop = startScanBroadcast({ onScan: value => this.scan(value) }); if (!this.task) { if (this.scanCode) this.scan(this.scanCode); else this.load() } },
|
||||
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
|
||||
methods: {
|
||||
stopScanner() { this.scannerStop?.(); this.scannerStop = null },
|
||||
async run(work) { if (this.busy) return; this.busy = true; this.error = null; try { await work() } catch (error) { this.error = { message: error?.message || '清点操作失败', code: error?.code, requestId: error?.requestId, traceId: error?.traceId } } finally { this.busy = false } },
|
||||
async fetchTasks() { const [active, value] = await Promise.all([getCurrentReturnCountTask(), listReturnCountTasks({ page: this.page })]); this.activeTask = active; this.tasks = value.records || []; this.total = Number(value.total) || 0 },
|
||||
async load() { await this.run(() => this.fetchTasks()) },
|
||||
changePage(delta) { this.page += delta; this.load() },
|
||||
ask(options) { return new Promise((resolve, reject) => uni.showModal({ ...options, success: resolve, fail: reject })) },
|
||||
async choose(row) {
|
||||
await this.run(() => this.enterTask(row, true));
|
||||
},
|
||||
async enterTask(row, confirmStart = false) {
|
||||
const active = await getCurrentReturnCountTask(); this.activeTask = active;
|
||||
if (active && active.receiptNo !== row.receiptNo) {
|
||||
const answer = await this.ask({ title: '有未完成的清点任务', content: `退货单 ${active.returnNo} 尚未清点完成,一次只能清点一单。`, confirmText: '继续当前单', cancelText: '取消' });
|
||||
if (answer.confirm) this.open(await beginReturnCountTask(active.receiptNo));
|
||||
return;
|
||||
}
|
||||
if (confirmStart) {
|
||||
const answer = await this.ask({ title: active ? '继续清点' : '开始清点', content: `是否${active ? '继续' : '开始'}清点退货单 ${row.returnNo}?`, confirmText: active ? '继续' : '开始', cancelText: '取消' });
|
||||
if (!answer.confirm) return;
|
||||
}
|
||||
this.open(await beginReturnCountTask(row.receiptNo)); this.activeTask = this.task; this.selectedMark = this.scanResult;
|
||||
},
|
||||
open(row) { this.task = JSON.parse(JSON.stringify(row)); this.requestKey = ''; this.signature = ''; this.selectedMark = ''; const draft = uni.getStorageSync(this.draftKey); if (row.status === 'COUNTING' && draft?.boxes?.length === row.boxes.length && draft.boxes.every(box => row.boxes.some(item => item.cartonMark === box.cartonMark))) { this.task.boxes = draft.boxes; this.requestKey = draft.requestKey || ''; this.signature = draft.signature || '' } },
|
||||
async scan(value) { const mark = String(value || this.scanCode).trim(); if (!mark) return; await this.run(async () => { const rows = await scanReturnCountTasks(mark); this.tasks = rows; this.total = rows.length; this.scanResult = mark; this.scanCode = ''; this.selectedMark = ''; if (rows.length === 1) await this.enterTask(rows[0]); else this.task = null }) },
|
||||
addSku(box) { box.items.push({ skuCode: '', quantity: '' }) },
|
||||
async confirm() { if (!this.ready) return; await this.run(async () => { const boxes = this.task.boxes.map(box => ({ cartonMark: box.cartonMark, items: box.items.map(line => ({ skuCode: line.skuCode, quantity: Number(line.quantity) })) })); const signature = JSON.stringify(boxes); if (!this.requestKey || signature !== this.signature) { this.requestKey = `return-count-${Date.now()}-${Math.random().toString(36).slice(2)}`; this.signature = signature } uni.setStorageSync(this.draftKey, { boxes: this.task.boxes, requestKey: this.requestKey, signature }); const result = await confirmReturnCount(this.task.receiptNo, { idempotencyKey: this.requestKey, boxes }); if (result?.status !== 'CONFIRMED') throw new Error('清点未完成,请刷新核实任务状态'); uni.removeStorageSync(this.draftKey); this.activeTask = null; this.resetList(); uni.showToast({ title: '箱内清点完成', icon: 'success' }); await this.fetchTasks() }) },
|
||||
resetList() { this.task = null; this.selectedMark = ''; this.scanCode = ''; this.scanResult = ''; this.page = 1; this.tasks = []; this.total = 0 },
|
||||
back() { this.resetList(); this.load() }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
@import '../warehouse/operations.scss';
|
||||
.counting-content{padding:12px 12px 42px}.error-card{color:#9d2323;background:#fff0f0}
|
||||
.actions{display:flex;align-items:center;gap:8px;margin-top:10px}.small{width:auto;min-height:32px;margin:0;padding:0 12px;font-size:12px;line-height:32px}.search{width:100%;font-size:12px}.pager{justify-content:center;font-size:12px}.entry,.success{color:#135ee8;font-size:12px}.total-row{display:flex;justify-content:space-between;gap:12px;font-size:11px;margin-top:8px;color:#c67716}.total-row.matched{color:#22804a}.box-mark{display:block;color:#65768b;font-size:11px;margin:6px 0;word-break:break-all}.count-line{display:flex;align-items:center;gap:8px;margin-top:8px}.sku-picker{flex:1;min-width:0;font-size:11px;word-break:break-all}.quantity{flex:none;width:70px;height:32px;text-align:center;font-size:12px}.box-card.selected{border-color:#135ee8}.row-head .card-title{font-size:13px}
|
||||
</style>
|
||||
@@ -4,14 +4,14 @@
|
||||
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
|
||||
<button v-if="!result && (activePlan || mode === 'BOX')" class="link-button" :disabled="busy" @tap="leavePlan">返回扫码 / 计划列表</button>
|
||||
<ReturnBoxMergeWork v-if="mode === 'BOX' && !result" ref="boxWork" :key="activePlan?.planNo || initialSource?.cartonMark || 'direct'" :plan="activePlan" :initial-source="initialSource" @busy-change="busy = $event" @completed="packingCompleted" @error="setError($event, '箱合并失败')" />
|
||||
<view v-if="!result && !activePlan && mode !== 'BOX'" class="work-card"><text class="card-title">扫描 SKU 或箱号</text><ScanTargetInput name="stock" v-model="searchCode" label="SKU 或箱号" placeholder="扫描 SKU 或箱号,查询可合箱库存" :active="true" @submit="search" /><button class="primary" :disabled="!searchCode.trim() || busy" @tap="search(searchCode)">{{ busy ? '查询中…' : '查询可合箱库存' }}</button></view>
|
||||
<view v-if="!result && !activePlan && plans.length" class="work-card"><view class="row-head"><text class="card-title">待作业合箱计划</text><text class="meta-line">审核通过后推送</text></view><button v-for="plan in plans" :key="plan.planNo" class="task-card candidate-card" @tap="startPlan(plan)"><view class="row-head"><text class="business-code">{{ plan.planNo }}</text><text class="status-pill">{{ plan.status === 'READY' ? '待合箱' : plan.status }}</text></view><text class="meta-line">退货单:{{ plan.returnNos.join('、') }}</text><text class="select-link">开始作业 ›</text></button></view>
|
||||
<view v-if="!result && !activePlan && mode !== 'BOX'" class="work-card direct-merge-card"><text class="card-title">仓库现场直接合箱</text><text class="hint">扫描 SKU 或箱号开始;系统会自动锁定客户和当前仓,跨客户货物不能合箱。</text><ScanTargetInput name="stock" v-model="searchCode" label="SKU 或箱号" placeholder="扫描 SKU 或箱号,查询可合箱库存" :active="true" @submit="search" /><button class="primary" :disabled="!searchCode.trim() || busy" @tap="search(searchCode)">{{ busy ? '查询中…' : '查询可合箱库存' }}</button></view>
|
||||
<view v-if="!result && !activePlan && plans.length" class="work-card"><view class="row-head"><text class="card-title">平台合箱计划</text><text class="meta-line">审核通过后推送</text></view><text class="hint">按计划锁定的来源箱和数量作业,后续合箱规则不变。</text><button v-for="plan in plans" :key="plan.planNo" class="task-card candidate-card" @tap="startPlan(plan)"><view class="row-head"><text class="business-code">{{ plan.planNo }}</text><text class="status-pill">{{ plan.status === 'READY' ? '待合箱' : plan.status }}</text></view><text class="meta-line">退货单:{{ plan.returnNos.join('、') }}</text><text class="select-link">开始作业 ›</text></button></view>
|
||||
|
||||
<view v-if="customerCandidates.length && !selectedCustomer && !result" class="section-gap"><view class="work-card"><text class="card-title">选择客户</text><text class="hint">该 SKU 在多个客户有可用退货库存,请先选择客户</text><view class="task-list"><button v-for="(customer, index) in customerCandidates" :key="index" class="task-card candidate-card" @tap="selectCustomer(customer)"><view class="row-head"><view><text class="business-code">{{ customerLabel(customer, index) }}</text><text class="meta-line">可用 SKU {{ customer.skus.length }} 个 · 退货单 {{ customer.orders.length }} 个</text></view><text class="select-link">选择 ›</text></view></button></view></view></view>
|
||||
<view v-if="candidates !== null && !customerCandidates.length && !result" class="section-gap"><view v-if="!candidates.length" class="empty-box"><text class="empty-copy">没有匹配的已完成退货单或可用库存</text></view><view v-else class="task-list"><button v-for="item in candidates" :key="item.returnNo" class="task-card candidate-card" @tap="addOrder(item)"><view class="row-head"><view><text class="business-code">{{ item.returnNo }}</text><text class="meta-line">客户参考号:{{ item.customerReferenceNo || '—' }}</text></view><text class="select-link">添加 ›</text></view></button></view></view>
|
||||
|
||||
<template v-if="selectedOrders.length && !result && mode === 'SKU'">
|
||||
<view class="work-card"><text class="card-title">{{ activePlan ? `合箱计划 ${activePlan.planNo}` : '客户散件库存' }}</text><text class="hint">{{ activePlan ? '数量已由计划锁定,请分配到目标箱。' : '可用数量来自该客户同仓全部散件库存,后台自动追溯来源退货单。' }}</text></view>
|
||||
<view class="work-card"><text class="card-title">{{ activePlan ? `合箱计划 ${activePlan.planNo}` : '客户散件库存' }}</text><text class="hint">{{ activePlan ? '数量已由计划锁定,请分配到目标箱。' : '已锁定一个客户;可用数量来自该客户同仓全部散件库存,后台自动追溯来源退货单。' }}</text></view>
|
||||
<view class="work-card"><text class="card-title">新箱 SKU 数量</text><view class="item-list"><view v-for="item in availableSkus" :key="item.skuCode" class="item-card"><view class="row-head"><view><text class="business-code">{{ item.skuCode }}</text><text class="meta-line">{{ item.productNameZh || '商品名称未填写' }}</text></view><text class="remaining-copy">可用 {{ item.availableQuantity }}</text></view><view class="quantity-row"><text>装入数量</text><input class="number-input" type="number" :disabled="!!activePlan" :value="quantity(item.skuCode)" placeholder="0" @input="setQuantity(item, $event.detail.value)" /></view></view></view></view>
|
||||
<button class="secondary" :disabled="busy || !mergeQuantity" @tap="distributeBoxes">按单 SKU 一箱分配</button>
|
||||
<view v-for="(box,index) in targetBoxes" :key="index" class="work-card">
|
||||
|
||||
@@ -16,29 +16,27 @@
|
||||
|
||||
<view v-if="receiptBatch && !result" class="work-card"><text class="card-title">待完成按箱签收</text><text class="business-code">{{ receiptBatch.returnNo }}</text><text class="hint">已确认 {{ receiptBatch.entries.filter(entry => entry.result).length }} / {{ receiptBatch.entries.length }} 箱。请继续本批签收,已确认的箱不会重新入库;本批内容暂不可修改。</text><button class="primary" :disabled="busy" @tap="confirmReceipt">{{ busy ? '提交中…' : '继续本批签收' }}</button></view>
|
||||
<template v-if="current && !result && !receiptBatch">
|
||||
<view class="work-card"><view class="row-head"><view><text class="business-code">{{ current.returnNo }}</text><text class="meta-line">{{ startedBy === 'SKU' ? '通过 SKU 候选进入' : '通过退货单号进入' }}</text></view><text class="status-pill">{{ statusLabel(current.status) }}</text></view><button class="link-button" @tap="clearCurrent">更换退货单</button></view>
|
||||
<view class="work-card receipt-order-card"><view class="row-head"><text class="card-title">退货单</text><button class="link-button" @tap="clearCurrent">更换退货单</button></view><view class="receipt-order-line"><text class="business-code">{{ current.returnNo }}</text><text class="status-pill">{{ statusLabel(current.status) }}</text></view></view>
|
||||
|
||||
<view class="work-card">
|
||||
<text class="card-title">本次实物单元</text>
|
||||
<text class="hint">按现场收到的实物选择维度,托盘不是必填项。</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'"><text class="field-label">包裹码/快递单号</text><ScanTargetInput name="unit" v-model="unitCode" label="包裹码/快递单号" placeholder="扫描快递面单" :active="activeTarget === 'unit'" @activate="activeTarget = 'unit'" @submit="acceptUnit" /></view>
|
||||
<view v-if="unitType === 'PARCEL'"><text class="field-label">快递单号(选填)</text><ScanTargetInput name="unit" v-model="unitCode" label="快递单号(选填)" placeholder="扫描快递面单,可留空" :active="activeTarget === 'unit'" @activate="activeTarget = 'unit'" @submit="acceptUnit" /></view>
|
||||
<template v-else>
|
||||
<text v-if="unitType === 'PALLET'" class="scan-hint">托盘入库:扫描已有托盘或生成新托盘,沿用原托盘测量流程。</text><text v-else class="scan-hint">箱子可直接散装入库,也可扫描托盘直接打托。</text>
|
||||
<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="长" /></label><label><text>宽 cm</text><input class="number-input" type="digit" v-model="palletWidth" placeholder="宽" /></label><label><text>高 cm</text><input class="number-input" type="digit" v-model="palletHeight" placeholder="高" /></label><label><text>重量 kg</text><input class="number-input" type="digit" v-model="palletWeight" placeholder="重量" /></label><view class="volume-box"><text>体积</text><strong>{{ palletVolume }}</strong><text>cm³</text></view></view>
|
||||
<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><text class="field-label">快递单号(选填)</text><ScanTargetInput name="tracking" v-model="trackingNo" label="快递单号" placeholder="扫描快递面单" :active="activeTarget === 'tracking'" @activate="activeTarget = 'tracking'" @submit="acceptTracking" /></view>
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<view class="work-card"><view class="row-head"><text class="card-title">{{ unitType === 'PARCEL' ? '本次实收数量' : (unitType === 'PALLET' ? '本托实收数量' : '逐箱实收数量') }}</text><button v-if="unitType !== 'BOX'" class="link-button" @tap="fillRemaining">全部填入</button></view><view v-if="unitType === 'PALLET'" class="quantity-row"><text>本托箱数合计</text><strong class="auto-count">{{ palletBoxCount }}</strong></view><template v-if="unitType === 'BOX'"><view class="quantity-row"><text>本次箱数</text><input class="number-input" type="number" :value="boxRows.length" placeholder="箱数" @input="resizeBoxRows($event.detail.value)" /></view><view v-for="(box,index) in boxRows" :key="index" class="item-card"><text class="business-code">第 {{ index + 1 }} 箱</text><view v-for="item in selectedItems" :key="item.skuCode" class="quantity-row"><text>{{ item.skuCode }} · 数量</text><input class="number-input" type="number" :value="box.items[item.skuCode] || ''" placeholder="0" @input="setBoxQuantity(index, item.skuCode, $event.detail.value)" /></view></view></template><view v-else class="item-list"><view v-for="item in selectedItems" :key="item.skuCode" class="item-card"><view class="row-head"><view><text class="business-code">{{ item.skuCode }}</text><text class="meta-line">{{ item.productNameZh || '商品名称未填写' }}</text></view><text class="remaining-copy">剩余 {{ remaining(item) }}</text></view><view class="quantity-row"><text>数量</text><input class="number-input" type="number" :value="quantity(item.skuCode)" placeholder="0" @input="setQuantity(item, $event.detail.value)" /></view><view v-if="unitType === 'PALLET'" class="quantity-row"><text>此 SKU 箱数</text><input class="number-input" type="number" :value="boxCount(item.skuCode)" placeholder="0" @input="setBoxCount(item, $event.detail.value)" /></view></view></view></view>
|
||||
<view class="summary-bar"><view><text class="summary-label">本托合计</text><text class="summary-value">{{ receiptQuantity }}</text></view><button class="primary submit-button" :disabled="!canConfirm || busy" @tap="confirmReceipt">{{ busy ? '提交中…' : '确认签收并入托' }}</button></view>
|
||||
<view class="work-card"><view class="row-head"><text class="card-title">{{ unitType === 'PARCEL' ? '本次实收数量' : (unitType === 'PALLET' ? '本托实收数量' : '逐箱实收数量') }}</text><button v-if="unitType !== 'BOX'" class="link-button" @tap="fillRemaining">全部填入</button></view><view v-if="unitType === 'PALLET'" class="quantity-row"><text>本托箱数合计</text><strong class="auto-count">{{ palletBoxCount }}</strong></view><template v-if="unitType === 'BOX'"><view class="quantity-row"><text>本次箱数</text><input class="number-input" type="number" :value="boxRows.length" placeholder="箱数" @input="resizeBoxRows($event.detail.value)" /></view><view v-for="(box,index) in boxRows" :key="index" class="item-card"><text class="business-code">第 {{ index + 1 }} 箱</text><view v-for="item in selectedItems" :key="item.skuCode" class="quantity-row"><text>{{ item.skuCode }} · 数量</text><input class="number-input" type="number" :value="box.items[item.skuCode] || ''" placeholder="0" @input="setBoxQuantity(index, item.skuCode, $event.detail.value)" /></view></view></template><view v-else class="item-list receipt-sku-list"><view v-for="item in selectedItems" :key="item.skuCode" class="item-card"><view class="row-head"><view><text class="business-code">{{ item.skuCode }}</text><text class="meta-line">{{ item.productNameZh || '商品名称未填写' }}</text></view><text class="remaining-copy">剩余 {{ remaining(item) }}</text></view><view class="sku-quantity-fields"><view class="quantity-row"><text>数量</text><input class="number-input" type="number" :value="quantity(item.skuCode)" placeholder="0" @input="setQuantity(item, $event.detail.value)" /></view><view v-if="unitType === 'PALLET'" class="quantity-row"><text>SKU 箱数</text><input class="number-input" type="number" :value="boxCount(item.skuCode)" placeholder="0" @input="setBoxCount(item, $event.detail.value)" /></view></view></view></view></view>
|
||||
<view class="summary-bar"><view><text class="summary-label">{{ unitType === 'PARCEL' ? '本次合计' : '本托合计' }}</text><text class="summary-value">{{ receiptQuantity }}</text></view><button class="primary submit-button" :disabled="!canConfirm || busy" @tap="confirmReceipt">{{ busy ? '提交中…' : unitType === 'PARCEL' ? '确认包裹签收' : '确认签收并入托' }}</button></view>
|
||||
<view class="work-card"><view class="row-head"><view><text class="card-title">退货打托照片</text><text class="hint">签收过程中可随时拍照</text></view><text class="status-pill">{{ photoFileIds.length }} / 20 张</text></view><button class="secondary" :disabled="busy || photoFileIds.length >= 20" @tap="choosePhoto">拍照或从相册选择</button><view v-if="photoFileIds.length" class="photo-grid"><view v-for="(fileId,index) in photoFileIds" :key="fileId" class="photo-tile"><image class="photo-thumbnail" :src="photoUrls[fileId]" mode="aspectFill" @tap="previewPhoto(fileId)" /><text class="photo-name">{{ photoNames[index] || '退货打托照片' }}</text><button class="link-button" :disabled="busy" @tap="removePhoto(index)">移除</button></view></view></view>
|
||||
<button v-if="current.status === 'PARTIALLY_RECEIVED'" class="secondary manual-complete" :disabled="busy" @tap="manualComplete">缺货手动完结</button>
|
||||
<view v-if="current.completedPallets?.length" class="work-card"><text class="card-title">本单已完成打托</text><view class="item-list"><view v-for="pallet in current.completedPallets" :key="pallet.palletCode" class="item-card"><text class="business-code">{{ pallet.palletCode }}</text><text class="meta-line">{{ pallet.skuCode }} · {{ pallet.quantity }} 件</text><text class="meta-line">签收批次:{{ pallet.receiptBatchNo }}</text></view></view></view>
|
||||
</template>
|
||||
|
||||
<view v-if="result" class="success-box result-card"><text class="success-title">退货签收成功</text><view v-if="result.labels?.length"><view v-for="(label,index) in result.labels" :key="label.cartonMark" class="item-card"><text class="business-code">第 {{ index + 1 }} 箱 · {{ label.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(label)">打印箱唛</button></view></view><view v-else-if="result.printableHtml" class="item-card"><text class="business-code">{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }} · {{ result.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(result)">打印{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }}</button></view><text v-if="result.palletCode" class="result-carton">托盘:{{ result.palletCode }}</text><text class="meta-line">签收单:{{ result.receiptNo }}</text><text class="meta-line">计费批次:{{ result.receiptBatchNo }}</text><view class="metric-row"><view class="metric"><strong>{{ result.receivedQuantity }}/{{ result.expectedQuantity }}</strong><text>整单累计</text></view><view class="metric"><strong>{{ result.fullyMatched ? '已完成' : '签收中' }}</strong><text>退货单状态</text></view></view><button class="primary" @tap="nextReceipt">继续签收</button></view>
|
||||
<view v-if="result" class="success-box result-card"><text class="success-title">退货签收成功</text><view v-if="result.labels?.length"><view v-for="(label,index) in result.labels" :key="label.cartonMark" class="item-card"><text class="business-code">第 {{ index + 1 }} 箱 · {{ label.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(label)">打印箱唛</button></view></view><view v-else-if="result.printableHtml" class="item-card"><text class="business-code">{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }} · {{ result.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(result)">打印{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }}</button></view><button v-if="result.unitType === 'PALLET' && result.labels?.length" class="secondary" :disabled="busy" @tap="goCount">去箱内清点</button><text v-if="result.palletCode" class="result-carton">托盘:{{ result.palletCode }}</text><text class="meta-line">签收单:{{ result.receiptNo }}</text><text class="meta-line">计费批次:{{ result.receiptBatchNo }}</text><view class="metric-row"><view class="metric"><strong>{{ result.receivedQuantity }}/{{ result.expectedQuantity }}</strong><text>整单累计</text></view><view class="metric"><strong>{{ result.fullyMatched ? '已完成' : '签收中' }}</strong><text>退货单状态</text></view></view><button class="primary" :disabled="busy" @tap="nextReceipt">{{ result.continuationReturnNo ? (unitType === 'PALLET' ? '新增托盘,继续签收剩余货物' : '继续签收本单剩余货物') : '签收下一单' }}</button><button v-if="unitType === 'PALLET' && result.continuationReturnNo" class="secondary" :disabled="busy" @tap="continueScanning">继续签收</button></view>
|
||||
|
||||
<view v-if="error" class="error-box" role="alert"><text class="error-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><button v-if="current && !receiptBatch" class="secondary" :disabled="busy" @tap="reloadCurrent">刷新退货单</button></view>
|
||||
</view></scroll-view>
|
||||
@@ -64,11 +62,11 @@
|
||||
startedBy: { get() { return warehouseWork.returnReceipt.startedBy }, set(value) { updateWarehouseWork('returnReceipt', { startedBy: value }) } },
|
||||
trackingNo: { get() { return warehouseWork.returnReceipt.trackingNo }, set(value) { updateWarehouseWork('returnReceipt', { trackingNo: value }) } },
|
||||
palletCode: { get() { return warehouseWork.returnReceipt.palletCode }, set(value) { updateWarehouseWork('returnReceipt', { palletCode: value }) } },
|
||||
selectedItems() { return this.current?.expectedItems?.filter(item => !this.selectedSku || item.skuCode === this.selectedSku) || [] },
|
||||
selectedItems() { return this.current?.expectedItems || [] },
|
||||
palletBoxCount() { return this.selectedItems.reduce((sum, item) => sum + this.boxCount(item.skuCode), 0) },
|
||||
activeSkuCode() { return this.selectedItems.length === 1 ? this.selectedItems[0].skuCode : '' },
|
||||
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) : '—' }
|
||||
@@ -76,6 +74,7 @@
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.entryMode = 'SKU'; this.startScanner(); if (!this.receiptBatch && warehouseWork.returnReceipt.currentReturnNo && !this.current && !this.busy) this.openOrder(warehouseWork.returnReceipt.currentReturnNo, 'SKU', false) },
|
||||
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
|
||||
methods: {
|
||||
goCount() { const mark = this.result?.labels?.[0]?.cartonMark; if (mark) uni.navigateTo({ url: '/pages/return-counting/return-counting?cartonMark=' + encodeURIComponent(mark) }) },
|
||||
selectUnitType(type) { if (this.busy) return; this.unitType = type; this.unitCode = ''; this.boxRows = [{ items: {} }]; this.palletCheck = null; this.activeTarget = type === 'PALLET' ? 'pallet' : 'unit' },
|
||||
resizeBoxRows(raw) { const count = Math.floor(Number(raw) || 0); if (count < 1 || count > 100) return; this.boxRows = Array.from({ length: count }, (_, index) => this.boxRows[index] || { items: {} }) },
|
||||
setBoxQuantity(index, skuCode, raw) { const value = Math.max(0, Math.floor(Number(raw) || 0)); this.boxRows[index].items = { ...this.boxRows[index].items, [skuCode]: value } },
|
||||
@@ -104,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 }
|
||||
})
|
||||
@@ -135,7 +134,7 @@
|
||||
},
|
||||
finishReceipt(results) {
|
||||
const last = results[results.length - 1]
|
||||
this.result = { ...last, labels: results.filter(item => item.unitType === 'BOX' && item.printableHtml).map(item => ({ cartonMark: item.cartonMark, printableHtml: item.printableHtml })) }
|
||||
this.result = { ...last, continuationReturnNo: !last.fullyMatched ? this.current?.returnNo || '' : '', labels: results.filter(item => item.unitType === 'BOX' && item.printableHtml).map(item => ({ cartonMark: item.cartonMark, printableHtml: item.printableHtml })) }
|
||||
resetWarehouseDomain('returnReceipt')
|
||||
this.current = null
|
||||
this.candidates = null
|
||||
@@ -148,7 +147,27 @@
|
||||
removePhoto(index) { const [id] = this.photoFileIds.splice(index, 1); this.photoNames.splice(index, 1); if (id) delete this.photoUrls[id] },
|
||||
async reloadCurrent() { if (!this.current?.returnNo || this.busy) return; await this.openOrder(this.current.returnNo, this.startedBy, false) },
|
||||
clearCurrent() { if (this.busy || this.receiptBatch) return; resetWarehouseDomain('returnReceipt'); this.current = null; this.candidates = null; this.error = null; this.activeTarget = 'lookup' },
|
||||
nextReceipt() { this.result = null; this.clearCurrent() },
|
||||
continueScanning() {
|
||||
if (this.busy || this.receiptBatch) return
|
||||
this.clearCurrent()
|
||||
this.result = null
|
||||
this.entryMode = 'SKU'; this.lookupCode = ''; this.selectedSku = ''
|
||||
this.palletCheck = null; this.newPalletHtml = ''
|
||||
this.palletLength = ''; this.palletWidth = ''; this.palletHeight = ''; this.palletWeight = ''
|
||||
this.unitCode = ''; this.boxRows = [{ items: {} }]
|
||||
this.photoFileIds = []; this.photoNames = []; this.photoUrls = {}
|
||||
},
|
||||
async nextReceipt() {
|
||||
if (this.busy) return
|
||||
const returnNo = this.result?.continuationReturnNo
|
||||
if (!returnNo) { this.result = null; this.clearCurrent(); return }
|
||||
await this.run(async () => {
|
||||
await this.openOrderValue(returnNo, 'SKU')
|
||||
this.palletLength = ''; this.palletWidth = ''; this.palletHeight = ''; this.palletWeight = ''
|
||||
this.unitCode = ''; this.boxRows = [{ items: {} }]; this.newPalletHtml = ''
|
||||
this.result = null
|
||||
}, '读取剩余待签收货物失败')
|
||||
},
|
||||
async run(work, fallback) { if (this.busy) return; this.busy = true; this.error = null; try { await work() } catch (error) { this.setError(error, fallback) } finally { this.busy = false } },
|
||||
setError(error, fallback) { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
|
||||
statusLabel(value) { return ({ PENDING_RECEIPT: '待签收', PARTIALLY_RECEIVED: '签收中', COMPLETED: '已完成' })[value] || value || '状态未知' }
|
||||
@@ -158,6 +177,9 @@
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '../warehouse/operations.scss';
|
||||
.dimension-grid label .dimension-label{display:flex;align-items:center;justify-content:space-between;gap:3px;margin-bottom:3px}.dimension-grid label .dimension-label>text{color:#65768b;font-size:10px;line-height:14px;font-weight:400;white-space:nowrap}.dimension-grid label .dimension-label>.volume-text{margin-left:auto;color:#135ee8;font-size:10px}
|
||||
.receipt-order-card{padding:10px 14px!important}.receipt-order-card .row-head{align-items:center;margin-bottom:5px}.receipt-order-card .card-title{margin:0;font-size:12px}.receipt-order-card .link-button{margin:0;padding:0;font-size:11px;line-height:24px}.receipt-order-line{display:flex;align-items:center;gap:8px}.receipt-order-line .business-code{flex:1;min-width:0;font-size:12px;line-height:18px;white-space:nowrap;overflow-x:auto;word-break:normal}.receipt-order-line .status-pill{flex-shrink:0;font-size:10px;padding:3px 6px}
|
||||
.receipt-sku-list .item-card{padding:10px 12px}.receipt-sku-list .row-head{align-items:flex-start;gap:8px}.receipt-sku-list .row-head>view{flex:1;min-width:0}.receipt-sku-list .business-code{font-size:12px;line-height:17px;word-break:break-all}.receipt-sku-list .meta-line{font-size:10px;line-height:15px;margin-top:3px}.receipt-sku-list .remaining-copy{flex-shrink:0;white-space:nowrap;font-size:10px}.sku-quantity-fields{display:flex;gap:14px;margin-top:8px}.sku-quantity-fields .quantity-row{flex:1;min-width:0;gap:6px;margin:0;justify-content:flex-start}.sku-quantity-fields .quantity-row>text{white-space:nowrap;font-size:10px}.sku-quantity-fields .quantity-row .number-input{box-sizing:border-box;width:64px;height:30px;min-height:30px;padding:0 5px;border-radius:6px;font-size:13px}
|
||||
.candidate-heading .card-title{margin-bottom:0}.candidate-card{width:100%;text-align:left}.select-link{color:#135ee8;font-size:12px;font-weight:800}.field-label{display:block;margin:13px 0 6px;color:#52667e;font-size:11px;font-weight:700}.remaining-copy{color:#135ee8;font-size:11px}.quantity-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:10px;color:#65768b;font-size:11px}.quantity-row .number-input{width:112px;text-align:center}.summary-bar{position:sticky;bottom:0;z-index:4;display:flex;align-items:center;gap:12px;margin-top:12px;padding:11px 13px;border:1px solid #d8e3f1;border-radius:13px;background:#fff;box-shadow:0 5px 20px rgba(22,55,96,.14)}.summary-bar>view{min-width:70px}.summary-label,.summary-value{display:block}.summary-label{color:#728197;font-size:10px}.summary-value{color:#135ee8;font-size:22px;font-weight:800}.submit-button{margin-top:0}.result-card{text-align:left}.result-carton{display:block;margin:9px 0;color:#135ee8;font-size:20px;font-weight:800;word-break:break-all}.link-button{width:auto}.work-card>.link-button{margin-top:8px;padding-left:0}.success-box .primary{margin-top:14px}.scan-hint{display:block;margin-top:9px;color:#65768b;font-size:11px;text-align:center}
|
||||
.photo-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:12px}.photo-tile{min-width:0;overflow:hidden;border:1px solid #d7e1ef;border-radius:8px}.photo-thumbnail{display:block;width:100%;height:130px;background:#f1f4f8}.photo-name{display:block;padding:6px 8px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:12px;color:#52647c}.photo-tile .link-button{margin:0 8px 6px}
|
||||
.unit-tabs{display:flex;gap:8px;margin:10px 0}.unit-tab{flex:1!important;min-width:0!important;height:34px!important;min-height:34px!important;margin:0!important;padding:0 10px!important;border:1px solid #d6e1ef!important;border-radius:8px!important;background:#f7f9fc!important;color:#53677f!important;font-size:13px!important;line-height:34px!important}.unit-tab.active{border-color:#135ee8!important;background:#eaf2ff!important;color:#135ee8!important;font-weight:700}
|
||||
|
||||
@@ -305,6 +305,7 @@ export default {
|
||||
.take-photo-button[disabled]{color:#9aa7b5;background:#e7ecf2;opacity:1}
|
||||
.print-card{box-sizing:border-box;margin-bottom:11px;padding:14px;border:1px solid #b9d3f4;border-radius:13px;background:#f8fbff}.print-row button{width:auto;margin:0;padding:0 10px;color:#135ee8;border:1px solid #9fc0ef;border-radius:8px;background:#fff}.print-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-top:1px solid #dce8f5;color:#728196;font-size:9px}.print-code{display:block;color:#17314f;font-size:13px;font-weight:850}
|
||||
.generate-button{width:100%;margin-top:12px;color:#fff;background:#135ee8}.generated-card{display:flex;align-items:center;justify-content:space-between;margin-top:12px;padding:10px;border-radius:9px;background:#edf5ff}.generated-card button,.bind-panel>button{width:auto;margin:0;color:#135ee8;background:#fff}.bind-panel{margin-top:10px}.bound-state{margin-top:10px;padding:9px;border-radius:8px;color:#08705f;font-weight:800;background:#dff5ed}
|
||||
.generated-card,.print-row{gap:10px}.generated-card>view,.print-row>view{flex:1;min-width:0;overflow-wrap:anywhere}.generated-card button,.print-row button{flex-shrink:0;white-space:nowrap;padding:0 10px}
|
||||
.operation-detail-heading{display:flex;align-items:flex-start;gap:10px;margin:0 2px 12px;padding:2px 0}.back-to-list{width:auto;height:30px;margin:0;padding:0 9px;border:1px solid #c8d8ec;border-radius:8px;color:#135ee8;font-size:10px;line-height:30px;background:#f8fbff}.operation-detail-heading>view{min-width:0}.operation-detail-title{display:block;color:#243951;font-size:15px;font-weight:850}.operation-detail-subtitle{display:block;margin-top:3px;color:#8794a4;font-size:9px;line-height:14px}
|
||||
.collapse-button{width:30px;height:28px;margin:0;padding:0;color:#135ee8;font-size:16px;line-height:28px;background:#edf5ff}
|
||||
.receipt-comparison{display:grid;grid-template-columns:repeat(3,1fr);gap:7px;margin-bottom:9px}.receipt-comparison view{padding:9px;border-radius:9px;text-align:center;background:#fff}.receipt-comparison strong,.receipt-comparison text{display:block}.receipt-comparison strong{color:#17314f;font-size:15px}.receipt-comparison text{margin-top:2px;color:#8390a1;font-size:8px}.receipt-comparison .difference{border:1px solid #e3a044;background:#fff4df}.receipt-comparison .difference strong,.receipt-comparison .difference text{color:#a35b0c;font-weight:800}
|
||||
|
||||
@@ -7,7 +7,9 @@ export function toDispatchError(error, fallback = '排车作业失败') {
|
||||
const originalMessage = String(error?.message || '').trim()
|
||||
let message = originalMessage || fallback
|
||||
|
||||
if (FORBIDDEN_CODES.has(code)) {
|
||||
if (error?.network) {
|
||||
message = '网络连接失败,请检查网络后重试,当前作业已保留'
|
||||
} else if (FORBIDDEN_CODES.has(code)) {
|
||||
message = '当前账号没有海外仓 PDA 作业权限,请联系管理员授权'
|
||||
} else if (CONFLICT_CODES.has(code) && originalMessage === VEHICLE_PLATE_REQUIRED) {
|
||||
message = '请先在平台维护车牌信息后再拣货'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -11,6 +11,16 @@ import {
|
||||
import { session } from '../src/stores/session.js'
|
||||
import { toDispatchError } from '../src/utils/dispatch-error.js'
|
||||
|
||||
test('dispatch network failures show recovery guidance and retain diagnostics', () => {
|
||||
const error = toDispatchError({ message: 'request:fail', network: true, code: 'NETWORK_ERROR', requestId: 'network-request', traceId: 'network-trace' })
|
||||
assert.equal(error.message, '网络连接失败,请检查网络后重试,当前作业已保留')
|
||||
assert.equal(error.network, true)
|
||||
assert.equal(error.code, 'NETWORK_ERROR')
|
||||
assert.equal(error.requestId, 'network-request')
|
||||
assert.equal(error.traceId, 'network-trace')
|
||||
assert.equal(toDispatchError({ message: '业务校验失败' }).message, '业务校验失败')
|
||||
})
|
||||
|
||||
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
|
||||
|
||||
test('dispatch merge retains return carton picking and review gates together', async () => {
|
||||
@@ -66,7 +76,7 @@ test('outbound partial pick submits the selected carton and distinguishes shared
|
||||
assert.deepEqual(outboundPickPayload({ outboundPalletItemId: '13' }, { inventoryPositionId: '23' }, 2).cartonMarks, [])
|
||||
const page = await read('../src/pages/outbound/outbound.vue')
|
||||
assert.match(page, /listOutboundTasks\('PENDING'\)/)
|
||||
assert.match(page, /:key="item.outboundPalletItemId"/)
|
||||
assert.match(page, /:key="item.displayKey \|\| item.outboundPalletItemId"/)
|
||||
assert.match(page, /outboundPickPayload\(this.selectedItem, this.selectedPosition, this.packages, this.selectedScanCode\)/)
|
||||
assert.match(page, /pickOutboundTask\(this.current.taskNo, \{ \.\.\.payload, idempotencyKey: key.value \}\)/)
|
||||
assert.match(page, /pickOutboundPallet\(this.current.taskNo, code, \{ idempotencyKey: key.value \}\)/)
|
||||
@@ -78,7 +88,7 @@ test('return outbound preserves its task type and only permits pallet scans for
|
||||
assert.equal(task.outboundType, 'RETURN_OUTBOUND')
|
||||
const page = await read('../src/pages/outbound/outbound.vue')
|
||||
assert.match(page, /this\.current\?\.outboundType === 'RETURN_OUTBOUND' && item\.palletNo/)
|
||||
assert.match(page, /整托出库请扫描托盘码/)
|
||||
assert.match(page, /整托出库请扫描托盘号/)
|
||||
const item = { outboundPalletItemId: '12', skuCode: 'SKU-A' }
|
||||
const position = { inventoryPositionId: '', cartonMark: 'RC-1' }
|
||||
assert.deepEqual(outboundPickPayload(item, position, 1, 'SKU-A'), {
|
||||
@@ -219,7 +229,7 @@ test('pages expose loading empty error offline and permission diagnostics with g
|
||||
assert.match(page, /正在加载/)
|
||||
assert.match(page, /暂无/)
|
||||
assert.match(page, /requestId[\s\S]*traceId/)
|
||||
assert.match(page, /error\.network[\s\S]*幂等键/)
|
||||
assert.match(page, /error\.network[\s\S]*保留/)
|
||||
assert.match(page, /fulfillmentIdempotencyKey/)
|
||||
assert.match(page, /releaseFulfillmentKey/)
|
||||
assert.match(page, /if \(!this\.can/)
|
||||
@@ -297,3 +307,42 @@ test('dispatch review gate preserves the server decision without treating a stri
|
||||
assert.match(source, /canPick\(\) \{ return this.current\?\.canPick === true/)
|
||||
assert.match(source, /canComplete\(\) \{ return this.current\?\.canPick === true/)
|
||||
})
|
||||
|
||||
async function dispatchScanHarness(items) {
|
||||
const source = (await read('../src/pages/dispatch/dispatch.vue')).match(/<script>([\s\S]*?)<\/script>/)[1]
|
||||
const draft = { palletCode: 'OLD', dispatchItemId: 'old', cartonCode: '' }; let sounds = 0
|
||||
const deps = { PortalHeader: {}, ScanTargetInput: {}, DispatchHandoverStatus: {},
|
||||
updateFulfillmentDraft: (_, value) => Object.assign(draft, value), playSuccessSound: () => { sounds++ },
|
||||
console: { info() {} } }
|
||||
const definition = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
|
||||
const state = { current: { dispatchNo: 'DS-TEST', items }, error: { message: 'previous error' }, successMessage: '',
|
||||
setError(error) { this.error = { message: error.message } } }
|
||||
state.selectPallet = definition.methods.selectPallet.bind(state)
|
||||
return { state, draft, scan: definition.methods.handleScan.bind(state), sounds: () => sounds }
|
||||
}
|
||||
|
||||
test('dispatch wrong scan clears prior selection and a valid pallet clears the old error', async () => {
|
||||
const h = await dispatchScanHarness([{ palletNo: 'PALLET-A', dispatchItemId: 'a', scheduledPackages: 1, pickedPackages: 0, positions: [{ availablePackages: 1 }] }])
|
||||
h.scan('UNKNOWN'); assert.equal(h.draft.dispatchItemId, ''); assert.ok(h.state.error)
|
||||
h.scan('PALLET-A'); assert.equal(h.state.error, null); assert.equal(h.draft.dispatchItemId, 'a'); assert.equal(h.sounds(), 1)
|
||||
})
|
||||
|
||||
test('dispatch cannot use a shared destination code to choose a physical pallet', async () => {
|
||||
const h = await dispatchScanHarness(['A', 'B'].map(x => ({ palletNo: `PALLET-${x}`, fbaWarehouseCode: 'ONT8', dispatchItemId: x, scheduledPackages: 1, pickedPackages: 0 })))
|
||||
h.scan('ONT8'); assert.equal(h.draft.dispatchItemId, ''); assert.ok(h.state.error); assert.equal(h.sounds(), 0)
|
||||
})
|
||||
|
||||
test('dispatch completed or ambiguous pallet scan never silently selects a row', async () => {
|
||||
const done = await dispatchScanHarness([{ palletNo: 'PALLET-A', dispatchItemId: 'a', scheduledPackages: 1, pickedPackages: 1 }])
|
||||
done.scan('PALLET-A'); assert.match(done.state.error.message, /已完成拣货/); assert.equal(done.draft.dispatchItemId, ''); assert.equal(done.sounds(), 0)
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,32 @@ function page(overrides = {}) {
|
||||
return { vm, calls }
|
||||
}
|
||||
|
||||
test('return pallet rows sum carton counts without changing raw pick lines', () => {
|
||||
const { vm } = page()
|
||||
const cartons = Array.from({ length: 5 }, (_, index) => ({ outboundPalletItemId: index + 1, palletNo: 'PALLET', plannedPackages: 1, pickedPackages: index < 3 ? 1 : 0, positions: [] }))
|
||||
const loose = { outboundPalletItemId: 6, palletNo: '', plannedPackages: 2, pickedPackages: 0, positions: [] }
|
||||
vm.current = { outboundType: 'RETURN_OUTBOUND', items: [...cartons, loose, { outboundPalletItemId: 7, palletNo: 'OTHER', plannedPackages: 2, pickedPackages: 1, positions: [] }] }
|
||||
const before = JSON.stringify(vm.current.items)
|
||||
assert.equal(vm.displayItems.length, 3)
|
||||
assert.equal(vm.displayItems[0].plannedPackages, 5)
|
||||
assert.equal(vm.displayItems[0].pickedPackages, 3)
|
||||
assert.equal(vm.displayItems[1], loose)
|
||||
assert.equal(vm.displayItems[2].palletNo, 'OTHER')
|
||||
assert.equal(JSON.stringify(vm.current.items), before)
|
||||
assert.match(vm.scanLabel, /扫描托盘号/)
|
||||
assert.match(vm.scanPlaceholder, /扫描托盘号/)
|
||||
cartons.forEach(item => { item.pickedPackages = 1 })
|
||||
assert.equal(vm.displayItems[0].pickedPackages, 5)
|
||||
})
|
||||
|
||||
test('other outbound types preserve individual selectable lines', () => {
|
||||
const { vm } = page()
|
||||
const items = [{ palletNo: 'PALLET', plannedPackages: 1, pickedPackages: 0, positions: [] }, { palletNo: 'PALLET', plannedPackages: 2, pickedPackages: 0, positions: [] }]
|
||||
vm.current = { outboundType: 'NORMAL', items }
|
||||
assert.equal(vm.displayItems, items)
|
||||
assert.equal(vm.hasReturnPallet, false)
|
||||
})
|
||||
|
||||
test('completed and cancelled tasks cannot submit pallet picking or task completion', async () => {
|
||||
for (const status of ['COMPLETED', 'CANCELLED']) {
|
||||
const { vm, calls } = page()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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><text class="source-code">\{\{ 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 = ''
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { listReturnCountTasks, scanReturnCountCarton, scanReturnCountTasks, getCurrentReturnCountTask, beginReturnCountTask, confirmReturnCount } from '../src/api/return-counting.js'
|
||||
import { session } from '../src/stores/session.js'
|
||||
|
||||
test('counting preserves box contents and retry key without accepting client warehouse scope', async () => {
|
||||
session.apiBase = '/api'; session.token = 'counting-test'
|
||||
const calls = []
|
||||
globalThis.uni = { request(options) { calls.push(options); options.success({ statusCode: 200, data: { code: '200', data: { status: 'PENDING' } }, header: {} }) } }
|
||||
await listReturnCountTasks({ page: 2, keyword: ' RC#2-1 ' })
|
||||
await scanReturnCountCarton('RC#2-1')
|
||||
const request = { idempotencyKey: 'count-retry', boxes: [{ cartonMark: 'RC#2-1', items: [{ skuCode: 'SKU-1', quantity: 3 }] }, { cartonMark: 'RC#2-2', items: [{ skuCode: 'SKU-1', quantity: 7 }] }] }
|
||||
await confirmReturnCount('RR/1', request)
|
||||
assert.deepEqual(calls.map(row => [row.method, row.url]), [
|
||||
['GET', '/api/pda/v1/last-leg/return-inventory/counting/tasks?page=2&size=10&keyword=RC%232-1'],
|
||||
['GET', '/api/pda/v1/last-leg/return-inventory/counting/cartons/RC%232-1'],
|
||||
['POST', '/api/pda/v1/last-leg/return-inventory/counting/receipts/RR%2F1']
|
||||
])
|
||||
assert.deepEqual(calls[2].data, request)
|
||||
assert.ok(calls.every(row => row.header.Authorization === 'Bearer counting-test'))
|
||||
})
|
||||
|
||||
test('unified scan supports box and pallet codes and preserves the no-count empty result', async () => {
|
||||
session.apiBase = '/api'; session.token = 'counting-test'
|
||||
const urls = []
|
||||
globalThis.uni = { request(options) { urls.push(options.url); options.success({ statusCode: 200, data: { code: '200', data: [] }, header: {} }) } }
|
||||
assert.deepEqual(await scanReturnCountTasks(' RC#2-1 '), [])
|
||||
assert.deepEqual(await scanReturnCountTasks('PLT/1'), [])
|
||||
assert.deepEqual(urls, ['/api/pda/v1/last-leg/return-inventory/counting/scan/RC%232-1', '/api/pda/v1/last-leg/return-inventory/counting/scan/PLT%2F1'])
|
||||
})
|
||||
|
||||
test('current task preserves null and begin uses the operator-scoped claim route', async () => {
|
||||
session.apiBase = '/api'; session.token = 'counting-test'
|
||||
const calls = []
|
||||
globalThis.uni = { request(options) { calls.push(options); options.success({ statusCode: 200, data: { code: '200', data: options.method === 'GET' ? null : { receiptNo: 'RR/1', status: 'COUNTING' } }, header: {} }) } }
|
||||
assert.equal(await getCurrentReturnCountTask(), null)
|
||||
assert.equal((await beginReturnCountTask('RR/1')).status, 'COUNTING')
|
||||
assert.deepEqual(calls.map(row => [row.method, row.url]), [
|
||||
['GET', '/api/pda/v1/last-leg/return-inventory/counting/current'],
|
||||
['POST', '/api/pda/v1/last-leg/return-inventory/counting/receipts/RR%2F1/begin']
|
||||
])
|
||||
assert.equal(calls[1].data, undefined)
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
|
||||
const source = readFileSync(new URL('../src/pages/return-counting/return-counting.vue', import.meta.url), 'utf8')
|
||||
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^import .+$/gm, '').replace('export default', 'page =')
|
||||
const scope = { PortalHeader: {}, ScanTargetInput: {} }
|
||||
runInNewContext(script, scope)
|
||||
|
||||
function state(a = '2', b = '2', c = '2') {
|
||||
const value = {
|
||||
scanCode: '', selectedMark: '',
|
||||
task: { status: 'COUNTING', expectedItems: [{ skuCode: 'A', quantity: '4' }, { skuCode: 'B', quantity: '2' }], boxes: [
|
||||
{ cartonMark: 'RC#2-1', items: [{ skuCode: 'A', quantity: a }] },
|
||||
{ cartonMark: 'RC#2-2', items: [{ skuCode: 'A', quantity: b }] },
|
||||
{ cartonMark: 'RC#1-1', items: [{ skuCode: 'B', quantity: c }] }
|
||||
] }
|
||||
}
|
||||
value.skuCodes = scope.page.computed.skuCodes.call(value)
|
||||
value.totals = scope.page.computed.totals.call(value)
|
||||
return value
|
||||
}
|
||||
|
||||
test('list entry can confirm without scanning when string wire quantities reconcile', () => {
|
||||
const value = state()
|
||||
assert.equal(value.totals[0].quantity, 4)
|
||||
assert.equal(value.totals[1].quantity, 2)
|
||||
assert.equal(value.totals.every(row => row.actual === row.quantity), true)
|
||||
assert.equal(scope.page.computed.ready.call(value), true)
|
||||
})
|
||||
|
||||
test('counting still rejects mismatched totals and invalid box quantities', () => {
|
||||
for (const value of [state('1', '2', '2'), state('0', '4', '2'), state('1.5', '2.5', '2')]) {
|
||||
assert.equal(scope.page.computed.ready.call(value), false)
|
||||
}
|
||||
const duplicate = state()
|
||||
duplicate.task.boxes[0].items = [{ skuCode: 'A', quantity: 1 }, { skuCode: 'A', quantity: 1 }]
|
||||
assert.equal(scope.page.computed.ready.call(duplicate), false)
|
||||
})
|
||||
|
||||
function scanState() {
|
||||
const value = { task: state().task, tasks: [], error: null, scanCode: '', scanResult: '', selectedMark: '', busy: false,
|
||||
open(row) { this.task = row }, $nextTick(callback) { callback() } }
|
||||
value.run = scope.page.methods.run.bind(value)
|
||||
value.enterTask = scope.page.methods.enterTask.bind(value)
|
||||
return value
|
||||
}
|
||||
|
||||
test('box and single-batch pallet scans claim and open counting directly without start dialog', async () => {
|
||||
for (const mark of ['RC#2-1', 'PLT-1']) {
|
||||
const value = scanState()
|
||||
scope.scanReturnCountTasks = async () => [{ receiptNo: 'RR-SCAN', returnNo: 'RT-SCAN' }]
|
||||
scope.getCurrentReturnCountTask = async () => null
|
||||
let claimed
|
||||
scope.beginReturnCountTask = async receiptNo => { claimed = receiptNo; assert.equal(value.busy, true); return { receiptNo, status: 'COUNTING' } }
|
||||
value.ask = async () => { assert.fail('matching scan should enter directly') }
|
||||
await scope.page.methods.scan.call(value, mark)
|
||||
assert.equal(claimed, 'RR-SCAN')
|
||||
assert.equal(value.task.status, 'COUNTING')
|
||||
assert.equal(value.selectedMark, mark)
|
||||
assert.equal(value.activeTask, value.task)
|
||||
}
|
||||
})
|
||||
|
||||
test('direct scan cannot claim a different task while the operator has unfinished work', async () => {
|
||||
const value = scanState()
|
||||
scope.scanReturnCountTasks = async () => [{ receiptNo: 'RR-NEW', returnNo: 'RT-NEW' }]
|
||||
scope.getCurrentReturnCountTask = async () => ({ receiptNo: 'RR-OLD', returnNo: 'RT-OLD' })
|
||||
scope.beginReturnCountTask = async () => { assert.fail('must not claim a second task') }
|
||||
let dialog
|
||||
value.ask = async options => { dialog = options; return { confirm: false } }
|
||||
await scope.page.methods.scan.call(value, 'PLT-NEW')
|
||||
assert.equal(dialog.title, '有未完成的清点任务')
|
||||
})
|
||||
|
||||
test('a pallet scan shows every pending batch and no pending results clear stale details', async () => {
|
||||
const value = scanState()
|
||||
const rows = [{ receiptNo: 'RR-1', boxes: [] }, { receiptNo: 'RR-2', boxes: [] }]
|
||||
scope.scanReturnCountTasks = async () => rows
|
||||
await scope.page.methods.scan.call(value, 'PLT-1')
|
||||
assert.equal(value.task, null)
|
||||
assert.equal(value.tasks.length, 2)
|
||||
assert.equal(value.scanResult, 'PLT-1')
|
||||
scope.scanReturnCountTasks = async () => []
|
||||
await scope.page.methods.scan.call(value, 'RC-DONE')
|
||||
assert.equal(value.task, null)
|
||||
assert.equal(value.tasks.length, 0)
|
||||
assert.equal(value.scanResult, 'RC-DONE')
|
||||
assert.equal(value.error, null)
|
||||
})
|
||||
|
||||
test('scanner failures remain visible rather than claiming no count is required', async () => {
|
||||
const value = scanState()
|
||||
scope.scanReturnCountTasks = async () => { throw Object.assign(new Error('服务暂不可用'), { code: 'B0500' }) }
|
||||
await scope.page.methods.scan.call(value, 'PLT-1')
|
||||
assert.equal(value.error.code, 'B0500')
|
||||
assert.equal(value.scanResult, '')
|
||||
})
|
||||
|
||||
test('declining start never claims a task and another active task blocks a new claim', async () => {
|
||||
const value = scanState(); value.activeTask = null
|
||||
let begins = 0
|
||||
scope.getCurrentReturnCountTask = async () => null
|
||||
scope.beginReturnCountTask = async () => { begins++; return { status: 'COUNTING' } }
|
||||
value.ask = async () => ({ confirm: false })
|
||||
await scope.page.methods.choose.call(value, { receiptNo: 'RR-NEW', returnNo: 'RT-NEW' })
|
||||
assert.equal(begins, 0)
|
||||
scope.getCurrentReturnCountTask = async () => ({ receiptNo: 'RR-OLD', returnNo: 'RT-OLD' })
|
||||
let dialog
|
||||
value.ask = async options => { dialog = options; return { confirm: false } }
|
||||
await scope.page.methods.choose.call(value, { receiptNo: 'RR-NEW', returnNo: 'RT-NEW' })
|
||||
assert.equal(dialog.title, '有未完成的清点任务')
|
||||
assert.equal(begins, 0)
|
||||
})
|
||||
|
||||
test('affirming start claims the selected task and switches it to working state', async () => {
|
||||
const value = scanState(); value.scanResult = 'RC-1'
|
||||
scope.getCurrentReturnCountTask = async () => null
|
||||
let claimed
|
||||
scope.beginReturnCountTask = async receiptNo => { claimed = receiptNo; return { receiptNo, status: 'COUNTING' } }
|
||||
value.ask = async () => ({ confirm: true })
|
||||
await scope.page.methods.choose.call(value, { receiptNo: 'RR-NEW', returnNo: 'RT-NEW' })
|
||||
assert.equal(claimed, 'RR-NEW')
|
||||
assert.equal(value.task.status, 'COUNTING')
|
||||
const unclaimed = state(); unclaimed.task.status = 'PENDING'
|
||||
assert.equal(scope.page.computed.ready.call(unclaimed), false)
|
||||
})
|
||||
|
||||
test('completed counting returns to page one and refreshes remaining tasks while busy', async () => {
|
||||
const value = scanState(); value.ready = true; value.draftKey = 'count-draft'; value.page = 3; value.scanResult = 'PLT-1'
|
||||
value.resetList = scope.page.methods.resetList.bind(value)
|
||||
value.fetchTasks = scope.page.methods.fetchTasks.bind(value)
|
||||
let removed = false, refreshed = false
|
||||
scope.uni = { setStorageSync() {}, removeStorageSync() { removed = true }, showToast() {} }
|
||||
scope.confirmReturnCount = async () => ({ status: 'CONFIRMED' })
|
||||
scope.getCurrentReturnCountTask = async () => null
|
||||
scope.listReturnCountTasks = async ({ page }) => { assert.equal(page, 1); assert.equal(value.busy, true); refreshed = true; return { records: [{ receiptNo: 'RR-NEXT' }], total: '1' } }
|
||||
await scope.page.methods.confirm.call(value)
|
||||
assert.equal(value.task, null)
|
||||
assert.equal(value.scanResult, '')
|
||||
assert.equal(value.activeTask, null)
|
||||
assert.equal(value.tasks[0].receiptNo, 'RR-NEXT')
|
||||
assert.equal(removed && refreshed, true)
|
||||
})
|
||||
|
||||
test('failed confirmation retains the current task and draft for retry', async () => {
|
||||
const value = scanState(); value.ready = true; value.draftKey = 'count-draft'
|
||||
const original = value.task
|
||||
let removed = false
|
||||
scope.uni = { setStorageSync() {}, removeStorageSync() { removed = true } }
|
||||
scope.confirmReturnCount = async () => { throw new Error('数量汇总不一致') }
|
||||
await scope.page.methods.confirm.call(value)
|
||||
assert.equal(value.task, original)
|
||||
assert.equal(removed, false)
|
||||
assert.equal(value.error.message, '数量汇总不一致')
|
||||
})
|
||||
@@ -23,6 +23,22 @@ function setup(confirm, saved = new Map(), scope = 'operator:warehouse') {
|
||||
return instance
|
||||
}
|
||||
|
||||
test('SKU lookup displays all pending order lines while retaining scanned SKU for pallet generation', () => {
|
||||
const source = readFileSync(new URL('../src/pages/return-receipt/return-receipt.vue', import.meta.url), 'utf8')
|
||||
const script = source.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1]
|
||||
const component = vm.runInNewContext(script.replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), { PortalHeader: {}, ScanTargetInput: {} })
|
||||
const lines = [{ skuCode: 'SKU-A', expectedQuantity: 6 }, { skuCode: 'SKU-B', expectedQuantity: 4 }]
|
||||
const instance = { current: { expectedItems: lines }, selectedSku: 'SKU-A' }
|
||||
instance.selectedItems = component.computed.selectedItems.call(instance)
|
||||
assert.equal(instance.selectedItems.length, 2)
|
||||
assert.equal(instance.selectedItems[1].skuCode, 'SKU-B')
|
||||
assert.equal(component.computed.activeSkuCode.call(instance), 'SKU-A')
|
||||
instance.selectedSku = ''
|
||||
assert.equal(component.computed.activeSkuCode.call(instance), 'SKU-A')
|
||||
instance.selectedSku = 'SKU-FROM-PREVIOUS-ORDER'
|
||||
assert.equal(component.computed.activeSkuCode.call(instance), 'SKU-A')
|
||||
})
|
||||
|
||||
test('PALLET page submits the required palletCode through the actual confirmation method', async () => {
|
||||
const calls = []
|
||||
const page = setup(async payload => { calls.push(JSON.parse(JSON.stringify(payload))); return { palletCode: payload.palletCode } })
|
||||
@@ -41,6 +57,46 @@ 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(/<script[^>]*>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), { PortalHeader: {}, ScanTargetInput: {} })
|
||||
const state = { current: {}, selectedItems: [{ skuCode: 'A' }], receiptQuantity: 2, unitType: 'PARCEL', unitCode: '', quantity: () => 2, remaining: () => 3 }
|
||||
assert.equal(component.computed.canConfirm.call(state), true)
|
||||
state.quantity = () => 4
|
||||
assert.equal(component.computed.canConfirm.call(state), false)
|
||||
const calls = []
|
||||
const page = setup(async payload => { calls.push(payload); return { unitType: 'PARCEL' } })
|
||||
page.unitType = 'PARCEL'; page.unitCode = ''; page.trackingNo = ''
|
||||
await page.confirmReceipt()
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0].trackingNo, undefined)
|
||||
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)
|
||||
})
|
||||
|
||||
test('partial receipt continues the same order with fresh pallet measurements', async () => {
|
||||
const page = setup(async () => ({}))
|
||||
page.finishReceipt([{ fullyMatched: false }])
|
||||
assert.equal(page.result.continuationReturnNo, 'RT-TEST')
|
||||
let reopened
|
||||
page.openOrderValue = async returnNo => { reopened = returnNo; page.current = { returnNo } }
|
||||
await page.nextReceipt()
|
||||
assert.equal(reopened, 'RT-TEST')
|
||||
assert.equal(page.result, null)
|
||||
assert.equal(page.palletLength, '')
|
||||
assert.equal(page.palletWeight, '')
|
||||
})
|
||||
|
||||
test('PALLET response failure keeps the draft and same idempotency key for retry', async () => {
|
||||
const calls = []
|
||||
const page = setup(async payload => { calls.push(JSON.parse(JSON.stringify(payload))); throw new Error('Response lost') })
|
||||
@@ -52,3 +108,32 @@ test('PALLET response failure keeps the draft and same idempotency key for retry
|
||||
assert.equal(calls.length, 2)
|
||||
assert.equal(calls[0].idempotencyKey, calls[1].idempotencyKey)
|
||||
})
|
||||
|
||||
test('continue signing returns to SKU scanning and accepts a fresh scan', () => {
|
||||
const page = setup(async () => ({}))
|
||||
page.finishReceipt([{ fullyMatched: false }])
|
||||
page.lookupCode = 'PREVIOUS-SKU'
|
||||
page.selectedSku = 'PREVIOUS-SKU'
|
||||
page.palletCheck = { palletCode: 'PLT-TEST' }
|
||||
page.photoFileIds = ['previous-photo']
|
||||
page.busy = true
|
||||
page.continueScanning()
|
||||
assert.ok(page.result)
|
||||
page.busy = false
|
||||
page.continueScanning()
|
||||
assert.equal(page.result, null)
|
||||
assert.equal(page.current, null)
|
||||
assert.equal(page.candidates, null)
|
||||
assert.equal(page.activeTarget, 'lookup')
|
||||
assert.equal(page.entryMode, 'SKU')
|
||||
assert.equal(page.lookupCode, '')
|
||||
assert.equal(page.selectedSku, '')
|
||||
assert.equal(page.palletCheck, null)
|
||||
assert.equal(page.palletLength, '')
|
||||
assert.equal(page.photoFileIds.length, 0)
|
||||
let scanned
|
||||
page.lookup = value => { scanned = value }
|
||||
page.handleScan('NEXT-SKU')
|
||||
assert.equal(scanned, 'NEXT-SKU')
|
||||
assert.equal(page.lookupCode, 'NEXT-SKU')
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
|
||||
{ module: 'inbound', name: '仓储转运测量列表' }, { module: 'inbound', name: '转运测量作业' },
|
||||
{ module: 'inbound', name: '退货入库' },
|
||||
{ module: 'inbound', name: '退货合箱' },
|
||||
{ module: 'inbound', name: '退货箱内清点' },
|
||||
{ module: 'outbound', name: '排车拣货' },
|
||||
{ module: 'outbound', name: '出库作业' },
|
||||
{ module: 'inventory', name: '商品移库' },
|
||||
@@ -26,6 +27,7 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
|
||||
assert.equal(operations.find((item) => item.name === '出库作业')?.route, '/pages/outbound/outbound')
|
||||
assert.equal(operations.find((item) => item.name === '退货入库')?.route, '/pages/return-receipt/return-receipt')
|
||||
assert.equal(operations.find((item) => item.name === '退货合箱')?.route, '/pages/return-packing/return-packing')
|
||||
assert.equal(operations.find((item) => item.name === '退货箱内清点')?.route, '/pages/return-counting/return-counting')
|
||||
assert.equal(operations.find((item) => item.name === '仓储转运测量列表')?.route, '/pages/warehouse-transfer/warehouse-transfer?mode=list')
|
||||
assert.equal(operations.find((item) => item.name === '转运测量作业')?.route, '/pages/warehouse-transfer/warehouse-transfer?mode=work')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user