50 Commits
Author SHA1 Message Date
ouyangxq 10d9896b6b Merge remote-tracking branch 'origin/release/test' into release/test 2026-11-05 16:30:50 +08:00
ouyangxq 1b66aeb4aa fix: display dispatch review times in eastern and pacific zones 2026-11-05 16:30:49 +08:00
liuyihe 30f370dd94 Merge branch 'release/test' of http://8.163.30.128:10082/html/pda-overseas-uniapp into release/test 2026-11-05 16:03:24 +08:00
liuyihe 3cc77c98b5 优化 2026-11-05 16:03:22 +08:00
ouyangxq 2b6f512daa Merge branch 'release/test' of http://git.buggoglobal.com/html/pda-overseas-uniapp into release/test 2026-11-05 14:53:59 +08:00
ouyangxq cc280338f1 优化 2026-11-05 14:53:57 +08:00
liuyihe 48602becd1 优化 2026-11-05 11:56:04 +08:00
liuyihe c1706685f5 Merge branch 'release/test' of http://8.163.30.128:10082/html/pda-overseas-uniapp into release/test 2026-11-04 19:27:36 +08:00
liuyihe a584fbfb91 优化 2026-11-04 19:27:34 +08:00
ouyangxq 173bbe2d02 优化 2026-11-04 18:08:34 +08:00
ouyangxq 8199234bd7 保留待确认箱后的排队扫码并阻止提前结束收货 2026-11-04 14:47:41 +08:00
liuyihe a42ecf4a59 Merge branch 'release/test' of http://8.163.30.128:10082/html/pda-overseas-uniapp into release/test 2026-11-04 14:39:30 +08:00
liuyihe b298a4b69d 优化 2026-11-04 14:39:28 +08:00
ouyangxq ed39421cdf 修复海外收货连续扫码步骤并提示本票预留托盘 2026-11-04 14:11:29 +08:00
liuyihe 55cdca5444 优化 2026-11-04 10:39:27 +08:00
ouyangxq 09ada7a441 优化 2026-11-03 17:15:11 +08:00
liuyihe bab96743d7 优化 2026-11-03 11:44:04 +08:00
liuyihe 8857faf457 Merge branch 'release/test' of http://8.163.30.128:10082/html/pda-overseas-uniapp into release/test 2026-11-02 17:16:29 +08:00
liuyihe 436393d4ea 优化 2026-11-02 17:16:15 +08:00
ouyangxq 2fc2956e6d Merge branch 'release/test' of http://git.buggoglobal.com/html/pda-overseas-uniapp into release/test 2026-11-02 17:10:11 +08:00
ouyangxq 1c18631813 优化 2026-11-02 17:03:38 +08:00
liuyihe 63407d176f Merge branch 'release/test' of http://8.163.30.128:10082/html/pda-overseas-uniapp into release/test 2026-10-30 17:59:59 +08:00
liuyihe f814453f40 优化 2026-10-30 17:59:57 +08:00
ouyangxq fadef06191 统一作业列表与待办统计并更新废弃扫码 2026-10-30 15:42:38 +08:00
liuyihe 868f18d7df 优化 2026-10-29 15:50:06 +08:00
liuyihe c726df3007 优化 2026-10-29 13:37:35 +08:00
ouyangxq a74f0a1e36 丢弃过期托盘校验响应并防止错误实收 2026-10-28 17:32:46 +08:00
ouyangxq 859630fbbe 优化 2026-10-28 17:17:54 +08:00
liuyihe fcba6b6f25 Merge branch 'release/test' of http://8.163.30.128:10082/html/pda-overseas-uniapp into release/test 2026-10-27 17:39:46 +08:00
liuyihe 70367afef8 优化 2026-10-27 17:39:44 +08:00
ouyangxq 493fa7c2e9 优化 2026-10-27 12:09:25 +08:00
ouyangxq 105c7825b4 fix(dispatch): synchronize loading evidence and review round validation 2026-10-26 19:21:15 +08:00
ouyangxq 9408e9e5f5 Merge remote-tracking branch 'origin/main' into release/test 2026-10-26 17:44:56 +08:00
admin f0e2560652 Merge pull request 'fix(pda): 暂停未配套上线的仓租预检查,恢复库存作业' (#11) from fix/skip-unreleased-storage-rent into main 2026-10-26 17:33:54 +08:00
ouyangxq 902d7bda86 fix(pda): 暂停未配套上线的仓租预检查调用 2026-10-26 17:31:11 +08:00
admin 3934ad57f8 Merge pull request '同步 PDA 五类装车照片与多图上传至 TEST' (#10) from hotfix-gray-bugfix into release/test 2026-10-26 17:26:13 +08:00
admin cc4b38261e Merge pull request 'feat(pda): 同步五类装车照片与每类五张上传' (#9) from hotfix-gray-bugfix into main 2026-10-26 17:15:10 +08:00
ouyangxq 7749b33700 feat(pda): support five loading photos per category and reconcile saves 2026-10-26 17:12:38 +08:00
admin c2261bc655 Merge pull request '同步 release/test 并精简 PDA 排车审核与装车界面' (#8) from hotfix-gray-bugfix into main 2026-10-26 16:12:39 +08:00
ouyangxq 7a47d3d35b fix(pda): shorten home operation labels 2026-10-26 16:05:05 +08:00
ouyangxq 59e81516a4 fix(pda): simplify dispatch review and loading confirmation 2026-10-26 15:51:10 +08:00
ouyangxq 2dced722b1 Merge remote-tracking branch 'origin/release/test' 2026-10-26 15:29:38 +08:00
liuyihe 2ff0ae7975 Merge origin/release/test and preserve storage rent checks with request unlock 2026-10-26 13:51:38 +08:00
liuyihe 034b41c39c 优化 2026-10-26 13:43:39 +08:00
ouyangxq f5dc84b3c2 fix(pda): clarify outbound identifiers and receiving displays 2026-10-23 18:45:48 +08:00
ouyangxq 554b0f119e fix(deploy): replace PDA H5 containers with static hosting 2026-10-23 15:25:45 +08:00
admin 4246468179 Merge pull request '发布采购、拼车、预约及匹配端侧修复至灰度' (#7) from release/test into main 2026-10-12 10:21:22 +08:00
admin 3133aac019 Merge pull request '发布已通过 TEST 回归的 2026-10-08 批次至灰度' (#6) from release/test into main 2026-10-08 22:22:45 +08:00
admin f5c2f1a00f Merge pull request '发布当前测试版本至临时灰度环境' (#5) from release/test into main 2026-10-07 11:11:57 +08:00
admin f0c0002f99 Merge pull request 'Release/test' (#4) from release/test into dev
Reviewed-on: #4
2026-09-28 16:03:23 +08:00
53 changed files with 2496 additions and 500 deletions
-12
View File
@@ -1,12 +0,0 @@
# Only compiled H5 assets and image configuration enter the build context.
**
!deploy/
!deploy/Dockerfile.h5
!deploy/nginx.conf
!dist/
!dist/build/
!dist/build/h5/
!dist/build/h5/**
!deploy/Dockerfile.h5.production
!deploy/nginx.production.conf.template
+12 -10
View File
@@ -6,15 +6,16 @@
2. 登录/Token/统一请求先读下表登录API/会话、`src/config/runtime.js`、`../../docs/统一认证与服务通信契约.md`。
3. 沿实际 API 读直接服务指引:入仓/库存 `../../baiyue-firstleg/lastleg-service/AGENTS.md`,资源 `../../baiyue-firstleg/warehouse-overseas-service/AGENTS.md`;网关路径/鉴权问题才读 `../../baiyue-firstleg/gateway/AGENTS.md`。跨模块或入口不明才读 `../../docs/海外仓业务/README.md`。
仅读适用指引/目标页面/API;纯页面不读后端,跨服务状态/鉴权不明才扩读。遵根预算,先规则后命中段,不全文读海外仓 README。
按根预算先规则后命中,不通读海外仓 README;纯页面不读后端,跨服务状态/鉴权不明才扩读。样式读[规范](../../docs/海外PDA界面样式规范.md)。
## 项目定位
- “柏悦物流海外版”面向海外仓现场人员,uni-app Vue 3 独立安装/构建/发布,不依赖 `apps/pda-uniapp/` 或仓库外 `pda-shared`。
- 业务/任务完成见下表;派送/客户回访归 Admin/Client,国内集装箱拣货/装柜归国内 PDA。
- “柏悦物流海外版”:海外仓现场 uni-app Vue 3 独立应用,不依赖国内 PDA 或仓外 `pda-shared`。
- 派送/客户回访归 Admin/Client,国内集装箱装柜归国内 PDA;其他作业见下表。
## 服务与发布边界
- H5只上传Nginx站点,禁止容器发布及重跑旧灰度脚本;步骤见`README.md`。
- Auth 唯一签发账号/Token;认证/业务共用 Gateway,App 根地址只读 VITE_API_BASE_URL,H5 用 VITE_H5_API_BASE_URL。
- 登录/刷新/会话校验Token平台主体、成员、有效期、冻结仓;`OVERSEAS_WAREHOUSE_PDA_OPERATOR`、`lastleg:pda:operate` 仅读存Auth登录/刷新顶层快照,禁解析JWT权限。快照缺失/无效/过期/跨主体或撤权均fail-closed清会话,不能只藏入口。
- 并发401仅一次refresh,完整校验Token/快照再原子替换;主体/仓变化清会话重登录,原请求/照片上传最多重放一次。刷新失败清会话;业务/网络错留code、requestId、traceId或network诊断。
@@ -23,11 +24,11 @@
- Android启动仅在 `APP-PLUS` 调 `src/utils/app-update.js`,以独立 `VITE_PDA_UPDATE_URL` 查HTTPS APK/WGT;强更禁跳过,WGT安装即重启。更新服务/标识/签名/版本序列不得与国内 PDA 混用。
- 源码根 `src/`;扫码广播 Action=`android.intent.action.SCANRESULT`,Extra=`value`。
- 均接真实PDA API;入仓账号锁柜、逐码核平;退货号/SKU候选进入,按本包实收生成退货箱/计费批次。换标含收货/执行/复核;盘点页面/API保留、工作台隐藏。
- 退货号/SKU候选进入,按本包实收生成退货箱/计费批次;其他作业范围见下表及对应接口边界。
## 页面与能力快速索引
路径相对应用根;[业务索引](docs/business-index.md)存API/字段/状态/恢复。应用根 `rg -n -F '任务词或文件名' docs/business-index.md`;涉及业务行为变更时必须核对对应细则及最近指引,定位不全文读。
路径相对应用根;[业务索引](docs/business-index.md)存 API/字段/状态/恢复;涉及业务行为变更时必须核对对应细则,只读命中段,纯展示不读。
| 目标 | 首个入口 |
|---|---|
@@ -35,10 +36,11 @@
| AppID、平台图标与打包资源 | `src/manifest.json`、`static/app-icons/` |
| Android APK/WGT 在线更新 | `src/App.vue`、`src/utils/app-update.js`、`.env.example` |
| 登录、刷新与会话 | `src/pages/login/login.vue`、`src/api/auth.js`、`src/stores/session.js` |
| 海外仓工作台与作业入口 | `src/pages/home/home.vue`;首页直接进入各作业,页面刷新后返回首页 |
| 海外仓工作台与待办统计 | `src/pages/home/home.vue`、`src/api/workbench.js` |
| 中英西语言设置 | `src/i18n/index.js`、`src/i18n/locales/`、`src/pages/profile/profile.vue` |
| 柜号签收、照片与入仓真实作业 | `src/pages/operation/operation.vue`、`src/components/ReceivingSessionSummary.vue`、`src/api/inbound.js` |
| 柜号签收、私人地址收货与照片 | `src/pages/operation/operation.vue`、`src/components/ReceivingSessionSummary.vue`、`src/api/inbound.js` |
| 退货入库签收与合箱 | `src/pages/return-receipt/return-receipt.vue`、`src/pages/return-packing/return-packing.vue`、`src/api/return-receipt.js` |
| 派送异常回仓签收 | `src/pages/return-receipt/return-receipt.vue` → `src/components/ExceptionReturnWork.vue`、`src/api/exception-returns.js` |
| 退货箱内清点 | `src/pages/return-counting/return-counting.vue`、`src/api/return-counting.js` |
| 仓储转运测量、打托与标签 | `src/pages/warehouse-transfer/warehouse-transfer.vue`、`src/api/warehouse-transfers.js` |
| 托盘管理真实作业 | `src/pages/pallet/pallet.vue`、`src/api/pallet.js` |
@@ -85,10 +87,10 @@
## 质量门禁
- 已发车详情按需挂载 `src/components/DispatchHandoverStatus.vue`,经 `src/api/trip-handover.js` 读当前派送资源/最近交接;进入/刷新各一次GET,列表不逐行请求。只读;后台确认交接,已完成派送留完成时资源。回归 `tests/trip-handover.test.mjs`。
- 发车后 `src/components/DispatchHandoverStatus.vue` 经 `src/api/trip-handover.js` 只读资源/交接;进入和刷新各一 GET,列表不逐行请求。交接由后台确认,完成后保留当时资源;回归 `tests/trip-handover.test.mjs`。
- 验证按 `../../docs/AI工程执行与协作约束.md`;文案看差异,小逻辑定向测试,不默认全量/构建/真机验收。`npm test` 为全量入口。
- 专项用 `node --test`:转运测量/打托/照片/标签行为变更选 `tests/warehouse-transfer-api.test.mjs`;退货签收/合箱行为变更选对应 `tests/return-receipt-api.test.mjs` / `tests/return-packing-api.test.mjs`;项目结构/工作台入口变化才加 `tests/project-contract.test.mjs` / `tests/workbench-availability.test.mjs`。离线不替代真实API、真机扫码/绑定/打印。
- 分层验证见 `../../docs/AI工程执行与协作约束.md`;`npm test` 为全量,不默认跑。
- 按行为用 `node --test`:转运选 `tests/warehouse-transfer-api.test.mjs`,退货签收/合箱选 `tests/return-receipt-api.test.mjs` / `tests/return-packing-api.test.mjs`;结构/工作台入口变更才加 `tests/project-contract.test.mjs` / `tests/workbench-availability.test.mjs`。离线不替代真实 API/扫码/绑定/打印。
- H5编译链 `vue-i18n` 固定 `9.1.9`,禁范围版本/单独升级;依赖变更同步 `package.json`、`package-lock.json`、现有 `pnpm-lock.yaml`,至少跑H5编译。
- 产品为海外PDA App;`npm run build:h5` 仅浏览器调试/模板门禁,App资源用 `npm run build:app`。APK/IPA须在HBuilderX/正式原生打包环境以独立AppID/签名验证,资源编译非APK真机验收。
- 改 `src/manifest.json` 图标映射或 `static/app-icons/` 跑 `node --test tests/app-icons.test.mjs`;禁缺声明尺寸、同一错尺寸占位、复制国内AppID/签名。
+25 -19
View File
@@ -54,6 +54,8 @@ npm run build:app
Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查海外 PDA 的独立更新通道,员工也可在“个人中心 → 检查更新”手动触发;H5 仅展示 App 操作提示,不执行更新。更新服务接收 `appid`、`platform`、`appVersion`、`appVersionCode`、`wgtVersion` 查询参数,无更新返回 `{ "update": false }`,有更新返回:
HBuilderX 真机调试及标准基座使用当前工程 `src/manifest.json` 中的 AppID、版本名和版本码模拟更新检查,避免把 HBuilderX 基座版本作为业务版本上报;正式 APK 仍使用实际安装包版本码及独立的 WGT 资源版本。调试检查同样只读取后台已发布版本,上传草稿不会触发更新;调试基座不能代替正式 APK 的覆盖安装与签名验收。
```json
{
"update": true,
@@ -75,34 +77,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 发布方式”;历史灰度报告不再作为发布入口。
执行 `npm run build:h5`。无需启动 Vite 开发服务器;`npm run build:app` 不属于 H5 发布。
发布构建使用 `/` 资源 base 与 hash 路由,部署到本应用独立域名根目录,浏览器入口为 `/#/`。不能放到子目录后继续共用根路径资源,也不能将 Vite 开发代理当作线上配置。
`deploy/Dockerfile.h5` 只复制已构建 `dist/build/h5/` 及静态 Nginx 配置,不在镜像中复制源码、依赖或重新编译。`.dockerignore` 只放行这两类输入。容器静态目录为 `/srv/pda-h5/`。
执行 `npm run build:h5` 导出 Web 静态文件;`npm run build:app` 不属于 H5 发布。
从本仓库执行 `docker compose -f deploy/docker-compose.h5.test.yml build --pull=false`,再由获授权的部署流程执行 `docker compose -f deploy/docker-compose.h5.test.yml up -d --wait --wait-timeout 120`。必须设置 `IMAGE_TAG`,`NGINX_BASE_IMAGE` 可指定已缓存的兼容 Alpine Nginx 镜像。容器名及网络别名为 `pda-overseas-h5-test`,仅在外部 `firstleg-test-network` 暴露容器 80,不发布宿主端口。健康检查同时请求 `/healthz` 与 `/` 的实际首页。
发布步骤:
独立域名入口将 `/` 代理至本应用静态容器根目录,或将构建目录内容直接发布到站点根目录;不再挂载 PDA 子路径。缺失资源返回 404,不回退首页,HTML 使用 `Cache-Control: no-store`。域名入口还须配置同源 API 代理:两端均将 `/gateway-api/` 转统一 Gateway `/api/`。入口须保留 Authorization,支持文件上传下载及至少 11m 请求体。域名与上游只在部署侧配置,不编入 PDA 源码。
1. 构建后执行 `node scripts/verify-h5.mjs`,核对 HTML/JS/CSS 资源路径及同源 API。构建、测试仍按根规则经 `local-check.ps1` 执行。
2. 将 `dist/build/h5/` **目录内的文件**上传到该 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;
}
```
域名入口及承接 API 的容器均须保留 `^~`,使 API 前缀优先于站点的敏感文件正则。若站点存在匹配 `LICENSE` 结尾的文件拦截规则,普通 `location /gateway-api/` 会导致驾驶证绑定路径 `loading-documents/DRIVER_LICENSE` 返回 Nginx HTML 404,而同槽位的 `/uploads` 仍可上传成功。TEST 静态容器不代理 API,应修改实际域名入口配置;仅更新应用静态包不能修复入口规则。获授权应用配置前先执行 `nginx -t`,重载后确认驾驶证绑定路径能进入 Gateway(未登录应返回 `401` JSON),再使用 PDA 登录态验证保存。
部署定向检查:`node --test tests/h5-deployment.test.mjs`,经 `local-check.ps1` 执行。该检查覆盖静态发布约束、资源 base 和开发代理重写,不替代目标主机 Nginx 校验、真实 HTTP、人工页面及 Android 真机验收。
-5
View File
@@ -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 dist/build/h5/ /srv/pda-h5/
EXPOSE 80
-5
View File
@@ -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 dist/build/h5/ /srv/pda-h5/
EXPOSE 80
-29
View File
@@ -1,29 +0,0 @@
name: firstleg-production-pda-overseas-h5
services:
pda-overseas-h5:
build:
context: ..
dockerfile: deploy/Dockerfile.h5.production
args:
NGINX_BASE_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
image: pda-overseas-h5-prod:${IMAGE_TAG:?IMAGE_TAG is required}
container_name: pda-overseas-h5-prod
restart: unless-stopped
environment:
API_UPSTREAM: "http://gateway:9000"
ports:
- "127.0.0.1:${HOST_PORT:-29006}:80"
healthcheck:
test: ["CMD-SHELL", "wget -qO /dev/null http://127.0.0.1/healthz && wget -qO /dev/null http://127.0.0.1/"]
interval: 5s
timeout: 3s
retries: 12
start_period: 5s
networks:
backend:
aliases:
- pda-overseas-h5-prod
networks:
backend:
external: true
name: firstleg-production-network
-27
View File
@@ -1,27 +0,0 @@
name: firstleg-test-pda-overseas-h5
services:
pda-overseas-h5:
build:
context: ..
dockerfile: deploy/Dockerfile.h5
args:
NGINX_BASE_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
image: pda-overseas-h5-test:${IMAGE_TAG:?IMAGE_TAG is required}
container_name: pda-overseas-h5-test
restart: unless-stopped
expose:
- "80"
healthcheck:
test: ["CMD-SHELL", "wget -qO /dev/null http://127.0.0.1/healthz && wget -qO /dev/null http://127.0.0.1/"]
interval: 5s
timeout: 3s
retries: 12
start_period: 5s
networks:
test-backend:
aliases:
- pda-overseas-h5-test
networks:
test-backend:
external: true
name: firstleg-test-network
-16
View File
@@ -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;
}
}
+21
View File
@@ -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;
}
-26
View File
@@ -1,26 +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";
}
# API优先于站点敏感文件正则,避免DRIVER_LICENSE被LICENSE规则误拦截。
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;
}
}
+5 -2
View File
@@ -25,7 +25,7 @@
| 商品移库真实作业 | `src/pages/move/move.vue`、`src/api/warehouse-operations.js`、`src/stores/warehouse-work.js` | 扫描来源托盘与目标库位执行同仓移库;冲突后重新读取服务端事实 |
| 换标真实作业 | `src/pages/relabel/relabel.vue`、`src/api/relabel.js` | 同一页面按操作参数承载换标收货、执行与复核;执行页按当前单明细切换 SKU/箱唛 Tab,组合明细同时显示两类需求。SKU 逐条扫码旧新SKU并携带审批件数,箱唛扫码仓库唛头/新FBA箱唛;组合单按同箱保留两个Tab的核对输入,全部核对后只提交一次箱事实。认领同步处理中;支持拍照、异常/返工和幂等恢复 |
| 现场查询真实作业 | `src/pages/query/query.vue`、`src/api/warehouse-operations.js` | 同一页面按操作参数承载库存、库位与托盘查询,只展示稳定业务标识 |
| 排车拣货与发车/理货审核 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 普通进入展示任务列表和“上次作业”,不因本地草稿自动打开详情;明确跳转 `?resume=1` 才恢复排车。默认任务列表含待理货排车,详情展示服务端车辆/司机快照、计划发车及预计送达时间、当前有效FBA收货确认、调度审核状态/意见和货物;时间明确标注北京时间。预约或审核依据失效时 `APPROVAL_BLOCKED` 禁止批准但保留驳回。仅 `actions.LOADING_REVIEW.allowed=true` 时可调用 `POST /api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-review`,提交原 `recordVersion/idempotencyKey/decision/reason`;驳回原因必填,任一级拒绝统一回客服处理(车辆信息),重新提交后再次逐级审核。PDA 不执行调度审核;理货通过并重读资格后才允许按实物托盘码或来源箱唛真实拣货,服务端 `actions.PICK/LOADING_COMPLETE` 分别决定真实拣货和装车完成资格;按已扫托数、箱数整车一次确认装车,不逐托填重量、体积或托位,资料未齐进入 `AFTER_LOADING`、已齐直接进入 `DELIVERING`;详情采用“拣货作业/装车拍照”两个 Tab,打开任务默认拣货;切换保留拣货草稿与照片状态,拍照页不响应拣货扫码,同单照片保存刷新保留当前 Tab。装车中即可在拍照 Tab 通过 `src/components/DispatchLoadingDocuments.vue` 上传五张整车照片,照片齐全且确认装车完成后由服务端在最后一项完成的事务自动生成派送并发车;不额外安排派送或确认发车。公网前缀 `/api/pda/v1/last-leg/dispatches/**`。回归 `tests/dispatch-workflow.test.mjs tests/fulfillment-workflows.test.mjs` |
| 排车拣货与发车/理货审核 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 普通进入展示任务列表和“上次作业”,不因本地草稿自动打开详情;明确跳转 `?resume=1` 才恢复排车。默认任务列表含待理货排车,详情展示服务端车辆/司机快照、计划发车及预计送达时间、当前有效FBA收货确认、调度审核状态/意见和货物;时间同时展示美东(America/New_York)与美西(America/Los_Angeles),按地区时区处理夏令时;只换算带时区的真实时间点,缺失时不补造时间。预约或审核依据失效时 `APPROVAL_BLOCKED` 禁止批准但保留驳回。仅 `actions.LOADING_REVIEW.allowed=true` 时可调用 `POST /api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-review`,提交原 `recordVersion/idempotencyKey/decision/reason`;驳回原因必填,任一级拒绝统一回客服处理(车辆信息),重新提交后再次逐级审核。PDA 不执行调度审核;理货通过并重读资格后才允许按实物托盘码或来源箱唛真实拣货,服务端 `actions.PICK/LOADING_COMPLETE` 分别决定真实拣货和装车完成资格;按已扫托数、箱数整车一次确认装车,不逐托填重量、体积或托位,资料未齐进入 `AFTER_LOADING`、已齐直接进入 `DELIVERING`;详情采用“拣货作业/装车拍照”两个 Tab,打开任务默认拣货;切换保留拣货草稿与照片状态,拍照页不响应拣货扫码,同单照片保存刷新保留当前 Tab。装车中即可在拍照 Tab 通过 `src/components/DispatchLoadingDocuments.vue` 上传五类整车照片(司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字,每类1至5张),照片齐全且确认装车完成后由服务端在最后一项完成的事务自动生成派送并发车;不额外安排派送或确认发车。公网前缀 `/api/pda/v1/last-leg/dispatches/**`。回归 `tests/dispatch-workflow.test.mjs tests/fulfillment-workflows.test.mjs` |
| 装车确认、自动派送与证照提醒 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js` 的 `completeDispatchLoading` | 一单一趟,使用 `dispatchNo`、公开 `recordVersion`(0 合法)及本次 `idempotencyKey`。只在请求中锁定,结束即释放;保留表单,不持久化或重放失败命令,下次点击按当前输入和版本生成新键。结果不明或 409 先刷新权威任务,不以超时推断未执行或成功。结果核对单号、动作、请求键和版本;理货审核通过须 `stage=LOADING`,驳回须 `stage=BEFORE_LOADING`;`LOADING_COMPLETE` 返回 `stage=AFTER_LOADING|DELIVERING`、Loaded 结果,提交版本、manifestHash 和幂等键,不逐托填测量。成功后重读任务,版本不得早于已确认结果;重读失败只刷新,不自动重复 POST。司机证照、资源及各客户派送资格由服务端校验;正式流程见工作区 `docs/海外仓业务/排车列表统一作业改造契约.md`,不使用独立车次发车请求 |
| 普通/一件代发出库 | `src/pages/outbound/outbound.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 首次进入清空扫描框并展示任务列表和“上次作业”标记,不自动恢复旧详情;详情提供独立返回列表入口。逐箱扫码、DP 托盘整托拣取及幂等完成保持不变;扫描排车业务号显式跳转排车 `?resume=1`。公网前缀 `/api/pda/v1/last-leg/outbound-tasks/**`;派送由 Admin/Client 承载 |
| 扫码设备与诊断 | `src/utils/scan-broadcast.js`、`src/pages/scanner-test/scanner-test.vue` | `src/components/ScanTargetInput.vue`、声音反馈工具;广播 Action/Extra 见本文件服务边界 |
@@ -61,4 +61,7 @@
- 库内、换标与现场查询只调用 `/api/pda/**` 业务 API/DTO,并统一受 `lastleg:pda:operate`、Token 冻结仓库范围和服务端资源归属约束;写请求携带本次幂等键,结束即释放操作态,409 后重新读取服务端状态,不得把平台、客户或内部接口直接暴露给 PDA。
- 柜号签收入仓调用 Lastleg `/api/pda/v1/last-leg/receiving-containers/**`;退货签收与合箱分别调用 `/api/pda/v1/last-leg/return-receipts/**`、`/api/pda/v1/last-leg/return-inventory/**`,具体入口见上表。浏览器或设备请求必须经统一 `gateway` 的显式 PDA 路由,不得直连 Lastleg。
- 海外业务 Token、PDA 权限和可信身份最终由 Gateway 与服务端校验;前端不得用工作台 `available`、本地客户 ID 或仓库 ID 代替授权和资源归属校验。
- 排车和出库入口只调用专用 PDA 接口;客户与绑定海外仓由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不展示内部数据库 ID 作为业务编号。动作资格、版本和失败处理见本文件排车条目,装车中可同步上传五张照片,装车确认与资料齐全两项均完成后服务端自动生成派送并发车,装完但资料未齐时留在装车后;照片API为 `/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents`,前端不补派送费用。完成成功后不得重复提交;PDA 不直接创建派送单,平台与客户的读取范围遵循现行契约。
- 排车和出库入口只调用专用 PDA 接口;客户与绑定海外仓由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不展示内部数据库 ID 作为业务编号。动作资格、版本和失败处理见本文件排车条目,装车中可同步上传五类照片,每类1至5张,装车确认与资料齐全两项均完成后服务端自动生成派送并发车,装完但资料未齐时留在装车后;照片API为 `/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents`,前端不补派送费用。完成成功后不得重复提交;PDA 不直接创建派送单,平台与客户的读取范围遵循现行契约。
- 排车拣货在提交库存扣减前调用 `/storage-rent/preparations` 完成仓租预校验和资金预占,预占回执未就绪或失败时不提交拣货;成功后仍只提交原排车库存命令,保留货源参数、箱数、版本和 `idempotencyKey`。预校验失败会释放未消费预占并保留错误诊断,不自动重放拣货。普通出库、整托出库和废弃确认沿各自现有流程;定向回归为 `tests/fulfillment-stock-commands.test.mjs`,覆盖排车仓租预校验、库存命令参数和失败传播。
- 废弃出库展示客户废弃备注。无关联箱唛库存扫描本单托盘后扫描现场箱唛,自动去重计数;多SKU先选择本次扫描货物,不手填实际数。每条 lines.scannedCartonMarks 与 actualPackages 一并提交,失败留箱唛/照片供刷新重试;后台保存实际扫描清单并回显废弃详情。有箱唛关联任务仍核对本单所选箱唛。
+14 -6
View File
@@ -1,6 +1,9 @@
import { overseasRequest, overseasUpload, overseasDownload } from './overseas.js'
export const loadingPhotoSlots = ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT']
// 保留既有五框标识以维持文件归属,界面按当前装车资料分类展示。
export const loadingPhotoLabels = { LOADING_SCENE: '司机驾驶证', LOADING_LIST: '车牌照片', PRE_DEPARTURE_CHECK: '装车完成', DRIVER_LICENSE: '封车照片', RECEIPT: 'BOL司机签字' }
export const loadingPhotoLimit = 5
const path = no => {
if (!no || typeof no !== 'string') throw new Error('请先打开排车任务')
return `/pda/v1/last-leg/dispatches/${encodeURIComponent(no)}/loading-documents`
@@ -10,16 +13,21 @@ function slotPath(no, slot) {
return `${path(no)}/${slot}`
}
export function normalizeLoadingDocuments(value, no) {
if (value?.dispatchNo !== no || !/^\d+$/.test(String(value.recordVersion ?? '')) || !Array.isArray(value.photos)
if (value?.dispatchNo !== no || !/^\d+$/.test(String(value.recordVersion ?? '')) || !/^[1-9]\d*$/.test(String(value.reviewRound ?? '')) || !Array.isArray(value.photos)
|| !['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(value.stage)) throw new Error('装车资料与当前排车事实不一致')
const slots = new Set()
const files = new Set(), counts = {}
for (const photo of value.photos) {
if (!loadingPhotoSlots.includes(photo.slot) || !photo.fileId || slots.has(photo.slot)) throw new Error('装车照片引用不完整或重复')
slots.add(photo.slot)
if (!loadingPhotoSlots.includes(photo.slot) || !photo.fileId || files.has(String(photo.fileId))) throw new Error('装车照片引用不完整或重复')
files.add(String(photo.fileId)); counts[photo.slot] = (counts[photo.slot] || 0) + 1
if (counts[photo.slot] > loadingPhotoLimit) throw new Error('每类装车照片最多5张')
}
return { ...value, editable: value.editable === true, complete: value.complete === true }
}
export async function getLoadingDocuments(no) { return normalizeLoadingDocuments(await overseasRequest('GET', path(no)), no) }
export function uploadLoadingPhoto(no, slot, filePath, key) { return overseasUpload(`${slotPath(no, slot)}/uploads?idempotencyKey=${encodeURIComponent(key)}`, filePath) }
export function uploadLoadingPhoto(no, slot, filePath, key, reviewRound) { return overseasUpload(`${slotPath(no, slot)}/uploads?idempotencyKey=${encodeURIComponent(key)}&reviewRound=${encodeURIComponent(reviewRound)}`, filePath) }
export async function bindLoadingPhoto(no, slot, body) { return normalizeLoadingDocuments(await overseasRequest('PUT', slotPath(no, slot), body), no) }
export function readLoadingPhoto(no, photo) { return overseasDownload(`${slotPath(no, photo.slot)}/files/${encodeURIComponent(photo.fileId)}/content`, { expectedContentTypePrefix: 'image/' }) }
export function readLoadingPhoto(no, photo) {
const pdf = photo.slot === 'LOADING_SCENE' && photo.contentType === 'application/pdf'
if (!pdf && !['image/jpeg', 'image/png'].includes(photo.contentType)) throw new Error('装车资料格式无效')
return overseasDownload(`${slotPath(no, photo.slot)}/files/${encodeURIComponent(photo.fileId)}/content`, { expectedContentTypePrefix: pdf ? 'application/pdf' : photo.contentType })
}
+7
View File
@@ -0,0 +1,7 @@
import { overseasRequest, overseasUpload } from './overseas.js'
const base = '/pda/v1/last-leg/return-receipts/exceptions'
const path = (number) => `${base}/${encodeURIComponent(number)}`
export const listExceptionReturns = () => overseasRequest('GET', base)
export const startExceptionReturn = (number) => overseasRequest('POST', `${path(number)}/start`, {})
export const uploadExceptionReturnEvidence = (number, filePath) => overseasUpload(`${path(number)}/evidence`, filePath)
export const receiveExceptionReturn = (number, body) => overseasRequest('POST', `${path(number)}/receive`, body)
+59 -6
View File
@@ -97,9 +97,27 @@ function dispatchWrite(path, request) {
requireDispatchVersion(request?.recordVersion)
return overseasRequest('POST', path, request)
}
export function pickDispatchItem(dispatchNo, dispatchItemId, request) {
// 库存命令与仓租预校验共用本次请求的防重标识。
function requireStockCommandIdentity(request) {
if (!request?.idempotencyKey) throw new Error('出库请求标识缺失,请重新进入任务')
}
export async function pickDispatchItem(dispatchNo, dispatchItemId, request) {
requireDispatchVersion(request?.recordVersion)
requireStockCommandIdentity(request)
await prepareStockRent({
mode: 'DISPATCH',
businessNo: dispatchNo,
itemId: dispatchItemId,
positionId: request.positionId,
packages: request.packages,
cartonMark: request.cartonMark || null,
idempotencyKey: request.idempotencyKey,
productQuantity: request.productQuantity || 0
})
return dispatchWrite(`${DISPATCH_BASE}/${encoded(dispatchNo)}/items/${encoded(dispatchItemId)}/pick`, request)
}
export function completeDispatchLoading(dispatchNo, request) {
return dispatchWrite(`${DISPATCH_BASE}/${encoded(dispatchNo)}/loading-complete`, request)
}
@@ -120,11 +138,13 @@ export function resolveOutboundTask(businessNo) {
return overseasRequest('GET', `${OUTBOUND_BASE}/resolve/${encoded(businessNo)}`)
}
export function pickOutboundTask(taskNo, request) {
export async function pickOutboundTask(taskNo, request) {
requireStockCommandIdentity(request)
return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/pick`, request)
}
export function pickOutboundPallet(taskNo, palletNo, request) {
export async function pickOutboundPallet(taskNo, palletNo, request) {
requireStockCommandIdentity(request)
return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/pallets/${encoded(palletNo)}/pick`, request)
}
@@ -148,7 +168,10 @@ export function normalizeCargoDiscardTask(value) {
const task = value || {}
return {
...task,
discardNo: String(task?.discardNo || ''),
remark: String(task?.remark || ''),
requestedPackages: task?.requestedPackages == null ? null : count(task.requestedPackages),
remainingPackages: task?.remainingPackages == null ? null : count(task.remainingPackages),
discardNo: String(task?.discardNo || ''),
outboundNo: String(task?.outboundNo || ''),
palletNo: String(task?.palletNo || ''),
scanMode: task?.scanMode === 'CARTON' ? 'CARTON' : 'PALLET',
@@ -163,6 +186,7 @@ export function normalizeCargoDiscardTask(value) {
plannedPackages: count(sku?.plannedPackages),
actualPackages: sku?.actualPackages == null ? null : count(sku.actualPackages),
actualQuantityEditable: sku?.actualQuantityEditable === true,
allowedCartonMarks: asList(sku?.allowedCartonMarks).map(String),
status: String(sku?.status || 'PENDING')
}))
}
@@ -176,8 +200,37 @@ export async function resolveCargoDiscardPallet(palletNo, discardNo) {
return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/resolve`, { palletNo, ...(discardNo ? { discardNo } : {}) }))
}
export async function confirmCargoDiscard(discardNo, palletNo, lines, photoFileIds, scannedCartonMarks = [], confirmAllCartons = false) {
return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, { lines, photoFileIds, scannedCartonMarks, confirmAllCartons }))
export async function confirmCargoDiscard(discardNo, palletNo, lines, photoFileIds, scannedCartonMarks = [], confirmAllCartons = false, request = {}) {
const body = { lines, photoFileIds, scannedCartonMarks, confirmAllCartons, idempotencyKey: request.idempotencyKey }
requireStockCommandIdentity(request)
// 出库前按同一份货物、照片和幂等键校验仓租,资金就绪后才提交库存扣减。
await prepareStockRent({ mode: 'DISCARD', businessNo: discardNo, palletNo, ...body })
return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, body))
}
/** 本次点击内只查询资金回执,不缓存或自动重放失败的出库命令。 */
async function prepareStockRent(body) {
const base = '/pda/v1/last-leg/storage-rent/preparations'
let preparationId
try {
let preparation = await overseasRequest('POST', base, body)
preparationId = preparation?.preparationId
for (let attempt = 0; preparation?.ready !== true && preparation?.status === 'FREEZE_PENDING' && preparationId && attempt < 20; attempt++) {
await new Promise(resolve => setTimeout(resolve, 500))
preparation = await overseasRequest('GET', `${base}/${encoded(preparationId)}`)
}
if (preparation?.ready !== true) {
const message = preparation?.message ? `仓租校验未通过:${preparation.message}` : '仓租资金预校验尚未通过,请稍后重新提交'
throw Object.assign(new Error(message), { code: 'A0409' })
}
} catch (error) {
// 尚未发送出库命令时释放已知的未使用预占,释放失败保留原错误诊断。
if (preparationId) {
try { await overseasRequest('DELETE', `${base}/${encoded(preparationId)}`) }
catch (releaseError) { console.error('[Overseas PDA stock] rent preparation release failed', { code: releaseError?.code, requestId: releaseError?.requestId, traceId: releaseError?.traceId }) }
}
throw error
}
}
const discardPhotoPath = (discardNo, palletNo) => `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/photos/files`
+7 -4
View File
@@ -115,7 +115,10 @@ export function isReceivingConflict(error) {
export function toReceivingError(error, fallback = '海外仓作业失败') {
const code = error?.code
const rawMessage = String(error?.message || '').trim()
const palletConflictMessages = {
const receivingMessages = {
'Carton mark must contain a non-empty inbound code prefix': '请扫描箱码或国内DP码',
'Pallet code is not part of the current FBA destination group': '托盘不属于当前目的仓,请重扫',
'Pallet code is not reserved for this inbound code': '该托盘未预留给当前入仓码,请扫描本票已预留托盘',
'Reserved pallet binding is incomplete': '预留托盘资料不完整,请联系管理员检查入仓计划',
'Reserved pallet customer binding is invalid': '预留托盘与当前客户不匹配,请联系管理员检查入仓计划',
'Scanned pallet does not match the reserved inbound plan resource': '扫描托盘与入仓计划预留资源不一致,请扫描已预留的托盘',
@@ -131,13 +134,13 @@ export function toReceivingError(error, fallback = '海外仓作业失败') {
'Pallet FBA destination does not match the inbound code': '该托盘的FBA目的仓与当前入仓码不一致,请更换托盘',
'FBA-designated pallet cannot receive an OVERSEAS destination carton': 'FBA专用托盘不能接收海外仓目的箱,请更换托盘'
}
const conflictMessage = ['409', 'A0409', 'B0409'].includes(String(code || ''))
? palletConflictMessages[rawMessage]
const businessMessage = ['404', 'A0404', 'B0404', '409', 'A0409', 'B0409'].includes(String(code || ''))
? receivingMessages[rawMessage]
: ''
const palletUnavailable = String(code || '') === 'S0503'
&& rawMessage === 'Warehouse pallet service returned no usable response'
const mapped = Object.assign(new Error(
conflictMessage || (palletUnavailable
businessMessage || (palletUnavailable
? '该托盘当前不可用于收货,请更换可用托盘或联系管理员检查托盘状态'
: rawMessage || fallback)
), {
+70
View File
@@ -0,0 +1,70 @@
import { listReceivingCandidates } from './inbound.js'
import { assertReceivingWarehouse } from './receiving-adapter.js'
import { listWarehouseTransferCandidates } from './warehouse-transfers.js'
import { listReturnCountTasks } from './return-counting.js'
import { listPackingPlans } from './return-packing.js'
import { listDispatchTasks, listOutboundTasks, listCargoDiscardTasks } from './fulfillment.js'
import { listRelabelCandidates } from './relabel.js'
export const WORKBENCH_COUNT_KEYS = ['receiving', 'warehouseTransferList', 'warehouseTransferWork', 'returnCounting', 'returnPacking', 'dispatchPicking', 'outboundPicking', 'relabelReceive', 'relabelExecute', 'relabelReview']
const ready = (count, unit = '单') => ({ status: 'ready', count, unit })
const distinctCount = (rows, key) => {
if (!Array.isArray(rows)) throw new Error('待办列表返回结构无效')
const codes = rows.map((row) => String(row?.[key] || '').trim())
if (codes.some((code) => !code)) throw new Error('待办列表缺少业务编号')
return new Set(codes).size
}
export async function loadWorkbenchCounts({ profile, activeTransferNo = '' }) {
const sources = [
{ keys: ['receiving'], load: async () => {
const rows = await listReceivingCandidates()
rows.forEach((row) => assertReceivingWarehouse(row, profile, '待收货柜'))
if (rows.some((row) => !row.sourceContainerBookingId || !row.containerNo)) throw new Error('待收货柜缺少业务定位信息')
const keys = rows.map((row) => ({ key: `${row.sourceContainerBookingId}:${row.containerNo}` }))
return { receiving: ready(distinctCount(keys, 'key'), '柜') }
} },
{ keys: ['warehouseTransferList', 'warehouseTransferWork'], load: async () => {
const rows = (await listWarehouseTransferCandidates()).filter((row) => ['WAIT_PDA_RECEIVING', 'PARTIALLY_RECEIVED'].includes(row.status))
const active = rows.filter((row) => String(row.transferNo).trim() === String(activeTransferNo).trim())
return { warehouseTransferList: ready(distinctCount(rows, 'transferNo')), warehouseTransferWork: ready(distinctCount(active, 'transferNo'), '单进行中') }
} },
{ keys: ['returnCounting'], load: async () => {
const value = await listReturnCountTasks({ page: 1, size: 1 })
const total = value?.total
if ((typeof total !== 'number' && !(typeof total === 'string' && /^\d+$/.test(total))) || !Number.isSafeInteger(Number(total)) || Number(total) < 0) throw new Error('退货清点待办总数无效')
return { returnCounting: ready(Number(total), '托') }
} },
{ keys: ['returnPacking'], load: async () => ({ returnPacking: ready(distinctCount((await listPackingPlans()).filter((row) => row.status === 'READY'), 'planNo'), '单计划') }) },
{ keys: ['dispatchPicking'], load: async () => {
const rows = (await listDispatchTasks()).filter((row) => row.status !== 'CANCELLED' && ['BEFORE_LOADING', 'LOADING', 'AFTER_LOADING'].includes(row.stage))
return { dispatchPicking: ready(distinctCount(rows, 'dispatchNo'), '单未发车') }
} },
{ keys: ['outboundPicking'], load: async () => {
const [outbounds, discards] = await Promise.all([listOutboundTasks('PENDING'), listCargoDiscardTasks()])
return { outboundPicking: { ...ready(distinctCount(outbounds, 'taskNo')), discardCount: distinctCount(discards.filter((row) => row.status !== 'COMPLETED'), 'discardNo') } }
} },
{ keys: ['relabelReceive', 'relabelExecute', 'relabelReview'], load: async () => {
const rows = await listRelabelCandidates()
return {
relabelReceive: ready(distinctCount(rows.filter((row) => !row.taskStatus || row.taskStatus === 'AVAILABLE'), 'relabelNo'), '单待接'),
relabelExecute: ready(distinctCount(rows.filter((row) => row.taskStatus === 'PROCESSING' && row.claimedByCurrentOperator === true), 'relabelNo'), '单进行中'),
relabelReview: ready(distinctCount(rows.filter((row) => row.taskStatus === 'WAIT_REVIEW'), 'relabelNo'), '单待复核')
}
} }
]
const results = await Promise.allSettled(sources.map((source) => source.load()))
return Object.assign({}, ...results.map((result, index) => result.status === 'fulfilled' ? result.value : Object.fromEntries(sources[index].keys.map((key) => [key, { status: 'error', error: result.reason }]))))
}
export function workbenchBadge(key, value) {
if (!WORKBENCH_COUNT_KEYS.includes(key)) return { label: key.endsWith('Query') ? '扫码查询' : key === 'returnReceipt' ? '扫码签收' : '扫码作业', tone: 'is-static' }
if (!value) return { label: '加载中…', tone: 'is-loading' }
if (value.status === 'error') return { label: '获取失败', tone: 'is-error' }
const hasWork = value.count > 0 || value.discardCount > 0
let label = `${value.count} ${value.unit}`
if (key === 'warehouseTransferWork' && value.count === 0) label = '未开始'
if (key === 'outboundPicking' && value.discardCount > 0) label = `${value.count} 出库 · ${value.discardCount} 废弃`
return { label, tone: hasWork ? 'has-work' : 'is-empty' }
}
+68 -25
View File
@@ -1,27 +1,28 @@
<template>
<view class="loading-documents">
<view class="heading">
<view class="heading-main"><text class="heading-title">装车照片</text><text class="heading-subtitle">每类 1 张,上传后自动保存,可更换</text></view>
<text class="heading-progress">{{ busy ? '同步中' : snapshot ? `${snapshot.photos.length}/5 已保存` : '待同步' }}</text>
<button class="heading-refresh" :disabled="busy" @tap="load">刷新</button>
<view class="heading-main"><text class="heading-title">装车照片</text></view>
<text class="heading-progress">{{ busy ? '同步中' : snapshot ? `${completedCategories}/5 类已保存` : '待同步' }}</text>
<button class="heading-refresh" :disabled="busy" @tap="refresh">刷新</button>
</view>
<text v-if="snapshot?.stage === 'LOADING'" class="section-hint">可边装车边上传;确认装车完成且照片齐全后自动进入派送中。</text>
<text v-if="snapshot?.stage === 'AFTER_LOADING'" class="section-hint">上传并保存 5 张装车照片后,本车自动进入派送中。</text>
<text v-if="needsRefresh" class="section-hint">保存状态尚未核实,请先刷新资料后再上传。</text>
<view v-if="error" class="error" role="alert"><text>{{ error.message }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text></view>
<view class="photo-list">
<view v-for="(slot, index) in slots" :key="slot" class="photo-card" :class="{ filled: snapshot?.photos.some(item => item.slot === slot) }">
<view class="photo-card-header">
<view class="photo-card-main">
<text class="photo-number">{{ snapshot?.photos.some(item => item.slot === slot) ? '✓' : index + 1 }}</text>
<view class="photo-card-title"><text class="photo-label">{{ slotLabels[slot] }}</text><text class="photo-status">{{ snapshot?.photos.some(item => item.slot === slot) ? '已保存 1 张' : '待上传' }}</text></view>
<view class="photo-card-title"><text class="photo-label">{{ slotLabels[slot] }}</text><text class="photo-status">{{ photosFor(slot).length }}/5 张已保存</text></view>
</view>
<button v-if="editable" class="photo-upload" :aria-label="`${snapshot?.photos.some(item => item.slot === slot) ? '更换' : '上传'}${slotLabels[slot]}`" :disabled="busy" @tap="choose(slot)">{{ snapshot?.photos.some(item => item.slot === slot) ? '更换图片' : '+ 上传图片' }}</button>
<button v-if="editable" class="photo-upload" :aria-label="`上传${slotLabels[slot]}`" :disabled="busy || photosFor(slot).length >= limit" @tap="choose(slot)">{{ photosFor(slot).length >= limit ? '已满 5 张' : '+ 上传图片' }}</button>
</view>
<view v-if="snapshot?.photos.some(item => item.slot === slot)" class="photo-preview-list">
<view v-for="photo in snapshot.photos.filter(item => item.slot === slot)" :key="photo.fileId" class="photo-preview">
<image v-if="images[photo.fileId]" class="photo-thumbnail" :src="images[photo.fileId]" mode="aspectFill" :aria-label="`预览${slotLabels[slot]}`" @tap="preview(images[photo.fileId])" />
<button v-if="photo.contentType === 'application/pdf'" class="photo-read-retry" :disabled="busy" @tap="viewPdf(photo)">查看驾驶证 PDF</button>
<image v-else-if="images[photo.fileId]" class="photo-thumbnail" :src="images[photo.fileId]" mode="aspectFill" :aria-label="`预览${slotLabels[slot]}`" @tap="preview(images[photo.fileId])" />
<button v-else class="photo-read-retry" :aria-label="`重新读取${slotLabels[slot]}`" :disabled="busy" @tap="load">图片读取失败,重试</button>
<text v-if="images[photo.fileId]" class="photo-preview-caption" :aria-label="`查看${slotLabels[slot]}大图`" @tap="preview(images[photo.fileId])">查看大图</text>
<button v-if="editable" class="photo-replace" :disabled="busy" :aria-label="`更换${slotLabels[slot]}图片`" @tap="choose(slot, photo.fileId)">更换</button>
</view>
</view>
</view>
@@ -31,43 +32,84 @@
<script>
import { session } from '@/stores/session.js'
import { toDispatchError } from '@/utils/dispatch-error.js'
import { loadingPhotoSlots, getLoadingDocuments, uploadLoadingPhoto, bindLoadingPhoto, readLoadingPhoto } from '@/api/dispatch-loading-documents.js'
import { loadingPhotoSlots, loadingPhotoLabels, loadingPhotoLimit, getLoadingDocuments, uploadLoadingPhoto, bindLoadingPhoto, readLoadingPhoto } from '@/api/dispatch-loading-documents.js'
export default {
props: { dispatchNo: { type: String, required: true } },
emits: ['saved', 'busy-change'],
data() { return { slots: loadingPhotoSlots, slotLabels: { LOADING_SCENE: '装车现场', LOADING_LIST: '装车清单', PRE_DEPARTURE_CHECK: '发车前检查', DRIVER_LICENSE: '驾驶证', RECEIPT: '回执' }, snapshot: null, busy: false, error: null, images: {}, active: true, revision: 0,
data() { return { slots: loadingPhotoSlots, slotLabels: loadingPhotoLabels, limit: loadingPhotoLimit, snapshot: null, busy: false, needsRefresh: false, error: null, images: {}, active: true, revision: 0,
scope: `${session.currentUser?.id || ''}:${session.currentUser?.warehouseId || ''}`, number: this.dispatchNo } },
computed: { editable() { return this.snapshot?.editable === true } },
computed: { editable() { return this.snapshot?.editable === true && !this.needsRefresh }, completedCategories() { return this.slots.filter(slot => this.photosFor(slot).length > 0).length } },
mounted() { this.load() },
beforeUnmount() { this.active = false; this.revision++; this.$emit('busy-change', false) },
methods: {
photosFor(slot) { return (this.snapshot?.photos || []).filter(photo => photo.slot === slot) },
ensureScope() { if (!this.active || this.number !== this.dispatchNo || this.scope !== `${session.currentUser?.id || ''}:${session.currentUser?.warehouseId || ''}`) throw new Error('作业身份已变化,请重新进入任务') },
key() { return `loading-${Date.now()}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}` },
async previews() {
await Promise.allSettled((this.snapshot?.photos || []).map(async photo => {
if (photo.contentType === 'application/pdf') return
if (this.images[photo.fileId]) return
const image = await readLoadingPhoto(this.number, photo); this.ensureScope(); this.images[photo.fileId] = image
}))
},
async load() { await this.run(async () => { const value = await getLoadingDocuments(this.number); this.ensureScope(); this.snapshot = value; await this.previews() }) },
async choose(slot) {
async refreshFacts() {
this.needsRefresh = true
const value = await getLoadingDocuments(this.number); this.ensureScope()
this.snapshot = value; this.needsRefresh = false; await this.previews()
},
async load() { await this.run(() => this.refreshFacts()) },
async refresh() { await this.run(async () => { await this.refreshFacts(); return true }, true) },
async choose(slot, replaceFileId = null) {
if (this.busy || !this.editable) return
const reviewRound = this.snapshot.reviewRound
const capacity = replaceFileId ? 1 : this.limit - this.photosFor(slot).length
if (capacity <= 0) return
let saved = 0, reconciled = false
await this.run(async () => {
const chosen = await uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['camera', 'album'] }); this.ensureScope()
const filePath = chosen.tempFilePaths?.[0]; if (!filePath) return
if (chosen.tempFiles?.[0]?.size > 10 * 1024 * 1024) throw new Error('请选择不超过10MiB的图片')
const photo = await uploadLoadingPhoto(this.number, slot, filePath, this.key()); this.ensureScope()
if (photo?.slot !== slot || !photo.fileId) throw new Error('上传结果与装车照片框不一致')
const latest = await getLoadingDocuments(this.number); this.ensureScope(); this.snapshot = latest
if (!this.editable) throw new Error('当前任务不可修改装车照片,请刷新任务查看')
const value = await bindLoadingPhoto(this.number, slot, { recordVersion: latest.recordVersion, idempotencyKey: this.key(), fileId: photo.fileId }); this.ensureScope()
this.snapshot = value; await this.previews()
uni.showToast({ title: value.stage === 'DELIVERING' && value.complete ? '资料齐全,已进入派送中' : '装车照片已保存', icon: 'none' })
return true
}, true)
const chosen = await uni.chooseImage({ count: capacity, sizeType: ['compressed'], sourceType: ['camera', 'album'] }); this.ensureScope()
const paths = chosen.tempFilePaths || []; if (!paths.length) return
if (paths.length > capacity) throw new Error(`本次最多可选择${capacity}张图片`)
if (chosen.tempFiles?.some(file => file.size > 10 * 1024 * 1024)) throw new Error('请选择不超过10MiB的图片')
// 逐张以最新版本绑定;部分失败保留前面已保存照片,不重放失败上传。
for (const filePath of paths) {
const uploadKey = this.key()
const photo = await uploadLoadingPhoto(this.number, slot, filePath, uploadKey, reviewRound); this.ensureScope()
if (photo?.slot !== slot || !photo.fileId) throw new Error('上传结果与装车照片框不一致')
const latest = await getLoadingDocuments(this.number); this.ensureScope(); this.snapshot = latest
if (latest.reviewRound !== reviewRound) { reconciled = true; throw new Error('审核轮次已变化,请核对新车辆、司机后重新选择资料') }
if (!this.editable) throw new Error('当前任务不可修改装车照片,请刷新任务查看')
try {
const value = await bindLoadingPhoto(this.number, slot, { recordVersion: latest.recordVersion, idempotencyKey: this.key(), fileId: photo.fileId, uploadKey, ...(replaceFileId ? { replaceFileId } : {}) }); this.ensureScope()
this.snapshot = value
} catch (cause) {
// 保存可能已成功;只重读最新事实,失败时必须先刷新再继续。
try {
await this.refreshFacts()
reconciled = this.snapshot.recordVersion !== latest.recordVersion || this.snapshot.dispatchRecordVersion !== latest.dispatchRecordVersion || this.snapshot.stage !== latest.stage
} catch { /* 保留原错误与刷新入口。 */ }
throw cause
}
saved++; await this.previews()
}
uni.showToast({ title: this.snapshot.stage === 'DELIVERING' && this.snapshot.complete ? '资料齐全,已进入派送中' : `已保存${saved}张照片`, icon: 'none' })
})
if ((saved || reconciled) && this.active) this.$emit('saved')
},
preview(url) { uni.previewImage({ current: url, urls: [url] }) },
async viewPdf(photo) {
await this.run(async () => {
const browser = typeof window !== 'undefined' && typeof document !== 'undefined'
const popup = browser ? window.open('', '_blank') : null
if (browser && !popup) throw new Error('请允许打开驾驶证预览窗口')
if (popup) popup.opener = null
try {
const path = await readLoadingPhoto(this.number, photo); this.ensureScope()
if (popup) popup.location.href = path
else await new Promise((resolve, reject) => uni.openDocument({ filePath: path, fileType: 'pdf', showMenu: true, success: resolve, fail: reject }))
} catch (cause) { if (popup) popup.close(); throw cause }
})
},
async run(work, notify = false) {
if (this.busy) return
const revision = ++this.revision
@@ -105,6 +147,7 @@ export default {
.photo-preview { box-sizing: border-box; width: calc(33.333% - 12rpx); min-width: 128rpx; padding: 8rpx; border: 2rpx solid #dbe5ef; border-radius: 16rpx; background: #fff; }
.photo-thumbnail { display: block; width: 100%; height: 136rpx; border-radius: 12rpx; background: #e7edf5; }
.photo-preview-caption { display: block; overflow: hidden; margin-top: 8rpx; color: #64748b; font-size: 18rpx; line-height: 28rpx; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
.photo-replace { margin: 8rpx 0 0; padding: 0; color: #174d91; font-size: 22rpx; line-height: 64rpx; background: #eef5ff; }
.photo-read-retry { display: flex; box-sizing: border-box; width: 100%; min-height: 136rpx; margin: 0; padding: 12rpx; align-items: center; justify-content: center; border: 0; border-radius: 12rpx; color: #174d91; font-size: 20rpx; line-height: 30rpx; background: #e7edf5; }
.error { margin-top: 16rpx; padding: 12rpx 16rpx; border-radius: 14rpx; color: #b91c1c; background: #fff1f0; }
.error text { display: block; font-size: 22rpx; line-height: 34rpx; overflow-wrap: anywhere; }
+82
View File
@@ -0,0 +1,82 @@
<template>
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
<view v-if="error" class="work-card"><text role="alert">{{ error }}</text><button :disabled="busy" @tap="refresh">刷新任务</button></view>
<template v-if="!current">
<view class="row-head"><button class="refresh-button" :disabled="busy" @tap="refresh">刷新</button></view>
<text v-if="!busy && !tasks.length" class="empty-copy">当前仓没有待验收异常退货任务</text>
<view v-for="task in tasks" :key="task.exceptionNo" class="work-card">
<text class="business-code">{{ task.exceptionNo }}</text>
<text class="meta-line">车牌号:{{ task.vehiclePlate || '未登记' }}</text>
<text class="meta-line">客户:{{ task.customerName || '—' }} · 待收 {{ task.packageCount - task.receivedPackages }} 箱</text>
<button class="primary-button" :disabled="busy" @tap="start(task)">开始作业</button>
</view>
</template>
<template v-else>
<view class="work-card"><text class="business-code">{{ current.order.exceptionNo }}</text><text class="meta-line">车牌号:{{ selectedTask.vehiclePlate || '未登记' }}</text><button :disabled="busy" @tap="leave">返回任务列表</button></view>
<view v-for="(item,index) in current.returnSources" :key="`${item.outboundPalletItemId}:${item.inventoryPositionId}:${item.returnCartonId}`" class="work-card">
<text class="card-title">{{ item.skuCode || '货物' }}</text><text class="meta-line">原托盘 / 箱唛:{{ item.cartonMark || item.palletNo || '—' }}</text>
<text class="meta-line">剩余可收 {{ item.maxReturnPackages }} 箱</text>
<input v-model="quantities[index]" type="number" placeholder="本批实收箱数" :disabled="busy" />
</view>
<view class="work-card"><text class="card-title">上架位置</text><input v-model="palletCode" placeholder="目标托盘号" :disabled="busy" /><input v-model="locationCode" placeholder="目标库位号" :disabled="busy" /></view>
<view class="work-card"><text class="card-title">实收照片</text><text v-for="photo in photos" :key="photo.fileId" class="meta-line">{{ photo.fileName }}</text><button :disabled="busy || photos.length >= 20" @tap="photo">拍照 / 选择照片</button></view>
<button class="primary-button" :disabled="busy || !ready" @tap="receive">{{ busy ? '处理中…' : '确认本批入库' }}</button>
</template>
<text v-if="message" class="meta-line" role="status">{{ message }}</text>
</view></scroll-view>
</template>
<script>
import { listExceptionReturns, startExceptionReturn, uploadExceptionReturnEvidence, receiveExceptionReturn } from '@/api/exception-returns.js'
export default {
emits: ['working-change'],
watch: {
current(value) { this.$emit('working-change', Boolean(value)) },
},
data() { return { tasks: [], current: null, selectedTask: null, quantities: [], palletCode: '', locationCode: '', photos: [], busy: false, error: '', message: '', revision: 0 } },
computed: {
ready() { return this.current && this.palletCode.trim() && this.locationCode.trim() && this.photos.length && this.quantities.some(value => Number(value) > 0) },
},
mounted() { this.refresh() },
beforeUnmount() { this.revision++ },
methods: {
// 页面切换后不再用迟到响应覆盖另一张作业单。
async run(action) { if (this.busy) return; const revision = this.revision; this.busy = true; this.error = ''; try { await action(() => revision === this.revision) } catch (error) { if (revision === this.revision) this.error = error.message || '操作失败,请刷新核对实际进度' } finally { if (revision === this.revision) this.busy = false } },
async refresh() { await this.run(async valid => { const tasks = await listExceptionReturns(); if (valid()) this.tasks = tasks }) },
async start(task) { await this.run(async valid => { const value = await startExceptionReturn(task.exceptionNo); if (valid()) { this.current = value; this.selectedTask = task; this.quantities = []; this.photos = []; this.palletCode = ''; this.locationCode = ''; this.message = '' } }) },
async leave() { if (this.busy) return; this.current = null; this.photos = []; await this.refresh() },
async photo() { await this.run(async valid => { const result = await new Promise((resolve,reject) => uni.chooseImage({ count: 1, sourceType: ['camera','album'], success: resolve, fail: reject })); const uploaded = await uploadExceptionReturnEvidence(this.current.order.exceptionNo,result.tempFilePaths[0]); if (valid()) this.photos.push(uploaded) }) },
async receive() {
if (!this.ready) return
await this.run(async valid => {
const items = this.current.returnSources.flatMap((item,index) => {
const packages = Number(this.quantities[index] || 0)
if (!Number.isSafeInteger(packages) || packages < 0 || packages > item.maxReturnPackages || item.returnCartonId && packages > 1) throw new Error('实收箱数必须为整数且不超过剩余可收数量')
return packages ? [{ outboundPalletItemId: item.outboundPalletItemId, inventoryPositionId: item.inventoryPositionId, returnCartonId: item.returnCartonId, packages }] : []
})
const number = this.current.order.exceptionNo
const result = await receiveExceptionReturn(number,{ expectedVersion: this.current.order.version, idempotencyKey: `exception-return-${Date.now()}-${Math.random().toString(36).slice(2)}`, palletCode: this.palletCode.trim(), locationCode: this.locationCode.trim(), items, evidenceFileIds: this.photos.map(photo => photo.fileId) })
if (!valid()) return
this.quantities = []; this.photos = []; this.message = `本批已入库 ${result.restoredPackages} 箱${result.status === 'COMPLETED' ? ',异常处理完成' : ',还有货物待收'}`
if (result.status === 'COMPLETED') { this.current = null; this.tasks = await listExceptionReturns() }
else this.current = await startExceptionReturn(number)
})
// 结果不明时重新读取权威版本,禁止使用旧快照重复累计数量。
if (this.error && this.current) {
try { this.current = await startExceptionReturn(this.current.order.exceptionNo); this.quantities = [] }
catch (_) { this.current = null; this.tasks = []; }
}
},
},
}
</script>
<style scoped>
.work-scroll { flex: 1; height: 0; min-height: 0; }
.work-content { padding-bottom: calc(24px + env(safe-area-inset-bottom)); }
.row-head { display: flex; justify-content: flex-end; margin-bottom: 12px; }
.refresh-button { margin: 0; padding: 0 16px; border: 1px solid #b8d1f5; border-radius: 8px; color: #135ee8; background: #f8fbff; }
.work-card { margin: 12px 0; padding: 14px; border: 1px solid #dfe7ef; border-radius: 12px; background: #fff; }
.business-code,.meta-line,.card-title { display: block; margin-bottom: 10px; }
.business-code,.card-title { font-weight: 700; }.meta-line { color: #61758b; font-size: 13px; }
input { height: 44px; margin: 8px 0; padding: 0 10px; border: 1px solid #cbd7e4; border-radius: 8px; }
button { margin-top: 10px; font-size: 14px; }.primary-button { color: #fff; background: #1668dc; }.primary-button[disabled] { background: #a2b2c8; }
</style>
-2
View File
@@ -13,7 +13,6 @@
<view class="header-menu-sheet">
<view class="header-menu-content">
<PopupHeader title="功能菜单" @close="closeMenu" />
<button class="header-menu-item" @tap="goHistory"><uni-icons type="list" color="#334155" size="22" /><text>作业记录</text><uni-icons type="right" color="#94a3b8" size="18" /></button>
<button class="header-menu-item" @tap="goProfile"><uni-icons type="person" color="#334155" size="22" /><text>个人中心</text><uni-icons type="right" color="#94a3b8" size="18" /></button>
<button class="header-menu-close" @tap="closeMenu">关闭</button>
</view>
@@ -39,7 +38,6 @@
methods: {
goBack() { navigateBackOr(this.fallbackUrl) },
goProfile() { this.closeMenu(); navigateToUnique('/pages/profile/profile') },
goHistory() { this.closeMenu(); navigateToUnique('/pages/history/history') },
openMenu() { this.$refs.menuPopup.open() },
closeMenu() { if (this.$refs.menuPopup) this.$refs.menuPopup.close() },
handleMenuChange(event) { this.$emit('menu-change', Boolean(event.show)) }
+6 -3
View File
@@ -1,7 +1,7 @@
<template>
<view class="receiving-summary">
<view class="receiving-summary-head">
<view><text class="receiving-summary-label">当前柜号</text><text class="receiving-summary-container">{{ session?.containerNo || '—' }}</text></view>
<view class="receiving-summary-identity"><text class="receiving-summary-label">当前柜号</text><text class="receiving-summary-container">{{ session?.containerNo || '—' }}</text></view>
<text class="receiving-summary-status">{{ statusLabel }}</text>
</view>
<view class="receiving-summary-context">
@@ -45,8 +45,11 @@
</script>
<style scoped>
.receiving-summary{box-sizing:border-box;margin-bottom:10px;padding:10px 12px;border:1px solid #b8d2f3;border-radius:13px;background:linear-gradient(145deg,#fff,#f2f7ff);box-shadow:0 3px 12px rgba(31,74,125,.08)}
.receiving-summary-head,.receiving-summary-progress>view:first-child{display:flex;align-items:center;justify-content:space-between;gap:10px}
.receiving-summary{box-sizing:border-box;width:100%;min-width:0;max-width:100%;margin-bottom:10px;padding:10px 12px;border:1px solid #b8d2f3;border-radius:13px;background:linear-gradient(145deg,#fff,#f2f7ff);box-shadow:0 3px 12px rgba(31,74,125,.08)}
.receiving-summary-head{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start;gap:10px}
.receiving-summary-progress>view:first-child{display:flex;align-items:center;justify-content:space-between;gap:10px}
.receiving-summary-identity{min-width:0;max-width:100%}
.receiving-summary-container{width:100%;min-width:0;max-width:100%;white-space:normal;overflow-wrap:anywhere;word-break:break-all}
.receiving-summary-label,.receiving-summary-container,.metric-value,.metric-label{display:block}.receiving-summary-label{color:#718095;font-size:9px;line-height:13px}.receiving-summary-container{margin-top:1px;color:#17324d;font-size:17px;font-weight:850;line-height:22px}
.receiving-summary-status{flex-shrink:0;padding:4px 8px;border-radius:99px;color:#1668dc;font-size:9px;font-weight:700;background:#e3efff}
.receiving-summary-context{display:flex;flex-direction:column;gap:4px;margin-top:7px;color:#617389;font-size:9px;line-height:14px}.receiving-summary-destinations{display:flex;min-width:0;align-items:flex-start}.destination-label{flex-shrink:0}.destination-tags{display:flex;min-width:0;flex-wrap:wrap;gap:3px 4px}.destination-tags text{padding:0 5px;border-radius:99px;color:#42617f;line-height:14px;background:#e8f0fa;white-space:nowrap}
+6 -6
View File
@@ -1,15 +1,15 @@
export const MODULES = [
{ key: 'inbound', name: '入仓作业', nameKey: 'modules.inbound.name', icon: '入', description: '处理拆柜收货与客户退货实收入仓', descriptionKey: 'modules.inbound.description', operations: [
{ key: 'receiving', name: '拆柜收货', nameKey: 'modules.receiving.name', icon: '卸', iconType: 'download', description: '三类照片签收,箱码定位入仓码后按目的类型收货', descriptionKey: 'modules.receiving.description', available: true },
{ key: 'warehouseTransferList', name: '仓储转运测量列表', nameKey: 'modules.warehouseTransferList.name', icon: '列', iconType: 'list', description: '查看待测量的仓储转运单', descriptionKey: 'modules.warehouseTransferList.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer?mode=list' },
{ key: 'warehouseTransferWork', name: '转运测量作业', nameKey: 'modules.warehouseTransferWork.name', icon: '转', iconType: 'compose', description: '进入转运单详情完成测量、打托和拍照', descriptionKey: 'modules.warehouseTransferWork.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer?mode=work' },
{ key: 'warehouseTransferList', name: '仓储转运测量', nameKey: 'modules.warehouseTransferList.name', icon: '列', iconType: 'list', description: '查看待测量的仓储转运单', descriptionKey: 'modules.warehouseTransferList.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer?mode=list' },
{ key: 'warehouseTransferWork', name: '转运测量', nameKey: 'modules.warehouseTransferWork.name', icon: '转', iconType: 'compose', description: '进入转运单详情完成测量、打托和拍照', descriptionKey: 'modules.warehouseTransferWork.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer?mode=work' },
{ key: 'returnReceipt', name: '退货入库', nameKey: 'modules.returnReceipt.name', icon: '退', iconType: 'undo', description: '扫描退货单或 SKU,按本包实收数量生成退货库存', descriptionKey: 'modules.returnReceipt.description', available: true, route: '/pages/return-receipt/return-receipt' },
{ key: 'returnPacking', name: '退货合箱', nameKey: 'modules.returnPacking.name', icon: '合', iconType: 'folder-add', description: '选择同客户已签收退货单,按 SKU 数量生成新箱', descriptionKey: 'modules.returnPacking.description', available: true, route: '/pages/return-packing/return-packing' },
{ key: 'returnCounting', name: '退货箱内清点', icon: '清', iconType: 'list', description: '逐箱清点托盘签收货物,核对 SKU 和数量', available: true, route: '/pages/return-counting/return-counting' }
{ key: 'returnPacking', name: '合箱', nameKey: 'modules.returnPacking.name', icon: '合', iconType: 'folder-add', description: '选择同客户已签收退货单,按 SKU 数量生成新箱', descriptionKey: 'modules.returnPacking.description', available: true, route: '/pages/return-packing/return-packing' },
{ key: 'returnCounting', name: '退货清点', icon: '清', iconType: 'list', description: '逐箱清点托盘签收货物,核对 SKU 和数量', available: true, route: '/pages/return-counting/return-counting' }
] },
{ key: 'outbound', name: '出库履约', nameKey: 'modules.outbound.name', icon: '出', description: '排车拣货、出库任务与发车闭环', descriptionKey: 'modules.outbound.description', operations: [
{ key: 'dispatchPicking', name: '排车拣货', nameKey: 'modules.dispatchPicking.name', icon: '车', iconType: 'paperplane', description: '理货审核通过后真实拣货,确认装车完成即进入派送中', descriptionKey: 'modules.dispatchPicking.description', available: true, route: '/pages/dispatch/dispatch' },
{ key: 'outboundPicking', name: '出库作业', nameKey: 'modules.outboundPicking.name', icon: '拣', iconType: 'checkbox', description: '处理普通出库、一件代发和废弃订单,按托盘扫码完成', descriptionKey: 'modules.outboundPicking.description', available: true, route: '/pages/outbound/outbound' }
{ key: 'dispatchPicking', name: '拣货', nameKey: 'modules.dispatchPicking.name', icon: '车', iconType: 'paperplane', description: '理货审核通过后真实拣货,确认装车完成即进入派送中', descriptionKey: 'modules.dispatchPicking.description', available: true, route: '/pages/dispatch/dispatch' },
{ key: 'outboundPicking', name: '出库', nameKey: 'modules.outboundPicking.name', icon: '拣', iconType: 'checkbox', description: '处理普通出库、一件代发和废弃订单,按托盘扫码完成', descriptionKey: 'modules.outboundPicking.description', available: true, route: '/pages/outbound/outbound' }
] },
{ key: 'inventory', name: '库内作业', nameKey: 'modules.inventory.name', icon: '库', description: '商品移库与托盘管理', descriptionKey: 'modules.inventory.description', operations: [
{ key: 'move', name: '商品移库', nameKey: 'modules.move.name', icon: '移', iconType: 'loop', description: '扫描来源与目标库位完成移库', descriptionKey: 'modules.move.description', available: true, route: '/pages/move/move' },
+82 -34
View File
@@ -1,7 +1,7 @@
<template>
<view v-if="user" class="page-shell portal-page fulfillment-page">
<PortalHeader title="排车作业" :user="user" />
<scroll-view class="fulfillment-scroll" scroll-y><view class="portal-content fulfillment-content">
<scroll-view class="fulfillment-scroll" scroll-y><view class="portal-content fulfillment-content" :class="{ 'pda-task-list': !current }">
<view class="fulfillment-heading"><text class="fulfillment-title">理货审核、真实拣货与装车</text></view>
<view v-if="current" class="detail-toolbar"><text class="detail-title">{{ current.dispatchNo }}</text><button :disabled="busy" @tap="closeTask">‹ 返回列表</button></view>
<view v-if="current" class="stage-switch" role="tablist" aria-label="排车作业">
@@ -10,16 +10,20 @@
</view>
<view v-if="!current || activeTab === 'picking'" class="fulfillment-card dispatch-scan-card">
<text class="card-title">{{ current ? '扫码拣货' : '扫码查询排车' }}</text>
<ScanTargetInput name="dispatch" :model-value="scanCode" label="排车单号/实物托盘码/来源箱唛" placeholder="扫描业务单号或实物码" :active="activeTab === 'picking'" @update:modelValue="scanCode = $event" @submit="handleScan" />
<ScanTargetInput name="dispatch" :model-value="scanCode" label="排车单号/实物托盘码/来源箱唛" placeholder="扫托盘码" :active="activeTab === 'picking'" @update:modelValue="scanCode = $event" @submit="handleScan" />
<button class="secondary-button" :disabled="busy || loading || !scanCode" @tap="handleScan(scanCode)">匹配扫描</button>
</view>
<view v-if="loading" class="fulfillment-state"><text>正在加载排车事实…</text></view>
<view v-if="error" class="fulfillment-error" role="alert"><text class="state-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><text v-if="error.network">网络中断,请稍后重新操作。</text><button :disabled="busy || loading" @tap="retry">重新核对</button></view>
<template v-if="!current && !loading">
<view class="list-toolbar"><text class="list-title">排车任务</text><button :disabled="busy" @tap="loadTasks">刷新</button></view>
<view class="list-toolbar pda-list-toolbar"><text>排车任务<text v-if="!error"> · {{ tasks.length }} 单</text></text><button :disabled="busy" @tap="loadTasks"><uni-icons type="refresh" color="#135ee8" size="18" /><text>刷新</text></button></view>
<view v-if="!tasks.length && !error" class="fulfillment-state">暂无排车任务,可扫描排车单号读取。</view>
<view v-if="tasks.length" class="task-list">
<button v-for="task in tasks" :key="task.dispatchNo" class="task-card" @tap="openTask(task.dispatchNo)"><text class="business-code">{{ task.dispatchNo }}</text><text v-if="task.dispatchNo === recentTaskNo" class="recent-badge">上次作业</text><text class="status-badge">{{ stageLabel(task) }}</text><text class="task-meta">任务号 {{ task.taskNo || '—' }}</text></button>
<view v-if="tasks.length" class="task-list pda-list-cards">
<button v-for="task in tasks" :key="task.dispatchNo" class="task-card pda-task-card" hover-class="pda-task-card-pressed" @tap="openTask(task.dispatchNo)">
<view class="pda-task-head"><text class="pda-task-no">{{ task.dispatchNo }}</text><text class="status-badge pda-task-status">{{ stageLabel(task) }}</text></view>
<text v-if="String(task.taskNo || '').trim() && String(task.taskNo || '').trim() !== String(task.dispatchNo || '').trim()" class="pda-task-meta">任务号 {{ task.taskNo }}</text>
<view class="pda-task-footer"><text v-if="task.dispatchNo === recentTaskNo" class="recent-badge">上次作业</text><view class="pda-task-action"><text>详情</text><uni-icons type="right" color="#135ee8" size="14" /></view></view>
</button>
</view>
</template>
<template v-if="current">
@@ -28,28 +32,30 @@
<DispatchHandoverStatus v-if="departed" :dispatch-no="current.dispatchNo" :record-version="current.recordVersion" />
<view class="fulfillment-card summary-card">
<text class="card-title">任务摘要</text>
<view class="task-head"><text class="business-code">{{ current.dispatchNo }}</text><text class="status-badge">{{ stageLabel(current) }}</text></view>
<text class="task-meta">任务号 {{ current.taskNo || '—' }}</text>
<text v-if="!gate('PICK') && current.stage === 'LOADING'" class="gate-warning">{{ current.actions.PICK?.blockReason || '拣货资格尚未允许,请核对两级审核及当前作业状态' }}</text>
<view class="task-head"><text class="status-badge">{{ stageLabel(current) }}</text></view>
<text class="picking-section-label">拣货进度</text>
<view class="metrics" aria-label="拣货进度"><view><text class="metric-value">{{ plannedPackages }}</text><text>计划箱数</text></view><view><text class="metric-value success">{{ pickedPackages }}</text><text>真实已拣</text></view><view><text class="metric-value warning">{{ remainingPackages }}</text><text>未拣箱数</text></view></view>
</view>
<view v-if="current.stage === 'BEFORE_LOADING'" class="fulfillment-card review-card">
<text class="card-title">理货员审核</text>
<text class="task-meta">车牌号:{{ current.resources?.vehicle?.plate || '—' }} · 托车公司:{{ current.resources?.vehicle?.carrierName || '—' }}</text>
<text class="task-meta">司机:{{ current.resources?.driver?.name || '—' }} · 电话:{{ current.resources?.driver?.phone || '—' }}</text>
<text class="task-meta">计划发车(北京时间):{{ reviewTime(current.plannedDepartureAt) }}</text>
<text class="task-meta">预计送达(北京时间):{{ reviewTime(current.estimatedArrivalAt) }}</text>
<text v-for="appointment in current.fbaAppointments || []" :key="appointment.destinationKey" class="task-meta">{{ appointment.destinationWarehouseCode }} · {{ appointment.appointmentNo || '—' }} · {{ appointment.status === 'SUCCESS' ? '已确认' : appointment.status === 'REGISTERED' ? '已登记' : '未填写' }}:{{ reviewTime(appointment.confirmedAt) }}(北京时间)</text>
<text class="task-meta">驾驶证号:{{ current.resources?.driver?.evidence?.licenseNo || '—' }} · 有效期:{{ current.resources?.driver?.evidence?.licenseExpiresOn || '—' }}</text>
<text class="task-meta">调度审核:{{ reviewLabel(current.reviews?.dispatchReview) }} · {{ current.reviews?.dispatchReview?.reviewedAt || '—' }}</text>
<text v-if="current.reviews?.dispatchReview?.reason" class="task-meta">调度意见:{{ current.reviews.dispatchReview.reason }}</text>
<view class="review-facts">
<view class="review-fact"><text class="review-label">车牌号</text><text>{{ current.resources?.vehicle?.plate || '—' }}</text></view>
<view class="review-fact"><text class="review-label">托车公司</text><text>{{ current.resources?.vehicle?.carrierName || '—' }}</text></view>
<view class="review-fact"><text class="review-label">司机</text><text>{{ current.resources?.driver?.name || '—' }}</text></view>
<view class="review-fact"><text class="review-label">电话</text><text>{{ current.resources?.driver?.phone || '—' }}</text></view>
<view class="review-fact"><text class="review-label">计划发车</text><text class="review-time">{{ reviewTime(current.plannedDepartureAt) }}</text></view>
<view class="review-fact"><text class="review-label">预计送达</text><text class="review-time">{{ reviewTime(current.estimatedArrivalAt) }}</text></view>
<view v-for="appointment in current.fbaAppointments || []" :key="appointment.destinationKey" class="review-fact"><text class="review-label">{{ appointment.destinationWarehouseCode }}</text><view class="review-appointment"><text>{{ appointment.appointmentNo || '—' }}</text><text>{{ appointment.status === 'SUCCESS' ? '已确认' : appointment.status === 'REGISTERED' ? '已登记' : '未填写' }}</text><text class="review-time">{{ reviewTime(appointment.confirmedAt) }}</text></view></view>
<view class="review-fact"><text class="review-label">驾驶证号</text><text>{{ current.resources?.driver?.evidence?.licenseNo || '—' }}</text></view>
<view class="review-fact"><text class="review-label">有效期</text><text>{{ current.resources?.driver?.evidence?.licenseExpiresOn || '—' }}</text></view>
<view class="review-fact"><text class="review-label">调度审核</text><view class="review-appointment"><text>{{ reviewLabel(current.reviews?.dispatchReview) }}</text><text class="review-time">{{ reviewTime(current.reviews?.dispatchReview?.reviewedAt) }}</text></view></view>
<view v-if="current.reviews?.dispatchReview?.reason" class="review-fact"><text class="review-label">调度意见</text><text>{{ current.reviews.dispatchReview.reason }}</text></view>
</view>
<text v-if="rejectionReason" class="gate-warning">已退回客服处理(车辆信息):{{ rejectionReason }}。修改后须重新提交审核。</text>
<template v-if="canReview">
<text v-if="current.actions.LOADING_REVIEW?.blockCode === 'APPROVAL_BLOCKED'" class="gate-warning">{{ current.actions.LOADING_REVIEW.blockReason }}</text>
<text class="task-meta">请核对车辆、司机及下方货物;通过后才能开始真实拣货。驳回将回到客服处理(车辆信息)。</text>
<textarea :value="reviewReason" maxlength="1000" placeholder="审核意见(驳回时必填)" :disabled="busy || loading" @input="updateReviewReason" />
<view class="count-row"><button :disabled="busy || loading || !reviewReason.trim()" @tap="submitReview('REJECTED')">驳回至客服处理</button><button class="primary-button" :disabled="busy || loading || current.actions.LOADING_REVIEW?.blockCode === 'APPROVAL_BLOCKED'" @tap="submitReview('APPROVED')">审核通过</button></view>
<view class="review-actions"><button :disabled="busy || loading || !reviewReason.trim()" @tap="submitReview('REJECTED')">驳回</button><button class="primary-button" :disabled="busy || loading || current.actions.LOADING_REVIEW?.blockCode === 'APPROVAL_BLOCKED'" @tap="submitReview('APPROVED')">审核通过</button></view>
</template>
<text v-else-if="!rejectionReason" class="gate-warning">{{ current.actions.LOADING_REVIEW?.blockReason || '当前不具备理货审核资格,请先完成客服提交和调度审核' }}</text>
</view>
@@ -72,16 +78,24 @@
<view v-else class="picking-quantity"><text class="picking-instruction">拣货箱数</text><input type="number" aria-label="本次拣货箱数" :value="packages" :placeholder="`请输入 1-${selectedRemaining} 箱`" :disabled="busy" @input="updatePackages" /><text class="task-meta">本次最多可拣 {{ selectedRemaining }} 箱</text></view>
<button class="primary-button" :disabled="!canPick || busy" @tap="submitPick">确认真实拣货</button>
</view>
<view v-if="current.stage === 'LOADING'" class="fulfillment-card gate-card picking-completion"><text class="card-title">确认装车完成</text><text class="picking-instruction">{{ current.pickingReconciled ? 'PDA拣货已核平,可边装车边上传5张装车照片;确认装车完成且照片齐全后自动进入派送中。' : '先完成全部真实拣货,不能补录数量代替核平。' }}</text><text v-if="!gate('LOADING_COMPLETE')" class="gate-warning">{{ current.actions.LOADING_COMPLETE?.blockReason || '尚未获得装车完成资格' }}</text><button class="primary-button" :disabled="!canComplete || busy" @tap="prepareLoading">确认装车完成</button></view>
<view v-if="current.stage === 'LOADING'" class="fulfillment-card gate-card picking-completion"><text v-if="!gate('LOADING_COMPLETE')" class="gate-warning">{{ current.actions.LOADING_COMPLETE?.blockReason || '尚未获得装车完成资格' }}</text><button class="primary-button" :disabled="!canComplete || busy" @tap="prepareLoading">确认装车完成</button></view>
</view>
</template><view v-if="successMessage" class="fulfillment-success" role="status">{{ successMessage }}</view>
</view></scroll-view>
<view v-if="scanNotice" class="loading-overlay scan-notice-overlay">
<view class="loading-dialog scan-notice-dialog" role="status" aria-live="polite">
<text class="loading-dialog-title">托盘扫描成功</text>
<text class="scan-notice-pallet">{{ scanNotice.palletNo }}</text>
<text class="scan-notice-label">本次可拣箱数</text>
<text class="scan-notice-count">{{ scanNotice.packages }}<text class="loading-count-unit"> 箱</text></text>
<text class="scan-notice-hint">3 秒后自动关闭</text>
</view>
</view>
<view v-if="loadingPreview" class="loading-overlay">
<view class="loading-dialog" role="dialog" aria-modal="true" aria-labelledby="loading-confirm-title">
<view class="loading-dialog-heading"><text id="loading-confirm-title" class="loading-dialog-title">确认装车完成</text><text class="loading-dialog-number">{{ loadingPreview.dispatchNo }}</text></view>
<view class="loading-dialog-heading"><text id="loading-confirm-title" class="loading-dialog-title">确认装车</text></view>
<view class="loading-counts"><view class="loading-count"><text class="loading-count-value">{{ loadingRows.length }}<text class="loading-count-unit"> 托</text></text><text class="loading-count-label">已拣托盘</text></view><view class="loading-count"><text class="loading-count-value">{{ loadingRows.reduce((sum, row) => sum + Number(row.pickedPackages || 0), 0) }}<text class="loading-count-unit"> 箱</text></text><text class="loading-count-label">已拣箱数</text></view></view>
<text class="loading-confirm-question">本车货物已全部装车?</text><text class="loading-confirm-note">照片可在“装车拍照”中继续上传</text>
<view class="loading-dialog-actions"><button class="loading-cancel" :disabled="busy" @tap="loadingPreview = null">取消</button><button class="loading-confirm" :disabled="busy" :loading="busy" @tap="completeLoading">确认装车</button></view>
<view class="loading-dialog-actions"><button class="loading-cancel" :disabled="busy" @tap="loadingPreview = null">取消</button><button class="loading-confirm" :disabled="busy" :loading="busy" @tap="completeLoading">确认</button></view>
</view>
</view>
</view>
@@ -99,7 +113,7 @@ import { toDispatchError } from '@/utils/dispatch-error.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
export default {
components: { PortalHeader, ScanTargetInput, DispatchHandoverStatus, DispatchLoadingDocuments },
data() { return { user: session.currentUser, tasks: [], current: null, activeTab: 'picking', scanCode: '', loading: false, requestBusy: false, photoBusy: false, error: null, successMessage: '', bootstrapped: false, resumeOnEntry: false, generation: 0, loadingPreview: null, loadingRows: [], sourceChoices: [], choiceCartonCode: '', reviewReason: '' } },
data() { return { user: session.currentUser, tasks: [], current: null, activeTab: 'picking', scanCode: '', scanNotice: null, loading: false, requestBusy: false, photoBusy: false, error: null, successMessage: '', bootstrapped: false, resumeOnEntry: false, generation: 0, loadingPreview: null, loadingRows: [], sourceChoices: [], choiceCartonCode: '', reviewReason: '' } },
computed: {
busy() { return this.requestBusy || this.photoBusy },
recentTaskNo() { return fulfillment.dispatch.currentNo || '' }, packages() { return fulfillment.dispatch.packages },
@@ -117,9 +131,15 @@ export default {
},
onLoad(options) { this.resumeOnEntry = options?.resume === '1' },
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (!this.bootstrapped) { this.bootstrapped = true; if (this.resumeOnEntry && fulfillment.dispatch.currentNo) this.openTask(fulfillment.dispatch.currentNo); else this.loadTasks() } else if (this.current?.dispatchNo) this.openTask(this.current.dispatchNo); else this.loadTasks() },
onHide() { this.stopScanner(); this.generation++ }, beforeUnmount() { this.stopScanner(); this.generation++ },
onHide() { this.stopScanner(); this.clearScanNotice(); this.generation++ }, beforeUnmount() { this.stopScanner(); this.clearScanNotice(); this.generation++ },
methods: {
selectTab(tab) { if (this.busy || this.loading || !['picking', 'photos'].includes(tab) || (tab === 'photos' && !this.canShowPhotos)) return; this.activeTab = tab },
clearScanNotice() { clearTimeout(this._scanNoticeTimer); this._scanNoticeTimer = null; this.scanNotice = null },
showScanNotice(item) {
this.clearScanNotice()
this.scanNotice = { palletNo: item.palletNo, packages: this.selectedRemaining }
this._scanNoticeTimer = setTimeout(() => this.clearScanNotice(), 3000)
},
selectTab(tab) { if (this.busy || this.loading || !['picking', 'photos'].includes(tab) || (tab === 'photos' && !this.canShowPhotos)) return; this.clearScanNotice(); this.activeTab = tab },
gate(action) { return this.current?.actions?.[action]?.allowed === true },
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: value => this.handleScan(value), onError: error => this.setError(error, '扫码广播注册失败') }) }, stopScanner() { this._stopScanner?.(); this._stopScanner = null },
updatePackages(event) { const value = Number(event.detail.value); updateFulfillmentDraft('dispatch', { packages: Number.isFinite(value) ? value : 0 }) },
@@ -129,6 +149,7 @@ export default {
handleScan(raw) {
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim()
if (!code || this.busy || this.loading || this.activeTab !== 'picking') return
this.clearScanNotice()
this.error = null; this.successMessage = ''; this.sourceChoices = []; this.choiceCartonCode = ''
updateFulfillmentDraft('dispatch', { palletCode: '', dispatchItemId: '', sourceKey: '', cartonCode: '', packages: 1 })
if (!this.current || code === this.current.dispatchNo || code === this.current.taskNo) return this.openTask(code === this.current?.taskNo ? this.current.dispatchNo : code)
@@ -147,6 +168,7 @@ export default {
this.selectPallet(item)
if (fulfillment.dispatch.dispatchItemId !== item.dispatchItemId) return
if (cartonCode) updateFulfillmentDraft('dispatch', { cartonCode, packages: 1 })
else this.showScanNotice(item)
this.scanCode = ''; playSuccessSound()
},
pickablePackages(item) {
@@ -161,7 +183,7 @@ export default {
this.sourceChoices = []; this.choiceCartonCode = ''; this.error = null
},
async submitPick() { if (!this.canPick) return; try { const body = { recordVersion: requireDispatchVersion(this.current.recordVersion), positionId: this.selectedReturnCarton ? null : this.selectedPosition.positionId, packages: this.selectedReturnCarton ? 1 : Number(this.packages), productQuantity: this.selectedReturnCarton?.productQuantity || 0, cartonMark: this.selectedReturnCarton?.cartonMark || null, remark: null }; await this.submitCommand(this.current.dispatchNo, 'PICK', { ...body, dispatchItemId: this.selectedItem.dispatchItemId }) } catch (cause) { this.setError(cause, '拣货未提交') } },
async prepareLoading() { if (!this.canComplete) return; await this.run(async () => { const data = await getDispatchLoading(this.current.dispatchNo); if (data.dispatchNo !== this.current.dispatchNo || data.pickingReconciled !== true || data.loadingComplete?.allowed !== true) throw new Error(data.loadingComplete?.blockReason || '装车前置未满足'); requireDispatchVersion(data.recordVersion); this.loadingPreview = data; this.loadingRows = data.pallets }) },
async prepareLoading() { if (!this.canComplete || this.busy || this.loading) return; await this.run(async () => { const data = await getDispatchLoading(this.current.dispatchNo); if (data.dispatchNo !== this.current.dispatchNo || data.pickingReconciled !== true || data.loadingComplete?.allowed !== true) throw new Error(data.loadingComplete?.blockReason || '装车前置未满足'); requireDispatchVersion(data.recordVersion); this.loadingPreview = data; this.loadingRows = data.pallets }) },
async completeLoading() { if (!this.loadingPreview || this.busy) return; try { if (!this.loadingRows.length || this.loadingRows.some(row => row.deliveryIndex == null || !Number.isInteger(row.deliveryIndex) || row.deliveryIndex < 1)) throw new Error('托盘卸货站尚未匹配,请后台核对'); await this.submitCommand(this.loadingPreview.dispatchNo, 'LOADING_COMPLETE', { recordVersion: requireDispatchVersion(this.loadingPreview.recordVersion), manifestHash: this.loadingPreview.manifestHash, remark: null }) } catch (cause) { this.setError(cause, '装车完成未确认') } },
updateReviewReason(event) { if (this.busy || this.loading) return; this.reviewReason = String(event.detail.value || '') },
async submitReview(decision) {
@@ -202,10 +224,20 @@ export default {
})
},
async retry() { if (this.current?.dispatchNo) await this.openTask(this.current.dispatchNo); else await this.loadTasks() },
async run(work, loading = false) { if (this.busy || this.loading) return; this.error = null; this.successMessage = ''; if (loading) this.loading = true; else this.requestBusy = true; try { await work() } catch (cause) { this.setError(cause, '排车操作失败') } finally { this.loading = false; this.requestBusy = false } },
setError(cause, fallback) { this.error = toDispatchError(cause, fallback); playErrorSound() },
async run(work, loading = false) { if (this.busy || this.loading) return; this.clearScanNotice(); this.error = null; this.successMessage = ''; if (loading) this.loading = true; else this.requestBusy = true; try { await work() } catch (cause) { this.setError(cause, '排车操作失败') } finally { this.loading = false; this.requestBusy = false } },
setError(cause, fallback) { this.clearScanNotice(); this.error = toDispatchError(cause, fallback); playErrorSound(); if (typeof uni !== 'undefined') uni.showToast?.({ title: this.error.message || fallback, icon: 'none', duration: 3000 }) },
stageLabel(task) { if (task.status === 'CANCELLED') return '已取消'; if (task.stage === 'BEFORE_LOADING') { if (task.actions?.LOADING_REVIEW?.allowed === true) return '待理货审核'; if ([task.reviews?.dispatchReview, task.reviews?.loadingReview].some(review => review?.status === 'REJECTED')) return '已退回客服处理' }; return ({ BEFORE_LOADING: '装车前', LOADING: '装车中', AFTER_LOADING: '装车后', DELIVERING: '派送中', DELIVERED: '派送完成' })[task.stage] || '状态待核实' },
reviewTime(value) { if (!value) return '未填写'; const date = new Date(new Date(value).getTime() + 8 * 60 * 60 * 1000); return Number.isNaN(date.getTime()) ? '时间无效' : date.toISOString().slice(0, 16).replace('T', ' ') },
reviewTime(value) {
if (!value) return '未填写'
const instant = typeof value === 'string' && /(?:Z|[+-]\d{2}:\d{2})$/i.test(value) ? Date.parse(value) : NaN
if (!Number.isFinite(instant)) return '时间无效'
return [['美东', 'America/New_York'], ['美西', 'America/Los_Angeles']].map(([label, timeZone]) => {
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-GB', {
timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
}).formatToParts(instant).map(part => [part.type, part.value]))
return `${label} ${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`
}).join('\n')
},
reviewLabel(review) { return ({ NOT_SUBMITTED: '未提交', PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' })[review?.status] || '待核对' },
routeLabel(item) { return [item.destination?.warehouseCode, item.destination?.warehouseName, item.destination?.address].filter(Boolean).join(' · ') || '目的仓未关联' }, itemProgress(item) { return item.scheduledPackages ? Math.min(100, Math.round(item.pickedPackages / item.scheduledPackages * 100)) : 0 }
}
@@ -244,25 +276,41 @@ export default {
.stage-tab::after { border: 0; }
.stage-tab.active { color: #1262f4; background: #fff; box-shadow: 0 1px 4px rgba(31, 41, 55, .12); }
.stage-tab[disabled] { opacity: .55; }
.review-card textarea { box-sizing: border-box; width: 100%; min-height: 160rpx; padding: 16rpx; border: 1px solid #cbd5e1; border-radius: 8rpx; }
.loading-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, .46); display: flex; align-items: center; justify-content: center; z-index: 100; padding: 40rpx; box-sizing: border-box; }
.review-facts { margin: 16rpx 0; }
.review-fact { display: flex; align-items: flex-start; gap: 20rpx; padding: 10rpx 0; color: #243c58; font-size: 26rpx; line-height: 1.5; }
.review-label { flex: 0 0 132rpx; color: #718095; overflow-wrap: anywhere; }
.review-fact > :last-child { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.review-appointment { display: flex; flex-direction: column; gap: 4rpx; }
.review-time { white-space: pre-line; }
.review-actions { display: flex; gap: 20rpx; margin-top: 24rpx; }
.review-actions button { flex: 1; min-width: 0; margin: 0; padding: 0 16rpx; min-height: 88rpx; line-height: 88rpx; font-size: 28rpx; }
.review-actions .primary-button { min-height: 88rpx; line-height: 88rpx; font-size: 28rpx; }
.review-card textarea { box-sizing: border-box; width: 100%; height: 160rpx; min-height: 160rpx; margin-top: 20rpx; padding: 16rpx; border: 1px solid #cbd5e1; border-radius: 8rpx; }
.loading-overlay { position: fixed; top: 0; right: 0; bottom: 0; left: 0; background: rgba(15, 23, 42, .46); display: flex; align-items: center; justify-content: center; z-index: 100; padding: 40rpx; box-sizing: border-box; }
.loading-dialog { width: 100%; max-width: 620rpx; max-height: 88vh; overflow-y: auto; box-sizing: border-box; background: #fff; border-radius: 28rpx; padding: 36rpx 32rpx 32rpx; box-shadow: 0 24rpx 80rpx rgba(15, 23, 42, .18); }
.loading-dialog-heading { display: flex; flex-direction: column; gap: 10rpx; }
.loading-dialog-title { color: #142c49; font-size: 36rpx; font-weight: 700; line-height: 1.4; }
.loading-dialog-number { color: #7a8aa0; font-size: 26rpx; line-height: 1.5; overflow-wrap: anywhere; }
.loading-counts { display: flex; margin: 28rpx 0; padding: 24rpx 0; border-radius: 20rpx; background: #f3f7ff; }
.loading-count { flex: 1; display: flex; align-items: center; flex-direction: column; gap: 8rpx; }
.loading-count + .loading-count { border-left: 1px solid #dce6f6; }
.loading-count-value { color: #1764ec; font-size: 48rpx; font-weight: 700; line-height: 1.2; }
.loading-count-unit { font-size: 26rpx; font-weight: 500; }
.loading-count-label { color: #72839b; font-size: 24rpx; }
.loading-confirm-question { display: block; color: #253a54; font-size: 30rpx; font-weight: 600; line-height: 1.5; }
.loading-confirm-note { display: block; color: #7a8aa0; font-size: 25rpx; line-height: 1.5; margin-top: 10rpx; }
.loading-dialog-actions { display: flex; gap: 20rpx; margin-top: 32rpx; }
.loading-dialog-actions button { flex: 1; min-width: 0; margin: 0; height: 88rpx; line-height: 88rpx; padding: 0 12rpx; border-radius: 16rpx; font-size: 30rpx; font-weight: 600; }
.loading-dialog-actions button::after { border: 0; }
.loading-cancel { color: #52657d; background: #f0f3f8; }
.loading-confirm { color: #fff; background: #1764ec; }
.loading-dialog-actions button[disabled] { opacity: .5; }
.scan-notice-overlay { pointer-events: none; }
.scan-notice-dialog { text-align: center; }
.scan-notice-pallet { display: block; margin-top: 20rpx; color: #243c58; font-size: 30rpx; overflow-wrap: anywhere; }
.scan-notice-label { display: block; margin-top: 28rpx; color: #536982; font-size: 28rpx; }
.scan-notice-count { display: block; margin-top: 12rpx; color: #1764ec; font-size: 72rpx; font-weight: 700; line-height: 1.3; }
.scan-notice-hint { display: block; margin-top: 24rpx; color: #72839b; font-size: 24rpx; }
</style>
<style scoped lang="scss">
@import '../../styles/task-list.scss';
</style>
+52 -7
View File
@@ -8,15 +8,15 @@
<text class="home-overview-title">{{ greeting }},{{ displayName }}</text>
<text class="home-overview-warehouse">{{ warehouseLabel }}</text>
</view>
<view class="home-overview-status"><text class="home-overview-status-value">{{ availableCount }}</text><text class="home-overview-status-label">项可处理</text></view>
</view>
<view class="home-workload-toolbar"><text>作业待办</text><button :disabled="countsLoading" @tap="refreshCounts"><uni-icons type="refresh" color="#135ee8" size="18" /><text>{{ countsLoading ? '刷新中…' : '刷新' }}</text></button></view>
<view v-for="group in groups" :key="group.key" class="home-group">
<view class="home-group-title"><view class="home-group-mark" :class="`home-group-mark-${group.key}`"></view><text>{{ group.name }}</text><text class="home-group-count">{{ group.operations.length }}</text></view>
<view class="home-group-title"><view class="home-group-mark" :class="`home-group-mark-${group.key}`"></view><text>{{ group.name }}</text></view>
<view class="home-operation-grid">
<button v-for="operation in group.operations" :key="operation.name" class="home-operation" :class="{ 'home-operation-primary': operation.available, 'home-operation-disabled': !operation.available }" :disabled="!operation.available" @tap="openOperation(operation)">
<view class="home-operation-icon" aria-hidden="true"><uni-icons :type="operation.iconType" :color="operation.available ? '#135ee8' : '#9aa6b2'" size="28" /></view>
<text class="home-operation-name">{{ operation.name }}</text>
<text v-if="operation.available" class="home-operation-pending">可处理</text>
<text v-if="operation.available" class="home-operation-pending" :class="countBadge(operation).tone">{{ countBadge(operation).label }}</text>
<text v-else class="home-operation-unavailable">暂未开放</text>
</button>
</view>
@@ -28,8 +28,10 @@
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import { getCurrentWarehouseProfile } from '@/api/inbound.js'
import { loadWorkbenchCounts, workbenchBadge } from '@/api/workbench.js'
import { MODULES } from '@/data/overseas-workbench.js'
import { applyWarehouseProfile, session, requireSession } from '@/stores/session.js'
import { restoreWarehouseWork, warehouseWork } from '@/stores/warehouse-work.js'
const HOME_GROUPS = MODULES.map((module) => ({
...module,
@@ -39,9 +41,8 @@
export default {
components: { PortalHeader },
data() { return { user: session.currentUser, groups: HOME_GROUPS, menuOpen: false } },
data() { return { user: session.currentUser, groups: HOME_GROUPS, menuOpen: false, counts: {}, countsLoading: false, countsGeneration: 0 } },
computed: {
availableCount() { return this.groups.reduce((total, group) => total + group.operations.filter((operation) => operation.available).length, 0) },
displayName() {
const name = String(this.user?.name || '').trim()
return name && !/^[a-z0-9._-]+$/i.test(name) ? name : '仓库作业员'
@@ -57,17 +58,48 @@
return '晚上好'
}
},
onShow() {
async onShow() {
if (!requireSession()) return
const generation = ++this.countsGeneration
this.user = session.currentUser
if (!this.user?.warehouseCode || !this.user?.warehouseName) this.loadWarehouseProfile()
this.counts = {}; this.countsLoading = true
if (!this.user?.warehouseCode || !this.user?.warehouseName) await this.loadWarehouseProfile()
if (generation !== this.countsGeneration) return
this.countsLoading = false
if (!session.currentUser) { this.user = null; return }
this.user = session.currentUser
await this.refreshCounts()
},
onHide() { this.invalidateCounts() },
onUnload() { this.invalidateCounts() },
beforeUnmount() { this.invalidateCounts() },
onBackPress() {
if (!this.menuOpen) return false
this.$refs.portalHeader.closeMenu()
return true
},
methods: {
countBadge(operation) { return workbenchBadge(operation.key, this.counts[operation.key]) },
countsScope() { const user = session.currentUser; return user?.id && user?.warehouseId ? `${session.apiBase}:${user.id}:${user.warehouseId}` : '' },
invalidateCounts() { this.countsGeneration += 1; this.countsLoading = false; this.counts = {} },
async refreshCounts() {
if (this.countsLoading || !this.countsScope()) return
const generation = ++this.countsGeneration
const scope = this.countsScope()
const profile = { ...session.currentUser }
restoreWarehouseWork(`${profile.id}:${profile.warehouseId}`)
const activeTransferNo = warehouseWork.warehouseTransfer.currentNo
this.counts = {}; this.countsLoading = true
try {
const counts = await loadWorkbenchCounts({ profile, activeTransferNo })
if (generation === this.countsGeneration && scope === this.countsScope()) this.counts = counts
} finally {
if (generation === this.countsGeneration) {
this.countsLoading = false
if (scope !== this.countsScope()) { this.counts = {}; this.user = session.currentUser }
}
}
},
async loadWarehouseProfile() {
try { this.user = applyWarehouseProfile(await getCurrentWarehouseProfile()) } catch (_) { this.user = session.currentUser }
},
@@ -79,3 +111,16 @@
}
}
</script>
<style scoped>
.home-overview{margin-bottom:12px}
.home-workload-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px;color:#526174;font-size:14px;font-weight:700}
.home-workload-toolbar button{display:flex;align-items:center;justify-content:center;gap:5px;width:auto;height:36px;margin:0;padding:0 14px;border:1px solid #b8d1f5;border-radius:20px;color:#135ee8;font-size:13px;font-weight:700;line-height:normal;background:#fff}
.home-workload-toolbar button[disabled]{opacity:.55}
.home-operation{height:auto;min-height:104px;padding:10px 6px}
.home-operation-name{min-height:17px}
.home-operation-pending{box-sizing:border-box;max-width:100%;font-size:10px;line-height:16px;white-space:normal}
.home-operation-pending.has-work{color:#9a5300;background:#fff0d6}
.home-operation-pending.is-empty,.home-operation-pending.is-loading,.home-operation-pending.is-static{color:#6f8194;background:#e7edf4}
.home-operation-pending.is-error{color:#b42318;background:#fff1f0}
</style>
File diff suppressed because one or more lines are too long
+136 -20
View File
@@ -2,7 +2,7 @@
<view v-if="user" class="page-shell portal-page fulfillment-page">
<PortalHeader title="出库作业" :user="user" />
<scroll-view class="fulfillment-scroll" scroll-y>
<view class="portal-content fulfillment-content">
<view class="portal-content fulfillment-content" :class="{ 'pda-task-list': !current && !discardCurrent }">
<view class="fulfillment-heading"><text class="fulfillment-title">出库任务</text></view>
<view v-if="current" class="detail-toolbar"><view><text class="detail-kicker">当前任务</text><text class="detail-title">出库作业详情</text></view><button :disabled="busy" @tap="closeTask">‹ 返回列表</button></view>
<view v-if="!discardCurrent" class="fulfillment-card">
@@ -11,19 +11,23 @@
</view>
<view v-if="loading" class="fulfillment-state"><text>正在加载出库任务…</text></view>
<view v-else-if="error" class="fulfillment-error" role="alert"><text class="state-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><text v-if="error.network" class="offline-note">网络连接失败,当前任务、扫码草稿和幂等键均已保留。</text><button class="secondary-button" :disabled="loading || busy || discardLoading || discardBusy" @tap="retry">重试</button></view>
<view v-else-if="error" class="fulfillment-error" role="alert"><text class="state-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><text v-if="error.network" class="offline-note">网络连接失败,请刷新核对任务状态后重试。</text><button class="secondary-button" :disabled="loading || busy || discardLoading || discardBusy" @tap="retry">重试</button></view>
<template v-if="!loading && !current && !discardCurrent">
<view class="list-toolbar"><view><text class="list-title">待处理出库任务</text><text class="list-count">{{ tasks.length }} 个任务</text></view><button :disabled="loading" @tap="loadTasks">刷新</button></view>
<view v-if="tasks.length" class="task-list">
<button v-for="task in tasks" :key="task.taskNo" class="task-card" @tap="openTask(task.taskNo)"><view class="task-head"><view class="task-code-row"><text class="business-code">{{ task.taskNo || '—' }}</text><text v-if="task.taskNo === recentTaskNo" class="recent-badge">上次作业</text></view><text class="status-badge">{{ statusLabel(task.status) }}</text></view><text class="task-meta">出库单 {{ task.outboundNo || '—' }} · {{ taskType(task) }}</text><text class="task-enter">查看并开始出库 ›</text></button>
<view class="list-toolbar pda-list-toolbar"><text>待处理出库任务<text v-if="!error"> · {{ tasks.length }} 项</text></text><button :disabled="loading" @tap="loadTasks"><uni-icons type="refresh" color="#135ee8" size="18" /><text>刷新</text></button></view>
<view v-if="tasks.length" class="task-list pda-list-cards">
<button v-for="task in tasks" :key="task.taskNo" class="task-card pda-task-card" hover-class="pda-task-card-pressed" @tap="openTask(task.taskNo)">
<view class="pda-task-head"><text class="pda-task-no">{{ task.outboundNo || '—' }}</text><text class="status-badge pda-task-status">{{ statusLabel(task.status) }}</text></view>
<view class="pda-task-meta"><text>{{ taskType(task) }}</text><text v-if="String(task.taskNo || '').trim() && String(task.taskNo || '').trim() !== String(task.outboundNo || '').trim()"> · 任务号 {{ task.taskNo }}</text></view>
<view class="pda-task-footer"><text v-if="task.taskNo === recentTaskNo" class="recent-badge">上次作业</text><view class="pda-task-action"><text>查看并开始出库</text><uni-icons type="right" color="#135ee8" size="14" /></view></view>
</button>
</view>
<view v-else-if="!error && !discardTasks.length" class="fulfillment-state"><text class="state-title">暂无出库任务</text><text>可刷新列表,或扫描任务号恢复未完成作业。</text></view>
<view v-if="discardTasks.length" class="discard-section"><view class="list-toolbar"><view><text class="list-title">废弃订单</text><text class="list-count">{{ discardOrderCount }} 个订单</text></view></view><view class="task-list"><button v-for="order in discardOrders" :key="order.discardNo" class="task-card" @tap="openDiscardOrder(order.discardNo)"><view class="task-head"><view class="task-code-row"><text class="business-code">{{ order.discardNo || '—' }}</text></view><text class="status-badge">废弃订单</text></view><text v-if="order.outboundNo" class="task-meta">出库单 {{ order.outboundNo }} · 废弃出库</text><text class="task-meta">托盘 {{ order.palletCount }} 个 · 按箱废弃 {{ order.cartonCount }} 箱 · 待作业 {{ order.pendingPalletCount }} 个</text><text class="task-enter">点击进入废弃出库作业 ›</text></button></view></view>
</template>
<template v-if="current">
<view class="fulfillment-card summary-card"><view class="task-head"><text class="business-code">{{ current.taskNo || '—' }}</text><text class="status-badge">{{ statusLabel(current.status) }}</text></view><text class="task-meta">出库单 {{ current.outboundNo || '—' }} · {{ taskType(current) }}</text><view class="metrics"><view><text class="metric-value">{{ plannedPackages }}</text><text>计划箱数</text></view><view><text class="metric-value success">{{ pickedPackages }}</text><text>已拣箱数</text></view><view><text class="metric-value warning">{{ remainingPackages }}</text><text>待拣箱数</text></view></view></view>
<view class="fulfillment-card summary-card"><view class="task-head"><text class="business-code">{{ current.outboundNo || '—' }}</text><text class="status-badge">{{ statusLabel(current.status) }}</text></view><text class="task-meta">{{ taskType(current) }} · 任务号 {{ current.taskNo || '—' }}</text><view class="metrics"><view><text class="metric-value">{{ plannedPackages }}</text><text>计划箱数</text></view><view><text class="metric-value success">{{ pickedPackages }}</text><text>已拣箱数</text></view><view><text class="metric-value warning">{{ remainingPackages }}</text><text>待拣箱数</text></view></view></view>
<view v-if="current.items.length" class="item-list">
<view v-for="item in displayItems" :key="item.displayKey || item.outboundPalletItemId" class="fulfillment-card item-card">
@@ -39,17 +43,29 @@
<view v-if="selectedPosition && selectedRemaining > 0" class="fulfillment-card"><text class="card-title">本次拣货:{{ lineBusinessCode(selectedPosition) }}</text><text v-if="selectedItem.returnSkuQuantity != null" class="scan-help">本来源计划出库 {{ selectedItem.returnSkuQuantity }} 件,请清点这些件数后确认;余数保留原箱。</text><view class="count-row"><text>拣货箱数</text><input type="number" :value="packages" :placeholder="`请输入 1-${selectedRemaining} 箱`" @input="updatePackages" /><text>最多 {{ selectedRemaining }} 箱</text></view><button class="primary-button" :disabled="!canPick || busy" @tap="submitPick">{{ busyAction === 'pick' ? '提交中…' : '确认出库拣货' }}</button></view>
<view class="fulfillment-card gate-card"><text class="card-title">完成出库任务</text><text v-if="remainingPackages > 0" class="gate-warning">尚有 {{ remainingPackages }} 箱未拣,不能完成出库。</text><text v-else-if="current.status === 'COMPLETED'" class="gate-success">出库任务已完成,不可重复提交。</text><text v-else class="gate-note">全部计划货物已扫描核平,服务端将再次校验库存门禁。</text><button v-if="current.status !== 'COMPLETED'" class="primary-button" :disabled="!canComplete || busy" @tap="completeTask">{{ busyAction === 'complete' ? '完成中…' : '完成出库任务' }}</button></view>
<view class="fulfillment-card gate-card"><text class="card-title">完成出库任务</text><text v-if="remainingPackages > 0" class="gate-warning">尚有 {{ remainingPackages }} 箱未拣,不能完成出库。</text><text v-else-if="current.status === 'COMPLETED'" class="gate-success">出库任务已完成,不可重复提交。</text><button v-if="current.status !== 'COMPLETED'" class="primary-button" :disabled="!canComplete || busy" @tap="completeTask">{{ busyAction === 'complete' ? '完成中…' : '完成出库任务' }}</button></view>
</template>
<template v-else-if="discardCurrent">
<view class="detail-toolbar"><view><text class="detail-kicker">废弃订单</text><text class="detail-title">{{ discardCurrent.discardNo }}</text><text v-if="discardCurrent.outboundNo" class="task-meta">出库单 {{ discardCurrent.outboundNo }}</text></view><button :disabled="discardBusy" @tap="closeDiscard">‹ 返回列表</button></view>
<view class="fulfillment-card"><text class="card-title">本单相关托盘及废弃范围</text><view v-for="task in discardCurrentTasks" :key="task.palletNo" class="outbound-line"><view class="task-head"><text class="business-code">{{ task.palletNo === '散箱' ? '散箱' : task.palletNo }}</text><text>{{ task.status === 'COMPLETED' ? '已完成' : '待废弃' }}</text></view><text v-for="mark in task.cartonMarks" :key="mark" class="line-code">{{ mark }}</text><text v-if="!task.cartonMarks.length" class="scan-help">缺少箱唛与托盘对应关系,请核对相关托盘,以仓库实际废弃箱数为准。</text></view></view>
<view class="fulfillment-card"><ScanTargetInput name="cargo-discard" :model-value="discardPalletCode" :label="discardTask?.scanMode === 'CARTON' ? '废弃箱唛' : '废弃托盘码'" :placeholder="discardTask?.scanMode === 'CARTON' ? '扫描本单所选箱唛' : '扫描对应托盘码'" :active="true" @update:modelValue="updateDiscardPalletCode" @submit="resolveDiscardPallet" /></view>
<view v-if="discardTask" class="fulfillment-card item-card"><view class="task-head"><text class="business-code">{{ discardTask.scanMode === 'CARTON' ? (discardTask.palletNo === '散箱' ? '散箱废弃' : '按箱废弃 · ' + discardTask.palletNo) : '托盘 ' + discardTask.palletNo }}</text><text class="status-badge">{{ discardTask.status === 'COMPLETED' ? '已完成' : '待废弃' }}</text></view><text class="task-meta">本次废弃 {{ discardQuantitySummary }}</text><view v-for="sku in discardTask.skus" :key="sku.itemId" class="outbound-line"><text class="line-code">{{ sku.skuCode || '—' }}</text><text class="line-meta">{{ sku.productName || '—' }} · 申请 {{ sku.plannedPackages }} {{ sku.quantityUnit || '箱' }}</text><view v-if="sku.actualQuantityEditable" class="quantity-row"><text>实际废弃箱数</text><input type="number" placeholder="请输入实际箱数" :value="discardActuals[sku.itemId] ?? ''" :disabled="discardTask.status === 'COMPLETED' || discardBusy || discardLoading || discardPhotoBusy" @input="updateDiscardActual(sku.itemId, $event)" /><text>箱(0–{{ sku.plannedPackages }})</text></view><text v-else-if="sku.actualPackages != null" class="line-meta">实际废弃 {{ sku.actualPackages }} {{ sku.quantityUnit || '箱' }}</text><text v-if="sku.actualQuantityEditable" class="scan-help">按现场实点填写,未废弃的预留库存将释放。</text></view><view v-if="discardTask.scanMode === 'CARTON'"><text class="scan-help">已核对 {{ discardScannedMarks.length }}/{{ discardTask.cartonMarks.length }} 箱</text><view v-for="mark in discardTask.cartonMarks" :key="mark" class="outbound-line"><text>{{ mark }}</text><text>{{ discardAllCartons ? '全部废弃' : discardScannedMarks.includes(mark) ? '已扫描' : '待扫描' }}</text></view><button v-if="discardTask.status !== 'COMPLETED'" class="secondary-button" :disabled="discardBusy || discardLoading || discardPhotoBusy" @tap="discardAllCartons = true">一键废弃本单所选箱子({{ discardTask.cartonMarks.length }} 箱)</button><text v-if="discardAllCartons" class="scan-help">已确认本单所选箱子全部废弃,拍照后点击废弃出库完成。</text></view><view class="discard-photo-section"><text class="business-code">废弃现场照片(至少 1 张)</text><text class="scan-help">照片关联当前废弃作业,完成出库后可在废弃单详情查看。</text><view class="discard-photo-grid"><view v-for="photo in discardPhotos" :key="photo.fileId" class="discard-photo-tile"><image v-if="discardPhotoUrls[photo.fileId]" :src="discardPhotoUrls[photo.fileId]" mode="aspectFill" @tap="previewDiscardPhoto(photo.fileId)" /><button v-else class="secondary-button" @tap="loadDiscardPhoto(photo.fileId)">{{ discardPhotoErrors[photo.fileId] ? '加载失败,点击重试' : '加载照片' }}</button></view></view><button v-if="discardTask.status !== 'COMPLETED'" class="secondary-button" :disabled="discardLoading || discardBusy || discardPhotoBusy || !discardPhotoReady || discardPhotos.length >= 20" @tap="takeDiscardPhoto">{{ discardPhotoBusy ? '照片上传中…' : '拍摄废弃现场照片' }}({{ discardPhotos.length }}/20)</button><button v-if="!discardPhotoReady" class="secondary-button" :disabled="discardLoading || discardPhotoBusy" @tap="restoreDiscardPhotos">重试读取照片</button></view><button class="primary-button" :disabled="!canConfirmDiscard || discardBusy" @tap="confirmDiscard">{{ discardBusy ? '提交中…' : '废弃出库' }}</button></view>
<view v-if="discardOrderRemark" class="fulfillment-card discard-remark"><text class="card-title">废弃备注</text><text class="task-meta">{{ discardOrderRemark }}</text></view>
<view v-if="discardOrderRemaining != null" class="fulfillment-card"><text>申请废弃 {{ discardCurrentTasks[0]?.requestedPackages ?? discardTask?.requestedPackages }} 箱 · 剩余 {{ discardOrderRemaining }} 箱</text></view>
<view v-if="successMessage" class="fulfillment-success" role="status"><text>{{ successMessage }}</text></view>
<view v-if="!discardTask && discardPendingTasks.length" class="fulfillment-card"><text class="card-title">废弃箱关联托盘</text><view v-for="task in discardPendingTasks" :key="task.palletNo" class="outbound-line"><view class="task-head"><text class="business-code">{{ task.palletNo === '散箱' ? '散箱' : task.palletNo }}</text><text>待废弃</text></view></view></view>
<view v-if="!discardTask && discardPendingTasks.length" class="fulfillment-card"><ScanTargetInput name="cargo-discard" :model-value="discardPalletCode" label="废弃托盘码" placeholder="扫描对应托盘码" :active="discardTask?.scanMode !== 'CARTON'" @update:modelValue="updateDiscardPalletCode" @submit="resolveDiscardPallet" /></view>
<view v-if="discardTask" class="fulfillment-card"><ScanTargetInput v-if="discardNeedsRefresh" name="cargo-discard-retry" :model-value="discardPalletCode" label="重新扫描废弃托盘" placeholder="重新扫描当前托盘码" :active="true" @update:modelValue="updateDiscardPalletCode" @submit="resolveDiscardPallet" /><view v-else class="task-head"><text class="business-code">{{ discardTask.palletNo }}</text><text>{{ discardTask.status === 'COMPLETED' ? '已完成' : '待废弃' }}</text></view></view>
<view v-if="discardTask" class="fulfillment-card item-card">
<text v-if="discardQuantityLines.length" class="discard-scanned-count">已扫描 {{ discardUnboundScans.length }} 箱</text>
<view v-if="discardQuantityLines.length > 1" class="quantity-row"><button v-for="sku in discardQuantityLines" :key="sku.itemId" class="secondary-button" :disabled="discardBusy" @tap="discardScanItemId = sku.itemId">{{ sku.skuCode || '—' }}{{ discardScanItemId === sku.itemId ? '(当前扫描)' : '' }}</button></view>
<view v-if="discardQuantityLines.length"><text class="business-code">扫描箱号出库</text><ScanTargetInput name="cargo-discard-count" :model-value="discardCartonCode" label="废弃箱唛" placeholder="扫描现场已有箱唛" :active="true" @update:modelValue="discardCartonCode = $event" @submit="scanDiscardCarton" /><view v-if="discardUnboundScans.length" class="discard-carton-heading" role="button" :aria-expanded="discardScannedExpanded" :aria-label="discardScannedExpanded ? '收起已扫描箱唛' : '展开已扫描箱唛'" @tap="discardScannedExpanded = !discardScannedExpanded"><text class="scan-help">已扫描 {{ discardUnboundScans.length }} 箱</text><view class="discard-carton-chevron" :class="{ expanded: discardScannedExpanded }" aria-hidden="true"></view></view><view v-if="discardScannedExpanded"><view v-for="scan in discardUnboundScans" :key="scan.mark" class="outbound-line"><text>{{ scan.mark }}</text><button class="discard-scan-remove" :disabled="discardBusy || discardPhotoBusy" :aria-label="`移除箱唛 ${scan.mark}`" @tap="removeDiscardScan(scan.mark)">移除</button></view></view></view><view v-if="discardTask.scanMode === 'CARTON'"><view class="discard-carton-heading" role="button" :aria-expanded="discardCartonsExpanded" :aria-label="discardCartonsExpanded ? '收起箱号核对' : '展开箱号核对'" @tap="discardCartonsExpanded = !discardCartonsExpanded"><text class="business-code">箱号核对</text><view class="discard-carton-heading-end"><text class="scan-help">待扫描 {{ discardPendingMarks.length }} 箱</text><view class="discard-carton-chevron" :class="{ expanded: discardCartonsExpanded }" aria-hidden="true"></view></view></view>
<text class="scan-help">已核对 {{ discardScannedMarks.length }}/{{ discardTask.cartonMarks.length }} 箱</text>
<view v-if="discardCartonsExpanded">
<view v-for="mark in discardPendingMarks" :key="mark" class="outbound-line"><text>{{ mark }}</text></view>
<text v-if="!discardPendingMarks.length" class="scan-help">箱号已全部核对</text>
</view><view v-if="discardTask.status !== 'COMPLETED'"><text class="business-code">扫描箱号出库</text><ScanTargetInput name="cargo-discard-carton" :model-value="discardCartonCode" label="废弃箱号" placeholder="扫描本次废弃的系统箱号" :active="true" @update:modelValue="discardCartonCode = $event" @submit="scanDiscardCarton" /></view></view><view class="discard-photo-section"><text class="business-code">废弃现场照片(至少 1 张)</text><view class="discard-photo-grid"><view v-for="photo in discardPhotos" :key="photo.fileId" class="discard-photo-tile"><image v-if="discardPhotoUrls[photo.fileId]" :src="discardPhotoUrls[photo.fileId]" mode="aspectFill" @tap="previewDiscardPhoto(photo.fileId)" /><button v-else class="secondary-button" @tap="loadDiscardPhoto(photo.fileId)">{{ discardPhotoErrors[photo.fileId] ? '加载失败,点击重试' : '加载照片' }}</button></view></view><button v-if="discardTask.status !== 'COMPLETED'" class="secondary-button" :disabled="discardLoading || discardBusy || discardPhotoBusy || !discardPhotoReady || discardPhotos.length >= 20" @tap="takeDiscardPhoto">{{ discardPhotoBusy ? '照片上传中…' : '拍摄废弃现场照片' }}({{ discardPhotos.length }}/20)</button><button v-if="!discardPhotoReady" class="secondary-button" :disabled="discardLoading || discardPhotoBusy" @tap="restoreDiscardPhotos">重试读取照片</button></view><button class="primary-button" :disabled="!canConfirmDiscard || discardBusy" @tap="confirmDiscard">{{ discardBusy ? '提交中…' : '废弃出库' }}</button></view>
</template>
<view v-if="successMessage" class="fulfillment-success" role="status"><text>{{ successMessage }}</text></view>
<view v-if="successMessage && !discardCurrent" class="fulfillment-success" role="status"><text>{{ successMessage }}</text></view>
</view>
</scroll-view>
</view>
@@ -66,8 +82,13 @@
export default {
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, tasks: [], discardTasks: [], current: null, discardCurrent: null, discardTask: null, discardActuals: {}, discardScannedMarks: [], discardAllCartons: false, discardPhotos: [], discardPhotoUrls: {}, discardPhotoErrors: {}, discardPhotoBusy: false, discardPhotoReady: false, discardPalletCode: '', discardLoading: false, discardBusy: false, selectedItem: null, selectedPosition: null, selectedScanCode: '', scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '', bootstrapped: false } },
data() { return { user: session.currentUser, tasks: [], discardTasks: [], current: null, discardCurrent: null, discardTask: null, discardScannedExpanded: false, discardUnboundScans: [], discardScanItemId: '', discardActuals: {}, discardScannedMarks: [], discardCartonsExpanded: true, discardCartonCode: '', discardPhotos: [], discardPhotoUrls: {}, discardPhotoErrors: {}, discardPhotoBusy: false, discardPhotoReady: false, discardPalletCode: '', discardLoading: false, discardBusy: false, discardNeedsRefresh: false, selectedItem: null, selectedPosition: null, selectedScanCode: '', scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '', bootstrapped: false } },
computed: {
// 已扫描箱号保留提交凭据,列表只显示尚未核对的箱子。
discardOrderRemark() { return this.discardTask?.remark || this.discardCurrentTasks.find(task => task.remark)?.remark || '' },
discardOrderRemaining() { return this.discardTask?.remainingPackages ?? this.discardCurrentTasks[0]?.remainingPackages ?? null },
discardPendingMarks() { return (this.discardTask?.cartonMarks || []).filter(mark => !this.discardScannedMarks.includes(mark)) },
discardQuantityLines() { return (this.discardTask?.skus || []).filter(sku => sku.actualQuantityEditable) },
displayItems() {
const items = this.current?.items || []
if (this.current?.outboundType !== 'RETURN_OUTBOUND') return items
@@ -101,6 +122,7 @@
canComplete() { return this.plannedPackages > 0 && this.remainingPackages === 0 && this.taskOperable },
discardOrders() { const groups = new Map(); for (const task of this.discardTasks) { const current = groups.get(task.discardNo) || { discardNo: task.discardNo, outboundNo: task.outboundNo, palletCount: 0, cartonCount: 0, pendingPalletCount: 0 }; if (task.scanMode === 'CARTON') current.cartonCount += (task.cartonMarks || []).length; else current.palletCount += 1; if (task.status !== 'COMPLETED') current.pendingPalletCount += 1; groups.set(task.discardNo, current) } return [...groups.values()] },
discardOrderCount() { return this.discardOrders.length },
discardPendingTasks() { return this.discardCurrentTasks.filter(task => task.status !== 'COMPLETED') },
discardCurrentTasks() { return this.discardTasks.filter(task => task.discardNo === this.discardCurrent?.discardNo) },
discardQuantitySummary() {
const task = this.discardTask
@@ -109,7 +131,7 @@
const boxes = new Set(task.cartonMarks || []).size || task.skus.filter(sku => sku.quantityUnit !== '件').reduce((total, sku) => total + sku.plannedPackages, 0)
return `${boxes} 箱${pieces ? ` · ${pieces} 件` : ''}`
},
canConfirmDiscard() { const task = this.discardTask; return Boolean(this.discardPhotoReady && this.discardPhotos.length > 0 && !this.discardPhotoBusy && !this.discardLoading && !this.discardBusy && task && task.status !== 'COMPLETED' && task.skus.length && task.skus.every(sku => this.validDiscardActual(sku)) && (task.scanMode !== 'CARTON' || (task.cartonMarks?.length && (this.discardAllCartons || task.cartonMarks.every(mark => this.discardScannedMarks.includes(mark)))))) }
canConfirmDiscard() { const task = this.discardTask; return Boolean(this.discardPhotoReady && this.discardPhotos.length > 0 && !this.discardPhotoBusy && !this.discardLoading && !this.discardBusy && task && task.status !== 'COMPLETED' && (task.remainingPackages == null || task.scanMode === 'CARTON' || task.skus.reduce((sum, sku) => sum + Number(this.discardActuals[sku.itemId] || 0), 0) <= task.remainingPackages) && task.skus.length && task.skus.every(sku => this.validDiscardActual(sku)) && (task.scanMode !== 'CARTON' || (task.cartonMarks?.length && task.cartonMarks.every(mark => this.discardScannedMarks.includes(mark))))) }
},
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (this.discardPhotoBusy || this.discardBusy) return; if (this.discardCurrent) { if (this.discardTask) this.restoreDiscardPhotos(); return } if (!this.bootstrapped) { this.bootstrapped = true; this.scanCode = ''; this.loadTasks(); return } if (this.current?.taskNo) this.openTask(this.current.taskNo); else this.loadTasks() },
onHide() { this.stopScanner() },
@@ -137,27 +159,86 @@
}, true)
},
closeTask() { this.current = null; this.selectedItem = null; this.selectedPosition = null; this.error = null; this.successMessage = ''; updateFulfillmentDraft('outbound', { currentTaskNo: '', scanCode: '', packages: 1 }); this.loadTasks() },
openDiscardOrder(discardNo) { if (this.discardPhotoBusy) return; this.resetDiscardPhotos(); const code = String(discardNo || '').trim(); if (!code || this.discardBusy || this.busy || this.loading) return; this.current = null; this.discardLoading = false; this.discardCurrent = { discardNo: code, outboundNo: this.discardOrders.find(order => order.discardNo === code)?.outboundNo || '' }; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.discardScannedMarks = []; this.discardAllCartons = false; this.error = null; const loose = this.discardTasks.find(task => task.discardNo === code && task.scanMode === 'CARTON' && task.status !== 'COMPLETED'); if (loose) { this.discardTask = normalizeCargoDiscardTask(loose); this.discardActuals = Object.fromEntries(this.discardTask.skus.map(sku => [sku.itemId, sku.plannedPackages])); this.restoreDiscardPhotos() } },
openDiscardOrder(discardNo) { if (this.discardPhotoBusy) return; this.resetDiscardPhotos(); const code = String(discardNo || '').trim(); if (!code || this.discardBusy || this.busy || this.loading) return; this.current = null; this.discardLoading = false; this.discardCurrent = { discardNo: code, outboundNo: this.discardOrders.find(order => order.discardNo === code)?.outboundNo || '' }; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.discardUnboundScans = []; this.discardScannedExpanded = false; this.discardScanItemId = ''; this.discardScannedMarks = []; this.discardCartonsExpanded = true; this.discardCartonCode = ''; this.discardNeedsRefresh = false; this.error = null; this.successMessage = ''; },
closeDiscard() { if (this.discardBusy || this.discardPhotoBusy) return; this.resetDiscardPhotos(); this.discardLoading = false; this.discardCurrent = null; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.loadTasks() },
updateDiscardPalletCode(value) { this.discardPalletCode = value },
// 下方扫描框只核对当前任务的真实箱号,不切换托盘或清空照片。
async scanDiscardCarton(raw) {
if (!this.discardTask || this.discardTask.status === 'COMPLETED' || this.discardBusy || this.discardLoading || this.discardPhotoBusy) return
const code = String(typeof raw === 'string' ? raw : raw?.detail?.value ?? this.discardCartonCode).trim()
if (!code) return
if (this.discardNeedsRefresh && !await this.refreshDiscardTask()) return
if (this.discardTask.status === 'COMPLETED') return
if (this.discardQuantityLines.length) {
if ((this.discardCurrentTasks || []).some(task => task.palletNo === code)) { this.setError(new Error('请扫描箱唛,不能把托盘码作为箱唛'), '箱唛核对失败'); return }
const sku = this.discardQuantityLines.length === 1 ? this.discardQuantityLines[0] : this.discardQuantityLines.find(line => line.itemId === this.discardScanItemId)
if (!sku) { this.setError(new Error('请先选择当前扫描的货物'), '箱唛核对失败'); return }
if (!(sku.allowedCartonMarks || []).includes(code)) { this.setError(new Error('箱唛不属于本废弃单的原入仓库存'), '箱唛核对失败'); return }
if (this.discardUnboundScans.some(scan => scan.mark === code)) { this.discardCartonCode = ''; return }
if (code.length > 128 || this.discardUnboundScans.filter(scan => scan.itemId === sku.itemId).length >= sku.plannedPackages || this.discardUnboundScans.length >= (this.discardTask.remainingPackages ?? Infinity)) { this.setError(new Error('扫描箱数不能超过本托预留或申请剩余箱数'), '箱唛核对失败'); return }
this.discardUnboundScans = [...this.discardUnboundScans, { itemId: sku.itemId, mark: code }]
this.discardActuals = { ...this.discardActuals, [sku.itemId]: this.discardUnboundScans.filter(scan => scan.itemId === sku.itemId).length }
this.discardCartonCode = ''; this.error = null; playSuccessSound(); return
}
if (!this.discardTask.cartonMarks.includes(code)) { this.setError(new Error('箱号不属于当前废弃任务'), '箱号核对失败'); return }
if (!this.discardScannedMarks.includes(code)) this.discardScannedMarks = [...this.discardScannedMarks, code]
this.discardCartonCode = ''; this.error = null; playSuccessSound()
},
/** 删除误扫的现场箱唛,并同步扣回对应明细实扫数量。 */
removeDiscardScan(mark) {
if (this.discardBusy || this.discardPhotoBusy) return
const removed = this.discardUnboundScans.find(scan => scan.mark === mark)
if (!removed) return
this.discardUnboundScans = this.discardUnboundScans.filter(scan => scan.mark !== mark)
this.discardActuals = { ...this.discardActuals, [removed.itemId]: this.discardUnboundScans.filter(scan => scan.itemId === removed.itemId).length }
this.error = null; this.successMessage = ''
},
async resolveDiscardPallet(raw) {
const code = String(typeof raw === 'string' ? raw : raw?.detail?.value ?? this.discardPalletCode).trim()
if (!code || !this.discardCurrent || this.discardBusy || this.discardLoading || this.discardPhotoBusy) return
if (this.discardTask?.scanMode === 'CARTON' && this.discardTask.cartonMarks.includes(code)) { if (!this.discardScannedMarks.includes(code)) this.discardScannedMarks = [...this.discardScannedMarks, code]; this.error = null; playSuccessSound(); return }
if ((this.discardQuantityLines.length && !this.discardCurrentTasks.some(task => task.palletNo === code) && this.discardTask?.palletNo !== code) || (this.discardTask?.scanMode === 'CARTON' && this.discardTask.cartonMarks.includes(code))) { await this.scanDiscardCarton(code); return }
if (this.discardTask?.palletNo === code) { await this.refreshDiscardTask(); return }
// 当前托盘可重扫,同一废弃单内也可通过托盘或所选箱唛切换任务。
if (this.discardTask?.scanMode === 'CARTON' && !this.discardTasks.some(task => task.discardNo === this.discardCurrent.discardNo && (task.palletNo === code || (task.cartonMarks || []).includes(code)))) { this.setError(new Error('托盘或箱唛不属于当前废弃单所选货物'), '箱唛核对失败'); return }
// 有托盘的任务须先扫描本单托盘,箱号仅用于展开后的逐箱核对。
const selectedTasks = this.discardTasks.filter(task => task.discardNo === this.discardCurrent.discardNo)
if (selectedTasks.length && !selectedTasks.some(task => task.palletNo !== '散箱' ? task.palletNo === code : (task.cartonMarks || []).includes(code))) { this.setError(new Error('请先扫描当前废弃单对应的托盘码'), '托盘核对失败'); return }
const order = this.discardCurrent
this.resetDiscardPhotos(); this.discardScannedMarks = []; this.discardAllCartons = false; this.discardPalletCode = code; this.discardLoading = true; this.discardTask = null; this.discardActuals = {}; this.error = null
this.resetDiscardPhotos(); this.discardScannedMarks = []; this.discardCartonsExpanded = true; this.discardCartonCode = ''; this.discardPalletCode = code; this.discardLoading = true; this.discardNeedsRefresh = false; this.discardTask = null; this.discardUnboundScans = []; this.discardScannedExpanded = false; this.discardScanItemId = ''; this.discardActuals = {}; this.error = null; this.successMessage = ''
try {
const task = await resolveCargoDiscardPallet(code, order.discardNo)
if (this.discardCurrent !== order) return
if (task.discardNo !== order.discardNo) throw new Error('扫描的托盘不属于当前废弃订单')
this.discardTask = normalizeCargoDiscardTask(task)
if (this.discardTask.scanMode === 'CARTON' && this.discardTask.cartonMarks.includes(code)) this.discardScannedMarks = [code]
this.discardActuals = Object.fromEntries(this.discardTask.skus.map((sku) => [sku.itemId, sku.actualPackages == null ? (sku.actualQuantityEditable ? '' : sku.plannedPackages) : sku.actualPackages])); await this.restoreDiscardPhotos()
this.discardActuals = Object.fromEntries(this.discardTask.skus.map((sku) => [sku.itemId, sku.actualPackages == null ? (sku.actualQuantityEditable ? 0 : sku.plannedPackages) : sku.actualPackages])); await this.restoreDiscardPhotos()
} catch (error) { if (this.discardCurrent === order) this.setError(error, '废弃托盘读取失败') }
finally { if (this.discardCurrent === order) this.discardLoading = false }
},
/** 重扫与重试先读取当前权威任务,只保留仍匹配的箱号和实点数;照片仍归当前托盘。 */
async refreshDiscardTask() {
if (!this.discardCurrent || !this.discardTask || this.discardLoading || this.discardPhotoBusy) return false
const order = this.discardCurrent; const previous = this.discardTask
this.discardLoading = true
try {
const latest = await resolveCargoDiscardPallet(previous.palletNo, order.discardNo)
if (this.discardCurrent !== order || this.discardTask !== previous) return false
if (latest.discardNo !== order.discardNo || latest.palletNo !== previous.palletNo) throw new Error('刷新结果不属于当前废弃托盘')
this.discardTask = normalizeCargoDiscardTask(latest)
this.discardScannedMarks = this.discardScannedMarks.filter(mark => (this.discardTask.cartonMarks || []).includes(mark))
this.discardUnboundScans = this.discardUnboundScans.filter(scan => this.discardTask.skus.some(sku => sku.actualQuantityEditable && sku.itemId === scan.itemId))
this.discardActuals = Object.fromEntries(this.discardTask.skus.map(sku => {
const old = previous.skus.find(line => line.itemId === sku.itemId)
const unchanged = old && old.plannedPackages === sku.plannedPackages && old.actualQuantityEditable === sku.actualQuantityEditable
return [sku.itemId, sku.actualPackages ?? (unchanged ? this.discardActuals[sku.itemId] : sku.actualQuantityEditable ? 0 : sku.plannedPackages)]
}))
this.discardPalletCode = latest.palletNo; this.discardNeedsRefresh = false; this.error = null
if (!this.discardPhotoReady) await this.restoreDiscardPhotos()
if (latest.status === 'COMPLETED') this.successMessage = '当前托盘已完成废弃,请返回列表核对本单剩余任务'
return true
} catch (error) { if (this.discardCurrent === order) { this.discardNeedsRefresh = true; this.setError(error, '废弃任务刷新失败,请重新扫描') }; return false }
finally { if (this.discardCurrent === order) this.discardLoading = false }
},
resetDiscardPhotos() { this.discardPhotos = []; this.discardPhotoUrls = {}; this.discardPhotoErrors = {}; this.discardPhotoReady = false },
async restoreDiscardPhotos() {
if (!this.discardTask || !this.discardCurrent) return
@@ -193,9 +274,31 @@
}, fail: () => { this.discardPhotoBusy = false } })
},
// 未填写、负数、小数或超出预留数均阻止提交,保留输入供仓库修正。
validDiscardActual(sku) { const raw = this.discardActuals[sku.itemId]; if (raw == null || String(raw).trim() === '') return false; const actual = Number(raw); return Number.isSafeInteger(actual) && actual >= 0 && (sku.actualQuantityEditable === true ? actual <= sku.plannedPackages : actual === sku.plannedPackages) },
validDiscardActual(sku) { const raw = this.discardActuals[sku.itemId]; if (raw == null || String(raw).trim() === '') return false; if (sku.actualQuantityEditable && Number(raw) !== this.discardUnboundScans.filter(scan => scan.itemId === sku.itemId).length) return false; const actual = Number(raw); return Number.isSafeInteger(actual) && actual >= 0 && (sku.actualQuantityEditable === true ? actual <= sku.plannedPackages : actual === sku.plannedPackages) },
updateDiscardActual(itemId, event) { this.discardActuals = { ...this.discardActuals, [itemId]: event.detail.value } },
async confirmDiscard() { if (this.discardBusy || this.discardLoading || !this.canConfirmDiscard || !this.discardTask || !this.discardCurrent) return; this.discardBusy = true; try { const lines = this.discardTask.skus.map((sku) => ({ itemId: sku.itemId, actualPackages: Number(this.discardActuals[sku.itemId]) })); await confirmCargoDiscard(this.discardCurrent.discardNo, this.discardTask.palletNo, lines, this.discardPhotos.map(photo => String(photo.fileId)), this.discardScannedMarks, this.discardAllCartons); this.successMessage = '废弃出库作业及照片已提交,已完成'; this.resetDiscardPhotos(); this.discardPalletCode = ''; this.discardTask = null; this.discardTasks = await listCargoDiscardTasks(); playSuccessSound() } catch (error) { this.setError(error, '废弃出库失败') } finally { this.discardBusy = false } },
/** 单托完成后刷新同单待办,仍有托盘时明确提示继续扫码。 */
async confirmDiscard() {
if (this.discardBusy || this.discardLoading || !this.canConfirmDiscard || !this.discardTask || !this.discardCurrent) return
this.error = null; this.successMessage = ''; this.discardBusy = true
const palletNo = this.discardTask.palletNo; const discardNo = this.discardCurrent.discardNo
try {
if (this.discardNeedsRefresh) {
if (!await this.refreshDiscardTask() || this.discardTask.status === 'COMPLETED' || !this.discardPhotoReady || !this.discardTask.skus.every(sku => this.validDiscardActual(sku)) || (this.discardTask.scanMode === 'CARTON' && !this.discardTask.cartonMarks.every(mark => this.discardScannedMarks.includes(mark)))) return
}
const lines = this.discardTask.skus.map(sku => ({ itemId: sku.itemId, actualPackages: Number(this.discardActuals[sku.itemId]), ...(sku.actualQuantityEditable ? { scannedCartonMarks: this.discardUnboundScans.filter(scan => scan.itemId === sku.itemId).map(scan => scan.mark) } : {}) }))
const photoFileIds = this.discardPhotos.map(photo => String(photo.fileId))
const key = fulfillmentIdempotencyKey('outbound', 'DISCARD', { discardNo, palletNo, lines, photoFileIds, scannedCartonMarks: this.discardScannedMarks, confirmAllCartons: false })
await confirmCargoDiscard(discardNo, palletNo, lines, photoFileIds, this.discardScannedMarks, false, { idempotencyKey: key.value })
this.resetDiscardPhotos(); this.discardPalletCode = ''; this.discardTask = null; this.discardScannedMarks = []; this.discardCartonCode = ''
// 确认已成功时,刷新失败不能被显示为出库失败或宣告整单完成。
try { this.discardTasks = await listCargoDiscardTasks() }
catch (error) { this.discardTasks = this.discardTasks.filter(task => task.discardNo !== discardNo || task.palletNo !== palletNo); this.successMessage = '托盘 ' + palletNo + ' 废弃已完成,其他托盘待办刷新失败,请返回列表重新加载'; this.setError(error, '待办刷新失败'); return }
const remaining = this.discardPendingTasks.map(task => task.palletNo)
this.successMessage = remaining.length ? '托盘 ' + palletNo + ' 废弃已完成,本单还有 ' + remaining.length + ' 个待废弃任务:' + remaining.join('、') + ',请继续扫描对应托盘' : '本单全部废弃出库作业及照片已提交,已完成'
playSuccessSound()
} catch (error) { this.discardNeedsRefresh = true; this.setError(error, '废弃出库失败') }
finally { this.discardBusy = false }
},
handleScan(raw) {
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim(); if (!code || this.busy || this.loading || this.discardBusy || this.discardLoading) return
console.info('[Overseas PDA outbound] scan', { code, task: this.current?.taskNo || '', discard: this.discardCurrent?.discardNo || '' }); this.scanCode = code; updateFulfillmentDraft('outbound', { scanCode: code })
@@ -226,7 +329,7 @@
const key = fulfillmentIdempotencyKey('outbound', 'complete', { taskNo: this.current.taskNo })
await this.run('完成出库任务', async () => { await completeOutboundTask(this.current.taskNo, { idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); this.successMessage = '出库任务已完成'; playSuccessSound() })
},
async retry() { if (this.discardCurrent) { if (this.discardPalletCode) await this.resolveDiscardPallet(this.discardPalletCode); return } if (this.current?.taskNo) await this.openTask(this.current.taskNo); else if (fulfillment.outbound.currentTaskNo) await this.openTask(fulfillment.outbound.currentTaskNo); else if (this.scanCode) await this.openScannedTask(this.scanCode); else await this.loadTasks() },
async retry() { if (this.discardCurrent) { if (this.discardTask) await this.refreshDiscardTask(); else if (this.discardPalletCode) await this.resolveDiscardPallet(this.discardPalletCode); return } if (this.current?.taskNo) await this.openTask(this.current.taskNo); else if (fulfillment.outbound.currentTaskNo) await this.openTask(fulfillment.outbound.currentTaskNo); else if (this.scanCode) await this.openScannedTask(this.scanCode); else await this.loadTasks() },
async run(action, work, loading = false) { if (this.busy || this.loading) return; this.error = null; this.successMessage = ''; if (loading) this.loading = true; else { this.busy = true; this.busyAction = action.includes('完成') ? 'complete' : 'pick' } try { await work() } catch (error) { this.setError(error, `${action}失败`) } finally { this.loading = false; this.busy = false; this.busyAction = '' } },
setError(error, fallback) { const forbidden = ['403', 'A0403'].includes(String(error?.code || '')); this.error = { message: forbidden ? '当前账号没有海外仓 PDA 作业权限,请联系管理员授权' : error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId, network: Boolean(error?.network) }; console.error('[Overseas PDA outbound] failed', this.error); playErrorSound() },
lineBusinessCode(position) { return position?.cartonMark || position?.palletNo || position?.skuCode || position?.inboundCode || '业务码缺失' },
@@ -239,6 +342,13 @@
<style scoped>
@import '../dispatch/fulfillment.scss';
.discard-carton-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 40px; cursor: pointer; }
.discard-carton-heading > .business-code { flex-shrink: 0; white-space: nowrap; }
.discard-carton-heading-end { display: flex; align-items: center; gap: 12px; }
.discard-carton-heading-end .scan-help { margin: 0; }
.discard-carton-chevron { flex-shrink: 0; width: 8px; height: 8px; margin-right: 3px; border-right: 2px solid #718095; border-bottom: 2px solid #718095; transform: rotate(45deg); }
.discard-carton-chevron.expanded { transform: rotate(225deg); }
.discard-scan-remove { margin-left: auto; padding: 4px 10px; border: 0; border-radius: 4px; background: #fef2f2; color: #dc2626; font-size: 12px; }
.discard-photo-section{margin:14px 0;padding-top:12px;border-top:1px solid #d9e3ee}.discard-photo-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin:12px 0}.discard-photo-tile image{display:block;width:100%;height:120px;border-radius:8px}
.outbound-line { margin-top: 9px; padding: 10px; border: 1px solid #e0e7ef; border-radius: 8px; background: #f8fafc; }
.outbound-line.selected { border: 2px solid #135ee8; background: #edf5ff; }
@@ -246,4 +356,10 @@
.line-code { color: #263b55; font-size: 12px; font-weight: 800; overflow-wrap: anywhere; }
.line-meta { margin-top: 4px; color: #718095; font-size: 10px; }
.missing-scan-contract { display: block; margin-top: 9px; padding: 9px; border-radius: 7px; color: #a56800; font-size: 10px; line-height: 16px; background: #fff6df; }
.discard-remark .card-title, .discard-remark .task-meta { color: #dc2626; }
.discard-scanned-count { display: block; margin-bottom: 8px; color: #dc2626; font-size: 15px; font-weight: 700; }
</style>
<style scoped lang="scss">
@import '../../styles/task-list.scss';
</style>
+62 -21
View File
@@ -7,12 +7,25 @@
<view class="profile-row"><text>作业仓库</text><text class="profile-value">{{ warehouseDisplay }}</text></view>
<view class="profile-row"><text>当前班次</text><text class="profile-value">以海外仓排班为准</text></view>
<view class="profile-row"><text>应用版本</text><text class="profile-value">{{ appVersion }}</text></view>
<button class="profile-row profile-row-action" @tap="checkUpdate"><text>检查更新</text><text class="profile-row-arrow">›</text></button>
<button class="profile-row profile-row-action" @tap="checkUpdate" :disabled="checkingUpdate"><text>{{ checkingUpdate ? '检查中…' : '检查更新' }}</text><text class="profile-row-arrow">›</text></button>
<button class="profile-row profile-row-action" @tap="showSettings"><text>系统设置</text><text class="profile-row-arrow">›</text></button>
<button class="profile-row profile-row-action" @tap="selectLanguage"><text>切换语言</text><view class="profile-row-end"><text class="profile-value">{{ languageLabel }}</text><text class="profile-row-arrow">›</text></view></button>
</view>
<button class="logout-action" @tap="openLogout">退出登录</button>
</view>
<uni-popup ref="languagePopup" type="bottom" background-color="transparent" :is-mask-click="true" @change="handleLanguageChange">
<view class="language-sheet" role="dialog" aria-modal="true" aria-label="选择语言">
<PopupHeader title="选择语言" @close="closeLanguage" />
<view class="language-options" role="radiogroup" aria-label="可选语言">
<button v-for="item in languageOptions" :key="item.value" class="language-option" :class="{ selected: locale === item.value }" role="radio" :aria-checked="locale === item.value" @tap="applyLanguage(item.value)">
<text class="language-option-name">{{ item.label }}</text>
<view v-if="locale === item.value" class="language-option-current"><text>已选择</text><uni-icons type="checkmarkempty" color="#135ee8" size="20" /></view>
</button>
</view>
<text class="language-sheet-hint">业务页面暂以中文显示,语言偏好将保存在本机。</text>
<button class="language-cancel" @tap="closeLanguage">取消</button>
</view>
</uni-popup>
<uni-popup ref="logoutPopup" type="center" :is-mask-click="true" @change="handleLogoutChange"><view class="logout-dialog-card" role="dialog" aria-modal="true" aria-label="确认退出登录"><PopupHeader title="确认退出登录?" tone="danger" @close="closeLogout" /><text class="logout-dialog-content">退出会撤销当前登录会话并清空本地作业草稿;未提交作业数据不得跨账号保留。</text><view class="logout-dialog-actions"><button class="logout-dialog-cancel" @tap="closeLogout">取消</button><button class="logout-dialog-confirm" @tap="logout">确认退出</button></view></view></uni-popup>
<view v-if="toast" class="app-toast warning" role="status">设置功能将在后续版本开放</view>
</view>
@@ -20,6 +33,7 @@
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import PopupHeader from '@/components/PopupHeader.vue'
import appManifest from '@/manifest.json'
import { getCurrentWarehouseProfile } from '@/api/inbound.js'
import { currentLocale, LOCALE_OPTIONS, setLocale } from '@/i18n/index.js'
import { applyWarehouseProfile, session, requireSession, getSessionRevision } from '@/stores/session.js'
@@ -27,7 +41,7 @@
import { checkForAppUpdate } from '@/utils/app-update.js'
export default {
components: { PopupHeader, PortalHeader },
data() { return { user: session.currentUser, appVersion: '0.0.1', locale: currentLocale(), logoutOpen: false, toast: false } },
data() { return { user: session.currentUser, appVersion: appManifest.versionName, checkingUpdate: false, locale: currentLocale(), languageOptions: LOCALE_OPTIONS, languageOpen: false, logoutOpen: false, toast: false } },
computed: {
displayName() { const name = String(this.user?.name || ''); return name && !/^[a-z0-9._-]+$/i.test(name) ? name : '仓库作业员' },
avatar() { return this.displayName.slice(0, 1).toUpperCase() },
@@ -35,34 +49,47 @@
warehouseDisplay() { return String(this.user?.warehouseName || '').trim() || '海外仓未关联' },
languageLabel() { return LOCALE_OPTIONS.find((item) => item.value === this.locale)?.label || 'English' }
},
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadAppVersion(); this.loadWarehouseProfile() },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.locale = currentLocale(); this.loadAppVersion(); this.loadWarehouseProfile() },
beforeUnmount() { clearTimeout(this._toastTimer) },
onBackPress() { if (!this.logoutOpen) return false; this.closeLogout(); return true },
onBackPress() { if (this.languageOpen) { this.closeLanguage(); return true }; if (!this.logoutOpen) return false; this.closeLogout(); return true },
methods: {
loadAppVersion() {
this.appVersion = appManifest.versionName
// #ifdef APP-PLUS
if (typeof plus === 'undefined' || !plus.runtime) return
this.appVersion = String(plus.runtime.version || this.appVersion)
plus.runtime.getProperty(plus.runtime.appid, (properties) => {
this.appVersion = String(properties?.version || plus.runtime.version || appManifest.versionName)
})
// #endif
},
checkUpdate() {
// #ifdef APP-PLUS
checkForAppUpdate({ manual: true })
return
// #endif
// #ifndef APP-PLUS
uni.showToast({ title: '请在 PDA App 中检查更新', icon: 'none' })
// #endif
async checkUpdate() {
if (this.checkingUpdate) return
this.checkingUpdate = true
try {
// #ifdef APP-PLUS
await checkForAppUpdate({ manual: true })
return
// #endif
// #ifndef APP-PLUS
uni.showToast({ title: '请在 PDA App 中检查更新', icon: 'none' })
// #endif
} finally { this.checkingUpdate = false }
},
selectLanguage() {
uni.showActionSheet({
itemList: LOCALE_OPTIONS.map((item) => item.label),
success: ({ tapIndex }) => {
const selected = LOCALE_OPTIONS[tapIndex]
if (!selected) return
this.locale = setLocale(selected.value)
uni.showToast({ title: `语言设置已保存:${selected.label}`, icon: 'none' })
}
})
this.languageOpen = true
this.$refs.languagePopup.open()
},
closeLanguage() { this.languageOpen = false; this.$refs.languagePopup.close() },
handleLanguageChange(event) { this.languageOpen = Boolean(event.show) },
applyLanguage(value) {
const selected = LOCALE_OPTIONS.find((item) => item.value === value)
if (!selected) return
if (selected.value !== this.locale) {
this.locale = setLocale(selected.value)
uni.showToast({ title: `语言偏好已保存:${selected.label}`, icon: 'none' })
}
this.closeLanguage()
},
async loadWarehouseProfile() { try { this.user = applyWarehouseProfile(await getCurrentWarehouseProfile()) } catch (_) { this.user = session.currentUser } },
openLogout() { this.logoutOpen = true; this.$refs.logoutPopup.open() }, closeLogout() { this.logoutOpen = false; this.$refs.logoutPopup.close() }, handleLogoutChange(event) { this.logoutOpen = Boolean(event.show) },
@@ -83,3 +110,17 @@
}
}
</script>
<style scoped>
.profile-row-end{flex-shrink:0}
.profile-row-end .profile-value{max-width:none;flex-shrink:0;white-space:nowrap;overflow-wrap:normal}
.language-sheet{box-sizing:border-box;width:100%;max-width:480px;margin:0 auto;padding:4px 16px calc(16px + env(safe-area-inset-bottom,0px));border-radius:20px 20px 0 0;background:#fff}
.language-options{display:flex;flex-direction:column;gap:8px;margin-top:8px}
.language-option{display:flex;box-sizing:border-box;align-items:center;justify-content:space-between;gap:12px;width:100%;min-height:52px;margin:0;padding:12px 14px;border:1px solid #dce5f0;border-radius:10px;color:#1f2937;font-size:15px;line-height:22px;text-align:left;background:#fff}
.language-option::after,.language-cancel::after{border:0}
.language-option.selected{border-color:#b8d1f5;color:#135ee8;background:#f1f6ff}
.language-option-name{min-width:0;white-space:nowrap}
.language-option-current{display:flex;flex-shrink:0;align-items:center;gap:6px;color:#135ee8;font-size:12px;line-height:20px}
.language-sheet-hint{display:block;margin:12px 2px;color:#718095;font-size:11px;line-height:18px}
.language-cancel{display:flex;align-items:center;justify-content:center;width:100%;min-height:44px;margin:0;padding:0 12px;border:1px solid #dce5f0;border-radius:10px;color:#526174;font-size:14px;line-height:22px;background:#f7f9fc}
</style>
+12 -7
View File
@@ -1,18 +1,19 @@
<template>
<view v-if="user" class="page-shell portal-page work-page">
<PortalHeader :title="title" :user="user" />
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content" :class="{ 'pda-task-list': !showCurrent && stage !== 'review' }">
<view v-if="showCurrent" class="detail-toolbar"><button :disabled="busy" @tap="closeCurrent">‹ 返回列表</button></view>
<template v-if="!showCurrent">
<view class="row-head candidate-toolbar"><text class="card-title">{{ candidateHeading }}</text><button v-if="stage !== 'review'" class="link-button" :disabled="loading" @tap="bootstrap">{{ loading ? '刷新中…' : '刷新' }}</button></view>
<view class="row-head candidate-toolbar" :class="{ 'pda-list-toolbar': stage !== 'review' }"><text class="card-title">{{ candidateHeading }}<text v-if="stage !== 'review' && !error"> · {{ visibleCandidates.length }} 单</text></text><button v-if="stage !== 'review'" class="link-button" :disabled="loading" @tap="bootstrap"><uni-icons type="refresh" color="#135ee8" size="18" /><text>{{ loading ? '刷新中…' : '刷新' }}</text></button></view>
<view v-if="loading && !visibleCandidates.length" class="empty-box"><uni-load-more status="loading" /><text class="empty-copy">正在加载换标任务…</text></view>
<view v-else-if="!visibleCandidates.length" class="empty-box"><uni-icons type="info" color="#8090a3" size="32" /><text class="empty-copy">{{ emptyText }}</text></view>
<view v-else-if="stage !== 'review'" class="task-list">
<view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card relabel-candidate" :class="{ current: current?.relabelNo === item.relabelNo, 'rework-card': item.reviewDecision === 'REWORK' }" @tap="openExecution(item)">
<view class="row-head"><text class="business-code">{{ item.relabelNo }}</text><text class="status-pill">{{ statusLabel(item.taskStatus || item.applicationStatus) }}</text></view>
<view class="candidate-kind"><text>{{ relabelTypeName(item.operationType) }}</text><text v-if="item.reviewDecision === 'REWORK'" class="rework-tag">退回重做</text></view>
<view class="candidate-footer"><text class="candidate-progress">已换标 <text class="candidate-progress-value">{{ item.relabelledPackages }} / {{ item.expectedPackages }}</text></text><text v-if="item.skippedPackages + item.exceptionPackages > 0" class="candidate-warning">跳过/异常 {{ item.skippedPackages + item.exceptionPackages }}</text><text class="candidate-enter">进入 ›</text></view>
<view v-else-if="stage !== 'review'" class="task-list pda-list-cards">
<view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card relabel-candidate pda-task-card" :class="{ current: current?.relabelNo === item.relabelNo, 'rework-card': item.reviewDecision === 'REWORK' }" hover-class="pda-task-card-pressed" @tap="openExecution(item)">
<view class="pda-task-head"><text class="pda-task-no">{{ item.relabelNo }}</text><text class="status-pill pda-task-status">{{ statusLabel(item.taskStatus || item.applicationStatus) }}</text></view>
<view class="candidate-kind pda-task-flags"><text>{{ relabelTypeName(item.operationType) }}</text><text v-if="item.reviewDecision === 'REWORK'" class="rework-tag">退回重做</text></view>
<view class="pda-task-metrics" :class="{ 'has-three': item.skippedPackages + item.exceptionPackages > 0 }"><view><strong>{{ item.expectedPackages }}</strong><text>应处理</text></view><view><strong>{{ item.relabelledPackages }}</strong><text>已换标</text></view><view v-if="item.skippedPackages + item.exceptionPackages > 0" class="pda-task-metric-warning"><strong>{{ item.skippedPackages + item.exceptionPackages }}</strong><text>跳过/异常</text></view></view>
<view class="pda-task-footer"><view class="pda-task-action"><text>进入</text><uni-icons type="right" color="#135ee8" size="14" /></view></view>
</view>
</view>
<view v-else class="task-list">
@@ -391,3 +392,7 @@
.summary-metrics{display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:4px;margin-left:auto;font-size:9px;line-height:18px}.summary-metric{display:inline-block;padding:1px 4px;border-radius:4px;background:#dcfce7;color:#16a34a;font-weight:700;white-space:nowrap}
.candidate-toolbar{align-items:center;margin-bottom:12px}.candidate-toolbar .card-title{margin-bottom:0;font-size:16px;line-height:24px}.candidate-toolbar .link-button{display:flex;align-items:center;justify-content:center;box-sizing:border-box;min-width:88px;height:44px;min-height:44px;margin:0;padding:0 16px;border:1px solid #9dc0f4;border-radius:8px;color:#135ee8;font-size:16px;font-weight:700;line-height:normal;background:#eaf2ff}.candidate-toolbar .link-button::after{border:0}.candidate-toolbar .link-button[disabled]{color:#718095;border-color:#dce5f0;background:#e8edf3}
.relabel-candidate .business-code{min-width:0;font-size:18px;line-height:24px;overflow-wrap:anywhere}.relabel-candidate .status-pill{font-size:12px;line-height:18px}.candidate-kind{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:8px;color:#52647c;font-size:14px;line-height:22px}.candidate-footer{display:flex;align-items:center;flex-wrap:wrap;gap:6px 12px;margin-top:10px}.candidate-progress{color:#52647c;font-size:14px;line-height:24px}.candidate-progress-value{color:#135ee8;font-size:18px;font-weight:700}.candidate-warning{color:#b54708;font-size:12px;line-height:20px}.candidate-enter{margin-left:auto;color:#135ee8;font-size:14px;font-weight:600;line-height:24px}</style>
<style scoped lang="scss">
@import '../../styles/task-list.scss';
</style>
+35 -9
View File
@@ -1,19 +1,32 @@
<template>
<view v-if="user" class="page-shell portal-page">
<view v-if="user" class="page-shell portal-page" :class="{ 'pda-task-list counting-list-page': !task }">
<PortalHeader title="退货箱内清点" :user="user" />
<view class="work-content counting-content">
<view class="work-card"><ScanTargetInput name="carton" v-model="scanCode" label="箱号或托盘号" placeholder="扫描箱号或托盘号" :active="true" @submit="scan" /><view class="actions"><button v-if="task || scanResult" class="secondary small" :disabled="busy" @tap="back">返回任务</button></view></view>
<view class="work-card counting-scan-card"><ScanTargetInput name="carton" v-model="scanCode" label="箱号或托盘号" placeholder="扫描箱号或托盘号" :active="true" @submit="scan" /><view v-if="task || scanResult" class="actions"><button class="secondary small" :disabled="busy" @tap="back">返回任务</button></view></view>
<view v-if="error" class="work-card error-card"><text>{{ error.message }}</text><text v-if="error.code" class="hint">错误码:{{ error.code }}</text><text v-if="error.requestId" class="hint">请求号:{{ error.requestId }}</text><text v-if="error.traceId" class="hint">跟踪号:{{ error.traceId }}</text></view>
<template v-if="!task">
<view v-if="activeTask" class="work-card"><text class="card-title">有未完成的清点任务</text><text class="hint">{{ activeTask.returnNo }} · {{ activeTask.palletCode }}</text><button class="secondary small" :disabled="busy" @tap="choose(activeTask)">继续未完成清点</button></view>
<view v-for="row in tasks" :key="row.receiptNo" class="work-card" @tap="choose(row)"><text class="card-title">{{ row.palletCode }}</text><text class="hint">{{ row.returnNo }}</text><text class="hint">{{ row.boxes.length }} 箱 · {{ row.status === 'COUNTING' ? '清点中' : '待清点' }}</text><text class="entry">{{ row.status === 'COUNTING' ? '继续清点' : '开始清点' }} ›</text></view>
<view v-if="!error && !busy && !tasks.length" class="work-card hint">{{ scanResult ? '无需清点' : '暂无待清点的托盘签收任务' }}</view>
<view class="pda-list-toolbar"><text>清点任务<text v-if="!listLoading && !error"> · {{ total }} 项</text></text><button v-if="!scanResult" :disabled="busy" @tap="load"><uni-icons type="refresh" color="#135ee8" size="18" /><text>{{ listLoading ? '刷新中…' : '刷新' }}</text></button></view>
<button v-if="activeTask && !activeTaskListed" class="pda-task-card current counting-resume-card" hover-class="pda-task-card-pressed" :disabled="busy" @tap="choose(activeTask)">
<text class="counting-resume-title">未完成的清点任务</text>
<view class="pda-task-head"><text class="pda-task-no">{{ activeTask.palletCode || '未关联托盘' }}</text><text class="pda-task-status counting-status-active">清点中</text></view>
<text class="pda-task-meta">退货单:{{ activeTask.returnNo || '—' }}</text>
<view class="pda-task-footer"><view class="pda-task-action"><text>{{ openingReceiptNo === activeTask.receiptNo ? '打开中…' : '继续清点' }}</text><uni-icons type="right" color="#135ee8" size="14" /></view></view>
</button>
<view v-if="tasks.length" class="pda-list-cards">
<button v-for="row in tasks" :key="row.receiptNo" class="pda-task-card counting-task-card" :class="{ current: activeTask?.receiptNo && activeTask.receiptNo === row.receiptNo }" hover-class="pda-task-card-pressed" :disabled="busy" @tap="choose(row)">
<view class="pda-task-head"><text class="pda-task-no">{{ row.palletCode || '未关联托盘' }}</text><text class="pda-task-status counting-status" :class="{ 'counting-status-active': row.status === 'COUNTING' }">{{ row.status === 'COUNTING' ? '清点中' : '待清点' }}</text></view>
<text class="pda-task-meta">退货单:{{ row.returnNo || '—' }}</text>
<view class="pda-task-footer"><view class="counting-task-facts"><text>{{ row.boxes.length }} 箱</text><text v-if="activeTask?.receiptNo && activeTask.receiptNo === row.receiptNo" class="counting-current-label">当前任务</text></view><view class="pda-task-action"><text>{{ openingReceiptNo === row.receiptNo ? '打开中…' : row.status === 'COUNTING' ? '继续清点' : '开始清点' }}</text><uni-icons type="right" color="#135ee8" size="14" /></view></view>
</button>
</view>
<view v-if="listLoading && !tasks.length && !activeTask" class="work-card hint">正在加载清点任务…</view>
<view v-if="!error && !busy && !tasks.length && !activeTask" class="work-card hint">{{ scanResult ? '无需清点' : '暂无待清点的托盘签收任务' }}</view>
<view v-if="!scanResult && total > 10" class="actions pager"><button class="secondary small" :disabled="busy || page <= 1" @tap="changePage(-1)">上一页</button><text>{{ page }} / {{ Math.max(1, Math.ceil(total / 10)) }}</text><button class="secondary small" :disabled="busy || page * 10 >= total" @tap="changePage(1)">下一页</button></view>
</template>
<template v-else>
<view class="work-card"><text class="card-title">{{ task.palletCode }}</text><text class="hint">{{ task.status === 'COUNTING' ? '清点中' : '已完成' }}</text><text class="hint">{{ task.returnNo }} · {{ task.boxes.length }} 箱</text><view v-for="row in totals" :key="row.skuCode" class="total-row" :class="{ matched: row.actual === row.quantity }"><text>{{ row.skuCode }}</text><text>签收 {{ row.quantity }} / 已填 {{ row.actual }}</text></view></view>
<view v-for="(box, index) in task.boxes" :id="`box-${index}`" :key="box.cartonMark" class="work-card box-card" :class="{ selected: box.cartonMark === selectedMark }">
<view class="row-head"><text class="card-title">第 {{ index + 1 }} 箱</text><button v-if="task.status === 'COUNTING'" class="link-button" :disabled="busy" @tap="addSku(box)">添加 SKU</button></view><text class="box-mark">{{ box.cartonMark }}</text><CartonCountFields :box="box" :sku-codes="skuCodes" :disabled="busy || task.status !== 'COUNTING'" />
<view class="row-head"><text class="card-title">第 {{ index + 1 }} 箱</text><button v-if="task.status === 'COUNTING'" class="link-button" :disabled="busy" @tap="addSku(box)">添加 SKU</button></view><text class="box-mark">{{ box.cartonMark }}</text><CartonCountFields volume-unit="m3" :box="box" :sku-codes="skuCodes" :disabled="busy || task.status !== 'COUNTING'" />
</view>
<view class="work-card"><button v-if="task.status === 'COUNTING'" class="primary" :disabled="busy || !ready" @tap="confirm">{{ busy ? '提交中…' : '确认全部箱内清点' }}</button><text v-else class="success">箱内清点已完成,可以继续换标或出库。</text></view>
</template>
@@ -29,8 +42,9 @@ import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { listReturnCountTasks, scanReturnCountTasks, getCurrentReturnCountTask, beginReturnCountTask, confirmReturnCount } from '@/api/return-counting.js'
export default {
components: { PortalHeader, ScanTargetInput, CartonCountFields },
data() { return { user: session.currentUser, busy: false, error: null, scanCode: '', scanResult: '', page: 1, total: 0, tasks: [], task: null, activeTask: null, selectedMark: '', requestKey: '', signature: '', scannerStop: null } },
data() { return { user: session.currentUser, busy: false, listLoading: false, openingReceiptNo: '', error: null, scanCode: '', scanResult: '', page: 1, total: 0, tasks: [], task: null, activeTask: null, selectedMark: '', requestKey: '', signature: '', scannerStop: null } },
computed: {
activeTaskListed() { return Boolean(this.activeTask?.receiptNo && this.tasks.some(row => row.receiptNo === this.activeTask.receiptNo)) },
skuCodes() { return this.task?.expectedItems.map(row => row.skuCode) || [] },
totals() { return (this.task?.expectedItems || []).map(row => ({ ...row, quantity: Number(row.quantity), actual: this.task.boxes.reduce((sum, box) => sum + box.items.filter(line => line.skuCode === row.skuCode).reduce((value, line) => value + (Number(line.quantity) || 0), 0), 0) })) },
ready() { return this.task?.status === 'COUNTING' && this.totals.length > 0 && this.totals.every(row => row.actual === row.quantity) && this.task.boxes.every(box => box.items.length && (!('length' in box) || (Number(box.length) > 0 && Number(box.width) > 0 && Number(box.height) > 0 && Number(box.weight) > 0)) && box.items.every(line => this.skuCodes.includes(line.skuCode) && Number.isSafeInteger(Number(line.quantity)) && Number(line.quantity) > 0) && new Set(box.items.map(line => line.skuCode)).size === box.items.length) },
@@ -44,11 +58,12 @@ export default {
stopScanner() { this.scannerStop?.(); this.scannerStop = null },
async run(work) { if (this.busy) return; this.busy = true; this.error = null; try { await work() } catch (error) { this.error = { message: error?.message || '清点操作失败', code: error?.code, requestId: error?.requestId, traceId: error?.traceId } } finally { this.busy = false } },
async fetchTasks() { const [active, value] = await Promise.all([getCurrentReturnCountTask(), listReturnCountTasks({ page: this.page })]); this.activeTask = active; this.tasks = value.records || []; this.total = Number(value.total) || 0 },
async load() { await this.run(() => this.fetchTasks()) },
async load() { await this.run(async () => { this.listLoading = true; try { await this.fetchTasks() } finally { this.listLoading = false } }) },
changePage(delta) { this.page += delta; this.load() },
ask(options) { return new Promise((resolve, reject) => uni.showModal({ ...options, success: resolve, fail: reject })) },
async choose(row) {
await this.run(() => this.enterTask(row, true));
if (!row?.receiptNo || this.busy) return;
await this.run(async () => { this.openingReceiptNo = row.receiptNo; try { await this.enterTask(row, true) } finally { this.openingReceiptNo = '' } });
},
async enterTask(row, confirmStart = false) {
const active = await getCurrentReturnCountTask(); this.activeTask = active;
@@ -91,4 +106,15 @@ export default {
.counting-content .primary{display:flex;align-items:center;justify-content:center;padding:0 12px;line-height:normal;text-align:center}
.actions{display:flex;align-items:center;gap:8px;margin-top:10px}.small{width:auto;min-height:32px;margin:0;padding:0 12px;font-size:12px;line-height:32px}.search{width:100%;font-size:12px}.pager{justify-content:center;font-size:12px}.entry,.success{color:#135ee8;font-size:12px}.total-row{display:flex;justify-content:space-between;gap:12px;font-size:11px;margin-top:8px;color:#c67716}.total-row.matched{color:#22804a}.box-mark{display:block;color:#65768b;font-size:11px;margin:6px 0;word-break:break-all}.count-line{display:flex;align-items:center;gap:8px;margin-top:8px}.sku-picker{flex:1;min-width:0;font-size:11px;word-break:break-all}.quantity{flex:none;width:70px;height:32px;text-align:center;font-size:12px}.box-card.selected{border-color:#135ee8}.row-head .card-title{font-size:13px}
.measure-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:7px;margin:10px 0 6px}.measure-grid label text{display:block;margin-bottom:4px;color:#65768b;font-size:10px}.measure-input{box-sizing:border-box;width:100%;height:34px;padding:0 8px;border:1px solid #cbd9e8;border-radius:8px;color:#203750;font-size:13px;text-align:center;background:#fbfdff}.volume-chip{display:flex;align-items:baseline;justify-content:flex-end;gap:4px;margin:4px 0 8px;color:#728197;font-size:10px}.volume-chip strong{color:#135ee8;font-size:14px}
@import '../../styles/task-list.scss';
.counting-list-page{background:#f1f5fd}
.counting-list-page .counting-content{padding:14px 14px calc(42px + env(safe-area-inset-bottom,0px))}
.counting-list-page .work-card{box-shadow:none}
.counting-list-page .pda-task-head{align-items:flex-start}
.counting-list-page .counting-resume-card{margin-bottom:11px}
.counting-resume-title{display:block;margin-bottom:8px;color:#526174;font-size:12px;line-height:18px}
.counting-status{color:#8a570c;background:#fff1dd}
.counting-status-active{color:#135ee8;background:#e4efff}
.counting-task-facts{display:flex;align-items:center;flex-wrap:wrap;gap:8px;min-width:0;color:#718095;font-size:12px;line-height:20px;overflow-wrap:anywhere}
.counting-current-label{color:#135ee8;font-size:11px}
</style>
+58 -23
View File
@@ -1,17 +1,29 @@
<template>
<view v-if="user" class="page-shell portal-page work-page">
<view v-if="user" class="page-shell portal-page work-page receipt-page" :class="{ 'pda-task-list receipt-lookup-page': receiptDomain === 'customer' && !current && !result }">
<PortalHeader title="退货入库" :user="user" />
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
<view v-if="!current && !result" class="work-card">
<view v-if="!current && !result && !exceptionWorking" class="receipt-domain-tabs" role="tablist" aria-label="退货入库类型">
<button :class="['receipt-domain-tab', { active: receiptDomain === 'customer' }]" role="tab" :aria-selected="receiptDomain === 'customer'" :disabled="busy" @tap="switchDomain('customer')">客户退货入库</button>
<button :class="['receipt-domain-tab', { active: receiptDomain === 'exception' }]" role="tab" :aria-selected="receiptDomain === 'exception'" :disabled="busy" @tap="switchDomain('exception')">异常退货入库</button>
</view>
<ExceptionReturnWork v-if="receiptDomain === 'exception'" @working-change="exceptionWorking = $event" />
<scroll-view v-if="receiptDomain === 'customer'" class="work-scroll" scroll-y><view class="portal-content work-content">
<view v-if="!current && !result" class="work-card receipt-lookup-card">
<text class="card-title">扫描 SKU 69 码</text>
<ScanTargetInput name="lookup" v-model="lookupCode" label="SKU 69 码" placeholder="扫描商品 69 码,自动带出退货单号" :active="activeTarget === 'lookup'" @activate="activeTarget = 'lookup'" @submit="lookup" />
<ScanTargetInput name="lookup" v-model="lookupCode" label="SKU 69 码" placeholder="扫描或输入69码" :active="activeTarget === 'lookup'" @activate="activeTarget = 'lookup'" @submit="lookup" />
<button class="secondary receipt-lookup-button" :disabled="busy || !lookupCode.trim()" @tap="lookup()">{{ busy ? '查询中…' : '查询退货单' }}</button>
</view>
<view v-if="!current && candidates !== null && !result" class="section-gap">
<view class="row-head candidate-heading"><text class="card-title">匹配退货单</text><text class="meta-line">{{ candidates.length }} 个候选</text></view>
<view v-if="!candidates.length" class="empty-box"><text class="empty-copy">{{ entryMode === 'SKU' ? '当前绑定仓没有包含该 SKU 的待签收退货单' : '当前绑定仓没有匹配的待签收或签收中退货单' }}</text></view>
<view v-else class="task-list"><button v-for="item in candidates" :key="item.returnNo" class="task-card candidate-card" @tap="selectCandidate(item)"><view class="row-head"><text class="business-code">{{ item.returnNo }}</text><text class="status-pill">{{ statusLabel(item.status) }}</text></view><text class="meta-line">客户参考号:{{ item.customerReferenceNo || '—' }}</text><view class="metric-row"><view class="metric"><strong>{{ item.skuReceivedQuantity }}/{{ item.skuExpectedQuantity }}</strong><text>该 SKU</text></view><view class="metric"><strong>{{ item.orderReceivedQuantity }}/{{ item.orderExpectedQuantity }}</strong><text>整单进度</text></view><text class="select-link">选择 ›</text></view></button></view>
<view class="pda-list-toolbar"><text>匹配退货单<text v-if="!busy && !error"> · {{ candidates.length }} 单</text></text></view>
<view v-if="!candidates.length && !busy && !error" class="empty-box receipt-candidate-empty"><text class="empty-copy">{{ entryMode === 'SKU' ? '当前绑定仓没有包含该 SKU 的待签收退货单' : '当前绑定仓没有匹配的待签收或签收中退货单' }}</text></view>
<view v-if="candidates.length" class="pda-list-cards">
<button v-for="item in candidates" :key="item.returnNo" class="pda-task-card receipt-candidate-card" hover-class="pda-task-card-pressed" :disabled="busy" @tap="selectCandidate(item)">
<view class="pda-task-head"><text class="pda-task-no">{{ item.returnNo }}</text><text class="status-pill pda-task-status" :class="{ 'receipt-status-active': item.status === 'PARTIALLY_RECEIVED', good: item.status === 'COMPLETED' }">{{ statusLabel(item.status) }}</text></view>
<text v-if="String(item.customerReferenceNo || '').trim() && String(item.customerReferenceNo || '').trim() !== String(item.returnNo || '').trim()" class="pda-task-meta">客户参考号:{{ item.customerReferenceNo }}</text>
<view class="pda-task-metrics"><view><strong>{{ item.skuReceivedQuantity }} / {{ item.skuExpectedQuantity }}</strong><text>SKU 实收 / 预期</text></view><view><strong>{{ item.orderReceivedQuantity }} / {{ item.orderExpectedQuantity }}</strong><text>整单实收 / 预期</text></view></view>
<view class="pda-task-footer"><view class="pda-task-action"><text>选择</text><uni-icons type="right" color="#135ee8" size="14" /></view></view>
</button>
</view>
</view>
<template v-if="current && !result">
<view class="work-card receipt-order-card"><view class="row-head"><text class="card-title">退货单</text><button class="link-button" @tap="clearCurrent">更换退货单</button></view><view class="receipt-order-line"><text class="business-code">{{ current.returnNo }}</text><text class="status-pill">{{ statusLabel(current.status) }}</text></view></view>
@@ -19,10 +31,10 @@
<view class="work-card">
<text class="card-title">本次实物单元</text>
<view class="unit-tabs"><button v-for="item in unitOptions" :key="item.value" :class="['unit-tab', {active: unitType === item.value}]" @tap="selectUnitType(item.value)">{{ item.label }}</button></view>
<view v-if="unitType === 'PARCEL'"><ScanTargetInput name="unit" v-model="unitCode" label="本批快递单号(选填,只填写一次)" placeholder="扫描本批快递面单,可留空" :active="activeTarget === 'unit'" @activate="activeTarget = 'unit'" @submit="acceptUnit" /><text class="hint">本批共用一个快递单号;填写包裹数,再逐包裹录入 SKU 数量,各包裹单独生成仓库标签。</text></view>
<view v-if="unitType === 'PARCEL'"><ScanTargetInput name="unit" v-model="unitCode" label="本批快递单号(选填,只填写一次)" placeholder="扫描本批快递面单,可留空" :active="activeTarget === 'unit'" @activate="activeTarget = 'unit'" @submit="acceptUnit" /></view>
<template v-else>
<view><text class="field-label">托盘码({{ unitType === 'BOX' ? '选填' : '必填' }})</text><ScanTargetInput name="pallet" v-model="palletCode" label="托盘码" placeholder="扫描已有托盘或新托盘标签" :active="activeTarget === 'pallet'" @activate="activeTarget = 'pallet'" @submit="acceptPallet" /><text v-if="palletCheck" class="scan-hint">{{ palletCheck.empty ? '空托盘,可绑定当前退货单和 SKU' : `当前单托盘,已有 ${palletCheck.skuCodes.join('、')},共 ${palletCheck.quantity} 件` }}</text><button class="secondary pallet-generate" :disabled="busy" @tap="generatePallet">生成新托盘</button></view>
<view v-if="unitType === 'PALLET'" class="dimension-grid"><label><text>托盘长(cm)</text><input class="number-input" type="digit" v-model="palletLength" placeholder="请输入长度(cm)" /></label><label><view class="dimension-label"><text>托盘宽(cm)</text><text class="volume-text">体积 {{ palletVolume }} cm³</text></view><input class="number-input" type="digit" v-model="palletWidth" placeholder="请输入宽度(cm)" /></label><label><text>托盘高(cm)</text><input class="number-input" type="digit" v-model="palletHeight" placeholder="请输入高度(cm)" /></label><label><text>托盘重量(kg)</text><input class="number-input" type="digit" v-model="palletWeight" placeholder="请输入重量(kg)" /></label></view>
<view v-if="unitType === 'PALLET'" class="dimension-grid"><label><text>托盘长(cm)</text><input class="number-input" type="digit" v-model="palletLength" placeholder="请输入长度(cm)" /></label><label><view class="dimension-label"><text>托盘宽(cm)</text><text class="volume-text">体积 {{ palletVolume }} m³</text></view><input class="number-input" type="digit" v-model="palletWidth" placeholder="请输入宽度(cm)" /></label><label><text>托盘高(cm)</text><input class="number-input" type="digit" v-model="palletHeight" placeholder="请输入高度(cm)" /></label><label><text>托盘重量(kg)</text><input class="number-input" type="digit" v-model="palletWeight" placeholder="请输入重量(kg)" /></label></view>
<view><text class="field-label">快递单号(选填)</text><ScanTargetInput name="tracking" v-model="trackingNo" label="快递单号" placeholder="扫描快递面单" :active="activeTarget === 'tracking'" @activate="activeTarget = 'tracking'" @submit="acceptTracking" /></view>
</template>
</view>
@@ -36,7 +48,7 @@
<template v-if="box.confirmed"><text class="meta-line">{{ box.length }} × {{ box.width }} × {{ box.height }} cm · {{ box.weight }} kg · {{ box.originalPackaging ? '原包装' : '新包装' }}</text><text v-for="item in selectedItems.filter(item => Number(box.items[item.skuCode]) > 0)" :key="item.skuCode" class="meta-line">{{ item.skuCode }} · {{ box.items[item.skuCode] }} 件</text></template>
<template v-else>
<view class="dimension-grid unit-dimension-grid"><label><text>长(cm)</text><input class="number-input" type="digit" v-model="box.length" placeholder="长" :disabled="busy" /></label><label><text>宽(cm)</text><input class="number-input" type="digit" v-model="box.width" placeholder="宽" :disabled="busy" /></label><label><text>高(cm)</text><input class="number-input" type="digit" v-model="box.height" placeholder="高" :disabled="busy" /></label><label><text>重量(kg)</text><input class="number-input" type="digit" v-model="box.weight" placeholder="重量" :disabled="busy" /></label></view>
<view class="unit-volume"><text>体积</text><strong>{{ rowVolume(box) }}</strong><text>cm³</text></view>
<view class="unit-volume"><text>体积</text><strong>{{ rowVolume(box) }}</strong><text>m³</text></view>
<view class="packaging-choice"><text>是否使用原{{ unitType === 'PARCEL' ? '包装' : '箱' }}</text><button :class="['choice-button', {selected: box.originalPackaging === true}]" :disabled="busy" @tap="setOriginalPackaging(index, true)">是</button><button :class="['choice-button', {selected: box.originalPackaging === false}]" :disabled="busy" @tap="setOriginalPackaging(index, false)">否</button></view>
<view v-for="item in selectedItems" :key="item.skuCode" class="quantity-row"><text>{{ item.skuCode }} · 数量</text><input class="number-input unit-quantity-input" type="number" :value="box.items[item.skuCode] || ''" :placeholder="'剩余 ' + boxRemaining(index, item)" :disabled="busy" @input="setBoxQuantity(index, item.skuCode, $event.detail.value)" /></view>
<button v-if="boxRowValid(index)" class="primary" :disabled="busy" @tap="confirmBoxRow(index)">确认第 {{ index + 1 }} {{ unitType === 'PARCEL' ? '包裹' : '箱' }}</button>
@@ -44,14 +56,12 @@
</view>
</template>
<view v-if="unitType === 'PALLET'" class="item-list receipt-sku-list"><view v-for="item in selectedItems" :key="item.skuCode" class="item-card"><view class="row-head"><view><text class="business-code">{{ item.skuCode }}</text><text class="meta-line">{{ item.productNameZh || '商品名称未填写' }}</text></view><text class="remaining-copy">剩余 {{ remaining(item) }}</text></view><view class="sku-quantity-fields"><view class="quantity-row"><text>数量</text><input class="number-input" type="number" :value="quantity(item.skuCode)" placeholder="0" @input="setQuantity(item, $event.detail.value)" /></view><view v-if="unitType === 'PALLET'" class="quantity-row"><text>SKU 箱数</text><input class="number-input" type="number" :value="boxCount(item.skuCode)" placeholder="0" @input="setBoxCount(item, $event.detail.value)" /></view></view></view></view></view>
<view v-if="unitType === 'PALLET'" class="summary-bar"><view><text class="summary-label">{{ unitType === 'PARCEL' ? '本次合计' : '本托合计' }}</text><text class="summary-value">{{ receiptQuantity }}</text></view><button class="primary submit-button" :disabled="!canConfirm || busy" @tap="confirmReceipt">{{ busy ? '提交中…' : unitType === 'PARCEL' ? '确认包裹签收' : '确认签收并入托' }}</button></view>
<view class="summary-bar"><view><text class="summary-label">{{ unitType === 'PALLET' ? '本托合计' : '本次合计' }}</text><text class="summary-value">{{ receiptQuantity }}</text></view><view class="receipt-action-buttons"><button class="primary submit-button" :disabled="busy || !canConfirm || (unitType !== 'PALLET' && !allBoxRowsConfirmed)" @tap="confirmReceipt">{{ busy ? '提交中…' : '确认签收' }}</button><button class="secondary manual-complete" :disabled="busy" @tap="manualComplete">缺货完结</button></view></view>
<view class="work-card"><view class="row-head"><view><text class="card-title">退货打托照片</text></view><text class="status-pill">{{ photoFileIds.length }} / 20 张</text></view><button class="secondary" :disabled="busy || photoFileIds.length >= 20" @tap="choosePhoto()">拍照或从相册选择</button><view v-if="photoFileIds.length" class="photo-grid"><view v-for="(fileId,index) in photoFileIds" :key="fileId" class="photo-tile"><image class="photo-thumbnail" :src="photoUrls[fileId]" mode="aspectFill" @tap="previewPhoto(fileId)" /><text class="photo-name">{{ photoNames[index] || '退货打托照片' }}</text><button class="link-button" :disabled="busy" @tap="removePhoto(index)">移除</button></view></view></view>
<view v-if="unitType !== 'PALLET'" class="summary-bar"><view><text class="summary-label">本次合计</text><text class="summary-value">{{ receiptQuantity }}</text></view><button class="primary submit-button" :disabled="busy || !canConfirm || !allBoxRowsConfirmed || !photoFileIds.length" @tap="confirmReceipt">{{ busy ? '提交中…' : '确认签收入库' }}</button></view>
<button v-if="current.status === 'PARTIALLY_RECEIVED'" class="secondary manual-complete" :disabled="busy" @tap="manualComplete">缺货手动完结</button>
<view v-if="current.completedPallets?.length" class="work-card"><text class="card-title">本单已完成打托</text><view class="item-list"><view v-for="pallet in current.completedPallets" :key="pallet.palletCode" class="item-card"><text class="business-code">{{ pallet.palletCode }}</text><text class="meta-line">{{ pallet.skuCode }} · {{ pallet.quantity }} 件</text><text class="meta-line">签收批次:{{ pallet.receiptBatchNo }}</text></view></view></view>
</template>
<view v-if="result" class="success-box result-card"><text class="success-title">退货签收成功</text><view v-if="result.labels?.length"><view v-for="(label,index) in result.labels" :key="label.cartonMark" class="item-card"><text class="business-code">第 {{ index + 1 }} {{ result.unitType === 'PARCEL' ? '包裹' : '箱' }} · {{ label.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(label)">打印包裹/箱标签</button><text class="meta-line">签收已默认贴标</text></view></view><view v-else-if="result.printableHtml" class="item-card"><text class="business-code">{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }} · {{ result.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(result)">打印{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }}</button></view><button v-if="result.unitType === 'PALLET' && result.labels?.length" class="secondary" :disabled="busy" @tap="goCount">去箱内清点</button><text v-if="result.palletCode" class="result-carton">托盘:{{ result.palletCode }}</text><text class="meta-line">签收单:{{ result.receiptNo }}</text><text class="meta-line">计费批次:{{ result.receiptBatchNo }}</text><view class="metric-row"><view class="metric"><strong>{{ result.receivedQuantity }}/{{ result.expectedQuantity }}</strong><text>整单累计</text></view><view class="metric"><strong>{{ result.fullyMatched ? '已完成' : '签收中' }}</strong><text>退货单状态</text></view></view><button class="primary" :disabled="busy" @tap="nextReceipt">{{ result.continuationReturnNo ? (unitType === 'PALLET' ? '新增托盘,继续签收剩余货物' : '继续签收本单剩余货物') : '签收下一单' }}</button><button v-if="unitType === 'PALLET' && result.continuationReturnNo" class="secondary" :disabled="busy" @tap="continueScanning">继续签收</button></view>
<view v-if="result" class="success-box result-card"><view v-if="result.labels?.length"><view v-for="(label,index) in result.labels" :key="label.cartonMark" class="item-card"><text class="business-code">第 {{ index + 1 }} {{ result.unitType === 'PARCEL' ? '包裹' : '箱' }} · {{ label.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(label)">打印包裹/箱标签</button></view></view><view v-else-if="result.printableHtml" class="item-card"><text class="business-code">{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }} · {{ result.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(result)">打印{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }}</button></view><button v-if="result.unitType === 'PALLET' && result.labels?.length" class="secondary" :disabled="busy" @tap="goCount">去箱内清点</button><text v-if="result.palletCode" class="result-carton">托盘:{{ result.palletCode }}</text><view class="metric-row"><view class="metric"><strong>{{ result.receivedQuantity }}/{{ result.expectedQuantity }}</strong></view><view class="metric"><strong>{{ result.fullyMatched ? '已完成' : '签收中' }}</strong></view></view><button class="primary" :disabled="busy" @tap="nextReceipt">{{ result.continuationReturnNo ? (unitType === 'PALLET' ? '新增托盘,继续签收剩余货物' : '继续签收本单剩余货物') : '签收下一单' }}</button><button v-if="unitType === 'PALLET' && result.continuationReturnNo" class="secondary" :disabled="busy" @tap="continueScanning">继续签收</button></view>
<view v-if="error" class="error-box" role="alert"><text class="error-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><button v-if="current" class="secondary" :disabled="busy" @tap="reloadCurrent">刷新退货单</button></view>
</view></scroll-view>
@@ -59,7 +69,8 @@
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ExceptionReturnWork from '@/components/ExceptionReturnWork.vue'
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { checkReturnPallet, confirmReturnReceipt, createReturnPallet, downloadReturnPalletPhoto, findReturnSkuCandidates, manuallyCompleteReturnReceipt, startReturnReceipt, uploadReturnPalletPhoto } from '@/api/return-receipt.js'
import { session, requireSession } from '@/stores/session.js'
@@ -68,8 +79,8 @@
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
export default {
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, current: null, candidates: null, result: null, receiptResults: [], selectedSku: '', palletCheck: null, newPalletHtml: '', unitType: 'PALLET', unitCode: '', boxRows: [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }], palletLength: '', palletWidth: '', palletHeight: '', palletWeight: '', unitOptions: [{ value: 'PALLET', label: '托盘' }, { value: 'BOX', label: '箱子' }, { value: 'PARCEL', label: '包裹' }], photoFileIds: [], photoNames: [], photoUrls: {}, activeTarget: 'lookup', busy: false, error: null } },
components: { PortalHeader, ScanTargetInput, ExceptionReturnWork },
data() { return { receiptDomain: 'customer', exceptionWorking: false, user: session.currentUser, current: null, candidates: null, result: null, receiptResults: [], selectedSku: '', palletCheck: null, newPalletHtml: '', unitType: 'PALLET', unitCode: '', boxRows: [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }], palletLength: '', palletWidth: '', palletHeight: '', palletWeight: '', unitOptions: [{ value: 'PALLET', label: '托盘' }, { value: 'BOX', label: '箱子' }, { value: 'PARCEL', label: '包裹' }], photoFileIds: [], photoNames: [], photoUrls: {}, activeTarget: 'lookup', busy: false, error: null } },
computed: {
entryMode: { get() { return warehouseWork.returnReceipt.entryMode }, set(value) { updateWarehouseWork('returnReceipt', { entryMode: value }) } },
lookupCode: { get() { return warehouseWork.returnReceipt.lookupCode }, set(value) { updateWarehouseWork('returnReceipt', { lookupCode: value }) } },
@@ -84,16 +95,17 @@
canConfirm() { if (!this.current || !this.selectedItems.length || this.receiptQuantity <= 0) return false; if (this.unitType !== 'PALLET') return this.boxRows.length > 0 && this.boxRows.every(box => Object.values(box.items).some(quantity => Number(quantity) > 0) && (!('length' in box) || (Number(box.length) > 0 && Number(box.width) > 0 && Number(box.height) > 0 && Number(box.weight) > 0 && typeof box.originalPackaging === 'boolean'))) && this.selectedItems.every(item => this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) <= this.remaining(item)); return (this.palletCode.trim() && this.palletCheck && Number(this.palletLength) > 0 && Number(this.palletWidth) > 0 && Number(this.palletHeight) > 0 && Number(this.palletWeight) > 0 && this.palletBoxCount > 0) && this.selectedItems.every((item) => this.quantity(item.skuCode) <= this.remaining(item) && (this.quantity(item.skuCode) === 0 || this.boxCount(item.skuCode) > 0)); },
unitLabel() { return this.unitType === 'PALLET' ? '托盘码' : this.unitType === 'BOX' ? '箱号/箱唛' : '包裹码/快递单号' },
unitPlaceholder() { return this.unitType === 'PALLET' ? '扫描托盘标签' : this.unitType === 'BOX' ? '扫描箱唛或录入箱号' : '扫描包裹面单或录入包裹码' }
,palletVolume() { const l = Number(this.palletLength) || 0; const w = Number(this.palletWidth) || 0; const h = Number(this.palletHeight) || 0; return l > 0 && w > 0 && h > 0 ? (l * w * h).toFixed(2) : '—' }
,palletVolume() { const l = Number(this.palletLength) || 0; const w = Number(this.palletWidth) || 0; const h = Number(this.palletHeight) || 0; return l > 0 && w > 0 && h > 0 ? (l * w * h / 1000000).toFixed(6) : '—' }
},
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.entryMode = 'SKU'; this.startScanner(); if (warehouseWork.returnReceipt.currentReturnNo && !this.current && !this.busy) this.openOrder(warehouseWork.returnReceipt.currentReturnNo, 'SKU', false) },
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.entryMode = 'SKU'; if (this.receiptDomain === 'customer') this.startScanner(); if (this.receiptDomain === 'customer' && warehouseWork.returnReceipt.currentReturnNo && !this.current && !this.busy) this.openOrder(warehouseWork.returnReceipt.currentReturnNo, 'SKU', false) },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: {
switchDomain(value) { if (this.busy || this.receiptDomain === value) return; this.stopScanner(); this.receiptDomain = value; if (value === 'customer') this.startScanner() },
goCount() { const mark = this.result?.labels?.[0]?.cartonMark; if (mark) uni.navigateTo({ url: '/pages/return-counting/return-counting?cartonMark=' + encodeURIComponent(mark) }) },
selectUnitType(type) { if (this.busy) return; this.unitType = type; this.unitCode = ''; this.boxRows = [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }]; this.palletCheck = null; this.activeTarget = type === 'PALLET' ? 'pallet' : 'unit' },
resizeBoxRows(raw) { if (this.busy) return; const count = Math.floor(Number(raw) || 0); if (count < 1 || count > 100) return; this.boxRows = Array.from({ length: count }, (_, index) => this.boxRows[index] || { items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }) },
setOriginalPackaging(index, value) { if (this.busy) return; this.boxRows[index].originalPackaging = value; this.boxRows[index].confirmed = false },
rowVolume(box) { const v = Number(box?.length) * Number(box?.width) * Number(box?.height); return Number.isFinite(v) && v > 0 ? v.toFixed(2) : '—' },
rowVolume(box) { const v = Number(box?.length) * Number(box?.width) * Number(box?.height); return Number.isFinite(v) && v > 0 ? (v / 1000000).toFixed(6) : '—' },
setBoxQuantity(index, skuCode, raw) { if (this.busy) return; this.boxRows[index].items = { ...this.boxRows[index].items, [skuCode]: raw }; this.boxRows[index].confirmed = false },
boxRemaining(index, item) { const allocated = this.boxRows.reduce((sum, box, rowIndex) => sum + (rowIndex === index ? 0 : Math.max(0, Number(box.items[item.skuCode]) || 0)), 0); return Math.max(0, this.remaining(item) - allocated) },
boxRowValid(index) {
@@ -129,7 +141,7 @@
printPallet() { if (!this.newPalletHtml) return; /* #ifdef H5 */ const view = window.open('', '_blank'); if (view) { view.document.write(this.newPalletHtml); view.document.close(); view.focus(); view.print() } else this.setError(new Error('请允许打开标签打印窗口'), '打印失败'); /* #endif */ /* #ifndef H5 */ this.setError(new Error('当前设备未配置打印服务'), '打印失败'); /* #endif */ },
printReturnCarton(label) { if (!label?.printableHtml) return; /* #ifdef H5 */ const view = window.open('', '_blank'); if (view) { view.document.write(label.printableHtml); view.document.close(); view.focus(); view.print() } else this.setError(new Error('请允许打开标签打印窗口'), '打印失败'); /* #endif */ /* #ifndef H5 */ this.setError(new Error('当前设备未配置打印服务'), '打印失败'); /* #endif */ },
acceptTracking(raw) { this.trackingNo = String(raw?.detail?.value ?? raw ?? '').trim(); playSuccessSound() },
async confirmReceipt() { if (this.busy) return; const totalBoxCount = this.palletBoxCount; if (this.unitType === 'PALLET' && totalBoxCount <= 0) { this.setError(new Error('请先填写各 SKU 的箱数'), '托盘签收校验失败'); return } if (!this.canConfirm || this.busy || (this.unitType !== 'PALLET' && !this.allBoxRowsConfirmed)) return; if (this.unitType !== 'PALLET' && !this.photoFileIds.length) { this.setError(new Error('请先拍照或上传签收照片,再确认签收入库'), '签收校验失败'); return } const willComplete = this.current.expectedItems.every((item) => (this.unitType !== 'PALLET' ? this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) : this.quantity(item.skuCode)) >= this.remaining(item)); if (willComplete && !this.photoFileIds.length) { const modal = await new Promise((resolve) => uni.showModal({ title: '缺少退货打托照片', content: '本次签收将完成整单入库,请先拍照并上传后再确认打托。', confirmText: '去拍照', cancelText: '取消', success: resolve })); if (modal?.confirm) this.error = { message: '请先拍照并上传退货打托照片后再确认' }; return }
async confirmReceipt() { if (this.busy) return; const totalBoxCount = this.palletBoxCount; if (this.unitType === 'PALLET' && totalBoxCount <= 0) { this.setError(new Error('请先填写各 SKU 的箱数'), '托盘签收校验失败'); return } if (!this.canConfirm || this.busy || (this.unitType !== 'PALLET' && !this.allBoxRowsConfirmed)) return; const willComplete = this.current.expectedItems.every((item) => (this.unitType !== 'PALLET' ? this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) : this.quantity(item.skuCode)) >= this.remaining(item)); if (!this.photoFileIds.length && (this.unitType !== 'PALLET' || willComplete)) { uni.showModal({ title: '请先拍照', content: '请先拍照或从相册上传签收照片,再点击确认签收。', showCancel: false, confirmText: '知道了' }); return }
await this.run(async () => {
const rows = this.unitType !== 'PALLET' ? this.boxRows : [null]
let expectedReceivedQuantity = this.current.expectedItems.reduce((sum, item) => sum + Number(item.receivedQuantity || 0), 0)
@@ -228,11 +240,34 @@
.dimension-grid label .dimension-label{display:flex;align-items:center;justify-content:space-between;gap:3px;margin-bottom:3px}.dimension-grid label .dimension-label>text{color:#65768b;font-size:10px;line-height:14px;font-weight:400;white-space:nowrap}.dimension-grid label .dimension-label>.volume-text{margin-left:auto;color:#135ee8;font-size:10px}
.receipt-order-card{padding:10px 14px!important}.receipt-order-card .row-head{align-items:center;margin-bottom:5px}.receipt-order-card .card-title{margin:0;font-size:12px}.receipt-order-card .link-button{margin:0;padding:0;font-size:11px;line-height:24px}.receipt-order-line{display:flex;align-items:center;gap:8px}.receipt-order-line .business-code{flex:1;min-width:0;font-size:12px;line-height:18px;white-space:nowrap;overflow-x:auto;word-break:normal}.receipt-order-line .status-pill{flex-shrink:0;font-size:10px;padding:3px 6px}
.receipt-sku-list .item-card{padding:10px 12px}.receipt-sku-list .row-head{align-items:flex-start;gap:8px}.receipt-sku-list .row-head>view{flex:1;min-width:0}.receipt-sku-list .business-code{font-size:12px;line-height:17px;word-break:break-all}.receipt-sku-list .meta-line{font-size:10px;line-height:15px;margin-top:3px}.receipt-sku-list .remaining-copy{flex-shrink:0;white-space:nowrap;font-size:10px}.sku-quantity-fields{display:flex;gap:14px;margin-top:8px}.sku-quantity-fields .quantity-row{flex:1;min-width:0;gap:6px;margin:0;justify-content:flex-start}.sku-quantity-fields .quantity-row>text{white-space:nowrap;font-size:10px}.sku-quantity-fields .quantity-row .number-input{box-sizing:border-box;width:64px;height:30px;min-height:30px;padding:0 5px;border-radius:6px;font-size:13px}
.candidate-heading .card-title{margin-bottom:0}.candidate-card{width:100%;text-align:left}.select-link{color:#135ee8;font-size:12px;font-weight:800}.field-label{display:block;margin:13px 0 6px;color:#52667e;font-size:11px;font-weight:700}.remaining-copy{color:#e53935;font-size:11px}.quantity-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:10px;color:#65768b;font-size:11px}.quantity-row .number-input{width:112px;text-align:center}.summary-bar{position:sticky;bottom:0;z-index:4;display:flex;align-items:center;gap:12px;margin-top:12px;padding:11px 13px;border:1px solid #d8e3f1;border-radius:13px;background:#fff;box-shadow:0 5px 20px rgba(22,55,96,.14)}.summary-bar>view{min-width:70px}.summary-label,.summary-value{display:block}.summary-label{color:#728197;font-size:10px}.summary-value{color:#135ee8;font-size:22px;font-weight:800}.submit-button{margin-top:0}.result-card{text-align:left}.result-carton{display:block;margin:9px 0;color:#135ee8;font-size:20px;font-weight:800;word-break:break-all}.link-button{width:auto}.work-card>.link-button{margin-top:8px;padding-left:0}.success-box .primary{margin-top:14px}.scan-hint{display:block;margin-top:9px;color:#65768b;font-size:11px;text-align:center}
.field-label{display:block;margin:13px 0 6px;color:#52667e;font-size:11px;font-weight:700}.remaining-copy{color:#e53935;font-size:11px}.quantity-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:10px;color:#65768b;font-size:11px}.quantity-row .number-input{width:112px;text-align:center}.summary-bar{position:sticky;bottom:0;z-index:4;display:flex;align-items:center;gap:12px;margin-top:12px;padding:11px 13px;border:1px solid #d8e3f1;border-radius:13px;background:#fff;box-shadow:0 5px 20px rgba(22,55,96,.14)}.summary-bar>view{min-width:70px}.summary-label,.summary-value{display:block}.summary-label{color:#728197;font-size:10px}.summary-value{color:#135ee8;font-size:22px;font-weight:800}.submit-button{margin-top:0}.result-card{text-align:left}.result-carton{display:block;margin:9px 0;color:#135ee8;font-size:20px;font-weight:800;word-break:break-all}.link-button{width:auto}.work-card>.link-button{margin-top:8px;padding-left:0}.success-box .primary{margin-top:14px}.scan-hint{display:block;margin-top:9px;color:#65768b;font-size:11px;text-align:center}
.photo-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:12px}.photo-tile{min-width:0;overflow:hidden;border:1px solid #d7e1ef;border-radius:8px}.photo-thumbnail{display:block;width:100%;height:130px;background:#f1f4f8}.photo-name{display:block;padding:6px 8px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:12px;color:#52647c}.photo-tile .link-button{margin:0 8px 6px}
.unit-tabs{display:flex;gap:8px;margin:10px 0}.unit-tab{flex:1!important;min-width:0!important;height:34px!important;min-height:34px!important;margin:0!important;padding:0 10px!important;border:1px solid #d6e1ef!important;border-radius:8px!important;background:#f7f9fc!important;color:#53677f!important;font-size:13px!important;line-height:34px!important}.unit-tab.active{border-color:#135ee8!important;background:#eaf2ff!important;color:#135ee8!important;font-weight:700}
.dimension-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:7px;margin-top:10px}.dimension-grid label>text{display:block;margin-bottom:3px;color:#65768b;font-size:10px}.dimension-grid .number-input{box-sizing:border-box;width:100%;height:34px;padding:0 8px;border:1px solid #d7e1ef;border-radius:7px;color:#24364b;font-size:13px;text-align:center;background:#fff}.dimension-grid .number-input:focus{border-color:#2167e8;box-shadow:0 0 0 2px rgba(33,103,232,.12)}.volume-box{display:flex;align-items:center;justify-content:center;gap:4px;height:34px;padding:0 6px;border:1px solid #b9d3f4;border-radius:7px;color:#65768b;font-size:10px;background:#f4f8ff}.volume-box strong{color:#135ee8;font-size:13px}
.work-card>.row-head .business-code{font-size:16px;line-height:20px}.work-card>.row-head .meta-line{font-size:10px;line-height:15px}.work-card>.row-head .status-pill{padding:4px 7px;font-size:10px}
.physical-unit-card{padding:12px!important;border:1px solid #d8e5f5!important;background:linear-gradient(145deg,#fff 0%,#f7faff 100%);box-shadow:0 4px 12px rgba(30,78,130,.08)}.unit-card-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}.unit-card-tag{padding:3px 7px;border-radius:10px;background:#eaf2ff;color:#135ee8;font-size:10px;font-weight:700}.unit-dimension-grid{margin-top:0}.unit-volume{display:flex;align-items:baseline;justify-content:flex-end;gap:4px;margin-top:7px;color:#728197;font-size:10px}.unit-volume strong{color:#135ee8;font-size:15px}.packaging-choice{display:flex;align-items:center;gap:7px;margin-top:10px;padding-top:9px;border-top:1px solid #e6edf6;color:#52667e;font-size:11px}.packaging-choice>text{flex:1}.choice-button{width:42px!important;min-width:42px!important;height:28px!important;min-height:28px!important;margin:0!important;padding:0!important;border:1px solid #d1ddeb!important;border-radius:7px!important;background:#fff!important;color:#65768b!important;font-size:11px!important;line-height:28px!important}.choice-button.selected{border-color:#135ee8!important;background:#eaf2ff!important;color:#135ee8!important;font-weight:700}
.receipt-quantity-heading{display:flex;align-items:center;flex-wrap:nowrap;gap:8px;margin-bottom:10px}.receipt-quantity-heading .card-title{margin-bottom:0;white-space:nowrap}.unit-count-row{display:flex;align-items:center;gap:6px;margin-left:auto;flex-shrink:0;white-space:nowrap;color:#65768b;font-size:10px}.unit-count-row .number-input{box-sizing:border-box;width:48px;min-width:48px;height:30px;min-height:30px;margin:0;padding:0 4px;border-radius:6px;text-align:center;font-size:13px}.quantity-row .unit-quantity-input{width:88px;flex-shrink:0;font-size:12px}.physical-unit-card .quantity-row>text{min-width:0;overflow-wrap:anywhere}.unit-card-head .link-button{margin:0;padding:0;font-size:10px}
@import '../../styles/task-list.scss';
.receipt-page{display:flex;flex-direction:column}
.receipt-page :deep(.portal-header-shell){flex-shrink:0}
.receipt-page>.work-scroll{flex:1;height:0;min-height:0}
.receipt-page .work-content{padding-bottom:calc(24px + env(safe-area-inset-bottom))}
.receipt-page .summary-bar{position:static;margin-bottom:10px}
.receipt-domain-tabs{display:flex;flex-shrink:0;gap:4px;margin:10px 14px 0;padding:4px;border:1px solid #d8e4f5;border-radius:10px;background:#e8eff9}
.receipt-domain-tab{box-sizing:border-box;flex:1;min-width:0;min-height:40px;margin:0;padding:0 8px;border-radius:7px;color:#52677f;background:transparent;font-size:14px;line-height:40px}
.receipt-domain-tab::after{border:0}
.receipt-domain-tab.active{color:#135ee8;background:#fff;font-weight:700;box-shadow:0 1px 5px rgba(28,69,121,.1)}
.receipt-domain-tab[disabled]{opacity:.6}
.summary-bar>.receipt-action-buttons{display:flex;flex:1;min-width:0;gap:8px}
.receipt-action-buttons>.primary,.receipt-action-buttons>.secondary{flex:1;min-width:0;width:auto;margin:0;padding:0 6px;white-space:nowrap}
.receipt-lookup-page{background:#f1f5fd}
.receipt-lookup-page .work-content{padding-top:14px}
.receipt-lookup-card,.receipt-candidate-empty{box-shadow:none}
.receipt-lookup-card .card-title{margin-bottom:10px;font-size:14px;line-height:20px}
.receipt-lookup-card .receipt-lookup-button{min-height:40px;margin-top:10px;font-size:13px;line-height:40px}
.receipt-lookup-page .section-gap{margin-top:0}
.receipt-candidate-card .pda-task-head{align-items:flex-start}
.receipt-candidate-card[disabled]{opacity:.6}
.receipt-candidate-card .receipt-status-active{color:#135ee8;background:#e4efff}
</style>
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -48,12 +48,14 @@ export function receivingProgress(session = receiving.current) {
export function setCurrentReceiving(value, owner = '') {
const nextOwner = photoOwner(value, owner)
const previous = previewOwner && previewOwner === nextOwner ? receiving.photoSlots : []
const keepUnsigned = receiving.current?.status === 'WAIT_SIGN' && value?.status === 'WAIT_SIGN'
previewOwner = nextOwner
receiving.current = value || null
receiving.selected = value || null
receiving.photoSlots = RECEIVING_PHOTO_ROLES.map((role) => {
const photo = (value?.photos || []).find((item) => item.role === role)
if (!photo) return null
// 相机返回会重新读取 current;签收前已上传的文件尚未绑定,保留同一身份、同一柜会话的草稿。
if (!photo) return keepUnsigned ? previous.find(item => item?.role === role && item.fileId) || null : null
const old = previous.find(item => item?.role === role && String(item.fileId) === String(photo.fileId)
&& item.downloadPath === photo.downloadPath)
return old || { ...photo, fileId: String(photo.fileId), role, localPath: '', uploaded: true, status: 'idle' }
+89
View File
@@ -0,0 +1,89 @@
.pda-task-list {
.pda-list-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 36px;
margin: 12px 0 11px;
color: #526174;
font-size: 14px;
font-weight: 700;
line-height: 20px;
}
.pda-list-toolbar > text { min-width: 0; margin: 0; color: inherit; font-size: inherit; font-weight: inherit; line-height: inherit; }
.pda-list-toolbar button {
display: flex;
box-sizing: border-box;
align-items: center;
justify-content: center;
flex-shrink: 0;
gap: 5px;
width: auto;
min-width: 72px;
height: 36px;
min-height: 36px;
margin: 0;
padding: 0 14px;
border: 1px solid #b8d1f5;
border-radius: 20px;
color: #135ee8;
font-size: 14px;
font-weight: 700;
line-height: normal;
background: #fff;
}
.pda-list-toolbar button[disabled] { opacity: .55; }
.pda-list-cards { display: flex; flex-direction: column; gap: 11px; }
.pda-task-card {
display: block;
box-sizing: border-box;
width: 100%;
margin: 0;
padding: 14px;
border: 1px solid #dce5f0;
border-radius: 13px;
color: #1f2937;
line-height: normal;
text-align: left;
white-space: normal;
background: #fff;
box-shadow: none;
}
.pda-task-card-pressed { border-color: #93baf0; background: #f4f8ff; }
.pda-task-card.current { border-color: #8eb8f5; background: #f7faff; }
.pda-task-card.rework-card { border-color: #f29b38; background: #fffaf2; box-shadow: none; }
.pda-task-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.pda-task-no {
display: block;
flex: 1;
min-width: 0;
color: #1f2937;
font-size: 18px;
font-weight: 750;
line-height: 26px;
overflow-wrap: anywhere;
word-break: break-all;
}
.pda-task-status { flex-shrink: 0; max-width: 45%; padding: 6px 10px; border-radius: 9px; font-size: 13px; line-height: 16px; white-space: normal; overflow-wrap: anywhere; }
.pda-task-meta { display: block; margin-top: 8px; color: #718095; font-size: 12px; line-height: 20px; overflow-wrap: anywhere; }
.pda-task-flags { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 8px; color: #526174; font-size: 12px; line-height: 20px; }
.pda-task-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 12px; padding: 9px 0; border-top: 1px solid #e1e7f0; border-bottom: 1px solid #e1e7f0; }
.pda-task-metrics.has-three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.pda-task-metrics > view { min-width: 0; padding: 0 6px; border-right: 1px solid #e1e7f0; text-align: center; }
.pda-task-metrics > view:last-child { border-right: 0; }
.pda-task-metrics strong { display: block; color: #1f2937; font-size: 22px; font-weight: 750; line-height: 26px; overflow-wrap: anywhere; }
.pda-task-metrics text { display: block; margin-top: 3px; color: #6f8194; font-size: 12px; line-height: 18px; }
.pda-task-metric-warning strong { color: #a35b0c; }
.pda-task-footer { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 8px; min-height: 28px; margin-top: 10px; }
.pda-task-action { display: flex; align-items: center; gap: 2px; margin-left: auto; color: #135ee8; font-size: 13px; font-weight: 700; line-height: 22px; white-space: nowrap; }
.pda-task-footer .recent-badge { font-size: 11px; line-height: 16px; }
}
+61 -13
View File
@@ -1,3 +1,5 @@
import appManifest from '@/manifest.json'
const updateEndpoint = String(import.meta.env.VITE_PDA_UPDATE_URL || '').trim()
let checking = false
@@ -15,20 +17,40 @@ function getRuntimeInfo() {
return
}
// 标准真机基座的版本属于 HBuilderX;调试更新按当前工程版本模拟。
const debugBase = import.meta.env.DEV || uni.getAppBaseInfo().packageName === 'io.dcloud.HBuilder'
const runtime = {
appid: String((debugBase ? appManifest.appid : plus.runtime.appid) || ''),
appVersion: String((debugBase ? appManifest.versionName : plus.runtime.version) || ''),
appVersionCode: Number(debugBase ? appManifest.versionCode : plus.runtime.versionCode)
}
if (!runtime.appid || !runtime.appVersion || !Number.isInteger(runtime.appVersionCode) || runtime.appVersionCode < 1) {
reject(new Error('无法读取当前应用版本,请重新运行 App'))
return
}
if (debugBase) {
resolve({ ...runtime, wgtVersion: runtime.appVersion })
return
}
// 正式 APK 始终保留原生基座版本码,WGT 升级只改变资源版本。
plus.runtime.getProperty(plus.runtime.appid, (properties) => {
resolve({
appid: plus.runtime.appid,
appVersion: String(plus.runtime.version || ''),
appVersionCode: Number(plus.runtime.versionCode || 0),
wgtVersion: String(properties?.version || plus.runtime.version || '')
})
resolve({ ...runtime, wgtVersion: String(properties?.version || runtime.appVersion) })
})
})
}
function normalizeUpdate(payload, runtime) {
const value = payload && typeof payload === 'object' ? payload : {}
if (value.update !== true) return null
if ('code' in value && String(value.code) !== '200') {
const error = new Error(String(value.message || '检查更新失败,请稍后重试'))
error.code = value.code
error.requestId = value.requestId
error.traceId = value.traceId
throw error
}
if (typeof value.update !== 'boolean') throw new Error('更新服务返回了无效的版本信息')
if (!value.update) return null
if (value.appid !== runtime.appid) throw new Error('更新包与当前应用不匹配')
const packageType = String(value.packageType || '').toLowerCase()
@@ -110,20 +132,45 @@ function downloadUpdate(update) {
})
}
function prepareUpdateFile(filePath, update) {
return new Promise((resolve, reject) => {
const fail = (cause) => {
const error = new Error('更新包文件准备失败,请重新下载')
error.stage = 'prepare'
error.code = cause?.code
reject(error)
}
// 下载接口以 /package 结尾,临时文件可能没有安装器要求的扩展名。
const filename = `overseas-pda-${update.versionName}-${Date.now()}.${update.packageType}`
plus.io.resolveLocalFileSystemURL(filePath, (entry) => {
entry.getParent((parent) => {
entry.moveTo(parent, filename, (file) => resolve(file.toLocalURL()), fail)
}, fail)
}, fail)
})
}
function installUpdate(filePath) {
return new Promise((resolve, reject) => {
plus.runtime.install(
filePath,
{ force: false },
resolve,
() => reject(new Error('更新包安装失败,请重新下载'))
(cause) => {
const code = cause?.code == null ? '' : `(${cause.code})`
const reason = String(cause?.message || '请重新下载后重试')
const error = new Error(`更新包安装失败${code}:${reason}`)
error.stage = 'install'
error.code = cause?.code
reject(error)
}
)
})
}
function showFailure(error, mandatory) {
const message = error?.message || '检查更新失败,请稍后重试'
if (!mandatory) {
if (!mandatory && error?.stage !== 'install' && error?.stage !== 'prepare') {
uni.showToast({ title: message, icon: 'none', duration: 3000 })
return Promise.resolve(false)
}
@@ -132,9 +179,9 @@ function showFailure(error, mandatory) {
uni.showModal({
title: '更新失败',
content: message,
confirmText: '重新尝试',
confirmText: mandatory ? '重新尝试' : '知道了',
showCancel: false,
complete: () => resolve(true)
complete: () => resolve(mandatory)
})
})
}
@@ -145,7 +192,8 @@ async function runUpdate(update) {
try {
const filePath = await downloadUpdate(update)
await installUpdate(filePath)
const installPath = await prepareUpdateFile(filePath, update)
await installUpdate(installPath)
if (update.packageType === 'wgt') {
uni.showModal({
title: '更新完成',
@@ -184,7 +232,7 @@ export async function checkForAppUpdate({ manual = false } = {}) {
})
if (response.statusCode !== 200) throw new Error('更新服务暂时不可用')
const update = normalizeUpdate(response.data?.data ?? response.data, runtime)
const update = normalizeUpdate(response.data, runtime)
if (update && isNewerUpdate(update, runtime)) {
await runUpdate(update)
} else if (manual) {
+218
View File
@@ -1,9 +1,82 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import { runInNewContext } from 'node:vm'
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
async function createUpdater({ development = true, packageName = 'io.dcloud.HBuilder',
manifest = { appid: '__UNI__936D079', versionName: '0.0.4', versionCode: 4 },
runtime = {}, resourceVersion = '0.0.4', response = { update: false }, acceptUpdate = false,
downloadResult = { statusCode: 200, tempFilePath: '_doc/temp/download/package' },
fileErrorAt, installError } = {}) {
const calls = { requests: [], modals: [], toasts: [], downloads: [], moves: [], installs: [], restarts: 0 }
const source = (await read('../src/utils/app-update.js'))
.replace(/^import .*$/gm, '')
.replaceAll('import.meta.env.VITE_PDA_UPDATE_URL', 'updateUrl')
.replaceAll('import.meta.env.DEV', 'development')
.replace('export async function checkForAppUpdate', 'async function checkForAppUpdate')
const updater = runInNewContext(`${source}\n;({ getRuntimeInfo, checkForAppUpdate })`, {
appManifest: manifest, development, updateUrl: 'https://updates.example.test/pda/overseas/latest',
plus: {
io: {
resolveLocalFileSystemURL(path, success, fail) {
const fileError = { code: 10, message: 'file operation failed' }
if (fileErrorAt === 'resolve') return fail(fileError)
const parent = {}
success({
getParent(success, fail) { fileErrorAt === 'parent' ? fail(fileError) : success(parent) },
moveTo(destination, name, success, fail) {
assert.equal(destination, parent)
calls.moves.push({ path, name })
if (fileErrorAt === 'move') return fail(fileError)
success({ toLocalURL: () => `_doc/temp/download/${name}` })
}
})
}
},
runtime: { appid: 'HBuilder', version: '5.24.0', versionCode: 52400,
getProperty(_appid, callback) { callback({ version: resourceVersion }) },
install(path, options, success, fail) {
calls.installs.push({ path, options })
if (installError) return fail(installError)
if (!/\.(wgt|apk)$/.test(path)) return fail({ code: -1201, message: 'WGT/WGTU文件格式错误' })
success()
},
restart() { calls.restarts += 1 }, ...runtime
}
},
uni: {
getAppBaseInfo: () => ({ packageName }),
request(options) {
calls.requests.push(options)
options.success({ statusCode: 200, data: typeof response === 'function' ? response(options.data) : response })
},
downloadFile(options) {
calls.downloads.push(options)
options.success(downloadResult)
options.complete()
return { onProgressUpdate() {} }
},
showLoading() {}, hideLoading() {},
showModal(options) {
calls.modals.push(options)
options.success?.({ confirm: acceptUpdate && options.confirmText === '立即更新' })
options.complete?.()
},
showToast(options) { calls.toasts.push(options) }
}
})
return { ...updater, calls }
}
const publishedApk = { update: true, appid: '__UNI__936D079', packageType: 'apk',
versionName: '0.0.5', versionCode: 5, minAppVersionCode: 5,
downloadUrl: 'https://updates.example.test/overseas-0.0.5.apk', mandatory: false }
const publishedWgt = { ...publishedApk, packageType: 'wgt', minAppVersionCode: 4,
downloadUrl: 'https://updates.example.test/overseas-0.0.5.wgt', releaseNotes: '更新测试' }
test('App-Plus checks the independent overseas update channel after launch', async () => {
const [app, updater, env] = await Promise.all([
read('../src/App.vue'),
@@ -53,3 +126,148 @@ test('overseas profile exposes manual App update checking and the runtime versio
assert.match(profile, /checkForAppUpdate\(\{ manual: true \}\)/)
assert.match(profile, /#ifndef APP-PLUS[\s\S]*?请在 PDA App 中检查更新/)
})
test('HBuilderX 0.0.4 checks the project version and offers a published 0.0.5 APK', async () => {
const updater = await createUpdater({ response: query => query.appVersionCode < 5 ? publishedApk : { update: false } })
await updater.checkForAppUpdate({ manual: true })
const query = updater.calls.requests[0].data
assert.equal(query.appid, '__UNI__936D079')
assert.equal(query.appVersion, '0.0.4')
assert.equal(query.appVersionCode, 4)
assert.equal(query.wgtVersion, '0.0.4')
assert.equal(updater.calls.modals[0]?.title, '发现更新 0.0.5')
assert.equal(updater.calls.toasts.length, 0)
})
test('standard HBuilder base uses project versions when running production WGT resources', async () => {
const updater = await createUpdater({ development: false })
const info = await updater.getRuntimeInfo()
assert.equal(info.appid, '__UNI__936D079')
assert.equal(info.appVersionCode, 4)
assert.equal(info.appVersion, '0.0.4')
})
test('released APK keeps its native base version after a WGT version increase', async () => {
const updater = await createUpdater({ development: false, packageName: 'com.baiyuelogistics.pda.overseas',
manifest: { appid: '__UNI__936D079', versionName: '0.0.5', versionCode: 5 },
runtime: { appid: '__UNI__936D079', version: '0.0.4', versionCode: 4 }, resourceVersion: '0.0.5' })
const info = await updater.getRuntimeInfo()
assert.equal(info.appVersionCode, 4)
assert.equal(info.appVersion, '0.0.4')
assert.equal(info.wgtVersion, '0.0.5')
})
test('HBuilderX 0.0.4 can detect a compatible published 0.0.5 WGT', async () => {
const updater = await createUpdater({ response: publishedWgt })
await updater.checkForAppUpdate({ manual: true })
assert.equal(updater.calls.requests[0].data.appVersionCode, 4)
assert.equal(updater.calls.requests[0].data.wgtVersion, '0.0.4')
assert.equal(updater.calls.modals[0]?.title, '发现更新 0.0.5')
assert.equal(updater.calls.modals[0]?.content, '更新测试')
})
test('released APK 0.0.4 shows the published 0.0.5 WGT release notes', async () => {
const updater = await createUpdater({ development: false, packageName: 'com.baiyuelogistics.pda.overseas',
runtime: { appid: '__UNI__936D079', version: '0.0.4', versionCode: 4 }, response: publishedWgt })
await updater.checkForAppUpdate({ manual: true })
assert.equal(updater.calls.modals[0]?.title, '发现更新 0.0.5')
assert.equal(updater.calls.modals[0]?.content, '更新测试')
})
test('an APK 0.0.4 with WGT 0.0.5 does not offer that WGT again', async () => {
const updater = await createUpdater({ development: false, packageName: 'com.baiyuelogistics.pda.overseas',
runtime: { appid: '__UNI__936D079', version: '0.0.4', versionCode: 4 },
resourceVersion: '0.0.5', response: publishedWgt })
await updater.checkForAppUpdate({ manual: true })
assert.equal(updater.calls.modals.length, 0)
assert.equal(updater.calls.toasts[0]?.title, '当前已是最新版本')
})
test('a WGT requiring APK 5 is rejected on a released APK 4 after resource updates', async () => {
const updater = await createUpdater({ development: false, packageName: 'com.baiyuelogistics.pda.overseas',
manifest: { appid: '__UNI__936D079', versionName: '0.0.5', versionCode: 5 },
runtime: { appid: '__UNI__936D079', version: '0.0.4', versionCode: 4 },
resourceVersion: '0.0.5', response: { ...publishedWgt, versionName: '0.0.6', minAppVersionCode: 5 } })
await updater.checkForAppUpdate({ manual: true })
assert.equal(updater.calls.modals.length, 0)
assert.equal(updater.calls.toasts[0]?.title, '当前安装包不支持此热更新,请安装新版 APK')
})
test('an unreadable native APK version is reported instead of claiming the latest version', async () => {
const updater = await createUpdater({ development: false, packageName: 'com.baiyuelogistics.pda.overseas',
runtime: { appid: '__UNI__936D079', version: '0.0.4', versionCode: 0 } })
await updater.checkForAppUpdate({ manual: true })
assert.equal(updater.calls.requests.length, 0)
assert.equal(updater.calls.toasts[0]?.title, '无法读取当前应用版本,请重新运行 App')
})
test('update service business errors are shown instead of claiming the latest version', async () => {
const updater = await createUpdater({ response: { code: 'S0503', message: 'PDA 发布通道未初始化', data: null } })
await updater.checkForAppUpdate({ manual: true })
assert.equal(updater.calls.toasts[0]?.title, 'PDA 发布通道未初始化')
assert.equal(updater.calls.modals.length, 0)
})
test('invalid update responses are rejected instead of claiming the latest version', async () => {
const updater = await createUpdater({ response: {} })
await updater.checkForAppUpdate({ manual: true })
assert.equal(updater.calls.toasts[0]?.title, '更新服务返回了无效的版本信息')
})
test('unpublished versions remain unavailable to the device', async () => {
const updater = await createUpdater({ response: { update: false } })
await updater.checkForAppUpdate({ manual: true })
assert.equal(updater.calls.modals.length, 0)
assert.equal(updater.calls.toasts[0]?.title, '当前已是最新版本')
})
test('a downloaded extensionless WGT is renamed before install and restarts on success', async () => {
const updater = await createUpdater({ response: publishedWgt, acceptUpdate: true })
await updater.checkForAppUpdate({ manual: true })
assert.equal(updater.calls.downloads[0]?.url, publishedWgt.downloadUrl)
assert.equal(updater.calls.moves[0]?.path, '_doc/temp/download/package')
assert.match(updater.calls.moves[0]?.name, /^overseas-pda-0\.0\.5-\d+\.wgt$/)
assert.equal(updater.calls.installs[0]?.path, `_doc/temp/download/${updater.calls.moves[0].name}`)
assert.equal(updater.calls.installs[0]?.options.force, false)
assert.equal(updater.calls.modals[1]?.title, '更新完成')
assert.equal(updater.calls.restarts, 1)
})
test('an extensionless APK receives the APK suffix and does not restart as a WGT', async () => {
const updater = await createUpdater({ response: publishedApk, acceptUpdate: true })
await updater.checkForAppUpdate({ manual: true })
assert.match(updater.calls.installs[0]?.path, /\.apk$/)
assert.equal(updater.calls.installs[0]?.options.force, false)
assert.equal(updater.calls.restarts, 0)
})
test('native installation errors remain visible with their actual reason and code', async () => {
const updater = await createUpdater({ response: publishedWgt, acceptUpdate: true,
installError: { code: -1205, message: 'WGT安装包中manifest.json文件的version版本不匹配' } })
await updater.checkForAppUpdate({ manual: true })
assert.equal(updater.calls.modals[1]?.title, '更新失败')
assert.match(updater.calls.modals[1]?.content, /-1205/)
assert.match(updater.calls.modals[1]?.content, /version版本不匹配/)
assert.equal(updater.calls.restarts, 0)
assert.equal(updater.calls.installs.length, 1)
})
test('unreadable or unrenamable downloads never reach the native installer', async () => {
for (const fileErrorAt of ['resolve', 'parent', 'move']) {
const updater = await createUpdater({ response: publishedWgt, acceptUpdate: true, fileErrorAt })
await updater.checkForAppUpdate({ manual: true })
assert.equal(updater.calls.installs.length, 0, fileErrorAt)
assert.equal(updater.calls.restarts, 0, fileErrorAt)
assert.equal(updater.calls.modals[1]?.title, '更新失败', fileErrorAt)
assert.match(updater.calls.modals[1]?.content, /更新包文件准备失败/, fileErrorAt)
}
})
test('an unsuccessful HTTP download never prepares or installs an update', async () => {
const updater = await createUpdater({ response: publishedWgt, acceptUpdate: true,
downloadResult: { statusCode: 404, tempFilePath: '_doc/temp/download/package' } })
await updater.checkForAppUpdate({ manual: true })
assert.equal(updater.calls.moves.length, 0)
assert.equal(updater.calls.installs.length, 0)
assert.match(updater.calls.toasts[0]?.title, /下载失败(404)/)
})
+86
View File
@@ -0,0 +1,86 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { confirmCargoDiscard } from '../src/api/fulfillment.js'
import { session } from '../src/stores/session.js'
const rentPath = '/api/pda/v1/last-leg/storage-rent/preparations'
const stockPath = '/api/pda/v1/last-leg/cargo-discards/CD%2F1/pallets/P%2F1/confirm'
const body = { lines: [{ itemId: '9007199254740993', actualPackages: 2 }], photoFileIds: ['101'], scannedCartonMarks: ['BOX-1', 'BOX-2'], confirmAllCartons: false, idempotencyKey: 'discard-key' }
const ready = { data: { preparationId: '9007199254740995', ready: true, status: 'READY' } }
const confirmed = { data: { discardNo: 'CD/1', palletNo: 'P/1', status: 'COMPLETED' } }
const pending = { data: { preparationId: '9007199254740995', ready: false, status: 'FREEZE_PENDING' } }
const submit = () => confirmCargoDiscard('CD/1', 'P/1', body.lines, body.photoFileIds, body.scannedCartonMarks, false, { idempotencyKey: body.idempotencyKey })
function transport(responses) {
const calls = []; session.apiBase = '/api'; session.token = 'overseas-token'
globalThis.uni = { request(options) {
calls.push({ method: options.method, url: options.url, data: options.data })
const response = responses.shift(); assert.ok(response, 'unexpected additional request')
if (response.network) return options.fail({ errMsg: 'network down' })
options.success({ statusCode: 200, data: { code: '200', ...response }, header: {} })
} }
return calls
}
test('出库先预校验仓租,再用相同明细、照片、箱号和幂等键确认一次', async () => {
const calls = transport([ready, confirmed]); await submit()
assert.deepEqual(calls, [
{ method: 'POST', url: rentPath, data: { mode: 'DISCARD', businessNo: 'CD/1', palletNo: 'P/1', ...body } },
{ method: 'POST', url: stockPath, data: body }
])
})
test('免费或不计仓租的服务端资格可以直接继续出库', async () => {
const calls = transport([{ data: { status: 'NOT_REQUIRED', ready: true, amount: 0 } }, confirmed])
await submit(); assert.equal(calls.length, 2); assert.equal(calls[1].url, stockPath)
})
test('等待财务回执期间只读状态,就绪后只提交一次出库', async t => {
t.mock.method(globalThis, 'setTimeout', callback => { callback(); return 0 })
const calls = transport([pending, ready, confirmed]); await submit()
assert.deepEqual(calls.map(call => call.method), ['POST', 'GET', 'POST'])
assert.equal(calls[1].url, rentPath + '/9007199254740995')
})
test('余额授信不足时显示财务原因,释放未使用预占且不发出库请求', async () => {
const calls = transport([{ data: { ...pending.data, status: 'FAILED', message: '可用余额和授信额度不足' } }, { data: {} }])
await assert.rejects(submit(), error => error.code === 'A0409' && /仓租校验未通过.*余额和授信额度不足/.test(error.message))
assert.deepEqual(calls.map(call => call.method), ['POST', 'DELETE'])
})
test('缺失、失败或断网的预校验不降级绕过,保留诊断信息', async () => {
for (const response of [{ code: 'A0404', message: 'missing', requestId: 'req', traceId: 'trace' }, { code: 'A0409', message: 'conflict' }, { network: true }]) {
const calls = transport([response])
await assert.rejects(submit(), error => response.network ? error.network : error.code === response.code && error.requestId === response.requestId && error.traceId === response.traceId)
assert.equal(calls.length, 1)
}
})
test('回执超时释放预占,阻止出库并提示稍后重新提交', async t => {
t.mock.method(globalThis, 'setTimeout', callback => { callback(); return 0 })
const calls = transport([pending, ...Array(20).fill(pending), { data: {} }])
await assert.rejects(submit(), /仓租资金预校验尚未通过/)
assert.equal(calls.filter(call => call.method === 'GET').length, 20)
assert.equal(calls.at(-1).method, 'DELETE')
assert.ok(calls.every(call => call.url.includes('/storage-rent/')))
})
test('状态查询失败仍释放原预占;释放失败不覆盖原始诊断', async t => {
t.mock.method(globalThis, 'setTimeout', callback => { callback(); return 0 })
const calls = transport([pending, { code: 'S0503', message: 'read failed', requestId: 'req', traceId: 'trace' }, { code: 'S0503', message: 'release failed' }])
await assert.rejects(submit(), error => error.message === 'read failed' && error.requestId === 'req' && error.traceId === 'trace')
assert.deepEqual(calls.map(call => call.method), ['POST', 'GET', 'DELETE'])
})
test('就绪标记必须为布尔真,异常响应不能放行出库', async () => {
const calls = transport([{ data: { ...ready.data, ready: 'true' } }, { data: {} }])
await assert.rejects(submit(), /预校验/); assert.equal(calls.at(-1).method, 'DELETE')
})
test('出库请求已发送后,业务错误或结果不明不重发、不取消原预占', async () => {
for (const failure of [{ code: 'A0409', message: 'stock changed' }, { network: true }]) {
const calls = transport([ready, failure])
await assert.rejects(submit(), error => failure.network ? error.network : error.code === failure.code)
assert.equal(calls.length, 2); assert.ok(calls.every(call => call.method === 'POST'))
}
})
+110 -10
View File
@@ -1,15 +1,15 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { loadingPhotoSlots, normalizeLoadingDocuments, getLoadingDocuments, bindLoadingPhoto, uploadLoadingPhoto } from '../src/api/dispatch-loading-documents.js'
import { loadingPhotoLabels, loadingPhotoLimit, loadingPhotoSlots, normalizeLoadingDocuments, getLoadingDocuments, bindLoadingPhoto, uploadLoadingPhoto } from '../src/api/dispatch-loading-documents.js'
import { session } from '../src/stores/session.js'
const source = (await readFile(new URL('../src/components/DispatchLoadingDocuments.vue', import.meta.url), 'utf8')).match(/<script>([\s\S]*?)<\/script>/)[1]
const data = extra => ({ dispatchNo: 'PC/1', recordVersion: '0', dispatchRecordVersion: '3', stage: 'AFTER_LOADING', editable: true, complete: false, photos: [], ...extra })
const data = extra => ({ dispatchNo: 'PC/1', recordVersion: '0', dispatchRecordVersion: '3', reviewRound: '1', stage: 'AFTER_LOADING', editable: true, complete: false, photos: [], ...extra })
const photo = { slot: 'LOADING_SCENE', fileId: '1001', fileName: 'loading.png', contentType: 'image/png' }
function component(overrides = {}) {
const values = new Map(), events = []
globalThis.uni = { setStorageSync: (key, value) => values.set(key, value), removeStorageSync: key => values.delete(key), getStorageSync: key => values.get(key), chooseImage: async () => ({ tempFilePaths: ['local-image'], tempFiles: [{ size: 123 }] }), showToast() {} }
const dependencies = { session: { currentUser: { id: 'user', warehouseId: 'warehouse' } }, loadingPhotoSlots, toDispatchError: cause => cause,
const dependencies = { session: { currentUser: { id: 'user', warehouseId: 'warehouse' } }, loadingPhotoSlots, loadingPhotoLabels, loadingPhotoLimit, toDispatchError: cause => cause,
getLoadingDocuments: async () => data(), uploadLoadingPhoto: async () => photo, recoverLoadingPhoto: async () => photo,
bindLoadingPhoto: async () => data({ recordVersion: '1', photos: [photo] }), readLoadingPhoto: async () => 'downloaded-image', ...overrides }
const definition = new Function(...Object.keys(dependencies), source.replace(/^import .*$/gm, '').replace('export default', 'return'))(...Object.values(dependencies))
@@ -22,30 +22,72 @@ function component(overrides = {}) {
test('five existing slots carry their business names into upload, replace and preview controls', async () => {
const { state } = component()
assert.deepEqual(state.slots, ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT'])
assert.deepEqual(state.slotLabels, { LOADING_SCENE: '装车现场', LOADING_LIST: '装车清单', PRE_DEPARTURE_CHECK: '发车前检查', DRIVER_LICENSE: '驾驶证', RECEIPT: '回执' })
assert.deepEqual(state.slotLabels, { LOADING_SCENE: '司机驾驶证', LOADING_LIST: '车牌照片', PRE_DEPARTURE_CHECK: '装车完成', DRIVER_LICENSE: '封车照片', RECEIPT: 'BOL司机签字' })
const template = (await readFile(new URL('../src/components/DispatchLoadingDocuments.vue', import.meta.url), 'utf8')).split('<script>')[0]
assert.match(template, /class="photo-label">\{\{ slotLabels\[slot\] \}\}/)
assert.match(template, /:aria-label="`\$\{snapshot\?\.photos.some\(item => item.slot === slot\) \? '更换' : '上传'\}\$\{slotLabels\[slot\]\}`"/)
assert.match(template, /@tap="choose\(slot, photo.fileId\)"/)
assert.match(template, /:aria-label="`预览\$\{slotLabels\[slot\]\}`"/)
assert.match(template, /:aria-label="`查看\$\{slotLabels\[slot\]\}大图`"/)
assert.doesNotMatch(template, /装车照片 \{\{ index \+ 1 \}\}/)
})
test('refresh reads all photos saved by PC and refreshes the task stage without another write', async () => {
let latest = data({ photos: [photo] }), writes = 0, fail = false
const { state, events } = component({ getLoadingDocuments: async () => { if (fail) throw new Error('刷新失败'); return latest },
uploadLoadingPhoto: async () => { writes++; return photo }, bindLoadingPhoto: async () => { writes++; return latest } })
await state.load()
latest = data({ recordVersion: '26', stage: 'DELIVERING', complete: true, photos: loadingPhotoSlots.flatMap((slot, category) =>
Array.from({ length: 5 }, (_, index) => ({ ...photo, slot, fileId: `${category + 2}${index}` }))) })
await state.refresh()
assert.deepEqual(state.snapshot.photos, latest.photos)
assert.equal(state.snapshot.stage, 'DELIVERING'); assert.equal(state.completedCategories, 5)
assert.ok(!state.snapshot.photos.some(item => item.fileId === photo.fileId))
assert.equal(events.filter(event => event[0] === 'saved').length, 1)
fail = true; await state.refresh()
assert.deepEqual(state.snapshot.photos, latest.photos)
assert.equal(state.needsRefresh, true); assert.equal(state.busy, false)
assert.equal(events.filter(event => event[0] === 'saved').length, 1)
assert.equal(writes, 0)
})
test('PDA reads and binds through its own namespace without customer or warehouse input', async () => {
const calls = []; session.apiBase = '/gateway-api'; session.token = 'test-token'
globalThis.uni = { request(options) { calls.push(options); options.success({ statusCode: 200, data: { code: '200', data: data() } }) } }
await getLoadingDocuments('PC/1')
const body = { recordVersion: '9007199254740993', idempotencyKey: 'stable-key', fileId: '1001' }
const body = { recordVersion: '9007199254740993', idempotencyKey: 'stable-key', uploadKey: 'original-upload-key', fileId: '1001' }
await bindLoadingPhoto('PC/1', 'LOADING_SCENE', body)
assert.match(calls[0].url, /\/pda\/v1\/last-leg\/dispatches\/PC%2F1\/loading-documents$/)
assert.equal(calls[1].method, 'PUT'); assert.deepEqual(calls[1].data, body)
})
test('imported PDF license is opened as a document on demand, never as a photo', async () => {
const license = { ...photo, fileName: 'license.pdf', contentType: 'application/pdf' }
let downloads = 0, opened
const { state } = component({ getLoadingDocuments: async () => data({ photos: [license] }), readLoadingPhoto: async () => { downloads++; return 'private-license.pdf' } })
uni.openDocument = options => { opened = options; options.success() }
await state.load()
assert.equal(downloads, 0); assert.equal(state.completedCategories, 1)
await state.viewPdf(license)
assert.equal(downloads, 1); assert.equal(opened.filePath, 'private-license.pdf'); assert.equal(opened.fileType, 'pdf')
assert.equal(state.busy, false)
})
test('PDA upload preserves the original request key through the authenticated upload helper', async () => {
const calls = []; session.apiBase = '/gateway-api'; session.token = 'test-token'
globalThis.uni = { uploadFile(options) { calls.push(options); options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: photo }) }) } }
await uploadLoadingPhoto('PC/1', 'LOADING_SCENE', 'local-image', 'same-key')
assert.match(calls[0].url, /loading-documents\/LOADING_SCENE\/uploads\?idempotencyKey=same-key$/)
await uploadLoadingPhoto('PC/1', 'LOADING_SCENE', 'local-image', 'same-key', '1')
assert.match(calls[0].url, /loading-documents\/LOADING_SCENE\/uploads\?idempotencyKey=same-key&reviewRound=1$/)
assert.equal(calls[0].filePath, 'local-image')
})
test('re-review during photo upload stops old files instead of adopting the latest round', async () => {
let latest = data(), uploads = 0, binds = 0, originalRound
const { state, events } = component({ getLoadingDocuments: async () => latest,
uploadLoadingPhoto: async (_no, _slot, _path, _key, round) => {
uploads++; originalRound = round; latest = data({ recordVersion: '8', reviewRound: '2' }); return photo
}, bindLoadingPhoto: async () => { binds++; return latest } })
uni.chooseImage = async () => ({ tempFilePaths: ['old-photo', 'not-uploaded'] })
await state.load(); await state.choose(photo.slot)
assert.equal(originalRound, '1'); assert.equal(uploads, 1); assert.equal(binds, 0)
assert.match(state.error.message, /审核轮次已变化/); assert.equal(state.busy, false)
assert.equal(events.filter(event => event[0] === 'saved').length, 1)
})
test('wrong dispatch, duplicate slots and invalid versions never become saved facts', () => {
assert.throws(() => normalizeLoadingDocuments(data(), 'PC/2'), /不一致/)
@@ -68,17 +110,29 @@ test('binding failure releases immediately and the next selection uses a new fil
const binds = []; let uploads = 0, version = '0', reads = 0
const { state, values, events } = component({ getLoadingDocuments: async () => { reads++; return data({ recordVersion: version }) }, uploadLoadingPhoto: async () => ({ ...photo, fileId: String(++uploads) }), bindLoadingPhoto: async (_no, _slot, body) => { binds.push(body); if (binds.length === 1) throw { network: true }; return data({ photos: [photo] }) } })
await state.load(); await state.choose(photo.slot)
assert.equal(state.busy, false); assert.equal(reads, 2); assert.equal(values.size, 0)
assert.equal(state.busy, false); assert.equal(reads, 3); assert.equal(values.size, 0)
version = '7'; await state.choose(photo.slot)
assert.equal(uploads, 2); assert.equal(binds[1].recordVersion, '7'); assert.notEqual(binds[1].fileId, binds[0].fileId)
assert.notEqual(binds[1].idempotencyKey, binds[0].idempotencyKey); assert.equal(events.filter(event => event[0] === 'saved').length, 1)
})
test('manual refresh after a lost binding response reflects server success without another write', async () => {
test('a lost binding response immediately reloads server success without another write', async () => {
let binds = 0, latest = data()
const { state } = component({ getLoadingDocuments: async () => latest, bindLoadingPhoto: async () => { binds++; latest = data({ editable: false, complete: true, stage: 'DELIVERING', photos: [photo] }); throw { network: true } } })
await state.load(); await state.choose(photo.slot); assert.equal(state.busy, false)
assert.equal(state.snapshot.stage, 'DELIVERING'); assert.equal(state.editable, false)
await state.load(); assert.equal(state.snapshot.stage, 'DELIVERING'); assert.equal(binds, 1); assert.equal(state.editable, false)
})
test('failed reconciliation blocks further uploads until an explicit refresh succeeds', async () => {
let reads = 0, uploads = 0
const { state } = component({ getLoadingDocuments: async () => { if (++reads === 3) throw new Error('读取失败'); return data() },
uploadLoadingPhoto: async () => { uploads++; return photo }, bindLoadingPhoto: async () => { throw new Error('保存超时') } })
await state.load(); await state.choose(photo.slot)
assert.equal(state.busy, false); assert.equal(state.needsRefresh, true); assert.equal(state.editable, false)
assert.match(state.error.message, /保存超时/)
await state.choose(photo.slot); assert.equal(uploads, 1)
await state.load(); assert.equal(state.needsRefresh, false); assert.equal(state.editable, true)
})
test('in-flight upload blocks duplicate clicks and unmount releases the parent busy flag', async () => {
let resolveUpload, uploads = 0
const { state, events, definition } = component({ uploadLoadingPhoto: () => { uploads++; return new Promise(resolve => { resolveUpload = resolve }) } })
@@ -88,3 +142,49 @@ test('in-flight upload blocks duplicate clicks and unmount releases the parent b
definition.beforeUnmount.call(state); assert.deepEqual(events.at(-1), ['busy-change', false])
resolveUpload(photo); await first; assert.equal(events.some(event => event[0] === 'saved'), false)
})
test('normalization accepts five distinct photos per category but rejects a sixth', () => {
const photos = Array.from({ length: 5 }, (_, index) => ({ ...photo, fileId: String(index + 1) }))
assert.equal(normalizeLoadingDocuments(data({ photos }), 'PC/1').photos.length, 5)
assert.throws(() => normalizeLoadingDocuments(data({ photos: [...photos, { ...photo, fileId: '6' }] }), 'PC/1'), /最多5张/)
})
test('multi-select appends every image using fresh versions and counts categories rather than files', async () => {
let latest = data(), count = 0, selectedCount
const binds = []
const { state, events } = component({ getLoadingDocuments: async () => latest,
uploadLoadingPhoto: async () => ({ ...photo, fileId: String(++count) }),
bindLoadingPhoto: async (_no, _slot, body) => {
binds.push(body)
latest = data({ recordVersion: String(binds.length), photos: [...latest.photos, { ...photo, fileId: body.fileId }] }); return latest
} })
uni.chooseImage = async options => { selectedCount = options.count; return { tempFilePaths: ['a', 'b', 'c', 'd', 'e'] } }
await state.load(); await state.choose(photo.slot)
assert.equal(selectedCount, 5); assert.equal(state.snapshot.photos.length, 5); assert.equal(state.completedCategories, 1)
assert.deepEqual(binds.map(body => body.recordVersion), ['0', '1', '2', '3', '4'])
assert.equal(new Set(binds.map(body => body.idempotencyKey)).size, 5)
await state.choose(photo.slot); assert.equal(count, 5)
assert.equal(events.filter(event => event[0] === 'saved').length, 1)
})
test('replacement sends the selected old file and retains the other four photos', async () => {
let latest = data({ photos: Array.from({ length: 5 }, (_, index) => ({ ...photo, fileId: String(index + 1) })) })
let selectedCount, request
const { state } = component({ getLoadingDocuments: async () => latest, uploadLoadingPhoto: async () => ({ ...photo, fileId: '6' }),
bindLoadingPhoto: async (_no, _slot, body) => { request = body; latest = data({ recordVersion: '1', photos: latest.photos.map(item => item.fileId === body.replaceFileId ? { ...photo, fileId: body.fileId } : item) }); return latest } })
uni.chooseImage = async options => { selectedCount = options.count; return { tempFilePaths: ['replacement'] } }
await state.load(); await state.choose(photo.slot, '3')
assert.equal(selectedCount, 1); assert.equal(request.replaceFileId, '3')
assert.deepEqual(state.snapshot.photos.map(item => item.fileId), ['1', '2', '6', '4', '5'])
})
test('a partial batch failure preserves successful photos and notifies the task once', async () => {
let latest = data(), uploads = 0
const { state, events } = component({ getLoadingDocuments: async () => latest,
uploadLoadingPhoto: async () => { if (++uploads === 2) throw new Error('网络中断'); return photo },
bindLoadingPhoto: async () => { latest = data({ recordVersion: '1', photos: [photo] }); return latest } })
uni.chooseImage = async () => ({ tempFilePaths: ['first', 'failed', 'unattempted'] })
await state.load(); await state.choose(photo.slot)
assert.equal(uploads, 2); assert.equal(state.busy, false); assert.match(state.error.message, /网络中断/)
assert.deepEqual(state.snapshot.photos, [photo]); assert.equal(events.filter(event => event[0] === 'saved').length, 1)
})
+48
View File
@@ -0,0 +1,48 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { pickDispatchItem } from '../src/api/fulfillment.js'
import { session } from '../src/stores/session.js'
const path = '/api/pda/v1/last-leg/storage-rent/preparations'
const pick = '/api/pda/v1/last-leg/dispatches/PC1/items/9007199254740995/pick'
const body = { recordVersion: '9007199254740993', positionId: '9007199254740994', packages: 2, productQuantity: 0, cartonMark: null, idempotencyKey: 'dispatch-rent-1' }
const ready = { data: { preparationId: 'rent1', ready: true, status: 'READY' } }
const pending = { data: { preparationId: 'rent1', ready: false, status: 'FREEZE_PENDING' } }
const submit = () => pickDispatchItem('PC1', '9007199254740995', body)
function transport(responses) {
const calls = []; session.apiBase = '/api'; session.token = 'overseas-token'
globalThis.uni = { request(options) {
calls.push({ method: options.method, url: options.url, data: options.data })
const response = responses.shift(); assert.ok(response, 'unexpected request')
if (response.network) return options.fail({ errMsg: 'network down' })
options.success({ statusCode: 200, data: { code: '200', ...response }, header: {} })
} }
return calls
}
test('排车仓租与实际拣货绑定同一来源、数量和幂等键,保持长ID精度', async () => {
const calls = transport([ready, { data: { success: true } }]); await submit()
assert.deepEqual(calls, [
{ method: 'POST', url: path, data: { mode: 'DISPATCH', businessNo: 'PC1', itemId: '9007199254740995', positionId: body.positionId, packages: 2, cartonMark: null, idempotencyKey: body.idempotencyKey, productQuantity: 0 } },
{ method: 'POST', url: pick, data: body }
])
})
test('免租或不计租仍按后端就绪结果继续,非布尔就绪禁止拣货', async () => {
const calls = transport([{ data: { ready: true, status: 'NOT_REQUIRED' } }, { data: {} }]); await submit(); assert.equal(calls.at(-1).url, pick)
const blocked = transport([{ data: { ready: 'true' } }]); await assert.rejects(submit(), /预校验/); assert.equal(blocked.length, 1)
})
test('预占待回执只读查询,就绪后仅发一次拣货', async t => {
t.mock.method(globalThis, 'setTimeout', callback => { callback(); return 0 })
const calls = transport([pending, ready, { data: {} }]); await submit()
assert.deepEqual(calls.map(c => [c.method, c.url]), [['POST', path], ['GET', path + '/rent1'], ['POST', pick]])
})
test('资金失败释放预占;预校验404或断网不绕过,不发拣货', async () => {
const calls = transport([{ data: { preparationId: 'rent1', ready: false, status: 'FAILED', message: '余额不足' } }, { data: {} }])
await assert.rejects(submit(), /余额不足/); assert.deepEqual(calls.map(c => c.method), ['POST', 'DELETE'])
for (const failure of [{ code: 'A0404', message: 'missing', requestId: 'req1' }, { network: true }]) {
const failed = transport([failure]); await assert.rejects(submit(), e => failure.network ? e.network : e.code === failure.code && e.requestId === 'req1'); assert.equal(failed.length, 1)
}
})
test('拣货请求发出后结果不明,不重发也不释放可能已消费的预占', async () => {
const calls = transport([ready, { network: true }]); await assert.rejects(submit(), e => e.network)
assert.deepEqual(calls.map(c => c.method), ['POST', 'POST'])
})
+58 -1
View File
@@ -43,6 +43,43 @@ function page(dependencies = {}) {
}
const proof = (command, version = 1) => ({ dispatchNo: command.dispatchNo, action: command.action, idempotencyKey: command.body.idempotencyKey, recordVersion: version, stage: command.action === 'LOADING_COMPLETE' ? 'AFTER_LOADING' : 'LOADING', result: { confirmed: true } })
test('pallet scan notice reports the current pickable quantity and closes after three seconds without submitting', t => {
t.mock.timers.enable({ apis: ['setTimeout'] })
storage(); let writes = 0, sounds = 0
const state = page({ pickDispatchItem: async () => { writes++ }, playSuccessSound: () => { sounds++ } })
state.current = task({ items: [item({ scheduledPackages: 12, pickedPackages: 4, positions: [{ positionId: 'position-1', availablePackages: 6, allocatedPackages: 0, pickedPackages: 0 }] })] })
state.handleScan('PALLET-1')
assert.deepEqual(state.scanNotice, { palletNo: 'PALLET-1', packages: 6 })
assert.equal(sounds, 1)
t.mock.timers.tick(2999); assert.ok(state.scanNotice)
t.mock.timers.tick(1); assert.equal(state.scanNotice, null)
assert.equal(writes, 0); assert.equal(state.packages, 6)
})
test('consecutive pallet scans replace the notice and restart its full display time', t => {
t.mock.timers.enable({ apis: ['setTimeout'] })
storage(); const state = page()
state.current = task({ items: [item(), item({ dispatchItemId: 'item-2', sourceKey: 'source-2', palletNo: 'PALLET-2', scheduledPackages: 1 })] })
state.handleScan('PALLET-1'); t.mock.timers.tick(2000)
state.handleScan('PALLET-2')
assert.deepEqual(state.scanNotice, { palletNo: 'PALLET-2', packages: 1 })
t.mock.timers.tick(1000); assert.ok(state.scanNotice)
t.mock.timers.tick(2000); assert.equal(state.scanNotice, null)
})
test('scan errors, carton scans and switching tabs cannot retain a stale pallet notice', t => {
t.mock.timers.enable({ apis: ['setTimeout'] })
storage(); const state = page()
state.handleScan('PALLET-1'); state.handleScan('UNKNOWN')
assert.equal(state.scanNotice, null); assert.ok(state.error)
state.handleScan('PALLET-1'); state.selectTab('photos')
assert.equal(state.scanNotice, null)
state.selectTab('picking'); state.handleScan('PALLET-1')
state.current = task({ items: [item({ returnCartons: [{ returnCartonId: 'rc-1', cartonMark: 'RC-1', status: 'OUTBOUND_PENDING', productQuantity: 5 }] })] })
state.handleScan('RC-1'); assert.equal(state.scanNotice, null)
t.mock.timers.tick(3000); assert.equal(state.scanNotice, null)
})
test('real picking requires the native gate, physical source and current stage; empty or merely counted tasks cannot complete loading', async () => {
storage(); const state = page(); state.selectPallet(state.current.items[0]); assert.equal(state.canPick, true)
for (const allowed of [false, 'true', undefined]) { state.current.actions.PICK.allowed = allowed; assert.equal(state.canPick, false) }
@@ -174,12 +211,32 @@ test('missing appointment blocks approval while preserving rejection to customer
storage(); const sent = []
const state = page({ reviewDispatchLoading: async (...args) => { sent.push(args); return proof(lastCommand) }, getDispatchTask: async () => task({ recordVersion: 1 }) })
state.current = reviewTask({ actions: { LOADING_REVIEW: { allowed: true, blockCode: 'APPROVAL_BLOCKED', blockReason: '预约收货时间尚未确认' } } })
assert.equal(state.reviewTime('2026-10-21T17:00:00Z'), '2026-10-22 01:00')
assert.equal(state.reviewTime('2026-10-21T17:00:00Z'), '美东 2026-10-21 13:00\n美西 2026-10-21 10:00')
assert.equal(state.reviewTime(null), '未填写')
state.reviewReason = '请客服补齐预约时间'
await state.submitReview('APPROVED'); assert.equal(sent.length, 0); assert.match(state.error.message, /预约收货时间尚未确认/)
await state.submitReview('REJECTED'); assert.equal(sent.length, 1); assert.equal(sent[0][1].decision, 'REJECTED')
})
for (const [instant, expected] of [
['2026-11-06T00:00:00+08:00', '美东 2026-11-05 11:00\n美西 2026-11-05 08:00'],
['2026-11-05T16:00:00Z', '美东 2026-11-05 11:00\n美西 2026-11-05 08:00'],
['2026-07-01T15:00:00Z', '美东 2026-07-01 11:00\n美西 2026-07-01 08:00'],
['2026-03-08T06:30:00Z', '美东 2026-03-08 01:30\n美西 2026-03-07 22:30'],
['2026-03-08T07:30:00Z', '美东 2026-03-08 03:30\n美西 2026-03-07 23:30'],
['2026-03-08T10:30:00Z', '美东 2026-03-08 06:30\n美西 2026-03-08 03:30'],
['2026-11-01T05:30:00Z', '美东 2026-11-01 01:30\n美西 2026-10-31 22:30'],
['2026-11-01T07:30:00Z', '美东 2026-11-01 02:30\n美西 2026-11-01 00:30'],
['2026-11-01T10:30:00Z', '美东 2026-11-01 05:30\n美西 2026-11-01 02:30'],
]) test(`review times use eastern and pacific daylight-saving rules for ${instant}`, () => {
assert.equal(page().reviewTime(instant), expected)
})
test('review times preserve missing values and reject invalid or timezone-free timestamps', () => {
const state = page()
for (const missing of [null, undefined, '']) assert.equal(state.reviewTime(missing), '未填写')
for (const invalid of ['not-a-date', 'invalidZ', '2026-11-06T00:00:00', '2026-11-06']) assert.equal(state.reviewTime(invalid), '时间无效')
})
test('review task is reachable in the existing list and blocks actual source selection until approval', async () => {
storage(); const state = page({ listDispatchTasks: async () => [reviewTask()], getDispatchTask: async () => reviewTask() })
await state.loadTasks(); assert.equal(state.tasks.length, 1); assert.equal(state.stageLabel(state.tasks[0]), '待理货审核')
+56
View File
@@ -0,0 +1,56 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { pickDispatchItem, pickOutboundTask, pickOutboundPallet, confirmCargoDiscard } from '../src/api/fulfillment.js'
import { session } from '../src/stores/session.js'
// 排车拣货先完成仓租预校验,再提交同一份库存命令。
function transport(failure) {
const calls = []
session.apiBase = '/api'; session.token = 'overseas-token'
globalThis.uni = { request(options) {
calls.push({ method: options.method, url: options.url, data: options.data })
if (failure?.network) return options.fail({ errMsg: 'network down' })
const rent = options.url.includes('/storage-rent/')
options.success({ statusCode: 200, data: rent ? { code: '200', data: { preparationId: 'rent-1', ready: true, status: 'READY' } } : failure || { code: '200', data: { success: true } }, header: {} })
} }
return calls
}
const dispatch = { recordVersion: '9007199254740993', positionId: '9007199254740994', packages: 21, cartonMark: null, productQuantity: 0, idempotencyKey: 'dispatch-key' }
const outbound = { outboundPalletItemId: '9007199254740996', inventoryPositionId: null, packages: 1, cartonMarks: ['RETURN-BOX-1'], skuCode: 'SKU-A', productQuantity: 5, idempotencyKey: 'outbound-key' }
const pallet = { idempotencyKey: 'pallet-key' }
const discard = { lines: [{ itemId: '9007199254740993', actualPackages: 2 }], photoFileIds: ['101'], scannedCartonMarks: ['BOX-1'], confirmAllCartons: false, idempotencyKey: 'discard-key' }
const modes = [
{ name: 'dispatch', path: '/dispatches/DSP%2F1/items/9007199254740995/pick', body: dispatch, submit: () => pickDispatchItem('DSP/1', '9007199254740995', dispatch) },
{ name: 'outbound', path: '/outbound-tasks/TASK%2F1/pick', body: outbound, submit: () => pickOutboundTask('TASK/1', outbound) },
{ name: 'pallet', path: '/outbound-tasks/TASK%2F1/pallets/DP%2F1/pick', body: pallet, submit: () => pickOutboundPallet('TASK/1', 'DP/1', pallet) },
]
for (const mode of modes) {
test(`${mode.name} preserves its stock payload`, async () => {
const calls = transport()
await mode.submit()
const stock = calls.find(call => call.url.includes(mode.path))
assert.deepEqual(stock, { method: 'POST', url: '/api/pda/v1/last-leg' + mode.path, data: mode.body })
if (mode.name === 'dispatch') assert.equal(calls.filter(call => call.url.includes('/storage-rent/')).length, 1)
})
test(`${mode.name} propagates stock failures without fallback or automatic resubmission`, async () => {
for (const failure of [{ code: 'A0404', message: 'missing' }, { code: 'A0409', message: 'conflict' }, { code: 'S0503', message: 'unavailable' }, { network: true }]) {
const calls = transport(failure)
await assert.rejects(mode.submit(), error => failure.network ? error.network === true : error.code === failure.code)
const stock = calls.find(call => call.url.includes(mode.path))
if (failure.network && mode.name === 'dispatch') assert.equal(stock, undefined)
else assert.deepEqual(stock, { method: 'POST', url: '/api/pda/v1/last-leg' + mode.path, data: mode.body })
}
})
}
test('invalid dispatch version and missing command keys fail before any request', async () => {
const calls = transport()
await assert.rejects(pickDispatchItem('DSP-1', '11', { idempotencyKey: 'key' }), /版本/)
await assert.rejects(pickDispatchItem('DSP-1', '11', { recordVersion: 1 }), /标识/)
await assert.rejects(pickOutboundTask('TASK-1', {}), /标识/)
await assert.rejects(pickOutboundPallet('TASK-1', 'DP-1', {}), /标识/)
await assert.rejects(confirmCargoDiscard('CD-1', 'P-1', [], [], [], false, {}), /标识/)
assert.equal(calls.length, 0)
})
+11 -1
View File
@@ -23,6 +23,15 @@ test('dispatch network failures show recovery guidance and retain diagnostics',
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
test('outbound list and detail lead with the outbound number and label the task number separately', async () => {
const page = await read('../src/pages/outbound/outbound.vue')
assert.match(page, /class="business-code">\{\{ task\.outboundNo \|\| '—' \}\}/)
assert.match(page, /class="business-code">\{\{ current\.outboundNo \|\| '—' \}\}/)
assert.match(page, /任务号 \{\{ task\.taskNo \|\| '—' \}\}/)
assert.match(page, /任务号 \{\{ current\.taskNo \|\| '—' \}\}/)
assert.match(page, /@tap="openTask\(task\.taskNo\)"/)
})
test('native dispatch versions preserve explicit zero and reject missing or invalid versions', () => {
assert.equal(requireDispatchVersion(0), 0)
assert.equal(requireDispatchVersion('9007199254740993'), '9007199254740993')
@@ -112,6 +121,7 @@ test('fulfillment API calls the frozen list detail pick complete and review rout
else if (options.method === 'GET' && options.url.includes('/dispatches/')) data = { dispatchNo: 'DSP-1', taskNo: 'TASK-1', items: [] }
else if (options.method === 'GET' && options.url.includes('/outbound-tasks/resolve/')) data = { workflow: 'DISPATCH', dispatchNo: 'DSP-1', outboundNo: 'OUT-1' }
else if (options.method === 'GET' && options.url.includes('/outbound-tasks/')) data = { task: { taskNo: 'OUT-TASK-1', outboundNo: 'OUT-1' }, items: [] }
else if (options.method === 'POST' && options.url.includes('/storage-rent/preparations')) data = { preparationId: 'rent-1', ready: true, status: 'READY' }
options.success({ statusCode: 200, data: { code: '200', data }, header: {} })
}
}
@@ -126,7 +136,7 @@ test('fulfillment API calls the frozen list detail pick complete and review rout
await pickOutboundPallet('OUT/TASK/1', 'DP/1', { idempotencyKey: 'out-pallet-key' })
await completeOutboundTask('OUT/TASK/1', { idempotencyKey: 'out-complete-key' })
assert.equal(calls.length, 13)
assert.equal(calls.length, 14)
assert.deepEqual(calls.find(call => call.url.endsWith('/dispatches/DSP%2F1/loading-review')).data, { recordVersion: 0, idempotencyKey: 'review-key', decision: 'REJECTED', reason: '车牌不符' })
assert.ok(calls.some((call) => call.url.endsWith('/dispatches')))
assert.ok(calls.some((call) => call.url.includes('/dispatches/DSP%2F1/items/11/pick')))
+48 -46
View File
@@ -1,52 +1,43 @@
import test from 'node:test'
import { runInNewContext } from 'node:vm'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { loadEnv } from 'vite'
const read = path => readFile(new URL(path, import.meta.url), 'utf8')
test('H5 image copies only compiled assets and static nginx configuration', async () => {
const dockerfile = await read('../deploy/Dockerfile.h5')
const copies = dockerfile.split(/\r?\n/).filter(line => line.startsWith('COPY '))
assert.deepEqual(copies, ['COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf', 'COPY dist/build/h5/ /srv/pda-h5/'])
assert.match(dockerfile, /ARG NGINX_BASE_IMAGE=/)
assert.match(dockerfile, /FROM \$\{NGINX_BASE_IMAGE\}/)
assert.doesNotMatch(dockerfile, /RUN |npm|node_modules|COPY \. /)
const ignore = await read('../.dockerignore')
assert.ok(ignore.split(/\r?\n/).includes('**'))
assert.ok(ignore.includes('!dist/build/h5/**'))
assert.doesNotMatch(ignore, /!\.env|!node_modules|!src/)
test('PDA H5 has no container publishing entry', async () => {
const files = await readdir(new URL('../deploy/', import.meta.url))
assert.deepEqual(files.filter(name => /docker|compose/i.test(name)), [])
await assert.rejects(read('../.dockerignore'), { code: 'ENOENT' })
})
test('static server serves the domain root and returns genuine missing-resource errors', async () => {
const nginx = await read('../deploy/nginx.conf')
assert.match(nginx, /listen 80;/)
assert.ok(nginx.includes('location /'))
assert.match(nginx, /root \/srv\/pda-h5;/)
assert.match(nginx, /try_files \$uri \$uri\/ =404;/)
assert.match(nginx, /Cache-Control "no-store" always/)
assert.doesNotMatch(nginx, /pda-domestic|pda-overseas/)
assert.match(nginx, /location = \/healthz/)
assert.doesNotMatch(nginx, /proxy_pass|gateway-api|try_files[^;]*index\.html/)
})
test('TEST H5 deployment has a required image tag and internal-only dual health checks', async () => {
const compose = await read('../deploy/docker-compose.h5.test.yml')
assert.ok(compose.includes('${IMAGE_TAG:?IMAGE_TAG is required}'))
assert.match(compose, /context: \.\./)
assert.match(compose, /dockerfile: deploy\/Dockerfile\.h5/)
assert.match(compose, /container_name: pda-overseas-h5-test/)
assert.match(compose, /aliases:\s*\n\s*- pda-overseas-h5-test/)
assert.match(compose, /external: true/)
assert.match(compose, /name: firstleg-test-network/)
assert.match(compose, /expose:\s*\n\s*- "80"/)
assert.ok(compose.includes('http://127.0.0.1/healthz && wget -qO /dev/null http://127.0.0.1/'))
assert.doesNotMatch(compose, /\bports:|volumes:|API_UPSTREAM|privileged:|pid: host/)
test('host nginx serves uploaded files and proxies only the API prefix', async () => {
const nginx = await read('../deploy/nginx.conf.template')
assert.ok(nginx.includes('root ${PDA_SITE_ROOT};'))
const page = nginx.match(/location \/ \{([^{}]*)\}/)?.[1]
assert.ok(page)
assert.match(page, /try_files \$uri \$uri\/ =404;/)
assert.match(page, /Cache-Control "no-store" always/)
assert.doesNotMatch(page, /proxy_pass|try_files[^;]*index\.html/)
assert.match(nginx, /location \^~ \/gateway-api\//)
assert.ok(nginx.includes('proxy_pass ${PDA_GATEWAY_ORIGIN}/api/;'))
assert.equal((nginx.match(/proxy_pass/g) || []).length, 1)
assert.match(nginx, /proxy_set_header Authorization \$http_authorization;/)
assert.match(nginx, /client_max_body_size 55m;/)
assert.doesNotMatch(nginx, /29005|29006|\/srv\/pda-h5|gateway:9000/)
})
test('only H5 production overrides the toolchain asset base', async () => {
const source = (await read('../vite.config.js'))
.replace(/^import .*$/gm, '')
.replaceAll('import.meta.url', 'configUrl')
.replace('export default ', '')
for (const platform of ['h5', 'app', 'app-plus', 'app-harmony', undefined]) {
for (const mode of ['production', 'development']) {
const factory = runInNewContext(source, {
URL, fileURLToPath, configUrl: new URL('../vite.config.js', import.meta.url).href,
defineConfig: value => value,
loadEnv: () => ({}),
uni: () => ({}),
@@ -60,8 +51,10 @@ test('only H5 production overrides the toolchain asset base', async () => {
})
test('H5 proxy uses the shared gateway prefix and preserves API suffixes', async () => {
const source = (await read('../vite.config.js')).replace(/^import .*$/gm, '').replace('export default ', '')
const source = (await read('../vite.config.js')).replace(/^import .*$/gm, '')
.replaceAll('import.meta.url', 'configUrl').replace('export default ', '')
const factory = runInNewContext(source, {
URL, fileURLToPath, configUrl: new URL('../vite.config.js', import.meta.url).href,
defineConfig: value => value, loadEnv: () => ({ API_PROXY_TARGET: 'https://gateway.example.com' }),
uni: () => ({}), process: { env: { UNI_PLATFORM: 'h5' }, cwd: () => '/project' }
})
@@ -73,14 +66,23 @@ test('H5 proxy uses the shared gateway prefix and preserves API suffixes', async
}
})
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/)
test('App update configuration loads from the project when HBuilderX starts elsewhere', async t => {
const fixturePrefix = join(tmpdir(), 'overseas-pda-env-')
const fixtureDir = await mkdtemp(fixturePrefix)
assert.ok(resolve(fixtureDir).startsWith(resolve(fixturePrefix)))
t.after(() => rm(fixtureDir, { recursive: true, force: true }))
const endpoint = 'https://updates.example.test/pda/overseas/latest'
await writeFile(join(fixtureDir, '.env.local'), `VITE_PDA_UPDATE_URL=${endpoint}\n`)
const source = (await read('../vite.config.js')).replace(/^import .*$/gm, '')
.replaceAll('import.meta.url', 'configUrl').replace('export default ', '')
const factory = runInNewContext(source, {
URL, fileURLToPath, configUrl: pathToFileURL(join(fixtureDir, 'vite.config.js')).href,
defineConfig: value => value, loadEnv, uni: () => ({}),
process: { env: { UNI_PLATFORM: 'app' }, cwd: () => join(fixtureDir, 'HBuilderX') }
})
for (const mode of ['development', 'production']) {
const config = factory({ mode })
assert.equal(JSON.parse(config.define['import.meta.env.VITE_PDA_UPDATE_URL']), endpoint)
assert.equal(resolve(config.envDir), resolve(fixtureDir))
}
})
+220 -14
View File
@@ -7,9 +7,9 @@ const deferred = () => { let resolve; const promise = new Promise(done => { reso
function page(overrides = {}) {
const calls = []
const deps = { PortalHeader: {}, ScanTargetInput: {}, session: { currentUser: {} }, fulfillment: { outbound: { packages: 1, currentTaskNo: 'OLD-TASK' } },
updateFulfillmentDraft() {}, fulfillmentIdempotencyKey: () => ({ value: 'key', signature: 'signature' }), releaseFulfillmentKey() {},
updateFulfillmentDraft() {}, fulfillmentIdempotencyKey: () => ({ value: 'key' }),
playErrorSound() {}, playSuccessSound() {}, pickOutboundPallet: async () => calls.push('pick'), completeOutboundTask: async () => calls.push('complete'),
listCargoDiscardPhotos: async () => [], getOutboundTask: async () => ({ taskNo: 'TASK', status: 'COMPLETED', items: [] }), normalizeCargoDiscardTask: value => value,
listCargoDiscardPhotos: async () => [], getOutboundTask: async () => ({ taskNo: 'TASK', status: 'COMPLETED', items: [] }), normalizeCargoDiscardTask: value => value, resolveCargoDiscardPallet: async code => vm.discardTasks.find(task => task.palletNo === code || task.cartonMarks?.includes(code)),
...overrides }
const definition = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
const vm = definition.data()
@@ -79,10 +79,13 @@ test('同单按箱废弃允许扫描当前托盘及其他托盘所选箱唛,
await vm.resolveDiscardPallet('P-1')
assert.equal(vm.error, null)
await vm.resolveDiscardPallet('BOX-2')
assert.equal(vm.discardTask.palletNo, 'P-1')
await vm.resolveDiscardPallet('P-2')
await vm.resolveDiscardPallet('BOX-2')
assert.equal(vm.discardTask.palletNo, 'P-2')
assert.deepEqual(vm.discardScannedMarks, ['BOX-2'])
await vm.resolveDiscardPallet('BOX-3')
assert.deepEqual(requests, ['P-1', 'BOX-2'])
assert.deepEqual(requests, ['P-1', 'P-2'])
assert.equal(vm.discardTask.palletNo, 'P-2')
assert.deepEqual(vm.discardScannedMarks, ['BOX-2'])
})
@@ -182,30 +185,46 @@ test('discard confirmation is guarded against double submission', async () => {
pending.resolve(); await Promise.all([first, second]); assert.equal(submits, 1)
})
test('无精确箱唛对应的托盘必须填写实际数并按实点提交,不能超量或用空值代替零', async () => {
test('无关联库存按现场箱唛去重计数,提交扫描清单而非手填数量', async () => {
let payload
const { vm } = page({ confirmCargoDiscard: async (...args) => { payload = args }, listCargoDiscardTasks: async () => [] })
vm.discardCurrent = { discardNo: 'CD-A' }
vm.discardTask = { palletNo: 'P-A', status: 'PENDING', scanMode: 'PALLET', cartonMarks: [], skus: [{ itemId: 'sku', plannedPackages: 10, actualQuantityEditable: true }] }
vm.discardTask = { palletNo: 'P-A', status: 'PENDING', scanMode: 'PALLET', cartonMarks: [], skus: [{ itemId: 'sku', plannedPackages: 10, actualQuantityEditable: true, allowedCartonMarks: ['FIELD-A','FIELD-B','FIELD-C','BOX-1','BOX-2',...Array.from({ length: 10 }, (_, i) => 'FIELD-'+i)] }] }
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
for (const value of ['', ' ', null, -1, 11, 1.5, 'abc']) {
vm.discardActuals = { sku: value }; assert.equal(vm.canConfirmDiscard, false)
}
vm.discardActuals = { sku: '0' }; assert.equal(vm.canConfirmDiscard, true)
vm.discardActuals = { sku: '6' }; assert.equal(vm.canConfirmDiscard, true)
vm.discardActuals = { sku: '6' }; assert.equal(vm.canConfirmDiscard, false)
vm.discardActuals = { sku: 0 }; for (let i=0;i<6;i++) await vm.scanDiscardCarton('FIELD-'+i)
await vm.scanDiscardCarton('FIELD-0'); assert.equal(vm.discardActuals.sku, 6); assert.equal(vm.canConfirmDiscard, true)
await vm.confirmDiscard()
assert.equal(payload[0], 'CD-A'); assert.equal(payload[1], 'P-A')
assert.deepEqual(payload[2], [{ itemId: 'sku', actualPackages: 6 }])
assert.deepEqual(payload[2], [{ itemId: 'sku', actualPackages: 6, scannedCartonMarks: Array.from({ length: 6 }, (_, i) => 'FIELD-'+i) }])
})
test('无箱唛对应托盘扫码后实际数保持未填,必须由仓库实点填写', async () => {
const { vm } = page({ resolveCargoDiscardPallet: async () => ({ discardNo: 'CD-A', palletNo: 'P-A', cartonMarks: [], scanMode: 'PALLET', skus: [{ itemId: 'sku', plannedPackages: 10, actualQuantityEditable: true }] }) })
test('无关联托盘扫码后从零开始,等待扫描现场箱唛', async () => {
const { vm } = page({ resolveCargoDiscardPallet: async () => ({ discardNo: 'CD-A', palletNo: 'P-A', cartonMarks: [], scanMode: 'PALLET', skus: [{ itemId: 'sku', plannedPackages: 10, actualQuantityEditable: true, allowedCartonMarks: ['FIELD-A','FIELD-B','FIELD-C','BOX-1','BOX-2',...Array.from({ length: 10 }, (_, i) => 'FIELD-'+i)] }] }) })
vm.discardCurrent = { discardNo: 'CD-A' }
await vm.resolveDiscardPallet('P-A')
assert.equal(vm.discardActuals.sku, '')
assert.equal(vm.discardActuals.sku, 0)
assert.equal(vm.canConfirmDiscard, false)
})
test('无关联任务先扫托盘,再扫现场箱唛自动计数', async () => {
const { vm } = page()
vm.discardTasks = [{ discardNo: 'CD-NO-BOX', palletNo: 'P-1', status: 'PENDING', scanMode: 'PALLET', cartonMarks: [], skus: [{ itemId: 'sku', plannedPackages: 2, actualQuantityEditable: true, allowedCartonMarks: ['FIELD-A','FIELD-B','FIELD-C','BOX-1','BOX-2',...Array.from({ length: 10 }, (_, i) => 'FIELD-'+i)] }] }]
vm.openDiscardOrder('CD-NO-BOX'); await Promise.resolve()
assert.equal(vm.discardTask, null)
await vm.resolveDiscardPallet('P-1')
assert.equal(vm.discardTask.palletNo, 'P-1')
assert.equal(vm.discardActuals.sku, 0)
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
await vm.scanDiscardCarton('RANDOM-FOREIGN-BOX'); assert.equal(vm.discardActuals.sku, 0); assert.match(vm.error.message, /不属于/);
await vm.scanDiscardCarton('FIELD-A'); await vm.scanDiscardCarton('FIELD-B')
assert.equal(vm.discardActuals.sku, 2); assert.equal(vm.canConfirmDiscard, true)
})
test('discard retry stays in the discard workflow instead of restoring an old outbound task', async () => {
const { vm } = page(); const calls = []
vm.discardCurrent = { discardNo: 'DISCARD' }; vm.discardPalletCode = 'PALLET'
@@ -224,11 +243,12 @@ test('closing or switching discard order ignores the previous resolve response',
})
test('loose disposal opens all marks, blocks partial scans and accepts explicit bulk confirmation', async () => {
test('loose disposal opens all marks and requires every carton scan', async () => {
const { vm } = page()
vm.discardTasks = [{ discardNo: 'CD-L', palletNo: '散箱', scanMode: 'CARTON', cartonMarks: ['BOX-1', 'BOX-2'], status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 2 }] }]
vm.openDiscardOrder('CD-L')
await Promise.resolve()
assert.equal(vm.discardTask, null)
await vm.resolveDiscardPallet('BOX-1')
assert.equal(vm.discardTask.scanMode, 'CARTON')
assert.deepEqual(vm.discardTask.cartonMarks, ['BOX-1', 'BOX-2'])
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
@@ -240,7 +260,7 @@ test('loose disposal opens all marks, blocks partial scans and accepts explicit
assert.equal(vm.canConfirmDiscard, true)
vm.discardActuals.sku = 1; assert.equal(vm.canConfirmDiscard, false)
vm.discardActuals.sku = 2; vm.discardScannedMarks = []; vm.discardAllCartons = true
assert.equal(vm.canConfirmDiscard, true)
assert.equal(vm.canConfirmDiscard, false)
vm.discardPhotos = []; assert.equal(vm.canConfirmDiscard, false)
})
@@ -250,9 +270,195 @@ test('partial bound pallet confirmation sends only selected marks and preserves
const { vm } = page({ confirmCargoDiscard: async (...args) => { payload = args }, listCargoDiscardTasks: async () => [] })
vm.discardTasks = [{ discardNo: 'CD-P', outboundNo: 'OB-P', palletNo: 'P-1', scanMode: 'CARTON', cartonMarks: ['BOX-2'], status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }]
vm.openDiscardOrder('CD-P'); await Promise.resolve()
assert.equal(vm.discardTask, null)
await vm.resolveDiscardPallet('P-1')
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
await vm.resolveDiscardPallet('BOX-2'); assert.equal(vm.canConfirmDiscard, true)
await vm.resolveDiscardPallet('BOX-OTHER'); assert.deepEqual(vm.discardScannedMarks, ['BOX-2']); assert.equal(vm.discardTask.palletNo, 'P-1')
await vm.confirmDiscard()
assert.deepEqual(payload, ['CD-P', 'P-1', [{ itemId: 'sku', actualPackages: 1 }], ['101'], ['BOX-2'], false])
assert.deepEqual(payload, ['CD-P', 'P-1', [{ itemId: 'sku', actualPackages: 1 }], ['101'], ['BOX-2'], false, { idempotencyKey: 'key' }])
})
test('discard confirmation preserves inputs after stock command failure and starts a new explicit request', async () => {
const submitted = []
const retry = deferred()
let attempt = 0
const { vm } = page({
confirmCargoDiscard: async (...args) => { submitted.push(args); if (++attempt === 1) throw new Error('库存状态已变化'); await retry.promise },
fulfillmentIdempotencyKey: () => ({ value: `discard-${attempt}` }), listCargoDiscardTasks: async () => []
})
vm.discardCurrent = { discardNo: 'CD-RETRY' }
vm.discardTask = { palletNo: 'P-1', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }
vm.discardTasks = [{ ...vm.discardTask, discardNo: 'CD-RETRY' }]
vm.discardActuals = { sku: 1 }; vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
await vm.confirmDiscard()
assert.equal(vm.discardBusy, false)
assert.ok(vm.discardTask)
assert.equal(vm.error.message, '库存状态已变化')
vm.successMessage = '上次作业已完成'
const retried = vm.confirmDiscard()
assert.equal(vm.error, null)
assert.equal(vm.successMessage, '')
retry.resolve(); await retried
assert.deepEqual(submitted[0].slice(0, 6), submitted[1].slice(0, 6))
assert.notEqual(submitted[0][6].idempotencyKey, submitted[1][6].idempotencyKey)
assert.equal(vm.discardBusy, false)
assert.equal(vm.error, null)
assert.match(vm.successMessage, /已完成/)
})
test('dedicated scanner validates carton scope and preserves photos', () => {
const { vm } = page()
vm.discardTask = { palletNo: 'P-1', scanMode: 'CARTON', cartonMarks: ['BX-1', 'BX-2'], status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 2 }] }
vm.discardActuals = { sku: 2 }; vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
assert.deepEqual(vm.discardPendingMarks, ['BX-1', 'BX-2'])
assert.deepEqual(vm.discardQuantityLines, [])
vm.discardCartonCode = ' BX-1 '; vm.scanDiscardCarton()
assert.equal(vm.discardCartonCode, '')
vm.scanDiscardCarton('BX-1'); vm.scanDiscardCarton('OTHER')
assert.deepEqual(vm.discardScannedMarks, ['BX-1']); assert.equal(vm.canConfirmDiscard, false)
assert.deepEqual(vm.discardPendingMarks, ['BX-2'])
assert.match(vm.error.message, /箱号不属于/)
vm.discardCartonsExpanded = false
vm.scanDiscardCarton('BX-2'); assert.equal(vm.canConfirmDiscard, true)
assert.deepEqual(vm.discardPendingMarks, [])
assert.equal(vm.discardCartonsExpanded, false)
assert.deepEqual(vm.discardPhotos, [{ fileId: '101' }]); assert.equal(vm.discardTask.palletNo, 'P-1')
vm.discardTask.status = 'COMPLETED'; vm.scanDiscardCarton('OTHER'); assert.equal(vm.error, null)
})
test('bound discard opens only after its pallet scan and summary hides carton numbers', async () => {
const { vm } = page()
vm.discardTasks = [{ discardNo: 'CD-1', palletNo: 'P-1', status: 'PENDING', scanMode: 'CARTON', cartonMarks: ['BX-1'], skus: [{ itemId: 'sku', plannedPackages: 1 }] }]
vm.openDiscardOrder('CD-1'); assert.equal(vm.discardTask, null)
await vm.resolveDiscardPallet('BX-1'); assert.equal(vm.discardTask, null)
await vm.resolveDiscardPallet('OTHER'); assert.equal(vm.discardTask, null)
await vm.resolveDiscardPallet('P-1'); assert.equal(vm.discardTask.palletNo, 'P-1')
assert.deepEqual(vm.discardScannedMarks, [])
assert.deepEqual(vm.discardTask.cartonMarks, ['BX-1'])
const template = readFileSync(new URL('../src/pages/outbound/outbound.vue', import.meta.url), 'utf8')
const summary = template.slice(template.indexOf('废弃箱关联托盘'), template.indexOf('<ScanTargetInput name="cargo-discard"'))
assert.doesNotMatch(summary, /task.cartonMarks/)
})
test('multi-pallet discard prompts remaining pallet numbers and requires a new pallet scan', async () => {
const first = { discardNo: 'CD-M', palletNo: 'P-1', status: 'PENDING', scanMode: 'CARTON', cartonMarks: ['BX-1'], skus: [{ itemId: 'sku1', plannedPackages: 1 }] }
const second = { ...first, palletNo: 'P-2', cartonMarks: ['BX-2'], skus: [{ itemId: 'sku2', plannedPackages: 1 }] }
const other = { ...first, discardNo: 'CD-OTHER', palletNo: 'P-OTHER' }
let completed = 0
const { vm } = page({ confirmCargoDiscard: async () => { completed++ }, listCargoDiscardTasks: async () => [{ ...first, status: 'COMPLETED' }, { ...second, status: completed === 2 ? 'COMPLETED' : 'PENDING' }, other] })
vm.discardTasks = [first, second, other]; vm.openDiscardOrder('CD-M')
await vm.resolveDiscardPallet('P-1'); vm.scanDiscardCarton('BX-1')
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
await vm.confirmDiscard()
assert.equal(vm.discardTask, null); assert.equal(vm.discardPalletCode, '')
assert.deepEqual(vm.discardPendingTasks.map(task => task.palletNo), ['P-2'])
assert.match(vm.successMessage, /P-1.*已完成.*P-2.*继续扫描/)
assert.doesNotMatch(vm.successMessage, /P-OTHER|全部/)
assert.equal(vm.canConfirmDiscard, false)
await vm.resolveDiscardPallet('P-2'); assert.equal(vm.successMessage, '')
assert.deepEqual(vm.discardPendingMarks, ['BX-2']); assert.deepEqual(vm.discardPhotos, [])
vm.scanDiscardCarton('BX-2'); vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '102' }]
await vm.confirmDiscard()
assert.deepEqual(vm.discardPendingTasks, []); assert.match(vm.successMessage, /本单全部.*已完成/)
})
test('successful discard followed by refresh failure does not claim the whole order completed', async () => {
let confirms = 0
const { vm } = page({ confirmCargoDiscard: async () => { confirms++ }, listCargoDiscardTasks: async () => { throw new Error('offline') } })
const first = { discardNo: 'CD-M', palletNo: 'P-1', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }
vm.discardTasks = [first, { ...first, palletNo: 'P-2' }]; vm.openDiscardOrder('CD-M')
await vm.resolveDiscardPallet('P-1'); vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
await vm.confirmDiscard(); await vm.confirmDiscard()
assert.equal(confirms, 1); assert.equal(vm.discardTask, null)
assert.match(vm.successMessage, /P-1.*已完成.*刷新失败/); assert.doesNotMatch(vm.successMessage, /全部/)
assert.deepEqual(vm.discardPendingTasks.map(task => task.palletNo), ['P-2'])
})
test('仓租余额不足显示原因并保留当前托盘、已扫箱号、实际数与现场照片', async () => {
const { vm } = page({ confirmCargoDiscard: async () => { throw Object.assign(new Error('仓租校验未通过:可用余额和授信额度不足'), { code: 'A0409' }) } })
vm.discardCurrent = { discardNo: 'CD-1' }
vm.discardTask = { palletNo: 'P-1', scanMode: 'CARTON', status: 'PENDING', cartonMarks: ['BX-1'], skus: [{ itemId: 'sku', plannedPackages: 1 }] }
vm.discardActuals = { sku: 1 }; vm.discardScannedMarks = ['BX-1']
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
await vm.confirmDiscard()
assert.match(vm.error.message, /余额和授信额度不足/); assert.equal(vm.error.code, 'A0409')
assert.equal(vm.discardTask.palletNo, 'P-1'); assert.deepEqual(vm.discardScannedMarks, ['BX-1'])
assert.deepEqual(vm.discardActuals, { sku: 1 }); assert.deepEqual(vm.discardPhotos, [{ fileId: '101' }])
assert.equal(vm.successMessage, ''); assert.equal(vm.discardBusy, false)
})
for (const action of ['pallet', 'carton', 'retry']) {
test(`出库资金不足后通过 ${action} 重新核对并再次手动提交`, async () => {
let submits = 0; let reads = 0; let key = 0; const keys = []
const task = { discardNo: 'CD-1', palletNo: 'P-1', status: 'PENDING', scanMode: 'CARTON', cartonMarks: ['BX-1'], skus: [{ itemId: 'sku', plannedPackages: 1 }] }
const { vm } = page({
resolveCargoDiscardPallet: async () => { reads++; return { ...task } },
confirmCargoDiscard: async (...args) => { keys.push(args.at(-1).idempotencyKey); if (++submits === 1) throw new Error('可用余额和授信额度不足') },
fulfillmentIdempotencyKey: () => ({ value: `key-${++key}` }), listCargoDiscardTasks: async () => []
})
vm.discardCurrent = { discardNo: 'CD-1' }; vm.discardTask = task; vm.discardTasks = [task]
vm.discardActuals = { sku: 1 }; vm.discardScannedMarks = ['BX-1']
vm.discardPhotos = [{ fileId: '101' }]; vm.discardPhotoReady = true
await vm.confirmDiscard(); assert.equal(vm.discardNeedsRefresh, true)
if (action === 'pallet') await vm.resolveDiscardPallet('P-1')
else if (action === 'carton') await vm.scanDiscardCarton('BX-1')
else await vm.retry()
assert.equal(reads, 1); assert.equal(submits, 1); assert.equal(vm.error, null)
assert.equal(vm.discardNeedsRefresh, false); assert.deepEqual(vm.discardScannedMarks, ['BX-1'])
assert.deepEqual(vm.discardActuals, { sku: 1 }); assert.deepEqual(vm.discardPhotos, [{ fileId: '101' }])
await vm.confirmDiscard(); assert.equal(submits, 2); assert.notEqual(keys[0], keys[1])
assert.match(vm.successMessage, /全部.*已完成/)
})
}
test('结果不明后刷新发现已完成,不再重复出库', async () => {
let submits = 0
const task = { discardNo: 'CD-1', palletNo: 'P-1', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }
const { vm } = page({ confirmCargoDiscard: async () => { submits++; throw Object.assign(new Error('网络中断'), { network: true }) }, resolveCargoDiscardPallet: async () => ({ ...task, status: 'COMPLETED' }) })
vm.discardCurrent = { discardNo: 'CD-1' }; vm.discardTask = task
vm.discardActuals = { sku: 1 }; vm.discardPhotos = [{ fileId: '101' }]; vm.discardPhotoReady = true
await vm.confirmDiscard(); await vm.confirmDiscard()
assert.equal(submits, 1); assert.equal(vm.discardTask.status, 'COMPLETED'); assert.equal(vm.canConfirmDiscard, false)
})
test('失败后刷新仍报错,保留草稿且不再发送出库', async () => {
let submits = 0
const { vm } = page({ confirmCargoDiscard: async () => { submits++; throw new Error('出库失败') }, resolveCargoDiscardPallet: async () => { throw new Error('任务读取失败') } })
vm.discardCurrent = { discardNo: 'CD-1' }; vm.discardTask = { palletNo: 'P-1', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }
vm.discardActuals = { sku: 1 }; vm.discardPhotos = [{ fileId: '101' }]; vm.discardPhotoReady = true
await vm.confirmDiscard(); await vm.confirmDiscard()
assert.equal(submits, 1); assert.match(vm.error.message, /任务读取失败/)
assert.equal(vm.discardNeedsRefresh, true); assert.equal(vm.discardBusy, false); assert.equal(vm.discardLoading, false)
assert.deepEqual(vm.discardPhotos, [{ fileId: '101' }])
})
test('无关联扫码显示备注,不能扫托盘或超过目标,失败刷新后保留现场箱唛再提交', async () => {
let attempts = 0
const task = { discardNo: 'CD-FIELD', palletNo: 'PALLET-FIELD', status: 'PENDING', scanMode: 'PALLET', cartonMarks: [], remark: '只废弃破损外箱', remainingPackages: 2, skus: [{ itemId: 'sku', plannedPackages: 8, actualQuantityEditable: true, allowedCartonMarks: ['FIELD-A','FIELD-B','FIELD-C','BOX-1','BOX-2',...Array.from({ length: 10 }, (_, i) => 'FIELD-'+i)] }] }
const { vm } = page({ confirmCargoDiscard: async () => { if (++attempts === 1) throw new Error('余额不足') }, listCargoDiscardTasks: async () => [] })
vm.discardTasks = [task]; vm.openDiscardOrder(task.discardNo); await vm.resolveDiscardPallet(task.palletNo)
assert.equal(vm.discardOrderRemark, '只废弃破损外箱')
await vm.scanDiscardCarton(task.palletNo); assert.equal(vm.discardActuals.sku, 0)
await vm.scanDiscardCarton('FIELD-A'); await vm.scanDiscardCarton('FIELD-B'); await vm.scanDiscardCarton('FIELD-C')
assert.equal(vm.discardActuals.sku, 2); assert.deepEqual(vm.discardUnboundScans.map(scan => scan.mark), ['FIELD-A', 'FIELD-B'])
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
await vm.confirmDiscard(); assert.equal(vm.discardNeedsRefresh, true)
await vm.resolveDiscardPallet(task.palletNo)
assert.equal(vm.discardActuals.sku, 2); assert.equal(vm.discardUnboundScans.length, 2)
assert.equal(vm.discardPhotos.length, 1); await vm.confirmDiscard(); assert.equal(attempts, 2)
})
test('已扫描现场箱唛可移除并同步回减数量,确认只提交剩余箱唛', async () => {
let payload
const task = { discardNo: 'CD-REMOVE', palletNo: 'P-REMOVE', status: 'PENDING', scanMode: 'PALLET', cartonMarks: [], remainingPackages: 4, skus: [{ itemId: 'sku', plannedPackages: 8, actualQuantityEditable: true, allowedCartonMarks: ['FIELD-A','FIELD-B','FIELD-C','BOX-1','BOX-2',...Array.from({ length: 10 }, (_, i) => 'FIELD-'+i)] }] }
const { vm } = page({ confirmCargoDiscard: async (...args) => { payload = args }, listCargoDiscardTasks: async () => [] })
vm.discardTasks = [task]; vm.openDiscardOrder(task.discardNo); await vm.resolveDiscardPallet(task.palletNo)
await vm.scanDiscardCarton('BOX-1'); await vm.scanDiscardCarton('BOX-2')
vm.removeDiscardScan('BOX-1')
assert.equal(vm.discardActuals.sku, 1)
assert.deepEqual(vm.discardUnboundScans.map(scan => scan.mark), ['BOX-2'])
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
await vm.confirmDiscard()
assert.deepEqual(payload[2], [{ itemId: 'sku', actualPackages: 1, scannedCartonMarks: ['BOX-2'] }])
})
+3 -2
View File
@@ -31,7 +31,7 @@ test('overseas workbench contains confirmed warehouse and phase-five outbound do
const [data, session, runtime] = await Promise.all([
read('../src/data/overseas-workbench.js'), read('../src/stores/session.js'), read('../src/config/runtime.js')
])
for (const label of ['入仓作业', '拆柜收货', '退货入库', '退货合箱', '出库履约', '排车拣货', '出库作业', '库内作业', '换标作业', '现场查询']) assert.match(data, new RegExp(label))
for (const label of ['入仓作业', '拆柜收货', '退货入库', '合箱', '出库履约', '拣货', '出库', '库内作业', '换标作业', '现场查询']) assert.match(data, new RegExp(label))
for (const excluded of ['集装箱拣货', '装柜取证', '海外派送', '派送单', '客户回访']) assert.doesNotMatch(data, new RegExp(excluded))
assert.match(session, /overseas-pda-auth-session/)
assert.match(runtime, /VITE_API_BASE_URL/)
@@ -146,7 +146,8 @@ test('Chinese English and Spanish locale selection is persisted from profile wit
for (const label of ['简体中文', 'English', 'Español']) assert.match(i18n, new RegExp(label))
assert.match(profile, /currentLocale, LOCALE_OPTIONS, setLocale/)
assert.match(profile, /@tap="selectLanguage"[\s\S]*切换语言/)
assert.match(profile, /uni\.showActionSheet[\s\S]*setLocale\(selected\.value\)/)
assert.match(profile, /ref="languagePopup"[\s\S]*role="radiogroup"/)
assert.match(profile, /applyLanguage\(value\)[\s\S]*setLocale\(selected\.value\)/)
assert.match(inbound, /warehouse-profile/)
})
+15
View File
@@ -22,6 +22,21 @@ test('reservation mismatch asks for the reserved pallet and retains diagnostics'
assert.equal(result.traceId, 'trace-1')
})
test('cargo and destination-pallet scan errors use concise Chinese and retain diagnostics', () => {
for (const [code, message, expected] of [
['A0409', 'Carton mark must contain a non-empty inbound code prefix', '请扫描箱码或国内DP码'],
['A0404', 'Pallet code is not part of the current FBA destination group', '托盘不属于当前目的仓,请重扫'],
['B0404', 'Pallet code is not part of the current FBA destination group', '托盘不属于当前目的仓,请重扫'],
[404, 'Pallet code is not part of the current FBA destination group', '托盘不属于当前目的仓,请重扫'],
]) {
const result = toReceivingError({ code, message, requestId: 'scan-request', traceId: 'scan-trace' })
assert.equal(result.message, expected)
assert.equal(result.code, code)
assert.equal(result.requestId, 'scan-request')
assert.equal(result.traceId, 'scan-trace')
}
})
test('first scan and receipt requests confirm server reservations without resource assignment commands', async () => {
const source = await readFile(new URL('../src/api/inbound.js', import.meta.url), 'utf8')
const calls = []
+222 -2
View File
@@ -2,7 +2,7 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import * as store from '../src/stores/receiving.js'
import { normalizeReceivingSession } from '../src/api/receiving-adapter.js'
import { normalizeReceivingSession, toReceivingError, RECEIVING_PHOTO_ROLES } from '../src/api/receiving-adapter.js'
const source = readFileSync(new URL('../src/pages/operation/operation.vue', import.meta.url), 'utf8').match(/<script>([\s\S]*?)<\/script>/)[1]
const defer = () => { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b }); return { promise, resolve, reject } }
@@ -13,7 +13,7 @@ const sessionValue = (patch = {}) => normalizeReceivingSession({ containerNo: 'C
function page(overrides = {}) {
const deps = { ...store, session: { tenantId: '100', currentUser: { id: 'operator', warehouseId: '900' } },
PortalHeader: {}, ReceivingSessionSummary: {}, ScanTargetInput: {}, requireSession: () => true,
PortalHeader: {}, ReceivingSessionSummary: {}, ScanTargetInput: {}, RECEIVING_PHOTO_ROLES, requireSession: () => true,
startScanBroadcast: () => () => {}, playErrorSound() {}, playSuccessSound() {},
receivingPhase: store.receivingPhase, toReceivingError: error => error, ...overrides }
const definition = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
@@ -25,6 +25,35 @@ function page(overrides = {}) {
return { vm, definition, deps }
}
test('camera resume retains all unsigned uploads by role and clears them across session or identity changes', async () => {
store.resetReceivingSession()
const unsigned = sessionValue({ status: 'WAIT_SIGN', photos: [], domesticLoadingPhotos: [] })
let nextFile = 10
const { vm, deps } = page({ uploadCurrentUnloadPhoto: async () => ({ fileId: String(++nextFile) }) })
await vm.applyCurrentReceiving(unsigned)
await vm.replacePhoto(0, 'camera:plate')
vm.suspendReceiving(); vm._pageActive = true
await vm.applyCurrentReceiving(structuredClone(unsigned))
await vm.replacePhoto(1, 'camera:seal')
vm.suspendReceiving(); vm._pageActive = true
await vm.applyCurrentReceiving(structuredClone(unsigned))
await vm.replacePhoto(2, 'camera:door')
assert.deepEqual(vm.photoSlots.map(photo => photo.fileId), ['11', '12', '13'])
assert.equal(vm.photosReady, true)
await vm.replacePhoto(1, 'camera:seal-again')
assert.deepEqual(vm.photoSlots.map(photo => photo.fileId), ['11', '14', '13'])
deps.session.currentUser = { ...deps.session.currentUser, id: 'other-operator' }; vm.user = deps.session.currentUser
await vm.applyCurrentReceiving(structuredClone(unsigned))
assert.deepEqual(vm.photoSlots, [null, null, null])
await vm.replacePhoto(0, 'camera:new-owner')
await vm.applyCurrentReceiving({ ...unsigned, preparedAt: '2026-11-05T10:00:00' })
assert.deepEqual(vm.photoSlots, [null, null, null])
await vm.replacePhoto(0, 'camera:new-session')
await vm.applyCurrentReceiving({ ...unsigned, preparedAt: '2026-11-05T10:00:00', status: 'SIGNED' })
assert.deepEqual(vm.photoSlots, [null, null, null])
vm.suspendReceiving()
})
test('same current readback retains three signed previews; collapsed unversioned domestic images issue no downloads', async () => {
store.resetReceivingSession(); const calls = []
const download = async path => { calls.push(path); return `local:${path}` }
@@ -37,6 +66,197 @@ test('same current readback retains three signed previews; collapsed unversioned
await vm.applyCurrentReceiving(sessionValue()); assert.equal(calls.length, 18)
})
test('editing and rejecting a pallet cannot submit using the previous successful validation', async t => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
store.receiving.resolvedCarton = { inboundCode: 'IN-A', cartonMark: 'IN-A#22-1', destinationType: 'FBA' }
store.receiving.resolvedPallet = { palletCode: 'PALLET-OLD', compatible: true, version: 1 }
let writes = 0
const { vm } = page({ toReceivingError,
resolveCurrentPallet: async () => { throw Object.assign(new Error('Pallet code is not part of the current FBA destination group'), { code: 'A0404', requestId: 'scan-request', traceId: 'scan-trace' }) },
receiveCurrentBulk: async () => { writes++ },
})
t.after(() => vm.suspendReceiving())
vm.palletInput = 'PALLET-OLD'; vm.packageCount = 3
assert.equal(vm.canSubmitReceipt, true)
vm.palletInput = 'PALLET-WRONG'
assert.equal(vm.canSubmitReceipt, false)
const toasts = []; vm.showToast = (message, type) => toasts.push({ message, type })
await vm.resolvePallet(vm.palletInput)
assert.equal(vm.resolvedPallet, null)
assert.equal(vm.packageCount, '')
assert.equal(vm.activeTarget, 'pallet')
assert.equal(vm.error.message, '托盘不属于当前目的仓,请重扫')
assert.equal(vm.error.requestId, 'scan-request')
assert.equal(vm.error.traceId, 'scan-trace')
assert.deepEqual(toasts, [{ message: '托盘不属于当前目的仓,请重扫', type: 'error' }])
await vm.submitReceipt()
assert.equal(writes, 0)
})
test('queued pallet scan follows the current step while a pending carton requires confirmation', t => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
store.receiving.resolvedCarton = { inboundCode: 'IN-A', cartonMark: 'IN-A#1', destinationType: 'OVERSEAS' }
const { vm } = page()
t.after(() => vm.suspendReceiving())
const calls = [], toasts = []
vm.resolvePallet = value => calls.push(['pallet', value])
vm.resolveCarton = value => calls.push(['carton', value])
vm.showToast = message => toasts.push(message)
vm.activeTarget = 'pallet'
vm.dispatchHardwareScan({ value: 'PAL-A', target: 'carton' })
assert.deepEqual(calls, [['pallet', 'PAL-A']])
store.receiving.resolvedPallet = { palletCode: 'PAL-A', compatible: true }
vm.palletInput = 'PAL-A'; vm.activeTarget = 'carton'
vm.dispatchHardwareScan({ value: 'IN-A#2', target: 'carton' })
assert.deepEqual(calls, [['pallet', 'PAL-A']])
assert.deepEqual(toasts, ['请先确认当前箱收货,再扫描下一箱'])
assert.equal(vm.activeInbound.cartonMark, 'IN-A#1')
vm.clearReceiptContext()
vm.dispatchHardwareScan({ value: 'IN-A#2', target: 'pallet' })
assert.deepEqual(calls, [['pallet', 'PAL-A'], ['carton', 'IN-A#2']])
})
test('queued carton remains pending until the current carton receipt is confirmed', async t => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
store.receiving.resolvedCarton = { inboundCode: 'IN-A', cartonMark: 'IN-A#1', destinationType: 'OVERSEAS' }
store.receiving.resolvedPallet = { palletCode: 'PAL-A', compatible: true }
const { vm } = page()
t.after(() => vm.suspendReceiving())
const parsed = []
vm.resolveCarton = value => parsed.push(value)
vm.palletInput = 'PAL-A'; vm.activeTarget = 'carton'
vm.scanQueue = [{ value: 'IN-A#2', target: 'carton' }]
vm.drainScanQueue()
assert.equal(vm.scanQueue.length, 1)
assert.deepEqual(parsed, [])
vm.clearReceiptContext()
await new Promise(resolve => setTimeout(resolve, 5))
assert.deepEqual(parsed, ['IN-A#2'])
assert.equal(vm.scanQueue.length, 0)
})
test('cannot finish a container while an overseas carton awaits receipt confirmation', async t => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
store.receiving.resolvedCarton = { inboundCode: 'IN-A', cartonMark: 'IN-A#1', destinationType: 'OVERSEAS' }
store.receiving.resolvedPallet = { palletCode: 'PAL-A', compatible: true }
let completions = 0
const { vm } = page({ completeCurrentReceiving: async () => {
completions++
return sessionValue({ status: 'COMPLETED_WITH_DIFFERENCE' })
} })
t.after(() => vm.suspendReceiving())
vm.applyCurrentReceiving = async () => {}
vm.palletInput = 'PAL-A'; vm.activeTarget = 'carton'
await vm.completeContainer()
assert.equal(completions, 0)
vm.clearReceiptContext()
await vm.completeContainer()
assert.equal(completions, 1)
})
test('an incompatible pallet never reports success and a corrected scan can be validated normally', async t => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
store.receiving.resolvedCarton = { inboundCode: 'IN-A', cartonMark: 'IN-A#22-1', destinationType: 'FBA' }
const calls = []; let successes = 0
const { vm } = page({ toReceivingError, playSuccessSound: () => successes++,
resolveCurrentPallet: async (palletCode, inboundCode) => {
calls.push({ palletCode, inboundCode })
return { palletCode, compatible: palletCode === 'PALLET-VALID', version: 2, currentReceivedPackages: 3 }
},
})
t.after(() => vm.suspendReceiving())
const toasts = []; vm.showToast = (message, type) => toasts.push({ message, type })
await vm.resolvePallet('PALLET-WRONG')
assert.equal(vm.resolvedPallet, null)
assert.equal(vm.canSubmitReceipt, false)
assert.equal(successes, 0)
assert.deepEqual(toasts, [{ message: '托盘不匹配,请重扫', type: 'error' }])
await vm.resolvePallet('PALLET-VALID')
assert.deepEqual(calls, [{ palletCode: 'PALLET-WRONG', inboundCode: 'IN-A' }, { palletCode: 'PALLET-VALID', inboundCode: 'IN-A' }])
assert.equal(vm.resolvedPallet.palletCode, 'PALLET-VALID')
assert.equal(vm.error, null)
assert.equal(vm.packageCount, 3)
assert.equal(vm.canSubmitReceipt, true)
assert.equal(vm.activeTarget, 'quantity')
assert.equal(successes, 1)
})
test('late pallet validation cannot replace edited input and only the corrected pallet can be received', async t => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
store.receiving.resolvedCarton = { inboundCode: 'IN-A', cartonMark: 'IN-A#22-1', destinationType: 'FBA' }
const pending = defer(), receipts = [], calls = [], toasts = []; let successes = 0
const { vm } = page({ toReceivingError, playSuccessSound: () => successes++,
resolveCurrentPallet: (palletCode, inboundCode) => {
calls.push({ palletCode, inboundCode })
return palletCode === 'PALLET-A' ? pending.promise : Promise.resolve({ palletCode, compatible: true, version: 2, currentReceivedPackages: 3 })
},
receiveCurrentBulk: async payload => { receipts.push(payload); return {} },
getCurrentReceiving: async () => sessionValue(),
})
t.after(() => vm.suspendReceiving())
vm.showToast = (message, type) => toasts.push({ message, type }); vm.applyCurrentReceiving = async () => {}
const validation = vm.resolvePallet('PALLET-A')
vm.palletInput = 'PALLET-B'
pending.resolve({ palletCode: 'PALLET-A', compatible: true, version: 1, currentReceivedPackages: 3 })
await validation
assert.equal(vm.palletInput, 'PALLET-B')
assert.equal(vm.resolvedPallet, null)
assert.equal(vm.packageCount, '')
assert.equal(vm.canSubmitReceipt, false)
assert.equal(vm.busy, false)
assert.equal(successes, 0)
assert.deepEqual(toasts, [])
await vm.submitReceipt(); assert.equal(receipts.length, 0)
await vm.resolvePallet('PALLET-B')
assert.equal(vm.canSubmitReceipt, true)
assert.equal(successes, 1)
await vm.submitReceipt()
assert.deepEqual(calls, [{ palletCode: 'PALLET-A', inboundCode: 'IN-A' }, { palletCode: 'PALLET-B', inboundCode: 'IN-A' }])
assert.deepEqual(receipts.map(({ palletCode }) => palletCode), ['PALLET-B'])
})
test('obsolete pallet failures cannot replace current input or feedback', async t => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
store.receiving.resolvedCarton = { inboundCode: 'IN-A', cartonMark: 'IN-A#22-1', destinationType: 'FBA' }
const pending = defer(), toasts = []; let failures = 0
const { vm } = page({ toReceivingError, resolveCurrentPallet: () => pending.promise, playErrorSound: () => failures++ })
t.after(() => vm.suspendReceiving()); vm.showToast = (message, type) => toasts.push({ message, type })
const validation = vm.resolvePallet('PALLET-A')
vm.palletInput = 'PALLET-B'
pending.reject(Object.assign(new Error('Pallet code is not part of the current FBA destination group'), { code: 'A0404' }))
await validation
assert.equal(vm.palletInput, 'PALLET-B'); assert.equal(vm.error, null)
assert.equal(vm.resolvedPallet, null); assert.equal(vm.canSubmitReceipt, false)
assert.equal(vm.busy, false); assert.equal(failures, 0); assert.deepEqual(toasts, [])
})
test('late pallet validation cannot cross a receiving context or page lifetime', async t => {
for (const [name, change] of [
['carton', () => { store.receiving.resolvedCarton = { inboundCode: 'IN-B', cartonMark: 'IN-B#1', destinationType: 'FBA' } }],
['container', () => store.setCurrentReceiving(sessionValue({ containerNo: 'CAB-B' }))],
['operator', ({ deps }) => { deps.session.currentUser = { id: 'another-operator', warehouseId: '900' } }],
['warehouse', ({ deps }) => { deps.session.currentUser.warehouseId = '901' }],
['hide and return', ({ vm, definition }) => { definition.onHide.call(vm); definition.onShow.call(vm) }],
]) await t.test(name, async t => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
store.receiving.resolvedCarton = { inboundCode: 'IN-A', cartonMark: 'IN-A#22-1', destinationType: 'FBA' }
const pending = defer(), toasts = []; let successes = 0
const context = page({ toReceivingError, resolveCurrentPallet: () => pending.promise, playSuccessSound: () => successes++ })
const { vm } = context
t.after(() => vm.suspendReceiving()); vm.bootstrap = async () => {}; vm.showToast = (message, type) => toasts.push({ message, type })
const validation = vm.resolvePallet('PALLET-A')
change(context)
pending.resolve({ palletCode: 'PALLET-A', compatible: true, version: 1, currentReceivedPackages: 3 })
await validation
assert.equal(vm.resolvedPallet, null); assert.equal(vm.packageCount, '')
assert.equal(vm.canSubmitReceipt, false); assert.equal(vm.busy, false)
assert.equal(successes, 0); assert.deepEqual(toasts, [])
})
})
test('signed photo changes, account/customer/cabinet changes and image failure cannot inherit stale previews', async () => {
store.resetReceivingSession(); let calls = 0
const { vm, deps } = page({ downloadCurrentUnloadPhoto: async () => `local:${++calls}`, downloadCurrentDomesticLoadingPhoto: async () => '' })
+4 -3
View File
@@ -90,10 +90,11 @@ test('domestic cabinet photos are read-only, previewable and non-blocking in sig
assert.doesNotMatch(page, /domesticLoadingPhotos[\s\S]{0,120}customer/)
})
test('fixed receiving action leaves enough scroll-safe space above the PDA bottom bar', () => {
assert.match(page, /v-if="canFinishOnsite" class="sticky-action one"/)
test('receiving finish action remains in document flow after the scan and save controls', () => {
assert.match(page, /v-if="canFinishOnsite" class="sticky-action-dock"><view class="sticky-action one"/)
assert.match(page, /allInboundsReconciled \? '完成整柜入库' : '现场收货结束(标记差异)'/)
assert.match(page, /receiving-content-with-action\{[^}]*padding-bottom:calc\(152px \+ env\(safe-area-inset-bottom,0px\)\)/)
assert.match(page, /sticky-action-dock\{position:static;max-width:none/)
assert.match(page, /receiving-content-with-action\{[^}]*padding-bottom:calc\(24px \+ env\(safe-area-inset-bottom,0px\)\)/)
assert.match(page, /primary-button,\.secondary-button,\.ghost-button\{[^}]*align-items:center;justify-content:center[^}]*line-height:normal;text-align:center/)
assert.match(page, /sticky-action button\{[^}]*align-items:center;justify-content:center;line-height:normal;text-align:center/)
})
+66 -1
View File
@@ -22,6 +22,16 @@ function state(a = '2', b = '2', c = '2') {
return value
}
test('resume card is redundant only when the same receipt task is on the current page', () => {
const activeTask = { receiptNo: 'RR-ACTIVE', palletCode: 'PLT-SHARED' }
const listed = tasks => scope.page.computed.activeTaskListed.call({ activeTask, tasks })
assert.equal(listed([{ ...activeTask }]), true)
assert.equal(listed([{ receiptNo: 'RR-OTHER', palletCode: 'PLT-SHARED' }]), false)
assert.equal(listed([]), false, 'keep resume access when paging away from the active task')
assert.equal(scope.page.computed.activeTaskListed.call({ activeTask: null, tasks: [activeTask] }), false)
assert.equal(scope.page.computed.activeTaskListed.call({ activeTask: {}, tasks: [{}] }), false)
})
test('list entry can confirm without scanning when string wire quantities reconcile', () => {
const value = state()
assert.equal(value.totals[0].quantity, 4)
@@ -40,7 +50,7 @@ test('counting still rejects mismatched totals and invalid box quantities', () =
})
function scanState() {
const value = { task: state().task, tasks: [], error: null, scanCode: '', scanResult: '', selectedMark: '', busy: false,
const value = { task: state().task, tasks: [], error: null, scanCode: '', scanResult: '', selectedMark: '', busy: false, listLoading: false, openingReceiptNo: '',
open(row) { this.task = row }, $nextTick(callback) { callback() } }
value.run = scope.page.methods.run.bind(value)
value.enterTask = scope.page.methods.enterTask.bind(value)
@@ -48,6 +58,61 @@ function scanState() {
return value
}
test('opening a task keeps list counts stable and uses only that task loading state until cancellation', async () => {
const value = scanState()
const row = { receiptNo: 'RR-SELECTED', returnNo: 'RT-SELECTED' }
value.task = null; value.tasks = [row]; value.total = 1
let release, currentReads = 0
const pending = new Promise(resolve => { release = resolve })
scope.getCurrentReturnCountTask = async () => { currentReads++; await pending; return null }
scope.beginReturnCountTask = async () => assert.fail('cancelled selection cannot begin a task')
value.fetchTasks = async () => assert.fail('opening a card cannot refresh the list')
value.ask = async () => ({ confirm: false })
const opening = scope.page.methods.choose.call(value, row)
assert.equal(value.busy, true)
assert.equal(value.listLoading, false)
assert.equal(value.openingReceiptNo, row.receiptNo)
assert.equal(value.total, 1)
assert.equal(value.tasks[0], row)
await scope.page.methods.choose.call(value, { receiptNo: 'RR-SECOND' })
assert.equal(currentReads, 1)
release(); await opening
assert.equal(value.busy, false)
assert.equal(value.openingReceiptNo, '')
assert.equal(value.task, null)
assert.equal(value.tasks[0], row)
})
test('failed task opening releases its indicator and retains list and diagnostic evidence', async () => {
const value = scanState(), row = { receiptNo: 'RR-SELECTED' }
value.task = null; value.tasks = [row]
scope.getCurrentReturnCountTask = async () => { throw Object.assign(new Error('读取任务失败'), { code: 'A0409', requestId: 'REQ', traceId: 'TRACE' }) }
await scope.page.methods.choose.call(value, row)
assert.equal(value.busy, false)
assert.equal(value.listLoading, false)
assert.equal(value.openingReceiptNo, '')
assert.equal(value.tasks[0], row)
assert.equal(value.error.code, 'A0409')
assert.equal(value.error.requestId, 'REQ')
assert.equal(value.error.traceId, 'TRACE')
})
test('explicit list loading owns the refresh indicator and always releases it', async () => {
for (const fails of [false, true]) {
const value = scanState()
let release
const pending = new Promise(resolve => { release = resolve })
value.fetchTasks = async () => { await pending; if (fails) throw new Error('加载失败') }
const loading = scope.page.methods.load.call(value)
assert.equal(value.listLoading, true)
assert.equal(value.openingReceiptNo, '')
release(); await loading
assert.equal(value.busy, false)
assert.equal(value.listLoading, false)
assert.equal(Boolean(value.error), fails)
}
})
test('box and single-batch pallet scans claim and open counting directly without start dialog', async () => {
for (const mark of ['RC#2-1', 'PLT-1']) {
const value = scanState()
+33 -13
View File
@@ -5,6 +5,15 @@ import vm from 'node:vm'
const physicalRow = items => ({ items, length: 10, width: 10, height: 10, weight: 1, originalPackaging: true, confirmed: true })
test('physical volume uses cubic metres while receipt dimensions remain centimetres', () => {
const page = setup(async () => ({}))
assert.equal(page.rowVolume({ length: 30, width: 20, height: 20 }), '0.012000')
assert.equal(page.rowVolume({ length: 1, width: 1, height: 1 }), '0.000001')
assert.equal(page.rowVolume({ length: '', width: 20, height: 20 }), '—')
page.palletLength = 120; page.palletWidth = 100; page.palletHeight = 150
assert.equal(page.palletVolume, '1.800000')
})
test('box and parcel confirmation opens the camera only after every valid row is confirmed', () => {
for (const type of ['BOX', 'PARCEL']) {
const cameras = []
@@ -58,7 +67,7 @@ test('final photo upload waits for explicit confirmation and failed uploads cann
assert.equal(requests.length, 0)
await page.confirmReceipt()
assert.equal(requests.length, failUpload ? 0 : 1)
if (failUpload) assert.match(page.error.message, /照片/)
if (failUpload) assert.equal(page.error.message, 'upload failed')
}
})
@@ -87,13 +96,23 @@ test('parcel photos accumulate without submitting until explicit receipt confirm
assert.equal(page.current, null)
})
test('parcel signing requires a photo even for a partial receipt', async () => {
let requests = 0
const page = setup(async () => { requests++; return {} })
page.unitType = 'PARCEL'; page.boxRows = [physicalRow({ 'SKU-TEST': 2 })]
await page.confirmReceipt()
assert.equal(requests, 0)
assert.match(page.error.message, /照片/)
test('missing receipt photos show a reminder without submitting or adding a page error', async () => {
for (const type of ['BOX', 'PARCEL', 'PALLET']) {
let requests = 0
const modals = []
const page = setup(async () => { requests++; return {} }, new Map(), 'operator:warehouse', {
uni: { showModal: options => modals.push(options) }
})
page.unitType = type; page.boxRows = [physicalRow({ 'SKU-TEST': 10 })]
page.quantity = () => 10; page.remaining = () => 10
await page.confirmReceipt()
assert.equal(requests, 0)
assert.equal(modals.length, 1)
assert.match(modals[0].content, /照片/)
assert.equal(modals[0].showCancel, false)
assert.equal(page.error, undefined)
assert.ok(page.current)
}
})
test('editing the draft before the camera returns prevents late photo submission', async () => {
@@ -111,10 +130,10 @@ test('editing the draft before the camera returns prevents late photo submission
})
function setup(confirm, saved = new Map(), scope = 'operator:warehouse', runtime = {}) {
const context = vm.createContext({ reactive: value => value, ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {},
const context = vm.createContext({ reactive: value => value, ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {}, ExceptionReturnWork: {},
playSuccessSound() {}, playErrorSound() {}, confirmReturnReceipt: confirm,
...runtime,
uni: { setStorageSync: (key, value) => saved.set(key, structuredClone(value)), getStorageSync: key => saved.get(key), removeStorageSync: key => saved.delete(key), ...runtime.uni } })
uni: { showModal() {}, setStorageSync: (key, value) => saved.set(key, structuredClone(value)), getStorageSync: key => saved.get(key), removeStorageSync: key => saved.delete(key), ...runtime.uni } })
const store = readFileSync(new URL('../src/stores/warehouse-work.js', import.meta.url), 'utf8')
vm.runInContext(store.replace(/^import .*$/mg, '').replace(/export /g, ''), context)
const page = readFileSync(new URL('../src/pages/return-receipt/return-receipt.vue', import.meta.url), 'utf8')
@@ -129,13 +148,14 @@ function setup(confirm, saved = new Map(), scope = 'operator:warehouse', runtime
quantity: () => 2, boxCount: () => 1
})
Object.defineProperty(instance, 'allBoxRowsConfirmed', { get: () => component.computed.allBoxRowsConfirmed.call(instance) })
Object.defineProperty(instance, 'palletVolume', { get: () => component.computed.palletVolume.call(instance) })
return instance
}
test('SKU lookup displays all pending order lines while retaining scanned SKU for pallet generation', () => {
const source = readFileSync(new URL('../src/pages/return-receipt/return-receipt.vue', import.meta.url), 'utf8')
const script = source.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1]
const component = vm.runInNewContext(script.replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), { ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {} })
const component = vm.runInNewContext(script.replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), { ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {}, ExceptionReturnWork: {} })
const lines = [{ skuCode: 'SKU-A', expectedQuantity: 6 }, { skuCode: 'SKU-B', expectedQuantity: 4 }]
const instance = { current: { expectedItems: lines }, selectedSku: 'SKU-A' }
instance.selectedItems = component.computed.selectedItems.call(instance)
@@ -168,7 +188,7 @@ test('PALLET page submits the required palletCode through the actual confirmatio
test('parcel signing allows an empty code but still rejects quantities above the remaining amount', async () => {
const source = readFileSync(new URL('../src/pages/return-receipt/return-receipt.vue', import.meta.url), 'utf8')
const component = vm.runInNewContext(source.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), { ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {} })
const component = vm.runInNewContext(source.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), { ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {}, ExceptionReturnWork: {} })
const state = { current: {}, selectedItems: [{ skuCode: 'A' }], receiptQuantity: 2, unitType: 'PARCEL', boxRows: [physicalRow({ A: 2 })], remaining: () => 3 }
assert.equal(component.computed.canConfirm.call(state), true)
state.boxRows[0].items.A = 4
@@ -205,7 +225,7 @@ test('batch waybill scans stay on the single parcel waybill input', () => {
test('parcel rows must be nonempty and summed quantities cannot exceed the order remainder', () => {
const source = readFileSync(new URL('../src/pages/return-receipt/return-receipt.vue', import.meta.url), 'utf8')
const component = vm.runInNewContext(source.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), { ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {} })
const component = vm.runInNewContext(source.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), { ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {}, ExceptionReturnWork: {} })
const state = { current: {}, selectedItems: [{ skuCode: 'A' }], receiptQuantity: 4, unitType: 'PARCEL', boxRows: [physicalRow({ A: 2 }), physicalRow({ A: 2 })], remaining: () => 3 }
assert.equal(component.computed.canConfirm.call(state), false)
state.remaining = () => 5
+4 -3
View File
@@ -30,7 +30,7 @@ test('warehouse transfer page loads detail without exposing internal identifiers
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
assert.match(page, /getWarehouseTransferDetail\(item\.transferNo\)/)
assert.match(page, /待测转运单/)
assert.match(page, /仓储转运测量列表/)
assert.match(page, /仓储转运测量/)
assert.match(page, /转运测量作业/)
assert.match(page, /!selected && entryMode === 'list'/)
assert.match(page, /entryMode === 'work' && taskNo/)
@@ -62,9 +62,10 @@ test('warehouse transfer shows forecast and actual variance and requires counted
assert.doesNotMatch(page, /本托 SKU 箱数|itemCounts/)
})
test('warehouse transfer retries reuse the pending idempotency key', async () => {
test('warehouse transfer creates a fresh completion key for each explicit attempt', async () => {
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
assert.match(page, /if \(!this\.pendingKey\) this\.pendingKey = `pda-transfer-/)
assert.doesNotMatch(page, /if \(!this\.pendingKey\) this\.pendingKey = `pda-transfer-/)
assert.match(page, /this\.pendingKey = `pda-transfer-/)
assert.match(page, /idempotencyKey: this\.pendingKey/)
assert.equal((page.match(/pendingKey = `pda-transfer-/g) || []).length, 1)
})
+10 -3
View File
@@ -107,12 +107,13 @@ test('transfer counts require valid pieces and measurements and record variance
assert.equal(value.cartonCountReady,true);value.countDiscrepancyReason='多一箱';assert.equal(value.cartonCountReady,true)
assert.equal(value.cartonQuantity,9);assert.equal(value.cartonVolume,'0.012')
})
test('network retry preserves request key and changed content uses a new request',async()=>{
test('failed count unlocks the draft and each explicit retry sends current contents with a fresh key',async()=>{
const {value,scope}=setup();const requests=[]
scope.countWarehouseTransferCartons=async(no,request)=>{requests.push(request);throw new Error('offline')}
await value.confirmCartons();await value.confirmCartons()
assert.equal(requests.length,2);assert.equal(requests[0].idempotencyKey,requests[1].idempotencyKey)
assert.equal(requests.length,2);assert.notEqual(requests[0].idempotencyKey,requests[1].idempotencyKey)
value.cartonBoxes[0].items[0].quantity=8;await value.confirmCartons();assert.notEqual(requests[1].idempotencyKey,requests[2].idempotencyKey)
assert.equal(requests[2].boxes[0].items[0].quantity,8);assert.equal(value.cartonBoxes[0].items[0].quantity,8)
assert.equal(value.countingCartons,false);assert.equal(value.error.message,'offline')
})
test('draft restores additional cartons and discrepancy reason without crossing tasks',()=>{
@@ -221,7 +222,7 @@ for (const answer of [{confirm:false}, {confirm:true,content:' '}, {confirm:tru
await value.submit()
assert.equal(prompts,1);assert.equal(value.submitting,false)
assert.equal(requests.length,answer.confirm && answer.content?.trim()?1:0)
if(requests.length){assert.equal(requests[0].discrepancyReason,'少到一箱');await value.submit();assert.equal(requests[0].idempotencyKey,requests[1].idempotencyKey)}
if(requests.length){assert.equal(requests[0].discrepancyReason,'少到一箱');await value.submit();assert.notEqual(requests[0].idempotencyKey,requests[1].idempotencyKey)}
})
}
test('matching SKU counts with different carton count submit without prompting, and missing photos block before prompting',async()=>{
@@ -299,3 +300,9 @@ test('新增预报箱无SKU绑定时恢复为可编辑箱,保留已有草稿',
assert.ok(added)
assert.ok(value.declaredSkuCodes.includes(added.items[0].skuCode))
})
test('warehouse transfer errors show guidance without diagnostic identifiers', () => {
const { value: page } = setup()
assert.equal(page.simpleErrorMessage({message:'该单为仓库上门取货,请先完成车辆取货送仓\nrequestId: request-1'}), '该单为仓库上门取货,请先完成车辆取货送仓')
assert.equal(page.simpleErrorMessage({message:'操作失败(错误码:S0503;requestId:request-1;traceId:trace-1)'}), '操作失败')
})
+11 -11
View File
@@ -7,12 +7,12 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
const enabled = operations.filter((operation) => operation.available)
assert.deepEqual(enabled.map(({ module, name }) => ({ module, name })), [
{ module: 'inbound', name: '拆柜收货' },
{ module: 'inbound', name: '仓储转运测量列表' }, { module: 'inbound', name: '转运测量作业' },
{ module: 'inbound', name: '仓储转运测量' }, { module: 'inbound', name: '转运测量' },
{ module: 'inbound', name: '退货入库' },
{ module: 'inbound', name: '退货合箱' },
{ module: 'inbound', name: '退货箱内清点' },
{ module: 'outbound', name: '排车拣货' },
{ module: 'outbound', name: '出库作业' },
{ module: 'inbound', name: '合箱' },
{ module: 'inbound', name: '退货清点' },
{ module: 'outbound', name: '拣货' },
{ module: 'outbound', name: '出库' },
{ module: 'inventory', name: '商品移库' },
{ module: 'inventory', name: '托盘管理' },
{ module: 'relabel', name: '换标列表' },
@@ -23,13 +23,13 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
{ module: 'query', name: '托盘查询' }
])
assert.equal(operations.find((item) => item.name === '托盘管理')?.route, '/pages/pallet/pallet')
assert.equal(operations.find((item) => item.name === '排车拣货')?.route, '/pages/dispatch/dispatch')
assert.equal(operations.find((item) => item.name === '出库作业')?.route, '/pages/outbound/outbound')
assert.equal(operations.find((item) => item.name === '拣货')?.route, '/pages/dispatch/dispatch')
assert.equal(operations.find((item) => item.name === '出库')?.route, '/pages/outbound/outbound')
assert.equal(operations.find((item) => item.name === '退货入库')?.route, '/pages/return-receipt/return-receipt')
assert.equal(operations.find((item) => item.name === '退货合箱')?.route, '/pages/return-packing/return-packing')
assert.equal(operations.find((item) => item.name === '退货箱内清点')?.route, '/pages/return-counting/return-counting')
assert.equal(operations.find((item) => item.name === '仓储转运测量列表')?.route, '/pages/warehouse-transfer/warehouse-transfer?mode=list')
assert.equal(operations.find((item) => item.name === '转运测量作业')?.route, '/pages/warehouse-transfer/warehouse-transfer?mode=work')
assert.equal(operations.find((item) => item.name === '合箱')?.route, '/pages/return-packing/return-packing')
assert.equal(operations.find((item) => item.name === '退货清点')?.route, '/pages/return-counting/return-counting')
assert.equal(operations.find((item) => item.name === '仓储转运测量')?.route, '/pages/warehouse-transfer/warehouse-transfer?mode=list')
assert.equal(operations.find((item) => item.name === '转运测量')?.route, '/pages/warehouse-transfer/warehouse-transfer?mode=work')
assert.equal(operations.some((item) => item.name === '库存盘点'), false)
+122
View File
@@ -0,0 +1,122 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { parse, compileScript, compileTemplate, compileStyle } from 'vue/compiler-sfc'
import { assertReceivingWarehouse } from '../src/api/receiving-adapter.js'
import { MODULES } from '../src/data/overseas-workbench.js'
const apiSource = readFileSync(new URL('../src/api/workbench.js', import.meta.url), 'utf8')
const homeSource = readFileSync(new URL('../src/pages/home/home.vue', import.meta.url), 'utf8')
const profile = { id: 'OP', warehouseId: 'WH', warehouseCode: 'WH-CODE', warehouseName: '测试仓' }
const emptySources = () => ({
assertReceivingWarehouse,
listReceivingCandidates: async () => [], listWarehouseTransferCandidates: async () => [],
listReturnCountTasks: async () => ({ records: [], total: 0 }), listPackingPlans: async () => [],
listDispatchTasks: async () => [], listOutboundTasks: async () => [], listCargoDiscardTasks: async () => [],
listRelabelCandidates: async () => []
})
function api(overrides = {}) {
const dependencies = { ...emptySources(), ...overrides }
return new Function(...Object.keys(dependencies), apiSource.replace(/^import .*$/gm, '').replace(/^export /gm, '') + '\nreturn { loadWorkbenchCounts, workbenchBadge, WORKBENCH_COUNT_KEYS }')(...Object.values(dependencies))
}
function home(loadWorkbenchCounts) {
const session = { currentUser: { ...profile }, apiBase: 'LOCAL' }
const scopes = []
const dependencies = { PortalHeader: {}, MODULES, session, requireSession: () => true, applyWarehouseProfile: value => value, getCurrentWarehouseProfile: async () => session.currentUser, loadWorkbenchCounts, workbenchBadge: api().workbenchBadge, restoreWarehouseWork: scope => scopes.push(scope), warehouseWork: { warehouseTransfer: { currentNo: 'T2' } } }
const page = new Function(...Object.keys(dependencies), homeSource.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(dependencies))
const value = page.data()
for (const [key, method] of Object.entries(page.methods)) value[key] = method.bind(value)
return { page, value, session, scopes }
}
test('workbench counts separate queues, current work and paginated totals without duplicate business records', async () => {
const container = { sourceContainerBookingId: 'B1', containerNo: 'C1', overseasWarehouseCode: profile.warehouseCode, overseasWarehouseName: profile.warehouseName }
const source = api({
listReceivingCandidates: async () => [container, container, { ...container, sourceContainerBookingId: 'B2', containerNo: 'C2', operable: false }],
listWarehouseTransferCandidates: async () => [{ transferNo: 'T1', status: 'WAIT_PDA_RECEIVING' }, { transferNo: 'T2', status: 'PARTIALLY_RECEIVED' }, { transferNo: 'T2', status: 'PARTIALLY_RECEIVED' }, { transferNo: 'T3', status: 'COMPLETED' }],
listReturnCountTasks: async options => { assert.deepEqual(options, { page: 1, size: 1 }); return { total: 37, records: [{ receiptNo: 'RR1' }] } },
listPackingPlans: async () => [{ planNo: 'P1', status: 'READY' }, { planNo: 'P1', status: 'READY' }, { planNo: 'P2', status: 'COMPLETED' }],
listDispatchTasks: async () => [{ dispatchNo: 'D1', stage: 'LOADING' }, { dispatchNo: 'D1', stage: 'LOADING' }, { dispatchNo: 'D2', stage: 'BEFORE_LOADING' }, { dispatchNo: 'D3', stage: 'DELIVERING' }, { dispatchNo: 'D4', stage: 'DELIVERED' }, { dispatchNo: 'D5', stage: 'LOADING', status: 'CANCELLED' }],
listOutboundTasks: async status => { assert.equal(status, 'PENDING'); return [{ taskNo: 'O1' }, { taskNo: 'O1' }] },
listCargoDiscardTasks: async () => [{ discardNo: 'CD1', status: 'PENDING' }, { discardNo: 'CD1', status: 'PENDING' }, { discardNo: 'CD2', status: 'COMPLETED' }],
listRelabelCandidates: async () => [{ relabelNo: 'R1', taskStatus: 'AVAILABLE' }, { relabelNo: 'R1', taskStatus: 'AVAILABLE' }, { relabelNo: 'R2', taskStatus: 'PROCESSING', claimedByCurrentOperator: true }, { relabelNo: 'R3', taskStatus: 'PROCESSING', claimedByCurrentOperator: false }, { relabelNo: 'R4', taskStatus: 'WAIT_REVIEW' }, { relabelNo: 'R5', taskStatus: 'COMPLETED' }]
})
const counts = await source.loadWorkbenchCounts({ profile, activeTransferNo: 'T2' })
assert.deepEqual(Object.fromEntries(Object.entries(counts).map(([key, value]) => [key, value.count])), { receiving: 2, warehouseTransferList: 2, warehouseTransferWork: 1, returnCounting: 37, returnPacking: 1, dispatchPicking: 2, outboundPicking: 1, relabelReceive: 1, relabelExecute: 1, relabelReview: 1 })
assert.equal(counts.outboundPicking.discardCount, 1)
assert.equal(source.workbenchBadge('outboundPicking', counts.outboundPicking).label, '1 出库 · 1 废弃')
})
test('a failed source stays unknown while other queue counts remain available', async () => {
const error = Object.assign(new Error('网络异常'), { requestId: 'REQ-COUNT', traceId: 'TRACE-COUNT' })
const source = api({ listWarehouseTransferCandidates: async () => { throw error } })
const counts = await source.loadWorkbenchCounts({ profile })
assert.equal(counts.warehouseTransferList.status, 'error')
assert.equal(counts.warehouseTransferWork.error, error)
assert.equal(counts.warehouseTransferList.count, undefined)
assert.equal(counts.receiving.count, 0)
assert.equal(source.workbenchBadge('warehouseTransferList', counts.warehouseTransferList).label, '获取失败')
assert.equal(source.workbenchBadge('receiving', counts.receiving).label, '0 柜')
})
test('foreign receiving warehouses and absent pagination totals cannot produce a success count', async () => {
const source = api({ listReceivingCandidates: async () => [{ overseasWarehouseCode: 'OTHER', overseasWarehouseName: '其他仓' }], listReturnCountTasks: async () => ({ records: [] }) })
const counts = await source.loadWorkbenchCounts({ profile })
assert.equal(counts.receiving.status, 'error')
assert.equal(counts.receiving.error.code, 'RECEIVING_WAREHOUSE_MISMATCH')
assert.equal(counts.returnCounting.status, 'error')
assert.equal(counts.returnCounting.count, undefined)
})
test('a completed or missing local transfer does not count as current measurement work', async () => {
const source = api({ listWarehouseTransferCandidates: async () => [{ transferNo: 'DONE', status: 'COMPLETED' }, { transferNo: 'OTHER', status: 'WAIT_PDA_RECEIVING' }] })
const counts = await source.loadWorkbenchCounts({ profile, activeTransferNo: 'DONE' })
assert.equal(counts.warehouseTransferWork.count, 0)
assert.equal(counts.warehouseTransferList.count, 1)
assert.equal(source.workbenchBadge('warehouseTransferWork', counts.warehouseTransferWork).label, '未开始')
})
test('scan tools and queries do not pretend to have task counts', () => {
const { workbenchBadge } = api()
assert.equal(workbenchBadge('returnReceipt').label, '扫码签收')
assert.equal(workbenchBadge('move').label, '扫码作业')
assert.equal(workbenchBadge('inventoryQuery').label, '扫码查询')
assert.equal(workbenchBadge('receiving').label, '加载中…')
})
test('returning home refreshes counts and ignores a response from a hidden page', async () => {
let resolveFirst, calls = 0
const { page, value, scopes } = home(async ({ activeTransferNo }) => { assert.equal(activeTransferNo, 'T2'); calls++; return calls === 1 ? new Promise(resolve => { resolveFirst = resolve }) : { receiving: { status: 'ready', count: 4, unit: '柜' } } })
const first = page.onShow.call(value)
assert.equal(value.countsLoading, true)
page.onHide.call(value)
resolveFirst({ receiving: { status: 'ready', count: 99, unit: '柜' } })
await first
assert.deepEqual(value.counts, {})
await page.onShow.call(value)
assert.equal(value.counts.receiving.count, 4)
assert.equal(calls, 2)
assert.deepEqual(scopes, ['OP:WH', 'OP:WH'])
})
test('warehouse or account changes invalidate pending home counts', async () => {
let resolve
const { page, value, session } = home(() => new Promise(done => { resolve = done }))
const pending = page.onShow.call(value)
session.currentUser = { ...profile, id: 'OTHER-OP', warehouseId: 'OTHER-WH' }
resolve({ receiving: { status: 'ready', count: 99, unit: '柜' } })
await pending
assert.deepEqual(value.counts, {})
assert.equal(value.countsLoading, false)
assert.equal(value.user.id, 'OTHER-OP')
})
test('home compiles with per-entry badges and no function-count summary', () => {
const { descriptor, errors } = parse(homeSource, { filename: 'home.vue' })
assert.deepEqual(errors, [])
compileScript(descriptor, { id: 'home-counts' })
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename: 'home.vue', id: 'home-counts' }).errors, [])
for (const style of descriptor.styles) assert.deepEqual(compileStyle({ source: style.content, filename: 'home.vue', id: 'data-v-home-counts', scoped: style.scoped }).errors, [])
assert.doesNotMatch(descriptor.template.content, /availableCount|项可处理|group\.operations\.length/)
assert.match(descriptor.template.content, /countBadge\(operation\)\.label/)
})
+5 -1
View File
@@ -1,5 +1,8 @@
import { defineConfig, loadEnv } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
import { fileURLToPath } from 'node:url'
const projectDir = fileURLToPath(new URL('.', import.meta.url))
function loggedProxy(name, options) {
return {
@@ -24,9 +27,10 @@ function loggedProxy(name, options) {
}
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const env = loadEnv(mode, projectDir, '')
const apiTarget = env.API_PROXY_TARGET || 'http://127.0.0.1:9000'
return {
envDir: projectDir,
base: process.env.UNI_PLATFORM === 'h5' && mode === 'production' ? '/' : undefined,
plugins: [uni()],
define: {