Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c33299adba | ||
|
|
500103e734 | ||
|
|
1acd33347a | ||
|
|
63fac79814 | ||
|
|
e341551190 | ||
|
|
cfd4fb06c4 | ||
|
|
f39303ea24 | ||
|
|
b663aac4c7 | ||
|
|
1aae3b8135 | ||
|
|
a84f263452 | ||
|
|
fb61ada3ea |
@@ -1,13 +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/**
|
|
||||||
|
|
||||||
!deploy/Dockerfile.h5.production
|
|
||||||
!deploy/nginx.production.conf.template
|
|
||||||
@@ -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`,不实现离线业务提交队列;恢复网络后提示员工刷新重试。
|
||||||
|
|||||||
@@ -109,32 +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 与灰度)
|
||||||
|
|
||||||
发布构建使用 `/` 资源 base 与 hash 路由,部署到本应用独立域名根目录,浏览器入口为 `/#/`。开发服务仍使用根路径;不能将 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/`。
|
国内使用本机已安装 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 子路径。缺失资源返回 404,不回退首页,HTML 使用 `Cache-Control: no-store`。域名入口还须配置同源 API 代理:两端均将 `/gateway-api/` 转统一 Gateway `/api/`。入口须保留 Authorization,支持文件上传下载及至少 11m 请求体。域名与上游只在部署侧配置,不编入 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 配置
|
### 两端统一 API 配置
|
||||||
|
|
||||||
两端的 `VITE_API_BASE_URL` 均为统一 Gateway 绝对地址(含 `/api`);`VITE_H5_API_BASE_URL=/gateway-api`;`API_PROXY_TARGET` 为同一 Gateway 源地址(不含 `/api`)。实际地址只放在未提交的 `.env.local`;修改后重启开发服务,发布前重新打包。
|
App 的 `VITE_API_BASE_URL` 为统一 Gateway 绝对地址(含 `/api`);H5 使用 `VITE_H5_API_BASE_URL=/gateway-api`。`API_PROXY_TARGET` 仅供本地 Vite 开发代理使用,不随静态包发布。实际地址放在未提交环境文件;变更编译变量后须重新构建。
|
||||||
|
|
||||||
两端独立域名入口均配置以下规则;`gateway:9000` 仅适用于同一 Docker 网络,其他部署须替换成实际统一 Gateway 上游。静态容器只提供页面文件,Vite 代理不会随静态包发布。
|
站点 API 规则须使用 `location ^~ /gateway-api/`,防止驾驶证等包含 `LICENSE` 的业务路径被站点敏感文件正则截获。仅更新静态包不能修复代理配置;域名入口和每层代理均须核对。
|
||||||
|
|
||||||
```nginx
|
部署定向检查:`node --test tests/h5-deployment.test.mjs`,经 `local-check.ps1` 执行。该检查覆盖静态发布约束、资源 base 和开发代理重写,不替代目标主机 Nginx 校验、真实 HTTP、人工页面及 Android 真机验收。
|
||||||
location ^~ /gateway-api/ {
|
|
||||||
proxy_pass http://gateway:9000/api/;
|
|
||||||
proxy_set_header Authorization $http_authorization;
|
|
||||||
client_max_body_size 11m;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -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/
|
|
||||||
EXPOSE 80
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
ARG NGINX_BASE_IMAGE=nginx:1.27-alpine
|
|
||||||
FROM ${NGINX_BASE_IMAGE}
|
|
||||||
COPY deploy/nginx.production.conf.template /etc/nginx/templates/default.conf.template
|
|
||||||
COPY unpackage/dist/build/web/ /srv/pda-h5/
|
|
||||||
EXPOSE 80
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
name: firstleg-production-pda-domestic-h5
|
|
||||||
services:
|
|
||||||
pda-domestic-h5:
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: deploy/Dockerfile.h5.production
|
|
||||||
args:
|
|
||||||
NGINX_BASE_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
|
|
||||||
image: pda-domestic-h5-prod:${IMAGE_TAG:?IMAGE_TAG is required}
|
|
||||||
container_name: pda-domestic-h5-prod
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
API_UPSTREAM: "http://gateway:9000"
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:${HOST_PORT:-29005}:80"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "wget -qO /dev/null http://127.0.0.1/healthz && wget -qO /dev/null http://127.0.0.1/"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 12
|
|
||||||
start_period: 5s
|
|
||||||
networks:
|
|
||||||
backend:
|
|
||||||
aliases:
|
|
||||||
- pda-domestic-h5-prod
|
|
||||||
networks:
|
|
||||||
backend:
|
|
||||||
external: true
|
|
||||||
name: firstleg-production-network
|
|
||||||
@@ -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/"]
|
|
||||||
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,16 +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 / {
|
|
||||||
index index.html;
|
|
||||||
try_files $uri $uri/ =404;
|
|
||||||
add_header Cache-Control "no-store" always;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,25 +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 /gateway-api/ {
|
|
||||||
client_max_body_size 55m;
|
|
||||||
proxy_pass ${API_UPSTREAM}/api/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $proxy_host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
location / {
|
|
||||||
index index.html;
|
|
||||||
try_files $uri $uri/ =404;
|
|
||||||
add_header Cache-Control "no-store" always;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||||
import { bindDropshipPendingCartons, claimDropshipPalletizationTask, countDropshipCarton, getDropshipPalletizationTask, moveDropshipCarton, resolveDropshipWorkspaceCarton, resolveDropshipWorkspacePallet, 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'
|
||||||
@@ -119,6 +119,10 @@
|
|||||||
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.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() } },
|
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() } },
|
||||||
@@ -127,7 +131,7 @@
|
|||||||
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); await this.refreshPallet({ restorePallet }); 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), '任务领取成功') },
|
||||||
@@ -137,7 +141,7 @@
|
|||||||
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 { 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 || '' })) }
|
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() {
|
||||||
@@ -167,7 +171,7 @@
|
|||||||
this.palletCode = code
|
this.palletCode = code
|
||||||
this.loading = true; this.resetMessages(); this.workspace = null
|
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 }) }
|
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 = formatApiError(error, '托盘校验失败'); return }
|
catch (error) { this.selectedPalletNo = ''; this.error = this.formatOperationError(error, '托盘校验失败'); return }
|
||||||
finally { this.loading = false }
|
finally { this.loading = false }
|
||||||
const pallet = this.currentPallet
|
const pallet = this.currentPallet
|
||||||
if (pallet?.status === 'SEALED') { this.notice = `托盘 ${code} 已封托,可在托盘操作区解除封托`; return }
|
if (pallet?.status === 'SEALED') { this.notice = `托盘 ${code} 已封托,可在托盘操作区解除封托`; return }
|
||||||
@@ -235,10 +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 = formatApiError(error, '操作已成功,托盘汇总刷新失败,请刷新后继续') }
|
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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,7 +266,7 @@
|
|||||||
.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; }
|
.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 { display: flex; align-items: center; justify-content: center; padding: 0; line-height: 46px; height: 46px; margin: 0; border: 0; border-radius: 9px; color: #fff; font-size: 14px; font-weight: 700; background: #d92d20; }.danger-button[disabled] { opacity: .45; }
|
||||||
.count-entry { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 6px; padding: 5px 7px; border: 0; border-radius: 0; color: #667085; font-size: 13px; background: #f6f9ff; }.compact-action { width: 86px; height: 34px; margin: 0; border: 0; border-radius: 6px; color: #fff; font-size: 13px; line-height: 34px; background: #1a73e8; }.compact-action[disabled] { opacity: .35; }.count-unavailable { padding: 12px 8px; color: #b42318; font-size: 13px; line-height: 18px; text-align: center; background: #fff1f0; }.pallet-scan-block { margin-top: 8px; padding-top: 8px; border-top: 1px solid #e6edf5; }.field-label { display: block; margin-bottom: 5px; color: #344054; font-size: 15px; font-weight: 700; }.field-label text { margin-left: 3px; color: #98a2b3; font-size: 12px; font-weight: 400; }
|
.count-entry { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 6px; padding: 5px 7px; border: 0; border-radius: 0; color: #667085; font-size: 13px; background: #f6f9ff; }.compact-action { width: 86px; height: 34px; margin: 0; border: 0; border-radius: 6px; color: #fff; font-size: 13px; line-height: 34px; background: #1a73e8; }.compact-action[disabled] { opacity: .35; }.count-unavailable { padding: 12px 8px; color: #b42318; font-size: 13px; line-height: 18px; text-align: center; background: #fff1f0; }.pallet-scan-block { margin-top: 8px; padding-top: 8px; border-top: 1px solid #e6edf5; }.field-label { display: block; margin-bottom: 5px; color: #344054; font-size: 15px; font-weight: 700; }.field-label text { margin-left: 3px; color: #98a2b3; font-size: 12px; font-weight: 400; }
|
||||||
.carton-card, .pallet-card { margin-top: 8px; padding: 8px 0 0; border: 0; border-top: 1px solid #e3eaf2; border-radius: 0; background: transparent; }.sku-count-row { display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid #edf1f5; }.sku-copy { min-width: 0; }.sku-label { display: block; color: #667085; font-size: 11px; }.sku-code { display: block; margin-top: 2px; color: #12233f; font-size: 16px; font-weight: 700; overflow-wrap: anywhere; }.pallet-meta { display: block; margin-top: 5px; color: #667085; font-size: 14px; line-height: 20px; }
|
.carton-card, .pallet-card { margin-top: 8px; padding: 8px 0 0; border: 0; border-top: 1px solid #e3eaf2; border-radius: 0; background: transparent; }.sku-count-row { display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid #edf1f5; }.sku-copy { min-width: 0; }.sku-label { display: block; color: #667085; font-size: 11px; }.sku-code { display: block; margin-top: 2px; color: #12233f; font-size: 16px; font-weight: 700; overflow-wrap: anywhere; }.pallet-meta { display: block; margin-top: 5px; color: #667085; font-size: 14px; line-height: 20px; }
|
||||||
.quantity-stepper { display: grid; grid-template-columns: 38px 56px 38px; overflow: hidden; border: 1px solid #b8c6d8; border-radius: 8px; background: #fff; }.quantity-stepper button,.quantity-stepper input { box-sizing: border-box; height: 40px; margin: 0; border: 0; border-radius: 0; text-align: center; background: #fff; }.quantity-stepper button { color: #155eef; font-size: 20px; line-height: 40px; }.quantity-stepper input { width: 56px; border-right: 1px solid #dce4ed; border-left: 1px solid #dce4ed; color: #12233f; font-size: 15px; }.difference-field { grid-column: 1 / -1; margin-top: 0; }.pallet-hint { display: block; margin-top: 10px; color: #155eef; font-size: 11px; text-align: center; }
|
.quantity-stepper { display: grid; grid-template-columns: 38px 56px 38px; overflow: hidden; border: 1px solid #b8c6d8; border-radius: 8px; background: #fff; }.quantity-stepper button,.quantity-stepper input { box-sizing: border-box; height: 40px; margin: 0; border: 0; border-radius: 0; text-align: center; background: #fff; }.quantity-stepper button { color: #155eef; font-size: 20px; line-height: 40px; }.quantity-stepper input { width: 56px; border-right: 1px solid #dce4ed; border-left: 1px solid #dce4ed; color: #12233f; font-size: 15px; }.difference-field { grid-column: 1 / -1; margin-top: 0; }.pallet-hint { display: block; margin-top: 10px; color: #155eef; font-size: 11px; text-align: center; }
|
||||||
|
|||||||
@@ -34,16 +34,22 @@
|
|||||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||||
import { listDropshipPalletizationTasks, resolveDropshipWorkspacePallet } 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 { startScanBroadcast } from '@/utils/scan-broadcast.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: '', palletNo: '' } },
|
data() { return { user: session.currentUser, keyword: '', tasks: [], loading: false, error: '', palletNo: '' } },
|
||||||
onLoad(options) { this.palletNo = String(options.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.startScanner(); this.loadTasks() },
|
||||||
|
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
|
||||||
methods: {
|
methods: {
|
||||||
|
// 入仓码广播直接驱动查询,不依赖输入框焦点。
|
||||||
|
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: value => { if (this.loading) return; this.keyword = String(value).trim(); this.loadTasks() }, onError: () => { this.error = '扫码头连接失败,可输入入仓码查询' } }) },
|
||||||
|
stopScanner() { this._stopScanner?.(); this._stopScanner = null },
|
||||||
statusLabel(status) { return status === 'CLAIMED' ? '作业中' : '待领取' },
|
statusLabel(status) { return status === 'CLAIMED' ? '作业中' : '待领取' },
|
||||||
async loadTasks() {
|
async loadTasks(value) {
|
||||||
|
if (typeof value === 'string') this.keyword = value.trim()
|
||||||
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 = ''
|
||||||
|
|||||||
@@ -693,7 +693,8 @@
|
|||||||
return String(error?.code || '') === 'A0409' || Number(error?.statusCode) === 409
|
return String(error?.code || '') === 'A0409' || Number(error?.statusCode) === 409
|
||||||
},
|
},
|
||||||
remoteFailureMessage(error, fallback = '操作失败') {
|
remoteFailureMessage(error, fallback = '操作失败') {
|
||||||
return formatApiError(error, fallback)
|
console.error('[PDA picking] failed', { message: error?.message, code: error?.code, requestId: error?.requestId, traceId: error?.traceId })
|
||||||
|
return formatApiError(error, fallback, false)
|
||||||
},
|
},
|
||||||
async refreshAfterTaskConflict(message) {
|
async refreshAfterTaskConflict(message) {
|
||||||
const requestedTaskId = this.remoteTaskId || this.currentBox.taskId
|
const requestedTaskId = this.remoteTaskId || this.currentBox.taskId
|
||||||
@@ -921,8 +922,13 @@
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
persistSession() {
|
persistSession() {
|
||||||
|
const identity = this.draftIdentity()
|
||||||
|
const completed = this.remotePick?.taskStatus === 'COMPLETED'
|
||||||
|
if (completed) this.completedBoxes = { ...this.completedBoxes, [this.currentBoxIndex]: true }
|
||||||
this.saveCurrentBoxDraft()
|
this.saveCurrentBoxDraft()
|
||||||
savePickingSession({ boxes: this.boxes, currentBoxIndex: this.currentBoxIndex, itemStates: this.itemStates, partialQuantities: this.partialQuantities, scannedBoxCodes: this.scannedBoxCodes, boxDrafts: this.boxDrafts, completedBoxes: this.completedBoxes, scanValue: this.scanValue, scanHistory: this.scanHistory }, this.draftIdentity())
|
// 完成任务仍更新作业记录的内存快照,空身份阻止重新写入其持久化草稿。
|
||||||
|
savePickingSession({ boxes: this.boxes, currentBoxIndex: this.currentBoxIndex, itemStates: this.itemStates, partialQuantities: this.partialQuantities, scannedBoxCodes: this.scannedBoxCodes, boxDrafts: this.boxDrafts, completedBoxes: this.completedBoxes, scanValue: this.scanValue, scanHistory: this.scanHistory }, completed ? {} : identity)
|
||||||
|
if (completed) clearPickingDraft(identity)
|
||||||
},
|
},
|
||||||
leavePicking() { this.persistSession(); this.activeOverlay = ''; navigateBackOr('/pages/home/home') },
|
leavePicking() { this.persistSession(); this.activeOverlay = ''; navigateBackOr('/pages/home/home') },
|
||||||
clearTimers() { clearTimeout(this._toastTimer); clearTimeout(this._evidencePreviewOpenTimer) },
|
clearTimers() { clearTimeout(this._toastTimer); clearTimeout(this._evidencePreviewOpenTimer) },
|
||||||
@@ -1185,6 +1191,16 @@
|
|||||||
this.currentBox.boxNo = task.currentContainerNo
|
this.currentBox.boxNo = task.currentContainerNo
|
||||||
this.currentBox.currentContainerNo = task.currentContainerNo
|
this.currentBox.currentContainerNo = task.currentContainerNo
|
||||||
const remoteTask = normalizePickingTask(task)
|
const remoteTask = normalizePickingTask(task)
|
||||||
|
// 刷新确认后台已完成时直接返回任务列表,不再停留在已完成柜子内。
|
||||||
|
if (remoteTask.taskStatus === 'COMPLETED') {
|
||||||
|
this.remotePick = remoteTask
|
||||||
|
this.pickingVersion = remoteTask.pickingVersion ?? remoteTask.version
|
||||||
|
if (this.activeOverlay) this.closeOverlay()
|
||||||
|
this.boxDropdownOpen = false
|
||||||
|
this.persistSession()
|
||||||
|
navigateBackOr('/pages/tasks/tasks')
|
||||||
|
return
|
||||||
|
}
|
||||||
this.assignmentVersion = remoteTask.assignment?.version ?? remoteTask.assignmentVersion ?? (remoteTask.workflowStatus === 'UNASSIGNED' ? 0 : null)
|
this.assignmentVersion = remoteTask.assignment?.version ?? remoteTask.assignmentVersion ?? (remoteTask.workflowStatus === 'UNASSIGNED' ? 0 : null)
|
||||||
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
|
||||||
@@ -1469,6 +1485,11 @@
|
|||||||
}
|
}
|
||||||
this.remoteError = ''
|
this.remoteError = ''
|
||||||
this.showToast('装柜已完成', 'success')
|
this.showToast('装柜已完成', 'success')
|
||||||
|
// 收起页面内浮层,避免 onBackPress 拦截完成后的程序化返回。
|
||||||
|
if (this.activeOverlay) this.closeOverlay()
|
||||||
|
this.boxDropdownOpen = false
|
||||||
|
this.persistSession()
|
||||||
|
navigateBackOr('/pages/tasks/tasks')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!this.isEvidenceContextActive(contextId, identity)) return
|
if (!this.isEvidenceContextActive(contextId, identity)) return
|
||||||
if (accepted) {
|
if (accepted) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import test from 'node:test'
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { readFile } from 'node:fs/promises'
|
import { readFile } from 'node:fs/promises'
|
||||||
import vm from 'node:vm'
|
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 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 =')
|
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/gm, '').replace('export default', 'globalThis.page =')
|
||||||
@@ -18,7 +19,7 @@ function harness(overrides = {}) {
|
|||||||
countDropshipCarton: async (id, payload) => { calls.push(['count', id, payload]); return { carton: {} } },
|
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 } },
|
moveDropshipCarton: async (id, cartonId, payload) => { calls.push(['move', id, cartonId, payload]); return { ...target, taskId: id } },
|
||||||
saveDropshipPalletizingDraft: () => {}, clearDropshipPalletizingDraft: () => {}, stableOperationId: () => 'stable-key',
|
saveDropshipPalletizingDraft: () => {}, clearDropshipPalletizingDraft: () => {}, stableOperationId: () => 'stable-key',
|
||||||
isOnline: () => true, formatApiError: (error) => error.message, uni: { redirectTo: (value) => calls.push(['redirect', value]) },
|
isOnline: () => true, sanitizeUserMessage, console: { error: (...args) => calls.push(['diagnostic', ...args]) }, uni: { redirectTo: (value) => calls.push(['redirect', value]) },
|
||||||
...overrides
|
...overrides
|
||||||
}
|
}
|
||||||
vm.runInNewContext(script, context)
|
vm.runInNewContext(script, context)
|
||||||
@@ -28,6 +29,19 @@ function harness(overrides = {}) {
|
|||||||
return { state, calls, pallet, target }
|
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 () => {
|
test('cross customer scan remains read only until confirmation and then claims original task', async () => {
|
||||||
const { state, calls, pallet, target } = harness()
|
const { state, calls, pallet, target } = harness()
|
||||||
state.workspace = { pallet, members: [] }; state.selectedPalletNo = pallet.palletNo
|
state.workspace = { pallet, members: [] }; state.selectedPalletNo = pallet.palletNo
|
||||||
@@ -116,3 +130,27 @@ test('task list stops carrying a pallet sealed while another ticket was being wo
|
|||||||
assert.equal(state.palletNo, '')
|
assert.equal(state.palletNo, '')
|
||||||
assert.ok(!navigations[1].includes('DP-261019-0014'))
|
assert.ok(!navigations[1].includes('DP-261019-0014'))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('task list hardware scan searches inbound code and releases receiver when hidden', async () => {
|
||||||
|
const taskSource = await readFile(new URL('../pages/dropship-tasks/dropship-tasks.vue', import.meta.url), 'utf8')
|
||||||
|
const script = taskSource.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/gm, '').replace('export default', 'globalThis.page =')
|
||||||
|
let receiver, stopped = 0
|
||||||
|
const searches = []
|
||||||
|
const context = { PortalHeader: {}, ScanTargetInput: {}, session: { currentUser: { warehouseId: '100' } }, requireSession: () => true,
|
||||||
|
startScanBroadcast: options => { receiver = options; return () => { stopped++ } },
|
||||||
|
listDropshipPalletizationTasks: async (code, warehouse) => { searches.push([code, warehouse]); return [] },
|
||||||
|
formatApiError: error => error.message }
|
||||||
|
vm.runInNewContext(script, context)
|
||||||
|
const state = Object.assign(context.page.data(), context.page.methods)
|
||||||
|
context.page.onShow.call(state)
|
||||||
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
receiver.onScan(' IN-2611050001 ')
|
||||||
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
assert.deepEqual(searches.at(-1), ['IN-2611050001', '100'])
|
||||||
|
state.loading = true
|
||||||
|
receiver.onScan('SECOND')
|
||||||
|
assert.equal(searches.length, 2)
|
||||||
|
context.page.onHide.call(state)
|
||||||
|
context.page.onUnload.call(state)
|
||||||
|
assert.equal(stopped, 1)
|
||||||
|
})
|
||||||
|
|||||||
@@ -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/'])
|
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 serves the domain root 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 /'))
|
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.doesNotMatch(nginx, /pda-domestic|pda-overseas/)
|
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/'))
|
|
||||||
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 () => {
|
||||||
@@ -72,15 +57,3 @@ test('H5 proxy uses the shared gateway prefix and preserves API suffixes', async
|
|||||||
assert.equal(proxy['/gateway-api'].rewrite(`/gateway-api${suffix}`), `/api${suffix}`)
|
assert.equal(proxy['/gateway-api'].rewrite(`/gateway-api${suffix}`), `/api${suffix}`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('gray H5 runs on an isolated network with same-origin Gateway API', async () => {
|
|
||||||
const compose = await read('../deploy/docker-compose.h5.production.yml')
|
|
||||||
const nginx = await read('../deploy/nginx.production.conf.template')
|
|
||||||
const docker = await read('../deploy/Dockerfile.h5.production')
|
|
||||||
assert.match(compose, /name: firstleg-production-network/)
|
|
||||||
assert.match(compose, /127\.0\.0\.1:/)
|
|
||||||
assert.doesNotMatch(compose, /test-network|gateway-overseas/)
|
|
||||||
assert.match(nginx, /location \/gateway-api\//)
|
|
||||||
assert.ok(nginx.includes('proxy_pass ${API_UPSTREAM}/api/;'))
|
|
||||||
assert.match(docker, /COPY deploy\/nginx.production.conf.template \/etc\/nginx\/templates\/default.conf.template/)
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -63,18 +63,19 @@ test('query failure is not retried as a completion mutation and completed accept
|
|||||||
readTask: async () => assert.fail('no query after authoritative completion'), isActive: () => true, onTask() {} }), 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 () => {
|
test('page only returns to the task list after authoritative completion without persisting a replay request', async () => {
|
||||||
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')
|
||||||
const method = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0]
|
const method = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0]
|
||||||
assert.ok(method)
|
assert.ok(method)
|
||||||
for (const status of ['COMPLETED', 'COMPLETION_FAILED', 'COMPLETING', 'LOADING', 'QUERY_ERROR', 'NO_RESPONSE']) {
|
for (const status of ['COMPLETED', 'COMPLETION_FAILED', 'COMPLETING', 'LOADING', 'QUERY_ERROR', 'NO_RESPONSE']) {
|
||||||
const messages = [], cleared = [], saved = []
|
const messages = [], cleared = [], saved = [], destinations = []
|
||||||
const bindings = {
|
const bindings = {
|
||||||
COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion,
|
COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion,
|
||||||
isRemoteReady: () => true, restoreLoadingCompletionAttempt: () => null,
|
isRemoteReady: () => true, restoreLoadingCompletionAttempt: () => null,
|
||||||
stableTaskOperationIdentity: () => 'stable-task', stableOperationId: () => 'original-operation',
|
stableTaskOperationIdentity: () => 'stable-task', stableOperationId: () => 'original-operation',
|
||||||
saveLoadingCompletionAttempt: (_, attempt) => saved.push(attempt),
|
saveLoadingCompletionAttempt: (_, attempt) => saved.push(attempt),
|
||||||
clearLoadingCompletionAttempt: identity => cleared.push(identity),
|
clearLoadingCompletionAttempt: identity => cleared.push(identity),
|
||||||
|
navigateBackOr: url => destinations.push(url),
|
||||||
completeContainerPickingTask: async () => status === 'NO_RESPONSE' ? null : { taskStatus: 'COMPLETING', version: 2 },
|
completeContainerPickingTask: async () => status === 'NO_RESPONSE' ? null : { taskStatus: 'COMPLETING', version: 2 },
|
||||||
normalizePickingTask: task => task,
|
normalizePickingTask: task => task,
|
||||||
observeLoadingCompletion: async options => {
|
observeLoadingCompletion: async options => {
|
||||||
@@ -99,6 +100,7 @@ test('page only announces success without persisting a replay request after auth
|
|||||||
assert.equal(saved.length, 0)
|
assert.equal(saved.length, 0)
|
||||||
assert.equal(cleared.length, 0)
|
assert.equal(cleared.length, 0)
|
||||||
assert.equal(messages.some(message => message.message === '装柜已完成'), status === 'COMPLETED')
|
assert.equal(messages.some(message => message.message === '装柜已完成'), status === 'COMPLETED')
|
||||||
|
assert.deepEqual(destinations, status === 'COMPLETED' ? ['/pages/tasks/tasks'] : [])
|
||||||
if (status !== 'COMPLETED') assert.ok(context.remoteError)
|
if (status !== 'COMPLETED') assert.ok(context.remoteError)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -85,8 +85,8 @@ test('picking page exposes five evidence slots, retry and remote completion stat
|
|||||||
for (const text of ['PHOTO_STEP_1', 'PHOTO_STEP_4', 'SEAL', '网络失败', '确认装柜完成']) {
|
for (const text of ['PHOTO_STEP_1', 'PHOTO_STEP_4', 'SEAL', '网络失败', '确认装柜完成']) {
|
||||||
assert.match(`${source}\n${page}`, new RegExp(text))
|
assert.match(`${source}\n${page}`, new RegExp(text))
|
||||||
}
|
}
|
||||||
assert.match(page, /createEvidenceUploadQueue/)
|
assert.match(page, /async queueEvidenceUpload\(type, path, sealNo = null\)/)
|
||||||
assert.match(page, /retryEvidence/)
|
assert.match(page, /@tap="refreshTask"/)
|
||||||
assert.match(page, /uploadEvidenceFile/)
|
assert.match(page, /uploadEvidenceFile/)
|
||||||
assert.match(page, /submitting \|\| \(!pickingCompleted && !summary\.canSubmit\)/)
|
assert.match(page, /submitting \|\| \(!pickingCompleted && !summary\.canSubmit\)/)
|
||||||
assert.doesNotMatch(page, /completePicking\(/)
|
assert.doesNotMatch(page, /completePicking\(/)
|
||||||
@@ -135,8 +135,8 @@ test('cabinet evidence never selects an inbound owner or builds a container rout
|
|||||||
const evidenceAdapter = source.match(/export function getEvidence\(taskId, warehouseId\)[\s\S]*?export function completeContainerPickingTask/)?.[0] || ''
|
const evidenceAdapter = source.match(/export function getEvidence\(taskId, warehouseId\)[\s\S]*?export function completeContainerPickingTask/)?.[0] || ''
|
||||||
for (const field of ['inboundId', 'tenantId', 'containerBookingId']) assert.doesNotMatch(evidenceAdapter, new RegExp(field))
|
for (const field of ['inboundId', 'tenantId', 'containerBookingId']) assert.doesNotMatch(evidenceAdapter, new RegExp(field))
|
||||||
assert.match(page, /captureEvidenceIdentity\(\) \{[\s\S]*?taskId:/)
|
assert.match(page, /captureEvidenceIdentity\(\) \{[\s\S]*?taskId:/)
|
||||||
assert.match(page, /uploadEvidenceFile\(item\.taskId, item\.warehouseId, item, item\.localPath/)
|
assert.match(page, /uploadEvidenceFile\(identity\.taskId, identity\.warehouseId,[\s\S]*?\}, path, progress/)
|
||||||
assert.match(page, /queue\.add\(\{ taskId: identity\.taskId, warehouseId: identity\.warehouseId, evidenceType: type, expectedVersion/)
|
assert.match(page, /evidenceType: type, expectedVersion: current\?\.version \?\? 0, sealNo/)
|
||||||
assert.doesNotMatch(page, /evidenceIdentity\?\.inboundId|identity\.inboundId/)
|
assert.doesNotMatch(page, /evidenceIdentity\?\.inboundId|identity\.inboundId/)
|
||||||
assert.doesNotMatch(page, /currentBox\.warehouseId \|\| session\.currentUser/)
|
assert.doesNotMatch(page, /currentBox\.warehouseId \|\| session\.currentUser/)
|
||||||
assert.match(source, /warehouseId=\$\{encodeURIComponent\(warehouseId\)\}/)
|
assert.match(source, /warehouseId=\$\{encodeURIComponent\(warehouseId\)\}/)
|
||||||
|
|||||||
@@ -266,9 +266,9 @@ test('picking mutations stay closed until the canonical task has been claimed',
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('completion shows a Chinese server failure with a request correlation id', () => {
|
test('completion shows a concise Chinese failure and retains correlation ids in diagnostics', () => {
|
||||||
assert.match(page, /remoteFailureMessage\(error, fallback = '操作失败'\)/)
|
assert.match(page, /remoteFailureMessage\(error, fallback = '操作失败'\)/)
|
||||||
assert.match(page, /formatApiError\(error, fallback\)/)
|
assert.match(page, /formatApiError\(error, fallback, false\)/)
|
||||||
assert.match(page, /import \{ containerReference, formatApiError, sanitizeUserMessage \}/)
|
assert.match(page, /import \{ containerReference, formatApiError, sanitizeUserMessage \}/)
|
||||||
assert.match(page, /requestIdFromServer: error\.requestId/)
|
assert.match(page, /requestIdFromServer: error\.requestId/)
|
||||||
assert.match(page, /this\.remoteFailureMessage\(error, '装柜完成失败'\)/)
|
assert.match(page, /this\.remoteFailureMessage\(error, '装柜完成失败'\)/)
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.
|
|||||||
const method = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)[0]
|
const method = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)[0]
|
||||||
function completion(overrides = {}, savedAttempt = null) {
|
function completion(overrides = {}, savedAttempt = null) {
|
||||||
let attempt = savedAttempt
|
let attempt = savedAttempt
|
||||||
const calls = [], cleared = [], messages = []
|
const calls = [], cleared = [], messages = [], destinations = []
|
||||||
const bindings = {
|
const bindings = {
|
||||||
COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion,
|
COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion,
|
||||||
isRemoteReady: () => true, stableTaskOperationIdentity: () => 'task', stableOperationId: (_action, _identity, payload) => `complete-v${payload.expectedTaskVersion}`,
|
isRemoteReady: () => true, stableTaskOperationIdentity: () => 'task', stableOperationId: (_action, _identity, payload) => `complete-v${payload.expectedTaskVersion}`,
|
||||||
restoreLoadingCompletionAttempt: () => attempt, saveLoadingCompletionAttempt: (_identity, value) => { attempt = value },
|
restoreLoadingCompletionAttempt: () => attempt, saveLoadingCompletionAttempt: (_identity, value) => { attempt = value },
|
||||||
clearLoadingCompletionAttempt: identity => { cleared.push(identity); attempt = null },
|
clearLoadingCompletionAttempt: identity => { cleared.push(identity); attempt = null },
|
||||||
|
navigateBackOr: url => destinations.push(url),
|
||||||
completeContainerPickingTask: async (_task, payload) => { calls.push(payload); return { taskStatus: 'COMPLETED' } },
|
completeContainerPickingTask: async (_task, payload) => { calls.push(payload); return { taskStatus: 'COMPLETED' } },
|
||||||
normalizePickingTask: task => task, observeLoadingCompletion: async options => options.initialTask,
|
normalizePickingTask: task => task, observeLoadingCompletion: async options => options.initialTask,
|
||||||
sanitizeUserMessage: value => value, ...overrides
|
sanitizeUserMessage: value => value, ...overrides
|
||||||
@@ -27,15 +28,16 @@ function completion(overrides = {}, savedAttempt = null) {
|
|||||||
remoteFailureMessage: error => error.message, showToast: message => messages.push(message)
|
remoteFailureMessage: error => error.message, showToast: message => messages.push(message)
|
||||||
}
|
}
|
||||||
state.complete = behavior.completeLoadingTask.bind(state)
|
state.complete = behavior.completeLoadingTask.bind(state)
|
||||||
return { state, calls, cleared, messages, attempt: () => attempt }
|
return { state, calls, cleared, messages, destinations, attempt: () => attempt }
|
||||||
}
|
}
|
||||||
|
|
||||||
test('completion in flight blocks a second click and releases busy after the task context changes', async () => {
|
test('completion in flight blocks a second click and releases busy after the task context changes', async () => {
|
||||||
let resolve
|
let resolve
|
||||||
const { state, calls } = completion({ completeContainerPickingTask: (...args) => { calls.push(args); return new Promise(done => { resolve = done }) } })
|
const { state, calls, destinations } = completion({ completeContainerPickingTask: (...args) => { calls.push(args); return new Promise(done => { resolve = done }) } })
|
||||||
const first = state.complete(); await state.complete(); assert.equal(calls.length, 1)
|
const first = state.complete(); await state.complete(); assert.equal(calls.length, 1)
|
||||||
state.evidenceContextId++; resolve({ taskStatus: 'COMPLETED' }); await first
|
state.evidenceContextId++; resolve({ taskStatus: 'COMPLETED' }); await first
|
||||||
assert.equal(state.submitting, false); assert.equal(state.remotePick.taskStatus, undefined)
|
assert.equal(state.submitting, false); assert.equal(state.remotePick.taskStatus, undefined)
|
||||||
|
assert.deepEqual(destinations, [])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('an old completion response cannot release a newer picking submission', async () => {
|
test('an old completion response cannot release a newer picking submission', async () => {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import test from 'node:test'
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import vm from 'node:vm'
|
import vm from 'node:vm'
|
||||||
import { readFile } from 'node:fs/promises'
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { navigateBackOr } from '../utils/navigation.js'
|
||||||
|
import { COMPLETION_PHASE, observeLoadingCompletion, resolveCompletionPhase, shouldReconcileCompletion } from '../utils/loading-completion-state.js'
|
||||||
|
|
||||||
const source = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
|
const source = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
|
||||||
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1]
|
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1]
|
||||||
@@ -107,6 +109,82 @@ test('failed request releases submit guard and retry keeps operation identity',
|
|||||||
|
|
||||||
const task = () => ({ taskId: 'task', recordStatus: 'ACTIVE', loadingPlanId: 'plan', containerBookingId: 'container',
|
const task = () => ({ taskId: 'task', recordStatus: 'ACTIVE', loadingPlanId: 'plan', containerBookingId: 'container',
|
||||||
currentContainerNo: 'TEST', version: 1, workflowStatus: 'UNASSIGNED', items: [{ id: 'item', packageCount: 2, itemStatus: 'PENDING' }] })
|
currentContainerNo: 'TEST', version: 1, workflowStatus: 'UNASSIGNED', items: [{ id: 'item', packageCount: 2, itemStatus: 'PENDING' }] })
|
||||||
|
|
||||||
|
test('refreshing a completed task returns to the list before claiming or loading evidence', async () => {
|
||||||
|
const destinations = []; let claims = 0, evidence = 0
|
||||||
|
const { ctx } = mount({ getContainerPickingTask: async () => ({ ...task(), taskStatus: 'COMPLETED', workflowStatus: 'COMPLETED' }),
|
||||||
|
navigateBackOr: url => destinations.push(url),
|
||||||
|
claimContainerPickingTask: async () => { claims++ } }, { loadLoadingEvidence: async () => { evidence++ } })
|
||||||
|
await ctx.syncRemoteTask()
|
||||||
|
assert.deepEqual(destinations, ['/pages/tasks/tasks'])
|
||||||
|
assert.equal(ctx.remotePick.taskStatus, 'COMPLETED')
|
||||||
|
assert.equal(claims, 0); assert.equal(evidence, 0); assert.equal(ctx.remoteLoading, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const entry of ['completion', 'refresh']) {
|
||||||
|
for (const control of ['dropdown', 'overlay']) {
|
||||||
|
test(`${entry} returns through onBackPress even when the ${control} opens before completion`, async t => {
|
||||||
|
const previousUni = globalThis.uni, previousPages = globalThis.getCurrentPages
|
||||||
|
t.after(() => { globalThis.uni = previousUni; globalThis.getCurrentPages = previousPages })
|
||||||
|
const response = deferred(), destinations = []; let popupClosed = 0
|
||||||
|
const completed = { ...task(), taskStatus: 'COMPLETED', workflowStatus: 'COMPLETED' }
|
||||||
|
const { page, ctx } = mount({ navigateBackOr, COMPLETION_PHASE, observeLoadingCompletion,
|
||||||
|
resolveCompletionPhase, shouldReconcileCompletion,
|
||||||
|
completeContainerPickingTask: () => response.promise, getContainerPickingTask: () => response.promise },
|
||||||
|
{ evidenceIdentity: { taskId: 'task', warehouseId: 'warehouse' }, evidenceComplete: true,
|
||||||
|
currentBoxIndex: 0, activeOverlay: '', boxDropdownOpen: false,
|
||||||
|
requireEvidenceStage: () => true, isEvidenceContextActive: () => true,
|
||||||
|
$nextTick: callback => callback(), $refs: { boxListPopup: { open() {}, close() { popupClosed++ } } } })
|
||||||
|
ctx.openOverlay = page.methods.openOverlay.bind(ctx)
|
||||||
|
globalThis.getCurrentPages = () => [{ route: 'pages/tasks/tasks' }, { route: 'pages/picking/picking' }]
|
||||||
|
globalThis.uni = {
|
||||||
|
// App 将返回钩子的拦截视为成功;仅调用导航函数不能证明页面已返回。
|
||||||
|
navigateBack() { if (!page.onBackPress.call(ctx)) destinations.push('/pages/tasks/tasks') },
|
||||||
|
reLaunch: ({ url }) => destinations.push(url)
|
||||||
|
}
|
||||||
|
const pending = entry === 'completion' ? ctx.completeLoadingTask() : ctx.syncRemoteTask()
|
||||||
|
if (control === 'dropdown') ctx.toggleBoxDropdown()
|
||||||
|
else ctx.openOverlay('boxList')
|
||||||
|
response.resolve(completed)
|
||||||
|
await pending
|
||||||
|
assert.deepEqual(destinations, ['/pages/tasks/tasks'])
|
||||||
|
assert.equal(ctx.boxDropdownOpen, false); assert.equal(ctx.activeOverlay, '')
|
||||||
|
assert.equal(popupClosed, control === 'overlay' ? 1 : 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('completed tasks update the real history state while clearing only their own persisted draft', async t => {
|
||||||
|
const previousUni = globalThis.uni
|
||||||
|
t.after(() => { globalThis.uni = previousUni })
|
||||||
|
const moduleUrl = value => `data:text/javascript;base64,${Buffer.from(value).toString('base64')}`
|
||||||
|
let storeSource = await readFile(new URL('../stores/picking.js', import.meta.url), 'utf8')
|
||||||
|
for (const path of ['data/demo-data.js', 'utils/picking-state.js', 'utils/task-identity.js']) {
|
||||||
|
storeSource = storeSource.replace(`@/${path}`, moduleUrl(await readFile(new URL(`../${path}`, import.meta.url), 'utf8')))
|
||||||
|
}
|
||||||
|
const store = await import(moduleUrl(storeSource)), storage = new Map(), writes = []
|
||||||
|
globalThis.uni = { getStorageSync: key => storage.get(key),
|
||||||
|
setStorageSync: (key, value) => { writes.push(key); storage.set(key, value) }, removeStorageSync: key => storage.delete(key) }
|
||||||
|
const identity = { operatorId: 'operator', taskId: 'task', loadingPlanId: 'plan', containerBookingId: 'container' }
|
||||||
|
const { page, ctx } = mount({ clearPickingDraft: store.clearPickingDraft, savePickingSession: store.savePickingSession },
|
||||||
|
{ remotePick: { taskStatus: 'LOADING' }, draftIdentity: () => identity, currentBoxIndex: 0,
|
||||||
|
boxes: [{ taskId: 'task', cabinetNo: 'D2126', items: [] }], boxDrafts: {}, scanHistory: [] })
|
||||||
|
ctx.persistSession = page.methods.persistSession.bind(ctx)
|
||||||
|
ctx.persistSession()
|
||||||
|
const draftKey = store.getPickingDraftKey(identity)
|
||||||
|
assert.equal(storage.has(draftKey), true); assert.equal(store.getPickingOverview().completedCount, 0)
|
||||||
|
storage.set('another-task-draft', { keep: true })
|
||||||
|
writes.length = 0
|
||||||
|
ctx.remotePick = { taskStatus: 'COMPLETED' }
|
||||||
|
ctx.persistSession()
|
||||||
|
page.onUnload.call(ctx)
|
||||||
|
assert.equal(store.getPickingOverview().completedCount, 1)
|
||||||
|
assert.equal(store.getPickingOverview().completedBoxes[0], true)
|
||||||
|
assert.equal(store.getPickingOverview().boxes[0].taskId, 'task')
|
||||||
|
assert.equal(storage.has(draftKey), false); assert.equal(storage.has('another-task-draft'), true)
|
||||||
|
assert.deepEqual(writes, [])
|
||||||
|
})
|
||||||
|
|
||||||
for (const hook of ['onHide', 'onUnload']) {
|
for (const hook of ['onHide', 'onUnload']) {
|
||||||
test(`${hook} while detail is pending prevents claim and evidence requests`, async () => {
|
test(`${hook} while detail is pending prevents claim and evidence requests`, async () => {
|
||||||
const detail = deferred(); let claims = 0, evidence = 0
|
const detail = deferred(); let claims = 0, evidence = 0
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ test('operator errors hide internal ids but retain request and trace evidence',
|
|||||||
assert.match(message, /错误码:S0500/)
|
assert.match(message, /错误码:S0500/)
|
||||||
assert.match(message, /requestId:dbde0d81/)
|
assert.match(message, /requestId:dbde0d81/)
|
||||||
assert.match(message, /traceId:5b0ba4f3/)
|
assert.match(message, /traceId:5b0ba4f3/)
|
||||||
|
const concise = formatApiError(error, '任务处理失败', false)
|
||||||
|
assert.doesNotMatch(concise, /错误码|requestId|traceId|S0500/)
|
||||||
|
assert.match(concise, /处理失败/)
|
||||||
assert.equal(sanitizeUserMessage('operation 2092889048731414530 failed'), 'operation 内部标识已隐藏 failed')
|
assert.equal(sanitizeUserMessage('operation 2092889048731414530 failed'), 'operation 内部标识已隐藏 failed')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -47,3 +50,8 @@ test('priority PDA templates do not interpolate internal ids', async () => {
|
|||||||
assert.match(pages[2], /containerReference\(currentBox\)/)
|
assert.match(pages[2], /containerReference\(currentBox\)/)
|
||||||
assert.match(pages[3], /containerReference\(entry\.box\)/)
|
assert.match(pages[3], /containerReference\(entry\.box\)/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('operator messages strip inline diagnostic suffixes', () => {
|
||||||
|
assert.equal(formatApiError({ message: '装柜证据文件上传失败(错误码:S0503;requestId:request-1;traceId:trace-1)' }, '上传失败', false), '装柜证据文件上传失败')
|
||||||
|
assert.equal(formatApiError({ message: '请先完成车辆取货送仓\nrequestId: request-1' }, '操作失败', false), '请先完成车辆取货送仓')
|
||||||
|
})
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function containerReference(source = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeUserMessage(message, fallback = '操作失败') {
|
export function sanitizeUserMessage(message, fallback = '操作失败') {
|
||||||
const raw = String(message ?? '').trim()
|
const raw = String(message ?? '').replace(/[((]\s*(?:错误码|code|requestId|traceId)\s*[::=][\s\S]*$/i, '').replace(/(?:requestId|traceId)\s*[::=][\s\S]*$/i, '').trim()
|
||||||
if (!raw) return fallback
|
if (!raw) return fallback
|
||||||
if (/^internal server error$/i.test(raw)) return '服务端处理异常,请联系管理员'
|
if (/^internal server error$/i.test(raw)) return '服务端处理异常,请联系管理员'
|
||||||
const sanitized = raw
|
const sanitized = raw
|
||||||
@@ -25,8 +25,9 @@ export function sanitizeUserMessage(message, fallback = '操作失败') {
|
|||||||
return sanitized || fallback
|
return sanitized || fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatApiError(error, fallback = '操作失败') {
|
export function formatApiError(error, fallback = '操作失败', includeDiagnostics = true) {
|
||||||
const message = sanitizeUserMessage(error?.message, fallback)
|
const message = sanitizeUserMessage(error?.message, fallback)
|
||||||
|
if (!includeDiagnostics) return message
|
||||||
const code = String(error?.code ?? '').trim()
|
const code = String(error?.code ?? '').trim()
|
||||||
const requestId = String(error?.requestId ?? '').trim()
|
const requestId = String(error?.requestId ?? '').trim()
|
||||||
const traceId = String(error?.traceId ?? '').trim()
|
const traceId = String(error?.traceId ?? '').trim()
|
||||||
|
|||||||
Reference in New Issue
Block a user