优化
This commit is contained in:
+45
-49
@@ -18,39 +18,35 @@ function count(value) {
|
||||
return Number.isFinite(number) ? Math.max(0, number) : 0
|
||||
}
|
||||
|
||||
export function requireDispatchVersion(value) {
|
||||
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) return value
|
||||
if (typeof value === 'string' && /^\d+$/.test(value)) return value
|
||||
throw new Error('排车操作版本缺失或无效,请刷新任务')
|
||||
}
|
||||
|
||||
export function dispatchVersionAtLeast(current, confirmed) {
|
||||
const latest = String(requireDispatchVersion(current)).replace(/^0+(?=\d)/, '')
|
||||
const original = String(requireDispatchVersion(confirmed)).replace(/^0+(?=\d)/, '')
|
||||
return latest.length > original.length || latest.length === original.length && latest >= original
|
||||
}
|
||||
|
||||
function dispatchCount(value) {
|
||||
if (typeof value !== 'number' && (typeof value !== 'string' || !/^(?:0|[1-9]\d*)$/.test(value))) throw new Error('排车数量不是有效非负整数,请重新读取任务')
|
||||
const number = typeof value === 'number' ? value : Number(value)
|
||||
if (!Number.isSafeInteger(number) || number < 0) throw new Error('排车数量超出安全整数范围或无效,请重新读取任务')
|
||||
return number
|
||||
}
|
||||
|
||||
export function normalizeDispatchTask(value) {
|
||||
if (!value || !value.dispatchNo || !Array.isArray(value.items)) throw new Error('排车任务返回结构不完整')
|
||||
return {
|
||||
...value,
|
||||
taskNo: String(value?.taskNo || ''),
|
||||
dispatchNo: String(value?.dispatchNo || ''),
|
||||
vehiclePlate: String(value?.vehiclePlate || ''),
|
||||
tripNo: String(value?.tripNo || ''),
|
||||
tripRecordVersion: value?.tripRecordVersion == null ? null : String(value.tripRecordVersion),
|
||||
tripDispatchCount: count(value?.tripDispatchCount),
|
||||
driverWarning: String(value?.driverWarning || ''),
|
||||
status: String(value?.status || ''),
|
||||
canDepart: value?.canDepart === true,
|
||||
canPick: value?.canPick === true,
|
||||
pickBlockReason: String(value?.pickBlockReason || ''),
|
||||
departBlockReason: String(value?.departBlockReason || ''),
|
||||
items: asList(value?.items).map((item) => ({
|
||||
...item,
|
||||
dispatchItemId: String(item?.dispatchItemId || ''),
|
||||
palletNo: String(item?.palletNo || ''),
|
||||
destinationWarehouseCode: String(item?.destinationWarehouseCode || ''),
|
||||
destinationWarehouseName: String(item?.destinationWarehouseName || ''),
|
||||
fbaWarehouseCode: String(item?.fbaWarehouseCode || ''),
|
||||
warehouseRouteLabel: String(item?.warehouseRouteLabel || ''),
|
||||
scheduledPackages: count(item?.scheduledPackages),
|
||||
pickedPackages: count(item?.pickedPackages),
|
||||
returnCartons: asList(item?.returnCartons).map((carton) => ({ ...carton, id: String(carton.id || ''), cartonMark: String(carton.cartonMark || ''), productQuantity: count(carton.productQuantity) })),
|
||||
positions: asList(item?.positions).map((position) => ({
|
||||
...position,
|
||||
id: String(position?.id || ''),
|
||||
availablePackages: count(position?.availablePackages ?? (count(item?.plannedPackages ?? item?.packages ?? item?.packageCount) - count(item?.pickedPackages ?? item?.pickedPackageCount))),
|
||||
allocatedPackages: count(position?.allocatedPackages),
|
||||
pickedPackages: count(position?.pickedPackages)
|
||||
}))
|
||||
...value, taskNo: String(value.taskNo || ''), dispatchNo: String(value.dispatchNo),
|
||||
recordVersion: value.recordVersion, pickingReconciled: value.pickingReconciled === true,
|
||||
actions: Object.fromEntries(Object.entries(value.actions || {}).map(([key, gate]) => [key, { ...gate, allowed: gate?.allowed === true }])),
|
||||
items: value.items.map(item => ({ ...item, dispatchItemId: String(item.dispatchItemId || ''), palletNo: String(item.palletNo || ''),
|
||||
scheduledPackages: dispatchCount(item.scheduledPackages), pickedPackages: dispatchCount(item.pickedPackages),
|
||||
returnCartons: (item.returnCartons || []).map(carton => ({ ...carton, returnCartonId: String(carton.returnCartonId || ''), cartonMark: String(carton.cartonMark || ''), productQuantity: dispatchCount(carton.productQuantity) })),
|
||||
positions: (item.positions || []).map(position => ({ ...position, positionId: String(position.positionId || ''), balanceId: String(position.balanceId || ''), availablePackages: dispatchCount(position.availablePackages), allocatedPackages: dispatchCount(position.allocatedPackages), pickedPackages: dispatchCount(position.pickedPackages) }))
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -83,32 +79,32 @@ export function normalizeOutboundTask(value) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function listDispatchTasks(status = '') {
|
||||
const suffix = status ? `?status=${encoded(status)}` : ''
|
||||
return asList(await overseasRequest('GET', `${DISPATCH_BASE}${suffix}`)).map(normalizeDispatchTask)
|
||||
export async function listDispatchTasks() {
|
||||
const result = await overseasRequest('GET', DISPATCH_BASE)
|
||||
if (!Array.isArray(result)) throw new Error('排车任务列表返回结构无效')
|
||||
return result.map(normalizeDispatchTask)
|
||||
}
|
||||
|
||||
export async function getDispatchTask(dispatchNo) {
|
||||
return normalizeDispatchTask(await overseasRequest('GET', `${DISPATCH_BASE}/${encoded(dispatchNo)}`))
|
||||
}
|
||||
|
||||
export function getDispatchLoading(dispatchNo) {
|
||||
return overseasRequest('GET', `${DISPATCH_BASE}/${encoded(dispatchNo)}/loading`)
|
||||
}
|
||||
export function getDispatchCommand(dispatchNo, key) {
|
||||
return overseasRequest('GET', `${DISPATCH_BASE}/${encoded(dispatchNo)}/commands/${encoded(key)}`)
|
||||
}
|
||||
function dispatchWrite(path, request) {
|
||||
requireDispatchVersion(request?.recordVersion)
|
||||
return overseasRequest('POST', path, request)
|
||||
}
|
||||
export function pickDispatchItem(dispatchNo, dispatchItemId, request) {
|
||||
return overseasRequest('POST', `${DISPATCH_BASE}/${encoded(dispatchNo)}/items/${encoded(dispatchItemId)}/pick`, request)
|
||||
return dispatchWrite(`${DISPATCH_BASE}/${encoded(dispatchNo)}/items/${encoded(dispatchItemId)}/pick`, request)
|
||||
}
|
||||
|
||||
export function completeDispatchPicking(dispatchNo, request) {
|
||||
return overseasRequest('POST', `${DISPATCH_BASE}/${encoded(dispatchNo)}/complete`, request)
|
||||
export function completeDispatchLoading(dispatchNo, request) {
|
||||
return dispatchWrite(`${DISPATCH_BASE}/${encoded(dispatchNo)}/loading-complete`, request)
|
||||
}
|
||||
|
||||
export function departDispatch(dispatchNo, request) {
|
||||
return overseasRequest('POST', `${DISPATCH_BASE}/${encoded(dispatchNo)}/depart`, request)
|
||||
}
|
||||
|
||||
export function tripDepartureRequest(task, idempotencyKey) {
|
||||
if (!task?.tripNo || task.tripRecordVersion == null || !/^\d+$/.test(String(task.tripRecordVersion))) {
|
||||
throw new Error('车次信息不完整,请刷新任务后再发车')
|
||||
}
|
||||
return { recordVersion: String(task.tripRecordVersion), idempotencyKey, remark: '海外 PDA 整车核平后发车' }
|
||||
return dispatchWrite(`${DISPATCH_BASE}/${encoded(dispatchNo)}/depart`, request)
|
||||
}
|
||||
|
||||
export async function listOutboundTasks(status = '') {
|
||||
|
||||
+72
-199
@@ -1,209 +1,82 @@
|
||||
<template>
|
||||
<view v-if="user" class="page-shell portal-page fulfillment-page">
|
||||
<PortalHeader title="排车拣货" :user="user" />
|
||||
<scroll-view class="fulfillment-scroll" scroll-y>
|
||||
<view class="portal-content fulfillment-content">
|
||||
<view class="fulfillment-heading">
|
||||
<text class="fulfillment-title">排车拣货与发车</text>
|
||||
<text class="fulfillment-subtitle">扫描排车单号打开任务,再扫描任务中的托盘码进行拣货</text>
|
||||
</view>
|
||||
<view v-if="current" class="detail-toolbar"><view><text class="detail-kicker">当前任务</text><text class="detail-title">排车拣货详情</text></view><button :disabled="busy" @tap="closeTask">‹ 返回列表</button></view>
|
||||
|
||||
<view class="fulfillment-card">
|
||||
<ScanTargetInput name="dispatch" :model-value="scanCode" label="排车单号 / 托盘码 / 来源箱唛" placeholder="扫描排车单号、托盘码或退货箱唛" :active="true" @update:modelValue="updateScanCode" @submit="handleScan" />
|
||||
<button class="secondary-button" :disabled="!scanCode || loading || busy" @tap="handleScan(scanCode)">{{ current ? '匹配托盘' : '打开排车任务' }}</button>
|
||||
<text class="scan-help">网络中断会保留当前作业,恢复后可重试。</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="fulfillment-state"><text>正在加载排车任务…</text></view>
|
||||
<view v-else-if="error" class="fulfillment-error" role="alert">
|
||||
<text class="state-title">{{ error.message }}</text>
|
||||
<text v-if="error.code">错误码:{{ error.code }}</text>
|
||||
<text v-if="error.requestId">requestId:{{ error.requestId }}</text>
|
||||
<text v-if="error.traceId">traceId:{{ error.traceId }}</text>
|
||||
<text v-if="error.network" class="offline-note">网络连接失败,当前作业已保留,请恢复网络后重试。</text>
|
||||
<button class="secondary-button" :disabled="loading || busy" @tap="retry">重试</button>
|
||||
</view>
|
||||
|
||||
<template v-if="!loading && !current">
|
||||
<view class="list-toolbar"><view><text class="list-title">排车任务</text><text class="list-count">{{ tasks.length }} 个任务</text></view><button :disabled="loading" @tap="loadTasks">刷新</button></view>
|
||||
<view v-if="tasks.length" class="task-list">
|
||||
<button v-for="task in tasks" :key="task.dispatchNo" class="task-card" @tap="openTask(task.dispatchNo)">
|
||||
<view class="task-head"><view class="task-code-row"><text class="business-code">{{ task.dispatchNo || '—' }}</text><text v-if="task.dispatchNo === recentTaskNo" class="recent-badge">上次作业</text></view><text class="status-badge">{{ statusLabel(task.status) }}</text></view>
|
||||
<text class="task-meta">任务号 {{ task.taskNo || '—' }} · {{ task.status === 'DEPARTED' ? '原发车车牌' : '车牌' }} {{ task.vehiclePlate || '—' }}</text>
|
||||
<text class="task-enter">查看任务详情 ›</text>
|
||||
</button>
|
||||
</view>
|
||||
<view v-else-if="!error" class="fulfillment-state"><text class="state-title">暂无排车拣货任务</text><text>可刷新列表,或直接扫描排车单号恢复作业。</text></view>
|
||||
</template>
|
||||
|
||||
<template v-if="current">
|
||||
<DispatchHandoverStatus v-if="departed" :dispatch-no="current.dispatchNo" :record-version="current.tripRecordVersion" />
|
||||
<view class="fulfillment-card summary-card">
|
||||
<view class="task-head"><text class="business-code">{{ current.dispatchNo || '—' }}</text><text class="status-badge">{{ statusLabel(current.status) }}</text></view>
|
||||
<text class="task-meta">任务号 {{ current.taskNo || '—' }} · {{ departed ? '原发车车牌' : '车牌' }} {{ current.vehiclePlate || '—' }}</text>
|
||||
<text v-if="!current.canPick && !pickingCompleted" class="gate-warning">{{ current.pickBlockReason || '请先通过调度审核再拣货' }}</text>
|
||||
<view class="metrics"><view><text class="metric-value">{{ plannedPackages }}</text><text>计划箱数</text></view><view><text class="metric-value success">{{ pickedPackages }}</text><text>已拣箱数</text></view><view><text class="metric-value warning">{{ remainingPackages }}</text><text>待拣箱数</text></view></view>
|
||||
</view>
|
||||
|
||||
<view v-if="current.items.length" class="item-list">
|
||||
<view v-for="item in current.items" :key="item.dispatchItemId" class="fulfillment-card item-card" :class="{ selected: selectedItem?.dispatchItemId === item.dispatchItemId }" @tap="selectPallet(item)">
|
||||
<view class="task-head"><text class="business-code">{{ item.palletNo || '未关联托盘码' }}</text><text>{{ item.pickedPackages }}/{{ item.scheduledPackages }} 箱</text></view>
|
||||
<text class="task-meta">{{ routeLabel(item) }}</text>
|
||||
<text v-for="carton in item.returnCartons" :key="carton.cartonMark" class="task-meta">{{ carton.cartonMark }} · {{ carton.status === 'OUTBOUNDED' ? '已拣货' : '待扫描' }}</text>
|
||||
<view class="progress"><view :style="{ width: `${itemProgress(item)}%` }"></view></view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="fulfillment-state"><text class="state-title">任务没有可拣货明细</text><text>请联系平台确认排车托盘是否已下发。</text></view>
|
||||
|
||||
<view v-if="selectedItem && selectedRemaining > 0" class="fulfillment-card">
|
||||
<text class="card-title">本次拣货:{{ selectedItem.palletNo }}</text>
|
||||
<text v-if="selectedItem.returnCartons.length" class="task-meta">{{ selectedReturnCarton ? `已扫描 ${selectedReturnCarton.cartonMark}` : '请扫描此托盘上的来源箱唛,每次拣货一箱' }}</text>
|
||||
<view v-else class="count-row"><text>拣货箱数</text><input type="number" :value="packages" :placeholder="`请输入 1-${selectedRemaining} 箱`" @input="updatePackages" /><text>最多 {{ selectedRemaining }} 箱</text></view>
|
||||
<button class="primary-button" :disabled="!canPick || busy" @tap="submitPick">{{ busyAction === 'pick' ? '提交中…' : '确认拣货' }}</button>
|
||||
</view>
|
||||
|
||||
<view v-if="canCompleteStage" class="fulfillment-card gate-card">
|
||||
<text class="card-title">完成拣货</text>
|
||||
<text v-if="remainingPackages > 0" class="gate-warning">尚有 {{ remainingPackages }} 箱未拣,不能完成排车拣货。</text>
|
||||
<text v-else class="gate-note">全部计划箱数已拣齐,可以完成任务。</text>
|
||||
<button class="primary-button" :disabled="!canComplete || busy" @tap="completePicking">{{ busyAction === 'complete' ? '完成中…' : '完成排车拣货' }}</button>
|
||||
</view>
|
||||
|
||||
<view v-if="pickingCompleted" class="fulfillment-card gate-card">
|
||||
<text class="card-title">整车发车</text>
|
||||
<text v-if="current.tripNo" class="task-meta">车次 {{ current.tripNo }} · 共 {{ current.tripDispatchCount }} 张排车单</text>
|
||||
<text v-if="current.driverWarning && !departed" class="gate-warning">{{ current.driverWarning }}</text>
|
||||
<text v-if="!allPicked" class="gate-warning">服务端仍显示 {{ remainingPackages }} 箱未拣,不允许发车。</text>
|
||||
<text v-else-if="departed" class="gate-success">该排车任务已执行发车,不可重复提交。</text>
|
||||
<text v-else-if="!current.canDepart" class="gate-warning">{{ current.departBlockReason || '当前排车暂不满足发车条件,请刷新后重试。' }}</text>
|
||||
<text v-else class="gate-note">本车所有排车单已就绪,确认后将一并发车。</text>
|
||||
<button v-if="!departed" class="depart-button" :disabled="!canDepart || busy" @tap="depart">{{ busyAction === 'depart' ? '发车中…' : '确认整车发车' }}</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view v-if="successMessage" class="fulfillment-success" role="status"><text>{{ successMessage }}</text></view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<PortalHeader title="排车作业" :user="user" />
|
||||
<scroll-view class="fulfillment-scroll" scroll-y><view class="portal-content fulfillment-content">
|
||||
<view class="fulfillment-heading"><text class="fulfillment-title">整车拣货、装车与发车</text><text class="fulfillment-subtitle">一张排车单代表一趟车,按实物托盘码或来源箱唛逐项作业。</text></view>
|
||||
<view v-if="pendingCommand" class="fulfillment-card gate-card"><text>排车 {{ pendingCommand.dispatchNo }} 的原操作结果待核对,请保留原请求。</text><button :disabled="busy || loading" @tap="recoverCommand">核对并重试原请求</button><button v-if="pendingCommand.rejected" :disabled="busy" @tap="discardRejected">按明确拒绝结果重新核对</button></view>
|
||||
<view v-if="current" class="detail-toolbar"><text class="detail-title">{{ current.dispatchNo }}</text><button :disabled="busy" @tap="closeTask">‹ 返回列表</button></view>
|
||||
<view class="fulfillment-card"><ScanTargetInput name="dispatch" :model-value="scanCode" label="排车单号/实物托盘码/来源箱唛" placeholder="扫描业务单号或实物码" :active="true" @update:modelValue="scanCode = $event" @submit="handleScan" /><button :disabled="busy || loading || !scanCode || Boolean(pendingCommand)" @tap="handleScan(scanCode)">匹配扫描</button></view>
|
||||
<view v-if="loading" class="fulfillment-state"><text>正在加载排车事实…</text></view>
|
||||
<view v-if="error" class="fulfillment-error" role="alert"><text class="state-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><text v-if="error.network">网络中断,原请求和作业草稿已保留。</text><button :disabled="busy || loading" @tap="retry">重新核对</button></view>
|
||||
<template v-if="!current && !loading"><view class="list-toolbar"><text class="list-title">排车任务</text><button :disabled="busy" @tap="loadTasks">刷新</button></view><view v-if="!tasks.length && !error" class="fulfillment-state">暂无排车任务,可扫描排车单号读取。</view><button v-for="task in tasks" :key="task.dispatchNo" class="task-card" @tap="openTask(task.dispatchNo)"><text class="business-code">{{ task.dispatchNo }}</text><text v-if="task.dispatchNo === recentTaskNo" class="recent-badge">上次作业</text><text class="status-badge">{{ stageLabel(task) }}</text><text class="task-meta">任务号 {{ task.taskNo || '—' }}</text></button></template>
|
||||
<template v-if="current"><DispatchHandoverStatus v-if="departed" :dispatch-no="current.dispatchNo" :record-version="current.recordVersion" />
|
||||
<view class="fulfillment-card summary-card"><view class="task-head"><text class="business-code">{{ current.dispatchNo }}</text><text class="status-badge">{{ stageLabel(current) }}</text></view><text class="task-meta">任务号 {{ current.taskNo || '—' }}</text><text v-if="!gate('PICK') && current.stage === 'LOADING'" class="gate-warning">{{ current.actions.PICK?.blockReason || '拣货资格尚未允许,请核对两级审核及当前作业状态' }}</text><view class="metrics"><view><text class="metric-value">{{ plannedPackages }}</text><text>计划箱数</text></view><view><text class="metric-value">{{ pickedPackages }}</text><text>真实已拣</text></view><view><text class="metric-value">{{ remainingPackages }}</text><text>未拣箱数</text></view></view></view>
|
||||
<view v-for="item in current.items" :key="item.dispatchItemId" class="fulfillment-card item-card" :class="{ selected: selectedItem?.dispatchItemId === item.dispatchItemId }" @tap="selectPallet(item)"><view class="task-head"><text class="business-code">{{ item.palletNo || '未关联托盘码' }}</text><text>{{ item.pickedPackages }}/{{ item.scheduledPackages }}箱</text></view><text class="task-meta">{{ item.customerName || '—' }} · {{ routeLabel(item) }}</text><text v-for="carton in item.returnCartons" :key="carton.returnCartonId" class="task-meta">{{ carton.cartonMark }} · {{ carton.status === 'OUTBOUNDED' ? '已拣货' : '待扫描' }}</text><view class="progress"><view :style="{ width: `${itemProgress(item)}%` }"></view></view></view>
|
||||
<view v-if="!current.items.length" class="fulfillment-state">本车暂无货物,空单不能核平、装车或发车。</view>
|
||||
<view v-if="selectedItem && selectedRemaining > 0" class="fulfillment-card"><text class="card-title">本次拣货:{{ selectedItem.palletNo || '来源箱唛' }}</text><text v-if="selectedItem.returnCartons.length">{{ selectedReturnCarton ? `已扫 ${selectedReturnCarton.cartonMark}` : '请扫描来源箱唛,每次一箱' }}</text><view v-else class="count-row"><text>拣货箱数</text><input type="number" :value="packages" :placeholder="`请输入 1-${selectedRemaining} 箱`" :disabled="busy || Boolean(pendingCommand)" @input="updatePackages" /><text>最多{{ selectedRemaining }}箱</text></view><button class="primary-button" :disabled="!canPick || busy || Boolean(pendingCommand)" @tap="submitPick">确认真实拣货</button></view>
|
||||
<view v-if="current.stage === 'LOADING'" class="fulfillment-card gate-card"><text class="card-title">确认装车完成</text><text>{{ current.pickingReconciled ? 'PDA拣货已核平,确认实际装载后进入装车后。' : '先完成全部真实拣货,不能补录数量代替核平。' }}</text><text v-if="!gate('LOADING_COMPLETE')" class="gate-warning">{{ current.actions.LOADING_COMPLETE?.blockReason || '尚未获得装车完成资格' }}</text><button class="primary-button" :disabled="!canComplete || busy || Boolean(pendingCommand)" @tap="prepareLoading">核对实载并确认装车完成</button></view>
|
||||
<view v-if="current.stage === 'AFTER_LOADING' || departed" class="fulfillment-card gate-card"><text class="card-title">实际发车</text><text v-if="departed" class="gate-success">本车已经实际发车,不重复提交。</text><text v-else-if="!gate('DEPART')" class="gate-warning">{{ current.actions.DEPART?.blockReason || '请先由后台完成本车全部客户的派送安排' }}</text><text v-else>装车完成与实际发车是两个动作,请确认现场已经发车。</text><button v-if="!departed" class="depart-button" :disabled="!canDepart || busy || Boolean(pendingCommand)" @tap="depart">确认整车发车</button></view>
|
||||
</template><view v-if="successMessage" class="fulfillment-success" role="status">{{ successMessage }}</view>
|
||||
</view></scroll-view>
|
||||
<view v-if="loadingPreview" class="loading-overlay"><view class="loading-dialog" role="dialog" aria-label="核对真实装载"><text class="card-title">核对真实装载 · {{ loadingPreview.dispatchNo }}</text><scroll-view scroll-y class="loading-dialog-body"><view v-for="row in loadingRows" :key="row.warehousePalletId" class="fulfillment-card"><text class="business-code">{{ row.palletNo || '—' }}</text><text>实际托位</text><input type="number" :value="row.palletSpaces ?? ''" placeholder="请确认实际托位" :disabled="busy" @input="setMeasurement(row, 'palletSpaces', $event)" /><text>实测重量(kg)</text><input type="digit" :value="row.weightKg ?? ''" placeholder="请输入实测重量,参考值不自动确认" :disabled="busy" @input="setMeasurement(row, 'weightKg', $event)" /><text>实测体积(m³)</text><input type="digit" :value="row.volumeCbm ?? ''" placeholder="请输入实测体积" :disabled="busy" @input="setMeasurement(row, 'volumeCbm', $event)" /><text>已匹配卸货顺序:{{ row.deliveryIndex ?? '尚未匹配,请后台核对' }}</text></view><view v-for="row in loadingPreview.pallets" :key="row.warehousePalletId"><text v-for="evidence in row.evidence" :key="evidence.field + evidence.sourceType">{{ evidence.actual ? '真实测量依据' : '仅供参考' }} {{ evidence.sourceBusinessNo || '—' }}:{{ evidence.value ?? '—' }}</text></view></scroll-view><view class="count-row"><button :disabled="busy" @tap="loadingPreview = null">返回</button><button :disabled="busy || Boolean(pendingCommand)" @tap="completeLoading">确认实际装车完成</button></view></view></view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import DispatchHandoverStatus from '@/components/DispatchHandoverStatus.vue'
|
||||
import { completeDispatchPicking, departDispatch, getDispatchTask, listDispatchTasks, pickDispatchItem, tripDepartureRequest } from '@/api/fulfillment.js'
|
||||
import { fulfillment, fulfillmentIdempotencyKey, releaseFulfillmentKey, restoreFulfillmentDraft, updateFulfillmentDraft } from '@/stores/fulfillment.js'
|
||||
import { requireSession, session } from '@/stores/session.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
import { toDispatchError } from '@/utils/dispatch-error.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
|
||||
const finishedStatuses = ['COMPLETED', 'PICKING_COMPLETED', 'READY_TO_DEPART', 'DEPARTED']
|
||||
|
||||
export default {
|
||||
components: { PortalHeader, ScanTargetInput, DispatchHandoverStatus },
|
||||
data() { return { user: session.currentUser, tasks: [], current: null, scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '', bootstrapped: false, resumeOnEntry: false } },
|
||||
computed: {
|
||||
recentTaskNo() { return fulfillment.dispatch.currentNo || '' },
|
||||
packages() { return fulfillment.dispatch.packages },
|
||||
selectedItem() { return this.current?.items?.find((item) => fulfillment.dispatch.dispatchItemId ? item.dispatchItemId === fulfillment.dispatch.dispatchItemId : item.palletNo === fulfillment.dispatch.palletCode) || null },
|
||||
selectedReturnCarton() { return this.selectedItem?.returnCartons?.find((carton) => carton.cartonMark === fulfillment.dispatch.cartonCode && carton.status === 'OUTBOUND_PENDING') || null },
|
||||
selectedPosition() { return this.selectedItem?.positions?.find((item) => item.availablePackages > 0) || this.selectedItem?.positions?.[0] || null },
|
||||
selectedRemaining() { const remaining = this.selectedItem?.scheduledPackages - this.selectedItem?.pickedPackages || 0; return Math.max(0, this.selectedItem?.returnCartons?.length ? remaining : Math.min(remaining, this.selectedPosition?.availablePackages || 0)) },
|
||||
plannedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.scheduledPackages, 0) },
|
||||
pickedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.pickedPackages, 0) },
|
||||
remainingPackages() { return Math.max(0, this.plannedPackages - this.pickedPackages) },
|
||||
allPicked() { return this.plannedPackages > 0 && this.remainingPackages === 0 },
|
||||
pickingCompleted() { return finishedStatuses.includes(this.current?.status) },
|
||||
departed() { return this.current?.status === 'DEPARTED' },
|
||||
canPick() { return this.current?.canPick === true && (this.selectedItem?.returnCartons?.length ? Boolean(this.selectedReturnCarton) : Boolean(this.selectedPosition) && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining) && !this.pickingCompleted },
|
||||
canCompleteStage() { return Boolean(this.current) && !this.pickingCompleted },
|
||||
canComplete() { return this.current?.canPick === true && this.plannedPackages > 0 && this.remainingPackages === 0 },
|
||||
canDepart() { return this.pickingCompleted && this.allPicked && this.current?.canDepart === true && !this.departed }
|
||||
},
|
||||
onLoad(options) { this.resumeOnEntry = options?.resume === '1' },
|
||||
onShow() {
|
||||
if (!requireSession()) return
|
||||
this.user = session.currentUser
|
||||
restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`)
|
||||
this.startScanner()
|
||||
if (!this.bootstrapped) {
|
||||
this.bootstrapped = true
|
||||
if (this.resumeOnEntry && fulfillment.dispatch.currentNo) this.openTask(fulfillment.dispatch.currentNo)
|
||||
else this.loadTasks()
|
||||
return
|
||||
}
|
||||
if (this.current?.dispatchNo) this.openTask(this.current.dispatchNo)
|
||||
else this.loadTasks()
|
||||
},
|
||||
onHide() { this.stopScanner() },
|
||||
beforeUnmount() { this.stopScanner() },
|
||||
methods: {
|
||||
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleScan(value), onError: (error) => this.setError(error, '扫码广播注册失败') }) },
|
||||
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
|
||||
updateScanCode(value) { this.scanCode = value },
|
||||
updatePackages(event) { const value = Math.trunc(Number(event.detail.value || 0)); updateFulfillmentDraft('dispatch', { packages: Number.isFinite(value) ? Math.max(0, value) : 0 }) },
|
||||
async loadTasks() { await this.run('加载排车任务', async () => { this.tasks = await listDispatchTasks(); this.current = null }, true) },
|
||||
async openTask(dispatchNo) {
|
||||
const code = String(dispatchNo || '').trim(); if (!code) return
|
||||
await this.run('恢复排车任务', async () => { const previousNo = fulfillment.dispatch.currentNo; this.current = await getDispatchTask(code); const same = previousNo === this.current.dispatchNo; updateFulfillmentDraft('dispatch', { currentNo: this.current.dispatchNo, palletCode: same ? fulfillment.dispatch.palletCode : '', dispatchItemId: same ? fulfillment.dispatch.dispatchItemId : '', cartonCode: same ? fulfillment.dispatch.cartonCode : '' }); this.scanCode = '' }, true)
|
||||
},
|
||||
closeTask() { this.current = null; this.scanCode = ''; this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { currentNo: '', palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1 }); this.loadTasks() },
|
||||
handleScan(raw) {
|
||||
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim(); if (!code || this.busy || this.loading) return
|
||||
console.info('[Overseas PDA dispatch] scan', { code, task: this.current?.dispatchNo || '' })
|
||||
this.scanCode = code
|
||||
this.error = null; this.successMessage = ''
|
||||
updateFulfillmentDraft('dispatch', { palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1 })
|
||||
if (!this.current || code === this.current.dispatchNo || code === this.current.taskNo) return this.openTask(code === this.current?.taskNo ? this.current.dispatchNo : code)
|
||||
const cartonItem = this.current.items.find((item) => item.returnCartons?.some((carton) => carton.cartonMark === code))
|
||||
if (cartonItem) {
|
||||
if (cartonItem.returnCartons.find((carton) => carton.cartonMark === code)?.status !== 'OUTBOUND_PENDING') return this.setError(new Error('该箱已拣货或不可出库'), '扫码未匹配')
|
||||
this.selectPallet(cartonItem); updateFulfillmentDraft('dispatch', { cartonCode: code, packages: 1 }); this.scanCode = ''; playSuccessSound(); return
|
||||
}
|
||||
const matches = this.current.items.filter((value) => value.palletNo === code && value.scheduledPackages > value.pickedPackages)
|
||||
if (matches.length > 1) return this.setError(new Error('此托盘包含多条待拣明细,请选择对应货物卡片'), '请选择拣货明细')
|
||||
const item = matches[0]
|
||||
if (!item) return this.setError(new Error(this.current.items.some((value) => value.palletNo === code) ? '该托盘已完成拣货,请勿重复操作' : '扫码未匹配当前任务的托盘码或来源箱唛'), '扫码未匹配')
|
||||
this.selectPallet(item); this.scanCode = ''; playSuccessSound()
|
||||
},
|
||||
selectPallet(item) { if (!item?.palletNo || item.scheduledPackages <= item.pickedPackages) return; this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { palletCode: item.palletNo, dispatchItemId: item.dispatchItemId, cartonCode: '', packages: Math.max(1, Math.min(item.scheduledPackages - item.pickedPackages, item.positions?.[0]?.availablePackages || 1)) }) },
|
||||
async submitPick() {
|
||||
if (!this.canPick) return
|
||||
const pick = this.selectedReturnCarton ? { positionId: null, cartonMark: this.selectedReturnCarton.cartonMark, packages: 1, productQuantity: this.selectedReturnCarton.productQuantity } : { positionId: this.selectedPosition.id, packages: Number(this.packages), productQuantity: 0 }
|
||||
const payload = { dispatchNo: this.current.dispatchNo, dispatchItemId: this.selectedItem.dispatchItemId, ...pick }
|
||||
const key = fulfillmentIdempotencyKey('dispatch', 'pick', payload)
|
||||
await this.run('提交排车拣货', async () => {
|
||||
await pickDispatchItem(this.current.dispatchNo, this.selectedItem.dispatchItemId, { ...pick, idempotencyKey: key.value })
|
||||
this.current = await getDispatchTask(this.current.dispatchNo); releaseFulfillmentKey('dispatch', key.signature); updateFulfillmentDraft('dispatch', { palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1 }); this.successMessage = '本次排车拣货已记录'; playSuccessSound()
|
||||
})
|
||||
},
|
||||
async completePicking() {
|
||||
if (!this.canComplete) return
|
||||
const key = fulfillmentIdempotencyKey('dispatch', 'complete', { dispatchNo: this.current.dispatchNo })
|
||||
await this.run('完成排车拣货', async () => { await completeDispatchPicking(this.current.dispatchNo, { idempotencyKey: key.value }); this.current = await getDispatchTask(this.current.dispatchNo); releaseFulfillmentKey('dispatch', key.signature); this.successMessage = '排车拣货任务已完成'; playSuccessSound() })
|
||||
},
|
||||
async depart() {
|
||||
if (!this.canDepart) return
|
||||
const key = fulfillmentIdempotencyKey('dispatch', 'depart', { tripNo: this.current.tripNo, recordVersion: this.current.tripRecordVersion })
|
||||
await this.run('执行整车发车', async () => {
|
||||
const result = await departDispatch(this.current.dispatchNo, tripDepartureRequest(this.current, key.value))
|
||||
if (result?.status !== 'IN_TRANSIT' || result.tripNo !== this.current.tripNo || !result.dispatches?.some(item => item.dispatchNo === this.current.dispatchNo)) throw new Error('发车结果未包含当前车次和排车单,请刷新核对')
|
||||
this.current = await getDispatchTask(this.current.dispatchNo)
|
||||
releaseFulfillmentKey('dispatch', key.signature); this.successMessage = `车次 ${result.tripNo} 已整车发车`; playSuccessSound()
|
||||
})
|
||||
},
|
||||
async retry() { if (this.current?.dispatchNo) await this.openTask(this.current.dispatchNo); else if (fulfillment.dispatch.currentNo) await this.openTask(fulfillment.dispatch.currentNo); else await this.loadTasks() },
|
||||
async run(action, work, loading = false) { if (this.busy || this.loading) return; this.error = null; this.successMessage = ''; if (loading) this.loading = true; else { this.busy = true; this.busyAction = action.includes('发车') ? 'depart' : action.includes('完成') ? 'complete' : 'pick' } try { await work() } catch (error) { this.setError(error, `${action}失败`) } finally { this.loading = false; this.busy = false; this.busyAction = '' } },
|
||||
setError(error, fallback) { this.error = toDispatchError(error, fallback); console.error('[Overseas PDA dispatch] failed', this.error); playErrorSound() },
|
||||
statusLabel(status) { return ({ PDA_PENDING: '待拣货', PENDING: '待拣货', PICKING: '拣货中', IN_PROGRESS: '拣货中', COMPLETED: '拣货完成', PICKING_COMPLETED: '拣货完成', READY_TO_DEPART: '待发车', DEPARTED: '已发车', CANCELLED: '已取消' })[status] || status || '状态未知' },
|
||||
routeLabel(item) { return item.warehouseRouteLabel || [item.destinationWarehouseCode, item.destinationWarehouseName, item.fbaWarehouseCode].filter(Boolean).join(' · ') || '目的仓未关联' },
|
||||
itemProgress(item) { return item.scheduledPackages ? Math.min(100, Math.round(item.pickedPackages / item.scheduledPackages * 100)) : 0 }
|
||||
}
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import DispatchHandoverStatus from '@/components/DispatchHandoverStatus.vue'
|
||||
import { completeDispatchLoading, departDispatch, getDispatchTask, getDispatchLoading, getDispatchCommand, listDispatchTasks, pickDispatchItem, requireDispatchVersion, dispatchVersionAtLeast } from '@/api/fulfillment.js'
|
||||
import { fulfillment, beginDispatchCommand, patchDispatchCommand, finishDispatchCommand, discardRejectedDispatchCommand, restoreFulfillmentDraft, updateFulfillmentDraft } from '@/stores/fulfillment.js'
|
||||
import { requireSession, session } from '@/stores/session.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
import { toDispatchError } from '@/utils/dispatch-error.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
export default {
|
||||
components: { PortalHeader, ScanTargetInput, DispatchHandoverStatus },
|
||||
data() { return { user: session.currentUser, tasks: [], current: null, scanCode: '', loading: false, busy: false, error: null, successMessage: '', bootstrapped: false, resumeOnEntry: false, generation: 0, loadingPreview: null, loadingRows: [] } },
|
||||
computed: {
|
||||
recentTaskNo() { return fulfillment.dispatch.currentNo || '' }, pendingCommand() { return fulfillment.dispatch.pendingCommand }, packages() { return fulfillment.dispatch.packages },
|
||||
selectedItem() { return this.current?.items.find(row => row.dispatchItemId === fulfillment.dispatch.dispatchItemId) || null },
|
||||
selectedReturnCarton() { return this.selectedItem?.returnCartons.find(row => row.cartonMark === fulfillment.dispatch.cartonCode && row.status === 'OUTBOUND_PENDING') || null },
|
||||
selectedPosition() { return this.selectedItem?.positions.find(row => row.availablePackages > 0 && row.allocatedPackages > row.pickedPackages) || null },
|
||||
selectedRemaining() { const remaining = Math.max(0, (this.selectedItem?.scheduledPackages || 0) - (this.selectedItem?.pickedPackages || 0)); return this.selectedItem?.returnCartons.length ? remaining : Math.min(remaining, this.selectedPosition?.availablePackages || 0, Math.max(0, (this.selectedPosition?.allocatedPackages || 0) - (this.selectedPosition?.pickedPackages || 0))) },
|
||||
plannedPackages() { return (this.current?.items || []).reduce((sum, row) => sum + row.scheduledPackages, 0) }, pickedPackages() { return (this.current?.items || []).reduce((sum, row) => sum + row.pickedPackages, 0) }, remainingPackages() { return Math.max(0, this.plannedPackages - this.pickedPackages) },
|
||||
departed() { return ['DELIVERING', 'DELIVERED'].includes(this.current?.stage) },
|
||||
canPick() { return this.current?.status === 'ACTIVE' && this.current?.stage === 'LOADING' && this.gate('PICK') && this.selectedRemaining > 0 && (this.selectedItem?.returnCartons.length ? Boolean(this.selectedReturnCarton) : Boolean(this.selectedPosition) && Number.isInteger(Number(this.packages)) && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining) },
|
||||
canComplete() { return this.current?.status === 'ACTIVE' && this.current?.stage === 'LOADING' && this.current.pickingReconciled === true && this.plannedPackages > 0 && this.remainingPackages === 0 && this.gate('LOADING_COMPLETE') },
|
||||
canDepart() { return this.current?.status === 'ACTIVE' && this.current?.stage === 'AFTER_LOADING' && this.gate('DEPART') }
|
||||
},
|
||||
onLoad(options) { this.resumeOnEntry = options?.resume === '1' },
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (!this.bootstrapped) { this.bootstrapped = true; if (this.resumeOnEntry && fulfillment.dispatch.currentNo) this.openTask(fulfillment.dispatch.currentNo); else this.loadTasks() } else if (this.current?.dispatchNo) this.openTask(this.current.dispatchNo); else this.loadTasks() },
|
||||
onHide() { this.stopScanner(); this.generation++ }, beforeUnmount() { this.stopScanner(); this.generation++ },
|
||||
methods: {
|
||||
gate(action) { return this.current?.actions?.[action]?.allowed === true },
|
||||
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: value => this.handleScan(value), onError: error => this.setError(error, '扫码广播注册失败') }) }, stopScanner() { this._stopScanner?.(); this._stopScanner = null },
|
||||
updatePackages(event) { if (this.pendingCommand) return; const value = Number(event.detail.value); updateFulfillmentDraft('dispatch', { packages: Number.isFinite(value) ? value : 0 }) },
|
||||
async loadTasks() { const generation = ++this.generation; await this.run(async () => { const result = await listDispatchTasks(); if (generation === this.generation) { this.tasks = result; this.current = null } }, true) },
|
||||
async openTask(no) { if (this.pendingCommand && this.pendingCommand.dispatchNo !== no) return this.setError(new Error('请先核对原排车请求,不能切到另一张单执行'), '操作未完成'); const generation = ++this.generation; await this.run(async () => { const value = await getDispatchTask(no); if (generation !== this.generation) return; if (value.dispatchNo !== no) throw new Error('返回排车与扫描业务号不一致'); const same = fulfillment.dispatch.currentNo === no; this.current = value; updateFulfillmentDraft('dispatch', { currentNo: no, palletCode: same ? fulfillment.dispatch.palletCode : '', dispatchItemId: same ? fulfillment.dispatch.dispatchItemId : '', cartonCode: same ? fulfillment.dispatch.cartonCode : '', packages: same ? fulfillment.dispatch.packages : 1 }); this.scanCode = '' }, true) },
|
||||
closeTask() { if (this.busy) return; this.current = null; this.loadingPreview = null; this.scanCode = ''; this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { currentNo: '', palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1 }); this.loadTasks() },
|
||||
handleScan(raw) { const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim(); if (!code || this.busy || this.loading) return; if (this.pendingCommand) return this.setError(new Error('请先核对原排车操作结果'), '原请求未完成'); this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1 }); if (!this.current || code === this.current.dispatchNo || code === this.current.taskNo) return this.openTask(code === this.current?.taskNo ? this.current.dispatchNo : code); const cartonItem = this.current.items.find(item => item.returnCartons.some(carton => carton.cartonMark === code)); if (cartonItem) { if (!cartonItem.returnCartons.some(carton => carton.cartonMark === code && carton.status === 'OUTBOUND_PENDING')) return this.setError(new Error('该箱已拣或不可出库'), '扫码未匹配'); this.selectPallet(cartonItem); updateFulfillmentDraft('dispatch', { cartonCode: code, packages: 1 }); this.scanCode = ''; playSuccessSound(); return } const matches = this.current.items.filter(item => item.palletNo === code && item.scheduledPackages > item.pickedPackages); if (matches.length > 1) return this.setError(new Error('此托盘包含多条待拣明细,请选择对应客户货物卡片'), '请选择明细'); if (!matches.length) return this.setError(new Error(this.current.items.some(item => item.palletNo === code) ? '该托盘已完成拣货,请勿重复操作' : '扫描未匹配本车实物托盘或来源箱唛'), '扫码未匹配'); this.selectPallet(matches[0]); this.scanCode = ''; playSuccessSound() },
|
||||
selectPallet(item) { if (this.pendingCommand || this.busy || !item?.dispatchItemId || item.scheduledPackages <= item.pickedPackages) return; updateFulfillmentDraft('dispatch', { palletCode: item.palletNo, dispatchItemId: item.dispatchItemId, cartonCode: '', packages: 1 }); this.error = null },
|
||||
async submitPick() { if (!this.canPick || this.pendingCommand) return; try { const body = { recordVersion: requireDispatchVersion(this.current.recordVersion), positionId: this.selectedReturnCarton ? null : this.selectedPosition.positionId, packages: this.selectedReturnCarton ? 1 : Number(this.packages), productQuantity: this.selectedReturnCarton?.productQuantity || 0, cartonMark: this.selectedReturnCarton?.cartonMark || null, remark: null }; beginDispatchCommand(this.current.dispatchNo, 'PICK', { ...body, dispatchItemId: this.selectedItem.dispatchItemId }); await this.recoverCommand() } catch (cause) { this.setError(cause, '拣货未提交') } },
|
||||
async prepareLoading() { if (!this.canComplete || this.pendingCommand) return; await this.run(async () => { const data = await getDispatchLoading(this.current.dispatchNo); if (data.dispatchNo !== this.current.dispatchNo || data.pickingReconciled !== true || data.loadingComplete?.allowed !== true) throw new Error(data.loadingComplete?.blockReason || '装车前置未满足'); requireDispatchVersion(data.recordVersion); this.loadingPreview = data; this.loadingRows = data.pallets.map(row => { const confirmed = data.confirmedLoad?.manifestHash === data.manifestHash ? data.confirmedLoad.pallets.find(item => String(item.warehousePalletId) === String(row.warehousePalletId)) : null; return { warehousePalletId: String(row.warehousePalletId), palletNo: row.palletNo, palletSpaces: row.palletSpaces, deliveryIndex: row.deliveryIndex, weightKg: confirmed?.weightKg ?? (row.evidence.some(item => item.field === 'weightKg' && item.actual === true) ? row.weightKg : null), volumeCbm: confirmed?.volumeCbm ?? (row.evidence.some(item => item.field === 'volumeCbm' && item.actual === true) ? row.volumeCbm : null) } }) }) },
|
||||
setMeasurement(row, field, event) { if (this.busy || this.pendingCommand) return; const value = event.detail.value === '' ? null : Number(event.detail.value); row[field] = value != null && Number.isFinite(value) ? value : null },
|
||||
async completeLoading() { if (!this.loadingPreview || this.pendingCommand || this.busy) return; try { if (!this.loadingRows.length || this.loadingRows.some(row => row.weightKg == null || row.volumeCbm == null || row.palletSpaces == null || row.deliveryIndex == null || row.weightKg <= 0 || row.volumeCbm <= 0 || !Number.isInteger(row.palletSpaces) || row.palletSpaces <= 0 || !Number.isInteger(row.deliveryIndex) || row.deliveryIndex < 0)) throw new Error('请核对各托真实重量、体积、托位及已匹配卸货站'); beginDispatchCommand(this.loadingPreview.dispatchNo, 'LOADING_COMPLETE', { recordVersion: requireDispatchVersion(this.loadingPreview.recordVersion), manifestHash: this.loadingPreview.manifestHash, measurements: this.loadingRows.map(({ warehousePalletId, palletSpaces, weightKg, volumeCbm, deliveryIndex }) => ({ warehousePalletId, palletSpaces, weightKg, volumeCbm, deliveryIndex })), remark: null }); await this.recoverCommand() } catch (cause) { this.setError(cause, '装车完成未确认') } },
|
||||
async depart() { if (!this.canDepart || this.pendingCommand || this.busy) return; try { const no = this.current.dispatchNo, version = requireDispatchVersion(this.current.recordVersion); const result = await uni.showModal({ title: '确认实际发车', content: '排车单 ' + no + ' 将进入派送中,请确认现场已实际发车。' }); if (!result.confirm || this.current?.dispatchNo !== no || this.current?.recordVersion !== version || this.pendingCommand || !this.canDepart) return; beginDispatchCommand(no, 'DEPART', { recordVersion: version, remark: '海外PDA确认本车实际发车' }); await this.recoverCommand() } catch (cause) { this.setError(cause, '发车未提交') } },
|
||||
async recoverCommand() { if (!this.pendingCommand || this.busy || this.loading) return; const command = JSON.parse(JSON.stringify(this.pendingCommand)), scope = fulfillment.scopeKey, previousAttempt = command.attempted; await this.run(async () => { const ensureScope = () => { if (scope !== fulfillment.scopeKey) throw new Error('作业身份已变化,请重新进入任务') }; let result = command.result; if (!result && previousAttempt) { try { result = await getDispatchCommand(command.dispatchNo, command.body.idempotencyKey) } catch (cause) { if (!['404', 'A0404', 'HTTP_404'].includes(String(cause.code))) throw cause } ensureScope() } if (!result) { patchDispatchCommand({ attempted: true }); try { const { dispatchItemId, ...body } = command.body; result = command.action === 'PICK' ? await pickDispatchItem(command.dispatchNo, dispatchItemId, body) : command.action === 'LOADING_COMPLETE' ? await completeDispatchLoading(command.dispatchNo, body) : command.action === 'DEPART' ? await departDispatch(command.dispatchNo, body) : null } catch (cause) { ensureScope(); if (!previousAttempt && !cause.network && /^(?:A0|HTTP_)?(?:400|401|403|404|409|410|422)$/.test(String(cause.code))) patchDispatchCommand({ rejected: true }); throw cause } } ensureScope(); if (!result || result.dispatchNo !== command.dispatchNo || result.action !== command.action || result.idempotencyKey !== command.body.idempotencyKey || !result.result || command.action === 'DEPART' && result.stage !== 'DELIVERING' || command.action === 'LOADING_COMPLETE' && result.stage !== 'AFTER_LOADING') throw new Error('返回结果未确认原排车动作,请保留原请求核对'); requireDispatchVersion(result.recordVersion); patchDispatchCommand({ result }); const latest = await getDispatchTask(command.dispatchNo); ensureScope(); if (latest.dispatchNo !== command.dispatchNo) throw new Error('恢复详情与原排车单不一致'); if (!dispatchVersionAtLeast(latest.recordVersion, result.recordVersion)) throw new Error('任务版本早于已确认动作,请重读原请求'); this.current = latest; updateFulfillmentDraft('dispatch', { currentNo: latest.dispatchNo, dispatchItemId: '', palletCode: '', cartonCode: '', packages: 1 }); finishDispatchCommand(command.body.idempotencyKey); this.loadingPreview = null; this.successMessage = command.action === 'PICK' ? '本次真实拣货已记录' : command.action === 'LOADING_COMPLETE' ? '装车完成已确认,等待后台安排派送' : '本车实际发车已确认'; playSuccessSound() }) },
|
||||
discardRejected() { if (this.busy || !this.pendingCommand?.rejected) return; discardRejectedDispatchCommand(); this.retry() },
|
||||
async retry() { if (this.pendingCommand) await this.recoverCommand(); else if (this.current?.dispatchNo) await this.openTask(this.current.dispatchNo); else await this.loadTasks() },
|
||||
async run(work, loading = false) { if (this.busy || this.loading) return; this.error = null; this.successMessage = ''; if (loading) this.loading = true; else this.busy = true; try { await work() } catch (cause) { this.setError(cause, '排车操作失败') } finally { this.loading = false; this.busy = false } },
|
||||
setError(cause, fallback) { this.error = toDispatchError(cause, fallback); playErrorSound() },
|
||||
stageLabel(task) { if (task.status === 'CANCELLED') return '已取消'; return ({ BEFORE_LOADING: '装车前', LOADING: '装车中', AFTER_LOADING: '装车后', DELIVERING: '派送中', DELIVERED: '派送完成' })[task.stage] || '状态待核实' },
|
||||
routeLabel(item) { return [item.destination?.warehouseCode, item.destination?.warehouseName, item.destination?.address].filter(Boolean).join(' · ') || '目的仓未关联' }, itemProgress(item) { return item.scheduledPackages ? Math.min(100, Math.round(item.pickedPackages / item.scheduledPackages * 100)) : 0 }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import './fulfillment.scss';
|
||||
.loading-overlay { position: fixed; inset: 0; background: rgba(15,23,42,.4); display: flex; align-items: center; justify-content: center; z-index: 100; padding: 24rpx; }
|
||||
.loading-dialog { width: 100%; max-height: 90%; background: #fff; border-radius: 16rpx; padding: 24rpx; display: flex; flex-direction: column; }
|
||||
.loading-dialog-body { max-height: 70vh; }
|
||||
.loading-dialog input { min-height: 72rpx; border: 1px solid #cbd5e1; padding: 8rpx; margin: 8rpx 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'overseas-pda-fulfillment-draft-v1'
|
||||
const STORAGE_KEY = 'overseas-pda-fulfillment-draft-v2'
|
||||
|
||||
const blankState = () => ({
|
||||
scopeKey: '',
|
||||
dispatch: { currentNo: '', palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1, pendingKeys: {} },
|
||||
dispatch: { currentNo: '', palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1, pendingCommand: null },
|
||||
outbound: { currentTaskNo: '', scanCode: '', packages: 1, pendingKeys: {} }
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ function safeDomain(value, fallback) {
|
||||
return {
|
||||
...fallback,
|
||||
...(value || {}),
|
||||
pendingKeys: { ...(value?.pendingKeys || {}) }
|
||||
...(Object.hasOwn(fallback, 'pendingKeys') ? { pendingKeys: { ...(value?.pendingKeys || {}) } } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ function randomKey(domain, action) {
|
||||
|
||||
export function fulfillmentIdempotencyKey(domain, action, payload = {}) {
|
||||
if (!fulfillment[domain]) throw new Error('未知海外 PDA 履约类型')
|
||||
if (domain === 'dispatch') throw new Error('排车必须保存完整原命令,不能只生成请求键')
|
||||
const signature = `${action}:${JSON.stringify(payload)}`
|
||||
const pendingKeys = fulfillment[domain].pendingKeys
|
||||
if (!pendingKeys[signature]) pendingKeys[signature] = randomKey(domain, action)
|
||||
@@ -66,10 +67,39 @@ export function fulfillmentIdempotencyKey(domain, action, payload = {}) {
|
||||
|
||||
export function releaseFulfillmentKey(domain, signature) {
|
||||
if (!fulfillment[domain]) return
|
||||
if (domain === 'dispatch') throw new Error('排车原命令只能在服务端事实核对后清除')
|
||||
delete fulfillment[domain].pendingKeys[signature]
|
||||
persistFulfillmentDraft()
|
||||
}
|
||||
|
||||
export function beginDispatchCommand(dispatchNo, action, body) {
|
||||
if (!fulfillment.scopeKey) throw new Error('当前作业身份未恢复')
|
||||
if (fulfillment.dispatch.pendingCommand) throw new Error('请先核对原排车操作结果')
|
||||
const idempotencyKey = randomKey('dispatch', action)
|
||||
fulfillment.dispatch.pendingCommand = { dispatchNo, action, body: JSON.parse(JSON.stringify({ ...body, idempotencyKey })), attempted: false, rejected: false, result: null }
|
||||
persistFulfillmentDraft()
|
||||
return fulfillment.dispatch.pendingCommand
|
||||
}
|
||||
|
||||
export function patchDispatchCommand(patch) {
|
||||
if (!fulfillment.dispatch.pendingCommand) throw new Error('原排车请求不存在')
|
||||
Object.assign(fulfillment.dispatch.pendingCommand, patch)
|
||||
persistFulfillmentDraft()
|
||||
}
|
||||
|
||||
export function finishDispatchCommand(key) {
|
||||
if (fulfillment.dispatch.pendingCommand?.body.idempotencyKey !== key) throw new Error('核对结果与原请求键不一致')
|
||||
fulfillment.dispatch.pendingCommand = null
|
||||
persistFulfillmentDraft()
|
||||
}
|
||||
|
||||
export function discardRejectedDispatchCommand() {
|
||||
if (fulfillment.dispatch.pendingCommand?.rejected === true) {
|
||||
fulfillment.dispatch.pendingCommand = null
|
||||
persistFulfillmentDraft()
|
||||
}
|
||||
}
|
||||
|
||||
export function resetFulfillmentDomain(domain) {
|
||||
if (!fulfillment[domain]) return
|
||||
fulfillment[domain] = blankState()[domain]
|
||||
|
||||
Reference in New Issue
Block a user