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
+6
View File
@@ -56,6 +56,12 @@
## 海外业务接口边界 ## 海外业务接口边界
- 换标目标确认即提交拆托合托,不再二次点击底部提交。`RelabelPalletWork.vue` 按当前轮次 `PALLET_BIND` 最新记录提供逐箱改绑,扫描新目标后弹窗确认,使用既有 `relabel-disassemble` 精确来源路径(不传 `actualSource`);同目标不发写请求。服务器持有库存、托盘版本与幂等约束,已排车或出库禁止直接改绑。离线回归 `tests/relabel-pallet-work.test.mjs`,不等同真机验收。
- SKU 正常作业固定显示一个旧SKU扫描框,通过 `relabel-tasks/{relabelNo}/skus/scan` 提交 itemId、skuIndex 和旧SKU,不要求扫描新标签;多箱同SKU先选择具体明细。服务端每条保存 `SKU_SCAN` 历史,页面按当前返工轮次隐藏已完成子项;全部SKU完成才完成箱的SKU阶段,混合单仍需箱唛阶段。箱唛待处理列表同样隐藏已完成项。回归 `tests/relabel-quick-sku.test.mjs`。
- 换标执行页内嵌 `src/components/RelabelPalletWork.vue`:`relabel-sources` 展示原入仓单各托盘箱数;选择来源库存行并扫描实托、输入拆出箱数、核对本批已完成换标箱,再扫描目标托盘合托。每次一个来源库存行,可分批处理多个托盘,无需为拆托额外逐箱扫码。`relabel-disassemble.actualSource` 原子调整本单冻结记账来源并移动库存,目标仍归原入仓单,不新建入库。仅同新目的仓合托;新增托盘不等待客户确认,409后刷新重扫。H5 提供标签打印,原生打印设备适配尚未完成;不能宣称生成托盘号即打印成功。回归 `tests/relabel-pallet-work.test.mjs`。
- 换标任务标明类型,仅显示实际明细需要的 Tab。混合明细同时进入箱唛/SKU 作业,服务端分开保存两部分进度,`cartonProcessedPackages/skuProcessedPackages` 决定各 Tab 待处理数量;两项均完成后整箱计数一次,不能仅扫箱唛就视为 SKU 完成。 - 换标任务标明类型,仅显示实际明细需要的 Tab。混合明细同时进入箱唛/SKU 作业,服务端分开保存两部分进度,`cartonProcessedPackages/skuProcessedPackages` 决定各 Tab 待处理数量;两项均完成后整箱计数一次,不能仅扫箱唛就视为 SKU 完成。
- 换标完工提交使 PDA 任务进入 `WAIT_REVIEW`,申请进入 `CUSTOMER_CONFIRM_PENDING`,客户确认原子完成复核并释放后续履约资格。PDA 复核池只读;平台通过专用 `/rework` 接口携带任务版本、幂等键和必填备注退回,历史事实归档后重新计数,PDA 展示 `reviewRemark` 并清空上一轮输入和照片草稿。 - 换标完工提交使 PDA 任务进入 `WAIT_REVIEW`,申请进入 `CUSTOMER_CONFIRM_PENDING`,客户确认原子完成复核并释放后续履约资格。PDA 复核池只读;平台通过专用 `/rework` 接口携带任务版本、幂等键和必填备注退回,历史事实归档后重新计数,PDA 展示 `reviewRemark` 并清空上一轮输入和照片草稿。
+48 -2
View File
@@ -42,6 +42,13 @@ async function tokenAfterUnauthorized(failedToken) {
function overseasUrl(path) { function overseasUrl(path) {
const base = String(session.apiBase || '').replace(/\/$/, '') 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 value = `/${String(path || '').replace(/^\/+/, '')}`
const normalized = /\/(?:api|overseas-api)$/.test(base) const normalized = /\/(?:api|overseas-api)$/.test(base)
? value.replace(/^\/api(?=\/)/, '') ? value.replace(/^\/api(?=\/)/, '')
@@ -79,14 +86,53 @@ export async function overseasRequest(method, path, data, retry = true) {
} }
function rawUpload(path, filePath, name, token) { 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) => { return new Promise((resolve, reject) => {
uni.uploadFile({ uni.uploadFile({
url: overseasUrl(path), url,
filePath, filePath,
name, name,
header: { Authorization: `Bearer ${token}` }, header: { Authorization: `Bearer ${token}` },
success: resolve, 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' import { overseasRequest } from './overseas.js'
const BASE = '/pda/v1/last-leg/pallet-operations' 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) { export function resolvePallet(code) {
return overseasRequest('POST', `${BASE}/pallets/resolve`, { code: String(code || '').trim() }) 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 BASE = '/pda/v1/last-leg/relabel-tasks'
const encoded = (value) => encodeURIComponent(String(value || '').trim()) 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 : [] } 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. // root there, even if a stale H5 bundle or compiler flag was reused.
if (nativeRuntime) { if (nativeRuntime) {
selectedAuthApiRoot = configuredAuthApiRoot || selectedAuthApiRoot 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(/\/$/, '') export const API_ROOT = String(selectedAuthApiRoot).replace(/\/$/, '')
+1
View File
@@ -1,6 +1,7 @@
export const MODULES = [ export const MODULES = [
{ key: 'inbound', name: '入仓作业', nameKey: 'modules.inbound.name', icon: '入', description: '处理拆柜收货与客户退货实收入仓', descriptionKey: 'modules.inbound.description', operations: [ { 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: '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: 'returnReceipt', name: '退货入库', nameKey: 'modules.returnReceipt.name', icon: '退', iconType: 'undo', description: '扫描退货单或 SKU,按本包实收数量生成退货库存', descriptionKey: 'modules.returnReceipt.description', available: true, route: '/pages/return-receipt/return-receipt' },
{ key: 'returnPacking', name: '退货合箱', nameKey: 'modules.returnPacking.name', icon: '合', iconType: 'folder-add', description: '选择同客户已签收退货单,按 SKU 数量生成新箱', descriptionKey: 'modules.returnPacking.description', available: true, route: '/pages/return-packing/return-packing' } { key: 'returnPacking', name: '退货合箱', nameKey: 'modules.returnPacking.name', icon: '合', iconType: 'folder-add', description: '选择同客户已签收退货单,按 SKU 数量生成新箱', descriptionKey: 'modules.returnPacking.description', available: true, route: '/pages/return-packing/return-packing' }
] }, ] },
+1
View File
@@ -4,6 +4,7 @@
{ "path": "pages/home/home", "style": { "navigationStyle": "custom", "navigationBarTitleText": "海外仓工作台", "backgroundColor": "#F5F7FA" } }, { "path": "pages/home/home", "style": { "navigationStyle": "custom", "navigationBarTitleText": "海外仓工作台", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/module/module", "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/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-receipt/return-receipt", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货入库", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/return-packing/return-packing", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货合箱", "backgroundColor": "#F5F7FA" } }, { "path": "pages/return-packing/return-packing", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货合箱", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/pallet/pallet", "style": { "navigationStyle": "custom", "navigationBarTitleText": "托盘管理", "backgroundColor": "#F5F7FA" } }, { "path": "pages/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 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 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-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="!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-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> <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"> <view v-if="showCurrent" class="work-card">
<text class="status-pill">换标类型:{{ relabelTypeName(current.operationType) }}</text> <text class="status-pill">换标类型:{{ relabelTypeName(current.operationType) }}</text>
<view class="row-head"><view><text class="business-code">{{ current.relabelNo }}</text><text class="meta-line">客户订单:{{ current.customerOrderNo || '—' }}</text><text class="meta-line">标签截止:{{ formatTime(current.labelDeadline) }}</text></view><text class="status-pill">{{ statusLabel(current.taskStatus || current.applicationStatus) }}</text></view> <view class="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> <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> <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> <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="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="!visibleItems.length" class="empty-box"><text class="empty-copy">{{ taskTab === 'sku' ? '本单没有 SKU 换标明细' : '本单没有箱唛换标明细' }}</text></view> <view v-if="taskTab === 'sku'" class="section-gap">
<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)"> <ScanTargetInput name="quickSku" v-model="quickSku" label="旧 SKU 标签" placeholder="扫描或输入旧 SKU 标签" :active="activeTarget === 'quick-sku'" @activate="activeTarget = 'quick-sku'" @submit="submitSku" />
<view class="row-head"><text class="business-code">仓库唛头:{{ item.cartonMark || '—' }}</text><text class="status-pill" :class="{ good: remaining(item) === 0 }">待处理 {{ remaining(item) }} 箱</text></view> <button class="primary" :disabled="busy || !quickSku.trim()" @tap="submitSku(quickSku)">确认 SKU</button>
<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> <text v-if="skuChoices.length" class="hint">同一旧 SKU 有多条明细,请核对实际箱选择本次作业:</text>
<template v-else><text class="meta-line">新 FBA 箱唛:{{ item.fbaCartonMark || '—' }}</text><text class="meta-line">箱数:{{ item.expectedPackages }}</text></template> <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>
<text v-if="item.productNameZh" class="meta-line">{{ item.productNameZh }}</text><text v-if="item.labelRequirement" class="meta-line">{{ item.labelRequirement }}</text> <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> </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> <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" /> <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> <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> <button class="primary" :disabled="busy || !canSubmitUnit" @tap="submitUnit">{{ busyAction === 'unit' ? '提交中…' : unitActionText }}</button>
</template> </template>
<view class="section-gap"> <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> <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> <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"> <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 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> </view></view>
<textarea v-model="remark" class="reason-input section-gap" maxlength="900" :placeholder="allProcessed ? '完成备注(选填)' : '必填:未全部扫描完成的仓库异常作业备注'" /> <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> </view>
</template> </template>
</template> </template>
@@ -68,21 +69,22 @@
<script> <script>
import PortalHeader from '@/components/PortalHeader.vue' import PortalHeader from '@/components/PortalHeader.vue'
import RelabelPalletWork from '@/components/RelabelPalletWork.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue' import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { claimRelabelTask, completeRelabelTask, getRelabelTask, listRelabelCandidates, relabelUnit, reportRelabelException, reviewRelabelTask, skipRelabelUnit, uploadRelabelPhoto } from '@/api/relabel.js' import { claimRelabelTask, completeRelabelTask, getRelabelTask, listRelabelCandidates, relabelUnit, reportRelabelException, reviewRelabelTask, skipRelabelUnit, uploadRelabelPhoto } from '@/api/relabel.js'
import { session, requireSession } from '@/stores/session.js' import { session, requireSession } from '@/stores/session.js'
import { releaseWarehouseKey, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js' import { releaseWarehouseKey, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.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' import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
const STAGES = { relabelReceive: 'receive', relabelExecute: 'execute', relabelReview: 'review' } const STAGES = { relabelReceive: 'receive', relabelExecute: 'execute', relabelReview: 'review' }
const isConflict = (error) => ['409', 'A0409', 'B0409'].includes(String(error?.code || '')) const isConflict = (error) => ['409', 'A0409', 'B0409'].includes(String(error?.code || ''))
export default { 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: '其他' }] } }, 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: { 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' ? '仅展示当前账号已认领且正在处理的换标任务' : '点击任意换标单直接进入执行;同一账号必须先完成当前任务才能处理其他换标单' }, 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))) }, 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: [] } }, unitDraft() { return this.unitDrafts?.[this.selectedItem?.itemId] || { cartonMark: '', fbaCartonMark: '', skuVerifications: [] } },
@@ -92,7 +94,8 @@
candidateHeading() { return this.stage === 'review' ? '待复核任务' : this.stage === 'execute' ? '处理中任务' : '换标任务' }, candidateHeading() { return this.stage === 'review' ? '待复核任务' : this.stage === 'execute' ? '处理中任务' : '换标任务' },
emptyText() { return `暂无${this.candidateHeading}` }, 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)) }, 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' }, requiresNewCartonLabel() { return this.action === 'RELABEL' && this.selectedItem && this.selectedItem.operationType !== 'SKU_LABEL' },
taskCode: { get() { return warehouseWork.relabel.currentNo }, set(value) { updateWarehouseWork('relabel', { currentNo: value }) } }, 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 } }, 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 }) } }, remark: { get() { return warehouseWork.relabel.remark }, set(value) { updateWarehouseWork('relabel', { remark: value }) } },
photoFileIds() { return warehouseWork.relabel.photoFileIds || [] }, photoNames() { return warehouseWork.relabel.photoNames || [] }, 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 }, 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()) }, 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] } unitActionText() { return ({ RELABEL: '确认本箱换标完成', SKIP: '确认跳过本箱', EXCEPTION: '登记本箱异常' })[this.action] }
}, },
onLoad(options) { this.stage = STAGES[options.op] || 'receive' }, 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: { 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: '' }) } }, '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) } }, 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() }, 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() }, onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: { 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] || '换标类型待确认' }, relabelTypeName(type) { return ({ CARTON_LABEL: '只换箱唛', SKU_LABEL: '只换 SKU', CARTON_AND_SKU: '混合换标(SKU + 箱唛)' })[type] || '换标类型待确认' },
async loadPhoto(fileId) { async loadPhoto(fileId) {
const no = this.current?.relabelNo const no = this.current?.relabelNo
@@ -162,33 +203,54 @@
this.applyCurrent(result) this.applyCurrent(result)
releaseWarehouseKey('relabel', key.signature) releaseWarehouseKey('relabel', key.signature)
const confirmedItem = result.items.find((row) => row.itemId === item.itemId) 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.quickCarton = ''
this.activeTarget = 'quick-carton' this.activeTarget = 'quick-carton'
this.successMessage = `本箱已处理,请贴 FBA 箱唛:${this.lastCarton.fbaCartonMark}` this.successMessage = ''
await new Promise((resolve) => uni.showModal({ title: '请粘贴对应箱唛', content: `仓库唛头:${mark}\n请贴 FBA 箱唛:${this.lastCarton.fbaCartonMark}`, showCancel: false, confirmText: '知道了', complete: resolve })) 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) }, true, key)
}, },
removePhoto(index) { updateWarehouseWork('relabel', { photoFileIds: this.photoFileIds.filter((_, i) => i !== index), photoNames: this.photoNames.filter((_, i) => i !== index) }) }, 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 }, 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) }, 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-old-0' : 'new' }, 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() }, 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 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) }, 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 }, 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 }] }, 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' }, 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' },
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) }, 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' }, 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 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) { async openExecution(item) {
if (this.busy || this.stage !== 'receive') return if (this.busy || this.stage === 'review') return
const active = this.candidates.find((candidate) => candidate.taskStatus === 'PROCESSING' && candidate.claimedByCurrentOperator) if (this.stage === 'execute') {
if (active && active.relabelNo !== item.relabelNo) return this.setError(new Error(`当前账号正在执行 ${active.relabelNo},请先完成后再操作其他换标单`)) 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 this.taskCode = item.relabelNo
if (item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator) return uni.redirectTo({ url: '/pages/relabel/relabel?op=relabelExecute' }) 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 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' }) 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) }, 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 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() }, 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' }) }, goToClaimList() { this.taskCode = ''; uni.redirectTo({ url: '/pages/relabel/relabel?op=relabelReceive' }) },
File diff suppressed because one or more lines are too long
+100
View File
@@ -162,6 +162,106 @@ test('photo upload refreshes and replays once while retaining the file payload',
]) ])
}) })
test('H5 blob photo uses authenticated multipart fetch instead of uni.uploadFile', async () => {
const value = prepareSession()
const originalWindow = globalThis.window
const originalLocation = globalThis.location
const originalFetch = globalThis.fetch
let uploadFileCalls = 0
let uploaded
globalThis.window = {}
globalThis.location = { protocol: 'http:' }
globalThis.uni.uploadFile = () => { uploadFileCalls += 1 }
globalThis.fetch = async (url, options) => {
if (String(url).startsWith('blob:')) return new Response(new Blob(['photo'], { type: 'image/png' }))
uploaded = { url, options }
return new Response(JSON.stringify({ code: '200', data: { fileId: 'h5-1' } }), { status: 200 })
}
try {
session.apiBase = '/overseas-api'
assert.deepEqual(await overseasUpload('/api/pda/v1/last-leg/photos/files', 'blob:http://localhost/photo'), { fileId: 'h5-1' })
assert.equal(uploadFileCalls, 0)
assert.equal(uploaded.url, '/overseas-api/pda/v1/last-leg/photos/files')
assert.equal(uploaded.options.headers.Authorization, `Bearer ${value.overseasAccessToken}`)
assert.ok(uploaded.options.body instanceof FormData)
} finally {
globalThis.fetch = originalFetch
if (originalWindow === undefined) delete globalThis.window
else globalThis.window = originalWindow
if (originalLocation === undefined) delete globalThis.location
else globalThis.location = originalLocation
}
})
test('H5 File photo is appended directly without rereading a temporary blob URL', async () => {
const value = prepareSession()
const originalWindow = globalThis.window
const originalLocation = globalThis.location
globalThis.window = {}
globalThis.location = { protocol: 'http:' }
const selected = new Blob(['photo'], { type: 'image/png' })
let calls = 0
globalThis.uni.uploadFile = (options) => {
calls += 1
assert.equal(options.url, '/overseas-api/pda/photo')
assert.equal(options.header.Authorization, `Bearer ${value.overseasAccessToken}`)
assert.equal(options.files[0].file, selected)
assert.equal(options.files[0].name, 'file')
options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: { fileId: 'direct-file' } }), header: {} })
}
try {
session.apiBase = '/overseas-api'
assert.deepEqual(await overseasUpload('/pda/photo', selected), { fileId: 'direct-file' })
assert.equal(calls, 1)
} finally {
if (originalWindow === undefined) delete globalThis.window
else globalThis.window = originalWindow
if (originalLocation === undefined) delete globalThis.location
else globalThis.location = originalLocation
}
})
test('native file runtime rejects an H5-relative overseas root before upload with a clear configuration error', async () => {
prepareSession()
const originalLocation = globalThis.location
globalThis.location = { protocol: 'file:' }
session.apiBase = '/overseas-api'
let uploadCalls = 0
globalThis.uni.uploadFile = () => { uploadCalls += 1 }
try {
await assert.rejects(overseasUpload('/pda/photo', '_doc/photo.jpg'), (error) => {
assert.equal(error.code, 'PDA_OVERSEAS_API_BASE_REQUIRED')
assert.match(error.message, /VITE_OVERSEAS_API_BASE_URL/)
assert.match(error.message, /HTTP\(S\)/)
return true
})
assert.equal(uploadCalls, 0)
} finally {
if (originalLocation === undefined) delete globalThis.location
else globalThis.location = originalLocation
}
})
test('native file runtime uploads to an absolute overseas root', async () => {
prepareSession()
const originalLocation = globalThis.location
globalThis.location = { protocol: 'file:' }
session.apiBase = 'https://overseas.example.com/api/'
let call
globalThis.uni.uploadFile = (options) => {
call = options
options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: { fileId: 'native-1' } }), header: {} })
}
try {
assert.deepEqual(await overseasUpload('/api/pda/v1/last-leg/photos/files', '_doc/photo.jpg'), { fileId: 'native-1' })
assert.equal(call.url, 'https://overseas.example.com/api/pda/v1/last-leg/photos/files')
assert.equal(call.filePath, '_doc/photo.jpg')
} finally {
if (originalLocation === undefined) delete globalThis.location
else globalThis.location = originalLocation
}
})
test('authenticated photo download maps the public api path through the H5 overseas proxy', async () => { test('authenticated photo download maps the public api path through the H5 overseas proxy', async () => {
const value = prepareSession(); const calls = [] const value = prepareSession(); const calls = []
globalThis.uni.downloadFile = (options) => { globalThis.uni.downloadFile = (options) => {
+32
View File
@@ -0,0 +1,32 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
const source = readFileSync(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
const body = source.split('<script>')[1].split('</script>')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
function setup(confirm) {
const calls = []
const uni = { showModal(options) { calls.push(options); options.success({ confirm }) }, redirectTo(options) { calls.push(options) } }
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'uni', body)({}, {}, {}, uni)
const context = { busy: false, stage: 'receive', taskCode: 'PREVIOUS', error: null, candidates: [{ relabelNo: 'RL-ACTIVE', taskStatus: 'PROCESSING', claimedByCurrentOperator: true }] }
context.promptActiveTask = component.methods.promptActiveTask.bind(context)
return { calls, context, open: () => component.methods.openExecution.call(context, { relabelNo: 'RL-OTHER', taskStatus: 'AVAILABLE' }) }
}
test('conflicting task opens modal and confirm resumes owned task without claiming another', async () => {
const { calls, context, open } = setup(true)
await open()
assert.equal(calls[0].confirmText, '前往任务')
assert.equal(calls[0].cancelText, '取消')
assert.match(calls[0].content, /RL-ACTIVE/)
assert.equal(context.taskCode, 'RL-ACTIVE')
assert.equal(calls[1].url, '/pages/relabel/relabel?op=relabelExecute')
assert.equal(context.error, null)
assert.equal(context.busy, false)
})
test('cancel stays on list and preserves existing task selection', async () => {
const { calls, context, open } = setup(false)
await open()
assert.equal(calls.length, 1)
assert.equal(context.taskCode, 'PREVIOUS')
assert.equal(context.busy, false)
})
+14
View File
@@ -0,0 +1,14 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const page = await readFile(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
test('PDA relabel completion explains pallet and photo blockers instead of silently disabling submit', () => {
assert.match(page, /还不能完成换标/)
assert.match(page, /请先在“换标后组托”中完成本轮已换标箱的拆托合托绑定/)
assert.match(page, /请至少上传 1 张换标完成照片/)
assert.match(page, /:disabled="busy \|\| Boolean\(completionBlockReason\)"/)
assert.match(page, /entry\.action === 'PALLET_BIND'/)
assert.match(page, /detail\.round === round/)
})
+12
View File
@@ -0,0 +1,12 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const page = readFileSync(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
test('PDA 箱唛换标明确展示每箱的新标签和 FBA 目的仓', () => {
assert.match(page, /item\.cartonMark[^\n]+item\.fbaCartonMark/)
assert.match(page, /FBA 目的仓:\{\{ item\.destinationFbaWarehouseCode/)
assert.match(page, /confirmedItem\?\.destinationFbaWarehouseCode \|\| item\.destinationFbaWarehouseCode/)
assert.match(page, /请贴 FBA 箱唛[^`]+FBA 目的仓:\$\{this\.lastCarton\.destinationFbaWarehouseCode/s)
})
+93
View File
@@ -0,0 +1,93 @@
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 script = source.split('<script>')[1].split('</script>')[0].replace(/^import .*$/gm, '').replace('export default', 'return')
const component = new Function('ScanTargetInput', script)({})
function context() {
const value = { ...component.data(), $emit() {}, task: { items: [
{ cartonMark: 'A', relabelledPackages: 1, destinationFbaWarehouseCode: 'ONT8' },
{ cartonMark: 'B', relabelledPackages: 1, destinationFbaWarehouseCode: 'JFK8' },
{ cartonMark: 'C', relabelledPackages: 0, destinationFbaWarehouseCode: 'ONT8' },
{ cartonMark: 'OTHER', relabelledPackages: 1, destinationFbaWarehouseCode: 'ONT8' }
] } }
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'] })
return value
}
test('quantity batch only includes completed boxes from same inbound and destination', () => {
const value = context()
value.quantity = '1'; value.fillBatch(); assert.deepEqual(value.marks, ['A'])
value.quantity = '2'; value.fillBatch(); assert.match(value.error, /不超过/)
value.quantity = '1.5'; value.fillBatch(); assert.match(value.error, /正整数/)
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', () => {
const value = context(); value.quantity = '1'; value.fillBatch()
value.source = { palletCode: 'SOURCE' }; value.sourceCode = 'source'
value.target = { palletCode: 'TARGET' }; value.targetCode = 'TARGET'
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'
assert.equal(value.readyToBind, false)
})
test('quantity disassembly has no extra carton scanner', () => {
assert.doesNotMatch(source, /relabel-move-carton/)
assert.match(source, /actualSource:/)
assert.match(source, /仍归属/)
})
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] }) }))
assert.deepEqual(value.currentBindings, [{ mark: 'A', pallet: 'NEW' }])
value.task.reviewedAt = 'NEW-ROUND'
assert.deepEqual(value.currentBindings, [{ mark: 'A', pallet: 'NEW' }])
value.task.history.push({ action: 'REWORK_ARCHIVE', detail: '{}' })
assert.deepEqual(value.currentBindings, [])
})
test('rescan same bound pallet does not call stock movement', async () => {
const value = context(); value.rebindSelection = { mark: 'A', pallet: 'TARGET' }; value.targetCode = 'target'
let message = ''
value.showBindingMessage = (title, content) => { message = content }
value.run = async () => assert.fail('same pallet must not move stock')
await value.submitRebind()
assert.match(message, /不会重复扣库存/)
})
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'
let binds = 0
value.resolve = async kind => { assert.equal(kind, 'target'); value.target = { palletCode: 'TARGET' } }
value.bind = async () => { binds++ }
await value.confirmTargetAndBind()
assert.equal(binds, 1)
assert.match(source, /@tap="confirmTargetAndBind"/)
assert.doesNotMatch(source, /@tap="bind"/)
})
test('missing source scan shows modal and never submits a binding', async () => {
const value = context(); value.quantity = '1'; value.fillBatch(); value.targetCode = 'TARGET'
let message = ''
value.showBindingMessage = (title, content) => { message = content }
value.bind = async () => assert.fail('must not bind without source confirmation')
value.resolve = async () => assert.fail('must validate source first')
await value.confirmTargetAndBind()
assert.match(message, /扫描并确认实际来源托盘/)
})
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.resolve = async () => { value.error = '目标托盘不可用' }
let title = ''
value.showBindingMessage = heading => { title = heading }
value.bind = async () => assert.fail('must not bind after lookup failure')
await value.confirmTargetAndBind()
assert.equal(title, '绑定失败')
})
+37
View File
@@ -0,0 +1,37 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const source = await readFile(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
const body = source.split('<script>')[1].split('</script>')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', body)({}, {}, {})
function context() {
const value = { current: { reviewedAt: null, history: [], items: [{ itemId: '1', operationType: 'CARTON_AND_SKU', expectedPackages: 1, skippedPackages: 0, exceptionPackages: 0, skuProcessedPackages: 0, cartonProcessedPackages: 1, skuItems: [{ skuCode: 'OLD', newSkuCode: 'NEW', quantity: 3 }, { skuCode: 'OTHER', newSkuCode: 'NEW2', quantity: 2 }] }] }, taskTab: 'sku' }
for (const name of ['skuDone', 'skuDetails', 'remaining']) value[name] = component.methods[name].bind(value)
for (const name of ['visibleItems', 'pendingSkuRows']) Object.defineProperty(value, name, { get: component.computed[name].bind(value) })
return value
}
test('each persisted SKU hides independently; mixed carton progress is separate', () => {
const value = context(); assert.equal(value.pendingSkuRows.length, 2)
value.current.history.push({ action: 'SKU_SCAN', detail: JSON.stringify({ round: 'INITIAL', itemId: '1', skuIndex: 0 }) })
assert.equal(value.pendingSkuRows.length, 1); assert.equal(value.pendingSkuRows[0].sku.skuCode, 'OTHER')
value.taskTab = 'carton'; assert.equal(value.visibleItems.length, 0)
value.taskTab = 'sku'; value.current.reviewedAt = '2026-09-15T18:00:00'; assert.equal(value.pendingSkuRows.length, 2)
value.current.items[0].skuProcessedPackages = 1; assert.equal(value.visibleItems.length, 0)
})
test('normal SKU input is always visible in SKU tab and never requests the new label', () => {
assert.match(source, /name="quickSku"/)
assert.doesNotMatch(source, /placeholder="扫描或输入新 SKU 标签号"/)
assert.match(source, /matches.length > 1/)
assert.match(source, /scanRelabelSku/)
})
test('pending list removes one confirmed row at a time without persistent empty or last-scan banners', () => {
const value = context()
assert.equal(value.pendingSkuRows.length, 2)
for (const index of [0, 1]) {
value.current.history.push({ action: 'SKU_SCAN', detail: JSON.stringify({ round: 'INITIAL', itemId: '1', skuIndex: index }) })
assert.equal(value.pendingSkuRows.length, 1 - index)
}
const template = source.split('<script>')[0]
assert.doesNotMatch(template, /待处理明细已清空|v-if="lastSku"|v-if="lastCarton"/)
assert.match(source, /uni.showModal/)
})
+117
View File
@@ -0,0 +1,117 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
test('warehouse transfer PDA API exposes candidates and palletize operations', async () => {
const source = await import('../src/api/warehouse-transfers.js')
assert.equal(typeof source.listWarehouseTransferCandidates, 'function')
assert.equal(typeof source.getWarehouseTransferDetail, 'function')
assert.equal(typeof source.palletizeWarehouseTransfer, 'function')
assert.equal(typeof source.saveWarehouseTransferPalletDraft, 'function')
assert.equal(typeof source.generateWarehouseTransferPallet, 'function')
assert.equal(typeof source.bindWarehouseTransferPallet, 'function')
assert.equal(typeof source.deleteWarehouseTransferPallet, 'function')
assert.equal(typeof source.uploadWarehouseTransferMeasurementPhoto, 'function')
assert.equal(typeof source.listWarehouseTransferMeasurementPhotos, 'function')
assert.equal(typeof source.downloadWarehouseTransferMeasurementPhoto, 'function')
assert.equal(typeof source.downloadWarehouseTransferPalletLabel, 'function')
})
test('warehouse transfer workbench entry is registered', async () => {
const { MODULES } = await import('../src/data/overseas-workbench.js')
const operation = MODULES.flatMap((m) => m.operations).find((item) => item.key === 'warehouseTransfer')
assert.equal(operation?.available, true)
assert.equal(operation?.route, '/pages/warehouse-transfer/warehouse-transfer')
})
test('warehouse transfer page loads detail without exposing internal identifiers', async () => {
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
assert.match(page, /getWarehouseTransferDetail\(item\.transferNo\)/)
assert.doesNotMatch(page, /转运明细 ID|manualItems/)
})
test('warehouse transfer shows forecast and actual variance without blocking short or over receipt', async () => {
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
for (const label of ['客户预报', '当前实收', '短收差异', '超收差异']) assert.match(page, new RegExp(label))
assert.match(page, /cartonDifference/)
assert.doesNotMatch(page, /全部托盘箱数必须等于订单计划箱数/)
assert.doesNotMatch(page, /本托 SKU 箱数|itemCounts|transferItemId/)
})
test('warehouse transfer retries reuse the pending idempotency key', async () => {
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
assert.match(page, /if \(!this\.pendingKey\) this\.pendingKey = `pda-transfer-/)
assert.match(page, /idempotencyKey: this\.pendingKey/)
assert.equal((page.match(/pendingKey = `pda-transfer-/g) || []).length, 1)
})
test('warehouse transfer measurement inputs retain explicit placeholders', async () => {
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
for (const placeholder of ['长度', '宽度', '高度', '毛重']) assert.match(page, new RegExp(`placeholder="${placeholder}"`))
})
test('warehouse transfer measurements are grouped and summarized per pallet', async () => {
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
assert.match(page, /v-for="\(pallet, index\) in pallets"/)
for (const label of ['本托实测', '当前托重量', '当前托箱数', '自动体积']) assert.match(page, new RegExp(label))
assert.match(page, /palletVolume\(pallet\)/)
assert.match(page, /rawPalletVolume\(pallet\)/)
})
test('warehouse transfer requires a camera photo and submits its file id with palletization', async () => {
const [page, api] = await Promise.all([
readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8'),
readFile(new URL('../src/api/warehouse-transfers.js', import.meta.url), 'utf8')
])
assert.match(page, /sourceType: \['camera', 'album'\]/)
assert.match(page, /请至少拍摄 1 张测量现场照片/)
assert.match(page, /photoFileIds: \[\.\.\.this\.photoFileIds\]/)
assert.match(api, /measurement-photos\/files/)
assert.match(page, /tempFilePaths \|\| \[\]/)
assert.match(page, /未获取到可上传的照片路径,请重新拍摄/)
assert.match(page, /所选转运单已切换,请重新拍摄/)
})
test('warehouse transfer restores uploaded photos and exposes honest pallet label printing', async () => {
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
assert.match(page, /listWarehouseTransferMeasurementPhotos/)
assert.match(page, /downloadWarehouseTransferMeasurementPhoto/)
assert.match(page, /completedPallets = \(result\?\.pallets \|\| \[\]\)/)
assert.match(page, /downloadWarehouseTransferPalletLabel/)
assert.match(page, /pallet\.labelDownloadPath/)
assert.match(page, /pallet\.fbaWarehouseCode \|\| completedDestination \|\| '目的仓待定'/)
assert.match(page, /目的仓待定/)
assert.match(page, /uni\.openDocument\(\{ filePath, fileType: 'pdf', showMenu: true/)
assert.doesNotMatch(page, /批量打印|printAllPallets/)
})
test('warehouse transfer always requests newly generated pallets', async () => {
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
assert.doesNotMatch(page, /托盘码\s*<small>选填|扫描或输入当前托盘码/)
assert.doesNotMatch(page, /palletCode: String\(p\.palletCode/)
assert.match(page, /completedPallets = \(result\?\.pallets \|\| \[\]\)/)
})
test('warehouse transfer pallet cards prepare generated and server-bound states', async () => {
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
assert.match(page, /生成托盘号/)
assert.match(page, /pallet\.generated && !pallet\.bound/)
assert.match(page, /ScanTargetInput/)
assert.match(page, /startScanBroadcast/)
assert.match(page, /handlePalletScan/)
assert.match(page, /扫描或手输新托盘标签号/)
assert.match(page, /已绑定实物托盘/)
assert.match(page, /!pallet\.generated.*removePallet/)
assert.match(page, /:disabled="pallet\.generated"/)
assert.match(page, /collapsed: false/)
assert.match(page, /pallet\.collapsed = !pallet\.collapsed/)
assert.match(page, /v-show="!pallet\.collapsed"/)
assert.match(page, /this\.selected\.pallets\?\.length \? this\.selected\.pallets\.map\(restoredPallet\)/)
assert.doesNotMatch(page, /if \(!this\.completedTransferNo \|\| this\.openingLabel\) return/)
assert.match(page, /saveWarehouseTransferPalletDraft/)
assert.match(page, /generateWarehouseTransferPallet/)
assert.match(page, /bindWarehouseTransferPallet/)
assert.match(page, /deleteWarehouseTransferPallet/)
assert.match(page, /请先生成并扫描确认绑定全部托盘/)
assert.match(page, /const payload = \{ idempotencyKey: this\.pendingKey, photoFileIds: \[\.\.\.this\.photoFileIds\] \}/)
})
+26 -4
View File
@@ -80,7 +80,8 @@ test('relabel API covers claim, unit outcomes, completion and review with encode
test('carton relabel uses one scan input and shows the confirmed FBA mapping after saving', async () => { test('carton relabel uses one scan input and shows the confirmed FBA mapping after saving', async () => {
const page = await read('../src/pages/relabel/relabel.vue') const page = await read('../src/pages/relabel/relabel.vue')
assert.equal((page.match(/name="quickCarton"/g) || []).length, 1) assert.equal((page.match(/name="quickCarton"/g) || []).length, 1)
assert.match(page, /v-if="taskTab === 'sku' && selectedItem && remaining\(selectedItem\) > 0"/) assert.match(page, /name="quickSku"/)
assert.match(page, /v-if="taskTab === 'sku' && selectedItem && action !== 'RELABEL' && remaining\(selectedItem\) > 0"/)
assert.match(page, /selectItem\(item\) \{ if \(this\.taskTab === 'carton'\) \{ this\.activeTarget = 'quick-carton'; return \}/) assert.match(page, /selectItem\(item\) \{ if \(this\.taskTab === 'carton'\) \{ this\.activeTarget = 'quick-carton'; return \}/)
const start = page.indexOf('async submitCarton(raw)') const start = page.indexOf('async submitCarton(raw)')
const scan = page.slice(start, page.indexOf('removePhoto(index)', start)) const scan = page.slice(start, page.indexOf('removePhoto(index)', start))
@@ -90,6 +91,17 @@ test('carton relabel uses one scan input and shows the confirmed FBA mapping aft
assert.match(scan, /confirmedItem\?\.fbaCartonMark/) assert.match(scan, /confirmedItem\?\.fbaCartonMark/)
}) })
test('mixed relabel keeps SKU work open after carton work and only carton tab 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)
const mixed = { operationType: 'CARTON_AND_SKU', expectedPackages: 1, relabelledPackages: 1,
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'"/)
})
test('relabel list opens a row directly in execution and completion returns to the list', async () => { test('relabel list opens a row directly in execution and completion returns to the list', async () => {
const page = await read('../src/pages/relabel/relabel.vue') const page = await read('../src/pages/relabel/relabel.vue')
assert.doesNotMatch(page, /name="relabelTask"/) assert.doesNotMatch(page, /name="relabelTask"/)
@@ -105,13 +117,23 @@ test('relabel list opens a row directly in execution and completion returns to t
test('relabel review is a read-only list and completed execution details stay hidden', async () => { test('relabel review is a read-only list and completed execution details stay hidden', async () => {
const page = await read('../src/pages/relabel/relabel.vue') const page = await read('../src/pages/relabel/relabel.vue')
assert.match(page, /showCurrent\(\) \{ if \(!this\.current \|\| this\.stage === 'review'\) return false; if \(this\.stage === 'execute'\) return this\.current\.taskStatus === 'PROCESSING' && this\.current\.claimedByCurrentOperator/) const body = page.match(/showCurrent\(\) \{([^\n]+)\},/)[1]
const visible = new Function(body)
const context = { stage: 'execute', current: { relabelNo: 'RL-1', taskStatus: 'PROCESSING' }, candidates: [{ relabelNo: 'RL-1', taskStatus: 'PROCESSING', claimedByCurrentOperator: true }] }
assert.equal(visible.call(context), true, 'detail without ownership field must use server candidate ownership')
assert.equal(visible.call({ ...context, candidates: [] }), false)
assert.equal(visible.call({ ...context, stage: 'review' }), false)
assert.equal(visible.call({ ...context, current: { ...context.current, taskStatus: 'WAIT_REVIEW' } }), false)
assert.match(page, /stageSubtitle\(\) \{ return this\.stage === 'review' \? '仅展示当前仓库已完成现场换标并等待复核的单据'/) assert.match(page, /stageSubtitle\(\) \{ return this\.stage === 'review' \? '仅展示当前仓库已完成现场换标并等待复核的单据'/)
const reviewList = page.slice(page.indexOf('<view v-else class="task-list">'), page.indexOf('<view v-if="showCurrent"')) const reviewList = page.slice(page.indexOf('<view v-else class="task-list">'), page.indexOf('<view v-if="showCurrent"'))
assert.doesNotMatch(reviewList, /@tap|详情/) assert.doesNotMatch(reviewList, /@tap|详情/)
assert.match(page, /<button v-if="stage !== 'review'" class="link-button"[^>]*@tap="loadCandidates"/) assert.match(page, /<button v-if="stage !== 'review'" class="link-button"[^>]*@tap="bootstrap"/)
assert.match(page, /if \(this\.stage === 'execute'\) this\.startScanner\(\)/) assert.match(page, /if \(this\.stage === 'execute'\) this\.startScanner\(\)/)
assert.match(page, /if \(this\.stage === 'execute' && this\.taskCode\) await this\.openTask/) const bootstrapBody = page.match(/async bootstrap\(\) \{([^\n]+)\},/)[1]
const bootstrap = new (Object.getPrototypeOf(async function () {}).constructor)(bootstrapBody)
const opened = []
await bootstrap.call({ ...context, taskCode: 'STALE', loadCandidates: async () => {}, openTask: async (no) => opened.push(no) })
assert.deepEqual(opened, ['RL-1'])
}) })
test('warehouse drafts are isolated by operator and warehouse and retain retry keys until success', () => { test('warehouse drafts are isolated by operator and warehouse and retain retry keys until success', () => {
+2
View File
@@ -7,6 +7,7 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
const enabled = operations.filter((operation) => operation.available) const enabled = operations.filter((operation) => operation.available)
assert.deepEqual(enabled.map(({ module, name }) => ({ module, name })), [ assert.deepEqual(enabled.map(({ module, name }) => ({ module, name })), [
{ module: 'inbound', name: '拆柜收货' }, { module: 'inbound', name: '拆柜收货' },
{ module: 'inbound', name: '仓储转运测量' },
{ module: 'inbound', name: '退货入库' }, { module: 'inbound', name: '退货入库' },
{ module: 'inbound', name: '退货合箱' }, { module: 'inbound', name: '退货合箱' },
{ module: 'outbound', name: '排车拣货' }, { module: 'outbound', name: '排车拣货' },
@@ -25,6 +26,7 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
assert.equal(operations.find((item) => item.name === '出库作业')?.route, '/pages/outbound/outbound') assert.equal(operations.find((item) => item.name === '出库作业')?.route, '/pages/outbound/outbound')
assert.equal(operations.find((item) => item.name === '退货入库')?.route, '/pages/return-receipt/return-receipt') assert.equal(operations.find((item) => item.name === '退货入库')?.route, '/pages/return-receipt/return-receipt')
assert.equal(operations.find((item) => item.name === '退货合箱')?.route, '/pages/return-packing/return-packing') assert.equal(operations.find((item) => item.name === '退货合箱')?.route, '/pages/return-packing/return-packing')
assert.equal(operations.find((item) => item.name === '仓储转运测量')?.route, '/pages/warehouse-transfer/warehouse-transfer')
assert.equal(operations.some((item) => item.name === '库存盘点'), false) assert.equal(operations.some((item) => item.name === '库存盘点'), false)