This commit is contained in:
2026-08-15 09:47:28 +08:00
parent 455e81fbbd
commit 1de45b7263
5 changed files with 77 additions and 18 deletions
+3 -3
View File
@@ -63,6 +63,6 @@
## 全量验收脚本
- PDA 本地自动回归在本目录执行 `node --test tests/*.test.mjs`,覆盖页面契约、导航、在线会话、拣货状态、幂等、证据上传队列、扫码广播和声音反馈。
- 自动化通过后仍需按真实 uni-app 页面完成样式、扫码输入、弱网/重试、草稿恢复、批量提交和窄屏验收;在线更新与真机验收按既定安排执行。
- 测试环境 API 或真实数据写入仍需当前对话明确授权;不要把本地 store 测试通过视为服务端作业链路已通过。
- PDA 不单独定义一套“全量 HTTP”入口;PDA 服务链路由 Admin 全量 HTTP 入口中的 `phase3-pda-convergence`、装柜、证据和出仓模块覆盖。
- 本目录 `node --test tests/*.test.mjs` 仅是 PDA 本地页面/状态质量检查,不代表服务端作业链路已通过。
- 服务 HTTP 通过后,再按真实 uni-app 页面完成扫码输入、网络阻断/恢复、草稿恢复、批量提交、窄屏和在线更新验收;真机检查属于最后的交互验收。
+13 -2
View File
@@ -19,6 +19,10 @@ const EVIDENCE_SLOTS = Object.freeze([
// inbound-scoped task and evidence operations. No local task is promoted to a
// server success when any required identifier or version is absent.
function debugLog(event, payload = {}) {
try { console.info(`[PDA] ${event}`, payload) } catch (_) { /* console may be unavailable on a device */ }
}
function request(options) {
requireOnline()
const baseUrl = session.apiBase || '/api/platform/v1'
@@ -27,6 +31,7 @@ function request(options) {
if (session.requestId) headers['X-Request-Id'] = session.requestId
return new Promise((resolve, reject) => {
debugLog('api.request', { method: options.method || 'GET', url: options.url })
uni.request({
...options,
url: `${baseUrl}${options.url}`,
@@ -34,6 +39,12 @@ function request(options) {
success(response) {
const body = response.data || {}
const code = String(body.code ?? response.statusCode)
debugLog('api.response', {
method: options.method || 'GET', url: options.url, statusCode: response.statusCode, code,
message: body.message || '', requestId: body.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id'] || '',
traceId: body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id'] || '',
dataKeys: body.data && typeof body.data === 'object' ? Object.keys(body.data) : []
})
if (code !== '200') {
const error = new Error(body.message || '服务端请求失败')
error.code = body.code || response.statusCode
@@ -46,6 +57,7 @@ function request(options) {
resolve(body.data)
},
fail(error) {
debugLog('api.network-error', { method: options.method || 'GET', url: options.url, errMsg: error.errMsg || '' })
const next = new Error(error.errMsg || '网络连接失败')
next.network = true
reject(next)
@@ -80,7 +92,7 @@ export function isRemoteReady(inboundId) {
}
export function getContainerPickingTask(identity, warehouseId) {
const selector = typeof identity === 'object' && identity !== null ? identity : { containerNo: identity }
const selector = typeof identity === 'object' && identity !== null ? identity : {}
const taskId = selector.taskId ?? selector.id
if (taskId) {
if (!warehouseId) throw new Error('缺少作业仓库标识,无法请求现场任务')
@@ -92,7 +104,6 @@ export function getContainerPickingTask(identity, warehouseId) {
const query = []
if (selector.cabinetNo) query.push(`cabinetNo=${encodeURIComponent(selector.cabinetNo)}`)
else if (selector.loadingPlanId) query.push(`loadingPlanId=${encodeURIComponent(selector.loadingPlanId)}`)
else if (selector.containerNo) query.push(`containerNo=${encodeURIComponent(selector.containerNo)}`)
else throw new Error('缺少稳定柜次标识,无法请求现场任务')
if (warehouseId) query.push(`warehouseId=${encodeURIComponent(warehouseId)}`)
if (session.tenantId) query.push(`tenantId=${encodeURIComponent(session.tenantId)}`)
+1 -1
View File
@@ -18,7 +18,7 @@
import { session, restoreSession, signInWithTokens } from '@/stores/session.js'
import { requireOnline } from '@/utils/network.js'
export default {
data() { return { form: { userId: '', password: '' }, error: '', submitting: false } },
data() { return { form: { userId: 'platform_admin', password: 'ChangeMe123!' }, error: '', submitting: false } },
onLoad() { if (session.currentUser || restoreSession()) uni.reLaunch({ url: '/pages/home/home' }) },
methods: {
async login() {
+50 -11
View File
@@ -153,7 +153,11 @@
<view class="submit-pending"><text class="submit-pending-number">{{ summary.pending }}</text><text class="submit-pending-label"> 箱待拣</text></view>
<text class="submit-status-hint">{{ submitStatusHint }}</text>
</view>
<button class="submit-btn" aria-label="提交拣货完成" :disabled="submitting || completedBoxes[currentBoxIndex] || !summary.canSubmit" @tap="submitPicking">
<view v-if="remoteError && !pickingCompleted" class="remote-error-row submit-error-row">
<text class="remote-error" role="alert">{{ remoteError }}</text>
<button class="evidence-retry-btn" :disabled="remoteLoading" @tap="refreshTask">刷新任务</button>
</view>
<button class="submit-btn" aria-label="提交拣货完成" :disabled="submitting || completedBoxes[currentBoxIndex] || pickingCompleted || !summary.canSubmit" @tap="submitPicking">
{{ submitting ? '正在提交…' : submitButtonText }}
</button>
</view>
@@ -320,7 +324,7 @@
</template>
<script>
import { ITEM_STATUS, classifyScan, getPickSummary, submitStates } from '@/utils/picking-state.js'
import { ITEM_STATUS, classifyScan, createItemStates, getPickSummary, submitStates } from '@/utils/picking-state.js'
import { SKIP_REASONS, INCOMPLETE_REASONS } from '@/data/demo-data.js'
import { session, requireSession } from '@/stores/session.js'
import { clearPickingDraft, getPickingSession, restorePickingSession, savePickingSession } from '@/stores/picking.js'
@@ -434,11 +438,11 @@
if (available !== null && value > available) return `超过库存/库位可用量,可拣 ${available}`
return ''
},
submitButtonText() {
return this.completedBoxes[this.currentBoxIndex] ? '该箱已完成' : this.summary.buttonText
submitButtonText() {
return this.completedBoxes[this.currentBoxIndex] || this.pickingCompleted ? '该箱已完成' : this.summary.buttonText
},
submitStatusHint() {
if (this.completedBoxes[this.currentBoxIndex]) return '本箱任务已完成'
if (this.completedBoxes[this.currentBoxIndex] || this.pickingCompleted) return '本箱任务已完成'
if (!this.summary.canSubmit) return '请先处理至少一件'
if (this.summary.pending > 0) return '提交时需选择原因'
return `已处理 ${this.summary.processed}/${this.summary.total}`
@@ -496,6 +500,20 @@
return false
},
methods: {
debugSubmit(event, payload = {}) {
try {
console.info('[PDA] picking.submit', {
event,
taskId: this.remoteTaskId,
loadingPlanId: this.currentBox.loadingPlanId,
containerBookingId: this.currentBox.containerBookingId,
warehouseId: this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse,
pickingVersion: this.pickingVersion,
pending: this.summary.pending,
...payload
})
} catch (_) { /* console may be unavailable on a device */ }
},
isEvidenceContextActive(contextId, inboundId) {
return contextId === this.evidenceContextId
&& String(this.currentBox.inboundId ?? '') === String(inboundId ?? '')
@@ -710,6 +728,7 @@
async syncRemoteTask() {
if (!isRemoteReady(this.currentBox.inboundId || this.currentBox.boxNo)) return
const requestId = ++this.taskRequestId
this.debugSubmit('refresh.start', { requestId, inboundId: this.currentBox.inboundId || null, cabinetNo: this.currentBox.cabinetNo || null })
this.remoteLoading = true
this.remoteError = ''
try {
@@ -722,7 +741,9 @@
this.currentBox.items = remoteItems.map((item) => ({
...item,
id: item.id ?? item.itemId,
taskItemId: item.itemId ?? item.id,
// The batch endpoint expects the PDA task-item primary key (`id`),
// not a nested loading-plan item alias when both are returned.
taskItemId: item.id ?? item.itemId,
boxCodes: Array.isArray(item.boxCodes) ? item.boxCodes.map(String) : [],
orderNo: item.orderNo ?? '', inboundCode: item.inboundCode ?? '',
desc: item.cargoName ?? item.desc ?? item.description ?? '箱级拣货明细',
@@ -739,8 +760,17 @@
this.currentBox.orderNo = firstItem.orderNo || firstItem.inboundCode || this.currentBox.orderNo
this.currentBox.inboundId = firstItem.inboundId || this.currentBox.inboundId
this.currentBox.tenantId = firstItem.tenantId || this.currentBox.tenantId
this.itemStates = createItemStates(this.currentBox.items)
this.partialQuantities = {}
const serverStates = createItemStates(this.currentBox.items)
const serverPartialQuantities = {}
this.currentBox.items.forEach((item) => {
if (item.itemStatus === 'PICKED') serverStates[item.id] = ITEM_STATUS.PICKED
else if (item.itemStatus === 'PARTIAL') {
serverStates[item.id] = ITEM_STATUS.PARTIAL
serverPartialQuantities[item.id] = Number(item.pickedPackageCount ?? item.finalPickedPackageCount ?? 0)
} else if (item.itemStatus === 'SKIPPED') serverStates[item.id] = ITEM_STATUS.SKIPPED
})
this.itemStates = serverStates
this.partialQuantities = serverPartialQuantities
this.scannedBoxCodes = {}
}
this.remoteTaskId = task.taskId ?? task.id
@@ -761,12 +791,14 @@
this.remotePick = normalizePickingTask({ ...task, ...(claimed || {}) })
this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? null
this.loadingVersion = this.currentBox.inboundVersion ?? this.remotePick.inboundVersion ?? null
this.debugSubmit('refresh.ready', { requestId, workflowStatus: this.remotePick.workflowStatus, taskStatus: this.remotePick.taskStatus, assignmentVersion: this.assignmentVersion, loadingVersion: this.loadingVersion })
this.assignmentVersion = this.remotePick.assignmentVersion ?? this.assignmentVersion
this.restoreRemoteDraft()
if (!this.pickingCompleted) this.restoreRemoteDraft()
this.resetEvidenceState()
if (this.pickingCompleted) await this.loadLoadingEvidence()
} catch (error) {
if (requestId !== this.taskRequestId) return
this.debugSubmit('refresh.error', { requestId, message: error.message || '', code: error.code || '', statusCode: error.statusCode || '', requestIdFromServer: error.requestId || '', traceId: error.traceId || '' })
this.remoteError = error.network ? '网络不可用,无法开始拣货,恢复网络后请重试' : (error.message || '任务同步失败')
} finally { if (requestId === this.taskRequestId) this.remoteLoading = false }
},
@@ -1021,7 +1053,7 @@
this.requestBoxSwitch(entry.index)
},
submitPicking() {
if (!this.summary.canSubmit || this.completedBoxes[this.currentBoxIndex]) return
if (!this.summary.canSubmit || this.completedBoxes[this.currentBoxIndex] || this.pickingCompleted) return
if (this.summary.pending > 0) {
this.selectedIncompleteReason = ''
this.openOverlay('submitConfirm')
@@ -1036,6 +1068,8 @@
async performSubmit(incompleteReason = '') {
const beforeSubmit = this.summary
const remoteRequired = Boolean(session.token)
this.remoteError = ''
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 = '缺少服务端任务信息,未提交拣货完成'
this.showToast('拣货完成未提交', 'error')
@@ -1051,6 +1085,8 @@
this.submitId = submitId
const warehouseId = this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse
const inventorySnapshot = await getPickingInventorySnapshot(this.remoteTaskId, warehouseId)
this.debugSubmit('submit.inventory-snapshot', { snapshotTokenLength: inventorySnapshot?.snapshotToken ? String(inventorySnapshot.snapshotToken).length : 0, taskVersion: inventorySnapshot?.version ?? null })
if (!inventorySnapshot?.snapshotToken) throw new Error('库存快照未同步,请刷新任务后重试')
const actions = this.currentBox.items.map((item) => {
const status = this.itemState(item)
const itemId = item.taskItemId ?? item.remoteItemId ?? item.id
@@ -1060,15 +1096,18 @@
if (incompleteReason) return { type: 'SKIP', itemId, reason: `${incompleteReason}(提交时确认未拣)`, version: Number(item.version || 0) }
return null
}).filter(Boolean)
this.debugSubmit('submit.batch', { localItemIds: this.currentBox.items.map((item) => item.id), actionItemIds: actions.map((action) => action.itemId), actionCount: actions.length })
const submittedTask = await submitContainerPickingBatch({ taskId: this.remoteTaskId, loadingPlanId: this.currentBox.loadingPlanId, containerBookingId: this.currentBox.containerBookingId, version: Number(this.pickingVersion) }, actions, { warehouseId, operationId: submitId, inventorySnapshotToken: inventorySnapshot?.snapshotToken })
this.debugSubmit('submit.success', { itemCount: actions.length, workflowStatus: submittedTask?.workflowStatus || null, taskStatus: submittedTask?.taskStatus || null })
if (submittedTask) this.remotePick = normalizePickingTask({ ...this.remotePick, ...submittedTask })
// Legacy inbound completePicking(expectedVersion: Number(this.pickingVersion)) remains adapter-only; cabinet tasks use item actions.
} 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 = error.code === 'A0409' || error.statusCode === 409
const inventoryConflict = versionConflict && /库存/.test(error.message || '')
const conflictMessage = inventoryConflict ? '库存已变化,请刷新后重试' : '任务版本已变化,请刷新后重试'
this.remoteError = versionConflict ? conflictMessage : (error.network ? '网络失败,拣货完成未提交,请重试' : (error.message || '拣货完成失败'))
this.showToast(versionConflict ? conflictMessage : '拣货完成未提交', 'error')
this.showToast(versionConflict ? conflictMessage : '拣货完成未提交,请查看错误提示', 'error')
return
} finally { this.submitting = false }
}
+10 -1
View File
@@ -34,7 +34,13 @@ test('C02 identifiers and separate picking/inbound versions are normalized expli
assert.match(source, /inboundVersion: row\.inboundVersion/)
assert.match(source, /pickingVersion: row\.pickingVersion \?\? row\.loading\?\.pickingVersion/)
assert.match(source, /pickingVersion: task\.pickingVersion \?\? task\.version/)
assert.match(page, /expectedVersion: Number\(this\.pickingVersion\)/)
assert.match(page, /expectedVersion: Number\(this\.pickingVersion\)/)
assert.match(page, /ITEM_STATUS, classifyScan, createItemStates, getPickSummary, submitStates/)
assert.match(page, /taskItemId: item\.id \?\? item\.itemId/)
assert.match(page, /submit\.batch/)
assert.match(page, /item\.itemStatus === 'PICKED'/)
assert.match(page, /item\.itemStatus === 'PARTIAL'/)
assert.match(page, /pickingCompleted \|\| !summary\.canSubmit/)
assert.match(page, /this\.loadingVersion = this\.currentBox\.inboundVersion/)
assert.match(page, /状态尚未同步,请刷新重试/)
})
@@ -84,6 +90,9 @@ test('PDA cabinet picking adapter claims online and submits one atomic batch', (
assert.doesNotMatch(source.match(/export async function submitContainerPickingBatch[\s\S]*?\n\}/)?.[0] || '', /for \(/)
assert.match(page, /version: Number\(item\.version \|\| 0\)/)
assert.match(page, /refreshTask/)
assert.match(page, /remoteError && !pickingCompleted/)
assert.match(page, /库存快照未同步,请刷新任务后重试/)
assert.match(page, /拣货完成未提交,请查看错误提示/)
assert.match(page, /if \(!isPickingCompleteTask\(remoteTask\)\)[\s\S]*claimContainerPickingTask/)
assert.match(page, /taskRequestId/)
assert.match(page, /resetEvidenceState/)