This commit is contained in:
2026-10-21 14:30:45 +08:00
parent 52cfc66340
commit efa73f0d63
7 changed files with 155 additions and 47 deletions
+3 -2
View File
@@ -45,7 +45,7 @@
| 库存盘点(入口隐藏)、商品移库 | `src/pages/stocktake/stocktake.vue`、`src/pages/move/move.vue`;共用 `src/api/warehouse-operations.js`、`src/stores/warehouse-work.js` |
| 换标真实作业 | `src/pages/relabel/relabel.vue`、`src/api/relabel.js` |
| 现场查询 | `src/pages/query/query.vue`;同上库内API |
| 排车拣货、整车发车与证照提醒 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`(`departDispatch`)、`src/stores/fulfillment.js` |
| 排车理货审核、拣货、整车发车与证照提醒 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`(`completeDispatchLoading`)、`src/stores/fulfillment.js` |
| 普通/一件代发出库 | `src/pages/outbound/outbound.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` |
| 扫码设备与诊断 | `src/utils/scan-broadcast.js`、`src/pages/scanner-test/scanner-test.vue` |
| 作业记录与人员 | `src/pages/history/history.vue`、`src/pages/profile/profile.vue` |
@@ -81,7 +81,8 @@
- 请求身份/资源范围仅取Token/current,不传 `customerId/planNo/allocationId/warehouseId/locationId`;409引导账号恢复当前柜,完成前禁切柜。
- 签收照片三角色各一当前文件;上传后以服务端 `downloadPath` 会话内鉴权读。换图失败留原 `fileId`/可提交状态,无原图失败槽不计 `photosReady`;临时路径仅本次预览。
- 柜号签收入仓用Lastleg `/api/pda/v1/last-leg/receiving-containers/**`,退货签收/合箱分别用 `/api/pda/v1/last-leg/return-receipts/**`、`/api/pda/v1/last-leg/return-inventory/**`,入口见上表。
- 排车/出库网络失败留草稿/幂等键,恢复看服务端任务。发车看 `canDepart/departBlockReason`,拣货/完成同时核 `canPick/pickBlockReason`;平台未转派送生成 `WAIT_OUTBOUND` 出库单禁发车,完成/发车成功禁重提。
- 理货审核沿既有排车详情;规则/回归见业务索引排车条目。
- 排车/出库网络失败留原命令/幂等键,恢复看服务端任务。排车拣货/装车完成核 `actions.PICK/LOADING_COMPLETE`;装车完成一次提交直接进入 `DELIVERING`,服务端自动生成派送并发车,无独立安排派送/发车入口。完成成功禁重提。
## 质量门禁
+3 -3
View File
@@ -25,8 +25,8 @@
| 商品移库真实作业 | `src/pages/move/move.vue`、`src/api/warehouse-operations.js`、`src/stores/warehouse-work.js` | 扫描来源托盘与目标库位执行同仓移库;冲突后重新读取服务端事实 |
| 换标真实作业 | `src/pages/relabel/relabel.vue`、`src/api/relabel.js` | 同一页面按操作参数承载换标收货、执行与复核;执行页按当前单明细切换 SKU/箱唛 Tab,组合明细同时显示两类需求。SKU 逐条扫码旧新SKU并携带审批件数,箱唛扫码仓库唛头/新FBA箱唛;组合单按同箱保留两个Tab的核对输入,全部核对后只提交一次箱事实。认领同步处理中;支持拍照、异常/返工和幂等恢复 |
| 现场查询真实作业 | `src/pages/query/query.vue`、`src/api/warehouse-operations.js` | 同一页面按操作参数承载库存、库位与托盘查询,只展示稳定业务标识 |
| 排车拣货与发车 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 普通进入展示任务列表和“上次作业”,不因本地草稿自动打开详情;明确跳转 `?resume=1` 才恢复排车。按实物托盘码或来源箱唛真实拣货,服务端 `actions.PICK/LOADING_COMPLETE/DEPART` 分别决定拣货、装车完成和实际发车资格;装车与发车分离,后台安排派送完成前不得发车。公网前缀 `/api/pda/v1/last-leg/dispatches/**` |
| 整车发车与证照提醒 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js` 的 `departDispatch` | 一张排车单代表一趟车,使用 `dispatchNo` 与公开 `recordVersion`(0 合法),保留完整原命令和 `idempotencyKey`。恢复校验原单号、动作、请求键、结果及版本;`DEPART` 结果须为 `stage=DELIVERING`,重读任务且版本不早于已确认结果后再清除原命令。司机证照、资源及各客户派送资格由服务端校验。正式流程见工作区 `docs/海外仓业务/排车列表统一作业改造契约.md`,不再使用独立车次发车请求或以车次状态代替排车公开状态 |
| 排车拣货与发车/理货审核 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 普通进入展示任务列表和“上次作业”,不因本地草稿自动打开详情;明确跳转 `?resume=1` 才恢复排车。默认任务列表含待理货排车,详情展示服务端车辆/司机快照、调度审核状态/意见和货物。仅 `actions.LOADING_REVIEW.allowed=true` 时可调用 `POST /api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-review`,提交原 `recordVersion/idempotencyKey/decision/reason`;驳回原因必填,任一级拒绝统一回客服处理(车辆信息),重新提交后再次逐级审核。PDA 不执行调度审核;理货通过并重读资格后才允许按实物托盘码或来源箱唛真实拣货,服务端 `actions.PICK/LOADING_COMPLETE` 分别决定真实拣货和装车完成资格;实载核对后一次提交装车完成,服务端同一事务自动生成派送并发车,直接进入 `DELIVERING`,不再额外安排派送或确认发车。公网前缀 `/api/pda/v1/last-leg/dispatches/**`。回归 `tests/dispatch-workflow.test.mjs tests/fulfillment-workflows.test.mjs` |
| 整车发车与证照提醒 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js` 的 `completeDispatchLoading` | 一张排车单代表一趟车,使用 `dispatchNo` 与公开 `recordVersion`(0 合法),保留完整原命令和 `idempotencyKey`。审核和现场作业均恢复校验原单号、动作、请求键、结果及版本;理货审核通过结果须 `stage=LOADING`,驳回须 `stage=BEFORE_LOADING`;已确认结果重读失败不再 POST,首个明确拒绝可手动重读、网络未知保留完整原命令且不以新版本覆盖。`LOADING_COMPLETE` 结果须为 `stage=DELIVERING`(result 仍为 Loaded,输入仍为原版本、manifestHash、measurements),重读任务且版本不早于已确认结果后再清除原命令。司机证照、资源及各客户派送资格由服务端校验。正式流程见工作区 `docs/海外仓业务/排车列表统一作业改造契约.md`,不再使用独立车次发车请求或以车次状态代替排车公开状态 |
| 普通/一件代发出库 | `src/pages/outbound/outbound.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 首次进入清空扫描框并展示任务列表和“上次作业”标记,不自动恢复旧详情;详情提供独立返回列表入口。逐箱扫码、DP 托盘整托拣取及幂等完成保持不变;扫描排车业务号显式跳转排车 `?resume=1`。公网前缀 `/api/pda/v1/last-leg/outbound-tasks/**`;派送由 Admin/Client 承载 |
| 扫码设备与诊断 | `src/utils/scan-broadcast.js`、`src/pages/scanner-test/scanner-test.vue` | `src/components/ScanTargetInput.vue`、声音反馈工具;广播 Action/Extra 见本文件服务边界 |
| 作业记录与人员 | `src/pages/history/history.vue`、`src/pages/profile/profile.vue` | 会话隔离、退出清理和后续真实作业历史契约 |
@@ -61,4 +61,4 @@
- 库内、换标与现场查询只调用 `/api/pda/**` 业务 API/DTO,并统一受 `lastleg:pda:operate`、Token 冻结仓库范围和服务端资源归属约束;写操作必须保留稳定幂等键,409 后重新读取服务端状态,不得把平台、客户或内部接口直接暴露给 PDA。
- 柜号签收入仓调用 Lastleg `/api/pda/v1/last-leg/receiving-containers/**`;退货签收与合箱分别调用 `/api/pda/v1/last-leg/return-receipts/**`、`/api/pda/v1/last-leg/return-inventory/**`,具体入口见上表。浏览器或设备请求必须经统一 `gateway` 的显式 PDA 路由,不得直连 Lastleg。
- 海外业务 Token、PDA 权限和可信身份最终由 Gateway 与服务端校验;前端不得用工作台 `available`、本地客户 ID 或仓库 ID 代替授权和资源归属校验。
- 排车和出库入口只调用专用 PDA 接口;客户与绑定海外仓由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不展示内部数据库 ID 作为业务编号。网络失败保留完整原命令、草稿和幂等键,恢复后以服务端任务状态为准;动作资格与恢复规则见本文件排车条目,不能将装车完成当作实际发车。完成或发车成功后不得重复提交;PDA 不承载派送单创建,平台与客户的读取范围遵循现行契约。
- 排车和出库入口只调用专用 PDA 接口;客户与绑定海外仓由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不展示内部数据库 ID 作为业务编号。网络失败保留完整原命令、草稿和幂等键,恢复后以服务端任务状态为准;动作资格与恢复规则见本文件排车条目,排车装车完成一次提交即生成派送并发车,前端不补派送费用或额外请求。完成成功后不得重复提交;PDA 不直接创建派送单,平台与客户的读取范围遵循现行契约。
+2 -2
View File
@@ -103,8 +103,8 @@ export function pickDispatchItem(dispatchNo, dispatchItemId, request) {
export function completeDispatchLoading(dispatchNo, request) {
return dispatchWrite(`${DISPATCH_BASE}/${encoded(dispatchNo)}/loading-complete`, request)
}
export function departDispatch(dispatchNo, request) {
return dispatchWrite(`${DISPATCH_BASE}/${encoded(dispatchNo)}/depart`, request)
export function reviewDispatchLoading(dispatchNo, request) {
return dispatchWrite(`${DISPATCH_BASE}/${encoded(dispatchNo)}/loading-review`, request)
}
export async function listOutboundTasks(status = '') {
+1 -1
View File
@@ -8,7 +8,7 @@ export const MODULES = [
{ key: 'returnCounting', name: '退货箱内清点', icon: '清', iconType: 'list', description: '逐箱清点托盘签收货物,核对 SKU 和数量', available: true, route: '/pages/return-counting/return-counting' }
] },
{ key: 'outbound', name: '出库履约', nameKey: 'modules.outbound.name', icon: '出', description: '排车拣货、出库任务与发车闭环', descriptionKey: 'modules.outbound.description', operations: [
{ key: 'dispatchPicking', name: '排车拣货', nameKey: 'modules.dispatchPicking.name', icon: '车', iconType: 'paperplane', description: '按排车任务扫描托盘拣货,完成门禁后执行发车', descriptionKey: 'modules.dispatchPicking.description', available: true, route: '/pages/dispatch/dispatch' },
{ key: 'dispatchPicking', name: '排车拣货', nameKey: 'modules.dispatchPicking.name', icon: '车', iconType: 'paperplane', description: '理货审核通过后真实拣货,确认装车完成即进入派送中', descriptionKey: 'modules.dispatchPicking.description', available: true, route: '/pages/dispatch/dispatch' },
{ key: 'outboundPicking', name: '出库作业', nameKey: 'modules.outboundPicking.name', icon: '拣', iconType: 'checkbox', description: '处理普通出库、一件代发和废弃订单,按托盘扫码完成', descriptionKey: 'modules.outboundPicking.description', available: true, route: '/pages/outbound/outbound' }
] },
{ key: 'inventory', name: '库内作业', nameKey: 'modules.inventory.name', icon: '库', description: '商品移库与托盘管理', descriptionKey: 'modules.inventory.description', operations: [
+44 -14
View File
@@ -2,7 +2,7 @@
<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></view>
<view class="fulfillment-heading"><text class="fulfillment-title">理货审核、真实拣货与装车</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>
@@ -11,12 +11,26 @@
<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.sourceBusinessType === 'RELABEL_ORDER' ? '换标单号' : '来源业务号' }} {{ item.sourceBusinessNo || '—' }} · {{ 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.stage === 'BEFORE_LOADING'" class="fulfillment-card review-card">
<text class="card-title">理货员审核</text>
<text class="task-meta">车牌号:{{ current.resources?.vehicle?.plate || '—' }} · 托车公司:{{ current.resources?.vehicle?.carrierName || '—' }}</text>
<text class="task-meta">司机:{{ current.resources?.driver?.name || '—' }} · 电话:{{ current.resources?.driver?.phone || '—' }}</text>
<text class="task-meta">驾驶证号:{{ current.resources?.driver?.evidence?.licenseNo || '—' }} · 有效期:{{ current.resources?.driver?.evidence?.licenseExpiresOn || '—' }}</text>
<text class="task-meta">调度审核:{{ reviewLabel(current.reviews?.dispatchReview) }} · {{ current.reviews?.dispatchReview?.reviewedAt || '—' }}</text>
<text v-if="current.reviews?.dispatchReview?.reason" class="task-meta">调度意见:{{ current.reviews.dispatchReview.reason }}</text>
<text v-if="rejectionReason" class="gate-warning">已退回客服处理(车辆信息):{{ rejectionReason }}。修改后须重新提交审核。</text>
<template v-if="canReview">
<text class="task-meta">请核对车辆、司机及下方货物;通过后才能开始真实拣货。驳回将回到客服处理(车辆信息)。</text>
<textarea :value="reviewReason" maxlength="1000" placeholder="审核意见(驳回时必填)" :disabled="busy || loading || Boolean(pendingCommand)" @input="updateReviewReason" />
<view class="count-row"><button :disabled="busy || loading || Boolean(pendingCommand) || !reviewReason.trim()" @tap="submitReview('REJECTED')">驳回至客服处理</button><button class="primary-button" :disabled="busy || loading || Boolean(pendingCommand)" @tap="submitReview('APPROVED')">审核通过</button></view>
</template>
<text v-else-if="!rejectionReason" class="gate-warning">{{ current.actions.LOADING_REVIEW?.blockReason || '当前不具备理货审核资格,请先完成客服提交和调度审核' }}</text>
</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.sourceBusinessType === 'RELABEL_ORDER' ? '换标单号' : '来源业务号' }} {{ item.sourceBusinessNo || '—' }} · {{ 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="sourceChoices.length > 1" class="fulfillment-card"><text class="card-title">请选择本次真实拣货来源</text><button v-for="item in sourceChoices" :key="item.dispatchItemId" :disabled="busy || Boolean(pendingCommand)" @tap="chooseSource(item)">{{ item.sourceBusinessNo || '—' }} · {{ item.customerName || '—' }} · 可拣{{ pickablePackages(item) }}箱</button></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>
<view v-if="current.stage === 'LOADING' && gate('PICK') && 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>
</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>
@@ -26,7 +40,7 @@
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 { reviewDispatchLoading, completeDispatchLoading, 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'
@@ -34,7 +48,7 @@ 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: [], sourceChoices: [], choiceCartonCode: '' } },
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: [], sourceChoices: [], choiceCartonCode: '', reviewReason: '' } },
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 && row.sourceKey === fulfillment.dispatch.sourceKey) || null },
@@ -45,7 +59,8 @@ export default {
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') }
canReview() { return this.current?.status === 'ACTIVE' && this.current?.stage === 'BEFORE_LOADING' && this.gate('LOADING_REVIEW') },
rejectionReason() { return [this.current?.reviews?.loadingReview, this.current?.reviews?.dispatchReview].find(review => review?.status === 'REJECTED')?.reason || '' },
},
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() },
@@ -55,8 +70,8 @@ export default {
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; this.sourceChoices = []; this.choiceCartonCode = ''; updateFulfillmentDraft('dispatch', { currentNo: no, palletCode: same ? fulfillment.dispatch.palletCode : '', dispatchItemId: same ? fulfillment.dispatch.dispatchItemId : '', sourceKey: same ? fulfillment.dispatch.sourceKey : '', 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: '', sourceKey: '', cartonCode: '', packages: 1 }); this.loadTasks() },
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; if (this.current?.dispatchNo !== no || this.current?.recordVersion !== value.recordVersion) this.reviewReason = ''; this.current = value; this.sourceChoices = []; this.choiceCartonCode = ''; updateFulfillmentDraft('dispatch', { currentNo: no, palletCode: same ? fulfillment.dispatch.palletCode : '', dispatchItemId: same ? fulfillment.dispatch.dispatchItemId : '', sourceKey: same ? fulfillment.dispatch.sourceKey : '', cartonCode: same ? fulfillment.dispatch.cartonCode : '', packages: same ? fulfillment.dispatch.packages : 1 }); this.scanCode = '' }, true) },
closeTask() { if (this.busy) return; this.current = null; this.reviewReason = ''; this.loadingPreview = null; this.scanCode = ''; this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { currentNo: '', palletCode: '', dispatchItemId: '', sourceKey: '', cartonCode: '', packages: 1 }); this.loadTasks() },
handleScan(raw) {
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim()
if (!code || this.busy || this.loading) return
@@ -64,6 +79,7 @@ export default {
this.error = null; this.successMessage = ''; this.sourceChoices = []; this.choiceCartonCode = ''
updateFulfillmentDraft('dispatch', { palletCode: '', dispatchItemId: '', sourceKey: '', 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)
if (this.current.stage !== 'LOADING' || !this.gate('PICK')) return this.setError(new Error(this.current.actions?.PICK?.blockReason || '理货审核通过后才能开始真实拣货'), '尚不能拣货')
const cartonItems = this.current.items.filter(item => item.returnCartons.some(carton => carton.cartonMark === code))
if (cartonItems.some(item => item.returnCartons.filter(carton => carton.cartonMark === code && carton.status === 'OUTBOUND_PENDING').length > 1)) return this.setError(new Error('同一来源返回重复待拣箱唛,请重新读取并核对箱记录'), '扫码未匹配')
const matches = cartonItems.length ? cartonItems.filter(item => item.scheduledPackages > item.pickedPackages && item.returnCartons.some(carton => carton.cartonMark === code && carton.status === 'OUTBOUND_PENDING')) : this.current.items.filter(item => item.palletNo === code && item.scheduledPackages > item.pickedPackages)
@@ -85,7 +101,7 @@ export default {
return Math.min(remaining, item.returnCartons.length ? item.returnCartons.filter(row => row.status === 'OUTBOUND_PENDING').length : item.positions.reduce((sum, row) => sum + row.availablePackages, 0))
},
selectPallet(item) {
if (this.pendingCommand || this.busy || !item?.dispatchItemId || item.scheduledPackages <= item.pickedPackages) return
if (this.current?.stage !== 'LOADING' || !this.gate('PICK') || this.pendingCommand || this.busy || !item?.dispatchItemId || item.scheduledPackages <= item.pickedPackages) return
if (!item.sourceKey || item.sourceBusinessType === 'RELABEL_ORDER' && !item.sourceBusinessNo) return this.setError(new Error('来源业务身份未完整返回,请重新读取任务'), '请选择明细')
updateFulfillmentDraft('dispatch', { palletCode: item.palletNo, dispatchItemId: item.dispatchItemId, sourceKey: item.sourceKey, cartonCode: '', packages: 1 })
this.sourceChoices = []; this.choiceCartonCode = ''; this.error = null
@@ -94,19 +110,33 @@ export default {
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() }) },
updateReviewReason(event) { if (this.busy || this.loading || this.pendingCommand) return; this.reviewReason = String(event.detail.value || '') },
async submitReview(decision) {
if (!this.canReview || this.busy || this.loading || this.pendingCommand || !['APPROVED', 'REJECTED'].includes(decision)) return
const reason = this.reviewReason.trim()
if (decision === 'REJECTED' && !reason) return this.setError(new Error('请填写驳回原因,退回客服处理'), '审核未提交')
try {
const no = this.current.dispatchNo, version = requireDispatchVersion(this.current.recordVersion)
const confirmation = await uni.showModal({ title: decision === 'APPROVED' ? '确认理货审核通过' : '确认退回客服处理', content: decision === 'APPROVED' ? '请确认车辆、司机和货物已核对,通过后进入装车中。' : `驳回原因:${reason}。本单将回到客服处理(车辆信息),重新提交后再逐级审核。` })
if (!confirmation.confirm || this.current?.dispatchNo !== no || this.current?.recordVersion !== version || !this.canReview || this.pendingCommand) return
beginDispatchCommand(no, 'LOADING_REVIEW', { recordVersion: version, decision, reason: reason || null })
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 === 'LOADING_REVIEW' ? await reviewDispatchLoading(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 === 'LOADING_COMPLETE' && result.stage !== 'DELIVERING' || command.action === 'LOADING_REVIEW' && result.stage !== (command.body.decision === 'APPROVED' ? 'LOADING' : 'BEFORE_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; if (command.action === 'LOADING_REVIEW') this.reviewReason = ''; this.successMessage = command.action === 'PICK' ? '本次真实拣货已记录' : command.action === 'LOADING_COMPLETE' ? '装车完成已确认,本车已进入派送中' : command.action === 'LOADING_REVIEW' ? command.body.decision === 'APPROVED' ? '理货审核已通过,可以开始真实拣货' : '已退回客服处理(车辆信息),等待修改并重新提交' : '排车操作已确认'; 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] || '状态待核实' },
stageLabel(task) { if (task.status === 'CANCELLED') return '已取消'; if (task.stage === 'BEFORE_LOADING') { if (task.actions?.LOADING_REVIEW?.allowed === true) return '待理货审核'; if ([task.reviews?.dispatchReview, task.reviews?.loadingReview].some(review => review?.status === 'REJECTED')) return '已退回客服处理' }; return ({ BEFORE_LOADING: '装车前', LOADING: '装车中', DELIVERING: '派送中', DELIVERED: '派送完成' })[task.stage] || '状态待核实' },
reviewLabel(review) { return ({ NOT_SUBMITTED: '未提交', PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' })[review?.status] || '待核对' },
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';
.review-card textarea { box-sizing: border-box; width: 100%; min-height: 160rpx; padding: 16rpx; border: 1px solid #cbd5e1; border-radius: 8rpx; }
.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; }
+90 -12
View File
@@ -21,7 +21,7 @@ test('invalid, missing and unsafe native quantities fail at every item, position
assert.throws(() => normalizeDispatchTask({ dispatchNo: 'PC/1', items: [item({ returnCartons: [{ productQuantity: invalid }] })] }), /排车数量/)
}
})
const task = (overrides = {}) => normalizeDispatchTask({ taskNo: 'TASK-1', dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'LOADING', pickingReconciled: false, actions: { PICK: { allowed: true }, LOADING_COMPLETE: { allowed: true }, DEPART: { allowed: false } }, items: [item()], ...overrides })
const task = (overrides = {}) => normalizeDispatchTask({ taskNo: 'TASK-1', dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'LOADING', pickingReconciled: false, actions: { PICK: { allowed: true }, LOADING_COMPLETE: { allowed: true } }, items: [item()], ...overrides })
function storage() { const values = new Map(); globalThis.uni = { setStorageSync: (key, value) => values.set(key, structuredClone(value)), getStorageSync: key => structuredClone(values.get(key)), removeStorageSync: key => values.delete(key), showModal: async () => ({ confirm: true }) }; resetFulfillmentSession(); restoreFulfillmentDraft('operator:warehouse'); return values }
function page(dependencies = {}) {
const deps = { PortalHeader: {}, ScanTargetInput: {}, DispatchHandoverStatus: {}, session: { currentUser: {} }, fulfillment, requireDispatchVersion, dispatchVersionAtLeast, beginDispatchCommand, patchDispatchCommand, finishDispatchCommand, discardRejectedDispatchCommand, updateFulfillmentDraft, playSuccessSound() {}, playErrorSound() {}, toDispatchError: error => ({ message: error.message }), ...dependencies }
@@ -31,13 +31,13 @@ function page(dependencies = {}) {
for (const [name, getter] of Object.entries(definition.computed)) Object.defineProperty(state, name, { get: () => getter.call(state) })
return state
}
const proof = (command, version = 1) => ({ dispatchNo: command.dispatchNo, action: command.action, idempotencyKey: command.body.idempotencyKey, recordVersion: version, stage: command.action === 'LOADING_COMPLETE' ? 'AFTER_LOADING' : command.action === 'DEPART' ? 'DELIVERING' : 'LOADING', result: { confirmed: true } })
const proof = (command, version = 1) => ({ dispatchNo: command.dispatchNo, action: command.action, idempotencyKey: command.body.idempotencyKey, recordVersion: version, stage: command.action === 'LOADING_COMPLETE' ? 'DELIVERING' : 'LOADING', result: { confirmed: true } })
test('full pending command survives restoration, rejects another operation, and isolates the next operator', () => {
storage(); const pending = beginDispatchCommand('PC/1', 'PICK', { recordVersion: 0, dispatchItemId: 'item-1', packages: 1 })
const original = structuredClone(JSON.parse(JSON.stringify(pending)))
fulfillment.dispatch.pendingCommand = null; restoreFulfillmentDraft('operator:warehouse')
assert.deepEqual(JSON.parse(JSON.stringify(fulfillment.dispatch.pendingCommand)), original)
assert.throws(() => beginDispatchCommand('PC/2', 'DEPART', { recordVersion: 2 }), /原排车/)
assert.throws(() => beginDispatchCommand('PC/2', 'LOADING_COMPLETE', { recordVersion: 2 }), /原排车/)
restoreFulfillmentDraft('another:warehouse'); assert.equal(fulfillment.dispatch.pendingCommand, null)
})
test('real picking requires the native gate, physical source and current stage; empty or merely counted tasks cannot complete loading', async () => {
@@ -46,7 +46,7 @@ test('real picking requires the native gate, physical source and current stage;
state.current.actions.PICK.allowed = true; state.current.stage = 'BEFORE_LOADING'; assert.equal(state.canPick, false)
state.current = task({ items: [] , pickingReconciled: true }); assert.equal(state.canComplete, false)
state.current = task({ items: [item({ pickedPackages: 2 })] }); assert.equal(state.canComplete, false)
state.current.pickingReconciled = true; assert.equal(state.canComplete, true); assert.equal(state.canDepart, false)
state.current.pickingReconciled = true; assert.equal(state.canComplete, true)
})
test('return carton pick keeps its exact item, quantity and explicit version zero without inventing a position', async () => {
storage(); let sent
@@ -64,16 +64,16 @@ test('unknown POST queries the original key and retries the unchanged payload; a
state.current.recordVersion = 80; await state.recoverCommand(); assert.deepEqual(sent[0], sent[1]); assert.equal(sent[1].body.recordVersion, 0)
state.selectPallet(state.current.items[0]); await state.submitPick(); assert.notEqual(sent[2].body.idempotencyKey, sent[1].body.idempotencyKey)
})
test('proven loading completion survives a failed GET and later departure by the other endpoint without another loading POST', async () => {
test('proven loading completion already delivered to the driver survives a failed GET without another POST', async () => {
storage(); let posts = 0, reads = 0
beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'manifest', measurements: [] })
const state = page({ completeDispatchLoading: async () => { posts++; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => { if (++reads === 1) throw Error('read failed'); return task({ stage: 'DELIVERING', recordVersion: 3 }) } })
await state.recoverCommand(); assert.equal(fulfillment.dispatch.pendingCommand.result.stage, 'AFTER_LOADING')
await state.recoverCommand(); assert.equal(fulfillment.dispatch.pendingCommand.result.stage, 'DELIVERING')
await state.recoverCommand(); assert.equal(posts, 1); assert.equal(state.current.stage, 'DELIVERING'); assert.equal(fulfillment.dispatch.pendingCommand, null)
})
test('later rejection cannot erase an earlier unknown write and older details cannot release a proven result', async () => {
storage(); beginDispatchCommand('PC/1', 'DEPART', { recordVersion: 0 }); patchDispatchCommand({ attempted: true })
const state = page({ getDispatchCommand: async () => { throw { code: '404' } }, departDispatch: async () => { throw { code: 'A0409', message: 'conflict' } } })
storage(); beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0 }); patchDispatchCommand({ attempted: true })
const state = page({ getDispatchCommand: async () => { throw { code: '404' } }, completeDispatchLoading: async () => { throw { code: 'A0409', message: 'conflict' } } })
await state.recoverCommand(); assert.equal(fulfillment.dispatch.pendingCommand.rejected, false)
state.discardRejected(); assert.ok(fulfillment.dispatch.pendingCommand)
patchDispatchCommand({ result: proof(fulfillment.dispatch.pendingCommand, '9007199254740993') })
@@ -86,10 +86,10 @@ test('actual loading confirmation leaves reference measurements blank and blocks
assert.equal(state.loadingRows[0].weightKg, null); assert.equal(state.loadingRows[0].volumeCbm, null)
Object.assign(state.loadingRows[0], { weightKg: 92, volumeCbm: 1.5 }); await state.completeLoading(); assert.equal(posts, 0); assert.match(state.error.message, /卸货站/)
})
test('missing versions surface a local error before picking or departure and never produce a write', async () => {
test('missing versions surface a local error before picking or loading completion and never produce a write', async () => {
storage(); const state = page(); state.selectPallet(state.current.items[0]); state.current.recordVersion = undefined
await state.submitPick(); assert.match(state.error.message, /版本/); assert.equal(fulfillment.dispatch.pendingCommand, null)
state.current = task({ recordVersion: undefined, stage: 'AFTER_LOADING', actions: { DEPART: { allowed: true } } }); await state.depart(); assert.match(state.error.message, /版本/); assert.equal(fulfillment.dispatch.pendingCommand, null)
state.loadingPreview = { dispatchNo: 'PC/1', recordVersion: undefined, manifestHash: 'manifest' }; state.loadingRows = [{ warehousePalletId: '21', weightKg: 1, volumeCbm: 1, palletSpaces: 1, deliveryIndex: 1 }]; await state.completeLoading(); assert.match(state.error.message, /版本/); assert.equal(fulfillment.dispatch.pendingCommand, null)
})
test('a first server error remains unknown and cannot be discarded as a definite business rejection', async () => {
storage(); beginDispatchCommand('PC/1', 'PICK', { recordVersion: 0, dispatchItemId: 'item-1', packages: 1 })
@@ -184,7 +184,85 @@ test('two picked logical sources use the single authoritative physical loading r
storage(); const rows = [item({ pickedPackages: 2 }), item({ dispatchItemId: 'item-2', sourceKey: 'source-2', sourceBusinessNo: 'RL-2', pickedPackages: 2 })]
const preview = { dispatchNo: 'PC/1', recordVersion: 0, manifestHash: 'one-physical-pallet', pickingReconciled: true, loadingComplete: { allowed: true }, pallets: [{ warehousePalletId: '21', palletNo: 'PALLET-1', palletSpaces: 1, deliveryIndex: 1, weightKg: 100, volumeCbm: 2, evidence: [{ field: 'weightKg', actual: true }, { field: 'volumeCbm', actual: true }] }] }
let sent
const state = page({ getDispatchLoading: async () => preview, completeDispatchLoading: async (...args) => { sent = args; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1, stage: 'AFTER_LOADING', items: rows, pickingReconciled: true }) })
const state = page({ getDispatchLoading: async () => preview, completeDispatchLoading: async (...args) => { sent = args; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1, stage: 'DELIVERING', items: rows, pickingReconciled: true }) })
state.current = task({ items: rows, pickingReconciled: true }); await state.prepareLoading(); assert.equal(state.loadingRows.length, 1)
await state.completeLoading(); assert.deepEqual(sent[1].measurements, [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 100, volumeCbm: 2, deliveryIndex: 1 }])
await state.completeLoading(); assert.equal(state.current.stage, 'DELIVERING'); assert.equal(state.departed, true); assert.equal(state.canComplete, false); assert.equal(state.loadingPreview, null); assert.deepEqual(sent[1].measurements, [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 100, volumeCbm: 2, deliveryIndex: 1 }])
})
const reviewTask = (overrides = {}) => task({ stage: 'BEFORE_LOADING', actions: { LOADING_REVIEW: { allowed: true }, PICK: { allowed: false } }, resources: { vehicle: { plate: 'ABC123', carrierName: '车队' }, driver: { name: '司机', phone: '123' } }, reviews: { dispatchReview: { status: 'APPROVED', reason: '已核对预约' }, loadingReview: { status: 'PENDING' } }, ...overrides })
test('review task is reachable in the existing list and blocks actual source selection until approval', async () => {
storage(); const state = page({ listDispatchTasks: async () => [reviewTask()], getDispatchTask: async () => reviewTask() })
await state.loadTasks(); assert.equal(state.tasks.length, 1); assert.equal(state.stageLabel(state.tasks[0]), '待理货审核')
await state.openTask('PC/1'); assert.equal(state.canReview, true); assert.equal(state.canPick, false)
state.selectPallet(state.current.items[0]); assert.equal(state.selectedItem, null)
state.handleScan('PALLET-1'); assert.match(state.error.message, /审核通过/); assert.equal(state.selectedItem, null)
for (const allowed of [false, undefined, 'true']) { state.current.actions.LOADING_REVIEW.allowed = allowed; assert.equal(state.canReview, false) }
})
test('reject requires a nonblank reason and approval uses original zero version then refreshes real pick eligibility', async () => {
storage(); let sent
const state = page({ reviewDispatchLoading: async (...args) => { sent = args; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1 }) })
state.current = reviewTask(); state.reviewReason = ' '; await state.submitReview('REJECTED')
assert.match(state.error.message, /原因/); assert.equal(fulfillment.dispatch.pendingCommand, null)
state.reviewReason = ' 已核对车辆和货物 '; await state.submitReview('APPROVED')
assert.deepEqual({ ...sent[1], idempotencyKey: 'key' }, { recordVersion: 0, decision: 'APPROVED', reason: '已核对车辆和货物', idempotencyKey: 'key' })
assert.equal(state.current.stage, 'LOADING'); state.selectPallet(state.current.items[0]); assert.equal(state.canPick, true)
assert.equal(state.reviewReason, ''); assert.equal(fulfillment.dispatch.pendingCommand, null)
})
test('rejected loading review returns to customer service with its reason and never enables picking', async () => {
storage(); let sent
const rejected = reviewTask({ recordVersion: 1, actions: { LOADING_REVIEW: { allowed: false } }, reviews: { dispatchReview: { status: 'NOT_SUBMITTED', invalidatedReason: '理货审核驳回,请客服修改后重新提交调度审核' }, loadingReview: { status: 'REJECTED', reason: '车牌不符' } } })
const state = page({ reviewDispatchLoading: async (no, body) => { sent = body; return { ...proof(fulfillment.dispatch.pendingCommand), stage: 'BEFORE_LOADING' } }, getDispatchTask: async () => rejected })
state.current = reviewTask(); state.reviewReason = '车牌不符'; await state.submitReview('REJECTED')
assert.equal(sent.decision, 'REJECTED'); assert.equal(state.rejectionReason, '车牌不符'); assert.equal(state.canReview, false)
assert.equal(state.stageLabel(state.current), '已退回客服处理'); assert.equal(state.canPick, false); assert.match(state.successMessage, /客服处理/)
})
test('review network uncertainty preserves exact decision reason version and key across restore and replay', async () => {
storage(); const sent = []; let fail = true
const state = page({ reviewDispatchLoading: async (no, body) => { sent.push(body); if (fail) { fail = false; throw { network: true } }; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchCommand: async () => { throw { code: 'HTTP_404' } }, getDispatchTask: async () => task({ recordVersion: 1 }) })
state.current = reviewTask(); state.reviewReason = '现场已核对'; await state.submitReview('APPROVED')
assert.equal(fulfillment.dispatch.pendingCommand.rejected, false); restoreFulfillmentDraft('operator:warehouse')
state.current.recordVersion = 99; state.reviewReason = '不得覆盖原文'; await state.recoverCommand()
assert.deepEqual(sent[0], sent[1]); assert.equal(sent[1].recordVersion, 0); assert.equal(sent[1].reason, '现场已核对'); assert.equal(fulfillment.dispatch.pendingCommand, null)
})
test('Admin wins a review race: definite conflict preserves command for explicit refresh and no automatic new approval', async () => {
storage(); let writes = 0
const state = page({ reviewDispatchLoading: async () => { writes++; throw { code: 'A0409', message: '版本已变化' } }, getDispatchTask: async () => task({ recordVersion: 1 }) })
state.current = reviewTask(); await state.submitReview('APPROVED')
assert.equal(fulfillment.dispatch.pendingCommand.rejected, true); assert.equal(writes, 1)
state.discardRejected(); await new Promise(resolve => setTimeout(resolve, 0))
assert.equal(fulfillment.dispatch.pendingCommand, null); assert.equal(state.canReview, false); assert.equal(writes, 1)
})
test('review confirmation cannot submit a different version or a revoked gate', async () => {
for (const change of [state => { state.current.recordVersion = 2 }, state => { state.current.actions.LOADING_REVIEW.allowed = false }]) {
storage(); const state = page(); state.current = reviewTask(); uni.showModal = async () => { change(state); return { confirm: true } }
await state.submitReview('APPROVED'); assert.equal(fulfillment.dispatch.pendingCommand, null)
}
})
test('wrong review result stage or stale refresh retains original command until authoritative confirmation', async () => {
storage(); beginDispatchCommand('PC/1', 'LOADING_REVIEW', { recordVersion: 0, decision: 'REJECTED', reason: '车牌不符' })
const state = page({ reviewDispatchLoading: async () => proof(fulfillment.dispatch.pendingCommand) })
await state.recoverCommand(); assert.ok(fulfillment.dispatch.pendingCommand); assert.match(state.error.message, /返回结果未确认/)
patchDispatchCommand({ result: { ...proof(fulfillment.dispatch.pendingCommand, 3), stage: 'BEFORE_LOADING' } })
const stale = page({ getDispatchTask: async () => reviewTask({ recordVersion: 2 }) }); await stale.recoverCommand()
assert.ok(fulfillment.dispatch.pendingCommand); assert.match(stale.error.message, /版本早于/)
})
test('review UI exposes authoritative resources and reason without reviewer database IDs or extra dispatch review action', async () => {
const template = (await readFile(new URL('../src/pages/dispatch/dispatch.vue', import.meta.url), 'utf8')).split('<script>')[0]
for (const field of ['resources?.vehicle?.plate', 'resources?.driver?.name', 'resources?.driver?.phone', 'reviews?.dispatchReview?.reason', 'rejectionReason']) assert.ok(template.includes(field))
assert.ok(template.includes("submitReview('APPROVED')")); assert.ok(template.includes("submitReview('REJECTED')"))
assert.doesNotMatch(template, /reviewerId|vehicleId|driverId|DISPATCH_REVIEW/)
})
test('loading completion keeps original manifest and measurements through a network replay and immediately enters delivery', async () => {
storage(); let fail = true; const sent = []
beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'original-manifest', measurements: [{ warehousePalletId: '21', weightKg: 100, volumeCbm: 2, palletSpaces: 1, deliveryIndex: 1 }] })
const state = page({ completeDispatchLoading: async (no, body) => { sent.push(body); if (fail) { fail = false; throw { network: true } }; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchCommand: async () => { throw { code: '404' } }, getDispatchTask: async () => task({ recordVersion: 1, stage: 'DELIVERING' }) })
await state.recoverCommand(); restoreFulfillmentDraft('operator:warehouse'); state.current.recordVersion = 9; await state.recoverCommand()
assert.deepEqual(sent[0], sent[1]); assert.equal(sent[1].recordVersion, 0); assert.equal(state.current.stage, 'DELIVERING'); assert.equal(state.canComplete, false); assert.equal(fulfillment.dispatch.pendingCommand, null)
await state.completeLoading(); await state.recoverCommand(); assert.equal(sent.length, 2)
})
test('loading completion requires a delivering result and does not accept an intermediate stage as success', async () => {
storage(); beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'manifest', measurements: [] })
const state = page({ completeDispatchLoading: async () => ({ ...proof(fulfillment.dispatch.pendingCommand), stage: 'LOADING' }) })
await state.recoverCommand(); assert.ok(fulfillment.dispatch.pendingCommand); assert.match(state.error.message, /返回结果未确认/)
})
+12 -13
View File
@@ -4,7 +4,7 @@ import { readFile } from 'node:fs/promises'
import { fulfillment, fulfillmentIdempotencyKey, releaseFulfillmentKey, resetFulfillmentSession, restoreFulfillmentDraft, updateFulfillmentDraft } from '../src/stores/fulfillment.js'
import {
completeDispatchLoading, getDispatchLoading, getDispatchCommand, requireDispatchVersion, completeOutboundTask, departDispatch, getDispatchTask, getOutboundTask,
reviewDispatchLoading, completeDispatchLoading, getDispatchLoading, getDispatchCommand, requireDispatchVersion, completeOutboundTask, getDispatchTask, getOutboundTask,
listDispatchTasks, listOutboundTasks, normalizeDispatchTask, normalizeOutboundTask,
pickDispatchItem, pickOutboundPallet, pickOutboundTask, resolveOutboundTask, outboundPickPayload
} from '../src/api/fulfillment.js'
@@ -95,12 +95,12 @@ test('phase-five workbench registers only dispatch and outbound PDA delivery bou
test('fulfillment API uses dedicated PDA routes and never sends customer or warehouse scope', async () => {
const api = await read('../src/api/fulfillment.js')
for (const path of ['dispatches', 'outbound-tasks', '/pick', '/loading-complete', '/depart']) assert.match(api, new RegExp(path.replace('/', '\\/')))
for (const path of ['dispatches', 'outbound-tasks', '/pick', '/loading-complete', '/loading-review']) assert.match(api, new RegExp(path.replace('/', '\\/')))
for (const forbidden of ['customerId', 'warehouseId', 'locationId']) assert.doesNotMatch(api, new RegExp(forbidden))
assert.match(api, /encodeURIComponent/)
})
test('fulfillment API calls the frozen list detail pick complete and depart routes with exact payloads', async () => {
test('fulfillment API calls the frozen list detail pick complete and review routes with exact payloads', async () => {
const calls = []
session.apiBase = '/api'; session.token = 'overseas-token'
globalThis.uni = {
@@ -119,7 +119,7 @@ test('fulfillment API calls the frozen list detail pick complete and depart rout
await listDispatchTasks(); await getDispatchTask('DSP/1')
await pickDispatchItem('DSP/1', '11', { recordVersion: 0, positionId: '22', packages: 1, productQuantity: 0, idempotencyKey: 'pick-key' })
await getDispatchLoading('DSP/1'); await getDispatchCommand('DSP/1', 'pick/key'); await completeDispatchLoading('DSP/1', { recordVersion: 1, idempotencyKey: 'complete-key', manifestHash: 'hash', measurements: [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 300, volumeCbm: 1, deliveryIndex: 1 }] })
await departDispatch('DSP/1', { recordVersion: '4', idempotencyKey: 'depart-key' })
await reviewDispatchLoading('DSP/1', { recordVersion: 0, idempotencyKey: 'review-key', decision: 'REJECTED', reason: '车牌不符' })
await listOutboundTasks('PENDING'); await getOutboundTask('OUT/TASK/1')
await resolveOutboundTask('OUT/1')
await pickOutboundTask('OUT/TASK/1', { outboundPalletItemId: '33', inventoryPositionId: '44', packages: 1, idempotencyKey: 'out-pick-key' })
@@ -127,10 +127,9 @@ test('fulfillment API calls the frozen list detail pick complete and depart rout
await completeOutboundTask('OUT/TASK/1', { idempotencyKey: 'out-complete-key' })
assert.equal(calls.length, 13)
assert.deepEqual(calls.find(call => call.url.endsWith('/depart')).data, { recordVersion: '4', idempotencyKey: 'depart-key' })
assert.deepEqual(calls.find(call => call.url.endsWith('/dispatches/DSP%2F1/loading-review')).data, { recordVersion: 0, idempotencyKey: 'review-key', decision: 'REJECTED', reason: '车牌不符' })
assert.ok(calls.some((call) => call.url.endsWith('/dispatches')))
assert.ok(calls.some((call) => call.url.includes('/dispatches/DSP%2F1/items/11/pick')))
assert.ok(calls.some((call) => call.url.endsWith('/dispatches/DSP%2F1/depart')))
assert.ok(calls.some((call) => call.url.includes('/outbound-tasks/OUT%2FTASK%2F1/pick')))
assert.ok(calls.some((call) => call.url.includes('/outbound-tasks/OUT%2FTASK%2F1/pallets/DP%2F1/pick')))
assert.ok(calls.some((call) => call.url.endsWith('/outbound-tasks/resolve/OUT%2F1')))
@@ -145,12 +144,12 @@ test('fulfillment API calls the frozen list detail pick complete and depart rout
})
test('dispatch and outbound adapters retain stable business identifiers and count progress', () => {
const dispatch = normalizeDispatchTask({ taskNo: 'DPT-1', dispatchNo: 'DSP-1', actions: { DEPART: { allowed: false, blockReason: '请先在平台安排派送' } }, items: [{ dispatchItemId: 8, palletNo: 'PLT-1', scheduledPackages: 5, pickedPackages: 2, positions: [{ positionId: 9, availablePackages: 5, allocatedPackages: 5, pickedPackages: 2 }] }] })
const dispatch = normalizeDispatchTask({ taskNo: 'DPT-1', dispatchNo: 'DSP-1', actions: { LOADING_COMPLETE: { allowed: false, blockReason: '请先完成真实拣货' } }, items: [{ dispatchItemId: 8, palletNo: 'PLT-1', scheduledPackages: 5, pickedPackages: 2, positions: [{ positionId: 9, availablePackages: 5, allocatedPackages: 5, pickedPackages: 2 }] }] })
assert.equal(dispatch.dispatchNo, 'DSP-1')
assert.equal(dispatch.items[0].palletNo, 'PLT-1')
assert.equal(dispatch.items[0].positions[0].availablePackages, 5)
assert.equal(dispatch.actions.DEPART.allowed, false)
assert.equal(dispatch.actions.DEPART.blockReason, '请先在平台安排派送')
assert.equal(dispatch.actions.LOADING_COMPLETE.allowed, false)
assert.equal(dispatch.actions.LOADING_COMPLETE.blockReason, '请先完成真实拣货')
const outbound = normalizeOutboundTask({ task: { taskNo: 'OUT-TASK-1', outboundNo: 'OUT-1', status: 'PENDING' }, items: [{ outboundPalletItemId: 3, palletNo: 'PLT-2', skuCode: 'SKU-1', inboundCode: 'INB-1', packageCount: 4, pickedPackageCount: 1, status: 'PARTIAL_PICKED', positions: [{ inventoryPositionId: 7, palletNo: 'PLT-2', cartonMark: 'BOX-1', skuCode: 'SKU-1', inboundCode: 'INB-1', availablePackages: 3, pickedPackages: 1 }] }] })
assert.equal(outbound.taskNo, 'OUT-TASK-1')
@@ -175,13 +174,13 @@ test('outbound draft restoration retains its retry key and stays scoped to opera
assert.throws(() => fulfillmentIdempotencyKey('dispatch', 'pick', {}), /完整原命令/)
})
test('pages retain loading, offline, diagnostics and separate native dispatch completion/departure', async () => {
test('pages retain loading, offline, diagnostics and single native dispatch completion into delivery', async () => {
const [dispatch, outbound] = await Promise.all([read('../src/pages/dispatch/dispatch.vue'), read('../src/pages/outbound/outbound.vue')])
for (const page of [dispatch,outbound]) {
for (const text of ['startScanBroadcast', '正在加载', '暂无', 'requestId', 'traceId', '返回列表']) assert.ok(page.includes(text))
assert.doesNotMatch(page, /模拟成功|静默成功|customerId:|warehouseId:|locationId:/)
}
assert.match(dispatch, /completeDispatchLoading/); assert.match(dispatch, /pickingReconciled === true/)
assert.doesNotMatch(dispatch, /departDispatch|canDepart|AFTER_LOADING|确认整车发车|等待后台安排派送/); assert.match(dispatch, /completeDispatchLoading/); assert.match(dispatch, /pickingReconciled === true/)
assert.match(dispatch, /beginDispatchCommand/); assert.match(dispatch, /await getDispatchTask[\s\S]*finishDispatchCommand/)
assert.doesNotMatch(dispatch, /tripDepartureRequest|completeDispatchPicking|fulfillmentIdempotencyKey/)
assert.match(outbound, /resolveOutboundTask[\s\S]*workflow === 'DISPATCH'[\s\S]*pages\/dispatch\/dispatch/)
@@ -242,8 +241,8 @@ async function dispatchScanHarness(items) {
const draft={palletCode:'OLD',dispatchItemId:'old',cartonCode:''};let sounds=0
const deps={PortalHeader:{},ScanTargetInput:{},DispatchHandoverStatus:{},fulfillment:{dispatch:draft},updateFulfillmentDraft:(_,value)=>Object.assign(draft,value),playSuccessSound:()=>{sounds++}}
const definition=new Function(...Object.keys(deps),source.replace(/^\s*import .*$/gm,'').replace('export default','return'))(...Object.values(deps))
const state={current:{dispatchNo:'DS-TEST',items:items.map(item=>({sourceKey:`source-${item.dispatchItemId}`,sourceBusinessType:'RELABEL_ORDER',sourceBusinessNo:`RL-${item.dispatchItemId}`,positions:[],...item,returnCartons:item.returnCartons||[]}))},error:{message:'previous error'},successMessage:'',setError(error){this.error={message:error.message}}}
state.selectPallet=definition.methods.selectPallet.bind(state);state.chooseSource=definition.methods.chooseSource.bind(state)
const state={current:{dispatchNo:'DS-TEST',stage:'LOADING',actions:{PICK:{allowed:true}},items:items.map(item=>({sourceKey:`source-${item.dispatchItemId}`,sourceBusinessType:'RELABEL_ORDER',sourceBusinessNo:`RL-${item.dispatchItemId}`,positions:[],...item,returnCartons:item.returnCartons||[]}))},error:{message:'previous error'},successMessage:'',setError(error){this.error={message:error.message}}}
state.gate=definition.methods.gate.bind(state);state.selectPallet=definition.methods.selectPallet.bind(state);state.chooseSource=definition.methods.chooseSource.bind(state)
return {state,draft,scan:definition.methods.handleScan.bind(state),sounds:()=>sounds}
}