381 lines
18 KiB
JavaScript
381 lines
18 KiB
JavaScript
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 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)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
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(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,
|
|
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, 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,
|
|
displayNo: row.orderNo ?? row.inboundCode ?? row.order?.orderNo ?? '业务单号待同步'
|
|
}
|
|
}
|
|
|
|
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,
|
|
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))}`,
|
|
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 getEvidencePreviewUrl(inboundId, evidenceId, tenantId) {
|
|
if (!inboundId || !evidenceId) return ''
|
|
return `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/loading/evidence/${encodeURIComponent(evidenceId)}/content/pda?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`
|
|
}
|
|
|
|
export function downloadEvidencePreview(previewUrl) {
|
|
requireOnline('当前网络不可用,无法查看装柜照片')
|
|
if (!previewUrl) throw new Error('装柜照片地址尚未同步')
|
|
const baseUrl = session.apiBase || '/api/platform/v1'
|
|
let url = String(previewUrl)
|
|
if (!/^https?:\/\//i.test(url) && /^https?:\/\//i.test(baseUrl)) {
|
|
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) {
|
|
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
|
|
reject(error)
|
|
return
|
|
}
|
|
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
|
|
reject(error)
|
|
return
|
|
}
|
|
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)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
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
|
|
debugLog('evidence.upload.start', { inboundId, evidenceType, tenantId: tenant, hasLocalPath: Boolean(localPath) })
|
|
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)
|
|
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))
|
|
})
|
|
}
|
|
|
|
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
|
|
})
|
|
}
|
|
|
|
/** 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 }
|