feat: add phase 5 overseas PDA fulfillment
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
- 本项目是“柏悦物流海外版”独立 uni-app Vue 3 工程,面向海外仓现场人员。
|
- 本项目是“柏悦物流海外版”独立 uni-app Vue 3 工程,面向海外仓现场人员。
|
||||||
- 工程必须独立安装、构建和发布,不得依赖 `apps/pda-uniapp/` 或仓库外 `pda-shared` 目录。
|
- 工程必须独立安装、构建和发布,不得依赖 `apps/pda-uniapp/` 或仓库外 `pda-shared` 目录。
|
||||||
- 当前业务范围按四期收敛为入仓、库内、换标、库存/库位/托盘查询;不包含海外仓出库、派送和国内集装箱拣货/装柜。
|
- 当前设备端业务范围包括四期入仓、库内、换标、库存/库位/托盘查询,以及五期排车拣货、普通/一件代发出库任务、任务完成和发车;不包含派送、客户回访,也不包含国内集装箱拣货/装柜。
|
||||||
|
|
||||||
## 服务与发布边界
|
## 服务与发布边界
|
||||||
|
|
||||||
@@ -42,6 +42,8 @@
|
|||||||
| 中英西语言设置 | `src/i18n/index.js`、`src/i18n/locales/`、`src/pages/profile/profile.vue` | 个人中心可选择并持久化 `zh-CN`、`en`、`es`;当前业务翻译资源仍为空,页面业务文案继续使用中文,不得把语言选择入口误报为全量翻译完成 |
|
| 中英西语言设置 | `src/i18n/index.js`、`src/i18n/locales/`、`src/pages/profile/profile.vue` | 个人中心可选择并持久化 `zh-CN`、`en`、`es`;当前业务翻译资源仍为空,页面业务文案继续使用中文,不得把语言选择入口误报为全量翻译完成 |
|
||||||
| 柜号签收、照片与入仓真实作业 | `src/pages/operation/operation.vue`、`src/api/inbound.js`、`src/api/receiving-adapter.js`、`src/api/overseas.js`、`src/stores/receiving.js` | 三个固定照片角色各一张,支持拍照/相册、鉴权预览及更换失败保留原图;Backend `lastleg-service/inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,公网前缀 `/api/pda/v1/last-leg/receiving-containers/**` |
|
| 柜号签收、照片与入仓真实作业 | `src/pages/operation/operation.vue`、`src/api/inbound.js`、`src/api/receiving-adapter.js`、`src/api/overseas.js`、`src/stores/receiving.js` | 三个固定照片角色各一张,支持拍照/相册、鉴权预览及更换失败保留原图;Backend `lastleg-service/inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,公网前缀 `/api/pda/v1/last-leg/receiving-containers/**` |
|
||||||
| 托盘管理真实作业 | `src/pages/pallet/pallet.vue`、`src/api/pallet.js` | 扫码查询托盘内容,按服务端返回的托盘/库存版本执行组托、拆托、换托和解绑;公网前缀 `/api/pda/v1/last-leg/pallet-operations/**` |
|
| 托盘管理真实作业 | `src/pages/pallet/pallet.vue`、`src/api/pallet.js` | 扫码查询托盘内容,按服务端返回的托盘/库存版本执行组托、拆托、换托和解绑;公网前缀 `/api/pda/v1/last-leg/pallet-operations/**` |
|
||||||
|
| 五期排车拣货与发车 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 任务列表/详情、托盘业务码扫码拣货、幂等完成和核平后发车;公网前缀 `/api/pda/v1/last-leg/dispatches/**` |
|
||||||
|
| 五期普通/一件代发出库 | `src/pages/outbound/outbound.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 任务列表/详情、稳定业务码扫码拣货和幂等完成;公网前缀 `/api/pda/v1/last-leg/outbound-tasks/**`;派送不进入 PDA |
|
||||||
| 其余库内、换标与现场查询待接前端 | `src/data/overseas-workbench.js` 中对应 `available: false` 入口 | Backend 已有 `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`relabel/pda/` 与 `overseas-pda-operations-v1.yaml`;本应用新增专用 API/状态恢复和测试前不得启用 |
|
| 其余库内、换标与现场查询待接前端 | `src/data/overseas-workbench.js` 中对应 `available: false` 入口 | Backend 已有 `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`relabel/pda/` 与 `overseas-pda-operations-v1.yaml`;本应用新增专用 API/状态恢复和测试前不得启用 |
|
||||||
| 扫码设备与诊断 | `src/utils/scan-broadcast.js`、`src/pages/scanner-test/scanner-test.vue` | `src/components/ScanTargetInput.vue`、声音反馈工具;广播 Action/Extra 见本文件服务边界 |
|
| 扫码设备与诊断 | `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` | 会话隔离、退出清理和后续真实作业历史契约 |
|
| 作业记录与人员 | `src/pages/history/history.vue`、`src/pages/profile/profile.vue` | 会话隔离、退出清理和后续真实作业历史契约 |
|
||||||
@@ -57,7 +59,7 @@
|
|||||||
- 除已开放的托盘管理外,其余库内、换标与现场查询仍保持禁用;新增真实作业必须接入 `/api/pda/**` 业务 API/DTO、`lastleg:pda:operate` 权限、Token 冻结仓库范围、错误态和契约测试,不得把平台、客户或内部接口直接暴露给 PDA。
|
- 除已开放的托盘管理外,其余库内、换标与现场查询仍保持禁用;新增真实作业必须接入 `/api/pda/**` 业务 API/DTO、`lastleg:pda:operate` 权限、Token 冻结仓库范围、错误态和契约测试,不得把平台、客户或内部接口直接暴露给 PDA。
|
||||||
- 已有海外入仓 PDA Controller 统一位于 Lastleg `/api/pda/v1/last-leg/inbound-plans/{planNo}/**`;浏览器或设备请求仍必须经 `gateway-overseas` 的显式 PDA 路由,不得直连 Lastleg,也不得改走国内 Gateway。
|
- 已有海外入仓 PDA Controller 统一位于 Lastleg `/api/pda/v1/last-leg/inbound-plans/{planNo}/**`;浏览器或设备请求仍必须经 `gateway-overseas` 的显式 PDA 路由,不得直连 Lastleg,也不得改走国内 Gateway。
|
||||||
- 海外业务 Token、PDA 权限和可信身份最终由 Gateway 与服务端校验;前端不得用工作台 `available`、本地客户 ID 或仓库 ID 代替授权和资源归属校验。
|
- 海外业务 Token、PDA 权限和可信身份最终由 Gateway 与服务端校验;前端不得用工作台 `available`、本地客户 ID 或仓库 ID 代替授权和资源归属校验。
|
||||||
- 四期仍止于入仓、库存与库内操作。后端历史 `dispatch/`、出库和派送代码不构成海外 PDA 页面入口,也不得加入工作台。
|
- 五期排车和出库入口只调用专用 PDA 接口;客户与冻结海外仓范围由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不把内部数据库 ID 展示成业务编号。网络失败保留草稿和幂等键,恢复后以服务端任务状态为准;完成或发车成功后不得重复提交。派送始终不进入 PDA。
|
||||||
|
|
||||||
## 质量门禁
|
## 质量门禁
|
||||||
|
|
||||||
@@ -65,5 +67,6 @@
|
|||||||
- 正式产品形态是海外 PDA App;`npm run build:h5` 仅用于浏览器调试和模板编译门禁,App 资源编译使用 `npm run build:app`。
|
- 正式产品形态是海外 PDA App;`npm run build:h5` 仅用于浏览器调试和模板编译门禁,App 资源编译使用 `npm run build:app`。
|
||||||
- APK/IPA 必须在 HBuilderX 或正式原生打包环境中使用独立 AppID 与签名验证;仅通过 App 资源编译不得声称 APK 真机验收完成。
|
- APK/IPA 必须在 HBuilderX 或正式原生打包环境中使用独立 AppID 与签名验证;仅通过 App 资源编译不得声称 APK 真机验收完成。
|
||||||
- 修改 `src/manifest.json` 图标映射或 `static/app-icons/` 时运行 `node --test tests/app-icons.test.mjs`,不得缺失已声明尺寸、用同一错误尺寸文件占位或从国内 PDA 复制 AppID/签名。
|
- 修改 `src/manifest.json` 图标映射或 `static/app-icons/` 时运行 `node --test tests/app-icons.test.mjs`,不得缺失已声明尺寸、用同一错误尺寸文件占位或从国内 PDA 复制 AppID/签名。
|
||||||
|
- 修改任务、入仓码、柜号、托盘码、库位码等用户可见标识时运行 `node --test tests/business-identifiers-ui.test.mjs`;不得以内部数据库 ID 作为业务编号回退。
|
||||||
- 修改更新协议、版本比较、强制更新、安装或重启行为时运行 `node --test tests/app-update.test.mjs`;新增原生模块、权限或不兼容运行时能力必须发海外 APK,不能仅发布 WGT,也不能下载国内更新包。
|
- 修改更新协议、版本比较、强制更新、安装或重启行为时运行 `node --test tests/app-update.test.mjs`;新增原生模块、权限或不兼容运行时能力必须发海外 APK,不能仅发布 WGT,也不能下载国内更新包。
|
||||||
- 不提交 `node_modules/`、`dist/`、`unpackage/`、环境文件、签名证书或打包产物。
|
- 不提交 `node_modules/`、`dist/`、`unpackage/`、环境文件、签名证书或打包产物。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
独立的 uni-app Vue 3 App 工程,正式产品形态是安装在海外仓手持 PDA 上的 App。工程不依赖国内 PDA 源码或仓库外共享目录,可独立安装依赖、运行、编译和发布;H5 只用于浏览器调试和编译门禁,不是最终交付形态。
|
独立的 uni-app Vue 3 App 工程,正式产品形态是安装在海外仓手持 PDA 上的 App。工程不依赖国内 PDA 源码或仓库外共享目录,可独立安装依赖、运行、编译和发布;H5 只用于浏览器调试和编译门禁,不是最终交付形态。
|
||||||
|
|
||||||
工作台按四期边界提供柜号级真实入仓作业和托盘管理,以及其余库内、换标和现场查询的禁用入口。登录使用国内 Auth 统一签发的双 Token;海外业务请求统一经 `gateway-overseas`,不得请求国内 Firstleg/Warehouse 业务接口。
|
工作台提供柜号级真实入仓、托盘管理,以及五期排车拣货、出库任务、一件代发出库、任务完成和发车入口;派送单、派送状态与客户回访不进入 PDA。登录使用国内 Auth 统一签发的双 Token;海外业务请求统一经 `gateway-overseas`,不得请求国内 Firstleg/Warehouse 业务接口。
|
||||||
|
|
||||||
## 本地运行与检查
|
## 本地运行与检查
|
||||||
|
|
||||||
@@ -42,6 +42,9 @@ npm run build:app
|
|||||||
- 入仓页面已接待卸柜候选、柜详情与账号锁柜、三类角色照片签收、current 恢复、统一箱码与既有物理托盘解析、FBA 按箱数和 OVERSEAS 逐箱收货、逐入仓码核平及整柜完成的真实 API;收货页不预列柜内入仓码,箱码解析成功后只显示本次命中的入仓码;409 会引导返回当前柜,错误会保留业务码、requestId 与 traceId。
|
- 入仓页面已接待卸柜候选、柜详情与账号锁柜、三类角色照片签收、current 恢复、统一箱码与既有物理托盘解析、FBA 按箱数和 OVERSEAS 逐箱收货、逐入仓码核平及整柜完成的真实 API;收货页不预列柜内入仓码,箱码解析成功后只显示本次命中的入仓码;409 会引导返回当前柜,错误会保留业务码、requestId 与 traceId。
|
||||||
- 候选列表同时显示已齐全与未齐全柜,展示计划到仓时间、总订单、入仓码、已生成计划、未操作和缺入仓码数量;未齐全柜置灰但可查看服务端阻断原因,只有服务端 `allPlansReady=true && operable=true` 才能 prepare。
|
- 候选列表同时显示已齐全与未齐全柜,展示计划到仓时间、总订单、入仓码、已生成计划、未操作和缺入仓码数量;未齐全柜置灰但可查看服务端阻断原因,只有服务端 `allPlansReady=true && operable=true` 才能 prepare。
|
||||||
- 托盘管理已接 `/api/pda/v1/last-leg/pallet-operations/**`:支持扫描查询当前仓实物托盘及库存内容,并按服务端版本执行组托、拆托、换托和解绑;不同目的仓不能合托,冻结或处理中的库存由服务端拒绝。
|
- 托盘管理已接 `/api/pda/v1/last-leg/pallet-operations/**`:支持扫描查询当前仓实物托盘及库存内容,并按服务端版本执行组托、拆托、换托和解绑;不同目的仓不能合托,冻结或处理中的库存由服务端拒绝。
|
||||||
|
- 排车拣货已接 `/api/pda/v1/last-leg/dispatches/**`:支持任务列表、业务单号恢复、托盘码扫码拣货、任务完成及全部箱数核平后的发车;服务端仍会校验关联出库单和托盘门禁。
|
||||||
|
- 出库作业已接 `/api/pda/v1/last-leg/outbound-tasks/**`:支持普通出库和一件代发任务列表/详情、托盘码/箱码/SKU/入仓码扫码拣货与核平完成;不会展示或进入派送流程。
|
||||||
|
- 五期写操作使用可恢复的稳定幂等键。网络失败保留当前任务、扫码草稿和幂等键;成功后释放键并按服务端状态恢复,避免重复完成或发车。
|
||||||
- 入仓请求不手填客户、计划、分配、仓库或库位标识;身份与当前柜范围由 Token、Gateway 和服务端会话确定。页面同时支持 PDA 广播扫码与 H5 人工输入。
|
- 入仓请求不手填客户、计划、分配、仓库或库位标识;身份与当前柜范围由 Token、Gateway 和服务端会话确定。页面同时支持 PDA 广播扫码与 H5 人工输入。
|
||||||
- 登录、恢复和刷新会 fail-closed 校验国内/海外双 Token 的同一平台操作主体、`OVERSEAS_WAREHOUSE_PDA_OPERATOR`、`lastleg:pda:operate` 及同一冻结海外仓;并发海外 401 共享一次国内 refresh,校验完成后原子替换双 Token并将原请求或照片上传重放一次。
|
- 登录、恢复和刷新会 fail-closed 校验国内/海外双 Token 的同一平台操作主体、`OVERSEAS_WAREHOUSE_PDA_OPERATOR`、`lastleg:pda:operate` 及同一冻结海外仓;并发海外 401 共享一次国内 refresh,校验完成后原子替换双 Token并将原请求或照片上传重放一次。
|
||||||
- 库存盘点、商品移库、换标收货/执行/复核以及库存/库位/托盘查询仍未接入海外 PDA 前端,入口保持禁用;不会调用平台、客户或内部接口,也不会模拟成功。
|
- 库存盘点、商品移库、换标收货/执行/复核以及库存/库位/托盘查询仍未接入海外 PDA 前端,入口保持禁用;不会调用平台、客户或内部接口,也不会模拟成功。
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { overseasRequest } from './overseas.js'
|
||||||
|
|
||||||
|
const DISPATCH_BASE = '/pda/v1/last-leg/dispatches'
|
||||||
|
const OUTBOUND_BASE = '/pda/v1/last-leg/outbound-tasks'
|
||||||
|
|
||||||
|
const encoded = (value) => encodeURIComponent(String(value || '').trim())
|
||||||
|
|
||||||
|
function asList(value) {
|
||||||
|
if (Array.isArray(value)) return value
|
||||||
|
if (Array.isArray(value?.records)) return value.records
|
||||||
|
if (Array.isArray(value?.items)) return value.items
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function count(value) {
|
||||||
|
const number = Number(value || 0)
|
||||||
|
return Number.isFinite(number) ? Math.max(0, number) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDispatchTask(value) {
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
taskNo: String(value?.taskNo || ''),
|
||||||
|
dispatchNo: String(value?.dispatchNo || ''),
|
||||||
|
vehiclePlate: String(value?.vehiclePlate || ''),
|
||||||
|
status: String(value?.status || ''),
|
||||||
|
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),
|
||||||
|
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)
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeOutboundTask(value) {
|
||||||
|
const header = value?.task || value || {}
|
||||||
|
return {
|
||||||
|
...header,
|
||||||
|
taskNo: String(header?.taskNo || ''),
|
||||||
|
outboundNo: String(header?.outboundNo || ''),
|
||||||
|
status: String(header?.status || ''),
|
||||||
|
items: asList(value?.items).map((item) => ({
|
||||||
|
...item,
|
||||||
|
outboundPalletItemId: String(item?.outboundPalletItemId || ''),
|
||||||
|
palletNo: String(item?.palletNo || ''),
|
||||||
|
plannedPackages: count(item?.packageCount),
|
||||||
|
pickedPackages: count(item?.pickedPackageCount),
|
||||||
|
positions: asList(item?.positions).map((position) => ({
|
||||||
|
...position,
|
||||||
|
inventoryPositionId: String(position?.inventoryPositionId || ''),
|
||||||
|
palletNo: String(position?.palletNo || item?.palletNo || ''),
|
||||||
|
cartonMark: String(position?.cartonMark || ''),
|
||||||
|
skuCode: String(position?.skuCode || position?.sku || ''),
|
||||||
|
inboundCode: String(position?.inboundCode || ''),
|
||||||
|
availablePackages: count(position?.availablePackages),
|
||||||
|
pickedPackages: count(position?.pickedPackages)
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDispatchTasks(status = '') {
|
||||||
|
const suffix = status ? `?status=${encoded(status)}` : ''
|
||||||
|
return asList(await overseasRequest('GET', `${DISPATCH_BASE}${suffix}`)).map(normalizeDispatchTask)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDispatchTask(dispatchNo) {
|
||||||
|
return normalizeDispatchTask(await overseasRequest('GET', `${DISPATCH_BASE}/${encoded(dispatchNo)}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickDispatchItem(dispatchNo, dispatchItemId, request) {
|
||||||
|
return overseasRequest('POST', `${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 departDispatch(dispatchNo, request) {
|
||||||
|
return overseasRequest('POST', `${DISPATCH_BASE}/${encoded(dispatchNo)}/depart`, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listOutboundTasks(status = '') {
|
||||||
|
const suffix = status ? `?status=${encoded(status)}` : ''
|
||||||
|
return asList(await overseasRequest('GET', `${OUTBOUND_BASE}${suffix}`)).map(normalizeOutboundTask)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOutboundTask(taskNo) {
|
||||||
|
return normalizeOutboundTask(await overseasRequest('GET', `${OUTBOUND_BASE}/${encoded(taskNo)}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickOutboundTask(taskNo, request) {
|
||||||
|
return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/pick`, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completeOutboundTask(taskNo, request) {
|
||||||
|
return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/complete`, request)
|
||||||
|
}
|
||||||
@@ -2,6 +2,10 @@ export const MODULES = [
|
|||||||
{ key: 'inbound', name: '入仓作业', nameKey: 'modules.inbound.name', icon: '入', description: '单账号锁定一柜,完成柜号级入库闭环', descriptionKey: 'modules.inbound.description', operations: [
|
{ key: 'inbound', name: '入仓作业', nameKey: 'modules.inbound.name', icon: '入', description: '单账号锁定一柜,完成柜号级入库闭环', descriptionKey: 'modules.inbound.description', operations: [
|
||||||
{ key: 'receiving', name: '拆柜收货', nameKey: 'modules.receiving.name', icon: '卸', iconType: 'download', description: '三类照片签收,箱码定位入仓码后按目的类型收货', descriptionKey: 'modules.receiving.description', available: true }
|
{ key: 'receiving', name: '拆柜收货', nameKey: 'modules.receiving.name', icon: '卸', iconType: 'download', description: '三类照片签收,箱码定位入仓码后按目的类型收货', descriptionKey: 'modules.receiving.description', available: true }
|
||||||
] },
|
] },
|
||||||
|
{ 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: '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: [
|
{ key: 'inventory', name: '库内作业', nameKey: 'modules.inventory.name', icon: '库', description: '盘点、移库与托盘管理', descriptionKey: 'modules.inventory.description', operations: [
|
||||||
{ key: 'stocktake', name: '库存盘点', nameKey: 'modules.stocktake.name', icon: '盘', iconType: 'list', description: '按任务扫描库位和库存', descriptionKey: 'modules.stocktake.description', available: false },
|
{ key: 'stocktake', name: '库存盘点', nameKey: 'modules.stocktake.name', icon: '盘', iconType: 'list', description: '按任务扫描库位和库存', descriptionKey: 'modules.stocktake.description', available: false },
|
||||||
{ key: 'move', name: '商品移库', nameKey: 'modules.move.name', icon: '移', iconType: 'loop', description: '扫描来源与目标库位完成移库', descriptionKey: 'modules.move.description', available: false },
|
{ key: 'move', name: '商品移库', nameKey: 'modules.move.name', icon: '移', iconType: 'loop', description: '扫描来源与目标库位完成移库', descriptionKey: 'modules.move.description', available: false },
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
{ "path": "pages/module/module", "style": { "navigationStyle": "custom", "navigationBarTitleText": "海外仓业务", "backgroundColor": "#F5F7FA" } },
|
{ "path": "pages/module/module", "style": { "navigationStyle": "custom", "navigationBarTitleText": "海外仓业务", "backgroundColor": "#F5F7FA" } },
|
||||||
{ "path": "pages/operation/operation", "style": { "navigationStyle": "custom", "navigationBarTitleText": "柜号入库与现场作业", "backgroundColor": "#F5F7FA" } },
|
{ "path": "pages/operation/operation", "style": { "navigationStyle": "custom", "navigationBarTitleText": "柜号入库与现场作业", "backgroundColor": "#F5F7FA" } },
|
||||||
{ "path": "pages/pallet/pallet", "style": { "navigationStyle": "custom", "navigationBarTitleText": "托盘管理", "backgroundColor": "#F5F7FA" } },
|
{ "path": "pages/pallet/pallet", "style": { "navigationStyle": "custom", "navigationBarTitleText": "托盘管理", "backgroundColor": "#F5F7FA" } },
|
||||||
|
{ "path": "pages/dispatch/dispatch", "style": { "navigationStyle": "custom", "navigationBarTitleText": "排车拣货与发车", "backgroundColor": "#F5F7FA" } },
|
||||||
|
{ "path": "pages/outbound/outbound", "style": { "navigationStyle": "custom", "navigationBarTitleText": "出库作业", "backgroundColor": "#F5F7FA" } },
|
||||||
{ "path": "pages/history/history", "style": { "navigationStyle": "custom", "navigationBarTitleText": "作业记录", "backgroundColor": "#F5F7FA" } },
|
{ "path": "pages/history/history", "style": { "navigationStyle": "custom", "navigationBarTitleText": "作业记录", "backgroundColor": "#F5F7FA" } },
|
||||||
{ "path": "pages/profile/profile", "style": { "navigationStyle": "custom", "navigationBarTitleText": "个人中心", "backgroundColor": "#F5F7FA" } },
|
{ "path": "pages/profile/profile", "style": { "navigationStyle": "custom", "navigationBarTitleText": "个人中心", "backgroundColor": "#F5F7FA" } },
|
||||||
{ "path": "pages/scanner-test/scanner-test", "style": { "navigationStyle": "custom", "navigationBarTitleText": "广播扫码测试", "backgroundColor": "#F5F7FA" } }
|
{ "path": "pages/scanner-test/scanner-test", "style": { "navigationStyle": "custom", "navigationBarTitleText": "广播扫码测试", "backgroundColor": "#F5F7FA" } }
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<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 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"><text>待处理排车任务</text><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"><text class="business-code">{{ task.dispatchNo || '—' }}</text><text class="status-badge">{{ statusLabel(task.status) }}</text></view>
|
||||||
|
<text class="task-meta">任务号 {{ task.taskNo || '—' }} · 车牌 {{ task.vehiclePlate || '—' }}</text>
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="!error" class="fulfillment-state"><text class="state-title">暂无排车拣货任务</text><text>可刷新列表,或直接扫描排车单号恢复作业。</text></view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="current">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<button class="link-button" :disabled="busy" @tap="closeTask">返回任务列表</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="current.items.length" class="item-list">
|
||||||
|
<view v-for="item in current.items" :key="item.palletNo" class="fulfillment-card item-card" :class="{ selected: selectedItem?.palletNo === item.palletNo }" @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>
|
||||||
|
<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>
|
||||||
|
<view class="count-row"><text>拣货箱数</text><input type="number" :value="packages" @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="!allPicked" class="gate-warning">服务端仍显示 {{ remainingPackages }} 箱未拣,不允许发车。</text>
|
||||||
|
<text v-else-if="departed" class="gate-success">该排车任务已执行发车,不可重复提交。</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>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import PortalHeader from '@/components/PortalHeader.vue'
|
||||||
|
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||||
|
import { completeDispatchPicking, departDispatch, getDispatchTask, listDispatchTasks, pickDispatchItem } 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 { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||||
|
|
||||||
|
const finishedStatuses = ['COMPLETED', 'PICKING_COMPLETED', 'READY_TO_DEPART', 'DEPARTED']
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { PortalHeader, ScanTargetInput },
|
||||||
|
data() { return { user: session.currentUser, tasks: [], current: null, scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '' } },
|
||||||
|
computed: {
|
||||||
|
packages() { return fulfillment.dispatch.packages },
|
||||||
|
selectedItem() { return this.current?.items?.find((item) => item.palletNo === fulfillment.dispatch.palletCode) || null },
|
||||||
|
selectedPosition() { return this.selectedItem?.positions?.find((item) => item.availablePackages > 0) || this.selectedItem?.positions?.[0] || null },
|
||||||
|
selectedRemaining() { return Math.max(0, Math.min(this.selectedItem?.scheduledPackages - this.selectedItem?.pickedPackages || 0, 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.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 },
|
||||||
|
canDepart() { return this.pickingCompleted && this.allPicked && !this.departed }
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
if (!requireSession()) return
|
||||||
|
this.user = session.currentUser
|
||||||
|
restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`)
|
||||||
|
this.startScanner()
|
||||||
|
if (fulfillment.dispatch.currentNo) this.openTask(fulfillment.dispatch.currentNo)
|
||||||
|
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); updateFulfillmentDraft('dispatch', { currentNo: this.current.dispatchNo, palletCode: previousNo === this.current.dispatchNo ? fulfillment.dispatch.palletCode : '' }); this.scanCode = '' }, true)
|
||||||
|
},
|
||||||
|
closeTask() { this.current = null; this.scanCode = ''; this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { currentNo: '', palletCode: '', 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
|
||||||
|
if (!this.current || code === this.current.dispatchNo || code === this.current.taskNo) return this.openTask(code === this.current?.taskNo ? this.current.dispatchNo : code)
|
||||||
|
const item = this.current.items.find((value) => [value.palletNo, value.fbaWarehouseCode].includes(code))
|
||||||
|
if (!item) return this.setError(new Error('扫码未匹配当前排车任务的托盘或稳定业务码'), '扫码未匹配')
|
||||||
|
this.selectPallet(item); this.scanCode = ''; playSuccessSound()
|
||||||
|
},
|
||||||
|
selectPallet(item) { if (!item?.palletNo || item.scheduledPackages <= item.pickedPackages) return; updateFulfillmentDraft('dispatch', { palletCode: item.palletNo, packages: Math.max(1, Math.min(item.scheduledPackages - item.pickedPackages, item.positions?.[0]?.availablePackages || 1)) }) },
|
||||||
|
async submitPick() {
|
||||||
|
if (!this.canPick) return
|
||||||
|
const payload = { dispatchNo: this.current.dispatchNo, palletNo: this.selectedItem.palletNo, positionId: this.selectedPosition.id, packages: Number(this.packages), productQuantity: 0 }
|
||||||
|
const key = fulfillmentIdempotencyKey('dispatch', 'pick', payload)
|
||||||
|
await this.run('提交排车拣货', async () => {
|
||||||
|
await pickDispatchItem(this.current.dispatchNo, this.selectedItem.dispatchItemId, { positionId: this.selectedPosition.id, packages: Number(this.packages), productQuantity: 0, idempotencyKey: key.value })
|
||||||
|
this.current = await getDispatchTask(this.current.dispatchNo); releaseFulfillmentKey('dispatch', key.signature); updateFulfillmentDraft('dispatch', { palletCode: '', 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', { 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() })
|
||||||
|
},
|
||||||
|
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) { const forbidden = ['403', 'A0403'].includes(String(error?.code || '')); this.error = { message: forbidden ? '当前账号没有海外仓 PDA 作业权限,请联系管理员授权' : error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId, network: Boolean(error?.network) }; 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@import './fulfillment.scss';
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
.fulfillment-page { height: 100vh; overflow: hidden; }
|
||||||
|
.fulfillment-scroll { height: calc(100vh - 48px - var(--status-bar-height, 0px)); }
|
||||||
|
.fulfillment-content { padding-bottom: 44px; }
|
||||||
|
.fulfillment-heading { margin: 4px 2px 12px; }
|
||||||
|
.fulfillment-title, .fulfillment-subtitle, .scan-help, .state-title, .fulfillment-error text, .fulfillment-success text, .task-meta, .card-title, .gate-note, .gate-warning, .gate-success, .offline-note { display: block; }
|
||||||
|
.fulfillment-title { color: #14263d; font-size: 21px; font-weight: 800; }
|
||||||
|
.fulfillment-subtitle { margin-top: 4px; color: #708197; font-size: 11px; line-height: 17px; }
|
||||||
|
.fulfillment-card { box-sizing: border-box; margin-bottom: 10px; padding: 14px; border: 1px solid #dce5f0; border-radius: 13px; background: #fff; box-shadow: 0 4px 15px rgba(32,67,111,.06); }
|
||||||
|
.secondary-button, .primary-button, .depart-button, .link-button { box-sizing: border-box; width: 100%; min-height: 44px; margin: 10px 0 0; border-radius: 8px; font-size: 13px; line-height: 44px; }
|
||||||
|
.secondary-button { border: 1px solid #9dc0f4; color: #135ee8; background: #f8fbff; }
|
||||||
|
.primary-button { color: #fff; font-weight: 800; background: #135ee8; }
|
||||||
|
.depart-button { color: #fff; font-weight: 800; background: #0a8754; }
|
||||||
|
.link-button { color: #5d6f84; background: #f1f4f8; }
|
||||||
|
.secondary-button[disabled], .primary-button[disabled], .depart-button[disabled], .link-button[disabled] { color: #9aa7b5; background: #e8edf3; opacity: 1; }
|
||||||
|
.scan-help, .gate-note { margin-top: 8px; color: #718095; font-size: 10px; line-height: 16px; }
|
||||||
|
.fulfillment-state { margin-bottom: 10px; padding: 28px 16px; border-radius: 12px; color: #748399; font-size: 11px; line-height: 18px; text-align: center; background: #fff; }
|
||||||
|
.state-title { margin-bottom: 4px; color: #263b55; font-size: 14px; font-weight: 800; }
|
||||||
|
.fulfillment-error, .fulfillment-success { margin-bottom: 10px; padding: 12px; border-radius: 10px; font-size: 10px; line-height: 17px; }
|
||||||
|
.fulfillment-error { color: #a62323; background: #fff0f0; }
|
||||||
|
.fulfillment-success { color: #087443; background: #e8f8ef; }
|
||||||
|
.offline-note { margin-top: 7px; color: #8a5a00; }
|
||||||
|
.list-toolbar, .task-head, .count-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||||
|
.list-toolbar { margin: 16px 2px 10px; color: #233951; font-size: 13px; font-weight: 800; }
|
||||||
|
.list-toolbar button { min-height: 32px; margin: 0; padding: 0 12px; border-radius: 7px; color: #135ee8; font-size: 11px; line-height: 32px; background: #e9f2ff; }
|
||||||
|
.task-list, .item-list { display: flex; flex-direction: column; gap: 9px; }
|
||||||
|
.task-card { box-sizing: border-box; width: 100%; min-height: 76px; margin: 0; padding: 13px; border: 1px solid #dce5f0; border-radius: 11px; text-align: left; background: #fff; }
|
||||||
|
.task-card::after { border: 0; }
|
||||||
|
.business-code { color: #16304d; font-size: 15px; font-weight: 800; overflow-wrap: anywhere; }
|
||||||
|
.status-badge { flex-shrink: 0; padding: 4px 7px; border-radius: 99px; color: #165fbf; font-size: 10px; background: #e6f0ff; }
|
||||||
|
.task-meta { margin-top: 6px; color: #718095; font-size: 10px; line-height: 16px; }
|
||||||
|
.summary-card { display: block; }
|
||||||
|
.metrics { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); margin-top: 12px; padding: 10px 0; border-radius: 9px; background: #f5f8fc; }
|
||||||
|
.metrics view { display: flex; align-items: center; flex-direction: column; gap: 3px; color: #718095; font-size: 9px; }
|
||||||
|
.metrics view + view { border-left: 1px solid #dce5f0; }
|
||||||
|
.metric-value { color: #135ee8; font-size: 20px; font-weight: 800; }
|
||||||
|
.metric-value.success { color: #087443; }
|
||||||
|
.metric-value.warning { color: #a56800; }
|
||||||
|
.item-card { margin: 0; }
|
||||||
|
.item-card.selected { border: 2px solid #135ee8; background: #f8fbff; }
|
||||||
|
.progress { height: 6px; margin-top: 10px; overflow: hidden; border-radius: 99px; background: #e4eaf1; }
|
||||||
|
.progress view { height: 100%; border-radius: 99px; background: #20a464; }
|
||||||
|
.card-title { color: #17314f; font-size: 13px; font-weight: 800; }
|
||||||
|
.count-row { margin-top: 12px; color: #65768b; font-size: 10px; }
|
||||||
|
.count-row input { box-sizing: border-box; width: 78px; height: 42px; padding: 0 8px; border: 2px solid #135ee8; border-radius: 8px; color: #17314f; font-size: 17px; text-align: center; background: #fff; }
|
||||||
|
.gate-card textarea { box-sizing: border-box; width: 100%; height: 76px; margin-top: 10px; padding: 9px; border: 1px solid #d3dde9; border-radius: 8px; color: #27384d; font-size: 12px; background: #fbfcfe; }
|
||||||
|
.gate-warning { margin-top: 8px; color: #a56800; font-size: 11px; line-height: 17px; }
|
||||||
|
.gate-success { margin-top: 8px; color: #087443; font-size: 11px; line-height: 17px; }
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<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">包含排车转出的出库任务和一件代发出库任务;派送单与派送状态不进入 PDA</text></view>
|
||||||
|
<view class="fulfillment-card">
|
||||||
|
<ScanTargetInput name="outbound" :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"><text>待处理出库任务</text><button :disabled="loading" @tap="loadTasks">刷新</button></view>
|
||||||
|
<view v-if="tasks.length" class="task-list">
|
||||||
|
<button v-for="task in tasks" :key="task.taskNo" class="task-card" @tap="openTask(task.taskNo)"><view class="task-head"><text class="business-code">{{ task.taskNo || '—' }}</text><text class="status-badge">{{ statusLabel(task.status) }}</text></view><text class="task-meta">出库单 {{ task.outboundNo || '—' }} · {{ taskType(task) }}</text></button>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="!error" class="fulfillment-state"><text class="state-title">暂无出库任务</text><text>可刷新列表,或扫描任务号恢复未完成作业。</text></view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="current">
|
||||||
|
<view class="fulfillment-card summary-card"><view class="task-head"><text class="business-code">{{ current.taskNo || '—' }}</text><text class="status-badge">{{ statusLabel(current.status) }}</text></view><text class="task-meta">出库单 {{ current.outboundNo || '—' }} · {{ taskType(current) }}</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><button class="link-button" :disabled="busy" @tap="closeTask">返回任务列表</button></view>
|
||||||
|
|
||||||
|
<view v-if="current.items.length" class="item-list">
|
||||||
|
<view v-for="item in current.items" :key="item.palletNo || item.outboundPalletItemId" class="fulfillment-card item-card">
|
||||||
|
<view class="task-head"><text class="business-code">{{ item.palletNo || '散件出库' }}</text><text>{{ item.pickedPackages }}/{{ item.plannedPackages }} 箱</text></view>
|
||||||
|
<view v-for="position in item.positions" :key="businessLineKey(position)" class="outbound-line" :class="{ selected: selectedPosition === position }" @tap="selectPosition(item, position)">
|
||||||
|
<text class="line-code">{{ lineBusinessCode(position) }}</text><text class="line-meta">{{ position.inboundCode || '无入仓码' }} · 已拣 {{ position.pickedPackages }} 箱 · 可拣 {{ position.availablePackages }} 箱</text>
|
||||||
|
</view>
|
||||||
|
<text v-if="!item.positions.length" class="missing-scan-contract">当前明细缺少可扫码库存业务码,已禁止拣货;请刷新或联系平台确认任务下发。</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-else class="fulfillment-state"><text class="state-title">任务详情尚无可拣货明细</text><text>请刷新;若持续为空,请联系平台确认出库货物是否已下发。</text></view>
|
||||||
|
|
||||||
|
<view v-if="selectedPosition && selectedRemaining > 0" class="fulfillment-card"><text class="card-title">本次拣货:{{ lineBusinessCode(selectedPosition) }}</text><view class="count-row"><text>拣货箱数</text><input type="number" :value="packages" @input="updatePackages" /><text>最多 {{ selectedRemaining }} 箱</text></view><button class="primary-button" :disabled="!canPick || busy" @tap="submitPick">{{ busyAction === 'pick' ? '提交中…' : '确认出库拣货' }}</button></view>
|
||||||
|
|
||||||
|
<view class="fulfillment-card gate-card"><text class="card-title">完成出库任务</text><text v-if="remainingPackages > 0" class="gate-warning">尚有 {{ remainingPackages }} 箱未拣,不能完成出库。</text><text v-else-if="current.status === 'COMPLETED'" class="gate-success">出库任务已完成,不可重复提交。</text><text v-else class="gate-note">全部计划货物已扫描核平,服务端将再次校验库存门禁。</text><button v-if="current.status !== 'COMPLETED'" class="primary-button" :disabled="!canComplete || busy" @tap="completeTask">{{ busyAction === 'complete' ? '完成中…' : '完成出库任务' }}</button></view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<view v-if="successMessage" class="fulfillment-success" role="status"><text>{{ successMessage }}</text></view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import PortalHeader from '@/components/PortalHeader.vue'
|
||||||
|
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||||
|
import { completeOutboundTask, getOutboundTask, listOutboundTasks, pickOutboundTask } 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 { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { PortalHeader, ScanTargetInput },
|
||||||
|
data() { return { user: session.currentUser, tasks: [], current: null, selectedItem: null, selectedPosition: null, scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '' } },
|
||||||
|
computed: {
|
||||||
|
packages() { return fulfillment.outbound.packages },
|
||||||
|
plannedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.plannedPackages, 0) },
|
||||||
|
pickedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.pickedPackages, 0) },
|
||||||
|
remainingPackages() { return Math.max(0, this.plannedPackages - this.pickedPackages) },
|
||||||
|
selectedRemaining() { return Math.max(0, Math.min(Number(this.selectedItem?.plannedPackages || 0) - Number(this.selectedItem?.pickedPackages || 0), Number(this.selectedPosition?.availablePackages || 0))) },
|
||||||
|
canPick() { return this.current?.status !== 'COMPLETED' && this.selectedItem && this.selectedPosition && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining },
|
||||||
|
canComplete() { return this.plannedPackages > 0 && this.remainingPackages === 0 && this.current?.status !== 'COMPLETED' }
|
||||||
|
},
|
||||||
|
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.scanCode = fulfillment.outbound.scanCode; this.startScanner(); if (fulfillment.outbound.currentTaskNo) this.openTask(fulfillment.outbound.currentTaskNo); 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; updateFulfillmentDraft('outbound', { scanCode: value }) },
|
||||||
|
updatePackages(event) { const value = Math.trunc(Number(event.detail.value || 0)); updateFulfillmentDraft('outbound', { packages: Number.isFinite(value) ? Math.max(0, value) : 0 }) },
|
||||||
|
async loadTasks() { await this.run('加载出库任务', async () => { this.tasks = await listOutboundTasks(); this.current = null }, true) },
|
||||||
|
async openTask(taskNo) { const code = String(taskNo || '').trim(); if (!code) return; await this.run('恢复出库任务', async () => { const pendingScan = fulfillment.outbound.currentTaskNo === code ? fulfillment.outbound.scanCode : ''; this.current = await getOutboundTask(code); this.selectedItem = null; this.selectedPosition = null; this.scanCode = pendingScan; updateFulfillmentDraft('outbound', { currentTaskNo: this.current.taskNo, scanCode: pendingScan, packages: pendingScan ? fulfillment.outbound.packages : 1 }); if (pendingScan) this.findAndSelectPosition(pendingScan, false) }, true) },
|
||||||
|
closeTask() { this.current = null; this.selectedItem = null; this.selectedPosition = null; this.error = null; this.successMessage = ''; updateFulfillmentDraft('outbound', { currentTaskNo: '', scanCode: '', 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 outbound] scan', { code, task: this.current?.taskNo || '' }); this.scanCode = code; updateFulfillmentDraft('outbound', { scanCode: code })
|
||||||
|
if (!this.current || code === this.current.taskNo || code === this.current.outboundNo) return this.openTask(code === this.current?.outboundNo ? this.current.taskNo : code)
|
||||||
|
if (this.findAndSelectPosition(code)) return
|
||||||
|
this.setError(new Error('扫码未匹配当前出库任务的托盘码、箱码、SKU 或入仓码'), '扫码未匹配')
|
||||||
|
},
|
||||||
|
findAndSelectPosition(code, feedback = true) { for (const item of this.current?.items || []) { const position = item.positions.find((value) => [value.palletNo, value.cartonMark, value.skuCode, value.inboundCode].filter(Boolean).includes(code)); if (position) { this.selectPosition(item, position); if (feedback) playSuccessSound(); return true } } return false },
|
||||||
|
selectPosition(item, position) { if (!position || position.availablePackages <= 0) return; this.selectedItem = item; this.selectedPosition = position; updateFulfillmentDraft('outbound', { scanCode: this.lineBusinessCode(position), packages: Math.max(1, Math.min(item.plannedPackages - item.pickedPackages, position.availablePackages)) }) },
|
||||||
|
async submitPick() {
|
||||||
|
if (!this.canPick) return
|
||||||
|
const businessCode = this.lineBusinessCode(this.selectedPosition); const payload = { taskNo: this.current.taskNo, businessCode, packages: Number(this.packages) }; const key = fulfillmentIdempotencyKey('outbound', 'pick', payload)
|
||||||
|
await this.run('提交出库拣货', async () => { await pickOutboundTask(this.current.taskNo, { outboundPalletItemId: this.selectedItem.outboundPalletItemId, inventoryPositionId: this.selectedPosition.inventoryPositionId, packages: Number(this.packages), idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); releaseFulfillmentKey('outbound', key.signature); this.selectedItem = null; this.selectedPosition = null; updateFulfillmentDraft('outbound', { scanCode: '', packages: 1 }); this.successMessage = '本次出库拣货已记录'; playSuccessSound() })
|
||||||
|
},
|
||||||
|
async completeTask() {
|
||||||
|
if (!this.canComplete) return
|
||||||
|
const key = fulfillmentIdempotencyKey('outbound', 'complete', { taskNo: this.current.taskNo })
|
||||||
|
await this.run('完成出库任务', async () => { await completeOutboundTask(this.current.taskNo, { idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); releaseFulfillmentKey('outbound', key.signature); this.successMessage = '出库任务已完成'; playSuccessSound() })
|
||||||
|
},
|
||||||
|
async retry() { if (this.current?.taskNo) await this.openTask(this.current.taskNo); else if (fulfillment.outbound.currentTaskNo) await this.openTask(fulfillment.outbound.currentTaskNo); 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('完成') ? 'complete' : 'pick' } try { await work() } catch (error) { this.setError(error, `${action}失败`) } finally { this.loading = false; this.busy = false; this.busyAction = '' } },
|
||||||
|
setError(error, fallback) { const forbidden = ['403', 'A0403'].includes(String(error?.code || '')); this.error = { message: forbidden ? '当前账号没有海外仓 PDA 作业权限,请联系管理员授权' : error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId, network: Boolean(error?.network) }; console.error('[Overseas PDA outbound] failed', this.error); playErrorSound() },
|
||||||
|
lineBusinessCode(position) { return position?.cartonMark || position?.palletNo || position?.skuCode || position?.inboundCode || '业务码缺失' },
|
||||||
|
businessLineKey(position) { return [position?.cartonMark, position?.palletNo, position?.skuCode, position?.inboundCode].filter(Boolean).join(':') || `line-${position?.inventoryPositionId || 'missing'}` },
|
||||||
|
statusLabel(status) { return ({ PENDING: '待拣货', IN_PROGRESS: '拣货中', COMPLETED: '已完成', CANCELLED: '已取消' })[status] || status || '状态未知' },
|
||||||
|
taskType() { return '普通/一件代发共用出库' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@import '../dispatch/fulfillment.scss';
|
||||||
|
.outbound-line { margin-top: 9px; padding: 10px; border: 1px solid #e0e7ef; border-radius: 8px; background: #f8fafc; }
|
||||||
|
.outbound-line.selected { border: 2px solid #135ee8; background: #edf5ff; }
|
||||||
|
.line-code, .line-meta { display: block; }
|
||||||
|
.line-code { color: #263b55; font-size: 12px; font-weight: 800; overflow-wrap: anywhere; }
|
||||||
|
.line-meta { margin-top: 4px; color: #718095; font-size: 10px; }
|
||||||
|
.missing-scan-contract { display: block; margin-top: 9px; padding: 9px; border-radius: 7px; color: #a56800; font-size: 10px; line-height: 16px; background: #fff6df; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'overseas-pda-fulfillment-draft-v1'
|
||||||
|
|
||||||
|
const blankState = () => ({
|
||||||
|
scopeKey: '',
|
||||||
|
dispatch: { currentNo: '', palletCode: '', packages: 1, pendingKeys: {} },
|
||||||
|
outbound: { currentTaskNo: '', scanCode: '', packages: 1, pendingKeys: {} }
|
||||||
|
})
|
||||||
|
|
||||||
|
export const fulfillment = reactive(blankState())
|
||||||
|
|
||||||
|
function storageAvailable() {
|
||||||
|
return typeof uni !== 'undefined' && typeof uni.setStorageSync === 'function' && typeof uni.getStorageSync === 'function'
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeDomain(value, fallback) {
|
||||||
|
return {
|
||||||
|
...fallback,
|
||||||
|
...(value || {}),
|
||||||
|
pendingKeys: { ...(value?.pendingKeys || {}) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistFulfillmentDraft() {
|
||||||
|
if (!storageAvailable() || !fulfillment.scopeKey) return
|
||||||
|
uni.setStorageSync(STORAGE_KEY, JSON.parse(JSON.stringify(fulfillment)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreFulfillmentDraft(scopeKey) {
|
||||||
|
const expected = String(scopeKey || '')
|
||||||
|
if (!expected) return false
|
||||||
|
const saved = storageAvailable() ? uni.getStorageSync(STORAGE_KEY) : null
|
||||||
|
if (!saved || saved.scopeKey !== expected) {
|
||||||
|
Object.assign(fulfillment, blankState(), { scopeKey: expected })
|
||||||
|
if (storageAvailable()) uni.removeStorageSync(STORAGE_KEY)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const fresh = blankState()
|
||||||
|
Object.assign(fulfillment, fresh, {
|
||||||
|
scopeKey: expected,
|
||||||
|
dispatch: safeDomain(saved.dispatch, fresh.dispatch),
|
||||||
|
outbound: safeDomain(saved.outbound, fresh.outbound)
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateFulfillmentDraft(domain, patch) {
|
||||||
|
if (!fulfillment[domain]) throw new Error('未知海外 PDA 履约草稿')
|
||||||
|
Object.assign(fulfillment[domain], patch)
|
||||||
|
persistFulfillmentDraft()
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomKey(domain, action) {
|
||||||
|
return `pda-${domain}-${action}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fulfillmentIdempotencyKey(domain, action, payload = {}) {
|
||||||
|
if (!fulfillment[domain]) throw new Error('未知海外 PDA 履约类型')
|
||||||
|
const signature = `${action}:${JSON.stringify(payload)}`
|
||||||
|
const pendingKeys = fulfillment[domain].pendingKeys
|
||||||
|
if (!pendingKeys[signature]) pendingKeys[signature] = randomKey(domain, action)
|
||||||
|
persistFulfillmentDraft()
|
||||||
|
return { signature, value: pendingKeys[signature] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function releaseFulfillmentKey(domain, signature) {
|
||||||
|
if (!fulfillment[domain]) return
|
||||||
|
delete fulfillment[domain].pendingKeys[signature]
|
||||||
|
persistFulfillmentDraft()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetFulfillmentDomain(domain) {
|
||||||
|
if (!fulfillment[domain]) return
|
||||||
|
fulfillment[domain] = blankState()[domain]
|
||||||
|
persistFulfillmentDraft()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetFulfillmentSession() {
|
||||||
|
Object.assign(fulfillment, blankState())
|
||||||
|
if (typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(STORAGE_KEY)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import { OVERSEAS_API_ROOT } from '../config/runtime.js'
|
import { OVERSEAS_API_ROOT } from '../config/runtime.js'
|
||||||
import { resetReceivingSession } from './receiving.js'
|
import { resetReceivingSession } from './receiving.js'
|
||||||
|
import { resetFulfillmentSession } from './fulfillment.js'
|
||||||
|
|
||||||
const SESSION_KEY = 'overseas-pda-auth-session'
|
const SESSION_KEY = 'overseas-pda-auth-session'
|
||||||
export const PDA_ROLE = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR'
|
export const PDA_ROLE = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR'
|
||||||
@@ -179,6 +180,7 @@ export function signOut() {
|
|||||||
session.apiBase = OVERSEAS_API_ROOT
|
session.apiBase = OVERSEAS_API_ROOT
|
||||||
if (typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(SESSION_KEY)
|
if (typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(SESSION_KEY)
|
||||||
resetReceivingSession()
|
resetReceivingSession()
|
||||||
|
resetFulfillmentSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function requireSession() {
|
export function requireSession() {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ test('all registered user-facing pages avoid rendering internal identifiers', as
|
|||||||
const paths = [
|
const paths = [
|
||||||
'../src/pages/login/login.vue', '../src/pages/home/home.vue', '../src/pages/module/module.vue',
|
'../src/pages/login/login.vue', '../src/pages/home/home.vue', '../src/pages/module/module.vue',
|
||||||
'../src/pages/operation/operation.vue', '../src/pages/pallet/pallet.vue', '../src/pages/profile/profile.vue',
|
'../src/pages/operation/operation.vue', '../src/pages/pallet/pallet.vue', '../src/pages/profile/profile.vue',
|
||||||
|
'../src/pages/dispatch/dispatch.vue', '../src/pages/outbound/outbound.vue',
|
||||||
'../src/pages/history/history.vue', '../src/pages/scanner-test/scanner-test.vue',
|
'../src/pages/history/history.vue', '../src/pages/scanner-test/scanner-test.vue',
|
||||||
'../src/components/PortalHeader.vue', '../src/components/PopupHeader.vue',
|
'../src/components/PortalHeader.vue', '../src/components/PopupHeader.vue',
|
||||||
'../src/components/ReceivingSessionSummary.vue', '../src/components/ScanTargetInput.vue'
|
'../src/components/ReceivingSessionSummary.vue', '../src/components/ScanTargetInput.vue'
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
|
||||||
|
import { fulfillment, fulfillmentIdempotencyKey, releaseFulfillmentKey, resetFulfillmentSession, restoreFulfillmentDraft, updateFulfillmentDraft } from '../src/stores/fulfillment.js'
|
||||||
|
import {
|
||||||
|
completeDispatchPicking, completeOutboundTask, departDispatch, getDispatchTask, getOutboundTask,
|
||||||
|
listDispatchTasks, listOutboundTasks, normalizeDispatchTask, normalizeOutboundTask,
|
||||||
|
pickDispatchItem, pickOutboundTask
|
||||||
|
} from '../src/api/fulfillment.js'
|
||||||
|
import { session } from '../src/stores/session.js'
|
||||||
|
|
||||||
|
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
|
||||||
|
|
||||||
|
function storageMock() {
|
||||||
|
const values = new Map()
|
||||||
|
globalThis.uni = {
|
||||||
|
setStorageSync(key, value) { values.set(key, structuredClone(value)) },
|
||||||
|
getStorageSync(key) { return structuredClone(values.get(key)) },
|
||||||
|
removeStorageSync(key) { values.delete(key) }
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
test('phase-five workbench registers only dispatch and outbound PDA delivery boundaries', async () => {
|
||||||
|
const [workbench, pages] = await Promise.all([read('../src/data/overseas-workbench.js'), read('../src/pages.json')])
|
||||||
|
assert.match(workbench, /name: '出库履约'/)
|
||||||
|
assert.match(workbench, /key: 'dispatchPicking'[\s\S]*available: true[\s\S]*route: '\/pages\/dispatch\/dispatch'/)
|
||||||
|
assert.match(workbench, /key: 'outboundPicking'[\s\S]*available: true[\s\S]*route: '\/pages\/outbound\/outbound'/)
|
||||||
|
assert.match(pages, /pages\/dispatch\/dispatch/)
|
||||||
|
assert.match(pages, /pages\/outbound\/outbound/)
|
||||||
|
for (const excluded of ['派送单', '派送状态', '客户回访']) assert.doesNotMatch(workbench, new RegExp(excluded))
|
||||||
|
})
|
||||||
|
|
||||||
|
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', '/complete', '/depart']) 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 () => {
|
||||||
|
const calls = []
|
||||||
|
session.apiBase = '/overseas-api'; session.token = 'overseas-token'
|
||||||
|
globalThis.uni = {
|
||||||
|
request(options) {
|
||||||
|
calls.push({ method: options.method, url: options.url, data: options.data, authorization: options.header.Authorization })
|
||||||
|
let data = {}
|
||||||
|
if (options.method === 'GET' && /\/dispatches(?:\?|$)/.test(options.url)) data = []
|
||||||
|
else if (options.method === 'GET' && /\/outbound-tasks(?:\?|$)/.test(options.url)) data = []
|
||||||
|
else if (options.method === 'GET' && options.url.includes('/dispatches/')) data = { dispatchNo: 'DSP-1', taskNo: 'TASK-1', items: [] }
|
||||||
|
else if (options.method === 'GET' && options.url.includes('/outbound-tasks/')) data = { task: { taskNo: 'OUT-TASK-1', outboundNo: 'OUT-1' }, items: [] }
|
||||||
|
options.success({ statusCode: 200, data: { code: '200', data }, header: {} })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 listOutboundTasks('PENDING'); await getOutboundTask('OUT/TASK/1')
|
||||||
|
await pickOutboundTask('OUT/TASK/1', { outboundPalletItemId: '33', inventoryPositionId: '44', packages: 1, idempotencyKey: 'out-pick-key' })
|
||||||
|
await completeOutboundTask('OUT/TASK/1', { idempotencyKey: 'out-complete-key' })
|
||||||
|
|
||||||
|
assert.equal(calls.length, 9)
|
||||||
|
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')))
|
||||||
|
assert.ok(calls.some((call) => call.url.includes('/outbound-tasks/OUT%2FTASK%2F1/pick')))
|
||||||
|
assert.ok(calls.every((call) => call.authorization === 'Bearer overseas-token'))
|
||||||
|
assert.ok(calls.every((call) => !/[?&](?:customerId|warehouseId)=/.test(call.url)))
|
||||||
|
for (const call of calls) {
|
||||||
|
assert.equal(Object.hasOwn(call.data || {}, 'customerId'), false)
|
||||||
|
assert.equal(Object.hasOwn(call.data || {}, 'warehouseId'), false)
|
||||||
|
}
|
||||||
|
const dispatchComplete = calls.find((call) => call.url.endsWith('/dispatches/DSP%2F1/complete'))
|
||||||
|
assert.deepEqual(dispatchComplete.data, { idempotencyKey: 'complete-key' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('dispatch and outbound adapters retain stable business identifiers and count progress', () => {
|
||||||
|
const dispatch = normalizeDispatchTask({ taskNo: 'DPT-1', dispatchNo: 'DSP-1', items: [{ dispatchItemId: 8, palletNo: 'PLT-1', scheduledPackages: 5, pickedPackages: 2, positions: [{ id: 9, availablePackages: 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)
|
||||||
|
|
||||||
|
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')
|
||||||
|
assert.equal(outbound.items[0].palletNo, 'PLT-2')
|
||||||
|
assert.equal(outbound.items[0].outboundPalletItemId, '3')
|
||||||
|
assert.equal(outbound.items[0].plannedPackages, 4)
|
||||||
|
assert.equal(outbound.items[0].pickedPackages, 1)
|
||||||
|
assert.equal(outbound.items[0].positions[0].cartonMark, 'BOX-1')
|
||||||
|
assert.equal(outbound.items[0].positions[0].availablePackages, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('draft restoration scopes work to the signed-in operator warehouse and retains retry idempotency', () => {
|
||||||
|
storageMock(); resetFulfillmentSession()
|
||||||
|
assert.equal(restoreFulfillmentDraft('operator-a:warehouse-a'), false)
|
||||||
|
updateFulfillmentDraft('dispatch', { currentNo: 'DSP-001', palletCode: 'PLT-001', packages: 3 })
|
||||||
|
const first = fulfillmentIdempotencyKey('dispatch', 'pick', { dispatchNo: 'DSP-001', palletNo: 'PLT-001', packages: 3 })
|
||||||
|
const retry = fulfillmentIdempotencyKey('dispatch', 'pick', { dispatchNo: 'DSP-001', palletNo: 'PLT-001', packages: 3 })
|
||||||
|
assert.equal(retry.value, first.value)
|
||||||
|
|
||||||
|
fulfillment.dispatch.currentNo = ''
|
||||||
|
assert.equal(restoreFulfillmentDraft('operator-a:warehouse-a'), true)
|
||||||
|
assert.equal(fulfillment.dispatch.currentNo, 'DSP-001')
|
||||||
|
assert.equal(fulfillment.dispatch.pendingKeys[first.signature], first.value)
|
||||||
|
|
||||||
|
releaseFulfillmentKey('dispatch', first.signature)
|
||||||
|
const next = fulfillmentIdempotencyKey('dispatch', 'pick', { dispatchNo: 'DSP-001', palletNo: 'PLT-001', packages: 3 })
|
||||||
|
assert.notEqual(next.value, first.value)
|
||||||
|
assert.equal(restoreFulfillmentDraft('operator-b:warehouse-a'), false)
|
||||||
|
assert.equal(fulfillment.dispatch.currentNo, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pages expose loading empty error offline and permission diagnostics with guarded writes', 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]) {
|
||||||
|
assert.match(page, /startScanBroadcast/)
|
||||||
|
assert.match(page, /正在加载/)
|
||||||
|
assert.match(page, /暂无/)
|
||||||
|
assert.match(page, /requestId[\s\S]*traceId/)
|
||||||
|
assert.match(page, /error\.network[\s\S]*幂等键/)
|
||||||
|
assert.match(page, /A0403[\s\S]*没有海外仓 PDA 作业权限/)
|
||||||
|
assert.match(page, /fulfillmentIdempotencyKey/)
|
||||||
|
assert.match(page, /releaseFulfillmentKey/)
|
||||||
|
assert.match(page, /if \(!this\.can/)
|
||||||
|
assert.doesNotMatch(page, /模拟成功|静默成功|customerId:|warehouseId:|locationId:/)
|
||||||
|
}
|
||||||
|
assert.match(dispatch, /completeDispatchPicking/)
|
||||||
|
assert.match(dispatch, /this\.remainingPackages === 0/)
|
||||||
|
assert.doesNotMatch(dispatch, /partialReason|reason \}/)
|
||||||
|
assert.match(dispatch, /this\.pickingCompleted && this\.allPicked && !this\.departed/)
|
||||||
|
assert.match(dispatch, /this\.selectedPosition\?\.availablePackages \|\| 0/)
|
||||||
|
assert.doesNotMatch(dispatch, /availablePackages - (?:this\.)?selectedPosition.*pickedPackages/)
|
||||||
|
assert.match(dispatch, /departDispatch/)
|
||||||
|
assert.doesNotMatch(dispatch, /departedNo/)
|
||||||
|
assert.match(outbound, /completeOutboundTask/)
|
||||||
|
assert.match(outbound, /this\.remainingPackages === 0/)
|
||||||
|
assert.match(outbound, /position\.availablePackages <= 0/)
|
||||||
|
assert.doesNotMatch(outbound, /position\.availablePackages - position\.pickedPackages/)
|
||||||
|
for (const page of [dispatch, outbound]) assert.match(page, /await get(?:Dispatch|Outbound)Task[\s\S]*releaseFulfillmentKey/)
|
||||||
|
assert.doesNotMatch(dispatch + outbound, /DeliveryOrder|deliveryStatus|customerVisit|\/deliveries/)
|
||||||
|
})
|
||||||
@@ -24,12 +24,12 @@ test('overseas PDA is an independent Vue 3 app with separate packaging identity'
|
|||||||
assert.doesNotMatch(styles, /home-operation-primary[^}]*grid-column:\s*1\s*\/\s*-1/)
|
assert.doesNotMatch(styles, /home-operation-primary[^}]*grid-column:\s*1\s*\/\s*-1/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('overseas workbench contains only confirmed inbound and in-warehouse domains', async () => {
|
test('overseas workbench contains confirmed warehouse and phase-five outbound domains without delivery', async () => {
|
||||||
const [data, session, runtime] = await Promise.all([
|
const [data, session, runtime] = await Promise.all([
|
||||||
read('../src/data/overseas-workbench.js'), read('../src/stores/session.js'), read('../src/config/runtime.js')
|
read('../src/data/overseas-workbench.js'), read('../src/stores/session.js'), read('../src/config/runtime.js')
|
||||||
])
|
])
|
||||||
for (const label of ['入仓作业', '库内作业', '换标作业', '现场查询']) assert.match(data, new RegExp(label))
|
for (const label of ['入仓作业', '出库履约', '排车拣货', '出库作业', '库内作业', '换标作业', '现场查询']) assert.match(data, new RegExp(label))
|
||||||
for (const excluded of ['集装箱拣货', '装柜取证', '海外派送', '出库履约']) assert.doesNotMatch(data, new RegExp(excluded))
|
for (const excluded of ['集装箱拣货', '装柜取证', '海外派送', '派送单', '客户回访']) assert.doesNotMatch(data, new RegExp(excluded))
|
||||||
assert.match(session, /overseas-pda-auth-session/)
|
assert.match(session, /overseas-pda-auth-session/)
|
||||||
assert.match(runtime, /VITE_OVERSEAS_API_BASE_URL/)
|
assert.match(runtime, /VITE_OVERSEAS_API_BASE_URL/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
decodeAccessToken, signInWithTokens, signOut, validatePdaTokenPair
|
decodeAccessToken, signInWithTokens, signOut, validatePdaTokenPair
|
||||||
} from '../src/stores/session.js'
|
} from '../src/stores/session.js'
|
||||||
import { receiving } from '../src/stores/receiving.js'
|
import { receiving } from '../src/stores/receiving.js'
|
||||||
|
import { fulfillment, restoreFulfillmentDraft, updateFulfillmentDraft } from '../src/stores/fulfillment.js'
|
||||||
|
|
||||||
const future = () => Math.floor(Date.now() / 1000) + 3600
|
const future = () => Math.floor(Date.now() / 1000) + 3600
|
||||||
const jwt = (claims) => [
|
const jwt = (claims) => [
|
||||||
@@ -124,7 +125,7 @@ test('restore rebuilds identity from claims and removes expired or tampered stor
|
|||||||
assert.equal(values.has('overseas-pda-auth-session'), false)
|
assert.equal(values.has('overseas-pda-auth-session'), false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('sign out clears tokens, frozen warehouse identity, and every receiving draft', () => {
|
test('sign out clears tokens, frozen warehouse identity, and every receiving or fulfillment draft', () => {
|
||||||
const values = storage(); signOut()
|
const values = storage(); signOut()
|
||||||
signInWithTokens(tokenPair())
|
signInWithTokens(tokenPair())
|
||||||
receiving.candidates = [{ containerNo: 'CONT-1' }]
|
receiving.candidates = [{ containerNo: 'CONT-1' }]
|
||||||
@@ -134,6 +135,9 @@ test('sign out clears tokens, frozen warehouse identity, and every receiving dra
|
|||||||
receiving.resolvedCarton = { cartonMark: 'BOX-1' }
|
receiving.resolvedCarton = { cartonMark: 'BOX-1' }
|
||||||
receiving.resolvedPallet = { palletCode: 'PALLET-1' }
|
receiving.resolvedPallet = { palletCode: 'PALLET-1' }
|
||||||
receiving.lastReceipt = { acceptedPackages: 1 }
|
receiving.lastReceipt = { acceptedPackages: 1 }
|
||||||
|
restoreFulfillmentDraft('42:900')
|
||||||
|
updateFulfillmentDraft('dispatch', { currentNo: 'DSP-1', palletCode: 'PLT-1' })
|
||||||
|
updateFulfillmentDraft('outbound', { currentTaskNo: 'PDA-1', scanCode: 'BOX-1' })
|
||||||
|
|
||||||
signOut()
|
signOut()
|
||||||
assert.equal(session.currentUser, null)
|
assert.equal(session.currentUser, null)
|
||||||
@@ -148,4 +152,8 @@ test('sign out clears tokens, frozen warehouse identity, and every receiving dra
|
|||||||
assert.equal(receiving.resolvedCarton, null)
|
assert.equal(receiving.resolvedCarton, null)
|
||||||
assert.equal(receiving.resolvedPallet, null)
|
assert.equal(receiving.resolvedPallet, null)
|
||||||
assert.equal(receiving.lastReceipt, null)
|
assert.equal(receiving.lastReceipt, null)
|
||||||
|
assert.equal(fulfillment.scopeKey, '')
|
||||||
|
assert.equal(fulfillment.dispatch.currentNo, '')
|
||||||
|
assert.equal(fulfillment.outbound.currentTaskNo, '')
|
||||||
|
assert.equal(values.has('overseas-pda-fulfillment-draft-v1'), false)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
|
|||||||
const enabled = operations.filter((operation) => operation.available)
|
const enabled = operations.filter((operation) => operation.available)
|
||||||
assert.deepEqual(enabled.map(({ module, name }) => ({ module, name })), [
|
assert.deepEqual(enabled.map(({ module, name }) => ({ module, name })), [
|
||||||
{ module: 'inbound', name: '拆柜收货' },
|
{ module: 'inbound', name: '拆柜收货' },
|
||||||
|
{ module: 'outbound', name: '排车拣货' },
|
||||||
|
{ module: 'outbound', name: '出库作业' },
|
||||||
{ module: 'inventory', name: '托盘管理' }
|
{ module: 'inventory', name: '托盘管理' }
|
||||||
])
|
])
|
||||||
assert.equal(operations.find((item) => item.name === '托盘管理')?.route, '/pages/pallet/pallet')
|
assert.equal(operations.find((item) => item.name === '托盘管理')?.route, '/pages/pallet/pallet')
|
||||||
|
assert.equal(operations.find((item) => item.name === '排车拣货')?.route, '/pages/dispatch/dispatch')
|
||||||
|
assert.equal(operations.find((item) => item.name === '出库作业')?.route, '/pages/outbound/outbound')
|
||||||
|
|
||||||
const pendingNames = ['库存盘点', '商品移库', '换标收货', '换标执行', '换标复核', '库存查询', '库位查询', '托盘查询']
|
const pendingNames = ['库存盘点', '商品移库', '换标收货', '换标执行', '换标复核', '库存查询', '库位查询', '托盘查询']
|
||||||
for (const name of pendingNames) {
|
for (const name of pendingNames) {
|
||||||
|
|||||||
Reference in New Issue
Block a user