整批提交原子性的方案
This commit is contained in:
@@ -19,7 +19,7 @@
|
||||
## 作业流程原则
|
||||
|
||||
- 扫码是主路径:输入焦点、连续扫描、防抖、重复码、无效码、错箱和扫描反馈必须作为一等场景处理。
|
||||
- 拣货扫描身份是后端下发的稳定箱唛箱码 `boxCode`;一次有效且未重复的箱码扫描表示拣出一箱。SKU、商品件数、入库单号和页面展示文案只用于辅助识别,不得作为扫码匹配键或拣货计数单位。
|
||||
- 普通散箱拣货的扫描身份是后端下发的稳定箱唛箱码 `boxCode`,一次有效且未重复的箱码扫描表示拣出一箱;一件代发已封托的 `SEALED_PALLET` 明细允许扫描系统 `palletNo` 或托内任一成员 `boxCode` 定位整托,但两种入口都必须按计划快照中的 `palletNo` 以 `PICK_PALLET` 一次性提交,不能逐箱累计、部分拣货或跳拣。SKU、商品件数、入库单号和页面展示文案只用于辅助识别,不得作为扫码匹配键。
|
||||
- 进入任务时由服务端标记“拣货中”;扫描结果先写入绑定作业员、任务和柜次的设备持久化草稿,仅在点击提交时批量写后端,禁止逐码提交。
|
||||
- 允许部分拣货和跳拣,但每个未完成项必须填写异常原因;存在未解决异常时任务挂起,不能完成或推进 `LOADED`。
|
||||
- 作业提交使用对员工不可见的轻量幂等标识和服务端版本;重复提交返回原结果,版本冲突提示刷新后重试,不静默覆盖。
|
||||
|
||||
+2
-1
@@ -119,7 +119,8 @@ export async function submitContainerPickingBatch(task, actions, { warehouseId,
|
||||
pickedPackageCount: action.type === 'PICK' ? action.pickedPackageCount : null,
|
||||
finalPickedPackageCount: action.type === 'PARTIAL' ? action.finalPickedPackageCount : null,
|
||||
version: action.version,
|
||||
reason: action.type === 'SKIP' ? action.reason : null
|
||||
reason: action.type === 'SKIP' ? action.reason : null,
|
||||
palletNo: action.type === 'PICK_PALLET' ? action.palletNo : null
|
||||
}))
|
||||
return request({
|
||||
url: `/first-leg/pda-loading/tasks/${encodeURIComponent(task.taskId)}/batch`,
|
||||
|
||||
+53
-13
@@ -38,7 +38,7 @@
|
||||
<view v-if="activeStage === 'picking'" class="picking-stage">
|
||||
<view class="scan-section">
|
||||
<view class="scan-row">
|
||||
<ScanTargetInput v-model="scanValue" name="boxCode" label="箱码扫码框" placeholder="扫描或输入箱唛标签码" :active="activeScanField === 'boxCode'" @activate="setScanTarget" @submit="handleManualSubmit" />
|
||||
<ScanTargetInput v-model="scanValue" name="boxCode" :label="scanInputLabel" :placeholder="scanPlaceholder" :active="activeScanField === 'boxCode'" @activate="setScanTarget" @submit="handleManualSubmit" />
|
||||
</view>
|
||||
<view v-if="lastScanResult" class="last-scan" :class="lastScanResult.type" role="status">
|
||||
<view class="last-scan-icon">{{ lastScanResult.type === 'success' ? '✓' : '!' }}</view>
|
||||
@@ -78,7 +78,7 @@
|
||||
:class="itemState(item)"
|
||||
>
|
||||
<view class="pick-item-row1">
|
||||
<text class="pick-item-sku">拣货明细</text>
|
||||
<text class="pick-item-sku">{{ isSealedPalletItem(item) ? '整托拣货' : '拣货明细' }}</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">
|
||||
@@ -98,7 +98,10 @@
|
||||
<text v-if="item.location" class="pick-item-location">{{ item.location }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="itemState(item) === ITEM_STATUS.PENDING" class="pick-item-actions">
|
||||
<view v-if="itemState(item) === ITEM_STATUS.PENDING && isSealedPalletItem(item)" class="pick-item-actions">
|
||||
<text class="version-hint">扫描托盘码 {{ item.palletNo }} 或托内任一箱码,确认整托装柜</text>
|
||||
</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>
|
||||
<button class="pick-btn pick-btn-partial" @tap="openPartial(item.id)">部分拣货</button>
|
||||
<button class="pick-btn pick-btn-skip" @tap="openSkip(item.id)">跳拣</button>
|
||||
@@ -333,7 +336,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ITEM_STATUS, classifyScan, createItemStates, getPickSummary, submitStates } from '@/utils/picking-state.js'
|
||||
import { ITEM_STATUS, classifyScan, createItemStates, getPickSummary, isSealedPalletItem, submitStates } from '@/utils/picking-state.js'
|
||||
import { SKIP_REASONS, INCOMPLETE_REASONS } from '@/data/demo-data.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { clearLoadingCompletionAttempt, clearPickingDraft, getPickingSession, restoreLoadingCompletionAttempt, restorePickingSession, saveLoadingCompletionAttempt, savePickingSession } from '@/stores/picking.js'
|
||||
@@ -413,6 +416,12 @@
|
||||
pendingItems() {
|
||||
return this.currentBox.items.filter((item) => this.itemState(item) === ITEM_STATUS.PENDING)
|
||||
},
|
||||
scanInputLabel() {
|
||||
return this.pendingItems.some((item) => isSealedPalletItem(item)) ? '托盘码或托内箱码扫码框' : '箱码扫码框'
|
||||
},
|
||||
scanPlaceholder() {
|
||||
return this.pendingItems.some((item) => isSealedPalletItem(item)) ? '扫描托盘码或托内任一箱码' : '扫描或输入箱唛标签码'
|
||||
},
|
||||
lastScanResult() {
|
||||
return this.scanHistory[0] || null
|
||||
},
|
||||
@@ -808,11 +817,15 @@
|
||||
const restored = restorePickingSession(this.draftIdentity())
|
||||
if (!restored) return false
|
||||
const itemIds = new Set(this.currentBox.items.map((item) => String(item.id)))
|
||||
const restoredScans = restored.scannedBoxCodes || {}
|
||||
const serverStates = { ...this.itemStates }
|
||||
const serverPartialQuantities = { ...this.partialQuantities }
|
||||
Object.entries(restored.itemStates || {}).forEach(([id, status]) => {
|
||||
if (!itemIds.has(String(id)) || serverStates[id] !== ITEM_STATUS.PENDING || status === ITEM_STATUS.PENDING) return
|
||||
const item = this.currentBox.items.find((entry) => String(entry.id) === String(id))
|
||||
const hasPalletScan = isSealedPalletItem(item) && Object.entries(restoredScans).some(([code, itemId]) =>
|
||||
String(itemId) === String(id) && (code === item?.palletNo || (item?.boxCodes || []).includes(code)))
|
||||
if (isSealedPalletItem(item) && (status !== ITEM_STATUS.PICKED || !hasPalletScan)) return
|
||||
const partial = Number(restored.partialQuantities?.[id] || 0)
|
||||
if (status === ITEM_STATUS.PARTIAL && (!partial || partial >= Number(item?.qty || 0))) return
|
||||
serverStates[id] = status
|
||||
@@ -820,7 +833,11 @@
|
||||
})
|
||||
this.itemStates = serverStates
|
||||
this.partialQuantities = serverPartialQuantities
|
||||
this.scannedBoxCodes = Object.fromEntries(Object.entries(restored.scannedBoxCodes || {}).filter(([, id]) => itemIds.has(String(id))))
|
||||
this.scannedBoxCodes = Object.fromEntries(Object.entries(restoredScans).filter(([code, id]) => {
|
||||
if (!itemIds.has(String(id))) return false
|
||||
const item = this.currentBox.items.find((entry) => String(entry.id) === String(id))
|
||||
return !isSealedPalletItem(item) || code === item.palletNo || (item.boxCodes || []).includes(code)
|
||||
}))
|
||||
this.boxDrafts = restored.boxDrafts || {}
|
||||
this.completedBoxes = restored.completedBoxes || {}
|
||||
if (this.remotePick && !isPickingCompleteTask(this.remotePick)) {
|
||||
@@ -944,6 +961,7 @@
|
||||
itemState(item) {
|
||||
return this.itemStates[item.id] || ITEM_STATUS.PENDING
|
||||
},
|
||||
isSealedPalletItem,
|
||||
hasBoxDraft(index) {
|
||||
const draft = this.boxDrafts[index]
|
||||
return Boolean(draft && Object.values(draft.itemStates).some((status) => status !== ITEM_STATUS.PENDING))
|
||||
@@ -952,7 +970,8 @@
|
||||
const reason = this.itemState(item) === ITEM_STATUS.SKIPPED && item.skipReason
|
||||
? ` · ${item.skipReason}`
|
||||
: ''
|
||||
return `${item.desc}${reason}`
|
||||
const pallet = isSealedPalletItem(item) && item.palletNo ? `${item.palletNo} · ` : ''
|
||||
return `${pallet}${item.desc}${reason}`
|
||||
},
|
||||
processScannedValue(scanValue, source = 'broadcast') {
|
||||
if (!this.requirePickingOperation()) return
|
||||
@@ -972,18 +991,19 @@
|
||||
[ITEM_STATUS.PARTIAL]: '已部分拣货',
|
||||
[ITEM_STATUS.SKIPPED]: '已跳拣'
|
||||
}
|
||||
this.recordScanResult('warning', '重复扫描', value, `该箱码已${labels[result.status]},无需重复处理`)
|
||||
this.showToast(`该箱码已处理:${value}`, 'warning')
|
||||
this.recordScanResult('warning', '重复扫描', value, `该条码已${labels[result.status]},无需重复处理`)
|
||||
this.showToast(`该条码已处理:${value}`, 'warning')
|
||||
return
|
||||
}
|
||||
if (result.type === 'unknown') {
|
||||
playErrorSound()
|
||||
this.recordScanResult('error', '当前任务无此箱码', value, '请核对箱唛标签或转入异常处理')
|
||||
this.showToast(`未找到匹配的待拣箱码:${value}`, 'error')
|
||||
this.recordScanResult('error', '当前任务无此条码', value, '请核对托盘码或箱唛标签')
|
||||
this.showToast(`未找到匹配的待拣条码:${value}`, 'error')
|
||||
return
|
||||
}
|
||||
playSuccessSound()
|
||||
this.pickScannedBox(result.item, value)
|
||||
if (result.type === 'pallet') this.pickScannedPallet(result.item, value)
|
||||
else this.pickScannedBox(result.item, value)
|
||||
},
|
||||
evidenceEntries(type) { return Array.isArray(this.evidenceFiles[type]) ? this.evidenceFiles[type].filter((item) => item.status !== 'VOID' && item.status !== 'VOIDED') : (this.evidenceFiles[type] ? [this.evidenceFiles[type]] : []) },
|
||||
evidencePreviewSource(photo) { return photo?.localPath || photo?.fileUrl || photo?.downloadUrl || photo?.url || '' },
|
||||
@@ -1419,9 +1439,19 @@
|
||||
this.showToast(`已拣箱 ${pickedCount}/${plannedCount}`, 'success')
|
||||
this.persistSession()
|
||||
},
|
||||
pickScannedPallet(item, palletNo) {
|
||||
const plannedCount = Number(item.qty || 0)
|
||||
this.scannedBoxCodes = { ...this.scannedBoxCodes, [item.palletNo]: item.id, [palletNo]: item.id }
|
||||
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.persistSession()
|
||||
},
|
||||
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 }
|
||||
const available = this.availablePickQuantity(item)
|
||||
if (available !== null && available < Number(item.qty || 0)) { this.showToast(`库存/库位可用量不足:可拣 ${available}`, 'error'); return }
|
||||
this.itemStates = { ...this.itemStates, [id]: ITEM_STATUS.PICKED }
|
||||
@@ -1448,6 +1478,8 @@
|
||||
},
|
||||
openSkip(id) {
|
||||
if (!this.requirePickingOperation()) return
|
||||
const item = this.currentBox.items.find((entry) => entry.id === id)
|
||||
if (isSealedPalletItem(item)) { this.showToast(`整托货物不能跳拣,请扫描 ${item.palletNo}`, 'warning'); return }
|
||||
this.skipTargetId = id
|
||||
this.selectedSkipReason = ''
|
||||
this.openOverlay('skip')
|
||||
@@ -1468,6 +1500,8 @@
|
||||
},
|
||||
openPartial(id) {
|
||||
if (!this.requirePickingOperation()) return
|
||||
const item = this.currentBox.items.find((entry) => entry.id === id)
|
||||
if (isSealedPalletItem(item)) { this.showToast(`整托货物不能部分拣货,请扫描 ${item.palletNo}`, 'warning'); return }
|
||||
this.partialTargetId = id
|
||||
this.partialInput = ''
|
||||
this.openOverlay('partial')
|
||||
@@ -1548,6 +1582,12 @@
|
||||
const beforeSubmit = this.summary
|
||||
const remoteRequired = Boolean(session.token)
|
||||
this.remoteError = ''
|
||||
const pendingPallet = this.currentBox.items.find((item) => isSealedPalletItem(item) && this.itemState(item) !== ITEM_STATUS.PICKED)
|
||||
if (pendingPallet) {
|
||||
this.remoteError = `请扫描托盘码 ${pendingPallet.palletNo} 或托内任一箱码后再提交`
|
||||
this.showToast(this.remoteError, 'warning')
|
||||
return
|
||||
}
|
||||
this.debugSubmit('submit.start', { incompleteReason, remoteRequired, remoteReady: isRemoteReady(this.currentBox.inboundId || this.currentBox.boxNo) })
|
||||
if (remoteRequired && !isRemoteReady(this.currentBox.inboundId || this.currentBox.boxNo)) {
|
||||
this.remoteError = '缺少服务端任务信息,未提交拣货完成'
|
||||
@@ -1563,6 +1603,7 @@
|
||||
if (item.itemStatus && !['PENDING', 'PICKING'].includes(item.itemStatus)) return null
|
||||
const status = this.itemState(item)
|
||||
const itemId = item.taskItemId ?? item.remoteItemId ?? item.id
|
||||
if (status === ITEM_STATUS.PICKED && isSealedPalletItem(item)) return { type: 'PICK_PALLET', itemId, palletNo: item.palletNo, version: Number(item.version || 0) }
|
||||
if (status === ITEM_STATUS.PICKED) return { type: 'PICK', itemId, pickedPackageCount: Number(item.qty || 0), locationIds: item.locationIds || [], version: Number(item.version || 0) }
|
||||
if (status === ITEM_STATUS.PARTIAL) return { type: 'PARTIAL', itemId, finalPickedPackageCount: Number(this.partialQuantities[item.id] || 0), locationIds: item.locationIds || [], version: Number(item.version || 0) }
|
||||
if (status === ITEM_STATUS.SKIPPED) return { type: 'SKIP', itemId, reason: item.skipReason || incompleteReason || '现场跳拣', version: Number(item.version || 0) }
|
||||
@@ -1601,9 +1642,8 @@
|
||||
} catch (error) {
|
||||
this.debugSubmit('submit.error', { message: error.message || '', code: error.code || '', statusCode: error.statusCode || '', requestIdFromServer: error.requestId || '', traceId: error.traceId || '', network: Boolean(error.network) })
|
||||
const versionConflict = this.isTaskConflict(error)
|
||||
const inventoryConflict = versionConflict && /库存/.test(error.message || '')
|
||||
const conflictMessage = inventoryConflict ? '库存已变化,请刷新后重试' : '任务版本已变化,请刷新后重试'
|
||||
if (versionConflict) {
|
||||
const conflictMessage = sanitizeUserMessage(error.message, '任务状态已变化,请刷新后重试')
|
||||
await this.refreshAfterTaskConflict(conflictMessage)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ test('C02 identifiers and separate picking/inbound versions are normalized expli
|
||||
assert.match(source, /pickingVersion: row\.pickingVersion \?\? row\.loading\?\.pickingVersion/)
|
||||
assert.match(source, /pickingVersion: task\.pickingVersion \?\? task\.version/)
|
||||
assert.match(page, /version: Number\(this\.pickingVersion\)/)
|
||||
assert.match(page, /ITEM_STATUS, classifyScan, createItemStates, getPickSummary, submitStates/)
|
||||
assert.match(page, /ITEM_STATUS, classifyScan, createItemStates, getPickSummary, isSealedPalletItem, submitStates/)
|
||||
assert.match(page, /taskItemId: item\.id \?\? item\.itemId/)
|
||||
assert.match(page, /submit\.batch/)
|
||||
assert.match(page, /item\.itemStatus === 'PICKED'/)
|
||||
@@ -117,7 +117,10 @@ test('PDA cabinet picking adapter claims online and submits one atomic batch', (
|
||||
assert.match(source, /slice\(0, 128\)/)
|
||||
assert.match(page, /claimContainerPickingTask\(/)
|
||||
assert.match(page, /submitContainerPickingBatch\(/)
|
||||
assert.match(page, /任务版本已变化,请刷新后重试/)
|
||||
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, /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/)
|
||||
assert.doesNotMatch(source, /query\.push\(`containerNo=/)
|
||||
|
||||
@@ -23,9 +23,10 @@ test('demo claim supports the PDA broadcast and normalized manual input without
|
||||
assert.match(page, /result\.type === 'processed'[\s\S]*?重复扫描[\s\S]*?return/)
|
||||
})
|
||||
|
||||
test('demo batch freezes inventory and submits PICK, SKIP and PARTIAL under one stable key', () => {
|
||||
test('demo batch freezes inventory and submits carton or whole-pallet actions under one stable key', () => {
|
||||
const submit = page.match(/async performSubmit\(incompleteReason = ''\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
|
||||
for (const action of ['PICK', 'SKIP', 'PARTIAL']) assert.match(submit, new RegExp(`type: '${action}'`))
|
||||
for (const action of ['PICK', 'SKIP', 'PARTIAL', 'PICK_PALLET']) assert.match(submit, new RegExp(`type: '${action}'`))
|
||||
assert.match(submit, /palletNo: item\.palletNo/)
|
||||
assert.match(submit, /const submitId = this\.submitId \|\| stableOperationId\('submit'/)
|
||||
assert.match(submit, /this\.submitId = submitId/)
|
||||
assert.match(submit, /const inventorySnapshot = await this\.refreshInventorySnapshot\(\)[\s\S]*?submitContainerPickingBatch/)
|
||||
|
||||
@@ -92,3 +92,31 @@ test('classifyScan distinguishes pending, repeated, and unknown carton codes', (
|
||||
item: null,
|
||||
})
|
||||
})
|
||||
|
||||
test('sealed pallet resolves either its DP code or any member carton as one whole-pallet action', () => {
|
||||
const pallet = {
|
||||
id: 9,
|
||||
unitType: 'SEALED_PALLET',
|
||||
palletId: '2098348162978738179',
|
||||
palletNo: 'DP-260911-0047',
|
||||
indivisible: true,
|
||||
boxCodes: ['BOX-001', 'BOX-002'],
|
||||
}
|
||||
|
||||
assert.deepEqual(state.classifyScan([pallet], {}, 'DP-260911-0047'), {
|
||||
type: 'pallet',
|
||||
item: pallet,
|
||||
})
|
||||
assert.deepEqual(state.classifyScan([pallet], {}, 'BOX-001'), {
|
||||
type: 'pallet',
|
||||
item: pallet,
|
||||
matchedBy: 'carton',
|
||||
})
|
||||
assert.deepEqual(state.classifyScan([pallet], {
|
||||
9: state.ITEM_STATUS.PICKED,
|
||||
}, 'DP-260911-0047', { 'DP-260911-0047': 9 }), {
|
||||
type: 'processed',
|
||||
item: pallet,
|
||||
status: state.ITEM_STATUS.PICKED,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,12 @@ export const ITEM_STATUS = Object.freeze({
|
||||
SKIPPED: 'skipped'
|
||||
})
|
||||
|
||||
export function isSealedPalletItem(item) {
|
||||
return item?.unitType === 'SEALED_PALLET'
|
||||
|| item?.palletId !== null && item?.palletId !== undefined
|
||||
|| item?.indivisible === true
|
||||
}
|
||||
|
||||
export function createItemStates(items) {
|
||||
return items.reduce((states, item) => {
|
||||
states[item.id] = ITEM_STATUS.PENDING
|
||||
@@ -61,8 +67,24 @@ export function submitStates(items, states) {
|
||||
}
|
||||
|
||||
export function classifyScan(items, states, scanCode, scannedBoxCodes = {}) {
|
||||
const palletItem = items.find((entry) => isSealedPalletItem(entry) && entry.palletNo === scanCode) || null
|
||||
if (palletItem) {
|
||||
const status = states[palletItem.id] || ITEM_STATUS.PENDING
|
||||
if (scannedBoxCodes[scanCode] || status === ITEM_STATUS.PICKED) {
|
||||
return { type: 'processed', item: palletItem, status }
|
||||
}
|
||||
return { type: 'pallet', item: palletItem }
|
||||
}
|
||||
|
||||
const item = items.find((entry) => (entry.boxCodes || []).includes(scanCode)) || null
|
||||
if (!item) return { type: 'unknown', item: null }
|
||||
if (isSealedPalletItem(item)) {
|
||||
const status = states[item.id] || ITEM_STATUS.PENDING
|
||||
if (scannedBoxCodes[scanCode] || status === ITEM_STATUS.PICKED) {
|
||||
return { type: 'processed', item, status }
|
||||
}
|
||||
return { type: 'pallet', item, matchedBy: 'carton' }
|
||||
}
|
||||
if (scannedBoxCodes[scanCode]) return { type: 'processed', item, status: states[item.id] || ITEM_STATUS.PARTIAL }
|
||||
|
||||
const status = states[item.id] || ITEM_STATUS.PENDING
|
||||
|
||||
Reference in New Issue
Block a user