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