Merge pull request '发布当前测试版本至临时灰度环境' (#5) from release/test into main
This commit was merged in pull request #5.
This commit is contained in:
@@ -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
|
||||
+2
-7
@@ -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 中填写真实开发账号。
|
||||
|
||||
@@ -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 联调与真机扫码验收。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
+19
-3
@@ -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)
|
||||
}
|
||||
|
||||
+51
-3
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
+43
-16
@@ -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: {} }, '签收照片下载失败')
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<view class="handover-card">
|
||||
<view class="handover-heading"><text class="handover-title">发车与交接记录</text><button :disabled="loading" @tap="load">刷新</button></view>
|
||||
<text v-if="loading">正在读取当前运输资源…</text>
|
||||
<view v-else-if="error" class="handover-error" role="alert">
|
||||
<text>{{ error.message }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text>
|
||||
<button @tap="load">重新读取</button>
|
||||
</view>
|
||||
<template v-else-if="status">
|
||||
<text class="handover-line">车次 {{ status.tripNo }}</text>
|
||||
<text class="handover-line">原始发车:{{ status.originalDeparture.vehiclePlate || '—' }} · {{ status.originalDeparture.driverName || '—' }}</text>
|
||||
<view v-if="status.latestHandover" class="handover-record">
|
||||
<text class="handover-line">最近交接 {{ status.latestHandover.handoverNo || '—' }}</text>
|
||||
<text class="handover-line">{{ time(status.latestHandover.handedOverAt) }} · {{ status.latestHandover.location || '—' }}</text>
|
||||
<text class="handover-line">{{ status.latestHandover.previous.vehiclePlate || '—' }} → {{ status.latestHandover.replacement.vehiclePlate || '—' }}</text>
|
||||
<text class="handover-line">{{ status.latestHandover.previous.driverName || '—' }} → {{ status.latestHandover.replacement.driverName || '—' }}</text>
|
||||
</view>
|
||||
<text v-else class="handover-line">本排车暂无在途交接记录。</text>
|
||||
<view v-for="delivery in status.deliveries" :key="delivery.deliveryNo" class="handover-delivery">
|
||||
<text class="handover-line">{{ delivery.deliveryNo }} · 原实发 {{ delivery.packages }} 箱 · 剩余 {{ delivery.remainingPackages }} 箱 · {{ label(delivery) }}</text>
|
||||
<text class="handover-line">{{ delivery.terminalAt ? '完成时承运' : '当前承运' }}:{{ delivery.transport.vehiclePlate || '—' }} · {{ delivery.transport.driverName || '—' }}</text>
|
||||
<text v-if="delivery.transport.driverPhone" class="handover-line">联系电话 {{ delivery.transport.driverPhone }}</text>
|
||||
</view>
|
||||
<text class="handover-note">交接由后台确认。本页只读,不重复发车。</text>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
<script>
|
||||
import { getDispatchHandoverStatus } from '@/api/trip-handover.js'
|
||||
import { toDispatchError } from '@/utils/dispatch-error.js'
|
||||
export default {
|
||||
props: { dispatchNo: { type: String, required: true }, recordVersion: { type: [String, Number], default: null } },
|
||||
data() { return { status: null, error: null, loading: false, generation: 0 } },
|
||||
computed: { scopeKey() { return `${this.dispatchNo}:${this.recordVersion ?? ''}` } },
|
||||
watch: { scopeKey: { immediate: true, handler() { this.status = null; this.load() } } },
|
||||
beforeUnmount() { this.generation++ },
|
||||
methods: {
|
||||
async load() {
|
||||
const generation = ++this.generation
|
||||
this.error = null; this.status = null
|
||||
if (!this.dispatchNo) { this.loading = false; return }
|
||||
this.loading = true
|
||||
try { const result = await getDispatchHandoverStatus(this.dispatchNo); if (generation === this.generation) this.status = result }
|
||||
catch (cause) { if (generation === this.generation) this.error = toDispatchError(cause, '交接记录读取失败') }
|
||||
finally { if (generation === this.generation) this.loading = false }
|
||||
},
|
||||
time(value) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' },
|
||||
label(delivery) {
|
||||
if (delivery.terminalAt) return delivery.status === 'RETURNED' ? '运输已完成' : '已签收'
|
||||
return { WAIT_DELIVERY: '派送中', RETURN_PENDING: '待退回' }[delivery.status] || '待核对'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.handover-card{padding:28rpx;background:#fff;border-radius:20rpx;margin-top:20rpx;color:#334155;font-size:26rpx}
|
||||
.handover-heading{display:flex;align-items:center;justify-content:space-between;gap:16rpx}.handover-title{font-size:30rpx;font-weight:600}
|
||||
.handover-heading button{margin:0;font-size:26rpx}.handover-line{display:block;margin-top:12rpx;overflow-wrap:anywhere}
|
||||
.handover-record,.handover-delivery{padding-top:16rpx;margin-top:16rpx;border-top:1px solid #e2e8f0}.handover-note{display:block;margin-top:20rpx;color:#64748b}
|
||||
.handover-error{color:#b91c1c}.handover-error text{display:block;margin-top:12rpx}
|
||||
</style>
|
||||
+10
-29
@@ -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 || '') : ''
|
||||
})
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -80,6 +80,9 @@
|
||||
},
|
||||
"vueVersion" : "3",
|
||||
"h5" : {
|
||||
"router" : {
|
||||
"mode" : "hash"
|
||||
},
|
||||
"devServer" : {
|
||||
"port" : 5176
|
||||
}
|
||||
|
||||
@@ -26,21 +26,23 @@
|
||||
</view>
|
||||
|
||||
<template v-if="!loading && !current">
|
||||
<view class="list-toolbar"><view><text class="list-title">待处理排车任务</text><text class="list-count">{{ tasks.length }} 个任务</text></view><button :disabled="loading" @tap="loadTasks">刷新</button></view>
|
||||
<view class="list-toolbar"><view><text class="list-title">排车任务</text><text class="list-count">{{ tasks.length }} 个任务</text></view><button :disabled="loading" @tap="loadTasks">刷新</button></view>
|
||||
<view v-if="tasks.length" class="task-list">
|
||||
<button v-for="task in tasks" :key="task.dispatchNo" class="task-card" @tap="openTask(task.dispatchNo)">
|
||||
<view class="task-head"><view class="task-code-row"><text class="business-code">{{ task.dispatchNo || '—' }}</text><text v-if="task.dispatchNo === recentTaskNo" class="recent-badge">上次作业</text></view><text class="status-badge">{{ statusLabel(task.status) }}</text></view>
|
||||
<text class="task-meta">任务号 {{ task.taskNo || '—' }} · 车牌 {{ task.vehiclePlate || '—' }}</text>
|
||||
<text class="task-enter">查看并开始拣货 ›</text>
|
||||
<text class="task-meta">任务号 {{ task.taskNo || '—' }} · {{ task.status === 'DEPARTED' ? '原发车车牌' : '车牌' }} {{ task.vehiclePlate || '—' }}</text>
|
||||
<text class="task-enter">查看任务详情 ›</text>
|
||||
</button>
|
||||
</view>
|
||||
<view v-else-if="!error" class="fulfillment-state"><text class="state-title">暂无排车拣货任务</text><text>可刷新列表,或直接扫描排车单号恢复作业。</text></view>
|
||||
</template>
|
||||
|
||||
<template v-if="current">
|
||||
<DispatchHandoverStatus v-if="departed" :dispatch-no="current.dispatchNo" :record-version="current.tripRecordVersion" />
|
||||
<view class="fulfillment-card summary-card">
|
||||
<view class="task-head"><text class="business-code">{{ current.dispatchNo || '—' }}</text><text class="status-badge">{{ statusLabel(current.status) }}</text></view>
|
||||
<text class="task-meta">任务号 {{ current.taskNo || '—' }} · 车牌 {{ current.vehiclePlate || '—' }}</text>
|
||||
<text class="task-meta">任务号 {{ current.taskNo || '—' }} · {{ departed ? '原发车车牌' : '车牌' }} {{ current.vehiclePlate || '—' }}</text>
|
||||
<text v-if="!current.canPick && !pickingCompleted" class="gate-warning">{{ current.pickBlockReason || '请先通过调度审核再拣货' }}</text>
|
||||
<view class="metrics"><view><text class="metric-value">{{ plannedPackages }}</text><text>计划箱数</text></view><view><text class="metric-value success">{{ pickedPackages }}</text><text>已拣箱数</text></view><view><text class="metric-value warning">{{ remainingPackages }}</text><text>待拣箱数</text></view></view>
|
||||
</view>
|
||||
|
||||
@@ -67,12 +69,14 @@
|
||||
</view>
|
||||
|
||||
<view v-if="pickingCompleted" class="fulfillment-card gate-card">
|
||||
<text class="card-title">发车门禁</text>
|
||||
<text class="card-title">整车发车</text>
|
||||
<text v-if="current.tripNo" class="task-meta">车次 {{ current.tripNo }} · 共 {{ current.tripDispatchCount }} 张排车单</text>
|
||||
<text v-if="current.driverWarning && !departed" class="gate-warning">{{ current.driverWarning }}</text>
|
||||
<text v-if="!allPicked" class="gate-warning">服务端仍显示 {{ remainingPackages }} 箱未拣,不允许发车。</text>
|
||||
<text v-else-if="departed" class="gate-success">该排车任务已执行发车,不可重复提交。</text>
|
||||
<text v-else-if="!current.canDepart" class="gate-warning">{{ current.departBlockReason || '当前排车暂不满足发车条件,请刷新后重试。' }}</text>
|
||||
<text v-else class="gate-note">拣货已完成且全部箱数核平,可以发车。</text>
|
||||
<button v-if="!departed" class="depart-button" :disabled="!canDepart || busy" @tap="depart">{{ busyAction === 'depart' ? '发车中…' : '确认发车' }}</button>
|
||||
<text v-else class="gate-note">本车所有排车单已就绪,确认后将一并发车。</text>
|
||||
<button v-if="!departed" class="depart-button" :disabled="!canDepart || busy" @tap="depart">{{ busyAction === 'depart' ? '发车中…' : '确认整车发车' }}</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -85,7 +89,8 @@
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { completeDispatchPicking, departDispatch, getDispatchTask, listDispatchTasks, pickDispatchItem } from '@/api/fulfillment.js'
|
||||
import DispatchHandoverStatus from '@/components/DispatchHandoverStatus.vue'
|
||||
import { completeDispatchPicking, departDispatch, getDispatchTask, listDispatchTasks, pickDispatchItem, tripDepartureRequest } from '@/api/fulfillment.js'
|
||||
import { fulfillment, fulfillmentIdempotencyKey, releaseFulfillmentKey, restoreFulfillmentDraft, updateFulfillmentDraft } from '@/stores/fulfillment.js'
|
||||
import { requireSession, session } from '@/stores/session.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
@@ -95,7 +100,7 @@
|
||||
const finishedStatuses = ['COMPLETED', 'PICKING_COMPLETED', 'READY_TO_DEPART', 'DEPARTED']
|
||||
|
||||
export default {
|
||||
components: { PortalHeader, ScanTargetInput },
|
||||
components: { PortalHeader, ScanTargetInput, DispatchHandoverStatus },
|
||||
data() { return { user: session.currentUser, tasks: [], current: null, scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '', bootstrapped: false, resumeOnEntry: false } },
|
||||
computed: {
|
||||
recentTaskNo() { return fulfillment.dispatch.currentNo || '' },
|
||||
@@ -109,9 +114,9 @@
|
||||
allPicked() { return this.plannedPackages > 0 && this.remainingPackages === 0 },
|
||||
pickingCompleted() { return finishedStatuses.includes(this.current?.status) },
|
||||
departed() { return this.current?.status === 'DEPARTED' },
|
||||
canPick() { return this.selectedPosition && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining && !this.pickingCompleted },
|
||||
canPick() { return this.current?.canPick === true && this.selectedPosition && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining && !this.pickingCompleted },
|
||||
canCompleteStage() { return Boolean(this.current) && !this.pickingCompleted },
|
||||
canComplete() { return this.plannedPackages > 0 && this.remainingPackages === 0 },
|
||||
canComplete() { return this.current?.canPick === true && this.plannedPackages > 0 && this.remainingPackages === 0 },
|
||||
canDepart() { return this.pickingCompleted && this.allPicked && this.current?.canDepart === true && !this.departed }
|
||||
},
|
||||
onLoad(options) { this.resumeOnEntry = options?.resume === '1' },
|
||||
@@ -168,8 +173,13 @@
|
||||
},
|
||||
async depart() {
|
||||
if (!this.canDepart) return
|
||||
const key = fulfillmentIdempotencyKey('dispatch', 'depart', { dispatchNo: this.current.dispatchNo })
|
||||
await this.run('执行发车', async () => { const result = await departDispatch(this.current.dispatchNo, { idempotencyKey: key.value, remark: '海外 PDA 拣货核平后发车' }); this.current = { ...this.current, status: result?.status || 'DEPARTED' }; releaseFulfillmentKey('dispatch', key.signature); this.successMessage = '排车已发车'; playSuccessSound() })
|
||||
const key = fulfillmentIdempotencyKey('dispatch', 'depart', { tripNo: this.current.tripNo, recordVersion: this.current.tripRecordVersion })
|
||||
await this.run('执行整车发车', async () => {
|
||||
const result = await departDispatch(this.current.dispatchNo, tripDepartureRequest(this.current, key.value))
|
||||
if (result?.status !== 'IN_TRANSIT' || result.tripNo !== this.current.tripNo || !result.dispatches?.some(item => item.dispatchNo === this.current.dispatchNo)) throw new Error('发车结果未包含当前车次和排车单,请刷新核对')
|
||||
this.current = await getDispatchTask(this.current.dispatchNo)
|
||||
releaseFulfillmentKey('dispatch', key.signature); this.successMessage = `车次 ${result.tripNo} 已整车发车`; playSuccessSound()
|
||||
})
|
||||
},
|
||||
async retry() { if (this.current?.dispatchNo) await this.openTask(this.current.dispatchNo); else if (fulfillment.dispatch.currentNo) await this.openTask(fulfillment.dispatch.currentNo); else await this.loadTasks() },
|
||||
async run(action, work, loading = false) { if (this.busy || this.loading) return; this.error = null; this.successMessage = ''; if (loading) this.loading = true; else { this.busy = true; this.busyAction = action.includes('发车') ? 'depart' : action.includes('完成') ? 'complete' : 'pick' } try { await work() } catch (error) { this.setError(error, `${action}失败`) } finally { this.loading = false; this.busy = false; this.busyAction = '' } },
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<view class="card-head"><view><text class="card-title">国内装柜照片(只读)</text><text class="container-meta">现场对照国内装柜过程与封柜状态</text></view><text class="status-tag status-good">{{ domesticPhotoCount }} / 5</text></view>
|
||||
<view class="domestic-photo-grid">
|
||||
<view v-for="photo in domesticLoadingPhotos" :key="photo.evidenceType" class="domestic-photo-item" @tap="previewDomesticPhoto(photo)">
|
||||
<image v-if="photo.localPath" class="domestic-photo-thumb" :src="photo.localPath" mode="aspectFill" />
|
||||
<image v-if="photo.localPath" class="domestic-photo-thumb" :src="photo.localPath" mode="aspectFill" @error="invalidatePhotoPreview(photo)" />
|
||||
<view v-else class="domestic-photo-placeholder"><uni-load-more v-if="photo.status === 'loading'" status="loading" :show-text="false" /><uni-icons v-else type="image" color="#8090a3" size="22" /><text>{{ photo.status === 'error' ? '加载失败,点按重试' : '正在准备照片' }}</text></view>
|
||||
<text>{{ photo.label }}</text>
|
||||
</view>
|
||||
@@ -58,7 +58,7 @@
|
||||
<view class="photo-grid">
|
||||
<view v-for="(step, index) in photoSteps" :key="step.role" class="photo-role" :class="{ complete: photoSlots[index]?.fileId }">
|
||||
<view class="photo-preview" :class="{ failed: photoSlots[index]?.status === 'error' }" @tap="previewPhoto(index)">
|
||||
<image v-if="photoSlots[index]?.localPath" class="photo-thumb" :src="photoSlots[index].localPath" mode="aspectFill" />
|
||||
<image v-if="photoSlots[index]?.localPath" class="photo-thumb" :src="photoSlots[index].localPath" mode="aspectFill" @error="invalidatePhotoPreview(photoSlots[index])" />
|
||||
<view v-else class="photo-placeholder"><uni-icons type="camera" color="#8090a3" size="23" /><text>未添加</text></view>
|
||||
</view>
|
||||
<view class="photo-role-info">
|
||||
@@ -76,10 +76,10 @@
|
||||
<template v-else-if="phase === 'receive'">
|
||||
<ReceivingSessionSummary :session="current" />
|
||||
<view class="photo-card domestic-photo-card domestic-photo-collapsible">
|
||||
<view class="card-head" @tap="domesticPhotosExpanded = !domesticPhotosExpanded"><view><text class="card-title">国内装柜照片</text><text class="container-meta">已同步 {{ domesticPhotoCount }} / 5,点按{{ domesticPhotosExpanded ? '收起' : '展开' }}对照</text></view><text class="container-detail-link">{{ domesticPhotosExpanded ? '收起' : '查看' }} ›</text></view>
|
||||
<view class="card-head" @tap="toggleDomesticPhotos"><view><text class="card-title">国内装柜照片</text><text class="container-meta">已同步 {{ domesticPhotoCount }} / 5,点按{{ domesticPhotosExpanded ? '收起' : '展开' }}对照</text></view><text class="container-detail-link">{{ domesticPhotosExpanded ? '收起' : '查看' }} ›</text></view>
|
||||
<view v-if="domesticPhotosExpanded" class="domestic-photo-grid">
|
||||
<view v-for="photo in domesticLoadingPhotos" :key="photo.evidenceType" class="domestic-photo-item" @tap.stop="previewDomesticPhoto(photo)">
|
||||
<image v-if="photo.localPath" class="domestic-photo-thumb" :src="photo.localPath" mode="aspectFill" />
|
||||
<image v-if="photo.localPath" class="domestic-photo-thumb" :src="photo.localPath" mode="aspectFill" @error="invalidatePhotoPreview(photo)" />
|
||||
<view v-else class="domestic-photo-placeholder"><uni-load-more v-if="photo.status === 'loading'" status="loading" :show-text="false" /><uni-icons v-else type="image" color="#8090a3" size="22" /><text>{{ photo.status === 'error' ? '加载失败,点按重试' : '正在准备照片' }}</text></view>
|
||||
<text>{{ photo.label }}</text>
|
||||
</view>
|
||||
@@ -110,7 +110,7 @@
|
||||
<button class="primary-button receipt-button" :disabled="!canSubmitReceipt || busy" @tap="submitReceipt">{{ activeInbound.destinationType === 'FBA' ? '保存该托累计箱数' : '确认本箱收货' }}</button>
|
||||
</template>
|
||||
</view>
|
||||
<view v-if="lastReceipt" class="success-card">本次已收 {{ lastReceipt.acceptedPackages }} 箱,{{ lastReceipt.inboundCode }} 剩余 {{ lastReceipt.pendingPackages }} 箱</view>
|
||||
<view v-if="lastReceipt" class="success-card">本次已收 {{ lastReceipt.acceptedPackages }} 箱,整柜剩余 {{ current.pendingPackages }} 箱</view>
|
||||
<view v-if="canFinishOnsite" class="sticky-action one"><button class="primary-button complete-button" :disabled="busy || activeTarget === 'quantity' || scanQueue.length" @tap="completeContainer">{{ allInboundsReconciled ? '完成整柜入库' : '现场收货结束(标记差异)' }}</button></view>
|
||||
</template>
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { assertReceivingWarehouse, isNoCurrentReceiving, isReceivingConflict, RECEIVING_PHOTO_ROLES, toReceivingError } from '@/api/receiving-adapter.js'
|
||||
import { applyWarehouseProfile, session, requireSession } from '@/stores/session.js'
|
||||
import { idempotencyKey, receiving, receivingPhase, receivingProgress, releaseIdempotencyKey, resetReceivingDraft, setCurrentReceiving } from '@/stores/receiving.js'
|
||||
import { idempotencyKey, receiving, receivingPhase, receivingProgress, releaseIdempotencyKey, resetReceivingDraft, restoreReceivingPhoto, setCurrentReceiving } from '@/stores/receiving.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
import { completeCurrentReceiving, downloadCurrentDomesticLoadingPhoto, downloadCurrentUnloadPhoto, getCurrentReceiving, getCurrentWarehouseProfile, getReceivingContainer, listReceivingCandidates, prepareReceivingContainer, receiveCurrentBulk, receiveCurrentCarton, receiveCurrentWholePallet, resolveCurrentCarton, resolveCurrentPallet, signCurrentReceiving, uploadCurrentUnloadPhoto } from '@/api/inbound.js'
|
||||
@@ -167,8 +167,8 @@
|
||||
pageSubtitle() { return { candidates: '选择集装箱后拍摄 3 张照片完成签收', detail: '核对柜号与整柜计划箱数', sign: '完成现场取证后进入收货', receive: '扫描箱码定位入仓码后开始收货', completed: '库存与柜级进度已由服务端核平' }[this.phase] }
|
||||
},
|
||||
onLoad(options) { this.moduleKey = options.module || 'query'; const key = options.op || (this.moduleKey === 'inbound' ? 'receiving' : 'inventoryQuery'); this.operationName = ({ receiving: '拆柜收货', stocktake: '库存盘点', move: '商品移库', pallet: '托盘管理', relabelReceive: '换标列表', relabelExecute: '换标执行', relabelReview: '换标复核', inventoryQuery: '库存查询', locationQuery: '库位查询', palletQuery: '托盘查询' })[key] || '库存查询' },
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.startScanner(); if (this.inboundMode && (!this.bootstrapped || this.phase === 'candidates')) this.bootstrap() },
|
||||
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { clearTimeout(this._toastTimer); this.stopScanner() },
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; this._pageActive = true; this.startScanner(); if (this.inboundMode && (!this.bootstrapped || this.phase === 'candidates' || this._resumeReceiving)) { if (!this.busy) { this._resumeReceiving = false; this.bootstrap() } } },
|
||||
onHide() { this.suspendReceiving() }, onUnload() { this.suspendReceiving() }, beforeUnmount() { this.suspendReceiving() },
|
||||
methods: {
|
||||
candidateKey(item) { return `${item?.sourceContainerBookingId || ''}:${item?.containerNo || ''}` },
|
||||
statusLabel(status) { return ({ WAIT_SIGN: '待签收', SIGNED: '已签收', RECEIVING: '收货中', COMPLETED: '已完成', COMPLETED_WITH_DIFFERENCE: '差异待核平' })[status] || status },
|
||||
@@ -180,16 +180,19 @@
|
||||
async refreshBoundWarehouse() { this.user = applyWarehouseProfile(await getCurrentWarehouseProfile()); return this.user },
|
||||
photoStateLabel(photo) { if (photo?.status === 'uploading') return '正在上传…'; if (photo?.status === 'error') return photo.errorMessage || (photo.fileId ? '重拍失败,已保留原图' : '上传失败,请重试'); return photo?.fileId ? '点击左侧照片可查看大图' : '请拍摄现场照片' },
|
||||
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleHardwareScan(value), onError: (error) => { this.error = error; this.showToast('硬件扫码接收器异常,请检查扫描服务', 'warning') } }) }, stopScanner() { if (typeof this._stopScanner === 'function') this._stopScanner(); this._stopScanner = null },
|
||||
handleHardwareScan(value) { const scan = { value: String(value || '').trim(), target: this.activeTarget }; if (!scan.value) return; if (!this.inboundMode) return this.showToast('该功能缺少海外 PDA 专用接口,扫码未提交', 'warning'); if (this.phase === 'candidates') return this.showToast('请直接点击待卸货集装箱进入详情', 'warning'); if (this.phase !== 'receive') return; if (this.busy || this.activeTarget === 'quantity') { if (this.scanQueue.length >= 100) return this.showToast('扫码队列已满,请先处理当前托盘', 'warning'); this.scanQueue.push(scan); return this.showToast(`已排队 ${this.scanQueue.length} 个扫码`, 'warning') } return this.dispatchHardwareScan(scan) },
|
||||
dispatchHardwareScan(scan) { const palletWorkflowActive = Boolean(this.activeInbound || this.resolvedPallet); const target = palletWorkflowActive ? 'pallet' : scan.target; console.info('[Overseas PDA scan] dispatch', { value: scan.value, target, queuedTarget: scan.target }); if (target === 'pallet') { this.palletInput = scan.value; return this.resolvePallet(scan.value) } this.cartonInput = scan.value; return this.resolveCarton(scan.value) },
|
||||
drainScanQueue() { if (this.busy || this.activeTarget === 'quantity' || !this.scanQueue.length || this.phase !== 'receive') return; const next = this.scanQueue.shift(); this.dispatchHardwareScan(next) },
|
||||
async run(action, work) { this.busy = true; this.error = null; try { const result = await work(); this.showToast(`${action}成功`, 'success'); return result } catch (error) { const mappedError = toReceivingError(error, `${action}失败`); this.error = mappedError; playErrorSound(); this.showToast(mappedError.message, 'error'); throw mappedError } finally { this.busy = false; setTimeout(() => this.drainScanQueue(), 0) } },
|
||||
handleHardwareScan(value) { if (!this._pageActive || this.loading) return; const scan = { value: String(value || '').trim(), target: this.activeTarget }; if (!scan.value) return; if (!this.inboundMode) return this.showToast('该功能缺少海外 PDA 专用接口,扫码未提交', 'warning'); if (this.phase === 'candidates') return this.showToast('请直接点击待卸货集装箱进入详情', 'warning'); if (this.phase !== 'receive') return; if (this.busy || this.activeTarget === 'quantity') { if (this.scanQueue.length >= 100) return this.showToast('扫码队列已满,请先处理当前托盘', 'warning'); this.scanQueue.push(scan); return this.showToast(`已排队 ${this.scanQueue.length} 个扫码`, 'warning') } return this.dispatchHardwareScan(scan) },
|
||||
dispatchHardwareScan(scan) { if (!this._pageActive || this.loading) return; const palletWorkflowActive = Boolean(this.activeInbound || this.resolvedPallet); const target = palletWorkflowActive ? 'pallet' : scan.target; console.info('[Overseas PDA scan] dispatch', { value: scan.value, target, queuedTarget: scan.target }); if (target === 'pallet') { this.palletInput = scan.value; return this.resolvePallet(scan.value) } this.cartonInput = scan.value; return this.resolveCarton(scan.value) },
|
||||
drainScanQueue() { if (!this._pageActive || this.loading || this.busy || this.activeTarget === 'quantity' || !this.scanQueue.length || this.phase !== 'receive') return; const next = this.scanQueue.shift(); this.dispatchHardwareScan(next) },
|
||||
suspendReceiving() { this._pageActive = false; this._scanEpoch = (this._scanEpoch || 0) + 1; this._resumeReceiving = true; clearTimeout(this._drainTimer); clearTimeout(this._toastTimer); this.scanQueue = []; this.stopScanner() },
|
||||
scheduleScanDrain() { clearTimeout(this._drainTimer); if (!this._pageActive) return; const epoch = this._scanEpoch || 0; this._drainTimer = setTimeout(() => { if (this._pageActive && epoch === (this._scanEpoch || 0)) this.drainScanQueue() }, 0) },
|
||||
async run(action, work) { if (this.busy || !this._pageActive) throw new Error('当前页面未就绪,请返回作业页重试'); this.busy = true; this.error = null; try { const result = await work(); this.showToast(`${action}成功`, 'success'); return result } catch (error) { const mappedError = toReceivingError(error, `${action}失败`); this.error = mappedError; playErrorSound(); this.showToast(mappedError.message, 'error'); throw mappedError } finally { this.busy = false; if (this._pageActive && this._resumeReceiving) { this._resumeReceiving = false; this.bootstrap() } else this.scheduleScanDrain() } },
|
||||
setScanTargetForCurrent(value) { receiving.activeScanTarget = value?.status === 'WAIT_SIGN' ? 'container' : 'carton' },
|
||||
async restorePhotoPreviews() { await Promise.all(receiving.photoSlots.filter((photo) => photo?.downloadPath && !photo.localPath).map(async (photo) => { photo.status = 'restoring'; try { photo.localPath = await downloadCurrentUnloadPhoto(photo.downloadPath); photo.status = 'success' } catch (error) { photo.status = 'error'; photo.errorMessage = error.message || '照片加载失败'; this.error = error } })) },
|
||||
async restoreDomesticPhotoPreviews() { await Promise.all(this.domesticLoadingPhotos.filter((photo) => photo.downloadPath && !photo.localPath).map((photo) => this.loadDomesticPhoto(photo))) },
|
||||
async loadDomesticPhoto(photo) { if (!photo?.downloadPath || photo.status === 'loading') return; photo.status = 'loading'; try { photo.localPath = await downloadCurrentDomesticLoadingPhoto(photo.downloadPath); photo.status = 'success' } catch (_) { photo.status = 'error' } },
|
||||
async restorePhotoPreviews() { await Promise.all(receiving.photoSlots.filter((photo) => photo?.downloadPath && !photo.localPath).map(async (photo) => { try { await restoreReceivingPhoto(photo, downloadCurrentUnloadPhoto) } catch (error) { this.error = error } })) },
|
||||
async restoreDomesticPhotoPreviews() { if (this.phase !== 'sign' && !this.domesticPhotosExpanded) return; await Promise.all(this.domesticLoadingPhotos.filter((photo) => photo.downloadPath && !photo.localPath).map((photo) => this.loadDomesticPhoto(photo))) },
|
||||
async toggleDomesticPhotos() { this.domesticPhotosExpanded = !this.domesticPhotosExpanded; if (this.domesticPhotosExpanded) await this.restoreDomesticPhotoPreviews() },
|
||||
async loadDomesticPhoto(photo) { if (!photo?.downloadPath) return; try { await restoreReceivingPhoto(photo, downloadCurrentDomesticLoadingPhoto) } catch (_) { photo.status = 'error' } },
|
||||
async previewDomesticPhoto(photo) { if (!photo?.localPath) await this.loadDomesticPhoto(photo); if (photo?.localPath) { const urls = this.domesticLoadingPhotos.map((item) => item.localPath).filter(Boolean); uni.previewImage({ urls, current: photo.localPath }) } },
|
||||
async applyCurrentReceiving(value) { this.validateReceivingWarehouse(value, '当前接柜会话'); setCurrentReceiving(value); await Promise.all([this.restorePhotoPreviews(), this.restoreDomesticPhotoPreviews()]); this.setScanTargetForCurrent(value) },
|
||||
async applyCurrentReceiving(value) { this.validateReceivingWarehouse(value, '当前接柜会话'); setCurrentReceiving(value, JSON.stringify([session.tenantId, this.user?.id, this.user?.warehouseId])); if (!this._pageActive) return; await Promise.all([this.restorePhotoPreviews(), this.restoreDomesticPhotoPreviews()]); this.setScanTargetForCurrent(value) },
|
||||
async bootstrap() { if (this.loading) return; this.loading = true; this.error = null; this.detailOpen = false; try { await this.refreshBoundWarehouse(); try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.candidateError = null } catch (error) { if (!isNoCurrentReceiving(error)) throw error; resetReceivingDraft(); await this.loadCandidates(false, false) } this.bootstrapped = true } catch (error) { this.candidateError = error } finally { this.loading = false } },
|
||||
async recoverCurrentAfterConflict(error, preserveEvidence = false) { if (!isReceivingConflict(error)) return false; try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.detailOpen = false; this.error = preserveEvidence ? error : null; this.showToast(`账号已有未完成柜 ${current.containerNo},已返回当前柜`, 'warning'); return true } catch (recoveryError) { if (isNoCurrentReceiving(recoveryError)) { this.detailOpen = false; receiving.selected = null; await this.loadCandidates(false); this.candidateError = preserveEvidence ? error : null; this.error = preserveEvidence ? error : null; this.showToast('柜状态已变化,列表已刷新', 'warning'); return true } this.error = error; return false } },
|
||||
async loadCandidates(feedback = true, refreshWarehouse = true) { if (this.candidateLoading) return; this.candidateLoading = true; this.candidateError = null; this.error = null; try { if (refreshWarehouse) await this.refreshBoundWarehouse(); const values = await listReceivingCandidates(); receiving.candidates = values.map((item) => this.validateReceivingWarehouse(item, `柜号 ${item.containerNo || '—'}`)); if (feedback) this.showToast('刷新待卸柜成功', 'success') } catch (error) { receiving.candidates = []; this.candidateError = error; if (feedback) { playErrorSound(); this.showToast(error.message || '刷新待卸柜失败', 'error') } } finally { this.candidateLoading = false } },
|
||||
@@ -198,10 +201,11 @@
|
||||
async prepareContainer() { if (!this.selected?.containerNo || !this.selected?.sourceContainerBookingId || !this.selected?.allPlansReady || !this.selected?.operable || this.busy) return; const key = idempotencyKey('prepare', { sourceContainerBookingId: this.selected.sourceContainerBookingId, containerNo: this.selected.containerNo }); try { this.validateReceivingWarehouse(this.selected, `柜号 ${this.selected.containerNo}`); const value = await this.run('选择当前柜', async () => this.validateReceivingWarehouse(await prepareReceivingContainer(this.selected.containerNo, this.selected.sourceContainerBookingId, { idempotencyKey: key.value }), '新建接柜会话')); releaseIdempotencyKey(key.signature); await this.applyCurrentReceiving(value); this.detailOpen = false } catch (error) { if (!await this.recoverCurrentAfterConflict(error, true)) this.error = error } },
|
||||
choosePhoto(index) { if (this.busy) return; uni.chooseImage({ count: 1, sourceType: ['camera'], success: ({ tempFilePaths, tempFiles }) => { const path = tempFilePaths?.[0]; const size = Number(tempFiles?.[0]?.size || 0); if (size > MAX_UNLOAD_PHOTO_BYTES) { const message = '照片不能超过10MB'; this.error = Object.assign(new Error(message), { code: 'A0400' }); this.showToast(message, 'error'); playErrorSound(); return } if (path) this.replacePhoto(index, path) } }) },
|
||||
async replacePhoto(index, path) { const previous = receiving.photoSlots[index]; receiving.photoSlots.splice(index, 1, { ...(previous || {}), role: this.photoSteps[index].role, status: 'uploading', errorMessage: '' }); this.busy = true; this.error = null; try { const file = await uploadCurrentUnloadPhoto(path); receiving.photoSlots.splice(index, 1, { ...file, fileId: String(file.fileId), role: this.photoSteps[index].role, localPath: path, uploaded: true, status: 'success' }); this.showToast(`${this.photoSteps[index].label}上传成功`, 'success') } catch (error) { receiving.photoSlots.splice(index, 1, previous?.fileId ? { ...previous, status: 'error', errorMessage: error.message || '重拍失败,已保留原图' } : { role: this.photoSteps[index].role, localPath: path, uploaded: false, status: 'error', errorMessage: error.message || '上传失败,请重试' }); this.error = error; this.showToast(error.message || '照片上传失败', 'error'); playErrorSound() } finally { this.busy = false } },
|
||||
previewPhoto(index) { const path = this.photoSlots[index]?.localPath; if (!path) return; uni.previewImage({ urls: [path], current: path }) },
|
||||
invalidatePhotoPreview(photo) { if (!photo) return; photo.localPath = ''; photo.status = 'error'; photo.errorMessage = '照片加载失败,点按重试' },
|
||||
async previewPhoto(index) { const photo = this.photoSlots[index]; if (!photo) return; try { const path = await restoreReceivingPhoto(photo, downloadCurrentUnloadPhoto); if (path) uni.previewImage({ urls: [path], current: path }) } catch (error) { this.error = error } },
|
||||
async signContainer() { if (!this.photosReady) return; const photos = this.photoSlots.map((item, index) => ({ role: this.photoSteps[index].role, fileId: String(item.fileId) })); const key = idempotencyKey('sign', { containerNo: this.current.containerNo, photos }); try { const value = await this.run('签收当前柜', async () => this.validateReceivingWarehouse(await signCurrentReceiving({ photos, idempotencyKey: key.value }), '当前接柜会话')); releaseIdempotencyKey(key.signature); await this.applyCurrentReceiving(value); this.clearReceiptContext() } catch (_) {} },
|
||||
clearReceiptContext() { receiving.resolvedCarton = null; receiving.resolvedPallet = null; this.cartonInput = ''; this.palletInput = ''; this.packageCount = ''; receiving.activeScanTarget = 'carton'; setTimeout(() => this.drainScanQueue(), 0) },
|
||||
async resolveCarton(raw) { const cartonMark = String(raw?.detail?.value ?? raw ?? this.cartonInput).trim(); if (!cartonMark) return; if (this.busy) return this.handleHardwareScan(cartonMark); try { const outcome = await this.run('处理货物码', async () => { const value = await resolveCurrentCarton(cartonMark); if (value.operationMode !== 'DROPSHIP_PALLET') return { value }; const payload = { sourcePalletNo: value.sourcePalletNo || cartonMark }; const key = idempotencyKey('whole-pallet-receipt', { containerNo: this.current?.containerNo, ...payload }); const result = await receiveCurrentWholePallet({ ...payload, idempotencyKey: key.value }); const current = this.validateReceivingWarehouse(await getCurrentReceiving(), '当前接柜会话'); releaseIdempotencyKey(key.signature); return { value, result, current } }); if (outcome.result) { receiving.lastReceipt = outcome.result; await this.applyCurrentReceiving(outcome.current); this.clearReceiptContext(); playSuccessSound(); return } receiving.resolvedCarton = outcome.value; receiving.resolvedPallet = null; this.cartonInput = outcome.value.cartonMark; this.palletInput = ''; this.packageCount = ''; receiving.activeScanTarget = 'pallet'; playSuccessSound() } catch (error) { console.error('[Overseas PDA cargo] failed', { cartonMark, message: error.message, code: error.code, requestId: error.requestId, traceId: error.traceId }) } },
|
||||
clearReceiptContext() { receiving.resolvedCarton = null; receiving.resolvedPallet = null; this.cartonInput = ''; this.palletInput = ''; this.packageCount = ''; receiving.activeScanTarget = 'carton'; this.scheduleScanDrain() },
|
||||
async resolveCarton(raw) { const cartonMark = String(raw?.detail?.value ?? raw ?? this.cartonInput).trim(); if (!cartonMark || !this._pageActive) return; const scanEpoch = this._scanEpoch || 0; if (this.busy) return this.handleHardwareScan(cartonMark); try { const outcome = await this.run('处理货物码', async () => { const value = await resolveCurrentCarton(cartonMark); if (value.operationMode !== 'DROPSHIP_PALLET') return { value }; if (!this._pageActive || scanEpoch !== (this._scanEpoch || 0)) throw new Error('页面已离开,请返回后重新扫码确认收货'); const payload = { sourcePalletNo: value.sourcePalletNo || cartonMark }; const key = idempotencyKey('whole-pallet-receipt', { containerNo: this.current?.containerNo, ...payload }); const result = await receiveCurrentWholePallet({ ...payload, idempotencyKey: key.value }); const current = this.validateReceivingWarehouse(await getCurrentReceiving(), '当前接柜会话'); releaseIdempotencyKey(key.signature); return { value, result, current } }); if (outcome.result) { receiving.lastReceipt = outcome.result; await this.applyCurrentReceiving(outcome.current); this.clearReceiptContext(); playSuccessSound(); return } receiving.resolvedCarton = outcome.value; receiving.resolvedPallet = null; this.cartonInput = outcome.value.cartonMark; this.palletInput = ''; this.packageCount = ''; receiving.activeScanTarget = 'pallet'; playSuccessSound() } catch (error) { console.error('[Overseas PDA cargo] failed', { cartonMark, message: error.message, code: error.code, requestId: error.requestId, traceId: error.traceId }) } },
|
||||
async resolvePallet(raw) { const palletCode = String(raw?.detail?.value ?? raw ?? this.palletInput).trim(); if (!palletCode || !this.activeInbound || this.busy) return; try { const value = await this.run('校验托盘', () => resolveCurrentPallet(palletCode, this.activeInbound.inboundCode)); receiving.resolvedPallet = value; this.palletInput = value.palletCode; this.packageCount = Number(value.currentReceivedPackages || 0) || ''; if (!value.compatible) throw Object.assign(new Error('托盘不匹配'), { code: 'PALLET_INCOMPATIBLE' }); receiving.activeScanTarget = this.activeInbound.destinationType === 'FBA' ? 'quantity' : 'carton'; playSuccessSound() } catch (error) { if (!this.error) this.error = error } },
|
||||
async submitReceipt() { const isFba = this.activeInbound?.destinationType === 'FBA'; if (isFba && Number(this.packageCount) > MAX_PACKAGES_PER_PALLET) { this.error = Object.assign(new Error(PALLET_CAPACITY_MESSAGE), { code: 'A0409' }); playErrorSound(); this.showToast(PALLET_CAPACITY_MESSAGE, 'error'); return } if (!this.canSubmitReceipt) return; const payload = isFba ? { inboundCode: this.activeInbound.inboundCode, palletCode: this.resolvedPallet.palletCode, packageCount: Number(this.packageCount), expectedPalletVersion: this.resolvedPallet.version } : { cartonMark: this.activeInbound.cartonMark, palletCode: this.resolvedPallet.palletCode }; const key = idempotencyKey(isFba ? 'bulk-receipt' : 'carton-receipt', { containerNo: this.current?.containerNo, groupKey: this.activeInbound.groupKey, ...payload }); try { const result = await this.run('提交实收', async () => { const receipt = isFba ? await receiveCurrentBulk({ ...payload, idempotencyKey: key.value }) : await receiveCurrentCarton({ ...payload, idempotencyKey: key.value }); const current = this.validateReceivingWarehouse(await getCurrentReceiving(), '当前接柜会话'); return { receipt, current } }); releaseIdempotencyKey(key.signature); receiving.lastReceipt = result.receipt; await this.applyCurrentReceiving(result.current); this.clearReceiptContext(); playSuccessSound() } catch (_) {} },
|
||||
async completeContainer() { if (!this.canFinishOnsite || this.activeTarget === 'quantity' || this.scanQueue.length) return; const payload = { allowDifference: !this.allInboundsReconciled }; const key = idempotencyKey('complete', { containerNo: this.current.containerNo, ...payload }); try { const value = await this.run(this.allInboundsReconciled ? '完成入库' : '结束现场收货', async () => this.validateReceivingWarehouse(await completeCurrentReceiving({ ...payload, idempotencyKey: key.value }), '当前接柜会话')); releaseIdempotencyKey(key.signature); await this.applyCurrentReceiving(value) } catch (_) {} },
|
||||
|
||||
@@ -3,23 +3,24 @@
|
||||
<PortalHeader title="出库作业" :user="user" />
|
||||
<scroll-view class="fulfillment-scroll" scroll-y>
|
||||
<view class="portal-content fulfillment-content">
|
||||
<view class="fulfillment-heading"><text class="fulfillment-title">出库任务</text><text class="fulfillment-subtitle">一件代发扫描托盘码可整托出库;扫描排车业务号会转入“排车拣货”</text></view>
|
||||
<view class="fulfillment-heading"><text class="fulfillment-title">出库任务</text><text class="fulfillment-subtitle">整托扫描托盘码,散箱扫描箱唛;扫描排车业务号会转入“排车拣货”</text></view>
|
||||
<view v-if="current" class="detail-toolbar"><view><text class="detail-kicker">当前任务</text><text class="detail-title">出库作业详情</text></view><button :disabled="busy" @tap="closeTask">‹ 返回列表</button></view>
|
||||
<view class="fulfillment-card">
|
||||
<ScanTargetInput name="outbound" :model-value="scanCode" label="任务号、出库单号、排车单号、托盘码或箱码" placeholder="扫描任务号 / 出库单号 / 排车单号 / 托盘码 / 箱码" :active="true" @update:modelValue="updateScanCode" @submit="handleScan" />
|
||||
<button class="secondary-button" :disabled="!scanCode || loading || busy" @tap="handleScan(scanCode)">{{ current ? '匹配出库货物' : '打开出库任务' }}</button>
|
||||
<text class="scan-help">任务号直接恢复;进入任务后扫描 DP 托盘码,将一次拣取该托全部未拣箱件。</text>
|
||||
<ScanTargetInput name="outbound" :model-value="scanCode" :label="discardCurrent ? '废弃订单托盘码' : '任务号、出库单号、排车单号、托盘码或箱码'" :placeholder="discardCurrent ? '扫描废弃订单对应托盘码' : '扫描任务号 / 出库单号 / 排车单号 / 托盘码 / 箱码'" :active="true" @update:modelValue="updateScanCode" @submit="handleScan" />
|
||||
<button class="secondary-button" :disabled="!scanCode || loading || busy || discardLoading || discardBusy" @tap="handleScan(scanCode)">{{ discardCurrent ? '打开废弃托盘' : current ? '匹配出库货物' : '打开出库任务' }}</button>
|
||||
<text class="scan-help">任务号直接恢复;退货整托出库扫托盘码,散箱出库扫箱唛。</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="fulfillment-state"><text>正在加载出库任务…</text></view>
|
||||
<view v-else-if="error" class="fulfillment-error" role="alert"><text class="state-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><text v-if="error.network" class="offline-note">网络连接失败,当前任务、扫码草稿和幂等键均已保留。</text><button class="secondary-button" :disabled="loading || busy" @tap="retry">重试</button></view>
|
||||
<view v-else-if="error" class="fulfillment-error" role="alert"><text class="state-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><text v-if="error.network" class="offline-note">网络连接失败,当前任务、扫码草稿和幂等键均已保留。</text><button class="secondary-button" :disabled="loading || busy || discardLoading || discardBusy" @tap="retry">重试</button></view>
|
||||
|
||||
<template v-if="!loading && !current">
|
||||
<template v-if="!loading && !current && !discardCurrent">
|
||||
<view class="list-toolbar"><view><text class="list-title">待处理出库任务</text><text class="list-count">{{ tasks.length }} 个任务</text></view><button :disabled="loading" @tap="loadTasks">刷新</button></view>
|
||||
<view v-if="tasks.length" class="task-list">
|
||||
<button v-for="task in tasks" :key="task.taskNo" class="task-card" @tap="openTask(task.taskNo)"><view class="task-head"><view class="task-code-row"><text class="business-code">{{ task.taskNo || '—' }}</text><text v-if="task.taskNo === recentTaskNo" class="recent-badge">上次作业</text></view><text class="status-badge">{{ statusLabel(task.status) }}</text></view><text class="task-meta">出库单 {{ task.outboundNo || '—' }} · {{ taskType(task) }}</text><text class="task-enter">查看并开始出库 ›</text></button>
|
||||
</view>
|
||||
<view v-else-if="!error" class="fulfillment-state"><text class="state-title">暂无出库任务</text><text>可刷新列表,或扫描任务号恢复未完成作业。</text></view>
|
||||
<view v-else-if="!error && !discardTasks.length" class="fulfillment-state"><text class="state-title">暂无出库任务</text><text>可刷新列表,或扫描任务号恢复未完成作业。</text></view>
|
||||
<view v-if="discardTasks.length" class="discard-section"><view class="list-toolbar"><view><text class="list-title">废弃订单</text><text class="list-count">{{ discardOrderCount }} 个订单</text></view></view><view class="task-list"><button v-for="order in discardOrders" :key="order.discardNo" class="task-card" @tap="openDiscardOrder(order.discardNo)"><view class="task-head"><view class="task-code-row"><text class="business-code">{{ order.discardNo || '—' }}</text></view><text class="status-badge">废弃订单</text></view><text class="task-meta">托盘 {{ order.palletCount }} 个 · 待作业 {{ order.pendingPalletCount }} 个</text><text class="task-enter">点击进入,扫描对应托盘 ›</text></button></view></view>
|
||||
</template>
|
||||
|
||||
<template v-if="current">
|
||||
@@ -28,9 +29,10 @@
|
||||
<view v-if="current.items.length" class="item-list">
|
||||
<view v-for="item in current.items" :key="item.outboundPalletItemId" class="fulfillment-card item-card">
|
||||
<view class="task-head"><text class="business-code">{{ item.palletNo || '散件出库' }}</text><text>{{ item.pickedPackages }}/{{ item.plannedPackages }} 箱</text></view>
|
||||
<view v-for="position in item.positions" :key="businessLineKey(position)" class="outbound-line" :class="{ selected: selectedPosition === position }" @tap="selectPosition(item, position)">
|
||||
<view v-for="position in current.outboundType === 'RETURN_OUTBOUND' && item.palletNo ? [] : item.positions" :key="businessLineKey(position)" class="outbound-line" :class="{ selected: selectedPosition === position }" @tap="selectPosition(item, position)">
|
||||
<text class="line-code">{{ lineBusinessCode(position) }}</text><text class="line-meta">{{ position.inboundCode || '无入仓码' }} · 已拣 {{ position.pickedPackages }} 箱 · 可拣 {{ position.availablePackages }} 箱</text>
|
||||
</view>
|
||||
<text v-if="current.outboundType === 'RETURN_OUTBOUND' && item.palletNo && item.pickedPackages < item.plannedPackages" class="scan-help">整托出库请扫描托盘码 {{ item.palletNo }}</text>
|
||||
<text v-if="!item.positions.length && item.pickedPackages < item.plannedPackages" class="missing-scan-contract">当前明细缺少可扫码库存业务码,已禁止拣货;请刷新或联系平台确认任务下发。</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -41,6 +43,12 @@
|
||||
<view class="fulfillment-card gate-card"><text class="card-title">完成出库任务</text><text v-if="remainingPackages > 0" class="gate-warning">尚有 {{ remainingPackages }} 箱未拣,不能完成出库。</text><text v-else-if="current.status === 'COMPLETED'" class="gate-success">出库任务已完成,不可重复提交。</text><text v-else class="gate-note">全部计划货物已扫描核平,服务端将再次校验库存门禁。</text><button v-if="current.status !== 'COMPLETED'" class="primary-button" :disabled="!canComplete || busy" @tap="completeTask">{{ busyAction === 'complete' ? '完成中…' : '完成出库任务' }}</button></view>
|
||||
</template>
|
||||
|
||||
<template v-else-if="discardCurrent">
|
||||
<view class="detail-toolbar"><view><text class="detail-kicker">废弃订单</text><text class="detail-title">{{ discardCurrent.discardNo }}</text></view><button :disabled="discardBusy" @tap="closeDiscard">‹ 返回列表</button></view>
|
||||
<view class="fulfillment-card"><ScanTargetInput name="cargo-discard" :model-value="discardPalletCode" label="废弃托盘码" placeholder="扫描对应托盘码" :active="true" @update:modelValue="updateDiscardPalletCode" @submit="resolveDiscardPallet" /><button class="secondary-button" :disabled="!discardPalletCode || discardLoading || discardBusy" @tap="resolveDiscardPallet">{{ discardLoading ? '读取中…' : '打开托盘废弃作业' }}</button><text class="scan-help">扫描该废弃订单对应的托盘,再填写每个 SKU 的实际废弃数量。</text></view>
|
||||
<view v-if="discardTask" class="fulfillment-card item-card"><view class="task-head"><text class="business-code">托盘 {{ discardTask.palletNo }}</text><text class="status-badge">{{ discardTask.status === 'COMPLETED' ? '已完成' : '待废弃' }}</text></view><view v-for="sku in discardTask.skus" :key="sku.itemId" class="outbound-line"><text class="line-code">{{ sku.skuCode || '—' }}</text><text class="line-meta">{{ sku.productName || '—' }} · 计划 {{ sku.plannedPackages }} 箱</text><view class="count-row"><text>实际废弃数量</text><input type="number" :value="discardActuals[sku.itemId]" :disabled="discardTask.status === 'COMPLETED'" :placeholder="`请输入 0-${sku.plannedPackages} 箱`" @input="updateDiscardActual(sku.itemId, $event)" /><text>最多 {{ sku.plannedPackages }} 箱</text></view></view><button class="primary-button" :disabled="!canConfirmDiscard || discardBusy" @tap="confirmDiscard">{{ discardBusy ? '提交中…' : '废弃出库' }}</button></view>
|
||||
</template>
|
||||
|
||||
<view v-if="successMessage" class="fulfillment-success" role="status"><text>{{ successMessage }}</text></view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
@@ -50,7 +58,7 @@
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { completeOutboundTask, getOutboundTask, listOutboundTasks, outboundPickPayload, pickOutboundPallet, pickOutboundTask, resolveOutboundTask } from '@/api/fulfillment.js'
|
||||
import { completeOutboundTask, confirmCargoDiscard, getOutboundTask, listCargoDiscardTasks, listOutboundTasks, normalizeCargoDiscardTask, outboundPickPayload, pickOutboundPallet, pickOutboundTask, resolveCargoDiscardPallet, resolveOutboundTask } from '@/api/fulfillment.js'
|
||||
import { fulfillment, fulfillmentIdempotencyKey, releaseFulfillmentKey, restoreFulfillmentDraft, updateFulfillmentDraft } from '@/stores/fulfillment.js'
|
||||
import { requireSession, session } from '@/stores/session.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
@@ -58,7 +66,7 @@
|
||||
|
||||
export default {
|
||||
components: { PortalHeader, ScanTargetInput },
|
||||
data() { return { user: session.currentUser, tasks: [], current: null, selectedItem: null, selectedPosition: null, scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '', bootstrapped: false } },
|
||||
data() { return { user: session.currentUser, tasks: [], discardTasks: [], current: null, discardCurrent: null, discardTask: null, discardActuals: {}, discardPalletCode: '', discardLoading: false, discardBusy: false, selectedItem: null, selectedPosition: null, selectedScanCode: '', scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '', bootstrapped: false } },
|
||||
computed: {
|
||||
recentTaskNo() { return fulfillment.outbound.currentTaskNo || '' },
|
||||
packages() { return fulfillment.outbound.packages },
|
||||
@@ -66,8 +74,12 @@
|
||||
pickedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.pickedPackages, 0) },
|
||||
remainingPackages() { return Math.max(0, this.plannedPackages - this.pickedPackages) },
|
||||
selectedRemaining() { return Math.max(0, Math.min(Number(this.selectedItem?.plannedPackages || 0) - Number(this.selectedItem?.pickedPackages || 0), Number(this.selectedPosition?.availablePackages || 0))) },
|
||||
canPick() { return this.current?.status !== 'COMPLETED' && this.selectedItem && this.selectedPosition && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining },
|
||||
canComplete() { return this.plannedPackages > 0 && this.remainingPackages === 0 && this.current?.status !== 'COMPLETED' }
|
||||
taskOperable() { return ['PENDING', 'IN_PROGRESS'].includes(this.current?.status) },
|
||||
canPick() { return this.taskOperable && this.selectedItem && this.selectedPosition && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining },
|
||||
canComplete() { return this.plannedPackages > 0 && this.remainingPackages === 0 && this.taskOperable },
|
||||
discardOrders() { const groups = new Map(); for (const task of this.discardTasks) { const current = groups.get(task.discardNo) || { discardNo: task.discardNo, palletCount: 0, pendingPalletCount: 0 }; current.palletCount += 1; if (task.status !== 'COMPLETED') current.pendingPalletCount += 1; groups.set(task.discardNo, current) } return [...groups.values()] },
|
||||
discardOrderCount() { return this.discardOrders.length },
|
||||
canConfirmDiscard() { return Boolean(!this.discardLoading && !this.discardBusy && this.discardTask && this.discardTask.status !== 'COMPLETED' && this.discardTask.skus.length && this.discardTask.skus.every((sku) => Number.isInteger(Number(this.discardActuals[sku.itemId])) && Number(this.discardActuals[sku.itemId]) >= 0 && Number(this.discardActuals[sku.itemId]) <= sku.plannedPackages)) }
|
||||
},
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (!this.bootstrapped) { this.bootstrapped = true; this.scanCode = ''; this.loadTasks(); return } if (this.current?.taskNo) this.openTask(this.current.taskNo); else this.loadTasks() },
|
||||
onHide() { this.stopScanner() },
|
||||
@@ -77,7 +89,7 @@
|
||||
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
|
||||
updateScanCode(value) { this.scanCode = value; updateFulfillmentDraft('outbound', { scanCode: value }) },
|
||||
updatePackages(event) { const value = Math.trunc(Number(event.detail.value || 0)); updateFulfillmentDraft('outbound', { packages: Number.isFinite(value) ? Math.max(0, value) : 0 }) },
|
||||
async loadTasks() { await this.run('加载出库任务', async () => { this.tasks = await listOutboundTasks(); this.current = null }, true) },
|
||||
async loadTasks() { await this.run('加载出库任务', async () => { const [tasks, discardTasks] = await Promise.all([listOutboundTasks('PENDING'), listCargoDiscardTasks()]); this.tasks = tasks; this.discardTasks = discardTasks; this.current = null; this.discardCurrent = null; this.discardTask = null }, true) },
|
||||
async openTask(taskNo) { const code = String(taskNo || '').trim(); if (!code) return; await this.run('恢复出库任务', async () => { const pendingScan = fulfillment.outbound.currentTaskNo === code ? fulfillment.outbound.scanCode : ''; this.current = await getOutboundTask(code); this.selectedItem = null; this.selectedPosition = null; this.scanCode = pendingScan; updateFulfillmentDraft('outbound', { currentTaskNo: this.current.taskNo, scanCode: pendingScan, packages: pendingScan ? fulfillment.outbound.packages : 1 }); if (pendingScan) this.findAndSelectPosition(pendingScan, false) }, true) },
|
||||
async openScannedTask(businessNo) {
|
||||
await this.run('解析出库作业', async () => {
|
||||
@@ -95,24 +107,45 @@
|
||||
}, true)
|
||||
},
|
||||
closeTask() { this.current = null; this.selectedItem = null; this.selectedPosition = null; this.error = null; this.successMessage = ''; updateFulfillmentDraft('outbound', { currentTaskNo: '', scanCode: '', packages: 1 }); this.loadTasks() },
|
||||
openDiscardOrder(discardNo) { const code = String(discardNo || '').trim(); if (!code || this.discardBusy || this.busy || this.loading) return; this.current = null; this.discardLoading = false; this.discardCurrent = { discardNo: code }; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.error = null },
|
||||
closeDiscard() { if (this.discardBusy) return; this.discardLoading = false; this.discardCurrent = null; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.loadTasks() },
|
||||
updateDiscardPalletCode(value) { this.discardPalletCode = value },
|
||||
async resolveDiscardPallet(raw) {
|
||||
const code = String(raw?.detail?.value ?? raw ?? this.discardPalletCode).trim()
|
||||
if (!code || !this.discardCurrent || this.discardBusy || this.discardLoading) return
|
||||
const order = this.discardCurrent
|
||||
this.discardPalletCode = code; this.discardLoading = true; this.discardTask = null; this.discardActuals = {}; this.error = null
|
||||
try {
|
||||
const task = await resolveCargoDiscardPallet(code)
|
||||
if (this.discardCurrent !== order) return
|
||||
if (task.discardNo !== order.discardNo) throw new Error('扫描的托盘不属于当前废弃订单')
|
||||
this.discardTask = normalizeCargoDiscardTask(task)
|
||||
this.discardActuals = Object.fromEntries(this.discardTask.skus.map((sku) => [sku.itemId, sku.actualPackages == null ? sku.plannedPackages : sku.actualPackages]))
|
||||
} catch (error) { if (this.discardCurrent === order) this.setError(error, '废弃托盘读取失败') }
|
||||
finally { if (this.discardCurrent === order) this.discardLoading = false }
|
||||
},
|
||||
updateDiscardActual(itemId, event) { const value = Math.max(0, Math.trunc(Number(event.detail.value || 0))); this.discardActuals = { ...this.discardActuals, [itemId]: value } },
|
||||
async confirmDiscard() { if (this.discardBusy || this.discardLoading || !this.canConfirmDiscard || !this.discardTask || !this.discardCurrent) return; this.discardBusy = true; try { const lines = this.discardTask.skus.map((sku) => ({ itemId: sku.itemId, actualPackages: Number(this.discardActuals[sku.itemId]) })); await confirmCargoDiscard(this.discardCurrent.discardNo, this.discardTask.palletNo, lines); this.successMessage = '废弃出库作业已完成,等待仓库确认'; this.discardPalletCode = ''; this.discardTask = null; this.discardTasks = await listCargoDiscardTasks(); playSuccessSound() } catch (error) { this.setError(error, '废弃出库失败') } finally { this.discardBusy = false } },
|
||||
handleScan(raw) {
|
||||
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim(); if (!code || this.busy || this.loading) return
|
||||
console.info('[Overseas PDA outbound] scan', { code, task: this.current?.taskNo || '' }); this.scanCode = code; updateFulfillmentDraft('outbound', { scanCode: code })
|
||||
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim(); if (!code || this.busy || this.loading || this.discardBusy || this.discardLoading) return
|
||||
console.info('[Overseas PDA outbound] scan', { code, task: this.current?.taskNo || '', discard: this.discardCurrent?.discardNo || '' }); this.scanCode = code; updateFulfillmentDraft('outbound', { scanCode: code })
|
||||
if (this.discardCurrent) return this.resolveDiscardPallet(code)
|
||||
if (!this.current) return this.openScannedTask(code)
|
||||
if (code === this.current.taskNo || code === this.current.outboundNo) return this.openTask(this.current.taskNo)
|
||||
if (!this.taskOperable) return
|
||||
if ((this.current.items || []).some((item) => item.palletNo === code)) return this.submitPalletPick(code)
|
||||
if (this.findAndSelectPosition(code)) return
|
||||
this.setError(new Error('扫码未匹配当前出库任务的托盘码、箱码、SKU 或入仓码'), '扫码未匹配')
|
||||
},
|
||||
findAndSelectPosition(code, feedback = true) { for (const item of this.current?.items || []) { const position = item.positions.find((value) => [value.palletNo, value.cartonMark, value.skuCode, value.inboundCode].filter(Boolean).includes(code)); if (position) { this.selectPosition(item, position); if (feedback) playSuccessSound(); return true } } return false },
|
||||
selectPosition(item, position) { if (!position || position.availablePackages <= 0) return; this.selectedItem = item; this.selectedPosition = position; updateFulfillmentDraft('outbound', { scanCode: this.lineBusinessCode(position), packages: Math.max(1, Math.min(item.plannedPackages - item.pickedPackages, position.availablePackages)) }) },
|
||||
findAndSelectPosition(code, feedback = true) { for (const item of this.current?.items || []) { if (this.current?.outboundType === 'RETURN_OUTBOUND' && item.palletNo) continue; const position = item.positions.find((value) => value.availablePackages > 0 && [value.palletNo, value.cartonMark, value.skuCode, value.inboundCode].filter(Boolean).includes(code)); if (position) { this.selectPosition(item, position, code); if (feedback) playSuccessSound(); return true } } return false },
|
||||
selectPosition(item, position, scannedCode = '') { if (!this.taskOperable || !position || position.availablePackages <= 0 || (this.current?.outboundType === 'RETURN_OUTBOUND' && item.palletNo)) return; this.selectedItem = item; this.selectedPosition = position; this.selectedScanCode = scannedCode || this.lineBusinessCode(position); updateFulfillmentDraft('outbound', { scanCode: this.selectedScanCode, packages: Math.max(1, Math.min(item.plannedPackages - item.pickedPackages, position.availablePackages)) }) },
|
||||
async submitPick() {
|
||||
if (!this.canPick) return
|
||||
const payload = outboundPickPayload(this.selectedItem, this.selectedPosition, this.packages); const key = fulfillmentIdempotencyKey('outbound', 'pick', { taskNo: this.current.taskNo, ...payload })
|
||||
await this.run('提交出库拣货', async () => { await pickOutboundTask(this.current.taskNo, { ...payload, idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); releaseFulfillmentKey('outbound', key.signature); this.selectedItem = null; this.selectedPosition = null; updateFulfillmentDraft('outbound', { scanCode: '', packages: 1 }); this.successMessage = '本次出库拣货已记录'; playSuccessSound() })
|
||||
const payload = outboundPickPayload(this.selectedItem, this.selectedPosition, this.packages, this.selectedScanCode); const key = fulfillmentIdempotencyKey('outbound', 'pick', { taskNo: this.current.taskNo, ...payload })
|
||||
await this.run('提交出库拣货', async () => { await pickOutboundTask(this.current.taskNo, { ...payload, idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); releaseFulfillmentKey('outbound', key.signature); this.selectedItem = null; this.selectedPosition = null; this.selectedScanCode = ''; updateFulfillmentDraft('outbound', { scanCode: '', packages: 1 }); this.successMessage = '本次出库拣货已记录'; playSuccessSound() })
|
||||
},
|
||||
async submitPalletPick(palletNo) {
|
||||
const code = String(palletNo || '').trim(); if (!code || !this.current) return
|
||||
const code = String(palletNo || '').trim(); if (!code || !this.taskOperable) return
|
||||
const key = fulfillmentIdempotencyKey('outbound', 'pallet-pick', { taskNo: this.current.taskNo, palletNo: code })
|
||||
await this.run('整托拣货', async () => { await pickOutboundPallet(this.current.taskNo, code, { idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); releaseFulfillmentKey('outbound', key.signature); this.selectedItem = null; this.selectedPosition = null; updateFulfillmentDraft('outbound', { scanCode: '', packages: 1 }); this.successMessage = `${code} 整托拣货已完成`; playSuccessSound() })
|
||||
},
|
||||
@@ -121,13 +154,13 @@
|
||||
const key = fulfillmentIdempotencyKey('outbound', 'complete', { taskNo: this.current.taskNo })
|
||||
await this.run('完成出库任务', async () => { await completeOutboundTask(this.current.taskNo, { idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); releaseFulfillmentKey('outbound', key.signature); this.successMessage = '出库任务已完成'; playSuccessSound() })
|
||||
},
|
||||
async retry() { if (this.current?.taskNo) await this.openTask(this.current.taskNo); else if (fulfillment.outbound.currentTaskNo) await this.openTask(fulfillment.outbound.currentTaskNo); else if (this.scanCode) await this.openScannedTask(this.scanCode); else await this.loadTasks() },
|
||||
async retry() { if (this.discardCurrent) { if (this.discardPalletCode) await this.resolveDiscardPallet(this.discardPalletCode); return } if (this.current?.taskNo) await this.openTask(this.current.taskNo); else if (fulfillment.outbound.currentTaskNo) await this.openTask(fulfillment.outbound.currentTaskNo); else if (this.scanCode) await this.openScannedTask(this.scanCode); else await this.loadTasks() },
|
||||
async run(action, work, loading = false) { if (this.busy || this.loading) return; this.error = null; this.successMessage = ''; if (loading) this.loading = true; else { this.busy = true; this.busyAction = action.includes('完成') ? 'complete' : 'pick' } try { await work() } catch (error) { this.setError(error, `${action}失败`) } finally { this.loading = false; this.busy = false; this.busyAction = '' } },
|
||||
setError(error, fallback) { const forbidden = ['403', 'A0403'].includes(String(error?.code || '')); this.error = { message: forbidden ? '当前账号没有海外仓 PDA 作业权限,请联系管理员授权' : error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId, network: Boolean(error?.network) }; console.error('[Overseas PDA outbound] failed', this.error); playErrorSound() },
|
||||
lineBusinessCode(position) { return position?.cartonMark || position?.palletNo || position?.skuCode || position?.inboundCode || '业务码缺失' },
|
||||
businessLineKey(position) { return [position?.cartonMark, position?.palletNo, position?.skuCode, position?.inboundCode].filter(Boolean).join(':') || `line-${position?.inventoryPositionId || 'missing'}` },
|
||||
statusLabel(status) { return ({ PENDING: '待拣货', IN_PROGRESS: '拣货中', COMPLETED: '已完成', CANCELLED: '已取消' })[status] || status || '状态未知' },
|
||||
taskType() { return '普通/一件代发共用出库' }
|
||||
taskType(task) { return ({ RETURN_OUTBOUND: '退货出库', RELABEL_OUTBOUND: '换标出库', ONE_CLICK_DROPSHIP: '一件代发出库', WAREHOUSE_TRANSFER_OUTBOUND: '仓储转运出库' })[task?.outboundType] || '普通出库' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</view>
|
||||
<button class="logout-action" @tap="openLogout">退出登录</button>
|
||||
</view>
|
||||
<uni-popup ref="logoutPopup" type="center" :is-mask-click="true" @change="handleLogoutChange"><view class="logout-dialog-card" role="dialog" aria-modal="true" aria-label="确认退出登录"><PopupHeader title="确认退出登录?" tone="danger" @close="closeLogout" /><text class="logout-dialog-content">退出后会清空海外 PDA 的本地登录会话;未提交作业数据不得跨账号保留。</text><view class="logout-dialog-actions"><button class="logout-dialog-cancel" @tap="closeLogout">取消</button><button class="logout-dialog-confirm" @tap="logout">确认退出</button></view></view></uni-popup>
|
||||
<uni-popup ref="logoutPopup" type="center" :is-mask-click="true" @change="handleLogoutChange"><view class="logout-dialog-card" role="dialog" aria-modal="true" aria-label="确认退出登录"><PopupHeader title="确认退出登录?" tone="danger" @close="closeLogout" /><text class="logout-dialog-content">退出会撤销当前登录会话并清空本地作业草稿;未提交作业数据不得跨账号保留。</text><view class="logout-dialog-actions"><button class="logout-dialog-cancel" @tap="closeLogout">取消</button><button class="logout-dialog-confirm" @tap="logout">确认退出</button></view></view></uni-popup>
|
||||
<view v-if="toast" class="app-toast warning" role="status">设置功能将在后续版本开放</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,38 +2,45 @@
|
||||
<view v-if="user" class="page-shell portal-page work-page">
|
||||
<PortalHeader title="退货入库" :user="user" fallback-url="/pages/module/module?key=inbound" />
|
||||
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
|
||||
<view v-if="!current && !result" class="work-card">
|
||||
<view v-if="!current && !result && !receiptBatch" class="work-card">
|
||||
<text class="card-title">扫描 SKU 69 码</text>
|
||||
<ScanTargetInput name="lookup" v-model="lookupCode" label="SKU 69 码" placeholder="扫描商品 69 码,自动带出退货单号" :active="activeTarget === 'lookup'" @activate="activeTarget = 'lookup'" @submit="lookup" />
|
||||
<text class="scan-hint">扫描完成后自动匹配对应退货单</text>
|
||||
</view>
|
||||
|
||||
<view v-if="!current && candidates !== null && !result" class="section-gap">
|
||||
<view v-if="!current && candidates !== null && !result && !receiptBatch" class="section-gap">
|
||||
<view class="row-head candidate-heading"><text class="card-title">匹配退货单</text><text class="meta-line">{{ candidates.length }} 个候选</text></view>
|
||||
<view v-if="!candidates.length" class="empty-box"><text class="empty-copy">{{ entryMode === 'SKU' ? '当前绑定仓没有包含该 SKU 的待签收退货单' : '当前绑定仓没有匹配的待签收或签收中退货单' }}</text></view>
|
||||
<view v-else class="task-list"><button v-for="item in candidates" :key="item.returnNo" class="task-card candidate-card" @tap="selectCandidate(item)"><view class="row-head"><text class="business-code">{{ item.returnNo }}</text><text class="status-pill">{{ statusLabel(item.status) }}</text></view><text class="meta-line">客户参考号:{{ item.customerReferenceNo || '—' }}</text><view class="metric-row"><view class="metric"><strong>{{ item.skuReceivedQuantity }}/{{ item.skuExpectedQuantity }}</strong><text>该 SKU</text></view><view class="metric"><strong>{{ item.orderReceivedQuantity }}/{{ item.orderExpectedQuantity }}</strong><text>整单进度</text></view><text class="select-link">选择 ›</text></view></button></view>
|
||||
</view>
|
||||
|
||||
<template v-if="current && !result">
|
||||
<view v-if="receiptBatch && !result" class="work-card"><text class="card-title">待完成按箱签收</text><text class="business-code">{{ receiptBatch.returnNo }}</text><text class="hint">已确认 {{ receiptBatch.entries.filter(entry => entry.result).length }} / {{ receiptBatch.entries.length }} 箱。请继续本批签收,已确认的箱不会重新入库;本批内容暂不可修改。</text><button class="primary" :disabled="busy" @tap="confirmReceipt">{{ busy ? '提交中…' : '继续本批签收' }}</button></view>
|
||||
<template v-if="current && !result && !receiptBatch">
|
||||
<view class="work-card"><view class="row-head"><view><text class="business-code">{{ current.returnNo }}</text><text class="meta-line">{{ startedBy === 'SKU' ? '通过 SKU 候选进入' : '通过退货单号进入' }}</text></view><text class="status-pill">{{ statusLabel(current.status) }}</text></view><button class="link-button" @tap="clearCurrent">更换退货单</button></view>
|
||||
|
||||
<view class="work-card">
|
||||
<text class="card-title">本托信息</text>
|
||||
<text class="hint">本次签收按一个 SKU 打一个托盘,托盘码必填。</text>
|
||||
<text class="field-label">托盘码</text><ScanTargetInput name="pallet" v-model="palletCode" label="托盘码" placeholder="扫描已有托盘或新托盘标签" :active="activeTarget === 'pallet'" @activate="activeTarget = 'pallet'" @submit="acceptPallet" /><text v-if="palletCheck" class="scan-hint">{{ palletCheck.empty ? '空托盘,可绑定当前退货单和 SKU' : `当前单托盘,已有 ${palletCheck.skuCodes.join('、')},共 ${palletCheck.quantity} 件` }}</text><button class="secondary pallet-generate" :disabled="busy" @tap="generatePallet">生成新托盘(无目的仓)</button><button v-if="newPalletHtml" class="secondary" @tap="printPallet">打印托盘标签</button>
|
||||
<text class="field-label">快递单号(选填)</text><ScanTargetInput name="tracking" v-model="trackingNo" label="快递单号" placeholder="扫描快递面单" :active="activeTarget === 'tracking'" @activate="activeTarget = 'tracking'" @submit="acceptTracking" />
|
||||
<text class="card-title">本次实物单元</text>
|
||||
<text class="hint">按现场收到的实物选择维度,托盘不是必填项。</text>
|
||||
<view class="unit-tabs"><button v-for="item in unitOptions" :key="item.value" :class="['unit-tab', {active: unitType === item.value}]" @tap="selectUnitType(item.value)">{{ item.label }}</button></view>
|
||||
<view v-if="unitType === 'PARCEL'"><text class="field-label">包裹码/快递单号</text><ScanTargetInput name="unit" v-model="unitCode" label="包裹码/快递单号" placeholder="扫描快递面单" :active="activeTarget === 'unit'" @activate="activeTarget = 'unit'" @submit="acceptUnit" /></view>
|
||||
<template v-else>
|
||||
<text v-if="unitType === 'PALLET'" class="scan-hint">托盘入库:扫描已有托盘或生成新托盘,沿用原托盘测量流程。</text><text v-else class="scan-hint">箱子可直接散装入库,也可扫描托盘直接打托。</text>
|
||||
<view><text class="field-label">托盘码({{ unitType === 'BOX' ? '选填' : '必填' }})</text><ScanTargetInput name="pallet" v-model="palletCode" label="托盘码" placeholder="扫描已有托盘或新托盘标签" :active="activeTarget === 'pallet'" @activate="activeTarget = 'pallet'" @submit="acceptPallet" /><text v-if="palletCheck" class="scan-hint">{{ palletCheck.empty ? '空托盘,可绑定当前退货单和 SKU' : `当前单托盘,已有 ${palletCheck.skuCodes.join('、')},共 ${palletCheck.quantity} 件` }}</text><button class="secondary pallet-generate" :disabled="busy" @tap="generatePallet">生成新托盘(仅作载体)</button></view>
|
||||
<view v-if="unitType === 'PALLET'" class="dimension-grid"><label><text>长 cm</text><input class="number-input" type="digit" v-model="palletLength" placeholder="长" /></label><label><text>宽 cm</text><input class="number-input" type="digit" v-model="palletWidth" placeholder="宽" /></label><label><text>高 cm</text><input class="number-input" type="digit" v-model="palletHeight" placeholder="高" /></label><label><text>重量 kg</text><input class="number-input" type="digit" v-model="palletWeight" placeholder="重量" /></label><view class="volume-box"><text>体积</text><strong>{{ palletVolume }}</strong><text>cm³</text></view></view>
|
||||
<view><text class="field-label">快递单号(选填)</text><ScanTargetInput name="tracking" v-model="trackingNo" label="快递单号" placeholder="扫描快递面单" :active="activeTarget === 'tracking'" @activate="activeTarget = 'tracking'" @submit="acceptTracking" /></view>
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<view class="work-card"><view class="row-head"><text class="card-title">本托实收数量</text><button class="link-button" @tap="fillRemaining">全部填入</button></view><view class="item-list"><view v-for="item in selectedItems" :key="item.skuCode" class="item-card"><view class="row-head"><view><text class="business-code">{{ item.skuCode }}</text><text class="meta-line">{{ item.productNameZh || '商品名称未填写' }}</text></view><text class="remaining-copy">剩余 {{ remaining(item) }}</text></view><view class="quantity-row"><text>本托实收</text><input class="number-input" type="number" :value="quantity(item.skuCode)" placeholder="0" @input="setQuantity(item, $event.detail.value)" /></view></view></view></view>
|
||||
<view class="work-card"><view class="row-head"><text class="card-title">{{ unitType === 'PARCEL' ? '本次实收数量' : (unitType === 'PALLET' ? '本托实收数量' : '逐箱实收数量') }}</text><button v-if="unitType !== 'BOX'" class="link-button" @tap="fillRemaining">全部填入</button></view><view v-if="unitType === 'PALLET'" class="quantity-row"><text>本托箱数合计</text><strong class="auto-count">{{ palletBoxCount }}</strong></view><template v-if="unitType === 'BOX'"><view class="quantity-row"><text>本次箱数</text><input class="number-input" type="number" :value="boxRows.length" placeholder="箱数" @input="resizeBoxRows($event.detail.value)" /></view><view v-for="(box,index) in boxRows" :key="index" class="item-card"><text class="business-code">第 {{ index + 1 }} 箱</text><view v-for="item in selectedItems" :key="item.skuCode" class="quantity-row"><text>{{ item.skuCode }} · 数量</text><input class="number-input" type="number" :value="box.items[item.skuCode] || ''" placeholder="0" @input="setBoxQuantity(index, item.skuCode, $event.detail.value)" /></view></view></template><view v-else class="item-list"><view v-for="item in selectedItems" :key="item.skuCode" class="item-card"><view class="row-head"><view><text class="business-code">{{ item.skuCode }}</text><text class="meta-line">{{ item.productNameZh || '商品名称未填写' }}</text></view><text class="remaining-copy">剩余 {{ remaining(item) }}</text></view><view class="quantity-row"><text>数量</text><input class="number-input" type="number" :value="quantity(item.skuCode)" placeholder="0" @input="setQuantity(item, $event.detail.value)" /></view><view v-if="unitType === 'PALLET'" class="quantity-row"><text>此 SKU 箱数</text><input class="number-input" type="number" :value="boxCount(item.skuCode)" placeholder="0" @input="setBoxCount(item, $event.detail.value)" /></view></view></view></view>
|
||||
<view class="summary-bar"><view><text class="summary-label">本托合计</text><text class="summary-value">{{ receiptQuantity }}</text></view><button class="primary submit-button" :disabled="!canConfirm || busy" @tap="confirmReceipt">{{ busy ? '提交中…' : '确认签收并入托' }}</button></view>
|
||||
<view class="work-card"><view class="row-head"><view><text class="card-title">退货打托照片</text><text class="hint">签收过程中可随时拍照</text></view><text class="status-pill">{{ photoFileIds.length }} / 20 张</text></view><button class="secondary" :disabled="busy || photoFileIds.length >= 20" @tap="choosePhoto">拍照或从相册选择</button><view v-if="photoFileIds.length" class="photo-grid"><view v-for="(fileId,index) in photoFileIds" :key="fileId" class="photo-tile"><image class="photo-thumbnail" :src="photoUrls[fileId]" mode="aspectFill" @tap="previewPhoto(fileId)" /><text class="photo-name">{{ photoNames[index] || '退货打托照片' }}</text><button class="link-button" :disabled="busy" @tap="removePhoto(index)">移除</button></view></view></view>
|
||||
<button v-if="current.status === 'PARTIALLY_RECEIVED'" class="secondary manual-complete" :disabled="busy" @tap="manualComplete">缺货手动完结</button>
|
||||
<view v-if="current.completedPallets?.length" class="work-card"><text class="card-title">本单已完成打托</text><view class="item-list"><view v-for="pallet in current.completedPallets" :key="pallet.palletCode" class="item-card"><text class="business-code">{{ pallet.palletCode }}</text><text class="meta-line">{{ pallet.skuCode }} · {{ pallet.quantity }} 件</text><text class="meta-line">签收批次:{{ pallet.receiptBatchNo }}</text></view></view></view>
|
||||
</template>
|
||||
|
||||
<view v-if="result" class="success-box result-card"><text class="success-title">退货签收并入托成功</text><text class="result-carton">托盘:{{ result.palletCode }}</text><text class="meta-line">签收单:{{ result.receiptNo }}</text><text class="meta-line">计费批次:{{ result.receiptBatchNo }}</text><view class="metric-row"><view class="metric"><strong>{{ result.receivedQuantity }}/{{ result.expectedQuantity }}</strong><text>整单累计</text></view><view class="metric"><strong>{{ result.fullyMatched ? '已完成' : '签收中' }}</strong><text>退货单状态</text></view></view><button class="primary" @tap="nextReceipt">继续签收</button></view>
|
||||
<view v-if="result" class="success-box result-card"><text class="success-title">退货签收成功</text><view v-if="result.labels?.length"><view v-for="(label,index) in result.labels" :key="label.cartonMark" class="item-card"><text class="business-code">第 {{ index + 1 }} 箱 · {{ label.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(label)">打印箱唛</button></view></view><view v-else-if="result.printableHtml" class="item-card"><text class="business-code">{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }} · {{ result.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(result)">打印{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }}</button></view><text v-if="result.palletCode" class="result-carton">托盘:{{ result.palletCode }}</text><text class="meta-line">签收单:{{ result.receiptNo }}</text><text class="meta-line">计费批次:{{ result.receiptBatchNo }}</text><view class="metric-row"><view class="metric"><strong>{{ result.receivedQuantity }}/{{ result.expectedQuantity }}</strong><text>整单累计</text></view><view class="metric"><strong>{{ result.fullyMatched ? '已完成' : '签收中' }}</strong><text>退货单状态</text></view></view><button class="primary" @tap="nextReceipt">继续签收</button></view>
|
||||
|
||||
<view v-if="error" class="error-box" role="alert"><text class="error-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><button v-if="current" class="secondary" :disabled="busy" @tap="reloadCurrent">刷新退货单</button></view>
|
||||
<view v-if="error" class="error-box" role="alert"><text class="error-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><button v-if="current && !receiptBatch" class="secondary" :disabled="busy" @tap="reloadCurrent">刷新退货单</button></view>
|
||||
</view></scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -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}
|
||||
</style>
|
||||
|
||||
@@ -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 }) },
|
||||
|
||||
+31
-2
@@ -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]
|
||||
|
||||
+38
-34
@@ -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()
|
||||
|
||||
@@ -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 || {}) }
|
||||
})
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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(/<script>([\s\S]*?)<\/script>/)[1]
|
||||
const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done }); return { promise, resolve } }
|
||||
function page(overrides = {}) {
|
||||
const calls = []
|
||||
const deps = { PortalHeader: {}, ScanTargetInput: {}, session: { currentUser: {} }, fulfillment: { outbound: { packages: 1, currentTaskNo: 'OLD-TASK' } },
|
||||
updateFulfillmentDraft() {}, fulfillmentIdempotencyKey: () => ({ value: 'key', signature: 'signature' }), releaseFulfillmentKey() {},
|
||||
playErrorSound() {}, playSuccessSound() {}, pickOutboundPallet: async () => calls.push('pick'), completeOutboundTask: async () => calls.push('complete'),
|
||||
getOutboundTask: async () => ({ taskNo: 'TASK', status: 'COMPLETED', items: [] }), normalizeCargoDiscardTask: value => value,
|
||||
...overrides }
|
||||
const definition = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
|
||||
const vm = definition.data()
|
||||
for (const [name, fn] of Object.entries(definition.methods)) vm[name] = fn.bind(vm)
|
||||
for (const [name, fn] of Object.entries(definition.computed)) Object.defineProperty(vm, name, { get: () => fn.call(vm) })
|
||||
return { vm, calls }
|
||||
}
|
||||
|
||||
test('completed and cancelled tasks cannot submit pallet picking or task completion', async () => {
|
||||
for (const status of ['COMPLETED', 'CANCELLED']) {
|
||||
const { vm, calls } = page()
|
||||
vm.current = { taskNo: 'TASK', status, items: [{ palletNo: 'PALLET', plannedPackages: 1, pickedPackages: 1, positions: [] }] }
|
||||
await vm.handleScan('PALLET'); await vm.submitPalletPick('PALLET'); await vm.completeTask()
|
||||
assert.deepEqual(calls, [])
|
||||
}
|
||||
})
|
||||
|
||||
test('pending and in-progress tasks still submit exactly once while busy', async () => {
|
||||
for (const status of ['PENDING', 'IN_PROGRESS']) {
|
||||
const pending = deferred(); let submits = 0
|
||||
const { vm } = page({ pickOutboundPallet: async () => { submits++; await pending.promise } })
|
||||
vm.current = { taskNo: 'TASK', status, items: [{ palletNo: 'PALLET', plannedPackages: 1, pickedPackages: 0, positions: [] }] }
|
||||
const first = vm.handleScan('PALLET'); const second = vm.handleScan('PALLET')
|
||||
pending.resolve(); await Promise.all([first, second]); assert.equal(submits, 1)
|
||||
}
|
||||
})
|
||||
|
||||
test('failed new pallet lookup clears the previous selectable pallet and preserves retry code', async () => {
|
||||
const { vm } = page({ resolveCargoDiscardPallet: async () => { throw new Error('offline') } })
|
||||
vm.discardCurrent = { discardNo: 'DISCARD' }; vm.discardTask = { palletNo: 'OLD', status: 'PENDING', skus: [] }
|
||||
await vm.resolveDiscardPallet('NEW')
|
||||
assert.equal(vm.discardTask, null); assert.equal(vm.canConfirmDiscard, false)
|
||||
assert.equal(vm.discardPalletCode, 'NEW'); assert.equal(vm.error.message, 'offline'); assert.equal(vm.discardLoading, false)
|
||||
})
|
||||
|
||||
test('discard pallet scans do not overlap and cannot leave the previous pallet ready to submit', async () => {
|
||||
const pending = deferred(); let resolves = 0
|
||||
const { vm } = page({ resolveCargoDiscardPallet: async () => { resolves++; return pending.promise } })
|
||||
vm.discardCurrent = { discardNo: 'DISCARD' }; vm.discardTask = { palletNo: 'OLD', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }; vm.discardActuals = { sku: 1 }
|
||||
const first = vm.resolveDiscardPallet('NEW')
|
||||
const second = vm.resolveDiscardPallet('SECOND')
|
||||
const canConfirmDuringLoad = vm.canConfirmDiscard
|
||||
pending.resolve({ discardNo: 'DISCARD', palletNo: 'NEW', status: 'PENDING', skus: [] }); await Promise.all([first, second])
|
||||
assert.equal(resolves, 1); assert.equal(canConfirmDuringLoad, false)
|
||||
assert.equal(vm.discardTask.palletNo, 'NEW')
|
||||
})
|
||||
|
||||
test('discard confirmation is guarded against double submission', async () => {
|
||||
const pending = deferred(); let submits = 0
|
||||
const { vm } = page({ confirmCargoDiscard: async () => { submits++; await pending.promise }, listCargoDiscardTasks: async () => [] })
|
||||
vm.discardCurrent = { discardNo: 'DISCARD' }; vm.discardTask = { palletNo: 'PALLET', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }; vm.discardActuals = { sku: 1 }
|
||||
const first = vm.confirmDiscard(); const second = vm.confirmDiscard()
|
||||
pending.resolve(); await Promise.all([first, second]); assert.equal(submits, 1)
|
||||
})
|
||||
|
||||
test('discard retry stays in the discard workflow instead of restoring an old outbound task', async () => {
|
||||
const { vm } = page(); const calls = []
|
||||
vm.discardCurrent = { discardNo: 'DISCARD' }; vm.discardPalletCode = 'PALLET'
|
||||
vm.resolveDiscardPallet = async code => calls.push(['discard', code]); vm.openTask = async code => calls.push(['outbound', code])
|
||||
await vm.retry(); assert.deepEqual(calls, [['discard', 'PALLET']])
|
||||
})
|
||||
|
||||
test('closing or switching discard order ignores the previous resolve response', async () => {
|
||||
const pending = deferred()
|
||||
const { vm } = page({ resolveCargoDiscardPallet: () => pending.promise })
|
||||
vm.discardCurrent = { discardNo: 'FIRST' }
|
||||
const first = vm.resolveDiscardPallet('PALLET')
|
||||
vm.openDiscardOrder('SECOND')
|
||||
pending.resolve({ discardNo: 'FIRST', palletNo: 'PALLET', skus: [] }); await first
|
||||
assert.equal(vm.discardTask, null); assert.equal(vm.error, null)
|
||||
})
|
||||
+104
-23
@@ -15,7 +15,6 @@ function tokens(version = 'old') {
|
||||
const base = { user_id: 42, membership_id: 84, tenant_id: 0, app_code: 'PLATFORM', overseas_warehouse_id: 900, exp: future(), version }
|
||||
return {
|
||||
accessToken: jwt({ ...base, username: 'picker-42' }),
|
||||
overseasAccessToken: jwt(base),
|
||||
refreshToken: `refresh-${version}`,
|
||||
roles: [PDA_ROLE], authorities: [PDA_AUTHORITY]
|
||||
}
|
||||
@@ -23,7 +22,8 @@ function tokens(version = 'old') {
|
||||
|
||||
function prepareSession() {
|
||||
globalThis.uni = {
|
||||
setStorageSync() {}, getStorageSync() {}, removeStorageSync() {}
|
||||
setStorageSync() {}, getStorageSync() {}, removeStorageSync() {},
|
||||
launches: [], reLaunch(options) { this.launches.push(options.url) }
|
||||
}
|
||||
resetOverseasRequestStateForTest(); signOut()
|
||||
const value = tokens('old')
|
||||
@@ -43,7 +43,7 @@ test('H5 proxy and App API roots normalize request upload and download paths to
|
||||
globalThis.uni.uploadFile = (options) => { uploads.push(options.url); options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: { fileId: '1' } }), header: {} }) }
|
||||
globalThis.uni.downloadFile = (options) => { downloads.push(options.url); options.success({ statusCode: 200, tempFilePath: '/tmp/photo.jpg', header: {} }) }
|
||||
|
||||
session.apiBase = '/overseas-api'
|
||||
session.apiBase = '/gateway-api'
|
||||
await resolvePallet('PLT-1')
|
||||
await overseasUpload('/api/pda/v1/last-leg/photos/files', '/tmp/photo.jpg')
|
||||
await overseasDownload('/api/pda/v1/last-leg/photos/files/1/download')
|
||||
@@ -54,15 +54,15 @@ test('H5 proxy and App API roots normalize request upload and download paths to
|
||||
await overseasDownload('/api/pda/v1/last-leg/photos/files/1/download')
|
||||
|
||||
assert.deepEqual(requests, [
|
||||
'/overseas-api/pda/v1/last-leg/pallet-operations/pallets/resolve',
|
||||
'/gateway-api/pda/v1/last-leg/pallet-operations/pallets/resolve',
|
||||
'https://overseas.example.com/api/pda/v1/last-leg/pallet-operations/pallets/resolve'
|
||||
])
|
||||
assert.deepEqual(uploads, [
|
||||
'/overseas-api/pda/v1/last-leg/photos/files',
|
||||
'/gateway-api/pda/v1/last-leg/photos/files',
|
||||
'https://overseas.example.com/api/pda/v1/last-leg/photos/files'
|
||||
])
|
||||
assert.deepEqual(downloads, [
|
||||
'/overseas-api/pda/v1/last-leg/photos/files/1/download',
|
||||
'/gateway-api/pda/v1/last-leg/photos/files/1/download',
|
||||
'https://overseas.example.com/api/pda/v1/last-leg/photos/files/1/download'
|
||||
])
|
||||
for (const url of [...requests, ...uploads, ...downloads]) assert.doesNotMatch(url, /\/api\/api\//)
|
||||
@@ -76,11 +76,11 @@ test('concurrent old-token 401 responses share one refresh and each replay once
|
||||
refreshCount += 1
|
||||
return setTimeout(() => options.success(ok(next)), 10)
|
||||
}
|
||||
if (options.header.Authorization === `Bearer ${old.overseasAccessToken}`) {
|
||||
if (options.header.Authorization === `Bearer ${old.accessToken}`) {
|
||||
oldCalls += 1
|
||||
return setTimeout(() => options.success(denied()), oldCalls === 1 ? 0 : 30)
|
||||
}
|
||||
assert.equal(options.header.Authorization, `Bearer ${next.overseasAccessToken}`)
|
||||
assert.equal(options.header.Authorization, `Bearer ${next.accessToken}`)
|
||||
newCalls += 1
|
||||
setTimeout(() => options.success(ok({ call: newCalls })), 0)
|
||||
}
|
||||
@@ -92,8 +92,7 @@ test('concurrent old-token 401 responses share one refresh and each replay once
|
||||
assert.equal(refreshCount, 1)
|
||||
assert.equal(oldCalls, 2)
|
||||
assert.equal(newCalls, 2)
|
||||
assert.equal(session.token, next.overseasAccessToken)
|
||||
assert.equal(session.domesticToken, next.accessToken)
|
||||
assert.equal(session.token, next.accessToken)
|
||||
})
|
||||
|
||||
test('request retries only once and preserves the final request and trace identifiers', async () => {
|
||||
@@ -116,6 +115,8 @@ test('request retries only once and preserves the final request and trace identi
|
||||
})
|
||||
assert.equal(refreshCount, 1)
|
||||
assert.equal(businessCalls, 2)
|
||||
assert.equal(session.token, '')
|
||||
assert.deepEqual(uni.launches, ['/pages/login/login'])
|
||||
})
|
||||
|
||||
test('refresh rejection clears every local token and returns the auth trace', async () => {
|
||||
@@ -134,10 +135,90 @@ test('refresh rejection clears every local token and returns the auth trace', as
|
||||
})
|
||||
assert.equal(session.currentUser, null)
|
||||
assert.equal(session.token, '')
|
||||
assert.equal(session.domesticToken, '')
|
||||
assert.equal(session.refreshToken, '')
|
||||
assert.deepEqual(uni.launches, ['/pages/login/login'])
|
||||
})
|
||||
|
||||
test('concurrent refresh rejection clears the session and navigates to login only once', async () => {
|
||||
prepareSession()
|
||||
let refreshCount = 0
|
||||
uni.request = (options) => {
|
||||
if (options.url.endsWith('/auth/refresh')) {
|
||||
refreshCount += 1
|
||||
return setTimeout(() => options.success(denied()), 5)
|
||||
}
|
||||
options.success(denied())
|
||||
}
|
||||
const results = await Promise.allSettled([overseasRequest('GET', '/one'), overseasRequest('GET', '/two')])
|
||||
assert.ok(results.every(result => result.status === 'rejected'))
|
||||
assert.equal(refreshCount, 1)
|
||||
assert.equal(session.currentUser, null)
|
||||
assert.deepEqual(uni.launches, ['/pages/login/login'])
|
||||
})
|
||||
|
||||
for (const refreshSucceeds of [true, false]) {
|
||||
test(`late refresh ${refreshSucceeds ? 'success' : 'failure'} cannot restore logout or clear a new login`, async () => {
|
||||
for (const relogin of [false, true]) {
|
||||
prepareSession()
|
||||
let completeRefresh
|
||||
let started
|
||||
const refreshStarted = new Promise(resolve => { started = resolve })
|
||||
let businessCalls = 0
|
||||
uni.request = (options) => {
|
||||
if (options.url.endsWith('/auth/refresh')) {
|
||||
completeRefresh = () => options.success(refreshSucceeds ? ok(tokens('late')) : denied())
|
||||
started()
|
||||
return
|
||||
}
|
||||
businessCalls += 1
|
||||
options.success(denied())
|
||||
}
|
||||
const pending = overseasRequest('POST', '/write')
|
||||
await refreshStarted
|
||||
signOut()
|
||||
const replacement = tokens('new-login')
|
||||
if (relogin) signInWithTokens(replacement)
|
||||
completeRefresh()
|
||||
await assert.rejects(pending)
|
||||
assert.equal(session.token, relogin ? replacement.accessToken : '')
|
||||
assert.equal(businessCalls, 1)
|
||||
assert.deepEqual(uni.launches, [])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('an old business response cannot replay with a newly logged-in session', async () => {
|
||||
prepareSession()
|
||||
let complete
|
||||
let calls = 0
|
||||
uni.request = options => { calls += 1; complete = () => options.success(denied()) }
|
||||
const pending = overseasRequest('POST', '/write')
|
||||
signOut()
|
||||
const replacement = tokens('another-login')
|
||||
signInWithTokens(replacement)
|
||||
complete()
|
||||
await assert.rejects(pending, /会话已变更/)
|
||||
assert.equal(calls, 1)
|
||||
assert.equal(session.token, replacement.accessToken)
|
||||
assert.deepEqual(uni.launches, [])
|
||||
})
|
||||
|
||||
for (const kind of ['upload', 'download']) {
|
||||
test(`${kind} final unauthorized response navigates once and stops after one replay`, async () => {
|
||||
prepareSession()
|
||||
let refreshCount = 0
|
||||
let calls = 0
|
||||
uni.request = options => { refreshCount += 1; options.success(ok(tokens(kind))) }
|
||||
uni.uploadFile = options => { calls += 1; options.success({ ...denied(), data: JSON.stringify(denied().data) }) }
|
||||
uni.downloadFile = options => { calls += 1; options.success(denied()) }
|
||||
await assert.rejects(kind === 'upload' ? overseasUpload('/photo', '/tmp/photo.jpg') : overseasDownload('/photo'))
|
||||
assert.equal(refreshCount, 1)
|
||||
assert.equal(calls, 2)
|
||||
assert.equal(session.currentUser, null)
|
||||
assert.deepEqual(uni.launches, ['/pages/login/login'])
|
||||
})
|
||||
}
|
||||
|
||||
test('photo upload refreshes and replays once while retaining the file payload', async () => {
|
||||
const old = prepareSession(); const next = tokens('upload')
|
||||
let refreshCount = 0; const uploads = []
|
||||
@@ -148,7 +229,7 @@ test('photo upload refreshes and replays once while retaining the file payload',
|
||||
}
|
||||
globalThis.uni.uploadFile = (options) => {
|
||||
uploads.push({ filePath: options.filePath, name: options.name, authorization: options.header.Authorization })
|
||||
if (options.header.Authorization === `Bearer ${old.overseasAccessToken}`) {
|
||||
if (options.header.Authorization === `Bearer ${old.accessToken}`) {
|
||||
return options.success({ statusCode: 401, data: JSON.stringify({ code: 'A0401', requestId: 'upload-old', traceId: 'upload-old-trace' }), header: {} })
|
||||
}
|
||||
options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: { fileId: '99' } }), header: {} })
|
||||
@@ -178,11 +259,11 @@ test('H5 blob photo uses authenticated multipart fetch instead of uni.uploadFile
|
||||
return new Response(JSON.stringify({ code: '200', data: { fileId: 'h5-1' } }), { status: 200 })
|
||||
}
|
||||
try {
|
||||
session.apiBase = '/overseas-api'
|
||||
session.apiBase = '/gateway-api'
|
||||
assert.deepEqual(await overseasUpload('/api/pda/v1/last-leg/photos/files', 'blob:http://localhost/photo'), { fileId: 'h5-1' })
|
||||
assert.equal(uploadFileCalls, 0)
|
||||
assert.equal(uploaded.url, '/overseas-api/pda/v1/last-leg/photos/files')
|
||||
assert.equal(uploaded.options.headers.Authorization, `Bearer ${value.overseasAccessToken}`)
|
||||
assert.equal(uploaded.url, '/gateway-api/pda/v1/last-leg/photos/files')
|
||||
assert.equal(uploaded.options.headers.Authorization, `Bearer ${value.accessToken}`)
|
||||
assert.ok(uploaded.options.body instanceof FormData)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
@@ -203,14 +284,14 @@ test('H5 File photo is appended directly without rereading a temporary blob URL'
|
||||
let calls = 0
|
||||
globalThis.uni.uploadFile = (options) => {
|
||||
calls += 1
|
||||
assert.equal(options.url, '/overseas-api/pda/photo')
|
||||
assert.equal(options.header.Authorization, `Bearer ${value.overseasAccessToken}`)
|
||||
assert.equal(options.url, '/gateway-api/pda/photo')
|
||||
assert.equal(options.header.Authorization, `Bearer ${value.accessToken}`)
|
||||
assert.equal(options.files[0].file, selected)
|
||||
assert.equal(options.files[0].name, 'file')
|
||||
options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: { fileId: 'direct-file' } }), header: {} })
|
||||
}
|
||||
try {
|
||||
session.apiBase = '/overseas-api'
|
||||
session.apiBase = '/gateway-api'
|
||||
assert.deepEqual(await overseasUpload('/pda/photo', selected), { fileId: 'direct-file' })
|
||||
assert.equal(calls, 1)
|
||||
} finally {
|
||||
@@ -225,13 +306,13 @@ test('native file runtime rejects an H5-relative overseas root before upload wit
|
||||
prepareSession()
|
||||
const originalLocation = globalThis.location
|
||||
globalThis.location = { protocol: 'file:' }
|
||||
session.apiBase = '/overseas-api'
|
||||
session.apiBase = '/gateway-api'
|
||||
let uploadCalls = 0
|
||||
globalThis.uni.uploadFile = () => { uploadCalls += 1 }
|
||||
try {
|
||||
await assert.rejects(overseasUpload('/pda/photo', '_doc/photo.jpg'), (error) => {
|
||||
assert.equal(error.code, 'PDA_OVERSEAS_API_BASE_REQUIRED')
|
||||
assert.match(error.message, /VITE_OVERSEAS_API_BASE_URL/)
|
||||
assert.equal(error.code, 'PDA_API_BASE_REQUIRED')
|
||||
assert.match(error.message, /VITE_API_BASE_URL/)
|
||||
assert.match(error.message, /HTTP\(S\)/)
|
||||
return true
|
||||
})
|
||||
@@ -270,8 +351,8 @@ test('authenticated photo download maps the public api path through the H5 overs
|
||||
}
|
||||
assert.equal(await overseasDownload('/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/99/download'), '/tmp/restored.jpg')
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0].url, '/overseas-api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/99/download')
|
||||
assert.equal(calls[0].header.Authorization, `Bearer ${value.overseasAccessToken}`)
|
||||
assert.equal(calls[0].url, '/gateway-api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/99/download')
|
||||
assert.equal(calls[0].header.Authorization, `Bearer ${value.accessToken}`)
|
||||
})
|
||||
|
||||
test('native image download accepts a valid temp file when App-Plus omits response headers', async () => {
|
||||
|
||||
@@ -14,9 +14,8 @@ test('overseas PDA is an independent Vue 3 app with separate packaging identity'
|
||||
assert.doesNotMatch(pages, /pages\/picking|pages\/tasks/)
|
||||
assert.match(pkg, /"build:app"\s*:\s*"uni build -p app"/)
|
||||
assert.match(vite, /API_PROXY_TARGET/)
|
||||
assert.match(vite, /OVERSEAS_API_PROXY_TARGET/)
|
||||
assert.match(vite, /\/overseas-api/)
|
||||
assert.match(vite, /replace\(\/\^\\\/overseas-api\//)
|
||||
assert.doesNotMatch(vite, /OVERSEAS_API_PROXY_TARGET|gateway-overseas/)
|
||||
assert.match(vite, /\/api/)
|
||||
assert.match(vite, /removeHeader\('origin'\)/)
|
||||
assert.match(vite, /removeHeader\('referer'\)/)
|
||||
assert.match(vite, /__VUE_I18N_FULL_INSTALL__:\s*true/)
|
||||
@@ -35,7 +34,7 @@ test('overseas workbench contains confirmed warehouse and phase-five outbound do
|
||||
for (const label of ['入仓作业', '拆柜收货', '退货入库', '退货合箱', '出库履约', '排车拣货', '出库作业', '库内作业', '换标作业', '现场查询']) assert.match(data, new RegExp(label))
|
||||
for (const excluded of ['集装箱拣货', '装柜取证', '海外派送', '派送单', '客户回访']) assert.doesNotMatch(data, new RegExp(excluded))
|
||||
assert.match(session, /overseas-pda-auth-session/)
|
||||
assert.match(runtime, /VITE_OVERSEAS_API_BASE_URL/)
|
||||
assert.match(runtime, /VITE_API_BASE_URL/)
|
||||
})
|
||||
|
||||
test('login has no committed demo credentials and inbound operation implements the locked container flow', async () => {
|
||||
@@ -49,9 +48,9 @@ test('login has no committed demo credentials and inbound operation implements t
|
||||
assert.match(auth, /authRequest\('\/auth\/login', \{ loginId, password \}\)/)
|
||||
assert.doesNotMatch(auth, /authRequest\('\/auth\/login', \{ username, password \}\)/)
|
||||
assert.match(runtime, /runtimeEnvironment\.DEV|VITE_PDA_DEV_USERNAME|VITE_PDA_DEV_PASSWORD/)
|
||||
assert.match(runtime, /'\/overseas-api'/)
|
||||
assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_AUTH_API_BASE_URL/)
|
||||
assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_OVERSEAS_API_BASE_URL/)
|
||||
assert.match(runtime, /'\/gateway-api'/)
|
||||
assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_API_BASE_URL/)
|
||||
assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_API_BASE_URL/)
|
||||
assert.match(operation, /startScanBroadcast/)
|
||||
assert.match(operation, /palletWorkflowActive = Boolean\(this\.activeInbound \|\| this\.resolvedPallet\)/)
|
||||
assert.match(operation, /queuedTarget: scan\.target/)
|
||||
@@ -68,7 +67,7 @@ test('login has no committed demo credentials and inbound operation implements t
|
||||
assert.match(operation, /resolveCurrentCarton\(cartonMark\)[\s\S]*value\.operationMode !== 'DROPSHIP_PALLET'/)
|
||||
assert.match(operation, /name="carton"[\s\S]*@submit="resolveCarton"/)
|
||||
assert.match(operation, /sourcePalletNo: value\.sourcePalletNo \|\| cartonMark[\s\S]*receiveCurrentWholePallet/)
|
||||
assert.match(operation, /clearReceiptContext\(\)[\s\S]*activeScanTarget = 'carton'[\s\S]*drainScanQueue/)
|
||||
assert.match(operation, /clearReceiptContext\(\)[\s\S]*activeScanTarget = 'carton'[\s\S]*scheduleScanDrain/)
|
||||
assert.match(operation, /name="pallet"[\s\S]*@activate="activeTarget = 'pallet'"/)
|
||||
assert.match(operation, /receiving\.activeScanTarget = 'pallet'/, '普通FBA或中转解析后直接进入托盘扫码以减少操作')
|
||||
assert.match(operation, /\[Overseas PDA scan\] dispatch/)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import * as store from '../src/stores/receiving.js'
|
||||
import { normalizeReceivingSession } from '../src/api/receiving-adapter.js'
|
||||
|
||||
const source = readFileSync(new URL('../src/pages/operation/operation.vue', import.meta.url), 'utf8').match(/<script>([\s\S]*?)<\/script>/)[1]
|
||||
const defer = () => { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b }); return { promise, resolve, reject } }
|
||||
const sessionValue = (patch = {}) => normalizeReceivingSession({ containerNo: 'CAB-A', preparedAt: '2026-10-02T10:00:00',
|
||||
overseasWarehouseId: '900', status: 'SIGNED', inbounds: [{ inboundCode: 'IN-A', customerId: '100' }],
|
||||
photos: ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN'].map((role, i) => ({ role, fileId: String(i + 1), downloadPath: `/signed/${i + 1}` })),
|
||||
domesticLoadingPhotos: Array.from({ length: 5 }, (_, i) => ({ evidenceType: `PHOTO_${i}`, downloadPath: `/domestic/${i}` })), ...patch })
|
||||
|
||||
function page(overrides = {}) {
|
||||
const deps = { ...store, session: { tenantId: '100', currentUser: { id: 'operator', warehouseId: '900' } },
|
||||
PortalHeader: {}, ReceivingSessionSummary: {}, ScanTargetInput: {}, requireSession: () => true,
|
||||
startScanBroadcast: () => () => {}, playErrorSound() {}, playSuccessSound() {},
|
||||
receivingPhase: store.receivingPhase, toReceivingError: error => error, ...overrides }
|
||||
const definition = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
|
||||
const vm = { ...definition.data(), _pageActive: true, _scanEpoch: 0 }
|
||||
for (const [name, method] of Object.entries(definition.methods)) vm[name] = method.bind(vm)
|
||||
for (const [name, descriptor] of Object.entries(definition.computed)) Object.defineProperty(vm, name,
|
||||
typeof descriptor === 'function' ? { get: () => descriptor.call(vm) } : { get: () => descriptor.get.call(vm), set: value => descriptor.set.call(vm, value) })
|
||||
vm.moduleKey = 'inbound'; vm.validateReceivingWarehouse = value => value; vm.showToast = () => {}
|
||||
return { vm, definition, deps }
|
||||
}
|
||||
|
||||
test('same current readback retains three signed previews; collapsed unversioned domestic images issue no downloads', async () => {
|
||||
store.resetReceivingSession(); const calls = []
|
||||
const download = async path => { calls.push(path); return `local:${path}` }
|
||||
const { vm } = page({ downloadCurrentUnloadPhoto: download, downloadCurrentDomesticLoadingPhoto: download })
|
||||
await vm.applyCurrentReceiving(sessionValue({ status: 'WAIT_SIGN' })); assert.equal(calls.length, 8)
|
||||
await vm.applyCurrentReceiving(sessionValue()); await vm.applyCurrentReceiving(sessionValue()); assert.equal(calls.length, 8)
|
||||
await vm.toggleDomesticPhotos(); assert.equal(calls.length, 13)
|
||||
await vm.restoreDomesticPhotoPreviews(); assert.equal(calls.length, 13)
|
||||
// Domestic DTO has no file identity/version: a new response must not reuse an old image.
|
||||
await vm.applyCurrentReceiving(sessionValue()); assert.equal(calls.length, 18)
|
||||
})
|
||||
|
||||
test('signed photo changes, account/customer/cabinet changes and image failure cannot inherit stale previews', async () => {
|
||||
store.resetReceivingSession(); let calls = 0
|
||||
const { vm, deps } = page({ downloadCurrentUnloadPhoto: async () => `local:${++calls}`, downloadCurrentDomesticLoadingPhoto: async () => '' })
|
||||
await vm.applyCurrentReceiving(sessionValue()); assert.equal(calls, 3)
|
||||
const changed = sessionValue(); changed.photos[0].fileId = 'NEW'; await vm.applyCurrentReceiving(changed); assert.equal(calls, 4)
|
||||
deps.session.tenantId = '200'; await vm.applyCurrentReceiving(changed); assert.equal(calls, 7)
|
||||
await vm.applyCurrentReceiving(sessionValue({ containerNo: 'CAB-B' })); assert.equal(calls, 10)
|
||||
await vm.applyCurrentReceiving(sessionValue({ containerNo: 'CAB-B', inbounds: [{ inboundCode: 'IN-OTHER', customerId: '201' }] })); assert.equal(calls, 13)
|
||||
vm.invalidatePhotoPreview(store.receiving.photoSlots[0]); await vm.restorePhotoPreviews(); assert.equal(calls, 14)
|
||||
})
|
||||
|
||||
test('overlapping current refresh joins inflight photo download and failed fetch remains retryable', async () => {
|
||||
store.resetReceivingSession(); const pending = defer(); let calls = 0
|
||||
const { vm } = page({ downloadCurrentUnloadPhoto: async path => { calls++; await pending.promise; return `local:${path}` } })
|
||||
const first = vm.applyCurrentReceiving(sessionValue()); await Promise.resolve()
|
||||
const second = vm.applyCurrentReceiving(sessionValue()); await Promise.resolve(); assert.equal(calls, 3)
|
||||
pending.resolve(); await Promise.all([first, second]); assert.equal(calls, 3)
|
||||
const photo = { downloadPath: '/retry' }; let attempts = 0
|
||||
const download = async () => { if (++attempts === 1) throw new Error('offline'); return 'local:retry' }
|
||||
await assert.rejects(store.restoreReceivingPhoto(photo, download), /offline/)
|
||||
assert.equal(photo.status, 'error'); assert.equal(await store.restoreReceivingPhoto(photo, download), 'local:retry'); assert.equal(attempts, 2)
|
||||
})
|
||||
|
||||
test('hide and unload discard queued scans and cancel drain scheduled by an in-flight request; return refreshes facts', async () => {
|
||||
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
|
||||
const pending = defer(); const { vm, definition } = page(); let submissions = 0, refreshes = 0
|
||||
vm.dispatchHardwareScan = () => { submissions++ }; vm.bootstrap = async () => { refreshes++ }
|
||||
vm.scanQueue = [{ value: 'SECOND', target: 'carton' }]
|
||||
const running = vm.run('already sent', async () => { submissions++; await pending.promise })
|
||||
definition.onHide.call(vm); pending.resolve(); await running
|
||||
await new Promise(resolve => setTimeout(resolve, 5)); assert.equal(submissions, 1); assert.equal(vm.scanQueue.length, 0)
|
||||
definition.onShow.call(vm); assert.equal(refreshes, 1); assert.equal(submissions, 1)
|
||||
vm.scanQueue.push({ value: 'THIRD' }); vm.scheduleScanDrain(); definition.onUnload.call(vm)
|
||||
await new Promise(resolve => setTimeout(resolve, 5)); assert.equal(submissions, 1)
|
||||
})
|
||||
|
||||
test('hide during cargo resolution prevents the subsequent automatic dropship write, including quick return', async () => {
|
||||
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
|
||||
const pending = defer(); let writes = 0
|
||||
const { vm, definition } = page({ resolveCurrentCarton: () => pending.promise, receiveCurrentWholePallet: async () => { writes++ } })
|
||||
vm.bootstrap = async () => {}
|
||||
const running = vm.resolveCarton('DP-1'); definition.onHide.call(vm); definition.onShow.call(vm)
|
||||
pending.resolve({ operationMode: 'DROPSHIP_PALLET', sourcePalletNo: 'DP-1' }); await running
|
||||
assert.equal(writes, 0); assert.equal(vm.scanQueue.length, 0)
|
||||
})
|
||||
|
||||
test('already sent receipt retains successful server fact after hide without draining another scan', async () => {
|
||||
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
|
||||
const pending = defer(); let writes = 0, reads = 0
|
||||
const { vm, definition } = page({ resolveCurrentCarton: async () => ({ operationMode: 'DROPSHIP_PALLET', sourcePalletNo: 'DP-1' }),
|
||||
receiveCurrentWholePallet: async () => { writes++; return pending.promise },
|
||||
getCurrentReceiving: async () => { reads++; return sessionValue({ receivedPackages: 1 }) } })
|
||||
const running = vm.resolveCarton('DP-1'); await Promise.resolve(); assert.equal(writes, 1)
|
||||
vm.scanQueue.push({ value: 'DP-2', target: 'carton' }); definition.onHide.call(vm)
|
||||
pending.resolve({ receivedPackages: 1 }); await running
|
||||
assert.equal(writes, 1); assert.equal(reads, 1); assert.equal(store.receiving.current.receivedPackages, 1)
|
||||
assert.equal(store.receiving.lastReceipt.receivedPackages, 1); assert.equal(vm.scanQueue.length, 0)
|
||||
})
|
||||
|
||||
test('late old photo download never overwrites replacement or cross-account preview', async () => {
|
||||
store.resetReceivingSession(); const first = defer(), calls = []
|
||||
const { vm, deps } = page({ downloadCurrentUnloadPhoto: async path => { calls.push(path); if (calls.length <= 3) await first.promise; return `local:${path}` } })
|
||||
const old = vm.applyCurrentReceiving(sessionValue()); await Promise.resolve()
|
||||
deps.session.tenantId = 'new-customer'
|
||||
const changed = sessionValue(); changed.photos[0] = { ...changed.photos[0], fileId: 'new', downloadPath: '/signed/new' }
|
||||
await vm.applyCurrentReceiving(changed); assert.equal(calls.length, 6)
|
||||
first.resolve(); await old
|
||||
assert.equal(store.receiving.photoSlots[0].fileId, 'new'); assert.equal(store.receiving.photoSlots[0].localPath, 'local:/signed/new')
|
||||
})
|
||||
@@ -68,12 +68,12 @@ test('camera replaces only one fixed slot and failed retake preserves its previo
|
||||
assert.match(page, /\.photo-grid\{display:grid;grid-template-columns:1fr/)
|
||||
assert.match(page, /grid-template-columns:82px minmax\(0,1fr\) 78px/)
|
||||
assert.match(page, /class="photo-role-info"/)
|
||||
assert.match(page, /onShow\(\)[\s\S]*this\.inboundMode && \(!this\.bootstrapped \|\| this\.phase === 'candidates'\)[\s\S]*this\.bootstrap\(\)/)
|
||||
assert.match(page, /onShow\(\)[\s\S]*this\.inboundMode && \(!this\.bootstrapped \|\| this\.phase === 'candidates' \|\| this\._resumeReceiving\)[\s\S]*this\.bootstrap\(\)/)
|
||||
assert.match(page, /await this\.applyCurrentReceiving\(current\);[\s\S]*this\.bootstrapped = true/)
|
||||
})
|
||||
|
||||
test('local thumbnail, preview, and authenticated session restore are wired to the frozen contract', () => {
|
||||
assert.match(page, /restorePhotoPreviews\(\)[\s\S]*photo\?\.downloadPath[\s\S]*downloadCurrentUnloadPhoto\(photo\.downloadPath\)/)
|
||||
assert.match(page, /restorePhotoPreviews\(\)[\s\S]*photo\?\.downloadPath[\s\S]*restoreReceivingPhoto\(photo, downloadCurrentUnloadPhoto\)/)
|
||||
assert.deepEqual(photoUiBlockers().map((item) => item.code), [])
|
||||
assert.ok(photoUiBlockers().every((item) => item.deploymentBlocker === true && item.reason))
|
||||
})
|
||||
@@ -81,7 +81,7 @@ test('local thumbnail, preview, and authenticated session restore are wired to t
|
||||
test('domestic cabinet photos are read-only, previewable and non-blocking in sign and receive phases', () => {
|
||||
assert.match(page, /国内装柜照片(只读)/)
|
||||
assert.match(page, /domesticPhotosExpanded/)
|
||||
assert.match(page, /downloadCurrentDomesticLoadingPhoto\(photo\.downloadPath\)/)
|
||||
assert.match(page, /restoreReceivingPhoto\(photo, downloadCurrentDomesticLoadingPhoto\)/)
|
||||
assert.match(page, /async loadDomesticPhoto\(photo\)[\s\S]*catch \(_\) \{ photo\.status = 'error' \}/)
|
||||
assert.match(page, /previewDomesticPhoto\(photo\)[\s\S]*uni\.previewImage/)
|
||||
assert.match(inbound, /downloadCurrentDomesticLoadingPhoto/)
|
||||
|
||||
@@ -13,16 +13,16 @@ test('packing preview keeps customer scope internal and exposes stable order ref
|
||||
|
||||
test('return packing API uses exact PDA inventory routes without submitting customer or warehouse scope', async () => {
|
||||
const calls = []
|
||||
session.apiBase = '/overseas-api'; session.token = 'packing-token'
|
||||
session.apiBase = '/api'; session.token = 'packing-token'
|
||||
globalThis.uni = { request(options) { calls.push({ method: options.method, url: options.url, data: options.data, authorization: options.header.Authorization }); let data = {}; if (options.url.includes('/packing/orders')) data = { customerId: '100', orders: [], skus: [] }; else if (options.url.endsWith('/cartons/merge')) data = { cartonMark: 'RC-1', skuCode: 'SKU-1', totalQuantity: 2, label: { cartonMark: 'RC-1', items: [] } }; else data = { cartonMark: 'RC/1', totalQuantity: 2, items: [] }; options.success({ statusCode: 200, data: { code: '200', data }, header: {} }) } }
|
||||
await findPackingOrders('RT/1')
|
||||
const request = { returnNos: ['RT/1'], items: [{ skuCode: 'SKU-1', quantity: 2 }], length: 10, width: 20, height: 30, weight: 4, idempotencyKey: 'merge-key' }
|
||||
await mergeReturnCartons(request)
|
||||
await getReturnCartonLabel('RC/1')
|
||||
assert.deepEqual(calls.map((item) => item.url), [
|
||||
'/overseas-api/pda/v1/last-leg/return-inventory/packing/orders?returnNo=RT%2F1',
|
||||
'/overseas-api/pda/v1/last-leg/return-inventory/cartons/merge',
|
||||
'/overseas-api/pda/v1/last-leg/return-inventory/cartons/RC%2F1/label'
|
||||
'/api/pda/v1/last-leg/return-inventory/packing/orders?returnNo=RT%2F1',
|
||||
'/api/pda/v1/last-leg/return-inventory/cartons/merge',
|
||||
'/api/pda/v1/last-leg/return-inventory/cartons/RC%2F1/label'
|
||||
])
|
||||
assert.deepEqual(calls[1].data, request)
|
||||
assert.ok(calls.every((item) => item.authorization === 'Bearer packing-token'))
|
||||
@@ -30,13 +30,13 @@ test('return packing API uses exact PDA inventory routes without submitting cust
|
||||
})
|
||||
|
||||
test('return packing lookup treats not found as an empty preview', async () => {
|
||||
session.apiBase = '/overseas-api'; session.token = 'packing-token'
|
||||
session.apiBase = '/api'; session.token = 'packing-token'
|
||||
globalThis.uni = { request(options) { options.success({ statusCode: 404, data: { code: 'A0404', message: '没有匹配的可合箱库存' }, header: {} }) } }
|
||||
assert.deepEqual(await findPackingOrders('MISSING-RETURN'), { customerKey: '', orders: [], skus: [] })
|
||||
})
|
||||
|
||||
test('return packing lookup still rejects real service errors', async () => {
|
||||
session.apiBase = '/overseas-api'; session.token = 'packing-token'
|
||||
session.apiBase = '/api'; session.token = 'packing-token'
|
||||
globalThis.uni = { request(options) { options.success({ statusCode: 500, data: { code: 'B0500', message: '服务暂不可用' }, header: {} }) } }
|
||||
await assert.rejects(() => findPackingOrders('RT-1'), (error) => error.code === 'B0500')
|
||||
})
|
||||
|
||||
@@ -24,7 +24,7 @@ test('confirmed receipt normalization preserves generated carton and batch refer
|
||||
|
||||
test('return receipt API calls only the dedicated PDA routes and preserves the receipt payload', async () => {
|
||||
const calls = []
|
||||
session.apiBase = '/overseas-api'; session.token = 'return-token'
|
||||
session.apiBase = '/api'; session.token = 'return-token'
|
||||
globalThis.uni = {
|
||||
request(options) {
|
||||
calls.push({ method: options.method, url: options.url, data: options.data, authorization: options.header.Authorization })
|
||||
@@ -40,9 +40,9 @@ test('return receipt API calls only the dedicated PDA routes and preserves the r
|
||||
const confirm = { returnNo: 'RT-001', trackingNo: 'TRACK-1', startedBy: 'SKU', palletCode: 'PLT-1', items: [{ skuCode: 'SKU-1', receivedQuantity: 2 }], idempotencyKey: 'return-key' }
|
||||
await confirmReturnReceipt(confirm)
|
||||
assert.deepEqual(calls.map((item) => item.url), [
|
||||
'/overseas-api/pda/v1/last-leg/return-receipts/start',
|
||||
'/overseas-api/pda/v1/last-leg/return-receipts/sku-candidates',
|
||||
'/overseas-api/pda/v1/last-leg/return-receipts/confirm'
|
||||
'/api/pda/v1/last-leg/return-receipts/start',
|
||||
'/api/pda/v1/last-leg/return-receipts/sku-candidates',
|
||||
'/api/pda/v1/last-leg/return-receipts/confirm'
|
||||
])
|
||||
assert.deepEqual(calls[0].data, { returnNo: 'RT-001' })
|
||||
assert.deepEqual(calls[1].data, { skuCode: 'SKU-1' })
|
||||
@@ -52,7 +52,7 @@ test('return receipt API calls only the dedicated PDA routes and preserves the r
|
||||
})
|
||||
|
||||
test('return receipt lookup treats A0404 and HTTP 404 as empty results', async () => {
|
||||
session.apiBase = '/overseas-api'; session.token = 'return-token'
|
||||
session.apiBase = '/api'; session.token = 'return-token'
|
||||
let requestCount = 0
|
||||
globalThis.uni = {
|
||||
request(options) {
|
||||
@@ -66,7 +66,7 @@ test('return receipt lookup treats A0404 and HTTP 404 as empty results', async (
|
||||
})
|
||||
|
||||
test('return receipt lookup still rejects real service errors', async () => {
|
||||
session.apiBase = '/overseas-api'; session.token = 'return-token'
|
||||
session.apiBase = '/api'; session.token = 'return-token'
|
||||
globalThis.uni = {
|
||||
request(options) {
|
||||
options.success({ statusCode: 500, data: { code: 'B0500', message: '服务暂不可用' }, header: {} })
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import vm from 'node:vm'
|
||||
|
||||
function setup(confirm, saved = new Map(), scope = 'operator:warehouse') {
|
||||
const context = vm.createContext({ reactive: value => value, PortalHeader: {}, ScanTargetInput: {},
|
||||
playSuccessSound() {}, playErrorSound() {}, confirmReturnReceipt: confirm,
|
||||
uni: { setStorageSync: (key, value) => saved.set(key, structuredClone(value)), getStorageSync: key => saved.get(key), removeStorageSync: key => saved.delete(key) } })
|
||||
const store = readFileSync(new URL('../src/stores/warehouse-work.js', import.meta.url), 'utf8')
|
||||
vm.runInContext(store.replace(/^import .*$/mg, '').replace(/export /g, ''), context)
|
||||
const page = readFileSync(new URL('../src/pages/return-receipt/return-receipt.vue', import.meta.url), 'utf8')
|
||||
const script = page.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1]
|
||||
const component = vm.runInContext(script.replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), context)
|
||||
vm.runInContext(`restoreWarehouseWork(${JSON.stringify(scope)})`, context)
|
||||
const instance = Object.assign({}, component.methods, {
|
||||
unitType: 'PALLET', palletBoxCount: 1, canConfirm: true, busy: false,
|
||||
current: { returnNo: 'RT-TEST', expectedItems: [{ skuCode: 'SKU-TEST', expectedQuantity: 10, receivedQuantity: 0 }] },
|
||||
selectedItems: [{ skuCode: 'SKU-TEST' }], photoFileIds: [], trackingNo: ' TRACK-TEST ', startedBy: 'SKU',
|
||||
palletCode: ' PLT-TEST ', palletLength: 10, palletWidth: 10, palletHeight: 10, palletWeight: 1,
|
||||
quantity: () => 2, boxCount: () => 1
|
||||
})
|
||||
return instance
|
||||
}
|
||||
|
||||
test('PALLET page submits the required palletCode through the actual confirmation method', async () => {
|
||||
const calls = []
|
||||
const page = setup(async payload => { calls.push(JSON.parse(JSON.stringify(payload))); return { palletCode: payload.palletCode } })
|
||||
await page.confirmReceipt()
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0].palletCode, 'PLT-TEST')
|
||||
assert.equal(calls[0].unitCode, undefined)
|
||||
assert.deepEqual(Object.keys(calls[0]).sort(), ['height', 'idempotencyKey', 'items', 'length', 'palletCode', 'returnNo', 'startedBy', 'totalBoxCount', 'trackingNo', 'unitType', 'weight', 'width'].sort())
|
||||
assert.equal(calls[0].unitType, 'PALLET')
|
||||
assert.equal(calls[0].totalBoxCount, 1)
|
||||
assert.deepEqual(Object.keys(calls[0].items[0]).sort(), ['boxCount', 'receivedQuantity', 'skuCode'].sort())
|
||||
assert.equal(calls[0].returnNo, 'RT-TEST')
|
||||
assert.equal(calls[0].trackingNo, 'TRACK-TEST')
|
||||
assert.equal(calls[0].items[0].receivedQuantity, 2)
|
||||
assert.ok(calls[0].idempotencyKey)
|
||||
assert.equal(page.current, null)
|
||||
})
|
||||
|
||||
test('PALLET response failure keeps the draft and same idempotency key for retry', async () => {
|
||||
const calls = []
|
||||
const page = setup(async payload => { calls.push(JSON.parse(JSON.stringify(payload))); throw new Error('Response lost') })
|
||||
await page.confirmReceipt()
|
||||
assert.equal(page.current.returnNo, 'RT-TEST')
|
||||
assert.equal(page.busy, false)
|
||||
assert.equal(page.error.message, 'Response lost')
|
||||
await page.confirmReceipt()
|
||||
assert.equal(calls.length, 2)
|
||||
assert.equal(calls[0].idempotencyKey, calls[1].idempotencyKey)
|
||||
})
|
||||
+37
-20
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
PDA_AUTHORITY, PDA_ROLE, applyWarehouseProfile, replaceTokens, restoreSession, session,
|
||||
decodeAccessToken, signInWithTokens, signOut, validatePdaTokenPair
|
||||
decodeAccessToken, signInWithTokens, signOut, validatePdaToken
|
||||
} from '../src/stores/session.js'
|
||||
import { receiving } from '../src/stores/receiving.js'
|
||||
import { fulfillment, restoreFulfillmentDraft, updateFulfillmentDraft } from '../src/stores/fulfillment.js'
|
||||
@@ -24,9 +24,9 @@ function tokenPair(overrides = {}) {
|
||||
overseas_warehouse_id: 900, exp: future()
|
||||
}
|
||||
const domestic = { ...base, username: 'picker-42', ...(overrides.domestic || {}) }
|
||||
const overseas = { ...base, ...(overrides.overseas || {}) }
|
||||
|
||||
return {
|
||||
accessToken: jwt(domestic), overseasAccessToken: jwt(overseas),
|
||||
accessToken: jwt(domestic),
|
||||
refreshToken: overrides.refreshToken ?? 'refresh-42',
|
||||
roles: Object.hasOwn(overrides, 'roles') ? overrides.roles : [PDA_ROLE],
|
||||
authorities: Object.hasOwn(overrides, 'authorities') ? overrides.authorities : [PDA_AUTHORITY]
|
||||
@@ -46,17 +46,17 @@ function storage() {
|
||||
test('valid platform warehouse picker tokens freeze the same operator and warehouse', () => {
|
||||
storage(); signOut()
|
||||
const tokens = tokenPair()
|
||||
const pair = validatePdaTokenPair(tokens.accessToken, tokens.overseasAccessToken, tokens)
|
||||
const pair = validatePdaToken(tokens.accessToken, tokens)
|
||||
assert.equal(pair.warehouseId, '900')
|
||||
const user = signInWithTokens(tokens)
|
||||
assert.deepEqual(user.roles, [PDA_ROLE])
|
||||
assert.equal(user.warehouseId, '900')
|
||||
assert.equal(user.warehouse, '')
|
||||
assert.equal(session.tenantId, '0')
|
||||
assert.equal(session.token, tokens.overseasAccessToken)
|
||||
assert.equal(session.token, tokens.accessToken)
|
||||
assert.deepEqual(session.authorization, { roles: [PDA_ROLE], authorities: [PDA_AUTHORITY] })
|
||||
assert.equal(decodeAccessToken(tokens.overseasAccessToken).roles, undefined)
|
||||
assert.equal(decodeAccessToken(tokens.overseasAccessToken).authorities, undefined)
|
||||
assert.equal(decodeAccessToken(tokens.accessToken).roles, undefined)
|
||||
assert.equal(decodeAccessToken(tokens.accessToken).authorities, undefined)
|
||||
})
|
||||
|
||||
test('JWT decoding preserves unquoted snowflake subjects and warehouse IDs exactly', () => {
|
||||
@@ -81,11 +81,11 @@ test('login fails closed for wrong role, authority, warehouse, application, or t
|
||||
tokenPair({ authorities: [] }),
|
||||
tokenPair({ roles: null }),
|
||||
tokenPair({ authorities: null }),
|
||||
tokenPair({ overseas: { overseas_warehouse_id: null } }),
|
||||
tokenPair({ domestic: { overseas_warehouse_id: null } }),
|
||||
tokenPair({ domestic: { app_code: 'MERCHANT', tenant_id: 100 } }),
|
||||
tokenPair({ overseas: { user_id: 99 } }),
|
||||
tokenPair({ overseas: { membership_id: 99 } }),
|
||||
tokenPair({ overseas: { overseas_warehouse_id: 901 } })
|
||||
tokenPair({ domestic: { user_id: null } }),
|
||||
tokenPair({ domestic: { membership_id: null } }),
|
||||
tokenPair({ domestic: { exp: null } })
|
||||
]
|
||||
for (const tokens of invalidPairs) {
|
||||
assert.throws(() => signInWithTokens(tokens))
|
||||
@@ -93,22 +93,40 @@ test('login fails closed for wrong role, authority, warehouse, application, or t
|
||||
}
|
||||
})
|
||||
|
||||
test('refresh replacement validates both tokens before atomically changing session', () => {
|
||||
test('refresh replacement validates the unified token before atomically changing session', () => {
|
||||
storage(); signOut()
|
||||
const original = tokenPair()
|
||||
signInWithTokens(original)
|
||||
const before = { domestic: session.domesticToken, overseas: session.token, refresh: session.refreshToken }
|
||||
const before = { access: session.token, refresh: session.refreshToken }
|
||||
const invalid = tokenPair({ authorities: [], refreshToken: 'invalid-refresh' })
|
||||
assert.throws(() => replaceTokens(invalid), /lastleg:pda:operate/)
|
||||
assert.deepEqual({ domestic: session.domesticToken, overseas: session.token, refresh: session.refreshToken }, before)
|
||||
assert.deepEqual({ access: session.token, refresh: session.refreshToken }, before)
|
||||
|
||||
const replacement = tokenPair({ refreshToken: 'refresh-43' })
|
||||
replaceTokens(replacement)
|
||||
assert.equal(session.domesticToken, replacement.accessToken)
|
||||
assert.equal(session.token, replacement.overseasAccessToken)
|
||||
assert.equal(session.token, replacement.accessToken)
|
||||
assert.equal(session.refreshToken, 'refresh-43')
|
||||
})
|
||||
|
||||
test('refresh cannot carry an old operation into another user, membership, tenant or warehouse', () => {
|
||||
storage(); signOut(); signInWithTokens(tokenPair())
|
||||
const original = session.token
|
||||
for (const domestic of [{ user_id: 99 }, { membership_id: 85 }, { tenant_id: 1 }, { overseas_warehouse_id: 901 }]) {
|
||||
assert.throws(() => replaceTokens(tokenPair({ domestic })), /主体或绑定仓库已变更|不属于平台仓库作业端/)
|
||||
assert.equal(session.token, original)
|
||||
assert.equal(session.currentUser.warehouseId, '900')
|
||||
}
|
||||
})
|
||||
|
||||
test('legacy dual-token storage requires a fresh login', () => {
|
||||
const values = storage(); signOut(); signInWithTokens(tokenPair())
|
||||
const saved = structuredClone([...values.values()][0])
|
||||
delete saved.version
|
||||
signOut(); values.set('overseas-pda-auth-session', saved)
|
||||
assert.equal(restoreSession(), false)
|
||||
assert.equal(session.currentUser, null)
|
||||
})
|
||||
|
||||
test('restore rebuilds identity from claims and removes expired or tampered storage', () => {
|
||||
const values = storage(); signOut()
|
||||
signInWithTokens(tokenPair())
|
||||
@@ -123,14 +141,14 @@ test('restore rebuilds identity from claims and removes expired or tampered stor
|
||||
assert.equal(restoreSession(), false)
|
||||
assert.equal(values.has('overseas-pda-auth-session'), false)
|
||||
|
||||
const tampered = { ...saved, token: tokenPair({ overseas: { overseas_warehouse_id: 901 } }).overseasAccessToken }
|
||||
const tampered = { ...saved, token: tokenPair({ domestic: { overseas_warehouse_id: 901 } }).accessToken }
|
||||
signOut(); values.set('overseas-pda-auth-session', tampered)
|
||||
assert.equal(restoreSession(), false)
|
||||
assert.equal(session.currentUser, null)
|
||||
assert.equal(values.has('overseas-pda-auth-session'), false)
|
||||
|
||||
const expired = tokenPair({ domestic: { exp: 1 }, overseas: { exp: 1 } })
|
||||
values.set('overseas-pda-auth-session', { ...saved, domesticToken: expired.accessToken, token: expired.overseasAccessToken })
|
||||
const expired = tokenPair({ domestic: { exp: 1 } })
|
||||
values.set('overseas-pda-auth-session', { ...saved, token: expired.accessToken })
|
||||
assert.equal(restoreSession(), false)
|
||||
assert.equal(values.has('overseas-pda-auth-session'), false)
|
||||
})
|
||||
@@ -152,7 +170,6 @@ test('sign out clears tokens, frozen warehouse identity, and every receiving or
|
||||
signOut()
|
||||
assert.equal(session.currentUser, null)
|
||||
assert.equal(session.token, '')
|
||||
assert.equal(session.domesticToken, '')
|
||||
assert.equal(session.refreshToken, '')
|
||||
assert.deepEqual(session.authorization, { roles: [], authorities: [] })
|
||||
assert.equal(values.has('overseas-pda-auth-session'), false)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import vm from 'node:vm'
|
||||
import { getDispatchHandoverStatus, normalizeHandoverStatus } from '../src/api/trip-handover.js'
|
||||
import { session } from '../src/stores/session.js'
|
||||
|
||||
const wire = () => ({ dispatchNo: 'PC-ONE', tripNo: 'TR-ONE', tripStatus: 'IN_TRANSIT', tripRecordVersion: '90071992547409931',
|
||||
originalDeparture: { vehiclePlate: 'ORIGINAL', driverName: '原司机' },
|
||||
deliveries: [{ deliveryNo: 'DL-DONE', status: 'DELIVERED', packages: '10', remainingPackages: 0, terminalAt: '2026-10-03T01:00:00Z', transport: { vehiclePlate: 'ORIGINAL', driverName: '原司机' } },
|
||||
{ deliveryNo: 'DL-PENDING', status: 'WAIT_DELIVERY', packages: 20, remainingPackages: 16, terminalAt: null, transport: { vehiclePlate: 'NEW', driverName: '接替司机' } }], latestHandover: null })
|
||||
|
||||
test('PDA query stays a GET in the dedicated namespace and never supplies customer or warehouse', async () => {
|
||||
const calls = []; session.apiBase = '/gateway-api'; session.token = 'test-token'
|
||||
globalThis.uni = { request(options) { calls.push(options); options.success({ statusCode: 200, data: { code: '200', data: wire() }, header: {} }) } }
|
||||
const result = await getDispatchHandoverStatus('PC-ONE')
|
||||
assert.equal(calls.length, 1); assert.equal(calls[0].method, 'GET'); assert.match(calls[0].url, /\/pda\/v1\/last-leg\/dispatches\/PC-ONE\/handover-status$/)
|
||||
assert.ok(!calls[0].data?.customerId); assert.ok(!calls[0].data?.warehouseId)
|
||||
assert.equal(result.tripRecordVersion, '90071992547409931'); assert.equal(result.deliveries[0].transport.vehiclePlate, 'ORIGINAL'); assert.equal(result.deliveries[1].transport.vehiclePlate, 'NEW')
|
||||
})
|
||||
test('normalization exposes only business references and does not replace missing fields with internal IDs', () => {
|
||||
const data = wire(); data.customerId = 'secret-customer'; data.originalDeparture = { vehicleId: 'secret-vehicle' }; data.deliveries[0].id = 'secret-id'
|
||||
const value = normalizeHandoverStatus(data)
|
||||
assert.doesNotMatch(JSON.stringify(value), /secret-/); assert.equal(value.originalDeparture.vehiclePlate, '')
|
||||
assert.equal(value.deliveries[0].packages, 10)
|
||||
assert.equal(value.deliveries[1].remainingPackages, 16)
|
||||
for (const remainingPackages of [null, '', -1, 21, '1.5']) assert.throws(() => normalizeHandoverStatus({ ...data, deliveries: [{ ...data.deliveries[1], remainingPackages }] }), /剩余箱数无效/)
|
||||
for (const packages of [null, '', -1, 0, '1.5']) assert.throws(() => normalizeHandoverStatus({ ...data, deliveries: [{ ...data.deliveries[0], packages }] }), /箱数无效/)
|
||||
})
|
||||
test('a response for another dispatch is rejected', async () => {
|
||||
session.apiBase = '/gateway-api'; session.token = 'test-token'
|
||||
globalThis.uni = { request(options) { options.success({ statusCode: 200, data: { code: '200', data: wire() }, header: {} }) } }
|
||||
await assert.rejects(getDispatchHandoverStatus('PC-OTHER'), /不属于当前排车/)
|
||||
})
|
||||
test('the component ignores stale responses after switching task and has no write commands', async () => {
|
||||
const file = await readFile(new URL('../src/components/DispatchHandoverStatus.vue', import.meta.url), 'utf8')
|
||||
const pending = []
|
||||
const context = { getDispatchHandoverStatus: () => new Promise(resolve => pending.push(resolve)), toDispatchError: error => ({ message: error.message }) }
|
||||
const source = file.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^import .*$/gm, '').replace('export default', 'component =')
|
||||
vm.runInNewContext(source, context)
|
||||
const component = context.component, state = { ...component.data(), dispatchNo: 'PC-OLD' }
|
||||
const first = component.methods.load.call(state)
|
||||
state.dispatchNo = 'PC-NEW'; const second = component.methods.load.call(state)
|
||||
pending[1]({ dispatchNo: 'PC-NEW' }); await second
|
||||
pending[0]({ dispatchNo: 'PC-OLD' }); await first
|
||||
assert.equal(state.status.dispatchNo, 'PC-NEW')
|
||||
assert.doesNotMatch(file, /confirmTripHandover|departDispatch|pickDispatchItem|POST/)
|
||||
const page = await readFile(new URL('../src/pages/dispatch/dispatch.vue', import.meta.url), 'utf8')
|
||||
assert.match(page, /DispatchHandoverStatus v-if="departed" :dispatch-no="current.dispatchNo"/)
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import { PDA_ROLE, PDA_AUTHORITY, session, signInWithTokens, signOut, getSessionRevision } from '../src/stores/session.js'
|
||||
import { receiving } from '../src/stores/receiving.js'
|
||||
import { refresh, getPendingRefresh } from '../src/api/auth.js'
|
||||
import { overseasRequest } from '../src/api/overseas.js'
|
||||
|
||||
const helperUrl = new URL('../src/api/logout-session.js', import.meta.url)
|
||||
const logoutHelper = fs.existsSync(helperUrl) ? await import(helperUrl) : {}
|
||||
const jwt = claims => [Buffer.from('{"alg":"none"}').toString('base64url'), Buffer.from(JSON.stringify(claims)).toString('base64url'), 'signature'].join('.')
|
||||
function tokens(version='old', identity={}) { return {accessToken:jwt({user_id:42,membership_id:84,tenant_id:0,app_code:'PLATFORM',overseas_warehouse_id:900,exp:Math.floor(Date.now()/1000)+3600,version,...identity}),refreshToken:`refresh-${version}`,roles:[PDA_ROLE],authorities:[PDA_AUTHORITY]} }
|
||||
const ok = data => ({statusCode:200,data:{code:'200',data},header:{}})
|
||||
function prepare(){const values=new Map(),calls=[],launches=[],toasts=[];globalThis.uni={setStorageSync:(k,v)=>values.set(k,JSON.parse(JSON.stringify(v))),getStorageSync:k=>values.get(k),removeStorageSync:k=>values.delete(k),reLaunch:o=>launches.push(o.url),showToast:o=>toasts.push(o.title)};signOut();const original=tokens();signInWithTokens(original);receiving.current={containerNo:'ORIGINAL'};return {values,calls,launches,toasts,original}}
|
||||
function page(){const source=fs.readFileSync(new URL('../src/pages/profile/profile.vue',import.meta.url),'utf8').match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^\s*import[^\n]*$/gm,'').replace('export default','return');return new Function('PortalHeader','PopupHeader','signOut','logoutCurrentSession','getSessionRevision','session',source)({}, {},signOut,logoutHelper.logoutCurrentSession,getSessionRevision,session)}
|
||||
test('actual profile confirmation clears drafts and revokes captured session before reporting success',async()=>{
|
||||
const state=prepare();uni.request=o=>{state.calls.push(o);assert.equal(session.token,'');assert.equal(receiving.current,null);o.success(ok(null))}
|
||||
const context={user:session.currentUser,closeLogout(){this.closed=true}}
|
||||
await page().methods.logout.call(context)
|
||||
assert.equal(state.calls.length,1)
|
||||
assert.match(state.calls[0].url,/\/auth\/logout$/)
|
||||
assert.deepEqual(state.calls[0].data,{refreshToken:state.original.refreshToken})
|
||||
assert.equal(state.calls[0].header.Authorization,`Bearer ${state.original.accessToken}`)
|
||||
assert.equal(state.values.has('overseas-pda-auth-session'),false)
|
||||
})
|
||||
const denied = (statusCode=401,code='A0401') => ({statusCode,data:{code,message:'denied',requestId:'logout-request',traceId:'logout-trace'},header:{}})
|
||||
test('explicit expired-access 401 refreshes captured credentials once and revokes without restoring local tokens',async()=>{
|
||||
const state=prepare(),replacement=tokens('rotated')
|
||||
session.token=tokens('expired',{exp:1}).accessToken
|
||||
uni.request=o=>{state.calls.push(o);assert.equal(session.token,'');if(state.calls.length===1)o.success(denied());else if(o.url.endsWith('/auth/refresh')){assert.deepEqual(o.data,{refreshToken:state.original.refreshToken});assert.equal(o.header.Authorization,undefined);o.success(ok(replacement))}else{o.success(ok(null));assert.equal(o.header.Authorization,`Bearer ${replacement.accessToken}`);assert.deepEqual(o.data,{refreshToken:replacement.refreshToken})}}
|
||||
const result=await logoutHelper.logoutCurrentSession()
|
||||
assert.equal(result.revoked,true);assert.equal(state.calls.length,3);assert.equal(session.currentUser,null);assert.equal(state.values.size,0)
|
||||
})
|
||||
test('unknown network and 5xx results never trigger refresh or blind logout replay',async()=>{
|
||||
for(const failure of ['network','server','business']){
|
||||
const state=prepare();uni.request=o=>{state.calls.push(o);if(failure==='network')o.fail({errMsg:'connection lost'});else o.success(denied(failure==='server'?503:200,failure==='server'?'200':'A0401'))}
|
||||
await assert.rejects(logoutHelper.logoutCurrentSession(),error=>failure==='network'?error.network===true:error.statusCode===(failure==='server'?503:200))
|
||||
assert.equal(state.calls.length,1);assert.equal(session.token,'');assert.equal(receiving.current,null)
|
||||
}
|
||||
})
|
||||
test('refresh failure preserves diagnostic evidence and does not send a second revoke',async()=>{
|
||||
const state=prepare();uni.request=o=>{state.calls.push(o);o.success(state.calls.length===1?denied():denied(503,'S0503'))}
|
||||
await assert.rejects(logoutHelper.logoutCurrentSession(),error=>error.code==='S0503'&&error.requestId==='logout-request'&&error.traceId==='logout-trace')
|
||||
assert.equal(state.calls.length,2);assert.equal(getPendingRefresh(state.original.refreshToken),null);assert.equal(session.token,'')
|
||||
})
|
||||
test('different user membership or warehouse from logout refresh cannot revoke another session',async()=>{
|
||||
for(const identity of [{user_id:43},{membership_id:85},{overseas_warehouse_id:901}]){
|
||||
const state=prepare();uni.request=o=>{state.calls.push(o);o.success(state.calls.length===1?denied():ok(tokens('wrong',identity)))}
|
||||
await assert.rejects(logoutHelper.logoutCurrentSession(),/不同作业主体/);assert.equal(state.calls.length,2);assert.equal(state.values.size,0)
|
||||
}
|
||||
})
|
||||
test('missing refreshed credentials fail closed without local restoration',async()=>{
|
||||
for(const field of ['refreshToken','accessToken']){
|
||||
const state=prepare(),replacement=tokens('invalid');delete replacement[field]
|
||||
uni.request=o=>{state.calls.push(o);o.success(state.calls.length===1?denied():ok(replacement))}
|
||||
await assert.rejects(logoutHelper.logoutCurrentSession());assert.equal(state.calls.length,2);assert.equal(session.token,'');assert.equal(state.values.size,0)
|
||||
}
|
||||
})
|
||||
test('revocation-only refresh does not load permissions or recreate a login when permission snapshots are absent',async()=>{
|
||||
const state=prepare(),replacement=tokens('revocation-only');delete replacement.roles;delete replacement.authorities
|
||||
uni.request=o=>{state.calls.push(o);o.success(state.calls.length===1?denied():o.url.endsWith('/auth/refresh')?ok(replacement):ok(null))}
|
||||
assert.equal((await logoutHelper.logoutCurrentSession()).revoked,true)
|
||||
assert.equal(state.calls.length,3);assert.equal(session.token,'');assert.deepEqual(session.authorization,{roles:[],authorities:[]});assert.equal(state.values.size,0)
|
||||
})
|
||||
test('second revoke 401 is terminal and cannot start another refresh loop',async()=>{
|
||||
const state=prepare();uni.request=o=>{state.calls.push(o);o.success(o.url.endsWith('/auth/refresh')?ok(tokens('rotated')):denied())}
|
||||
await assert.rejects(logoutHelper.logoutCurrentSession(),error=>error.statusCode===401)
|
||||
assert.equal(state.calls.length,3);assert.equal(state.calls.filter(o=>o.url.endsWith('/auth/refresh')).length,1)
|
||||
})
|
||||
test('profile duplicate confirmation shares its pending operation',async()=>{
|
||||
const state=prepare();uni.request=o=>state.calls.push(o)
|
||||
const context={user:session.currentUser,closeLogout(){this.closed=true}},methods=page().methods
|
||||
const first=methods.logout.call(context),second=methods.logout.call(context)
|
||||
assert.equal(first,second);assert.equal(state.calls.length,1);assert.equal(state.launches.length,1)
|
||||
state.calls[0].success(ok(null));await first
|
||||
})
|
||||
test('old logout result cannot clear redirect or warn over a subsequent new login',async()=>{
|
||||
for(const failed of [false,true]){
|
||||
const state=prepare();uni.request=o=>state.calls.push(o)
|
||||
const context={user:session.currentUser,closeLogout(){}},pending=page().methods.logout.call(context)
|
||||
const replacement=tokens('new-login',{user_id:43,membership_id:85,overseas_warehouse_id:901})
|
||||
signInWithTokens(replacement);receiving.current={containerNo:'NEW-USER'}
|
||||
if(failed)state.calls[0].fail({errMsg:'old connection lost'});else state.calls[0].success(ok(null))
|
||||
await pending
|
||||
assert.equal(session.token,replacement.accessToken);assert.equal(receiving.current.containerNo,'NEW-USER');assert.equal(state.launches.length,1);assert.equal(state.toasts.length,0)
|
||||
}
|
||||
})
|
||||
test('logout joins actual in-flight business refresh and revokes its rotation while preserving a new login',async()=>{
|
||||
const state=prepare();let refreshRequest
|
||||
uni.request=o=>{state.calls.push(o);if(o.url.endsWith('/auth/refresh'))refreshRequest=o;else if(o.url.endsWith('/auth/logout'))o.success(ok(null));else o.success(denied())}
|
||||
const business=overseasRequest('GET','/pda/v1/last-leg/receiving-containers/current').catch(error=>error)
|
||||
await new Promise(resolve=>setImmediate(resolve));assert.ok(refreshRequest)
|
||||
const logout=logoutHelper.logoutCurrentSession()
|
||||
const newer=tokens('new-login',{user_id:43,membership_id:85,overseas_warehouse_id:901});signInWithTokens(newer)
|
||||
const rotated=tokens('rotated-old');refreshRequest.success(ok(rotated))
|
||||
assert.equal((await logout).revoked,true);assert.match((await business).message,/会话已变更/)
|
||||
assert.equal(state.calls.filter(o=>o.url.endsWith('/auth/refresh')).length,1)
|
||||
const revoked=state.calls.find(o=>o.url.endsWith('/auth/logout'));assert.deepEqual(revoked.data,{refreshToken:rotated.refreshToken});assert.equal(revoked.header.Authorization,`Bearer ${rotated.accessToken}`)
|
||||
assert.equal(session.token,newer.accessToken);assert.deepEqual(state.launches,[])
|
||||
})
|
||||
test('unknown in-flight refresh never rotates captured credentials again or reports remote success',async()=>{
|
||||
const state=prepare();uni.request=o=>state.calls.push(o)
|
||||
const inFlight=refresh(state.original.refreshToken).catch(error=>error)
|
||||
const logout=logoutHelper.logoutCurrentSession();const rejected=assert.rejects(logout,error=>error.network===true)
|
||||
state.calls[0].fail({errMsg:'refresh result unknown'});await rejected;await inFlight
|
||||
assert.equal(state.calls.length,1);assert.equal(session.token,'');assert.equal(getPendingRefresh(state.original.refreshToken),null)
|
||||
})
|
||||
test('ordinary local signOut and cancelled profile dialog never contact Auth',()=>{
|
||||
const state=prepare();uni.request=o=>state.calls.push(o)
|
||||
page().methods.closeLogout.call({logoutOpen:true,$refs:{logoutPopup:{close(){}}}})
|
||||
assert.equal(session.token,state.original.accessToken);signOut();assert.equal(state.calls.length,0);assert.equal(session.token,'')
|
||||
})
|
||||
test('no captured credentials require no remote request',async()=>{
|
||||
const state=prepare();signOut();uni.request=o=>state.calls.push(o)
|
||||
assert.equal((await logoutHelper.logoutCurrentSession()).revoked,false);assert.equal(state.calls.length,0)
|
||||
})
|
||||
@@ -111,6 +111,16 @@ test('warehouse transfer restores uploaded photos and exposes honest pallet labe
|
||||
assert.doesNotMatch(page, /批量打印|printAllPallets/)
|
||||
})
|
||||
|
||||
test('warehouse transfer enforces the photo limit and restores pending server uploads', async () => {
|
||||
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
|
||||
assert.match(page, /const MAX_MEASUREMENT_PHOTOS = 20/)
|
||||
assert.match(page, /photos = await listWarehouseTransferMeasurementPhotos\(transferNo\)/)
|
||||
assert.match(page, /const uploadPaths = paths\.slice\(0, remaining\)/)
|
||||
assert.match(page, /this\.photoFileIds\.length >= MAX_MEASUREMENT_PHOTOS/)
|
||||
assert.match(page, /await this\.restorePhotos\(transferNo\)/)
|
||||
assert.match(page, /at most 20\|最多\.\?20\|supports at most/)
|
||||
})
|
||||
|
||||
test('warehouse transfer always requests newly generated pallets', async () => {
|
||||
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
|
||||
assert.doesNotMatch(page, /托盘码\s*<small>选填|扫描或输入当前托盘码/)
|
||||
|
||||
@@ -20,7 +20,7 @@ const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
|
||||
|
||||
function requestMock() {
|
||||
const calls = []
|
||||
session.apiBase = '/overseas-api'; session.token = 'warehouse-token'
|
||||
session.apiBase = '/api'; session.token = 'warehouse-token'
|
||||
globalThis.uni = {
|
||||
request(options) {
|
||||
calls.push({ method: options.method, url: options.url, data: options.data, authorization: options.header.Authorization })
|
||||
|
||||
+8
-8
@@ -25,12 +25,14 @@ function loggedProxy(name, options) {
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const domesticTarget = env.API_PROXY_TARGET || 'http://127.0.0.1:9000'
|
||||
const overseasTarget = env.OVERSEAS_API_PROXY_TARGET || 'http://127.0.0.1:9300'
|
||||
const preserveOverseasPrefix = env.OVERSEAS_API_PROXY_PRESERVE_PREFIX === 'true'
|
||||
const apiTarget = env.API_PROXY_TARGET || 'http://127.0.0.1:9000'
|
||||
return {
|
||||
base: process.env.UNI_PLATFORM === 'h5' && mode === 'production' ? '/' : undefined,
|
||||
plugins: [uni()],
|
||||
define: {
|
||||
'import.meta.env.VITE_PDA_UPDATE_URL': JSON.stringify(env.VITE_PDA_UPDATE_URL || ''),
|
||||
'import.meta.env.VITE_PDA_DEV_USERNAME': JSON.stringify(env.VITE_PDA_DEV_USERNAME || ''),
|
||||
'import.meta.env.VITE_PDA_DEV_PASSWORD': JSON.stringify(env.VITE_PDA_DEV_PASSWORD || ''),
|
||||
__VUE_I18N_FULL_INSTALL__: true,
|
||||
__VUE_I18N_LEGACY_API__: false,
|
||||
__VUE_I18N_PROD_DEVTOOLS__: false,
|
||||
@@ -38,11 +40,9 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': loggedProxy('auth', { target: domesticTarget, changeOrigin: true }),
|
||||
'/overseas-api': loggedProxy('overseas', {
|
||||
target: overseasTarget,
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => preserveOverseasPrefix ? path : path.replace(/^\/overseas-api/, '/api')
|
||||
'/gateway-api': loggedProxy('api', {
|
||||
target: apiTarget, changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/gateway-api/, '/api')
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user