92 Commits
Author SHA1 Message Date
ouyangxq b96b9a7e02 Merge latest release/test into dev [skip ci] 2026-10-14 17:26:39 +08:00
liuyihe 627dfd32da 优化 2026-10-14 13:53:24 +08:00
liuyihe fec7295a04 优化 2026-10-14 12:14:46 +08:00
liuyihe 016ccd5dd3 优化 2026-10-14 11:43:44 +08:00
liuyihe 5c0000472d 优化 2026-10-13 14:47:16 +08:00
liuyihe dee7ee5eef 优化 2026-10-13 13:47:10 +08:00
liuyihe 92a86d8566 Merge branch 'release/test' of http://8.163.30.128:10082/html/pda-overseas-uniapp into release/test 2026-10-12 18:01:58 +08:00
liuyihe 298681e121 优化 2026-10-12 18:01:55 +08:00
ouyangxq 5c4c1c56e6 优化 2026-10-12 16:10:32 +08:00
liuyihe 785b4bca51 优化 2026-10-12 15:56:16 +08:00
liuyihe aebe4f9528 优化 2026-10-12 13:43:37 +08:00
ouyangxq 9c3855cec2 Merge remote-tracking branch 'origin/release/test' into release/test 2026-10-11 21:49:19 +08:00
ouyangxq 4f56d97f5e fix(pda): keep pallet label actions readable on handheld screens 2026-10-11 21:48:44 +08:00
liuyihe e6689f0d0d 优化 2026-10-10 13:47:56 +08:00
liuyihe c4d43542f4 优化 2026-10-09 20:39:46 +08:00
ouyangxq cc0fae9765 Merge branch 'release/test' of http://git.buggoglobal.com/html/pda-overseas-uniapp into release/test 2026-10-09 17:36:41 +08:00
ouyangxq e251a1c570 优化 2026-10-09 17:36:40 +08:00
liuyihe b014624fa3 优化 2026-10-09 17:28:03 +08:00
ouyangxq 95078f750e fix(pda): prevent stale and ambiguous dispatch scan selections 2026-10-09 15:54:21 +08:00
liuyihe 2d68431199 优化 2026-10-09 15:25:36 +08:00
liuyihe 648b0c858d 优化 2026-10-09 11:49:03 +08:00
ouyangxq 962978ad6a fix(pda): restore whole-trip departure and handover after merge 2026-10-08 18:08:42 +08:00
liuyihe a8992266ed merge overseas PDA frontend changes 2026-10-08 15:45:25 +08:00
liuyihe c20d5c53e1 优化 2026-10-08 15:34:47 +08:00
ouyangxq 5bebd23e32 Merge remote-tracking branch 'origin/release/test' into dev 2026-10-08 10:37:06 +08:00
ouyangxq 71ffe1808e fix(deploy): support isolated temporary production release 2026-10-07 11:09:42 +08:00
ouyangxq b247b0cbb4 fix(auth): revoke overseas PDA session on user logout 2026-10-04 21:28:53 +08:00
ouyangxq 12de982dac feat(dispatch): show original and remaining handover quantities 2026-10-03 13:28:23 +08:00
ouyangxq a1a8bb4b8b feat(pda): display warehouse-scoped fleet handover status 2026-10-03 12:44:19 +08:00
ouyangxq 3990074352 fix(pda): leave expired sessions and clarify dispatch navigation 2026-10-03 06:49:54 +08:00
ouyangxq 28a046c37f feat(dispatch): validate reviewed whole-trip departures 2026-10-03 04:35:58 +08:00
ouyangxq f5f64e22e9 fix(pda): submit null position for return carton picks 2026-10-02 17:06:08 +08:00
ouyangxq cd9bc815a5 fix(pda): guard outbound and discard operations 2026-10-02 11:46:59 +08:00
ouyangxq c77fdb775c fix(pda): preserve scoped receipt photos and stop hidden scan queues 2026-10-02 11:12:56 +08:00
liuyihe 23572c125f Merge branch 'release/test' of http://8.163.30.128:10082/html/pda-overseas-uniapp into release/test 2026-09-30 14:53:49 +08:00
liuyihe c395f33a2b 优化 2026-09-30 14:53:47 +08:00
ouyangxq 244208c690 优化 2026-09-30 14:27:37 +08:00
ouyangxq 8398d5ead9 fix(pda): show container-wide pending count after receipt 2026-09-30 13:00:10 +08:00
ouyangxq 33c395937a fix(pda): align return workflows with current test backend 2026-09-29 23:25:00 +08:00
ouyangxq fddaaf71fb fix(pda): resume partial box receipts from durable batch requests 2026-09-29 22:46:16 +08:00
ouyangxq 19268b38d1 fix(pda): preserve packing plan retry keys and surface missing API 2026-09-29 22:41:05 +08:00
ouyangxq 4e14721cb7 fix(pda): submit required pallet code from return receipt page 2026-09-29 21:25:34 +08:00
ouyangxq 900449f1b0 Merge remote-tracking branch 'origin/release/test' into release/test 2026-09-29 20:58:10 +08:00
ouyangxq 4a36da292e 提供独立路径的海外PDA测试H5部署 2026-09-29 20:57:40 +08:00
liuyihe 021e5b9e00 Merge branch 'release/test' of http://8.163.30.128:10082/html/pda-overseas-uniapp into release/test 2026-09-29 20:52:46 +08:00
liuyihe e379e5b586 优化 2026-09-29 20:52:44 +08:00
ouyangxq b118189e4e 优化 2026-09-29 18:47:52 +08:00
ouyangxq 0aaf855ce2 优化 2026-09-29 17:56:05 +08:00
liuyihe ef5c8250c5 Merge branch 'release/test' of http://8.163.30.128:10082/html/pda-overseas-uniapp into release/test 2026-09-28 17:26:52 +08:00
liuyihe a646ade658 优化提交 2026-09-28 17:26:50 +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
ouyangxq 6365f09957 fix(overseas-pda): preserve relabel source labels 2026-09-28 15:04:54 +08:00
liuyihe dd6ffd0ef6 优化 2026-09-24 14:43:09 +08:00
liuyihe 5b3d8b00d5 优化 2026-09-24 14:20:26 +08:00
liuyihe c4b1d281dc 优化 2026-09-24 12:06:44 +08:00
liuyihe 257e58c024 优化 2026-09-24 11:38:51 +08:00
liuyihe 5d7d29625a Merge remote-tracking branch 'origin/release/test' into dev 2026-09-24 09:04:08 +08:00
liuyihe 822c06e250 Merge branch 'dev' of http://8.163.30.128:10082/html/pda-overseas-uniapp into dev 2026-09-24 08:55:40 +08:00
liuyihe bc9fa0faa3 优化 2026-09-24 08:55:38 +08:00
ouyangxq 77028009bb 优化 2026-09-23 18:26:13 +08:00
ouyangxq 1f0352d48d 优化 2026-09-23 17:47:57 +08:00
ouyangxq 83300f7fc2 Merge branch 'dev' of http://git.buggoglobal.com/html/pda-overseas-uniapp into dev 2026-09-23 17:46:17 +08:00
ouyangxq 083cce1476 优化 2026-09-23 17:46:07 +08:00
liuyihe 50dbbcaa6e 优化 2026-09-23 11:25:47 +08:00
ouyangxq 7146cf80dc Merge remote-tracking branch 'origin/release/test' into release/test 2026-09-23 10:28:39 +08:00
liuyihe f6659af42b 提交 2026-09-23 09:06:31 +08:00
liuyihe a4f335f960 提交 2026-09-22 17:27:56 +08:00
ouyangxq 9731a97cdd feat: show reserved overseas pallet location in receiving 2026-09-22 09:39:02 +08:00
liuyihe 7d0cec9b17 Merge branch 'release/test' of http://8.163.30.128:10082/html/pda-overseas-uniapp into release/test 2026-09-22 08:47:54 +08:00
liuyihe 67dd844dc2 修改提交 2026-09-22 08:47:18 +08:00
ouyangxq 2dd488196e Merge remote-tracking branch 'origin/release/test' into dev 2026-09-21 14:30:02 +08:00
ouyangxq e4f02c5e73 优化 2026-09-21 11:17:17 +08:00
ouyangxq cec0027376 优化 2026-09-20 23:06:27 +08:00
admin 32b95788f6 Merge pull request '游湖' (#3) from release/test into dev
Reviewed-on: #3
2026-09-20 22:23:54 +08:00
liuyihe be8420e173 游湖 2026-09-20 21:50:14 +08:00
liuyihe c24f8739e8 merge release test changes and preserve local frontend updates 2026-09-20 15:24:04 +08:00
liuyihe ca05994986 Merge branch 'release/test' into dev 2026-09-20 15:19:10 +08:00
ouyangxq 375701fcba 优化 2026-09-20 15:03:34 +08:00
ouyangxq 4dc8899086 优化 2026-09-20 14:43:33 +08:00
ouyangxq 3ba8809222 优化 2026-09-20 14:22:17 +08:00
admin 5d54e18144 Merge pull request 'Release/test' (#2) from release/test into dev
Reviewed-on: #2
2026-09-18 18:02:56 +08:00
ouyangxq 69521095e8 优化 2026-09-18 16:55:27 +08:00
ouyangxq 5722e0d7c9 Merge branch 'release/test' of http://git.buggoglobal.com/html/pda-overseas-uniapp into release/test 2026-09-18 16:48:46 +08:00
ouyangxq 190aa37b00 优化 2026-09-18 16:48:44 +08:00
liuyihe 728f04ea00 优化 2026-09-17 16:34:56 +08:00
liuyihe e47e88efb0 Merge branch 'release/test' of http://8.163.30.128:10082/html/pda-overseas-uniapp into release/test 2026-09-17 13:36:11 +08:00
liuyihe 86b51760a3 优化 2026-09-17 13:36:08 +08:00
admin 1acb4925d1 Merge pull request 'Release/test' (#1) from release/test into dev
Reviewed-on: #1
2026-09-17 11:53:38 +08:00
ouyangxq 2dabdfde7c 支持PDA整托拣货 2026-09-15 21:05:09 +08:00
ouyangxq c6978c0983 优化 2026-09-15 20:51:09 +08:00
liuyihe bcad98d5e3 Merge branch 'release/test' of http://8.163.30.128:10082/html/pda-overseas-uniapp into release/test 2026-09-15 15:20:45 +08:00
liuyihe bf760f1abd 优化 2026-09-15 15:20:43 +08:00
92 changed files with 5356 additions and 433 deletions
+12
View File
@@ -0,0 +1,12 @@
# Only compiled H5 assets and image configuration enter the build context.
**
!deploy/
!deploy/Dockerfile.h5
!deploy/nginx.conf
!dist/
!dist/build/
!dist/build/h5/
!dist/build/h5/**
!deploy/Dockerfile.h5.production
!deploy/nginx.production.conf.template
+2 -5
View File
@@ -1,11 +1,8 @@
VITE_AUTH_API_BASE_URL=http://127.0.0.1:9000/api
VITE_OVERSEAS_API_BASE_URL=http://127.0.0.1:9300/api
VITE_API_BASE_URL=http://127.0.0.1:9000/api
# H5 同源前缀,由 Vite 代理转发;App 构建使用上面的绝对域名直连。
VITE_H5_AUTH_API_BASE_URL=/api
VITE_H5_OVERSEAS_API_BASE_URL=/overseas-api
VITE_H5_API_BASE_URL=/gateway-api
# H5 开发代理上游;正式 App 构建请在本机环境文件中改为可访问的绝对 API 根地址。
API_PROXY_TARGET=http://127.0.0.1:9000
OVERSEAS_API_PROXY_TARGET=http://127.0.0.1:9300
# 海外 PDA 独立更新清单地址;不得与国内 PDA 发布记录混用。
VITE_PDA_UPDATE_URL=https://updates.example.com/pda/overseas/latest
# 仅开发服务器自动预填;请在不提交的 .env.local 中填写真实开发账号。
+35 -13
View File
@@ -3,8 +3,10 @@
## 快速读取顺序
1. 先用“页面与能力快速索引”定位 `src/pages.json`、工作台或最小页面;页面业务配置从 `src/data/overseas-workbench.js` 进入,不扫描 `src/uni_modules/`。
2. 登录、Token 或双区域请求先读 `src/api/auth.js`、`src/stores/session.js`、`src/config/runtime.js` 和 `../../docs/跨区域认证与服务通信契约.md`。
3. 海外入仓、库存或仓储资源联调先读 `../../docs/海外仓业务/README.md`、`../../baiyue-firstleg/lastleg-service/AGENTS.md`、`warehouse-overseas-service/AGENTS.md` 与 `gateway-overseas/AGENTS.md`。
2. 登录、Token 或统一入口请求先读 `src/api/auth.js`、`src/stores/session.js`、`src/config/runtime.js` 和 `../../docs/统一认证与服务通信契约.md`。
3. 沿实际 API 定位:入仓/库存契约读 `../../baiyue-firstleg/lastleg-service/AGENTS.md`;资源契约读 `../../baiyue-firstleg/warehouse-overseas-service/AGENTS.md`;网关路径或鉴权问题才读 `../../baiyue-firstleg/gateway/AGENTS.md`。跨模块流程或入口不明才读 `../../docs/海外仓业务/README.md`;纯页面改动不自动读取后端指引。
最小读取集按任务保持在适用指引和目标页面/API;纯展示任务不增加服务指引,服务端判断才读直接服务指引,跨服务状态或鉴权无法判断时再增加相关方。读取预算遵循根指引的行数与字符数标准,先读适用规则,再检索业务命中段,不全文加载海外仓 README。
## 项目定位
@@ -14,9 +16,9 @@
## 服务与发布边界
- 国内 Auth 是唯一账号和 Token 签发方;海外业务请求统一经 `gateway-overseas`。
- 登录、刷新和本地会话必须同时校验国内/海外 Token 的平台主体、成员、冻结海外仓一致;`OVERSEAS_WAREHOUSE_PDA_OPERATOR` 与 `lastleg:pda:operate` 只从 Auth 登录/刷新响应的顶层权限快照读取并持久化,不得解析 JWT 权限。权限快照缺失、无效、过期、跨主体或撤权响应必须 fail-closed 清理会话,不得只依赖页面入口隐藏。
- 并发海外 401 只允许一次国内 refresh;双 Token 完整校验后原子替换,每个原请求或照片上传最多重放一次。刷新失败清理本地会话,业务错误和网络错误保留 code、requestId、traceId 或 network 诊断。
- Auth 是唯一账号和 Token 签发方;认证和业务请求共用统一 Gateway,App 根地址只读 VITE_API_BASE_URL,H5 使用 VITE_H5_API_BASE_URL。
- 登录、刷新和本地会话必须校验统一 Token 的平台主体、成员、有效期和冻结海外仓;`OVERSEAS_WAREHOUSE_PDA_OPERATOR` 与 `lastleg:pda:operate` 只从 Auth 登录/刷新响应的顶层权限快照读取并持久化,不得解析 JWT 权限。权限快照缺失、无效、过期、跨主体或撤权响应必须 fail-closed 清理会话,不得只依赖页面入口隐藏。
- 并发 401 只允许一次 refresh;统一 Token 和权限快照完整校验后原子替换,主体或绑定仓改变时清理会话并重新登录,每个原请求或照片上传最多重放一次。刷新失败清理本地会话,业务错误和网络错误保留 code、requestId、traceId 或 network 诊断。
- 国内与海外 AppID、Android 包名、签名、版本号、构建产物和在线更新通道必须独立。
- `src/manifest.json` 中的 AppID 在正式打包前由海外版所属 DCloud 账号创建;不得伪造或复用国内 AppID。
- 环境地址只通过 `.env.local` 等未提交环境文件配置,不写入源码。
@@ -40,15 +42,18 @@
| 登录、刷新与会话 | `src/pages/login/login.vue`、`src/api/auth.js`、`src/stores/session.js` | 国内 Auth 签发 Token;API 根见 `src/config/runtime.js` |
| 海外仓工作台与模块入口 | `src/pages/home/home.vue`、`src/pages/module/module.vue` | `src/data/overseas-workbench.js` 是模块与作业可用性入口;首页通过 `/api/pda/v1/last-leg/receiving-containers/warehouse-profile` 读取 Token 绑定仓的中文主档名称 |
| 中英西语言设置 | `src/i18n/index.js`、`src/i18n/locales/`、`src/pages/profile/profile.vue` | 个人中心可选择并持久化 `zh-CN`、`en`、`es`;当前业务翻译资源仍为空,页面业务文案继续使用中文,不得把语言选择入口误报为全量翻译完成 |
| 柜号签收、照片与入仓真实作业 | `src/pages/operation/operation.vue`、`src/components/ReceivingSessionSummary.vue`、`src/api/inbound.js`、`src/api/receiving-adapter.js`、`src/api/overseas.js`、`src/stores/receiving.js` | 签收前展开显示国内空仓、半仓、满仓、锁仓和带锁码五类整柜只读照片,收货中折叠查看,单图加载失败只允许重试且不阻断拆柜;海外三个固定照片角色各一张,支持现场拍照、鉴权预览及更换失败保留原图;多计划同柜直接展示服务端 `assigned/mergeAllowed/urgent/mergeHint`,合托明确为现场可选而非完成门禁;扫码实收确认计划生成阶段已预留的真实托盘和库位,不在设备端重新分配。Backend `lastleg-service/inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,公网前缀 `/api/pda/v1/last-leg/receiving-containers/**` |
| 柜号签收、照片与入仓真实作业 | `src/pages/operation/operation.vue`、`src/components/ReceivingSessionSummary.vue`、`src/api/inbound.js`、`src/api/receiving-adapter.js`、`src/api/overseas.js`、`src/stores/receiving.js` | 签收前展开显示国内空仓、半仓、满仓、锁仓和带锁码五类整柜只读照片,收货中折叠查看,单图加载失败只允许重试且不阻断拆柜;海外三个固定照片角色各一张,支持现场拍照、鉴权预览及更换失败保留原图;多计划同柜直接展示服务端 `assigned/mergeAllowed/urgent/mergeHint`,合托明确为现场可选而非完成门禁;扫码实收确认计划生成阶段已预留的真实托盘和库位,校验卡展示预留库位编码及 `A0` 共享溢出位提示,不在设备端重新分配。Backend `lastleg-service/inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,公网前缀 `/api/pda/v1/last-leg/receiving-containers/**` |
| 退货入库签收与合箱 | `src/pages/return-receipt/return-receipt.vue`、`src/pages/return-packing/return-packing.vue`、`src/api/return-receipt.js`、`src/api/return-packing.js`、`src/stores/warehouse-work.js` | 签收优先扫描退货单号,也可扫描 SKU 后选择候选退货单,按本包实际 SKU 数量生成退货箱与计费批次;合箱只选择同客户已完成退货单的可用库存并录入新箱箱规;公网前缀 `/api/pda/v1/last-leg/return-receipts/**`、`/api/pda/v1/last-leg/return-inventory/**` |
| 退货箱内清点 | `src/pages/return-counting/return-counting.vue`、`src/api/return-counting.js` | 扫描箱号或托盘号定位清点任务;各箱 SKU 汇总与签收数量核平,支持恢复当前任务;页面注册见 `src/pages.json` |
| 仓储转运测量、打托与标签 | `src/pages/warehouse-transfer/warehouse-transfer.vue`、`src/api/warehouse-transfers.js` | 工作台键 `warehouseTransfer`;候选→详情→逐托测量草稿→生成新托盘号→扫码绑定→照片与完成打托。API 模块路径 `/pda/v1/last-leg/warehouse-transfers` 经统一海外请求层处理;不是 Admin 转运审核或排车转派送。回归 `tests/warehouse-transfer-api.test.mjs` |
| 托盘管理真实作业 | `src/pages/pallet/pallet.vue`、`src/api/pallet.js` | 扫码查询托盘内容,按服务端返回的托盘/库存版本执行组托、拆托、换托和解绑;公网前缀 `/api/pda/v1/last-leg/pallet-operations/**` |
| 库存盘点(入口隐藏) | `src/pages/stocktake/stocktake.vue`、`src/api/warehouse-operations.js`、`src/stores/warehouse-work.js` | 底层创建、恢复、计数与提交实现仍保留;当前 `src/data/overseas-workbench.js` 不注册库存盘点,不向现场开放入口 |
| 商品移库真实作业 | `src/pages/move/move.vue`、`src/api/warehouse-operations.js`、`src/stores/warehouse-work.js` | 扫描来源托盘与目标库位执行同仓移库;冲突后重新读取服务端事实 |
| 换标真实作业 | `src/pages/relabel/relabel.vue`、`src/api/relabel.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` | 任务列表/详情、托盘业务码扫码拣货、幂等完成;发车必须同时完成拣货核平、平台转派送并严格消费服务端 `canDepart`、`departBlockReason`,不得按本地数量自行推断;公网前缀 `/api/pda/v1/last-leg/dispatches/**` |
| 普通/一件代发出库 | `src/pages/outbound/outbound.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 任务列表/详情、稳定业务码扫码拣货和幂等完成;公网前缀 `/api/pda/v1/last-leg/outbound-tasks/**`;派送由 Admin/Client 承载 |
| 排车拣货与发车 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 普通进入默认展示任务列表和“上次作业”标记,不因本地草稿自动打开详情;只有出库扫码明确跳转 `?resume=1` 时恢复对应排车。详情提供独立返回列表入口;托盘业务码扫码拣货、幂等完成前消费服务端 `canPick/pickBlockReason` 调度审核结果,整车发车消费 `canDepart/departBlockReason`(含理货审核)。公网前缀 `/api/pda/v1/last-leg/dispatches/**` |
| 整车发车与证照提醒 | 同上 `dispatch.vue`、`fulfillment.js` 的 `tripDepartureRequest` | 显示 `tripNo/tripDispatchCount/driverWarning`;发车提交 `tripRecordVersion` 对应的 `recordVersion`,幂等键按车次和版本保留。响应必须为该车次 `IN_TRANSIT` 且包含当前排车,随后读取服务端任务状态再释放幂等键;不能把整车状态当子单状态,也不能缺省伪造 `DEPARTED`。证照即将到期提醒不独立禁用按钮,门禁仍以服务端结果为准 |
| 普通/一件代发出库 | `src/pages/outbound/outbound.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 首次进入清空扫描框并展示任务列表和“上次作业”标记,不自动恢复旧详情;详情提供独立返回列表入口。逐箱扫码、DP 托盘整托拣取及幂等完成保持不变;扫描排车业务号显式跳转排车 `?resume=1`。公网前缀 `/api/pda/v1/last-leg/outbound-tasks/**`;派送由 Admin/Client 承载 |
| 扫码设备与诊断 | `src/utils/scan-broadcast.js`、`src/pages/scanner-test/scanner-test.vue` | `src/components/ScanTargetInput.vue`、声音反馈工具;广播 Action/Extra 见本文件服务边界 |
| 作业记录与人员 | `src/pages/history/history.vue`、`src/pages/profile/profile.vue` | 会话隔离、退出清理和后续真实作业历史契约 |
| 业务标识与内部 ID 隔离 | `tests/business-identifiers-ui.test.mjs`;页面入口 `src/pages/`、`src/components/`,会话映射 `src/stores/session.js` | 页面只展示集装箱号、入仓码、箱码、托盘码、库位码、仓库名称等业务值;不得以 orderId、inboundId、planId、warehouseId、palletId、locationId、user.id 等内部 ID 或截断值回退,错误诊断保留 code、requestId、traceId |
@@ -56,23 +61,40 @@
## 海外业务接口边界
- 仓储转运按托盘分别保存箱数、长宽高(cm)和毛重(kg),体积按本托尺寸计算;短收/超收仅展示差异,不以预报箱数强制核平。生成托盘号后锁定该卡测量数据,必须取得服务端绑定确认且至少有一张现场照片才能完成打托;完成请求只提交稳定幂等键和照片文件 ID,不重复提交整份托盘测量。重新进入从详情恢复托盘和鉴权照片;标签使用服务端 `labelDownloadPath` 下载 PDF,打开文档不等于真机打印完成。
- 换标目标确认即提交拆托合托,不再二次点击底部提交。`RelabelPalletWork.vue` 按当前轮次 `PALLET_BIND` 最新记录提供逐箱改绑,扫描新目标后弹窗确认,使用既有 `relabel-disassemble` 精确来源路径(不传 `actualSource`);同目标不发写请求。服务器持有库存、托盘版本与幂等约束,已排车或出库禁止直接改绑。离线回归 `tests/relabel-pallet-work.test.mjs`,不等同真机验收。
- SKU 正常作业固定显示一个旧SKU扫描框,通过 `relabel-tasks/{relabelNo}/skus/scan` 提交 itemId、skuIndex 和旧SKU,不要求扫描新标签;多箱同SKU先选择具体明细。服务端每条保存 `SKU_SCAN` 历史,页面按当前返工轮次隐藏已完成子项;全部SKU完成才完成箱的SKU阶段,混合单仍需箱唛阶段。箱唛待处理列表同样隐藏已完成项。回归 `tests/relabel-quick-sku.test.mjs`。
- 换标执行页内嵌 `src/components/RelabelPalletWork.vue`:`relabel-sources` 展示原入仓单各托盘箱数;选择来源库存行并扫描实托、输入拆出箱数、核对本批已完成换标箱,再扫描目标托盘合托。每次一个来源库存行,可分批处理多个托盘,无需为拆托额外逐箱扫码。`relabel-disassemble.actualSource` 原子调整本单冻结记账来源并移动库存,目标仍归原入仓单,不新建入库。仅同新目的仓合托;新增托盘不等待客户确认,409后刷新重扫。H5 提供标签打印,原生打印设备适配尚未完成;不能宣称生成托盘号即打印成功。回归 `tests/relabel-pallet-work.test.mjs`。
- 换标任务标明类型,仅显示实际明细需要的 Tab。混合明细同时进入箱唛/SKU 作业,服务端分开保存两部分进度,`cartonProcessedPackages/skuProcessedPackages` 决定各 Tab 待处理数量;两项均完成后整箱计数一次,不能仅扫箱唛就视为 SKU 完成。
- 换标完工提交使 PDA 任务进入 `WAIT_REVIEW`,申请进入 `CUSTOMER_CONFIRM_PENDING`,客户确认原子完成复核并释放后续履约资格。PDA 复核池只读;平台通过专用 `/rework` 接口携带任务版本、幂等键和必填备注退回,历史事实归档后重新计数,PDA 展示 `reviewRemark` 并清空上一轮输入和照片草稿。
- 换标执行完成照片以缩略图展示并支持大图预览、移除和重拍。重新进入任务通过 `relabel-tasks/{relabelNo}/photos/files/{fileId}/download` 鉴权读取,不持久化设备临时图片地址;重拍先上传成功再替换待提交文件 ID,失败保留原照片,移除只解绑待提交引用。
- 退货查询空态集中在 `src/api/return-receipt.js`、`src/api/return-packing.js`:仅 `startReturnReceipt`、`findReturnSkuCandidates`、`findPackingOrders` 将 `A0404/404` 转为无匹配结果;签收确认、合箱写入和标签读取不得套用此处理,权限、网络与服务错误仍须保留诊断并报错。回归见 `tests/return-receipt-api.test.mjs`、`tests/return-packing-api.test.mjs`,不得将查询无记录误报为写入成功。
- `src/api/inbound.js` 已接入待卸柜候选、柜详情/prepare、current 恢复、三类照片签收、统一箱码定位入仓码、实物托盘解析、普通 FBA bulk/普通 OVERSEAS carton、一件代发 source pallet 整托收货及整柜完成;FBA bulk 不提交 `skuCode`,库存分配由服务端按计划明细完成;一件代发只提交国内冻结的来源托盘号,不允许逐箱或拆托收货。响应与 404/409 判定集中在 `src/api/receiving-adapter.js`,双 Token 刷新与海外 401 单次重放由 `src/api/overseas.js` 统一处理。
- `src/api/inbound.js` 已接入待卸柜候选、柜详情/prepare、current 恢复、三类照片签收、统一箱码定位入仓码、实物托盘解析、普通 FBA bulk/普通 OVERSEAS carton、一件代发 source pallet 整托收货及整柜完成;FBA bulk 不提交 `skuCode`,库存分配由服务端按计划明细完成;一件代发只提交国内冻结的来源托盘号,不允许逐箱或拆托收货。响应与 404/409 判定集中在 `src/api/receiving-adapter.js`,统一 Token 刷新与业务 401 单次重放由 `src/api/overseas.js` 统一处理。
- 入仓摘要、箱码解析和实收响应的 `assigned/mergeAllowed/urgent/mergeHint` 只消费 Lastleg 返回值,布尔标志只有 `true` 生效,`mergeAllowed` 缺省为 `false`;合托仅作提示,不能改变收货资格或触发分配。首扫实物托盘只确认服务端已有托盘、库位与资源锁预留,不在设备端重新分配托盘、库位或资源锁;只提交业务箱码、托盘码及当前收货数量,最终资格由服务端决定。
- `src/api/overseas.js` 的 `overseasUrl` 统一拼接普通请求、上传和下载地址:先移除运行时 `apiBase` 末尾斜杠;当基址已以 `/api` 或 `/overseas-api` 结尾时,再移除业务路径重复的 `/api` 前缀。各业务 API 不得自行拼接 `session.apiBase`,也不得依赖双斜杠或重复 `/api` 被网关容错。
- `src/api/overseas.js` 的 `overseasUrl` 统一拼接普通请求、上传和下载地址:先移除运行时 `apiBase` 末尾斜杠;当基址已以 `/api` 结尾时,再移除业务路径重复的 `/api` 前缀。各业务 API 不得自行拼接 `session.apiBase`,也不得依赖双斜杠或重复 `/api` 被网关容错。
- 待卸柜候选只消费服务端已按 Token 绑定仓过滤的结果,并严格校验每柜正式 `overseasWarehouseCode`、`overseasWarehouseName` 与 `warehouse-profile` 一致;字段缺失或仓不一致一律 fail-closed,不得从 FBA/业务目的仓或内部 ID 推导。候选保留服务端返回的未齐全柜并严格消费 `allPlansReady`、`operable`、`blockReason`、`plannedArrivalAt` 与柜级计划统计;`operable=false` 只能查看只读摘要,不得调用 prepare。候选顺序沿用服务端“可操作优先、同组计划到仓时间升序、时间为空最后”的排序,前端不得用入仓码/计划数组长度自行推导就绪或把预计到港冒充计划到仓。
- 候选详情、prepare 响应和 current 恢复都必须重复校验同一接柜仓编码;prepare 只建立已分配柜的作业会话,不得当作首次认领。服务端仓冲突的 `409/A0409` 中文错误保留 `requestId`/`traceId`;会话建立后仍按既有 current 恢复与完成前禁止切柜逻辑处理。
- `src/api/pallet.js` 只调用 Lastleg 托盘专用 PDA Controller;前端提交服务端扫码返回的 `palletVersion`、`positionVersion` 和随机幂等键,不提交客户、仓库或库位标识。409 后必须重新扫码获取最新版本,禁止用旧库存快照继续作业。
- 请求身份和资源范围只取 Token 与 current 会话;前端不得提交 `customerId`、`planNo`、`allocationId`、`warehouseId` 或 `locationId`。409 必须引导账号恢复当前柜,完成前不得切柜。
- 签收照片固定为三个角色且每个角色只有一个当前文件;上传成功后使用服务端 `downloadPath` 在当前会话内鉴权读取。更换失败时保留原 `fileId` 和可提交状态,无原图的失败槽不得计入 `photosReady`;本地临时路径仅用于本次设备预览,不作为跨会话文件事实。
- 库内、换标与现场查询只调用 `/api/pda/**` 业务 API/DTO,并统一受 `lastleg:pda:operate`、Token 冻结仓库范围和服务端资源归属约束;写操作必须保留稳定幂等键,409 后重新读取服务端状态,不得把平台、客户或内部接口直接暴露给 PDA。
- 柜号签收入仓调用 Lastleg `/api/pda/v1/last-leg/receiving-containers/**`;退货签收与合箱分别调用 `/api/pda/v1/last-leg/return-receipts/**`、`/api/pda/v1/last-leg/return-inventory/**`,具体入口见上表。浏览器或设备请求必须经 `gateway-overseas` 的显式 PDA 路由,不得直连 Lastleg,也不得改走国内 Gateway。
- 柜号签收入仓调用 Lastleg `/api/pda/v1/last-leg/receiving-containers/**`;退货签收与合箱分别调用 `/api/pda/v1/last-leg/return-receipts/**`、`/api/pda/v1/last-leg/return-inventory/**`,具体入口见上表。浏览器或设备请求必须经统一 `gateway` 的显式 PDA 路由,不得直连 Lastleg。
- 海外业务 Token、PDA 权限和可信身份最终由 Gateway 与服务端校验;前端不得用工作台 `available`、本地客户 ID 或仓库 ID 代替授权和资源归属校验。
- 排车和出库入口只调用专用 PDA 接口;客户与冻结海外仓范围由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不把内部数据库 ID 展示成业务编号。网络失败保留草稿和幂等键,恢复后以服务端任务状态为准;排车详情返回的 `canDepart`、`departBlockReason` 是发车按钮唯一门禁,平台未转派送生成 `WAIT_OUTBOUND` 出库单时不得发车;完成或发车成功后不得重复提交。派送始终由 Admin/Client 承载。
- 排车和出库入口只调用专用 PDA 接口;客户与冻结海外仓范围由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不把内部数据库 ID 展示成业务编号。网络失败保留草稿和幂等键,恢复后以服务端任务状态为准;排车详情返回的 `canDepart`、`departBlockReason` 是发车资格依据,拣货及完成按钮同时核对当前单`canPick/pickBlockReason`;平台未转派送生成 `WAIT_OUTBOUND` 出库单时不得发车;完成或发车成功后不得重复提交。派送始终由 Admin/Client 承载。
## 质量门禁
- 已发车排车详情按需挂载`src/components/DispatchHandoverStatus.vue`,通过`src/api/trip-handover.js`读取当前派送资源和最近交接;每次进入/刷新一次GET,列表不逐行请求。仅只读、不提交客户或仓ID;后台确认交接,已完成派送保留完成时资源。回归`tests/trip-handover.test.mjs`。
- 安装:`npm install`;检查:`npm test`。
- 仓储转运测量、打托、照片或标签入口变更运行 `node --test tests/warehouse-transfer-api.test.mjs tests/project-contract.test.mjs tests/workbench-availability.test.mjs`;静态合同不替代真实扫码、绑定及打印验收。
- 退货签收、合箱或工作台入口变更先运行 `node --test tests/return-receipt-api.test.mjs tests/return-packing-api.test.mjs tests/project-contract.test.mjs tests/workbench-availability.test.mjs`;离线检查不替代真实 API 联调与真机扫码验收。
- 当前 uni-app 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`。
+37 -5
View File
@@ -2,7 +2,7 @@
独立的 uni-app Vue 3 App 工程,正式产品形态是安装在海外仓手持 PDA 上的 App。工程不依赖国内 PDA 源码或仓库外共享目录,可独立安装依赖、运行、编译和发布;H5 只用于浏览器调试和编译门禁,不是最终交付形态。
工作台提供柜号级真实入仓、托盘管理、排车拣货、出库任务、一件代发出库、任务完成和发车入口;派送单、派送状态与客户回访由 Admin/Client 承载。登录使用国内 Auth 统一签发的双 Token;海外业务请求统一经 `gateway-overseas`,不得请求国内 Firstleg/Warehouse 业务接口。
工作台提供柜号级真实入仓、托盘管理、排车拣货、出库任务、一件代发出库、任务完成和发车入口;派送单、派送状态与客户回访由 Admin/Client 承载。登录使用Auth 签发的统一 Access/Refresh Token;业务请求统一经 `gateway`,不得请求国内 Firstleg/Warehouse 业务接口。
## 本地运行与检查
@@ -32,7 +32,7 @@ npm run build:app
1. 使用海外版所属的 DCloud 账号为本应用获取独立 AppID,写入 `src/manifest.json` 的 `appid`;不得复用国内版 AppID。
2. 确认 Android 包名 `com.baiyuelogistics.pda.overseas`,如公司已有正式命名规范,在首次正式签名前修改。
3. 配置海外版独立 Android 签名证书;证书、密码和账号信息不得提交仓库。
4. 复制 `.env.example` 为本机 `.env.local`,配置 Auth 与海外网关地址;环境文件不提交仓库。
4. 复制 `.env.example` 为本机 `.env.local`,配置统一 Gateway 地址;环境文件不提交仓库。
5. 在目标海外 PDA 上验证扫描广播 Action=`android.intent.action.SCANRESULT`、Extra=`value`。
## 当前实现边界
@@ -46,9 +46,9 @@ npm run build:app
- 出库作业已接 `/api/pda/v1/last-leg/outbound-tasks/**`:支持普通出库和一件代发任务列表/详情、托盘码/箱码/SKU/入仓码扫码拣货与核平完成;不会展示或进入派送流程。
- 排车与出库写操作使用可恢复的稳定幂等键。网络失败保留当前任务、扫码草稿和幂等键;成功后释放键并按服务端状态恢复,避免重复完成或发车。
- 入仓请求不手填客户、计划、分配、仓库或库位标识;身份与当前柜范围由 Token、Gateway 和服务端会话确定。页面同时支持 PDA 广播扫码与 H5 人工输入。
- 登录、恢复和刷新会 fail-closed 校验国内/海外双 Token 的同一平台操作主体和同一冻结海外仓,并从 Auth 登录/刷新响应的顶层权限快照校验 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`、`lastleg:pda:operate`,不从 JWT 解析权限;并发海外 401 共享一次国内 refresh,校验完成后原子替换双 Token和权限快照,并将原请求或照片上传重放一次。
- 登录、恢复和刷新会 fail-closed 校验统一 Token 的平台操作主体和冻结海外仓,并从 Auth 登录/刷新响应的顶层权限快照校验 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`、`lastleg:pda:operate`,不从 JWT 解析权限;并发 401 共享一次 refresh,主体与绑定仓保持一致后原子替换统一 Token 和权限快照,并将原请求或照片上传重放一次。刷新失败或重放后仍未授权时清空会话和作业草稿,并返回登录页;退出或重新登录后,旧请求不能重放到新会话,迟到的刷新响应也不能恢复旧会话或清除新会话。
- 库存盘点、商品移库、换标收货/执行/复核以及库存/库位/托盘查询均已接入 `/api/pda/**` 专用接口;支持 PDA 广播扫码与 H5 人工输入,并按服务端版本、幂等键和冲突状态恢复,不调用平台、客户或内部接口。
- 本地契约测试与 H5/App 资源编译不等于真实开发环境 HTTP 或 PDA 真机验收。
- 本地契约测试与 H5/App 资源编译不等于真实测试环境 HTTP 或 PDA 真机验收。
## App 在线更新
@@ -57,18 +57,50 @@ Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查海外 PDA 的独
```json
{
"update": true,
"appid": "<海外PDA AppID>",
"packageType": "wgt",
"versionName": "0.0.2",
"versionCode": 2,
"minAppVersionCode": 1,
"downloadUrl": "https://updates.example.com/pda/overseas/0.0.2.wgt",
"mandatory": true,
"releaseNotes": "本次更新内容"
}
```
`packageType` 只允许 `apk` 或 `wgt`。`mandatory=true` 时员工不能跳过,并在下载或安装失败后提供重试;wgt 安装完成会立即重启,APK 交由 Android 安装器完成覆盖安装。正式发布要求:
`packageType` 只允许 `apk` 或 `wgt`,`appid` 必须与当前安装包一致。WGT 清单还须提供 `minAppVersionCode`(最低兼容 APK 整数版本),不满足时客户端拒绝安装并提示升级 APK。`mandatory=true` 时员工不能跳过,并在下载或安装失败后提供重试;wgt 安装完成会立即重启,APK 交由 Android 安装器完成覆盖安装。正式发布要求:
1. 海外 PDA 每次整包发布同时递增 `src/manifest.json` 的 `versionName` 与整数 `versionCode`,使用海外独立 AppID、包名和签名打包 APK。
2. wgt 发布至少递增 `versionName`,并使用与已安装原生包兼容的 uni-app 编译器生成;新增原生模块、权限或不兼容运行时能力时必须发布 APK,不能只发 wgt。
3. 更新清单与下载地址使用 HTTPS,先在测试设备验证可选更新、强制更新、断网重试、APK 覆盖安装、wgt 重启及扫码作业回归,再切换正式清单。
4. 海外和国内更新记录、文件目录及发布权限保持隔离,服务端必须按请求中的 `appid` 拒绝跨应用更新包。
## TEST H5 静态托管
发布构建使用 `/` 资源 base 与 hash 路由,部署到本应用独立域名根目录,浏览器入口为 `/#/`。开发服务仍使用根路径;不能将 Vite 开发代理当作静态服务器配置。
执行 `npm run build:h5`。无需启动 Vite 开发服务器;`npm run build:app` 不属于 H5 发布。
`deploy/Dockerfile.h5` 只复制已构建 `dist/build/h5/` 及静态 Nginx 配置,不在镜像中复制源码、依赖或重新编译。`.dockerignore` 只放行这两类输入。容器静态目录为 `/srv/pda-h5/`。
从本仓库执行 `docker compose -f deploy/docker-compose.h5.test.yml build --pull=false`,再由获授权的部署流程执行 `docker compose -f deploy/docker-compose.h5.test.yml up -d --wait --wait-timeout 120`。必须设置 `IMAGE_TAG`,`NGINX_BASE_IMAGE` 可指定已缓存的兼容 Alpine Nginx 镜像。容器名及网络别名为 `pda-overseas-h5-test`,仅在外部 `firstleg-test-network` 暴露容器 80,不发布宿主端口。健康检查同时请求 `/healthz` 与 `/` 的实际首页。
独立域名入口将 `/` 代理至本应用静态容器根目录,或将构建目录内容直接发布到站点根目录;不再挂载 PDA 子路径。缺失资源返回 404,不回退首页,HTML 使用 `Cache-Control: no-store`。域名入口还须配置同源 API 代理:两端均将 `/gateway-api/` 转统一 Gateway `/api/`。入口须保留 Authorization,支持文件上传下载及至少 11m 请求体。域名与上游只在部署侧配置,不编入 PDA 源码。
部署配置检查:`node --test tests/h5-deployment.test.mjs`。没有 Docker 运行时的本地合同检查不能替代目标主机 `docker compose config`、Nginx 启动与真实 HTTP 验收。
构建后执行 `node scripts/verify-h5.mjs`,核对 HTML/JS/CSS 资源路径、同源 API 和无本机/旧海外配置泄漏。该检查与本地构建仅证明产物;TEST 页面登录、仓权限、业务操作和 Android 真机另行验收。
### 两端统一 API 配置
两端的 `VITE_API_BASE_URL` 均为统一 Gateway 绝对地址(含 `/api`);`VITE_H5_API_BASE_URL=/gateway-api`;`API_PROXY_TARGET` 为同一 Gateway 源地址(不含 `/api`)。实际地址只放在未提交的 `.env.local`;修改后重启开发服务,发布前重新打包。
两端独立域名入口均配置以下规则;`gateway:9000` 仅适用于同一 Docker 网络,其他部署须替换成实际统一 Gateway 上游。静态容器只提供页面文件,Vite 代理不会随静态包发布。
```nginx
location ^~ /gateway-api/ {
proxy_pass http://gateway:9000/api/;
proxy_set_header Authorization $http_authorization;
client_max_body_size 11m;
}
```
+5
View File
@@ -0,0 +1,5 @@
ARG NGINX_BASE_IMAGE=nginx:1.27-alpine
FROM ${NGINX_BASE_IMAGE}
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY dist/build/h5/ /srv/pda-h5/
EXPOSE 80
+5
View File
@@ -0,0 +1,5 @@
ARG NGINX_BASE_IMAGE=nginx:1.27-alpine
FROM ${NGINX_BASE_IMAGE}
COPY deploy/nginx.production.conf.template /etc/nginx/templates/default.conf.template
COPY dist/build/h5/ /srv/pda-h5/
EXPOSE 80
+29
View File
@@ -0,0 +1,29 @@
name: firstleg-production-pda-overseas-h5
services:
pda-overseas-h5:
build:
context: ..
dockerfile: deploy/Dockerfile.h5.production
args:
NGINX_BASE_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
image: pda-overseas-h5-prod:${IMAGE_TAG:?IMAGE_TAG is required}
container_name: pda-overseas-h5-prod
restart: unless-stopped
environment:
API_UPSTREAM: "http://gateway:9000"
ports:
- "127.0.0.1:${HOST_PORT:-29006}:80"
healthcheck:
test: ["CMD-SHELL", "wget -qO /dev/null http://127.0.0.1/healthz && wget -qO /dev/null http://127.0.0.1/"]
interval: 5s
timeout: 3s
retries: 12
start_period: 5s
networks:
backend:
aliases:
- pda-overseas-h5-prod
networks:
backend:
external: true
name: firstleg-production-network
+27
View File
@@ -0,0 +1,27 @@
name: firstleg-test-pda-overseas-h5
services:
pda-overseas-h5:
build:
context: ..
dockerfile: deploy/Dockerfile.h5
args:
NGINX_BASE_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
image: pda-overseas-h5-test:${IMAGE_TAG:?IMAGE_TAG is required}
container_name: pda-overseas-h5-test
restart: unless-stopped
expose:
- "80"
healthcheck:
test: ["CMD-SHELL", "wget -qO /dev/null http://127.0.0.1/healthz && wget -qO /dev/null http://127.0.0.1/"]
interval: 5s
timeout: 3s
retries: 12
start_period: 5s
networks:
test-backend:
aliases:
- pda-overseas-h5-test
networks:
test-backend:
external: true
name: firstleg-test-network
+16
View File
@@ -0,0 +1,16 @@
server {
listen 80;
server_name _;
root /srv/pda-h5;
location = /healthz {
default_type text/plain;
add_header Cache-Control "no-store" always;
return 200 "ok\n";
}
location / {
index index.html;
try_files $uri $uri/ =404;
add_header Cache-Control "no-store" always;
}
}
+25
View File
@@ -0,0 +1,25 @@
server {
listen 80;
server_name _;
root /srv/pda-h5;
location = /healthz {
default_type text/plain;
add_header Cache-Control "no-store" always;
return 200 "ok\n";
}
location /gateway-api/ {
client_max_body_size 55m;
proxy_pass ${API_UPSTREAM}/api/;
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
index index.html;
try_files $uri $uri/ =404;
add_header Cache-Control "no-store" always;
}
}
@@ -0,0 +1,16 @@
{
"base": "3133aac01953a2b7034517171cb098148407cd6e",
"commit": "a7388914cdd1ae654efa08867edb51fc4e0b6e5c",
"files": [
"src/pages/dispatch/dispatch.vue",
"src/utils/dispatch-error.js",
"tests/fulfillment-workflows.test.mjs"
],
"supersedes": "cf984ab4812d64b33e1ad2c98bc2804a272c6c77",
"deployed": false,
"checks": {
"tests": 215,
"h5Build": "PASS",
"browser": "local corrected H5 with gray API"
}
}
+32
View File
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict'
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
import { resolve, dirname, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createHash } from 'node:crypto'
const project = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const output = resolve(project, 'dist/build/h5')
const base = '/'
function walk(dir) { return readdirSync(dir).flatMap(name => { const path = resolve(dir, name); return statSync(path).isDirectory() ? walk(path) : [path] }) }
assert.ok(existsSync(resolve(output, 'index.html')), 'Build H5 before verifying')
const files = walk(output).sort()
const html = readFileSync(resolve(output, 'index.html'), 'utf8')
const refs = [...html.matchAll(/(?:src|href)="([^"]+)"/g)].map(match => match[1])
assert.ok(refs.length >= 2, 'HTML must reference compiled assets')
for (const ref of refs) {
assert.ok(ref.startsWith(base), `Asset must remain under ${base}: ${ref}`)
assert.ok(existsSync(resolve(output, ref.slice(base.length))), `Missing asset: ${ref}`)
}
const js = files.filter(path => path.endsWith('.js')).map(path => readFileSync(path, 'utf8')).join('\n')
assert.doesNotMatch(html + js, /\/pda-(?:domestic|overseas)\//, 'Build must not reference the retired subdirectory deployment')
assert.doesNotMatch(js, /VITE_ROOT_DIR|VITE_AUTH_API_BASE_URL|VITE_OVERSEAS_API_BASE_URL|overseas-api|overseasAccessToken/, 'Build contains broad local env or old overseas config')
assert.doesNotMatch(js, /https?:\/\/(?:localhost|127\.0\.0\.1):\d+/, 'Build must not depend on a local dev server')
assert.doesNotMatch(js, /https?:\/\/[^"'\s]+\/api["']/, 'API must be same-origin for this H5 deployment')
for (const path of files.filter(path => /\.(?:js|css)$/.test(path))) {
const source = readFileSync(path, 'utf8')
for (const match of source.matchAll(/(?:from\s*|import\s*\(\s*)["'](\.[^"']+\.(?:js|css))["']/g)) {
assert.ok(existsSync(resolve(dirname(path), match[1])), `Missing chunk ${match[1]}`)
}
}
const digest = createHash('sha256')
for (const path of files) { digest.update(relative(output,path).replaceAll('\\','/')); digest.update('\0'); digest.update(readFileSync(path)) }
console.log(JSON.stringify({output, base, files: files.length, bytes: files.reduce((sum,path)=>sum+statSync(path).size,0), sha256: digest.digest('hex')}, null, 2))
+19 -3
View File
@@ -1,6 +1,8 @@
import { API_ROOT } from '../config/runtime.js'
function authRequest(path, data) {
const pendingRefreshes = new Map()
function authRequest(path, data, accessToken = '') {
return new Promise((resolve, reject) => {
const url = `${API_ROOT}${path}`
console.info('[Overseas PDA auth] request', { method: 'POST', url })
@@ -8,6 +10,8 @@ function authRequest(path, data) {
url,
method: 'POST',
data,
header: { Accept: 'application/json', 'Content-Type': 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}) },
success(response) {
const body = response.data || {}
console.info('[Overseas PDA auth] response', { url, statusCode: response.statusCode, code: body.code,
@@ -15,9 +19,10 @@ function authRequest(path, data) {
contentType: response.header?.['Content-Type'] || response.header?.['content-type'],
requestId: body.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id'],
traceId: body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id'] })
if (String(body.code ?? response.statusCode) !== '200') {
if (response.statusCode !== 200 || String(body.code) !== '200') {
const error = new Error(body.message || '认证服务请求失败')
error.code = body.code || response.statusCode
error.statusCode = response.statusCode
error.requestId = body.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id']
error.traceId = body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id']
reject(error)
@@ -40,5 +45,16 @@ export function login(loginId, password) {
}
export function refresh(refreshToken) {
return authRequest('/auth/refresh', { refreshToken })
if (pendingRefreshes.has(refreshToken)) return pendingRefreshes.get(refreshToken)
const pending = authRequest('/auth/refresh', { refreshToken }).finally(() => {
if (pendingRefreshes.get(refreshToken) === pending) pendingRefreshes.delete(refreshToken)
})
pendingRefreshes.set(refreshToken, pending)
return pending
}
export function getPendingRefresh(refreshToken) { return pendingRefreshes.get(refreshToken) || null }
export function logout(accessToken, refreshToken) {
return authRequest('/auth/logout', { refreshToken }, accessToken)
}
+63 -4
View File
@@ -1,7 +1,8 @@
import { overseasRequest } from './overseas.js'
import { overseasRequest, overseasUpload, overseasDownload } from './overseas.js'
const DISPATCH_BASE = '/pda/v1/last-leg/dispatches'
const OUTBOUND_BASE = '/pda/v1/last-leg/outbound-tasks'
const CARGO_DISCARD_BASE = '/pda/v1/last-leg/cargo-discards'
const encoded = (value) => encodeURIComponent(String(value || '').trim())
@@ -23,8 +24,14 @@ export function normalizeDispatchTask(value) {
taskNo: String(value?.taskNo || ''),
dispatchNo: String(value?.dispatchNo || ''),
vehiclePlate: String(value?.vehiclePlate || ''),
tripNo: String(value?.tripNo || ''),
tripRecordVersion: value?.tripRecordVersion == null ? null : String(value.tripRecordVersion),
tripDispatchCount: count(value?.tripDispatchCount),
driverWarning: String(value?.driverWarning || ''),
status: String(value?.status || ''),
canDepart: value?.canDepart === true,
canPick: value?.canPick === true,
pickBlockReason: String(value?.pickBlockReason || ''),
departBlockReason: String(value?.departBlockReason || ''),
items: asList(value?.items).map((item) => ({
...item,
@@ -36,6 +43,7 @@ export function normalizeDispatchTask(value) {
warehouseRouteLabel: String(item?.warehouseRouteLabel || ''),
scheduledPackages: count(item?.scheduledPackages),
pickedPackages: count(item?.pickedPackages),
returnCartons: asList(item?.returnCartons).map((carton) => ({ ...carton, id: String(carton.id || ''), cartonMark: String(carton.cartonMark || ''), productQuantity: count(carton.productQuantity) })),
positions: asList(item?.positions).map((position) => ({
...position,
id: String(position?.id || ''),
@@ -53,6 +61,7 @@ export function normalizeOutboundTask(value) {
...header,
taskNo: String(header?.taskNo || ''),
outboundNo: String(header?.outboundNo || ''),
outboundType: String(header?.outboundType || ''),
status: String(header?.status || ''),
items: asList(value?.items).map((item) => ({
...item,
@@ -95,6 +104,13 @@ export function departDispatch(dispatchNo, request) {
return overseasRequest('POST', `${DISPATCH_BASE}/${encoded(dispatchNo)}/depart`, request)
}
export function tripDepartureRequest(task, idempotencyKey) {
if (!task?.tripNo || task.tripRecordVersion == null || !/^\d+$/.test(String(task.tripRecordVersion))) {
throw new Error('车次信息不完整,请刷新任务后再发车')
}
return { recordVersion: String(task.tripRecordVersion), idempotencyKey, remark: '海外 PDA 整车核平后发车' }
}
export async function listOutboundTasks(status = '') {
const suffix = status ? `?status=${encoded(status)}` : ''
return asList(await overseasRequest('GET', `${OUTBOUND_BASE}${suffix}`)).map(normalizeOutboundTask)
@@ -112,15 +128,58 @@ export function pickOutboundTask(taskNo, request) {
return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/pick`, request)
}
export function outboundPickPayload(item, position, packages) {
export function pickOutboundPallet(taskNo, palletNo, request) {
return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/pallets/${encoded(palletNo)}/pick`, request)
}
export function outboundPickPayload(item, position, packages, scannedCode = '') {
const skuScan = item.skuCode && scannedCode === item.skuCode
return {
outboundPalletItemId: item.outboundPalletItemId,
inventoryPositionId: position.inventoryPositionId,
inventoryPositionId: position.inventoryPositionId || null,
packages: Number(packages),
cartonMarks: position.cartonMark ? [position.cartonMark] : []
...(item.returnSkuQuantity != null ? { productQuantity: Number(item.returnSkuQuantity) } : {}),
cartonMarks: skuScan ? [] : position.cartonMark ? [position.cartonMark] : [],
...(skuScan ? { skuCode: item.skuCode } : {})
}
}
export function completeOutboundTask(taskNo, request) {
return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/complete`, request)
}
export function normalizeCargoDiscardTask(value) {
const task = value || {}
return {
...task,
discardNo: String(task?.discardNo || ''),
palletNo: String(task?.palletNo || ''),
status: String(task?.status || 'PENDING'),
skus: asList(task?.skus).map((sku) => ({
...sku,
itemId: String(sku?.itemId || ''),
skuCode: String(sku?.skuCode || ''),
productName: String(sku?.productName || ''),
plannedPackages: count(sku?.plannedPackages),
actualPackages: sku?.actualPackages == null ? null : count(sku.actualPackages),
status: String(sku?.status || 'PENDING')
}))
}
}
export async function listCargoDiscardTasks() {
return asList(await overseasRequest('GET', CARGO_DISCARD_BASE)).map(normalizeCargoDiscardTask)
}
export async function resolveCargoDiscardPallet(palletNo) {
return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/resolve`, { palletNo }))
}
export async function confirmCargoDiscard(discardNo, palletNo, lines, photoFileIds) {
return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, { lines, photoFileIds }))
}
const discardPhotoPath = (discardNo, palletNo) => `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/photos/files`
export const listCargoDiscardPhotos = (discardNo, palletNo) => overseasRequest('GET', discardPhotoPath(discardNo, palletNo))
export const uploadCargoDiscardPhoto = (discardNo, palletNo, filePath) => overseasUpload(discardPhotoPath(discardNo, palletNo), filePath)
export const downloadCargoDiscardPhoto = (discardNo, palletNo, fileId) => overseasDownload(`${discardPhotoPath(discardNo, palletNo)}/${encoded(fileId)}/download`, { expectedContentTypePrefix: 'image/' })
+9 -5
View File
@@ -14,15 +14,19 @@ export const getCurrentWarehouseProfile = () => {
}
return warehouseProfileRequest
}
export const getReceivingContainer = async (containerNo) => normalizeReceivingSession(await overseasRequest('GET', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}`))
export const prepareReceivingContainer = async (containerNo, request) => normalizeReceivingSession(await overseasRequest('POST', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}/prepare`, request))
const receivingIdentityQuery = (sourceContainerBookingId) => `sourceContainerBookingId=${encodeURIComponent(sourceContainerBookingId)}`
export const getReceivingContainer = async (containerNo, sourceContainerBookingId) => normalizeReceivingSession(await overseasRequest('GET', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}?${receivingIdentityQuery(sourceContainerBookingId)}`))
export const prepareReceivingContainer = async (containerNo, sourceContainerBookingId, request) => normalizeReceivingSession(await overseasRequest('POST', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}/prepare?${receivingIdentityQuery(sourceContainerBookingId)}`, request))
export const uploadCurrentUnloadPhoto = (filePath) => overseasUpload(current('/unload-photos/files'), filePath)
export const downloadCurrentUnloadPhoto = (downloadPath) => overseasDownload(downloadPath)
export const downloadCurrentDomesticLoadingPhoto = (downloadPath) => overseasDownload(downloadPath)
export const downloadCurrentUnloadPhoto = (downloadPath) => overseasDownload(downloadPath, { expectedContentTypePrefix: 'image/' })
export const downloadCurrentDomesticLoadingPhoto = (downloadPath) => overseasDownload(downloadPath, { expectedContentTypePrefix: 'image/' })
export const signCurrentReceiving = async (request) => normalizeReceivingSession(await overseasRequest('POST', current('/sign'), request))
export const getCurrentReceiving = async () => normalizeReceivingSession(await overseasRequest('GET', current()))
export const resolveCurrentCarton = async (cartonMark) => normalizeReceivingInbound(await overseasRequest('POST', current('/cartons/resolve'), { cartonMark }))
export const resolveCurrentPallet = (palletCode, inboundCode) => overseasRequest('POST', current('/pallets/resolve'), { palletCode, inboundCode })
export const resolveCurrentPallet = async (palletCode, inboundCode) => {
const value = await overseasRequest('POST', current('/pallets/resolve'), { palletCode, inboundCode })
return { ...value, palletCode: String(value?.palletCode || palletCode || ''), locationCode: String(value?.locationCode || value?.warehouseLocationCode || ''), locationType: String(value?.locationType || value?.warehouseLocationType || ''), locationStatus: String(value?.locationStatus || value?.warehouseLocationStatus || '') }
}
export const receiveCurrentBulk = async (request) => normalizeReceivingAssignment(await overseasRequest('POST', current('/receipts/bulk'), request))
export const receiveCurrentCarton = async (request) => normalizeReceivingAssignment(await overseasRequest('POST', current('/receipts/carton'), request))
export const receiveCurrentWholePallet = async (request) => normalizeReceivingAssignment(await overseasRequest('POST', current('/receipts/pallet'), request))
+53
View File
@@ -0,0 +1,53 @@
import { logout, refresh, getPendingRefresh } from './auth.js'
import { session, signOut, getSessionRevision, decodeAccessToken } from '../stores/session.js'
function replacementForCapturedSession(captured, tokens) {
if (!tokens?.accessToken || typeof tokens.refreshToken !== 'string' || !tokens.refreshToken.trim()) {
throw new Error('退出刷新响应缺少会话凭据')
}
const previous = decodeAccessToken(captured.accessToken)
const replacement = decodeAccessToken(tokens.accessToken)
if (!Number.isFinite(Number(replacement?.exp)) || Number(replacement.exp) * 1000 <= Date.now()) {
throw new Error('退出刷新凭据无效或已过期')
}
for (const name of ['user_id', 'membership_id', 'tenant_id', 'app_code', 'overseas_warehouse_id']) {
if (previous[name] == null || String(previous[name]) !== String(replacement[name])) {
throw new Error('退出刷新返回了不同作业主体,无法确认原会话撤销')
}
}
return { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken }
}
// Active user logout only. Ordinary authentication failures continue to use local signOut.
export function logoutCurrentSession() {
const captured = {
accessToken: session.token, refreshToken: session.refreshToken,
pendingRefresh: getPendingRefresh(session.refreshToken)
}
signOut()
const clearedRevision = getSessionRevision()
const revoke = async () => {
if (!captured.accessToken || !captured.refreshToken) return { revoked: false, reason: 'NO_CAPTURED_SESSION' }
let credentials = captured
let refreshed = false
if (captured.pendingRefresh) {
// Auth rotates access/refresh credentials. Revoke the captured refresh's result,
// while the business refresh wrapper's revision guard prevents local restoration.
refreshed = true
credentials = replacementForCapturedSession(captured, await captured.pendingRefresh)
}
try {
await logout(credentials.accessToken, credentials.refreshToken)
} catch (error) {
if (error.network || error.statusCode !== 401 || refreshed) throw error
// Only an explicit expired-access HTTP 401 permits one refresh and one revoke.
credentials = replacementForCapturedSession(captured, await refresh(captured.refreshToken))
await logout(credentials.accessToken, credentials.refreshToken)
}
return { revoked: true }
}
return revoke().then(result => ({ ...result, clearedRevision }), error => {
error.logoutRevision = clearedRevision
throw error
})
}
+97 -17
View File
@@ -1,7 +1,18 @@
import { refresh } from './auth.js'
import { session, replaceTokens, signOut } from '../stores/session.js'
import { session, replaceTokens, signOut, getSessionRevision } from '../stores/session.js'
let refreshPromise = null
let refreshRevision = -1
function requireCurrentSession(revision) {
if (revision !== getSessionRevision()) throw new Error('登录会话已变更,请重新操作')
}
function expireSession(revision) {
if (revision !== getSessionRevision()) return
signOut()
uni.reLaunch({ url: '/pages/login/login' })
}
function errorFrom(response, fallback) {
const body = response?.data || {}
@@ -21,29 +32,40 @@ function successful(response, body) {
return response?.statusCode === 200 && ['00000', '200'].includes(String(body?.code))
}
async function refreshOverseasToken() {
async function refreshOverseasToken(revision) {
requireCurrentSession(revision)
if (!session.refreshToken) {
signOut()
expireSession(revision)
throw new Error('登录状态已过期,请重新登录')
}
if (!refreshPromise) {
refreshPromise = refresh(session.refreshToken)
.then((tokens) => { replaceTokens(tokens); return session.token })
.catch((error) => { signOut(); throw error })
.finally(() => { refreshPromise = null })
if (!refreshPromise || refreshRevision !== revision) {
refreshRevision = revision
const pending = refresh(session.refreshToken)
.then((tokens) => { requireCurrentSession(revision); replaceTokens(tokens); return session.token })
.catch((error) => { expireSession(revision); throw error })
.finally(() => { if (refreshPromise === pending) refreshPromise = null })
refreshPromise = pending
}
return refreshPromise
}
async function tokenAfterUnauthorized(failedToken) {
async function tokenAfterUnauthorized(failedToken, revision) {
requireCurrentSession(revision)
if (session.token && session.token !== failedToken) return session.token
return refreshOverseasToken()
return refreshOverseasToken(revision)
}
function overseasUrl(path) {
const base = String(session.apiBase || '').replace(/\/$/, '')
const nativeRuntime = typeof plus !== 'undefined'
|| (typeof location !== 'undefined' && location.protocol === 'file:')
if (nativeRuntime && !/^https?:\/\//i.test(base)) {
const error = new Error('海外服务地址未配置:App 必须设置绝对 HTTP(S) 地址 VITE_API_BASE_URL')
error.code = 'PDA_API_BASE_REQUIRED'
throw error
}
const value = `/${String(path || '').replace(/^\/+/, '')}`
const normalized = /\/(?:api|overseas-api)$/.test(base)
const normalized = /\/(?:api|gateway-api)$/.test(base)
? value.replace(/^\/api(?=\/)/, '')
: value
return `${base}${normalized}`
@@ -63,13 +85,17 @@ function rawRequest(method, path, data, token) {
}
export async function overseasRequest(method, path, data, retry = true) {
const revision = getSessionRevision()
console.info('[Overseas PDA API] request', { method, path })
const attemptedToken = session.token
let response = await rawRequest(method, path, data, attemptedToken)
requireCurrentSession(revision)
if (retry && unauthorized(response)) {
const replacementToken = await tokenAfterUnauthorized(attemptedToken)
const replacementToken = await tokenAfterUnauthorized(attemptedToken, revision)
response = await rawRequest(method, path, data, replacementToken)
}
requireCurrentSession(revision)
if (unauthorized(response)) expireSession(revision)
const body = response?.data || {}
console.info('[Overseas PDA API] response', { method, path, statusCode: response?.statusCode, code: body?.code,
message: body?.message, requestId: body?.requestId || response?.header?.['x-request-id'] || response?.header?.['X-Request-Id'],
@@ -79,14 +105,53 @@ export async function overseasRequest(method, path, data, retry = true) {
}
function rawUpload(path, filePath, name, token) {
const browserFile = typeof Blob !== 'undefined' && filePath instanceof Blob
if (!browserFile && !String(filePath || '').trim()) {
const error = new Error('未获取到可上传的照片路径,请重新拍摄')
error.code = 'PDA_UPLOAD_FILE_PATH_REQUIRED'
return Promise.reject(error)
}
let url
try { url = overseasUrl(path) } catch (error) { return Promise.reject(error) }
const h5Runtime = typeof window !== 'undefined' && typeof plus === 'undefined'
if (h5Runtime && browserFile) return new Promise((resolve, reject) => {
uni.uploadFile({
url, files: [{ name, file: filePath }],
header: { Authorization: `Bearer ${token}` },
success: resolve,
fail: (cause) => reject(Object.assign(new Error(cause.errMsg || '海外作业文件上传失败'), { network: true, uploadUrl: url }))
})
})
if (h5Runtime && /^blob:/i.test(filePath)) {
const selectedFile = fetch(filePath).then((source) => {
if (!source.ok) throw new Error('无法读取已选择的照片')
return source.blob()
})
return selectedFile.then(async (blob) => {
const form = new FormData()
const suffix = String(blob.type || '').split('/')[1] || 'jpg'
form.append(name, blob, blob.name || `pda-photo.${suffix}`)
if (typeof XMLHttpRequest !== 'undefined') return new Promise((resolve, reject) => {
const request = new XMLHttpRequest()
request.open('POST', url)
request.setRequestHeader('Authorization', `Bearer ${token}`)
request.onload = () => resolve({ statusCode: request.status, data: request.responseText, header: {} })
request.onerror = () => reject(new Error('海外作业文件上传失败'))
request.send(form)
})
const response = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: form })
return { statusCode: response.status, data: await response.text(), header: Object.fromEntries(response.headers.entries()) }
})
.catch((cause) => { throw Object.assign(new Error(cause.message || '海外作业文件上传失败'), { network: true, uploadUrl: url }) })
}
return new Promise((resolve, reject) => {
uni.uploadFile({
url: overseasUrl(path),
url,
filePath,
name,
header: { Authorization: `Bearer ${token}` },
success: resolve,
fail: (cause) => reject(Object.assign(new Error(cause.errMsg || '海外作业文件上传失败'), { network: true }))
fail: (cause) => reject(Object.assign(new Error(cause.errMsg || '海外作业文件上传失败'), { network: true, uploadUrl: url }))
})
})
}
@@ -103,29 +168,44 @@ function rawDownload(path, token) {
}
export async function overseasUpload(path, filePath, name = 'file', retry = true) {
const revision = getSessionRevision()
const attemptedToken = session.token
let response = await rawUpload(path, filePath, name, attemptedToken)
requireCurrentSession(revision)
let body = null
try { body = JSON.parse(response.data || '{}') } catch (_) { /* handled below */ }
if (retry && unauthorized({ ...response, data: body })) {
const replacementToken = await tokenAfterUnauthorized(attemptedToken)
const replacementToken = await tokenAfterUnauthorized(attemptedToken, revision)
response = await rawUpload(path, filePath, name, replacementToken)
try { body = JSON.parse(response.data || '{}') } catch (_) { body = null }
}
requireCurrentSession(revision)
if (unauthorized({ ...response, data: body })) expireSession(revision)
if (!successful(response, body)) throw errorFrom({ ...response, data: body }, '海外作业文件上传失败')
return body.data
}
export async function overseasDownload(path, retry = true) {
export async function overseasDownload(path, options = {}) {
const revision = getSessionRevision()
const { retry = true, expectedContentTypePrefix = '' } = options
const attemptedToken = session.token
let response = await rawDownload(path, attemptedToken)
requireCurrentSession(revision)
if (retry && unauthorized(response)) {
const replacementToken = await tokenAfterUnauthorized(attemptedToken)
const replacementToken = await tokenAfterUnauthorized(attemptedToken, revision)
response = await rawDownload(path, replacementToken)
}
requireCurrentSession(revision)
if (unauthorized(response)) expireSession(revision)
if (response?.statusCode !== 200 || !response?.tempFilePath) {
throw errorFrom({ ...response, data: {} }, '签收照片下载失败')
}
const contentType = String(response?.header?.['content-type'] || response?.header?.['Content-Type'] || '').toLowerCase()
// App-Plus downloadFile may omit response headers even when the image body was downloaded successfully.
// Reject an explicit non-image response, but allow a valid temp file when Content-Type is unavailable.
if (expectedContentTypePrefix && contentType && !contentType.startsWith(expectedContentTypePrefix.toLowerCase())) {
throw errorFrom({ ...response, data: {} }, '照片服务返回了无效内容,请稍后重试')
}
return response.tempFilePath
}
+5
View File
@@ -1,6 +1,11 @@
import { overseasRequest } from './overseas.js'
const BASE = '/pda/v1/last-leg/pallet-operations'
export const findRelabelSources = (relabelNo) => overseasRequest('POST', `${BASE}/relabel-sources`, { relabelNo })
export const createRelabelPallet = (payload) => overseasRequest('POST', `${BASE}/relabel-pallet-label`, payload)
export const bindRelabelPallet = (payload) => overseasRequest('POST', `${BASE}/relabel-disassemble`, payload)
export const listRelabelPalletMeasurements = (relabelNo) => overseasRequest('POST', `${BASE}/relabel-pallet-measurements`, { relabelNo })
export const measureRelabelPallet = (payload) => overseasRequest('POST', `${BASE}/relabel-pallet-measure`, payload)
export function resolvePallet(code) {
return overseasRequest('POST', `${BASE}/pallets/resolve`, { code: String(code || '').trim() })
+20 -7
View File
@@ -1,23 +1,32 @@
import { overseasRequest, overseasUpload } from './overseas.js'
import { overseasRequest, overseasUpload, overseasDownload } from './overseas.js'
const BASE = '/pda/v1/last-leg/relabel-tasks'
const encoded = (value) => encodeURIComponent(String(value || '').trim())
export const scanRelabelSku = async (no, payload) => normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(no)}/skus/scan`, payload))
export const completeReturnRelabelSku = async (no, payload) => normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(no)}/return-skus/complete`, payload))
export const packReturnRelabelCartons = async (no, payload) => normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(no)}/return-cartons/pack`, payload))
export const completeReturnRelabelSkuTotal = async (no, payload) => normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(no)}/return-skus/complete-total`, payload))
function asList(value) { return Array.isArray(value) ? value : [] }
export function normalizeRelabel(value = {}) {
const items = asList(value.items).map((item) => ({
...item, itemId: String(item.itemId || ''), skuCode: String(item.skuCode || ''),
returnCartonId: item.returnCartonId == null ? null : String(item.returnCartonId), returnQuantity: Number(item.returnQuantity || 0),
newSkuCode: String(item.newSkuCode || ''), operationType: String(item.operationType || 'CARTON_LABEL'), expectedPackages: Number(item.expectedPackages || 0),
relabelledPackages: Number(item.relabelledPackages || 0), skippedPackages: Number(item.skippedPackages || 0),
exceptionPackages: Number(item.exceptionPackages || 0)
}))
const inferredOperationType = items.some((item) => item.operationType === 'CARTON_AND_SKU') ? 'CARTON_AND_SKU' : items.some((item) => item.operationType === 'SKU_LABEL')
? (items.some((item) => item.operationType !== 'SKU_LABEL') ? 'CARTON_AND_SKU' : 'SKU_LABEL') : 'CARTON_LABEL'
return {
...value,
relabelNo: String(value.relabelNo || ''), customerOrderNo: String(value.customerOrderNo || ''),
version: Number(value.version || 0), expectedPackages: Number(value.expectedPackages || 0),
operationType: String(value.operationType || inferredOperationType),
relabelledPackages: Number(value.relabelledPackages || 0), skippedPackages: Number(value.skippedPackages || 0),
exceptionPackages: Number(value.exceptionPackages || 0),
items: asList(value.items).map((item) => ({
...item, itemId: String(item.itemId || ''), skuCode: String(item.skuCode || ''),
newSkuCode: String(item.newSkuCode || ''), expectedPackages: Number(item.expectedPackages || 0),
relabelledPackages: Number(item.relabelledPackages || 0), skippedPackages: Number(item.skippedPackages || 0),
exceptionPackages: Number(item.exceptionPackages || 0)
})),
items,
unitFacts: asList(value.unitFacts), history: asList(value.history),
completionPhotoFileIds: asList(value.completionPhotoFileIds).map(String)
}
@@ -36,9 +45,13 @@ async function detailAction(relabelNo, suffix, request) {
}
export const claimRelabelTask = (relabelNo, request) => detailAction(relabelNo, '/claim', request)
export const scanRelabelCarton = (relabelNo, request) => detailAction(relabelNo, '/cartons/scan', request)
export const listRelabelDocuments = (relabelNo) => overseasRequest('GET', `${BASE}/${encoded(relabelNo)}/documents`)
export const downloadRelabelDocument = (relabelNo, file) => overseasDownload(`${BASE}/${encoded(relabelNo)}/documents/${encoded(file.fileId)}/download?scope=${encoded(file.scope)}${file.itemId ? `&itemId=${encoded(file.itemId)}` : ''}`)
export const relabelUnit = (relabelNo, request) => detailAction(relabelNo, '/units', request)
export const skipRelabelUnit = (relabelNo, request) => detailAction(relabelNo, '/units/skip', request)
export const reportRelabelException = (relabelNo, request) => detailAction(relabelNo, '/exceptions', request)
export const completeRelabelTask = (relabelNo, request) => detailAction(relabelNo, '/complete', request)
export const reviewRelabelTask = (relabelNo, request) => detailAction(relabelNo, '/review', request)
export const uploadRelabelPhoto = (relabelNo, filePath) => overseasUpload(`${BASE}/${encoded(relabelNo)}/photos/files`, filePath)
export const downloadRelabelPhoto = (relabelNo, fileId) => overseasDownload(`${BASE}/${encoded(relabelNo)}/photos/files/${encoded(fileId)}/download`, { expectedContentTypePrefix: 'image/' })
+20
View File
@@ -0,0 +1,20 @@
import { overseasRequest } from './overseas.js'
const BASE = '/pda/v1/last-leg/return-inventory/counting'
export function getCurrentReturnCountTask() {
return overseasRequest('GET', `${BASE}/current`)
}
export function beginReturnCountTask(receiptNo) {
return overseasRequest('POST', `${BASE}/receipts/${encodeURIComponent(receiptNo)}/begin`)
}
export function scanReturnCountTasks(code) {
return overseasRequest('GET', `${BASE}/scan/${encodeURIComponent(String(code).trim())}`)
}
export function listReturnCountTasks({ page = 1, size = 10, keyword = '' } = {}) {
return overseasRequest('GET', `${BASE}/tasks?page=${page}&size=${size}&keyword=${encodeURIComponent(keyword.trim())}`)
}
export function scanReturnCountCarton(mark) {
return overseasRequest('GET', `${BASE}/cartons/${encodeURIComponent(String(mark).trim())}`)
}
export function confirmReturnCount(receiptNo, request) {
return overseasRequest('POST', `${BASE}/receipts/${encodeURIComponent(receiptNo)}`, request)
}
+61 -3
View File
@@ -17,7 +17,17 @@ function normalizeItems(items) {
}
export function normalizePackingPreview(value = {}) {
return {
value = value && typeof value === 'object' ? value : {}
const normalizeCustomer = (customer = {}) => ({
customerId: customer.customerId == null ? '' : String(customer.customerId),
orders: Array.isArray(customer.orders) ? customer.orders.map((item) => ({
returnNo: String(item.returnNo || ''), customerReferenceNo: String(item.customerReferenceNo || ''), status: String(item.status || '')
})) : [],
skus: Array.isArray(customer.skus) ? customer.skus.map((item) => ({
skuCode: String(item.skuCode || ''), productNameZh: String(item.productNameZh || ''), availableQuantity: count(item.availableQuantity)
})) : []
})
const result = {
customerKey: value.customerId == null ? '' : String(value.customerId),
orders: Array.isArray(value.orders) ? value.orders.map((item) => ({
returnNo: String(item.returnNo || ''), customerReferenceNo: String(item.customerReferenceNo || ''), status: String(item.status || '')
@@ -26,6 +36,13 @@ export function normalizePackingPreview(value = {}) {
skuCode: String(item.skuCode || ''), productNameZh: String(item.productNameZh || ''), availableQuantity: count(item.availableQuantity)
})) : []
}
if (Array.isArray(value.customers)) result.customers = value.customers.map(normalizeCustomer).filter((item) => item.customerId)
else if (result.customerKey && result.orders.length) {
// Older service versions returned the single-customer shape. Keep the
// PDA behavior consistent by exposing it through the grouped contract.
result.customers = [{ customerId: result.customerKey, orders: result.orders, skus: result.skus }]
}
return result
}
export function normalizeReturnLabel(value = {}) {
@@ -37,16 +54,53 @@ export function normalizeReturnLabel(value = {}) {
}
}
export async function findPackingOrders(returnNo = '') {
export async function findPackingOrders(returnNo = '', mode = 'RETURN_NO') {
const query = String(returnNo || '').trim()
try {
return normalizePackingPreview(await overseasRequest('GET', `${BASE}/packing/orders${query ? `?returnNo=${encoded(query)}` : ''}`))
return normalizePackingPreview(await overseasRequest('GET', `${BASE}/packing/orders${query ? `?${mode === 'RETURN_NO' ? 'returnNo' : 'skuCode'}=${encoded(query)}` : ''}`))
} catch (error) {
if (isNotFound(error)) return normalizePackingPreview()
throw error
}
}
export async function listPackingPlans() {
const value = await overseasRequest('GET', `${BASE}/packing/plans`)
return Array.isArray(value) ? value.map(normalizePackingPlan) : []
}
export async function completePackingPlan(planNo, request) {
const value = await overseasRequest('POST', `${BASE}/packing/plans/${encoded(planNo)}/complete`, request)
return { planNo: String(value?.planNo || planNo), labels: Array.isArray(value?.labels) ? value.labels.map(normalizeReturnLabel) : [] }
}
export function normalizePackingPlan(value = {}) {
value = { ...value, completionCartons: Array.isArray(value.completionCartons) ? value.completionCartons.map(normalizeReturnLabel) : [], transfers: Array.isArray(value.transfers) ? value.transfers : [], completedAt: value.completedAt || null }
return { completionCartons: value.completionCartons, transfers: value.transfers, completedAt: value.completedAt, planNo: String(value.planNo || ''), customerId: String(value.customerId || ''), returnNos: Array.isArray(value.returnNos) ? value.returnNos.map(String) : [], sourceCartonMarks: Array.isArray(value.sourceCartonMarks) ? value.sourceCartonMarks.map(String) : [], sources: Array.isArray(value.sources) ? value.sources : [], mode: String(value.mode || ''), status: String(value.status || ''), boxes: Array.isArray(value.boxes) ? value.boxes : [], remark: String(value.remark || '') }
}
export async function getPackingPlan(planNo) {
return normalizePackingPlan(await overseasRequest('GET', `${BASE}/packing/plans/${encoded(planNo)}`))
}
export async function scanReturnCarton(mark) {
return overseasRequest('GET', `${BASE}/cartons/${encoded(mark)}`)
}
export async function mergeReturnBoxes(request) {
const value = await overseasRequest('POST', `${BASE}/cartons/merge-boxes`, request)
return { labels: [normalizeReturnLabel(value?.label)] }
}
export async function packReturnSkus(request) {
const value = await overseasRequest('POST', `${BASE}/cartons/pack`, request)
return { labels: Array.isArray(value?.labels) ? value.labels.map(normalizeReturnLabel) : [] }
}
export async function putawayReturnCarton(mark, request) {
return overseasRequest('POST', `${BASE}/cartons/${encoded(mark)}/putaway`, request)
}
export async function mergeReturnCartons(request) {
const value = await overseasRequest('POST', `${BASE}/cartons/merge`, request)
return { cartonMark: String(value?.cartonMark || ''), skuCode: String(value?.skuCode || ''), totalQuantity: count(value?.totalQuantity), label: normalizeReturnLabel(value?.label) }
@@ -55,3 +109,7 @@ export async function mergeReturnCartons(request) {
export async function getReturnCartonLabel(cartonMark) {
return normalizeReturnLabel(await overseasRequest('GET', `${BASE}/cartons/${encoded(cartonMark)}/label`))
}
export async function confirmReturnLabel(cartonMark) {
return normalizeReturnLabel(await overseasRequest('POST', `${BASE}/cartons/${encoded(cartonMark)}/confirm-label`))
}
+35 -5
View File
@@ -1,4 +1,4 @@
import { overseasRequest } from './overseas.js'
import { overseasDownload, overseasRequest, overseasUpload } from './overseas.js'
const BASE = '/pda/v1/last-leg/return-receipts'
@@ -25,7 +25,9 @@ export function normalizeReturnReceiptStart(value = {}) {
return {
returnNo: String(value.returnNo || ''),
status: String(value.status || ''),
expectedItems: Array.isArray(value.expectedItems) ? value.expectedItems.map(normalizeItem) : []
expectedItems: Array.isArray(value.expectedItems) ? value.expectedItems.map(normalizeItem) : [],
completedPallets: Array.isArray(value.completedPallets) ? value.completedPallets.map((item) => ({ palletCode: String(item.palletCode || ''), skuCode: String(item.skuCode || ''), quantity: count(item.quantity), receiptBatchNo: String(item.receiptBatchNo || '') })) : []
,photos: Array.isArray(value.photos) ? value.photos.map((item) => ({ fileId: String(item.fileId || ''), fileName: String(item.fileName || '退货打托照片'), contentType: String(item.contentType || 'image/jpeg'), sizeBytes: count(item.sizeBytes) })) : []
}
}
@@ -45,17 +47,28 @@ export function normalizeReturnSkuCandidates(value = {}) {
}
export function normalizeReturnReceiptResult(value = {}) {
return {
const result = {
returnNo: String(value.returnNo || ''),
receiptNo: String(value.receiptNo || ''),
receiptBatchNo: String(value.receiptBatchNo || ''),
cartonMark: String(value.cartonMark || ''),
palletCode: String(value.palletCode || ''),
orderStatus: String(value.orderStatus || ''),
fullyMatched: Boolean(value.fullyMatched),
expectedQuantity: count(value.expectedQuantity),
receivedQuantity: count(value.receivedQuantity),
signedAt: value.signedAt || null
}
if (value.cartonMark != null || value.printableHtml != null || value.totalBoxCount != null) {
result.cartonMark = String(value.cartonMark || '')
result.printableHtml = String(value.printableHtml || '')
result.totalBoxCount = count(value.totalBoxCount)
}
if (value.unitType != null || value.unitCode != null) {
result.unitType = String(value.unitType || (value.palletCode ? 'PALLET' : ''))
result.unitCode = String(value.unitCode || value.palletCode || '')
}
if (Array.isArray(value.labels)) result.labels = value.labels.map((item) => ({ cartonMark: String(item.cartonMark || ''), printableHtml: String(item.printableHtml || '') })).filter((item) => item.cartonMark && item.printableHtml)
return result
}
export async function startReturnReceipt(returnNo) {
@@ -78,5 +91,22 @@ export async function findReturnSkuCandidates(skuCode) {
}
export async function confirmReturnReceipt(request) {
return normalizeReturnReceiptResult(await overseasRequest('POST', `${BASE}/confirm`, request))
const value = String(request?.palletCode || '')
const encoded = value.startsWith('BOX:') || value.startsWith('PARCEL:')
? { ...request, unitType: value.slice(0, value.indexOf(':')), unitCode: value.slice(value.indexOf(':') + 1), palletCode: undefined }
: request
return normalizeReturnReceiptResult(await overseasRequest('POST', `${BASE}/confirm`, encoded))
}
export async function createReturnPallet(returnNo, skuCode) {
const value = await overseasRequest('POST', `${BASE}/pallets`, { returnNo: String(returnNo || '').trim(), skuCode: String(skuCode || '').trim() })
return { palletCode: String(value?.palletCode || value?.code || ''), printableHtml: String(value?.printableHtml || '') }
}
export async function checkReturnPallet(returnNo, palletCode) {
const value = await overseasRequest('POST', `${BASE}/pallets/check`, { returnNo: String(returnNo || '').trim(), palletCode: String(palletCode || '').trim() })
return { palletCode: String(value?.palletCode || ''), empty: Boolean(value?.empty), skuCodes: Array.isArray(value?.skuCodes) ? value.skuCodes.map(String) : [], quantity: count(value?.quantity) }
}
export const manuallyCompleteReturnReceipt = (returnNo, discrepancyReason) => overseasRequest('POST', `${BASE}/${encodeURIComponent(String(returnNo || '').trim())}/manual-complete`, { discrepancyReason: String(discrepancyReason || '').trim() })
export const uploadReturnPalletPhoto = (returnNo, filePath) => overseasUpload(`${BASE}/${encodeURIComponent(String(returnNo || '').trim())}/photos/files`, filePath)
export const downloadReturnPalletPhoto = (returnNo, fileId) => overseasDownload(`${BASE}/${encodeURIComponent(String(returnNo || '').trim())}/photos/files/${encodeURIComponent(String(fileId || '').trim())}/download`, { expectedContentTypePrefix: 'image/' })
+28
View File
@@ -0,0 +1,28 @@
import { overseasRequest } from './overseas.js'
const text = value => typeof value === 'string' ? value : ''
const transport = value => ({ vehiclePlate: text(value?.vehiclePlate), driverName: text(value?.driverName), driverPhone: text(value?.driverPhone) })
export function normalizeHandoverStatus(value) {
if (!value?.dispatchNo || !value?.tripNo || !Array.isArray(value.deliveries)) throw new Error('交接状态缺少排车或派送依据')
return {
dispatchNo: text(value.dispatchNo), tripNo: text(value.tripNo), tripStatus: text(value.tripStatus),
tripRecordVersion: value.tripRecordVersion == null ? '' : String(value.tripRecordVersion),
originalDeparture: transport(value.originalDeparture),
deliveries: value.deliveries.map(row => {
const packages = Number(row.packages)
if (!row.deliveryNo || row.packages == null || String(row.packages).trim() === '' || !Number.isSafeInteger(packages) || packages <= 0) throw new Error('交接状态中的派送箱数无效')
const remainingPackages = Number(row.remainingPackages)
if (row.remainingPackages == null || String(row.remainingPackages).trim() === '' || !Number.isSafeInteger(remainingPackages) || remainingPackages < 0 || remainingPackages > packages) throw new Error('交接状态中的剩余箱数无效')
return { deliveryNo: text(row.deliveryNo), status: text(row.status), packages, remainingPackages, terminalAt: text(row.terminalAt), transport: transport(row.transport) }
}),
latestHandover: value.latestHandover ? { handoverNo: text(value.latestHandover.handoverNo), handedOverAt: text(value.latestHandover.handedOverAt),
location: text(value.latestHandover.location), previous: transport(value.latestHandover.previous), replacement: transport(value.latestHandover.replacement) } : null
}
}
export async function getDispatchHandoverStatus(dispatchNo) {
const number = String(dispatchNo || '').trim()
if (!number) throw new Error('请先打开排车任务')
const result = normalizeHandoverStatus(await overseasRequest('GET', `/pda/v1/last-leg/dispatches/${encodeURIComponent(number)}/handover-status`))
if (result.dispatchNo !== number) throw new Error('返回的交接记录不属于当前排车任务')
return result
}
+51
View File
@@ -0,0 +1,51 @@
import { overseasDownload, overseasRequest, overseasUpload } from './overseas.js'
const BASE = '/pda/v1/last-leg/warehouse-transfers'
const encoded = (value) => encodeURIComponent(String(value || '').trim())
export async function listWarehouseTransferCandidates() {
const value = await overseasRequest('GET', `${BASE}/candidates`)
return Array.isArray(value) ? value : (Array.isArray(value?.records) ? value.records : [])
}
export function getWarehouseTransferDetail(transferNo) {
return overseasRequest('GET', `${BASE}/${encoded(transferNo)}`)
}
export function palletizeWarehouseTransfer(transferNo, payload) {
return overseasRequest('POST', `${BASE}/${encoded(transferNo)}/palletize`, payload)
}
export function saveWarehouseTransferPalletDraft(transferNo, payload) {
return overseasRequest('POST', `${BASE}/${encoded(transferNo)}/pallets/drafts`, payload)
}
export function generateWarehouseTransferPallet(transferNo, palletId, payload) {
return overseasRequest('POST', `${BASE}/${encoded(transferNo)}/pallets/${encoded(palletId)}/generate`, payload)
}
export function bindWarehouseTransferPallet(transferNo, palletId, payload) {
return overseasRequest('POST', `${BASE}/${encoded(transferNo)}/pallets/${encoded(palletId)}/bind`, payload)
}
export function deleteWarehouseTransferPallet(transferNo, palletId) {
return overseasRequest('DELETE', `${BASE}/${encoded(transferNo)}/pallets/${encoded(palletId)}`)
}
export function uploadWarehouseTransferMeasurementPhoto(transferNo, filePath) {
return overseasUpload(`${BASE}/${encoded(transferNo)}/measurement-photos/files`, filePath)
}
export function listWarehouseTransferMeasurementPhotos(transferNo) {
return overseasRequest('GET', `${BASE}/${encoded(transferNo)}/measurement-photos/files`)
}
export function downloadWarehouseTransferMeasurementPhoto(transferNo, fileId) {
return overseasDownload(`${BASE}/${encoded(transferNo)}/measurement-photos/files/${encoded(fileId)}/download`)
}
export function downloadWarehouseTransferPalletLabel(labelDownloadPath) {
const path = String(labelDownloadPath || '').trim()
if (!path) return Promise.reject(new Error('服务端未返回托盘标签下载地址'))
return overseasDownload(path)
}
+61
View File
@@ -0,0 +1,61 @@
<template>
<view class="handover-card">
<view class="handover-heading"><text class="handover-title">发车与交接记录</text><button :disabled="loading" @tap="load">刷新</button></view>
<text v-if="loading">正在读取当前运输资源…</text>
<view v-else-if="error" class="handover-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>
<button @tap="load">重新读取</button>
</view>
<template v-else-if="status">
<text class="handover-line">车次 {{ status.tripNo }}</text>
<text class="handover-line">原始发车:{{ status.originalDeparture.vehiclePlate || '—' }} · {{ status.originalDeparture.driverName || '—' }}</text>
<view v-if="status.latestHandover" class="handover-record">
<text class="handover-line">最近交接 {{ status.latestHandover.handoverNo || '—' }}</text>
<text class="handover-line">{{ time(status.latestHandover.handedOverAt) }} · {{ status.latestHandover.location || '—' }}</text>
<text class="handover-line">{{ status.latestHandover.previous.vehiclePlate || '—' }} → {{ status.latestHandover.replacement.vehiclePlate || '—' }}</text>
<text class="handover-line">{{ status.latestHandover.previous.driverName || '—' }} → {{ status.latestHandover.replacement.driverName || '—' }}</text>
</view>
<text v-else class="handover-line">本排车暂无在途交接记录。</text>
<view v-for="delivery in status.deliveries" :key="delivery.deliveryNo" class="handover-delivery">
<text class="handover-line">{{ delivery.deliveryNo }} · 原实发 {{ delivery.packages }} 箱 · 剩余 {{ delivery.remainingPackages }} 箱 · {{ label(delivery) }}</text>
<text class="handover-line">{{ delivery.terminalAt ? '完成时承运' : '当前承运' }}:{{ delivery.transport.vehiclePlate || '—' }} · {{ delivery.transport.driverName || '—' }}</text>
<text v-if="delivery.transport.driverPhone" class="handover-line">联系电话 {{ delivery.transport.driverPhone }}</text>
</view>
<text class="handover-note">交接由后台确认。本页只读,不重复发车。</text>
</template>
</view>
</template>
<script>
import { getDispatchHandoverStatus } from '@/api/trip-handover.js'
import { toDispatchError } from '@/utils/dispatch-error.js'
export default {
props: { dispatchNo: { type: String, required: true }, recordVersion: { type: [String, Number], default: null } },
data() { return { status: null, error: null, loading: false, generation: 0 } },
computed: { scopeKey() { return `${this.dispatchNo}:${this.recordVersion ?? ''}` } },
watch: { scopeKey: { immediate: true, handler() { this.status = null; this.load() } } },
beforeUnmount() { this.generation++ },
methods: {
async load() {
const generation = ++this.generation
this.error = null; this.status = null
if (!this.dispatchNo) { this.loading = false; return }
this.loading = true
try { const result = await getDispatchHandoverStatus(this.dispatchNo); if (generation === this.generation) this.status = result }
catch (cause) { if (generation === this.generation) this.error = toDispatchError(cause, '交接记录读取失败') }
finally { if (generation === this.generation) this.loading = false }
},
time(value) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' },
label(delivery) {
if (delivery.terminalAt) return delivery.status === 'RETURNED' ? '运输已完成' : '已签收'
return { WAIT_DELIVERY: '派送中', RETURN_PENDING: '待退回' }[delivery.status] || '待核对'
}
}
}
</script>
<style scoped>
.handover-card{padding:28rpx;background:#fff;border-radius:20rpx;margin-top:20rpx;color:#334155;font-size:26rpx}
.handover-heading{display:flex;align-items:center;justify-content:space-between;gap:16rpx}.handover-title{font-size:30rpx;font-weight:600}
.handover-heading button{margin:0;font-size:26rpx}.handover-line{display:block;margin-top:12rpx;overflow-wrap:anywhere}
.handover-record,.handover-delivery{padding-top:16rpx;margin-top:16rpx;border-top:1px solid #e2e8f0}.handover-note{display:block;margin-top:20rpx;color:#64748b}
.handover-error{color:#b91c1c}.handover-error text{display:block;margin-top:12rpx}
</style>
+325
View File
@@ -0,0 +1,325 @@
<template>
<view class="pallet-work">
<view class="pallet-heading">
<text class="heading">托盘</text>
<button class="refresh-button" :disabled="busy" @tap="loadSources">刷新</button>
</view>
<view v-if="currentBindings.length">
<text class="hint">已绑定箱唛:选择后扫描新目标托盘可改绑(已排车或出库不可直接改绑)。</text>
<button v-for="binding in currentBindings" :key="binding.mark" class="binding-row" :disabled="busy" @tap="selectRebind(binding)"><text class="binding-mark">{{ binding.mark }}</text><text class="binding-arrow">→</text><text class="binding-pallet">{{ binding.pallet }}</text><text class="binding-action">重新绑定</text></button>
</view>
<view v-for="pallet in boundPalletTotals" :key="pallet.palletCode" class="hint"><text>{{ pallet.palletCode }} · {{ pallet.totalPackages }} 箱 · 体积 {{ pallet.volumeCbm == null ? '待补测' : pallet.volumeCbm + ' m³' }} · 重量 {{ pallet.grossWeightKg == null ? '待补测' : pallet.grossWeightKg + ' kg' }}</text></view>
<view v-for="pallet in measurements" :key="pallet.palletCode" class="measurement-card">
<text class="heading">{{ pallet.palletCode }} · {{ pallet.packages }} 箱</text>
<text class="hint">{{ pallet.inherited ? '原托盘货物未变,沿用原测量;包装变化请重新测量。' : '拆托、合托后请确认本托最终尺寸和毛重,原托盘剩余货物也需补测。' }}</text>
<text v-if="pallet.volumeCbm != null" class="hint">已确认:{{ pallet.volumeCbm }} m³ · {{ pallet.grossWeightKg }} kg</text>
<view class="measurement-inputs">
<label v-for="field in measurementFields" :key="field.key"><text>{{ field.label }}</text><input v-model="pallet[field.key]" type="digit" :disabled="busy" :placeholder="field.label" /></label>
</view>
<button :disabled="busy" @tap="saveMeasurement(pallet)">确认本托实测</button>
</view>
<view v-if="rebindSelection"><text class="hint">正在改绑:{{ rebindSelection.mark }},当前托盘:{{ rebindSelection.pallet }}</text><button :disabled="busy" @tap="rebindSelection = null; targetCode = ''; target = null">取消改绑</button></view>
<template v-if="!allBindingsComplete && !rebindSelection">
<view v-for="row in operableSourceRows" :key="row.positionId" class="source-row" :class="{ selected: selectedSource?.positionId === row.positionId }" @tap="selectSource(row)">
<view class="source-identifiers"><view v-if="!unboundSourceMode" class="source-identifier"><text class="source-caption">托盘号</text><text class="source-code">{{ row.palletCode }}</text></view><view v-else class="source-identifier"><text class="source-caption">来源箱</text><text class="source-code">{{ (row.cartonMarks || []).join('、') }}</text></view></view>
<text class="source-quantity">{{ isReturnBulk ? '件数' : '箱数' }}:{{ row.stockPackages }} · 可拆{{ isReturnBulk ? '件数' : '箱数' }}:{{ row.movablePackages }}</text>
</view>
<text v-if="!busy && !operableSourceRows.length" class="hint">当前没有可操作的来源托盘或来源箱</text>
<ScanTargetInput v-if="!isReturnBulk && !unboundSourceMode" v-model="sourceCode" name="relabel-source" label="实际来源托盘" placeholder="扫描实际来源托盘" :active="active === 'source'" @activate="activate('source')" @submit="confirmSourceScan" />
<ScanTargetInput v-if="unboundSourceMode" v-model="sourceCode" name="relabel-source-carton" label="来源箱唛" placeholder="扫描来源箱唛确认" :active="active === 'source'" @activate="activate('source')" @submit="confirmSourceScan" />
<text v-if="source" class="hint">来源已确认:{{ source.palletCode }};尚未拆出,请完成贴标登记、核对拆出数量并扫描目标托盘,绑定成功后才保存拆托明细。</text>
<view v-if="selectedSource">
<text v-if="isReturnBulk" class="hint">本次{{ wholeSource ? '整托换标数量' : '拆出件数' }}(可分多个来源托盘,逐批提交)</text>
<template v-if="isReturnBulk">
<text class="return-pallet-summary">{{ selectedSource.palletCode }}:{{ wholeSource ? '整托换标' : '部分拆箱' }} {{ quantity || 0 }} 件 → {{ selectedDestination || '目的仓待维护' }}</text>
<text v-if="wholeSource" class="hint">贴标数量等于本托盘全部 {{ selectedSource.stockPackages }} 件,无需填写拆箱数量;直接新增带目的仓的托盘标签并绑定。</text>
<template v-else-if="allowDisassemble"><input v-model="quantity" type="number" :disabled="busy" :placeholder="`请输入拆箱数量(应为 ${plannedPackages} 件)`" /><text class="hint">贴标数量少于本托盘库存,请按现场实际选择来源托盘并填写拆箱数量;本托盘应拆 {{ plannedPackages }} 件。</text></template>
<text v-else class="hint">退货换标按整托绑定,不支持拆箱;托盘仍会展示用于核对。</text>
<text class="hint">退货换标无需扫描箱唛。</text>
</template>
<template v-else><view class="split-quantity-row"><text>本次拆出箱数</text><input v-model="quantity" type="number" :disabled="busy" placeholder="箱数" @input="marks = []" /><text>箱</text></view></template>
</view>
<view class="split-progress"><template v-if="!isReturnBulk"><text>本次需换标拆 <strong>{{ requiredSplitPackages }}</strong> 箱</text><text>已拆 <strong>{{ splitPackages }}</strong> 箱</text></template><button class="create-pallet-button" :disabled="busy || !!newPallet" @tap="createPallet">新增托盘号</button></view>
</template>
<template v-if="!allBindingsComplete || rebindSelection">
<ScanTargetInput v-model="targetCode" name="relabel-target" label="目标托盘" placeholder="扫描同目的仓目标托盘或新托盘号" :active="active === 'target'" @activate="activate('target')" @submit="confirmTargetAndBind" />
<button v-if="canRetainOriginal" :disabled="busy" @tap="quantity = String(source.totalPackages); targetCode = source.palletCode; confirmTargetAndBind()">整托全部贴标,保留原托盘</button>
<button :disabled="busy || !targetCode.trim()" @tap="confirmTargetAndBind">{{ rebindSelection ? '确认改绑目标托盘' : '确认绑定目标托盘' }}</button>
<text v-if="labelBlockReason" class="hint">{{ labelBlockReason }}</text>
<text v-if="target" class="hint">已识别目标:{{ target.palletCode }},尚未绑定成功</text>
<view v-if="newPallet" class="hint"><text>托盘 {{ newPallet.palletCode }} 待绑定</text><button @tap="printPallet">查看 / 打印托盘标签</button></view>
</template>
<text v-if="message" class="hint">{{ message }}</text><text v-if="error" class="error">{{ error }}</text>
</view>
</template>
<script>
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { resolvePallet, createRelabelPallet, bindRelabelPallet, findRelabelSources, listRelabelPalletMeasurements, measureRelabelPallet } from '@/api/pallet.js'
import { warehouseIdempotencyKey, releaseWarehouseKey } from '@/stores/warehouse-work.js'
export default {
components: { ScanTargetInput },
props: { task: { type: Object, required: true }, allowDisassemble: { type: Boolean, default: true } },
emits: ['activate', 'busy', 'saved'],
data() { return { measurements: [], measurementFields: [{ key: 'lengthCm', label: '长 cm' }, { key: 'widthCm', label: '宽 cm' }, { key: 'heightCm', label: '高 cm' }, { key: 'grossWeightKg', label: '毛重 kg' }], palletTotals: {}, rebindSelection: null, active: 'source', sourceCode: '', targetCode: '', source: null, target: null, marks: [], busy: false, error: '', message: '', newPallet: null, sourceRows: [], selectedSource: null, quantity: '', selectedDestination: '' } },
mounted() { this.loadSources() },
computed: {
canRetainOriginal() { return !this.isReturnBulk && !this.unboundSourceMode && this.source && this.selectedSource && this.plannedPackages === Number(this.source.totalPackages) },
boundPalletTotals() { const codes = new Set(this.currentBindings.map(binding => binding.pallet)); return Object.values(this.palletTotals).filter(pallet => codes.has(pallet.palletCode) && !this.measurements.some(measured => measured.palletCode === pallet.palletCode)) },
allBindingsComplete() {
const required = (this.task.items || []).filter(item => Number(item.expectedPackages || 0) > 0 && (item.returnCartonId || ['CARTON_LABEL', 'CARTON_AND_SKU', 'RETURN_BULK_SKU_LABEL'].includes(item.operationType)))
const bound = new Set(this.currentBindings.map(binding => binding.mark))
return required.length > 0 && required.every(item => bound.has(item.cartonMark))
},
requiredSplitPackages() { return (this.task.items || []).filter(item => ['CARTON_LABEL', 'CARTON_AND_SKU'].includes(item.operationType)).reduce((sum, item) => sum + Number(item.expectedPackages || 0), 0) },
splitPackages() { const bound = new Set(this.currentBindings.map(binding => binding.mark)); return (this.task.items || []).filter(item => ['CARTON_LABEL', 'CARTON_AND_SKU'].includes(item.operationType) && bound.has(item.cartonMark)).reduce((sum, item) => sum + Number(item.expectedPackages || 0), 0) },
currentBindings() {
const latest = new Map()
for (const entry of this.task.history || []) {
if (entry.action === 'REWORK_ARCHIVE') latest.clear()
if (entry.action !== 'PALLET_BIND') continue
try {
const data = JSON.parse(entry.detail)
if (data.targetPallets?.length !== 1) continue
for (const mark of data.cartonMarks || []) latest.set(mark, { mark, pallet: data.targetPallets[0] })
} catch { /* Invalid history is not used to infer a binding. */ }
}
return [...latest.values()]
},
destinations() { return [...new Set((this.isReturnBulk ? this.sourceItems() : this.completedItems()).map(item => item.destinationFbaWarehouseCode).filter(Boolean))] },
eligibleItems() { return (this.isReturnBulk ? this.sourceItems() : this.completedItems()).filter(item => item.destinationFbaWarehouseCode === this.selectedDestination) },
isReturnBulk() { return this.task.operationType === 'RETURN_BULK_SKU_LABEL' },
returnCartonItems() { return (this.task.items || []).filter(item => item.returnCartonId && !this.currentBindings.some(binding => binding.mark === item.cartonMark)) },
operableSourceRows() { return this.sourceRows.filter(row => Number(row.movablePackages) > 0) },
plannedPackages() { return this.eligibleItems.reduce((sum, item) => sum + Number(item.expectedPackages || 0), 0) },
labelBlockReason() {
const pending = this.sourceItems().filter(item => Number(item.relabelledPackages || 0) < Number(item.expectedPackages || 1))
if (!pending.length) return ''
const missingSku = pending.some(item => item.operationType === 'CARTON_AND_SKU' && Number(item.skuProcessedPackages || 0) < Number(item.expectedPackages || 1))
if (missingSku) return '本托盘还有未完成的 SKU 贴标登记。混合换标须完成箱唛和 SKU 两项,请返回 SKU 页签处理后再确认绑定。'
return '本托盘还有未完成的换标明细,请先完成贴标登记,再确认目标托盘绑定。'
},
wholeSource() { return this.isReturnBulk && this.selectedSource && (!this.allowDisassemble || this.plannedPackages === Number(this.selectedSource.stockPackages || 0)) },
unboundSourceMode() { return !this.isReturnBulk && this.returnCartonItems.length > 0 && (this.selectedSource ? (!this.selectedSource.palletCode || this.selectedSource.palletCode === 'UNBOUND') : !this.operableSourceRows.some(row => row.palletCode && row.palletCode !== 'UNBOUND')) },
readyToBind() { return this.target && this.selectedSource && (this.unboundSourceMode || (this.source && this.samePallet(this.source, this.selectedSource) && (!this.samePallet(this.source, this.target) || this.canRetainOriginal))) && Number.isSafeInteger(Number(this.quantity)) && Number(this.quantity) > 0 && Number(this.quantity) <= this.selectedSource.movablePackages && this.bindingSelectionValid() }
},
methods: {
async saveMeasurement(pallet) {
const dimensions = Object.fromEntries(this.measurementFields.map(field => [field.key, Number(pallet[field.key])]))
if (!Object.values(dimensions).every(value => Number.isFinite(value) && value > 0)) { this.error = '请填写有效的长、宽、高和毛重'; return }
const payload = { relabelNo: this.task.relabelNo, palletCode: pallet.palletCode, stockVersion: pallet.stockVersion, ...dimensions }
const key = warehouseIdempotencyKey('relabel', 'measure-pallet', payload)
await this.run(async () => {
this.measurements = await measureRelabelPallet({ ...payload, idempotencyKey: key.value })
releaseWarehouseKey('relabel', key.signature)
this.message = `托盘 ${pallet.palletCode} 实测已保存`
this.$emit('saved')
})
},
savePalletTotals(result) { for (const pallet of result?.pallets || []) this.palletTotals = { ...this.palletTotals, [pallet.palletCode]: pallet } },
samePallet(left, right) {
if (!left || !right) return false
// 扫码枪可能在托盘号前后附带 CR/LF、零宽字符或全角空格;
// 这些字符不属于托盘号,不能让同一托盘被误判为不同托盘。
const code = value => String(value?.palletCode || '')
.replace(/[\u0000-\u001f\u007f\u200b-\u200d\u2060\ufeff]/g, '')
.replace(/[\u3000]/g, ' ')
.trim()
.toUpperCase()
const leftCode = code(left); const rightCode = code(right)
const sameCode = !!leftCode && leftCode === rightCode
if (sameCode) return true
if (left.palletId == null || right.palletId == null) return false
if (String(left.palletId) === String(right.palletId)) return true
return false
},
selectRebind(binding) {
if (this.busy) return
uni.showModal({ title: '重新绑定托盘', content: `箱唛 ${binding.mark} 当前在 ${binding.pallet}。请扫描新的目标托盘。`, showCancel: false })
this.rebindSelection = binding
this.targetCode = ''; this.target = null; this.message = ''; this.error = ''
this.activate('target')
},
async submitRebind() {
const binding = this.rebindSelection
const targetCode = this.targetCode.trim().toUpperCase()
if (!binding || !targetCode || this.busy) return
if (targetCode === binding.pallet.toUpperCase()) { this.showBindingMessage('无需改绑', '该箱唛已经绑定此托盘,不会重复扣库存。'); return }
this.busy = true; this.$emit('busy', true)
const confirmed = await new Promise(resolve => uni.showModal({ title: '确认改绑', content: `将 ${binding.mark} 从 ${binding.pallet} 移至 ${targetCode}?旧托盘扣箱,新托盘加箱。`, confirmText: '是', cancelText: '否', success: result => resolve(result.confirm), fail: () => resolve(false) }))
this.busy = false; this.$emit('busy', false)
if (!confirmed) return
await this.run(async () => {
const source = await resolvePallet(binding.pallet)
const target = await resolvePallet(targetCode)
const payload = { relabelNo: this.task.relabelNo, source: { palletCode: source.palletCode, expectedVersion: source.palletVersion }, target: { palletCode: target.palletCode, expectedVersion: target.palletVersion }, cartonMarks: [binding.mark], reason: 'PDA 已绑定箱唛确认改绑托盘' }
const key = warehouseIdempotencyKey('relabel', 'rebind-pallet', payload)
this.savePalletTotals(await bindRelabelPallet({ ...payload, idempotencyKey: key.value }))
releaseWarehouseKey('relabel', key.signature)
this.rebindSelection = null; this.targetCode = ''; this.target = null
this.$emit('saved')
this.showBindingMessage('绑定成功', `托盘 ${target.palletCode} 已绑定`)
})
if (this.error) this.showBindingMessage('改绑失败', this.error)
},
sourceItems() { return (this.task.items || []).filter(item => this.selectedSource?.cartonMarks.includes(item.cartonMark)) },
completedItems() { const bound = new Set(this.currentBindings.map(binding => binding.mark)); return this.sourceItems().filter(item => this.isReturnBulk ? Number(item.expectedPackages) > 0 && Number(item.relabelledPackages) >= Number(item.expectedPackages) : Number(item.relabelledPackages) >= 1 && !bound.has(item.cartonMark)) },
async loadSources() { await this.run(async () => { try { this.sourceRows = await findRelabelSources(this.task.relabelNo) } catch (error) { if (!this.isReturnBulk && this.returnCartonItems.length) this.sourceRows = []; else throw error } if (!this.isReturnBulk && this.returnCartonItems.length && !this.sourceRows.some(row => Number(row.movablePackages) > 0)) this.sourceRows = [{ positionId: this.returnCartonItems[0].returnCartonId, positionVersion: 0, palletCode: 'UNBOUND', stockPackages: this.returnCartonItems.length, movablePackages: this.returnCartonItems.length, cartonMarks: this.returnCartonItems.map(item => item.cartonMark).filter(Boolean) }]; for (const code of new Set(this.currentBindings.map(binding => binding.pallet))) { const pallet = await resolvePallet(code); this.savePalletTotals({ pallets: [pallet] }) } this.measurements = await listRelabelPalletMeasurements(this.task.relabelNo); this.selectedSource = null; this.source = null; this.marks = []; this.quantity = ''; if (this.unboundSourceMode && this.operableSourceRows.length === 1) { this.selectedSource = this.operableSourceRows[0]; this.selectedDestination = this.returnCartonItems[0]?.destinationFbaWarehouseCode || ''; this.marks = this.completedItems().map(item => item.cartonMark); this.quantity = String(this.marks.length); this.activate('target') } }) },
async selectSource(row) {
if (this.busy) return
this.rebindSelection = null; this.selectedSource = row; this.source = null; this.sourceCode = ''; this.quantity = ''; this.marks = []
this.selectedDestination = (this.isReturnBulk ? this.sourceItems() : this.completedItems())[0]?.destinationFbaWarehouseCode || ''
if (this.unboundSourceMode) {
this.marks = this.completedItems().map(item => item.cartonMark)
this.quantity = String(this.marks.length)
this.activate('target')
return
}
if (this.isReturnBulk) {
const items = this.eligibleItems
this.marks = items.map(item => item.cartonMark)
if (!this.allowDisassemble || items.reduce((sum, item) => sum + Number(item.expectedPackages || 0), 0) === Number(row.stockPackages || 0)) this.quantity = String(row.stockPackages)
await this.run(async () => { const value = await resolvePallet(row.palletCode); if (this.selectedSource?.positionId === row.positionId) { this.source = value; this.sourceCode = value.palletCode } })
this.activate(this.quantity ? 'target' : 'source')
return
}
this.activate('source')
},
bindingSelectionValid() {
const count = Number(this.quantity)
if (this.isReturnBulk) {
const packages = this.eligibleItems.reduce((sum, item) => sum + Number(item.expectedPackages || 0), 0)
return count === packages && this.completedItems().length === this.eligibleItems.length && this.marks.length === this.eligibleItems.length
&& this.marks.every(mark => this.eligibleItems.some(item => item.cartonMark === mark))
}
return this.marks.length === count && this.marks.every(mark => this.eligibleItems.some(item => item.cartonMark === mark))
},
fillBatch() { const count = Number(this.quantity); const packages = this.eligibleItems.reduce((sum, item) => sum + Number(item.expectedPackages || 0), 0); const invalid = !Number.isSafeInteger(count) || count <= 0 || count > this.selectedSource.movablePackages || (this.isReturnBulk ? count !== packages : count > this.eligibleItems.length); if (invalid) { this.error = this.isReturnBulk ? '拆出件数必须等于本批已完成贴标件数,且不能超过来源托盘可拆数量' : '拆出数量必须为正整数,且不超过来源托盘可拆箱数和已完成换标箱数'; return } this.marks = (this.isReturnBulk ? this.eligibleItems : this.eligibleItems.slice(0, count)).map(item => item.cartonMark); this.error = '' },
toggleMark(mark) { if (this.busy) return; this.marks = this.marks.includes(mark) ? this.marks.filter(value => value !== mark) : [...this.marks, mark] },
activate(value) { this.active = value; this.$emit('activate') },
handleScan(value) { if (this.busy) return; this[this.active + 'Code'] = value; return this.active === 'target' ? this.confirmTargetAndBind() : this.confirmSourceScan() },
async confirmBindingChoice(content) {
this.busy = true; this.$emit('busy', true)
try { return await new Promise(resolve => uni.showModal({ title: '是否绑定', content, confirmText: '是', cancelText: '否', success: result => resolve(!!result.confirm), fail: () => resolve(false) })) }
finally { this.busy = false; this.$emit('busy', false) }
},
async confirmSourceScan() {
if (this.busy || !this.sourceCode.trim()) return
const matched = this.unboundSourceMode
? this.operableSourceRows.find(row => (row.cartonMarks || []).some(mark => String(mark).trim().toUpperCase() === this.sourceCode.trim().toUpperCase()))
: this.operableSourceRows.find(row => this.samePallet(row, { palletCode: this.sourceCode }))
this.source = null
if (!matched) { this.error = '该托盘不在本单可操作的来源托盘列表中'; this.showBindingMessage('来源托盘不匹配', this.error); return }
if (this.unboundSourceMode) {
this.selectedSource = matched
this.selectedDestination = this.completedItems()[0]?.destinationFbaWarehouseCode || ''
this.marks = this.completedItems().map(item => item.cartonMark)
this.quantity = String(this.marks.length)
this.activate('target')
return
}
await this.resolve('source')
if (this.error || !this.source) return
const source = this.source; this.source = null
if (await this.confirmBindingChoice(`是否绑定来源托盘 ${source.palletCode}?`)) { this.source = source; this.activate('target') }
},
showBindingMessage(title, content) { uni.showModal({ title, content, showCancel: false, confirmText: '知道了' }) },
async confirmTargetAndBind() {
if (this.busy) return
if (this.rebindSelection) return this.submitRebind()
if (!this.isReturnBulk && this.selectedSource && this.samePallet(this.source, this.selectedSource)) {
if (!this.completedItems().length && this.labelBlockReason) { this.error = this.labelBlockReason; this.showBindingMessage('尚未绑定', this.error); return }
this.selectedDestination = this.completedItems()[0]?.destinationFbaWarehouseCode || ''
this.marks = []
this.fillBatch()
if (this.error) { this.showBindingMessage('尚未绑定', this.error); return }
}
this.message = ''
let reason = ''
if (!this.selectedSource) reason = '请先选择实际拆出货物的来源托盘或来源箱。'
else if (this.unboundSourceMode && !this.bindingSelectionValid()) reason = '请先完成来源箱换标,再绑定目标托盘。'
else if (!this.unboundSourceMode && !this.samePallet(this.source, this.selectedSource)) reason = this.isReturnBulk ? '来源托盘识别失败,请重新选择。' : '请先扫描并确认实际来源托盘;选择来源列表不等于扫描确认。'
else if (this.isReturnBulk && this.completedItems().length !== this.eligibleItems.length) reason = '请先完成本托盘的 SKU 贴标登记,再绑定新托盘;整托无需填写拆箱数量。'
else if (!Number.isSafeInteger(Number(this.quantity)) || Number(this.quantity) <= 0 || Number(this.quantity) > this.selectedSource.movablePackages) reason = this.isReturnBulk ? '本托盘可操作件数不足,请刷新来源托盘。' : '请填写有效的拆出箱数,不能超过来源托盘可拆数量。'
else if (!this.selectedDestination || !this.bindingSelectionValid()) reason = this.isReturnBulk ? `本托盘拆箱数量必须等于贴标预报分配数量 ${this.plannedPackages} 件。` : '本次拆出箱数必须对应已完成换标且尚未拆出的同目的仓箱唛。'
else if (!this.targetCode.trim()) reason = '请扫描或输入目标托盘号。'
if (reason) { this.error = reason; this.showBindingMessage('尚未绑定', reason); return }
await this.resolve('target')
if (this.error) { this.showBindingMessage('绑定失败', this.error); return }
if (!this.readyToBind) { this.error = '请核对来源箱、目标托盘和换标数量后重新确认。'; this.showBindingMessage('尚未绑定', this.error); return }
if (!await this.confirmBindingChoice(`是否绑定目标托盘 ${this.target.palletCode}(${this.quantity} ${this.isReturnBulk ? '件' : '箱'})?${this.samePallet(this.source, this.target) ? '整托保留原托盘且货物和包装未变可沿用原测量;包装变化请重新补测。' : ''}`)) { this.target = null; return }
await this.bind()
},
destination(mark) { return this.task.items.find(item => item.cartonMark === mark)?.destinationFbaWarehouseCode || '目的仓待维护' },
async run(work) { if (this.busy) return; this.busy = true; this.$emit('busy', true); this.error = ''; try { await work() } catch (error) { this.error = error?.message || '托盘作业失败,请重新核对'; if (['409', 'A0409'].includes(String(error?.code))) { this.source = null; this.target = null; this.selectedSource = null; this.marks = []; this.error += ';请刷新来源托盘并重新扫码'; } } finally { this.busy = false; this.$emit('busy', false) } },
async resolve(kind) {
const raw = String(this[kind + 'Code'] || '')
const code = raw.replace(/[\u0000-\u001f\u007f\u200b-\u200d\u2060\ufeff]/g, '').replace(/[\u3000]/g, ' ').trim()
if (!code) return
this[kind + 'Code'] = code
this[kind] = null
// 来源托盘以扫码结果为准:当页面当前选中的是另一张来源卡片时,
// 自动切换到扫码命中的来源,避免“已扫正确托盘但仍提示不匹配”。
if (kind === 'source') {
const normalized = value => String(value || '')
.replace(/[\u0000-\u001f\u007f\u200b-\u200d\u2060\ufeff]/g, '')
.replace(/[\u3000]/g, ' ').trim().toUpperCase()
const matched = this.sourceRows.find(row => normalized(row.palletCode) === normalized(code))
if (matched && this.selectedSource?.positionId !== matched.positionId) {
this.selectedSource = matched
this.selectedDestination = this.sourceItems()[0]?.destinationFbaWarehouseCode || ''
this.quantity = ''
this.marks = []
}
}
await this.run(async () => {
const value = await resolvePallet(code)
if (kind === 'source' && !this.samePallet(value, this.selectedSource)) throw new Error('请扫描所选原入仓单的实际来源托盘')
if (this[kind + 'Code'] === code) this[kind] = value
})
},
async createPallet() { const payload = { relabelNo: this.task.relabelNo }; const key = warehouseIdempotencyKey('relabel', 'new-pallet', payload); await this.run(async () => { this.newPallet = await createRelabelPallet({ ...payload, idempotencyKey: key.value }); this.target = null; this.targetCode = this.newPallet.palletCode || ''; this.activate('target') }) },
async bind() {
if (!this.readyToBind) { this.error = '请核对来源托盘、拆出箱数、本批箱唛和目标托盘'; return }
const expectation = value => ({ palletCode: value.palletCode, expectedVersion: value.palletVersion })
const payload = { relabelNo: this.task.relabelNo, ...(this.unboundSourceMode ? {} : { source: expectation(this.source), actualSource: { positionId: this.selectedSource.positionId, expectedPositionVersion: this.selectedSource.positionVersion, packages: Number(this.quantity) } }), target: expectation(this.target), cartonMarks: [...this.marks], reason: this.unboundSourceMode ? 'PDA 扫描无来源托盘退货箱并绑定目的托盘' : 'PDA 按原入仓单箱数拆托并按新目的仓合托' }
const key = warehouseIdempotencyKey('relabel', 'bind-pallet', payload)
let saved = false
await this.run(async () => { await bindRelabelPallet({ ...payload, idempotencyKey: key.value }); saved = true; releaseWarehouseKey('relabel', key.signature); const createdKey = warehouseIdempotencyKey('relabel', 'new-pallet', { relabelNo: this.task.relabelNo }); releaseWarehouseKey('relabel', createdKey.signature); this.message = `托盘 ${this.target.palletCode} 已绑定`; this.marks = []; this.source = null; this.target = null; this.newPallet = null; this.sourceCode = ''; this.targetCode = ''; this.$emit('saved'); this.activate('source') })
if (saved) { this.showBindingMessage('绑定成功', this.message); await this.loadSources() }
else if (this.error) this.showBindingMessage('绑定失败', this.error)
},
printPallet() {
// #ifdef H5
const view = window.open('', '_blank'); if (view) { view.document.write(this.newPallet.printableHtml); view.document.close(); view.focus(); view.print() } else this.error = '请允许打开标签打印窗口'
// #endif
// #ifndef H5
this.error = '当前设备需通过已适配的打印服务打印托盘标签;请先在后台打印此托盘号,不要虚报打印成功。'
// #endif
}
}
}
</script>
<style scoped>
.pallet-work{margin-top:18px;padding:14px;border:1px solid #d7e1ef;border-radius:10px}
.measurement-card{margin:12px 0;padding:12px;border:1px solid #d7e1ef;border-radius:8px}
.measurement-inputs{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:10px 0}
.measurement-inputs input{padding:8px;border:1px solid #d7e1ef;border-radius:6px}
.pallet-heading{display:flex;align-items:center;justify-content:space-between;gap:12px}
.heading{display:block;font-size:18px;font-weight:700}
.refresh-button{flex:none;margin:0;padding:6px 14px;font-size:13px;line-height:1.5}
.hint{display:block;font-size:13px;color:#52647c;margin:10px 0;white-space:normal}
.source-row{display:flex;align-items:center;gap:8px;padding:8px;margin:8px 0;border:1px solid #d7e1ef;border-radius:8px;white-space:nowrap}
.source-identifiers{flex:1;min-width:0}
.source-identifier{display:flex;align-items:baseline;gap:5px;max-width:100%;min-width:0}
.source-caption{flex-shrink:0;color:#718095;font-size:10px}
.source-code{min-width:0;color:#243951;font-size:11px;line-height:18px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.source-quantity{flex-shrink:0;color:#52647c;font-size:9px;line-height:16px}
.split-quantity-row{display:flex;align-items:center;gap:8px;margin:10px 0;color:#52647c;font-size:12px}.split-quantity-row input{box-sizing:border-box;width:72px;height:34px;padding:6px 8px;text-align:center;font-size:13px}
.split-progress{display:flex;align-items:center;flex-wrap:wrap;gap:8px 12px;margin:10px 0;color:#52647c;font-size:12px;line-height:20px}.split-progress strong{color:#1f5fca;font-weight:700}.split-progress .create-pallet-button{margin:0 0 0 auto;padding:0 10px;border-radius:6px;background:#22c55e;color:#fff;font-size:10px;font-weight:700;line-height:28px}.split-progress .create-pallet-button[disabled]{background:#bbf7d0;color:#6b987b}.split-progress .create-pallet-button::after{border:none}
.source-row.selected{border-color:#2674d9;background:#eff6ff}
.binding-row{display:flex;align-items:center;gap:6px;box-sizing:border-box;width:100%;min-width:0;padding:8px 9px;font-size:11px;line-height:20px;text-align:left;white-space:nowrap}.binding-mark{max-width:25%;overflow:hidden;text-overflow:ellipsis}.binding-arrow,.binding-action{flex-shrink:0}.binding-pallet{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}.binding-action{color:#1f5fca;font-size:10px}
.line{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin:8px 0}.line text{flex:1;overflow-wrap:anywhere}
input{padding:12px;background:#f3f6fa;border-radius:6px}button{font-size:13px;margin:8px 0}
.error{display:block;color:#bc3030;font-size:13px}
.return-pallet-summary{display:block;margin:10px 0;padding:12px;border-radius:8px;background:#eff6ff;color:#1f5fca;font-weight:700;overflow-wrap:anywhere}
</style>
+162
View File
@@ -0,0 +1,162 @@
<template>
<view>
<view class="work-card">
<text class="card-title">包裹/箱合箱</text>
<text v-if="plan" class="business-code">{{ plan.planNo }}</text>
<text v-if="plan && plan.remark" class="hint plan-remark">合箱备注:{{ plan.remark }}</text>
<view class="choice-row"><button class="secondary" @tap="choose('NEW')">{{ mode === 'NEW' ? '✓ ' : '' }}新增箱子</button><button v-if="!plan || plan.mode !== 'SKU'" class="secondary" @tap="choose('EXISTING')">{{ mode === 'EXISTING' ? '✓ ' : '' }}使用已有箱子</button></view>
<text class="hint">{{ mode === 'NEW' ? '从下方来源箱选择 SKU 和扣除数量,完成后生成新箱唛。' : '扫描来源箱,再扫描另一只箱作为目标;目标箱原有库存不扣除。' }}</text>
<template v-if="mode">
<ScanTargetInput name="source" v-model="sourceCode" label="来源箱唛" placeholder="扫描来源箱唛" :active="active === 'source'" @activate="active = 'source'" @submit="scanSource" />
<button class="secondary" :disabled="busy || !mode || !sourceCode.trim()" @tap="scanSource(sourceCode)">{{ !plan && mode === 'EXISTING' && sources.length ? '确认更换来源箱' : '确认来源箱' }}</button>
<view v-if="plan" class="plan-boxes section-gap">
<text class="card-title">计划需扫描箱子</text>
<view v-for="box in plannedSourceRows" :key="box.cartonMark" class="plan-box">
<text class="business-code">{{ box.cartonMark }} · {{ box.participation === 'FULL' ? '整箱' : box.participation === 'PARTIAL' ? '部分' : '计划来源' }}</text>
<text class="meta-line">来源退货单:{{ box.sourceReturnNo || '—' }}</text>
<text v-for="item in box.items || []" :key="item.skuCode" class="meta-line">{{ item.skuCode }} · {{ item.quantity }} 件</text>
<text v-for="item in box.originalItems || []" :key="'original-' + item.skuCode" class="meta-line">{{ item.skuCode }} · 原有 {{ item.quantity }} 件 · 计划后剩余 {{ target?.cartonMark === box.cartonMark ? item.quantity : Math.max(0, item.quantity - (box.items || []).filter(row => row.skuCode === item.skuCode).reduce((sum, row) => sum + Number(row.quantity), 0)) }} 件</text>
<text class="hint">{{ target?.cartonMark === box.cartonMark ? '目标箱 · 保留原有库存' : sources.some(source => source.cartonMark === box.cartonMark) ? '已扫描确认' : '待扫描' }}</text>
</view>
</view>
<view v-if="mode === 'EXISTING'" class="confirmed-sources">
<view v-for="box in sources" :key="box.cartonMark" class="item-card section-gap">
<view class="row-head"><text class="business-code">{{ box.cartonMark }}</text><button class="link-button" :disabled="busy" @tap="active = 'source'">更换来源箱</button></view>
<text v-for="item in box.items || []" :key="item.skuCode" class="meta-line">{{ item.skuCode }} · {{ item.quantity }} 件</text>
<text class="hint">来源箱已确认</text>
</view>
</view>
</template>
</view>
<view v-if="mode" class="work-card">
<text class="card-title">{{ mode === 'NEW' ? '新增目标箱' : '已有目标箱' }}</text>
<template v-if="mode === 'EXISTING'">
<ScanTargetInput name="target" v-model="targetCode" label="目标箱唛" placeholder="扫描另一只箱作为目标箱" :active="active === 'target'" @activate="active = 'target'" @submit="scanTarget" />
<button class="secondary" :disabled="busy || !sources.length || !targetCode.trim()" @tap="scanTarget(targetCode)">确认目标箱</button>
<view v-if="target" class="item-card section-gap"><text class="business-code">{{ target.cartonMark }}</text><text v-for="item in target.items" :key="item.skuCode" class="meta-line">{{ item.skuCode }} · 原有 {{ item.quantity }} 件(不扣除)</text></view>
</template>
<template v-else><view class="dimensions"><label v-for="field in fields" :key="field.key"><text>{{ field.label }}</text><input class="small-input" type="digit" v-model="dimensions[field.key]" placeholder="请输入" /></label></view><text class="hint">体积:{{ volume }} m³</text></template>
<view v-for="box in sources" :key="box.cartonMark" class="item-card section-gap">
<view class="row-head"><text class="business-code">{{ box.cartonMark }}</text><button v-if="!plan" class="link-button" @tap="remove(box.cartonMark)">移除</button></view>
<button v-if="!plan" class="secondary" @tap="printSource(box.cartonMark)">补打包裹/箱标签</button>
<view v-for="item in box.items" :key="item.skuCode" class="source-quantity"><text class="meta-line">{{ item.skuCode }} · {{ plan ? '计划取出' : '可扣' }} {{ item.quantity }} 件</text><input class="small-input" type="number" :disabled="!!plan" :value="deduction(box.cartonMark,item.skuCode)" placeholder="扣除数量" @input="setDeduction(box.cartonMark,item.skuCode,$event.detail.value)" /></view>
</view>
<button class="primary" :disabled="!canComplete" @tap="complete">{{ busy ? '处理中…' : '确认合箱' }}</button>
</view>
</view>
</template>
<script>
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { scanReturnCarton, completePackingPlan, mergeReturnBoxes, getReturnCartonLabel } from '@/api/return-packing.js'
import { updateWarehouseWork, warehouseWork, warehouseIdempotencyKey, releaseWarehouseKey } from '@/stores/warehouse-work.js'
export default {
components: { ScanTargetInput },
props: { plan: { type: Object, default: null }, initialSource: { type: Object, default: null } },
emits: ['completed', 'error', 'busy-change'],
data() { return { mode:'', dimensions:{length:'',width:'',height:'',weight:''}, fields:[{key:'length',label:'长 cm'},{key:'width',label:'宽 cm'},{key:'height',label:'高 cm'},{key:'weight',label:'重量 kg'}], sourceCode: '', targetCode: '', sources: [], target: null, deductions: {}, active: 'source', busy: false } },
computed: { plannedSourceRows() { const listed = Array.isArray(this.plan?.sources) ? this.plan.sources : []; const marks = this.plan?.sourceCartonMarks?.length ? this.plan.sourceCartonMarks : listed.map(box => box.cartonMark).filter(Boolean); return marks.map(mark => listed.find(box => box.cartonMark === mark) || { cartonMark: mark, items: [] }) }, volume() { const value = Number(this.dimensions.length) * Number(this.dimensions.width) * Number(this.dimensions.height) / 1000000; return Number.isFinite(value) && value > 0 ? value.toFixed(6) : '0.000000' }, canComplete() { return !this.busy && (!this.plan || (this.plan.sourceCartonMarks || this.plan.sources.map(box => box.cartonMark)).every(mark => this.sources.some(box => box.cartonMark === mark) || this.target?.cartonMark === mark)) && this.plannedItems().length > 0 && (this.mode === 'NEW' ? this.fields.every(f => Number(this.dimensions[f.key]) > 0) : !!this.target && this.target.cartonMark === this.targetCode.trim() && (!!this.plan || this.sources[0]?.cartonMark === this.sourceCode.trim())) } },
async mounted() {
if (!this.plan && this.initialSource) { this.choose('NEW'); this.sources = [this.initialSource]; this.sourceCode = ''; this.active = 'source'; return }
this.choose('NEW')
this.targetCode = warehouseWork.returnPacking.targetCode || ''
const marks = this.plan?.sourceCartonMarks || warehouseWork.returnPacking.sourceCartonMarks || []
if (this.plan) {
// Reserved plan sources are read from the reviewed plan, never from the free-stock scan endpoint.
this.sources = []
this.sourceCode = ''
this.active = 'source'
} else for (const mark of marks) await this.scanSource(mark)
},
methods: {
choose(mode) { if(this.busy || this.mode === mode) return; const current = !this.plan ? (this.sources[0] || this.initialSource) : null; this.mode=mode; this.sources=current ? [current] : []; this.target=null; this.targetCode=''; this.sourceCode=mode === 'EXISTING' && current ? current.cartonMark : ''; this.deductions={}; this.active=mode === 'EXISTING' && current ? 'target' : 'source' },
save() { updateWarehouseWork('returnPacking', { sourceCartonMarks: this.sources.map(box => box.cartonMark), targetCode: this.targetCode }) },
remove(mark) { this.sources = this.sources.filter(box => box.cartonMark !== mark); this.save() },
async scanSource(raw) {
if (!this.mode || this.busy) return
const mark = String(raw || this.sourceCode).trim(); if (!mark) return
await this.run(async () => {
if (this.sources.some(box => box.cartonMark === mark)) {
if (this.mode === 'EXISTING') { this.sourceCode = mark; this.active = 'target'; return }
throw new Error('该来源箱已添加')
}
if ((this.plan || this.mode !== 'EXISTING') && this.target?.cartonMark === mark) throw new Error('来源箱不能与目标箱相同')
const planned = this.plan?.sources?.find(box => box.cartonMark === mark)
if(this.plan && !planned) throw new Error('该来源箱不在合箱计划中')
const box = planned ? {...planned,customerId:this.plan.customerId} : await scanReturnCarton(mark)
const customer = this.sources[0]?.customerId || this.target?.customerId
if (customer && customer !== box.customerId) throw new Error('不同客户的箱不能合并')
if (this.mode === 'EXISTING' && !this.plan) {
this.sources = [box]; this.sourceCode = mark; this.deductions = {}; this.target = null; this.targetCode = ''
} else { this.sources.push(box); this.sourceCode = '' }
if (this.plan) this.deductions = { ...this.deductions, [mark]: Object.fromEntries((box.items || []).map(item => [item.skuCode, Number(item.quantity)])) }
this.save(); this.active = this.mode === 'NEW' ? 'source' : 'target'
})
},
async scanTarget(raw) {
if (this.busy || !this.sources.length || this.mode !== 'EXISTING') return
const mark = String(raw || this.targetCode).trim(); if (!mark) return
this.targetCode = mark; this.target = null
await this.run(async () => {
if(this.sources.some(box => box.cartonMark === mark)) throw new Error('来源箱和目标箱不能相同,请扫描另一只箱')
const plannedSource = this.plan?.sources?.find(box => box.cartonMark === mark)
if (plannedSource) {
// A reviewed BOX plan may nominate one of its original boxes as the
// target. The remaining planned boxes are the source boxes.
this.target = {...plannedSource,customerId:this.plan.customerId}
this.targetCode = mark
this.deductions = Object.fromEntries(this.sources.map(box => [box.cartonMark, Object.fromEntries((box.items || []).map(item => [item.skuCode, Number(item.quantity)]))]))
this.save()
return
}
if (this.sources.some(box => box.cartonMark === mark)) throw new Error('目标箱不能与来源箱相同')
const box = await scanReturnCarton(mark)
const customer = this.plan?.customerId || this.sources[0]?.customerId
if (customer && customer !== box.customerId) throw new Error('目标箱与来源箱必须属于同一客户')
this.target = box; this.save()
})
},
acceptScan(value) { if (!this.active) return; return this.active === 'source' ? this.scanSource(value) : this.scanTarget(value) },
async printSource(mark) {
// #ifdef H5
const tab = window.open('', '_blank')
if (!tab) { this.$emit('error', new Error('请允许打开标签打印窗口')); return }
try { const label = await getReturnCartonLabel(mark); tab.document.write(label.printableHtml); tab.document.close(); tab.focus(); tab.print() }
catch (error) { tab.close(); this.$emit('error', error) }
// #endif
// #ifndef H5
this.$emit('error', new Error('当前设备未配置打印服务'))
// #endif
},
deduction(mark, sku) { return this.deductions?.[mark]?.[sku] || '' },
setDeduction(mark, sku, raw) {
if (this.plan) return
const box = this.sources.find(item => item.cartonMark === mark)
const max = Number(box?.items?.find(item => item.skuCode === sku)?.quantity || 0)
const value = Math.min(max, Math.max(0, Math.floor(Number(raw) || 0)))
this.deductions = { ...this.deductions, [mark]: { ...(this.deductions[mark] || {}), [sku]: value } }
},
plannedItems() { return this.sources.flatMap(box => (box.items || []).map(item => ({ skuCode: item.skuCode, quantity: Number(this.deduction(box.cartonMark, item.skuCode)) || 0 }))).filter(item => item.quantity > 0) },
async complete() {
if (!this.canComplete) return
const sourceDeductions=this.sources.flatMap(box => (box.items || []).map(item => ({cartonMark:box.cartonMark,skuCode:item.skuCode,quantity:Number(this.deduction(box.cartonMark,item.skuCode))||0}))).filter(item=>item.quantity>0)
const totals={}; for(const item of sourceDeductions) totals[item.skuCode]=(totals[item.skuCode]||0)+item.quantity
const items=Object.entries(totals).map(([skuCode,quantity])=>({skuCode,quantity}))
const box={items,...(this.mode==='NEW'?Object.fromEntries(this.fields.map(f=>[f.key,Number(this.dimensions[f.key])])):{targetCartonMark:this.target.cartonMark})}
const payload = { sourceCartonMarks: this.sources.map(box => box.cartonMark), targetCartonMark: this.target?.cartonMark,sourceDeductions,...box }
const key = warehouseIdempotencyKey('returnPacking', 'box-merge', { ...payload, planNo: this.plan?.planNo })
await this.run(async () => {
const result = this.plan ? await completePackingPlan(this.plan.planNo, { boxes: [box], sourceDeductions, idempotencyKey: key.value }) : await mergeReturnBoxes({ ...payload, idempotencyKey: key.value })
releaseWarehouseKey('returnPacking', key.signature); this.$emit('completed', { ...result, targetMode: this.mode })
})
},
async run(work) { this.busy = true; this.$emit('busy-change', true); try { await work() } catch (error) { this.$emit('error', error) } finally { this.busy = false; this.$emit('busy-change', false) } }
}
}
</script>
<style scoped lang="scss">
@import '../pages/warehouse/operations.scss';
.business-code{font-size:13px}.item-card{padding:8px}.secondary,.primary{min-height:36px}.source-quantity{display:flex;align-items:center;justify-content:space-between;gap:8px}.small-input{width:90px;height:30px;padding:0 6px;border:1px solid #cbd8e8;border-radius:6px;text-align:center;font-size:11px}.plan-remark{display:block;color:#315985;font-weight:600}.choice-row{display:flex;gap:8px}.choice-row button{flex:1}.dimensions{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}
.dimensions{gap:8px 12px;padding:8px 0}.dimensions label{display:flex;align-items:center;gap:6px;min-width:0}.dimensions label text{flex-shrink:0;width:44px;font-size:11px;color:#617287}.dimensions .small-input{box-sizing:border-box;width:100%;min-width:0;height:30px;background:#f8fbff;font-size:12px}.choice-row button{height:34px;min-height:34px;line-height:32px;margin:4px 0;padding:0 8px;font-size:11px}.plan-box{padding:7px 0;border-bottom:1px solid #e6edf5}.source-quantity .meta-line{flex:1;min-width:0;overflow-wrap:anywhere}.source-quantity .small-input{flex-shrink:0;width:72px}.primary{height:36px;line-height:36px;padding:0 10px}.business-code{overflow-wrap:anywhere}
</style>
@@ -0,0 +1,31 @@
<template>
<view class="label-confirmation">
<text v-if="confirmed">标签已扫码确认,可按该包裹/箱号出库</text>
<template v-else>
<ScanTargetInput v-model="code" label="确认已贴标签" placeholder="贴好后扫描此包裹/箱标签" @submit="confirm" />
<button class="secondary" :disabled="busy || !code.trim()" @tap="confirm(code)">确认贴标</button>
<text v-if="error">{{ error }}</text>
</template>
</view>
</template>
<script>
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { confirmReturnLabel } from '@/api/return-packing.js'
export default {
components: { ScanTargetInput },
props: { cartonMark: { type: String, required: true } },
data() { return { code: '', confirmed: false, busy: false, error: '' } },
methods: {
acceptScan(value) { if (String(value).trim() === this.cartonMark) return this.confirm(value) },
async confirm(raw) {
if (this.busy || this.confirmed) return
const mark = String(raw?.detail?.value ?? raw ?? this.code).trim()
if (mark !== this.cartonMark) { this.error = '请扫描当前包裹/箱的标签'; return }
this.busy = true; this.error = ''
try { await confirmReturnLabel(mark); this.confirmed = true }
catch (error) { this.error = error?.message || '贴标确认失败,请重试' }
finally { this.busy = false }
}
}
}
</script>
+41
View File
@@ -0,0 +1,41 @@
<template>
<view class="section-gap">
<text class="card-title">换标商品独立合箱</text><text class="hint">部分换标须装入独立新箱;来源余量留在原包裹/箱。所有来源登记成功后才可生成新箱。</text>
<view v-for="row in deductions" :key="`${row.cartonMark}:${row.skuCode}`" class="meta-line">{{ row.cartonMark }} · {{ row.skuCode }} · {{ row.quantity }} 件</view>
<template v-if="!facts.length"><view class="dimensions"><label v-for="field in fields" :key="field.key"><text>{{ field.label }}</text><input v-model="dimensions[field.key]" type="digit" placeholder="请输入" /></label></view><button class="primary" :disabled="!ready" @tap="pack">{{ busy ? '生成中…' : '生成独立新箱及标签' }}</button></template>
<view v-for="fact in facts" :key="fact.cartonMark"><text class="business-code">新箱:{{ fact.cartonMark }} · {{ fact.quantity }} 件</text><button class="secondary" @tap="print(fact.label)">查看 / 打印新箱识别标签</button><text class="hint">此标签用于识别仓库包裹/箱;商品目标 SKU 标签请按上方换标明细核对。</text></view>
</view>
</template>
<script>
import { packReturnRelabelCartons } from '@/api/relabel.js'
import { requiresReturnPack, returnDeductions, returnPackFacts, returnSelections } from '@/utils/return-relabel.js'
import { warehouseIdempotencyKey, releaseWarehouseKey } from '@/stores/warehouse-work.js'
export default {
props: { task: { type: Object, required: true }, locked: { type: Boolean, default: false } }, emits: ['saved', 'error', 'busy', 'refresh'],
data() { return { busy: false, dimensions: { length: '', width: '', height: '', weight: '' }, fields: [{ key: 'length', label: '长 cm' }, { key: 'width', label: '宽 cm' }, { key: 'height', label: '高 cm' }, { key: 'weight', label: '重量 kg' }] } },
computed: {
deductions() { return returnDeductions(this.task) }, facts() { return returnPackFacts(this.task) },
ready() { const items = returnSelections(this.task); return !this.locked && !this.busy && requiresReturnPack(this.task) && !this.facts.length && items.length === this.task.items.length && items.length > 0 && items.every(item => Number(item.relabelledPackages) === Number(item.expectedPackages) && !Number(item.skippedPackages) && !Number(item.exceptionPackages)) && this.deductions.every(row => row.cartonMark && row.skuCode && Number.isSafeInteger(row.quantity) && row.quantity > 0) && this.fields.every(field => Number.isFinite(Number(this.dimensions[field.key])) && Number(this.dimensions[field.key]) > 0) }
},
methods: {
async pack() {
if (!this.ready) return
const payload = { sourceCartonMarks: [...new Set(this.deductions.map(row => row.cartonMark))], targetCartonMark: null, sourceDeductions: this.deductions, ...Object.fromEntries(this.fields.map(field => [field.key, Number(this.dimensions[field.key])])) }
const key = warehouseIdempotencyKey('relabel', 'return-pack', { relabelNo: this.task.relabelNo, round: this.task.reviewedAt || 'INITIAL', ...payload })
this.busy = true; this.$emit('busy', true)
try { const result = await packReturnRelabelCartons(this.task.relabelNo, { ...payload, idempotencyKey: key.value }); releaseWarehouseKey('relabel', key.signature); this.$emit('saved', result) } catch (error) { if (['409', 'A0409', 'B0409'].includes(String(error?.code || ''))) { releaseWarehouseKey('relabel', key.signature); this.$emit('refresh') } this.$emit('error', error) } finally { this.busy = false; this.$emit('busy', false) }
},
print(label) {
// #ifdef H5
if (!label?.printableHtml) return this.$emit('error', new Error('服务端未返回可打印的新箱标签'))
const tab = window.open('', '_blank'); if (!tab) return this.$emit('error', new Error('请允许打开标签打印窗口'))
tab.document.write(label.printableHtml); tab.document.close(); tab.focus(); tab.print()
// #endif
// #ifndef H5
this.$emit('error', new Error('当前设备未配置打印服务,生成标签不代表打印完成'))
// #endif
}
}
}
</script>
<style scoped>.dimensions{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;margin:12px 0}.dimensions input{border:1px solid #cbd8e8;padding:8px}.hint,.meta-line{display:block;margin:8px 0}</style>
+10 -26
View File
@@ -1,29 +1,13 @@
const runtimeEnvironment = import.meta.env || {}
const configuredAuthApiRoot = runtimeEnvironment.VITE_AUTH_API_BASE_URL || ''
const configuredOverseasApiRoot = runtimeEnvironment.VITE_OVERSEAS_API_BASE_URL || ''
const development = Boolean(runtimeEnvironment.DEV)
let selectedAuthApiRoot = configuredAuthApiRoot || '/api'
let selectedOverseasApiRoot = configuredOverseasApiRoot || '/overseas-api'
const nativeRuntime = typeof plus !== 'undefined'
|| (typeof location !== 'undefined' && location.protocol === 'file:')
// H5 must stay same-origin and use Vite's CORS-safe proxy; App builds keep the absolute API roots above.
// #ifdef H5
selectedAuthApiRoot = runtimeEnvironment.VITE_H5_AUTH_API_BASE_URL || '/api'
selectedOverseasApiRoot = runtimeEnvironment.VITE_H5_OVERSEAS_API_BASE_URL || '/overseas-api'
const development = Boolean(import.meta.env?.DEV)
let selectedApiRoot = ''
// #ifndef H5
selectedApiRoot = import.meta.env?.VITE_API_BASE_URL || ''
// #endif
// HBuilderX/App 基座 can expose a file:// location; never allow a relative API
// root there, even if a stale H5 bundle or compiler flag was reused.
if (nativeRuntime) {
selectedAuthApiRoot = configuredAuthApiRoot || selectedAuthApiRoot
selectedOverseasApiRoot = configuredOverseasApiRoot || selectedOverseasApiRoot
}
export const API_ROOT = String(selectedAuthApiRoot).replace(/\/$/, '')
export const OVERSEAS_API_ROOT = String(selectedOverseasApiRoot).replace(/\/$/, '')
// #ifdef H5
selectedApiRoot = import.meta.env?.VITE_H5_API_BASE_URL || '/gateway-api'
// #endif
export const API_ROOT = String(selectedApiRoot).replace(/\/$/, '')
export const DEV_LOGIN = Object.freeze({
username: development ? String(runtimeEnvironment.VITE_PDA_DEV_USERNAME || '') : '',
password: development ? String(runtimeEnvironment.VITE_PDA_DEV_PASSWORD || '') : ''
username: development ? String(import.meta.env?.VITE_PDA_DEV_USERNAME || '') : '',
password: development ? String(import.meta.env?.VITE_PDA_DEV_PASSWORD || '') : ''
})
+7 -4
View File
@@ -1,19 +1,22 @@
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: '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: '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: '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' },
{ key: 'pallet', name: '托盘管理', nameKey: 'modules.pallet.name', icon: '托', iconType: 'map', description: '扫码查询、组托、拆托、换托及解绑', descriptionKey: 'modules.pallet.description', available: true, route: '/pages/pallet/pallet' }
] },
{ key: 'relabel', name: '换标作业', nameKey: 'modules.relabel.name', icon: '换', description: '换标收货、执行与复核', descriptionKey: 'modules.relabel.description', operations: [
{ key: 'relabelReceive', name: '换标收货', nameKey: 'modules.relabelReceive.name', icon: '收', iconType: 'download', description: '扫描换标单并确认待处理库存', descriptionKey: 'modules.relabelReceive.description', available: true, route: '/pages/relabel/relabel?op=relabelReceive' },
{ key: 'relabel', name: '换标作业', nameKey: 'modules.relabel.name', icon: '换', description: '换标列表、执行与复核', descriptionKey: 'modules.relabel.description', operations: [
{ key: 'relabelReceive', name: '换标列表', nameKey: 'modules.relabelReceive.name', icon: '列', iconType: 'list', description: '选择换标单并直接进入执行', descriptionKey: 'modules.relabelReceive.description', available: true, route: '/pages/relabel/relabel?op=relabelReceive' },
{ key: 'relabelExecute', name: '换标执行', nameKey: 'modules.relabelExecute.name', icon: '换', iconType: 'compose', description: '扫描旧标和新标记录换标结果', descriptionKey: 'modules.relabelExecute.description', available: true, route: '/pages/relabel/relabel?op=relabelExecute' },
{ key: 'relabelReview', name: '换标复核', nameKey: 'modules.relabelReview.name', icon: '核', iconType: 'checkbox', description: '复核标签和商品关联关系', descriptionKey: 'modules.relabelReview.description', available: true, route: '/pages/relabel/relabel?op=relabelReview' }
] },
+8 -3
View File
@@ -2,8 +2,8 @@
"name" : "柏悦物流 PDA 海外版",
"appid" : "__UNI__936D079",
"description" : "柏悦物流海外仓 PDA 作业端",
"versionName" : "0.0.2",
"versionCode" : 2,
"versionName" : "0.0.4",
"versionCode" : 4,
"transformPx" : false,
"app-plus" : {
"usingComponents" : true,
@@ -80,8 +80,13 @@
},
"vueVersion" : "3",
"h5" : {
"router" : {
"mode" : "hash"
},
"devServer" : {
"port" : 5176
}
}
},
"locale" : "zh-Hans",
"fallbackLocale" : "zh-Hans"
}
+2
View File
@@ -4,7 +4,9 @@
{ "path": "pages/home/home", "style": { "navigationStyle": "custom", "navigationBarTitleText": "海外仓工作台", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/module/module", "style": { "navigationStyle": "custom", "navigationBarTitleText": "海外仓业务", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/operation/operation", "style": { "navigationStyle": "custom", "navigationBarTitleText": "柜号入库与现场作业", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/warehouse-transfer/warehouse-transfer", "style": { "navigationStyle": "custom", "navigationBarTitleText": "仓储转运测量", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/return-receipt/return-receipt", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货入库", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/return-counting/return-counting", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货箱内清点", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/return-packing/return-packing", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货合箱", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/pallet/pallet", "style": { "navigationStyle": "custom", "navigationBarTitleText": "托盘管理", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/stocktake/stocktake", "style": { "navigationStyle": "custom", "navigationBarTitleText": "库存盘点", "backgroundColor": "#F5F7FA" } },
+64 -32
View File
@@ -5,13 +5,14 @@
<view class="portal-content fulfillment-content">
<view class="fulfillment-heading">
<text class="fulfillment-title">排车拣货与发车</text>
<text class="fulfillment-subtitle">客户与当前海外仓由登录令牌和服务端确定;扫描排车单号或任务中的托盘码开始作业</text>
<text class="fulfillment-subtitle">扫描排车单号打开任务,再扫描任务中的托盘码进行拣货</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 class="fulfillment-card">
<ScanTargetInput name="dispatch" :model-value="scanCode" label="排车单号或托盘码" placeholder="扫描排车单号 / 托盘码" :active="true" @update:modelValue="updateScanCode" @submit="handleScan" />
<ScanTargetInput name="dispatch" :model-value="scanCode" label="排车单号 / 托盘码 / 来源箱唛" placeholder="扫描排车单号、托盘码或退货箱唛" :active="true" @update:modelValue="updateScanCode" @submit="handleScan" />
<button class="secondary-button" :disabled="!scanCode || loading || busy" @tap="handleScan(scanCode)">{{ current ? '匹配托盘' : '打开排车任务' }}</button>
<text class="scan-help">网络失败不会清空草稿,再次提交会沿用同一幂等键。</text>
<text class="scan-help">网络中断会保留当前作业,恢复后可重试。</text>
</view>
<view v-if="loading" class="fulfillment-state"><text>正在加载排车任务…</text></view>
@@ -20,33 +21,36 @@
<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>
<text v-if="error.network" class="offline-note">网络连接失败,当前作业已保留,请恢复网络后重试。</text>
<button class="secondary-button" :disabled="loading || busy" @tap="retry">重试</button>
</view>
<template v-if="!loading && !current">
<view class="list-toolbar"><text>待处理排车任务</text><button :disabled="loading" @tap="loadTasks">刷新</button></view>
<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.dispatchNo" class="task-card" @tap="openTask(task.dispatchNo)">
<view class="task-head"><text class="business-code">{{ task.dispatchNo || '—' }}</text><text class="status-badge">{{ statusLabel(task.status) }}</text></view>
<text class="task-meta">任务号 {{ task.taskNo || '—' }} · 车牌 {{ task.vehiclePlate || '—' }}</text>
<view class="task-head"><view class="task-code-row"><text class="business-code">{{ task.dispatchNo || '—' }}</text><text v-if="task.dispatchNo === recentTaskNo" class="recent-badge">上次作业</text></view><text class="status-badge">{{ statusLabel(task.status) }}</text></view>
<text class="task-meta">任务号 {{ task.taskNo || '—' }} · {{ task.status === 'DEPARTED' ? '原发车车牌' : '车牌' }} {{ task.vehiclePlate || '—' }}</text>
<text class="task-enter">查看任务详情 ›</text>
</button>
</view>
<view v-else-if="!error" class="fulfillment-state"><text class="state-title">暂无排车拣货任务</text><text>可刷新列表,或直接扫描排车单号恢复作业。</text></view>
</template>
<template v-if="current">
<DispatchHandoverStatus v-if="departed" :dispatch-no="current.dispatchNo" :record-version="current.tripRecordVersion" />
<view class="fulfillment-card summary-card">
<view class="task-head"><text class="business-code">{{ current.dispatchNo || '—' }}</text><text class="status-badge">{{ statusLabel(current.status) }}</text></view>
<text class="task-meta">任务号 {{ current.taskNo || '—' }} · 车牌 {{ current.vehiclePlate || '—' }}</text>
<text class="task-meta">任务号 {{ current.taskNo || '—' }} · {{ departed ? '原发车车牌' : '车牌' }} {{ current.vehiclePlate || '—' }}</text>
<text v-if="!current.canPick && !pickingCompleted" class="gate-warning">{{ current.pickBlockReason || '请先通过调度审核再拣货' }}</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>
<button class="link-button" :disabled="busy" @tap="closeTask">返回任务列表</button>
</view>
<view v-if="current.items.length" class="item-list">
<view v-for="item in current.items" :key="item.palletNo" class="fulfillment-card item-card" :class="{ selected: selectedItem?.palletNo === item.palletNo }" @tap="selectPallet(item)">
<view v-for="item in current.items" :key="item.dispatchItemId" class="fulfillment-card item-card" :class="{ selected: selectedItem?.dispatchItemId === item.dispatchItemId }" @tap="selectPallet(item)">
<view class="task-head"><text class="business-code">{{ item.palletNo || '未关联托盘码' }}</text><text>{{ item.pickedPackages }}/{{ item.scheduledPackages }} 箱</text></view>
<text class="task-meta">{{ routeLabel(item) }}</text>
<text v-for="carton in item.returnCartons" :key="carton.cartonMark" class="task-meta">{{ carton.cartonMark }} · {{ carton.status === 'OUTBOUNDED' ? '已拣货' : '待扫描' }}</text>
<view class="progress"><view :style="{ width: `${itemProgress(item)}%` }"></view></view>
</view>
</view>
@@ -54,7 +58,8 @@
<view v-if="selectedItem && selectedRemaining > 0" class="fulfillment-card">
<text class="card-title">本次拣货:{{ selectedItem.palletNo }}</text>
<view class="count-row"><text>拣货箱数</text><input type="number" :value="packages" :placeholder="`请输入 1-${selectedRemaining} 箱`" @input="updatePackages" /><text>最多 {{ selectedRemaining }} 箱</text></view>
<text v-if="selectedItem.returnCartons.length" class="task-meta">{{ selectedReturnCarton ? `已扫描 ${selectedReturnCarton.cartonMark}` : '请扫描此托盘上的来源箱唛,每次拣货一箱' }}</text>
<view v-else 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>
@@ -66,12 +71,14 @@
</view>
<view v-if="pickingCompleted" class="fulfillment-card gate-card">
<text class="card-title">发车门禁</text>
<text class="card-title">整车发车</text>
<text v-if="current.tripNo" class="task-meta">车次 {{ current.tripNo }} · 共 {{ current.tripDispatchCount }} 张排车单</text>
<text v-if="current.driverWarning && !departed" class="gate-warning">{{ current.driverWarning }}</text>
<text v-if="!allPicked" class="gate-warning">服务端仍显示 {{ remainingPackages }} 箱未拣,不允许发车。</text>
<text v-else-if="departed" class="gate-success">该排车任务已执行发车,不可重复提交。</text>
<text v-else-if="!current.canDepart" class="gate-warning">{{ current.departBlockReason || '当前排车暂不满足发车条件,请刷新后重试。' }}</text>
<text v-else class="gate-note">拣货已完成且全部箱数核平,可以发车。</text>
<button v-if="!departed" class="depart-button" :disabled="!canDepart || busy" @tap="depart">{{ busyAction === 'depart' ? '发车中…' : '确认发车' }}</button>
<text v-else class="gate-note">本车所有排车单已就绪,确认后将一并发车。</text>
<button v-if="!departed" class="depart-button" :disabled="!canDepart || busy" @tap="depart">{{ busyAction === 'depart' ? '发车中…' : '确认整车发车' }}</button>
</view>
</template>
@@ -84,7 +91,8 @@
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { completeDispatchPicking, departDispatch, getDispatchTask, listDispatchTasks, pickDispatchItem } from '@/api/fulfillment.js'
import DispatchHandoverStatus from '@/components/DispatchHandoverStatus.vue'
import { completeDispatchPicking, departDispatch, getDispatchTask, listDispatchTasks, pickDispatchItem, tripDepartureRequest } from '@/api/fulfillment.js'
import { fulfillment, fulfillmentIdempotencyKey, releaseFulfillmentKey, restoreFulfillmentDraft, updateFulfillmentDraft } from '@/stores/fulfillment.js'
import { requireSession, session } from '@/stores/session.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
@@ -94,30 +102,39 @@
const finishedStatuses = ['COMPLETED', 'PICKING_COMPLETED', 'READY_TO_DEPART', 'DEPARTED']
export default {
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, tasks: [], current: null, scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '' } },
components: { PortalHeader, ScanTargetInput, DispatchHandoverStatus },
data() { return { user: session.currentUser, tasks: [], current: null, scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '', bootstrapped: false, resumeOnEntry: false } },
computed: {
recentTaskNo() { return fulfillment.dispatch.currentNo || '' },
packages() { return fulfillment.dispatch.packages },
selectedItem() { return this.current?.items?.find((item) => item.palletNo === fulfillment.dispatch.palletCode) || null },
selectedItem() { return this.current?.items?.find((item) => fulfillment.dispatch.dispatchItemId ? item.dispatchItemId === fulfillment.dispatch.dispatchItemId : item.palletNo === fulfillment.dispatch.palletCode) || null },
selectedReturnCarton() { return this.selectedItem?.returnCartons?.find((carton) => carton.cartonMark === fulfillment.dispatch.cartonCode && carton.status === 'OUTBOUND_PENDING') || null },
selectedPosition() { return this.selectedItem?.positions?.find((item) => item.availablePackages > 0) || this.selectedItem?.positions?.[0] || null },
selectedRemaining() { return Math.max(0, Math.min(this.selectedItem?.scheduledPackages - this.selectedItem?.pickedPackages || 0, this.selectedPosition?.availablePackages || 0)) },
selectedRemaining() { const remaining = this.selectedItem?.scheduledPackages - this.selectedItem?.pickedPackages || 0; return Math.max(0, this.selectedItem?.returnCartons?.length ? remaining : Math.min(remaining, this.selectedPosition?.availablePackages || 0)) },
plannedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.scheduledPackages, 0) },
pickedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.pickedPackages, 0) },
remainingPackages() { return Math.max(0, this.plannedPackages - this.pickedPackages) },
allPicked() { return this.plannedPackages > 0 && this.remainingPackages === 0 },
pickingCompleted() { return finishedStatuses.includes(this.current?.status) },
departed() { return this.current?.status === 'DEPARTED' },
canPick() { return this.selectedPosition && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining && !this.pickingCompleted },
canPick() { return this.current?.canPick === true && (this.selectedItem?.returnCartons?.length ? Boolean(this.selectedReturnCarton) : Boolean(this.selectedPosition) && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining) && !this.pickingCompleted },
canCompleteStage() { return Boolean(this.current) && !this.pickingCompleted },
canComplete() { return this.plannedPackages > 0 && this.remainingPackages === 0 },
canComplete() { return this.current?.canPick === true && this.plannedPackages > 0 && this.remainingPackages === 0 },
canDepart() { return this.pickingCompleted && this.allPicked && this.current?.canDepart === true && !this.departed }
},
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 (fulfillment.dispatch.currentNo) this.openTask(fulfillment.dispatch.currentNo)
if (!this.bootstrapped) {
this.bootstrapped = true
if (this.resumeOnEntry && fulfillment.dispatch.currentNo) this.openTask(fulfillment.dispatch.currentNo)
else this.loadTasks()
return
}
if (this.current?.dispatchNo) this.openTask(this.current.dispatchNo)
else this.loadTasks()
},
onHide() { this.stopScanner() },
@@ -130,26 +147,36 @@
async loadTasks() { await this.run('加载排车任务', async () => { this.tasks = await listDispatchTasks(); this.current = null }, true) },
async openTask(dispatchNo) {
const code = String(dispatchNo || '').trim(); if (!code) return
await this.run('恢复排车任务', async () => { const previousNo = fulfillment.dispatch.currentNo; this.current = await getDispatchTask(code); updateFulfillmentDraft('dispatch', { currentNo: this.current.dispatchNo, palletCode: previousNo === this.current.dispatchNo ? fulfillment.dispatch.palletCode : '' }); this.scanCode = '' }, true)
await this.run('恢复排车任务', async () => { const previousNo = fulfillment.dispatch.currentNo; this.current = await getDispatchTask(code); const same = previousNo === this.current.dispatchNo; updateFulfillmentDraft('dispatch', { currentNo: this.current.dispatchNo, palletCode: same ? fulfillment.dispatch.palletCode : '', dispatchItemId: same ? fulfillment.dispatch.dispatchItemId : '', cartonCode: same ? fulfillment.dispatch.cartonCode : '' }); this.scanCode = '' }, true)
},
closeTask() { this.current = null; this.scanCode = ''; this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { currentNo: '', palletCode: '', packages: 1 }); this.loadTasks() },
closeTask() { this.current = null; this.scanCode = ''; this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { currentNo: '', palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1 }); this.loadTasks() },
handleScan(raw) {
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim(); if (!code || this.busy || this.loading) return
console.info('[Overseas PDA dispatch] scan', { code, task: this.current?.dispatchNo || '' })
this.scanCode = code
this.error = null; this.successMessage = ''
updateFulfillmentDraft('dispatch', { palletCode: '', dispatchItemId: '', 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)
const item = this.current.items.find((value) => [value.palletNo, value.fbaWarehouseCode].includes(code))
if (!item) return this.setError(new Error('扫码未匹配当前排车任务的托盘或稳定业务码'), '扫码未匹配')
const cartonItem = this.current.items.find((item) => item.returnCartons?.some((carton) => carton.cartonMark === code))
if (cartonItem) {
if (cartonItem.returnCartons.find((carton) => carton.cartonMark === code)?.status !== 'OUTBOUND_PENDING') return this.setError(new Error('该箱已拣货或不可出库'), '扫码未匹配')
this.selectPallet(cartonItem); updateFulfillmentDraft('dispatch', { cartonCode: code, packages: 1 }); this.scanCode = ''; playSuccessSound(); return
}
const matches = this.current.items.filter((value) => value.palletNo === code && value.scheduledPackages > value.pickedPackages)
if (matches.length > 1) return this.setError(new Error('此托盘包含多条待拣明细,请选择对应货物卡片'), '请选择拣货明细')
const item = matches[0]
if (!item) return this.setError(new Error(this.current.items.some((value) => value.palletNo === code) ? '该托盘已完成拣货,请勿重复操作' : '扫码未匹配当前任务的托盘码或来源箱唛'), '扫码未匹配')
this.selectPallet(item); this.scanCode = ''; playSuccessSound()
},
selectPallet(item) { if (!item?.palletNo || item.scheduledPackages <= item.pickedPackages) return; updateFulfillmentDraft('dispatch', { palletCode: item.palletNo, packages: Math.max(1, Math.min(item.scheduledPackages - item.pickedPackages, item.positions?.[0]?.availablePackages || 1)) }) },
selectPallet(item) { if (!item?.palletNo || item.scheduledPackages <= item.pickedPackages) return; this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { palletCode: item.palletNo, dispatchItemId: item.dispatchItemId, cartonCode: '', packages: Math.max(1, Math.min(item.scheduledPackages - item.pickedPackages, item.positions?.[0]?.availablePackages || 1)) }) },
async submitPick() {
if (!this.canPick) return
const payload = { dispatchNo: this.current.dispatchNo, palletNo: this.selectedItem.palletNo, positionId: this.selectedPosition.id, packages: Number(this.packages), productQuantity: 0 }
const pick = this.selectedReturnCarton ? { positionId: null, cartonMark: this.selectedReturnCarton.cartonMark, packages: 1, productQuantity: this.selectedReturnCarton.productQuantity } : { positionId: this.selectedPosition.id, packages: Number(this.packages), productQuantity: 0 }
const payload = { dispatchNo: this.current.dispatchNo, dispatchItemId: this.selectedItem.dispatchItemId, ...pick }
const key = fulfillmentIdempotencyKey('dispatch', 'pick', payload)
await this.run('提交排车拣货', async () => {
await pickDispatchItem(this.current.dispatchNo, this.selectedItem.dispatchItemId, { positionId: this.selectedPosition.id, packages: Number(this.packages), productQuantity: 0, idempotencyKey: key.value })
this.current = await getDispatchTask(this.current.dispatchNo); releaseFulfillmentKey('dispatch', key.signature); updateFulfillmentDraft('dispatch', { palletCode: '', packages: 1 }); this.successMessage = '本次排车拣货已记录'; playSuccessSound()
await pickDispatchItem(this.current.dispatchNo, this.selectedItem.dispatchItemId, { ...pick, idempotencyKey: key.value })
this.current = await getDispatchTask(this.current.dispatchNo); releaseFulfillmentKey('dispatch', key.signature); updateFulfillmentDraft('dispatch', { palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1 }); this.successMessage = '本次排车拣货已记录'; playSuccessSound()
})
},
async completePicking() {
@@ -159,8 +186,13 @@
},
async depart() {
if (!this.canDepart) return
const key = fulfillmentIdempotencyKey('dispatch', 'depart', { dispatchNo: this.current.dispatchNo })
await this.run('执行发车', async () => { const result = await departDispatch(this.current.dispatchNo, { idempotencyKey: key.value, remark: '海外 PDA 拣货核平后发车' }); this.current = { ...this.current, status: result?.status || 'DEPARTED' }; releaseFulfillmentKey('dispatch', key.signature); this.successMessage = '排车已发车'; playSuccessSound() })
const key = fulfillmentIdempotencyKey('dispatch', 'depart', { tripNo: this.current.tripNo, recordVersion: this.current.tripRecordVersion })
await this.run('执行整车发车', async () => {
const result = await departDispatch(this.current.dispatchNo, tripDepartureRequest(this.current, key.value))
if (result?.status !== 'IN_TRANSIT' || result.tripNo !== this.current.tripNo || !result.dispatches?.some(item => item.dispatchNo === this.current.dispatchNo)) throw new Error('发车结果未包含当前车次和排车单,请刷新核对')
this.current = await getDispatchTask(this.current.dispatchNo)
releaseFulfillmentKey('dispatch', key.signature); this.successMessage = `车次 ${result.tripNo} 已整车发车`; playSuccessSound()
})
},
async retry() { if (this.current?.dispatchNo) await this.openTask(this.current.dispatchNo); else if (fulfillment.dispatch.currentNo) await this.openTask(fulfillment.dispatch.currentNo); 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('发车') ? 'depart' : action.includes('完成') ? 'complete' : 'pick' } try { await work() } catch (error) { this.setError(error, `${action}失败`) } finally { this.loading = false; this.busy = false; this.busyAction = '' } },
+9
View File
@@ -21,13 +21,22 @@
.offline-note { margin-top: 7px; color: #8a5a00; }
.list-toolbar, .task-head, .count-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.list-toolbar { margin: 16px 2px 10px; color: #233951; font-size: 13px; font-weight: 800; }
.list-title, .list-count, .detail-kicker, .detail-title, .task-enter { display: block; }
.list-count { margin-top: 2px; color: #8795a7; font-size: 10px; font-weight: 500; }
.list-toolbar button { min-height: 32px; margin: 0; padding: 0 12px; border-radius: 7px; color: #135ee8; font-size: 11px; line-height: 32px; background: #e9f2ff; }
.task-list, .item-list { display: flex; flex-direction: column; gap: 9px; }
.task-card { box-sizing: border-box; width: 100%; min-height: 76px; margin: 0; padding: 13px; border: 1px solid #dce5f0; border-radius: 11px; text-align: left; background: #fff; }
.task-card::after { border: 0; }
.task-code-row { display: flex; min-width: 0; align-items: center; gap: 7px; }
.business-code { color: #16304d; font-size: 15px; font-weight: 800; overflow-wrap: anywhere; }
.recent-badge { flex-shrink: 0; padding: 3px 6px; border-radius: 99px; color: #8a570c; font-size: 9px; line-height: 14px; background: #fff1dd; }
.status-badge { flex-shrink: 0; padding: 4px 7px; border-radius: 99px; color: #165fbf; font-size: 10px; background: #e6f0ff; }
.task-meta { margin-top: 6px; color: #718095; font-size: 10px; line-height: 16px; }
.task-enter { margin-top: 8px; color: #135ee8; font-size: 10px; font-weight: 700; text-align: right; }
.detail-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 4px 0 10px; padding: 10px 12px; border: 1px solid #bed4f6; border-radius: 11px; background: #eef5ff; }
.detail-kicker { color: #6580a4; font-size: 9px; }
.detail-title { margin-top: 2px; color: #153d74; font-size: 14px; font-weight: 800; }
.detail-toolbar button { flex-shrink: 0; min-height: 34px; margin: 0; padding: 0 11px; border-radius: 8px; color: #135ee8; font-size: 10px; line-height: 34px; background: #fff; }
.summary-card { display: block; }
.metrics { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); margin-top: 12px; padding: 10px 0; border-radius: 9px; background: #f5f8fc; }
.metrics view { display: flex; align-items: center; flex-direction: column; gap: 3px; color: #718095; font-size: 9px; }
+32 -25
View File
@@ -13,7 +13,7 @@
<view v-if="candidateError" class="error-card candidate-error-card"><text class="error-message">{{ candidateError.message || '待卸货集装箱加载失败' }}</text><text v-if="candidateError.code">code={{ candidateError.code }}</text><text v-if="candidateError.requestId">requestId={{ candidateError.requestId }}</text><text v-if="candidateError.traceId">traceId={{ candidateError.traceId }}</text><button class="secondary-button" :disabled="candidateLoading" @tap="loadCandidates">重新加载</button></view>
<view v-if="candidateLoading && !candidates.length" class="state-card"><uni-load-more status="loading" /><text>正在刷新待卸货集装箱…</text></view>
<view v-else-if="!candidateError && !candidates.length" class="empty-card"><uni-icons type="info" color="#8090a3" size="34" /><text>暂无待卸货集装箱</text><button class="secondary-button" :disabled="candidateLoading" @tap="loadCandidates">重新加载</button></view>
<view v-for="item in candidates" :key="item.containerNo" class="container-card container-card-compact" :class="{ 'container-card-disabled': !item.operable }" role="button" :aria-label="`${item.containerNo},${item.operable ? '查看可操作柜详情' : '查看未齐全柜摘要'}`" hover-class="container-card-pressed" @tap="openContainer(item)">
<view v-for="item in candidates" :key="candidateKey(item)" class="container-card container-card-compact" :class="{ 'container-card-disabled': !item.operable }" role="button" :aria-label="`${item.containerNo},${item.operable ? '查看可操作柜详情' : '查看未齐全柜摘要'}`" hover-class="container-card-pressed" @tap="openContainer(item)">
<view class="card-head"><view><text class="container-no">{{ item.containerNo }}</text><text class="candidate-arrival">计划到仓:{{ formatPlannedArrival(item.plannedArrivalAt) }}</text></view><view class="container-card-actions"><text class="status-tag" :class="item.operable ? statusTone(item.status) : 'status-disabled'">{{ item.operable ? statusLabel(item.status) : '待齐全' }}</text><text class="container-detail-link">{{ item.operable ? (item.currentAccount ? '继续' : '详情') : '只读' }} ›</text></view></view>
<view class="candidate-facts"><view><strong>{{ item.totalOrderCount }}</strong><text>总订单</text></view><view><strong>{{ item.inboundCodeCount }}</strong><text>入仓码</text></view><view><strong>{{ item.planCount }}</strong><text>已生成计划</text></view><view><strong>{{ item.pendingPlanCount }}</strong><text>未操作</text></view></view>
<text class="candidate-progress-copy">已生成 {{ item.planCount }} 个入库计划,{{ item.pendingPlanCount }} 个未操作<template v-if="item.missingInboundCodeCount">,其中 {{ item.missingInboundCodeCount }} 个缺入仓码</template></text>
@@ -47,7 +47,7 @@
<view class="card-head"><view><text class="card-title">国内装柜照片(只读)</text><text class="container-meta">现场对照国内装柜过程与封柜状态</text></view><text class="status-tag status-good">{{ domesticPhotoCount }} / 5</text></view>
<view class="domestic-photo-grid">
<view v-for="photo in domesticLoadingPhotos" :key="photo.evidenceType" class="domestic-photo-item" @tap="previewDomesticPhoto(photo)">
<image v-if="photo.localPath" class="domestic-photo-thumb" :src="photo.localPath" mode="aspectFill" />
<image v-if="photo.localPath" class="domestic-photo-thumb" :src="photo.localPath" mode="aspectFill" @error="invalidatePhotoPreview(photo)" />
<view v-else class="domestic-photo-placeholder"><uni-load-more v-if="photo.status === 'loading'" status="loading" :show-text="false" /><uni-icons v-else type="image" color="#8090a3" size="22" /><text>{{ photo.status === 'error' ? '加载失败,点按重试' : '正在准备照片' }}</text></view>
<text>{{ photo.label }}</text>
</view>
@@ -58,7 +58,7 @@
<view class="photo-grid">
<view v-for="(step, index) in photoSteps" :key="step.role" class="photo-role" :class="{ complete: photoSlots[index]?.fileId }">
<view class="photo-preview" :class="{ failed: photoSlots[index]?.status === 'error' }" @tap="previewPhoto(index)">
<image v-if="photoSlots[index]?.localPath" class="photo-thumb" :src="photoSlots[index].localPath" mode="aspectFill" />
<image v-if="photoSlots[index]?.localPath" class="photo-thumb" :src="photoSlots[index].localPath" mode="aspectFill" @error="invalidatePhotoPreview(photoSlots[index])" />
<view v-else class="photo-placeholder"><uni-icons type="camera" color="#8090a3" size="23" /><text>未添加</text></view>
</view>
<view class="photo-role-info">
@@ -76,10 +76,10 @@
<template v-else-if="phase === 'receive'">
<ReceivingSessionSummary :session="current" />
<view class="photo-card domestic-photo-card domestic-photo-collapsible">
<view class="card-head" @tap="domesticPhotosExpanded = !domesticPhotosExpanded"><view><text class="card-title">国内装柜照片</text><text class="container-meta">已同步 {{ domesticPhotoCount }} / 5,点按{{ domesticPhotosExpanded ? '收起' : '展开' }}对照</text></view><text class="container-detail-link">{{ domesticPhotosExpanded ? '收起' : '查看' }} ›</text></view>
<view class="card-head" @tap="toggleDomesticPhotos"><view><text class="card-title">国内装柜照片</text><text class="container-meta">已同步 {{ domesticPhotoCount }} / 5,点按{{ domesticPhotosExpanded ? '收起' : '展开' }}对照</text></view><text class="container-detail-link">{{ domesticPhotosExpanded ? '收起' : '查看' }} ›</text></view>
<view v-if="domesticPhotosExpanded" class="domestic-photo-grid">
<view v-for="photo in domesticLoadingPhotos" :key="photo.evidenceType" class="domestic-photo-item" @tap.stop="previewDomesticPhoto(photo)">
<image v-if="photo.localPath" class="domestic-photo-thumb" :src="photo.localPath" mode="aspectFill" />
<image v-if="photo.localPath" class="domestic-photo-thumb" :src="photo.localPath" mode="aspectFill" @error="invalidatePhotoPreview(photo)" />
<view v-else class="domestic-photo-placeholder"><uni-load-more v-if="photo.status === 'loading'" status="loading" :show-text="false" /><uni-icons v-else type="image" color="#8090a3" size="22" /><text>{{ photo.status === 'error' ? '加载失败,点按重试' : '正在准备照片' }}</text></view>
<text>{{ photo.label }}</text>
</view>
@@ -102,7 +102,7 @@
<text class="reservation-hint">扫码确认服务端已预留的托盘及库位;合托提示不改变当前分配。</text>
<ScanTargetInput name="pallet" v-model="palletInput" label="托盘码" placeholder="扫描或人工输入托盘码" :active="activeTarget === 'pallet'" @activate="activeTarget = 'pallet'" @submit="resolvePallet" />
<button class="secondary-button" :disabled="busy || !palletInput.trim()" @tap="resolvePallet(palletInput)">校验托盘</button>
<view v-if="resolvedPallet" class="resolved-card"><text>{{ resolvedPallet.palletCode }}</text><text>{{ resolvedPallet.compatible ? '预留托盘校验通过,可收货' : '托盘不匹配' }}</text></view>
<view v-if="resolvedPallet" class="resolved-card"><text>{{ resolvedPallet.palletCode }}</text><text>{{ resolvedPallet.compatible ? '预留托盘校验通过,可收货' : '托盘不匹配' }}</text><text v-if="resolvedPallet.locationCode">库位:{{ resolvedPallet.locationCode }}{{ resolvedPallet.locationType === 'SHARED_OVERFLOW' ? '(A0共享溢出位)' : '' }}</text><text v-else>库位:待现场确认</text></view>
<template v-if="activeInbound.destinationType === 'FBA'">
<text class="section-label">3. 输入该托累计箱数</text>
<input v-model.number="packageCount" class="quantity-input" type="number" inputmode="numeric" :placeholder="`该托当前累计箱数`" />
@@ -110,7 +110,7 @@
<button class="primary-button receipt-button" :disabled="!canSubmitReceipt || busy" @tap="submitReceipt">{{ activeInbound.destinationType === 'FBA' ? '保存该托累计箱数' : '确认本箱收货' }}</button>
</template>
</view>
<view v-if="lastReceipt" class="success-card">本次已收 {{ lastReceipt.acceptedPackages }} 箱,{{ lastReceipt.inboundCode }} 剩余 {{ lastReceipt.pendingPackages }} 箱</view>
<view v-if="lastReceipt" class="success-card">本次已收 {{ lastReceipt.acceptedPackages }} 箱,整柜剩余 {{ current.pendingPackages }} 箱</view>
<view v-if="canFinishOnsite" class="sticky-action one"><button class="primary-button complete-button" :disabled="busy || activeTarget === 'quantity' || scanQueue.length" @tap="completeContainer">{{ allInboundsReconciled ? '完成整柜入库' : '现场收货结束(标记差异)' }}</button></view>
</template>
@@ -139,11 +139,13 @@
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { assertReceivingWarehouse, isNoCurrentReceiving, isReceivingConflict, RECEIVING_PHOTO_ROLES, toReceivingError } from '@/api/receiving-adapter.js'
import { applyWarehouseProfile, session, requireSession } from '@/stores/session.js'
import { idempotencyKey, receiving, receivingPhase, receivingProgress, releaseIdempotencyKey, resetReceivingDraft, setCurrentReceiving } from '@/stores/receiving.js'
import { idempotencyKey, receiving, receivingPhase, receivingProgress, releaseIdempotencyKey, resetReceivingDraft, restoreReceivingPhoto, setCurrentReceiving } from '@/stores/receiving.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
import { completeCurrentReceiving, downloadCurrentDomesticLoadingPhoto, downloadCurrentUnloadPhoto, getCurrentReceiving, getCurrentWarehouseProfile, getReceivingContainer, listReceivingCandidates, prepareReceivingContainer, receiveCurrentBulk, receiveCurrentCarton, receiveCurrentWholePallet, resolveCurrentCarton, resolveCurrentPallet, signCurrentReceiving, uploadCurrentUnloadPhoto } from '@/api/inbound.js'
const MAX_UNLOAD_PHOTO_BYTES = 10 * 1024 * 1024
const MAX_PACKAGES_PER_PALLET = 66
const PALLET_CAPACITY_MESSAGE = '一个托盘最多只能收66箱,请拆分到其他托盘'
export default {
components: { PortalHeader, ReceivingSessionSummary, ScanTargetInput },
@@ -164,10 +166,11 @@
pageTitle() { return { candidates: '待卸货集装箱', detail: '柜号详情', sign: '拆柜拍照签收', receive: '当前柜收货', completed: '入库完成' }[this.phase] },
pageSubtitle() { return { candidates: '选择集装箱后拍摄 3 张照片完成签收', detail: '核对柜号与整柜计划箱数', sign: '完成现场取证后进入收货', receive: '扫描箱码定位入仓码后开始收货', completed: '库存与柜级进度已由服务端核平' }[this.phase] }
},
onLoad(options) { this.moduleKey = options.module || 'query'; const key = options.op || (this.moduleKey === 'inbound' ? 'receiving' : 'inventoryQuery'); this.operationName = ({ receiving: '拆柜收货', stocktake: '库存盘点', move: '商品移库', pallet: '托盘管理', relabelReceive: '换标收货', relabelExecute: '换标执行', relabelReview: '换标复核', inventoryQuery: '库存查询', locationQuery: '库位查询', palletQuery: '托盘查询' })[key] || '库存查询' },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.startScanner(); if (this.inboundMode && (!this.bootstrapped || this.phase === 'candidates')) this.bootstrap() },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { clearTimeout(this._toastTimer); this.stopScanner() },
onLoad(options) { this.moduleKey = options.module || 'query'; const key = options.op || (this.moduleKey === 'inbound' ? 'receiving' : 'inventoryQuery'); this.operationName = ({ receiving: '拆柜收货', stocktake: '库存盘点', move: '商品移库', pallet: '托盘管理', relabelReceive: '换标列表', relabelExecute: '换标执行', relabelReview: '换标复核', inventoryQuery: '库存查询', locationQuery: '库位查询', palletQuery: '托盘查询' })[key] || '库存查询' },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this._pageActive = true; this.startScanner(); if (this.inboundMode && (!this.bootstrapped || this.phase === 'candidates' || this._resumeReceiving)) { if (!this.busy) { this._resumeReceiving = false; this.bootstrap() } } },
onHide() { this.suspendReceiving() }, onUnload() { this.suspendReceiving() }, beforeUnmount() { this.suspendReceiving() },
methods: {
candidateKey(item) { return `${item?.sourceContainerBookingId || ''}:${item?.containerNo || ''}` },
statusLabel(status) { return ({ WAIT_SIGN: '待签收', SIGNED: '已签收', RECEIVING: '收货中', COMPLETED: '已完成', COMPLETED_WITH_DIFFERENCE: '差异待核平' })[status] || status },
statusTone(status) { return status === 'RECEIVING' ? 'status-current' : status === 'COMPLETED' ? 'status-good' : 'status-wait' },
formatPlannedArrival(value) { const text = String(value || '').trim(); return text ? text.replace('T', ' ').slice(0, 16) : '待确认' },
@@ -177,30 +180,34 @@
async refreshBoundWarehouse() { this.user = applyWarehouseProfile(await getCurrentWarehouseProfile()); return this.user },
photoStateLabel(photo) { if (photo?.status === 'uploading') return '正在上传…'; if (photo?.status === 'error') return photo.errorMessage || (photo.fileId ? '重拍失败,已保留原图' : '上传失败,请重试'); return photo?.fileId ? '点击左侧照片可查看大图' : '请拍摄现场照片' },
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleHardwareScan(value), onError: (error) => { this.error = error; this.showToast('硬件扫码接收器异常,请检查扫描服务', 'warning') } }) }, stopScanner() { if (typeof this._stopScanner === 'function') this._stopScanner(); this._stopScanner = null },
handleHardwareScan(value) { const scan = { value: String(value || '').trim(), target: this.activeTarget }; if (!scan.value) return; if (!this.inboundMode) return this.showToast('该功能缺少海外 PDA 专用接口,扫码未提交', 'warning'); if (this.phase === 'candidates') return this.showToast('请直接点击待卸货集装箱进入详情', 'warning'); if (this.phase !== 'receive') return; if (this.busy || this.activeTarget === 'quantity') { if (this.scanQueue.length >= 100) return this.showToast('扫码队列已满,请先处理当前托盘', 'warning'); this.scanQueue.push(scan); return this.showToast(`已排队 ${this.scanQueue.length} 个扫码`, 'warning') } return this.dispatchHardwareScan(scan) },
dispatchHardwareScan(scan) { console.info('[Overseas PDA scan] dispatch', { value: scan.value, target: scan.target }); if (scan.target === 'pallet' && this.activeInbound) { this.palletInput = scan.value; return this.resolvePallet(scan.value) } this.cartonInput = scan.value; return this.resolveCarton(scan.value) },
drainScanQueue() { if (this.busy || this.activeTarget === 'quantity' || !this.scanQueue.length || this.phase !== 'receive') return; const next = this.scanQueue.shift(); this.dispatchHardwareScan(next) },
async run(action, work) { this.busy = true; this.error = null; try { const result = await work(); this.showToast(`${action}成功`, 'success'); return result } catch (error) { const mappedError = toReceivingError(error, `${action}失败`); this.error = mappedError; playErrorSound(); this.showToast(mappedError.message, 'error'); throw mappedError } finally { this.busy = false; setTimeout(() => this.drainScanQueue(), 0) } },
handleHardwareScan(value) { if (!this._pageActive || this.loading) return; const scan = { value: String(value || '').trim(), target: this.activeTarget }; if (!scan.value) return; if (!this.inboundMode) return this.showToast('该功能缺少海外 PDA 专用接口,扫码未提交', 'warning'); if (this.phase === 'candidates') return this.showToast('请直接点击待卸货集装箱进入详情', 'warning'); if (this.phase !== 'receive') return; if (this.busy || this.activeTarget === 'quantity') { if (this.scanQueue.length >= 100) return this.showToast('扫码队列已满,请先处理当前托盘', 'warning'); this.scanQueue.push(scan); return this.showToast(`已排队 ${this.scanQueue.length} 个扫码`, 'warning') } return this.dispatchHardwareScan(scan) },
dispatchHardwareScan(scan) { if (!this._pageActive || this.loading) return; const palletWorkflowActive = Boolean(this.activeInbound || this.resolvedPallet); const target = palletWorkflowActive ? 'pallet' : scan.target; console.info('[Overseas PDA scan] dispatch', { value: scan.value, target, queuedTarget: scan.target }); if (target === 'pallet') { this.palletInput = scan.value; return this.resolvePallet(scan.value) } this.cartonInput = scan.value; return this.resolveCarton(scan.value) },
drainScanQueue() { if (!this._pageActive || this.loading || this.busy || this.activeTarget === 'quantity' || !this.scanQueue.length || this.phase !== 'receive') return; const next = this.scanQueue.shift(); this.dispatchHardwareScan(next) },
suspendReceiving() { this._pageActive = false; this._scanEpoch = (this._scanEpoch || 0) + 1; this._resumeReceiving = true; clearTimeout(this._drainTimer); clearTimeout(this._toastTimer); this.scanQueue = []; this.stopScanner() },
scheduleScanDrain() { clearTimeout(this._drainTimer); if (!this._pageActive) return; const epoch = this._scanEpoch || 0; this._drainTimer = setTimeout(() => { if (this._pageActive && epoch === (this._scanEpoch || 0)) this.drainScanQueue() }, 0) },
async run(action, work) { if (this.busy || !this._pageActive) throw new Error('当前页面未就绪,请返回作业页重试'); this.busy = true; this.error = null; try { const result = await work(); this.showToast(`${action}成功`, 'success'); return result } catch (error) { const mappedError = toReceivingError(error, `${action}失败`); this.error = mappedError; playErrorSound(); this.showToast(mappedError.message, 'error'); throw mappedError } finally { this.busy = false; if (this._pageActive && this._resumeReceiving) { this._resumeReceiving = false; this.bootstrap() } else this.scheduleScanDrain() } },
setScanTargetForCurrent(value) { receiving.activeScanTarget = value?.status === 'WAIT_SIGN' ? 'container' : 'carton' },
async restorePhotoPreviews() { await Promise.all(receiving.photoSlots.filter((photo) => photo?.downloadPath && !photo.localPath).map(async (photo) => { photo.status = 'restoring'; try { photo.localPath = await downloadCurrentUnloadPhoto(photo.downloadPath); photo.status = 'success' } catch (error) { photo.status = 'error'; photo.errorMessage = error.message || '照片加载失败'; this.error = error } })) },
async restoreDomesticPhotoPreviews() { await Promise.all(this.domesticLoadingPhotos.filter((photo) => photo.downloadPath && !photo.localPath).map((photo) => this.loadDomesticPhoto(photo))) },
async loadDomesticPhoto(photo) { if (!photo?.downloadPath || photo.status === 'loading') return; photo.status = 'loading'; try { photo.localPath = await downloadCurrentDomesticLoadingPhoto(photo.downloadPath); photo.status = 'success' } catch (_) { photo.status = 'error' } },
async restorePhotoPreviews() { await Promise.all(receiving.photoSlots.filter((photo) => photo?.downloadPath && !photo.localPath).map(async (photo) => { try { await restoreReceivingPhoto(photo, downloadCurrentUnloadPhoto) } catch (error) { this.error = error } })) },
async restoreDomesticPhotoPreviews() { if (this.phase !== 'sign' && !this.domesticPhotosExpanded) return; await Promise.all(this.domesticLoadingPhotos.filter((photo) => photo.downloadPath && !photo.localPath).map((photo) => this.loadDomesticPhoto(photo))) },
async toggleDomesticPhotos() { this.domesticPhotosExpanded = !this.domesticPhotosExpanded; if (this.domesticPhotosExpanded) await this.restoreDomesticPhotoPreviews() },
async loadDomesticPhoto(photo) { if (!photo?.downloadPath) return; try { await restoreReceivingPhoto(photo, downloadCurrentDomesticLoadingPhoto) } catch (_) { photo.status = 'error' } },
async previewDomesticPhoto(photo) { if (!photo?.localPath) await this.loadDomesticPhoto(photo); if (photo?.localPath) { const urls = this.domesticLoadingPhotos.map((item) => item.localPath).filter(Boolean); uni.previewImage({ urls, current: photo.localPath }) } },
async applyCurrentReceiving(value) { this.validateReceivingWarehouse(value, '当前接柜会话'); setCurrentReceiving(value); await Promise.all([this.restorePhotoPreviews(), this.restoreDomesticPhotoPreviews()]); this.setScanTargetForCurrent(value) },
async applyCurrentReceiving(value) { this.validateReceivingWarehouse(value, '当前接柜会话'); setCurrentReceiving(value, JSON.stringify([session.tenantId, this.user?.id, this.user?.warehouseId])); if (!this._pageActive) return; await Promise.all([this.restorePhotoPreviews(), this.restoreDomesticPhotoPreviews()]); this.setScanTargetForCurrent(value) },
async bootstrap() { if (this.loading) return; this.loading = true; this.error = null; this.detailOpen = false; try { await this.refreshBoundWarehouse(); try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.candidateError = null } catch (error) { if (!isNoCurrentReceiving(error)) throw error; resetReceivingDraft(); await this.loadCandidates(false, false) } this.bootstrapped = true } catch (error) { this.candidateError = error } finally { this.loading = false } },
async recoverCurrentAfterConflict(error, preserveEvidence = false) { if (!isReceivingConflict(error)) return false; try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.detailOpen = false; this.error = preserveEvidence ? error : null; this.showToast(`账号已有未完成柜 ${current.containerNo},已返回当前柜`, 'warning'); return true } catch (recoveryError) { if (isNoCurrentReceiving(recoveryError)) { this.detailOpen = false; receiving.selected = null; await this.loadCandidates(false); this.candidateError = preserveEvidence ? error : null; this.error = preserveEvidence ? error : null; this.showToast('柜状态已变化,列表已刷新', 'warning'); return true } this.error = error; return false } },
async loadCandidates(feedback = true, refreshWarehouse = true) { if (this.candidateLoading) return; this.candidateLoading = true; this.candidateError = null; this.error = null; try { if (refreshWarehouse) await this.refreshBoundWarehouse(); const values = await listReceivingCandidates(); receiving.candidates = values.map((item) => this.validateReceivingWarehouse(item, `柜号 ${item.containerNo || '—'}`)); if (feedback) this.showToast('刷新待卸柜成功', 'success') } catch (error) { receiving.candidates = []; this.candidateError = error; if (feedback) { playErrorSound(); this.showToast(error.message || '刷新待卸柜失败', 'error') } } finally { this.candidateLoading = false } },
async openContainer(candidate) { const containerNo = String(candidate?.containerNo || '').trim(); if (!containerNo || this.busy) return; this.error = null; try { this.validateReceivingWarehouse(candidate, `柜号 ${containerNo}`); if (!candidate.operable) { receiving.selected = candidate; this.detailOpen = true; return } const detail = await this.run('查询柜号', async () => this.validateReceivingWarehouse(await getReceivingContainer(containerNo), `柜号 ${containerNo}`)); receiving.selected = { ...candidate, ...detail, allPlansReady: candidate.allPlansReady, operable: candidate.operable, blockReason: candidate.blockReason }; this.detailOpen = true; playSuccessSound() } catch (error) { if (!await this.recoverCurrentAfterConflict(error, true)) { this.error = error; this.showToast(error.message || '查询柜号失败', 'error') } } },
async openContainer(candidate) { const containerNo = String(candidate?.containerNo || '').trim(); const sourceContainerBookingId = String(candidate?.sourceContainerBookingId || '').trim(); if (!containerNo || !sourceContainerBookingId || this.busy) return; this.error = null; try { this.validateReceivingWarehouse(candidate, `柜号 ${containerNo}`); if (!candidate.operable) { receiving.selected = candidate; this.detailOpen = true; return } const detail = await this.run('查询柜号', async () => this.validateReceivingWarehouse(await getReceivingContainer(containerNo, sourceContainerBookingId), `柜号 ${containerNo}`)); receiving.selected = { ...candidate, ...detail, sourceContainerBookingId, allPlansReady: candidate.allPlansReady, operable: candidate.operable, blockReason: candidate.blockReason }; this.detailOpen = true; playSuccessSound() } catch (error) { if (!await this.recoverCurrentAfterConflict(error, true)) { this.error = error; this.showToast(error.message || '查询柜号失败', 'error') } } },
cancelDetail() { this.detailOpen = false; receiving.selected = null },
async prepareContainer() { if (!this.selected?.containerNo || !this.selected?.allPlansReady || !this.selected?.operable || this.busy) return; const key = idempotencyKey('prepare', { containerNo: this.selected.containerNo }); try { this.validateReceivingWarehouse(this.selected, `柜号 ${this.selected.containerNo}`); const value = await this.run('选择当前柜', async () => this.validateReceivingWarehouse(await prepareReceivingContainer(this.selected.containerNo, { idempotencyKey: key.value }), '新建接柜会话')); releaseIdempotencyKey(key.signature); await this.applyCurrentReceiving(value); this.detailOpen = false } catch (error) { if (!await this.recoverCurrentAfterConflict(error, true)) this.error = error } },
async prepareContainer() { if (!this.selected?.containerNo || !this.selected?.sourceContainerBookingId || !this.selected?.allPlansReady || !this.selected?.operable || this.busy) return; const key = idempotencyKey('prepare', { sourceContainerBookingId: this.selected.sourceContainerBookingId, containerNo: this.selected.containerNo }); try { this.validateReceivingWarehouse(this.selected, `柜号 ${this.selected.containerNo}`); const value = await this.run('选择当前柜', async () => this.validateReceivingWarehouse(await prepareReceivingContainer(this.selected.containerNo, this.selected.sourceContainerBookingId, { idempotencyKey: key.value }), '新建接柜会话')); releaseIdempotencyKey(key.signature); await this.applyCurrentReceiving(value); this.detailOpen = false } catch (error) { if (!await this.recoverCurrentAfterConflict(error, true)) this.error = error } },
choosePhoto(index) { if (this.busy) return; uni.chooseImage({ count: 1, sourceType: ['camera'], success: ({ tempFilePaths, tempFiles }) => { const path = tempFilePaths?.[0]; const size = Number(tempFiles?.[0]?.size || 0); if (size > MAX_UNLOAD_PHOTO_BYTES) { const message = '照片不能超过10MB'; this.error = Object.assign(new Error(message), { code: 'A0400' }); this.showToast(message, 'error'); playErrorSound(); return } if (path) this.replacePhoto(index, path) } }) },
async replacePhoto(index, path) { const previous = receiving.photoSlots[index]; receiving.photoSlots.splice(index, 1, { ...(previous || {}), role: this.photoSteps[index].role, status: 'uploading', errorMessage: '' }); this.busy = true; this.error = null; try { const file = await uploadCurrentUnloadPhoto(path); receiving.photoSlots.splice(index, 1, { ...file, fileId: String(file.fileId), role: this.photoSteps[index].role, localPath: path, uploaded: true, status: 'success' }); this.showToast(`${this.photoSteps[index].label}上传成功`, 'success') } catch (error) { receiving.photoSlots.splice(index, 1, previous?.fileId ? { ...previous, status: 'error', errorMessage: error.message || '重拍失败,已保留原图' } : { role: this.photoSteps[index].role, localPath: path, uploaded: false, status: 'error', errorMessage: error.message || '上传失败,请重试' }); this.error = error; this.showToast(error.message || '照片上传失败', 'error'); playErrorSound() } finally { this.busy = false } },
previewPhoto(index) { const path = this.photoSlots[index]?.localPath; if (!path) return; uni.previewImage({ urls: [path], current: path }) },
invalidatePhotoPreview(photo) { if (!photo) return; photo.localPath = ''; photo.status = 'error'; photo.errorMessage = '照片加载失败,点按重试' },
async previewPhoto(index) { const photo = this.photoSlots[index]; if (!photo) return; try { const path = await restoreReceivingPhoto(photo, downloadCurrentUnloadPhoto); if (path) uni.previewImage({ urls: [path], current: path }) } catch (error) { this.error = error } },
async signContainer() { if (!this.photosReady) return; const photos = this.photoSlots.map((item, index) => ({ role: this.photoSteps[index].role, fileId: String(item.fileId) })); const key = idempotencyKey('sign', { containerNo: this.current.containerNo, photos }); try { const value = await this.run('签收当前柜', async () => this.validateReceivingWarehouse(await signCurrentReceiving({ photos, idempotencyKey: key.value }), '当前接柜会话')); releaseIdempotencyKey(key.signature); await this.applyCurrentReceiving(value); this.clearReceiptContext() } catch (_) {} },
clearReceiptContext() { receiving.resolvedCarton = null; receiving.resolvedPallet = null; this.cartonInput = ''; this.palletInput = ''; this.packageCount = ''; receiving.activeScanTarget = 'carton'; setTimeout(() => this.drainScanQueue(), 0) },
async resolveCarton(raw) { const cartonMark = String(raw?.detail?.value ?? raw ?? this.cartonInput).trim(); if (!cartonMark) return; if (this.busy) return this.handleHardwareScan(cartonMark); try { const outcome = await this.run('处理货物码', async () => { const value = await resolveCurrentCarton(cartonMark); if (value.operationMode !== 'DROPSHIP_PALLET') return { value }; const payload = { sourcePalletNo: value.sourcePalletNo || cartonMark }; const key = idempotencyKey('whole-pallet-receipt', { containerNo: this.current?.containerNo, ...payload }); const result = await receiveCurrentWholePallet({ ...payload, idempotencyKey: key.value }); const current = this.validateReceivingWarehouse(await getCurrentReceiving(), '当前接柜会话'); releaseIdempotencyKey(key.signature); return { value, result, current } }); if (outcome.result) { receiving.lastReceipt = outcome.result; await this.applyCurrentReceiving(outcome.current); this.clearReceiptContext(); playSuccessSound(); return } receiving.resolvedCarton = outcome.value; receiving.resolvedPallet = null; this.cartonInput = outcome.value.cartonMark; this.palletInput = ''; this.packageCount = ''; receiving.activeScanTarget = 'pallet'; playSuccessSound() } catch (error) { console.error('[Overseas PDA cargo] failed', { cartonMark, message: error.message, code: error.code, requestId: error.requestId, traceId: error.traceId }) } },
clearReceiptContext() { receiving.resolvedCarton = null; receiving.resolvedPallet = null; this.cartonInput = ''; this.palletInput = ''; this.packageCount = ''; receiving.activeScanTarget = 'carton'; this.scheduleScanDrain() },
async resolveCarton(raw) { const cartonMark = String(raw?.detail?.value ?? raw ?? this.cartonInput).trim(); if (!cartonMark || !this._pageActive) return; const scanEpoch = this._scanEpoch || 0; if (this.busy) return this.handleHardwareScan(cartonMark); try { const outcome = await this.run('处理货物码', async () => { const value = await resolveCurrentCarton(cartonMark); if (value.operationMode !== 'DROPSHIP_PALLET') return { value }; if (!this._pageActive || scanEpoch !== (this._scanEpoch || 0)) throw new Error('页面已离开,请返回后重新扫码确认收货'); const payload = { sourcePalletNo: value.sourcePalletNo || cartonMark }; const key = idempotencyKey('whole-pallet-receipt', { containerNo: this.current?.containerNo, ...payload }); const result = await receiveCurrentWholePallet({ ...payload, idempotencyKey: key.value }); const current = this.validateReceivingWarehouse(await getCurrentReceiving(), '当前接柜会话'); releaseIdempotencyKey(key.signature); return { value, result, current } }); if (outcome.result) { receiving.lastReceipt = outcome.result; await this.applyCurrentReceiving(outcome.current); this.clearReceiptContext(); playSuccessSound(); return } receiving.resolvedCarton = outcome.value; receiving.resolvedPallet = null; this.cartonInput = outcome.value.cartonMark; this.palletInput = ''; this.packageCount = ''; receiving.activeScanTarget = 'pallet'; playSuccessSound() } catch (error) { console.error('[Overseas PDA cargo] failed', { cartonMark, message: error.message, code: error.code, requestId: error.requestId, traceId: error.traceId }) } },
async resolvePallet(raw) { const palletCode = String(raw?.detail?.value ?? raw ?? this.palletInput).trim(); if (!palletCode || !this.activeInbound || this.busy) return; try { const value = await this.run('校验托盘', () => resolveCurrentPallet(palletCode, this.activeInbound.inboundCode)); receiving.resolvedPallet = value; this.palletInput = value.palletCode; this.packageCount = Number(value.currentReceivedPackages || 0) || ''; if (!value.compatible) throw Object.assign(new Error('托盘不匹配'), { code: 'PALLET_INCOMPATIBLE' }); receiving.activeScanTarget = this.activeInbound.destinationType === 'FBA' ? 'quantity' : 'carton'; playSuccessSound() } catch (error) { if (!this.error) this.error = error } },
async submitReceipt() { if (!this.canSubmitReceipt) return; const isFba = this.activeInbound.destinationType === 'FBA'; const payload = isFba ? { inboundCode: this.activeInbound.inboundCode, palletCode: this.resolvedPallet.palletCode, packageCount: Number(this.packageCount), expectedPalletVersion: this.resolvedPallet.version } : { cartonMark: this.activeInbound.cartonMark, palletCode: this.resolvedPallet.palletCode }; const key = idempotencyKey(isFba ? 'bulk-receipt' : 'carton-receipt', { containerNo: this.current?.containerNo, groupKey: this.activeInbound.groupKey, ...payload }); try { const result = await this.run('提交实收', async () => { const receipt = isFba ? await receiveCurrentBulk({ ...payload, idempotencyKey: key.value }) : await receiveCurrentCarton({ ...payload, idempotencyKey: key.value }); const current = this.validateReceivingWarehouse(await getCurrentReceiving(), '当前接柜会话'); return { receipt, current } }); releaseIdempotencyKey(key.signature); receiving.lastReceipt = result.receipt; await this.applyCurrentReceiving(result.current); this.clearReceiptContext(); playSuccessSound() } catch (_) {} },
async submitReceipt() { const isFba = this.activeInbound?.destinationType === 'FBA'; if (isFba && Number(this.packageCount) > MAX_PACKAGES_PER_PALLET) { this.error = Object.assign(new Error(PALLET_CAPACITY_MESSAGE), { code: 'A0409' }); playErrorSound(); this.showToast(PALLET_CAPACITY_MESSAGE, 'error'); return } if (!this.canSubmitReceipt) return; const payload = isFba ? { inboundCode: this.activeInbound.inboundCode, palletCode: this.resolvedPallet.palletCode, packageCount: Number(this.packageCount), expectedPalletVersion: this.resolvedPallet.version } : { cartonMark: this.activeInbound.cartonMark, palletCode: this.resolvedPallet.palletCode }; const key = idempotencyKey(isFba ? 'bulk-receipt' : 'carton-receipt', { containerNo: this.current?.containerNo, groupKey: this.activeInbound.groupKey, ...payload }); try { const result = await this.run('提交实收', async () => { const receipt = isFba ? await receiveCurrentBulk({ ...payload, idempotencyKey: key.value }) : await receiveCurrentCarton({ ...payload, idempotencyKey: key.value }); const current = this.validateReceivingWarehouse(await getCurrentReceiving(), '当前接柜会话'); return { receipt, current } }); releaseIdempotencyKey(key.signature); receiving.lastReceipt = result.receipt; await this.applyCurrentReceiving(result.current); this.clearReceiptContext(); playSuccessSound() } catch (_) {} },
async completeContainer() { if (!this.canFinishOnsite || this.activeTarget === 'quantity' || this.scanQueue.length) return; const payload = { allowDifference: !this.allInboundsReconciled }; const key = idempotencyKey('complete', { containerNo: this.current.containerNo, ...payload }); try { const value = await this.run(this.allInboundsReconciled ? '完成入库' : '结束现场收货', async () => this.validateReceivingWarehouse(await completeCurrentReceiving({ ...payload, idempotencyKey: key.value }), '当前接柜会话')); releaseIdempotencyKey(key.signature); await this.applyCurrentReceiving(value) } catch (_) {} },
async finishAndReturn() { resetReceivingDraft(); await this.loadCandidates(false) },
showToast(message, type) { clearTimeout(this._toastTimer); this.toast = { visible: true, message, type }; this._toastTimer = setTimeout(() => { this.toast.visible = false }, 2600) }
+126 -28
View File
@@ -3,43 +3,52 @@
<PortalHeader title="出库作业" :user="user" />
<scroll-view class="fulfillment-scroll" scroll-y>
<view class="portal-content fulfillment-content">
<view class="fulfillment-heading"><text class="fulfillment-title">出库任务</text><text class="fulfillment-subtitle">列表仅显示普通/一件代发出库任务;扫描排车业务号会转入“排车拣货”</text></view>
<view class="fulfillment-heading"><text class="fulfillment-title">出库任务</text><text class="fulfillment-subtitle">整托扫描托盘码,散箱扫描箱唛;扫描排车业务号会转入“排车拣货”</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 class="fulfillment-card">
<ScanTargetInput name="outbound" :model-value="scanCode" label="任务号、出库单号、排车单号、托盘码或箱码" placeholder="扫描任务号 / 出库单号 / 排车单号 / 托盘码 / 箱码" :active="true" @update:modelValue="updateScanCode" @submit="handleScan" />
<button class="secondary-button" :disabled="!scanCode || loading || busy" @tap="handleScan(scanCode)">{{ current ? '匹配出库货物' : '打开出库任务' }}</button>
<text class="scan-help">任务号直接恢复;出库单号或排车单号先由服务端解析流程,不会作为出库任务号误查。</text>
<ScanTargetInput name="outbound" :model-value="scanCode" :label="scanLabel" :placeholder="scanPlaceholder" :active="true" @update:modelValue="updateScanCode" @submit="handleScan" />
<button class="secondary-button" :disabled="!scanCode || loading || busy || discardLoading || discardBusy" @tap="handleScan(scanCode)">{{ discardCurrent ? '打开废弃托盘' : current ? '匹配出库货物' : '打开出库任务' }}</button>
<text class="scan-help">任务号直接恢复;退货整托出库扫托盘码,散箱出库扫箱唛。</text>
</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" @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">
<view class="list-toolbar"><text>待处理出库任务</text><button :disabled="loading" @tap="loadTasks">刷新</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"><text class="business-code">{{ task.taskNo || '—' }}</text><text class="status-badge">{{ statusLabel(task.status) }}</text></view><text class="task-meta">出库单 {{ task.outboundNo || '—' }} · {{ taskType(task) }}</text></button>
<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>
<view v-else-if="!error" class="fulfillment-state"><text class="state-title">暂无出库任务</text><text>可刷新列表,或扫描任务号恢复未完成作业。</text></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 class="task-meta">托盘 {{ order.palletCount }} 个 · 待作业 {{ 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><button class="link-button" :disabled="busy" @tap="closeTask">返回任务列表</button></view>
<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 v-if="current.items.length" class="item-list">
<view v-for="item in current.items" :key="item.outboundPalletItemId" class="fulfillment-card item-card">
<view v-for="item in displayItems" :key="item.displayKey || item.outboundPalletItemId" class="fulfillment-card item-card">
<view class="task-head"><text class="business-code">{{ item.palletNo || '散件出库' }}</text><text>{{ item.pickedPackages }}/{{ item.plannedPackages }} 箱</text></view>
<view v-for="position in item.positions" :key="businessLineKey(position)" class="outbound-line" :class="{ selected: selectedPosition === position }" @tap="selectPosition(item, position)">
<view v-for="position in current.outboundType === 'RETURN_OUTBOUND' && item.palletNo ? [] : item.positions" :key="businessLineKey(position)" class="outbound-line" :class="{ selected: selectedPosition === position }" @tap="selectPosition(item, position)">
<text class="line-code">{{ lineBusinessCode(position) }}</text><text class="line-meta">{{ position.inboundCode || '无入仓码' }} · 已拣 {{ position.pickedPackages }} 箱 · 可拣 {{ position.availablePackages }} 箱</text>
</view>
<text v-if="current.outboundType === 'RETURN_OUTBOUND' && item.palletNo && item.pickedPackages < item.plannedPackages" class="scan-help">整托出库请扫描托盘号 {{ item.palletNo }}</text>
<text v-if="!item.positions.length && item.pickedPackages < item.plannedPackages" class="missing-scan-contract">当前明细缺少可扫码库存业务码,已禁止拣货;请刷新或联系平台确认任务下发。</text>
</view>
</view>
<view v-else class="fulfillment-state"><text class="state-title">任务详情尚无可拣货明细</text><text>请刷新;若持续为空,请联系平台确认出库货物是否已下发。</text></view>
<view v-if="selectedPosition && selectedRemaining > 0" class="fulfillment-card"><text class="card-title">本次拣货:{{ lineBusinessCode(selectedPosition) }}</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 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>
</template>
<template v-else-if="discardCurrent">
<view class="detail-toolbar"><view><text class="detail-kicker">废弃订单</text><text class="detail-title">{{ discardCurrent.discardNo }}</text></view><button :disabled="discardBusy" @tap="closeDiscard">‹ 返回列表</button></view>
<view class="fulfillment-card"><ScanTargetInput name="cargo-discard" :model-value="discardPalletCode" label="废弃托盘码" placeholder="扫描对应托盘码" :active="true" @update:modelValue="updateDiscardPalletCode" @submit="resolveDiscardPallet" /><button class="secondary-button" :disabled="!discardPalletCode || discardLoading || discardBusy" @tap="resolveDiscardPallet">{{ discardLoading ? '读取中…' : '打开托盘废弃作业' }}</button><text class="scan-help">扫描该废弃订单对应的托盘,再填写每个 SKU 的实际废弃数量。</text></view>
<view v-if="discardTask" class="fulfillment-card item-card"><view class="task-head"><text class="business-code">托盘 {{ discardTask.palletNo }}</text><text class="status-badge">{{ discardTask.status === 'COMPLETED' ? '已完成' : '待废弃' }}</text></view><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 }} 箱</text><view class="count-row"><text>实际废弃数量</text><input type="number" :value="discardActuals[sku.itemId]" :disabled="discardTask.status === 'COMPLETED'" :placeholder="`请输入 0-${sku.plannedPackages} 箱`" @input="updateDiscardActual(sku.itemId, $event)" /><text>最多 {{ sku.plannedPackages }} 箱</text></view></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>
</template>
<view v-if="successMessage" class="fulfillment-success" role="status"><text>{{ successMessage }}</text></view>
</view>
</scroll-view>
@@ -49,7 +58,7 @@
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { completeOutboundTask, getOutboundTask, listOutboundTasks, outboundPickPayload, pickOutboundTask, resolveOutboundTask } from '@/api/fulfillment.js'
import { listCargoDiscardPhotos, uploadCargoDiscardPhoto, downloadCargoDiscardPhoto, completeOutboundTask, confirmCargoDiscard, getOutboundTask, listCargoDiscardTasks, listOutboundTasks, normalizeCargoDiscardTask, outboundPickPayload, pickOutboundPallet, pickOutboundTask, resolveCargoDiscardPallet, resolveOutboundTask } from '@/api/fulfillment.js'
import { fulfillment, fulfillmentIdempotencyKey, releaseFulfillmentKey, restoreFulfillmentDraft, updateFulfillmentDraft } from '@/stores/fulfillment.js'
import { requireSession, session } from '@/stores/session.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
@@ -57,17 +66,44 @@
export default {
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, tasks: [], current: null, selectedItem: null, selectedPosition: null, scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '' } },
data() { return { user: session.currentUser, tasks: [], discardTasks: [], current: null, discardCurrent: null, discardTask: null, discardActuals: {}, 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 } },
computed: {
displayItems() {
const items = this.current?.items || []
if (this.current?.outboundType !== 'RETURN_OUTBOUND') return items
const pallets = new Map()
const rows = []
for (const item of items) {
if (!item.palletNo) { rows.push(item); continue }
let pallet = pallets.get(item.palletNo)
if (!pallet) {
pallet = { ...item, displayKey: `pallet:${item.palletNo}`, plannedPackages: 0, pickedPackages: 0, positions: [] }
pallets.set(item.palletNo, pallet)
rows.push(pallet)
}
pallet.plannedPackages += Number(item.plannedPackages || 0)
pallet.pickedPackages += Number(item.pickedPackages || 0)
pallet.positions.push(...(item.positions || []))
}
return rows
},
hasReturnPallet() { return this.current?.outboundType === 'RETURN_OUTBOUND' && (this.current.items || []).some(item => item.palletNo) },
scanLabel() { return this.discardCurrent ? '废弃订单托盘码' : this.hasReturnPallet ? '扫描托盘号(退货整托出库)' : '任务号、出库单号、排车单号、托盘号或箱码' },
scanPlaceholder() { return this.discardCurrent ? '扫描废弃订单对应托盘码' : this.hasReturnPallet ? '扫描托盘号;散箱扫描箱唛' : '扫描任务号 / 出库单号 / 排车单号 / 托盘号 / 箱码' },
recentTaskNo() { return fulfillment.outbound.currentTaskNo || '' },
packages() { return fulfillment.outbound.packages },
plannedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.plannedPackages, 0) },
pickedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.pickedPackages, 0) },
remainingPackages() { return Math.max(0, this.plannedPackages - this.pickedPackages) },
selectedRemaining() { return Math.max(0, Math.min(Number(this.selectedItem?.plannedPackages || 0) - Number(this.selectedItem?.pickedPackages || 0), Number(this.selectedPosition?.availablePackages || 0))) },
canPick() { return this.current?.status !== 'COMPLETED' && this.selectedItem && this.selectedPosition && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining },
canComplete() { return this.plannedPackages > 0 && this.remainingPackages === 0 && this.current?.status !== 'COMPLETED' }
taskOperable() { return ['PENDING', 'IN_PROGRESS'].includes(this.current?.status) },
canPick() { return this.taskOperable && this.selectedItem && this.selectedPosition && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining },
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, palletCount: 0, pendingPalletCount: 0 }; current.palletCount += 1; if (task.status !== 'COMPLETED') current.pendingPalletCount += 1; groups.set(task.discardNo, current) } return [...groups.values()] },
discardOrderCount() { return this.discardOrders.length },
canConfirmDiscard() { return Boolean(this.discardPhotoReady && this.discardPhotos.length > 0 && !this.discardPhotoBusy && !this.discardLoading && !this.discardBusy && this.discardTask && this.discardTask.status !== 'COMPLETED' && this.discardTask.skus.length && this.discardTask.skus.every((sku) => Number.isInteger(Number(this.discardActuals[sku.itemId])) && Number(this.discardActuals[sku.itemId]) >= 0 && Number(this.discardActuals[sku.itemId]) <= sku.plannedPackages)) }
},
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.scanCode = fulfillment.outbound.scanCode; this.startScanner(); if (fulfillment.outbound.currentTaskNo) this.openTask(fulfillment.outbound.currentTaskNo); else this.loadTasks() },
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() },
beforeUnmount() { this.stopScanner() },
methods: {
@@ -75,7 +111,7 @@
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
updateScanCode(value) { this.scanCode = value; updateFulfillmentDraft('outbound', { scanCode: value }) },
updatePackages(event) { const value = Math.trunc(Number(event.detail.value || 0)); updateFulfillmentDraft('outbound', { packages: Number.isFinite(value) ? Math.max(0, value) : 0 }) },
async loadTasks() { await this.run('加载出库任务', async () => { this.tasks = await listOutboundTasks(); this.current = null }, true) },
async loadTasks() { await this.run('加载出库任务', async () => { const [tasks, discardTasks] = await Promise.all([listOutboundTasks('PENDING'), listCargoDiscardTasks()]); this.tasks = tasks; this.discardTasks = discardTasks; this.current = null; this.discardCurrent = null; this.discardTask = null }, true) },
async openTask(taskNo) { const code = String(taskNo || '').trim(); if (!code) return; await this.run('恢复出库任务', async () => { const pendingScan = fulfillment.outbound.currentTaskNo === code ? fulfillment.outbound.scanCode : ''; this.current = await getOutboundTask(code); this.selectedItem = null; this.selectedPosition = null; this.scanCode = pendingScan; updateFulfillmentDraft('outbound', { currentTaskNo: this.current.taskNo, scanCode: pendingScan, packages: pendingScan ? fulfillment.outbound.packages : 1 }); if (pendingScan) this.findAndSelectPosition(pendingScan, false) }, true) },
async openScannedTask(businessNo) {
await this.run('解析出库作业', async () => {
@@ -83,7 +119,7 @@
if (resolution?.workflow === 'DISPATCH' && resolution.dispatchNo) {
updateFulfillmentDraft('outbound', { currentTaskNo: '', scanCode: '', packages: 1 })
updateFulfillmentDraft('dispatch', { currentNo: resolution.dispatchNo, palletCode: '', packages: 1 })
uni.navigateTo({ url: '/pages/dispatch/dispatch' })
uni.navigateTo({ url: '/pages/dispatch/dispatch?resume=1' })
return
}
if (resolution?.workflow !== 'OUTBOUND' || !resolution.taskNo) throw new Error('服务端未返回可执行的出库任务')
@@ -93,39 +129,101 @@
}, 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 }; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.error = null },
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 resolveDiscardPallet(raw) {
const code = String(raw?.detail?.value ?? raw ?? this.discardPalletCode).trim()
if (!code || !this.discardCurrent || this.discardBusy || this.discardLoading || this.discardPhotoBusy) return
const order = this.discardCurrent
this.resetDiscardPhotos(); this.discardPalletCode = code; this.discardLoading = true; this.discardTask = null; this.discardActuals = {}; this.error = null
try {
const task = await resolveCargoDiscardPallet(code)
if (this.discardCurrent !== order) return
if (task.discardNo !== order.discardNo) throw new Error('扫描的托盘不属于当前废弃订单')
this.discardTask = normalizeCargoDiscardTask(task)
this.discardActuals = Object.fromEntries(this.discardTask.skus.map((sku) => [sku.itemId, sku.actualPackages == null ? sku.plannedPackages : sku.actualPackages])); await this.restoreDiscardPhotos()
} catch (error) { if (this.discardCurrent === order) this.setError(error, '废弃托盘读取失败') }
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
const order = this.discardCurrent; const task = this.discardTask
this.discardPhotoReady = false
try {
const photos = await listCargoDiscardPhotos(order.discardNo, task.palletNo)
if (this.discardCurrent !== order || this.discardTask !== task) return
this.discardPhotos = photos; this.discardPhotoReady = true
await Promise.all(photos.map(photo => this.loadDiscardPhoto(photo.fileId)))
} catch (error) { if (this.discardCurrent === order && this.discardTask === task) this.setError(error, '废弃照片读取失败') }
},
async loadDiscardPhoto(fileId) {
if (!this.discardCurrent || !this.discardTask) return
const order = this.discardCurrent; const task = this.discardTask
try {
const url = await downloadCargoDiscardPhoto(order.discardNo, task.palletNo, fileId)
if (this.discardCurrent === order && this.discardTask === task) { this.discardPhotoUrls = { ...this.discardPhotoUrls, [fileId]: url }; this.discardPhotoErrors = { ...this.discardPhotoErrors, [fileId]: false } }
} catch (_) { if (this.discardCurrent === order && this.discardTask === task) this.discardPhotoErrors = { ...this.discardPhotoErrors, [fileId]: true } }
},
previewDiscardPhoto(fileId) { const urls = this.discardPhotos.map(photo => this.discardPhotoUrls[photo.fileId]).filter(Boolean); if (this.discardPhotoUrls[fileId]) uni.previewImage({ current: this.discardPhotoUrls[fileId], urls }) },
takeDiscardPhoto() {
if (!this.discardCurrent || !this.discardTask || this.discardTask.status === 'COMPLETED' || this.discardPhotoBusy || this.discardBusy || this.discardLoading || !this.discardPhotoReady || this.discardPhotos.length >= 20) return
const order = this.discardCurrent; const task = this.discardTask; this.discardPhotoBusy = true
uni.chooseImage({ count: 1, sourceType: ['camera'], success: async ({ tempFilePaths }) => {
try {
if (this.discardCurrent !== order || this.discardTask !== task || !tempFilePaths?.[0]) return
const photo = await uploadCargoDiscardPhoto(order.discardNo, task.palletNo, tempFilePaths[0])
if (this.discardCurrent !== order || this.discardTask !== task) return
this.discardPhotos.push(photo); this.discardPhotoUrls = { ...this.discardPhotoUrls, [photo.fileId]: tempFilePaths[0] }; this.error = null
} catch (error) { this.setError(error, '废弃照片上传失败'); await this.restoreDiscardPhotos() }
finally { this.discardPhotoBusy = false }
}, fail: () => { this.discardPhotoBusy = false } })
},
updateDiscardActual(itemId, event) { const value = Math.max(0, Math.trunc(Number(event.detail.value || 0))); this.discardActuals = { ...this.discardActuals, [itemId]: 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.successMessage = '废弃出库作业及照片已提交,等待仓库确认'; this.resetDiscardPhotos(); this.discardPalletCode = ''; this.discardTask = null; this.discardTasks = await listCargoDiscardTasks(); playSuccessSound() } catch (error) { 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) return
console.info('[Overseas PDA outbound] scan', { code, task: this.current?.taskNo || '' }); this.scanCode = code; updateFulfillmentDraft('outbound', { scanCode: code })
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 })
if (this.discardCurrent) return this.resolveDiscardPallet(code)
if (!this.current) return this.openScannedTask(code)
if (code === this.current.taskNo || code === this.current.outboundNo) return this.openTask(this.current.taskNo)
if (!this.taskOperable) return
if ((this.current.items || []).some((item) => item.palletNo === code)) return this.submitPalletPick(code)
if (this.findAndSelectPosition(code)) return
this.setError(new Error('扫码未匹配当前出库任务的托盘码、箱码、SKU 或入仓码'), '扫码未匹配')
},
findAndSelectPosition(code, feedback = true) { for (const item of this.current?.items || []) { const position = item.positions.find((value) => [value.palletNo, value.cartonMark, value.skuCode, value.inboundCode].filter(Boolean).includes(code)); if (position) { this.selectPosition(item, position); if (feedback) playSuccessSound(); return true } } return false },
selectPosition(item, position) { if (!position || position.availablePackages <= 0) return; this.selectedItem = item; this.selectedPosition = position; updateFulfillmentDraft('outbound', { scanCode: this.lineBusinessCode(position), packages: Math.max(1, Math.min(item.plannedPackages - item.pickedPackages, position.availablePackages)) }) },
findAndSelectPosition(code, feedback = true) { for (const item of this.current?.items || []) { if (this.current?.outboundType === 'RETURN_OUTBOUND' && item.palletNo) continue; const position = item.positions.find((value) => value.availablePackages > 0 && [value.palletNo, value.cartonMark, value.skuCode, value.inboundCode].filter(Boolean).includes(code)); if (position) { this.selectPosition(item, position, code); if (feedback) playSuccessSound(); return true } } return false },
selectPosition(item, position, scannedCode = '') { if (!this.taskOperable || !position || position.availablePackages <= 0 || (this.current?.outboundType === 'RETURN_OUTBOUND' && item.palletNo)) return; this.selectedItem = item; this.selectedPosition = position; this.selectedScanCode = scannedCode || this.lineBusinessCode(position); updateFulfillmentDraft('outbound', { scanCode: this.selectedScanCode, packages: Math.max(1, Math.min(item.plannedPackages - item.pickedPackages, position.availablePackages)) }) },
async submitPick() {
if (!this.canPick) return
const payload = outboundPickPayload(this.selectedItem, this.selectedPosition, this.packages); const key = fulfillmentIdempotencyKey('outbound', 'pick', { taskNo: this.current.taskNo, ...payload })
await this.run('提交出库拣货', async () => { await pickOutboundTask(this.current.taskNo, { ...payload, idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); releaseFulfillmentKey('outbound', key.signature); this.selectedItem = null; this.selectedPosition = null; updateFulfillmentDraft('outbound', { scanCode: '', packages: 1 }); this.successMessage = '本次出库拣货已记录'; playSuccessSound() })
const payload = outboundPickPayload(this.selectedItem, this.selectedPosition, this.packages, this.selectedScanCode); const key = fulfillmentIdempotencyKey('outbound', 'pick', { taskNo: this.current.taskNo, ...payload })
await this.run('提交出库拣货', async () => { await pickOutboundTask(this.current.taskNo, { ...payload, idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); releaseFulfillmentKey('outbound', key.signature); this.selectedItem = null; this.selectedPosition = null; this.selectedScanCode = ''; updateFulfillmentDraft('outbound', { scanCode: '', packages: 1 }); this.successMessage = '本次出库拣货已记录'; playSuccessSound() })
},
async submitPalletPick(palletNo) {
const code = String(palletNo || '').trim(); if (!code || !this.taskOperable) return
const key = fulfillmentIdempotencyKey('outbound', 'pallet-pick', { taskNo: this.current.taskNo, palletNo: code })
await this.run('整托拣货', async () => { await pickOutboundPallet(this.current.taskNo, code, { idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); releaseFulfillmentKey('outbound', key.signature); this.selectedItem = null; this.selectedPosition = null; updateFulfillmentDraft('outbound', { scanCode: '', packages: 1 }); this.successMessage = `${code} 整托拣货已完成`; playSuccessSound() })
},
async completeTask() {
if (!this.canComplete) return
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); releaseFulfillmentKey('outbound', key.signature); this.successMessage = '出库任务已完成'; playSuccessSound() })
},
async retry() { 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.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 || '业务码缺失' },
businessLineKey(position) { return [position?.cartonMark, position?.palletNo, position?.skuCode, position?.inboundCode].filter(Boolean).join(':') || `line-${position?.inventoryPositionId || 'missing'}` },
statusLabel(status) { return ({ PENDING: '待拣货', IN_PROGRESS: '拣货中', COMPLETED: '已完成', CANCELLED: '已取消' })[status] || status || '状态未知' },
taskType() { return '普通/一件代发共用出库' }
taskType(task) { return ({ RETURN_OUTBOUND: '退货出库', RELABEL_OUTBOUND: '换标出库', ONE_CLICK_DROPSHIP: '一件代发出库', WAREHOUSE_TRANSFER_OUTBOUND: '仓储转运出库' })[task?.outboundType] || '普通出库' }
}
}
</script>
<style scoped>
@import '../dispatch/fulfillment.scss';
.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; }
.line-code, .line-meta { display: block; }
+16 -3
View File
@@ -13,7 +13,7 @@
</view>
<button class="logout-action" @tap="openLogout">退出登录</button>
</view>
<uni-popup ref="logoutPopup" type="center" :is-mask-click="true" @change="handleLogoutChange"><view class="logout-dialog-card" role="dialog" aria-modal="true" aria-label="确认退出登录"><PopupHeader title="确认退出登录?" tone="danger" @close="closeLogout" /><text class="logout-dialog-content">退出后会清空海外 PDA 的本地登录会话;未提交作业数据不得跨账号保留。</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>
<uni-popup ref="logoutPopup" type="center" :is-mask-click="true" @change="handleLogoutChange"><view class="logout-dialog-card" role="dialog" aria-modal="true" aria-label="确认退出登录"><PopupHeader title="确认退出登录?" tone="danger" @close="closeLogout" /><text class="logout-dialog-content">退出会撤销当前登录会话并清空本地作业草稿;未提交作业数据不得跨账号保留。</text><view class="logout-dialog-actions"><button class="logout-dialog-cancel" @tap="closeLogout">取消</button><button class="logout-dialog-confirm" @tap="logout">确认退出</button></view></view></uni-popup>
<view v-if="toast" class="app-toast warning" role="status">设置功能将在后续版本开放</view>
</view>
</template>
@@ -22,7 +22,8 @@
import PopupHeader from '@/components/PopupHeader.vue'
import { getCurrentWarehouseProfile } from '@/api/inbound.js'
import { currentLocale, LOCALE_OPTIONS, setLocale } from '@/i18n/index.js'
import { applyWarehouseProfile, session, requireSession, signOut } from '@/stores/session.js'
import { applyWarehouseProfile, session, requireSession, getSessionRevision } from '@/stores/session.js'
import { logoutCurrentSession } from '@/api/logout-session.js'
import { checkForAppUpdate } from '@/utils/app-update.js'
export default {
components: { PopupHeader, PortalHeader },
@@ -66,7 +67,19 @@
async loadWarehouseProfile() { try { this.user = applyWarehouseProfile(await getCurrentWarehouseProfile()) } catch (_) { this.user = session.currentUser } },
openLogout() { this.logoutOpen = true; this.$refs.logoutPopup.open() }, closeLogout() { this.logoutOpen = false; this.$refs.logoutPopup.close() }, handleLogoutChange(event) { this.logoutOpen = Boolean(event.show) },
showSettings() { clearTimeout(this._toastTimer); this.toast = true; this._toastTimer = setTimeout(() => { this.toast = false }, 2200) },
logout() { signOut(); uni.reLaunch({ url: '/pages/login/login' }) }
logout() {
if (this._logoutPromise) return this._logoutPromise
this.closeLogout()
const operation = logoutCurrentSession()
this.user = null
uni.reLaunch({ url: '/pages/login/login' })
this._logoutPromise = operation.catch(error => {
if (getSessionRevision() === error.logoutRevision) {
uni.showToast({ title: '已清除本地登录,服务端退出未确认', icon: 'none' })
}
})
return this._logoutPromise
}
}
}
</script>
+323 -44
View File
@@ -2,39 +2,71 @@
<view v-if="user" class="page-shell portal-page work-page">
<PortalHeader :title="title" :user="user" fallback-url="/pages/module/module?key=relabel" />
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
<view class="work-heading"><text class="work-title">{{ title }}</text><text class="work-subtitle">任务和仓库范围由服务端按当前 PDA Token 校验;扫描与人工输入使用同一正式接口</text></view>
<view class="work-card"><ScanTargetInput name="relabelTask" v-model="taskCode" label="换标单号" placeholder="扫描或输入换标单号" :active="activeTarget === 'task'" @activate="activeTarget = 'task'" @submit="openTask" /><button class="primary" :disabled="!taskCode.trim() || busy" @tap="openTask(taskCode)">{{ busyAction === 'detail' ? '查询中…' : '打开换标任务' }}</button></view>
<view v-if="!showCurrent" class="work-heading"><text class="work-title">{{ title }}</text><text class="work-subtitle">{{ stageSubtitle }}</text></view>
<view v-if="showCurrent" class="detail-toolbar"><button :disabled="busy" @tap="closeCurrent">‹ 返回列表</button></view>
<view class="row-head"><text class="card-title">{{ candidateHeading }}</text><button class="link-button" :disabled="loading" @tap="loadCandidates">{{ loading ? '刷新中…' : '刷新' }}</button></view>
<template v-if="!showCurrent">
<view class="row-head"><text class="card-title">{{ candidateHeading }}</text><button v-if="stage !== 'review'" class="link-button" :disabled="loading" @tap="bootstrap">{{ loading ? '刷新中…' : '刷新' }}</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 class="task-list"><view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card" :class="{ current: current?.relabelNo === item.relabelNo }" @tap="openTask(item.relabelNo)"><view class="row-head"><view><text class="business-code">{{ item.relabelNo }}</text><text class="meta-line">客户订单:{{ item.customerOrderNo || '—' }}</text></view><text class="status-pill">{{ statusLabel(item.taskStatus || item.applicationStatus) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ item.expectedPackages }}</strong><text>应处理</text></view><view class="metric"><strong>{{ item.relabelledPackages }}</strong><text>已换标</text></view><view class="metric"><strong>{{ item.skippedPackages + item.exceptionPackages }}</strong><text>跳过/异常</text></view><text class="link-button">详情 ›</text></view></view></view>
<view v-else-if="stage !== 'review'" class="task-list"><view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card" :class="{ current: current?.relabelNo === item.relabelNo, 'rework-card': item.reviewDecision === 'REWORK' }" @tap="openExecution(item)"><view v-if="item.reviewDecision === 'REWORK'" class="rework-banner"><text class="rework-tag">退回重做</text><text class="rework-priority">优先处理</text></view><view v-if="item.reviewDecision === 'REWORK' && item.reviewRemark" class="rework-remark">退回备注:{{ item.reviewRemark }}</view><view class="row-head"><view><view class="candidate-heading"><text class="business-code">{{ item.relabelNo }}</text><text class="candidate-customer">所属公司:{{ item.customerName || '未提供' }}</text></view><text class="meta-line">客户订单:{{ item.customerOrderNo || '—' }}</text><text class="meta-line">换标类型:{{ relabelTypeName(item.operationType) }}</text></view><text class="status-pill">{{ statusLabel(item.taskStatus || item.applicationStatus) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ item.expectedPackages }}</strong><text>应处理</text></view><view class="metric"><strong>{{ item.relabelledPackages }}</strong><text>已换标</text></view><view class="metric"><strong>{{ item.skippedPackages + item.exceptionPackages }}</strong><text>跳过/异常</text></view><text class="link-button">{{ item.reviewDecision === 'REWORK' ? '优先重做' : '查看并执行' }} ›</text></view></view></view>
<view v-else class="task-list"><view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card" @tap="openReview(item)"><view class="row-head"><view><view class="candidate-heading"><text class="business-code">{{ item.relabelNo }}</text><text class="candidate-customer">所属公司:{{ item.customerName || '未提供' }}</text></view><text class="meta-line">客户订单:{{ item.customerOrderNo || '—' }}</text><text class="meta-line">换标类型:{{ relabelTypeName(item.operationType) }}</text></view><text class="status-pill">{{ statusLabel(item.taskStatus || item.applicationStatus) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ item.expectedPackages }}</strong><text>应处理</text></view><view class="metric"><strong>{{ item.relabelledPackages }}</strong><text>已换标</text></view><view class="metric"><strong>{{ item.skippedPackages + item.exceptionPackages }}</strong><text>跳过/异常</text></view><text class="link-button">查看并复核 ›</text></view></view></view>
<view v-if="current" class="work-card">
</template>
<view v-if="showCurrent" class="work-card">
<text class="status-pill">换标类型:{{ relabelTypeName(current.operationType) }}</text>
<view class="row-head"><view><text class="business-code">{{ current.relabelNo }}</text><text class="meta-line">客户订单:{{ current.customerOrderNo || '—' }}</text><text class="meta-line">标签截止:{{ formatTime(current.labelDeadline) }}</text></view><text class="status-pill">{{ statusLabel(current.taskStatus || current.applicationStatus) }}</text></view>
<view class="metric-row"><view class="metric"><strong>{{ current.expectedPackages }}</strong><text>应处理</text></view><view class="metric"><strong>{{ current.relabelledPackages }}</strong><text>已换标</text></view><view class="metric"><strong>{{ current.skippedPackages }}</strong><text>跳过</text></view><view class="metric"><strong>{{ current.exceptionPackages }}</strong><text>异常</text></view></view>
<template v-if="stage === 'receive'"><text class="hint">认领后任务进入处理中,并锁定当前操作人;其他账号不可并发作业。</text><button class="primary" :disabled="busy || current.taskStatus === 'WAIT_REVIEW' || current.taskStatus === 'COMPLETED'" @tap="claimTask">{{ busyAction === 'claim' ? '认领中…' : current.claimedByCurrentOperator ? '恢复当前换标任务' : '确认收货并认领任务' }}</button></template>
<template v-else-if="stage === 'execute'">
<view v-if="current.taskStatus !== 'PROCESSING'" class="empty-box section-gap"><text class="empty-copy">当前任务不是处理中状态,请先在“换标收货”认领,或等待复核退回。</text></view>
<view class="destination-summary"><text class="meta-line">FBA 目的仓:</text><text v-for="destination in fbaDestinations" :key="destination" class="destination-tag">{{ destination }}</text><text v-if="!fbaDestinations.length" class="meta-line">待维护</text><view class="summary-metrics"><text class="summary-metric">应处理 {{ current.expectedPackages }}</text><text class="summary-metric">已换标 {{ current.relabelledPackages }}</text><text class="summary-metric">跳过 {{ current.skippedPackages }}</text><text class="summary-metric">异常 {{ current.exceptionPackages }}</text></view></view>
<view class="section-gap"><view class="row-head"><text class="card-title">标签文件</text><button class="link-button" :disabled="busy" @tap="loadDocuments">刷新文件</button></view><text v-if="!documents.length" class="meta-line">暂无标签文件</text><view v-for="file in documents" :key="`${file.scope}:${file.fileId}`" class="row-head"><text class="meta-line">{{ file.scope === 'CARTON_TOTAL' ? '总箱唛' : file.scope === 'SKU_TOTAL' ? '总 SKU 标签' : '明细标签' }}:{{ file.fileName }}</text><button class="secondary" :disabled="busy" @tap="printDocument(file)">查看 / 打印</button></view></view>
<template v-if="stage === 'execute'">
<view v-if="current.reviewDecision === 'REWORK' && current.reviewRemark" class="error-box section-gap"><text class="error-title">退回重新贴标</text><text>{{ current.reviewRemark }}</text></view>
<view v-if="current.taskStatus !== 'PROCESSING'" class="empty-box section-gap"><text class="empty-copy">当前任务不是处理中状态,请返回换标列表选择可执行任务,或等待复核退回。</text></view>
<template v-else>
<text class="card-title section-gap">选择商品明细</text>
<view class="item-list"><view v-for="item in current.items" :key="item.itemId" class="item-card" :class="{ done: remaining(item) === 0, current: selectedItem?.itemId === item.itemId }" @tap="selectItem(item)"><view class="row-head"><view><text class="business-code">{{ item.skuCode || 'SKU 未关联' }}</text><text class="meta-line">{{ item.productNameZh || '商品名称未关联' }}</text><text class="meta-line">新 SKU:{{ item.newSkuCode || '—' }}</text></view><text class="status-pill" :class="{ good: remaining(item) === 0 }">待处理 {{ remaining(item) }}</text></view><text class="meta-line">{{ item.labelRequirement || '无额外标签要求' }}</text></view></view>
<template v-if="selectedItem && remaining(selectedItem) > 0">
<view v-if="availableTabs.length > 1" class="tab-row" role="tablist"><button v-for="tab in availableTabs" :key="tab.key" class="tab-button" role="tab" :aria-selected="taskTab === tab.key" :class="{ active: taskTab === tab.key }" :disabled="busy" @tap="changeTab(tab.key)">{{ tab.name }}</button></view>
<text class="card-title section-gap">{{ isReturnBulk ? '退货贴标 SKU 明细' : taskTab === 'sku' ? 'SKU 换标明细' : '箱唛换标明细' }}</text>
<view v-if="isReturnBulk" class="section-gap"><text class="hint">先扫描来源包裹/箱,再核对商品 SKU、目标标签和实际数量。</text><ScanTargetInput name="returnCarton" v-model="returnCartonCode" label="来源包裹/箱码" placeholder="扫描真实来源包裹/箱" :active="activeTarget === 'return-carton'" @activate="activeTarget = 'return-carton'" @submit="verifyReturnCarton" /><button class="secondary" :disabled="busy" @tap="verifyReturnCarton(returnCartonCode)">确认来源包裹/箱</button><text v-if="verifiedReturnCarton" class="success-title">已确认:{{ verifiedReturnCarton }}</text></view>
<view v-if="isReturnBulk" class="section-gap"><ScanTargetInput name="returnSku" v-model="returnSkuCode" label="商品 SKU" placeholder="扫描本来源待换标 SKU" :active="activeTarget === 'return-sku'" @activate="activeTarget = 'return-sku'" @submit="verifyReturnSku" /><button class="secondary" @tap="verifyReturnSku(returnSkuCode)">核对商品 SKU</button><text v-for="group in returnBulkSkuGroups" :key="group.key" class="meta-line">{{ group.skuCode }} · 汇总待贴 {{ group.remaining }} 件(不自动分配)</text></view>
<template v-if="isReturnBulk"><view v-for="item in returnBulkSources" :key="item.itemId" class="item-card section-gap"><text class="business-code">{{ item.cartonMark }} · {{ item.skuCode }}</text><text class="meta-line">目标商品标签:{{ item.newSkuCode || '未提供,禁止登记' }} · 本来源剩余 {{ remainingReturnQuantity(item) }} 件</text><ScanTargetInput :name="'target-' + item.itemId" v-model="returnTargetLabels[item.itemId]" label="目标商品 SKU 标签" placeholder="扫描目标商品标签" :active="activeTarget === 'target-' + item.itemId" @activate="activeTarget = 'target-' + item.itemId" /><input v-model="bulkQuantities[item.itemId]" type="number" placeholder="必填:本来源本次实际贴标件数" /><button class="secondary" :disabled="busy" @tap="fillAllReturn(item)">本来源全部(需确认)</button><button class="primary" :disabled="busy" @tap="completeBulkSku(item)">登记本来源实际数量</button></view></template>
<view v-if="taskTab === 'carton' && !isReturnBulk" class="section-gap"><ScanTargetInput name="quickCarton" v-model="quickCarton" label="仓库唛头" placeholder="扫描箱唛自动确认,手输后按完成" :active="activeTarget === 'quick-carton'" @activate="activeTarget = 'quick-carton'" @submit="submitCarton" /></view>
<view v-if="taskTab === 'sku'" class="section-gap">
<ScanTargetInput name="quickSku" v-model="quickSku" label="旧 SKU 标签" placeholder="扫描旧 SKU 自动确认" :active="activeTarget === 'quick-sku'" @activate="activeTarget = 'quick-sku'" @submit="submitSku" />
<text v-if="skuChoices.length" class="hint">同 SKU 对应多个明细,请选择本次换标的来源箱:</text>
<button v-for="row in skuChoices" :key="`${row.item.itemId}:${row.index}`" :disabled="busy" @tap="commitSku(row)">{{ row.item.cartonMark }} · {{ row.item.sourcePalletCode || '未提供托盘信息' }}:{{ row.sku.skuCode }} 换标 {{ row.sku.newSkuCode }} · {{ row.sku.quantity }} 件</button>
<view v-for="row in pendingSkuRows" :key="`${row.item.itemId}:${row.index}`" class="item-card sku-detail-card"><text class="business-code">{{ row.sku.skuCode }} 换标 {{ row.sku.newSkuCode }}</text><text class="meta-line">来源箱号:{{ row.item.cartonMark || '未提供' }} · {{ row.sku.quantity }} 件</text><text class="meta-line">{{ row.item.sourcePalletBasis === 'RETURN_CARTON' ? '所在托盘' : '参考来源托盘' }}:{{ row.item.sourcePalletCode === 'UNBOUND' ? '未绑定托盘' : row.item.sourcePalletCode || '未提供托盘信息' }}</text><text v-if="row.item.sourcePalletBasis === 'INVENTORY_REFERENCE' && row.item.sourcePalletCode && row.item.sourcePalletCode !== 'UNBOUND'" class="meta-line">参考托盘来自库存预留,实际箱子位置请现场核对。</text><button class="link-button" @tap="selectItem(row.item); changeAction('EXCEPTION')">异常 / 跳过</button></view>
</view>
<view v-if="taskTab === 'carton'" class="item-list carton-label-list"><view v-for="item in visibleItems" :key="item.itemId" class="carton-label-item" :class="{ done: remaining(item) === 0, current: selectedItem?.itemId === item.itemId }" @tap="selectItem(item)">
<view class="carton-label"><text class="label-caption">仓库箱唛</text><text class="label-code">{{ item.cartonMark || '—' }}</text></view><text class="label-arrow">→</text><view class="carton-label fba-label"><text class="label-caption">FBA 箱唛</text><text class="label-code">{{ item.fbaCartonMark || '—' }}</text></view>
<text v-if="fbaDestinations.length > 1" class="destination-tag">{{ item.destinationFbaWarehouseCode || '待维护' }}</text>
</view></view>
<template v-if="taskTab === 'sku' && selectedItem && action !== 'RELABEL' && remaining(selectedItem) > 0">
<view class="mode-row section-gap"><button v-for="mode in actionModes" :key="mode.key" class="mode-button" :class="{ active: action === mode.key }" @tap="changeAction(mode.key)">{{ mode.name }}</button></view>
<ScanTargetInput name="oldLabel" v-model="oldLabel" label="旧标签" placeholder="扫描或输入旧标签" :active="activeTarget === 'old'" @activate="activeTarget = 'old'" @submit="handleOldLabel" />
<ScanTargetInput v-if="action === 'RELABEL'" name="newLabel" v-model="newLabel" label="新标签" :placeholder="`应匹配 ${selectedItem.newSkuCode || '审批新 SKU'}`" :active="activeTarget === 'new'" @activate="activeTarget = 'new'" @submit="handleNewLabel" />
<ScanTargetInput name="oldLabel" v-model="oldLabel" label="仓库唛头" placeholder="扫描或输入仓库唛头" :active="activeTarget === 'old'" @activate="activeTarget = 'old'" @submit="handleOldLabel" />
<template v-if="action !== 'RELABEL'"><textarea v-model="reason" class="reason-input section-gap" maxlength="1000" placeholder="必填:跳过或异常原因" /><view v-if="action === 'EXCEPTION'" class="mode-row section-gap"><button v-for="item in exceptionCodes" :key="item.key" class="mode-button" :class="{ active: exceptionCode === item.key }" @tap="exceptionCode = item.key">{{ item.name }}</button></view></template>
<button class="primary" :disabled="busy || !canSubmitUnit" @tap="submitUnit">{{ busyAction === 'unit' ? '提交中…' : unitActionText }}</button>
</template>
<view class="work-card section-gap"><view class="row-head"><text class="card-title">完成照片</text><text class="status-pill">{{ photoFileIds.length }} 张</text></view><button class="secondary" :disabled="busy" @tap="choosePhoto">拍照或从相册选择</button><text v-for="(name,index) in photoNames" :key="`${name}:${index}`" class="meta-line">{{ index + 1 }}. {{ name || '换标现场照片' }}</text><textarea v-model="remark" class="reason-input section-gap" maxlength="1000" placeholder="完成备注(选填)" /><button class="primary" :disabled="busy || !allProcessed || !photoFileIds.length" @tap="completeTask">{{ busyAction === 'complete' ? '提交中…' : '完成现场换标并等待复核' }}</button><text class="hint">每件货物必须换标、跳过或登记异常,且至少上传一张现场照片。</text></view>
<view class="section-gap">
<ReturnRelabelPackWork v-if="showReturnPack" :key="current.relabelNo + ':' + (current.reviewedAt || 'INITIAL')" :task="current" :locked="busy" @saved="applyCurrent" @refresh="reloadCurrent" @error="setError" @busy="busy = $event" />
<RelabelPalletWork v-if="requiresPalletBinding && (taskTab === 'carton' || isReturnTask)" ref="palletWork" :key="current.relabelNo" :task="palletTask" :allow-disassemble="true" @activate="activeTarget = 'pallet-work'" @busy="busy = $event" @saved="reloadCurrent" />
<button class="primary" :disabled="busy || Boolean(completionBlockReason)" @tap="completeTask">{{ busyAction === 'complete' ? '提交中…' : allProcessed ? '完成现场换标并等待复核' : '手动完成并登记异常' }}</button>
<view class="row-head"><text class="card-title">完成照片</text><text class="status-pill">{{ photoFileIds.length }} / 20 张</text></view>
<button class="secondary" :disabled="busy || photoFileIds.length >= 20" @tap="choosePhoto">拍照或从相册选择</button>
<view class="photo-grid"><view v-for="(fileId,index) in photoFileIds" :key="fileId" class="photo-tile">
<image v-if="photoUrls[fileId]" class="photo-thumbnail" :src="photoUrls[fileId]" mode="aspectFill" @tap="previewPhoto(fileId)" />
<button v-else class="photo-thumbnail photo-placeholder" @tap="loadPhoto(fileId)">{{ photoErrors[fileId] ? '加载失败,点击重试' : '照片加载中' }}</button>
<text class="photo-name">{{ photoNames[index] || '换标现场照片' }}</text>
<view class="photo-actions"><button class="link-button" :disabled="busy" @tap="retakePhoto(index)">重拍</button><button class="link-button" :disabled="busy" @tap="removePhoto(index)">移除</button></view>
</view></view>
<textarea v-model="remark" class="reason-input section-gap" maxlength="900" :placeholder="allProcessed ? '完成备注(选填)' : '必填:未全部扫描完成的仓库异常作业备注'" />
<view v-if="completionBlockReason" class="error-box section-gap"><text class="error-title">还不能完成换标</text><text>{{ completionBlockReason }}</text></view>
</view>
</template>
</template>
<template v-else-if="stage === 'review'">
<view class="section-gap"><text class="card-title">待客户确认</text><text class="meta-line">现场换标已经完成。请客户在客户工作台核对完工结果并确认;需要退回重做时由平台处理。</text></view>
</template>
<template v-else><view v-if="current.taskStatus !== 'WAIT_REVIEW'" class="empty-box section-gap"><text class="empty-copy">当前任务尚未进入待复核状态。</text></view><template v-else><textarea v-model="remark" class="reason-input section-gap" maxlength="1000" placeholder="复核备注;退回重做时建议填写原因" /><view class="action-row"><button class="danger" :disabled="busy" @tap="review('REWORK')">退回重做</button><button class="primary" :disabled="busy" @tap="review('APPROVE')">复核通过</button></view></template></template>
</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 class="secondary" @tap="retry">重新读取任务</button></view>
<view v-if="successMessage" class="success-box" role="status"><text class="success-title">{{ successMessage }}</text></view>
</view></scroll-view>
</view>
@@ -42,64 +74,311 @@
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ReturnRelabelPackWork from '@/components/ReturnRelabelPackWork.vue'
import { requiresReturnPack, returnPackFacts } from '@/utils/return-relabel.js'
import RelabelPalletWork from '@/components/RelabelPalletWork.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { claimRelabelTask, completeRelabelTask, getRelabelTask, listRelabelCandidates, relabelUnit, reportRelabelException, reviewRelabelTask, skipRelabelUnit, uploadRelabelPhoto } from '@/api/relabel.js'
import { session, requireSession } from '@/stores/session.js'
import { releaseWarehouseKey, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { scanRelabelSku, scanRelabelCarton, completeReturnRelabelSku, listRelabelDocuments, downloadRelabelDocument, downloadRelabelPhoto } from '@/api/relabel.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
import { isRelabelFundsBlocked, relabelErrorMessage } from '@/utils/relabel-error.js'
const STAGES = { relabelReceive: 'receive', relabelExecute: 'execute', relabelReview: 'review' }
const isConflict = (error) => ['409', 'A0409', 'B0409'].includes(String(error?.code || ''))
export default {
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, stage: 'receive', candidates: [], current: null, selectedItem: null, activeTarget: 'task', loading: false, busy: false, busyAction: '', error: null, successMessage: '', exceptionCode: 'OTHER', actionModes: [{ key: 'RELABEL', name: '正常换标' }, { key: 'SKIP', name: '跳过' }, { key: 'EXCEPTION', name: '异常' }], exceptionCodes: [{ key: 'DAMAGED', name: '破损' }, { key: 'MISSING_LABEL', name: '缺标签' }, { key: 'LABEL_MISMATCH', name: '标签不符' }, { key: 'UNREADABLE', name: '无法识别' }, { key: 'OTHER', name: '其他' }] } },
components: { PortalHeader, ScanTargetInput, RelabelPalletWork, ReturnRelabelPackWork },
data() { return { user: session.currentUser, stage: 'receive', entryTaskNo: '', unitDrafts: {}, taskTab: 'sku', candidates: [], current: null, selectedItem: null, returnCartonCode: '', verifiedReturnCarton: '', returnTargetLabels: {}, quickTargetLabel: '', quickActualQuantity: '', returnSkuCode: '', verifiedReturnSkuKey: '', bulkQuantities: {}, activeTarget: 'task', loading: false, busy: false, busyAction: '', error: null, successMessage: '', exceptionCode: 'OTHER', actionModes: [{ key: 'RELABEL', name: '正常换标' }, { key: 'SKIP', name: '跳过' }, { key: 'EXCEPTION', name: '异常' }], exceptionCodes: [{ key: 'DAMAGED', name: '破损' }, { key: 'MISSING_LABEL', name: '缺标签' }, { key: 'LABEL_MISMATCH', name: '标签不符' }, { key: 'UNREADABLE', name: '无法识别' }, { key: 'OTHER', name: '其他' }] } },
computed: {
title() { return ({ receive: '换标收货', execute: '换标执行', review: '换标复核' })[this.stage] },
candidateHeading() { return this.stage === 'review' ? '待复核任务' : this.stage === 'execute' ? '处理中任务' : '可认领任务' },
palletTask() {
if (!this.isReturnTask) return this.current
const boxes = new Map()
for (const item of this.current.items || []) {
const previous = boxes.get(item.cartonMark)
const done = Number(item.relabelledPackages) === Number(item.expectedPackages)
boxes.set(item.cartonMark, { ...item, operationType: 'CARTON_LABEL', expectedPackages: 1, relabelledPackages: done && (!previous || previous.relabelledPackages === 1) ? 1 : 0 })
}
return { ...this.current, operationType: 'CARTON_LABEL', items: [...boxes.values()] }
},
isReturnTask() { return (this.current?.items || []).some(item => item.returnCartonId) },
showReturnPack() { return this.isReturnTask && (requiresReturnPack(this.current) || returnPackFacts(this.current).length > 0) },
requiresPalletBinding() { return this.isReturnTask ? this.current?.requiresPalletBinding === true || this.current?.deliveryMethod === 'TRUCK' : true },
returnBulkSources() { return (this.current?.items || []).filter(item => item.returnCartonId && item.operationType === 'RETURN_BULK_SKU_LABEL' && item.cartonMark === this.verifiedReturnCarton && item.skuCode.toUpperCase() === this.returnSkuCode.trim().toUpperCase() && this.remainingReturnQuantity(item) > 0) },
fbaDestinations() { return [...new Set((this.current?.items || []).map(item => item.destinationFbaWarehouseCode).filter(Boolean))] },
isReturnBulk() { return this.current?.operationType === 'RETURN_BULK_SKU_LABEL' },
returnBulkSkuGroups() {
const groups = new Map()
for (const item of (this.current?.items || []).filter(row => row.operationType === 'RETURN_BULK_SKU_LABEL')) {
const skuCode = String(item.skuCode || '').trim()
const destination = String(item.destinationFbaWarehouseCode || '').trim()
const key = `${skuCode.toUpperCase()}::${destination.toUpperCase()}`
if (!groups.has(key)) groups.set(key, { key, skuCode, destinationFbaWarehouseCode: destination, expectedPackages: 0, relabelledPackages: 0, skippedPackages: 0, exceptionPackages: 0, sources: [] })
const group = groups.get(key)
group.expectedPackages += Number(item.expectedPackages || 0)
group.relabelledPackages += Number(item.relabelledPackages || 0)
group.skippedPackages += Number(item.skippedPackages || 0)
group.exceptionPackages += Number(item.exceptionPackages || 0)
group.sources.push({ itemId: item.itemId, cartonMark: item.cartonMark, expectedPackages: Number(item.expectedPackages || 0) })
}
return [...groups.values()].map(group => ({ ...group, remaining: Math.max(0, group.expectedPackages - group.relabelledPackages - group.skippedPackages - group.exceptionPackages) }))
},
showCurrent() { if (!this.current) return false; if (this.stage === 'review') return this.current.taskStatus === 'WAIT_REVIEW'; const owned = this.candidates.some((item) => item.relabelNo === this.current.relabelNo && item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator === true); if (this.stage === 'execute') return this.current.taskStatus === 'PROCESSING' && owned; return !this.current.taskStatus || this.current.taskStatus === 'AVAILABLE' || (this.current.taskStatus === 'PROCESSING' && owned) },
stageSubtitle() { return this.stage === 'review' ? '仅展示当前仓库已完成现场换标并等待客户或平台处理的单据' : this.stage === 'execute' ? '仅展示当前账号已认领且正在处理的换标任务' : '退回重做单置顶优先处理;同一账号一次只能执行一票' },
availableTabs() { const items = this.current?.items || []; return [{ key: 'sku', name: 'SKU 换标', types: ['SKU_LABEL', 'CARTON_AND_SKU'] }, { key: 'carton', name: '箱唛换标', types: ['CARTON_LABEL', 'CARTON_AND_SKU'] }].filter((tab) => items.some((item) => tab.types.includes(item.operationType))) },
unitDraft() { return this.unitDrafts?.[this.selectedItem?.itemId] || { cartonMark: '', fbaCartonMark: '', skuVerifications: [] } },
skuVerified() { if (!this.selectedItem || this.selectedItem.operationType === 'CARTON_LABEL') return true; const expected = this.skuDetails(this.selectedItem); return expected.length > 0 && expected.every((sku, index) => { const actual = this.unitDraft.skuVerifications[index]; return actual && actual.skuCode.trim() === sku.skuCode && actual.newSkuCode.trim() === sku.newSkuCode && Number(actual.quantity) > 0 && Number(actual.quantity) === Number(sku.quantity) }) },
cartonVerified() { return Boolean(this.selectedItem && this.oldLabel.trim() === this.selectedItem.cartonMark && (this.taskTab === 'sku' || this.newLabel.trim() === this.selectedItem.fbaCartonMark && this.newLabel.trim())) },
title() { return ({ receive: '换标列表', execute: '换标执行', review: '换标复核' })[this.stage] },
candidateHeading() { return this.stage === 'review' ? '待复核任务' : this.stage === 'execute' ? '处理中任务' : '换标任务' },
emptyText() { return `暂无${this.candidateHeading}` },
visibleCandidates() { if (this.stage === 'review') return this.candidates.filter((item) => item.taskStatus === 'WAIT_REVIEW'); if (this.stage === 'execute') return this.candidates.filter((item) => item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator); return this.candidates.filter((item) => !item.taskStatus || (item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator)) },
visibleCandidates() { const candidates = this.candidates; if (this.stage === 'review') return candidates.filter((item) => item.taskStatus === 'WAIT_REVIEW'); if (this.stage === 'execute') return candidates.filter((item) => item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator); return candidates.filter((item) => !item.taskStatus || item.taskStatus === 'AVAILABLE' || (item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator)).sort((left, right) => Number(right.reviewDecision === 'REWORK') - Number(left.reviewDecision === 'REWORK') || String(right.reviewedAt || '').localeCompare(String(left.reviewedAt || ''))) },
visibleItems() { return (this.current?.items || []).filter((item) => (this.taskTab === 'sku' ? ['SKU_LABEL', 'CARTON_AND_SKU'].includes(item.operationType) : ['CARTON_LABEL', 'CARTON_AND_SKU'].includes(item.operationType)) && this.remaining(item) > 0) },
pendingSkuRows() { return this.visibleItems.flatMap(item => this.skuDetails(item).map((sku, index) => ({ item, sku, index })).filter(row => !this.skuDone(row.item.itemId, row.index))) },
requiresNewCartonLabel() { return this.action === 'RELABEL' && this.selectedItem && this.selectedItem.operationType !== 'SKU_LABEL' },
taskCode: { get() { return warehouseWork.relabel.currentNo }, set(value) { updateWarehouseWork('relabel', { currentNo: value }) } },
oldLabel: { get() { return warehouseWork.relabel.oldLabel }, set(value) { updateWarehouseWork('relabel', { oldLabel: value }) } },
newLabel: { get() { return warehouseWork.relabel.newLabel }, set(value) { updateWarehouseWork('relabel', { newLabel: value }) } },
oldLabel: { get() { return this.unitDraft.cartonMark }, set(value) { if (this.selectedItem) this.unitDraft.cartonMark = value } },
newLabel: { get() { return this.unitDraft.fbaCartonMark }, set(value) { if (this.selectedItem) this.unitDraft.fbaCartonMark = value } },
action: { get() { return warehouseWork.relabel.action }, set(value) { updateWarehouseWork('relabel', { action: value }) } },
reason: { get() { return warehouseWork.relabel.reason }, set(value) { updateWarehouseWork('relabel', { reason: value }) } },
remark: { get() { return warehouseWork.relabel.remark }, set(value) { updateWarehouseWork('relabel', { remark: value }) } },
photoFileIds() { return warehouseWork.relabel.photoFileIds || [] }, photoNames() { return warehouseWork.relabel.photoNames || [] },
allProcessed() { return Boolean(this.current?.expectedPackages) && this.current.relabelledPackages + this.current.skippedPackages + this.current.exceptionPackages === this.current.expectedPackages },
canSubmitUnit() { if (!this.selectedItem || !this.oldLabel.trim()) return false; if (this.action === 'RELABEL') return Boolean(this.newLabel.trim()); return Boolean(this.reason.trim()) },
unitActionText() { return ({ RELABEL: '确认本件换标', SKIP: '确认跳过本件', EXCEPTION: '登记本件异常' })[this.action] }
completionBlockReason() {
if (!this.current) return '请先选择换标任务'
if (!this.allProcessed && !this.remark.trim()) return '仍有未扫描明细,请继续处理;需要异常完工时必须填写异常作业备注'
if (this.isReturnTask && requiresReturnPack(this.current) && !returnPackFacts(this.current).length) return '部分换标须先完成全部来源登记并生成独立新箱'
const requiresPalletBinding = this.requiresPalletBinding && (this.current.items || []).some((item) => item.returnCartonId || ['CARTON_LABEL', 'CARTON_AND_SKU', 'RETURN_BULK_SKU_LABEL'].includes(item.operationType))
if (requiresPalletBinding && Number(this.current.relabelledPackages || 0) > 0) {
const round = this.current.reviewedAt || 'INITIAL'
const marks = new Set((this.current.history || []).filter((entry) => entry.action === 'PALLET_BIND').flatMap((entry) => {
try { const detail = JSON.parse(entry.detail); return detail.round === round ? detail.cartonMarks || [] : [] } catch { return [] }
}))
const requiredMarks = new Set((this.current.items || []).filter((item) =>
(item.returnCartonId || ['CARTON_LABEL', 'CARTON_AND_SKU', 'RETURN_BULK_SKU_LABEL'].includes(item.operationType))
&& Number(item.relabelledPackages || 0) > 0).map((item) => item.cartonMark).filter(Boolean))
if ([...requiredMarks].some((mark) => !marks.has(mark))) return '请先在“换标后组托”中完成本轮已换标箱的拆托合托绑定'
}
if (!this.photoFileIds.length) return '请至少上传 1 张换标完成照片'
return ''
},
canSubmitUnit() { if (!this.selectedItem || !this.visibleItems.some((item) => item.itemId === this.selectedItem.itemId) || !this.oldLabel.trim()) return false; if (this.action === 'RELABEL') return this.cartonVerified && this.skuVerified; return Boolean(this.reason.trim()) },
unitActionText() { return ({ RELABEL: '确认本箱换标完成', SKIP: '确认跳过本箱', EXCEPTION: '登记本箱异常' })[this.action] }
},
onLoad(options) { this.stage = STAGES[options.op] || 'receive' },
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.startScanner(); this.bootstrap() },
onLoad(options) { this.stage = STAGES[options.op] || 'receive'; this.entryTaskNo = String(options.task || '').trim() },
mixins: [{ data() { return { documents: [], quickCarton: '', lastCarton: null, quickSku: '', lastSku: '', skuChoices: [], photoUrls: {}, photoErrors: {} } } }],
watch: {
'current.reviewedAt'(value) { if (this.current?.reviewDecision === 'REWORK' && warehouseWork.relabel.reworkRound !== `${this.current.relabelNo}:${value}`) { this.unitDrafts = {}; this.selectedItem = null; this.lastCarton = null; this.quickCarton = ''; this.verifiedReturnCarton = ''; this.returnCartonCode = ''; this.returnTargetLabels = {}; this.bulkQuantities = {}; this.quickTargetLabel = ''; this.quickActualQuantity = ''; updateWarehouseWork('relabel', { reworkRound: `${this.current.relabelNo}:${value}`, photoFileIds: [], photoNames: [], remark: '' }) } },
photoFileIds: { deep: true, handler(ids) { for (const id of ids) if (!this.photoUrls[id]) this.loadPhoto(id) } },
'current.relabelNo'() { this.photoUrls = {}; this.photoErrors = {}; for (const id of this.photoFileIds) this.loadPhoto(id) }
},
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); if (this.stage === 'execute') this.startScanner(); this.bootstrap() },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: {
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleHardwareScan(value), onError: (error) => this.setError(error, '扫码广播注册失败') }) },
skuDone(itemId, index) { return (this.current?.history || []).some(entry => { if (entry.action !== 'SKU_SCAN') return false; try { const data = JSON.parse(entry.detail); return String(data.itemId) === String(itemId) && data.skuIndex === index && data.round === (this.current.reviewedAt || 'INITIAL') } catch { return false } }) },
async submitSku(raw) {
const oldSku = String(raw?.detail?.value ?? raw ?? this.quickSku).trim()
if (!oldSku || this.busy || !this.current) return
this.quickSku = oldSku
const matches = this.pendingSkuRows.filter(row => row.sku.skuCode === oldSku)
this.skuChoices = []
if (!matches.length) {
const exists = (this.current.items || []).some(item => this.skuDetails(item).some(sku => sku.skuCode === oldSku))
return this.setError(new Error(exists ? '该 SKU 已处理完成,不需要重复换标' : '本单没有该 SKU,不需要换标'))
}
if (matches.length > 1) { this.skuChoices = matches; return }
await this.commitSku(matches[0])
},
async commitSku(row) {
if (this.busy || this.quickSku.trim() !== row.sku.skuCode) return
if (row.item.returnCartonId && (!row.item.cartonMark || !row.sku.newSkuCode || !Number.isSafeInteger(Number(row.sku.quantity)) || Number(row.sku.quantity) <= 0)) return this.setError(new Error('来源箱号、目标 SKU 或申请件数缺失,请先维护换标明细'))
const payload = { itemId: row.item.itemId, ...(row.item.returnCartonId ? { cartonMark: row.item.cartonMark, newSkuCode: row.sku.newSkuCode, quantity: Number(row.sku.quantity) } : {}), skuIndex: row.index, oldSku: this.quickSku.trim(), expectedVersion: this.current.version }
const key = warehouseIdempotencyKey('relabel', 'sku-scan', { relabelNo: this.current.relabelNo, ...payload })
await this.run('sku-scan', async () => {
const value = await scanRelabelSku(this.current.relabelNo, { ...payload, idempotencyKey: key.value })
this.applyCurrent(value); releaseWarehouseKey('relabel', key.signature)
const confirmed = value.items.find(item => item.itemId === row.item.itemId)?.skuItems?.[row.index]
this.lastSku = confirmed ? `${confirmed.skuCode} 换标 ${confirmed.newSkuCode} · ${confirmed.quantity} 件` : '已保存,请重新读取标签绑定信息'
this.quickSku = ''; this.quickTargetLabel = ''; this.quickActualQuantity = ''; this.skuChoices = []; this.activeTarget = 'quick-sku'
await new Promise(resolve => uni.showModal({ title: '请粘贴对应 SKU 标签', content: `${row.item.cartonMark}\n${this.lastSku}`, showCancel: false, confirmText: '知道了', complete: resolve }))
}, true, key)
},
relabelTypeName(type) { return ({ CARTON_LABEL: '只换箱唛', SKU_LABEL: '只换 SKU', CARTON_AND_SKU: '混合换标(SKU + 箱唛)', RETURN_BULK_SKU_LABEL: '退货贴标(按 SKU 数量)' })[type] || '换标类型待确认' },
remainingReturnQuantity(item) { return Math.max(0, Number(item.expectedPackages || 0) - Number(item.relabelledPackages || 0) - Number(item.skippedPackages || 0) - Number(item.exceptionPackages || 0)) },
verifyReturnSku(raw) {
if (this.busy) return
const code = String(raw?.detail?.value ?? raw ?? this.returnSkuCode).trim()
this.returnSkuCode = code; this.verifiedReturnSkuKey = ''
if (!this.verifiedReturnCarton || this.returnCartonCode.trim() !== this.verifiedReturnCarton) return this.setError(new Error('请先确认来源包裹/箱'))
const matches = this.returnBulkSources
if (!matches.length) { this.setError(new Error('该 SKU 不属于已扫描来源的待贴标明细,或已全部登记')); return }
this.error = null; this.verifiedReturnSkuKey = code.toUpperCase(); this.activeTarget = 'return-sku'
},
verifyReturnCarton(raw) {
if (this.busy) return
const mark = String(raw?.detail?.value ?? raw ?? this.returnCartonCode).trim()
this.returnCartonCode = mark; this.verifiedReturnCarton = ''; this.returnSkuCode = ''; this.verifiedReturnSkuKey = ''; this.returnTargetLabels = {}; this.bulkQuantities = {}; this.quickSku = ''; this.quickTargetLabel = ''; this.quickActualQuantity = ''; this.skuChoices = []
if (!(this.current?.items || []).some(item => item.returnCartonId && item.cartonMark === mark)) return this.setError(new Error('该包裹/箱不属于本单真实退货来源'))
this.verifiedReturnCarton = mark; this.activeTarget = this.taskTab === 'carton' ? 'quick-carton' : 'quick-sku'
},
async fillAllReturn(item) {
const no = this.current.relabelNo; const version = this.current.version; const mark = this.verifiedReturnCarton
if (this.busy || item.cartonMark !== mark || this.returnCartonCode.trim() !== mark) return
const confirmed = await new Promise(resolve => uni.showModal({ title: '登记本来源全部', content: mark + ' · ' + item.skuCode + ':确认实际已贴标剩余 ' + this.remainingReturnQuantity(item) + ' 件?', success: result => resolve(result.confirm), fail: () => resolve(false) }))
if (confirmed && this.current.relabelNo === no && this.current.version === version && this.verifiedReturnCarton === mark) this.bulkQuantities[item.itemId] = String(this.remainingReturnQuantity(item))
},
async completeBulkSku(item) {
if (this.busy) return
if (!this.verifiedReturnCarton || this.returnCartonCode.trim() !== this.verifiedReturnCarton || item.cartonMark !== this.verifiedReturnCarton || this.returnSkuCode.trim().toUpperCase() !== item.skuCode.toUpperCase() || !item.newSkuCode || this.returnTargetLabels[item.itemId]?.trim() !== item.newSkuCode) return this.setError(new Error('请先确认本来源包裹、商品 SKU 和目标商品标签'))
const entered = String(this.bulkQuantities[item.itemId] ?? '').trim(); const quantity = entered ? Number(entered) : NaN
const remaining = this.remainingReturnQuantity(item)
if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > remaining) return this.setError(new Error('本来源实际贴标数量必填,须为 1 至 ' + remaining + ' 件'))
const payload = { itemId: item.itemId, cartonMark: item.cartonMark, skuCode: item.skuCode, quantity, expectedVersion: this.current.version }
const key = warehouseIdempotencyKey('relabel', 'return-bulk-sku', { relabelNo: this.current.relabelNo, ...payload })
await this.run('return-bulk-sku', async () => { this.applyCurrent(await completeReturnRelabelSku(this.current.relabelNo, { ...payload, idempotencyKey: key.value })); releaseWarehouseKey('relabel', key.signature); delete this.bulkQuantities[item.itemId]; this.successMessage = item.cartonMark + ' · ' + item.skuCode + ' 已登记 ' + quantity + ' 件' }, true, key)
},
async loadPhoto(fileId) {
const no = this.current?.relabelNo
if (!no || this.photoUrls[fileId] || !this.photoFileIds.includes(fileId)) return
this.photoErrors[fileId] = false
try { const path = await downloadRelabelPhoto(no, fileId); if (this.current?.relabelNo === no && this.photoFileIds.includes(fileId)) this.photoUrls[fileId] = path }
catch (_) { if (this.current?.relabelNo === no) this.photoErrors[fileId] = true }
},
previewPhoto(fileId) { const urls = this.photoFileIds.map((id) => this.photoUrls[id]).filter(Boolean); if (this.photoUrls[fileId]) uni.previewImage({ current: this.photoUrls[fileId], urls }) },
retakePhoto(index) {
if (this.busy || !this.current) return
const no = this.current.relabelNo; const oldId = this.photoFileIds[index]
uni.chooseImage({ count: 1, sourceType: ['camera'], success: ({ tempFilePaths, tempFiles }) => {
if (this.current?.relabelNo !== no || !this.photoFileIds.includes(oldId) || !tempFilePaths?.[0]) return
this.run('photo', async () => {
if (Number(tempFiles?.[0]?.size || 0) > 10 * 1024 * 1024) throw new Error('照片不能超过 10MB')
const file = await uploadRelabelPhoto(no, tempFilePaths[0])
const ids = [...this.photoFileIds]; const names = [...this.photoNames]; const position = ids.indexOf(oldId)
if (position < 0 || this.current?.relabelNo !== no) return
ids[position] = String(file.fileId); names[position] = file.fileName || '换标现场照片'
this.photoUrls[String(file.fileId)] = tempFilePaths[0]
updateWarehouseWork('relabel', { photoFileIds: ids, photoNames: names })
this.successMessage = '照片已重新拍摄并替换'
}, false)
} })
},
async loadDocuments() { if (!this.current) return; const no = this.current.relabelNo; try { const files = await listRelabelDocuments(no); if (this.current?.relabelNo === no) this.documents = files || [] } catch (error) { this.setError(error, '标签文件加载失败') } },
async printDocument(file) { await this.run('document', async () => { const path = await downloadRelabelDocument(this.current.relabelNo, file);
// #ifdef H5
const frame = document.createElement('iframe'); frame.style.cssText = 'position:fixed;width:1px;height:1px;border:0'; frame.src = path; frame.onload = () => { try { frame.contentWindow.focus(); frame.contentWindow.print() } catch (_) { window.open(path, '_blank', 'noopener') } }; document.body.appendChild(frame); setTimeout(() => frame.remove(), 120000);
// #endif
// #ifndef H5
await new Promise((resolve, reject) => uni.openDocument({ filePath: path, fileType: 'pdf', showMenu: true, success: resolve, fail: reject }));
// #endif
}, false) },
async submitCarton(raw) {
const mark = String(raw?.detail?.value ?? raw ?? this.quickCarton).trim()
if (!mark || this.busy || !this.current) return
const item = this.current.items.find((row) => row.cartonMark === mark && row.operationType !== 'SKU_LABEL')
if (!item) return this.setError(new Error('该仓库唛头不属于本单箱唛换标明细'))
// The carton scan identifies the physical return source and records its label stage in one request.
const payload = { relabelNo: this.current.relabelNo, cartonMark: mark, expectedVersion: this.current.version }
const key = warehouseIdempotencyKey('relabel', 'carton-scan', payload)
await this.run('carton-scan', async () => {
const result = await scanRelabelCarton(payload.relabelNo, { cartonMark: mark, expectedVersion: payload.expectedVersion, idempotencyKey: key.value })
this.applyCurrent(result)
releaseWarehouseKey('relabel', key.signature)
const confirmedItem = result.items.find((row) => row.itemId === item.itemId)
this.lastCarton = { cartonMark: mark, fbaCartonMark: confirmedItem?.fbaCartonMark || item.fbaCartonMark, destinationFbaWarehouseCode: confirmedItem?.destinationFbaWarehouseCode || item.destinationFbaWarehouseCode }
this.quickCarton = ''
this.activeTarget = 'quick-carton'
this.successMessage = ''
await new Promise((resolve) => uni.showModal({ title: '请粘贴对应箱唛', content: `仓库唛头:${mark}\n请贴 FBA 箱唛:${this.lastCarton.fbaCartonMark}\nFBA 目的仓:${this.lastCarton.destinationFbaWarehouseCode || '待维护'}`, showCancel: false, confirmText: '知道了', complete: resolve }))
}, true, key)
},
removePhoto(index) { updateWarehouseWork('relabel', { photoFileIds: this.photoFileIds.filter((_, i) => i !== index), photoNames: this.photoNames.filter((_, i) => i !== index) }) },
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => { if (this.busy) return; if (this.activeTarget === 'pallet-work') { this.$refs.palletWork?.handleScan(value); return } if (this.activeTarget === 'quick-carton') { this.quickCarton = value; this.submitCarton(value) } else this.handleHardwareScan(value) }, onError: (error) => this.setError(error, '扫码广播注册失败') }) },
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
handleHardwareScan(value) { if (this.activeTarget === 'old') { this.oldLabel = value; this.activeTarget = this.action === 'RELABEL' ? 'new' : 'old'; return } if (this.activeTarget === 'new') { this.newLabel = value; return } this.taskCode = value; this.openTask(value) },
handleOldLabel(value) { this.oldLabel = String(value?.detail?.value ?? value ?? '').trim(); if (this.action === 'RELABEL') this.activeTarget = 'new' },
handleHardwareScan(value) { if (this.activeTarget === 'return-carton') { this.verifyReturnCarton(value); return } if (this.activeTarget.startsWith('target-')) { this.returnTargetLabels[this.activeTarget.slice(7)] = value; return } if (this.activeTarget === 'quick-target') { this.quickTargetLabel = value; return } if (this.activeTarget === 'return-sku') { this.verifyReturnSku(value); return } if (this.activeTarget === 'quick-sku') { this.quickSku = value; this.submitSku(value); return } if (this.activeTarget === 'old') { this.oldLabel = value; return } this.taskCode = value; this.openTask(value) },
handleOldLabel(value) { this.oldLabel = String(value?.detail?.value ?? value ?? '').trim(); if (this.action === 'RELABEL') this.activeTarget = this.taskTab === 'sku' ? 'sku-new-0' : 'new' },
handleNewLabel(value) { this.newLabel = String(value?.detail?.value ?? value ?? '').trim() },
async bootstrap() { await this.loadCandidates(); if (this.taskCode) await this.openTask(this.taskCode, false) },
async bootstrap() { await this.loadCandidates(); if (this.error || this.stage !== 'execute' || !this.entryTaskNo) return; const target = this.candidates.find((item) => item.relabelNo === this.entryTaskNo && item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator === true); this.entryTaskNo = ''; if (target) await this.openTask(target.relabelNo, false) },
async loadCandidates() { if (this.loading) return; this.loading = true; this.error = null; try { this.candidates = await listRelabelCandidates() } catch (error) { this.setError(error, '换标任务加载失败') } finally { this.loading = false } },
async openTask(raw, sound = true) { const no = String(raw?.detail?.value ?? raw ?? this.taskCode).trim(); if (!no || this.busy) return; this.taskCode = no; await this.run('detail', async () => { this.applyCurrent(await getRelabelTask(no)); if (sound) playSuccessSound() }, false) },
applyCurrent(value) { this.current = value; this.taskCode = value.relabelNo; if (this.selectedItem) this.selectedItem = value.items.find((item) => item.itemId === this.selectedItem.itemId) || null },
selectItem(item) { if (this.remaining(item) <= 0) return; this.selectedItem = item; this.oldLabel = ''; this.newLabel = ''; this.reason = ''; this.activeTarget = 'old' },
remaining(item) { return Math.max(0, item.expectedPackages - item.relabelledPackages - item.skippedPackages - item.exceptionPackages) },
applyCurrent(value) { const changed = this.current?.relabelNo !== value.relabelNo; this.current = value; this.taskCode = value.relabelNo; if (changed) { this.unitDrafts = {}; this.documents = []; this.quickCarton = ''; this.lastCarton = null; this.returnCartonCode = ''; this.verifiedReturnCarton = ''; this.returnTargetLabels = {}; this.quickTargetLabel = ''; this.quickActualQuantity = ''; this.returnSkuCode = ''; this.verifiedReturnSkuKey = ''; this.bulkQuantities = {}; if (warehouseWork.relabel.photoTaskNo !== value.relabelNo) updateWarehouseWork('relabel', { photoTaskNo: value.relabelNo, photoFileIds: [], photoNames: [], remark: '' }); this.changeTab(value.items.some((item) => item.operationType === 'SKU_LABEL') ? 'sku' : 'carton'); this.loadDocuments() } if (this.selectedItem) this.selectedItem = this.visibleItems.find((item) => item.itemId === this.selectedItem.itemId) || null },
changeTab(value) { this.taskTab = value; this.selectedItem = null; this.reason = ''; this.quickSku = ''; this.skuChoices = []; this.activeTarget = value === 'carton' ? 'quick-carton' : this.isReturnBulk ? 'return-carton' : 'quick-sku' },
skuDetails(item) { return item.skuItems?.length ? item.skuItems : [{ skuCode: item.skuCode, newSkuCode: item.newSkuCode, quantity: null }] },
selectItem(item) { if (this.taskTab === 'carton') { this.activeTarget = 'quick-carton'; return } if (this.remaining(item) <= 0) return; if (!this.unitDrafts[item.itemId]) this.unitDrafts[item.itemId] = { cartonMark: '', fbaCartonMark: '', skuVerifications: this.skuDetails(item).map((sku) => ({ skuCode: sku.skuCode || '', newSkuCode: '', quantity: sku.quantity })) }; this.selectedItem = item; this.reason = ''; this.activeTarget = 'old' },
focusNextSku(index) { const next = index + 1; this.activeTarget = next < this.unitDraft.skuVerifications.length ? `sku-new-${next}` : 'old' },
remaining(item) { const processed = this.taskTab === 'sku' ? item.skuProcessedPackages : item.cartonProcessedPackages; const completed = item.operationType === 'CARTON_AND_SKU' ? Number(processed || 0) : Number(processed ?? item.relabelledPackages); return Math.max(0, item.expectedPackages - completed - item.skippedPackages - item.exceptionPackages) },
changeAction(value) { this.action = value; this.newLabel = ''; this.reason = ''; this.activeTarget = 'old' },
async claimTask() { if (!this.current) return; const payload = { relabelNo: this.current.relabelNo }; const key = warehouseIdempotencyKey('relabel', 'claim', payload); await this.run('claim', async () => { this.applyCurrent(await claimRelabelTask(this.current.relabelNo, { idempotencyKey: key.value })); releaseWarehouseKey('relabel', key.signature); this.successMessage = '换标任务已认领,可进入换标执行'; await this.loadCandidates() }, true, key) },
async submitUnit() { if (!this.canSubmitUnit || this.busy) return; const base = { itemId: this.selectedItem.itemId, oldLabel: this.oldLabel.trim(), expectedVersion: this.current.version }; const payload = { relabelNo: this.current.relabelNo, action: this.action, ...base, newLabel: this.newLabel.trim(), reason: this.reason.trim(), exceptionCode: this.exceptionCode }; const key = warehouseIdempotencyKey('relabel', 'unit', payload); await this.run('unit', async () => { let value; if (this.action === 'SKIP') value = await skipRelabelUnit(this.current.relabelNo, { ...base, reason: this.reason.trim(), idempotencyKey: key.value }); else if (this.action === 'EXCEPTION') value = await reportRelabelException(this.current.relabelNo, { ...base, exceptionCode: this.exceptionCode, reason: this.reason.trim(), idempotencyKey: key.value }); else value = await relabelUnit(this.current.relabelNo, { ...base, newLabel: this.newLabel.trim(), idempotencyKey: key.value }); releaseWarehouseKey('relabel', key.signature); this.applyCurrent(value); this.oldLabel = ''; this.newLabel = ''; this.reason = ''; this.activeTarget = 'old'; this.successMessage = '本件处理结果已保存' }, true, key) },
choosePhoto() { if (this.busy || !this.current) return; uni.chooseImage({ count: 1, sourceType: ['camera', 'album'], success: ({ tempFilePaths, tempFiles }) => { const path = tempFilePaths?.[0]; if (Number(tempFiles?.[0]?.size || 0) > 10 * 1024 * 1024) return this.setError(new Error('照片不能超过 10MB')); if (path) this.uploadPhoto(path) } }) },
async promptActiveTask(active) {
if (this.busy) return
this.busy = true
this.error = null
try {
const confirmed = await new Promise((resolve) => uni.showModal({
title: '已有正在执行的换标任务',
content: `当前账号正在执行 ${active.relabelNo},请先完成该任务。是否前往继续作业?`,
confirmText: '前往任务', cancelText: '取消',
success: (result) => resolve(result.confirm), fail: () => resolve(false)
}))
if (!confirmed) return
this.taskCode = active.relabelNo
uni.redirectTo({ url: `/pages/relabel/relabel?op=relabelExecute&task=${encodeURIComponent(active.relabelNo)}` })
} finally { this.busy = false }
},
async confirmStartTask(item) {
if (this.busy) return false
this.busy = true
try {
return await new Promise((resolve) => uni.showModal({
title: '开始贴标作业',
content: `是否开始贴标作业?\n贴标申请:${item.relabelNo}`,
confirmText: '是', cancelText: '否',
success: (result) => resolve(result.confirm), fail: () => resolve(false)
}))
} finally { this.busy = false }
},
async openExecution(item) {
if (this.busy || this.stage === 'review') return
if (this.stage === 'execute') {
if (item.taskStatus !== 'PROCESSING' || item.claimedByCurrentOperator !== true) return
return this.openTask(item.relabelNo)
}
const active = this.candidates.find((candidate) => candidate.taskStatus === 'PROCESSING' && candidate.claimedByCurrentOperator === true)
if (active && active.relabelNo !== item.relabelNo) return this.promptActiveTask(active)
if (item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator) return uni.redirectTo({ url: `/pages/relabel/relabel?op=relabelExecute&task=${encodeURIComponent(item.relabelNo)}` })
const confirmed = await this.confirmStartTask(item)
if (!confirmed) return
this.taskCode = item.relabelNo
const payload = { relabelNo: item.relabelNo }; const key = warehouseIdempotencyKey('relabel', 'claim', payload); let claimed = false
await this.run('claim', async () => { this.applyCurrent(await claimRelabelTask(item.relabelNo, { idempotencyKey: key.value })); releaseWarehouseKey('relabel', key.signature); claimed = true }, true, key)
if (claimed) uni.redirectTo({ url: `/pages/relabel/relabel?op=relabelExecute&task=${encodeURIComponent(item.relabelNo)}` })
},
async openReview(item) { if (this.busy || this.stage !== 'review' || item.taskStatus !== 'WAIT_REVIEW') return; await this.openTask(item.relabelNo) },
async submitUnit() { if (!this.canSubmitUnit || this.busy) return; const base = { itemId: this.selectedItem.itemId, oldLabel: this.oldLabel.trim(), expectedVersion: this.current.version }; const payload = { relabelNo: this.current.relabelNo, action: this.action, ...base, newLabel: this.newLabel.trim(), skuVerifications: this.unitDraft.skuVerifications.map((sku) => ({ ...sku, skuCode: sku.skuCode.trim(), newSkuCode: sku.newSkuCode.trim() })), reason: this.reason.trim(), exceptionCode: this.exceptionCode }; const key = warehouseIdempotencyKey('relabel', 'unit', payload); await this.run('unit', async () => { let value; if (this.action === 'SKIP') value = await skipRelabelUnit(this.current.relabelNo, { ...base, reason: this.reason.trim(), idempotencyKey: key.value }); else if (this.action === 'EXCEPTION') value = await reportRelabelException(this.current.relabelNo, { ...base, exceptionCode: this.exceptionCode, reason: this.reason.trim(), idempotencyKey: key.value }); else value = await relabelUnit(this.current.relabelNo, { ...base, newLabel: this.newLabel.trim(), skuVerifications: payload.skuVerifications, idempotencyKey: key.value }); releaseWarehouseKey('relabel', key.signature); this.applyCurrent(value); this.oldLabel = ''; this.newLabel = ''; this.reason = ''; this.activeTarget = 'old'; this.successMessage = '本箱处理结果已保存' }, true, key) },
choosePhoto() { if (this.busy || !this.current || this.photoFileIds.length >= 20) return; const no = this.current.relabelNo; uni.chooseImage({ count: Math.min(9, 20 - this.photoFileIds.length), sourceType: ['camera', 'album'], success: async ({ tempFilePaths, tempFiles }) => { if (this.current?.relabelNo !== no) return; await this.run('photo', async () => { for (const [index, path] of (tempFilePaths || []).entries()) { const selected = tempFiles?.[index]?.file || tempFiles?.[index]; if (Number(tempFiles?.[index]?.size || 0) > 10 * 1024 * 1024) throw new Error('照片不能超过 10MB'); const uploadSource = typeof Blob !== 'undefined' && selected instanceof Blob ? selected : path; const file = await uploadRelabelPhoto(no, uploadSource); this.photoUrls[String(file.fileId)] = path; updateWarehouseWork('relabel', { photoTaskNo: no, photoFileIds: [...this.photoFileIds, String(file.fileId)], photoNames: [...this.photoNames, file.fileName || '换标现场照片'] }) } this.successMessage = '现场照片已上传' }, false) } }) },
async uploadPhoto(path) { await this.run('photo', async () => { const file = await uploadRelabelPhoto(this.current.relabelNo, path); updateWarehouseWork('relabel', { photoFileIds: [...this.photoFileIds, String(file.fileId)], photoNames: [...this.photoNames, file.fileName || '换标现场照片'] }); this.successMessage = '现场照片已上传' }, false) },
async completeTask() { if (!this.allProcessed || !this.photoFileIds.length) return; const payload = { relabelNo: this.current.relabelNo, photoFileIds: this.photoFileIds, expectedVersion: this.current.version, remark: this.remark.trim() }; const key = warehouseIdempotencyKey('relabel', 'complete', payload); await this.run('complete', async () => { this.applyCurrent(await completeRelabelTask(this.current.relabelNo, { photoFileIds: this.photoFileIds, expectedVersion: this.current.version, idempotencyKey: key.value, remark: this.remark.trim() || undefined })); releaseWarehouseKey('relabel', key.signature); updateWarehouseWork('relabel', { photoFileIds: [], photoNames: [], remark: '' }); this.successMessage = '现场换标已提交复核'; await this.loadCandidates() }, true, key) },
async completeTask() { if (this.busy || this.completionBlockReason) return; const payload = { photoFileIds: [...this.photoFileIds], expectedVersion: this.current.version, remark: this.remark.trim(), manualCompletion: !this.allProcessed }; const no = this.current.relabelNo; if (payload.manualCompletion) { const confirmed = await new Promise((resolve) => uni.showModal({ title: '手动完成换标', content: '未处理箱将登记异常,关联尾程订单将标为异常。确认提交?', success: (result) => resolve(result.confirm), fail: () => resolve(false) })); if (!confirmed || this.current?.relabelNo !== no || this.current.version !== payload.expectedVersion) return } const key = warehouseIdempotencyKey('relabel', 'complete', { relabelNo: no, ...payload }); let completed = false; await this.run('complete', async () => { await completeRelabelTask(no, { ...payload, idempotencyKey: key.value }); releaseWarehouseKey('relabel', key.signature); updateWarehouseWork('relabel', { currentNo: '', photoFileIds: [], photoNames: [], remark: '' }); completed = true }, true, key); if (completed) this.goToClaimList() },
goToClaimList() { this.taskCode = ''; uni.redirectTo({ url: '/pages/relabel/relabel?op=relabelReceive' }) },
closeCurrent() { this.current = null; this.selectedItem = null; this.error = null; this.successMessage = ''; this.activeTarget = 'task'; this.taskCode = '' },
async review(decision) { if (!this.current || this.current.taskStatus !== 'WAIT_REVIEW') return; const payload = { relabelNo: this.current.relabelNo, decision, expectedVersion: this.current.version, remark: this.remark.trim() }; const key = warehouseIdempotencyKey('relabel', 'review', payload); await this.run('review', async () => { this.applyCurrent(await reviewRelabelTask(this.current.relabelNo, { decision, expectedVersion: this.current.version, idempotencyKey: key.value, remark: this.remark.trim() || undefined })); releaseWarehouseKey('relabel', key.signature); this.remark = ''; this.successMessage = decision === 'APPROVE' ? '换标复核已通过' : '换标任务已退回重做'; await this.loadCandidates() }, true, key) },
async retry() { if (this.current?.relabelNo) await this.openTask(this.current.relabelNo, false); else await this.loadCandidates() },
async reloadCurrent() { if (this.current?.relabelNo) this.applyCurrent(await getRelabelTask(this.current.relabelNo)) },
async run(action, work, refreshOnConflict = false, key = null) { if (this.busy) return; this.busy = true; this.busyAction = action; this.error = null; this.successMessage = ''; try { await work(); if (!['detail'].includes(action)) playSuccessSound() } catch (error) { this.setError(error, `${this.title}失败`); if (refreshOnConflict && isConflict(error)) { if (key) releaseWarehouseKey('relabel', key.signature); try { await this.reloadCurrent(); this.error.message += ',已刷新任务版本,请重新确认' } catch (_) {} } } finally { this.busy = false; this.busyAction = '' } },
setError(error, fallback = '换标作业失败') { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
statusLabel(value) { return ({ PENDING_PROCESSING: '待认领', PROCESSING: '处理中', WAIT_REVIEW: '待复核', COMPLETED: '已完成' })[value] || value || '待认领' },
async reloadCurrent() { if (!this.current?.relabelNo) return; this.verifiedReturnCarton = ''; this.returnCartonCode = ''; this.returnTargetLabels = {}; this.bulkQuantities = {}; this.quickTargetLabel = ''; this.quickActualQuantity = ''; this.applyCurrent(await getRelabelTask(this.current.relabelNo)) },
async run(action, work, refreshOnConflict = false, key = null) { if (this.busy) return; this.busy = true; this.busyAction = action; this.error = null; this.successMessage = ''; try { await work(); if (!['detail'].includes(action)) playSuccessSound() } catch (error) { this.setError(error, `${this.title}失败`); if (refreshOnConflict && isConflict(error) && !isRelabelFundsBlocked(error)) { if (key) releaseWarehouseKey('relabel', key.signature); try { await this.reloadCurrent(); this.error.message += ',已刷新任务版本,请重新确认' } catch (_) {} } } finally { this.busy = false; this.busyAction = '' } },
setError(error, fallback = '换标作业失败') {
this.error = { message: relabelErrorMessage(error, fallback), code: error?.code, requestId: error?.requestId, traceId: error?.traceId }
playErrorSound()
const content = [this.error.message, this.error.code && `错误码:${this.error.code}`, this.error.requestId && `requestId:${this.error.requestId}`, this.error.traceId && `traceId:${this.error.traceId}`].filter(Boolean).join('\n')
uni.showModal({ title: '操作提示', content, showCancel: false, confirmText: '知道了' })
},
statusLabel(value) { return ({ AVAILABLE: '待认领', PENDING_PROCESSING: '待认领', PROCESSING: '处理中', WAIT_REVIEW: '待客户/平台处理', COMPLETED: '已完成' })[value] || value || '待认领' },
formatTime(value) { return value ? String(value).replace('T', ' ').slice(0, 16) : '—' }
}
}
</script>
<style scoped lang="scss">@import '../warehouse/operations.scss';.action-row>.danger,.action-row>.primary{width:50%}.item-card.current{border-color:#6ea3ee;background:#f3f8ff}</style>
<style scoped lang="scss">@import '../warehouse/operations.scss';.action-row>.danger,.action-row>.primary{width:50%}.item-card.current{border-color:#6ea3ee;background:#f3f8ff}.task-card.rework-card{border:2px solid #f29b38;background:#fffaf2;box-shadow:0 5px 16px rgba(221,116,20,.13)}.rework-banner{display:flex;align-items:center;gap:8px;margin-bottom:9px}.rework-tag,.rework-priority{display:inline-flex;padding:4px 9px;border-radius:999px;font-size:11px;font-weight:800}.rework-tag{background:#d94343;color:#fff}.rework-priority{background:#fff0d7;color:#b55a0b}.rework-remark{margin-bottom:11px;padding:9px 10px;border-left:3px solid #e2761b;border-radius:6px;background:#fff3e4;color:#8e4308;font-size:12px;line-height:1.55}.detail-toolbar{display:flex;align-items:center;justify-content:flex-end;margin:0 0 10px}.detail-toolbar button{flex-shrink:0;min-height:34px;margin:0;padding:0 11px;border-radius:8px;color:#135ee8;font-size:10px;line-height:34px;background:#fff}.tab-row{display:flex;gap:8px;margin:12px 0 8px}.tab-button{flex:none;width:auto;margin:0;border:1px solid #d7e1ef;border-radius:7px;background:#fff;color:#52647c;font-size:12px;line-height:30px;padding:0 14px}.tab-button.active{border-color:#2167e8;background:#2167e8;color:#fff;font-weight:600}
.photo-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:12px}.photo-tile{min-width:0;overflow:hidden;border:1px solid #d7e1ef;border-radius:8px}.photo-thumbnail{display:block;width:100%;height:140px;background:#f1f4f8}.photo-placeholder{margin:0;border:0;border-radius:0;font-size:13px;white-space:normal;line-height:1.5;display:flex;align-items:center;justify-content:center}.photo-name{display:block;padding:6px 8px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:12px;color:#52647c}.photo-actions{display:flex;justify-content:space-around;padding:4px}.photo-actions .link-button{margin:0}.item-card>input{box-sizing:border-box;width:100%;padding:10px;border:1px solid #d7e1ef;border-radius:8px;margin:8px 0}.bulk-total-button{margin-top:14px}.bulk-quantity{box-sizing:border-box;width:100%;padding:12px;border:1px solid #d7e1ef;border-radius:8px;background:#fff}.source-allocation{margin-top:14px;padding:12px;border-radius:10px;background:#f6f8fb}.source-title{display:block;font-weight:700;color:#24364b;margin-bottom:8px}.source-row,.source-total{display:flex;justify-content:space-between;gap:12px;padding:8px 0;color:#52647c}.source-row+ .source-row{border-top:1px solid #e4eaf2}.source-row text{min-width:0;overflow-wrap:anywhere}.source-row strong,.source-total strong{white-space:nowrap;color:#1f5fca}.source-total{border-top:1px solid #d7e1ef;font-weight:700}
.destination-summary{display:flex;align-items:center;flex-wrap:wrap;gap:5px;margin-top:6px}.destination-summary .meta-line{margin:0}.destination-tag{display:inline-block;padding:3px 7px;border-radius:5px;background:#edf4ff;color:#285d9f;font-size:10px;line-height:14px}
.carton-label-list{margin-top:10px;gap:6px}.carton-label-item{display:flex;align-items:center;flex-wrap:nowrap;gap:6px;padding:7px 0;min-width:0}.carton-label{display:flex;flex:1;flex-direction:column;gap:2px;box-sizing:border-box;min-width:0;overflow:hidden;padding:5px 7px;border:1px solid #dce5ef;border-radius:6px;background:#f6f8fb}.fba-label{border-color:#cddff9;background:#eff5ff}.label-caption{color:#718095;font-size:9px;line-height:13px}.label-code{display:block;color:#253e5b;font-size:10px;font-weight:600;line-height:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.label-arrow{flex-shrink:0;color:#8090a3;font-size:12px}.carton-label-item>.destination-tag{flex-shrink:0}.sku-detail-card{padding:8px 10px}.sku-detail-card .business-code{font-size:12px;font-weight:600;line-height:18px;overflow-wrap:anywhere}.sku-detail-card .meta-line{font-size:9px;line-height:14px}.sku-detail-card .link-button{height:26px;font-size:9px;line-height:26px}
.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-heading{display:flex;align-items:baseline;flex-wrap:wrap;gap:4px 8px}.candidate-customer{color:#52647c;font-size:10px;line-height:16px;overflow-wrap:anywhere}</style>
@@ -0,0 +1,93 @@
<template>
<view v-if="user" class="page-shell portal-page">
<PortalHeader title="退货箱内清点" :user="user" fallback-url="/pages/module/module?key=inbound" />
<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 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 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><view class="measure-grid"><label><text>长(cm)</text><input v-model="box.length" type="digit" class="measure-input" :disabled="busy || task.status !== 'COUNTING'" placeholder="长度" /></label><label><text>宽(cm)</text><input v-model="box.width" type="digit" class="measure-input" :disabled="busy || task.status !== 'COUNTING'" placeholder="宽度" /></label><label><text>高(cm)</text><input v-model="box.height" type="digit" class="measure-input" :disabled="busy || task.status !== 'COUNTING'" placeholder="高度" /></label><label><text>重量(kg)</text><input v-model="box.weight" type="digit" class="measure-input" :disabled="busy || task.status !== 'COUNTING'" placeholder="重量" /></label></view><view class="volume-chip"><text>体积</text><strong>{{ boxVolume(box) }} cm³</strong></view>
<view v-for="(line, lineIndex) in box.items" :key="lineIndex" class="count-line"><picker class="sku-picker" :disabled="busy || task.status !== 'COUNTING'" :range="skuCodes" :value="Math.max(0, skuCodes.indexOf(line.skuCode))" @change="line.skuCode = skuCodes[Number($event.detail.value)]"><text>{{ line.skuCode || '选择 SKU' }} ▾</text></picker><input v-model="line.quantity" type="number" class="number-input quantity" :disabled="busy || task.status !== 'COUNTING'" placeholder="数量" /><button v-if="task.status === 'COUNTING' && box.items.length > 1" class="link-button" :disabled="busy" @tap="box.items.splice(lineIndex, 1)">删除</button></view>
</view>
<view class="work-card"><text class="hint">各箱 SKU 数量汇总必须与本托签收数量一致,确认后这些箱子才能换标、合箱或创建出库单。</text><button v-if="task.status === 'COUNTING'" class="primary" :disabled="busy || !ready" @tap="confirm">{{ busy ? '提交中…' : '确认全部箱内清点' }}</button><text v-else class="success">箱内清点已完成,可以继续换标或出库。</text></view>
</template>
</view>
</view>
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { session, requireSession } from '@/stores/session.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { listReturnCountTasks, scanReturnCountTasks, getCurrentReturnCountTask, beginReturnCountTask, confirmReturnCount } from '@/api/return-counting.js'
export default {
components: { PortalHeader, ScanTargetInput },
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 } },
computed: {
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) },
draftKey() { return this.task ? `return-count:${session.apiBase}:${this.user?.id}:${this.task.receiptNo}` : '' }
},
watch: { task: { deep: true, handler() { if (this.task?.status === 'COUNTING') uni.setStorageSync(this.draftKey, { boxes: this.task.boxes, requestKey: this.requestKey, signature: this.signature }) } } },
onLoad(options) { this.scanCode = String(options?.cartonMark || '') },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.scannerStop = startScanBroadcast({ onScan: value => this.scan(value) }); if (!this.task) { if (this.scanCode) this.scan(this.scanCode); else this.load() } },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: {
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()) },
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));
},
async enterTask(row, confirmStart = false) {
const active = await getCurrentReturnCountTask(); this.activeTask = active;
if (active && active.receiptNo !== row.receiptNo) {
const answer = await this.ask({ title: '有未完成的清点任务', content: `退货单 ${active.returnNo} 尚未清点完成,一次只能清点一单。`, confirmText: '继续当前单', cancelText: '取消' });
if (answer.confirm) this.open(await beginReturnCountTask(active.receiptNo));
return;
}
if (confirmStart) {
const answer = await this.ask({ title: active ? '继续清点' : '开始清点', content: `是否${active ? '继续' : '开始'}清点退货单 ${row.returnNo}?`, confirmText: active ? '继续' : '开始', cancelText: '取消' });
if (!answer.confirm) return;
}
this.open(await beginReturnCountTask(row.receiptNo)); this.activeTask = this.task; this.selectedMark = this.scanResult;
},
open(row) { this.task = JSON.parse(JSON.stringify(row)); this.requestKey = ''; this.signature = ''; this.selectedMark = ''; const draft = uni.getStorageSync(this.draftKey); if (row.status === 'COUNTING' && draft?.boxes?.length === row.boxes.length && draft.boxes.every(box => row.boxes.some(item => item.cartonMark === box.cartonMark))) { this.task.boxes = draft.boxes; this.requestKey = draft.requestKey || ''; this.signature = draft.signature || '' } },
async scan(value) { const mark = String(value || this.scanCode).trim(); if (!mark) return; await this.run(async () => { const rows = await scanReturnCountTasks(mark); this.tasks = rows; this.total = rows.length; this.scanResult = mark; this.scanCode = ''; this.selectedMark = ''; if (rows.length === 1) await this.enterTask(rows[0]); else this.task = null }) },
addSku(box) { box.items.push({ skuCode: '', quantity: '' }) },
boxVolume(box) { const value = Number(box?.length) * Number(box?.width) * Number(box?.height); return Number.isFinite(value) && value > 0 ? value.toFixed(2) : '—' },
async confirm() { if (!this.ready) return; await this.run(async () => { const completed = { receiptNo: this.task.receiptNo, palletCode: this.task.palletCode }; const boxes = this.task.boxes.map(box => ({ cartonMark: box.cartonMark, length: Number(box.length), width: Number(box.width), height: Number(box.height), weight: Number(box.weight), items: box.items.map(line => ({ skuCode: line.skuCode, quantity: Number(line.quantity) })) })); const signature = JSON.stringify(boxes); if (!this.requestKey || signature !== this.signature) { this.requestKey = `return-count-${Date.now()}-${Math.random().toString(36).slice(2)}`; this.signature = signature } uni.setStorageSync(this.draftKey, { boxes: this.task.boxes, requestKey: this.requestKey, signature }); const result = await confirmReturnCount(this.task.receiptNo, { idempotencyKey: this.requestKey, boxes }); if (result?.status !== 'CONFIRMED') throw new Error('清点未完成,请刷新核实任务状态'); uni.removeStorageSync(this.draftKey); this.activeTask = null; this.resetList(); uni.showToast({ title: '箱内清点完成', icon: 'success' }); await this.fetchTasks(); await this.offerNextPalletTask(completed) }) },
async offerNextPalletTask(completed) {
if (!completed.palletCode) return;
let rows;
try { rows = await scanReturnCountTasks(completed.palletCode) }
catch (error) { error.message = `当前清点已完成,查询该托盘剩余任务失败:${error.message || '请刷新任务列表'}`; throw error }
const remaining = rows.filter(row => row.palletCode === completed.palletCode && row.receiptNo !== completed.receiptNo && ['PENDING', 'COUNTING'].includes(row.status));
if (!remaining.length) return;
const answer = await this.ask({ title: '该托盘还有待清点项', content: `当前清点已完成,托盘 ${completed.palletCode} 还有 ${remaining.length} 项待清点任务,是否继续?`, confirmText: '继续清点', cancelText: '暂不清点' });
if (!answer.confirm) return;
this.tasks = remaining; this.total = remaining.length; this.scanResult = completed.palletCode;
await this.enterTask(remaining[0]);
},
resetList() { this.task = null; this.selectedMark = ''; this.scanCode = ''; this.scanResult = ''; this.page = 1; this.tasks = []; this.total = 0 },
back() { this.resetList(); this.load() }
}
}
</script>
<style scoped lang="scss">
@import '../warehouse/operations.scss';
.counting-content{padding:12px 12px 42px}.error-card{color:#9d2323;background:#fff0f0}
.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}
</style>
+98 -20
View File
@@ -2,18 +2,37 @@
<view v-if="user" class="page-shell portal-page work-page">
<PortalHeader title="退货合箱" :user="user" fallback-url="/pages/module/module?key=inbound" />
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
<view v-if="!result" class="work-card"><text class="card-title">添加退货单</text><ScanTargetInput name="returnNo" v-model="searchCode" label="退货单号" placeholder="扫描或输入完整退货单号" :active="true" @submit="search" /><button class="primary" :disabled="!searchCode.trim() || busy" @tap="search(searchCode)">{{ busy ? '查询中…' : '查询可合箱库存' }}</button></view>
<button v-if="!result && (activePlan || mode === 'BOX')" class="link-button" :disabled="busy" @tap="leavePlan">返回扫码 / 计划列表</button>
<ReturnBoxMergeWork v-if="mode === 'BOX' && !result" ref="boxWork" :key="activePlan?.planNo || initialSource?.cartonMark || 'direct'" :plan="activePlan" :initial-source="initialSource" @busy-change="busy = $event" @completed="packingCompleted" @error="setError($event, '箱合并失败')" />
<view v-if="!result && !activePlan && mode !== 'BOX'" class="work-card direct-merge-card"><text class="card-title">仓库现场直接合箱</text><text class="hint">扫描 SKU 或箱号开始;系统会自动锁定客户和当前仓,跨客户货物不能合箱。</text><ScanTargetInput name="stock" v-model="searchCode" label="SKU 或箱号" placeholder="扫描 SKU 或箱号,查询可合箱库存" :active="true" @submit="search" /><button class="primary" :disabled="!searchCode.trim() || busy" @tap="search(searchCode)">{{ busy ? '查询中…' : '查询可合箱库存' }}</button></view>
<view v-if="!result && !activePlan && plans.length" class="work-card"><view class="row-head"><text class="card-title">平台合箱计划</text><text class="meta-line">审核通过后推送</text></view><text class="hint">按计划锁定的来源箱和数量作业,后续合箱规则不变。</text><button v-for="plan in plans" :key="plan.planNo" class="task-card candidate-card" @tap="startPlan(plan)"><view class="row-head"><text class="business-code">{{ plan.planNo }}</text><text class="status-pill">{{ plan.status === 'READY' ? '待合箱' : plan.status }}</text></view><text class="meta-line">退货单:{{ plan.returnNos.join('、') }}</text><text class="select-link">开始作业 ›</text></button></view>
<view v-if="candidates !== null && !result" class="section-gap"><view v-if="!candidates.length" class="empty-box"><text class="empty-copy">没有匹配的已完成退货单或可用库存</text></view><view v-else class="task-list"><button v-for="item in candidates" :key="item.returnNo" class="task-card candidate-card" @tap="addOrder(item)"><view class="row-head"><view><text class="business-code">{{ item.returnNo }}</text><text class="meta-line">客户参考号:{{ item.customerReferenceNo || '—' }}</text></view><text class="select-link">添加 ›</text></view></button></view></view>
<view v-if="customerCandidates.length && !selectedCustomer && !result" class="section-gap"><view class="work-card"><text class="card-title">选择客户</text><text class="hint">该 SKU 在多个客户有可用退货库存,请先选择客户</text><view class="task-list"><button v-for="(customer, index) in customerCandidates" :key="index" class="task-card candidate-card" @tap="selectCustomer(customer)"><view class="row-head"><view><text class="business-code">{{ customerLabel(customer, index) }}</text><text class="meta-line">可用 SKU {{ customer.skus.length }} 个 · 退货单 {{ customer.orders.length }} 个</text></view><text class="select-link">选择 ›</text></view></button></view></view></view>
<view v-if="candidates !== null && !customerCandidates.length && !result" class="section-gap"><view v-if="!candidates.length" class="empty-box"><text class="empty-copy">没有匹配的已完成退货单或可用库存</text></view><view v-else class="task-list"><button v-for="item in candidates" :key="item.returnNo" class="task-card candidate-card" @tap="addOrder(item)"><view class="row-head"><view><text class="business-code">{{ item.returnNo }}</text><text class="meta-line">客户参考号:{{ item.customerReferenceNo || '—' }}</text></view><text class="select-link">添加 ›</text></view></button></view></view>
<template v-if="selectedOrders.length && !result">
<view class="work-card"><text class="card-title">已选退货单</text><view class="selected-tags"><button v-for="item in selectedOrders" :key="item.returnNo" class="selected-tag" @tap="removeOrder(item.returnNo)">{{ item.returnNo }} ×</button></view><text class="hint">不同客户的退货单不能合箱;选择时会自动校验。</text></view>
<view class="work-card"><text class="card-title">新箱 SKU 数量</text><view class="item-list"><view v-for="item in availableSkus" :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">可用 {{ item.availableQuantity }}</text></view><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></view></view>
<view class="work-card"><text class="card-title">新箱箱规</text><view class="dimension-grid"><label v-for="field in dimensionFields" :key="field.key"><text>{{ field.label }}</text><input class="number-input" type="digit" :value="packing[field.key]" :placeholder="field.placeholder" @input="setDimension(field.key, $event.detail.value)" /></label></view></view>
<template v-if="selectedOrders.length && !result && mode === 'SKU'">
<view class="work-card"><text class="card-title">{{ activePlan ? `合箱计划 ${activePlan.planNo}` : '客户散件库存' }}</text><text class="hint">{{ activePlan ? '数量已由计划锁定,请分配到目标箱。' : '已锁定一个客户;可用数量来自该客户同仓全部散件库存,后台自动追溯来源退货单。' }}</text></view>
<view class="work-card"><text class="card-title">新箱 SKU 数量</text><view class="item-list"><view v-for="item in availableSkus" :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">可用 {{ item.availableQuantity }}</text></view><view class="quantity-row"><text>装入数量</text><input class="number-input" type="number" :disabled="!!activePlan" :value="quantity(item.skuCode)" placeholder="0" @input="setQuantity(item, $event.detail.value)" /></view></view></view></view>
<button class="secondary" :disabled="busy || !mergeQuantity" @tap="distributeBoxes">按单 SKU 一箱分配</button>
<view v-for="(box,index) in targetBoxes" :key="index" class="work-card">
<view class="row-head"><text class="card-title">目标箱 {{ index + 1 }}</text><button class="link-button" @tap="removeBox(index)">删除</button></view>
<view v-for="item in availableSkus" :key="item.skuCode" class="quantity-row"><text>{{ item.skuCode }}</text><input class="number-input" type="number" :value="box.quantities?.[item.skuCode] || ''" placeholder="装入数量" @input="setBoxQuantity(index,item.skuCode,$event.detail.value)" /></view>
<view class="dimension-grid section-gap"><label v-for="field in dimensionFields" :key="field.key"><text>{{ field.label }}</text><input class="number-input" type="digit" :value="box[field.key]" :placeholder="field.placeholder" @input="setBoxField(index,field.key,$event.detail.value)" /></label></view>
<text class="hint">体积 {{ boxVolume(box) }} m³</text>
</view>
<button class="secondary" :disabled="busy" @tap="addBox">新增目标箱(可混 SKU)</button>
<button class="primary" :disabled="!canMerge || busy" @tap="mergeCartons">{{ busy ? '合箱中…' : `确认合箱(${mergeQuantity} 件)` }}</button>
</template>
<view v-if="result" class="success-box result-card"><text class="success-title">退货合箱成功</text><text class="result-carton">{{ result.label.cartonMark || result.cartonMark }}</text><text class="meta-line">来源退货单:{{ result.label.sourceReturnNo || '—' }}</text><text class="meta-line">箱规:{{ sizeText(result.label) }}</text><view class="item-list section-gap"><view v-for="item in result.label.items" :key="item.skuCode" class="item-card"><view class="row-head"><text class="business-code">{{ item.skuCode }}</text><text class="remaining-copy">{{ item.quantity }} 件</text></view><text class="meta-line">{{ item.productNameZh || '商品名称未填写' }}</text></view></view><button class="secondary" @tap="copyCartonMark">复制新箱号</button><button class="primary" @tap="nextPacking">继续合箱</button></view>
<view v-if="result" class="success-box result-card"><text class="success-title">合箱完成 · {{ result.labels.length }} 箱</text>
<view v-for="label in result.labels" :key="label.cartonMark" class="item-card section-gap">
<text class="business-code">{{ label.cartonMark }}</text><text class="meta-line">来源退货单:{{ label.sourceReturnNo }}</text>
<text v-for="item in label.items" :key="item.skuCode" class="meta-line">{{ item.skuCode }} · {{ item.quantity }} 件</text>
<button class="secondary" :disabled="busy" @tap="printLabel(label)">打印新箱唛</button>
<ReturnLabelConfirmation ref="labelConfirmations" :carton-mark="label.cartonMark" />
</view>
<button class="primary" @tap="nextPacking">返回合箱计划列表</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="selectedOrders.length" class="secondary" :disabled="busy" @tap="refreshSelected">重新读取可用库存</button></view>
</view></scroll-view>
</view>
@@ -22,40 +41,99 @@
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { findPackingOrders, mergeReturnCartons } from '@/api/return-packing.js'
import ReturnBoxMergeWork from '@/components/ReturnBoxMergeWork.vue'
import ReturnLabelConfirmation from '@/components/ReturnLabelConfirmation.vue'
import { completePackingPlan, findPackingOrders, listPackingPlans, getPackingPlan, getReturnCartonLabel, scanReturnCarton, packReturnSkus } from '@/api/return-packing.js'
import { defaultPackingBoxes, packingBoxPayload, validPackingAllocation } from '@/utils/return-packing-allocation.js'
import { session, requireSession } from '@/stores/session.js'
import { releaseWarehouseKey, resetWarehouseDomain, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
export default {
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, candidates: null, previews: {}, result: null, busy: false, error: null, dimensionFields: [{ key: 'length', label: '长 cm', placeholder: '长' }, { key: 'width', label: '宽 cm', placeholder: '宽' }, { key: 'height', label: '高 cm', placeholder: '高' }, { key: 'weight', label: '重量 kg', placeholder: '重量' }] } },
components: { PortalHeader, ScanTargetInput, ReturnBoxMergeWork, ReturnLabelConfirmation },
data() { return { mode: 'SKU', initialSource: null, user: session.currentUser, candidates: null, customerCandidates: [], selectedCustomer: null, previews: {}, plans: [], activePlan: null, result: null, busy: false, error: null, dimensionFields: [{ key: 'length', label: '长 cm', placeholder: '长' }, { key: 'width', label: '宽 cm', placeholder: '宽' }, { key: 'height', label: '高 cm', placeholder: '高' }, { key: 'weight', label: '重量 kg', placeholder: '重量' }] } },
computed: {
packing() { return warehouseWork.returnPacking },
targetBoxes() { return this.packing.targetBoxes || [] },
searchCode: { get() { return this.packing.searchCode }, set(value) { updateWarehouseWork('returnPacking', { searchCode: value }) } },
selectedOrders() { return Array.isArray(this.packing.selectedOrders) ? this.packing.selectedOrders : [] },
availableSkus() { const result = new Map(); for (const order of this.selectedOrders) for (const item of this.previews[order.returnNo]?.skus || []) { const current = result.get(item.skuCode) || { ...item, availableQuantity: 0 }; current.availableQuantity += Number(item.availableQuantity || 0); result.set(item.skuCode, current) } return [...result.values()] },
availableSkus() { if (this.selectedCustomer) return this.selectedCustomer.skus || []; const result = new Map(); for (const order of this.selectedOrders) for (const item of this.previews[order.returnNo]?.skus || []) { const current = result.get(item.skuCode) || { ...item, availableQuantity: 0 }; current.availableQuantity += Number(item.availableQuantity || 0); result.set(item.skuCode, current) } return [...result.values()] },
mergeQuantity() { return Object.values(this.packing.quantities || {}).reduce((total, value) => total + (Number(value) || 0), 0) },
validDimensions() { return this.dimensionFields.every((field) => Number(this.packing[field.key]) > 0) },
canMerge() { return this.selectedOrders.length > 0 && this.mergeQuantity > 0 && this.validDimensions && this.availableSkus.every((item) => this.quantity(item.skuCode) <= item.availableQuantity) }
canMerge() { return this.selectedOrders.length > 0 && this.mergeQuantity > 0 && validPackingAllocation(this.targetBoxes, this.packing.quantities || {}) && this.availableSkus.every((item) => this.quantity(item.skuCode) <= item.availableQuantity) }
},
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.startScanner(); if (this.selectedOrders.length && !Object.keys(this.previews).length) this.refreshSelected() },
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.mode = this.packing.mode || 'SKU'; this.activePlan = this.packing.activePlan || null; this.result = this.packing.result || null; if (this.activePlan) { const quantities = {}; for (const box of this.activePlan.boxes) for (const item of box.items || []) quantities[item.skuCode] = (quantities[item.skuCode] || 0) + Number(item.quantity); this.selectedCustomer = { skus: Object.entries(quantities).map(([skuCode, availableQuantity]) => ({ skuCode, availableQuantity })) }; } this.startScanner(); this.loadPlans(); if (!this.activePlan && !this.result && this.selectedOrders.length && !Object.keys(this.previews).length) this.refreshSelected() },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: {
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => { if (!this.busy) { this.searchCode = value; this.search(value) } }, onError: (error) => this.setError(error, '扫码广播注册失败') }) },
startScanner() { this.initialSource = this.activePlan ? null : this.packing.initialSource || null; if (this.mode === 'BOX' && !this.activePlan && !this.initialSource) this.mode = 'SKU'; this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => { if (this.result) { for (const confirmation of this.$refs.labelConfirmations || []) confirmation.acceptScan(value); return } if (!this.busy && !this.result) { if (this.mode === 'BOX') this.$refs.boxWork?.acceptScan(value); else { this.searchCode = value; this.search(value) } } }, onError: (error) => this.setError(error, '扫码广播注册失败') }) },
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
async search(raw) { const value = String(raw?.detail?.value ?? raw ?? this.searchCode).trim(); if (!value || this.busy) return; this.searchCode = value; await this.run(async () => { const preview = await findPackingOrders(value); this.candidates = preview.orders.filter((item) => !this.selectedOrders.some((selected) => selected.returnNo === item.returnNo)); if (this.candidates.length) playSuccessSound() }, '查询可合箱退货单失败') },
async addOrder(item) { if (this.busy || !item?.returnNo) return; await this.run(async () => { const preview = await findPackingOrders(item.returnNo); const exact = preview.orders.filter((order) => order.returnNo === item.returnNo); if (exact.length !== 1 || !preview.customerKey) throw new Error('请扫描完整退货单号后重试'); const customerKey = this.selectedOrders[0]?.customerKey; if (customerKey && customerKey !== preview.customerKey) throw new Error('不同客户的退货单不能合箱'); const selected = [...this.selectedOrders, { ...exact[0], customerKey: preview.customerKey }]; this.previews = { ...this.previews, [item.returnNo]: preview }; updateWarehouseWork('returnPacking', { selectedOrders: selected, searchCode: '', quantities: this.validQuantities(selected, this.previews) }); this.candidates = null; playSuccessSound() }, '添加退货单失败') },
removeOrder(returnNo) { const selected = this.selectedOrders.filter((item) => item.returnNo !== returnNo); const previews = { ...this.previews }; delete previews[returnNo]; this.previews = previews; updateWarehouseWork('returnPacking', { selectedOrders: selected, quantities: this.validQuantities(selected, previews) }) },
async loadPlans() { try { this.plans = await listPackingPlans() } catch (error) { this.setError(error, '加载合箱计划失败') } },
async search(raw) {
const value = String(raw?.detail?.value ?? raw ?? this.searchCode).trim()
if (!value || this.busy || this.activePlan || this.result) return
this.searchCode = value
await this.run(async () => {
let carton
try { carton = await scanReturnCarton(value) } catch (error) {
if (!['A0404', '404'].includes(String(error?.code || '').toUpperCase())) throw error
}
if (carton) {
resetWarehouseDomain('returnPacking'); this.selectedCustomer = null; this.customerCandidates = []; this.candidates = null; this.previews = {}
this.initialSource = carton; this.mode = 'BOX'; updateWarehouseWork('returnPacking', {mode:'BOX', initialSource:carton}); playSuccessSound(); return
}
throw new Error('请扫描来源包裹/箱标签,部分出库必须确认具体实物来源')
}, '查询 SKU 或箱号失败')
},
selectCustomer(customer) { if (!customer?.customerId || !customer.orders?.length) return; const existingCustomer = this.selectedOrders[0]?.customerKey; if (existingCustomer && existingCustomer !== customer.customerId) { this.setError(new Error('不同客户的退货库存不能合箱'), '不能选择其他客户'); return } this.selectedCustomer = customer; const selected = customer.orders.filter((item) => !this.selectedOrders.some((current) => current.returnNo === item.returnNo)).map((item) => ({ ...item, customerKey: customer.customerId })); const all = this.selectedOrders.length ? [...this.selectedOrders, ...selected] : selected; updateWarehouseWork('returnPacking', { selectedOrders: all, searchCode: '' }); this.customerCandidates = []; this.candidates = null; this.previews = {} },
customerLabel(customer, index) { return `客户 ${index + 1}` },
async startPlan(plan) { if (!plan?.planNo || this.busy || this.activePlan || this.result) return; await this.run(async () => {
const latest = await getPackingPlan(plan.planNo)
if (latest.status !== 'READY') throw new Error('计划已不在待作业状态,请刷新列表')
this.activePlan = latest; this.mode = 'BOX'; this.customerCandidates = []; this.candidates = null
const quantities = {}; for (const box of latest.boxes) for (const item of box.items || []) quantities[item.skuCode] = (quantities[item.skuCode] || 0) + Number(item.quantity)
this.selectedCustomer = { customerId: latest.customerId, orders: latest.returnNos.map(returnNo => ({ returnNo })), skus: Object.entries(quantities).map(([skuCode, availableQuantity]) => ({ skuCode, availableQuantity })) }
updateWarehouseWork('returnPacking', { mode: 'BOX', activePlan: latest, selectedOrders: latest.returnNos.map(returnNo => ({ returnNo, customerKey: latest.customerId })), quantities, targetBoxes: defaultPackingBoxes(Object.entries(quantities).map(([skuCode,quantity]) => ({skuCode,quantity}))), searchCode: '' })
}, '读取合箱计划失败') },
async addOrder(item) { if (this.busy || !item?.returnNo) return; await this.run(async () => { const preview = await findPackingOrders(item.returnNo, 'RETURN_NO'); const exact = preview.orders.filter((order) => order.returnNo === item.returnNo); if (exact.length !== 1 || !preview.customerKey) throw new Error('该退货单没有可用库存'); const customerKey = this.selectedOrders[0]?.customerKey; if (customerKey && customerKey !== preview.customerKey) throw new Error('不同客户的退货单不能合箱'); const selected = [...this.selectedOrders, { ...exact[0], customerKey: preview.customerKey }]; this.selectedCustomer = null; this.previews = { ...this.previews, [item.returnNo]: preview }; updateWarehouseWork('returnPacking', { selectedOrders: selected, searchCode: '', quantities: this.validQuantities(selected, this.previews) }); this.candidates = null; this.customerCandidates = []; playSuccessSound() }, '添加退货单失败') },
removeOrder(returnNo) { const selected = this.selectedOrders.filter((item) => item.returnNo !== returnNo); const previews = { ...this.previews }; delete previews[returnNo]; this.previews = previews; if (this.selectedCustomer) this.selectedCustomer = { ...this.selectedCustomer, orders: selected }; updateWarehouseWork('returnPacking', { selectedOrders: selected, quantities: this.validQuantities(selected, previews) }) },
validQuantities(selected, previews) { const available = new Map(); for (const order of selected) for (const item of previews[order.returnNo]?.skus || []) available.set(item.skuCode, (available.get(item.skuCode) || 0) + Number(item.availableQuantity || 0)); return Object.fromEntries([...available].map(([sku, maximum]) => [sku, Math.min(maximum, this.quantity(sku))])) },
quantity(skuCode) { return Math.max(0, Number(this.packing.quantities?.[skuCode]) || 0) },
setQuantity(item, raw) { const value = Math.min(item.availableQuantity, Math.max(0, Math.floor(Number(raw) || 0))); updateWarehouseWork('returnPacking', { quantities: { ...(this.packing.quantities || {}), [item.skuCode]: value } }) },
setDimension(key, raw) { updateWarehouseWork('returnPacking', { [key]: String(raw || '').trim() }) },
async refreshSelected() { if (this.busy || !this.selectedOrders.length) return; await this.run(async () => { const previews = {}; let customerKey = ''; for (const selected of this.selectedOrders) { const preview = await findPackingOrders(selected.returnNo); if (!preview.customerKey || (customerKey && preview.customerKey !== customerKey)) throw new Error('所选退货单已不可合箱,请重新选择'); customerKey = preview.customerKey; previews[selected.returnNo] = preview } this.previews = previews; updateWarehouseWork('returnPacking', { quantities: this.validQuantities(this.selectedOrders, previews) }) }, '刷新可用库存失败') },
async mergeCartons() { if (!this.canMerge || this.busy) return; const payload = { returnNos: this.selectedOrders.map((item) => item.returnNo), items: this.availableSkus.map((item) => ({ skuCode: item.skuCode, quantity: this.quantity(item.skuCode) })).filter((item) => item.quantity > 0), length: Number(this.packing.length), width: Number(this.packing.width), height: Number(this.packing.height), weight: Number(this.packing.weight) }; const key = warehouseIdempotencyKey('returnPacking', 'merge', payload); await this.run(async () => { this.result = await mergeReturnCartons({ ...payload, idempotencyKey: key.value }); releaseWarehouseKey('returnPacking', key.signature); resetWarehouseDomain('returnPacking'); this.previews = {}; this.candidates = null; playSuccessSound() }, '退货合箱失败') },
async refreshSelected() { if (this.busy || !this.selectedOrders.length || this.selectedCustomer) return; await this.run(async () => { const previews = {}; let customerKey = ''; for (const selected of this.selectedOrders) { const preview = await findPackingOrders(selected.returnNo); if (!preview || !preview.customerKey || (customerKey && preview.customerKey !== customerKey)) throw new Error('所选退货单已不可合箱,请重新选择'); customerKey = preview.customerKey; previews[selected.returnNo] = preview } this.previews = previews; updateWarehouseWork('returnPacking', { quantities: this.validQuantities(this.selectedOrders, previews) }) }, '刷新可用库存失败') },
async mergeCartons() {
if (!this.canMerge || this.busy) return
const payload = { returnNos: this.selectedOrders.map(item => item.returnNo), boxes: this.targetBoxes.map(packingBoxPayload) }
const key = warehouseIdempotencyKey('returnPacking', 'pack', { ...payload, planNo: this.activePlan?.planNo })
await this.run(async () => {
const value = this.activePlan ? await completePackingPlan(this.activePlan.planNo, { boxes: payload.boxes, idempotencyKey: key.value }) : await packReturnSkus({ ...payload, idempotencyKey: key.value })
this.packingCompleted(value); releaseWarehouseKey('returnPacking', key.signature); await this.loadPlans()
}, '退货合箱失败')
},
distributeBoxes() { updateWarehouseWork('returnPacking', { targetBoxes: defaultPackingBoxes(Object.entries(this.packing.quantities || {}).map(([skuCode,quantity]) => ({skuCode,quantity}))) }) },
addBox() { updateWarehouseWork('returnPacking', { targetBoxes: [...this.targetBoxes, { quantities: {}, length:'', width:'', height:'', weight:'' }] }) },
removeBox(index) { updateWarehouseWork('returnPacking', { targetBoxes: this.targetBoxes.filter((_,i) => i !== index) }) },
setBoxField(index,key,value) { updateWarehouseWork('returnPacking', { targetBoxes: this.targetBoxes.map((box,i) => i === index ? { ...box, [key]: String(value || '') } : box) }) },
setBoxQuantity(index,sku,value) { updateWarehouseWork('returnPacking', { targetBoxes: this.targetBoxes.map((box,i) => i === index ? { ...box, quantities: { ...box.quantities, [sku]: Math.max(0,Math.floor(Number(value) || 0)) } } : box) }) },
boxVolume(box) { return ((Number(box.length)||0)*(Number(box.width)||0)*(Number(box.height)||0)/1000000).toFixed(6) },
changeMode(mode) { if (this.activePlan || this.busy) return; this.mode = mode; resetWarehouseDomain('returnPacking'); updateWarehouseWork('returnPacking', { mode }); this.selectedCustomer = null; this.customerCandidates = []; this.candidates = null },
leavePlan() { if (this.busy) return; this.returnToPlans() },
packingCompleted(value) { this.busy = false; playSuccessSound(); if (value.targetMode === 'EXISTING') { this.returnToPlans(); return } this.result = { labels: value.labels || (value.label ? [value.label] : []) }; updateWarehouseWork('returnPacking', { result: this.result }) },
async printLabel(label) {
// #ifdef H5
const tab = window.open('', '_blank')
if (!tab) { this.setError(new Error('请允许打开箱唛打印窗口'), '打印失败'); return }
try { const latest = await getReturnCartonLabel(label.cartonMark); if (!latest.printableHtml) throw new Error('箱唛打印内容为空,请重试'); tab.document.write(latest.printableHtml); tab.document.close(); tab.focus(); tab.print() } catch (error) { tab.close(); this.setError(error, '打印失败') }
// #endif
// #ifndef H5
this.setError(new Error('当前设备未配置打印服务'), '打印失败')
// #endif
},
copyCartonMark() { const value = this.result?.label?.cartonMark || this.result?.cartonMark; if (value) uni.setClipboardData({ data: value }) },
nextPacking() { this.result = null; this.error = null; this.searchCode = '' },
nextPacking() { this.returnToPlans() },
returnToPlans() { resetWarehouseDomain('returnPacking'); this.initialSource = null; this.activePlan = null; this.result = null; this.mode = 'SKU'; this.error = null; this.customerCandidates = []; this.selectedCustomer = null; this.candidates = null; this.previews = {}; this.loadPlans() },
async run(work, fallback) { if (this.busy) return; this.busy = true; this.error = null; try { await work() } catch (error) { this.setError(error, fallback) } finally { this.busy = false } },
setError(error, fallback) { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
sizeText(label) { return `${label.length || '—'} × ${label.width || '—'} × ${label.height || '—'} cm · ${label.weight || '—'} kg` }
+181 -35
View File
@@ -2,37 +2,60 @@
<view v-if="user" class="page-shell portal-page work-page">
<PortalHeader title="退货入库" :user="user" fallback-url="/pages/module/module?key=inbound" />
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
<view v-if="!current && !result" class="work-card">
<text class="card-title">选择查询方式</text>
<view class="mode-row"><button class="mode-button" :class="{ active: entryMode === 'RETURN_NO' }" @tap="switchMode('RETURN_NO')">退货单号</button><button class="mode-button" :class="{ active: entryMode === 'SKU' }" @tap="switchMode('SKU')">SKU</button></view>
<ScanTargetInput name="lookup" v-model="lookupCode" :label="entryMode === 'SKU' ? 'SKU' : '退货单号'" :placeholder="entryMode === 'SKU' ? '扫描或输入 SKU' : '扫描包裹上的退货单号'" :active="activeTarget === 'lookup'" @activate="activeTarget = 'lookup'" @submit="lookup" />
<button class="primary" :disabled="!lookupCode.trim() || busy" @tap="lookup(lookupCode)">{{ busy ? '查询中…' : '查询' }}</button>
<view v-if="!current && !result && !receiptBatch" class="work-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" />
<text class="scan-hint">扫描完成后自动匹配对应退货单</text>
</view>
<view v-if="!current && candidates !== null && !result" class="section-gap">
<view v-if="!current && candidates !== null && !result && !receiptBatch" 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>
<template v-if="current && !result">
<view class="work-card"><view class="row-head"><view><text class="business-code">{{ current.returnNo }}</text><text class="meta-line">{{ startedBy === 'SKU' ? '通过 SKU 候选进入' : '通过退货单号进入' }}</text></view><text class="status-pill">{{ statusLabel(current.status) }}</text></view><button class="link-button" @tap="clearCurrent">更换退货单</button></view>
<view v-if="receiptBatch && !result" class="work-card"><text class="card-title">待完成{{ receiptBatch.unitType === 'PARCEL' ? '包裹' : '按箱' }}签收</text><text class="business-code">{{ receiptBatch.returnNo }}</text><text class="hint">已确认 {{ receiptBatch.entries.filter(entry => entry.result).length }} / {{ receiptBatch.entries.length }} {{ receiptBatch.unitType === 'PARCEL' ? '包裹' : '箱' }}。请继续本批签收,已确认的包裹/箱不会重新入库;本批内容暂不可修改。</text><button class="primary" :disabled="busy" @tap="confirmReceipt">{{ busy ? '提交中…' : '继续本批签收' }}</button></view>
<template v-if="current && !result && !receiptBatch">
<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>
<view class="work-card">
<text class="card-title">本包信息</text>
<text class="hint">快递单号和箱号均可不填;箱号留空时由系统自动生成。</text>
<text class="field-label">快递单号(选填)</text><ScanTargetInput name="tracking" v-model="trackingNo" label="快递单号" placeholder="扫描快递面单" :active="activeTarget === 'tracking'" @activate="activeTarget = 'tracking'" @submit="acceptTracking" />
<text class="field-label">箱号(选填)</text><ScanTargetInput name="carton" v-model="cartonMark" label="退货箱号" placeholder="扫描已有箱号,留空自动生成" :active="activeTarget === 'carton'" @activate="activeTarget = 'carton'" @submit="acceptCarton" />
<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>
<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><text class="field-label">快递单号(选填)</text><ScanTargetInput name="tracking" v-model="trackingNo" label="快递单号" placeholder="扫描快递面单" :active="activeTarget === 'tracking'" @activate="activeTarget = 'tracking'" @submit="acceptTracking" /></view>
</template>
</view>
<view class="work-card"><view class="row-head"><text class="card-title">本包实收数量</text><button class="link-button" @tap="fillRemaining">全部填入</button></view><view class="item-list"><view v-for="item in current.expectedItems" :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="quantity-row"><text>本包实收</text><input class="number-input" type="number" :value="quantity(item.skuCode)" placeholder="0" @input="setQuantity(item, $event.detail.value)" /></view></view></view></view>
<view class="summary-bar"><view><text class="summary-label">本包合计</text><text class="summary-value">{{ receiptQuantity }}</text></view><button class="primary submit-button" :disabled="!canConfirm || busy" @tap="confirmReceipt">{{ busy ? '提交中…' : '确认本包入库' }}</button></view>
<view class="work-card">
<view class="row-head receipt-quantity-heading"><text class="card-title">{{ unitType === 'PARCEL' ? '逐包裹实收数量' : unitType === 'PALLET' ? '本托实收数量' : '逐箱实收数量' }}</text><button v-if="unitType === 'PALLET'" class="link-button" @tap="fillRemaining">全部填入</button><view v-else class="unit-count-row"><text>{{ unitType === 'PARCEL' ? '本次包裹数' : '本次箱数' }}</text><input class="number-input" type="number" placeholder="数量" :value="boxRows.length" :disabled="busy" @input="resizeBoxRows($event.detail.value)" /></view></view>
<view v-if="unitType === 'PALLET'" class="quantity-row"><text>本托箱数合计</text><strong class="auto-count">{{ palletBoxCount }}</strong></view>
<template v-if="unitType !== 'PALLET'">
<view v-for="(box,index) in boxRows" :key="index" class="item-card physical-unit-card">
<view class="unit-card-head"><text class="business-code">第 {{ index + 1 }} {{ unitType === 'PARCEL' ? '包裹' : '箱' }}</text><button v-if="box.confirmed" class="link-button" :disabled="busy" @tap="editBoxRow(index)">已确认 · 展开修改</button><text v-else class="unit-card-tag">实测信息</text></view>
<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="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>
</template>
</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="work-card"><view class="row-head"><view><text class="card-title">退货打托照片</text><text class="hint">{{ unitType !== 'PALLET' ? '支持多张照片,上传后点击确认签收入库' : '签收过程中可随时拍照' }}</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><text class="result-carton">{{ result.cartonMark }}</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" @tap="nextReceipt">继续签收</button></view>
<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="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 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 && !receiptBatch" class="secondary" :disabled="busy" @tap="reloadCurrent">刷新退货单</button></view>
</view></scroll-view>
</view>
</template>
@@ -40,45 +63,159 @@
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { confirmReturnReceipt, findReturnSkuCandidates, startReturnReceipt } from '@/api/return-receipt.js'
import { checkReturnPallet, confirmReturnReceipt, createReturnPallet, downloadReturnPalletPhoto, findReturnSkuCandidates, manuallyCompleteReturnReceipt, startReturnReceipt, uploadReturnPalletPhoto } from '@/api/return-receipt.js'
import { session, requireSession } from '@/stores/session.js'
import { releaseWarehouseKey, resetWarehouseDomain, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
import { resetWarehouseDomain, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
export default {
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, current: null, candidates: null, result: null, activeTarget: 'lookup', busy: false, error: null } },
data() { return { user: session.currentUser, current: null, candidates: null, result: null, selectedSku: '', palletCheck: null, newPalletHtml: '', unitType: 'PALLET', unitCode: '', boxRows: [{ items: {}, 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: {
receiptBatch() { return warehouseWork.returnReceipt.pendingBatch },
entryMode: { get() { return warehouseWork.returnReceipt.entryMode }, set(value) { updateWarehouseWork('returnReceipt', { entryMode: value }) } },
lookupCode: { get() { return warehouseWork.returnReceipt.lookupCode }, set(value) { updateWarehouseWork('returnReceipt', { lookupCode: value }) } },
startedBy: { get() { return warehouseWork.returnReceipt.startedBy }, set(value) { updateWarehouseWork('returnReceipt', { startedBy: value }) } },
trackingNo: { get() { return warehouseWork.returnReceipt.trackingNo }, set(value) { updateWarehouseWork('returnReceipt', { trackingNo: value }) } },
cartonMark: { get() { return warehouseWork.returnReceipt.cartonMark }, set(value) { updateWarehouseWork('returnReceipt', { cartonMark: value }) } },
receiptQuantity() { return Object.values(warehouseWork.returnReceipt.quantities || {}).reduce((total, value) => total + (Number(value) || 0), 0) },
canConfirm() { return this.current && this.receiptQuantity > 0 && this.current.expectedItems.every((item) => this.quantity(item.skuCode) <= this.remaining(item)) }
palletCode: { get() { return warehouseWork.returnReceipt.palletCode }, set(value) { updateWarehouseWork('returnReceipt', { palletCode: value }) } },
selectedItems() { return this.current?.expectedItems || [] },
allBoxRowsConfirmed() { return this.unitType !== 'PALLET' && this.boxRows.length > 0 && this.boxRows.every((box, index) => box.confirmed && this.boxRowValid(index)) },
palletBoxCount() { return this.selectedItems.reduce((sum, item) => sum + this.boxCount(item.skuCode), 0) },
activeSkuCode() { return this.selectedItems.find(item => item.skuCode === this.selectedSku)?.skuCode || this.selectedItems[0]?.skuCode || '' },
receiptQuantity() { return this.unitType !== 'PALLET' ? this.boxRows.reduce((sum, box) => sum + Object.values(box.items).reduce((n, quantity) => n + (Number(quantity) || 0), 0), 0) : Object.values(warehouseWork.returnReceipt.quantities || {}).reduce((total, value) => total + (Number(value) || 0), 0) },
canConfirm() { if (!this.current || !this.selectedItems.length || this.receiptQuantity <= 0) return false; if (this.unitType !== '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) : '—' }
},
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.startScanner(); if (warehouseWork.returnReceipt.currentReturnNo && !this.current && !this.busy) this.openOrder(warehouseWork.returnReceipt.currentReturnNo, warehouseWork.returnReceipt.startedBy, false) },
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.entryMode = 'SKU'; this.startScanner(); if (!this.receiptBatch && warehouseWork.returnReceipt.currentReturnNo && !this.current && !this.busy) this.openOrder(warehouseWork.returnReceipt.currentReturnNo, 'SKU', false) },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: {
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) : '—' },
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) {
const box = this.boxRows[index]
if (!box || !['length', 'width', 'height', 'weight'].every(field => Number.isFinite(Number(box[field])) && Number(box[field]) > 0) || typeof box.originalPackaging !== 'boolean') return false
return this.selectedItems.some(item => Number(box.items[item.skuCode]) > 0) && this.selectedItems.every(item => { const quantity = Number(box.items[item.skuCode] || 0); return Number.isSafeInteger(quantity) && quantity >= 0 && quantity <= this.boxRemaining(index, item) })
},
editBoxRow(index) { if (this.busy) return; this.boxRows[index].confirmed = false; this.error = null },
confirmBoxRow(index) {
if (this.busy) return
if (!this.boxRowValid(index)) return this.setError(new Error('请填写有效箱规、重量和正整数实收数量,数量不能超过扣除其他箱后的剩余量'), '本箱确认失败')
this.boxRows[index].confirmed = true; this.error = null
if (this.allBoxRowsConfirmed) this.choosePhoto(true)
},
acceptUnit(raw) { const code = String(raw?.detail?.value ?? raw ?? '').trim(); if (!code) return; this.unitCode = code; if (this.unitType !== 'PARCEL') this.palletCode = `${this.unitType}:${code}`; this.activeTarget = this.unitType === 'PARCEL' ? 'unit' : 'tracking'; playSuccessSound() },
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleScan(value), onError: (error) => this.setError(error, '扫码广播注册失败') }) },
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
handleScan(value) { if (this.busy) return; if (!this.current) { this.lookupCode = value; this.lookup(value); return } if (this.activeTarget === 'carton') this.acceptCarton(value); else this.acceptTracking(value) },
handleScan(value) { if (this.result) return; if (this.busy || this.receiptBatch) return; if (!this.current) { this.lookupCode = value; this.lookup(value); return } if (this.activeTarget === 'unit') this.acceptUnit(value); else this.acceptTracking(value) },
switchMode(mode) { if (this.busy) return; this.entryMode = mode; this.lookupCode = ''; this.candidates = null; this.error = null; this.activeTarget = 'lookup' },
async lookup(raw) { const value = String(raw?.detail?.value ?? raw ?? this.lookupCode).trim(); if (!value || this.busy) return; this.lookupCode = value; this.error = null; this.result = null; if (this.entryMode === 'SKU') await this.run(async () => { const response = await findReturnSkuCandidates(value); this.candidates = response.candidates; if (this.candidates.length) playSuccessSound() }, '查询 SKU 候选失败'); else await this.openOrder(value, 'RETURN_NO') },
async selectCandidate(item) { if (!item?.returnNo || this.busy) return; await this.openOrder(item.returnNo, 'SKU') },
async openOrder(returnNo, startedBy, feedback = true) { await this.run(async () => { const value = await startReturnReceipt(returnNo); if (!value) { this.current = null; this.candidates = []; updateWarehouseWork('returnReceipt', { currentReturnNo: '', quantities: {} }); this.activeTarget = 'lookup'; return } this.current = value; this.candidates = null; this.startedBy = startedBy; updateWarehouseWork('returnReceipt', { currentReturnNo: value.returnNo, startedBy, lookupCode: value.returnNo, quantities: this.validQuantities(value) }); this.activeTarget = 'tracking'; if (feedback) playSuccessSound() }, '读取退货单失败') },
async lookup(raw) { const value = String(raw?.detail?.value ?? raw ?? this.lookupCode).trim(); if (!value || this.busy) return; this.lookupCode = value; this.error = null; this.result = null; if (this.entryMode === 'SKU') await this.run(async () => { const response = await findReturnSkuCandidates(value); this.candidates = response.candidates; this.selectedSku = response.skuCode; if (this.candidates.length === 1) await this.openOrderValue(this.candidates[0].returnNo, 'SKU'); else if (this.candidates.length) playSuccessSound() }, '查询 SKU 候选失败'); else await this.openOrder(value, 'RETURN_NO') },
async selectCandidate(item) { if (!item?.returnNo || this.busy) return; this.selectedSku = this.lookupCode; await this.openOrder(item.returnNo, 'SKU') },
async openOrder(returnNo, startedBy, feedback = true) { await this.run(async () => { await this.openOrderValue(returnNo, startedBy, feedback) }, '读取退货单失败') },
async openOrderValue(returnNo, startedBy, feedback = true) { const value = await startReturnReceipt(returnNo); if (!value) { this.current = null; this.candidates = []; updateWarehouseWork('returnReceipt', { currentReturnNo: '', quantities: {}, boxCounts: {} }); this.activeTarget = 'lookup'; return } this.current = value; this.palletCheck = null; this.candidates = null; this.startedBy = startedBy; this.photoFileIds = (value.photos || []).map((item) => String(item.fileId)); this.photoNames = (value.photos || []).map((item) => item.fileName || '退货打托照片'); this.photoUrls = {}; await Promise.all(this.photoFileIds.map(async (fileId) => { try { this.photoUrls[fileId] = await downloadReturnPalletPhoto(value.returnNo, fileId) } catch (_) { /* 单张照片失败不阻断作业 */ } })); updateWarehouseWork('returnReceipt', { currentReturnNo: value.returnNo, startedBy, lookupCode: value.returnNo, palletCode: '', quantities: this.validQuantities(value), boxCounts: {} }); this.activeTarget = 'pallet'; if (feedback) playSuccessSound() },
validQuantities(order) { const source = warehouseWork.returnReceipt.quantities || {}; return Object.fromEntries(order.expectedItems.map((item) => [item.skuCode, Math.min(Math.max(0, Number(source[item.skuCode]) || 0), this.remaining(item))])) },
quantity(skuCode) { return Math.max(0, Number(warehouseWork.returnReceipt.quantities?.[skuCode]) || 0) },
boxCount(skuCode) { return Math.max(0, Number(warehouseWork.returnReceipt.boxCounts?.[skuCode]) || 0) },
remaining(item) { return Math.max(0, Number(item.expectedQuantity || 0) - Number(item.receivedQuantity || 0)) },
setQuantity(item, raw) { const maximum = this.remaining(item); const value = Math.min(maximum, Math.max(0, Math.floor(Number(raw) || 0))); updateWarehouseWork('returnReceipt', { quantities: { ...(warehouseWork.returnReceipt.quantities || {}), [item.skuCode]: value } }) },
fillRemaining() { updateWarehouseWork('returnReceipt', { quantities: Object.fromEntries(this.current.expectedItems.map((item) => [item.skuCode, this.remaining(item)])) }) },
acceptTracking(raw) { this.trackingNo = String(raw?.detail?.value ?? raw ?? '').trim(); this.activeTarget = 'carton'; playSuccessSound() },
acceptCarton(raw) { this.cartonMark = String(raw?.detail?.value ?? raw ?? '').trim(); playSuccessSound() },
async confirmReceipt() { if (!this.canConfirm || this.busy) return; const payload = { returnNo: this.current.returnNo, trackingNo: this.trackingNo.trim() || undefined, startedBy: this.startedBy, cartonMark: this.cartonMark.trim() || undefined, items: this.current.expectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: this.quantity(item.skuCode) })).filter((item) => item.receivedQuantity > 0) }; const key = warehouseIdempotencyKey('returnReceipt', 'confirm', payload); await this.run(async () => { this.result = await confirmReturnReceipt({ ...payload, idempotencyKey: key.value }); releaseWarehouseKey('returnReceipt', key.signature); resetWarehouseDomain('returnReceipt'); this.current = null; this.candidates = null; this.activeTarget = 'lookup'; playSuccessSound() }, '退货入库失败') },
setBoxCount(item, raw) { const value = Math.max(0, Math.floor(Number(raw) || 0)); updateWarehouseWork('returnReceipt', { boxCounts: { ...(warehouseWork.returnReceipt.boxCounts || {}), [item.skuCode]: value } }) },
fillRemaining() { updateWarehouseWork('returnReceipt', { quantities: Object.fromEntries(this.selectedItems.map((item) => [item.skuCode, this.remaining(item)])) }) },
async acceptPallet(raw) { const code = String(raw?.detail?.value ?? raw ?? '').trim(); if (!code || this.busy) return; if (!this.activeSkuCode) { this.setError(new Error('请先通过 SKU 69 码选择一个待签收 SKU'), '托盘校验失败'); return } await this.run(async () => { const value = await checkReturnPallet(this.current.returnNo, code); this.palletCode = value.palletCode; this.newPalletHtml = ''; this.palletCheck = value; this.activeTarget = 'tracking'; playSuccessSound() }, '托盘校验失败') },
async generatePallet() { if (!this.current || this.busy) return; const skuCode = this.activeSkuCode; if (!skuCode) { this.setError(new Error('请先通过 SKU 69 码选择一个待签收 SKU'), '无法生成退货托盘'); return } await this.run(async () => { const value = await createReturnPallet(this.current.returnNo, skuCode); if (!value.palletCode) throw new Error('服务端未返回新托盘码'); this.palletCode = value.palletCode; this.newPalletHtml = value.printableHtml; this.palletCheck = { palletCode: value.palletCode, empty: true, skuCodes: [], quantity: 0 }; this.activeTarget = 'tracking'; playSuccessSound() }, '生成退货托盘失败') },
printPallet() { if (!this.newPalletHtml) return; /* #ifdef H5 */ const view = window.open('', '_blank'); if (view) { view.document.write(this.newPalletHtml); view.document.close(); view.focus(); view.print() } else this.setError(new Error('请允许打开标签打印窗口'), '打印失败'); /* #endif */ /* #ifndef H5 */ this.setError(new Error('当前设备未配置打印服务'), '打印失败'); /* #endif */ },
printReturnCarton(label) { if (!label?.printableHtml) return; /* #ifdef H5 */ const view = window.open('', '_blank'); if (view) { view.document.write(label.printableHtml); view.document.close(); view.focus(); view.print() } else this.setError(new Error('请允许打开标签打印窗口'), '打印失败'); /* #endif */ /* #ifndef H5 */ this.setError(new Error('当前设备未配置打印服务'), '打印失败'); /* #endif */ },
acceptTracking(raw) { this.trackingNo = String(raw?.detail?.value ?? raw ?? '').trim(); playSuccessSound() },
async confirmReceipt() { if (this.busy) return; if (this.receiptBatch) { await this.run(() => this.resumeReceiptBatch(), '退货入库失败'); return } const totalBoxCount = this.palletBoxCount; if (this.unitType === 'PALLET' && totalBoxCount <= 0) { this.setError(new Error('请先填写各 SKU 的箱数'), '托盘签收校验失败'); return } if (!this.canConfirm || this.busy || (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 }
await this.run(async () => {
const rows = this.unitType !== 'PALLET' ? this.boxRows : [null]
const entries = rows.map((box, index) => {
const payload = { returnNo: this.current.returnNo, trackingNo: (this.unitType === 'PARCEL' ? this.unitCode.trim() : this.trackingNo.trim()) || undefined, startedBy: this.startedBy, unitType: this.unitType, unitCode: this.unitType === 'PARCEL' || this.unitType === 'BOX' ? undefined : undefined, palletCode: this.unitType === 'PALLET' || this.unitType === 'BOX' ? this.palletCode.trim() || undefined : undefined, length: this.unitType === 'PALLET' ? Number(this.palletLength) : Number(box.length), width: this.unitType === 'PALLET' ? Number(this.palletWidth) : Number(box.width), height: this.unitType === 'PALLET' ? Number(this.palletHeight) : Number(box.height), weight: this.unitType === 'PALLET' ? Number(this.palletWeight) : Number(box.weight), originalPackaging: this.unitType === 'PALLET' ? undefined : box.originalPackaging, totalBoxCount: this.unitType === 'PALLET' ? totalBoxCount : undefined, items: (this.unitType !== 'PALLET' ? this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: Number(box.items[item.skuCode]) || 0, boxCount: this.unitType === 'BOX' ? 1 : undefined })) : this.selectedItems.map((item) => ({ skuCode: item.skuCode, receivedQuantity: this.quantity(item.skuCode), boxCount: this.boxCount(item.skuCode) }))).filter((item) => item.receivedQuantity > 0) }
const key = warehouseIdempotencyKey('returnReceipt', `confirm-${index}`, payload)
return { request: { ...payload, idempotencyKey: key.value }, result: null }
})
if (this.unitType !== 'PALLET') {
updateWarehouseWork('returnReceipt', { pendingBatch: JSON.parse(JSON.stringify({ returnNo: this.current.returnNo, unitType: this.unitType, entries })) })
await this.resumeReceiptBatch()
} else {
this.finishReceipt([await confirmReturnReceipt(entries[0].request)])
}
}, '退货入库失败')
},
async resumeReceiptBatch() {
const batch = this.receiptBatch
const scopeKey = warehouseWork.scopeKey
if (!batch) return
for (let index = 0; index < batch.entries.length; index++) {
if (warehouseWork.scopeKey !== scopeKey) throw new Error('作业账号或仓库已改变,请重新进入作业')
if (batch.entries[index].result) continue
// Persist the immutable request before every attempt; storage failures must stop submission.
updateWarehouseWork('returnReceipt', { pendingBatch: batch })
const result = await confirmReturnReceipt(batch.entries[index].request)
if (warehouseWork.scopeKey !== scopeKey) throw new Error('作业账号或仓库已改变,请重新进入作业')
if (!result) throw new Error('服务端未返回签收结果,请重试本批签收')
batch.entries[index].result = result
updateWarehouseWork('returnReceipt', { pendingBatch: batch })
}
this.finishReceipt(batch.entries.map(entry => entry.result))
},
finishReceipt(results) {
const last = results[results.length - 1]
this.result = { ...last, continuationReturnNo: !last.fullyMatched ? this.current?.returnNo || '' : '', labels: results.flatMap(item => item.labels?.length ? item.labels : item.printableHtml && item.cartonMark ? [{ cartonMark: item.cartonMark, printableHtml: item.printableHtml }] : []) }
resetWarehouseDomain('returnReceipt')
this.current = null
this.candidates = null
this.activeTarget = 'lookup'
playSuccessSound()
},
manualComplete() { if (!this.current || this.busy) return; uni.showModal({ title: '缺货手动完结', editable: true, placeholderText: '请填写实收少于预期数量的原因', success: async (modal) => { if (!modal.confirm) return; const reason = String(modal.content || '').trim(); if (!reason) { this.setError(new Error('必须填写差异原因'), '手动完结失败'); return } await this.run(async () => { await manuallyCompleteReturnReceipt(this.current.returnNo, reason); this.result = { palletCode: '', receiptNo: '', receiptBatchNo: '', expectedQuantity: this.current.expectedItems.reduce((sum, item) => sum + item.expectedQuantity, 0), receivedQuantity: this.current.expectedItems.reduce((sum, item) => sum + item.receivedQuantity, 0), fullyMatched: false }; this.current = null; this.candidates = null; this.activeTarget = 'lookup'; playSuccessSound() }, '手动完结失败') } }) },
choosePhoto(cameraOnly = false) {
if (!this.current || this.busy) return
if (this.photoFileIds.length >= 20) return
const no = this.current.returnNo; const draft = JSON.stringify(this.boxRows); const type = this.unitType
uni.chooseImage({ count: cameraOnly ? 1 : Math.min(9, 20 - this.photoFileIds.length), sourceType: cameraOnly ? ['camera'] : ['camera', 'album'], success: async ({ tempFilePaths, tempFiles }) => {
if (this.current?.returnNo !== no || this.unitType !== type || JSON.stringify(this.boxRows) !== draft) return
await this.run(async () => {
for (const [index,path] of (tempFilePaths || []).entries()) {
if (Number(tempFiles?.[index]?.size || 0) > 10 * 1024 * 1024) throw new Error('照片不能超过 10MB')
const file = await uploadReturnPalletPhoto(no, path); const id = String(file.fileId)
this.photoFileIds.push(id); this.photoNames.push(file.fileName || '退货打托照片'); this.photoUrls[id] = path
}
playSuccessSound()
}, '退货打托照片上传失败')
} })
},
previewPhoto(fileId) { const urls = this.photoFileIds.map(id => this.photoUrls[id]).filter(Boolean); if (this.photoUrls[fileId]) uni.previewImage({ current: this.photoUrls[fileId], urls }) },
removePhoto(index) { const [id] = this.photoFileIds.splice(index, 1); this.photoNames.splice(index, 1); if (id) delete this.photoUrls[id] },
async reloadCurrent() { if (!this.current?.returnNo || this.busy) return; await this.openOrder(this.current.returnNo, this.startedBy, false) },
clearCurrent() { resetWarehouseDomain('returnReceipt'); this.current = null; this.candidates = null; this.error = null; this.activeTarget = 'lookup' },
nextReceipt() { this.result = null; this.clearCurrent() },
clearCurrent() { if (this.busy || this.receiptBatch) return; resetWarehouseDomain('returnReceipt'); this.current = null; this.candidates = null; this.error = null; this.activeTarget = 'lookup' },
continueScanning() {
if (this.busy || this.receiptBatch) return
this.clearCurrent()
this.result = null
this.entryMode = 'SKU'; this.lookupCode = ''; this.selectedSku = ''
this.palletCheck = null; this.newPalletHtml = ''
this.palletLength = ''; this.palletWidth = ''; this.palletHeight = ''; this.palletWeight = ''
this.unitCode = ''; this.boxRows = [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }]
this.photoFileIds = []; this.photoNames = []; this.photoUrls = {}
},
async nextReceipt() {
if (this.busy) return
const returnNo = this.result?.continuationReturnNo
if (!returnNo) { this.result = null; this.clearCurrent(); return }
await this.run(async () => {
await this.openOrderValue(returnNo, 'SKU')
this.palletLength = ''; this.palletWidth = ''; this.palletHeight = ''; this.palletWeight = ''
this.unitCode = ''; this.boxRows = [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }]; this.newPalletHtml = ''
this.result = null
}, '读取剩余待签收货物失败')
},
async run(work, fallback) { if (this.busy) return; this.busy = true; this.error = null; try { await work() } catch (error) { this.setError(error, fallback) } finally { this.busy = false } },
setError(error, fallback) { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
statusLabel(value) { return ({ PENDING_RECEIPT: '待签收', PARTIALLY_RECEIVED: '签收中', COMPLETED: '已完成' })[value] || value || '状态未知' }
@@ -88,5 +225,14 @@
<style scoped lang="scss">
@import '../warehouse/operations.scss';
.candidate-heading .card-title{margin-bottom:0}.candidate-card{width:100%;text-align:left}.select-link{color:#135ee8;font-size:12px;font-weight:800}.field-label{display:block;margin:13px 0 6px;color:#52667e;font-size:11px;font-weight:700}.remaining-copy{color:#135ee8;font-size:11px}.quantity-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:10px;color:#65768b;font-size:11px}.quantity-row .number-input{width:112px;text-align:center}.summary-bar{position:sticky;bottom:0;z-index:4;display:flex;align-items:center;gap:12px;margin-top:12px;padding:11px 13px;border:1px solid #d8e3f1;border-radius:13px;background:#fff;box-shadow:0 5px 20px rgba(22,55,96,.14)}.summary-bar>view{min-width:70px}.summary-label,.summary-value{display:block}.summary-label{color:#728197;font-size:10px}.summary-value{color:#135ee8;font-size:22px;font-weight:800}.submit-button{margin-top:0}.result-card{text-align:left}.result-carton{display:block;margin:9px 0;color:#135ee8;font-size:20px;font-weight:800;word-break:break-all}.link-button{width:auto}.work-card>.link-button{margin-top:8px;padding-left:0}.success-box .primary{margin-top:14px}
.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}
.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}
</style>
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -4,7 +4,7 @@ const STORAGE_KEY = 'overseas-pda-fulfillment-draft-v1'
const blankState = () => ({
scopeKey: '',
dispatch: { currentNo: '', palletCode: '', packages: 1, pendingKeys: {} },
dispatch: { currentNo: '', palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1, pendingKeys: {} },
outbound: { currentTaskNo: '', scanCode: '', packages: 1, pendingKeys: {} }
})
+31 -2
View File
@@ -8,6 +8,28 @@ export const receiving = reactive({
})
const idempotencyKeys = new Map()
let previewOwner = ''
const previewRequests = new WeakMap()
function photoOwner(value, owner) {
if (!owner || !value?.containerNo || !value?.preparedAt) return ''
return JSON.stringify([owner, value.containerNo, value.preparedAt, value.overseasWarehouseId,
(value.inbounds || []).map(row => [row.customerId || '', row.inboundCode || '']).sort()])
}
export async function restoreReceivingPhoto(photo, download) {
if (!photo?.downloadPath || photo.localPath) return photo?.localPath || ''
if (previewRequests.has(photo)) return previewRequests.get(photo)
photo.status = 'loading'
const request = Promise.resolve().then(() => download(photo.downloadPath)).then(path => {
if (!path) throw new Error('照片加载失败')
photo.localPath = path; photo.status = 'success'; photo.errorMessage = ''
return path
}).catch(error => { photo.status = 'error'; photo.errorMessage = error.message || '照片加载失败'; throw error })
.finally(() => previewRequests.delete(photo))
previewRequests.set(photo, request)
return request
}
export function receivingPhase(session = receiving.current) {
if (!session) return 'candidates'
@@ -23,16 +45,23 @@ export function receivingProgress(session = receiving.current) {
return { total, received, pending: Math.max(0, Number(session?.pendingPackages ?? total - received)), percent: total ? Math.min(100, Math.round(received / total * 100)) : 0 }
}
export function setCurrentReceiving(value) {
export function setCurrentReceiving(value, owner = '') {
const nextOwner = photoOwner(value, owner)
const previous = previewOwner && previewOwner === nextOwner ? receiving.photoSlots : []
previewOwner = nextOwner
receiving.current = value || null
receiving.selected = value || null
receiving.photoSlots = RECEIVING_PHOTO_ROLES.map((role) => {
const photo = (value?.photos || []).find((item) => item.role === role)
return photo ? { ...photo, fileId: String(photo.fileId), role, uploaded: true, status: 'success' } : null
if (!photo) return null
const old = previous.find(item => item?.role === role && String(item.fileId) === String(photo.fileId)
&& item.downloadPath === photo.downloadPath)
return old || { ...photo, fileId: String(photo.fileId), role, localPath: '', uploaded: true, status: 'idle' }
})
}
export function resetReceivingDraft({ keepCurrent = false } = {}) {
previewOwner = ''
receiving.selected = keepCurrent ? receiving.current : null
if (!keepCurrent) receiving.current = null
receiving.photoSlots = [null, null, null]
+38 -34
View File
@@ -1,15 +1,17 @@
import { reactive } from 'vue'
import { OVERSEAS_API_ROOT } from '../config/runtime.js'
import { API_ROOT } from '../config/runtime.js'
import { resetReceivingSession } from './receiving.js'
import { resetFulfillmentSession } from './fulfillment.js'
import { resetWarehouseWork } from './warehouse-work.js'
const SESSION_KEY = 'overseas-pda-auth-session'
let sessionRevision = 0
export function getSessionRevision() { return sessionRevision }
export const PDA_ROLE = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR'
export const PDA_AUTHORITY = 'lastleg:pda:operate'
export const session = reactive({
currentUser: null, token: '', domesticToken: '', refreshToken: '', tenantId: '', apiBase: OVERSEAS_API_ROOT,
currentUser: null, token: '', refreshToken: '', tenantId: '', apiBase: API_ROOT,
authorization: { roles: [], authorities: [] }
})
@@ -47,7 +49,7 @@ function includesClaim(values, expected) {
}
function requireActiveClaims(claims, label) {
if (claims.exp && Number(claims.exp) * 1000 <= Date.now()) throw new Error(`${label}已过期`)
if (!Number.isFinite(Number(claims.exp)) || Number(claims.exp) * 1000 <= Date.now()) throw new Error(`${label}已过期`)
if (claims.app_code !== 'PLATFORM' || claimId(claims, 'tenant_id') !== '0') {
throw new Error('当前账号不属于平台仓库作业端')
}
@@ -67,25 +69,20 @@ function requireAuthorizationSnapshot(authorization) {
return { roles, authorities }
}
export function validatePdaTokenPair(accessToken, overseasAccessToken, authorization) {
const domestic = decodeAccessToken(accessToken)
const overseas = decodeAccessToken(overseasAccessToken)
requireActiveClaims(domestic, '登录状态')
requireActiveClaims(overseas, '海外作业令牌')
for (const claim of ['user_id', 'membership_id', 'tenant_id', 'app_code', 'overseas_warehouse_id']) {
if (claimId(domestic, claim) !== claimId(overseas, claim)) throw new Error('国内与海外令牌作业主体不一致')
}
const warehouseId = claimId(overseas, 'overseas_warehouse_id')
export function validatePdaToken(accessToken, authorization) {
const claims = decodeAccessToken(accessToken)
requireActiveClaims(claims, '登录状态')
const warehouseId = claimId(claims, 'overseas_warehouse_id')
if (!/^\d+$/.test(warehouseId) || warehouseId === '0') throw new Error('海外PDA账号未绑定有效作业仓库')
return { domestic, overseas, warehouseId, authorization: requireAuthorizationSnapshot(authorization) }
return { claims, warehouseId, authorization: requireAuthorizationSnapshot(authorization) }
}
function userFromClaims(pair, saved = {}) {
const savedWarehouseId = String(saved?.warehouseId || '')
const sameWarehouse = savedWarehouseId === pair.warehouseId
return {
id: claimId(pair.domestic, 'user_id'),
name: pair.domestic.username || '仓库作业员',
id: claimId(pair.claims, 'user_id'),
name: pair.claims.username || '仓库作业员',
role: PDA_ROLE,
roles: [...pair.authorization.roles],
warehouse: sameWarehouse && saved.warehouseName ? saved.warehouseName : '',
@@ -102,9 +99,9 @@ function storageAvailable() {
function persistSession() {
if (!storageAvailable()) return
uni.setStorageSync(SESSION_KEY, {
version: 2,
currentUser: session.currentUser,
token: session.token,
domesticToken: session.domesticToken,
refreshToken: session.refreshToken,
tenantId: session.tenantId,
apiBase: session.apiBase,
@@ -113,13 +110,13 @@ function persistSession() {
}
export function signIn(account, warehouse, options = {}) {
sessionRevision += 1
session.currentUser = { id: String(account.id), name: account.name, role: account.role, roles: account.roles || [], warehouse,
warehouseId: account.warehouseId || null, warehouseCode: account.warehouseCode || '', warehouseName: account.warehouseName || '' }
session.token = options.token || account.token || ''
session.domesticToken = options.domesticToken || account.domesticToken || ''
session.refreshToken = options.refreshToken || account.refreshToken || ''
session.tenantId = options.tenantId || account.tenantId || ''
session.apiBase = options.apiBase || account.apiBase || OVERSEAS_API_ROOT
session.apiBase = options.apiBase || account.apiBase || API_ROOT
session.authorization = {
roles: [...(options.authorization?.roles || [])],
authorities: [...(options.authorization?.authorities || [])]
@@ -130,28 +127,33 @@ export function signIn(account, warehouse, options = {}) {
export function signInWithTokens(tokens) {
if (!tokens?.accessToken) throw new Error('登录响应缺少 accessToken')
if (!tokens?.overseasAccessToken) throw new Error('登录响应缺少 overseasAccessToken')
const pair = validatePdaTokenPair(tokens.accessToken, tokens.overseasAccessToken, tokens)
const pair = validatePdaToken(tokens.accessToken, tokens)
const user = userFromClaims(pair)
return signIn(user, user.warehouse, {
token: tokens.overseasAccessToken,
domesticToken: tokens.accessToken,
token: tokens.accessToken,
refreshToken: tokens.refreshToken || '',
tenantId: claimId(pair.domestic, 'tenant_id'),
tenantId: claimId(pair.claims, 'tenant_id'),
authorization: pair.authorization
})
}
export function replaceTokens(tokens) {
if (!tokens?.accessToken || !tokens?.overseasAccessToken) throw new Error('刷新响应缺少双区域访问令牌')
const pair = validatePdaTokenPair(tokens.accessToken, tokens.overseasAccessToken, tokens)
if (!tokens?.accessToken) throw new Error('刷新响应缺少访问令牌')
const pair = validatePdaToken(tokens.accessToken, tokens)
if (session.currentUser) {
const previousClaims = decodeAccessToken(session.token)
const identityChanged = ['user_id', 'membership_id', 'tenant_id', 'app_code', 'overseas_warehouse_id']
.some((name) => claimId(previousClaims, name) !== claimId(pair.claims, name))
if (identityChanged || String(session.currentUser.id) !== claimId(pair.claims, 'user_id') || String(session.currentUser.warehouseId) !== pair.warehouseId) {
throw new Error('作业主体或绑定仓库已变更,请重新登录')
}
}
const user = userFromClaims(pair, session.currentUser)
const nextRefreshToken = tokens.refreshToken || session.refreshToken
// Validate the complete replacement before mutating reactive or persisted session state.
session.domesticToken = tokens.accessToken
session.token = tokens.overseasAccessToken
session.token = tokens.accessToken
session.refreshToken = nextRefreshToken
session.tenantId = claimId(pair.domestic, 'tenant_id')
session.tenantId = claimId(pair.claims, 'tenant_id')
session.currentUser = user
session.authorization = pair.authorization
persistSession()
@@ -174,16 +176,18 @@ export function applyWarehouseProfile(profile) {
export function restoreSession() {
if (!storageAvailable()) return false
const saved = uni.getStorageSync(SESSION_KEY)
if (!saved?.token || !saved?.domesticToken || !saved?.refreshToken || !saved?.currentUser) {
if (!saved?.token || !saved?.refreshToken || !saved?.currentUser || saved.version !== 2) {
signOut()
return false
}
try {
const pair = validatePdaTokenPair(saved.domesticToken, saved.token, saved.authorization)
const pair = validatePdaToken(saved.token, saved.authorization)
if (String(saved.currentUser.id) !== claimId(pair.claims, 'user_id') || String(saved.currentUser.warehouseId) !== pair.warehouseId) throw new Error('缓存作业主体不一致')
Object.assign(session, saved, {
currentUser: userFromClaims(pair, saved.currentUser), tenantId: claimId(pair.domestic, 'tenant_id'),
apiBase: OVERSEAS_API_ROOT, authorization: pair.authorization
currentUser: userFromClaims(pair, saved.currentUser), tenantId: claimId(pair.claims, 'tenant_id'),
apiBase: API_ROOT, authorization: pair.authorization
})
sessionRevision += 1
return true
} catch (_) {
signOut()
@@ -192,12 +196,12 @@ export function restoreSession() {
}
export function signOut() {
sessionRevision += 1
session.currentUser = null
session.token = ''
session.domesticToken = ''
session.refreshToken = ''
session.tenantId = ''
session.apiBase = OVERSEAS_API_ROOT
session.apiBase = API_ROOT
session.authorization = { roles: [], authorities: [] }
if (typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(SESSION_KEY)
resetReceivingSession()
+6 -2
View File
@@ -6,8 +6,10 @@ const blankState = () => ({
stocktake: { taskNo: '', scopeType: 'LOCATION', scopeCode: '', reason: '', pendingKeys: {} },
move: { sourcePalletCode: '', targetLocationCode: '', reason: '', pendingKeys: {} },
relabel: { currentNo: '', oldLabel: '', newLabel: '', action: 'RELABEL', reason: '', remark: '', photoFileIds: [], photoNames: [], pendingKeys: {} },
returnReceipt: { entryMode: 'RETURN_NO', lookupCode: '', currentReturnNo: '', startedBy: 'RETURN_NO', trackingNo: '', cartonMark: '', quantities: {}, pendingKeys: {} },
returnPacking: { searchCode: '', selectedOrders: [], quantities: {}, length: '', width: '', height: '', weight: '', pendingKeys: {} },
returnReceipt: { pendingBatch: null, entryMode: 'SKU', lookupCode: '', currentReturnNo: '', startedBy: 'SKU', trackingNo: '', palletCode: '', quantities: {}, boxCounts: {}, pendingKeys: {} },
returnPacking: { mode: 'SKU', searchCode: '', selectedOrders: [], quantities: {}, targetBoxes: [], sourceCartonMarks: [], targetCode: '', activePlan: null, result: null, putawayResults: {}, pendingKeys: {} },
returnPackingPlan: { pendingKeys: {} },
warehouseTransfer: { currentNo: '', pendingKeys: {} },
query: { inventoryCode: '', locationCode: '', palletCode: '' }
})
@@ -39,6 +41,8 @@ export function restoreWarehouseWork(scopeKey) {
stocktake: domain(saved.stocktake, fresh.stocktake), move: domain(saved.move, fresh.move),
relabel: domain(saved.relabel, fresh.relabel), returnReceipt: domain(saved.returnReceipt, fresh.returnReceipt),
returnPacking: domain(saved.returnPacking, fresh.returnPacking),
returnPackingPlan: domain(saved.returnPackingPlan, fresh.returnPackingPlan),
warehouseTransfer: domain(saved.warehouseTransfer, fresh.warehouseTransfer),
query: { ...fresh.query, ...(saved.query || {}) }
})
return true
+9 -2
View File
@@ -26,10 +26,11 @@ function getRuntimeInfo() {
})
}
function normalizeUpdate(payload) {
function normalizeUpdate(payload, runtime) {
const value = payload && typeof payload === 'object' ? payload : {}
if (value.update !== true) return null
if (value.appid !== runtime.appid) throw new Error('更新包与当前应用不匹配')
const packageType = String(value.packageType || '').toLowerCase()
const downloadUrl = String(value.downloadUrl || '').trim()
const versionName = String(value.versionName || '').trim()
@@ -37,12 +38,18 @@ function normalizeUpdate(payload) {
throw new Error('更新服务返回了无效的版本信息')
}
if (!/^https:\/\//i.test(downloadUrl)) throw new Error('更新包必须使用 HTTPS 地址')
const minAppVersionCode = Number(value.minAppVersionCode)
if (packageType === 'wgt' && (!Number.isInteger(minAppVersionCode) || minAppVersionCode < 1
|| minAppVersionCode > runtime.appVersionCode)) {
throw new Error('当前安装包不支持此热更新,请安装新版 APK')
}
return {
packageType,
downloadUrl,
versionName,
versionCode: Number(value.versionCode || 0),
minAppVersionCode,
mandatory: value.mandatory === true,
releaseNotes: String(value.releaseNotes || '').trim()
}
@@ -177,7 +184,7 @@ export async function checkForAppUpdate({ manual = false } = {}) {
})
if (response.statusCode !== 200) throw new Error('更新服务暂时不可用')
const update = normalizeUpdate(response.data?.data ?? response.data)
const update = normalizeUpdate(response.data?.data ?? response.data, runtime)
if (update && isNewerUpdate(update, runtime)) {
await runUpdate(update)
} else if (manual) {
+4 -1
View File
@@ -4,6 +4,7 @@ export const AUDIO_FEEDBACK_SOURCES = Object.freeze({
})
let audioContext = null
let hasStartedPlayback = false
function getAudioContext() {
if (audioContext) return audioContext
@@ -22,9 +23,10 @@ export function playFeedbackSound(type) {
if (!source || !player) return false
try {
player.stop()
if (hasStartedPlayback) player.stop()
player.src = source
player.play()
hasStartedPlayback = true
return true
} catch (_) {
return false
@@ -48,4 +50,5 @@ export function destroyFeedbackSound() {
if (!audioContext) return
try { audioContext.destroy() } catch (_) {}
audioContext = null
hasStartedPlayback = false
}
+3 -1
View File
@@ -7,7 +7,9 @@ export function toDispatchError(error, fallback = '排车作业失败') {
const originalMessage = String(error?.message || '').trim()
let message = originalMessage || fallback
if (FORBIDDEN_CODES.has(code)) {
if (error?.network) {
message = '网络连接失败,请检查网络后重试,当前作业已保留'
} else if (FORBIDDEN_CODES.has(code)) {
message = '当前账号没有海外仓 PDA 作业权限,请联系管理员授权'
} else if (CONFLICT_CODES.has(code) && originalMessage === VEHICLE_PLATE_REQUIRED) {
message = '请先在平台维护车牌信息后再拣货'
+8
View File
@@ -0,0 +1,8 @@
export function isRelabelFundsBlocked(error) {
return /Relabel funds must be frozen before processing|换标资金尚未冻结/.test(String(error?.message || ''))
}
export function relabelErrorMessage(error, fallback = '换标作业失败') {
if (isRelabelFundsBlocked(error)) return '换标资金尚未冻结,暂时不能认领。请后台检查客户余额或授信及资金冻结结果,处理后再认领。'
return error?.message || fallback
}
+26
View File
@@ -0,0 +1,26 @@
export function defaultPackingBoxes(items) {
return items.filter(item => Number(item.quantity) > 0).map(item => ({
quantities: { [item.skuCode]: Number(item.quantity) }, length: '', width: '', height: '', weight: ''
}))
}
export function packingBoxPayload(box) {
return { items: Object.entries(box.quantities || {}).filter(([, quantity]) => Number(quantity) > 0)
.map(([skuCode, quantity]) => ({ skuCode, quantity: Number(quantity) })),
length: Number(box.length), width: Number(box.width), height: Number(box.height), weight: Number(box.weight) }
}
export function validPackingAllocation(boxes, expected) {
if (!boxes.length) return false
const totals = {}
for (const box of boxes) {
const payload = packingBoxPayload(box)
if (!payload.items.length || !['length', 'width', 'height', 'weight'].every(key => Number.isFinite(payload[key]) && payload[key] > 0)) return false
for (const item of payload.items) {
if (!Number.isSafeInteger(item.quantity) || item.quantity <= 0 || !Object.hasOwn(expected, item.skuCode)) return false
totals[item.skuCode] = (totals[item.skuCode] || 0) + item.quantity
}
}
return Object.entries(expected).filter(([, quantity]) => Number(quantity) > 0).every(([sku, quantity]) => totals[sku] === Number(quantity))
&& Object.keys(totals).length === Object.values(expected).filter(quantity => Number(quantity) > 0).length
}
+25
View File
@@ -0,0 +1,25 @@
export function returnSelections(task) {
return (task?.items || []).filter(item => item.returnCartonId && ['SKU_LABEL', 'CARTON_AND_SKU', 'RETURN_BULK_SKU_LABEL'].includes(item.operationType))
}
export function returnDeductions(task) {
const rows = new Map()
for (const item of returnSelections(task)) {
const skus = item.operationType === 'RETURN_BULK_SKU_LABEL' ? [{ skuCode: item.skuCode, quantity: item.expectedPackages }] : item.skuItems || []
for (const sku of skus) {
const key = `${item.cartonMark}::${sku.skuCode}`
const row = rows.get(key) || { cartonMark: item.cartonMark, skuCode: sku.skuCode, quantity: 0 }
row.quantity += Number(sku.quantity || 0); rows.set(key, row)
}
}
return [...rows.values()]
}
export function requiresReturnPack(task) {
const totals = new Map()
for (const row of returnDeductions(task)) totals.set(row.cartonMark, (totals.get(row.cartonMark) || 0) + row.quantity)
return returnSelections(task).some(item => !Number(item.returnQuantity) || totals.get(item.cartonMark) < Number(item.returnQuantity))
}
export function returnPackFacts(task) {
return (task?.history || []).filter(row => row.action === 'RETURN_PACK').flatMap(row => {
try { const detail = typeof row.detail === 'string' ? JSON.parse(row.detail) : row.detail; return detail?.round === (task.reviewedAt || 'INITIAL') ? [detail] : [] } catch { return [] }
})
}
+36 -16
View File
@@ -1,5 +1,11 @@
export const SCAN_BROADCAST_ACTION = 'android.intent.action.SCANRESULT'
export const SCAN_BROADCAST_EXTRA = 'value'
export const SCAN_BROADCAST_ACTIONS = Object.freeze([
SCAN_BROADCAST_ACTION,
'com.android.server.scannerservice.broadcast',
'com.sunmi.scanner.ACTION_DATA_CODE_RECEIVED'
])
const SCAN_BROADCAST_EXTRAS = Object.freeze(['value', 'data', 'barcode', 'SCAN_BARCODE1'])
const DUPLICATE_WINDOW_MS = 80
@@ -29,17 +35,23 @@ export function startScanBroadcast({ onScan, onError } = {}) {
try {
if (receiver) stopScanBroadcast()
activity = plus.android.runtimeMainActivity()
const registeredActivity = plus.android.runtimeMainActivity()
const IntentFilter = plus.android.importClass('android.content.IntentFilter')
const filter = new IntentFilter()
filter.addAction(SCAN_BROADCAST_ACTION)
receiver = plus.android.implements('io.dcloud.feature.internal.reflect.BroadcastReceiver', {
onReceive(context, intent) {
SCAN_BROADCAST_ACTIONS.forEach((action) => filter.addAction(action))
const registeredReceiver = plus.android.implements('io.dcloud.feature.internal.reflect.BroadcastReceiver', {
onReceive(context, intent) {
try {
plus.android.importClass(intent)
const rawValue = intent.getStringExtra(SCAN_BROADCAST_EXTRA)
let rawValue = ''
for (const extra of SCAN_BROADCAST_EXTRAS) {
try {
const candidate = intent.getStringExtra(extra)
if (candidate !== null && candidate !== undefined && String(candidate).trim()) { rawValue = candidate; break }
} catch (_) {}
}
const value = normalizeScanValue(rawValue)
console.info('[Overseas PDA broadcast] received', { action: intent.getAction(), extra: SCAN_BROADCAST_EXTRA, rawValue, value })
console.info('[Overseas PDA broadcast] received', { action: intent.getAction(), extras: SCAN_BROADCAST_EXTRAS, rawValue, value })
if (!value) { console.warn('[Overseas PDA broadcast] ignored empty value'); return }
if (isDuplicateScan(value)) { console.info('[Overseas PDA broadcast] ignored duplicate', { value }); return }
onScan(value)
@@ -53,23 +65,26 @@ export function startScanBroadcast({ onScan, onError } = {}) {
const sdkInt = Number(Build.VERSION.SDK_INT)
if (Build.VERSION.SDK_INT >= 33) {
const Context = plus.android.importClass('android.content.Context')
activity.registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED)
registeredActivity.registerReceiver(registeredReceiver, filter, Context.RECEIVER_EXPORTED)
} else {
activity.registerReceiver(receiver, filter)
registeredActivity.registerReceiver(registeredReceiver, filter)
}
activity = registeredActivity
receiver = registeredReceiver
let packageName = ''
try { packageName = String(activity.getPackageName()) } catch (_) {}
try { packageName = String(registeredActivity.getPackageName()) } catch (_) {}
console.info('[Overseas PDA broadcast] receiver registered', {
action: SCAN_BROADCAST_ACTION,
extra: SCAN_BROADCAST_EXTRA,
actions: SCAN_BROADCAST_ACTIONS,
extras: SCAN_BROADCAST_EXTRAS,
packageName,
sdkInt,
exported: sdkInt >= 33
})
return () => stopRegisteredBroadcast(registeredActivity, registeredReceiver)
} catch (error) {
console.error('[Overseas PDA broadcast] registration failed', {
action: SCAN_BROADCAST_ACTION,
extra: SCAN_BROADCAST_EXTRA,
actions: SCAN_BROADCAST_ACTIONS,
extras: SCAN_BROADCAST_EXTRAS,
message: error?.message || String(error)
})
receiver = null
@@ -81,15 +96,20 @@ export function startScanBroadcast({ onScan, onError } = {}) {
return stopScanBroadcast
}
export function stopScanBroadcast() {
function stopRegisteredBroadcast(registeredActivity, registeredReceiver) {
// #ifdef APP-PLUS
if (activity && receiver) {
if (registeredActivity && registeredReceiver) {
try {
activity.unregisterReceiver(receiver)
registeredActivity.unregisterReceiver(registeredReceiver)
} catch (_) {}
}
// #endif
if (activity !== registeredActivity || receiver !== registeredReceiver) return
receiver = null
activity = null
resetScanDeduplication()
}
export function stopScanBroadcast() {
stopRegisteredBroadcast(activity, receiver)
}
+13
View File
@@ -31,6 +31,19 @@ test('overseas online update supports mandatory APK and wgt installation flows',
assert.match(updater, /if \(retry\) await runUpdate\(update\)/)
})
test('overseas update manifest rejects a domestic AppID and incompatible WGT base', async () => {
const updater = await read('../src/utils/app-update.js')
const body = updater.match(/function normalizeUpdate\(payload, runtime\) \{[\s\S]*?\n\}/)?.[0]
assert.ok(body)
const normalize = new Function(`${body}; return normalizeUpdate`)()
const runtime = { appid: 'overseas-app', appVersionCode: 3 }
const manifest = { update: true, appid: 'overseas-app', packageType: 'wgt', downloadUrl: 'https://example.com/app.wgt', versionName: '1.0.4', minAppVersionCode: 3 }
assert.equal(normalize(manifest, runtime).minAppVersionCode, 3)
assert.throws(() => normalize({ ...manifest, appid: 'domestic-app' }, runtime), /不匹配/)
assert.throws(() => normalize({ ...manifest, minAppVersionCode: 4 }, runtime), /新版 APK/)
assert.throws(() => normalize({ ...manifest, minAppVersionCode: undefined }, runtime), /新版 APK/)
})
test('overseas profile exposes manual App update checking and the runtime version', async () => {
const profile = await read('../src/pages/profile/profile.vue')
+1 -1
View File
@@ -19,7 +19,7 @@ test('shared audio feedback reuses one player and switches success and error sou
assert.equal(audio.playErrorSound(), true)
assert.equal(created, 1)
assert.deepEqual(calls, [
'stop', 'play:/static/mp3/success.mp3',
'play:/static/mp3/success.mp3',
'stop', 'play:/static/mp3/fail.mp3'
])
audio.destroyFeedbackSound()
+163 -8
View File
@@ -6,22 +6,111 @@ import { fulfillment, fulfillmentIdempotencyKey, releaseFulfillmentKey, resetFul
import {
completeDispatchPicking, completeOutboundTask, departDispatch, getDispatchTask, getOutboundTask,
listDispatchTasks, listOutboundTasks, normalizeDispatchTask, normalizeOutboundTask,
pickDispatchItem, pickOutboundTask, resolveOutboundTask, outboundPickPayload
pickDispatchItem, pickOutboundPallet, pickOutboundTask, resolveOutboundTask, outboundPickPayload, tripDepartureRequest
} from '../src/api/fulfillment.js'
import { session } from '../src/stores/session.js'
import { toDispatchError } from '../src/utils/dispatch-error.js'
test('dispatch network failures show recovery guidance and retain diagnostics', () => {
const error = toDispatchError({ message: 'request:fail', network: true, code: 'NETWORK_ERROR', requestId: 'network-request', traceId: 'network-trace' })
assert.equal(error.message, '网络连接失败,请检查网络后重试,当前作业已保留')
assert.equal(error.network, true)
assert.equal(error.code, 'NETWORK_ERROR')
assert.equal(error.requestId, 'network-request')
assert.equal(error.traceId, 'network-trace')
assert.equal(toDispatchError({ message: '业务校验失败' }).message, '业务校验失败')
})
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
test('dispatch merge retains return carton picking and review gates together', async () => {
const source = (await read('../src/pages/dispatch/dispatch.vue')).match(/<script>([\s\S]*?)<\/script>/)[1]
const definition = new Function('PortalHeader', 'ScanTargetInput', 'DispatchHandoverStatus',
source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))({}, {}, {})
const state = { current: { canPick: true }, selectedItem: { returnCartons: [{}] },
selectedReturnCarton: { cartonMark: 'RETURN-BOX' }, selectedPosition: null,
packages: 1, selectedRemaining: 1, pickingCompleted: false, plannedPackages: 1, remainingPackages: 0 }
assert.equal(definition.computed.canPick.call(state), true)
state.selectedReturnCarton = null
assert.equal(definition.computed.canPick.call(state), false)
state.selectedItem.returnCartons = []
state.selectedPosition = { id: 'position' }
assert.equal(definition.computed.canPick.call(state), true)
for (const canPick of [false, undefined, 'true']) {
state.current.canPick = canPick
assert.equal(definition.computed.canPick.call(state), false)
assert.equal(definition.computed.canComplete.call(state), false)
}
const calls = []
const deps = { pickDispatchItem: async (...args) => calls.push(args), getDispatchTask: async () => state.current,
fulfillmentIdempotencyKey: () => ({ value: 'retry-key', signature: 'signature' }), releaseFulfillmentKey() {},
updateFulfillmentDraft() {}, playSuccessSound() {}, PortalHeader: {}, ScanTargetInput: {}, DispatchHandoverStatus: {} }
const page = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
await page.methods.submitPick.call({ canPick: true, current: { dispatchNo: 'DS-RETURN' },
selectedItem: { dispatchItemId: 'item' }, selectedReturnCarton: { cartonMark: 'RETURN-BOX', productQuantity: 5 },
run: async (_, work) => work() })
assert.deepEqual(calls, [['DS-RETURN', 'item', { positionId: null, cartonMark: 'RETURN-BOX', packages: 1, productQuantity: 5, idempotencyKey: 'retry-key' }]])
})
test('trip departure preserves version zero and rejects incomplete trip identity', () => {
const task = normalizeDispatchTask({ tripNo: 'TR-1', tripRecordVersion: 0, tripDispatchCount: 2, items: [] })
assert.equal(task.tripRecordVersion, '0')
assert.equal(task.tripDispatchCount, 2)
assert.deepEqual(tripDepartureRequest(task, 'departure-key'), { recordVersion: '0', idempotencyKey: 'departure-key', remark: '海外 PDA 整车核平后发车' })
for (const invalid of [{}, { tripNo: 'TR-1' }, { tripNo: 'TR-1', tripRecordVersion: '-1' }]) assert.throws(() => tripDepartureRequest(invalid, 'key'), /车次信息不完整/)
})
test('whole trip departure UI identifies all members and refreshes server state before releasing retry key', async () => {
const page = await read('../src/pages/dispatch/dispatch.vue')
assert.match(page, /current\.tripNo[\s\S]*current\.tripDispatchCount/)
assert.match(page, /确认整车发车/)
assert.match(page, /tripDepartureRequest\(this.current, key.value\)/)
assert.doesNotMatch(page, /status: result\?\.status \|\| 'DEPARTED'/)
assert.match(page, /result\.dispatches\?\.some[\s\S]*await getDispatchTask[\s\S]*releaseFulfillmentKey/)
})
test('outbound partial pick submits the selected carton and distinguishes shared-pallet items', async () => {
const first = outboundPickPayload({ outboundPalletItemId: '11' }, { inventoryPositionId: '21', cartonMark: 'BOX-A' }, '1')
assert.deepEqual(first, { outboundPalletItemId: '11', inventoryPositionId: '21', packages: 1, cartonMarks: ['BOX-A'] })
assert.notDeepEqual(first, outboundPickPayload({ outboundPalletItemId: '12' }, { inventoryPositionId: '22', cartonMark: 'BOX-B' }, 1))
assert.deepEqual(outboundPickPayload({ outboundPalletItemId: '13' }, { inventoryPositionId: '23' }, 2).cartonMarks, [])
const page = await read('../src/pages/outbound/outbound.vue')
assert.match(page, /:key="item.outboundPalletItemId"/)
assert.match(page, /outboundPickPayload\(this.selectedItem, this.selectedPosition, this.packages\)/)
assert.match(page, /listOutboundTasks\('PENDING'\)/)
assert.match(page, /:key="item.displayKey \|\| item.outboundPalletItemId"/)
assert.match(page, /outboundPickPayload\(this.selectedItem, this.selectedPosition, this.packages, this.selectedScanCode\)/)
assert.match(page, /pickOutboundTask\(this.current.taskNo, \{ \.\.\.payload, idempotencyKey: key.value \}\)/)
assert.match(page, /pickOutboundPallet\(this.current.taskNo, code, \{ idempotencyKey: key.value \}\)/)
assert.match(page, /item\.palletNo === code/)
})
test('return outbound preserves its task type and only permits pallet scans for pallet items', async () => {
const task = normalizeOutboundTask({ task: { taskNo: 'PDA-1', outboundType: 'RETURN_OUTBOUND' }, items: [] })
assert.equal(task.outboundType, 'RETURN_OUTBOUND')
const page = await read('../src/pages/outbound/outbound.vue')
assert.match(page, /this\.current\?\.outboundType === 'RETURN_OUTBOUND' && item\.palletNo/)
assert.match(page, /整托出库请扫描托盘号/)
const item = { outboundPalletItemId: '12', skuCode: 'SKU-A' }
const position = { inventoryPositionId: '', cartonMark: 'RC-1' }
assert.deepEqual(outboundPickPayload(item, position, 1, 'SKU-A'), {
outboundPalletItemId: '12', inventoryPositionId: null, packages: 1, cartonMarks: [], skuCode: 'SKU-A'
})
assert.deepEqual(outboundPickPayload(item, position, 1, 'RC-1'), {
outboundPalletItemId: '12', inventoryPositionId: null, packages: 1, cartonMarks: ['RC-1']
})
})
test('real return-carton DTO remains scannable without inventing a warehouse inventory position or pallet', () => {
const task = normalizeOutboundTask({ task: { taskNo: 'PDA-RETURN', outboundType: 'RETURN_OUTBOUND', status: 'PENDING' },
items: [{ outboundPalletItemId: '12', palletNo: null, packageCount: 1, pickedPackageCount: 0,
positions: [{ inventoryPositionId: null, palletNo: null, cartonMark: 'RC-REAL', inboundCode: 'RT-REAL', availablePackages: 1, pickedPackages: 0 }] }] })
const item = task.items[0], position = item.positions[0]
assert.equal(item.palletNo, '')
assert.equal(position.cartonMark, 'RC-REAL')
assert.equal(position.availablePackages, 1)
assert.deepEqual(outboundPickPayload(item, position, 1, 'RC-REAL'), {
outboundPalletItemId: '12', inventoryPositionId: null, packages: 1, cartonMarks: ['RC-REAL']
})
assert.equal(outboundPickPayload(item, { ...position, inventoryPositionId: '2099999999999999999' }, 1).inventoryPositionId, '2099999999999999999')
})
function storageMock() {
@@ -53,7 +142,7 @@ test('fulfillment API uses dedicated PDA routes and never sends customer or ware
test('fulfillment API calls the frozen list detail pick complete and depart routes with exact payloads', async () => {
const calls = []
session.apiBase = '/overseas-api'; session.token = 'overseas-token'
session.apiBase = '/api'; session.token = 'overseas-token'
globalThis.uni = {
request(options) {
calls.push({ method: options.method, url: options.url, data: options.data, authorization: options.header.Authorization })
@@ -70,17 +159,20 @@ test('fulfillment API calls the frozen list detail pick complete and depart rout
await listDispatchTasks('PICKING'); await getDispatchTask('DSP/1')
await pickDispatchItem('DSP/1', '11', { positionId: '22', packages: 1, productQuantity: 0, idempotencyKey: 'pick-key' })
await completeDispatchPicking('DSP/1', { idempotencyKey: 'complete-key' })
await departDispatch('DSP/1', { idempotencyKey: 'depart-key' })
await departDispatch('DSP/1', { recordVersion: '4', idempotencyKey: 'depart-key' })
await listOutboundTasks('PENDING'); await getOutboundTask('OUT/TASK/1')
await resolveOutboundTask('OUT/1')
await pickOutboundTask('OUT/TASK/1', { outboundPalletItemId: '33', inventoryPositionId: '44', packages: 1, idempotencyKey: 'out-pick-key' })
await pickOutboundPallet('OUT/TASK/1', 'DP/1', { idempotencyKey: 'out-pallet-key' })
await completeOutboundTask('OUT/TASK/1', { idempotencyKey: 'out-complete-key' })
assert.equal(calls.length, 10)
assert.equal(calls.length, 11)
assert.deepEqual(calls.find(call => call.url.endsWith('/depart')).data, { recordVersion: '4', idempotencyKey: 'depart-key' })
assert.ok(calls.some((call) => call.url.endsWith('/dispatches?status=PICKING')))
assert.ok(calls.some((call) => call.url.includes('/dispatches/DSP%2F1/items/11/pick')))
assert.ok(calls.some((call) => call.url.endsWith('/dispatches/DSP%2F1/depart')))
assert.ok(calls.some((call) => call.url.includes('/outbound-tasks/OUT%2FTASK%2F1/pick')))
assert.ok(calls.some((call) => call.url.includes('/outbound-tasks/OUT%2FTASK%2F1/pallets/DP%2F1/pick')))
assert.ok(calls.some((call) => call.url.endsWith('/outbound-tasks/resolve/OUT%2F1')))
assert.ok(calls.every((call) => call.authorization === 'Bearer overseas-token'))
assert.ok(calls.every((call) => !/[?&](?:customerId|warehouseId)=/.test(call.url)))
@@ -137,7 +229,7 @@ test('pages expose loading empty error offline and permission diagnostics with g
assert.match(page, /正在加载/)
assert.match(page, /暂无/)
assert.match(page, /requestId[\s\S]*traceId/)
assert.match(page, /error\.network[\s\S]*幂等键/)
assert.match(page, /error\.network[\s\S]*保留/)
assert.match(page, /fulfillmentIdempotencyKey/)
assert.match(page, /releaseFulfillmentKey/)
assert.match(page, /if \(!this\.can/)
@@ -155,7 +247,8 @@ test('pages expose loading empty error offline and permission diagnostics with g
assert.doesNotMatch(dispatch, /departedNo/)
assert.match(outbound, /completeOutboundTask/)
assert.match(outbound, /resolveOutboundTask[\s\S]*workflow === 'DISPATCH'[\s\S]*pages\/dispatch\/dispatch/)
assert.match(outbound, /列表仅显示普通\/一件代发出库任务/)
assert.match(outbound, /整托扫描托盘码,散箱扫描箱唛/)
assert.match(outbound, /退货整托出库扫托盘码,散箱出库扫箱唛/)
assert.match(outbound, /if \(!this\.current\) return this\.openScannedTask\(code\)/)
assert.doesNotMatch(outbound, /if \(!this\.current\)[^\n]*openTask\(code\)/)
assert.match(outbound, /this\.remainingPackages === 0/)
@@ -165,6 +258,20 @@ test('pages expose loading empty error offline and permission diagnostics with g
assert.doesNotMatch(dispatch + outbound, /DeliveryOrder|deliveryStatus|customerVisit|\/deliveries/)
})
test('fulfillment workbench entries open task lists before details and keep explicit cross-workflow resume', async () => {
const [dispatch, outbound] = await Promise.all([read('../src/pages/dispatch/dispatch.vue'), read('../src/pages/outbound/outbound.vue')])
assert.match(dispatch, /resumeOnEntry: false/)
assert.match(dispatch, /onLoad\(options\) \{ this\.resumeOnEntry = options\?\.resume === '1' \}/)
assert.match(dispatch, /if \(this\.resumeOnEntry && fulfillment\.dispatch\.currentNo\) this\.openTask/)
assert.match(outbound, /if \(!this\.bootstrapped\) \{ this\.bootstrapped = true; this\.scanCode = ''; this\.loadTasks\(\); return \}/)
assert.match(outbound, /pages\/dispatch\/dispatch\?resume=1/)
for (const page of [dispatch, outbound]) {
assert.match(page, /detail-toolbar/)
assert.match(page, /上次作业/)
assert.match(page, /返回列表/)
}
})
test('dispatch vehicle-plate conflict uses actionable Chinese copy and preserves diagnostics', () => {
const error = toDispatchError({
code: 'A0409',
@@ -191,3 +298,51 @@ test('receiving conflict recovery keeps request and trace evidence visible on na
assert.match(operation, /openContainer\(candidate\)[\s\S]*recoverCurrentAfterConflict\(error, true\)/)
assert.match(operation, /\.error-card text\{overflow-wrap:anywhere;word-break:break-word\}/)
})
test('dispatch review gate preserves the server decision without treating a string or missing value as approval', async () => {
assert.equal(normalizeDispatchTask({ canPick: true }).canPick, true)
for (const canPick of [false, undefined, 'true']) assert.equal(normalizeDispatchTask({ canPick }).canPick, false)
assert.equal(normalizeDispatchTask({ canPick: false, pickBlockReason: '调度审核已打回:预约待核对' }).pickBlockReason, '调度审核已打回:预约待核对')
const source = await read('../src/pages/dispatch/dispatch.vue')
assert.match(source, /canPick\(\) \{ return this.current\?\.canPick === true/)
assert.match(source, /canComplete\(\) \{ return this.current\?\.canPick === true/)
})
async function dispatchScanHarness(items) {
const source = (await read('../src/pages/dispatch/dispatch.vue')).match(/<script>([\s\S]*?)<\/script>/)[1]
const draft = { palletCode: 'OLD', dispatchItemId: 'old', cartonCode: '' }; let sounds = 0
const deps = { PortalHeader: {}, ScanTargetInput: {}, DispatchHandoverStatus: {},
updateFulfillmentDraft: (_, value) => Object.assign(draft, value), playSuccessSound: () => { sounds++ },
console: { info() {} } }
const definition = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
const state = { current: { dispatchNo: 'DS-TEST', items }, error: { message: 'previous error' }, successMessage: '',
setError(error) { this.error = { message: error.message } } }
state.selectPallet = definition.methods.selectPallet.bind(state)
return { state, draft, scan: definition.methods.handleScan.bind(state), sounds: () => sounds }
}
test('dispatch wrong scan clears prior selection and a valid pallet clears the old error', async () => {
const h = await dispatchScanHarness([{ palletNo: 'PALLET-A', dispatchItemId: 'a', scheduledPackages: 1, pickedPackages: 0, positions: [{ availablePackages: 1 }] }])
h.scan('UNKNOWN'); assert.equal(h.draft.dispatchItemId, ''); assert.ok(h.state.error)
h.scan('PALLET-A'); assert.equal(h.state.error, null); assert.equal(h.draft.dispatchItemId, 'a'); assert.equal(h.sounds(), 1)
})
test('dispatch cannot use a shared destination code to choose a physical pallet', async () => {
const h = await dispatchScanHarness(['A', 'B'].map(x => ({ palletNo: `PALLET-${x}`, fbaWarehouseCode: 'ONT8', dispatchItemId: x, scheduledPackages: 1, pickedPackages: 0 })))
h.scan('ONT8'); assert.equal(h.draft.dispatchItemId, ''); assert.ok(h.state.error); assert.equal(h.sounds(), 0)
})
test('dispatch completed or ambiguous pallet scan never silently selects a row', async () => {
const done = await dispatchScanHarness([{ palletNo: 'PALLET-A', dispatchItemId: 'a', scheduledPackages: 1, pickedPackages: 1 }])
done.scan('PALLET-A'); assert.match(done.state.error.message, /已完成拣货/); assert.equal(done.draft.dispatchItemId, ''); assert.equal(done.sounds(), 0)
const shared = await dispatchScanHarness(['A','B'].map(x => ({ palletNo: 'PALLET-SHARED', dispatchItemId: x, scheduledPackages: 1, pickedPackages: 0 })))
shared.scan('PALLET-SHARED'); assert.match(shared.state.error.message, /多条待拣明细/); assert.equal(shared.draft.dispatchItemId, ''); assert.equal(shared.sounds(), 0)
})
test('single SKU piece plan is preserved for packed carton and loose SKU scans', () => {
const item={outboundPalletItemId:'12',returnSkuQuantity:'2'};
const pos={cartonMark:'RC-1',inventoryPositionId:null};
assert.deepEqual(outboundPickPayload(item,pos,1,'RC-1'),{outboundPalletItemId:'12',inventoryPositionId:null,packages:1,productQuantity:2,cartonMarks:['RC-1']});
assert.deepEqual(outboundPickPayload({...item,skuCode:'SKU-A'},pos,1,'SKU-A'),{outboundPalletItemId:'12',inventoryPositionId:null,packages:1,productQuantity:2,cartonMarks:[],skuCode:'SKU-A'});
assert.equal('productQuantity' in outboundPickPayload({outboundPalletItemId:'12'},pos,1),false);
});
+86
View File
@@ -0,0 +1,86 @@
import test from 'node:test'
import { runInNewContext } from 'node:vm'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const read = path => readFile(new URL(path, import.meta.url), 'utf8')
test('H5 image copies only compiled assets and static nginx configuration', async () => {
const dockerfile = await read('../deploy/Dockerfile.h5')
const copies = dockerfile.split(/\r?\n/).filter(line => line.startsWith('COPY '))
assert.deepEqual(copies, ['COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf', 'COPY dist/build/h5/ /srv/pda-h5/'])
assert.match(dockerfile, /ARG NGINX_BASE_IMAGE=/)
assert.match(dockerfile, /FROM \$\{NGINX_BASE_IMAGE\}/)
assert.doesNotMatch(dockerfile, /RUN |npm|node_modules|COPY \. /)
const ignore = await read('../.dockerignore')
assert.ok(ignore.split(/\r?\n/).includes('**'))
assert.ok(ignore.includes('!dist/build/h5/**'))
assert.doesNotMatch(ignore, /!\.env|!node_modules|!src/)
})
test('static server serves the domain root and returns genuine missing-resource errors', async () => {
const nginx = await read('../deploy/nginx.conf')
assert.match(nginx, /listen 80;/)
assert.ok(nginx.includes('location /'))
assert.match(nginx, /root \/srv\/pda-h5;/)
assert.match(nginx, /try_files \$uri \$uri\/ =404;/)
assert.match(nginx, /Cache-Control "no-store" always/)
assert.doesNotMatch(nginx, /pda-domestic|pda-overseas/)
assert.match(nginx, /location = \/healthz/)
assert.doesNotMatch(nginx, /proxy_pass|gateway-api|try_files[^;]*index\.html/)
})
test('TEST H5 deployment has a required image tag and internal-only dual health checks', async () => {
const compose = await read('../deploy/docker-compose.h5.test.yml')
assert.ok(compose.includes('${IMAGE_TAG:?IMAGE_TAG is required}'))
assert.match(compose, /context: \.\./)
assert.match(compose, /dockerfile: deploy\/Dockerfile\.h5/)
assert.match(compose, /container_name: pda-overseas-h5-test/)
assert.match(compose, /aliases:\s*\n\s*- pda-overseas-h5-test/)
assert.match(compose, /external: true/)
assert.match(compose, /name: firstleg-test-network/)
assert.match(compose, /expose:\s*\n\s*- "80"/)
assert.ok(compose.includes('http://127.0.0.1/healthz && wget -qO /dev/null http://127.0.0.1/'))
assert.doesNotMatch(compose, /\bports:|volumes:|API_UPSTREAM|privileged:|pid: host/)
})
test('only H5 production overrides the toolchain asset base', async () => {
const source = (await read('../vite.config.js'))
.replace(/^import .*$/gm, '')
.replace('export default ', '')
for (const platform of ['h5', 'app', 'app-plus', 'app-harmony', undefined]) {
for (const mode of ['production', 'development']) {
const factory = runInNewContext(source, {
defineConfig: value => value,
loadEnv: () => ({}),
uni: () => ({}),
process: { env: { UNI_PLATFORM: platform }, cwd: () => '/project' }
})
assert.equal(factory({ mode }).base,
platform === 'h5' && mode === 'production' ? '/' : undefined,
`${platform}:${mode}`)
}
}
})
test('H5 proxy uses the shared gateway prefix and preserves API suffixes', async () => {
const source = (await read('../vite.config.js')).replace(/^import .*$/gm, '').replace('export default ', '')
const factory = runInNewContext(source, {
defineConfig: value => value, loadEnv: () => ({ API_PROXY_TARGET: 'https://gateway.example.com' }),
uni: () => ({}), process: { env: { UNI_PLATFORM: 'h5' }, cwd: () => '/project' }
})
const proxy = factory({ mode: 'development' }).server.proxy
assert.deepEqual(Object.keys(proxy), ['/gateway-api'])
assert.equal(proxy['/gateway-api'].target, 'https://gateway.example.com')
for (const suffix of ['/auth/login', '/pda/v1/last-leg/photos/1/download?preview=1']) {
assert.equal(proxy['/gateway-api'].rewrite(`/gateway-api${suffix}`), `/api${suffix}`)
}
})
test('gray H5 runs on an isolated network with same-origin Gateway API', async () => {
const compose = await read('../deploy/docker-compose.h5.production.yml')
const nginx = await read('../deploy/nginx.production.conf.template')
const docker = await read('../deploy/Dockerfile.h5.production')
assert.match(compose, /name: firstleg-production-network/)
assert.match(compose, /127\.0\.0\.1:/)
assert.doesNotMatch(compose, /test-network|gateway-overseas/)
assert.match(nginx, /location \/gateway-api\//)
assert.ok(nginx.includes('proxy_pass ${API_UPSTREAM}/api/;'))
assert.match(docker, /COPY deploy\/nginx.production.conf.template \/etc\/nginx\/templates\/default.conf.template/)
})
+2 -2
View File
@@ -116,7 +116,7 @@ test('container UI locks and signs once, then renders the resolved destination g
assert.match(inboundApi, /\$\{RECEIVING_BASE\}\/\$\{encodeURIComponent\(containerNo\)\}\/prepare/)
assert.match(inboundApi, /current\('\/sign'\)/)
assert.match(operation, /prepareReceivingContainer\(this\.selected\.containerNo, \{ idempotencyKey: key\.value \}\)/)
assert.match(operation, /prepareReceivingContainer\(this\.selected\.containerNo, this\.selected\.sourceContainerBookingId, \{ idempotencyKey: key\.value \}\)/)
assert.match(operation, /getCurrentReceiving\(\)[\s\S]*applyCurrentReceiving\(current\)/)
assert.match(operation, /applyWarehouseProfile\(await getCurrentWarehouseProfile\(\)\)/)
assert.match(operation, /values\.map\(\(item\) => this\.validateReceivingWarehouse/)
@@ -139,7 +139,7 @@ test('PDA does not infer container eligibility from partial plan workflow states
assert.match(adapter, /allPlansReady: value\.allPlansReady === true/)
assert.match(adapter, /operable: value\.operable === true/)
assert.match(operation, /if \(!candidate\.operable\) \{ receiving\.selected = candidate; this\.detailOpen = true; return \}/)
assert.match(operation, /if \(!this\.selected\?\.containerNo \|\| !this\.selected\?\.allPlansReady \|\| !this\.selected\?\.operable/)
assert.match(operation, /if \(!this\.selected\?\.containerNo \|\| !this\.selected\?\.sourceContainerBookingId \|\| !this\.selected\?\.allPlansReady \|\| !this\.selected\?\.operable/)
assert.match(operation, /recoverCurrentAfterConflict\(error, true\)/)
assert.match(operation, /preserveEvidence \? error : null[\s\S]*this\.candidateError = preserveEvidence \? error : null/)
assert.doesNotMatch(inbound, /sort\(/)
+131
View File
@@ -0,0 +1,131 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
const source = readFileSync(new URL('../src/pages/outbound/outbound.vue', import.meta.url), 'utf8').match(/<script>([\s\S]*?)<\/script>/)[1]
const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done }); return { promise, resolve } }
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() {},
playErrorSound() {}, playSuccessSound() {}, pickOutboundPallet: async () => calls.push('pick'), completeOutboundTask: async () => calls.push('complete'),
listCargoDiscardPhotos: async () => [], getOutboundTask: async () => ({ taskNo: 'TASK', status: 'COMPLETED', items: [] }), normalizeCargoDiscardTask: value => value,
...overrides }
const definition = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
const vm = definition.data()
for (const [name, fn] of Object.entries(definition.methods)) vm[name] = fn.bind(vm)
for (const [name, fn] of Object.entries(definition.computed)) Object.defineProperty(vm, name, { get: () => fn.call(vm) })
return { vm, calls, definition }
}
test('discard requires restored photos and retains task while native camera returns', async () => {
const { vm, definition } = page({ requireSession: () => true, restoreFulfillmentDraft() {} })
vm.discardCurrent = { discardNo: 'CD-1' }; vm.discardTask = { palletNo: 'PALLET', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 3 }] }; vm.discardActuals = { sku: 1 }
assert.equal(vm.canConfirmDiscard, false)
vm.discardPhotoReady = true; assert.equal(vm.canConfirmDiscard, false)
vm.discardPhotos = [{ fileId: '101' }]; assert.equal(vm.canConfirmDiscard, true)
vm.discardPhotoBusy = true; assert.equal(vm.canConfirmDiscard, false)
let loads = 0; vm.startScanner = () => {}; vm.loadTasks = () => { loads++ }
definition.onShow.call(vm)
assert.equal(loads, 0); assert.equal(vm.discardTask.palletNo, 'PALLET')
})
test('discard list failure blocks confirmation and stale downloads cannot replace a new pallet', async () => {
const pending = deferred()
const { vm } = page({ listCargoDiscardPhotos: async () => { throw new Error('offline photos') }, downloadCargoDiscardPhoto: () => pending.promise })
vm.discardCurrent = { discardNo: 'CD-1' }; vm.discardTask = { palletNo: 'A', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }; vm.discardActuals = { sku: 1 }
vm.discardPhotos = [{ fileId: '101' }]; vm.discardPhotoReady = true
await vm.restoreDiscardPhotos(); assert.equal(vm.canConfirmDiscard, false)
const first = vm.loadDiscardPhoto('101'); vm.discardTask = { palletNo: 'B' }; vm.resetDiscardPhotos()
pending.resolve('old-photo-url'); await first; assert.deepEqual(vm.discardPhotoUrls, {})
})
test('return pallet rows sum carton counts without changing raw pick lines', () => {
const { vm } = page()
const cartons = Array.from({ length: 5 }, (_, index) => ({ outboundPalletItemId: index + 1, palletNo: 'PALLET', plannedPackages: 1, pickedPackages: index < 3 ? 1 : 0, positions: [] }))
const loose = { outboundPalletItemId: 6, palletNo: '', plannedPackages: 2, pickedPackages: 0, positions: [] }
vm.current = { outboundType: 'RETURN_OUTBOUND', items: [...cartons, loose, { outboundPalletItemId: 7, palletNo: 'OTHER', plannedPackages: 2, pickedPackages: 1, positions: [] }] }
const before = JSON.stringify(vm.current.items)
assert.equal(vm.displayItems.length, 3)
assert.equal(vm.displayItems[0].plannedPackages, 5)
assert.equal(vm.displayItems[0].pickedPackages, 3)
assert.equal(vm.displayItems[1], loose)
assert.equal(vm.displayItems[2].palletNo, 'OTHER')
assert.equal(JSON.stringify(vm.current.items), before)
assert.match(vm.scanLabel, /扫描托盘号/)
assert.match(vm.scanPlaceholder, /扫描托盘号/)
cartons.forEach(item => { item.pickedPackages = 1 })
assert.equal(vm.displayItems[0].pickedPackages, 5)
})
test('other outbound types preserve individual selectable lines', () => {
const { vm } = page()
const items = [{ palletNo: 'PALLET', plannedPackages: 1, pickedPackages: 0, positions: [] }, { palletNo: 'PALLET', plannedPackages: 2, pickedPackages: 0, positions: [] }]
vm.current = { outboundType: 'NORMAL', items }
assert.equal(vm.displayItems, items)
assert.equal(vm.hasReturnPallet, false)
})
test('completed and cancelled tasks cannot submit pallet picking or task completion', async () => {
for (const status of ['COMPLETED', 'CANCELLED']) {
const { vm, calls } = page()
vm.current = { taskNo: 'TASK', status, items: [{ palletNo: 'PALLET', plannedPackages: 1, pickedPackages: 1, positions: [] }] }
await vm.handleScan('PALLET'); await vm.submitPalletPick('PALLET'); await vm.completeTask()
assert.deepEqual(calls, [])
}
})
test('pending and in-progress tasks still submit exactly once while busy', async () => {
for (const status of ['PENDING', 'IN_PROGRESS']) {
const pending = deferred(); let submits = 0
const { vm } = page({ pickOutboundPallet: async () => { submits++; await pending.promise } })
vm.current = { taskNo: 'TASK', status, items: [{ palletNo: 'PALLET', plannedPackages: 1, pickedPackages: 0, positions: [] }] }
const first = vm.handleScan('PALLET'); const second = vm.handleScan('PALLET')
pending.resolve(); await Promise.all([first, second]); assert.equal(submits, 1)
}
})
test('failed new pallet lookup clears the previous selectable pallet and preserves retry code', async () => {
const { vm } = page({ resolveCargoDiscardPallet: async () => { throw new Error('offline') } })
vm.discardCurrent = { discardNo: 'DISCARD' }; vm.discardTask = { palletNo: 'OLD', status: 'PENDING', skus: [] }
await vm.resolveDiscardPallet('NEW')
assert.equal(vm.discardTask, null); assert.equal(vm.canConfirmDiscard, false)
assert.equal(vm.discardPalletCode, 'NEW'); assert.equal(vm.error.message, 'offline'); assert.equal(vm.discardLoading, false)
})
test('discard pallet scans do not overlap and cannot leave the previous pallet ready to submit', async () => {
const pending = deferred(); let resolves = 0
const { vm } = page({ resolveCargoDiscardPallet: async () => { resolves++; return pending.promise } })
vm.discardCurrent = { discardNo: 'DISCARD' }; vm.discardTask = { palletNo: 'OLD', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }; vm.discardActuals = { sku: 1 }
const first = vm.resolveDiscardPallet('NEW')
const second = vm.resolveDiscardPallet('SECOND')
const canConfirmDuringLoad = vm.canConfirmDiscard
pending.resolve({ discardNo: 'DISCARD', palletNo: 'NEW', status: 'PENDING', skus: [] }); await Promise.all([first, second])
assert.equal(resolves, 1); assert.equal(canConfirmDuringLoad, false)
assert.equal(vm.discardTask.palletNo, 'NEW')
})
test('discard confirmation is guarded against double submission', async () => {
const pending = deferred(); let submits = 0
const { vm } = page({ confirmCargoDiscard: async () => { submits++; await pending.promise }, listCargoDiscardTasks: async () => [] })
vm.discardCurrent = { discardNo: 'DISCARD' }; vm.discardTask = { palletNo: 'PALLET', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }; vm.discardActuals = { sku: 1 }
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]; const first = vm.confirmDiscard(); const second = vm.confirmDiscard()
pending.resolve(); await Promise.all([first, second]); assert.equal(submits, 1)
})
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'
vm.resolveDiscardPallet = async code => calls.push(['discard', code]); vm.openTask = async code => calls.push(['outbound', code])
await vm.retry(); assert.deepEqual(calls, [['discard', 'PALLET']])
})
test('closing or switching discard order ignores the previous resolve response', async () => {
const pending = deferred()
const { vm } = page({ resolveCargoDiscardPallet: () => pending.promise })
vm.discardCurrent = { discardNo: 'FIRST' }
const first = vm.resolveDiscardPallet('PALLET')
vm.openDiscardOrder('SECOND')
pending.resolve({ discardNo: 'FIRST', palletNo: 'PALLET', skus: [] }); await first
assert.equal(vm.discardTask, null); assert.equal(vm.error, null)
})
+224 -14
View File
@@ -15,7 +15,6 @@ function tokens(version = 'old') {
const base = { user_id: 42, membership_id: 84, tenant_id: 0, app_code: 'PLATFORM', overseas_warehouse_id: 900, exp: future(), version }
return {
accessToken: jwt({ ...base, username: 'picker-42' }),
overseasAccessToken: jwt(base),
refreshToken: `refresh-${version}`,
roles: [PDA_ROLE], authorities: [PDA_AUTHORITY]
}
@@ -23,7 +22,8 @@ function tokens(version = 'old') {
function prepareSession() {
globalThis.uni = {
setStorageSync() {}, getStorageSync() {}, removeStorageSync() {}
setStorageSync() {}, getStorageSync() {}, removeStorageSync() {},
launches: [], reLaunch(options) { this.launches.push(options.url) }
}
resetOverseasRequestStateForTest(); signOut()
const value = tokens('old')
@@ -43,7 +43,7 @@ test('H5 proxy and App API roots normalize request upload and download paths to
globalThis.uni.uploadFile = (options) => { uploads.push(options.url); options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: { fileId: '1' } }), header: {} }) }
globalThis.uni.downloadFile = (options) => { downloads.push(options.url); options.success({ statusCode: 200, tempFilePath: '/tmp/photo.jpg', header: {} }) }
session.apiBase = '/overseas-api'
session.apiBase = '/gateway-api'
await resolvePallet('PLT-1')
await overseasUpload('/api/pda/v1/last-leg/photos/files', '/tmp/photo.jpg')
await overseasDownload('/api/pda/v1/last-leg/photos/files/1/download')
@@ -54,15 +54,15 @@ test('H5 proxy and App API roots normalize request upload and download paths to
await overseasDownload('/api/pda/v1/last-leg/photos/files/1/download')
assert.deepEqual(requests, [
'/overseas-api/pda/v1/last-leg/pallet-operations/pallets/resolve',
'/gateway-api/pda/v1/last-leg/pallet-operations/pallets/resolve',
'https://overseas.example.com/api/pda/v1/last-leg/pallet-operations/pallets/resolve'
])
assert.deepEqual(uploads, [
'/overseas-api/pda/v1/last-leg/photos/files',
'/gateway-api/pda/v1/last-leg/photos/files',
'https://overseas.example.com/api/pda/v1/last-leg/photos/files'
])
assert.deepEqual(downloads, [
'/overseas-api/pda/v1/last-leg/photos/files/1/download',
'/gateway-api/pda/v1/last-leg/photos/files/1/download',
'https://overseas.example.com/api/pda/v1/last-leg/photos/files/1/download'
])
for (const url of [...requests, ...uploads, ...downloads]) assert.doesNotMatch(url, /\/api\/api\//)
@@ -76,11 +76,11 @@ test('concurrent old-token 401 responses share one refresh and each replay once
refreshCount += 1
return setTimeout(() => options.success(ok(next)), 10)
}
if (options.header.Authorization === `Bearer ${old.overseasAccessToken}`) {
if (options.header.Authorization === `Bearer ${old.accessToken}`) {
oldCalls += 1
return setTimeout(() => options.success(denied()), oldCalls === 1 ? 0 : 30)
}
assert.equal(options.header.Authorization, `Bearer ${next.overseasAccessToken}`)
assert.equal(options.header.Authorization, `Bearer ${next.accessToken}`)
newCalls += 1
setTimeout(() => options.success(ok({ call: newCalls })), 0)
}
@@ -92,8 +92,7 @@ test('concurrent old-token 401 responses share one refresh and each replay once
assert.equal(refreshCount, 1)
assert.equal(oldCalls, 2)
assert.equal(newCalls, 2)
assert.equal(session.token, next.overseasAccessToken)
assert.equal(session.domesticToken, next.accessToken)
assert.equal(session.token, next.accessToken)
})
test('request retries only once and preserves the final request and trace identifiers', async () => {
@@ -116,6 +115,8 @@ test('request retries only once and preserves the final request and trace identi
})
assert.equal(refreshCount, 1)
assert.equal(businessCalls, 2)
assert.equal(session.token, '')
assert.deepEqual(uni.launches, ['/pages/login/login'])
})
test('refresh rejection clears every local token and returns the auth trace', async () => {
@@ -134,10 +135,90 @@ test('refresh rejection clears every local token and returns the auth trace', as
})
assert.equal(session.currentUser, null)
assert.equal(session.token, '')
assert.equal(session.domesticToken, '')
assert.equal(session.refreshToken, '')
assert.deepEqual(uni.launches, ['/pages/login/login'])
})
test('concurrent refresh rejection clears the session and navigates to login only once', async () => {
prepareSession()
let refreshCount = 0
uni.request = (options) => {
if (options.url.endsWith('/auth/refresh')) {
refreshCount += 1
return setTimeout(() => options.success(denied()), 5)
}
options.success(denied())
}
const results = await Promise.allSettled([overseasRequest('GET', '/one'), overseasRequest('GET', '/two')])
assert.ok(results.every(result => result.status === 'rejected'))
assert.equal(refreshCount, 1)
assert.equal(session.currentUser, null)
assert.deepEqual(uni.launches, ['/pages/login/login'])
})
for (const refreshSucceeds of [true, false]) {
test(`late refresh ${refreshSucceeds ? 'success' : 'failure'} cannot restore logout or clear a new login`, async () => {
for (const relogin of [false, true]) {
prepareSession()
let completeRefresh
let started
const refreshStarted = new Promise(resolve => { started = resolve })
let businessCalls = 0
uni.request = (options) => {
if (options.url.endsWith('/auth/refresh')) {
completeRefresh = () => options.success(refreshSucceeds ? ok(tokens('late')) : denied())
started()
return
}
businessCalls += 1
options.success(denied())
}
const pending = overseasRequest('POST', '/write')
await refreshStarted
signOut()
const replacement = tokens('new-login')
if (relogin) signInWithTokens(replacement)
completeRefresh()
await assert.rejects(pending)
assert.equal(session.token, relogin ? replacement.accessToken : '')
assert.equal(businessCalls, 1)
assert.deepEqual(uni.launches, [])
}
})
}
test('an old business response cannot replay with a newly logged-in session', async () => {
prepareSession()
let complete
let calls = 0
uni.request = options => { calls += 1; complete = () => options.success(denied()) }
const pending = overseasRequest('POST', '/write')
signOut()
const replacement = tokens('another-login')
signInWithTokens(replacement)
complete()
await assert.rejects(pending, /会话已变更/)
assert.equal(calls, 1)
assert.equal(session.token, replacement.accessToken)
assert.deepEqual(uni.launches, [])
})
for (const kind of ['upload', 'download']) {
test(`${kind} final unauthorized response navigates once and stops after one replay`, async () => {
prepareSession()
let refreshCount = 0
let calls = 0
uni.request = options => { refreshCount += 1; options.success(ok(tokens(kind))) }
uni.uploadFile = options => { calls += 1; options.success({ ...denied(), data: JSON.stringify(denied().data) }) }
uni.downloadFile = options => { calls += 1; options.success(denied()) }
await assert.rejects(kind === 'upload' ? overseasUpload('/photo', '/tmp/photo.jpg') : overseasDownload('/photo'))
assert.equal(refreshCount, 1)
assert.equal(calls, 2)
assert.equal(session.currentUser, null)
assert.deepEqual(uni.launches, ['/pages/login/login'])
})
}
test('photo upload refreshes and replays once while retaining the file payload', async () => {
const old = prepareSession(); const next = tokens('upload')
let refreshCount = 0; const uploads = []
@@ -148,7 +229,7 @@ test('photo upload refreshes and replays once while retaining the file payload',
}
globalThis.uni.uploadFile = (options) => {
uploads.push({ filePath: options.filePath, name: options.name, authorization: options.header.Authorization })
if (options.header.Authorization === `Bearer ${old.overseasAccessToken}`) {
if (options.header.Authorization === `Bearer ${old.accessToken}`) {
return options.success({ statusCode: 401, data: JSON.stringify({ code: 'A0401', requestId: 'upload-old', traceId: 'upload-old-trace' }), header: {} })
}
options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: { fileId: '99' } }), header: {} })
@@ -162,6 +243,106 @@ test('photo upload refreshes and replays once while retaining the file payload',
])
})
test('H5 blob photo uses authenticated multipart fetch instead of uni.uploadFile', async () => {
const value = prepareSession()
const originalWindow = globalThis.window
const originalLocation = globalThis.location
const originalFetch = globalThis.fetch
let uploadFileCalls = 0
let uploaded
globalThis.window = {}
globalThis.location = { protocol: 'http:' }
globalThis.uni.uploadFile = () => { uploadFileCalls += 1 }
globalThis.fetch = async (url, options) => {
if (String(url).startsWith('blob:')) return new Response(new Blob(['photo'], { type: 'image/png' }))
uploaded = { url, options }
return new Response(JSON.stringify({ code: '200', data: { fileId: 'h5-1' } }), { status: 200 })
}
try {
session.apiBase = '/gateway-api'
assert.deepEqual(await overseasUpload('/api/pda/v1/last-leg/photos/files', 'blob:http://localhost/photo'), { fileId: 'h5-1' })
assert.equal(uploadFileCalls, 0)
assert.equal(uploaded.url, '/gateway-api/pda/v1/last-leg/photos/files')
assert.equal(uploaded.options.headers.Authorization, `Bearer ${value.accessToken}`)
assert.ok(uploaded.options.body instanceof FormData)
} finally {
globalThis.fetch = originalFetch
if (originalWindow === undefined) delete globalThis.window
else globalThis.window = originalWindow
if (originalLocation === undefined) delete globalThis.location
else globalThis.location = originalLocation
}
})
test('H5 File photo is appended directly without rereading a temporary blob URL', async () => {
const value = prepareSession()
const originalWindow = globalThis.window
const originalLocation = globalThis.location
globalThis.window = {}
globalThis.location = { protocol: 'http:' }
const selected = new Blob(['photo'], { type: 'image/png' })
let calls = 0
globalThis.uni.uploadFile = (options) => {
calls += 1
assert.equal(options.url, '/gateway-api/pda/photo')
assert.equal(options.header.Authorization, `Bearer ${value.accessToken}`)
assert.equal(options.files[0].file, selected)
assert.equal(options.files[0].name, 'file')
options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: { fileId: 'direct-file' } }), header: {} })
}
try {
session.apiBase = '/gateway-api'
assert.deepEqual(await overseasUpload('/pda/photo', selected), { fileId: 'direct-file' })
assert.equal(calls, 1)
} finally {
if (originalWindow === undefined) delete globalThis.window
else globalThis.window = originalWindow
if (originalLocation === undefined) delete globalThis.location
else globalThis.location = originalLocation
}
})
test('native file runtime rejects an H5-relative overseas root before upload with a clear configuration error', async () => {
prepareSession()
const originalLocation = globalThis.location
globalThis.location = { protocol: 'file:' }
session.apiBase = '/gateway-api'
let uploadCalls = 0
globalThis.uni.uploadFile = () => { uploadCalls += 1 }
try {
await assert.rejects(overseasUpload('/pda/photo', '_doc/photo.jpg'), (error) => {
assert.equal(error.code, 'PDA_API_BASE_REQUIRED')
assert.match(error.message, /VITE_API_BASE_URL/)
assert.match(error.message, /HTTP\(S\)/)
return true
})
assert.equal(uploadCalls, 0)
} finally {
if (originalLocation === undefined) delete globalThis.location
else globalThis.location = originalLocation
}
})
test('native file runtime uploads to an absolute overseas root', async () => {
prepareSession()
const originalLocation = globalThis.location
globalThis.location = { protocol: 'file:' }
session.apiBase = 'https://overseas.example.com/api/'
let call
globalThis.uni.uploadFile = (options) => {
call = options
options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: { fileId: 'native-1' } }), header: {} })
}
try {
assert.deepEqual(await overseasUpload('/api/pda/v1/last-leg/photos/files', '_doc/photo.jpg'), { fileId: 'native-1' })
assert.equal(call.url, 'https://overseas.example.com/api/pda/v1/last-leg/photos/files')
assert.equal(call.filePath, '_doc/photo.jpg')
} finally {
if (originalLocation === undefined) delete globalThis.location
else globalThis.location = originalLocation
}
})
test('authenticated photo download maps the public api path through the H5 overseas proxy', async () => {
const value = prepareSession(); const calls = []
globalThis.uni.downloadFile = (options) => {
@@ -170,8 +351,37 @@ test('authenticated photo download maps the public api path through the H5 overs
}
assert.equal(await overseasDownload('/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/99/download'), '/tmp/restored.jpg')
assert.equal(calls.length, 1)
assert.equal(calls[0].url, '/overseas-api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/99/download')
assert.equal(calls[0].header.Authorization, `Bearer ${value.overseasAccessToken}`)
assert.equal(calls[0].url, '/gateway-api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/99/download')
assert.equal(calls[0].header.Authorization, `Bearer ${value.accessToken}`)
})
test('native image download accepts a valid temp file when App-Plus omits response headers', async () => {
prepareSession()
globalThis.uni.downloadFile = (options) => options.success({
statusCode: 200,
tempFilePath: '_doc/uniapp_temp/photo.jpg',
header: {}
})
assert.equal(
await overseasDownload('/api/pda/v1/last-leg/photo', { expectedContentTypePrefix: 'image/' }),
'_doc/uniapp_temp/photo.jpg'
)
})
test('image download rejects a JSON error envelope returned with HTTP 200', async () => {
prepareSession()
globalThis.uni.downloadFile = (options) => options.success({
statusCode: 200,
tempFilePath: '/tmp/error.json',
header: { 'Content-Type': 'application/json', 'X-Request-Id': 'photo-request', 'X-Trace-Id': 'photo-trace' }
})
await assert.rejects(
overseasDownload('/api/pda/v1/last-leg/photo', { expectedContentTypePrefix: 'image/' }),
(error) => error.message === '照片服务返回了无效内容,请稍后重试'
&& error.requestId === 'photo-request' && error.traceId === 'photo-trace'
)
})
test('business and network errors retain diagnostics without refreshing or clearing a valid session', async () => {
+11 -10
View File
@@ -14,9 +14,8 @@ test('overseas PDA is an independent Vue 3 app with separate packaging identity'
assert.doesNotMatch(pages, /pages\/picking|pages\/tasks/)
assert.match(pkg, /"build:app"\s*:\s*"uni build -p app"/)
assert.match(vite, /API_PROXY_TARGET/)
assert.match(vite, /OVERSEAS_API_PROXY_TARGET/)
assert.match(vite, /\/overseas-api/)
assert.match(vite, /replace\(\/\^\\\/overseas-api\//)
assert.doesNotMatch(vite, /OVERSEAS_API_PROXY_TARGET|gateway-overseas/)
assert.match(vite, /\/api/)
assert.match(vite, /removeHeader\('origin'\)/)
assert.match(vite, /removeHeader\('referer'\)/)
assert.match(vite, /__VUE_I18N_FULL_INSTALL__:\s*true/)
@@ -35,7 +34,7 @@ test('overseas workbench contains confirmed warehouse and phase-five outbound do
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_OVERSEAS_API_BASE_URL/)
assert.match(runtime, /VITE_API_BASE_URL/)
})
test('login has no committed demo credentials and inbound operation implements the locked container flow', async () => {
@@ -49,10 +48,12 @@ test('login has no committed demo credentials and inbound operation implements t
assert.match(auth, /authRequest\('\/auth\/login', \{ loginId, password \}\)/)
assert.doesNotMatch(auth, /authRequest\('\/auth\/login', \{ username, password \}\)/)
assert.match(runtime, /runtimeEnvironment\.DEV|VITE_PDA_DEV_USERNAME|VITE_PDA_DEV_PASSWORD/)
assert.match(runtime, /'\/overseas-api'/)
assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_AUTH_API_BASE_URL/)
assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_OVERSEAS_API_BASE_URL/)
assert.match(runtime, /'\/gateway-api'/)
assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_API_BASE_URL/)
assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_API_BASE_URL/)
assert.match(operation, /startScanBroadcast/)
assert.match(operation, /palletWorkflowActive = Boolean\(this\.activeInbound \|\| this\.resolvedPallet\)/)
assert.match(operation, /queuedTarget: scan\.target/)
for (const action of ['prepareReceivingContainer', 'getCurrentReceiving', 'resolveCurrentCarton', 'resolveCurrentPallet', 'receiveCurrentBulk', 'receiveCurrentCarton', 'receiveCurrentWholePallet', 'completeCurrentReceiving']) assert.match(operation, new RegExp(action))
for (const path of ['/candidates', '/prepare', '/unload-photos/files', '/sign', '/cartons/resolve', '/pallets/resolve', '/receipts/bulk', '/receipts/carton', '/receipts/pallet', '/complete']) assert.match(inbound, new RegExp(path.replaceAll('/', '\\/')))
assert.match(inbound, /current\('\/pallets\/resolve'\)/)
@@ -66,7 +67,7 @@ test('login has no committed demo credentials and inbound operation implements t
assert.match(operation, /resolveCurrentCarton\(cartonMark\)[\s\S]*value\.operationMode !== 'DROPSHIP_PALLET'/)
assert.match(operation, /name="carton"[\s\S]*@submit="resolveCarton"/)
assert.match(operation, /sourcePalletNo: value\.sourcePalletNo \|\| cartonMark[\s\S]*receiveCurrentWholePallet/)
assert.match(operation, /clearReceiptContext\(\)[\s\S]*activeScanTarget = 'carton'[\s\S]*drainScanQueue/)
assert.match(operation, /clearReceiptContext\(\)[\s\S]*activeScanTarget = 'carton'[\s\S]*scheduleScanDrain/)
assert.match(operation, /name="pallet"[\s\S]*@activate="activeTarget = 'pallet'"/)
assert.match(operation, /receiving\.activeScanTarget = 'pallet'/, '普通FBA或中转解析后直接进入托盘扫码以减少操作')
assert.match(operation, /\[Overseas PDA scan\] dispatch/)
@@ -175,8 +176,8 @@ test('return packing is a separate same-customer inventory operation', async ()
])
assert.match(pages, /pages\/return-packing\/return-packing/)
assert.match(workbench, /key: 'returnPacking'[^\n]*available: true[^\n]*route: '\/pages\/return-packing\/return-packing'/)
for (const action of ['findPackingOrders', 'mergeReturnCartons']) assert.match(page, new RegExp(action))
for (const path of ['/packing/orders', '/cartons/merge', '/cartons/']) assert.match(api, new RegExp(path.replaceAll('/', '\\/')))
for (const action of ['findPackingOrders', 'packReturnSkus']) assert.match(page, new RegExp(action))
for (const path of ['/packing/orders', '/cartons/pack', '/cartons/']) assert.match(api, new RegExp(path.replaceAll('/', '\\/')))
assert.match(page, /startScanBroadcast/)
assert.match(page, /customerKey[\s\S]*不同客户的退货单不能合箱/)
assert.match(page, /idempotencyKey: key\.value/)
+4
View File
@@ -49,6 +49,10 @@ test('first scan and receipt requests confirm server reservations without resour
assert.ok(gate)
assert.doesNotMatch(gate, /mergeAllowed|assigned|urgent/)
assert.match(gate, /resolvedPallet\?\.compatible/)
assert.match(page, /const MAX_PACKAGES_PER_PALLET = 66/)
assert.match(page, /Number\(this\.packageCount\) > MAX_PACKAGES_PER_PALLET/)
assert.match(page, /一个托盘最多只能收66箱,请拆分到其他托盘/)
assert.match(page, /this\.showToast\(PALLET_CAPACITY_MESSAGE, 'error'\)/)
})
test('one-click dropship inbound is marked only by an explicit server boolean', () => {
+108
View File
@@ -0,0 +1,108 @@
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'
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 } }
const sessionValue = (patch = {}) => normalizeReceivingSession({ containerNo: 'CAB-A', preparedAt: '2026-10-02T10:00:00',
overseasWarehouseId: '900', status: 'SIGNED', inbounds: [{ inboundCode: 'IN-A', customerId: '100' }],
photos: ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN'].map((role, i) => ({ role, fileId: String(i + 1), downloadPath: `/signed/${i + 1}` })),
domesticLoadingPhotos: Array.from({ length: 5 }, (_, i) => ({ evidenceType: `PHOTO_${i}`, downloadPath: `/domestic/${i}` })), ...patch })
function page(overrides = {}) {
const deps = { ...store, session: { tenantId: '100', currentUser: { id: 'operator', warehouseId: '900' } },
PortalHeader: {}, ReceivingSessionSummary: {}, ScanTargetInput: {}, 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))
const vm = { ...definition.data(), _pageActive: true, _scanEpoch: 0 }
for (const [name, method] of Object.entries(definition.methods)) vm[name] = method.bind(vm)
for (const [name, descriptor] of Object.entries(definition.computed)) Object.defineProperty(vm, name,
typeof descriptor === 'function' ? { get: () => descriptor.call(vm) } : { get: () => descriptor.get.call(vm), set: value => descriptor.set.call(vm, value) })
vm.moduleKey = 'inbound'; vm.validateReceivingWarehouse = value => value; vm.showToast = () => {}
return { vm, definition, deps }
}
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}` }
const { vm } = page({ downloadCurrentUnloadPhoto: download, downloadCurrentDomesticLoadingPhoto: download })
await vm.applyCurrentReceiving(sessionValue({ status: 'WAIT_SIGN' })); assert.equal(calls.length, 8)
await vm.applyCurrentReceiving(sessionValue()); await vm.applyCurrentReceiving(sessionValue()); assert.equal(calls.length, 8)
await vm.toggleDomesticPhotos(); assert.equal(calls.length, 13)
await vm.restoreDomesticPhotoPreviews(); assert.equal(calls.length, 13)
// Domestic DTO has no file identity/version: a new response must not reuse an old image.
await vm.applyCurrentReceiving(sessionValue()); assert.equal(calls.length, 18)
})
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 () => '' })
await vm.applyCurrentReceiving(sessionValue()); assert.equal(calls, 3)
const changed = sessionValue(); changed.photos[0].fileId = 'NEW'; await vm.applyCurrentReceiving(changed); assert.equal(calls, 4)
deps.session.tenantId = '200'; await vm.applyCurrentReceiving(changed); assert.equal(calls, 7)
await vm.applyCurrentReceiving(sessionValue({ containerNo: 'CAB-B' })); assert.equal(calls, 10)
await vm.applyCurrentReceiving(sessionValue({ containerNo: 'CAB-B', inbounds: [{ inboundCode: 'IN-OTHER', customerId: '201' }] })); assert.equal(calls, 13)
vm.invalidatePhotoPreview(store.receiving.photoSlots[0]); await vm.restorePhotoPreviews(); assert.equal(calls, 14)
})
test('overlapping current refresh joins inflight photo download and failed fetch remains retryable', async () => {
store.resetReceivingSession(); const pending = defer(); let calls = 0
const { vm } = page({ downloadCurrentUnloadPhoto: async path => { calls++; await pending.promise; return `local:${path}` } })
const first = vm.applyCurrentReceiving(sessionValue()); await Promise.resolve()
const second = vm.applyCurrentReceiving(sessionValue()); await Promise.resolve(); assert.equal(calls, 3)
pending.resolve(); await Promise.all([first, second]); assert.equal(calls, 3)
const photo = { downloadPath: '/retry' }; let attempts = 0
const download = async () => { if (++attempts === 1) throw new Error('offline'); return 'local:retry' }
await assert.rejects(store.restoreReceivingPhoto(photo, download), /offline/)
assert.equal(photo.status, 'error'); assert.equal(await store.restoreReceivingPhoto(photo, download), 'local:retry'); assert.equal(attempts, 2)
})
test('hide and unload discard queued scans and cancel drain scheduled by an in-flight request; return refreshes facts', async () => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
const pending = defer(); const { vm, definition } = page(); let submissions = 0, refreshes = 0
vm.dispatchHardwareScan = () => { submissions++ }; vm.bootstrap = async () => { refreshes++ }
vm.scanQueue = [{ value: 'SECOND', target: 'carton' }]
const running = vm.run('already sent', async () => { submissions++; await pending.promise })
definition.onHide.call(vm); pending.resolve(); await running
await new Promise(resolve => setTimeout(resolve, 5)); assert.equal(submissions, 1); assert.equal(vm.scanQueue.length, 0)
definition.onShow.call(vm); assert.equal(refreshes, 1); assert.equal(submissions, 1)
vm.scanQueue.push({ value: 'THIRD' }); vm.scheduleScanDrain(); definition.onUnload.call(vm)
await new Promise(resolve => setTimeout(resolve, 5)); assert.equal(submissions, 1)
})
test('hide during cargo resolution prevents the subsequent automatic dropship write, including quick return', async () => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
const pending = defer(); let writes = 0
const { vm, definition } = page({ resolveCurrentCarton: () => pending.promise, receiveCurrentWholePallet: async () => { writes++ } })
vm.bootstrap = async () => {}
const running = vm.resolveCarton('DP-1'); definition.onHide.call(vm); definition.onShow.call(vm)
pending.resolve({ operationMode: 'DROPSHIP_PALLET', sourcePalletNo: 'DP-1' }); await running
assert.equal(writes, 0); assert.equal(vm.scanQueue.length, 0)
})
test('already sent receipt retains successful server fact after hide without draining another scan', async () => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
const pending = defer(); let writes = 0, reads = 0
const { vm, definition } = page({ resolveCurrentCarton: async () => ({ operationMode: 'DROPSHIP_PALLET', sourcePalletNo: 'DP-1' }),
receiveCurrentWholePallet: async () => { writes++; return pending.promise },
getCurrentReceiving: async () => { reads++; return sessionValue({ receivedPackages: 1 }) } })
const running = vm.resolveCarton('DP-1'); await Promise.resolve(); assert.equal(writes, 1)
vm.scanQueue.push({ value: 'DP-2', target: 'carton' }); definition.onHide.call(vm)
pending.resolve({ receivedPackages: 1 }); await running
assert.equal(writes, 1); assert.equal(reads, 1); assert.equal(store.receiving.current.receivedPackages, 1)
assert.equal(store.receiving.lastReceipt.receivedPackages, 1); assert.equal(vm.scanQueue.length, 0)
})
test('late old photo download never overwrites replacement or cross-account preview', async () => {
store.resetReceivingSession(); const first = defer(), calls = []
const { vm, deps } = page({ downloadCurrentUnloadPhoto: async path => { calls.push(path); if (calls.length <= 3) await first.promise; return `local:${path}` } })
const old = vm.applyCurrentReceiving(sessionValue()); await Promise.resolve()
deps.session.tenantId = 'new-customer'
const changed = sessionValue(); changed.photos[0] = { ...changed.photos[0], fileId: 'new', downloadPath: '/signed/new' }
await vm.applyCurrentReceiving(changed); assert.equal(calls.length, 6)
first.resolve(); await old
assert.equal(store.receiving.photoSlots[0].fileId, 'new'); assert.equal(store.receiving.photoSlots[0].localPath, 'local:/signed/new')
})
+3 -3
View File
@@ -68,12 +68,12 @@ test('camera replaces only one fixed slot and failed retake preserves its previo
assert.match(page, /\.photo-grid\{display:grid;grid-template-columns:1fr/)
assert.match(page, /grid-template-columns:82px minmax\(0,1fr\) 78px/)
assert.match(page, /class="photo-role-info"/)
assert.match(page, /onShow\(\)[\s\S]*this\.inboundMode && \(!this\.bootstrapped \|\| this\.phase === 'candidates'\)[\s\S]*this\.bootstrap\(\)/)
assert.match(page, /onShow\(\)[\s\S]*this\.inboundMode && \(!this\.bootstrapped \|\| this\.phase === 'candidates' \|\| this\._resumeReceiving\)[\s\S]*this\.bootstrap\(\)/)
assert.match(page, /await this\.applyCurrentReceiving\(current\);[\s\S]*this\.bootstrapped = true/)
})
test('local thumbnail, preview, and authenticated session restore are wired to the frozen contract', () => {
assert.match(page, /restorePhotoPreviews\(\)[\s\S]*photo\?\.downloadPath[\s\S]*downloadCurrentUnloadPhoto\(photo\.downloadPath\)/)
assert.match(page, /restorePhotoPreviews\(\)[\s\S]*photo\?\.downloadPath[\s\S]*restoreReceivingPhoto\(photo, downloadCurrentUnloadPhoto\)/)
assert.deepEqual(photoUiBlockers().map((item) => item.code), [])
assert.ok(photoUiBlockers().every((item) => item.deploymentBlocker === true && item.reason))
})
@@ -81,7 +81,7 @@ test('local thumbnail, preview, and authenticated session restore are wired to t
test('domestic cabinet photos are read-only, previewable and non-blocking in sign and receive phases', () => {
assert.match(page, /国内装柜照片(只读)/)
assert.match(page, /domesticPhotosExpanded/)
assert.match(page, /downloadCurrentDomesticLoadingPhoto\(photo\.downloadPath\)/)
assert.match(page, /restoreReceivingPhoto\(photo, downloadCurrentDomesticLoadingPhoto\)/)
assert.match(page, /async loadDomesticPhoto\(photo\)[\s\S]*catch \(_\) \{ photo\.status = 'error' \}/)
assert.match(page, /previewDomesticPhoto\(photo\)[\s\S]*uni\.previewImage/)
assert.match(inbound, /downloadCurrentDomesticLoadingPhoto/)
+59
View File
@@ -0,0 +1,59 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
const source = readFileSync(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
const body = source.split('<script>')[1].split('</script>')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
function setup(confirm) {
const calls = []
const uni = { showModal(options) { calls.push(options); options.success({ confirm }) }, redirectTo(options) { calls.push(options) } }
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'uni', body)({}, {}, {}, {}, uni)
const context = { busy: false, stage: 'receive', taskCode: 'PREVIOUS', error: null, candidates: [{ relabelNo: 'RL-ACTIVE', taskStatus: 'PROCESSING', claimedByCurrentOperator: true }] }
context.promptActiveTask = component.methods.promptActiveTask.bind(context)
return { calls, context, open: () => component.methods.openExecution.call(context, { relabelNo: 'RL-OTHER', taskStatus: 'AVAILABLE' }) }
}
test('conflicting task opens modal and confirm resumes owned task without claiming another', async () => {
const { calls, context, open } = setup(true)
await open()
assert.equal(calls[0].confirmText, '前往任务')
assert.equal(calls[0].cancelText, '取消')
assert.match(calls[0].content, /RL-ACTIVE/)
assert.equal(context.taskCode, 'RL-ACTIVE')
assert.equal(calls[1].url, '/pages/relabel/relabel?op=relabelExecute&task=RL-ACTIVE')
assert.equal(context.error, null)
assert.equal(context.busy, false)
})
test('cancel stays on list and preserves existing task selection', async () => {
const { calls, context, open } = setup(false)
await open()
assert.equal(calls.length, 1)
assert.equal(context.taskCode, 'PREVIOUS')
assert.equal(context.busy, false)
})
test('new relabel task asks whether to start before it can be claimed', async () => {
const calls = []
const uni = { showModal(options) { calls.push(options); options.success({ confirm: false }) } }
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'uni', body)({}, {}, {}, {}, uni)
const context = { busy: false }
const confirmed = await component.methods.confirmStartTask.call(context, { relabelNo: 'RL-NEW' })
assert.equal(confirmed, false)
assert.equal(calls[0].title, '开始贴标作业')
assert.match(calls[0].content, /是否开始贴标作业/)
assert.match(calls[0].content, /RL-NEW/)
assert.equal(calls[0].confirmText, '是')
assert.equal(calls[0].cancelText, '否')
assert.equal(context.busy, false)
})
test('start confirmation gates task selection and claim', () => {
const start = source.indexOf('const confirmed = await this.confirmStartTask(item)')
const selection = source.indexOf('this.taskCode = item.relabelNo', start)
const claim = source.indexOf("warehouseIdempotencyKey('relabel', 'claim', payload)", start)
assert.ok(start >= 0)
assert.ok(selection > start)
assert.ok(claim > selection)
assert.match(source.slice(start, selection), /if \(!confirmed\) return/)
})
+14
View File
@@ -0,0 +1,14 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const page = await readFile(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
test('PDA relabel completion explains pallet and photo blockers instead of silently disabling submit', () => {
assert.match(page, /还不能完成换标/)
assert.match(page, /请先在“换标后组托”中完成本轮已换标箱的拆托合托绑定/)
assert.match(page, /请至少上传 1 张换标完成照片/)
assert.match(page, /:disabled="busy \|\| Boolean\(completionBlockReason\)"/)
assert.match(page, /entry\.action === 'PALLET_BIND'/)
assert.match(page, /detail\.round === round/)
})
+13
View File
@@ -0,0 +1,13 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const page = readFileSync(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
test('PDA 箱唛换标明确展示每箱的新标签和 FBA 目的仓', () => {
assert.match(page, /item\.cartonMark[^\n]+item\.fbaCartonMark/)
assert.match(page, /class="destination-summary"[^\n]+FBA 目的仓:[^\n]+v-for="destination in fbaDestinations"/)
assert.match(page, /v-if="fbaDestinations.length > 1"[^\n]+item\.destinationFbaWarehouseCode/)
assert.match(page, /confirmedItem\?\.destinationFbaWarehouseCode \|\| item\.destinationFbaWarehouseCode/)
assert.match(page, /请贴 FBA 箱唛[^`]+FBA 目的仓:\$\{this\.lastCarton\.destinationFbaWarehouseCode/s)
})
+12
View File
@@ -0,0 +1,12 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { isRelabelFundsBlocked, relabelErrorMessage } from '../src/utils/relabel-error.js'
test('wallet block receives actionable Chinese guidance without version-conflict recovery', () => {
const error = { code: 'A0409', message: 'Relabel funds must be frozen before processing' }
assert.equal(isRelabelFundsBlocked(error), true)
assert.match(relabelErrorMessage(error), /后台检查客户余额或授信/)
assert.doesNotMatch(relabelErrorMessage(error), /刷新任务版本/)
assert.equal(isRelabelFundsBlocked({ code: 'A0409', message: 'Task version changed' }), false)
assert.equal(relabelErrorMessage({ message: 'Task version changed' }), 'Task version changed')
})
+35
View File
@@ -0,0 +1,35 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const source = await readFile(new URL('../src/components/RelabelPalletWork.vue', import.meta.url), 'utf8')
const script = source.split('<script>')[1].split('</script>')[0].replace(/^import .*$/gm, '').replace('export default', 'return')
function setup() {
const calls = []
const component = new Function('ScanTargetInput', 'measureRelabelPallet', 'warehouseIdempotencyKey', 'releaseWarehouseKey', script)(
{}, async payload => { calls.push(payload); return [{ palletCode: payload.palletCode, volumeCbm: 0.25, grossWeightKg: payload.grossWeightKg }] },
() => ({ value: 'stable-key', signature: 'signature' }), () => {})
const value = { ...component.data(), task: { relabelNo: 'RL-1' }, $emit() {} }
for (const [key, method] of Object.entries(component.methods)) value[key] = method.bind(value)
return { component, value, calls }
}
test('pallet measurement submits actual dimensions, weight and current stock version and refreshes results', async () => {
const { value, calls } = setup()
await value.saveMeasurement({ palletCode: 'NEW', stockVersion: 'position:version:count', lengthCm: '100', widthCm: '50', heightCm: '50', grossWeightKg: '40' })
assert.deepEqual(calls, [{ relabelNo: 'RL-1', palletCode: 'NEW', stockVersion: 'position:version:count', lengthCm: 100, widthCm: 50, heightCm: 50, grossWeightKg: 40, idempotencyKey: 'stable-key' }])
assert.equal(value.measurements[0].volumeCbm, 0.25)
assert.equal(value.busy, false)
await value.saveMeasurement({ palletCode: 'NEW', lengthCm: 0, widthCm: 50, heightCm: 50, grossWeightKg: 40 })
assert.equal(calls.length, 1)
assert.match(value.error, /有效/)
})
test('only whole physical pallet quantities offer original pallet reuse', () => {
const { component } = setup()
const canRetain = component.computed.canRetainOriginal
const ctx = { source: { totalPackages: 10 }, selectedSource: {}, plannedPackages: 10, isReturnBulk: false, unboundSourceMode: false }
assert.equal(canRetain.call(ctx), true)
ctx.plannedPackages = 4
assert.equal(canRetain.call(ctx), false)
ctx.plannedPackages = 10;ctx.isReturnBulk = true
assert.equal(canRetain.call(ctx), false)
})
+198
View File
@@ -0,0 +1,198 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const source = await readFile(new URL('../src/components/RelabelPalletWork.vue', import.meta.url), 'utf8')
const template = source.split('</template>')[0]
const script = source.split('<script>')[1].split('</script>')[0].replace(/^import .*$/gm, '').replace('export default', 'return')
const component = new Function('ScanTargetInput', script)({})
function context() {
const value = { ...component.data(), $emit() {}, task: { items: [
{ cartonMark: 'A', relabelledPackages: 1, destinationFbaWarehouseCode: 'ONT8' },
{ cartonMark: 'B', relabelledPackages: 1, destinationFbaWarehouseCode: 'JFK8' },
{ cartonMark: 'C', relabelledPackages: 0, destinationFbaWarehouseCode: 'ONT8' },
{ cartonMark: 'OTHER', relabelledPackages: 1, destinationFbaWarehouseCode: 'ONT8' }
] } }
for (const [key, method] of Object.entries(component.methods)) value[key] = method.bind(value)
for (const [key, method] of Object.entries(component.computed)) Object.defineProperty(value, key, { get: method.bind(value) })
value.confirmBindingChoice = async () => true
value.selectSource({ positionId: '1', palletId: '20', palletCode: 'SOURCE', movablePackages: 2, cartonMarks: ['A', 'B', 'C'] })
return value
}
test('quantity batch only includes completed boxes from same inbound and destination', () => {
const value = context()
value.quantity = '1'; value.fillBatch(); assert.deepEqual(value.marks, ['A'])
value.quantity = '2'; value.fillBatch(); assert.match(value.error, /不超过/)
value.quantity = '1.5'; value.fillBatch(); assert.match(value.error, /正整数/)
value.selectedDestination = 'JFK8'; value.quantity = '1'; value.fillBatch(); assert.deepEqual(value.marks, ['B'])
})
test('submission uses pallet identity instead of scanned presentation text', () => {
const value = context(); value.quantity = '1'; value.fillBatch()
value.source = { palletId: '20', palletCode: 'SOURCE' }; value.sourceCode = ' source\n'
value.target = { palletId: '30', palletCode: 'TARGET' }; value.targetCode = 'target-alias'
assert.equal(value.readyToBind, true)
value.toggleMark('A'); assert.equal(value.readyToBind, false)
value.toggleMark('A'); value.source = { palletId: '21', palletCode: 'OTHER' }; assert.equal(value.readyToBind, false)
value.source = { palletId: '20', palletCode: 'SOURCE' }; value.target = { palletId: '20', palletCode: 'TARGET-ALIAS' }
assert.equal(value.readyToBind, false)
})
test('pallet comparison tolerates mixed API versions and canonical code formatting', () => {
const value = context()
assert.equal(value.samePallet(
{ palletCode: ' plt-us-lax-01-1 ' },
{ palletCode: 'PLT-US-LAX-01-1' }
), true)
assert.equal(value.samePallet(
{ palletId: '20', palletCode: 'SOURCE' },
{ palletId: '21', palletCode: 'OTHER' }
), false)
assert.equal(value.samePallet(
{ palletId: '20', palletCode: 'SOURCE' },
{ palletId: '21', palletCode: ' source ' }
), true)
assert.equal(value.samePallet(
{ palletId: 2100203890682015700, palletCode: 'PLT-US-LAX-01-20260916-2100203890682015746' },
{ palletId: '2100203890682015746', palletCode: ' plt-us-lax-01-20260916-2100203890682015746 ' }
), true)
})
test('quantity disassembly has no extra carton scanner', () => {
assert.doesNotMatch(source, /relabel-move-carton/)
assert.match(source, /actualSource:/)
assert.match(source, /来源已确认:.*尚未拆出.*绑定成功后才保存拆托明细/)
assert.match(source, /this\.message = `托盘 \$\{this\.target\.palletCode\} 已绑定`/)
})
test('source pallet list is concise and hides rows with no movable packages', () => {
const value = context()
value.sourceRows = [
{ palletCode: 'PALLET-1', inboundCode: 'IN-1', stockPackages: 12, movablePackages: 2 },
{ palletCode: 'PALLET-2', inboundCode: 'IN-2', stockPackages: 10, movablePackages: 0 }
]
assert.deepEqual(value.operableSourceRows.map(row => row.palletCode), ['PALLET-1'])
assert.match(source, />托盘</)
assert.match(source, />刷新</)
assert.match(source, /class="source-caption">托盘号<\/text><text class="source-code">\{\{ row\.palletCode \}\}/)
assert.doesNotMatch(template, /入仓码|row\.inboundCode/)
assert.match(source, /可拆\{\{ isReturnBulk \? '件数' : '箱数' \}\}:\{\{ row\.movablePackages \}\}/)
assert.doesNotMatch(template, /刷新来源托盘/)
assert.doesNotMatch(template, /按原入仓单拆托/)
assert.doesNotMatch(template, /确认后直接提交本批拆托合托/)
})
test('latest current-round binding is used for rebind source', () => {
const value = context()
value.task.history = ['OLD', 'NEW'].map(pallet => ({ action: 'PALLET_BIND', detail: JSON.stringify({ round: 'INITIAL', cartonMarks: ['A'], targetPallets: [pallet] }) }))
assert.deepEqual(value.currentBindings, [{ mark: 'A', pallet: 'NEW' }])
value.task.reviewedAt = 'NEW-ROUND'
assert.deepEqual(value.currentBindings, [{ mark: 'A', pallet: 'NEW' }])
value.task.history.push({ action: 'REWORK_ARCHIVE', detail: '{}' })
assert.deepEqual(value.currentBindings, [])
})
test('rescan same bound pallet does not call stock movement', async () => {
const value = context(); value.rebindSelection = { mark: 'A', pallet: 'TARGET' }; value.targetCode = 'target'
let message = ''
value.showBindingMessage = (title, content) => { message = content }
value.run = async () => assert.fail('same pallet must not move stock')
await value.submitRebind()
assert.match(message, /不会重复扣库存/)
})
test('target confirmation submits binding directly, with no second submit button', async () => {
const value = context(); value.quantity = '1'; value.fillBatch()
value.source = { palletId: '20', palletCode: 'SOURCE' }; value.sourceCode = 'SOURCE'; value.targetCode = 'TARGET'
let binds = 0
value.resolve = async kind => { assert.equal(kind, 'target'); value.target = { palletId: '30', palletCode: 'TARGET' } }
value.bind = async () => { binds++ }
await value.confirmTargetAndBind()
assert.equal(binds, 1)
assert.match(source, /@submit="confirmTargetAndBind"/)
assert.match(source, /@tap="confirmTargetAndBind"/)
assert.doesNotMatch(source, /@tap="bind"/)
})
test('mixed carton scan alone explains missing SKU and does not bind; completing SKU allows binding', async () => {
const value = context()
value.task.items = [{ cartonMark: 'A', operationType: 'CARTON_AND_SKU', expectedPackages: 1, relabelledPackages: 0, cartonProcessedPackages: 1, skuProcessedPackages: 0, destinationFbaWarehouseCode: 'ONT8' }]
value.source = { palletCode: 'SOURCE' }; value.targetCode = 'TARGET'; value.quantity = '1'
let message = ''; let binds = 0
value.showBindingMessage = (title, content) => { message = content }
value.resolve = async () => { value.target = { palletCode: 'TARGET' } }
value.bind = async () => { binds++ }
await value.confirmTargetAndBind()
assert.match(message, /SKU 贴标登记/)
assert.equal(binds, 0)
assert.equal(value.target, null)
value.task.items[0].skuProcessedPackages = 1
value.task.items[0].relabelledPackages = 1
assert.equal(value.labelBlockReason, '')
await value.confirmTargetAndBind()
assert.equal(binds, 1)
})
test('source scan asks before accepting a listed pallet and rejects unknown pallets', async () => {
const value = context()
value.sourceRows = [{ positionId: '1', palletCode: 'SOURCE', movablePackages: 2 }]
value.sourceCode = 'SOURCE'
let prompts = 0
value.resolve = async () => { value.source = { palletCode: 'SOURCE' } }
value.confirmBindingChoice = async () => { prompts++; return false }
await value.confirmSourceScan()
assert.equal(value.source, null)
value.confirmBindingChoice = async () => { prompts++; return true }
await value.confirmSourceScan()
assert.equal(value.source.palletCode, 'SOURCE')
value.sourceCode = 'OTHER'
value.showBindingMessage = () => {}
value.resolve = async () => assert.fail('unknown pallet must not resolve')
await value.confirmSourceScan()
assert.equal(value.source, null)
assert.equal(prompts, 2)
})
test('declining target confirmation does not move stock', async () => {
const value = context()
value.quantity = '1'; value.source = { palletId: '20', palletCode: 'SOURCE' }; value.targetCode = 'TARGET'
value.resolve = async () => { value.target = { palletId: '30', palletCode: 'TARGET' } }
value.confirmBindingChoice = async () => false
value.bind = async () => assert.fail('cancelled target must not bind')
await value.confirmTargetAndBind()
assert.equal(value.target, null)
})
test('simplified split automatically fills a batch and counts saved bindings without repeats', async () => {
const value = context()
value.task.items = ['A', 'B', 'C'].map(cartonMark => ({ cartonMark, operationType: 'CARTON_LABEL', expectedPackages: 1, relabelledPackages: 1, destinationFbaWarehouseCode: 'ONT8' }))
value.task.history = [{ action: 'PALLET_BIND', detail: JSON.stringify({ cartonMarks: ['A'], targetPallets: ['OLD-TARGET'] }) }]
assert.equal(value.requiredSplitPackages, 3)
assert.equal(value.splitPackages, 1)
value.quantity = '1'; value.marks = []; value.source = { palletId: '20', palletCode: 'SOURCE' }; value.targetCode = 'TARGET'
value.resolve = async () => { value.target = { palletId: '30', palletCode: 'TARGET' } }
value.bind = async () => { assert.deepEqual(value.marks, ['B']); assert.equal(value.readyToBind, true) }
await value.confirmTargetAndBind()
assert.doesNotMatch(template, /按箱数填入本批换标箱|选择本批新目的仓|本批确认|@tap="toggleMark/)
value.task.history.push({ action: 'REWORK_ARCHIVE', detail: '{}' })
assert.equal(value.splitPackages, 0)
})
test('missing source scan shows modal and never submits a binding', async () => {
const value = context(); value.quantity = '1'; value.fillBatch(); value.targetCode = 'TARGET'
let message = ''
value.showBindingMessage = (title, content) => { message = content }
value.bind = async () => assert.fail('must not bind without source confirmation')
value.resolve = async () => assert.fail('must validate source first')
await value.confirmTargetAndBind()
assert.match(message, /扫描并确认实际来源托盘/)
})
test('target lookup failure cannot submit binding or report success', async () => {
const value = context(); value.quantity = '1'; value.fillBatch()
value.source = { palletId: '20', palletCode: 'SOURCE' }; value.sourceCode = 'SOURCE'; value.targetCode = 'TARGET'
value.resolve = async () => { value.error = '目标托盘不可用' }
let title = ''
value.showBindingMessage = heading => { title = heading }
value.bind = async () => assert.fail('must not bind after lookup failure')
await value.confirmTargetAndBind()
assert.equal(title, '绑定失败')
})
+66
View File
@@ -0,0 +1,66 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const source = await readFile(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
const body = source.split('<script>')[1].split('</script>')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', body)({}, {}, {}, {})
function context() {
const value = { current: { reviewedAt: null, history: [], items: [{ itemId: '1', operationType: 'CARTON_AND_SKU', expectedPackages: 1, skippedPackages: 0, exceptionPackages: 0, skuProcessedPackages: 0, cartonProcessedPackages: 1, skuItems: [{ skuCode: 'OLD', newSkuCode: 'NEW', quantity: 3 }, { skuCode: 'OTHER', newSkuCode: 'NEW2', quantity: 2 }] }] }, taskTab: 'sku' }
for (const name of ['skuDone', 'skuDetails', 'remaining']) value[name] = component.methods[name].bind(value)
for (const name of ['visibleItems', 'pendingSkuRows']) Object.defineProperty(value, name, { get: component.computed[name].bind(value) })
return value
}
test('each persisted SKU hides independently; mixed carton progress is separate', () => {
const value = context(); assert.equal(value.pendingSkuRows.length, 2)
value.current.history.push({ action: 'SKU_SCAN', detail: JSON.stringify({ round: 'INITIAL', itemId: '1', skuIndex: 0 }) })
assert.equal(value.pendingSkuRows.length, 1); assert.equal(value.pendingSkuRows[0].sku.skuCode, 'OTHER')
value.taskTab = 'carton'; assert.equal(value.visibleItems.length, 0)
value.taskTab = 'sku'; value.current.reviewedAt = '2026-09-15T18:00:00'; assert.equal(value.pendingSkuRows.length, 2)
value.current.items[0].skuProcessedPackages = 1; assert.equal(value.visibleItems.length, 0)
})
test('normal SKU input is always visible in SKU tab and never requests the new label', () => {
assert.match(source, /name="quickSku"/)
assert.doesNotMatch(source, /placeholder="扫描或输入新 SKU 标签号"/)
assert.match(source, /matches.length > 1/)
assert.match(source, /scanRelabelSku/)
})
test('return SKU follows ordinary single scan and requires explicit choice when the same SKU spans boxes', async () => {
const calls = []
let value
const subject = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'scanRelabelSku', 'warehouseIdempotencyKey', 'releaseWarehouseKey', 'uni', body)(
{}, {}, {}, {}, async (no, payload) => { calls.push(payload); return value.current },
() => ({ value: 'KEY', signature: 'SIG' }), () => {}, { showModal: options => options.complete() }
)
value = context()
Object.assign(value, { busy: false, quickSku: '', skuChoices: [], verifiedReturnCarton: '', returnCartonCode: '',
async run(action, work) { await work() }, applyCurrent(task) { this.current = task },
setError(error) { throw error }
})
for (const name of ['submitSku', 'commitSku']) value[name] = subject.methods[name].bind(value)
value.current.relabelNo = 'RL'; value.current.version = 3
value.current.items = [{ itemId: '1', returnCartonId: 'R1', cartonMark: 'BOX-A', operationType: 'SKU_LABEL', expectedPackages: 1, relabelledPackages: 0, skippedPackages: 0, exceptionPackages: 0, skuItems: [{ skuCode: 'OLD', newSkuCode: 'NEW', quantity: 20 }] }]
await value.submitSku('OLD')
assert.deepEqual(calls, [{ itemId: '1', cartonMark: 'BOX-A', newSkuCode: 'NEW', quantity: 20, skuIndex: 0, oldSku: 'OLD', expectedVersion: 3, idempotencyKey: 'KEY' }])
calls.length = 0
value.current.items.push({ ...value.current.items[0], itemId: '2', returnCartonId: 'R2', cartonMark: 'BOX-B' })
await value.submitSku('OLD')
assert.equal(calls.length, 0)
assert.equal(value.skuChoices.length, 2)
await value.commitSku(value.skuChoices[1])
assert.equal(calls.length, 1)
assert.equal(calls[0].itemId, '2')
assert.equal(calls[0].cartonMark, 'BOX-B')
assert.doesNotMatch(source.split('<script>')[0], /name="quickTarget"|placeholder="必填:本来源该 SKU 实际件数"/)
})
test('pending list removes one confirmed row at a time without persistent empty or last-scan banners', () => {
const value = context()
assert.equal(value.pendingSkuRows.length, 2)
for (const index of [0, 1]) {
value.current.history.push({ action: 'SKU_SCAN', detail: JSON.stringify({ round: 'INITIAL', itemId: '1', skuIndex: index }) })
assert.equal(value.pendingSkuRows.length, 1 - index)
}
const template = source.split('<script>')[0]
assert.doesNotMatch(template, /待处理明细已清空|v-if="lastSku"|v-if="lastCarton"/)
assert.match(source, /uni.showModal/)
})
+74
View File
@@ -0,0 +1,74 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const source = await readFile(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
const helperSource = await readFile(new URL('../src/utils/return-relabel.js', import.meta.url), 'utf8')
const helpers = await import('data:text/javascript;base64,' + Buffer.from(helperSource).toString('base64'))
const body = source.split('<script>')[1].split('</script>')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
function setup(confirm = false) {
const calls = [], errors = []
const uni = { showModal(options) { options.success({ confirm }) } }
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'requiresReturnPack', 'returnPackFacts', 'completeReturnRelabelSku', 'warehouseIdempotencyKey', 'releaseWarehouseKey', 'uni', body)({}, {}, {}, {}, helpers.requiresReturnPack, helpers.returnPackFacts, async (no, payload) => { calls.push(payload); return value.current }, () => ({ value: 'key', signature: 'signature' }), () => {}, uni)
const item = (id, mark) => ({ itemId: id, returnCartonId: id, cartonMark: mark, returnQuantity: 10, operationType: 'RETURN_BULK_SKU_LABEL', skuCode: 'SKU', newSkuCode: 'TARGET', expectedPackages: 4, relabelledPackages: 0, skippedPackages: 0, exceptionPackages: 0 })
const value = { current: { relabelNo: 'RL', version: 1, items: [item('1', 'BOX-A'), item('2', 'BOX-B')], history: [] }, busy: false, allProcessed: true, remark: '', photoFileIds: ['photo'], returnCartonCode: '', verifiedReturnCarton: '', returnSkuCode: '', bulkQuantities: {}, returnTargetLabels: {}, setError(error) { errors.push(error.message) }, applyCurrent(result) { this.current = result }, async run(action, work) { await work() } }
for (const [name, fn] of Object.entries(component.methods)) if (!value[name]) value[name] = fn.bind(value)
for (const name of ['isReturnTask', 'isReturnBulk', 'returnBulkSources', 'returnBulkSkuGroups', 'requiresPalletBinding', 'palletTask', 'completionBlockReason']) Object.defineProperty(value, name, { get: component.computed[name].bind(value) })
return { value, calls, errors }
}
test('SKU alone cannot register or allocate unscanned sources', async () => {
const { value, calls, errors } = setup()
value.verifyReturnSku('SKU'); assert.match(errors.pop(), /先确认来源/)
value.verifyReturnCarton('BOX-A'); value.verifyReturnSku('SKU')
assert.deepEqual(value.returnBulkSources.map(item => item.cartonMark), ['BOX-A'])
value.returnTargetLabels['2'] = 'TARGET'; value.bulkQuantities['2'] = '4'
await value.completeBulkSku(value.current.items[1]); assert.equal(calls.length, 0)
})
test('quantity is explicit, integral and source bounded; wrong target cannot write', async () => {
const { value, calls } = setup(); value.verifyReturnCarton('BOX-A'); value.verifyReturnSku('SKU')
const item = value.current.items[0]; value.returnTargetLabels['1'] = 'TARGET'
for (const quantity of ['', '1.5', '5', '0']) { value.bulkQuantities['1'] = quantity; await value.completeBulkSku(item) }
assert.equal(calls.length, 0)
value.bulkQuantities['1'] = '2'; value.returnTargetLabels['1'] = 'WRONG'; await value.completeBulkSku(item); assert.equal(calls.length, 0)
value.returnTargetLabels['1'] = 'TARGET'; await value.completeBulkSku(item)
assert.deepEqual(calls, [{ itemId: '1', cartonMark: 'BOX-A', skuCode: 'SKU', quantity: 2, expectedVersion: 1, idempotencyKey: 'key' }])
})
test('all action needs confirmation and rescanning clears quantity and target', async () => {
for (const confirm of [false, true]) { const { value } = setup(confirm); value.verifyReturnCarton('BOX-A'); await value.fillAllReturn(value.current.items[0]); assert.equal(value.bulkQuantities['1'], confirm ? '4' : undefined); value.verifyReturnCarton('BOX-B'); assert.deepEqual(value.bulkQuantities, {}); assert.deepEqual(value.returnTargetLabels, {}) }
})
test('normal return SKU rejects incomplete approved target before server scan', async () => {
const { value, errors } = setup(); const item = { ...value.current.items[0], operationType: 'SKU_LABEL', expectedPackages: 1, skuItems: [{ skuCode: 'SKU', newSkuCode: 'TARGET', quantity: 3 }] }
value.current.items = [item]; value.pendingSkuRows = [{ item, sku: item.skuItems[0], index: 0 }]
item.skuItems[0].newSkuCode = ''; value.quickSku = 'SKU'; await value.commitSku(value.pendingSkuRows[0]); assert.match(errors.pop(), /申请件数缺失/)
})
test('packing uses SKU pieces instead of box count and preserves both sources', () => {
const task = { items: [{ returnCartonId: '1', cartonMark: 'A', operationType: 'SKU_LABEL', expectedPackages: 1, returnQuantity: 8, skuItems: [{ skuCode: 'SKU', quantity: 3 }] }, { returnCartonId: '2', cartonMark: 'B', operationType: 'SKU_LABEL', expectedPackages: 1, returnQuantity: 2, skuItems: [{ skuCode: 'SKU', quantity: 2 }] }] }
assert.deepEqual(helpers.returnDeductions(task), [{ cartonMark: 'A', skuCode: 'SKU', quantity: 3 }, { cartonMark: 'B', skuCode: 'SKU', quantity: 2 }]); assert.equal(helpers.requiresReturnPack(task), true)
task.items[0].returnQuantity = 3; assert.equal(helpers.requiresReturnPack(task), false)
})
test('only truck requires return pallet binding and counts physical new boxes', () => {
const { value } = setup(); for (const method of [null, 'EXPRESS', 'EXPRESS_PICKUP', 'TRUCK']) { value.current.deliveryMethod = method; assert.equal(value.requiresPalletBinding, method === 'TRUCK') }
value.current.items.forEach(item => { item.cartonMark = 'NEW'; item.relabelledPackages = item.expectedPackages })
assert.equal(value.palletTask.items.length, 1); assert.equal(value.palletTask.items[0].expectedPackages, 1)
})
test('pack recovery ignores previous rework round', () => {
const task = { reviewedAt: 'ROUND2', history: [{ action: 'RETURN_PACK', detail: JSON.stringify({ round: 'INITIAL', cartonMark: 'OLD' }) }, { action: 'RETURN_PACK', detail: JSON.stringify({ round: 'ROUND2', cartonMark: 'NEW' }) }] }
assert.deepEqual(helpers.returnPackFacts(task).map(row => row.cartonMark), ['NEW'])
})
test('partial return completion requires current-round independent box then truck binding and photo', () => {
const { value } = setup()
value.current.items.forEach(item => { item.relabelledPackages = item.expectedPackages })
value.current.relabelledPackages = 8
value.current.deliveryMethod = 'EXPRESS'
assert.match(value.completionBlockReason, /独立新箱/)
value.current.history.push({ action: 'RETURN_PACK', detail: JSON.stringify({ round: 'INITIAL', cartonMark: 'NEW' }) })
value.current.items.forEach(item => { item.cartonMark = 'NEW'; item.returnQuantity = 8 })
assert.equal(value.completionBlockReason, '')
value.current.deliveryMethod = 'TRUCK'
assert.match(value.completionBlockReason, /组托/)
value.current.history.push({ action: 'PALLET_BIND', detail: JSON.stringify({ round: 'INITIAL', cartonMarks: ['NEW'] }) })
assert.equal(value.completionBlockReason, '')
value.photoFileIds = []
assert.match(value.completionBlockReason, /照片/)
})
@@ -0,0 +1,54 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const page = await readFile(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
const helper = await readFile(new URL('../src/utils/return-relabel.js', import.meta.url), 'utf8')
const { returnDeductions, requiresReturnPack, returnPackFacts } = await import('data:text/javascript;base64,' + Buffer.from(helper).toString('base64'))
const body = page.split('<script>')[1].split('</script>')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'requiresReturnPack', 'returnPackFacts', body)({}, {}, {}, {}, requiresReturnPack, returnPackFacts)
test('return carton and normal SKU use single scans; explicit source scan is reserved for bulk quantities', () => {
assert.doesNotMatch(page, /退货箱唛换标必须先确认同一来源包裹\/箱/)
assert.doesNotMatch(page, /v-if="isReturnTask && taskTab !== 'carton'"/)
assert.match(page, /v-if="isReturnBulk" class="section-gap"><text class="hint">先扫描来源/)
assert.match(page, /this.activeTarget = value === 'carton' \? 'quick-carton'/)
assert.match(page, /this\.taskTab === 'carton' \? 'quick-carton' : 'quick-sku'/)
})
test('whole carton and full SKU relabel hide new box dimensions; partial SKU keeps isolation form', () => {
const value = { isReturnTask: true, current: { items: [{ returnCartonId: '1', cartonMark: 'C1', operationType: 'CARTON_LABEL', returnQuantity: 15 }], history: [] } }
assert.equal(component.computed.showReturnPack.call(value), false)
value.current.items[0].operationType = 'SKU_LABEL'
value.current.items[0].skuItems = [{ skuCode: 'A', quantity: 15 }]
assert.equal(component.computed.showReturnPack.call(value), false)
value.current.items[0].skuItems[0].quantity = 5
assert.equal(component.computed.showReturnPack.call(value), true)
assert.match(page, /ReturnRelabelPackWork v-if="showReturnPack"/)
})
test('one return carton scan saves label stage without a prior source scan; unrelated carton is rejected', async () => {
const calls = []
const item = { itemId: 'I1', cartonMark: 'C1', operationType: 'CARTON_LABEL', fbaCartonMark: 'FBA1' }
const result = { relabelNo: 'RL1', version: 1, items: [{ ...item, relabelledPackages: 1 }] }
const subject = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'scanRelabelCarton', 'warehouseIdempotencyKey', 'releaseWarehouseKey', 'uni', body)(
{}, {}, {}, {},
async (no, payload) => { calls.push({ no, payload }); return result },
() => ({ value: 'KEY', signature: 'SIG' }), () => {},
{ showModal: options => options.complete() }
)
const value = {
busy: false, current: { relabelNo: 'RL1', version: 0, items: [item] }, verifiedReturnCarton: '',
run: async (action, work) => work(), applyCurrent(task) { this.current = task },
setError(error) { this.error = error.message }
}
await subject.methods.submitCarton.call(value, ' C1\n')
assert.deepEqual(calls, [{ no: 'RL1', payload: { cartonMark: 'C1', expectedVersion: 0, idempotencyKey: 'KEY' } }])
assert.equal(value.current.version, 1)
assert.equal(value.lastCarton.fbaCartonMark, 'FBA1')
await subject.methods.submitCarton.call(value, 'OTHER')
assert.equal(calls.length, 1)
assert.match(value.error, /不属于本单/)
})
test('mixed return carton keeps one physical package while SKU deductions use pieces', () => {
const task={items:[{returnCartonId:'1',cartonMark:'P1',operationType:'CARTON_AND_SKU',returnQuantity:5,skuItems:[{skuCode:'S',quantity:5}]}]}
assert.deepEqual(returnDeductions(task),[{cartonMark:'P1',skuCode:'S',quantity:5}])
})
+31
View File
@@ -0,0 +1,31 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const helper = await readFile(new URL('../src/utils/return-relabel.js', import.meta.url), 'utf8')
const { requiresReturnPack, returnDeductions, returnPackFacts, returnSelections } = await import('data:text/javascript;base64,' + Buffer.from(helper).toString('base64'))
const source = await readFile(new URL('../src/components/ReturnRelabelPackWork.vue', import.meta.url), 'utf8')
const body = source.split('<script>')[1].split('</script>')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
function setup() {
const calls = [], events = []
const component = new Function('requiresReturnPack','returnDeductions','returnPackFacts','returnSelections','packReturnRelabelCartons','warehouseIdempotencyKey','releaseWarehouseKey',body)(requiresReturnPack,returnDeductions,returnPackFacts,returnSelections,async(no,payload)=>{calls.push(payload);return {relabelNo:no}},()=>({value:'stable',signature:'s'}),()=>{})
const item = (mark,done) => ({returnCartonId:mark,cartonMark:mark,operationType:'SKU_LABEL',expectedPackages:1,relabelledPackages:done,skippedPackages:0,exceptionPackages:0,skuItems:[{skuCode:'SKU',quantity:3}]})
const value = {...component.data(),task:{relabelNo:'RL',items:[item('A',1),item('B',0)],history:[]},$emit:(...args)=>events.push(args)}
for(const[name,fn]of Object.entries(component.methods))value[name]=fn.bind(value)
for(const[name,fn]of Object.entries(component.computed))Object.defineProperty(value,name,{get:fn.bind(value)})
return {value,calls,events}
}
test('packing cannot run with an unfinished source, missing measurement, skipped or exception fact',async()=>{
const {value,calls}=setup(); await value.pack();assert.equal(calls.length,0)
value.dimensions={length:'20',width:'30',height:'40',weight:'2'}
assert.equal(value.ready,false);value.task.items[1].relabelledPackages=1;assert.equal(value.ready,true)
value.task.items[0].skippedPackages=1;assert.equal(value.ready,false);value.task.items[0].skippedPackages=0
value.task.items[0].exceptionPackages=1;assert.equal(value.ready,false)
})
test('pack sends every source with exact SKU piece deductions and generated target; saved history prevents repeat',async()=>{
const {value,calls,events}=setup();value.task.items[1].relabelledPackages=1;value.dimensions={length:'20',width:'30',height:'40',weight:'2'}
await value.pack()
assert.deepEqual(calls,[{sourceCartonMarks:['A','B'],targetCartonMark:null,sourceDeductions:[{cartonMark:'A',skuCode:'SKU',quantity:3},{cartonMark:'B',skuCode:'SKU',quantity:3}],length:20,width:30,height:40,weight:2,idempotencyKey:'stable'}])
assert.ok(events.some(row=>row[0]==='saved'))
value.task.history=[{action:'RETURN_PACK',detail:JSON.stringify({round:'INITIAL',cartonMark:'NEW',quantity:6,label:{printableHtml:'<html/>'}})}]
assert.equal(value.ready,false);assert.equal(value.facts[0].cartonMark,'NEW')
})
+90
View File
@@ -0,0 +1,90 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
import { parse, compileScript, compileTemplate } from 'vue/compiler-sfc'
const filename = 'src/components/ReturnBoxMergeWork.vue'
const source = readFileSync(new URL('../' + filename, import.meta.url), 'utf8')
const { descriptor } = parse(source)
test('box merge component compiles both its script and template', () => {
compileScript(descriptor, { id: 'box-merge' })
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename, id: 'box-merge' }).errors, [])
})
function instance() {
const boxes = [{cartonMark:'A',customerId:'C1',items:[{skuCode:'SKU1',quantity:2},{skuCode:'SKU2',quantity:3}]},{cartonMark:'B',customerId:'C1',items:[{skuCode:'SKU1',quantity:2}]}]
const component = vm.runInNewContext(descriptor.script.content.replace(/^import .*$/gm, '').replace('export default', 'component ='), { ScanTargetInput: {}, ReturnLabelConfirmation: {}, component: null, scanReturnCarton: async mark => boxes.find(box => box.cartonMark === mark) })
const work = { ...component.data(), plan: {customerId:'C1',sources:[{cartonMark:'A',items:[{skuCode:'SKU1',quantity:2},{skuCode:'SKU2',quantity:3}]},{cartonMark:'B',items:[{skuCode:'SKU1',quantity:2}]}]}, $emit(){} }
for (const [name, method] of Object.entries(component.methods)) work[name] = method.bind(work)
work.save = () => {}
return {work,component}
}
test('new plan requires scanning each source and preserves locked SKU quantities', async () => {
const {work,component}=instance(); work.choose('NEW')
assert.equal(work.sources.length,0)
assert.equal(work.active,'source')
await work.acceptScan('A'); await work.acceptScan('B')
work.setDeduction('A','SKU1',1); work.setDeduction('A','SKU2',2); work.setDeduction('B','SKU1',2)
work.dimensions={length:10,width:20,height:30,weight:1}
assert.equal(work.sources.length,2)
assert.equal(work.plannedItems().reduce((sum,item)=>sum+item.quantity,0),7)
assert.equal(component.computed.canComplete.call(work),true)
})
test('planned source carton marks are visible before scanning', () => {
const {work, component} = instance()
work.plan = { sourceCartonMarks: ['A', 'B'], sources: [] }
assert.deepEqual(component.computed.plannedSourceRows.call(work).map(box => box.cartonMark), ['A', 'B'])
})
test('existing box uses another box as target and never removes target inventory', async () => {
const {work,component}=instance(); work.choose('EXISTING'); await work.scanSource('A'); await work.scanTarget('B')
assert.equal(work.target.cartonMark,'B'); assert.equal(work.sources.length,1)
work.setDeduction('A','SKU1',1)
assert.equal(work.plannedItems().length,2)
assert.equal(work.target.items[0].quantity,2)
assert.equal(component.computed.canComplete.call(work),true)
work.choose('NEW'); assert.equal(work.target,null); assert.equal(work.sources.length,0)
assert.equal(work.plannedItems().length,0)
})
test('existing source can be replaced before merging, echoing the mark and clearing old deductions and target', async () => {
const {work,component}=instance()
work.plan=null
work.choose('EXISTING'); await work.scanSource('A'); await work.scanTarget('B')
work.setDeduction('A','SKU1',1)
assert.equal(work.sourceCode,'A')
work.sourceCode='B'
assert.equal(component.computed.canComplete.call(work),false)
await work.scanSource('B')
assert.equal(work.sources.length,1)
assert.equal(work.sources[0].cartonMark,'B')
assert.equal(work.sourceCode,'B')
assert.equal(work.target,null)
assert.equal(work.targetCode,'')
assert.equal(work.plannedItems().length,0)
await work.scanTarget('A'); work.setDeduction('B','SKU1',1)
assert.equal(component.computed.canComplete.call(work),true)
})
test('BOX plan requires every source and accumulates multiple scanned sources', async () => {
const {work,component}=instance()
work.plan.mode='BOX'
work.plan.sources.push({cartonMark:'C',items:[{skuCode:'SKU1',quantity:4}]})
work.choose('EXISTING'); await work.scanSource('A'); await work.scanTarget('B')
assert.equal(component.computed.canComplete.call(work),false)
await work.scanSource('C')
assert.equal(work.sources.length,2)
assert.equal(component.computed.canComplete.call(work),true)
assert.equal(work.plannedItems().reduce((sum,item)=>sum+item.quantity,0),9)
})
test('a directly scanned box survives switching between existing and new targets', async () => {
const {work}=instance()
const box=work.plan.sources[0];work.plan=null;work.initialSource=box
work.choose('EXISTING')
assert.equal(work.sources[0].cartonMark,'A');assert.equal(work.sourceCode,'A')
work.choose('NEW')
assert.equal(work.sources[0].items.length,2)
work.choose('EXISTING')
assert.equal(work.sourceCode,'A');assert.equal(work.active,'target')
})
+43
View File
@@ -0,0 +1,43 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { listReturnCountTasks, scanReturnCountCarton, scanReturnCountTasks, getCurrentReturnCountTask, beginReturnCountTask, confirmReturnCount } from '../src/api/return-counting.js'
import { session } from '../src/stores/session.js'
test('counting preserves box contents and retry key without accepting client warehouse scope', async () => {
session.apiBase = '/api'; session.token = 'counting-test'
const calls = []
globalThis.uni = { request(options) { calls.push(options); options.success({ statusCode: 200, data: { code: '200', data: { status: 'PENDING' } }, header: {} }) } }
await listReturnCountTasks({ page: 2, keyword: ' RC#2-1 ' })
await scanReturnCountCarton('RC#2-1')
const request = { idempotencyKey: 'count-retry', boxes: [{ cartonMark: 'RC#2-1', items: [{ skuCode: 'SKU-1', quantity: 3 }] }, { cartonMark: 'RC#2-2', items: [{ skuCode: 'SKU-1', quantity: 7 }] }] }
await confirmReturnCount('RR/1', request)
assert.deepEqual(calls.map(row => [row.method, row.url]), [
['GET', '/api/pda/v1/last-leg/return-inventory/counting/tasks?page=2&size=10&keyword=RC%232-1'],
['GET', '/api/pda/v1/last-leg/return-inventory/counting/cartons/RC%232-1'],
['POST', '/api/pda/v1/last-leg/return-inventory/counting/receipts/RR%2F1']
])
assert.deepEqual(calls[2].data, request)
assert.ok(calls.every(row => row.header.Authorization === 'Bearer counting-test'))
})
test('unified scan supports box and pallet codes and preserves the no-count empty result', async () => {
session.apiBase = '/api'; session.token = 'counting-test'
const urls = []
globalThis.uni = { request(options) { urls.push(options.url); options.success({ statusCode: 200, data: { code: '200', data: [] }, header: {} }) } }
assert.deepEqual(await scanReturnCountTasks(' RC#2-1 '), [])
assert.deepEqual(await scanReturnCountTasks('PLT/1'), [])
assert.deepEqual(urls, ['/api/pda/v1/last-leg/return-inventory/counting/scan/RC%232-1', '/api/pda/v1/last-leg/return-inventory/counting/scan/PLT%2F1'])
})
test('current task preserves null and begin uses the operator-scoped claim route', async () => {
session.apiBase = '/api'; session.token = 'counting-test'
const calls = []
globalThis.uni = { request(options) { calls.push(options); options.success({ statusCode: 200, data: { code: '200', data: options.method === 'GET' ? null : { receiptNo: 'RR/1', status: 'COUNTING' } }, header: {} }) } }
assert.equal(await getCurrentReturnCountTask(), null)
assert.equal((await beginReturnCountTask('RR/1')).status, 'COUNTING')
assert.deepEqual(calls.map(row => [row.method, row.url]), [
['GET', '/api/pda/v1/last-leg/return-inventory/counting/current'],
['POST', '/api/pda/v1/last-leg/return-inventory/counting/receipts/RR%2F1/begin']
])
assert.equal(calls[1].data, undefined)
})
+187
View File
@@ -0,0 +1,187 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { runInNewContext } from 'node:vm'
const source = readFileSync(new URL('../src/pages/return-counting/return-counting.vue', import.meta.url), 'utf8')
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^import .+$/gm, '').replace('export default', 'page =')
const scope = { PortalHeader: {}, ScanTargetInput: {} }
runInNewContext(script, scope)
function state(a = '2', b = '2', c = '2') {
const value = {
scanCode: '', selectedMark: '',
task: { status: 'COUNTING', expectedItems: [{ skuCode: 'A', quantity: '4' }, { skuCode: 'B', quantity: '2' }], boxes: [
{ cartonMark: 'RC#2-1', items: [{ skuCode: 'A', quantity: a }] },
{ cartonMark: 'RC#2-2', items: [{ skuCode: 'A', quantity: b }] },
{ cartonMark: 'RC#1-1', items: [{ skuCode: 'B', quantity: c }] }
] }
}
value.skuCodes = scope.page.computed.skuCodes.call(value)
value.totals = scope.page.computed.totals.call(value)
return value
}
test('list entry can confirm without scanning when string wire quantities reconcile', () => {
const value = state()
assert.equal(value.totals[0].quantity, 4)
assert.equal(value.totals[1].quantity, 2)
assert.equal(value.totals.every(row => row.actual === row.quantity), true)
assert.equal(scope.page.computed.ready.call(value), true)
})
test('counting still rejects mismatched totals and invalid box quantities', () => {
for (const value of [state('1', '2', '2'), state('0', '4', '2'), state('1.5', '2.5', '2')]) {
assert.equal(scope.page.computed.ready.call(value), false)
}
const duplicate = state()
duplicate.task.boxes[0].items = [{ skuCode: 'A', quantity: 1 }, { skuCode: 'A', quantity: 1 }]
assert.equal(scope.page.computed.ready.call(duplicate), false)
})
function scanState() {
const value = { task: state().task, tasks: [], error: null, scanCode: '', scanResult: '', selectedMark: '', busy: false,
open(row) { this.task = row }, $nextTick(callback) { callback() } }
value.run = scope.page.methods.run.bind(value)
value.enterTask = scope.page.methods.enterTask.bind(value)
value.offerNextPalletTask = scope.page.methods.offerNextPalletTask.bind(value)
return value
}
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()
scope.scanReturnCountTasks = async () => [{ receiptNo: 'RR-SCAN', returnNo: 'RT-SCAN' }]
scope.getCurrentReturnCountTask = async () => null
let claimed
scope.beginReturnCountTask = async receiptNo => { claimed = receiptNo; assert.equal(value.busy, true); return { receiptNo, status: 'COUNTING' } }
value.ask = async () => { assert.fail('matching scan should enter directly') }
await scope.page.methods.scan.call(value, mark)
assert.equal(claimed, 'RR-SCAN')
assert.equal(value.task.status, 'COUNTING')
assert.equal(value.selectedMark, mark)
assert.equal(value.activeTask, value.task)
}
})
test('completed pallet offers remaining batches and starts only after confirmation', async () => {
for (const confirm of [false, true]) {
const value = scanState(); value.task = null
const next = { receiptNo: 'RR-NEXT', palletCode: 'PLT-1', status: 'PENDING' }
scope.scanReturnCountTasks = async code => { assert.equal(code, 'PLT-1'); return [next, { ...next, receiptNo: 'RR-DONE', status: 'CONFIRMED' }, { ...next, palletCode: 'PLT-2' }] }
scope.getCurrentReturnCountTask = async () => null
let claimed = 0
scope.beginReturnCountTask = async receiptNo => { claimed++; assert.equal(receiptNo, 'RR-NEXT'); return { ...next, status: 'COUNTING' } }
value.ask = async options => { assert.match(options.content, /还有 1 项/); assert.equal(options.cancelText, '暂不清点'); return { confirm } }
await value.offerNextPalletTask({ receiptNo: 'RR-DONE', palletCode: 'PLT-1' })
assert.equal(claimed, confirm ? 1 : 0)
assert.equal(value.task?.status || null, confirm ? 'COUNTING' : null)
}
})
test('no remaining pallet task skips prompt and claim conflicts remain visible', async () => {
const value = scanState(); value.task = null
value.ask = async () => assert.fail('no remaining task should not prompt')
scope.scanReturnCountTasks = async () => []
await value.offerNextPalletTask({ receiptNo: 'RR-DONE', palletCode: 'PLT-1' })
scope.scanReturnCountTasks = async () => [{ receiptNo: 'RR-NEXT', palletCode: 'PLT-1', status: 'COUNTING' }]
value.ask = async () => ({ confirm: true })
scope.getCurrentReturnCountTask = async () => null
scope.beginReturnCountTask = async () => { throw Object.assign(new Error('该任务正在由其他人员清点'), { code: 'A0409' }) }
await value.run(() => value.offerNextPalletTask({ receiptNo: 'RR-DONE', palletCode: 'PLT-1' }))
assert.equal(value.error.code, 'A0409')
assert.equal(value.task, null)
})
test('direct scan cannot claim a different task while the operator has unfinished work', async () => {
const value = scanState()
scope.scanReturnCountTasks = async () => [{ receiptNo: 'RR-NEW', returnNo: 'RT-NEW' }]
scope.getCurrentReturnCountTask = async () => ({ receiptNo: 'RR-OLD', returnNo: 'RT-OLD' })
scope.beginReturnCountTask = async () => { assert.fail('must not claim a second task') }
let dialog
value.ask = async options => { dialog = options; return { confirm: false } }
await scope.page.methods.scan.call(value, 'PLT-NEW')
assert.equal(dialog.title, '有未完成的清点任务')
})
test('a pallet scan shows every pending batch and no pending results clear stale details', async () => {
const value = scanState()
const rows = [{ receiptNo: 'RR-1', boxes: [] }, { receiptNo: 'RR-2', boxes: [] }]
scope.scanReturnCountTasks = async () => rows
await scope.page.methods.scan.call(value, 'PLT-1')
assert.equal(value.task, null)
assert.equal(value.tasks.length, 2)
assert.equal(value.scanResult, 'PLT-1')
scope.scanReturnCountTasks = async () => []
await scope.page.methods.scan.call(value, 'RC-DONE')
assert.equal(value.task, null)
assert.equal(value.tasks.length, 0)
assert.equal(value.scanResult, 'RC-DONE')
assert.equal(value.error, null)
})
test('scanner failures remain visible rather than claiming no count is required', async () => {
const value = scanState()
scope.scanReturnCountTasks = async () => { throw Object.assign(new Error('服务暂不可用'), { code: 'B0500' }) }
await scope.page.methods.scan.call(value, 'PLT-1')
assert.equal(value.error.code, 'B0500')
assert.equal(value.scanResult, '')
})
test('declining start never claims a task and another active task blocks a new claim', async () => {
const value = scanState(); value.activeTask = null
let begins = 0
scope.getCurrentReturnCountTask = async () => null
scope.beginReturnCountTask = async () => { begins++; return { status: 'COUNTING' } }
value.ask = async () => ({ confirm: false })
await scope.page.methods.choose.call(value, { receiptNo: 'RR-NEW', returnNo: 'RT-NEW' })
assert.equal(begins, 0)
scope.getCurrentReturnCountTask = async () => ({ receiptNo: 'RR-OLD', returnNo: 'RT-OLD' })
let dialog
value.ask = async options => { dialog = options; return { confirm: false } }
await scope.page.methods.choose.call(value, { receiptNo: 'RR-NEW', returnNo: 'RT-NEW' })
assert.equal(dialog.title, '有未完成的清点任务')
assert.equal(begins, 0)
})
test('affirming start claims the selected task and switches it to working state', async () => {
const value = scanState(); value.scanResult = 'RC-1'
scope.getCurrentReturnCountTask = async () => null
let claimed
scope.beginReturnCountTask = async receiptNo => { claimed = receiptNo; return { receiptNo, status: 'COUNTING' } }
value.ask = async () => ({ confirm: true })
await scope.page.methods.choose.call(value, { receiptNo: 'RR-NEW', returnNo: 'RT-NEW' })
assert.equal(claimed, 'RR-NEW')
assert.equal(value.task.status, 'COUNTING')
const unclaimed = state(); unclaimed.task.status = 'PENDING'
assert.equal(scope.page.computed.ready.call(unclaimed), false)
})
test('completed counting returns to page one and refreshes remaining tasks while busy', async () => {
const value = scanState(); value.ready = true; value.draftKey = 'count-draft'; value.page = 3; value.scanResult = 'PLT-1'
value.resetList = scope.page.methods.resetList.bind(value)
value.fetchTasks = scope.page.methods.fetchTasks.bind(value)
let removed = false, refreshed = false
scope.uni = { setStorageSync() {}, removeStorageSync() { removed = true }, showToast() {} }
scope.confirmReturnCount = async () => ({ status: 'CONFIRMED' })
scope.getCurrentReturnCountTask = async () => null
scope.listReturnCountTasks = async ({ page }) => { assert.equal(page, 1); assert.equal(value.busy, true); refreshed = true; return { records: [{ receiptNo: 'RR-NEXT' }], total: '1' } }
await scope.page.methods.confirm.call(value)
assert.equal(value.task, null)
assert.equal(value.scanResult, '')
assert.equal(value.activeTask, null)
assert.equal(value.tasks[0].receiptNo, 'RR-NEXT')
assert.equal(removed && refreshed, true)
})
test('failed confirmation retains the current task and draft for retry', async () => {
const value = scanState(); value.ready = true; value.draftKey = 'count-draft'
const original = value.task
let removed = false
scope.uni = { setStorageSync() {}, removeStorageSync() { removed = true } }
scope.confirmReturnCount = async () => { throw new Error('数量汇总不一致') }
await scope.page.methods.confirm.call(value)
assert.equal(value.task, original)
assert.equal(removed, false)
assert.equal(value.error.message, '数量汇总不一致')
})
+20
View File
@@ -0,0 +1,20 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
import { parse, compileScript, compileTemplate } from 'vue/compiler-sfc'
const filename = 'src/components/ReturnLabelConfirmation.vue'
const { descriptor } = parse(readFileSync(new URL('../' + filename, import.meta.url), 'utf8'))
test('physical label confirmation compiles and requires its own barcode before calling the service', async () => {
compileScript(descriptor, { id: 'return-label' })
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename, id: 'return-label' }).errors, [])
const calls = []
const component = vm.runInNewContext(descriptor.script.content.replace(/^import .*$/gm, '').replace('export default', 'component ='), {
component: null, ScanTargetInput: {}, confirmReturnLabel: async mark => calls.push(mark)
})
const label = { ...component.data(), cartonMark: 'RP-1' }
for (const [name, method] of Object.entries(component.methods)) label[name] = method.bind(label)
await label.confirm('RP-OTHER'); assert.equal(calls.length, 0); assert.equal(label.confirmed, false)
await label.acceptScan('RP-1'); assert.deepEqual(calls, ['RP-1']); assert.equal(label.confirmed, true)
await label.confirm('RP-1'); assert.equal(calls.length, 1)
})
+24
View File
@@ -0,0 +1,24 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { defaultPackingBoxes, packingBoxPayload, validPackingAllocation } from '../src/utils/return-packing-allocation.js'
const measured = box => ({ ...box, length: '20', width: '30', height: '40', weight: '5' })
test('default one box per SKU preserves all planned quantities', () => {
const boxes = defaultPackingBoxes([{ skuCode: 'A', quantity: 7 }, { skuCode: 'B', quantity: 3 }, { skuCode: 'C', quantity: 0 }]).map(measured)
assert.equal(boxes.length, 2)
assert.equal(validPackingAllocation(boxes, { A: 7, B: 3 }), true)
assert.deepEqual(packingBoxPayload(boxes[0]).items, [{ skuCode: 'A', quantity: 7 }])
})
test('split and mixed boxes must equal the complete locked plan', () => {
const boxes = [measured({ quantities: { A: 2, B: 3 } }), measured({ quantities: { A: 5 } })]
assert.equal(validPackingAllocation(boxes, { A: 7, B: 3 }), true)
assert.equal(validPackingAllocation(boxes, { A: 8, B: 3 }), false)
assert.equal(validPackingAllocation(boxes, { A: 6, B: 3 }), false)
assert.equal(validPackingAllocation(boxes, { A: 7 }), false)
})
test('empty boxes, decimal quantities and invalid measurements block completion', () => {
assert.equal(validPackingAllocation([], { A: 1 }), false)
assert.equal(validPackingAllocation([measured({ quantities: {} })], {}), false)
assert.equal(validPackingAllocation([measured({ quantities: { A: 1.5 } })], { A: 1.5 }), false)
assert.equal(validPackingAllocation([{ ...measured({ quantities: { A: 1 } }), weight: '0' }], { A: 1 }), false)
})
+11 -6
View File
@@ -11,18 +11,23 @@ test('packing preview keeps customer scope internal and exposes stable order ref
assert.equal(value.skus[0].availableQuantity, 8)
})
test('legacy single-customer preview is normalized to all customer orders', () => {
const value = normalizePackingPreview({ customerId: '100', orders: [{ returnNo: 'RT-1' }, { returnNo: 'RT-2' }], skus: [{ skuCode: 'SKU-1', availableQuantity: 8 }] })
assert.deepEqual(value.customers[0].orders.map((item) => item.returnNo), ['RT-1', 'RT-2'])
})
test('return packing API uses exact PDA inventory routes without submitting customer or warehouse scope', async () => {
const calls = []
session.apiBase = '/overseas-api'; session.token = 'packing-token'
session.apiBase = '/api'; session.token = 'packing-token'
globalThis.uni = { request(options) { calls.push({ method: options.method, url: options.url, data: options.data, authorization: options.header.Authorization }); let data = {}; if (options.url.includes('/packing/orders')) data = { customerId: '100', orders: [], skus: [] }; else if (options.url.endsWith('/cartons/merge')) data = { cartonMark: 'RC-1', skuCode: 'SKU-1', totalQuantity: 2, label: { cartonMark: 'RC-1', items: [] } }; else data = { cartonMark: 'RC/1', totalQuantity: 2, items: [] }; options.success({ statusCode: 200, data: { code: '200', data }, header: {} }) } }
await findPackingOrders('RT/1')
const request = { returnNos: ['RT/1'], items: [{ skuCode: 'SKU-1', quantity: 2 }], length: 10, width: 20, height: 30, weight: 4, idempotencyKey: 'merge-key' }
await mergeReturnCartons(request)
await getReturnCartonLabel('RC/1')
assert.deepEqual(calls.map((item) => item.url), [
'/overseas-api/pda/v1/last-leg/return-inventory/packing/orders?returnNo=RT%2F1',
'/overseas-api/pda/v1/last-leg/return-inventory/cartons/merge',
'/overseas-api/pda/v1/last-leg/return-inventory/cartons/RC%2F1/label'
'/api/pda/v1/last-leg/return-inventory/packing/orders?returnNo=RT%2F1',
'/api/pda/v1/last-leg/return-inventory/cartons/merge',
'/api/pda/v1/last-leg/return-inventory/cartons/RC%2F1/label'
])
assert.deepEqual(calls[1].data, request)
assert.ok(calls.every((item) => item.authorization === 'Bearer packing-token'))
@@ -30,13 +35,13 @@ test('return packing API uses exact PDA inventory routes without submitting cust
})
test('return packing lookup treats not found as an empty preview', async () => {
session.apiBase = '/overseas-api'; session.token = 'packing-token'
session.apiBase = '/api'; session.token = 'packing-token'
globalThis.uni = { request(options) { options.success({ statusCode: 404, data: { code: 'A0404', message: '没有匹配的可合箱库存' }, header: {} }) } }
assert.deepEqual(await findPackingOrders('MISSING-RETURN'), { customerKey: '', orders: [], skus: [] })
})
test('return packing lookup still rejects real service errors', async () => {
session.apiBase = '/overseas-api'; session.token = 'packing-token'
session.apiBase = '/api'; session.token = 'packing-token'
globalThis.uni = { request(options) { options.success({ statusCode: 500, data: { code: 'B0500', message: '服务暂不可用' }, header: {} }) } }
await assert.rejects(() => findPackingOrders('RT-1'), (error) => error.code === 'B0500')
})
+75
View File
@@ -0,0 +1,75 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
import { parse, compileScript, compileTemplate } from 'vue/compiler-sfc'
const filename = 'src/pages/return-packing/return-packing.vue'
const { descriptor } = parse(readFileSync(new URL('../' + filename, import.meta.url), 'utf8'))
function instance(api = {}) {
let saved = {}, reset = false
const component = vm.runInNewContext(descriptor.script.content.replace(/^\s*import .*$/gm, '').replace('export default', 'component ='), {
component: null, ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {}, ReturnBoxMergeWork: {}, session: {currentUser:{}}, playSuccessSound() {},
updateWarehouseWork(domain, patch) { saved = {...saved,...patch} }, resetWarehouseDomain() { reset = true }, ...api
})
const page = {...component.data(), activePlan:{planNo:'P1'}, busy:true}
for (const [key, method] of Object.entries(component.methods)) page[key] = method.bind(page)
page.loadPlans = () => { page.plans = [{planNo:'P2'}] }
return {page, saved:()=>saved, reset:()=>reset}
}
test('packing completion template compiles without a putaway prerequisite', () => {
compileScript(descriptor, {id:'packing'})
assert.deepEqual(compileTemplate({source:descriptor.template.content,filename,id:'packing'}).errors,[])
assert.equal(descriptor.template.content.includes('确认上架'),false)
})
test('unified scan resolves an actual carton and passes its SKU contents into box work', async () => {
let skuCalls=0
const carton={cartonMark:'BOX1',customerId:'C1',items:[{skuCode:'SKU1',quantity:2}]}
const {page,saved}=instance({scanReturnCarton:async()=>carton,findPackingOrders:async()=>{skuCalls++;return {}}})
page.activePlan=null;page.busy=false;page.selectedOrders=[]
await page.search('BOX1')
assert.equal(page.mode,'BOX');assert.equal(page.initialSource,carton)
assert.equal(saved().initialSource.items[0].quantity,2);assert.equal(skuCalls,0)
})
test('unknown SKU cannot silently choose parcel sources; occupied sources retain the conflict', async () => {
let skuCalls=0, queryError={code:'A0404'}
const {page}=instance({scanReturnCarton:async()=>{throw queryError},findPackingOrders:async()=>{skuCalls++;return {}}})
page.activePlan=null;page.busy=false;page.selectedOrders=[];page.searchCode=''
page.setError=error=>{page.error=error}
await page.search('SKU1')
assert.match(page.error.message,/扫描来源包裹/)
assert.equal(skuCalls,0)
queryError={code:'A0409',message:'箱库存被占用'}
await page.search('BOX1')
assert.equal(page.error.message,'箱库存被占用');assert.equal(skuCalls,0)
})
test('existing target immediately clears work and returns to refreshed plans even during completion', () => {
const {page,reset}=instance()
page.packingCompleted({targetMode:'EXISTING',labels:[{cartonMark:'B'}]})
assert.equal(page.result,null)
assert.equal(page.activePlan,null)
assert.equal(reset(),true)
assert.equal(page.plans[0].planNo,'P2')
})
test('new target retains labels for printing and can return without putaway', () => {
const {page,saved,reset}=instance()
page.packingCompleted({targetMode:'NEW',labels:[{cartonMark:'NEW#1-1'}]})
assert.equal(page.busy,false)
assert.equal(saved().result.labels[0].cartonMark,'NEW#1-1')
assert.equal(reset(),false)
page.busy=false; page.nextPacking()
assert.equal(page.result,null)
assert.equal(reset(),true)
})
test('completed packing can return to plans even when the page busy flag is stale', () => {
const {page,reset}=instance()
page.result={labels:[{cartonMark:'NEW#1-1'}]}
page.nextPacking()
assert.equal(page.result,null)
assert.equal(reset(),true)
assert.equal(page.plans[0].planNo,'P2')
assert.doesNotMatch(descriptor.template.content, /<button[^>]*:disabled[^>]*@tap="nextPacking"/)
})
+11 -11
View File
@@ -5,7 +5,7 @@ import { session } from '../src/stores/session.js'
test('return receipt start normalization keeps only stable business facts', () => {
assert.deepEqual(normalizeReturnReceiptStart({ returnNo: 'RT-001', status: 'PENDING_RECEIPT', expectedItems: [{ skuCode: 'SKU-1', productNameZh: '商品', expectedQuantity: '5', receivedQuantity: 2 }] }), {
returnNo: 'RT-001', status: 'PENDING_RECEIPT', expectedItems: [{ skuCode: 'SKU-1', productNameZh: '商品', expectedQuantity: 5, receivedQuantity: 2 }]
returnNo: 'RT-001', status: 'PENDING_RECEIPT', expectedItems: [{ skuCode: 'SKU-1', productNameZh: '商品', expectedQuantity: 5, receivedQuantity: 2 }], completedPallets: [], photos: []
})
})
@@ -17,32 +17,32 @@ test('SKU candidates do not expose the backend customer identifier to the page',
})
test('confirmed receipt normalization preserves generated carton and batch references', () => {
assert.deepEqual(normalizeReturnReceiptResult({ returnNo: 'RT-001', receiptNo: 'RR-1', receiptBatchNo: 'RB-1', cartonMark: 'RC-1', orderStatus: 'COMPLETED', fullyMatched: true, expectedQuantity: 5, receivedQuantity: 5, signedAt: '2026-09-10T10:00:00' }), {
returnNo: 'RT-001', receiptNo: 'RR-1', receiptBatchNo: 'RB-1', cartonMark: 'RC-1', orderStatus: 'COMPLETED', fullyMatched: true, expectedQuantity: 5, receivedQuantity: 5, signedAt: '2026-09-10T10:00:00'
assert.deepEqual(normalizeReturnReceiptResult({ returnNo: 'RT-001', receiptNo: 'RR-1', receiptBatchNo: 'RB-1', palletCode: 'PLT-1', orderStatus: 'COMPLETED', fullyMatched: true, expectedQuantity: 5, receivedQuantity: 5, signedAt: '2026-09-10T10:00:00' }), {
returnNo: 'RT-001', receiptNo: 'RR-1', receiptBatchNo: 'RB-1', palletCode: 'PLT-1', orderStatus: 'COMPLETED', fullyMatched: true, expectedQuantity: 5, receivedQuantity: 5, signedAt: '2026-09-10T10:00:00'
})
})
test('return receipt API calls only the dedicated PDA routes and preserves the receipt payload', async () => {
const calls = []
session.apiBase = '/overseas-api'; session.token = 'return-token'
session.apiBase = '/api'; session.token = 'return-token'
globalThis.uni = {
request(options) {
calls.push({ method: options.method, url: options.url, data: options.data, authorization: options.header.Authorization })
let data
if (options.url.endsWith('/start')) data = { returnNo: 'RT-001', status: 'PENDING_RECEIPT', expectedItems: [] }
else if (options.url.endsWith('/sku-candidates')) data = { skuCode: 'SKU-1', candidates: [] }
else data = { returnNo: 'RT-001', receiptNo: 'RR-1', receiptBatchNo: 'RB-1', cartonMark: 'RC-1', orderStatus: 'PARTIALLY_RECEIVED', fullyMatched: false, expectedQuantity: 5, receivedQuantity: 2 }
else data = { returnNo: 'RT-001', receiptNo: 'RR-1', receiptBatchNo: 'RB-1', palletCode: 'PLT-1', orderStatus: 'PARTIALLY_RECEIVED', fullyMatched: false, expectedQuantity: 5, receivedQuantity: 2 }
options.success({ statusCode: 200, data: { code: '200', data }, header: {} })
}
}
await startReturnReceipt(' RT-001 ')
await findReturnSkuCandidates(' SKU-1 ')
const confirm = { returnNo: 'RT-001', trackingNo: 'TRACK-1', startedBy: 'SKU', cartonMark: 'RC-1', items: [{ skuCode: 'SKU-1', receivedQuantity: 2 }], idempotencyKey: 'return-key' }
const confirm = { returnNo: 'RT-001', trackingNo: 'TRACK-1', startedBy: 'SKU', palletCode: 'PLT-1', items: [{ skuCode: 'SKU-1', receivedQuantity: 2 }], idempotencyKey: 'return-key' }
await confirmReturnReceipt(confirm)
assert.deepEqual(calls.map((item) => item.url), [
'/overseas-api/pda/v1/last-leg/return-receipts/start',
'/overseas-api/pda/v1/last-leg/return-receipts/sku-candidates',
'/overseas-api/pda/v1/last-leg/return-receipts/confirm'
'/api/pda/v1/last-leg/return-receipts/start',
'/api/pda/v1/last-leg/return-receipts/sku-candidates',
'/api/pda/v1/last-leg/return-receipts/confirm'
])
assert.deepEqual(calls[0].data, { returnNo: 'RT-001' })
assert.deepEqual(calls[1].data, { skuCode: 'SKU-1' })
@@ -52,7 +52,7 @@ test('return receipt API calls only the dedicated PDA routes and preserves the r
})
test('return receipt lookup treats A0404 and HTTP 404 as empty results', async () => {
session.apiBase = '/overseas-api'; session.token = 'return-token'
session.apiBase = '/api'; session.token = 'return-token'
let requestCount = 0
globalThis.uni = {
request(options) {
@@ -66,7 +66,7 @@ test('return receipt lookup treats A0404 and HTTP 404 as empty results', async (
})
test('return receipt lookup still rejects real service errors', async () => {
session.apiBase = '/overseas-api'; session.token = 'return-token'
session.apiBase = '/api'; session.token = 'return-token'
globalThis.uni = {
request(options) {
options.success({ statusCode: 500, data: { code: 'B0500', message: '服务暂不可用' }, header: {} })
+308
View File
@@ -0,0 +1,308 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
const physicalRow = items => ({ items, length: 10, width: 10, height: 10, weight: 1, originalPackaging: true, confirmed: true })
test('box and parcel confirmation opens the camera only after every valid row is confirmed', () => {
for (const type of ['BOX', 'PARCEL']) {
const cameras = []
const page = setup(async () => ({}), new Map(), 'operator:warehouse', { uni: { chooseImage: options => cameras.push(options) } })
page.unitType = type
page.boxRows = [physicalRow({ 'SKU-TEST': 2 }), physicalRow({ 'SKU-TEST': 3 })]
page.boxRows.forEach(row => { row.confirmed = false })
page.confirmBoxRow(0)
assert.equal(page.boxRows[0].confirmed, true)
assert.equal(cameras.length, 0)
page.confirmBoxRow(1)
assert.equal(cameras.length, 1)
assert.deepEqual(Array.from(cameras[0].sourceType), ['camera'])
page.editBoxRow(0)
assert.equal(page.allBoxRowsConfirmed, false)
assert.equal(page.boxRows[0].confirmed, false)
}
})
test('remaining quantity accounts for other drafts and rejects fractional negative or excessive input', () => {
const page = setup(async () => ({}))
page.unitType = 'BOX'
page.boxRows = [physicalRow({ 'SKU-TEST': 2 }), physicalRow({ 'SKU-TEST': 3 })]
assert.equal(page.boxRemaining(0, page.selectedItems[0]), 7)
for (const quantity of ['7.5', '-1', '8']) {
page.setBoxQuantity(0, 'SKU-TEST', quantity)
assert.equal(page.boxRows[0].items['SKU-TEST'], quantity)
assert.equal(page.boxRowValid(0), false)
page.confirmBoxRow(0)
assert.equal(page.boxRows[0].confirmed, false)
}
page.setBoxQuantity(0, 'SKU-TEST', '7')
assert.equal(page.boxRowValid(0), true)
page.resizeBoxRows(3)
assert.equal(page.boxRows[2].originalPackaging, true)
assert.equal(page.boxRows[2].confirmed, false)
})
test('final photo upload waits for explicit confirmation and failed uploads cannot submit', async () => {
for (const failUpload of [false, true]) {
let camera, requests = []
const page = setup(async request => { requests.push(request); return { fullyMatched: true } }, new Map(), 'operator:warehouse', {
uni: { chooseImage: options => { camera = options } },
uploadReturnPalletPhoto: async () => { if (failUpload) throw new Error('upload failed'); return { fileId: 'PHOTO-1' } }
})
page.unitType = 'BOX'; page.boxRows = [physicalRow({ 'SKU-TEST': 10 })]
page.boxRows[0].confirmed = false
page.confirmBoxRow(0)
assert.equal(requests.length, 0)
await camera.success({ tempFilePaths: ['photo.jpg'], tempFiles: [{ size: 100 }] })
assert.equal(requests.length, 0)
await page.confirmReceipt()
assert.equal(requests.length, failUpload ? 0 : 1)
if (failUpload) assert.match(page.error.message, /照片/)
}
})
test('parcel photos accumulate without submitting until explicit receipt confirmation', async () => {
let camera, uploads = 0
const requests = []
const page = setup(async request => { requests.push(request); return { fullyMatched: true } }, new Map(), 'operator:warehouse', {
uni: { chooseImage: options => { camera = options } },
uploadReturnPalletPhoto: async () => ({ fileId: `PHOTO-${++uploads}` })
})
page.unitType = 'PARCEL'; page.unitCode = ''; page.boxRows = [physicalRow({ 'SKU-TEST': 10 })]
page.boxRows[0].confirmed = false
await page.confirmReceipt()
assert.equal(requests.length, 0)
page.confirmBoxRow(0)
await camera.success({ tempFilePaths: ['first.jpg'] })
assert.equal(requests.length, 0)
assert.ok(page.current)
page.choosePhoto()
assert.equal(camera.count, 9)
await camera.success({ tempFilePaths: ['second.jpg', 'third.jpg'] })
assert.equal(page.photoFileIds.length, 3)
assert.equal(requests.length, 0)
await page.confirmReceipt()
assert.equal(requests.length, 1)
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('editing the draft before the camera returns prevents late photo submission', async () => {
let camera, uploads = 0, requests = 0
const page = setup(async () => { requests++; return {} }, new Map(), 'operator:warehouse', {
uni: { chooseImage: options => { camera = options } },
uploadReturnPalletPhoto: async () => { uploads++; return { fileId: 'PHOTO-1' } }
})
page.unitType = 'PARCEL'; page.photoFileIds = ['PHOTO-EXISTING']; page.boxRows = [physicalRow({ 'SKU-TEST': 2 })]
page.choosePhoto(true)
page.editBoxRow(0)
await camera.success({ tempFilePaths: ['late.jpg'] })
assert.equal(uploads, 0)
assert.equal(requests, 0)
})
function setup(confirm, saved = new Map(), scope = 'operator:warehouse', runtime = {}) {
const context = vm.createContext({ reactive: value => value, ReturnLabelConfirmation: {}, PortalHeader: {}, ScanTargetInput: {},
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 } })
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')
const script = page.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1]
const component = vm.runInContext(script.replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), context)
vm.runInContext(`restoreWarehouseWork(${JSON.stringify(scope)})`, context)
const instance = Object.assign({}, component.methods, {
unitType: 'PALLET', palletBoxCount: 1, canConfirm: true, busy: false,
current: { returnNo: 'RT-TEST', expectedItems: [{ skuCode: 'SKU-TEST', expectedQuantity: 10, receivedQuantity: 0 }] },
selectedItems: [{ skuCode: 'SKU-TEST', expectedQuantity: 10, receivedQuantity: 0 }], photoFileIds: [], photoNames: [], photoUrls: {}, trackingNo: ' TRACK-TEST ', startedBy: 'SKU',
palletCode: ' PLT-TEST ', palletLength: 10, palletWidth: 10, palletHeight: 10, palletWeight: 1,
quantity: () => 2, boxCount: () => 1
})
Object.defineProperty(instance, 'receiptBatch', { get: () => vm.runInContext('warehouseWork.returnReceipt.pendingBatch', context) })
Object.defineProperty(instance, 'allBoxRowsConfirmed', { get: () => component.computed.allBoxRowsConfirmed.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 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)
assert.equal(instance.selectedItems.length, 2)
assert.equal(instance.selectedItems[1].skuCode, 'SKU-B')
assert.equal(component.computed.activeSkuCode.call(instance), 'SKU-A')
instance.selectedSku = ''
assert.equal(component.computed.activeSkuCode.call(instance), 'SKU-A')
instance.selectedSku = 'SKU-FROM-PREVIOUS-ORDER'
assert.equal(component.computed.activeSkuCode.call(instance), 'SKU-A')
})
test('PALLET page submits the required palletCode through the actual confirmation method', async () => {
const calls = []
const page = setup(async payload => { calls.push(JSON.parse(JSON.stringify(payload))); return { palletCode: payload.palletCode } })
await page.confirmReceipt()
assert.equal(calls.length, 1)
assert.equal(calls[0].palletCode, 'PLT-TEST')
assert.equal(calls[0].unitCode, undefined)
assert.deepEqual(Object.keys(calls[0]).sort(), ['height', 'idempotencyKey', 'items', 'length', 'palletCode', 'returnNo', 'startedBy', 'totalBoxCount', 'trackingNo', 'unitType', 'weight', 'width'].sort())
assert.equal(calls[0].unitType, 'PALLET')
assert.equal(calls[0].totalBoxCount, 1)
assert.deepEqual(Object.keys(calls[0].items[0]).sort(), ['boxCount', 'receivedQuantity', 'skuCode'].sort())
assert.equal(calls[0].returnNo, 'RT-TEST')
assert.equal(calls[0].trackingNo, 'TRACK-TEST')
assert.equal(calls[0].items[0].receivedQuantity, 2)
assert.ok(calls[0].idempotencyKey)
assert.equal(page.current, null)
})
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 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
assert.equal(component.computed.canConfirm.call(state), false)
const calls = []
const page = setup(async payload => { calls.push(payload); return { unitType: 'PARCEL' } })
page.unitType = 'PARCEL'; page.photoFileIds = ['PHOTO-EXISTING']; page.unitCode = ''; page.trackingNo = ''; page.boxRows = [physicalRow({ 'SKU-TEST': 2 })]
await page.confirmReceipt()
assert.equal(calls.length, 1)
assert.equal(calls[0].trackingNo, undefined)
assert.equal(calls[0].unitCode, undefined)
assert.equal(calls[0].palletCode, undefined)
})
test('parcel waybill is submitted as trackingNo and leaves unitCode for the server', async () => {
const calls = []
const page = setup(async payload => { calls.push(payload); return { unitType: 'PARCEL' } })
page.unitType = 'PARCEL'; page.photoFileIds = ['PHOTO-EXISTING']; page.unitCode = ' sf561626 '; page.trackingNo = 'stale-from-pallet'; page.boxRows = [physicalRow({ 'SKU-TEST': 2 })]
await page.confirmReceipt()
assert.equal(calls[0].trackingNo, 'sf561626')
assert.equal(calls[0].unitCode, undefined)
assert.equal(calls[0].palletCode, undefined)
})
test('batch waybill scans stay on the single parcel waybill input', () => {
const page = setup(async () => ({}))
page.unitType = 'PARCEL'; page.photoFileIds = ['PHOTO-EXISTING']; page.activeTarget = 'unit'; page.palletCode = ''
page.handleScan('WAYBILL-ONE')
assert.equal(page.unitCode, 'WAYBILL-ONE'); assert.equal(page.palletCode, '')
assert.equal(page.activeTarget, 'unit')
page.handleScan('WAYBILL-CORRECTED')
assert.equal(page.unitCode, 'WAYBILL-CORRECTED')
})
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 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
assert.equal(component.computed.canConfirm.call(state), true)
state.boxRows[1].items = {}
assert.equal(component.computed.canConfirm.call(state), false)
})
test('one submission signs multiple mixed-SKU parcels sharing one waybill and independent labels', async () => {
const calls = []
const page = setup(async request => {
calls.push(JSON.parse(JSON.stringify(request)))
return { unitType: 'PARCEL', labels: [{ cartonMark: `RP-${calls.length}`, printableHtml: 'label' }], fullyMatched: false }
})
page.unitType = 'PARCEL'; page.photoFileIds = ['PHOTO-EXISTING']; page.unitCode = ' WAYBILL-BATCH '; page.trackingNo = 'stale'; page.selectedItems = [{ skuCode: 'SKU-TEST', expectedQuantity: 10 }, { skuCode: 'SKU-B', expectedQuantity: 10 }]
page.boxRows = [physicalRow({ 'SKU-TEST': 1, 'SKU-B': 2 }), physicalRow({ 'SKU-TEST': 3 })]
await page.confirmReceipt()
assert.equal(calls.length, 2)
assert.deepEqual(calls[0].items, [{ skuCode: 'SKU-TEST', receivedQuantity: 1 }, { skuCode: 'SKU-B', receivedQuantity: 2 }])
assert.deepEqual(calls[1].items, [{ skuCode: 'SKU-TEST', receivedQuantity: 3 }])
assert.equal(calls[0].trackingNo, 'WAYBILL-BATCH'); assert.equal(calls[1].trackingNo, 'WAYBILL-BATCH')
assert.notEqual(calls[0].idempotencyKey, calls[1].idempotencyKey)
assert.deepEqual(Array.from(page.result.labels, row => row.cartonMark), ['RP-1', 'RP-2'])
})
test('failed multi-parcel batch resumes immutable requests without re-signing a successful parcel', async () => {
const saved = new Map(), calls = []
let failSecond = true
const confirm = async request => {
calls.push(JSON.parse(JSON.stringify(request)))
if (request.items[0].receivedQuantity === 2 && failSecond) throw new Error('response lost')
return { unitType: 'PARCEL', labels: [{ cartonMark: `RP-${request.items[0].receivedQuantity}`, printableHtml: 'label' }], fullyMatched: false }
}
const page = setup(confirm, saved)
page.unitType = 'PARCEL'; page.photoFileIds = ['PHOTO-EXISTING']; page.unitCode = 'WAYBILL-BATCH'; page.boxRows = [physicalRow({ 'SKU-TEST': 1 }), physicalRow({ 'SKU-TEST': 2 })]
await page.confirmReceipt()
assert.equal(page.receiptBatch.entries[0].result.labels[0].cartonMark, 'RP-1')
failSecond = false
const restored = setup(confirm, saved)
await restored.confirmReceipt()
assert.equal(calls.length, 3)
assert.equal(calls[1].idempotencyKey, calls[2].idempotencyKey)
assert.deepEqual(calls[1], calls[2])
assert.equal(restored.result.labels.length, 2)
})
test('partial receipt continues the same order with fresh pallet measurements', async () => {
const page = setup(async () => ({}))
page.finishReceipt([{ fullyMatched: false }])
assert.equal(page.result.continuationReturnNo, 'RT-TEST')
let reopened
page.openOrderValue = async returnNo => { reopened = returnNo; page.current = { returnNo } }
await page.nextReceipt()
assert.equal(reopened, 'RT-TEST')
assert.equal(page.result, null)
assert.equal(page.palletLength, '')
assert.equal(page.palletWeight, '')
})
test('PALLET response failure keeps the draft and same idempotency key for retry', async () => {
const calls = []
const page = setup(async payload => { calls.push(JSON.parse(JSON.stringify(payload))); throw new Error('Response lost') })
await page.confirmReceipt()
assert.equal(page.current.returnNo, 'RT-TEST')
assert.equal(page.busy, false)
assert.equal(page.error.message, 'Response lost')
await page.confirmReceipt()
assert.equal(calls.length, 2)
assert.equal(calls[0].idempotencyKey, calls[1].idempotencyKey)
})
test('continue signing returns to SKU scanning and accepts a fresh scan', () => {
const page = setup(async () => ({}))
page.finishReceipt([{ fullyMatched: false }])
page.lookupCode = 'PREVIOUS-SKU'
page.selectedSku = 'PREVIOUS-SKU'
page.palletCheck = { palletCode: 'PLT-TEST' }
page.photoFileIds = ['previous-photo']
page.busy = true
page.continueScanning()
assert.ok(page.result)
page.busy = false
page.continueScanning()
assert.equal(page.result, null)
assert.equal(page.current, null)
assert.equal(page.candidates, null)
assert.equal(page.activeTarget, 'lookup')
assert.equal(page.entryMode, 'SKU')
assert.equal(page.lookupCode, '')
assert.equal(page.selectedSku, '')
assert.equal(page.palletCheck, null)
assert.equal(page.palletLength, '')
assert.equal(page.photoFileIds.length, 0)
let scanned
page.lookup = value => { scanned = value }
page.handleScan('NEXT-SKU')
assert.equal(scanned, 'NEXT-SKU')
assert.equal(page.lookupCode, 'NEXT-SKU')
})
+47 -2
View File
@@ -4,15 +4,23 @@ import { readFile } from 'node:fs/promises'
import {
SCAN_BROADCAST_ACTION,
SCAN_BROADCAST_ACTIONS,
SCAN_BROADCAST_EXTRA,
isDuplicateScan,
normalizeScanValue,
resetScanDeduplication
resetScanDeduplication,
startScanBroadcast,
stopScanBroadcast
} from '../src/utils/scan-broadcast.js'
test('scanner uses the confirmed PDA broadcast contract', () => {
assert.equal(SCAN_BROADCAST_ACTION, 'android.intent.action.SCANRESULT')
assert.equal(SCAN_BROADCAST_EXTRA, 'value')
assert.deepEqual(SCAN_BROADCAST_ACTIONS, [
'android.intent.action.SCANRESULT',
'com.android.server.scannerservice.broadcast',
'com.sunmi.scanner.ACTION_DATA_CODE_RECEIVED'
])
assert.equal(normalizeScanValue(' EL-38201\r\n'), 'EL-38201')
assert.equal(normalizeScanValue(null), '')
})
@@ -25,7 +33,7 @@ test('scanner source keeps receiver diagnostics before page routing', async () =
assert.match(source, /receive failed/)
assert.match(source, /receiver registered/)
assert.match(source, /registration failed/)
assert.match(source, /activity\.getPackageName\(\)/)
assert.match(source, /registeredActivity\.getPackageName\(\)/)
})
test('scanner drops only immediate duplicate broadcasts', () => {
@@ -35,3 +43,40 @@ test('scanner drops only immediate duplicate broadcasts', () => {
assert.equal(isDuplicateScan('EL-38201', 1100), false)
assert.equal(isDuplicateScan('EL-38202', 1110), false)
})
test('an old page disposer cannot unregister the receiver owned by the newly shown page', () => {
const registered = []
const unregistered = []
class IntentFilter { addAction(action) { this.action = action } }
const activity = {
registerReceiver(value) { registered.push(value) },
unregisterReceiver(value) { unregistered.push(value) },
getPackageName() { return 'com.baiyuelogistics.pda.overseas' }
}
globalThis.plus = { android: {
runtimeMainActivity: () => activity,
importClass(name) {
if (name === 'android.content.IntentFilter') return IntentFilter
if (name === 'android.os.Build') return { VERSION: { SDK_INT: 32 } }
return name
},
implements: (_name, implementation) => implementation
} }
try {
const stopFirst = startScanBroadcast({ onScan() {} })
const first = registered[0]
const stopSecond = startScanBroadcast({ onScan() {} })
const second = registered[1]
assert.deepEqual(unregistered, [first])
stopFirst()
assert.deepEqual(unregistered, [first, first])
assert.ok(!unregistered.includes(second))
stopSecond()
assert.equal(unregistered.at(-1), second)
} finally {
stopScanBroadcast()
delete globalThis.plus
}
})
+37 -20
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
import {
PDA_AUTHORITY, PDA_ROLE, applyWarehouseProfile, replaceTokens, restoreSession, session,
decodeAccessToken, signInWithTokens, signOut, validatePdaTokenPair
decodeAccessToken, signInWithTokens, signOut, validatePdaToken
} from '../src/stores/session.js'
import { receiving } from '../src/stores/receiving.js'
import { fulfillment, restoreFulfillmentDraft, updateFulfillmentDraft } from '../src/stores/fulfillment.js'
@@ -24,9 +24,9 @@ function tokenPair(overrides = {}) {
overseas_warehouse_id: 900, exp: future()
}
const domestic = { ...base, username: 'picker-42', ...(overrides.domestic || {}) }
const overseas = { ...base, ...(overrides.overseas || {}) }
return {
accessToken: jwt(domestic), overseasAccessToken: jwt(overseas),
accessToken: jwt(domestic),
refreshToken: overrides.refreshToken ?? 'refresh-42',
roles: Object.hasOwn(overrides, 'roles') ? overrides.roles : [PDA_ROLE],
authorities: Object.hasOwn(overrides, 'authorities') ? overrides.authorities : [PDA_AUTHORITY]
@@ -46,17 +46,17 @@ function storage() {
test('valid platform warehouse picker tokens freeze the same operator and warehouse', () => {
storage(); signOut()
const tokens = tokenPair()
const pair = validatePdaTokenPair(tokens.accessToken, tokens.overseasAccessToken, tokens)
const pair = validatePdaToken(tokens.accessToken, tokens)
assert.equal(pair.warehouseId, '900')
const user = signInWithTokens(tokens)
assert.deepEqual(user.roles, [PDA_ROLE])
assert.equal(user.warehouseId, '900')
assert.equal(user.warehouse, '')
assert.equal(session.tenantId, '0')
assert.equal(session.token, tokens.overseasAccessToken)
assert.equal(session.token, tokens.accessToken)
assert.deepEqual(session.authorization, { roles: [PDA_ROLE], authorities: [PDA_AUTHORITY] })
assert.equal(decodeAccessToken(tokens.overseasAccessToken).roles, undefined)
assert.equal(decodeAccessToken(tokens.overseasAccessToken).authorities, undefined)
assert.equal(decodeAccessToken(tokens.accessToken).roles, undefined)
assert.equal(decodeAccessToken(tokens.accessToken).authorities, undefined)
})
test('JWT decoding preserves unquoted snowflake subjects and warehouse IDs exactly', () => {
@@ -81,11 +81,11 @@ test('login fails closed for wrong role, authority, warehouse, application, or t
tokenPair({ authorities: [] }),
tokenPair({ roles: null }),
tokenPair({ authorities: null }),
tokenPair({ overseas: { overseas_warehouse_id: null } }),
tokenPair({ domestic: { overseas_warehouse_id: null } }),
tokenPair({ domestic: { app_code: 'MERCHANT', tenant_id: 100 } }),
tokenPair({ overseas: { user_id: 99 } }),
tokenPair({ overseas: { membership_id: 99 } }),
tokenPair({ overseas: { overseas_warehouse_id: 901 } })
tokenPair({ domestic: { user_id: null } }),
tokenPair({ domestic: { membership_id: null } }),
tokenPair({ domestic: { exp: null } })
]
for (const tokens of invalidPairs) {
assert.throws(() => signInWithTokens(tokens))
@@ -93,22 +93,40 @@ test('login fails closed for wrong role, authority, warehouse, application, or t
}
})
test('refresh replacement validates both tokens before atomically changing session', () => {
test('refresh replacement validates the unified token before atomically changing session', () => {
storage(); signOut()
const original = tokenPair()
signInWithTokens(original)
const before = { domestic: session.domesticToken, overseas: session.token, refresh: session.refreshToken }
const before = { access: session.token, refresh: session.refreshToken }
const invalid = tokenPair({ authorities: [], refreshToken: 'invalid-refresh' })
assert.throws(() => replaceTokens(invalid), /lastleg:pda:operate/)
assert.deepEqual({ domestic: session.domesticToken, overseas: session.token, refresh: session.refreshToken }, before)
assert.deepEqual({ access: session.token, refresh: session.refreshToken }, before)
const replacement = tokenPair({ refreshToken: 'refresh-43' })
replaceTokens(replacement)
assert.equal(session.domesticToken, replacement.accessToken)
assert.equal(session.token, replacement.overseasAccessToken)
assert.equal(session.token, replacement.accessToken)
assert.equal(session.refreshToken, 'refresh-43')
})
test('refresh cannot carry an old operation into another user, membership, tenant or warehouse', () => {
storage(); signOut(); signInWithTokens(tokenPair())
const original = session.token
for (const domestic of [{ user_id: 99 }, { membership_id: 85 }, { tenant_id: 1 }, { overseas_warehouse_id: 901 }]) {
assert.throws(() => replaceTokens(tokenPair({ domestic })), /主体或绑定仓库已变更|不属于平台仓库作业端/)
assert.equal(session.token, original)
assert.equal(session.currentUser.warehouseId, '900')
}
})
test('legacy dual-token storage requires a fresh login', () => {
const values = storage(); signOut(); signInWithTokens(tokenPair())
const saved = structuredClone([...values.values()][0])
delete saved.version
signOut(); values.set('overseas-pda-auth-session', saved)
assert.equal(restoreSession(), false)
assert.equal(session.currentUser, null)
})
test('restore rebuilds identity from claims and removes expired or tampered storage', () => {
const values = storage(); signOut()
signInWithTokens(tokenPair())
@@ -123,14 +141,14 @@ test('restore rebuilds identity from claims and removes expired or tampered stor
assert.equal(restoreSession(), false)
assert.equal(values.has('overseas-pda-auth-session'), false)
const tampered = { ...saved, token: tokenPair({ overseas: { overseas_warehouse_id: 901 } }).overseasAccessToken }
const tampered = { ...saved, token: tokenPair({ domestic: { overseas_warehouse_id: 901 } }).accessToken }
signOut(); values.set('overseas-pda-auth-session', tampered)
assert.equal(restoreSession(), false)
assert.equal(session.currentUser, null)
assert.equal(values.has('overseas-pda-auth-session'), false)
const expired = tokenPair({ domestic: { exp: 1 }, overseas: { exp: 1 } })
values.set('overseas-pda-auth-session', { ...saved, domesticToken: expired.accessToken, token: expired.overseasAccessToken })
const expired = tokenPair({ domestic: { exp: 1 } })
values.set('overseas-pda-auth-session', { ...saved, token: expired.accessToken })
assert.equal(restoreSession(), false)
assert.equal(values.has('overseas-pda-auth-session'), false)
})
@@ -152,7 +170,6 @@ test('sign out clears tokens, frozen warehouse identity, and every receiving or
signOut()
assert.equal(session.currentUser, null)
assert.equal(session.token, '')
assert.equal(session.domesticToken, '')
assert.equal(session.refreshToken, '')
assert.deepEqual(session.authorization, { roles: [], authorities: [] })
assert.equal(values.has('overseas-pda-auth-session'), false)
+50
View File
@@ -0,0 +1,50 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import vm from 'node:vm'
import { getDispatchHandoverStatus, normalizeHandoverStatus } from '../src/api/trip-handover.js'
import { session } from '../src/stores/session.js'
const wire = () => ({ dispatchNo: 'PC-ONE', tripNo: 'TR-ONE', tripStatus: 'IN_TRANSIT', tripRecordVersion: '90071992547409931',
originalDeparture: { vehiclePlate: 'ORIGINAL', driverName: '原司机' },
deliveries: [{ deliveryNo: 'DL-DONE', status: 'DELIVERED', packages: '10', remainingPackages: 0, terminalAt: '2026-10-03T01:00:00Z', transport: { vehiclePlate: 'ORIGINAL', driverName: '原司机' } },
{ deliveryNo: 'DL-PENDING', status: 'WAIT_DELIVERY', packages: 20, remainingPackages: 16, terminalAt: null, transport: { vehiclePlate: 'NEW', driverName: '接替司机' } }], latestHandover: null })
test('PDA query stays a GET in the dedicated namespace and never supplies customer or warehouse', 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: wire() }, header: {} }) } }
const result = await getDispatchHandoverStatus('PC-ONE')
assert.equal(calls.length, 1); assert.equal(calls[0].method, 'GET'); assert.match(calls[0].url, /\/pda\/v1\/last-leg\/dispatches\/PC-ONE\/handover-status$/)
assert.ok(!calls[0].data?.customerId); assert.ok(!calls[0].data?.warehouseId)
assert.equal(result.tripRecordVersion, '90071992547409931'); assert.equal(result.deliveries[0].transport.vehiclePlate, 'ORIGINAL'); assert.equal(result.deliveries[1].transport.vehiclePlate, 'NEW')
})
test('normalization exposes only business references and does not replace missing fields with internal IDs', () => {
const data = wire(); data.customerId = 'secret-customer'; data.originalDeparture = { vehicleId: 'secret-vehicle' }; data.deliveries[0].id = 'secret-id'
const value = normalizeHandoverStatus(data)
assert.doesNotMatch(JSON.stringify(value), /secret-/); assert.equal(value.originalDeparture.vehiclePlate, '')
assert.equal(value.deliveries[0].packages, 10)
assert.equal(value.deliveries[1].remainingPackages, 16)
for (const remainingPackages of [null, '', -1, 21, '1.5']) assert.throws(() => normalizeHandoverStatus({ ...data, deliveries: [{ ...data.deliveries[1], remainingPackages }] }), /剩余箱数无效/)
for (const packages of [null, '', -1, 0, '1.5']) assert.throws(() => normalizeHandoverStatus({ ...data, deliveries: [{ ...data.deliveries[0], packages }] }), /箱数无效/)
})
test('a response for another dispatch is rejected', async () => {
session.apiBase = '/gateway-api'; session.token = 'test-token'
globalThis.uni = { request(options) { options.success({ statusCode: 200, data: { code: '200', data: wire() }, header: {} }) } }
await assert.rejects(getDispatchHandoverStatus('PC-OTHER'), /不属于当前排车/)
})
test('the component ignores stale responses after switching task and has no write commands', async () => {
const file = await readFile(new URL('../src/components/DispatchHandoverStatus.vue', import.meta.url), 'utf8')
const pending = []
const context = { getDispatchHandoverStatus: () => new Promise(resolve => pending.push(resolve)), toDispatchError: error => ({ message: error.message }) }
const source = file.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^import .*$/gm, '').replace('export default', 'component =')
vm.runInNewContext(source, context)
const component = context.component, state = { ...component.data(), dispatchNo: 'PC-OLD' }
const first = component.methods.load.call(state)
state.dispatchNo = 'PC-NEW'; const second = component.methods.load.call(state)
pending[1]({ dispatchNo: 'PC-NEW' }); await second
pending[0]({ dispatchNo: 'PC-OLD' }); await first
assert.equal(state.status.dispatchNo, 'PC-NEW')
assert.doesNotMatch(file, /confirmTripHandover|departDispatch|pickDispatchItem|POST/)
const page = await readFile(new URL('../src/pages/dispatch/dispatch.vue', import.meta.url), 'utf8')
assert.match(page, /DispatchHandoverStatus v-if="departed" :dispatch-no="current.dispatchNo"/)
})
+116
View File
@@ -0,0 +1,116 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import { PDA_ROLE, PDA_AUTHORITY, session, signInWithTokens, signOut, getSessionRevision } from '../src/stores/session.js'
import { receiving } from '../src/stores/receiving.js'
import { refresh, getPendingRefresh } from '../src/api/auth.js'
import { overseasRequest } from '../src/api/overseas.js'
const helperUrl = new URL('../src/api/logout-session.js', import.meta.url)
const logoutHelper = fs.existsSync(helperUrl) ? await import(helperUrl) : {}
const jwt = claims => [Buffer.from('{"alg":"none"}').toString('base64url'), Buffer.from(JSON.stringify(claims)).toString('base64url'), 'signature'].join('.')
function tokens(version='old', identity={}) { return {accessToken:jwt({user_id:42,membership_id:84,tenant_id:0,app_code:'PLATFORM',overseas_warehouse_id:900,exp:Math.floor(Date.now()/1000)+3600,version,...identity}),refreshToken:`refresh-${version}`,roles:[PDA_ROLE],authorities:[PDA_AUTHORITY]} }
const ok = data => ({statusCode:200,data:{code:'200',data},header:{}})
function prepare(){const values=new Map(),calls=[],launches=[],toasts=[];globalThis.uni={setStorageSync:(k,v)=>values.set(k,JSON.parse(JSON.stringify(v))),getStorageSync:k=>values.get(k),removeStorageSync:k=>values.delete(k),reLaunch:o=>launches.push(o.url),showToast:o=>toasts.push(o.title)};signOut();const original=tokens();signInWithTokens(original);receiving.current={containerNo:'ORIGINAL'};return {values,calls,launches,toasts,original}}
function page(){const source=fs.readFileSync(new URL('../src/pages/profile/profile.vue',import.meta.url),'utf8').match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^\s*import[^\n]*$/gm,'').replace('export default','return');return new Function('PortalHeader','PopupHeader','signOut','logoutCurrentSession','getSessionRevision','session',source)({}, {},signOut,logoutHelper.logoutCurrentSession,getSessionRevision,session)}
test('actual profile confirmation clears drafts and revokes captured session before reporting success',async()=>{
const state=prepare();uni.request=o=>{state.calls.push(o);assert.equal(session.token,'');assert.equal(receiving.current,null);o.success(ok(null))}
const context={user:session.currentUser,closeLogout(){this.closed=true}}
await page().methods.logout.call(context)
assert.equal(state.calls.length,1)
assert.match(state.calls[0].url,/\/auth\/logout$/)
assert.deepEqual(state.calls[0].data,{refreshToken:state.original.refreshToken})
assert.equal(state.calls[0].header.Authorization,`Bearer ${state.original.accessToken}`)
assert.equal(state.values.has('overseas-pda-auth-session'),false)
})
const denied = (statusCode=401,code='A0401') => ({statusCode,data:{code,message:'denied',requestId:'logout-request',traceId:'logout-trace'},header:{}})
test('explicit expired-access 401 refreshes captured credentials once and revokes without restoring local tokens',async()=>{
const state=prepare(),replacement=tokens('rotated')
session.token=tokens('expired',{exp:1}).accessToken
uni.request=o=>{state.calls.push(o);assert.equal(session.token,'');if(state.calls.length===1)o.success(denied());else if(o.url.endsWith('/auth/refresh')){assert.deepEqual(o.data,{refreshToken:state.original.refreshToken});assert.equal(o.header.Authorization,undefined);o.success(ok(replacement))}else{o.success(ok(null));assert.equal(o.header.Authorization,`Bearer ${replacement.accessToken}`);assert.deepEqual(o.data,{refreshToken:replacement.refreshToken})}}
const result=await logoutHelper.logoutCurrentSession()
assert.equal(result.revoked,true);assert.equal(state.calls.length,3);assert.equal(session.currentUser,null);assert.equal(state.values.size,0)
})
test('unknown network and 5xx results never trigger refresh or blind logout replay',async()=>{
for(const failure of ['network','server','business']){
const state=prepare();uni.request=o=>{state.calls.push(o);if(failure==='network')o.fail({errMsg:'connection lost'});else o.success(denied(failure==='server'?503:200,failure==='server'?'200':'A0401'))}
await assert.rejects(logoutHelper.logoutCurrentSession(),error=>failure==='network'?error.network===true:error.statusCode===(failure==='server'?503:200))
assert.equal(state.calls.length,1);assert.equal(session.token,'');assert.equal(receiving.current,null)
}
})
test('refresh failure preserves diagnostic evidence and does not send a second revoke',async()=>{
const state=prepare();uni.request=o=>{state.calls.push(o);o.success(state.calls.length===1?denied():denied(503,'S0503'))}
await assert.rejects(logoutHelper.logoutCurrentSession(),error=>error.code==='S0503'&&error.requestId==='logout-request'&&error.traceId==='logout-trace')
assert.equal(state.calls.length,2);assert.equal(getPendingRefresh(state.original.refreshToken),null);assert.equal(session.token,'')
})
test('different user membership or warehouse from logout refresh cannot revoke another session',async()=>{
for(const identity of [{user_id:43},{membership_id:85},{overseas_warehouse_id:901}]){
const state=prepare();uni.request=o=>{state.calls.push(o);o.success(state.calls.length===1?denied():ok(tokens('wrong',identity)))}
await assert.rejects(logoutHelper.logoutCurrentSession(),/不同作业主体/);assert.equal(state.calls.length,2);assert.equal(state.values.size,0)
}
})
test('missing refreshed credentials fail closed without local restoration',async()=>{
for(const field of ['refreshToken','accessToken']){
const state=prepare(),replacement=tokens('invalid');delete replacement[field]
uni.request=o=>{state.calls.push(o);o.success(state.calls.length===1?denied():ok(replacement))}
await assert.rejects(logoutHelper.logoutCurrentSession());assert.equal(state.calls.length,2);assert.equal(session.token,'');assert.equal(state.values.size,0)
}
})
test('revocation-only refresh does not load permissions or recreate a login when permission snapshots are absent',async()=>{
const state=prepare(),replacement=tokens('revocation-only');delete replacement.roles;delete replacement.authorities
uni.request=o=>{state.calls.push(o);o.success(state.calls.length===1?denied():o.url.endsWith('/auth/refresh')?ok(replacement):ok(null))}
assert.equal((await logoutHelper.logoutCurrentSession()).revoked,true)
assert.equal(state.calls.length,3);assert.equal(session.token,'');assert.deepEqual(session.authorization,{roles:[],authorities:[]});assert.equal(state.values.size,0)
})
test('second revoke 401 is terminal and cannot start another refresh loop',async()=>{
const state=prepare();uni.request=o=>{state.calls.push(o);o.success(o.url.endsWith('/auth/refresh')?ok(tokens('rotated')):denied())}
await assert.rejects(logoutHelper.logoutCurrentSession(),error=>error.statusCode===401)
assert.equal(state.calls.length,3);assert.equal(state.calls.filter(o=>o.url.endsWith('/auth/refresh')).length,1)
})
test('profile duplicate confirmation shares its pending operation',async()=>{
const state=prepare();uni.request=o=>state.calls.push(o)
const context={user:session.currentUser,closeLogout(){this.closed=true}},methods=page().methods
const first=methods.logout.call(context),second=methods.logout.call(context)
assert.equal(first,second);assert.equal(state.calls.length,1);assert.equal(state.launches.length,1)
state.calls[0].success(ok(null));await first
})
test('old logout result cannot clear redirect or warn over a subsequent new login',async()=>{
for(const failed of [false,true]){
const state=prepare();uni.request=o=>state.calls.push(o)
const context={user:session.currentUser,closeLogout(){}},pending=page().methods.logout.call(context)
const replacement=tokens('new-login',{user_id:43,membership_id:85,overseas_warehouse_id:901})
signInWithTokens(replacement);receiving.current={containerNo:'NEW-USER'}
if(failed)state.calls[0].fail({errMsg:'old connection lost'});else state.calls[0].success(ok(null))
await pending
assert.equal(session.token,replacement.accessToken);assert.equal(receiving.current.containerNo,'NEW-USER');assert.equal(state.launches.length,1);assert.equal(state.toasts.length,0)
}
})
test('logout joins actual in-flight business refresh and revokes its rotation while preserving a new login',async()=>{
const state=prepare();let refreshRequest
uni.request=o=>{state.calls.push(o);if(o.url.endsWith('/auth/refresh'))refreshRequest=o;else if(o.url.endsWith('/auth/logout'))o.success(ok(null));else o.success(denied())}
const business=overseasRequest('GET','/pda/v1/last-leg/receiving-containers/current').catch(error=>error)
await new Promise(resolve=>setImmediate(resolve));assert.ok(refreshRequest)
const logout=logoutHelper.logoutCurrentSession()
const newer=tokens('new-login',{user_id:43,membership_id:85,overseas_warehouse_id:901});signInWithTokens(newer)
const rotated=tokens('rotated-old');refreshRequest.success(ok(rotated))
assert.equal((await logout).revoked,true);assert.match((await business).message,/会话已变更/)
assert.equal(state.calls.filter(o=>o.url.endsWith('/auth/refresh')).length,1)
const revoked=state.calls.find(o=>o.url.endsWith('/auth/logout'));assert.deepEqual(revoked.data,{refreshToken:rotated.refreshToken});assert.equal(revoked.header.Authorization,`Bearer ${rotated.accessToken}`)
assert.equal(session.token,newer.accessToken);assert.deepEqual(state.launches,[])
})
test('unknown in-flight refresh never rotates captured credentials again or reports remote success',async()=>{
const state=prepare();uni.request=o=>state.calls.push(o)
const inFlight=refresh(state.original.refreshToken).catch(error=>error)
const logout=logoutHelper.logoutCurrentSession();const rejected=assert.rejects(logout,error=>error.network===true)
state.calls[0].fail({errMsg:'refresh result unknown'});await rejected;await inFlight
assert.equal(state.calls.length,1);assert.equal(session.token,'');assert.equal(getPendingRefresh(state.original.refreshToken),null)
})
test('ordinary local signOut and cancelled profile dialog never contact Auth',()=>{
const state=prepare();uni.request=o=>state.calls.push(o)
page().methods.closeLogout.call({logoutOpen:true,$refs:{logoutPopup:{close(){}}}})
assert.equal(session.token,state.original.accessToken);signOut();assert.equal(state.calls.length,0);assert.equal(session.token,'')
})
test('no captured credentials require no remote request',async()=>{
const state=prepare();signOut();uni.request=o=>state.calls.push(o)
assert.equal((await logoutHelper.logoutCurrentSession()).revoked,false);assert.equal(state.calls.length,0)
})
+153
View File
@@ -0,0 +1,153 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
test('warehouse transfer PDA API exposes candidates and palletize operations', async () => {
const source = await import('../src/api/warehouse-transfers.js')
assert.equal(typeof source.listWarehouseTransferCandidates, 'function')
assert.equal(typeof source.getWarehouseTransferDetail, 'function')
assert.equal(typeof source.palletizeWarehouseTransfer, 'function')
assert.equal(typeof source.saveWarehouseTransferPalletDraft, 'function')
assert.equal(typeof source.generateWarehouseTransferPallet, 'function')
assert.equal(typeof source.bindWarehouseTransferPallet, 'function')
assert.equal(typeof source.deleteWarehouseTransferPallet, 'function')
assert.equal(typeof source.uploadWarehouseTransferMeasurementPhoto, 'function')
assert.equal(typeof source.listWarehouseTransferMeasurementPhotos, 'function')
assert.equal(typeof source.downloadWarehouseTransferMeasurementPhoto, 'function')
assert.equal(typeof source.downloadWarehouseTransferPalletLabel, 'function')
})
test('warehouse transfer workbench entry is registered', async () => {
const { MODULES } = await import('../src/data/overseas-workbench.js')
const operation = MODULES.flatMap((m) => m.operations).find((item) => item.key === 'warehouseTransferList')
assert.equal(operation?.available, true)
assert.equal(operation?.route, '/pages/warehouse-transfer/warehouse-transfer?mode=list')
const work = MODULES.flatMap((m) => m.operations).find((item) => item.key === 'warehouseTransferWork')
assert.equal(work?.route, '/pages/warehouse-transfer/warehouse-transfer?mode=work')
})
test('warehouse transfer page loads detail without exposing internal identifiers', async () => {
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, /!selected && entryMode === 'list'/)
assert.match(page, /entryMode === 'work' && taskNo/)
assert.match(page, /mode=work&task=\$\{encodeURIComponent\(String\(item\.transferNo\)\)\}/)
assert.match(page, /v-if="!selected && entryMode === 'list'"/)
assert.match(page, /作业详情/)
assert.match(page, /backToCandidates\(\)/)
assert.match(page, /backToCandidates\(\) \{ if \(this\.submitting.*this\.entryMode = 'list'/)
assert.match(page, /存在未完结的测量单/)
assert.match(page, /title: '开始测量'/)
assert.match(page, /确认开始测量转运单/)
assert.match(page, /warehouseWork\.warehouseTransfer\.currentNo/)
assert.match(page, /resetWarehouseDomain\('warehouseTransfer'\)/)
assert.match(page, /statusLabel\(status, transferNo = ''\) \{ if \(String\(status\) === 'WAIT_PDA_RECEIVING'/)
assert.match(page, /listWarehouseTransferCandidates\(\)\)\.map\(\(item\) => String\(item\.transferNo\) === activeNo.*PARTIALLY_RECEIVED/s)
assert.doesNotMatch(page, /转运明细 ID|manualItems/)
})
test('warehouse transfer removes completed work from the pending list', async () => {
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
assert.match(page, /this\.selected = null; this\.pendingKey = '';[\s\S]*await this\.loadCandidates\(\)/)
})
test('warehouse transfer shows forecast and actual variance without blocking short or over receipt', async () => {
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
for (const label of ['客户预报', '当前实收', '短收差异', '超收差异']) assert.match(page, new RegExp(label))
assert.match(page, /cartonDifference/)
assert.doesNotMatch(page, /全部托盘箱数必须等于订单计划箱数/)
assert.doesNotMatch(page, /本托 SKU 箱数|itemCounts|transferItemId/)
})
test('warehouse transfer retries reuse the pending idempotency key', 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.match(page, /idempotencyKey: this\.pendingKey/)
assert.equal((page.match(/pendingKey = `pda-transfer-/g) || []).length, 1)
})
test('warehouse transfer measurement inputs retain explicit placeholders', async () => {
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
for (const placeholder of ['长度', '宽度', '高度', '毛重']) assert.match(page, new RegExp(`placeholder="${placeholder}"`))
})
test('warehouse transfer measurements are grouped and summarized per pallet', async () => {
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
assert.match(page, /v-for="\(pallet, index\) in pallets"/)
for (const label of ['本托实测', '当前托重量', '当前托箱数', '自动体积']) assert.match(page, new RegExp(label))
assert.match(page, /palletVolume\(pallet\)/)
assert.match(page, /rawPalletVolume\(pallet\)/)
})
test('warehouse transfer requires a camera photo and submits its file id with palletization', async () => {
const [page, api] = await Promise.all([
readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8'),
readFile(new URL('../src/api/warehouse-transfers.js', import.meta.url), 'utf8')
])
assert.match(page, /sourceType: \['camera', 'album'\]/)
assert.match(page, /请至少拍摄 1 张测量现场照片/)
assert.match(page, /photoFileIds: \[\.\.\.this\.photoFileIds\]/)
assert.match(api, /measurement-photos\/files/)
assert.match(page, /tempFilePaths \|\| \[\]/)
assert.match(page, /未获取到可上传的照片路径,请重新拍摄/)
assert.match(page, /所选转运单已切换,请重新拍摄/)
})
test('warehouse transfer restores uploaded photos and exposes honest pallet label printing', async () => {
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
assert.match(page, /listWarehouseTransferMeasurementPhotos/)
assert.match(page, /downloadWarehouseTransferMeasurementPhoto/)
assert.match(page, /completedPallets = \(result\?\.pallets \|\| \[\]\)/)
assert.match(page, /downloadWarehouseTransferPalletLabel/)
assert.match(page, /pallet\.labelDownloadPath/)
assert.match(page, /destinationLabel\(pallet, \{ destinationType: completedDestinationType \}\)/)
assert.match(page, /type === 'PRIVATE' \? '私人仓' : '目的仓待定'/)
assert.match(page, /completedDestinationType = result\?\.destinationType \|\| this\.selected\.destinationType/)
assert.doesNotMatch(page, /privateDestinationAddress/)
assert.match(page, /uni\.openDocument\(\{ filePath, fileType: 'pdf', showMenu: true/)
assert.doesNotMatch(page, /批量打印|printAllPallets/)
})
test('warehouse transfer enforces the photo limit and restores pending server uploads', async () => {
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
assert.match(page, /const MAX_MEASUREMENT_PHOTOS = 20/)
assert.match(page, /photos = await listWarehouseTransferMeasurementPhotos\(transferNo\)/)
assert.match(page, /const uploadPaths = paths\.slice\(0, remaining\)/)
assert.match(page, /this\.photoFileIds\.length >= MAX_MEASUREMENT_PHOTOS/)
assert.match(page, /await this\.restorePhotos\(transferNo\)/)
assert.match(page, /at most 20\|最多\.\?20\|supports at most/)
})
test('warehouse transfer always requests newly generated pallets', async () => {
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
assert.doesNotMatch(page, /托盘码\s*<small>选填|扫描或输入当前托盘码/)
assert.doesNotMatch(page, /palletCode: String\(p\.palletCode/)
assert.match(page, /completedPallets = \(result\?\.pallets \|\| \[\]\)/)
})
test('warehouse transfer pallet cards prepare generated and server-bound states', async () => {
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
assert.match(page, /生成托盘号/)
assert.match(page, /pallet\.generated && !pallet\.bound/)
assert.match(page, /ScanTargetInput/)
assert.match(page, /startScanBroadcast/)
assert.match(page, /handlePalletScan/)
assert.match(page, /扫描或手输新托盘标签号/)
assert.match(page, /已绑定实物托盘/)
assert.match(page, /!pallet\.generated.*removePallet/)
assert.match(page, /:disabled="pallet\.generated"/)
assert.match(page, /collapsed: false/)
assert.match(page, /pallet\.collapsed = !pallet\.collapsed/)
assert.match(page, /v-show="!pallet\.collapsed"/)
assert.match(page, /this\.selected\.pallets\?\.length \? this\.selected\.pallets\.map\(restoredPallet\)/)
assert.doesNotMatch(page, /if \(!this\.completedTransferNo \|\| this\.openingLabel\) return/)
assert.match(page, /saveWarehouseTransferPalletDraft/)
assert.match(page, /generateWarehouseTransferPallet/)
assert.match(page, /bindWarehouseTransferPallet/)
assert.match(page, /deleteWarehouseTransferPallet/)
assert.match(page, /请先生成并扫描确认绑定全部托盘/)
assert.match(page, /const payload = \{ idempotencyKey: this\.pendingKey, photoFileIds: \[\.\.\.this\.photoFileIds\] \}/)
})
+77 -1
View File
@@ -20,7 +20,7 @@ const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
function requestMock() {
const calls = []
session.apiBase = '/overseas-api'; session.token = 'warehouse-token'
session.apiBase = '/api'; session.token = 'warehouse-token'
globalThis.uni = {
request(options) {
calls.push({ method: options.method, url: options.url, data: options.data, authorization: options.header.Authorization })
@@ -77,6 +77,82 @@ test('relabel API covers claim, unit outcomes, completion and review with encode
assert.ok(calls.every((call) => !/[?&](?:customerId|warehouseId)=/.test(call.url)))
})
test('carton relabel uses one scan input and shows the confirmed FBA mapping after saving', async () => {
const page = await read('../src/pages/relabel/relabel.vue')
assert.equal((page.match(/name="quickCarton"/g) || []).length, 1)
assert.match(page, /name="quickSku"/)
assert.match(page, /v-if="taskTab === 'sku' && selectedItem && action !== 'RELABEL' && remaining\(selectedItem\) > 0"/)
assert.match(page, /selectItem\(item\) \{ if \(this\.taskTab === 'carton'\) \{ this\.activeTarget = 'quick-carton'; return \}/)
const start = page.indexOf('async submitCarton(raw)')
const scan = page.slice(start, page.indexOf('removePhoto(index)', start))
assert.ok(scan.indexOf('await scanRelabelCarton') < scan.indexOf('this.applyCurrent(result)'))
assert.ok(scan.indexOf('this.applyCurrent(result)') < scan.indexOf('uni.showModal'))
assert.match(scan, /showCancel: false/)
assert.match(scan, /confirmedItem\?\.fbaCartonMark/)
})
test('mixed relabel keeps SKU work open and return transport gates pallet work', async () => {
const page = await read('../src/pages/relabel/relabel.vue')
const body = page.match(/remaining\(item\) \{([^\n]+)\},/)[1]
const remaining = new Function('item', body)
const mixed = { operationType: 'CARTON_AND_SKU', expectedPackages: 1, relabelledPackages: 1,
cartonProcessedPackages: 1, skuProcessedPackages: 0, skippedPackages: 0, exceptionPackages: 0 }
assert.equal(remaining.call({ taskTab: 'sku' }, mixed), 1)
assert.equal(remaining.call({ taskTab: 'carton' }, mixed), 0)
assert.match(page, /<RelabelPalletWork v-if="requiresPalletBinding && \(taskTab === 'carton' \|\| isReturnTask\)"/)
})
test('relabel list confirms before starting a new task and completion returns to the list', async () => {
const page = await read('../src/pages/relabel/relabel.vue')
assert.doesNotMatch(page, /name="relabelTask"/)
assert.match(page, /@tap="openExecution\(item\)"/)
assert.match(page, /async openExecution\(item\)/)
assert.match(page, /const confirmed = await this\.confirmStartTask\(item\)[\s\S]*if \(!confirmed\) return[\s\S]*claimRelabelTask/)
assert.match(page, /relabelExecute&task=\$\{encodeURIComponent\(item\.relabelNo\)\}/)
assert.match(page, /当前账号正在执行 \$\{active\.relabelNo\}/)
assert.match(page, /v-if="showCurrent" class="detail-toolbar"[\s\S]*@tap="closeCurrent">\u2039 返回列表<\/button>/)
assert.match(page, /closeCurrent\(\) \{ this\.current = null;[\s\S]*this\.taskCode = '' \}/)
assert.match(page, /updateWarehouseWork\('relabel', \{ currentNo: '', photoFileIds: \[\], photoNames: \[\], remark: '' \}\)/)
assert.match(page, /if \(completed\) this\.goToClaimList\(\)/)
assert.match(page, /goToClaimList\(\) \{ this\.taskCode = ''; uni\.redirectTo\(\{ url: '\/pages\/relabel\/relabel\?op=relabelReceive' \}\) \}/)
})
test('relabel review opens a pending task as customer-confirmation read-only detail', async () => {
const page = await read('../src/pages/relabel/relabel.vue')
const body = page.match(/showCurrent\(\) \{([^\n]+)\},/)[1]
const visible = new Function(body)
const context = { stage: 'execute', entryTaskNo: '', current: { relabelNo: 'RL-1', taskStatus: 'PROCESSING' }, candidates: [{ relabelNo: 'RL-1', taskStatus: 'PROCESSING', claimedByCurrentOperator: true }] }
assert.equal(visible.call(context), true, 'detail without ownership field must use server candidate ownership')
assert.equal(visible.call({ ...context, candidates: [] }), false)
assert.equal(visible.call({ ...context, stage: 'review', current: { ...context.current, taskStatus: 'WAIT_REVIEW' } }), true)
assert.equal(visible.call({ ...context, current: { ...context.current, taskStatus: 'WAIT_REVIEW' } }), false)
assert.match(page, /stageSubtitle\(\) \{ return this\.stage === 'review' \? '仅展示当前仓库已完成现场换标并等待客户或平台处理的单据'/)
assert.match(page, /<view v-else class="task-list">[\s\S]*@tap="openReview\(item\)"/)
assert.match(page, /查看并复核/)
assert.match(page, /async openReview\(item\)/)
assert.match(page, /现场换标已经完成。请客户在客户工作台核对完工结果并确认/)
assert.doesNotMatch(page, /@tap="review\('APPROVE'\)"/)
assert.match(page, /<button v-if="stage !== 'review'" class="link-button"[^>]*@tap="bootstrap"/)
assert.match(page, /if \(this\.stage === 'execute'\) this\.startScanner\(\)/)
const bootstrapBody = page.match(/async bootstrap\(\) \{([^\n]+)\},/)[1]
const bootstrap = new (Object.getPrototypeOf(async function () {}).constructor)(bootstrapBody)
const opened = []
await bootstrap.call({ ...context, entryTaskNo: 'RL-1', loadCandidates: async () => {}, openTask: async (no) => opened.push(no) })
assert.deepEqual(opened, ['RL-1'])
await bootstrap.call({ ...context, entryTaskNo: '', loadCandidates: async () => {}, openTask: async (no) => opened.push(no) })
assert.deepEqual(opened, ['RL-1'], 'workbench execution entry must stay on the processing-task list')
})
test('returned relabel tasks are highlighted, sorted first, and still obey the single active task lock', async () => {
const page = await read('../src/pages/relabel/relabel.vue')
assert.match(page, /item\.reviewDecision === 'REWORK'[\s\S]*退回重做[\s\S]*优先处理/)
assert.match(page, /退回备注:\{\{ item\.reviewRemark \}\}/)
assert.match(page, /\.sort\(\(left, right\) => Number\(right\.reviewDecision === 'REWORK'\) - Number\(left\.reviewDecision === 'REWORK'\)/)
assert.match(page, /const active = this\.candidates\.find\(\(candidate\) => candidate\.taskStatus === 'PROCESSING' && candidate\.claimedByCurrentOperator === true\)/)
assert.match(page, /if \(active && active\.relabelNo !== item\.relabelNo\) return this\.promptActiveTask\(active\)/)
assert.match(page, /AVAILABLE: '待认领'/)
})
test('warehouse drafts are isolated by operator and warehouse and retain retry keys until success', () => {
const values = new Map()
globalThis.uni = {
+7 -2
View File
@@ -7,13 +7,15 @@ 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: 'outbound', name: '排车拣货' },
{ module: 'outbound', name: '出库作业' },
{ module: 'inventory', name: '商品移库' },
{ module: 'inventory', name: '托盘管理' },
{ module: 'relabel', name: '换标收货' },
{ module: 'relabel', name: '换标列表' },
{ module: 'relabel', name: '换标执行' },
{ module: 'relabel', name: '换标复核' },
{ module: 'query', name: '库存查询' },
@@ -25,10 +27,13 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
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.some((item) => item.name === '库存盘点'), false)
for (const name of ['商品移库', '换标收货', '换标执行', '换标复核', '库存查询', '库位查询', '托盘查询']) {
for (const name of ['商品移库', '换标列表', '换标执行', '换标复核', '库存查询', '库位查询', '托盘查询']) {
const operation = operations.find((item) => item.name === name)
assert.equal(operation?.available, true)
assert.match(operation?.route || '', /^\/pages\//)
+8 -7
View File
@@ -25,11 +25,14 @@ function loggedProxy(name, options) {
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const domesticTarget = env.API_PROXY_TARGET || 'http://127.0.0.1:9000'
const overseasTarget = env.OVERSEAS_API_PROXY_TARGET || 'http://127.0.0.1:9300'
const apiTarget = env.API_PROXY_TARGET || 'http://127.0.0.1:9000'
return {
base: process.env.UNI_PLATFORM === 'h5' && mode === 'production' ? '/' : undefined,
plugins: [uni()],
define: {
'import.meta.env.VITE_PDA_UPDATE_URL': JSON.stringify(env.VITE_PDA_UPDATE_URL || ''),
'import.meta.env.VITE_PDA_DEV_USERNAME': JSON.stringify(env.VITE_PDA_DEV_USERNAME || ''),
'import.meta.env.VITE_PDA_DEV_PASSWORD': JSON.stringify(env.VITE_PDA_DEV_PASSWORD || ''),
__VUE_I18N_FULL_INSTALL__: true,
__VUE_I18N_LEGACY_API__: false,
__VUE_I18N_PROD_DEVTOOLS__: false,
@@ -37,11 +40,9 @@ export default defineConfig(({ mode }) => {
},
server: {
proxy: {
'/api': loggedProxy('auth', { target: domesticTarget, changeOrigin: true }),
'/overseas-api': loggedProxy('overseas', {
target: overseasTarget,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/overseas-api/, '/api')
'/gateway-api': loggedProxy('api', {
target: apiTarget, changeOrigin: true,
rewrite: (path) => path.replace(/^\/gateway-api/, '/api')
})
}
},