import { session } from '@/stores/session.js' import { requireOnline } from '@/utils/network.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: '带锁码照' } ]) // 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) { 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) } }) }) } function idempotencyKey(operation, inboundId) { return `pda-${operation}-${inboundId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` } 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) } 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 getContainerPickingTask(containerNo, warehouseId) { if (!containerNo) throw new Error('缺少柜次号,无法请求现场任务') const query = [`containerNo=${encodeURIComponent(containerNo)}`] 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 }) } 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}` } }) } 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 } = {}) { 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 (!task.containerNo) throw new Error('缺少柜号,无法整批提交') const items = (actions || []).map((action) => ({ itemId: action.itemId, action: action.type, pickedPackageCount: action.type === 'PICK' ? action.pickedPackageCount : null, finalPickedPackageCount: action.type === 'PARTIAL' ? action.finalPickedPackageCount : null, releaseMode: action.type === 'SKIP' ? null : (action.releaseMode || 'AUTO'), locationIds: action.type === 'SKIP' ? [] : (action.locationIds || []), 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, containerNo: task.containerNo, expectedVersion, items } }) } 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, 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 } } 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/pda?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/pda?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`, method: 'POST', header: { 'Idempotency-Key': stableIdempotencyKey(payload.operationId || `evidence-${inboundId}-${payload.evidenceType}-${payload.fileId || 'seal'}`) }, data: payload }) } export function voidEvidence(inboundId, evidenceId, payload = {}, tenantId) { if (!inboundId || !evidenceId) throw new Error('缺少装柜证据标识,无法作废') if (!String(payload.reason || '').trim()) throw new Error('作废原因不能为空') return request({ url: `/inbounds/${encodeURIComponent(inboundId)}/loading/evidence/${encodeURIComponent(evidenceId)}/void/pda?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`, method: 'POST', header: { 'Idempotency-Key': stableIdempotencyKey(payload.operationId || `void-evidence-${inboundId}-${evidenceId}-${payload.expectedVersion ?? 0}`) }, data: payload }) } /** Upload a local photo to the file service and return its durable fileId. */ export function uploadEvidenceFile(inboundId, evidenceType, localPath, tenantId, onProgress) { requireOnline('当前网络不可用,无法上传装柜照片') if (!EVIDENCE_TYPES.includes(evidenceType)) throw new Error('不支持的装柜证据类型') 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 return new Promise((resolve, reject) => { const task = uni.uploadFile({ url: `${baseUrl}/files/loading-evidence/${encodeURIComponent(tenant)}/${encodeURIComponent(inboundId)}?evidenceType=${encodeURIComponent(evidenceType)}`, filePath: localPath, name: 'file', header: headers, success(response) { let body = response.data try { body = typeof body === 'string' ? JSON.parse(body) : body } catch (_) { /* handled below */ } const code = String(body?.code ?? response.statusCode) if (code !== '200') { const error = new Error(body?.message || '文件上传失败'); error.code = body?.code || response.statusCode; reject(error); return } resolve(body?.data || body) }, fail(error) { 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)) }) } 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_SLOTS, EVIDENCE_TYPES }