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}` +}