fix(pda): stage sealed pallet scans for atomic batch picking
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
|
||||
- 扫码是主路径:输入焦点、连续扫描、防抖、重复码、无效码、错箱和扫描反馈必须作为一等场景处理。
|
||||
- 拣货扫描身份是后端下发的稳定箱唛箱码 `boxCode`;一次有效且未重复的箱码扫描表示拣出一箱。SKU、商品件数、入库单号和页面展示文案只用于辅助识别,不得作为扫码匹配键或拣货计数单位。
|
||||
- 一件代发封托明细由 `Task.items` 的 `unitType/palletId/palletNo/indivisible` 标识,`PALLET` 只扫描 DP 码并一次将同托全部明细写入本地草稿,不接受托内逐箱扫码、部分拣货或跳拣;可整托撤销未提交草稿。点击提交时通过同一 `/tasks/{taskId}/batch` 按每个明细提交 `PICK_PALLET + palletNo + version`,由服务端验证封托和完整集合后原子处理,禁止扫码立即写后端。DP 托盘标识不得与库存资源托盘标识混用。
|
||||
- 进入任务时由服务端标记“拣货中”;扫描结果先写入绑定作业员、任务和柜次的设备持久化草稿,仅在点击提交时批量写后端,禁止逐码提交。
|
||||
- 允许部分拣货和跳拣,但每个未完成项必须填写异常原因;存在未解决异常时任务挂起,不能完成或推进 `LOADED`。
|
||||
- 作业提交使用对员工不可见的轻量幂等标识和服务端版本;重复提交返回原结果,版本冲突提示刷新后重试,不静默覆盖。
|
||||
@@ -27,6 +28,7 @@
|
||||
- PDA 仅允许现场拍照;Admin 可通过独立受权入口人工上传。五类证据各至少 1 张有效照片,同类型允许多张追加;错误或重复照片标记作废并保留原因、操作人和时间,不硬删除。设备端证据查询、上传、预览和作废统一按当前 `taskId + warehouseId` 走任务专用 API,服务端再绑定真实柜 `containerBookingId`;不得调用旧入仓单/客户作用域证据接口。
|
||||
- 与管理后台头程订单、仓库任务和 `baiyue-firstleg` Controller/OpenAPI 对齐稳定标识、状态机、时间和异常码,禁止在前端自行发明另一套业务状态。
|
||||
- 一件代发清点组托使用独立 `/first-leg/pda-dropship` 任务链:箱码先经只读识别再确认实收;系统托盘码可先于或晚于箱码扫描。`count` 携托盘码时直接绑定,箱码先扫则进入待绑托区并由 `bind-pending` 批量绑定;换托只使用服务端 `cartonId`,封托后按整托不可拆。只消费 `platform:firstleg:pda:view` 与 `platform:firstleg:pda:work` 权限结果,不在 PDA 自建权限语义。
|
||||
- 清点组托页面有待绑箱时提供“新建托盘并绑定”,调用 `/tasks/{taskId}/pallets/create-and-bind`,只提交 `warehouseId + operationId`。服务端生成 DP 编号并原子绑定当前全部待绑箱,无待绑箱不建空托;失败重试复用幂等键,成功后选中新托盘。此处是独立清点确认命令,不改变装柜拣货扫描仅记录草稿的原则。
|
||||
|
||||
## 设备、安全与可用性
|
||||
|
||||
|
||||
@@ -58,6 +58,12 @@ export function bindPendingDropshipCartons(taskId, payload = {}) {
|
||||
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/pallets/bind-pending`, method: 'POST', header: operationHeader(payload.operationId), data: payload })
|
||||
}
|
||||
|
||||
export function createAndBindDropshipPallet(taskId, payload = {}) {
|
||||
requireValue(taskId, '缺少清点组托任务标识')
|
||||
for (const [field, message] of [['warehouseId', '缺少作业仓库标识'], ['operationId', '缺少操作幂等标识']]) requireValue(payload[field], message)
|
||||
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/pallets/create-and-bind`, method: 'POST', header: operationHeader(payload.operationId), data: { warehouseId: payload.warehouseId, operationId: payload.operationId } })
|
||||
}
|
||||
|
||||
export function moveDropshipCarton(taskId, cartonId, payload = {}) {
|
||||
requireValue(cartonId, '缺少箱明细标识')
|
||||
for (const [field, message] of [['warehouseId', '缺少作业仓库标识'], ['operationId', '缺少操作幂等标识']]) requireValue(payload[field], message)
|
||||
|
||||
@@ -107,6 +107,7 @@ export async function submitContainerPickingBatch(task, actions, { warehouseId,
|
||||
const items = (actions || []).map((action) => ({
|
||||
itemId: action.itemId,
|
||||
action: action.type,
|
||||
palletNo: action.type === 'PICK_PALLET' ? action.palletNo : null,
|
||||
pickedPackageCount: action.type === 'PICK' ? action.pickedPackageCount : null,
|
||||
finalPickedPackageCount: action.type === 'PARTIAL' ? action.finalPickedPackageCount : null,
|
||||
version: action.version,
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
<view v-if="hasDifference" class="difference-box"><text>实收与应收不一致,请确认接受差异并填写处理结果。</text><label><checkbox :checked="acceptVariance" @tap="acceptVariance = !acceptVariance" />接受本次数量差异</label><textarea v-model="discrepancyRemark" maxlength="500" placeholder="填写差异原因及处理结果" /></view>
|
||||
<button class="primary-button" :disabled="busy || !online || !canConfirmCarton" @tap="confirmCarton">确认本箱实收</button>
|
||||
</view>
|
||||
<view class="panel"><view class="heading"><text class="panel-title">待绑托箱</text><text class="badge">{{ task.unboundCartons || 0 }}</text></view><view v-if="!unboundCartons.length" class="empty">暂无待绑托箱</view><view v-for="carton in unboundCartons" :key="carton.cartonId" class="carton-row"><view><text>{{ carton.boxCode }}</text><text>{{ carton.skuCode }} · 实收 {{ carton.actualSkuQuantity }}</text></view></view></view>
|
||||
<view class="panel"><view class="heading"><text class="panel-title">托盘</text><text class="minor">{{ pallets.length }} 托</text></view><view v-if="!pallets.length" class="empty">扫描 DP 托盘码开始组托</view>
|
||||
<view class="panel"><button class="primary-button" :disabled="busy || !online || task.taskStatus !== 'CLAIMED' || !unboundCartons.length" @tap="createPallet">新建托盘并绑定</button><view class="heading"><text class="panel-title">待绑托箱</text><text class="badge">{{ task.unboundCartons || 0 }}</text></view><view v-if="!unboundCartons.length" class="empty">暂无待绑托箱</view><view v-for="carton in unboundCartons" :key="carton.cartonId" class="carton-row"><view><text>{{ carton.boxCode }}</text><text>{{ carton.skuCode }} · 实收 {{ carton.actualSkuQuantity }}</text></view></view></view>
|
||||
<view class="panel"><view class="heading"><text class="panel-title">托盘</text><text class="minor">{{ pallets.length }} 托</text></view><view v-if="!pallets.length" class="empty">清点箱码后新建托盘,或扫描已有 DP 托盘码</view>
|
||||
<view v-for="pallet in pallets" :key="pallet.palletId" class="pallet-card" :class="{ active: pallet.palletNo === activePalletNo }">
|
||||
<view class="heading"><view><text class="pallet-code">{{ pallet.palletNo }}</text><text class="minor">{{ pallet.cartonCount || 0 }} 箱 · {{ pallet.skuCount || 0 }} SKU</text></view><text class="badge" :class="{ sealed: pallet.status === 'SEALED' }">{{ pallet.status === 'SEALED' ? '已封托' : '组托中' }}</text></view>
|
||||
<view v-if="pallet.weightKg != null || pallet.volumeCbm != null" class="measure-row"><text>重量 {{ pallet.weightKg ?? '—' }} kg</text><text>体积 {{ pallet.volumeCbm ?? '—' }} m³</text></view>
|
||||
@@ -38,7 +38,7 @@
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { bindPendingDropshipCartons, countDropshipCarton, getDropshipPalletizingTask, moveDropshipCarton, resolveDropshipCarton, sealDropshipPallet } from '@/api/pda-dropship.js'
|
||||
import { createAndBindDropshipPallet, bindPendingDropshipCartons, countDropshipCarton, getDropshipPalletizingTask, moveDropshipCarton, resolveDropshipCarton, sealDropshipPallet } from '@/api/pda-dropship.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { clearDropshipPalletizingDraft, restoreDropshipPalletizingDraft, saveDropshipPalletizingDraft } from '@/stores/dropship-palletizing.js'
|
||||
import { stableOperationId } from '@/utils/idempotency.js'
|
||||
@@ -61,6 +61,19 @@
|
||||
onLoad(options) { if (!requireSession()) return; this.user = session.currentUser; this.taskId = String(options.taskId || ''); this.warehouseId = String(options.warehouseId || ''); const draft = restoreDropshipPalletizingDraft(this.draftIdentity); if (draft?.activePalletNo) this.activePalletNo = draft.activePalletNo; this.scanState = createDropshipScanState({ activePalletNo: this.activePalletNo }); this._stopNetwork = watchNetwork((value) => { this.online = value; this.scanState.online = value }) },
|
||||
onShow() { if (!this.taskId || !this.warehouseId) { this.error = '任务参数缺失,请返回任务列表刷新'; return }; this.startScanner(); this.loadTask() }, onHide() { this.stopScanner() }, onUnload() { this.stopScanner(); if (this._stopNetwork) this._stopNetwork() },
|
||||
methods: {
|
||||
async createPallet() {
|
||||
if (this.busy || !this.online || this.task?.taskStatus !== 'CLAIMED' || !this.unboundCartons.length) return
|
||||
const cartonIds = this.unboundCartons.map((carton) => String(carton.cartonId)).sort()
|
||||
const payload = { warehouseId: this.warehouseId, operationId: stableOperationId('dropship-create-bind', `${this.user?.id}-${this.taskId}`, { warehouseId: this.warehouseId, taskVersion: this.task.version, cartonIds }) }
|
||||
this.busy = true; this.error = ''
|
||||
try {
|
||||
this.task = await createAndBindDropshipPallet(this.taskId, payload)
|
||||
const pallet = this.pallets.find((item) => (item.cartons || []).some((carton) => cartonIds.includes(String(carton.cartonId))))
|
||||
if (pallet) { this.activePalletNo = pallet.palletNo; this.scanState.activePalletNo = pallet.palletNo; this.persistDraft() }
|
||||
this.notice = `已新建托盘 ${pallet?.palletNo || ''} 并绑定待绑托箱`; playSuccessSound()
|
||||
} catch (error) { this.error = formatApiError(error, '新建托盘失败,请重试'); playErrorSound() }
|
||||
finally { this.busy = false }
|
||||
},
|
||||
statusLabel(status) { return status === 'COMPLETED' ? '已完成' : status === 'CLAIMED' ? '清点组托中' : '待领取' }, palletCartons(pallet) { return pallet.cartons || [] },
|
||||
startScanner() { if (!this._stopScanner) this._stopScanner = startScanBroadcast({ onScan: (value) => this.processScan(value), onError: () => playErrorSound() }) }, stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
|
||||
async loadTask() { if (!this.online) { this.error = '当前网络不可用,请联网后刷新'; return }; this.loading = true; this.error = ''; try { this.task = await getDropshipPalletizingTask(this.taskId, this.warehouseId); if (this.task?.taskStatus === 'COMPLETED') { this.activePalletNo = ''; this.scanState = createDropshipScanState(); clearDropshipPalletizingDraft(this.draftIdentity) } } catch (error) { this.error = formatApiError(error, '清点组托任务加载失败') } finally { this.loading = false } },
|
||||
|
||||
@@ -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="hasPalletItems ? '箱码或托盘码扫码框' : '箱码扫码框'" :placeholder="hasPalletItems ? '扫描 DP 托盘码或箱唛标签码' : '扫描或输入箱唛标签码'" :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">{{ isPalletItem(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">
|
||||
@@ -98,12 +98,12 @@
|
||||
<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 && !isPalletItem(item)" 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>
|
||||
</view>
|
||||
<view v-else-if="pickingOperationReady" class="pick-item-actions processed-actions">
|
||||
<view v-else-if="isPalletItem(item) && itemState(item) === ITEM_STATUS.PENDING" class="pick-item-actions"><text>请扫描上方 DP 托盘码整托拣出</text></view><view v-else-if="isPalletItem(item) && pickingOperationReady && ['PENDING', 'PICKING'].includes(item.itemStatus)" class="pick-item-actions"><button class="pick-btn pick-btn-undo" @tap="undoPallet(item)">撤销整托草稿</button></view><view v-else-if="pickingOperationReady && !isPalletItem(item)" class="pick-item-actions processed-actions">
|
||||
<button class="pick-btn pick-btn-undo" @tap="undoItem(item.id)">撤销</button>
|
||||
</view>
|
||||
</view>
|
||||
@@ -333,7 +333,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ITEM_STATUS, classifyScan, createItemStates, getPickSummary, submitStates } from '@/utils/picking-state.js'
|
||||
import { ITEM_STATUS, classifyScan, createItemStates, getPickSummary, submitStates, isPalletItem } 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'
|
||||
@@ -395,6 +395,7 @@
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
hasPalletItems() { return this.currentBox.items.some(isPalletItem) },
|
||||
currentBox() {
|
||||
return this.boxes[this.currentBoxIndex] || { boxNo: '', orderNo: '', inboundCode: '', items: [] }
|
||||
},
|
||||
@@ -561,6 +562,24 @@
|
||||
return false
|
||||
},
|
||||
methods: {
|
||||
isPalletItem,
|
||||
undoPallet(item) {
|
||||
if (!this.requirePickingOperation()) return
|
||||
const members = this.currentBox.items.filter((entry) => isPalletItem(entry) && entry.palletNo === item.palletNo)
|
||||
if (members.some((entry) => !['PENDING', 'PICKING'].includes(entry.itemStatus))) return
|
||||
for (const member of members) { this.itemStates[member.id] = ITEM_STATUS.PENDING; delete this.partialQuantities[member.id] }
|
||||
delete this.scannedBoxCodes[item.palletNo]
|
||||
this.persistSession()
|
||||
},
|
||||
pickScannedPallet(item) {
|
||||
if (this.submitting || !this.requirePickingOperation() || !item.palletNo) return
|
||||
const members = this.currentBox.items.filter((entry) => isPalletItem(entry) && entry.palletNo === item.palletNo)
|
||||
if (members.some((entry) => !['PENDING', 'PICKING'].includes(entry.itemStatus))) { this.showToast('托盘状态已变化,请刷新任务', 'warning'); return }
|
||||
for (const member of members) { this.itemStates[member.id] = ITEM_STATUS.PICKED; this.partialQuantities[member.id] = Number(member.qty) }
|
||||
this.scannedBoxCodes = { ...this.scannedBoxCodes, [item.palletNo]: item.id }
|
||||
this.recordScanResult('success', '整托已记入草稿', item.palletNo, `整托 ${members.reduce((sum, entry) => sum + Number(entry.qty), 0)} 箱,提交后保存`)
|
||||
this.showToast('整托已拣,待统一提交', 'success'); this.persistSession()
|
||||
},
|
||||
containerReference,
|
||||
isSelectableTask(task) {
|
||||
return Boolean(task?.recordStatus === 'ACTIVE'
|
||||
@@ -813,6 +832,10 @@
|
||||
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))
|
||||
if (isPalletItem(item)) {
|
||||
const members = this.currentBox.items.filter((entry) => isPalletItem(entry) && entry.palletNo === item.palletNo)
|
||||
if (!item.palletNo || !restored.scannedBoxCodes?.[item.palletNo] || status !== ITEM_STATUS.PICKED || members.some((entry) => restored.itemStates?.[entry.id] !== ITEM_STATUS.PICKED || Number(restored.partialQuantities?.[entry.id]) !== Number(entry.qty) || !['PENDING', 'PICKING'].includes(entry.itemStatus))) 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 +843,7 @@
|
||||
})
|
||||
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(restored.scannedBoxCodes || {}).filter(([, id]) => itemIds.has(String(id)) && (!isPalletItem(this.currentBox.items.find((item) => String(item.id) === String(id))) || serverStates[id] === ITEM_STATUS.PICKED)))
|
||||
this.boxDrafts = restored.boxDrafts || {}
|
||||
this.completedBoxes = restored.completedBoxes || {}
|
||||
if (this.remotePick && !isPickingCompleteTask(this.remotePick)) {
|
||||
@@ -983,6 +1006,7 @@
|
||||
return
|
||||
}
|
||||
playSuccessSound()
|
||||
if (isPalletItem(result.item)) return this.pickScannedPallet(result.item)
|
||||
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]] : []) },
|
||||
@@ -1422,6 +1446,7 @@
|
||||
pickItem(id, source = 'manual') {
|
||||
if (!this.requirePickingOperation()) return
|
||||
const item = this.currentBox.items.find((entry) => entry.id === id)
|
||||
if (isPalletItem(item)) { this.showToast('请扫描托盘码整体拣出', '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 +1473,7 @@
|
||||
},
|
||||
openSkip(id) {
|
||||
if (!this.requirePickingOperation()) return
|
||||
if (isPalletItem(this.currentBox.items.find((item) => item.id === id))) return
|
||||
this.skipTargetId = id
|
||||
this.selectedSkipReason = ''
|
||||
this.openOverlay('skip')
|
||||
@@ -1468,6 +1494,7 @@
|
||||
},
|
||||
openPartial(id) {
|
||||
if (!this.requirePickingOperation()) return
|
||||
if (isPalletItem(this.currentBox.items.find((item) => item.id === id))) return
|
||||
this.partialTargetId = id
|
||||
this.partialInput = ''
|
||||
this.openOverlay('partial')
|
||||
@@ -1486,6 +1513,7 @@
|
||||
if (!this.requirePickingOperation()) return
|
||||
const item = this.partialTargetItem
|
||||
if (!item) return
|
||||
if (isPalletItem(item)) return
|
||||
if (!this.partialInputValid) return
|
||||
const value = parseInt(this.partialInput, 10)
|
||||
const available = this.availablePickQuantity(item)
|
||||
@@ -1562,6 +1590,7 @@
|
||||
const actions = this.currentBox.items.map((item) => {
|
||||
if (item.itemStatus && !['PENDING', 'PICKING'].includes(item.itemStatus)) return null
|
||||
const status = this.itemState(item)
|
||||
if (isPalletItem(item)) { if (status !== ITEM_STATUS.PICKED) throw new Error('已封托货物须先扫描 DP 托盘码整托拣出'); return { type: 'PICK_PALLET', itemId: item.taskItemId ?? item.id, palletNo: item.palletNo, version: Number(item.version || 0) } }
|
||||
const itemId = item.taskItemId ?? item.remoteItemId ?? item.id
|
||||
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) }
|
||||
|
||||
@@ -4,6 +4,30 @@ import { readFile } from 'node:fs/promises'
|
||||
|
||||
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
|
||||
|
||||
test('system pallet action binds through one request and keeps the same operation on retry', async () => {
|
||||
const page = await read('../pages/dropship-palletizing/dropship-palletizing.vue')
|
||||
const method = page.slice(page.indexOf('async createPallet()'), page.indexOf('statusLabel(status)'))
|
||||
const requests = []
|
||||
const { stableOperationId } = await import('../utils/idempotency.js')
|
||||
const methods = new Function('createAndBindDropshipPallet', 'stableOperationId', 'playSuccessSound', 'playErrorSound', 'formatApiError', `return ({${method}})`)(async (taskId, payload) => {
|
||||
requests.push({ taskId, payload })
|
||||
if (requests.length === 1) throw new Error('network timeout')
|
||||
return { taskStatus: 'CLAIMED', unbound: [], pallets: [{ palletNo: 'DP-260909-0001', cartons: [{ cartonId: '9007199254740993' }] }] }
|
||||
}, stableOperationId, () => {}, () => {}, (error) => error.message)
|
||||
const context = { busy: false, online: true, task: { taskStatus: 'CLAIMED' }, taskId: '10', warehouseId: '100', user: { id: '7' }, unboundCartons: [{ cartonId: '9007199254740993' }], scanState: {}, persistDraft() {}, get pallets() { return this.task.pallets || [] } }
|
||||
await methods.createPallet.call(context)
|
||||
assert.equal(context.error, 'network timeout')
|
||||
assert.equal(context.busy, false)
|
||||
await methods.createPallet.call(context)
|
||||
assert.deepEqual(requests[0], requests[1])
|
||||
assert.equal(context.activePalletNo, 'DP-260909-0001')
|
||||
assert.equal(context.scanState.activePalletNo, context.activePalletNo)
|
||||
context.unboundCartons = []
|
||||
await methods.createPallet.call(context)
|
||||
assert.equal(requests.length, 2)
|
||||
assert.match(page, /新建托盘并绑定/)
|
||||
})
|
||||
|
||||
test('dropship palletizing route and workbench entry are registered', async () => {
|
||||
const [pages, data, home, modulePage] = await Promise.all([read('../pages.json'), read('../data/demo-data.js'), read('../pages/home/home.vue'), read('../pages/module/module.vue')])
|
||||
assert.match(pages, /pages\/dropship-palletizing\/tasks/)
|
||||
|
||||
@@ -6,6 +6,38 @@ const source = await readFile(new URL('../api/pda-loading.js', import.meta.url),
|
||||
const http = await readFile(new URL('../api/http.js', import.meta.url), 'utf8')
|
||||
const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('sealed pallets use atomic batch actions and cannot use manual package actions', () => {
|
||||
assert.doesNotMatch(source, /\/pallets\/pick/)
|
||||
assert.match(source, /palletNo: action\.type === 'PICK_PALLET' \? action\.palletNo : null/)
|
||||
assert.match(page, /type: 'PICK_PALLET', itemId: item\.taskItemId \?\? item\.id, palletNo: item\.palletNo/)
|
||||
assert.match(page, /if \(isPalletItem\(result\.item\)\) return this\.pickScannedPallet\(result\.item\)/)
|
||||
assert.match(page, /itemState\(item\) === ITEM_STATUS\.PENDING && !isPalletItem\(item\)/)
|
||||
assert.match(page, /if \(isPalletItem\(item\)\) \{ this\.showToast\('请扫描托盘码整体拣出'/)
|
||||
assert.match(page, /if \(isPalletItem\(item\)\) return/)
|
||||
assert.match(page, /item\.palletNo \|\| '托盘码待同步'/)
|
||||
})
|
||||
|
||||
test('a DP scan changes all same-pallet rows locally without any remote command', async () => {
|
||||
const method = page.match(/pickScannedPallet\(item\) \{[\s\S]*?\n\t\t\t\},/)?.[0]
|
||||
assert.ok(method)
|
||||
const stateSource = await readFile(new URL('../utils/picking-state.js', import.meta.url), 'utf8')
|
||||
const { isPalletItem, ITEM_STATUS } = await import(`data:text/javascript;base64,${Buffer.from(stateSource).toString('base64')}`)
|
||||
const undo = page.match(/undoPallet\(item\) \{[\s\S]*?\n\t\t\t\},/)?.[0]
|
||||
const behavior = new Function('isPalletItem', 'ITEM_STATUS', `return ({${method}${undo}})`)(isPalletItem, ITEM_STATUS)
|
||||
let saved = 0
|
||||
const rows = [{ id: '1', unitType: 'PALLET', palletNo: 'DP-260909-0143', qty: 2, itemStatus: 'PENDING' }, { id: '2', unitType: 'PALLET', palletNo: 'DP-260909-0143', qty: 3, itemStatus: 'PENDING' }, { id: '3', unitType: 'PACKAGE', qty: 1, itemStatus: 'PENDING' }]
|
||||
const context = { submitting: false, requirePickingOperation: () => true, currentBox: { items: rows }, itemStates: {}, partialQuantities: {}, scannedBoxCodes: {}, recordScanResult() {}, showToast() {}, persistSession() { saved++ } }
|
||||
behavior.pickScannedPallet.call(context, rows[0])
|
||||
assert.deepEqual(context.itemStates, { 1: 'picked', 2: 'picked' })
|
||||
assert.deepEqual(context.partialQuantities, { 1: 2, 2: 3 })
|
||||
assert.equal(saved, 1)
|
||||
behavior.undoPallet.call(context, rows[0])
|
||||
assert.deepEqual(context.itemStates, { 1: 'pending', 2: 'pending' })
|
||||
assert.deepEqual(context.scannedBoxCodes, {})
|
||||
assert.equal(saved, 2)
|
||||
assert.doesNotMatch(method, /await|pickContainer|syncRemote|request\(/)
|
||||
})
|
||||
|
||||
test('PDA loading adapter uses only task-scoped cabinet evidence paths', () => {
|
||||
for (const path of [
|
||||
'/first-leg/pda-loading/tasks/pending'
|
||||
|
||||
@@ -21,7 +21,7 @@ test('a server task never inherits a completed marker from the previously opened
|
||||
assert.match(page, /submitStatusHint\(\)[\s\S]*?if \(this\.pickingCompleted\) return '拣货已完成,请继续装柜取证'/)
|
||||
const submit = page.match(/async submitPicking\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
|
||||
assert.doesNotMatch(submit, /completedBoxes/)
|
||||
assert.match(page, /v-else-if="pickingOperationReady" class="pick-item-actions processed-actions"/)
|
||||
assert.match(page, /v-else-if="pickingOperationReady && !isPalletItem\(item\)" class="pick-item-actions processed-actions"/)
|
||||
assert.doesNotMatch(page, /v-else-if="!(?:pickingCompleted|completedBoxes\[currentBoxIndex\])"/)
|
||||
})
|
||||
|
||||
|
||||
@@ -7,6 +7,20 @@ const state = await import(`data:text/javascript;base64,${Buffer.from(source).to
|
||||
|
||||
const items = [{ id: 1 }, { id: 2 }, { id: 3 }]
|
||||
|
||||
test('sealed pallet matches its DP code and never accepts an individual carton', () => {
|
||||
const pallet = { id: '1', unitType: 'PALLET', indivisible: true, palletNo: 'DP-260909-0143', boxCodes: ['IN#2-1', 'IN#2-2'], qty: 2 }
|
||||
assert.equal(state.classifyScan([pallet], {}, 'IN#2-1').type, 'unknown')
|
||||
assert.equal(state.classifyScan([pallet], {}, pallet.palletNo).type, 'pending')
|
||||
assert.equal(state.classifyScan([pallet], { 1: state.ITEM_STATUS.PICKED }, pallet.palletNo).type, 'processed')
|
||||
})
|
||||
|
||||
test('mixed task keeps package scanning separate from sealed pallet scanning', () => {
|
||||
const mixed = [{ id: '1', unitType: 'PALLET', palletNo: 'DP-260909-0001', boxCodes: ['BOX-1'] }, { id: '2', unitType: 'PACKAGE', boxCodes: ['BOX-2'] }]
|
||||
assert.equal(state.classifyScan(mixed, {}, 'BOX-2').item.id, '2')
|
||||
assert.equal(state.classifyScan(mixed, {}, 'DP-260909-0001').item.id, '1')
|
||||
assert.equal(state.classifyScan(mixed, {}, 'BOX-1').type, 'unknown')
|
||||
})
|
||||
|
||||
test('createItemStates initializes every item as pending', () => {
|
||||
assert.deepEqual(state.createItemStates(items), {
|
||||
1: state.ITEM_STATUS.PENDING,
|
||||
|
||||
@@ -61,7 +61,7 @@ export function submitStates(items, states) {
|
||||
}
|
||||
|
||||
export function classifyScan(items, states, scanCode, scannedBoxCodes = {}) {
|
||||
const item = items.find((entry) => (entry.boxCodes || []).includes(scanCode)) || null
|
||||
const item = items.find((entry) => isPalletItem(entry) ? entry.palletNo === scanCode : (entry.boxCodes || []).includes(scanCode)) || null
|
||||
if (!item) return { type: 'unknown', item: null }
|
||||
if (scannedBoxCodes[scanCode]) return { type: 'processed', item, status: states[item.id] || ITEM_STATUS.PARTIAL }
|
||||
|
||||
@@ -70,3 +70,7 @@ export function classifyScan(items, states, scanCode, scannedBoxCodes = {}) {
|
||||
|
||||
return { type: 'processed', item, status }
|
||||
}
|
||||
|
||||
export function isPalletItem(item) {
|
||||
return item?.unitType === 'PALLET' || item?.indivisible === true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user