2 Commits
68 changed files with 1083 additions and 3603 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
+76 -69
View File
@@ -1,98 +1,105 @@
# 海外 PDA 指引 # Overseas PDA Uni-app Instructions
## 快速读取顺序 ## 快速读取顺序
1. 按下表定位最小页面;业务配置见 `src/data/overseas-workbench.js`,不扫 `src/uni_modules/`。 1. 先用“页面与能力快速索引”定位 `src/pages.json`、工作台或最小页面;页面业务配置从 `src/data/overseas-workbench.js` 进入,不扫描 `src/uni_modules/`。
2. 登录/Token/统一请求先读下表登录API/会话、`src/config/runtime.js`、`../../docs/统一认证与服务通信契约.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`。 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。 最小读取集按任务保持在适用指引和目标页面/API;纯展示任务不增加服务指引,服务端判断才读直接服务指引,跨服务状态或鉴权无法判断时再增加相关方。读取预算遵循根指引的行数与字符数标准,先读适用规则,再检索业务命中段,不全文加载海外仓 README。
## 项目定位 ## 项目定位
- “柏悦物流海外版”面向海外仓现场人员,uni-app Vue 3 独立安装/构建/发布,不依赖 `apps/pda-uniapp/` 或仓库外 `pda-shared`。 - 本项目是“柏悦物流海外版”独立 uni-app Vue 3 工程,面向海外仓现场人员。
- 业务/任务完成见下表;派送/客户回访归 Admin/Client,国内集装箱拣货/装柜归国内 PDA。 - 工程必须独立安装、构建和发布,不得依赖 `apps/pda-uniapp/` 或仓库外 `pda-shared` 目录。
- 当前设备端业务范围包括海外仓入仓、退货签收与合箱、库内、换标、库存/库位/托盘查询,以及排车拣货、普通/一件代发出库任务、任务完成和发车;派送与客户回访由 Admin/Client 承载,国内集装箱拣货/装柜由国内 PDA 承载。
## 服务与发布边界 ## 服务与发布边界
- H5只上传Nginx站点,禁止容器发布及重跑旧灰度脚本;步骤见`README.md`。 - Auth 是唯一账号和 Token 签发方;认证和业务请求共用统一 Gateway,App 根地址只读 VITE_API_BASE_URL,H5 使用 VITE_H5_API_BASE_URL。
- 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 清理会话,不得只依赖页面入口隐藏。
- 登录/刷新/会话校验Token平台主体、成员、有效期、冻结仓;`OVERSEAS_WAREHOUSE_PDA_OPERATOR`、`lastleg:pda:operate` 仅读存Auth登录/刷新顶层快照,禁解析JWT权限。快照缺失/无效/过期/跨主体或撤权均fail-closed清会话,不能只藏入口。 - 并发 401 只允许一次 refresh;统一 Token 和权限快照完整校验后原子替换,主体或绑定仓改变时清理会话并重新登录,每个原请求或照片上传最多重放一次。刷新失败清理本地会话,业务错误和网络错误保留 code、requestId、traceId 或 network 诊断。
- 并发401仅一次refresh,完整校验Token/快照再原子替换;主体/仓变化清会话重登录,原请求/照片上传最多重放一次。刷新失败清会话;业务/网络错留code、requestId、traceId或network诊断。 - 国内与海外 AppID、Android 包名、签名、版本号、构建产物和在线更新通道必须独立。
- 国内/海外 AppID、Android 包名、签名、版本号、产物、更新通道独立;`src/manifest.json` AppID须正式打包前由海外所属DCloud账号创建,禁伪造/复用国内AppID。 - `src/manifest.json` 中的 AppID 在正式打包前由海外版所属 DCloud 账号创建;不得伪造或复用国内 AppID。
- 环境地址仅配 `.env.local` 等未提交环境文件,不入源码。 - 环境地址只通过 `.env.local` 等未提交环境文件配置,不写入源码。
- Android启动仅在 `APP-PLUS` 调 `src/utils/app-update.js`,以独立 `VITE_PDA_UPDATE_URL` 查HTTPS APK/WGT;强更禁跳过,WGT安装即重启。更新服务/标识/签名/版本序列不得与国内 PDA 混用。 - 海外 Android App 启动后仅在 `APP-PLUS` 调用 `src/utils/app-update.js`,通过海外独立 `VITE_PDA_UPDATE_URL` 检查 HTTPS APK/WGT;强制更新不可跳过,WGT 安装后立即重启。更新服务、AppID、包名、签名和版本序列均不得与国内 PDA 混用。
- 源码根 `src/`;扫码广播 Action=`android.intent.action.SCANRESULT`,Extra=`value`。 ## 工程入口
- 均接真实PDA API;入仓账号锁柜、逐码核平;退货号/SKU候选进入,按本包实收生成退货箱/计费批次。换标含收货/执行/复核;盘点页面/API保留、工作台隐藏。
- 源码根:`src/`;页面注册:`src/pages.json`;应用配置:`src/manifest.json`。
- 工作台定义:`src/data/overseas-workbench.js`。
- 登录与会话:`src/api/auth.js`、`src/stores/session.js`。
- 扫码设备层:`src/utils/scan-broadcast.js`;广播 Action=`android.intent.action.SCANRESULT`,Extra=`value`。
- 入仓作业已接柜号级真实海外 API:账号锁柜、三类照片签收、统一箱码定位入仓码、FBA 按箱数收货、OVERSEAS 逐箱收货、一件代发按国内封托来源托盘整托收货并逐入仓码核平;退货入库通过退货单号或 SKU 候选进入并按本包实收生成退货箱与计费批次;移库、托盘管理、换标收货/执行/复核以及库存/库位/托盘查询均已接入专用 PDA API。库存盘点底层页面与接口仍保留,但当前工作台不开放入口。
## 页面与能力快速索引 ## 页面与能力快速索引
路径相对应用根;[业务索引](docs/business-index.md)存API/字段/状态/恢复。应用根 `rg -n -F '任务词或文件名' docs/business-index.md`;涉及业务行为变更时必须核对对应细则及最近指引,定位不全文读。 | 目标 | 入口 | 继续查询 |
|---|---|---|
| 目标 | 首个入口 | | 页面注册与启动 | `src/pages.json`、`src/main.js`、`src/App.vue` | `src/manifest.json`、全局样式与应用生命周期 |
|---|---| | AppID、平台图标与打包资源 | `src/manifest.json`、`static/app-icons/` | Android/iOS 尺寸映射由 `tests/app-icons.test.mjs` 锁定;独立 AppID、签名和发布通道不得复用国内 PDA |
| 页面注册与启动 | `src/pages.json`、`src/main.js`、`src/App.vue` | | Android APK/WGT 在线更新 | `src/App.vue`、`src/utils/app-update.js`、`.env.example`、`src/manifest.json` | `VITE_PDA_UPDATE_URL` 使用海外独立 HTTPS 通道;合同 `tests/app-update.test.mjs`,H5 不执行更新逻辑 |
| AppID、平台图标与打包资源 | `src/manifest.json`、`static/app-icons/` | | 登录、刷新与会话 | `src/pages/login/login.vue`、`src/api/auth.js`、`src/stores/session.js` | 国内 Auth 签发 Token;API 根见 `src/config/runtime.js` |
| Android APK/WGT 在线更新 | `src/App.vue`、`src/utils/app-update.js`、`.env.example` | | 海外仓工作台与模块入口 | `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/pages/login/login.vue`、`src/api/auth.js`、`src/stores/session.js` | | 中英西语言设置 | `src/i18n/index.js`、`src/i18n/locales/`、`src/pages/profile/profile.vue` | 个人中心可选择并持久化 `zh-CN`、`en`、`es`;当前业务翻译资源仍为空,页面业务文案继续使用中文,不得把语言选择入口误报为全量翻译完成 |
| 海外仓工作台与作业入口 | `src/pages/home/home.vue`;首页直接进入各作业,页面刷新后返回首页 | | 柜号签收、照片与入仓真实作业 | `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/i18n/index.js`、`src/i18n/locales/`、`src/pages/profile/profile.vue` | | 退货入库签收与合箱 | `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/operation/operation.vue`、`src/components/ReceivingSessionSummary.vue`、`src/api/inbound.js` | | 退货箱内清点 | `src/pages/return-counting/return-counting.vue`、`src/api/return-counting.js` | 扫描箱号或托盘号定位清点任务;各箱 SKU 汇总与签收数量核平,支持恢复当前任务;页面注册见 `src/pages.json` |
| 退货入库签收与合箱 | `src/pages/return-receipt/return-receipt.vue`、`src/pages/return-packing/return-packing.vue`、`src/api/return-receipt.js` | | 仓储转运测量、打托与标签 | `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/return-counting/return-counting.vue`、`src/api/return-counting.js` | | 托盘管理真实作业 | `src/pages/pallet/pallet.vue`、`src/api/pallet.js` | 扫码查询托盘内容,按服务端返回的托盘/库存版本执行组托、拆托、换托和解绑;公网前缀 `/api/pda/v1/last-leg/pallet-operations/**` |
| 仓储转运测量、打托与标签 | `src/pages/warehouse-transfer/warehouse-transfer.vue`、`src/api/warehouse-transfers.js` | | 库存盘点(入口隐藏) | `src/pages/stocktake/stocktake.vue`、`src/api/warehouse-operations.js`、`src/stores/warehouse-work.js` | 底层创建、恢复、计数与提交实现仍保留;当前 `src/data/overseas-workbench.js` 不注册库存盘点,不向现场开放入口 |
| 托盘管理真实作业 | `src/pages/pallet/pallet.vue`、`src/api/pallet.js` | | 商品移库真实作业 | `src/pages/move/move.vue`、`src/api/warehouse-operations.js`、`src/stores/warehouse-work.js` | 扫描来源托盘与目标库位执行同仓移库;冲突后重新读取服务端事实 |
| 库存盘点(入口隐藏)、商品移库 | `src/pages/stocktake/stocktake.vue`、`src/pages/move/move.vue`;共用 `src/api/warehouse-operations.js`、`src/stores/warehouse-work.js` | | 换标真实作业 | `src/pages/relabel/relabel.vue`、`src/api/relabel.js` | 同一页面按操作参数承载换标收货、执行与复核;执行页按当前单明细切换 SKU/箱唛 Tab,组合明细同时显示两类需求。SKU 逐条扫码旧新SKU并携带审批件数,箱唛扫码仓库唛头/新FBA箱唛;组合单按同箱保留两个Tab的核对输入,全部核对后只提交一次箱事实。认领同步处理中;支持拍照、异常/返工和幂等恢复 |
| 换标真实作业 | `src/pages/relabel/relabel.vue`、`src/api/relabel.js` | | 现场查询真实作业 | `src/pages/query/query.vue`、`src/api/warehouse-operations.js` | 同一页面按操作参数承载库存、库位与托盘查询,只展示稳定业务标识 |
| 现场查询 | `src/pages/query/query.vue`;同上库内API | | 排车拣货与发车 | `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/**` |
| 排车理货审核、拣货、整车发车与证照提醒 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`(`completeDispatchLoading`)、`src/stores/fulfillment.js` | | 整车发车与证照提醒 | 同上 `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` | | 普通/一件代发出库 | `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/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` | | 作业记录与人员 | `src/pages/history/history.vue`、`src/pages/profile/profile.vue` | 会话隔离、退出清理和后续真实作业历史契约 |
| 业务标识与内部 ID 隔离 | `tests/business-identifiers-ui.test.mjs`、`src/pages/`、`src/components/` | | 业务标识与内部 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 |
| 本地契约测试 | `tests/project-contract.test.mjs`、`tests/navigation.test.mjs`、`tests/scan-broadcast.test.mjs` | | 本地契约测试 | `tests/project-contract.test.mjs`、`navigation.test.mjs`、`scan-broadcast.test.mjs`、`audio-feedback.test.mjs` | 运行 `npm test`;不代表真实海外 API 或真机验收 |
## 海外业务接口边界 ## 海外业务接口边界
- 库内/换标/收货/排车/出库/查询仅用 `/api/pda/**` API/DTO,经 `gateway` 显式PDA路由,不直连Lastleg/借平台、客户、内部接口。Gateway/服务端校验Token、`lastleg:pda:operate`、冻结仓、客户/资源归属;工作台 `available`、本地客户/仓ID不代替授权。 - 仓储转运按托盘分别保存箱数、长宽高(cm)和毛重(kg),体积按本托尺寸计算;短收/超收仅展示差异,不以预报箱数强制核平。生成托盘号后锁定该卡测量数据,必须取得服务端绑定确认且至少有一张现场照片才能完成打托;完成请求只提交稳定幂等键和照片文件 ID,不重复提交整份托盘测量。重新进入从详情恢复托盘和鉴权照片;标签使用服务端 `labelDownloadPath` 下载 PDF,打开文档不等于真机打印完成。
- 仅展示服务端业务标识/资格;写请求携当前版本及本次幂等键,仅请求中防连点,结束即释放操作态。保留表单,不持久化或重放失败命令,下次点击按当前输入新建请求;409/结果不明先刷新权威状态,超时不判未执行/成功。Token刷新单次重放仍沿认证规则。
- 仓储转运逐托存箱数、长宽高(cm)、毛重(kg),本托尺寸算体积;逐SKU核对件数差异,不以预报箱数强制核平。沿用客户预报箱唛,不造箱号。测量完成前客户/平台可增整单预报箱数、不选SKU,箱内SKU以PDA实点为准;刷新合并新箱、留本机草稿,清点传forecastPackageCount拦旧快照。生成托盘号锁该卡测量,服务端绑定确认且至少一张现场照片才可完成打托;完成传稳定幂等键、照片文件ID,SKU实收差异在完成弹窗必填原因,不重传测量。重进从详情恢复托盘/鉴权照片;`labelDownloadPath` 下载PDF,打开不等于真机打印。
- 换标目标确认即提交拆合托,无底部二次提交。下述组件按本轮最新 `PALLET_BIND` 逐箱改绑,扫新目标弹窗确认,经 `relabel-disassemble` 精确来源路径(不传 `actualSource`);同目标不写。服务端约束库存/托盘版本及幂等,已排车/出库禁改绑。回归 `tests/relabel-pallet-work.test.mjs`,离线非真机验收。 - 换标目标确认即提交拆托合托,不再二次点击底部提交。`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`。 - 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可印标签,原生打印未适配,生成托盘号非打印成功;回归见逐箱改绑。 - 换标执行页内嵌 `src/components/RelabelPalletWork.vue`:`relabel-sources` 展示原入仓单各托盘箱数;选择来源库存行并扫描实托、输入拆出箱数、核对本批已完成换标箱,再扫描目标托盘合托。每次一个来源库存行,可分批处理多个托盘,无需为拆托额外逐箱扫码。`relabel-disassemble.actualSource` 原子调整本单冻结记账来源并移动库存,目标仍归原入仓单,不新建入库。仅同新目的仓合托;新增托盘不等待客户确认,409后刷新重扫。H5 提供标签打印,原生打印设备适配尚未完成;不能宣称生成托盘号即打印成功。回归 `tests/relabel-pallet-work.test.mjs`。
- 换标标类型,仅显明细所需Tab;混合明细做箱唛/SKU两项,服务端分存进度,`cartonProcessedPackages/skuProcessedPackages` 定各Tab待处理数;两项完成整箱计一次,扫箱唛非SKU完成。 - 换标任务标明类型,仅显示实际明细需要的 Tab。混合明细同时进入箱唛/SKU 作业,服务端分开保存两部分进度,`cartonProcessedPackages/skuProcessedPackages` 决定各 Tab 待处理数量;两项均完成后整箱计数一次,不能仅扫箱唛就视为 SKU 完成。
- 换标完工后任务 `WAIT_REVIEW`、申请 `CUSTOMER_CONFIRM_PENDING`;客户确认原子复核/释放履约资格。PDA复核池只读;平台经 `/rework` 携任务版本/幂等键/必填备注退回,历史归档重计数;PDA显 `reviewRemark`、清上轮输入/照片草稿。 - 换标完工提交使 PDA 任务进入 `WAIT_REVIEW`,申请进入 `CUSTOMER_CONFIRM_PENDING`,客户确认原子完成复核并释放后续履约资格。PDA 复核池只读;平台通过专用 `/rework` 接口携带任务版本、幂等键和必填备注退回,历史事实归档后重新计数,PDA 展示 `reviewRemark` 并清空上一轮输入和照片草稿。
- 换标完工照片支持缩略图/大图/移除/重拍;重进经 `relabel-tasks/{relabelNo}/photos/files/{fileId}/download` 鉴权读,不存设备临时地址。重拍上传成功才换待提交ID,失败留原图;移除仅解绑待提交引用。 - 换标执行完成照片以缩略图展示并支持大图预览、移除和重拍。重新进入任务通过 `relabel-tasks/{relabelNo}/photos/files/{fileId}/download` 鉴权读取,不持久化设备临时图片地址;重拍先上传成功再替换待提交文件 ID,失败保留原照片,移除只解绑待提交引用。
- 退货空态归上表签收API及 `src/api/return-packing.js`:仅 `startReturnReceipt/findReturnSkuCandidates/findPackingOrders` 将 `A0404/404` 转无匹配;签收确认/合箱写/标签读禁套用,权限/网络/服务错留诊断并报错,无记录非写成功;跑下述两项退货API回归。 - 退货查询空态集中在 `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`,不得将查询无记录误报为写入成功。
- 上表入仓API接待卸柜候选、详情/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`。合托只提示,不改资格/触发分配;首扫仅确认服务端已有实托/库位/资源锁预留,设备端不重分配。仅传业务箱码/托盘码/收货数量,服务端定资格。 - 入仓摘要、箱码解析和实收响应的 `assigned/mergeAllowed/urgent/mergeHint` 只消费 Lastleg 返回值,布尔标志只有 `true` 生效,`mergeAllowed` 缺省为 `false`;合托仅作提示,不能改变收货资格或触发分配。首扫实物托盘只确认服务端已有托盘、库位与资源锁预留,不在设备端重新分配托盘、库位或资源锁;只提交业务箱码、托盘码及当前收货数量,最终资格由服务端决定。
- `overseasUrl`(上述overseas.js)统一拼请求/上传/下载:去 `apiBase` 尾斜杠,基址以 `/api` 结尾则去业务路径重复 `/api`;禁自拼 `session.apiBase`,不靠网关容错双斜杠/重复前缀。 - `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。沿用服务端可操作优先、同组计划到仓升序、空值末位;禁按入仓码/计划数组长度推就绪,禁预计到港替代计划到仓。 - 待卸柜候选只消费服务端已按 Token 绑定仓过滤的结果,并严格校验每柜正式 `overseasWarehouseCode`、`overseasWarehouseName` 与 `warehouse-profile` 一致;字段缺失或仓不一致一律 fail-closed,不得从 FBA/业务目的仓或内部 ID 推导。候选保留服务端返回的未齐全柜并严格消费 `allPlansReady`、`operable`、`blockReason`、`plannedArrivalAt` 与柜级计划统计;`operable=false` 只能查看只读摘要,不得调用 prepare。候选顺序沿用服务端“可操作优先、同组计划到仓时间升序、时间为空最后”的排序,前端不得用入仓码/计划数组长度自行推导就绪或把预计到港冒充计划到仓。
- 详情/prepare/current均重验接柜仓码;prepare仅建已分配柜会话,非首次认领。仓冲突 `409/A0409` 留中文错误及 `requestId/traceId`;建后仍走current恢复。 - 候选详情、prepare 响应和 current 恢复都必须重复校验同一接柜仓编码;prepare 只建立已分配柜的作业会话,不得当作首次认领。服务端仓冲突的 `409/A0409` 中文错误保留 `requestId`/`traceId`;会话建立后仍按既有 current 恢复与完成前禁止切柜逻辑处理。
- 上表托盘API仅用Lastleg专用PDA Controller,传扫码的 `palletVersion/positionVersion`、随机幂等键,不传客户/仓/库位;409重扫取新版本,禁旧库存快照。 - `src/api/pallet.js` 只调用 Lastleg 托盘专用 PDA Controller;前端提交服务端扫码返回的 `palletVersion`、`positionVersion` 和随机幂等键,不提交客户、仓库或库位标识。409 后必须重新扫码获取最新版本,禁止用旧库存快照继续作业。
- 请求身份/资源范围仅取Token/current,不传 `customerId/planNo/allocationId/warehouseId/locationId`;409引导账号恢复当前柜,完成前禁切柜。 - 请求身份和资源范围只取 Token 与 current 会话;前端不得提交 `customerId`、`planNo`、`allocationId`、`warehouseId` 或 `locationId`。409 必须引导账号恢复当前柜,完成前不得切柜。
- 签收照片三角色各一当前文件;上传后以服务端 `downloadPath` 会话内鉴权读。换图失败留原 `fileId`/可提交状态,无原图失败槽不计 `photosReady`;临时路径仅本次预览。 - 签收照片固定为三个角色且每个角色只有一个当前文件;上传成功后使用服务端 `downloadPath` 在当前会话内鉴权读取。更换失败时保留原 `fileId` 和可提交状态,无原图的失败槽不得计入 `photosReady`;本地临时路径仅用于本次设备预览,不作为跨会话文件事实。
- 柜号签收入仓用Lastleg `/api/pda/v1/last-leg/receiving-containers/**`,退货签收/合箱分别用 `/api/pda/v1/last-leg/return-receipts/**`、`/api/pda/v1/last-leg/return-inventory/**`,入口见上表。 - 库内、换标与现场查询只调用 `/api/pda/**` 业务 API/DTO,并统一受 `lastleg:pda:operate`、Token 冻结仓库范围和服务端资源归属约束;写操作必须保留稳定幂等键,409 后重新读取服务端状态,不得把平台、客户或内部接口直接暴露给 PDA。
- 排车资格、阶段、理货审核及装车资料 API/回归查业务索引排车条目。 - 柜号签收入仓调用 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。
- `src/components/DispatchLoadingDocuments.vue` 支持装车中上传,装车与资料均完成后自动派送,无独立发车入口。 - 海外业务 Token、PDA 权限和可信身份最终由 Gateway 与服务端校验;前端不得用工作台 `available`、本地客户 ID 或仓库 ID 代替授权和资源归属校验。
- 排车和出库入口只调用专用 PDA 接口;客户与冻结海外仓范围由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不把内部数据库 ID 展示成业务编号。网络失败保留草稿和幂等键,恢复后以服务端任务状态为准;排车详情返回的 `canDepart`、`departBlockReason` 是发车资格依据,拣货及完成按钮同时核对当前单`canPick/pickBlockReason`;平台未转派送生成 `WAIT_OUTBOUND` 出库单时不得发车;完成或发车成功后不得重复提交。派送始终由 Admin/Client 承载。
## 质量门禁 ## 质量门禁
- 已发车详情按需挂载 `src/components/DispatchHandoverStatus.vue`,经 `src/api/trip-handover.js` 读当前派送资源/最近交接;进入/刷新各一次GET,列表不逐行请求。只读;后台确认交接,已完成派送留完成时资源。回归 `tests/trip-handover.test.mjs`。 - 已发车排车详情按需挂载`src/components/DispatchHandoverStatus.vue`,通过`src/api/trip-handover.js`读取当前派送资源和最近交接;每次进入/刷新一次GET,列表不逐行请求。仅只读、不提交客户或仓ID;后台确认交接,已完成派送保留完成时资源。回归`tests/trip-handover.test.mjs`。
- 验证按 `../../docs/AI工程执行与协作约束.md`;文案看差异,小逻辑定向测试,不默认全量/构建/真机验收。`npm test` 为全量入口。 - 安装:`npm install`;检查:`npm test`。
- 专项用 `node --test`:转运测量/打托/照片/标签行为变更选 `tests/warehouse-transfer-api.test.mjs`;退货签收/合箱行为变更选对应 `tests/return-receipt-api.test.mjs` / `tests/return-packing-api.test.mjs`;项目结构/工作台入口变化才加 `tests/project-contract.test.mjs` / `tests/workbench-availability.test.mjs`。离线不替代真实API、真机扫码/绑定/打印。 - 仓储转运测量、打托、照片或标签入口变更运行 `node --test tests/warehouse-transfer-api.test.mjs tests/project-contract.test.mjs tests/workbench-availability.test.mjs`;静态合同不替代真实扫码、绑定及打印验收。
- H5编译链 `vue-i18n` 固定 `9.1.9`,禁范围版本/单独升级;依赖变更同步 `package.json`、`package-lock.json`、现有 `pnpm-lock.yaml`,至少跑H5编译。 - 退货签收、合箱或工作台入口变更先运行 `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 联调与真机扫码验收。
- 产品为海外PDA App;`npm run build:h5` 仅浏览器调试/模板门禁,App资源用 `npm run build:app`。APK/IPA须在HBuilderX/正式原生打包环境以独立AppID/签名验证,资源编译非APK真机验收。 - 当前 uni-app H5 编译链要求 `vue-i18n` 精确固定为 `9.1.9`;不得改回范围版本或单独升级。依赖变更必须同步 `package.json`、`package-lock.json` 与现有 `pnpm-lock.yaml`,并至少执行 H5 编译门禁。
- 改 `src/manifest.json` 图标映射或 `static/app-icons/` 跑 `node --test tests/app-icons.test.mjs`;禁缺声明尺寸、同一错尺寸占位、复制国内AppID/签名。 - 正式产品形态是海外 PDA App;`npm run build:h5` 仅用于浏览器调试和模板编译门禁,App 资源编译使用 `npm run build:app`。
- 改任务/入仓码/柜号/托盘码/库位码等可见标识,以 `node --test` 跑上表标识隔离测试;禁内部ID回退业务号。 - APK/IPA 必须在 HBuilderX 或正式原生打包环境中使用独立 AppID 与签名验证;仅通过 App 资源编译不得声称 APK 真机验收完成。
- 改更新协议/版本比较/强制更新/安装/重启跑 `node --test tests/app-update.test.mjs`;新增原生模块/权限/不兼容运行时能力须发海外APK,不能只发WGT或下载国内包。 - 修改 `src/manifest.json` 图标映射或 `static/app-icons/` 时运行 `node --test tests/app-icons.test.mjs`,不得缺失已声明尺寸、用同一错误尺寸文件占位或从国内 PDA 复制 AppID/签名。
- 修改任务、入仓码、柜号、托盘码、库位码等用户可见标识时运行 `node --test tests/business-identifiers-ui.test.mjs`;不得以内部数据库 ID 作为业务编号回退。
- 修改更新协议、版本比较、强制更新、安装或重启行为时运行 `node --test tests/app-update.test.mjs`;新增原生模块、权限或不兼容运行时能力必须发海外 APK,不能仅发布 WGT,也不能下载国内更新包。
- 不提交 `node_modules/`、`dist/`、`unpackage/`、环境文件、签名证书或打包产物。 - 不提交 `node_modules/`、`dist/`、`unpackage/`、环境文件、签名证书或打包产物。
+17 -23
View File
@@ -75,38 +75,32 @@ Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查海外 PDA 的独
3. 更新清单与下载地址使用 HTTPS,先在测试设备验证可选更新、强制更新、断网重试、APK 覆盖安装、wgt 重启及扫码作业回归,再切换正式清单。 3. 更新清单与下载地址使用 HTTPS,先在测试设备验证可选更新、强制更新、断网重试、APK 覆盖安装、wgt 重启及扫码作业回归,再切换正式清单。
4. 海外和国内更新记录、文件目录及发布权限保持隔离,服务端必须按请求中的 `appid` 拒绝跨应用更新包。 4. 海外和国内更新记录、文件目录及发布权限保持隔离,服务端必须按请求中的 `appid` 拒绝跨应用更新包。
## H5 静态托管(TEST 与灰度) ## TEST H5 静态托管
国内、海外 PDA H5 统一由宿主机已有 Nginx/宝塔站点直接提供静态文件,不再构建或发布 PDA H5 Docker 镜像、Compose 服务或容器。正式部署约束见 [测试环境单机部署与配置契约](../../docs/测试环境单机部署与配置契约.md) 的“PDA H5 发布方式”;历史灰度报告不再作为发布入口。 发布构建使用 `/` 资源 base 与 hash 路由,部署到本应用独立域名根目录,浏览器入口为 `/#/`。开发服务仍使用根路径;不能将 Vite 开发代理当作静态服务器配置。
发布构建使用 `/` 资源 base 与 hash 路由,部署到本应用独立域名根目录,浏览器入口为 `/#/`。不能放到子目录后继续共用根路径资源,也不能将 Vite 开发代理当作线上配置。 执行 `npm run build:h5`。无需启动 Vite 开发服务器;`npm run build:app` 不属于 H5 发布。
执行 `npm run build:h5` 导出 Web 静态文件;`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` 与 `/` 的实际首页。
1. 构建后执行 `node scripts/verify-h5.mjs`,核对 HTML/JS/CSS 资源路径及同源 API。构建、测试仍按根规则经 `local-check.ps1` 执行。 独立域名入口将 `/` 代理至本应用静态容器根目录,或将构建目录内容直接发布到站点根目录;不再挂载 PDA 子路径。缺失资源返回 404,不回退首页,HTML 使用 `Cache-Control: no-store`。域名入口还须配置同源 API 代理:两端均将 `/gateway-api/` 转统一 Gateway `/api/`。入口须保留 Authorization,支持文件上传下载及至少 11m 请求体。域名与上游只在部署侧配置,不编入 PDA 源码。
2. 将 `dist/build/h5/` **目录内的文件**上传到该 PDA 站点现有 `root`。先上传 `assets/`、`static/` 等资源,最后替换 `index.html`,保留站点证书验证目录和站点配置;不要把外层构建目录一起嵌套上传。
3. 页面 `location /` 使用 `try_files $uri $uri/ =404` 直接读取静态文件,设置 `Cache-Control: no-store`;不得再转发到旧 PDA 容器。接口 `/gateway-api/` 独立转发到当前环境统一 Gateway `/api/`,保留 Authorization 和上传能力。
4. 首次切换前备份实际生效站点配置,合并下述片段并执行 `nginx -t`,成功后才重载。已有独立 API 规则先核对并复用,不能直接追加重复 `location`。后续仅更新静态文件时无需新建容器或重载 Nginx。
5. 检查域名首页及实际引用的 JS/CSS 均返回 200,并将响应内容的 SHA-256 与本次产物逐项比较;确认响应不是旧容器内容。再检查登录和受影响页面。首页 200 不等于接口或业务验收通过。
### 站点配置片段 部署配置检查:`node --test tests/h5-deployment.test.mjs`。没有 Docker 运行时的本地合同检查不能替代目标主机 `docker compose config`、Nginx 启动与真实 HTTP 验收。
`deploy/nginx.conf.template` 是合并到已有 `server` 的参考片段,不是完整站点配置。保留现有域名、HTTPS、证书与宝塔 include;替换原有 root/index 及同名 location。环境地址、端口及网站目录只在部署侧配置,不提交到源码。 构建后执行 `node scripts/verify-h5.mjs`,核对 HTML/JS/CSS 资源路径、同源 API 和无本机/旧海外配置泄漏。该检查与本地构建仅证明产物;TEST 页面登录、仓权限、业务操作和 Android 真机另行验收。
在部署机器上将 `PDA_SITE_ROOT` 设置为该站点真实根目录,`PDA_GATEWAY_ORIGIN` 设置为宿主机可达的当前环境 Gateway 源地址(协议、主机、端口,不含末尾 `/` 或 `/api`)。不要使用仅 Docker 网络可解析的服务名。显式只渲染这两个变量,保留 `$uri`、`$host` 和代理请求头变量:
```bash
envsubst '${PDA_SITE_ROOT} ${PDA_GATEWAY_ORIGIN}' < deploy/nginx.conf.template
```
输出仅供核对和合并,不直接覆盖完整站点。若机器没有 `envsubst`,手工替换这两个占位符;未替换的模板不能直接加载。
### 两端统一 API 配置 ### 两端统一 API 配置
App 的 `VITE_API_BASE_URL` 为统一 Gateway 绝对地址(含 `/api`);H5 使用 `VITE_H5_API_BASE_URL=/gateway-api`。`API_PROXY_TARGET` 仅供本地 Vite 开发代理使用,不随静态包发布。实际地址放在未提交环境文件;变更编译变量后须重新构建。 两端的 `VITE_API_BASE_URL` 均为统一 Gateway 绝对地址(含 `/api`);`VITE_H5_API_BASE_URL=/gateway-api`;`API_PROXY_TARGET` 为同一 Gateway 源地址(不含 `/api`)。实际地址只放在未提交的 `.env.local`;修改后重启开发服务,发布前重新打包。
站点 API 规则须使用 `location ^~ /gateway-api/`,防止驾驶证等包含 `LICENSE` 的业务路径被站点敏感文件正则截获。仅更新静态包不能修复代理配置;域名入口和每层代理均须核对。 两端独立域名入口均配置以下规则;`gateway:9000` 仅适用于同一 Docker 网络,其他部署须替换成实际统一 Gateway 上游。静态容器只提供页面文件,Vite 代理不会随静态包发布。
部署定向检查:`node --test tests/h5-deployment.test.mjs`,经 `local-check.ps1` 执行。该检查覆盖静态发布约束、资源 base 和开发代理重写,不替代目标主机 Nginx 校验、真实 HTTP、人工页面及 Android 真机验收。 ```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;
}
}
-21
View File
@@ -1,21 +0,0 @@
# Merge into the existing site's server block; preserve TLS and panel includes.
# Render only PDA_SITE_ROOT and PDA_GATEWAY_ORIGIN (see README).
# Replace existing root/index and matching locations; do not append duplicates.
root ${PDA_SITE_ROOT};
index index.html;
location / {
try_files $uri $uri/ =404;
add_header Cache-Control "no-store" always;
}
location ^~ /gateway-api/ {
proxy_pass ${PDA_GATEWAY_ORIGIN}/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Authorization $http_authorization;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 55m;
}
+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;
}
}
-67
View File
@@ -1,67 +0,0 @@
# 海外 PDA业务详细索引
本文件保存当前页面/API 入口及业务规则,是[应用指引](../AGENTS.md)的按需补充。涉及字段、状态、权限、金额、资源或作业恢复时必须读取受影响条款,不作为普通定位任务的全文首读。
正文代码路径以工作区 `apps/pda-overseas-uniapp/` 应用根起算,源码包缩写与裸文件名沿对应条目的目录解释;明确标注“工作区”的路径从工作区根起算。Markdown 相对链接以本文件所在的 `docs/` 为起点。已知文件直接定位;行为变更同时核对最近模块指引及服务端合同。
原条款在本文件维护,主 AGENTS 仅保留短入口和通用边界;不要将新业务细节复制回主指引。
## 页面与能力快速索引
| 目标 | 入口 | 继续查询 |
|---|---|---|
| 页面注册与启动 | `src/pages.json`、`src/main.js`、`src/App.vue` | `src/manifest.json`、全局样式与应用生命周期 |
| AppID、平台图标与打包资源 | `src/manifest.json`、`static/app-icons/` | Android/iOS 尺寸映射由 `tests/app-icons.test.mjs` 锁定;独立 AppID、签名和发布通道不得复用国内 PDA |
| Android APK/WGT 在线更新 | `src/App.vue`、`src/utils/app-update.js`、`.env.example`、`src/manifest.json` | `VITE_PDA_UPDATE_URL` 使用海外独立 HTTPS 通道;合同 `tests/app-update.test.mjs`,H5 不执行更新逻辑 |
| 登录、刷新与会话 | `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/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`,合托明确为现场可选而非完成门禁;扫码实收确认计划生成阶段已预留的真实托盘和库位,校验卡展示预留库位编码及 `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` | 同一页面按操作参数承载换标收货、执行与复核;执行页按当前单明细切换 SKU/箱唛 Tab,组合明细同时显示两类需求。SKU 逐条扫码旧新SKU并携带审批件数,箱唛扫码仓库唛头/新FBA箱唛;组合单按同箱保留两个Tab的核对输入,全部核对后只提交一次箱事实。认领同步处理中;支持拍照、异常/返工和幂等恢复 |
| 现场查询真实作业 | `src/pages/query/query.vue`、`src/api/warehouse-operations.js` | 同一页面按操作参数承载库存、库位与托盘查询,只展示稳定业务标识 |
| 排车拣货与发车/理货审核 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 普通进入展示任务列表和“上次作业”,不因本地草稿自动打开详情;明确跳转 `?resume=1` 才恢复排车。默认任务列表含待理货排车,详情展示服务端车辆/司机快照、计划发车及预计送达时间、当前有效FBA收货确认、调度审核状态/意见和货物;时间明确标注北京时间。预约或审核依据失效时 `APPROVAL_BLOCKED` 禁止批准但保留驳回。仅 `actions.LOADING_REVIEW.allowed=true` 时可调用 `POST /api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-review`,提交原 `recordVersion/idempotencyKey/decision/reason`;驳回原因必填,任一级拒绝统一回客服处理(车辆信息),重新提交后再次逐级审核。PDA 不执行调度审核;理货通过并重读资格后才允许按实物托盘码或来源箱唛真实拣货,服务端 `actions.PICK/LOADING_COMPLETE` 分别决定真实拣货和装车完成资格;按已扫托数、箱数整车一次确认装车,不逐托填重量、体积或托位,资料未齐进入 `AFTER_LOADING`、已齐直接进入 `DELIVERING`;详情采用“拣货作业/装车拍照”两个 Tab,打开任务默认拣货;切换保留拣货草稿与照片状态,拍照页不响应拣货扫码,同单照片保存刷新保留当前 Tab。装车中即可在拍照 Tab 通过 `src/components/DispatchLoadingDocuments.vue` 上传五类整车照片(司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字,每类1至5张),照片齐全且确认装车完成后由服务端在最后一项完成的事务自动生成派送并发车;不额外安排派送或确认发车。公网前缀 `/api/pda/v1/last-leg/dispatches/**`。回归 `tests/dispatch-workflow.test.mjs tests/fulfillment-workflows.test.mjs` |
| 装车确认、自动派送与证照提醒 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js` 的 `completeDispatchLoading` | 一单一趟,使用 `dispatchNo`、公开 `recordVersion`(0 合法)及本次 `idempotencyKey`。只在请求中锁定,结束即释放;保留表单,不持久化或重放失败命令,下次点击按当前输入和版本生成新键。结果不明或 409 先刷新权威任务,不以超时推断未执行或成功。结果核对单号、动作、请求键和版本;理货审核通过须 `stage=LOADING`,驳回须 `stage=BEFORE_LOADING`;`LOADING_COMPLETE` 返回 `stage=AFTER_LOADING|DELIVERING`、Loaded 结果,提交版本、manifestHash 和幂等键,不逐托填测量。成功后重读任务,版本不得早于已确认结果;重读失败只刷新,不自动重复 POST。司机证照、资源及各客户派送资格由服务端校验;正式流程见工作区 `docs/海外仓业务/排车列表统一作业改造契约.md`,不使用独立车次发车请求 |
| 普通/一件代发出库 | `src/pages/outbound/outbound.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 首次进入清空扫描框并展示任务列表和“上次作业”标记,不自动恢复旧详情;详情提供独立返回列表入口。逐箱扫码、DP 托盘整托拣取及幂等完成保持不变;扫描排车业务号显式跳转排车 `?resume=1`。公网前缀 `/api/pda/v1/last-leg/outbound-tasks/**`;派送由 Admin/Client 承载 |
| 扫码设备与诊断 | `src/utils/scan-broadcast.js`、`src/pages/scanner-test/scanner-test.vue` | `src/components/ScanTargetInput.vue`、声音反馈工具;广播 Action/Extra 见本文件服务边界 |
| 作业记录与人员 | `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 |
| 本地契约测试 | `tests/project-contract.test.mjs`、`navigation.test.mjs`、`scan-broadcast.test.mjs`、`audio-feedback.test.mjs` | 运行 `npm test`;不代表真实海外 API 或真机验收 |
## 海外业务接口边界
- 仓储转运按托盘分别保存箱数、长宽高(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` 统一处理。
- 入仓摘要、箱码解析和实收响应的 `assigned/mergeAllowed/urgent/mergeHint` 只消费 Lastleg 返回值,布尔标志只有 `true` 生效,`mergeAllowed` 缺省为 `false`;合托仅作提示,不能改变收货资格或触发分配。首扫实物托盘只确认服务端已有托盘、库位与资源锁预留,不在设备端重新分配托盘、库位或资源锁;只提交业务箱码、托盘码及当前收货数量,最终资格由服务端决定。
- `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` 的显式 PDA 路由,不得直连 Lastleg。
- 海外业务 Token、PDA 权限和可信身份最终由 Gateway 与服务端校验;前端不得用工作台 `available`、本地客户 ID 或仓库 ID 代替授权和资源归属校验。
- 排车和出库入口只调用专用 PDA 接口;客户与绑定海外仓由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不展示内部数据库 ID 作为业务编号。动作资格、版本和失败处理见本文件排车条目,装车中可同步上传五类照片,每类1至5张,装车确认与资料齐全两项均完成后服务端自动生成派送并发车,装完但资料未齐时留在装车后;照片API为 `/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents`,前端不补派送费用。完成成功后不得重复提交;PDA 不直接创建派送单,平台与客户的读取范围遵循现行契约。
- 生产仓租功能尚未配套发布,当前 main 的排车拣货、普通出库、整托出库和废弃确认直接调用既有库存命令,不请求 `/storage-rent/preparations`。保留原始货源参数、箱数、版本和 `idempotencyKey`;实际库存接口的业务失败与网络错误照常上报,不吞掉 404、不自动重试或伪造成功。未来启用仓租须以后端接口、数据库及资金链路配套上线为前提。定向回归为 `tests/fulfillment-stock-commands.test.mjs`,覆盖四类命令单次提交、参数保留、版本/防重校验与失败传播;离线测试不代替生产实货验收。
- 废弃出库展示客户废弃备注。无关联箱唛库存扫描本单托盘后扫描现场箱唛,自动去重计数;多SKU先选择本次扫描货物,不手填实际数。每条 lines.scannedCartonMarks 与 actualPackages 一并提交,失败留箱唛/照片供刷新重试;后台保存实际扫描清单并回显废弃详情。有箱唛关联任务仍核对本单所选箱唛。
-33
View File
@@ -1,33 +0,0 @@
import { overseasRequest, overseasUpload, overseasDownload } from './overseas.js'
export const loadingPhotoSlots = ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT']
// 保留既有五框标识以维持文件归属,界面按当前装车资料分类展示。
export const loadingPhotoLabels = { LOADING_SCENE: '司机驾驶证', LOADING_LIST: '车牌照片', PRE_DEPARTURE_CHECK: '装车完成', DRIVER_LICENSE: '封车照片', RECEIPT: 'BOL司机签字' }
export const loadingPhotoLimit = 5
const path = no => {
if (!no || typeof no !== 'string') throw new Error('请先打开排车任务')
return `/pda/v1/last-leg/dispatches/${encodeURIComponent(no)}/loading-documents`
}
function slotPath(no, slot) {
if (!loadingPhotoSlots.includes(slot)) throw new Error('装车照片框无效')
return `${path(no)}/${slot}`
}
export function normalizeLoadingDocuments(value, no) {
if (value?.dispatchNo !== no || !/^\d+$/.test(String(value.recordVersion ?? '')) || !/^[1-9]\d*$/.test(String(value.reviewRound ?? '')) || !Array.isArray(value.photos)
|| !['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(value.stage)) throw new Error('装车资料与当前排车事实不一致')
const files = new Set(), counts = {}
for (const photo of value.photos) {
if (!loadingPhotoSlots.includes(photo.slot) || !photo.fileId || files.has(String(photo.fileId))) throw new Error('装车照片引用不完整或重复')
files.add(String(photo.fileId)); counts[photo.slot] = (counts[photo.slot] || 0) + 1
if (counts[photo.slot] > loadingPhotoLimit) throw new Error('每类装车照片最多5张')
}
return { ...value, editable: value.editable === true, complete: value.complete === true }
}
export async function getLoadingDocuments(no) { return normalizeLoadingDocuments(await overseasRequest('GET', path(no)), no) }
export function uploadLoadingPhoto(no, slot, filePath, key, reviewRound) { return overseasUpload(`${slotPath(no, slot)}/uploads?idempotencyKey=${encodeURIComponent(key)}&reviewRound=${encodeURIComponent(reviewRound)}`, filePath) }
export async function bindLoadingPhoto(no, slot, body) { return normalizeLoadingDocuments(await overseasRequest('PUT', slotPath(no, slot), body), no) }
export function readLoadingPhoto(no, photo) {
const pdf = photo.slot === 'LOADING_SCENE' && photo.contentType === 'application/pdf'
if (!pdf && !['image/jpeg', 'image/png'].includes(photo.contentType)) throw new Error('装车资料格式无效')
return overseasDownload(`${slotPath(no, photo.slot)}/files/${encodeURIComponent(photo.fileId)}/content`, { expectedContentTypePrefix: pdf ? 'application/pdf' : photo.contentType })
}
+56 -98
View File
@@ -18,35 +18,39 @@ function count(value) {
return Number.isFinite(number) ? Math.max(0, number) : 0 return Number.isFinite(number) ? Math.max(0, number) : 0
} }
export function requireDispatchVersion(value) {
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) return value
if (typeof value === 'string' && /^\d+$/.test(value)) return value
throw new Error('排车操作版本缺失或无效,请刷新任务')
}
export function dispatchVersionAtLeast(current, confirmed) {
const latest = String(requireDispatchVersion(current)).replace(/^0+(?=\d)/, '')
const original = String(requireDispatchVersion(confirmed)).replace(/^0+(?=\d)/, '')
return latest.length > original.length || latest.length === original.length && latest >= original
}
function dispatchCount(value) {
if (typeof value !== 'number' && (typeof value !== 'string' || !/^(?:0|[1-9]\d*)$/.test(value))) throw new Error('排车数量不是有效非负整数,请重新读取任务')
const number = typeof value === 'number' ? value : Number(value)
if (!Number.isSafeInteger(number) || number < 0) throw new Error('排车数量超出安全整数范围或无效,请重新读取任务')
return number
}
export function normalizeDispatchTask(value) { export function normalizeDispatchTask(value) {
if (!value || !value.dispatchNo || !Array.isArray(value.items)) throw new Error('排车任务返回结构不完整')
return { return {
...value, taskNo: String(value.taskNo || ''), dispatchNo: String(value.dispatchNo), ...value,
recordVersion: value.recordVersion, pickingReconciled: value.pickingReconciled === true, taskNo: String(value?.taskNo || ''),
actions: Object.fromEntries(Object.entries(value.actions || {}).map(([key, gate]) => [key, { ...gate, allowed: gate?.allowed === true }])), dispatchNo: String(value?.dispatchNo || ''),
items: value.items.map(item => ({ ...item, dispatchItemId: String(item.dispatchItemId || ''), sourceKey: String(item.sourceKey || ''), sourceBusinessType: item.sourceBusinessType == null ? null : String(item.sourceBusinessType), sourceBusinessNo: item.sourceBusinessNo == null ? null : String(item.sourceBusinessNo), palletNo: String(item.palletNo || ''), vehiclePlate: String(value?.vehiclePlate || ''),
scheduledPackages: dispatchCount(item.scheduledPackages), pickedPackages: dispatchCount(item.pickedPackages), tripNo: String(value?.tripNo || ''),
returnCartons: (item.returnCartons || []).map(carton => ({ ...carton, returnCartonId: String(carton.returnCartonId || ''), cartonMark: String(carton.cartonMark || ''), productQuantity: dispatchCount(carton.productQuantity) })), tripRecordVersion: value?.tripRecordVersion == null ? null : String(value.tripRecordVersion),
positions: (item.positions || []).map(position => ({ ...position, positionId: String(position.positionId || ''), balanceId: String(position.balanceId || ''), availablePackages: dispatchCount(position.availablePackages), allocatedPackages: dispatchCount(position.allocatedPackages), pickedPackages: dispatchCount(position.pickedPackages) })) 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,
dispatchItemId: String(item?.dispatchItemId || ''),
palletNo: String(item?.palletNo || ''),
destinationWarehouseCode: String(item?.destinationWarehouseCode || ''),
destinationWarehouseName: String(item?.destinationWarehouseName || ''),
fbaWarehouseCode: String(item?.fbaWarehouseCode || ''),
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 || ''),
availablePackages: count(position?.availablePackages ?? (count(item?.plannedPackages ?? item?.packages ?? item?.packageCount) - count(item?.pickedPackages ?? item?.pickedPackageCount))),
allocatedPackages: count(position?.allocatedPackages),
pickedPackages: count(position?.pickedPackages)
}))
})) }))
} }
} }
@@ -79,39 +83,32 @@ export function normalizeOutboundTask(value) {
} }
} }
export async function listDispatchTasks() { export async function listDispatchTasks(status = '') {
const result = await overseasRequest('GET', DISPATCH_BASE) const suffix = status ? `?status=${encoded(status)}` : ''
if (!Array.isArray(result)) throw new Error('排车任务列表返回结构无效') return asList(await overseasRequest('GET', `${DISPATCH_BASE}${suffix}`)).map(normalizeDispatchTask)
return result.map(normalizeDispatchTask)
} }
export async function getDispatchTask(dispatchNo) { export async function getDispatchTask(dispatchNo) {
return normalizeDispatchTask(await overseasRequest('GET', `${DISPATCH_BASE}/${encoded(dispatchNo)}`)) return normalizeDispatchTask(await overseasRequest('GET', `${DISPATCH_BASE}/${encoded(dispatchNo)}`))
} }
export function getDispatchLoading(dispatchNo) {
return overseasRequest('GET', `${DISPATCH_BASE}/${encoded(dispatchNo)}/loading`) export function pickDispatchItem(dispatchNo, dispatchItemId, request) {
} return overseasRequest('POST', `${DISPATCH_BASE}/${encoded(dispatchNo)}/items/${encoded(dispatchItemId)}/pick`, request)
export function getDispatchCommand(dispatchNo, key) {
return overseasRequest('GET', `${DISPATCH_BASE}/${encoded(dispatchNo)}/commands/${encoded(key)}`)
}
function dispatchWrite(path, request) {
requireDispatchVersion(request?.recordVersion)
return overseasRequest('POST', path, request)
}
// 生产仓租接口尚未配套发布,直接提交既有库存命令,仍保留原请求的防重标识。
function requireStockCommandIdentity(request) {
if (!request?.idempotencyKey) throw new Error('出库请求标识缺失,请重新进入任务')
} }
export async function pickDispatchItem(dispatchNo, dispatchItemId, request) { export function completeDispatchPicking(dispatchNo, request) {
requireDispatchVersion(request?.recordVersion) return overseasRequest('POST', `${DISPATCH_BASE}/${encoded(dispatchNo)}/complete`, request)
requireStockCommandIdentity(request)
return dispatchWrite(`${DISPATCH_BASE}/${encoded(dispatchNo)}/items/${encoded(dispatchItemId)}/pick`, request)
} }
export function completeDispatchLoading(dispatchNo, request) {
return dispatchWrite(`${DISPATCH_BASE}/${encoded(dispatchNo)}/loading-complete`, request) export function departDispatch(dispatchNo, request) {
return overseasRequest('POST', `${DISPATCH_BASE}/${encoded(dispatchNo)}/depart`, request)
} }
export function reviewDispatchLoading(dispatchNo, request) {
return dispatchWrite(`${DISPATCH_BASE}/${encoded(dispatchNo)}/loading-review`, 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 = '') { export async function listOutboundTasks(status = '') {
@@ -127,13 +124,11 @@ export function resolveOutboundTask(businessNo) {
return overseasRequest('GET', `${OUTBOUND_BASE}/resolve/${encoded(businessNo)}`) return overseasRequest('GET', `${OUTBOUND_BASE}/resolve/${encoded(businessNo)}`)
} }
export async function pickOutboundTask(taskNo, request) { export function pickOutboundTask(taskNo, request) {
requireStockCommandIdentity(request)
return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/pick`, request) return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/pick`, request)
} }
export async function pickOutboundPallet(taskNo, palletNo, request) { export function pickOutboundPallet(taskNo, palletNo, request) {
requireStockCommandIdentity(request)
return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/pallets/${encoded(palletNo)}/pick`, request) return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/pallets/${encoded(palletNo)}/pick`, request)
} }
@@ -157,24 +152,16 @@ export function normalizeCargoDiscardTask(value) {
const task = value || {} const task = value || {}
return { return {
...task, ...task,
remark: String(task?.remark || ''), discardNo: String(task?.discardNo || ''),
requestedPackages: task?.requestedPackages == null ? null : count(task.requestedPackages),
remainingPackages: task?.remainingPackages == null ? null : count(task.remainingPackages),
discardNo: String(task?.discardNo || ''),
outboundNo: String(task?.outboundNo || ''),
palletNo: String(task?.palletNo || ''), palletNo: String(task?.palletNo || ''),
scanMode: task?.scanMode === 'CARTON' ? 'CARTON' : 'PALLET',
cartonMarks: asList(task?.cartonMarks).map(String),
status: String(task?.status || 'PENDING'), status: String(task?.status || 'PENDING'),
skus: asList(task?.skus).map((sku) => ({ skus: asList(task?.skus).map((sku) => ({
...sku, ...sku,
itemId: String(sku?.itemId || ''), itemId: String(sku?.itemId || ''),
skuCode: String(sku?.skuCode || ''), skuCode: String(sku?.skuCode || ''),
productName: String(sku?.productName || ''), productName: String(sku?.productName || ''),
quantityUnit: sku?.quantityUnit === '件' ? '件' : '箱',
plannedPackages: count(sku?.plannedPackages), plannedPackages: count(sku?.plannedPackages),
actualPackages: sku?.actualPackages == null ? null : count(sku.actualPackages), actualPackages: sku?.actualPackages == null ? null : count(sku.actualPackages),
actualQuantityEditable: sku?.actualQuantityEditable === true,
status: String(sku?.status || 'PENDING') status: String(sku?.status || 'PENDING')
})) }))
} }
@@ -184,41 +171,12 @@ export async function listCargoDiscardTasks() {
return asList(await overseasRequest('GET', CARGO_DISCARD_BASE)).map(normalizeCargoDiscardTask) return asList(await overseasRequest('GET', CARGO_DISCARD_BASE)).map(normalizeCargoDiscardTask)
} }
export async function resolveCargoDiscardPallet(palletNo, discardNo) { export async function resolveCargoDiscardPallet(palletNo) {
return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/resolve`, { palletNo, ...(discardNo ? { discardNo } : {}) })) return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/resolve`, { palletNo }))
} }
export async function confirmCargoDiscard(discardNo, palletNo, lines, photoFileIds, scannedCartonMarks = [], confirmAllCartons = false, request = {}) { export async function confirmCargoDiscard(discardNo, palletNo, lines, photoFileIds) {
const body = { lines, photoFileIds, scannedCartonMarks, confirmAllCartons, idempotencyKey: request.idempotencyKey } return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, { lines, photoFileIds }))
requireStockCommandIdentity(request)
// 出库前按同一份货物、照片和幂等键校验仓租,资金就绪后才提交库存扣减。
await prepareDiscardRent({ mode: 'DISCARD', businessNo: discardNo, palletNo, ...body })
return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, body))
}
/** 本次点击内只查询资金回执,不缓存或自动重放失败的出库命令。 */
async function prepareDiscardRent(body) {
const base = '/pda/v1/last-leg/storage-rent/preparations'
let preparationId
try {
let preparation = await overseasRequest('POST', base, body)
preparationId = preparation?.preparationId
for (let attempt = 0; preparation?.ready !== true && preparation?.status === 'FREEZE_PENDING' && preparationId && attempt < 20; attempt++) {
await new Promise(resolve => setTimeout(resolve, 500))
preparation = await overseasRequest('GET', `${base}/${encoded(preparationId)}`)
}
if (preparation?.ready !== true) {
const message = preparation?.message ? `仓租校验未通过:${preparation.message}` : '仓租资金预校验尚未通过,请稍后重新提交'
throw Object.assign(new Error(message), { code: 'A0409' })
}
} catch (error) {
// 尚未发送出库命令时释放已知的未使用预占,释放失败保留原错误诊断。
if (preparationId) {
try { await overseasRequest('DELETE', `${base}/${encoded(preparationId)}`) }
catch (releaseError) { console.error('[Overseas PDA discard] rent preparation release failed', { code: releaseError?.code, requestId: releaseError?.requestId, traceId: releaseError?.traceId }) }
}
throw error
}
} }
const discardPhotoPath = (discardNo, palletNo) => `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/photos/files` const discardPhotoPath = (discardNo, palletNo) => `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/photos/files`
+2 -12
View File
@@ -135,22 +135,12 @@ function rawUpload(path, filePath, name, token) {
const request = new XMLHttpRequest() const request = new XMLHttpRequest()
request.open('POST', url) request.open('POST', url)
request.setRequestHeader('Authorization', `Bearer ${token}`) request.setRequestHeader('Authorization', `Bearer ${token}`)
request.timeout = 60000
request.onload = () => resolve({ statusCode: request.status, data: request.responseText, header: {} }) request.onload = () => resolve({ statusCode: request.status, data: request.responseText, header: {} })
request.onerror = () => reject(new Error('海外作业文件上传失败')) request.onerror = () => reject(new Error('海外作业文件上传失败'))
request.ontimeout = () => reject(new Error('照片上传超时,请重试'))
request.onabort = () => reject(new Error('照片上传已中断,请重试'))
request.send(form) request.send(form)
}) })
const controller = new AbortController() const response = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: form })
const timeout = setTimeout(() => controller.abort(), 60000) return { statusCode: response.status, data: await response.text(), header: Object.fromEntries(response.headers.entries()) }
try {
const response = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: form, signal: controller.signal })
return { statusCode: response.status, data: await response.text(), header: Object.fromEntries(response.headers.entries()) }
} catch (cause) {
if (controller.signal.aborted) throw new Error('照片上传超时,请重试')
throw cause
} finally { clearTimeout(timeout) }
}) })
.catch((cause) => { throw Object.assign(new Error(cause.message || '海外作业文件上传失败'), { network: true, uploadUrl: url }) }) .catch((cause) => { throw Object.assign(new Error(cause.message || '海外作业文件上传失败'), { network: true, uploadUrl: url }) })
} }
+4 -6
View File
@@ -115,9 +115,7 @@ export function isReceivingConflict(error) {
export function toReceivingError(error, fallback = '海外仓作业失败') { export function toReceivingError(error, fallback = '海外仓作业失败') {
const code = error?.code const code = error?.code
const rawMessage = String(error?.message || '').trim() const rawMessage = String(error?.message || '').trim()
const receivingMessages = { const palletConflictMessages = {
'Carton mark must contain a non-empty inbound code prefix': '请扫描箱码或国内DP码',
'Pallet code is not part of the current FBA destination group': '托盘不属于当前目的仓,请重扫',
'Reserved pallet binding is incomplete': '预留托盘资料不完整,请联系管理员检查入仓计划', 'Reserved pallet binding is incomplete': '预留托盘资料不完整,请联系管理员检查入仓计划',
'Reserved pallet customer binding is invalid': '预留托盘与当前客户不匹配,请联系管理员检查入仓计划', 'Reserved pallet customer binding is invalid': '预留托盘与当前客户不匹配,请联系管理员检查入仓计划',
'Scanned pallet does not match the reserved inbound plan resource': '扫描托盘与入仓计划预留资源不一致,请扫描已预留的托盘', 'Scanned pallet does not match the reserved inbound plan resource': '扫描托盘与入仓计划预留资源不一致,请扫描已预留的托盘',
@@ -133,13 +131,13 @@ export function toReceivingError(error, fallback = '海外仓作业失败') {
'Pallet FBA destination does not match the inbound code': '该托盘的FBA目的仓与当前入仓码不一致,请更换托盘', 'Pallet FBA destination does not match the inbound code': '该托盘的FBA目的仓与当前入仓码不一致,请更换托盘',
'FBA-designated pallet cannot receive an OVERSEAS destination carton': 'FBA专用托盘不能接收海外仓目的箱,请更换托盘' 'FBA-designated pallet cannot receive an OVERSEAS destination carton': 'FBA专用托盘不能接收海外仓目的箱,请更换托盘'
} }
const businessMessage = ['404', 'A0404', 'B0404', '409', 'A0409', 'B0409'].includes(String(code || '')) const conflictMessage = ['409', 'A0409', 'B0409'].includes(String(code || ''))
? receivingMessages[rawMessage] ? palletConflictMessages[rawMessage]
: '' : ''
const palletUnavailable = String(code || '') === 'S0503' const palletUnavailable = String(code || '') === 'S0503'
&& rawMessage === 'Warehouse pallet service returned no usable response' && rawMessage === 'Warehouse pallet service returned no usable response'
const mapped = Object.assign(new Error( const mapped = Object.assign(new Error(
businessMessage || (palletUnavailable conflictMessage || (palletUnavailable
? '该托盘当前不可用于收货,请更换可用托盘或联系管理员检查托盘状态' ? '该托盘当前不可用于收货,请更换可用托盘或联系管理员检查托盘状态'
: rawMessage || fallback) : rawMessage || fallback)
), { ), {
-12
View File
@@ -49,15 +49,3 @@ export function downloadWarehouseTransferPalletLabel(labelDownloadPath) {
if (!path) return Promise.reject(new Error('服务端未返回托盘标签下载地址')) if (!path) return Promise.reject(new Error('服务端未返回托盘标签下载地址'))
return overseasDownload(path) return overseasDownload(path)
} }
export function countWarehouseTransferCartons(transferNo, payload) {
return overseasRequest('POST', `${BASE}/${encoded(transferNo)}/cartons/count`, payload)
}
export function reopenWarehouseTransferMeasurement(transferNo) {
return overseasRequest('POST', `${BASE}/${encoded(transferNo)}/cartons/reopen`)
}
export function getWarehouseTransferCartonLabel(transferNo) {
return overseasRequest('GET', `${BASE}/${encoded(transferNo)}/carton-label`)
}
-17
View File
@@ -1,17 +0,0 @@
<template><view :class="{ 'compact-measure': volumeUnit === 'm3' }"><view class="measure-grid"><label><text>长(cm)</text><input v-model="box.length" type="digit" class="measure-input" :disabled="disabled" placeholder="长度" /></label><label><text>宽(cm)</text><input v-model="box.width" type="digit" class="measure-input" :disabled="disabled" placeholder="宽度" /></label><label><text>高(cm)</text><input v-model="box.height" type="digit" class="measure-input" :disabled="disabled" placeholder="高度" /></label><label><text>重量(kg)</text><input v-model="box.weight" type="digit" class="measure-input" :disabled="disabled" placeholder="重量" /></label></view><view class="volume-chip"><text>体积</text><strong>{{ boxVolume(box) }} {{ volumeUnit === 'm3' ? 'm³' : 'cm³' }}</strong></view>
<view v-for="(line, lineIndex) in box.items" :key="lineIndex" class="count-line"><picker class="sku-picker" :disabled="disabled || !allowSkuChange || (lockFirstSku && lineIndex === 0)" :range="lineSkuCodes(lineIndex)" :value="Math.max(0, lineSkuCodes(lineIndex).indexOf(line.skuCode))" @change="line.skuCode = lineSkuCodes(lineIndex)[Number($event.detail.value)]"><text>{{ line.skuCode || '选择 SKU' }} ▾</text></picker><slot v-if="lineIndex === 0" name="sku-action" /><input v-model="line.quantity" type="number" class="number-input quantity" :disabled="disabled" placeholder="数量" /><button v-if="!disabled && allowSkuChange && box.items.length > 1 && (!lockFirstSku || lineIndex > 0)" class="link-button" :disabled="disabled" @tap="box.items.splice(lineIndex, 1)">删除</button></view></view></template>
<script>
export default {
props: { box: { type: Object, required: true }, skuCodes: { type: Array, default: () => [] }, volumeUnit: { type: String, default: 'cm3' }, disabled: Boolean, uniqueSkus: Boolean, lockFirstSku: Boolean, allowSkuChange: { type: Boolean, default: true } },
methods: {
// 混箱中同一 SKU 只出现一次,保留当前行已选择的 SKU。
lineSkuCodes(index) { return this.uniqueSkus ? this.skuCodes.filter(sku => !this.box.items.some((line, other) => other !== index && line.skuCode === sku)) : this.skuCodes },
// 转运按立方米显示三位;其他作业保留原来的立方厘米显示。
boxVolume(box) { const value = Number(box.length) * Number(box.width) * Number(box.height); return Number.isFinite(value) && value > 0 ? (this.volumeUnit === 'm3' ? (value / 1000000).toFixed(3) : value.toFixed(2)) : '—' }
}
}
</script>
<style scoped>
.count-line{display:flex;align-items:center;gap:8px;margin-top:8px}.sku-picker{flex:1;min-width:0;font-size:12px;word-break:break-all}.quantity{box-sizing:border-box;flex:none;width:90px;height:34px;text-align:center;font-size:13px;border:1px solid #cbd9e8;border-radius:8px;background:#fbfdff}.link-button{width:auto;font-size:12px;color:#135ee8}
.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}
.compact-measure .count-line{padding-top:8px;border-top:1px solid #edf1f6}.compact-measure .sku-picker{line-height:18px}.compact-measure .quantity{width:76px;font-weight:600;background:#fff}.compact-measure .measure-grid{gap:10px}.compact-measure .measure-input{height:38px;background:#f8fafd}.compact-measure .volume-chip{margin:8px 0;padding:6px 10px;border-radius:7px;background:#f4f8ff}.compact-measure .volume-chip strong{font-size:13px}</style>
-158
View File
@@ -1,158 +0,0 @@
<template>
<view class="loading-documents">
<view class="heading">
<view class="heading-main"><text class="heading-title">装车照片</text></view>
<text class="heading-progress">{{ busy ? '同步中' : snapshot ? `${completedCategories}/5 类已保存` : '待同步' }}</text>
<button class="heading-refresh" :disabled="busy" @tap="refresh">刷新</button>
</view>
<text class="section-hint">每类至少 1 张、最多 5 张,上传后自动保存,可逐张更换。</text>
<text class="section-hint">司机驾驶证、车牌照片在理货审核通过后自动带入,可补拍或更换;PDF 驾驶证可点击查看。</text>
<text v-if="needsRefresh" class="section-hint">保存状态尚未核实,请先刷新资料后再上传。</text>
<view v-if="error" class="error" role="alert"><text>{{ error.message }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text></view>
<view class="photo-list">
<view v-for="(slot, index) in slots" :key="slot" class="photo-card" :class="{ filled: snapshot?.photos.some(item => item.slot === slot) }">
<view class="photo-card-header">
<view class="photo-card-main">
<text class="photo-number">{{ snapshot?.photos.some(item => item.slot === slot) ? '✓' : index + 1 }}</text>
<view class="photo-card-title"><text class="photo-label">{{ slotLabels[slot] }}</text><text class="photo-status">{{ photosFor(slot).length }}/5 张已保存</text></view>
</view>
<button v-if="editable" class="photo-upload" :aria-label="`上传${slotLabels[slot]}`" :disabled="busy || photosFor(slot).length >= limit" @tap="choose(slot)">{{ photosFor(slot).length >= limit ? '已满 5 张' : '+ 上传图片' }}</button>
</view>
<view v-if="snapshot?.photos.some(item => item.slot === slot)" class="photo-preview-list">
<view v-for="photo in snapshot.photos.filter(item => item.slot === slot)" :key="photo.fileId" class="photo-preview">
<button v-if="photo.contentType === 'application/pdf'" class="photo-read-retry" :disabled="busy" @tap="viewPdf(photo)">查看驾驶证 PDF</button>
<image v-else-if="images[photo.fileId]" class="photo-thumbnail" :src="images[photo.fileId]" mode="aspectFill" :aria-label="`预览${slotLabels[slot]}`" @tap="preview(images[photo.fileId])" />
<button v-else class="photo-read-retry" :aria-label="`重新读取${slotLabels[slot]}`" :disabled="busy" @tap="load">图片读取失败,重试</button>
<text v-if="images[photo.fileId]" class="photo-preview-caption" :aria-label="`查看${slotLabels[slot]}大图`" @tap="preview(images[photo.fileId])">查看大图</text>
<button v-if="editable" class="photo-replace" :disabled="busy" :aria-label="`更换${slotLabels[slot]}图片`" @tap="choose(slot, photo.fileId)">更换</button>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import { session } from '@/stores/session.js'
import { toDispatchError } from '@/utils/dispatch-error.js'
import { loadingPhotoSlots, loadingPhotoLabels, loadingPhotoLimit, getLoadingDocuments, uploadLoadingPhoto, bindLoadingPhoto, readLoadingPhoto } from '@/api/dispatch-loading-documents.js'
export default {
props: { dispatchNo: { type: String, required: true } },
emits: ['saved', 'busy-change'],
data() { return { slots: loadingPhotoSlots, slotLabels: loadingPhotoLabels, limit: loadingPhotoLimit, snapshot: null, busy: false, needsRefresh: false, error: null, images: {}, active: true, revision: 0,
scope: `${session.currentUser?.id || ''}:${session.currentUser?.warehouseId || ''}`, number: this.dispatchNo } },
computed: { editable() { return this.snapshot?.editable === true && !this.needsRefresh }, completedCategories() { return this.slots.filter(slot => this.photosFor(slot).length > 0).length } },
mounted() { this.load() },
beforeUnmount() { this.active = false; this.revision++; this.$emit('busy-change', false) },
methods: {
photosFor(slot) { return (this.snapshot?.photos || []).filter(photo => photo.slot === slot) },
ensureScope() { if (!this.active || this.number !== this.dispatchNo || this.scope !== `${session.currentUser?.id || ''}:${session.currentUser?.warehouseId || ''}`) throw new Error('作业身份已变化,请重新进入任务') },
key() { return `loading-${Date.now()}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}` },
async previews() {
await Promise.allSettled((this.snapshot?.photos || []).map(async photo => {
if (photo.contentType === 'application/pdf') return
if (this.images[photo.fileId]) return
const image = await readLoadingPhoto(this.number, photo); this.ensureScope(); this.images[photo.fileId] = image
}))
},
async refreshFacts() {
this.needsRefresh = true
const value = await getLoadingDocuments(this.number); this.ensureScope()
this.snapshot = value; this.needsRefresh = false; await this.previews()
},
async load() { await this.run(() => this.refreshFacts()) },
async refresh() { await this.run(async () => { await this.refreshFacts(); return true }, true) },
async choose(slot, replaceFileId = null) {
if (this.busy || !this.editable) return
const reviewRound = this.snapshot.reviewRound
const capacity = replaceFileId ? 1 : this.limit - this.photosFor(slot).length
if (capacity <= 0) return
let saved = 0, reconciled = false
await this.run(async () => {
const chosen = await uni.chooseImage({ count: capacity, sizeType: ['compressed'], sourceType: ['camera', 'album'] }); this.ensureScope()
const paths = chosen.tempFilePaths || []; if (!paths.length) return
if (paths.length > capacity) throw new Error(`本次最多可选择${capacity}张图片`)
if (chosen.tempFiles?.some(file => file.size > 10 * 1024 * 1024)) throw new Error('请选择不超过10MiB的图片')
// 逐张以最新版本绑定;部分失败保留前面已保存照片,不重放失败上传。
for (const filePath of paths) {
const uploadKey = this.key()
const photo = await uploadLoadingPhoto(this.number, slot, filePath, uploadKey, reviewRound); this.ensureScope()
if (photo?.slot !== slot || !photo.fileId) throw new Error('上传结果与装车照片框不一致')
const latest = await getLoadingDocuments(this.number); this.ensureScope(); this.snapshot = latest
if (latest.reviewRound !== reviewRound) { reconciled = true; throw new Error('审核轮次已变化,请核对新车辆、司机后重新选择资料') }
if (!this.editable) throw new Error('当前任务不可修改装车照片,请刷新任务查看')
try {
const value = await bindLoadingPhoto(this.number, slot, { recordVersion: latest.recordVersion, idempotencyKey: this.key(), fileId: photo.fileId, uploadKey, ...(replaceFileId ? { replaceFileId } : {}) }); this.ensureScope()
this.snapshot = value
} catch (cause) {
// 保存可能已成功;只重读最新事实,失败时必须先刷新再继续。
try {
await this.refreshFacts()
reconciled = this.snapshot.recordVersion !== latest.recordVersion || this.snapshot.dispatchRecordVersion !== latest.dispatchRecordVersion || this.snapshot.stage !== latest.stage
} catch { /* 保留原错误与刷新入口。 */ }
throw cause
}
saved++; await this.previews()
}
uni.showToast({ title: this.snapshot.stage === 'DELIVERING' && this.snapshot.complete ? '资料齐全,已进入派送中' : `已保存${saved}张照片`, icon: 'none' })
})
if ((saved || reconciled) && this.active) this.$emit('saved')
},
preview(url) { uni.previewImage({ current: url, urls: [url] }) },
async viewPdf(photo) {
await this.run(async () => {
const browser = typeof window !== 'undefined' && typeof document !== 'undefined'
const popup = browser ? window.open('', '_blank') : null
if (browser && !popup) throw new Error('请允许打开驾驶证预览窗口')
if (popup) popup.opener = null
try {
const path = await readLoadingPhoto(this.number, photo); this.ensureScope()
if (popup) popup.location.href = path
else await new Promise((resolve, reject) => uni.openDocument({ filePath: path, fileType: 'pdf', showMenu: true, success: resolve, fail: reject }))
} catch (cause) { if (popup) popup.close(); throw cause }
})
},
async run(work, notify = false) {
if (this.busy) return
const revision = ++this.revision
this.busy = true; this.$emit('busy-change', true); this.error = null; let saved = false
try { this.ensureScope(); saved = await work() === true }
catch (cause) { if (this.active && !String(cause.errMsg || '').includes('cancel')) { this.error = toDispatchError(cause, '装车资料操作失败,请重新操作') } }
finally { if (this.active) { this.busy = false; this.$emit('busy-change', false) } }
if (saved && notify && this.active) this.$emit('saved')
}
}
}
</script>
<style scoped>
.loading-documents { padding: 24rpx; margin-top: 20rpx; border: 2rpx solid #d9e5f4; border-radius: 20rpx; color: #24364d; font-size: 24rpx; background: #fff; }
.heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16rpx; }
.heading-main { flex: 1; min-width: 0; }
.heading-title { display: block; font-size: 30rpx; font-weight: 700; }
.heading-subtitle, .section-hint { display: block; margin-top: 8rpx; color: #64748b; font-size: 22rpx; line-height: 34rpx; }
.heading-progress { flex-shrink: 0; padding: 8rpx 12rpx; border-radius: 40rpx; color: #087443; font-size: 20rpx; line-height: 30rpx; background: #e8f8ef; }
.heading-refresh { flex-shrink: 0; min-width: 100rpx; min-height: 80rpx; margin: 0; padding: 0 18rpx; border: 2rpx solid #9db7d9; border-radius: 14rpx; color: #174d91; font-size: 24rpx; line-height: 80rpx; background: #fff; }
.photo-list { margin-top: 20rpx; }
.photo-card { margin-bottom: 16rpx; padding: 14rpx 18rpx; border: 2rpx solid #d7e1ed; border-radius: 18rpx; background: #f8fafc; }
.photo-card:last-child { margin-bottom: 0; }
.photo-card.filled { border-color: #a7d6ba; background: #f4fbf6; }
.photo-card-header { display: flex; min-height: 88rpx; align-items: center; justify-content: space-between; gap: 20rpx; }
.photo-card-main { display: flex; min-width: 0; align-items: center; gap: 18rpx; }
.photo-number { display: flex; width: 56rpx; height: 56rpx; flex-shrink: 0; align-items: center; justify-content: center; border-radius: 50%; color: #47617f; font-size: 22rpx; font-weight: 700; background: #e8eef6; }
.filled .photo-number { color: #fff; background: #16a34a; }
.photo-card-title { display: flex; min-width: 0; flex-direction: column; gap: 2rpx; }
.photo-label { display: block; color: #24364d; font-size: 24rpx; font-weight: 700; }
.photo-status { color: #64748b; font-size: 20rpx; line-height: 30rpx; }
.filled .photo-status { color: #087443; }
.photo-upload { flex-shrink: 0; min-width: 152rpx; min-height: 84rpx; margin: 0; padding: 0 18rpx; border: 2rpx solid #9db7d9; border-radius: 14rpx; color: #174d91; font-size: 24rpx; line-height: 84rpx; background: #fff; }
.photo-preview-list { display: flex; flex-wrap: wrap; gap: 16rpx; margin-top: 18rpx; }
.photo-preview { box-sizing: border-box; width: calc(33.333% - 12rpx); min-width: 128rpx; padding: 8rpx; border: 2rpx solid #dbe5ef; border-radius: 16rpx; background: #fff; }
.photo-thumbnail { display: block; width: 100%; height: 136rpx; border-radius: 12rpx; background: #e7edf5; }
.photo-preview-caption { display: block; overflow: hidden; margin-top: 8rpx; color: #64748b; font-size: 18rpx; line-height: 28rpx; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
.photo-replace { margin: 8rpx 0 0; padding: 0; color: #174d91; font-size: 22rpx; line-height: 64rpx; background: #eef5ff; }
.photo-read-retry { display: flex; box-sizing: border-box; width: 100%; min-height: 136rpx; margin: 0; padding: 12rpx; align-items: center; justify-content: center; border: 0; border-radius: 12rpx; color: #174d91; font-size: 20rpx; line-height: 30rpx; background: #e7edf5; }
.error { margin-top: 16rpx; padding: 12rpx 16rpx; border-radius: 14rpx; color: #b91c1c; background: #fff1f0; }
.error text { display: block; font-size: 22rpx; line-height: 34rpx; overflow-wrap: anywhere; }
.heading-refresh::after, .photo-upload::after, .photo-read-retry::after, .recover-button::after { border: 0; }
.loading-documents button[disabled] { opacity: .55; }
</style>
+3 -3
View File
@@ -5,6 +5,7 @@
<text class="receiving-summary-status">{{ statusLabel }}</text> <text class="receiving-summary-status">{{ statusLabel }}</text>
</view> </view>
<view class="receiving-summary-context"> <view class="receiving-summary-context">
<text class="receiving-summary-warehouse">海外接柜仓:{{ receivingWarehouseLabel }}</text>
<view class="receiving-summary-destinations"><text class="destination-label">目的仓:</text><view class="destination-tags"><text v-for="value in destinationValues" :key="value">{{ value }}</text></view></view> <view class="receiving-summary-destinations"><text class="destination-label">目的仓:</text><view class="destination-tags"><text v-for="value in destinationValues" :key="value">{{ value }}</text></view></view>
</view> </view>
<view class="receiving-summary-metrics"> <view class="receiving-summary-metrics">
@@ -32,6 +33,7 @@
props: { session: { type: Object, default: null } }, props: { session: { type: Object, default: null } },
computed: { computed: {
planFlags() { return Array.isArray(this.session?.inbounds) ? this.session.inbounds : [] }, planFlags() { return Array.isArray(this.session?.inbounds) ? this.session.inbounds : [] },
receivingWarehouseLabel() { const code = String(this.session?.overseasWarehouseCode || '').trim(); const name = String(this.session?.overseasWarehouseName || '').trim(); return code && name ? `${code} · ${name}` : '—' },
inboundCodeCount() { return Array.isArray(this.session?.inbounds) ? this.session.inbounds.length : 0 }, inboundCodeCount() { return Array.isArray(this.session?.inbounds) ? this.session.inbounds.length : 0 },
plannedPalletCount() { return (this.session?.inbounds || []).reduce((sum, item) => sum + Math.max(0, Number(item?.plannedPalletCount || 0)), 0) }, plannedPalletCount() { return (this.session?.inbounds || []).reduce((sum, item) => sum + Math.max(0, Number(item?.plannedPalletCount || 0)), 0) },
destinationValues() { const values = (this.session?.inbounds || []).map((item) => item?.fbaWarehouseCode || item?.destinationWarehouseName || item?.destinationWarehouseCode).filter(Boolean); const unique = [...new Set(values)]; return unique.length ? unique : ['—'] }, destinationValues() { const values = (this.session?.inbounds || []).map((item) => item?.fbaWarehouseCode || item?.destinationWarehouseName || item?.destinationWarehouseCode).filter(Boolean); const unique = [...new Set(values)]; return unique.length ? unique : ['—'] },
@@ -47,11 +49,9 @@
<style scoped> <style scoped>
.receiving-summary{box-sizing:border-box;margin-bottom:10px;padding:10px 12px;border:1px solid #b8d2f3;border-radius:13px;background:linear-gradient(145deg,#fff,#f2f7ff);box-shadow:0 3px 12px rgba(31,74,125,.08)} .receiving-summary{box-sizing:border-box;margin-bottom:10px;padding:10px 12px;border:1px solid #b8d2f3;border-radius:13px;background:linear-gradient(145deg,#fff,#f2f7ff);box-shadow:0 3px 12px rgba(31,74,125,.08)}
.receiving-summary-head,.receiving-summary-progress>view:first-child{display:flex;align-items:center;justify-content:space-between;gap:10px} .receiving-summary-head,.receiving-summary-progress>view:first-child{display:flex;align-items:center;justify-content:space-between;gap:10px}
.receiving-summary-head > view:first-child{flex:1;min-width:0}
.receiving-summary-container{white-space:normal;overflow-wrap:anywhere;word-break:break-all}
.receiving-summary-label,.receiving-summary-container,.metric-value,.metric-label{display:block}.receiving-summary-label{color:#718095;font-size:9px;line-height:13px}.receiving-summary-container{margin-top:1px;color:#17324d;font-size:17px;font-weight:850;line-height:22px} .receiving-summary-label,.receiving-summary-container,.metric-value,.metric-label{display:block}.receiving-summary-label{color:#718095;font-size:9px;line-height:13px}.receiving-summary-container{margin-top:1px;color:#17324d;font-size:17px;font-weight:850;line-height:22px}
.receiving-summary-status{flex-shrink:0;padding:4px 8px;border-radius:99px;color:#1668dc;font-size:9px;font-weight:700;background:#e3efff} .receiving-summary-status{flex-shrink:0;padding:4px 8px;border-radius:99px;color:#1668dc;font-size:9px;font-weight:700;background:#e3efff}
.receiving-summary-context{display:flex;flex-direction:column;gap:4px;margin-top:7px;color:#617389;font-size:9px;line-height:14px}.receiving-summary-destinations{display:flex;min-width:0;align-items:flex-start}.destination-label{flex-shrink:0}.destination-tags{display:flex;min-width:0;flex-wrap:wrap;gap:3px 4px}.destination-tags text{padding:0 5px;border-radius:99px;color:#42617f;line-height:14px;background:#e8f0fa;white-space:nowrap} .receiving-summary-context{display:flex;flex-direction:column;gap:4px;margin-top:7px;color:#617389;font-size:9px;line-height:14px}.receiving-summary-warehouse{display:block}.receiving-summary-destinations{display:flex;min-width:0;align-items:flex-start}.destination-label{flex-shrink:0}.destination-tags{display:flex;min-width:0;flex-wrap:wrap;gap:3px 4px}.destination-tags text{padding:0 5px;border-radius:99px;color:#42617f;line-height:14px;background:#e8f0fa;white-space:nowrap}
.receiving-summary-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin-top:7px;padding:7px 2px;border-radius:9px;background:rgba(255,255,255,.8)}.receiving-summary-metrics>view{display:flex;align-items:center;flex-direction:column;border-right:1px solid #e3eaf2}.receiving-summary-metrics>view:last-child{border-right:0} .receiving-summary-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin-top:7px;padding:7px 2px;border-radius:9px;background:rgba(255,255,255,.8)}.receiving-summary-metrics>view{display:flex;align-items:center;flex-direction:column;border-right:1px solid #e3eaf2}.receiving-summary-metrics>view:last-child{border-right:0}
.receiving-plan-flags{margin-top:7px;padding:3px 8px;border-radius:9px;background:rgba(255,255,255,.82)}.receiving-plan-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:5px 0;border-bottom:1px solid #e9eef5}.receiving-plan-row:last-child{border-bottom:0}.receiving-plan-code{min-width:0;color:#17324d;font-size:10px;font-weight:750;overflow-wrap:anywhere}.receiving-plan-tags{display:flex;flex-shrink:0;gap:3px}.receiving-plan-tags text{padding:2px 5px;border-radius:99px;font-size:8px;line-height:12px}.flag-positive{color:#08705f;background:#e2f6f0}.flag-option{color:#1668dc;background:#e7f0ff}.flag-urgent{color:#b54708;background:#fff0dc}.flag-muted{color:#718095;background:#edf1f5} .receiving-plan-flags{margin-top:7px;padding:3px 8px;border-radius:9px;background:rgba(255,255,255,.82)}.receiving-plan-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:5px 0;border-bottom:1px solid #e9eef5}.receiving-plan-row:last-child{border-bottom:0}.receiving-plan-code{min-width:0;color:#17324d;font-size:10px;font-weight:750;overflow-wrap:anywhere}.receiving-plan-tags{display:flex;flex-shrink:0;gap:3px}.receiving-plan-tags text{padding:2px 5px;border-radius:99px;font-size:8px;line-height:12px}.flag-positive{color:#08705f;background:#e2f6f0}.flag-option{color:#1668dc;background:#e7f0ff}.flag-urgent{color:#b54708;background:#fff0dc}.flag-muted{color:#718095;background:#edf1f5}
.metric-value{color:#17324d;font-size:17px;font-weight:850;line-height:21px}.metric-pending{color:#b66a08}.metric-label{margin-top:1px;color:#718095;font-size:8px;line-height:12px;text-align:center} .metric-value{color:#17324d;font-size:17px;font-weight:850;line-height:21px}.metric-pending{color:#b66a08}.metric-label{margin-top:1px;color:#718095;font-size:8px;line-height:12px;text-align:center}
+25 -45
View File
@@ -5,23 +5,18 @@
<button class="refresh-button" :disabled="busy" @tap="loadSources">刷新</button> <button class="refresh-button" :disabled="busy" @tap="loadSources">刷新</button>
</view> </view>
<view v-if="currentBindings.length"> <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> <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>
<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 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"> <view v-for="pallet in measurements" :key="pallet.palletCode" class="measurement-card">
<text class="heading">{{ pallet.palletCode }} · {{ pallet.packages }} 箱</text> <text class="heading">{{ pallet.palletCode }} · {{ pallet.packages }} 箱</text>
<text v-if="pallet.volumeCbm != null" class="hint">{{ pallet.inherited ? '来源汇总' : '已确认' }}:{{ pallet.volumeCbm }} m³ · {{ pallet.grossWeightKg }} kg</text> <text class="hint">{{ pallet.inherited ? '原托盘货物未变,沿用原测量;包装变化请重新测量。' : '拆托、合托后请确认本托最终尺寸和毛重,原托盘剩余货物也需补测。' }}</text>
<view v-if="pallet.inherited && pallet.sourceCartonSpecs?.length" class="source-carton-specs"> <text v-if="pallet.volumeCbm != null" class="hint">已确认:{{ pallet.volumeCbm }} m³ · {{ pallet.grossWeightKg }} kg</text>
<text class="source-caption">来源箱规(长 × 宽 × 高)</text> <view class="measurement-inputs">
<text v-for="(spec, index) in pallet.sourceCartonSpecs" :key="index" class="hint">{{ spec.lengthCm }} × {{ spec.widthCm }} × {{ spec.heightCm }} cm · {{ spec.grossWeightKg }} kg/箱 · {{ spec.cartons }} 箱</text>
</view>
<text v-if="pallet.inherited && pallet.lengthCm > 0 && pallet.widthCm > 0 && pallet.heightCm > 0" class="hint">原整托尺寸:{{ pallet.lengthCm }} × {{ pallet.widthCm }} × {{ pallet.heightCm }} cm</text>
<button v-if="!pallet.cartonAggregated && pallet.inherited && !pallet.remeasuring" :disabled="busy" @tap="pallet.remeasuring = true">包装变化,重新测量</button>
<view v-if="!pallet.cartonAggregated && (!pallet.inherited || pallet.remeasuring)" 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> <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> </view>
<button v-if="!pallet.cartonAggregated && (!pallet.inherited || pallet.remeasuring)" :disabled="busy" @tap="saveMeasurement(pallet)">确认本托实测</button> <button :disabled="busy" @tap="saveMeasurement(pallet)">确认本托实测</button>
<button v-if="!pallet.cartonAggregated && pallet.inherited && pallet.remeasuring" :disabled="busy" @tap="pallet.remeasuring = false">取消重新测量</button>
</view> </view>
<view v-if="rebindSelection"><text class="hint">正在改绑:{{ rebindSelection.mark }},当前托盘:{{ rebindSelection.pallet }}</text><button :disabled="busy" @tap="rebindSelection = null; targetCode = ''; target = null">取消改绑</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"> <template v-if="!allBindingsComplete && !rebindSelection">
@@ -32,13 +27,17 @@
<text v-if="!busy && !operableSourceRows.length" class="hint">当前没有可操作的来源托盘或来源箱</text> <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="!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" /> <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> <text v-if="source" class="hint">来源已确认:{{ source.palletCode }};尚未拆出,请完成贴标登记、核对拆出数量并扫描目标托盘,绑定成功后才保存拆托明细。</text>
<view v-if="selectedSource"> <view v-if="selectedSource">
<text v-if="isReturnBulk" class="hint">本次{{ wholeSource ? '整托换标数量' : '拆出件数' }}(可分多个来源托盘,逐批提交)</text>
<template v-if="isReturnBulk"> <template v-if="isReturnBulk">
<text class="return-pallet-summary">{{ selectedSource.palletCode }}:{{ wholeSource ? '整托换标' : '部分拆箱' }} {{ quantity || 0 }} 件 → {{ selectedDestination || '目的仓待维护' }}</text> <text class="return-pallet-summary">{{ selectedSource.palletCode }}:{{ wholeSource ? '整托换标' : '部分拆箱' }} {{ quantity || 0 }} 件 → {{ selectedDestination || '目的仓待维护' }}</text>
<input v-if="!wholeSource && allowDisassemble" v-model="quantity" type="number" :disabled="busy" :placeholder="`拆出件数(${plannedPackages} 件)`" /> <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>
<template v-else><view class="split-quantity-row"><text>本次拆出箱数</text><input v-model="quantity" type="number" :disabled="busy" placeholder="箱数" @input="prepareMeasuredBatch" /><text>箱</text></view></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>
<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> <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>
@@ -46,6 +45,8 @@
<ScanTargetInput v-model="targetCode" name="relabel-target" label="目标托盘" placeholder="扫描同目的仓目标托盘或新托盘号" :active="active === 'target'" @activate="activate('target')" @submit="confirmTargetAndBind" /> <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 v-if="canRetainOriginal" :disabled="busy" @tap="quantity = String(source.totalPackages); targetCode = source.palletCode; confirmTargetAndBind()">整托全部贴标,保留原托盘</button>
<button :disabled="busy || !targetCode.trim()" @tap="confirmTargetAndBind">{{ rebindSelection ? '确认改绑目标托盘' : '确认绑定目标托盘' }}</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> <view v-if="newPallet" class="hint"><text>托盘 {{ newPallet.palletCode }} 待绑定</text><button @tap="printPallet">查看 / 打印托盘标签</button></view>
</template> </template>
<text v-if="message" class="hint">{{ message }}</text><text v-if="error" class="error">{{ error }}</text> <text v-if="message" class="hint">{{ message }}</text><text v-if="error" class="error">{{ error }}</text>
@@ -54,7 +55,7 @@
<script> <script>
import ScanTargetInput from '@/components/ScanTargetInput.vue' import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { resolvePallet, createRelabelPallet, bindRelabelPallet, findRelabelSources, listRelabelPalletMeasurements, measureRelabelPallet } from '@/api/pallet.js' import { resolvePallet, createRelabelPallet, bindRelabelPallet, findRelabelSources, listRelabelPalletMeasurements, measureRelabelPallet } from '@/api/pallet.js'
import { warehouseIdempotencyKey } from '@/stores/warehouse-work.js' import { warehouseIdempotencyKey, releaseWarehouseKey } from '@/stores/warehouse-work.js'
export default { export default {
components: { ScanTargetInput }, components: { ScanTargetInput },
props: { task: { type: Object, required: true }, allowDisassemble: { type: Boolean, default: true } }, props: { task: { type: Object, required: true }, allowDisassemble: { type: Boolean, default: true } },
@@ -62,8 +63,6 @@ export default {
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: '' } }, 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() }, mounted() { this.loadSources() },
computed: { computed: {
measuredBoxChoices() { return this.measurements.find(p => p.palletCode === this.selectedSource?.palletCode)?.cartons || [] },
requiresMeasuredBoxMapping() { return new Set(this.measuredBoxChoices.map(box => [box.lengthCm,box.widthCm,box.heightCm,box.grossWeightKg].join(':'))).size > 1 },
canRetainOriginal() { return !this.isReturnBulk && !this.unboundSourceMode && this.source && this.selectedSource && this.plannedPackages === Number(this.source.totalPackages) }, 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)) }, 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() { allBindingsComplete() {
@@ -96,31 +95,14 @@ export default {
const pending = this.sourceItems().filter(item => Number(item.relabelledPackages || 0) < Number(item.expectedPackages || 1)) const pending = this.sourceItems().filter(item => Number(item.relabelledPackages || 0) < Number(item.expectedPackages || 1))
if (!pending.length) return '' if (!pending.length) return ''
const missingSku = pending.some(item => item.operationType === 'CARTON_AND_SKU' && Number(item.skuProcessedPackages || 0) < Number(item.expectedPackages || 1)) const missingSku = pending.some(item => item.operationType === 'CARTON_AND_SKU' && Number(item.skuProcessedPackages || 0) < Number(item.expectedPackages || 1))
if (missingSku) return '请先完成 SKU 贴标登记' if (missingSku) return '本托盘还有未完成的 SKU 贴标登记。混合换标须完成箱唛和 SKU 两项,请返回 SKU 页签处理后再确认绑定。'
return '请先完成贴标登记' return '本托盘还有未完成的换标明细,请先完成贴标登记,再确认目标托盘绑定。'
}, },
wholeSource() { return this.isReturnBulk && this.selectedSource && (!this.allowDisassemble || this.plannedPackages === Number(this.selectedSource.stockPackages || 0)) }, 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')) }, 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() } 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: { methods: {
prepareMeasuredBatch(event) {
this.quantity = String(event.detail.value)
this.marks = []
const count = Number(this.quantity)
if (this.measuredBoxChoices.length && Number.isSafeInteger(count) && count > 0 && count <= this.selectedSource.movablePackages && count <= this.eligibleItems.length) this.fillBatch()
},
// 按申请时的仓库箱唛或客户箱唛对应原实测箱,禁止重新选择其他箱。
measuredCartonBindings() {
if (!this.requiresMeasuredBoxMapping) return []
const used = new Set()
return this.marks.map(mark => {
const matches = this.measuredBoxChoices.filter(box => box.cartonMark === mark || box.customerCartonMark === mark)
const box = matches.length === 1 && !used.has(matches[0].cartonMark) ? matches[0] : null
if (box) used.add(box.cartonMark)
return { cartonMark: mark, sourceCartonMark: box?.cartonMark }
})
},
async saveMeasurement(pallet) { async saveMeasurement(pallet) {
const dimensions = Object.fromEntries(this.measurementFields.map(field => [field.key, Number(pallet[field.key])])) 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 } if (!Object.values(dimensions).every(value => Number.isFinite(value) && value > 0)) { this.error = '请填写有效的长、宽、高和毛重'; return }
@@ -128,6 +110,7 @@ export default {
const key = warehouseIdempotencyKey('relabel', 'measure-pallet', payload) const key = warehouseIdempotencyKey('relabel', 'measure-pallet', payload)
await this.run(async () => { await this.run(async () => {
this.measurements = await measureRelabelPallet({ ...payload, idempotencyKey: key.value }) this.measurements = await measureRelabelPallet({ ...payload, idempotencyKey: key.value })
releaseWarehouseKey('relabel', key.signature)
this.message = `托盘 ${pallet.palletCode} 实测已保存` this.message = `托盘 ${pallet.palletCode} 实测已保存`
this.$emit('saved') this.$emit('saved')
}) })
@@ -160,9 +143,9 @@ export default {
const binding = this.rebindSelection const binding = this.rebindSelection
const targetCode = this.targetCode.trim().toUpperCase() const targetCode = this.targetCode.trim().toUpperCase()
if (!binding || !targetCode || this.busy) return if (!binding || !targetCode || this.busy) return
if (targetCode === binding.pallet.toUpperCase()) { this.showBindingMessage('无需改绑', '该箱唛已绑定此托盘'); return } if (targetCode === binding.pallet.toUpperCase()) { this.showBindingMessage('无需改绑', '该箱唛已经绑定此托盘,不会重复扣库存。'); return }
this.busy = true; this.$emit('busy', true) 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) })) 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) this.busy = false; this.$emit('busy', false)
if (!confirmed) return if (!confirmed) return
await this.run(async () => { await this.run(async () => {
@@ -171,6 +154,7 @@ export default {
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 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) const key = warehouseIdempotencyKey('relabel', 'rebind-pallet', payload)
this.savePalletTotals(await bindRelabelPallet({ ...payload, idempotencyKey: key.value })) this.savePalletTotals(await bindRelabelPallet({ ...payload, idempotencyKey: key.value }))
releaseWarehouseKey('relabel', key.signature)
this.rebindSelection = null; this.targetCode = ''; this.target = null this.rebindSelection = null; this.targetCode = ''; this.target = null
this.$emit('saved') this.$emit('saved')
this.showBindingMessage('绑定成功', `托盘 ${target.palletCode} 已绑定`) this.showBindingMessage('绑定成功', `托盘 ${target.palletCode} 已绑定`)
@@ -253,17 +237,16 @@ export default {
let reason = '' let reason = ''
if (!this.selectedSource) reason = '请先选择实际拆出货物的来源托盘或来源箱。' if (!this.selectedSource) reason = '请先选择实际拆出货物的来源托盘或来源箱。'
else if (this.unboundSourceMode && !this.bindingSelectionValid()) reason = '请先完成来源箱换标,再绑定目标托盘。' else if (this.unboundSourceMode && !this.bindingSelectionValid()) reason = '请先完成来源箱换标,再绑定目标托盘。'
else if (!this.unboundSourceMode && !this.samePallet(this.source, this.selectedSource)) reason = this.isReturnBulk ? '来源托盘识别失败,请重新选择。' : '请扫描确认来源托盘' 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 (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 (!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.selectedDestination || !this.bindingSelectionValid()) reason = this.isReturnBulk ? `本托盘拆箱数量必须等于贴标预报分配数量 ${this.plannedPackages} 件。` : '本次拆出箱数必须对应已完成换标且尚未拆出的同目的仓箱唛。'
else if (this.measuredCartonBindings().some(row => !row.sourceCartonMark)) reason = '换标箱与来源实测箱不匹配,请核对来源托盘'
else if (!this.targetCode.trim()) reason = '请扫描或输入目标托盘号。' else if (!this.targetCode.trim()) reason = '请扫描或输入目标托盘号。'
if (reason) { this.error = reason; this.showBindingMessage('尚未绑定', reason); return } if (reason) { this.error = reason; this.showBindingMessage('尚未绑定', reason); return }
await this.resolve('target') await this.resolve('target')
if (this.error) { this.showBindingMessage('绑定失败', this.error); return } if (this.error) { this.showBindingMessage('绑定失败', this.error); return }
if (!this.readyToBind) { 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.target = null; 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() await this.bind()
}, },
destination(mark) { return this.task.items.find(item => item.cartonMark === mark)?.destinationFbaWarehouseCode || '目的仓待维护' }, destination(mark) { return this.task.items.find(item => item.cartonMark === mark)?.destinationFbaWarehouseCode || '目的仓待维护' },
@@ -297,14 +280,11 @@ export default {
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 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() { async bind() {
if (!this.readyToBind) { this.error = '请核对来源托盘、拆出箱数、本批箱唛和目标托盘'; return } if (!this.readyToBind) { this.error = '请核对来源托盘、拆出箱数、本批箱唛和目标托盘'; return }
const measuredCartons = this.measuredCartonBindings()
if (measuredCartons.some(row => !row.sourceCartonMark)) { this.error = '换标箱与来源实测箱不匹配,请核对来源托盘'; return }
const expectation = value => ({ palletCode: value.palletCode, expectedVersion: value.palletVersion }) 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 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 按原入仓单箱数拆托并按新目的仓合托' }
if (measuredCartons.length) payload.measuredCartons = measuredCartons
const key = warehouseIdempotencyKey('relabel', 'bind-pallet', payload) const key = warehouseIdempotencyKey('relabel', 'bind-pallet', payload)
let saved = false let saved = false
await this.run(async () => { await bindRelabelPallet({ ...payload, idempotencyKey: key.value }); saved = true; 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') }) 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() } if (saved) { this.showBindingMessage('绑定成功', this.message); await this.loadSources() }
else if (this.error) this.showBindingMessage('绑定失败', this.error) else if (this.error) this.showBindingMessage('绑定失败', this.error)
}, },
+3 -4
View File
@@ -49,7 +49,7 @@
<script> <script>
import ScanTargetInput from '@/components/ScanTargetInput.vue' import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { scanReturnCarton, completePackingPlan, mergeReturnBoxes, getReturnCartonLabel } from '@/api/return-packing.js' import { scanReturnCarton, completePackingPlan, mergeReturnBoxes, getReturnCartonLabel } from '@/api/return-packing.js'
import { updateWarehouseWork, warehouseWork, warehouseIdempotencyKey } from '@/stores/warehouse-work.js' import { updateWarehouseWork, warehouseWork, warehouseIdempotencyKey, releaseWarehouseKey } from '@/stores/warehouse-work.js'
export default { export default {
components: { ScanTargetInput }, components: { ScanTargetInput },
props: { plan: { type: Object, default: null }, initialSource: { type: Object, default: null } }, props: { plan: { type: Object, default: null }, initialSource: { type: Object, default: null } },
@@ -143,12 +143,11 @@ export default {
const totals={}; for(const item of sourceDeductions) totals[item.skuCode]=(totals[item.skuCode]||0)+item.quantity 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 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 box={items,...(this.mode==='NEW'?Object.fromEntries(this.fields.map(f=>[f.key,Number(this.dimensions[f.key])])):{targetCartonMark:this.target.cartonMark})}
const expectedSourceQuantities = this.sources.flatMap(source => (source.items || []).map(item => ({ cartonMark: source.cartonMark, skuCode: item.skuCode, quantity: Number(item.quantity) }))) const payload = { sourceCartonMarks: this.sources.map(box => box.cartonMark), targetCartonMark: this.target?.cartonMark,sourceDeductions,...box }
const payload = { sourceCartonMarks: this.sources.map(box => box.cartonMark), targetCartonMark: this.target?.cartonMark, sourceDeductions, expectedSourceQuantities, ...box }
const key = warehouseIdempotencyKey('returnPacking', 'box-merge', { ...payload, planNo: this.plan?.planNo }) const key = warehouseIdempotencyKey('returnPacking', 'box-merge', { ...payload, planNo: this.plan?.planNo })
await this.run(async () => { 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 }) const result = this.plan ? await completePackingPlan(this.plan.planNo, { boxes: [box], sourceDeductions, idempotencyKey: key.value }) : await mergeReturnBoxes({ ...payload, idempotencyKey: key.value })
this.$emit('completed', { ...result, targetMode: this.mode }) 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) } } 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) } }
+2 -2
View File
@@ -9,7 +9,7 @@
<script> <script>
import { packReturnRelabelCartons } from '@/api/relabel.js' import { packReturnRelabelCartons } from '@/api/relabel.js'
import { requiresReturnPack, returnDeductions, returnPackFacts, returnSelections } from '@/utils/return-relabel.js' import { requiresReturnPack, returnDeductions, returnPackFacts, returnSelections } from '@/utils/return-relabel.js'
import { warehouseIdempotencyKey } from '@/stores/warehouse-work.js' import { warehouseIdempotencyKey, releaseWarehouseKey } from '@/stores/warehouse-work.js'
export default { export default {
props: { task: { type: Object, required: true }, locked: { type: Boolean, default: false } }, emits: ['saved', 'error', 'busy', 'refresh'], 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' }] } }, 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' }] } },
@@ -23,7 +23,7 @@ export default {
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 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 }) const key = warehouseIdempotencyKey('relabel', 'return-pack', { relabelNo: this.task.relabelNo, round: this.task.reviewedAt || 'INITIAL', ...payload })
this.busy = true; this.$emit('busy', true) this.busy = true; this.$emit('busy', true)
try { const result = await packReturnRelabelCartons(this.task.relabelNo, { ...payload, idempotencyKey: key.value }); this.$emit('saved', result) } catch (error) { if (['409', 'A0409', 'B0409'].includes(String(error?.code || ''))) { this.$emit('refresh') } this.$emit('error', error) } finally { this.busy = false; this.$emit('busy', false) } 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) { print(label) {
// #ifdef H5 // #ifdef H5
+5 -5
View File
@@ -2,14 +2,14 @@ export const MODULES = [
{ key: 'inbound', name: '入仓作业', nameKey: 'modules.inbound.name', icon: '入', description: '处理拆柜收货与客户退货实收入仓', descriptionKey: 'modules.inbound.description', operations: [ { 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: '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: '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: '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: '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: '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: '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: '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: '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: 'move', name: '商品移库', nameKey: 'modules.move.name', icon: '移', iconType: 'loop', description: '扫描来源与目标库位完成移库', descriptionKey: 'modules.move.description', available: true, route: '/pages/move/move' },
+1
View File
@@ -2,6 +2,7 @@
"pages": [ "pages": [
{ "path": "pages/login/login", "style": { "navigationStyle": "custom", "navigationBarTitleText": "柏悦物流海外版", "backgroundColor": "#F5F7FA" } }, { "path": "pages/login/login", "style": { "navigationStyle": "custom", "navigationBarTitleText": "柏悦物流海外版", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/home/home", "style": { "navigationStyle": "custom", "navigationBarTitleText": "海外仓工作台", "backgroundColor": "#F5F7FA" } }, { "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/operation/operation", "style": { "navigationStyle": "custom", "navigationBarTitleText": "柜号入库与现场作业", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/warehouse-transfer/warehouse-transfer", "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-receipt/return-receipt", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货入库", "backgroundColor": "#F5F7FA" } },
+196 -287
View File
@@ -1,300 +1,209 @@
<template> <template>
<view v-if="user" class="page-shell portal-page fulfillment-page"> <view v-if="user" class="page-shell portal-page fulfillment-page">
<PortalHeader title="排车作业" :user="user" /> <PortalHeader title="排车拣货" :user="user" />
<scroll-view class="fulfillment-scroll" scroll-y><view class="portal-content fulfillment-content"> <scroll-view class="fulfillment-scroll" scroll-y>
<view class="fulfillment-heading"><text class="fulfillment-title">理货审核、真实拣货与装车</text></view> <view class="portal-content fulfillment-content">
<view v-if="current" class="detail-toolbar"><text class="detail-title">{{ current.dispatchNo }}</text><button :disabled="busy" @tap="closeTask">‹ 返回列表</button></view> <view class="fulfillment-heading">
<view v-if="current" class="stage-switch" role="tablist" aria-label="排车作业"> <text class="fulfillment-title">排车拣货与发车</text>
<button id="dispatch-picking-tab" class="stage-tab" :class="{ active: activeTab === 'picking' }" role="tab" :aria-selected="activeTab === 'picking'" aria-controls="dispatch-picking-panel" :disabled="busy || loading" @tap="selectTab('picking')">拣货作业</button> <text class="fulfillment-subtitle">扫描排车单号打开任务,再扫描任务中的托盘码进行拣货</text>
<button id="dispatch-photos-tab" class="stage-tab" :class="{ active: activeTab === 'photos' }" role="tab" :aria-selected="activeTab === 'photos'" aria-controls="dispatch-photos-panel" :disabled="!canShowPhotos || busy || loading" @tap="selectTab('photos')">装车拍照</button> </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" />
<button class="secondary-button" :disabled="!scanCode || loading || busy" @tap="handleScan(scanCode)">{{ 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>
<template v-if="!loading && !current">
<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"><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 || '—' }} · {{ 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>
</view>
<view v-if="current.items.length" class="item-list">
<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>
<view v-else class="fulfillment-state"><text class="state-title">任务没有可拣货明细</text><text>请联系平台确认排车托盘是否已下发。</text></view>
<view v-if="selectedItem && selectedRemaining > 0" class="fulfillment-card">
<text class="card-title">本次拣货:{{ selectedItem.palletNo }}</text>
<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>
<view v-if="canCompleteStage" class="fulfillment-card gate-card">
<text class="card-title">完成拣货</text>
<text v-if="remainingPackages > 0" class="gate-warning">尚有 {{ remainingPackages }} 箱未拣,不能完成排车拣货。</text>
<text v-else class="gate-note">全部计划箱数已拣齐,可以完成任务。</text>
<button class="primary-button" :disabled="!canComplete || busy" @tap="completePicking">{{ busyAction === 'complete' ? '完成中…' : '完成排车拣货' }}</button>
</view>
<view v-if="pickingCompleted" class="fulfillment-card gate-card">
<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>
</view>
</template>
<view v-if="successMessage" class="fulfillment-success" role="status"><text>{{ successMessage }}</text></view>
</view> </view>
<view v-if="!current || activeTab === 'picking'" class="fulfillment-card dispatch-scan-card"> </scroll-view>
<text class="card-title">{{ current ? '扫码拣货' : '扫码查询排车' }}</text>
<ScanTargetInput name="dispatch" :model-value="scanCode" label="排车单号/实物托盘码/来源箱唛" placeholder="扫描业务单号或实物码" :active="activeTab === 'picking'" @update:modelValue="scanCode = $event" @submit="handleScan" />
<button class="secondary-button" :disabled="busy || loading || !scanCode" @tap="handleScan(scanCode)">匹配扫描</button>
</view>
<view v-if="loading" class="fulfillment-state"><text>正在加载排车事实…</text></view>
<view v-if="error" class="fulfillment-error" role="alert"><text class="state-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><text v-if="error.network">网络中断,请稍后重新操作。</text><button :disabled="busy || loading" @tap="retry">重新核对</button></view>
<template v-if="!current && !loading">
<view class="list-toolbar"><text class="list-title">排车任务</text><button :disabled="busy" @tap="loadTasks">刷新</button></view>
<view v-if="!tasks.length && !error" class="fulfillment-state">暂无排车任务,可扫描排车单号读取。</view>
<view v-if="tasks.length" class="task-list">
<button v-for="task in tasks" :key="task.dispatchNo" class="task-card" @tap="openTask(task.dispatchNo)"><text class="business-code">{{ task.dispatchNo }}</text><text v-if="task.dispatchNo === recentTaskNo" class="recent-badge">上次作业</text><text class="status-badge">{{ stageLabel(task) }}</text><text class="task-meta">任务号 {{ task.taskNo || '—' }}</text></button>
</view>
</template>
<template v-if="current">
<view v-show="activeTab === 'photos'" id="dispatch-photos-panel" role="tabpanel" aria-labelledby="dispatch-photos-tab"><DispatchLoadingDocuments v-if="canShowPhotos" :key="current.dispatchNo" :dispatch-no="current.dispatchNo" @busy-change="photoBusy = $event" @saved="openTask(current.dispatchNo)" /></view>
<view v-show="activeTab === 'picking'" id="dispatch-picking-panel" class="picking-workspace" role="tabpanel" aria-labelledby="dispatch-picking-tab">
<DispatchHandoverStatus v-if="departed" :dispatch-no="current.dispatchNo" :record-version="current.recordVersion" />
<view class="fulfillment-card summary-card">
<text class="card-title">任务摘要</text>
<view class="task-head"><text class="business-code">{{ current.dispatchNo }}</text><text class="status-badge">{{ stageLabel(current) }}</text></view>
<text class="task-meta">任务号 {{ current.taskNo || '—' }}</text>
<text v-if="!gate('PICK') && current.stage === 'LOADING'" class="gate-warning">{{ current.actions.PICK?.blockReason || '拣货资格尚未允许,请核对两级审核及当前作业状态' }}</text>
<text class="picking-section-label">拣货进度</text>
<view class="metrics" aria-label="拣货进度"><view><text class="metric-value">{{ plannedPackages }}</text><text>计划箱数</text></view><view><text class="metric-value success">{{ pickedPackages }}</text><text>真实已拣</text></view><view><text class="metric-value warning">{{ remainingPackages }}</text><text>未拣箱数</text></view></view>
</view>
<view v-if="current.stage === 'BEFORE_LOADING'" class="fulfillment-card review-card">
<text class="card-title">理货员审核</text>
<view class="review-facts">
<view class="review-fact"><text class="review-label">车牌号</text><text>{{ current.resources?.vehicle?.plate || '—' }}</text></view>
<view class="review-fact"><text class="review-label">托车公司</text><text>{{ current.resources?.vehicle?.carrierName || '—' }}</text></view>
<view class="review-fact"><text class="review-label">司机</text><text>{{ current.resources?.driver?.name || '—' }}</text></view>
<view class="review-fact"><text class="review-label">电话</text><text>{{ current.resources?.driver?.phone || '—' }}</text></view>
<view class="review-fact"><text class="review-label">计划发车</text><text>{{ reviewTime(current.plannedDepartureAt) }}(北京时间)</text></view>
<view class="review-fact"><text class="review-label">预计送达</text><text>{{ reviewTime(current.estimatedArrivalAt) }}(北京时间)</text></view>
<view v-for="appointment in current.fbaAppointments || []" :key="appointment.destinationKey" class="review-fact"><text class="review-label">{{ appointment.destinationWarehouseCode }}</text><view class="review-appointment"><text>{{ appointment.appointmentNo || '—' }}</text><text>{{ appointment.status === 'SUCCESS' ? '已确认' : appointment.status === 'REGISTERED' ? '已登记' : '未填写' }}:{{ reviewTime(appointment.confirmedAt) }}(北京时间)</text></view></view>
<view class="review-fact"><text class="review-label">驾驶证号</text><text>{{ current.resources?.driver?.evidence?.licenseNo || '—' }}</text></view>
<view class="review-fact"><text class="review-label">有效期</text><text>{{ current.resources?.driver?.evidence?.licenseExpiresOn || '—' }}</text></view>
<view class="review-fact"><text class="review-label">调度审核</text><text>{{ reviewLabel(current.reviews?.dispatchReview) }} · {{ reviewTime(current.reviews?.dispatchReview?.reviewedAt) }}</text></view>
<view v-if="current.reviews?.dispatchReview?.reason" class="review-fact"><text class="review-label">调度意见</text><text>{{ current.reviews.dispatchReview.reason }}</text></view>
</view>
<text v-if="rejectionReason" class="gate-warning">已退回客服处理(车辆信息):{{ rejectionReason }}。修改后须重新提交审核。</text>
<template v-if="canReview">
<text v-if="current.actions.LOADING_REVIEW?.blockCode === 'APPROVAL_BLOCKED'" class="gate-warning">{{ current.actions.LOADING_REVIEW.blockReason }}</text>
<text class="task-meta">请核对车辆、司机及下方货物;通过后才能开始真实拣货。驳回将回到客服处理(车辆信息)。</text>
<textarea :value="reviewReason" maxlength="1000" placeholder="审核意见(驳回时必填)" :disabled="busy || loading" @input="updateReviewReason" />
<view class="review-actions"><button :disabled="busy || loading || !reviewReason.trim()" @tap="submitReview('REJECTED')">驳回</button><button class="primary-button" :disabled="busy || loading || current.actions.LOADING_REVIEW?.blockCode === 'APPROVAL_BLOCKED'" @tap="submitReview('APPROVED')">审核通过</button></view>
</template>
<text v-else-if="!rejectionReason" class="gate-warning">{{ current.actions.LOADING_REVIEW?.blockReason || '当前不具备理货审核资格,请先完成客服提交和调度审核' }}</text>
</view>
<text v-if="current.items.length" class="picking-section-title">托盘与货物来源</text>
<view v-for="item in current.items" :key="item.dispatchItemId" class="fulfillment-card item-card picking-item" :class="{ selected: selectedItem?.dispatchItemId === item.dispatchItemId }" @tap="selectPallet(item)">
<view class="task-head"><text class="business-code">{{ item.palletNo || '未关联托盘码' }}</text><text v-if="selectedItem?.dispatchItemId === item.dispatchItemId" class="picking-selected">当前选择</text></view>
<view class="picking-fact"><text class="picking-fact-label">{{ item.sourceBusinessType === 'RELABEL_ORDER' ? '换标单号' : '来源业务号' }}</text><text>{{ item.sourceBusinessNo || '—' }}</text></view>
<view class="picking-fact"><text class="picking-fact-label">客户</text><text>{{ item.customerName || '—' }}</text></view>
<view class="picking-fact"><text class="picking-fact-label">目的地</text><text>{{ routeLabel(item) }}</text></view>
<text v-for="carton in item.returnCartons" :key="carton.returnCartonId" class="task-meta picking-carton">{{ carton.cartonMark }} · {{ carton.status === 'OUTBOUNDED' ? '已拣货' : '待扫描' }}</text>
<text class="picking-item-progress">已拣 {{ item.pickedPackages }} / {{ item.scheduledPackages }} 箱</text>
<view class="progress"><view :style="{ width: `${itemProgress(item)}%` }"></view></view>
</view>
<view v-if="!current.items.length" class="fulfillment-state">本车暂无货物,空单不能核平、装车或发车。</view>
<view v-if="current.stage === 'LOADING' && gate('PICK') && selectedItem && selectedItem.scheduledPackages > selectedItem.pickedPackages && selectedRemaining === 0" class="fulfillment-card gate-card"><text class="gate-warning">该托盘暂无可拣来源,请核对是否已完成实收,或货物是否冻结、被占用及来源发生变化。</text><button :disabled="busy || loading" @tap="retry">刷新托盘实收信息</button></view>
<view v-if="sourceChoices.length > 1" class="fulfillment-card picking-source-choices"><text class="card-title">请选择本次真实拣货来源</text><button v-for="item in sourceChoices" :key="item.dispatchItemId" :disabled="busy" @tap="chooseSource(item)"><text class="business-code">{{ item.sourceBusinessNo || '—' }}</text><text class="task-meta">客户:{{ item.customerName || '—' }}</text><text class="picking-item-progress">可拣 {{ pickablePackages(item) }} 箱</text></button></view>
<view v-if="current.stage === 'LOADING' && gate('PICK') && selectedItem && selectedRemaining > 0" class="fulfillment-card picking-current">
<text class="card-title">当前拣货</text><text class="business-code">{{ selectedItem.palletNo || '来源箱唛' }}</text>
<text v-if="selectedItem.returnCartons.length" class="picking-instruction">{{ selectedReturnCarton ? `已扫 ${selectedReturnCarton.cartonMark}` : '请扫描来源箱唛,每次一箱' }}</text>
<view v-else class="picking-quantity"><text class="picking-instruction">拣货箱数</text><input type="number" aria-label="本次拣货箱数" :value="packages" :placeholder="`请输入 1-${selectedRemaining} 箱`" :disabled="busy" @input="updatePackages" /><text class="task-meta">本次最多可拣 {{ selectedRemaining }} 箱</text></view>
<button class="primary-button" :disabled="!canPick || busy" @tap="submitPick">确认真实拣货</button>
</view>
<view v-if="current.stage === 'LOADING'" class="fulfillment-card gate-card picking-completion"><text v-if="!gate('LOADING_COMPLETE')" class="gate-warning">{{ current.actions.LOADING_COMPLETE?.blockReason || '尚未获得装车完成资格' }}</text><button class="primary-button" :disabled="!canComplete || busy" @tap="prepareLoading">确认装车完成</button></view>
</view>
</template><view v-if="successMessage" class="fulfillment-success" role="status">{{ successMessage }}</view>
</view></scroll-view>
<view v-if="scanNotice" class="loading-overlay scan-notice-overlay">
<view class="loading-dialog scan-notice-dialog" role="status" aria-live="polite">
<text class="loading-dialog-title">托盘扫描成功</text>
<text class="scan-notice-pallet">{{ scanNotice.palletNo }}</text>
<text class="scan-notice-label">本次可拣箱数</text>
<text class="scan-notice-count">{{ scanNotice.packages }}<text class="loading-count-unit"> 箱</text></text>
<text class="scan-notice-hint">3 秒后自动关闭</text>
</view>
</view>
<view v-if="loadingPreview" class="loading-overlay">
<view class="loading-dialog" role="dialog" aria-modal="true" aria-labelledby="loading-confirm-title">
<view class="loading-dialog-heading"><text id="loading-confirm-title" class="loading-dialog-title">确认装车</text></view>
<view class="loading-counts"><view class="loading-count"><text class="loading-count-value">{{ loadingRows.length }}<text class="loading-count-unit"> 托</text></text><text class="loading-count-label">已拣托盘</text></view><view class="loading-count"><text class="loading-count-value">{{ loadingRows.reduce((sum, row) => sum + Number(row.pickedPackages || 0), 0) }}<text class="loading-count-unit"> 箱</text></text><text class="loading-count-label">已拣箱数</text></view></view>
<view class="loading-dialog-actions"><button class="loading-cancel" :disabled="busy" @tap="loadingPreview = null">取消</button><button class="loading-confirm" :disabled="busy" :loading="busy" @tap="completeLoading">确认</button></view>
</view>
</view>
</view> </view>
</template> </template>
<script> <script>
import PortalHeader from '@/components/PortalHeader.vue' import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue' import ScanTargetInput from '@/components/ScanTargetInput.vue'
import DispatchHandoverStatus from '@/components/DispatchHandoverStatus.vue' import DispatchHandoverStatus from '@/components/DispatchHandoverStatus.vue'
import DispatchLoadingDocuments from '@/components/DispatchLoadingDocuments.vue' import { completeDispatchPicking, departDispatch, getDispatchTask, listDispatchTasks, pickDispatchItem, tripDepartureRequest } from '@/api/fulfillment.js'
import { reviewDispatchLoading, completeDispatchLoading, getDispatchTask, getDispatchLoading, listDispatchTasks, pickDispatchItem, requireDispatchVersion, dispatchVersionAtLeast } from '@/api/fulfillment.js' import { fulfillment, fulfillmentIdempotencyKey, releaseFulfillmentKey, restoreFulfillmentDraft, updateFulfillmentDraft } from '@/stores/fulfillment.js'
import { fulfillment, restoreFulfillmentDraft, updateFulfillmentDraft } from '@/stores/fulfillment.js' import { requireSession, session } from '@/stores/session.js'
import { requireSession, session } from '@/stores/session.js' import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js' import { toDispatchError } from '@/utils/dispatch-error.js'
import { toDispatchError } from '@/utils/dispatch-error.js' import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
export default { const finishedStatuses = ['COMPLETED', 'PICKING_COMPLETED', 'READY_TO_DEPART', 'DEPARTED']
components: { PortalHeader, ScanTargetInput, DispatchHandoverStatus, DispatchLoadingDocuments },
data() { return { user: session.currentUser, tasks: [], current: null, activeTab: 'picking', scanCode: '', scanNotice: null, loading: false, requestBusy: false, photoBusy: false, error: null, successMessage: '', bootstrapped: false, resumeOnEntry: false, generation: 0, loadingPreview: null, loadingRows: [], sourceChoices: [], choiceCartonCode: '', reviewReason: '' } }, export default {
computed: { components: { PortalHeader, ScanTargetInput, DispatchHandoverStatus },
busy() { return this.requestBusy || this.photoBusy }, data() { return { user: session.currentUser, tasks: [], current: null, scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '', bootstrapped: false, resumeOnEntry: false } },
recentTaskNo() { return fulfillment.dispatch.currentNo || '' }, packages() { return fulfillment.dispatch.packages }, computed: {
selectedItem() { return this.current?.items.find(row => row.dispatchItemId === fulfillment.dispatch.dispatchItemId && row.sourceKey === fulfillment.dispatch.sourceKey) || null }, recentTaskNo() { return fulfillment.dispatch.currentNo || '' },
selectedReturnCarton() { const matches = this.selectedItem?.returnCartons.filter(row => row.cartonMark === fulfillment.dispatch.cartonCode && row.status === 'OUTBOUND_PENDING') || []; return matches.length === 1 ? matches[0] : null }, packages() { return fulfillment.dispatch.packages },
selectedPosition() { return this.selectedItem && this.selectedItem.scheduledPackages > this.selectedItem.pickedPackages ? this.selectedItem.positions.find(row => row.availablePackages > 0) || null : null }, selectedItem() { return this.current?.items?.find((item) => fulfillment.dispatch.dispatchItemId ? item.dispatchItemId === fulfillment.dispatch.dispatchItemId : item.palletNo === fulfillment.dispatch.palletCode) || null },
selectedRemaining() { const remaining = Math.max(0, (this.selectedItem?.scheduledPackages || 0) - (this.selectedItem?.pickedPackages || 0)); return this.selectedItem?.returnCartons.length ? remaining : Math.min(remaining, this.selectedPosition?.availablePackages || 0) }, selectedReturnCarton() { return this.selectedItem?.returnCartons?.find((carton) => carton.cartonMark === fulfillment.dispatch.cartonCode && carton.status === 'OUTBOUND_PENDING') || null },
plannedPackages() { return (this.current?.items || []).reduce((sum, row) => sum + row.scheduledPackages, 0) }, pickedPackages() { return (this.current?.items || []).reduce((sum, row) => sum + row.pickedPackages, 0) }, remainingPackages() { return Math.max(0, this.plannedPackages - this.pickedPackages) }, selectedPosition() { return this.selectedItem?.positions?.find((item) => item.availablePackages > 0) || this.selectedItem?.positions?.[0] || null },
canShowPhotos() { return ['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(this.current?.stage) }, 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)) },
departed() { return ['DELIVERING', 'DELIVERED'].includes(this.current?.stage) }, plannedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.scheduledPackages, 0) },
canPick() { return this.current?.status === 'ACTIVE' && this.current?.stage === 'LOADING' && this.gate('PICK') && this.selectedRemaining > 0 && (this.selectedItem?.returnCartons.length ? Boolean(this.selectedReturnCarton) : Boolean(this.selectedPosition) && Number.isInteger(Number(this.packages)) && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining) }, pickedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.pickedPackages, 0) },
canComplete() { return this.current?.status === 'ACTIVE' && this.current?.stage === 'LOADING' && this.current.pickingReconciled === true && this.plannedPackages > 0 && this.remainingPackages === 0 && this.gate('LOADING_COMPLETE') }, remainingPackages() { return Math.max(0, this.plannedPackages - this.pickedPackages) },
canReview() { return this.current?.status === 'ACTIVE' && this.current?.stage === 'BEFORE_LOADING' && this.gate('LOADING_REVIEW') }, allPicked() { return this.plannedPackages > 0 && this.remainingPackages === 0 },
rejectionReason() { return [this.current?.reviews?.loadingReview, this.current?.reviews?.dispatchReview].find(review => review?.status === 'REJECTED')?.reason || '' }, pickingCompleted() { return finishedStatuses.includes(this.current?.status) },
}, departed() { return this.current?.status === 'DEPARTED' },
onLoad(options) { this.resumeOnEntry = options?.resume === '1' }, 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 },
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (!this.bootstrapped) { this.bootstrapped = true; if (this.resumeOnEntry && fulfillment.dispatch.currentNo) this.openTask(fulfillment.dispatch.currentNo); else this.loadTasks() } else if (this.current?.dispatchNo) this.openTask(this.current.dispatchNo); else this.loadTasks() }, canCompleteStage() { return Boolean(this.current) && !this.pickingCompleted },
onHide() { this.stopScanner(); this.clearScanNotice(); this.generation++ }, beforeUnmount() { this.stopScanner(); this.clearScanNotice(); this.generation++ }, canComplete() { return this.current?.canPick === true && this.plannedPackages > 0 && this.remainingPackages === 0 },
methods: { canDepart() { return this.pickingCompleted && this.allPicked && this.current?.canDepart === true && !this.departed }
clearScanNotice() { clearTimeout(this._scanNoticeTimer); this._scanNoticeTimer = null; this.scanNotice = null },
showScanNotice(item) {
this.clearScanNotice()
this.scanNotice = { palletNo: item.palletNo, packages: this.selectedRemaining }
this._scanNoticeTimer = setTimeout(() => this.clearScanNotice(), 3000)
}, },
selectTab(tab) { if (this.busy || this.loading || !['picking', 'photos'].includes(tab) || (tab === 'photos' && !this.canShowPhotos)) return; this.clearScanNotice(); this.activeTab = tab }, onLoad(options) { this.resumeOnEntry = options?.resume === '1' },
gate(action) { return this.current?.actions?.[action]?.allowed === true }, onShow() {
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: value => this.handleScan(value), onError: error => this.setError(error, '扫码广播注册失败') }) }, stopScanner() { this._stopScanner?.(); this._stopScanner = null }, if (!requireSession()) return
updatePackages(event) { const value = Number(event.detail.value); updateFulfillmentDraft('dispatch', { packages: Number.isFinite(value) ? value : 0 }) }, this.user = session.currentUser
async loadTasks() { const generation = ++this.generation; await this.run(async () => { const result = await listDispatchTasks(); if (generation === this.generation) { this.tasks = result; this.current = null } }, true) }, restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`)
async openTask(no) { const generation = ++this.generation; await this.run(async () => { const value = await getDispatchTask(no); if (generation !== this.generation) return; if (value.dispatchNo !== no) throw new Error('返回排车与扫描业务号不一致'); const same = fulfillment.dispatch.currentNo === no; if (this.current?.dispatchNo !== no || this.current?.recordVersion !== value.recordVersion) this.reviewReason = ''; if (this.current?.dispatchNo !== no || !['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(value.stage)) this.activeTab = 'picking'; this.current = value; this.sourceChoices = []; this.choiceCartonCode = ''; updateFulfillmentDraft('dispatch', { currentNo: no, palletCode: same ? fulfillment.dispatch.palletCode : '', dispatchItemId: same ? fulfillment.dispatch.dispatchItemId : '', sourceKey: same ? fulfillment.dispatch.sourceKey : '', cartonCode: same ? fulfillment.dispatch.cartonCode : '', packages: same ? fulfillment.dispatch.packages : 1 }); this.scanCode = '' }, true) }, this.startScanner()
closeTask() { if (this.busy) return; this.current = null; this.activeTab = 'picking'; this.reviewReason = ''; this.loadingPreview = null; this.scanCode = ''; this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { currentNo: '', palletCode: '', dispatchItemId: '', sourceKey: '', cartonCode: '', packages: 1 }); this.loadTasks() }, if (!this.bootstrapped) {
handleScan(raw) { this.bootstrapped = true
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim() if (this.resumeOnEntry && fulfillment.dispatch.currentNo) this.openTask(fulfillment.dispatch.currentNo)
if (!code || this.busy || this.loading || this.activeTab !== 'picking') return else this.loadTasks()
this.clearScanNotice() return
this.error = null; this.successMessage = ''; this.sourceChoices = []; this.choiceCartonCode = '' }
updateFulfillmentDraft('dispatch', { palletCode: '', dispatchItemId: '', sourceKey: '', cartonCode: '', packages: 1 }) if (this.current?.dispatchNo) this.openTask(this.current.dispatchNo)
if (!this.current || code === this.current.dispatchNo || code === this.current.taskNo) return this.openTask(code === this.current?.taskNo ? this.current.dispatchNo : code) else this.loadTasks()
if (this.current.stage !== 'LOADING' || !this.gate('PICK')) return this.setError(new Error(this.current.actions?.PICK?.blockReason || '理货审核通过后才能开始真实拣货'), '尚不能拣货')
const cartonItems = this.current.items.filter(item => item.returnCartons.some(carton => carton.cartonMark === code))
if (cartonItems.some(item => item.returnCartons.filter(carton => carton.cartonMark === code && carton.status === 'OUTBOUND_PENDING').length > 1)) return this.setError(new Error('同一来源返回重复待拣箱唛,请重新读取并核对箱记录'), '扫码未匹配')
const matches = cartonItems.length ? cartonItems.filter(item => item.scheduledPackages > item.pickedPackages && item.returnCartons.some(carton => carton.cartonMark === code && carton.status === 'OUTBOUND_PENDING')) : this.current.items.filter(item => item.palletNo === code && item.scheduledPackages > item.pickedPackages)
if (!matches.length) return this.setError(new Error(cartonItems.length ? '该箱已拣或不可出库' : this.current.items.some(item => item.palletNo === code) ? '该托盘已完成拣货,请勿重复操作' : '扫描未匹配本车实物托盘或来源箱唛'), '扫码未匹配')
this.sourceChoices = matches; this.choiceCartonCode = cartonItems.length ? code : ''
if (matches.length > 1) return this.setError(new Error('此扫码命中多条待拣明细,请按来源业务号、客户和可拣箱数选择'), '请选择明细')
this.chooseSource(matches[0])
}, },
chooseSource(item) { onHide() { this.stopScanner() },
if (this.busy || !this.sourceChoices.some(row => row.dispatchItemId === item.dispatchItemId)) return beforeUnmount() { this.stopScanner() },
const cartonCode = this.choiceCartonCode methods: {
this.selectPallet(item) startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleScan(value), onError: (error) => this.setError(error, '扫码广播注册失败') }) },
if (fulfillment.dispatch.dispatchItemId !== item.dispatchItemId) return stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
if (cartonCode) updateFulfillmentDraft('dispatch', { cartonCode, packages: 1 }) updateScanCode(value) { this.scanCode = value },
else this.showScanNotice(item) updatePackages(event) { const value = Math.trunc(Number(event.detail.value || 0)); updateFulfillmentDraft('dispatch', { packages: Number.isFinite(value) ? Math.max(0, value) : 0 }) },
this.scanCode = ''; playSuccessSound() async loadTasks() { await this.run('加载排车任务', async () => { this.tasks = await listDispatchTasks(); this.current = null }, true) },
}, async openTask(dispatchNo) {
pickablePackages(item) { const code = String(dispatchNo || '').trim(); if (!code) return
const remaining = Math.max(0, item.scheduledPackages - item.pickedPackages) 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)
return Math.min(remaining, item.returnCartons.length ? item.returnCartons.filter(row => row.status === 'OUTBOUND_PENDING').length : item.positions.reduce((sum, row) => sum + row.availablePackages, 0)) },
}, closeTask() { this.current = null; this.scanCode = ''; this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { currentNo: '', palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1 }); this.loadTasks() },
selectPallet(item) { handleScan(raw) {
if (this.current?.stage !== 'LOADING' || !this.gate('PICK') || this.busy || !item?.dispatchItemId || item.scheduledPackages <= item.pickedPackages) return const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim(); if (!code || this.busy || this.loading) return
if (!item.sourceKey || item.sourceBusinessType === 'RELABEL_ORDER' && !item.sourceBusinessNo) return this.setError(new Error('来源业务身份未完整返回,请重新读取任务'), '请选择明细') console.info('[Overseas PDA dispatch] scan', { code, task: this.current?.dispatchNo || '' })
const actualPackages = item.returnCartons.length ? 1 : Math.min(item.scheduledPackages - item.pickedPackages, item.positions.find(row => row.availablePackages > 0)?.availablePackages || 0) this.scanCode = code
updateFulfillmentDraft('dispatch', { palletCode: item.palletNo, dispatchItemId: item.dispatchItemId, sourceKey: item.sourceKey, cartonCode: '', packages: actualPackages }) this.error = null; this.successMessage = ''
this.sourceChoices = []; this.choiceCartonCode = ''; this.error = null 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)
async submitPick() { if (!this.canPick) return; try { const body = { recordVersion: requireDispatchVersion(this.current.recordVersion), positionId: this.selectedReturnCarton ? null : this.selectedPosition.positionId, packages: this.selectedReturnCarton ? 1 : Number(this.packages), productQuantity: this.selectedReturnCarton?.productQuantity || 0, cartonMark: this.selectedReturnCarton?.cartonMark || null, remark: null }; await this.submitCommand(this.current.dispatchNo, 'PICK', { ...body, dispatchItemId: this.selectedItem.dispatchItemId }) } catch (cause) { this.setError(cause, '拣货未提交') } }, const cartonItem = this.current.items.find((item) => item.returnCartons?.some((carton) => carton.cartonMark === code))
async prepareLoading() { if (!this.canComplete) return; await this.run(async () => { const data = await getDispatchLoading(this.current.dispatchNo); if (data.dispatchNo !== this.current.dispatchNo || data.pickingReconciled !== true || data.loadingComplete?.allowed !== true) throw new Error(data.loadingComplete?.blockReason || '装车前置未满足'); requireDispatchVersion(data.recordVersion); this.loadingPreview = data; this.loadingRows = data.pallets }) }, if (cartonItem) {
async completeLoading() { if (!this.loadingPreview || this.busy) return; try { if (!this.loadingRows.length || this.loadingRows.some(row => row.deliveryIndex == null || !Number.isInteger(row.deliveryIndex) || row.deliveryIndex < 1)) throw new Error('托盘卸货站尚未匹配,请后台核对'); await this.submitCommand(this.loadingPreview.dispatchNo, 'LOADING_COMPLETE', { recordVersion: requireDispatchVersion(this.loadingPreview.recordVersion), manifestHash: this.loadingPreview.manifestHash, remark: null }) } catch (cause) { this.setError(cause, '装车完成未确认') } }, if (cartonItem.returnCartons.find((carton) => carton.cartonMark === code)?.status !== 'OUTBOUND_PENDING') return this.setError(new Error('该箱已拣货或不可出库'), '扫码未匹配')
updateReviewReason(event) { if (this.busy || this.loading) return; this.reviewReason = String(event.detail.value || '') }, this.selectPallet(cartonItem); updateFulfillmentDraft('dispatch', { cartonCode: code, packages: 1 }); this.scanCode = ''; playSuccessSound(); return
async submitReview(decision) { }
if (!this.canReview || this.busy || this.loading || !['APPROVED', 'REJECTED'].includes(decision)) return const matches = this.current.items.filter((value) => value.palletNo === code && value.scheduledPackages > value.pickedPackages)
if (decision === 'APPROVED' && this.current.actions.LOADING_REVIEW?.blockCode === 'APPROVAL_BLOCKED') return this.setError(new Error(this.current.actions.LOADING_REVIEW.blockReason), '预约及时间尚未核对') if (matches.length > 1) return this.setError(new Error('此托盘包含多条待拣明细,请选择对应货物卡片'), '请选择拣货明细')
const reason = this.reviewReason.trim() const item = matches[0]
if (decision === 'REJECTED' && !reason) return this.setError(new Error('请填写驳回原因,退回客服处理'), '审核未提交') if (!item) return this.setError(new Error(this.current.items.some((value) => value.palletNo === code) ? '该托盘已完成拣货,请勿重复操作' : '扫码未匹配当前任务的托盘码或来源箱唛'), '扫码未匹配')
try { this.selectPallet(item); this.scanCode = ''; playSuccessSound()
const no = this.current.dispatchNo, version = requireDispatchVersion(this.current.recordVersion) },
const confirmation = await uni.showModal({ title: decision === 'APPROVED' ? '确认理货审核通过' : '确认退回客服处理', content: decision === 'APPROVED' ? '请确认车辆、司机和货物已核对,通过后进入装车中。' : `驳回原因:${reason}。本单将回到客服处理(车辆信息),重新提交后再逐级审核。` }) 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)) }) },
if (!confirmation.confirm || this.current?.dispatchNo !== no || this.current?.recordVersion !== version || !this.canReview) return async submitPick() {
await this.submitCommand(no, 'LOADING_REVIEW', { recordVersion: version, decision, reason: reason || null }) if (!this.canPick) return
} catch (cause) { this.setError(cause, '理货审核未提交') } 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 }
async submitCommand(no, action, body) { const key = fulfillmentIdempotencyKey('dispatch', 'pick', payload)
if (this.busy || this.loading) return await this.run('提交排车拣货', async () => {
const scope = fulfillment.scopeKey await pickDispatchItem(this.current.dispatchNo, this.selectedItem.dispatchItemId, { ...pick, idempotencyKey: key.value })
await this.run(async () => { this.current = await getDispatchTask(this.current.dispatchNo); releaseFulfillmentKey('dispatch', key.signature); updateFulfillmentDraft('dispatch', { palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1 }); this.successMessage = '本次排车拣货已记录'; playSuccessSound()
const ensureScope = () => { if (scope !== fulfillment.scopeKey || this.current?.dispatchNo !== no) throw new Error('作业身份已变化,请重新进入任务') } })
ensureScope() },
const { dispatchItemId, ...payload } = body async completePicking() {
payload.idempotencyKey = `pda-dispatch-${Date.now()}-${Math.random().toString(36).slice(2)}` if (!this.canComplete) return
const result = action === 'PICK' ? await pickDispatchItem(no, dispatchItemId, payload) const key = fulfillmentIdempotencyKey('dispatch', 'complete', { dispatchNo: this.current.dispatchNo })
: action === 'LOADING_COMPLETE' ? await completeDispatchLoading(no, payload) : await reviewDispatchLoading(no, payload) await this.run('完成排车拣货', async () => { await completeDispatchPicking(this.current.dispatchNo, { idempotencyKey: key.value }); this.current = await getDispatchTask(this.current.dispatchNo); releaseFulfillmentKey('dispatch', key.signature); this.successMessage = '排车拣货任务已完成'; playSuccessSound() })
ensureScope() },
if (!result || result.dispatchNo !== no || result.action !== action || result.idempotencyKey !== payload.idempotencyKey || !result.result) throw new Error('操作结果未确认,请刷新任务查看') async depart() {
requireDispatchVersion(result.recordVersion) if (!this.canDepart) return
if (action === 'LOADING_COMPLETE' && !['AFTER_LOADING', 'DELIVERING'].includes(result.stage) || action === 'LOADING_REVIEW' && result.stage !== (body.decision === 'REJECTED' ? 'BEFORE_LOADING' : 'LOADING')) throw new Error('返回结果未确认本次操作,请刷新任务查看') const key = fulfillmentIdempotencyKey('dispatch', 'depart', { tripNo: this.current.tripNo, recordVersion: this.current.tripRecordVersion })
this.loadingPreview = null await this.run('执行整车发车', async () => {
if (action === 'LOADING_REVIEW') this.reviewReason = '' const result = await departDispatch(this.current.dispatchNo, tripDepartureRequest(this.current, key.value))
const current = await getDispatchTask(no); ensureScope() if (result?.status !== 'IN_TRANSIT' || result.tripNo !== this.current.tripNo || !result.dispatches?.some(item => item.dispatchNo === this.current.dispatchNo)) throw new Error('发车结果未包含当前车次和排车单,请刷新核对')
if (current.dispatchNo !== no) throw new Error('任务与当前排车单不一致') this.current = await getDispatchTask(this.current.dispatchNo)
if (!dispatchVersionAtLeast(current.recordVersion, result.recordVersion)) throw new Error('详情版本早于已确认操作,请刷新任务查看') releaseFulfillmentKey('dispatch', key.signature); this.successMessage = `车次 ${result.tripNo} 已整车发车`; playSuccessSound()
this.current = current })
updateFulfillmentDraft('dispatch', { currentNo: no, dispatchItemId: '', palletCode: '', cartonCode: '', packages: 1 }) },
this.successMessage = action === 'PICK' ? '本次真实拣货已记录' : action === 'LOADING_COMPLETE' ? '装车完成已确认' : body.decision === 'REJECTED' ? '已退回客服处理' : '理货审核已提交' 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() },
playSuccessSound() 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 = '' } },
}) setError(error, fallback) { this.error = toDispatchError(error, fallback); console.error('[Overseas PDA dispatch] failed', this.error); playErrorSound() },
}, statusLabel(status) { return ({ PDA_PENDING: '待拣货', PENDING: '待拣货', PICKING: '拣货中', IN_PROGRESS: '拣货中', COMPLETED: '拣货完成', PICKING_COMPLETED: '拣货完成', READY_TO_DEPART: '待发车', DEPARTED: '已发车', CANCELLED: '已取消' })[status] || status || '状态未知' },
async retry() { if (this.current?.dispatchNo) await this.openTask(this.current.dispatchNo); else await this.loadTasks() }, routeLabel(item) { return item.warehouseRouteLabel || [item.destinationWarehouseCode, item.destinationWarehouseName, item.fbaWarehouseCode].filter(Boolean).join(' · ') || '目的仓未关联' },
async run(work, loading = false) { if (this.busy || this.loading) return; this.clearScanNotice(); this.error = null; this.successMessage = ''; if (loading) this.loading = true; else this.requestBusy = true; try { await work() } catch (cause) { this.setError(cause, '排车操作失败') } finally { this.loading = false; this.requestBusy = false } }, itemProgress(item) { return item.scheduledPackages ? Math.min(100, Math.round(item.pickedPackages / item.scheduledPackages * 100)) : 0 }
setError(cause, fallback) { this.clearScanNotice(); this.error = toDispatchError(cause, fallback); playErrorSound() }, }
stageLabel(task) { if (task.status === 'CANCELLED') return '已取消'; if (task.stage === 'BEFORE_LOADING') { if (task.actions?.LOADING_REVIEW?.allowed === true) return '待理货审核'; if ([task.reviews?.dispatchReview, task.reviews?.loadingReview].some(review => review?.status === 'REJECTED')) return '已退回客服处理' }; return ({ BEFORE_LOADING: '装车前', LOADING: '装车中', AFTER_LOADING: '装车后', DELIVERING: '派送中', DELIVERED: '派送完成' })[task.stage] || '状态待核实' },
reviewTime(value) { if (!value) return '未填写'; const date = new Date(new Date(value).getTime() + 8 * 60 * 60 * 1000); return Number.isNaN(date.getTime()) ? '时间无效' : date.toISOString().slice(0, 16).replace('T', ' ') },
reviewLabel(review) { return ({ NOT_SUBMITTED: '未提交', PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' })[review?.status] || '待核对' },
routeLabel(item) { return [item.destination?.warehouseCode, item.destination?.warehouseName, item.destination?.address].filter(Boolean).join(' · ') || '目的仓未关联' }, itemProgress(item) { return item.scheduledPackages ? Math.min(100, Math.round(item.pickedPackages / item.scheduledPackages * 100)) : 0 }
} }
}
</script> </script>
<style scoped> <style scoped>
@import './fulfillment.scss'; @import './fulfillment.scss';
.dispatch-scan-card .card-title { margin-bottom: 12px; font-size: 16px; }
.picking-workspace .card-title { font-size: 16px; }
.picking-workspace .task-head { flex-wrap: wrap; align-items: flex-start; margin-top: 10px; }
.picking-workspace .business-code { min-width: 0; font-size: 16px; line-height: 1.5; }
.picking-workspace .task-meta { font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
.picking-workspace .status-badge { font-size: 12px; }
.picking-workspace .gate-warning { font-size: 13px; line-height: 1.6; }
.picking-section-label, .picking-section-title { display: block; margin: 16px 0 10px; color: #536982; font-size: 14px; font-weight: 700; }
.picking-workspace .metrics { margin-top: 0; padding: 14px 0; }
.picking-workspace .metrics view { font-size: 12px; }
.picking-workspace .metric-value { font-size: 26px; }
.picking-workspace .picking-item { margin-bottom: 12px; }
.picking-item .task-head { margin-top: 0; }
.picking-selected { padding: 4px 8px; border-radius: 6px; color: #135ee8; background: #e6f0ff; font-size: 12px; }
.picking-fact { display: flex; align-items: flex-start; gap: 10px; margin-top: 10px; color: #243c58; font-size: 14px; line-height: 1.6; }
.picking-fact-label { flex: 0 0 74px; color: #718095; }
.picking-fact > text:last-child { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.picking-item-progress { display: block; margin-top: 12px; color: #135ee8; font-size: 14px; font-weight: 700; }
.picking-source-choices button { min-height: 48px; margin-top: 12px; padding: 12px; text-align: left; line-height: 1.5; background: #f3f7ff; }
.picking-source-choices .business-code { display: block; }
.picking-current { border-color: #9dc0f4; }
.picking-current > .business-code { display: block; margin-top: 10px; }
.picking-instruction { display: block; margin-top: 10px; color: #536982; font-size: 14px; line-height: 1.6; overflow-wrap: anywhere; }
.picking-quantity input { box-sizing: border-box; width: 100%; height: 48px; margin-top: 10px; padding: 0 12px; border: 2px solid #135ee8; border-radius: 8px; color: #17314f; font-size: 18px; background: #fff; }
.picking-workspace .primary-button, .dispatch-scan-card .secondary-button { min-height: 48px; font-size: 16px; line-height: 48px; }
.picking-completion { margin-top: 16px; }
.stage-switch { position: sticky; top: 0; z-index: 2; display: flex; gap: 8rpx; margin-bottom: 20rpx; padding: 8rpx; border: 1px solid #dbe5f0; border-radius: 20rpx; background: #eef2f7; }
.stage-tab { flex: 1; margin: 0; min-height: 84rpx; padding: 0 16rpx; border-radius: 14rpx; color: #64748b; font-size: 28rpx; font-weight: 600; line-height: 84rpx; background: transparent; }
.stage-tab::after { border: 0; }
.stage-tab.active { color: #1262f4; background: #fff; box-shadow: 0 1px 4px rgba(31, 41, 55, .12); }
.stage-tab[disabled] { opacity: .55; }
.review-facts { margin: 16rpx 0; }
.review-fact { display: flex; align-items: flex-start; gap: 20rpx; padding: 10rpx 0; color: #243c58; font-size: 26rpx; line-height: 1.5; }
.review-label { flex: 0 0 132rpx; color: #718095; overflow-wrap: anywhere; }
.review-fact > :last-child { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.review-appointment { display: flex; flex-direction: column; gap: 4rpx; }
.review-actions { display: flex; gap: 20rpx; margin-top: 24rpx; }
.review-actions button { flex: 1; min-width: 0; margin: 0; padding: 0 16rpx; min-height: 88rpx; line-height: 88rpx; font-size: 28rpx; }
.review-actions .primary-button { min-height: 88rpx; line-height: 88rpx; font-size: 28rpx; }
.review-card textarea { box-sizing: border-box; width: 100%; height: 160rpx; min-height: 160rpx; margin-top: 20rpx; padding: 16rpx; border: 1px solid #cbd5e1; border-radius: 8rpx; }
.loading-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, .46); display: flex; align-items: center; justify-content: center; z-index: 100; padding: 40rpx; box-sizing: border-box; }
.loading-dialog { width: 100%; max-width: 620rpx; max-height: 88vh; overflow-y: auto; box-sizing: border-box; background: #fff; border-radius: 28rpx; padding: 36rpx 32rpx 32rpx; box-shadow: 0 24rpx 80rpx rgba(15, 23, 42, .18); }
.loading-dialog-heading { display: flex; flex-direction: column; gap: 10rpx; }
.loading-dialog-title { color: #142c49; font-size: 36rpx; font-weight: 700; line-height: 1.4; }
.loading-counts { display: flex; margin: 28rpx 0; padding: 24rpx 0; border-radius: 20rpx; background: #f3f7ff; }
.loading-count { flex: 1; display: flex; align-items: center; flex-direction: column; gap: 8rpx; }
.loading-count + .loading-count { border-left: 1px solid #dce6f6; }
.loading-count-value { color: #1764ec; font-size: 48rpx; font-weight: 700; line-height: 1.2; }
.loading-count-unit { font-size: 26rpx; font-weight: 500; }
.loading-count-label { color: #72839b; font-size: 24rpx; }
.loading-dialog-actions { display: flex; gap: 20rpx; margin-top: 32rpx; }
.loading-dialog-actions button { flex: 1; min-width: 0; margin: 0; height: 88rpx; line-height: 88rpx; padding: 0 12rpx; border-radius: 16rpx; font-size: 30rpx; font-weight: 600; }
.loading-dialog-actions button::after { border: 0; }
.loading-cancel { color: #52657d; background: #f0f3f8; }
.loading-confirm { color: #fff; background: #1764ec; }
.loading-dialog-actions button[disabled] { opacity: .5; }
.scan-notice-overlay { pointer-events: none; }
.scan-notice-dialog { text-align: center; }
.scan-notice-pallet { display: block; margin-top: 20rpx; color: #243c58; font-size: 30rpx; overflow-wrap: anywhere; }
.scan-notice-label { display: block; margin-top: 28rpx; color: #536982; font-size: 28rpx; }
.scan-notice-count { display: block; margin-top: 12rpx; color: #1764ec; font-size: 72rpx; font-weight: 700; line-height: 1.3; }
.scan-notice-hint { display: block; margin-top: 24rpx; color: #72839b; font-size: 24rpx; }
</style> </style>
+30
View File
@@ -0,0 +1,30 @@
<template>
<view v-if="user && currentModule" class="page-shell portal-page">
<PortalHeader title="业务模块" :user="user" />
<view class="portal-content">
<view class="module-hero" :class="`module-hero-${currentModule.key}`"><view class="module-hero-icon">{{ currentModule.icon }}</view><view class="module-hero-copy"><text class="module-hero-title">{{ currentModule.name }}</text><text class="module-hero-desc">{{ currentModule.description }}</text></view><text class="module-hero-count">{{ availableCount }}/{{ currentModule.operations.length }} 可用</text></view>
<view class="section-heading"><text class="group-title">业务功能</text><text class="section-heading-hint">点击可用功能开始作业</text></view>
<view class="operation-list"><button v-for="operation in currentModule.operations" :key="operation.name" class="operation-item" :class="{ 'operation-item-disabled': !operation.available }" :disabled="!operation.available" @tap="openOperation(operation)"><view class="operation-icon">{{ operation.icon }}</view><view class="operation-info"><text class="operation-name">{{ operation.name }}</text><text class="operation-desc">{{ operation.description }}</text></view><text v-if="operation.available" class="operation-ready">可处理</text><text v-else class="operation-locked">未开放</text><text v-if="operation.available" class="operation-arrow">›</text></button></view>
<view v-if="availableCount < currentModule.operations.length" class="module-empty-note">灰色功能尚未开放,仅可进入已接入真实接口的作业。</view>
</view>
</view>
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import { MODULES } from '@/data/overseas-workbench.js'
import { session, requireSession } from '@/stores/session.js'
export default {
components: { PortalHeader },
data() { return { user: session.currentUser, moduleKey: 'inbound' } },
computed: {
currentModule() { return MODULES.find((module) => module.key === this.moduleKey) || MODULES[0] },
availableCount() { return this.currentModule.operations.filter((operation) => operation.available).length }
},
onLoad(options) { this.moduleKey = options.key || 'inbound' },
onShow() { if (!requireSession()) return; this.user = session.currentUser },
methods: {
openOperation(operation) { if (operation.available) uni.navigateTo({ url: operation.route || `/pages/operation/operation?module=${this.moduleKey}&op=${operation.key}` }) }
}
}
</script>
+5 -5
View File
@@ -1,8 +1,8 @@
<template> <template>
<view v-if="user" class="page-shell portal-page work-page"> <view v-if="user" class="page-shell portal-page work-page">
<PortalHeader title="商品移库" :user="user" /> <PortalHeader title="商品移库" :user="user" fallback-url="/pages/module/module?key=inventory" />
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content"> <scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
<view class="work-heading"><text class="work-title">整托移库</text></view> <view class="work-heading"><text class="work-title">整托移库</text><text class="work-subtitle">扫描来源托盘和同仓目标库位,按服务端资源版本一次移动整托库存</text></view>
<view v-if="restoring" class="empty-box"><uni-load-more status="loading" /><text class="empty-copy">正在恢复未完成的移库草稿…</text></view> <view v-if="restoring" class="empty-box"><uni-load-more status="loading" /><text class="empty-copy">正在恢复未完成的移库草稿…</text></view>
<view v-else-if="!source && !sourceCode" class="empty-box"><text class="empty-copy">暂无已读取托盘,请先扫描或输入来源托盘码</text></view> <view v-else-if="!source && !sourceCode" class="empty-box"><text class="empty-copy">暂无已读取托盘,请先扫描或输入来源托盘码</text></view>
<view class="work-card"> <view class="work-card">
@@ -17,7 +17,7 @@
<button class="secondary" :disabled="!targetCode.trim() || busy" @tap="resolveTarget(targetCode)">{{ resolving === 'target' ? '校验中…' : '校验目标库位' }}</button> <button class="secondary" :disabled="!targetCode.trim() || busy" @tap="resolveTarget(targetCode)">{{ resolving === 'target' ? '校验中…' : '校验目标库位' }}</button>
<view v-if="target" class="task-card section-gap"><view class="row-head"><view><text class="business-code">{{ target.locationCode }}</text><text class="meta-line">{{ locationTypeLabel(target.locationType) }}</text></view><text class="status-pill">{{ statusLabel(target.status) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ target.occupiedPackages }}</strong><text>已占箱数</text></view><view class="metric"><strong>{{ target.inventoryPositionCount }}</strong><text>库存位置</text></view></view></view> <view v-if="target" class="task-card section-gap"><view class="row-head"><view><text class="business-code">{{ target.locationCode }}</text><text class="meta-line">{{ locationTypeLabel(target.locationType) }}</text></view><text class="status-pill">{{ statusLabel(target.status) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ target.occupiedPackages }}</strong><text>已占箱数</text></view><view class="metric"><strong>{{ target.inventoryPositionCount }}</strong><text>库存位置</text></view></view></view>
</view> </view>
<view class="work-card"><text class="card-title">3. 移库原因(必填)</text><textarea v-model="reason" class="reason-input" maxlength="500" aria-label="移库原因(必填)" /><button class="primary" :disabled="!canMove || busy" @tap="submitMove">{{ busyAction === 'move' ? '移库中…' : '确认整托移库' }}</button><text class="hint">发生冲突后请重新扫描。</text></view> <view class="work-card"><text class="card-title">3. 移库原因</text><textarea v-model="reason" class="reason-input" maxlength="500" placeholder="必填,例如:库位容量调整" /><button class="primary" :disabled="!canMove || busy" @tap="submitMove">{{ busyAction === 'move' ? '移库中…' : '确认整托移库' }}</button><text class="hint">来源和目标必须属于 Token 绑定的同一海外仓;冲突后必须重新扫描。</text></view>
<view v-if="result" class="success-box" role="status"><text class="success-title">移库完成</text><text>{{ result.moveNo || '移库单号待生成' }}</text><text>{{ result.palletCode }}:{{ result.sourceLocationCode || '未绑定库位' }} → {{ result.targetLocationCode || '—' }}</text><text>{{ result.packageCount }} 箱 · {{ result.replayed ? '幂等恢复结果' : '本次提交成功' }}</text><button class="secondary" @tap="resetPage">继续移库</button></view> <view v-if="result" class="success-box" role="status"><text class="success-title">移库完成</text><text>{{ result.moveNo || '移库单号待生成' }}</text><text>{{ result.palletCode }}:{{ result.sourceLocationCode || '未绑定库位' }} → {{ result.targetLocationCode || '—' }}</text><text>{{ result.packageCount }} 箱 · {{ result.replayed ? '幂等恢复结果' : '本次提交成功' }}</text><button class="secondary" @tap="resetPage">继续移库</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 class="secondary" @tap="refreshFacts">重新读取托盘和库位</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 class="secondary" @tap="refreshFacts">重新读取托盘和库位</button></view>
</view></scroll-view> </view></scroll-view>
@@ -29,7 +29,7 @@
import ScanTargetInput from '@/components/ScanTargetInput.vue' import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { moveInventory, resolveWarehouseLocation, resolveWarehousePallet } from '@/api/warehouse-operations.js' import { moveInventory, resolveWarehouseLocation, resolveWarehousePallet } from '@/api/warehouse-operations.js'
import { session, requireSession } from '@/stores/session.js' import { session, requireSession } from '@/stores/session.js'
import { resetWarehouseDomain, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js' import { releaseWarehouseKey, resetWarehouseDomain, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js' import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js' import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
@@ -52,7 +52,7 @@
async restoreDraftFacts() { this.restoring = true; try { if (this.sourceCode) await this.resolveSource(this.sourceCode, false); if (this.targetCode) await this.resolveTarget(this.targetCode, false) } finally { this.restoring = false } }, async restoreDraftFacts() { this.restoring = true; try { if (this.sourceCode) await this.resolveSource(this.sourceCode, false); if (this.targetCode) await this.resolveTarget(this.targetCode, false) } finally { this.restoring = false } },
async resolveSource(raw, sound = true) { const code = String(raw?.detail?.value ?? raw ?? this.sourceCode).trim(); if (!code || this.busy) return; this.sourceCode = code; this.resolving = 'source'; this.error = null; try { this.source = await resolveWarehousePallet(code); this.sourceCode = this.source.palletCode; if (sound) playSuccessSound(); this.activeTarget = 'target' } catch (error) { this.setError(error, '来源托盘查询失败') } finally { this.resolving = '' } }, async resolveSource(raw, sound = true) { const code = String(raw?.detail?.value ?? raw ?? this.sourceCode).trim(); if (!code || this.busy) return; this.sourceCode = code; this.resolving = 'source'; this.error = null; try { this.source = await resolveWarehousePallet(code); this.sourceCode = this.source.palletCode; if (sound) playSuccessSound(); this.activeTarget = 'target' } catch (error) { this.setError(error, '来源托盘查询失败') } finally { this.resolving = '' } },
async resolveTarget(raw, sound = true) { const code = String(raw?.detail?.value ?? raw ?? this.targetCode).trim(); if (!code || this.busy) return; this.targetCode = code; this.resolving = 'target'; this.error = null; try { this.target = await resolveWarehouseLocation(code); this.targetCode = this.target.locationCode; if (sound) playSuccessSound() } catch (error) { this.setError(error, '目标库位校验失败') } finally { this.resolving = '' } }, async resolveTarget(raw, sound = true) { const code = String(raw?.detail?.value ?? raw ?? this.targetCode).trim(); if (!code || this.busy) return; this.targetCode = code; this.resolving = 'target'; this.error = null; try { this.target = await resolveWarehouseLocation(code); this.targetCode = this.target.locationCode; if (sound) playSuccessSound() } catch (error) { this.setError(error, '目标库位校验失败') } finally { this.resolving = '' } },
async submitMove() { if (!this.canMove || this.busy) return; const payload = { sourcePalletCode: this.source.palletCode, targetLocationCode: this.target.locationCode, expectedPalletVersion: Number(this.source.version || 0), reason: this.reason.trim() }; const key = warehouseIdempotencyKey('move', 'move', payload); this.busy = true; this.busyAction = 'move'; this.error = null; this.result = null; try { this.result = await moveInventory({ ...payload, idempotencyKey: key.value }); playSuccessSound(); updateWarehouseWork('move', { sourcePalletCode: '', targetLocationCode: '', reason: '' }); this.source = null; this.target = null } catch (error) { this.setError(error, '整托移库失败'); if (isConflict(error)) { await this.refreshFacts(false); if (this.error) this.error.message += ',已刷新最新资源版本,请重新确认' } } finally { this.busy = false; this.busyAction = '' } }, async submitMove() { if (!this.canMove || this.busy) return; const payload = { sourcePalletCode: this.source.palletCode, targetLocationCode: this.target.locationCode, expectedPalletVersion: Number(this.source.version || 0), reason: this.reason.trim() }; const key = warehouseIdempotencyKey('move', 'move', payload); this.busy = true; this.busyAction = 'move'; this.error = null; this.result = null; try { this.result = await moveInventory({ ...payload, idempotencyKey: key.value }); releaseWarehouseKey('move', key.signature); playSuccessSound(); updateWarehouseWork('move', { sourcePalletCode: '', targetLocationCode: '', reason: '' }); this.source = null; this.target = null } catch (error) { this.setError(error, '整托移库失败'); if (isConflict(error)) { releaseWarehouseKey('move', key.signature); await this.refreshFacts(false); if (this.error) this.error.message += ',已刷新最新资源版本,请重新确认' } } finally { this.busy = false; this.busyAction = '' } },
async refreshFacts(clearError = true) { const retainedError = clearError ? null : this.error; const sourceCode = this.sourceCode; const targetCode = this.targetCode; this.source = null; this.target = null; try { if (sourceCode) this.source = await resolveWarehousePallet(sourceCode); if (targetCode) this.target = await resolveWarehouseLocation(targetCode); this.error = retainedError } catch (error) { this.setError(error, '最新托盘或库位状态读取失败') } }, async refreshFacts(clearError = true) { const retainedError = clearError ? null : this.error; const sourceCode = this.sourceCode; const targetCode = this.targetCode; this.source = null; this.target = null; try { if (sourceCode) this.source = await resolveWarehousePallet(sourceCode); if (targetCode) this.target = await resolveWarehouseLocation(targetCode); this.error = retainedError } catch (error) { this.setError(error, '最新托盘或库位状态读取失败') } },
resetPage() { resetWarehouseDomain('move'); this.source = null; this.target = null; this.result = null; this.error = null; this.activeTarget = 'source' }, resetPage() { resetWarehouseDomain('move'); this.source = null; this.target = null; this.result = null; this.error = null; this.activeTarget = 'source' },
setError(error, fallback) { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() }, setError(error, fallback) { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
File diff suppressed because one or more lines are too long
+29 -152
View File
@@ -3,27 +3,28 @@
<PortalHeader title="出库作业" :user="user" /> <PortalHeader title="出库作业" :user="user" />
<scroll-view class="fulfillment-scroll" scroll-y> <scroll-view class="fulfillment-scroll" scroll-y>
<view class="portal-content fulfillment-content"> <view class="portal-content fulfillment-content">
<view class="fulfillment-heading"><text class="fulfillment-title">出库任务</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 v-if="current" class="detail-toolbar"><view><text class="detail-kicker">当前任务</text><text class="detail-title">出库作业详情</text></view><button :disabled="busy" @tap="closeTask">‹ 返回列表</button></view>
<view v-if="!discardCurrent" class="fulfillment-card"> <view class="fulfillment-card">
<ScanTargetInput name="outbound" :model-value="scanCode" :label="scanLabel" :placeholder="scanPlaceholder" :active="true" @update:modelValue="updateScanCode" @submit="handleScan" /> <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 ? (discardTask?.scanMode === 'CARTON' ? '核对废弃箱唛' : '打开废弃托盘') : current ? '匹配出库货物' : '打开出库任务' }}</button> <button class="secondary-button" :disabled="!scanCode || loading || busy || discardLoading || discardBusy" @tap="handleScan(scanCode)">{{ discardCurrent ? '打开废弃托盘' : current ? '匹配出库货物' : '打开出库任务' }}</button>
<text class="scan-help">任务号直接恢复;退货整托出库扫托盘码,散箱出库扫箱唛。</text>
</view> </view>
<view v-if="loading" class="fulfillment-state"><text>正在加载出库任务…</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 || discardLoading || discardBusy" @tap="retry">重试</button></view> <view v-else-if="error" class="fulfillment-error" role="alert"><text class="state-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><text v-if="error.network" class="offline-note">网络连接失败,当前任务、扫码草稿和幂等键均已保留。</text><button class="secondary-button" :disabled="loading || busy || discardLoading || discardBusy" @tap="retry">重试</button></view>
<template v-if="!loading && !current && !discardCurrent"> <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 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"> <view v-if="tasks.length" class="task-list">
<button v-for="task in tasks" :key="task.taskNo" class="task-card" @tap="openTask(task.taskNo)"><view class="task-head"><view class="task-code-row"><text class="business-code">{{ task.outboundNo || '—' }}</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">{{ taskType(task) }} · 任务号 {{ task.taskNo || '—' }}</text><text class="task-enter">查看并开始出库 ›</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>
<view v-else-if="!error && !discardTasks.length" 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 v-if="order.outboundNo" class="task-meta">出库单 {{ order.outboundNo }} · 废弃出库</text><text class="task-meta">托盘 {{ order.palletCount }} 个 · 按箱废弃 {{ order.cartonCount }} 箱 · 待作业 {{ order.pendingPalletCount }} 个</text><text class="task-enter">点击进入废弃出库作业 ›</text></button></view></view> <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>
<template v-if="current"> <template v-if="current">
<view class="fulfillment-card summary-card"><view class="task-head"><text class="business-code">{{ current.outboundNo || '—' }}</text><text class="status-badge">{{ statusLabel(current.status) }}</text></view><text class="task-meta">{{ taskType(current) }} · 任务号 {{ current.taskNo || '—' }}</text><view class="metrics"><view><text class="metric-value">{{ plannedPackages }}</text><text>计划箱数</text></view><view><text class="metric-value success">{{ pickedPackages }}</text><text>已拣箱数</text></view><view><text class="metric-value warning">{{ remainingPackages }}</text><text>待拣箱数</text></view></view></view> <view 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-if="current.items.length" class="item-list">
<view v-for="item in displayItems" :key="item.displayKey || item.outboundPalletItemId" class="fulfillment-card item-card"> <view v-for="item in displayItems" :key="item.displayKey || item.outboundPalletItemId" class="fulfillment-card item-card">
@@ -43,25 +44,12 @@
</template> </template>
<template v-else-if="discardCurrent"> <template v-else-if="discardCurrent">
<view class="detail-toolbar"><view><text class="detail-kicker">废弃订单</text><text class="detail-title">{{ discardCurrent.discardNo }}</text><text v-if="discardCurrent.outboundNo" class="task-meta">出库单 {{ discardCurrent.outboundNo }}</text></view><button :disabled="discardBusy" @tap="closeDiscard">‹ 返回列表</button></view> <view class="detail-toolbar"><view><text class="detail-kicker">废弃订单</text><text class="detail-title">{{ discardCurrent.discardNo }}</text></view><button :disabled="discardBusy" @tap="closeDiscard">‹ 返回列表</button></view>
<view v-if="discardOrderRemark" class="fulfillment-card discard-remark"><text class="card-title">废弃备注</text><text class="task-meta">{{ discardOrderRemark }}</text></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="discardOrderRemaining != null" class="fulfillment-card"><text>申请废弃 {{ discardCurrentTasks[0]?.requestedPackages ?? discardTask?.requestedPackages }} 箱 · 剩余 {{ discardOrderRemaining }} 箱</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>
<view v-if="successMessage" class="fulfillment-success" role="status"><text>{{ successMessage }}</text></view>
<view v-if="!discardTask && discardPendingTasks.length" class="fulfillment-card"><text class="card-title">废弃箱关联托盘</text><view v-for="task in discardPendingTasks" :key="task.palletNo" class="outbound-line"><view class="task-head"><text class="business-code">{{ task.palletNo === '散箱' ? '散箱' : task.palletNo }}</text><text>待废弃</text></view></view></view>
<view v-if="!discardTask && discardPendingTasks.length" class="fulfillment-card"><ScanTargetInput name="cargo-discard" :model-value="discardPalletCode" label="废弃托盘码" placeholder="扫描对应托盘码" :active="discardTask?.scanMode !== 'CARTON'" @update:modelValue="updateDiscardPalletCode" @submit="resolveDiscardPallet" /></view>
<view v-if="discardTask" class="fulfillment-card"><ScanTargetInput v-if="discardNeedsRefresh" name="cargo-discard-retry" :model-value="discardPalletCode" label="重新扫描废弃托盘" placeholder="重新扫描当前托盘码" :active="true" @update:modelValue="updateDiscardPalletCode" @submit="resolveDiscardPallet" /><view v-else class="task-head"><text class="business-code">{{ discardTask.palletNo }}</text><text>{{ discardTask.status === 'COMPLETED' ? '已完成' : '待废弃' }}</text></view></view>
<view v-if="discardTask" class="fulfillment-card item-card">
<text v-if="discardQuantityLines.length" class="discard-scanned-count">已扫描 {{ discardUnboundScans.length }} 箱</text>
<view v-if="discardQuantityLines.length > 1" class="quantity-row"><button v-for="sku in discardQuantityLines" :key="sku.itemId" class="secondary-button" :disabled="discardBusy" @tap="discardScanItemId = sku.itemId">{{ sku.skuCode || '—' }}{{ discardScanItemId === sku.itemId ? '(当前扫描)' : '' }}</button></view>
<view v-if="discardQuantityLines.length"><text class="business-code">扫描箱号出库</text><ScanTargetInput name="cargo-discard-count" :model-value="discardCartonCode" label="废弃箱唛" placeholder="扫描现场已有箱唛" :active="true" @update:modelValue="discardCartonCode = $event" @submit="scanDiscardCarton" /><view v-if="discardUnboundScans.length" class="discard-carton-heading" role="button" :aria-expanded="discardScannedExpanded" :aria-label="discardScannedExpanded ? '收起已扫描箱唛' : '展开已扫描箱唛'" @tap="discardScannedExpanded = !discardScannedExpanded"><text class="scan-help">已扫描 {{ discardUnboundScans.length }} 箱</text><view class="discard-carton-chevron" :class="{ expanded: discardScannedExpanded }" aria-hidden="true"></view></view><view v-if="discardScannedExpanded"><view v-for="scan in discardUnboundScans" :key="scan.mark" class="outbound-line"><text>{{ scan.mark }}</text><button class="discard-scan-remove" :disabled="discardBusy || discardPhotoBusy" :aria-label="`移除箱唛 ${scan.mark}`" @tap="removeDiscardScan(scan.mark)">移除</button></view></view></view><view v-if="discardTask.scanMode === 'CARTON'"><view class="discard-carton-heading" role="button" :aria-expanded="discardCartonsExpanded" :aria-label="discardCartonsExpanded ? '收起箱号核对' : '展开箱号核对'" @tap="discardCartonsExpanded = !discardCartonsExpanded"><text class="business-code">箱号核对</text><view class="discard-carton-heading-end"><text class="scan-help">待扫描 {{ discardPendingMarks.length }} 箱</text><view class="discard-carton-chevron" :class="{ expanded: discardCartonsExpanded }" aria-hidden="true"></view></view></view>
<text class="scan-help">已核对 {{ discardScannedMarks.length }}/{{ discardTask.cartonMarks.length }} 箱</text>
<view v-if="discardCartonsExpanded">
<view v-for="mark in discardPendingMarks" :key="mark" class="outbound-line"><text>{{ mark }}</text></view>
<text v-if="!discardPendingMarks.length" class="scan-help">箱号已全部核对</text>
</view><view v-if="discardTask.status !== 'COMPLETED'"><text class="business-code">扫描箱号出库</text><ScanTargetInput name="cargo-discard-carton" :model-value="discardCartonCode" label="废弃箱号" placeholder="扫描本次废弃的系统箱号" :active="true" @update:modelValue="discardCartonCode = $event" @submit="scanDiscardCarton" /></view></view><view class="discard-photo-section"><text class="business-code">废弃现场照片(至少 1 张)</text><view class="discard-photo-grid"><view v-for="photo in discardPhotos" :key="photo.fileId" class="discard-photo-tile"><image v-if="discardPhotoUrls[photo.fileId]" :src="discardPhotoUrls[photo.fileId]" mode="aspectFill" @tap="previewDiscardPhoto(photo.fileId)" /><button v-else class="secondary-button" @tap="loadDiscardPhoto(photo.fileId)">{{ discardPhotoErrors[photo.fileId] ? '加载失败,点击重试' : '加载照片' }}</button></view></view><button v-if="discardTask.status !== 'COMPLETED'" class="secondary-button" :disabled="discardLoading || discardBusy || discardPhotoBusy || !discardPhotoReady || discardPhotos.length >= 20" @tap="takeDiscardPhoto">{{ discardPhotoBusy ? '照片上传中…' : '拍摄废弃现场照片' }}({{ discardPhotos.length }}/20)</button><button v-if="!discardPhotoReady" class="secondary-button" :disabled="discardLoading || discardPhotoBusy" @tap="restoreDiscardPhotos">重试读取照片</button></view><button class="primary-button" :disabled="!canConfirmDiscard || discardBusy" @tap="confirmDiscard">{{ discardBusy ? '提交中…' : '废弃出库' }}</button></view>
</template> </template>
<view v-if="successMessage && !discardCurrent" class="fulfillment-success" role="status"><text>{{ successMessage }}</text></view> <view v-if="successMessage" class="fulfillment-success" role="status"><text>{{ successMessage }}</text></view>
</view> </view>
</scroll-view> </scroll-view>
</view> </view>
@@ -71,20 +59,15 @@
import PortalHeader from '@/components/PortalHeader.vue' import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue' import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { listCargoDiscardPhotos, uploadCargoDiscardPhoto, downloadCargoDiscardPhoto, completeOutboundTask, confirmCargoDiscard, getOutboundTask, listCargoDiscardTasks, listOutboundTasks, normalizeCargoDiscardTask, outboundPickPayload, pickOutboundPallet, pickOutboundTask, resolveCargoDiscardPallet, 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, restoreFulfillmentDraft, updateFulfillmentDraft } from '@/stores/fulfillment.js' import { fulfillment, fulfillmentIdempotencyKey, releaseFulfillmentKey, restoreFulfillmentDraft, updateFulfillmentDraft } from '@/stores/fulfillment.js'
import { requireSession, session } from '@/stores/session.js' import { requireSession, session } from '@/stores/session.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js' import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js' import { startScanBroadcast } from '@/utils/scan-broadcast.js'
export default { export default {
components: { PortalHeader, ScanTargetInput }, components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, tasks: [], discardTasks: [], current: null, discardCurrent: null, discardTask: null, discardScannedExpanded: false, discardUnboundScans: [], discardScanItemId: '', discardActuals: {}, discardScannedMarks: [], discardCartonsExpanded: true, discardCartonCode: '', discardPhotos: [], discardPhotoUrls: {}, discardPhotoErrors: {}, discardPhotoBusy: false, discardPhotoReady: false, discardPalletCode: '', discardLoading: false, discardBusy: false, discardNeedsRefresh: false, selectedItem: null, selectedPosition: null, selectedScanCode: '', scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '', bootstrapped: false } }, 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: { computed: {
// 已扫描箱号保留提交凭据,列表只显示尚未核对的箱子。
discardOrderRemark() { return this.discardTask?.remark || this.discardCurrentTasks.find(task => task.remark)?.remark || '' },
discardOrderRemaining() { return this.discardTask?.remainingPackages ?? this.discardCurrentTasks[0]?.remainingPackages ?? null },
discardPendingMarks() { return (this.discardTask?.cartonMarks || []).filter(mark => !this.discardScannedMarks.includes(mark)) },
discardQuantityLines() { return (this.discardTask?.skus || []).filter(sku => sku.actualQuantityEditable) },
displayItems() { displayItems() {
const items = this.current?.items || [] const items = this.current?.items || []
if (this.current?.outboundType !== 'RETURN_OUTBOUND') return items if (this.current?.outboundType !== 'RETURN_OUTBOUND') return items
@@ -105,8 +88,8 @@
return rows return rows
}, },
hasReturnPallet() { return this.current?.outboundType === 'RETURN_OUTBOUND' && (this.current.items || []).some(item => item.palletNo) }, hasReturnPallet() { return this.current?.outboundType === 'RETURN_OUTBOUND' && (this.current.items || []).some(item => item.palletNo) },
scanLabel() { return this.discardCurrent ? (this.discardTask?.scanMode === 'CARTON' ? '废弃箱唛' : '废弃订单托盘码') : this.hasReturnPallet ? '扫描托盘号(退货整托出库)' : '任务号、出库单号、排车单号、托盘号或箱码' }, scanLabel() { return this.discardCurrent ? '废弃订单托盘码' : this.hasReturnPallet ? '扫描托盘号(退货整托出库)' : '任务号、出库单号、排车单号、托盘号或箱码' },
scanPlaceholder() { return this.discardCurrent ? (this.discardTask?.scanMode === 'CARTON' ? '扫描本单所选箱唛' : '扫描废弃订单对应托盘码') : this.hasReturnPallet ? '扫描托盘号;散箱扫描箱唛' : '扫描任务号 / 出库单号 / 排车单号 / 托盘号 / 箱码' }, scanPlaceholder() { return this.discardCurrent ? '扫描废弃订单对应托盘码' : this.hasReturnPallet ? '扫描托盘号;散箱扫描箱唛' : '扫描任务号 / 出库单号 / 排车单号 / 托盘号 / 箱码' },
recentTaskNo() { return fulfillment.outbound.currentTaskNo || '' }, recentTaskNo() { return fulfillment.outbound.currentTaskNo || '' },
packages() { return fulfillment.outbound.packages }, packages() { return fulfillment.outbound.packages },
plannedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.plannedPackages, 0) }, plannedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.plannedPackages, 0) },
@@ -116,18 +99,9 @@
taskOperable() { return ['PENDING', 'IN_PROGRESS'].includes(this.current?.status) }, 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 }, 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 }, canComplete() { return this.plannedPackages > 0 && this.remainingPackages === 0 && this.taskOperable },
discardOrders() { const groups = new Map(); for (const task of this.discardTasks) { const current = groups.get(task.discardNo) || { discardNo: task.discardNo, outboundNo: task.outboundNo, palletCount: 0, cartonCount: 0, pendingPalletCount: 0 }; if (task.scanMode === 'CARTON') current.cartonCount += (task.cartonMarks || []).length; else current.palletCount += 1; if (task.status !== 'COMPLETED') current.pendingPalletCount += 1; groups.set(task.discardNo, current) } return [...groups.values()] }, 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 }, discardOrderCount() { return this.discardOrders.length },
discardPendingTasks() { return this.discardCurrentTasks.filter(task => task.status !== 'COMPLETED') }, 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)) }
discardCurrentTasks() { return this.discardTasks.filter(task => task.discardNo === this.discardCurrent?.discardNo) },
discardQuantitySummary() {
const task = this.discardTask
if (!task) return ''
const pieces = task.skus.filter(sku => sku.quantityUnit === '件').reduce((total, sku) => total + sku.plannedPackages, 0)
const boxes = new Set(task.cartonMarks || []).size || task.skus.filter(sku => sku.quantityUnit !== '件').reduce((total, sku) => total + sku.plannedPackages, 0)
return `${boxes} 箱${pieces ? ` · ${pieces} 件` : ''}`
},
canConfirmDiscard() { const task = this.discardTask; return Boolean(this.discardPhotoReady && this.discardPhotos.length > 0 && !this.discardPhotoBusy && !this.discardLoading && !this.discardBusy && task && task.status !== 'COMPLETED' && (task.remainingPackages == null || task.scanMode === 'CARTON' || task.skus.reduce((sum, sku) => sum + Number(this.discardActuals[sku.itemId] || 0), 0) <= task.remainingPackages) && task.skus.length && task.skus.every(sku => this.validDiscardActual(sku)) && (task.scanMode !== 'CARTON' || (task.cartonMarks?.length && task.cartonMarks.every(mark => this.discardScannedMarks.includes(mark))))) }
}, },
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (this.discardPhotoBusy || this.discardBusy) return; if (this.discardCurrent) { if (this.discardTask) this.restoreDiscardPhotos(); return } if (!this.bootstrapped) { this.bootstrapped = true; this.scanCode = ''; this.loadTasks(); return } if (this.current?.taskNo) this.openTask(this.current.taskNo); else this.loadTasks() }, 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() }, onHide() { this.stopScanner() },
@@ -155,85 +129,23 @@
}, true) }, true)
}, },
closeTask() { this.current = null; this.selectedItem = null; this.selectedPosition = null; this.error = null; this.successMessage = ''; updateFulfillmentDraft('outbound', { currentTaskNo: '', scanCode: '', packages: 1 }); this.loadTasks() }, closeTask() { this.current = null; this.selectedItem = null; this.selectedPosition = null; this.error = null; this.successMessage = ''; updateFulfillmentDraft('outbound', { currentTaskNo: '', scanCode: '', packages: 1 }); this.loadTasks() },
openDiscardOrder(discardNo) { if (this.discardPhotoBusy) return; this.resetDiscardPhotos(); const code = String(discardNo || '').trim(); if (!code || this.discardBusy || this.busy || this.loading) return; this.current = null; this.discardLoading = false; this.discardCurrent = { discardNo: code, outboundNo: this.discardOrders.find(order => order.discardNo === code)?.outboundNo || '' }; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.discardUnboundScans = []; this.discardScannedExpanded = false; this.discardScanItemId = ''; this.discardScannedMarks = []; this.discardCartonsExpanded = true; this.discardCartonCode = ''; this.discardNeedsRefresh = false; this.error = null; this.successMessage = ''; }, 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() }, 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 }, updateDiscardPalletCode(value) { this.discardPalletCode = value },
// 下方扫描框只核对当前任务的真实箱号,不切换托盘或清空照片。
async scanDiscardCarton(raw) {
if (!this.discardTask || this.discardTask.status === 'COMPLETED' || this.discardBusy || this.discardLoading || this.discardPhotoBusy) return
const code = String(typeof raw === 'string' ? raw : raw?.detail?.value ?? this.discardCartonCode).trim()
if (!code) return
if (this.discardNeedsRefresh && !await this.refreshDiscardTask()) return
if (this.discardTask.status === 'COMPLETED') return
if (this.discardQuantityLines.length) {
if ((this.discardCurrentTasks || []).some(task => task.palletNo === code)) { this.setError(new Error('请扫描箱唛,不能把托盘码作为箱唛'), '箱唛核对失败'); return }
const sku = this.discardQuantityLines.length === 1 ? this.discardQuantityLines[0] : this.discardQuantityLines.find(line => line.itemId === this.discardScanItemId)
if (!sku) { this.setError(new Error('请先选择当前扫描的货物'), '箱唛核对失败'); return }
if (this.discardUnboundScans.some(scan => scan.mark === code)) { this.discardCartonCode = ''; return }
if (code.length > 128 || this.discardUnboundScans.filter(scan => scan.itemId === sku.itemId).length >= sku.plannedPackages || this.discardUnboundScans.length >= (this.discardTask.remainingPackages ?? Infinity)) { this.setError(new Error('扫描箱数不能超过本托预留或申请剩余箱数'), '箱唛核对失败'); return }
this.discardUnboundScans = [...this.discardUnboundScans, { itemId: sku.itemId, mark: code }]
this.discardActuals = { ...this.discardActuals, [sku.itemId]: this.discardUnboundScans.filter(scan => scan.itemId === sku.itemId).length }
this.discardCartonCode = ''; this.error = null; playSuccessSound(); return
}
if (!this.discardTask.cartonMarks.includes(code)) { this.setError(new Error('箱号不属于当前废弃任务'), '箱号核对失败'); return }
if (!this.discardScannedMarks.includes(code)) this.discardScannedMarks = [...this.discardScannedMarks, code]
this.discardCartonCode = ''; this.error = null; playSuccessSound()
},
/** 删除误扫的现场箱唛,并同步扣回对应明细实扫数量。 */
removeDiscardScan(mark) {
if (this.discardBusy || this.discardPhotoBusy) return
const removed = this.discardUnboundScans.find(scan => scan.mark === mark)
if (!removed) return
this.discardUnboundScans = this.discardUnboundScans.filter(scan => scan.mark !== mark)
this.discardActuals = { ...this.discardActuals, [removed.itemId]: this.discardUnboundScans.filter(scan => scan.itemId === removed.itemId).length }
this.error = null; this.successMessage = ''
},
async resolveDiscardPallet(raw) { async resolveDiscardPallet(raw) {
const code = String(typeof raw === 'string' ? raw : raw?.detail?.value ?? this.discardPalletCode).trim() const code = String(raw?.detail?.value ?? raw ?? this.discardPalletCode).trim()
if (!code || !this.discardCurrent || this.discardBusy || this.discardLoading || this.discardPhotoBusy) return if (!code || !this.discardCurrent || this.discardBusy || this.discardLoading || this.discardPhotoBusy) return
if ((this.discardQuantityLines.length && !this.discardCurrentTasks.some(task => task.palletNo === code) && this.discardTask?.palletNo !== code) || (this.discardTask?.scanMode === 'CARTON' && this.discardTask.cartonMarks.includes(code))) { await this.scanDiscardCarton(code); return }
if (this.discardTask?.palletNo === code) { await this.refreshDiscardTask(); return }
// 当前托盘可重扫,同一废弃单内也可通过托盘或所选箱唛切换任务。
if (this.discardTask?.scanMode === 'CARTON' && !this.discardTasks.some(task => task.discardNo === this.discardCurrent.discardNo && (task.palletNo === code || (task.cartonMarks || []).includes(code)))) { this.setError(new Error('托盘或箱唛不属于当前废弃单所选货物'), '箱唛核对失败'); return }
// 有托盘的任务须先扫描本单托盘,箱号仅用于展开后的逐箱核对。
const selectedTasks = this.discardTasks.filter(task => task.discardNo === this.discardCurrent.discardNo)
if (selectedTasks.length && !selectedTasks.some(task => task.palletNo !== '散箱' ? task.palletNo === code : (task.cartonMarks || []).includes(code))) { this.setError(new Error('请先扫描当前废弃单对应的托盘码'), '托盘核对失败'); return }
const order = this.discardCurrent const order = this.discardCurrent
this.resetDiscardPhotos(); this.discardScannedMarks = []; this.discardCartonsExpanded = true; this.discardCartonCode = ''; this.discardPalletCode = code; this.discardLoading = true; this.discardNeedsRefresh = false; this.discardTask = null; this.discardUnboundScans = []; this.discardScannedExpanded = false; this.discardScanItemId = ''; this.discardActuals = {}; this.error = null; this.successMessage = '' this.resetDiscardPhotos(); this.discardPalletCode = code; this.discardLoading = true; this.discardTask = null; this.discardActuals = {}; this.error = null
try { try {
const task = await resolveCargoDiscardPallet(code, order.discardNo) const task = await resolveCargoDiscardPallet(code)
if (this.discardCurrent !== order) return if (this.discardCurrent !== order) return
if (task.discardNo !== order.discardNo) throw new Error('扫描的托盘不属于当前废弃订单') if (task.discardNo !== order.discardNo) throw new Error('扫描的托盘不属于当前废弃订单')
this.discardTask = normalizeCargoDiscardTask(task) this.discardTask = normalizeCargoDiscardTask(task)
if (this.discardTask.scanMode === 'CARTON' && this.discardTask.cartonMarks.includes(code)) this.discardScannedMarks = [code] this.discardActuals = Object.fromEntries(this.discardTask.skus.map((sku) => [sku.itemId, sku.actualPackages == null ? sku.plannedPackages : sku.actualPackages])); await this.restoreDiscardPhotos()
this.discardActuals = Object.fromEntries(this.discardTask.skus.map((sku) => [sku.itemId, sku.actualPackages == null ? (sku.actualQuantityEditable ? 0 : sku.plannedPackages) : sku.actualPackages])); await this.restoreDiscardPhotos()
} catch (error) { if (this.discardCurrent === order) this.setError(error, '废弃托盘读取失败') } } catch (error) { if (this.discardCurrent === order) this.setError(error, '废弃托盘读取失败') }
finally { if (this.discardCurrent === order) this.discardLoading = false } finally { if (this.discardCurrent === order) this.discardLoading = false }
}, },
/** 重扫与重试先读取当前权威任务,只保留仍匹配的箱号和实点数;照片仍归当前托盘。 */
async refreshDiscardTask() {
if (!this.discardCurrent || !this.discardTask || this.discardLoading || this.discardPhotoBusy) return false
const order = this.discardCurrent; const previous = this.discardTask
this.discardLoading = true
try {
const latest = await resolveCargoDiscardPallet(previous.palletNo, order.discardNo)
if (this.discardCurrent !== order || this.discardTask !== previous) return false
if (latest.discardNo !== order.discardNo || latest.palletNo !== previous.palletNo) throw new Error('刷新结果不属于当前废弃托盘')
this.discardTask = normalizeCargoDiscardTask(latest)
this.discardScannedMarks = this.discardScannedMarks.filter(mark => (this.discardTask.cartonMarks || []).includes(mark))
this.discardUnboundScans = this.discardUnboundScans.filter(scan => this.discardTask.skus.some(sku => sku.actualQuantityEditable && sku.itemId === scan.itemId))
this.discardActuals = Object.fromEntries(this.discardTask.skus.map(sku => {
const old = previous.skus.find(line => line.itemId === sku.itemId)
const unchanged = old && old.plannedPackages === sku.plannedPackages && old.actualQuantityEditable === sku.actualQuantityEditable
return [sku.itemId, sku.actualPackages ?? (unchanged ? this.discardActuals[sku.itemId] : sku.actualQuantityEditable ? 0 : sku.plannedPackages)]
}))
this.discardPalletCode = latest.palletNo; this.discardNeedsRefresh = false; this.error = null
if (!this.discardPhotoReady) await this.restoreDiscardPhotos()
if (latest.status === 'COMPLETED') this.successMessage = '当前托盘已完成废弃,请返回列表核对本单剩余任务'
return true
} catch (error) { if (this.discardCurrent === order) { this.discardNeedsRefresh = true; this.setError(error, '废弃任务刷新失败,请重新扫描') }; return false }
finally { if (this.discardCurrent === order) this.discardLoading = false }
},
resetDiscardPhotos() { this.discardPhotos = []; this.discardPhotoUrls = {}; this.discardPhotoErrors = {}; this.discardPhotoReady = false }, resetDiscardPhotos() { this.discardPhotos = []; this.discardPhotoUrls = {}; this.discardPhotoErrors = {}; this.discardPhotoReady = false },
async restoreDiscardPhotos() { async restoreDiscardPhotos() {
if (!this.discardTask || !this.discardCurrent) return if (!this.discardTask || !this.discardCurrent) return
@@ -268,38 +180,12 @@
finally { this.discardPhotoBusy = false } finally { this.discardPhotoBusy = false }
}, fail: () => { 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 } },
validDiscardActual(sku) { const raw = this.discardActuals[sku.itemId]; if (raw == null || String(raw).trim() === '') return false; if (sku.actualQuantityEditable && Number(raw) !== this.discardUnboundScans.filter(scan => scan.itemId === sku.itemId).length) return false; const actual = Number(raw); return Number.isSafeInteger(actual) && actual >= 0 && (sku.actualQuantityEditable === true ? actual <= sku.plannedPackages : actual === sku.plannedPackages) }, 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 } },
updateDiscardActual(itemId, event) { this.discardActuals = { ...this.discardActuals, [itemId]: event.detail.value } },
/** 单托完成后刷新同单待办,仍有托盘时明确提示继续扫码。 */
async confirmDiscard() {
if (this.discardBusy || this.discardLoading || !this.canConfirmDiscard || !this.discardTask || !this.discardCurrent) return
this.error = null; this.successMessage = ''; this.discardBusy = true
const palletNo = this.discardTask.palletNo; const discardNo = this.discardCurrent.discardNo
try {
if (this.discardNeedsRefresh) {
if (!await this.refreshDiscardTask() || this.discardTask.status === 'COMPLETED' || !this.discardPhotoReady || !this.discardTask.skus.every(sku => this.validDiscardActual(sku)) || (this.discardTask.scanMode === 'CARTON' && !this.discardTask.cartonMarks.every(mark => this.discardScannedMarks.includes(mark)))) return
}
const lines = this.discardTask.skus.map(sku => ({ itemId: sku.itemId, actualPackages: Number(this.discardActuals[sku.itemId]), ...(sku.actualQuantityEditable ? { scannedCartonMarks: this.discardUnboundScans.filter(scan => scan.itemId === sku.itemId).map(scan => scan.mark) } : {}) }))
const photoFileIds = this.discardPhotos.map(photo => String(photo.fileId))
const key = fulfillmentIdempotencyKey('outbound', 'DISCARD', { discardNo, palletNo, lines, photoFileIds, scannedCartonMarks: this.discardScannedMarks, confirmAllCartons: false })
await confirmCargoDiscard(discardNo, palletNo, lines, photoFileIds, this.discardScannedMarks, false, { idempotencyKey: key.value })
this.resetDiscardPhotos(); this.discardPalletCode = ''; this.discardTask = null; this.discardScannedMarks = []; this.discardCartonCode = ''
// 确认已成功时,刷新失败不能被显示为出库失败或宣告整单完成。
try { this.discardTasks = await listCargoDiscardTasks() }
catch (error) { this.discardTasks = this.discardTasks.filter(task => task.discardNo !== discardNo || task.palletNo !== palletNo); this.successMessage = '托盘 ' + palletNo + ' 废弃已完成,其他托盘待办刷新失败,请返回列表重新加载'; this.setError(error, '待办刷新失败'); return }
const remaining = this.discardPendingTasks.map(task => task.palletNo)
this.successMessage = remaining.length ? '托盘 ' + palletNo + ' 废弃已完成,本单还有 ' + remaining.length + ' 个待废弃任务:' + remaining.join('、') + ',请继续扫描对应托盘' : '本单全部废弃出库作业及照片已提交,已完成'
playSuccessSound()
} catch (error) { this.discardNeedsRefresh = true; this.setError(error, '废弃出库失败') }
finally { this.discardBusy = false }
},
handleScan(raw) { handleScan(raw) {
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim(); if (!code || this.busy || this.loading || this.discardBusy || this.discardLoading) return 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 }) 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.discardCurrent) return this.resolveDiscardPallet(code)
const discardOrder = !this.current && this.discardOrders.find(order => order.discardNo === code || order.outboundNo === code)
if (discardOrder) return this.openDiscardOrder(discardOrder.discardNo)
if (!this.current) return this.openScannedTask(code) if (!this.current) return this.openScannedTask(code)
if (code === this.current.taskNo || code === this.current.outboundNo) return this.openTask(this.current.taskNo) if (code === this.current.taskNo || code === this.current.outboundNo) return this.openTask(this.current.taskNo)
if (!this.taskOperable) return if (!this.taskOperable) return
@@ -312,19 +198,19 @@
async submitPick() { async submitPick() {
if (!this.canPick) return if (!this.canPick) return
const payload = outboundPickPayload(this.selectedItem, this.selectedPosition, this.packages, this.selectedScanCode); const key = fulfillmentIdempotencyKey('outbound', 'pick', { taskNo: this.current.taskNo, ...payload }) 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); this.selectedItem = null; this.selectedPosition = null; this.selectedScanCode = ''; updateFulfillmentDraft('outbound', { scanCode: '', packages: 1 }); this.successMessage = '本次出库拣货已记录'; playSuccessSound() }) 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) { async submitPalletPick(palletNo) {
const code = String(palletNo || '').trim(); if (!code || !this.taskOperable) return const code = String(palletNo || '').trim(); if (!code || !this.taskOperable) return
const key = fulfillmentIdempotencyKey('outbound', 'pallet-pick', { taskNo: this.current.taskNo, palletNo: code }) 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); this.selectedItem = null; this.selectedPosition = null; updateFulfillmentDraft('outbound', { scanCode: '', packages: 1 }); this.successMessage = `${code} 整托拣货已完成`; playSuccessSound() }) 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() { async completeTask() {
if (!this.canComplete) return if (!this.canComplete) return
const key = fulfillmentIdempotencyKey('outbound', 'complete', { taskNo: this.current.taskNo }) const key = fulfillmentIdempotencyKey('outbound', 'complete', { taskNo: this.current.taskNo })
await this.run('完成出库任务', async () => { await completeOutboundTask(this.current.taskNo, { idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); this.successMessage = '出库任务已完成'; playSuccessSound() }) 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.discardCurrent) { if (this.discardTask) await this.refreshDiscardTask(); else if (this.discardPalletCode) await this.resolveDiscardPallet(this.discardPalletCode); return } if (this.current?.taskNo) await this.openTask(this.current.taskNo); else if (fulfillment.outbound.currentTaskNo) await this.openTask(fulfillment.outbound.currentTaskNo); else if (this.scanCode) await this.openScannedTask(this.scanCode); else await this.loadTasks() }, async 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 = '' } }, 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() }, 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 || '业务码缺失' }, lineBusinessCode(position) { return position?.cartonMark || position?.palletNo || position?.skuCode || position?.inboundCode || '业务码缺失' },
@@ -337,13 +223,6 @@
<style scoped> <style scoped>
@import '../dispatch/fulfillment.scss'; @import '../dispatch/fulfillment.scss';
.discard-carton-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 40px; cursor: pointer; }
.discard-carton-heading > .business-code { flex-shrink: 0; white-space: nowrap; }
.discard-carton-heading-end { display: flex; align-items: center; gap: 12px; }
.discard-carton-heading-end .scan-help { margin: 0; }
.discard-carton-chevron { flex-shrink: 0; width: 8px; height: 8px; margin-right: 3px; border-right: 2px solid #718095; border-bottom: 2px solid #718095; transform: rotate(45deg); }
.discard-carton-chevron.expanded { transform: rotate(225deg); }
.discard-scan-remove { margin-left: auto; padding: 4px 10px; border: 0; border-radius: 4px; background: #fef2f2; color: #dc2626; font-size: 12px; }
.discard-photo-section{margin:14px 0;padding-top:12px;border-top:1px solid #d9e3ee}.discard-photo-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin:12px 0}.discard-photo-tile image{display:block;width:100%;height:120px;border-radius:8px} .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 { margin-top: 9px; padding: 10px; border: 1px solid #e0e7ef; border-radius: 8px; background: #f8fafc; }
.outbound-line.selected { border: 2px solid #135ee8; background: #edf5ff; } .outbound-line.selected { border: 2px solid #135ee8; background: #edf5ff; }
@@ -351,6 +230,4 @@
.line-code { color: #263b55; font-size: 12px; font-weight: 800; overflow-wrap: anywhere; } .line-code { color: #263b55; font-size: 12px; font-weight: 800; overflow-wrap: anywhere; }
.line-meta { margin-top: 4px; color: #718095; font-size: 10px; } .line-meta { margin-top: 4px; color: #718095; font-size: 10px; }
.missing-scan-contract { display: block; margin-top: 9px; padding: 9px; border-radius: 7px; color: #a56800; font-size: 10px; line-height: 16px; background: #fff6df; } .missing-scan-contract { display: block; margin-top: 9px; padding: 9px; border-radius: 7px; color: #a56800; font-size: 10px; line-height: 16px; background: #fff6df; }
.discard-remark .card-title, .discard-remark .task-meta { color: #dc2626; }
.discard-scanned-count { display: block; margin-bottom: 8px; color: #dc2626; font-size: 15px; font-weight: 700; }
</style> </style>
+3 -3
View File
@@ -5,7 +5,7 @@
<view class="portal-content pallet-content"> <view class="portal-content pallet-content">
<view class="pallet-heading"> <view class="pallet-heading">
<text class="pallet-title">托盘管理</text> <text class="pallet-title">托盘管理</text>
<text class="pallet-subtitle">扫描实物托盘,按 Token 绑定的当前海外仓执行作业</text>
</view> </view>
<scroll-view class="pallet-mode-scroll" scroll-x :show-scrollbar="false"> <scroll-view class="pallet-mode-scroll" scroll-x :show-scrollbar="false">
@@ -46,7 +46,7 @@
<view v-if="mode !== 'query' && sources.length" class="pallet-card"> <view v-if="mode !== 'query' && sources.length" class="pallet-card">
<text class="pallet-card-title">作业备注</text> <text class="pallet-card-title">作业备注</text>
<textarea v-model="reason" class="pallet-reason" maxlength="500" aria-label="作业备注(选填)" /> <textarea v-model="reason" class="pallet-reason" maxlength="500" placeholder="选填,异常或调整原因" />
<text class="pallet-confirm-tip">{{ confirmationTip }}</text> <text class="pallet-confirm-tip">{{ confirmationTip }}</text>
<button class="pallet-primary" :disabled="!canSubmit || submitting" @tap="submitOperation">{{ submitting ? '提交中…' : actionText }}</button> <button class="pallet-primary" :disabled="!canSubmit || submitting" @tap="submitOperation">{{ submitting ? '提交中…' : actionText }}</button>
</view> </view>
@@ -235,7 +235,7 @@
async submitOperation() { async submitOperation() {
if (!this.canSubmit) return if (!this.canSubmit) return
this.submitting = true; this.error = null; this.result = null this.submitting = true; this.error = null; this.result = null
this.pendingKey = idempotencyKey(this.mode) if (!this.pendingKey) this.pendingKey = idempotencyKey(this.mode)
try { try {
const source = expectation(this.sources[0]) const source = expectation(this.sources[0])
const target = this.target ? expectation(this.target) : null const target = this.target ? expectation(this.target) : null
+6 -6
View File
@@ -1,8 +1,8 @@
<template> <template>
<view v-if="user" class="page-shell portal-page work-page"> <view v-if="user" class="page-shell portal-page work-page">
<PortalHeader :title="title" :user="user" /> <PortalHeader :title="title" :user="user" fallback-url="/pages/module/module?key=query" />
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content"> <scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
<view class="work-heading"><text class="work-title">{{ title }}</text></view> <view class="work-heading"><text class="work-title">{{ title }}</text><text class="work-subtitle">{{ subtitle }};查询范围固定为 Token 绑定的海外仓</text></view>
<view class="work-card"><ScanTargetInput name="query" v-model="code" :label="label" :placeholder="placeholder" :active="true" @submit="search" /><button class="primary" :disabled="!code.trim() || loading" @tap="search(code)">{{ loading ? '查询中…' : '查询' }}</button></view> <view class="work-card"><ScanTargetInput name="query" v-model="code" :label="label" :placeholder="placeholder" :active="true" @submit="search" /><button class="primary" :disabled="!code.trim() || loading" @tap="search(code)">{{ loading ? '查询中…' : '查询' }}</button></view>
<view v-if="loading" class="empty-box"><uni-load-more status="loading" /><text class="empty-copy">正在读取最新库存事实…</text></view> <view v-if="loading" class="empty-box"><uni-load-more status="loading" /><text class="empty-copy">正在读取最新库存事实…</text></view>
<template v-else-if="searched && mode === 'inventory'"> <template v-else-if="searched && mode === 'inventory'">
@@ -26,13 +26,13 @@
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js' import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
const MODES = { const MODES = {
inventoryQuery: { mode: 'inventory', title: '库存查询', label: '商品码或箱码', placeholder: '扫描或输入 SKU / 箱码' }, inventoryQuery: { mode: 'inventory', title: '库存查询', label: '商品码或箱码', placeholder: '扫描或输入 SKU / 箱码', subtitle: '查看可用、冻结和收货中库存' },
locationQuery: { mode: 'location', title: '库位查询', label: '库位码', placeholder: '扫描或输入库位码' }, locationQuery: { mode: 'location', title: '库位查询', label: '库位码', placeholder: '扫描或输入库位码', subtitle: '查看库位状态、容量和占用' },
palletQuery: { mode: 'pallet', title: '托盘查询', label: '托盘码', placeholder: '扫描或输入托盘码' } palletQuery: { mode: 'pallet', title: '托盘查询', label: '托盘码', placeholder: '扫描或输入托盘码', subtitle: '查看托盘状态和绑定库存' }
} }
export default { export default {
components: { PortalHeader, ScanTargetInput }, components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, operationKey: 'inventoryQuery', mode: 'inventory', title: '库存查询', label: '商品码或箱码', placeholder: '扫描或输入 SKU / 箱码', loading: false, searched: false, result: null, error: null } }, data() { return { user: session.currentUser, operationKey: 'inventoryQuery', mode: 'inventory', title: '库存查询', label: '商品码或箱码', placeholder: '扫描或输入 SKU / 箱码', subtitle: '', loading: false, searched: false, result: null, error: null } },
computed: { computed: {
storeField() { return `${this.mode}Code` }, storeField() { return `${this.mode}Code` },
code: { get() { return warehouseWork.query[this.storeField] || '' }, set(value) { updateWarehouseWork('query', { [this.storeField]: value }) } }, code: { get() { return warehouseWork.query[this.storeField] || '' }, set(value) { updateWarehouseWork('query', { [this.storeField]: value }) } },
+23 -32
View File
@@ -1,32 +1,21 @@
<template> <template>
<view v-if="user" class="page-shell portal-page work-page"> <view v-if="user" class="page-shell portal-page work-page">
<PortalHeader :title="title" :user="user" /> <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"> <scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
<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 v-if="showCurrent" class="detail-toolbar"><button :disabled="busy" @tap="closeCurrent">‹ 返回列表</button></view>
<template v-if="!showCurrent"> <template v-if="!showCurrent">
<view class="row-head candidate-toolbar"><text class="card-title">{{ candidateHeading }}</text><button v-if="stage !== 'review'" class="link-button" :disabled="loading" @tap="bootstrap">{{ loading ? '刷新中…' : '刷新' }}</button></view> <view class="row-head"><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-if="loading && !visibleCandidates.length" class="empty-box"><uni-load-more status="loading" /><text class="empty-copy">正在加载换标任务…</text></view>
<view v-else-if="!visibleCandidates.length" class="empty-box"><uni-icons type="info" color="#8090a3" size="32" /><text class="empty-copy">{{ emptyText }}</text></view> <view v-else-if="!visibleCandidates.length" class="empty-box"><uni-icons type="info" color="#8090a3" size="32" /><text class="empty-copy">{{ emptyText }}</text></view>
<view v-else-if="stage !== 'review'" class="task-list"> <view v-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-for="item in visibleCandidates" :key="item.relabelNo" class="task-card relabel-candidate" :class="{ current: current?.relabelNo === item.relabelNo, 'rework-card': item.reviewDecision === 'REWORK' }" @tap="openExecution(item)"> <view 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 class="row-head"><text class="business-code">{{ item.relabelNo }}</text><text class="status-pill">{{ statusLabel(item.taskStatus || item.applicationStatus) }}</text></view>
<view class="candidate-kind"><text>{{ relabelTypeName(item.operationType) }}</text><text v-if="item.reviewDecision === 'REWORK'" class="rework-tag">退回重做</text></view>
<view class="candidate-footer"><text class="candidate-progress">已换标 <text class="candidate-progress-value">{{ item.relabelledPackages }} / {{ item.expectedPackages }}</text></text><text v-if="item.skippedPackages + item.exceptionPackages > 0" class="candidate-warning">跳过/异常 {{ item.skippedPackages + item.exceptionPackages }}</text><text class="candidate-enter">进入 ›</text></view>
</view>
</view>
<view v-else class="task-list">
<view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card relabel-candidate" @tap="openReview(item)">
<view class="row-head"><text class="business-code">{{ item.relabelNo }}</text><text class="status-pill">{{ statusLabel(item.taskStatus || item.applicationStatus) }}</text></view>
<view class="candidate-kind"><text>{{ relabelTypeName(item.operationType) }}</text></view>
<view class="candidate-footer"><text class="candidate-progress">已换标 <text class="candidate-progress-value">{{ item.relabelledPackages }} / {{ item.expectedPackages }}</text></text><text v-if="item.skippedPackages + item.exceptionPackages > 0" class="candidate-warning">跳过/异常 {{ item.skippedPackages + item.exceptionPackages }}</text><text class="candidate-enter">查看并复核 ›</text></view>
</view>
</view>
</template> </template>
<view v-if="showCurrent" class="work-card"> <view v-if="showCurrent" class="work-card">
<text class="status-pill">换标类型:{{ relabelTypeName(current.operationType) }}</text> <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.customerName || candidates.find(item => item.relabelNo === current.relabelNo)?.customerName || '未提供' }}</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="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="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="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> <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'"> <template v-if="stage === 'execute'">
@@ -52,7 +41,7 @@
<template v-if="taskTab === 'sku' && selectedItem && action !== 'RELABEL' && remaining(selectedItem) > 0"> <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> <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 name="oldLabel" v-model="oldLabel" label="仓库唛头" placeholder="扫描或输入仓库唛头" :active="activeTarget === 'old'" @activate="activeTarget = 'old'" @submit="handleOldLabel" />
<template v-if="action !== 'RELABEL'"><text class="field-label section-gap">跳过或异常原因(必填)</text><textarea v-model="reason" class="reason-input" maxlength="1000" aria-label="跳过或异常原因(必填)" /><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> <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> <button class="primary" :disabled="busy || !canSubmitUnit" @tap="submitUnit">{{ busyAction === 'unit' ? '提交中…' : unitActionText }}</button>
</template> </template>
<view class="section-gap"> <view class="section-gap">
@@ -67,13 +56,13 @@
<text class="photo-name">{{ photoNames[index] || '换标现场照片' }}</text> <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 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> </view></view>
<text class="field-label section-gap">{{ allProcessed ? '完成备注(选填)' : '异常作业备注(必填)' }}</text><textarea v-model="remark" class="reason-input" maxlength="900" :aria-label="allProcessed ? '完成备注(选填)' : '异常作业备注(必填)'" /> <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 v-if="completionBlockReason" class="error-box section-gap"><text class="error-title">还不能完成换标</text><text>{{ completionBlockReason }}</text></view>
</view> </view>
</template> </template>
</template> </template>
<template v-else-if="stage === 'review'"> <template v-else-if="stage === 'review'">
<view class="section-gap"><text class="card-title">待客户确认</text><text class="meta-line">现场换标已完成,等待客户确认。</text></view> <view class="section-gap"><text class="card-title">待客户确认</text><text class="meta-line">现场换标已经完成。请客户在客户工作台核对完工结果并确认;需要退回重做时由平台处理。</text></view>
</template> </template>
</view> </view>
@@ -91,7 +80,7 @@
import ScanTargetInput from '@/components/ScanTargetInput.vue' import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { claimRelabelTask, completeRelabelTask, getRelabelTask, listRelabelCandidates, relabelUnit, reportRelabelException, reviewRelabelTask, skipRelabelUnit, uploadRelabelPhoto } from '@/api/relabel.js' import { claimRelabelTask, completeRelabelTask, getRelabelTask, listRelabelCandidates, relabelUnit, reportRelabelException, reviewRelabelTask, skipRelabelUnit, uploadRelabelPhoto } from '@/api/relabel.js'
import { session, requireSession } from '@/stores/session.js' import { session, requireSession } from '@/stores/session.js'
import { restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js' import { releaseWarehouseKey, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js' import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { scanRelabelSku, scanRelabelCarton, completeReturnRelabelSku, listRelabelDocuments, downloadRelabelDocument, downloadRelabelPhoto } from '@/api/relabel.js' import { scanRelabelSku, scanRelabelCarton, completeReturnRelabelSku, listRelabelDocuments, downloadRelabelDocument, downloadRelabelPhoto } from '@/api/relabel.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js' import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
@@ -136,6 +125,7 @@
return [...groups.values()].map(group => ({ ...group, remaining: Math.max(0, group.expectedPackages - group.relabelledPackages - group.skippedPackages - group.exceptionPackages) })) 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) }, 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))) }, 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: [] } }, 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) }) }, 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) }) },
@@ -207,7 +197,8 @@
const key = warehouseIdempotencyKey('relabel', 'sku-scan', { relabelNo: this.current.relabelNo, ...payload }) const key = warehouseIdempotencyKey('relabel', 'sku-scan', { relabelNo: this.current.relabelNo, ...payload })
await this.run('sku-scan', async () => { await this.run('sku-scan', async () => {
const value = await scanRelabelSku(this.current.relabelNo, { ...payload, idempotencyKey: key.value }) const value = await scanRelabelSku(this.current.relabelNo, { ...payload, idempotencyKey: key.value })
this.applyCurrent(value); const confirmed = value.items.find(item => item.itemId === row.item.itemId)?.skuItems?.[row.index] 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.lastSku = confirmed ? `${confirmed.skuCode} 换标 ${confirmed.newSkuCode} · ${confirmed.quantity} 件` : '已保存,请重新读取标签绑定信息'
this.quickSku = ''; this.quickTargetLabel = ''; this.quickActualQuantity = ''; this.skuChoices = []; this.activeTarget = 'quick-sku' 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 })) await new Promise(resolve => uni.showModal({ title: '请粘贴对应 SKU 标签', content: `${row.item.cartonMark}\n${this.lastSku}`, showCancel: false, confirmText: '知道了', complete: resolve }))
@@ -245,7 +236,7 @@
if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > remaining) return this.setError(new Error('本来源实际贴标数量必填,须为 1 至 ' + remaining + ' 件')) 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 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 }) 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 })); delete this.bulkQuantities[item.itemId]; this.successMessage = item.cartonMark + ' · ' + item.skuCode + ' 已登记 ' + quantity + ' 件' }, true, key) 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) { async loadPhoto(fileId) {
const no = this.current?.relabelNo const no = this.current?.relabelNo
@@ -292,6 +283,7 @@
await this.run('carton-scan', async () => { await this.run('carton-scan', async () => {
const result = await scanRelabelCarton(payload.relabelNo, { cartonMark: mark, expectedVersion: payload.expectedVersion, idempotencyKey: key.value }) const result = await scanRelabelCarton(payload.relabelNo, { cartonMark: mark, expectedVersion: payload.expectedVersion, idempotencyKey: key.value })
this.applyCurrent(result) this.applyCurrent(result)
releaseWarehouseKey('relabel', key.signature)
const confirmedItem = result.items.find((row) => row.itemId === item.itemId) 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.lastCarton = { cartonMark: mark, fbaCartonMark: confirmedItem?.fbaCartonMark || item.fbaCartonMark, destinationFbaWarehouseCode: confirmedItem?.destinationFbaWarehouseCode || item.destinationFbaWarehouseCode }
this.quickCarton = '' this.quickCarton = ''
@@ -316,7 +308,7 @@
focusNextSku(index) { const next = index + 1; this.activeTarget = next < this.unitDraft.skuVerifications.length ? `sku-new-${next}` : '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) }, 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' }, 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 })); this.successMessage = '换标任务已认领,可进入换标执行'; await this.loadCandidates() }, true, key) }, 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 promptActiveTask(active) { async promptActiveTask(active) {
if (this.busy) return if (this.busy) return
this.busy = true this.busy = true
@@ -358,20 +350,20 @@
if (!confirmed) return if (!confirmed) return
this.taskCode = item.relabelNo this.taskCode = item.relabelNo
const payload = { relabelNo: item.relabelNo }; const key = warehouseIdempotencyKey('relabel', 'claim', payload); let claimed = false 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 })); claimed = true }, true, key) 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)}` }) 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 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 }); this.applyCurrent(value); this.oldLabel = ''; this.newLabel = ''; this.reason = ''; this.activeTarget = 'old'; this.successMessage = '本箱处理结果已保存' }, 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(), 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) } }) }, 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 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.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 }); updateWarehouseWork('relabel', { currentNo: '', photoFileIds: [], photoNames: [], remark: '' }); completed = true }, true, key); if (completed) this.goToClaimList() }, 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' }) }, 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 = '' }, 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 })); this.remark = ''; this.successMessage = decision === 'APPROVE' ? '换标复核已通过' : '换标任务已退回重做'; await this.loadCandidates() }, true, key) }, 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 retry() { if (this.current?.relabelNo) await this.openTask(this.current.relabelNo, false); else await this.loadCandidates() },
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 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)) { try { await this.reloadCurrent(); this.error.message += ',已刷新任务版本,请重新确认' } catch (_) {} } } finally { this.busy = false; this.busyAction = '' } }, 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 = '换标作业失败') { setError(error, fallback = '换标作业失败') {
this.error = { message: relabelErrorMessage(error, fallback), code: error?.code, requestId: error?.requestId, traceId: error?.traceId } this.error = { message: relabelErrorMessage(error, fallback), code: error?.code, requestId: error?.requestId, traceId: error?.traceId }
playErrorSound() playErrorSound()
@@ -384,10 +376,9 @@
} }
</script> </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}.task-card.rework-card{border:2px solid #f29b38;background:#fffaf2;box-shadow:0 5px 16px rgba(221,116,20,.13)}.rework-tag{display:inline-flex;padding:2px 8px;border-radius:999px;font-size:12px;font-weight:700;line-height:18px;background:#d94343;color:#fff}.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} <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} .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} .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} .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} .summary-metrics{display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:4px;margin-left:auto;font-size:9px;line-height:18px}.summary-metric{display:inline-block;padding:1px 4px;border-radius:4px;background:#dcfce7;color:#16a34a;font-weight:700;white-space:nowrap}
.candidate-toolbar{align-items:center;margin-bottom:12px}.candidate-toolbar .card-title{margin-bottom:0;font-size:16px;line-height:24px}.candidate-toolbar .link-button{display:flex;align-items:center;justify-content:center;box-sizing:border-box;min-width:88px;height:44px;min-height:44px;margin:0;padding:0 16px;border:1px solid #9dc0f4;border-radius:8px;color:#135ee8;font-size:16px;font-weight:700;line-height:normal;background:#eaf2ff}.candidate-toolbar .link-button::after{border:0}.candidate-toolbar .link-button[disabled]{color:#718095;border-color:#dce5f0;background:#e8edf3} .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>
.relabel-candidate .business-code{min-width:0;font-size:18px;line-height:24px;overflow-wrap:anywhere}.relabel-candidate .status-pill{font-size:12px;line-height:18px}.candidate-kind{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:8px;color:#52647c;font-size:14px;line-height:22px}.candidate-footer{display:flex;align-items:center;flex-wrap:wrap;gap:6px 12px;margin-top:10px}.candidate-progress{color:#52647c;font-size:14px;line-height:24px}.candidate-progress-value{color:#135ee8;font-size:18px;font-weight:700}.candidate-warning{color:#b54708;font-size:12px;line-height:20px}.candidate-enter{margin-left:auto;color:#135ee8;font-size:14px;font-weight:600;line-height:24px}</style>
@@ -1,6 +1,6 @@
<template> <template>
<view v-if="user" class="page-shell portal-page"> <view v-if="user" class="page-shell portal-page">
<PortalHeader title="退货箱内清点" :user="user" /> <PortalHeader title="退货箱内清点" :user="user" fallback-url="/pages/module/module?key=inbound" />
<view class="work-content counting-content"> <view class="work-content counting-content">
<view class="work-card"><ScanTargetInput name="carton" v-model="scanCode" label="箱号或托盘号" placeholder="扫描箱号或托盘号" :active="true" @submit="scan" /><view class="actions"><button v-if="task || scanResult" class="secondary small" :disabled="busy" @tap="back">返回任务</button></view></view> <view class="work-card"><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> <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>
@@ -13,22 +13,22 @@
<template v-else> <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 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 v-for="(box, index) in task.boxes" :id="`box-${index}`" :key="box.cartonMark" class="work-card box-card" :class="{ selected: box.cartonMark === selectedMark }">
<view class="row-head"><text class="card-title">第 {{ index + 1 }} 箱</text><button v-if="task.status === 'COUNTING'" class="link-button" :disabled="busy" @tap="addSku(box)">添加 SKU</button></view><text class="box-mark">{{ box.cartonMark }}</text><CartonCountFields volume-unit="m3" :box="box" :sku-codes="skuCodes" :disabled="busy || task.status !== 'COUNTING'" /> <view class="row-head"><text class="card-title">第 {{ index + 1 }} 箱</text><button v-if="task.status === 'COUNTING'" class="link-button" :disabled="busy" @tap="addSku(box)">添加 SKU</button></view><text class="box-mark">{{ box.cartonMark }}</text><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>
<view class="work-card"><button v-if="task.status === 'COUNTING'" class="primary" :disabled="busy || !ready" @tap="confirm">{{ busy ? '提交中…' : '确认全部箱内清点' }}</button><text v-else class="success">箱内清点已完成,可以继续换标或出库。</text></view> <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> </template>
</view> </view>
</view> </view>
</template> </template>
<script> <script>
import CartonCountFields from '@/components/CartonCountFields.vue'
import PortalHeader from '@/components/PortalHeader.vue' import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue' import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { session, requireSession } from '@/stores/session.js' import { session, requireSession } from '@/stores/session.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js' import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { listReturnCountTasks, scanReturnCountTasks, getCurrentReturnCountTask, beginReturnCountTask, confirmReturnCount } from '@/api/return-counting.js' import { listReturnCountTasks, scanReturnCountTasks, getCurrentReturnCountTask, beginReturnCountTask, confirmReturnCount } from '@/api/return-counting.js'
export default { export default {
components: { PortalHeader, ScanTargetInput, CartonCountFields }, 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 } }, 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: { computed: {
skuCodes() { return this.task?.expectedItems.map(row => row.skuCode) || [] }, skuCodes() { return this.task?.expectedItems.map(row => row.skuCode) || [] },
@@ -88,7 +88,6 @@ export default {
<style scoped lang="scss"> <style scoped lang="scss">
@import '../warehouse/operations.scss'; @import '../warehouse/operations.scss';
.counting-content{padding:12px 12px 42px}.error-card{color:#9d2323;background:#fff0f0} .counting-content{padding:12px 12px 42px}.error-card{color:#9d2323;background:#fff0f0}
.counting-content .primary{display:flex;align-items:center;justify-content:center;padding:0 12px;line-height:normal;text-align:center}
.actions{display:flex;align-items:center;gap:8px;margin-top:10px}.small{width:auto;min-height:32px;margin:0;padding:0 12px;font-size:12px;line-height:32px}.search{width:100%;font-size:12px}.pager{justify-content:center;font-size:12px}.entry,.success{color:#135ee8;font-size:12px}.total-row{display:flex;justify-content:space-between;gap:12px;font-size:11px;margin-top:8px;color:#c67716}.total-row.matched{color:#22804a}.box-mark{display:block;color:#65768b;font-size:11px;margin:6px 0;word-break:break-all}.count-line{display:flex;align-items:center;gap:8px;margin-top:8px}.sku-picker{flex:1;min-width:0;font-size:11px;word-break:break-all}.quantity{flex:none;width:70px;height:32px;text-align:center;font-size:12px}.box-card.selected{border-color:#135ee8}.row-head .card-title{font-size:13px} .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} .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> </style>
+6 -11
View File
@@ -1,22 +1,17 @@
<template> <template>
<view v-if="user" class="page-shell portal-page work-page"> <view v-if="user" class="page-shell portal-page work-page">
<PortalHeader title="退货合箱" :user="user" /> <PortalHeader title="退货合箱" :user="user" fallback-url="/pages/module/module?key=inbound" />
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content"> <scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
<button v-if="!result && (activePlan || mode === 'BOX')" class="link-button" :disabled="busy" @tap="leavePlan">返回扫码 / 计划列表</button> <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, '箱合并失败')" /> <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><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 && 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 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 class="row-head"><text class="card-title">平台合箱计划</text><text class="meta-line">审核通过后推送</text></view>
<view class="task-list">
<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>
<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="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> <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 && mode === 'SKU'"> <template v-if="selectedOrders.length && !result && mode === 'SKU'">
<view class="work-card"><text class="card-title">{{ activePlan ? `合箱计划 ${activePlan.planNo}` : '客户散件库存' }}</text></view> <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> <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> <button class="secondary" :disabled="busy || !mergeQuantity" @tap="distributeBoxes">按单 SKU 一箱分配</button>
<view v-for="(box,index) in targetBoxes" :key="index" class="work-card"> <view v-for="(box,index) in targetBoxes" :key="index" class="work-card">
@@ -51,7 +46,7 @@
import { completePackingPlan, findPackingOrders, listPackingPlans, getPackingPlan, getReturnCartonLabel, scanReturnCarton, packReturnSkus } from '@/api/return-packing.js' import { completePackingPlan, findPackingOrders, listPackingPlans, getPackingPlan, getReturnCartonLabel, scanReturnCarton, packReturnSkus } from '@/api/return-packing.js'
import { defaultPackingBoxes, packingBoxPayload, validPackingAllocation } from '@/utils/return-packing-allocation.js' import { defaultPackingBoxes, packingBoxPayload, validPackingAllocation } from '@/utils/return-packing-allocation.js'
import { session, requireSession } from '@/stores/session.js' import { session, requireSession } from '@/stores/session.js'
import { resetWarehouseDomain, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js' import { releaseWarehouseKey, resetWarehouseDomain, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js' import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js' import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
@@ -113,7 +108,7 @@
const key = warehouseIdempotencyKey('returnPacking', 'pack', { ...payload, planNo: this.activePlan?.planNo }) const key = warehouseIdempotencyKey('returnPacking', 'pack', { ...payload, planNo: this.activePlan?.planNo })
await this.run(async () => { 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 }) const value = this.activePlan ? await completePackingPlan(this.activePlan.planNo, { boxes: payload.boxes, idempotencyKey: key.value }) : await packReturnSkus({ ...payload, idempotencyKey: key.value })
this.packingCompleted(value); await this.loadPlans() 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}))) }) }, distributeBoxes() { updateWarehouseWork('returnPacking', { targetBoxes: defaultPackingBoxes(Object.entries(this.packing.quantities || {}).map(([skuCode,quantity]) => ({skuCode,quantity}))) }) },
+42 -42
View File
@@ -1,19 +1,21 @@
<template> <template>
<view v-if="user" class="page-shell portal-page work-page"> <view v-if="user" class="page-shell portal-page work-page">
<PortalHeader title="退货入库" :user="user" /> <PortalHeader title="退货入库" :user="user" fallback-url="/pages/module/module?key=inbound" />
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content"> <scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
<view v-if="!current && !result" class="work-card"> <view v-if="!current && !result && !receiptBatch" class="work-card">
<text class="card-title">扫描 SKU 69 码</text> <text class="card-title">扫描 SKU 69 码</text>
<ScanTargetInput name="lookup" v-model="lookupCode" label="SKU 69 码" placeholder="扫描商品 69 码,自动带出退货单号" :active="activeTarget === 'lookup'" @activate="activeTarget = 'lookup'" @submit="lookup" /> <ScanTargetInput name="lookup" v-model="lookupCode" label="SKU 69 码" placeholder="扫描商品 69 码,自动带出退货单号" :active="activeTarget === 'lookup'" @activate="activeTarget = 'lookup'" @submit="lookup" />
<text class="scan-hint">扫描完成后自动匹配对应退货单</text>
</view> </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 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-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 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> </view>
<template v-if="current && !result">
<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 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"> <view class="work-card">
@@ -45,7 +47,7 @@
</template> </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="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 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></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 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> <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> <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> <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>
@@ -53,7 +55,7 @@
<view v-if="result" class="success-box result-card"><text class="success-title">退货签收成功</text><view v-if="result.labels?.length"><view v-for="(label,index) in result.labels" :key="label.cartonMark" class="item-card"><text class="business-code">第 {{ index + 1 }} {{ result.unitType === 'PARCEL' ? '包裹' : '箱' }} · {{ label.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(label)">打印包裹/箱标签</button><text class="meta-line">签收已默认贴标</text></view></view><view v-else-if="result.printableHtml" class="item-card"><text class="business-code">{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }} · {{ result.cartonMark }}</text><button class="secondary" @tap="printReturnCarton(result)">打印{{ result.unitType === 'PALLET' ? '托盘标签' : '箱唛' }}</button></view><button v-if="result.unitType === 'PALLET' && result.labels?.length" class="secondary" :disabled="busy" @tap="goCount">去箱内清点</button><text v-if="result.palletCode" class="result-carton">托盘:{{ result.palletCode }}</text><text class="meta-line">签收单:{{ result.receiptNo }}</text><text class="meta-line">计费批次:{{ result.receiptBatchNo }}</text><view class="metric-row"><view class="metric"><strong>{{ result.receivedQuantity }}/{{ result.expectedQuantity }}</strong><text>整单累计</text></view><view class="metric"><strong>{{ result.fullyMatched ? '已完成' : '签收中' }}</strong><text>退货单状态</text></view></view><button class="primary" :disabled="busy" @tap="nextReceipt">{{ result.continuationReturnNo ? (unitType === 'PALLET' ? '新增托盘,继续签收剩余货物' : '继续签收本单剩余货物') : '签收下一单' }}</button><button v-if="unitType === 'PALLET' && result.continuationReturnNo" class="secondary" :disabled="busy" @tap="continueScanning">继续签收</button></view> <view v-if="result" class="success-box result-card"><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></scroll-view>
</view> </view>
</template> </template>
@@ -69,8 +71,9 @@
export default { export default {
components: { PortalHeader, ScanTargetInput }, components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, current: null, candidates: null, result: null, receiptResults: [], selectedSku: '', palletCheck: null, newPalletHtml: '', unitType: 'PALLET', unitCode: '', boxRows: [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }], palletLength: '', palletWidth: '', palletHeight: '', palletWeight: '', unitOptions: [{ value: 'PALLET', label: '托盘' }, { value: 'BOX', label: '箱子' }, { value: 'PARCEL', label: '包裹' }], photoFileIds: [], photoNames: [], photoUrls: {}, activeTarget: 'lookup', busy: false, error: null } }, 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: { computed: {
receiptBatch() { return warehouseWork.returnReceipt.pendingBatch },
entryMode: { get() { return warehouseWork.returnReceipt.entryMode }, set(value) { updateWarehouseWork('returnReceipt', { entryMode: value }) } }, entryMode: { get() { return warehouseWork.returnReceipt.entryMode }, set(value) { updateWarehouseWork('returnReceipt', { entryMode: value }) } },
lookupCode: { get() { return warehouseWork.returnReceipt.lookupCode }, set(value) { updateWarehouseWork('returnReceipt', { lookupCode: 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 }) } }, startedBy: { get() { return warehouseWork.returnReceipt.startedBy }, set(value) { updateWarehouseWork('returnReceipt', { startedBy: value }) } },
@@ -86,7 +89,7 @@
unitPlaceholder() { return this.unitType === 'PALLET' ? '扫描托盘标签' : this.unitType === 'BOX' ? '扫描箱唛或录入箱号' : '扫描包裹面单或录入包裹码' } unitPlaceholder() { return this.unitType === 'PALLET' ? '扫描托盘标签' : this.unitType === 'BOX' ? '扫描箱唛或录入箱号' : '扫描包裹面单或录入包裹码' }
,palletVolume() { const l = Number(this.palletLength) || 0; const w = Number(this.palletWidth) || 0; const h = Number(this.palletHeight) || 0; return l > 0 && w > 0 && h > 0 ? (l * w * h).toFixed(2) : '—' } ,palletVolume() { const l = Number(this.palletLength) || 0; const w = Number(this.palletWidth) || 0; const h = Number(this.palletHeight) || 0; return l > 0 && w > 0 && h > 0 ? (l * w * h).toFixed(2) : '—' }
}, },
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.entryMode = 'SKU'; this.startScanner(); if (warehouseWork.returnReceipt.currentReturnNo && !this.current && !this.busy) this.openOrder(warehouseWork.returnReceipt.currentReturnNo, 'SKU', false) }, onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.entryMode = 'SKU'; 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() }, onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: { methods: {
goCount() { const mark = this.result?.labels?.[0]?.cartonMark; if (mark) uni.navigateTo({ url: '/pages/return-counting/return-counting?cartonMark=' + encodeURIComponent(mark) }) }, goCount() { const mark = this.result?.labels?.[0]?.cartonMark; if (mark) uni.navigateTo({ url: '/pages/return-counting/return-counting?cartonMark=' + encodeURIComponent(mark) }) },
@@ -111,12 +114,12 @@
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() }, 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, '扫码广播注册失败') }) }, 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 }, stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
handleScan(value) { if (this.result) return; if (this.busy) return; if (!this.current) { this.lookupCode = value; this.lookup(value); return } if (this.activeTarget === 'unit') this.acceptUnit(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' }, 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; 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 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 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 openOrder(returnNo, startedBy, feedback = true) { await this.run(async () => { await this.openOrderValue(returnNo, startedBy, feedback) }, '读取退货单失败') },
async openOrderValue(returnNo, startedBy, feedback = true) { const previous = this.current; const value = await startReturnReceipt(returnNo); if (!value) { this.resetReceiptInput(); this.current = null; this.candidates = []; updateWarehouseWork('returnReceipt', { currentReturnNo: '', quantities: {}, boxCounts: {} }); this.activeTarget = 'lookup'; return } if (!previous || previous.returnNo !== value.returnNo || JSON.stringify(previous.expectedItems) !== JSON.stringify(value.expectedItems)) this.resetReceiptInput(); 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() }, 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))])) }, 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) }, quantity(skuCode) { return Math.max(0, Number(warehouseWork.returnReceipt.quantities?.[skuCode]) || 0) },
boxCount(skuCode) { return Math.max(0, Number(warehouseWork.returnReceipt.boxCounts?.[skuCode]) || 0) }, boxCount(skuCode) { return Math.max(0, Number(warehouseWork.returnReceipt.boxCounts?.[skuCode]) || 0) },
@@ -129,35 +132,39 @@
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 */ }, 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 */ }, 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() }, acceptTracking(raw) { this.trackingNo = String(raw?.detail?.value ?? raw ?? '').trim(); playSuccessSound() },
async confirmReceipt() { if (this.busy) return; const totalBoxCount = this.palletBoxCount; if (this.unitType === 'PALLET' && totalBoxCount <= 0) { this.setError(new Error('请先填写各 SKU 的箱数'), '托盘签收校验失败'); return } if (!this.canConfirm || this.busy || (this.unitType !== 'PALLET' && !this.allBoxRowsConfirmed)) return; if (this.unitType !== 'PALLET' && !this.photoFileIds.length) { this.setError(new Error('请先拍照或上传签收照片,再确认签收入库'), '签收校验失败'); return } const willComplete = this.current.expectedItems.every((item) => (this.unitType !== 'PALLET' ? this.boxRows.reduce((sum, box) => sum + (Number(box.items[item.skuCode]) || 0), 0) : this.quantity(item.skuCode)) >= this.remaining(item)); if (willComplete && !this.photoFileIds.length) { const modal = await new Promise((resolve) => uni.showModal({ title: '缺少退货打托照片', content: '本次签收将完成整单入库,请先拍照并上传后再确认打托。', confirmText: '去拍照', cancelText: '取消', success: resolve })); if (modal?.confirm) this.error = { message: '请先拍照并上传退货打托照片后再确认' }; return } async confirmReceipt() { if (this.busy) return; 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 () => { await this.run(async () => {
const rows = this.unitType !== 'PALLET' ? this.boxRows : [null] const rows = this.unitType !== 'PALLET' ? this.boxRows : [null]
let expectedReceivedQuantity = this.current.expectedItems.reduce((sum, item) => sum + Number(item.receivedQuantity || 0), 0)
const entries = rows.map((box, index) => { 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 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) }
payload.expectedReceivedQuantity = expectedReceivedQuantity
expectedReceivedQuantity += payload.items.reduce((sum, item) => sum + item.receivedQuantity, 0)
const key = warehouseIdempotencyKey('returnReceipt', `confirm-${index}`, payload) const key = warehouseIdempotencyKey('returnReceipt', `confirm-${index}`, payload)
return { request: { ...payload, idempotencyKey: key.value }, result: null } return { request: { ...payload, idempotencyKey: key.value }, result: null }
}) })
const scopeKey = warehouseWork.scopeKey if (this.unitType !== 'PALLET') {
for (const entry of entries) { updateWarehouseWork('returnReceipt', { pendingBatch: JSON.parse(JSON.stringify({ returnNo: this.current.returnNo, unitType: this.unitType, entries })) })
const result = await confirmReturnReceipt(entry.request) await this.resumeReceiptBatch()
if (warehouseWork.scopeKey !== scopeKey) throw new Error('作业账号或仓库已改变,请重新进入作业') } else {
if (!result) throw new Error('服务端未返回签收结果') this.finishReceipt([await confirmReturnReceipt(entries[0].request)])
this.receiptResults.push(result) }
if (this.unitType !== 'PALLET') { }, '退货入库失败')
this.boxRows.shift() },
for (const received of entry.request.items) { async resumeReceiptBatch() {
const item = this.current.expectedItems.find(item => item.skuCode === received.skuCode) const batch = this.receiptBatch
if (item) item.receivedQuantity = Number(item.receivedQuantity || 0) + received.receivedQuantity const scopeKey = warehouseWork.scopeKey
} if (!batch) return
} for (let index = 0; index < batch.entries.length; index++) {
} if (warehouseWork.scopeKey !== scopeKey) throw new Error('作业账号或仓库已改变,请重新进入作业')
this.finishReceipt(this.receiptResults) if (batch.entries[index].result) continue
this.receiptResults = [] // 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) { finishReceipt(results) {
const last = results[results.length - 1] 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 }] : []) } 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 }] : []) }
@@ -186,17 +193,10 @@
}, },
previewPhoto(fileId) { const urls = this.photoFileIds.map(id => this.photoUrls[id]).filter(Boolean); if (this.photoUrls[fileId]) uni.previewImage({ current: this.photoUrls[fileId], urls }) }, 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] }, removePhoto(index) { const [id] = this.photoFileIds.splice(index, 1); this.photoNames.splice(index, 1); if (id) delete this.photoUrls[id] },
// 服务端实收变化后重新录入下一份实物,不能给旧草稿换上新库存快照。
resetReceiptInput() {
this.boxRows = [{ items: {}, length: '', width: '', height: '', weight: '', originalPackaging: true, confirmed: false }]
this.unitCode = ''; this.palletLength = ''; this.palletWidth = ''; this.palletHeight = ''; this.palletWeight = ''
this.palletCheck = null; this.newPalletHtml = ''
updateWarehouseWork('returnReceipt', { quantities: {}, boxCounts: {}, palletCode: '', trackingNo: '' })
},
async reloadCurrent() { if (!this.current?.returnNo || this.busy) return; await this.openOrder(this.current.returnNo, this.startedBy, false) }, async reloadCurrent() { if (!this.current?.returnNo || this.busy) return; await this.openOrder(this.current.returnNo, this.startedBy, false) },
clearCurrent() { if (this.busy) return; this.receiptResults = []; resetWarehouseDomain('returnReceipt'); this.current = null; this.candidates = null; this.error = null; this.activeTarget = 'lookup' }, clearCurrent() { if (this.busy || this.receiptBatch) return; resetWarehouseDomain('returnReceipt'); this.current = null; this.candidates = null; this.error = null; this.activeTarget = 'lookup' },
continueScanning() { continueScanning() {
if (this.busy) return if (this.busy || this.receiptBatch) return
this.clearCurrent() this.clearCurrent()
this.result = null this.result = null
this.entryMode = 'SKU'; this.lookupCode = ''; this.selectedSku = '' this.entryMode = 'SKU'; this.lookupCode = ''; this.selectedSku = ''
+8 -8
View File
@@ -1,8 +1,8 @@
<template> <template>
<view v-if="user" class="page-shell portal-page work-page"> <view v-if="user" class="page-shell portal-page work-page">
<PortalHeader title="库存盘点" :user="user" /> <PortalHeader title="库存盘点" :user="user" fallback-url="/pages/module/module?key=inventory" />
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content"> <scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
<view class="work-heading"><text class="work-title">库存盘点</text></view> <view class="work-heading"><text class="work-title">库存盘点</text><text class="work-subtitle">按 Token 绑定的海外仓冻结库位或托盘库存,逐位置实盘后提交差异</text></view>
<view class="work-card"> <view class="work-card">
<text class="card-title">新建盘点任务</text> <text class="card-title">新建盘点任务</text>
<view class="mode-row"><button class="mode-button" :class="{ active: scopeType === 'LOCATION' }" @tap="changeScope('LOCATION')">按库位</button><button class="mode-button" :class="{ active: scopeType === 'PALLET' }" @tap="changeScope('PALLET')">按托盘</button></view> <view class="mode-row"><button class="mode-button" :class="{ active: scopeType === 'LOCATION' }" @tap="changeScope('LOCATION')">按库位</button><button class="mode-button" :class="{ active: scopeType === 'PALLET' }" @tap="changeScope('PALLET')">按托盘</button></view>
@@ -24,7 +24,7 @@
<input v-model.number="item.inputPackages" class="number-input section-gap" type="number" inputmode="numeric" placeholder="输入实盘箱数(可为 0)" /> <input v-model.number="item.inputPackages" class="number-input section-gap" type="number" inputmode="numeric" placeholder="输入实盘箱数(可为 0)" />
<button class="secondary" :disabled="busy || !validCount(item) || current.task.status !== 'IN_PROGRESS'" @tap="saveCount(item)">{{ isSaving(item) ? '保存中…' : '保存本位置实盘数' }}</button> <button class="secondary" :disabled="busy || !validCount(item) || current.task.status !== 'IN_PROGRESS'" @tap="saveCount(item)">{{ isSaving(item) ? '保存中…' : '保存本位置实盘数' }}</button>
</view></view> </view></view>
<template v-if="current.task.status === 'IN_PROGRESS'"><text class="card-title section-gap">提交原因(必填)</text><textarea v-model="reason" class="reason-input" maxlength="500" aria-label="提交原因(必填)" /><button class="primary" :disabled="busy || !allCounted || !reason.trim()" @tap="submitTask">{{ busyAction === 'submit' ? '提交中…' : '提交盘点并调整差异' }}</button><text class="hint">所有位置完成实盘后才能提交。</text></template> <template v-if="current.task.status === 'IN_PROGRESS'"><text class="card-title section-gap">提交原因</text><textarea v-model="reason" class="reason-input" maxlength="500" placeholder="必填,例如:月度循环盘点" /><button class="primary" :disabled="busy || !allCounted || !reason.trim()" @tap="submitTask">{{ busyAction === 'submit' ? '提交中…' : '提交盘点并调整差异' }}</button><text class="hint">所有位置完成实盘后才能提交;版本变化时页面会自动刷新。</text></template>
</view> </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="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>
@@ -38,7 +38,7 @@
import ScanTargetInput from '@/components/ScanTargetInput.vue' import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { countStocktake, createStocktake, getStocktake, listStocktakes, submitStocktake } from '@/api/warehouse-operations.js' import { countStocktake, createStocktake, getStocktake, listStocktakes, submitStocktake } from '@/api/warehouse-operations.js'
import { session, requireSession } from '@/stores/session.js' import { session, requireSession } from '@/stores/session.js'
import { restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js' import { releaseWarehouseKey, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js' import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js' import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
@@ -61,15 +61,15 @@
async bootstrap() { await this.loadTasks(); const saved = warehouseWork.stocktake.taskNo; if (saved) await this.openTask(saved, false) }, async bootstrap() { await this.loadTasks(); const saved = warehouseWork.stocktake.taskNo; if (saved) await this.openTask(saved, false) },
async loadTasks() { if (this.loading) return; this.loading = true; this.error = null; try { this.tasks = await listStocktakes('IN_PROGRESS') } catch (error) { this.setError(error, '盘点任务加载失败') } finally { this.loading = false } }, async loadTasks() { if (this.loading) return; this.loading = true; this.error = null; try { this.tasks = await listStocktakes('IN_PROGRESS') } catch (error) { this.setError(error, '盘点任务加载失败') } finally { this.loading = false } },
changeScope(value) { this.scopeType = value; this.scopeCode = '' }, changeScope(value) { this.scopeType = value; this.scopeCode = '' },
async createTask(raw) { const code = String(raw?.detail?.value ?? raw ?? this.scopeCode).trim(); if (!code || this.busy) return; this.scopeCode = code; const payload = { scopeType: this.scopeType, scopeCode: code }; const key = warehouseIdempotencyKey('stocktake', 'create', payload); await this.run('create', async () => { const value = await createStocktake({ ...payload, idempotencyKey: key.value }); this.applyCurrent(value); this.scopeCode = ''; await this.loadTasks(); this.successMessage = '盘点任务已创建' }) }, async createTask(raw) { const code = String(raw?.detail?.value ?? raw ?? this.scopeCode).trim(); if (!code || this.busy) return; this.scopeCode = code; const payload = { scopeType: this.scopeType, scopeCode: code }; const key = warehouseIdempotencyKey('stocktake', 'create', payload); await this.run('create', async () => { const value = await createStocktake({ ...payload, idempotencyKey: key.value }); releaseWarehouseKey('stocktake', key.signature); this.applyCurrent(value); this.scopeCode = ''; await this.loadTasks(); this.successMessage = '盘点任务已创建' }) },
async openTask(taskNo, feedback = true) { if (!taskNo || this.busy) return; await this.run('detail', async () => { const value = await getStocktake(taskNo); this.applyCurrent(value); if (feedback) playSuccessSound() }, false) }, async openTask(taskNo, feedback = true) { if (!taskNo || this.busy) return; await this.run('detail', async () => { const value = await getStocktake(taskNo); this.applyCurrent(value); if (feedback) playSuccessSound() }, false) },
applyCurrent(value) { this.current = value; this.current.items = this.current.items.map((item) => ({ ...item, inputPackages: item.actualPackages })); updateWarehouseWork('stocktake', { taskNo: value.task.taskNo }) }, applyCurrent(value) { this.current = value; this.current.items = this.current.items.map((item) => ({ ...item, inputPackages: item.actualPackages })); updateWarehouseWork('stocktake', { taskNo: value.task.taskNo }) },
validCount(item) { const value = Number(item.inputPackages); return Number.isInteger(value) && value >= 0 }, validCount(item) { const value = Number(item.inputPackages); return Number.isInteger(value) && value >= 0 },
async saveCount(item) { if (!this.validCount(item) || this.busy) return; const payload = { taskNo: this.current.task.taskNo, positionId: item.positionId, actualPackages: Number(item.inputPackages), expectedVersion: item.version }; const key = warehouseIdempotencyKey('stocktake', 'count', payload); this.busyItem = item.positionId; await this.run('count', async () => { await countStocktake(this.current.task.taskNo, { positionId: item.positionId, actualPackages: payload.actualPackages, expectedVersion: item.version, idempotencyKey: key.value }); await this.reloadCurrent(); this.successMessage = '实盘数量已保存' }, true, key) }, async saveCount(item) { if (!this.validCount(item) || this.busy) return; const payload = { taskNo: this.current.task.taskNo, positionId: item.positionId, actualPackages: Number(item.inputPackages), expectedVersion: item.version }; const key = warehouseIdempotencyKey('stocktake', 'count', payload); this.busyItem = item.positionId; await this.run('count', async () => { await countStocktake(this.current.task.taskNo, { positionId: item.positionId, actualPackages: payload.actualPackages, expectedVersion: item.version, idempotencyKey: key.value }); releaseWarehouseKey('stocktake', key.signature); await this.reloadCurrent(); this.successMessage = '实盘数量已保存' }, true, key) },
async submitTask() { if (!this.allCounted || !this.reason.trim() || this.busy) return; const payload = { taskNo: this.current.task.taskNo, expectedVersion: this.current.task.version, reason: this.reason.trim() }; const key = warehouseIdempotencyKey('stocktake', 'submit', payload); await this.run('submit', async () => { const value = await submitStocktake(this.current.task.taskNo, { expectedVersion: payload.expectedVersion, reason: payload.reason, idempotencyKey: key.value }); this.applyCurrent(value); this.reason = ''; this.successMessage = '盘点已提交'; await this.loadTasks() }, true, key) }, async submitTask() { if (!this.allCounted || !this.reason.trim() || this.busy) return; const payload = { taskNo: this.current.task.taskNo, expectedVersion: this.current.task.version, reason: this.reason.trim() }; const key = warehouseIdempotencyKey('stocktake', 'submit', payload); await this.run('submit', async () => { const value = await submitStocktake(this.current.task.taskNo, { expectedVersion: payload.expectedVersion, reason: payload.reason, idempotencyKey: key.value }); releaseWarehouseKey('stocktake', key.signature); this.applyCurrent(value); this.reason = ''; this.successMessage = '盘点已提交'; await this.loadTasks() }, true, key) },
async reloadCurrent() { if (this.current?.task?.taskNo) this.applyCurrent(await getStocktake(this.current.task.taskNo)) }, async reloadCurrent() { if (this.current?.task?.taskNo) this.applyCurrent(await getStocktake(this.current.task.taskNo)) },
async retry() { this.error = null; if (this.current?.task?.taskNo) await this.openTask(this.current.task.taskNo, false); else await this.loadTasks() }, async retry() { this.error = null; if (this.current?.task?.taskNo) await this.openTask(this.current.task.taskNo, false); else await this.loadTasks() },
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(); playSuccessSound() } catch (error) { this.setError(error, '盘点作业失败'); if (refreshOnConflict && isConflict(error)) { try { await this.reloadCurrent(); this.error.message += ',已刷新最新版本,请重新确认' } catch (_) {} } } finally { this.busy = false; this.busyAction = ''; this.busyItem = '' } }, 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(); playSuccessSound() } catch (error) { this.setError(error, '盘点作业失败'); if (refreshOnConflict && isConflict(error)) { if (key) releaseWarehouseKey('stocktake', key.signature); try { await this.reloadCurrent(); this.error.message += ',已刷新最新版本,请重新确认' } catch (_) {} } } finally { this.busy = false; this.busyAction = ''; this.busyItem = '' } },
setError(error, fallback) { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() }, setError(error, fallback) { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
scopeLabel(value) { return value === 'PALLET' ? '托盘盘点' : '库位盘点' }, scopeLabel(value) { return value === 'PALLET' ? '托盘盘点' : '库位盘点' },
statusLabel(value) { return ({ IN_PROGRESS: '进行中', SUBMITTED: '已提交' })[value] || value || '状态未知' } statusLabel(value) { return ({ IN_PROGRESS: '进行中', SUBMITTED: '已提交' })[value] || value || '状态未知' }
File diff suppressed because one or more lines are too long
+23 -7
View File
@@ -1,11 +1,11 @@
import { reactive } from 'vue' import { reactive } from 'vue'
const STORAGE_KEY = 'overseas-pda-fulfillment-draft-v2' const STORAGE_KEY = 'overseas-pda-fulfillment-draft-v1'
const blankState = () => ({ const blankState = () => ({
scopeKey: '', scopeKey: '',
dispatch: { currentNo: '', palletCode: '', dispatchItemId: '', sourceKey: '', cartonCode: '', packages: 1 }, dispatch: { currentNo: '', palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1, pendingKeys: {} },
outbound: { currentTaskNo: '', scanCode: '', packages: 1 } outbound: { currentTaskNo: '', scanCode: '', packages: 1, pendingKeys: {} }
}) })
export const fulfillment = reactive(blankState()) export const fulfillment = reactive(blankState())
@@ -14,7 +14,13 @@ function storageAvailable() {
return typeof uni !== 'undefined' && typeof uni.setStorageSync === 'function' && typeof uni.getStorageSync === 'function' return typeof uni !== 'undefined' && typeof uni.setStorageSync === 'function' && typeof uni.getStorageSync === 'function'
} }
function safeDomain(value, fallback) { return Object.fromEntries(Object.keys(fallback).map(key => [key, value?.[key] ?? fallback[key]])) } function safeDomain(value, fallback) {
return {
...fallback,
...(value || {}),
pendingKeys: { ...(value?.pendingKeys || {}) }
}
}
export function persistFulfillmentDraft() { export function persistFulfillmentDraft() {
if (!storageAvailable() || !fulfillment.scopeKey) return if (!storageAvailable() || !fulfillment.scopeKey) return
@@ -49,9 +55,19 @@ function randomKey(domain, action) {
return `pda-${domain}-${action}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` return `pda-${domain}-${action}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
} }
export function fulfillmentIdempotencyKey(domain, action, _payload = {}) { export function fulfillmentIdempotencyKey(domain, action, payload = {}) {
if (!fulfillment[domain]) throw new Error('未知海外 PDA 履约类型') if (!fulfillment[domain]) throw new Error('未知海外 PDA 履约类型')
return { value: randomKey(domain, action) } const signature = `${action}:${JSON.stringify(payload)}`
const pendingKeys = fulfillment[domain].pendingKeys
if (!pendingKeys[signature]) pendingKeys[signature] = randomKey(domain, action)
persistFulfillmentDraft()
return { signature, value: pendingKeys[signature] }
}
export function releaseFulfillmentKey(domain, signature) {
if (!fulfillment[domain]) return
delete fulfillment[domain].pendingKeys[signature]
persistFulfillmentDraft()
} }
export function resetFulfillmentDomain(domain) { export function resetFulfillmentDomain(domain) {
+22 -11
View File
@@ -3,13 +3,13 @@ import { reactive } from 'vue'
const STORAGE_KEY = 'overseas-pda-warehouse-work-v1' const STORAGE_KEY = 'overseas-pda-warehouse-work-v1'
const blankState = () => ({ const blankState = () => ({
scopeKey: '', scopeKey: '',
stocktake: { taskNo: '', scopeType: 'LOCATION', scopeCode: '', reason: '' }, stocktake: { taskNo: '', scopeType: 'LOCATION', scopeCode: '', reason: '', pendingKeys: {} },
move: { sourcePalletCode: '', targetLocationCode: '', reason: '' }, move: { sourcePalletCode: '', targetLocationCode: '', reason: '', pendingKeys: {} },
relabel: { currentNo: '', oldLabel: '', newLabel: '', action: 'RELABEL', reason: '', remark: '', photoFileIds: [], photoNames: [] }, relabel: { currentNo: '', oldLabel: '', newLabel: '', action: 'RELABEL', reason: '', remark: '', photoFileIds: [], photoNames: [], pendingKeys: {} },
returnReceipt: { entryMode: 'SKU', lookupCode: '', currentReturnNo: '', startedBy: 'SKU', trackingNo: '', palletCode: '', quantities: {}, boxCounts: {} }, 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: {} }, returnPacking: { mode: 'SKU', searchCode: '', selectedOrders: [], quantities: {}, targetBoxes: [], sourceCartonMarks: [], targetCode: '', activePlan: null, result: null, putawayResults: {}, pendingKeys: {} },
returnPackingPlan: {}, returnPackingPlan: { pendingKeys: {} },
warehouseTransfer: { currentNo: '' }, warehouseTransfer: { currentNo: '', pendingKeys: {} },
query: { inventoryCode: '', locationCode: '', palletCode: '' } query: { inventoryCode: '', locationCode: '', palletCode: '' }
}) })
@@ -19,7 +19,7 @@ function storageAvailable() {
return typeof uni !== 'undefined' && typeof uni.setStorageSync === 'function' && typeof uni.getStorageSync === 'function' return typeof uni !== 'undefined' && typeof uni.setStorageSync === 'function' && typeof uni.getStorageSync === 'function'
} }
function domain(value, fallback) { return Object.fromEntries(Object.keys(fallback).map(key => [key, value?.[key] ?? fallback[key]])) } function domain(value, fallback) { return { ...fallback, ...(value || {}), pendingKeys: { ...(value?.pendingKeys || {}) } } }
export function persistWarehouseWork() { export function persistWarehouseWork() {
if (!storageAvailable() || !warehouseWork.scopeKey) return if (!storageAvailable() || !warehouseWork.scopeKey) return
@@ -54,10 +54,21 @@ export function updateWarehouseWork(name, patch) {
persistWarehouseWork() persistWarehouseWork()
} }
export function warehouseIdempotencyKey(name, action, _payload = {}) { export function warehouseIdempotencyKey(name, action, payload = {}) {
if (!warehouseWork[name]) throw new Error('当前作业不支持写操作') const target = warehouseWork[name]
return { value: `pda-${name}-${action}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` } if (!target?.pendingKeys) throw new Error('当前作业不支持写操作')
const signature = `${action}:${JSON.stringify(payload)}`
if (!target.pendingKeys[signature]) target.pendingKeys[signature] = `pda-${name}-${action}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
persistWarehouseWork()
return { signature, value: target.pendingKeys[signature] }
} }
export function releaseWarehouseKey(name, signature) {
if (!warehouseWork[name]?.pendingKeys) return
delete warehouseWork[name].pendingKeys[signature]
persistWarehouseWork()
}
export function resetWarehouseDomain(name) { export function resetWarehouseDomain(name) {
const fresh = blankState() const fresh = blankState()
if (!fresh[name]) return if (!fresh[name]) return
+41 -2
View File
@@ -699,6 +699,10 @@
} }
.portal-title, .portal-title,
.module-hero-title,
.module-hero-desc,
.operation-name,
.operation-desc,
.summary-number, .summary-number,
.summary-label, .summary-label,
.profile-name, .profile-name,
@@ -776,15 +780,50 @@
.group-title { font-size: 16px; font-weight: 700; } .group-title { font-size: 16px; font-weight: 700; }
.module-hero-icon, .operation-icon {
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
}
.operation-info { display: flex; min-width: 0; flex: 1; flex-direction: column; margin-left: 11px; }
.inline-back { height: 48px; padding: 0 4px; color: #42617f; font-size: 13px; line-height: 48px; background: transparent; } .inline-back { height: 48px; padding: 0 4px; color: #42617f; font-size: 13px; line-height: 48px; background: transparent; }
.module-hero { position: relative; display: flex; align-items: center; margin-top: 5px; padding: 17px; overflow: hidden; border: 1px solid rgba(23, 105, 232, 0.09); border-radius: 16px; background: #e7f0ff; box-shadow: 0 5px 16px rgba(32, 67, 111, 0.07); }
.module-hero-inbound { background: #e1f7f0; }
.module-hero-inventory { background: #fff3da; }
.module-hero-relabel { background: #f1eafe; }
.module-hero-query { background: #f0e9ff; }
.module-hero-icon { width: 52px; height: 52px; flex-shrink: 0; margin-right: 13px; border-radius: 14px; color: #ffffff; font-size: 18px; background: $primary; }
.module-hero-inbound .module-hero-icon { background: #0d9488; }
.module-hero-inventory .module-hero-icon { background: #d68c0c; }
.module-hero-relabel .module-hero-icon { background: #7c3aed; }
.module-hero-query .module-hero-icon { background: #6d55c7; }
.module-hero-copy { min-width: 0; flex: 1; }
.module-hero-title { font-size: 18px; font-weight: 700; }
.module-hero-desc { margin-top: 4px; color: #617187; font-size: 11px; line-height: 17px; }
.module-hero-count { flex-shrink: 0; margin-left: 8px; padding: 4px 7px; border-radius: 99px; color: #49627d; font-size: 9px; line-height: 15px; background: rgba(255, 255, 255, 0.72); }
.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 10px; margin: 22px 2px 11px; } .section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 10px; margin: 22px 2px 11px; }
.section-heading .group-title { margin: 0; } .section-heading .group-title { margin: 0; }
.section-heading-hint { color: #8190a3; font-size: 10px; line-height: 16px; } .section-heading-hint { color: #8190a3; font-size: 10px; line-height: 16px; }
.group-title { display: block; margin: 22px 2px 11px; } .group-title { display: block; margin: 22px 2px 11px; }
.history-item { display: flex; align-items: center; box-sizing: border-box; width: 100%; min-height: 78px; padding: 13px 12px; border-bottom: 1px solid #edf0f3; text-align: left; background: #ffffff; } .operation-list { overflow: hidden; border-radius: 12px; background: #ffffff; box-shadow: 0 4px 15px rgba(32, 67, 111, 0.06); }
.history-item:last-child { border-bottom: 0; } .operation-item, .history-item { display: flex; align-items: center; box-sizing: border-box; width: 100%; min-height: 78px; padding: 13px 12px; border-bottom: 1px solid #edf0f3; text-align: left; background: #ffffff; }
.operation-item:active { background: #f2f7ff; }
.operation-item-disabled, .operation-item-disabled[disabled] { color: #8d98a6; opacity: 1; background: #f2f4f6; }
.operation-item-disabled .operation-icon { color: #8b97a5; background: #e3e7eb; }
.operation-item-disabled .operation-desc { color: #929daa; }
.operation-item:last-child, .history-item:last-child { border-bottom: 0; }
.operation-icon { width: 39px; height: 39px; flex-shrink: 0; border-radius: 10px; color: #366cae; font-size: 12px; background: #edf4fd; }
.operation-name { font-size: 13px; font-weight: 700; }
.operation-desc { margin-top: 4px; color: $text-secondary; font-size: 11px; line-height: 17px; }
.operation-ready, .operation-locked { flex-shrink: 0; padding: 3px 6px; border-radius: 99px; font-size: 9px; line-height: 15px; }
.operation-ready { color: #0b6f65; background: #dff7f0; }
.operation-locked { color: #7c8795; background: #e5e9ee; }
.operation-count { min-width: 22px; height: 22px; border-radius: 11px; color: #ffffff; font-size: 11px; line-height: 22px; text-align: center; background: $danger; } .operation-count { min-width: 22px; height: 22px; border-radius: 11px; color: #ffffff; font-size: 11px; line-height: 22px; text-align: center; background: $danger; }
.operation-prototype { flex-shrink: 0; padding: 5px 7px; border-radius: 5px; color: #737f8d; font-size: 11px; background: #e1e5e9; } .operation-prototype { flex-shrink: 0; padding: 5px 7px; border-radius: 5px; color: #737f8d; font-size: 11px; background: #e1e5e9; }
.operation-arrow { margin-left: 8px; color: #9aa5b1; font-size: 21px; }
.module-empty-note { margin-top: 14px; padding: 12px; border-radius: 9px; color: #758194; font-size: 11px; line-height: 18px; text-align: center; background: #e9eef5; }
.operation-page { padding-top: 12px; } .operation-page { padding-top: 12px; }
.operation-page-title-row, .operation-scan-row, .task-toolbar, .prototype-task-head, .prototype-task-meta, .prototype-task-foot { display: flex; align-items: center; } .operation-page-title-row, .operation-scan-row, .task-toolbar, .prototype-task-head, .prototype-task-meta, .prototype-task-foot { display: flex; align-items: center; }
+2 -1
View File
@@ -8,7 +8,7 @@ const interpolations = (source) => [...template(source).matchAll(/\{\{([\s\S]*?)
test('all registered user-facing pages avoid rendering internal identifiers', async () => { test('all registered user-facing pages avoid rendering internal identifiers', async () => {
const paths = [ const paths = [
'../src/pages/login/login.vue', '../src/pages/home/home.vue', '../src/pages/login/login.vue', '../src/pages/home/home.vue', '../src/pages/module/module.vue',
'../src/pages/operation/operation.vue', '../src/pages/pallet/pallet.vue', '../src/pages/profile/profile.vue', '../src/pages/operation/operation.vue', '../src/pages/pallet/pallet.vue', '../src/pages/profile/profile.vue',
'../src/pages/dispatch/dispatch.vue', '../src/pages/outbound/outbound.vue', '../src/pages/dispatch/dispatch.vue', '../src/pages/outbound/outbound.vue',
'../src/pages/stocktake/stocktake.vue', '../src/pages/move/move.vue', '../src/pages/relabel/relabel.vue', '../src/pages/query/query.vue', '../src/pages/stocktake/stocktake.vue', '../src/pages/move/move.vue', '../src/pages/relabel/relabel.vue', '../src/pages/query/query.vue',
@@ -86,6 +86,7 @@ test('missing user-visible relationships use explicit empty labels instead of ID
read('../src/components/ReceivingSessionSummary.vue'), read('../src/components/ReceivingSessionSummary.vue'),
read('../src/pages/pallet/pallet.vue') read('../src/pages/pallet/pallet.vue')
]) ])
assert.match(summary, /overseasWarehouseCode[\s\S]*overseasWarehouseName/)
assert.match(summary, /unique\.length \? unique : \['—'\]/) assert.match(summary, /unique\.length \? unique : \['—'\]/)
assert.match(pallet, /inboundCode \|\| '无入仓码'/) assert.match(pallet, /inboundCode \|\| '无入仓码'/)
assert.match(pallet, /locationCode \|\| '未绑定库位'/) assert.match(pallet, /locationCode \|\| '未绑定库位'/)
-86
View File
@@ -1,86 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { confirmCargoDiscard } from '../src/api/fulfillment.js'
import { session } from '../src/stores/session.js'
const rentPath = '/api/pda/v1/last-leg/storage-rent/preparations'
const stockPath = '/api/pda/v1/last-leg/cargo-discards/CD%2F1/pallets/P%2F1/confirm'
const body = { lines: [{ itemId: '9007199254740993', actualPackages: 2 }], photoFileIds: ['101'], scannedCartonMarks: ['BOX-1', 'BOX-2'], confirmAllCartons: false, idempotencyKey: 'discard-key' }
const ready = { data: { preparationId: '9007199254740995', ready: true, status: 'READY' } }
const confirmed = { data: { discardNo: 'CD/1', palletNo: 'P/1', status: 'COMPLETED' } }
const pending = { data: { preparationId: '9007199254740995', ready: false, status: 'FREEZE_PENDING' } }
const submit = () => confirmCargoDiscard('CD/1', 'P/1', body.lines, body.photoFileIds, body.scannedCartonMarks, false, { idempotencyKey: body.idempotencyKey })
function transport(responses) {
const calls = []; session.apiBase = '/api'; session.token = 'overseas-token'
globalThis.uni = { request(options) {
calls.push({ method: options.method, url: options.url, data: options.data })
const response = responses.shift(); assert.ok(response, 'unexpected additional request')
if (response.network) return options.fail({ errMsg: 'network down' })
options.success({ statusCode: 200, data: { code: '200', ...response }, header: {} })
} }
return calls
}
test('出库先预校验仓租,再用相同明细、照片、箱号和幂等键确认一次', async () => {
const calls = transport([ready, confirmed]); await submit()
assert.deepEqual(calls, [
{ method: 'POST', url: rentPath, data: { mode: 'DISCARD', businessNo: 'CD/1', palletNo: 'P/1', ...body } },
{ method: 'POST', url: stockPath, data: body }
])
})
test('免费或不计仓租的服务端资格可以直接继续出库', async () => {
const calls = transport([{ data: { status: 'NOT_REQUIRED', ready: true, amount: 0 } }, confirmed])
await submit(); assert.equal(calls.length, 2); assert.equal(calls[1].url, stockPath)
})
test('等待财务回执期间只读状态,就绪后只提交一次出库', async t => {
t.mock.method(globalThis, 'setTimeout', callback => { callback(); return 0 })
const calls = transport([pending, ready, confirmed]); await submit()
assert.deepEqual(calls.map(call => call.method), ['POST', 'GET', 'POST'])
assert.equal(calls[1].url, rentPath + '/9007199254740995')
})
test('余额授信不足时显示财务原因,释放未使用预占且不发出库请求', async () => {
const calls = transport([{ data: { ...pending.data, status: 'FAILED', message: '可用余额和授信额度不足' } }, { data: {} }])
await assert.rejects(submit(), error => error.code === 'A0409' && /仓租校验未通过.*余额和授信额度不足/.test(error.message))
assert.deepEqual(calls.map(call => call.method), ['POST', 'DELETE'])
})
test('缺失、失败或断网的预校验不降级绕过,保留诊断信息', async () => {
for (const response of [{ code: 'A0404', message: 'missing', requestId: 'req', traceId: 'trace' }, { code: 'A0409', message: 'conflict' }, { network: true }]) {
const calls = transport([response])
await assert.rejects(submit(), error => response.network ? error.network : error.code === response.code && error.requestId === response.requestId && error.traceId === response.traceId)
assert.equal(calls.length, 1)
}
})
test('回执超时释放预占,阻止出库并提示稍后重新提交', async t => {
t.mock.method(globalThis, 'setTimeout', callback => { callback(); return 0 })
const calls = transport([pending, ...Array(20).fill(pending), { data: {} }])
await assert.rejects(submit(), /仓租资金预校验尚未通过/)
assert.equal(calls.filter(call => call.method === 'GET').length, 20)
assert.equal(calls.at(-1).method, 'DELETE')
assert.ok(calls.every(call => call.url.includes('/storage-rent/')))
})
test('状态查询失败仍释放原预占;释放失败不覆盖原始诊断', async t => {
t.mock.method(globalThis, 'setTimeout', callback => { callback(); return 0 })
const calls = transport([pending, { code: 'S0503', message: 'read failed', requestId: 'req', traceId: 'trace' }, { code: 'S0503', message: 'release failed' }])
await assert.rejects(submit(), error => error.message === 'read failed' && error.requestId === 'req' && error.traceId === 'trace')
assert.deepEqual(calls.map(call => call.method), ['POST', 'GET', 'DELETE'])
})
test('就绪标记必须为布尔真,异常响应不能放行出库', async () => {
const calls = transport([{ data: { ...ready.data, ready: 'true' } }, { data: {} }])
await assert.rejects(submit(), /预校验/); assert.equal(calls.at(-1).method, 'DELETE')
})
test('出库请求已发送后,业务错误或结果不明不重发、不取消原预占', async () => {
for (const failure of [{ code: 'A0409', message: 'stock changed' }, { network: true }]) {
const calls = transport([ready, failure])
await assert.rejects(submit(), error => failure.network ? error.network : error.code === failure.code)
assert.equal(calls.length, 2); assert.ok(calls.every(call => call.method === 'POST'))
}
})
-190
View File
@@ -1,190 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { loadingPhotoLabels, loadingPhotoLimit, loadingPhotoSlots, normalizeLoadingDocuments, getLoadingDocuments, bindLoadingPhoto, uploadLoadingPhoto } from '../src/api/dispatch-loading-documents.js'
import { session } from '../src/stores/session.js'
const source = (await readFile(new URL('../src/components/DispatchLoadingDocuments.vue', import.meta.url), 'utf8')).match(/<script>([\s\S]*?)<\/script>/)[1]
const data = extra => ({ dispatchNo: 'PC/1', recordVersion: '0', dispatchRecordVersion: '3', reviewRound: '1', stage: 'AFTER_LOADING', editable: true, complete: false, photos: [], ...extra })
const photo = { slot: 'LOADING_SCENE', fileId: '1001', fileName: 'loading.png', contentType: 'image/png' }
function component(overrides = {}) {
const values = new Map(), events = []
globalThis.uni = { setStorageSync: (key, value) => values.set(key, value), removeStorageSync: key => values.delete(key), getStorageSync: key => values.get(key), chooseImage: async () => ({ tempFilePaths: ['local-image'], tempFiles: [{ size: 123 }] }), showToast() {} }
const dependencies = { session: { currentUser: { id: 'user', warehouseId: 'warehouse' } }, loadingPhotoSlots, loadingPhotoLabels, loadingPhotoLimit, toDispatchError: cause => cause,
getLoadingDocuments: async () => data(), uploadLoadingPhoto: async () => photo, recoverLoadingPhoto: async () => photo,
bindLoadingPhoto: async () => data({ recordVersion: '1', photos: [photo] }), readLoadingPhoto: async () => 'downloaded-image', ...overrides }
const definition = new Function(...Object.keys(dependencies), source.replace(/^import .*$/gm, '').replace('export default', 'return'))(...Object.values(dependencies))
const state = { ...definition.data.call({ dispatchNo: 'PC/1' }), dispatchNo: 'PC/1', $emit: (...args) => events.push(args) }
for (const [key, method] of Object.entries(definition.methods)) state[key] = method.bind(state)
for (const [key, getter] of Object.entries(definition.computed)) Object.defineProperty(state, key, { get: () => getter.call(state) })
return { state, events, values, definition }
}
test('five existing slots carry their business names into upload, replace and preview controls', async () => {
const { state } = component()
assert.deepEqual(state.slots, ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT'])
assert.deepEqual(state.slotLabels, { LOADING_SCENE: '司机驾驶证', LOADING_LIST: '车牌照片', PRE_DEPARTURE_CHECK: '装车完成', DRIVER_LICENSE: '封车照片', RECEIPT: 'BOL司机签字' })
const template = (await readFile(new URL('../src/components/DispatchLoadingDocuments.vue', import.meta.url), 'utf8')).split('<script>')[0]
assert.match(template, /class="photo-label">\{\{ slotLabels\[slot\] \}\}/)
assert.match(template, /@tap="choose\(slot, photo.fileId\)"/)
assert.match(template, /:aria-label="`预览\$\{slotLabels\[slot\]\}`"/)
assert.match(template, /:aria-label="`查看\$\{slotLabels\[slot\]\}大图`"/)
assert.doesNotMatch(template, /装车照片 \{\{ index \+ 1 \}\}/)
})
test('refresh reads all photos saved by PC and refreshes the task stage without another write', async () => {
let latest = data({ photos: [photo] }), writes = 0, fail = false
const { state, events } = component({ getLoadingDocuments: async () => { if (fail) throw new Error('刷新失败'); return latest },
uploadLoadingPhoto: async () => { writes++; return photo }, bindLoadingPhoto: async () => { writes++; return latest } })
await state.load()
latest = data({ recordVersion: '26', stage: 'DELIVERING', complete: true, photos: loadingPhotoSlots.flatMap((slot, category) =>
Array.from({ length: 5 }, (_, index) => ({ ...photo, slot, fileId: `${category + 2}${index}` }))) })
await state.refresh()
assert.deepEqual(state.snapshot.photos, latest.photos)
assert.equal(state.snapshot.stage, 'DELIVERING'); assert.equal(state.completedCategories, 5)
assert.ok(!state.snapshot.photos.some(item => item.fileId === photo.fileId))
assert.equal(events.filter(event => event[0] === 'saved').length, 1)
fail = true; await state.refresh()
assert.deepEqual(state.snapshot.photos, latest.photos)
assert.equal(state.needsRefresh, true); assert.equal(state.busy, false)
assert.equal(events.filter(event => event[0] === 'saved').length, 1)
assert.equal(writes, 0)
})
test('PDA reads and binds through its own namespace without customer or warehouse input', async () => {
const calls = []; session.apiBase = '/gateway-api'; session.token = 'test-token'
globalThis.uni = { request(options) { calls.push(options); options.success({ statusCode: 200, data: { code: '200', data: data() } }) } }
await getLoadingDocuments('PC/1')
const body = { recordVersion: '9007199254740993', idempotencyKey: 'stable-key', uploadKey: 'original-upload-key', fileId: '1001' }
await bindLoadingPhoto('PC/1', 'LOADING_SCENE', body)
assert.match(calls[0].url, /\/pda\/v1\/last-leg\/dispatches\/PC%2F1\/loading-documents$/)
assert.equal(calls[1].method, 'PUT'); assert.deepEqual(calls[1].data, body)
})
test('imported PDF license is opened as a document on demand, never as a photo', async () => {
const license = { ...photo, fileName: 'license.pdf', contentType: 'application/pdf' }
let downloads = 0, opened
const { state } = component({ getLoadingDocuments: async () => data({ photos: [license] }), readLoadingPhoto: async () => { downloads++; return 'private-license.pdf' } })
uni.openDocument = options => { opened = options; options.success() }
await state.load()
assert.equal(downloads, 0); assert.equal(state.completedCategories, 1)
await state.viewPdf(license)
assert.equal(downloads, 1); assert.equal(opened.filePath, 'private-license.pdf'); assert.equal(opened.fileType, 'pdf')
assert.equal(state.busy, false)
})
test('PDA upload preserves the original request key through the authenticated upload helper', async () => {
const calls = []; session.apiBase = '/gateway-api'; session.token = 'test-token'
globalThis.uni = { uploadFile(options) { calls.push(options); options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: photo }) }) } }
await uploadLoadingPhoto('PC/1', 'LOADING_SCENE', 'local-image', 'same-key', '1')
assert.match(calls[0].url, /loading-documents\/LOADING_SCENE\/uploads\?idempotencyKey=same-key&reviewRound=1$/)
assert.equal(calls[0].filePath, 'local-image')
})
test('re-review during photo upload stops old files instead of adopting the latest round', async () => {
let latest = data(), uploads = 0, binds = 0, originalRound
const { state, events } = component({ getLoadingDocuments: async () => latest,
uploadLoadingPhoto: async (_no, _slot, _path, _key, round) => {
uploads++; originalRound = round; latest = data({ recordVersion: '8', reviewRound: '2' }); return photo
}, bindLoadingPhoto: async () => { binds++; return latest } })
uni.chooseImage = async () => ({ tempFilePaths: ['old-photo', 'not-uploaded'] })
await state.load(); await state.choose(photo.slot)
assert.equal(originalRound, '1'); assert.equal(uploads, 1); assert.equal(binds, 0)
assert.match(state.error.message, /审核轮次已变化/); assert.equal(state.busy, false)
assert.equal(events.filter(event => event[0] === 'saved').length, 1)
})
test('wrong dispatch, duplicate slots and invalid versions never become saved facts', () => {
assert.throws(() => normalizeLoadingDocuments(data(), 'PC/2'), /不一致/)
assert.throws(() => normalizeLoadingDocuments(data({ photos: [photo, photo] }), 'PC/1'), /重复/)
assert.throws(() => normalizeLoadingDocuments(data({ recordVersion: null }), 'PC/1'), /不一致/)
})
for (const failure of [{ network: true, message: 'timeout' }, { code: 'A0409', message: 'conflict' }, { code: 'A0422', message: 'invalid' }]) test('upload failure leaves saved photos and unlocks current selection: ' + failure.message, async () => {
const uploads = []; let reads = 0, choice = 0
const { state, values, events } = component({ getLoadingDocuments: async () => { reads++; return data({ photos: [photo] }) }, uploadLoadingPhoto: async (...args) => { uploads.push(args); throw failure } })
uni.chooseImage = async () => ({ tempFilePaths: ['image-' + ++choice] })
await state.load(); await state.choose(photo.slot)
assert.equal(state.busy, false); assert.equal(values.size, 0); assert.equal(reads, 1)
assert.equal(state.snapshot.photos[0].fileId, photo.fileId)
await state.choose(photo.slot)
assert.equal(uploads.length, 2); assert.equal(uploads[1][2], 'image-2'); assert.notEqual(uploads[0][3], uploads[1][3])
assert.deepEqual(events.at(-1), ['busy-change', false])
})
test('binding failure releases immediately and the next selection uses a new file, key and current version', async () => {
const binds = []; let uploads = 0, version = '0', reads = 0
const { state, values, events } = component({ getLoadingDocuments: async () => { reads++; return data({ recordVersion: version }) }, uploadLoadingPhoto: async () => ({ ...photo, fileId: String(++uploads) }), bindLoadingPhoto: async (_no, _slot, body) => { binds.push(body); if (binds.length === 1) throw { network: true }; return data({ photos: [photo] }) } })
await state.load(); await state.choose(photo.slot)
assert.equal(state.busy, false); assert.equal(reads, 3); assert.equal(values.size, 0)
version = '7'; await state.choose(photo.slot)
assert.equal(uploads, 2); assert.equal(binds[1].recordVersion, '7'); assert.notEqual(binds[1].fileId, binds[0].fileId)
assert.notEqual(binds[1].idempotencyKey, binds[0].idempotencyKey); assert.equal(events.filter(event => event[0] === 'saved').length, 1)
})
test('a lost binding response immediately reloads server success without another write', async () => {
let binds = 0, latest = data()
const { state } = component({ getLoadingDocuments: async () => latest, bindLoadingPhoto: async () => { binds++; latest = data({ editable: false, complete: true, stage: 'DELIVERING', photos: [photo] }); throw { network: true } } })
await state.load(); await state.choose(photo.slot); assert.equal(state.busy, false)
assert.equal(state.snapshot.stage, 'DELIVERING'); assert.equal(state.editable, false)
await state.load(); assert.equal(state.snapshot.stage, 'DELIVERING'); assert.equal(binds, 1); assert.equal(state.editable, false)
})
test('failed reconciliation blocks further uploads until an explicit refresh succeeds', async () => {
let reads = 0, uploads = 0
const { state } = component({ getLoadingDocuments: async () => { if (++reads === 3) throw new Error('读取失败'); return data() },
uploadLoadingPhoto: async () => { uploads++; return photo }, bindLoadingPhoto: async () => { throw new Error('保存超时') } })
await state.load(); await state.choose(photo.slot)
assert.equal(state.busy, false); assert.equal(state.needsRefresh, true); assert.equal(state.editable, false)
assert.match(state.error.message, /保存超时/)
await state.choose(photo.slot); assert.equal(uploads, 1)
await state.load(); assert.equal(state.needsRefresh, false); assert.equal(state.editable, true)
})
test('in-flight upload blocks duplicate clicks and unmount releases the parent busy flag', async () => {
let resolveUpload, uploads = 0
const { state, events, definition } = component({ uploadLoadingPhoto: () => { uploads++; return new Promise(resolve => { resolveUpload = resolve }) } })
await state.load(); const first = state.choose(photo.slot)
while (!resolveUpload) await new Promise(resolve => setImmediate(resolve))
await state.choose(photo.slot); assert.equal(uploads, 1)
definition.beforeUnmount.call(state); assert.deepEqual(events.at(-1), ['busy-change', false])
resolveUpload(photo); await first; assert.equal(events.some(event => event[0] === 'saved'), false)
})
test('normalization accepts five distinct photos per category but rejects a sixth', () => {
const photos = Array.from({ length: 5 }, (_, index) => ({ ...photo, fileId: String(index + 1) }))
assert.equal(normalizeLoadingDocuments(data({ photos }), 'PC/1').photos.length, 5)
assert.throws(() => normalizeLoadingDocuments(data({ photos: [...photos, { ...photo, fileId: '6' }] }), 'PC/1'), /最多5张/)
})
test('multi-select appends every image using fresh versions and counts categories rather than files', async () => {
let latest = data(), count = 0, selectedCount
const binds = []
const { state, events } = component({ getLoadingDocuments: async () => latest,
uploadLoadingPhoto: async () => ({ ...photo, fileId: String(++count) }),
bindLoadingPhoto: async (_no, _slot, body) => {
binds.push(body)
latest = data({ recordVersion: String(binds.length), photos: [...latest.photos, { ...photo, fileId: body.fileId }] }); return latest
} })
uni.chooseImage = async options => { selectedCount = options.count; return { tempFilePaths: ['a', 'b', 'c', 'd', 'e'] } }
await state.load(); await state.choose(photo.slot)
assert.equal(selectedCount, 5); assert.equal(state.snapshot.photos.length, 5); assert.equal(state.completedCategories, 1)
assert.deepEqual(binds.map(body => body.recordVersion), ['0', '1', '2', '3', '4'])
assert.equal(new Set(binds.map(body => body.idempotencyKey)).size, 5)
await state.choose(photo.slot); assert.equal(count, 5)
assert.equal(events.filter(event => event[0] === 'saved').length, 1)
})
test('replacement sends the selected old file and retains the other four photos', async () => {
let latest = data({ photos: Array.from({ length: 5 }, (_, index) => ({ ...photo, fileId: String(index + 1) })) })
let selectedCount, request
const { state } = component({ getLoadingDocuments: async () => latest, uploadLoadingPhoto: async () => ({ ...photo, fileId: '6' }),
bindLoadingPhoto: async (_no, _slot, body) => { request = body; latest = data({ recordVersion: '1', photos: latest.photos.map(item => item.fileId === body.replaceFileId ? { ...photo, fileId: body.fileId } : item) }); return latest } })
uni.chooseImage = async options => { selectedCount = options.count; return { tempFilePaths: ['replacement'] } }
await state.load(); await state.choose(photo.slot, '3')
assert.equal(selectedCount, 1); assert.equal(request.replaceFileId, '3')
assert.deepEqual(state.snapshot.photos.map(item => item.fileId), ['1', '2', '6', '4', '5'])
})
test('a partial batch failure preserves successful photos and notifies the task once', async () => {
let latest = data(), uploads = 0
const { state, events } = component({ getLoadingDocuments: async () => latest,
uploadLoadingPhoto: async () => { if (++uploads === 2) throw new Error('网络中断'); return photo },
bindLoadingPhoto: async () => { latest = data({ recordVersion: '1', photos: [photo] }); return latest } })
uni.chooseImage = async () => ({ tempFilePaths: ['first', 'failed', 'unattempted'] })
await state.load(); await state.choose(photo.slot)
assert.equal(uploads, 2); assert.equal(state.busy, false); assert.match(state.error.message, /网络中断/)
assert.deepEqual(state.snapshot.photos, [photo]); assert.equal(events.filter(event => event[0] === 'saved').length, 1)
})
-30
View File
@@ -1,30 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { parse, compileTemplate } from 'vue/compiler-sfc'
const page = await readFile(new URL('../src/pages/dispatch/dispatch.vue', import.meta.url), 'utf8')
const photos = await readFile(new URL('../src/components/DispatchLoadingDocuments.vue', import.meta.url), 'utf8')
for (const [filename, source] of [['dispatch.vue', page], ['DispatchLoadingDocuments.vue', photos]]) {
test(`${filename} compiles its actual presentation template`, () => {
const { descriptor, errors } = parse(source, { filename })
assert.deepEqual(errors, [])
const compiled = compileTemplate({ source: descriptor.template.content, filename, id: 'dispatch-presentation' })
assert.deepEqual(compiled.errors, [])
})
}
test('picking separates source facts, current quantity and completion while retaining scanner and action guards', () => {
const { descriptor } = parse(page)
const template = descriptor.template.content
for (const title of ['扫码拣货', '任务摘要', '拣货进度', '托盘与货物来源', '当前拣货', '确认装车完成']) assert.ok(template.includes(title), title)
assert.match(template, /class="picking-fact-label">客户<\/text>/)
assert.match(template, /class="picking-fact-label">目的地<\/text>/)
assert.match(template, /aria-label="本次拣货箱数"[^>]+:disabled="busy"[^>]+@input="updatePackages"/)
assert.match(template, /name="dispatch"[^>]+:active="activeTab === 'picking'"[^>]+@submit="handleScan"/)
assert.match(template, /:disabled="!canPick \|\| busy" @tap="submitPick"/)
assert.match(template, /:disabled="!canComplete \|\| busy" @tap="prepareLoading"/)
assert.match(template, /@tap="selectPallet\(item\)"/)
assert.match(page, /\.picking-fact > text:last-child \{[^}]*overflow-wrap: anywhere/)
})
-335
View File
@@ -1,335 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fulfillment, restoreFulfillmentDraft, updateFulfillmentDraft, resetFulfillmentSession } from '../src/stores/fulfillment.js'
import { requireDispatchVersion, dispatchVersionAtLeast, normalizeDispatchTask } from '../src/api/fulfillment.js'
const source = (await readFile(new URL('../src/pages/dispatch/dispatch.vue', import.meta.url), 'utf8')).match(/<script>([\s\S]*?)<\/script>/)[1]
const item = (overrides = {}) => ({ dispatchItemId: 'item-1', sourceKey: 'source-1', sourceBusinessType: 'RELABEL_ORDER', sourceBusinessNo: 'RL-1', customerId: 'customer-1', customerName: '客户甲', palletNo: 'PALLET-1', scheduledPackages: 2, pickedPackages: 0, positions: [{ positionId: 'position-1', availablePackages: 2, allocatedPackages: 0, pickedPackages: 0 }], returnCartons: [], ...overrides })
test('native string quantities normalize without changing Long identity or version', () => {
const id = '9007199254740993'
const value = normalizeDispatchTask(JSON.parse(JSON.stringify({ dispatchNo: 'PC/1', recordVersion: id, items: [item({ dispatchItemId: id, scheduledPackages: '12', pickedPackages: '0', positions: [{ positionId: id, balanceId: id, availablePackages: '12', allocatedPackages: '0', pickedPackages: '0' }], returnCartons: [{ returnCartonId: id, productQuantity: '24' }] })] })))
assert.equal(value.recordVersion, id); assert.equal(value.items[0].dispatchItemId, id)
assert.equal(value.items[0].scheduledPackages, 12); assert.equal(value.items[0].pickedPackages, 0)
assert.deepEqual(value.items[0].positions[0], { positionId: id, balanceId: id, availablePackages: 12, allocatedPackages: 0, pickedPackages: 0 })
assert.equal(value.items[0].returnCartons[0].returnCartonId, id); assert.equal(value.items[0].returnCartons[0].productQuantity, 24)
assert.equal(normalizeDispatchTask({ dispatchNo: 'PC/1', items: [item({ scheduledPackages: String(Number.MAX_SAFE_INTEGER) })] }).items[0].scheduledPackages, Number.MAX_SAFE_INTEGER)
})
test('invalid, missing and unsafe native quantities fail at every item, position and carton boundary', () => {
for (const invalid of [undefined, null, true, '', ' ', '01', '+1', '-1', '1.0', '1e2', -1, 0.5, NaN, Infinity, '9007199254740992', 9007199254740992]) {
for (const field of ['scheduledPackages', 'pickedPackages']) assert.throws(() => normalizeDispatchTask({ dispatchNo: 'PC/1', items: [item({ [field]: invalid })] }), /排车数量/)
for (const field of ['availablePackages', 'allocatedPackages', 'pickedPackages']) assert.throws(() => normalizeDispatchTask({ dispatchNo: 'PC/1', items: [item({ positions: [{ ...item().positions[0], [field]: invalid }] })] }), /排车数量/)
assert.throws(() => normalizeDispatchTask({ dispatchNo: 'PC/1', items: [item({ returnCartons: [{ productQuantity: invalid }] })] }), /排车数量/)
}
})
const task = (overrides = {}) => normalizeDispatchTask({ taskNo: 'TASK-1', dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'LOADING', pickingReconciled: false, actions: { PICK: { allowed: true }, LOADING_COMPLETE: { allowed: true } }, items: [item()], ...overrides })
function storage() { const values = new Map(); globalThis.uni = { setStorageSync: (key, value) => values.set(key, structuredClone(value)), getStorageSync: key => structuredClone(values.get(key)), removeStorageSync: key => values.delete(key), showModal: async () => ({ confirm: true }) }; resetFulfillmentSession(); restoreFulfillmentDraft('operator:warehouse'); return values }
let lastCommand
function page(dependencies = {}) {
const deps = { PortalHeader: {}, ScanTargetInput: {}, DispatchHandoverStatus: {}, DispatchLoadingDocuments: {}, session: { currentUser: {} }, fulfillment, requireDispatchVersion, dispatchVersionAtLeast, updateFulfillmentDraft, playSuccessSound() {}, playErrorSound() {}, toDispatchError: error => ({ message: error.message }), ...dependencies }
for (const [api, action] of [['pickDispatchItem', 'PICK'], ['completeDispatchLoading', 'LOADING_COMPLETE'], ['reviewDispatchLoading', 'LOADING_REVIEW']]) {
const original = deps[api]; if (!original) continue
deps[api] = (...args) => { lastCommand = { dispatchNo: args[0], action, body: args.at(-1) }; return original(...args) }
}
// Only bind dependencies actually imported by the page; injecting every mock
// would hide missing production imports behind successful workflow tests.
const importedNames = new Set([...source.matchAll(/^\s*import\s+(?:\{([^}]+)\}|(\w+))\s+from\s+/gm)]
.flatMap(match => (match[1] || match[2]).split(',').map(name => name.trim().split(/\s+as\s+/).at(-1))))
const bindings = Object.entries(deps).filter(([name]) => importedNames.has(name))
const definition = new Function(...bindings.map(([name]) => name), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...bindings.map(([, value]) => value))
const state = { ...definition.data(), current: task() }
for (const [name, method] of Object.entries(definition.methods)) state[name] = method.bind(state)
for (const [name, getter] of Object.entries(definition.computed)) Object.defineProperty(state, name, { get: () => getter.call(state) })
return state
}
const proof = (command, version = 1) => ({ dispatchNo: command.dispatchNo, action: command.action, idempotencyKey: command.body.idempotencyKey, recordVersion: version, stage: command.action === 'LOADING_COMPLETE' ? 'AFTER_LOADING' : 'LOADING', result: { confirmed: true } })
test('pallet scan notice reports the current pickable quantity and closes after three seconds without submitting', t => {
t.mock.timers.enable({ apis: ['setTimeout'] })
storage(); let writes = 0, sounds = 0
const state = page({ pickDispatchItem: async () => { writes++ }, playSuccessSound: () => { sounds++ } })
state.current = task({ items: [item({ scheduledPackages: 12, pickedPackages: 4, positions: [{ positionId: 'position-1', availablePackages: 6, allocatedPackages: 0, pickedPackages: 0 }] })] })
state.handleScan('PALLET-1')
assert.deepEqual(state.scanNotice, { palletNo: 'PALLET-1', packages: 6 })
assert.equal(sounds, 1)
t.mock.timers.tick(2999); assert.ok(state.scanNotice)
t.mock.timers.tick(1); assert.equal(state.scanNotice, null)
assert.equal(writes, 0); assert.equal(state.packages, 6)
})
test('consecutive pallet scans replace the notice and restart its full display time', t => {
t.mock.timers.enable({ apis: ['setTimeout'] })
storage(); const state = page()
state.current = task({ items: [item(), item({ dispatchItemId: 'item-2', sourceKey: 'source-2', palletNo: 'PALLET-2', scheduledPackages: 1 })] })
state.handleScan('PALLET-1'); t.mock.timers.tick(2000)
state.handleScan('PALLET-2')
assert.deepEqual(state.scanNotice, { palletNo: 'PALLET-2', packages: 1 })
t.mock.timers.tick(1000); assert.ok(state.scanNotice)
t.mock.timers.tick(2000); assert.equal(state.scanNotice, null)
})
test('scan errors, carton scans and switching tabs cannot retain a stale pallet notice', t => {
t.mock.timers.enable({ apis: ['setTimeout'] })
storage(); const state = page()
state.handleScan('PALLET-1'); state.handleScan('UNKNOWN')
assert.equal(state.scanNotice, null); assert.ok(state.error)
state.handleScan('PALLET-1'); state.selectTab('photos')
assert.equal(state.scanNotice, null)
state.selectTab('picking'); state.handleScan('PALLET-1')
state.current = task({ items: [item({ returnCartons: [{ returnCartonId: 'rc-1', cartonMark: 'RC-1', status: 'OUTBOUND_PENDING', productQuantity: 5 }] })] })
state.handleScan('RC-1'); assert.equal(state.scanNotice, null)
t.mock.timers.tick(3000); assert.equal(state.scanNotice, null)
})
test('real picking requires the native gate, physical source and current stage; empty or merely counted tasks cannot complete loading', async () => {
storage(); const state = page(); state.selectPallet(state.current.items[0]); assert.equal(state.canPick, true)
for (const allowed of [false, 'true', undefined]) { state.current.actions.PICK.allowed = allowed; assert.equal(state.canPick, false) }
state.current.actions.PICK.allowed = true; state.current.stage = 'BEFORE_LOADING'; assert.equal(state.canPick, false)
state.current = task({ items: [] , pickingReconciled: true }); assert.equal(state.canComplete, false)
state.current = task({ items: [item({ pickedPackages: 2 })] }); assert.equal(state.canComplete, false)
state.current.pickingReconciled = true; assert.equal(state.canComplete, true)
})
test('return carton pick keeps its exact item, quantity and explicit version zero without inventing a position', async () => {
storage(); let sent
const state = page({ pickDispatchItem: async (...args) => { sent = args; return proof(lastCommand) }, getDispatchTask: async () => task({ recordVersion: 1 }) })
state.current = task({ items: [item({ returnCartons: [{ returnCartonId: 'rc-1', cartonMark: 'RC-1', status: 'OUTBOUND_PENDING', productQuantity: 5 }] })] })
state.handleScan('RC-1'); await state.submitPick()
assert.equal(sent[0], 'PC/1'); assert.equal(sent[1], 'item-1')
assert.deepEqual({ ...sent[2], idempotencyKey: 'key' }, { recordVersion: 0, positionId: null, packages: 1, productQuantity: 5, cartonMark: 'RC-1', remark: null, idempotencyKey: 'key' })
assert.equal('pendingCommand' in fulfillment.dispatch, false)
})
test('actual loading confirmation leaves reference measurements blank and blocks missing unloading mapping', async () => {
storage(); const preview = { dispatchNo: 'PC/1', recordVersion: 0, manifestHash: 'manifest', pickingReconciled: true, loadingComplete: { allowed: true }, pallets: [{ warehousePalletId: 'pallet-1', palletNo: 'PALLET-1', palletSpaces: 1, deliveryIndex: null, weightKg: 100, volumeCbm: 2, evidence: [{ field: 'weightKg', actual: false, sourceType: 'ORDER_SNAPSHOT_ALLOCATION' }] }] }
let posts = 0; const state = page({ getDispatchLoading: async () => preview, completeDispatchLoading: async () => { posts++ } })
state.current = task({ pickingReconciled: true, items: [item({ pickedPackages: 2 })] }); await state.prepareLoading()
assert.equal(state.loadingRows[0].deliveryIndex, null)
Object.assign(state.loadingRows[0], { weightKg: 92, volumeCbm: 1.5 }); await state.completeLoading(); assert.equal(posts, 0); assert.match(state.error.message, /卸货站/)
})
test('missing versions surface a local error before picking or loading completion and never produce a write', async () => {
storage(); const state = page(); state.selectPallet(state.current.items[0]); state.current.recordVersion = undefined
await state.submitPick(); assert.match(state.error.message, /版本/); assert.equal('pendingCommand' in fulfillment.dispatch, false)
state.loadingPreview = { dispatchNo: 'PC/1', recordVersion: undefined, manifestHash: 'manifest' }; state.loadingRows = [{ warehousePalletId: '21', weightKg: 1, volumeCbm: 1, palletSpaces: 1, deliveryIndex: 1 }]; await state.completeLoading(); assert.match(state.error.message, /版本/); assert.equal('pendingCommand' in fulfillment.dispatch, false)
})
test('position picking uses available stock and this source remaining instead of warehouse allocation or picked buckets', () => {
storage(); const state = page(); state.current = task({ items: [item({ scheduledPackages: '3', pickedPackages: '1', positions: [{ positionId: 'empty-position', availablePackages: '0', allocatedPackages: '20', pickedPackages: '0' }, { positionId: 'actual-position', availablePackages: '20', allocatedPackages: '0', pickedPackages: '7' }] })] })
state.selectPallet(state.current.items[0]); assert.equal(state.selectedPosition.positionId, 'actual-position'); assert.equal(state.selectedRemaining, 2)
assert.equal(state.pickablePackages(state.current.items[0]), 2); assert.equal(state.canPick, true)
updateFulfillmentDraft('dispatch', { packages: 2 }); assert.equal(state.canPick, true)
updateFulfillmentDraft('dispatch', { packages: 3 }); assert.equal(state.canPick, false)
})
for (const scenario of [
{ name: 'zero available stock', scheduled: '2', picked: '0', available: '0', allocated: '20', stockPicked: '0', packages: '1', maximum: 0, position: null },
{ name: 'source already reconciled', scheduled: '1', picked: '1', available: '8', allocated: '0', stockPicked: '0', packages: '1', maximum: 0, position: null },
{ name: 'quantity above this dispatch source remaining', scheduled: '3', picked: '2', available: '9', allocated: '0', stockPicked: '8', packages: '2', maximum: 1, position: 'position-1' },
{ name: 'quantity above this position available stock', scheduled: '5', picked: '0', available: '2', allocated: '0', stockPicked: '0', packages: '3', maximum: 2, position: 'position-1' },
]) test(`real stock buckets prevent a pick for ${scenario.name}`, async () => {
storage(); let posts = 0
const state = page({ pickDispatchItem: async () => { posts++ } })
state.current = task({ pickingReconciled: scenario.scheduled === scenario.picked, items: [item({ scheduledPackages: scenario.scheduled, pickedPackages: scenario.picked, positions: [{ positionId: 'position-1', availablePackages: scenario.available, allocatedPackages: scenario.allocated, pickedPackages: scenario.stockPicked }] })] })
state.selectPallet(state.current.items[0]); state.updatePackages({ detail: { value: scenario.packages } })
assert.equal(state.selectedPosition?.positionId ?? null, scenario.position); assert.equal(state.selectedRemaining, scenario.maximum)
assert.equal(state.pickablePackages(state.current.items[0]), scenario.maximum); assert.equal(state.canPick, false)
await state.submitPick(); assert.equal(posts, 0); assert.equal('pendingCommand' in fulfillment.dispatch, false)
})
test('a shared pallet exposes both relabel sources and picking the second preserves its exact position and quantity', async () => {
storage(); const rows = [item({ scheduledPackages: 3 }), item({ dispatchItemId: 'item-2', sourceKey: 'source-2', sourceBusinessNo: 'RL-2', customerId: 'customer-2', customerName: '客户乙', scheduledPackages: 5, positions: [{ positionId: 'position-2', availablePackages: 5, allocatedPackages: 0, pickedPackages: 0 }] })]
let sent
const state = page({ pickDispatchItem: async (...args) => { sent = args; return proof(lastCommand) }, getDispatchTask: async () => task({ recordVersion: 1, items: rows.map((row, index) => index ? { ...row, pickedPackages: 5 } : row) }) })
state.current = task({ items: rows }); state.handleScan('PALLET-1')
assert.equal(fulfillment.dispatch.dispatchItemId, ''); assert.deepEqual(state.sourceChoices.map(row => [row.sourceBusinessNo, row.customerId, state.pickablePackages(row)]), [['RL-1', 'customer-1', 2], ['RL-2', 'customer-2', 5]])
state.chooseSource(state.sourceChoices[1]); state.updatePackages({ detail: { value: '5' } }); await state.submitPick()
assert.equal(sent[1], 'item-2'); assert.equal(sent[2].positionId, 'position-2'); assert.equal(sent[2].packages, 5); assert.equal(state.canComplete, false)
})
test('an ambiguous source carton requires explicit source selection and never picks the first matching item', async () => {
storage(); let sent
const rows = [item({ returnCartons: [{ returnCartonId: 'carton-1', cartonMark: 'SHARED-MARK', status: 'OUTBOUND_PENDING', productQuantity: 2 }] }), item({ dispatchItemId: 'item-2', sourceKey: 'source-2', sourceBusinessNo: 'RL-2', customerId: 'customer-2', returnCartons: [{ returnCartonId: 'carton-2', cartonMark: 'SHARED-MARK', status: 'OUTBOUND_PENDING', productQuantity: 7 }] })]
const state = page({ pickDispatchItem: async (...args) => { sent = args; return proof(lastCommand) }, getDispatchTask: async () => task({ recordVersion: 1, items: rows }) })
state.current = task({ items: rows }); state.handleScan('SHARED-MARK')
assert.equal(state.sourceChoices.length, 2); assert.equal(fulfillment.dispatch.dispatchItemId, ''); assert.equal(state.canPick, false)
state.chooseSource(state.sourceChoices[1]); await state.submitPick()
assert.equal(sent[1], 'item-2'); assert.equal(sent[2].cartonMark, 'SHARED-MARK'); assert.equal(sent[2].productQuantity, 7); assert.equal(sent[2].positionId, null)
})
test('selection restoration cannot reuse the same item ID for a different logical source', () => {
storage(); const state = page(); state.selectPallet(state.current.items[0]); assert.equal(state.canPick, true)
state.current.items[0].sourceKey = 'different-source'; assert.equal(state.selectedItem, null); assert.equal(state.canPick, false)
})
for (const sourceType of ['INBOUND_PLAN', 'RETURN_RECEIPT']) test(`ordinary ${sourceType} real 1/0/0 stock and null business fields allow one exact pick`, async () => {
storage(); let sent
const raw = JSON.parse(JSON.stringify({ dispatchNo: 'PC/1', taskNo: 'TASK-1', recordVersion: '0', status: 'ACTIVE', stage: 'LOADING', actions: { PICK: { allowed: true } }, pickingReconciled: false, items: [item({ sourceKey: `${sourceType}:source-1`, sourceBusinessType: null, sourceBusinessNo: null, scheduledPackages: '1', pickedPackages: '0', positions: [{ positionId: '9007199254740993', balanceId: '9007199254740994', availablePackages: '1', allocatedPackages: '0', pickedPackages: '0' }] })] }))
const normalized = normalizeDispatchTask(raw)
assert.equal(normalized.items[0].sourceBusinessType, null); assert.equal(normalized.items[0].sourceBusinessNo, null)
const state = page({ pickDispatchItem: async (...args) => { sent = args; return proof(lastCommand) }, getDispatchTask: async () => normalizeDispatchTask({ ...raw, recordVersion: 1 }) })
state.current = normalized; state.handleScan('PALLET-1'); assert.equal(state.canPick, true); assert.equal(state.selectedRemaining, 1)
assert.equal(state.pickablePackages(state.current.items[0]), 1)
state.updatePackages({ detail: { value: '1' } }); await state.submitPick()
assert.equal(sent[1], 'item-1'); assert.equal(sent[2].positionId, '9007199254740993'); assert.equal(sent[2].packages, 1); assert.equal(sent[2].recordVersion, '0')
assert.equal('customerId' in sent[2], false)
})
test('shared relabel pallet with real string 1/0/0 positions requires a source choice and picks each exact source once', async () => {
storage(); const sent = []; let version = 5
let rows = ['1', '2'].map(number => item({ dispatchItemId: `item-${number}`, sourceKey: `source-${number}`, sourceBusinessNo: `RL-${number}`, customerId: `customer-${number}`, scheduledPackages: '1', pickedPackages: '0', positions: [{ positionId: `position-${number}`, availablePackages: '1', allocatedPackages: '0', pickedPackages: '0' }] }))
const state = page({ pickDispatchItem: async (no, itemId, body) => {
sent.push({ no, itemId, body }); rows = rows.map(row => row.dispatchItemId === itemId ? { ...row, pickedPackages: '1', positions: row.positions.map(position => ({ ...position, availablePackages: '0' })) } : row)
return proof(lastCommand, ++version)
}, getDispatchTask: async () => task({ recordVersion: version, items: rows, pickingReconciled: rows.every(row => row.pickedPackages === '1') }) })
state.current = task({ recordVersion: version, items: rows }); state.handleScan('PALLET-1')
assert.equal(fulfillment.dispatch.dispatchItemId, ''); assert.equal(state.canPick, false)
assert.deepEqual(state.sourceChoices.map(row => [row.sourceKey, state.pickablePackages(row)]), [['source-1', 1], ['source-2', 1]])
state.chooseSource(state.sourceChoices[1]); assert.equal(state.selectedRemaining, 1); assert.equal(state.canPick, true); await state.submitPick()
assert.equal(state.current.stage, 'LOADING'); assert.equal(state.canComplete, false)
assert.equal(state.current.items[0].pickedPackages, 0); assert.equal(state.current.items[1].pickedPackages, 1)
state.handleScan('PALLET-1'); assert.equal(state.selectedItem.sourceKey, 'source-1'); assert.equal(state.canPick, true); await state.submitPick()
assert.deepEqual(sent.map(entry => [entry.no, entry.itemId, entry.body.positionId, entry.body.packages, entry.body.recordVersion]), [['PC/1', 'item-2', 'position-2', 1, 5], ['PC/1', 'item-1', 'position-1', 1, 6]])
assert.notEqual(sent[0].body.idempotencyKey, sent[1].body.idempotencyKey)
assert.ok(sent.every(entry => !('customerId' in entry.body) && !('warehouseId' in entry.body)))
assert.equal('pendingCommand' in fulfillment.dispatch, false); assert.equal(state.canPick, false); assert.equal(state.canComplete, true)
})
test('RELABEL_ORDER still requires its true business number and all sources require sourceKey', () => {
storage(); const state = page()
state.selectPallet(item({ sourceBusinessNo: null })); assert.equal(fulfillment.dispatch.dispatchItemId, ''); assert.match(state.error.message, /来源业务身份/)
state.selectPallet(item({ sourceKey: '', sourceBusinessType: null, sourceBusinessNo: null })); assert.equal(fulfillment.dispatch.dispatchItemId, '')
})
test('duplicate matching carton facts inside one source fail closed rather than taking its first carton', () => {
storage(); const state = page()
state.current = task({ items: [item({ returnCartons: ['one', 'two'].map(returnCartonId => ({ returnCartonId, cartonMark: 'DUPLICATE', status: 'OUTBOUND_PENDING', productQuantity: 1 })) })] })
state.handleScan('DUPLICATE'); assert.match(state.error.message, /重复待拣箱唛/); assert.equal(fulfillment.dispatch.dispatchItemId, ''); assert.equal(state.canPick, false)
})
test('two picked logical sources use the single authoritative physical loading row without duplicating measurements', async () => {
storage(); const rows = [item({ pickedPackages: 2 }), item({ dispatchItemId: 'item-2', sourceKey: 'source-2', sourceBusinessNo: 'RL-2', pickedPackages: 2 })]
const preview = { dispatchNo: 'PC/1', recordVersion: 0, manifestHash: 'one-physical-pallet', pickingReconciled: true, loadingComplete: { allowed: true }, pallets: [{ warehousePalletId: '21', palletNo: 'PALLET-1', palletSpaces: 1, deliveryIndex: 1, weightKg: 100, volumeCbm: 2, evidence: [{ field: 'weightKg', actual: true }, { field: 'volumeCbm', actual: true }] }] }
let sent
const state = page({ getDispatchLoading: async () => preview, completeDispatchLoading: async (...args) => { sent = args; return proof(lastCommand) }, getDispatchTask: async () => task({ recordVersion: 1, stage: 'AFTER_LOADING', items: rows, pickingReconciled: true }) })
state.current = task({ items: rows, pickingReconciled: true }); await state.prepareLoading(); assert.equal(state.loadingRows.length, 1)
await state.completeLoading(); assert.equal(state.current.stage, 'AFTER_LOADING'); assert.equal(state.departed, false); assert.equal(state.canComplete, false); assert.equal(state.loadingPreview, null); assert.equal('measurements' in sent[1], false)
})
const reviewTask = (overrides = {}) => task({ stage: 'BEFORE_LOADING', actions: { LOADING_REVIEW: { allowed: true }, PICK: { allowed: false } }, resources: { vehicle: { plate: 'ABC123', carrierName: '车队' }, driver: { name: '司机', phone: '123' } }, reviews: { dispatchReview: { status: 'APPROVED', reason: '已核对预约' }, loadingReview: { status: 'PENDING' } }, ...overrides })
test('missing appointment blocks approval while preserving rejection to customer service', async () => {
storage(); const sent = []
const state = page({ reviewDispatchLoading: async (...args) => { sent.push(args); return proof(lastCommand) }, getDispatchTask: async () => task({ recordVersion: 1 }) })
state.current = reviewTask({ actions: { LOADING_REVIEW: { allowed: true, blockCode: 'APPROVAL_BLOCKED', blockReason: '预约收货时间尚未确认' } } })
assert.equal(state.reviewTime('2026-10-21T17:00:00Z'), '2026-10-22 01:00')
assert.equal(state.reviewTime(null), '未填写')
state.reviewReason = '请客服补齐预约时间'
await state.submitReview('APPROVED'); assert.equal(sent.length, 0); assert.match(state.error.message, /预约收货时间尚未确认/)
await state.submitReview('REJECTED'); assert.equal(sent.length, 1); assert.equal(sent[0][1].decision, 'REJECTED')
})
test('review task is reachable in the existing list and blocks actual source selection until approval', async () => {
storage(); const state = page({ listDispatchTasks: async () => [reviewTask()], getDispatchTask: async () => reviewTask() })
await state.loadTasks(); assert.equal(state.tasks.length, 1); assert.equal(state.stageLabel(state.tasks[0]), '待理货审核')
await state.openTask('PC/1'); assert.equal(state.canReview, true); assert.equal(state.canPick, false)
state.selectPallet(state.current.items[0]); assert.equal(state.selectedItem, null)
state.handleScan('PALLET-1'); assert.match(state.error.message, /审核通过/); assert.equal(state.selectedItem, null)
for (const allowed of [false, undefined, 'true']) { state.current.actions.LOADING_REVIEW.allowed = allowed; assert.equal(state.canReview, false) }
})
test('reject requires a nonblank reason and approval uses original zero version then refreshes real pick eligibility', async () => {
storage(); let sent
const state = page({ reviewDispatchLoading: async (...args) => { sent = args; return proof(lastCommand) }, getDispatchTask: async () => task({ recordVersion: 1 }) })
state.current = reviewTask(); state.reviewReason = ' '; await state.submitReview('REJECTED')
assert.match(state.error.message, /原因/); assert.equal('pendingCommand' in fulfillment.dispatch, false)
state.reviewReason = ' 已核对车辆和货物 '; await state.submitReview('APPROVED')
assert.deepEqual({ ...sent[1], idempotencyKey: 'key' }, { recordVersion: 0, decision: 'APPROVED', reason: '已核对车辆和货物', idempotencyKey: 'key' })
assert.equal(state.current.stage, 'LOADING'); state.selectPallet(state.current.items[0]); assert.equal(state.canPick, true)
assert.equal(state.reviewReason, ''); assert.equal('pendingCommand' in fulfillment.dispatch, false)
})
test('rejected loading review returns to customer service with its reason and never enables picking', async () => {
storage(); let sent
const rejected = reviewTask({ recordVersion: 1, actions: { LOADING_REVIEW: { allowed: false } }, reviews: { dispatchReview: { status: 'NOT_SUBMITTED', invalidatedReason: '理货审核驳回,请客服修改后重新提交调度审核' }, loadingReview: { status: 'REJECTED', reason: '车牌不符' } } })
const state = page({ reviewDispatchLoading: async (no, body) => { sent = body; return { ...proof(lastCommand), stage: 'BEFORE_LOADING' } }, getDispatchTask: async () => rejected })
state.current = reviewTask(); state.reviewReason = '车牌不符'; await state.submitReview('REJECTED')
assert.equal(sent.decision, 'REJECTED'); assert.equal(state.rejectionReason, '车牌不符'); assert.equal(state.canReview, false)
assert.equal(state.stageLabel(state.current), '已退回客服处理'); assert.equal(state.canPick, false); assert.match(state.successMessage, /客服处理/)
})
test('review confirmation cannot submit a different version or a revoked gate', async () => {
for (const change of [state => { state.current.recordVersion = 2 }, state => { state.current.actions.LOADING_REVIEW.allowed = false }]) {
storage(); const state = page(); state.current = reviewTask(); uni.showModal = async () => { change(state); return { confirm: true } }
await state.submitReview('APPROVED'); assert.equal('pendingCommand' in fulfillment.dispatch, false)
}
})
test('review UI exposes authoritative resources and reason without reviewer database IDs or extra dispatch review action', async () => {
const template = (await readFile(new URL('../src/pages/dispatch/dispatch.vue', import.meta.url), 'utf8')).split('<script>')[0]
for (const field of ['resources?.vehicle?.plate', 'resources?.driver?.name', 'resources?.driver?.phone', 'reviews?.dispatchReview?.reason', 'rejectionReason']) assert.ok(template.includes(field))
assert.ok(template.includes("submitReview('APPROVED')")); assert.ok(template.includes("submitReview('REJECTED')"))
assert.doesNotMatch(template, /reviewerId|vehicleId|driverId|DISPATCH_REVIEW/)
})
test('scanning received pallets defaults to real nine and eleven boxes without manually reentering estimates', async () => {
storage(); const sent = []
let rows = [9, 11].map((quantity, index) => item({ dispatchItemId: `received-${index}`, sourceKey: `PLAN:${index}`, sourceBusinessType: null, sourceBusinessNo: null, palletNo: `PALLET-${index}`, scheduledPackages: quantity, positions: [{ positionId: `position-${index}`, availablePackages: quantity, allocatedPackages: 0, pickedPackages: 0 }] }))
const state = page({ pickDispatchItem: async (number, id, body) => { sent.push(body.packages); rows = rows.map(row => row.dispatchItemId === id ? { ...row, pickedPackages: body.packages, positions: [] } : row); return proof(lastCommand, sent.length) }, getDispatchTask: async () => task({ items: rows, recordVersion: sent.length }) })
state.current = task({ items: rows })
state.handleScan('PALLET-0'); assert.equal(state.packages, 9); assert.equal(state.canPick, true); await state.submitPick()
state.handleScan('PALLET-1'); assert.equal(state.packages, 11); assert.equal(state.canPick, true); await state.submitPick()
assert.deepEqual(sent, [9, 11]); assert.equal(state.pickedPackages, 20); assert.equal(state.remainingPackages, 0)
})
test('photo tab preserves the selected pallet and quantity and ignores picking broadcasts', () => {
storage(); const state = page(); state.handleScan('PALLET-1'); state.updatePackages({ detail: { value: '1' } })
const before = JSON.parse(JSON.stringify(fulfillment.dispatch))
state.selectTab('photos'); state.handleScan('UNKNOWN-PALLET')
assert.equal(state.activeTab, 'photos'); assert.equal(state.error, null)
assert.deepEqual(JSON.parse(JSON.stringify(fulfillment.dispatch)), before)
state.selectTab('picking'); assert.equal(state.selectedItem.palletNo, 'PALLET-1'); assert.equal(state.packages, 1)
})
test('same-task photo refresh stays on photos while a new task starts on picking', async () => {
storage(); const state = page({ getDispatchTask: async no => task({ dispatchNo: no, recordVersion: 2 }) })
state.selectTab('photos'); await state.openTask('PC/1'); assert.equal(state.activeTab, 'photos')
await state.openTask('PC/2'); assert.equal(state.activeTab, 'picking')
})
test('tab changes cannot interrupt an upload or pending command, or open photos before loading', () => {
storage(); const state = page(); state.photoBusy = true; state.selectTab('photos'); assert.equal(state.activeTab, 'picking')
state.photoBusy = false; state.current.stage = 'BEFORE_LOADING'; state.selectTab('photos'); assert.equal(state.activeTab, 'picking')
state.current.stage = 'LOADING'; state.requestBusy = true
state.selectTab('photos'); assert.equal(state.activeTab, 'picking')
})
test('thirty scanned pallets confirm once without weight volume or pallet entry', async () => {
storage(); let sent
const preview = { dispatchNo: 'PC/1', recordVersion: 0, manifestHash: 'thirty', pickingReconciled: true, loadingComplete: { allowed: true }, pallets: Array.from({ length: 30 }, (_, i) => ({ warehousePalletId: String(i), palletNo: `PALLET-${i}`, deliveryIndex: 1, pickedPackages: 10, weightKg: null, volumeCbm: null, evidence: [] })) }
const state = page({ getDispatchLoading: async () => preview, completeDispatchLoading: async (_, body) => { sent = body; return proof(lastCommand) }, getDispatchTask: async () => task({ recordVersion: 1, stage: 'AFTER_LOADING', pickingReconciled: true }) })
state.current = task({ pickingReconciled: true, items: [item({ pickedPackages: 2 })] }); await state.prepareLoading(); await state.completeLoading()
assert.equal(state.error, null); assert.equal(state.current.stage, 'AFTER_LOADING'); assert.equal(sent.manifestHash, 'thirty'); assert.equal('measurements' in sent, false)
})
for (const failure of [{ network: true, message: 'timeout' }, { code: 'A0409', message: 'conflict' }, { code: 'A0500', message: 'server error' }]) test('failed pick unlocks and submits current edited values: ' + failure.message, async () => {
const sent = []; let reads = 0
storage()
const state = page({ pickDispatchItem: async (no, id, body) => { sent.push(body); throw failure }, getDispatchTask: async () => { reads++; return task() } })
state.selectPallet(state.current.items[0]); await state.submitPick()
assert.equal(state.busy, false); assert.equal(reads, 0); assert.equal('pendingCommand' in fulfillment.dispatch, false)
state.current.recordVersion = 7; state.updatePackages({ detail: { value: 1 } }); await state.submitPick()
assert.equal(sent.length, 2); assert.equal(sent[1].recordVersion, 7); assert.equal(sent[1].packages, 1)
assert.notEqual(sent[0].idempotencyKey, sent[1].idempotencyKey)
state.selectTab('photos'); assert.equal(state.activeTab, 'photos')
})
test('failed review keeps editable reason and releases the request without a readback gate', async () => {
storage(); const sent = []
const state = page({ reviewDispatchLoading: async (no, body) => { sent.push(body); throw { network: true } } })
state.current = reviewTask(); state.reviewReason = 'first'; await state.submitReview('REJECTED')
assert.equal(state.busy, false); state.updateReviewReason({ detail: { value: 'changed' } }); await state.submitReview('REJECTED')
assert.equal(sent[1].reason, 'changed'); assert.notEqual(sent[0].idempotencyKey, sent[1].idempotencyKey)
})
test('in-flight pick blocks a second click and releases after failure', async () => {
storage(); let reject, writes = 0
const state = page({ pickDispatchItem: () => { writes++; return new Promise((_, failure) => { reject = failure }) } })
state.selectPallet(state.current.items[0]); const first = state.submitPick(); await state.submitPick()
assert.equal(writes, 1); reject({ network: true }); await first; assert.equal(state.busy, false)
})
test('invalid completion stage reports an error and still releases the page', async () => {
storage(); const state = page({ completeDispatchLoading: async () => ({ ...proof(lastCommand), stage: 'LOADING' }) })
await state.submitCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0 })
assert.match(state.error.message, /返回结果未确认/); assert.equal(state.busy, false)
})
-54
View File
@@ -1,54 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { pickDispatchItem, pickOutboundTask, pickOutboundPallet, confirmCargoDiscard } from '../src/api/fulfillment.js'
import { session } from '../src/stores/session.js'
// 模拟当前生产:仓租预检查接口不存在,既有库存命令仍正常受理。
function transport(failure) {
const calls = []
session.apiBase = '/api'; session.token = 'overseas-token'
globalThis.uni = { request(options) {
calls.push({ method: options.method, url: options.url, data: options.data })
if (failure?.network) return options.fail({ errMsg: 'network down' })
const error = options.url.includes('/storage-rent/') ? { code: 'A0404', message: 'Resource not found' } : failure
options.success({ statusCode: 200, data: error || { code: '200', data: { success: true } }, header: {} })
} }
return calls
}
const dispatch = { recordVersion: '9007199254740993', positionId: '9007199254740994', packages: 21, cartonMark: null, productQuantity: 0, idempotencyKey: 'dispatch-key' }
const outbound = { outboundPalletItemId: '9007199254740996', inventoryPositionId: null, packages: 1, cartonMarks: ['RETURN-BOX-1'], skuCode: 'SKU-A', productQuantity: 5, idempotencyKey: 'outbound-key' }
const pallet = { idempotencyKey: 'pallet-key' }
const discard = { lines: [{ itemId: '9007199254740993', actualPackages: 2 }], photoFileIds: ['101'], scannedCartonMarks: ['BOX-1'], confirmAllCartons: false, idempotencyKey: 'discard-key' }
const modes = [
{ name: 'dispatch', path: '/dispatches/DSP%2F1/items/9007199254740995/pick', body: dispatch, submit: () => pickDispatchItem('DSP/1', '9007199254740995', dispatch) },
{ name: 'outbound', path: '/outbound-tasks/TASK%2F1/pick', body: outbound, submit: () => pickOutboundTask('TASK/1', outbound) },
{ name: 'pallet', path: '/outbound-tasks/TASK%2F1/pallets/DP%2F1/pick', body: pallet, submit: () => pickOutboundPallet('TASK/1', 'DP/1', pallet) },
]
for (const mode of modes) {
test(`${mode.name} works without the unpublished rent endpoint and preserves its stock payload`, async () => {
const calls = transport()
await mode.submit()
assert.deepEqual(calls, [{ method: 'POST', url: '/api/pda/v1/last-leg' + mode.path, data: mode.body }])
})
test(`${mode.name} propagates stock failures without fallback or automatic resubmission`, async () => {
for (const failure of [{ code: 'A0404', message: 'missing' }, { code: 'A0409', message: 'conflict' }, { code: 'S0503', message: 'unavailable' }, { network: true }]) {
const calls = transport(failure)
await assert.rejects(mode.submit(), error => failure.network ? error.network === true : error.code === failure.code)
assert.equal(calls.length, 1)
assert.equal(calls[0].url, '/api/pda/v1/last-leg' + mode.path)
assert.deepEqual(calls[0].data, mode.body)
}
})
}
test('invalid dispatch version and missing command keys fail before any request', async () => {
const calls = transport()
await assert.rejects(pickDispatchItem('DSP-1', '11', { idempotencyKey: 'key' }), /版本/)
await assert.rejects(pickDispatchItem('DSP-1', '11', { recordVersion: 1 }), /标识/)
await assert.rejects(pickOutboundTask('TASK-1', {}), /标识/)
await assert.rejects(pickOutboundPallet('TASK-1', 'DP-1', {}), /标识/)
await assert.rejects(confirmCargoDiscard('CD-1', 'P-1', [], [], [], false, {}), /标识/)
assert.equal(calls.length, 0)
})
+128 -61
View File
@@ -2,11 +2,11 @@ import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
import { fulfillment, fulfillmentIdempotencyKey, resetFulfillmentSession, restoreFulfillmentDraft, updateFulfillmentDraft } from '../src/stores/fulfillment.js' import { fulfillment, fulfillmentIdempotencyKey, releaseFulfillmentKey, resetFulfillmentSession, restoreFulfillmentDraft, updateFulfillmentDraft } from '../src/stores/fulfillment.js'
import { import {
reviewDispatchLoading, completeDispatchLoading, getDispatchLoading, getDispatchCommand, requireDispatchVersion, completeOutboundTask, getDispatchTask, getOutboundTask, completeDispatchPicking, completeOutboundTask, departDispatch, getDispatchTask, getOutboundTask,
listDispatchTasks, listOutboundTasks, normalizeDispatchTask, normalizeOutboundTask, listDispatchTasks, listOutboundTasks, normalizeDispatchTask, normalizeOutboundTask,
pickDispatchItem, pickOutboundPallet, pickOutboundTask, resolveOutboundTask, outboundPickPayload pickDispatchItem, pickOutboundPallet, pickOutboundTask, resolveOutboundTask, outboundPickPayload, tripDepartureRequest
} from '../src/api/fulfillment.js' } from '../src/api/fulfillment.js'
import { session } from '../src/stores/session.js' import { session } from '../src/stores/session.js'
import { toDispatchError } from '../src/utils/dispatch-error.js' import { toDispatchError } from '../src/utils/dispatch-error.js'
@@ -23,19 +23,50 @@ test('dispatch network failures show recovery guidance and retain diagnostics',
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8') const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
test('outbound list and detail lead with the outbound number and label the task number separately', async () => { test('dispatch merge retains return carton picking and review gates together', async () => {
const page = await read('../src/pages/outbound/outbound.vue') const source = (await read('../src/pages/dispatch/dispatch.vue')).match(/<script>([\s\S]*?)<\/script>/)[1]
assert.match(page, /class="business-code">\{\{ task\.outboundNo \|\| '—' \}\}/) const definition = new Function('PortalHeader', 'ScanTargetInput', 'DispatchHandoverStatus',
assert.match(page, /class="business-code">\{\{ current\.outboundNo \|\| '—' \}\}/) source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))({}, {}, {})
assert.match(page, /任务号 \{\{ task\.taskNo \|\| '—' \}\}/) const state = { current: { canPick: true }, selectedItem: { returnCartons: [{}] },
assert.match(page, /任务号 \{\{ current\.taskNo \|\| '—' \}\}/) selectedReturnCarton: { cartonMark: 'RETURN-BOX' }, selectedPosition: null,
assert.match(page, /@tap="openTask\(task\.taskNo\)"/) 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('native dispatch versions preserve explicit zero and reject missing or invalid versions', () => { test('trip departure preserves version zero and rejects incomplete trip identity', () => {
assert.equal(requireDispatchVersion(0), 0) const task = normalizeDispatchTask({ tripNo: 'TR-1', tripRecordVersion: 0, tripDispatchCount: 2, items: [] })
assert.equal(requireDispatchVersion('9007199254740993'), '9007199254740993') assert.equal(task.tripRecordVersion, '0')
for (const value of [undefined, null, -1, '-1', '', '1.1', 1.1, Number.MAX_SAFE_INTEGER + 1]) assert.throws(() => requireDispatchVersion(value), /版本/) 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 () => { test('outbound partial pick submits the selected carton and distinguishes shared-pallet items', async () => {
@@ -104,12 +135,12 @@ test('phase-five workbench registers only dispatch and outbound PDA delivery bou
test('fulfillment API uses dedicated PDA routes and never sends customer or warehouse scope', async () => { test('fulfillment API uses dedicated PDA routes and never sends customer or warehouse scope', async () => {
const api = await read('../src/api/fulfillment.js') const api = await read('../src/api/fulfillment.js')
for (const path of ['dispatches', 'outbound-tasks', '/pick', '/loading-complete', '/loading-review']) assert.match(api, new RegExp(path.replace('/', '\\/'))) for (const path of ['dispatches', 'outbound-tasks', '/pick', '/complete', '/depart']) assert.match(api, new RegExp(path.replace('/', '\\/')))
for (const forbidden of ['customerId', 'warehouseId', 'locationId']) assert.doesNotMatch(api, new RegExp(forbidden)) for (const forbidden of ['customerId', 'warehouseId', 'locationId']) assert.doesNotMatch(api, new RegExp(forbidden))
assert.match(api, /encodeURIComponent/) assert.match(api, /encodeURIComponent/)
}) })
test('fulfillment API calls the frozen list detail pick complete and review routes with exact payloads', async () => { test('fulfillment API calls the frozen list detail pick complete and depart routes with exact payloads', async () => {
const calls = [] const calls = []
session.apiBase = '/api'; session.token = 'overseas-token' session.apiBase = '/api'; session.token = 'overseas-token'
globalThis.uni = { globalThis.uni = {
@@ -125,20 +156,21 @@ test('fulfillment API calls the frozen list detail pick complete and review rout
} }
} }
await listDispatchTasks(); await getDispatchTask('DSP/1') await listDispatchTasks('PICKING'); await getDispatchTask('DSP/1')
await pickDispatchItem('DSP/1', '11', { recordVersion: 0, positionId: '22', packages: 1, productQuantity: 0, idempotencyKey: 'pick-key' }) await pickDispatchItem('DSP/1', '11', { positionId: '22', packages: 1, productQuantity: 0, idempotencyKey: 'pick-key' })
await getDispatchLoading('DSP/1'); await getDispatchCommand('DSP/1', 'pick/key'); await completeDispatchLoading('DSP/1', { recordVersion: 1, idempotencyKey: 'complete-key', manifestHash: 'hash' }) await completeDispatchPicking('DSP/1', { idempotencyKey: 'complete-key' })
await reviewDispatchLoading('DSP/1', { recordVersion: 0, idempotencyKey: 'review-key', decision: 'REJECTED', reason: '车牌不符' }) await departDispatch('DSP/1', { recordVersion: '4', idempotencyKey: 'depart-key' })
await listOutboundTasks('PENDING'); await getOutboundTask('OUT/TASK/1') await listOutboundTasks('PENDING'); await getOutboundTask('OUT/TASK/1')
await resolveOutboundTask('OUT/1') await resolveOutboundTask('OUT/1')
await pickOutboundTask('OUT/TASK/1', { outboundPalletItemId: '33', inventoryPositionId: '44', packages: 1, idempotencyKey: 'out-pick-key' }) 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 pickOutboundPallet('OUT/TASK/1', 'DP/1', { idempotencyKey: 'out-pallet-key' })
await completeOutboundTask('OUT/TASK/1', { idempotencyKey: 'out-complete-key' }) await completeOutboundTask('OUT/TASK/1', { idempotencyKey: 'out-complete-key' })
assert.equal(calls.length, 13) assert.equal(calls.length, 11)
assert.deepEqual(calls.find(call => call.url.endsWith('/dispatches/DSP%2F1/loading-review')).data, { recordVersion: 0, idempotencyKey: 'review-key', decision: 'REJECTED', reason: '车牌不符' }) assert.deepEqual(calls.find(call => call.url.endsWith('/depart')).data, { recordVersion: '4', idempotencyKey: 'depart-key' })
assert.ok(calls.some((call) => call.url.endsWith('/dispatches'))) 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.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/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.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.some((call) => call.url.endsWith('/outbound-tasks/resolve/OUT%2F1')))
@@ -148,17 +180,17 @@ test('fulfillment API calls the frozen list detail pick complete and review rout
assert.equal(Object.hasOwn(call.data || {}, 'customerId'), false) assert.equal(Object.hasOwn(call.data || {}, 'customerId'), false)
assert.equal(Object.hasOwn(call.data || {}, 'warehouseId'), false) assert.equal(Object.hasOwn(call.data || {}, 'warehouseId'), false)
} }
const dispatchComplete = calls.find((call) => call.url.endsWith('/dispatches/DSP%2F1/loading-complete')) const dispatchComplete = calls.find((call) => call.url.endsWith('/dispatches/DSP%2F1/complete'))
assert.deepEqual(dispatchComplete.data, { recordVersion: 1, idempotencyKey: 'complete-key', manifestHash: 'hash' }); assert.ok(calls.some(call => call.url.endsWith('/commands/pick%2Fkey'))) assert.deepEqual(dispatchComplete.data, { idempotencyKey: 'complete-key' })
}) })
test('dispatch and outbound adapters retain stable business identifiers and count progress', () => { test('dispatch and outbound adapters retain stable business identifiers and count progress', () => {
const dispatch = normalizeDispatchTask({ taskNo: 'DPT-1', dispatchNo: 'DSP-1', actions: { LOADING_COMPLETE: { allowed: false, blockReason: '请先完成真实拣货' } }, items: [{ dispatchItemId: 8, palletNo: 'PLT-1', scheduledPackages: 5, pickedPackages: 2, positions: [{ positionId: 9, availablePackages: 5, allocatedPackages: 5, pickedPackages: 2 }] }] }) const dispatch = normalizeDispatchTask({ taskNo: 'DPT-1', dispatchNo: 'DSP-1', canDepart: false, departBlockReason: '请先在平台完成“转派送”', items: [{ dispatchItemId: 8, palletNo: 'PLT-1', scheduledPackages: 5, pickedPackages: 2, positions: [{ id: 9, availablePackages: 5, pickedPackages: 2 }] }] })
assert.equal(dispatch.dispatchNo, 'DSP-1') assert.equal(dispatch.dispatchNo, 'DSP-1')
assert.equal(dispatch.items[0].palletNo, 'PLT-1') assert.equal(dispatch.items[0].palletNo, 'PLT-1')
assert.equal(dispatch.items[0].positions[0].availablePackages, 5) assert.equal(dispatch.items[0].positions[0].availablePackages, 5)
assert.equal(dispatch.actions.LOADING_COMPLETE.allowed, false) assert.equal(dispatch.canDepart, false)
assert.equal(dispatch.actions.LOADING_COMPLETE.blockReason, '请先完成真实拣货') assert.equal(dispatch.departBlockReason, '请先在平台完成“转派送”')
const outbound = normalizeOutboundTask({ task: { taskNo: 'OUT-TASK-1', outboundNo: 'OUT-1', status: 'PENDING' }, items: [{ outboundPalletItemId: 3, palletNo: 'PLT-2', skuCode: 'SKU-1', inboundCode: 'INB-1', packageCount: 4, pickedPackageCount: 1, status: 'PARTIAL_PICKED', positions: [{ inventoryPositionId: 7, palletNo: 'PLT-2', cartonMark: 'BOX-1', skuCode: 'SKU-1', inboundCode: 'INB-1', availablePackages: 3, pickedPackages: 1 }] }] }) const outbound = normalizeOutboundTask({ task: { taskNo: 'OUT-TASK-1', outboundNo: 'OUT-1', status: 'PENDING' }, items: [{ outboundPalletItemId: 3, palletNo: 'PLT-2', skuCode: 'SKU-1', inboundCode: 'INB-1', packageCount: 4, pickedPackageCount: 1, status: 'PARTIAL_PICKED', positions: [{ inventoryPositionId: 7, palletNo: 'PLT-2', cartonMark: 'BOX-1', skuCode: 'SKU-1', inboundCode: 'INB-1', availablePackages: 3, pickedPackages: 1 }] }] })
assert.equal(outbound.taskNo, 'OUT-TASK-1') assert.equal(outbound.taskNo, 'OUT-TASK-1')
@@ -170,30 +202,60 @@ test('dispatch and outbound adapters retain stable business identifiers and coun
assert.equal(outbound.items[0].positions[0].availablePackages, 3) assert.equal(outbound.items[0].positions[0].availablePackages, 3)
}) })
test('outbound draft restoration discards request keys and stays scoped to operator and warehouse', () => { test('draft restoration scopes work to the signed-in operator warehouse and retains retry idempotency', () => {
storageMock(); resetFulfillmentSession(); restoreFulfillmentDraft('operator-a:warehouse-a') storageMock(); resetFulfillmentSession()
updateFulfillmentDraft('outbound', { currentTaskNo: 'OUT-001' }) assert.equal(restoreFulfillmentDraft('operator-a:warehouse-a'), false)
const first = fulfillmentIdempotencyKey('outbound', 'pick', { taskNo: 'OUT-001', packages: 3 }) updateFulfillmentDraft('dispatch', { currentNo: 'DSP-001', palletCode: 'PLT-001', packages: 3 })
assert.notEqual(fulfillmentIdempotencyKey('outbound', 'pick', { taskNo: 'OUT-001', packages: 3 }).value, first.value) const first = fulfillmentIdempotencyKey('dispatch', 'pick', { dispatchNo: 'DSP-001', palletNo: 'PLT-001', packages: 3 })
fulfillment.outbound.currentTaskNo = ''; assert.equal(restoreFulfillmentDraft('operator-a:warehouse-a'), true) const retry = fulfillmentIdempotencyKey('dispatch', 'pick', { dispatchNo: 'DSP-001', palletNo: 'PLT-001', packages: 3 })
assert.equal(fulfillment.outbound.currentTaskNo, 'OUT-001') assert.equal(retry.value, first.value)
assert.notEqual(fulfillmentIdempotencyKey('outbound', 'pick', { taskNo: 'OUT-001', packages: 3 }).value, first.value)
assert.equal(restoreFulfillmentDraft('operator-b:warehouse-a'), false); assert.equal(fulfillment.outbound.currentTaskNo, '') fulfillment.dispatch.currentNo = ''
assert.equal('pendingKeys' in fulfillment.outbound, false) assert.equal(restoreFulfillmentDraft('operator-a:warehouse-a'), true)
assert.equal(fulfillment.dispatch.currentNo, 'DSP-001')
assert.equal(fulfillment.dispatch.pendingKeys[first.signature], first.value)
releaseFulfillmentKey('dispatch', first.signature)
const next = fulfillmentIdempotencyKey('dispatch', 'pick', { dispatchNo: 'DSP-001', palletNo: 'PLT-001', packages: 3 })
assert.notEqual(next.value, first.value)
assert.equal(restoreFulfillmentDraft('operator-b:warehouse-a'), false)
assert.equal(fulfillment.dispatch.currentNo, '')
}) })
test('pages retain loading, offline, diagnostics and native loading completion followed by shared loading documents', async () => { test('pages expose loading empty error offline and permission diagnostics with guarded writes', async () => {
const [dispatch, outbound] = await Promise.all([read('../src/pages/dispatch/dispatch.vue'), read('../src/pages/outbound/outbound.vue')]) const [dispatch, outbound] = await Promise.all([read('../src/pages/dispatch/dispatch.vue'), read('../src/pages/outbound/outbound.vue')])
for (const page of [dispatch,outbound]) { for (const page of [dispatch, outbound]) {
for (const text of ['startScanBroadcast', '正在加载', '暂无', 'requestId', 'traceId', '返回列表']) assert.ok(page.includes(text)) assert.match(page, /startScanBroadcast/)
assert.doesNotMatch(page, /模拟成功|静默成功|customerId:|warehouseId:|locationId:/) assert.match(page, /正在加载/)
} assert.match(page, /暂无/)
assert.doesNotMatch(dispatch, /departDispatch|canDepart|确认整车发车|等待后台安排派送/); assert.match(dispatch, /DispatchLoadingDocuments/); assert.match(dispatch, /completeDispatchLoading/); assert.match(dispatch, /pickingReconciled === true/) assert.match(page, /requestId[\s\S]*traceId/)
assert.doesNotMatch(dispatch, /beginDispatchCommand|pendingCommand/); assert.match(dispatch, /submitCommand/) assert.match(page, /error\.network[\s\S]*保留/)
assert.doesNotMatch(dispatch, /tripDepartureRequest|completeDispatchPicking|fulfillmentIdempotencyKey/) assert.match(page, /fulfillmentIdempotencyKey/)
assert.match(outbound, /resolveOutboundTask[\s\S]*workflow === 'DISPATCH'[\s\S]*pages\/dispatch\/dispatch/) assert.match(page, /releaseFulfillmentKey/)
assert.match(outbound, /outboundPickPayload/); assert.match(outbound, /await getOutboundTask/); assert.doesNotMatch(outbound, /releaseFulfillmentKey/) assert.match(page, /if \(!this\.can/)
assert.doesNotMatch(dispatch + outbound, /DeliveryOrder|deliveryStatus|customerVisit|\/deliveries/) assert.doesNotMatch(page, /模拟成功|静默成功|customerId:|warehouseId:|locationId:/)
}
assert.match(dispatch, /completeDispatchPicking/)
assert.match(dispatch, /toDispatchError/)
assert.match(dispatch, /this\.remainingPackages === 0/)
assert.doesNotMatch(dispatch, /partialReason|reason \}/)
assert.match(dispatch, /this\.pickingCompleted && this\.allPicked && this\.current\?\.canDepart === true && !this\.departed/)
assert.match(dispatch, /current\.departBlockReason/)
assert.match(dispatch, /this\.selectedPosition\?\.availablePackages \|\| 0/)
assert.doesNotMatch(dispatch, /availablePackages - (?:this\.)?selectedPosition.*pickedPackages/)
assert.match(dispatch, /departDispatch/)
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, /if \(!this\.current\) return this\.openScannedTask\(code\)/)
assert.doesNotMatch(outbound, /if \(!this\.current\)[^\n]*openTask\(code\)/)
assert.match(outbound, /this\.remainingPackages === 0/)
assert.match(outbound, /position\.availablePackages <= 0/)
assert.doesNotMatch(outbound, /position\.availablePackages - position\.pickedPackages/)
for (const page of [dispatch, outbound]) assert.match(page, /await get(?:Dispatch|Outbound)Task[\s\S]*releaseFulfillmentKey/)
assert.doesNotMatch(dispatch + outbound, /DeliveryOrder|deliveryStatus|customerVisit|\/deliveries/)
}) })
test('fulfillment workbench entries open task lists before details and keep explicit cross-workflow resume', async () => { test('fulfillment workbench entries open task lists before details and keep explicit cross-workflow resume', async () => {
@@ -237,21 +299,26 @@ test('receiving conflict recovery keeps request and trace evidence visible on na
assert.match(operation, /\.error-card text\{overflow-wrap:anywhere;word-break:break-word\}/) assert.match(operation, /\.error-card text\{overflow-wrap:anywhere;word-break:break-word\}/)
}) })
test('native action gates reject strings and retain server block reasons', () => { test('dispatch review gate preserves the server decision without treating a string or missing value as approval', async () => {
for (const allowed of [true, false, undefined, 'true']) { assert.equal(normalizeDispatchTask({ canPick: true }).canPick, true)
const task=normalizeDispatchTask({dispatchNo:'DS-1',items:[],actions:{PICK:{allowed,blockReason:'审核未通过'}}}) for (const canPick of [false, undefined, 'true']) assert.equal(normalizeDispatchTask({ canPick }).canPick, false)
assert.equal(task.actions.PICK.allowed,allowed===true); assert.equal(task.actions.PICK.blockReason,'审核未通过') 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) { async function dispatchScanHarness(items) {
const source = (await read('../src/pages/dispatch/dispatch.vue')).match(/<script>([\s\S]*?)<\/script>/)[1] 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 draft = { palletCode: 'OLD', dispatchItemId: 'old', cartonCode: '' }; let sounds = 0
const deps={PortalHeader:{},ScanTargetInput:{},DispatchHandoverStatus:{},DispatchLoadingDocuments:{},fulfillment:{dispatch:draft},updateFulfillmentDraft:(_,value)=>Object.assign(draft,value),playSuccessSound:()=>{sounds++}} const deps = { PortalHeader: {}, ScanTargetInput: {}, DispatchHandoverStatus: {},
const definition=new Function(...Object.keys(deps),source.replace(/^\s*import .*$/gm,'').replace('export default','return'))(...Object.values(deps)) updateFulfillmentDraft: (_, value) => Object.assign(draft, value), playSuccessSound: () => { sounds++ },
const state={activeTab:'picking',current:{dispatchNo:'DS-TEST',stage:'LOADING',actions:{PICK:{allowed:true}},items:items.map(item=>({sourceKey:`source-${item.dispatchItemId}`,sourceBusinessType:'RELABEL_ORDER',sourceBusinessNo:`RL-${item.dispatchItemId}`,positions:[],...item,returnCartons:item.returnCartons||[]}))},error:{message:'previous error'},successMessage:'',setError(error){this.error={message:error.message}}} console: { info() {} } }
state.gate=definition.methods.gate.bind(state);state.selectPallet=definition.methods.selectPallet.bind(state);state.chooseSource=definition.methods.chooseSource.bind(state) const definition = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
return {state,draft,scan:definition.methods.handleScan.bind(state),sounds:()=>sounds} 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 () => { test('dispatch wrong scan clears prior selection and a valid pallet clears the old error', async () => {
+47 -20
View File
@@ -1,28 +1,43 @@
import test from 'node:test' import test from 'node:test'
import { runInNewContext } from 'node:vm' import { runInNewContext } from 'node:vm'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFile, readdir } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
const read = path => readFile(new URL(path, import.meta.url), 'utf8') const read = path => readFile(new URL(path, import.meta.url), 'utf8')
test('PDA H5 has no container publishing entry', async () => { test('H5 image copies only compiled assets and static nginx configuration', async () => {
const files = await readdir(new URL('../deploy/', import.meta.url)) const dockerfile = await read('../deploy/Dockerfile.h5')
assert.deepEqual(files.filter(name => /docker|compose/i.test(name)), []) const copies = dockerfile.split(/\r?\n/).filter(line => line.startsWith('COPY '))
await assert.rejects(read('../.dockerignore'), { code: 'ENOENT' }) 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 () => {
test('host nginx serves uploaded files and proxies only the API prefix', async () => { const nginx = await read('../deploy/nginx.conf')
const nginx = await read('../deploy/nginx.conf.template') assert.match(nginx, /listen 80;/)
assert.ok(nginx.includes('root ${PDA_SITE_ROOT};')) assert.ok(nginx.includes('location /'))
const page = nginx.match(/location \/ \{([^{}]*)\}/)?.[1] assert.match(nginx, /root \/srv\/pda-h5;/)
assert.ok(page) assert.match(nginx, /try_files \$uri \$uri\/ =404;/)
assert.match(page, /try_files \$uri \$uri\/ =404;/) assert.match(nginx, /Cache-Control "no-store" always/)
assert.match(page, /Cache-Control "no-store" always/) assert.doesNotMatch(nginx, /pda-domestic|pda-overseas/)
assert.doesNotMatch(page, /proxy_pass|try_files[^;]*index\.html/) assert.match(nginx, /location = \/healthz/)
assert.match(nginx, /location \^~ \/gateway-api\//) assert.doesNotMatch(nginx, /proxy_pass|gateway-api|try_files[^;]*index\.html/)
assert.ok(nginx.includes('proxy_pass ${PDA_GATEWAY_ORIGIN}/api/;')) })
assert.equal((nginx.match(/proxy_pass/g) || []).length, 1) test('TEST H5 deployment has a required image tag and internal-only dual health checks', async () => {
assert.match(nginx, /proxy_set_header Authorization \$http_authorization;/) const compose = await read('../deploy/docker-compose.h5.test.yml')
assert.match(nginx, /client_max_body_size 55m;/) assert.ok(compose.includes('${IMAGE_TAG:?IMAGE_TAG is required}'))
assert.doesNotMatch(nginx, /29005|29006|\/srv\/pda-h5|gateway:9000/) 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 () => { test('only H5 production overrides the toolchain asset base', async () => {
@@ -57,3 +72,15 @@ test('H5 proxy uses the shared gateway prefix and preserves API suffixes', async
assert.equal(proxy['/gateway-api'].rewrite(`/gateway-api${suffix}`), `/api${suffix}`) assert.equal(proxy['/gateway-api'].rewrite(`/gateway-api${suffix}`), `/api${suffix}`)
} }
}) })
test('gray H5 runs on an isolated network with same-origin Gateway API', async () => {
const compose = await read('../deploy/docker-compose.h5.production.yml')
const nginx = await read('../deploy/nginx.production.conf.template')
const docker = await read('../deploy/Dockerfile.h5.production')
assert.match(compose, /name: firstleg-production-network/)
assert.match(compose, /127\.0\.0\.1:/)
assert.doesNotMatch(compose, /test-network|gateway-overseas/)
assert.match(nginx, /location \/gateway-api\//)
assert.ok(nginx.includes('proxy_pass ${API_UPSTREAM}/api/;'))
assert.match(docker, /COPY deploy\/nginx.production.conf.template \/etc\/nginx\/templates\/default.conf.template/)
})
+3 -3
View File
@@ -18,18 +18,18 @@ async function vueFiles(directory) {
return files return files
} }
test('every editable PDA input and textarea provides a label or input hint', async () => { test('every editable PDA input and textarea provides an input hint', async () => {
const missing = [] const missing = []
for (const file of await vueFiles(sourceRoot)) { for (const file of await vueFiles(sourceRoot)) {
const source = await readFile(file, 'utf8') const source = await readFile(file, 'utf8')
for (const match of source.matchAll(/<(input|textarea)\b[^>]*>/gs)) { for (const match of source.matchAll(/<(input|textarea)\b[^>]*>/gs)) {
if (!/\s:?(?:placeholder|aria-label)\s*=/.test(match[0])) { if (!/\s:?placeholder\s*=/.test(match[0])) {
const line = source.slice(0, match.index).split('\n').length const line = source.slice(0, match.index).split('\n').length
missing.push(`${relative(sourceRoot, file)}:${line}`) missing.push(`${relative(sourceRoot, file)}:${line}`)
} }
} }
} }
assert.deepEqual(missing, [], `缺少输入标签或提示:${missing.join(', ')}`) assert.deepEqual(missing, [], `缺少 placeholder:${missing.join(', ')}`)
}) })
test('quantity inputs explain the valid package range', async () => { test('quantity inputs explain the valid package range', async () => {
+2 -1
View File
@@ -108,7 +108,7 @@ test('container UI locks and signs once, then renders the resolved destination g
for (const fact of ['已分配', '未分配', '可合托·非强制', '不合托', '加急', '普通']) assert.match(summary, new RegExp(fact)) for (const fact of ['已分配', '未分配', '可合托·非强制', '不合托', '加急', '普通']) assert.match(summary, new RegExp(fact))
assert.doesNotMatch(operation, /v-for="[^"]*inbound[^"]*"/) assert.doesNotMatch(operation, /v-for="[^"]*inbound[^"]*"/)
assert.match(operation, /v-if="activeInbound"[\s\S]*activeInbound\.inboundCodes[\s\S]*activeInbound\.pendingPackages/) assert.match(operation, /v-if="activeInbound"[\s\S]*activeInbound\.inboundCodes[\s\S]*activeInbound\.pendingPackages/)
assert.match(operation, /class="mode-banner"[^\n]*activeInbound\.destinationType === 'FBA'[^\n]*FBA 按箱数收货[^\n]*海外仓逐箱收货/) assert.match(operation, /activeInbound\.operationMode === 'FBA_GROUP'[\s\S]*按 FBA 仓成组收货/)
assert.doesNotMatch(operation, /activeInbound\.customer|客户/) assert.doesNotMatch(operation, /activeInbound\.customer|客户/)
assert.match(operation, /resolveCurrentCarton\(cartonMark\)/) assert.match(operation, /resolveCurrentCarton\(cartonMark\)/)
assert.match(operation, /operationMode !== 'DROPSHIP_PALLET'[\s\S]*receiveCurrentWholePallet/) assert.match(operation, /operationMode !== 'DROPSHIP_PALLET'[\s\S]*receiveCurrentWholePallet/)
@@ -121,6 +121,7 @@ test('container UI locks and signs once, then renders the resolved destination g
assert.match(operation, /applyWarehouseProfile\(await getCurrentWarehouseProfile\(\)\)/) assert.match(operation, /applyWarehouseProfile\(await getCurrentWarehouseProfile\(\)\)/)
assert.match(operation, /values\.map\(\(item\) => this\.validateReceivingWarehouse/) assert.match(operation, /values\.map\(\(item\) => this\.validateReceivingWarehouse/)
assert.match(operation, /prepareReceivingContainer[\s\S]*validateReceivingWarehouse/) assert.match(operation, /prepareReceivingContainer[\s\S]*validateReceivingWarehouse/)
assert.match(summary, /overseasWarehouseCode[\s\S]*overseasWarehouseName/)
for (const forbidden of ['customerId', 'planNo', 'allocationId', 'warehouseId', 'locationId']) { for (const forbidden of ['customerId', 'planNo', 'allocationId', 'warehouseId', 'locationId']) {
assert.doesNotMatch(inboundApi, new RegExp(forbidden)) assert.doesNotMatch(inboundApi, new RegExp(forbidden))
} }
-48
View File
@@ -1,12 +1,7 @@
import test from 'node:test' import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { MODULES } from '../src/data/overseas-workbench.js'
import { navigateBackOr, navigateToUnique } from '../src/utils/navigation.js' import { navigateBackOr, navigateToUnique } from '../src/utils/navigation.js'
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
const component = (source, dependencies) => new Function(...Object.keys(dependencies), source.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(dependencies))
test('navigateBackOr returns through the stack and falls back when the stack is empty', () => { test('navigateBackOr returns through the stack and falls back when the stack is empty', () => {
const calls = [] const calls = []
globalThis.uni = { globalThis.uni = {
@@ -41,46 +36,3 @@ test('navigateToUnique does not stack the current utility page twice', () => {
navigateToUnique('/pages/history/history') navigateToUnique('/pages/history/history')
assert.deepEqual(calls, [{ url: '/pages/history/history' }]) assert.deepEqual(calls, [{ url: '/pages/history/history' }])
}) })
test('every registered page header returns home after a reload without page history', async (t) => {
const previousUni = globalThis.uni
const previousPages = globalThis.getCurrentPages
t.after(() => { globalThis.uni = previousUni; globalThis.getCurrentPages = previousPages })
const { pages } = JSON.parse(await read('../src/pages.json'))
const header = component(await read('../src/components/PortalHeader.vue'), { PopupHeader: {}, navigateBackOr, navigateToUnique })
const calls = []
globalThis.uni = { reLaunch: (options) => calls.push(options), navigateBack: () => assert.fail('a reloaded page has no back stack') }
let checked = 0
for (const { path } of pages) {
const source = await read(`../src/${path}.vue`)
const tag = source.match(/<PortalHeader\b[^>]*\/>/)?.[0]
if (!tag) continue
assert.doesNotMatch(tag, /:fallback-url=/, path)
const fallbackUrl = tag.match(/\bfallback-url="([^"]*)"/)?.[1] || header.props.fallbackUrl.default
assert.equal(fallbackUrl, '/pages/home/home', path)
globalThis.getCurrentPages = () => [{ route: path }]
header.methods.goBack.call({ fallbackUrl })
assert.deepEqual(calls.at(-1), { url: '/pages/home/home' }, path)
checked++
}
assert.ok(checked > 0)
assert.equal(calls.length, checked)
})
test('home opens every enabled operation directly on a registered page', async (t) => {
const previousUni = globalThis.uni
t.after(() => { globalThis.uni = previousUni })
const { pages } = JSON.parse(await read('../src/pages.json'))
const registered = new Set(pages.map(({ path }) => `/${path}`))
const home = component(await read('../src/pages/home/home.vue'), { PortalHeader: {}, MODULES })
const calls = []
globalThis.uni = { navigateTo: (options) => calls.push(options) }
for (const group of MODULES) {
for (const operation of group.operations.filter((item) => item.available)) {
const count = calls.length
home.methods.openOperation.call({ groups: MODULES }, operation)
assert.equal(calls.length, count + 1, operation.name)
assert.ok(registered.has(calls.at(-1).url.split('?')[0]), operation.name)
}
}
})
+3 -335
View File
@@ -7,9 +7,9 @@ const deferred = () => { let resolve; const promise = new Promise(done => { reso
function page(overrides = {}) { function page(overrides = {}) {
const calls = [] const calls = []
const deps = { PortalHeader: {}, ScanTargetInput: {}, session: { currentUser: {} }, fulfillment: { outbound: { packages: 1, currentTaskNo: 'OLD-TASK' } }, const deps = { PortalHeader: {}, ScanTargetInput: {}, session: { currentUser: {} }, fulfillment: { outbound: { packages: 1, currentTaskNo: 'OLD-TASK' } },
updateFulfillmentDraft() {}, fulfillmentIdempotencyKey: () => ({ value: 'key' }), updateFulfillmentDraft() {}, fulfillmentIdempotencyKey: () => ({ value: 'key', signature: 'signature' }), releaseFulfillmentKey() {},
playErrorSound() {}, playSuccessSound() {}, pickOutboundPallet: async () => calls.push('pick'), completeOutboundTask: async () => calls.push('complete'), playErrorSound() {}, playSuccessSound() {}, pickOutboundPallet: async () => calls.push('pick'), completeOutboundTask: async () => calls.push('complete'),
listCargoDiscardPhotos: async () => [], getOutboundTask: async () => ({ taskNo: 'TASK', status: 'COMPLETED', items: [] }), normalizeCargoDiscardTask: value => value, resolveCargoDiscardPallet: async code => vm.discardTasks.find(task => task.palletNo === code || task.cartonMarks?.includes(code)), listCargoDiscardPhotos: async () => [], getOutboundTask: async () => ({ taskNo: 'TASK', status: 'COMPLETED', items: [] }), normalizeCargoDiscardTask: value => value,
...overrides } ...overrides }
const definition = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps)) const definition = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
const vm = definition.data() const vm = definition.data()
@@ -18,81 +18,9 @@ function page(overrides = {}) {
return { vm, calls, definition } return { vm, calls, definition }
} }
test('scanning a discard outbound document opens the discard workflow with its linked number', () => {
const { vm } = page()
vm.discardTasks = [{ discardNo: 'CD-1', outboundNo: 'OB-1', palletNo: 'P-1', status: 'PENDING' }]
vm.openScannedTask = () => { throw new Error('must not enter regular inventory picking') }
vm.handleScan('OB-1')
assert.equal(vm.discardCurrent.discardNo, 'CD-1')
assert.equal(vm.discardCurrent.outboundNo, 'OB-1')
})
test('退货废弃一箱五件分别展示,混装SKU按箱唛去重', () => {
const { vm } = page()
vm.discardTask = { cartonMarks: ['RETURN-BOX-1'], skus: [{ quantityUnit: '件', plannedPackages: 5 }] }
assert.equal(vm.discardQuantitySummary, '1 箱 · 5 件')
vm.discardTask.skus = [{ quantityUnit: '件', plannedPackages: 2 }, { quantityUnit: '件', plannedPackages: 3 }]
assert.equal(vm.discardQuantitySummary, '1 箱 · 5 件')
vm.discardTask = { cartonMarks: ['BOX-1', 'BOX-1'], skus: [{ quantityUnit: '箱', plannedPackages: 1 }] }
assert.equal(vm.discardQuantitySummary, '1 箱')
})
test('discard pallet resolution passes the selected discard number', async () => {
let received
const { vm } = page({ resolveCargoDiscardPallet: async (...args) => {
received = args
return { discardNo: 'CD-2', palletNo: 'P-1', skus: [] }
} })
vm.discardCurrent = { discardNo: 'CD-2' }
await vm.resolveDiscardPallet('P-1')
assert.deepEqual(received, ['P-1', 'CD-2'])
})
test('点击打开废弃作业使用输入框值,不把 tap 事件当作托盘号', async () => {
let received
const { vm } = page({ resolveCargoDiscardPallet: async (...args) => {
received = args
return { discardNo: 'CD-2', palletNo: 'P-1', skus: [] }
} })
vm.discardCurrent = { discardNo: 'CD-2' }
vm.discardPalletCode = ' P-1 '
await vm.resolveDiscardPallet({ type: 'tap', detail: { x: 10, y: 20 } })
assert.deepEqual(received, ['P-1', 'CD-2'])
assert.equal(vm.discardPalletCode, 'P-1')
})
test('同单按箱废弃允许扫描当前托盘及其他托盘所选箱唛,范围不扩展到其他单据', async () => {
const requests = []
const tasks = [
{ discardNo: 'CD-1', palletNo: 'P-1', scanMode: 'CARTON', cartonMarks: ['BOX-1'], skus: [], status: 'PENDING' },
{ discardNo: 'CD-1', palletNo: 'P-2', scanMode: 'CARTON', cartonMarks: ['BOX-2'], skus: [], status: 'PENDING' },
{ discardNo: 'CD-OTHER', palletNo: 'P-3', scanMode: 'CARTON', cartonMarks: ['BOX-3'], skus: [], status: 'PENDING' },
]
const { vm } = page({ resolveCargoDiscardPallet: async code => {
requests.push(code)
return tasks.find(task => task.palletNo === code || task.cartonMarks.includes(code))
} })
vm.discardTasks = tasks
vm.openDiscardOrder('CD-1')
await Promise.resolve()
assert.equal(vm.discardCurrentTasks.length, 2)
await vm.resolveDiscardPallet('P-1')
assert.equal(vm.error, null)
await vm.resolveDiscardPallet('BOX-2')
assert.equal(vm.discardTask.palletNo, 'P-1')
await vm.resolveDiscardPallet('P-2')
await vm.resolveDiscardPallet('BOX-2')
assert.equal(vm.discardTask.palletNo, 'P-2')
assert.deepEqual(vm.discardScannedMarks, ['BOX-2'])
await vm.resolveDiscardPallet('BOX-3')
assert.deepEqual(requests, ['P-1', 'P-2'])
assert.equal(vm.discardTask.palletNo, 'P-2')
assert.deepEqual(vm.discardScannedMarks, ['BOX-2'])
})
test('discard requires restored photos and retains task while native camera returns', async () => { test('discard requires restored photos and retains task while native camera returns', async () => {
const { vm, definition } = page({ requireSession: () => true, restoreFulfillmentDraft() {} }) 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: 3 } 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) assert.equal(vm.canConfirmDiscard, false)
vm.discardPhotoReady = true; assert.equal(vm.canConfirmDiscard, false) vm.discardPhotoReady = true; assert.equal(vm.canConfirmDiscard, false)
vm.discardPhotos = [{ fileId: '101' }]; assert.equal(vm.canConfirmDiscard, true) vm.discardPhotos = [{ fileId: '101' }]; assert.equal(vm.canConfirmDiscard, true)
@@ -185,45 +113,6 @@ test('discard confirmation is guarded against double submission', async () => {
pending.resolve(); await Promise.all([first, second]); assert.equal(submits, 1) pending.resolve(); await Promise.all([first, second]); assert.equal(submits, 1)
}) })
test('无关联库存按现场箱唛去重计数,提交扫描清单而非手填数量', async () => {
let payload
const { vm } = page({ confirmCargoDiscard: async (...args) => { payload = args }, listCargoDiscardTasks: async () => [] })
vm.discardCurrent = { discardNo: 'CD-A' }
vm.discardTask = { palletNo: 'P-A', status: 'PENDING', scanMode: 'PALLET', cartonMarks: [], skus: [{ itemId: 'sku', plannedPackages: 10, actualQuantityEditable: true }] }
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
for (const value of ['', ' ', null, -1, 11, 1.5, 'abc']) {
vm.discardActuals = { sku: value }; assert.equal(vm.canConfirmDiscard, false)
}
vm.discardActuals = { sku: '0' }; assert.equal(vm.canConfirmDiscard, true)
vm.discardActuals = { sku: '6' }; assert.equal(vm.canConfirmDiscard, false)
vm.discardActuals = { sku: 0 }; for (let i=0;i<6;i++) await vm.scanDiscardCarton('FIELD-'+i)
await vm.scanDiscardCarton('FIELD-0'); assert.equal(vm.discardActuals.sku, 6); assert.equal(vm.canConfirmDiscard, true)
await vm.confirmDiscard()
assert.equal(payload[0], 'CD-A'); assert.equal(payload[1], 'P-A')
assert.deepEqual(payload[2], [{ itemId: 'sku', actualPackages: 6, scannedCartonMarks: Array.from({ length: 6 }, (_, i) => 'FIELD-'+i) }])
})
test('无关联托盘扫码后从零开始,等待扫描现场箱唛', async () => {
const { vm } = page({ resolveCargoDiscardPallet: async () => ({ discardNo: 'CD-A', palletNo: 'P-A', cartonMarks: [], scanMode: 'PALLET', skus: [{ itemId: 'sku', plannedPackages: 10, actualQuantityEditable: true }] }) })
vm.discardCurrent = { discardNo: 'CD-A' }
await vm.resolveDiscardPallet('P-A')
assert.equal(vm.discardActuals.sku, 0)
assert.equal(vm.canConfirmDiscard, false)
})
test('无关联任务先扫托盘,再扫现场箱唛自动计数', async () => {
const { vm } = page()
vm.discardTasks = [{ discardNo: 'CD-NO-BOX', palletNo: 'P-1', status: 'PENDING', scanMode: 'PALLET', cartonMarks: [], skus: [{ itemId: 'sku', plannedPackages: 2, actualQuantityEditable: true }] }]
vm.openDiscardOrder('CD-NO-BOX'); await Promise.resolve()
assert.equal(vm.discardTask, null)
await vm.resolveDiscardPallet('P-1')
assert.equal(vm.discardTask.palletNo, 'P-1')
assert.equal(vm.discardActuals.sku, 0)
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
await vm.scanDiscardCarton('FIELD-A'); await vm.scanDiscardCarton('FIELD-B')
assert.equal(vm.discardActuals.sku, 2); assert.equal(vm.canConfirmDiscard, true)
})
test('discard retry stays in the discard workflow instead of restoring an old outbound task', async () => { test('discard retry stays in the discard workflow instead of restoring an old outbound task', async () => {
const { vm } = page(); const calls = [] const { vm } = page(); const calls = []
vm.discardCurrent = { discardNo: 'DISCARD' }; vm.discardPalletCode = 'PALLET' vm.discardCurrent = { discardNo: 'DISCARD' }; vm.discardPalletCode = 'PALLET'
@@ -240,224 +129,3 @@ test('closing or switching discard order ignores the previous resolve response',
pending.resolve({ discardNo: 'FIRST', palletNo: 'PALLET', skus: [] }); await first pending.resolve({ discardNo: 'FIRST', palletNo: 'PALLET', skus: [] }); await first
assert.equal(vm.discardTask, null); assert.equal(vm.error, null) assert.equal(vm.discardTask, null); assert.equal(vm.error, null)
}) })
test('loose disposal opens all marks and requires every carton scan', async () => {
const { vm } = page()
vm.discardTasks = [{ discardNo: 'CD-L', palletNo: '散箱', scanMode: 'CARTON', cartonMarks: ['BOX-1', 'BOX-2'], status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 2 }] }]
vm.openDiscardOrder('CD-L')
assert.equal(vm.discardTask, null)
await vm.resolveDiscardPallet('BOX-1')
assert.equal(vm.discardTask.scanMode, 'CARTON')
assert.deepEqual(vm.discardTask.cartonMarks, ['BOX-1', 'BOX-2'])
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
assert.equal(vm.canConfirmDiscard, false)
await vm.resolveDiscardPallet('BOX-1'); await vm.resolveDiscardPallet('BOX-1')
assert.deepEqual(vm.discardScannedMarks, ['BOX-1'])
assert.equal(vm.canConfirmDiscard, false)
await vm.resolveDiscardPallet('BOX-2')
assert.equal(vm.canConfirmDiscard, true)
vm.discardActuals.sku = 1; assert.equal(vm.canConfirmDiscard, false)
vm.discardActuals.sku = 2; vm.discardScannedMarks = []; vm.discardAllCartons = true
assert.equal(vm.canConfirmDiscard, false)
vm.discardPhotos = []; assert.equal(vm.canConfirmDiscard, false)
})
test('partial bound pallet confirmation sends only selected marks and preserves all photo gates', async () => {
let payload
const { vm } = page({ confirmCargoDiscard: async (...args) => { payload = args }, listCargoDiscardTasks: async () => [] })
vm.discardTasks = [{ discardNo: 'CD-P', outboundNo: 'OB-P', palletNo: 'P-1', scanMode: 'CARTON', cartonMarks: ['BOX-2'], status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }]
vm.openDiscardOrder('CD-P'); await Promise.resolve()
assert.equal(vm.discardTask, null)
await vm.resolveDiscardPallet('P-1')
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
await vm.resolveDiscardPallet('BOX-2'); assert.equal(vm.canConfirmDiscard, true)
await vm.resolveDiscardPallet('BOX-OTHER'); assert.deepEqual(vm.discardScannedMarks, ['BOX-2']); assert.equal(vm.discardTask.palletNo, 'P-1')
await vm.confirmDiscard()
assert.deepEqual(payload, ['CD-P', 'P-1', [{ itemId: 'sku', actualPackages: 1 }], ['101'], ['BOX-2'], false, { idempotencyKey: 'key' }])
})
test('discard confirmation preserves inputs after stock command failure and starts a new explicit request', async () => {
const submitted = []
const retry = deferred()
let attempt = 0
const { vm } = page({
confirmCargoDiscard: async (...args) => { submitted.push(args); if (++attempt === 1) throw new Error('库存状态已变化'); await retry.promise },
fulfillmentIdempotencyKey: () => ({ value: `discard-${attempt}` }), listCargoDiscardTasks: async () => []
})
vm.discardCurrent = { discardNo: 'CD-RETRY' }
vm.discardTask = { palletNo: 'P-1', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }
vm.discardTasks = [{ ...vm.discardTask, discardNo: 'CD-RETRY' }]
vm.discardActuals = { sku: 1 }; vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
await vm.confirmDiscard()
assert.equal(vm.discardBusy, false)
assert.ok(vm.discardTask)
assert.equal(vm.error.message, '库存状态已变化')
vm.successMessage = '上次作业已完成'
const retried = vm.confirmDiscard()
assert.equal(vm.error, null)
assert.equal(vm.successMessage, '')
retry.resolve(); await retried
assert.deepEqual(submitted[0].slice(0, 6), submitted[1].slice(0, 6))
assert.notEqual(submitted[0][6].idempotencyKey, submitted[1][6].idempotencyKey)
assert.equal(vm.discardBusy, false)
assert.equal(vm.error, null)
assert.match(vm.successMessage, /已完成/)
})
test('dedicated scanner validates carton scope and preserves photos', () => {
const { vm } = page()
vm.discardTask = { palletNo: 'P-1', scanMode: 'CARTON', cartonMarks: ['BX-1', 'BX-2'], status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 2 }] }
vm.discardActuals = { sku: 2 }; vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
assert.deepEqual(vm.discardPendingMarks, ['BX-1', 'BX-2'])
assert.deepEqual(vm.discardQuantityLines, [])
vm.discardCartonCode = ' BX-1 '; vm.scanDiscardCarton()
assert.equal(vm.discardCartonCode, '')
vm.scanDiscardCarton('BX-1'); vm.scanDiscardCarton('OTHER')
assert.deepEqual(vm.discardScannedMarks, ['BX-1']); assert.equal(vm.canConfirmDiscard, false)
assert.deepEqual(vm.discardPendingMarks, ['BX-2'])
assert.match(vm.error.message, /箱号不属于/)
vm.discardCartonsExpanded = false
vm.scanDiscardCarton('BX-2'); assert.equal(vm.canConfirmDiscard, true)
assert.deepEqual(vm.discardPendingMarks, [])
assert.equal(vm.discardCartonsExpanded, false)
assert.deepEqual(vm.discardPhotos, [{ fileId: '101' }]); assert.equal(vm.discardTask.palletNo, 'P-1')
vm.discardTask.status = 'COMPLETED'; vm.scanDiscardCarton('OTHER'); assert.equal(vm.error, null)
})
test('bound discard opens only after its pallet scan and summary hides carton numbers', async () => {
const { vm } = page()
vm.discardTasks = [{ discardNo: 'CD-1', palletNo: 'P-1', status: 'PENDING', scanMode: 'CARTON', cartonMarks: ['BX-1'], skus: [{ itemId: 'sku', plannedPackages: 1 }] }]
vm.openDiscardOrder('CD-1'); assert.equal(vm.discardTask, null)
await vm.resolveDiscardPallet('BX-1'); assert.equal(vm.discardTask, null)
await vm.resolveDiscardPallet('OTHER'); assert.equal(vm.discardTask, null)
await vm.resolveDiscardPallet('P-1'); assert.equal(vm.discardTask.palletNo, 'P-1')
assert.deepEqual(vm.discardScannedMarks, [])
assert.deepEqual(vm.discardTask.cartonMarks, ['BX-1'])
const template = readFileSync(new URL('../src/pages/outbound/outbound.vue', import.meta.url), 'utf8')
const summary = template.slice(template.indexOf('废弃箱关联托盘'), template.indexOf('<ScanTargetInput name="cargo-discard"'))
assert.doesNotMatch(summary, /task.cartonMarks/)
})
test('multi-pallet discard prompts remaining pallet numbers and requires a new pallet scan', async () => {
const first = { discardNo: 'CD-M', palletNo: 'P-1', status: 'PENDING', scanMode: 'CARTON', cartonMarks: ['BX-1'], skus: [{ itemId: 'sku1', plannedPackages: 1 }] }
const second = { ...first, palletNo: 'P-2', cartonMarks: ['BX-2'], skus: [{ itemId: 'sku2', plannedPackages: 1 }] }
const other = { ...first, discardNo: 'CD-OTHER', palletNo: 'P-OTHER' }
let completed = 0
const { vm } = page({ confirmCargoDiscard: async () => { completed++ }, listCargoDiscardTasks: async () => [{ ...first, status: 'COMPLETED' }, { ...second, status: completed === 2 ? 'COMPLETED' : 'PENDING' }, other] })
vm.discardTasks = [first, second, other]; vm.openDiscardOrder('CD-M')
await vm.resolveDiscardPallet('P-1'); vm.scanDiscardCarton('BX-1')
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
await vm.confirmDiscard()
assert.equal(vm.discardTask, null); assert.equal(vm.discardPalletCode, '')
assert.deepEqual(vm.discardPendingTasks.map(task => task.palletNo), ['P-2'])
assert.match(vm.successMessage, /P-1.*已完成.*P-2.*继续扫描/)
assert.doesNotMatch(vm.successMessage, /P-OTHER|全部/)
assert.equal(vm.canConfirmDiscard, false)
await vm.resolveDiscardPallet('P-2'); assert.equal(vm.successMessage, '')
assert.deepEqual(vm.discardPendingMarks, ['BX-2']); assert.deepEqual(vm.discardPhotos, [])
vm.scanDiscardCarton('BX-2'); vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '102' }]
await vm.confirmDiscard()
assert.deepEqual(vm.discardPendingTasks, []); assert.match(vm.successMessage, /本单全部.*已完成/)
})
test('successful discard followed by refresh failure does not claim the whole order completed', async () => {
let confirms = 0
const { vm } = page({ confirmCargoDiscard: async () => { confirms++ }, listCargoDiscardTasks: async () => { throw new Error('offline') } })
const first = { discardNo: 'CD-M', palletNo: 'P-1', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }
vm.discardTasks = [first, { ...first, palletNo: 'P-2' }]; vm.openDiscardOrder('CD-M')
await vm.resolveDiscardPallet('P-1'); vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
await vm.confirmDiscard(); await vm.confirmDiscard()
assert.equal(confirms, 1); assert.equal(vm.discardTask, null)
assert.match(vm.successMessage, /P-1.*已完成.*刷新失败/); assert.doesNotMatch(vm.successMessage, /全部/)
assert.deepEqual(vm.discardPendingTasks.map(task => task.palletNo), ['P-2'])
})
test('仓租余额不足显示原因并保留当前托盘、已扫箱号、实际数与现场照片', async () => {
const { vm } = page({ confirmCargoDiscard: async () => { throw Object.assign(new Error('仓租校验未通过:可用余额和授信额度不足'), { code: 'A0409' }) } })
vm.discardCurrent = { discardNo: 'CD-1' }
vm.discardTask = { palletNo: 'P-1', scanMode: 'CARTON', status: 'PENDING', cartonMarks: ['BX-1'], skus: [{ itemId: 'sku', plannedPackages: 1 }] }
vm.discardActuals = { sku: 1 }; vm.discardScannedMarks = ['BX-1']
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
await vm.confirmDiscard()
assert.match(vm.error.message, /余额和授信额度不足/); assert.equal(vm.error.code, 'A0409')
assert.equal(vm.discardTask.palletNo, 'P-1'); assert.deepEqual(vm.discardScannedMarks, ['BX-1'])
assert.deepEqual(vm.discardActuals, { sku: 1 }); assert.deepEqual(vm.discardPhotos, [{ fileId: '101' }])
assert.equal(vm.successMessage, ''); assert.equal(vm.discardBusy, false)
})
for (const action of ['pallet', 'carton', 'retry']) {
test(`出库资金不足后通过 ${action} 重新核对并再次手动提交`, async () => {
let submits = 0; let reads = 0; let key = 0; const keys = []
const task = { discardNo: 'CD-1', palletNo: 'P-1', status: 'PENDING', scanMode: 'CARTON', cartonMarks: ['BX-1'], skus: [{ itemId: 'sku', plannedPackages: 1 }] }
const { vm } = page({
resolveCargoDiscardPallet: async () => { reads++; return { ...task } },
confirmCargoDiscard: async (...args) => { keys.push(args.at(-1).idempotencyKey); if (++submits === 1) throw new Error('可用余额和授信额度不足') },
fulfillmentIdempotencyKey: () => ({ value: `key-${++key}` }), listCargoDiscardTasks: async () => []
})
vm.discardCurrent = { discardNo: 'CD-1' }; vm.discardTask = task; vm.discardTasks = [task]
vm.discardActuals = { sku: 1 }; vm.discardScannedMarks = ['BX-1']
vm.discardPhotos = [{ fileId: '101' }]; vm.discardPhotoReady = true
await vm.confirmDiscard(); assert.equal(vm.discardNeedsRefresh, true)
if (action === 'pallet') await vm.resolveDiscardPallet('P-1')
else if (action === 'carton') await vm.scanDiscardCarton('BX-1')
else await vm.retry()
assert.equal(reads, 1); assert.equal(submits, 1); assert.equal(vm.error, null)
assert.equal(vm.discardNeedsRefresh, false); assert.deepEqual(vm.discardScannedMarks, ['BX-1'])
assert.deepEqual(vm.discardActuals, { sku: 1 }); assert.deepEqual(vm.discardPhotos, [{ fileId: '101' }])
await vm.confirmDiscard(); assert.equal(submits, 2); assert.notEqual(keys[0], keys[1])
assert.match(vm.successMessage, /全部.*已完成/)
})
}
test('结果不明后刷新发现已完成,不再重复出库', async () => {
let submits = 0
const task = { discardNo: 'CD-1', palletNo: 'P-1', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }
const { vm } = page({ confirmCargoDiscard: async () => { submits++; throw Object.assign(new Error('网络中断'), { network: true }) }, resolveCargoDiscardPallet: async () => ({ ...task, status: 'COMPLETED' }) })
vm.discardCurrent = { discardNo: 'CD-1' }; vm.discardTask = task
vm.discardActuals = { sku: 1 }; vm.discardPhotos = [{ fileId: '101' }]; vm.discardPhotoReady = true
await vm.confirmDiscard(); await vm.confirmDiscard()
assert.equal(submits, 1); assert.equal(vm.discardTask.status, 'COMPLETED'); assert.equal(vm.canConfirmDiscard, false)
})
test('失败后刷新仍报错,保留草稿且不再发送出库', async () => {
let submits = 0
const { vm } = page({ confirmCargoDiscard: async () => { submits++; throw new Error('出库失败') }, resolveCargoDiscardPallet: async () => { throw new Error('任务读取失败') } })
vm.discardCurrent = { discardNo: 'CD-1' }; vm.discardTask = { palletNo: 'P-1', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }
vm.discardActuals = { sku: 1 }; vm.discardPhotos = [{ fileId: '101' }]; vm.discardPhotoReady = true
await vm.confirmDiscard(); await vm.confirmDiscard()
assert.equal(submits, 1); assert.match(vm.error.message, /任务读取失败/)
assert.equal(vm.discardNeedsRefresh, true); assert.equal(vm.discardBusy, false); assert.equal(vm.discardLoading, false)
assert.deepEqual(vm.discardPhotos, [{ fileId: '101' }])
})
test('无关联扫码显示备注,不能扫托盘或超过目标,失败刷新后保留现场箱唛再提交', async () => {
let attempts = 0
const task = { discardNo: 'CD-FIELD', palletNo: 'PALLET-FIELD', status: 'PENDING', scanMode: 'PALLET', cartonMarks: [], remark: '只废弃破损外箱', remainingPackages: 2, skus: [{ itemId: 'sku', plannedPackages: 8, actualQuantityEditable: true }] }
const { vm } = page({ confirmCargoDiscard: async () => { if (++attempts === 1) throw new Error('余额不足') }, listCargoDiscardTasks: async () => [] })
vm.discardTasks = [task]; vm.openDiscardOrder(task.discardNo); await vm.resolveDiscardPallet(task.palletNo)
assert.equal(vm.discardOrderRemark, '只废弃破损外箱')
await vm.scanDiscardCarton(task.palletNo); assert.equal(vm.discardActuals.sku, 0)
await vm.scanDiscardCarton('FIELD-A'); await vm.scanDiscardCarton('FIELD-B'); await vm.scanDiscardCarton('FIELD-C')
assert.equal(vm.discardActuals.sku, 2); assert.deepEqual(vm.discardUnboundScans.map(scan => scan.mark), ['FIELD-A', 'FIELD-B'])
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
await vm.confirmDiscard(); assert.equal(vm.discardNeedsRefresh, true)
await vm.resolveDiscardPallet(task.palletNo)
assert.equal(vm.discardActuals.sku, 2); assert.equal(vm.discardUnboundScans.length, 2)
assert.equal(vm.discardPhotos.length, 1); await vm.confirmDiscard(); assert.equal(attempts, 2)
})
test('已扫描现场箱唛可移除并同步回减数量,确认只提交剩余箱唛', async () => {
let payload
const task = { discardNo: 'CD-REMOVE', palletNo: 'P-REMOVE', status: 'PENDING', scanMode: 'PALLET', cartonMarks: [], remainingPackages: 4, skus: [{ itemId: 'sku', plannedPackages: 8, actualQuantityEditable: true }] }
const { vm } = page({ confirmCargoDiscard: async (...args) => { payload = args }, listCargoDiscardTasks: async () => [] })
vm.discardTasks = [task]; vm.openDiscardOrder(task.discardNo); await vm.resolveDiscardPallet(task.palletNo)
await vm.scanDiscardCarton('BOX-1'); await vm.scanDiscardCarton('BOX-2')
vm.removeDiscardScan('BOX-1')
assert.equal(vm.discardActuals.sku, 1)
assert.deepEqual(vm.discardUnboundScans.map(scan => scan.mark), ['BOX-2'])
vm.discardPhotoReady = true; vm.discardPhotos = [{ fileId: '101' }]
await vm.confirmDiscard()
assert.deepEqual(payload[2], [{ itemId: 'sku', actualPackages: 1, scannedCartonMarks: ['BOX-2'] }])
})
-43
View File
@@ -274,49 +274,6 @@ test('H5 blob photo uses authenticated multipart fetch instead of uni.uploadFile
} }
}) })
test('H5 XMLHttpRequest timeout and abort reject once so the page can retry without an automatic upload replay', async () => {
prepareSession()
const originals = { window: globalThis.window, location: globalThis.location, fetch: globalThis.fetch, XMLHttpRequest: globalThis.XMLHttpRequest }
globalThis.window = {}; globalThis.location = { protocol: 'http:' }
globalThis.fetch = async () => new Response(new Blob(['photo'], { type: 'image/png' }))
const requests = []
globalThis.XMLHttpRequest = class {
constructor() { requests.push(this) }
open() {} setRequestHeader() {}
send() { queueMicrotask(() => requests.length === 1 ? this.ontimeout() : this.onabort()) }
}
try {
await assert.rejects(overseasUpload('/pda/photo', 'blob:photo'), error => error.network && /超时/.test(error.message))
assert.equal(requests.length, 1); assert.equal(requests[0].timeout, 60000)
await assert.rejects(overseasUpload('/pda/photo', 'blob:photo'), error => error.network && /中断/.test(error.message))
assert.equal(requests.length, 2)
} finally {
for (const [key, value] of Object.entries(originals)) { if (value === undefined) delete globalThis[key]; else globalThis[key] = value }
}
})
test('H5 fetch upload aborts a stalled transfer, clears its timer, and permits a later upload', async () => {
prepareSession()
const originals = { window: globalThis.window, location: globalThis.location, fetch: globalThis.fetch, XMLHttpRequest: globalThis.XMLHttpRequest, setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout }
let timeoutCallback, posts = 0, cleared = 0
globalThis.window = {}; globalThis.location = { protocol: 'http:' }; delete globalThis.XMLHttpRequest
globalThis.setTimeout = (callback, delay) => { assert.equal(delay, 60000); timeoutCallback = callback; return 'upload-timer' }
globalThis.clearTimeout = handle => { assert.equal(handle, 'upload-timer'); cleared++ }
globalThis.fetch = async (url, options) => {
if (String(url).startsWith('blob:')) return new Response(new Blob(['photo'], { type: 'image/png' }))
if (++posts === 1) return new Promise((_resolve, reject) => { options.signal.addEventListener('abort', () => reject(new Error('aborted'))); queueMicrotask(timeoutCallback) })
return new Response(JSON.stringify({ code: '200', data: { fileId: 'retried-photo' } }), { status: 200 })
}
try {
await assert.rejects(overseasUpload('/pda/photo', 'blob:photo'), error => error.network && /超时/.test(error.message))
assert.equal(posts, 1); assert.equal(cleared, 1)
assert.deepEqual(await overseasUpload('/pda/photo', 'blob:photo'), { fileId: 'retried-photo' })
assert.equal(posts, 2); assert.equal(cleared, 2)
} finally {
for (const [key, value] of Object.entries(originals)) { if (value === undefined) delete globalThis[key]; else globalThis[key] = value }
}
})
test('H5 File photo is appended directly without rereading a temporary blob URL', async () => { test('H5 File photo is appended directly without rereading a temporary blob URL', async () => {
const value = prepareSession() const value = prepareSession()
const originalWindow = globalThis.window const originalWindow = globalThis.window
+3 -2
View File
@@ -5,13 +5,14 @@ import { readFile } from 'node:fs/promises'
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8') const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
test('pallet management is enabled through its dedicated real PDA route', async () => { test('pallet management is enabled through its dedicated real PDA route', async () => {
const [workbench, pages, home] = await Promise.all([ const [workbench, pages, home, module] = await Promise.all([
read('../src/data/overseas-workbench.js'), read('../src/pages.json'), read('../src/data/overseas-workbench.js'), read('../src/pages.json'),
read('../src/pages/home/home.vue') read('../src/pages/home/home.vue'), read('../src/pages/module/module.vue')
]) ])
assert.match(workbench, /key: 'pallet'[\s\S]*available: true[\s\S]*route: '\/pages\/pallet\/pallet'/) assert.match(workbench, /key: 'pallet'[\s\S]*available: true[\s\S]*route: '\/pages\/pallet\/pallet'/)
assert.match(pages, /pages\/pallet\/pallet/) assert.match(pages, /pages\/pallet\/pallet/)
assert.match(home, /operation\.route \|\|/) assert.match(home, /operation\.route \|\|/)
assert.match(module, /operation\.route \|\|/)
}) })
test('pallet API maps only the frozen overseas PDA operation endpoints', async () => { test('pallet API maps only the frozen overseas PDA operation endpoints', async () => {
+7 -7
View File
@@ -31,7 +31,7 @@ test('overseas workbench contains confirmed warehouse and phase-five outbound do
const [data, session, runtime] = await Promise.all([ const [data, session, runtime] = await Promise.all([
read('../src/data/overseas-workbench.js'), read('../src/stores/session.js'), read('../src/config/runtime.js') read('../src/data/overseas-workbench.js'), read('../src/stores/session.js'), read('../src/config/runtime.js')
]) ])
for (const label of ['入仓作业', '拆柜收货', '退货入库', '合箱', '出库履约', '拣货', '出库', '库内作业', '换标作业', '现场查询']) assert.match(data, new RegExp(label)) for (const label of ['入仓作业', '拆柜收货', '退货入库', '退货合箱', '出库履约', '排车拣货', '出库作业', '库内作业', '换标作业', '现场查询']) assert.match(data, new RegExp(label))
for (const excluded of ['集装箱拣货', '装柜取证', '海外派送', '派送单', '客户回访']) assert.doesNotMatch(data, new RegExp(excluded)) for (const excluded of ['集装箱拣货', '装柜取证', '海外派送', '派送单', '客户回访']) assert.doesNotMatch(data, new RegExp(excluded))
assert.match(session, /overseas-pda-auth-session/) assert.match(session, /overseas-pda-auth-session/)
assert.match(runtime, /VITE_API_BASE_URL/) assert.match(runtime, /VITE_API_BASE_URL/)
@@ -90,9 +90,9 @@ test('login has no committed demo credentials and inbound operation implements t
}) })
test('warehouse operations use dedicated real pages and contain no prototype task data', async () => { test('warehouse operations use dedicated real pages and contain no prototype task data', async () => {
const [operation, home, workbench, pages] = await Promise.all([ const [operation, home, module, workbench, pages] = await Promise.all([
read('../src/pages/operation/operation.vue'), read('../src/pages/home/home.vue'), read('../src/pages/operation/operation.vue'), read('../src/pages/home/home.vue'),
read('../src/data/overseas-workbench.js'), read('../src/pages.json') read('../src/pages/module/module.vue'), read('../src/data/overseas-workbench.js'), read('../src/pages.json')
]) ])
assert.doesNotMatch(operation, /prototypeScan|prototypeInput/) assert.doesNotMatch(operation, /prototypeScan|prototypeInput/)
assert.doesNotMatch(workbench, /PROTOTYPES|getOperationPrototype|模拟成功/) assert.doesNotMatch(workbench, /PROTOTYPES|getOperationPrototype|模拟成功/)
@@ -101,7 +101,7 @@ test('warehouse operations use dedicated real pages and contain no prototype tas
} }
assert.doesNotMatch(workbench, /key: 'stocktake'/) assert.doesNotMatch(workbench, /key: 'stocktake'/)
for (const page of ['pages/stocktake/stocktake', 'pages/move/move', 'pages/relabel/relabel', 'pages/query/query']) assert.match(pages, new RegExp(page)) for (const page of ['pages/stocktake/stocktake', 'pages/move/move', 'pages/relabel/relabel', 'pages/query/query']) assert.match(pages, new RegExp(page))
assert.match(home, /operation\.route \|\|/) assert.match(home + module, /operation\.route \|\|/)
}) })
test('receiving phases use a compiled container summary with cabinet and aggregate facts', async () => { test('receiving phases use a compiled container summary with cabinet and aggregate facts', async () => {
@@ -130,7 +130,7 @@ test('candidate containers use compact card navigation and a small refresh actio
assert.match(operation, /class="candidate-facts"[\s\S]*总订单[\s\S]*入仓码[\s\S]*已生成计划[\s\S]*未操作/) assert.match(operation, /class="candidate-facts"[\s\S]*总订单[\s\S]*入仓码[\s\S]*已生成计划[\s\S]*未操作/)
assert.match(operation, /class="candidate-destinations"/) assert.match(operation, /class="candidate-destinations"/)
assert.match(operation, /计划到仓:\{\{ formatPlannedArrival\(item\.plannedArrivalAt\) \}\}/) assert.match(operation, /计划到仓:\{\{ formatPlannedArrival\(item\.plannedArrivalAt\) \}\}/)
assert.match(operation, /selected\?\.blockReason \|\| '当前柜入库计划尚未齐全'/) assert.match(operation, /item\.blockReason \|\| '当前柜入库计划尚未齐全'/)
assert.doesNotMatch(operation, /预计到港/) assert.doesNotMatch(operation, /预计到港/)
assert.doesNotMatch(operation, /item\.receivedPackages[\s\S]*item\.totalPackages[\s\S]*progress-fill/) assert.doesNotMatch(operation, /item\.receivedPackages[\s\S]*item\.totalPackages[\s\S]*progress-fill/)
}) })
@@ -164,7 +164,7 @@ test('return receipt is a real PDA flow backed by the dedicated return APIs', as
assert.match(page, /startedBy: this\.startedBy/) assert.match(page, /startedBy: this\.startedBy/)
assert.match(page, /idempotencyKey: key\.value/) assert.match(page, /idempotencyKey: key\.value/)
assert.match(page, /requestId[\s\S]*traceId/) assert.match(page, /requestId[\s\S]*traceId/)
assert.doesNotMatch(warehouseWork, /pendingKeys/) assert.match(warehouseWork, /returnReceipt:[^\n]*pendingKeys/)
assert.doesNotMatch(page, /customerId/) assert.doesNotMatch(page, /customerId/)
}) })
@@ -182,7 +182,7 @@ test('return packing is a separate same-customer inventory operation', async ()
assert.match(page, /customerKey[\s\S]*不同客户的退货单不能合箱/) assert.match(page, /customerKey[\s\S]*不同客户的退货单不能合箱/)
assert.match(page, /idempotencyKey: key\.value/) assert.match(page, /idempotencyKey: key\.value/)
assert.match(page, /requestId[\s\S]*traceId/) assert.match(page, /requestId[\s\S]*traceId/)
assert.doesNotMatch(warehouseWork, /pendingBatch/) assert.match(warehouseWork, /returnPacking:[^\n]*pendingKeys/)
assert.doesNotMatch(page.match(/<template>([\s\S]*?)<\/template>/)?.[1] || '', /customerId|warehouseId/) assert.doesNotMatch(page.match(/<template>([\s\S]*?)<\/template>/)?.[1] || '', /customerId|warehouseId/)
}) })
-15
View File
@@ -22,21 +22,6 @@ test('reservation mismatch asks for the reserved pallet and retains diagnostics'
assert.equal(result.traceId, 'trace-1') assert.equal(result.traceId, 'trace-1')
}) })
test('cargo and destination-pallet scan errors use concise Chinese and retain diagnostics', () => {
for (const [code, message, expected] of [
['A0409', 'Carton mark must contain a non-empty inbound code prefix', '请扫描箱码或国内DP码'],
['A0404', 'Pallet code is not part of the current FBA destination group', '托盘不属于当前目的仓,请重扫'],
['B0404', 'Pallet code is not part of the current FBA destination group', '托盘不属于当前目的仓,请重扫'],
[404, 'Pallet code is not part of the current FBA destination group', '托盘不属于当前目的仓,请重扫'],
]) {
const result = toReceivingError({ code, message, requestId: 'scan-request', traceId: 'scan-trace' })
assert.equal(result.message, expected)
assert.equal(result.code, code)
assert.equal(result.requestId, 'scan-request')
assert.equal(result.traceId, 'scan-trace')
}
})
test('first scan and receipt requests confirm server reservations without resource assignment commands', async () => { test('first scan and receipt requests confirm server reservations without resource assignment commands', async () => {
const source = await readFile(new URL('../src/api/inbound.js', import.meta.url), 'utf8') const source = await readFile(new URL('../src/api/inbound.js', import.meta.url), 'utf8')
const calls = [] const calls = []
+1 -128
View File
@@ -2,7 +2,7 @@ import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import * as store from '../src/stores/receiving.js' import * as store from '../src/stores/receiving.js'
import { normalizeReceivingSession, toReceivingError } from '../src/api/receiving-adapter.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 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 defer = () => { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b }); return { promise, resolve, reject } }
@@ -37,133 +37,6 @@ test('same current readback retains three signed previews; collapsed unversioned
await vm.applyCurrentReceiving(sessionValue()); assert.equal(calls.length, 18) await vm.applyCurrentReceiving(sessionValue()); assert.equal(calls.length, 18)
}) })
test('editing and rejecting a pallet cannot submit using the previous successful validation', async t => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
store.receiving.resolvedCarton = { inboundCode: 'IN-A', cartonMark: 'IN-A#22-1', destinationType: 'FBA' }
store.receiving.resolvedPallet = { palletCode: 'PALLET-OLD', compatible: true, version: 1 }
let writes = 0
const { vm } = page({ toReceivingError,
resolveCurrentPallet: async () => { throw Object.assign(new Error('Pallet code is not part of the current FBA destination group'), { code: 'A0404', requestId: 'scan-request', traceId: 'scan-trace' }) },
receiveCurrentBulk: async () => { writes++ },
})
t.after(() => vm.suspendReceiving())
vm.palletInput = 'PALLET-OLD'; vm.packageCount = 3
assert.equal(vm.canSubmitReceipt, true)
vm.palletInput = 'PALLET-WRONG'
assert.equal(vm.canSubmitReceipt, false)
const toasts = []; vm.showToast = (message, type) => toasts.push({ message, type })
await vm.resolvePallet(vm.palletInput)
assert.equal(vm.resolvedPallet, null)
assert.equal(vm.packageCount, '')
assert.equal(vm.activeTarget, 'pallet')
assert.equal(vm.error.message, '托盘不属于当前目的仓,请重扫')
assert.equal(vm.error.requestId, 'scan-request')
assert.equal(vm.error.traceId, 'scan-trace')
assert.deepEqual(toasts, [{ message: '托盘不属于当前目的仓,请重扫', type: 'error' }])
await vm.submitReceipt()
assert.equal(writes, 0)
})
test('an incompatible pallet never reports success and a corrected scan can be validated normally', async t => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
store.receiving.resolvedCarton = { inboundCode: 'IN-A', cartonMark: 'IN-A#22-1', destinationType: 'FBA' }
const calls = []; let successes = 0
const { vm } = page({ toReceivingError, playSuccessSound: () => successes++,
resolveCurrentPallet: async (palletCode, inboundCode) => {
calls.push({ palletCode, inboundCode })
return { palletCode, compatible: palletCode === 'PALLET-VALID', version: 2, currentReceivedPackages: 3 }
},
})
t.after(() => vm.suspendReceiving())
const toasts = []; vm.showToast = (message, type) => toasts.push({ message, type })
await vm.resolvePallet('PALLET-WRONG')
assert.equal(vm.resolvedPallet, null)
assert.equal(vm.canSubmitReceipt, false)
assert.equal(successes, 0)
assert.deepEqual(toasts, [{ message: '托盘不匹配,请重扫', type: 'error' }])
await vm.resolvePallet('PALLET-VALID')
assert.deepEqual(calls, [{ palletCode: 'PALLET-WRONG', inboundCode: 'IN-A' }, { palletCode: 'PALLET-VALID', inboundCode: 'IN-A' }])
assert.equal(vm.resolvedPallet.palletCode, 'PALLET-VALID')
assert.equal(vm.error, null)
assert.equal(vm.packageCount, 3)
assert.equal(vm.canSubmitReceipt, true)
assert.equal(vm.activeTarget, 'quantity')
assert.equal(successes, 1)
})
test('late pallet validation cannot replace edited input and only the corrected pallet can be received', async t => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
store.receiving.resolvedCarton = { inboundCode: 'IN-A', cartonMark: 'IN-A#22-1', destinationType: 'FBA' }
const pending = defer(), receipts = [], calls = [], toasts = []; let successes = 0
const { vm } = page({ toReceivingError, playSuccessSound: () => successes++,
resolveCurrentPallet: (palletCode, inboundCode) => {
calls.push({ palletCode, inboundCode })
return palletCode === 'PALLET-A' ? pending.promise : Promise.resolve({ palletCode, compatible: true, version: 2, currentReceivedPackages: 3 })
},
receiveCurrentBulk: async payload => { receipts.push(payload); return {} },
getCurrentReceiving: async () => sessionValue(),
})
t.after(() => vm.suspendReceiving())
vm.showToast = (message, type) => toasts.push({ message, type }); vm.applyCurrentReceiving = async () => {}
const validation = vm.resolvePallet('PALLET-A')
vm.palletInput = 'PALLET-B'
pending.resolve({ palletCode: 'PALLET-A', compatible: true, version: 1, currentReceivedPackages: 3 })
await validation
assert.equal(vm.palletInput, 'PALLET-B')
assert.equal(vm.resolvedPallet, null)
assert.equal(vm.packageCount, '')
assert.equal(vm.canSubmitReceipt, false)
assert.equal(vm.busy, false)
assert.equal(successes, 0)
assert.deepEqual(toasts, [])
await vm.submitReceipt(); assert.equal(receipts.length, 0)
await vm.resolvePallet('PALLET-B')
assert.equal(vm.canSubmitReceipt, true)
assert.equal(successes, 1)
await vm.submitReceipt()
assert.deepEqual(calls, [{ palletCode: 'PALLET-A', inboundCode: 'IN-A' }, { palletCode: 'PALLET-B', inboundCode: 'IN-A' }])
assert.deepEqual(receipts.map(({ palletCode }) => palletCode), ['PALLET-B'])
})
test('obsolete pallet failures cannot replace current input or feedback', async t => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
store.receiving.resolvedCarton = { inboundCode: 'IN-A', cartonMark: 'IN-A#22-1', destinationType: 'FBA' }
const pending = defer(), toasts = []; let failures = 0
const { vm } = page({ toReceivingError, resolveCurrentPallet: () => pending.promise, playErrorSound: () => failures++ })
t.after(() => vm.suspendReceiving()); vm.showToast = (message, type) => toasts.push({ message, type })
const validation = vm.resolvePallet('PALLET-A')
vm.palletInput = 'PALLET-B'
pending.reject(Object.assign(new Error('Pallet code is not part of the current FBA destination group'), { code: 'A0404' }))
await validation
assert.equal(vm.palletInput, 'PALLET-B'); assert.equal(vm.error, null)
assert.equal(vm.resolvedPallet, null); assert.equal(vm.canSubmitReceipt, false)
assert.equal(vm.busy, false); assert.equal(failures, 0); assert.deepEqual(toasts, [])
})
test('late pallet validation cannot cross a receiving context or page lifetime', async t => {
for (const [name, change] of [
['carton', () => { store.receiving.resolvedCarton = { inboundCode: 'IN-B', cartonMark: 'IN-B#1', destinationType: 'FBA' } }],
['container', () => store.setCurrentReceiving(sessionValue({ containerNo: 'CAB-B' }))],
['operator', ({ deps }) => { deps.session.currentUser = { id: 'another-operator', warehouseId: '900' } }],
['warehouse', ({ deps }) => { deps.session.currentUser.warehouseId = '901' }],
['hide and return', ({ vm, definition }) => { definition.onHide.call(vm); definition.onShow.call(vm) }],
]) await t.test(name, async t => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
store.receiving.resolvedCarton = { inboundCode: 'IN-A', cartonMark: 'IN-A#22-1', destinationType: 'FBA' }
const pending = defer(), toasts = []; let successes = 0
const context = page({ toReceivingError, resolveCurrentPallet: () => pending.promise, playSuccessSound: () => successes++ })
const { vm } = context
t.after(() => vm.suspendReceiving()); vm.bootstrap = async () => {}; vm.showToast = (message, type) => toasts.push({ message, type })
const validation = vm.resolvePallet('PALLET-A')
change(context)
pending.resolve({ palletCode: 'PALLET-A', compatible: true, version: 1, currentReceivedPackages: 3 })
await validation
assert.equal(vm.resolvedPallet, null); assert.equal(vm.packageCount, '')
assert.equal(vm.canSubmitReceipt, false); assert.equal(vm.busy, false)
assert.equal(successes, 0); assert.deepEqual(toasts, [])
})
})
test('signed photo changes, account/customer/cabinet changes and image failure cannot inherit stale previews', async () => { test('signed photo changes, account/customer/cabinet changes and image failure cannot inherit stale previews', async () => {
store.resetReceivingSession(); let calls = 0 store.resetReceivingSession(); let calls = 0
const { vm, deps } = page({ downloadCurrentUnloadPhoto: async () => `local:${++calls}`, downloadCurrentDomesticLoadingPhoto: async () => '' }) const { vm, deps } = page({ downloadCurrentUnloadPhoto: async () => `local:${++calls}`, downloadCurrentDomesticLoadingPhoto: async () => '' })
+2 -92
View File
@@ -1,16 +1,14 @@
import test from 'node:test' import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
const source = await readFile(new URL('../src/components/RelabelPalletWork.vue', import.meta.url), 'utf8') 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') const script = source.split('<script>')[1].split('</script>')[0].replace(/^import .*$/gm, '').replace('export default', 'return')
function setup() { function setup() {
const calls = [] const calls = []
const component = new Function('ScanTargetInput', 'measureRelabelPallet', 'warehouseIdempotencyKey', 'releaseWarehouseKey', 'bindRelabelPallet', script)( const component = new Function('ScanTargetInput', 'measureRelabelPallet', 'warehouseIdempotencyKey', 'releaseWarehouseKey', script)(
{}, async payload => { calls.push(payload); return [{ palletCode: payload.palletCode, volumeCbm: 0.25, grossWeightKg: payload.grossWeightKg }] }, {}, async payload => { calls.push(payload); return [{ palletCode: payload.palletCode, volumeCbm: 0.25, grossWeightKg: payload.grossWeightKg }] },
() => ({ value: 'stable-key', signature: 'signature' }), () => {}, async payload => calls.push(payload)) () => ({ value: 'stable-key', signature: 'signature' }), () => {})
const value = { ...component.data(), task: { relabelNo: 'RL-1' }, $emit() {} } const value = { ...component.data(), task: { relabelNo: 'RL-1' }, $emit() {} }
for (const [key, method] of Object.entries(component.methods)) value[key] = method.bind(value) for (const [key, method] of Object.entries(component.methods)) value[key] = method.bind(value)
return { component, value, calls } return { component, value, calls }
@@ -35,91 +33,3 @@ test('only whole physical pallet quantities offer original pallet reuse', () =>
ctx.plannedPackages = 10;ctx.isReturnBulk = true ctx.plannedPackages = 10;ctx.isReturnBulk = true
assert.equal(canRetain.call(ctx), false) assert.equal(canRetain.call(ctx), false)
}) })
async function renderMeasurements(measurements) {
const { component } = setup()
const template = source.slice(source.indexOf('<template>') + 10, source.indexOf('<script>')).trim().replace(/<\/template>$/, '')
return renderToString(createSSRApp({ ...component, template,
components: { ScanTargetInput: { template: '<view />' } },
props: undefined,
data() { return { ...component.data(), task: { relabelNo: 'RL-1', items: [], history: [] }, measurements } }
}))
}
test('unchanged source renders real grouped box specs and totals without zero pallet dimension inputs', async () => {
const html = await renderMeasurements([
{ palletCode: 'FIRST', packages: 2, inherited: true, lengthCm: null, widthCm: null, heightCm: null,
grossWeightKg: 5, volumeCbm: 0.3, sourceCartonSpecs: [{ lengthCm: 60, widthCm: 50, heightCm: 50, grossWeightKg: 2.5, cartons: 2 }] },
{ palletCode: 'SECOND', packages: 3, inherited: true, lengthCm: null, widthCm: null, heightCm: null,
grossWeightKg: 9.7, volumeCbm: 0.31, sourceCartonSpecs: [
{ lengthCm: 60, widthCm: 50, heightCm: 50, grossWeightKg: 2.5, cartons: 1 },
{ lengthCm: 50, widthCm: 40, heightCm: 40, grossWeightKg: 3.6, cartons: 2 }] }
])
assert.match(html, /60 × 50 × 50 cm · 2.5 kg\/箱 · 2 箱/)
assert.match(html, /50 × 40 × 40 cm · 3.6 kg\/箱 · 2 箱/)
assert.match(html, /来源汇总:0.31 m³ · 9.7 kg/)
assert.doesNotMatch(html, /未测整托长宽高/)
assert.equal((html.match(/class="measurement-inputs"/g) || []).length, 0)
assert.doesNotMatch(html, /确认本托实测/)
})
test('changed pallets and explicit packaging changes still render final pallet measurement inputs', async () => {
for (const pallet of [{ inherited: false }, { inherited: true, remeasuring: true }]) {
const html = await renderMeasurements([{ ...pallet, palletCode: 'P', packages: 1 }])
assert.match(html, /class="measurement-inputs"/)
assert.match(html, /确认本托实测/)
assert.match(html, /长 cm/)
}
})
test('box aggregated pallets render current totals and no manual pallet measurement even after moving', async () => {
const html = await renderMeasurements([{ palletCode: 'NEW', packages: 1, inherited: true, cartonAggregated: true,
grossWeightKg: 3.6, volumeCbm: 0.08, sourceCartonSpecs: [{lengthCm:50,widthCm:40,heightCm:40,grossWeightKg:3.6,cartons:1}] }])
assert.doesNotMatch(html, /移出扣减、移入增加/)
assert.match(html, /0.08 m³ · 3.6 kg/)
assert.doesNotMatch(html, /确认本托实测|重新测量|measurement-inputs/)
})
const mixedBoxes = [
{ cartonMark: 'OTHER', lengthCm: 60, widthCm: 50, heightCm: 50, grossWeightKg: 2.5 },
{ cartonMark: 'SMALL1', customerCartonMark: 'CUSTOMER', lengthCm: 50, widthCm: 40, heightCm: 40, grossWeightKg: 3.6 }
]
test('mixed specs automatically bind the submitted carton without another picker', async () => {
const { value, calls } = setup()
Object.assign(value, { readyToBind: true, requiresMeasuredBoxMapping: true, marks: ['CUSTOMER'], measuredBoxChoices: mixedBoxes,
source: { palletCode: 'OLD', palletVersion: 1 }, target: { palletCode: 'NEW', palletVersion: 1 },
selectedSource: { positionId: '1', positionVersion: 1 }, quantity: '1', loadSources: async () => {}, showBindingMessage() {} })
await value.bind()
assert.deepEqual(calls[0].cartonMarks, ['CUSTOMER'])
assert.deepEqual(calls[0].measuredCartons, [{ cartonMark: 'CUSTOMER', sourceCartonMark: 'SMALL1' }])
assert.doesNotMatch(source, /<picker|chooseMeasuredBox|点击选择实测箱/)
})
test('warehouse carton marks also match automatically and follow the current batch', () => {
const { value } = setup()
Object.assign(value, { requiresMeasuredBoxMapping: true, measuredBoxChoices: mixedBoxes, marks: ['SMALL1'] })
assert.deepEqual(value.measuredCartonBindings(), [{ cartonMark: 'SMALL1', sourceCartonMark: 'SMALL1' }])
value.marks = ['OTHER']
assert.deepEqual(value.measuredCartonBindings(), [{ cartonMark: 'OTHER', sourceCartonMark: 'OTHER' }])
})
test('unmatched, ambiguous or duplicate physical boxes cannot bind another carton', async () => {
for (const [marks, boxes] of [
[['MISSING'], mixedBoxes],
[['CUSTOMER'], [...mixedBoxes, { ...mixedBoxes[0], customerCartonMark: 'CUSTOMER' }]],
[['CUSTOMER', 'SMALL1'], mixedBoxes]
]) {
const { value, calls } = setup()
Object.assign(value, { readyToBind: true, requiresMeasuredBoxMapping: true, measuredBoxChoices: boxes, marks })
await value.bind()
assert.equal(calls.length, 0)
assert.match(value.error, /不匹配/)
}
})
test('entering split quantity prepares the batch and clears invalid quantities', () => {
const { value } = setup()
Object.assign(value, { measuredBoxChoices: [{ cartonMark: 'A' }], eligibleItems: [{ cartonMark: 'X' }],
selectedSource: { movablePackages: 1 }, fillBatch() { this.marks = ['X'] } })
value.prepareMeasuredBatch({ detail: { value: '1' } })
assert.deepEqual(value.marks, ['X'])
value.prepareMeasuredBatch({ detail: { value: '0' } })
assert.deepEqual(value.marks, [])
})
+3 -3
View File
@@ -59,7 +59,7 @@ test('pallet comparison tolerates mixed API versions and canonical code formatti
test('quantity disassembly has no extra carton scanner', () => { test('quantity disassembly has no extra carton scanner', () => {
assert.doesNotMatch(source, /relabel-move-carton/) assert.doesNotMatch(source, /relabel-move-carton/)
assert.match(source, /actualSource:/) assert.match(source, /actualSource:/)
assert.match(source, /来源已确认:\{\{ source.palletCode \}\}<\/text>/) assert.match(source, /来源已确认:.*尚未拆出.*绑定成功后才保存拆托明细/)
assert.match(source, /this\.message = `托盘 \$\{this\.target\.palletCode\} 已绑定`/) assert.match(source, /this\.message = `托盘 \$\{this\.target\.palletCode\} 已绑定`/)
}) })
@@ -96,7 +96,7 @@ test('rescan same bound pallet does not call stock movement', async () => {
value.showBindingMessage = (title, content) => { message = content } value.showBindingMessage = (title, content) => { message = content }
value.run = async () => assert.fail('same pallet must not move stock') value.run = async () => assert.fail('same pallet must not move stock')
await value.submitRebind() await value.submitRebind()
assert.match(message, /已绑定此托盘/) assert.match(message, /不会重复扣库存/)
}) })
test('target confirmation submits binding directly, with no second submit button', async () => { test('target confirmation submits binding directly, with no second submit button', async () => {
@@ -183,7 +183,7 @@ test('missing source scan shows modal and never submits a binding', async () =>
value.bind = async () => assert.fail('must not bind without source confirmation') value.bind = async () => assert.fail('must not bind without source confirmation')
value.resolve = async () => assert.fail('must validate source first') value.resolve = async () => assert.fail('must validate source first')
await value.confirmTargetAndBind() await value.confirmTargetAndBind()
assert.match(message, /扫描确认来源托盘/) assert.match(message, /扫描并确认实际来源托盘/)
}) })
test('target lookup failure cannot submit binding or report success', async () => { test('target lookup failure cannot submit binding or report success', async () => {
+2 -33
View File
@@ -11,15 +11,12 @@ test('box merge component compiles both its script and template', () => {
compileScript(descriptor, { id: 'box-merge' }) compileScript(descriptor, { id: 'box-merge' })
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename, id: 'box-merge' }).errors, []) assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename, id: 'box-merge' }).errors, [])
}) })
function instance(runtime = {}) { 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 boxes = [{cartonMark:'A',customerId:'C1',items:[{skuCode:'SKU1',quantity:2},{skuCode:'SKU2',quantity:3}]},{cartonMark:'B',customerId:'C1',items:[{skuCode:'SKU1',quantity:2}]}]
const context = vm.createContext({ reactive: value => value, ScanTargetInput: {}, ReturnLabelConfirmation: {}, component: null, scanReturnCarton: async mark => boxes.find(box => box.cartonMark === mark), ...runtime }) 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) })
vm.runInContext(readFileSync(new URL('../src/stores/warehouse-work.js', import.meta.url), 'utf8').replace(/^import .*$/gm, '').replace(/export /g, ''), context)
const component = vm.runInContext(descriptor.script.content.replace(/^import .*$/gm, '').replace('export default', 'component ='), context)
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(){} } 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) for (const [name, method] of Object.entries(component.methods)) work[name] = method.bind(work)
work.save = () => {} work.save = () => {}
Object.defineProperty(work, 'canComplete', { get: () => component.computed.canComplete.call(work) })
return {work,component} return {work,component}
} }
test('new plan requires scanning each source and preserves locked SKU quantities', async () => { test('new plan requires scanning each source and preserves locked SKU quantities', async () => {
@@ -34,34 +31,6 @@ test('new plan requires scanning each source and preserves locked SKU quantities
assert.equal(component.computed.canComplete.call(work),true) assert.equal(component.computed.canComplete.call(work),true)
}) })
test('partial merge preserves the scanned snapshot after failure and accepts a fresh scan without locking', async () => {
let available = 10
const calls = [], errors = []
const { work } = instance({
scanReturnCarton: async () => ({ cartonMark: 'A', customerId: 'C1', items: [{ skuCode: 'SKU1', quantity: available }] }),
mergeReturnBoxes: async request => {
calls.push(JSON.parse(JSON.stringify(request)))
if (request.expectedSourceQuantities[0].quantity !== available) throw new Error('来源箱库存已变化')
available -= request.sourceDeductions[0].quantity
if (calls.length === 1) throw new Error('response lost after commit')
return { label: { cartonMark: 'NEW' } }
}
})
work.plan = null; work.choose('NEW'); work.$emit = (name, value) => { if (name === 'error') errors.push(value) }
await work.scanSource('A'); work.setDeduction('A', 'SKU1', 3)
work.dimensions = { length: 10, width: 10, height: 10, weight: 1 }
await work.complete(); await work.complete()
assert.equal(available, 7)
assert.equal(work.busy, false)
assert.equal(errors.at(-1).message, '来源箱库存已变化')
assert.notEqual(calls[0].idempotencyKey, calls[1].idempotencyKey)
work.remove('A'); await work.scanSource('A'); work.setDeduction('A', 'SKU1', 2)
await work.complete()
assert.equal(available, 5)
assert.deepEqual(calls.map(call => call.expectedSourceQuantities[0].quantity), [10, 10, 7])
assert.equal(work.busy, false)
})
test('planned source carton marks are visible before scanning', () => { test('planned source carton marks are visible before scanning', () => {
const {work, component} = instance() const {work, component} = instance()
work.plan = { sourceCartonMarks: ['A', 'B'], sources: [] } work.plan = { sourceCartonMarks: ['A', 'B'], sources: [] }
+1 -1
View File
@@ -5,7 +5,7 @@ import { runInNewContext } from 'node:vm'
const source = readFileSync(new URL('../src/pages/return-counting/return-counting.vue', import.meta.url), 'utf8') 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 script = source.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^import .+$/gm, '').replace('export default', 'page =')
const scope = { PortalHeader: {}, ScanTargetInput: {}, CartonCountFields: {} } const scope = { PortalHeader: {}, ScanTargetInput: {} }
runInNewContext(script, scope) runInNewContext(script, scope)
function state(a = '2', b = '2', c = '2') { function state(a = '2', b = '2', c = '2') {
+1 -1
View File
@@ -37,7 +37,7 @@ test('return receipt API calls only the dedicated PDA routes and preserves the r
} }
await startReturnReceipt(' RT-001 ') await startReturnReceipt(' RT-001 ')
await findReturnSkuCandidates(' SKU-1 ') await findReturnSkuCandidates(' SKU-1 ')
const confirm = { returnNo: 'RT-001', expectedReceivedQuantity: 0, trackingNo: 'TRACK-1', startedBy: 'SKU', palletCode: 'PLT-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) await confirmReturnReceipt(confirm)
assert.deepEqual(calls.map((item) => item.url), [ assert.deepEqual(calls.map((item) => item.url), [
'/api/pda/v1/last-leg/return-receipts/start', '/api/pda/v1/last-leg/return-receipts/start',
+10 -58
View File
@@ -122,12 +122,13 @@ function setup(confirm, saved = new Map(), scope = 'operator:warehouse', runtime
const component = vm.runInContext(script.replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), context) const component = vm.runInContext(script.replace(/^\s*import .*$/mg, '').replace('export default', 'globalThis.component ='), context)
vm.runInContext(`restoreWarehouseWork(${JSON.stringify(scope)})`, context) vm.runInContext(`restoreWarehouseWork(${JSON.stringify(scope)})`, context)
const instance = Object.assign({}, component.methods, { const instance = Object.assign({}, component.methods, {
receiptResults: [], unitType: 'PALLET', palletBoxCount: 1, canConfirm: true, busy: false, unitType: 'PALLET', palletBoxCount: 1, canConfirm: true, busy: false,
current: { returnNo: 'RT-TEST', expectedItems: [{ skuCode: 'SKU-TEST', expectedQuantity: 10, receivedQuantity: 0 }] }, 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', 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, palletCode: ' PLT-TEST ', palletLength: 10, palletWidth: 10, palletHeight: 10, palletWeight: 1,
quantity: () => 2, boxCount: () => 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) }) Object.defineProperty(instance, 'allBoxRowsConfirmed', { get: () => component.computed.allBoxRowsConfirmed.call(instance) })
return instance return instance
} }
@@ -155,7 +156,7 @@ test('PALLET page submits the required palletCode through the actual confirmatio
assert.equal(calls.length, 1) assert.equal(calls.length, 1)
assert.equal(calls[0].palletCode, 'PLT-TEST') assert.equal(calls[0].palletCode, 'PLT-TEST')
assert.equal(calls[0].unitCode, undefined) assert.equal(calls[0].unitCode, undefined)
assert.deepEqual(Object.keys(calls[0]).sort(), ['expectedReceivedQuantity', 'height', 'idempotencyKey', 'items', 'length', 'palletCode', 'returnNo', 'startedBy', 'totalBoxCount', 'trackingNo', 'unitType', 'weight', 'width'].sort()) 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].unitType, 'PALLET')
assert.equal(calls[0].totalBoxCount, 1) assert.equal(calls[0].totalBoxCount, 1)
assert.deepEqual(Object.keys(calls[0].items[0]).sort(), ['boxCount', 'receivedQuantity', 'skuCode'].sort()) assert.deepEqual(Object.keys(calls[0].items[0]).sort(), ['boxCount', 'receivedQuantity', 'skuCode'].sort())
@@ -231,7 +232,7 @@ test('one submission signs multiple mixed-SKU parcels sharing one waybill and in
assert.deepEqual(Array.from(page.result.labels, row => row.cartonMark), ['RP-1', 'RP-2']) assert.deepEqual(Array.from(page.result.labels, row => row.cartonMark), ['RP-1', 'RP-2'])
}) })
test('failed multi-parcel receipt keeps successful results and accepts edits to remaining rows', async () => { test('failed multi-parcel batch resumes immutable requests without re-signing a successful parcel', async () => {
const saved = new Map(), calls = [] const saved = new Map(), calls = []
let failSecond = true let failSecond = true
const confirm = async request => { const confirm = async request => {
@@ -242,65 +243,16 @@ test('failed multi-parcel receipt keeps successful results and accepts edits to
const page = setup(confirm, saved) 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 })] page.unitType = 'PARCEL'; page.photoFileIds = ['PHOTO-EXISTING']; page.unitCode = 'WAYBILL-BATCH'; page.boxRows = [physicalRow({ 'SKU-TEST': 1 }), physicalRow({ 'SKU-TEST': 2 })]
await page.confirmReceipt() await page.confirmReceipt()
assert.equal(page.receiptResults[0].labels[0].cartonMark, 'RP-1'); assert.equal(page.boxRows.length, 1); assert.equal(page.busy, false) assert.equal(page.receiptBatch.entries[0].result.labels[0].cartonMark, 'RP-1')
failSecond = false failSecond = false
const restored = page; page.boxRows[0].items['SKU-TEST'] = 3 const restored = setup(confirm, saved)
await restored.confirmReceipt() await restored.confirmReceipt()
assert.equal(calls.length, 3) assert.equal(calls.length, 3)
assert.notEqual(calls[1].idempotencyKey, calls[2].idempotencyKey) assert.equal(calls[1].idempotencyKey, calls[2].idempotencyKey)
assert.equal(calls[2].items[0].receivedQuantity, 3) assert.deepEqual(calls[1], calls[2])
assert.deepEqual(calls.map(call => call.expectedReceivedQuantity), [0, 1, 1])
assert.equal(restored.result.labels.length, 2) assert.equal(restored.result.labels.length, 2)
}) })
test('lost receipt response keeps the old quantity precondition and does not lock editing or refresh', async () => {
const calls = []
let received = 0
const page = setup(async request => {
calls.push(JSON.parse(JSON.stringify(request)))
if (request.expectedReceivedQuantity !== received) throw new Error('退货单实收数量已变化')
received += request.items[0].receivedQuantity
if (calls.length === 1) throw new Error('response lost after commit')
return { unitType: 'PARCEL', fullyMatched: false }
}, new Map(), 'operator:warehouse', {
startReturnReceipt: async () => ({ returnNo: 'RT-TEST', expectedItems: [{ skuCode: 'SKU-TEST', expectedQuantity: 10, receivedQuantity: received }], photos: [{ fileId: 'PHOTO' }] }),
downloadReturnPalletPhoto: async () => 'photo-url'
})
page.unitType = 'PARCEL'; page.unitCode = ''; page.photoFileIds = ['PHOTO']
page.boxRows = [physicalRow({ 'SKU-TEST': 2 })]
await page.confirmReceipt()
await page.confirmReceipt()
assert.equal(received, 2)
assert.equal(page.busy, false)
assert.equal(page.boxRows.length, 1)
assert.equal(page.error.message, '退货单实收数量已变化')
assert.notEqual(calls[0].idempotencyKey, calls[1].idempotencyKey)
// 用户刷新后,按当前实收量登记下一份实物。
await page.reloadCurrent()
assert.equal(page.boxRows[0].confirmed, false)
assert.deepEqual(Object.keys(page.boxRows[0].items), [])
await page.confirmReceipt()
assert.equal(received, 2, '刷新后原包裹不能再次提交')
assert.equal(calls.length, 2)
page.boxRows[0] = physicalRow({ 'SKU-TEST': 3 })
await page.confirmReceipt()
assert.equal(received, 5)
assert.deepEqual(calls.map(call => call.expectedReceivedQuantity), [0, 0, 2])
assert.equal(page.busy, false)
})
test('refresh with unchanged receipt quantities preserves editable input after a rejected request', async () => {
const page = setup(async () => { throw new Error('not committed') }, new Map(), 'operator:warehouse', {
startReturnReceipt: async () => ({ returnNo: 'RT-TEST', expectedItems: [{ skuCode: 'SKU-TEST', expectedQuantity: 10, receivedQuantity: 0 }], photos: [{ fileId: 'PHOTO' }] }),
downloadReturnPalletPhoto: async () => 'photo-url'
})
page.unitType = 'PARCEL'; page.unitCode = ''; page.photoFileIds = ['PHOTO']
page.boxRows = [physicalRow({ 'SKU-TEST': 2 })]
await page.confirmReceipt(); await page.reloadCurrent()
assert.equal(page.boxRows[0].items['SKU-TEST'], 2)
assert.equal(page.busy, false)
})
test('partial receipt continues the same order with fresh pallet measurements', async () => { test('partial receipt continues the same order with fresh pallet measurements', async () => {
const page = setup(async () => ({})) const page = setup(async () => ({}))
page.finishReceipt([{ fullyMatched: false }]) page.finishReceipt([{ fullyMatched: false }])
@@ -314,7 +266,7 @@ test('partial receipt continues the same order with fresh pallet measurements',
assert.equal(page.palletWeight, '') assert.equal(page.palletWeight, '')
}) })
test('PALLET response failure keeps editable input and generates a new key for the next click', async () => { test('PALLET response failure keeps the draft and same idempotency key for retry', async () => {
const calls = [] const calls = []
const page = setup(async payload => { calls.push(JSON.parse(JSON.stringify(payload))); throw new Error('Response lost') }) const page = setup(async payload => { calls.push(JSON.parse(JSON.stringify(payload))); throw new Error('Response lost') })
await page.confirmReceipt() await page.confirmReceipt()
@@ -323,7 +275,7 @@ test('PALLET response failure keeps editable input and generates a new key for t
assert.equal(page.error.message, 'Response lost') assert.equal(page.error.message, 'Response lost')
await page.confirmReceipt() await page.confirmReceipt()
assert.equal(calls.length, 2) assert.equal(calls.length, 2)
assert.notEqual(calls[0].idempotencyKey, calls[1].idempotencyKey) assert.equal(calls[0].idempotencyKey, calls[1].idempotencyKey)
}) })
test('continue signing returns to SKU scanning and accepts a fresh scan', () => { test('continue signing returns to SKU scanning and accepts a fresh scan', () => {
+12 -13
View File
@@ -36,7 +36,7 @@ test('warehouse transfer page loads detail without exposing internal identifiers
assert.match(page, /entryMode === 'work' && taskNo/) assert.match(page, /entryMode === 'work' && taskNo/)
assert.match(page, /mode=work&task=\$\{encodeURIComponent\(String\(item\.transferNo\)\)\}/) assert.match(page, /mode=work&task=\$\{encodeURIComponent\(String\(item\.transferNo\)\)\}/)
assert.match(page, /v-if="!selected && entryMode === 'list'"/) assert.match(page, /v-if="!selected && entryMode === 'list'"/)
assert.match(page, /返回列表/) assert.match(page, /作业详情/)
assert.match(page, /backToCandidates\(\)/) assert.match(page, /backToCandidates\(\)/)
assert.match(page, /backToCandidates\(\) \{ if \(this\.submitting.*this\.entryMode = 'list'/) assert.match(page, /backToCandidates\(\) \{ if \(this\.submitting.*this\.entryMode = 'list'/)
assert.match(page, /存在未完结的测量单/) assert.match(page, /存在未完结的测量单/)
@@ -54,31 +54,30 @@ test('warehouse transfer removes completed work from the pending list', async ()
assert.match(page, /this\.selected = null; this\.pendingKey = '';[\s\S]*await this\.loadCandidates\(\)/) assert.match(page, /this\.selected = null; this\.pendingKey = '';[\s\S]*await this\.loadCandidates\(\)/)
}) })
test('warehouse transfer shows forecast and actual variance and requires counted box reconciliation', async () => { 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') const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
for (const label of ['客户预报', 'SKU实收', 'SKU签收差异']) assert.match(page, new RegExp(label)) for (const label of ['客户预报', '当前实收', '短收差异', '超收差异']) assert.match(page, new RegExp(label))
assert.match(page, /hasSkuReceiptDifference/) assert.match(page, /cartonDifference/)
assert.doesNotMatch(page, /全部托盘箱数必须等于订单计划箱数/) assert.doesNotMatch(page, /全部托盘箱数必须等于订单计划箱数/)
assert.doesNotMatch(page, /本托 SKU 箱数|itemCounts/) assert.doesNotMatch(page, /本托 SKU 箱数|itemCounts|transferItemId/)
}) })
test('warehouse transfer creates a fresh completion key for each explicit attempt', async () => { 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') const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
assert.doesNotMatch(page, /if \(!this\.pendingKey\) this\.pendingKey = `pda-transfer-/) assert.match(page, /if \(!this\.pendingKey\) this\.pendingKey = `pda-transfer-/)
assert.match(page, /this\.pendingKey = `pda-transfer-/)
assert.match(page, /idempotencyKey: this\.pendingKey/) assert.match(page, /idempotencyKey: this\.pendingKey/)
assert.equal((page.match(/pendingKey = `pda-transfer-/g) || []).length, 1) assert.equal((page.match(/pendingKey = `pda-transfer-/g) || []).length, 1)
}) })
test('warehouse transfer measurement inputs retain explicit placeholders', async () => { 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') const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
assert.doesNotMatch(page, /v-model.number="pallet\.(lengthCm|widthCm|heightCm|grossWeightKg)"/) for (const placeholder of ['长度', '宽度', '高度', '毛重']) assert.match(page, new RegExp(`placeholder="${placeholder}"`))
}) })
test('warehouse transfer measurements are grouped and summarized per pallet', async () => { 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') 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"/) assert.match(page, /v-for="\(pallet, index\) in pallets"/)
for (const label of ['本托箱子汇总', '实收箱数', '箱子重量 kg', '箱子体积 m³']) assert.match(page, new RegExp(label)) for (const label of ['本托实测', '当前托重量', '当前托箱数', '自动体积']) assert.match(page, new RegExp(label))
assert.match(page, /palletVolume\(pallet\)/) assert.match(page, /palletVolume\(pallet\)/)
assert.match(page, /rawPalletVolume\(pallet\)/) assert.match(page, /rawPalletVolume\(pallet\)/)
}) })
@@ -138,8 +137,8 @@ test('warehouse transfer pallet cards prepare generated and server-bound states'
assert.match(page, /handlePalletScan/) assert.match(page, /handlePalletScan/)
assert.match(page, /扫描或手输新托盘标签号/) assert.match(page, /扫描或手输新托盘标签号/)
assert.match(page, /已绑定实物托盘/) assert.match(page, /已绑定实物托盘/)
assert.match(page, /@tap\.stop="removePallet\(index\)">×/) assert.match(page, /!pallet\.generated.*removePallet/)
assert.match(page, /请先生成托盘号,再填写签收箱子/) assert.match(page, /:disabled="pallet\.generated"/)
assert.match(page, /collapsed: false/) assert.match(page, /collapsed: false/)
assert.match(page, /pallet\.collapsed = !pallet\.collapsed/) assert.match(page, /pallet\.collapsed = !pallet\.collapsed/)
assert.match(page, /v-show="!pallet\.collapsed"/) assert.match(page, /v-show="!pallet\.collapsed"/)
@@ -150,5 +149,5 @@ test('warehouse transfer pallet cards prepare generated and server-bound states'
assert.match(page, /bindWarehouseTransferPallet/) assert.match(page, /bindWarehouseTransferPallet/)
assert.match(page, /deleteWarehouseTransferPallet/) assert.match(page, /deleteWarehouseTransferPallet/)
assert.match(page, /请先生成并扫描确认绑定全部托盘/) assert.match(page, /请先生成并扫描确认绑定全部托盘/)
assert.match(page, /const payload = \{ idempotencyKey: this\.pendingKey, photoFileIds: \[\.\.\.this\.photoFileIds\], discrepancyReason:/) assert.match(page, /const payload = \{ idempotencyKey: this\.pendingKey, photoFileIds: \[\.\.\.this\.photoFileIds\] \}/)
}) })
-302
View File
@@ -1,302 +0,0 @@
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/warehouse-transfer/warehouse-transfer.vue',import.meta.url),'utf8')
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^import .+$/gm,'').replace('export default','page =')
function setup() {
const storage = new Map()
const scope = { CartonCountFields:{},PortalHeader:{},ScanTargetInput:{},session:{apiBase:'LOCAL',currentUser:{id:'OP',warehouseId:'WH'}},uni:{showModal: options=>options.success({confirm:true}),getStorageSync:key=>storage.get(key),setStorageSync:(key,value)=>storage.set(key,JSON.parse(JSON.stringify(value))),removeStorageSync:key=>storage.delete(key)} }
runInNewContext(script,scope)
const value = scope.page.data()
for(const [key,method] of Object.entries(scope.page.methods)) value[key]=method.bind(value)
for(const [key,get] of Object.entries(scope.page.computed)) Object.defineProperty(value,key,{get:()=>get.call(value)})
value.selected={transferNo:'WT1',packageCount:1,status:'WAIT_PDA_RECEIVING',items:[{id:'I1',skuCode:'A',quantity:4}],cartons:[{cartonMark:'BOX1',transferItemId:'I1',status:'EXPECTED'}]}
value.pallets=[{key:'P1',draftId:'P1',palletNo:'PALLET1',generated:true,bound:false}];value.restoreCartons();Object.assign(value.cartonBoxes[0],{length:10,width:20,height:30,weight:2});value.cartonBoxes[0].items[0].quantity=4
return {scope,value,storage}
}
test('deletion needs confirmation and cancel preserves box and measurements',async()=>{
const {value,scope}=setup(); let warning;
scope.uni.showModal=options=>{warning=options;options.success({confirm:false})};
await value.removeCarton(0);
assert.equal(value.cartonBoxes.length,1);assert.equal(value.cartonBoxes[0].weight,2);
assert.match(warning.content,/不计入本次实收/);assert.equal(warning.confirmText,'确认删除');
assert.equal(value.restorableCartons.length,0);assert.equal(value.deletingCarton,false);
})
test('deleted box survives reload and restores original identity, SKU, measurements and pallet',async()=>{
const {value}=setup(); await value.removeCarton(0);
assert.equal(value.cartonBoxes.length,0);assert.equal(value.restorableCartons.length,1);
value.restoreCartons();await value.restoreDeletedCarton();
const box=value.cartonBoxes[0];
assert.equal(box.cartonMark,'BOX1');assert.equal(box.weight,2);assert.equal(box.items[0].quantity,4);
assert.equal(box.transferPalletId,'P1');assert.equal(value.restorableCartons.length,0);
assert.equal(value.selected.packageCount,1);assert.equal(value.cartonConfirmed(box),false);
})
test('saved missing box can be restored after reopening count without increasing forecast',async()=>{
const {value,scope}=setup();
value.selected.cartons[0].status='NOT_RECEIVED';value.selected.cartons[0].countedAt='2026-10-16';
value.restoreCartons();assert.equal(value.cartonBoxes.length,0);
scope.reopenWarehouseTransferMeasurement=async()=>({...value.selected,cartons:value.selected.cartons.map(box=>({...box,countedAt:null}))});
await value.restoreDeletedCarton();
assert.equal(value.cartonsCounted,false);assert.equal(value.cartonBoxes[0].cartonMark,'BOX1');
assert.equal(value.selected.packageCount,1);assert.equal(value.restorableCartons.length,0);
value.cartonBoxes[0].weight=9;value.saveCartonDraft();value.restoreCartons();
assert.equal(value.cartonBoxes[0].cartonMark,'BOX1');assert.equal(value.cartonBoxes[0].weight,9);
})
test('previously deleted forecast box without an archive can still be restored',async()=>{
const {value}=setup();value.cartonBoxes=[];value.saveCartonDraft();value.restoreCartons();
assert.equal(value.restorableCartons.length,1);
await value.restoreDeletedCarton();
assert.equal(value.cartonBoxes[0].cartonMark,'BOX1');assert.equal(value.selected.packageCount,1);
})
test('summary toggle preserves measurements and volume uses three decimals',()=>{
const {value}=setup()
assert.equal(value.summaryCollapsed,false)
value.summaryCollapsed=true
assert.equal(value.cartonBoxes[0].weight,2)
Object.assign(value.cartonBoxes[0],{length:10,width:10,height:6})
value.addExtraCarton()
Object.assign(value.cartonBoxes[1],{length:10,width:10,height:6,weight:2})
assert.equal(value.palletVolume(value.pallets[0]),'0.001')
assert.equal(value.totalVolume,'0.001')
assert.match(source,/aria-label="添加 SKU"/)
assert.match(source,/aria-label="删除箱子"/)
})
test('remeasure preserves saved measurements and unlocks carton editing',async()=>{
const {value,scope}=setup();
const box={...value.cartonBoxes[0],countedAt:'2026-10-16',status:'COUNTED'};
value.selected.cartons=[box]; value.cartonRequestKey='old-key';
scope.reopenWarehouseTransferMeasurement=async()=>({...value.selected,cartons:[{...box,countedAt:null}]});
await value.remeasure();
assert.equal(value.cartonsCounted,false); assert.equal(value.cartonBoxes[0].weight,2);
assert.equal(value.cartonBoxes[0].transferPalletId,'P1'); assert.equal(value.cartonRequestKey,'');
assert.equal(value.cartonCountReady,true);
})
test('bound pallet deletion retains box values and requires reassignment',async()=>{
const {value,scope}=setup();
scope.uni.showModal=options=>options.success({confirm:true});
value.pallets[0].bound=true;
scope.deleteWarehouseTransferPallet=async()=>({...value.selected,pallets:[]});
await value.removePallet(0);
assert.equal(value.cartonBoxes[0].transferPalletId,''); assert.equal(value.cartonBoxes[0].weight,2);
assert.equal(value.generatedPallets.length,0); assert.equal(value.pallets.length,1);
assert.equal(value.cartonCountReady,false); assert.equal(value.cartonPalletFilter,'');
})
test('failed pallet deletion retains binding and measurements',async()=>{
const {value,scope}=setup();
scope.uni.showModal=options=>options.success({confirm:true});
scope.deleteWarehouseTransferPallet=async()=>{throw new Error('offline')};
await value.removePallet(0);
assert.equal(value.cartonBoxes[0].transferPalletId,'P1'); assert.equal(value.pallets[0].busy,false);
assert.equal(value.error.message,'offline');
})
test('transfer counts require valid pieces and measurements and record variance reason',()=>{
const {value}=setup();assert.equal(value.cartonCountReady,true)
value.cartonBoxes[0].items[0].quantity=0;assert.equal(value.cartonCountReady,false)
value.cartonBoxes[0].items[0].quantity=1.5;assert.equal(value.cartonCountReady,false)
value.cartonBoxes[0].items[0].quantity=4;value.cartonBoxes[0].height=0;assert.equal(value.cartonCountReady,false)
value.cartonBoxes[0].height=30;value.addExtraCarton();Object.assign(value.cartonBoxes[1],{length:10,width:20,height:30,weight:2});value.cartonBoxes[1].items[0].quantity=5
assert.equal(value.cartonCountReady,true);value.countDiscrepancyReason='多一箱';assert.equal(value.cartonCountReady,true)
assert.equal(value.cartonQuantity,9);assert.equal(value.cartonVolume,'0.012')
})
test('failed count unlocks the draft and each explicit retry sends current contents with a fresh key',async()=>{
const {value,scope}=setup();const requests=[]
scope.countWarehouseTransferCartons=async(no,request)=>{requests.push(request);throw new Error('offline')}
await value.confirmCartons();await value.confirmCartons()
assert.equal(requests.length,2);assert.notEqual(requests[0].idempotencyKey,requests[1].idempotencyKey)
value.cartonBoxes[0].items[0].quantity=8;await value.confirmCartons();assert.notEqual(requests[1].idempotencyKey,requests[2].idempotencyKey)
assert.equal(requests[2].boxes[0].items[0].quantity,8);assert.equal(value.cartonBoxes[0].items[0].quantity,8)
assert.equal(value.countingCartons,false);assert.equal(value.error.message,'offline')
})
test('draft restores additional cartons and discrepancy reason without crossing tasks',()=>{
const {value}=setup();value.addExtraCarton();value.countDiscrepancyReason='超收';value.saveCartonDraft();value.restoreCartons()
assert.equal(value.cartonBoxes.length,2);assert.equal(value.countDiscrepancyReason,'超收')
value.selected={...value.selected,transferNo:'WT2'};value.restoreCartons();assert.equal(value.cartonBoxes.length,1)
})
test('received count gates pallet completion and saving cannot switch active order',()=>{
const {value}=setup();assert.match(value.validationMessage,/先完成全部箱子/)
value.selected.cartons[0].countedAt='2026-10-15';value.cartonBoxes[0].transferPalletId='FOREIGN';assert.match(value.validationMessage,/已清点实收箱数/)
value.countingCartons=true;value.backToCandidates();assert.equal(value.selected.transferNo,'WT1')
})
test('batch applies only selected received boxes of one SKU and preserves independent drafts',()=>{
const {value}=setup()
value.selected.items.push({id:'I2',skuCode:'B'})
value.addExtraCarton();value.addExtraCarton();value.addExtraCarton()
value.cartonBoxes[2].items[0].skuCode='B'
value.cartonBoxes[3].received=false
value.selectBatchCartons(true)
assert.equal(value.batchTargetBoxes.length,2)
value.toggleBatchCarton(0)
Object.assign(value.batchBox,{length:32,width:24,height:18,weight:3.2})
value.batchBox.items[0].quantity=6
assert.equal(value.batchTotalQuantity,6)
value.applyBatchCartons()
assert.equal(value.cartonBoxes[0].items[0].quantity,4)
assert.equal(value.cartonBoxes[1].items[0].quantity,6)
assert.equal(value.cartonBoxes[2].items[0].quantity,'')
assert.equal(value.cartonBoxes[3].items[0].quantity,'')
value.batchBox.items[0].quantity=8
assert.equal(value.cartonBoxes[1].items[0].quantity,6)
value.restoreCartons()
assert.equal(value.cartonBoxes[1].items[0].quantity,6)
assert.equal(value.batchTargetBoxes.length,0)
value.changeBatchSku(1)
assert.equal(value.batchSku,'B')
assert.equal(value.batchBox.items[0].quantity,'')
})
test('batch guards invalid quantities, busy state, submitted facts and stale mixed SKU selections',()=>{
const {value}=setup();value.selectBatchCartons(true)
Object.assign(value.batchBox,{length:32,width:24,height:18,weight:3.2})
for(const quantity of [0,1.5,2147483648]){
value.batchBox.items[0].quantity=quantity;value.applyBatchCartons()
assert.equal(value.cartonBoxes[0].items[0].quantity,4)
}
value.batchBox.items[0].quantity=6;value.countingCartons=true;value.applyBatchCartons()
assert.equal(value.cartonBoxes[0].items[0].quantity,4)
value.countingCartons=false;value.cartonBoxes[0].items[0].skuCode='B'
assert.equal(value.batchTargetBoxes.length,0);value.applyBatchCartons()
assert.equal(value.cartonBoxes[0].items[0].quantity,4)
value.cartonBoxes[0].items[0].skuCode='A';value.selected.cartons[0].countedAt='now';value.applyBatchCartons()
assert.equal(value.cartonBoxes[0].items[0].quantity,4)
})
test('copy uses preceding received same SKU without sharing quantity objects or changing box identity',()=>{
const {value}=setup();value.addExtraCarton();value.addExtraCarton()
value.cartonBoxes[1].items[0].skuCode='B'
value.copyPreviousCarton(1);assert.equal(value.cartonBoxes[1].items[0].quantity,'')
const identity=value.cartonBoxes[2].localKey
value.copyPreviousCarton(2)
assert.equal(value.cartonBoxes[2].length,10)
assert.equal(value.cartonBoxes[2].items[0].quantity,4)
assert.equal(value.cartonBoxes[2].localKey,identity)
value.cartonBoxes[2].items[0].quantity=9
assert.equal(value.cartonBoxes[0].items[0].quantity,4)
value.cartonBoxes[0].received=false;assert.equal(value.previousMatchingCarton(2),undefined)
})
test('pallet totals follow actual box assignments and exclude missing boxes',()=>{
const {value}=setup();value.pallets.push({key:'P2',draftId:'P2',palletNo:'PALLET2',generated:true})
value.addExtraCarton();Object.assign(value.cartonBoxes[1],{length:40,width:20,height:10,weight:3,transferPalletId:'P2'});value.cartonBoxes[1].items[0].quantity=6
assert.equal(value.palletSummary(value.pallets[0]).cartonCount,1)
assert.equal(value.palletSummary(value.pallets[1]).volume,0.008)
assert.equal(value.totalWeight,'5.00')
value.assignCartonPallet(value.cartonBoxes[1],0)
assert.equal(value.palletSummary(value.pallets[0]).cartonCount,2)
assert.equal(value.palletSummary(value.pallets[1]).cartonCount,0)
value.cartonBoxes[1].received=false
assert.equal(value.totalWeight,'2.00')
value.cartonBoxes[0].transferPalletId='FOREIGN';assert.equal(value.cartonCountReady,false)
})
test('pallet number can be created before measurements and new boxes receive that identity',async()=>{
const {value,scope}=setup();const requests=[]
value.pallets=[{key:'NEW',generated:false,busy:false}];value.cartonBoxes[0].transferPalletId=''
const pallet={id:'CREATED',sequenceNo:1,status:'GENERATED',palletNo:'NEWPALLET'}
scope.saveWarehouseTransferPalletDraft=async(no,request)=>{requests.push(request);return {pallets:[{...pallet,status:'DRAFT'}]}}
scope.generateWarehouseTransferPallet=async()=>({pallets:[pallet]})
await value.generatePallet(value.pallets[0])
assert.equal(requests.length,1);assert.equal('lengthCm' in requests[0],false);assert.equal('grossWeightKg' in requests[0],false)
assert.equal(value.cartonBoxes[0].transferPalletId,'CREATED')
assert.equal(value.pallets[0].palletNo,'NEWPALLET')
})
// 最终完成入口需要区分无差异、取消、空白原因和网络重试。
for (const answer of [{confirm:false}, {confirm:true,content:' '}, {confirm:true,content:'少到一箱'}]) {
test('completion handles discrepancy answer '+JSON.stringify(answer),async()=>{
const {value,scope}=setup();let requests=[];let prompts=0
value.selected.items[0].quantity=5;value.selected.cartons[0].countedAt='2026-10-16'
value.pallets[0].bound=true;value.photoFileIds=[91]
scope.uni.showModal=options=>{prompts++;options.success(answer)}
scope.palletizeWarehouseTransfer=async(no,payload)=>{requests.push(payload);throw new Error('offline')}
await value.submit()
assert.equal(prompts,1);assert.equal(value.submitting,false)
assert.equal(requests.length,answer.confirm && answer.content?.trim()?1:0)
if(requests.length){assert.equal(requests[0].discrepancyReason,'少到一箱');await value.submit();assert.notEqual(requests[0].idempotencyKey,requests[1].idempotencyKey)}
})
}
test('matching SKU counts with different carton count submit without prompting, and missing photos block before prompting',async()=>{
const {value,scope}=setup();let prompts=0;let requests=[]
value.selected.packageCount=9;value.selected.cartons[0].countedAt='2026-10-16';value.pallets[0].bound=true
scope.uni.showModal=()=>{prompts++}
scope.palletizeWarehouseTransfer=async(no,payload)=>{requests.push(payload);throw new Error('offline')}
await value.submit();assert.equal(requests.length,0);assert.equal(prompts,0)
value.photoFileIds=[91];await value.submit();assert.equal(prompts,0);assert.equal(requests.length,1);assert.equal(requests[0].discrepancyReason,null)
})
// 逐箱确认收起、再次编辑及草稿恢复必须保留其他箱的确认状态。
test('individual confirmation collapses only that box and resumes from draft',async()=>{
const {value,scope}=setup();value.addExtraCarton()
Object.assign(value.cartonBoxes[1],{length:10,width:20,height:30,weight:2});value.cartonBoxes[1].items[0].quantity=5
let saves=0;scope.countWarehouseTransferCartons=async()=>{saves++;throw new Error('offline')}
await value.confirmSingleCarton(value.cartonBoxes[0])
assert.equal(value.cartonConfirmed(value.cartonBoxes[0]),true);assert.equal(value.allCartonsConfirmed,false);assert.equal(saves,0)
value.restoreCartons();assert.equal(value.cartonConfirmed(value.cartonBoxes[0]),true)
await value.editCarton(value.cartonBoxes[0]);assert.equal(value.cartonConfirmed(value.cartonBoxes[0]),false)
await value.confirmSingleCarton(value.cartonBoxes[0]);await value.confirmSingleCarton(value.cartonBoxes[1])
assert.equal(value.allCartonsConfirmed,true);assert.equal(saves,1);assert.equal(value.error.message,'offline')
value.cartonBoxes[0].weight=3;assert.equal(value.cartonConfirmed(value.cartonBoxes[0]),false);assert.equal(value.allCartonsConfirmed,false)
})
test('saved boxes restore collapsed and editing reopens only selected box',async()=>{
const {value,scope}=setup()
const first={...value.cartonBoxes[0],quantity:4,countedAt:'2026-10-16',status:'COUNTED'}
const second={...first,cartonMark:'BOX2'}
value.selected.cartons=[first,second];value.restoreCartons()
assert.equal(value.cartonBoxes.every(box=>value.cartonConfirmed(box)),true)
scope.reopenWarehouseTransferMeasurement=async()=>({...value.selected,cartons:value.selected.cartons.map(box=>({...box,countedAt:null}))})
await value.editCarton(value.cartonBoxes[0])
assert.equal(value.cartonsCounted,false);assert.equal(value.cartonConfirmed(value.cartonBoxes[0]),false);assert.equal(value.cartonConfirmed(value.cartonBoxes[1]),true)
value.restoreCartons();assert.equal(value.cartonConfirmed(value.cartonBoxes[1]),true)
})
test('failed reopening preserves confirmed data and invalid box cannot confirm',async()=>{
const {value,scope}=setup();value.cartonBoxes[0].weight=0
await value.confirmSingleCarton(value.cartonBoxes[0]);assert.equal(value.cartonConfirmed(value.cartonBoxes[0]),false)
value.cartonBoxes[0].weight=2;value.selected.cartons=[{...value.cartonBoxes[0],countedAt:'2026-10-16',status:'COUNTED'}];value.restoreCartons()
scope.reopenWarehouseTransferMeasurement=async()=>{throw new Error('offline')}
await value.editCarton(value.cartonBoxes[0]);assert.equal(value.cartonsCounted,true);assert.equal(value.cartonConfirmed(value.cartonBoxes[0]),true)
})
// 箱数变化不影响 SKU 门禁,异 SKU 正负差异不能按总件数抵消。
test('SKU differences compare each SKU and ignore carton count',()=>{
const {value}=setup();value.selected.packageCount=9
assert.equal(value.hasSkuReceiptDifference,false)
value.selected.items.push({id:'I2',skuCode:'B',quantity:3})
value.cartonBoxes[0].items=[{skuCode:'A',quantity:3},{skuCode:'B',quantity:4}]
assert.equal(value.cartonQuantity,value.forecastSkuQuantity)
assert.equal(value.skuReceiptDifferences.length,2)
value.cartonBoxes[0].items=[{skuCode:'A',quantity:4},{skuCode:'B',quantity:3}]
assert.equal(value.hasSkuReceiptDifference,false)
})
// 客户补箱后,新增预报箱进入PDA,原本机草稿和删除标记不丢失。
test('forecast additions merge into existing draft without losing edited boxes',async()=>{
const {value}=setup();value.cartonBoxes[0].weight=7;value.saveCartonDraft()
value.selected.packageCount=2;value.selected.cartons.push({cartonMark:'BOX2',transferItemId:'I1',status:'EXPECTED'})
value.restoreCartons();assert.equal(value.cartonBoxes.length,2);assert.equal(value.cartonBoxes[0].weight,7);assert.equal(value.cartonBoxes[1].cartonMark,'BOX2')
await value.removeCarton(0);value.restoreCartons();assert.equal(value.cartonBoxes.length,1);assert.equal(value.cartonBoxes[0].cartonMark,'BOX2')
})
test('count payload carries forecast version for stale snapshot rejection',async()=>{
const {value,scope}=setup();let payload
scope.countWarehouseTransferCartons=async(no,request)=>{payload=request;throw new Error('offline')}
await value.confirmCartons();assert.equal(payload.forecastPackageCount,1);assert.equal(payload.boxes[0].cartonMark,'BOX1')
})
test('新增预报箱无SKU绑定时恢复为可编辑箱,保留已有草稿', () => {
const { value } = setup()
value.selected.cartons.push({ cartonMark: 'NEW-UNBOUND', cartonIndex: 2, status: 'EXPECTED', transferItemId: null })
value.selected.packageCount = 2
value.restoreCartons()
const added = value.cartonBoxes.find(box => box.cartonMark === 'NEW-UNBOUND')
assert.ok(added)
assert.ok(value.declaredSkuCodes.includes(added.items[0].skuCode))
})
@@ -1,127 +0,0 @@
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/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^import .+$/gm, '').replace('export default', 'return')
const page = new Function('CartonCountFields', 'PortalHeader', 'ScanTargetInput', 'session', 'uni', script)({}, {}, {}, { currentUser: null }, { showModal: options => options.success({confirm:true}), getStorageSync: key => globalThis.uni?.getStorageSync(key) })
function context() {
const ctx = { ...page.data(), selected: { transferNo: 'WT1', packageCount: 2, items: [{ id: '1', skuCode: 'A' }, { id: '2', skuCode: 'B' }], cartons: [] }, saveCartonDraft() {} }
for (const [name, method] of Object.entries(page.methods)) ctx[name] = method.bind(ctx)
for (const [name, getter] of Object.entries(page.computed)) Object.defineProperty(ctx, name, { get: getter.bind(ctx), configurable: true })
ctx.saveCartonDraft = () => {}
return ctx
}
const box = (sku = 'A', quantity = 3) => ({ received: true, length: 10, width: 20, height: 30, weight: 2, items: [{ skuCode: sku, quantity }] })
test('one carton can add distinct forecast SKUs and validates every quantity', () => {
const ctx = context(), current = box()
ctx.addCartonSku(current)
assert.deepEqual(current.items, [{ skuCode: 'A', quantity: 3 }, { skuCode: 'B', quantity: '' }])
assert.equal(ctx.validCartonSkuLines(current), false)
current.items[1].quantity = 7
assert.equal(ctx.validCartonSkuLines(current), true)
ctx.addCartonSku(current)
assert.equal(current.items.length, 2)
current.items[1].skuCode = 'A'
assert.equal(ctx.validCartonSkuLines(current), false)
current.items[1].skuCode = 'FOREIGN'
assert.equal(ctx.validCartonSkuLines(current), false)
})
test('single SKU batch changes never overwrite mixed carton contents', () => {
const ctx = context(), single = box(), mixed = box()
mixed.items.push({ skuCode: 'B', quantity: 7 })
ctx.cartonBoxes = [single, mixed]; ctx.batchSku = 'A'
ctx.selectBatchCartons(true)
assert.deepEqual(ctx.batchSelectedIndexes, [0])
ctx.toggleBatchCarton(1)
assert.deepEqual(ctx.batchSelectedIndexes, [0])
assert.equal(ctx.previousMatchingCarton(1), null)
assert.equal(ctx.cartonQuantity, 13)
})
test('delete only changes actual draft and preserves forecast; additions keep their own SKU rows', async () => {
const ctx = context()
ctx.cartonBoxes = [box(), box('B')]; ctx.batchSelectedIndexes = [1]
await ctx.removeCarton(0)
assert.equal(ctx.cartonBoxes.length, 1)
assert.equal(ctx.selected.packageCount, 2)
assert.deepEqual(ctx.batchSelectedIndexes, [])
ctx.addExtraCarton()
assert.equal(ctx.cartonBoxes.length, 2)
assert.equal(ctx.cartonBoxes[1].items[0].skuCode, 'A')
ctx.cartonBoxes[1].items[0].quantity = 8
assert.equal(ctx.cartonBoxes[0].items[0].quantity, 3)
})
test('draft reload preserves deletions, including an empty actual list', () => {
const ctx = context()
ctx.selected.cartons = [{ cartonMark: 'BOX1', transferItemId: '1', status: 'EXPECTED' }, { cartonMark: 'BOX2', transferItemId: '2', status: 'EXPECTED' }]
let saved = { forecastMarks: ['BOX1','BOX2'], boxes: [{ ...box('B'), cartonMark: 'BOX2', transferItemId: '2' }], discrepancyReason: '少到一箱' }
globalThis.uni = { getStorageSync: () => saved }
try {
ctx.restoreCartons()
assert.equal(ctx.cartonBoxes.length, 1)
assert.equal(ctx.cartonBoxes[0].cartonMark, 'BOX2')
assert.equal(ctx.countDiscrepancyReason, '少到一箱')
saved = { forecastMarks: ['BOX1','BOX2'], boxes: [], discrepancyReason: '重新录入' }
ctx.restoreCartons()
assert.equal(ctx.cartonBoxes.length, 0)
assert.equal(ctx.selected.cartons.length, 2)
} finally { delete globalThis.uni }
})
test('100 same SKU cartons accept two distinct specification groups and independent exceptions', () => {
const ctx = context()
ctx.selected.packageCount = 100
ctx.pallets = [{ generated: true, draftId: 'P1' }, { generated: true, draftId: 'P2' }]
ctx.cartonBoxes = Array.from({ length: 100 }, (_, index) => ({ ...box('A', ''), localKey: `BOX-${index}`, transferItemId: '1', length: '', width: '', height: '', weight: '' }))
ctx.batchSku = 'A'; ctx.batchPalletId = 'P1'; ctx.batchBox = { ...box('A', 12), length: 40, width: 30, height: 20, weight: 5 }
ctx.selectBatchCartons(true)
for (let index = 60; index < 100; index++) ctx.toggleBatchCarton(index)
assert.equal(ctx.batchTargetBoxes.length, 60)
assert.equal(ctx.batchTotalQuantity, 720)
ctx.applyBatchCartons()
assert.equal(ctx.cartonBoxes[59].items[0].quantity, 12)
assert.equal(ctx.cartonBoxes[60].items[0].quantity, '')
ctx.selectBatchCartons(true)
for (let index = 0; index < 60; index++) ctx.toggleBatchCarton(index)
ctx.batchPalletId = 'P2'; ctx.batchBox = { ...box('A', 8), length: 50, width: 40, height: 30, weight: 7 }
ctx.applyBatchCartons()
assert.equal(ctx.palletSummary(ctx.pallets[0]).cartonCount, 60)
assert.equal(ctx.palletSummary(ctx.pallets[1]).cartonCount, 40)
assert.equal(ctx.cartonQuantity, 1040)
assert.equal(ctx.totalWeight, '580.00')
assert.equal(ctx.totalVolume, '3.840')
assert.equal(ctx.cartonCountReady, true)
ctx.cartonBoxes[0].items[0].quantity = 11
ctx.cartonBoxes[0].weight = 6
assert.equal(ctx.cartonBoxes[1].items[0].quantity, 12)
assert.equal(ctx.cartonBoxes[1].weight, 5)
assert.equal(ctx.batchBox.items[0].quantity, 8)
assert.equal(ctx.cartonQuantity, 1039)
assert.equal(ctx.totalWeight, '581.00')
})
test('copy previous matching SKU skips other SKUs and preserves the target pallet', () => {
const ctx = context()
ctx.cartonBoxes = [{ ...box('A', 12), length: 40, width: 30, height: 20, weight: 5, transferPalletId: 'P1' }, box('B', 99), { ...box('A', ''), transferPalletId: 'P2' }]
ctx.copyPreviousCarton(2)
assert.equal(ctx.cartonBoxes[2].length, 40)
assert.equal(ctx.cartonBoxes[2].weight, 5)
assert.equal(ctx.cartonBoxes[2].items[0].quantity, 12)
assert.equal(ctx.cartonBoxes[2].transferPalletId, 'P2')
ctx.cartonBoxes[2].items[0].quantity = 9
assert.equal(ctx.cartonBoxes[0].items[0].quantity, 12)
})
test('预报箱可调整原SKU,新增无绑定箱也可清点', () => {
const ctx = context(); const current = { ...box('B', 4), cartonMark: 'FORECAST-1', transferItemId: '1' }
assert.equal(ctx.validCartonSkuLines(current), true)
current.transferItemId = null
assert.equal(ctx.validCartonSkuLines(current), true)
current.items[0].skuCode = 'OTHER'
assert.equal(ctx.validCartonSkuLines(current), false)
assert.doesNotMatch(source, /:lock-first-sku="Boolean\(box.cartonMark\)"/)
})
+8 -6
View File
@@ -12,7 +12,7 @@ import {
reportRelabelException, reviewRelabelTask, skipRelabelUnit reportRelabelException, reviewRelabelTask, skipRelabelUnit
} from '../src/api/relabel.js' } from '../src/api/relabel.js'
import { import {
resetWarehouseWork, restoreWarehouseWork, updateWarehouseWork, releaseWarehouseKey, resetWarehouseWork, restoreWarehouseWork, updateWarehouseWork,
warehouseIdempotencyKey, warehouseWork warehouseIdempotencyKey, warehouseWork
} from '../src/stores/warehouse-work.js' } from '../src/stores/warehouse-work.js'
@@ -126,10 +126,11 @@ test('relabel review opens a pending task as customer-confirmation read-only det
assert.equal(visible.call({ ...context, candidates: [] }), false) 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, stage: 'review', current: { ...context.current, taskStatus: 'WAIT_REVIEW' } }), true)
assert.equal(visible.call({ ...context, current: { ...context.current, taskStatus: 'WAIT_REVIEW' } }), false) 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, /<view v-else class="task-list">[\s\S]*@tap="openReview\(item\)"/)
assert.match(page, /查看并复核/) assert.match(page, /查看并复核/)
assert.match(page, /async openReview\(item\)/) assert.match(page, /async openReview\(item\)/)
assert.match(page, /现场换标已完成,等待客户确认/) assert.match(page, /现场换标已经完成。请客户在客户工作台核对完工结果并确认/)
assert.doesNotMatch(page, /@tap="review\('APPROVE'\)"/) assert.doesNotMatch(page, /@tap="review\('APPROVE'\)"/)
assert.match(page, /<button v-if="stage !== 'review'" class="link-button"[^>]*@tap="bootstrap"/) assert.match(page, /<button v-if="stage !== 'review'" class="link-button"[^>]*@tap="bootstrap"/)
assert.match(page, /if \(this\.stage === 'execute'\) this\.startScanner\(\)/) assert.match(page, /if \(this\.stage === 'execute'\) this\.startScanner\(\)/)
@@ -144,8 +145,8 @@ test('relabel review opens a pending task as customer-confirmation read-only det
test('returned relabel tasks are highlighted, sorted first, and still obey the single active task lock', async () => { 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') const page = await read('../src/pages/relabel/relabel.vue')
assert.match(page, /item\.reviewDecision === 'REWORK'[\s\S]*退回重做/) assert.match(page, /item\.reviewDecision === 'REWORK'[\s\S]*退回重做[\s\S]*优先处理/)
assert.match(page, /current\.reviewDecision === 'REWORK' && current\.reviewRemark[\s\S]*\{\{ current\.reviewRemark \}\}/) assert.match(page, /退回备注:\{\{ item\.reviewRemark \}\}/)
assert.match(page, /\.sort\(\(left, right\) => Number\(right\.reviewDecision === 'REWORK'\) - Number\(left\.reviewDecision === 'REWORK'\)/) 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, /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, /if \(active && active\.relabelNo !== item\.relabelNo\) return this\.promptActiveTask\(active\)/)
@@ -164,10 +165,11 @@ test('warehouse drafts are isolated by operator and warehouse and retain retry k
updateWarehouseWork('move', { sourcePalletCode: 'PLT-1', targetLocationCode: 'A-01', reason: 'capacity' }) updateWarehouseWork('move', { sourcePalletCode: 'PLT-1', targetLocationCode: 'A-01', reason: 'capacity' })
const first = warehouseIdempotencyKey('move', 'move', { palletCode: 'PLT-1', locationCode: 'A-01' }) const first = warehouseIdempotencyKey('move', 'move', { palletCode: 'PLT-1', locationCode: 'A-01' })
const retry = warehouseIdempotencyKey('move', 'move', { palletCode: 'PLT-1', locationCode: 'A-01' }) const retry = warehouseIdempotencyKey('move', 'move', { palletCode: 'PLT-1', locationCode: 'A-01' })
assert.notEqual(retry.value, first.value) assert.equal(retry.value, first.value)
warehouseWork.move.sourcePalletCode = '' warehouseWork.move.sourcePalletCode = ''
assert.equal(restoreWarehouseWork('operator-a:warehouse-a'), true) assert.equal(restoreWarehouseWork('operator-a:warehouse-a'), true)
assert.equal(warehouseWork.move.sourcePalletCode, 'PLT-1') assert.equal(warehouseWork.move.sourcePalletCode, 'PLT-1')
releaseWarehouseKey('move', first.signature)
assert.equal(restoreWarehouseWork('operator-b:warehouse-a'), false) assert.equal(restoreWarehouseWork('operator-b:warehouse-a'), false)
assert.equal(warehouseWork.move.sourcePalletCode, '') assert.equal(warehouseWork.move.sourcePalletCode, '')
}) })
@@ -189,7 +191,7 @@ test('new operation pages wire scans, manual inputs, conflict refresh, loading a
} }
for (const page of [stocktake, move, relabel]) { for (const page of [stocktake, move, relabel]) {
assert.match(page, /warehouseIdempotencyKey/) assert.match(page, /warehouseIdempotencyKey/)
assert.doesNotMatch(page, /releaseWarehouseKey/) assert.match(page, /releaseWarehouseKey/)
assert.match(page, /isConflict[\s\S]*刷新/) assert.match(page, /isConflict[\s\S]*刷新/)
} }
assert.match(stocktake, /expectedVersion: item\.version/) assert.match(stocktake, /expectedVersion: item\.version/)
+10 -10
View File
@@ -7,12 +7,12 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
const enabled = operations.filter((operation) => operation.available) const enabled = operations.filter((operation) => operation.available)
assert.deepEqual(enabled.map(({ module, name }) => ({ module, name })), [ assert.deepEqual(enabled.map(({ module, name }) => ({ module, name })), [
{ module: 'inbound', name: '拆柜收货' }, { module: 'inbound', name: '拆柜收货' },
{ module: 'inbound', name: '仓储转运测量列表' }, { module: 'inbound', name: '转运测量' }, { module: 'inbound', name: '仓储转运测量列表' }, { module: 'inbound', name: '转运测量作业' },
{ module: 'inbound', name: '退货入库' }, { module: 'inbound', name: '退货入库' },
{ module: 'inbound', name: '合箱' }, { module: 'inbound', name: '退货合箱' },
{ module: 'inbound', name: '退货清点' }, { module: 'inbound', name: '退货箱内清点' },
{ module: 'outbound', name: '拣货' }, { module: 'outbound', name: '排车拣货' },
{ module: 'outbound', name: '出库' }, { module: 'outbound', name: '出库作业' },
{ module: 'inventory', name: '商品移库' }, { module: 'inventory', name: '商品移库' },
{ module: 'inventory', name: '托盘管理' }, { module: 'inventory', name: '托盘管理' },
{ module: 'relabel', name: '换标列表' }, { module: 'relabel', name: '换标列表' },
@@ -23,13 +23,13 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
{ module: 'query', name: '托盘查询' } { module: 'query', name: '托盘查询' }
]) ])
assert.equal(operations.find((item) => item.name === '托盘管理')?.route, '/pages/pallet/pallet') assert.equal(operations.find((item) => item.name === '托盘管理')?.route, '/pages/pallet/pallet')
assert.equal(operations.find((item) => item.name === '拣货')?.route, '/pages/dispatch/dispatch') assert.equal(operations.find((item) => item.name === '排车拣货')?.route, '/pages/dispatch/dispatch')
assert.equal(operations.find((item) => item.name === '出库')?.route, '/pages/outbound/outbound') 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-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-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/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=list')
assert.equal(operations.find((item) => item.name === '转运测量')?.route, '/pages/warehouse-transfer/warehouse-transfer?mode=work') assert.equal(operations.find((item) => item.name === '转运测量作业')?.route, '/pages/warehouse-transfer/warehouse-transfer?mode=work')
assert.equal(operations.some((item) => item.name === '库存盘点'), false) assert.equal(operations.some((item) => item.name === '库存盘点'), false)