From fb059e469174a69f9835290706a0eb21f0eb6216 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 1 Aug 2026 11:59:28 +0800 Subject: [PATCH 01/98] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=9B=BE=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- manifest.json | 34 ++++++++++++++++++++- pages/login/login.vue | 2 +- static/app-icons/1024x1024.png | Bin 0 -> 89408 bytes static/app-icons/120x120.png | Bin 0 -> 6235 bytes static/app-icons/144x144.png | Bin 0 -> 7281 bytes static/app-icons/152x152.png | Bin 0 -> 7775 bytes static/app-icons/167x167.png | Bin 0 -> 8503 bytes static/app-icons/180x180.png | Bin 0 -> 9136 bytes static/app-icons/192x192.png | Bin 0 -> 9839 bytes static/app-icons/20x20.png | Bin 0 -> 889 bytes static/app-icons/29x29.png | Bin 0 -> 1375 bytes static/app-icons/40x40.png | Bin 0 -> 1988 bytes static/app-icons/58x58.png | Bin 0 -> 2958 bytes static/app-icons/60x60.png | Bin 0 -> 3138 bytes static/app-icons/72x72.png | Bin 0 -> 3675 bytes static/app-icons/76x76.png | Bin 0 -> 4007 bytes static/app-icons/80x80.png | Bin 0 -> 4103 bytes static/app-icons/87x87.png | Bin 0 -> 4488 bytes static/app-icons/96x96.png | Bin 0 -> 4985 bytes static/brand-mark-white.png | Bin 0 -> 12512 bytes styles/pda.scss | 52 +++++++-------------------------- 21 files changed, 44 insertions(+), 44 deletions(-) create mode 100644 static/app-icons/1024x1024.png create mode 100644 static/app-icons/120x120.png create mode 100644 static/app-icons/144x144.png create mode 100644 static/app-icons/152x152.png create mode 100644 static/app-icons/167x167.png create mode 100644 static/app-icons/180x180.png create mode 100644 static/app-icons/192x192.png create mode 100644 static/app-icons/20x20.png create mode 100644 static/app-icons/29x29.png create mode 100644 static/app-icons/40x40.png create mode 100644 static/app-icons/58x58.png create mode 100644 static/app-icons/60x60.png create mode 100644 static/app-icons/72x72.png create mode 100644 static/app-icons/76x76.png create mode 100644 static/app-icons/80x80.png create mode 100644 static/app-icons/87x87.png create mode 100644 static/app-icons/96x96.png create mode 100644 static/brand-mark-white.png diff --git a/manifest.json b/manifest.json index 9c3a74a..5586287 100644 --- a/manifest.json +++ b/manifest.json @@ -26,7 +26,39 @@ }, "ios" : {}, /* ios打包配置 */ - "sdkConfigs" : {} + "sdkConfigs" : {}, + "icons" : { + "android" : { + "hdpi" : "static/app-icons/72x72.png", + "xhdpi" : "static/app-icons/96x96.png", + "xxhdpi" : "static/app-icons/144x144.png", + "xxxhdpi" : "static/app-icons/192x192.png" + }, + "ios" : { + "appstore" : "static/app-icons/1024x1024.png", + "ipad" : { + "app" : "static/app-icons/76x76.png", + "app@2x" : "static/app-icons/152x152.png", + "notification" : "static/app-icons/20x20.png", + "notification@2x" : "static/app-icons/40x40.png", + "proapp@2x" : "static/app-icons/167x167.png", + "settings" : "static/app-icons/29x29.png", + "settings@2x" : "static/app-icons/58x58.png", + "spotlight" : "static/app-icons/40x40.png", + "spotlight@2x" : "static/app-icons/80x80.png" + }, + "iphone" : { + "app@2x" : "static/app-icons/120x120.png", + "app@3x" : "static/app-icons/180x180.png", + "notification@2x" : "static/app-icons/40x40.png", + "notification@3x" : "static/app-icons/60x60.png", + "settings@2x" : "static/app-icons/58x58.png", + "settings@3x" : "static/app-icons/87x87.png", + "spotlight@2x" : "static/app-icons/80x80.png", + "spotlight@3x" : "static/app-icons/120x120.png" + } + } + } } }, /* SDK配置 */ diff --git a/pages/login/login.vue b/pages/login/login.vue index feb21e2..dd5dc0f 100644 --- a/pages/login/login.vue +++ b/pages/login/login.vue @@ -1,7 +1,7 @@ diff --git a/pages/module/module.vue b/pages/module/module.vue index f8df248..f36d69a 100644 --- a/pages/module/module.vue +++ b/pages/module/module.vue @@ -22,7 +22,7 @@ onLoad(options) { this.moduleKey = options.key || 'outbound' }, onShow() { if (!requireSession()) return; this.user = session.currentUser; this.overview = getPickingOverview() }, methods: { - openOperation(operation) { if (operation.available) uni.navigateTo({ url: '/pages/picking/picking' }) } + openOperation(operation) { if (operation.available) uni.navigateTo({ url: operation.name === '集装箱拣货' ? '/pages/tasks/tasks' : '/pages/operation/operation' }) } } } diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue index 1b1dce3..4104dae 100644 --- a/pages/picking/picking.vue +++ b/pages/picking/picking.vue @@ -140,7 +140,7 @@ - {{ remoteError }} + {{ remoteError }} 等待统一订单详情提供装柜版本号后可提交完成 @@ -326,6 +326,8 @@ import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js' import { claimContainerPickingTask, completeLoading, completePicking, EVIDENCE_SLOTS, getContainerPickingTask, getEvidence, getPickingTask, isRemoteReady, normalizePickingTask, submitContainerPickingBatch, uploadEvidenceFile, upsertEvidence, voidEvidence } from '@/api/pda-loading.js' import { createEvidenceUploadQueue } from '@/utils/evidence-upload-queue.js' + import { isOnline } from '@/utils/network.js' + import { stableOperationId } from '@/utils/idempotency.js' import ScanTargetInput from '@/components/ScanTargetInput.vue' import PopupHeader from '@/components/PopupHeader.vue' @@ -433,8 +435,7 @@ : !this.completedBoxes[entry.index]) }, remoteSyncState() { - if (!this.currentBox.inboundId) return { label: '演示任务', tone: 'local' } - if (!isRemoteReady(this.currentBox.inboundId)) return { label: '未连接', tone: 'error' } + if (!isRemoteReady(this.currentBox.inboundId || this.currentBox.boxNo)) return { label: '未连接', tone: 'error' } if (this.remoteLoading) return { label: '同步中', tone: 'loading' } if (this.remoteError) return { label: '同步失败', tone: 'error' } return { label: this.remotePick ? '已同步' : '待同步', tone: this.remotePick ? 'ready' : 'local' } @@ -445,11 +446,24 @@ }, created() { this.evidenceQueue = createEvidenceUploadQueue({ - upload: (item, onProgress) => uploadEvidenceFile(item.inboundId, item.evidenceType, item.localPath, null, onProgress), + upload: (item, onProgress) => uploadEvidenceFile(item.inboundId, item.evidenceType, item.localPath, item.tenantId, onProgress), onChange: (items) => { this.evidenceQueueItems = items } }) }, - onLoad() { requireSession() }, + onLoad(options = {}) { + if (!requireSession()) return + if (options.containerNo) { + this.boxes = [{ + boxNo: String(options.containerNo), orderNo: String(options.orderNo || options.inboundCode || ''), + inboundId: options.inboundId ? String(options.inboundId) : null, + inboundVersion: options.inboundVersion == null ? null : Number(options.inboundVersion), + warehouseId: options.warehouseId ? String(options.warehouseId) : null, + tenantId: options.tenantId ? String(options.tenantId) : null, items: [] + }] + this.currentBoxIndex = 0 + this.remoteTaskId = options.taskId ? String(options.taskId) : null + } + }, onShow() { if (!requireSession()) return this.startHardwareScanner() @@ -472,10 +486,9 @@ restoreRemoteDraft() { const restored = restorePickingSession(this.draftIdentity()) if (!restored) return false - if (Array.isArray(restored.boxes) && restored.boxes.length) this.boxes = restored.boxes - this.currentBoxIndex = Math.min(Number(restored.currentBoxIndex || 0), Math.max(0, this.boxes.length - 1)) - this.itemStates = restored.itemStates || this.itemStates - this.partialQuantities = restored.partialQuantities || {} + const itemIds = new Set(this.currentBox.items.map((item) => String(item.id))) + this.itemStates = Object.fromEntries(Object.entries(restored.itemStates || {}).filter(([id]) => itemIds.has(String(id)))) + this.partialQuantities = Object.fromEntries(Object.entries(restored.partialQuantities || {}).filter(([id]) => itemIds.has(String(id)))) this.boxDrafts = restored.boxDrafts || {} this.completedBoxes = restored.completedBoxes || {} this.scanHistory = restored.scanHistory || [] @@ -680,7 +693,7 @@ this.assignmentVersion = this.remotePick.assignmentVersion ?? this.assignmentVersion this.restoreRemoteDraft() if (this.currentBox.inboundId) { - const evidence = await getEvidence(this.currentBox.inboundId) + const evidence = await getEvidence(this.currentBox.inboundId, this.currentBox.tenantId) this.evidenceFiles = (evidence && evidence.evidences ? evidence.evidences : []).reduce((all, item) => { (all[item.evidenceType] ||= []).push(item); return all }, {}) const seal = this.evidenceEntry('SEAL') if (seal) this.sealNo = seal.sealNo || '' @@ -689,12 +702,19 @@ this.remoteError = error.network ? '网络不可用,无法开始拣货,恢复网络后请重试' : (error.message || '任务同步失败') } finally { this.remoteLoading = false } }, + async refreshTask() { + if (!this.currentBox.boxNo) return + this.persistSession() + await this.syncRemoteTask() + if (!this.remoteError) this.showToast('任务已刷新,本地拣货标记已保留', 'success') + }, evidenceUploadLabel(type) { const queued = this.evidenceQueueEntry(type) if (queued) return queued.status === 'UPLOADING' ? `上传中 ${queued.progress || 0}%` : '等待上传' - return this.evidenceEntry(type) ? '重新选择' : '拍照/选择' + return this.evidenceEntry(type) ? '继续拍照' : '拍照' }, chooseEvidence(slot) { + if (!isOnline()) { this.showToast('当前网络不可用,请联网后再拍照上传', 'warning'); return } if (typeof uni.chooseImage !== 'function') { this.showToast('当前环境不支持选择照片', 'warning'); return } uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['camera'], success: (result) => { const path = result.tempFilePaths && result.tempFilePaths[0] @@ -706,7 +726,7 @@ }, async queueEvidenceUpload(type, path) { if (!this.currentBox.inboundId || !isRemoteReady(this.currentBox.inboundId)) { this.showToast('当前会话未连接服务端,证据保留本地', 'warning'); return } - const entry = this.evidenceQueue.add({ inboundId: this.currentBox.inboundId, evidenceType: type, localPath: path }) + const entry = this.evidenceQueue.add({ inboundId: this.currentBox.inboundId, tenantId: this.currentBox.tenantId, evidenceType: type, localPath: path }) await this.evidenceQueue.flush() const latest = this.evidenceQueue.snapshot().find((item) => item.id === entry.id) if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return } @@ -732,7 +752,7 @@ if (!current.fileId) { this.showToast('照片尚未上传完成', 'warning'); return } const payload = { expectedVersion: current.version || 0, evidenceType: type, fileId: current.fileId || null, fileName: current.fileName || null, sealNo: type === 'SEAL' ? this.sealNo : null, remark: null, operationId: operationId || `evidence-${this.remoteTaskId || this.currentBox.inboundId}-${type}-${current.fileId || 'seal'}` } try { - const result = await upsertEvidence(this.currentBox.inboundId, payload) + const result = await upsertEvidence(this.currentBox.inboundId, payload, this.currentBox.tenantId) this.evidenceFiles = (result && result.evidences ? result.evidences : []).reduce((all, item) => { const list = (all[item.evidenceType] || []).filter((entry) => entry.id || entry.fileId !== item.fileId); const index = list.findIndex((entry) => entry.id === item.id); all[item.evidenceType] = index >= 0 ? list.map((entry, i) => i === index ? item : entry) : [...list, item]; return all }, this.evidenceFiles) this.showToast(`${type === 'SEAL' ? '封签' : '照片'}已保存`, 'success') } catch (error) { this.remoteError = error.network ? '网络失败,证据未提交,请重试' : (error.message || '证据保存失败'); this.showToast('证据保存失败', 'error') } @@ -745,7 +765,7 @@ if (!photo?.id || !this.currentBox.inboundId) return const reason = 'PDA现场标记错图' try { - await voidEvidence(this.currentBox.inboundId, photo.id, { expectedVersion: photo.version || 0, reason, operationId: `void-${this.currentBox.inboundId}-${photo.id}-${photo.version || 0}` }) + await voidEvidence(this.currentBox.inboundId, photo.id, { expectedVersion: photo.version || 0, reason, operationId: `void-${this.currentBox.inboundId}-${photo.id}-${photo.version || 0}` }, this.currentBox.tenantId) this.evidenceFiles = { ...this.evidenceFiles, [photo.evidenceType]: (this.evidenceFiles[photo.evidenceType] || []).map((item) => item.id === photo.id ? { ...item, status: 'VOID', voidReason: reason } : item) } this.showToast('照片已作废,可重新拍摄', 'success') } catch (error) { @@ -758,7 +778,7 @@ if (!this.completedBoxes[this.currentBoxIndex]) { this.showToast('请先提交拣货完成', 'warning'); return } if (!this.evidenceComplete) { this.showToast('五类有效照片和封签号均为必填', 'warning'); return } this.submitting = true - try { await completeLoading(this.currentBox.inboundId, { expectedVersion: this.loadingVersion, remark: 'PDA确认装柜完成' }); this.showToast('已确认装柜完成', 'success') } + try { await completeLoading(this.currentBox.inboundId, { expectedVersion: this.loadingVersion, remark: 'PDA确认装柜完成' }, this.currentBox.tenantId); this.showToast('已确认装柜完成', 'success') } catch (error) { this.remoteError = error.network ? '网络失败,装柜完成未提交' : (error.message || '装柜完成失败'); this.showToast('装柜完成失败', 'error') } finally { this.submitting = false } }, @@ -876,9 +896,9 @@ }, async performSubmit(incompleteReason = '') { const beforeSubmit = this.summary - const remoteRequired = Boolean(this.currentBox.inboundId || (session.token && session.tenantId)) + const remoteRequired = Boolean(session.token) if (remoteRequired && !isRemoteReady(this.currentBox.inboundId || this.currentBox.boxNo)) { - this.remoteError = '缺少服务端会话或 tenantId,未提交拣货完成' + this.remoteError = '缺少服务端任务信息,未提交拣货完成' this.showToast('拣货完成未提交', 'error') return } @@ -887,15 +907,15 @@ try { if (!this.remoteTaskId) throw new Error('尚未进入服务端拣货任务,请联网刷新后重试') if (this.pickingVersion === null) throw new Error('缺少拣货版本号 pickingVersion,无法提交') - const submitId = this.submitId || `submit-${session.currentUser?.id || 'operator'}-${this.remoteTaskId}-${this.currentBox.boxNo}-${JSON.stringify(this.itemStates)}-${JSON.stringify(this.partialQuantities)}-${incompleteReason}` + const submitId = this.submitId || stableOperationId('submit', `${session.currentUser?.id || 'operator'}-${this.remoteTaskId}-${this.currentBox.boxNo}`, { itemStates: this.itemStates, partialQuantities: this.partialQuantities, incompleteReason }) this.submitId = submitId const actions = this.currentBox.items.map((item) => { const status = this.itemState(item) 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 || [] } - if (status === ITEM_STATUS.PARTIAL) return { type: 'PARTIAL', itemId, finalPickedPackageCount: Number(this.partialQuantities[item.id] || 0), locationIds: item.locationIds || [] } - if (status === ITEM_STATUS.SKIPPED) return { type: 'SKIP', itemId, reason: item.skipReason || incompleteReason || '现场跳拣' } - if (incompleteReason) return { type: 'SKIP', itemId, reason: `${incompleteReason}(提交时确认未拣)` } + 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) } + if (incompleteReason) return { type: 'SKIP', itemId, reason: `${incompleteReason}(提交时确认未拣)`, version: Number(item.version || 0) } return null }).filter(Boolean) await submitContainerPickingBatch({ taskId: this.remoteTaskId, containerNo: this.currentBox.boxNo, version: Number(this.pickingVersion) }, actions, { warehouseId: this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse, operationId: submitId }) diff --git a/pages/tasks/tasks.vue b/pages/tasks/tasks.vue new file mode 100644 index 0000000..0729d82 --- /dev/null +++ b/pages/tasks/tasks.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/stores/session.js b/stores/session.js index 8151d0c..bba1957 100644 --- a/stores/session.js +++ b/stores/session.js @@ -1,26 +1,97 @@ import { reactive } from 'vue' import { resetPickingSession } from '@/stores/picking.js' +import { PLATFORM_API_ROOT } from '@/config/runtime.js' -export const session = reactive({ currentUser: null, token: '', tenantId: '', apiBase: '' }) +const SESSION_KEY = 'pda-auth-session' + +export const session = reactive({ currentUser: null, token: '', refreshToken: '', tenantId: '', apiBase: PLATFORM_API_ROOT }) + +function decodeBase64Url(value) { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/') + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=') + if (typeof atob === 'function') return decodeURIComponent(Array.from(atob(padded), (char) => `%${char.charCodeAt(0).toString(16).padStart(2, '0')}`).join('')) + throw new Error('当前运行环境无法解析登录令牌') +} + +export function decodeAccessToken(token) { + const payload = String(token || '').split('.')[1] + if (!payload) throw new Error('登录响应中的 accessToken 格式无效') + try { + return JSON.parse(decodeBase64Url(payload)) + } catch (error) { + if (error.message === '当前运行环境无法解析登录令牌') throw error + throw new Error('登录响应中的 accessToken 无法解析') + } +} + +function storageAvailable() { + return typeof uni !== 'undefined' && typeof uni.setStorageSync === 'function' && typeof uni.getStorageSync === 'function' +} + +function persistSession() { + if (!storageAvailable()) return + uni.setStorageSync(SESSION_KEY, { + currentUser: session.currentUser, + token: session.token, + refreshToken: session.refreshToken, + tenantId: session.tenantId, + apiBase: session.apiBase + }) +} export function signIn(account, warehouse, options = {}) { - session.currentUser = { id: account.id, name: account.name, role: account.role, warehouse } + session.currentUser = { id: String(account.id), name: account.name, role: account.role, roles: account.roles || [], warehouse, warehouseId: account.warehouseId || null } session.token = options.token || account.token || '' + session.refreshToken = options.refreshToken || account.refreshToken || '' session.tenantId = options.tenantId || account.tenantId || '' - session.apiBase = options.apiBase || account.apiBase || '' + session.apiBase = options.apiBase || account.apiBase || PLATFORM_API_ROOT + persistSession() return session.currentUser } +export function signInWithTokens(tokens) { + if (!tokens?.accessToken) throw new Error('登录响应缺少 accessToken') + const claims = decodeAccessToken(tokens.accessToken) + if (claims.app_code !== 'PLATFORM') throw new Error('当前账号不属于平台作业端') + if (claims.exp && claims.exp * 1000 <= Date.now()) throw new Error('登录状态已过期') + return signIn({ + id: claims.user_id ?? '', + name: claims.username || '仓库作业员', + role: Array.isArray(claims.roles) ? claims.roles.join('、') : '', + roles: claims.roles || [] + }, '', { + token: tokens.accessToken, + refreshToken: tokens.refreshToken || '', + tenantId: claims.tenant_id == null ? '' : String(claims.tenant_id) + }) +} + +export function restoreSession() { + if (!storageAvailable()) return false + const saved = uni.getStorageSync(SESSION_KEY) + if (!saved?.token || !saved?.currentUser) return false + try { + const claims = decodeAccessToken(saved.token) + if (claims.exp && claims.exp * 1000 <= Date.now()) return false + Object.assign(session, saved, { apiBase: saved.apiBase || PLATFORM_API_ROOT }) + return true + } catch (_) { + return false + } +} + export function signOut() { session.currentUser = null session.token = '' + session.refreshToken = '' session.tenantId = '' - session.apiBase = '' + session.apiBase = PLATFORM_API_ROOT + if (typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(SESSION_KEY) resetPickingSession() } export function requireSession() { - if (session.currentUser) return true + if (session.currentUser || restoreSession()) return true uni.reLaunch({ url: '/pages/login/login' }) return false } diff --git a/tests/idempotency.test.mjs b/tests/idempotency.test.mjs new file mode 100644 index 0000000..36890a3 --- /dev/null +++ b/tests/idempotency.test.mjs @@ -0,0 +1,19 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +const { stableOperationId } = await import(new URL('../utils/idempotency.js', import.meta.url).href) + +test('PDA operation id is stable ASCII and within the backend 128 character limit', () => { + const payload = { itemStates: Object.fromEntries(Array.from({ length: 500 }, (_, index) => [`明细-${index}`, 'PICKED'])), reason: '中文异常原因'.repeat(100) } + const first = stableOperationId('submit', 'operator-1-task-2-container-3', payload) + const replay = stableOperationId('submit', 'operator-1-task-2-container-3', payload) + assert.equal(first, replay) + assert.ok(first.length <= 128) + assert.match(first, /^[\x20-\x7E]+$/) +}) + +test('PDA operation id changes when the draft changes', () => { + const first = stableOperationId('submit', 'operator-task-container', { item: 'PICKED' }) + const second = stableOperationId('submit', 'operator-task-container', { item: 'SKIPPED' }) + assert.notEqual(first, second) +}) diff --git a/tests/online-session.test.mjs b/tests/online-session.test.mjs new file mode 100644 index 0000000..b9ab1dd --- /dev/null +++ b/tests/online-session.test.mjs @@ -0,0 +1,25 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' + +const read = (path) => readFile(new URL(path, import.meta.url), 'utf8') + +test('remote PDA mutations use the shared online gate', async () => { + const [api, network, page] = await Promise.all([ + read('../api/pda-loading.js'), read('../utils/network.js'), read('../pages/picking/picking.vue') + ]) + assert.match(api, /function request\(options\) \{[\s\S]*?requireOnline\(\)/) + assert.match(api, /uploadEvidenceFile[\s\S]*?requireOnline\('当前网络不可用,无法上传装柜照片'\)/) + assert.match(network, /uni\.onNetworkStatusChange/) + assert.match(network, /result\.networkType !== 'none'/) + assert.match(page, /当前网络不可用,请联网后再拍照上传/) +}) + +test('remote refresh keeps draft actions but does not restore stale server item snapshots', async () => { + const page = await read('../pages/picking/picking.vue') + const restore = page.match(/restoreRemoteDraft\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' + assert.match(restore, /itemIds/) + assert.match(restore, /restored\.itemStates/) + assert.doesNotMatch(restore, /this\.boxes = restored\.boxes/) + assert.match(page, /refreshTask\(\)[\s\S]*?persistSession\(\)[\s\S]*?syncRemoteTask\(\)/) +}) diff --git a/tests/page-contract.test.mjs b/tests/page-contract.test.mjs index 85d27f0..f74c82f 100644 --- a/tests/page-contract.test.mjs +++ b/tests/page-contract.test.mjs @@ -10,7 +10,7 @@ test('page configuration starts at login and registers independent PDA routes', assert.equal(pages.pages[0].style.navigationStyle, 'custom') assert.deepEqual(pages.pages.map((page) => page.path), [ 'pages/login/login', 'pages/home/home', 'pages/module/module', 'pages/operation/operation', - 'pages/history/history', 'pages/profile/profile', 'pages/scanner-test/scanner-test', 'pages/picking/picking' + 'pages/tasks/tasks', 'pages/history/history', 'pages/profile/profile', 'pages/scanner-test/scanner-test', 'pages/picking/picking' ]) }) @@ -22,13 +22,28 @@ test('application uses the Vue 3 uni-app entry and no unused Android permissions assert.doesNotMatch(manifest, /android\.permission\.(VIBRATE|ACCESS_NETWORK_STATE)/) }) -test('login is isolated and relaunches to the modular workbench', async () => { - const login = await read('../pages/login/login.vue') - for (const text of ['柏悦物流 · PDA作业端', '快捷登录(演示账号)', '工号 / 用户名', '作业仓库']) assert.match(login, new RegExp(text)) - assert.match(login, /signIn\(account, this\.form\.warehouse\)/) +test('login uses the real auth endpoint and relaunches to the modular workbench', async () => { + const [login, auth, session] = await Promise.all([read('../pages/login/login.vue'), read('../api/auth.js'), read('../stores/session.js')]) + for (const text of ['柏悦物流 · PDA作业端', '工号 / 用户名', '登录中']) assert.match(login, new RegExp(text)) + assert.doesNotMatch(login, /DEMO_ACCOUNTS|快捷登录|作业仓库/) + assert.match(auth, /\/auth\/login/) + assert.match(login, /loginRequest\(userId, this\.form\.password\)/) + assert.match(login, /signInWithTokens\(tokens\)/) + assert.match(session, /pda-auth-session/) + assert.match(session, /claims\.app_code !== 'PLATFORM'/) assert.match(login, /uni\.reLaunch\(\{ url: '\/pages\/home\/home' \}\)/) }) +test('picking task list uses remote loading orders and never falls back to demo tasks', async () => { + const [tasks, home, modulePage] = await Promise.all([read('../pages/tasks/tasks.vue'), read('../pages/home/home.vue'), read('../pages/module/module.vue')]) + assert.match(tasks, /listLoadingOrders/) + assert.match(tasks, /当前网络不可用,请联网后刷新/) + assert.match(tasks, /进入并开始拣货/) + assert.doesNotMatch(tasks, /demo-data|DEMO_/) + assert.match(home, /pages\/tasks\/tasks/) + assert.match(modulePage, /pages\/tasks\/tasks/) +}) + test('workbench, module, history and profile are independent sources', async () => { const [home, module, history, profile] = await Promise.all([ read('../pages/home/home.vue'), read('../pages/module/module.vue'), read('../pages/history/history.vue'), read('../pages/profile/profile.vue') @@ -194,7 +209,7 @@ test('reusable scan target input separates field selection from page-level broad assert.match(picking, /handleHardwareScan\(value\)[\s\S]*?activeScanField !== 'sku'[\s\S]*?processScannedValue\(value, 'broadcast'\)/) }) -test('picking session is memory-only, survives navigation and clears on logout', async () => { +test('picking session persists per task identity and clears on logout', async () => { const [store, session, page] = await Promise.all([read('../stores/picking.js'), read('../stores/session.js'), read('../pages/picking/picking.vue')]) assert.match(store, /getPickingSession/) assert.match(store, /savePickingSession/) diff --git a/tests/pda-loading-api.test.mjs b/tests/pda-loading-api.test.mjs index 874ef78..214bd05 100644 --- a/tests/pda-loading-api.test.mjs +++ b/tests/pda-loading-api.test.mjs @@ -55,18 +55,21 @@ test('picking page exposes five evidence slots, retry and remote completion stat assert.match(page, /evidenceComplete/) }) -test('PDA cabinet picking adapter exposes online claim and item action contracts', () => { - assert.match(source, /\/first-leg\/pda-loading\/tasks\?/) - assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\/claim/) - assert.match(source, /items\/\$\{encodeURIComponent\(itemId\)\}\/pick/) - assert.match(source, /items\/\$\{encodeURIComponent\(itemId\)\}\/partial/) - assert.match(source, /items\/\$\{encodeURIComponent\(itemId\)\}\/skip/) - assert.match(source, /submitContainerPickingBatch/) - assert.match(source, /stableIdempotencyKey/) - assert.match(page, /claimContainerPickingTask\(/) - assert.match(page, /submitContainerPickingBatch\(/) - assert.match(page, /任务版本已变化,请刷新后重试/) - assert.match(source, /locationIds: action\.locationIds \|\| \[\]/) - assert.match(page, /availablePickQuantity/) - assert.match(page, /库存\/库位可用量不足/) +test('PDA cabinet picking adapter claims online and submits one atomic batch', () => { + assert.match(source, /\/first-leg\/pda-loading\/tasks\?/) + assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\/claim/) + assert.match(source, /tasks\/\$\{encodeURIComponent\(task\.taskId\)\}\/batch/) + assert.match(source, /submitContainerPickingBatch/) + assert.match(source, /stableIdempotencyKey/) + assert.match(source, /Math\.imul\(hash, 16777619\)/) + assert.match(source, /slice\(0, 128\)/) + assert.match(page, /claimContainerPickingTask\(/) + assert.match(page, /submitContainerPickingBatch\(/) + assert.match(page, /任务版本已变化,请刷新后重试/) + assert.match(source, /data: \{ warehouseId, containerNo: task\.containerNo, expectedVersion, items \}/) + 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, /availablePickQuantity/) + assert.match(page, /库存\/库位可用量不足/) }) diff --git a/utils/idempotency.js b/utils/idempotency.js new file mode 100644 index 0000000..161fada --- /dev/null +++ b/utils/idempotency.js @@ -0,0 +1,19 @@ +function hash32(value, seed) { + let hash = seed >>> 0 + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index) + hash = Math.imul(hash, 0x01000193) >>> 0 + } + return hash.toString(16).padStart(8, '0') +} + +export function shortStableHash(value) { + const input = String(value ?? '') + return `${hash32(input, 0x811c9dc5)}${hash32(input, 0x9e3779b9)}` +} + +export function stableOperationId(scope, identity, payload) { + const safeScope = String(scope || 'operation').replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 24) + const safeIdentity = String(identity || 'unknown').replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 48) + return `${safeScope}-${safeIdentity}-${shortStableHash(JSON.stringify(payload ?? null))}`.slice(0, 120) +} diff --git a/utils/network.js b/utils/network.js new file mode 100644 index 0000000..723914a --- /dev/null +++ b/utils/network.js @@ -0,0 +1,46 @@ +let online = true +let listening = false +const listeners = new Set() + +function notify() { + for (const listener of listeners) listener(online) +} + +function ensureListening() { + if (listening || typeof uni === 'undefined') return + listening = true + if (typeof uni.onNetworkStatusChange === 'function') { + uni.onNetworkStatusChange((result) => { + online = Boolean(result.isConnected) + notify() + }) + } + if (typeof uni.getNetworkType === 'function') { + uni.getNetworkType({ + success: (result) => { + online = result.networkType !== 'none' + notify() + } + }) + } +} + +export function isOnline() { + ensureListening() + return online +} + +export function requireOnline(message = '当前网络不可用,请联网后刷新重试') { + if (!isOnline()) { + const error = new Error(message) + error.network = true + throw error + } +} + +export function watchNetwork(listener) { + ensureListening() + listeners.add(listener) + listener(online) + return () => listeners.delete(listener) +} From f83a0c47c1e2f37a663b63b1f0348d89423d3702 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 11 Aug 2026 11:41:55 +0800 Subject: [PATCH 07/98] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 23 +++++++++++++++++------ README.md | 4 ++-- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 67cfaae..b47c4f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,10 +6,10 @@ - README 记录的登录、箱号选择、扫码、整单或部分拣货、异常原因和完成提交是当前静态原型范围;项目使用 uni-app Vue 3,业务页面采用 Vue 3 兼容的 Options API。 - 本项目负责现场执行体验,不承担管理后台的订单配置、客户工作台或通用库存管理能力。 -## 当前阶段门禁 +## 开发与发布边界 - 当前已有 uni-app Vue 3 静态原型和 `uni_modules/`,没有项目根级依赖清单或可核实的 HBuilderX/Android 构建命令;现有 Node.js 内置测试仅覆盖页面契约与本地状态规则。 -- Android 硬件扫描已确认使用广播模式并已接入:Action=`android.intent.action.SCANRESULT`、字符串 Extra=`value`。目标 PDA 型号、Android 版本、打包发布方式和真实设备验收仍待确认;继续通过设备适配层接入,不绑定单一厂商 SDK。 +- PDA 采用 uni-app 开发并支持在线更新;Android 硬件扫描已确认使用广播模式并已接入:Action=`android.intent.action.SCANRESULT`、字符串 Extra=`value`。设备型号不构成开发、发布或验收门禁;继续通过设备适配层接入,不绑定单一厂商 SDK。 - 正式作业已确认为在线模式:断网不能开始正式拣货、提交、完成任务或推进 `LOADED`,不实现离线业务提交队列;恢复网络后提示员工刷新重试。 - 明确使用的工具链和发布流程后,补充准确的依赖版本、安装、调试、测试和打包命令,并同步更新 `README.md` 与本文件。 @@ -39,14 +39,25 @@ - 状态转换、扫码分类与页面契约使用 `node --test tests/*.test.mjs` 验证;新增本地行为时继续补充对应自动化测试。 - 当前原型没有可核实的 HBuilderX/Android 构建命令,不得声称打包或真机检查已通过;交付说明应明确实际验证方式、设备、系统版本和仍未覆盖的风险。 -## 一期/二期/三期快速索引 +## 页面与能力快速查询 + +| 目标 | 入口 | 继续查询 | +|---|---|---| +| 页面注册与启动 | `pages.json`、`main.js`、`App.vue` | 全局样式和应用生命周期 | +| 登录与会话 | `pages/login/`、`stores/session.js` | 远程鉴权适配和会话清理 | +| 工作台与模块入口 | `pages/home/`、`pages/module/`、`pages/operation/` | `data/demo-data.js` 与页面禁用规则 | +| 拣货任务与扫码 | `pages/picking/`、`stores/picking.js` | `api/pda-loading.js`、`utils/scan-broadcast.js`、`components/ScanTargetInput.vue` | +| 扫描诊断 | `pages/scanner-test/` | `utils/scan-broadcast.js` 与广播 Action/Extra | +| 作业记录与人员 | `pages/history/`、`pages/profile/` | session 与本地敏感数据清理 | + +## 分期范围索引 按阶段处理 PDA 任务时,先核对阶段文档,再从 `pages.json` 定位页面,沿页面 → `stores/` → `utils/` → 设备适配层检查;本表只描述 PDA 作业端,不代表后台或 Client 已具备对应能力。 -| 阶段 | 当前状态与边界 | PDA 入口 | 开始前核对 | +| 阶段 | PDA 范围边界 | 代码入口 | 开发首读 | |---|---|---|---| | 一期:头程订单基础闭环 | PDA 仅保留静态登录、工作台和出库拣货原型;是否与一期头程订单/仓库任务同步上线尚未确认,不得将本地状态当作服务端闭环。 | `pages/login/`、`pages/home/`、`pages/picking/`、`stores/session.js`、`stores/picking.js`;页面注册见 `pages.json`。 | `../../docs/一期/README.md`、`../../docs/一期/一期头程订单闭环设计.md`、`README.md`;确认设备、同步范围、鉴权和服务端任务契约后再接 API。 | | 二期:到仓与计费 | 不在二期实现 PDA 到仓测量、称重、托盘/库位或费用确认;这些能力由 Admin/Client/Backend 完成,PDA 继续维持静态原型。 | 现有 `pages/picking/` 不扩展二期业务;扫码适配保持在 `utils/scan-broadcast.js` 与 `components/ScanTargetInput.vue`。 | `../../docs/二期/README.md`、`../../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`;确认二期终点 `WAIT_SPLIT + APPROVED + CONFIRMED`,不得提前接入分柜。 | -| 三期:国内出仓 | PDA 承接分柜后拣货和装柜证据。在线作业、进入即开始、持久化草稿、批量提交、异常阻断、轻量幂等/版本和五类多图规则已确认;一柜多人、换班释放、最终 `LOADED` 触发和设备发布仍待确认。 | 复用 `pages/picking/`、`stores/picking.js`、`utils/scan-broadcast.js` 和远程 API 适配;当前内存草稿、单类型照片替换等实现不得作为最终契约。 | `../../docs/三期/README.md`、`../../docs/三期/三期开发规划.md`、`../../docs/三期/三期需求确认表.md`、`README.md`;按已确认子规则继续开发,待定项保持可扩展,不自行固定。 | +| 三期:国内出仓 | PDA 承接分柜后拣货和装柜证据。在线作业、进入即开始、持久化草稿、批量提交、异常阻断、轻量幂等/版本、五类多图和单柜次单作业员均已确认;`LOADED` 由服务端统一门禁推进。PDA 采用 uni-app 开发并支持在线更新。 | 复用 `pages/picking/`、`stores/picking.js`、`utils/scan-broadcast.js` 和远程 API 适配;当前内存草稿、单类型照片替换等实现不得作为最终契约。 | `../../docs/三期/README.md`、`../../docs/三期/三期开工门禁.md`、`../../docs/三期/三期开发规划.md`、`../../docs/三期/三期需求确认表.md`、`README.md`;按已确认子规则继续开发,扫描适配保持厂商无关。 | -阶段边界:PDA 不承担客户工作台或后台配置;一期仅验证原型与同步范围,二期不提前引入 PDA/分柜/装柜。三期已确认的 PDA 子规则可进入开发和契约测试,仍待确认的多人协作、完成触发和真机发布不得自行定稿。 +阶段边界:PDA 不承担客户工作台或后台配置;一期仅验证原型与同步范围,二期不提前引入 PDA/分柜/装柜。三期不支持多人协作、任务接管或换班释放;发布采用既定 uni-app 在线更新安排。 diff --git a/README.md b/README.md index 27e1dc0..c8636e1 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ 正式 PDA 作业必须联网。断网时不能开始正式拣货、提交、完成任务或推进 `LOADED`;网络恢复后由员工刷新并重试。提交使用对员工不可见的轻量幂等键和服务端版本:重复提交返回原结果,版本冲突提示刷新。项目不建设离线业务提交队列。 -拣货页面和本地交互以 `../../docs/悦/PDA拣货/pda-picking.html` 为依据。Android App 端已按广播模式接入:Action=`android.intent.action.SCANRESULT`,字符串 Extra=`value`;拣货页的扫码框仅显示广播值和当前接收目标样式,不获取输入焦点或弹出软键盘,页面隐藏或销毁时会注销接收器。当前保留演示任务,同时已有三期拣货、装柜证据和完成操作的远程接口适配;只有会话包含真实 Token、客户标识和入仓单标识时才会请求服务端,不会把缺少契约字段的本地操作模拟为远程成功。具体 PDA 型号、Android 版本、正式鉴权、任务领取、打包发布流程和目标设备真机验收仍待完成。 +拣货页面和本地交互以 `../../docs/悦/PDA拣货/pda-picking.html` 为依据。PDA 采用 uni-app 开发并支持在线更新;Android App 端已按广播模式接入:Action=`android.intent.action.SCANRESULT`,字符串 Extra=`value`;拣货页的扫码框仅显示广播值和当前接收目标样式,不获取输入焦点或弹出软键盘,页面隐藏或销毁时会注销接收器。当前保留演示任务,同时已有三期拣货、装柜证据和完成操作的远程接口适配;只有会话包含真实 Token、客户标识和入仓单标识时才会请求服务端,不会把缺少契约字段的本地操作模拟为远程成功。设备型号不构成开发、发布或验收门禁;正式鉴权和任务领取按服务端契约接入。 扫码输入统一使用 `components/ScanTargetInput.vue` 展示和选择目标,组件本身不注册广播。每个业务页面只在 `onShow` 注册一次广播、在 `onHide`/`onUnload` 注销,并通过页面内的 `activeScanField` 将广播值路由到当前选中的字段。多输入框页面应由用户点击切换蓝色“当前扫码”目标,避免多个输入框或隐藏页面同时消费同一次扫描。 @@ -58,7 +58,7 @@ node --test tests/*.test.mjs - 提交真实批量拣货明细,并覆盖幂等重放、版本冲突刷新和异常挂起。 - 将五类证据从单槽位模型改为多图追加,补错误记录作废及 Admin 人工上传。 - 在目标 Android PDA 上验证状态栏、导航栏、软键盘避让、连续扫描、重复码、低电量和前后台切换。 -- 确认具体设备型号、Android 版本、HBuilderX/Android 打包和正式发布流程。 +- 按既定 uni-app 在线更新安排完成正式发布,并在目标设备验证扫描、网络和前后台行为。 ## 仍待产品确认 From 41e83bf15feb2948504af3b98da083844a729215 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 11 Aug 2026 18:46:04 +0800 Subject: [PATCH 08/98] feat(pda): validate live inventory before batch submit --- api/pda-loading.js | 14 ++++++++++++-- pages/picking/picking.vue | 12 ++++++++---- tests/pda-loading-api.test.mjs | 5 ++++- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/api/pda-loading.js b/api/pda-loading.js index 3a0ab9d..a26cf55 100644 --- a/api/pda-loading.js +++ b/api/pda-loading.js @@ -101,6 +101,15 @@ export function claimContainerPickingTask(taskId, expectedAssignmentVersion, ope }) } +export function getPickingInventorySnapshot(taskId, warehouseId) { + if (!taskId) throw new Error('缺少拣货任务标识,无法查询实时库存') + if (!warehouseId) throw new Error('缺少作业仓库标识,无法查询实时库存') + return request({ + url: `/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`, + method: 'GET' + }) +} + function itemAction(taskId, itemId, path, payload, operationId) { // Concrete routes include /items/${encodeURIComponent(itemId)}/pick, /items/${encodeURIComponent(itemId)}/partial and /items/${encodeURIComponent(itemId)}/skip. if (!taskId || itemId == null) throw new Error('缺少拣货任务或明细标识,无法提交') @@ -124,12 +133,13 @@ export function skipContainerItem(taskId, itemId, payload = {}) { return itemAction(taskId, itemId, 'skip', payload, payload.operationId) } -export async function submitContainerPickingBatch(task, actions, { warehouseId, operationId } = {}) { +export async function submitContainerPickingBatch(task, actions, { warehouseId, operationId, inventorySnapshotToken } = {}) { if (!task?.taskId) throw new Error('缺少拣货任务标识,无法提交') const submitOperationId = operationId || `submit-${task.taskId}-${task.containerNo || ''}-${task.version ?? task.pickingVersion ?? ''}` const expectedVersion = task.version ?? task.pickingVersion if (expectedVersion == null) throw new Error('缺少拣货任务版本,无法整批提交') if (!warehouseId) throw new Error('缺少作业仓库标识,无法整批提交') + if (!inventorySnapshotToken) throw new Error('缺少实时库存快照,请刷新后重试') if (!task.containerNo) throw new Error('缺少柜号,无法整批提交') const items = (actions || []).map((action) => ({ itemId: action.itemId, @@ -145,7 +155,7 @@ export async function submitContainerPickingBatch(task, actions, { warehouseId, url: `/first-leg/pda-loading/tasks/${encodeURIComponent(task.taskId)}/batch`, method: 'POST', header: { 'Idempotency-Key': stableIdempotencyKey(submitOperationId) }, - data: { warehouseId, containerNo: task.containerNo, expectedVersion, items } + data: { warehouseId, containerNo: task.containerNo, expectedVersion, inventorySnapshotToken, items } }) } diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue index 4104dae..dda7d44 100644 --- a/pages/picking/picking.vue +++ b/pages/picking/picking.vue @@ -324,7 +324,7 @@ import { navigateBackOr } from '@/utils/navigation.js' import { startScanBroadcast } from '@/utils/scan-broadcast.js' import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js' - import { claimContainerPickingTask, completeLoading, completePicking, EVIDENCE_SLOTS, getContainerPickingTask, getEvidence, getPickingTask, isRemoteReady, normalizePickingTask, submitContainerPickingBatch, uploadEvidenceFile, upsertEvidence, voidEvidence } from '@/api/pda-loading.js' + import { claimContainerPickingTask, completeLoading, completePicking, EVIDENCE_SLOTS, getContainerPickingTask, getEvidence, getPickingInventorySnapshot, getPickingTask, isRemoteReady, normalizePickingTask, submitContainerPickingBatch, uploadEvidenceFile, upsertEvidence, voidEvidence } from '@/api/pda-loading.js' import { createEvidenceUploadQueue } from '@/utils/evidence-upload-queue.js' import { isOnline } from '@/utils/network.js' import { stableOperationId } from '@/utils/idempotency.js' @@ -909,6 +909,8 @@ if (this.pickingVersion === null) throw new Error('缺少拣货版本号 pickingVersion,无法提交') const submitId = this.submitId || stableOperationId('submit', `${session.currentUser?.id || 'operator'}-${this.remoteTaskId}-${this.currentBox.boxNo}`, { itemStates: this.itemStates, partialQuantities: this.partialQuantities, incompleteReason }) this.submitId = submitId + const warehouseId = this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse + const inventorySnapshot = await getPickingInventorySnapshot(this.remoteTaskId, warehouseId) const actions = this.currentBox.items.map((item) => { const status = this.itemState(item) const itemId = item.taskItemId ?? item.remoteItemId ?? item.id @@ -918,12 +920,14 @@ if (incompleteReason) return { type: 'SKIP', itemId, reason: `${incompleteReason}(提交时确认未拣)`, version: Number(item.version || 0) } return null }).filter(Boolean) - await submitContainerPickingBatch({ taskId: this.remoteTaskId, containerNo: this.currentBox.boxNo, version: Number(this.pickingVersion) }, actions, { warehouseId: this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse, operationId: submitId }) + await submitContainerPickingBatch({ taskId: this.remoteTaskId, containerNo: this.currentBox.boxNo, version: Number(this.pickingVersion) }, actions, { warehouseId, operationId: submitId, inventorySnapshotToken: inventorySnapshot?.snapshotToken }) // Legacy inbound completePicking(expectedVersion: Number(this.pickingVersion)) remains adapter-only; cabinet tasks use item actions. } catch (error) { const versionConflict = error.code === 'A0409' || error.statusCode === 409 - this.remoteError = versionConflict ? '任务版本已变化,请刷新后重试' : (error.network ? '网络失败,拣货完成未提交,请重试' : (error.message || '拣货完成失败')) - this.showToast(versionConflict ? '任务版本已变化,请刷新后重试' : '拣货完成未提交', 'error') + const inventoryConflict = versionConflict && /库存/.test(error.message || '') + const conflictMessage = inventoryConflict ? '库存已变化,请刷新后重试' : '任务版本已变化,请刷新后重试' + this.remoteError = versionConflict ? conflictMessage : (error.network ? '网络失败,拣货完成未提交,请重试' : (error.message || '拣货完成失败')) + this.showToast(versionConflict ? conflictMessage : '拣货完成未提交', 'error') return } finally { this.submitting = false } } diff --git a/tests/pda-loading-api.test.mjs b/tests/pda-loading-api.test.mjs index 214bd05..d669203 100644 --- a/tests/pda-loading-api.test.mjs +++ b/tests/pda-loading-api.test.mjs @@ -59,6 +59,7 @@ test('PDA cabinet picking adapter claims online and submits one atomic batch', ( assert.match(source, /\/first-leg\/pda-loading\/tasks\?/) assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\/claim/) assert.match(source, /tasks\/\$\{encodeURIComponent\(task\.taskId\)\}\/batch/) + assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\/inventory-snapshot\?warehouseId=/) assert.match(source, /submitContainerPickingBatch/) assert.match(source, /stableIdempotencyKey/) assert.match(source, /Math\.imul\(hash, 16777619\)/) @@ -66,7 +67,9 @@ test('PDA cabinet picking adapter claims online and submits one atomic batch', ( assert.match(page, /claimContainerPickingTask\(/) assert.match(page, /submitContainerPickingBatch\(/) assert.match(page, /任务版本已变化,请刷新后重试/) - assert.match(source, /data: \{ warehouseId, containerNo: task\.containerNo, expectedVersion, items \}/) + assert.match(source, /data: \{ warehouseId, containerNo: task\.containerNo, expectedVersion, inventorySnapshotToken, items \}/) + assert.match(page, /await getPickingInventorySnapshot\([\s\S]*await submitContainerPickingBatch/) + assert.match(page, /库存已变化,请刷新后重试/) 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/) From 5f585427d93fbea11d455ad006e9ca85ca247187 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 11 Aug 2026 20:04:13 +0800 Subject: [PATCH 09/98] fix(pda): define loading evidence shadow --- styles/pda.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/styles/pda.scss b/styles/pda.scss index 7d81eb9..5d58aa9 100644 --- a/styles/pda.scss +++ b/styles/pda.scss @@ -1088,7 +1088,7 @@ border: 1px solid #d8e4f4; border-radius: 10px; background: $card; - box-shadow: $shadow; + box-shadow: 0 4px 15px rgba(32, 67, 111, 0.07); } .section-heading-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; } .section-title { display: block; color: $text; font-size: 15px; font-weight: 700; } From 687b4e437782d6e9c255f85e9a2726626b7c5342 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 11 Aug 2026 20:18:09 +0800 Subject: [PATCH 10/98] fix(pda): avoid unsupported URL runtime bootstrap --- config/runtime.js | 4 +--- tests/page-contract.test.mjs | 6 +++++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/config/runtime.js b/config/runtime.js index 817b83b..c1edabb 100644 --- a/config/runtime.js +++ b/config/runtime.js @@ -1,6 +1,4 @@ -const configuredApiRoot = typeof import.meta !== 'undefined' - ? import.meta.env?.VITE_API_BASE_URL - : '' +const configuredApiRoot = import.meta.env.VITE_API_BASE_URL || '' export const API_ROOT = String(configuredApiRoot || '/api').replace(/\/$/, '') export const PLATFORM_API_ROOT = `${API_ROOT}/platform/v1` diff --git a/tests/page-contract.test.mjs b/tests/page-contract.test.mjs index f74c82f..7890cb5 100644 --- a/tests/page-contract.test.mjs +++ b/tests/page-contract.test.mjs @@ -23,7 +23,9 @@ test('application uses the Vue 3 uni-app entry and no unused Android permissions }) test('login uses the real auth endpoint and relaunches to the modular workbench', async () => { - const [login, auth, session] = await Promise.all([read('../pages/login/login.vue'), read('../api/auth.js'), read('../stores/session.js')]) + const [login, auth, session, runtime] = await Promise.all([ + read('../pages/login/login.vue'), read('../api/auth.js'), read('../stores/session.js'), read('../config/runtime.js') + ]) for (const text of ['柏悦物流 · PDA作业端', '工号 / 用户名', '登录中']) assert.match(login, new RegExp(text)) assert.doesNotMatch(login, /DEMO_ACCOUNTS|快捷登录|作业仓库/) assert.match(auth, /\/auth\/login/) @@ -31,6 +33,8 @@ test('login uses the real auth endpoint and relaunches to the modular workbench' assert.match(login, /signInWithTokens\(tokens\)/) assert.match(session, /pda-auth-session/) assert.match(session, /claims\.app_code !== 'PLATFORM'/) + assert.match(runtime, /import\.meta\.env\.VITE_API_BASE_URL/) + assert.doesNotMatch(runtime, /typeof\s+import\.meta/) assert.match(login, /uni\.reLaunch\(\{ url: '\/pages\/home\/home' \}\)/) }) From 64a8fcd130f92e79f6d2efaa6619bb13380e33a9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 11 Aug 2026 20:39:38 +0800 Subject: [PATCH 11/98] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/pda-loading.js | 12 +++---- pages/picking/picking.vue | 75 +++++++++++++++++++++++++-------------- pages/tasks/tasks.vue | 5 +-- stores/picking.js | 3 +- utils/picking-state.js | 15 +++++--- 5 files changed, 68 insertions(+), 42 deletions(-) diff --git a/api/pda-loading.js b/api/pda-loading.js index a26cf55..b9ec7a9 100644 --- a/api/pda-loading.js +++ b/api/pda-loading.js @@ -160,12 +160,11 @@ export async function submitContainerPickingBatch(task, actions, { warehouseId, } export function listLoadingOrders({ page = 1, size = 50, keyword = '' } = {}) { - const query = [`mainStatus=LOADING`, `page=${page}`, `size=${size}`] - if (session.tenantId) query.push(`tenantId=${encodeURIComponent(session.tenantId)}`) + const query = [] if (keyword) query.push(`keyword=${encodeURIComponent(keyword)}`) - return request({ url: `/orders?${query.join('&')}`, method: 'GET' }).then((data) => ({ - ...data, - records: (data && data.records ? data.records : []).map(normalizePdaOrder) + const suffix = query.length ? `?${query.join('&')}` : '' + return request({ url: `/first-leg/pda-loading/tasks/pending${suffix}`, method: 'GET' }).then((data) => ({ + page, size, records: (Array.isArray(data) ? data : data?.records || []).map(normalizePdaOrder) })) } @@ -176,12 +175,11 @@ export function normalizePdaOrder(row = {}) { inboundId: row.inboundId ?? row.inbound?.id ?? null, inboundVersion: row.inboundVersion ?? row.inbound?.version ?? null, tenantId: row.tenantId ?? row.accountId ?? row.inbound?.tenantId ?? null, - tenantId: row.tenantId ?? row.accountId ?? row.inbound?.tenantId ?? null, pickingVersion: row.pickingVersion ?? row.loading?.pickingVersion ?? null, taskId: row.taskId ?? row.pdaTaskId ?? row.loadingTaskId ?? row.loading?.taskId ?? null, containerNo: row.containerNo ?? row.container?.containerNo ?? row.loadingPlan?.containerNo ?? row.loading?.containerNo ?? '', warehouseId: row.warehouseId ?? row.warehouse?.id ?? row.loadingPlan?.warehouseId ?? null, - displayNo: row.orderNo ?? row.inboundCode ?? row.order?.orderNo ?? row.id + displayNo: row.orderNo ?? row.inboundCode ?? row.order?.orderNo ?? `任务 ${row.taskId ?? row.id}` } } diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue index dda7d44..baec523 100644 --- a/pages/picking/picking.vue +++ b/pages/picking/picking.vue @@ -34,7 +34,7 @@ - + 支持硬件广播扫码,也可点击输入后按“完成”确认 @@ -58,11 +58,11 @@ @@ -85,7 +85,7 @@ :class="itemState(item)" > - {{ item.sku }} + {{ item.inboundCode || item.orderNo || '箱级拣货明细' }} 已拣 ✓ @@ -93,7 +93,7 @@ 已跳拣 - 需拣 {{ item.qty }} {{ item.unit }} + 需拣 {{ item.qty }} 箱 @@ -147,7 +147,7 @@ - {{ summary.pending }} 件待拣 + {{ summary.pending }} 箱待拣 {{ submitStatusHint }} @@ -33,6 +33,7 @@ onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadTasks() }, onUnload() { if (this._stopNetwork) this._stopNetwork(); clearTimeout(this._toastTimer) }, methods: { + statusLabel(status) { return status === 'IN_PROGRESS' ? '拣货中' : '待领取' }, async loadTasks() { if (!isOnline()) { this.error = '当前网络不可用,请联网后刷新'; return } this.loading = true; this.error = '' diff --git a/stores/picking.js b/stores/picking.js index 066f271..1b1f65a 100644 --- a/stores/picking.js +++ b/stores/picking.js @@ -11,7 +11,7 @@ function createInitialState() { boxes, currentBoxIndex: 0, itemStates: createItemStates(boxes[0].items), - partialQuantities: {}, boxDrafts: {}, completedBoxes: {}, scanValue: '', scanHistory: [] + partialQuantities: {}, scannedBoxCodes: {}, boxDrafts: {}, completedBoxes: {}, scanValue: '', scanHistory: [] } } @@ -46,6 +46,7 @@ function draftPayload(nextState, identity) { currentBoxIndex: nextState.currentBoxIndex, itemStates: nextState.itemStates, partialQuantities: nextState.partialQuantities, + scannedBoxCodes: nextState.scannedBoxCodes, boxDrafts: nextState.boxDrafts, completedBoxes: nextState.completedBoxes, scanHistory: nextState.scanHistory, diff --git a/utils/picking-state.js b/utils/picking-state.js index f554995..db590cb 100644 --- a/utils/picking-state.js +++ b/utils/picking-state.js @@ -12,7 +12,7 @@ export function createItemStates(items) { }, {}) } -export function getPickSummary(items, states) { +export function getPickSummary(items, states, partialQuantities = {}) { const summary = { total: items.length, picked: 0, @@ -23,7 +23,11 @@ export function getPickSummary(items, states) { items.forEach((item) => { const status = states[item.id] || ITEM_STATUS.PENDING - summary[status] += 1 + const quantity = Math.max(1, Number(item.qty || 1)) + summary.total += quantity - 1 + summary[status] += status === ITEM_STATUS.PARTIAL + ? Math.max(0, Number(partialQuantities[item.id] || 0)) + : quantity }) summary.processed = summary.picked + summary.partial + summary.skipped @@ -56,12 +60,13 @@ export function submitStates(items, states) { } } -export function classifyScan(items, states, scanCode) { - const item = items.find((entry) => entry.sku === scanCode) || null +export function classifyScan(items, states, scanCode, scannedBoxCodes = {}) { + const item = items.find((entry) => (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 } const status = states[item.id] || ITEM_STATUS.PENDING - if (status === ITEM_STATUS.PENDING) return { type: 'pending', item } + if (status !== ITEM_STATUS.PICKED && status !== ITEM_STATUS.SKIPPED) return { type: 'pending', item } return { type: 'processed', item, status } } From 0b51ed434702eb59eca00c7523b31bc801b88115 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 12 Aug 2026 09:26:00 +0800 Subject: [PATCH 12/98] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 3 ++- pages/picking/picking.vue | 21 +++++++++++++++------ tests/page-contract.test.mjs | 12 ++++++++---- tests/pda-loading-api.test.mjs | 3 ++- tests/picking-state.test.mjs | 10 +++++----- 5 files changed, 32 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b47c4f1..5e182d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ ## 作业流程原则 - 扫码是主路径:输入焦点、连续扫描、防抖、重复码、无效码、错箱和扫描反馈必须作为一等场景处理。 +- 拣货扫描身份是后端下发的稳定箱唛箱码 `boxCode`;一次有效且未重复的箱码扫描表示拣出一箱。SKU、商品件数、入库单号和页面展示文案只用于辅助识别,不得作为扫码匹配键或拣货计数单位。 - 进入任务时由服务端标记“拣货中”;扫描结果先写入绑定作业员、任务和柜次的设备持久化草稿,仅在点击提交时批量写后端,禁止逐码提交。 - 允许部分拣货和跳拣,但每个未完成项必须填写异常原因;存在未解决异常时任务挂起,不能完成或推进 `LOADED`。 - 作业提交使用对员工不可见的轻量幂等标识和服务端版本;重复提交返回原结果,版本冲突提示刷新后重试,不静默覆盖。 @@ -46,7 +47,7 @@ | 页面注册与启动 | `pages.json`、`main.js`、`App.vue` | 全局样式和应用生命周期 | | 登录与会话 | `pages/login/`、`stores/session.js` | 远程鉴权适配和会话清理 | | 工作台与模块入口 | `pages/home/`、`pages/module/`、`pages/operation/` | `data/demo-data.js` 与页面禁用规则 | -| 拣货任务与扫码 | `pages/picking/`、`stores/picking.js` | `api/pda-loading.js`、`utils/scan-broadcast.js`、`components/ScanTargetInput.vue` | +| 拣货任务与箱码扫码 | `pages/picking/`、`stores/picking.js` | `api/pda-loading.js`、`utils/scan-broadcast.js`、`components/ScanTargetInput.vue`;服务端契约见 `../../baiyue-firstleg/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml` | | 扫描诊断 | `pages/scanner-test/` | `utils/scan-broadcast.js` 与广播 Action/Extra | | 作业记录与人员 | `pages/history/`、`pages/profile/` | session 与本地敏感数据清理 | diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue index baec523..ecba9d7 100644 --- a/pages/picking/picking.vue +++ b/pages/picking/picking.vue @@ -41,12 +41,12 @@ {{ lastScanResult.type === 'success' ? '✓' : '!' }} 最近扫码 · {{ lastScanResult.title }} - {{ lastScanResult.sku }} + {{ lastScanResult.boxCode }} {{ lastScanResult.detail }} - {{ entry.sku }} · {{ entry.title }} + {{ entry.boxCode }} · {{ entry.title }} @@ -479,6 +479,14 @@ return false }, methods: { + normalizeScanHistory(entries) { + if (!Array.isArray(entries)) return [] + return entries.map((entry) => { + const normalized = { ...entry, boxCode: entry.boxCode ?? entry.sku ?? '' } + delete normalized.sku + return normalized + }) + }, draftIdentity() { const operatorId = session.currentUser?.id || session.currentUser?.userId || session.currentUser?.accountId return { operatorId, taskId: this.remoteTaskId || this.currentBox.taskId || this.currentBox.inboundId, containerNo: this.currentBox.boxNo, warehouseId: this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse } @@ -492,7 +500,7 @@ this.scannedBoxCodes = { ...(restored.scannedBoxCodes || {}) } this.boxDrafts = restored.boxDrafts || {} this.completedBoxes = restored.completedBoxes || {} - this.scanHistory = restored.scanHistory || [] + this.scanHistory = this.normalizeScanHistory(restored.scanHistory) return true }, handleOverlayChange(name, event) { @@ -555,7 +563,7 @@ this.itemStates = { ...draft.itemStates } this.partialQuantities = { ...draft.partialQuantities } this.scannedBoxCodes = { ...(draft.scannedBoxCodes || {}) } - this.scanHistory = draft.scanHistory.map((entry) => ({ ...entry })) + this.scanHistory = this.normalizeScanHistory(draft.scanHistory) } else { this.itemStates = createItemStates(this.currentBox.items) this.partialQuantities = {} @@ -687,6 +695,7 @@ const firstItem = this.currentBox.items[0] 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 = {} this.scannedBoxCodes = {} @@ -812,8 +821,8 @@ this.showToast(item ? `已确认拣出 ${item.qty} 箱` : '已确认拣出', 'success') this.persistSession() }, - recordScanResult(type, title, sku, detail) { - const entry = { id: `${Date.now()}-${this.scanHistory.length}-${sku}`, type, title, sku, detail } + recordScanResult(type, title, boxCode, detail) { + const entry = { id: `${Date.now()}-${this.scanHistory.length}-${boxCode}`, type, title, boxCode, detail } this.scanHistory = [entry, ...this.scanHistory].slice(0, 3) }, undoItem(id) { diff --git a/tests/page-contract.test.mjs b/tests/page-contract.test.mjs index 7890cb5..685d2e9 100644 --- a/tests/page-contract.test.mjs +++ b/tests/page-contract.test.mjs @@ -102,12 +102,16 @@ test('picking page keeps scan, draft and risky transition contracts', async () = assert.match(page, /submitStatusHint\(\)[\s\S]*?提交时需选择原因/) assert.match(page, /onBackPress\(\)/) assert.doesNotMatch(page, /scanFocused|restoreScanFocus|hideKeyboard|@confirm="handleScan"/) - assert.match(page, / { @@ -209,8 +213,8 @@ test('reusable scan target input separates field selection from page-level broad assert.match(component, / { diff --git a/tests/pda-loading-api.test.mjs b/tests/pda-loading-api.test.mjs index d669203..701beae 100644 --- a/tests/pda-loading-api.test.mjs +++ b/tests/pda-loading-api.test.mjs @@ -7,7 +7,7 @@ const page = await readFile(new URL('../pages/picking/picking.vue', import.meta. test('PDA loading adapter keeps C04 read and write paths', () => { for (const path of [ - '/orders?', + '/first-leg/pda-loading/tasks/pending', '/loading/picking?tenantId=', '/loading/picking/start?tenantId=', '/loading/picking/complete?tenantId=', @@ -56,6 +56,7 @@ test('picking page exposes five evidence slots, retry and remote completion stat }) test('PDA cabinet picking adapter claims online and submits one atomic batch', () => { + assert.match(source, /\/first-leg\/pda-loading\/tasks\/pending/) assert.match(source, /\/first-leg\/pda-loading\/tasks\?/) assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\/claim/) assert.match(source, /tasks\/\$\{encodeURIComponent\(task\.taskId\)\}\/batch/) diff --git a/tests/picking-state.test.mjs b/tests/picking-state.test.mjs index 7d376a0..3034aad 100644 --- a/tests/picking-state.test.mjs +++ b/tests/picking-state.test.mjs @@ -69,25 +69,25 @@ test('submitStates turns only pending items into skipped', () => { }) }) -test('classifyScan distinguishes pending, repeated, and unknown SKU codes', () => { - const scanItems = [{ id: 1, sku: 'EL-38201' }, { id: 2, sku: 'EL-38202' }] +test('classifyScan distinguishes pending, repeated, and unknown carton codes', () => { + const scanItems = [{ id: 1, boxCodes: ['BOX-001'] }, { id: 2, boxCodes: ['BOX-002', 'BOX-003'] }] assert.deepEqual(state.classifyScan(scanItems, { 1: state.ITEM_STATUS.PICKED, 2: state.ITEM_STATUS.PENDING, - }, 'EL-38202'), { + }, 'BOX-002'), { type: 'pending', item: scanItems[1], }) assert.deepEqual(state.classifyScan(scanItems, { 1: state.ITEM_STATUS.PARTIAL, 2: state.ITEM_STATUS.PENDING, - }, 'EL-38201'), { + }, 'BOX-001', { 'BOX-001': 1 }), { type: 'processed', item: scanItems[0], status: state.ITEM_STATUS.PARTIAL, }) - assert.deepEqual(state.classifyScan(scanItems, {}, 'NO-SUCH-SKU'), { + assert.deepEqual(state.classifyScan(scanItems, {}, 'NO-SUCH-BOX'), { type: 'unknown', item: null, }) From c8509cb4523d803d4b5e3c718cc9b747e3088a60 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 12 Aug 2026 18:32:31 +0800 Subject: [PATCH 13/98] =?UTF-8?q?=E9=80=82=E9=85=8DPDA=E7=A8=B3=E5=AE=9A?= =?UTF-8?q?=E6=9F=9C=E6=AC=A1=E8=BA=AB=E4=BB=BD=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/pda-loading.js | 67 +++++++++++++++++++++++----------- pages/picking/picking.vue | 24 ++++++++---- pages/tasks/tasks.vue | 14 ++++--- stores/picking.js | 24 +++++------- tests/idempotency.test.mjs | 16 ++++++++ tests/pda-loading-api.test.mjs | 10 ++++- tests/picking-store.test.mjs | 20 +++++++--- utils/task-identity.js | 23 ++++++++++++ 8 files changed, 142 insertions(+), 56 deletions(-) create mode 100644 utils/task-identity.js diff --git a/api/pda-loading.js b/api/pda-loading.js index b9ec7a9..21f9074 100644 --- a/api/pda-loading.js +++ b/api/pda-loading.js @@ -79,15 +79,26 @@ export function isRemoteReady(inboundId) { return Boolean(session.token && (session.tenantId || inboundId)) } -export function getContainerPickingTask(containerNo, warehouseId) { - if (!containerNo) throw new Error('缺少柜次号,无法请求现场任务') - const query = [`containerNo=${encodeURIComponent(containerNo)}`] - if (warehouseId) query.push(`warehouseId=${encodeURIComponent(warehouseId)}`) +export function getContainerPickingTask(identity, warehouseId) { + const selector = typeof identity === 'object' && identity !== null ? identity : { containerNo: identity } + const taskId = selector.taskId ?? selector.id + if (taskId) { + if (!warehouseId) throw new Error('缺少作业仓库标识,无法请求现场任务') + return request({ + url: `/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}?warehouseId=${encodeURIComponent(warehouseId)}`, + method: 'GET' + }).then(normalizePickingTask) + } + 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)}`) return request({ url: `/first-leg/pda-loading/tasks?${query.join('&')}`, method: 'GET' }).then((data) => { - if (Array.isArray(data)) return data[0] || null - if (data?.records) return data.records[0] || null - return data + const task = Array.isArray(data) ? data[0] : (data?.records ? data.records[0] : data) + return task ? normalizePickingTask(task) : null }) } @@ -104,10 +115,10 @@ export function claimContainerPickingTask(taskId, expectedAssignmentVersion, ope export function getPickingInventorySnapshot(taskId, warehouseId) { if (!taskId) throw new Error('缺少拣货任务标识,无法查询实时库存') if (!warehouseId) throw new Error('缺少作业仓库标识,无法查询实时库存') - return request({ - url: `/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`, - method: 'GET' - }) + return request({ + url: `/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`, + method: 'GET' + }).then(normalizeInventorySnapshot) } function itemAction(taskId, itemId, path, payload, operationId) { @@ -134,13 +145,13 @@ export function skipContainerItem(taskId, itemId, payload = {}) { } export async function submitContainerPickingBatch(task, actions, { warehouseId, operationId, inventorySnapshotToken } = {}) { - if (!task?.taskId) throw new Error('缺少拣货任务标识,无法提交') - const submitOperationId = operationId || `submit-${task.taskId}-${task.containerNo || ''}-${task.version ?? task.pickingVersion ?? ''}` + if (!task?.taskId) throw new Error('缺少拣货任务标识,无法提交') + if (!task.loadingPlanId || !task.containerBookingId) throw new Error('缺少稳定柜次标识,无法整批提交') + const submitOperationId = operationId || `submit-${task.taskId}-${task.loadingPlanId}-${task.containerBookingId}-${task.version ?? task.pickingVersion ?? ''}` const expectedVersion = task.version ?? task.pickingVersion if (expectedVersion == null) throw new Error('缺少拣货任务版本,无法整批提交') if (!warehouseId) throw new Error('缺少作业仓库标识,无法整批提交') if (!inventorySnapshotToken) throw new Error('缺少实时库存快照,请刷新后重试') - if (!task.containerNo) throw new Error('缺少柜号,无法整批提交') const items = (actions || []).map((action) => ({ itemId: action.itemId, action: action.type, @@ -155,7 +166,7 @@ export async function submitContainerPickingBatch(task, actions, { warehouseId, url: `/first-leg/pda-loading/tasks/${encodeURIComponent(task.taskId)}/batch`, method: 'POST', header: { 'Idempotency-Key': stableIdempotencyKey(submitOperationId) }, - data: { warehouseId, containerNo: task.containerNo, expectedVersion, inventorySnapshotToken, items } + data: { warehouseId, loadingPlanId: task.loadingPlanId, containerBookingId: task.containerBookingId, expectedVersion, inventorySnapshotToken, items } }) } @@ -175,23 +186,35 @@ export function normalizePdaOrder(row = {}) { inboundId: row.inboundId ?? row.inbound?.id ?? null, inboundVersion: row.inboundVersion ?? row.inbound?.version ?? null, tenantId: row.tenantId ?? row.accountId ?? row.inbound?.tenantId ?? null, - pickingVersion: row.pickingVersion ?? row.loading?.pickingVersion ?? null, - taskId: row.taskId ?? row.pdaTaskId ?? row.loadingTaskId ?? row.loading?.taskId ?? null, - containerNo: row.containerNo ?? row.container?.containerNo ?? row.loadingPlan?.containerNo ?? row.loading?.containerNo ?? '', + pickingVersion: row.pickingVersion ?? row.loading?.pickingVersion ?? null, + taskId: row.taskId ?? row.pdaTaskId ?? row.loadingTaskId ?? row.loading?.taskId ?? null, + loadingPlanId: row.loadingPlanId ?? row.loading?.loadingPlanId ?? row.loadingPlan?.id ?? null, + containerBookingId: row.containerBookingId ?? row.loading?.containerBookingId ?? row.container?.id ?? null, + cabinetNo: row.cabinetNo ?? row.loading?.cabinetNo ?? row.container?.cabinetNo ?? row.containerNo ?? '', + currentContainerNo: row.currentContainerNo ?? row.containerNo ?? row.container?.containerNo ?? row.loadingPlan?.containerNo ?? row.loading?.containerNo ?? null, warehouseId: row.warehouseId ?? row.warehouse?.id ?? row.loadingPlan?.warehouseId ?? null, displayNo: row.orderNo ?? row.inboundCode ?? row.order?.orderNo ?? `任务 ${row.taskId ?? row.id}` } } export function normalizePickingTask(task = {}) { - task = task || {} - return { - ...task, - pickingVersion: task.pickingVersion ?? task.version ?? null, + task = task || {} + return { + ...task, + taskId: task.taskId ?? task.id ?? null, + loadingPlanId: task.loadingPlanId ?? task.loading?.loadingPlanId ?? null, + containerBookingId: task.containerBookingId ?? task.loading?.containerBookingId ?? null, + cabinetNo: task.cabinetNo ?? task.loading?.cabinetNo ?? task.containerNo ?? '', + currentContainerNo: task.currentContainerNo ?? task.containerNo ?? null, + pickingVersion: task.pickingVersion ?? task.version ?? null, inboundVersion: task.inboundVersion ?? null } } +export function normalizeInventorySnapshot(snapshot = {}) { + return normalizePickingTask({ ...snapshot, version: snapshot.taskVersion ?? snapshot.version }) +} + export function getPickingTask(inboundId, tenantId) { return request({ url: `/inbounds/${encodeURIComponent(inboundId)}/loading/picking?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`, diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue index ecba9d7..ca65818 100644 --- a/pages/picking/picking.vue +++ b/pages/picking/picking.vue @@ -14,7 +14,7 @@ @@ -328,6 +328,7 @@ import { createEvidenceUploadQueue } from '@/utils/evidence-upload-queue.js' import { isOnline } from '@/utils/network.js' import { stableOperationId } from '@/utils/idempotency.js' + import { stableTaskOperationIdentity } from '@/utils/task-identity.js' import ScanTargetInput from '@/components/ScanTargetInput.vue' import PopupHeader from '@/components/PopupHeader.vue' @@ -452,9 +453,10 @@ }, onLoad(options = {}) { if (!requireSession()) return - if (options.containerNo) { + if (options.taskId && (options.cabinetNo || options.containerNo)) { this.boxes = [{ - boxNo: String(options.containerNo), orderNo: String(options.orderNo || options.inboundCode || ''), + boxNo: String(options.cabinetNo || options.containerNo), cabinetNo: String(options.cabinetNo || options.containerNo), currentContainerNo: options.currentContainerNo ? String(options.currentContainerNo) : null, orderNo: String(options.orderNo || options.inboundCode || ''), + taskId: String(options.taskId), loadingPlanId: options.loadingPlanId ? String(options.loadingPlanId) : null, containerBookingId: options.containerBookingId ? String(options.containerBookingId) : null, inboundId: options.inboundId ? String(options.inboundId) : null, inboundVersion: options.inboundVersion == null ? null : Number(options.inboundVersion), warehouseId: options.warehouseId ? String(options.warehouseId) : null, @@ -489,7 +491,7 @@ }, draftIdentity() { const operatorId = session.currentUser?.id || session.currentUser?.userId || session.currentUser?.accountId - return { operatorId, taskId: this.remoteTaskId || this.currentBox.taskId || this.currentBox.inboundId, containerNo: this.currentBox.boxNo, warehouseId: this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse } + return { operatorId, taskId: this.remoteTaskId || this.currentBox.taskId, loadingPlanId: this.currentBox.loadingPlanId || this.remotePick?.loadingPlanId, containerBookingId: this.currentBox.containerBookingId || this.remotePick?.containerBookingId, warehouseId: this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse } }, restoreRemoteDraft() { const restored = restorePickingSession(this.draftIdentity()) @@ -672,7 +674,7 @@ this.remoteError = '' try { const warehouseId = this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse - const task = await getContainerPickingTask(this.currentBox.boxNo, warehouseId) + const task = await getContainerPickingTask({ taskId: this.remoteTaskId || this.currentBox.taskId, cabinetNo: this.currentBox.cabinetNo, loadingPlanId: this.currentBox.loadingPlanId, containerNo: this.currentBox.currentContainerNo }, warehouseId) if (!task) throw new Error('未找到当前柜次的拣货任务') const remoteItems = task.items || task.pickItems || task.details || task.lines if (Array.isArray(remoteItems) && remoteItems.length) { @@ -703,7 +705,12 @@ this.remoteTaskId = task.taskId ?? task.id if (!this.remoteTaskId) throw new Error('服务端任务缺少 taskId,无法开始拣货') this.assignmentVersion = task.assignmentVersion ?? task.version ?? 0 - const operationId = `claim-${session.currentUser?.id || 'operator'}-${this.remoteTaskId}-${this.currentBox.boxNo}` + this.currentBox.loadingPlanId = task.loadingPlanId + this.currentBox.containerBookingId = task.containerBookingId + this.currentBox.cabinetNo = task.cabinetNo + this.currentBox.boxNo = task.cabinetNo + this.currentBox.currentContainerNo = task.currentContainerNo + const operationId = `claim-${session.currentUser?.id || 'operator'}-${this.remoteTaskId}-${task.loadingPlanId}-${task.containerBookingId}` const claimed = await claimContainerPickingTask(this.remoteTaskId, this.assignmentVersion, operationId) this.remotePick = normalizePickingTask({ ...task, ...(claimed || {}) }) this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? null @@ -937,7 +944,8 @@ try { if (!this.remoteTaskId) throw new Error('尚未进入服务端拣货任务,请联网刷新后重试') if (this.pickingVersion === null) throw new Error('缺少拣货版本号 pickingVersion,无法提交') - const submitId = this.submitId || stableOperationId('submit', `${session.currentUser?.id || 'operator'}-${this.remoteTaskId}-${this.currentBox.boxNo}`, { itemStates: this.itemStates, partialQuantities: this.partialQuantities, incompleteReason }) + const operationIdentity = stableTaskOperationIdentity(this.draftIdentity()) + const submitId = this.submitId || stableOperationId('submit', operationIdentity, { itemStates: this.itemStates, partialQuantities: this.partialQuantities, incompleteReason }) this.submitId = submitId const warehouseId = this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse const inventorySnapshot = await getPickingInventorySnapshot(this.remoteTaskId, warehouseId) @@ -950,7 +958,7 @@ if (incompleteReason) return { type: 'SKIP', itemId, reason: `${incompleteReason}(提交时确认未拣)`, version: Number(item.version || 0) } return null }).filter(Boolean) - await submitContainerPickingBatch({ taskId: this.remoteTaskId, containerNo: this.currentBox.boxNo, version: Number(this.pickingVersion) }, actions, { warehouseId, operationId: submitId, inventorySnapshotToken: inventorySnapshot?.snapshotToken }) + await submitContainerPickingBatch({ taskId: this.remoteTaskId, loadingPlanId: this.currentBox.loadingPlanId, containerBookingId: this.currentBox.containerBookingId, version: Number(this.pickingVersion) }, actions, { warehouseId, operationId: submitId, inventorySnapshotToken: inventorySnapshot?.snapshotToken }) // Legacy inbound completePicking(expectedVersion: Number(this.pickingVersion)) remains adapter-only; cabinet tasks use item actions. } catch (error) { const versionConflict = error.code === 'A0409' || error.statusCode === 409 diff --git a/pages/tasks/tasks.vue b/pages/tasks/tasks.vue index 9a1dd64..4fccc47 100644 --- a/pages/tasks/tasks.vue +++ b/pages/tasks/tasks.vue @@ -9,11 +9,12 @@ {{ error }} 正在加载真实任务... 暂无待处理拣货任务 - - {{ task.containerNo || '待分配柜次' }}{{ statusLabel(task.workflowStatus) }} + + {{ task.cabinetNo || '待分配柜次' }}{{ statusLabel(task.workflowStatus) }} + 当前真实柜号 {{ task.currentContainerNo }} {{ task.displayNo || '未命名订单' }} {{ task.warehouseName || (task.warehouseId ? `仓库 ${task.warehouseId}` : '缺少仓库信息') }} · 待处理 {{ task.pendingItemCount ?? task.itemCount ?? 0 }} 项 - + {{ toast.message }} @@ -34,19 +35,20 @@ onUnload() { if (this._stopNetwork) this._stopNetwork(); clearTimeout(this._toastTimer) }, methods: { statusLabel(status) { return status === 'IN_PROGRESS' ? '拣货中' : '待领取' }, + isTaskReady(task) { return Boolean(task.taskId && task.loadingPlanId && task.containerBookingId && task.cabinetNo && task.warehouseId && task.taskStatus === 'LOADING' && ['UNASSIGNED', 'IN_PROGRESS'].includes(task.workflowStatus)) }, async loadTasks() { if (!isOnline()) { this.error = '当前网络不可用,请联网后刷新'; return } this.loading = true; this.error = '' try { const result = await listLoadingOrders({ page: 1, size: 50 }) - this.tasks = (result.records || []).filter((task) => task.containerNo || task.taskId || task.inboundId) + this.tasks = (result.records || []).filter((task) => task.taskId) } catch (error) { this.error = error.network ? '当前网络不可用,请联网后刷新' : (error.message || '任务加载失败') } finally { this.loading = false } }, enterTask(task) { - if (!task.containerNo || !task.warehouseId) { this.showToast('任务缺少柜号或仓库标识,无法开始', 'error'); return } - uni.navigateTo({ url: `/pages/picking/picking?containerNo=${encodeURIComponent(task.containerNo)}&warehouseId=${encodeURIComponent(task.warehouseId)}&inboundId=${encodeURIComponent(task.inboundId || '')}&inboundVersion=${encodeURIComponent(task.inboundVersion ?? '')}&tenantId=${encodeURIComponent(task.tenantId || '')}&taskId=${encodeURIComponent(task.taskId || '')}&orderNo=${encodeURIComponent(task.displayNo || '')}` }) + if (!this.isTaskReady(task)) { this.showToast('任务尚未满足后端进入条件', 'error'); return } + uni.navigateTo({ url: `/pages/picking/picking?cabinetNo=${encodeURIComponent(task.cabinetNo)}¤tContainerNo=${encodeURIComponent(task.currentContainerNo || '')}&loadingPlanId=${encodeURIComponent(task.loadingPlanId)}&containerBookingId=${encodeURIComponent(task.containerBookingId)}&warehouseId=${encodeURIComponent(task.warehouseId)}&taskId=${encodeURIComponent(task.taskId)}&orderNo=${encodeURIComponent(task.displayNo || '')}` }) }, showToast(message, type) { clearTimeout(this._toastTimer); this.toast = { visible: true, message, type }; this._toastTimer = setTimeout(() => { this.toast.visible = false }, 2200) } } diff --git a/stores/picking.js b/stores/picking.js index 1b1f65a..2373c30 100644 --- a/stores/picking.js +++ b/stores/picking.js @@ -1,5 +1,6 @@ import { createBoxes } from '@/data/demo-data.js' import { createItemStates } from '@/utils/picking-state.js' +import { stableTaskIdentity, stableTaskIdentityKey } from '@/utils/task-identity.js' function clone(value) { return JSON.parse(JSON.stringify(value)) @@ -23,25 +24,18 @@ function storageAvailable() { return typeof uni !== 'undefined' && typeof uni.getStorageSync === 'function' && typeof uni.setStorageSync === 'function' } -function identityValue(identity = {}) { - const operatorId = identity.operatorId ?? identity.accountId ?? identity.userId - const taskId = identity.taskId ?? identity.inboundId - const containerNo = identity.containerNo ?? identity.boxNo - return { operatorId: operatorId == null ? '' : String(operatorId), taskId: taskId == null ? '' : String(taskId), containerNo: containerNo == null ? '' : String(containerNo) } -} - export function getPickingDraftKey(identity = {}) { - const value = identityValue(identity) - if (!value.operatorId || !value.taskId || !value.containerNo) return '' - return `${STORAGE_PREFIX}:${encodeURIComponent(value.operatorId)}:${encodeURIComponent(value.taskId)}:${encodeURIComponent(value.containerNo)}` + const identityKey = stableTaskIdentityKey(identity) + return identityKey ? `${STORAGE_PREFIX}:${identityKey}` : '' } function draftPayload(nextState, identity) { - const value = identityValue(identity) + const value = stableTaskIdentity(identity) return { operatorId: value.operatorId, taskId: value.taskId, - containerNo: value.containerNo, + loadingPlanId: value.loadingPlanId, + containerBookingId: value.containerBookingId, warehouseId: identity.warehouseId ?? null, currentBoxIndex: nextState.currentBoxIndex, itemStates: nextState.itemStates, @@ -68,11 +62,13 @@ export function restorePickingSession(identity = {}) { const key = getPickingDraftKey(identity) if (!key || !storageAvailable()) return null const saved = uni.getStorageSync(key) - if (!saved || saved.operatorId !== identityValue(identity).operatorId || saved.taskId !== identityValue(identity).taskId || saved.containerNo !== identityValue(identity).containerNo) return null + const expected = stableTaskIdentity(identity) + if (!saved || saved.operatorId !== expected.operatorId || saved.taskId !== expected.taskId || saved.loadingPlanId !== expected.loadingPlanId || saved.containerBookingId !== expected.containerBookingId) return null const restored = { ...state, ...clone(saved) } delete restored.operatorId delete restored.taskId - delete restored.containerNo + delete restored.loadingPlanId + delete restored.containerBookingId delete restored.warehouseId delete restored.updatedAt state = restored diff --git a/tests/idempotency.test.mjs b/tests/idempotency.test.mjs index 36890a3..873f6b4 100644 --- a/tests/idempotency.test.mjs +++ b/tests/idempotency.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test' import assert from 'node:assert/strict' const { stableOperationId } = await import(new URL('../utils/idempotency.js', import.meta.url).href) +const { stableTaskIdentityKey, stableTaskOperationIdentity } = await import(new URL('../utils/task-identity.js', import.meta.url).href) test('PDA operation id is stable ASCII and within the backend 128 character limit', () => { const payload = { itemStates: Object.fromEntries(Array.from({ length: 500 }, (_, index) => [`明细-${index}`, 'PICKED'])), reason: '中文异常原因'.repeat(100) } @@ -17,3 +18,18 @@ test('PDA operation id changes when the draft changes', () => { const second = stableOperationId('submit', 'operator-task-container', { item: 'SKIPPED' }) assert.notEqual(first, second) }) + +test('real container number changes do not move the draft or idempotency identity', () => { + const before = { operatorId: 7, taskId: 11, loadingPlanId: 13, containerBookingId: 17, currentContainerNo: 'REAL-001' } + const after = { ...before, currentContainerNo: 'REAL-002' } + const payload = { item: 'PICKED' } + assert.equal(stableTaskIdentityKey(before), stableTaskIdentityKey(after)) + assert.equal(stableOperationId('submit', stableTaskOperationIdentity(before), payload), stableOperationId('submit', stableTaskOperationIdentity(after), payload)) +}) + +test('container booking mismatch is rejected by stable task binding', () => { + const original = { operatorId: 7, taskId: 11, loadingPlanId: 13, containerBookingId: 17 } + const differentBooking = { ...original, containerBookingId: 18 } + assert.equal(stableTaskIdentityKey(original), stableTaskIdentityKey(differentBooking)) + assert.notEqual(stableTaskOperationIdentity(original), stableTaskOperationIdentity(differentBooking)) +}) diff --git a/tests/pda-loading-api.test.mjs b/tests/pda-loading-api.test.mjs index 701beae..cc45cc6 100644 --- a/tests/pda-loading-api.test.mjs +++ b/tests/pda-loading-api.test.mjs @@ -68,7 +68,15 @@ test('PDA cabinet picking adapter claims online and submits one atomic batch', ( assert.match(page, /claimContainerPickingTask\(/) assert.match(page, /submitContainerPickingBatch\(/) assert.match(page, /任务版本已变化,请刷新后重试/) - assert.match(source, /data: \{ warehouseId, containerNo: task\.containerNo, expectedVersion, inventorySnapshotToken, items \}/) + assert.match(source, /data: \{ warehouseId, loadingPlanId: task\.loadingPlanId, containerBookingId: task\.containerBookingId, expectedVersion, inventorySnapshotToken, items \}/) + assert.match(source, /cabinetNo=/) + assert.match(source, /loadingPlanId=/) + assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\?warehouseId=/) + assert.match(source, /currentContainerNo/) + assert.match(source, /getPickingInventorySnapshot[\s\S]*?\.then\(normalizeInventorySnapshot\)/) + assert.doesNotMatch(source.match(/export async function submitContainerPickingBatch[\s\S]*?\n\}/)?.[0] || '', /containerNo/) + assert.doesNotMatch(page, /claim-[^`]*currentBox\.boxNo/) + assert.doesNotMatch(page.match(/stableOperationId\('submit'[\s\S]*?\n/)?.[0] || '', /boxNo|containerNo|currentContainerNo/) assert.match(page, /await getPickingInventorySnapshot\([\s\S]*await submitContainerPickingBatch/) assert.match(page, /库存已变化,请刷新后重试/) assert.doesNotMatch(source.match(/export async function submitContainerPickingBatch[\s\S]*?\n\}/)?.[0] || '', /for \(/) diff --git a/tests/picking-store.test.mjs b/tests/picking-store.test.mjs index 9b01f53..2155633 100644 --- a/tests/picking-store.test.mjs +++ b/tests/picking-store.test.mjs @@ -3,19 +3,29 @@ import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' const source = await readFile(new URL('../stores/picking.js', import.meta.url), 'utf8') +const taskIdentitySource = await readFile(new URL('../utils/task-identity.js', import.meta.url), 'utf8') -test('picking drafts are persisted under operator, task and cabinet identity', () => { +test('picking drafts are persisted under stable operator, task and loading plan identity', () => { assert.match(source, /pda-picking-draft/) assert.match(source, /operatorId/) assert.match(source, /taskId/) - assert.match(source, /containerNo/) + assert.match(source, /loadingPlanId/) + assert.match(source, /containerBookingId/) assert.match(source, /uni\.setStorageSync\(key/) assert.match(source, /uni\.getStorageSync\(key/) assert.match(source, /uni\.removeStorageSync\(key/) }) test('draft restore rejects a different operator or task', () => { - assert.match(source, /saved\.operatorId !== identityValue\(identity\)\.operatorId/) - assert.match(source, /saved\.taskId !== identityValue\(identity\)\.taskId/) - assert.match(source, /saved\.containerNo !== identityValue\(identity\)\.containerNo/) + assert.match(source, /saved\.operatorId !== expected\.operatorId/) + assert.match(source, /saved\.taskId !== expected\.taskId/) + assert.match(source, /saved\.loadingPlanId !== expected\.loadingPlanId/) + assert.match(source, /saved\.containerBookingId !== expected\.containerBookingId/) + assert.doesNotMatch(taskIdentitySource, /containerNo|currentContainerNo|boxNo/) +}) + +test('mutable real container number is not part of draft identity or restore validation', () => { + const restoreGuard = source.match(/const expected = stableTaskIdentity[\s\S]*?return null/)?.[0] || '' + assert.doesNotMatch(taskIdentitySource, /containerNo|currentContainerNo|boxNo/) + assert.doesNotMatch(restoreGuard, /containerNo|currentContainerNo|boxNo/) }) diff --git a/utils/task-identity.js b/utils/task-identity.js new file mode 100644 index 0000000..3fa73c3 --- /dev/null +++ b/utils/task-identity.js @@ -0,0 +1,23 @@ +function text(value) { + return value == null ? '' : String(value) +} + +export function stableTaskIdentity(identity = {}) { + return { + operatorId: text(identity.operatorId ?? identity.accountId ?? identity.userId), + taskId: text(identity.taskId ?? identity.inboundId), + loadingPlanId: text(identity.loadingPlanId), + containerBookingId: text(identity.containerBookingId) + } +} + +export function stableTaskIdentityKey(identity = {}) { + const value = stableTaskIdentity(identity) + if (!value.operatorId || !value.taskId || !value.loadingPlanId) return '' + return [value.operatorId, value.taskId, value.loadingPlanId].map(encodeURIComponent).join(':') +} + +export function stableTaskOperationIdentity(identity = {}) { + const value = stableTaskIdentity(identity) + return `${value.operatorId}-${value.taskId}-${value.loadingPlanId}-${value.containerBookingId}` +} From a969187f86d78b3a24c732c4d80457903a84b6a2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 12 Aug 2026 19:42:29 +0800 Subject: [PATCH 14/98] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5e182d2..f34fa23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,14 +51,12 @@ | 扫描诊断 | `pages/scanner-test/` | `utils/scan-broadcast.js` 与广播 Action/Extra | | 作业记录与人员 | `pages/history/`、`pages/profile/` | session 与本地敏感数据清理 | -## 分期范围索引 +## 全量验收索引 -按阶段处理 PDA 任务时,先核对阶段文档,再从 `pages.json` 定位页面,沿页面 → `stores/` → `utils/` → 设备适配层检查;本表只描述 PDA 作业端,不代表后台或 Client 已具备对应能力。 +当前按 PDA 全量作业链路验收。阶段文档仍用于核对功能来源和验收证据,不再限制本次验收范围;从 `pages.json` 定位页面,沿页面 → `stores/` → `utils/` → 设备适配层检查。本表只描述 PDA 作业端,不代表后台或 Client 已具备对应能力。 -| 阶段 | PDA 范围边界 | 代码入口 | 开发首读 | +| 验收域 | PDA 范围与代码入口 | 验收首读 | |---|---|---|---| -| 一期:头程订单基础闭环 | PDA 仅保留静态登录、工作台和出库拣货原型;是否与一期头程订单/仓库任务同步上线尚未确认,不得将本地状态当作服务端闭环。 | `pages/login/`、`pages/home/`、`pages/picking/`、`stores/session.js`、`stores/picking.js`;页面注册见 `pages.json`。 | `../../docs/一期/README.md`、`../../docs/一期/一期头程订单闭环设计.md`、`README.md`;确认设备、同步范围、鉴权和服务端任务契约后再接 API。 | -| 二期:到仓与计费 | 不在二期实现 PDA 到仓测量、称重、托盘/库位或费用确认;这些能力由 Admin/Client/Backend 完成,PDA 继续维持静态原型。 | 现有 `pages/picking/` 不扩展二期业务;扫码适配保持在 `utils/scan-broadcast.js` 与 `components/ScanTargetInput.vue`。 | `../../docs/二期/README.md`、`../../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`;确认二期终点 `WAIT_SPLIT + APPROVED + CONFIRMED`,不得提前接入分柜。 | -| 三期:国内出仓 | PDA 承接分柜后拣货和装柜证据。在线作业、进入即开始、持久化草稿、批量提交、异常阻断、轻量幂等/版本、五类多图和单柜次单作业员均已确认;`LOADED` 由服务端统一门禁推进。PDA 采用 uni-app 开发并支持在线更新。 | 复用 `pages/picking/`、`stores/picking.js`、`utils/scan-broadcast.js` 和远程 API 适配;当前内存草稿、单类型照片替换等实现不得作为最终契约。 | `../../docs/三期/README.md`、`../../docs/三期/三期开工门禁.md`、`../../docs/三期/三期开发规划.md`、`../../docs/三期/三期需求确认表.md`、`README.md`;按已确认子规则继续开发,扫描适配保持厂商无关。 | +| 作业全链路 | 登录、工作台、扫码拣货、装柜证据、草稿持久化、批量提交、异常和在线更新 | `pages/login/`、`pages/home/`、`pages/picking/`、`stores/session.js`、`stores/picking.js`、`utils/scan-broadcast.js`、`pages.json` | 按业务选择 `../../docs/一期/`、`../../docs/二期/`、`../../docs/三期/` 和 `README.md` 的契约与验收规则 | -阶段边界:PDA 不承担客户工作台或后台配置;一期仅验证原型与同步范围,二期不提前引入 PDA/分柜/装柜。三期不支持多人协作、任务接管或换班释放;发布采用既定 uni-app 在线更新安排。 +作业边界:PDA 不承担客户工作台或后台配置;服务端状态、权限、幂等和 `LOADED` 门禁是验收事实来源。发布采用既定 uni-app 在线更新安排。 From a5eca66bfd88a9ce19e58c881f04ff9acc985882 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 12 Aug 2026 20:06:35 +0800 Subject: [PATCH 15/98] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index f34fa23..fe18234 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,3 +60,9 @@ | 作业全链路 | 登录、工作台、扫码拣货、装柜证据、草稿持久化、批量提交、异常和在线更新 | `pages/login/`、`pages/home/`、`pages/picking/`、`stores/session.js`、`stores/picking.js`、`utils/scan-broadcast.js`、`pages.json` | 按业务选择 `../../docs/一期/`、`../../docs/二期/`、`../../docs/三期/` 和 `README.md` 的契约与验收规则 | 作业边界:PDA 不承担客户工作台或后台配置;服务端状态、权限、幂等和 `LOADED` 门禁是验收事实来源。发布采用既定 uni-app 在线更新安排。 + +## 全量验收脚本 + +- PDA 本地自动回归在本目录执行 `node --test tests/*.test.mjs`,覆盖页面契约、导航、在线会话、拣货状态、幂等、证据上传队列、扫码广播和声音反馈。 +- 自动化通过后仍需按真实 uni-app 页面完成样式、扫码输入、弱网/重试、草稿恢复、批量提交和窄屏验收;在线更新与真机验收按既定安排执行。 +- 测试环境 API 或真实数据写入仍需当前对话明确授权;不要把本地 store 测试通过视为服务端作业链路已通过。 From 455e81fbbd62020273cf916531ab7bb37156adf3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 14 Aug 2026 20:58:38 +0800 Subject: [PATCH 16/98] fix(pda): isolate loading evidence by cabinet context --- pages/picking/picking.vue | 174 ++++++++++++++++++++------ tests/pda-loading-api.test.mjs | 7 +- tests/picking-evidence-stage.test.mjs | 77 ++++++++++++ 3 files changed, 222 insertions(+), 36 deletions(-) create mode 100644 tests/picking-evidence-stage.test.mjs diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue index ca65818..dbd1bcf 100644 --- a/pages/picking/picking.vue +++ b/pages/picking/picking.vue @@ -117,7 +117,7 @@ - + 装柜证据空仓、半仓、满仓、锁仓及带锁码照 {{ remoteSyncState.label }} @@ -141,8 +141,11 @@ {{ remoteError }} - - 等待统一订单详情提供装柜版本号后可提交完成 + + + 状态尚未同步,请刷新重试 + + @@ -332,6 +335,16 @@ import ScanTargetInput from '@/components/ScanTargetInput.vue' import PopupHeader from '@/components/PopupHeader.vue' + const VERSION_SYNC_MESSAGE = '状态尚未同步,请刷新重试' + + function isPickingCompleteTask(task) { + const workflowStatus = task?.workflowStatus + const taskStatus = task?.taskStatus ?? task?.status + return workflowStatus === 'AWAITING_REVIEW' + || workflowStatus === 'COMPLETED' + || taskStatus === 'COMPLETED' + } + export default { components: { PopupHeader, ScanTargetInput }, data() { @@ -354,8 +367,9 @@ selectedIncompleteReason: '', completionSummary: { picked: 0, partial: 0, skipped: 0, autoSkipped: 0 }, evidenceSlots: EVIDENCE_SLOTS, - evidenceFiles: {}, evidenceQueueItems: [], evidenceQueue: null, sealNo: '', loadingVersion: null, pickingVersion: null, remotePick: null, + evidenceFiles: {}, evidenceQueueItems: [], evidenceQueue: null, evidenceContextId: 0, sealNo: '', loadingVersion: null, pickingVersion: null, remotePick: null, remoteTaskId: null, assignmentVersion: null, submitId: '', + taskRequestId: 0, remoteError: '', remoteLoading: false, submitting: false, toast: { visible: false, message: '', type: '' } } @@ -388,6 +402,10 @@ pendingBoxCount() { return this.boxes.length - this.completedCount }, + pickingCompleted() { + return Boolean(this.completedBoxes[this.currentBoxIndex]) + || isPickingCompleteTask(this.remotePick) + }, partialTargetItem() { return this.currentBox.items.find((item) => item.id === this.partialTargetId) || null }, @@ -446,10 +464,7 @@ } }, created() { - this.evidenceQueue = createEvidenceUploadQueue({ - upload: (item, onProgress) => uploadEvidenceFile(item.inboundId, item.evidenceType, item.localPath, item.tenantId, onProgress), - onChange: (items) => { this.evidenceQueueItems = items } - }) + this.resetEvidenceState() }, onLoad(options = {}) { if (!requireSession()) return @@ -481,6 +496,26 @@ return false }, methods: { + isEvidenceContextActive(contextId, inboundId) { + return contextId === this.evidenceContextId + && String(this.currentBox.inboundId ?? '') === String(inboundId ?? '') + }, + requireEvidenceStage() { + if (this.pickingCompleted) return true + this.showToast('请先提交拣货完成', 'warning') + return false + }, + resetEvidenceState() { + const contextId = this.evidenceContextId + 1 + this.evidenceContextId = contextId + this.evidenceFiles = {} + this.evidenceQueueItems = [] + this.sealNo = '' + this.evidenceQueue = createEvidenceUploadQueue({ + upload: (item, onProgress) => uploadEvidenceFile(item.inboundId, item.evidenceType, item.localPath, item.tenantId, onProgress), + onChange: (items) => { if (this.evidenceContextId === contextId) this.evidenceQueueItems = items } + }) + }, normalizeScanHistory(entries) { if (!Array.isArray(entries)) return [] return entries.map((entry) => { @@ -598,11 +633,15 @@ }, selectBox(index) { this.saveCurrentBoxDraft() + this.taskRequestId += 1 + this.submitting = false this.currentBoxIndex = index this.remoteTaskId = null this.remotePick = null this.pickingVersion = null + this.loadingVersion = null this.assignmentVersion = null + this.resetEvidenceState() this.boxDropdownOpen = false this.loadBoxState(index) this.scrollToTop() @@ -670,11 +709,13 @@ }, async syncRemoteTask() { if (!isRemoteReady(this.currentBox.inboundId || this.currentBox.boxNo)) return + const requestId = ++this.taskRequestId this.remoteLoading = true this.remoteError = '' try { const warehouseId = this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse - const task = await getContainerPickingTask({ taskId: this.remoteTaskId || this.currentBox.taskId, cabinetNo: this.currentBox.cabinetNo, loadingPlanId: this.currentBox.loadingPlanId, containerNo: this.currentBox.currentContainerNo }, warehouseId) + const task = await getContainerPickingTask({ taskId: this.remoteTaskId || this.currentBox.taskId, cabinetNo: this.currentBox.cabinetNo, loadingPlanId: this.currentBox.loadingPlanId }, warehouseId) + if (requestId !== this.taskRequestId) return if (!task) throw new Error('未找到当前柜次的拣货任务') const remoteItems = task.items || task.pickItems || task.details || task.lines if (Array.isArray(remoteItems) && remoteItems.length) { @@ -710,22 +751,38 @@ this.currentBox.cabinetNo = task.cabinetNo this.currentBox.boxNo = task.cabinetNo this.currentBox.currentContainerNo = task.currentContainerNo - const operationId = `claim-${session.currentUser?.id || 'operator'}-${this.remoteTaskId}-${task.loadingPlanId}-${task.containerBookingId}` - const claimed = await claimContainerPickingTask(this.remoteTaskId, this.assignmentVersion, operationId) + const remoteTask = normalizePickingTask(task) + let claimed = null + if (!isPickingCompleteTask(remoteTask)) { + const operationId = `claim-${session.currentUser?.id || 'operator'}-${this.remoteTaskId}-${task.loadingPlanId}-${task.containerBookingId}` + claimed = await claimContainerPickingTask(this.remoteTaskId, this.assignmentVersion, operationId) + if (requestId !== this.taskRequestId) return + } this.remotePick = normalizePickingTask({ ...task, ...(claimed || {}) }) this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? null this.loadingVersion = this.currentBox.inboundVersion ?? this.remotePick.inboundVersion ?? null this.assignmentVersion = this.remotePick.assignmentVersion ?? this.assignmentVersion this.restoreRemoteDraft() - if (this.currentBox.inboundId) { - const evidence = await getEvidence(this.currentBox.inboundId, this.currentBox.tenantId) - this.evidenceFiles = (evidence && evidence.evidences ? evidence.evidences : []).reduce((all, item) => { (all[item.evidenceType] ||= []).push(item); return all }, {}) - const seal = this.evidenceEntry('SEAL') - if (seal) this.sealNo = seal.sealNo || '' - } + this.resetEvidenceState() + if (this.pickingCompleted) await this.loadLoadingEvidence() } catch (error) { + if (requestId !== this.taskRequestId) return this.remoteError = error.network ? '网络不可用,无法开始拣货,恢复网络后请重试' : (error.message || '任务同步失败') - } finally { this.remoteLoading = false } + } finally { if (requestId === this.taskRequestId) this.remoteLoading = false } + }, + async loadLoadingEvidence() { + if (!this.pickingCompleted || !this.currentBox.inboundId || !isRemoteReady(this.currentBox.inboundId)) return + const contextId = this.evidenceContextId + try { + const evidence = await getEvidence(this.currentBox.inboundId, this.currentBox.tenantId) + if (contextId !== this.evidenceContextId) return + this.evidenceFiles = (evidence && evidence.evidences ? evidence.evidences : []).reduce((all, item) => { (all[item.evidenceType] ||= []).push(item); return all }, {}) + const seal = this.evidenceEntry('SEAL') + this.sealNo = seal?.sealNo || '' + } catch (error) { + if (contextId !== this.evidenceContextId) return + this.remoteError = error.network ? '网络失败,装柜证据未加载,请刷新重试' : (error.message || '装柜证据加载失败') + } }, async refreshTask() { if (!this.currentBox.boxNo) return @@ -739,73 +796,118 @@ return this.evidenceEntry(type) ? '继续拍照' : '拍照' }, chooseEvidence(slot) { + if (!this.requireEvidenceStage()) return if (!isOnline()) { this.showToast('当前网络不可用,请联网后再拍照上传', 'warning'); return } if (typeof uni.chooseImage !== 'function') { this.showToast('当前环境不支持选择照片', 'warning'); return } + const contextId = this.evidenceContextId + const inboundId = this.currentBox.inboundId uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['camera'], success: (result) => { + if (!this.isEvidenceContextActive(contextId, inboundId)) return const path = result.tempFilePaths && result.tempFilePaths[0] if (!path) return const next = { evidenceType: slot.type, fileName: path.split('/').pop() || path, localPath: path, uploadStatus: 'PENDING' } this.evidenceFiles = { ...this.evidenceFiles, [slot.type]: [...(this.evidenceFiles[slot.type] || []), next] } this.queueEvidenceUpload(slot.type, path) - }, fail: () => this.showToast('未选择照片,可稍后重试', 'warning') }) + }, fail: () => { + if (!this.isEvidenceContextActive(contextId, inboundId)) return + this.showToast('未选择照片,可稍后重试', 'warning') + } }) }, async queueEvidenceUpload(type, path) { + if (!this.requireEvidenceStage()) return if (!this.currentBox.inboundId || !isRemoteReady(this.currentBox.inboundId)) { this.showToast('当前会话未连接服务端,证据保留本地', 'warning'); return } - const entry = this.evidenceQueue.add({ inboundId: this.currentBox.inboundId, tenantId: this.currentBox.tenantId, evidenceType: type, localPath: path }) - await this.evidenceQueue.flush() - const latest = this.evidenceQueue.snapshot().find((item) => item.id === entry.id) + const contextId = this.evidenceContextId + const queue = this.evidenceQueue + const entry = queue.add({ inboundId: this.currentBox.inboundId, tenantId: this.currentBox.tenantId, evidenceType: type, localPath: path }) + await queue.flush() + if (contextId !== this.evidenceContextId || queue !== this.evidenceQueue) return + const latest = queue.snapshot().find((item) => item.id === entry.id) if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return } const current = this.evidenceEntry(type) || {} this.evidenceFiles = { ...this.evidenceFiles, [type]: [...(this.evidenceFiles[type] || []).slice(0, -1), { ...current, fileId: latest.result?.fileId, fileName: latest.result?.fileName || current.fileName, uploadStatus: 'SUCCEEDED' }] } await this.saveEvidence(type) }, async retryEvidence(type) { + if (!this.requireEvidenceStage()) return const item = this.evidenceQueueEntry(type) - if (!item || !this.evidenceQueue.retry(item.id)) return + const contextId = this.evidenceContextId + const queue = this.evidenceQueue + if (!item || !queue.retry(item.id)) return this.remoteError = '' - await this.evidenceQueue.flush() - const latest = this.evidenceQueue.latest(type) + await queue.flush() + if (contextId !== this.evidenceContextId || queue !== this.evidenceQueue) return + const latest = queue.latest(type) if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return } const current = this.evidenceEntry(type) || {} this.evidenceFiles = { ...this.evidenceFiles, [type]: [...(this.evidenceFiles[type] || []).slice(0, -1), { ...current, fileId: latest.result?.fileId, fileName: latest.result?.fileName || current.fileName, uploadStatus: 'SUCCEEDED' }] } await this.saveEvidence(type) }, async saveEvidence(type, operationId = null) { + if (!this.requireEvidenceStage()) return if (!this.currentBox.inboundId) { this.showToast('演示任务已记录本地证据', 'success'); return } if (!isRemoteReady(this.currentBox.inboundId)) { this.showToast('当前会话未连接服务端,证据保留本地', 'warning'); return } + const contextId = this.evidenceContextId + const inboundId = this.currentBox.inboundId + const tenantId = this.currentBox.tenantId const current = this.evidenceEntry(type) || {} if (!current.fileId) { this.showToast('照片尚未上传完成', 'warning'); return } const payload = { expectedVersion: current.version || 0, evidenceType: type, fileId: current.fileId || null, fileName: current.fileName || null, sealNo: type === 'SEAL' ? this.sealNo : null, remark: null, operationId: operationId || `evidence-${this.remoteTaskId || this.currentBox.inboundId}-${type}-${current.fileId || 'seal'}` } try { - const result = await upsertEvidence(this.currentBox.inboundId, payload, this.currentBox.tenantId) + const result = await upsertEvidence(inboundId, payload, tenantId) + if (!this.isEvidenceContextActive(contextId, inboundId)) return this.evidenceFiles = (result && result.evidences ? result.evidences : []).reduce((all, item) => { const list = (all[item.evidenceType] || []).filter((entry) => entry.id || entry.fileId !== item.fileId); const index = list.findIndex((entry) => entry.id === item.id); all[item.evidenceType] = index >= 0 ? list.map((entry, i) => i === index ? item : entry) : [...list, item]; return all }, this.evidenceFiles) this.showToast(`${type === 'SEAL' ? '封签' : '照片'}已保存`, 'success') - } catch (error) { this.remoteError = error.network ? '网络失败,证据未提交,请重试' : (error.message || '证据保存失败'); this.showToast('证据保存失败', 'error') } + } catch (error) { + if (!this.isEvidenceContextActive(contextId, inboundId)) return + this.remoteError = error.network ? '网络失败,证据未提交,请重试' : (error.message || '证据保存失败') + this.showToast('证据保存失败', 'error') + } }, async saveSealNo() { + if (!this.requireEvidenceStage()) return if (!String(this.sealNo || '').trim()) { this.showToast('请输入封签号', 'warning'); return } await this.saveEvidence('SEAL', `seal-no-${this.remoteTaskId || this.currentBox.inboundId}-${Date.now()}`) }, async voidEvidencePhoto(photo) { + if (!this.requireEvidenceStage()) return if (!photo?.id || !this.currentBox.inboundId) return + const contextId = this.evidenceContextId + const inboundId = this.currentBox.inboundId + const tenantId = this.currentBox.tenantId const reason = 'PDA现场标记错图' try { - await voidEvidence(this.currentBox.inboundId, photo.id, { expectedVersion: photo.version || 0, reason, operationId: `void-${this.currentBox.inboundId}-${photo.id}-${photo.version || 0}` }, this.currentBox.tenantId) + await voidEvidence(inboundId, photo.id, { expectedVersion: photo.version || 0, reason, operationId: `void-${inboundId}-${photo.id}-${photo.version || 0}` }, tenantId) + if (!this.isEvidenceContextActive(contextId, inboundId)) return this.evidenceFiles = { ...this.evidenceFiles, [photo.evidenceType]: (this.evidenceFiles[photo.evidenceType] || []).map((item) => item.id === photo.id ? { ...item, status: 'VOID', voidReason: reason } : item) } this.showToast('照片已作废,可重新拍摄', 'success') } catch (error) { + if (!this.isEvidenceContextActive(contextId, inboundId)) return this.remoteError = error.network ? '网络失败,照片未作废,请重试' : (error.message || '照片作废失败') this.showToast('照片作废失败', 'error') } }, async completeLoadingTask() { - if (!isRemoteReady(this.currentBox.inboundId) || this.loadingVersion === null) return - if (!this.completedBoxes[this.currentBoxIndex]) { this.showToast('请先提交拣货完成', 'warning'); return } + if (!this.requireEvidenceStage()) return + if (!isRemoteReady(this.currentBox.inboundId)) { this.showToast('装柜任务尚未同步,请刷新后重试', 'warning'); return } + if (this.loadingVersion === null) { this.showToast(VERSION_SYNC_MESSAGE, 'warning'); return } if (!this.evidenceComplete) { this.showToast('五类有效照片和封签号均为必填', 'warning'); return } + const contextId = this.evidenceContextId + const inboundId = this.currentBox.inboundId + const tenantId = this.currentBox.tenantId + const loadingVersion = this.loadingVersion this.submitting = true - try { await completeLoading(this.currentBox.inboundId, { expectedVersion: this.loadingVersion, remark: 'PDA确认装柜完成' }, this.currentBox.tenantId); this.showToast('已确认装柜完成', 'success') } - catch (error) { this.remoteError = error.network ? '网络失败,装柜完成未提交' : (error.message || '装柜完成失败'); this.showToast('装柜完成失败', 'error') } - finally { this.submitting = false } + try { + await completeLoading(inboundId, { expectedVersion: loadingVersion, remark: 'PDA确认装柜完成' }, tenantId) + if (!this.isEvidenceContextActive(contextId, inboundId)) return + this.showToast('已确认装柜完成', 'success') + } catch (error) { + if (!this.isEvidenceContextActive(contextId, inboundId)) return + this.remoteError = error.network ? '网络失败,装柜完成未提交' : (error.message || '装柜完成失败') + this.showToast('装柜完成失败', 'error') + } + finally { + if (this.isEvidenceContextActive(contextId, inboundId)) this.submitting = false + } }, pickScannedBox(item, boxCode) { const pickedCount = Number(this.partialQuantities[item.id] || 0) + 1 @@ -943,7 +1045,7 @@ this.submitting = true try { if (!this.remoteTaskId) throw new Error('尚未进入服务端拣货任务,请联网刷新后重试') - if (this.pickingVersion === null) throw new Error('缺少拣货版本号 pickingVersion,无法提交') + if (this.pickingVersion === null) throw new Error(VERSION_SYNC_MESSAGE) const operationIdentity = stableTaskOperationIdentity(this.draftIdentity()) const submitId = this.submitId || stableOperationId('submit', operationIdentity, { itemStates: this.itemStates, partialQuantities: this.partialQuantities, incompleteReason }) this.submitId = submitId @@ -958,7 +1060,8 @@ if (incompleteReason) return { type: 'SKIP', itemId, reason: `${incompleteReason}(提交时确认未拣)`, version: Number(item.version || 0) } return null }).filter(Boolean) - await submitContainerPickingBatch({ taskId: this.remoteTaskId, loadingPlanId: this.currentBox.loadingPlanId, containerBookingId: this.currentBox.containerBookingId, version: Number(this.pickingVersion) }, actions, { warehouseId, operationId: submitId, inventorySnapshotToken: inventorySnapshot?.snapshotToken }) + 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 }) + 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) { const versionConflict = error.code === 'A0409' || error.statusCode === 409 @@ -979,6 +1082,7 @@ item.finalPartialQuantity = this.partialQuantities[item.id] || 0 }) this.completedBoxes = { ...this.completedBoxes, [this.currentBoxIndex]: true } + this.loadLoadingEvidence() this.saveCurrentBoxDraft() if (remoteRequired) clearPickingDraft(this.draftIdentity()) this.submitId = '' diff --git a/tests/pda-loading-api.test.mjs b/tests/pda-loading-api.test.mjs index cc45cc6..3267b91 100644 --- a/tests/pda-loading-api.test.mjs +++ b/tests/pda-loading-api.test.mjs @@ -36,7 +36,7 @@ test('C02 identifiers and separate picking/inbound versions are normalized expli assert.match(source, /pickingVersion: task\.pickingVersion \?\? task\.version/) assert.match(page, /expectedVersion: Number\(this\.pickingVersion\)/) assert.match(page, /this\.loadingVersion = this\.currentBox\.inboundVersion/) - assert.match(page, /缺少拣货版本号 pickingVersion/) + assert.match(page, /状态尚未同步,请刷新重试/) }) test('picking page exposes five evidence slots, retry and remote completion states', () => { @@ -71,6 +71,8 @@ test('PDA cabinet picking adapter claims online and submits one atomic batch', ( assert.match(source, /data: \{ warehouseId, loadingPlanId: task\.loadingPlanId, containerBookingId: task\.containerBookingId, expectedVersion, inventorySnapshotToken, items \}/) assert.match(source, /cabinetNo=/) assert.match(source, /loadingPlanId=/) + assert.doesNotMatch(source, /query\.push\(`containerNo=/) + assert.doesNotMatch(page, /getContainerPickingTask\([^\n]*containerNo:/) assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\?warehouseId=/) assert.match(source, /currentContainerNo/) assert.match(source, /getPickingInventorySnapshot[\s\S]*?\.then\(normalizeInventorySnapshot\)/) @@ -82,6 +84,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, /if \(!isPickingCompleteTask\(remoteTask\)\)[\s\S]*claimContainerPickingTask/) + assert.match(page, /taskRequestId/) + assert.match(page, /resetEvidenceState/) assert.match(page, /availablePickQuantity/) assert.match(page, /库存\/库位可用量不足/) }) diff --git a/tests/picking-evidence-stage.test.mjs b/tests/picking-evidence-stage.test.mjs new file mode 100644 index 0000000..aed7e88 --- /dev/null +++ b/tests/picking-evidence-stage.test.mjs @@ -0,0 +1,77 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' + +const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8') + +test('loading evidence is mounted only after the current picking task is complete', () => { + assert.match(page, /v-if="currentBox\.inboundId && pickingCompleted" class="loading-evidence-section"/) + assert.match(page, /isPickingCompleteTask\(task\)[\s\S]*?AWAITING_REVIEW[\s\S]*?COMPLETED/) + assert.match(page, /pickingCompleted\(\)[\s\S]*?completedBoxes\[this\.currentBoxIndex\][\s\S]*?isPickingCompleteTask\(this\.remotePick\)/) + assert.doesNotMatch(page, /class="loading-evidence-section" v-if="currentBox\.inboundId"/) +}) + +test('loading evidence is not requested before picking completion', () => { + assert.match(page, /if \(this\.pickingCompleted\) await this\.loadLoadingEvidence\(\)/) + const loader = page.match(/async loadLoadingEvidence\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' + assert.match(loader, /if \(!this\.pickingCompleted/) + assert.match(loader, /await getEvidence\(/) + assert.match(page, /this\.completedBoxes = \{[\s\S]*?this\.loadLoadingEvidence\(\)/) +}) + +test('operators receive actionable refresh guidance without internal version wording', () => { + assert.match(page, /状态尚未同步,请刷新重试/) + assert.doesNotMatch(page, /缺少拣货版本号|提供装柜版本号/) + const complete = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' + assert.match(complete, /if \(!this\.requireEvidenceStage\(\)\) return/) +}) + +test('evidence mutations defensively reject calls before picking completion', () => { + assert.match(page, /requireEvidenceStage\(\)[\s\S]*?if \(this\.pickingCompleted\) return true[\s\S]*?请先提交拣货完成/) + for (const method of ['chooseEvidence', 'queueEvidenceUpload', 'retryEvidence', 'saveEvidence', 'saveSealNo', 'voidEvidencePhoto', 'completeLoadingTask']) { + const body = page.match(new RegExp(`(?:async )?${method}\\([^)]*\\) \\{[\\s\\S]*?\\n\\t\\t\\t\\},`))?.[0] || '' + assert.match(body, /if \(!this\.requireEvidenceStage\(\)\) return/, `${method} should guard the evidence stage`) + } +}) + +test('switching cabinets invalidates old sync and evidence upload state', () => { + const selector = page.match(/selectBox\(index\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' + assert.match(selector, /this\.taskRequestId \+= 1/) + assert.match(selector, /this\.pickingVersion = null/) + assert.match(selector, /this\.loadingVersion = null/) + assert.match(selector, /this\.submitting = false/) + assert.match(selector, /this\.resetEvidenceState\(\)/) + assert.match(page, /contextId !== this\.evidenceContextId \|\| queue !== this\.evidenceQueue/) + const loader = page.match(/async loadLoadingEvidence\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' + assert.equal((loader.match(/contextId !== this\.evidenceContextId/g) || []).length, 2) +}) + +test('camera callbacks cannot append evidence after switching cabinets', () => { + const chooser = page.match(/chooseEvidence\(slot\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' + assert.match(chooser, /const contextId = this\.evidenceContextId/) + assert.match(chooser, /const inboundId = this\.currentBox\.inboundId/) + assert.equal((chooser.match(/isEvidenceContextActive\(contextId, inboundId\)/g) || []).length, 2) + assert.match(chooser, /isEvidenceContextActive\(contextId, inboundId\)[\s\S]*?this\.evidenceFiles =/) +}) + +test('evidence save, void and loading completion ignore stale cabinet responses', () => { + for (const [method, awaitedCall] of [ + ['saveEvidence', 'upsertEvidence'], + ['voidEvidencePhoto', 'voidEvidence'], + ['completeLoadingTask', 'completeLoading'] + ]) { + const body = page.match(new RegExp(`async ${method}\\([^)]*\\) \\{[\\s\\S]*?\\n\\t\\t\\t\\},`))?.[0] || '' + assert.match(body, /const contextId = this\.evidenceContextId/, `${method} should capture the evidence context`) + assert.match(body, /const inboundId = this\.currentBox\.inboundId/, `${method} should capture stable inbound identity`) + assert.match(body, new RegExp(`await ${awaitedCall}\\([\\s\\S]*?if \\(!this\\.isEvidenceContextActive\\(contextId, inboundId\\)\\) return`), `${method} should guard the success writeback`) + assert.ok((body.match(/isEvidenceContextActive\(contextId, inboundId\)/g) || []).length >= 2, `${method} should guard success and error writebacks`) + } + const complete = page.match(/async completeLoadingTask\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' + assert.match(complete, /finally \{[\s\S]*?isEvidenceContextActive\(contextId, inboundId\)[\s\S]*?this\.submitting = false/) +}) + +test('completed server tasks restore evidence stage without being claimed again', () => { + assert.match(page, /const remoteTask = normalizePickingTask\(task\)/) + assert.match(page, /if \(!isPickingCompleteTask\(remoteTask\)\) \{[\s\S]*?claimContainerPickingTask/) + assert.match(page, /if \(this\.pickingCompleted\) await this\.loadLoadingEvidence\(\)/) +}) From 1de45b7263be17fd6327b67adb41c59d3096c236 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 15 Aug 2026 09:47:28 +0800 Subject: [PATCH 17/98] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 6 ++-- api/pda-loading.js | 15 +++++++-- pages/login/login.vue | 2 +- pages/picking/picking.vue | 61 ++++++++++++++++++++++++++++------ tests/pda-loading-api.test.mjs | 11 +++++- 5 files changed, 77 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fe18234..5385d3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 页面完成扫码输入、网络阻断/恢复、草稿恢复、批量提交、窄屏和在线更新验收;真机检查属于最后的交互验收。 diff --git a/api/pda-loading.js b/api/pda-loading.js index 21f9074..534890b 100644 --- a/api/pda-loading.js +++ b/api/pda-loading.js @@ -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)}`) diff --git a/pages/login/login.vue b/pages/login/login.vue index 7d5b5c6..adb5acf 100644 --- a/pages/login/login.vue +++ b/pages/login/login.vue @@ -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() { diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue index dbd1bcf..b959307 100644 --- a/pages/picking/picking.vue +++ b/pages/picking/picking.vue @@ -153,7 +153,11 @@ {{ summary.pending }} 箱待拣 {{ submitStatusHint }} - + + @@ -320,7 +324,7 @@ diff --git a/styles/pda.scss b/styles/pda.scss index be05608..d809741 100644 --- a/styles/pda.scss +++ b/styles/pda.scss @@ -259,6 +259,10 @@ padding: 1px 0 104px; } + .work-content.evidence-active { + padding-bottom: 1px; + } + .box-select-wrap { position: relative; z-index: 5; @@ -1148,19 +1152,27 @@ .sync-state.ready { color: $success; background: #e8f8ef; } .sync-state.error { color: $danger; background: #fff0f0; } .sync-state.loading { color: #8a5a00; background: #fff7df; } - .evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 12px; } - .evidence-card { min-height: 86px; padding: 10px; border: 1px dashed #b8c9df; border-radius: 8px; background: #f8fafc; } - .evidence-card.filled { border-style: solid; border-color: #69b88a; background: #f0fbf4; } + .evidence-grid { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; } + .evidence-card { padding: 10px; border: 1px solid #d7e1ed; border-radius: 9px; background: #f8fafc; } + .evidence-card.filled { border-color: #a7d6ba; background: #f4fbf6; } + .evidence-card-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; } + .evidence-card-title { display: flex; min-width: 0; align-items: center; gap: 7px; } .evidence-label { display: block; color: $text; font-size: 12px; font-weight: 700; } - .evidence-file-row { display: flex; align-items: center; gap: 7px; min-width: 0; margin-top: 7px; } - .evidence-thumbnail { flex: 0 0 48px; width: 48px; height: 48px; border-radius: 6px; background: #e7edf5; } - .evidence-file-detail { flex: 1; min-width: 0; } - .evidence-file { display: block; overflow: hidden; margin-top: 5px; color: $text-secondary; font-size: 10px; line-height: 15px; text-overflow: ellipsis; white-space: nowrap; } + .evidence-count { padding: 2px 6px; border-radius: 99px; color: $text-secondary; font-size: 10px; line-height: 16px; background: #e9eef5; } + .evidence-count.complete { color: #087443; background: #dff4e7; } + .evidence-photo-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 9px; } + .evidence-photo-item { position: relative; min-width: 0; padding: 4px; border: 1px solid #dbe5ef; border-radius: 8px; background: #ffffff; } + .evidence-thumbnail { display: flex; width: 100%; height: 68px; align-items: center; justify-content: center; border-radius: 6px; color: $text-secondary; font-size: 9px; line-height: 13px; text-align: center; background: #e7edf5; } + .evidence-thumbnail-placeholder { box-sizing: border-box; padding: 6px; } + .evidence-file { display: block; overflow: hidden; margin-top: 4px; padding: 0 2px; color: $text-secondary; font-size: 9px; line-height: 14px; text-overflow: ellipsis; white-space: nowrap; } + .evidence-void-btn { position: absolute; top: 8px; right: 8px; min-width: 40px; height: 40px; padding: 0 7px; border: 0; border-radius: 20px; color: #ffffff; font-size: 10px; line-height: 40px; background: rgba(185, 28, 28, 0.9); } + .evidence-empty-hint { display: block; margin-top: 7px; color: $text-secondary; font-size: 10px; line-height: 15px; } .evidence-upload-state { display: block; margin-top: 5px; color: $text-secondary; font-size: 10px; line-height: 15px; } .evidence-upload-state.uploading, .evidence-upload-state.pending { color: #174d91; } .evidence-upload-state.failed { color: $danger; } .evidence-upload-state.succeeded { color: $success; } .evidence-btn { min-height: 40px; margin-top: 8px; padding: 0 9px; border: 1px solid #9db7d9; border-radius: 7px; color: #174d91; font-size: 12px; line-height: 40px; background: #ffffff; } + .evidence-add-btn { flex-shrink: 0; min-width: 78px; min-height: 40px; margin-top: 0; line-height: 40px; } .evidence-retry-btn { min-height: 40px; margin-top: 6px; padding: 0 9px; border: 1px solid $danger; border-radius: 7px; color: $danger; font-size: 12px; line-height: 40px; background: #fff5f4; } .seal-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; } .seal-input { flex: 1; height: 42px; box-sizing: border-box; padding: 0 10px; border: 1px solid $border; border-radius: 7px; font-size: 12px; background: #f8fafc; } @@ -1206,6 +1218,7 @@ .pick-item-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); } .pick-btn-confirm { grid-column: 1 / -1; } .processed-actions { grid-template-columns: 1fr; } + .evidence-photo-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .submit-section { gap: 8px; width: calc(100% - 12px); padding-right: 8px; padding-left: 8px; } .submit-info { width: 96px; min-width: 96px; } } diff --git a/tests/page-contract.test.mjs b/tests/page-contract.test.mjs index 158195e..a9d956e 100644 --- a/tests/page-contract.test.mjs +++ b/tests/page-contract.test.mjs @@ -48,8 +48,9 @@ test('picking task list uses remote loading orders and never falls back to demo assert.match(tasks, /\['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW'\]\.includes\(task\.workflowStatus\)/) assert.match(tasks, /task\.workflowStatus === 'AWAITING_REVIEW' \|\| \(task\.assignmentVersion !== null/) assert.match(tasks, /暂无可领取的拣货任务/) - assert.match(tasks, /集装箱号 \{\{ task\.currentContainerNo \}\}/) + assert.match(tasks, /class="task-label">集装箱号<\/text>\{\{ task\.currentContainerNo \}\}/) assert.match(tasks, /柜次号 \{\{ task\.cabinetNo \}\}/) + assert.match(tasks, /button v-for="task in tasks"[\s\S]*?class="task-card"[\s\S]*?@tap="enterTask\(task\)"/) assert.match(tasks, /records\.filter\(\(task\) => this\.isTaskReady\(task\)\)/) assert.match(tasks, /isTaskReady\(task\)[\s\S]*?task\.recordStatus === 'ACTIVE'[\s\S]*?!task\.mergedIntoContainerBookingId/) assert.match(tasks, /records\.filter\(\(task\) => this\.isMergedTask\(task\)\)\.forEach\(\(task\) => this\.clearMergedTaskDraft\(task\)\)/) diff --git a/tests/picking-evidence-stage.test.mjs b/tests/picking-evidence-stage.test.mjs index 008a35c..49e5ae9 100644 --- a/tests/picking-evidence-stage.test.mjs +++ b/tests/picking-evidence-stage.test.mjs @@ -146,6 +146,9 @@ test('refresh preserves local photos for the same evidence identity and flushes test('evidence photos render thumbnails and support full-screen preview', () => { assert.match(page, /class="evidence-thumbnail" :src="evidencePreviewSource\(photo\)"/) + assert.match(page, /class="work-content" :class="\{ 'evidence-active': activeStage === 'evidence' && loadingStageReady \}"/) + assert.match(page, /class="evidence-photo-grid"/) + assert.match(page, /class="evidence-count"/) assert.match(page, /@tap="previewEvidencePhoto\(photo, slot\.type\)"/) assert.match(page, /evidencePreviewSource\(photo\)[\s\S]*?localPath[\s\S]*?fileUrl/) assert.match(page, /previewEvidencePhoto\(photo, type\)[\s\S]*?uni\.previewImage\([\s\S]*?current[\s\S]*?urls/) From b2a62c4b70de3d4667a9b2a51dd5bd50a9d9b26b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 17 Aug 2026 19:52:01 +0800 Subject: [PATCH 32/98] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pages/picking/picking.vue | 91 +++++++++++++++++++++++++-- pages/tasks/tasks.vue | 5 +- styles/pda.scss | 5 +- tests/page-contract.test.mjs | 17 ++++- tests/picking-evidence-stage.test.mjs | 14 ++++- 5 files changed, 121 insertions(+), 11 deletions(-) diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue index de6e860..9ff9360 100644 --- a/pages/picking/picking.vue +++ b/pages/picking/picking.vue @@ -150,6 +150,9 @@ {{ evidenceUploadState(slot.type) }} + + {{ slot.type === 'SEAL' ? '照片已上传,请输入封签号并保存' : '照片已上传,等待保存' }} + 本地暂存,待自动上传 @@ -353,7 +356,7 @@ import { navigateBackOr } from '@/utils/navigation.js' import { startScanBroadcast } from '@/utils/scan-broadcast.js' import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js' - import { claimContainerPickingTask, completeContainerPickingTask, completePicking, downloadEvidencePreview, EVIDENCE_SLOTS, getContainerPickingTask, getEvidence, getEvidencePreviewUrl, getPickingInventorySnapshot, getPickingTask, isRemoteReady, normalizePickingTask, submitContainerPickingBatch, uploadEvidenceFile, upsertEvidence, voidEvidence } from '@/api/pda-loading.js' + import { claimContainerPickingTask, completeContainerPickingTask, completePicking, downloadEvidencePreview, EVIDENCE_SLOTS, getContainerPickingTask, getEvidence, getEvidencePreviewUrl, getPickingInventorySnapshot, getPickingTask, isRemoteReady, listLoadingOrders, normalizePickingTask, submitContainerPickingBatch, uploadEvidenceFile, upsertEvidence, voidEvidence } from '@/api/pda-loading.js' import { createEvidenceUploadQueue } from '@/utils/evidence-upload-queue.js' import { isOnline } from '@/utils/network.js' import { stableOperationId } from '@/utils/idempotency.js' @@ -528,7 +531,7 @@ this.remoteTaskId = options.taskId ? String(options.taskId) : null } }, - onShow() { + async onShow() { if (!requireSession()) return this.startHardwareScanner() if (this.cameraPicking) { @@ -540,7 +543,8 @@ this.debugEvidence('preview.resume', { evidenceIdentity: this.evidenceIdentity }) return } - this.syncRemoteTask() + await this.refreshAvailableBoxes() + await this.syncRemoteTask() }, onHide() { if (this.evidencePreviewOpening) { @@ -558,6 +562,67 @@ return false }, methods: { + isSelectableTask(task) { + return Boolean(task?.recordStatus === 'ACTIVE' + && !task?.mergedIntoContainerBookingId + && task?.taskId + && task?.loadingPlanId + && task?.containerBookingId + && task?.currentContainerNo + && task?.warehouseId + && task?.version !== null + && task?.version !== undefined + && ['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW'].includes(task?.workflowStatus)) + }, + boxFromTask(task, existing = null) { + return { + boxNo: String(task.currentContainerNo || existing?.boxNo || task.cabinetNo || '同步中'), + cabinetNo: task.cabinetNo ? String(task.cabinetNo) : (existing?.cabinetNo || null), + currentContainerNo: task.currentContainerNo ? String(task.currentContainerNo) : (existing?.currentContainerNo || null), + orderNo: String(task.displayNo || task.orderNo || existing?.orderNo || ''), + taskId: String(task.taskId), + loadingPlanId: task.loadingPlanId || existing?.loadingPlanId || null, + containerBookingId: task.containerBookingId || existing?.containerBookingId || null, + inboundId: existing?.inboundId || task.inboundId || null, + inboundVersion: existing?.inboundVersion ?? task.inboundVersion ?? null, + warehouseId: task.warehouseId || existing?.warehouseId || null, + tenantId: existing?.tenantId || task.tenantId || null, + items: existing?.items || [] + } + }, + remapBoxState(previousBoxes, nextBoxes, state) { + const byTaskId = new Map(previousBoxes.map((box, index) => [String(box.taskId || ''), state[index]])) + return Object.fromEntries(nextBoxes.map((box, index) => [index, byTaskId.get(String(box.taskId || ''))]).filter(([, value]) => value !== undefined)) + }, + async refreshAvailableBoxes() { + if (!session.token) return + try { + const result = await listLoadingOrders({ page: 1, size: 50 }) + const records = (result.records || []).filter((task) => this.isSelectableTask(task)) + const previousBoxes = [...this.boxes] + const selectedTaskId = String(this.remoteTaskId || this.currentBox.taskId || '') + const existingByTaskId = new Map(previousBoxes.map((box) => [String(box.taskId || ''), box])) + const recordByTaskId = new Map(records.map((task) => [String(task.taskId), task])) + const orderedTaskIds = previousBoxes.map((box) => String(box.taskId || '')).filter((taskId) => recordByTaskId.has(taskId)) + records.forEach((task) => { const taskId = String(task.taskId); if (!orderedTaskIds.includes(taskId)) orderedTaskIds.push(taskId) }) + if (selectedTaskId && !orderedTaskIds.includes(selectedTaskId) && existingByTaskId.has(selectedTaskId)) orderedTaskIds.unshift(selectedTaskId) + const nextBoxes = orderedTaskIds.map((taskId) => { + const existing = existingByTaskId.get(taskId) || null + const task = recordByTaskId.get(taskId) + return task ? this.boxFromTask(task, existing) : existing + }).filter(Boolean) + if (!nextBoxes.length) return + this.boxDrafts = this.remapBoxState(previousBoxes, nextBoxes, this.boxDrafts) + this.completedBoxes = this.remapBoxState(previousBoxes, nextBoxes, this.completedBoxes) + this.boxes = nextBoxes + const selectedIndex = this.boxes.findIndex((box) => String(box.taskId || '') === selectedTaskId) + this.currentBoxIndex = selectedIndex >= 0 ? selectedIndex : 0 + this.debugSubmit('tasks.available', { selectedTaskId, taskIds: this.boxes.map((box) => box.taskId), count: this.boxes.length }) + } catch (error) { + this.debugSubmit('tasks.available.error', { message: error.message || '', code: error.code || '', requestIdFromServer: error.requestId || '', traceId: error.traceId || '' }) + this.showToast(error.network ? '任务列表同步失败,请检查网络后重试' : '任务列表同步失败,当前任务仍可继续', 'warning') + } + }, selectStage(stage) { if (stage === 'evidence' && !this.loadingStageReady) { this.showToast('请先完成新增待拣明细', 'warning') @@ -953,6 +1018,7 @@ validEvidenceEntries(type) { return this.evidenceEntries(type).filter((item) => item.id && item.fileId && item.uploadStatus !== 'PENDING') }, evidenceEntry(type) { const list = this.evidenceEntries(type); return list.length ? list[list.length - 1] : null }, hasPendingLocalEvidence(type) { return this.evidenceEntries(type).some((item) => item.localPath && !item.fileId) }, + hasUnboundUploadedEvidence(type) { return this.evidenceEntries(type).some((item) => item.fileId && !item.id) }, evidenceQueueEntry(type) { return [...this.evidenceQueueItems].reverse().find((item) => item.evidenceType === type) || null }, @@ -962,7 +1028,10 @@ if (item.status === 'PENDING') return '本地待上传' if (item.status === 'UPLOADING') return `上传中 ${item.progress || 0}%` if (item.status === 'FAILED') return item.error || '上传失败' - if (item.status === 'SUCCEEDED') return this.validEvidenceEntries(type).length ? '已上传并保存' : (item.attempts > 1 ? '重试成功' : '已上传') + if (item.status === 'SUCCEEDED') { + if (type === 'SEAL' && this.hasUnboundUploadedEvidence(type)) return '照片已上传,请输入封签号并保存' + return this.validEvidenceEntries(type).length ? '已上传并保存' : (item.attempts > 1 ? '重试成功' : '已上传') + } if (item.status === 'REPLACED') return '已替换' return '' }, @@ -1203,6 +1272,11 @@ const entries = this.evidenceFiles[type] || [] const current = entries.find((item) => item.localPath === path) || {} this.evidenceFiles = { ...this.evidenceFiles, [type]: entries.map((item) => item.localPath === path ? { ...item, fileId: latest.result?.fileId, fileName: latest.result?.fileName || item.fileName, uploadStatus: 'SUCCEEDED' } : item) } + if (type === 'SEAL') { + this.rememberEvidenceDraft(identity) + this.showToast('带锁码照已上传,请输入封签号并点击保存封签', 'success') + return + } await this.saveEvidence(type, null, path) }, async flushPendingEvidence() { @@ -1210,7 +1284,8 @@ const pending = this.evidenceSlots.flatMap((slot) => this.evidenceEntries(slot.type).filter((entry) => !entry.id && entry.localPath).map((entry) => ({ type: slot.type, path: entry.localPath, fileId: entry.fileId }))) for (const item of pending) { if (!this.loadingStageReady) return - if (item.fileId) await this.saveEvidence(item.type, null, item.path) + if (item.fileId && item.type !== 'SEAL') await this.saveEvidence(item.type, null, item.path) + else if (item.fileId) continue else await this.queueEvidenceUpload(item.type, item.path) } }, @@ -1228,10 +1303,16 @@ if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return } const current = this.evidenceEntry(type) || {} this.evidenceFiles = { ...this.evidenceFiles, [type]: [...(this.evidenceFiles[type] || []).slice(0, -1), { ...current, fileId: latest.result?.fileId, fileName: latest.result?.fileName || current.fileName, uploadStatus: 'SUCCEEDED' }] } + if (type === 'SEAL') { + this.rememberEvidenceDraft(identity) + this.showToast('带锁码照已上传,请输入封签号并点击保存封签', 'success') + return + } await this.saveEvidence(type) }, async saveEvidence(type, operationId = null, localPath = null) { if (!this.requireEvidenceStage()) return + if (type === 'SEAL' && !String(this.sealNo || '').trim()) { this.showToast('请输入封签号后点击保存封签', 'warning'); return } if (!this.evidenceIdentity?.inboundId) { this.showToast('状态尚未同步,请刷新重试', 'warning'); return } if (!isRemoteReady(this.evidenceIdentity.inboundId)) { this.showToast('当前会话未连接服务端,证据保留本地', 'warning'); return } const contextId = this.evidenceContextId diff --git a/pages/tasks/tasks.vue b/pages/tasks/tasks.vue index ac4c47a..7662dbe 100644 --- a/pages/tasks/tasks.vue +++ b/pages/tasks/tasks.vue @@ -92,8 +92,9 @@ .task-error, .task-state { padding: 20px 14px; border-radius: 10px; background: #fff; color: #667085; font-size: 13px; line-height: 20px; text-align: center; } .task-error { color: #b42318; background: #fff1f0; } .task-notice { padding: 10px 12px; margin-bottom: 10px; border-left: 3px solid #f79009; border-radius: 6px; background: #fffaeb; color: #7a2e0e; font-size: 12px; line-height: 18px; } -.task-list { display: flex; flex-direction: column; gap: 9px; } -.task-card { box-sizing: border-box; width: 100%; margin: 0; padding: 12px; border: 1px solid #dce6f1; border-radius: 10px; color: #12233f; text-align: left; background: #fff; box-shadow: 0 2px 8px rgba(23, 55, 93, .05); } +.task-list { display: flex; flex-direction: column; } +.task-card { box-sizing: border-box; width: 100%; margin: 0 0 12px; padding: 12px; border: 1px solid #dce6f1; border-radius: 10px; color: #12233f; text-align: left; background: #fff; box-shadow: 0 2px 8px rgba(23, 55, 93, .05); } +.task-card:last-child { margin-bottom: 0; } .task-card:active { border-color: #8ab8ef; background: #f7faff; } .task-card[disabled] { opacity: .62; } .task-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; } diff --git a/styles/pda.scss b/styles/pda.scss index d809741..51cb69b 100644 --- a/styles/pda.scss +++ b/styles/pda.scss @@ -1152,8 +1152,9 @@ .sync-state.ready { color: $success; background: #e8f8ef; } .sync-state.error { color: $danger; background: #fff0f0; } .sync-state.loading { color: #8a5a00; background: #fff7df; } - .evidence-grid { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; } - .evidence-card { padding: 10px; border: 1px solid #d7e1ed; border-radius: 9px; background: #f8fafc; } + .evidence-grid { display: flex; flex-direction: column; margin-top: 12px; } + .evidence-card { margin-bottom: 12px; padding: 10px; border: 1px solid #d7e1ed; border-radius: 9px; background: #f8fafc; } + .evidence-card:last-child { margin-bottom: 0; } .evidence-card.filled { border-color: #a7d6ba; background: #f4fbf6; } .evidence-card-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; } .evidence-card-title { display: flex; min-width: 0; align-items: center; gap: 7px; } diff --git a/tests/page-contract.test.mjs b/tests/page-contract.test.mjs index a9d956e..cad682c 100644 --- a/tests/page-contract.test.mjs +++ b/tests/page-contract.test.mjs @@ -138,6 +138,17 @@ test('picking page keeps scan, draft and risky transition contracts', async () = assert.match(page, /leavePicking\(\)[\s\S]*?navigateBackOr\('\/pages\/home\/home'\)/) }) +test('picking cabinet selector synchronizes all ready server tasks by stable task id', async () => { + const page = await read('../pages/picking/picking.vue') + assert.match(page, /listLoadingOrders/) + assert.match(page, /async onShow\(\)[\s\S]*?await this\.refreshAvailableBoxes\(\)[\s\S]*?await this\.syncRemoteTask\(\)/) + assert.match(page, /async refreshAvailableBoxes\(\)[\s\S]*?listLoadingOrders\(\{ page: 1, size: 50 \}\)/) + assert.match(page, /isSelectableTask\(task\)[\s\S]*?recordStatus === 'ACTIVE'[\s\S]*?\['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW'\]/) + assert.match(page, /existingByTaskId[\s\S]*?recordByTaskId[\s\S]*?orderedTaskIds[\s\S]*?this\.boxes = nextBoxes/) + assert.match(page, /selectedTaskId[\s\S]*?findIndex\(\(box\) => String\(box\.taskId \|\| ''\) === selectedTaskId\)/) + assert.match(page, /selectBox\(index\)[\s\S]*?this\.remoteTaskId = this\.currentBox\.taskId \|\| null[\s\S]*?this\.resetEvidenceState\(\)[\s\S]*?this\.syncRemoteTask\(\)/) +}) + test('navigation returns to the real source and prevents duplicate utility pages', async () => { const [navigation, header, modulePage, operation, picking] = await Promise.all([ read('../utils/navigation.js'), read('../components/PortalHeader.vue'), read('../pages/module/module.vue'), @@ -241,7 +252,7 @@ test('picking session persists per task identity and clears on logout', async () }) test('shared visual contracts preserve accessible PDA targets', async () => { - const [app, styles, picking] = await Promise.all([read('../App.vue'), read('../styles/pda.scss'), read('../pages/picking/picking.vue')]) + const [app, styles, picking, tasks] = await Promise.all([read('../App.vue'), read('../styles/pda.scss'), read('../pages/picking/picking.vue'), read('../pages/tasks/tasks.vue')]) assert.match(app, /@import '@\/styles\/pda\.scss'/) assert.doesNotMatch(styles, /<\/?style\b/) for (const className of ['pick-btn', 'partial-quick-btn']) assert.match(styles, new RegExp(`\\.${className}\\s*\\{[\\s\\S]*?height:\\s*48px;`)) @@ -250,6 +261,10 @@ test('shared visual contracts preserve accessible PDA targets', async () => { assert.match(styles, /\.modal-sheet\s*\{[\s\S]*?padding:\s*20px 18px 18px;/) assert.doesNotMatch(styles, /\.modal-sheet\s*\{[\s\S]*?padding:[^;]*env\(/) assert.match(styles, /\.submit-decision-modal\s*\{[^}]*padding:\s*18px 16px 16px;/) + assert.match(styles, /\.evidence-card\s*\{[^}]*margin-bottom:\s*12px;/) + assert.match(styles, /\.evidence-card:last-child\s*\{[^}]*margin-bottom:\s*0;/) + assert.match(tasks, /\.task-card\s*\{[^}]*margin:\s*0 0 12px;/) + assert.match(tasks, /\.task-card:last-child\s*\{[^}]*margin-bottom:\s*0;/) assert.match(styles, /\.work-content\s*\{[^}]*padding:\s*1px 0 104px;/) assert.match(styles, /\.submit-section\s*\{[\s\S]*?bottom:\s*8px;[\s\S]*?width:\s*calc\(100% - 20px\);/) assert.match(styles, /\.submit-btn\s*\{[\s\S]*?flex:\s*1;[\s\S]*?height:\s*56px;/) diff --git a/tests/picking-evidence-stage.test.mjs b/tests/picking-evidence-stage.test.mjs index 49e5ae9..313a40a 100644 --- a/tests/picking-evidence-stage.test.mjs +++ b/tests/picking-evidence-stage.test.mjs @@ -99,10 +99,22 @@ test('final completion preserves backend gate errors and refreshes after an atom assert.match(save, /this\.isTaskConflict\(error\)[\s\S]*?refreshAfterTaskConflict/) const flush = page.match(/async flushPendingEvidence\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' assert.match(flush, /!entry\.id && entry\.localPath/) - assert.match(flush, /if \(item\.fileId\) await this\.saveEvidence\(item\.type, null, item\.path\)/) + assert.match(flush, /if \(item\.fileId && item\.type !== 'SEAL'\) await this\.saveEvidence\(item\.type, null, item\.path\)/) + assert.match(flush, /else if \(item\.fileId\) continue/) assert.match(flush, /else await this\.queueEvidenceUpload\(item\.type, item\.path\)/) }) +test('seal photo upload waits for an explicit seal-number save', () => { + const queue = page.match(/async queueEvidenceUpload\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' + const retry = page.match(/async retryEvidence\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' + const save = page.match(/async saveEvidence\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' + assert.match(queue, /if \(type === 'SEAL'\)[\s\S]*?rememberEvidenceDraft\(identity\)[\s\S]*?return[\s\S]*?saveEvidence\(type, null, path\)/) + assert.match(retry, /if \(type === 'SEAL'\)[\s\S]*?rememberEvidenceDraft\(identity\)[\s\S]*?return[\s\S]*?saveEvidence\(type\)/) + assert.match(save, /type === 'SEAL' && !String\(this\.sealNo \|\| ''\)\.trim\(\)[\s\S]*?请输入封签号后点击保存封签/) + assert.match(page, /async saveSealNo\(\)[\s\S]*?await this\.saveEvidence\('SEAL'/) + assert.match(page, /hasUnboundUploadedEvidence\(slot\.type\)[\s\S]*?照片已上传,请输入封签号并保存/) +}) + test('evidence identity is bound to task, inbound, loading plan and container booking', () => { assert.match(page, /captureEvidenceIdentity\(\)[\s\S]*?taskId:[\s\S]*?inboundId:[\s\S]*?loadingPlanId:[\s\S]*?containerBookingId:/) assert.match(page, /isSameEvidenceIdentity\(left, right\)[\s\S]*?taskId[\s\S]*?inboundId[\s\S]*?loadingPlanId[\s\S]*?containerBookingId/) From 258f5414d11919cf95a579384e3c09401bc6edb8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 20 Aug 2026 19:55:29 +0800 Subject: [PATCH 33/98] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 5385d3b..3b2ff22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,9 @@ | 页面注册与启动 | `pages.json`、`main.js`、`App.vue` | 全局样式和应用生命周期 | | 登录与会话 | `pages/login/`、`stores/session.js` | 远程鉴权适配和会话清理 | | 工作台与模块入口 | `pages/home/`、`pages/module/`、`pages/operation/` | `data/demo-data.js` 与页面禁用规则 | +| 待处理任务、服务端就绪与领取 | `pages/tasks/tasks.vue` | `api/pda-loading.js` 的 `listLoadingOrders`、`getContainerPickingTask`、`claimContainerPickingTask`;合并任务和缺少柜号/仓库/版本的任务不可进入 | | 拣货任务与箱码扫码 | `pages/picking/`、`stores/picking.js` | `api/pda-loading.js`、`utils/scan-broadcast.js`、`components/ScanTargetInput.vue`;服务端契约见 `../../baiyue-firstleg/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml` | +| 装柜取证与完成 | `pages/picking/picking.vue` 的 `evidence` 阶段 | `api/pda-loading.js` 的 `getEvidence`、`uploadEvidenceFile`、`upsertEvidence`、`voidEvidence`、`completeLoading`;上传队列 `utils/evidence-upload-queue.js` | | 扫描诊断 | `pages/scanner-test/` | `utils/scan-broadcast.js` 与广播 Action/Extra | | 作业记录与人员 | `pages/history/`、`pages/profile/` | session 与本地敏感数据清理 | From f6d4ed6c3c8e02363898cccc00eefe678c191406 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 20:47:13 +0800 Subject: [PATCH 34/98] =?UTF-8?q?=E4=BC=98=E5=8C=96PDA=E8=A3=85=E6=9F=9C?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E9=94=99=E8=AF=AF=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pages/picking/picking.vue | 11 ++++++++++- tests/picking-evidence-stage.test.mjs | 9 +++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue index 9ff9360..0ee5978 100644 --- a/pages/picking/picking.vue +++ b/pages/picking/picking.vue @@ -651,6 +651,14 @@ isTaskConflict(error) { return String(error?.code || '') === 'A0409' || Number(error?.statusCode) === 409 }, + remoteFailureMessage(error, fallback = '操作失败') { + const raw = String(error?.message || '').trim() + const message = /^internal server error$/i.test(raw) + ? '服务端处理异常,请联系管理员' + : (raw || fallback) + const reference = String(error?.requestId || error?.traceId || '').trim() + return reference ? `${message}(请求编号:${reference})` : message + }, async refreshAfterTaskConflict(message) { const requestedTaskId = this.remoteTaskId || this.currentBox.taskId this.submitId = '' @@ -1405,7 +1413,8 @@ await this.refreshAfterTaskConflict(error.message || '订舱、装柜关联或证据状态已变化,请刷新后重试') return } - this.remoteError = error.network ? '网络失败,装柜完成未提交' : (error.message || '装柜完成失败') + this.debugSubmit('complete.error', { message: error.message || '', code: error.code || '', statusCode: error.statusCode || '', requestIdFromServer: error.requestId || '', traceId: error.traceId || '' }) + this.remoteError = error.network ? '网络失败,装柜完成未提交' : this.remoteFailureMessage(error, '装柜完成失败') this.showToast('装柜完成失败', 'error') } finally { diff --git a/tests/picking-evidence-stage.test.mjs b/tests/picking-evidence-stage.test.mjs index 313a40a..f04b8b8 100644 --- a/tests/picking-evidence-stage.test.mjs +++ b/tests/picking-evidence-stage.test.mjs @@ -243,3 +243,12 @@ test('picking mutations stay closed until the canonical task has been claimed', assert.match(body, /if \(!this\.requirePickingOperation\(\)\) return/, `${method} should wait for claim`) } }) + +test('completion shows a Chinese server failure with a request correlation id', () => { + assert.match(page, /remoteFailureMessage\(error, fallback = '操作失败'\)/) + assert.match(page, /\^internal server error\$\/i/) + assert.match(page, /服务端处理异常,请联系管理员/) + assert.match(page, /请求编号/) + assert.match(page, /requestIdFromServer: error\.requestId/) + assert.match(page, /this\.remoteFailureMessage\(error, '装柜完成失败'\)/) +}) From 6572771227a99ae7bb61d3d0f2ff794f92a735db Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 20:50:01 +0800 Subject: [PATCH 35/98] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- api/auth.js | 11 ++- api/http.js | 149 +++++++++++++++++++++++++++++++++ api/pda-loading.js | 84 ++++--------------- pages/login/login.vue | 28 ++++++- pages/profile/profile.vue | 4 +- stores/session.js | 51 ++++++++++- styles/pda.scss | 14 ++++ tests/auth-session.test.mjs | 59 +++++++++++++ tests/online-session.test.mjs | 7 +- tests/page-contract.test.mjs | 5 ++ tests/pda-loading-api.test.mjs | 13 +-- 12 files changed, 341 insertions(+), 86 deletions(-) create mode 100644 api/http.js create mode 100644 tests/auth-session.test.mjs diff --git a/README.md b/README.md index c8636e1..74f7ccb 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ - `pages/scanner-test/scanner-test`:Android 硬件扫描广播诊断页; - `pages/picking/picking`:集装箱拣货完整交互。 -登录会话集中在 `stores/session.js`,拣货会话集中在 `stores/picking.js`;当前实现仍是内存态,不写入设备。产品契约要求未提交拣货草稿持久化到设备,并绑定作业员、任务和柜次,其他账号不能继承;成功提交或明确放弃后才清理。页面头部与底部主导航位于 `components/`,演示账号、模块和任务数据位于 `data/demo-data.js`,跨页面 PDA 视觉规则位于 `styles/pda.scss`。 +登录会话集中在 `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`。当前草稿尚未持久化到设备,这是实现缺口,不代表允许离线作业。 diff --git a/api/auth.js b/api/auth.js index 4ed5cb5..86e1969 100644 --- a/api/auth.js +++ b/api/auth.js @@ -1,11 +1,13 @@ import { API_ROOT } from '@/config/runtime.js' -function authRequest(path, data) { +function authRequest(path, data, options = {}) { return new Promise((resolve, reject) => { uni.request({ url: `${API_ROOT}${path}`, method: 'POST', data, + header: options.header, + timeout: options.timeout || 15000, success(response) { const body = response.data || {} if (String(body.code ?? response.statusCode) !== '200') { @@ -34,3 +36,10 @@ export function login(username, password) { export function refresh(refreshToken) { return authRequest('/auth/refresh', { refreshToken }) } + +export function logout(refreshToken, accessToken) { + return authRequest('/auth/logout', { refreshToken }, { + header: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined, + timeout: 5000 + }) +} diff --git a/api/http.js b/api/http.js new file mode 100644 index 0000000..eb27260 --- /dev/null +++ b/api/http.js @@ -0,0 +1,149 @@ +import { refresh as refreshRequest } from '@/api/auth.js' +import { isAccessTokenExpiring, session, signOut, updateTokenPair } from '@/stores/session.js' +import { requireOnline } from '@/utils/network.js' + +let refreshPromise = null +let redirectingToLogin = false + +function responseCode(response) { + return String(response?.data?.code ?? response?.statusCode ?? '') +} + +function isUnauthorized(responseOrError) { + const statusCode = Number(responseOrError?.statusCode ?? responseOrError?.response?.statusCode ?? 0) + const code = String(responseOrError?.code ?? responseCode(responseOrError)) + return statusCode === 401 || code === '401' || code === 'A0401' +} + +function sessionExpiredError(source) { + const error = new Error('登录已过期,请重新登录') + error.code = source?.code || 'A0401' + error.statusCode = source?.statusCode || 401 + return error +} + +function redirectAfterSessionExpiry() { + if (redirectingToLogin) return + redirectingToLogin = true + if (typeof uni !== 'undefined' && typeof uni.showToast === 'function') { + uni.showToast({ title: '登录已过期,请重新登录', icon: 'none', duration: 2200 }) + } + if (typeof uni !== 'undefined' && typeof uni.reLaunch === 'function') { + uni.reLaunch({ + url: '/pages/login/login', + complete() { setTimeout(() => { redirectingToLogin = false }, 1000) } + }) + } +} + +function expireSession(source, observedRefreshToken = session.refreshToken) { + if (observedRefreshToken && session.refreshToken && observedRefreshToken !== session.refreshToken) return session.token + const hadSession = Boolean(session.currentUser || session.token || session.refreshToken) + signOut() + if (hadSession) redirectAfterSessionExpiry() + throw sessionExpiredError(source) +} + +function networkError(error, fallback = '网络连接失败') { + const next = new Error(error?.errMsg || fallback) + next.network = true + return next +} + +function rawRequest(options, accessToken) { + const baseUrl = session.apiBase || '/api/platform/v1' + const headers = { ...(options.header || {}) } + if (accessToken) headers.Authorization = `Bearer ${accessToken}` + if (session.requestId) headers['X-Request-Id'] = session.requestId + return new Promise((resolve, reject) => { + uni.request({ + ...options, + url: `${baseUrl}${options.url}`, + header: headers, + success: resolve, + fail: (error) => reject(networkError(error)) + }) + }) +} + +async function refreshAccessToken() { + const observedRefreshToken = session.refreshToken + if (!observedRefreshToken) return expireSession(null, observedRefreshToken) + if (!refreshPromise) { + refreshPromise = (async () => { + if (session.refreshToken !== observedRefreshToken && session.token) return session.token + try { + const tokens = await refreshRequest(observedRefreshToken) + if (session.refreshToken !== observedRefreshToken) { + if (session.token) return session.token + throw sessionExpiredError() + } + updateTokenPair(tokens) + redirectingToLogin = false + return session.token + } catch (error) { + if (isUnauthorized(error)) return expireSession(error, observedRefreshToken) + throw error + } + })().finally(() => { refreshPromise = null }) + } + return refreshPromise +} + +async function validAccessToken() { + if (!session.token || isAccessTokenExpiring(session.token)) return refreshAccessToken() + return session.token +} + +export function ensureAccessToken() { + return validAccessToken() +} + +async function runAuthenticated(execute) { + requireOnline() + let accessToken = await validAccessToken() + let response = await execute(accessToken) + if (!isUnauthorized(response)) return response + accessToken = session.token && session.token !== accessToken ? session.token : await refreshAccessToken() + const activeRefreshToken = session.refreshToken + response = await execute(accessToken) + if (isUnauthorized(response)) expireSession(response, activeRefreshToken) + return response +} + +export function apiRequest(options) { + return runAuthenticated((accessToken) => rawRequest(options, accessToken)).then((response) => { + const body = response.data || {} + const code = responseCode(response) + if (code !== '200') { + const error = new Error(body.message || '服务端请求失败') + error.code = body.code || response.statusCode + error.statusCode = response.statusCode + error.requestId = body.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id'] + error.traceId = body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id'] + throw error + } + return body.data + }) +} + +export function apiDownload(options) { + return runAuthenticated((accessToken) => new Promise((resolve, reject) => { + const headers = { ...(options.header || {}) } + if (accessToken) headers.Authorization = `Bearer ${accessToken}` + if (session.requestId) headers['X-Request-Id'] = session.requestId + uni.downloadFile({ ...options, header: headers, success: resolve, fail: (error) => reject(networkError(error, '文件下载失败')) }) + })) +} + +export function apiUpload(options, onProgress) { + return runAuthenticated((accessToken) => new Promise((resolve, reject) => { + const headers = { ...(options.header || {}) } + if (accessToken) headers.Authorization = `Bearer ${accessToken}` + if (session.requestId) headers['X-Request-Id'] = session.requestId + const task = uni.uploadFile({ ...options, header: headers, success: resolve, fail: (error) => reject(networkError(error)) }) + if (task && typeof task.onProgressUpdate === 'function' && typeof onProgress === 'function') task.onProgressUpdate(({ progress }) => onProgress(progress)) + })) +} + +export { isUnauthorized } diff --git a/api/pda-loading.js b/api/pda-loading.js index 7d7cfce..46b01da 100644 --- a/api/pda-loading.js +++ b/api/pda-loading.js @@ -1,5 +1,6 @@ import { session } from '@/stores/session.js' import { requireOnline } from '@/utils/network.js' +import { apiDownload, apiRequest, apiUpload } from '@/api/http.js' const EVIDENCE_TYPES = Object.freeze([ 'PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL' @@ -23,41 +24,7 @@ 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' - const headers = { ...(options.header || {}) } - if (session.token) headers.Authorization = `Bearer ${session.token}` - if (session.requestId) headers['X-Request-Id'] = session.requestId - - return new Promise((resolve, reject) => { - uni.request({ - ...options, - url: `${baseUrl}${options.url}`, - header: headers, - success(response) { - const body = response.data || {} - const code = String(body.code ?? response.statusCode) - - if (code !== '200') { - const error = new Error(body.message || '服务端请求失败') - error.code = body.code || response.statusCode - error.statusCode = response.statusCode - error.requestId = body.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id'] - error.traceId = body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id'] - reject(error) - return - } - resolve(body.data) - }, - fail(error) { - const next = new Error(error.errMsg || '网络连接失败') - next.network = true - reject(next) - } - }) - }) -} +const request = apiRequest function idempotencyKey(operation, inboundId) { return `pda-${operation}-${inboundId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` @@ -260,15 +227,8 @@ export function downloadEvidencePreview(previewUrl) { const origin = baseUrl.match(/^(https?:\/\/[^/]+)/i)?.[1] || '' url = `${origin}${url.startsWith('/') ? '' : '/'}${url}` } - const headers = {} - if (session.token) headers.Authorization = `Bearer ${session.token}` - if (session.requestId) headers['X-Request-Id'] = session.requestId debugLog('evidence.preview.start', { previewPath: String(previewUrl), resolvedUrl: url }) - return new Promise((resolve, reject) => { - uni.downloadFile({ - url, - header: headers, - success(response) { + return apiDownload({ url }).then((response) => { const responseHeaders = response.header || {} const contentType = responseHeaders['Content-Type'] || responseHeaders['content-type'] || '' const requestId = responseHeaders['X-Request-Id'] || responseHeaders['x-request-id'] || '' @@ -279,8 +239,7 @@ export function downloadEvidencePreview(previewUrl) { error.statusCode = response.statusCode error.requestId = requestId error.traceId = traceId - reject(error) - return + throw error } if (contentType && !/^image\//i.test(contentType) && !/^application\/octet-stream/i.test(contentType)) { const error = new Error('服务端未返回有效照片') @@ -288,18 +247,12 @@ export function downloadEvidencePreview(previewUrl) { error.contentType = contentType error.requestId = requestId error.traceId = traceId - reject(error) - return + throw error } - resolve(response.tempFilePath) - }, - fail(error) { - debugLog('evidence.preview.error', { previewPath: String(previewUrl), message: error.errMsg || '' }) - const next = new Error(error.errMsg || '装柜照片下载失败') - next.network = true - reject(next) - } - }) + return response.tempFilePath + }).catch((error) => { + debugLog('evidence.preview.error', { previewPath: String(previewUrl), message: error.message || error.errMsg || '' }) + throw error }) } @@ -331,28 +284,19 @@ export function uploadEvidenceFile(inboundId, evidenceType, localPath, tenantId, if (!localPath) throw new Error('缺少待上传的照片') const tenant = tenantQuery(tenantId) const baseUrl = session.apiBase || '/api/platform/v1' - const headers = {} - if (session.token) headers.Authorization = `Bearer ${session.token}` - if (session.requestId) headers['X-Request-Id'] = session.requestId debugLog('evidence.upload.start', { inboundId, evidenceType, tenantId: tenant, hasLocalPath: Boolean(localPath) }) - return new Promise((resolve, reject) => { - const task = uni.uploadFile({ + return apiUpload({ url: `${baseUrl}/files/loading-evidence/${encodeURIComponent(tenant)}/${encodeURIComponent(inboundId)}?evidenceType=${encodeURIComponent(evidenceType)}`, filePath: localPath, name: 'file', - header: headers, - success(response) { + }, onProgress).then((response) => { let body = response.data try { body = typeof body === 'string' ? JSON.parse(body) : body } catch (_) { /* handled below */ } const code = String(body?.code ?? response.statusCode) debugLog('evidence.upload.response', { inboundId, evidenceType, 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'] || '', fileId: body?.data?.fileId || null }) - if (code !== '200') { const error = new Error(body?.message || '文件上传失败'); error.code = body?.code || response.statusCode; reject(error); return } - resolve(body?.data || body) - }, - fail(error) { debugLog('evidence.upload.error', { inboundId, evidenceType, message: error.errMsg || '' }); const next = new Error(error.errMsg || '网络连接失败'); next.network = true; reject(next) } - }) - if (task && typeof task.onProgressUpdate === 'function' && typeof onProgress === 'function') task.onProgressUpdate(({ progress }) => onProgress(progress)) - }) + if (code !== '200') { const error = new Error(body?.message || '文件上传失败'); error.code = body?.code || response.statusCode; throw error } + return body?.data || body + }).catch((error) => { debugLog('evidence.upload.error', { inboundId, evidenceType, message: error.message || error.errMsg || '' }); throw error }) } export function completeLoading(inboundId, payload, tenantId) { diff --git a/pages/login/login.vue b/pages/login/login.vue index adb5acf..06217e1 100644 --- a/pages/login/login.vue +++ b/pages/login/login.vue @@ -7,6 +7,7 @@ + {{ error }} @@ -15,12 +16,32 @@ diff --git a/stores/session.js b/stores/session.js index bba1957..5dc5269 100644 --- a/stores/session.js +++ b/stores/session.js @@ -3,6 +3,7 @@ import { resetPickingSession } from '@/stores/picking.js' import { PLATFORM_API_ROOT } from '@/config/runtime.js' const SESSION_KEY = 'pda-auth-session' +const LOGIN_PREFERENCE_KEY = 'pda-login-account-preference' export const session = reactive({ currentUser: null, token: '', refreshToken: '', tenantId: '', apiBase: PLATFORM_API_ROOT }) @@ -24,10 +25,34 @@ export function decodeAccessToken(token) { } } +export function isAccessTokenExpiring(token = session.token, skewSeconds = 30) { + try { + const expiresAt = Number(decodeAccessToken(token).exp || 0) * 1000 + return !expiresAt || expiresAt <= Date.now() + skewSeconds * 1000 + } catch (_) { + return true + } +} + function storageAvailable() { return typeof uni !== 'undefined' && typeof uni.setStorageSync === 'function' && typeof uni.getStorageSync === 'function' } +export function loadLoginPreference() { + if (!storageAvailable()) return { rememberAccount: true, account: '' } + const saved = uni.getStorageSync(LOGIN_PREFERENCE_KEY) + if (!saved || typeof saved !== 'object') return { rememberAccount: true, account: '' } + return { rememberAccount: saved.rememberAccount !== false, account: String(saved.account || '') } +} + +export function saveLoginPreference(rememberAccount, account = '') { + if (!storageAvailable()) return + uni.setStorageSync(LOGIN_PREFERENCE_KEY, { + rememberAccount: Boolean(rememberAccount), + account: rememberAccount ? String(account || '').trim() : '' + }) +} + function persistSession() { if (!storageAvailable()) return uni.setStorageSync(SESSION_KEY, { @@ -51,6 +76,7 @@ export function signIn(account, warehouse, options = {}) { export function signInWithTokens(tokens) { if (!tokens?.accessToken) throw new Error('登录响应缺少 accessToken') + if (!tokens?.refreshToken) throw new Error('登录响应缺少 refreshToken') const claims = decodeAccessToken(tokens.accessToken) if (claims.app_code !== 'PLATFORM') throw new Error('当前账号不属于平台作业端') if (claims.exp && claims.exp * 1000 <= Date.now()) throw new Error('登录状态已过期') @@ -66,16 +92,39 @@ export function signInWithTokens(tokens) { }) } +export function updateTokenPair(tokens) { + if (!tokens?.accessToken) throw new Error('刷新响应缺少 accessToken') + if (!tokens?.refreshToken) throw new Error('刷新响应缺少 refreshToken') + const claims = decodeAccessToken(tokens.accessToken) + if (claims.app_code !== 'PLATFORM') throw new Error('当前账号不属于平台作业端') + if (claims.exp && claims.exp * 1000 <= Date.now()) throw new Error('刷新后的登录状态已过期') + const previousUser = session.currentUser || {} + session.currentUser = { + ...previousUser, + id: String(claims.user_id ?? previousUser.id ?? ''), + name: claims.username || previousUser.name || '仓库作业员', + role: Array.isArray(claims.roles) ? claims.roles.join('、') : (previousUser.role || ''), + roles: claims.roles || previousUser.roles || [] + } + session.token = tokens.accessToken + session.refreshToken = tokens.refreshToken + session.tenantId = claims.tenant_id == null ? session.tenantId : String(claims.tenant_id) + persistSession() + return session.token +} + export function restoreSession() { if (!storageAvailable()) return false const saved = uni.getStorageSync(SESSION_KEY) if (!saved?.token || !saved?.currentUser) return false try { const claims = decodeAccessToken(saved.token) - if (claims.exp && claims.exp * 1000 <= Date.now()) return false + if (claims.app_code !== 'PLATFORM') throw new Error('登录域无效') + if (claims.exp && claims.exp * 1000 <= Date.now() && !saved.refreshToken) throw new Error('登录状态已过期') Object.assign(session, saved, { apiBase: saved.apiBase || PLATFORM_API_ROOT }) return true } catch (_) { + signOut() return false } } diff --git a/styles/pda.scss b/styles/pda.scss index 51cb69b..f009902 100644 --- a/styles/pda.scss +++ b/styles/pda.scss @@ -133,6 +133,20 @@ font-size: 13px; } + .login-remember-row { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 48px; + margin: -8px 0 10px; + color: $text-secondary; + } + + .login-remember-label { + font-size: 14px; + font-weight: 500; + } + .login-btn { width: 100%; height: 50px; diff --git a/tests/auth-session.test.mjs b/tests/auth-session.test.mjs new file mode 100644 index 0000000..212eb9f --- /dev/null +++ b/tests/auth-session.test.mjs @@ -0,0 +1,59 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' + +const read = (path) => readFile(new URL(path, import.meta.url), 'utf8') + +test('authenticated PDA requests refresh once and replay once after unauthorized', async () => { + const http = await read('../api/http.js') + assert.match(http, /let refreshPromise = null/) + assert.match(http, /refreshPromise = \(async \(\) =>/) + assert.match(http, /session\.token && session\.token !== accessToken \? session\.token : await refreshAccessToken\(\)/) + assert.match(http, /response = await execute\(accessToken\)[\s\S]*?isUnauthorized\(response\)[\s\S]*?response = await execute\(accessToken\)/) + assert.match(http, /apiRequest/) + assert.match(http, /apiDownload/) + assert.match(http, /apiUpload/) +}) + +test('refresh rotation persists the complete token pair and preserves operator context', async () => { + const [session, auth] = await Promise.all([read('../stores/session.js'), read('../api/auth.js')]) + assert.match(session, /export function updateTokenPair\(tokens\)/) + assert.match(session, /session\.refreshToken = tokens\.refreshToken/) + assert.match(session, /const previousUser = session\.currentUser \|\| \{\}/) + assert.match(session, /persistSession\(\)/) + assert.match(session, /isAccessTokenExpiring/) + assert.match(auth, /authRequest\('\/auth\/refresh', \{ refreshToken \}\)/) +}) + +test('app launch restores and validates persisted tokens before entering the workbench', async () => { + const [login, http] = await Promise.all([read('../pages/login/login.vue'), read('../api/http.js')]) + assert.match(login, /async onLoad\(\)[\s\S]*?restoreSession\(\)[\s\S]*?await ensureAccessToken\(\)[\s\S]*?pages\/home\/home/) + assert.match(http, /export function ensureAccessToken\(\)/) + assert.match(login, /暂时无法恢复登录状态/) +}) + +test('confirmed session expiry clears sensitive state and redirects only once', async () => { + const http = await read('../api/http.js') + assert.match(http, /observedRefreshToken !== session\.refreshToken/) + assert.match(http, /signOut\(\)[\s\S]*?redirectAfterSessionExpiry\(\)/) + assert.match(http, /if \(redirectingToLogin\) return/) + assert.match(http, /url: '\/pages\/login\/login'/) + assert.match(http, /if \(isUnauthorized\(error\)\) return expireSession/) +}) + +test('manual logout revokes the refresh token without delaying local cleanup', async () => { + const [profile, auth] = await Promise.all([read('../pages/profile/profile.vue'), read('../api/auth.js')]) + assert.match(auth, /export function logout\(refreshToken, accessToken\)/) + assert.match(profile, /const refreshToken = session\.refreshToken; const accessToken = session\.token; signOut\(\); uni\.reLaunch/) + assert.match(profile, /logoutRequest\(refreshToken, accessToken\)\.catch/) +}) + +test('development login remembers only the account preference, never the entered password', async () => { + const [login, session] = await Promise.all([read('../pages/login/login.vue'), read('../stores/session.js')]) + assert.match(login, /form: \{ userId: 'platform_admin', password: 'ChangeMe123!' \}/) + assert.match(session, /export function saveLoginPreference\(rememberAccount, account = ''\)/) + const savePreference = session.match(/export function saveLoginPreference[\s\S]*?\n\}/)?.[0] || '' + assert.match(savePreference, /account: rememberAccount/) + assert.doesNotMatch(savePreference, /password/) + assert.match(login, /if \(!this\.rememberAccount\) saveLoginPreference\(false\)/) +}) diff --git a/tests/online-session.test.mjs b/tests/online-session.test.mjs index b9ab1dd..7d6cd8e 100644 --- a/tests/online-session.test.mjs +++ b/tests/online-session.test.mjs @@ -5,10 +5,11 @@ import { readFile } from 'node:fs/promises' const read = (path) => readFile(new URL(path, import.meta.url), 'utf8') test('remote PDA mutations use the shared online gate', async () => { - const [api, network, page] = await Promise.all([ - read('../api/pda-loading.js'), read('../utils/network.js'), read('../pages/picking/picking.vue') + const [api, http, network, page] = await Promise.all([ + read('../api/pda-loading.js'), read('../api/http.js'), read('../utils/network.js'), read('../pages/picking/picking.vue') ]) - assert.match(api, /function request\(options\) \{[\s\S]*?requireOnline\(\)/) + assert.match(api, /const request = apiRequest/) + assert.match(http, /async function runAuthenticated\(execute\) \{[\s\S]*?requireOnline\(\)/) assert.match(api, /uploadEvidenceFile[\s\S]*?requireOnline\('当前网络不可用,无法上传装柜照片'\)/) assert.match(network, /uni\.onNetworkStatusChange/) assert.match(network, /result\.networkType !== 'none'/) diff --git a/tests/page-contract.test.mjs b/tests/page-contract.test.mjs index cad682c..0a8b258 100644 --- a/tests/page-contract.test.mjs +++ b/tests/page-contract.test.mjs @@ -31,7 +31,12 @@ test('login uses the real auth endpoint and relaunches to the modular workbench' assert.match(auth, /\/auth\/login/) assert.match(login, /loginRequest\(userId, this\.form\.password\)/) assert.match(login, /signInWithTokens\(tokens\)/) + assert.match(login, /userId: 'platform_admin', password: 'ChangeMe123!'/) + assert.match(login, /记住账号/) + assert.match(login, /loadLoginPreference\(\)/) + assert.match(login, /saveLoginPreference\(this\.rememberAccount, userId\)/) assert.match(session, /pda-auth-session/) + assert.match(session, /pda-login-account-preference/) assert.match(session, /claims\.app_code !== 'PLATFORM'/) assert.match(runtime, /import\.meta\.env\.VITE_API_BASE_URL/) assert.doesNotMatch(runtime, /typeof\s+import\.meta/) diff --git a/tests/pda-loading-api.test.mjs b/tests/pda-loading-api.test.mjs index c6eeb79..8ac2072 100644 --- a/tests/pda-loading-api.test.mjs +++ b/tests/pda-loading-api.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' const source = await readFile(new URL('../api/pda-loading.js', import.meta.url), 'utf8') +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('PDA loading adapter keeps C04 read and write paths', () => { @@ -17,7 +18,8 @@ test('PDA loading adapter keeps C04 read and write paths', () => { assert.match(source, /'Idempotency-Key'/) assert.match(source, /PHOTO_STEP_1[\s\S]*PHOTO_STEP_4[\s\S]*SEAL/) for (const label of ['空仓照片', '半仓照片', '满仓照片', '锁仓照片', '带锁码照']) assert.match(source, new RegExp(label)) - assert.match(source, /uni\.uploadFile/) + assert.match(source, /apiUpload\(/) + assert.match(http, /uni\.uploadFile/) assert.match(source, /\/files\/loading-evidence\//) assert.match(source, /loading\/evidence\/pda\?tenantId=/) assert.match(source, /loading\/evidence\/\$\{encodeURIComponent\(evidenceId\)\}\/void\/pda\?tenantId=/) @@ -27,8 +29,8 @@ test('PDA loading adapter keeps C04 read and write paths', () => { }) test('PDA adapter does not treat non-200 response bodies or network failures as success', () => { - assert.match(source, /if \(code !== '200'\)/) - assert.match(source, /next\.network = true/) + assert.match(http, /if \(code !== '200'\)/) + assert.match(http, /next\.network = true/) assert.match(source, /throw new Error\('缺少客户隔离标识 tenantId/) }) @@ -81,8 +83,9 @@ test('PDA downloads authenticated evidence previews returned by the backend', () assert.match(source, /export function getEvidencePreviewUrl\(inboundId, evidenceId, tenantId\)/) assert.match(source, /loading\/evidence\/\$\{encodeURIComponent\(evidenceId\)\}\/content\/pda\?tenantId=/) assert.match(source, /export function downloadEvidencePreview\(previewUrl\)/) - assert.match(source, /uni\.downloadFile\(/) - assert.match(source, /headers\.Authorization = `Bearer \$\{session\.token\}`/) + assert.match(source, /apiDownload\(\{ url \}\)/) + assert.match(http, /uni\.downloadFile\(/) + assert.match(http, /headers\.Authorization = `Bearer \$\{accessToken\}`/) assert.match(source, /response\.tempFilePath/) assert.match(source, /evidence\.preview\.response/) assert.match(source, /\^image\\\//) From 69f8fdfcdbcc48504cf556466f4de2b2f26984d4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 20:52:11 +0800 Subject: [PATCH 36/98] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 +++ config/runtime.js | 8 +++++++- tests/page-contract.test.mjs | 8 ++++++-- vite.config.js | 24 ++++++++++++++++++++++++ 4 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 .env.example create mode 100644 vite.config.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..471ef2b --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +VITE_API_BASE_URL=http://127.0.0.1:9000/api +VITE_H5_API_BASE_URL=/api +API_PROXY_TARGET=http://127.0.0.1:9000 diff --git a/config/runtime.js b/config/runtime.js index c1edabb..e6c09d8 100644 --- a/config/runtime.js +++ b/config/runtime.js @@ -1,4 +1,10 @@ const configuredApiRoot = import.meta.env.VITE_API_BASE_URL || '' +let selectedApiRoot = configuredApiRoot || '/api' -export const API_ROOT = String(configuredApiRoot || '/api').replace(/\/$/, '') +// H5 必须请求同源地址,由 Vite 开发服务器代理到国内 Gateway,避免浏览器 CORS。 +// #ifdef H5 +selectedApiRoot = import.meta.env.VITE_H5_API_BASE_URL || '/api' +// #endif + +export const API_ROOT = String(selectedApiRoot).replace(/\/$/, '') export const PLATFORM_API_ROOT = `${API_ROOT}/platform/v1` diff --git a/tests/page-contract.test.mjs b/tests/page-contract.test.mjs index 0a8b258..f01869b 100644 --- a/tests/page-contract.test.mjs +++ b/tests/page-contract.test.mjs @@ -23,8 +23,8 @@ test('application uses the Vue 3 uni-app entry and no unused Android permissions }) test('login uses the real auth endpoint and relaunches to the modular workbench', async () => { - const [login, auth, session, runtime] = await Promise.all([ - read('../pages/login/login.vue'), read('../api/auth.js'), read('../stores/session.js'), read('../config/runtime.js') + const [login, auth, session, runtime, vite] = await Promise.all([ + read('../pages/login/login.vue'), read('../api/auth.js'), read('../stores/session.js'), read('../config/runtime.js'), read('../vite.config.js') ]) for (const text of ['柏悦物流 · PDA作业端', '工号 / 用户名', '登录中']) assert.match(login, new RegExp(text)) assert.doesNotMatch(login, /DEMO_ACCOUNTS|快捷登录|作业仓库/) @@ -39,7 +39,11 @@ test('login uses the real auth endpoint and relaunches to the modular workbench' assert.match(session, /pda-login-account-preference/) assert.match(session, /claims\.app_code !== 'PLATFORM'/) assert.match(runtime, /import\.meta\.env\.VITE_API_BASE_URL/) + assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_API_BASE_URL \|\| '\/api'/) assert.doesNotMatch(runtime, /typeof\s+import\.meta/) + assert.match(vite, /API_PROXY_TARGET/) + assert.match(vite, /'\/api'[\s\S]*?target: proxyTarget[\s\S]*?changeOrigin: true/) + assert.match(vite, /removeHeader\('origin'\)/) assert.match(login, /uni\.reLaunch\(\{ url: '\/pages\/home\/home' \}\)/) }) diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..6f4b9d5 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,24 @@ +import { defineConfig, loadEnv } from 'vite' +import uni from '@dcloudio/vite-plugin-uni' + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + const proxyTarget = env.API_PROXY_TARGET || 'http://127.0.0.1:9000' + + return { + plugins: [uni()], + server: { + proxy: { + '/api': { + target: proxyTarget, + changeOrigin: true, + configure(proxy) { + proxy.on('proxyReq', (proxyRequest) => { + proxyRequest.removeHeader('origin') + }) + } + } + } + } + } +}) From 88b52cff40c39e34d32ddddf31b45607a0083046 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 20:50:58 +0800 Subject: [PATCH 37/98] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 5 + AGENTS.md | 10 +- App.vue | 10 +- README.md | 23 +++++ config/runtime.js | 9 +- index.html | 2 + manifest.json | 167 +++++++++++++++++------------- pages/profile/profile.vue | 37 ++++++- pages/tasks/tasks.vue | 17 ++- stores/session.js | 9 +- tests/app-update.test.mjs | 42 ++++++++ tests/page-contract.test.mjs | 12 ++- utils/app-update.js | 195 +++++++++++++++++++++++++++++++++++ vite.config.js | 40 +++++++ 14 files changed, 488 insertions(+), 90 deletions(-) create mode 100644 .env.example create mode 100644 tests/app-update.test.mjs create mode 100644 utils/app-update.js create mode 100644 vite.config.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5481b4e --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +VITE_API_BASE_URL=http://127.0.0.1:9000/api +VITE_H5_API_BASE_URL=/gateway-api +API_PROXY_TARGET=http://127.0.0.1:9000 +# 国内 PDA 独立更新清单地址;仅 App-Plus 启动时请求。 +VITE_PDA_UPDATE_URL=https://updates.example.com/pda/domestic/latest diff --git a/AGENTS.md b/AGENTS.md index 3b2ff22..b2eb641 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,10 +8,13 @@ ## 开发与发布边界 -- 当前已有 uni-app Vue 3 静态原型和 `uni_modules/`,没有项目根级依赖清单或可核实的 HBuilderX/Android 构建命令;现有 Node.js 内置测试仅覆盖页面契约与本地状态规则。 +- 当前已有 uni-app Vue 3 页面、`uni_modules/`、`vite.config.js`、`.env.example` 和 H5 同源代理配置,但仍没有项目根级依赖清单或可复现的 npm/HBuilderX/Android 构建命令;现有 Node.js 内置测试仅覆盖页面契约与本地状态规则。 - PDA 采用 uni-app 开发并支持在线更新;Android 硬件扫描已确认使用广播模式并已接入:Action=`android.intent.action.SCANRESULT`、字符串 Extra=`value`。设备型号不构成开发、发布或验收门禁;继续通过设备适配层接入,不绑定单一厂商 SDK。 - 正式作业已确认为在线模式:断网不能开始正式拣货、提交、完成任务或推进 `LOADED`,不实现离线业务提交队列;恢复网络后提示员工刷新重试。 - 明确使用的工具链和发布流程后,补充准确的依赖版本、安装、调试、测试和打包命令,并同步更新 `README.md` 与本文件。 +- 在 `package.json` 与锁文件正式补齐前,`vite.config.js` 只证明目标配置,不证明依赖可安装;维护者只能运行仓库当前可执行的 `node --test tests/*.test.mjs`,不得从海外 PDA 或全局缓存猜测国内 PDA 的 uni-app/Vite 版本。 +- App API 根只读 `VITE_API_BASE_URL`;H5 固定优先使用 `VITE_H5_API_BASE_URL` 的同源 `/api` 并由 `vite.config.js` 将其代理到 `API_PROXY_TARGET`。环境地址不写入源码,切换构建环境后不得沿用登录缓存中的旧 API 地址。 +- 国内 Android App 启动后仅在 `APP-PLUS` 调用 `utils/app-update.js`,通过独立 `VITE_PDA_UPDATE_URL` 检查更新;更新服务和下载地址必须为 HTTPS。APK 以整数 `versionCode` 判新,WGT 以 `versionName` 判新;强制更新不可跳过,WGT 安装后立即重启。国内 AppID、包名、签名、版本和更新通道不得复用海外 PDA。 ## 作业流程原则 @@ -38,13 +41,16 @@ - 在真实目标 PDA 上验证扫描速度、连续扫描、断网阻断与恢复刷新、低电量、前后台切换和重复提交。 - 状态转换、扫码分类与页面契约使用 `node --test tests/*.test.mjs` 验证;新增本地行为时继续补充对应自动化测试。 -- 当前原型没有可核实的 HBuilderX/Android 构建命令,不得声称打包或真机检查已通过;交付说明应明确实际验证方式、设备、系统版本和仍未覆盖的风险。 +- 修改在线更新协议、版本比较、强制更新、安装或重启行为时运行 `node --test tests/app-update.test.mjs`;新增原生模块、权限或不兼容运行时能力必须发布 APK,不能只发布 WGT。 +- 当前没有依赖清单和可核实的 npm/HBuilderX/Android 构建命令,不得仅凭 `vite.config.js` 声称 H5、App 打包或真机检查已通过;交付说明应明确实际验证方式、设备、系统版本和仍未覆盖的风险。 ## 页面与能力快速查询 | 目标 | 入口 | 继续查询 | |---|---|---| | 页面注册与启动 | `pages.json`、`main.js`、`App.vue` | 全局样式和应用生命周期 | +| API 环境与 H5 开发代理 | `config/runtime.js`、`.env.example`、`vite.config.js` | App `VITE_API_BASE_URL`;H5 同源 `VITE_H5_API_BASE_URL=/api`,代理上游 `API_PROXY_TARGET`;会话恢复不得覆盖当前构建地址 | +| Android APK/WGT 在线更新 | `App.vue`、`utils/app-update.js`、`.env.example`、`manifest.json` | `VITE_PDA_UPDATE_URL` 使用国内独立 HTTPS 通道;合同 `tests/app-update.test.mjs`,H5 不执行更新逻辑 | | 登录与会话 | `pages/login/`、`stores/session.js` | 远程鉴权适配和会话清理 | | 工作台与模块入口 | `pages/home/`、`pages/module/`、`pages/operation/` | `data/demo-data.js` 与页面禁用规则 | | 待处理任务、服务端就绪与领取 | `pages/tasks/tasks.vue` | `api/pda-loading.js` 的 `listLoadingOrders`、`getContainerPickingTask`、`claimContainerPickingTask`;合并任务和缺少柜号/仓库/版本的任务不可进入 | diff --git a/App.vue b/App.vue index bee3444..f5ee245 100644 --- a/App.vue +++ b/App.vue @@ -1,5 +1,13 @@ diff --git a/pages/dropship-palletizing/tasks.vue b/pages/dropship-palletizing/tasks.vue new file mode 100644 index 0000000..9d310bc --- /dev/null +++ b/pages/dropship-palletizing/tasks.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/pages/home/home.vue b/pages/home/home.vue index fa74fef..b21ee98 100644 --- a/pages/home/home.vue +++ b/pages/home/home.vue @@ -23,6 +23,7 @@ import { session, requireSession } from '@/stores/session.js' const ICON_TYPES = { + '一件代发清点组托': 'download', '到货收货': 'download', '商品上架': 'upload', '入库质检': 'checkbox', '集装箱拣货': 'cart', '出库复核': 'compose', '装箱交接': 'paperplane', '库存盘点': 'list', '商品移库': 'loop', '库存调整': 'compose', @@ -46,7 +47,7 @@ return true }, methods: { - openOperation(operation) { if (operation.available) uni.navigateTo({ url: operation.name === '集装箱拣货' ? '/pages/tasks/tasks' : '/pages/operation/operation' }) } + openOperation(operation) { if (operation.available) uni.navigateTo({ url: operation.route || '/pages/operation/operation' }) } } } diff --git a/pages/module/module.vue b/pages/module/module.vue index f36d69a..dbcf1b3 100644 --- a/pages/module/module.vue +++ b/pages/module/module.vue @@ -5,7 +5,7 @@ {{ currentModule.icon }}{{ currentModule.name }}{{ currentModule.description }} 业务功能 - 第一版本仅开放“出库 → 集装箱拣货”,其他业务将在后续版本接入。 + 当前开放“入库 → 一件代发清点组托”和“出库 → 集装箱拣货”,其余业务将在后续版本接入。 @@ -22,7 +22,7 @@ onLoad(options) { this.moduleKey = options.key || 'outbound' }, onShow() { if (!requireSession()) return; this.user = session.currentUser; this.overview = getPickingOverview() }, methods: { - openOperation(operation) { if (operation.available) uni.navigateTo({ url: operation.name === '集装箱拣货' ? '/pages/tasks/tasks' : '/pages/operation/operation' }) } + openOperation(operation) { if (operation.available) uni.navigateTo({ url: operation.route || '/pages/operation/operation' }) } } } diff --git a/stores/dropship-palletizing.js b/stores/dropship-palletizing.js new file mode 100644 index 0000000..d20a993 --- /dev/null +++ b/stores/dropship-palletizing.js @@ -0,0 +1,36 @@ +const STORAGE_PREFIX = 'pda-dropship-palletizing-draft' + +function clone(value) { + return JSON.parse(JSON.stringify(value)) +} + +function text(value) { + return value == null ? '' : String(value) +} + +export function getDropshipPalletizingDraftKey(identity = {}) { + const parts = [text(identity.operatorId), text(identity.taskId), text(identity.warehouseId)] + return parts.every(Boolean) ? `${STORAGE_PREFIX}:${parts.map(encodeURIComponent).join(':')}` : '' +} + +export function saveDropshipPalletizingDraft(identity, state) { + const key = getDropshipPalletizingDraftKey(identity) + if (!key || typeof uni === 'undefined' || typeof uni.setStorageSync !== 'function') return false + uni.setStorageSync(key, { identity: { ...identity }, state: clone(state), updatedAt: new Date().toISOString() }) + return true +} + +export function restoreDropshipPalletizingDraft(identity) { + const key = getDropshipPalletizingDraftKey(identity) + if (!key || typeof uni === 'undefined' || typeof uni.getStorageSync !== 'function') return null + const saved = uni.getStorageSync(key) + if (!saved?.state || text(saved.identity?.operatorId) !== text(identity.operatorId) || text(saved.identity?.taskId) !== text(identity.taskId) || text(saved.identity?.warehouseId) !== text(identity.warehouseId)) return null + return clone(saved.state) +} + +export function clearDropshipPalletizingDraft(identity) { + const key = getDropshipPalletizingDraftKey(identity) + if (!key || typeof uni === 'undefined' || typeof uni.removeStorageSync !== 'function') return false + uni.removeStorageSync(key) + return true +} diff --git a/tests/dropship-palletizing-page.test.mjs b/tests/dropship-palletizing-page.test.mjs new file mode 100644 index 0000000..d8fa6d8 --- /dev/null +++ b/tests/dropship-palletizing-page.test.mjs @@ -0,0 +1,49 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' + +const read = (path) => readFile(new URL(path, import.meta.url), 'utf8') + +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/) + assert.match(pages, /pages\/dropship-palletizing\/dropship-palletizing/) + assert.match(data, /一件代发清点组托[\s\S]*?route: '\/pages\/dropship-palletizing\/tasks'/) + assert.match(home, /operation\.route \|\| '\/pages\/operation\/operation'/) + assert.match(modulePage, /operation\.route \|\| '\/pages\/operation\/operation'/) +}) + +test('page exposes both scan orders, receiving confirmation, pending boxes, pallet changes, and sealing guards', async () => { + const page = await read('../pages/dropship-palletizing/dropship-palletizing.vue') + for (const text of ['先扫托盘', '先扫多箱', '确认本箱实收', '待绑托箱', '移回待绑托', '封托']) assert.match(page, new RegExp(text)) + assert.match(page, /startScanBroadcast/) + assert.match(page, /watchNetwork/) + assert.match(page, /saveDropshipPalletizingDraft/) + assert.match(page, /restoreDropshipPalletizingDraft/) + assert.match(page, /resolveDropshipCarton/) + assert.match(page, /countDropshipCarton/) + assert.match(page, /bindPendingDropshipCartons/) + assert.match(page, /moveDropshipCarton/) + assert.match(page, /sealDropshipPallet/) + assert.match(page, /pallet\.cartons \|\| \[\]/) + assert.match(page, /carton\.canCount === false[\s\S]*?carton\.blockReason/) + assert.doesNotMatch(page, /\(pallet\.items \|\| \[\]\)\.filter/) + assert.match(page, /:disabled="busy \|\| !online \|\| !canSeal"/) + assert.doesNotMatch(page, /warehouseId:\s*Number\(this\.warehouseId\)/) + assert.doesNotMatch(page, /模拟成功|DEMO_|palletStatus/) +}) + +test('390px work layout uses bounded grids and touch-sized primary actions', async () => { + const page = await read('../pages/dropship-palletizing/dropship-palletizing.vue') + assert.match(page, /\.dropship-page\{min-width:0;overflow:hidden;/) + assert.match(page, /\.summary-grid\{[^}]*grid-template-columns:repeat\(3,minmax\(0,1fr\)\);/) + assert.match(page, /\.primary-button,\.secondary-button\{[^}]*width:100%;min-height:48px;/) + assert.match(page, /\.quantity-control\{[^}]*grid-template-columns:52px minmax\(0,1fr\) 52px/) +}) + +test('dropship API uses only the frozen cartons/count contract', async () => { + const api = await read('../api/pda-dropship.js') + for (const path of ['tasks/pending', 'cartons/resolve', 'cartons/count', 'pallets/bind-pending', '/move', '/seal']) assert.match(api, new RegExp(path.replace('/', '\\/'))) + for (const field of ['actualSkuQuantity', 'discrepancyAction', 'discrepancyRemark', 'palletNo', 'operationId', 'expectedVersion']) assert.match(api, new RegExp(field)) + assert.doesNotMatch(api, /pallets\/open|pallets\/\$\{encodeURIComponent\(palletId\)\}\/scan|palletStatus/) +}) diff --git a/tests/dropship-palletizing-state.test.mjs b/tests/dropship-palletizing-state.test.mjs new file mode 100644 index 0000000..580c9b0 --- /dev/null +++ b/tests/dropship-palletizing-state.test.mjs @@ -0,0 +1,28 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' + +const source = await readFile(new URL('../utils/dropship-palletizing-state.js', import.meta.url), 'utf8') +const state = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`) + +test('pallet first selects the pallet and later carton resolution carries it', () => { + let scanState = state.createDropshipScanState() + let plan = state.planDropshipScan(scanState, 'dp-260908-0001') + assert.equal(plan.intent, state.DROPSHIP_SCAN_INTENT.SELECT_PALLET) + scanState = state.releaseDropshipScan(plan.state) + plan = state.planDropshipScan(scanState, 'box-001') + assert.deepEqual({ intent: plan.intent, boxCode: plan.boxCode, palletNo: plan.palletNo }, { intent: state.DROPSHIP_SCAN_INTENT.RESOLVE_CARTON, boxCode: 'BOX-001', palletNo: 'DP-260908-0001' }) +}) + +test('boxes first requests batch binding when a pallet is scanned with pending cartons', () => { + const plan = state.planDropshipScan(state.createDropshipScanState(), 'DP-260908-0002', { unboundCartons: 3 }) + assert.equal(plan.intent, state.DROPSHIP_SCAN_INTENT.BIND_PENDING) + assert.equal(plan.palletNo, 'DP-260908-0002') +}) + +test('offline, empty, and immediate repeated scans are blocked before API intent', () => { + assert.equal(state.planDropshipScan(state.createDropshipScanState({ online: false }), 'BOX-001').intent, state.DROPSHIP_SCAN_INTENT.BLOCKED) + assert.equal(state.planDropshipScan(state.createDropshipScanState(), ' ').intent, state.DROPSHIP_SCAN_INTENT.INVALID) + const first = state.planDropshipScan(state.createDropshipScanState(), 'BOX-001') + assert.equal(state.planDropshipScan(first.state, 'BOX-001').intent, state.DROPSHIP_SCAN_INTENT.DUPLICATE) +}) diff --git a/tests/page-contract.test.mjs b/tests/page-contract.test.mjs index ce1441e..25db090 100644 --- a/tests/page-contract.test.mjs +++ b/tests/page-contract.test.mjs @@ -10,7 +10,8 @@ test('page configuration starts at login and registers independent PDA routes', assert.equal(pages.pages[0].style.navigationStyle, 'custom') assert.deepEqual(pages.pages.map((page) => page.path), [ 'pages/login/login', 'pages/home/home', 'pages/module/module', 'pages/operation/operation', - 'pages/tasks/tasks', 'pages/history/history', 'pages/profile/profile', 'pages/scanner-test/scanner-test', 'pages/picking/picking' + 'pages/tasks/tasks', 'pages/history/history', 'pages/profile/profile', 'pages/scanner-test/scanner-test', 'pages/picking/picking', + 'pages/dropship-palletizing/tasks', 'pages/dropship-palletizing/dropship-palletizing' ]) }) @@ -76,8 +77,8 @@ test('picking task list uses remote loading orders and never falls back to demo assert.match(tasks, /task\.taskId[\s\S]*?task\.loadingPlanId[\s\S]*?task\.containerBookingId[\s\S]*?task\.currentContainerNo[\s\S]*?task\.version[\s\S]*?task\.assignmentVersion/) assert.doesNotMatch(tasks, /task\.taskStatus === 'LOADING'/) assert.doesNotMatch(tasks, /demo-data|DEMO_/) - assert.match(home, /pages\/tasks\/tasks/) - assert.match(modulePage, /pages\/tasks\/tasks/) + assert.match(home, /operation\.route \|\| '\/pages\/operation\/operation'/) + assert.match(modulePage, /operation\.route \|\| '\/pages\/operation\/operation'/) }) test('workbench, module, history and profile are independent sources', async () => { @@ -87,7 +88,7 @@ test('workbench, module, history and profile are independent sources', async () for (const text of ['首页', '库内作业', 'home-operation-grid', 'home-operation-primary', '开始作业', 'uni-icons']) assert.match(home, new RegExp(text)) assert.doesNotMatch(home, /getPickingOverview|pendingBoxCount|箱待拣/) assert.doesNotMatch(home, /BottomNav|status-strip|task-card/) - for (const text of ['业务模块', '业务功能', '第一版本仅开放']) assert.match(module, new RegExp(text)) + for (const text of ['业务模块', '业务功能', '一件代发清点组托', '集装箱拣货']) assert.match(module, new RegExp(text)) assert.match(home, /:disabled="!operation\.available"/) assert.match(home, /home-operation-disabled/) assert.match(home, /home-operation-meta home-operation-pending/) @@ -106,7 +107,7 @@ test('workbench, module, history and profile are independent sources', async () test('demo model contains all four modules and picking belongs to outbound', async () => { const data = await read('../data/demo-data.js') - for (const text of ['入库', '出库', '库存', '查询', '集装箱拣货']) assert.match(data, new RegExp(text)) + for (const text of ['入库', '出库', '库存', '查询', '集装箱拣货', '一件代发清点组托']) assert.match(data, new RegExp(text)) assert.match(data, /key: 'outbound'[\s\S]*?name: '集装箱拣货'[\s\S]*?available: true/) for (const boxNo of ['CBX-20260709-001', 'CBX-20260709-002', 'CBX-20260709-003']) assert.match(data, new RegExp(boxNo)) }) diff --git a/utils/dropship-palletizing-state.js b/utils/dropship-palletizing-state.js new file mode 100644 index 0000000..7b09a21 --- /dev/null +++ b/utils/dropship-palletizing-state.js @@ -0,0 +1,40 @@ +export const DROPSHIP_SCAN_INTENT = Object.freeze({ + BLOCKED: 'BLOCKED', + INVALID: 'INVALID', + DUPLICATE: 'DUPLICATE', + RESOLVE_CARTON: 'RESOLVE_CARTON', + SELECT_PALLET: 'SELECT_PALLET', + BIND_PENDING: 'BIND_PENDING' +}) + +export const PALLET_NO_PATTERN = /^DP-[0-9]{6}-[0-9]{4}$/ + +function normalize(value) { + return value == null ? '' : String(value).trim().toUpperCase() +} + +export function createDropshipScanState(value = {}) { + return { + online: value.online !== false, + activePalletNo: normalize(value.activePalletNo), + pendingBoxCode: normalize(value.pendingBoxCode), + lastScanCode: normalize(value.lastScanCode) + } +} + +export function planDropshipScan(currentState, rawCode, { unboundCartons = 0 } = {}) { + const state = createDropshipScanState(currentState) + const scanCode = normalize(rawCode) + if (!state.online) return { state, intent: DROPSHIP_SCAN_INTENT.BLOCKED, message: '当前网络不可用,不能执行正式扫码' } + if (!scanCode) return { state, intent: DROPSHIP_SCAN_INTENT.INVALID, message: '扫码内容不能为空' } + if (scanCode === state.lastScanCode) return { state, intent: DROPSHIP_SCAN_INTENT.DUPLICATE, message: '请勿重复扫描同一码' } + if (PALLET_NO_PATTERN.test(scanCode)) { + const next = { ...state, activePalletNo: scanCode, lastScanCode: scanCode } + return { state: next, intent: Number(unboundCartons) > 0 ? DROPSHIP_SCAN_INTENT.BIND_PENDING : DROPSHIP_SCAN_INTENT.SELECT_PALLET, palletNo: scanCode } + } + return { state: { ...state, pendingBoxCode: scanCode, lastScanCode: scanCode }, intent: DROPSHIP_SCAN_INTENT.RESOLVE_CARTON, boxCode: scanCode, palletNo: state.activePalletNo || null } +} + +export function releaseDropshipScan(state, { clearBox = false } = {}) { + return { ...createDropshipScanState(state), pendingBoxCode: clearBox ? '' : state.pendingBoxCode, lastScanCode: '' } +} diff --git a/utils/user-facing.js b/utils/user-facing.js index d4c19f3..57304f1 100644 --- a/utils/user-facing.js +++ b/utils/user-facing.js @@ -1,4 +1,4 @@ -const INTERNAL_ID_FIELD_PATTERN = /(?:taskId|inboundId|orderId|loadingPlanId|containerBookingId|任务ID|入库单ID|订单ID|装柜计划ID|柜次ID)\s*[:=:]?\s*[A-Za-z0-9_-]*/gi +const INTERNAL_ID_FIELD_PATTERN = /(?:taskId|inboundId|orderId|loadingPlanId|containerBookingId|cartonId|palletId|warehouseId|任务ID|入库单ID|订单ID|装柜计划ID|柜次ID|箱明细ID|托盘ID|仓库ID)\s*[:=:]?\s*[A-Za-z0-9_-]*/gi const LONG_INTERNAL_NUMBER_PATTERN = /(^|[^A-Za-z0-9])\d{18,20}(?=$|[^A-Za-z0-9])/g export function displayBusinessValue(value, missing = '—') { From b19cecb54f39b01e54b40879e8c8d55a487529ca Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 12:29:46 +0800 Subject: [PATCH 68/98] fix(pda): stage sealed pallet scans for atomic batch picking --- AGENTS.md | 2 + api/pda-dropship.js | 6 +++ api/pda-loading.js | 1 + .../dropship-palletizing.vue | 19 +++++++-- pages/picking/picking.vue | 41 ++++++++++++++++--- tests/dropship-palletizing-page.test.mjs | 24 +++++++++++ tests/pda-loading-api.test.mjs | 32 +++++++++++++++ tests/picking-evidence-stage.test.mjs | 2 +- tests/picking-state.test.mjs | 14 +++++++ utils/picking-state.js | 6 ++- 10 files changed, 136 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 28343fd..a5b6e2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 编号并原子绑定当前全部待绑箱,无待绑箱不建空托;失败重试复用幂等键,成功后选中新托盘。此处是独立清点确认命令,不改变装柜拣货扫描仅记录草稿的原则。 ## 设备、安全与可用性 diff --git a/api/pda-dropship.js b/api/pda-dropship.js index 609e061..c178fe2 100644 --- a/api/pda-dropship.js +++ b/api/pda-dropship.js @@ -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) diff --git a/api/pda-loading.js b/api/pda-loading.js index 4983efb..e101fa4 100644 --- a/api/pda-loading.js +++ b/api/pda-loading.js @@ -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, diff --git a/pages/dropship-palletizing/dropship-palletizing.vue b/pages/dropship-palletizing/dropship-palletizing.vue index ba30bf1..dcf5937 100644 --- a/pages/dropship-palletizing/dropship-palletizing.vue +++ b/pages/dropship-palletizing/dropship-palletizing.vue @@ -21,8 +21,8 @@ 实收与应收不一致,请确认接受差异并填写处理结果。