From cec002737644e14f8bffa9f1af1039972679b78d Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Sun, 20 Sep 2026 23:06:27 +0800
Subject: [PATCH 1/2] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/manifest.json | 4 +--
src/utils/scan-broadcast.js | 52 ++++++++++++++++++++++++-----------
tests/scan-broadcast.test.mjs | 49 +++++++++++++++++++++++++++++++--
3 files changed, 85 insertions(+), 20 deletions(-)
diff --git a/src/manifest.json b/src/manifest.json
index 6153bb4..69a2632 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -2,8 +2,8 @@
"name" : "柏悦物流 PDA 海外版",
"appid" : "__UNI__936D079",
"description" : "柏悦物流海外仓 PDA 作业端",
- "versionName" : "0.0.3",
- "versionCode" : 3,
+ "versionName" : "0.0.4",
+ "versionCode" : 4,
"transformPx" : false,
"app-plus" : {
"usingComponents" : true,
diff --git a/src/utils/scan-broadcast.js b/src/utils/scan-broadcast.js
index 2cc7b56..ca71e05 100644
--- a/src/utils/scan-broadcast.js
+++ b/src/utils/scan-broadcast.js
@@ -1,5 +1,11 @@
export const SCAN_BROADCAST_ACTION = 'android.intent.action.SCANRESULT'
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
@@ -29,17 +35,23 @@ export function startScanBroadcast({ onScan, onError } = {}) {
try {
if (receiver) stopScanBroadcast()
- activity = plus.android.runtimeMainActivity()
+ const registeredActivity = plus.android.runtimeMainActivity()
const IntentFilter = plus.android.importClass('android.content.IntentFilter')
const filter = new IntentFilter()
- filter.addAction(SCAN_BROADCAST_ACTION)
- receiver = plus.android.implements('io.dcloud.feature.internal.reflect.BroadcastReceiver', {
- onReceive(context, intent) {
+ SCAN_BROADCAST_ACTIONS.forEach((action) => filter.addAction(action))
+ const registeredReceiver = plus.android.implements('io.dcloud.feature.internal.reflect.BroadcastReceiver', {
+ onReceive(context, intent) {
try {
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)
- 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 (isDuplicateScan(value)) { console.info('[Overseas PDA broadcast] ignored duplicate', { value }); return }
onScan(value)
@@ -53,23 +65,26 @@ export function startScanBroadcast({ onScan, onError } = {}) {
const sdkInt = Number(Build.VERSION.SDK_INT)
if (Build.VERSION.SDK_INT >= 33) {
const Context = plus.android.importClass('android.content.Context')
- activity.registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED)
+ registeredActivity.registerReceiver(registeredReceiver, filter, Context.RECEIVER_EXPORTED)
} else {
- activity.registerReceiver(receiver, filter)
+ registeredActivity.registerReceiver(registeredReceiver, filter)
}
+ activity = registeredActivity
+ receiver = registeredReceiver
let packageName = ''
- try { packageName = String(activity.getPackageName()) } catch (_) {}
+ try { packageName = String(registeredActivity.getPackageName()) } catch (_) {}
console.info('[Overseas PDA broadcast] receiver registered', {
- action: SCAN_BROADCAST_ACTION,
- extra: SCAN_BROADCAST_EXTRA,
+ actions: SCAN_BROADCAST_ACTIONS,
+ extras: SCAN_BROADCAST_EXTRAS,
packageName,
sdkInt,
exported: sdkInt >= 33
})
+ return () => stopRegisteredBroadcast(registeredActivity, registeredReceiver)
} catch (error) {
console.error('[Overseas PDA broadcast] registration failed', {
- action: SCAN_BROADCAST_ACTION,
- extra: SCAN_BROADCAST_EXTRA,
+ actions: SCAN_BROADCAST_ACTIONS,
+ extras: SCAN_BROADCAST_EXTRAS,
message: error?.message || String(error)
})
receiver = null
@@ -81,15 +96,20 @@ export function startScanBroadcast({ onScan, onError } = {}) {
return stopScanBroadcast
}
-export function stopScanBroadcast() {
+function stopRegisteredBroadcast(registeredActivity, registeredReceiver) {
// #ifdef APP-PLUS
- if (activity && receiver) {
+ if (registeredActivity && registeredReceiver) {
try {
- activity.unregisterReceiver(receiver)
+ registeredActivity.unregisterReceiver(registeredReceiver)
} catch (_) {}
}
// #endif
+ if (activity !== registeredActivity || receiver !== registeredReceiver) return
receiver = null
activity = null
resetScanDeduplication()
}
+
+export function stopScanBroadcast() {
+ stopRegisteredBroadcast(activity, receiver)
+}
diff --git a/tests/scan-broadcast.test.mjs b/tests/scan-broadcast.test.mjs
index 827dde2..591be69 100644
--- a/tests/scan-broadcast.test.mjs
+++ b/tests/scan-broadcast.test.mjs
@@ -4,15 +4,23 @@ import { readFile } from 'node:fs/promises'
import {
SCAN_BROADCAST_ACTION,
+ SCAN_BROADCAST_ACTIONS,
SCAN_BROADCAST_EXTRA,
isDuplicateScan,
normalizeScanValue,
- resetScanDeduplication
+ resetScanDeduplication,
+ startScanBroadcast,
+ stopScanBroadcast
} from '../src/utils/scan-broadcast.js'
test('scanner uses the confirmed PDA broadcast contract', () => {
assert.equal(SCAN_BROADCAST_ACTION, 'android.intent.action.SCANRESULT')
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(null), '')
})
@@ -25,7 +33,7 @@ test('scanner source keeps receiver diagnostics before page routing', async () =
assert.match(source, /receive failed/)
assert.match(source, /receiver registered/)
assert.match(source, /registration failed/)
- assert.match(source, /activity\.getPackageName\(\)/)
+ assert.match(source, /registeredActivity\.getPackageName\(\)/)
})
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-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
+ }
+})
From e4f02c5e73790728f3daef31f9f163672897cd6f Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Mon, 21 Sep 2026 11:17:17 +0800
Subject: [PATCH 2/2] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/pages/dispatch/dispatch.vue | 19 ++++++++++++++-----
src/pages/dispatch/fulfillment.scss | 9 +++++++++
src/pages/operation/operation.vue | 2 +-
src/pages/outbound/outbound.vue | 14 ++++++++------
src/pages/relabel/relabel.vue | 19 ++++++++++---------
tests/fulfillment-workflows.test.mjs | 14 ++++++++++++++
tests/project-contract.test.mjs | 2 ++
tests/relabel-active-task.test.mjs | 2 +-
tests/warehouse-workflows.test.mjs | 11 +++++++----
9 files changed, 66 insertions(+), 26 deletions(-)
diff --git a/src/pages/dispatch/dispatch.vue b/src/pages/dispatch/dispatch.vue
index 83c4b24..e57881b 100644
--- a/src/pages/dispatch/dispatch.vue
+++ b/src/pages/dispatch/dispatch.vue
@@ -7,6 +7,7 @@
排车拣货与发车
客户与当前海外仓由登录令牌和服务端确定;扫描排车单号或任务中的托盘码开始作业
+ 当前任务排车拣货详情
@@ -25,11 +26,12 @@
- 待处理排车任务
+ 待处理排车任务{{ tasks.length }} 个任务
暂无排车拣货任务可刷新列表,或直接扫描排车单号恢复作业。
@@ -40,7 +42,6 @@
{{ current.dispatchNo || '—' }}{{ statusLabel(current.status) }}
任务号 {{ current.taskNo || '—' }} · 车牌 {{ current.vehiclePlate || '—' }}
{{ plannedPackages }}计划箱数{{ pickedPackages }}已拣箱数{{ remainingPackages }}待拣箱数
-
@@ -95,8 +96,9 @@
export default {
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: {
+ recentTaskNo() { return fulfillment.dispatch.currentNo || '' },
packages() { return fulfillment.dispatch.packages },
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 },
@@ -112,12 +114,19 @@
canComplete() { return this.plannedPackages > 0 && this.remainingPackages === 0 },
canDepart() { return this.pickingCompleted && this.allPicked && this.current?.canDepart === true && !this.departed }
},
+ onLoad(options) { this.resumeOnEntry = options?.resume === '1' },
onShow() {
if (!requireSession()) return
this.user = session.currentUser
restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`)
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()
},
onHide() { this.stopScanner() },
diff --git a/src/pages/dispatch/fulfillment.scss b/src/pages/dispatch/fulfillment.scss
index d8b5522..a9cc34e 100644
--- a/src/pages/dispatch/fulfillment.scss
+++ b/src/pages/dispatch/fulfillment.scss
@@ -21,13 +21,22 @@
.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 { 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; }
.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::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; }
+ .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; }
.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; }
.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; }
diff --git a/src/pages/operation/operation.vue b/src/pages/operation/operation.vue
index 52de268..fda78eb 100644
--- a/src/pages/operation/operation.vue
+++ b/src/pages/operation/operation.vue
@@ -181,7 +181,7 @@
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 },
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) },
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' },
diff --git a/src/pages/outbound/outbound.vue b/src/pages/outbound/outbound.vue
index a455740..b1dd1c8 100644
--- a/src/pages/outbound/outbound.vue
+++ b/src/pages/outbound/outbound.vue
@@ -4,6 +4,7 @@
出库任务一件代发扫描托盘码可整托出库;扫描排车业务号会转入“排车拣货”
+ 当前任务出库作业详情
@@ -14,15 +15,15 @@
{{ error.message }}错误码:{{ error.code }}requestId:{{ error.requestId }}traceId:{{ error.traceId }}网络连接失败,当前任务、扫码草稿和幂等键均已保留。
- 待处理出库任务
+ 待处理出库任务{{ tasks.length }} 个任务
-
+
暂无出库任务可刷新列表,或扫描任务号恢复未完成作业。
- {{ current.taskNo || '—' }}{{ statusLabel(current.status) }}出库单 {{ current.outboundNo || '—' }} · {{ taskType(current) }}{{ plannedPackages }}计划箱数{{ pickedPackages }}已拣箱数{{ remainingPackages }}待拣箱数
+ {{ current.taskNo || '—' }}{{ statusLabel(current.status) }}出库单 {{ current.outboundNo || '—' }} · {{ taskType(current) }}{{ plannedPackages }}计划箱数{{ pickedPackages }}已拣箱数{{ remainingPackages }}待拣箱数
@@ -57,8 +58,9 @@
export default {
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: {
+ recentTaskNo() { return fulfillment.outbound.currentTaskNo || '' },
packages() { return fulfillment.outbound.packages },
plannedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.plannedPackages, 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 },
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() },
beforeUnmount() { this.stopScanner() },
methods: {
@@ -83,7 +85,7 @@
if (resolution?.workflow === 'DISPATCH' && resolution.dispatchNo) {
updateFulfillmentDraft('outbound', { currentTaskNo: '', scanCode: '', 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
}
if (resolution?.workflow !== 'OUTBOUND' || !resolution.taskNo) throw new Error('服务端未返回可执行的出库任务')
diff --git a/src/pages/relabel/relabel.vue b/src/pages/relabel/relabel.vue
index b68298c..f3923d0 100644
--- a/src/pages/relabel/relabel.vue
+++ b/src/pages/relabel/relabel.vue
@@ -3,13 +3,13 @@
{{ title }}{{ stageSubtitle }}
-
+ 当前任务换标执行详情
{{ candidateHeading }}
正在加载换标任务…
{{ emptyText }}
- {{ item.relabelNo }}客户订单:{{ item.customerOrderNo || '—' }}换标类型:{{ relabelTypeName(item.operationType) }}{{ statusLabel(item.taskStatus || item.applicationStatus) }}{{ item.expectedPackages }}应处理{{ item.relabelledPackages }}已换标{{ item.skippedPackages + item.exceptionPackages }}跳过/异常执行 ›
+ {{ item.relabelNo }}客户订单:{{ item.customerOrderNo || '—' }}换标类型:{{ relabelTypeName(item.operationType) }}{{ statusLabel(item.taskStatus || item.applicationStatus) }}{{ item.expectedPackages }}应处理{{ item.relabelledPackages }}已换标{{ item.skippedPackages + item.exceptionPackages }}跳过/异常查看并执行 ›
{{ item.relabelNo }}客户订单:{{ item.customerOrderNo || '—' }}换标类型:{{ relabelTypeName(item.operationType) }}{{ statusLabel(item.taskStatus || item.applicationStatus) }}{{ item.expectedPackages }}应处理{{ item.relabelledPackages }}已换标{{ item.skippedPackages + item.exceptionPackages }}跳过/异常
@@ -91,7 +91,7 @@
const isConflict = (error) => ['409', 'A0409', 'B0409'].includes(String(error?.code || ''))
export default {
components: { PortalHeader, ScanTargetInput, RelabelPalletWork },
- data() { return { user: session.currentUser, stage: 'receive', unitDrafts: {}, taskTab: 'sku', candidates: [], current: null, selectedItem: null, 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: {
isReturnBulk() { return this.current?.operationType === 'RETURN_BULK_SKU_LABEL' },
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()) },
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: {} } } }],
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: '' }) } },
@@ -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) },
handleOldLabel(value) { this.oldLabel = String(value?.detail?.value ?? value ?? '').trim(); if (this.action === 'RELABEL') this.activeTarget = this.taskTab === 'sku' ? 'sku-new-0' : 'new' },
handleNewLabel(value) { this.newLabel = String(value?.detail?.value ?? value ?? '').trim() },
- async bootstrap() { await this.loadCandidates(); if (this.error || this.stage !== 'execute') return; const owned = this.candidates.filter((item) => item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator === true); const target = owned.find((item) => item.relabelNo === this.taskCode) || (owned.length === 1 ? owned[0] : null); if (target) await this.openTask(target.relabelNo, false) },
+ async 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 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 },
@@ -286,7 +286,7 @@
}))
if (!confirmed) return
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 }
},
async openExecution(item) {
@@ -298,16 +298,17 @@
const active = this.candidates.find((candidate) => candidate.taskStatus === 'PROCESSING' && candidate.claimedByCurrentOperator === true)
if (active && active.relabelNo !== item.relabelNo) return this.promptActiveTask(active)
this.taskCode = item.relabelNo
- if (item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator) return uni.redirectTo({ url: '/pages/relabel/relabel?op=relabelExecute' })
+ 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
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) },
choosePhoto() { if (this.busy || !this.current || this.photoFileIds.length >= 20) return; const no = this.current.relabelNo; uni.chooseImage({ count: Math.min(9, 20 - this.photoFileIds.length), sourceType: ['camera', 'album'], success: async ({ tempFilePaths, tempFiles }) => { if (this.current?.relabelNo !== no) return; await this.run('photo', async () => { for (const [index, path] of (tempFilePaths || []).entries()) { const selected = tempFiles?.[index]?.file || tempFiles?.[index]; if (Number(tempFiles?.[index]?.size || 0) > 10 * 1024 * 1024) throw new Error('照片不能超过 10MB'); const uploadSource = typeof Blob !== 'undefined' && selected instanceof Blob ? selected : path; const file = await uploadRelabelPhoto(no, uploadSource); this.photoUrls[String(file.fileId)] = path; updateWarehouseWork('relabel', { photoTaskNo: no, photoFileIds: [...this.photoFileIds, String(file.fileId)], photoNames: [...this.photoNames, file.fileName || '换标现场照片'] }) } this.successMessage = '现场照片已上传' }, false) } }) },
async uploadPhoto(path) { await this.run('photo', async () => { const file = await uploadRelabelPhoto(this.current.relabelNo, path); updateWarehouseWork('relabel', { photoFileIds: [...this.photoFileIds, String(file.fileId)], photoNames: [...this.photoNames, file.fileName || '换标现场照片'] }); this.successMessage = '现场照片已上传' }, false) },
async completeTask() { if (this.busy || !this.photoFileIds.length || (!this.allProcessed && !this.remark.trim())) return; const payload = { photoFileIds: [...this.photoFileIds], expectedVersion: this.current.version, remark: this.remark.trim(), manualCompletion: !this.allProcessed }; const no = this.current.relabelNo; if (payload.manualCompletion) { const confirmed = await new Promise((resolve) => uni.showModal({ title: '手动完成换标', content: '未处理箱将登记异常,关联尾程订单将标为异常。确认提交?', success: (result) => resolve(result.confirm), fail: () => resolve(false) })); if (!confirmed || this.current?.relabelNo !== no || this.current.version !== payload.expectedVersion) return } const key = warehouseIdempotencyKey('relabel', 'complete', { relabelNo: no, ...payload }); let completed = false; await this.run('complete', async () => { await completeRelabelTask(no, { ...payload, idempotencyKey: key.value }); releaseWarehouseKey('relabel', key.signature); updateWarehouseWork('relabel', { currentNo: '', photoFileIds: [], photoNames: [], remark: '' }); completed = true }, true, key); if (completed) this.goToClaimList() },
goToClaimList() { this.taskCode = ''; uni.redirectTo({ url: '/pages/relabel/relabel?op=relabelReceive' }) },
+ 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 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)) },
@@ -319,6 +320,6 @@
}
-
diff --git a/tests/fulfillment-workflows.test.mjs b/tests/fulfillment-workflows.test.mjs
index 045796a..068f965 100644
--- a/tests/fulfillment-workflows.test.mjs
+++ b/tests/fulfillment-workflows.test.mjs
@@ -170,6 +170,20 @@ test('pages expose loading empty error offline and permission diagnostics with g
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', () => {
const error = toDispatchError({
code: 'A0409',
diff --git a/tests/project-contract.test.mjs b/tests/project-contract.test.mjs
index 2b3b443..74a6c91 100644
--- a/tests/project-contract.test.mjs
+++ b/tests/project-contract.test.mjs
@@ -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_OVERSEAS_API_BASE_URL/)
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 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'\)/)
diff --git a/tests/relabel-active-task.test.mjs b/tests/relabel-active-task.test.mjs
index e2080f8..3c0486a 100644
--- a/tests/relabel-active-task.test.mjs
+++ b/tests/relabel-active-task.test.mjs
@@ -19,7 +19,7 @@ test('conflicting task opens modal and confirm resumes owned task without claimi
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(calls[1].url, '/pages/relabel/relabel?op=relabelExecute&task=RL-ACTIVE')
assert.equal(context.error, null)
assert.equal(context.busy, false)
})
diff --git a/tests/warehouse-workflows.test.mjs b/tests/warehouse-workflows.test.mjs
index 391a12c..aa18a44 100644
--- a/tests/warehouse-workflows.test.mjs
+++ b/tests/warehouse-workflows.test.mjs
@@ -107,9 +107,10 @@ test('relabel list opens a row directly in execution and completion returns to t
assert.doesNotMatch(page, /name="relabelTask"/)
assert.match(page, /@tap="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, /v-if="stage === 'execute'" class="work-card">