diff --git a/.dockerignore b/.dockerignore
deleted file mode 100644
index de0e642..0000000
--- a/.dockerignore
+++ /dev/null
@@ -1,12 +0,0 @@
-# Only compiled H5 assets and image configuration enter the build context.
-**
-!deploy/
-!deploy/Dockerfile.h5
-!deploy/nginx.conf
-!dist/
-!dist/build/
-!dist/build/h5/
-!dist/build/h5/**
-
-!deploy/Dockerfile.h5.production
-!deploy/nginx.production.conf.template
diff --git a/AGENTS.md b/AGENTS.md
index 31693af..68cc29b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,104 +1,98 @@
-# Overseas PDA Uni-app Instructions
+# 海外 PDA 指引
## 快速读取顺序
-1. 先用“页面与能力快速索引”定位 `src/pages.json`、工作台或最小页面;页面业务配置从 `src/data/overseas-workbench.js` 进入,不扫描 `src/uni_modules/`。
-2. 登录、Token 或统一入口请求先读 `src/api/auth.js`、`src/stores/session.js`、`src/config/runtime.js` 和 `../../docs/统一认证与服务通信契约.md`。
-3. 沿实际 API 定位:入仓/库存契约读 `../../baiyue-firstleg/lastleg-service/AGENTS.md`;资源契约读 `../../baiyue-firstleg/warehouse-overseas-service/AGENTS.md`;网关路径或鉴权问题才读 `../../baiyue-firstleg/gateway/AGENTS.md`。跨模块流程或入口不明才读 `../../docs/海外仓业务/README.md`;纯页面改动不自动读取后端指引。
+1. 按下表定位最小页面;业务配置见 `src/data/overseas-workbench.js`,不扫 `src/uni_modules/`。
+2. 登录/Token/统一请求先读下表登录API/会话、`src/config/runtime.js`、`../../docs/统一认证与服务通信契约.md`。
+3. 沿实际 API 读直接服务指引:入仓/库存 `../../baiyue-firstleg/lastleg-service/AGENTS.md`,资源 `../../baiyue-firstleg/warehouse-overseas-service/AGENTS.md`;网关路径/鉴权问题才读 `../../baiyue-firstleg/gateway/AGENTS.md`。跨模块或入口不明才读 `../../docs/海外仓业务/README.md`。
-最小读取集按任务保持在适用指引和目标页面/API;纯展示任务不增加服务指引,服务端判断才读直接服务指引,跨服务状态或鉴权无法判断时再增加相关方。读取预算遵循根指引的行数与字符数标准,先读适用规则,再检索业务命中段,不全文加载海外仓 README。
+仅读适用指引/目标页面/API;纯页面不读后端,跨服务状态/鉴权不明才扩读。遵根预算,先规则后命中段,不全文读海外仓 README。
## 项目定位
-- 本项目是“柏悦物流海外版”独立 uni-app Vue 3 工程,面向海外仓现场人员。
-- 工程必须独立安装、构建和发布,不得依赖 `apps/pda-uniapp/` 或仓库外 `pda-shared` 目录。
-- 当前设备端业务范围包括海外仓入仓、退货签收与合箱、库内、换标、库存/库位/托盘查询,以及排车拣货、普通/一件代发出库任务、任务完成和发车;派送与客户回访由 Admin/Client 承载,国内集装箱拣货/装柜由国内 PDA 承载。
+- “柏悦物流海外版”面向海外仓现场人员,uni-app Vue 3 独立安装/构建/发布,不依赖 `apps/pda-uniapp/` 或仓库外 `pda-shared`。
+- 业务/任务完成见下表;派送/客户回访归 Admin/Client,国内集装箱拣货/装柜归国内 PDA。
## 服务与发布边界
-- Auth 是唯一账号和 Token 签发方;认证和业务请求共用统一 Gateway,App 根地址只读 VITE_API_BASE_URL,H5 使用 VITE_H5_API_BASE_URL。
-- 登录、刷新和本地会话必须校验统一 Token 的平台主体、成员、有效期和冻结海外仓;`OVERSEAS_WAREHOUSE_PDA_OPERATOR` 与 `lastleg:pda:operate` 只从 Auth 登录/刷新响应的顶层权限快照读取并持久化,不得解析 JWT 权限。权限快照缺失、无效、过期、跨主体或撤权响应必须 fail-closed 清理会话,不得只依赖页面入口隐藏。
-- 并发 401 只允许一次 refresh;统一 Token 和权限快照完整校验后原子替换,主体或绑定仓改变时清理会话并重新登录,每个原请求或照片上传最多重放一次。刷新失败清理本地会话,业务错误和网络错误保留 code、requestId、traceId 或 network 诊断。
-- 国内与海外 AppID、Android 包名、签名、版本号、构建产物和在线更新通道必须独立。
-- `src/manifest.json` 中的 AppID 在正式打包前由海外版所属 DCloud 账号创建;不得伪造或复用国内 AppID。
-- 环境地址只通过 `.env.local` 等未提交环境文件配置,不写入源码。
-- 海外 Android App 启动后仅在 `APP-PLUS` 调用 `src/utils/app-update.js`,通过海外独立 `VITE_PDA_UPDATE_URL` 检查 HTTPS APK/WGT;强制更新不可跳过,WGT 安装后立即重启。更新服务、AppID、包名、签名和版本序列均不得与国内 PDA 混用。
+- H5只上传Nginx站点,禁止容器发布及重跑旧灰度脚本;步骤见`README.md`。
+- Auth 唯一签发账号/Token;认证/业务共用 Gateway,App 根地址只读 VITE_API_BASE_URL,H5 用 VITE_H5_API_BASE_URL。
+- 登录/刷新/会话校验Token平台主体、成员、有效期、冻结仓;`OVERSEAS_WAREHOUSE_PDA_OPERATOR`、`lastleg:pda:operate` 仅读存Auth登录/刷新顶层快照,禁解析JWT权限。快照缺失/无效/过期/跨主体或撤权均fail-closed清会话,不能只藏入口。
+- 并发401仅一次refresh,完整校验Token/快照再原子替换;主体/仓变化清会话重登录,原请求/照片上传最多重放一次。刷新失败清会话;业务/网络错留code、requestId、traceId或network诊断。
+- 国内/海外 AppID、Android 包名、签名、版本号、产物、更新通道独立;`src/manifest.json` AppID须正式打包前由海外所属DCloud账号创建,禁伪造/复用国内AppID。
+- 环境地址仅配 `.env.local` 等未提交环境文件,不入源码。
+- Android启动仅在 `APP-PLUS` 调 `src/utils/app-update.js`,以独立 `VITE_PDA_UPDATE_URL` 查HTTPS APK/WGT;强更禁跳过,WGT安装即重启。更新服务/标识/签名/版本序列不得与国内 PDA 混用。
-## 工程入口
-
-- 源码根:`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。库存盘点底层页面与接口仍保留,但当前工作台不开放入口。
+- 源码根 `src/`;扫码广播 Action=`android.intent.action.SCANRESULT`,Extra=`value`。
+- 均接真实PDA API;入仓账号锁柜、逐码核平;退货号/SKU候选进入,按本包实收生成退货箱/计费批次。换标含收货/执行/复核;盘点页面/API保留、工作台隐藏。
## 页面与能力快速索引
-| 目标 | 入口 | 继续查询 |
-|---|---|---|
-| 页面注册与启动 | `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/pages/module/module.vue` | `src/data/overseas-workbench.js` 是模块与作业可用性入口;首页通过 `/api/pda/v1/last-leg/receiving-containers/warehouse-profile` 读取 Token 绑定仓的中文主档名称 |
-| 中英西语言设置 | `src/i18n/index.js`、`src/i18n/locales/`、`src/pages/profile/profile.vue` | 个人中心可选择并持久化 `zh-CN`、`en`、`es`;当前业务翻译资源仍为空,页面业务文案继续使用中文,不得把语言选择入口误报为全量翻译完成 |
-| 柜号签收、照片与入仓真实作业 | `src/pages/operation/operation.vue`、`src/components/ReceivingSessionSummary.vue`、`src/api/inbound.js`、`src/api/receiving-adapter.js`、`src/api/overseas.js`、`src/stores/receiving.js` | 签收前展开显示国内空仓、半仓、满仓、锁仓和带锁码五类整柜只读照片,收货中折叠查看,单图加载失败只允许重试且不阻断拆柜;海外三个固定照片角色各一张,支持现场拍照、鉴权预览及更换失败保留原图;多计划同柜直接展示服务端 `assigned/mergeAllowed/urgent/mergeHint`,合托明确为现场可选而非完成门禁;扫码实收确认计划生成阶段已预留的真实托盘和库位,校验卡展示预留库位编码及 `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/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` 时恢复对应排车。详情提供独立返回列表入口;托盘业务码扫码拣货、幂等完成前消费服务端 `canPick/pickBlockReason` 调度审核结果,整车发车消费 `canDepart/departBlockReason`(含理货审核)。公网前缀 `/api/pda/v1/last-leg/dispatches/**` |
-| 整车发车与证照提醒 | 同上 `dispatch.vue`、`fulfillment.js` 的 `tripDepartureRequest` | 显示 `tripNo/tripDispatchCount/driverWarning`;发车提交 `tripRecordVersion` 对应的 `recordVersion`,幂等键按车次和版本保留。响应必须为该车次 `IN_TRANSIT` 且包含当前排车,随后读取服务端任务状态再释放幂等键;不能把整车状态当子单状态,也不能缺省伪造 `DEPARTED`。证照即将到期提醒不独立禁用按钮,门禁仍以服务端结果为准 |
-| 普通/一件代发出库 | `src/pages/outbound/outbound.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 首次进入清空扫描框并展示任务列表和“上次作业”标记,不自动恢复旧详情;详情提供独立返回列表入口。逐箱扫码、DP 托盘整托拣取及幂等完成保持不变;扫描排车业务号显式跳转排车 `?resume=1`。公网前缀 `/api/pda/v1/last-leg/outbound-tasks/**`;派送由 Admin/Client 承载 |
-| 扫码设备与诊断 | `src/utils/scan-broadcast.js`、`src/pages/scanner-test/scanner-test.vue` | `src/components/ScanTargetInput.vue`、声音反馈工具;广播 Action/Extra 见本文件服务边界 |
-| 作业记录与人员 | `src/pages/history/history.vue`、`src/pages/profile/profile.vue` | 会话隔离、退出清理和后续真实作业历史契约 |
-| 业务标识与内部 ID 隔离 | `tests/business-identifiers-ui.test.mjs`;页面入口 `src/pages/`、`src/components/`,会话映射 `src/stores/session.js` | 页面只展示集装箱号、入仓码、箱码、托盘码、库位码、仓库名称等业务值;不得以 orderId、inboundId、planId、warehouseId、palletId、locationId、user.id 等内部 ID 或截断值回退,错误诊断保留 code、requestId、traceId |
-| 本地契约测试 | `tests/project-contract.test.mjs`、`navigation.test.mjs`、`scan-broadcast.test.mjs`、`audio-feedback.test.mjs` | 运行 `npm test`;不代表真实海外 API 或真机验收 |
+路径相对应用根;[业务索引](docs/business-index.md)存API/字段/状态/恢复。应用根 `rg -n -F '任务词或文件名' docs/business-index.md`;涉及业务行为变更时必须核对对应细则及最近指引,定位不全文读。
+
+| 目标 | 首个入口 |
+|---|---|
+| 页面注册与启动 | `src/pages.json`、`src/main.js`、`src/App.vue` |
+| AppID、平台图标与打包资源 | `src/manifest.json`、`static/app-icons/` |
+| Android APK/WGT 在线更新 | `src/App.vue`、`src/utils/app-update.js`、`.env.example` |
+| 登录、刷新与会话 | `src/pages/login/login.vue`、`src/api/auth.js`、`src/stores/session.js` |
+| 海外仓工作台与作业入口 | `src/pages/home/home.vue`;首页直接进入各作业,页面刷新后返回首页 |
+| 中英西语言设置 | `src/i18n/index.js`、`src/i18n/locales/`、`src/pages/profile/profile.vue` |
+| 柜号签收、照片与入仓真实作业 | `src/pages/operation/operation.vue`、`src/components/ReceivingSessionSummary.vue`、`src/api/inbound.js` |
+| 退货入库签收与合箱 | `src/pages/return-receipt/return-receipt.vue`、`src/pages/return-packing/return-packing.vue`、`src/api/return-receipt.js` |
+| 退货箱内清点 | `src/pages/return-counting/return-counting.vue`、`src/api/return-counting.js` |
+| 仓储转运测量、打托与标签 | `src/pages/warehouse-transfer/warehouse-transfer.vue`、`src/api/warehouse-transfers.js` |
+| 托盘管理真实作业 | `src/pages/pallet/pallet.vue`、`src/api/pallet.js` |
+| 库存盘点(入口隐藏)、商品移库 | `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` |
+| 现场查询 | `src/pages/query/query.vue`;同上库内API |
+| 排车理货审核、拣货、整车发车与证照提醒 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`(`completeDispatchLoading`)、`src/stores/fulfillment.js` |
+| 普通/一件代发出库 | `src/pages/outbound/outbound.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` |
+| 扫码设备与诊断 | `src/utils/scan-broadcast.js`、`src/pages/scanner-test/scanner-test.vue` |
+| 作业记录与人员 | `src/pages/history/history.vue`、`src/pages/profile/profile.vue` |
+| 业务标识与内部 ID 隔离 | `tests/business-identifiers-ui.test.mjs`、`src/pages/`、`src/components/` |
+| 本地契约测试 | `tests/project-contract.test.mjs`、`tests/navigation.test.mjs`、`tests/scan-broadcast.test.mjs` |
## 海外业务接口边界
-- 仓储转运按托盘分别保存箱数、长宽高(cm)和毛重(kg),体积按本托尺寸计算;短收/超收仅展示差异,不以预报箱数强制核平。生成托盘号后锁定该卡测量数据,必须取得服务端绑定确认且至少有一张现场照片才能完成打托;完成请求只提交稳定幂等键和照片文件 ID,不重复提交整份托盘测量。重新进入从详情恢复托盘和鉴权照片;标签使用服务端 `labelDownloadPath` 下载 PDF,打开文档不等于真机打印完成。
+- 库内/换标/收货/排车/出库/查询仅用 `/api/pda/**` API/DTO,经 `gateway` 显式PDA路由,不直连Lastleg/借平台、客户、内部接口。Gateway/服务端校验Token、`lastleg:pda:operate`、冻结仓、客户/资源归属;工作台 `available`、本地客户/仓ID不代替授权。
+- 仅展示服务端业务标识/资格;写请求携当前版本及本次幂等键,仅请求中防连点,结束即释放操作态。保留表单,不持久化或重放失败命令,下次点击按当前输入新建请求;409/结果不明先刷新权威状态,超时不判未执行/成功。Token刷新单次重放仍沿认证规则。
+- 仓储转运逐托存箱数、长宽高(cm)、毛重(kg),本托尺寸算体积;逐SKU核对件数差异,不以预报箱数强制核平。沿用客户预报箱唛,不造箱号。测量完成前客户/平台可增整单预报箱数、不选SKU,箱内SKU以PDA实点为准;刷新合并新箱、留本机草稿,清点传forecastPackageCount拦旧快照。生成托盘号锁该卡测量,服务端绑定确认且至少一张现场照片才可完成打托;完成传稳定幂等键、照片文件ID,SKU实收差异在完成弹窗必填原因,不重传测量。重进从详情恢复托盘/鉴权照片;`labelDownloadPath` 下载PDF,打开不等于真机打印。
-- 换标目标确认即提交拆托合托,不再二次点击底部提交。`RelabelPalletWork.vue` 按当前轮次 `PALLET_BIND` 最新记录提供逐箱改绑,扫描新目标后弹窗确认,使用既有 `relabel-disassemble` 精确来源路径(不传 `actualSource`);同目标不发写请求。服务器持有库存、托盘版本与幂等约束,已排车或出库禁止直接改绑。离线回归 `tests/relabel-pallet-work.test.mjs`,不等同真机验收。
+- 换标目标确认即提交拆合托,无底部二次提交。下述组件按本轮最新 `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 提供标签打印,原生打印设备适配尚未完成;不能宣称生成托盘号即打印成功。回归 `tests/relabel-pallet-work.test.mjs`。
+- 换标页内嵌 `src/components/RelabelPalletWork.vue`;`relabel-sources` 展示原入仓单逐托箱数。每次选一来源库存行、扫实托、填拆出箱数、核本批已换标箱、扫目标合托;可分批多托,拆托不额外逐箱扫。`relabel-disassemble.actualSource` 原子调整本单冻结记账来源/移库存,目标归原入仓单、不新入库;仅同新目的仓合托,新托不等客户确认,409刷新重扫。H5可印标签,原生打印未适配,生成托盘号非打印成功;回归见逐箱改绑。
-- 换标任务标明类型,仅显示实际明细需要的 Tab。混合明细同时进入箱唛/SKU 作业,服务端分开保存两部分进度,`cartonProcessedPackages/skuProcessedPackages` 决定各 Tab 待处理数量;两项均完成后整箱计数一次,不能仅扫箱唛就视为 SKU 完成。
+- 换标标类型,仅显明细所需Tab;混合明细做箱唛/SKU两项,服务端分存进度,`cartonProcessedPackages/skuProcessedPackages` 定各Tab待处理数;两项完成整箱计一次,扫箱唛非SKU完成。
-- 换标完工提交使 PDA 任务进入 `WAIT_REVIEW`,申请进入 `CUSTOMER_CONFIRM_PENDING`,客户确认原子完成复核并释放后续履约资格。PDA 复核池只读;平台通过专用 `/rework` 接口携带任务版本、幂等键和必填备注退回,历史事实归档后重新计数,PDA 展示 `reviewRemark` 并清空上一轮输入和照片草稿。
+- 换标完工后任务 `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,失败留原图;移除仅解绑待提交引用。
-- 退货查询空态集中在 `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 展示成业务编号。网络失败保留草稿和幂等键,恢复后以服务端任务状态为准;排车详情返回的 `canDepart`、`departBlockReason` 是发车资格依据,拣货及完成按钮同时核对当前单`canPick/pickBlockReason`;平台未转派送生成 `WAIT_OUTBOUND` 出库单时不得发车;完成或发车成功后不得重复提交。派送始终由 Admin/Client 承载。
+- 退货空态归上表签收API及 `src/api/return-packing.js`:仅 `startReturnReceipt/findReturnSkuCandidates/findPackingOrders` 将 `A0404/404` 转无匹配;签收确认/合箱写/标签读禁套用,权限/网络/服务错留诊断并报错,无记录非写成功;跑下述两项退货API回归。
+- 上表入仓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`。
+- 入仓摘要/箱码/实收的 `assigned/mergeAllowed/urgent/mergeHint` 仅读Lastleg;布尔仅 `true` 生效,`mergeAllowed` 默认 `false`。合托只提示,不改资格/触发分配;首扫仅确认服务端已有实托/库位/资源锁预留,设备端不重分配。仅传业务箱码/托盘码/收货数量,服务端定资格。
+- `overseasUrl`(上述overseas.js)统一拼请求/上传/下载:去 `apiBase` 尾斜杠,基址以 `/api` 结尾则去业务路径重复 `/api`;禁自拼 `session.apiBase`,不靠网关容错双斜杠/重复前缀。
+- 待卸柜候选仅读服务端Token绑定仓过滤结果;逐柜验正式 `overseasWarehouseCode/overseasWarehouseName` 与 `warehouse-profile`,缺失/不符fail-closed,禁FBA/目的仓/内部ID推导。保留未齐全柜,严格取 `allPlansReady/operable/blockReason/plannedArrivalAt` 及柜统计;`operable=false` 仅只读摘要、禁prepare。沿用服务端可操作优先、同组计划到仓升序、空值末位;禁按入仓码/计划数组长度推就绪,禁预计到港替代计划到仓。
+- 详情/prepare/current均重验接柜仓码;prepare仅建已分配柜会话,非首次认领。仓冲突 `409/A0409` 留中文错误及 `requestId/traceId`;建后仍走current恢复。
+- 上表托盘API仅用Lastleg专用PDA Controller,传扫码的 `palletVersion/positionVersion`、随机幂等键,不传客户/仓/库位;409重扫取新版本,禁旧库存快照。
+- 请求身份/资源范围仅取Token/current,不传 `customerId/planNo/allocationId/warehouseId/locationId`;409引导账号恢复当前柜,完成前禁切柜。
+- 签收照片三角色各一当前文件;上传后以服务端 `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/回归查业务索引排车条目。
+- `src/components/DispatchLoadingDocuments.vue` 支持装车中上传,装车与资料均完成后自动派送,无独立发车入口。
## 质量门禁
-- 已发车排车详情按需挂载`src/components/DispatchHandoverStatus.vue`,通过`src/api/trip-handover.js`读取当前派送资源和最近交接;每次进入/刷新一次GET,列表不逐行请求。仅只读、不提交客户或仓ID;后台确认交接,已完成派送保留完成时资源。回归`tests/trip-handover.test.mjs`。
+- 已发车详情按需挂载 `src/components/DispatchHandoverStatus.vue`,经 `src/api/trip-handover.js` 读当前派送资源/最近交接;进入/刷新各一次GET,列表不逐行请求。只读;后台确认交接,已完成派送留完成时资源。回归 `tests/trip-handover.test.mjs`。
-- 安装:`npm install`;检查:`npm test`。
-- 仓储转运测量、打托、照片或标签入口变更运行 `node --test tests/warehouse-transfer-api.test.mjs tests/project-contract.test.mjs tests/workbench-availability.test.mjs`;静态合同不替代真实扫码、绑定及打印验收。
-- 退货签收、合箱或工作台入口变更先运行 `node --test tests/return-receipt-api.test.mjs tests/return-packing-api.test.mjs tests/project-contract.test.mjs tests/workbench-availability.test.mjs`;离线检查不替代真实 API 联调与真机扫码验收。
-- 当前 uni-app H5 编译链要求 `vue-i18n` 精确固定为 `9.1.9`;不得改回范围版本或单独升级。依赖变更必须同步 `package.json`、`package-lock.json` 与现有 `pnpm-lock.yaml`,并至少执行 H5 编译门禁。
-- 正式产品形态是海外 PDA App;`npm run build:h5` 仅用于浏览器调试和模板编译门禁,App 资源编译使用 `npm run build:app`。
-- APK/IPA 必须在 HBuilderX 或正式原生打包环境中使用独立 AppID 与签名验证;仅通过 App 资源编译不得声称 APK 真机验收完成。
-- 修改 `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,也不能下载国内更新包。
+- 验证按 `../../docs/AI工程执行与协作约束.md`;文案看差异,小逻辑定向测试,不默认全量/构建/真机验收。`npm test` 为全量入口。
+- 专项用 `node --test`:转运测量/打托/照片/标签行为变更选 `tests/warehouse-transfer-api.test.mjs`;退货签收/合箱行为变更选对应 `tests/return-receipt-api.test.mjs` / `tests/return-packing-api.test.mjs`;项目结构/工作台入口变化才加 `tests/project-contract.test.mjs` / `tests/workbench-availability.test.mjs`。离线不替代真实API、真机扫码/绑定/打印。
+- H5编译链 `vue-i18n` 固定 `9.1.9`,禁范围版本/单独升级;依赖变更同步 `package.json`、`package-lock.json`、现有 `pnpm-lock.yaml`,至少跑H5编译。
+- 产品为海外PDA App;`npm run build:h5` 仅浏览器调试/模板门禁,App资源用 `npm run build:app`。APK/IPA须在HBuilderX/正式原生打包环境以独立AppID/签名验证,资源编译非APK真机验收。
+- 改 `src/manifest.json` 图标映射或 `static/app-icons/` 跑 `node --test tests/app-icons.test.mjs`;禁缺声明尺寸、同一错尺寸占位、复制国内AppID/签名。
+- 改任务/入仓码/柜号/托盘码/库位码等可见标识,以 `node --test` 跑上表标识隔离测试;禁内部ID回退业务号。
+- 改更新协议/版本比较/强制更新/安装/重启跑 `node --test tests/app-update.test.mjs`;新增原生模块/权限/不兼容运行时能力须发海外APK,不能只发WGT或下载国内包。
- 不提交 `node_modules/`、`dist/`、`unpackage/`、环境文件、签名证书或打包产物。
diff --git a/README.md b/README.md
index 5d9b525..0829686 100644
--- a/README.md
+++ b/README.md
@@ -75,32 +75,38 @@ Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查海外 PDA 的独
3. 更新清单与下载地址使用 HTTPS,先在测试设备验证可选更新、强制更新、断网重试、APK 覆盖安装、wgt 重启及扫码作业回归,再切换正式清单。
4. 海外和国内更新记录、文件目录及发布权限保持隔离,服务端必须按请求中的 `appid` 拒绝跨应用更新包。
-## TEST H5 静态托管
+## H5 静态托管(TEST 与灰度)
-发布构建使用 `/` 资源 base 与 hash 路由,部署到本应用独立域名根目录,浏览器入口为 `/#/`。开发服务仍使用根路径;不能将 Vite 开发代理当作静态服务器配置。
+国内、海外 PDA H5 统一由宿主机已有 Nginx/宝塔站点直接提供静态文件,不再构建或发布 PDA H5 Docker 镜像、Compose 服务或容器。正式部署约束见 [测试环境单机部署与配置契约](../../docs/测试环境单机部署与配置契约.md) 的“PDA H5 发布方式”;历史灰度报告不再作为发布入口。
-执行 `npm run build:h5`。无需启动 Vite 开发服务器;`npm run build:app` 不属于 H5 发布。
+发布构建使用 `/` 资源 base 与 hash 路由,部署到本应用独立域名根目录,浏览器入口为 `/#/`。不能放到子目录后继续共用根路径资源,也不能将 Vite 开发代理当作线上配置。
-`deploy/Dockerfile.h5` 只复制已构建 `dist/build/h5/` 及静态 Nginx 配置,不在镜像中复制源码、依赖或重新编译。`.dockerignore` 只放行这两类输入。容器静态目录为 `/srv/pda-h5/`。
+执行 `npm run build:h5` 导出 Web 静态文件;`npm run build:app` 不属于 H5 发布。
-从本仓库执行 `docker compose -f deploy/docker-compose.h5.test.yml build --pull=false`,再由获授权的部署流程执行 `docker compose -f deploy/docker-compose.h5.test.yml up -d --wait --wait-timeout 120`。必须设置 `IMAGE_TAG`,`NGINX_BASE_IMAGE` 可指定已缓存的兼容 Alpine Nginx 镜像。容器名及网络别名为 `pda-overseas-h5-test`,仅在外部 `firstleg-test-network` 暴露容器 80,不发布宿主端口。健康检查同时请求 `/healthz` 与 `/` 的实际首页。
+发布步骤:
-独立域名入口将 `/` 代理至本应用静态容器根目录,或将构建目录内容直接发布到站点根目录;不再挂载 PDA 子路径。缺失资源返回 404,不回退首页,HTML 使用 `Cache-Control: no-store`。域名入口还须配置同源 API 代理:两端均将 `/gateway-api/` 转统一 Gateway `/api/`。入口须保留 Authorization,支持文件上传下载及至少 11m 请求体。域名与上游只在部署侧配置,不编入 PDA 源码。
+1. 构建后执行 `node scripts/verify-h5.mjs`,核对 HTML/JS/CSS 资源路径及同源 API。构建、测试仍按根规则经 `local-check.ps1` 执行。
+2. 将 `dist/build/h5/` **目录内的文件**上传到该 PDA 站点现有 `root`。先上传 `assets/`、`static/` 等资源,最后替换 `index.html`,保留站点证书验证目录和站点配置;不要把外层构建目录一起嵌套上传。
+3. 页面 `location /` 使用 `try_files $uri $uri/ =404` 直接读取静态文件,设置 `Cache-Control: no-store`;不得再转发到旧 PDA 容器。接口 `/gateway-api/` 独立转发到当前环境统一 Gateway `/api/`,保留 Authorization 和上传能力。
+4. 首次切换前备份实际生效站点配置,合并下述片段并执行 `nginx -t`,成功后才重载。已有独立 API 规则先核对并复用,不能直接追加重复 `location`。后续仅更新静态文件时无需新建容器或重载 Nginx。
+5. 检查域名首页及实际引用的 JS/CSS 均返回 200,并将响应内容的 SHA-256 与本次产物逐项比较;确认响应不是旧容器内容。再检查登录和受影响页面。首页 200 不等于接口或业务验收通过。
-部署配置检查:`node --test tests/h5-deployment.test.mjs`。没有 Docker 运行时的本地合同检查不能替代目标主机 `docker compose config`、Nginx 启动与真实 HTTP 验收。
+### 站点配置片段
-构建后执行 `node scripts/verify-h5.mjs`,核对 HTML/JS/CSS 资源路径、同源 API 和无本机/旧海外配置泄漏。该检查与本地构建仅证明产物;TEST 页面登录、仓权限、业务操作和 Android 真机另行验收。
+`deploy/nginx.conf.template` 是合并到已有 `server` 的参考片段,不是完整站点配置。保留现有域名、HTTPS、证书与宝塔 include;替换原有 root/index 及同名 location。环境地址、端口及网站目录只在部署侧配置,不提交到源码。
+
+在部署机器上将 `PDA_SITE_ROOT` 设置为该站点真实根目录,`PDA_GATEWAY_ORIGIN` 设置为宿主机可达的当前环境 Gateway 源地址(协议、主机、端口,不含末尾 `/` 或 `/api`)。不要使用仅 Docker 网络可解析的服务名。显式只渲染这两个变量,保留 `$uri`、`$host` 和代理请求头变量:
+
+```bash
+envsubst '${PDA_SITE_ROOT} ${PDA_GATEWAY_ORIGIN}' < deploy/nginx.conf.template
+```
+
+输出仅供核对和合并,不直接覆盖完整站点。若机器没有 `envsubst`,手工替换这两个占位符;未替换的模板不能直接加载。
### 两端统一 API 配置
-两端的 `VITE_API_BASE_URL` 均为统一 Gateway 绝对地址(含 `/api`);`VITE_H5_API_BASE_URL=/gateway-api`;`API_PROXY_TARGET` 为同一 Gateway 源地址(不含 `/api`)。实际地址只放在未提交的 `.env.local`;修改后重启开发服务,发布前重新打包。
+App 的 `VITE_API_BASE_URL` 为统一 Gateway 绝对地址(含 `/api`);H5 使用 `VITE_H5_API_BASE_URL=/gateway-api`。`API_PROXY_TARGET` 仅供本地 Vite 开发代理使用,不随静态包发布。实际地址放在未提交环境文件;变更编译变量后须重新构建。
-两端独立域名入口均配置以下规则;`gateway:9000` 仅适用于同一 Docker 网络,其他部署须替换成实际统一 Gateway 上游。静态容器只提供页面文件,Vite 代理不会随静态包发布。
+站点 API 规则须使用 `location ^~ /gateway-api/`,防止驾驶证等包含 `LICENSE` 的业务路径被站点敏感文件正则截获。仅更新静态包不能修复代理配置;域名入口和每层代理均须核对。
-```nginx
-location ^~ /gateway-api/ {
- proxy_pass http://gateway:9000/api/;
- proxy_set_header Authorization $http_authorization;
- client_max_body_size 11m;
-}
-```
+部署定向检查:`node --test tests/h5-deployment.test.mjs`,经 `local-check.ps1` 执行。该检查覆盖静态发布约束、资源 base 和开发代理重写,不替代目标主机 Nginx 校验、真实 HTTP、人工页面及 Android 真机验收。
diff --git a/deploy/Dockerfile.h5 b/deploy/Dockerfile.h5
deleted file mode 100644
index d20020b..0000000
--- a/deploy/Dockerfile.h5
+++ /dev/null
@@ -1,5 +0,0 @@
-ARG NGINX_BASE_IMAGE=nginx:1.27-alpine
-FROM ${NGINX_BASE_IMAGE}
-COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
-COPY dist/build/h5/ /srv/pda-h5/
-EXPOSE 80
diff --git a/deploy/Dockerfile.h5.production b/deploy/Dockerfile.h5.production
deleted file mode 100644
index 5ecc940..0000000
--- a/deploy/Dockerfile.h5.production
+++ /dev/null
@@ -1,5 +0,0 @@
-ARG NGINX_BASE_IMAGE=nginx:1.27-alpine
-FROM ${NGINX_BASE_IMAGE}
-COPY deploy/nginx.production.conf.template /etc/nginx/templates/default.conf.template
-COPY dist/build/h5/ /srv/pda-h5/
-EXPOSE 80
diff --git a/deploy/docker-compose.h5.production.yml b/deploy/docker-compose.h5.production.yml
deleted file mode 100644
index 3dc79ee..0000000
--- a/deploy/docker-compose.h5.production.yml
+++ /dev/null
@@ -1,29 +0,0 @@
-name: firstleg-production-pda-overseas-h5
-services:
- pda-overseas-h5:
- build:
- context: ..
- dockerfile: deploy/Dockerfile.h5.production
- args:
- NGINX_BASE_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
- image: pda-overseas-h5-prod:${IMAGE_TAG:?IMAGE_TAG is required}
- container_name: pda-overseas-h5-prod
- restart: unless-stopped
- environment:
- API_UPSTREAM: "http://gateway:9000"
- ports:
- - "127.0.0.1:${HOST_PORT:-29006}:80"
- healthcheck:
- test: ["CMD-SHELL", "wget -qO /dev/null http://127.0.0.1/healthz && wget -qO /dev/null http://127.0.0.1/"]
- interval: 5s
- timeout: 3s
- retries: 12
- start_period: 5s
- networks:
- backend:
- aliases:
- - pda-overseas-h5-prod
-networks:
- backend:
- external: true
- name: firstleg-production-network
diff --git a/deploy/docker-compose.h5.test.yml b/deploy/docker-compose.h5.test.yml
deleted file mode 100644
index 1022898..0000000
--- a/deploy/docker-compose.h5.test.yml
+++ /dev/null
@@ -1,27 +0,0 @@
-name: firstleg-test-pda-overseas-h5
-services:
- pda-overseas-h5:
- build:
- context: ..
- dockerfile: deploy/Dockerfile.h5
- args:
- NGINX_BASE_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
- image: pda-overseas-h5-test:${IMAGE_TAG:?IMAGE_TAG is required}
- container_name: pda-overseas-h5-test
- restart: unless-stopped
- expose:
- - "80"
- healthcheck:
- test: ["CMD-SHELL", "wget -qO /dev/null http://127.0.0.1/healthz && wget -qO /dev/null http://127.0.0.1/"]
- interval: 5s
- timeout: 3s
- retries: 12
- start_period: 5s
- networks:
- test-backend:
- aliases:
- - pda-overseas-h5-test
-networks:
- test-backend:
- external: true
- name: firstleg-test-network
diff --git a/deploy/nginx.conf b/deploy/nginx.conf
deleted file mode 100644
index bdef818..0000000
--- a/deploy/nginx.conf
+++ /dev/null
@@ -1,16 +0,0 @@
-server {
- listen 80;
- server_name _;
- root /srv/pda-h5;
-
- location = /healthz {
- default_type text/plain;
- add_header Cache-Control "no-store" always;
- return 200 "ok\n";
- }
- location / {
- index index.html;
- try_files $uri $uri/ =404;
- add_header Cache-Control "no-store" always;
- }
-}
diff --git a/deploy/nginx.conf.template b/deploy/nginx.conf.template
new file mode 100644
index 0000000..b7a742d
--- /dev/null
+++ b/deploy/nginx.conf.template
@@ -0,0 +1,21 @@
+# Merge into the existing site's server block; preserve TLS and panel includes.
+# Render only PDA_SITE_ROOT and PDA_GATEWAY_ORIGIN (see README).
+# Replace existing root/index and matching locations; do not append duplicates.
+root ${PDA_SITE_ROOT};
+index index.html;
+
+location / {
+ try_files $uri $uri/ =404;
+ add_header Cache-Control "no-store" always;
+}
+
+location ^~ /gateway-api/ {
+ proxy_pass ${PDA_GATEWAY_ORIGIN}/api/;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header Authorization $http_authorization;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ client_max_body_size 55m;
+}
diff --git a/deploy/nginx.production.conf.template b/deploy/nginx.production.conf.template
deleted file mode 100644
index 4ce203e..0000000
--- a/deploy/nginx.production.conf.template
+++ /dev/null
@@ -1,25 +0,0 @@
-server {
- listen 80;
- server_name _;
- root /srv/pda-h5;
-
- location = /healthz {
- default_type text/plain;
- add_header Cache-Control "no-store" always;
- return 200 "ok\n";
- }
- location /gateway-api/ {
- client_max_body_size 55m;
- proxy_pass ${API_UPSTREAM}/api/;
- proxy_http_version 1.1;
- proxy_set_header Host $proxy_host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- }
- location / {
- index index.html;
- try_files $uri $uri/ =404;
- add_header Cache-Control "no-store" always;
- }
-}
diff --git a/docs/business-index.md b/docs/business-index.md
new file mode 100644
index 0000000..87f9a8a
--- /dev/null
+++ b/docs/business-index.md
@@ -0,0 +1,65 @@
+# 海外 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` 上传五张整车照片,照片齐全且确认装车完成后由服务端在最后一项完成的事务自动生成派送并发车;不额外安排派送或确认发车。公网前缀 `/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 作为业务编号。动作资格、版本和失败处理见本文件排车条目,装车中可同步上传五张照片,装车确认与资料齐全两项均完成后服务端自动生成派送并发车,装完但资料未齐时留在装车后;照片API为 `/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents`,前端不补派送费用。完成成功后不得重复提交;PDA 不直接创建派送单,平台与客户的读取范围遵循现行契约。
+- 排车拣货、普通出库、整托出库和废弃确认在实际写库存前,先用原扫码 `idempotencyKey` 调用 `POST /api/pda/v1/last-leg/storage-rent/preparations`。四种模式分别为 `DISPATCH/OUTBOUND/OUTBOUND_PALLET/DISCARD`,货源、客户、计租范围和可用资金由服务端校验。仅明确 `ready=true` 且状态为 `NOT_REQUIRED/READY/CONSUMED` 才继续原扫码命令;待冻结、失败、过期、响应异常或网络未知均不发库存写请求。预校验与紧随其后的库存写请求使用同一个键和同一份货源参数;请求结束释放操作态,保留表单但不自动重放失败命令。再次点击按当前输入和版本生成新键,仍必须重新通过服务端费用预校验;结果不明时先刷新权威任务,不以换键跳过费用确认。定向回归为 `tests/storage-rent-preflight.test.mjs`,离线通过不代表实际冻结、扣款和出库联调已验收。
diff --git a/src/api/dispatch-loading-documents.js b/src/api/dispatch-loading-documents.js
new file mode 100644
index 0000000..07b188d
--- /dev/null
+++ b/src/api/dispatch-loading-documents.js
@@ -0,0 +1,25 @@
+import { overseasRequest, overseasUpload, overseasDownload } from './overseas.js'
+
+export const loadingPhotoSlots = ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT']
+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 ?? '')) || !Array.isArray(value.photos)
+ || !['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(value.stage)) throw new Error('装车资料与当前排车事实不一致')
+ const slots = new Set()
+ for (const photo of value.photos) {
+ if (!loadingPhotoSlots.includes(photo.slot) || !photo.fileId || slots.has(photo.slot)) throw new Error('装车照片引用不完整或重复')
+ slots.add(photo.slot)
+ }
+ return { ...value, editable: value.editable === true, complete: value.complete === true }
+}
+export async function getLoadingDocuments(no) { return normalizeLoadingDocuments(await overseasRequest('GET', path(no)), no) }
+export function uploadLoadingPhoto(no, slot, filePath, key) { return overseasUpload(`${slotPath(no, slot)}/uploads?idempotencyKey=${encodeURIComponent(key)}`, filePath) }
+export async function bindLoadingPhoto(no, slot, body) { return normalizeLoadingDocuments(await overseasRequest('PUT', slotPath(no, slot), body), no) }
+export function readLoadingPhoto(no, photo) { return overseasDownload(`${slotPath(no, photo.slot)}/files/${encodeURIComponent(photo.fileId)}/content`, { expectedContentTypePrefix: 'image/' }) }
diff --git a/src/api/fulfillment.js b/src/api/fulfillment.js
index a8b4f03..bc56f24 100644
--- a/src/api/fulfillment.js
+++ b/src/api/fulfillment.js
@@ -1,8 +1,9 @@
-import { overseasRequest } from './overseas.js'
+import { overseasRequest, overseasUpload, overseasDownload } from './overseas.js'
const DISPATCH_BASE = '/pda/v1/last-leg/dispatches'
const OUTBOUND_BASE = '/pda/v1/last-leg/outbound-tasks'
const CARGO_DISCARD_BASE = '/pda/v1/last-leg/cargo-discards'
+const STORAGE_PREPARATION_BASE = '/pda/v1/last-leg/storage-rent/preparations'
const encoded = (value) => encodeURIComponent(String(value || '').trim())
@@ -18,39 +19,35 @@ function count(value) {
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) {
+ if (!value || !value.dispatchNo || !Array.isArray(value.items)) throw new Error('排车任务返回结构不完整')
return {
- ...value,
- taskNo: String(value?.taskNo || ''),
- dispatchNo: String(value?.dispatchNo || ''),
- vehiclePlate: String(value?.vehiclePlate || ''),
- tripNo: String(value?.tripNo || ''),
- tripRecordVersion: value?.tripRecordVersion == null ? null : String(value.tripRecordVersion),
- tripDispatchCount: count(value?.tripDispatchCount),
- driverWarning: String(value?.driverWarning || ''),
- status: String(value?.status || ''),
- canDepart: value?.canDepart === true,
- canPick: value?.canPick === true,
- pickBlockReason: String(value?.pickBlockReason || ''),
- departBlockReason: String(value?.departBlockReason || ''),
- items: asList(value?.items).map((item) => ({
- ...item,
- 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)
- }))
+ ...value, taskNo: String(value.taskNo || ''), dispatchNo: String(value.dispatchNo),
+ recordVersion: value.recordVersion, pickingReconciled: value.pickingReconciled === true,
+ actions: Object.fromEntries(Object.entries(value.actions || {}).map(([key, gate]) => [key, { ...gate, allowed: gate?.allowed === true }])),
+ 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 || ''),
+ scheduledPackages: dispatchCount(item.scheduledPackages), pickedPackages: dispatchCount(item.pickedPackages),
+ returnCartons: (item.returnCartons || []).map(carton => ({ ...carton, returnCartonId: String(carton.returnCartonId || ''), cartonMark: String(carton.cartonMark || ''), productQuantity: dispatchCount(carton.productQuantity) })),
+ 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) }))
}))
}
}
@@ -83,32 +80,55 @@ export function normalizeOutboundTask(value) {
}
}
-export async function listDispatchTasks(status = '') {
- const suffix = status ? `?status=${encoded(status)}` : ''
- return asList(await overseasRequest('GET', `${DISPATCH_BASE}${suffix}`)).map(normalizeDispatchTask)
+export async function listDispatchTasks() {
+ const result = await overseasRequest('GET', DISPATCH_BASE)
+ if (!Array.isArray(result)) throw new Error('排车任务列表返回结构无效')
+ return result.map(normalizeDispatchTask)
}
-
export async function getDispatchTask(dispatchNo) {
return normalizeDispatchTask(await overseasRequest('GET', `${DISPATCH_BASE}/${encoded(dispatchNo)}`))
}
-
-export function pickDispatchItem(dispatchNo, dispatchItemId, request) {
- return overseasRequest('POST', `${DISPATCH_BASE}/${encoded(dispatchNo)}/items/${encoded(dispatchItemId)}/pick`, request)
+export function getDispatchLoading(dispatchNo) {
+ return overseasRequest('GET', `${DISPATCH_BASE}/${encoded(dispatchNo)}/loading`)
}
-
-export function completeDispatchPicking(dispatchNo, request) {
- return overseasRequest('POST', `${DISPATCH_BASE}/${encoded(dispatchNo)}/complete`, request)
+export function getDispatchCommand(dispatchNo, key) {
+ return overseasRequest('GET', `${DISPATCH_BASE}/${encoded(dispatchNo)}/commands/${encoded(key)}`)
}
-
-export function departDispatch(dispatchNo, request) {
- return overseasRequest('POST', `${DISPATCH_BASE}/${encoded(dispatchNo)}/depart`, request)
+function dispatchWrite(path, request) {
+ requireDispatchVersion(request?.recordVersion)
+ return overseasRequest('POST', path, request)
}
-
-export function tripDepartureRequest(task, idempotencyKey) {
- if (!task?.tripNo || task.tripRecordVersion == null || !/^\d+$/.test(String(task.tripRecordVersion))) {
- throw new Error('车次信息不完整,请刷新任务后再发车')
+async function prepareStorageRent(mode, businessNo, request, extra = {}) {
+ try {
+ if (!request?.idempotencyKey) throw new Error('出库请求标识缺失,请重新进入任务')
+ const result = await overseasRequest('POST', STORAGE_PREPARATION_BASE, {
+ mode, businessNo, idempotencyKey: request.idempotencyKey, ...extra
+ })
+ if (result?.ready !== true || !['NOT_REQUIRED', 'READY', 'CONSUMED'].includes(result?.status)) {
+ const error = new Error(result?.message || (result?.status === 'FREEZE_PENDING' ? '费用确认中,请稍后重试' : '费用尚未确认,请稍后重试'))
+ error.code = `STORAGE_RENT_${result?.status || 'INVALID_RESPONSE'}`
+ throw error
+ }
+ } catch (cause) {
+ // 预扣可能已受理,保留完整原请求及幂等键继续核对。
+ cause.storageRentPreparation = true
+ throw cause
}
- return { recordVersion: String(task.tripRecordVersion), idempotencyKey, remark: '海外 PDA 整车核平后发车' }
+}
+
+export async function pickDispatchItem(dispatchNo, dispatchItemId, request) {
+ requireDispatchVersion(request?.recordVersion)
+ await prepareStorageRent('DISPATCH', dispatchNo, request, {
+ itemId: dispatchItemId, positionId: request.positionId, packages: request.packages,
+ cartonMark: request.cartonMark, productQuantity: request.productQuantity
+ })
+ 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 reviewDispatchLoading(dispatchNo, request) {
+ return dispatchWrite(`${DISPATCH_BASE}/${encoded(dispatchNo)}/loading-review`, request)
}
export async function listOutboundTasks(status = '') {
@@ -124,11 +144,17 @@ export function resolveOutboundTask(businessNo) {
return overseasRequest('GET', `${OUTBOUND_BASE}/resolve/${encoded(businessNo)}`)
}
-export function pickOutboundTask(taskNo, request) {
+export async function pickOutboundTask(taskNo, request) {
+ await prepareStorageRent('OUTBOUND', taskNo, request, {
+ itemId: request.outboundPalletItemId, positionId: request.inventoryPositionId,
+ packages: request.packages, cartonMarks: request.cartonMarks,
+ skuCode: request.skuCode, productQuantity: request.productQuantity
+ })
return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/pick`, request)
}
-export function pickOutboundPallet(taskNo, palletNo, request) {
+export async function pickOutboundPallet(taskNo, palletNo, request) {
+ await prepareStorageRent('OUTBOUND_PALLET', taskNo, request, { palletNo })
return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/pallets/${encoded(palletNo)}/pick`, request)
}
@@ -153,15 +179,20 @@ export function normalizeCargoDiscardTask(value) {
return {
...task,
discardNo: String(task?.discardNo || ''),
+ outboundNo: String(task?.outboundNo || ''),
palletNo: String(task?.palletNo || ''),
+ scanMode: task?.scanMode === 'CARTON' ? 'CARTON' : 'PALLET',
+ cartonMarks: asList(task?.cartonMarks).map(String),
status: String(task?.status || 'PENDING'),
skus: asList(task?.skus).map((sku) => ({
...sku,
itemId: String(sku?.itemId || ''),
skuCode: String(sku?.skuCode || ''),
productName: String(sku?.productName || ''),
+ quantityUnit: sku?.quantityUnit === '件' ? '件' : '箱',
plannedPackages: count(sku?.plannedPackages),
actualPackages: sku?.actualPackages == null ? null : count(sku.actualPackages),
+ actualQuantityEditable: sku?.actualQuantityEditable === true,
status: String(sku?.status || 'PENDING')
}))
}
@@ -171,10 +202,17 @@ export async function listCargoDiscardTasks() {
return asList(await overseasRequest('GET', CARGO_DISCARD_BASE)).map(normalizeCargoDiscardTask)
}
-export async function resolveCargoDiscardPallet(palletNo) {
- return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/resolve`, { palletNo }))
+export async function resolveCargoDiscardPallet(palletNo, discardNo) {
+ return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/resolve`, { palletNo, ...(discardNo ? { discardNo } : {}) }))
}
-export async function confirmCargoDiscard(discardNo, palletNo, lines) {
- return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, { lines }))
+export async function confirmCargoDiscard(discardNo, palletNo, lines, photoFileIds, scannedCartonMarks = [], confirmAllCartons = false, request = {}) {
+ const body = { lines, photoFileIds, scannedCartonMarks, confirmAllCartons, idempotencyKey: request.idempotencyKey }
+ await prepareStorageRent('DISCARD', discardNo, request, { palletNo, lines, photoFileIds, scannedCartonMarks, confirmAllCartons })
+ return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, body))
}
+
+const discardPhotoPath = (discardNo, palletNo) => `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/photos/files`
+export const listCargoDiscardPhotos = (discardNo, palletNo) => overseasRequest('GET', discardPhotoPath(discardNo, palletNo))
+export const uploadCargoDiscardPhoto = (discardNo, palletNo, filePath) => overseasUpload(discardPhotoPath(discardNo, palletNo), filePath)
+export const downloadCargoDiscardPhoto = (discardNo, palletNo, fileId) => overseasDownload(`${discardPhotoPath(discardNo, palletNo)}/${encoded(fileId)}/download`, { expectedContentTypePrefix: 'image/' })
diff --git a/src/api/overseas.js b/src/api/overseas.js
index 3966a6f..1f618eb 100644
--- a/src/api/overseas.js
+++ b/src/api/overseas.js
@@ -135,12 +135,22 @@ function rawUpload(path, filePath, name, token) {
const request = new XMLHttpRequest()
request.open('POST', url)
request.setRequestHeader('Authorization', `Bearer ${token}`)
+ request.timeout = 60000
request.onload = () => resolve({ statusCode: request.status, data: request.responseText, header: {} })
request.onerror = () => reject(new Error('海外作业文件上传失败'))
+ request.ontimeout = () => reject(new Error('照片上传超时,请重试'))
+ request.onabort = () => reject(new Error('照片上传已中断,请重试'))
request.send(form)
})
- const response = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: form })
- return { statusCode: response.status, data: await response.text(), header: Object.fromEntries(response.headers.entries()) }
+ const controller = new AbortController()
+ const timeout = setTimeout(() => controller.abort(), 60000)
+ 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 }) })
}
diff --git a/src/api/pallet.js b/src/api/pallet.js
index d90081f..1677fd4 100644
--- a/src/api/pallet.js
+++ b/src/api/pallet.js
@@ -4,6 +4,8 @@ const BASE = '/pda/v1/last-leg/pallet-operations'
export const findRelabelSources = (relabelNo) => overseasRequest('POST', `${BASE}/relabel-sources`, { relabelNo })
export const createRelabelPallet = (payload) => overseasRequest('POST', `${BASE}/relabel-pallet-label`, payload)
export const bindRelabelPallet = (payload) => overseasRequest('POST', `${BASE}/relabel-disassemble`, payload)
+export const listRelabelPalletMeasurements = (relabelNo) => overseasRequest('POST', `${BASE}/relabel-pallet-measurements`, { relabelNo })
+export const measureRelabelPallet = (payload) => overseasRequest('POST', `${BASE}/relabel-pallet-measure`, payload)
export function resolvePallet(code) {
return overseasRequest('POST', `${BASE}/pallets/resolve`, { code: String(code || '').trim() })
diff --git a/src/api/relabel.js b/src/api/relabel.js
index a04e836..117342b 100644
--- a/src/api/relabel.js
+++ b/src/api/relabel.js
@@ -4,6 +4,7 @@ const BASE = '/pda/v1/last-leg/relabel-tasks'
const encoded = (value) => encodeURIComponent(String(value || '').trim())
export const scanRelabelSku = async (no, payload) => normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(no)}/skus/scan`, payload))
export const completeReturnRelabelSku = async (no, payload) => normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(no)}/return-skus/complete`, payload))
+export const packReturnRelabelCartons = async (no, payload) => normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(no)}/return-cartons/pack`, payload))
export const completeReturnRelabelSkuTotal = async (no, payload) => normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(no)}/return-skus/complete-total`, payload))
function asList(value) { return Array.isArray(value) ? value : [] }
@@ -11,6 +12,7 @@ function asList(value) { return Array.isArray(value) ? value : [] }
export function normalizeRelabel(value = {}) {
const items = asList(value.items).map((item) => ({
...item, itemId: String(item.itemId || ''), skuCode: String(item.skuCode || ''),
+ returnCartonId: item.returnCartonId == null ? null : String(item.returnCartonId), returnQuantity: Number(item.returnQuantity || 0),
newSkuCode: String(item.newSkuCode || ''), operationType: String(item.operationType || 'CARTON_LABEL'), expectedPackages: Number(item.expectedPackages || 0),
relabelledPackages: Number(item.relabelledPackages || 0), skippedPackages: Number(item.skippedPackages || 0),
exceptionPackages: Number(item.exceptionPackages || 0)
diff --git a/src/api/return-packing.js b/src/api/return-packing.js
index c5f42b3..40197a4 100644
--- a/src/api/return-packing.js
+++ b/src/api/return-packing.js
@@ -75,7 +75,8 @@ export async function completePackingPlan(planNo, request) {
}
export function normalizePackingPlan(value = {}) {
- return { planNo: String(value.planNo || ''), customerId: String(value.customerId || ''), returnNos: Array.isArray(value.returnNos) ? value.returnNos.map(String) : [], sourceCartonMarks: Array.isArray(value.sourceCartonMarks) ? value.sourceCartonMarks.map(String) : [], sources: Array.isArray(value.sources) ? value.sources : [], mode: String(value.mode || ''), status: String(value.status || ''), boxes: Array.isArray(value.boxes) ? value.boxes : [], remark: String(value.remark || '') }
+ value = { ...value, completionCartons: Array.isArray(value.completionCartons) ? value.completionCartons.map(normalizeReturnLabel) : [], transfers: Array.isArray(value.transfers) ? value.transfers : [], completedAt: value.completedAt || null }
+ return { completionCartons: value.completionCartons, transfers: value.transfers, completedAt: value.completedAt, planNo: String(value.planNo || ''), customerId: String(value.customerId || ''), returnNos: Array.isArray(value.returnNos) ? value.returnNos.map(String) : [], sourceCartonMarks: Array.isArray(value.sourceCartonMarks) ? value.sourceCartonMarks.map(String) : [], sources: Array.isArray(value.sources) ? value.sources : [], mode: String(value.mode || ''), status: String(value.status || ''), boxes: Array.isArray(value.boxes) ? value.boxes : [], remark: String(value.remark || '') }
}
export async function getPackingPlan(planNo) {
@@ -108,3 +109,7 @@ export async function mergeReturnCartons(request) {
export async function getReturnCartonLabel(cartonMark) {
return normalizeReturnLabel(await overseasRequest('GET', `${BASE}/cartons/${encoded(cartonMark)}/label`))
}
+
+export async function confirmReturnLabel(cartonMark) {
+ return normalizeReturnLabel(await overseasRequest('POST', `${BASE}/cartons/${encoded(cartonMark)}/confirm-label`))
+}
diff --git a/src/api/warehouse-transfers.js b/src/api/warehouse-transfers.js
index 90e7479..156b48d 100644
--- a/src/api/warehouse-transfers.js
+++ b/src/api/warehouse-transfers.js
@@ -49,3 +49,15 @@ export function downloadWarehouseTransferPalletLabel(labelDownloadPath) {
if (!path) return Promise.reject(new Error('服务端未返回托盘标签下载地址'))
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`)
+}
diff --git a/src/components/CartonCountFields.vue b/src/components/CartonCountFields.vue
new file mode 100644
index 0000000..481b29b
--- /dev/null
+++ b/src/components/CartonCountFields.vue
@@ -0,0 +1,17 @@
+体积{{ boxVolume(box) }} {{ volumeUnit === 'm3' ? 'm³' : 'cm³' }}
+ {{ line.skuCode || '选择 SKU' }} ▾
+
+
diff --git a/src/components/DispatchLoadingDocuments.vue b/src/components/DispatchLoadingDocuments.vue
new file mode 100644
index 0000000..d42babd
--- /dev/null
+++ b/src/components/DispatchLoadingDocuments.vue
@@ -0,0 +1,111 @@
+
+
+
+ 装车照片
+ {{ busy ? '同步中' : snapshot ? `${snapshot.photos.length}/5 已保存` : '待同步' }}
+
+
+ {{ error.message }}requestId:{{ error.requestId }}traceId:{{ error.traceId }}
+
+
+
+
+
+
+
+ 查看大图
+
+
+
+
+
+
+
+
diff --git a/src/components/ReceivingSessionSummary.vue b/src/components/ReceivingSessionSummary.vue
index 9e80c34..242d696 100644
--- a/src/components/ReceivingSessionSummary.vue
+++ b/src/components/ReceivingSessionSummary.vue
@@ -5,7 +5,6 @@
{{ statusLabel }}
- 海外接柜仓:{{ receivingWarehouseLabel }}
目的仓:{{ value }}
@@ -33,7 +32,6 @@
props: { session: { type: Object, default: null } },
computed: {
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 },
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 : ['—'] },
@@ -51,7 +49,7 @@
.receiving-summary-head,.receiving-summary-progress>view:first-child{display:flex;align-items:center;justify-content:space-between;gap:10px}
.receiving-summary-label,.receiving-summary-container,.metric-value,.metric-label{display:block}.receiving-summary-label{color:#718095;font-size:9px;line-height:13px}.receiving-summary-container{margin-top:1px;color:#17324d;font-size:17px;font-weight:850;line-height:22px}
.receiving-summary-status{flex-shrink:0;padding:4px 8px;border-radius:99px;color:#1668dc;font-size:9px;font-weight:700;background:#e3efff}
- .receiving-summary-context{display:flex;flex-direction:column;gap:4px;margin-top:7px;color:#617389;font-size:9px;line-height:14px}.receiving-summary-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-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-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}
.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}
diff --git a/src/components/RelabelPalletWork.vue b/src/components/RelabelPalletWork.vue
index 268aa6d..e258808 100644
--- a/src/components/RelabelPalletWork.vue
+++ b/src/components/RelabelPalletWork.vue
@@ -5,46 +5,72 @@
- 已绑定箱唛:选择后扫描新目标托盘可改绑(已排车或出库不可直接改绑)。
+ {{ pallet.palletCode }} · {{ pallet.totalPackages }} 箱 · 体积 {{ pallet.volumeCbm == null ? '待补测' : pallet.volumeCbm + ' m³' }} · 重量 {{ pallet.grossWeightKg == null ? '待补测' : pallet.grossWeightKg + ' kg' }}
+
+ {{ pallet.palletCode }} · {{ pallet.packages }} 箱
+ {{ pallet.inherited ? '来源汇总' : '已确认' }}:{{ pallet.volumeCbm }} m³ · {{ pallet.grossWeightKg }} kg
+
+ 来源箱规(长 × 宽 × 高)
+ {{ spec.lengthCm }} × {{ spec.widthCm }} × {{ spec.heightCm }} cm · {{ spec.grossWeightKg }} kg/箱 · {{ spec.cartons }} 箱
+
+ 原整托尺寸:{{ pallet.lengthCm }} × {{ pallet.widthCm }} × {{ pallet.heightCm }} cm
+
+
+
+
+
+
+
正在改绑:{{ rebindSelection.mark }},当前托盘:{{ rebindSelection.pallet }}
+
- 托盘号{{ row.palletCode }}
+ 托盘号{{ row.palletCode }}来源箱{{ (row.cartonMarks || []).join('、') }}
{{ isReturnBulk ? '件数' : '箱数' }}:{{ row.stockPackages }} · 可拆{{ isReturnBulk ? '件数' : '箱数' }}:{{ row.movablePackages }}
- 当前没有可操作的来源托盘
-
- 已确认:{{ source.palletCode }}
+ 当前没有可操作的来源托盘或来源箱
+
+
+ 来源已确认:{{ source.palletCode }}
- 本次{{ wholeSource ? '整托换标数量' : '拆出件数' }}(可分多个来源托盘,逐批提交)
{{ selectedSource.palletCode }}:{{ wholeSource ? '整托换标' : '部分拆箱' }} {{ quantity || 0 }} 件 → {{ selectedDestination || '目的仓待维护' }}
- 贴标数量等于本托盘全部 {{ selectedSource.stockPackages }} 件,无需填写拆箱数量;直接新增带目的仓的托盘标签并绑定。
- 贴标数量少于本托盘库存,请按现场实际选择来源托盘并填写拆箱数量;本托盘应拆 {{ plannedPackages }} 件。
- 退货换标按整托绑定,不支持拆箱;托盘仍会展示用于核对。
- 退货换标无需扫描箱唛。
+
- 本次拆出箱数箱
+ 本次拆出箱数箱
本次需换标拆 {{ requiredSplitPackages }} 箱已拆 {{ splitPackages }} 箱
+
+
- 已识别目标:{{ target.palletCode }},尚未绑定成功
+
+
托盘 {{ newPallet.palletCode }} 待绑定
+
{{ message }}{{ error }}
diff --git a/src/data/overseas-workbench.js b/src/data/overseas-workbench.js
index ec649be..7def44e 100644
--- a/src/data/overseas-workbench.js
+++ b/src/data/overseas-workbench.js
@@ -2,14 +2,14 @@ export const MODULES = [
{ key: 'inbound', name: '入仓作业', nameKey: 'modules.inbound.name', icon: '入', description: '处理拆柜收货与客户退货实收入仓', descriptionKey: 'modules.inbound.description', operations: [
{ key: 'receiving', name: '拆柜收货', nameKey: 'modules.receiving.name', icon: '卸', iconType: 'download', description: '三类照片签收,箱码定位入仓码后按目的类型收货', descriptionKey: 'modules.receiving.description', available: true },
{ key: 'warehouseTransferList', name: '仓储转运测量列表', nameKey: 'modules.warehouseTransferList.name', icon: '列', iconType: 'list', description: '查看待测量的仓储转运单', descriptionKey: 'modules.warehouseTransferList.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer?mode=list' },
- { key: 'warehouseTransferWork', name: '转运测量作业', nameKey: 'modules.warehouseTransferWork.name', icon: '转', iconType: 'compose', description: '进入转运单详情完成测量、打托和拍照', descriptionKey: 'modules.warehouseTransferWork.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer?mode=work' },
+ { key: 'warehouseTransferWork', name: '转运测量', nameKey: 'modules.warehouseTransferWork.name', icon: '转', iconType: 'compose', description: '进入转运单详情完成测量、打托和拍照', descriptionKey: 'modules.warehouseTransferWork.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer?mode=work' },
{ key: 'returnReceipt', name: '退货入库', nameKey: 'modules.returnReceipt.name', icon: '退', iconType: 'undo', description: '扫描退货单或 SKU,按本包实收数量生成退货库存', descriptionKey: 'modules.returnReceipt.description', available: true, route: '/pages/return-receipt/return-receipt' },
- { key: 'returnPacking', name: '退货合箱', nameKey: 'modules.returnPacking.name', icon: '合', iconType: 'folder-add', description: '选择同客户已签收退货单,按 SKU 数量生成新箱', descriptionKey: 'modules.returnPacking.description', available: true, route: '/pages/return-packing/return-packing' },
- { key: 'returnCounting', name: '退货箱内清点', icon: '清', iconType: 'list', description: '逐箱清点托盘签收货物,核对 SKU 和数量', available: true, route: '/pages/return-counting/return-counting' }
+ { key: 'returnPacking', name: '合箱', nameKey: 'modules.returnPacking.name', icon: '合', iconType: 'folder-add', description: '选择同客户已签收退货单,按 SKU 数量生成新箱', descriptionKey: 'modules.returnPacking.description', available: true, route: '/pages/return-packing/return-packing' },
+ { key: 'returnCounting', name: '退货清点', icon: '清', iconType: 'list', description: '逐箱清点托盘签收货物,核对 SKU 和数量', available: true, route: '/pages/return-counting/return-counting' }
] },
{ key: 'outbound', name: '出库履约', nameKey: 'modules.outbound.name', icon: '出', description: '排车拣货、出库任务与发车闭环', descriptionKey: 'modules.outbound.description', operations: [
- { key: 'dispatchPicking', name: '排车拣货', nameKey: 'modules.dispatchPicking.name', icon: '车', iconType: 'paperplane', description: '按排车任务扫描托盘拣货,完成门禁后执行发车', descriptionKey: 'modules.dispatchPicking.description', available: true, route: '/pages/dispatch/dispatch' },
- { key: 'outboundPicking', name: '出库作业', nameKey: 'modules.outboundPicking.name', icon: '拣', iconType: 'checkbox', description: '处理普通出库、一件代发和废弃订单,按托盘扫码完成', descriptionKey: 'modules.outboundPicking.description', available: true, route: '/pages/outbound/outbound' }
+ { key: 'dispatchPicking', name: '拣货', nameKey: 'modules.dispatchPicking.name', icon: '车', iconType: 'paperplane', description: '理货审核通过后真实拣货,确认装车完成即进入派送中', descriptionKey: 'modules.dispatchPicking.description', available: true, route: '/pages/dispatch/dispatch' },
+ { key: 'outboundPicking', name: '出库', nameKey: 'modules.outboundPicking.name', icon: '拣', iconType: 'checkbox', description: '处理普通出库、一件代发和废弃订单,按托盘扫码完成', descriptionKey: 'modules.outboundPicking.description', available: true, route: '/pages/outbound/outbound' }
] },
{ key: 'inventory', name: '库内作业', nameKey: 'modules.inventory.name', icon: '库', description: '商品移库与托盘管理', descriptionKey: 'modules.inventory.description', operations: [
{ key: 'move', name: '商品移库', nameKey: 'modules.move.name', icon: '移', iconType: 'loop', description: '扫描来源与目标库位完成移库', descriptionKey: 'modules.move.description', available: true, route: '/pages/move/move' },
diff --git a/src/pages.json b/src/pages.json
index efd3a9a..8a04281 100644
--- a/src/pages.json
+++ b/src/pages.json
@@ -2,7 +2,6 @@
"pages": [
{ "path": "pages/login/login", "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/warehouse-transfer/warehouse-transfer", "style": { "navigationStyle": "custom", "navigationBarTitleText": "仓储转运测量", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/return-receipt/return-receipt", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货入库", "backgroundColor": "#F5F7FA" } },
diff --git a/src/pages/dispatch/dispatch.vue b/src/pages/dispatch/dispatch.vue
index 723da3c..a15651c 100644
--- a/src/pages/dispatch/dispatch.vue
+++ b/src/pages/dispatch/dispatch.vue
@@ -1,209 +1,277 @@
-
-
-
-
- 排车拣货与发车
- 扫描排车单号打开任务,再扫描任务中的托盘码进行拣货
-
- 当前任务排车拣货详情
-
-
-
-
- 网络中断会保留当前作业,恢复后可重试。
-
-
- 正在加载排车任务…
-
- {{ error.message }}
- 错误码:{{ error.code }}
- requestId:{{ error.requestId }}
- traceId:{{ error.traceId }}
- 网络连接失败,当前作业已保留,请恢复网络后重试。
-
-
-
-
- 排车任务{{ tasks.length }} 个任务
-
-
-
- 暂无排车拣货任务可刷新列表,或直接扫描排车单号恢复作业。
-
-
-
-
-
- {{ current.dispatchNo || '—' }}{{ statusLabel(current.status) }}
- 任务号 {{ current.taskNo || '—' }} · {{ departed ? '原发车车牌' : '车牌' }} {{ current.vehiclePlate || '—' }}
- {{ current.pickBlockReason || '请先通过调度审核再拣货' }}
- {{ plannedPackages }}计划箱数{{ pickedPackages }}已拣箱数{{ remainingPackages }}待拣箱数
-
-
-
-
- {{ item.palletNo || '未关联托盘码' }}{{ item.pickedPackages }}/{{ item.scheduledPackages }} 箱
- {{ routeLabel(item) }}
- {{ carton.cartonMark }} · {{ carton.status === 'OUTBOUNDED' ? '已拣货' : '待扫描' }}
-
-
-
- 任务没有可拣货明细请联系平台确认排车托盘是否已下发。
-
-
- 本次拣货:{{ selectedItem.palletNo }}
- {{ selectedReturnCarton ? `已扫描 ${selectedReturnCarton.cartonMark}` : '请扫描此托盘上的来源箱唛,每次拣货一箱' }}
- 拣货箱数最多 {{ selectedRemaining }} 箱
-
-
-
-
- 完成拣货
- 尚有 {{ remainingPackages }} 箱未拣,不能完成排车拣货。
- 全部计划箱数已拣齐,可以完成任务。
-
-
-
-
- 整车发车
- 车次 {{ current.tripNo }} · 共 {{ current.tripDispatchCount }} 张排车单
- {{ current.driverWarning }}
- 服务端仍显示 {{ remainingPackages }} 箱未拣,不允许发车。
- 该排车任务已执行发车,不可重复提交。
- {{ current.departBlockReason || '当前排车暂不满足发车条件,请刷新后重试。' }}
- 本车所有排车单已就绪,确认后将一并发车。
-
-
-
-
- {{ successMessage }}
+
+
+ 理货审核、真实拣货与装车
+ {{ current.dispatchNo }}
+
+
+
-
+
+ {{ current ? '扫码拣货' : '扫码查询排车' }}
+
+
+
+ 正在加载排车事实…
+ {{ error.message }}错误码:{{ error.code }}requestId:{{ error.requestId }}traceId:{{ error.traceId }}网络中断,请稍后重新操作。
+
+ 排车任务
+ 暂无排车任务,可扫描排车单号读取。
+
+
+
+
+
+
+
+
+
+ 任务摘要
+ {{ current.dispatchNo }}{{ stageLabel(current) }}
+ 任务号 {{ current.taskNo || '—' }}
+ {{ current.actions.PICK?.blockReason || '拣货资格尚未允许,请核对两级审核及当前作业状态' }}
+ 拣货进度
+ {{ plannedPackages }}计划箱数{{ pickedPackages }}真实已拣{{ remainingPackages }}未拣箱数
+
+
+ 理货员审核
+
+ 车牌号{{ current.resources?.vehicle?.plate || '—' }}
+ 托车公司{{ current.resources?.vehicle?.carrierName || '—' }}
+ 司机{{ current.resources?.driver?.name || '—' }}
+ 电话{{ current.resources?.driver?.phone || '—' }}
+ 计划发车{{ reviewTime(current.plannedDepartureAt) }}(北京时间)
+ 预计送达{{ reviewTime(current.estimatedArrivalAt) }}(北京时间)
+ {{ appointment.destinationWarehouseCode }}{{ appointment.appointmentNo || '—' }}{{ appointment.status === 'SUCCESS' ? '已确认' : appointment.status === 'REGISTERED' ? '已登记' : '未填写' }}:{{ reviewTime(appointment.confirmedAt) }}(北京时间)
+ 驾驶证号{{ current.resources?.driver?.evidence?.licenseNo || '—' }}
+ 有效期{{ current.resources?.driver?.evidence?.licenseExpiresOn || '—' }}
+ 调度审核{{ reviewLabel(current.reviews?.dispatchReview) }} · {{ reviewTime(current.reviews?.dispatchReview?.reviewedAt) }}
+ 调度意见{{ current.reviews.dispatchReview.reason }}
+
+ 已退回客服处理(车辆信息):{{ rejectionReason }}。修改后须重新提交审核。
+
+ {{ current.actions.LOADING_REVIEW.blockReason }}
+ 请核对车辆、司机及下方货物;通过后才能开始真实拣货。驳回将回到客服处理(车辆信息)。
+
+
+
+ {{ current.actions.LOADING_REVIEW?.blockReason || '当前不具备理货审核资格,请先完成客服提交和调度审核' }}
+
+ 托盘与货物来源
+
+ {{ item.palletNo || '未关联托盘码' }}当前选择
+ {{ item.sourceBusinessType === 'RELABEL_ORDER' ? '换标单号' : '来源业务号' }}{{ item.sourceBusinessNo || '—' }}
+ 客户{{ item.customerName || '—' }}
+ 目的地{{ routeLabel(item) }}
+ {{ carton.cartonMark }} · {{ carton.status === 'OUTBOUNDED' ? '已拣货' : '待扫描' }}
+ 已拣 {{ item.pickedPackages }} / {{ item.scheduledPackages }} 箱
+
+
+ 本车暂无货物,空单不能核平、装车或发车。
+ 该托盘暂无可拣来源,请核对是否已完成实收,或货物是否冻结、被占用及来源发生变化。
+ 请选择本次真实拣货来源
+
+ 当前拣货{{ selectedItem.palletNo || '来源箱唛' }}
+ {{ selectedReturnCarton ? `已扫 ${selectedReturnCarton.cartonMark}` : '请扫描来源箱唛,每次一箱' }}
+ 拣货箱数本次最多可拣 {{ selectedRemaining }} 箱
+
+
+ {{ current.actions.LOADING_COMPLETE?.blockReason || '尚未获得装车完成资格' }}
+
+ {{ successMessage }}
+
+
+
+ 确认装车
+ {{ loadingRows.length }} 托已拣托盘{{ loadingRows.reduce((sum, row) => sum + Number(row.pickedPackages || 0), 0) }} 箱已拣箱数
+
+
+
-
-
diff --git a/src/pages/module/module.vue b/src/pages/module/module.vue
deleted file mode 100644
index f2b777d..0000000
--- a/src/pages/module/module.vue
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
- {{ currentModule.icon }}{{ currentModule.name }}{{ currentModule.description }}{{ availableCount }}/{{ currentModule.operations.length }} 可用
- 业务功能点击可用功能开始作业
-
- 灰色功能尚未开放,仅可进入已接入真实接口的作业。
-
-
-
-
-
diff --git a/src/pages/move/move.vue b/src/pages/move/move.vue
index b85adb0..29a69a2 100644
--- a/src/pages/move/move.vue
+++ b/src/pages/move/move.vue
@@ -1,8 +1,8 @@
-
+
- 整托移库扫描来源托盘和同仓目标库位,按服务端资源版本一次移动整托库存
+ 整托移库
正在恢复未完成的移库草稿…
暂无已读取托盘,请先扫描或输入来源托盘码
@@ -17,7 +17,7 @@
{{ target.locationCode }}{{ locationTypeLabel(target.locationType) }}{{ statusLabel(target.status) }}{{ target.occupiedPackages }}已占箱数{{ target.inventoryPositionCount }}库存位置
- 3. 移库原因来源和目标必须属于 Token 绑定的同一海外仓;冲突后必须重新扫描。
+ 3. 移库原因(必填)发生冲突后请重新扫描。
移库完成{{ result.moveNo || '移库单号待生成' }}{{ result.palletCode }}:{{ result.sourceLocationCode || '未绑定库位' }} → {{ result.targetLocationCode || '—' }}{{ result.packageCount }} 箱 · {{ result.replayed ? '幂等恢复结果' : '本次提交成功' }}
{{ error.message }}错误码:{{ error.code }}requestId:{{ error.requestId }}traceId:{{ error.traceId }}
@@ -29,7 +29,7 @@
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { moveInventory, resolveWarehouseLocation, resolveWarehousePallet } from '@/api/warehouse-operations.js'
import { session, requireSession } from '@/stores/session.js'
- import { releaseWarehouseKey, resetWarehouseDomain, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
+ import { resetWarehouseDomain, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
@@ -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 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 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 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 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' },
setError(error, fallback) { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
diff --git a/src/pages/operation/operation.vue b/src/pages/operation/operation.vue
index eb3bf22..111e944 100644
--- a/src/pages/operation/operation.vue
+++ b/src/pages/operation/operation.vue
@@ -1,32 +1,30 @@
-
+
- {{ pageTitle }}{{ pageSubtitle }}
+ {{ pageTitle }}
正在恢复当前柜作业…
- 当前 PDA 绑定仓{{ boundWarehouseLabel }}仅显示已分配到当前仓的集装箱
共 {{ candidates.length }} 柜
{{ candidateError.message || '待卸货集装箱加载失败' }}code={{ candidateError.code }}requestId={{ candidateError.requestId }}traceId={{ candidateError.traceId }}
正在刷新待卸货集装箱…
暂无待卸货集装箱
- {{ item.containerNo }}计划到仓:{{ formatPlannedArrival(item.plannedArrivalAt) }}{{ item.operable ? statusLabel(item.status) : '待齐全' }}{{ item.operable ? (item.currentAccount ? '继续' : '详情') : '只读' }} ›
- {{ item.totalOrderCount }}总订单{{ item.inboundCodeCount }}入仓码{{ item.planCount }}已生成计划{{ item.pendingPlanCount }}未操作
- 已生成 {{ item.planCount }} 个入库计划,{{ item.pendingPlanCount }} 个未操作,其中 {{ item.missingInboundCodeCount }} 个缺入仓码
- {{ item.blockReason || '当前柜入库计划尚未齐全' }}
- 海外接柜仓:{{ receivingWarehouseLabel(item) }}
- 业务目的仓:{{ item.destinationCodes.join(' / ') }}
+ {{ item.containerNo }}计划到仓:{{ formatPlannedArrival(item.plannedArrivalAt) }}{{ item.operable ? statusLabel(item.status) : '待齐全' }}{{ item.operable ? (item.currentAccount ? '继续' : '详情') : '只读' }} ›
+ {{ item.totalOrderCount }}总订单{{ item.inboundCodeCount }}入仓码{{ item.planCount }}已生成计划{{ item.pendingPlanCount }}未操作
+ 已生成 {{ item.planCount }} 个入库计划,{{ item.pendingPlanCount }} 个未操作,其中 {{ item.missingInboundCodeCount }} 个缺入仓码
+ {{ item.pendingPlanCount }} 单待处理
+ 海外接柜仓:{{ receivingWarehouseLabel(item) }}
+ 业务目的仓:{{ item.destinationCodes.join(' / ') }}
- 接柜仓由平台预先分配;确认后只建立当前账号的接柜会话,完成前不能切柜。
@@ -37,14 +35,14 @@
{{ selected?.blockReason || '当前柜入库计划尚未齐全' }}
海外接柜仓:{{ receivingWarehouseLabel(selected) }}
-
+
- 国内装柜照片(只读)现场对照国内装柜过程与封柜状态{{ domesticPhotoCount }} / 5
+ 国内装柜照片(只读){{ domesticPhotoCount }} / 5
@@ -54,7 +52,7 @@
- 卸货签收照片三个固定槽位各拍摄 1 张现场照片{{ uploadedPhotoCount }} / 3
+ 卸货签收照片{{ uploadedPhotoCount }} / 3
@@ -63,7 +61,7 @@
{{ step.label }}{{ photoSlots[index]?.fileId ? '已上传' : '必传' }}
- {{ photoStateLabel(photoSlots[index]) }}
+ {{ photoStateLabel(photoSlots[index]) }}
{{ photoSlots[index].errorMessage || '请重试' }}
@@ -76,7 +74,7 @@
- 国内装柜照片已同步 {{ domesticPhotoCount }} / 5,点按{{ domesticPhotosExpanded ? '收起' : '展开' }}对照{{ domesticPhotosExpanded ? '收起' : '查看' }} ›
+ 国内装柜照片({{ domesticPhotoCount }} / 5){{ domesticPhotosExpanded ? '收起' : '查看' }} ›
@@ -86,7 +84,7 @@
- 1. 扫描 DP、箱码或 FBA 任一箱
+ 1. 货物码
@@ -94,17 +92,17 @@
{{ activeInbound.destinationType === 'FBA' ? 'FBA 按箱数收货' : '海外仓逐箱收货' }}{{ destinationLabel(activeInbound) }}
{{ (activeInbound.inboundCodes || [activeInbound.inboundCode]).join(' / ') }}
- {{ activeInbound.operationMode === 'FBA_GROUP' ? '按 FBA 仓成组收货' : '逐箱收货' }}{{ activeInbound.assigned ? '已分配' : '未分配' }}{{ activeInbound.mergeAllowed ? '可合托(现场自选)' : '不合托' }}{{ activeInbound.urgent ? '加急' : '普通' }}
+ {{ activeInbound.assigned ? '已分配' : '未分配' }}{{ activeInbound.mergeAllowed ? '可合托(现场自选)' : '不合托' }}{{ activeInbound.urgent ? '加急' : '普通' }}
{{ activeInbound.mergeHint }}
总计 {{ activeInbound.totalPackages }} 箱已收 {{ activeInbound.receivedPackages }} 箱待收 {{ activeInbound.pendingPackages }} 箱
- 2. 扫描预留实物托盘码
- 扫码确认服务端已预留的托盘及库位;合托提示不改变当前分配。
+ 2. 托盘码
+
{{ resolvedPallet.palletCode }}{{ resolvedPallet.compatible ? '预留托盘校验通过,可收货' : '托盘不匹配' }}库位:{{ resolvedPallet.locationCode }}{{ resolvedPallet.locationType === 'SHARED_OVERFLOW' ? '(A0共享溢出位)' : '' }}库位:待现场确认
- 3. 输入该托累计箱数
+ 3. 该托累计箱数
@@ -162,9 +160,7 @@
canSubmitReceipt() { if (!this.activeInbound || !this.resolvedPallet?.compatible) return false; if (this.activeInbound.destinationType === 'FBA') { const count = Number(this.packageCount); return Number.isInteger(count) && count > 0 } return Boolean(this.activeInbound.cartonMark) },
allInboundsReconciled() { const values = this.current?.inbounds || []; return values.length > 0 && !this.current?.hasDifference && values.every((item) => Number(item.pendingPackages) === 0) },
canFinishOnsite() { return Boolean(this.current && ['SIGNED', 'RECEIVING'].includes(this.current.status)) },
- boundWarehouseLabel() { const code = String(this.user?.warehouseCode || '').trim(); const name = String(this.user?.warehouseName || '').trim(); return code && name ? `${code} · ${name}` : '海外仓未关联' },
- pageTitle() { return { candidates: '待卸货集装箱', detail: '柜号详情', sign: '拆柜拍照签收', receive: '当前柜收货', completed: '入库完成' }[this.phase] },
- pageSubtitle() { return { candidates: '选择集装箱后拍摄 3 张照片完成签收', detail: '核对柜号与整柜计划箱数', sign: '完成现场取证后进入收货', receive: '扫描箱码定位入仓码后开始收货', completed: '库存与柜级进度已由服务端核平' }[this.phase] }
+ pageTitle() { return { candidates: '待卸货集装箱', detail: '柜号详情', sign: '拆柜拍照签收', receive: '当前柜收货', completed: '入库完成' }[this.phase] }
},
onLoad(options) { this.moduleKey = options.module || 'query'; const key = options.op || (this.moduleKey === 'inbound' ? 'receiving' : 'inventoryQuery'); this.operationName = ({ receiving: '拆柜收货', stocktake: '库存盘点', move: '商品移库', pallet: '托盘管理', relabelReceive: '换标列表', relabelExecute: '换标执行', relabelReview: '换标复核', inventoryQuery: '库存查询', locationQuery: '库位查询', palletQuery: '托盘查询' })[key] || '库存查询' },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this._pageActive = true; this.startScanner(); if (this.inboundMode && (!this.bootstrapped || this.phase === 'candidates' || this._resumeReceiving)) { if (!this.busy) { this._resumeReceiving = false; this.bootstrap() } } },
@@ -178,7 +174,7 @@
receivingWarehouseLabel(item) { const code = String(item?.overseasWarehouseCode || '').trim(); const name = String(item?.overseasWarehouseName || '').trim(); return code && name ? `${code} · ${name}` : '—' },
validateReceivingWarehouse(value, label) { return assertReceivingWarehouse(value, this.user, label) },
async refreshBoundWarehouse() { this.user = applyWarehouseProfile(await getCurrentWarehouseProfile()); return this.user },
- photoStateLabel(photo) { if (photo?.status === 'uploading') return '正在上传…'; if (photo?.status === 'error') return photo.errorMessage || (photo.fileId ? '重拍失败,已保留原图' : '上传失败,请重试'); return photo?.fileId ? '点击左侧照片可查看大图' : '请拍摄现场照片' },
+ photoStateLabel(photo) { if (photo?.status === 'uploading') return '正在上传…'; if (photo?.status === 'error') return photo.errorMessage || (photo.fileId ? '重拍失败,已保留原图' : '上传失败,请重试'); return '' },
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleHardwareScan(value), onError: (error) => { this.error = error; this.showToast('硬件扫码接收器异常,请检查扫描服务', 'warning') } }) }, stopScanner() { if (typeof this._stopScanner === 'function') this._stopScanner(); this._stopScanner = null },
handleHardwareScan(value) { if (!this._pageActive || this.loading) return; const scan = { value: String(value || '').trim(), target: this.activeTarget }; if (!scan.value) return; if (!this.inboundMode) return this.showToast('该功能缺少海外 PDA 专用接口,扫码未提交', 'warning'); if (this.phase === 'candidates') return this.showToast('请直接点击待卸货集装箱进入详情', 'warning'); if (this.phase !== 'receive') return; if (this.busy || this.activeTarget === 'quantity') { if (this.scanQueue.length >= 100) return this.showToast('扫码队列已满,请先处理当前托盘', 'warning'); this.scanQueue.push(scan); return this.showToast(`已排队 ${this.scanQueue.length} 个扫码`, 'warning') } return this.dispatchHardwareScan(scan) },
dispatchHardwareScan(scan) { if (!this._pageActive || this.loading) return; const palletWorkflowActive = Boolean(this.activeInbound || this.resolvedPallet); const target = palletWorkflowActive ? 'pallet' : scan.target; console.info('[Overseas PDA scan] dispatch', { value: scan.value, target, queuedTarget: scan.target }); if (target === 'pallet') { this.palletInput = scan.value; return this.resolvePallet(scan.value) } this.cartonInput = scan.value; return this.resolveCarton(scan.value) },
@@ -217,7 +213,7 @@
diff --git a/src/pages/outbound/outbound.vue b/src/pages/outbound/outbound.vue
index 1deda49..a138331 100644
--- a/src/pages/outbound/outbound.vue
+++ b/src/pages/outbound/outbound.vue
@@ -3,12 +3,11 @@
- 出库任务整托扫描托盘码,散箱扫描箱唛;扫描排车业务号会转入“排车拣货”
+ 出库任务
当前任务出库作业详情
-
+
-
- 任务号直接恢复;退货整托出库扫托盘码,散箱出库扫箱唛。
+
正在加载出库任务…
@@ -17,14 +16,14 @@
待处理出库任务{{ tasks.length }} 个任务
-
+
暂无出库任务可刷新列表,或扫描任务号恢复未完成作业。
- 废弃订单{{ discardOrderCount }} 个订单
+ 废弃订单{{ discardOrderCount }} 个订单
- {{ current.taskNo || '—' }}{{ statusLabel(current.status) }}出库单 {{ current.outboundNo || '—' }} · {{ taskType(current) }}{{ plannedPackages }}计划箱数{{ pickedPackages }}已拣箱数{{ remainingPackages }}待拣箱数
+ {{ current.outboundNo || '—' }}{{ statusLabel(current.status) }}{{ taskType(current) }} · 任务号 {{ current.taskNo || '—' }}{{ plannedPackages }}计划箱数{{ pickedPackages }}已拣箱数{{ remainingPackages }}待拣箱数
@@ -44,9 +43,10 @@
- 废弃订单{{ discardCurrent.discardNo }}
- 扫描该废弃订单对应的托盘,再填写每个 SKU 的实际废弃数量。
- 托盘 {{ discardTask.palletNo }}{{ discardTask.status === 'COMPLETED' ? '已完成' : '待废弃' }}{{ sku.skuCode || '—' }}{{ sku.productName || '—' }} · 计划 {{ sku.plannedPackages }} 箱实际废弃数量最多 {{ sku.plannedPackages }} 箱
+ 废弃订单{{ discardCurrent.discardNo }}出库单 {{ discardCurrent.outboundNo }}
+ 本单相关托盘及废弃范围{{ task.palletNo === '散箱' ? '散箱' : task.palletNo }}{{ task.status === 'COMPLETED' ? '已完成' : '待废弃' }}{{ mark }}缺少箱唛与托盘对应关系,请核对相关托盘,以仓库实际废弃箱数为准。
+
+ {{ discardTask.scanMode === 'CARTON' ? (discardTask.palletNo === '散箱' ? '散箱废弃' : '按箱废弃 · ' + discardTask.palletNo) : '托盘 ' + discardTask.palletNo }}{{ discardTask.status === 'COMPLETED' ? '已完成' : '待废弃' }}本次废弃 {{ discardQuantitySummary }}{{ sku.skuCode || '—' }}{{ sku.productName || '—' }} · 申请 {{ sku.plannedPackages }} {{ sku.quantityUnit || '箱' }}实际废弃箱数箱(0–{{ sku.plannedPackages }})实际废弃 {{ sku.actualPackages }} {{ sku.quantityUnit || '箱' }}按现场实点填写,未废弃的预留库存将释放。已核对 {{ discardScannedMarks.length }}/{{ discardTask.cartonMarks.length }} 箱{{ mark }}{{ discardAllCartons ? '全部废弃' : discardScannedMarks.includes(mark) ? '已扫描' : '待扫描' }}已确认本单所选箱子全部废弃,拍照后点击废弃出库完成。废弃现场照片(至少 1 张)照片关联当前废弃作业,完成出库后可在废弃单详情查看。
{{ successMessage }}
@@ -58,15 +58,15 @@
-
+.candidate-toolbar{align-items:center;margin-bottom:12px}.candidate-toolbar .card-title{margin-bottom:0;font-size:16px;line-height:24px}.candidate-toolbar .link-button{display:flex;align-items:center;justify-content:center;box-sizing:border-box;min-width:88px;height:44px;min-height:44px;margin:0;padding:0 16px;border:1px solid #9dc0f4;border-radius:8px;color:#135ee8;font-size:16px;font-weight:700;line-height:normal;background:#eaf2ff}.candidate-toolbar .link-button::after{border:0}.candidate-toolbar .link-button[disabled]{color:#718095;border-color:#dce5f0;background:#e8edf3}
+.relabel-candidate .business-code{min-width:0;font-size:18px;line-height:24px;overflow-wrap:anywhere}.relabel-candidate .status-pill{font-size:12px;line-height:18px}.candidate-kind{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:8px;color:#52647c;font-size:14px;line-height:22px}.candidate-footer{display:flex;align-items:center;flex-wrap:wrap;gap:6px 12px;margin-top:10px}.candidate-progress{color:#52647c;font-size:14px;line-height:24px}.candidate-progress-value{color:#135ee8;font-size:18px;font-weight:700}.candidate-warning{color:#b54708;font-size:12px;line-height:20px}.candidate-enter{margin-left:auto;color:#135ee8;font-size:14px;font-weight:600;line-height:24px}
diff --git a/src/pages/return-counting/return-counting.vue b/src/pages/return-counting/return-counting.vue
index cc28006..1775a6a 100644
--- a/src/pages/return-counting/return-counting.vue
+++ b/src/pages/return-counting/return-counting.vue
@@ -1,6 +1,6 @@
-
+
{{ error.message }}错误码:{{ error.code }}请求号:{{ error.requestId }}跟踪号:{{ error.traceId }}
@@ -13,27 +13,27 @@
{{ task.palletCode }}{{ task.status === 'COUNTING' ? '清点中' : '已完成' }}{{ task.returnNo }} · {{ task.boxes.length }} 箱{{ row.skuCode }}签收 {{ row.quantity }} / 已填 {{ row.actual }}
- 第 {{ index + 1 }} 箱{{ box.cartonMark }}
- {{ line.skuCode || '选择 SKU' }} ▾
+ 第 {{ index + 1 }} 箱{{ box.cartonMark }}
- 各箱 SKU 数量汇总必须与本托签收数量一致,确认后这些箱子才能换标、合箱或创建出库单。箱内清点已完成,可以继续换标或出库。
+ 箱内清点已完成,可以继续换标或出库。
diff --git a/src/stores/fulfillment.js b/src/stores/fulfillment.js
index 43208c9..9ced8b1 100644
--- a/src/stores/fulfillment.js
+++ b/src/stores/fulfillment.js
@@ -1,11 +1,11 @@
import { reactive } from 'vue'
-const STORAGE_KEY = 'overseas-pda-fulfillment-draft-v1'
+const STORAGE_KEY = 'overseas-pda-fulfillment-draft-v2'
const blankState = () => ({
scopeKey: '',
- dispatch: { currentNo: '', palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1, pendingKeys: {} },
- outbound: { currentTaskNo: '', scanCode: '', packages: 1, pendingKeys: {} }
+ dispatch: { currentNo: '', palletCode: '', dispatchItemId: '', sourceKey: '', cartonCode: '', packages: 1 },
+ outbound: { currentTaskNo: '', scanCode: '', packages: 1 }
})
export const fulfillment = reactive(blankState())
@@ -14,13 +14,7 @@ function storageAvailable() {
return typeof uni !== 'undefined' && typeof uni.setStorageSync === 'function' && typeof uni.getStorageSync === 'function'
}
-function safeDomain(value, fallback) {
- return {
- ...fallback,
- ...(value || {}),
- pendingKeys: { ...(value?.pendingKeys || {}) }
- }
-}
+function safeDomain(value, fallback) { return Object.fromEntries(Object.keys(fallback).map(key => [key, value?.[key] ?? fallback[key]])) }
export function persistFulfillmentDraft() {
if (!storageAvailable() || !fulfillment.scopeKey) return
@@ -55,19 +49,9 @@ function randomKey(domain, action) {
return `pda-${domain}-${action}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
-export function fulfillmentIdempotencyKey(domain, action, payload = {}) {
- if (!fulfillment[domain]) throw new Error('未知海外 PDA 履约类型')
- 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 fulfillmentIdempotencyKey(domain, action, _payload = {}) {
+ if (!fulfillment[domain]) throw new Error('未知海外 PDA 履约类型')
+ return { value: randomKey(domain, action) }
}
export function resetFulfillmentDomain(domain) {
diff --git a/src/stores/warehouse-work.js b/src/stores/warehouse-work.js
index 5d09d0e..a4edcd4 100644
--- a/src/stores/warehouse-work.js
+++ b/src/stores/warehouse-work.js
@@ -3,13 +3,13 @@ import { reactive } from 'vue'
const STORAGE_KEY = 'overseas-pda-warehouse-work-v1'
const blankState = () => ({
scopeKey: '',
- stocktake: { taskNo: '', scopeType: 'LOCATION', scopeCode: '', reason: '', pendingKeys: {} },
- move: { sourcePalletCode: '', targetLocationCode: '', reason: '', pendingKeys: {} },
- relabel: { currentNo: '', oldLabel: '', newLabel: '', action: 'RELABEL', reason: '', remark: '', photoFileIds: [], photoNames: [], pendingKeys: {} },
- returnReceipt: { pendingBatch: null, entryMode: 'SKU', lookupCode: '', currentReturnNo: '', startedBy: 'SKU', trackingNo: '', palletCode: '', quantities: {}, boxCounts: {}, pendingKeys: {} },
- returnPacking: { mode: 'SKU', searchCode: '', selectedOrders: [], quantities: {}, targetBoxes: [], sourceCartonMarks: [], targetCode: '', activePlan: null, result: null, putawayResults: {}, pendingKeys: {} },
- returnPackingPlan: { pendingKeys: {} },
- warehouseTransfer: { currentNo: '', pendingKeys: {} },
+ stocktake: { taskNo: '', scopeType: 'LOCATION', scopeCode: '', reason: '' },
+ move: { sourcePalletCode: '', targetLocationCode: '', reason: '' },
+ relabel: { currentNo: '', oldLabel: '', newLabel: '', action: 'RELABEL', reason: '', remark: '', photoFileIds: [], photoNames: [] },
+ returnReceipt: { entryMode: 'SKU', lookupCode: '', currentReturnNo: '', startedBy: 'SKU', trackingNo: '', palletCode: '', quantities: {}, boxCounts: {} },
+ returnPacking: { mode: 'SKU', searchCode: '', selectedOrders: [], quantities: {}, targetBoxes: [], sourceCartonMarks: [], targetCode: '', activePlan: null, result: null, putawayResults: {} },
+ returnPackingPlan: {},
+ warehouseTransfer: { currentNo: '' },
query: { inventoryCode: '', locationCode: '', palletCode: '' }
})
@@ -19,7 +19,7 @@ function storageAvailable() {
return typeof uni !== 'undefined' && typeof uni.setStorageSync === 'function' && typeof uni.getStorageSync === 'function'
}
-function domain(value, fallback) { return { ...fallback, ...(value || {}), pendingKeys: { ...(value?.pendingKeys || {}) } } }
+function domain(value, fallback) { return Object.fromEntries(Object.keys(fallback).map(key => [key, value?.[key] ?? fallback[key]])) }
export function persistWarehouseWork() {
if (!storageAvailable() || !warehouseWork.scopeKey) return
@@ -54,21 +54,10 @@ export function updateWarehouseWork(name, patch) {
persistWarehouseWork()
}
-export function warehouseIdempotencyKey(name, action, payload = {}) {
- const target = warehouseWork[name]
- 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 warehouseIdempotencyKey(name, action, _payload = {}) {
+ if (!warehouseWork[name]) throw new Error('当前作业不支持写操作')
+ return { value: `pda-${name}-${action}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` }
}
-
-export function releaseWarehouseKey(name, signature) {
- if (!warehouseWork[name]?.pendingKeys) return
- delete warehouseWork[name].pendingKeys[signature]
- persistWarehouseWork()
-}
-
export function resetWarehouseDomain(name) {
const fresh = blankState()
if (!fresh[name]) return
diff --git a/src/styles/pda.scss b/src/styles/pda.scss
index 91ed560..b269ab7 100644
--- a/src/styles/pda.scss
+++ b/src/styles/pda.scss
@@ -699,10 +699,6 @@
}
.portal-title,
- .module-hero-title,
- .module-hero-desc,
- .operation-name,
- .operation-desc,
.summary-number,
.summary-label,
.profile-name,
@@ -780,50 +776,15 @@
.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; }
- .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 .group-title { margin: 0; }
.section-heading-hint { color: #8190a3; font-size: 10px; line-height: 16px; }
.group-title { display: block; margin: 22px 2px 11px; }
- .operation-list { overflow: hidden; border-radius: 12px; background: #ffffff; box-shadow: 0 4px 15px rgba(32, 67, 111, 0.06); }
- .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; }
+ .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; }
+ .history-item:last-child { border-bottom: 0; }
.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-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-title-row, .operation-scan-row, .task-toolbar, .prototype-task-head, .prototype-task-meta, .prototype-task-foot { display: flex; align-items: center; }
diff --git a/src/utils/return-relabel.js b/src/utils/return-relabel.js
new file mode 100644
index 0000000..6231978
--- /dev/null
+++ b/src/utils/return-relabel.js
@@ -0,0 +1,25 @@
+export function returnSelections(task) {
+ return (task?.items || []).filter(item => item.returnCartonId && ['SKU_LABEL', 'CARTON_AND_SKU', 'RETURN_BULK_SKU_LABEL'].includes(item.operationType))
+}
+export function returnDeductions(task) {
+ const rows = new Map()
+ for (const item of returnSelections(task)) {
+ const skus = item.operationType === 'RETURN_BULK_SKU_LABEL' ? [{ skuCode: item.skuCode, quantity: item.expectedPackages }] : item.skuItems || []
+ for (const sku of skus) {
+ const key = `${item.cartonMark}::${sku.skuCode}`
+ const row = rows.get(key) || { cartonMark: item.cartonMark, skuCode: sku.skuCode, quantity: 0 }
+ row.quantity += Number(sku.quantity || 0); rows.set(key, row)
+ }
+ }
+ return [...rows.values()]
+}
+export function requiresReturnPack(task) {
+ const totals = new Map()
+ for (const row of returnDeductions(task)) totals.set(row.cartonMark, (totals.get(row.cartonMark) || 0) + row.quantity)
+ return returnSelections(task).some(item => !Number(item.returnQuantity) || totals.get(item.cartonMark) < Number(item.returnQuantity))
+}
+export function returnPackFacts(task) {
+ return (task?.history || []).filter(row => row.action === 'RETURN_PACK').flatMap(row => {
+ try { const detail = typeof row.detail === 'string' ? JSON.parse(row.detail) : row.detail; return detail?.round === (task.reviewedAt || 'INITIAL') ? [detail] : [] } catch { return [] }
+ })
+}
diff --git a/tests/business-identifiers-ui.test.mjs b/tests/business-identifiers-ui.test.mjs
index e1beb72..f2ddd2c 100644
--- a/tests/business-identifiers-ui.test.mjs
+++ b/tests/business-identifiers-ui.test.mjs
@@ -8,7 +8,7 @@ const interpolations = (source) => [...template(source).matchAll(/\{\{([\s\S]*?)
test('all registered user-facing pages avoid rendering internal identifiers', async () => {
const paths = [
- '../src/pages/login/login.vue', '../src/pages/home/home.vue', '../src/pages/module/module.vue',
+ '../src/pages/login/login.vue', '../src/pages/home/home.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/stocktake/stocktake.vue', '../src/pages/move/move.vue', '../src/pages/relabel/relabel.vue', '../src/pages/query/query.vue',
@@ -86,7 +86,6 @@ test('missing user-visible relationships use explicit empty labels instead of ID
read('../src/components/ReceivingSessionSummary.vue'),
read('../src/pages/pallet/pallet.vue')
])
- assert.match(summary, /overseasWarehouseCode[\s\S]*overseasWarehouseName/)
assert.match(summary, /unique\.length \? unique : \['—'\]/)
assert.match(pallet, /inboundCode \|\| '无入仓码'/)
assert.match(pallet, /locationCode \|\| '未绑定库位'/)
diff --git a/tests/dispatch-loading-documents.test.mjs b/tests/dispatch-loading-documents.test.mjs
new file mode 100644
index 0000000..908a51a
--- /dev/null
+++ b/tests/dispatch-loading-documents.test.mjs
@@ -0,0 +1,90 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import { loadingPhotoSlots, normalizeLoadingDocuments, getLoadingDocuments, bindLoadingPhoto, uploadLoadingPhoto } from '../src/api/dispatch-loading-documents.js'
+import { session } from '../src/stores/session.js'
+const source = (await readFile(new URL('../src/components/DispatchLoadingDocuments.vue', import.meta.url), 'utf8')).match(/')[0].replace(/^\s*impo
function setup(confirm) {
const calls = []
const uni = { showModal(options) { calls.push(options); options.success({ confirm }) }, redirectTo(options) { calls.push(options) } }
- const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'uni', body)({}, {}, {}, uni)
+ const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'uni', body)({}, {}, {}, {}, uni)
const context = { busy: false, stage: 'receive', taskCode: 'PREVIOUS', error: null, candidates: [{ relabelNo: 'RL-ACTIVE', taskStatus: 'PROCESSING', claimedByCurrentOperator: true }] }
context.promptActiveTask = component.methods.promptActiveTask.bind(context)
return { calls, context, open: () => component.methods.openExecution.call(context, { relabelNo: 'RL-OTHER', taskStatus: 'AVAILABLE' }) }
@@ -34,7 +34,7 @@ test('cancel stays on list and preserves existing task selection', async () => {
test('new relabel task asks whether to start before it can be claimed', async () => {
const calls = []
const uni = { showModal(options) { calls.push(options); options.success({ confirm: false }) } }
- const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'uni', body)({}, {}, {}, uni)
+ const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'uni', body)({}, {}, {}, {}, uni)
const context = { busy: false }
const confirmed = await component.methods.confirmStartTask.call(context, { relabelNo: 'RL-NEW' })
diff --git a/tests/relabel-pallet-measurement.test.mjs b/tests/relabel-pallet-measurement.test.mjs
new file mode 100644
index 0000000..bfe9647
--- /dev/null
+++ b/tests/relabel-pallet-measurement.test.mjs
@@ -0,0 +1,125 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+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 script = source.split('')[0].replace(/^import .*$/gm, '').replace('export default', 'return')
+function setup() {
+ const calls = []
+ const component = new Function('ScanTargetInput', 'measureRelabelPallet', 'warehouseIdempotencyKey', 'releaseWarehouseKey', 'bindRelabelPallet', script)(
+ {}, 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))
+ const value = { ...component.data(), task: { relabelNo: 'RL-1' }, $emit() {} }
+ for (const [key, method] of Object.entries(component.methods)) value[key] = method.bind(value)
+ return { component, value, calls }
+}
+test('pallet measurement submits actual dimensions, weight and current stock version and refreshes results', async () => {
+ const { value, calls } = setup()
+ await value.saveMeasurement({ palletCode: 'NEW', stockVersion: 'position:version:count', lengthCm: '100', widthCm: '50', heightCm: '50', grossWeightKg: '40' })
+ assert.deepEqual(calls, [{ relabelNo: 'RL-1', palletCode: 'NEW', stockVersion: 'position:version:count', lengthCm: 100, widthCm: 50, heightCm: 50, grossWeightKg: 40, idempotencyKey: 'stable-key' }])
+ assert.equal(value.measurements[0].volumeCbm, 0.25)
+ assert.equal(value.busy, false)
+ await value.saveMeasurement({ palletCode: 'NEW', lengthCm: 0, widthCm: 50, heightCm: 50, grossWeightKg: 40 })
+ assert.equal(calls.length, 1)
+ assert.match(value.error, /有效/)
+})
+test('only whole physical pallet quantities offer original pallet reuse', () => {
+ const { component } = setup()
+ const canRetain = component.computed.canRetainOriginal
+ const ctx = { source: { totalPackages: 10 }, selectedSource: {}, plannedPackages: 10, isReturnBulk: false, unboundSourceMode: false }
+ assert.equal(canRetain.call(ctx), true)
+ ctx.plannedPackages = 4
+ assert.equal(canRetain.call(ctx), false)
+ ctx.plannedPackages = 10;ctx.isReturnBulk = true
+ assert.equal(canRetain.call(ctx), false)
+})
+
+async function renderMeasurements(measurements) {
+ const { component } = setup()
+ const template = source.slice(source.indexOf('') + 10, source.indexOf('')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
-const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', body)({}, {}, {})
+const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', body)({}, {}, {}, {})
function context() {
const value = { current: { reviewedAt: null, history: [], items: [{ itemId: '1', operationType: 'CARTON_AND_SKU', expectedPackages: 1, skippedPackages: 0, exceptionPackages: 0, skuProcessedPackages: 0, cartonProcessedPackages: 1, skuItems: [{ skuCode: 'OLD', newSkuCode: 'NEW', quantity: 3 }, { skuCode: 'OTHER', newSkuCode: 'NEW2', quantity: 2 }] }] }, taskTab: 'sku' }
for (const name of ['skuDone', 'skuDetails', 'remaining']) value[name] = component.methods[name].bind(value)
@@ -24,6 +24,35 @@ test('normal SKU input is always visible in SKU tab and never requests the new l
assert.match(source, /matches.length > 1/)
assert.match(source, /scanRelabelSku/)
})
+
+test('return SKU follows ordinary single scan and requires explicit choice when the same SKU spans boxes', async () => {
+ const calls = []
+ let value
+ const subject = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'scanRelabelSku', 'warehouseIdempotencyKey', 'releaseWarehouseKey', 'uni', body)(
+ {}, {}, {}, {}, async (no, payload) => { calls.push(payload); return value.current },
+ () => ({ value: 'KEY', signature: 'SIG' }), () => {}, { showModal: options => options.complete() }
+ )
+ value = context()
+ Object.assign(value, { busy: false, quickSku: '', skuChoices: [], verifiedReturnCarton: '', returnCartonCode: '',
+ async run(action, work) { await work() }, applyCurrent(task) { this.current = task },
+ setError(error) { throw error }
+ })
+ for (const name of ['submitSku', 'commitSku']) value[name] = subject.methods[name].bind(value)
+ value.current.relabelNo = 'RL'; value.current.version = 3
+ value.current.items = [{ itemId: '1', returnCartonId: 'R1', cartonMark: 'BOX-A', operationType: 'SKU_LABEL', expectedPackages: 1, relabelledPackages: 0, skippedPackages: 0, exceptionPackages: 0, skuItems: [{ skuCode: 'OLD', newSkuCode: 'NEW', quantity: 20 }] }]
+ await value.submitSku('OLD')
+ assert.deepEqual(calls, [{ itemId: '1', cartonMark: 'BOX-A', newSkuCode: 'NEW', quantity: 20, skuIndex: 0, oldSku: 'OLD', expectedVersion: 3, idempotencyKey: 'KEY' }])
+ calls.length = 0
+ value.current.items.push({ ...value.current.items[0], itemId: '2', returnCartonId: 'R2', cartonMark: 'BOX-B' })
+ await value.submitSku('OLD')
+ assert.equal(calls.length, 0)
+ assert.equal(value.skuChoices.length, 2)
+ await value.commitSku(value.skuChoices[1])
+ assert.equal(calls.length, 1)
+ assert.equal(calls[0].itemId, '2')
+ assert.equal(calls[0].cartonMark, 'BOX-B')
+ assert.doesNotMatch(source.split('')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
+function setup(confirm = false) {
+ const calls = [], errors = []
+ const uni = { showModal(options) { options.success({ confirm }) } }
+ const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'requiresReturnPack', 'returnPackFacts', 'completeReturnRelabelSku', 'warehouseIdempotencyKey', 'releaseWarehouseKey', 'uni', body)({}, {}, {}, {}, helpers.requiresReturnPack, helpers.returnPackFacts, async (no, payload) => { calls.push(payload); return value.current }, () => ({ value: 'key', signature: 'signature' }), () => {}, uni)
+ const item = (id, mark) => ({ itemId: id, returnCartonId: id, cartonMark: mark, returnQuantity: 10, operationType: 'RETURN_BULK_SKU_LABEL', skuCode: 'SKU', newSkuCode: 'TARGET', expectedPackages: 4, relabelledPackages: 0, skippedPackages: 0, exceptionPackages: 0 })
+ const value = { current: { relabelNo: 'RL', version: 1, items: [item('1', 'BOX-A'), item('2', 'BOX-B')], history: [] }, busy: false, allProcessed: true, remark: '', photoFileIds: ['photo'], returnCartonCode: '', verifiedReturnCarton: '', returnSkuCode: '', bulkQuantities: {}, returnTargetLabels: {}, setError(error) { errors.push(error.message) }, applyCurrent(result) { this.current = result }, async run(action, work) { await work() } }
+ for (const [name, fn] of Object.entries(component.methods)) if (!value[name]) value[name] = fn.bind(value)
+ for (const name of ['isReturnTask', 'isReturnBulk', 'returnBulkSources', 'returnBulkSkuGroups', 'requiresPalletBinding', 'palletTask', 'completionBlockReason']) Object.defineProperty(value, name, { get: component.computed[name].bind(value) })
+ return { value, calls, errors }
+}
+test('SKU alone cannot register or allocate unscanned sources', async () => {
+ const { value, calls, errors } = setup()
+ value.verifyReturnSku('SKU'); assert.match(errors.pop(), /先确认来源/)
+ value.verifyReturnCarton('BOX-A'); value.verifyReturnSku('SKU')
+ assert.deepEqual(value.returnBulkSources.map(item => item.cartonMark), ['BOX-A'])
+ value.returnTargetLabels['2'] = 'TARGET'; value.bulkQuantities['2'] = '4'
+ await value.completeBulkSku(value.current.items[1]); assert.equal(calls.length, 0)
+})
+test('quantity is explicit, integral and source bounded; wrong target cannot write', async () => {
+ const { value, calls } = setup(); value.verifyReturnCarton('BOX-A'); value.verifyReturnSku('SKU')
+ const item = value.current.items[0]; value.returnTargetLabels['1'] = 'TARGET'
+ for (const quantity of ['', '1.5', '5', '0']) { value.bulkQuantities['1'] = quantity; await value.completeBulkSku(item) }
+ assert.equal(calls.length, 0)
+ value.bulkQuantities['1'] = '2'; value.returnTargetLabels['1'] = 'WRONG'; await value.completeBulkSku(item); assert.equal(calls.length, 0)
+ value.returnTargetLabels['1'] = 'TARGET'; await value.completeBulkSku(item)
+ assert.deepEqual(calls, [{ itemId: '1', cartonMark: 'BOX-A', skuCode: 'SKU', quantity: 2, expectedVersion: 1, idempotencyKey: 'key' }])
+})
+test('all action needs confirmation and rescanning clears quantity and target', async () => {
+ for (const confirm of [false, true]) { const { value } = setup(confirm); value.verifyReturnCarton('BOX-A'); await value.fillAllReturn(value.current.items[0]); assert.equal(value.bulkQuantities['1'], confirm ? '4' : undefined); value.verifyReturnCarton('BOX-B'); assert.deepEqual(value.bulkQuantities, {}); assert.deepEqual(value.returnTargetLabels, {}) }
+})
+test('normal return SKU rejects incomplete approved target before server scan', async () => {
+ const { value, errors } = setup(); const item = { ...value.current.items[0], operationType: 'SKU_LABEL', expectedPackages: 1, skuItems: [{ skuCode: 'SKU', newSkuCode: 'TARGET', quantity: 3 }] }
+ value.current.items = [item]; value.pendingSkuRows = [{ item, sku: item.skuItems[0], index: 0 }]
+ item.skuItems[0].newSkuCode = ''; value.quickSku = 'SKU'; await value.commitSku(value.pendingSkuRows[0]); assert.match(errors.pop(), /申请件数缺失/)
+})
+test('packing uses SKU pieces instead of box count and preserves both sources', () => {
+ const task = { items: [{ returnCartonId: '1', cartonMark: 'A', operationType: 'SKU_LABEL', expectedPackages: 1, returnQuantity: 8, skuItems: [{ skuCode: 'SKU', quantity: 3 }] }, { returnCartonId: '2', cartonMark: 'B', operationType: 'SKU_LABEL', expectedPackages: 1, returnQuantity: 2, skuItems: [{ skuCode: 'SKU', quantity: 2 }] }] }
+ assert.deepEqual(helpers.returnDeductions(task), [{ cartonMark: 'A', skuCode: 'SKU', quantity: 3 }, { cartonMark: 'B', skuCode: 'SKU', quantity: 2 }]); assert.equal(helpers.requiresReturnPack(task), true)
+ task.items[0].returnQuantity = 3; assert.equal(helpers.requiresReturnPack(task), false)
+})
+test('only truck requires return pallet binding and counts physical new boxes', () => {
+ const { value } = setup(); for (const method of [null, 'EXPRESS', 'EXPRESS_PICKUP', 'TRUCK']) { value.current.deliveryMethod = method; assert.equal(value.requiresPalletBinding, method === 'TRUCK') }
+ value.current.items.forEach(item => { item.cartonMark = 'NEW'; item.relabelledPackages = item.expectedPackages })
+ assert.equal(value.palletTask.items.length, 1); assert.equal(value.palletTask.items[0].expectedPackages, 1)
+})
+test('pack recovery ignores previous rework round', () => {
+ const task = { reviewedAt: 'ROUND2', history: [{ action: 'RETURN_PACK', detail: JSON.stringify({ round: 'INITIAL', cartonMark: 'OLD' }) }, { action: 'RETURN_PACK', detail: JSON.stringify({ round: 'ROUND2', cartonMark: 'NEW' }) }] }
+ assert.deepEqual(helpers.returnPackFacts(task).map(row => row.cartonMark), ['NEW'])
})
-test('return relabel pallet binding validates pieces while binding physical carton marks', () => {
- assert.match(palletWork, /isReturnBulk\(\).*RETURN_BULK_SKU_LABEL/)
- assert.match(palletWork, /count === packages && this\.completedItems\(\)\.length === this\.eligibleItems\.length && this\.marks\.length === this\.eligibleItems\.length/)
- assert.match(palletWork, /this\.isReturnBulk \? this\.eligibleItems : this\.eligibleItems\.slice\(0, count\)/)
- assert.match(page, /requiredMarks[\s\S]*some\(\(mark\) => !marks\.has\(mark\)\)/)
-})
-
-test('return relabel mode works by SKU remaining quantity without carton-mark scanning', () => {
- assert.match(page, /current\?\.operationType === 'RETURN_BULK_SKU_LABEL'/)
- assert.match(page, /退货贴标 SKU 明细/)
- assert.match(page, /name="returnSku"[\s\S]*@submit="verifyReturnSku"/)
- assert.match(page, /activeTarget === 'return-sku'[\s\S]*this\.verifyReturnSku\(value\)/)
- assert.match(page, /verifiedReturnSkuKey !== group\.key/)
- assert.match(page, /Number\.isSafeInteger\(quantity\)[\s\S]*quantity > group\.remaining/)
- assert.match(page, /returnBulkSkuGroups/)
- assert.match(page, /来源托盘拆箱数量/)
- assert.match(page, /登记本次贴标.*group\.remaining/)
- assert.match(page, /completeReturnRelabelSkuTotal/)
- assert.match(page, /taskTab === 'carton' && !isReturnBulk/)
-})
-
-test('new relabel pallet is immediately copied into the target scan field', () => {
- assert.match(palletWork, /this\.targetCode = this\.newPallet\.palletCode \|\| ''/)
- assert.match(palletWork, /退货换标无需扫描箱唛/)
-})
-
-test('return relabel distinguishes a whole pallet from a partial unpack', () => {
- assert.match(palletWork, /wholeSource\(\).*plannedPackages === Number\(this\.selectedSource\.stockPackages/)
- assert.match(palletWork, /eligibleItems\(\).*this\.isReturnBulk \? this\.sourceItems\(\) : this\.completedItems\(\)/)
- assert.match(palletWork, /this\.selectedDestination = this\.sourceItems\(\)\[0\]/)
- assert.match(palletWork, /this\.completedItems\(\)\.length !== this\.eligibleItems\.length/)
- assert.match(palletWork, /贴标数量等于本托盘全部/)
- assert.match(palletWork, /贴标数量少于本托盘库存/)
- assert.match(palletWork, /v-if="!isReturnBulk"[\s\S]*name="relabel-source"/)
- assert.match(palletWork, /await this\.run\(async \(\) => \{ const value = await resolvePallet\(row\.palletCode\)/)
+test('partial return completion requires current-round independent box then truck binding and photo', () => {
+ const { value } = setup()
+ value.current.items.forEach(item => { item.relabelledPackages = item.expectedPackages })
+ value.current.relabelledPackages = 8
+ value.current.deliveryMethod = 'EXPRESS'
+ assert.match(value.completionBlockReason, /独立新箱/)
+ value.current.history.push({ action: 'RETURN_PACK', detail: JSON.stringify({ round: 'INITIAL', cartonMark: 'NEW' }) })
+ value.current.items.forEach(item => { item.cartonMark = 'NEW'; item.returnQuantity = 8 })
+ assert.equal(value.completionBlockReason, '')
+ value.current.deliveryMethod = 'TRUCK'
+ assert.match(value.completionBlockReason, /组托/)
+ value.current.history.push({ action: 'PALLET_BIND', detail: JSON.stringify({ round: 'INITIAL', cartonMarks: ['NEW'] }) })
+ assert.equal(value.completionBlockReason, '')
+ value.photoFileIds = []
+ assert.match(value.completionBlockReason, /照片/)
})
diff --git a/tests/relabel-return-carton-mixed.test.mjs b/tests/relabel-return-carton-mixed.test.mjs
new file mode 100644
index 0000000..e0827e3
--- /dev/null
+++ b/tests/relabel-return-carton-mixed.test.mjs
@@ -0,0 +1,54 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+const page = await readFile(new URL('../src/pages/relabel/relabel.vue', import.meta.url), 'utf8')
+const helper = await readFile(new URL('../src/utils/return-relabel.js', import.meta.url), 'utf8')
+const { returnDeductions, requiresReturnPack, returnPackFacts } = await import('data:text/javascript;base64,' + Buffer.from(helper).toString('base64'))
+const body = page.split('')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
+const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'requiresReturnPack', 'returnPackFacts', body)({}, {}, {}, {}, requiresReturnPack, returnPackFacts)
+test('return carton and normal SKU use single scans; explicit source scan is reserved for bulk quantities', () => {
+ assert.doesNotMatch(page, /退货箱唛换标必须先确认同一来源包裹\/箱/)
+ assert.doesNotMatch(page, /v-if="isReturnTask && taskTab !== 'carton'"/)
+ assert.match(page, /v-if="isReturnBulk" class="section-gap">先扫描来源/)
+ assert.match(page, /this.activeTarget = value === 'carton' \? 'quick-carton'/)
+ assert.match(page, /this\.taskTab === 'carton' \? 'quick-carton' : 'quick-sku'/)
+})
+
+test('whole carton and full SKU relabel hide new box dimensions; partial SKU keeps isolation form', () => {
+ const value = { isReturnTask: true, current: { items: [{ returnCartonId: '1', cartonMark: 'C1', operationType: 'CARTON_LABEL', returnQuantity: 15 }], history: [] } }
+ assert.equal(component.computed.showReturnPack.call(value), false)
+ value.current.items[0].operationType = 'SKU_LABEL'
+ value.current.items[0].skuItems = [{ skuCode: 'A', quantity: 15 }]
+ assert.equal(component.computed.showReturnPack.call(value), false)
+ value.current.items[0].skuItems[0].quantity = 5
+ assert.equal(component.computed.showReturnPack.call(value), true)
+ assert.match(page, /ReturnRelabelPackWork v-if="showReturnPack"/)
+})
+
+test('one return carton scan saves label stage without a prior source scan; unrelated carton is rejected', async () => {
+ const calls = []
+ const item = { itemId: 'I1', cartonMark: 'C1', operationType: 'CARTON_LABEL', fbaCartonMark: 'FBA1' }
+ const result = { relabelNo: 'RL1', version: 1, items: [{ ...item, relabelledPackages: 1 }] }
+ const subject = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'ReturnRelabelPackWork', 'scanRelabelCarton', 'warehouseIdempotencyKey', 'releaseWarehouseKey', 'uni', body)(
+ {}, {}, {}, {},
+ async (no, payload) => { calls.push({ no, payload }); return result },
+ () => ({ value: 'KEY', signature: 'SIG' }), () => {},
+ { showModal: options => options.complete() }
+ )
+ const value = {
+ busy: false, current: { relabelNo: 'RL1', version: 0, items: [item] }, verifiedReturnCarton: '',
+ run: async (action, work) => work(), applyCurrent(task) { this.current = task },
+ setError(error) { this.error = error.message }
+ }
+ await subject.methods.submitCarton.call(value, ' C1\n')
+ assert.deepEqual(calls, [{ no: 'RL1', payload: { cartonMark: 'C1', expectedVersion: 0, idempotencyKey: 'KEY' } }])
+ assert.equal(value.current.version, 1)
+ assert.equal(value.lastCarton.fbaCartonMark, 'FBA1')
+ await subject.methods.submitCarton.call(value, 'OTHER')
+ assert.equal(calls.length, 1)
+ assert.match(value.error, /不属于本单/)
+})
+test('mixed return carton keeps one physical package while SKU deductions use pieces', () => {
+ const task={items:[{returnCartonId:'1',cartonMark:'P1',operationType:'CARTON_AND_SKU',returnQuantity:5,skuItems:[{skuCode:'S',quantity:5}]}]}
+ assert.deepEqual(returnDeductions(task),[{cartonMark:'P1',skuCode:'S',quantity:5}])
+})
diff --git a/tests/relabel-return-pack.test.mjs b/tests/relabel-return-pack.test.mjs
new file mode 100644
index 0000000..1d3c11c
--- /dev/null
+++ b/tests/relabel-return-pack.test.mjs
@@ -0,0 +1,31 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+const helper = await readFile(new URL('../src/utils/return-relabel.js', import.meta.url), 'utf8')
+const { requiresReturnPack, returnDeductions, returnPackFacts, returnSelections } = await import('data:text/javascript;base64,' + Buffer.from(helper).toString('base64'))
+const source = await readFile(new URL('../src/components/ReturnRelabelPackWork.vue', import.meta.url), 'utf8')
+const body = source.split('')[0].replace(/^\s*import .*$/gm, '').replace('export default', 'return')
+function setup() {
+ const calls = [], events = []
+ const component = new Function('requiresReturnPack','returnDeductions','returnPackFacts','returnSelections','packReturnRelabelCartons','warehouseIdempotencyKey','releaseWarehouseKey',body)(requiresReturnPack,returnDeductions,returnPackFacts,returnSelections,async(no,payload)=>{calls.push(payload);return {relabelNo:no}},()=>({value:'stable',signature:'s'}),()=>{})
+ const item = (mark,done) => ({returnCartonId:mark,cartonMark:mark,operationType:'SKU_LABEL',expectedPackages:1,relabelledPackages:done,skippedPackages:0,exceptionPackages:0,skuItems:[{skuCode:'SKU',quantity:3}]})
+ const value = {...component.data(),task:{relabelNo:'RL',items:[item('A',1),item('B',0)],history:[]},$emit:(...args)=>events.push(args)}
+ for(const[name,fn]of Object.entries(component.methods))value[name]=fn.bind(value)
+ for(const[name,fn]of Object.entries(component.computed))Object.defineProperty(value,name,{get:fn.bind(value)})
+ return {value,calls,events}
+}
+test('packing cannot run with an unfinished source, missing measurement, skipped or exception fact',async()=>{
+ const {value,calls}=setup(); await value.pack();assert.equal(calls.length,0)
+ value.dimensions={length:'20',width:'30',height:'40',weight:'2'}
+ assert.equal(value.ready,false);value.task.items[1].relabelledPackages=1;assert.equal(value.ready,true)
+ value.task.items[0].skippedPackages=1;assert.equal(value.ready,false);value.task.items[0].skippedPackages=0
+ value.task.items[0].exceptionPackages=1;assert.equal(value.ready,false)
+})
+test('pack sends every source with exact SKU piece deductions and generated target; saved history prevents repeat',async()=>{
+ const {value,calls,events}=setup();value.task.items[1].relabelledPackages=1;value.dimensions={length:'20',width:'30',height:'40',weight:'2'}
+ await value.pack()
+ assert.deepEqual(calls,[{sourceCartonMarks:['A','B'],targetCartonMark:null,sourceDeductions:[{cartonMark:'A',skuCode:'SKU',quantity:3},{cartonMark:'B',skuCode:'SKU',quantity:3}],length:20,width:30,height:40,weight:2,idempotencyKey:'stable'}])
+ assert.ok(events.some(row=>row[0]==='saved'))
+ value.task.history=[{action:'RETURN_PACK',detail:JSON.stringify({round:'INITIAL',cartonMark:'NEW',quantity:6,label:{printableHtml:''}})}]
+ assert.equal(value.ready,false);assert.equal(value.facts[0].cartonMark,'NEW')
+})
diff --git a/tests/return-box-merge-work.test.mjs b/tests/return-box-merge-work.test.mjs
index aef8f67..ab64905 100644
--- a/tests/return-box-merge-work.test.mjs
+++ b/tests/return-box-merge-work.test.mjs
@@ -11,37 +11,76 @@ test('box merge component compiles both its script and template', () => {
compileScript(descriptor, { id: 'box-merge' })
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename, id: 'box-merge' }).errors, [])
})
-function instance() {
- const component = vm.runInNewContext(descriptor.script.content.replace(/^import .*$/gm, '').replace('export default', 'component ='), { ScanTargetInput: {}, component: null })
+function instance(runtime = {}) {
+ 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 })
+ 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(){} }
for (const [name, method] of Object.entries(component.methods)) work[name] = method.bind(work)
work.save = () => {}
+ Object.defineProperty(work, 'canComplete', { get: () => component.computed.canComplete.call(work) })
return {work,component}
}
-test('new plan box displays all sources without scanning and keeps independent SKU deductions', async () => {
+test('new plan requires scanning each source and preserves locked SKU quantities', async () => {
const {work,component}=instance(); work.choose('NEW')
- assert.equal(work.sources.length,2)
- assert.equal(work.active,'')
- await work.acceptScan('A')
+ assert.equal(work.sources.length,0)
+ assert.equal(work.active,'source')
+ await work.acceptScan('A'); await work.acceptScan('B')
work.setDeduction('A','SKU1',1); work.setDeduction('A','SKU2',2); work.setDeduction('B','SKU1',2)
work.dimensions={length:10,width:20,height:30,weight:1}
assert.equal(work.sources.length,2)
- assert.equal(work.plannedItems().reduce((sum,item)=>sum+item.quantity,0),5)
+ assert.equal(work.plannedItems().reduce((sum,item)=>sum+item.quantity,0),7)
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', () => {
+ const {work, component} = instance()
+ work.plan = { sourceCartonMarks: ['A', 'B'], sources: [] }
+ assert.deepEqual(component.computed.plannedSourceRows.call(work).map(box => box.cartonMark), ['A', 'B'])
+})
test('existing box uses another box as target and never removes target inventory', async () => {
const {work,component}=instance(); work.choose('EXISTING'); await work.scanSource('A'); await work.scanTarget('B')
assert.equal(work.target.cartonMark,'B'); assert.equal(work.sources.length,1)
work.setDeduction('A','SKU1',1)
- assert.equal(work.plannedItems().length,1)
+ assert.equal(work.plannedItems().length,2)
assert.equal(work.target.items[0].quantity,2)
assert.equal(component.computed.canComplete.call(work),true)
- work.choose('NEW'); assert.equal(work.target,null); assert.equal(work.sources.length,2)
+ work.choose('NEW'); assert.equal(work.target,null); assert.equal(work.sources.length,0)
assert.equal(work.plannedItems().length,0)
})
test('existing source can be replaced before merging, echoing the mark and clearing old deductions and target', async () => {
const {work,component}=instance()
+ work.plan=null
work.choose('EXISTING'); await work.scanSource('A'); await work.scanTarget('B')
work.setDeduction('A','SKU1',1)
assert.equal(work.sourceCode,'A')
@@ -58,6 +97,18 @@ test('existing source can be replaced before merging, echoing the mark and clear
assert.equal(component.computed.canComplete.call(work),true)
})
+test('BOX plan requires every source and accumulates multiple scanned sources', async () => {
+ const {work,component}=instance()
+ work.plan.mode='BOX'
+ work.plan.sources.push({cartonMark:'C',items:[{skuCode:'SKU1',quantity:4}]})
+ work.choose('EXISTING'); await work.scanSource('A'); await work.scanTarget('B')
+ assert.equal(component.computed.canComplete.call(work),false)
+ await work.scanSource('C')
+ assert.equal(work.sources.length,2)
+ assert.equal(component.computed.canComplete.call(work),true)
+ assert.equal(work.plannedItems().reduce((sum,item)=>sum+item.quantity,0),9)
+})
+
test('a directly scanned box survives switching between existing and new targets', async () => {
const {work}=instance()
const box=work.plan.sources[0];work.plan=null;work.initialSource=box
diff --git a/tests/return-counting-page.test.mjs b/tests/return-counting-page.test.mjs
index f00a954..f50cc7d 100644
--- a/tests/return-counting-page.test.mjs
+++ b/tests/return-counting-page.test.mjs
@@ -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 script = source.match(/