diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..de0e642
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,12 @@
+# Only compiled H5 assets and image configuration enter the build context.
+**
+!deploy/
+!deploy/Dockerfile.h5
+!deploy/nginx.conf
+!dist/
+!dist/build/
+!dist/build/h5/
+!dist/build/h5/**
+
+!deploy/Dockerfile.h5.production
+!deploy/nginx.production.conf.template
diff --git a/.env.example b/.env.example
index a227b2d..6fb31a0 100644
--- a/.env.example
+++ b/.env.example
@@ -1,13 +1,8 @@
-VITE_AUTH_API_BASE_URL=http://127.0.0.1:9000/api
-VITE_OVERSEAS_API_BASE_URL=http://127.0.0.1:9300/api
+VITE_API_BASE_URL=http://127.0.0.1:9000/api
# H5 同源前缀,由 Vite 代理转发;App 构建使用上面的绝对域名直连。
-VITE_H5_AUTH_API_BASE_URL=/api
-VITE_H5_OVERSEAS_API_BASE_URL=/overseas-api
+VITE_H5_API_BASE_URL=/gateway-api
# H5 开发代理上游;正式 App 构建请在本机环境文件中改为可访问的绝对 API 根地址。
API_PROXY_TARGET=http://127.0.0.1:9000
-OVERSEAS_API_PROXY_TARGET=http://127.0.0.1:9300
-# 上游本身以 /overseas-api 对外代理时设为 true;直连 gateway-overseas 时保持 false。
-OVERSEAS_API_PROXY_PRESERVE_PREFIX=false
# 海外 PDA 独立更新清单地址;不得与国内 PDA 发布记录混用。
VITE_PDA_UPDATE_URL=https://updates.example.com/pda/overseas/latest
# 仅开发服务器自动预填;请在不提交的 .env.local 中填写真实开发账号。
diff --git a/AGENTS.md b/AGENTS.md
index 328dc2e..31693af 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -3,10 +3,10 @@
## 快速读取顺序
1. 先用“页面与能力快速索引”定位 `src/pages.json`、工作台或最小页面;页面业务配置从 `src/data/overseas-workbench.js` 进入,不扫描 `src/uni_modules/`。
-2. 登录、Token 或双区域请求先读 `src/api/auth.js`、`src/stores/session.js`、`src/config/runtime.js` 和 `../../docs/跨区域认证与服务通信契约.md`。
-3. 沿实际 API 定位:入仓/库存契约读 `../../baiyue-firstleg/lastleg-service/AGENTS.md`;资源契约读 `../../baiyue-firstleg/warehouse-overseas-service/AGENTS.md`;网关路径或鉴权问题才读 `../../baiyue-firstleg/gateway-overseas/AGENTS.md`。跨模块流程或入口不明才读 `../../docs/海外仓业务/README.md`;纯页面改动不自动读取后端指引。
+2. 登录、Token 或统一入口请求先读 `src/api/auth.js`、`src/stores/session.js`、`src/config/runtime.js` 和 `../../docs/统一认证与服务通信契约.md`。
+3. 沿实际 API 定位:入仓/库存契约读 `../../baiyue-firstleg/lastleg-service/AGENTS.md`;资源契约读 `../../baiyue-firstleg/warehouse-overseas-service/AGENTS.md`;网关路径或鉴权问题才读 `../../baiyue-firstleg/gateway/AGENTS.md`。跨模块流程或入口不明才读 `../../docs/海外仓业务/README.md`;纯页面改动不自动读取后端指引。
-最小读取集按任务保持在本文件、目标页面/API 和一个直接服务指引;只有跨服务状态或鉴权无法判断时才增加第二个服务指引。累计超过约 250 行时优先读取命中段,不全文加载海外仓 README。
+最小读取集按任务保持在适用指引和目标页面/API;纯展示任务不增加服务指引,服务端判断才读直接服务指引,跨服务状态或鉴权无法判断时再增加相关方。读取预算遵循根指引的行数与字符数标准,先读适用规则,再检索业务命中段,不全文加载海外仓 README。
## 项目定位
@@ -16,9 +16,9 @@
## 服务与发布边界
-- 国内 Auth 是唯一账号和 Token 签发方;海外业务请求统一经 `gateway-overseas`。
-- 登录、刷新和本地会话必须同时校验国内/海外 Token 的平台主体、成员、冻结海外仓一致;`OVERSEAS_WAREHOUSE_PDA_OPERATOR` 与 `lastleg:pda:operate` 只从 Auth 登录/刷新响应的顶层权限快照读取并持久化,不得解析 JWT 权限。权限快照缺失、无效、过期、跨主体或撤权响应必须 fail-closed 清理会话,不得只依赖页面入口隐藏。
-- 并发海外 401 只允许一次国内 refresh;双 Token 完整校验后原子替换,每个原请求或照片上传最多重放一次。刷新失败清理本地会话,业务错误和网络错误保留 code、requestId、traceId 或 network 诊断。
+- Auth 是唯一账号和 Token 签发方;认证和业务请求共用统一 Gateway,App 根地址只读 VITE_API_BASE_URL,H5 使用 VITE_H5_API_BASE_URL。
+- 登录、刷新和本地会话必须校验统一 Token 的平台主体、成员、有效期和冻结海外仓;`OVERSEAS_WAREHOUSE_PDA_OPERATOR` 与 `lastleg:pda:operate` 只从 Auth 登录/刷新响应的顶层权限快照读取并持久化,不得解析 JWT 权限。权限快照缺失、无效、过期、跨主体或撤权响应必须 fail-closed 清理会话,不得只依赖页面入口隐藏。
+- 并发 401 只允许一次 refresh;统一 Token 和权限快照完整校验后原子替换,主体或绑定仓改变时清理会话并重新登录,每个原请求或照片上传最多重放一次。刷新失败清理本地会话,业务错误和网络错误保留 code、requestId、traceId 或 network 诊断。
- 国内与海外 AppID、Android 包名、签名、版本号、构建产物和在线更新通道必须独立。
- `src/manifest.json` 中的 AppID 在正式打包前由海外版所属 DCloud 账号创建;不得伪造或复用国内 AppID。
- 环境地址只通过 `.env.local` 等未提交环境文件配置,不写入源码。
@@ -50,7 +50,8 @@
| 商品移库真实作业 | `src/pages/move/move.vue`、`src/api/warehouse-operations.js`、`src/stores/warehouse-work.js` | 扫描来源托盘与目标库位执行同仓移库;冲突后重新读取服务端事实 |
| 换标真实作业 | `src/pages/relabel/relabel.vue`、`src/api/relabel.js` | 同一页面按操作参数承载换标收货、执行与复核;执行页按当前单明细切换 SKU/箱唛 Tab,组合明细同时显示两类需求。SKU 逐条扫码旧新SKU并携带审批件数,箱唛扫码仓库唛头/新FBA箱唛;组合单按同箱保留两个Tab的核对输入,全部核对后只提交一次箱事实。认领同步处理中;支持拍照、异常/返工和幂等恢复 |
| 现场查询真实作业 | `src/pages/query/query.vue`、`src/api/warehouse-operations.js` | 同一页面按操作参数承载库存、库位与托盘查询,只展示稳定业务标识 |
-| 排车拣货与发车 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 普通进入默认展示任务列表和“上次作业”标记,不因本地草稿自动打开详情;只有出库扫码明确跳转 `?resume=1` 时恢复对应排车。详情提供独立返回列表入口;托盘业务码扫码拣货、幂等完成及发车仍严格消费服务端 `canDepart/departBlockReason`。公网前缀 `/api/pda/v1/last-leg/dispatches/**` |
+| 排车拣货与发车 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 普通进入默认展示任务列表和“上次作业”标记,不因本地草稿自动打开详情;只有出库扫码明确跳转 `?resume=1` 时恢复对应排车。详情提供独立返回列表入口;托盘业务码扫码拣货、幂等完成前消费服务端 `canPick/pickBlockReason` 调度审核结果,整车发车消费 `canDepart/departBlockReason`(含理货审核)。公网前缀 `/api/pda/v1/last-leg/dispatches/**` |
+| 整车发车与证照提醒 | 同上 `dispatch.vue`、`fulfillment.js` 的 `tripDepartureRequest` | 显示 `tripNo/tripDispatchCount/driverWarning`;发车提交 `tripRecordVersion` 对应的 `recordVersion`,幂等键按车次和版本保留。响应必须为该车次 `IN_TRANSIT` 且包含当前排车,随后读取服务端任务状态再释放幂等键;不能把整车状态当子单状态,也不能缺省伪造 `DEPARTED`。证照即将到期提醒不独立禁用按钮,门禁仍以服务端结果为准 |
| 普通/一件代发出库 | `src/pages/outbound/outbound.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 首次进入清空扫描框并展示任务列表和“上次作业”标记,不自动恢复旧详情;详情提供独立返回列表入口。逐箱扫码、DP 托盘整托拣取及幂等完成保持不变;扫描排车业务号显式跳转排车 `?resume=1`。公网前缀 `/api/pda/v1/last-leg/outbound-tasks/**`;派送由 Admin/Client 承载 |
| 扫码设备与诊断 | `src/utils/scan-broadcast.js`、`src/pages/scanner-test/scanner-test.vue` | `src/components/ScanTargetInput.vue`、声音反馈工具;广播 Action/Extra 见本文件服务边界 |
| 作业记录与人员 | `src/pages/history/history.vue`、`src/pages/profile/profile.vue` | 会话隔离、退出清理和后续真实作业历史契约 |
@@ -74,21 +75,23 @@
- 换标执行完成照片以缩略图展示并支持大图预览、移除和重拍。重新进入任务通过 `relabel-tasks/{relabelNo}/photos/files/{fileId}/download` 鉴权读取,不持久化设备临时图片地址;重拍先上传成功再替换待提交文件 ID,失败保留原照片,移除只解绑待提交引用。
- 退货查询空态集中在 `src/api/return-receipt.js`、`src/api/return-packing.js`:仅 `startReturnReceipt`、`findReturnSkuCandidates`、`findPackingOrders` 将 `A0404/404` 转为无匹配结果;签收确认、合箱写入和标签读取不得套用此处理,权限、网络与服务错误仍须保留诊断并报错。回归见 `tests/return-receipt-api.test.mjs`、`tests/return-packing-api.test.mjs`,不得将查询无记录误报为写入成功。
-- `src/api/inbound.js` 已接入待卸柜候选、柜详情/prepare、current 恢复、三类照片签收、统一箱码定位入仓码、实物托盘解析、普通 FBA bulk/普通 OVERSEAS carton、一件代发 source pallet 整托收货及整柜完成;FBA bulk 不提交 `skuCode`,库存分配由服务端按计划明细完成;一件代发只提交国内冻结的来源托盘号,不允许逐箱或拆托收货。响应与 404/409 判定集中在 `src/api/receiving-adapter.js`,双 Token 刷新与海外 401 单次重放由 `src/api/overseas.js` 统一处理。
+- `src/api/inbound.js` 已接入待卸柜候选、柜详情/prepare、current 恢复、三类照片签收、统一箱码定位入仓码、实物托盘解析、普通 FBA bulk/普通 OVERSEAS carton、一件代发 source pallet 整托收货及整柜完成;FBA bulk 不提交 `skuCode`,库存分配由服务端按计划明细完成;一件代发只提交国内冻结的来源托盘号,不允许逐箱或拆托收货。响应与 404/409 判定集中在 `src/api/receiving-adapter.js`,统一 Token 刷新与业务 401 单次重放由 `src/api/overseas.js` 统一处理。
- 入仓摘要、箱码解析和实收响应的 `assigned/mergeAllowed/urgent/mergeHint` 只消费 Lastleg 返回值,布尔标志只有 `true` 生效,`mergeAllowed` 缺省为 `false`;合托仅作提示,不能改变收货资格或触发分配。首扫实物托盘只确认服务端已有托盘、库位与资源锁预留,不在设备端重新分配托盘、库位或资源锁;只提交业务箱码、托盘码及当前收货数量,最终资格由服务端决定。
-- `src/api/overseas.js` 的 `overseasUrl` 统一拼接普通请求、上传和下载地址:先移除运行时 `apiBase` 末尾斜杠;当基址已以 `/api` 或 `/overseas-api` 结尾时,再移除业务路径重复的 `/api` 前缀。各业务 API 不得自行拼接 `session.apiBase`,也不得依赖双斜杠或重复 `/api` 被网关容错。
+- `src/api/overseas.js` 的 `overseasUrl` 统一拼接普通请求、上传和下载地址:先移除运行时 `apiBase` 末尾斜杠;当基址已以 `/api` 结尾时,再移除业务路径重复的 `/api` 前缀。各业务 API 不得自行拼接 `session.apiBase`,也不得依赖双斜杠或重复 `/api` 被网关容错。
- 待卸柜候选只消费服务端已按 Token 绑定仓过滤的结果,并严格校验每柜正式 `overseasWarehouseCode`、`overseasWarehouseName` 与 `warehouse-profile` 一致;字段缺失或仓不一致一律 fail-closed,不得从 FBA/业务目的仓或内部 ID 推导。候选保留服务端返回的未齐全柜并严格消费 `allPlansReady`、`operable`、`blockReason`、`plannedArrivalAt` 与柜级计划统计;`operable=false` 只能查看只读摘要,不得调用 prepare。候选顺序沿用服务端“可操作优先、同组计划到仓时间升序、时间为空最后”的排序,前端不得用入仓码/计划数组长度自行推导就绪或把预计到港冒充计划到仓。
- 候选详情、prepare 响应和 current 恢复都必须重复校验同一接柜仓编码;prepare 只建立已分配柜的作业会话,不得当作首次认领。服务端仓冲突的 `409/A0409` 中文错误保留 `requestId`/`traceId`;会话建立后仍按既有 current 恢复与完成前禁止切柜逻辑处理。
- `src/api/pallet.js` 只调用 Lastleg 托盘专用 PDA Controller;前端提交服务端扫码返回的 `palletVersion`、`positionVersion` 和随机幂等键,不提交客户、仓库或库位标识。409 后必须重新扫码获取最新版本,禁止用旧库存快照继续作业。
- 请求身份和资源范围只取 Token 与 current 会话;前端不得提交 `customerId`、`planNo`、`allocationId`、`warehouseId` 或 `locationId`。409 必须引导账号恢复当前柜,完成前不得切柜。
- 签收照片固定为三个角色且每个角色只有一个当前文件;上传成功后使用服务端 `downloadPath` 在当前会话内鉴权读取。更换失败时保留原 `fileId` 和可提交状态,无原图的失败槽不得计入 `photosReady`;本地临时路径仅用于本次设备预览,不作为跨会话文件事实。
- 库内、换标与现场查询只调用 `/api/pda/**` 业务 API/DTO,并统一受 `lastleg:pda:operate`、Token 冻结仓库范围和服务端资源归属约束;写操作必须保留稳定幂等键,409 后重新读取服务端状态,不得把平台、客户或内部接口直接暴露给 PDA。
-- 柜号签收入仓调用 Lastleg `/api/pda/v1/last-leg/receiving-containers/**`;退货签收与合箱分别调用 `/api/pda/v1/last-leg/return-receipts/**`、`/api/pda/v1/last-leg/return-inventory/**`,具体入口见上表。浏览器或设备请求必须经 `gateway-overseas` 的显式 PDA 路由,不得直连 Lastleg,也不得改走国内 Gateway。
+- 柜号签收入仓调用 Lastleg `/api/pda/v1/last-leg/receiving-containers/**`;退货签收与合箱分别调用 `/api/pda/v1/last-leg/return-receipts/**`、`/api/pda/v1/last-leg/return-inventory/**`,具体入口见上表。浏览器或设备请求必须经统一 `gateway` 的显式 PDA 路由,不得直连 Lastleg。
- 海外业务 Token、PDA 权限和可信身份最终由 Gateway 与服务端校验;前端不得用工作台 `available`、本地客户 ID 或仓库 ID 代替授权和资源归属校验。
-- 排车和出库入口只调用专用 PDA 接口;客户与冻结海外仓范围由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不把内部数据库 ID 展示成业务编号。网络失败保留草稿和幂等键,恢复后以服务端任务状态为准;排车详情返回的 `canDepart`、`departBlockReason` 是发车按钮唯一门禁,平台未转派送生成 `WAIT_OUTBOUND` 出库单时不得发车;完成或发车成功后不得重复提交。派送始终由 Admin/Client 承载。
+- 排车和出库入口只调用专用 PDA 接口;客户与冻结海外仓范围由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不把内部数据库 ID 展示成业务编号。网络失败保留草稿和幂等键,恢复后以服务端任务状态为准;排车详情返回的 `canDepart`、`departBlockReason` 是发车资格依据,拣货及完成按钮同时核对当前单`canPick/pickBlockReason`;平台未转派送生成 `WAIT_OUTBOUND` 出库单时不得发车;完成或发车成功后不得重复提交。派送始终由 Admin/Client 承载。
## 质量门禁
+- 已发车排车详情按需挂载`src/components/DispatchHandoverStatus.vue`,通过`src/api/trip-handover.js`读取当前派送资源和最近交接;每次进入/刷新一次GET,列表不逐行请求。仅只读、不提交客户或仓ID;后台确认交接,已完成派送保留完成时资源。回归`tests/trip-handover.test.mjs`。
+
- 安装:`npm install`;检查:`npm test`。
- 仓储转运测量、打托、照片或标签入口变更运行 `node --test tests/warehouse-transfer-api.test.mjs tests/project-contract.test.mjs tests/workbench-availability.test.mjs`;静态合同不替代真实扫码、绑定及打印验收。
- 退货签收、合箱或工作台入口变更先运行 `node --test tests/return-receipt-api.test.mjs tests/return-packing-api.test.mjs tests/project-contract.test.mjs tests/workbench-availability.test.mjs`;离线检查不替代真实 API 联调与真机扫码验收。
diff --git a/README.md b/README.md
index 676776d..5d9b525 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
独立的 uni-app Vue 3 App 工程,正式产品形态是安装在海外仓手持 PDA 上的 App。工程不依赖国内 PDA 源码或仓库外共享目录,可独立安装依赖、运行、编译和发布;H5 只用于浏览器调试和编译门禁,不是最终交付形态。
-工作台提供柜号级真实入仓、托盘管理、排车拣货、出库任务、一件代发出库、任务完成和发车入口;派送单、派送状态与客户回访由 Admin/Client 承载。登录使用国内 Auth 统一签发的双 Token;海外业务请求统一经 `gateway-overseas`,不得请求国内 Firstleg/Warehouse 业务接口。
+工作台提供柜号级真实入仓、托盘管理、排车拣货、出库任务、一件代发出库、任务完成和发车入口;派送单、派送状态与客户回访由 Admin/Client 承载。登录使用Auth 签发的统一 Access/Refresh Token;业务请求统一经 `gateway`,不得请求国内 Firstleg/Warehouse 业务接口。
## 本地运行与检查
@@ -32,7 +32,7 @@ npm run build:app
1. 使用海外版所属的 DCloud 账号为本应用获取独立 AppID,写入 `src/manifest.json` 的 `appid`;不得复用国内版 AppID。
2. 确认 Android 包名 `com.baiyuelogistics.pda.overseas`,如公司已有正式命名规范,在首次正式签名前修改。
3. 配置海外版独立 Android 签名证书;证书、密码和账号信息不得提交仓库。
-4. 复制 `.env.example` 为本机 `.env.local`,配置 Auth 与海外网关地址;环境文件不提交仓库。
+4. 复制 `.env.example` 为本机 `.env.local`,配置统一 Gateway 地址;环境文件不提交仓库。
5. 在目标海外 PDA 上验证扫描广播 Action=`android.intent.action.SCANRESULT`、Extra=`value`。
## 当前实现边界
@@ -46,9 +46,9 @@ npm run build:app
- 出库作业已接 `/api/pda/v1/last-leg/outbound-tasks/**`:支持普通出库和一件代发任务列表/详情、托盘码/箱码/SKU/入仓码扫码拣货与核平完成;不会展示或进入派送流程。
- 排车与出库写操作使用可恢复的稳定幂等键。网络失败保留当前任务、扫码草稿和幂等键;成功后释放键并按服务端状态恢复,避免重复完成或发车。
- 入仓请求不手填客户、计划、分配、仓库或库位标识;身份与当前柜范围由 Token、Gateway 和服务端会话确定。页面同时支持 PDA 广播扫码与 H5 人工输入。
-- 登录、恢复和刷新会 fail-closed 校验国内/海外双 Token 的同一平台操作主体和同一冻结海外仓,并从 Auth 登录/刷新响应的顶层权限快照校验 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`、`lastleg:pda:operate`,不从 JWT 解析权限;并发海外 401 共享一次国内 refresh,校验完成后原子替换双 Token和权限快照,并将原请求或照片上传重放一次。
+- 登录、恢复和刷新会 fail-closed 校验统一 Token 的平台操作主体和冻结海外仓,并从 Auth 登录/刷新响应的顶层权限快照校验 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`、`lastleg:pda:operate`,不从 JWT 解析权限;并发 401 共享一次 refresh,主体与绑定仓保持一致后原子替换统一 Token 和权限快照,并将原请求或照片上传重放一次。刷新失败或重放后仍未授权时清空会话和作业草稿,并返回登录页;退出或重新登录后,旧请求不能重放到新会话,迟到的刷新响应也不能恢复旧会话或清除新会话。
- 库存盘点、商品移库、换标收货/执行/复核以及库存/库位/托盘查询均已接入 `/api/pda/**` 专用接口;支持 PDA 广播扫码与 H5 人工输入,并按服务端版本、幂等键和冲突状态恢复,不调用平台、客户或内部接口。
-- 本地契约测试与 H5/App 资源编译不等于真实开发环境 HTTP 或 PDA 真机验收。
+- 本地契约测试与 H5/App 资源编译不等于真实测试环境 HTTP 或 PDA 真机验收。
## App 在线更新
@@ -74,3 +74,33 @@ Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查海外 PDA 的独
2. wgt 发布至少递增 `versionName`,并使用与已安装原生包兼容的 uni-app 编译器生成;新增原生模块、权限或不兼容运行时能力时必须发布 APK,不能只发 wgt。
3. 更新清单与下载地址使用 HTTPS,先在测试设备验证可选更新、强制更新、断网重试、APK 覆盖安装、wgt 重启及扫码作业回归,再切换正式清单。
4. 海外和国内更新记录、文件目录及发布权限保持隔离,服务端必须按请求中的 `appid` 拒绝跨应用更新包。
+
+## TEST H5 静态托管
+
+发布构建使用 `/` 资源 base 与 hash 路由,部署到本应用独立域名根目录,浏览器入口为 `/#/`。开发服务仍使用根路径;不能将 Vite 开发代理当作静态服务器配置。
+
+执行 `npm run build:h5`。无需启动 Vite 开发服务器;`npm run build:app` 不属于 H5 发布。
+
+`deploy/Dockerfile.h5` 只复制已构建 `dist/build/h5/` 及静态 Nginx 配置,不在镜像中复制源码、依赖或重新编译。`.dockerignore` 只放行这两类输入。容器静态目录为 `/srv/pda-h5/`。
+
+从本仓库执行 `docker compose -f deploy/docker-compose.h5.test.yml build --pull=false`,再由获授权的部署流程执行 `docker compose -f deploy/docker-compose.h5.test.yml up -d --wait --wait-timeout 120`。必须设置 `IMAGE_TAG`,`NGINX_BASE_IMAGE` 可指定已缓存的兼容 Alpine Nginx 镜像。容器名及网络别名为 `pda-overseas-h5-test`,仅在外部 `firstleg-test-network` 暴露容器 80,不发布宿主端口。健康检查同时请求 `/healthz` 与 `/` 的实际首页。
+
+独立域名入口将 `/` 代理至本应用静态容器根目录,或将构建目录内容直接发布到站点根目录;不再挂载 PDA 子路径。缺失资源返回 404,不回退首页,HTML 使用 `Cache-Control: no-store`。域名入口还须配置同源 API 代理:两端均将 `/gateway-api/` 转统一 Gateway `/api/`。入口须保留 Authorization,支持文件上传下载及至少 11m 请求体。域名与上游只在部署侧配置,不编入 PDA 源码。
+
+部署配置检查:`node --test tests/h5-deployment.test.mjs`。没有 Docker 运行时的本地合同检查不能替代目标主机 `docker compose config`、Nginx 启动与真实 HTTP 验收。
+
+构建后执行 `node scripts/verify-h5.mjs`,核对 HTML/JS/CSS 资源路径、同源 API 和无本机/旧海外配置泄漏。该检查与本地构建仅证明产物;TEST 页面登录、仓权限、业务操作和 Android 真机另行验收。
+
+### 两端统一 API 配置
+
+两端的 `VITE_API_BASE_URL` 均为统一 Gateway 绝对地址(含 `/api`);`VITE_H5_API_BASE_URL=/gateway-api`;`API_PROXY_TARGET` 为同一 Gateway 源地址(不含 `/api`)。实际地址只放在未提交的 `.env.local`;修改后重启开发服务,发布前重新打包。
+
+两端独立域名入口均配置以下规则;`gateway:9000` 仅适用于同一 Docker 网络,其他部署须替换成实际统一 Gateway 上游。静态容器只提供页面文件,Vite 代理不会随静态包发布。
+
+```nginx
+location ^~ /gateway-api/ {
+ proxy_pass http://gateway:9000/api/;
+ proxy_set_header Authorization $http_authorization;
+ client_max_body_size 11m;
+}
+```
diff --git a/deploy/Dockerfile.h5 b/deploy/Dockerfile.h5
new file mode 100644
index 0000000..d20020b
--- /dev/null
+++ b/deploy/Dockerfile.h5
@@ -0,0 +1,5 @@
+ARG NGINX_BASE_IMAGE=nginx:1.27-alpine
+FROM ${NGINX_BASE_IMAGE}
+COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
+COPY dist/build/h5/ /srv/pda-h5/
+EXPOSE 80
diff --git a/deploy/Dockerfile.h5.production b/deploy/Dockerfile.h5.production
new file mode 100644
index 0000000..5ecc940
--- /dev/null
+++ b/deploy/Dockerfile.h5.production
@@ -0,0 +1,5 @@
+ARG NGINX_BASE_IMAGE=nginx:1.27-alpine
+FROM ${NGINX_BASE_IMAGE}
+COPY deploy/nginx.production.conf.template /etc/nginx/templates/default.conf.template
+COPY dist/build/h5/ /srv/pda-h5/
+EXPOSE 80
diff --git a/deploy/docker-compose.h5.production.yml b/deploy/docker-compose.h5.production.yml
new file mode 100644
index 0000000..3dc79ee
--- /dev/null
+++ b/deploy/docker-compose.h5.production.yml
@@ -0,0 +1,29 @@
+name: firstleg-production-pda-overseas-h5
+services:
+ pda-overseas-h5:
+ build:
+ context: ..
+ dockerfile: deploy/Dockerfile.h5.production
+ args:
+ NGINX_BASE_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
+ image: pda-overseas-h5-prod:${IMAGE_TAG:?IMAGE_TAG is required}
+ container_name: pda-overseas-h5-prod
+ restart: unless-stopped
+ environment:
+ API_UPSTREAM: "http://gateway:9000"
+ ports:
+ - "127.0.0.1:${HOST_PORT:-29006}:80"
+ healthcheck:
+ test: ["CMD-SHELL", "wget -qO /dev/null http://127.0.0.1/healthz && wget -qO /dev/null http://127.0.0.1/"]
+ interval: 5s
+ timeout: 3s
+ retries: 12
+ start_period: 5s
+ networks:
+ backend:
+ aliases:
+ - pda-overseas-h5-prod
+networks:
+ backend:
+ external: true
+ name: firstleg-production-network
diff --git a/deploy/docker-compose.h5.test.yml b/deploy/docker-compose.h5.test.yml
new file mode 100644
index 0000000..1022898
--- /dev/null
+++ b/deploy/docker-compose.h5.test.yml
@@ -0,0 +1,27 @@
+name: firstleg-test-pda-overseas-h5
+services:
+ pda-overseas-h5:
+ build:
+ context: ..
+ dockerfile: deploy/Dockerfile.h5
+ args:
+ NGINX_BASE_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
+ image: pda-overseas-h5-test:${IMAGE_TAG:?IMAGE_TAG is required}
+ container_name: pda-overseas-h5-test
+ restart: unless-stopped
+ expose:
+ - "80"
+ healthcheck:
+ test: ["CMD-SHELL", "wget -qO /dev/null http://127.0.0.1/healthz && wget -qO /dev/null http://127.0.0.1/"]
+ interval: 5s
+ timeout: 3s
+ retries: 12
+ start_period: 5s
+ networks:
+ test-backend:
+ aliases:
+ - pda-overseas-h5-test
+networks:
+ test-backend:
+ external: true
+ name: firstleg-test-network
diff --git a/deploy/nginx.conf b/deploy/nginx.conf
new file mode 100644
index 0000000..bdef818
--- /dev/null
+++ b/deploy/nginx.conf
@@ -0,0 +1,16 @@
+server {
+ listen 80;
+ server_name _;
+ root /srv/pda-h5;
+
+ location = /healthz {
+ default_type text/plain;
+ add_header Cache-Control "no-store" always;
+ return 200 "ok\n";
+ }
+ location / {
+ index index.html;
+ try_files $uri $uri/ =404;
+ add_header Cache-Control "no-store" always;
+ }
+}
diff --git a/deploy/nginx.production.conf.template b/deploy/nginx.production.conf.template
new file mode 100644
index 0000000..4ce203e
--- /dev/null
+++ b/deploy/nginx.production.conf.template
@@ -0,0 +1,25 @@
+server {
+ listen 80;
+ server_name _;
+ root /srv/pda-h5;
+
+ location = /healthz {
+ default_type text/plain;
+ add_header Cache-Control "no-store" always;
+ return 200 "ok\n";
+ }
+ location /gateway-api/ {
+ client_max_body_size 55m;
+ proxy_pass ${API_UPSTREAM}/api/;
+ proxy_http_version 1.1;
+ proxy_set_header Host $proxy_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+ location / {
+ index index.html;
+ try_files $uri $uri/ =404;
+ add_header Cache-Control "no-store" always;
+ }
+}
diff --git a/scripts/verify-h5.mjs b/scripts/verify-h5.mjs
new file mode 100644
index 0000000..510e674
--- /dev/null
+++ b/scripts/verify-h5.mjs
@@ -0,0 +1,32 @@
+import assert from 'node:assert/strict'
+import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
+import { resolve, dirname, relative } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { createHash } from 'node:crypto'
+const project = resolve(dirname(fileURLToPath(import.meta.url)), '..')
+const output = resolve(project, 'dist/build/h5')
+const base = '/'
+function walk(dir) { return readdirSync(dir).flatMap(name => { const path = resolve(dir, name); return statSync(path).isDirectory() ? walk(path) : [path] }) }
+assert.ok(existsSync(resolve(output, 'index.html')), 'Build H5 before verifying')
+const files = walk(output).sort()
+const html = readFileSync(resolve(output, 'index.html'), 'utf8')
+const refs = [...html.matchAll(/(?:src|href)="([^"]+)"/g)].map(match => match[1])
+assert.ok(refs.length >= 2, 'HTML must reference compiled assets')
+for (const ref of refs) {
+ assert.ok(ref.startsWith(base), `Asset must remain under ${base}: ${ref}`)
+ assert.ok(existsSync(resolve(output, ref.slice(base.length))), `Missing asset: ${ref}`)
+}
+const js = files.filter(path => path.endsWith('.js')).map(path => readFileSync(path, 'utf8')).join('\n')
+assert.doesNotMatch(html + js, /\/pda-(?:domestic|overseas)\//, 'Build must not reference the retired subdirectory deployment')
+assert.doesNotMatch(js, /VITE_ROOT_DIR|VITE_AUTH_API_BASE_URL|VITE_OVERSEAS_API_BASE_URL|overseas-api|overseasAccessToken/, 'Build contains broad local env or old overseas config')
+assert.doesNotMatch(js, /https?:\/\/(?:localhost|127\.0\.0\.1):\d+/, 'Build must not depend on a local dev server')
+assert.doesNotMatch(js, /https?:\/\/[^"'\s]+\/api["']/, 'API must be same-origin for this H5 deployment')
+for (const path of files.filter(path => /\.(?:js|css)$/.test(path))) {
+ const source = readFileSync(path, 'utf8')
+ for (const match of source.matchAll(/(?:from\s*|import\s*\(\s*)["'](\.[^"']+\.(?:js|css))["']/g)) {
+ assert.ok(existsSync(resolve(dirname(path), match[1])), `Missing chunk ${match[1]}`)
+ }
+}
+const digest = createHash('sha256')
+for (const path of files) { digest.update(relative(output,path).replaceAll('\\','/')); digest.update('\0'); digest.update(readFileSync(path)) }
+console.log(JSON.stringify({output, base, files: files.length, bytes: files.reduce((sum,path)=>sum+statSync(path).size,0), sha256: digest.digest('hex')}, null, 2))
diff --git a/src/api/auth.js b/src/api/auth.js
index 966d4e7..da78f63 100644
--- a/src/api/auth.js
+++ b/src/api/auth.js
@@ -1,6 +1,8 @@
import { API_ROOT } from '../config/runtime.js'
-function authRequest(path, data) {
+const pendingRefreshes = new Map()
+
+function authRequest(path, data, accessToken = '') {
return new Promise((resolve, reject) => {
const url = `${API_ROOT}${path}`
console.info('[Overseas PDA auth] request', { method: 'POST', url })
@@ -8,6 +10,8 @@ function authRequest(path, data) {
url,
method: 'POST',
data,
+ header: { Accept: 'application/json', 'Content-Type': 'application/json',
+ ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}) },
success(response) {
const body = response.data || {}
console.info('[Overseas PDA auth] response', { url, statusCode: response.statusCode, code: body.code,
@@ -15,9 +19,10 @@ function authRequest(path, data) {
contentType: response.header?.['Content-Type'] || response.header?.['content-type'],
requestId: body.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id'],
traceId: body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id'] })
- if (String(body.code ?? response.statusCode) !== '200') {
+ if (response.statusCode !== 200 || String(body.code) !== '200') {
const error = new Error(body.message || '认证服务请求失败')
error.code = body.code || response.statusCode
+ error.statusCode = response.statusCode
error.requestId = body.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id']
error.traceId = body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id']
reject(error)
@@ -40,5 +45,16 @@ export function login(loginId, password) {
}
export function refresh(refreshToken) {
- return authRequest('/auth/refresh', { refreshToken })
+ if (pendingRefreshes.has(refreshToken)) return pendingRefreshes.get(refreshToken)
+ const pending = authRequest('/auth/refresh', { refreshToken }).finally(() => {
+ if (pendingRefreshes.get(refreshToken) === pending) pendingRefreshes.delete(refreshToken)
+ })
+ pendingRefreshes.set(refreshToken, pending)
+ return pending
+}
+
+export function getPendingRefresh(refreshToken) { return pendingRefreshes.get(refreshToken) || null }
+
+export function logout(accessToken, refreshToken) {
+ return authRequest('/auth/logout', { refreshToken }, accessToken)
}
diff --git a/src/api/fulfillment.js b/src/api/fulfillment.js
index 0a82511..e558403 100644
--- a/src/api/fulfillment.js
+++ b/src/api/fulfillment.js
@@ -2,6 +2,7 @@ import { overseasRequest } from './overseas.js'
const DISPATCH_BASE = '/pda/v1/last-leg/dispatches'
const OUTBOUND_BASE = '/pda/v1/last-leg/outbound-tasks'
+const CARGO_DISCARD_BASE = '/pda/v1/last-leg/cargo-discards'
const encoded = (value) => encodeURIComponent(String(value || '').trim())
@@ -23,8 +24,14 @@ export function normalizeDispatchTask(value) {
taskNo: String(value?.taskNo || ''),
dispatchNo: String(value?.dispatchNo || ''),
vehiclePlate: String(value?.vehiclePlate || ''),
+ tripNo: String(value?.tripNo || ''),
+ tripRecordVersion: value?.tripRecordVersion == null ? null : String(value.tripRecordVersion),
+ tripDispatchCount: count(value?.tripDispatchCount),
+ driverWarning: String(value?.driverWarning || ''),
status: String(value?.status || ''),
canDepart: value?.canDepart === true,
+ canPick: value?.canPick === true,
+ pickBlockReason: String(value?.pickBlockReason || ''),
departBlockReason: String(value?.departBlockReason || ''),
items: asList(value?.items).map((item) => ({
...item,
@@ -53,6 +60,7 @@ export function normalizeOutboundTask(value) {
...header,
taskNo: String(header?.taskNo || ''),
outboundNo: String(header?.outboundNo || ''),
+ outboundType: String(header?.outboundType || ''),
status: String(header?.status || ''),
items: asList(value?.items).map((item) => ({
...item,
@@ -95,6 +103,13 @@ export function departDispatch(dispatchNo, request) {
return overseasRequest('POST', `${DISPATCH_BASE}/${encoded(dispatchNo)}/depart`, request)
}
+export function tripDepartureRequest(task, idempotencyKey) {
+ if (!task?.tripNo || task.tripRecordVersion == null || !/^\d+$/.test(String(task.tripRecordVersion))) {
+ throw new Error('车次信息不完整,请刷新任务后再发车')
+ }
+ return { recordVersion: String(task.tripRecordVersion), idempotencyKey, remark: '海外 PDA 整车核平后发车' }
+}
+
export async function listOutboundTasks(status = '') {
const suffix = status ? `?status=${encoded(status)}` : ''
return asList(await overseasRequest('GET', `${OUTBOUND_BASE}${suffix}`)).map(normalizeOutboundTask)
@@ -116,15 +131,48 @@ export function pickOutboundPallet(taskNo, palletNo, request) {
return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/pallets/${encoded(palletNo)}/pick`, request)
}
-export function outboundPickPayload(item, position, packages) {
+export function outboundPickPayload(item, position, packages, scannedCode = '') {
+ const skuScan = item.skuCode && scannedCode === item.skuCode
return {
outboundPalletItemId: item.outboundPalletItemId,
- inventoryPositionId: position.inventoryPositionId,
+ inventoryPositionId: position.inventoryPositionId || null,
packages: Number(packages),
- cartonMarks: position.cartonMark ? [position.cartonMark] : []
+ cartonMarks: skuScan ? [] : position.cartonMark ? [position.cartonMark] : [],
+ ...(skuScan ? { skuCode: item.skuCode } : {})
}
}
export function completeOutboundTask(taskNo, request) {
return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/complete`, request)
}
+
+export function normalizeCargoDiscardTask(value) {
+ const task = value || {}
+ return {
+ ...task,
+ discardNo: String(task?.discardNo || ''),
+ palletNo: String(task?.palletNo || ''),
+ status: String(task?.status || 'PENDING'),
+ skus: asList(task?.skus).map((sku) => ({
+ ...sku,
+ itemId: String(sku?.itemId || ''),
+ skuCode: String(sku?.skuCode || ''),
+ productName: String(sku?.productName || ''),
+ plannedPackages: count(sku?.plannedPackages),
+ actualPackages: sku?.actualPackages == null ? null : count(sku.actualPackages),
+ status: String(sku?.status || 'PENDING')
+ }))
+ }
+}
+
+export async function listCargoDiscardTasks() {
+ return asList(await overseasRequest('GET', CARGO_DISCARD_BASE)).map(normalizeCargoDiscardTask)
+}
+
+export async function resolveCargoDiscardPallet(palletNo) {
+ return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/resolve`, { palletNo }))
+}
+
+export async function confirmCargoDiscard(discardNo, palletNo, lines) {
+ return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, { lines }))
+}
diff --git a/src/api/logout-session.js b/src/api/logout-session.js
new file mode 100644
index 0000000..35500f2
--- /dev/null
+++ b/src/api/logout-session.js
@@ -0,0 +1,53 @@
+import { logout, refresh, getPendingRefresh } from './auth.js'
+import { session, signOut, getSessionRevision, decodeAccessToken } from '../stores/session.js'
+
+function replacementForCapturedSession(captured, tokens) {
+ if (!tokens?.accessToken || typeof tokens.refreshToken !== 'string' || !tokens.refreshToken.trim()) {
+ throw new Error('退出刷新响应缺少会话凭据')
+ }
+ const previous = decodeAccessToken(captured.accessToken)
+ const replacement = decodeAccessToken(tokens.accessToken)
+ if (!Number.isFinite(Number(replacement?.exp)) || Number(replacement.exp) * 1000 <= Date.now()) {
+ throw new Error('退出刷新凭据无效或已过期')
+ }
+ for (const name of ['user_id', 'membership_id', 'tenant_id', 'app_code', 'overseas_warehouse_id']) {
+ if (previous[name] == null || String(previous[name]) !== String(replacement[name])) {
+ throw new Error('退出刷新返回了不同作业主体,无法确认原会话撤销')
+ }
+ }
+ return { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken }
+}
+
+// Active user logout only. Ordinary authentication failures continue to use local signOut.
+export function logoutCurrentSession() {
+ const captured = {
+ accessToken: session.token, refreshToken: session.refreshToken,
+ pendingRefresh: getPendingRefresh(session.refreshToken)
+ }
+ signOut()
+ const clearedRevision = getSessionRevision()
+ const revoke = async () => {
+ if (!captured.accessToken || !captured.refreshToken) return { revoked: false, reason: 'NO_CAPTURED_SESSION' }
+ let credentials = captured
+ let refreshed = false
+ if (captured.pendingRefresh) {
+ // Auth rotates access/refresh credentials. Revoke the captured refresh's result,
+ // while the business refresh wrapper's revision guard prevents local restoration.
+ refreshed = true
+ credentials = replacementForCapturedSession(captured, await captured.pendingRefresh)
+ }
+ try {
+ await logout(credentials.accessToken, credentials.refreshToken)
+ } catch (error) {
+ if (error.network || error.statusCode !== 401 || refreshed) throw error
+ // Only an explicit expired-access HTTP 401 permits one refresh and one revoke.
+ credentials = replacementForCapturedSession(captured, await refresh(captured.refreshToken))
+ await logout(credentials.accessToken, credentials.refreshToken)
+ }
+ return { revoked: true }
+ }
+ return revoke().then(result => ({ ...result, clearedRevision }), error => {
+ error.logoutRevision = clearedRevision
+ throw error
+ })
+}
diff --git a/src/api/overseas.js b/src/api/overseas.js
index 3282d7e..3966a6f 100644
--- a/src/api/overseas.js
+++ b/src/api/overseas.js
@@ -1,7 +1,18 @@
import { refresh } from './auth.js'
-import { session, replaceTokens, signOut } from '../stores/session.js'
+import { session, replaceTokens, signOut, getSessionRevision } from '../stores/session.js'
let refreshPromise = null
+let refreshRevision = -1
+
+function requireCurrentSession(revision) {
+ if (revision !== getSessionRevision()) throw new Error('登录会话已变更,请重新操作')
+}
+
+function expireSession(revision) {
+ if (revision !== getSessionRevision()) return
+ signOut()
+ uni.reLaunch({ url: '/pages/login/login' })
+}
function errorFrom(response, fallback) {
const body = response?.data || {}
@@ -21,23 +32,27 @@ function successful(response, body) {
return response?.statusCode === 200 && ['00000', '200'].includes(String(body?.code))
}
-async function refreshOverseasToken() {
+async function refreshOverseasToken(revision) {
+ requireCurrentSession(revision)
if (!session.refreshToken) {
- signOut()
+ expireSession(revision)
throw new Error('登录状态已过期,请重新登录')
}
- if (!refreshPromise) {
- refreshPromise = refresh(session.refreshToken)
- .then((tokens) => { replaceTokens(tokens); return session.token })
- .catch((error) => { signOut(); throw error })
- .finally(() => { refreshPromise = null })
+ if (!refreshPromise || refreshRevision !== revision) {
+ refreshRevision = revision
+ const pending = refresh(session.refreshToken)
+ .then((tokens) => { requireCurrentSession(revision); replaceTokens(tokens); return session.token })
+ .catch((error) => { expireSession(revision); throw error })
+ .finally(() => { if (refreshPromise === pending) refreshPromise = null })
+ refreshPromise = pending
}
return refreshPromise
}
-async function tokenAfterUnauthorized(failedToken) {
+async function tokenAfterUnauthorized(failedToken, revision) {
+ requireCurrentSession(revision)
if (session.token && session.token !== failedToken) return session.token
- return refreshOverseasToken()
+ return refreshOverseasToken(revision)
}
function overseasUrl(path) {
@@ -45,12 +60,12 @@ function overseasUrl(path) {
const nativeRuntime = typeof plus !== 'undefined'
|| (typeof location !== 'undefined' && location.protocol === 'file:')
if (nativeRuntime && !/^https?:\/\//i.test(base)) {
- const error = new Error('海外服务地址未配置:App 必须设置绝对 HTTP(S) 地址 VITE_OVERSEAS_API_BASE_URL')
- error.code = 'PDA_OVERSEAS_API_BASE_REQUIRED'
+ const error = new Error('海外服务地址未配置:App 必须设置绝对 HTTP(S) 地址 VITE_API_BASE_URL')
+ error.code = 'PDA_API_BASE_REQUIRED'
throw error
}
const value = `/${String(path || '').replace(/^\/+/, '')}`
- const normalized = /\/(?:api|overseas-api)$/.test(base)
+ const normalized = /\/(?:api|gateway-api)$/.test(base)
? value.replace(/^\/api(?=\/)/, '')
: value
return `${base}${normalized}`
@@ -70,13 +85,17 @@ function rawRequest(method, path, data, token) {
}
export async function overseasRequest(method, path, data, retry = true) {
+ const revision = getSessionRevision()
console.info('[Overseas PDA API] request', { method, path })
const attemptedToken = session.token
let response = await rawRequest(method, path, data, attemptedToken)
+ requireCurrentSession(revision)
if (retry && unauthorized(response)) {
- const replacementToken = await tokenAfterUnauthorized(attemptedToken)
+ const replacementToken = await tokenAfterUnauthorized(attemptedToken, revision)
response = await rawRequest(method, path, data, replacementToken)
}
+ requireCurrentSession(revision)
+ if (unauthorized(response)) expireSession(revision)
const body = response?.data || {}
console.info('[Overseas PDA API] response', { method, path, statusCode: response?.statusCode, code: body?.code,
message: body?.message, requestId: body?.requestId || response?.header?.['x-request-id'] || response?.header?.['X-Request-Id'],
@@ -149,27 +168,35 @@ function rawDownload(path, token) {
}
export async function overseasUpload(path, filePath, name = 'file', retry = true) {
+ const revision = getSessionRevision()
const attemptedToken = session.token
let response = await rawUpload(path, filePath, name, attemptedToken)
+ requireCurrentSession(revision)
let body = null
try { body = JSON.parse(response.data || '{}') } catch (_) { /* handled below */ }
if (retry && unauthorized({ ...response, data: body })) {
- const replacementToken = await tokenAfterUnauthorized(attemptedToken)
+ const replacementToken = await tokenAfterUnauthorized(attemptedToken, revision)
response = await rawUpload(path, filePath, name, replacementToken)
try { body = JSON.parse(response.data || '{}') } catch (_) { body = null }
}
+ requireCurrentSession(revision)
+ if (unauthorized({ ...response, data: body })) expireSession(revision)
if (!successful(response, body)) throw errorFrom({ ...response, data: body }, '海外作业文件上传失败')
return body.data
}
export async function overseasDownload(path, options = {}) {
+ const revision = getSessionRevision()
const { retry = true, expectedContentTypePrefix = '' } = options
const attemptedToken = session.token
let response = await rawDownload(path, attemptedToken)
+ requireCurrentSession(revision)
if (retry && unauthorized(response)) {
- const replacementToken = await tokenAfterUnauthorized(attemptedToken)
+ const replacementToken = await tokenAfterUnauthorized(attemptedToken, revision)
response = await rawDownload(path, replacementToken)
}
+ requireCurrentSession(revision)
+ if (unauthorized(response)) expireSession(revision)
if (response?.statusCode !== 200 || !response?.tempFilePath) {
throw errorFrom({ ...response, data: {} }, '签收照片下载失败')
}
diff --git a/src/api/return-receipt.js b/src/api/return-receipt.js
index 3698ddf..35ba2ae 100644
--- a/src/api/return-receipt.js
+++ b/src/api/return-receipt.js
@@ -47,7 +47,7 @@ export function normalizeReturnSkuCandidates(value = {}) {
}
export function normalizeReturnReceiptResult(value = {}) {
- return {
+ const result = {
returnNo: String(value.returnNo || ''),
receiptNo: String(value.receiptNo || ''),
receiptBatchNo: String(value.receiptBatchNo || ''),
@@ -58,6 +58,17 @@ export function normalizeReturnReceiptResult(value = {}) {
receivedQuantity: count(value.receivedQuantity),
signedAt: value.signedAt || null
}
+ if (value.cartonMark != null || value.printableHtml != null || value.totalBoxCount != null) {
+ result.cartonMark = String(value.cartonMark || '')
+ result.printableHtml = String(value.printableHtml || '')
+ result.totalBoxCount = count(value.totalBoxCount)
+ }
+ if (value.unitType != null || value.unitCode != null) {
+ result.unitType = String(value.unitType || (value.palletCode ? 'PALLET' : ''))
+ result.unitCode = String(value.unitCode || value.palletCode || '')
+ }
+ if (Array.isArray(value.labels)) result.labels = value.labels.map((item) => ({ cartonMark: String(item.cartonMark || ''), printableHtml: String(item.printableHtml || '') })).filter((item) => item.cartonMark && item.printableHtml)
+ return result
}
export async function startReturnReceipt(returnNo) {
@@ -80,7 +91,11 @@ export async function findReturnSkuCandidates(skuCode) {
}
export async function confirmReturnReceipt(request) {
- return normalizeReturnReceiptResult(await overseasRequest('POST', `${BASE}/confirm`, request))
+ const value = String(request?.palletCode || '')
+ const encoded = value.startsWith('BOX:') || value.startsWith('PARCEL:')
+ ? { ...request, unitType: value.slice(0, value.indexOf(':')), unitCode: value.slice(value.indexOf(':') + 1), palletCode: undefined }
+ : request
+ return normalizeReturnReceiptResult(await overseasRequest('POST', `${BASE}/confirm`, encoded))
}
export async function createReturnPallet(returnNo, skuCode) {
diff --git a/src/api/trip-handover.js b/src/api/trip-handover.js
new file mode 100644
index 0000000..558286f
--- /dev/null
+++ b/src/api/trip-handover.js
@@ -0,0 +1,28 @@
+import { overseasRequest } from './overseas.js'
+
+const text = value => typeof value === 'string' ? value : ''
+const transport = value => ({ vehiclePlate: text(value?.vehiclePlate), driverName: text(value?.driverName), driverPhone: text(value?.driverPhone) })
+export function normalizeHandoverStatus(value) {
+ if (!value?.dispatchNo || !value?.tripNo || !Array.isArray(value.deliveries)) throw new Error('交接状态缺少排车或派送依据')
+ return {
+ dispatchNo: text(value.dispatchNo), tripNo: text(value.tripNo), tripStatus: text(value.tripStatus),
+ tripRecordVersion: value.tripRecordVersion == null ? '' : String(value.tripRecordVersion),
+ originalDeparture: transport(value.originalDeparture),
+ deliveries: value.deliveries.map(row => {
+ const packages = Number(row.packages)
+ if (!row.deliveryNo || row.packages == null || String(row.packages).trim() === '' || !Number.isSafeInteger(packages) || packages <= 0) throw new Error('交接状态中的派送箱数无效')
+ const remainingPackages = Number(row.remainingPackages)
+ if (row.remainingPackages == null || String(row.remainingPackages).trim() === '' || !Number.isSafeInteger(remainingPackages) || remainingPackages < 0 || remainingPackages > packages) throw new Error('交接状态中的剩余箱数无效')
+ return { deliveryNo: text(row.deliveryNo), status: text(row.status), packages, remainingPackages, terminalAt: text(row.terminalAt), transport: transport(row.transport) }
+ }),
+ latestHandover: value.latestHandover ? { handoverNo: text(value.latestHandover.handoverNo), handedOverAt: text(value.latestHandover.handedOverAt),
+ location: text(value.latestHandover.location), previous: transport(value.latestHandover.previous), replacement: transport(value.latestHandover.replacement) } : null
+ }
+}
+export async function getDispatchHandoverStatus(dispatchNo) {
+ const number = String(dispatchNo || '').trim()
+ if (!number) throw new Error('请先打开排车任务')
+ const result = normalizeHandoverStatus(await overseasRequest('GET', `/pda/v1/last-leg/dispatches/${encodeURIComponent(number)}/handover-status`))
+ if (result.dispatchNo !== number) throw new Error('返回的交接记录不属于当前排车任务')
+ return result
+}
diff --git a/src/components/DispatchHandoverStatus.vue b/src/components/DispatchHandoverStatus.vue
new file mode 100644
index 0000000..39f2589
--- /dev/null
+++ b/src/components/DispatchHandoverStatus.vue
@@ -0,0 +1,61 @@
+
+
+ 发车与交接记录
+ 正在读取当前运输资源…
+
+ {{ error.message }}requestId:{{ error.requestId }}traceId:{{ error.traceId }}
+
+
+
+ 车次 {{ status.tripNo }}
+ 原始发车:{{ status.originalDeparture.vehiclePlate || '—' }} · {{ status.originalDeparture.driverName || '—' }}
+
+ 最近交接 {{ status.latestHandover.handoverNo || '—' }}
+ {{ time(status.latestHandover.handedOverAt) }} · {{ status.latestHandover.location || '—' }}
+ {{ status.latestHandover.previous.vehiclePlate || '—' }} → {{ status.latestHandover.replacement.vehiclePlate || '—' }}
+ {{ status.latestHandover.previous.driverName || '—' }} → {{ status.latestHandover.replacement.driverName || '—' }}
+
+ 本排车暂无在途交接记录。
+
+ {{ delivery.deliveryNo }} · 原实发 {{ delivery.packages }} 箱 · 剩余 {{ delivery.remainingPackages }} 箱 · {{ label(delivery) }}
+ {{ delivery.terminalAt ? '完成时承运' : '当前承运' }}:{{ delivery.transport.vehiclePlate || '—' }} · {{ delivery.transport.driverName || '—' }}
+ 联系电话 {{ delivery.transport.driverPhone }}
+
+ 交接由后台确认。本页只读,不重复发车。
+
+
+
+
+
diff --git a/src/config/runtime.js b/src/config/runtime.js
index 6ab17d4..12a8ed0 100644
--- a/src/config/runtime.js
+++ b/src/config/runtime.js
@@ -1,32 +1,13 @@
-const runtimeEnvironment = import.meta.env || {}
-const configuredAuthApiRoot = runtimeEnvironment.VITE_AUTH_API_BASE_URL || ''
-const configuredOverseasApiRoot = runtimeEnvironment.VITE_OVERSEAS_API_BASE_URL || ''
-const development = Boolean(runtimeEnvironment.DEV)
-
-let selectedAuthApiRoot = configuredAuthApiRoot || '/api'
-let selectedOverseasApiRoot = configuredOverseasApiRoot || '/overseas-api'
-const nativeRuntime = typeof plus !== 'undefined'
- || (typeof location !== 'undefined' && location.protocol === 'file:')
-
-// H5 must stay same-origin and use Vite's CORS-safe proxy; App builds keep the absolute API roots above.
-// #ifdef H5
-selectedAuthApiRoot = runtimeEnvironment.VITE_H5_AUTH_API_BASE_URL || '/api'
-selectedOverseasApiRoot = runtimeEnvironment.VITE_H5_OVERSEAS_API_BASE_URL || '/overseas-api'
+const development = Boolean(import.meta.env?.DEV)
+let selectedApiRoot = ''
+// #ifndef H5
+selectedApiRoot = import.meta.env?.VITE_API_BASE_URL || ''
// #endif
-
-// HBuilderX/App 基座 can expose a file:// location; never allow a relative API
-// root there, even if a stale H5 bundle or compiler flag was reused.
-if (nativeRuntime) {
- selectedAuthApiRoot = configuredAuthApiRoot || selectedAuthApiRoot
- // Native file:// builds cannot use the H5-only /overseas-api Vite proxy.
- // Keep the missing value explicit so the request layer can report a useful
- // configuration error instead of surfacing an opaque uploadFile:fail.
- selectedOverseasApiRoot = configuredOverseasApiRoot || ''
-}
-
-export const API_ROOT = String(selectedAuthApiRoot).replace(/\/$/, '')
-export const OVERSEAS_API_ROOT = String(selectedOverseasApiRoot).replace(/\/$/, '')
+// #ifdef H5
+selectedApiRoot = import.meta.env?.VITE_H5_API_BASE_URL || '/gateway-api'
+// #endif
+export const API_ROOT = String(selectedApiRoot).replace(/\/$/, '')
export const DEV_LOGIN = Object.freeze({
- username: development ? String(runtimeEnvironment.VITE_PDA_DEV_USERNAME || '') : '',
- password: development ? String(runtimeEnvironment.VITE_PDA_DEV_PASSWORD || '') : ''
+ username: development ? String(import.meta.env?.VITE_PDA_DEV_USERNAME || '') : '',
+ password: development ? String(import.meta.env?.VITE_PDA_DEV_PASSWORD || '') : ''
})
diff --git a/src/data/overseas-workbench.js b/src/data/overseas-workbench.js
index 60b9292..d99c613 100644
--- a/src/data/overseas-workbench.js
+++ b/src/data/overseas-workbench.js
@@ -8,7 +8,7 @@ export const MODULES = [
] },
{ 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: '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: 'move', name: '商品移库', nameKey: 'modules.move.name', icon: '移', iconType: 'loop', description: '扫描来源与目标库位完成移库', descriptionKey: 'modules.move.description', available: true, route: '/pages/move/move' },
diff --git a/src/manifest.json b/src/manifest.json
index 69a2632..f15f42a 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -80,6 +80,9 @@
},
"vueVersion" : "3",
"h5" : {
+ "router" : {
+ "mode" : "hash"
+ },
"devServer" : {
"port" : 5176
}
diff --git a/src/pages/dispatch/dispatch.vue b/src/pages/dispatch/dispatch.vue
index e57881b..ba9b125 100644
--- a/src/pages/dispatch/dispatch.vue
+++ b/src/pages/dispatch/dispatch.vue
@@ -26,21 +26,23 @@
- 待处理排车任务{{ tasks.length }} 个任务
+ 排车任务{{ tasks.length }} 个任务
暂无排车拣货任务可刷新列表,或直接扫描排车单号恢复作业。
+
{{ current.dispatchNo || '—' }}{{ statusLabel(current.status) }}
- 任务号 {{ current.taskNo || '—' }} · 车牌 {{ current.vehiclePlate || '—' }}
+ 任务号 {{ current.taskNo || '—' }} · {{ departed ? '原发车车牌' : '车牌' }} {{ current.vehiclePlate || '—' }}
+ {{ current.pickBlockReason || '请先通过调度审核再拣货' }}
{{ plannedPackages }}计划箱数{{ pickedPackages }}已拣箱数{{ remainingPackages }}待拣箱数
@@ -67,12 +69,14 @@
- 发车门禁
+ 整车发车
+ 车次 {{ current.tripNo }} · 共 {{ current.tripDispatchCount }} 张排车单
+ {{ current.driverWarning }}
服务端仍显示 {{ remainingPackages }} 箱未拣,不允许发车。
该排车任务已执行发车,不可重复提交。
{{ current.departBlockReason || '当前排车暂不满足发车条件,请刷新后重试。' }}
- 拣货已完成且全部箱数核平,可以发车。
-
+ 本车所有排车单已就绪,确认后将一并发车。
+
@@ -85,7 +89,8 @@
diff --git a/src/pages/profile/profile.vue b/src/pages/profile/profile.vue
index d9c39a4..500a025 100644
--- a/src/pages/profile/profile.vue
+++ b/src/pages/profile/profile.vue
@@ -13,7 +13,7 @@
- 退出后会清空海外 PDA 的本地登录会话;未提交作业数据不得跨账号保留。
+ 退出会撤销当前登录会话并清空本地作业草稿;未提交作业数据不得跨账号保留。
设置功能将在后续版本开放
@@ -22,7 +22,8 @@
import PopupHeader from '@/components/PopupHeader.vue'
import { getCurrentWarehouseProfile } from '@/api/inbound.js'
import { currentLocale, LOCALE_OPTIONS, setLocale } from '@/i18n/index.js'
- import { applyWarehouseProfile, session, requireSession, signOut } from '@/stores/session.js'
+ import { applyWarehouseProfile, session, requireSession, getSessionRevision } from '@/stores/session.js'
+ import { logoutCurrentSession } from '@/api/logout-session.js'
import { checkForAppUpdate } from '@/utils/app-update.js'
export default {
components: { PopupHeader, PortalHeader },
@@ -66,7 +67,19 @@
async loadWarehouseProfile() { try { this.user = applyWarehouseProfile(await getCurrentWarehouseProfile()) } catch (_) { this.user = session.currentUser } },
openLogout() { this.logoutOpen = true; this.$refs.logoutPopup.open() }, closeLogout() { this.logoutOpen = false; this.$refs.logoutPopup.close() }, handleLogoutChange(event) { this.logoutOpen = Boolean(event.show) },
showSettings() { clearTimeout(this._toastTimer); this.toast = true; this._toastTimer = setTimeout(() => { this.toast = false }, 2200) },
- logout() { signOut(); uni.reLaunch({ url: '/pages/login/login' }) }
+ logout() {
+ if (this._logoutPromise) return this._logoutPromise
+ this.closeLogout()
+ const operation = logoutCurrentSession()
+ this.user = null
+ uni.reLaunch({ url: '/pages/login/login' })
+ this._logoutPromise = operation.catch(error => {
+ if (getSessionRevision() === error.logoutRevision) {
+ uni.showToast({ title: '已清除本地登录,服务端退出未确认', icon: 'none' })
+ }
+ })
+ return this._logoutPromise
+ }
}
}
diff --git a/src/pages/return-receipt/return-receipt.vue b/src/pages/return-receipt/return-receipt.vue
index c9c1eb6..3f41748 100644
--- a/src/pages/return-receipt/return-receipt.vue
+++ b/src/pages/return-receipt/return-receipt.vue
@@ -2,38 +2,45 @@
-
+
扫描 SKU 69 码
扫描完成后自动匹配对应退货单
-
+
匹配退货单{{ candidates.length }} 个候选
{{ entryMode === 'SKU' ? '当前绑定仓没有包含该 SKU 的待签收退货单' : '当前绑定仓没有匹配的待签收或签收中退货单' }}
-
+ 待完成按箱签收{{ receiptBatch.returnNo }}已确认 {{ receiptBatch.entries.filter(entry => entry.result).length }} / {{ receiptBatch.entries.length }} 箱。请继续本批签收,已确认的箱不会重新入库;本批内容暂不可修改。
+
{{ current.returnNo }}{{ startedBy === 'SKU' ? '通过 SKU 候选进入' : '通过退货单号进入' }}{{ statusLabel(current.status) }}
- 本托信息
- 本次签收按一个 SKU 打一个托盘,托盘码必填。
- 托盘码{{ palletCheck.empty ? '空托盘,可绑定当前退货单和 SKU' : `当前单托盘,已有 ${palletCheck.skuCodes.join('、')},共 ${palletCheck.quantity} 件` }}
- 快递单号(选填)
+ 本次实物单元
+ 按现场收到的实物选择维度,托盘不是必填项。
+
+ 包裹码/快递单号
+
+ 托盘入库:扫描已有托盘或生成新托盘,沿用原托盘测量流程。箱子可直接散装入库,也可扫描托盘直接打托。
+ 托盘码({{ unitType === 'BOX' ? '选填' : '必填' }}){{ palletCheck.empty ? '空托盘,可绑定当前退货单和 SKU' : `当前单托盘,已有 ${palletCheck.skuCodes.join('、')},共 ${palletCheck.quantity} 件` }}
+ 体积{{ palletVolume }}cm³
+ 快递单号(选填)
+
- 本托实收数量{{ item.skuCode }}{{ item.productNameZh || '商品名称未填写' }}剩余 {{ remaining(item) }}本托实收
+ {{ unitType === 'PARCEL' ? '本次实收数量' : (unitType === 'PALLET' ? '本托实收数量' : '逐箱实收数量') }}本托箱数合计{{ palletBoxCount }}本次箱数第 {{ index + 1 }} 箱{{ item.skuCode }} · 数量{{ item.skuCode }}{{ item.productNameZh || '商品名称未填写' }}剩余 {{ remaining(item) }}数量此 SKU 箱数
本托合计{{ receiptQuantity }}
退货打托照片签收过程中可随时拍照{{ photoFileIds.length }} / 20 张{{ photoNames[index] || '退货打托照片' }}
本单已完成打托{{ pallet.palletCode }}{{ pallet.skuCode }} · {{ pallet.quantity }} 件签收批次:{{ pallet.receiptBatchNo }}
- 退货签收并入托成功托盘:{{ result.palletCode }}签收单:{{ result.receiptNo }}计费批次:{{ result.receiptBatchNo }}{{ result.receivedQuantity }}/{{ result.expectedQuantity }}整单累计{{ result.fullyMatched ? '已完成' : '签收中' }}退货单状态
+ 退货签收成功第 {{ index + 1 }} 箱 · {{ label.cartonMark }}{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }} · {{ result.cartonMark }}托盘:{{ result.palletCode }}签收单:{{ result.receiptNo }}计费批次:{{ result.receiptBatchNo }}{{ result.receivedQuantity }}/{{ result.expectedQuantity }}整单累计{{ result.fullyMatched ? '已完成' : '签收中' }}退货单状态
- {{ error.message }}错误码:{{ error.code }}requestId:{{ error.requestId }}traceId:{{ error.traceId }}
+ {{ error.message }}错误码:{{ error.code }}requestId:{{ error.requestId }}traceId:{{ error.traceId }}
@@ -43,51 +50,104 @@
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { checkReturnPallet, confirmReturnReceipt, createReturnPallet, downloadReturnPalletPhoto, findReturnSkuCandidates, manuallyCompleteReturnReceipt, startReturnReceipt, uploadReturnPalletPhoto } from '@/api/return-receipt.js'
import { session, requireSession } from '@/stores/session.js'
- import { releaseWarehouseKey, resetWarehouseDomain, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
+ import { resetWarehouseDomain, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
export default {
components: { PortalHeader, ScanTargetInput },
- data() { return { user: session.currentUser, current: null, candidates: null, result: null, selectedSku: '', palletCheck: null, newPalletHtml: '', photoFileIds: [], photoNames: [], photoUrls: {}, activeTarget: 'lookup', busy: false, error: null } },
+ data() { return { user: session.currentUser, current: null, candidates: null, result: null, selectedSku: '', palletCheck: null, newPalletHtml: '', unitType: 'PALLET', unitCode: '', boxRows: [{ items: {} }], palletLength: '', palletWidth: '', palletHeight: '', palletWeight: '', unitOptions: [{ value: 'PALLET', label: '托盘' }, { value: 'BOX', label: '箱子' }, { value: 'PARCEL', label: '包裹' }], photoFileIds: [], photoNames: [], photoUrls: {}, activeTarget: 'lookup', busy: false, error: null } },
computed: {
+ receiptBatch() { return warehouseWork.returnReceipt.pendingBatch },
entryMode: { get() { return warehouseWork.returnReceipt.entryMode }, set(value) { updateWarehouseWork('returnReceipt', { entryMode: value }) } },
lookupCode: { get() { return warehouseWork.returnReceipt.lookupCode }, set(value) { updateWarehouseWork('returnReceipt', { lookupCode: value }) } },
startedBy: { get() { return warehouseWork.returnReceipt.startedBy }, set(value) { updateWarehouseWork('returnReceipt', { startedBy: value }) } },
trackingNo: { get() { return warehouseWork.returnReceipt.trackingNo }, set(value) { updateWarehouseWork('returnReceipt', { trackingNo: value }) } },
palletCode: { get() { return warehouseWork.returnReceipt.palletCode }, set(value) { updateWarehouseWork('returnReceipt', { palletCode: value }) } },
selectedItems() { return this.current?.expectedItems?.filter(item => !this.selectedSku || item.skuCode === this.selectedSku) || [] },
+ palletBoxCount() { return this.selectedItems.reduce((sum, item) => sum + this.boxCount(item.skuCode), 0) },
activeSkuCode() { return this.selectedItems.length === 1 ? this.selectedItems[0].skuCode : '' },
- receiptQuantity() { return Object.values(warehouseWork.returnReceipt.quantities || {}).reduce((total, value) => total + (Number(value) || 0), 0) },
- canConfirm() { return this.current && this.palletCode.trim() && this.palletCheck && this.selectedItems.length === 1 && this.receiptQuantity > 0 && this.selectedItems.every((item) => this.quantity(item.skuCode) <= this.remaining(item)) }
+ receiptQuantity() { return this.unitType === 'BOX' ? this.boxRows.reduce((sum, box) => sum + Object.values(box.items).reduce((n, quantity) => n + (Number(quantity) || 0), 0), 0) : Object.values(warehouseWork.returnReceipt.quantities || {}).reduce((total, value) => total + (Number(value) || 0), 0) },
+ canConfirm() { if (!this.current || !this.selectedItems.length || this.receiptQuantity <= 0) return false; if (this.unitType === 'BOX') return this.boxRows.length > 0 && this.boxRows.every(box => Object.values(box.items).some(quantity => Number(quantity) > 0)) && this.selectedItems.every(item => this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) <= this.remaining(item)); return (this.unitType === 'PARCEL' ? this.unitCode.trim() : this.palletCode.trim() && this.palletCheck && Number(this.palletLength) > 0 && Number(this.palletWidth) > 0 && Number(this.palletHeight) > 0 && Number(this.palletWeight) > 0 && (this.unitType !== 'PALLET' || this.palletBoxCount > 0)) && this.selectedItems.every((item) => this.quantity(item.skuCode) <= this.remaining(item) && (this.unitType !== 'PALLET' || this.quantity(item.skuCode) === 0 || this.boxCount(item.skuCode) > 0)); },
+ unitLabel() { return this.unitType === 'PALLET' ? '托盘码' : this.unitType === 'BOX' ? '箱号/箱唛' : '包裹码/快递单号' },
+ unitPlaceholder() { return this.unitType === 'PALLET' ? '扫描托盘标签' : this.unitType === 'BOX' ? '扫描箱唛或录入箱号' : '扫描包裹面单或录入包裹码' }
+ ,palletVolume() { const l = Number(this.palletLength) || 0; const w = Number(this.palletWidth) || 0; const h = Number(this.palletHeight) || 0; return l > 0 && w > 0 && h > 0 ? (l * w * h).toFixed(2) : '—' }
},
- onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.entryMode = 'SKU'; this.startScanner(); if (warehouseWork.returnReceipt.currentReturnNo && !this.current && !this.busy) this.openOrder(warehouseWork.returnReceipt.currentReturnNo, 'SKU', false) },
+ onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.entryMode = 'SKU'; this.startScanner(); if (!this.receiptBatch && warehouseWork.returnReceipt.currentReturnNo && !this.current && !this.busy) this.openOrder(warehouseWork.returnReceipt.currentReturnNo, 'SKU', false) },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: {
+ selectUnitType(type) { if (this.busy) return; this.unitType = type; this.unitCode = ''; this.boxRows = [{ items: {} }]; this.palletCheck = null; this.activeTarget = type === 'PALLET' ? 'pallet' : 'unit' },
+ resizeBoxRows(raw) { const count = Math.floor(Number(raw) || 0); if (count < 1 || count > 100) return; this.boxRows = Array.from({ length: count }, (_, index) => this.boxRows[index] || { items: {} }) },
+ setBoxQuantity(index, skuCode, raw) { const value = Math.max(0, Math.floor(Number(raw) || 0)); this.boxRows[index].items = { ...this.boxRows[index].items, [skuCode]: value } },
+ acceptUnit(raw) { const code = String(raw?.detail?.value ?? raw ?? '').trim(); if (!code) return; this.unitCode = code; this.palletCode = `${this.unitType}:${code}`; this.activeTarget = 'tracking'; playSuccessSound() },
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 },
- handleScan(value) { if (this.busy) return; if (!this.current) { this.lookupCode = value; this.lookup(value); return } if (this.activeTarget === 'pallet') this.acceptPallet(value); else this.acceptTracking(value) },
+ handleScan(value) { if (this.busy || this.receiptBatch) return; if (!this.current) { this.lookupCode = value; this.lookup(value); return } if (this.activeTarget === 'unit') this.acceptUnit(value); else this.acceptTracking(value) },
switchMode(mode) { if (this.busy) return; this.entryMode = mode; this.lookupCode = ''; this.candidates = null; this.error = null; this.activeTarget = 'lookup' },
async lookup(raw) { const value = String(raw?.detail?.value ?? raw ?? this.lookupCode).trim(); if (!value || this.busy) return; this.lookupCode = value; this.error = null; this.result = null; if (this.entryMode === 'SKU') await this.run(async () => { const response = await findReturnSkuCandidates(value); this.candidates = response.candidates; this.selectedSku = response.skuCode; if (this.candidates.length === 1) await this.openOrderValue(this.candidates[0].returnNo, 'SKU'); else if (this.candidates.length) playSuccessSound() }, '查询 SKU 候选失败'); else await this.openOrder(value, 'RETURN_NO') },
async selectCandidate(item) { if (!item?.returnNo || this.busy) return; this.selectedSku = this.lookupCode; await this.openOrder(item.returnNo, 'SKU') },
async openOrder(returnNo, startedBy, feedback = true) { await this.run(async () => { await this.openOrderValue(returnNo, startedBy, feedback) }, '读取退货单失败') },
- async openOrderValue(returnNo, startedBy, feedback = true) { const value = await startReturnReceipt(returnNo); if (!value) { this.current = null; this.candidates = []; updateWarehouseWork('returnReceipt', { currentReturnNo: '', quantities: {} }); this.activeTarget = 'lookup'; return } this.current = value; this.palletCheck = null; this.candidates = null; this.startedBy = startedBy; this.photoFileIds = (value.photos || []).map((item) => String(item.fileId)); this.photoNames = (value.photos || []).map((item) => item.fileName || '退货打托照片'); this.photoUrls = {}; await Promise.all(this.photoFileIds.map(async (fileId) => { try { this.photoUrls[fileId] = await downloadReturnPalletPhoto(value.returnNo, fileId) } catch (_) { /* 单张照片失败不阻断作业 */ } })); updateWarehouseWork('returnReceipt', { currentReturnNo: value.returnNo, startedBy, lookupCode: value.returnNo, palletCode: '', quantities: this.validQuantities(value) }); this.activeTarget = 'pallet'; if (feedback) playSuccessSound() },
+ async openOrderValue(returnNo, startedBy, feedback = true) { const value = await startReturnReceipt(returnNo); if (!value) { this.current = null; this.candidates = []; updateWarehouseWork('returnReceipt', { currentReturnNo: '', quantities: {}, boxCounts: {} }); this.activeTarget = 'lookup'; return } this.current = value; this.palletCheck = null; this.candidates = null; this.startedBy = startedBy; this.photoFileIds = (value.photos || []).map((item) => String(item.fileId)); this.photoNames = (value.photos || []).map((item) => item.fileName || '退货打托照片'); this.photoUrls = {}; await Promise.all(this.photoFileIds.map(async (fileId) => { try { this.photoUrls[fileId] = await downloadReturnPalletPhoto(value.returnNo, fileId) } catch (_) { /* 单张照片失败不阻断作业 */ } })); updateWarehouseWork('returnReceipt', { currentReturnNo: value.returnNo, startedBy, lookupCode: value.returnNo, palletCode: '', quantities: this.validQuantities(value), boxCounts: {} }); this.activeTarget = 'pallet'; if (feedback) playSuccessSound() },
validQuantities(order) { const source = warehouseWork.returnReceipt.quantities || {}; return Object.fromEntries(order.expectedItems.map((item) => [item.skuCode, Math.min(Math.max(0, Number(source[item.skuCode]) || 0), this.remaining(item))])) },
quantity(skuCode) { return Math.max(0, Number(warehouseWork.returnReceipt.quantities?.[skuCode]) || 0) },
+ boxCount(skuCode) { return Math.max(0, Number(warehouseWork.returnReceipt.boxCounts?.[skuCode]) || 0) },
remaining(item) { return Math.max(0, Number(item.expectedQuantity || 0) - Number(item.receivedQuantity || 0)) },
setQuantity(item, raw) { const maximum = this.remaining(item); const value = Math.min(maximum, Math.max(0, Math.floor(Number(raw) || 0))); updateWarehouseWork('returnReceipt', { quantities: { ...(warehouseWork.returnReceipt.quantities || {}), [item.skuCode]: value } }) },
+ setBoxCount(item, raw) { const value = Math.max(0, Math.floor(Number(raw) || 0)); updateWarehouseWork('returnReceipt', { boxCounts: { ...(warehouseWork.returnReceipt.boxCounts || {}), [item.skuCode]: value } }) },
fillRemaining() { updateWarehouseWork('returnReceipt', { quantities: Object.fromEntries(this.selectedItems.map((item) => [item.skuCode, this.remaining(item)])) }) },
async acceptPallet(raw) { const code = String(raw?.detail?.value ?? raw ?? '').trim(); if (!code || this.busy) return; if (!this.activeSkuCode) { this.setError(new Error('请先通过 SKU 69 码选择一个待签收 SKU'), '托盘校验失败'); return } await this.run(async () => { const value = await checkReturnPallet(this.current.returnNo, code); this.palletCode = value.palletCode; this.newPalletHtml = ''; this.palletCheck = value; this.activeTarget = 'tracking'; playSuccessSound() }, '托盘校验失败') },
async generatePallet() { if (!this.current || this.busy) return; const skuCode = this.activeSkuCode; if (!skuCode) { this.setError(new Error('请先通过 SKU 69 码选择一个待签收 SKU'), '无法生成退货托盘'); return } await this.run(async () => { const value = await createReturnPallet(this.current.returnNo, skuCode); if (!value.palletCode) throw new Error('服务端未返回新托盘码'); this.palletCode = value.palletCode; this.newPalletHtml = value.printableHtml; this.palletCheck = { palletCode: value.palletCode, empty: true, skuCodes: [], quantity: 0 }; this.activeTarget = 'tracking'; playSuccessSound() }, '生成退货托盘失败') },
printPallet() { if (!this.newPalletHtml) return; /* #ifdef H5 */ const view = window.open('', '_blank'); if (view) { view.document.write(this.newPalletHtml); view.document.close(); view.focus(); view.print() } else this.setError(new Error('请允许打开标签打印窗口'), '打印失败'); /* #endif */ /* #ifndef H5 */ this.setError(new Error('当前设备未配置打印服务'), '打印失败'); /* #endif */ },
+ printReturnCarton(label) { if (!label?.printableHtml) return; /* #ifdef H5 */ const view = window.open('', '_blank'); if (view) { view.document.write(label.printableHtml); view.document.close(); view.focus(); view.print() } else this.setError(new Error('请允许打开标签打印窗口'), '打印失败'); /* #endif */ /* #ifndef H5 */ this.setError(new Error('当前设备未配置打印服务'), '打印失败'); /* #endif */ },
acceptTracking(raw) { this.trackingNo = String(raw?.detail?.value ?? raw ?? '').trim(); playSuccessSound() },
- async confirmReceipt() { if (!this.canConfirm || this.busy) return; const willComplete = this.current.expectedItems.every((item) => this.quantity(item.skuCode) >= this.remaining(item)); if (willComplete && !this.photoFileIds.length) { const modal = await new Promise((resolve) => uni.showModal({ title: '缺少退货打托照片', content: '本次签收将完成整单入库,请先拍照并上传后再确认打托。', confirmText: '去拍照', cancelText: '取消', success: resolve })); if (modal?.confirm) { this.error = { message: '请先拍照并上传退货打托照片后再确认' }; } return } const payload = { returnNo: this.current.returnNo, trackingNo: this.trackingNo.trim() || undefined, startedBy: this.startedBy, palletCode: this.palletCode.trim(), items: this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: this.quantity(item.skuCode) })).filter((item) => item.receivedQuantity > 0) }; const key = warehouseIdempotencyKey('returnReceipt', 'confirm', payload); await this.run(async () => { this.result = await confirmReturnReceipt({ ...payload, idempotencyKey: key.value }); releaseWarehouseKey('returnReceipt', key.signature); resetWarehouseDomain('returnReceipt'); this.current = null; this.candidates = null; this.activeTarget = 'lookup'; playSuccessSound() }, '退货入库失败') },
+ async confirmReceipt() { if (this.busy) return; if (this.receiptBatch) { await this.run(() => this.resumeReceiptBatch(), '退货入库失败'); return } const totalBoxCount = this.palletBoxCount; if (this.unitType === 'PALLET' && totalBoxCount <= 0) { this.setError(new Error('请先填写各 SKU 的箱数'), '托盘签收校验失败'); return } if (!this.canConfirm || this.busy) return; const willComplete = this.current.expectedItems.every((item) => (this.unitType === 'BOX' ? this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) : this.quantity(item.skuCode)) >= this.remaining(item)); if (willComplete && !this.photoFileIds.length) { const modal = await new Promise((resolve) => uni.showModal({ title: '缺少退货打托照片', content: '本次签收将完成整单入库,请先拍照并上传后再确认打托。', confirmText: '去拍照', cancelText: '取消', success: resolve })); if (modal?.confirm) this.error = { message: '请先拍照并上传退货打托照片后再确认' }; return }
+ await this.run(async () => {
+ const rows = this.unitType === 'BOX' ? this.boxRows : [null]
+ const entries = rows.map((box, index) => {
+ const payload = { returnNo: this.current.returnNo, trackingNo: this.trackingNo.trim() || undefined, startedBy: this.startedBy, unitType: this.unitType, unitCode: this.unitType === 'PARCEL' ? this.unitCode.trim() : undefined, palletCode: this.unitType === 'PALLET' || this.unitType === 'BOX' ? this.palletCode.trim() || undefined : undefined, length: this.unitType === 'PALLET' ? Number(this.palletLength) : undefined, width: this.unitType === 'PALLET' ? Number(this.palletWidth) : undefined, height: this.unitType === 'PALLET' ? Number(this.palletHeight) : undefined, weight: this.unitType === 'PALLET' ? Number(this.palletWeight) : undefined, totalBoxCount: this.unitType === 'PALLET' ? totalBoxCount : undefined, items: (this.unitType === 'BOX' ? this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: Number(box.items[item.skuCode]) || 0, boxCount: 1 })) : this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: this.quantity(item.skuCode), boxCount: this.unitType === 'PALLET' ? this.boxCount(item.skuCode) : undefined }))).filter((item) => item.receivedQuantity > 0) }
+ const key = warehouseIdempotencyKey('returnReceipt', `confirm-${index}`, payload)
+ return { request: { ...payload, idempotencyKey: key.value }, result: null }
+ })
+ if (this.unitType === 'BOX') {
+ updateWarehouseWork('returnReceipt', { pendingBatch: JSON.parse(JSON.stringify({ returnNo: this.current.returnNo, entries })) })
+ await this.resumeReceiptBatch()
+ } else {
+ this.finishReceipt([await confirmReturnReceipt(entries[0].request)])
+ }
+ }, '退货入库失败')
+ },
+ async resumeReceiptBatch() {
+ const batch = this.receiptBatch
+ const scopeKey = warehouseWork.scopeKey
+ if (!batch) return
+ for (let index = 0; index < batch.entries.length; index++) {
+ if (warehouseWork.scopeKey !== scopeKey) throw new Error('作业账号或仓库已改变,请重新进入作业')
+ if (batch.entries[index].result) continue
+ // Persist the immutable request before every attempt; storage failures must stop submission.
+ updateWarehouseWork('returnReceipt', { pendingBatch: batch })
+ const result = await confirmReturnReceipt(batch.entries[index].request)
+ if (warehouseWork.scopeKey !== scopeKey) throw new Error('作业账号或仓库已改变,请重新进入作业')
+ if (!result) throw new Error('服务端未返回签收结果,请重试本批签收')
+ batch.entries[index].result = result
+ updateWarehouseWork('returnReceipt', { pendingBatch: batch })
+ }
+ this.finishReceipt(batch.entries.map(entry => entry.result))
+ },
+ finishReceipt(results) {
+ const last = results[results.length - 1]
+ this.result = { ...last, labels: results.filter(item => item.unitType === 'BOX' && item.printableHtml).map(item => ({ cartonMark: item.cartonMark, printableHtml: item.printableHtml })) }
+ resetWarehouseDomain('returnReceipt')
+ this.current = null
+ this.candidates = null
+ this.activeTarget = 'lookup'
+ playSuccessSound()
+ },
manualComplete() { if (!this.current || this.busy) return; uni.showModal({ title: '缺货手动完结', editable: true, placeholderText: '请填写实收少于预期数量的原因', success: async (modal) => { if (!modal.confirm) return; const reason = String(modal.content || '').trim(); if (!reason) { this.setError(new Error('必须填写差异原因'), '手动完结失败'); return } await this.run(async () => { await manuallyCompleteReturnReceipt(this.current.returnNo, reason); this.result = { palletCode: '', receiptNo: '', receiptBatchNo: '', expectedQuantity: this.current.expectedItems.reduce((sum, item) => sum + item.expectedQuantity, 0), receivedQuantity: this.current.expectedItems.reduce((sum, item) => sum + item.receivedQuantity, 0), fullyMatched: false }; this.current = null; this.candidates = null; this.activeTarget = 'lookup'; playSuccessSound() }, '手动完结失败') } }) },
choosePhoto() { if (!this.current || this.busy || this.photoFileIds.length >= 20) return; const no = this.current.returnNo; uni.chooseImage({ count: Math.min(9, 20 - this.photoFileIds.length), sourceType: ['camera', 'album'], success: async ({ tempFilePaths, tempFiles }) => { await this.run(async () => { for (const [index,path] of (tempFilePaths || []).entries()) { if (Number(tempFiles?.[index]?.size || 0) > 10 * 1024 * 1024) throw new Error('照片不能超过 10MB'); const file = await uploadReturnPalletPhoto(no, path); const id = String(file.fileId); this.photoFileIds.push(id); this.photoNames.push(file.fileName || '退货打托照片'); this.photoUrls[id] = path } playSuccessSound() }, '退货打托照片上传失败') } }) },
previewPhoto(fileId) { const urls = this.photoFileIds.map(id => this.photoUrls[id]).filter(Boolean); if (this.photoUrls[fileId]) uni.previewImage({ current: this.photoUrls[fileId], urls }) },
removePhoto(index) { const [id] = this.photoFileIds.splice(index, 1); this.photoNames.splice(index, 1); if (id) delete this.photoUrls[id] },
async reloadCurrent() { if (!this.current?.returnNo || this.busy) return; await this.openOrder(this.current.returnNo, this.startedBy, false) },
- clearCurrent() { resetWarehouseDomain('returnReceipt'); this.current = null; this.candidates = null; this.error = null; this.activeTarget = 'lookup' },
+ clearCurrent() { if (this.busy || this.receiptBatch) return; resetWarehouseDomain('returnReceipt'); this.current = null; this.candidates = null; this.error = null; this.activeTarget = 'lookup' },
nextReceipt() { this.result = null; this.clearCurrent() },
async run(work, fallback) { if (this.busy) return; this.busy = true; this.error = null; try { await work() } catch (error) { this.setError(error, fallback) } finally { this.busy = false } },
setError(error, fallback) { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
@@ -100,4 +160,7 @@
@import '../warehouse/operations.scss';
.candidate-heading .card-title{margin-bottom:0}.candidate-card{width:100%;text-align:left}.select-link{color:#135ee8;font-size:12px;font-weight:800}.field-label{display:block;margin:13px 0 6px;color:#52667e;font-size:11px;font-weight:700}.remaining-copy{color:#135ee8;font-size:11px}.quantity-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:10px;color:#65768b;font-size:11px}.quantity-row .number-input{width:112px;text-align:center}.summary-bar{position:sticky;bottom:0;z-index:4;display:flex;align-items:center;gap:12px;margin-top:12px;padding:11px 13px;border:1px solid #d8e3f1;border-radius:13px;background:#fff;box-shadow:0 5px 20px rgba(22,55,96,.14)}.summary-bar>view{min-width:70px}.summary-label,.summary-value{display:block}.summary-label{color:#728197;font-size:10px}.summary-value{color:#135ee8;font-size:22px;font-weight:800}.submit-button{margin-top:0}.result-card{text-align:left}.result-carton{display:block;margin:9px 0;color:#135ee8;font-size:20px;font-weight:800;word-break:break-all}.link-button{width:auto}.work-card>.link-button{margin-top:8px;padding-left:0}.success-box .primary{margin-top:14px}.scan-hint{display:block;margin-top:9px;color:#65768b;font-size:11px;text-align:center}
.photo-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:12px}.photo-tile{min-width:0;overflow:hidden;border:1px solid #d7e1ef;border-radius:8px}.photo-thumbnail{display:block;width:100%;height:130px;background:#f1f4f8}.photo-name{display:block;padding:6px 8px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:12px;color:#52647c}.photo-tile .link-button{margin:0 8px 6px}
+ .unit-tabs{display:flex;gap:8px;margin:10px 0}.unit-tab{flex:1!important;min-width:0!important;height:34px!important;min-height:34px!important;margin:0!important;padding:0 10px!important;border:1px solid #d6e1ef!important;border-radius:8px!important;background:#f7f9fc!important;color:#53677f!important;font-size:13px!important;line-height:34px!important}.unit-tab.active{border-color:#135ee8!important;background:#eaf2ff!important;color:#135ee8!important;font-weight:700}
+ .dimension-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:7px;margin-top:10px}.dimension-grid label>text{display:block;margin-bottom:3px;color:#65768b;font-size:10px}.dimension-grid .number-input{box-sizing:border-box;width:100%;height:34px;padding:0 8px;border:1px solid #d7e1ef;border-radius:7px;color:#24364b;font-size:13px;text-align:center;background:#fff}.dimension-grid .number-input:focus{border-color:#2167e8;box-shadow:0 0 0 2px rgba(33,103,232,.12)}.volume-box{display:flex;align-items:center;justify-content:center;gap:4px;height:34px;padding:0 6px;border:1px solid #b9d3f4;border-radius:7px;color:#65768b;font-size:10px;background:#f4f8ff}.volume-box strong{color:#135ee8;font-size:13px}
+ .work-card>.row-head .business-code{font-size:16px;line-height:20px}.work-card>.row-head .meta-line{font-size:10px;line-height:15px}.work-card>.row-head .status-pill{padding:4px 7px;font-size:10px}
diff --git a/src/pages/warehouse-transfer/warehouse-transfer.vue b/src/pages/warehouse-transfer/warehouse-transfer.vue
index 271b3a7..259c77b 100644
--- a/src/pages/warehouse-transfer/warehouse-transfer.vue
+++ b/src/pages/warehouse-transfer/warehouse-transfer.vue
@@ -91,6 +91,7 @@ import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { bindWarehouseTransferPallet, deleteWarehouseTransferPallet, downloadWarehouseTransferMeasurementPhoto, downloadWarehouseTransferPalletLabel, generateWarehouseTransferPallet, getWarehouseTransferDetail, listWarehouseTransferCandidates, listWarehouseTransferMeasurementPhotos, palletizeWarehouseTransfer, saveWarehouseTransferPalletDraft, uploadWarehouseTransferMeasurementPhoto } from '@/api/warehouse-transfers.js'
const newPallet = () => ({ key: `${Date.now()}-${Math.random()}`, cartonCount: 0, lengthCm: null, widthCm: null, heightCm: null, grossWeightKg: null, generated: false, bound: false, collapsed: false, palletNo: '', fbaWarehouseCode: '', labelDownloadPath: '', bindCode: '', busy: false })
+const MAX_MEASUREMENT_PHOTOS = 20
const restoredPallet = (value, index) => ({
key: `server-${value.draftId || value.id || index}`,
@@ -120,6 +121,7 @@ export default {
if (!Number.isInteger(Number(pallet.cartonCount))) return `托盘 ${index + 1} 的箱数必须是整数`
}
if (this.pallets.some((pallet) => !pallet.bound)) return '请先生成并扫描确认绑定全部托盘'
+ if (this.photoFileIds.length > MAX_MEASUREMENT_PHOTOS) return `测量现场照片最多 ${MAX_MEASUREMENT_PHOTOS} 张,请移除多余照片`
if (!this.photoFileIds.length) return '请至少拍摄 1 张测量现场照片'
return ''
},
@@ -207,30 +209,53 @@ export default {
addPallet() { this.pendingKey = ''; this.pallets.push(newPallet()) },
async removePallet(index) { const pallet = this.pallets[index]; if (!pallet || pallet.generated || pallet.busy) return; pallet.busy = true; try { if (pallet.draftId) await deleteWarehouseTransferPallet(this.selected.transferNo, pallet.draftId); this.pallets.splice(index, 1); this.pendingKey = '' } catch (error) { pallet.busy = false; this.error = error } },
async restorePhotos(transferNo, knownPhotos) {
- this.photoFileIds = []; this.photoUrls = {}; this.photoNames = []
+ let photos
try {
- const photos = Array.isArray(knownPhotos) ? knownPhotos : await listWarehouseTransferMeasurementPhotos(transferNo)
- this.photoFileIds = photos.map((photo) => String(photo.fileId)); this.photoNames = photos.map((photo) => photo.fileName || '测量现场照片')
- await Promise.all(photos.map(async (photo) => { try { this.photoUrls[String(photo.fileId)] = await downloadWarehouseTransferMeasurementPhoto(transferNo, photo.fileId) } catch (_) { /* 单张恢复失败不阻断补拍 */ } }))
- } catch (error) { this.error = error }
+ // Detail responses contain submitted photos only. The PDA list endpoint
+ // is the authoritative source for uploads that have not been submitted.
+ photos = await listWarehouseTransferMeasurementPhotos(transferNo)
+ } catch (error) {
+ if (!Array.isArray(knownPhotos)) { this.error = error; return false }
+ photos = knownPhotos
+ }
+ const nextUrls = {}
+ await Promise.all(photos.map(async (photo) => { try { nextUrls[String(photo.fileId)] = await downloadWarehouseTransferMeasurementPhoto(transferNo, photo.fileId) } catch (_) { /* 单张恢复失败不阻断补拍 */ } }))
+ this.photoFileIds = photos.map((photo) => String(photo.fileId)); this.photoNames = photos.map((photo) => photo.fileName || '测量现场照片'); this.photoUrls = nextUrls
+ return true
},
takePhoto() {
- if (!this.selected || this.uploadingPhoto || this.submitting || this.photoFileIds.length >= 20) return
+ if (!this.selected || this.uploadingPhoto || this.submitting || this.photoFileIds.length >= MAX_MEASUREMENT_PHOTOS) {
+ if (this.photoFileIds.length >= MAX_MEASUREMENT_PHOTOS) this.error = new Error(`测量现场照片最多 ${MAX_MEASUREMENT_PHOTOS} 张`)
+ return
+ }
const transferNo = this.selected.transferNo
- uni.chooseImage({ count: Math.min(9, 20 - this.photoFileIds.length), sourceType: ['camera', 'album'], success: async ({ tempFilePaths, tempFiles }) => {
+ uni.chooseImage({ count: Math.min(9, MAX_MEASUREMENT_PHOTOS - this.photoFileIds.length), sourceType: ['camera', 'album'], success: async ({ tempFilePaths, tempFiles }) => {
const paths = (tempFilePaths || []).length ? tempFilePaths : (tempFiles || []).map((file) => file.path || file.tempFilePath).filter(Boolean)
if (this.selected?.transferNo !== transferNo) { this.error = new Error('所选转运单已切换,请重新拍摄'); return }
if (!paths.length) { this.error = new Error('未获取到可上传的照片路径,请重新拍摄'); return }
+ const remaining = MAX_MEASUREMENT_PHOTOS - this.photoFileIds.length
+ const uploadPaths = paths.slice(0, remaining)
+ const skipped = paths.length - uploadPaths.length
this.uploadingPhoto = true; this.error = null
try {
- for (const [index, path] of paths.entries()) {
+ for (const [index, path] of uploadPaths.entries()) {
+ if (this.photoFileIds.length >= MAX_MEASUREMENT_PHOTOS) throw new Error(`测量现场照片最多 ${MAX_MEASUREMENT_PHOTOS} 张`)
if (Number(tempFiles?.[index]?.size || 0) > 10 * 1024 * 1024) throw new Error('照片不能超过 10MB')
const file = await uploadWarehouseTransferMeasurementPhoto(transferNo, path)
if (this.selected?.transferNo !== transferNo) throw new Error('所选转运单已切换,请重新拍摄')
const fileId = String(file.fileId); this.photoUrls[fileId] = path; this.photoFileIds.push(fileId); this.photoNames.push(file.fileName || '测量现场照片')
}
+ if (skipped > 0) this.error = new Error(`最多上传 ${MAX_MEASUREMENT_PHOTOS} 张测量现场照片,已忽略超出的 ${skipped} 张`)
this.pendingKey = ''
- } catch (error) { this.error = error } finally { this.uploadingPhoto = false }
+ } catch (error) {
+ this.error = error
+ // Another window/device may have filled the remaining slots. Re-read
+ // server state so a refresh cannot reset the UI back to 0/20.
+ if (/at most 20|最多.?20|supports at most/i.test(String(error?.message || ''))) {
+ await this.restorePhotos(transferNo)
+ this.error = error
+ }
+ } finally { this.uploadingPhoto = false }
}, fail: (cause) => { if (!/cancel/i.test(String(cause?.errMsg || ''))) this.error = new Error(cause?.errMsg || '选择照片失败,请重试') } })
},
previewPhoto(fileId) { const urls = this.photoFileIds.map((id) => this.photoUrls[id]).filter(Boolean); if (this.photoUrls[fileId]) uni.previewImage({ current: this.photoUrls[fileId], urls }) },
diff --git a/src/stores/receiving.js b/src/stores/receiving.js
index 20bacd8..6847a0d 100644
--- a/src/stores/receiving.js
+++ b/src/stores/receiving.js
@@ -8,6 +8,28 @@ export const receiving = reactive({
})
const idempotencyKeys = new Map()
+let previewOwner = ''
+const previewRequests = new WeakMap()
+
+function photoOwner(value, owner) {
+ if (!owner || !value?.containerNo || !value?.preparedAt) return ''
+ return JSON.stringify([owner, value.containerNo, value.preparedAt, value.overseasWarehouseId,
+ (value.inbounds || []).map(row => [row.customerId || '', row.inboundCode || '']).sort()])
+}
+
+export async function restoreReceivingPhoto(photo, download) {
+ if (!photo?.downloadPath || photo.localPath) return photo?.localPath || ''
+ if (previewRequests.has(photo)) return previewRequests.get(photo)
+ photo.status = 'loading'
+ const request = Promise.resolve().then(() => download(photo.downloadPath)).then(path => {
+ if (!path) throw new Error('照片加载失败')
+ photo.localPath = path; photo.status = 'success'; photo.errorMessage = ''
+ return path
+ }).catch(error => { photo.status = 'error'; photo.errorMessage = error.message || '照片加载失败'; throw error })
+ .finally(() => previewRequests.delete(photo))
+ previewRequests.set(photo, request)
+ return request
+}
export function receivingPhase(session = receiving.current) {
if (!session) return 'candidates'
@@ -23,16 +45,23 @@ export function receivingProgress(session = receiving.current) {
return { total, received, pending: Math.max(0, Number(session?.pendingPackages ?? total - received)), percent: total ? Math.min(100, Math.round(received / total * 100)) : 0 }
}
-export function setCurrentReceiving(value) {
+export function setCurrentReceiving(value, owner = '') {
+ const nextOwner = photoOwner(value, owner)
+ const previous = previewOwner && previewOwner === nextOwner ? receiving.photoSlots : []
+ previewOwner = nextOwner
receiving.current = value || null
receiving.selected = value || null
receiving.photoSlots = RECEIVING_PHOTO_ROLES.map((role) => {
const photo = (value?.photos || []).find((item) => item.role === role)
- return photo ? { ...photo, fileId: String(photo.fileId), role, uploaded: true, status: 'success' } : null
+ if (!photo) return null
+ const old = previous.find(item => item?.role === role && String(item.fileId) === String(photo.fileId)
+ && item.downloadPath === photo.downloadPath)
+ return old || { ...photo, fileId: String(photo.fileId), role, localPath: '', uploaded: true, status: 'idle' }
})
}
export function resetReceivingDraft({ keepCurrent = false } = {}) {
+ previewOwner = ''
receiving.selected = keepCurrent ? receiving.current : null
if (!keepCurrent) receiving.current = null
receiving.photoSlots = [null, null, null]
diff --git a/src/stores/session.js b/src/stores/session.js
index b82e2d0..c086f91 100644
--- a/src/stores/session.js
+++ b/src/stores/session.js
@@ -1,15 +1,17 @@
import { reactive } from 'vue'
-import { OVERSEAS_API_ROOT } from '../config/runtime.js'
+import { API_ROOT } from '../config/runtime.js'
import { resetReceivingSession } from './receiving.js'
import { resetFulfillmentSession } from './fulfillment.js'
import { resetWarehouseWork } from './warehouse-work.js'
const SESSION_KEY = 'overseas-pda-auth-session'
+let sessionRevision = 0
+export function getSessionRevision() { return sessionRevision }
export const PDA_ROLE = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR'
export const PDA_AUTHORITY = 'lastleg:pda:operate'
export const session = reactive({
- currentUser: null, token: '', domesticToken: '', refreshToken: '', tenantId: '', apiBase: OVERSEAS_API_ROOT,
+ currentUser: null, token: '', refreshToken: '', tenantId: '', apiBase: API_ROOT,
authorization: { roles: [], authorities: [] }
})
@@ -47,7 +49,7 @@ function includesClaim(values, expected) {
}
function requireActiveClaims(claims, label) {
- if (claims.exp && Number(claims.exp) * 1000 <= Date.now()) throw new Error(`${label}已过期`)
+ if (!Number.isFinite(Number(claims.exp)) || Number(claims.exp) * 1000 <= Date.now()) throw new Error(`${label}已过期`)
if (claims.app_code !== 'PLATFORM' || claimId(claims, 'tenant_id') !== '0') {
throw new Error('当前账号不属于平台仓库作业端')
}
@@ -67,25 +69,20 @@ function requireAuthorizationSnapshot(authorization) {
return { roles, authorities }
}
-export function validatePdaTokenPair(accessToken, overseasAccessToken, authorization) {
- const domestic = decodeAccessToken(accessToken)
- const overseas = decodeAccessToken(overseasAccessToken)
- requireActiveClaims(domestic, '登录状态')
- requireActiveClaims(overseas, '海外作业令牌')
- for (const claim of ['user_id', 'membership_id', 'tenant_id', 'app_code', 'overseas_warehouse_id']) {
- if (claimId(domestic, claim) !== claimId(overseas, claim)) throw new Error('国内与海外令牌作业主体不一致')
- }
- const warehouseId = claimId(overseas, 'overseas_warehouse_id')
+export function validatePdaToken(accessToken, authorization) {
+ const claims = decodeAccessToken(accessToken)
+ requireActiveClaims(claims, '登录状态')
+ const warehouseId = claimId(claims, 'overseas_warehouse_id')
if (!/^\d+$/.test(warehouseId) || warehouseId === '0') throw new Error('海外PDA账号未绑定有效作业仓库')
- return { domestic, overseas, warehouseId, authorization: requireAuthorizationSnapshot(authorization) }
+ return { claims, warehouseId, authorization: requireAuthorizationSnapshot(authorization) }
}
function userFromClaims(pair, saved = {}) {
const savedWarehouseId = String(saved?.warehouseId || '')
const sameWarehouse = savedWarehouseId === pair.warehouseId
return {
- id: claimId(pair.domestic, 'user_id'),
- name: pair.domestic.username || '仓库作业员',
+ id: claimId(pair.claims, 'user_id'),
+ name: pair.claims.username || '仓库作业员',
role: PDA_ROLE,
roles: [...pair.authorization.roles],
warehouse: sameWarehouse && saved.warehouseName ? saved.warehouseName : '',
@@ -102,9 +99,9 @@ function storageAvailable() {
function persistSession() {
if (!storageAvailable()) return
uni.setStorageSync(SESSION_KEY, {
+ version: 2,
currentUser: session.currentUser,
token: session.token,
- domesticToken: session.domesticToken,
refreshToken: session.refreshToken,
tenantId: session.tenantId,
apiBase: session.apiBase,
@@ -113,13 +110,13 @@ function persistSession() {
}
export function signIn(account, warehouse, options = {}) {
+ sessionRevision += 1
session.currentUser = { id: String(account.id), name: account.name, role: account.role, roles: account.roles || [], warehouse,
warehouseId: account.warehouseId || null, warehouseCode: account.warehouseCode || '', warehouseName: account.warehouseName || '' }
session.token = options.token || account.token || ''
- session.domesticToken = options.domesticToken || account.domesticToken || ''
session.refreshToken = options.refreshToken || account.refreshToken || ''
session.tenantId = options.tenantId || account.tenantId || ''
- session.apiBase = options.apiBase || account.apiBase || OVERSEAS_API_ROOT
+ session.apiBase = options.apiBase || account.apiBase || API_ROOT
session.authorization = {
roles: [...(options.authorization?.roles || [])],
authorities: [...(options.authorization?.authorities || [])]
@@ -130,28 +127,33 @@ export function signIn(account, warehouse, options = {}) {
export function signInWithTokens(tokens) {
if (!tokens?.accessToken) throw new Error('登录响应缺少 accessToken')
- if (!tokens?.overseasAccessToken) throw new Error('登录响应缺少 overseasAccessToken')
- const pair = validatePdaTokenPair(tokens.accessToken, tokens.overseasAccessToken, tokens)
+ const pair = validatePdaToken(tokens.accessToken, tokens)
const user = userFromClaims(pair)
return signIn(user, user.warehouse, {
- token: tokens.overseasAccessToken,
- domesticToken: tokens.accessToken,
+ token: tokens.accessToken,
refreshToken: tokens.refreshToken || '',
- tenantId: claimId(pair.domestic, 'tenant_id'),
+ tenantId: claimId(pair.claims, 'tenant_id'),
authorization: pair.authorization
})
}
export function replaceTokens(tokens) {
- if (!tokens?.accessToken || !tokens?.overseasAccessToken) throw new Error('刷新响应缺少双区域访问令牌')
- const pair = validatePdaTokenPair(tokens.accessToken, tokens.overseasAccessToken, tokens)
+ if (!tokens?.accessToken) throw new Error('刷新响应缺少访问令牌')
+ const pair = validatePdaToken(tokens.accessToken, tokens)
+ if (session.currentUser) {
+ const previousClaims = decodeAccessToken(session.token)
+ const identityChanged = ['user_id', 'membership_id', 'tenant_id', 'app_code', 'overseas_warehouse_id']
+ .some((name) => claimId(previousClaims, name) !== claimId(pair.claims, name))
+ if (identityChanged || String(session.currentUser.id) !== claimId(pair.claims, 'user_id') || String(session.currentUser.warehouseId) !== pair.warehouseId) {
+ throw new Error('作业主体或绑定仓库已变更,请重新登录')
+ }
+ }
const user = userFromClaims(pair, session.currentUser)
const nextRefreshToken = tokens.refreshToken || session.refreshToken
// Validate the complete replacement before mutating reactive or persisted session state.
- session.domesticToken = tokens.accessToken
- session.token = tokens.overseasAccessToken
+ session.token = tokens.accessToken
session.refreshToken = nextRefreshToken
- session.tenantId = claimId(pair.domestic, 'tenant_id')
+ session.tenantId = claimId(pair.claims, 'tenant_id')
session.currentUser = user
session.authorization = pair.authorization
persistSession()
@@ -174,16 +176,18 @@ export function applyWarehouseProfile(profile) {
export function restoreSession() {
if (!storageAvailable()) return false
const saved = uni.getStorageSync(SESSION_KEY)
- if (!saved?.token || !saved?.domesticToken || !saved?.refreshToken || !saved?.currentUser) {
+ if (!saved?.token || !saved?.refreshToken || !saved?.currentUser || saved.version !== 2) {
signOut()
return false
}
try {
- const pair = validatePdaTokenPair(saved.domesticToken, saved.token, saved.authorization)
+ const pair = validatePdaToken(saved.token, saved.authorization)
+ if (String(saved.currentUser.id) !== claimId(pair.claims, 'user_id') || String(saved.currentUser.warehouseId) !== pair.warehouseId) throw new Error('缓存作业主体不一致')
Object.assign(session, saved, {
- currentUser: userFromClaims(pair, saved.currentUser), tenantId: claimId(pair.domestic, 'tenant_id'),
- apiBase: OVERSEAS_API_ROOT, authorization: pair.authorization
+ currentUser: userFromClaims(pair, saved.currentUser), tenantId: claimId(pair.claims, 'tenant_id'),
+ apiBase: API_ROOT, authorization: pair.authorization
})
+ sessionRevision += 1
return true
} catch (_) {
signOut()
@@ -192,12 +196,12 @@ export function restoreSession() {
}
export function signOut() {
+ sessionRevision += 1
session.currentUser = null
session.token = ''
- session.domesticToken = ''
session.refreshToken = ''
session.tenantId = ''
- session.apiBase = OVERSEAS_API_ROOT
+ session.apiBase = API_ROOT
session.authorization = { roles: [], authorities: [] }
if (typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(SESSION_KEY)
resetReceivingSession()
diff --git a/src/stores/warehouse-work.js b/src/stores/warehouse-work.js
index 9c3349c..f980a06 100644
--- a/src/stores/warehouse-work.js
+++ b/src/stores/warehouse-work.js
@@ -6,8 +6,9 @@ const blankState = () => ({
stocktake: { taskNo: '', scopeType: 'LOCATION', scopeCode: '', reason: '', pendingKeys: {} },
move: { sourcePalletCode: '', targetLocationCode: '', reason: '', pendingKeys: {} },
relabel: { currentNo: '', oldLabel: '', newLabel: '', action: 'RELABEL', reason: '', remark: '', photoFileIds: [], photoNames: [], pendingKeys: {} },
- returnReceipt: { entryMode: 'SKU', lookupCode: '', currentReturnNo: '', startedBy: 'SKU', trackingNo: '', palletCode: '', quantities: {}, pendingKeys: {} },
+ returnReceipt: { pendingBatch: null, entryMode: 'SKU', lookupCode: '', currentReturnNo: '', startedBy: 'SKU', trackingNo: '', palletCode: '', quantities: {}, boxCounts: {}, pendingKeys: {} },
returnPacking: { searchCode: '', selectedOrders: [], quantities: {}, length: '', width: '', height: '', weight: '', pendingKeys: {} },
+ returnPackingPlan: { pendingKeys: {} },
warehouseTransfer: { currentNo: '', pendingKeys: {} },
query: { inventoryCode: '', locationCode: '', palletCode: '' }
})
@@ -40,6 +41,7 @@ export function restoreWarehouseWork(scopeKey) {
stocktake: domain(saved.stocktake, fresh.stocktake), move: domain(saved.move, fresh.move),
relabel: domain(saved.relabel, fresh.relabel), returnReceipt: domain(saved.returnReceipt, fresh.returnReceipt),
returnPacking: domain(saved.returnPacking, fresh.returnPacking),
+ returnPackingPlan: domain(saved.returnPackingPlan, fresh.returnPackingPlan),
warehouseTransfer: domain(saved.warehouseTransfer, fresh.warehouseTransfer),
query: { ...fresh.query, ...(saved.query || {}) }
})
diff --git a/tests/fulfillment-workflows.test.mjs b/tests/fulfillment-workflows.test.mjs
index 068f965..1d232d0 100644
--- a/tests/fulfillment-workflows.test.mjs
+++ b/tests/fulfillment-workflows.test.mjs
@@ -6,26 +6,74 @@ import { fulfillment, fulfillmentIdempotencyKey, releaseFulfillmentKey, resetFul
import {
completeDispatchPicking, completeOutboundTask, departDispatch, getDispatchTask, getOutboundTask,
listDispatchTasks, listOutboundTasks, normalizeDispatchTask, normalizeOutboundTask,
- pickDispatchItem, pickOutboundPallet, pickOutboundTask, resolveOutboundTask, outboundPickPayload
+ pickDispatchItem, pickOutboundPallet, pickOutboundTask, resolveOutboundTask, outboundPickPayload, tripDepartureRequest
} from '../src/api/fulfillment.js'
import { session } from '../src/stores/session.js'
import { toDispatchError } from '../src/utils/dispatch-error.js'
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
+test('trip departure preserves version zero and rejects incomplete trip identity', () => {
+ const task = normalizeDispatchTask({ tripNo: 'TR-1', tripRecordVersion: 0, tripDispatchCount: 2, items: [] })
+ assert.equal(task.tripRecordVersion, '0')
+ assert.equal(task.tripDispatchCount, 2)
+ assert.deepEqual(tripDepartureRequest(task, 'departure-key'), { recordVersion: '0', idempotencyKey: 'departure-key', remark: '海外 PDA 整车核平后发车' })
+ for (const invalid of [{}, { tripNo: 'TR-1' }, { tripNo: 'TR-1', tripRecordVersion: '-1' }]) assert.throws(() => tripDepartureRequest(invalid, 'key'), /车次信息不完整/)
+})
+
+test('whole trip departure UI identifies all members and refreshes server state before releasing retry key', async () => {
+ const page = await read('../src/pages/dispatch/dispatch.vue')
+ assert.match(page, /current\.tripNo[\s\S]*current\.tripDispatchCount/)
+ assert.match(page, /确认整车发车/)
+ assert.match(page, /tripDepartureRequest\(this.current, key.value\)/)
+ assert.doesNotMatch(page, /status: result\?\.status \|\| 'DEPARTED'/)
+ assert.match(page, /result\.dispatches\?\.some[\s\S]*await getDispatchTask[\s\S]*releaseFulfillmentKey/)
+})
+
test('outbound partial pick submits the selected carton and distinguishes shared-pallet items', async () => {
const first = outboundPickPayload({ outboundPalletItemId: '11' }, { inventoryPositionId: '21', cartonMark: 'BOX-A' }, '1')
assert.deepEqual(first, { outboundPalletItemId: '11', inventoryPositionId: '21', packages: 1, cartonMarks: ['BOX-A'] })
assert.notDeepEqual(first, outboundPickPayload({ outboundPalletItemId: '12' }, { inventoryPositionId: '22', cartonMark: 'BOX-B' }, 1))
assert.deepEqual(outboundPickPayload({ outboundPalletItemId: '13' }, { inventoryPositionId: '23' }, 2).cartonMarks, [])
const page = await read('../src/pages/outbound/outbound.vue')
+ assert.match(page, /listOutboundTasks\('PENDING'\)/)
assert.match(page, /:key="item.outboundPalletItemId"/)
- assert.match(page, /outboundPickPayload\(this.selectedItem, this.selectedPosition, this.packages\)/)
+ assert.match(page, /outboundPickPayload\(this.selectedItem, this.selectedPosition, this.packages, this.selectedScanCode\)/)
assert.match(page, /pickOutboundTask\(this.current.taskNo, \{ \.\.\.payload, idempotencyKey: key.value \}\)/)
assert.match(page, /pickOutboundPallet\(this.current.taskNo, code, \{ idempotencyKey: key.value \}\)/)
assert.match(page, /item\.palletNo === code/)
})
+test('return outbound preserves its task type and only permits pallet scans for pallet items', async () => {
+ const task = normalizeOutboundTask({ task: { taskNo: 'PDA-1', outboundType: 'RETURN_OUTBOUND' }, items: [] })
+ assert.equal(task.outboundType, 'RETURN_OUTBOUND')
+ const page = await read('../src/pages/outbound/outbound.vue')
+ assert.match(page, /this\.current\?\.outboundType === 'RETURN_OUTBOUND' && item\.palletNo/)
+ assert.match(page, /整托出库请扫描托盘码/)
+ const item = { outboundPalletItemId: '12', skuCode: 'SKU-A' }
+ const position = { inventoryPositionId: '', cartonMark: 'RC-1' }
+ assert.deepEqual(outboundPickPayload(item, position, 1, 'SKU-A'), {
+ outboundPalletItemId: '12', inventoryPositionId: null, packages: 1, cartonMarks: [], skuCode: 'SKU-A'
+ })
+ assert.deepEqual(outboundPickPayload(item, position, 1, 'RC-1'), {
+ outboundPalletItemId: '12', inventoryPositionId: null, packages: 1, cartonMarks: ['RC-1']
+ })
+})
+
+test('real return-carton DTO remains scannable without inventing a warehouse inventory position or pallet', () => {
+ const task = normalizeOutboundTask({ task: { taskNo: 'PDA-RETURN', outboundType: 'RETURN_OUTBOUND', status: 'PENDING' },
+ items: [{ outboundPalletItemId: '12', palletNo: null, packageCount: 1, pickedPackageCount: 0,
+ positions: [{ inventoryPositionId: null, palletNo: null, cartonMark: 'RC-REAL', inboundCode: 'RT-REAL', availablePackages: 1, pickedPackages: 0 }] }] })
+ const item = task.items[0], position = item.positions[0]
+ assert.equal(item.palletNo, '')
+ assert.equal(position.cartonMark, 'RC-REAL')
+ assert.equal(position.availablePackages, 1)
+ assert.deepEqual(outboundPickPayload(item, position, 1, 'RC-REAL'), {
+ outboundPalletItemId: '12', inventoryPositionId: null, packages: 1, cartonMarks: ['RC-REAL']
+ })
+ assert.equal(outboundPickPayload(item, { ...position, inventoryPositionId: '2099999999999999999' }, 1).inventoryPositionId, '2099999999999999999')
+})
+
function storageMock() {
const values = new Map()
globalThis.uni = {
@@ -55,7 +103,7 @@ test('fulfillment API uses dedicated PDA routes and never sends customer or ware
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'
+ session.apiBase = '/api'; session.token = 'overseas-token'
globalThis.uni = {
request(options) {
calls.push({ method: options.method, url: options.url, data: options.data, authorization: options.header.Authorization })
@@ -72,7 +120,7 @@ test('fulfillment API calls the frozen list detail pick complete and depart rout
await listDispatchTasks('PICKING'); await getDispatchTask('DSP/1')
await pickDispatchItem('DSP/1', '11', { positionId: '22', packages: 1, productQuantity: 0, idempotencyKey: 'pick-key' })
await completeDispatchPicking('DSP/1', { idempotencyKey: 'complete-key' })
- await departDispatch('DSP/1', { idempotencyKey: 'depart-key' })
+ await departDispatch('DSP/1', { recordVersion: '4', idempotencyKey: 'depart-key' })
await listOutboundTasks('PENDING'); await getOutboundTask('OUT/TASK/1')
await resolveOutboundTask('OUT/1')
await pickOutboundTask('OUT/TASK/1', { outboundPalletItemId: '33', inventoryPositionId: '44', packages: 1, idempotencyKey: 'out-pick-key' })
@@ -80,6 +128,7 @@ test('fulfillment API calls the frozen list detail pick complete and depart rout
await completeOutboundTask('OUT/TASK/1', { idempotencyKey: 'out-complete-key' })
assert.equal(calls.length, 11)
+ assert.deepEqual(calls.find(call => call.url.endsWith('/depart')).data, { recordVersion: '4', idempotencyKey: 'depart-key' })
assert.ok(calls.some((call) => call.url.endsWith('/dispatches?status=PICKING')))
assert.ok(calls.some((call) => call.url.includes('/dispatches/DSP%2F1/items/11/pick')))
assert.ok(calls.some((call) => call.url.endsWith('/dispatches/DSP%2F1/depart')))
@@ -159,8 +208,8 @@ test('pages expose loading empty error offline and permission diagnostics with g
assert.doesNotMatch(dispatch, /departedNo/)
assert.match(outbound, /completeOutboundTask/)
assert.match(outbound, /resolveOutboundTask[\s\S]*workflow === 'DISPATCH'[\s\S]*pages\/dispatch\/dispatch/)
- assert.match(outbound, /一件代发扫描托盘码可整托出库/)
- assert.match(outbound, /扫描 DP 托盘码,将一次拣取该托全部未拣箱件/)
+ assert.match(outbound, /整托扫描托盘码,散箱扫描箱唛/)
+ assert.match(outbound, /退货整托出库扫托盘码,散箱出库扫箱唛/)
assert.match(outbound, /if \(!this\.current\) return this\.openScannedTask\(code\)/)
assert.doesNotMatch(outbound, /if \(!this\.current\)[^\n]*openTask\(code\)/)
assert.match(outbound, /this\.remainingPackages === 0/)
@@ -210,3 +259,12 @@ test('receiving conflict recovery keeps request and trace evidence visible on na
assert.match(operation, /openContainer\(candidate\)[\s\S]*recoverCurrentAfterConflict\(error, true\)/)
assert.match(operation, /\.error-card text\{overflow-wrap:anywhere;word-break:break-word\}/)
})
+
+test('dispatch review gate preserves the server decision without treating a string or missing value as approval', async () => {
+ assert.equal(normalizeDispatchTask({ canPick: true }).canPick, true)
+ for (const canPick of [false, undefined, 'true']) assert.equal(normalizeDispatchTask({ canPick }).canPick, false)
+ assert.equal(normalizeDispatchTask({ canPick: false, pickBlockReason: '调度审核已打回:预约待核对' }).pickBlockReason, '调度审核已打回:预约待核对')
+ const source = await read('../src/pages/dispatch/dispatch.vue')
+ assert.match(source, /canPick\(\) \{ return this.current\?\.canPick === true/)
+ assert.match(source, /canComplete\(\) \{ return this.current\?\.canPick === true/)
+})
diff --git a/tests/h5-deployment.test.mjs b/tests/h5-deployment.test.mjs
new file mode 100644
index 0000000..b7a411b
--- /dev/null
+++ b/tests/h5-deployment.test.mjs
@@ -0,0 +1,86 @@
+import test from 'node:test'
+import { runInNewContext } from 'node:vm'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+const read = path => readFile(new URL(path, import.meta.url), 'utf8')
+test('H5 image copies only compiled assets and static nginx configuration', async () => {
+ const dockerfile = await read('../deploy/Dockerfile.h5')
+ const copies = dockerfile.split(/\r?\n/).filter(line => line.startsWith('COPY '))
+ assert.deepEqual(copies, ['COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf', 'COPY dist/build/h5/ /srv/pda-h5/'])
+ assert.match(dockerfile, /ARG NGINX_BASE_IMAGE=/)
+ assert.match(dockerfile, /FROM \$\{NGINX_BASE_IMAGE\}/)
+ assert.doesNotMatch(dockerfile, /RUN |npm|node_modules|COPY \. /)
+ const ignore = await read('../.dockerignore')
+ assert.ok(ignore.split(/\r?\n/).includes('**'))
+ assert.ok(ignore.includes('!dist/build/h5/**'))
+ assert.doesNotMatch(ignore, /!\.env|!node_modules|!src/)
+})
+test('static server serves the domain root and returns genuine missing-resource errors', async () => {
+ const nginx = await read('../deploy/nginx.conf')
+ assert.match(nginx, /listen 80;/)
+ assert.ok(nginx.includes('location /'))
+ assert.match(nginx, /root \/srv\/pda-h5;/)
+ assert.match(nginx, /try_files \$uri \$uri\/ =404;/)
+ assert.match(nginx, /Cache-Control "no-store" always/)
+ assert.doesNotMatch(nginx, /pda-domestic|pda-overseas/)
+ assert.match(nginx, /location = \/healthz/)
+ assert.doesNotMatch(nginx, /proxy_pass|gateway-api|try_files[^;]*index\.html/)
+})
+test('TEST H5 deployment has a required image tag and internal-only dual health checks', async () => {
+ const compose = await read('../deploy/docker-compose.h5.test.yml')
+ assert.ok(compose.includes('${IMAGE_TAG:?IMAGE_TAG is required}'))
+ assert.match(compose, /context: \.\./)
+ assert.match(compose, /dockerfile: deploy\/Dockerfile\.h5/)
+ assert.match(compose, /container_name: pda-overseas-h5-test/)
+ assert.match(compose, /aliases:\s*\n\s*- pda-overseas-h5-test/)
+ assert.match(compose, /external: true/)
+ assert.match(compose, /name: firstleg-test-network/)
+ assert.match(compose, /expose:\s*\n\s*- "80"/)
+ assert.ok(compose.includes('http://127.0.0.1/healthz && wget -qO /dev/null http://127.0.0.1/'))
+ assert.doesNotMatch(compose, /\bports:|volumes:|API_UPSTREAM|privileged:|pid: host/)
+})
+
+test('only H5 production overrides the toolchain asset base', async () => {
+ const source = (await read('../vite.config.js'))
+ .replace(/^import .*$/gm, '')
+ .replace('export default ', '')
+ for (const platform of ['h5', 'app', 'app-plus', 'app-harmony', undefined]) {
+ for (const mode of ['production', 'development']) {
+ const factory = runInNewContext(source, {
+ defineConfig: value => value,
+ loadEnv: () => ({}),
+ uni: () => ({}),
+ process: { env: { UNI_PLATFORM: platform }, cwd: () => '/project' }
+ })
+ assert.equal(factory({ mode }).base,
+ platform === 'h5' && mode === 'production' ? '/' : undefined,
+ `${platform}:${mode}`)
+ }
+ }
+})
+
+test('H5 proxy uses the shared gateway prefix and preserves API suffixes', async () => {
+ const source = (await read('../vite.config.js')).replace(/^import .*$/gm, '').replace('export default ', '')
+ const factory = runInNewContext(source, {
+ defineConfig: value => value, loadEnv: () => ({ API_PROXY_TARGET: 'https://gateway.example.com' }),
+ uni: () => ({}), process: { env: { UNI_PLATFORM: 'h5' }, cwd: () => '/project' }
+ })
+ const proxy = factory({ mode: 'development' }).server.proxy
+ assert.deepEqual(Object.keys(proxy), ['/gateway-api'])
+ assert.equal(proxy['/gateway-api'].target, 'https://gateway.example.com')
+ for (const suffix of ['/auth/login', '/pda/v1/last-leg/photos/1/download?preview=1']) {
+ assert.equal(proxy['/gateway-api'].rewrite(`/gateway-api${suffix}`), `/api${suffix}`)
+ }
+})
+
+test('gray H5 runs on an isolated network with same-origin Gateway API', async () => {
+ const compose = await read('../deploy/docker-compose.h5.production.yml')
+ const nginx = await read('../deploy/nginx.production.conf.template')
+ const docker = await read('../deploy/Dockerfile.h5.production')
+ assert.match(compose, /name: firstleg-production-network/)
+ assert.match(compose, /127\.0\.0\.1:/)
+ assert.doesNotMatch(compose, /test-network|gateway-overseas/)
+ assert.match(nginx, /location \/gateway-api\//)
+ assert.ok(nginx.includes('proxy_pass ${API_UPSTREAM}/api/;'))
+ assert.match(docker, /COPY deploy\/nginx.production.conf.template \/etc\/nginx\/templates\/default.conf.template/)
+})
diff --git a/tests/outbound-lifecycle.test.mjs b/tests/outbound-lifecycle.test.mjs
new file mode 100644
index 0000000..a77f491
--- /dev/null
+++ b/tests/outbound-lifecycle.test.mjs
@@ -0,0 +1,83 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+
+const source = readFileSync(new URL('../src/pages/outbound/outbound.vue', import.meta.url), 'utf8').match(/