From fc8a52bc4b09bf5d5a9b64e8c63d5dc95d85e5a5 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Tue, 1 Sep 2026 14:23:55 +0800
Subject: [PATCH] feat: add phase 5 overseas PDA fulfillment
---
AGENTS.md | 7 +-
README.md | 5 +-
src/api/fulfillment.js | 111 ++++++++++++++++
src/data/overseas-workbench.js | 4 +
src/pages.json | 2 +
src/pages/dispatch/dispatch.vue | 175 +++++++++++++++++++++++++
src/pages/dispatch/fulfillment.scss | 47 +++++++
src/pages/outbound/outbound.vue | 119 +++++++++++++++++
src/stores/fulfillment.js | 82 ++++++++++++
src/stores/session.js | 2 +
tests/business-identifiers-ui.test.mjs | 1 +
tests/fulfillment-workflows.test.mjs | 144 ++++++++++++++++++++
tests/project-contract.test.mjs | 6 +-
tests/session-auth.test.mjs | 10 +-
tests/workbench-availability.test.mjs | 4 +
15 files changed, 712 insertions(+), 7 deletions(-)
create mode 100644 src/api/fulfillment.js
create mode 100644 src/pages/dispatch/dispatch.vue
create mode 100644 src/pages/dispatch/fulfillment.scss
create mode 100644 src/pages/outbound/outbound.vue
create mode 100644 src/stores/fulfillment.js
create mode 100644 tests/fulfillment-workflows.test.mjs
diff --git a/AGENTS.md b/AGENTS.md
index a4dcfa6..1b76e99 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,7 +10,7 @@
- 本项目是“柏悦物流海外版”独立 uni-app Vue 3 工程,面向海外仓现场人员。
- 工程必须独立安装、构建和发布,不得依赖 `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/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/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/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` | 会话隔离、退出清理和后续真实作业历史契约 |
@@ -57,7 +59,7 @@
- 除已开放的托盘管理外,其余库内、换标与现场查询仍保持禁用;新增真实作业必须接入 `/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。
- 海外业务 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`。
- APK/IPA 必须在 HBuilderX 或正式原生打包环境中使用独立 AppID 与签名验证;仅通过 App 资源编译不得声称 APK 真机验收完成。
- 修改 `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_modules/`、`dist/`、`unpackage/`、环境文件、签名证书或打包产物。
diff --git a/README.md b/README.md
index 5fa07f4..ffa70da 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
独立的 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。
- 候选列表同时显示已齐全与未齐全柜,展示计划到仓时间、总订单、入仓码、已生成计划、未操作和缺入仓码数量;未齐全柜置灰但可查看服务端阻断原因,只有服务端 `allPlansReady=true && operable=true` 才能 prepare。
- 托盘管理已接 `/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 人工输入。
- 登录、恢复和刷新会 fail-closed 校验国内/海外双 Token 的同一平台操作主体、`OVERSEAS_WAREHOUSE_PDA_OPERATOR`、`lastleg:pda:operate` 及同一冻结海外仓;并发海外 401 共享一次国内 refresh,校验完成后原子替换双 Token并将原请求或照片上传重放一次。
- 库存盘点、商品移库、换标收货/执行/复核以及库存/库位/托盘查询仍未接入海外 PDA 前端,入口保持禁用;不会调用平台、客户或内部接口,也不会模拟成功。
diff --git a/src/api/fulfillment.js b/src/api/fulfillment.js
new file mode 100644
index 0000000..4e979a5
--- /dev/null
+++ b/src/api/fulfillment.js
@@ -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)
+}
diff --git a/src/data/overseas-workbench.js b/src/data/overseas-workbench.js
index f4b274d..da27998 100644
--- a/src/data/overseas-workbench.js
+++ b/src/data/overseas-workbench.js
@@ -2,6 +2,10 @@ export const MODULES = [
{ 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: '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: '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 },
diff --git a/src/pages.json b/src/pages.json
index 250e454..e3d882c 100644
--- a/src/pages.json
+++ b/src/pages.json
@@ -5,6 +5,8 @@
{ "path": "pages/module/module", "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/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/profile/profile", "style": { "navigationStyle": "custom", "navigationBarTitleText": "个人中心", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/scanner-test/scanner-test", "style": { "navigationStyle": "custom", "navigationBarTitleText": "广播扫码测试", "backgroundColor": "#F5F7FA" } }
diff --git a/src/pages/dispatch/dispatch.vue b/src/pages/dispatch/dispatch.vue
new file mode 100644
index 0000000..d1d17ff
--- /dev/null
+++ b/src/pages/dispatch/dispatch.vue
@@ -0,0 +1,175 @@
+
+
+
+
+
+
+ 排车拣货与发车
+ 客户与当前海外仓由登录令牌和服务端确定;扫描排车单号或任务中的托盘码开始作业
+
+
+
+
+
+ 网络失败不会清空草稿,再次提交会沿用同一幂等键。
+
+
+ 正在加载排车任务…
+
+ {{ error.message }}
+ 错误码:{{ error.code }}
+ requestId:{{ error.requestId }}
+ traceId:{{ error.traceId }}
+ 网络连接失败,未完成作业与幂等键已保留。
+
+
+
+
+ 待处理排车任务
+
+
+
+ 暂无排车拣货任务可刷新列表,或直接扫描排车单号恢复作业。
+
+
+
+
+ {{ current.dispatchNo || '—' }}{{ statusLabel(current.status) }}
+ 任务号 {{ current.taskNo || '—' }} · 车牌 {{ current.vehiclePlate || '—' }}
+ {{ plannedPackages }}计划箱数{{ pickedPackages }}已拣箱数{{ remainingPackages }}待拣箱数
+
+
+
+
+
+ {{ item.palletNo || '未关联托盘码' }}{{ item.pickedPackages }}/{{ item.scheduledPackages }} 箱
+ {{ routeLabel(item) }}
+
+
+
+ 任务没有可拣货明细请联系平台确认排车托盘是否已下发。
+
+
+ 本次拣货:{{ selectedItem.palletNo }}
+ 拣货箱数最多 {{ selectedRemaining }} 箱
+
+
+
+
+ 完成拣货
+ 尚有 {{ remainingPackages }} 箱未拣,不能完成排车拣货。
+ 全部计划箱数已拣齐,可以完成任务。
+
+
+
+
+ 发车门禁
+ 服务端仍显示 {{ remainingPackages }} 箱未拣,不允许发车。
+ 该排车任务已执行发车,不可重复提交。
+ 拣货已完成且全部箱数核平,服务端将再次校验出库单和托盘门禁。
+
+
+
+
+ {{ successMessage }}
+
+
+
+
+
+
+
+
diff --git a/src/pages/dispatch/fulfillment.scss b/src/pages/dispatch/fulfillment.scss
new file mode 100644
index 0000000..d8b5522
--- /dev/null
+++ b/src/pages/dispatch/fulfillment.scss
@@ -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; }
diff --git a/src/pages/outbound/outbound.vue b/src/pages/outbound/outbound.vue
new file mode 100644
index 0000000..841be48
--- /dev/null
+++ b/src/pages/outbound/outbound.vue
@@ -0,0 +1,119 @@
+
+
+
+
+
+ 出库任务包含排车转出的出库任务和一件代发出库任务;派送单与派送状态不进入 PDA
+
+
+
+ 扫描只接受服务端任务内的稳定业务码;网络失败可用原幂等键安全重试。
+
+
+ 正在加载出库任务…
+ {{ error.message }}错误码:{{ error.code }}requestId:{{ error.requestId }}traceId:{{ error.traceId }}网络连接失败,当前任务、扫码草稿和幂等键均已保留。
+
+
+ 待处理出库任务
+
+
+
+ 暂无出库任务可刷新列表,或扫描任务号恢复未完成作业。
+
+
+
+ {{ current.taskNo || '—' }}{{ statusLabel(current.status) }}出库单 {{ current.outboundNo || '—' }} · {{ taskType(current) }}{{ plannedPackages }}计划箱数{{ pickedPackages }}已拣箱数{{ remainingPackages }}待拣箱数
+
+
+
+ {{ item.palletNo || '散件出库' }}{{ item.pickedPackages }}/{{ item.plannedPackages }} 箱
+
+ {{ lineBusinessCode(position) }}{{ position.inboundCode || '无入仓码' }} · 已拣 {{ position.pickedPackages }} 箱 · 可拣 {{ position.availablePackages }} 箱
+
+ 当前明细缺少可扫码库存业务码,已禁止拣货;请刷新或联系平台确认任务下发。
+
+
+ 任务详情尚无可拣货明细请刷新;若持续为空,请联系平台确认出库货物是否已下发。
+
+ 本次拣货:{{ lineBusinessCode(selectedPosition) }}拣货箱数最多 {{ selectedRemaining }} 箱
+
+ 完成出库任务尚有 {{ remainingPackages }} 箱未拣,不能完成出库。出库任务已完成,不可重复提交。全部计划货物已扫描核平,服务端将再次校验库存门禁。
+
+
+ {{ successMessage }}
+
+
+
+
+
+
+
+
diff --git a/src/stores/fulfillment.js b/src/stores/fulfillment.js
new file mode 100644
index 0000000..6ff9b3e
--- /dev/null
+++ b/src/stores/fulfillment.js
@@ -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)
+}
diff --git a/src/stores/session.js b/src/stores/session.js
index 6f82a3f..9b26ede 100644
--- a/src/stores/session.js
+++ b/src/stores/session.js
@@ -1,6 +1,7 @@
import { reactive } from 'vue'
import { OVERSEAS_API_ROOT } from '../config/runtime.js'
import { resetReceivingSession } from './receiving.js'
+import { resetFulfillmentSession } from './fulfillment.js'
const SESSION_KEY = 'overseas-pda-auth-session'
export const PDA_ROLE = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR'
@@ -179,6 +180,7 @@ export function signOut() {
session.apiBase = OVERSEAS_API_ROOT
if (typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(SESSION_KEY)
resetReceivingSession()
+ resetFulfillmentSession()
}
export function requireSession() {
diff --git a/tests/business-identifiers-ui.test.mjs b/tests/business-identifiers-ui.test.mjs
index 08a5205..966da59 100644
--- a/tests/business-identifiers-ui.test.mjs
+++ b/tests/business-identifiers-ui.test.mjs
@@ -10,6 +10,7 @@ test('all registered user-facing pages avoid rendering internal identifiers', as
const paths = [
'../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/dispatch/dispatch.vue', '../src/pages/outbound/outbound.vue',
'../src/pages/history/history.vue', '../src/pages/scanner-test/scanner-test.vue',
'../src/components/PortalHeader.vue', '../src/components/PopupHeader.vue',
'../src/components/ReceivingSessionSummary.vue', '../src/components/ScanTargetInput.vue'
diff --git a/tests/fulfillment-workflows.test.mjs b/tests/fulfillment-workflows.test.mjs
new file mode 100644
index 0000000..1accbb0
--- /dev/null
+++ b/tests/fulfillment-workflows.test.mjs
@@ -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/)
+})
diff --git a/tests/project-contract.test.mjs b/tests/project-contract.test.mjs
index 962b33a..9d914e9 100644
--- a/tests/project-contract.test.mjs
+++ b/tests/project-contract.test.mjs
@@ -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/)
})
-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([
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 excluded of ['集装箱拣货', '装柜取证', '海外派送', '出库履约']) assert.doesNotMatch(data, new RegExp(excluded))
+ for (const label of ['入仓作业', '出库履约', '排车拣货', '出库作业', '库内作业', '换标作业', '现场查询']) assert.match(data, new RegExp(label))
+ for (const excluded of ['集装箱拣货', '装柜取证', '海外派送', '派送单', '客户回访']) assert.doesNotMatch(data, new RegExp(excluded))
assert.match(session, /overseas-pda-auth-session/)
assert.match(runtime, /VITE_OVERSEAS_API_BASE_URL/)
})
diff --git a/tests/session-auth.test.mjs b/tests/session-auth.test.mjs
index 7595c04..257a35d 100644
--- a/tests/session-auth.test.mjs
+++ b/tests/session-auth.test.mjs
@@ -6,6 +6,7 @@ import {
decodeAccessToken, signInWithTokens, signOut, validatePdaTokenPair
} from '../src/stores/session.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 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)
})
-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()
signInWithTokens(tokenPair())
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.resolvedPallet = { palletCode: 'PALLET-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()
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.resolvedPallet, 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)
})
diff --git a/tests/workbench-availability.test.mjs b/tests/workbench-availability.test.mjs
index 8bd70a7..a1ddb43 100644
--- a/tests/workbench-availability.test.mjs
+++ b/tests/workbench-availability.test.mjs
@@ -7,9 +7,13 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
const enabled = operations.filter((operation) => operation.available)
assert.deepEqual(enabled.map(({ module, name }) => ({ module, name })), [
{ module: 'inbound', name: '拆柜收货' },
+ { module: 'outbound', name: '排车拣货' },
+ { module: 'outbound', name: '出库作业' },
{ module: 'inventory', name: '托盘管理' }
])
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 = ['库存盘点', '商品移库', '换标收货', '换标执行', '换标复核', '库存查询', '库位查询', '托盘查询']
for (const name of pendingNames) {