Merge branch 'release/test' of http://8.163.30.128:10082/html/pda-overseas-uniapp into release/test

This commit is contained in:
2026-09-22 08:47:54 +08:00
12 changed files with 151 additions and 46 deletions
+2 -2
View File
@@ -2,8 +2,8 @@
"name" : "柏悦物流 PDA 海外版", "name" : "柏悦物流 PDA 海外版",
"appid" : "__UNI__936D079", "appid" : "__UNI__936D079",
"description" : "柏悦物流海外仓 PDA 作业端", "description" : "柏悦物流海外仓 PDA 作业端",
"versionName" : "0.0.3", "versionName" : "0.0.4",
"versionCode" : 3, "versionCode" : 4,
"transformPx" : false, "transformPx" : false,
"app-plus" : { "app-plus" : {
"usingComponents" : true, "usingComponents" : true,
+14 -5
View File
@@ -7,6 +7,7 @@
<text class="fulfillment-title">排车拣货与发车</text> <text class="fulfillment-title">排车拣货与发车</text>
<text class="fulfillment-subtitle">客户与当前海外仓由登录令牌和服务端确定;扫描排车单号或任务中的托盘码开始作业</text> <text class="fulfillment-subtitle">客户与当前海外仓由登录令牌和服务端确定;扫描排车单号或任务中的托盘码开始作业</text>
</view> </view>
<view v-if="current" class="detail-toolbar"><view><text class="detail-kicker">当前任务</text><text class="detail-title">排车拣货详情</text></view><button :disabled="busy" @tap="closeTask">‹ 返回列表</button></view>
<view class="fulfillment-card"> <view class="fulfillment-card">
<ScanTargetInput name="dispatch" :model-value="scanCode" label="排车单号或托盘码" placeholder="扫描排车单号 / 托盘码" :active="true" @update:modelValue="updateScanCode" @submit="handleScan" /> <ScanTargetInput name="dispatch" :model-value="scanCode" label="排车单号或托盘码" placeholder="扫描排车单号 / 托盘码" :active="true" @update:modelValue="updateScanCode" @submit="handleScan" />
@@ -25,11 +26,12 @@
</view> </view>
<template v-if="!loading && !current"> <template v-if="!loading && !current">
<view class="list-toolbar"><text>待处理排车任务</text><button :disabled="loading" @tap="loadTasks">刷新</button></view> <view class="list-toolbar"><view><text class="list-title">待处理排车任务</text><text class="list-count">{{ tasks.length }} 个任务</text></view><button :disabled="loading" @tap="loadTasks">刷新</button></view>
<view v-if="tasks.length" class="task-list"> <view v-if="tasks.length" class="task-list">
<button v-for="task in tasks" :key="task.dispatchNo" class="task-card" @tap="openTask(task.dispatchNo)"> <button v-for="task in tasks" :key="task.dispatchNo" class="task-card" @tap="openTask(task.dispatchNo)">
<view class="task-head"><text class="business-code">{{ task.dispatchNo || '—' }}</text><text class="status-badge">{{ statusLabel(task.status) }}</text></view> <view class="task-head"><view class="task-code-row"><text class="business-code">{{ task.dispatchNo || '—' }}</text><text v-if="task.dispatchNo === recentTaskNo" class="recent-badge">上次作业</text></view><text class="status-badge">{{ statusLabel(task.status) }}</text></view>
<text class="task-meta">任务号 {{ task.taskNo || '—' }} · 车牌 {{ task.vehiclePlate || '—' }}</text> <text class="task-meta">任务号 {{ task.taskNo || '—' }} · 车牌 {{ task.vehiclePlate || '—' }}</text>
<text class="task-enter">查看并开始拣货 ›</text>
</button> </button>
</view> </view>
<view v-else-if="!error" class="fulfillment-state"><text class="state-title">暂无排车拣货任务</text><text>可刷新列表,或直接扫描排车单号恢复作业。</text></view> <view v-else-if="!error" class="fulfillment-state"><text class="state-title">暂无排车拣货任务</text><text>可刷新列表,或直接扫描排车单号恢复作业。</text></view>
@@ -40,7 +42,6 @@
<view class="task-head"><text class="business-code">{{ current.dispatchNo || '—' }}</text><text class="status-badge">{{ statusLabel(current.status) }}</text></view> <view class="task-head"><text class="business-code">{{ current.dispatchNo || '—' }}</text><text class="status-badge">{{ statusLabel(current.status) }}</text></view>
<text class="task-meta">任务号 {{ current.taskNo || '—' }} · 车牌 {{ current.vehiclePlate || '—' }}</text> <text class="task-meta">任务号 {{ current.taskNo || '—' }} · 车牌 {{ current.vehiclePlate || '—' }}</text>
<view class="metrics"><view><text class="metric-value">{{ plannedPackages }}</text><text>计划箱数</text></view><view><text class="metric-value success">{{ pickedPackages }}</text><text>已拣箱数</text></view><view><text class="metric-value warning">{{ remainingPackages }}</text><text>待拣箱数</text></view></view> <view class="metrics"><view><text class="metric-value">{{ plannedPackages }}</text><text>计划箱数</text></view><view><text class="metric-value success">{{ pickedPackages }}</text><text>已拣箱数</text></view><view><text class="metric-value warning">{{ remainingPackages }}</text><text>待拣箱数</text></view></view>
<button class="link-button" :disabled="busy" @tap="closeTask">返回任务列表</button>
</view> </view>
<view v-if="current.items.length" class="item-list"> <view v-if="current.items.length" class="item-list">
@@ -95,8 +96,9 @@
export default { export default {
components: { PortalHeader, ScanTargetInput }, components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, tasks: [], current: null, scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '' } }, data() { return { user: session.currentUser, tasks: [], current: null, scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '', bootstrapped: false, resumeOnEntry: false } },
computed: { computed: {
recentTaskNo() { return fulfillment.dispatch.currentNo || '' },
packages() { return fulfillment.dispatch.packages }, packages() { return fulfillment.dispatch.packages },
selectedItem() { return this.current?.items?.find((item) => item.palletNo === fulfillment.dispatch.palletCode) || null }, selectedItem() { return this.current?.items?.find((item) => item.palletNo === fulfillment.dispatch.palletCode) || null },
selectedPosition() { return this.selectedItem?.positions?.find((item) => item.availablePackages > 0) || this.selectedItem?.positions?.[0] || null }, selectedPosition() { return this.selectedItem?.positions?.find((item) => item.availablePackages > 0) || this.selectedItem?.positions?.[0] || null },
@@ -112,12 +114,19 @@
canComplete() { return this.plannedPackages > 0 && this.remainingPackages === 0 }, canComplete() { return this.plannedPackages > 0 && this.remainingPackages === 0 },
canDepart() { return this.pickingCompleted && this.allPicked && this.current?.canDepart === true && !this.departed } canDepart() { return this.pickingCompleted && this.allPicked && this.current?.canDepart === true && !this.departed }
}, },
onLoad(options) { this.resumeOnEntry = options?.resume === '1' },
onShow() { onShow() {
if (!requireSession()) return if (!requireSession()) return
this.user = session.currentUser this.user = session.currentUser
restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`) restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`)
this.startScanner() this.startScanner()
if (fulfillment.dispatch.currentNo) this.openTask(fulfillment.dispatch.currentNo) if (!this.bootstrapped) {
this.bootstrapped = true
if (this.resumeOnEntry && fulfillment.dispatch.currentNo) this.openTask(fulfillment.dispatch.currentNo)
else this.loadTasks()
return
}
if (this.current?.dispatchNo) this.openTask(this.current.dispatchNo)
else this.loadTasks() else this.loadTasks()
}, },
onHide() { this.stopScanner() }, onHide() { this.stopScanner() },
+9
View File
@@ -21,13 +21,22 @@
.offline-note { margin-top: 7px; color: #8a5a00; } .offline-note { margin-top: 7px; color: #8a5a00; }
.list-toolbar, .task-head, .count-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; } .list-toolbar, .task-head, .count-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.list-toolbar { margin: 16px 2px 10px; color: #233951; font-size: 13px; font-weight: 800; } .list-toolbar { margin: 16px 2px 10px; color: #233951; font-size: 13px; font-weight: 800; }
.list-title, .list-count, .detail-kicker, .detail-title, .task-enter { display: block; }
.list-count { margin-top: 2px; color: #8795a7; font-size: 10px; font-weight: 500; }
.list-toolbar button { min-height: 32px; margin: 0; padding: 0 12px; border-radius: 7px; color: #135ee8; font-size: 11px; line-height: 32px; background: #e9f2ff; } .list-toolbar button { min-height: 32px; margin: 0; padding: 0 12px; border-radius: 7px; color: #135ee8; font-size: 11px; line-height: 32px; background: #e9f2ff; }
.task-list, .item-list { display: flex; flex-direction: column; gap: 9px; } .task-list, .item-list { display: flex; flex-direction: column; gap: 9px; }
.task-card { box-sizing: border-box; width: 100%; min-height: 76px; margin: 0; padding: 13px; border: 1px solid #dce5f0; border-radius: 11px; text-align: left; background: #fff; } .task-card { box-sizing: border-box; width: 100%; min-height: 76px; margin: 0; padding: 13px; border: 1px solid #dce5f0; border-radius: 11px; text-align: left; background: #fff; }
.task-card::after { border: 0; } .task-card::after { border: 0; }
.task-code-row { display: flex; min-width: 0; align-items: center; gap: 7px; }
.business-code { color: #16304d; font-size: 15px; font-weight: 800; overflow-wrap: anywhere; } .business-code { color: #16304d; font-size: 15px; font-weight: 800; overflow-wrap: anywhere; }
.recent-badge { flex-shrink: 0; padding: 3px 6px; border-radius: 99px; color: #8a570c; font-size: 9px; line-height: 14px; background: #fff1dd; }
.status-badge { flex-shrink: 0; padding: 4px 7px; border-radius: 99px; color: #165fbf; font-size: 10px; background: #e6f0ff; } .status-badge { flex-shrink: 0; padding: 4px 7px; border-radius: 99px; color: #165fbf; font-size: 10px; background: #e6f0ff; }
.task-meta { margin-top: 6px; color: #718095; font-size: 10px; line-height: 16px; } .task-meta { margin-top: 6px; color: #718095; font-size: 10px; line-height: 16px; }
.task-enter { margin-top: 8px; color: #135ee8; font-size: 10px; font-weight: 700; text-align: right; }
.detail-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 4px 0 10px; padding: 10px 12px; border: 1px solid #bed4f6; border-radius: 11px; background: #eef5ff; }
.detail-kicker { color: #6580a4; font-size: 9px; }
.detail-title { margin-top: 2px; color: #153d74; font-size: 14px; font-weight: 800; }
.detail-toolbar button { flex-shrink: 0; min-height: 34px; margin: 0; padding: 0 11px; border-radius: 8px; color: #135ee8; font-size: 10px; line-height: 34px; background: #fff; }
.summary-card { display: block; } .summary-card { display: block; }
.metrics { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); margin-top: 12px; padding: 10px 0; border-radius: 9px; background: #f5f8fc; } .metrics { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); margin-top: 12px; padding: 10px 0; border-radius: 9px; background: #f5f8fc; }
.metrics view { display: flex; align-items: center; flex-direction: column; gap: 3px; color: #718095; font-size: 9px; } .metrics view { display: flex; align-items: center; flex-direction: column; gap: 3px; color: #718095; font-size: 9px; }
+1 -1
View File
@@ -181,7 +181,7 @@
photoStateLabel(photo) { if (photo?.status === 'uploading') return '正在上传…'; if (photo?.status === 'error') return photo.errorMessage || (photo.fileId ? '重拍失败,已保留原图' : '上传失败,请重试'); return photo?.fileId ? '点击左侧照片可查看大图' : '请拍摄现场照片' }, photoStateLabel(photo) { if (photo?.status === 'uploading') return '正在上传…'; if (photo?.status === 'error') return photo.errorMessage || (photo.fileId ? '重拍失败,已保留原图' : '上传失败,请重试'); return photo?.fileId ? '点击左侧照片可查看大图' : '请拍摄现场照片' },
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleHardwareScan(value), onError: (error) => { this.error = error; this.showToast('硬件扫码接收器异常,请检查扫描服务', 'warning') } }) }, stopScanner() { if (typeof this._stopScanner === 'function') this._stopScanner(); this._stopScanner = null }, startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleHardwareScan(value), onError: (error) => { this.error = error; this.showToast('硬件扫码接收器异常,请检查扫描服务', 'warning') } }) }, stopScanner() { if (typeof this._stopScanner === 'function') this._stopScanner(); this._stopScanner = null },
handleHardwareScan(value) { const scan = { value: String(value || '').trim(), target: this.activeTarget }; if (!scan.value) return; if (!this.inboundMode) return this.showToast('该功能缺少海外 PDA 专用接口,扫码未提交', 'warning'); if (this.phase === 'candidates') return this.showToast('请直接点击待卸货集装箱进入详情', 'warning'); if (this.phase !== 'receive') return; if (this.busy || this.activeTarget === 'quantity') { if (this.scanQueue.length >= 100) return this.showToast('扫码队列已满,请先处理当前托盘', 'warning'); this.scanQueue.push(scan); return this.showToast(`已排队 ${this.scanQueue.length} 个扫码`, 'warning') } return this.dispatchHardwareScan(scan) }, handleHardwareScan(value) { const scan = { value: String(value || '').trim(), target: this.activeTarget }; if (!scan.value) return; if (!this.inboundMode) return this.showToast('该功能缺少海外 PDA 专用接口,扫码未提交', 'warning'); if (this.phase === 'candidates') return this.showToast('请直接点击待卸货集装箱进入详情', 'warning'); if (this.phase !== 'receive') return; if (this.busy || this.activeTarget === 'quantity') { if (this.scanQueue.length >= 100) return this.showToast('扫码队列已满,请先处理当前托盘', 'warning'); this.scanQueue.push(scan); return this.showToast(`已排队 ${this.scanQueue.length} 个扫码`, 'warning') } return this.dispatchHardwareScan(scan) },
dispatchHardwareScan(scan) { console.info('[Overseas PDA scan] dispatch', { value: scan.value, target: scan.target }); if (scan.target === 'pallet' && this.activeInbound) { this.palletInput = scan.value; return this.resolvePallet(scan.value) } this.cartonInput = scan.value; return this.resolveCarton(scan.value) }, dispatchHardwareScan(scan) { const palletWorkflowActive = Boolean(this.activeInbound || this.resolvedPallet); const target = palletWorkflowActive ? 'pallet' : scan.target; console.info('[Overseas PDA scan] dispatch', { value: scan.value, target, queuedTarget: scan.target }); if (target === 'pallet') { this.palletInput = scan.value; return this.resolvePallet(scan.value) } this.cartonInput = scan.value; return this.resolveCarton(scan.value) },
drainScanQueue() { if (this.busy || this.activeTarget === 'quantity' || !this.scanQueue.length || this.phase !== 'receive') return; const next = this.scanQueue.shift(); this.dispatchHardwareScan(next) }, drainScanQueue() { if (this.busy || this.activeTarget === 'quantity' || !this.scanQueue.length || this.phase !== 'receive') return; const next = this.scanQueue.shift(); this.dispatchHardwareScan(next) },
async run(action, work) { this.busy = true; this.error = null; try { const result = await work(); this.showToast(`${action}成功`, 'success'); return result } catch (error) { const mappedError = toReceivingError(error, `${action}失败`); this.error = mappedError; playErrorSound(); this.showToast(mappedError.message, 'error'); throw mappedError } finally { this.busy = false; setTimeout(() => this.drainScanQueue(), 0) } }, async run(action, work) { this.busy = true; this.error = null; try { const result = await work(); this.showToast(`${action}成功`, 'success'); return result } catch (error) { const mappedError = toReceivingError(error, `${action}失败`); this.error = mappedError; playErrorSound(); this.showToast(mappedError.message, 'error'); throw mappedError } finally { this.busy = false; setTimeout(() => this.drainScanQueue(), 0) } },
setScanTargetForCurrent(value) { receiving.activeScanTarget = value?.status === 'WAIT_SIGN' ? 'container' : 'carton' }, setScanTargetForCurrent(value) { receiving.activeScanTarget = value?.status === 'WAIT_SIGN' ? 'container' : 'carton' },
+8 -6
View File
@@ -4,6 +4,7 @@
<scroll-view class="fulfillment-scroll" scroll-y> <scroll-view class="fulfillment-scroll" scroll-y>
<view class="portal-content fulfillment-content"> <view class="portal-content fulfillment-content">
<view class="fulfillment-heading"><text class="fulfillment-title">出库任务</text><text class="fulfillment-subtitle">一件代发扫描托盘码可整托出库;扫描排车业务号会转入“排车拣货”</text></view> <view class="fulfillment-heading"><text class="fulfillment-title">出库任务</text><text class="fulfillment-subtitle">一件代发扫描托盘码可整托出库;扫描排车业务号会转入“排车拣货”</text></view>
<view v-if="current" class="detail-toolbar"><view><text class="detail-kicker">当前任务</text><text class="detail-title">出库作业详情</text></view><button :disabled="busy" @tap="closeTask">‹ 返回列表</button></view>
<view class="fulfillment-card"> <view class="fulfillment-card">
<ScanTargetInput name="outbound" :model-value="scanCode" label="任务号、出库单号、排车单号、托盘码或箱码" placeholder="扫描任务号 / 出库单号 / 排车单号 / 托盘码 / 箱码" :active="true" @update:modelValue="updateScanCode" @submit="handleScan" /> <ScanTargetInput name="outbound" :model-value="scanCode" label="任务号、出库单号、排车单号、托盘码或箱码" placeholder="扫描任务号 / 出库单号 / 排车单号 / 托盘码 / 箱码" :active="true" @update:modelValue="updateScanCode" @submit="handleScan" />
<button class="secondary-button" :disabled="!scanCode || loading || busy" @tap="handleScan(scanCode)">{{ current ? '匹配出库货物' : '打开出库任务' }}</button> <button class="secondary-button" :disabled="!scanCode || loading || busy" @tap="handleScan(scanCode)">{{ current ? '匹配出库货物' : '打开出库任务' }}</button>
@@ -14,15 +15,15 @@
<view v-else-if="error" class="fulfillment-error" role="alert"><text class="state-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><text v-if="error.network" class="offline-note">网络连接失败,当前任务、扫码草稿和幂等键均已保留。</text><button class="secondary-button" :disabled="loading || busy" @tap="retry">重试</button></view> <view v-else-if="error" class="fulfillment-error" role="alert"><text class="state-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><text v-if="error.network" class="offline-note">网络连接失败,当前任务、扫码草稿和幂等键均已保留。</text><button class="secondary-button" :disabled="loading || busy" @tap="retry">重试</button></view>
<template v-if="!loading && !current"> <template v-if="!loading && !current">
<view class="list-toolbar"><text>待处理出库任务</text><button :disabled="loading" @tap="loadTasks">刷新</button></view> <view class="list-toolbar"><view><text class="list-title">待处理出库任务</text><text class="list-count">{{ tasks.length }} 个任务</text></view><button :disabled="loading" @tap="loadTasks">刷新</button></view>
<view v-if="tasks.length" class="task-list"> <view v-if="tasks.length" class="task-list">
<button v-for="task in tasks" :key="task.taskNo" class="task-card" @tap="openTask(task.taskNo)"><view class="task-head"><text class="business-code">{{ task.taskNo || '—' }}</text><text class="status-badge">{{ statusLabel(task.status) }}</text></view><text class="task-meta">出库单 {{ task.outboundNo || '—' }} · {{ taskType(task) }}</text></button> <button v-for="task in tasks" :key="task.taskNo" class="task-card" @tap="openTask(task.taskNo)"><view class="task-head"><view class="task-code-row"><text class="business-code">{{ task.taskNo || '—' }}</text><text v-if="task.taskNo === recentTaskNo" class="recent-badge">上次作业</text></view><text class="status-badge">{{ statusLabel(task.status) }}</text></view><text class="task-meta">出库单 {{ task.outboundNo || '—' }} · {{ taskType(task) }}</text><text class="task-enter">查看并开始出库 ›</text></button>
</view> </view>
<view v-else-if="!error" class="fulfillment-state"><text class="state-title">暂无出库任务</text><text>可刷新列表,或扫描任务号恢复未完成作业。</text></view> <view v-else-if="!error" class="fulfillment-state"><text class="state-title">暂无出库任务</text><text>可刷新列表,或扫描任务号恢复未完成作业。</text></view>
</template> </template>
<template v-if="current"> <template v-if="current">
<view class="fulfillment-card summary-card"><view class="task-head"><text class="business-code">{{ current.taskNo || '—' }}</text><text class="status-badge">{{ statusLabel(current.status) }}</text></view><text class="task-meta">出库单 {{ current.outboundNo || '—' }} · {{ taskType(current) }}</text><view class="metrics"><view><text class="metric-value">{{ plannedPackages }}</text><text>计划箱数</text></view><view><text class="metric-value success">{{ pickedPackages }}</text><text>已拣箱数</text></view><view><text class="metric-value warning">{{ remainingPackages }}</text><text>待拣箱数</text></view></view><button class="link-button" :disabled="busy" @tap="closeTask">返回任务列表</button></view> <view class="fulfillment-card summary-card"><view class="task-head"><text class="business-code">{{ current.taskNo || '—' }}</text><text class="status-badge">{{ statusLabel(current.status) }}</text></view><text class="task-meta">出库单 {{ current.outboundNo || '—' }} · {{ taskType(current) }}</text><view class="metrics"><view><text class="metric-value">{{ plannedPackages }}</text><text>计划箱数</text></view><view><text class="metric-value success">{{ pickedPackages }}</text><text>已拣箱数</text></view><view><text class="metric-value warning">{{ remainingPackages }}</text><text>待拣箱数</text></view></view></view>
<view v-if="current.items.length" class="item-list"> <view v-if="current.items.length" class="item-list">
<view v-for="item in current.items" :key="item.outboundPalletItemId" class="fulfillment-card item-card"> <view v-for="item in current.items" :key="item.outboundPalletItemId" class="fulfillment-card item-card">
@@ -57,8 +58,9 @@
export default { export default {
components: { PortalHeader, ScanTargetInput }, components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, tasks: [], current: null, selectedItem: null, selectedPosition: null, scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '' } }, data() { return { user: session.currentUser, tasks: [], current: null, selectedItem: null, selectedPosition: null, scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '', bootstrapped: false } },
computed: { computed: {
recentTaskNo() { return fulfillment.outbound.currentTaskNo || '' },
packages() { return fulfillment.outbound.packages }, packages() { return fulfillment.outbound.packages },
plannedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.plannedPackages, 0) }, plannedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.plannedPackages, 0) },
pickedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.pickedPackages, 0) }, pickedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.pickedPackages, 0) },
@@ -67,7 +69,7 @@
canPick() { return this.current?.status !== 'COMPLETED' && this.selectedItem && this.selectedPosition && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining }, canPick() { return this.current?.status !== 'COMPLETED' && this.selectedItem && this.selectedPosition && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining },
canComplete() { return this.plannedPackages > 0 && this.remainingPackages === 0 && this.current?.status !== 'COMPLETED' } canComplete() { return this.plannedPackages > 0 && this.remainingPackages === 0 && this.current?.status !== 'COMPLETED' }
}, },
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.scanCode = fulfillment.outbound.scanCode; this.startScanner(); if (fulfillment.outbound.currentTaskNo) this.openTask(fulfillment.outbound.currentTaskNo); else this.loadTasks() }, onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (!this.bootstrapped) { this.bootstrapped = true; this.scanCode = ''; this.loadTasks(); return } if (this.current?.taskNo) this.openTask(this.current.taskNo); else this.loadTasks() },
onHide() { this.stopScanner() }, onHide() { this.stopScanner() },
beforeUnmount() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: { methods: {
@@ -83,7 +85,7 @@
if (resolution?.workflow === 'DISPATCH' && resolution.dispatchNo) { if (resolution?.workflow === 'DISPATCH' && resolution.dispatchNo) {
updateFulfillmentDraft('outbound', { currentTaskNo: '', scanCode: '', packages: 1 }) updateFulfillmentDraft('outbound', { currentTaskNo: '', scanCode: '', packages: 1 })
updateFulfillmentDraft('dispatch', { currentNo: resolution.dispatchNo, palletCode: '', packages: 1 }) updateFulfillmentDraft('dispatch', { currentNo: resolution.dispatchNo, palletCode: '', packages: 1 })
uni.navigateTo({ url: '/pages/dispatch/dispatch' }) uni.navigateTo({ url: '/pages/dispatch/dispatch?resume=1' })
return return
} }
if (resolution?.workflow !== 'OUTBOUND' || !resolution.taskNo) throw new Error('服务端未返回可执行的出库任务') if (resolution?.workflow !== 'OUTBOUND' || !resolution.taskNo) throw new Error('服务端未返回可执行的出库任务')
+10 -9
View File
@@ -3,13 +3,13 @@
<PortalHeader :title="title" :user="user" fallback-url="/pages/module/module?key=relabel" /> <PortalHeader :title="title" :user="user" fallback-url="/pages/module/module?key=relabel" />
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content"> <scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
<view class="work-heading"><text class="work-title">{{ title }}</text><text class="work-subtitle">{{ stageSubtitle }}</text></view> <view 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' && showCurrent" class="detail-toolbar"><view><text class="detail-kicker">当前任务</text><text class="detail-title">换标执行详情</text></view><button :disabled="busy" @tap="closeCurrent">‹ 返回列表</button></view>
<template v-if="stage !== 'execute' || !showCurrent"> <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 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> </template>
@@ -91,7 +91,7 @@
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, RelabelPalletWork }, components: { PortalHeader, ScanTargetInput, RelabelPalletWork },
data() { return { user: session.currentUser, stage: 'receive', unitDrafts: {}, taskTab: 'sku', candidates: [], current: null, selectedItem: null, returnSkuCode: '', verifiedReturnSkuKey: '', bulkQuantities: {}, activeTarget: 'task', loading: false, busy: false, busyAction: '', error: null, successMessage: '', exceptionCode: 'OTHER', actionModes: [{ key: 'RELABEL', name: '正常换标' }, { key: 'SKIP', name: '跳过' }, { key: 'EXCEPTION', name: '异常' }], exceptionCodes: [{ key: 'DAMAGED', name: '破损' }, { key: 'MISSING_LABEL', name: '缺标签' }, { key: 'LABEL_MISMATCH', name: '标签不符' }, { key: 'UNREADABLE', name: '无法识别' }, { key: 'OTHER', name: '其他' }] } }, data() { return { user: session.currentUser, stage: 'receive', entryTaskNo: '', unitDrafts: {}, taskTab: 'sku', candidates: [], current: null, selectedItem: null, returnSkuCode: '', verifiedReturnSkuKey: '', bulkQuantities: {}, activeTarget: 'task', loading: false, busy: false, busyAction: '', error: null, successMessage: '', exceptionCode: 'OTHER', actionModes: [{ key: 'RELABEL', name: '正常换标' }, { key: 'SKIP', name: '跳过' }, { key: 'EXCEPTION', name: '异常' }], exceptionCodes: [{ key: 'DAMAGED', name: '破损' }, { key: 'MISSING_LABEL', name: '缺标签' }, { key: 'LABEL_MISMATCH', name: '标签不符' }, { key: 'UNREADABLE', name: '无法识别' }, { key: 'OTHER', name: '其他' }] } },
computed: { computed: {
isReturnBulk() { return this.current?.operationType === 'RETURN_BULK_SKU_LABEL' }, isReturnBulk() { return this.current?.operationType === 'RETURN_BULK_SKU_LABEL' },
returnBulkSkuGroups() { returnBulkSkuGroups() {
@@ -151,7 +151,7 @@
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'; this.entryTaskNo = String(options.task || '').trim() },
mixins: [{ data() { return { documents: [], quickCarton: '', lastCarton: null, quickSku: '', lastSku: '', skuChoices: [], 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: '' }) } },
@@ -262,7 +262,7 @@
handleHardwareScan(value) { if (this.activeTarget === 'return-sku') { this.verifyReturnSku(value); return } if (this.activeTarget === 'quick-sku') { this.quickSku = value; this.submitSku(value); return } if (this.activeTarget === 'old') { this.oldLabel = value; return } this.taskCode = value; this.openTask(value) }, handleHardwareScan(value) { if (this.activeTarget === 'return-sku') { this.verifyReturnSku(value); return } if (this.activeTarget === 'quick-sku') { this.quickSku = value; this.submitSku(value); return } if (this.activeTarget === 'old') { this.oldLabel = value; return } this.taskCode = value; this.openTask(value) },
handleOldLabel(value) { this.oldLabel = String(value?.detail?.value ?? value ?? '').trim(); if (this.action === 'RELABEL') this.activeTarget = this.taskTab === 'sku' ? 'sku-new-0' : 'new' }, 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.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 bootstrap() { await this.loadCandidates(); if (this.error || this.stage !== 'execute' || !this.entryTaskNo) return; const target = this.candidates.find((item) => item.relabelNo === this.entryTaskNo && item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator === true); this.entryTaskNo = ''; 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; this.returnSkuCode = ''; this.verifiedReturnSkuKey = ''; this.bulkQuantities = {}; if (warehouseWork.relabel.photoTaskNo !== value.relabelNo) updateWarehouseWork('relabel', { photoTaskNo: value.relabelNo, photoFileIds: [], photoNames: [], remark: '' }); this.changeTab(value.items.some((item) => item.operationType === 'SKU_LABEL') ? 'sku' : 'carton'); this.loadDocuments() } if (this.selectedItem) this.selectedItem = this.visibleItems.find((item) => item.itemId === this.selectedItem.itemId) || null }, applyCurrent(value) { const changed = this.current?.relabelNo !== value.relabelNo; this.current = value; this.taskCode = value.relabelNo; if (changed) { this.unitDrafts = {}; this.documents = []; this.quickCarton = ''; this.lastCarton = null; this.returnSkuCode = ''; this.verifiedReturnSkuKey = ''; this.bulkQuantities = {}; if (warehouseWork.relabel.photoTaskNo !== value.relabelNo) updateWarehouseWork('relabel', { photoTaskNo: value.relabelNo, photoFileIds: [], photoNames: [], remark: '' }); this.changeTab(value.items.some((item) => item.operationType === 'SKU_LABEL') ? 'sku' : 'carton'); this.loadDocuments() } if (this.selectedItem) this.selectedItem = this.visibleItems.find((item) => item.itemId === this.selectedItem.itemId) || null },
@@ -286,7 +286,7 @@
})) }))
if (!confirmed) return if (!confirmed) return
this.taskCode = active.relabelNo this.taskCode = active.relabelNo
uni.redirectTo({ url: '/pages/relabel/relabel?op=relabelExecute' }) uni.redirectTo({ url: `/pages/relabel/relabel?op=relabelExecute&task=${encodeURIComponent(active.relabelNo)}` })
} finally { this.busy = false } } finally { this.busy = false }
}, },
async openExecution(item) { async openExecution(item) {
@@ -298,16 +298,17 @@
const active = this.candidates.find((candidate) => candidate.taskStatus === 'PROCESSING' && candidate.claimedByCurrentOperator === true) const active = this.candidates.find((candidate) => candidate.taskStatus === 'PROCESSING' && candidate.claimedByCurrentOperator === true)
if (active && active.relabelNo !== item.relabelNo) return this.promptActiveTask(active) 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&task=${encodeURIComponent(item.relabelNo)}` })
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
await this.run('claim', async () => { this.applyCurrent(await claimRelabelTask(item.relabelNo, { idempotencyKey: key.value })); releaseWarehouseKey('relabel', key.signature); claimed = true }, true, key) await this.run('claim', async () => { this.applyCurrent(await claimRelabelTask(item.relabelNo, { idempotencyKey: key.value })); releaseWarehouseKey('relabel', key.signature); claimed = true }, true, key)
if (claimed) uni.redirectTo({ url: '/pages/relabel/relabel?op=relabelExecute' }) if (claimed) uni.redirectTo({ url: `/pages/relabel/relabel?op=relabelExecute&task=${encodeURIComponent(item.relabelNo)}` })
}, },
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()) { 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) } }) }, 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' }) },
closeCurrent() { this.current = null; this.selectedItem = null; this.error = null; this.successMessage = ''; this.activeTarget = 'task'; this.taskCode = '' },
async review(decision) { if (!this.current || this.current.taskStatus !== 'WAIT_REVIEW') return; const payload = { relabelNo: this.current.relabelNo, decision, expectedVersion: this.current.version, remark: this.remark.trim() }; const key = warehouseIdempotencyKey('relabel', 'review', payload); await this.run('review', async () => { this.applyCurrent(await reviewRelabelTask(this.current.relabelNo, { decision, expectedVersion: this.current.version, idempotencyKey: key.value, remark: this.remark.trim() || undefined })); releaseWarehouseKey('relabel', key.signature); this.remark = ''; this.successMessage = decision === 'APPROVE' ? '换标复核已通过' : '换标任务已退回重做'; await this.loadCandidates() }, true, key) }, async review(decision) { if (!this.current || this.current.taskStatus !== 'WAIT_REVIEW') return; const payload = { relabelNo: this.current.relabelNo, decision, expectedVersion: this.current.version, remark: this.remark.trim() }; const key = warehouseIdempotencyKey('relabel', 'review', payload); await this.run('review', async () => { this.applyCurrent(await reviewRelabelTask(this.current.relabelNo, { decision, expectedVersion: this.current.version, idempotencyKey: key.value, remark: this.remark.trim() || undefined })); releaseWarehouseKey('relabel', key.signature); this.remark = ''; this.successMessage = decision === 'APPROVE' ? '换标复核已通过' : '换标任务已退回重做'; await this.loadCandidates() }, true, key) },
async retry() { if (this.current?.relabelNo) await this.openTask(this.current.relabelNo, false); else await this.loadCandidates() }, async retry() { if (this.current?.relabelNo) await this.openTask(this.current.relabelNo, false); else await this.loadCandidates() },
async reloadCurrent() { if (this.current?.relabelNo) this.applyCurrent(await getRelabelTask(this.current.relabelNo)) }, async reloadCurrent() { if (this.current?.relabelNo) this.applyCurrent(await getRelabelTask(this.current.relabelNo)) },
@@ -319,6 +320,6 @@
} }
</script> </script>
<style scoped lang="scss">@import '../warehouse/operations.scss';.action-row>.danger,.action-row>.primary{width:50%}.item-card.current{border-color:#6ea3ee;background:#f3f8ff}.tab-row{display:flex;gap:10px;margin:14px 0 10px}.tab-button{flex:1;border:1px solid #d7e1ef;border-radius:10px;background:#fff;color:#52647c;font-size:15px;padding:10px 8px}.tab-button.active{border-color:#2167e8;background:#2167e8;color:#fff;font-weight:600} <style scoped lang="scss">@import '../warehouse/operations.scss';.action-row>.danger,.action-row>.primary{width:50%}.item-card.current{border-color:#6ea3ee;background:#f3f8ff}.detail-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:4px 0 10px;padding:10px 12px;border:1px solid #bed4f6;border-radius:11px;background:#eef5ff}.detail-kicker,.detail-title{display:block}.detail-kicker{color:#6580a4;font-size:9px}.detail-title{margin-top:2px;color:#153d74;font-size:14px;font-weight:800}.detail-toolbar button{flex-shrink:0;min-height:34px;margin:0;padding:0 11px;border-radius:8px;color:#135ee8;font-size:10px;line-height:34px;background:#fff}.tab-row{display:flex;gap:10px;margin:14px 0 10px}.tab-button{flex:1;border:1px solid #d7e1ef;border-radius:10px;background:#fff;color:#52647c;font-size:15px;padding:10px 8px}.tab-button.active{border-color:#2167e8;background:#2167e8;color:#fff;font-weight:600}
.photo-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:12px}.photo-tile{min-width:0;overflow:hidden;border:1px solid #d7e1ef;border-radius:8px}.photo-thumbnail{display:block;width:100%;height:140px;background:#f1f4f8}.photo-placeholder{margin:0;border:0;border-radius:0;font-size:13px;white-space:normal;line-height:1.5;display:flex;align-items:center;justify-content:center}.photo-name{display:block;padding:6px 8px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:12px;color:#52647c}.photo-actions{display:flex;justify-content:space-around;padding:4px}.photo-actions .link-button{margin:0}.bulk-total-button{margin-top:14px}.bulk-quantity{box-sizing:border-box;width:100%;padding:12px;border:1px solid #d7e1ef;border-radius:8px;background:#fff}.source-allocation{margin-top:14px;padding:12px;border-radius:10px;background:#f6f8fb}.source-title{display:block;font-weight:700;color:#24364b;margin-bottom:8px}.source-row,.source-total{display:flex;justify-content:space-between;gap:12px;padding:8px 0;color:#52647c}.source-row+ .source-row{border-top:1px solid #e4eaf2}.source-row text{min-width:0;overflow-wrap:anywhere}.source-row strong,.source-total strong{white-space:nowrap;color:#1f5fca}.source-total{border-top:1px solid #d7e1ef;font-weight:700} .photo-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:12px}.photo-tile{min-width:0;overflow:hidden;border:1px solid #d7e1ef;border-radius:8px}.photo-thumbnail{display:block;width:100%;height:140px;background:#f1f4f8}.photo-placeholder{margin:0;border:0;border-radius:0;font-size:13px;white-space:normal;line-height:1.5;display:flex;align-items:center;justify-content:center}.photo-name{display:block;padding:6px 8px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:12px;color:#52647c}.photo-actions{display:flex;justify-content:space-around;padding:4px}.photo-actions .link-button{margin:0}.bulk-total-button{margin-top:14px}.bulk-quantity{box-sizing:border-box;width:100%;padding:12px;border:1px solid #d7e1ef;border-radius:8px;background:#fff}.source-allocation{margin-top:14px;padding:12px;border-radius:10px;background:#f6f8fb}.source-title{display:block;font-weight:700;color:#24364b;margin-bottom:8px}.source-row,.source-total{display:flex;justify-content:space-between;gap:12px;padding:8px 0;color:#52647c}.source-row+ .source-row{border-top:1px solid #e4eaf2}.source-row text{min-width:0;overflow-wrap:anywhere}.source-row strong,.source-total strong{white-space:nowrap;color:#1f5fca}.source-total{border-top:1px solid #d7e1ef;font-weight:700}
</style> </style>
+35 -15
View File
@@ -1,5 +1,11 @@
export const SCAN_BROADCAST_ACTION = 'android.intent.action.SCANRESULT' export const SCAN_BROADCAST_ACTION = 'android.intent.action.SCANRESULT'
export const SCAN_BROADCAST_EXTRA = 'value' export const SCAN_BROADCAST_EXTRA = 'value'
export const SCAN_BROADCAST_ACTIONS = Object.freeze([
SCAN_BROADCAST_ACTION,
'com.android.server.scannerservice.broadcast',
'com.sunmi.scanner.ACTION_DATA_CODE_RECEIVED'
])
const SCAN_BROADCAST_EXTRAS = Object.freeze(['value', 'data', 'barcode', 'SCAN_BARCODE1'])
const DUPLICATE_WINDOW_MS = 80 const DUPLICATE_WINDOW_MS = 80
@@ -29,17 +35,23 @@ export function startScanBroadcast({ onScan, onError } = {}) {
try { try {
if (receiver) stopScanBroadcast() if (receiver) stopScanBroadcast()
activity = plus.android.runtimeMainActivity() const registeredActivity = plus.android.runtimeMainActivity()
const IntentFilter = plus.android.importClass('android.content.IntentFilter') const IntentFilter = plus.android.importClass('android.content.IntentFilter')
const filter = new IntentFilter() const filter = new IntentFilter()
filter.addAction(SCAN_BROADCAST_ACTION) SCAN_BROADCAST_ACTIONS.forEach((action) => filter.addAction(action))
receiver = plus.android.implements('io.dcloud.feature.internal.reflect.BroadcastReceiver', { const registeredReceiver = plus.android.implements('io.dcloud.feature.internal.reflect.BroadcastReceiver', {
onReceive(context, intent) { onReceive(context, intent) {
try { try {
plus.android.importClass(intent) plus.android.importClass(intent)
const rawValue = intent.getStringExtra(SCAN_BROADCAST_EXTRA) let rawValue = ''
for (const extra of SCAN_BROADCAST_EXTRAS) {
try {
const candidate = intent.getStringExtra(extra)
if (candidate !== null && candidate !== undefined && String(candidate).trim()) { rawValue = candidate; break }
} catch (_) {}
}
const value = normalizeScanValue(rawValue) const value = normalizeScanValue(rawValue)
console.info('[Overseas PDA broadcast] received', { action: intent.getAction(), extra: SCAN_BROADCAST_EXTRA, rawValue, value }) console.info('[Overseas PDA broadcast] received', { action: intent.getAction(), extras: SCAN_BROADCAST_EXTRAS, rawValue, value })
if (!value) { console.warn('[Overseas PDA broadcast] ignored empty value'); return } if (!value) { console.warn('[Overseas PDA broadcast] ignored empty value'); return }
if (isDuplicateScan(value)) { console.info('[Overseas PDA broadcast] ignored duplicate', { value }); return } if (isDuplicateScan(value)) { console.info('[Overseas PDA broadcast] ignored duplicate', { value }); return }
onScan(value) onScan(value)
@@ -53,23 +65,26 @@ export function startScanBroadcast({ onScan, onError } = {}) {
const sdkInt = Number(Build.VERSION.SDK_INT) const sdkInt = Number(Build.VERSION.SDK_INT)
if (Build.VERSION.SDK_INT >= 33) { if (Build.VERSION.SDK_INT >= 33) {
const Context = plus.android.importClass('android.content.Context') const Context = plus.android.importClass('android.content.Context')
activity.registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED) registeredActivity.registerReceiver(registeredReceiver, filter, Context.RECEIVER_EXPORTED)
} else { } else {
activity.registerReceiver(receiver, filter) registeredActivity.registerReceiver(registeredReceiver, filter)
} }
activity = registeredActivity
receiver = registeredReceiver
let packageName = '' let packageName = ''
try { packageName = String(activity.getPackageName()) } catch (_) {} try { packageName = String(registeredActivity.getPackageName()) } catch (_) {}
console.info('[Overseas PDA broadcast] receiver registered', { console.info('[Overseas PDA broadcast] receiver registered', {
action: SCAN_BROADCAST_ACTION, actions: SCAN_BROADCAST_ACTIONS,
extra: SCAN_BROADCAST_EXTRA, extras: SCAN_BROADCAST_EXTRAS,
packageName, packageName,
sdkInt, sdkInt,
exported: sdkInt >= 33 exported: sdkInt >= 33
}) })
return () => stopRegisteredBroadcast(registeredActivity, registeredReceiver)
} catch (error) { } catch (error) {
console.error('[Overseas PDA broadcast] registration failed', { console.error('[Overseas PDA broadcast] registration failed', {
action: SCAN_BROADCAST_ACTION, actions: SCAN_BROADCAST_ACTIONS,
extra: SCAN_BROADCAST_EXTRA, extras: SCAN_BROADCAST_EXTRAS,
message: error?.message || String(error) message: error?.message || String(error)
}) })
receiver = null receiver = null
@@ -81,15 +96,20 @@ export function startScanBroadcast({ onScan, onError } = {}) {
return stopScanBroadcast return stopScanBroadcast
} }
export function stopScanBroadcast() { function stopRegisteredBroadcast(registeredActivity, registeredReceiver) {
// #ifdef APP-PLUS // #ifdef APP-PLUS
if (activity && receiver) { if (registeredActivity && registeredReceiver) {
try { try {
activity.unregisterReceiver(receiver) registeredActivity.unregisterReceiver(registeredReceiver)
} catch (_) {} } catch (_) {}
} }
// #endif // #endif
if (activity !== registeredActivity || receiver !== registeredReceiver) return
receiver = null receiver = null
activity = null activity = null
resetScanDeduplication() resetScanDeduplication()
} }
export function stopScanBroadcast() {
stopRegisteredBroadcast(activity, receiver)
}
+14
View File
@@ -170,6 +170,20 @@ test('pages expose loading empty error offline and permission diagnostics with g
assert.doesNotMatch(dispatch + outbound, /DeliveryOrder|deliveryStatus|customerVisit|\/deliveries/) assert.doesNotMatch(dispatch + outbound, /DeliveryOrder|deliveryStatus|customerVisit|\/deliveries/)
}) })
test('fulfillment workbench entries open task lists before details and keep explicit cross-workflow resume', async () => {
const [dispatch, outbound] = await Promise.all([read('../src/pages/dispatch/dispatch.vue'), read('../src/pages/outbound/outbound.vue')])
assert.match(dispatch, /resumeOnEntry: false/)
assert.match(dispatch, /onLoad\(options\) \{ this\.resumeOnEntry = options\?\.resume === '1' \}/)
assert.match(dispatch, /if \(this\.resumeOnEntry && fulfillment\.dispatch\.currentNo\) this\.openTask/)
assert.match(outbound, /if \(!this\.bootstrapped\) \{ this\.bootstrapped = true; this\.scanCode = ''; this\.loadTasks\(\); return \}/)
assert.match(outbound, /pages\/dispatch\/dispatch\?resume=1/)
for (const page of [dispatch, outbound]) {
assert.match(page, /detail-toolbar/)
assert.match(page, /上次作业/)
assert.match(page, /返回列表/)
}
})
test('dispatch vehicle-plate conflict uses actionable Chinese copy and preserves diagnostics', () => { test('dispatch vehicle-plate conflict uses actionable Chinese copy and preserves diagnostics', () => {
const error = toDispatchError({ const error = toDispatchError({
code: 'A0409', code: 'A0409',
+2
View File
@@ -53,6 +53,8 @@ test('login has no committed demo credentials and inbound operation implements t
assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_AUTH_API_BASE_URL/) assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_AUTH_API_BASE_URL/)
assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_OVERSEAS_API_BASE_URL/) assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_OVERSEAS_API_BASE_URL/)
assert.match(operation, /startScanBroadcast/) assert.match(operation, /startScanBroadcast/)
assert.match(operation, /palletWorkflowActive = Boolean\(this\.activeInbound \|\| this\.resolvedPallet\)/)
assert.match(operation, /queuedTarget: scan\.target/)
for (const action of ['prepareReceivingContainer', 'getCurrentReceiving', 'resolveCurrentCarton', 'resolveCurrentPallet', 'receiveCurrentBulk', 'receiveCurrentCarton', 'receiveCurrentWholePallet', 'completeCurrentReceiving']) assert.match(operation, new RegExp(action)) for (const action of ['prepareReceivingContainer', 'getCurrentReceiving', 'resolveCurrentCarton', 'resolveCurrentPallet', 'receiveCurrentBulk', 'receiveCurrentCarton', 'receiveCurrentWholePallet', 'completeCurrentReceiving']) assert.match(operation, new RegExp(action))
for (const path of ['/candidates', '/prepare', '/unload-photos/files', '/sign', '/cartons/resolve', '/pallets/resolve', '/receipts/bulk', '/receipts/carton', '/receipts/pallet', '/complete']) assert.match(inbound, new RegExp(path.replaceAll('/', '\\/'))) for (const path of ['/candidates', '/prepare', '/unload-photos/files', '/sign', '/cartons/resolve', '/pallets/resolve', '/receipts/bulk', '/receipts/carton', '/receipts/pallet', '/complete']) assert.match(inbound, new RegExp(path.replaceAll('/', '\\/')))
assert.match(inbound, /current\('\/pallets\/resolve'\)/) assert.match(inbound, /current\('\/pallets\/resolve'\)/)
+1 -1
View File
@@ -19,7 +19,7 @@ test('conflicting task opens modal and confirm resumes owned task without claimi
assert.equal(calls[0].cancelText, '取消') assert.equal(calls[0].cancelText, '取消')
assert.match(calls[0].content, /RL-ACTIVE/) assert.match(calls[0].content, /RL-ACTIVE/)
assert.equal(context.taskCode, 'RL-ACTIVE') assert.equal(context.taskCode, 'RL-ACTIVE')
assert.equal(calls[1].url, '/pages/relabel/relabel?op=relabelExecute') assert.equal(calls[1].url, '/pages/relabel/relabel?op=relabelExecute&task=RL-ACTIVE')
assert.equal(context.error, null) assert.equal(context.error, null)
assert.equal(context.busy, false) assert.equal(context.busy, false)
}) })
+47 -2
View File
@@ -4,15 +4,23 @@ import { readFile } from 'node:fs/promises'
import { import {
SCAN_BROADCAST_ACTION, SCAN_BROADCAST_ACTION,
SCAN_BROADCAST_ACTIONS,
SCAN_BROADCAST_EXTRA, SCAN_BROADCAST_EXTRA,
isDuplicateScan, isDuplicateScan,
normalizeScanValue, normalizeScanValue,
resetScanDeduplication resetScanDeduplication,
startScanBroadcast,
stopScanBroadcast
} from '../src/utils/scan-broadcast.js' } from '../src/utils/scan-broadcast.js'
test('scanner uses the confirmed PDA broadcast contract', () => { test('scanner uses the confirmed PDA broadcast contract', () => {
assert.equal(SCAN_BROADCAST_ACTION, 'android.intent.action.SCANRESULT') assert.equal(SCAN_BROADCAST_ACTION, 'android.intent.action.SCANRESULT')
assert.equal(SCAN_BROADCAST_EXTRA, 'value') assert.equal(SCAN_BROADCAST_EXTRA, 'value')
assert.deepEqual(SCAN_BROADCAST_ACTIONS, [
'android.intent.action.SCANRESULT',
'com.android.server.scannerservice.broadcast',
'com.sunmi.scanner.ACTION_DATA_CODE_RECEIVED'
])
assert.equal(normalizeScanValue(' EL-38201\r\n'), 'EL-38201') assert.equal(normalizeScanValue(' EL-38201\r\n'), 'EL-38201')
assert.equal(normalizeScanValue(null), '') assert.equal(normalizeScanValue(null), '')
}) })
@@ -25,7 +33,7 @@ test('scanner source keeps receiver diagnostics before page routing', async () =
assert.match(source, /receive failed/) assert.match(source, /receive failed/)
assert.match(source, /receiver registered/) assert.match(source, /receiver registered/)
assert.match(source, /registration failed/) assert.match(source, /registration failed/)
assert.match(source, /activity\.getPackageName\(\)/) assert.match(source, /registeredActivity\.getPackageName\(\)/)
}) })
test('scanner drops only immediate duplicate broadcasts', () => { test('scanner drops only immediate duplicate broadcasts', () => {
@@ -35,3 +43,40 @@ test('scanner drops only immediate duplicate broadcasts', () => {
assert.equal(isDuplicateScan('EL-38201', 1100), false) assert.equal(isDuplicateScan('EL-38201', 1100), false)
assert.equal(isDuplicateScan('EL-38202', 1110), false) assert.equal(isDuplicateScan('EL-38202', 1110), false)
}) })
test('an old page disposer cannot unregister the receiver owned by the newly shown page', () => {
const registered = []
const unregistered = []
class IntentFilter { addAction(action) { this.action = action } }
const activity = {
registerReceiver(value) { registered.push(value) },
unregisterReceiver(value) { unregistered.push(value) },
getPackageName() { return 'com.baiyuelogistics.pda.overseas' }
}
globalThis.plus = { android: {
runtimeMainActivity: () => activity,
importClass(name) {
if (name === 'android.content.IntentFilter') return IntentFilter
if (name === 'android.os.Build') return { VERSION: { SDK_INT: 32 } }
return name
},
implements: (_name, implementation) => implementation
} }
try {
const stopFirst = startScanBroadcast({ onScan() {} })
const first = registered[0]
const stopSecond = startScanBroadcast({ onScan() {} })
const second = registered[1]
assert.deepEqual(unregistered, [first])
stopFirst()
assert.deepEqual(unregistered, [first, first])
assert.ok(!unregistered.includes(second))
stopSecond()
assert.equal(unregistered.at(-1), second)
} finally {
stopScanBroadcast()
delete globalThis.plus
}
})
+7 -4
View File
@@ -107,9 +107,10 @@ test('relabel list opens a row directly in execution and completion returns to t
assert.doesNotMatch(page, /name="relabelTask"/) assert.doesNotMatch(page, /name="relabelTask"/)
assert.match(page, /@tap="openExecution\(item\)"/) assert.match(page, /@tap="openExecution\(item\)"/)
assert.match(page, /async openExecution\(item\)/) assert.match(page, /async openExecution\(item\)/)
assert.match(page, /uni\.redirectTo\(\{ url: '\/pages\/relabel\/relabel\?op=relabelExecute' \}\)/) assert.match(page, /relabelExecute&task=\$\{encodeURIComponent\(item\.relabelNo\)\}/)
assert.match(page, /当前账号正在执行 \$\{active\.relabelNo\}/) assert.match(page, /当前账号正在执行 \$\{active\.relabelNo\}/)
assert.match(page, /v-if="stage === 'execute'" class="work-card"><button class="secondary"[^>]*@tap="goToClaimList">返回换标列表<\/button>/) assert.match(page, /v-if="stage === 'execute' && showCurrent" class="detail-toolbar"[\s\S]*@tap="closeCurrent">\u2039 返回列表<\/button>/)
assert.match(page, /closeCurrent\(\) \{ this\.current = null;[\s\S]*this\.taskCode = '' \}/)
assert.match(page, /updateWarehouseWork\('relabel', \{ currentNo: '', photoFileIds: \[\], photoNames: \[\], remark: '' \}\)/) assert.match(page, /updateWarehouseWork\('relabel', \{ currentNo: '', photoFileIds: \[\], photoNames: \[\], remark: '' \}\)/)
assert.match(page, /if \(completed\) this\.goToClaimList\(\)/) assert.match(page, /if \(completed\) this\.goToClaimList\(\)/)
assert.match(page, /goToClaimList\(\) \{ this\.taskCode = ''; uni\.redirectTo\(\{ url: '\/pages\/relabel\/relabel\?op=relabelReceive' \}\) \}/) assert.match(page, /goToClaimList\(\) \{ this\.taskCode = ''; uni\.redirectTo\(\{ url: '\/pages\/relabel\/relabel\?op=relabelReceive' \}\) \}/)
@@ -119,7 +120,7 @@ test('relabel review is a read-only list and completed execution details stay hi
const page = await read('../src/pages/relabel/relabel.vue') const page = await read('../src/pages/relabel/relabel.vue')
const body = page.match(/showCurrent\(\) \{([^\n]+)\},/)[1] const body = page.match(/showCurrent\(\) \{([^\n]+)\},/)[1]
const visible = new Function(body) const visible = new Function(body)
const context = { stage: 'execute', current: { relabelNo: 'RL-1', taskStatus: 'PROCESSING' }, candidates: [{ relabelNo: 'RL-1', taskStatus: 'PROCESSING', claimedByCurrentOperator: true }] } const context = { stage: 'execute', entryTaskNo: '', 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), true, 'detail without ownership field must use server candidate ownership')
assert.equal(visible.call({ ...context, candidates: [] }), false) assert.equal(visible.call({ ...context, candidates: [] }), false)
assert.equal(visible.call({ ...context, stage: 'review' }), false) assert.equal(visible.call({ ...context, stage: 'review' }), false)
@@ -132,8 +133,10 @@ test('relabel review is a read-only list and completed execution details stay hi
const bootstrapBody = page.match(/async bootstrap\(\) \{([^\n]+)\},/)[1] const bootstrapBody = page.match(/async bootstrap\(\) \{([^\n]+)\},/)[1]
const bootstrap = new (Object.getPrototypeOf(async function () {}).constructor)(bootstrapBody) const bootstrap = new (Object.getPrototypeOf(async function () {}).constructor)(bootstrapBody)
const opened = [] const opened = []
await bootstrap.call({ ...context, taskCode: 'STALE', loadCandidates: async () => {}, openTask: async (no) => opened.push(no) }) await bootstrap.call({ ...context, entryTaskNo: 'RL-1', loadCandidates: async () => {}, openTask: async (no) => opened.push(no) })
assert.deepEqual(opened, ['RL-1']) assert.deepEqual(opened, ['RL-1'])
await bootstrap.call({ ...context, entryTaskNo: '', loadCandidates: async () => {}, openTask: async (no) => opened.push(no) })
assert.deepEqual(opened, ['RL-1'], 'workbench execution entry must stay on the processing-task list')
}) })
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', () => {