Compare commits
19
Commits
test
..
release/test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfd4fb06c4 | ||
|
|
f39303ea24 | ||
|
|
b663aac4c7 | ||
|
|
81f63e5d26 | ||
|
|
51109ac852 | ||
|
|
5fcc51d73c | ||
|
|
2777b703d8 | ||
|
|
5dc2a87a7b | ||
|
|
8cce909c78 | ||
|
|
53cb68a8bc | ||
|
|
1aae3b8135 | ||
|
|
54764156bf | ||
|
|
e699e9d9ba | ||
|
|
7aebbd2a70 | ||
|
|
8d2d80ec11 | ||
|
|
7ba6a24505 | ||
|
|
707711b3bd | ||
|
|
a84f263452 | ||
|
|
fb61ada3ea |
@@ -1,10 +0,0 @@
|
|||||||
# Only compiled H5 assets and image configuration enter the build context.
|
|
||||||
**
|
|
||||||
!deploy/
|
|
||||||
!deploy/Dockerfile.h5
|
|
||||||
!deploy/nginx.conf
|
|
||||||
!unpackage/
|
|
||||||
!unpackage/dist/
|
|
||||||
!unpackage/dist/build/
|
|
||||||
!unpackage/dist/build/web/
|
|
||||||
!unpackage/dist/build/web/**
|
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
## 开发与发布边界
|
## 开发与发布边界
|
||||||
|
|
||||||
|
- H5 在 TEST/灰度均上传静态文件至宿主机 Nginx 站点;禁止重建 PDA H5 容器或将页面入口转发到旧容器。发布步骤见 `README.md`,站点片段见 `deploy/nginx.conf.template`;历史灰度脚本不作现行入口。
|
||||||
- 当前已有 uni-app Vue 3 页面、`uni_modules/`、`vite.config.js`、`.env.example` 和 H5 同源代理配置。首页当前只展示“入库 → 一件代发”和“出库 → 出库拣货”;“库位查询”等其余能力保留源码与路由但不展示。项目仍没有根级依赖清单、仓库内 npm 构建命令或可核实的 Android 构建命令。H5 已使用 HBuilderX 5.15 自带编译器和 `cli.exe launch web` 验证,具体命令见 `README.md`。现有 Node.js 内置测试仅覆盖页面契约与本地状态规则。
|
- 当前已有 uni-app Vue 3 页面、`uni_modules/`、`vite.config.js`、`.env.example` 和 H5 同源代理配置。首页当前只展示“入库 → 一件代发”和“出库 → 出库拣货”;“库位查询”等其余能力保留源码与路由但不展示。项目仍没有根级依赖清单、仓库内 npm 构建命令或可核实的 Android 构建命令。H5 已使用 HBuilderX 5.15 自带编译器和 `cli.exe launch web` 验证,具体命令见 `README.md`。现有 Node.js 内置测试仅覆盖页面契约与本地状态规则。
|
||||||
- PDA 采用 uni-app 开发并支持在线更新;Android 硬件扫描已确认使用广播模式并已接入:Action=`android.intent.action.SCANRESULT`、字符串 Extra=`value`。设备型号不构成开发、发布或验收门禁;继续通过设备适配层接入,不绑定单一厂商 SDK。
|
- PDA 采用 uni-app 开发并支持在线更新;Android 硬件扫描已确认使用广播模式并已接入:Action=`android.intent.action.SCANRESULT`、字符串 Extra=`value`。设备型号不构成开发、发布或验收门禁;继续通过设备适配层接入,不绑定单一厂商 SDK。
|
||||||
- 正式作业已确认为在线模式:断网不能开始正式拣货、提交、完成任务或推进 `LOADED`,不实现离线业务提交队列;恢复网络后提示员工刷新重试。
|
- 正式作业已确认为在线模式:断网不能开始正式拣货、提交、完成任务或推进 `LOADED`,不实现离线业务提交队列;恢复网络后提示员工刷新重试。
|
||||||
@@ -42,8 +43,9 @@
|
|||||||
|
|
||||||
## 质量门禁
|
## 质量门禁
|
||||||
|
|
||||||
- 在真实目标 PDA 上验证扫描速度、连续扫描、断网阻断与恢复刷新、低电量、前后台切换和重复提交。
|
- 验证按 `../../docs/AI工程执行与协作约束.md` 的“验证范围选择”;文案/纯展示核对差异,局部逻辑指定受影响测试文件,不默认全量、构建或真机全面验收。
|
||||||
- 状态转换、扫码分类与页面契约使用 `node --test tests/*.test.mjs` 验证;新增本地行为时继续补充对应自动化测试。
|
- 扫码、断网恢复、生命周期或设备能力变化才在真实 PDA 核对对应场景;完整设备回归用于明确的发布/验收任务。
|
||||||
|
- 状态转换、扫码分类与页面契约按受影响文件执行 `node --test`;`node --test tests/*.test.mjs` 为全量入口,不因新增本地行为自动触发。
|
||||||
- 修改在线更新协议、版本比较、强制更新、安装或重启行为时运行 `node --test tests/app-update.test.mjs`;新增原生模块、权限或不兼容运行时能力必须发布 APK,不能只发布 WGT。
|
- 修改在线更新协议、版本比较、强制更新、安装或重启行为时运行 `node --test tests/app-update.test.mjs`;新增原生模块、权限或不兼容运行时能力必须发布 APK,不能只发布 WGT。
|
||||||
- 当前没有依赖清单、仓库内 npm 构建命令或可核实的 Android 构建命令;H5 必须按 `README.md` 使用 HBuilderX 5.15 CLI 实际启动后才可声称通过,不得仅凭 `vite.config.js` 声称 H5、App 打包或真机检查已通过。交付说明应明确实际验证方式、设备、系统版本和仍未覆盖的风险。
|
- 当前没有依赖清单、仓库内 npm 构建命令或可核实的 Android 构建命令;H5 必须按 `README.md` 使用 HBuilderX 5.15 CLI 实际启动后才可声称通过,不得仅凭 `vite.config.js` 声称 H5、App 打包或真机检查已通过。交付说明应明确实际验证方式、设备、系统版本和仍未覆盖的风险。
|
||||||
|
|
||||||
@@ -62,14 +64,14 @@
|
|||||||
| 业务编号展示与错误脱敏 | `utils/user-facing.js`、`tests/user-facing-identifiers.test.mjs` | 任务和作业页优先显示真实集装箱号,缺失时显示柜次号,再缺失显示“集装箱号 —”;不得展示 taskId、inboundId、orderId、loadingPlanId、containerBookingId 或长整型内部 ID,错误仍保留 code、requestId、traceId |
|
| 业务编号展示与错误脱敏 | `utils/user-facing.js`、`tests/user-facing-identifiers.test.mjs` | 任务和作业页优先显示真实集装箱号,缺失时显示柜次号,再缺失显示“集装箱号 —”;不得展示 taskId、inboundId、orderId、loadingPlanId、containerBookingId 或长整型内部 ID,错误仍保留 code、requestId、traceId |
|
||||||
| 拣货任务与箱码扫码 | `pages/picking/`、`stores/picking.js` | `api/pda-loading.js`、`utils/scan-broadcast.js`、`components/ScanTargetInput.vue`;服务端契约见 `../../baiyue-firstleg/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml` |
|
| 拣货任务与箱码扫码 | `pages/picking/`、`stores/picking.js` | `api/pda-loading.js`、`utils/scan-broadcast.js`、`components/ScanTargetInput.vue`;服务端契约见 `../../baiyue-firstleg/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml` |
|
||||||
| 一件代发清点组托状态与草稿 | `pages/dropship-palletization/dropship-palletization.vue` | `stores/dropship-palletizing.js`、`utils/dropship-palletizing-state.js`;服务端契约见 `../../baiyue-firstleg/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml`,合同 `tests/dropship-palletizing-*.test.mjs` |
|
| 一件代发清点组托状态与草稿 | `pages/dropship-palletization/dropship-palletization.vue` | `stores/dropship-palletizing.js`、`utils/dropship-palletizing-state.js`;服务端契约见 `../../baiyue-firstleg/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml`,合同 `tests/dropship-palletizing-*.test.mjs` |
|
||||||
| 装柜取证与完成 | `pages/picking/picking.vue` 的 `evidence` 阶段 | 正式边界是 `api/pda-loading.js` 的证据查询、预览、上传、作废和 `completeContainerPickingTask` 全部按当前任务与仓库进入;不得保留按入仓单/客户作用域的旧证据调用。上传队列 `utils/evidence-upload-queue.js`,合同 `tests/pda-loading-api.test.mjs`、`tests/picking-evidence-stage.test.mjs`、`tests/seal-save-idempotency.test.mjs` |
|
| 装柜取证与完成 | `pages/picking/picking.vue` 的 `evidence` 阶段 | 正式边界是 `api/pda-loading.js` 的证据查询、预览、上传、作废和 `completeContainerPickingTask` 全部按当前任务与仓库进入;不得保留按入仓单/客户作用域的旧证据调用。照片由页面直接上传,失败释放操作态,不缓存请求或自动重放;合同 `tests/pda-loading-api.test.mjs`、`tests/picking-evidence-stage.test.mjs`、`tests/seal-save-idempotency.test.mjs` |
|
||||||
| 扫描诊断 | `pages/scanner-test/` | `utils/scan-broadcast.js` 与广播 Action/Extra |
|
| 扫描诊断 | `pages/scanner-test/` | `utils/scan-broadcast.js` 与广播 Action/Extra |
|
||||||
| 作业记录与人员 | `pages/history/`、`pages/profile/` | session 与本地敏感数据清理 |
|
| 作业记录与人员 | `pages/history/`、`pages/profile/` | session 与本地敏感数据清理 |
|
||||||
| 页面与真实 API 合同 | `tests/page-contract.test.mjs`、`tests/pda-loading-api.test.mjs` | 覆盖部分拣货页面、提交门禁、库位不足反馈和国内 PDA 真实接口路径;统一由 `node --test tests/*.test.mjs` 执行 |
|
| 页面与真实 API 合同 | `tests/page-contract.test.mjs`、`tests/pda-loading-api.test.mjs` | 覆盖部分拣货页面、提交门禁、库位不足反馈和国内 PDA 真实接口路径;统一由 `node --test tests/*.test.mjs` 执行 |
|
||||||
|
|
||||||
## 全量验收索引
|
## 全量验收索引
|
||||||
|
|
||||||
当前按 PDA 全量作业链路验收。业务域文档用于核对功能来源和验收证据,不限制本次验收范围;从 `pages.json` 定位页面,沿页面 → `stores/` → `utils/` → 设备适配层检查。本表只描述 PDA 作业端,不代表后台或 Client 已具备对应能力。
|
用户明确要求 PDA 全量作业链路验收时使用本表;普通修改不触发全链路。业务域文档用于核对功能来源和验收证据;从 `pages.json` 定位页面,沿页面 → `stores/` → `utils/` → 设备适配层检查。本表只描述 PDA 作业端,不代表后台或 Client 已具备对应能力。
|
||||||
|
|
||||||
| 验收域 | PDA 范围与代码入口 | 验收首读 |
|
| 验收域 | PDA 范围与代码入口 | 验收首读 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
|
|
||||||
一件代发清点组托只调用 Firstleg 的 `/first-leg/pda-dropship` 正式接口。正式入口沿用 `pages/dropship-tasks/dropship-tasks` 和 `pages/dropship-palletization/dropship-palletization`。箱码先经 `cartons/resolve` 只读识别,确认后才调用 `cartons/count`;先扫托盘时确认箱码直接绑定当前托盘,先扫多箱时箱码进入待绑托区,后扫系统托盘码通过 `pallets/bind-pending` 批量绑定。所有写操作要求在线并携带稳定幂等标识,冲突后刷新权威任务;封托后的托盘只读并作为后续分柜、装柜的不可拆单元。
|
一件代发清点组托只调用 Firstleg 的 `/first-leg/pda-dropship` 正式接口。正式入口沿用 `pages/dropship-tasks/dropship-tasks` 和 `pages/dropship-palletization/dropship-palletization`。箱码先经 `cartons/resolve` 只读识别,确认后才调用 `cartons/count`;先扫托盘时确认箱码直接绑定当前托盘,先扫多箱时箱码进入待绑托区,后扫系统托盘码通过 `pallets/bind-pending` 批量绑定。所有写操作要求在线并携带稳定幂等标识,冲突后刷新权威任务;封托后的托盘只读并作为后续分柜、装柜的不可拆单元。
|
||||||
|
|
||||||
|
混托作业保持当前实物托盘,允许连续扫描不同客户的箱码。仓库级 `cartons/resolve` 只读返回原任务与箱内 SKU;确认清点时重新读取并领取原任务,再按原 taskId 写入,不将货物改归当前页客户。`pallets/resolve` 返回真实托盘状态和逐客户、逐票组成;未知托盘不能默认开放。切换入仓单携带已校验开放托盘,托内移箱使用箱的原任务;封托前显示各客户入仓码及箱数供核对。混托要求同作业仓、同渠道、同目的仓,封托和解封继续遵守正式后端门禁。合同及行为测试:`node --test tests/dropship*.test.mjs`。
|
||||||
|
|
||||||
登录会话集中在 `stores/session.js` 并持久化到设备,拣货会话集中在 `stores/picking.js`。认证请求由 `api/http.js` 统一处理:Access Token 临近过期时使用 Refresh Token 轮换;并发请求共享一次刷新;401 刷新成功后只重放一次;Refresh Token 明确失效时清理会话和作业草稿并返回登录页,网络或服务异常不会误清会话。产品契约要求未提交拣货草稿持久化到设备,并绑定作业员、任务和柜次,其他账号不能继承;成功提交或明确放弃后才清理。页面头部与底部主导航位于 `components/`,演示账号、模块和任务数据位于 `data/demo-data.js`,跨页面 PDA 视觉规则位于 `styles/pda.scss`。
|
登录会话集中在 `stores/session.js` 并持久化到设备,拣货会话集中在 `stores/picking.js`。认证请求由 `api/http.js` 统一处理:Access Token 临近过期时使用 Refresh Token 轮换;并发请求共享一次刷新;401 刷新成功后只重放一次;Refresh Token 明确失效时清理会话和作业草稿并返回登录页,网络或服务异常不会误清会话。产品契约要求未提交拣货草稿持久化到设备,并绑定作业员、任务和柜次,其他账号不能继承;成功提交或明确放弃后才清理。页面头部与底部主导航位于 `components/`,演示账号、模块和任务数据位于 `data/demo-data.js`,跨页面 PDA 视觉规则位于 `styles/pda.scss`。
|
||||||
|
|
||||||
拣货页会在设备端保留当前任务草稿和最近扫码结果,但正式作业以服务端事实为准:进入任务后由后端标记“拣货中”,每次有效箱码/托盘码扫描及“确认拣出、部分拣货、跳拣”操作立即写入服务端,后台智能装柜据此锁定已发生实物拣货的明细。底部提交只负责收尾当前仍未处理的明细并进入装柜取证;网络失败时页面不得先显示成功。允许部分拣货或跳拣,但每个未完成项必须填写异常原因,并挂起任务、阻断完成和 `LOADED`。
|
拣货页会在设备端保留当前任务草稿和最近扫码结果,但正式作业以服务端事实为准:进入任务后由后端标记“拣货中”,每次有效箱码/托盘码扫描及“确认拣出、部分拣货、跳拣”操作立即写入服务端,后台智能装柜据此锁定已发生实物拣货的明细。底部提交只负责收尾当前仍未处理的明细并进入装柜取证;网络失败时页面不得先显示成功。允许部分拣货或跳拣,但每个未完成项必须填写异常原因,并挂起任务、阻断完成和 `LOADED`。
|
||||||
@@ -107,18 +109,38 @@ Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查国内 PDA 的独
|
|||||||
3. 更新清单与下载地址使用 HTTPS,先在测试设备验证可选更新、强制更新、断网重试、APK 覆盖安装、wgt 重启及扫码作业回归,再切换正式清单。
|
3. 更新清单与下载地址使用 HTTPS,先在测试设备验证可选更新、强制更新、断网重试、APK 覆盖安装、wgt 重启及扫码作业回归,再切换正式清单。
|
||||||
4. 国内和海外更新记录、文件目录及发布权限保持隔离,服务端必须按请求中的 `appid` 拒绝跨应用更新包。
|
4. 国内和海外更新记录、文件目录及发布权限保持隔离,服务端必须按请求中的 `appid` 拒绝跨应用更新包。
|
||||||
|
|
||||||
## TEST H5 静态托管
|
## H5 静态托管(TEST 与灰度)
|
||||||
|
|
||||||
发布构建使用 `/pda-domestic/` 资源 base 与 hash 路由;开发服务仍使用根路径。浏览器入口为 `/pda-domestic/#/`,不能将 Vite 开发代理当作静态服务器配置。
|
国内、海外 PDA H5 统一由宿主机已有 Nginx/宝塔站点直接提供静态文件,不再构建或发布 PDA H5 Docker 镜像、Compose 服务或容器。正式部署约束见 [测试环境单机部署与配置契约](../../docs/测试环境单机部署与配置契约.md) 的“PDA H5 发布方式”;历史灰度报告不再作为发布入口。
|
||||||
|
|
||||||
国内使用本机已安装 HBuilderX:`cli.exe project open --path <本仓库绝对路径>` 后执行 `cli.exe publish web --project <本仓库绝对路径> --webHosting false --sourceMap false`,仅导出本地 Web 静态文件;本次验证编译器 5.26,不上传云托管。
|
发布构建使用 `/` 资源 base 与 hash 路由,部署到本应用独立域名根目录,浏览器入口为 `/#/`。不能放到子目录后继续共用根路径资源,也不能将 Vite 开发代理当作线上配置。
|
||||||
|
|
||||||
`deploy/Dockerfile.h5` 只复制已构建 `unpackage/dist/build/web/` 及静态 Nginx 配置,不在镜像中复制源码、依赖或重新编译。`.dockerignore` 只放行这两类输入。容器静态目录为 `/srv/pda-h5/pda-domestic/`。
|
国内使用本机已安装 HBuilderX:`cli.exe project open --path <本仓库绝对路径>` 后执行 `cli.exe publish web --project <本仓库绝对路径> --webHosting false --sourceMap false`,仅导出本地 Web 静态文件,不上传云托管。
|
||||||
|
|
||||||
从本仓库执行 `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-domestic-h5-test`,仅在外部 `firstleg-test-network` 暴露容器 80,不发布宿主端口。健康检查同时请求 `/healthz` 与 `/pda-domestic/` 的实际首页。
|
发布步骤:
|
||||||
|
|
||||||
Admin Nginx 将 `/pda-domestic/` 原样代理至 `http://pda-domestic-h5-test:80`,不去掉路径前缀;无尾斜杠入口重定向为 `/pda-domestic/`。PDA 容器只提供本应用静态目录,其他路径返回 404,缺失资源不回退首页,HTML 使用 `Cache-Control: no-store`。API 全部由 Admin 入口代理:国内 `/gateway-api/` 转统一 Gateway `/api/`,海外同源 `/api/` 保持路径。入口须保留 Authorization,支持文件上传下载及至少 11m 请求体。环境域名与上游只在部署侧配置,不修改宿主 Nginx 权限,也不编入 PDA 源码。
|
1. 构建后执行 `node scripts/verify-h5.mjs`,核对 HTML/JS/CSS 资源路径及同源 API。构建、测试仍按根规则经 `local-check.ps1` 执行。
|
||||||
|
2. 将 `unpackage/dist/build/web/` **目录内的文件**上传到该 PDA 站点现有 `root`。先上传 `assets/`、`static/` 等资源,最后替换 `index.html`,保留站点证书验证目录和站点配置;不要把外层构建目录一起嵌套上传。
|
||||||
|
3. 页面 `location /` 使用 `try_files $uri $uri/ =404` 直接读取静态文件,设置 `Cache-Control: no-store`;不得再转发到旧 PDA 容器。接口 `/gateway-api/` 独立转发到当前环境统一 Gateway `/api/`,保留 Authorization 和上传能力。
|
||||||
|
4. 首次切换前备份实际生效站点配置,合并下述片段并执行 `nginx -t`,成功后才重载。已有独立 API 规则先核对并复用,不能直接追加重复 `location`。后续仅更新静态文件时无需新建容器或重载 Nginx。
|
||||||
|
5. 检查域名首页及实际引用的 JS/CSS 均返回 200,并将响应内容的 SHA-256 与本次产物逐项比较;确认响应不是旧容器内容。再检查登录和受影响页面。首页 200 不等于接口或业务验收通过。
|
||||||
|
|
||||||
部署配置检查:`node --test tests/h5-deployment.test.mjs`。没有 Docker 运行时的本地合同检查不能替代目标主机 `docker compose config`、Nginx 启动与真实 HTTP 验收。
|
### 站点配置片段
|
||||||
|
|
||||||
构建后执行 `node scripts/verify-h5.mjs`,核对 HTML/JS/CSS 资源路径、同源 API 和无本机/旧海外配置泄漏。该检查与本地构建仅证明产物;TEST 页面登录、仓权限、业务操作和 Android 真机另行验收。
|
`deploy/nginx.conf.template` 是合并到已有 `server` 的参考片段,不是完整站点配置。保留现有域名、HTTPS、证书与宝塔 include;替换原有 root/index 及同名 location。环境地址、端口及网站目录只在部署侧配置,不提交到源码。
|
||||||
|
|
||||||
|
在部署机器上将 `PDA_SITE_ROOT` 设置为该站点真实根目录,`PDA_GATEWAY_ORIGIN` 设置为宿主机可达的当前环境 Gateway 源地址(协议、主机、端口,不含末尾 `/` 或 `/api`)。不要使用仅 Docker 网络可解析的服务名。显式只渲染这两个变量,保留 `$uri`、`$host` 和代理请求头变量:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
envsubst '${PDA_SITE_ROOT} ${PDA_GATEWAY_ORIGIN}' < deploy/nginx.conf.template
|
||||||
|
```
|
||||||
|
|
||||||
|
输出仅供核对和合并,不直接覆盖完整站点。若机器没有 `envsubst`,手工替换这两个占位符;未替换的模板不能直接加载。
|
||||||
|
|
||||||
|
### 两端统一 API 配置
|
||||||
|
|
||||||
|
App 的 `VITE_API_BASE_URL` 为统一 Gateway 绝对地址(含 `/api`);H5 使用 `VITE_H5_API_BASE_URL=/gateway-api`。`API_PROXY_TARGET` 仅供本地 Vite 开发代理使用,不随静态包发布。实际地址放在未提交环境文件;变更编译变量后须重新构建。
|
||||||
|
|
||||||
|
站点 API 规则须使用 `location ^~ /gateway-api/`,防止驾驶证等包含 `LICENSE` 的业务路径被站点敏感文件正则截获。仅更新静态包不能修复代理配置;域名入口和每层代理均须核对。
|
||||||
|
|
||||||
|
部署定向检查:`node --test tests/h5-deployment.test.mjs`,经 `local-check.ps1` 执行。该检查覆盖静态发布约束、资源 base 和开发代理重写,不替代目标主机 Nginx 校验、真实 HTTP、人工页面及 Android 真机验收。
|
||||||
|
|||||||
+11
-2
@@ -10,9 +10,10 @@ function authRequest(path, data, options = {}) {
|
|||||||
timeout: options.timeout || 15000,
|
timeout: options.timeout || 15000,
|
||||||
success(response) {
|
success(response) {
|
||||||
const body = response.data || {}
|
const body = response.data || {}
|
||||||
if (String(body.code ?? response.statusCode) !== '200') {
|
if (response.statusCode !== 200 || String(body.code ?? response.statusCode) !== '200') {
|
||||||
const error = new Error(body.message || '认证服务请求失败')
|
const error = new Error(body.message || '认证服务请求失败')
|
||||||
error.code = body.code || response.statusCode
|
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.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']
|
error.traceId = body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id']
|
||||||
reject(error)
|
reject(error)
|
||||||
@@ -33,9 +34,17 @@ export function login(loginId, password) {
|
|||||||
return authRequest('/auth/login', { loginId, password })
|
return authRequest('/auth/login', { loginId, password })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pendingRefreshes = new Map()
|
||||||
export function refresh(refreshToken) {
|
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(refreshToken, accessToken) {
|
export function logout(refreshToken, accessToken) {
|
||||||
return authRequest('/auth/logout', { refreshToken }, {
|
return authRequest('/auth/logout', { refreshToken }, {
|
||||||
|
|||||||
+20
-3
@@ -1,8 +1,9 @@
|
|||||||
import { refresh as refreshRequest } from '@/api/auth.js'
|
import { refresh as refreshRequest } from '@/api/auth.js'
|
||||||
import { isAccessTokenExpiring, session, signOut, updateTokenPair } from '@/stores/session.js'
|
import { isAccessTokenExpiring, session, signOut, updateTokenPair, getSessionRevision } from '@/stores/session.js'
|
||||||
import { requireOnline } from '@/utils/network.js'
|
import { requireOnline } from '@/utils/network.js'
|
||||||
|
|
||||||
let refreshPromise = null
|
let refreshPromise = null
|
||||||
|
let refreshRevision = null
|
||||||
let redirectingToLogin = false
|
let redirectingToLogin = false
|
||||||
|
|
||||||
function responseCode(response) {
|
function responseCode(response) {
|
||||||
@@ -66,14 +67,21 @@ function rawRequest(options, accessToken) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requireSameSession(revision) {
|
||||||
|
if (getSessionRevision() !== revision) throw new Error('登录会话已变更,请重新操作')
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshAccessToken() {
|
async function refreshAccessToken() {
|
||||||
|
const revision = getSessionRevision()
|
||||||
const observedRefreshToken = session.refreshToken
|
const observedRefreshToken = session.refreshToken
|
||||||
if (!observedRefreshToken) return expireSession(null, observedRefreshToken)
|
if (!observedRefreshToken) return expireSession(null, observedRefreshToken)
|
||||||
if (!refreshPromise) {
|
if (!refreshPromise || refreshRevision !== revision) {
|
||||||
|
refreshRevision = revision
|
||||||
refreshPromise = (async () => {
|
refreshPromise = (async () => {
|
||||||
if (session.refreshToken !== observedRefreshToken && session.token) return session.token
|
if (session.refreshToken !== observedRefreshToken && session.token) return session.token
|
||||||
try {
|
try {
|
||||||
const tokens = await refreshRequest(observedRefreshToken)
|
const tokens = await refreshRequest(observedRefreshToken)
|
||||||
|
requireSameSession(revision)
|
||||||
if (session.refreshToken !== observedRefreshToken) {
|
if (session.refreshToken !== observedRefreshToken) {
|
||||||
if (session.token) return session.token
|
if (session.token) return session.token
|
||||||
throw sessionExpiredError()
|
throw sessionExpiredError()
|
||||||
@@ -82,10 +90,14 @@ async function refreshAccessToken() {
|
|||||||
redirectingToLogin = false
|
redirectingToLogin = false
|
||||||
return session.token
|
return session.token
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
requireSameSession(revision)
|
||||||
if (isUnauthorized(error)) return expireSession(error, observedRefreshToken)
|
if (isUnauthorized(error)) return expireSession(error, observedRefreshToken)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
})().finally(() => { refreshPromise = null })
|
})()
|
||||||
|
const pending = refreshPromise
|
||||||
|
const release = () => { if (refreshPromise === pending) { refreshPromise = null; refreshRevision = null } }
|
||||||
|
pending.then(release, release) // Original pending promise still propagates failures to callers.
|
||||||
}
|
}
|
||||||
return refreshPromise
|
return refreshPromise
|
||||||
}
|
}
|
||||||
@@ -101,12 +113,17 @@ export function ensureAccessToken() {
|
|||||||
|
|
||||||
async function runAuthenticated(execute) {
|
async function runAuthenticated(execute) {
|
||||||
requireOnline()
|
requireOnline()
|
||||||
|
const revision = getSessionRevision()
|
||||||
let accessToken = await validAccessToken()
|
let accessToken = await validAccessToken()
|
||||||
|
requireSameSession(revision)
|
||||||
let response = await execute(accessToken)
|
let response = await execute(accessToken)
|
||||||
|
requireSameSession(revision)
|
||||||
if (!isUnauthorized(response)) return response
|
if (!isUnauthorized(response)) return response
|
||||||
accessToken = session.token && session.token !== accessToken ? session.token : await refreshAccessToken()
|
accessToken = session.token && session.token !== accessToken ? session.token : await refreshAccessToken()
|
||||||
|
requireSameSession(revision)
|
||||||
const activeRefreshToken = session.refreshToken
|
const activeRefreshToken = session.refreshToken
|
||||||
response = await execute(accessToken)
|
response = await execute(accessToken)
|
||||||
|
requireSameSession(revision)
|
||||||
if (isUnauthorized(response)) expireSession(response, activeRefreshToken)
|
if (isUnauthorized(response)) expireSession(response, activeRefreshToken)
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { logout, refresh, getPendingRefresh } from './auth.js'
|
||||||
|
import { session, signOut, getSessionRevision, decodeAccessToken } from '../stores/session.js'
|
||||||
|
|
||||||
|
function replacement(captured, tokens) {
|
||||||
|
if (!tokens?.accessToken || typeof tokens.refreshToken !== 'string' || !tokens.refreshToken.trim()) throw new Error('退出刷新响应缺少会话凭据')
|
||||||
|
const previous = decodeAccessToken(captured.accessToken), next = decodeAccessToken(tokens.accessToken)
|
||||||
|
if (!Number.isFinite(Number(next.exp)) || Number(next.exp) * 1000 <= Date.now()) throw new Error('退出刷新凭据无效或已过期')
|
||||||
|
for (const field of ['user_id', 'membership_id', 'tenant_id', 'app_code']) {
|
||||||
|
if (previous[field] == null || String(previous[field]) !== String(next[field])) throw new Error('退出刷新返回了不同作业主体,无法确认原会话撤销')
|
||||||
|
}
|
||||||
|
if (String(previous.domestic_warehouse_id ?? '') !== String(next.domestic_warehouse_id ?? '')) throw new Error('退出刷新返回了不同作业仓库,无法确认原会话撤销')
|
||||||
|
return { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken }
|
||||||
|
}
|
||||||
|
export function logoutCurrentSession() {
|
||||||
|
const captured = { accessToken: session.token, refreshToken: session.refreshToken, pendingRefresh: getPendingRefresh(session.refreshToken) }
|
||||||
|
// Clear sensitive data and invalidate business requests while revocation is pending.
|
||||||
|
signOut()
|
||||||
|
const clearedRevision = getSessionRevision()
|
||||||
|
const revoke = async () => {
|
||||||
|
if (!captured.accessToken && !captured.refreshToken) return { revoked: false, reason: 'NO_CAPTURED_SESSION' }
|
||||||
|
if (!captured.accessToken || !captured.refreshToken) throw new Error('原会话凭据不完整,服务端退出未确认')
|
||||||
|
let credentials = captured, refreshed = false
|
||||||
|
if (captured.pendingRefresh) { refreshed = true; credentials = replacement(captured, await captured.pendingRefresh) }
|
||||||
|
try { await logout(credentials.refreshToken, credentials.accessToken) }
|
||||||
|
catch (error) {
|
||||||
|
if (error.network || error.statusCode !== 401 || refreshed) throw error
|
||||||
|
credentials = replacement(captured, await refresh(captured.refreshToken))
|
||||||
|
await logout(credentials.refreshToken, credentials.accessToken)
|
||||||
|
}
|
||||||
|
return { revoked: true }
|
||||||
|
}
|
||||||
|
return revoke().then(result => ({ ...result, clearedRevision }), error => { error.logoutRevision = clearedRevision; throw error })
|
||||||
|
}
|
||||||
@@ -4,6 +4,14 @@ import { session } from '@/stores/session.js'
|
|||||||
|
|
||||||
const ROOT = '/first-leg/pda-dropship'
|
const ROOT = '/first-leg/pda-dropship'
|
||||||
|
|
||||||
|
export function resolveDropshipWorkspaceCarton(warehouseId, boxCode) {
|
||||||
|
return apiRequest({ url: `${ROOT}/cartons/resolve?warehouseId=${encodeURIComponent(warehouseId)}&boxCode=${encodeURIComponent(String(boxCode || '').trim())}`, method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDropshipWorkspacePallet(warehouseId, palletNo) {
|
||||||
|
return apiRequest({ url: `${ROOT}/pallets/resolve?warehouseId=${encodeURIComponent(warehouseId)}&palletNo=${encodeURIComponent(String(palletNo || '').trim().toUpperCase())}`, method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
function stableId(value) {
|
function stableId(value) {
|
||||||
return String(value ?? '').trim()
|
return String(value ?? '').trim()
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ let selectedApiRoot = ''
|
|||||||
selectedApiRoot = import.meta.env.VITE_API_BASE_URL || '/api'
|
selectedApiRoot = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// H5 必须请求同源地址,由 Vite 开发服务器代理到国内 Gateway,避免浏览器 CORS。
|
// H5 必须请求同源地址,由 Vite 开发服务器代理到统一 Gateway,避免浏览器 CORS。
|
||||||
// 前缀不能使用 /api,否则会截获本项目 api/ 目录下的前端模块请求。
|
// 前缀不能使用 /api,否则会截获本项目 api/ 目录下的前端模块请求。
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
selectedApiRoot = import.meta.env.VITE_H5_API_BASE_URL || '/gateway-api'
|
selectedApiRoot = import.meta.env.VITE_H5_API_BASE_URL || '/gateway-api'
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
ARG NGINX_BASE_IMAGE=nginx:1.27-alpine
|
|
||||||
FROM ${NGINX_BASE_IMAGE}
|
|
||||||
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
COPY unpackage/dist/build/web/ /srv/pda-h5/pda-domestic/
|
|
||||||
EXPOSE 80
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
name: firstleg-test-pda-domestic-h5
|
|
||||||
services:
|
|
||||||
pda-domestic-h5:
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: deploy/Dockerfile.h5
|
|
||||||
args:
|
|
||||||
NGINX_BASE_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
|
|
||||||
image: pda-domestic-h5-test:${IMAGE_TAG:?IMAGE_TAG is required}
|
|
||||||
container_name: pda-domestic-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/pda-domestic/"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 12
|
|
||||||
start_period: 5s
|
|
||||||
networks:
|
|
||||||
test-backend:
|
|
||||||
aliases:
|
|
||||||
- pda-domestic-h5-test
|
|
||||||
networks:
|
|
||||||
test-backend:
|
|
||||||
external: true
|
|
||||||
name: firstleg-test-network
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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 = /pda-domestic { return 302 /pda-domestic/; }
|
|
||||||
location ^~ /pda-domestic/ {
|
|
||||||
index index.html;
|
|
||||||
try_files $uri $uri/ =404;
|
|
||||||
add_header Cache-Control "no-store" always;
|
|
||||||
}
|
|
||||||
location / { return 404; }
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Merge into the existing site's server block; preserve TLS and panel includes.
|
||||||
|
# Render only PDA_SITE_ROOT and PDA_GATEWAY_ORIGIN (see README).
|
||||||
|
# Replace existing root/index and matching locations; do not append duplicates.
|
||||||
|
root ${PDA_SITE_ROOT};
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
add_header Cache-Control "no-store" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /gateway-api/ {
|
||||||
|
proxy_pass ${PDA_GATEWAY_ORIGIN}/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header Authorization $http_authorization;
|
||||||
|
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;
|
||||||
|
client_max_body_size 55m;
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<button v-if="task.taskStatus === 'PENDING'" class="primary-button" :disabled="submitting" @tap="claimTask">领取并开始入库</button>
|
<button v-if="task.taskStatus === 'PENDING'" class="primary-button" :disabled="submitting" @tap="claimTask">领取并开始入库</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="task.taskStatus === 'CLAIMED'" class="section-card">
|
<view v-if="task.taskStatus === 'CLAIMED' || currentPallet" class="section-card">
|
||||||
<text class="section-title">1. 扫箱清点</text>
|
<text class="section-title">1. 扫箱清点</text>
|
||||||
<template v-if="palletSetupReady">
|
<template v-if="palletSetupReady">
|
||||||
<ScanTargetInput v-model="boxCode" name="boxCode" label="箱码" placeholder="扫描或输入箱唛箱码" :active="activeScan === 'boxCode'" @activate="activeScan = 'boxCode'" @submit="resolveCarton" />
|
<ScanTargetInput v-model="boxCode" name="boxCode" label="箱码" placeholder="扫描或输入箱唛箱码" :active="activeScan === 'boxCode'" @activate="activeScan = 'boxCode'" @submit="resolveCarton" />
|
||||||
@@ -29,6 +29,8 @@
|
|||||||
<button class="compact-action" :disabled="!canResolveCarton" @tap="resolveCarton()">读取 SKU</button>
|
<button class="compact-action" :disabled="!canResolveCarton" @tap="resolveCarton()">读取 SKU</button>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="carton" class="carton-card">
|
<view v-if="carton" class="carton-card">
|
||||||
|
<text class="carton-owner">{{ scanTask?.customerName || task.customerName }} · {{ carton.inboundCode }}</text>
|
||||||
|
<text v-if="carton.blockReason" class="pallet-hint blocked">{{ carton.blockReason }}</text>
|
||||||
<view v-for="(line, index) in skuCounts" :key="line.inboundDetailId" class="sku-count-row">
|
<view v-for="(line, index) in skuCounts" :key="line.inboundDetailId" class="sku-count-row">
|
||||||
<view class="sku-copy"><text class="sku-label">SKU {{ index + 1 }}</text><text class="sku-code">{{ line.skuCode || '—' }}</text></view>
|
<view class="sku-copy"><text class="sku-label">SKU {{ index + 1 }}</text><text class="sku-code">{{ line.skuCode || '—' }}</text></view>
|
||||||
<view v-if="!isResolvedPendingCarton" class="quantity-stepper">
|
<view v-if="!isResolvedPendingCarton" class="quantity-stepper">
|
||||||
@@ -53,15 +55,17 @@
|
|||||||
</view>
|
</view>
|
||||||
<view v-if="task.taskStatus === 'CLAIMED' && task.totalCartons > 0 && task.boundCartons === task.totalCartons" class="continue-strip">
|
<view v-if="task.taskStatus === 'CLAIMED' && task.totalCartons > 0 && task.boundCartons === task.totalCartons" class="continue-strip">
|
||||||
<text>本票已全部归托。托盘未装满时先继续装其他票,实物装满后再封托。</text>
|
<text>本票已全部归托。托盘未装满时先继续装其他票,实物装满后再封托。</text>
|
||||||
<button class="secondary-button" @tap="continueOtherTask">继续装其他入仓单</button>
|
<button class="secondary-button" :disabled="submitting || loading" @tap="continueOtherTask">继续装其他入仓单</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="section-card">
|
<view class="section-card">
|
||||||
<view class="section-heading"><view><text class="section-title">2. 托盘操作</text><text class="section-desc">直接扫描托盘码,只显示当前操作的托盘;待绑托箱扫描目标托盘后批量归托</text></view><button class="text-button" :disabled="loading" @tap="loadTask">刷新</button></view>
|
<view class="section-heading"><text class="section-title">2. 托盘操作</text><button class="refresh-button" :disabled="loading" @tap="loadTask">{{ loading ? '刷新中…' : '刷新' }}</button></view>
|
||||||
<view v-if="currentPallet" class="pallet-card">
|
<view v-if="currentPallet" class="pallet-card">
|
||||||
<view class="pallet-head"><text class="pallet-code">{{ currentPallet.palletNo }}</text><text class="pallet-status" :class="currentPallet.status === 'SEALED' ? 'sealed' : ''">{{ currentPallet.status === 'SEALED' ? '已封托' : '当前托盘' }}</text></view>
|
<view class="pallet-head"><text class="pallet-code">{{ currentPallet.palletNo }}</text><text class="pallet-status" :class="currentPallet.status === 'SEALED' ? 'sealed' : ''">{{ currentPallet.status === 'SEALED' ? '已封托' : '当前托盘' }}</text></view>
|
||||||
<text class="pallet-meta">{{ currentPallet.cartonCount || 0 }} 箱 · {{ currentPallet.skuCount || 0 }} SKU · {{ currentPallet.weightKg || 0 }} kg · {{ currentPallet.volumeCbm || 0 }} m³</text>
|
<text class="pallet-meta">{{ currentPallet.cartonCount || 0 }} 箱 · {{ currentPallet.skuCount || 0 }} SKU · {{ currentPallet.weightKg || 0 }} kg · {{ currentPallet.volumeCbm || 0 }} m³</text>
|
||||||
<view v-for="item in (currentPalletDetails?.cartons || [])" :key="item.cartonId" class="carton-row"><text>{{ item.boxCode }}</text><button v-if="currentPallet.status === 'OPEN'" class="text-button" :disabled="submitting" @tap="moveCarton(item, null)">移回待绑托</button></view>
|
<text class="pallet-meta">整托 {{ palletCustomerCount }} 个客户 · {{ palletMembers.length }} 票</text>
|
||||||
|
<view v-for="member in palletMembers" :key="member.taskId" class="member-row"><text>{{ member.customerName || '客户未关联' }} · {{ member.inboundCode }}</text><strong>{{ member.cartonCount }} 箱</strong></view>
|
||||||
|
<view v-for="item in (currentPalletDetails?.cartons || [])" :key="item.cartonId" class="carton-row"><view><text>{{ item.boxCode }}</text><text class="carton-owner">{{ memberForCarton(item)?.customerName }} · {{ item.inboundCode }}</text></view><button v-if="currentPallet.status === 'OPEN'" class="text-button" :disabled="submitting || loading" @tap="moveCarton(item, null)">移回待绑托</button></view>
|
||||||
<button v-if="currentPallet.palletId && currentPallet.status === 'OPEN'" class="primary-button" :disabled="!currentPallet.cartonCount || submitting" @tap="sealPallet(currentPallet)">封托</button>
|
<button v-if="currentPallet.palletId && currentPallet.status === 'OPEN'" class="primary-button" :disabled="!currentPallet.cartonCount || submitting" @tap="sealPallet(currentPallet)">封托</button>
|
||||||
<button v-if="currentPallet.palletId && currentPallet.status === 'SEALED'" class="secondary-button unseal-button" :disabled="submitting" @tap="openUnseal(currentPallet)">解除封托</button>
|
<button v-if="currentPallet.palletId && currentPallet.status === 'SEALED'" class="secondary-button unseal-button" :disabled="submitting" @tap="openUnseal(currentPallet)">解除封托</button>
|
||||||
</view>
|
</view>
|
||||||
@@ -82,9 +86,9 @@
|
|||||||
import PortalHeader from '@/components/PortalHeader.vue'
|
import PortalHeader from '@/components/PortalHeader.vue'
|
||||||
import PopupHeader from '@/components/PopupHeader.vue'
|
import PopupHeader from '@/components/PopupHeader.vue'
|
||||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||||
import { bindDropshipPendingCartons, claimDropshipPalletizationTask, countDropshipCarton, getDropshipPalletizationTask, moveDropshipCarton, resolveDropshipCarton, sealDropshipPallet, unsealDropshipPallet } from '@/api/pda-dropship.js'
|
import { bindDropshipPendingCartons, claimDropshipPalletizationTask, countDropshipCarton, getDropshipPalletizationTask, moveDropshipCarton, resolveDropshipWorkspaceCarton, resolveDropshipWorkspacePallet, sealDropshipPallet, unsealDropshipPallet } from '@/api/pda-dropship.js'
|
||||||
import { session, requireSession } from '@/stores/session.js'
|
import { session, requireSession } from '@/stores/session.js'
|
||||||
import { formatApiError } from '@/utils/user-facing.js'
|
import { sanitizeUserMessage } from '@/utils/user-facing.js'
|
||||||
import { stableOperationId } from '@/utils/idempotency.js'
|
import { stableOperationId } from '@/utils/idempotency.js'
|
||||||
import { isOnline } from '@/utils/network.js'
|
import { isOnline } from '@/utils/network.js'
|
||||||
import { clearDropshipPalletizingDraft, restoreDropshipPalletizingDraft, saveDropshipPalletizingDraft } from '@/stores/dropship-palletizing.js'
|
import { clearDropshipPalletizingDraft, restoreDropshipPalletizingDraft, saveDropshipPalletizingDraft } from '@/stores/dropship-palletizing.js'
|
||||||
@@ -93,52 +97,60 @@
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { PopupHeader, PortalHeader, ScanTargetInput },
|
components: { PopupHeader, PortalHeader, ScanTargetInput },
|
||||||
data() { return { user: session.currentUser, taskId: '', warehouseId: '', task: null, palletCode: '', selectedPalletId: '', selectedPalletNo: '', boxCode: '', carton: null, skuCounts: [], activeScan: 'boxCode', loading: false, submitting: false, error: '', notice: '', unsealOpen: false, unsealTarget: null } },
|
data() { return { user: session.currentUser, taskId: '', warehouseId: '', task: null, scanTask: null, workspace: null, initialPalletNo: '', palletCode: '', selectedPalletId: '', selectedPalletNo: '', boxCode: '', carton: null, skuCounts: [], activeScan: 'boxCode', loading: false, submitting: false, error: '', notice: '', unsealOpen: false, unsealTarget: null } },
|
||||||
computed: {
|
computed: {
|
||||||
statusLabel() { return this.task?.taskStatus === 'COMPLETED' ? '已完成' : (this.task?.taskStatus === 'CLAIMED' ? '作业中' : '待领取') },
|
statusLabel() { return this.task?.taskStatus === 'COMPLETED' ? '已完成' : (this.task?.taskStatus === 'CLAIMED' ? '作业中' : '待领取') },
|
||||||
palletSetupReady() { return this.task?.palletSetupReady === true },
|
palletSetupReady() { return this.task?.palletSetupReady === true },
|
||||||
palletSetupMessage() { return this.task?.palletSetupMessage || `预计 ${this.task?.expectedPalletCount || 0} 托,当前仅生成 ${this.task?.generatedPalletCount || 0} 个托盘码,请联系后台处理后刷新` },
|
palletSetupMessage() { return this.task?.palletSetupMessage || `预计 ${this.task?.expectedPalletCount || 0} 托,当前仅生成 ${this.task?.generatedPalletCount || 0} 个托盘码,请联系后台处理后刷新` },
|
||||||
canResolveCarton() { return Boolean(this.palletSetupReady && this.task?.taskStatus === 'CLAIMED' && String(this.boxCode || '').trim() && !this.loading && !this.submitting) },
|
canResolveCarton() { return Boolean(this.palletSetupReady && String(this.boxCode || '').trim() && !this.loading && !this.submitting) },
|
||||||
canCount() { return Boolean(this.carton?.canCount && this.skuCounts.length && this.skuCounts.every((line) => Number.isInteger(Number(line.actualQuantity)) && Number(line.actualQuantity) >= 0 && (!this.lineDiffers(line) || String(line.discrepancyRemark || '').trim()))) },
|
canCount() { return Boolean(this.carton?.canCount && this.skuCounts.length && this.skuCounts.every((line) => Number.isInteger(Number(line.actualQuantity)) && Number(line.actualQuantity) >= 0 && (!this.lineDiffers(line) || String(line.discrepancyRemark || '').trim()))) },
|
||||||
isResolvedPendingCarton() { return this.carton?.countStatus === 'COUNTED_UNBOUND' },
|
isResolvedPendingCarton() { return this.carton?.countStatus === 'COUNTED_UNBOUND' },
|
||||||
canRebindResolvedCarton() { return Boolean(this.isResolvedPendingCarton && this.carton?.cartonId && this.assignableCurrentPallet?.palletNo) },
|
canRebindResolvedCarton() { return Boolean(this.isResolvedPendingCarton && this.carton?.cartonId && this.assignableCurrentPallet?.palletNo) },
|
||||||
canSubmitCarton() { return this.currentPallet?.status === 'SEALED' ? false : (this.isResolvedPendingCarton ? this.canRebindResolvedCarton : this.canCount) },
|
canSubmitCarton() { return this.currentPallet?.status === 'SEALED' || ((this.selectedPalletNo || this.palletCode) && !this.currentPallet) ? false : (this.isResolvedPendingCarton ? this.canRebindResolvedCarton : this.canCount) },
|
||||||
cartonActionLabel() { return this.isResolvedPendingCarton ? '确认归入当前托盘' : '确认清点' },
|
cartonActionLabel() { return this.isResolvedPendingCarton ? '确认归入当前托盘' : '确认清点' },
|
||||||
currentPalletDetails() { return (this.task?.pallets || []).find((item) => item.palletNo === this.selectedPalletNo) || null },
|
currentPalletDetails() { return this.workspace?.pallet?.palletNo === this.selectedPalletNo ? this.workspace.pallet : null },
|
||||||
currentPallet() { return this.currentPalletDetails || (this.selectedPalletNo ? { palletNo: this.selectedPalletNo, status: 'OPEN' } : null) },
|
currentPallet() { return this.currentPalletDetails },
|
||||||
|
palletMembers() { return this.workspace?.members || [] },
|
||||||
|
palletCustomerCount() { return new Set(this.palletMembers.map((member) => String(member.customerId))).size },
|
||||||
assignableCurrentPallet() { return this.currentPallet?.status === 'OPEN' ? this.currentPallet : null }
|
assignableCurrentPallet() { return this.currentPallet?.status === 'OPEN' ? this.currentPallet : null }
|
||||||
},
|
},
|
||||||
onLoad(options) { this.taskId = String(options.taskId || ''); this.warehouseId = String(options.warehouseId || '') },
|
onLoad(options) { this.taskId = String(options.taskId || ''); this.warehouseId = String(options.warehouseId || ''); this.initialPalletNo = String(options.palletNo || '') },
|
||||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; const draft = restoreDropshipPalletizingDraft(this.draftIdentity()); this.selectedPalletNo = draft?.activePalletNo || ''; this.startScanner(); this.loadTask() },
|
onShow() { if (!requireSession()) return; this.user = session.currentUser; const draft = restoreDropshipPalletizingDraft(this.draftIdentity()); this.selectedPalletNo = this.initialPalletNo || draft?.activePalletNo || ''; this.initialPalletNo = ''; this.startScanner(); this.loadTask({ restorePallet: true }) },
|
||||||
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
|
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
|
||||||
onBackPress() { if (!this.unsealOpen) return false; this.closeUnseal(); return true },
|
onBackPress() { if (!this.unsealOpen) return false; this.closeUnseal(); return true },
|
||||||
methods: {
|
methods: {
|
||||||
|
formatOperationError(error, fallback) {
|
||||||
|
console.error('[PDA dropship inbound]', { code: error?.code, requestId: error?.requestId, traceId: error?.traceId })
|
||||||
|
return sanitizeUserMessage(error?.message, fallback)
|
||||||
|
},
|
||||||
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleScan(value), onError: () => { this.error = '扫码头连接失败,可使用输入框继续作业' } }) },
|
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleScan(value), onError: () => { this.error = '扫码头连接失败,可使用输入框继续作业' } }) },
|
||||||
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
|
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
|
||||||
handleScan(value) { if (!this.palletSetupReady) { this.error = this.palletSetupMessage; return } const plan = planDropshipScan(createDropshipScanState({ online: isOnline() }), value, { unboundCartons: this.task?.unboundCartons }); if ([DROPSHIP_SCAN_INTENT.BLOCKED, DROPSHIP_SCAN_INTENT.INVALID].includes(plan.intent)) { this.error = plan.message; return } const code = plan.palletNo || plan.boxCode; if (plan.palletNo) { this.palletCode = code; this.selectPallet(code) } else { this.boxCode = code; this.resolveCarton() } },
|
handleScan(value) { if (this.loading || this.submitting) return; if (!this.palletSetupReady) { this.error = this.palletSetupMessage; return } const plan = planDropshipScan(createDropshipScanState({ online: isOnline() }), value, { unboundCartons: this.task?.unboundCartons }); if ([DROPSHIP_SCAN_INTENT.BLOCKED, DROPSHIP_SCAN_INTENT.INVALID].includes(plan.intent)) { this.error = plan.message; return } const code = plan.palletNo || plan.boxCode; if (plan.palletNo) { this.palletCode = code; this.selectPallet(code) } else { this.boxCode = code; this.resolveCarton() } },
|
||||||
resetMessages() { this.error = ''; this.notice = '' },
|
resetMessages() { this.error = ''; this.notice = '' },
|
||||||
async loadTask() {
|
async loadTask({ restorePallet = false } = {}) {
|
||||||
if (!this.taskId || !this.warehouseId) { this.error = '缺少合托任务或仓库信息,请返回任务列表'; return }
|
if (!this.taskId || !this.warehouseId) { this.error = '缺少合托任务或仓库信息,请返回任务列表'; return }
|
||||||
this.loading = true; this.resetMessages()
|
this.loading = true; this.resetMessages()
|
||||||
try { this.task = await getDropshipPalletizationTask(this.taskId, this.warehouseId); if (this.task.taskStatus === 'COMPLETED') clearDropshipPalletizingDraft(this.draftIdentity()) }
|
try { this.task = await getDropshipPalletizationTask(this.taskId, this.warehouseId); await this.refreshPallet({ restorePallet }); if (this.task.taskStatus === 'COMPLETED') clearDropshipPalletizingDraft(this.draftIdentity()) }
|
||||||
catch (error) { this.error = formatApiError(error, '一件代发入库任务加载失败') }
|
catch (error) { this.error = this.formatOperationError(error, '一件代发入库任务加载失败') }
|
||||||
finally { this.loading = false }
|
finally { this.loading = false }
|
||||||
},
|
},
|
||||||
async claimTask() { await this.runMutation(() => claimDropshipPalletizationTask(this.taskId, this.warehouseId), '任务领取成功') },
|
async claimTask() { await this.runMutation(() => claimDropshipPalletizationTask(this.taskId, this.warehouseId), '任务领取成功') },
|
||||||
async resolveCarton(value) {
|
async resolveCarton(value) {
|
||||||
|
if (this.loading || this.submitting) return
|
||||||
if (!this.palletSetupReady) { this.error = this.palletSetupMessage; return }
|
if (!this.palletSetupReady) { this.error = this.palletSetupMessage; return }
|
||||||
const code = String(value || this.boxCode || '').trim().toUpperCase(); if (!code || this.loading) return
|
const code = String(value || this.boxCode || '').trim().toUpperCase(); if (!code || this.loading) return
|
||||||
this.boxCode = code; this.loading = true; this.resetMessages(); this.carton = null
|
this.boxCode = code; this.loading = true; this.resetMessages(); this.carton = null
|
||||||
try { this.carton = await resolveDropshipCarton(this.taskId, this.warehouseId, code); this.skuCounts = (this.carton.skuLines || []).map((line) => ({ ...line, actualQuantity: String(line.actualQuantity ?? line.expectedQuantity ?? ''), discrepancyRemark: line.discrepancyRemark || '' })) }
|
try { const resolved = await resolveDropshipWorkspaceCarton(this.warehouseId, code); this.scanTask = resolved.task; this.carton = resolved.carton; this.skuCounts = (this.carton.skuLines || []).map((line) => ({ ...line, actualQuantity: String(line.actualQuantity ?? line.expectedQuantity ?? ''), discrepancyRemark: line.discrepancyRemark || '' })) }
|
||||||
catch (error) { this.error = formatApiError(error, '箱码解析失败') }
|
catch (error) { this.error = this.formatOperationError(error, '箱码解析失败') }
|
||||||
finally { this.loading = false }
|
finally { this.loading = false }
|
||||||
},
|
},
|
||||||
async countCarton() {
|
async countCarton() {
|
||||||
const skuLines = this.skuCounts.map((line) => ({ inboundDetailId: line.inboundDetailId, skuCode: line.skuCode, actualQuantity: Number(line.actualQuantity), discrepancyAction: this.lineDiffers(line) ? 'ACCEPT_VARIANCE' : 'MATCH', discrepancyRemark: this.lineDiffers(line) ? String(line.discrepancyRemark || '').trim() : null }))
|
const skuLines = this.skuCounts.map((line) => ({ inboundDetailId: line.inboundDetailId, skuCode: line.skuCode, actualQuantity: Number(line.actualQuantity), discrepancyAction: this.lineDiffers(line) ? 'ACCEPT_VARIANCE' : 'MATCH', discrepancyRemark: this.lineDiffers(line) ? String(line.discrepancyRemark || '').trim() : null }))
|
||||||
const result = await this.runMutation(() => countDropshipCarton(this.taskId, { warehouseId: this.warehouseId, boxCode: this.carton.boxCode, palletNo: this.assignableCurrentPallet?.palletNo || null, skuLines }), this.assignableCurrentPallet ? '本箱已清点并归托' : '本箱已完成入库清点')
|
const result = await this.runMutation(async () => { await this.activateScanTask(); return countDropshipCarton(this.taskId, { warehouseId: this.warehouseId, boxCode: this.carton.boxCode, palletNo: this.assignableCurrentPallet?.palletNo || null, skuLines }) }, this.assignableCurrentPallet ? '本箱已清点并归托' : '本箱已完成入库清点')
|
||||||
if (result) { this.boxCode = ''; this.carton = null; this.skuCounts = [] }
|
if (result) { this.boxCode = ''; this.carton = null; this.skuCounts = [] }
|
||||||
},
|
},
|
||||||
async confirmCarton() {
|
async confirmCarton() {
|
||||||
|
if (!this.canSubmitCarton || this.loading || this.submitting) return
|
||||||
if (this.isResolvedPendingCarton) { await this.rebindResolvedCarton(); return }
|
if (this.isResolvedPendingCarton) { await this.rebindResolvedCarton(); return }
|
||||||
await this.countCarton()
|
await this.countCarton()
|
||||||
},
|
},
|
||||||
@@ -147,35 +159,73 @@
|
|||||||
const targetPalletNo = this.assignableCurrentPallet.palletNo
|
const targetPalletNo = this.assignableCurrentPallet.palletNo
|
||||||
const payload = { warehouseId: this.warehouseId, targetPalletNo }
|
const payload = { warehouseId: this.warehouseId, targetPalletNo }
|
||||||
payload.operationId = stableOperationId('dropship-move', String(this.user?.id) + '-' + this.taskId, { cartonId: this.carton.cartonId, taskVersion: this.task.version, ...payload })
|
payload.operationId = stableOperationId('dropship-move', String(this.user?.id) + '-' + this.taskId, { cartonId: this.carton.cartonId, taskVersion: this.task.version, ...payload })
|
||||||
const result = await this.runMutation(() => moveDropshipCarton(this.taskId, this.carton.cartonId, payload), `本箱已归入 ${targetPalletNo}`)
|
const result = await this.runMutation(async () => { await this.activateScanTask(); return moveDropshipCarton(this.taskId, this.carton.cartonId, payload) }, `本箱已归入 ${targetPalletNo}`)
|
||||||
if (result) { this.boxCode = ''; this.carton = null; this.skuCounts = [] }
|
if (result) { this.boxCode = ''; this.carton = null; this.skuCounts = [] }
|
||||||
},
|
},
|
||||||
lineDiffers(line) { return Number(line.actualQuantity) !== Number(line.expectedQuantity) },
|
lineDiffers(line) { return Number(line.actualQuantity) !== Number(line.expectedQuantity) },
|
||||||
adjustQuantity(index, step) { const line = this.skuCounts[index]; if (line) line.actualQuantity = String(Math.max(0, Number(line.actualQuantity || 0) + step)) },
|
adjustQuantity(index, step) { const line = this.skuCounts[index]; if (line) line.actualQuantity = String(Math.max(0, Number(line.actualQuantity || 0) + step)) },
|
||||||
async selectPallet(value) {
|
async selectPallet(value) {
|
||||||
|
if (this.loading || this.submitting) return
|
||||||
if (!this.palletSetupReady) { this.error = this.palletSetupMessage; return }
|
if (!this.palletSetupReady) { this.error = this.palletSetupMessage; return }
|
||||||
const code = String(value || this.palletCode || '').trim().toUpperCase(); const pallet = (this.task?.pallets || []).find((item) => item.palletNo === code)
|
const code = String(value || this.palletCode || '').trim().toUpperCase()
|
||||||
this.palletCode = code; this.selectedPalletId = pallet ? String(pallet.palletId) : ''; this.selectedPalletNo = code; this.activeScan = 'boxCode'; saveDropshipPalletizingDraft(this.draftIdentity(), { activePalletNo: code })
|
this.palletCode = code
|
||||||
|
this.loading = true; this.resetMessages(); this.workspace = null
|
||||||
|
try { this.workspace = await resolveDropshipWorkspacePallet(this.warehouseId, code); this.selectedPalletNo = code; this.palletCode = code; this.selectedPalletId = String(this.workspace.pallet.palletId); this.activeScan = 'boxCode'; saveDropshipPalletizingDraft(this.draftIdentity(), { activePalletNo: code }) }
|
||||||
|
catch (error) { this.selectedPalletNo = ''; this.error = this.formatOperationError(error, '托盘校验失败'); return }
|
||||||
|
finally { this.loading = false }
|
||||||
|
const pallet = this.currentPallet
|
||||||
if (pallet?.status === 'SEALED') { this.notice = `托盘 ${code} 已封托,可在托盘操作区解除封托`; return }
|
if (pallet?.status === 'SEALED') { this.notice = `托盘 ${code} 已封托,可在托盘操作区解除封托`; return }
|
||||||
if (Number(this.task?.unboundCartons || 0) > 0) await this.runMutation(() => bindDropshipPendingCartons(this.taskId, this.warehouseId, code, this.task.version), `待归托箱已绑定至 ${code}`)
|
if (Number(this.task?.unboundCartons || 0) > 0) await this.runMutation(() => bindDropshipPendingCartons(this.taskId, this.warehouseId, code, this.task.version), `待归托箱已绑定至 ${code}`)
|
||||||
else this.notice = pallet ? `当前托盘:${code}` : `已选择仓库开放托盘 ${code},清点箱码时将校验并归托`
|
else this.notice = `已选择仓库开放托盘 ${code},可连续扫描不同客户的箱码`
|
||||||
|
},
|
||||||
|
// 页面恢复只延续开放托盘;主动扫码和刚封托后的回查仍保留真实封托结果。
|
||||||
|
async refreshPallet({ restorePallet = false } = {}) {
|
||||||
|
this.workspace = null
|
||||||
|
if (!this.selectedPalletNo) return
|
||||||
|
const workspace = await resolveDropshipWorkspacePallet(this.warehouseId, this.selectedPalletNo)
|
||||||
|
if (restorePallet && workspace.pallet.status !== 'OPEN') {
|
||||||
|
this.selectedPalletNo = ''; this.selectedPalletId = ''; this.palletCode = ''
|
||||||
|
clearDropshipPalletizingDraft(this.draftIdentity())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.workspace = workspace
|
||||||
|
},
|
||||||
|
memberForCarton(carton) { return this.palletMembers.find((member) => String(member.inboundId) === String(carton.inboundId)) },
|
||||||
|
async activateScanTask() {
|
||||||
|
const targetId = String(this.scanTask?.taskId || this.taskId)
|
||||||
|
let target = await getDropshipPalletizationTask(targetId, this.warehouseId)
|
||||||
|
if (target.taskStatus === 'PENDING') target = await claimDropshipPalletizationTask(targetId, this.warehouseId)
|
||||||
|
this.taskId = targetId; this.task = target
|
||||||
|
saveDropshipPalletizingDraft(this.draftIdentity(), { activePalletNo: this.selectedPalletNo })
|
||||||
|
},
|
||||||
|
async activatePalletTask() {
|
||||||
|
const member = this.palletMembers.find((item) => String(item.taskId) === this.taskId) || this.palletMembers[0]
|
||||||
|
if (!member) throw new Error('托盘没有已归托箱,请先清点归托')
|
||||||
|
const target = await getDropshipPalletizationTask(member.taskId, this.warehouseId)
|
||||||
|
this.taskId = String(target.taskId); this.task = target
|
||||||
},
|
},
|
||||||
draftIdentity() { return { operatorId: this.user?.id, taskId: this.taskId, warehouseId: this.warehouseId } },
|
draftIdentity() { return { operatorId: this.user?.id, taskId: this.taskId, warehouseId: this.warehouseId } },
|
||||||
async moveCarton(carton, targetPalletNo) {
|
async moveCarton(carton, targetPalletNo) {
|
||||||
if (this.submitting || !isOnline() || !this.palletSetupReady) return
|
if (this.submitting || !isOnline() || !this.palletSetupReady) return
|
||||||
const payload = { warehouseId: this.warehouseId, targetPalletNo: targetPalletNo || null }
|
const payload = { warehouseId: this.warehouseId, targetPalletNo: targetPalletNo || null }
|
||||||
payload.operationId = stableOperationId('dropship-move', String(this.user?.id) + '-' + this.taskId, { cartonId: carton.cartonId, taskVersion: this.task.version, ...payload })
|
payload.operationId = stableOperationId('dropship-move', String(this.user?.id) + '-' + this.taskId, { cartonId: carton.cartonId, taskVersion: this.task.version, ...payload })
|
||||||
await this.runMutation(() => moveDropshipCarton(this.taskId, carton.cartonId, payload), targetPalletNo ? '箱码已换托' : '箱码已移回待绑托区')
|
const member = this.memberForCarton(carton)
|
||||||
|
if (!member) { this.error = '未找到箱码所属任务,请刷新托盘'; return }
|
||||||
|
await this.runMutation(async () => { const target = await getDropshipPalletizationTask(member.taskId, this.warehouseId); this.taskId = String(target.taskId); this.task = target; return moveDropshipCarton(this.taskId, carton.cartonId, payload) }, targetPalletNo ? '箱码已换托' : '箱码已移回待绑托区')
|
||||||
|
},
|
||||||
|
continueOtherTask() { uni.redirectTo({ url: `/pages/dropship-tasks/dropship-tasks?warehouseId=${encodeURIComponent(this.warehouseId)}&palletNo=${encodeURIComponent(this.assignableCurrentPallet?.palletNo || '')}` }) },
|
||||||
|
async sealPallet(pallet) {
|
||||||
|
if (this.loading || this.submitting) return
|
||||||
|
const content = this.palletMembers.map((member) => `${member.customerName || '客户未关联'} / ${member.inboundCode}:${member.cartonCount}箱`).join('\n')
|
||||||
|
uni.showModal({ title: `确认封托 · ${pallet.cartonCount}箱`, content: `${content}\n请确认实物已装满,封托后按整托装柜。`, success: async (answer) => { if (answer.confirm) await this.runMutation(async () => { await this.activatePalletTask(); return sealDropshipPallet(this.taskId, this.warehouseId, pallet) }, `${pallet.palletNo} 已封托`) } })
|
||||||
},
|
},
|
||||||
continueOtherTask() { uni.redirectTo({ url: `/pages/dropship-tasks/dropship-tasks?warehouseId=${encodeURIComponent(this.warehouseId)}` }) },
|
|
||||||
async sealPallet(pallet) { await this.runMutation(() => sealDropshipPallet(this.taskId, this.warehouseId, pallet), `${pallet.palletNo} 已封托`) },
|
|
||||||
openUnseal(pallet) { this.unsealTarget = pallet; this.unsealOpen = true; this.$refs.unsealPopup.open() },
|
openUnseal(pallet) { this.unsealTarget = pallet; this.unsealOpen = true; this.$refs.unsealPopup.open() },
|
||||||
closeUnseal() { this.unsealOpen = false; this.$refs.unsealPopup?.close(); this.unsealTarget = null },
|
closeUnseal() { this.unsealOpen = false; this.$refs.unsealPopup?.close(); this.unsealTarget = null },
|
||||||
handleUnsealPopupChange(event) { this.unsealOpen = Boolean(event.show); if (!event.show) this.unsealTarget = null },
|
handleUnsealPopupChange(event) { this.unsealOpen = Boolean(event.show); if (!event.show) this.unsealTarget = null },
|
||||||
async confirmUnseal() {
|
async confirmUnseal() {
|
||||||
const pallet = this.unsealTarget; if (!pallet || this.submitting) return
|
const pallet = this.unsealTarget; if (!pallet || this.submitting) return
|
||||||
this.$refs.unsealPopup.close(); this.unsealOpen = false
|
this.$refs.unsealPopup.close(); this.unsealOpen = false
|
||||||
const result = await this.runMutation(() => unsealDropshipPallet(this.taskId, this.warehouseId, pallet), `${pallet.palletNo} 已解除封托`)
|
const result = await this.runMutation(async () => { await this.activatePalletTask(); return unsealDropshipPallet(this.taskId, this.warehouseId, pallet) }, `${pallet.palletNo} 已解除封托`)
|
||||||
this.unsealTarget = null
|
this.unsealTarget = null
|
||||||
if (result) { this.selectedPalletNo = pallet.palletNo; saveDropshipPalletizingDraft(this.draftIdentity(), { activePalletNo: pallet.palletNo }) }
|
if (result) { this.selectedPalletNo = pallet.palletNo; saveDropshipPalletizingDraft(this.draftIdentity(), { activePalletNo: pallet.palletNo }) }
|
||||||
},
|
},
|
||||||
@@ -189,9 +239,10 @@
|
|||||||
? result
|
? result
|
||||||
: await getDropshipPalletizationTask(this.taskId, this.warehouseId)
|
: await getDropshipPalletizationTask(this.taskId, this.warehouseId)
|
||||||
this.notice = message
|
this.notice = message
|
||||||
|
try { await this.refreshPallet() } catch (error) { this.error = this.formatOperationError(error, '操作已成功,托盘汇总刷新失败,请刷新后继续') }
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
catch (error) { this.error = formatApiError(error, '操作失败'); return null }
|
catch (error) { this.error = this.formatOperationError(error, '操作失败'); return null }
|
||||||
finally { this.submitting = false }
|
finally { this.submitting = false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,6 +250,8 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.member-row { display: flex; justify-content: space-between; gap: 8px; padding: 8px 0; border-bottom: 1px solid #edf1f5; font-size: 12px; }
|
||||||
|
.carton-owner { display: block; color: #667085; font-size: 12px; line-height: 20px; overflow-wrap: anywhere; }
|
||||||
.content { padding: 0 0 32px; background: #fff; }
|
.content { padding: 0 0 32px; background: #fff; }
|
||||||
.state, .message { margin-bottom: 10px; padding: 12px; border-radius: 9px; color: #667085; font-size: 12px; line-height: 18px; text-align: center; background: #fff; }
|
.state, .message { margin-bottom: 10px; padding: 12px; border-radius: 9px; color: #667085; font-size: 12px; line-height: 18px; text-align: center; background: #fff; }
|
||||||
.message.error { color: #b42318; background: #fff1f0; }.message.success { color: #067647; background: #ecfdf3; }
|
.message.error { color: #b42318; background: #fff1f0; }.message.success { color: #067647; background: #ecfdf3; }
|
||||||
@@ -210,7 +263,7 @@
|
|||||||
.status, .pallet-status { flex-shrink: 0; padding: 4px 8px; border-radius: 99px; color: #155eef; font-size: 12px; background: #eff4ff; }.pallet-status.sealed { color: #067647; background: #ecfdf3; }
|
.status, .pallet-status { flex-shrink: 0; padding: 4px 8px; border-radius: 99px; color: #155eef; font-size: 12px; background: #eff4ff; }.pallet-status.sealed { color: #067647; background: #ecfdf3; }
|
||||||
.warehouse-summary { display: block; margin-top: 3px; color: #667085; font-size: 12px; }
|
.warehouse-summary { display: block; margin-top: 3px; color: #667085; font-size: 12px; }
|
||||||
.compact-metrics { display: grid; grid-template-columns: repeat(5,1fr); margin-top: 8px; padding-top: 7px; border-top: 1px solid #edf1f5; }.compact-metrics > text { display: flex; align-items: center; justify-content: center; gap: 3px; color: #667085; font-size: 11px; white-space: nowrap; }.compact-metrics strong { color: #12233f; font-size: 15px; }.compact-metrics .pallet-readiness { color: #067647; }.compact-metrics .pallet-readiness.invalid,.compact-metrics .pallet-readiness.invalid strong { color: #b42318; }.pallet-setup-error { margin-top: 8px; margin-bottom: 0; text-align: left; }
|
.compact-metrics { display: grid; grid-template-columns: repeat(5,1fr); margin-top: 8px; padding-top: 7px; border-top: 1px solid #edf1f5; }.compact-metrics > text { display: flex; align-items: center; justify-content: center; gap: 3px; color: #667085; font-size: 11px; white-space: nowrap; }.compact-metrics strong { color: #12233f; font-size: 15px; }.compact-metrics .pallet-readiness { color: #067647; }.compact-metrics .pallet-readiness.invalid,.compact-metrics .pallet-readiness.invalid strong { color: #b42318; }.pallet-setup-error { margin-top: 8px; margin-bottom: 0; text-align: left; }
|
||||||
.section-title { display: block; color: #12233f; font-size: 18px; font-weight: 700; }.section-desc { display: block; margin: 2px 0 8px; color: #667085; font-size: 13px; line-height: 18px; }.section-heading > view { min-width: 0; }
|
.section-title { display: block; color: #12233f; font-size: 18px; font-weight: 700; }.section-heading { align-items: center; }.refresh-button { display: flex; align-items: center; justify-content: center; flex-shrink: 0; box-sizing: border-box; min-width: 88px; height: 44px; margin: 0; padding: 0 16px; border: 1px solid #9dc0f4; border-radius: 8px; color: #155eef; font-size: 16px; font-weight: 700; line-height: normal; background: #eaf2ff; }.refresh-button::after { border: 0; }.refresh-button[disabled] { color: #718095; border-color: #dce5f0; background: #e8edf3; }
|
||||||
.primary-button, .secondary-button { box-sizing: border-box; width: 100%; height: 46px; margin-top: 10px; border-radius: 9px; font-size: 14px; font-weight: 700; line-height: 44px; }.primary-button { border: 0; color: #fff; background: #1a73e8; }.secondary-button { border: 1px solid #8ab8ef; color: #155eef; background: #f6f9ff; }.primary-button[disabled],.secondary-button[disabled] { opacity: .45; }
|
.primary-button, .secondary-button { box-sizing: border-box; width: 100%; height: 46px; margin-top: 10px; border-radius: 9px; font-size: 14px; font-weight: 700; line-height: 44px; }.primary-button { border: 0; color: #fff; background: #1a73e8; }.secondary-button { border: 1px solid #8ab8ef; color: #155eef; background: #f6f9ff; }.primary-button[disabled],.secondary-button[disabled] { opacity: .45; }
|
||||||
.unseal-button { border-color: #fda29b; color: #b42318; background: #fff5f4; }.pallet-hint.blocked { color: #b42318; }
|
.unseal-button { border-color: #fda29b; color: #b42318; background: #fff5f4; }.pallet-hint.blocked { color: #b42318; }
|
||||||
.unseal-dialog { box-sizing: border-box; width: min(350px, calc(100vw - 32px)); overflow: hidden; border-radius: 12px; background: #fff; }.unseal-copy { display: block; padding: 18px 16px; color: #475467; font-size: 14px; line-height: 21px; }.unseal-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 0 16px 16px; }.unseal-actions .secondary-button { margin-top: 0; }.danger-button { height: 46px; margin: 0; border: 0; border-radius: 9px; color: #fff; font-size: 14px; font-weight: 700; background: #d92d20; }.danger-button[disabled] { opacity: .45; }
|
.unseal-dialog { box-sizing: border-box; width: min(350px, calc(100vw - 32px)); overflow: hidden; border-radius: 12px; background: #fff; }.unseal-copy { display: block; padding: 18px 16px; color: #475467; font-size: 14px; line-height: 21px; }.unseal-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 0 16px 16px; }.unseal-actions .secondary-button { margin-top: 0; }.danger-button { height: 46px; margin: 0; border: 0; border-radius: 9px; color: #fff; font-size: 14px; font-weight: 700; background: #d92d20; }.danger-button[disabled] { opacity: .45; }
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
<view v-if="user" class="page-shell dropship-task-page">
|
<view v-if="user" class="page-shell dropship-task-page">
|
||||||
<PortalHeader title="一件代发" :user="user" :fallback-url="'/pages/home/home'" />
|
<PortalHeader title="一件代发" :user="user" :fallback-url="'/pages/home/home'" />
|
||||||
<view class="content">
|
<view class="content">
|
||||||
|
<view v-if="palletNo" class="state">继续装入托盘 {{ palletNo }} · 选择其他票,或进入后直接扫描其他客户箱码</view>
|
||||||
<view class="search-card">
|
<view class="search-card">
|
||||||
<ScanTargetInput v-model="keyword" name="taskKeyword" label="入仓码查询" placeholder="扫描或输入入仓码" :active="true" @submit="loadTasks" />
|
<ScanTargetInput v-model="keyword" name="taskKeyword" label="入仓码查询" placeholder="扫描或输入入仓码" :active="true" @submit="loadTasks" />
|
||||||
<button class="primary-button search-button" :disabled="loading" @tap="loadTasks">{{ loading ? '查询中…' : '查询任务' }}</button>
|
<button class="primary-button search-button" :disabled="loading" @tap="loadTasks">{{ loading ? '查询中…' : '查询任务' }}</button>
|
||||||
</view>
|
</view>
|
||||||
<view class="heading-row">
|
<view class="heading-row">
|
||||||
<view><text class="heading">待入库任务</text><text class="summary">{{ loading ? '正在同步' : `共 ${tasks.length} 个任务` }}</text></view>
|
<view><text class="heading">待入库任务</text><text class="summary">{{ loading ? '正在同步' : `共 ${tasks.length} 个任务` }}</text></view>
|
||||||
<button class="refresh-button" :disabled="loading" @tap="refresh">刷新</button>
|
|
||||||
</view>
|
</view>
|
||||||
<view v-if="error" class="state error" role="alert">{{ error }}</view>
|
<view v-if="error" class="state error" role="alert">{{ error }}</view>
|
||||||
<view v-else-if="!loading && !tasks.length" class="state">暂无待入库任务</view>
|
<view v-else-if="!loading && !tasks.length" class="state">暂无待入库任务</view>
|
||||||
@@ -32,29 +32,34 @@
|
|||||||
<script>
|
<script>
|
||||||
import PortalHeader from '@/components/PortalHeader.vue'
|
import PortalHeader from '@/components/PortalHeader.vue'
|
||||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||||
import { listDropshipPalletizationTasks } from '@/api/pda-dropship.js'
|
import { listDropshipPalletizationTasks, resolveDropshipWorkspacePallet } from '@/api/pda-dropship.js'
|
||||||
import { session, requireSession } from '@/stores/session.js'
|
import { session, requireSession } from '@/stores/session.js'
|
||||||
import { formatApiError } from '@/utils/user-facing.js'
|
import { formatApiError } from '@/utils/user-facing.js'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { PortalHeader, ScanTargetInput },
|
components: { PortalHeader, ScanTargetInput },
|
||||||
data() { return { user: session.currentUser, keyword: '', tasks: [], loading: false, error: '' } },
|
data() { return { user: session.currentUser, keyword: '', tasks: [], loading: false, error: '', palletNo: '' } },
|
||||||
|
onLoad(options) { this.palletNo = String(options.palletNo || '') },
|
||||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadTasks() },
|
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadTasks() },
|
||||||
methods: {
|
methods: {
|
||||||
statusLabel(status) { return status === 'CLAIMED' ? '作业中' : '待领取' },
|
statusLabel(status) { return status === 'CLAIMED' ? '作业中' : '待领取' },
|
||||||
refresh() { this.keyword = ''; this.loadTasks() },
|
|
||||||
async loadTasks() {
|
async loadTasks() {
|
||||||
if (this.loading) return
|
if (this.loading) return
|
||||||
if (!this.user?.warehouseId) { this.error = '请返回首页选择作业仓库'; return }
|
if (!this.user?.warehouseId) { this.error = '请返回首页选择作业仓库'; return }
|
||||||
this.loading = true; this.error = ''
|
this.loading = true; this.error = ''
|
||||||
try {
|
try {
|
||||||
|
// 返回列表时重查跨票续装目标,已封托盘不再传入其他任务。
|
||||||
|
if (this.palletNo) {
|
||||||
|
const workspace = await resolveDropshipWorkspacePallet(this.user.warehouseId, this.palletNo)
|
||||||
|
if (workspace.pallet.status !== 'OPEN') this.palletNo = ''
|
||||||
|
}
|
||||||
const records = await listDropshipPalletizationTasks(this.keyword, this.user.warehouseId)
|
const records = await listDropshipPalletizationTasks(this.keyword, this.user.warehouseId)
|
||||||
this.tasks = Array.isArray(records) ? records : []
|
this.tasks = Array.isArray(records) ? records : []
|
||||||
}
|
}
|
||||||
catch (error) { this.tasks = []; this.error = formatApiError(error, '一件代发入库任务加载失败') }
|
catch (error) { this.tasks = []; this.error = formatApiError(error, '一件代发入库任务加载失败') }
|
||||||
finally { this.loading = false }
|
finally { this.loading = false }
|
||||||
},
|
},
|
||||||
openTask(task) { uni.navigateTo({ url: `/pages/dropship-palletization/dropship-palletization?taskId=${encodeURIComponent(task.taskId)}&warehouseId=${encodeURIComponent(task.warehouseId)}` }) }
|
openTask(task) { uni.navigateTo({ url: `/pages/dropship-palletization/dropship-palletization?taskId=${encodeURIComponent(task.taskId)}&warehouseId=${encodeURIComponent(task.warehouseId)}&palletNo=${encodeURIComponent(this.palletNo)}` }) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -73,7 +78,6 @@
|
|||||||
.heading-row > view { display: flex; align-items: baseline; gap: 8px; }
|
.heading-row > view { display: flex; align-items: baseline; gap: 8px; }
|
||||||
.heading { color: #12233f; font-size: 17px; font-weight: 700; }
|
.heading { color: #12233f; font-size: 17px; font-weight: 700; }
|
||||||
.summary { color: #667085; font-size: 11px; }
|
.summary { color: #667085; font-size: 11px; }
|
||||||
.refresh-button { min-width: 64px; height: 40px; border: 0; border-radius: 6px; color: #155eef; font-size: 13px; line-height: 40px; background: #eaf2ff; }
|
|
||||||
.state { padding: 24px 12px; border-radius: 10px; color: #667085; font-size: 13px; text-align: center; background: #fff; }
|
.state { padding: 24px 12px; border-radius: 10px; color: #667085; font-size: 13px; text-align: center; background: #fff; }
|
||||||
.state.error { color: #b42318; background: #fff1f0; }
|
.state.error { color: #b42318; background: #fff1f0; }
|
||||||
.task-list { overflow: hidden; border: 1px solid #dce6f1; border-radius: 7px; background: #fff; }
|
.task-list { overflow: hidden; border: 1px solid #dce6f1; border-radius: 7px; background: #fff; }
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<view v-if="user" class="page-shell portal-page">
|
<view v-if="user" class="page-shell portal-page">
|
||||||
<PortalHeader :title="operationName" :user="user" :fallback-url="`/pages/module/module?key=${moduleKey}`" />
|
<PortalHeader :title="operationName" :user="user" :fallback-url="`/pages/module/module?key=${moduleKey}`" />
|
||||||
<view class="portal-content operation-page">
|
<view class="portal-content operation-page">
|
||||||
<view class="operation-page-title-row"><view><text class="operation-page-title">{{ operationName }}</text><text class="operation-page-subtitle">{{ prototype.subtitle }}</text></view><text class="prototype-badge">静态原型</text></view>
|
<view class="operation-page-title-row"><view><text class="operation-page-title">{{ operationName }}</text></view><text class="prototype-badge">静态原型</text></view>
|
||||||
<view class="operation-scan-card"><text class="operation-scan-label">{{ prototype.scanLabel }}</text><view class="operation-scan-row"><input class="operation-scan-input" :aria-label="prototype.scanLabel" :placeholder="prototype.scanPlaceholder" confirm-type="search" @confirm="prototypeScan" /><button class="operation-scan-button" @tap="prototypeScan">扫码</button></view><text class="operation-scan-tip">支持硬件扫描头连续扫描,也可手工输入单号</text></view>
|
<view class="operation-scan-card"><text class="operation-scan-label">{{ prototype.scanLabel }}</text><view class="operation-scan-row"><input class="operation-scan-input" :aria-label="prototype.scanLabel" :placeholder="prototype.scanPlaceholder" confirm-type="search" @confirm="prototypeScan" /><button class="operation-scan-button" @tap="prototypeScan">扫码</button></view><text class="operation-scan-tip">支持硬件扫描头连续扫描,也可手工输入单号</text></view>
|
||||||
<view class="operation-metrics"><view v-for="metric in prototype.metrics" :key="metric.label" class="operation-metric"><text class="operation-metric-value" :class="metric.tone">{{ metric.value }}</text><text class="operation-metric-label">{{ metric.label }}</text></view></view>
|
<view class="operation-metrics"><view v-for="metric in prototype.metrics" :key="metric.label" class="operation-metric"><text class="operation-metric-value" :class="metric.tone">{{ metric.value }}</text><text class="operation-metric-label">{{ metric.label }}</text></view></view>
|
||||||
<view class="task-toolbar"><text class="group-title operation-group-title">任务列表</text><button class="filter-button" @tap="showToast('筛选面板为静态原型', 'warning')">筛选</button></view>
|
<view class="task-toolbar"><text class="group-title operation-group-title">任务列表</text><button class="filter-button" @tap="showToast('筛选面板为静态原型', 'warning')">筛选</button></view>
|
||||||
|
|||||||
+137
-83
@@ -107,7 +107,7 @@
|
|||||||
<button v-if="itemState(item) === ITEM_STATUS.PENDING" class="pick-btn pick-btn-skip" @tap="openSkip(item.id)">跳拣</button>
|
<button v-if="itemState(item) === ITEM_STATUS.PENDING" class="pick-btn pick-btn-skip" @tap="openSkip(item.id)">跳拣</button>
|
||||||
</view>
|
</view>
|
||||||
<view v-else-if="pickingOperationReady" class="pick-item-actions processed-actions">
|
<view v-else-if="pickingOperationReady" class="pick-item-actions processed-actions">
|
||||||
<text class="version-hint">已同步服务端</text>
|
<text class="version-hint">已保存</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -129,31 +129,32 @@
|
|||||||
<text class="evidence-count" :class="{ complete: evidenceEntries(slot.type).length }">{{ evidenceEntries(slot.type).length ? `已拍 ${evidenceEntries(slot.type).length} 张` : '待拍摄' }}</text>
|
<text class="evidence-count" :class="{ complete: evidenceEntries(slot.type).length }">{{ evidenceEntries(slot.type).length ? `已拍 ${evidenceEntries(slot.type).length} 张` : '待拍摄' }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<button class="evidence-btn evidence-add-btn" @tap="chooseEvidence(slot)">{{ evidenceUploadLabel(slot.type) }}</button>
|
<button class="evidence-btn evidence-add-btn" :disabled="evidenceReadOnly || evidenceUploading" @tap="chooseEvidence(slot)">{{ evidenceUploadLabel(slot.type) }}</button>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="evidenceEntries(slot.type).length" class="evidence-photo-grid">
|
<view v-if="evidenceEntries(slot.type).length" class="evidence-photo-grid">
|
||||||
<view v-for="photo in evidenceEntries(slot.type)" :key="photo.id || photo.fileId || photo.localPath" class="evidence-photo-item">
|
<view v-for="photo in evidenceEntries(slot.type)" :key="photo.id || photo.fileId || photo.localPath" class="evidence-photo-item">
|
||||||
<image v-if="evidencePreviewSource(photo)" class="evidence-thumbnail" :src="evidencePreviewSource(photo)" mode="aspectFill" @tap="previewEvidencePhoto(photo, slot.type)" />
|
<image v-if="evidencePreviewSource(photo)" class="evidence-thumbnail" :src="evidencePreviewSource(photo)" mode="aspectFill" @tap="previewEvidencePhoto(photo, slot.type)" />
|
||||||
<view v-else class="evidence-thumbnail evidence-thumbnail-placeholder" @tap="previewEvidencePhoto(photo, slot.type)">图片处理中</view>
|
<view v-else class="evidence-thumbnail evidence-thumbnail-placeholder" @tap="previewEvidencePhoto(photo, slot.type)">图片处理中</view>
|
||||||
<text class="evidence-file" @tap="previewEvidencePhoto(photo, slot.type)">{{ photo.fileName || '已记录' }}</text>
|
<text class="evidence-file" @tap="previewEvidencePhoto(photo, slot.type)">{{ photo.fileName || '已记录' }}</text>
|
||||||
<button v-if="photo.id && photo.status !== 'VOID' && photo.status !== 'VOIDED'" class="evidence-delete-btn" aria-label="删除照片" @tap="deleteEvidencePhoto(photo)">×</button>
|
<button v-if="!evidenceReadOnly && photo.id && photo.status !== 'VOID' && photo.status !== 'VOIDED'" class="evidence-delete-btn" aria-label="删除照片" @tap="deleteEvidencePhoto(photo)">×</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<text v-if="evidenceQueueEntry(slot.type)" class="evidence-upload-state" :class="evidenceQueueEntry(slot.type).status.toLowerCase()">
|
<text v-if="evidenceQueueEntry(slot.type)" class="evidence-upload-state" :class="evidenceQueueEntry(slot.type).status.toLowerCase()">
|
||||||
{{ evidenceUploadState(slot.type) }}
|
{{ evidenceUploadState(slot.type) }}
|
||||||
</text>
|
</text>
|
||||||
<text v-else-if="hasPendingLocalEvidence(slot.type)" class="evidence-upload-state pending">
|
<text v-else-if="hasPendingLocalEvidence(slot.type)" class="evidence-upload-state pending">
|
||||||
{{ slot.type === 'SEAL' ? '请填写封签号,保存后上传' : '照片待自动上传' }}
|
{{ slot.type === 'SEAL' ? '请填写封签号,保存后上传' : '照片未保存,请重新拍照上传' }}
|
||||||
</text>
|
</text>
|
||||||
<button v-if="evidenceQueueEntry(slot.type)?.status === 'FAILED'" class="evidence-retry-btn" @tap="retryEvidence(slot.type)">重试上传</button>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="seal-row">
|
<view class="seal-row">
|
||||||
<input v-model="sealNo" class="seal-input" placeholder="输入封签号(必填)" maxlength="64" />
|
<input v-model="sealNo" :disabled="evidenceReadOnly" class="seal-input" placeholder="输入封签号(必填)" maxlength="64" />
|
||||||
<button class="evidence-btn seal-btn" :disabled="sealSaving || !sealReadyToSave" @tap="saveSealNo">{{ sealSaving ? '上传中…' : '保存并上传' }}</button>
|
<button class="evidence-btn seal-btn" :disabled="evidenceReadOnly || evidenceUploading || sealSaving || !sealReadyToSave" @tap="saveSealNo">{{ sealSaving ? '上传中…' : '保存并上传' }}</button>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="remoteError" class="remote-error-row"><text class="remote-error" role="alert">{{ remoteError }}</text><button class="evidence-retry-btn" :disabled="remoteLoading" @tap="refreshTask">刷新任务</button></view>
|
<view v-if="remoteError" class="remote-error-row"><text class="remote-error" role="alert">{{ remoteError }}</text><button class="evidence-retry-btn" :disabled="remoteLoading" @tap="refreshTask">刷新任务</button></view>
|
||||||
<button class="loading-complete-btn" :disabled="submitting || !loadingStageReady || pickingVersion === null || !evidenceComplete" @tap="completeLoadingTask">确认装柜完成</button>
|
<text v-if="evidenceReadOnly" class="version-hint" role="status">{{ loadingCompletionState.label }}</text>
|
||||||
|
<button v-else class="loading-complete-btn" :disabled="submitting || evidenceUploading || !loadingStageReady || pickingVersion === null || !evidenceComplete" @tap="completeLoadingTask">确认装柜完成</button>
|
||||||
|
<button v-if="evidenceReadOnly && loadingCompletionState.phase !== 'COMPLETED'" class="evidence-retry-btn" :disabled="remoteLoading || submitting" @tap="refreshTask">刷新完成状态</button>
|
||||||
<view v-if="pickingVersion === null" class="remote-error-row">
|
<view v-if="pickingVersion === null" class="remote-error-row">
|
||||||
<text class="version-hint" role="status">状态尚未同步,请刷新重试</text>
|
<text class="version-hint" role="status">状态尚未同步,请刷新重试</text>
|
||||||
<button class="evidence-retry-btn" :disabled="remoteLoading" @tap="refreshTask">刷新任务</button>
|
<button class="evidence-retry-btn" :disabled="remoteLoading" @tap="refreshTask">刷新任务</button>
|
||||||
@@ -166,7 +167,7 @@
|
|||||||
|
|
||||||
<view v-if="activeStage === 'picking'" class="submit-section">
|
<view v-if="activeStage === 'picking'" class="submit-section">
|
||||||
<view class="submit-info">
|
<view class="submit-info">
|
||||||
<view class="submit-pending"><text class="submit-pending-number">{{ summary.pending }}</text><text class="submit-pending-label"> 箱待拣</text></view>
|
<view class="submit-pending"><text class="submit-pending-number">{{ remainingCartons }}</text><text class="submit-pending-label"> 箱待拣</text></view>
|
||||||
<text class="submit-status-hint">{{ submitStatusHint }}</text>
|
<text class="submit-status-hint">{{ submitStatusHint }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="remoteError && !pickingCompleted" class="remote-error-row submit-error-row">
|
<view v-if="remoteError && !pickingCompleted" class="remote-error-row submit-error-row">
|
||||||
@@ -281,7 +282,7 @@
|
|||||||
<uni-popup ref="submitConfirmPopup" type="bottom" background-color="#ffffff" border-radius="16px 16px 0 0" :is-mask-click="true" @change="handleOverlayChange('submitConfirm', $event)">
|
<uni-popup ref="submitConfirmPopup" type="bottom" background-color="#ffffff" border-radius="16px 16px 0 0" :is-mask-click="true" @change="handleOverlayChange('submitConfirm', $event)">
|
||||||
<view class="modal-sheet decision-modal submit-decision-modal" role="dialog" aria-modal="true" aria-label="确认未完成提交" @tap.stop>
|
<view class="modal-sheet decision-modal submit-decision-modal" role="dialog" aria-modal="true" aria-label="确认未完成提交" @tap.stop>
|
||||||
<PopupHeader :title="`仍有 ${summary.pending} 件未完成`" tone="danger" @close="closeOverlay" />
|
<PopupHeader :title="`仍有 ${summary.pending} 件未完成`" tone="danger" @close="closeOverlay" />
|
||||||
<text class="decision-desc submit-decision-desc">确认提交后,以下待拣商品会被明确标记为“未拣出”,本次演示提交不可撤销。</text>
|
<text class="decision-desc submit-decision-desc">确认提交后,以下待拣商品会标记为“未拣出”,并记录所选原因。</text>
|
||||||
<view class="pending-sku-list">
|
<view class="pending-sku-list">
|
||||||
<text v-for="item in pendingItems.slice(0, 3)" :key="item.id">{{ item.location ? `${item.sku} · ${item.location}` : item.sku }}</text>
|
<text v-for="item in pendingItems.slice(0, 3)" :key="item.id">{{ item.location ? `${item.sku} · ${item.location}` : item.sku }}</text>
|
||||||
<text v-if="pendingItems.length > 3">另有 {{ pendingItems.length - 3 }} 件</text>
|
<text v-if="pendingItems.length > 3">另有 {{ pendingItems.length - 3 }} 件</text>
|
||||||
@@ -292,7 +293,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="modal-actions submit-decision-actions">
|
<view class="modal-actions submit-decision-actions">
|
||||||
<button class="modal-cancel-btn" @tap="closeOverlay">继续拣货</button>
|
<button class="modal-cancel-btn" @tap="closeOverlay">继续拣货</button>
|
||||||
<button class="modal-confirm-btn danger-confirm" :disabled="!selectedIncompleteReason" @tap="confirmIncompleteSubmit">确认未拣并提交</button>
|
<button class="modal-confirm-btn danger-confirm" :disabled="submitting || !selectedIncompleteReason" @tap="confirmIncompleteSubmit">{{ submitting ? '正在提交…' : '确认未拣并提交' }}</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</uni-popup>
|
</uni-popup>
|
||||||
@@ -336,19 +337,18 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ITEM_STATUS, classifyScan, createItemStates, getPickSummary, isSealedPalletItem, submitStates, summarizeInventoryResources } from '@/utils/picking-state.js'
|
import { ITEM_STATUS, classifyScan, createItemStates, getPickSummary, isSealedPalletItem, submitStates, summarizeInventoryResources, remainingPhysicalCartons } from '@/utils/picking-state.js'
|
||||||
import { SKIP_REASONS, INCOMPLETE_REASONS } from '@/data/demo-data.js'
|
import { SKIP_REASONS, INCOMPLETE_REASONS } from '@/data/demo-data.js'
|
||||||
import { session, requireSession } from '@/stores/session.js'
|
import { session, requireSession } from '@/stores/session.js'
|
||||||
import { clearLoadingCompletionAttempt, clearPickingDraft, getPickingSession, restoreLoadingCompletionAttempt, restorePickingSession, saveLoadingCompletionAttempt, savePickingSession } from '@/stores/picking.js'
|
import { clearPickingDraft, getPickingSession, restorePickingSession, savePickingSession } from '@/stores/picking.js'
|
||||||
import { navigateBackOr } from '@/utils/navigation.js'
|
import { navigateBackOr } from '@/utils/navigation.js'
|
||||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||||
import { claimContainerPickingTask, completeContainerPickingTask, downloadEvidencePreview, EVIDENCE_SLOTS, getContainerPickingTask, getEvidence, getEvidencePreviewUrl, getPickingInventorySnapshot, isRemoteReady, listLoadingOrders, normalizePickingTask, partialContainerItem, pickContainerItem, pickContainerPallet, skipContainerItem, submitContainerPickingBatch, uploadEvidenceFile, voidEvidence } from '@/api/pda-loading.js'
|
import { claimContainerPickingTask, completeContainerPickingTask, downloadEvidencePreview, EVIDENCE_SLOTS, getContainerPickingTask, getEvidence, getEvidencePreviewUrl, getPickingInventorySnapshot, isRemoteReady, listLoadingOrders, normalizePickingTask, partialContainerItem, pickContainerItem, pickContainerPallet, skipContainerItem, submitContainerPickingBatch, uploadEvidenceFile, voidEvidence } from '@/api/pda-loading.js'
|
||||||
import { createEvidenceUploadQueue } from '@/utils/evidence-upload-queue.js'
|
|
||||||
import { isOnline } from '@/utils/network.js'
|
import { isOnline } from '@/utils/network.js'
|
||||||
import { stableOperationId } from '@/utils/idempotency.js'
|
import { stableOperationId } from '@/utils/idempotency.js'
|
||||||
import { stableTaskOperationIdentity } from '@/utils/task-identity.js'
|
import { stableTaskOperationIdentity } from '@/utils/task-identity.js'
|
||||||
import { COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion } from '@/utils/loading-completion-state.js'
|
import { COMPLETION_PHASE, completionPresentation, observeLoadingCompletion, resolveCompletionPhase, shouldReconcileCompletion } from '@/utils/loading-completion-state.js'
|
||||||
import { containerReference, formatApiError, sanitizeUserMessage } from '@/utils/user-facing.js'
|
import { containerReference, formatApiError, sanitizeUserMessage } from '@/utils/user-facing.js'
|
||||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||||
import PopupHeader from '@/components/PopupHeader.vue'
|
import PopupHeader from '@/components/PopupHeader.vue'
|
||||||
@@ -390,7 +390,7 @@
|
|||||||
selectedIncompleteReason: '',
|
selectedIncompleteReason: '',
|
||||||
completionSummary: { picked: 0, partial: 0, skipped: 0, autoSkipped: 0 },
|
completionSummary: { picked: 0, partial: 0, skipped: 0, autoSkipped: 0 },
|
||||||
evidenceSlots: EVIDENCE_SLOTS,
|
evidenceSlots: EVIDENCE_SLOTS,
|
||||||
evidenceFiles: {}, evidenceDrafts: {}, evidencePreviewCache: {}, evidenceQueueItems: [], evidenceQueue: null, evidenceContextId: 0, evidenceIdentity: null, cameraPicking: false, evidencePreviewOpening: false, evidencePreviewReturning: false, sealNo: '', sealSaving: false, pickingVersion: null, remotePick: null,
|
evidenceFiles: {}, evidenceDrafts: {}, evidencePreviewCache: {}, evidenceQueueItems: [], evidenceUploading: false, evidenceContextId: 0, evidenceIdentity: null, cameraPicking: false, evidencePreviewOpening: false, evidencePreviewReturning: false, sealNo: '', sealSaving: false, pickingVersion: null, remotePick: null,
|
||||||
remoteTaskId: null, assignmentVersion: null, submitId: '',
|
remoteTaskId: null, assignmentVersion: null, submitId: '',
|
||||||
taskRequestId: 0,
|
taskRequestId: 0,
|
||||||
remoteError: '', remoteLoading: false, submitting: false,
|
remoteError: '', remoteLoading: false, submitting: false,
|
||||||
@@ -416,6 +416,9 @@
|
|||||||
pendingItems() {
|
pendingItems() {
|
||||||
return this.currentBox.items.filter((item) => this.itemState(item) === ITEM_STATUS.PENDING)
|
return this.currentBox.items.filter((item) => this.itemState(item) === ITEM_STATUS.PENDING)
|
||||||
},
|
},
|
||||||
|
remainingCartons() {
|
||||||
|
return remainingPhysicalCartons(this.currentBox.items, this.itemStates, this.partialQuantities)
|
||||||
|
},
|
||||||
scanInputLabel() {
|
scanInputLabel() {
|
||||||
return this.pendingItems.some((item) => isSealedPalletItem(item)) ? '托盘码或托内箱码扫码框' : '箱码扫码框'
|
return this.pendingItems.some((item) => isSealedPalletItem(item)) ? '托盘码或托内箱码扫码框' : '箱码扫码框'
|
||||||
},
|
},
|
||||||
@@ -444,6 +447,17 @@
|
|||||||
&& Boolean(this.remoteTaskId && this.currentBox.loadingPlanId && this.currentBox.containerBookingId)
|
&& Boolean(this.remoteTaskId && this.currentBox.loadingPlanId && this.currentBox.containerBookingId)
|
||||||
&& this.pickingVersion !== null
|
&& this.pickingVersion !== null
|
||||||
},
|
},
|
||||||
|
loadingCompletionState() {
|
||||||
|
const phase = resolveCompletionPhase({
|
||||||
|
taskStatus: this.remotePick?.workflowStatus || this.remotePick?.taskStatus,
|
||||||
|
commandStatus: this.remotePick?.completionCommand?.status || this.remotePick?.commandStatus,
|
||||||
|
submitting: this.submitting
|
||||||
|
})
|
||||||
|
return { ...completionPresentation(phase), phase }
|
||||||
|
},
|
||||||
|
evidenceReadOnly() {
|
||||||
|
return this.loadingCompletionState.blocked && !this.loadingCompletionState.retryable
|
||||||
|
},
|
||||||
loadingStageReady() {
|
loadingStageReady() {
|
||||||
return this.pickingCompleted
|
return this.pickingCompleted
|
||||||
&& this.evidenceIdentity
|
&& this.evidenceIdentity
|
||||||
@@ -559,12 +573,15 @@
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (this._showRefreshPromise) return this._showRefreshPromise
|
if (this._showRefreshPromise) return this._showRefreshPromise
|
||||||
this._showRefreshPromise = (async () => {
|
const showGeneration = this.taskRequestId
|
||||||
|
const refreshPromise = (async () => {
|
||||||
await this.refreshAvailableBoxes()
|
await this.refreshAvailableBoxes()
|
||||||
|
if (!this._pageVisible || showGeneration !== this.taskRequestId) return
|
||||||
await this.syncRemoteTask()
|
await this.syncRemoteTask()
|
||||||
})()
|
})()
|
||||||
try { await this._showRefreshPromise }
|
this._showRefreshPromise = refreshPromise
|
||||||
finally { this._showRefreshPromise = null }
|
try { await refreshPromise }
|
||||||
|
finally { if (this._showRefreshPromise === refreshPromise) this._showRefreshPromise = null }
|
||||||
},
|
},
|
||||||
onHide() {
|
onHide() {
|
||||||
this._pageVisible = false
|
this._pageVisible = false
|
||||||
@@ -572,10 +589,11 @@
|
|||||||
this.evidencePreviewOpening = false
|
this.evidencePreviewOpening = false
|
||||||
this.evidencePreviewReturning = true
|
this.evidencePreviewReturning = true
|
||||||
}
|
}
|
||||||
|
if (!this.cameraPicking && !this.evidencePreviewReturning) this.invalidateTaskSync()
|
||||||
this.stopHardwareScanner()
|
this.stopHardwareScanner()
|
||||||
},
|
},
|
||||||
onUnload() { this.stopHardwareScanner(); this.persistSession(); this.clearTimers() },
|
onUnload() { this._pageVisible = false; this.invalidateTaskSync(); this.evidenceContextId++; this.stopHardwareScanner(); this.persistSession(); this.clearTimers() },
|
||||||
beforeUnmount() { this.stopHardwareScanner(); this.clearTimers() },
|
beforeUnmount() { this._pageVisible = false; this.invalidateTaskSync(); this.stopHardwareScanner(); this.clearTimers() },
|
||||||
onBackPress() {
|
onBackPress() {
|
||||||
if (this.activeOverlay) { this.closeOverlay(); return true }
|
if (this.activeOverlay) { this.closeOverlay(); return true }
|
||||||
if (this.boxDropdownOpen) { this.boxDropdownOpen = false; return true }
|
if (this.boxDropdownOpen) { this.boxDropdownOpen = false; return true }
|
||||||
@@ -583,6 +601,11 @@
|
|||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
invalidateTaskSync() { this.taskRequestId++; this.remoteLoading = false; this._showRefreshPromise = null },
|
||||||
|
isTaskSyncActive(requestId, taskId) {
|
||||||
|
return this._pageVisible === true && requestId === this.taskRequestId
|
||||||
|
&& String(this.remoteTaskId || this.currentBox.taskId) === String(taskId)
|
||||||
|
},
|
||||||
containerReference,
|
containerReference,
|
||||||
isSelectableTask(task) {
|
isSelectableTask(task) {
|
||||||
return Boolean(task?.recordStatus === 'ACTIVE'
|
return Boolean(task?.recordStatus === 'ACTIVE'
|
||||||
@@ -791,6 +814,10 @@
|
|||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
requireEvidenceStage() {
|
requireEvidenceStage() {
|
||||||
|
if (this.evidenceReadOnly) {
|
||||||
|
this.showToast(this.loadingCompletionState.label, 'warning')
|
||||||
|
return false
|
||||||
|
}
|
||||||
if (!this.pickingCompleted) {
|
if (!this.pickingCompleted) {
|
||||||
this.showToast('请先提交拣货完成', 'warning')
|
this.showToast('请先提交拣货完成', 'warning')
|
||||||
return false
|
return false
|
||||||
@@ -809,10 +836,7 @@
|
|||||||
this.evidenceQueueItems = []
|
this.evidenceQueueItems = []
|
||||||
this.sealNo = ''
|
this.sealNo = ''
|
||||||
this.sealSaving = false
|
this.sealSaving = false
|
||||||
this.evidenceQueue = createEvidenceUploadQueue({
|
this.evidenceUploading = false
|
||||||
upload: (item, onProgress) => uploadEvidenceFile(item.taskId, item.warehouseId, item, item.localPath, onProgress),
|
|
||||||
onChange: (items) => { if (this.evidenceContextId === contextId) this.evidenceQueueItems = items }
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
normalizeScanHistory(entries) {
|
normalizeScanHistory(entries) {
|
||||||
if (!Array.isArray(entries)) return []
|
if (!Array.isArray(entries)) return []
|
||||||
@@ -1003,7 +1027,7 @@
|
|||||||
[ITEM_STATUS.PARTIAL]: '已部分拣货',
|
[ITEM_STATUS.PARTIAL]: '已部分拣货',
|
||||||
[ITEM_STATUS.SKIPPED]: '已跳拣'
|
[ITEM_STATUS.SKIPPED]: '已跳拣'
|
||||||
}
|
}
|
||||||
this.recordScanResult('warning', '重复扫描', value, `该条码已${labels[result.status]},无需重复处理`)
|
this.recordScanResult('warning', '重复扫描', value, `该条码${labels[result.status]},无需重复处理`)
|
||||||
this.showToast(`该条码已处理:${value}`, 'warning')
|
this.showToast(`该条码已处理:${value}`, 'warning')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1074,7 +1098,7 @@
|
|||||||
return ''
|
return ''
|
||||||
},
|
},
|
||||||
async syncRemoteTask() {
|
async syncRemoteTask() {
|
||||||
if (!session.token) return
|
if (!session.token || !this._pageVisible) return
|
||||||
const requestedTaskId = this.remoteTaskId || this.currentBox.taskId
|
const requestedTaskId = this.remoteTaskId || this.currentBox.taskId
|
||||||
if (!requestedTaskId) { this.remoteError = '当前没有可进入的拣货任务,请返回任务列表刷新'; return }
|
if (!requestedTaskId) { this.remoteError = '当前没有可进入的拣货任务,请返回任务列表刷新'; return }
|
||||||
this.rememberEvidenceDraft()
|
this.rememberEvidenceDraft()
|
||||||
@@ -1098,7 +1122,7 @@
|
|||||||
try {
|
try {
|
||||||
const warehouseId = this.currentBox.warehouseId
|
const warehouseId = this.currentBox.warehouseId
|
||||||
const task = await getContainerPickingTask(selector, warehouseId)
|
const task = await getContainerPickingTask(selector, warehouseId)
|
||||||
if (requestId !== this.taskRequestId) return
|
if (!this.isTaskSyncActive(requestId, requestedTaskId)) return
|
||||||
if (!task) throw new Error('服务端未返回当前拣货任务')
|
if (!task) throw new Error('服务端未返回当前拣货任务')
|
||||||
if (this.isMergedContainerRecord(task)) {
|
if (this.isMergedContainerRecord(task)) {
|
||||||
this.clearMergedTask(task, previousIdentity)
|
this.clearMergedTask(task, previousIdentity)
|
||||||
@@ -1165,9 +1189,10 @@
|
|||||||
if (remoteTask.workflowStatus !== 'AWAITING_REVIEW' && this.assignmentVersion === null) throw new Error('任务领取状态尚未同步,请刷新重试')
|
if (remoteTask.workflowStatus !== 'AWAITING_REVIEW' && this.assignmentVersion === null) throw new Error('任务领取状态尚未同步,请刷新重试')
|
||||||
let claimed = null
|
let claimed = null
|
||||||
if (remoteTask.workflowStatus === 'UNASSIGNED') {
|
if (remoteTask.workflowStatus === 'UNASSIGNED') {
|
||||||
|
if (!this.isTaskSyncActive(requestId, requestedTaskId)) return
|
||||||
const operationId = `claim-${session.currentUser?.id || 'operator'}-${this.remoteTaskId}-${task.loadingPlanId}-${task.containerBookingId}`
|
const operationId = `claim-${session.currentUser?.id || 'operator'}-${this.remoteTaskId}-${task.loadingPlanId}-${task.containerBookingId}`
|
||||||
claimed = await claimContainerPickingTask(this.remoteTaskId, this.assignmentVersion, operationId)
|
claimed = await claimContainerPickingTask(this.remoteTaskId, this.assignmentVersion, operationId)
|
||||||
if (requestId !== this.taskRequestId) return
|
if (!this.isTaskSyncActive(requestId, requestedTaskId)) return
|
||||||
} else if (remoteTask.workflowStatus === 'IN_PROGRESS') {
|
} else if (remoteTask.workflowStatus === 'IN_PROGRESS') {
|
||||||
const assigneeId = remoteTask.assignment?.assigneeId
|
const assigneeId = remoteTask.assignment?.assigneeId
|
||||||
if (!assigneeId || String(assigneeId) !== String(session.currentUser?.id || '')) throw new Error('当前任务已由其他作业员领取,请返回任务列表刷新')
|
if (!assigneeId || String(assigneeId) !== String(session.currentUser?.id || '')) throw new Error('当前任务已由其他作业员领取,请返回任务列表刷新')
|
||||||
@@ -1199,9 +1224,10 @@
|
|||||||
if (addedItemIds.length) this.showToast(`已同步 ${addedItemIds.length} 条新增待拣明细`, 'warning')
|
if (addedItemIds.length) this.showToast(`已同步 ${addedItemIds.length} 条新增待拣明细`, 'warning')
|
||||||
}
|
}
|
||||||
await this.loadLoadingEvidence()
|
await this.loadLoadingEvidence()
|
||||||
|
if (!this.isTaskSyncActive(requestId, requestedTaskId)) return
|
||||||
if (this.loadingStageReady) this.activeStage = 'evidence'
|
if (this.loadingStageReady) this.activeStage = 'evidence'
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestId !== this.taskRequestId) return
|
if (!this.isTaskSyncActive(requestId, requestedTaskId)) return
|
||||||
this.debugSubmit('refresh.error', { requestId, message: error.message || '', code: error.code || '', statusCode: error.statusCode || '', requestIdFromServer: error.requestId || '', traceId: error.traceId || '' })
|
this.debugSubmit('refresh.error', { requestId, message: error.message || '', code: error.code || '', statusCode: error.statusCode || '', requestIdFromServer: error.requestId || '', traceId: error.traceId || '' })
|
||||||
this.remoteError = error.network ? '网络不可用,无法开始拣货,恢复网络后请重试' : this.remoteFailureMessage(error, '任务同步失败')
|
this.remoteError = error.network ? '网络不可用,无法开始拣货,恢复网络后请重试' : this.remoteFailureMessage(error, '任务同步失败')
|
||||||
} finally { if (requestId === this.taskRequestId) this.remoteLoading = false }
|
} finally { if (requestId === this.taskRequestId) this.remoteLoading = false }
|
||||||
@@ -1224,7 +1250,6 @@
|
|||||||
if (seal?.sealNo) this.sealNo = seal.sealNo
|
if (seal?.sealNo) this.sealNo = seal.sealNo
|
||||||
await this.loadEvidencePreviews(contextId, identity)
|
await this.loadEvidencePreviews(contextId, identity)
|
||||||
if (!this.isEvidenceContextActive(contextId, identity)) return
|
if (!this.isEvidenceContextActive(contextId, identity)) return
|
||||||
await this.flushPendingEvidence()
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!this.isEvidenceContextActive(contextId, identity)) return
|
if (!this.isEvidenceContextActive(contextId, identity)) return
|
||||||
this.remoteError = error.network ? '网络失败,装柜证据未加载,请刷新重试' : this.remoteFailureMessage(error, '装柜证据加载失败')
|
this.remoteError = error.network ? '网络失败,装柜证据未加载,请刷新重试' : this.remoteFailureMessage(error, '装柜证据加载失败')
|
||||||
@@ -1305,46 +1330,33 @@
|
|||||||
this.removeDraftPhoto(type, localPath)
|
this.removeDraftPhoto(type, localPath)
|
||||||
},
|
},
|
||||||
async queueEvidenceUpload(type, path, sealNo = null) {
|
async queueEvidenceUpload(type, path, sealNo = null) {
|
||||||
if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId || !isRemoteReady(this.evidenceIdentity.taskId)) { this.showToast('当前任务仓库尚未同步,证据保留本地', 'warning'); return }
|
if (this.evidenceUploading || this.evidenceReadOnly) return
|
||||||
|
if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId || !isRemoteReady(this.evidenceIdentity.taskId)) { this.showToast('当前任务仓库尚未同步,请刷新后重新操作', 'warning'); return }
|
||||||
const contextId = this.evidenceContextId
|
const contextId = this.evidenceContextId
|
||||||
const identity = { ...this.evidenceIdentity }
|
const identity = { ...this.evidenceIdentity }
|
||||||
const queue = this.evidenceQueue
|
const current = [...this.validEvidenceEntries(type)].reverse()[0]
|
||||||
const active = [...this.validEvidenceEntries(type)].reverse()[0]
|
const entry = { id: `${Date.now()}-${Math.random()}`, evidenceType: type, status: 'UPLOADING', progress: 0, error: '' }
|
||||||
const expectedVersion = active?.version ?? 0
|
this.evidenceQueueItems = [...this.evidenceQueueItems.filter(item => item.evidenceType !== type), entry]
|
||||||
const operationId = stableOperationId('evidence-upload', `${identity.taskId}|${identity.warehouseId}`, { evidenceType: type, expectedVersion, localPath: path, sealNo: sealNo || '' })
|
this.evidenceUploading = true
|
||||||
const entry = queue.add({ taskId: identity.taskId, warehouseId: identity.warehouseId, evidenceType: type, expectedVersion, sealNo: sealNo || null, remark: null, operationId, localPath: path })
|
|
||||||
this.debugSubmit('evidence.queue.start', { evidenceType: type, queueId: entry.id, evidenceIdentity: identity })
|
|
||||||
await queue.flush()
|
|
||||||
if (!this.isEvidenceContextActive(contextId, identity) || queue !== this.evidenceQueue) return
|
|
||||||
const latest = queue.snapshot().find((item) => item.id === entry.id)
|
|
||||||
this.debugSubmit('evidence.queue.result', { evidenceType: type, queueId: entry.id, status: latest?.status || 'missing', fileId: latest?.result?.fileId || null, error: latest?.error || '' })
|
|
||||||
if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return }
|
|
||||||
this.applyEvidenceResponse(latest.result, type, path, identity)
|
|
||||||
this.showToast(type === 'SEAL' ? '带锁码照上传成功,封签号已保存' : '照片已上传并保存', 'success')
|
|
||||||
},
|
|
||||||
async flushPendingEvidence() {
|
|
||||||
if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId) return
|
|
||||||
const pending = this.evidenceSlots.flatMap((slot) => this.evidenceEntries(slot.type).filter((entry) => !entry.id && entry.localPath).map((entry) => ({ type: slot.type, path: entry.localPath })))
|
|
||||||
for (const item of pending) {
|
|
||||||
if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId) return
|
|
||||||
if (item.type === 'SEAL' && !String(this.sealNo || '').trim()) continue
|
|
||||||
await this.queueEvidenceUpload(item.type, item.path, item.type === 'SEAL' ? this.sealNo : null)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async retryEvidence(type) {
|
|
||||||
if (!this.requireEvidenceCapture()) return
|
|
||||||
const item = this.evidenceQueueEntry(type)
|
|
||||||
const contextId = this.evidenceContextId
|
|
||||||
const identity = { ...this.evidenceIdentity }
|
|
||||||
const queue = this.evidenceQueue
|
|
||||||
if (!item || !queue.retry(item.id)) return
|
|
||||||
this.remoteError = ''
|
this.remoteError = ''
|
||||||
await queue.flush()
|
try {
|
||||||
if (!this.isEvidenceContextActive(contextId, identity) || queue !== this.evidenceQueue) return
|
const result = await uploadEvidenceFile(identity.taskId, identity.warehouseId, {
|
||||||
const latest = queue.latest(type)
|
evidenceType: type, expectedVersion: current?.version ?? 0, sealNo,
|
||||||
if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return }
|
operationId: `evidence-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
this.applyEvidenceResponse(latest.result, type, item.localPath, identity)
|
}, path, progress => { if (this.isEvidenceContextActive(contextId, identity)) entry.progress = progress })
|
||||||
this.showToast(type === 'SEAL' ? '带锁码照上传成功,封签号已保存' : '照片重试上传成功', 'success')
|
if (!this.isEvidenceContextActive(contextId, identity)) return
|
||||||
|
entry.status = 'SUCCEEDED'
|
||||||
|
this.applyEvidenceResponse(result, type, path, identity)
|
||||||
|
this.showToast(type === 'SEAL' ? '带锁码照上传成功,封签号已保存' : '照片已上传并保存', 'success')
|
||||||
|
} catch (error) {
|
||||||
|
if (!this.isEvidenceContextActive(contextId, identity)) return
|
||||||
|
if (type !== 'SEAL') {
|
||||||
|
this.evidenceFiles = { ...this.evidenceFiles, [type]: (this.evidenceFiles[type] || []).filter(photo => photo.id || photo.localPath !== path) }
|
||||||
|
this.removeDraftPhoto(type, path)
|
||||||
|
}
|
||||||
|
entry.status = 'FAILED'; entry.error = this.remoteFailureMessage(error, '照片上传失败,请重新操作')
|
||||||
|
this.remoteError = entry.error
|
||||||
|
} finally { if (this.isEvidenceContextActive(contextId, identity)) { this.evidenceUploading = false; this.evidenceQueueItems = this.evidenceQueueItems.map(item => ({ ...item })) } }
|
||||||
},
|
},
|
||||||
async saveSealNo() {
|
async saveSealNo() {
|
||||||
if (this.sealSaving) return
|
if (this.sealSaving) return
|
||||||
@@ -1381,18 +1393,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async completeLoadingTask() {
|
async completeLoadingTask() {
|
||||||
if (!this.requireEvidenceStage()) return
|
if (this.evidenceUploading || !this.requireEvidenceStage()) return
|
||||||
if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId || !isRemoteReady(this.evidenceIdentity.taskId)) { this.showToast('装柜任务或仓库尚未同步,请刷新后重试', 'warning'); return }
|
if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId || !isRemoteReady(this.evidenceIdentity.taskId)) { this.showToast('装柜任务或仓库尚未同步,请刷新后重试', 'warning'); return }
|
||||||
if (this.pickingVersion === null) { this.showToast(VERSION_SYNC_MESSAGE, 'warning'); return }
|
if (this.pickingVersion === null) { this.showToast(VERSION_SYNC_MESSAGE, 'warning'); return }
|
||||||
if (!this.evidenceComplete) { this.showToast('五类有效照片和封签号均为必填', 'warning'); return }
|
if (!this.evidenceComplete) { this.showToast('五类有效照片和封签号均为必填', 'warning'); return }
|
||||||
const contextId = this.evidenceContextId
|
const contextId = this.evidenceContextId
|
||||||
const identity = { ...this.evidenceIdentity }
|
const identity = { ...this.evidenceIdentity }
|
||||||
const serverAttempt = this.remotePick?.completionCommand
|
const serverAttempt = this.remotePick?.completionCommand
|
||||||
const savedAttempt = restoreLoadingCompletionAttempt(this.draftIdentity()) || (serverAttempt?.operationId ? {
|
const savedAttempt = serverAttempt?.status === 'FAILED' && serverAttempt.operationId ? {
|
||||||
operationId: serverAttempt.operationId,
|
operationId: serverAttempt.operationId,
|
||||||
expectedTaskVersion: serverAttempt.expectedTaskVersion,
|
expectedTaskVersion: serverAttempt.expectedTaskVersion,
|
||||||
remark: serverAttempt.remark || 'PDA确认装柜完成'
|
remark: serverAttempt.remark || 'PDA确认装柜完成'
|
||||||
} : null)
|
} : null
|
||||||
const expectedTaskVersion = savedAttempt?.expectedTaskVersion ?? Number(this.pickingVersion)
|
const expectedTaskVersion = savedAttempt?.expectedTaskVersion ?? Number(this.pickingVersion)
|
||||||
const completionIdentity = this.draftIdentity()
|
const completionIdentity = this.draftIdentity()
|
||||||
const operationIdentity = stableTaskOperationIdentity(completionIdentity)
|
const operationIdentity = stableTaskOperationIdentity(completionIdentity)
|
||||||
@@ -1403,8 +1415,12 @@
|
|||||||
containerBookingId: completionIdentity.containerBookingId
|
containerBookingId: completionIdentity.containerBookingId
|
||||||
})
|
})
|
||||||
const completionAttempt = { operationId, expectedTaskVersion, remark: savedAttempt?.remark || 'PDA确认装柜完成' }
|
const completionAttempt = { operationId, expectedTaskVersion, remark: savedAttempt?.remark || 'PDA确认装柜完成' }
|
||||||
saveLoadingCompletionAttempt(this.draftIdentity(), completionAttempt)
|
const submission = {}
|
||||||
|
this._submissionOwner = submission
|
||||||
this.submitting = true
|
this.submitting = true
|
||||||
|
this.remoteError = ''
|
||||||
|
const started = Date.now()
|
||||||
|
let accepted = false
|
||||||
try {
|
try {
|
||||||
const completedTask = await completeContainerPickingTask(this.remoteTaskId, {
|
const completedTask = await completeContainerPickingTask(this.remoteTaskId, {
|
||||||
expectedTaskVersion,
|
expectedTaskVersion,
|
||||||
@@ -1412,25 +1428,58 @@
|
|||||||
remark: completionAttempt.remark
|
remark: completionAttempt.remark
|
||||||
})
|
})
|
||||||
if (!this.isEvidenceContextActive(contextId, identity)) return
|
if (!this.isEvidenceContextActive(contextId, identity)) return
|
||||||
|
accepted = true
|
||||||
|
this.debugSubmit('complete.accepted', { elapsedMs: Date.now() - started, taskStatus: completedTask?.taskStatus })
|
||||||
|
if (!completedTask) throw new Error('服务端未返回装柜完成结果,请刷新任务')
|
||||||
if (completedTask) {
|
if (completedTask) {
|
||||||
this.remotePick = normalizePickingTask({ ...this.remotePick, ...completedTask })
|
this.remotePick = normalizePickingTask({ ...this.remotePick, ...completedTask })
|
||||||
this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? this.pickingVersion
|
this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? this.pickingVersion
|
||||||
}
|
}
|
||||||
const phase = resolveCompletionPhase({ taskStatus: completedTask?.taskStatus, commandStatus: completedTask?.commandStatus })
|
// 已受理后保留处理中门禁,查询权威任务;不等待整页证据和库存重新加载。
|
||||||
|
this.submitting = false
|
||||||
|
const finalTask = await observeLoadingCompletion({
|
||||||
|
initialTask: completedTask,
|
||||||
|
isActive: () => this._pageVisible === true && this.isEvidenceContextActive(contextId, identity),
|
||||||
|
readTask: async () => {
|
||||||
|
const readStarted = Date.now()
|
||||||
|
const task = await getContainerPickingTask({ taskId: identity.taskId }, identity.warehouseId)
|
||||||
|
this.debugSubmit('complete.query', { elapsedMs: Date.now() - readStarted, taskStatus: task?.taskStatus })
|
||||||
|
if (String(task?.taskId || task?.id || '') !== String(identity.taskId)) throw new Error('装柜完成状态尚未同步,请刷新任务')
|
||||||
|
return task
|
||||||
|
},
|
||||||
|
onTask: (task) => {
|
||||||
|
this.remotePick = normalizePickingTask({ ...this.remotePick, ...task })
|
||||||
|
this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? this.pickingVersion
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!this.isEvidenceContextActive(contextId, identity) || !finalTask) return
|
||||||
|
const phase = resolveCompletionPhase({ taskStatus: finalTask.taskStatus || finalTask.workflowStatus,
|
||||||
|
commandStatus: finalTask.completionCommand?.status || finalTask.commandStatus })
|
||||||
|
this.debugSubmit('complete.observed', { elapsedMs: Date.now() - started, phase })
|
||||||
if (phase === COMPLETION_PHASE.PROCESSING) {
|
if (phase === COMPLETION_PHASE.PROCESSING) {
|
||||||
this.showToast('完成命令已受理,后台处理中', 'success')
|
this.remoteError = '完成命令仍在处理中,请稍后刷新任务确认'
|
||||||
|
this.showToast('完成处理中,请稍后刷新', 'warning')
|
||||||
this.persistSession()
|
this.persistSession()
|
||||||
navigateBackOr('/pages/tasks/tasks')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (phase === COMPLETION_PHASE.COMPLETED) clearLoadingCompletionAttempt(this.draftIdentity())
|
if (phase !== COMPLETION_PHASE.COMPLETED) {
|
||||||
|
this.remoteError = phase === COMPLETION_PHASE.FAILED ? '装柜完成失败,请重新操作' : '提交结果待确认,请刷新任务'
|
||||||
|
this.showToast(phase === COMPLETION_PHASE.FAILED ? '装柜完成失败' : '提交结果待确认', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.remoteError = ''
|
||||||
this.showToast('装柜已完成', 'success')
|
this.showToast('装柜已完成', 'success')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!this.isEvidenceContextActive(contextId, identity)) return
|
if (!this.isEvidenceContextActive(contextId, identity)) return
|
||||||
|
if (accepted) {
|
||||||
|
this.debugSubmit('complete.unconfirmed', { elapsedMs: Date.now() - started, code: error.code || '', requestId: error.requestId || '', traceId: error.traceId || '' })
|
||||||
|
this.remoteError = '完成命令已受理,结果待确认,请刷新任务'
|
||||||
|
this.showToast('提交结果待确认', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (shouldReconcileCompletion(error)) {
|
if (shouldReconcileCompletion(error)) {
|
||||||
this.remoteError = '提交结果待确认,请返回任务列表刷新'
|
this.remoteError = '提交结果待确认,请刷新任务后重新操作'
|
||||||
this.showToast('提交结果待确认', 'warning')
|
this.showToast('提交结果待确认', 'warning')
|
||||||
navigateBackOr('/pages/tasks/tasks')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (this.isTaskConflict(error)) {
|
if (this.isTaskConflict(error)) {
|
||||||
@@ -1442,7 +1491,7 @@
|
|||||||
this.showToast('装柜完成失败', 'error')
|
this.showToast('装柜完成失败', 'error')
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
if (this.isEvidenceContextActive(contextId, identity)) this.submitting = false
|
if (this._submissionOwner === submission) { this.submitting = false; this._submissionOwner = null }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async pickScannedBox(item, boxCode) {
|
async pickScannedBox(item, boxCode) {
|
||||||
@@ -1568,6 +1617,8 @@
|
|||||||
itemId, itemVersion: item.version, taskVersion: this.pickingVersion, ...payload
|
itemId, itemVersion: item.version, taskVersion: this.pickingVersion, ...payload
|
||||||
})
|
})
|
||||||
const common = { warehouseId: this.currentBox.warehouseId, version: Number(item.version || 0), operationId }
|
const common = { warehouseId: this.currentBox.warehouseId, version: Number(item.version || 0), operationId }
|
||||||
|
const submission = {}
|
||||||
|
this._submissionOwner = submission
|
||||||
this.submitting = true
|
this.submitting = true
|
||||||
this.remoteError = ''
|
this.remoteError = ''
|
||||||
try {
|
try {
|
||||||
@@ -1586,7 +1637,7 @@
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
this.submitting = false
|
if (this._submissionOwner === submission) { this.submitting = false; this._submissionOwner = null }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
applyImmediateTaskResult(result) {
|
applyImmediateTaskResult(result) {
|
||||||
@@ -1659,10 +1710,11 @@
|
|||||||
this.performSubmit()
|
this.performSubmit()
|
||||||
},
|
},
|
||||||
confirmIncompleteSubmit() {
|
confirmIncompleteSubmit() {
|
||||||
if (!this.selectedIncompleteReason) return
|
if (this.submitting || !this.selectedIncompleteReason) return
|
||||||
this.performSubmit(this.selectedIncompleteReason)
|
return this.performSubmit(this.selectedIncompleteReason)
|
||||||
},
|
},
|
||||||
async performSubmit(incompleteReason = '') {
|
async performSubmit(incompleteReason = '') {
|
||||||
|
if (this.submitting) return
|
||||||
const beforeSubmit = this.summary
|
const beforeSubmit = this.summary
|
||||||
const remoteRequired = Boolean(session.token)
|
const remoteRequired = Boolean(session.token)
|
||||||
this.remoteError = ''
|
this.remoteError = ''
|
||||||
@@ -1679,6 +1731,8 @@
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (remoteRequired) {
|
if (remoteRequired) {
|
||||||
|
const submission = {}
|
||||||
|
this._submissionOwner = submission
|
||||||
this.submitting = true
|
this.submitting = true
|
||||||
try {
|
try {
|
||||||
if (!this.remoteTaskId) throw new Error('尚未进入服务端拣货任务,请联网刷新后重试')
|
if (!this.remoteTaskId) throw new Error('尚未进入服务端拣货任务,请联网刷新后重试')
|
||||||
@@ -1734,7 +1788,7 @@
|
|||||||
this.remoteError = error.network ? '网络失败,拣货完成未提交,请重试' : this.remoteFailureMessage(error, '拣货完成失败')
|
this.remoteError = error.network ? '网络失败,拣货完成未提交,请重试' : this.remoteFailureMessage(error, '拣货完成失败')
|
||||||
this.showToast('拣货完成未提交,请查看错误提示', 'error')
|
this.showToast('拣货完成未提交,请查看错误提示', 'error')
|
||||||
return
|
return
|
||||||
} finally { this.submitting = false }
|
} finally { if (this._submissionOwner === submission) { this.submitting = false; this._submissionOwner = null } }
|
||||||
}
|
}
|
||||||
const result = submitStates(this.currentBox.items, this.itemStates)
|
const result = submitStates(this.currentBox.items, this.itemStates)
|
||||||
this.itemStates = result.states
|
this.itemStates = result.states
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
<view v-if="user" class="page-shell portal-page"><PortalHeader title="个人中心" :user="user" :show-profile="false" /><view class="portal-content profile-content"><view class="profile-card"><view class="profile-avatar">{{ user.name.slice(0, 1) }}</view><view><text class="profile-name">{{ user.name }}</text><text class="profile-role">国内仓作业员</text></view></view><view class="profile-list"><view class="profile-row"><text>作业仓库</text><text class="profile-value">{{ user.warehouse }}</text></view><view class="profile-row"><text>当前班次</text><text class="profile-value">08:00–17:00</text></view><view class="profile-row"><text>应用版本</text><text class="profile-value">{{ appVersion }}</text></view><button class="profile-row profile-row-action" @tap="checkUpdate"><text>检查更新</text><text class="profile-row-arrow">›</text></button><button class="profile-row profile-row-action" @tap="showSettings"><text>系统设置</text><text class="profile-row-arrow">›</text></button></view><button class="logout-action" @tap="openLogout">退出登录</button></view><uni-popup ref="logoutPopup" type="center" :is-mask-click="true" @change="handleLogoutChange"><view class="logout-dialog-card" role="dialog" aria-modal="true" aria-label="确认退出登录"><PopupHeader title="确认退出登录?" tone="danger" @close="closeLogout" /><text class="logout-dialog-content">退出后会清空本次会话中的箱号草稿、扫码记录与作业进度。</text><view class="logout-dialog-actions"><button class="logout-dialog-cancel" @tap="closeLogout">取消</button><button class="logout-dialog-confirm" @tap="logout">确认退出</button></view></view></uni-popup><view v-if="toast" class="app-toast warning" role="status">设置功能将在后续版本开放</view></view>
|
<view v-if="user" class="page-shell portal-page"><PortalHeader title="个人中心" :user="user" :show-profile="false" /><view class="portal-content profile-content"><view class="profile-card"><view class="profile-avatar">{{ user.name.slice(0, 1) }}</view><view><text class="profile-name">{{ user.name }}</text><text class="profile-role">国内仓作业员</text></view></view><view class="profile-list"><view class="profile-row"><text>作业仓库</text><text class="profile-value">{{ user.warehouse }}</text></view><view class="profile-row"><text>当前班次</text><text class="profile-value">08:00–17:00</text></view><view class="profile-row"><text>应用版本</text><text class="profile-value">{{ appVersion }}</text></view><button class="profile-row profile-row-action" @tap="checkUpdate"><text>检查更新</text><text class="profile-row-arrow">›</text></button><button class="profile-row profile-row-action" @tap="showSettings"><text>系统设置</text><text class="profile-row-arrow">›</text></button></view><button class="logout-action" @tap="openLogout">退出登录</button></view><uni-popup ref="logoutPopup" type="center" :is-mask-click="true" @change="handleLogoutChange"><view class="logout-dialog-card" role="dialog" aria-modal="true" aria-label="确认退出登录"><PopupHeader title="确认退出登录?" tone="danger" @close="closeLogout" /><text class="logout-dialog-content">退出后会清空本次会话中的箱号草稿、扫码记录与作业进度。</text><view class="logout-dialog-actions"><button class="logout-dialog-cancel" @tap="closeLogout">取消</button><button class="logout-dialog-confirm" @tap="logout">确认退出</button></view></view></uni-popup><view v-if="toast" class="app-toast warning" role="status">设置功能将在后续版本开放</view></view>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
import { logout as logoutRequest } from '@/api/auth.js'
|
import { logoutCurrentSession } from '@/api/logout-session.js'
|
||||||
import PortalHeader from '@/components/PortalHeader.vue'
|
import PortalHeader from '@/components/PortalHeader.vue'
|
||||||
import PopupHeader from '@/components/PopupHeader.vue'
|
import PopupHeader from '@/components/PopupHeader.vue'
|
||||||
import { session, requireSession, signOut } from '@/stores/session.js'
|
import { session, requireSession, getSessionRevision } from '@/stores/session.js'
|
||||||
import { checkForAppUpdate } from '@/utils/app-update.js'
|
import { checkForAppUpdate } from '@/utils/app-update.js'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -34,11 +34,20 @@
|
|||||||
handleLogoutChange(event) { this.logoutOpen = Boolean(event.show) },
|
handleLogoutChange(event) { this.logoutOpen = Boolean(event.show) },
|
||||||
showSettings() { clearTimeout(this._toastTimer); this.toast = true; this._toastTimer = setTimeout(() => { this.toast = false }, 2200) },
|
showSettings() { clearTimeout(this._toastTimer); this.toast = true; this._toastTimer = setTimeout(() => { this.toast = false }, 2200) },
|
||||||
logout() {
|
logout() {
|
||||||
const refreshToken = session.refreshToken
|
if (this._logoutPromise) return this._logoutPromise
|
||||||
const accessToken = session.token
|
this.closeLogout()
|
||||||
signOut()
|
const operation = logoutCurrentSession()
|
||||||
uni.reLaunch({ url: '/pages/login/login' })
|
this.user = null
|
||||||
if (refreshToken) logoutRequest(refreshToken, accessToken).catch(() => {})
|
this._logoutPromise = operation.then(result => {
|
||||||
|
if (getSessionRevision() === result.clearedRevision) uni.reLaunch({ url: '/pages/login/login' })
|
||||||
|
return result
|
||||||
|
}, error => {
|
||||||
|
if (getSessionRevision() === error.logoutRevision) {
|
||||||
|
uni.reLaunch({ url: '/pages/login/login', complete() { uni.showToast({ title: '已清除本地登录,服务端退出未确认', icon: 'none', duration: 3000 }) } })
|
||||||
|
}
|
||||||
|
return { revoked: false, error }
|
||||||
|
})
|
||||||
|
return this._logoutPromise
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'
|
|||||||
import { createHash } from 'node:crypto'
|
import { createHash } from 'node:crypto'
|
||||||
const project = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
const project = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
const output = resolve(project, 'unpackage/dist/build/web')
|
const output = resolve(project, 'unpackage/dist/build/web')
|
||||||
const base = '/pda-domestic/'
|
const base = '/'
|
||||||
function walk(dir) { return readdirSync(dir).flatMap(name => { const path = resolve(dir, name); return statSync(path).isDirectory() ? walk(path) : [path] }) }
|
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')
|
assert.ok(existsSync(resolve(output, 'index.html')), 'Build H5 before verifying')
|
||||||
const files = walk(output).sort()
|
const files = walk(output).sort()
|
||||||
@@ -17,7 +17,7 @@ for (const ref of refs) {
|
|||||||
assert.ok(existsSync(resolve(output, ref.slice(base.length))), `Missing asset: ${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')
|
const js = files.filter(path => path.endsWith('.js')).map(path => readFileSync(path, 'utf8')).join('\n')
|
||||||
assert.ok(js.includes(base), 'Runtime router and assets must know the deployment base')
|
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, /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?:\/\/(?: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')
|
assert.doesNotMatch(js, /https?:\/\/[^"'\s]+\/api["']/, 'API must be same-origin for this H5 deployment')
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ function createInitialState() {
|
|||||||
let state = createInitialState()
|
let state = createInitialState()
|
||||||
|
|
||||||
const STORAGE_PREFIX = 'pda-picking-draft'
|
const STORAGE_PREFIX = 'pda-picking-draft'
|
||||||
const COMPLETION_PREFIX = 'pda-loading-completion'
|
|
||||||
|
|
||||||
function storageAvailable() {
|
function storageAvailable() {
|
||||||
return typeof uni !== 'undefined' && typeof uni.getStorageSync === 'function' && typeof uni.setStorageSync === 'function'
|
return typeof uni !== 'undefined' && typeof uni.getStorageSync === 'function' && typeof uni.setStorageSync === 'function'
|
||||||
@@ -81,34 +80,6 @@ export function clearPickingDraft(identity = {}) {
|
|||||||
if (key && typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(key)
|
if (key && typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
function completionKey(identity = {}) {
|
|
||||||
const identityKey = stableTaskIdentityKey(identity)
|
|
||||||
return identityKey ? `${COMPLETION_PREFIX}:${identityKey}` : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveLoadingCompletionAttempt(identity = {}, attempt = {}) {
|
|
||||||
const key = completionKey(identity)
|
|
||||||
if (!key || !storageAvailable() || !attempt.operationId) return
|
|
||||||
uni.setStorageSync(key, {
|
|
||||||
operationId: String(attempt.operationId),
|
|
||||||
expectedTaskVersion: Number(attempt.expectedTaskVersion),
|
|
||||||
remark: attempt.remark || '',
|
|
||||||
updatedAt: new Date().toISOString()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function restoreLoadingCompletionAttempt(identity = {}) {
|
|
||||||
const key = completionKey(identity)
|
|
||||||
if (!key || !storageAvailable()) return null
|
|
||||||
const saved = uni.getStorageSync(key)
|
|
||||||
return saved?.operationId && Number.isInteger(Number(saved.expectedTaskVersion)) ? clone(saved) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearLoadingCompletionAttempt(identity = {}) {
|
|
||||||
const key = completionKey(identity)
|
|
||||||
if (key && typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetPickingSession() {
|
export function resetPickingSession() {
|
||||||
state = createInitialState()
|
state = createInitialState()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { decodeAccessToken } from '@/utils/jwt-claims.js'
|
|||||||
|
|
||||||
export { decodeAccessToken } from '@/utils/jwt-claims.js'
|
export { decodeAccessToken } from '@/utils/jwt-claims.js'
|
||||||
|
|
||||||
|
let sessionRevision = 0
|
||||||
|
export function getSessionRevision() { return sessionRevision }
|
||||||
|
|
||||||
const SESSION_KEY = 'pda-auth-session'
|
const SESSION_KEY = 'pda-auth-session'
|
||||||
const LOGIN_PREFERENCE_KEY = 'pda-login-account-preference'
|
const LOGIN_PREFERENCE_KEY = 'pda-login-account-preference'
|
||||||
|
|
||||||
@@ -53,6 +56,7 @@ function persistSession() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function signIn(account, warehouse, options = {}) {
|
export function signIn(account, warehouse, options = {}) {
|
||||||
|
sessionRevision++
|
||||||
session.currentUser = { id: String(account.id), name: account.name, role: account.role, roles: account.roles || [], warehouse, warehouseId: account.warehouseId || null, domesticWarehouseId: account.domesticWarehouseId || null, warehouseBound: Boolean(account.domesticWarehouseId) }
|
session.currentUser = { id: String(account.id), name: account.name, role: account.role, roles: account.roles || [], warehouse, warehouseId: account.warehouseId || null, domesticWarehouseId: account.domesticWarehouseId || null, warehouseBound: Boolean(account.domesticWarehouseId) }
|
||||||
session.token = options.token || account.token || ''
|
session.token = options.token || account.token || ''
|
||||||
session.refreshToken = options.refreshToken || account.refreshToken || ''
|
session.refreshToken = options.refreshToken || account.refreshToken || ''
|
||||||
@@ -144,6 +148,7 @@ export function restoreSession() {
|
|||||||
tenantId: claims.tenant_id == null ? (saved.tenantId || '') : String(claims.tenant_id),
|
tenantId: claims.tenant_id == null ? (saved.tenantId || '') : String(claims.tenant_id),
|
||||||
apiBase: saved.apiBase || PLATFORM_API_ROOT
|
apiBase: saved.apiBase || PLATFORM_API_ROOT
|
||||||
})
|
})
|
||||||
|
sessionRevision++
|
||||||
persistSession()
|
persistSession()
|
||||||
return true
|
return true
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -153,6 +158,7 @@ export function restoreSession() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function signOut() {
|
export function signOut() {
|
||||||
|
sessionRevision++
|
||||||
session.currentUser = null
|
session.currentUser = null
|
||||||
session.token = ''
|
session.token = ''
|
||||||
session.refreshToken = ''
|
session.refreshToken = ''
|
||||||
|
|||||||
@@ -57,11 +57,14 @@ test('confirmed session expiry clears sensitive state and redirects only once',
|
|||||||
assert.match(http, /if \(isUnauthorized\(error\)\) return expireSession/)
|
assert.match(http, /if \(isUnauthorized\(error\)\) return expireSession/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('manual logout revokes the refresh token without delaying local cleanup', async () => {
|
test('manual logout waits for captured-session revoke and honestly handles failure', async () => {
|
||||||
const [profile, auth] = await Promise.all([read('../pages/profile/profile.vue'), read('../api/auth.js')])
|
const [profile, helper] = await Promise.all([read('../pages/profile/profile.vue'), read('../api/logout-session.js')])
|
||||||
assert.match(auth, /export function logout\(refreshToken, accessToken\)/)
|
assert.match(profile, /logoutCurrentSession\(\)/)
|
||||||
assert.match(profile, /const refreshToken = session\.refreshToken[\s\S]*?const accessToken = session\.token[\s\S]*?signOut\(\)[\s\S]*?uni\.reLaunch/)
|
assert.match(profile, /operation\.then/)
|
||||||
assert.match(profile, /logoutRequest\(refreshToken, accessToken\)\.catch/)
|
assert.match(profile, /服务端退出未确认/)
|
||||||
|
assert.match(helper, /signOut\(\)/)
|
||||||
|
assert.match(helper, /error\.statusCode !== 401/)
|
||||||
|
assert.doesNotMatch(profile, /catch\(\(\) => \{\}\)/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('production login starts empty and remembers only the account preference', async () => {
|
test('production login starts empty and remembers only the account preference', async () => {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ test('palletization UI accepts carton and pre-generated pallet scans in either o
|
|||||||
assert.match(work, /减少 \$\{line\.skuCode\} 清点数量/)
|
assert.match(work, /减少 \$\{line\.skuCode\} 清点数量/)
|
||||||
assert.match(work, /增加 \$\{line\.skuCode\} 清点数量/)
|
assert.match(work, /增加 \$\{line\.skuCode\} 清点数量/)
|
||||||
for (const text of ['compact-metrics', 'compact-action']) assert.match(work, new RegExp(text))
|
for (const text of ['compact-metrics', 'compact-action']) assert.match(work, new RegExp(text))
|
||||||
assert.doesNotMatch(work, /task\.customerName/)
|
assert.match(work, /task\.customerName/)
|
||||||
assert.match(work, /\.task-summary-card, \.section-card \{[^}]*border:\s*0;[^}]*border-bottom:\s*6px solid[^}]*border-radius:\s*0;[^}]*box-shadow:\s*none;/)
|
assert.match(work, /\.task-summary-card, \.section-card \{[^}]*border:\s*0;[^}]*border-bottom:\s*6px solid[^}]*border-radius:\s*0;[^}]*box-shadow:\s*none;/)
|
||||||
assert.match(work, /\.carton-card, \.pallet-card \{[^}]*border:\s*0;[^}]*border-top:\s*1px solid[^}]*border-radius:\s*0;[^}]*background:\s*transparent;/)
|
assert.match(work, /\.carton-card, \.pallet-card \{[^}]*border:\s*0;[^}]*border-top:\s*1px solid[^}]*border-radius:\s*0;[^}]*background:\s*transparent;/)
|
||||||
for (const text of ['计划 {{ carton.expectedSkuQuantity', "carton.cargoName", "carton.countStatus === 'UNCOUNTED'"]) assert.doesNotMatch(work, new RegExp(text))
|
for (const text of ['计划 {{ carton.expectedSkuQuantity', "carton.cargoName", "carton.countStatus === 'UNCOUNTED'"]) assert.doesNotMatch(work, new RegExp(text))
|
||||||
@@ -54,7 +54,8 @@ test('palletization UI accepts carton and pre-generated pallet scans in either o
|
|||||||
for (const text of ['expectedPalletCount', 'generatedPalletCount', 'palletSetupReady', 'palletSetupMessage']) assert.match(work, new RegExp(text))
|
for (const text of ['expectedPalletCount', 'generatedPalletCount', 'palletSetupReady', 'palletSetupMessage']) assert.match(work, new RegExp(text))
|
||||||
assert.match(work, /<template v-if="palletSetupReady">[\s\S]*?ScanTargetInput[\s\S]*?count-unavailable/)
|
assert.match(work, /<template v-if="palletSetupReady">[\s\S]*?ScanTargetInput[\s\S]*?count-unavailable/)
|
||||||
assert.match(work, /if \(!this\.palletSetupReady\) \{ this\.error = this\.palletSetupMessage; return \}/)
|
assert.match(work, /if \(!this\.palletSetupReady\) \{ this\.error = this\.palletSetupMessage; return \}/)
|
||||||
for (const text of ['绑定到扫描托盘', '生成 DP 托盘并绑定', '确认封托']) assert.doesNotMatch(work, new RegExp(text))
|
for (const text of ['绑定到扫描托盘', '生成 DP 托盘并绑定']) assert.doesNotMatch(work, new RegExp(text))
|
||||||
|
assert.match(work, /uni\.showModal\([\s\S]*?确认封托/)
|
||||||
assert.match(work, /class="task-summary-card"/)
|
assert.match(work, /class="task-summary-card"/)
|
||||||
assert.match(work, /\.task-summary-card\s*\{\s*display:\s*block;/)
|
assert.match(work, /\.task-summary-card\s*\{\s*display:\s*block;/)
|
||||||
assert.doesNotMatch(work, /class="summary-card"/)
|
assert.doesNotMatch(work, /class="summary-card"/)
|
||||||
@@ -65,7 +66,6 @@ test('palletization UI accepts carton and pre-generated pallet scans in either o
|
|||||||
assert.match(work, /继续装其他入仓单/)
|
assert.match(work, /继续装其他入仓单/)
|
||||||
assert.match(work, /selectedPalletNo/)
|
assert.match(work, /selectedPalletNo/)
|
||||||
assert.match(work, /已选择仓库开放托盘/)
|
assert.match(work, /已选择仓库开放托盘/)
|
||||||
assert.match(work, /直接扫描托盘码,只显示当前操作的托盘/)
|
|
||||||
assert.match(work, /currentPalletDetails/)
|
assert.match(work, /currentPalletDetails/)
|
||||||
assert.match(work, /解除封托/)
|
assert.match(work, /解除封托/)
|
||||||
assert.match(work, /<uni-popup ref="unsealPopup"/)
|
assert.match(work, /<uni-popup ref="unsealPopup"/)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ test('palletization uses the test workbench and canonical routes', async () => {
|
|||||||
assert.match(await read('../pages/home/home.vue'), /'一件代发': '\/pages\/dropship-tasks\/dropship-tasks'/)
|
assert.match(await read('../pages/home/home.vue'), /'一件代发': '\/pages\/dropship-tasks\/dropship-tasks'/)
|
||||||
})
|
})
|
||||||
test('formal palletization preserves both scan orders, moves, sealing and safe unsealing', () => {
|
test('formal palletization preserves both scan orders, moves, sealing and safe unsealing', () => {
|
||||||
for (const symbol of ['resolveDropshipCarton', 'countDropshipCarton', 'bindDropshipPendingCartons', 'moveDropshipCarton', 'sealDropshipPallet', 'unsealDropshipPallet', 'planDropshipScan', 'restoreDropshipPalletizingDraft']) assert.ok(page.includes(symbol))
|
for (const symbol of ['resolveDropshipWorkspaceCarton', 'resolveDropshipWorkspacePallet', 'countDropshipCarton', 'bindDropshipPendingCartons', 'moveDropshipCarton', 'sealDropshipPallet', 'unsealDropshipPallet', 'planDropshipScan', 'restoreDropshipPalletizingDraft']) assert.ok(page.includes(symbol))
|
||||||
assert.match(page, /移回待绑托/)
|
assert.match(page, /移回待绑托/)
|
||||||
assert.match(page, /palletSetupReady/)
|
assert.match(page, /palletSetupReady/)
|
||||||
assert.match(page, /pallet\?\.status === 'SEALED'/)
|
assert.match(page, /pallet\?\.status === 'SEALED'/)
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import vm from 'node:vm'
|
||||||
|
import { sanitizeUserMessage } from '../utils/user-facing.js'
|
||||||
|
|
||||||
|
const source = await readFile(new URL('../pages/dropship-palletization/dropship-palletization.vue', import.meta.url), 'utf8')
|
||||||
|
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/gm, '').replace('export default', 'globalThis.page =')
|
||||||
|
function harness(overrides = {}) {
|
||||||
|
const calls = []
|
||||||
|
const target = { taskId: '9007199254740999', customerName: '客户B', taskStatus: 'PENDING', palletSetupReady: true, pallets: [] }
|
||||||
|
const pallet = { palletId: '9007199254740998', palletNo: 'DP-261019-0001', status: 'OPEN', cartonCount: 2 }
|
||||||
|
const context = {
|
||||||
|
PortalHeader: {}, PopupHeader: {}, ScanTargetInput: {}, session: { currentUser: { id: '7' } },
|
||||||
|
getDropshipPalletizationTask: async (id) => { calls.push(['get', id]); return { ...target, taskId: id } },
|
||||||
|
claimDropshipPalletizationTask: async (id) => { calls.push(['claim', id]); return { ...target, taskId: id, taskStatus: 'CLAIMED' } },
|
||||||
|
resolveDropshipWorkspaceCarton: async () => ({ task: target, carton: { boxCode: 'BOX-B', canCount: true, skuLines: [{ skuCode: 'S', inboundDetailId: '44', expectedQuantity: 3 }] } }),
|
||||||
|
resolveDropshipWorkspacePallet: async () => ({ pallet, members: [] }),
|
||||||
|
countDropshipCarton: async (id, payload) => { calls.push(['count', id, payload]); return { carton: {} } },
|
||||||
|
moveDropshipCarton: async (id, cartonId, payload) => { calls.push(['move', id, cartonId, payload]); return { ...target, taskId: id } },
|
||||||
|
saveDropshipPalletizingDraft: () => {}, clearDropshipPalletizingDraft: () => {}, stableOperationId: () => 'stable-key',
|
||||||
|
isOnline: () => true, sanitizeUserMessage, console: { error: (...args) => calls.push(['diagnostic', ...args]) }, uni: { redirectTo: (value) => calls.push(['redirect', value]) },
|
||||||
|
...overrides
|
||||||
|
}
|
||||||
|
vm.runInNewContext(script, context)
|
||||||
|
const state = context.page.data()
|
||||||
|
Object.assign(state, context.page.methods, { taskId: '10', warehouseId: '100', task: { taskStatus: 'CLAIMED', palletSetupReady: true, version: 1 }, loading: false })
|
||||||
|
for (const [key, value] of Object.entries(context.page.computed)) Object.defineProperty(state, key, { get: () => value.call(state), configurable: true })
|
||||||
|
return { state, calls, pallet, target }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('scan failures show the business message and retain correlation evidence in diagnostics', async () => {
|
||||||
|
const failure = { message: '箱码不属于当前一件代发入仓单', code: 'A0404', requestId: 'request-test', traceId: 'trace-test' }
|
||||||
|
const { state, calls } = harness({ resolveDropshipWorkspaceCarton: async () => { throw failure } })
|
||||||
|
await state.resolveCarton('WRONG-BOX')
|
||||||
|
assert.equal(state.error, failure.message)
|
||||||
|
assert.equal(state.loading, false)
|
||||||
|
const evidence = calls.find(call => call[0] === 'diagnostic')[2]
|
||||||
|
assert.equal(evidence.code, failure.code)
|
||||||
|
assert.equal(evidence.requestId, failure.requestId)
|
||||||
|
assert.equal(evidence.traceId, failure.traceId)
|
||||||
|
assert.equal(state.formatOperationError({}, '箱码解析失败'), '箱码解析失败')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cross customer scan remains read only until confirmation and then claims original task', async () => {
|
||||||
|
const { state, calls, pallet, target } = harness()
|
||||||
|
state.workspace = { pallet, members: [] }; state.selectedPalletNo = pallet.palletNo
|
||||||
|
await state.resolveCarton('BOX-B')
|
||||||
|
assert.equal(state.taskId, '10')
|
||||||
|
assert.equal(state.scanTask.taskId, target.taskId)
|
||||||
|
assert.equal(calls.length, 0)
|
||||||
|
await state.confirmCarton()
|
||||||
|
assert.equal(calls[0][0], 'get')
|
||||||
|
assert.equal(calls[1][0], 'claim')
|
||||||
|
const counted = calls.find((call) => call[0] === 'count')
|
||||||
|
assert.equal(counted[1], target.taskId)
|
||||||
|
assert.equal(counted[2].palletNo, pallet.palletNo)
|
||||||
|
assert.equal(state.taskId, target.taskId)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('failed pallet lookup removes stale active pallet and blocks counting', async () => {
|
||||||
|
const { state, pallet } = harness({ resolveDropshipWorkspacePallet: async () => { throw new Error('托盘不存在') } })
|
||||||
|
state.workspace = { pallet, members: [] }; state.selectedPalletNo = pallet.palletNo
|
||||||
|
state.carton = { canCount: true }; state.skuCounts = [{ actualQuantity: 1, expectedQuantity: 1 }]
|
||||||
|
await state.selectPallet('DP-261019-9999')
|
||||||
|
assert.equal(state.currentPallet, null)
|
||||||
|
assert.equal(state.canSubmitCarton, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('moving another customers carton submits its own task id', async () => {
|
||||||
|
const { state, calls, pallet, target } = harness()
|
||||||
|
state.workspace = { pallet, members: [{ taskId: target.taskId, inboundId: '31', customerId: '2', customerName: '客户B' }] }
|
||||||
|
state.selectedPalletNo = pallet.palletNo
|
||||||
|
await state.moveCarton({ cartonId: '99', inboundId: '31' }, null)
|
||||||
|
assert.equal(calls.find((call) => call[0] === 'move')[1], target.taskId)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('switching tickets carries only validated open pallet and scanner cannot race submission', async () => {
|
||||||
|
const { state, calls, pallet } = harness()
|
||||||
|
state.workspace = { pallet, members: [] }; state.selectedPalletNo = pallet.palletNo
|
||||||
|
state.continueOtherTask()
|
||||||
|
assert.match(calls[0][1].url, /palletNo=DP-261019-0001/)
|
||||||
|
state.submitting = true
|
||||||
|
await state.selectPallet('DP-261019-0002')
|
||||||
|
assert.equal(state.currentPallet.palletNo, pallet.palletNo)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reopening a task drops a pallet sealed since the draft was saved without mutating business facts', async () => {
|
||||||
|
const cleared = []
|
||||||
|
const { state, pallet, calls } = harness({ clearDropshipPalletizingDraft: (identity) => cleared.push(identity) })
|
||||||
|
pallet.status = 'SEALED'
|
||||||
|
state.selectedPalletNo = pallet.palletNo; state.palletCode = pallet.palletNo; state.selectedPalletId = pallet.palletId
|
||||||
|
await state.loadTask({ restorePallet: true })
|
||||||
|
assert.equal(state.currentPallet, null)
|
||||||
|
assert.equal(state.selectedPalletNo, '')
|
||||||
|
assert.equal(state.selectedPalletId, '')
|
||||||
|
assert.equal(state.palletCode, '')
|
||||||
|
assert.equal(cleared.length, 1)
|
||||||
|
assert.equal(cleared[0].taskId, '10')
|
||||||
|
assert.deepEqual(calls.map(call => call[0]), ['get'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('restoring open cross customer pallet still works and explicit sealed scan remains inspectable', async () => {
|
||||||
|
const { state, pallet } = harness()
|
||||||
|
state.selectedPalletNo = pallet.palletNo
|
||||||
|
await state.loadTask({ restorePallet: true })
|
||||||
|
assert.equal(state.currentPallet.palletNo, pallet.palletNo)
|
||||||
|
pallet.status = 'SEALED'
|
||||||
|
await state.selectPallet(pallet.palletNo)
|
||||||
|
assert.equal(state.currentPallet.status, 'SEALED')
|
||||||
|
assert.equal(state.canSubmitCarton, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('task list stops carrying a pallet sealed while another ticket was being worked', async () => {
|
||||||
|
const taskSource = await readFile(new URL('../pages/dropship-tasks/dropship-tasks.vue', import.meta.url), 'utf8')
|
||||||
|
const taskScript = taskSource.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/gm, '').replace('export default', 'globalThis.page =')
|
||||||
|
let status = 'OPEN'
|
||||||
|
const navigations = []
|
||||||
|
const context = { PortalHeader: {}, ScanTargetInput: {}, session: { currentUser: { warehouseId: '100' } },
|
||||||
|
resolveDropshipWorkspacePallet: async () => ({ pallet: { status } }),
|
||||||
|
listDropshipPalletizationTasks: async () => [], formatApiError: error => error.message,
|
||||||
|
uni: { navigateTo: value => navigations.push(value.url) } }
|
||||||
|
vm.runInNewContext(taskScript, context)
|
||||||
|
const state = Object.assign(context.page.data(), context.page.methods, { palletNo: 'DP-261019-0014' })
|
||||||
|
const task = { taskId: '20', warehouseId: '100' }
|
||||||
|
await state.loadTasks(); state.openTask(task)
|
||||||
|
assert.match(navigations[0], /palletNo=DP-261019-0014/)
|
||||||
|
status = 'SEALED'
|
||||||
|
await state.loadTasks(); state.openTask(task)
|
||||||
|
assert.equal(state.palletNo, '')
|
||||||
|
assert.ok(!navigations[1].includes('DP-261019-0014'))
|
||||||
|
})
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import test from 'node:test'
|
|
||||||
import assert from 'node:assert/strict'
|
|
||||||
|
|
||||||
const { createEvidenceUploadQueue } = await import(new URL('../utils/evidence-upload-queue.js', import.meta.url).href)
|
|
||||||
|
|
||||||
test('evidence queue exposes upload progress and successful durable result', async () => {
|
|
||||||
const changes = []
|
|
||||||
const queue = createEvidenceUploadQueue({
|
|
||||||
upload: async (item, progress) => {
|
|
||||||
progress(45)
|
|
||||||
return { fileId: 81, fileName: `${item.evidenceType}.jpg` }
|
|
||||||
},
|
|
||||||
onChange: (items) => changes.push(items)
|
|
||||||
})
|
|
||||||
|
|
||||||
queue.add({ id: 'empty-1', inboundId: 12, evidenceType: 'PHOTO_STEP_1', localPath: 'tmp/empty.jpg' })
|
|
||||||
const result = await queue.flush()
|
|
||||||
|
|
||||||
assert.equal(result[0].status, 'SUCCEEDED')
|
|
||||||
assert.equal(result[0].progress, 100)
|
|
||||||
assert.equal(result[0].result.fileId, 81)
|
|
||||||
assert.ok(changes.some((items) => items[0].status === 'UPLOADING' && items[0].progress === 45))
|
|
||||||
})
|
|
||||||
|
|
||||||
test('failed evidence remains observable and can be retried without a duplicate entry', async () => {
|
|
||||||
let attempts = 0
|
|
||||||
const queue = createEvidenceUploadQueue({
|
|
||||||
upload: async () => {
|
|
||||||
attempts += 1
|
|
||||||
if (attempts === 1) throw new Error('网络中断')
|
|
||||||
return { fileId: 82 }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const entry = queue.add({ id: 'half-1', inboundId: 12, evidenceType: 'PHOTO_STEP_2', localPath: 'tmp/half.jpg' })
|
|
||||||
await queue.flush()
|
|
||||||
assert.equal(queue.latest('PHOTO_STEP_2').status, 'FAILED')
|
|
||||||
assert.equal(queue.latest('PHOTO_STEP_2').error, '网络中断')
|
|
||||||
|
|
||||||
assert.equal(queue.retry(entry.id), true)
|
|
||||||
await queue.flush()
|
|
||||||
assert.equal(queue.latest('PHOTO_STEP_2').status, 'SUCCEEDED')
|
|
||||||
assert.equal(queue.latest('PHOTO_STEP_2').attempts, 2)
|
|
||||||
assert.equal(queue.snapshot().length, 1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('same evidence type appends and explicit void preserves the old photo', async () => {
|
|
||||||
const queue = createEvidenceUploadQueue({ upload: async () => { throw new Error('失败') } })
|
|
||||||
queue.add({ id: 'old', inboundId: 12, evidenceType: 'SEAL', localPath: 'tmp/old.jpg' })
|
|
||||||
await queue.flush()
|
|
||||||
queue.add({ id: 'new', inboundId: 12, evidenceType: 'SEAL', localPath: 'tmp/new.jpg' })
|
|
||||||
|
|
||||||
assert.equal(queue.snapshot().find((item) => item.id === 'old').status, 'FAILED')
|
|
||||||
assert.equal(queue.latest('SEAL').id, 'new')
|
|
||||||
assert.equal(queue.voidEntry('old', '照片模糊', 'worker-1'), true)
|
|
||||||
assert.equal(queue.snapshot().find((item) => item.id === 'old').status, 'VOIDED')
|
|
||||||
assert.equal(queue.snapshot().find((item) => item.id === 'old').voidReason, '照片模糊')
|
|
||||||
})
|
|
||||||
@@ -1,43 +1,28 @@
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import { runInNewContext } from 'node:vm'
|
import { runInNewContext } from 'node:vm'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { readFile } from 'node:fs/promises'
|
import { readFile, readdir } from 'node:fs/promises'
|
||||||
const read = path => readFile(new URL(path, import.meta.url), 'utf8')
|
const read = path => readFile(new URL(path, import.meta.url), 'utf8')
|
||||||
test('H5 image copies only compiled assets and static nginx configuration', async () => {
|
test('PDA H5 has no container publishing entry', async () => {
|
||||||
const dockerfile = await read('../deploy/Dockerfile.h5')
|
const files = await readdir(new URL('../deploy/', import.meta.url))
|
||||||
const copies = dockerfile.split(/\r?\n/).filter(line => line.startsWith('COPY '))
|
assert.deepEqual(files.filter(name => /docker|compose/i.test(name)), [])
|
||||||
assert.deepEqual(copies, ['COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf', 'COPY unpackage/dist/build/web/ /srv/pda-h5/pda-domestic/'])
|
await assert.rejects(read('../.dockerignore'), { code: 'ENOENT' })
|
||||||
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('!unpackage/dist/build/web/**'))
|
|
||||||
assert.doesNotMatch(ignore, /!\.env|!node_modules|!src/)
|
|
||||||
})
|
})
|
||||||
test('static server preserves the app prefix and returns genuine missing-resource errors', async () => {
|
|
||||||
const nginx = await read('../deploy/nginx.conf')
|
test('host nginx serves uploaded files and proxies only the API prefix', async () => {
|
||||||
assert.match(nginx, /listen 80;/)
|
const nginx = await read('../deploy/nginx.conf.template')
|
||||||
assert.ok(nginx.includes('location ^~ /pda-domestic/'))
|
assert.ok(nginx.includes('root ${PDA_SITE_ROOT};'))
|
||||||
assert.match(nginx, /root \/srv\/pda-h5;/)
|
const page = nginx.match(/location \/ \{([^{}]*)\}/)?.[1]
|
||||||
assert.match(nginx, /try_files \$uri \$uri\/ =404;/)
|
assert.ok(page)
|
||||||
assert.match(nginx, /Cache-Control "no-store" always/)
|
assert.match(page, /try_files \$uri \$uri\/ =404;/)
|
||||||
assert.match(nginx, /location \/ \{ return 404; \}/)
|
assert.match(page, /Cache-Control "no-store" always/)
|
||||||
assert.match(nginx, /location = \/healthz/)
|
assert.doesNotMatch(page, /proxy_pass|try_files[^;]*index\.html/)
|
||||||
assert.doesNotMatch(nginx, /proxy_pass|gateway-api|try_files[^;]*index\.html/)
|
assert.match(nginx, /location \^~ \/gateway-api\//)
|
||||||
})
|
assert.ok(nginx.includes('proxy_pass ${PDA_GATEWAY_ORIGIN}/api/;'))
|
||||||
test('TEST H5 deployment has a required image tag and internal-only dual health checks', async () => {
|
assert.equal((nginx.match(/proxy_pass/g) || []).length, 1)
|
||||||
const compose = await read('../deploy/docker-compose.h5.test.yml')
|
assert.match(nginx, /proxy_set_header Authorization \$http_authorization;/)
|
||||||
assert.ok(compose.includes('${IMAGE_TAG:?IMAGE_TAG is required}'))
|
assert.match(nginx, /client_max_body_size 55m;/)
|
||||||
assert.match(compose, /context: \.\./)
|
assert.doesNotMatch(nginx, /29005|29006|\/srv\/pda-h5|gateway:9000/)
|
||||||
assert.match(compose, /dockerfile: deploy\/Dockerfile\.h5/)
|
|
||||||
assert.match(compose, /container_name: pda-domestic-h5-test/)
|
|
||||||
assert.match(compose, /aliases:\s*\n\s*- pda-domestic-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/pda-domestic/'))
|
|
||||||
assert.doesNotMatch(compose, /\bports:|volumes:|API_UPSTREAM|privileged:|pid: host/)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('only H5 production overrides the toolchain asset base', async () => {
|
test('only H5 production overrides the toolchain asset base', async () => {
|
||||||
@@ -53,8 +38,22 @@ test('only H5 production overrides the toolchain asset base', async () => {
|
|||||||
process: { env: { UNI_PLATFORM: platform }, cwd: () => '/project' }
|
process: { env: { UNI_PLATFORM: platform }, cwd: () => '/project' }
|
||||||
})
|
})
|
||||||
assert.equal(factory({ mode }).base,
|
assert.equal(factory({ mode }).base,
|
||||||
platform === 'h5' && mode === 'production' ? '/pda-domestic/' : undefined,
|
platform === 'h5' && mode === 'production' ? '/' : undefined,
|
||||||
`${platform}:${mode}`)
|
`${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}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,13 +1,133 @@
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
import {
|
import {
|
||||||
COMPLETION_PHASE,
|
COMPLETION_PHASE,
|
||||||
completionPresentation,
|
completionPresentation,
|
||||||
|
observeLoadingCompletion,
|
||||||
refreshCompletionAttempt,
|
refreshCompletionAttempt,
|
||||||
resolveCompletionPhase,
|
resolveCompletionPhase,
|
||||||
shouldReconcileCompletion
|
shouldReconcileCompletion
|
||||||
} from '../utils/loading-completion-state.js'
|
} from '../utils/loading-completion-state.js'
|
||||||
|
|
||||||
|
test('accepted completion queries immediately and returns as soon as the real task completes', async () => {
|
||||||
|
const reads = [], waits = [], applied = []
|
||||||
|
let clock = 0
|
||||||
|
const result = await observeLoadingCompletion({
|
||||||
|
initialTask: { taskStatus: 'COMPLETING' },
|
||||||
|
readTask: async () => {
|
||||||
|
reads.push(clock)
|
||||||
|
return { taskStatus: reads.length === 1 ? 'COMPLETING' : 'COMPLETED' }
|
||||||
|
},
|
||||||
|
isActive: () => true, onTask: task => applied.push(task.taskStatus), now: () => clock,
|
||||||
|
wait: async delay => { waits.push(delay); clock += delay }
|
||||||
|
})
|
||||||
|
assert.deepEqual(reads, [0, 250])
|
||||||
|
assert.deepEqual(waits, [250])
|
||||||
|
assert.deepEqual(applied, ['COMPLETING', 'COMPLETED'])
|
||||||
|
assert.equal(result.taskStatus, 'COMPLETED')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('completion observation preserves failure, pending and unknown results without inventing success', async () => {
|
||||||
|
for (const status of ['COMPLETION_FAILED', 'LOADING']) {
|
||||||
|
const result = await observeLoadingCompletion({ initialTask: { taskStatus: 'COMPLETING' },
|
||||||
|
readTask: async () => ({ taskStatus: status }), isActive: () => true, onTask() {},
|
||||||
|
wait: async () => assert.fail('terminal or unknown facts must stop polling') })
|
||||||
|
assert.equal(result.taskStatus, status)
|
||||||
|
}
|
||||||
|
let clock = 0, reads = 0
|
||||||
|
const pending = await observeLoadingCompletion({ initialTask: { taskStatus: 'COMPLETING' },
|
||||||
|
readTask: async () => { reads++; return { taskStatus: 'COMPLETING' } },
|
||||||
|
isActive: () => true, onTask() {}, now: () => clock, maxWaitMs: 500,
|
||||||
|
wait: async delay => { clock += delay } })
|
||||||
|
assert.equal(pending.taskStatus, 'COMPLETING')
|
||||||
|
assert.equal(reads, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('hidden or switched tasks stop completion observation and discard in-flight responses', async () => {
|
||||||
|
let active = true, applied = 0
|
||||||
|
const result = await observeLoadingCompletion({ initialTask: { taskStatus: 'COMPLETING' },
|
||||||
|
readTask: async () => { active = false; return { taskStatus: 'COMPLETED' } },
|
||||||
|
isActive: () => active, onTask: () => { applied++ } })
|
||||||
|
assert.equal(result, null)
|
||||||
|
assert.equal(applied, 0)
|
||||||
|
await observeLoadingCompletion({ initialTask: { taskStatus: 'COMPLETING' },
|
||||||
|
readTask: async () => assert.fail('hidden task must not query'), isActive: () => false, onTask() {} })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('query failure is not retried as a completion mutation and completed acceptance needs no query', async () => {
|
||||||
|
await assert.rejects(observeLoadingCompletion({ initialTask: { taskStatus: 'COMPLETING' },
|
||||||
|
readTask: async () => { throw new Error('network unavailable') }, isActive: () => true, onTask() {} }), /network/)
|
||||||
|
const completed = { taskStatus: 'COMPLETED' }
|
||||||
|
assert.equal(await observeLoadingCompletion({ initialTask: completed,
|
||||||
|
readTask: async () => assert.fail('no query after authoritative completion'), isActive: () => true, onTask() {} }), completed)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('page only announces success without persisting a replay request after authoritative completion', async () => {
|
||||||
|
const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
|
||||||
|
const method = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0]
|
||||||
|
assert.ok(method)
|
||||||
|
for (const status of ['COMPLETED', 'COMPLETION_FAILED', 'COMPLETING', 'LOADING', 'QUERY_ERROR', 'NO_RESPONSE']) {
|
||||||
|
const messages = [], cleared = [], saved = []
|
||||||
|
const bindings = {
|
||||||
|
COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion,
|
||||||
|
isRemoteReady: () => true, restoreLoadingCompletionAttempt: () => null,
|
||||||
|
stableTaskOperationIdentity: () => 'stable-task', stableOperationId: () => 'original-operation',
|
||||||
|
saveLoadingCompletionAttempt: (_, attempt) => saved.push(attempt),
|
||||||
|
clearLoadingCompletionAttempt: identity => cleared.push(identity),
|
||||||
|
completeContainerPickingTask: async () => status === 'NO_RESPONSE' ? null : { taskStatus: 'COMPLETING', version: 2 },
|
||||||
|
normalizePickingTask: task => task,
|
||||||
|
observeLoadingCompletion: async options => {
|
||||||
|
assert.notEqual(status, 'NO_RESPONSE')
|
||||||
|
assert.equal(options.isActive(), true)
|
||||||
|
if (status === 'QUERY_ERROR') throw new Error('network error after acceptance')
|
||||||
|
const task = { taskStatus: status, version: 3 }
|
||||||
|
options.onTask(task)
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const behavior = new Function(...Object.keys(bindings), `return ({${method}})`)(...Object.values(bindings))
|
||||||
|
const context = {
|
||||||
|
requireEvidenceStage: () => true, evidenceIdentity: { taskId: 'task', warehouseId: 'warehouse' },
|
||||||
|
pickingVersion: 1, evidenceComplete: true, evidenceContextId: 1, remoteTaskId: 'task', remotePick: {},
|
||||||
|
draftIdentity: () => ({ loadingPlanId: 'plan', containerBookingId: 'cabinet' }),
|
||||||
|
isEvidenceContextActive: () => true, _pageVisible: true, debugSubmit() {}, persistSession() {},
|
||||||
|
showToast: (message, type) => messages.push({ message, type })
|
||||||
|
}
|
||||||
|
await behavior.completeLoadingTask.call(context)
|
||||||
|
assert.equal(context.submitting, false)
|
||||||
|
assert.equal(saved.length, 0)
|
||||||
|
assert.equal(cleared.length, 0)
|
||||||
|
assert.equal(messages.some(message => message.message === '装柜已完成'), status === 'COMPLETED')
|
||||||
|
if (status !== 'COMPLETED') assert.ok(context.remoteError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reopened processing and completed tasks keep evidence and completion read-only', async () => {
|
||||||
|
const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
|
||||||
|
const methods = ['loadingCompletionState', 'evidenceReadOnly', 'requireEvidenceStage'].map(name => {
|
||||||
|
const method = page.match(new RegExp(`${name}\\(\\) \\{[\\s\\S]*?\\n\\t\\t\\t\\},`))?.[0]
|
||||||
|
assert.ok(method, name)
|
||||||
|
return method
|
||||||
|
})
|
||||||
|
const behavior = new Function('resolveCompletionPhase', 'completionPresentation', `return ({${methods.join('\n')}})`)(resolveCompletionPhase, completionPresentation)
|
||||||
|
for (const workflowStatus of ['PROCESSING', 'COMPLETED']) {
|
||||||
|
const messages = []
|
||||||
|
const context = { remotePick: { workflowStatus, completionCommand: { status: workflowStatus } }, submitting: false, pickingCompleted: true, loadingStageReady: true, showToast(message) { messages.push(message) } }
|
||||||
|
context.loadingCompletionState = behavior.loadingCompletionState.call(context)
|
||||||
|
context.evidenceReadOnly = behavior.evidenceReadOnly.call(context)
|
||||||
|
assert.equal(context.evidenceReadOnly, true)
|
||||||
|
assert.equal(behavior.requireEvidenceStage.call(context), false)
|
||||||
|
assert.deepEqual(messages, [workflowStatus === 'COMPLETED' ? '装柜已完成' : '完成处理中'])
|
||||||
|
}
|
||||||
|
const retry = { remotePick: { workflowStatus: 'COMPLETION_FAILED' }, submitting: false }
|
||||||
|
retry.loadingCompletionState = behavior.loadingCompletionState.call(retry)
|
||||||
|
assert.equal(behavior.evidenceReadOnly.call(retry), false)
|
||||||
|
assert.match(page, /v-if="evidenceReadOnly"[^>]*role="status"/)
|
||||||
|
assert.match(page, /v-else class="loading-complete-btn"/)
|
||||||
|
assert.match(page, /class="evidence-btn evidence-add-btn" :disabled="evidenceReadOnly \|\| evidenceUploading"/)
|
||||||
|
})
|
||||||
|
|
||||||
test('accepted completion is processing and blocks every repeated mutation', () => {
|
test('accepted completion is processing and blocks every repeated mutation', () => {
|
||||||
for (const facts of [
|
for (const facts of [
|
||||||
{ taskStatus: 'COMPLETING' },
|
{ taskStatus: 'COMPLETING' },
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ test('workbench, module, history and profile are independent sources', async ()
|
|||||||
assert.match(module, /pages\/operation\/operation\?module=\$\{encodeURIComponent\(this\.moduleKey\)\}&name=/)
|
assert.match(module, /pages\/operation\/operation\?module=\$\{encodeURIComponent\(this\.moduleKey\)\}&name=/)
|
||||||
assert.match(history, /作业记录/)
|
assert.match(history, /作业记录/)
|
||||||
assert.match(profile, /个人中心/)
|
assert.match(profile, /个人中心/)
|
||||||
assert.match(profile, /signOut\(\)/)
|
assert.match(profile, /logoutCurrentSession\(\)/)
|
||||||
assert.match(profile, /class="logout-dialog-card"/)
|
assert.match(profile, /class="logout-dialog-card"/)
|
||||||
assert.doesNotMatch([home, module, history, profile].join('\n'), /BottomNav/)
|
assert.doesNotMatch([home, module, history, profile].join('\n'), /BottomNav/)
|
||||||
})
|
})
|
||||||
@@ -292,7 +292,10 @@ test('picking session persists per task identity and clears on logout', async ()
|
|||||||
assert.match(store, /savePickingSession/)
|
assert.match(store, /savePickingSession/)
|
||||||
assert.match(store, /resetPickingSession/)
|
assert.match(store, /resetPickingSession/)
|
||||||
assert.match(session, /signOut\(\)[\s\S]*?resetPickingSession\(\)/)
|
assert.match(session, /signOut\(\)[\s\S]*?resetPickingSession\(\)/)
|
||||||
assert.match(page, /onUnload\(\) \{ this\.stopHardwareScanner\(\); this\.persistSession\(\)/)
|
const unload = page.match(/onUnload\(\) \{([^}]+)\}/)?.[1] || ''
|
||||||
|
assert.match(unload, /this\.stopHardwareScanner\(\)/)
|
||||||
|
assert.match(unload, /this\.persistSession\(\)/)
|
||||||
|
assert.match(unload, /this\.invalidateTaskSync\(\)/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('shared visual contracts preserve accessible PDA targets', async () => {
|
test('shared visual contracts preserve accessible PDA targets', async () => {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ test('operators receive actionable refresh guidance without internal version wor
|
|||||||
assert.match(page, /状态尚未同步,请刷新重试/)
|
assert.match(page, /状态尚未同步,请刷新重试/)
|
||||||
assert.doesNotMatch(page, /缺少拣货版本号|提供装柜版本号/)
|
assert.doesNotMatch(page, /缺少拣货版本号|提供装柜版本号/)
|
||||||
const complete = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
|
const complete = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
|
||||||
assert.match(complete, /if \(!this\.requireEvidenceStage\(\)\) return/)
|
assert.match(complete, /if \(this\.evidenceUploading \|\| !this\.requireEvidenceStage\(\)\) return/)
|
||||||
assert.match(complete, /completeContainerPickingTask\(this\.remoteTaskId/)
|
assert.match(complete, /completeContainerPickingTask\(this\.remoteTaskId/)
|
||||||
assert.match(complete, /expectedTaskVersion/)
|
assert.match(complete, /expectedTaskVersion/)
|
||||||
assert.doesNotMatch(complete, /completeLoading\(inboundId/)
|
assert.doesNotMatch(complete, /completeLoading\(inboundId/)
|
||||||
@@ -64,7 +64,7 @@ test('dynamic pending items do not block switching between picking and evidence'
|
|||||||
assert.doesNotMatch(page, /stage === 'evidence' && !this\.loadingStageReady/)
|
assert.doesNotMatch(page, /stage === 'evidence' && !this\.loadingStageReady/)
|
||||||
assert.doesNotMatch(page, /请先完成新增待拣明细/)
|
assert.doesNotMatch(page, /请先完成新增待拣明细/)
|
||||||
assert.match(page, /activeStage === 'picking'/)
|
assert.match(page, /activeStage === 'picking'/)
|
||||||
assert.match(page, /:disabled="submitting \|\| !loadingStageReady \|\| pickingVersion === null/)
|
assert.match(page, /:disabled="submitting \|\| evidenceUploading \|\| !loadingStageReady \|\| pickingVersion === null/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('incremental refresh merges new pending items without overwriting submitted server results', () => {
|
test('incremental refresh merges new pending items without overwriting submitted server results', () => {
|
||||||
@@ -128,7 +128,7 @@ test('seal photo upload waits for an explicit seal-number save without waiting f
|
|||||||
const saver = page.match(/async saveSealNo\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
|
const saver = page.match(/async saveSealNo\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
|
||||||
assert.doesNotMatch(saver, /if \(!this\.loadingStageReady\)/)
|
assert.doesNotMatch(saver, /if \(!this\.loadingStageReady\)/)
|
||||||
assert.match(page, /sealReadyToSave\(\)[\s\S]*?hasPendingLocalEvidence\('SEAL'\)/)
|
assert.match(page, /sealReadyToSave\(\)[\s\S]*?hasPendingLocalEvidence\('SEAL'\)/)
|
||||||
assert.match(page, /slot\.type === 'SEAL' \? '请填写封签号,保存后上传' : '照片待自动上传'/)
|
assert.match(page, /slot\.type === 'SEAL' \? '请填写封签号,保存后上传' : '照片未保存,请重新拍照上传'/)
|
||||||
assert.match(page, /带锁码照上传成功,封签号已保存/)
|
assert.match(page, /带锁码照上传成功,封签号已保存/)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -162,24 +162,21 @@ test('merged or non-active cabinet details are rejected before claim and clear s
|
|||||||
test('photos upload, bind, reload and void before picking completion', () => {
|
test('photos upload, bind, reload and void before picking completion', () => {
|
||||||
assert.match(page, /requireEvidenceStage\(\)[\s\S]*?if \(!this\.pickingCompleted\)[\s\S]*?请先提交拣货完成[\s\S]*?if \(!this\.loadingStageReady\)/)
|
assert.match(page, /requireEvidenceStage\(\)[\s\S]*?if \(!this\.pickingCompleted\)[\s\S]*?请先提交拣货完成[\s\S]*?if \(!this\.loadingStageReady\)/)
|
||||||
const queue = page.match(/async queueEvidenceUpload\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
|
const queue = page.match(/async queueEvidenceUpload\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
|
||||||
assert.match(queue, /await queue\.flush\(\)[\s\S]*?this\.applyEvidenceResponse\(latest\.result, type, path, identity\)/)
|
assert.match(queue, /await uploadEvidenceFile\([\s\S]*?this\.applyEvidenceResponse\(result, type, path, identity\)/)
|
||||||
assert.doesNotMatch(queue, /if \(!this\.loadingStageReady\)/)
|
assert.doesNotMatch(queue, /if \(!this\.loadingStageReady\)/)
|
||||||
assert.match(page, /chooseEvidence\(slot\)[\s\S]*?this\.queueEvidenceUpload\(slot\.type, path\)/)
|
assert.match(page, /chooseEvidence\(slot\)[\s\S]*?this\.queueEvidenceUpload\(slot\.type, path\)/)
|
||||||
const retry = page.match(/async retryEvidence\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
|
assert.doesNotMatch(page, /async retryEvidence|async flushPendingEvidence/)
|
||||||
assert.match(retry, /if \(!this\.requireEvidenceCapture\(\)\) return/)
|
|
||||||
assert.match(retry, /await queue\.flush\(\)[\s\S]*?this\.applyEvidenceResponse\(latest\.result, type, item\.localPath, identity\)/)
|
|
||||||
assert.doesNotMatch(retry, /if \(!this\.loadingStageReady\)/)
|
|
||||||
assert.match(page, /async loadLoadingEvidence\(\) \{[\s\S]*?!this\.evidenceIdentity\?\.taskId[\s\S]*?!this\.evidenceIdentity\?\.warehouseId[\s\S]*?getEvidence\(identity\.taskId, identity\.warehouseId\)/)
|
assert.match(page, /async loadLoadingEvidence\(\) \{[\s\S]*?!this\.evidenceIdentity\?\.taskId[\s\S]*?!this\.evidenceIdentity\?\.warehouseId[\s\S]*?getEvidence\(identity\.taskId, identity\.warehouseId\)/)
|
||||||
assert.match(page, /await this\.loadLoadingEvidence\(\)[\s\S]*?if \(this\.loadingStageReady\) this\.activeStage = 'evidence'/)
|
assert.match(page, /await this\.loadLoadingEvidence\(\)[\s\S]*?if \(this\.loadingStageReady\) this\.activeStage = 'evidence'/)
|
||||||
assert.match(page, /async deleteEvidencePhoto\([^)]*\) \{[\s\S]*?if \(!this\.requireEvidenceMutation\(\)\) return/)
|
assert.match(page, /async deleteEvidencePhoto\([^)]*\) \{[\s\S]*?if \(!this\.requireEvidenceMutation\(\)\) return/)
|
||||||
assert.match(page, /async completeLoadingTask\([^)]*\) \{[\s\S]*?if \(!this\.requireEvidenceStage\(\)\) return/)
|
assert.match(page, /async completeLoadingTask\([^)]*\) \{[\s\S]*?if \(this\.evidenceUploading \|\| !this\.requireEvidenceStage\(\)\) return/)
|
||||||
assert.doesNotMatch(page, /照片已上传,拣货完成后自动保存/)
|
assert.doesNotMatch(page, /照片已上传,拣货完成后自动保存/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refresh preserves local photos and flushes them as soon as evidence identity is ready', () => {
|
test('refresh preserves local photos without submitting them automatically', () => {
|
||||||
assert.match(page, /this\.rememberEvidenceDraft\(\)/)
|
assert.match(page, /this\.rememberEvidenceDraft\(\)/)
|
||||||
assert.match(page, /this\.restoreEvidenceDraft\(\)/)
|
assert.match(page, /this\.restoreEvidenceDraft\(\)/)
|
||||||
assert.match(page, /async flushPendingEvidence\(\)[\s\S]*?queueEvidenceUpload/)
|
assert.doesNotMatch(page, /async flushPendingEvidence/)
|
||||||
assert.match(page, /const localFiles = Object\.fromEntries/)
|
assert.match(page, /const localFiles = Object\.fromEntries/)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -206,7 +203,7 @@ test('switching cabinets invalidates old sync and evidence upload state', () =>
|
|||||||
assert.match(selector, /this\.pickingVersion = null/)
|
assert.match(selector, /this\.pickingVersion = null/)
|
||||||
assert.match(selector, /this\.submitting = false/)
|
assert.match(selector, /this\.submitting = false/)
|
||||||
assert.match(selector, /this\.resetEvidenceState\(\)/)
|
assert.match(selector, /this\.resetEvidenceState\(\)/)
|
||||||
assert.match(page, /!this\.isEvidenceContextActive\(contextId, identity\) \|\| queue !== this\.evidenceQueue/)
|
assert.match(page, /if \(!this\.isEvidenceContextActive\(contextId, identity\)\) return/)
|
||||||
const loader = page.match(/async loadLoadingEvidence\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
|
const loader = page.match(/async loadLoadingEvidence\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
|
||||||
assert.ok((loader.match(/isEvidenceContextActive\(contextId, identity\)/g) || []).length >= 2)
|
assert.ok((loader.match(/isEvidenceContextActive\(contextId, identity\)/g) || []).length >= 2)
|
||||||
})
|
})
|
||||||
@@ -249,7 +246,7 @@ test('evidence save, void and loading completion ignore stale cabinet responses'
|
|||||||
assert.ok((body.match(/isEvidenceContextActive\(contextId, identity\)/g) || []).length >= 2, `${method} should guard success and error writebacks`)
|
assert.ok((body.match(/isEvidenceContextActive\(contextId, identity\)/g) || []).length >= 2, `${method} should guard success and error writebacks`)
|
||||||
}
|
}
|
||||||
const complete = page.match(/async completeLoadingTask\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
|
const complete = page.match(/async completeLoadingTask\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
|
||||||
assert.match(complete, /finally \{[\s\S]*?isEvidenceContextActive\(contextId, identity\)[\s\S]*?this\.submitting = false/)
|
assert.match(complete, /finally \{[\s\S]*?this\._submissionOwner === submission[\s\S]*?this\.submitting = false/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('completed server tasks restore evidence stage without being claimed again', () => {
|
test('completed server tasks restore evidence stage without being claimed again', () => {
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion } from '../utils/loading-completion-state.js'
|
||||||
|
|
||||||
|
const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
|
||||||
|
const method = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)[0]
|
||||||
|
function completion(overrides = {}, savedAttempt = null) {
|
||||||
|
let attempt = savedAttempt
|
||||||
|
const calls = [], cleared = [], messages = []
|
||||||
|
const bindings = {
|
||||||
|
COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion,
|
||||||
|
isRemoteReady: () => true, stableTaskOperationIdentity: () => 'task', stableOperationId: (_action, _identity, payload) => `complete-v${payload.expectedTaskVersion}`,
|
||||||
|
restoreLoadingCompletionAttempt: () => attempt, saveLoadingCompletionAttempt: (_identity, value) => { attempt = value },
|
||||||
|
clearLoadingCompletionAttempt: identity => { cleared.push(identity); attempt = null },
|
||||||
|
completeContainerPickingTask: async (_task, payload) => { calls.push(payload); return { taskStatus: 'COMPLETED' } },
|
||||||
|
normalizePickingTask: task => task, observeLoadingCompletion: async options => options.initialTask,
|
||||||
|
sanitizeUserMessage: value => value, ...overrides
|
||||||
|
}
|
||||||
|
const behavior = new Function(...Object.keys(bindings), `return ({${method}})`)(...Object.values(bindings))
|
||||||
|
const state = {
|
||||||
|
evidenceIdentity: { taskId: 'task', warehouseId: 'warehouse' }, evidenceContextId: 1, remoteTaskId: 'task', remotePick: {},
|
||||||
|
pickingVersion: 1, evidenceComplete: true, submitting: false, _pageVisible: true,
|
||||||
|
requireEvidenceStage() { return !this.submitting }, draftIdentity: () => ({ taskId: 'task', loadingPlanId: 'plan', containerBookingId: 'cabinet' }),
|
||||||
|
isEvidenceContextActive(id) { return id === this.evidenceContextId }, isTaskConflict: error => error.code === 'A0409',
|
||||||
|
async refreshAfterTaskConflict() { this.pickingVersion = 2 }, debugSubmit() {}, persistSession() {},
|
||||||
|
remoteFailureMessage: error => error.message, showToast: message => messages.push(message)
|
||||||
|
}
|
||||||
|
state.complete = behavior.completeLoadingTask.bind(state)
|
||||||
|
return { state, calls, cleared, messages, attempt: () => attempt }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('completion in flight blocks a second click and releases busy after the task context changes', async () => {
|
||||||
|
let resolve
|
||||||
|
const { state, calls } = completion({ completeContainerPickingTask: (...args) => { calls.push(args); return new Promise(done => { resolve = done }) } })
|
||||||
|
const first = state.complete(); await state.complete(); assert.equal(calls.length, 1)
|
||||||
|
state.evidenceContextId++; resolve({ taskStatus: 'COMPLETED' }); await first
|
||||||
|
assert.equal(state.submitting, false); assert.equal(state.remotePick.taskStatus, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an old completion response cannot release a newer picking submission', async () => {
|
||||||
|
let resolve
|
||||||
|
const { state } = completion({ completeContainerPickingTask: () => new Promise(done => { resolve = done }) })
|
||||||
|
const first = state.complete()
|
||||||
|
state.evidenceContextId++; state._submissionOwner = {}; state.submitting = true
|
||||||
|
resolve({ taskStatus: 'COMPLETED' }); await first
|
||||||
|
assert.equal(state.submitting, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('timeout unlocks completion and the next click uses the current version', async () => {
|
||||||
|
const commands = []
|
||||||
|
const { state, attempt } = completion({ completeContainerPickingTask: async (_task, payload) => {
|
||||||
|
commands.push(payload); if (commands.length === 1) throw { network: true }; return { taskStatus: 'COMPLETED' }
|
||||||
|
} })
|
||||||
|
await state.complete(); assert.equal(state.submitting, false); assert.equal(attempt(), null)
|
||||||
|
state.pickingVersion = 7; await state.complete()
|
||||||
|
assert.equal(commands[1].expectedTaskVersion, 7); assert.notEqual(commands[0].operationId, commands[1].operationId)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('first definite conflict releases its stale completion version so the next operation can succeed', async () => {
|
||||||
|
const commands = []
|
||||||
|
const { state, cleared } = completion({ completeContainerPickingTask: async (_task, payload) => {
|
||||||
|
commands.push(payload); if (commands.length === 1) throw { code: 'A0409', message: '任务已更新' }; return { taskStatus: 'COMPLETED' }
|
||||||
|
} })
|
||||||
|
await state.complete(); assert.equal(state.submitting, false); assert.equal(cleared.length, 0)
|
||||||
|
await state.complete(); assert.equal(commands[1].expectedTaskVersion, 2); assert.notEqual(commands[0].operationId, commands[1].operationId)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('old local completion snapshots are ignored and cannot freeze the current task', async () => {
|
||||||
|
const old = { operationId: 'old', expectedTaskVersion: 1 }
|
||||||
|
const { state, calls } = completion({}, old)
|
||||||
|
state.pickingVersion = 8; await state.complete()
|
||||||
|
assert.equal(calls[0].expectedTaskVersion, 8); assert.notEqual(calls[0].operationId, old.operationId)
|
||||||
|
assert.equal(state.submitting, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a reopened processing task has an explicit refresh action without a mutation retry', () => {
|
||||||
|
assert.match(page, /v-if="evidenceReadOnly && loadingCompletionState.phase !== 'COMPLETED'"[^>]*@tap="refreshTask">刷新完成状态/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('photo failure releases the page and another click uploads the current photo with a fresh key', async () => {
|
||||||
|
const uploadMethod = page.slice(page.indexOf('async queueEvidenceUpload('), page.indexOf('async saveSealNo('))
|
||||||
|
const requests = []
|
||||||
|
const uploadEvidenceFile = async (...args) => { requests.push(args); if (requests.length === 1) throw { network: true, message: 'timeout' }; return { saved: true } }
|
||||||
|
const behavior = new Function('isRemoteReady', 'uploadEvidenceFile', 'return ({' + uploadMethod + '})')(() => true, uploadEvidenceFile)
|
||||||
|
const state = { evidenceUploading: false, evidenceReadOnly: false, evidenceIdentity: { taskId: 'task', warehouseId: 'warehouse' }, evidenceContextId: 1, evidenceQueueItems: [], remoteError: '', validEvidenceEntries: () => [], isEvidenceContextActive: () => true, remoteFailureMessage: error => error.message, applyEvidenceResponse() {}, showToast() {} }
|
||||||
|
await behavior.queueEvidenceUpload.call(state, 'SEAL', 'old.jpg', 'OLD')
|
||||||
|
assert.equal(state.evidenceUploading, false); assert.equal(state.remoteError, 'timeout')
|
||||||
|
await behavior.queueEvidenceUpload.call(state, 'SEAL', 'new.jpg', 'NEW')
|
||||||
|
assert.equal(state.evidenceUploading, false); assert.equal(requests[1][3], 'new.jpg'); assert.equal(requests[1][2].sealNo, 'NEW')
|
||||||
|
assert.notEqual(requests[1][2].operationId, requests[0][2].operationId)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('failed ordinary photo is removed from the visible and restored draft without deleting saved evidence', async () => {
|
||||||
|
const uploadMethod = page.slice(page.indexOf('async queueEvidenceUpload('), page.indexOf('async saveSealNo('))
|
||||||
|
const draftMethods = page.slice(page.indexOf('evidenceDraftKey(identity)'), page.indexOf('requireEvidenceCapture() {'))
|
||||||
|
const behavior = new Function('isRemoteReady', 'uploadEvidenceFile', 'return ({' + draftMethods + uploadMethod + '})')(
|
||||||
|
() => true, async () => { throw new Error('upload failed') }
|
||||||
|
)
|
||||||
|
const saved = { id: 'saved-photo', fileId: 'saved-file', localPath: 'saved.jpg' }
|
||||||
|
const failed = { localPath: 'failed.jpg', uploadStatus: 'PENDING' }
|
||||||
|
const seal = { localPath: 'seal.jpg', uploadStatus: 'PENDING' }
|
||||||
|
const state = {
|
||||||
|
...behavior, evidenceUploading: false, evidenceReadOnly: false,
|
||||||
|
evidenceIdentity: { taskId: 'task', warehouseId: 'warehouse' }, evidenceContextId: 1,
|
||||||
|
evidenceQueueItems: [], evidenceDrafts: {}, sealNo: 'SEAL-A',
|
||||||
|
evidenceFiles: { PHOTO_STEP_1: [saved, failed], SEAL: [seal] },
|
||||||
|
validEvidenceEntries: () => [saved], isEvidenceContextActive: () => true,
|
||||||
|
remoteFailureMessage: error => error.message, showToast() {}
|
||||||
|
}
|
||||||
|
state.rememberEvidenceDraft()
|
||||||
|
await state.queueEvidenceUpload('PHOTO_STEP_1', 'failed.jpg')
|
||||||
|
assert.deepEqual(state.evidenceFiles.PHOTO_STEP_1, [saved])
|
||||||
|
assert.equal(state.evidenceUploading, false)
|
||||||
|
assert.equal(state.remoteError, 'upload failed')
|
||||||
|
state.restoreEvidenceDraft()
|
||||||
|
assert.equal(state.evidenceFiles.PHOTO_STEP_1, undefined)
|
||||||
|
assert.deepEqual(state.evidenceFiles.SEAL, [seal])
|
||||||
|
await state.queueEvidenceUpload('SEAL', 'seal.jpg', 'SEAL-A')
|
||||||
|
assert.deepEqual(state.evidenceFiles.SEAL, [seal])
|
||||||
|
assert.equal(state.evidenceUploading, false)
|
||||||
|
})
|
||||||
@@ -36,12 +36,14 @@ test('duplicate onShow shares one load chain while background return refreshes a
|
|||||||
const page = await read('../pages/picking/picking.vue')
|
const page = await read('../pages/picking/picking.vue')
|
||||||
const show = page.match(/async onShow\(\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
|
const show = page.match(/async onShow\(\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
|
||||||
const hide = page.match(/onHide\(\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
|
const hide = page.match(/onHide\(\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
|
||||||
assert.ok(show && hide)
|
const invalidate = page.match(/invalidateTaskSync\(\) \{[^\r\n]+\},/)?.[0]
|
||||||
const hooks = new Function('requireSession', 'session', `return ({${show}${hide}})`)(() => true, { currentUser: { id: '7' } })
|
assert.ok(show && hide && invalidate)
|
||||||
|
const hooks = new Function('requireSession', 'session', `return ({${show}${hide}${invalidate}})`)(() => true, { currentUser: { id: '7' } })
|
||||||
const calls = []
|
const calls = []
|
||||||
let release
|
let release
|
||||||
const pending = new Promise((resolve) => { release = resolve })
|
const pending = new Promise((resolve) => { release = resolve })
|
||||||
const context = { startHardwareScanner() {}, stopHardwareScanner() {}, async refreshAvailableBoxes() { calls.push('pending'); await pending }, async syncRemoteTask() { calls.push('detail+evidence') } }
|
const context = { taskRequestId: 0, invalidateTaskSync: hooks.invalidateTaskSync,
|
||||||
|
startHardwareScanner() {}, stopHardwareScanner() {}, async refreshAvailableBoxes() { calls.push('pending'); await pending }, async syncRemoteTask() { calls.push('detail+evidence') } }
|
||||||
const first = hooks.onShow.call(context)
|
const first = hooks.onShow.call(context)
|
||||||
const duplicate = hooks.onShow.call(context)
|
const duplicate = hooks.onShow.call(context)
|
||||||
assert.deepEqual(calls, ['pending'])
|
assert.deepEqual(calls, ['pending'])
|
||||||
@@ -51,6 +53,9 @@ test('duplicate onShow shares one load chain while background return refreshes a
|
|||||||
await hooks.onShow.call(context)
|
await hooks.onShow.call(context)
|
||||||
assert.equal(calls.length, 2)
|
assert.equal(calls.length, 2)
|
||||||
hooks.onHide.call(context)
|
hooks.onHide.call(context)
|
||||||
|
assert.equal(context.taskRequestId, 1)
|
||||||
|
assert.equal(context.remoteLoading, false)
|
||||||
|
assert.equal(context._showRefreshPromise, null)
|
||||||
await hooks.onShow.call(context)
|
await hooks.onShow.call(context)
|
||||||
assert.deepEqual(calls, ['pending', 'detail+evidence', 'pending', 'detail+evidence'])
|
assert.deepEqual(calls, ['pending', 'detail+evidence', 'pending', 'detail+evidence'])
|
||||||
const onLoad = page.match(/onLoad\(options = \{\}\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
|
const onLoad = page.match(/onLoad\(options = \{\}\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import vm from 'node:vm'
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
|
||||||
|
const source = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
|
||||||
|
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1]
|
||||||
|
const stateSource = await readFile(new URL('../utils/picking-state.js', import.meta.url), 'utf8')
|
||||||
|
const pickingState = await import(`data:text/javascript;base64,${Buffer.from(stateSource).toString('base64')}`)
|
||||||
|
const noop = () => {}
|
||||||
|
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } }
|
||||||
|
function mount(overrides = {}, state = {}) {
|
||||||
|
const globals = { console, setTimeout, clearTimeout }
|
||||||
|
for (const match of script.matchAll(/import\s+(.*?)\s+from\s+['"][^'"]+['"]/g)) {
|
||||||
|
for (const name of match[1].replace(/[{}]/g, '').split(',').map(value => value.trim()).filter(Boolean)) globals[name] = noop
|
||||||
|
}
|
||||||
|
Object.assign(globals, { session: { token: 'offline', currentUser: { id: 'operator' } }, requireSession: () => true,
|
||||||
|
ITEM_STATUS: { PICKED: 'PICKED', PARTIAL: 'PARTIAL', SKIPPED: 'SKIPPED' },
|
||||||
|
isSealedPalletItem: () => false, isRemoteReady: () => true, stableOperationId: () => 'stable-operation',
|
||||||
|
stableTaskOperationIdentity: () => ({}), normalizePickingTask: value => value,
|
||||||
|
createItemStates: () => ({}), summarizeInventoryResources: () => ({}),
|
||||||
|
submitStates: () => ({ states: {}, autoSkippedIds: [] }), getPickSummary: () => ({}), ...overrides })
|
||||||
|
vm.createContext(globals)
|
||||||
|
vm.runInContext(script.replace(/^\s*import .*$/gm, '').replace('export default', 'globalThis.page ='), globals)
|
||||||
|
const page = globals.page
|
||||||
|
const ctx = { taskRequestId: 0, _pageVisible: true, evidenceContextId: 0, remoteTaskId: 'task',
|
||||||
|
currentBox: { taskId: 'task', warehouseId: 'warehouse', inboundId: 'inbound', loadingPlanId: 'plan', containerBookingId: 'container',
|
||||||
|
items: [{ id: 'item', taskItemId: 'item', itemStatus: 'PENDING', qty: 2, version: 0 }] },
|
||||||
|
evidenceFiles: {}, evidenceIdentity: null, sealNo: '', pickingVersion: 1,
|
||||||
|
summary: { pending: 1 }, itemStates: {}, partialQuantities: {}, completedBoxes: {},
|
||||||
|
selectedIncompleteReason: '现场未找到', submitting: false, loadingStageReady: false, pickingCompleted: false }
|
||||||
|
for (const [name, method] of Object.entries(page.methods)) ctx[name] = method.bind(ctx)
|
||||||
|
Object.assign(ctx, { rememberEvidenceDraft: noop, draftIdentity: () => ({}), resetEvidenceState: noop,
|
||||||
|
debugSubmit: noop, debugEvidence: noop, showToast: noop, persistSession: noop, clearTimers: noop,
|
||||||
|
stopHardwareScanner: noop, startHardwareScanner: noop, captureEvidenceIdentity: () => ({}),
|
||||||
|
restoreEvidenceDraft: noop, isSameEvidenceIdentity: () => false, restoreRemoteDraft: noop,
|
||||||
|
itemState: () => 'PENDING', saveCurrentBoxDraft: noop, openOverlay: noop,
|
||||||
|
isTaskConflict: () => false, remoteFailureMessage: () => 'failed', ...state })
|
||||||
|
return { page, ctx }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('real scan handlers display remaining 2→1→1→0, preserve pending semantics and avoid duplicate POST', async () => {
|
||||||
|
let posts = 0
|
||||||
|
const messages = []
|
||||||
|
const { page, ctx } = mount({ ...pickingState, pickContainerItem: async () => {
|
||||||
|
posts++
|
||||||
|
return { version: posts + 1, items: [{ id: 'item', plannedPackageCount: 2, pickedPackageCount: posts,
|
||||||
|
itemStatus: posts === 2 ? 'PICKED' : 'PICKING' }] }
|
||||||
|
} }, { scannedBoxCodes: {}, requirePickingOperation: () => true, recordScanResult: (...args) => messages.push(args) })
|
||||||
|
ctx.currentBox.items[0].boxCodes = ['BOX-1', 'BOX-2']
|
||||||
|
ctx.currentBox.items[0].plannedPackageCount = 2
|
||||||
|
ctx.currentBox.items[0].pickedPackageCount = 0
|
||||||
|
const remaining = () => page.computed.remainingCartons.call(ctx)
|
||||||
|
assert.equal(remaining(), 2)
|
||||||
|
await ctx.processScannedValue('BOX-1')
|
||||||
|
assert.equal(remaining(), 1)
|
||||||
|
assert.equal(page.computed.summary.call(ctx).pending, 0)
|
||||||
|
await ctx.processScannedValue('BOX-1')
|
||||||
|
assert.equal(remaining(), 1); assert.equal(posts, 1)
|
||||||
|
assert.equal(messages.at(-1)[3], '该条码已部分拣货,无需重复处理')
|
||||||
|
await ctx.processScannedValue('BOX-2')
|
||||||
|
assert.equal(remaining(), 0); assert.equal(posts, 2)
|
||||||
|
assert.match(source, /submit-pending-number">\{\{ remainingCartons \}\}/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('physical remaining distinguishes skipped and partial from business pending and trusts server counts', () => {
|
||||||
|
const items = [{ id: 'partial', qty: 4, pickedPackageCount: 1 }, { id: 'skip', qty: 3, pickedPackageCount: 0 }]
|
||||||
|
const states = { partial: pickingState.ITEM_STATUS.PARTIAL, skip: pickingState.ITEM_STATUS.SKIPPED }
|
||||||
|
const quantities = { partial: 1 }
|
||||||
|
const before = JSON.stringify({ items, states, quantities })
|
||||||
|
const summary = pickingState.getPickSummary(items, states, quantities)
|
||||||
|
assert.equal(summary.pending, 0); assert.equal(summary.partial, 1); assert.equal(summary.skipped, 3)
|
||||||
|
assert.equal(pickingState.remainingPhysicalCartons(items, states, quantities), 6)
|
||||||
|
assert.equal(pickingState.getPickSummary(items, states, quantities).canSubmit, summary.canSubmit)
|
||||||
|
assert.equal(JSON.stringify({ items, states, quantities }), before)
|
||||||
|
assert.equal(pickingState.remainingPhysicalCartons([{ id: 'partial', qty: 4, pickedPackageCount: 1 }],
|
||||||
|
{ partial: pickingState.ITEM_STATUS.PICKED }, { partial: 4 }), 3)
|
||||||
|
assert.equal(pickingState.remainingPhysicalCartons([{ id: 'partial', qty: 4 }], states, quantities), 3)
|
||||||
|
assert.equal(pickingState.remainingPhysicalCartons([{ id: 'skip', qty: 3 }], states, quantities), 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('double confirmation and direct reentry send exactly one snapshot and one batch', async () => {
|
||||||
|
const snapshot = deferred(), batch = deferred(); let snapshots = 0, batches = 0
|
||||||
|
const { ctx } = mount({ submitContainerPickingBatch: async () => { batches++; return batch.promise } }, {
|
||||||
|
refreshInventorySnapshot: async () => { snapshots++; return snapshot.promise },
|
||||||
|
syncRemoteTask: async function () { this.pickingCompleted = true },
|
||||||
|
})
|
||||||
|
const first = ctx.confirmIncompleteSubmit()
|
||||||
|
ctx.confirmIncompleteSubmit(); await ctx.performSubmit('现场未找到')
|
||||||
|
assert.equal(ctx.submitting, true); assert.equal(snapshots, 1); assert.equal(batches, 0)
|
||||||
|
snapshot.resolve({ snapshotToken: 'snapshot' }); await new Promise(setImmediate)
|
||||||
|
ctx.confirmIncompleteSubmit(); assert.equal(batches, 1)
|
||||||
|
batch.resolve(null); await first
|
||||||
|
assert.equal(snapshots, 1); assert.equal(batches, 1); assert.equal(ctx.submitting, false)
|
||||||
|
assert.match(source, /:disabled="submitting \|\| !selectedIncompleteReason"/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('failed request releases submit guard and retry keeps operation identity', async () => {
|
||||||
|
const keys = []; let snapshots = 0
|
||||||
|
const { ctx } = mount({ submitContainerPickingBatch: async (_task, _actions, options) => { keys.push(options.operationId); throw new Error('network') } }, {
|
||||||
|
refreshInventorySnapshot: async () => { snapshots++; return { snapshotToken: 'snapshot' } },
|
||||||
|
})
|
||||||
|
await ctx.confirmIncompleteSubmit(); assert.equal(ctx.submitting, false)
|
||||||
|
await ctx.confirmIncompleteSubmit()
|
||||||
|
assert.equal(snapshots, 2); assert.deepEqual(keys, ['stable-operation', 'stable-operation'])
|
||||||
|
})
|
||||||
|
|
||||||
|
const task = () => ({ taskId: 'task', recordStatus: 'ACTIVE', loadingPlanId: 'plan', containerBookingId: 'container',
|
||||||
|
currentContainerNo: 'TEST', version: 1, workflowStatus: 'UNASSIGNED', items: [{ id: 'item', packageCount: 2, itemStatus: 'PENDING' }] })
|
||||||
|
for (const hook of ['onHide', 'onUnload']) {
|
||||||
|
test(`${hook} while detail is pending prevents claim and evidence requests`, async () => {
|
||||||
|
const detail = deferred(); let claims = 0, evidence = 0
|
||||||
|
const { page, ctx } = mount({ getContainerPickingTask: () => detail.promise,
|
||||||
|
claimContainerPickingTask: async () => { claims++; return {} } }, { loadLoadingEvidence: async () => { evidence++ } })
|
||||||
|
const pending = ctx.syncRemoteTask(); page[hook].call(ctx); detail.resolve(task()); await pending
|
||||||
|
assert.equal(claims, 0); assert.equal(evidence, 0); assert.equal(ctx.remoteLoading, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('unload during already-sent claim prevents its response from loading evidence', async () => {
|
||||||
|
const claim = deferred(); let claims = 0, evidence = 0
|
||||||
|
const { page, ctx } = mount({ getContainerPickingTask: async () => task(),
|
||||||
|
claimContainerPickingTask: () => { claims++; return claim.promise } }, { loadLoadingEvidence: async () => { evidence++ } })
|
||||||
|
const pending = ctx.syncRemoteTask(); await new Promise(setImmediate)
|
||||||
|
assert.equal(claims, 1, ctx.remoteError)
|
||||||
|
page.onUnload.call(ctx); claim.resolve({ workflowStatus: 'IN_PROGRESS', version: 2 }); await pending
|
||||||
|
assert.equal(evidence, 0); assert.equal(ctx.remotePick, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unload during onShow list fetch prevents starting task sync afterward', async () => {
|
||||||
|
const list = deferred(); let syncs = 0
|
||||||
|
const { page, ctx } = mount({}, { _pageVisible: false, refreshAvailableBoxes: () => list.promise,
|
||||||
|
syncRemoteTask: async () => { syncs++ } })
|
||||||
|
const pending = page.onShow.call(ctx); page.onUnload.call(ctx); list.resolve(); await pending
|
||||||
|
assert.equal(syncs, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('old hidden onShow cannot clear a newer visible refresh or start another sync', async () => {
|
||||||
|
const oldList = deferred(), newList = deferred(); let lists = 0, syncs = 0
|
||||||
|
const { page, ctx } = mount({}, { _pageVisible: false,
|
||||||
|
refreshAvailableBoxes: () => (++lists === 1 ? oldList.promise : newList.promise),
|
||||||
|
syncRemoteTask: async () => { syncs++ } })
|
||||||
|
const oldShow = page.onShow.call(ctx)
|
||||||
|
page.onHide.call(ctx)
|
||||||
|
const newShow = page.onShow.call(ctx), currentPromise = ctx._showRefreshPromise
|
||||||
|
oldList.resolve(); await oldShow
|
||||||
|
assert.equal(syncs, 0); assert.equal(ctx._showRefreshPromise, currentPromise)
|
||||||
|
newList.resolve(); await newShow
|
||||||
|
assert.equal(syncs, 1); assert.equal(ctx._showRefreshPromise, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const mode of ['camera', 'preview']) {
|
||||||
|
test(`${mode} hide/return keeps existing exception and avoids refresh`, async () => {
|
||||||
|
let refreshes = 0
|
||||||
|
const { page, ctx } = mount({}, { cameraPicking: mode === 'camera', evidencePreviewOpening: mode === 'preview',
|
||||||
|
refreshAvailableBoxes: async () => { refreshes++ }, syncRemoteTask: async () => { refreshes++ } })
|
||||||
|
page.onHide.call(ctx); assert.equal(ctx.taskRequestId, 0)
|
||||||
|
await page.onShow.call(ctx); assert.equal(refreshes, 0); assert.equal(ctx._pageVisible, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -30,10 +30,6 @@ test('mutable real container number is not part of draft identity or restore val
|
|||||||
assert.doesNotMatch(restoreGuard, /containerNo|currentContainerNo|boxNo/)
|
assert.doesNotMatch(restoreGuard, /containerNo|currentContainerNo|boxNo/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('completion retry persists the exact accepted operation id and request version', () => {
|
test('completion commands are never persisted with ordinary picking drafts', () => {
|
||||||
assert.match(source, /pda-loading-completion/)
|
assert.doesNotMatch(source, /pda-loading-completion|saveLoadingCompletionAttempt|restoreLoadingCompletionAttempt/)
|
||||||
assert.match(source, /saveLoadingCompletionAttempt/)
|
|
||||||
assert.match(source, /restoreLoadingCompletionAttempt/)
|
|
||||||
assert.match(source, /operationId: String\(attempt\.operationId\)/)
|
|
||||||
assert.match(source, /expectedTaskVersion: Number\(attempt\.expectedTaskVersion\)/)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import { readFile } from 'node:fs/promises'
|
|||||||
const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
|
const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
|
||||||
|
|
||||||
test('saving a seal is single-flight and submits the pending photo through the atomic evidence command', () => {
|
test('saving a seal is single-flight and submits the pending photo through the atomic evidence command', () => {
|
||||||
assert.match(page, /:disabled="sealSaving \|\| !sealReadyToSave"/)
|
assert.match(page, /:disabled="evidenceReadOnly \|\| evidenceUploading \|\| sealSaving \|\| !sealReadyToSave"/)
|
||||||
assert.match(page, /sealSaving \? '上传中…' : '保存并上传'/)
|
assert.match(page, /sealSaving \? '上传中…' : '保存并上传'/)
|
||||||
assert.match(page, /async saveSealNo\(\) \{[\s\S]*?if \(this\.sealSaving\) return/)
|
assert.match(page, /async saveSealNo\(\) \{[\s\S]*?if \(this\.sealSaving\) return/)
|
||||||
assert.match(page, /find\(\(item\) => item\.localPath && !item\.id\)/)
|
assert.match(page, /find\(\(item\) => item\.localPath && !item\.id\)/)
|
||||||
assert.match(page, /await this\.queueEvidenceUpload\('SEAL', current\.localPath, String\(this\.sealNo\)\.trim\(\)\)/)
|
assert.match(page, /await this\.queueEvidenceUpload\('SEAL', current\.localPath, String\(this\.sealNo\)\.trim\(\)\)/)
|
||||||
assert.match(page, /stableOperationId\('evidence-upload',[\s\S]*?evidenceType: type,[\s\S]*?expectedVersion,[\s\S]*?localPath: path,[\s\S]*?sealNo: sealNo \|\| ''/)
|
assert.match(page, /operationId: `evidence-\$\{Date.now\(\)\}/)
|
||||||
assert.doesNotMatch(page, /seal-no-[^\n]*Date\.now\(\)/)
|
assert.doesNotMatch(page, /seal-no-[^\n]*Date\.now\(\)/)
|
||||||
assert.match(page, /this\.sealSaving = true[\s\S]*?finally \{[\s\S]*?this\.sealSaving = false/)
|
assert.match(page, /this\.sealSaving = true[\s\S]*?finally \{[\s\S]*?this\.sealSaving = false/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import { decodeAccessToken } from '../utils/jwt-claims.js'
|
||||||
|
const source = path => fs.readFileSync(new URL(path, import.meta.url), 'utf8')
|
||||||
|
const plain = text => text.replace(/^\s*(?:import|export \{)[^\n]*$/gm, '').replace(/export (?=function|const)/g, '')
|
||||||
|
const pair = (version='old', identity={}) => ({ accessToken: `header.${Buffer.from(JSON.stringify({ user_id:'42', membership_id:'84', tenant_id:'0', app_code:'PLATFORM', domestic_warehouse_id:'900', exp:Math.floor(Date.now()/1000)+3600, version, ...identity })).toString('base64url')}.signature`, refreshToken:`refresh-${version}` })
|
||||||
|
const ok = data => ({statusCode:200,data:{code:'200',data},header:{}})
|
||||||
|
const denied = (statusCode=401,code='A0401') => ({statusCode,data:{code,message:'denied',requestId:'req',traceId:'trace'},header:{}})
|
||||||
|
function harness() {
|
||||||
|
const values=new Map(),calls=[],launches=[],toasts=[];let drafts={owner:'old'}
|
||||||
|
globalThis.uni={setStorageSync:(k,v)=>values.set(k,structuredClone(v)),getStorageSync:k=>values.get(k),removeStorageSync:k=>values.delete(k),reLaunch:o=>{launches.push(o.url);o.complete?.()},showToast:o=>toasts.push(o.title),request:o=>calls.push(o)}
|
||||||
|
const s=new Function('reactive','resetPickingSession','PLATFORM_API_ROOT','decodeAccessToken',plain(source('../stores/session.js'))+';return {session,signInWithTokens,signOut,updateTokenPair,isAccessTokenExpiring,requireSession,getSessionRevision:typeof getSessionRevision === "function" ? getSessionRevision : () => 0}')(x=>x,()=>{drafts=null},'/api/platform/v1',decodeAccessToken)
|
||||||
|
const auth=new Function('API_ROOT',plain(source('../api/auth.js'))+';return {logout,refresh,getPendingRefresh:typeof getPendingRefresh === "function" ? getPendingRefresh : () => null}')('/api')
|
||||||
|
const path=new URL('../api/logout-session.js',import.meta.url)
|
||||||
|
const helper=fs.existsSync(path)?new Function('logout','refresh','getPendingRefresh','session','signOut','getSessionRevision','decodeAccessToken',plain(fs.readFileSync(path,'utf8'))+';return {logoutCurrentSession}')(auth.logout,auth.refresh,auth.getPendingRefresh,s.session,s.signOut,s.getSessionRevision,decodeAccessToken):{}
|
||||||
|
const script=source('../pages/profile/profile.vue').match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^\s*import[^\n]*$/gm,'').replace('export default','return')
|
||||||
|
const page=new Function('logoutRequest','PortalHeader','PopupHeader','session','requireSession','signOut','checkForAppUpdate','logoutCurrentSession','getSessionRevision',script)(auth.logout,{},{},s.session,s.requireSession,s.signOut,()=>{},helper.logoutCurrentSession,s.getSessionRevision)
|
||||||
|
const original=pair();s.signInWithTokens(original)
|
||||||
|
const http=new Function('refreshRequest','isAccessTokenExpiring','session','signOut','updateTokenPair','requireOnline','getSessionRevision',plain(source('../api/http.js'))+';return {apiRequest}')(auth.refresh,s.isAccessTokenExpiring,s.session,s.signOut,s.updateTokenPair,()=>{},s.getSessionRevision)
|
||||||
|
return {...s,auth,helper,http,page,context:{user:s.session.currentUser,closeLogout(){}},original,values,calls,launches,toasts,get drafts(){return drafts}}
|
||||||
|
}
|
||||||
|
const tick=()=>new Promise(r=>setImmediate(r))
|
||||||
|
test('profile waits for revoke before navigating and clears drafts immediately',async()=>{
|
||||||
|
const h=harness(),pending=h.page.methods.logout.call(h.context)
|
||||||
|
assert.equal(h.calls.length,1);assert.equal(h.session.token,'');assert.equal(h.drafts,null);assert.equal(h.launches.length,0)
|
||||||
|
h.calls[0].success(ok(null));await pending;assert.deepEqual(h.launches,['/pages/login/login']);assert.equal(h.values.has('pda-auth-session'),false)
|
||||||
|
})
|
||||||
|
test('duplicate confirmation shares one pending revoke',async()=>{
|
||||||
|
const h=harness(),a=h.page.methods.logout.call(h.context),b=h.page.methods.logout.call(h.context)
|
||||||
|
assert.equal(h.calls.length,1);assert.ok(a && typeof a.then === 'function');assert.equal(a,b);h.calls[0].success(ok(null));await a
|
||||||
|
})
|
||||||
|
test('expired access 401 rotates once and revokes new pair without restoring local login',async()=>{
|
||||||
|
const h=harness(),p=h.helper.logoutCurrentSession();h.calls[0].success(denied());await tick()
|
||||||
|
assert.match(h.calls[1].url,/\/auth\/refresh$/);const rotated=pair('rotated');h.calls[1].success(ok(rotated));await tick()
|
||||||
|
assert.deepEqual(h.calls[2].data,{refreshToken:rotated.refreshToken});assert.equal(h.calls[2].header.Authorization,`Bearer ${rotated.accessToken}`)
|
||||||
|
h.calls[2].success(ok(null));assert.equal((await p).revoked,true);assert.equal(h.session.token,'');assert.equal(h.calls.length,3)
|
||||||
|
})
|
||||||
|
test('network unknown, 5xx and business rejection do not trigger blind retries',async()=>{
|
||||||
|
for(const kind of ['network','server','business']) {
|
||||||
|
const h=harness(),p=h.helper.logoutCurrentSession();const rejection=assert.rejects(p)
|
||||||
|
if(kind==='network')h.calls[0].fail({errMsg:'lost'});else h.calls[0].success(denied(kind==='server'?503:200,kind==='server'?'S0503':'A0401'))
|
||||||
|
await rejection;assert.equal(h.calls.length,1);assert.equal(h.session.token,'');assert.equal(h.drafts,null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
test('failed profile logout warns instead of reporting confirmed revocation',async()=>{
|
||||||
|
const h=harness(),p=h.page.methods.logout.call(h.context);h.calls[0].fail({errMsg:'lost'});const result=await p
|
||||||
|
assert.equal(result.revoked,false);assert.deepEqual(h.toasts,['已清除本地登录,服务端退出未确认']);assert.equal(h.session.refreshToken,'')
|
||||||
|
})
|
||||||
|
test('identity mismatch or malformed rotated credentials cannot be used for another revoke',async()=>{
|
||||||
|
for(const change of [{user_id:'43'},{membership_id:'85'},{domestic_warehouse_id:'901'},{exp:1}]) {
|
||||||
|
const h=harness(),p=h.helper.logoutCurrentSession(),rejection=assert.rejects(p);h.calls[0].success(denied());await tick()
|
||||||
|
h.calls[1].success(ok(pair('wrong',change)));await rejection;assert.equal(h.calls.length,2);assert.equal(h.session.token,'')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
test('second 401 is terminal with diagnostic evidence and no refresh loop',async()=>{
|
||||||
|
const h=harness(),p=h.helper.logoutCurrentSession(),rejection=assert.rejects(p,e=>e.statusCode===401 && e.requestId==='req' && e.traceId==='trace')
|
||||||
|
h.calls[0].success(denied());await tick();h.calls[1].success(ok(pair('rotated')));await tick();h.calls[2].success(denied());await rejection;assert.equal(h.calls.length,3)
|
||||||
|
})
|
||||||
|
test('logout joins in-flight automatic refresh, revokes its result, and never revives cleared session',async()=>{
|
||||||
|
const h=harness();h.session.token=pair('expired',{exp:1}).accessToken
|
||||||
|
const business=h.http.apiRequest({url:'/work'}).catch(e=>e);await tick();assert.equal(h.calls.length,1)
|
||||||
|
const exit=h.helper.logoutCurrentSession();const newer=pair('new',{user_id:'99',membership_id:'98',domestic_warehouse_id:'950'});h.signInWithTokens(newer)
|
||||||
|
const rotated=pair('rotated');h.calls[0].success(ok(rotated));await tick()
|
||||||
|
assert.equal(h.calls.length,2);assert.match(h.calls[1].url,/\/auth\/logout$/);assert.deepEqual(h.calls[1].data,{refreshToken:rotated.refreshToken})
|
||||||
|
h.calls[1].success(ok(null));await exit;assert.match((await business).message,/会话已变更/);assert.equal(h.session.token,newer.accessToken)
|
||||||
|
})
|
||||||
|
test('old logout success and failure cannot redirect or warn over a later new login',async()=>{
|
||||||
|
for(const fail of [false,true]) {
|
||||||
|
const h=harness(),p=h.page.methods.logout.call(h.context),newer=pair('new',{user_id:'99'});h.signInWithTokens(newer)
|
||||||
|
if(fail)h.calls[0].fail({errMsg:'lost'});else h.calls[0].success(ok(null));await p
|
||||||
|
assert.equal(h.session.token,newer.accessToken);assert.equal(h.launches.length,0);assert.equal(h.toasts.length,0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
test('unknown pending refresh is not repeated and its failure remains observable',async()=>{
|
||||||
|
const h=harness(),refresh=h.auth.refresh(h.original.refreshToken).catch(e=>e),exit=h.helper.logoutCurrentSession(),rejected=assert.rejects(exit,e=>e.network===true)
|
||||||
|
h.calls[0].fail({errMsg:'lost refresh'});await rejected;await refresh;assert.equal(h.calls.length,1);assert.equal(h.session.token,'')
|
||||||
|
})
|
||||||
|
test('stale business 401 after logout cannot refresh or clear a newer login',async()=>{
|
||||||
|
const h=harness(),business=h.http.apiRequest({url:'/work'}).catch(e=>e);await tick();assert.equal(h.calls.length,1)
|
||||||
|
const exit=h.helper.logoutCurrentSession(),newer=pair('new',{user_id:'99'});h.signInWithTokens(newer)
|
||||||
|
h.calls[0].success(denied());h.calls[1].success(ok(null));await exit
|
||||||
|
assert.match((await business).message,/会话已变更/);assert.equal(h.calls.length,2);assert.equal(h.session.token,newer.accessToken)
|
||||||
|
})
|
||||||
|
test('concurrent automatic requests share one refresh and are rejected after logout without business replay',async()=>{
|
||||||
|
const h=harness();h.session.token=pair('expired',{exp:1}).accessToken
|
||||||
|
const a=h.http.apiRequest({url:'/one'}).catch(e=>e),b=h.http.apiRequest({url:'/two'}).catch(e=>e);await tick();assert.equal(h.calls.length,1)
|
||||||
|
const exit=h.helper.logoutCurrentSession();h.calls[0].success(ok(pair('rotated')));await tick();assert.equal(h.calls.length,2)
|
||||||
|
h.calls[1].success(ok(null));await exit
|
||||||
|
assert.match((await a).message,/会话已变更/);assert.match((await b).message,/会话已变更/);assert.equal(h.session.token,'')
|
||||||
|
})
|
||||||
|
test('missing rotated token fields fail closed and never report a revoked session',async()=>{
|
||||||
|
for(const field of ['accessToken','refreshToken']) {
|
||||||
|
const h=harness(),exit=h.helper.logoutCurrentSession(),rejected=assert.rejects(exit);h.calls[0].success(denied());await tick()
|
||||||
|
const bad=pair('bad');delete bad[field];h.calls[1].success(ok(bad));await rejected;assert.equal(h.calls.length,2);assert.equal(h.session.token,'')
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
/**
|
|
||||||
* Small, observable upload queue for PDA evidence photos.
|
|
||||||
* The queue deliberately keeps local paths only for the active session; callers
|
|
||||||
* decide when it is safe to persist/clear them (see PDA session policy).
|
|
||||||
*/
|
|
||||||
export function createEvidenceUploadQueue({ upload, onChange } = {}) {
|
|
||||||
const entries = []
|
|
||||||
const notify = () => onChange?.(entries.map((item) => ({ ...item })))
|
|
||||||
const snapshot = () => entries.map((item) => ({ ...item }))
|
|
||||||
const add = (payload) => {
|
|
||||||
if (!payload?.evidenceType || !payload?.localPath) throw new Error('缺少证据类型或本地照片')
|
|
||||||
// Evidence is append-only by type. Incorrect or duplicate photos are
|
|
||||||
// explicitly voided, preserving the local audit trail instead of replacing.
|
|
||||||
const entry = { id: payload.id || `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, ...payload, status: 'PENDING', attempts: 0, progress: 0, error: '' }
|
|
||||||
entries.push(entry); notify(); return entry
|
|
||||||
}
|
|
||||||
const cancel = (id) => { const item = entries.find((entry) => entry.id === id); if (!item || item.status === 'UPLOADING') return false; item.status = 'CANCELLED'; notify(); return true }
|
|
||||||
const voidEntry = (id, reason, actor) => {
|
|
||||||
const item = entries.find((entry) => entry.id === id)
|
|
||||||
if (!item || item.status === 'UPLOADING' || item.status === 'VOIDED') return false
|
|
||||||
if (!String(reason || '').trim()) throw new Error('作废原因不能为空')
|
|
||||||
item.status = 'VOIDED'; item.voidReason = String(reason).trim(); item.voidedBy = actor || null; item.voidedAt = new Date().toISOString(); notify(); return true
|
|
||||||
}
|
|
||||||
const retry = (id) => { const item = entries.find((entry) => entry.id === id); if (!item || !['FAILED', 'CANCELLED'].includes(item.status)) return false; item.status = 'PENDING'; item.error = ''; notify(); return true }
|
|
||||||
async function flush() {
|
|
||||||
if (typeof upload !== 'function') throw new Error('未配置文件上传器')
|
|
||||||
for (const item of entries) {
|
|
||||||
if (item.status !== 'PENDING') continue
|
|
||||||
item.status = 'UPLOADING'; item.attempts += 1; item.error = ''; notify()
|
|
||||||
try {
|
|
||||||
const result = await upload(item, (progress) => { item.progress = progress; notify() })
|
|
||||||
item.status = 'SUCCEEDED'; item.progress = 100; item.result = result; notify()
|
|
||||||
} catch (error) {
|
|
||||||
item.status = 'FAILED'; item.error = error?.message || '上传失败'; item.network = Boolean(error?.network); notify()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return snapshot()
|
|
||||||
}
|
|
||||||
const latest = (evidenceType) => [...entries].reverse().find((item) => item.evidenceType === evidenceType && item.status !== 'VOIDED') || null
|
|
||||||
const active = (evidenceType) => entries.filter((item) => item.evidenceType === evidenceType && item.status === 'SUCCEEDED')
|
|
||||||
return { add, cancel, voidEntry, retry, flush, latest, active, snapshot }
|
|
||||||
}
|
|
||||||
@@ -46,6 +46,27 @@ export function shouldReconcileCompletion(error = {}) {
|
|||||||
return Boolean(error.network || error.timeout || code === 'TIMEOUT' || code === 'REQUEST_TIMEOUT')
|
return Boolean(error.network || error.timeout || code === 'TIMEOUT' || code === 'REQUEST_TIMEOUT')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 受理后立即查询真实任务,串行短轮询只等待终态,不重发完成命令。 */
|
||||||
|
export async function observeLoadingCompletion({ initialTask, readTask, isActive, onTask,
|
||||||
|
intervalMs = 250, maxWaitMs = 10000, now = Date.now,
|
||||||
|
wait = (delay) => new Promise((resolve) => setTimeout(resolve, delay)) }) {
|
||||||
|
const started = now()
|
||||||
|
let task = initialTask
|
||||||
|
const phase = () => resolveCompletionPhase({ taskStatus: task?.taskStatus || task?.workflowStatus,
|
||||||
|
commandStatus: task?.completionCommand?.status || task?.commandStatus })
|
||||||
|
while (phase() === COMPLETION_PHASE.PROCESSING && isActive() && now() - started < maxWaitMs) {
|
||||||
|
const latest = await readTask()
|
||||||
|
if (!isActive()) return null
|
||||||
|
if (!latest) throw new Error('服务端未返回装柜完成状态,请刷新任务')
|
||||||
|
task = latest
|
||||||
|
onTask(task)
|
||||||
|
if (phase() !== COMPLETION_PHASE.PROCESSING) break
|
||||||
|
const remaining = maxWaitMs - (now() - started)
|
||||||
|
if (remaining > 0) await wait(Math.min(intervalMs, remaining))
|
||||||
|
}
|
||||||
|
return isActive() ? task : null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A retry keeps the original operationId and the exact accepted request facts.
|
* A retry keeps the original operationId and the exact accepted request facts.
|
||||||
* expectedTaskVersion participates in requestHash and must not be refreshed.
|
* expectedTaskVersion participates in requestHash and must not be refreshed.
|
||||||
|
|||||||
@@ -9,6 +9,20 @@ export function isSealedPalletItem(item) {
|
|||||||
return item?.unitType === 'SEALED_PALLET'
|
return item?.unitType === 'SEALED_PALLET'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Physical cartons still absent from the picked count, independent of whether
|
||||||
|
// the operator has handled the line as partial or skipped.
|
||||||
|
export function remainingPhysicalCartons(items, states = {}, partialQuantities = {}) {
|
||||||
|
const count = (value) => Number.isFinite(Number(value)) ? Math.max(0, Number(value)) : 0
|
||||||
|
return items.reduce((total, item) => {
|
||||||
|
const planned = count(item.plannedPackageCount ?? item.qty)
|
||||||
|
const hasServerCount = item.pickedPackageCount !== null && item.pickedPackageCount !== undefined && item.pickedPackageCount !== ''
|
||||||
|
const picked = hasServerCount ? count(item.pickedPackageCount)
|
||||||
|
: states[item.id] === ITEM_STATUS.PICKED ? planned
|
||||||
|
: states[item.id] === ITEM_STATUS.PARTIAL ? count(partialQuantities[item.id]) : 0
|
||||||
|
return total + Math.max(0, planned - picked)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
export function summarizeInventoryResources(resources) {
|
export function summarizeInventoryResources(resources) {
|
||||||
const list = Array.isArray(resources) ? resources : []
|
const list = Array.isArray(resources) ? resources : []
|
||||||
const locationCodes = [...new Set(list
|
const locationCodes = [...new Set(list
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
const proxyTarget = env.API_PROXY_TARGET || 'http://127.0.0.1:9000'
|
const proxyTarget = env.API_PROXY_TARGET || 'http://127.0.0.1:9000'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
base: process.env.UNI_PLATFORM === 'h5' && mode === 'production' ? '/pda-domestic/' : undefined,
|
base: process.env.UNI_PLATFORM === 'h5' && mode === 'production' ? '/' : undefined,
|
||||||
plugins: [uni()],
|
plugins: [uni()],
|
||||||
define: {
|
define: {
|
||||||
'import.meta.env.VITE_PDA_UPDATE_URL': JSON.stringify(env.VITE_PDA_UPDATE_URL || '')
|
'import.meta.env.VITE_PDA_UPDATE_URL': JSON.stringify(env.VITE_PDA_UPDATE_URL || '')
|
||||||
|
|||||||
Reference in New Issue
Block a user