feat(dispatch): validate reviewed whole-trip departures
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
2. 登录、Token 或统一入口请求先读 `src/api/auth.js`、`src/stores/session.js`、`src/config/runtime.js` 和 `../../docs/统一认证与服务通信契约.md`。
|
||||
3. 沿实际 API 定位:入仓/库存契约读 `../../baiyue-firstleg/lastleg-service/AGENTS.md`;资源契约读 `../../baiyue-firstleg/warehouse-overseas-service/AGENTS.md`;网关路径或鉴权问题才读 `../../baiyue-firstleg/gateway/AGENTS.md`。跨模块流程或入口不明才读 `../../docs/海外仓业务/README.md`;纯页面改动不自动读取后端指引。
|
||||
|
||||
最小读取集按任务保持在本文件、目标页面/API 和一个直接服务指引;只有跨服务状态或鉴权无法判断时才增加第二个服务指引。累计超过约 250 行时优先读取命中段,不全文加载海外仓 README。
|
||||
最小读取集按任务保持在适用指引和目标页面/API;纯展示任务不增加服务指引,服务端判断才读直接服务指引,跨服务状态或鉴权无法判断时再增加相关方。读取预算遵循根指引的行数与字符数标准,先读适用规则,再检索业务命中段,不全文加载海外仓 README。
|
||||
|
||||
## 项目定位
|
||||
|
||||
@@ -50,7 +50,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` 时恢复对应排车。详情提供独立返回列表入口;托盘业务码扫码拣货、幂等完成及发车仍严格消费服务端 `canDepart/departBlockReason`。公网前缀 `/api/pda/v1/last-leg/dispatches/**` |
|
||||
| 排车拣货与发车 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 普通进入默认展示任务列表和“上次作业”标记,不因本地草稿自动打开详情;只有出库扫码明确跳转 `?resume=1` 时恢复对应排车。详情提供独立返回列表入口;托盘业务码扫码拣货、幂等完成前消费服务端 `canPick/pickBlockReason` 调度审核结果,整车发车消费 `canDepart/departBlockReason`(含理货审核)。公网前缀 `/api/pda/v1/last-leg/dispatches/**` |
|
||||
| 整车发车与证照提醒 | 同上 `dispatch.vue`、`fulfillment.js` 的 `tripDepartureRequest` | 显示 `tripNo/tripDispatchCount/driverWarning`;发车提交 `tripRecordVersion` 对应的 `recordVersion`,幂等键按车次和版本保留。响应必须为该车次 `IN_TRANSIT` 且包含当前排车,随后读取服务端任务状态再释放幂等键;不能把整车状态当子单状态,也不能缺省伪造 `DEPARTED`。证照即将到期提醒不独立禁用按钮,门禁仍以服务端结果为准 |
|
||||
| 普通/一件代发出库 | `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` | 会话隔离、退出清理和后续真实作业历史契约 |
|
||||
@@ -85,7 +86,7 @@
|
||||
- 库内、换标与现场查询只调用 `/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 展示成业务编号。网络失败保留草稿和幂等键,恢复后以服务端任务状态为准;排车详情返回的 `canDepart`、`departBlockReason` 是发车按钮唯一门禁,平台未转派送生成 `WAIT_OUTBOUND` 出库单时不得发车;完成或发车成功后不得重复提交。派送始终由 Admin/Client 承载。
|
||||
- 排车和出库入口只调用专用 PDA 接口;客户与冻结海外仓范围由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不把内部数据库 ID 展示成业务编号。网络失败保留草稿和幂等键,恢复后以服务端任务状态为准;排车详情返回的 `canDepart`、`departBlockReason` 是发车资格依据,拣货及完成按钮同时核对当前单`canPick/pickBlockReason`;平台未转派送生成 `WAIT_OUTBOUND` 出库单时不得发车;完成或发车成功后不得重复提交。派送始终由 Admin/Client 承载。
|
||||
|
||||
## 质量门禁
|
||||
|
||||
|
||||
@@ -24,8 +24,14 @@ export function normalizeDispatchTask(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,
|
||||
@@ -97,6 +103,13 @@ 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 整车核平后发车' }
|
||||
}
|
||||
|
||||
export async function listOutboundTasks(status = '') {
|
||||
const suffix = status ? `?status=${encoded(status)}` : ''
|
||||
return asList(await overseasRequest('GET', `${OUTBOUND_BASE}${suffix}`)).map(normalizeOutboundTask)
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
<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 || '—' }} · 车牌 {{ 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>
|
||||
|
||||
@@ -67,12 +68,14 @@
|
||||
</view>
|
||||
|
||||
<view v-if="pickingCompleted" class="fulfillment-card gate-card">
|
||||
<text class="card-title">发车门禁</text>
|
||||
<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>
|
||||
<text v-else class="gate-note">本车所有排车单已就绪,确认后将一并发车。</text>
|
||||
<button v-if="!departed" class="depart-button" :disabled="!canDepart || busy" @tap="depart">{{ busyAction === 'depart' ? '发车中…' : '确认整车发车' }}</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -85,7 +88,7 @@
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { completeDispatchPicking, departDispatch, getDispatchTask, listDispatchTasks, pickDispatchItem } from '@/api/fulfillment.js'
|
||||
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'
|
||||
@@ -109,9 +112,9 @@
|
||||
allPicked() { return this.plannedPackages > 0 && this.remainingPackages === 0 },
|
||||
pickingCompleted() { return finishedStatuses.includes(this.current?.status) },
|
||||
departed() { return this.current?.status === 'DEPARTED' },
|
||||
canPick() { return this.selectedPosition && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining && !this.pickingCompleted },
|
||||
canPick() { return this.current?.canPick === true && this.selectedPosition && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining && !this.pickingCompleted },
|
||||
canCompleteStage() { return Boolean(this.current) && !this.pickingCompleted },
|
||||
canComplete() { return this.plannedPackages > 0 && this.remainingPackages === 0 },
|
||||
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' },
|
||||
@@ -168,8 +171,13 @@
|
||||
},
|
||||
async depart() {
|
||||
if (!this.canDepart) return
|
||||
const key = fulfillmentIdempotencyKey('dispatch', 'depart', { dispatchNo: this.current.dispatchNo })
|
||||
await this.run('执行发车', async () => { const result = await departDispatch(this.current.dispatchNo, { idempotencyKey: key.value, remark: '海外 PDA 拣货核平后发车' }); this.current = { ...this.current, status: result?.status || 'DEPARTED' }; releaseFulfillmentKey('dispatch', key.signature); this.successMessage = '排车已发车'; playSuccessSound() })
|
||||
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 = '' } },
|
||||
|
||||
@@ -6,13 +6,30 @@ import { fulfillment, fulfillmentIdempotencyKey, releaseFulfillmentKey, resetFul
|
||||
import {
|
||||
completeDispatchPicking, completeOutboundTask, departDispatch, getDispatchTask, getOutboundTask,
|
||||
listDispatchTasks, listOutboundTasks, normalizeDispatchTask, normalizeOutboundTask,
|
||||
pickDispatchItem, pickOutboundPallet, pickOutboundTask, resolveOutboundTask, outboundPickPayload
|
||||
pickDispatchItem, pickOutboundPallet, pickOutboundTask, resolveOutboundTask, outboundPickPayload, tripDepartureRequest
|
||||
} from '../src/api/fulfillment.js'
|
||||
import { session } from '../src/stores/session.js'
|
||||
import { toDispatchError } from '../src/utils/dispatch-error.js'
|
||||
|
||||
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
|
||||
|
||||
test('trip departure preserves version zero and rejects incomplete trip identity', () => {
|
||||
const task = normalizeDispatchTask({ tripNo: 'TR-1', tripRecordVersion: 0, tripDispatchCount: 2, items: [] })
|
||||
assert.equal(task.tripRecordVersion, '0')
|
||||
assert.equal(task.tripDispatchCount, 2)
|
||||
assert.deepEqual(tripDepartureRequest(task, 'departure-key'), { recordVersion: '0', idempotencyKey: 'departure-key', remark: '海外 PDA 整车核平后发车' })
|
||||
for (const invalid of [{}, { tripNo: 'TR-1' }, { tripNo: 'TR-1', tripRecordVersion: '-1' }]) assert.throws(() => tripDepartureRequest(invalid, 'key'), /车次信息不完整/)
|
||||
})
|
||||
|
||||
test('whole trip departure UI identifies all members and refreshes server state before releasing retry key', async () => {
|
||||
const page = await read('../src/pages/dispatch/dispatch.vue')
|
||||
assert.match(page, /current\.tripNo[\s\S]*current\.tripDispatchCount/)
|
||||
assert.match(page, /确认整车发车/)
|
||||
assert.match(page, /tripDepartureRequest\(this.current, key.value\)/)
|
||||
assert.doesNotMatch(page, /status: result\?\.status \|\| 'DEPARTED'/)
|
||||
assert.match(page, /result\.dispatches\?\.some[\s\S]*await getDispatchTask[\s\S]*releaseFulfillmentKey/)
|
||||
})
|
||||
|
||||
test('outbound partial pick submits the selected carton and distinguishes shared-pallet items', async () => {
|
||||
const first = outboundPickPayload({ outboundPalletItemId: '11' }, { inventoryPositionId: '21', cartonMark: 'BOX-A' }, '1')
|
||||
assert.deepEqual(first, { outboundPalletItemId: '11', inventoryPositionId: '21', packages: 1, cartonMarks: ['BOX-A'] })
|
||||
@@ -103,7 +120,7 @@ test('fulfillment API calls the frozen list detail pick complete and depart rout
|
||||
await listDispatchTasks('PICKING'); await getDispatchTask('DSP/1')
|
||||
await pickDispatchItem('DSP/1', '11', { positionId: '22', packages: 1, productQuantity: 0, idempotencyKey: 'pick-key' })
|
||||
await completeDispatchPicking('DSP/1', { idempotencyKey: 'complete-key' })
|
||||
await departDispatch('DSP/1', { idempotencyKey: 'depart-key' })
|
||||
await departDispatch('DSP/1', { recordVersion: '4', idempotencyKey: 'depart-key' })
|
||||
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' })
|
||||
@@ -111,6 +128,7 @@ 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, 11)
|
||||
assert.deepEqual(calls.find(call => call.url.endsWith('/depart')).data, { recordVersion: '4', idempotencyKey: 'depart-key' })
|
||||
assert.ok(calls.some((call) => call.url.endsWith('/dispatches?status=PICKING')))
|
||||
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')))
|
||||
@@ -241,3 +259,12 @@ test('receiving conflict recovery keeps request and trace evidence visible on na
|
||||
assert.match(operation, /openContainer\(candidate\)[\s\S]*recoverCurrentAfterConflict\(error, true\)/)
|
||||
assert.match(operation, /\.error-card text\{overflow-wrap:anywhere;word-break:break-word\}/)
|
||||
})
|
||||
|
||||
test('dispatch review gate preserves the server decision without treating a string or missing value as approval', async () => {
|
||||
assert.equal(normalizeDispatchTask({ canPick: true }).canPick, true)
|
||||
for (const canPick of [false, undefined, 'true']) assert.equal(normalizeDispatchTask({ canPick }).canPick, false)
|
||||
assert.equal(normalizeDispatchTask({ canPick: false, pickBlockReason: '调度审核已打回:预约待核对' }).pickBlockReason, '调度审核已打回:预约待核对')
|
||||
const source = await read('../src/pages/dispatch/dispatch.vue')
|
||||
assert.match(source, /canPick\(\) \{ return this.current\?\.canPick === true/)
|
||||
assert.match(source, /canComplete\(\) \{ return this.current\?\.canPick === true/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user