diff --git a/AGENTS.md b/AGENTS.md
index 68cc29b..122e731 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -6,12 +6,12 @@
2. 登录/Token/统一请求先读下表登录API/会话、`src/config/runtime.js`、`../../docs/统一认证与服务通信契约.md`。
3. 沿实际 API 读直接服务指引:入仓/库存 `../../baiyue-firstleg/lastleg-service/AGENTS.md`,资源 `../../baiyue-firstleg/warehouse-overseas-service/AGENTS.md`;网关路径/鉴权问题才读 `../../baiyue-firstleg/gateway/AGENTS.md`。跨模块或入口不明才读 `../../docs/海外仓业务/README.md`。
-仅读适用指引/目标页面/API;纯页面不读后端,跨服务状态/鉴权不明才扩读。遵根预算,先规则后命中段,不全文读海外仓 README。
+按根预算先规则后命中,不通读海外仓 README;纯页面不读后端,跨服务状态/鉴权不明才扩读。样式读[规范](../../docs/海外PDA界面样式规范.md)。
## 项目定位
-- “柏悦物流海外版”面向海外仓现场人员,uni-app Vue 3 独立安装/构建/发布,不依赖 `apps/pda-uniapp/` 或仓库外 `pda-shared`。
-- 业务/任务完成见下表;派送/客户回访归 Admin/Client,国内集装箱拣货/装柜归国内 PDA。
+- “柏悦物流海外版”:海外仓现场 uni-app Vue 3 独立应用,不依赖国内 PDA 或仓外 `pda-shared`。
+- 派送/客户回访归 Admin/Client,国内集装箱装柜归国内 PDA;其他作业见下表。
## 服务与发布边界
@@ -21,14 +21,14 @@
- 并发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 混用。
+- Android仅在 `APP-PLUS` 调 `src/utils/app-update.js`,用独立 `VITE_PDA_UPDATE_URL` 查HTTPS APK/WGT;强更禁跳过,WGT安装即重启,不得与国内 PDA 混用。发布先装APK再发兼容WGT;原生变化先升APK。步骤及版本见 `README.md`“App 在线更新”。
- 源码根 `src/`;扫码广播 Action=`android.intent.action.SCANRESULT`,Extra=`value`。
-- 均接真实PDA API;入仓账号锁柜、逐码核平;退货号/SKU候选进入,按本包实收生成退货箱/计费批次。换标含收货/执行/复核;盘点页面/API保留、工作台隐藏。
+- 退货号/SKU候选进入,按本包实收生成退货箱/计费批次;其他作业范围见下表及对应接口边界。
## 页面与能力快速索引
-路径相对应用根;[业务索引](docs/business-index.md)存API/字段/状态/恢复。应用根 `rg -n -F '任务词或文件名' docs/business-index.md`;涉及业务行为变更时必须核对对应细则及最近指引,定位不全文读。
+路径相对应用根;[业务索引](docs/business-index.md)存 API/字段/状态/恢复;涉及业务行为变更时必须核对对应细则,只读命中段,纯展示不读。
| 目标 | 首个入口 |
|---|---|
@@ -36,10 +36,11 @@
| AppID、平台图标与打包资源 | `src/manifest.json`、`static/app-icons/` |
| Android APK/WGT 在线更新 | `src/App.vue`、`src/utils/app-update.js`、`.env.example` |
| 登录、刷新与会话 | `src/pages/login/login.vue`、`src/api/auth.js`、`src/stores/session.js` |
-| 海外仓工作台与作业入口 | `src/pages/home/home.vue`;首页直接进入各作业,页面刷新后返回首页 |
+| 海外仓工作台与待办统计 | `src/pages/home/home.vue`、`src/api/workbench.js` |
| 中英西语言设置 | `src/i18n/index.js`、`src/i18n/locales/`、`src/pages/profile/profile.vue` |
-| 柜号签收、照片与入仓真实作业 | `src/pages/operation/operation.vue`、`src/components/ReceivingSessionSummary.vue`、`src/api/inbound.js` |
+| 柜号签收、私人地址收货与照片 | `src/pages/operation/operation.vue`、`src/components/ReceivingSessionSummary.vue`、`src/api/inbound.js` |
| 退货入库签收与合箱 | `src/pages/return-receipt/return-receipt.vue`、`src/pages/return-packing/return-packing.vue`、`src/api/return-receipt.js` |
+| 派送异常回仓签收 | `src/pages/return-receipt/return-receipt.vue` → `src/components/ExceptionReturnWork.vue`、`src/api/exception-returns.js` |
| 退货箱内清点 | `src/pages/return-counting/return-counting.vue`、`src/api/return-counting.js` |
| 仓储转运测量、打托与标签 | `src/pages/warehouse-transfer/warehouse-transfer.vue`、`src/api/warehouse-transfers.js` |
| 托盘管理真实作业 | `src/pages/pallet/pallet.vue`、`src/api/pallet.js` |
@@ -86,10 +87,10 @@
## 质量门禁
-- 已发车详情按需挂载 `src/components/DispatchHandoverStatus.vue`,经 `src/api/trip-handover.js` 读当前派送资源/最近交接;进入/刷新各一次GET,列表不逐行请求。只读;后台确认交接,已完成派送留完成时资源。回归 `tests/trip-handover.test.mjs`。
+- 发车后 `src/components/DispatchHandoverStatus.vue` 经 `src/api/trip-handover.js` 只读资源/交接;进入和刷新各一 GET,列表不逐行请求。交接由后台确认,完成后保留当时资源;回归 `tests/trip-handover.test.mjs`。
-- 验证按 `../../docs/AI工程执行与协作约束.md`;文案看差异,小逻辑定向测试,不默认全量/构建/真机验收。`npm test` 为全量入口。
-- 专项用 `node --test`:转运测量/打托/照片/标签行为变更选 `tests/warehouse-transfer-api.test.mjs`;退货签收/合箱行为变更选对应 `tests/return-receipt-api.test.mjs` / `tests/return-packing-api.test.mjs`;项目结构/工作台入口变化才加 `tests/project-contract.test.mjs` / `tests/workbench-availability.test.mjs`。离线不替代真实API、真机扫码/绑定/打印。
+- 分层验证见 `../../docs/AI工程执行与协作约束.md`;`npm test` 为全量,不默认跑。
+- 按行为用 `node --test`:转运选 `tests/warehouse-transfer-api.test.mjs`,退货签收/合箱选 `tests/return-receipt-api.test.mjs` / `tests/return-packing-api.test.mjs`;结构/工作台入口变更才加 `tests/project-contract.test.mjs` / `tests/workbench-availability.test.mjs`。离线不替代真实 API/扫码/绑定/打印。
- H5编译链 `vue-i18n` 固定 `9.1.9`,禁范围版本/单独升级;依赖变更同步 `package.json`、`package-lock.json`、现有 `pnpm-lock.yaml`,至少跑H5编译。
- 产品为海外PDA App;`npm run build:h5` 仅浏览器调试/模板门禁,App资源用 `npm run build:app`。APK/IPA须在HBuilderX/正式原生打包环境以独立AppID/签名验证,资源编译非APK真机验收。
- 改 `src/manifest.json` 图标映射或 `static/app-icons/` 跑 `node --test tests/app-icons.test.mjs`;禁缺声明尺寸、同一错尺寸占位、复制国内AppID/签名。
diff --git a/README.md b/README.md
index 0829686..315f4b9 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,8 @@ npm run build:app
Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查海外 PDA 的独立更新通道,员工也可在“个人中心 → 检查更新”手动触发;H5 仅展示 App 操作提示,不执行更新。更新服务接收 `appid`、`platform`、`appVersion`、`appVersionCode`、`wgtVersion` 查询参数,无更新返回 `{ "update": false }`,有更新返回:
+HBuilderX 真机调试及标准基座使用当前工程 `src/manifest.json` 中的 AppID、版本名和版本码模拟更新检查,避免把 HBuilderX 基座版本作为业务版本上报;正式 APK 仍使用实际安装包版本码及独立的 WGT 资源版本。调试检查同样只读取后台已发布版本,上传草稿不会触发更新;调试基座不能代替正式 APK 的覆盖安装与签名验收。
+
```json
{
"update": true,
@@ -70,10 +72,11 @@ Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查海外 PDA 的独
`packageType` 只允许 `apk` 或 `wgt`,`appid` 必须与当前安装包一致。WGT 清单还须提供 `minAppVersionCode`(最低兼容 APK 整数版本),不满足时客户端拒绝安装并提示升级 APK。`mandatory=true` 时员工不能跳过,并在下载或安装失败后提供重试;wgt 安装完成会立即重启,APK 交由 Android 安装器完成覆盖安装。正式发布要求:
-1. 海外 PDA 每次整包发布同时递增 `src/manifest.json` 的 `versionName` 与整数 `versionCode`,使用海外独立 AppID、包名和签名打包 APK。
-2. wgt 发布至少递增 `versionName`,并使用与已安装原生包兼容的 uni-app 编译器生成;新增原生模块、权限或不兼容运行时能力时必须发布 APK,不能只发 wgt。
-3. 更新清单与下载地址使用 HTTPS,先在测试设备验证可选更新、强制更新、断网重试、APK 覆盖安装、wgt 重启及扫码作业回归,再切换正式清单。
-4. 海外和国内更新记录、文件目录及发布权限保持隔离,服务端必须按请求中的 `appid` 拒绝跨应用更新包。
+1. 发布顺序固定为“先安装 APK 基线,再发布后续 WGT 热更新”。首次安装先使用海外独立 AppID、包名和签名打包 APK,在目标 PDA 安装后确认登录、扫码、拍照和“个人中心 → 检查更新”正常,再以该 APK 作为后续热更新基线。
+2. 后续兼容的页面、交互和业务逻辑修改发布 WGT。递增 `src/manifest.json` 的 `versionName`,使其高于设备当前的 `wgtVersion`,沿用 APK 基线的整数 `versionCode`,并使用与基线 APK 兼容的 uni-app 编译器生成。更新记录的 `minAppVersionCode` 填该 WGT 要求的 APK 基线版本码;设备低于基线时先安装新版 APK。
+3. 例如 APK 基线为 `0.0.5(versionCode=5)`,下一次 WGT 可发布 `0.0.6`,`minAppVersionCode=5`。安装基线 APK 后首次检查同版本资源会判定为最新版本;后续 WGT 使用更高的资源版本号。
+4. 新增原生模块、权限或不兼容运行时能力时,先同时递增 `versionName` 与整数 `versionCode` 并发布、安装新版 APK,完成真机验证后再基于新 APK 发布后续 WGT。
+5. 更新清单与下载地址使用 HTTPS。每次发布先在已安装对应 APK 基线的测试设备验证实际升级路径、断网重试、WGT 安装重启及本次受影响作业,再切换正式清单;新 APK 基线同时验证覆盖安装和签名。海外和国内更新记录、文件目录及发布权限保持隔离,服务端必须按请求中的 `appid` 拒绝跨应用更新包。
## H5 静态托管(TEST 与灰度)
diff --git a/docs/business-index.md b/docs/business-index.md
index 40b9e67..f4ec812 100644
--- a/docs/business-index.md
+++ b/docs/business-index.md
@@ -25,7 +25,7 @@
| 商品移库真实作业 | `src/pages/move/move.vue`、`src/api/warehouse-operations.js`、`src/stores/warehouse-work.js` | 扫描来源托盘与目标库位执行同仓移库;冲突后重新读取服务端事实 |
| 换标真实作业 | `src/pages/relabel/relabel.vue`、`src/api/relabel.js` | 同一页面按操作参数承载换标收货、执行与复核;执行页按当前单明细切换 SKU/箱唛 Tab,组合明细同时显示两类需求。SKU 逐条扫码旧新SKU并携带审批件数,箱唛扫码仓库唛头/新FBA箱唛;组合单按同箱保留两个Tab的核对输入,全部核对后只提交一次箱事实。认领同步处理中;支持拍照、异常/返工和幂等恢复 |
| 现场查询真实作业 | `src/pages/query/query.vue`、`src/api/warehouse-operations.js` | 同一页面按操作参数承载库存、库位与托盘查询,只展示稳定业务标识 |
-| 排车拣货与发车/理货审核 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 普通进入展示任务列表和“上次作业”,不因本地草稿自动打开详情;明确跳转 `?resume=1` 才恢复排车。默认任务列表含待理货排车,详情展示服务端车辆/司机快照、计划发车及预计送达时间、当前有效FBA收货确认、调度审核状态/意见和货物;时间明确标注北京时间。预约或审核依据失效时 `APPROVAL_BLOCKED` 禁止批准但保留驳回。仅 `actions.LOADING_REVIEW.allowed=true` 时可调用 `POST /api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-review`,提交原 `recordVersion/idempotencyKey/decision/reason`;驳回原因必填,任一级拒绝统一回客服处理(车辆信息),重新提交后再次逐级审核。PDA 不执行调度审核;理货通过并重读资格后才允许按实物托盘码或来源箱唛真实拣货,服务端 `actions.PICK/LOADING_COMPLETE` 分别决定真实拣货和装车完成资格;按已扫托数、箱数整车一次确认装车,不逐托填重量、体积或托位,资料未齐进入 `AFTER_LOADING`、已齐直接进入 `DELIVERING`;详情采用“拣货作业/装车拍照”两个 Tab,打开任务默认拣货;切换保留拣货草稿与照片状态,拍照页不响应拣货扫码,同单照片保存刷新保留当前 Tab。装车中即可在拍照 Tab 通过 `src/components/DispatchLoadingDocuments.vue` 上传五类整车照片(司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字,每类1至5张),照片齐全且确认装车完成后由服务端在最后一项完成的事务自动生成派送并发车;不额外安排派送或确认发车。公网前缀 `/api/pda/v1/last-leg/dispatches/**`。回归 `tests/dispatch-workflow.test.mjs tests/fulfillment-workflows.test.mjs` |
+| 排车拣货与发车/理货审核 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 普通进入展示任务列表和“上次作业”,不因本地草稿自动打开详情;明确跳转 `?resume=1` 才恢复排车。默认任务列表含待理货排车,详情展示服务端车辆/司机快照、计划发车及预计送达时间、当前有效FBA收货确认、调度审核状态/意见和货物;时间同时展示美东(America/New_York)与美西(America/Los_Angeles),按地区时区处理夏令时;只换算带时区的真实时间点,缺失时不补造时间。预约或审核依据失效时 `APPROVAL_BLOCKED` 禁止批准但保留驳回。仅 `actions.LOADING_REVIEW.allowed=true` 时可调用 `POST /api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-review`,提交原 `recordVersion/idempotencyKey/decision/reason`;驳回原因必填,任一级拒绝统一回客服处理(车辆信息),重新提交后再次逐级审核。PDA 不执行调度审核;理货通过并重读资格后才允许按实物托盘码或来源箱唛真实拣货,服务端 `actions.PICK/LOADING_COMPLETE` 分别决定真实拣货和装车完成资格;按已扫托数、箱数整车一次确认装车,不逐托填重量、体积或托位,资料未齐进入 `AFTER_LOADING`、已齐直接进入 `DELIVERING`;详情采用“拣货作业/装车拍照”两个 Tab,打开任务默认拣货;切换保留拣货草稿与照片状态,拍照页不响应拣货扫码,同单照片保存刷新保留当前 Tab。装车中即可在拍照 Tab 通过 `src/components/DispatchLoadingDocuments.vue` 上传五类整车照片(司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字,每类1至5张),照片齐全且确认装车完成后由服务端在最后一项完成的事务自动生成派送并发车;不额外安排派送或确认发车。公网前缀 `/api/pda/v1/last-leg/dispatches/**`。回归 `tests/dispatch-workflow.test.mjs tests/fulfillment-workflows.test.mjs` |
| 装车确认、自动派送与证照提醒 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js` 的 `completeDispatchLoading` | 一单一趟,使用 `dispatchNo`、公开 `recordVersion`(0 合法)及本次 `idempotencyKey`。只在请求中锁定,结束即释放;保留表单,不持久化或重放失败命令,下次点击按当前输入和版本生成新键。结果不明或 409 先刷新权威任务,不以超时推断未执行或成功。结果核对单号、动作、请求键和版本;理货审核通过须 `stage=LOADING`,驳回须 `stage=BEFORE_LOADING`;`LOADING_COMPLETE` 返回 `stage=AFTER_LOADING|DELIVERING`、Loaded 结果,提交版本、manifestHash 和幂等键,不逐托填测量。成功后重读任务,版本不得早于已确认结果;重读失败只刷新,不自动重复 POST。司机证照、资源及各客户派送资格由服务端校验;正式流程见工作区 `docs/海外仓业务/排车列表统一作业改造契约.md`,不使用独立车次发车请求 |
| 普通/一件代发出库 | `src/pages/outbound/outbound.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 首次进入清空扫描框并展示任务列表和“上次作业”标记,不自动恢复旧详情;详情提供独立返回列表入口。逐箱扫码、DP 托盘整托拣取及幂等完成保持不变;扫描排车业务号显式跳转排车 `?resume=1`。公网前缀 `/api/pda/v1/last-leg/outbound-tasks/**`;派送由 Admin/Client 承载 |
| 扫码设备与诊断 | `src/utils/scan-broadcast.js`、`src/pages/scanner-test/scanner-test.vue` | `src/components/ScanTargetInput.vue`、声音反馈工具;广播 Action/Extra 见本文件服务边界 |
@@ -62,4 +62,6 @@
- 柜号签收入仓调用 Lastleg `/api/pda/v1/last-leg/receiving-containers/**`;退货签收与合箱分别调用 `/api/pda/v1/last-leg/return-receipts/**`、`/api/pda/v1/last-leg/return-inventory/**`,具体入口见上表。浏览器或设备请求必须经统一 `gateway` 的显式 PDA 路由,不得直连 Lastleg。
- 海外业务 Token、PDA 权限和可信身份最终由 Gateway 与服务端校验;前端不得用工作台 `available`、本地客户 ID 或仓库 ID 代替授权和资源归属校验。
- 排车和出库入口只调用专用 PDA 接口;客户与绑定海外仓由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不展示内部数据库 ID 作为业务编号。动作资格、版本和失败处理见本文件排车条目,装车中可同步上传五类照片,每类1至5张,装车确认与资料齐全两项均完成后服务端自动生成派送并发车,装完但资料未齐时留在装车后;照片API为 `/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents`,前端不补派送费用。完成成功后不得重复提交;PDA 不直接创建派送单,平台与客户的读取范围遵循现行契约。
-- 生产仓租功能尚未配套发布,当前 main 的排车拣货、普通出库、整托出库和废弃确认直接调用既有库存命令,不请求 `/storage-rent/preparations`。保留原始货源参数、箱数、版本和 `idempotencyKey`;实际库存接口的业务失败与网络错误照常上报,不吞掉 404、不自动重试或伪造成功。未来启用仓租须以后端接口、数据库及资金链路配套上线为前提。定向回归为 `tests/fulfillment-stock-commands.test.mjs`,覆盖四类命令单次提交、参数保留、版本/防重校验与失败传播;离线测试不代替生产实货验收。
+- 排车拣货在提交库存扣减前调用 `/storage-rent/preparations` 完成仓租预校验和资金预占,预占回执未就绪或失败时不提交拣货;成功后仍只提交原排车库存命令,保留货源参数、箱数、版本和 `idempotencyKey`。预校验失败会释放未消费预占并保留错误诊断,不自动重放拣货。普通出库、整托出库和废弃确认沿各自现有流程;定向回归为 `tests/fulfillment-stock-commands.test.mjs`,覆盖排车仓租预校验、库存命令参数和失败传播。
+
+- 废弃出库展示客户废弃备注。无关联箱唛库存扫描本单托盘后扫描现场箱唛,自动去重计数;多SKU先选择本次扫描货物,不手填实际数。每条 lines.scannedCartonMarks 与 actualPackages 一并提交,失败留箱唛/照片供刷新重试;后台保存实际扫描清单并回显废弃详情。有箱唛关联任务仍核对本单所选箱唛。
diff --git a/src/api/dispatch-loading-documents.js b/src/api/dispatch-loading-documents.js
index 1894179..3c9757e 100644
--- a/src/api/dispatch-loading-documents.js
+++ b/src/api/dispatch-loading-documents.js
@@ -13,7 +13,7 @@ function slotPath(no, slot) {
return `${path(no)}/${slot}`
}
export function normalizeLoadingDocuments(value, no) {
- if (value?.dispatchNo !== no || !/^\d+$/.test(String(value.recordVersion ?? '')) || !Array.isArray(value.photos)
+ if (value?.dispatchNo !== no || !/^\d+$/.test(String(value.recordVersion ?? '')) || !/^[1-9]\d*$/.test(String(value.reviewRound ?? '')) || !Array.isArray(value.photos)
|| !['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(value.stage)) throw new Error('装车资料与当前排车事实不一致')
const files = new Set(), counts = {}
for (const photo of value.photos) {
@@ -24,6 +24,10 @@ export function normalizeLoadingDocuments(value, no) {
return { ...value, editable: value.editable === true, complete: value.complete === true }
}
export async function getLoadingDocuments(no) { return normalizeLoadingDocuments(await overseasRequest('GET', path(no)), no) }
-export function uploadLoadingPhoto(no, slot, filePath, key) { return overseasUpload(`${slotPath(no, slot)}/uploads?idempotencyKey=${encodeURIComponent(key)}`, filePath) }
+export function uploadLoadingPhoto(no, slot, filePath, key, reviewRound) { return overseasUpload(`${slotPath(no, slot)}/uploads?idempotencyKey=${encodeURIComponent(key)}&reviewRound=${encodeURIComponent(reviewRound)}`, filePath) }
export async function bindLoadingPhoto(no, slot, body) { return normalizeLoadingDocuments(await overseasRequest('PUT', slotPath(no, slot), body), no) }
-export function readLoadingPhoto(no, photo) { return overseasDownload(`${slotPath(no, photo.slot)}/files/${encodeURIComponent(photo.fileId)}/content`, { expectedContentTypePrefix: 'image/' }) }
+export function readLoadingPhoto(no, photo) {
+ const pdf = photo.slot === 'LOADING_SCENE' && photo.contentType === 'application/pdf'
+ if (!pdf && !['image/jpeg', 'image/png'].includes(photo.contentType)) throw new Error('装车资料格式无效')
+ return overseasDownload(`${slotPath(no, photo.slot)}/files/${encodeURIComponent(photo.fileId)}/content`, { expectedContentTypePrefix: pdf ? 'application/pdf' : photo.contentType })
+}
diff --git a/src/api/exception-returns.js b/src/api/exception-returns.js
new file mode 100644
index 0000000..f2732ea
--- /dev/null
+++ b/src/api/exception-returns.js
@@ -0,0 +1,7 @@
+import { overseasRequest, overseasUpload } from './overseas.js'
+const base = '/pda/v1/last-leg/return-receipts/exceptions'
+const path = (number) => `${base}/${encodeURIComponent(number)}`
+export const listExceptionReturns = () => overseasRequest('GET', base)
+export const startExceptionReturn = (number) => overseasRequest('POST', `${path(number)}/start`, {})
+export const uploadExceptionReturnEvidence = (number, filePath) => overseasUpload(`${path(number)}/evidence`, filePath)
+export const receiveExceptionReturn = (number, body) => overseasRequest('POST', `${path(number)}/receive`, body)
diff --git a/src/api/fulfillment.js b/src/api/fulfillment.js
index 78eadf9..347d4eb 100644
--- a/src/api/fulfillment.js
+++ b/src/api/fulfillment.js
@@ -97,7 +97,7 @@ function dispatchWrite(path, request) {
requireDispatchVersion(request?.recordVersion)
return overseasRequest('POST', path, request)
}
-// 生产仓租接口尚未配套发布,直接提交既有库存命令,仍保留原请求的防重标识。
+// 库存命令与仓租预校验共用本次请求的防重标识。
function requireStockCommandIdentity(request) {
if (!request?.idempotencyKey) throw new Error('出库请求标识缺失,请重新进入任务')
}
@@ -105,8 +105,19 @@ function requireStockCommandIdentity(request) {
export async function pickDispatchItem(dispatchNo, dispatchItemId, request) {
requireDispatchVersion(request?.recordVersion)
requireStockCommandIdentity(request)
+ await prepareStockRent({
+ mode: 'DISPATCH',
+ businessNo: dispatchNo,
+ itemId: dispatchItemId,
+ positionId: request.positionId,
+ packages: request.packages,
+ cartonMark: request.cartonMark || null,
+ idempotencyKey: request.idempotencyKey,
+ productQuantity: request.productQuantity || 0
+ })
return dispatchWrite(`${DISPATCH_BASE}/${encoded(dispatchNo)}/items/${encoded(dispatchItemId)}/pick`, request)
}
+
export function completeDispatchLoading(dispatchNo, request) {
return dispatchWrite(`${DISPATCH_BASE}/${encoded(dispatchNo)}/loading-complete`, request)
}
@@ -157,7 +168,10 @@ export function normalizeCargoDiscardTask(value) {
const task = value || {}
return {
...task,
- discardNo: String(task?.discardNo || ''),
+ remark: String(task?.remark || ''),
+ requestedPackages: task?.requestedPackages == null ? null : count(task.requestedPackages),
+ remainingPackages: task?.remainingPackages == null ? null : count(task.remainingPackages),
+ discardNo: String(task?.discardNo || ''),
outboundNo: String(task?.outboundNo || ''),
palletNo: String(task?.palletNo || ''),
scanMode: task?.scanMode === 'CARTON' ? 'CARTON' : 'PALLET',
@@ -172,6 +186,7 @@ export function normalizeCargoDiscardTask(value) {
plannedPackages: count(sku?.plannedPackages),
actualPackages: sku?.actualPackages == null ? null : count(sku.actualPackages),
actualQuantityEditable: sku?.actualQuantityEditable === true,
+ allowedCartonMarks: asList(sku?.allowedCartonMarks).map(String),
status: String(sku?.status || 'PENDING')
}))
}
@@ -188,9 +203,36 @@ export async function resolveCargoDiscardPallet(palletNo, discardNo) {
export async function confirmCargoDiscard(discardNo, palletNo, lines, photoFileIds, scannedCartonMarks = [], confirmAllCartons = false, request = {}) {
const body = { lines, photoFileIds, scannedCartonMarks, confirmAllCartons, idempotencyKey: request.idempotencyKey }
requireStockCommandIdentity(request)
+ // 出库前按同一份货物、照片和幂等键校验仓租,资金就绪后才提交库存扣减。
+ await prepareStockRent({ mode: 'DISCARD', businessNo: discardNo, palletNo, ...body })
return normalizeCargoDiscardTask(await overseasRequest('POST', `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/confirm`, body))
}
+/** 本次点击内只查询资金回执,不缓存或自动重放失败的出库命令。 */
+async function prepareStockRent(body) {
+ const base = '/pda/v1/last-leg/storage-rent/preparations'
+ let preparationId
+ try {
+ let preparation = await overseasRequest('POST', base, body)
+ preparationId = preparation?.preparationId
+ for (let attempt = 0; preparation?.ready !== true && preparation?.status === 'FREEZE_PENDING' && preparationId && attempt < 20; attempt++) {
+ await new Promise(resolve => setTimeout(resolve, 500))
+ preparation = await overseasRequest('GET', `${base}/${encoded(preparationId)}`)
+ }
+ if (preparation?.ready !== true) {
+ const message = preparation?.message ? `仓租校验未通过:${preparation.message}` : '仓租资金预校验尚未通过,请稍后重新提交'
+ throw Object.assign(new Error(message), { code: 'A0409' })
+ }
+ } catch (error) {
+ // 尚未发送出库命令时释放已知的未使用预占,释放失败保留原错误诊断。
+ if (preparationId) {
+ try { await overseasRequest('DELETE', `${base}/${encoded(preparationId)}`) }
+ catch (releaseError) { console.error('[Overseas PDA stock] rent preparation release failed', { code: releaseError?.code, requestId: releaseError?.requestId, traceId: releaseError?.traceId }) }
+ }
+ throw error
+ }
+}
+
const discardPhotoPath = (discardNo, palletNo) => `${CARGO_DISCARD_BASE}/${encoded(discardNo)}/pallets/${encoded(palletNo)}/photos/files`
export const listCargoDiscardPhotos = (discardNo, palletNo) => overseasRequest('GET', discardPhotoPath(discardNo, palletNo))
export const uploadCargoDiscardPhoto = (discardNo, palletNo, filePath) => overseasUpload(discardPhotoPath(discardNo, palletNo), filePath)
diff --git a/src/api/receiving-adapter.js b/src/api/receiving-adapter.js
index 629e429..e7e0925 100644
--- a/src/api/receiving-adapter.js
+++ b/src/api/receiving-adapter.js
@@ -115,7 +115,10 @@ export function isReceivingConflict(error) {
export function toReceivingError(error, fallback = '海外仓作业失败') {
const code = error?.code
const rawMessage = String(error?.message || '').trim()
- const palletConflictMessages = {
+ const receivingMessages = {
+ 'Carton mark must contain a non-empty inbound code prefix': '请扫描箱码或国内DP码',
+ 'Pallet code is not part of the current FBA destination group': '托盘不属于当前目的仓,请重扫',
+ 'Pallet code is not reserved for this inbound code': '该托盘未预留给当前入仓码,请扫描本票已预留托盘',
'Reserved pallet binding is incomplete': '预留托盘资料不完整,请联系管理员检查入仓计划',
'Reserved pallet customer binding is invalid': '预留托盘与当前客户不匹配,请联系管理员检查入仓计划',
'Scanned pallet does not match the reserved inbound plan resource': '扫描托盘与入仓计划预留资源不一致,请扫描已预留的托盘',
@@ -131,13 +134,13 @@ export function toReceivingError(error, fallback = '海外仓作业失败') {
'Pallet FBA destination does not match the inbound code': '该托盘的FBA目的仓与当前入仓码不一致,请更换托盘',
'FBA-designated pallet cannot receive an OVERSEAS destination carton': 'FBA专用托盘不能接收海外仓目的箱,请更换托盘'
}
- const conflictMessage = ['409', 'A0409', 'B0409'].includes(String(code || ''))
- ? palletConflictMessages[rawMessage]
+ const businessMessage = ['404', 'A0404', 'B0404', '409', 'A0409', 'B0409'].includes(String(code || ''))
+ ? receivingMessages[rawMessage]
: ''
const palletUnavailable = String(code || '') === 'S0503'
&& rawMessage === 'Warehouse pallet service returned no usable response'
const mapped = Object.assign(new Error(
- conflictMessage || (palletUnavailable
+ businessMessage || (palletUnavailable
? '该托盘当前不可用于收货,请更换可用托盘或联系管理员检查托盘状态'
: rawMessage || fallback)
), {
diff --git a/src/api/workbench.js b/src/api/workbench.js
new file mode 100644
index 0000000..43afa2e
--- /dev/null
+++ b/src/api/workbench.js
@@ -0,0 +1,70 @@
+import { listReceivingCandidates } from './inbound.js'
+import { assertReceivingWarehouse } from './receiving-adapter.js'
+import { listWarehouseTransferCandidates } from './warehouse-transfers.js'
+import { listReturnCountTasks } from './return-counting.js'
+import { listPackingPlans } from './return-packing.js'
+import { listDispatchTasks, listOutboundTasks, listCargoDiscardTasks } from './fulfillment.js'
+import { listRelabelCandidates } from './relabel.js'
+
+export const WORKBENCH_COUNT_KEYS = ['receiving', 'warehouseTransferList', 'warehouseTransferWork', 'returnCounting', 'returnPacking', 'dispatchPicking', 'outboundPicking', 'relabelReceive', 'relabelExecute', 'relabelReview']
+
+const ready = (count, unit = '单') => ({ status: 'ready', count, unit })
+const distinctCount = (rows, key) => {
+ if (!Array.isArray(rows)) throw new Error('待办列表返回结构无效')
+ const codes = rows.map((row) => String(row?.[key] || '').trim())
+ if (codes.some((code) => !code)) throw new Error('待办列表缺少业务编号')
+ return new Set(codes).size
+}
+
+export async function loadWorkbenchCounts({ profile, activeTransferNo = '' }) {
+ const sources = [
+ { keys: ['receiving'], load: async () => {
+ const rows = await listReceivingCandidates()
+ rows.forEach((row) => assertReceivingWarehouse(row, profile, '待收货柜'))
+ if (rows.some((row) => !row.sourceContainerBookingId || !row.containerNo)) throw new Error('待收货柜缺少业务定位信息')
+ const keys = rows.map((row) => ({ key: `${row.sourceContainerBookingId}:${row.containerNo}` }))
+ return { receiving: ready(distinctCount(keys, 'key'), '柜') }
+ } },
+ { keys: ['warehouseTransferList', 'warehouseTransferWork'], load: async () => {
+ const rows = (await listWarehouseTransferCandidates()).filter((row) => ['WAIT_PDA_RECEIVING', 'PARTIALLY_RECEIVED'].includes(row.status))
+ const active = rows.filter((row) => String(row.transferNo).trim() === String(activeTransferNo).trim())
+ return { warehouseTransferList: ready(distinctCount(rows, 'transferNo')), warehouseTransferWork: ready(distinctCount(active, 'transferNo'), '单进行中') }
+ } },
+ { keys: ['returnCounting'], load: async () => {
+ const value = await listReturnCountTasks({ page: 1, size: 1 })
+ const total = value?.total
+ if ((typeof total !== 'number' && !(typeof total === 'string' && /^\d+$/.test(total))) || !Number.isSafeInteger(Number(total)) || Number(total) < 0) throw new Error('退货清点待办总数无效')
+ return { returnCounting: ready(Number(total), '托') }
+ } },
+ { keys: ['returnPacking'], load: async () => ({ returnPacking: ready(distinctCount((await listPackingPlans()).filter((row) => row.status === 'READY'), 'planNo'), '单计划') }) },
+ { keys: ['dispatchPicking'], load: async () => {
+ const rows = (await listDispatchTasks()).filter((row) => row.status !== 'CANCELLED' && ['BEFORE_LOADING', 'LOADING', 'AFTER_LOADING'].includes(row.stage))
+ return { dispatchPicking: ready(distinctCount(rows, 'dispatchNo'), '单未发车') }
+ } },
+ { keys: ['outboundPicking'], load: async () => {
+ const [outbounds, discards] = await Promise.all([listOutboundTasks('PENDING'), listCargoDiscardTasks()])
+ return { outboundPicking: { ...ready(distinctCount(outbounds, 'taskNo')), discardCount: distinctCount(discards.filter((row) => row.status !== 'COMPLETED'), 'discardNo') } }
+ } },
+ { keys: ['relabelReceive', 'relabelExecute', 'relabelReview'], load: async () => {
+ const rows = await listRelabelCandidates()
+ return {
+ relabelReceive: ready(distinctCount(rows.filter((row) => !row.taskStatus || row.taskStatus === 'AVAILABLE'), 'relabelNo'), '单待接'),
+ relabelExecute: ready(distinctCount(rows.filter((row) => row.taskStatus === 'PROCESSING' && row.claimedByCurrentOperator === true), 'relabelNo'), '单进行中'),
+ relabelReview: ready(distinctCount(rows.filter((row) => row.taskStatus === 'WAIT_REVIEW'), 'relabelNo'), '单待复核')
+ }
+ } }
+ ]
+ const results = await Promise.allSettled(sources.map((source) => source.load()))
+ return Object.assign({}, ...results.map((result, index) => result.status === 'fulfilled' ? result.value : Object.fromEntries(sources[index].keys.map((key) => [key, { status: 'error', error: result.reason }]))))
+}
+
+export function workbenchBadge(key, value) {
+ if (!WORKBENCH_COUNT_KEYS.includes(key)) return { label: key.endsWith('Query') ? '扫码查询' : key === 'returnReceipt' ? '扫码签收' : '扫码作业', tone: 'is-static' }
+ if (!value) return { label: '加载中…', tone: 'is-loading' }
+ if (value.status === 'error') return { label: '获取失败', tone: 'is-error' }
+ const hasWork = value.count > 0 || value.discardCount > 0
+ let label = `${value.count} ${value.unit}`
+ if (key === 'warehouseTransferWork' && value.count === 0) label = '未开始'
+ if (key === 'outboundPicking' && value.discardCount > 0) label = `${value.count} 出库 · ${value.discardCount} 废弃`
+ return { label, tone: hasWork ? 'has-work' : 'is-empty' }
+}
diff --git a/src/components/DispatchLoadingDocuments.vue b/src/components/DispatchLoadingDocuments.vue
index 464b612..1160a5c 100644
--- a/src/components/DispatchLoadingDocuments.vue
+++ b/src/components/DispatchLoadingDocuments.vue
@@ -3,9 +3,8 @@
装车照片
{{ busy ? '同步中' : snapshot ? `${completedCategories}/5 类已保存` : '待同步' }}
-
+
- 每类至少 1 张、最多 5 张,上传后自动保存,可逐张更换。
保存状态尚未核实,请先刷新资料后再上传。
{{ error.message }}requestId:{{ error.requestId }}traceId:{{ error.traceId }}
@@ -19,7 +18,8 @@
-
+
+
查看大图
@@ -48,6 +48,7 @@ export default {
key() { return `loading-${Date.now()}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}` },
async previews() {
await Promise.allSettled((this.snapshot?.photos || []).map(async photo => {
+ if (photo.contentType === 'application/pdf') return
if (this.images[photo.fileId]) return
const image = await readLoadingPhoto(this.number, photo); this.ensureScope(); this.images[photo.fileId] = image
}))
@@ -58,8 +59,10 @@ export default {
this.snapshot = value; this.needsRefresh = false; await this.previews()
},
async load() { await this.run(() => this.refreshFacts()) },
+ async refresh() { await this.run(async () => { await this.refreshFacts(); return true }, true) },
async choose(slot, replaceFileId = null) {
if (this.busy || !this.editable) return
+ const reviewRound = this.snapshot.reviewRound
const capacity = replaceFileId ? 1 : this.limit - this.photosFor(slot).length
if (capacity <= 0) return
let saved = 0, reconciled = false
@@ -70,12 +73,14 @@ export default {
if (chosen.tempFiles?.some(file => file.size > 10 * 1024 * 1024)) throw new Error('请选择不超过10MiB的图片')
// 逐张以最新版本绑定;部分失败保留前面已保存照片,不重放失败上传。
for (const filePath of paths) {
- const photo = await uploadLoadingPhoto(this.number, slot, filePath, this.key()); this.ensureScope()
+ const uploadKey = this.key()
+ const photo = await uploadLoadingPhoto(this.number, slot, filePath, uploadKey, reviewRound); this.ensureScope()
if (photo?.slot !== slot || !photo.fileId) throw new Error('上传结果与装车照片框不一致')
const latest = await getLoadingDocuments(this.number); this.ensureScope(); this.snapshot = latest
+ if (latest.reviewRound !== reviewRound) { reconciled = true; throw new Error('审核轮次已变化,请核对新车辆、司机后重新选择资料') }
if (!this.editable) throw new Error('当前任务不可修改装车照片,请刷新任务查看')
try {
- const value = await bindLoadingPhoto(this.number, slot, { recordVersion: latest.recordVersion, idempotencyKey: this.key(), fileId: photo.fileId, ...(replaceFileId ? { replaceFileId } : {}) }); this.ensureScope()
+ const value = await bindLoadingPhoto(this.number, slot, { recordVersion: latest.recordVersion, idempotencyKey: this.key(), fileId: photo.fileId, uploadKey, ...(replaceFileId ? { replaceFileId } : {}) }); this.ensureScope()
this.snapshot = value
} catch (cause) {
// 保存可能已成功;只重读最新事实,失败时必须先刷新再继续。
@@ -92,6 +97,19 @@ export default {
if ((saved || reconciled) && this.active) this.$emit('saved')
},
preview(url) { uni.previewImage({ current: url, urls: [url] }) },
+ async viewPdf(photo) {
+ await this.run(async () => {
+ const browser = typeof window !== 'undefined' && typeof document !== 'undefined'
+ const popup = browser ? window.open('', '_blank') : null
+ if (browser && !popup) throw new Error('请允许打开驾驶证预览窗口')
+ if (popup) popup.opener = null
+ try {
+ const path = await readLoadingPhoto(this.number, photo); this.ensureScope()
+ if (popup) popup.location.href = path
+ else await new Promise((resolve, reject) => uni.openDocument({ filePath: path, fileType: 'pdf', showMenu: true, success: resolve, fail: reject }))
+ } catch (cause) { if (popup) popup.close(); throw cause }
+ })
+ },
async run(work, notify = false) {
if (this.busy) return
const revision = ++this.revision
diff --git a/src/components/ExceptionReturnWork.vue b/src/components/ExceptionReturnWork.vue
new file mode 100644
index 0000000..ed6cb07
--- /dev/null
+++ b/src/components/ExceptionReturnWork.vue
@@ -0,0 +1,82 @@
+
+
+ {{ error }}
+
+
+ 当前仓没有待验收异常退货任务
+
+ {{ task.exceptionNo }}
+ 车牌号:{{ task.vehiclePlate || '未登记' }}
+ 客户:{{ task.customerName || '—' }} · 待收 {{ task.packageCount - task.receivedPackages }} 箱
+
+
+
+
+ {{ current.order.exceptionNo }}车牌号:{{ selectedTask.vehiclePlate || '未登记' }}
+
+ {{ item.skuCode || '货物' }}原托盘 / 箱唛:{{ item.cartonMark || item.palletNo || '—' }}
+ 剩余可收 {{ item.maxReturnPackages }} 箱
+
+
+ 上架位置
+ 实收照片{{ photo.fileName }}
+
+
+ {{ message }}
+
+
+
+
diff --git a/src/components/PortalHeader.vue b/src/components/PortalHeader.vue
index 52b5cc8..78077e1 100644
--- a/src/components/PortalHeader.vue
+++ b/src/components/PortalHeader.vue
@@ -13,7 +13,6 @@