import { session } from '@/stores/session.js' import { requireOnline } from '@/utils/network.js' import { apiDownload, apiRequest, apiUpload } from '@/api/http.js' import { resolveEvidencePreviewUrl } from '@/utils/api-url.js' export { resolveEvidencePreviewUrl } from '@/utils/api-url.js' const EVIDENCE_TYPES = Object.freeze([ 'PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL' ]) // Keep the confirmed business labels in the PDA while the server retains its // legacy evidence codes. Remove this mapping only after the backend contract is migrated. const EVIDENCE_SLOTS = Object.freeze([ { type: 'PHOTO_STEP_1', label: '空仓照片' }, { type: 'PHOTO_STEP_2', label: '半仓照片' }, { type: 'PHOTO_STEP_3', label: '满仓照片' }, { type: 'PHOTO_STEP_4', label: '锁仓照片' }, { type: 'SEAL', label: '带锁码照' } ]) // Picking and cabinet evidence are both scoped by the canonical PDA task. The // task warehouse is always sent as an authorization boundary and never guessed. function debugLog(event, payload = {}) { try { console.info(`[PDA] ${event}`, payload) } catch (_) { /* console may be unavailable on a device */ } } const request = apiRequest function stableIdempotencyKey(operationId, suffix = '') { const raw = `pda-${operationId}${suffix ? `-${suffix}` : ''}` let hash = 2166136261 for (let index = 0; index < raw.length; index += 1) { hash ^= raw.charCodeAt(index) hash = Math.imul(hash, 16777619) } const prefix = raw.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 96) return `${prefix}-${(hash >>> 0).toString(36)}-${raw.length}`.slice(0, 128) } export function isRemoteReady(inboundId) { return Boolean(session.token && (session.tenantId || inboundId)) } export function getContainerPickingTask(identity, warehouseId) { const selector = typeof identity === 'object' && identity !== null ? identity : {} const taskId = selector.taskId ?? selector.id if (!taskId) throw new Error('当前没有可进入的拣货任务,请返回任务列表刷新') if (!warehouseId) throw new Error('缺少作业仓库标识,无法请求现场任务') return request({ url: `/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}?warehouseId=${encodeURIComponent(warehouseId)}`, method: 'GET' }).then(normalizePickingTask) } export function claimContainerPickingTask(taskId, expectedAssignmentVersion, operationId) { if (!taskId) throw new Error('缺少拣货任务标识,无法开始作业') return request({ url: `/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/claim`, method: 'POST', header: { 'Idempotency-Key': stableIdempotencyKey(operationId || `claim-${taskId}`) }, data: { expectedAssignmentVersion, operationId: operationId || `pda-claim-${taskId}` } }) } 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' }).then(normalizeInventorySnapshot) } 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('缺少拣货任务或明细标识,无法提交') return request({ url: `/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/items/${encodeURIComponent(itemId)}/${path}`, method: 'POST', header: { 'Idempotency-Key': stableIdempotencyKey(operationId || `${path}-${taskId}-${itemId}`) }, data: payload }) } export function pickContainerItem(taskId, itemId, payload = {}) { return itemAction(taskId, itemId, 'pick', payload, payload.operationId) } export function partialContainerItem(taskId, itemId, payload = {}) { return itemAction(taskId, itemId, 'partial', payload, payload.operationId) } export function skipContainerItem(taskId, itemId, payload = {}) { return itemAction(taskId, itemId, 'skip', payload, payload.operationId) } export async function submitContainerPickingBatch(task, actions, { warehouseId, operationId, inventorySnapshotToken } = {}) { 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('缺少实时库存快照,请刷新后重试') 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, reason: action.type === 'SKIP' ? action.reason : null })) return request({ url: `/first-leg/pda-loading/tasks/${encodeURIComponent(task.taskId)}/batch`, method: 'POST', header: { 'Idempotency-Key': stableIdempotencyKey(submitOperationId) }, data: { warehouseId, loadingPlanId: task.loadingPlanId, containerBookingId: task.containerBookingId, expectedVersion, inventorySnapshotToken, items } }) } export function listLoadingOrders({ page = 1, size = 50, keyword = '' } = {}) { const query = [] if (keyword) query.push(`keyword=${encodeURIComponent(keyword)}`) 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) })) } export function normalizePdaOrder(row = {}) { row = row || {} return { ...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, loadingPlanId: row.loadingPlanId ?? row.loading?.loadingPlanId ?? row.loadingPlan?.id ?? null, containerBookingId: row.containerBookingId ?? row.loading?.containerBookingId ?? row.container?.id ?? null, sourceType: row.sourceType ?? row.container?.sourceType ?? null, recordStatus: row.recordStatus ?? row.container?.recordStatus ?? null, mergedIntoContainerBookingId: row.mergedIntoContainerBookingId ?? row.container?.mergedIntoContainerBookingId ?? 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, warehouseName: row.warehouseName ?? row.warehouse?.name ?? row.loadingPlan?.warehouseName ?? '', orderNo: row.orderNo ?? row.order?.orderNo ?? '', inboundCode: row.inboundCode ?? row.inbound?.inboundCode ?? '', displayNo: row.orderNo ?? row.order?.orderNo ?? row.inboundCode ?? row.inbound?.inboundCode ?? '' } } export function normalizePickingTask(task = {}) { task = task || {} return { ...task, taskId: task.taskId ?? task.id ?? null, loadingPlanId: task.loadingPlanId ?? task.loading?.loadingPlanId ?? null, containerBookingId: task.containerBookingId ?? task.loading?.containerBookingId ?? null, sourceType: task.sourceType ?? task.container?.sourceType ?? null, recordStatus: task.recordStatus ?? task.container?.recordStatus ?? null, mergedIntoContainerBookingId: task.mergedIntoContainerBookingId ?? task.container?.mergedIntoContainerBookingId ?? null, cabinetNo: task.cabinetNo ?? task.loading?.cabinetNo ?? task.containerNo ?? '', currentContainerNo: task.currentContainerNo ?? task.containerNo ?? null, orderNo: task.orderNo ?? task.order?.orderNo ?? '', inboundCode: task.inboundCode ?? task.inbound?.inboundCode ?? '', warehouseName: task.warehouseName ?? task.warehouse?.name ?? '', pickingVersion: task.pickingVersion ?? task.version ?? null, inboundVersion: task.inboundVersion ?? null } } export function normalizeInventorySnapshot(snapshot = {}) { return normalizePickingTask({ ...snapshot, version: snapshot.taskVersion ?? snapshot.version }) } export function getEvidence(taskId, warehouseId) { if (!taskId) throw new Error('缺少装柜任务标识,无法加载装柜证据') if (!warehouseId) throw new Error('缺少任务仓库标识,无法加载装柜证据') return request({ url: `/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence?warehouseId=${encodeURIComponent(warehouseId)}`, method: 'GET' }) } export function getEvidencePreviewUrl(taskId, evidenceId, warehouseId) { if (!taskId || !evidenceId || !warehouseId) return '' return `/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence/${encodeURIComponent(evidenceId)}/content?warehouseId=${encodeURIComponent(warehouseId)}` } export function downloadEvidencePreview(previewUrl) { requireOnline('当前网络不可用,无法查看装柜照片') if (!previewUrl) throw new Error('装柜照片地址尚未同步') const url = resolveEvidencePreviewUrl(previewUrl, session.apiBase || '/api/platform/v1') debugLog('evidence.preview.start', { previewPath: String(previewUrl), resolvedUrl: url }) 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'] || '' const traceId = responseHeaders['X-Trace-Id'] || responseHeaders['x-trace-id'] || '' debugLog('evidence.preview.response', { previewPath: String(previewUrl), statusCode: response.statusCode, contentType, hasTempFilePath: Boolean(response.tempFilePath), requestId, traceId }) if (response.statusCode < 200 || response.statusCode >= 300 || !response.tempFilePath) { const error = new Error('装柜照片下载失败') error.statusCode = response.statusCode error.requestId = requestId error.traceId = traceId throw error } if (contentType && !/^image\//i.test(contentType) && !/^application\/octet-stream/i.test(contentType)) { const error = new Error('服务端未返回有效照片') error.statusCode = response.statusCode error.contentType = contentType error.requestId = requestId error.traceId = traceId throw error } return response.tempFilePath }).catch((error) => { debugLog('evidence.preview.error', { previewPath: String(previewUrl), message: error.message || error.errMsg || '' }) throw error }) } export function voidEvidence(taskId, evidenceId, warehouseId, payload = {}) { if (!taskId || !evidenceId) throw new Error('缺少装柜任务或证据标识,无法作废') if (!warehouseId) throw new Error('缺少任务仓库标识,无法作废装柜证据') if (!String(payload.reason || '').trim()) throw new Error('作废原因不能为空') return request({ url: `/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence/${encodeURIComponent(evidenceId)}/void`, method: 'POST', data: { warehouseId, expectedVersion: payload.expectedVersion ?? 0, reason: String(payload.reason).trim() } }) } /** Upload a local photo to the file service and return its durable fileId. */ export function uploadEvidenceFile(taskId, warehouseId, payload, localPath, onProgress) { requireOnline('当前网络不可用,无法上传装柜照片') if (!taskId) throw new Error('缺少装柜任务标识,无法上传装柜照片') if (!warehouseId) throw new Error('缺少任务仓库标识,无法上传装柜照片') const evidenceType = payload?.evidenceType if (!EVIDENCE_TYPES.includes(evidenceType)) throw new Error('不支持的装柜证据类型') if (!localPath) throw new Error('缺少待上传的照片') if (payload.expectedVersion == null) throw new Error('缺少装柜证据版本,无法上传装柜照片') if (evidenceType === 'SEAL' && !String(payload.sealNo || '').trim()) throw new Error('带锁码照必须填写封签号') const baseUrl = session.apiBase || '/api/platform/v1' debugLog('evidence.upload.start', { taskId, evidenceType, hasLocalPath: Boolean(localPath) }) const query = [ `warehouseId=${encodeURIComponent(warehouseId)}`, `expectedVersion=${encodeURIComponent(payload.expectedVersion)}`, `evidenceType=${encodeURIComponent(evidenceType)}` ] if (payload.sealNo) query.push(`sealNo=${encodeURIComponent(String(payload.sealNo).trim())}`) if (payload.remark) query.push(`remark=${encodeURIComponent(payload.remark)}`) return apiUpload({ url: `${baseUrl}/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence?${query.join('&')}`, filePath: localPath, name: 'file', header: { 'Idempotency-Key': stableIdempotencyKey(payload.operationId || `evidence-${taskId}-${evidenceType}-${payload.expectedVersion}`) } }, 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', { taskId, 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 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 || body }).catch((error) => { debugLog('evidence.upload.error', { taskId, evidenceType, message: error.message || error.errMsg || '' }); throw error }) } /** Complete the cabinet PDA task after the operator has captured all evidence. */ export function completeContainerPickingTask(taskId, payload = {}) { if (!taskId) throw new Error('缺少拣货任务标识,无法确认装柜完成') if (payload.expectedTaskVersion == null) throw new Error('缺少拣货任务版本,无法确认装柜完成') if (!payload.operationId) throw new Error('缺少操作标识,无法确认装柜完成') return request({ url: `/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/complete`, method: 'POST', header: { 'Idempotency-Key': stableIdempotencyKey(payload.operationId) }, data: payload }) } export { EVIDENCE_SLOTS, EVIDENCE_TYPES }