fix: sync PDA picking actions immediately

This commit is contained in:
2026-09-11 18:24:18 +08:00
parent ec62a8a397
commit 8e370aed83
4 changed files with 23 additions and 10 deletions
+2 -2
View File
@@ -19,8 +19,8 @@
## 作业流程原则
- 扫码是主路径:输入焦点、连续扫描、防抖、重复码、无效码、错箱和扫描反馈必须作为一等场景处理。
- 普通散箱拣货的扫描身份是后端下发的稳定箱唛箱码 `boxCode`,一次有效且未重复的箱码扫描表示拣出一箱;一件代发已封托的 `SEALED_PALLET` 明细允许扫描系统 `palletNo` 或托内任一成员 `boxCode` 定位整托,但两种入口都必须按计划快照中的 `palletNo` 以 `PICK_PALLET` 一次性提交,不能逐箱累计、部分拣货或跳拣。SKU、商品件数、入库单号和页面展示文案只用于辅助识别,不得作为扫码匹配键。
- 进入任务时由服务端标记“拣货中”;扫描结果先写入绑定作业员、任务和柜次的设备持久化草稿,仅在点击提交时批量写后端,禁止逐码提交。
- 普通散箱拣货的扫描身份是后端下发的稳定箱唛箱码 `boxCode`,一次有效且未重复的箱码扫描表示拣出一箱;一件代发已封托的 `SEALED_PALLET` 明细允许扫描系统 `palletNo`、扫描托内任一成员 `boxCode` 或在明细卡片手工确认整托,但三种入口都必须按计划快照中的 `palletNo` 以 `PICK_PALLET` 一次性提交,不能逐箱累计、部分拣货或跳拣。SKU、商品件数、入库单号和页面展示文案只用于辅助识别,不得作为扫码匹配键。
- 进入任务时由服务端标记“拣货中”;每次有效箱码或托盘码扫描,以及确认拣出、部分拣货、跳拣操作,都必须立即写入服务端,设备持久化草稿只用于恢复页面状态。底部提交只原子收尾尚未处理的明细并进入装柜取证,不重复提交已落库动作。
- 允许部分拣货和跳拣,但每个未完成项必须填写异常原因;存在未解决异常时任务挂起,不能完成或推进 `LOADED`。
- 作业提交使用对员工不可见的轻量幂等标识和服务端版本;重复提交返回原结果,版本冲突提示刷新后重试,不静默覆盖。
- 提交成功或明确放弃后清理草稿;退出应用或设备重启后同一作业员、任务和柜次可恢复,其他账号不得继承。
+1 -1
View File
@@ -23,7 +23,7 @@
登录会话集中在 `stores/session.js` 并持久化到设备,拣货会话集中在 `stores/picking.js`。认证请求由 `api/http.js` 统一处理:Access Token 临近过期时使用 Refresh Token 轮换;并发请求共享一次刷新;401 刷新成功后只重放一次;Refresh Token 明确失效时清理会话和作业草稿并返回登录页,网络或服务异常不会误清会话。产品契约要求未提交拣货草稿持久化到设备,并绑定作业员、任务和柜次,其他账号不能继承;成功提交或明确放弃后才清理。页面头部与底部主导航位于 `components/`,演示账号、模块和任务数据位于 `data/demo-data.js`,跨页面 PDA 视觉规则位于 `styles/pda.scss`。
拣货原型会在本次登录会话内按箱保存本地草稿和最近扫码结果。已确认的正式流程是:进入任务即由后端标记“拣货中”;扫码只更新本地草稿,员工点击提交时才将整批明细写入后端,不逐码提交。允许部分拣货或跳拣,但每个未完成项必须填写异常原因,并挂起任务、阻断完成和 `LOADED`。当前草稿尚未持久化到设备,这是实现缺口,不代表允许离线作业。
拣货页会在设备端保留当前任务草稿和最近扫码结果,但正式作业以服务端事实为准:进入任务后由后端标记“拣货中”,每次有效箱码/托盘码扫描及“确认拣出、部分拣货、跳拣”操作立即写入服务端,后台智能装柜据此锁定已发生实物拣货的明细。底部提交只负责收尾当前仍未处理的明细并进入装柜取证;网络失败时页面不得先显示成功。允许部分拣货或跳拣,但每个未完成项必须填写异常原因,并挂起任务、阻断完成和 `LOADED`。
正式 PDA 作业必须联网。断网时不能开始正式拣货、提交、完成任务或推进 `LOADED`;网络恢复后由员工刷新并重试。提交使用对员工不可见的轻量幂等键和服务端版本:重复提交返回原结果,版本冲突提示刷新。项目不建设离线业务提交队列。
+17 -7
View File
@@ -78,7 +78,7 @@
:class="itemState(item)"
>
<view class="pick-item-row1">
<text class="pick-item-sku">{{ isSealedPalletItem(item) ? '整托拣货' : '拣货明细' }}</text>
<text class="pick-item-sku">{{ isSealedPalletItem(item) ? item.palletNo : '拣货明细' }}</text>
<view class="pick-item-meta">
<text v-if="itemState(item) === ITEM_STATUS.PICKED" class="pick-status-tag tag-picked">已拣 ✓</text>
<text v-else-if="itemState(item) === ITEM_STATUS.PARTIAL" class="pick-status-tag tag-partial">
@@ -99,7 +99,7 @@
</view>
<view v-if="itemState(item) === ITEM_STATUS.PENDING && isSealedPalletItem(item)" class="pick-item-actions">
<text class="version-hint">扫描托盘码 {{ item.palletNo }} 或托内任一箱码,确认整托装柜</text>
<button class="pick-btn pick-btn-confirm" @tap="pickItem(item.id)">确认整托拣出</button>
</view>
<view v-else-if="itemState(item) === ITEM_STATUS.PENDING" class="pick-item-actions">
<button class="pick-btn pick-btn-confirm" @tap="pickItem(item.id)">确认拣出</button>
@@ -970,8 +970,8 @@
const reason = this.itemState(item) === ITEM_STATUS.SKIPPED && item.skipReason
? ` · ${item.skipReason}`
: ''
const pallet = isSealedPalletItem(item) && item.palletNo ? `${item.palletNo} · ` : ''
return `${pallet}${item.desc}${reason}`
const inbound = isSealedPalletItem(item) && item.inboundCode ? `${item.inboundCode} · ` : ''
return `${inbound}${item.desc}${reason}`
},
async processScannedValue(scanValue, source = 'broadcast') {
if (!this.requirePickingOperation()) return
@@ -1452,14 +1452,24 @@
this.partialQuantities = { ...this.partialQuantities, [item.id]: plannedCount }
this.itemStates = { ...this.itemStates, [item.id]: ITEM_STATUS.PICKED }
this.recordScanResult('success', '整托扫描成功', palletNo, `已定位托盘 ${item.palletNo} · 共 ${plannedCount} 箱`)
this.showToast(`托盘 ${item.palletNo} 已确认装柜`, 'success')
this.showToast(`托盘 ${item.palletNo} 已确认整托拣出`, 'success')
this.persistSession()
return true
},
async pickItem(id, source = 'manual') {
if (!this.requirePickingOperation()) return
const item = this.currentBox.items.find((entry) => entry.id === id)
if (isSealedPalletItem(item)) { this.showToast(`已封托货物必须扫描托盘码 ${item.palletNo}`, 'warning'); return }
if (isSealedPalletItem(item)) {
const submitted = await this.submitImmediateItemAction(item, 'PICK_PALLET', { palletNo: item.palletNo })
if (!submitted) return
const plannedCount = Number(item.qty || 0)
this.scannedBoxCodes = { ...this.scannedBoxCodes, [item.palletNo]: item.id }
this.partialQuantities = { ...this.partialQuantities, [item.id]: plannedCount }
this.itemStates = { ...this.itemStates, [item.id]: ITEM_STATUS.PICKED }
this.showToast(`托盘 ${item.palletNo} 已确认整托拣出`, 'success')
this.persistSession()
return
}
const available = this.availablePickQuantity(item)
if (available !== null && available < Number(item.qty || 0)) { this.showToast(`库存/库位可用量不足:可拣 ${available}`, 'error'); return }
const remaining = Number(item?.qty || 0) - Number(item?.pickedPackageCount || 0)
@@ -1641,7 +1651,7 @@
this.remoteError = ''
const pendingPallet = this.currentBox.items.find((item) => isSealedPalletItem(item) && this.itemState(item) !== ITEM_STATUS.PICKED)
if (pendingPallet) {
this.remoteError = `请扫描托盘码 ${pendingPallet.palletNo} 或托内任一箱码后再提交`
this.remoteError = `请扫描托盘码 ${pendingPallet.palletNo}、托内任一箱码,或点击“确认整托拣出”后再提交`
this.showToast(this.remoteError, 'warning')
return
}
+3
View File
@@ -128,6 +128,9 @@ test('PDA cabinet picking adapter claims online and submits one atomic batch', (
assert.match(source, /palletNo: action\.type === 'PICK_PALLET' \? action\.palletNo : null/)
assert.match(page, /type: 'PICK_PALLET'[\s\S]*?palletNo: item\.palletNo/)
assert.match(page, /扫描托盘码[\s\S]*?托内任一箱码/)
assert.match(page, /确认整托拣出/)
assert.match(page, /async pickItem[\s\S]*?isSealedPalletItem\(item\)[\s\S]*?submitImmediateItemAction\(item, 'PICK_PALLET', \{ palletNo: item\.palletNo \}\)/)
assert.match(page, /或点击“确认整托拣出”后再提交/)
assert.match(page, /sanitizeUserMessage\(error\.message, '任务状态已变化,请刷新后重试'\)/)
assert.match(source, /data: \{ warehouseId, loadingPlanId: task\.loadingPlanId, containerBookingId: task\.containerBookingId, expectedVersion, inventorySnapshotToken, items \}/)
assert.doesNotMatch(source.match(/export async function submitContainerPickingBatch[\s\S]*?\n\}/)?.[0] || '', /releaseMode|locationIds|reservationIds/)