diff --git a/AGENTS.md b/AGENTS.md
index cc85b95..e25a67b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -18,7 +18,7 @@
- 明确区分整单拣货、部分拣货、异常挂起和完成提交;关键状态转换需服务端确认,不能只依赖本地页面状态。
- 作业提交应具备幂等标识,防止弱网、重复点击或重放造成重复扣减与重复完成。
- 离线队列只有在业务批准后才能实现;队列必须可观察、可重试、可取消,并能识别冲突而不是静默覆盖服务端状态。
-- 与管理后台头程订单、仓库任务和 `services/api` 对齐稳定标识、状态机、时间和异常码,禁止在前端自行发明另一套业务状态。
+- 与管理后台头程订单、仓库任务和 `baiyue-firstleg` Controller/OpenAPI 对齐稳定标识、状态机、时间和异常码,禁止在前端自行发明另一套业务状态。
## 设备、安全与可用性
diff --git a/api/pda-loading.js b/api/pda-loading.js
new file mode 100644
index 0000000..06699b1
--- /dev/null
+++ b/api/pda-loading.js
@@ -0,0 +1,137 @@
+import { session } from '@/stores/session.js'
+
+const EVIDENCE_TYPES = Object.freeze([
+ 'PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'
+])
+
+// C02 supplies inboundId/inboundVersion and loading.pickingVersion. C04 supplies
+// inbound-scoped task and evidence operations. No local task is promoted to a
+// server success when any required identifier or version is absent.
+
+function request(options) {
+ 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
+ reject(error)
+ return
+ }
+ resolve(body.data)
+ },
+ fail(error) {
+ const next = new Error(error.errMsg || '网络连接失败')
+ next.network = true
+ reject(next)
+ }
+ })
+ })
+}
+
+function idempotencyKey(operation, inboundId) {
+ return `pda-${operation}-${inboundId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
+}
+
+function tenantQuery(tenantId) {
+ const value = tenantId ?? session.tenantId
+ if (!value) throw new Error('缺少客户隔离标识 tenantId,无法请求现场任务')
+ return String(value)
+}
+
+export function isRemoteReady(inboundId) {
+ return Boolean(session.token && session.tenantId && inboundId)
+}
+
+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)}`)
+ 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)
+ }))
+}
+
+export function normalizePdaOrder(row = {}) {
+ row = row || {}
+ return {
+ ...row,
+ inboundId: row.inboundId ?? row.inbound?.id ?? null,
+ inboundVersion: row.inboundVersion ?? row.inbound?.version ?? null,
+ pickingVersion: row.pickingVersion ?? row.loading?.pickingVersion ?? null
+ }
+}
+
+export function normalizePickingTask(task = {}) {
+ task = task || {}
+ return {
+ ...task,
+ pickingVersion: task.pickingVersion ?? task.version ?? null,
+ inboundVersion: task.inboundVersion ?? null
+ }
+}
+
+export function getPickingTask(inboundId, tenantId) {
+ return request({
+ url: `/inbounds/${encodeURIComponent(inboundId)}/loading/picking?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`,
+ method: 'GET'
+ })
+}
+
+export function startPicking(inboundId, payload, tenantId) {
+ return request({
+ url: `/inbounds/${encodeURIComponent(inboundId)}/loading/picking/start?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`,
+ method: 'POST',
+ header: { 'Idempotency-Key': idempotencyKey('start-picking', inboundId) },
+ data: payload
+ })
+}
+
+export function completePicking(inboundId, payload, tenantId) {
+ return request({
+ url: `/inbounds/${encodeURIComponent(inboundId)}/loading/picking/complete?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`,
+ method: 'POST',
+ header: { 'Idempotency-Key': idempotencyKey('complete-picking', inboundId) },
+ data: payload
+ })
+}
+
+export function getEvidence(inboundId, tenantId) {
+ return request({
+ url: `/inbounds/${encodeURIComponent(inboundId)}/loading/evidence?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`,
+ method: 'GET'
+ })
+}
+
+export function upsertEvidence(inboundId, payload, tenantId) {
+ if (!EVIDENCE_TYPES.includes(payload.evidenceType)) throw new Error('不支持的装柜证据类型')
+ return request({
+ url: `/inbounds/${encodeURIComponent(inboundId)}/loading/evidence?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`,
+ method: 'POST',
+ header: { 'Idempotency-Key': idempotencyKey(`evidence-${payload.evidenceType}`, inboundId) },
+ data: payload
+ })
+}
+
+export function completeLoading(inboundId, payload, tenantId) {
+ return request({
+ url: `/inbounds/${encodeURIComponent(inboundId)}/loading/complete?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`,
+ method: 'POST',
+ header: { 'Idempotency-Key': idempotencyKey('complete-loading', inboundId) },
+ data: payload
+ })
+}
+
+export { EVIDENCE_TYPES }
diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue
index 8b74163..cf1c130 100644
--- a/pages/picking/picking.vue
+++ b/pages/picking/picking.vue
@@ -117,13 +117,34 @@
+
+
+ 装柜证据四步照片与封签可重复修改,不阻断作业
+ {{ remoteSyncState.label }}
+
+
+
+ {{ slot.label }}
+ {{ evidenceEntry(slot.type).fileName || '已记录' }}
+
+
+
+
+
+
+
+ {{ remoteError }}
+
+ 等待统一订单详情提供装柜版本号后可提交完成
+
+
{{ summary.pending }} 件待拣
{{ submitStatusHint }}
-
@@ -296,6 +317,7 @@
import { navigateBackOr } from '@/utils/navigation.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
+ import { completeLoading, completePicking, getEvidence, getPickingTask, isRemoteReady, normalizePickingTask, upsertEvidence } from '@/api/pda-loading.js'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import PopupHeader from '@/components/PopupHeader.vue'
@@ -320,6 +342,12 @@
switchTargetIndex: null,
selectedIncompleteReason: '',
completionSummary: { picked: 0, partial: 0, skipped: 0, autoSkipped: 0 },
+ evidenceSlots: [
+ { type: 'PHOTO_STEP_1', label: '装柜照片 1' }, { type: 'PHOTO_STEP_2', label: '装柜照片 2' },
+ { type: 'PHOTO_STEP_3', label: '装柜照片 3' }, { type: 'PHOTO_STEP_4', label: '装柜照片 4' }
+ ],
+ evidenceFiles: {}, sealNo: '', loadingVersion: null, pickingVersion: null, remotePick: null,
+ remoteError: '', remoteLoading: false, submitting: false,
toast: { visible: false, message: '', type: '' }
}
},
@@ -394,12 +422,20 @@
.filter((entry) => this.boxListType === 'done'
? Boolean(this.completedBoxes[entry.index])
: !this.completedBoxes[entry.index])
+ },
+ remoteSyncState() {
+ if (!this.currentBox.inboundId) return { label: '演示任务', tone: 'local' }
+ if (!isRemoteReady(this.currentBox.inboundId)) 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' }
}
},
onLoad() { requireSession() },
onShow() {
if (!requireSession()) return
this.startHardwareScanner()
+ this.syncRemoteTask()
},
onHide() { this.stopHardwareScanner() },
onUnload() { this.stopHardwareScanner(); this.persistSession(); this.clearTimers() },
@@ -550,6 +586,50 @@
playSuccessSound()
this.pickItem(result.item.id, 'scan')
},
+ evidenceEntry(type) { return this.evidenceFiles[type] || null },
+ async syncRemoteTask() {
+ if (!isRemoteReady(this.currentBox.inboundId)) return
+ this.remoteLoading = true
+ this.remoteError = ''
+ try {
+ this.remotePick = normalizePickingTask(await getPickingTask(this.currentBox.inboundId))
+ this.pickingVersion = this.currentBox.pickingVersion ?? this.remotePick.pickingVersion
+ this.loadingVersion = this.currentBox.inboundVersion ?? this.remotePick.inboundVersion ?? null
+ const evidence = await getEvidence(this.currentBox.inboundId)
+ this.evidenceFiles = (evidence && evidence.evidences ? evidence.evidences : []).reduce((all, item) => { all[item.evidenceType] = item; return all }, {})
+ const seal = this.evidenceFiles.SEAL
+ if (seal) this.sealNo = seal.sealNo || ''
+ } catch (error) {
+ this.remoteError = error.network ? '网络不可用,当前保留本地草稿,恢复网络后可重试' : (error.message || '任务同步失败')
+ } finally { this.remoteLoading = false }
+ },
+ chooseEvidence(slot) {
+ if (typeof uni.chooseImage !== 'function') { this.showToast('当前环境不支持选择照片', 'warning'); return }
+ uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['camera', 'album'], success: (result) => {
+ const path = result.tempFilePaths && result.tempFilePaths[0]
+ if (!path) return
+ this.evidenceFiles = { ...this.evidenceFiles, [slot.type]: { ...(this.evidenceFiles[slot.type] || {}), evidenceType: slot.type, fileName: path, localPath: path } }
+ this.saveEvidence(slot.type)
+ }, fail: () => this.showToast('未选择照片,可稍后重试', 'warning') })
+ },
+ async saveEvidence(type) {
+ if (!this.currentBox.inboundId) { this.showToast('演示任务已记录本地证据', 'success'); return }
+ if (!isRemoteReady(this.currentBox.inboundId)) { this.showToast('当前会话未连接服务端,证据保留本地', 'warning'); return }
+ const current = this.evidenceFiles[type] || {}
+ const payload = { expectedVersion: current.version || 0, evidenceType: type, fileId: current.fileId || null, fileName: current.fileName || null, sealNo: type === 'SEAL' ? this.sealNo : null, remark: null }
+ try {
+ const result = await upsertEvidence(this.currentBox.inboundId, payload)
+ this.evidenceFiles = (result && result.evidences ? result.evidences : []).reduce((all, item) => { all[item.evidenceType] = item; return all }, this.evidenceFiles)
+ this.showToast(`${type === 'SEAL' ? '封签' : '照片'}已保存`, 'success')
+ } catch (error) { this.remoteError = error.network ? '网络失败,证据未提交,请重试' : (error.message || '证据保存失败'); this.showToast('证据保存失败', 'error') }
+ },
+ async completeLoadingTask() {
+ if (!isRemoteReady(this.currentBox.inboundId) || this.loadingVersion === null) return
+ this.submitting = true
+ try { await completeLoading(this.currentBox.inboundId, { expectedVersion: this.loadingVersion, remark: 'PDA确认装柜完成' }); this.showToast('已确认装柜完成', 'success') }
+ catch (error) { this.remoteError = error.network ? '网络失败,装柜完成未提交' : (error.message || '装柜完成失败'); this.showToast('装柜完成失败', 'error') }
+ finally { this.submitting = false }
+ },
pickItem(id, source = 'manual') {
const item = this.currentBox.items.find((entry) => entry.id === id)
this.itemStates = { ...this.itemStates, [id]: ITEM_STATUS.PICKED }
@@ -648,8 +728,32 @@
if (!this.selectedIncompleteReason) return
this.performSubmit(this.selectedIncompleteReason)
},
- performSubmit(incompleteReason = '') {
+ async performSubmit(incompleteReason = '') {
const beforeSubmit = this.summary
+ if (this.currentBox.inboundId && !isRemoteReady(this.currentBox.inboundId)) {
+ this.remoteError = '缺少服务端会话或 tenantId,未提交拣货完成'
+ this.showToast('拣货完成未提交', 'error')
+ return
+ }
+ if (this.currentBox.inboundId && isRemoteReady(this.currentBox.inboundId)) {
+ this.submitting = true
+ try {
+ if (this.pickingVersion === null) throw new Error('缺少拣货版本号 pickingVersion,无法提交')
+ const expectedCount = this.currentBox.items.reduce((total, item) => total + Number(item.qty || 0), 0)
+ const pickedCount = this.currentBox.items.reduce((total, item) => {
+ const status = this.itemState(item)
+ return total + (status === ITEM_STATUS.PICKED ? Number(item.qty || 0) : status === ITEM_STATUS.PARTIAL ? Number(this.partialQuantities[item.id] || 0) : 0)
+ }, 0)
+ this.remotePick = await completePicking(this.currentBox.inboundId, {
+ expectedVersion: Number(this.pickingVersion),
+ expectedCount, pickedCount, remark: incompleteReason || null
+ })
+ } catch (error) {
+ this.remoteError = error.network ? '网络失败,拣货完成未提交,请重试' : (error.message || '拣货完成失败')
+ this.showToast('拣货完成未提交', 'error')
+ return
+ } finally { this.submitting = false }
+ }
const result = submitStates(this.currentBox.items, this.itemStates)
this.itemStates = result.states
this.currentBox.items.forEach((item) => {
diff --git a/stores/session.js b/stores/session.js
index 46b2861..8151d0c 100644
--- a/stores/session.js
+++ b/stores/session.js
@@ -1,15 +1,21 @@
import { reactive } from 'vue'
import { resetPickingSession } from '@/stores/picking.js'
-export const session = reactive({ currentUser: null })
+export const session = reactive({ currentUser: null, token: '', tenantId: '', apiBase: '' })
-export function signIn(account, warehouse) {
+export function signIn(account, warehouse, options = {}) {
session.currentUser = { id: account.id, name: account.name, role: account.role, warehouse }
+ session.token = options.token || account.token || ''
+ session.tenantId = options.tenantId || account.tenantId || ''
+ session.apiBase = options.apiBase || account.apiBase || ''
return session.currentUser
}
export function signOut() {
session.currentUser = null
+ session.token = ''
+ session.tenantId = ''
+ session.apiBase = ''
resetPickingSession()
}
diff --git a/styles/pda.scss b/styles/pda.scss
index 47e65bd..e9cd56c 100644
--- a/styles/pda.scss
+++ b/styles/pda.scss
@@ -1082,6 +1082,35 @@
.gray { color: $skip; }
.red { color: $danger; }
+ .loading-evidence-section {
+ margin: 8px 12px 92px;
+ padding: 14px;
+ border: 1px solid #d8e4f4;
+ border-radius: 10px;
+ background: $card;
+ box-shadow: $shadow;
+ }
+ .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; }
+ .section-subtitle { display: block; margin-top: 4px; color: $text-secondary; font-size: 11px; line-height: 17px; }
+ .sync-state { flex-shrink: 0; padding: 4px 7px; border-radius: 99px; font-size: 11px; background: #eef2f7; }
+ .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-label { display: block; color: $text; font-size: 12px; font-weight: 700; }
+ .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-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; }
+ .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; }
+ .seal-btn { flex-shrink: 0; margin-top: 0; }
+ .remote-error { display: block; margin-top: 9px; color: $danger; font-size: 11px; line-height: 17px; }
+ .loading-complete-btn { width: 100%; min-height: 46px; margin-top: 12px; border-radius: 8px; color: #ffffff; font-size: 13px; font-weight: 700; line-height: 46px; background: $primary; }
+ .loading-complete-btn[disabled] { color: #8c96a3; background: #e5e9ef; }
+ .version-hint { display: block; margin-top: 6px; color: $text-secondary; font-size: 10px; line-height: 15px; text-align: center; }
+
.app-toast {
position: fixed;
z-index: 30;
diff --git a/tests/pda-loading-api.test.mjs b/tests/pda-loading-api.test.mjs
new file mode 100644
index 0000000..2715905
--- /dev/null
+++ b/tests/pda-loading-api.test.mjs
@@ -0,0 +1,43 @@
+import test from 'node:test'
+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 page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
+
+test('PDA loading adapter keeps C04 read and write paths', () => {
+ for (const path of [
+ '/orders?',
+ '/loading/picking?tenantId=',
+ '/loading/picking/start?tenantId=',
+ '/loading/picking/complete?tenantId=',
+ '/loading/evidence?tenantId=',
+ '/loading/complete?tenantId='
+ ]) assert.match(source, new RegExp(path.replace(/[?]/g, '\\?')))
+ assert.match(source, /'Idempotency-Key'/)
+ assert.match(source, /PHOTO_STEP_1[\s\S]*PHOTO_STEP_4[\s\S]*SEAL/)
+})
+
+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(source, /throw new Error\('缺少客户隔离标识 tenantId/)
+})
+
+test('C02 identifiers and separate picking/inbound versions are normalized explicitly', () => {
+ assert.match(source, /inboundId: row\.inboundId/)
+ assert.match(source, /inboundVersion: row\.inboundVersion/)
+ assert.match(source, /pickingVersion: row\.pickingVersion \?\? row\.loading\?\.pickingVersion/)
+ assert.match(source, /pickingVersion: task\.pickingVersion \?\? task\.version/)
+ assert.match(page, /expectedVersion: Number\(this\.pickingVersion\)/)
+ assert.match(page, /this\.loadingVersion = this\.currentBox\.inboundVersion/)
+ assert.match(page, /缺少拣货版本号 pickingVersion/)
+})
+
+test('picking page exposes evidence, retry and remote completion states', () => {
+ for (const text of ['PHOTO_STEP_1', 'PHOTO_STEP_4', 'SEAL', '网络失败', '确认装柜完成', 'loadingVersion']) {
+ assert.match(page, new RegExp(text))
+ }
+ assert.match(page, /submitting \|\| completedBoxes\[currentBoxIndex\]/)
+ assert.match(page, /completePicking\(/)
+})