Merge branch 'release/test' of http://git.buggoglobal.com/html/pda-overseas-uniapp into release/test

This commit is contained in:
2026-09-18 16:48:46 +08:00
20 changed files with 1039 additions and 43 deletions
+48 -2
View File
@@ -42,6 +42,13 @@ async function tokenAfterUnauthorized(failedToken) {
function overseasUrl(path) {
const base = String(session.apiBase || '').replace(/\/$/, '')
const nativeRuntime = typeof plus !== 'undefined'
|| (typeof location !== 'undefined' && location.protocol === 'file:')
if (nativeRuntime && !/^https?:\/\//i.test(base)) {
const error = new Error('海外服务地址未配置:App 必须设置绝对 HTTP(S) 地址 VITE_OVERSEAS_API_BASE_URL')
error.code = 'PDA_OVERSEAS_API_BASE_REQUIRED'
throw error
}
const value = `/${String(path || '').replace(/^\/+/, '')}`
const normalized = /\/(?:api|overseas-api)$/.test(base)
? value.replace(/^\/api(?=\/)/, '')
@@ -79,14 +86,53 @@ export async function overseasRequest(method, path, data, retry = true) {
}
function rawUpload(path, filePath, name, token) {
const browserFile = typeof Blob !== 'undefined' && filePath instanceof Blob
if (!browserFile && !String(filePath || '').trim()) {
const error = new Error('未获取到可上传的照片路径,请重新拍摄')
error.code = 'PDA_UPLOAD_FILE_PATH_REQUIRED'
return Promise.reject(error)
}
let url
try { url = overseasUrl(path) } catch (error) { return Promise.reject(error) }
const h5Runtime = typeof window !== 'undefined' && typeof plus === 'undefined'
if (h5Runtime && browserFile) return new Promise((resolve, reject) => {
uni.uploadFile({
url, files: [{ name, file: filePath }],
header: { Authorization: `Bearer ${token}` },
success: resolve,
fail: (cause) => reject(Object.assign(new Error(cause.errMsg || '海外作业文件上传失败'), { network: true, uploadUrl: url }))
})
})
if (h5Runtime && /^blob:/i.test(filePath)) {
const selectedFile = fetch(filePath).then((source) => {
if (!source.ok) throw new Error('无法读取已选择的照片')
return source.blob()
})
return selectedFile.then(async (blob) => {
const form = new FormData()
const suffix = String(blob.type || '').split('/')[1] || 'jpg'
form.append(name, blob, blob.name || `pda-photo.${suffix}`)
if (typeof XMLHttpRequest !== 'undefined') return new Promise((resolve, reject) => {
const request = new XMLHttpRequest()
request.open('POST', url)
request.setRequestHeader('Authorization', `Bearer ${token}`)
request.onload = () => resolve({ statusCode: request.status, data: request.responseText, header: {} })
request.onerror = () => reject(new Error('海外作业文件上传失败'))
request.send(form)
})
const response = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: form })
return { statusCode: response.status, data: await response.text(), header: Object.fromEntries(response.headers.entries()) }
})
.catch((cause) => { throw Object.assign(new Error(cause.message || '海外作业文件上传失败'), { network: true, uploadUrl: url }) })
}
return new Promise((resolve, reject) => {
uni.uploadFile({
url: overseasUrl(path),
url,
filePath,
name,
header: { Authorization: `Bearer ${token}` },
success: resolve,
fail: (cause) => reject(Object.assign(new Error(cause.errMsg || '海外作业文件上传失败'), { network: true }))
fail: (cause) => reject(Object.assign(new Error(cause.errMsg || '海外作业文件上传失败'), { network: true, uploadUrl: url }))
})
})
}
+3
View File
@@ -1,6 +1,9 @@
import { overseasRequest } from './overseas.js'
const BASE = '/pda/v1/last-leg/pallet-operations'
export const findRelabelSources = (relabelNo) => overseasRequest('POST', `${BASE}/relabel-sources`, { relabelNo })
export const createRelabelPallet = (payload) => overseasRequest('POST', `${BASE}/relabel-pallet-label`, payload)
export const bindRelabelPallet = (payload) => overseasRequest('POST', `${BASE}/relabel-disassemble`, payload)
export function resolvePallet(code) {
return overseasRequest('POST', `${BASE}/pallets/resolve`, { code: String(code || '').trim() })
+1
View File
@@ -2,6 +2,7 @@ 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))
function asList(value) { return Array.isArray(value) ? value : [] }
+51
View File
@@ -0,0 +1,51 @@
import { overseasDownload, overseasRequest, overseasUpload } from './overseas.js'
const BASE = '/pda/v1/last-leg/warehouse-transfers'
const encoded = (value) => encodeURIComponent(String(value || '').trim())
export async function listWarehouseTransferCandidates() {
const value = await overseasRequest('GET', `${BASE}/candidates`)
return Array.isArray(value) ? value : (Array.isArray(value?.records) ? value.records : [])
}
export function getWarehouseTransferDetail(transferNo) {
return overseasRequest('GET', `${BASE}/${encoded(transferNo)}`)
}
export function palletizeWarehouseTransfer(transferNo, payload) {
return overseasRequest('POST', `${BASE}/${encoded(transferNo)}/palletize`, payload)
}
export function saveWarehouseTransferPalletDraft(transferNo, payload) {
return overseasRequest('POST', `${BASE}/${encoded(transferNo)}/pallets/drafts`, payload)
}
export function generateWarehouseTransferPallet(transferNo, palletId, payload) {
return overseasRequest('POST', `${BASE}/${encoded(transferNo)}/pallets/${encoded(palletId)}/generate`, payload)
}
export function bindWarehouseTransferPallet(transferNo, palletId, payload) {
return overseasRequest('POST', `${BASE}/${encoded(transferNo)}/pallets/${encoded(palletId)}/bind`, payload)
}
export function deleteWarehouseTransferPallet(transferNo, palletId) {
return overseasRequest('DELETE', `${BASE}/${encoded(transferNo)}/pallets/${encoded(palletId)}`)
}
export function uploadWarehouseTransferMeasurementPhoto(transferNo, filePath) {
return overseasUpload(`${BASE}/${encoded(transferNo)}/measurement-photos/files`, filePath)
}
export function listWarehouseTransferMeasurementPhotos(transferNo) {
return overseasRequest('GET', `${BASE}/${encoded(transferNo)}/measurement-photos/files`)
}
export function downloadWarehouseTransferMeasurementPhoto(transferNo, fileId) {
return overseasDownload(`${BASE}/${encoded(transferNo)}/measurement-photos/files/${encoded(fileId)}/download`)
}
export function downloadWarehouseTransferPalletLabel(labelDownloadPath) {
const path = String(labelDownloadPath || '').trim()
if (!path) return Promise.reject(new Error('服务端未返回托盘标签下载地址'))
return overseasDownload(path)
}
+155
View File
@@ -0,0 +1,155 @@
<template>
<view class="pallet-work">
<text class="heading">换标后组托</text>
<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>
<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="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>
</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>
</view>
</template>
<script>
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { resolvePallet, createRelabelPallet, bindRelabelPallet, findRelabelSources } from '@/api/pallet.js'
import { warehouseIdempotencyKey, releaseWarehouseKey } from '@/stores/warehouse-work.js'
export default {
components: { ScanTargetInput },
props: { task: { type: Object, required: true } },
emits: ['activate', 'busy', 'saved'],
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: {
currentBindings() {
const latest = new Map()
for (const entry of this.task.history || []) {
if (entry.action === 'REWORK_ARCHIVE') latest.clear()
if (entry.action !== 'PALLET_BIND') continue
try {
const data = JSON.parse(entry.detail)
if (data.targetPallets?.length !== 1) continue
for (const mark of data.cartonMarks || []) latest.set(mark, { mark, pallet: data.targetPallets[0] })
} catch { /* Invalid history is not used to infer a binding. */ }
}
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) }
},
methods: {
selectRebind(binding) {
if (this.busy) return
uni.showModal({ title: '重新绑定托盘', content: `箱唛 ${binding.mark} 当前在 ${binding.pallet}。请扫描新的目标托盘。`, showCancel: false })
this.rebindSelection = binding
this.targetCode = ''; this.target = null; this.message = ''; this.error = ''
this.activate('target')
},
async submitRebind() {
const binding = this.rebindSelection
const targetCode = this.targetCode.trim().toUpperCase()
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) }))
this.busy = false; this.$emit('busy', false)
if (!confirmed) return
await this.run(async () => {
const source = await resolvePallet(binding.pallet)
const target = await resolvePallet(targetCode)
const payload = { relabelNo: this.task.relabelNo, source: { palletCode: source.palletCode, expectedVersion: source.palletVersion }, target: { palletCode: target.palletCode, expectedVersion: target.palletVersion }, cartonMarks: [binding.mark], reason: 'PDA 已绑定箱唛确认改绑托盘' }
const key = warehouseIdempotencyKey('relabel', 'rebind-pallet', payload)
await bindRelabelPallet({ ...payload, idempotencyKey: key.value })
releaseWarehouseKey('relabel', key.signature)
this.rebindSelection = null; this.targetCode = ''; this.target = null
this.$emit('saved')
this.showBindingMessage('绑定成功', `${binding.mark} 已从 ${binding.pallet} 移至 ${target.palletCode}`)
})
if (this.error) this.showBindingMessage('改绑失败', this.error)
},
completedItems() { return (this.task.items || []).filter(item => this.selectedSource?.cartonMarks.includes(item.cartonMark) && 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 = '' },
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) },
showBindingMessage(title, content) { uni.showModal({ title, content, showCancel: false, confirmText: '知道了' }) },
async confirmTargetAndBind() {
if (this.busy) return
if (this.rebindSelection) return this.submitRebind()
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.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 }
await this.bind()
},
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 bind() {
if (!this.readyToBind) { this.error = '请核对来源托盘、拆出箱数、本批箱唛和目标托盘'; return }
const expectation = value => ({ palletCode: value.palletCode, expectedVersion: value.palletVersion })
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') })
if (saved) { this.showBindingMessage('绑定成功', this.message); await this.loadSources() }
else if (this.error) this.showBindingMessage('绑定失败', this.error)
},
printPallet() {
// #ifdef H5
const view = window.open('', '_blank'); if (view) { view.document.write(this.newPallet.printableHtml); view.document.close(); view.focus(); view.print() } else this.error = '请允许打开标签打印窗口'
// #endif
// #ifndef H5
this.error = '当前设备需通过已适配的打印服务打印托盘标签;请先在后台打印此托盘号,不要虚报打印成功。'
// #endif
}
}
}
</script>
<style scoped>
.pallet-work{margin-top:18px;padding:14px;border:1px solid #d7e1ef;border-radius:10px}
.heading{display:block;font-size:18px;font-weight:700}
.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}
</style>
+4 -1
View File
@@ -18,7 +18,10 @@ selectedOverseasApiRoot = runtimeEnvironment.VITE_H5_OVERSEAS_API_BASE_URL || '/
// root there, even if a stale H5 bundle or compiler flag was reused.
if (nativeRuntime) {
selectedAuthApiRoot = configuredAuthApiRoot || selectedAuthApiRoot
selectedOverseasApiRoot = configuredOverseasApiRoot || selectedOverseasApiRoot
// Native file:// builds cannot use the H5-only /overseas-api Vite proxy.
// Keep the missing value explicit so the request layer can report a useful
// configuration error instead of surfacing an opaque uploadFile:fail.
selectedOverseasApiRoot = configuredOverseasApiRoot || ''
}
export const API_ROOT = String(selectedAuthApiRoot).replace(/\/$/, '')
+1
View File
@@ -1,6 +1,7 @@
export const MODULES = [
{ key: 'inbound', name: '入仓作业', nameKey: 'modules.inbound.name', icon: '入', description: '处理拆柜收货与客户退货实收入仓', descriptionKey: 'modules.inbound.description', operations: [
{ key: 'receiving', name: '拆柜收货', nameKey: 'modules.receiving.name', icon: '卸', iconType: 'download', description: '三类照片签收,箱码定位入仓码后按目的类型收货', descriptionKey: 'modules.receiving.description', available: true },
{ key: 'warehouseTransfer', name: '仓储转运测量', nameKey: 'modules.warehouseTransfer.name', icon: '转', iconType: 'compose', description: '回填箱规、箱数并完成托盘打托', descriptionKey: 'modules.warehouseTransfer.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer' },
{ 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' }
] },
+1
View File
@@ -4,6 +4,7 @@
{ "path": "pages/home/home", "style": { "navigationStyle": "custom", "navigationBarTitleText": "海外仓工作台", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/module/module", "style": { "navigationStyle": "custom", "navigationBarTitleText": "海外仓业务", "backgroundColor": "#F5F7FA" } },
{ "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-packing/return-packing", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货合箱", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/pallet/pallet", "style": { "navigationStyle": "custom", "navigationBarTitleText": "托盘管理", "backgroundColor": "#F5F7FA" } },
+98 -36
View File
@@ -5,12 +5,14 @@
<view class="work-heading"><text class="work-title">{{ title }}</text><text class="work-subtitle">{{ stageSubtitle }}</text></view>
<view v-if="stage === 'execute'" class="work-card"><button class="secondary" :disabled="busy" @tap="goToClaimList">返回换标列表</button></view>
<view class="row-head"><text class="card-title">{{ candidateHeading }}</text><button v-if="stage !== 'review'" class="link-button" :disabled="loading" @tap="loadCandidates">{{ loading ? '刷新中…' : '刷新' }}</button></view>
<template v-if="stage !== 'execute' || !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 }" @tap="openExecution(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 class="task-list"><view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card"><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></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>
@@ -22,28 +24,26 @@
<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 v-if="lastCarton" class="success-box"><text class="meta-line">仓库唛头:{{ lastCarton.cartonMark }}</text><text class="card-title">请贴 FBA 箱唛:{{ lastCarton.fbaCartonMark }}</text></view></view>
<view v-if="!visibleItems.length" class="empty-box"><text class="empty-copy">{{ taskTab === 'sku' ? '本单没有 SKU 换标明细' : '本单没有箱唛换标明细' }}</text></view>
<view v-else 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 || '—' }}</text><text class="status-pill" :class="{ good: remaining(item) === 0 }">待处理 {{ remaining(item) }} 箱</text></view>
<template v-if="taskTab === 'sku'"><view v-for="(sku, index) in skuDetails(item)" :key="index" class="sku-detail"><text class="meta-line">旧 SKU:{{ sku.skuCode || '—' }}</text><text class="meta-line">新 SKU:{{ sku.newSkuCode || '—' }}</text><text class="meta-line">数量:{{ sku.quantity == null ? '—' : sku.quantity }} 件</text></view></template>
<template v-else><text class="meta-line">新 FBA 箱唛:{{ item.fbaCartonMark || '—' }}</text><text class="meta-line">箱数:{{ item.expectedPackages }}</text></template>
<text v-if="item.productNameZh" class="meta-line">{{ item.productNameZh }}</text><text v-if="item.labelRequirement" class="meta-line">{{ item.labelRequirement }}</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="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>
<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>
<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></view>
<template v-if="taskTab === 'sku' && selectedItem && remaining(selectedItem) > 0">
<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>
<ScanTargetInput name="oldLabel" v-model="oldLabel" label="仓库唛头" placeholder="扫描或输入仓库唛头" :active="activeTarget === 'old'" @activate="activeTarget = 'old'" @submit="handleOldLabel" />
<template v-if="taskTab === 'sku' && action === 'RELABEL'"><view v-for="(sku, index) in unitDraft.skuVerifications" :key="index" class="sku-detail">
<text class="meta-line">{{ skuDetails(selectedItem)[index]?.skuCode }} → {{ skuDetails(selectedItem)[index]?.newSkuCode }} · {{ sku.quantity }} 件</text>
<ScanTargetInput :name="`sku-old-${index}`" v-model="sku.skuCode" label="旧 SKU" placeholder="扫描或输入旧 SKU" :active="activeTarget === `sku-old-${index}`" @activate="activeTarget = `sku-old-${index}`" @submit="activeTarget = `sku-new-${index}`" />
<ScanTargetInput :name="`sku-new-${index}`" v-model="sku.newSkuCode" label="新 SKU" placeholder="扫描或输入新 SKU" :active="activeTarget === `sku-new-${index}`" @activate="activeTarget = `sku-new-${index}`" />
</view></template>
<ScanTargetInput v-if="taskTab === 'carton' && requiresNewCartonLabel" name="newLabel" v-model="newLabel" label="新 FBA 箱唛" placeholder="扫描或输入新 FBA 箱唛" :active="activeTarget === 'new'" @activate="activeTarget = 'new'" @submit="handleNewLabel" />
<text v-if="action === 'RELABEL' && selectedItem.operationType === 'CARTON_AND_SKU'" class="hint">箱唛作业:{{ selectedItem.cartonProcessedPackages > 0 ? '已完成' : '待处理' }} · SKU 作业:{{ selectedItem.skuProcessedPackages > 0 ? '已完成' : '待处理' }}</text>
<template v-if="action !== 'RELABEL'"><textarea v-model="reason" class="reason-input section-gap" maxlength="1000" placeholder="必填:跳过或异常原因" /><view v-if="action === 'EXCEPTION'" class="mode-row section-gap"><button v-for="item in exceptionCodes" :key="item.key" class="mode-button" :class="{ active: exceptionCode === item.key }" @tap="exceptionCode = item.key">{{ item.name }}</button></view></template>
<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" />
<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">
@@ -53,7 +53,8 @@
<view class="photo-actions"><button class="link-button" :disabled="busy" @tap="retakePhoto(index)">重拍</button><button class="link-button" :disabled="busy" @tap="removePhoto(index)">移除</button></view>
</view></view>
<textarea v-model="remark" class="reason-input section-gap" maxlength="900" :placeholder="allProcessed ? '完成备注(选填)' : '必填:未全部扫描完成的仓库异常作业备注'" />
<button class="primary" :disabled="busy || !photoFileIds.length || (!allProcessed && !remark.trim())" @tap="completeTask">{{ busyAction === 'complete' ? '提交中…' : allProcessed ? '完成现场换标并等待复核' : '手动完成并登记异常' }}</button>
<view v-if="completionBlockReason" class="error-box section-gap"><text class="error-title">还不能完成换标</text><text>{{ completionBlockReason }}</text></view>
<button class="primary" :disabled="busy || Boolean(completionBlockReason)" @tap="completeTask">{{ busyAction === 'complete' ? '提交中…' : allProcessed ? '完成现场换标并等待复核' : '手动完成并登记异常' }}</button>
</view>
</template>
</template>
@@ -68,21 +69,22 @@
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import RelabelPalletWork from '@/components/RelabelPalletWork.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { claimRelabelTask, completeRelabelTask, getRelabelTask, listRelabelCandidates, relabelUnit, reportRelabelException, reviewRelabelTask, skipRelabelUnit, uploadRelabelPhoto } from '@/api/relabel.js'
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 { scanRelabelCarton, listRelabelDocuments, downloadRelabelDocument, downloadRelabelPhoto } from '@/api/relabel.js'
import { scanRelabelSku, scanRelabelCarton, 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 },
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: '其他' }] } },
computed: {
showCurrent() { if (!this.current || this.stage === 'review') return false; if (this.stage === 'execute') return this.current.taskStatus === 'PROCESSING' && this.current.claimedByCurrentOperator; return !this.current.taskStatus || this.current.taskStatus === 'AVAILABLE' || (this.current.taskStatus === 'PROCESSING' && this.current.claimedByCurrentOperator) },
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))) },
unitDraft() { return this.unitDrafts?.[this.selectedItem?.itemId] || { cartonMark: '', fbaCartonMark: '', skuVerifications: [] } },
@@ -92,7 +94,8 @@
candidateHeading() { return this.stage === 'review' ? '待复核任务' : this.stage === 'execute' ? '处理中任务' : '换标任务' },
emptyText() { return `暂无${this.candidateHeading}` },
visibleCandidates() { const candidates = this.candidates; if (this.stage === 'review') return candidates.filter((item) => item.taskStatus === 'WAIT_REVIEW'); if (this.stage === 'execute') return candidates.filter((item) => item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator); return candidates.filter((item) => !item.taskStatus || item.taskStatus === 'AVAILABLE' || (item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator)) },
visibleItems() { return (this.current?.items || []).filter((item) => this.taskTab === 'sku' ? ['SKU_LABEL', 'CARTON_AND_SKU'].includes(item.operationType) : ['CARTON_LABEL', 'CARTON_AND_SKU'].includes(item.operationType)) },
visibleItems() { return (this.current?.items || []).filter((item) => (this.taskTab === 'sku' ? ['SKU_LABEL', 'CARTON_AND_SKU'].includes(item.operationType) : ['CARTON_LABEL', 'CARTON_AND_SKU'].includes(item.operationType)) && this.remaining(item) > 0) },
pendingSkuRows() { return this.visibleItems.flatMap(item => this.skuDetails(item).map((sku, index) => ({ item, sku, index })).filter(row => !this.skuDone(row.item.itemId, row.index))) },
requiresNewCartonLabel() { return this.action === 'RELABEL' && this.selectedItem && this.selectedItem.operationType !== 'SKU_LABEL' },
taskCode: { get() { return warehouseWork.relabel.currentNo }, set(value) { updateWarehouseWork('relabel', { currentNo: value }) } },
oldLabel: { get() { return this.unitDraft.cartonMark }, set(value) { if (this.selectedItem) this.unitDraft.cartonMark = value } },
@@ -102,11 +105,25 @@
remark: { get() { return warehouseWork.relabel.remark }, set(value) { updateWarehouseWork('relabel', { remark: value }) } },
photoFileIds() { return warehouseWork.relabel.photoFileIds || [] }, photoNames() { return warehouseWork.relabel.photoNames || [] },
allProcessed() { return Boolean(this.current?.expectedPackages) && this.current.relabelledPackages + this.current.skippedPackages + this.current.exceptionPackages === this.current.expectedPackages },
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))
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 '请先在“换标后组托”中完成本轮已换标箱的拆托合托绑定'
}
if (!this.photoFileIds.length) return '请至少上传 1 张换标完成照片'
return ''
},
canSubmitUnit() { if (!this.selectedItem || !this.visibleItems.some((item) => item.itemId === this.selectedItem.itemId) || !this.oldLabel.trim()) return false; if (this.action === 'RELABEL') return this.cartonVerified && this.skuVerified; return Boolean(this.reason.trim()) },
unitActionText() { return ({ RELABEL: '确认本箱换标完成', SKIP: '确认跳过本箱', EXCEPTION: '登记本箱异常' })[this.action] }
},
onLoad(options) { this.stage = STAGES[options.op] || 'receive' },
mixins: [{ data() { return { documents: [], quickCarton: '', lastCarton: null, photoUrls: {}, photoErrors: {} } } }],
mixins: [{ data() { return { documents: [], quickCarton: '', lastCarton: null, quickSku: '', lastSku: '', skuChoices: [], photoUrls: {}, photoErrors: {} } } }],
watch: {
'current.reviewedAt'(value) { if (this.current?.reviewDecision === 'REWORK' && warehouseWork.relabel.reworkRound !== `${this.current.relabelNo}:${value}`) { this.unitDrafts = {}; this.selectedItem = null; this.lastCarton = null; this.quickCarton = ''; updateWarehouseWork('relabel', { reworkRound: `${this.current.relabelNo}:${value}`, photoFileIds: [], photoNames: [], remark: '' }) } },
photoFileIds: { deep: true, handler(ids) { for (const id of ids) if (!this.photoUrls[id]) this.loadPhoto(id) } },
@@ -115,6 +132,30 @@
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); if (this.stage === 'execute') this.startScanner(); this.bootstrap() },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: {
skuDone(itemId, index) { return (this.current?.history || []).some(entry => { if (entry.action !== 'SKU_SCAN') return false; try { const data = JSON.parse(entry.detail); return String(data.itemId) === String(itemId) && data.skuIndex === index && data.round === (this.current.reviewedAt || 'INITIAL') } catch { return false } }) },
async submitSku(raw) {
const oldSku = String(raw?.detail?.value ?? raw ?? this.quickSku).trim()
if (!oldSku || this.busy || !this.current) return
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 > 1) { this.skuChoices = matches; return }
await this.commitSku(matches[0])
},
async commitSku(row) {
if (this.busy || this.quickSku.trim() !== row.sku.skuCode) return
const payload = { itemId: row.item.itemId, skuIndex: row.index, oldSku: this.quickSku.trim(), expectedVersion: this.current.version }
const key = warehouseIdempotencyKey('relabel', 'sku-scan', { relabelNo: this.current.relabelNo, ...payload })
await this.run('sku-scan', async () => {
const value = await scanRelabelSku(this.current.relabelNo, { ...payload, idempotencyKey: key.value })
this.applyCurrent(value); releaseWarehouseKey('relabel', key.signature)
const confirmed = value.items.find(item => item.itemId === row.item.itemId)?.skuItems?.[row.index]
this.lastSku = confirmed ? `${confirmed.skuCode} 换标 ${confirmed.newSkuCode} · ${confirmed.quantity} 件` : '已保存,请重新读取标签绑定信息'
this.quickSku = ''; this.skuChoices = []; this.activeTarget = 'quick-sku'
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] || '换标类型待确认' },
async loadPhoto(fileId) {
const no = this.current?.relabelNo
@@ -162,33 +203,54 @@
this.applyCurrent(result)
releaseWarehouseKey('relabel', key.signature)
const confirmedItem = result.items.find((row) => row.itemId === item.itemId)
this.lastCarton = { cartonMark: mark, fbaCartonMark: confirmedItem?.fbaCartonMark || item.fbaCartonMark }
this.lastCarton = { cartonMark: mark, fbaCartonMark: confirmedItem?.fbaCartonMark || item.fbaCartonMark, destinationFbaWarehouseCode: confirmedItem?.destinationFbaWarehouseCode || item.destinationFbaWarehouseCode }
this.quickCarton = ''
this.activeTarget = 'quick-carton'
this.successMessage = `本箱已处理,请贴 FBA 箱唛:${this.lastCarton.fbaCartonMark}`
await new Promise((resolve) => uni.showModal({ title: '请粘贴对应箱唛', content: `仓库唛头:${mark}\n请贴 FBA 箱唛:${this.lastCarton.fbaCartonMark}`, showCancel: false, confirmText: '知道了', complete: resolve }))
this.successMessage = ''
await new Promise((resolve) => uni.showModal({ title: '请粘贴对应箱唛', content: `仓库唛头:${mark}\n请贴 FBA 箱唛:${this.lastCarton.fbaCartonMark}\nFBA 目的仓:${this.lastCarton.destinationFbaWarehouseCode || '待维护'}`, showCancel: false, confirmText: '知道了', complete: resolve }))
}, true, key)
},
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 === 'quick-carton') { this.quickCarton = value; this.submitCarton(value) } else this.handleHardwareScan(value) }, onError: (error) => this.setError(error, '扫码广播注册失败') }) },
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) { const skuTarget = /^sku-(old|new)-(\d+)$/.exec(this.activeTarget); if (skuTarget && this.selectedItem) { const row = this.unitDraft.skuVerifications[Number(skuTarget[2])]; if (row) row[skuTarget[1] === 'old' ? 'skuCode' : 'newSkuCode'] = String(value).trim(); if (skuTarget[1] === 'old') this.activeTarget = `sku-new-${skuTarget[2]}`; return } if (this.activeTarget === 'old') { this.oldLabel = value; this.activeTarget = this.taskTab === 'sku' ? 'sku-old-0' : this.action === 'RELABEL' ? 'new' : 'old'; return } if (this.activeTarget === 'new') { this.newLabel = 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-old-0' : 'new' },
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) },
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.stage === 'execute' && this.taskCode) await this.openTask(this.taskCode, false) },
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.activeTarget = value === 'carton' ? 'quick-carton' : 'task' },
changeTab(value) { this.taskTab = value; this.selectedItem = null; this.reason = ''; this.quickSku = ''; this.skuChoices = []; this.activeTarget = 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: '', newSkuCode: '', quantity: sku.quantity })) }; this.selectedItem = item; this.reason = ''; this.activeTarget = 'sku-old-0' },
remaining(item) { const processed = this.taskTab === 'sku' ? item.skuProcessedPackages : item.cartonProcessedPackages; return Math.max(0, item.expectedPackages - Number(processed ?? item.relabelledPackages) - item.skippedPackages - item.exceptionPackages) },
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' },
remaining(item) { const processed = this.taskTab === 'sku' ? item.skuProcessedPackages : item.cartonProcessedPackages; const completed = item.operationType === 'CARTON_AND_SKU' ? Number(processed || 0) : Number(processed ?? item.relabelledPackages); return Math.max(0, item.expectedPackages - completed - item.skippedPackages - item.exceptionPackages) },
changeAction(value) { this.action = value; this.newLabel = ''; this.reason = ''; this.activeTarget = 'old' },
async claimTask() { if (!this.current) return; const payload = { relabelNo: this.current.relabelNo }; const key = warehouseIdempotencyKey('relabel', 'claim', payload); await this.run('claim', async () => { this.applyCurrent(await claimRelabelTask(this.current.relabelNo, { idempotencyKey: key.value })); releaseWarehouseKey('relabel', key.signature); this.successMessage = '换标任务已认领,可进入换标执行'; await this.loadCandidates() }, true, key) },
async promptActiveTask(active) {
if (this.busy) return
this.busy = true
this.error = null
try {
const confirmed = await new Promise((resolve) => uni.showModal({
title: '已有正在执行的换标任务',
content: `当前账号正在执行 ${active.relabelNo},请先完成该任务。是否前往继续作业?`,
confirmText: '前往任务', cancelText: '取消',
success: (result) => resolve(result.confirm), fail: () => resolve(false)
}))
if (!confirmed) return
this.taskCode = active.relabelNo
uni.redirectTo({ url: '/pages/relabel/relabel?op=relabelExecute' })
} finally { this.busy = false }
},
async openExecution(item) {
if (this.busy || this.stage !== 'receive') return
const active = this.candidates.find((candidate) => candidate.taskStatus === 'PROCESSING' && candidate.claimedByCurrentOperator)
if (active && active.relabelNo !== item.relabelNo) return this.setError(new Error(`当前账号正在执行 ${active.relabelNo},请先完成后再操作其他换标单`))
if (this.busy || this.stage === 'review') return
if (this.stage === 'execute') {
if (item.taskStatus !== 'PROCESSING' || item.claimedByCurrentOperator !== true) return
return this.openTask(item.relabelNo)
}
const active = this.candidates.find((candidate) => candidate.taskStatus === 'PROCESSING' && candidate.claimedByCurrentOperator === true)
if (active && active.relabelNo !== item.relabelNo) return this.promptActiveTask(active)
this.taskCode = item.relabelNo
if (item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator) return uni.redirectTo({ url: '/pages/relabel/relabel?op=relabelExecute' })
const payload = { relabelNo: item.relabelNo }; const key = warehouseIdempotencyKey('relabel', 'claim', payload); let claimed = false
@@ -196,7 +258,7 @@
if (claimed) uni.redirectTo({ url: '/pages/relabel/relabel?op=relabelExecute' })
},
async submitUnit() { if (!this.canSubmitUnit || this.busy) return; const base = { itemId: this.selectedItem.itemId, oldLabel: this.oldLabel.trim(), expectedVersion: this.current.version }; const payload = { relabelNo: this.current.relabelNo, action: this.action, ...base, newLabel: this.newLabel.trim(), skuVerifications: this.unitDraft.skuVerifications.map((sku) => ({ ...sku, skuCode: sku.skuCode.trim(), newSkuCode: sku.newSkuCode.trim() })), reason: this.reason.trim(), exceptionCode: this.exceptionCode }; const key = warehouseIdempotencyKey('relabel', 'unit', payload); await this.run('unit', async () => { let value; if (this.action === 'SKIP') value = await skipRelabelUnit(this.current.relabelNo, { ...base, reason: this.reason.trim(), idempotencyKey: key.value }); else if (this.action === 'EXCEPTION') value = await reportRelabelException(this.current.relabelNo, { ...base, exceptionCode: this.exceptionCode, reason: this.reason.trim(), idempotencyKey: key.value }); else value = await relabelUnit(this.current.relabelNo, { ...base, newLabel: this.newLabel.trim(), skuVerifications: payload.skuVerifications, idempotencyKey: key.value }); releaseWarehouseKey('relabel', key.signature); this.applyCurrent(value); this.oldLabel = ''; this.newLabel = ''; this.reason = ''; this.activeTarget = 'old'; this.successMessage = '本箱处理结果已保存' }, true, key) },
choosePhoto() { if (this.busy || !this.current || this.photoFileIds.length >= 20) return; const no = this.current.relabelNo; uni.chooseImage({ count: Math.min(9, 20 - this.photoFileIds.length), sourceType: ['camera', 'album'], success: async ({ tempFilePaths, tempFiles }) => { if (this.current?.relabelNo !== no) return; await this.run('photo', async () => { for (const [index, path] of (tempFilePaths || []).entries()) { if (Number(tempFiles?.[index]?.size || 0) > 10 * 1024 * 1024) throw new Error('照片不能超过 10MB'); const file = await uploadRelabelPhoto(no, path); this.photoUrls[String(file.fileId)] = path; updateWarehouseWork('relabel', { photoTaskNo: no, photoFileIds: [...this.photoFileIds, String(file.fileId)], photoNames: [...this.photoNames, file.fileName || '换标现场照片'] }) } this.successMessage = '现场照片已上传' }, false) } }) },
choosePhoto() { if (this.busy || !this.current || this.photoFileIds.length >= 20) return; const no = this.current.relabelNo; uni.chooseImage({ count: Math.min(9, 20 - this.photoFileIds.length), sourceType: ['camera', 'album'], success: async ({ tempFilePaths, tempFiles }) => { if (this.current?.relabelNo !== no) return; await this.run('photo', async () => { for (const [index, path] of (tempFilePaths || []).entries()) { const selected = tempFiles?.[index]?.file || tempFiles?.[index]; if (Number(tempFiles?.[index]?.size || 0) > 10 * 1024 * 1024) throw new Error('照片不能超过 10MB'); const uploadSource = typeof Blob !== 'undefined' && selected instanceof Blob ? selected : path; const file = await uploadRelabelPhoto(no, uploadSource); this.photoUrls[String(file.fileId)] = path; updateWarehouseWork('relabel', { photoTaskNo: no, photoFileIds: [...this.photoFileIds, String(file.fileId)], photoNames: [...this.photoNames, file.fileName || '换标现场照片'] }) } this.successMessage = '现场照片已上传' }, false) } }) },
async uploadPhoto(path) { await this.run('photo', async () => { const file = await uploadRelabelPhoto(this.current.relabelNo, path); updateWarehouseWork('relabel', { photoFileIds: [...this.photoFileIds, String(file.fileId)], photoNames: [...this.photoNames, file.fileName || '换标现场照片'] }); this.successMessage = '现场照片已上传' }, false) },
async completeTask() { if (this.busy || !this.photoFileIds.length || (!this.allProcessed && !this.remark.trim())) return; const payload = { photoFileIds: [...this.photoFileIds], expectedVersion: this.current.version, remark: this.remark.trim(), manualCompletion: !this.allProcessed }; const no = this.current.relabelNo; if (payload.manualCompletion) { const confirmed = await new Promise((resolve) => uni.showModal({ title: '手动完成换标', content: '未处理箱将登记异常,关联尾程订单将标为异常。确认提交?', success: (result) => resolve(result.confirm), fail: () => resolve(false) })); if (!confirmed || this.current?.relabelNo !== no || this.current.version !== payload.expectedVersion) return } const key = warehouseIdempotencyKey('relabel', 'complete', { relabelNo: no, ...payload }); let completed = false; await this.run('complete', async () => { await completeRelabelTask(no, { ...payload, idempotencyKey: key.value }); releaseWarehouseKey('relabel', key.signature); updateWarehouseWork('relabel', { currentNo: '', photoFileIds: [], photoNames: [], remark: '' }); completed = true }, true, key); if (completed) this.goToClaimList() },
goToClaimList() { this.taskCode = ''; uni.redirectTo({ url: '/pages/relabel/relabel?op=relabelReceive' }) },
File diff suppressed because one or more lines are too long