diff --git a/.dockerignore b/.dockerignore
deleted file mode 100644
index 8d714a4..0000000
--- a/.dockerignore
+++ /dev/null
@@ -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
diff --git a/AGENTS.md b/AGENTS.md
index c94d497..ed02d02 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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 内置测试仅覆盖页面契约与本地状态规则。
- PDA 采用 uni-app 开发并支持在线更新;Android 硬件扫描已确认使用广播模式并已接入:Action=`android.intent.action.SCANRESULT`、字符串 Extra=`value`。设备型号不构成开发、发布或验收门禁;继续通过设备适配层接入,不绑定单一厂商 SDK。
- 正式作业已确认为在线模式:断网不能开始正式拣货、提交、完成任务或推进 `LOADED`,不实现离线业务提交队列;恢复网络后提示员工刷新重试。
@@ -42,8 +43,9 @@
## 质量门禁
-- 在真实目标 PDA 上验证扫描速度、连续扫描、断网阻断与恢复刷新、低电量、前后台切换和重复提交。
-- 状态转换、扫码分类与页面契约使用 `node --test tests/*.test.mjs` 验证;新增本地行为时继续补充对应自动化测试。
+- 验证按 `../../docs/AI工程执行与协作约束.md` 的“验证范围选择”;文案/纯展示核对差异,局部逻辑指定受影响测试文件,不默认全量、构建或真机全面验收。
+- 扫码、断网恢复、生命周期或设备能力变化才在真实 PDA 核对对应场景;完整设备回归用于明确的发布/验收任务。
+- 状态转换、扫码分类与页面契约按受影响文件执行 `node --test`;`node --test tests/*.test.mjs` 为全量入口,不因新增本地行为自动触发。
- 修改在线更新协议、版本比较、强制更新、安装或重启行为时运行 `node --test tests/app-update.test.mjs`;新增原生模块、权限或不兼容运行时能力必须发布 APK,不能只发布 WGT。
- 当前没有依赖清单、仓库内 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 |
| 拣货任务与箱码扫码 | `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/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/history/`、`pages/profile/` | session 与本地敏感数据清理 |
| 页面与真实 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 范围与代码入口 | 验收首读 |
|---|---|---|---|
diff --git a/README.md b/README.md
index d741926..cab837c 100644
--- a/README.md
+++ b/README.md
@@ -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` 批量绑定。所有写操作要求在线并携带稳定幂等标识,冲突后刷新权威任务;封托后的托盘只读并作为后续分柜、装柜的不可拆单元。
+混托作业保持当前实物托盘,允许连续扫描不同客户的箱码。仓库级 `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`。
拣货页会在设备端保留当前任务草稿和最近扫码结果,但正式作业以服务端事实为准:进入任务后由后端标记“拣货中”,每次有效箱码/托盘码扫描及“确认拣出、部分拣货、跳拣”操作立即写入服务端,后台智能装柜据此锁定已发生实物拣货的明细。底部提交只负责收尾当前仍未处理的明细并进入装柜取证;网络失败时页面不得先显示成功。允许部分拣货或跳拣,但每个未完成项必须填写异常原因,并挂起任务、阻断完成和 `LOADED`。
@@ -107,32 +109,38 @@ Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查国内 PDA 的独
3. 更新清单与下载地址使用 HTTPS,先在测试设备验证可选更新、强制更新、断网重试、APK 覆盖安装、wgt 重启及扫码作业回归,再切换正式清单。
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 配置
-两端的 `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
-location ^~ /gateway-api/ {
- proxy_pass http://gateway:9000/api/;
- proxy_set_header Authorization $http_authorization;
- client_max_body_size 11m;
-}
-```
+部署定向检查:`node --test tests/h5-deployment.test.mjs`,经 `local-check.ps1` 执行。该检查覆盖静态发布约束、资源 base 和开发代理重写,不替代目标主机 Nginx 校验、真实 HTTP、人工页面及 Android 真机验收。
diff --git a/api/pda-dropship.js b/api/pda-dropship.js
index 775a970..7dd9446 100644
--- a/api/pda-dropship.js
+++ b/api/pda-dropship.js
@@ -4,6 +4,14 @@ import { session } from '@/stores/session.js'
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) {
return String(value ?? '').trim()
}
diff --git a/deploy/Dockerfile.h5 b/deploy/Dockerfile.h5
deleted file mode 100644
index 56d7173..0000000
--- a/deploy/Dockerfile.h5
+++ /dev/null
@@ -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
diff --git a/deploy/Dockerfile.h5.production b/deploy/Dockerfile.h5.production
deleted file mode 100644
index f08e893..0000000
--- a/deploy/Dockerfile.h5.production
+++ /dev/null
@@ -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
diff --git a/deploy/docker-compose.h5.production.yml b/deploy/docker-compose.h5.production.yml
deleted file mode 100644
index 60dbdfc..0000000
--- a/deploy/docker-compose.h5.production.yml
+++ /dev/null
@@ -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
diff --git a/deploy/docker-compose.h5.test.yml b/deploy/docker-compose.h5.test.yml
deleted file mode 100644
index 5e9342c..0000000
--- a/deploy/docker-compose.h5.test.yml
+++ /dev/null
@@ -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
diff --git a/deploy/nginx.conf b/deploy/nginx.conf
deleted file mode 100644
index bdef818..0000000
--- a/deploy/nginx.conf
+++ /dev/null
@@ -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;
- }
-}
diff --git a/deploy/nginx.conf.template b/deploy/nginx.conf.template
new file mode 100644
index 0000000..b7a742d
--- /dev/null
+++ b/deploy/nginx.conf.template
@@ -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;
+}
diff --git a/deploy/nginx.production.conf.template b/deploy/nginx.production.conf.template
deleted file mode 100644
index 4ce203e..0000000
--- a/deploy/nginx.production.conf.template
+++ /dev/null
@@ -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;
- }
-}
diff --git a/manifest.json b/manifest.json
index e71d9b7..1c590bb 100644
--- a/manifest.json
+++ b/manifest.json
@@ -2,8 +2,8 @@
"name" : "柏悦物流 PDA",
"appid" : "__UNI__9BFFAF2",
"description" : "柏悦物流 PDA 作业端",
- "versionName" : "0.0.2",
- "versionCode" : 2,
+ "versionName" : "0.1.0",
+ "versionCode" : 10,
"transformPx" : false,
"app-plus" : {
/* 5+App特有相关 */
diff --git a/pages/dropship-palletization/dropship-palletization.vue b/pages/dropship-palletization/dropship-palletization.vue
index 403194c..cd7b714 100644
--- a/pages/dropship-palletization/dropship-palletization.vue
+++ b/pages/dropship-palletization/dropship-palletization.vue
@@ -20,7 +20,7 @@
-
+
1. 扫箱清点
@@ -29,6 +29,8 @@
+ {{ scanTask?.customerName || task.customerName }} · {{ carton.inboundCode }}
+ {{ carton.blockReason }}
SKU {{ index + 1 }}{{ line.skuCode || '—' }}
@@ -53,15 +55,17 @@
本票已全部归托。托盘未装满时先继续装其他票,实物装满后再封托。
-
+
- 2. 托盘操作直接扫描托盘码,只显示当前操作的托盘;待绑托箱扫描目标托盘后批量归托
+ 2. 托盘操作
{{ currentPallet.palletNo }}{{ currentPallet.status === 'SEALED' ? '已封托' : '当前托盘' }}
{{ currentPallet.cartonCount || 0 }} 箱 · {{ currentPallet.skuCount || 0 }} SKU · {{ currentPallet.weightKg || 0 }} kg · {{ currentPallet.volumeCbm || 0 }} m³
- {{ item.boxCode }}
+ 整托 {{ palletCustomerCount }} 个客户 · {{ palletMembers.length }} 票
+ {{ member.customerName || '客户未关联' }} · {{ member.inboundCode }}{{ member.cartonCount }} 箱
+ {{ item.boxCode }}{{ memberForCarton(item)?.customerName }} · {{ item.inboundCode }}
@@ -82,9 +86,9 @@
import PortalHeader from '@/components/PortalHeader.vue'
import PopupHeader from '@/components/PopupHeader.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 { formatApiError } from '@/utils/user-facing.js'
+ import { sanitizeUserMessage } from '@/utils/user-facing.js'
import { stableOperationId } from '@/utils/idempotency.js'
import { isOnline } from '@/utils/network.js'
import { clearDropshipPalletizingDraft, restoreDropshipPalletizingDraft, saveDropshipPalletizingDraft } from '@/stores/dropship-palletizing.js'
@@ -93,52 +97,60 @@
export default {
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: {
statusLabel() { return this.task?.taskStatus === 'COMPLETED' ? '已完成' : (this.task?.taskStatus === 'CLAIMED' ? '作业中' : '待领取') },
palletSetupReady() { return this.task?.palletSetupReady === true },
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()))) },
isResolvedPendingCarton() { return this.carton?.countStatus === 'COUNTED_UNBOUND' },
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 ? '确认归入当前托盘' : '确认清点' },
- currentPalletDetails() { return (this.task?.pallets || []).find((item) => item.palletNo === this.selectedPalletNo) || null },
- currentPallet() { return this.currentPalletDetails || (this.selectedPalletNo ? { palletNo: this.selectedPalletNo, status: 'OPEN' } : null) },
+ currentPalletDetails() { return this.workspace?.pallet?.palletNo === this.selectedPalletNo ? this.workspace.pallet : 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 }
},
- onLoad(options) { this.taskId = String(options.taskId || ''); this.warehouseId = String(options.warehouseId || '') },
- onShow() { if (!requireSession()) return; this.user = session.currentUser; const draft = restoreDropshipPalletizingDraft(this.draftIdentity()); this.selectedPalletNo = draft?.activePalletNo || ''; this.startScanner(); this.loadTask() },
+ 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 = this.initialPalletNo || draft?.activePalletNo || ''; this.initialPalletNo = ''; this.startScanner(); this.loadTask({ restorePallet: true }) },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
onBackPress() { if (!this.unsealOpen) return false; this.closeUnseal(); return true },
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 = '扫码头连接失败,可使用输入框继续作业' } }) },
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 = '' },
- async loadTask() {
+ async loadTask({ restorePallet = false } = {}) {
if (!this.taskId || !this.warehouseId) { this.error = '缺少合托任务或仓库信息,请返回任务列表'; return }
this.loading = true; this.resetMessages()
- try { this.task = await getDropshipPalletizationTask(this.taskId, this.warehouseId); if (this.task.taskStatus === 'COMPLETED') clearDropshipPalletizingDraft(this.draftIdentity()) }
- catch (error) { this.error = formatApiError(error, '一件代发入库任务加载失败') }
+ 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 = this.formatOperationError(error, '一件代发入库任务加载失败') }
finally { this.loading = false }
},
async claimTask() { await this.runMutation(() => claimDropshipPalletizationTask(this.taskId, this.warehouseId), '任务领取成功') },
async resolveCarton(value) {
+ if (this.loading || this.submitting) return
if (!this.palletSetupReady) { this.error = this.palletSetupMessage; 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
- 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 || '' })) }
- catch (error) { this.error = formatApiError(error, '箱码解析失败') }
+ 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 = this.formatOperationError(error, '箱码解析失败') }
finally { this.loading = false }
},
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 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 = [] }
},
async confirmCarton() {
+ if (!this.canSubmitCarton || this.loading || this.submitting) return
if (this.isResolvedPendingCarton) { await this.rebindResolvedCarton(); return }
await this.countCarton()
},
@@ -147,35 +159,73 @@
const targetPalletNo = this.assignableCurrentPallet.palletNo
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 })
- 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 = [] }
},
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)) },
async selectPallet(value) {
+ if (this.loading || this.submitting) 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)
- this.palletCode = code; this.selectedPalletId = pallet ? String(pallet.palletId) : ''; this.selectedPalletNo = code; this.activeScan = 'boxCode'; saveDropshipPalletizingDraft(this.draftIdentity(), { activePalletNo: code })
+ const code = String(value || this.palletCode || '').trim().toUpperCase()
+ 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 (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 } },
async moveCarton(carton, targetPalletNo) {
if (this.submitting || !isOnline() || !this.palletSetupReady) return
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 })
- 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() },
closeUnseal() { this.unsealOpen = false; this.$refs.unsealPopup?.close(); this.unsealTarget = null },
handleUnsealPopupChange(event) { this.unsealOpen = Boolean(event.show); if (!event.show) this.unsealTarget = null },
async confirmUnseal() {
const pallet = this.unsealTarget; if (!pallet || this.submitting) return
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
if (result) { this.selectedPalletNo = pallet.palletNo; saveDropshipPalletizingDraft(this.draftIdentity(), { activePalletNo: pallet.palletNo }) }
},
@@ -189,9 +239,10 @@
? result
: await getDropshipPalletizationTask(this.taskId, this.warehouseId)
this.notice = message
+ try { await this.refreshPallet() } catch (error) { this.error = this.formatOperationError(error, '操作已成功,托盘汇总刷新失败,请刷新后继续') }
return result
}
- catch (error) { this.error = formatApiError(error, '操作失败'); return null }
+ catch (error) { this.error = this.formatOperationError(error, '操作失败'); return null }
finally { this.submitting = false }
}
}
@@ -199,6 +250,8 @@