merge release test changes and preserve local frontend updates

This commit is contained in:
2026-09-20 15:24:04 +08:00
parent ca05994986
commit c24f8739e8
10 changed files with 288 additions and 87 deletions
+2
View File
@@ -3,6 +3,8 @@ import { overseasRequest, overseasUpload, overseasDownload } from './overseas.js
const BASE = '/pda/v1/last-leg/relabel-tasks'
const encoded = (value) => encodeURIComponent(String(value || '').trim())
export const scanRelabelSku = async (no, payload) => normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(no)}/skus/scan`, payload))
export const completeReturnRelabelSku = async (no, payload) => normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(no)}/return-skus/complete`, payload))
export const completeReturnRelabelSkuTotal = async (no, payload) => normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(no)}/return-skus/complete-total`, payload))
function asList(value) { return Array.isArray(value) ? value : [] }
+18 -3
View File
@@ -1,4 +1,4 @@
import { overseasRequest } from './overseas.js'
import { overseasDownload, overseasRequest, overseasUpload } from './overseas.js'
const BASE = '/pda/v1/last-leg/return-receipts'
@@ -25,7 +25,9 @@ export function normalizeReturnReceiptStart(value = {}) {
return {
returnNo: String(value.returnNo || ''),
status: String(value.status || ''),
expectedItems: Array.isArray(value.expectedItems) ? value.expectedItems.map(normalizeItem) : []
expectedItems: Array.isArray(value.expectedItems) ? value.expectedItems.map(normalizeItem) : [],
completedPallets: Array.isArray(value.completedPallets) ? value.completedPallets.map((item) => ({ palletCode: String(item.palletCode || ''), skuCode: String(item.skuCode || ''), quantity: count(item.quantity), receiptBatchNo: String(item.receiptBatchNo || '') })) : []
,photos: Array.isArray(value.photos) ? value.photos.map((item) => ({ fileId: String(item.fileId || ''), fileName: String(item.fileName || '退货打托照片'), contentType: String(item.contentType || 'image/jpeg'), sizeBytes: count(item.sizeBytes) })) : []
}
}
@@ -49,7 +51,7 @@ export function normalizeReturnReceiptResult(value = {}) {
returnNo: String(value.returnNo || ''),
receiptNo: String(value.receiptNo || ''),
receiptBatchNo: String(value.receiptBatchNo || ''),
cartonMark: String(value.cartonMark || ''),
palletCode: String(value.palletCode || ''),
orderStatus: String(value.orderStatus || ''),
fullyMatched: Boolean(value.fullyMatched),
expectedQuantity: count(value.expectedQuantity),
@@ -80,3 +82,16 @@ export async function findReturnSkuCandidates(skuCode) {
export async function confirmReturnReceipt(request) {
return normalizeReturnReceiptResult(await overseasRequest('POST', `${BASE}/confirm`, request))
}
export async function createReturnPallet(returnNo, skuCode) {
const value = await overseasRequest('POST', `${BASE}/pallets`, { returnNo: String(returnNo || '').trim(), skuCode: String(skuCode || '').trim() })
return { palletCode: String(value?.palletCode || value?.code || ''), printableHtml: String(value?.printableHtml || '') }
}
export async function checkReturnPallet(returnNo, palletCode) {
const value = await overseasRequest('POST', `${BASE}/pallets/check`, { returnNo: String(returnNo || '').trim(), palletCode: String(palletCode || '').trim() })
return { palletCode: String(value?.palletCode || ''), empty: Boolean(value?.empty), skuCodes: Array.isArray(value?.skuCodes) ? value.skuCodes.map(String) : [], quantity: count(value?.quantity) }
}
export const manuallyCompleteReturnReceipt = (returnNo, discrepancyReason) => overseasRequest('POST', `${BASE}/${encodeURIComponent(String(returnNo || '').trim())}/manual-complete`, { discrepancyReason: String(discrepancyReason || '').trim() })
export const uploadReturnPalletPhoto = (returnNo, filePath) => overseasUpload(`${BASE}/${encodeURIComponent(String(returnNo || '').trim())}/photos/files`, filePath)
export const downloadReturnPalletPhoto = (returnNo, fileId) => overseasDownload(`${BASE}/${encodeURIComponent(String(returnNo || '').trim())}/photos/files/${encodeURIComponent(String(fileId || '').trim())}/download`, { expectedContentTypePrefix: 'image/' })
+70 -29
View File
@@ -1,35 +1,35 @@
<template>
<view class="pallet-work">
<text class="heading">换标后组托</text>
<view class="pallet-heading">
<text class="heading">托盘</text>
<button class="refresh-button" :disabled="busy" @tap="loadSources">刷新</button>
</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>
</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>
<text class="hint">按原入仓单拆托,再合入同新目的仓的托盘。下方为入仓单在各托盘的箱数,并非历史箱唛定位;合托后仍归属原入仓单。</text>
<button :disabled="busy" @tap="loadSources">刷新来源托盘</button>
<view v-for="row in sourceRows" :key="row.positionId" class="source-row" :class="{ selected: selectedSource?.positionId === row.positionId }" @tap="selectSource(row)">
<text>{{ row.palletCode }} · {{ row.inboundCode }}</text>
<text class="hint">{{ row.locationCode || '未维护库位' }} · 在库 {{ row.stockPackages }} 箱 · 可拆 {{ row.movablePackages }} 箱</text>
<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>
<text v-if="!busy && !sourceRows.length" class="hint">当前没有可操作的来源托盘</text>
<ScanTargetInput v-model="sourceCode" name="relabel-source" label="实际来源托盘" placeholder="扫描实际来源托盘" :active="active === 'source'" @activate="activate('source')" @submit="resolve('source')" />
<button :disabled="busy || !sourceCode.trim()" @tap="resolve('source')">确认来源托盘</button>
<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>
<text v-if="source" class="hint">已确认:{{ source.palletCode }}</text>
<view v-if="selectedSource">
<text class="hint">本次拆出箱数(可分多个来源托盘,逐批提交)</text>
<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>
<text class="hint">本次{{ isReturnBulk && wholeSource ? '整托换标数量' : `拆出${isReturnBulk ? '件数' : '箱数'}` }}(可分多个来源托盘,逐批提交)</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>
<template v-else><input v-model="quantity" type="number" :disabled="busy" :placeholder="`请输入拆箱数量(应为 ${plannedPackages} 件)`" /><text class="hint">贴标数量少于本托盘库存,请按现场实际选择来源托盘并填写拆箱数量;本托盘应拆 {{ plannedPackages }} 件。</text></template>
<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>
</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 class="hint">确认后直接提交本批拆托合托,只有提示“绑定成功”才表示已保存。</text>
<text v-if="target" class="hint">已识别目标:{{ target.palletCode }},尚未绑定成功</text>
<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>
@@ -60,11 +60,25 @@ 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.completedItems().map(item => item.destinationFbaWarehouseCode).filter(Boolean))] },
eligibleItems() { return this.completedItems().filter(item => item.destinationFbaWarehouseCode === this.selectedDestination) },
readyToBind() { return this.source && this.target && this.selectedSource && this.source.palletCode === this.sourceCode.trim().toUpperCase() && this.target.palletCode === this.targetCode.trim().toUpperCase() && this.source.palletCode === this.selectedSource.palletCode && this.source.palletCode !== this.target.palletCode && Number.isSafeInteger(Number(this.quantity)) && Number(this.quantity) > 0 && Number(this.quantity) <= this.selectedSource.movablePackages && this.marks.length === Number(this.quantity) }
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' },
operableSourceRows() { return this.sourceRows.filter(row => Number(row.movablePackages) > 0) },
plannedPackages() { return this.eligibleItems.reduce((sum, item) => sum + Number(item.expectedPackages || 0), 0) },
wholeSource() { return this.isReturnBulk && this.selectedSource && this.plannedPackages === Number(this.selectedSource.stockPackages || 0) },
readyToBind() { return this.source && this.target && this.selectedSource && this.samePallet(this.source, this.selectedSource) && !this.samePallet(this.source, this.target) && Number.isSafeInteger(Number(this.quantity)) && Number(this.quantity) > 0 && Number(this.quantity) <= this.selectedSource.movablePackages && this.bindingSelectionValid() }
},
methods: {
samePallet(left, right) {
if (!left || !right) return false
const code = value => String(value?.palletCode || '').trim().toUpperCase()
const leftCode = code(left); const rightCode = code(right)
const sameCode = !!leftCode && leftCode === rightCode
if (sameCode) return true
if (left.palletId == null || right.palletId == null) return false
if (String(left.palletId) === String(right.palletId)) return true
return false
},
selectRebind(binding) {
if (this.busy) return
uni.showModal({ title: '重新绑定托盘', content: `箱唛 ${binding.mark} 当前在 ${binding.pallet}。请扫描新的目标托盘。`, showCancel: false })
@@ -94,10 +108,33 @@ export default {
})
if (this.error) this.showBindingMessage('改绑失败', this.error)
},
completedItems() { return (this.task.items || []).filter(item => this.selectedSource?.cartonMarks.includes(item.cartonMark) && Number(item.relabelledPackages) >= 1) },
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) },
async loadSources() { await this.run(async () => { this.sourceRows = await findRelabelSources(this.task.relabelNo); this.selectedSource = null; this.source = null; this.marks = []; this.quantity = '' }) },
selectSource(row) { if (this.busy) return; this.rebindSelection = null; this.selectedSource = row; this.source = null; this.sourceCode = ''; this.quantity = ''; this.marks = []; this.selectedDestination = this.completedItems()[0]?.destinationFbaWarehouseCode || ''; this.activate('source') },
fillBatch() { const count = Number(this.quantity); if (!Number.isSafeInteger(count) || count <= 0 || count > this.selectedSource.movablePackages || count > this.eligibleItems.length) { this.error = '拆出数量必须为正整数,且不超过来源托盘可拆箱数和已完成换标箱数'; return } this.marks = this.eligibleItems.slice(0, count).map(item => item.cartonMark); this.error = '' },
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 || ''
if (this.isReturnBulk) {
const items = this.eligibleItems
this.marks = items.map(item => item.cartonMark)
if (items.reduce((sum, item) => sum + Number(item.expectedPackages || 0), 0) === Number(row.stockPackages || 0)) this.quantity = String(row.stockPackages)
await this.run(async () => { const value = await resolvePallet(row.palletCode); if (this.selectedSource?.positionId === row.positionId) { this.source = value; this.sourceCode = value.palletCode } })
this.activate(this.quantity ? 'target' : 'source')
return
}
this.activate('source')
},
bindingSelectionValid() {
const count = Number(this.quantity)
if (this.isReturnBulk) {
const packages = this.eligibleItems.reduce((sum, item) => sum + Number(item.expectedPackages || 0), 0)
return count === packages && this.completedItems().length === this.eligibleItems.length && this.marks.length === this.eligibleItems.length
&& this.marks.every(mark => this.eligibleItems.some(item => item.cartonMark === mark))
}
return this.marks.length === count && this.marks.every(mark => this.eligibleItems.some(item => item.cartonMark === mark))
},
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) },
@@ -108,9 +145,10 @@ export default {
this.message = ''
let reason = ''
if (!this.selectedSource) reason = '请先选择实际拆出货物的来源托盘。'
else if (!this.source || this.source.palletCode !== this.sourceCode.trim().toUpperCase() || this.source.palletCode !== this.selectedSource.palletCode) reason = '请先扫描并确认实际来源托盘;选择来源列表不等于扫描确认。'
else if (!Number.isSafeInteger(Number(this.quantity)) || Number(this.quantity) <= 0 || Number(this.quantity) > this.selectedSource.movablePackages) reason = '请填写有效的拆出箱数,不能超过来源托盘可拆数量。'
else if (!this.selectedDestination || this.marks.length !== Number(this.quantity) || this.marks.some(mark => !this.eligibleItems.some(item => item.cartonMark === mark))) 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.targetCode.trim()) reason = '请扫描或输入目标托盘号。'
if (reason) { this.error = reason; this.showBindingMessage('尚未绑定', reason); return }
await this.resolve('target')
@@ -120,8 +158,8 @@ export default {
},
destination(mark) { return this.task.items.find(item => item.cartonMark === mark)?.destinationFbaWarehouseCode || '目的仓待维护' },
async run(work) { if (this.busy) return; this.busy = true; this.$emit('busy', true); this.error = ''; try { await work() } catch (error) { this.error = error?.message || '托盘作业失败,请重新核对'; if (['409', 'A0409'].includes(String(error?.code))) { this.source = null; this.target = null; this.selectedSource = null; this.marks = []; this.error += ';请刷新来源托盘并重新扫码'; } } finally { this.busy = false; this.$emit('busy', false) } },
async resolve(kind) { const code = this[kind + 'Code'].trim(); if (!code) return; this[kind] = null; await this.run(async () => { const value = await resolvePallet(code); if (kind === 'source' && value.palletCode !== this.selectedSource?.palletCode) throw new Error('请扫描所选原入仓单的实际来源托盘'); if (this[kind + 'Code'].trim() === code) this[kind] = value }) },
async createPallet() { const payload = { relabelNo: this.task.relabelNo }; const key = warehouseIdempotencyKey('relabel', 'new-pallet', payload); await this.run(async () => { this.newPallet = await createRelabelPallet({ ...payload, idempotencyKey: key.value }); this.target = null; this.targetCode = ''; this.activate('target') }) },
async resolve(kind) { const code = this[kind + 'Code'].trim(); if (!code) return; this[kind] = null; await this.run(async () => { const value = await resolvePallet(code); if (kind === 'source' && !this.samePallet(value, this.selectedSource)) throw new Error('请扫描所选原入仓单的实际来源托盘'); if (this[kind + 'Code'].trim() === code) this[kind] = value }) },
async createPallet() { const payload = { relabelNo: this.task.relabelNo }; const key = warehouseIdempotencyKey('relabel', 'new-pallet', payload); await this.run(async () => { this.newPallet = await createRelabelPallet({ ...payload, idempotencyKey: key.value }); this.target = null; this.targetCode = this.newPallet.palletCode || ''; this.activate('target') }) },
async bind() {
if (!this.readyToBind) { this.error = '请核对来源托盘、拆出箱数、本批箱唛和目标托盘'; return }
const expectation = value => ({ palletCode: value.palletCode, expectedVersion: value.palletVersion })
@@ -145,11 +183,14 @@ export default {
</script>
<style scoped>
.pallet-work{margin-top:18px;padding:14px;border:1px solid #d7e1ef;border-radius:10px}
.pallet-heading{display:flex;align-items:center;justify-content:space-between;gap:12px}
.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.selected{border-color:#2674d9;background:#eff6ff}
.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}
.return-pallet-summary{display:block;margin:10px 0;padding:12px;border-radius:8px;background:#eff6ff;color:#1f5fca;font-weight:700;overflow-wrap:anywhere}
</style>
+59 -13
View File
@@ -22,9 +22,16 @@
<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 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">{{ taskTab === 'sku' ? 'SKU 换标明细' : '箱唛换标明细' }}</text>
<view v-if="taskTab === 'carton'" 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="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>
<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 }">
<view class="row-head"><view><text class="business-code">{{ group.skuCode }}</text><text class="meta-line">FBA 目的仓:{{ group.destinationFbaWarehouseCode || '—' }}</text></view><text class="status-pill">剩余 {{ group.remaining }} 件</text></view>
<view class="metric-row"><view class="metric"><strong>{{ group.expectedPackages }}</strong><text>贴标预报</text></view><view class="metric"><strong>{{ group.relabelledPackages }}</strong><text>已贴</text></view></view>
<view class="source-allocation"><text class="source-title">来源托盘拆箱数量</text><view v-for="source in group.sources" :key="source.itemId" class="source-row"><text>{{ source.palletCode || '未记录托盘' }}</text><strong>拆箱 {{ source.expectedPackages }} 件</strong></view><view class="source-total"><text>合计拆箱</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 === '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>
@@ -43,7 +50,9 @@
<button class="primary" :disabled="busy || !canSubmitUnit" @tap="submitUnit">{{ busyAction === 'unit' ? '提交中…' : unitActionText }}</button>
</template>
<view class="section-gap">
<RelabelPalletWork v-if="taskTab === 'carton'" ref="palletWork" :key="current.relabelNo" :task="current" @activate="activeTarget = 'pallet-work'" @busy="busy = $event" @saved="reloadCurrent" />
<!-- Return SKU relabel still requires moving the received carton from the
source pallet to a new pallet carrying the FBA destination. -->
<RelabelPalletWork v-if="taskTab === 'carton' || isReturnBulk" ref="palletWork" :key="current.relabelNo" :task="current" @activate="activeTarget = 'pallet-work'" @busy="busy = $event" @saved="reloadCurrent" />
<view class="row-head"><text class="card-title">完成照片</text><text class="status-pill">{{ photoFileIds.length }} / 20 张</text></view>
<button class="secondary" :disabled="busy || photoFileIds.length >= 20" @tap="choosePhoto">拍照或从相册选择</button>
<view class="photo-grid"><view v-for="(fileId,index) in photoFileIds" :key="fileId" class="photo-tile">
@@ -75,15 +84,32 @@
import { session, requireSession } from '@/stores/session.js'
import { releaseWarehouseKey, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { scanRelabelSku, scanRelabelCarton, listRelabelDocuments, downloadRelabelDocument, downloadRelabelPhoto } from '@/api/relabel.js'
import { scanRelabelSku, scanRelabelCarton, completeReturnRelabelSkuTotal, listRelabelDocuments, downloadRelabelDocument, downloadRelabelPhoto } from '@/api/relabel.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
const STAGES = { relabelReceive: 'receive', relabelExecute: 'execute', relabelReview: 'review' }
const isConflict = (error) => ['409', 'A0409', 'B0409'].includes(String(error?.code || ''))
export default {
components: { PortalHeader, ScanTargetInput, RelabelPalletWork },
data() { return { user: session.currentUser, stage: 'receive', unitDrafts: {}, taskTab: 'sku', candidates: [], current: null, selectedItem: null, 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: '其他' }] } },
data() { return { user: session.currentUser, stage: 'receive', 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: {
isReturnBulk() { return this.current?.operationType === 'RETURN_BULK_SKU_LABEL' },
returnBulkSkuGroups() {
const groups = new Map()
for (const item of (this.current?.items || []).filter(row => row.operationType === 'RETURN_BULK_SKU_LABEL')) {
const skuCode = String(item.skuCode || '').trim()
const destination = String(item.destinationFbaWarehouseCode || '').trim()
const key = `${skuCode.toUpperCase()}::${destination.toUpperCase()}`
if (!groups.has(key)) groups.set(key, { key, skuCode, destinationFbaWarehouseCode: destination, expectedPackages: 0, relabelledPackages: 0, skippedPackages: 0, exceptionPackages: 0, sources: [] })
const group = groups.get(key)
group.expectedPackages += Number(item.expectedPackages || 0)
group.relabelledPackages += Number(item.relabelledPackages || 0)
group.skippedPackages += Number(item.skippedPackages || 0)
group.exceptionPackages += Number(item.exceptionPackages || 0)
group.sources.push({ itemId: item.itemId, palletCode: item.cartonMark, expectedPackages: Number(item.expectedPackages || 0) })
}
return [...groups.values()].map(group => ({ ...group, remaining: Math.max(0, group.expectedPackages - group.relabelledPackages - group.skippedPackages - group.exceptionPackages) }))
},
showCurrent() { if (!this.current || this.stage === 'review') return false; const owned = this.candidates.some((item) => item.relabelNo === this.current.relabelNo && item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator === true); if (this.stage === 'execute') return this.current.taskStatus === 'PROCESSING' && owned; return !this.current.taskStatus || this.current.taskStatus === 'AVAILABLE' || (this.current.taskStatus === 'PROCESSING' && owned) },
stageSubtitle() { return this.stage === 'review' ? '仅展示当前仓库已完成现场换标并等待复核的单据' : this.stage === 'execute' ? '仅展示当前账号已认领且正在处理的换标任务' : '点击任意换标单直接进入执行;同一账号必须先完成当前任务才能处理其他换标单' },
availableTabs() { const items = this.current?.items || []; return [{ key: 'sku', name: 'SKU 换标', types: ['SKU_LABEL', 'CARTON_AND_SKU'] }, { key: 'carton', name: '箱唛换标', types: ['CARTON_LABEL', 'CARTON_AND_SKU'] }].filter((tab) => items.some((item) => tab.types.includes(item.operationType))) },
@@ -108,13 +134,16 @@
completionBlockReason() {
if (!this.current) return '请先选择换标任务'
if (!this.allProcessed && !this.remark.trim()) return '仍有未扫描明细,请继续处理;需要异常完工时必须填写异常作业备注'
const requiresPalletBinding = (this.current.items || []).some((item) => ['CARTON_LABEL', 'CARTON_AND_SKU'].includes(item.operationType))
const requiresPalletBinding = (this.current.items || []).some((item) => ['CARTON_LABEL', 'CARTON_AND_SKU', 'RETURN_BULK_SKU_LABEL'].includes(item.operationType))
if (requiresPalletBinding && Number(this.current.relabelledPackages || 0) > 0) {
const round = this.current.reviewedAt || 'INITIAL'
const marks = new Set((this.current.history || []).filter((entry) => entry.action === 'PALLET_BIND').flatMap((entry) => {
try { const detail = JSON.parse(entry.detail); return detail.round === round ? detail.cartonMarks || [] : [] } catch { return [] }
}))
if (marks.size < Number(this.current.relabelledPackages)) return '请先在“换标后组托”中完成本轮已换标箱的拆托合托绑定'
const requiredMarks = new Set((this.current.items || []).filter((item) =>
['CARTON_LABEL', 'CARTON_AND_SKU', 'RETURN_BULK_SKU_LABEL'].includes(item.operationType)
&& Number(item.relabelledPackages || 0) > 0).map((item) => item.cartonMark).filter(Boolean))
if ([...requiredMarks].some((mark) => !marks.has(mark))) return '请先在“换标后组托”中完成本轮已换标箱的拆托合托绑定'
}
if (!this.photoFileIds.length) return '请至少上传 1 张换标完成照片'
return ''
@@ -156,7 +185,24 @@
await new Promise(resolve => uni.showModal({ title: '请粘贴对应 SKU 标签', content: `${row.item.cartonMark}\n${this.lastSku}`, showCancel: false, confirmText: '知道了', complete: resolve }))
}, true, key)
},
relabelTypeName(type) { return ({ CARTON_LABEL: '只换箱唛', SKU_LABEL: '只换 SKU', CARTON_AND_SKU: '混合换标(SKU + 箱唛)' })[type] || '换标类型待确认' },
relabelTypeName(type) { return ({ CARTON_LABEL: '只换箱唛', SKU_LABEL: '只换 SKU', CARTON_AND_SKU: '混合换标(SKU + 箱唛)', RETURN_BULK_SKU_LABEL: '退货贴标(按 SKU 数量)' })[type] || '换标类型待确认' },
remainingReturnQuantity(item) { return Math.max(0, Number(item.expectedPackages || 0) - Number(item.relabelledPackages || 0) - Number(item.skippedPackages || 0) - Number(item.exceptionPackages || 0)) },
verifyReturnSku(raw) {
const code = String(raw?.detail?.value ?? raw ?? this.returnSkuCode).trim()
this.returnSkuCode = code; this.verifiedReturnSkuKey = ''
const matches = this.returnBulkSkuGroups.filter(group => group.remaining > 0 && group.skuCode.toUpperCase() === code.toUpperCase())
if (matches.length !== 1) { this.setError(new Error(matches.length ? '相同 SKU 对应多个目的仓,请核对任务' : '扫描的 SKU 不属于本单待贴标明细,或已全部登记')); return }
this.error = null; this.verifiedReturnSkuKey = matches[0].key; this.activeTarget = 'return-sku'
},
async completeBulkSku(group) {
if (this.busy || this.verifiedReturnSkuKey !== group.key || this.returnSkuCode.trim().toUpperCase() !== group.skuCode.toUpperCase()) return
const entered = String(this.bulkQuantities[group.key] ?? '').trim()
const quantity = entered ? Number(entered) : Number(group.remaining || 0)
if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > group.remaining) { this.setError(new Error(`本次贴标数量须为 1 至 ${group.remaining} 件`)); return }
const payload = { skuCode: group.skuCode, quantity, expectedVersion: this.current.version }
const key = warehouseIdempotencyKey('relabel', 'return-bulk-sku', { relabelNo: this.current.relabelNo, ...payload })
await this.run('return-bulk-sku', async () => { this.applyCurrent(await completeReturnRelabelSkuTotal(this.current.relabelNo, { ...payload, idempotencyKey: key.value })); releaseWarehouseKey('relabel', key.signature); this.returnSkuCode = ''; this.verifiedReturnSkuKey = ''; delete this.bulkQuantities[group.key]; this.activeTarget = 'return-sku'; this.successMessage = `${group.skuCode} 已登记贴标 ${quantity} 件` }, true, key)
},
async loadPhoto(fileId) {
const no = this.current?.relabelNo
if (!no || this.photoUrls[fileId] || !this.photoFileIds.includes(fileId)) return
@@ -213,14 +259,14 @@
removePhoto(index) { updateWarehouseWork('relabel', { photoFileIds: this.photoFileIds.filter((_, i) => i !== index), photoNames: this.photoNames.filter((_, i) => i !== index) }) },
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => { if (this.busy) return; if (this.activeTarget === 'pallet-work') { this.$refs.palletWork?.handleScan(value); return } if (this.activeTarget === 'quick-carton') { this.quickCarton = value; this.submitCarton(value) } else this.handleHardwareScan(value) }, onError: (error) => this.setError(error, '扫码广播注册失败') }) },
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
handleHardwareScan(value) { if (this.activeTarget === 'quick-sku') { this.quickSku = value; this.submitSku(value); return } if (this.activeTarget === 'old') { this.oldLabel = value; return } this.taskCode = value; this.openTask(value) },
handleHardwareScan(value) { if (this.activeTarget === 'return-sku') { this.verifyReturnSku(value); return } if (this.activeTarget === 'quick-sku') { this.quickSku = value; this.submitSku(value); return } if (this.activeTarget === 'old') { this.oldLabel = value; return } this.taskCode = value; this.openTask(value) },
handleOldLabel(value) { this.oldLabel = String(value?.detail?.value ?? value ?? '').trim(); if (this.action === 'RELABEL') this.activeTarget = this.taskTab === 'sku' ? 'sku-new-0' : 'new' },
handleNewLabel(value) { this.newLabel = String(value?.detail?.value ?? value ?? '').trim() },
async bootstrap() { await this.loadCandidates(); if (this.error || this.stage !== 'execute') return; const owned = this.candidates.filter((item) => item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator === true); const target = owned.find((item) => item.relabelNo === this.taskCode) || (owned.length === 1 ? owned[0] : null); if (target) await this.openTask(target.relabelNo, false) },
async loadCandidates() { if (this.loading) return; this.loading = true; this.error = null; try { this.candidates = await listRelabelCandidates() } catch (error) { this.setError(error, '换标任务加载失败') } finally { this.loading = false } },
async openTask(raw, sound = true) { const no = String(raw?.detail?.value ?? raw ?? this.taskCode).trim(); if (!no || this.busy) return; this.taskCode = no; await this.run('detail', async () => { this.applyCurrent(await getRelabelTask(no)); if (sound) playSuccessSound() }, false) },
applyCurrent(value) { const changed = this.current?.relabelNo !== value.relabelNo; this.current = value; this.taskCode = value.relabelNo; if (changed) { this.unitDrafts = {}; this.documents = []; this.quickCarton = ''; this.lastCarton = null; if (warehouseWork.relabel.photoTaskNo !== value.relabelNo) updateWarehouseWork('relabel', { photoTaskNo: value.relabelNo, photoFileIds: [], photoNames: [], remark: '' }); this.changeTab(value.items.some((item) => item.operationType === 'SKU_LABEL') ? 'sku' : 'carton'); this.loadDocuments() } if (this.selectedItem) this.selectedItem = this.visibleItems.find((item) => item.itemId === this.selectedItem.itemId) || null },
changeTab(value) { this.taskTab = value; this.selectedItem = null; this.reason = ''; this.quickSku = ''; this.skuChoices = []; this.activeTarget = value === 'carton' ? 'quick-carton' : 'quick-sku' },
applyCurrent(value) { const changed = this.current?.relabelNo !== value.relabelNo; this.current = value; this.taskCode = value.relabelNo; if (changed) { this.unitDrafts = {}; this.documents = []; this.quickCarton = ''; this.lastCarton = null; this.returnSkuCode = ''; this.verifiedReturnSkuKey = ''; this.bulkQuantities = {}; if (warehouseWork.relabel.photoTaskNo !== value.relabelNo) updateWarehouseWork('relabel', { photoTaskNo: value.relabelNo, photoFileIds: [], photoNames: [], remark: '' }); this.changeTab(value.items.some((item) => item.operationType === 'SKU_LABEL') ? 'sku' : 'carton'); this.loadDocuments() } if (this.selectedItem) this.selectedItem = this.visibleItems.find((item) => item.itemId === this.selectedItem.itemId) || null },
changeTab(value) { this.taskTab = value; this.selectedItem = null; this.reason = ''; this.quickSku = ''; this.skuChoices = []; this.activeTarget = this.isReturnBulk ? 'return-sku' : value === 'carton' ? 'quick-carton' : 'quick-sku' },
skuDetails(item) { return item.skuItems?.length ? item.skuItems : [{ skuCode: item.skuCode, newSkuCode: item.newSkuCode, quantity: null }] },
selectItem(item) { if (this.taskTab === 'carton') { this.activeTarget = 'quick-carton'; return } if (this.remaining(item) <= 0) return; if (!this.unitDrafts[item.itemId]) this.unitDrafts[item.itemId] = { cartonMark: '', fbaCartonMark: '', skuVerifications: this.skuDetails(item).map((sku) => ({ skuCode: sku.skuCode || '', newSkuCode: '', quantity: sku.quantity })) }; this.selectedItem = item; this.reason = ''; this.activeTarget = 'old' },
focusNextSku(index) { const next = index + 1; this.activeTarget = next < this.unitDraft.skuVerifications.length ? `sku-new-${next}` : 'old' },
@@ -274,5 +320,5 @@
</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}.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}
.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}
.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>
+36 -25
View File
@@ -3,10 +3,9 @@
<PortalHeader title="退货入库" :user="user" fallback-url="/pages/module/module?key=inbound" />
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
<view v-if="!current && !result" class="work-card">
<text class="card-title">选择查询方式</text>
<view class="mode-row"><button class="mode-button" :class="{ active: entryMode === 'RETURN_NO' }" @tap="switchMode('RETURN_NO')">退货单号</button><button class="mode-button" :class="{ active: entryMode === 'SKU' }" @tap="switchMode('SKU')">SKU</button></view>
<ScanTargetInput name="lookup" v-model="lookupCode" :label="entryMode === 'SKU' ? 'SKU' : '退货单号'" :placeholder="entryMode === 'SKU' ? '扫描或输入 SKU' : '扫描包裹上的退货单号'" :active="activeTarget === 'lookup'" @activate="activeTarget = 'lookup'" @submit="lookup" />
<button class="primary" :disabled="!lookupCode.trim() || busy" @tap="lookup(lookupCode)">{{ busy ? '查询中…' : '查询' }}</button>
<text class="card-title">扫描 SKU 69 码</text>
<ScanTargetInput name="lookup" v-model="lookupCode" label="SKU 69 码" placeholder="扫描商品 69 码,自动带出退货单号" :active="activeTarget === 'lookup'" @activate="activeTarget = 'lookup'" @submit="lookup" />
<text class="scan-hint">扫描完成后自动匹配对应退货单</text>
</view>
<view v-if="!current && candidates !== null && !result" class="section-gap">
@@ -19,18 +18,20 @@
<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">
<text class="card-title">本包信息</text>
<text class="hint">快递单号和箱号均可不填;箱号留空时由系统自动生成。</text>
<text class="card-title">本托信息</text>
<text class="hint">本次签收按一个 SKU 打一个托盘,托盘码必填。</text>
<text class="field-label">托盘码</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><button v-if="newPalletHtml" class="secondary" @tap="printPallet">打印托盘标签</button>
<text class="field-label">快递单号(选填)</text><ScanTargetInput name="tracking" v-model="trackingNo" label="快递单号" placeholder="扫描快递面单" :active="activeTarget === 'tracking'" @activate="activeTarget = 'tracking'" @submit="acceptTracking" />
<text class="field-label">箱号(选填)</text><ScanTargetInput name="carton" v-model="cartonMark" label="退货箱号" placeholder="扫描已有箱号,留空自动生成" :active="activeTarget === 'carton'" @activate="activeTarget = 'carton'" @submit="acceptCarton" />
</view>
<view class="work-card"><view class="row-head"><text class="card-title">本包实收数量</text><button class="link-button" @tap="fillRemaining">全部填入</button></view><view class="item-list"><view v-for="item in current.expectedItems" :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></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">本托实收数量</text><button class="link-button" @tap="fillRemaining">全部填入</button></view><view 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></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"><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><text class="result-carton">{{ result.cartonMark }}</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><text 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="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" class="secondary" :disabled="busy" @tap="reloadCurrent">刷新退货单</button></view>
</view></scroll-view>
@@ -40,7 +41,7 @@
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { confirmReturnReceipt, findReturnSkuCandidates, startReturnReceipt } from '@/api/return-receipt.js'
import { checkReturnPallet, confirmReturnReceipt, createReturnPallet, downloadReturnPalletPhoto, findReturnSkuCandidates, manuallyCompleteReturnReceipt, startReturnReceipt, uploadReturnPalletPhoto } from '@/api/return-receipt.js'
import { session, requireSession } from '@/stores/session.js'
import { releaseWarehouseKey, resetWarehouseDomain, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
@@ -48,34 +49,43 @@
export default {
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, current: null, candidates: null, result: null, activeTarget: 'lookup', busy: false, error: null } },
data() { return { user: session.currentUser, current: null, candidates: null, result: null, selectedSku: '', palletCheck: null, newPalletHtml: '', photoFileIds: [], photoNames: [], photoUrls: {}, activeTarget: 'lookup', busy: false, error: null } },
computed: {
entryMode: { get() { return warehouseWork.returnReceipt.entryMode }, set(value) { updateWarehouseWork('returnReceipt', { entryMode: value }) } },
lookupCode: { get() { return warehouseWork.returnReceipt.lookupCode }, set(value) { updateWarehouseWork('returnReceipt', { lookupCode: value }) } },
startedBy: { get() { return warehouseWork.returnReceipt.startedBy }, set(value) { updateWarehouseWork('returnReceipt', { startedBy: value }) } },
trackingNo: { get() { return warehouseWork.returnReceipt.trackingNo }, set(value) { updateWarehouseWork('returnReceipt', { trackingNo: value }) } },
cartonMark: { get() { return warehouseWork.returnReceipt.cartonMark }, set(value) { updateWarehouseWork('returnReceipt', { cartonMark: 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) || [] },
activeSkuCode() { return this.selectedItems.length === 1 ? this.selectedItems[0].skuCode : '' },
receiptQuantity() { return Object.values(warehouseWork.returnReceipt.quantities || {}).reduce((total, value) => total + (Number(value) || 0), 0) },
canConfirm() { return this.current && this.receiptQuantity > 0 && this.current.expectedItems.every((item) => this.quantity(item.skuCode) <= this.remaining(item)) }
canConfirm() { return this.current && this.palletCode.trim() && this.palletCheck && this.selectedItems.length === 1 && this.receiptQuantity > 0 && this.selectedItems.every((item) => this.quantity(item.skuCode) <= this.remaining(item)) }
},
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.startScanner(); if (warehouseWork.returnReceipt.currentReturnNo && !this.current && !this.busy) this.openOrder(warehouseWork.returnReceipt.currentReturnNo, warehouseWork.returnReceipt.startedBy, false) },
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.entryMode = 'SKU'; this.startScanner(); if (warehouseWork.returnReceipt.currentReturnNo && !this.current && !this.busy) this.openOrder(warehouseWork.returnReceipt.currentReturnNo, 'SKU', false) },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: {
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleScan(value), onError: (error) => this.setError(error, '扫码广播注册失败') }) },
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
handleScan(value) { if (this.busy) return; if (!this.current) { this.lookupCode = value; this.lookup(value); return } if (this.activeTarget === 'carton') this.acceptCarton(value); else this.acceptTracking(value) },
handleScan(value) { if (this.busy) return; if (!this.current) { this.lookupCode = value; this.lookup(value); return } if (this.activeTarget === 'pallet') this.acceptPallet(value); else this.acceptTracking(value) },
switchMode(mode) { if (this.busy) return; this.entryMode = mode; this.lookupCode = ''; this.candidates = null; this.error = null; this.activeTarget = 'lookup' },
async lookup(raw) { const value = String(raw?.detail?.value ?? raw ?? this.lookupCode).trim(); if (!value || this.busy) return; this.lookupCode = value; this.error = null; this.result = null; if (this.entryMode === 'SKU') await this.run(async () => { const response = await findReturnSkuCandidates(value); this.candidates = response.candidates; if (this.candidates.length) playSuccessSound() }, '查询 SKU 候选失败'); else await this.openOrder(value, 'RETURN_NO') },
async selectCandidate(item) { if (!item?.returnNo || this.busy) return; await this.openOrder(item.returnNo, 'SKU') },
async openOrder(returnNo, startedBy, feedback = true) { await this.run(async () => { const value = await startReturnReceipt(returnNo); if (!value) { this.current = null; this.candidates = []; updateWarehouseWork('returnReceipt', { currentReturnNo: '', quantities: {} }); this.activeTarget = 'lookup'; return } this.current = value; this.candidates = null; this.startedBy = startedBy; updateWarehouseWork('returnReceipt', { currentReturnNo: value.returnNo, startedBy, lookupCode: value.returnNo, quantities: this.validQuantities(value) }); this.activeTarget = 'tracking'; if (feedback) playSuccessSound() }, '读取退货单失败') },
async lookup(raw) { const value = String(raw?.detail?.value ?? raw ?? this.lookupCode).trim(); if (!value || this.busy) return; this.lookupCode = value; this.error = null; this.result = null; if (this.entryMode === 'SKU') await this.run(async () => { const response = await findReturnSkuCandidates(value); this.candidates = response.candidates; this.selectedSku = response.skuCode; if (this.candidates.length === 1) await this.openOrderValue(this.candidates[0].returnNo, 'SKU'); else if (this.candidates.length) playSuccessSound() }, '查询 SKU 候选失败'); else await this.openOrder(value, 'RETURN_NO') },
async selectCandidate(item) { if (!item?.returnNo || this.busy) return; this.selectedSku = this.lookupCode; await this.openOrder(item.returnNo, 'SKU') },
async openOrder(returnNo, startedBy, feedback = true) { await this.run(async () => { await this.openOrderValue(returnNo, startedBy, feedback) }, '读取退货单失败') },
async openOrderValue(returnNo, startedBy, feedback = true) { const value = await startReturnReceipt(returnNo); if (!value) { this.current = null; this.candidates = []; updateWarehouseWork('returnReceipt', { currentReturnNo: '', quantities: {} }); this.activeTarget = 'lookup'; return } this.current = value; this.palletCheck = null; this.candidates = null; this.startedBy = startedBy; this.photoFileIds = (value.photos || []).map((item) => String(item.fileId)); this.photoNames = (value.photos || []).map((item) => item.fileName || '退货打托照片'); this.photoUrls = {}; await Promise.all(this.photoFileIds.map(async (fileId) => { try { this.photoUrls[fileId] = await downloadReturnPalletPhoto(value.returnNo, fileId) } catch (_) { /* 单张照片失败不阻断作业 */ } })); updateWarehouseWork('returnReceipt', { currentReturnNo: value.returnNo, startedBy, lookupCode: value.returnNo, palletCode: '', quantities: this.validQuantities(value) }); this.activeTarget = 'pallet'; if (feedback) playSuccessSound() },
validQuantities(order) { const source = warehouseWork.returnReceipt.quantities || {}; return Object.fromEntries(order.expectedItems.map((item) => [item.skuCode, Math.min(Math.max(0, Number(source[item.skuCode]) || 0), this.remaining(item))])) },
quantity(skuCode) { return Math.max(0, Number(warehouseWork.returnReceipt.quantities?.[skuCode]) || 0) },
remaining(item) { return Math.max(0, Number(item.expectedQuantity || 0) - Number(item.receivedQuantity || 0)) },
setQuantity(item, raw) { const maximum = this.remaining(item); const value = Math.min(maximum, Math.max(0, Math.floor(Number(raw) || 0))); updateWarehouseWork('returnReceipt', { quantities: { ...(warehouseWork.returnReceipt.quantities || {}), [item.skuCode]: value } }) },
fillRemaining() { updateWarehouseWork('returnReceipt', { quantities: Object.fromEntries(this.current.expectedItems.map((item) => [item.skuCode, this.remaining(item)])) }) },
acceptTracking(raw) { this.trackingNo = String(raw?.detail?.value ?? raw ?? '').trim(); this.activeTarget = 'carton'; playSuccessSound() },
acceptCarton(raw) { this.cartonMark = String(raw?.detail?.value ?? raw ?? '').trim(); playSuccessSound() },
async confirmReceipt() { if (!this.canConfirm || this.busy) return; const payload = { returnNo: this.current.returnNo, trackingNo: this.trackingNo.trim() || undefined, startedBy: this.startedBy, cartonMark: this.cartonMark.trim() || undefined, items: this.current.expectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: this.quantity(item.skuCode) })).filter((item) => item.receivedQuantity > 0) }; const key = warehouseIdempotencyKey('returnReceipt', 'confirm', payload); await this.run(async () => { this.result = await confirmReturnReceipt({ ...payload, idempotencyKey: key.value }); releaseWarehouseKey('returnReceipt', key.signature); resetWarehouseDomain('returnReceipt'); this.current = null; this.candidates = null; this.activeTarget = 'lookup'; playSuccessSound() }, '退货入库失败') },
fillRemaining() { updateWarehouseWork('returnReceipt', { quantities: Object.fromEntries(this.selectedItems.map((item) => [item.skuCode, this.remaining(item)])) }) },
async acceptPallet(raw) { const code = String(raw?.detail?.value ?? raw ?? '').trim(); if (!code || this.busy) return; if (!this.activeSkuCode) { this.setError(new Error('请先通过 SKU 69 码选择一个待签收 SKU'), '托盘校验失败'); return } await this.run(async () => { const value = await checkReturnPallet(this.current.returnNo, code); this.palletCode = value.palletCode; this.newPalletHtml = ''; this.palletCheck = value; this.activeTarget = 'tracking'; playSuccessSound() }, '托盘校验失败') },
async generatePallet() { if (!this.current || this.busy) return; const skuCode = this.activeSkuCode; if (!skuCode) { this.setError(new Error('请先通过 SKU 69 码选择一个待签收 SKU'), '无法生成退货托盘'); return } await this.run(async () => { const value = await createReturnPallet(this.current.returnNo, skuCode); if (!value.palletCode) throw new Error('服务端未返回新托盘码'); this.palletCode = value.palletCode; this.newPalletHtml = value.printableHtml; this.palletCheck = { palletCode: value.palletCode, empty: true, skuCodes: [], quantity: 0 }; this.activeTarget = 'tracking'; playSuccessSound() }, '生成退货托盘失败') },
printPallet() { if (!this.newPalletHtml) return; /* #ifdef H5 */ const view = window.open('', '_blank'); if (view) { view.document.write(this.newPalletHtml); view.document.close(); view.focus(); view.print() } else this.setError(new Error('请允许打开标签打印窗口'), '打印失败'); /* #endif */ /* #ifndef H5 */ this.setError(new Error('当前设备未配置打印服务'), '打印失败'); /* #endif */ },
acceptTracking(raw) { this.trackingNo = String(raw?.detail?.value ?? raw ?? '').trim(); playSuccessSound() },
async confirmReceipt() { if (!this.canConfirm || this.busy) return; const willComplete = this.current.expectedItems.every((item) => this.quantity(item.skuCode) >= this.remaining(item)); if (willComplete && !this.photoFileIds.length) { const modal = await new Promise((resolve) => uni.showModal({ title: '缺少退货打托照片', content: '本次签收将完成整单入库,请先拍照并上传后再确认打托。', confirmText: '去拍照', cancelText: '取消', success: resolve })); if (modal?.confirm) { this.error = { message: '请先拍照并上传退货打托照片后再确认' }; } return } const payload = { returnNo: this.current.returnNo, trackingNo: this.trackingNo.trim() || undefined, startedBy: this.startedBy, palletCode: this.palletCode.trim(), items: this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: this.quantity(item.skuCode) })).filter((item) => item.receivedQuantity > 0) }; const key = warehouseIdempotencyKey('returnReceipt', 'confirm', payload); await this.run(async () => { this.result = await confirmReturnReceipt({ ...payload, idempotencyKey: key.value }); releaseWarehouseKey('returnReceipt', key.signature); resetWarehouseDomain('returnReceipt'); this.current = null; this.candidates = null; this.activeTarget = 'lookup'; playSuccessSound() }, '退货入库失败') },
manualComplete() { if (!this.current || this.busy) return; uni.showModal({ title: '缺货手动完结', editable: true, placeholderText: '请填写实收少于预期数量的原因', success: async (modal) => { if (!modal.confirm) return; const reason = String(modal.content || '').trim(); if (!reason) { this.setError(new Error('必须填写差异原因'), '手动完结失败'); return } await this.run(async () => { await manuallyCompleteReturnReceipt(this.current.returnNo, reason); this.result = { palletCode: '', receiptNo: '', receiptBatchNo: '', expectedQuantity: this.current.expectedItems.reduce((sum, item) => sum + item.expectedQuantity, 0), receivedQuantity: this.current.expectedItems.reduce((sum, item) => sum + item.receivedQuantity, 0), fullyMatched: false }; this.current = null; this.candidates = null; this.activeTarget = 'lookup'; playSuccessSound() }, '手动完结失败') } }) },
choosePhoto() { if (!this.current || this.busy || this.photoFileIds.length >= 20) return; const no = this.current.returnNo; uni.chooseImage({ count: Math.min(9, 20 - this.photoFileIds.length), sourceType: ['camera', 'album'], success: async ({ tempFilePaths, tempFiles }) => { await this.run(async () => { for (const [index,path] of (tempFilePaths || []).entries()) { if (Number(tempFiles?.[index]?.size || 0) > 10 * 1024 * 1024) throw new Error('照片不能超过 10MB'); const file = await uploadReturnPalletPhoto(no, path); const id = String(file.fileId); this.photoFileIds.push(id); this.photoNames.push(file.fileName || '退货打托照片'); this.photoUrls[id] = path } playSuccessSound() }, '退货打托照片上传失败') } }) },
previewPhoto(fileId) { const urls = this.photoFileIds.map(id => this.photoUrls[id]).filter(Boolean); if (this.photoUrls[fileId]) uni.previewImage({ current: this.photoUrls[fileId], urls }) },
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() { resetWarehouseDomain('returnReceipt'); this.current = null; this.candidates = null; this.error = null; this.activeTarget = 'lookup' },
nextReceipt() { this.result = null; this.clearCurrent() },
@@ -88,5 +98,6 @@
<style scoped lang="scss">
@import '../warehouse/operations.scss';
.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}
.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}
</style>
+1 -1
View File
@@ -6,7 +6,7 @@ const blankState = () => ({
stocktake: { taskNo: '', scopeType: 'LOCATION', scopeCode: '', reason: '', pendingKeys: {} },
move: { sourcePalletCode: '', targetLocationCode: '', reason: '', pendingKeys: {} },
relabel: { currentNo: '', oldLabel: '', newLabel: '', action: 'RELABEL', reason: '', remark: '', photoFileIds: [], photoNames: [], pendingKeys: {} },
returnReceipt: { entryMode: 'RETURN_NO', lookupCode: '', currentReturnNo: '', startedBy: 'RETURN_NO', trackingNo: '', cartonMark: '', quantities: {}, pendingKeys: {} },
returnReceipt: { entryMode: 'SKU', lookupCode: '', currentReturnNo: '', startedBy: 'SKU', trackingNo: '', palletCode: '', quantities: {}, pendingKeys: {} },
returnPacking: { searchCode: '', selectedOrders: [], quantities: {}, length: '', width: '', height: '', weight: '', pendingKeys: {} },
query: { inventoryCode: '', locationCode: '', palletCode: '' }
})
+46 -9
View File
@@ -2,6 +2,7 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const source = await readFile(new URL('../src/components/RelabelPalletWork.vue', import.meta.url), 'utf8')
const template = source.split('</template>')[0]
const script = source.split('<script>')[1].split('</script>')[0].replace(/^import .*$/gm, '').replace('export default', 'return')
const component = new Function('ScanTargetInput', script)({})
function context() {
@@ -13,7 +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.selectSource({ positionId: '1', palletCode: 'SOURCE', movablePackages: 2, cartonMarks: ['A', 'B', 'C'] })
value.selectSource({ positionId: '1', palletId: '20', palletCode: 'SOURCE', movablePackages: 2, cartonMarks: ['A', 'B', 'C'] })
return value
}
test('quantity batch only includes completed boxes from same inbound and destination', () => {
@@ -24,22 +25,58 @@ test('quantity batch only includes completed boxes from same inbound and destina
value.selectedDestination = 'JFK8'; value.quantity = '1'; value.fillBatch(); assert.deepEqual(value.marks, ['B'])
})
test('submission requires exact count and scanned distinct source and target pallets', () => {
test('submission uses pallet identity instead of scanned presentation text', () => {
const value = context(); value.quantity = '1'; value.fillBatch()
value.source = { palletCode: 'SOURCE' }; value.sourceCode = 'source'
value.target = { palletCode: 'TARGET' }; value.targetCode = 'TARGET'
value.source = { palletId: '20', palletCode: 'SOURCE' }; value.sourceCode = ' source\n'
value.target = { palletId: '30', palletCode: 'TARGET' }; value.targetCode = 'target-alias'
assert.equal(value.readyToBind, true)
value.toggleMark('A'); assert.equal(value.readyToBind, false)
value.toggleMark('A'); value.sourceCode = 'OTHER'; assert.equal(value.readyToBind, false)
value.sourceCode = 'SOURCE'; value.target = { palletCode: 'SOURCE' }; value.targetCode = 'SOURCE'
value.toggleMark('A'); value.source = { palletId: '21', palletCode: 'OTHER' }; assert.equal(value.readyToBind, false)
value.source = { palletId: '20', palletCode: 'SOURCE' }; value.target = { palletId: '20', palletCode: 'TARGET-ALIAS' }
assert.equal(value.readyToBind, false)
})
test('pallet comparison tolerates mixed API versions and canonical code formatting', () => {
const value = context()
assert.equal(value.samePallet(
{ palletCode: ' plt-us-lax-01-1 ' },
{ palletCode: 'PLT-US-LAX-01-1' }
), true)
assert.equal(value.samePallet(
{ palletId: '20', palletCode: 'SOURCE' },
{ palletId: '21', palletCode: 'OTHER' }
), false)
assert.equal(value.samePallet(
{ palletId: '20', palletCode: 'SOURCE' },
{ palletId: '21', palletCode: ' source ' }
), true)
assert.equal(value.samePallet(
{ palletId: 2100203890682015700, palletCode: 'PLT-US-LAX-01-20260916-2100203890682015746' },
{ palletId: '2100203890682015746', palletCode: ' plt-us-lax-01-20260916-2100203890682015746 ' }
), true)
})
test('quantity disassembly has no extra carton scanner', () => {
assert.doesNotMatch(source, /relabel-move-carton/)
assert.match(source, /actualSource:/)
assert.match(source, /仍归属/)
})
test('source pallet list is concise and hides rows with no movable packages', () => {
const value = context()
value.sourceRows = [
{ palletCode: 'PALLET-1', inboundCode: 'IN-1', stockPackages: 12, movablePackages: 2 },
{ palletCode: 'PALLET-2', inboundCode: 'IN-2', stockPackages: 10, movablePackages: 0 }
]
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, /可拆\{\{ isReturnBulk \? '件数' : '箱数' \}\}:\{\{ row\.movablePackages \}\}/)
assert.doesNotMatch(template, /刷新来源托盘/)
assert.doesNotMatch(template, /按原入仓单拆托/)
assert.doesNotMatch(template, /确认后直接提交本批拆托合托/)
})
test('latest current-round binding is used for rebind source', () => {
const value = context()
value.task.history = ['OLD', 'NEW'].map(pallet => ({ action: 'PALLET_BIND', detail: JSON.stringify({ round: 'INITIAL', cartonMarks: ['A'], targetPallets: [pallet] }) }))
@@ -61,9 +98,9 @@ test('rescan same bound pallet does not call stock movement', async () => {
test('target confirmation submits binding directly, with no second submit button', async () => {
const value = context(); value.quantity = '1'; value.fillBatch()
value.source = { palletCode: 'SOURCE' }; value.sourceCode = 'SOURCE'; value.targetCode = 'TARGET'
value.source = { palletId: '20', palletCode: 'SOURCE' }; value.sourceCode = 'SOURCE'; value.targetCode = 'TARGET'
let binds = 0
value.resolve = async kind => { assert.equal(kind, 'target'); value.target = { palletCode: 'TARGET' } }
value.resolve = async kind => { assert.equal(kind, 'target'); value.target = { palletId: '30', palletCode: 'TARGET' } }
value.bind = async () => { binds++ }
await value.confirmTargetAndBind()
assert.equal(binds, 1)
@@ -83,7 +120,7 @@ test('missing source scan shows modal and never submits a binding', async () =>
test('target lookup failure cannot submit binding or report success', async () => {
const value = context(); value.quantity = '1'; value.fillBatch()
value.source = { palletCode: 'SOURCE' }; value.sourceCode = 'SOURCE'; value.targetCode = 'TARGET'
value.source = { palletId: '20', palletCode: 'SOURCE' }; value.sourceCode = 'SOURCE'; value.targetCode = 'TARGET'
value.resolve = async () => { value.error = '目标托盘不可用' }
let title = ''
value.showBindingMessage = heading => { title = heading }
+49
View File
@@ -0,0 +1,49 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const api = await readFile(new URL('../src/api/relabel.js', import.meta.url), 'utf8')
const page = await readFile(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
const palletWork = await readFile(new URL('../src/components/RelabelPalletWork.vue', import.meta.url), 'utf8')
test('PDA exposes the approved return bulk SKU completion endpoint', () => {
assert.match(api, /completeReturnRelabelSkuTotal/)
assert.match(api, /\/return-skus\/complete-total/)
})
test('return relabel pallet binding validates pieces while binding physical carton marks', () => {
assert.match(palletWork, /isReturnBulk\(\).*RETURN_BULK_SKU_LABEL/)
assert.match(palletWork, /count === packages && this\.completedItems\(\)\.length === this\.eligibleItems\.length && this\.marks\.length === this\.eligibleItems\.length/)
assert.match(palletWork, /this\.isReturnBulk \? this\.eligibleItems : this\.eligibleItems\.slice\(0, count\)/)
assert.match(page, /requiredMarks[\s\S]*some\(\(mark\) => !marks\.has\(mark\)\)/)
})
test('return relabel mode works by SKU remaining quantity without carton-mark scanning', () => {
assert.match(page, /current\?\.operationType === 'RETURN_BULK_SKU_LABEL'/)
assert.match(page, /退货贴标 SKU 明细/)
assert.match(page, /name="returnSku"[\s\S]*@submit="verifyReturnSku"/)
assert.match(page, /activeTarget === 'return-sku'[\s\S]*this\.verifyReturnSku\(value\)/)
assert.match(page, /verifiedReturnSkuKey !== group\.key/)
assert.match(page, /Number\.isSafeInteger\(quantity\)[\s\S]*quantity > group\.remaining/)
assert.match(page, /returnBulkSkuGroups/)
assert.match(page, /来源托盘拆箱数量/)
assert.match(page, /登记本次贴标.*group\.remaining/)
assert.match(page, /completeReturnRelabelSkuTotal/)
assert.match(page, /taskTab === 'carton' && !isReturnBulk/)
})
test('new relabel pallet is immediately copied into the target scan field', () => {
assert.match(palletWork, /this\.targetCode = this\.newPallet\.palletCode \|\| ''/)
assert.match(palletWork, /退货换标无需扫描箱唛/)
})
test('return relabel distinguishes a whole pallet from a partial unpack', () => {
assert.match(palletWork, /wholeSource\(\).*plannedPackages === Number\(this\.selectedSource\.stockPackages/)
assert.match(palletWork, /eligibleItems\(\).*this\.isReturnBulk \? this\.sourceItems\(\) : this\.completedItems\(\)/)
assert.match(palletWork, /this\.selectedDestination = this\.sourceItems\(\)\[0\]/)
assert.match(palletWork, /this\.completedItems\(\)\.length !== this\.eligibleItems\.length/)
assert.match(palletWork, /贴标数量等于本托盘全部/)
assert.match(palletWork, /贴标数量少于本托盘库存/)
assert.match(palletWork, /v-if="!isReturnBulk"[\s\S]*name="relabel-source"/)
assert.match(palletWork, /await this\.run\(async \(\) => \{ const value = await resolvePallet\(row\.palletCode\)/)
})
+5 -5
View File
@@ -5,7 +5,7 @@ import { session } from '../src/stores/session.js'
test('return receipt start normalization keeps only stable business facts', () => {
assert.deepEqual(normalizeReturnReceiptStart({ returnNo: 'RT-001', status: 'PENDING_RECEIPT', expectedItems: [{ skuCode: 'SKU-1', productNameZh: '商品', expectedQuantity: '5', receivedQuantity: 2 }] }), {
returnNo: 'RT-001', status: 'PENDING_RECEIPT', expectedItems: [{ skuCode: 'SKU-1', productNameZh: '商品', expectedQuantity: 5, receivedQuantity: 2 }]
returnNo: 'RT-001', status: 'PENDING_RECEIPT', expectedItems: [{ skuCode: 'SKU-1', productNameZh: '商品', expectedQuantity: 5, receivedQuantity: 2 }], completedPallets: [], photos: []
})
})
@@ -17,8 +17,8 @@ test('SKU candidates do not expose the backend customer identifier to the page',
})
test('confirmed receipt normalization preserves generated carton and batch references', () => {
assert.deepEqual(normalizeReturnReceiptResult({ returnNo: 'RT-001', receiptNo: 'RR-1', receiptBatchNo: 'RB-1', cartonMark: 'RC-1', orderStatus: 'COMPLETED', fullyMatched: true, expectedQuantity: 5, receivedQuantity: 5, signedAt: '2026-09-10T10:00:00' }), {
returnNo: 'RT-001', receiptNo: 'RR-1', receiptBatchNo: 'RB-1', cartonMark: 'RC-1', orderStatus: 'COMPLETED', fullyMatched: true, expectedQuantity: 5, receivedQuantity: 5, signedAt: '2026-09-10T10:00:00'
assert.deepEqual(normalizeReturnReceiptResult({ returnNo: 'RT-001', receiptNo: 'RR-1', receiptBatchNo: 'RB-1', palletCode: 'PLT-1', orderStatus: 'COMPLETED', fullyMatched: true, expectedQuantity: 5, receivedQuantity: 5, signedAt: '2026-09-10T10:00:00' }), {
returnNo: 'RT-001', receiptNo: 'RR-1', receiptBatchNo: 'RB-1', palletCode: 'PLT-1', orderStatus: 'COMPLETED', fullyMatched: true, expectedQuantity: 5, receivedQuantity: 5, signedAt: '2026-09-10T10:00:00'
})
})
@@ -31,13 +31,13 @@ test('return receipt API calls only the dedicated PDA routes and preserves the r
let data
if (options.url.endsWith('/start')) data = { returnNo: 'RT-001', status: 'PENDING_RECEIPT', expectedItems: [] }
else if (options.url.endsWith('/sku-candidates')) data = { skuCode: 'SKU-1', candidates: [] }
else data = { returnNo: 'RT-001', receiptNo: 'RR-1', receiptBatchNo: 'RB-1', cartonMark: 'RC-1', orderStatus: 'PARTIALLY_RECEIVED', fullyMatched: false, expectedQuantity: 5, receivedQuantity: 2 }
else data = { returnNo: 'RT-001', receiptNo: 'RR-1', receiptBatchNo: 'RB-1', palletCode: 'PLT-1', orderStatus: 'PARTIALLY_RECEIVED', fullyMatched: false, expectedQuantity: 5, receivedQuantity: 2 }
options.success({ statusCode: 200, data: { code: '200', data }, header: {} })
}
}
await startReturnReceipt(' RT-001 ')
await findReturnSkuCandidates(' SKU-1 ')
const confirm = { returnNo: 'RT-001', trackingNo: 'TRACK-1', startedBy: 'SKU', cartonMark: 'RC-1', items: [{ skuCode: 'SKU-1', receivedQuantity: 2 }], idempotencyKey: 'return-key' }
const confirm = { returnNo: 'RT-001', trackingNo: 'TRACK-1', startedBy: 'SKU', palletCode: 'PLT-1', items: [{ skuCode: 'SKU-1', receivedQuantity: 2 }], idempotencyKey: 'return-key' }
await confirmReturnReceipt(confirm)
assert.deepEqual(calls.map((item) => item.url), [
'/overseas-api/pda/v1/last-leg/return-receipts/start',
+2 -2
View File
@@ -91,7 +91,7 @@ test('carton relabel uses one scan input and shows the confirmed FBA mapping aft
assert.match(scan, /confirmedItem\?\.fbaCartonMark/)
})
test('mixed relabel keeps SKU work open after carton work and only carton tab mounts pallet work', async () => {
test('mixed relabel keeps SKU work open and return bulk also mounts pallet work', async () => {
const page = await read('../src/pages/relabel/relabel.vue')
const body = page.match(/remaining\(item\) \{([^\n]+)\},/)[1]
const remaining = new Function('item', body)
@@ -99,7 +99,7 @@ test('mixed relabel keeps SKU work open after carton work and only carton tab mo
cartonProcessedPackages: 1, skuProcessedPackages: 0, skippedPackages: 0, exceptionPackages: 0 }
assert.equal(remaining.call({ taskTab: 'sku' }, mixed), 1)
assert.equal(remaining.call({ taskTab: 'carton' }, mixed), 0)
assert.match(page, /<RelabelPalletWork v-if="taskTab === 'carton'"/)
assert.match(page, /<RelabelPalletWork v-if="taskTab === 'carton' \|\| isReturnBulk"/)
})
test('relabel list opens a row directly in execution and completion returns to the list', async () => {