diff --git a/AGENTS.md b/AGENTS.md
index ea0a386..19a782f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -34,60 +34,40 @@
## 页面与能力快速索引
-| 目标 | 入口 | 继续查询 |
-|---|---|---|
-| 页面注册与启动 | `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/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` 时恢复对应排车。详情提供独立返回列表入口;托盘业务码扫码拣货、幂等完成前消费服务端 `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 或真机验收 |
+本表只保留首个入口;完整 API、字段、状态与恢复规则见 [按需业务索引](docs/business-index.md)。从本应用根执行 `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/pages/module/module.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/api/warehouse-operations.js`、`src/stores/warehouse-work.js` |
+| 商品移库真实作业 | `src/pages/move/move.vue`、`src/api/warehouse-operations.js`、`src/stores/warehouse-work.js` |
+| 换标真实作业 | `src/pages/relabel/relabel.vue`、`src/api/relabel.js` |
+| 现场查询真实作业 | `src/pages/query/query.vue`、`src/api/warehouse-operations.js` |
+| 排车拣货与发车 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` |
+| 整车发车与证照提醒 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js` 的 `departDispatch` |
+| 普通/一件代发出库 | `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,打开文档不等于真机打印完成。
-
-- 换标目标确认即提交拆托合托,不再二次点击底部提交。`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 展示成业务编号。网络失败保留草稿和幂等键,恢复后以服务端任务状态为准;排车详情返回的 `canDepart`、`departBlockReason` 是发车资格依据,拣货及完成按钮同时核对当前单`canPick/pickBlockReason`;平台未转派送生成 `WAIT_OUTBOUND` 出库单时不得发车;完成或发车成功后不得重复提交。派送始终由 Admin/Client 承载。
+- 库内、换标、收货、排车与出库只调用专用 `/api/pda/**`,客户身份、绑定仓及资源范围由服务端校验;不得借用平台、客户或内部接口绕过授权。
+- 前端只展示服务端业务标识与操作资格;写操作保留稳定幂等键,失败/409 后重新读取权威状态,不把网络超时判成操作未执行或已成功。
+- 修改收货、退货、移库、托盘、换标、发车、照片或任务恢复前,必须定向读取 `docs/business-index.md` 的对应页面行及“海外业务接口边界”条款;专项检查仍按下文质量门禁执行。
## 质量门禁
diff --git a/docs/business-index.md b/docs/business-index.md
new file mode 100644
index 0000000..86787c6
--- /dev/null
+++ b/docs/business-index.md
@@ -0,0 +1,64 @@
+# 海外 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/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/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` 才恢复排车。按实物托盘码或来源箱唛真实拣货,服务端 `actions.PICK/LOADING_COMPLETE/DEPART` 分别决定拣货、装车完成和实际发车资格;装车与发车分离,后台安排派送完成前不得发车。公网前缀 `/api/pda/v1/last-leg/dispatches/**` |
+| 整车发车与证照提醒 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js` 的 `departDispatch` | 一张排车单代表一趟车,使用 `dispatchNo` 与公开 `recordVersion`(0 合法),保留完整原命令和 `idempotencyKey`。恢复校验原单号、动作、请求键、结果及版本;`DEPART` 结果须为 `stage=DELIVERING`,重读任务且版本不早于已确认结果后再清除原命令。司机证照、资源及各客户派送资格由服务端校验。正式流程见工作区 `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 作为业务编号。网络失败保留完整原命令、草稿和幂等键,恢复后以服务端任务状态为准;动作资格与恢复规则见本文件排车条目,不能将装车完成当作实际发车。完成或发车成功后不得重复提交;PDA 不承载派送单创建,平台与客户的读取范围遵循现行契约。
diff --git a/src/api/fulfillment.js b/src/api/fulfillment.js
index 2aa4a04..cf0e8fd 100644
--- a/src/api/fulfillment.js
+++ b/src/api/fulfillment.js
@@ -18,39 +18,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 || ''), 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 +79,32 @@ 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 getDispatchLoading(dispatchNo) {
+ return overseasRequest('GET', `${DISPATCH_BASE}/${encoded(dispatchNo)}/loading`)
+}
+export function getDispatchCommand(dispatchNo, key) {
+ return overseasRequest('GET', `${DISPATCH_BASE}/${encoded(dispatchNo)}/commands/${encoded(key)}`)
+}
+function dispatchWrite(path, request) {
+ requireDispatchVersion(request?.recordVersion)
+ return overseasRequest('POST', path, request)
+}
export function pickDispatchItem(dispatchNo, dispatchItemId, request) {
- return overseasRequest('POST', `${DISPATCH_BASE}/${encoded(dispatchNo)}/items/${encoded(dispatchItemId)}/pick`, request)
+ return dispatchWrite(`${DISPATCH_BASE}/${encoded(dispatchNo)}/items/${encoded(dispatchItemId)}/pick`, request)
}
-
-export function completeDispatchPicking(dispatchNo, request) {
- return overseasRequest('POST', `${DISPATCH_BASE}/${encoded(dispatchNo)}/complete`, request)
+export function completeDispatchLoading(dispatchNo, request) {
+ return dispatchWrite(`${DISPATCH_BASE}/${encoded(dispatchNo)}/loading-complete`, request)
}
-
export function departDispatch(dispatchNo, request) {
- return overseasRequest('POST', `${DISPATCH_BASE}/${encoded(dispatchNo)}/depart`, request)
-}
-
-export function tripDepartureRequest(task, idempotencyKey) {
- if (!task?.tripNo || task.tripRecordVersion == null || !/^\d+$/.test(String(task.tripRecordVersion))) {
- throw new Error('车次信息不完整,请刷新任务后再发车')
- }
- return { recordVersion: String(task.tripRecordVersion), idempotencyKey, remark: '海外 PDA 整车核平后发车' }
+ return dispatchWrite(`${DISPATCH_BASE}/${encoded(dispatchNo)}/depart`, request)
}
export async function listOutboundTasks(status = '') {
diff --git a/src/pages/dispatch/dispatch.vue b/src/pages/dispatch/dispatch.vue
index 723da3c..d15f1da 100644
--- a/src/pages/dispatch/dispatch.vue
+++ b/src/pages/dispatch/dispatch.vue
@@ -1,209 +1,82 @@
-
-
-
-
- 排车拣货与发车
- 扫描排车单号打开任务,再扫描任务中的托盘码进行拣货
-
- 当前任务排车拣货详情
-
-
-
-
- 网络中断会保留当前作业,恢复后可重试。
-
-
- 正在加载排车任务…
-
- {{ 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 }}
-
-
+
+
+ 整车拣货、装车与发车一张排车单代表一趟车,按实物托盘码或来源箱唛逐项作业。
+ 排车 {{ pendingCommand.dispatchNo }} 的原操作结果待核对,请保留原请求。
+ {{ current.dispatchNo }}
+
+ 正在加载排车事实…
+ {{ error.message }}错误码:{{ error.code }}requestId:{{ error.requestId }}traceId:{{ error.traceId }}网络中断,原请求和作业草稿已保留。
+ 排车任务暂无排车任务,可扫描排车单号读取。
+
+ {{ current.dispatchNo }}{{ stageLabel(current) }}任务号 {{ current.taskNo || '—' }}{{ current.actions.PICK?.blockReason || '拣货资格尚未允许,请核对两级审核及当前作业状态' }}{{ plannedPackages }}计划箱数{{ pickedPackages }}真实已拣{{ remainingPackages }}未拣箱数
+ {{ item.palletNo || '未关联托盘码' }}{{ item.pickedPackages }}/{{ item.scheduledPackages }}箱{{ item.customerName || '—' }} · {{ routeLabel(item) }}{{ carton.cartonMark }} · {{ carton.status === 'OUTBOUNDED' ? '已拣货' : '待扫描' }}
+ 本车暂无货物,空单不能核平、装车或发车。
+ 本次拣货:{{ selectedItem.palletNo || '来源箱唛' }}{{ selectedReturnCarton ? `已扫 ${selectedReturnCarton.cartonMark}` : '请扫描来源箱唛,每次一箱' }}拣货箱数最多{{ selectedRemaining }}箱
+ 确认装车完成{{ current.pickingReconciled ? 'PDA拣货已核平,确认实际装载后进入装车后。' : '先完成全部真实拣货,不能补录数量代替核平。' }}{{ current.actions.LOADING_COMPLETE?.blockReason || '尚未获得装车完成资格' }}
+ 实际发车本车已经实际发车,不重复提交。{{ current.actions.DEPART?.blockReason || '请先由后台完成本车全部客户的派送安排' }}装车完成与实际发车是两个动作,请确认现场已经发车。
+ {{ successMessage }}
+
+ 核对真实装载 · {{ loadingPreview.dispatchNo }}{{ row.palletNo || '—' }}实际托位实测重量(kg)实测体积(m³)已匹配卸货顺序:{{ row.deliveryIndex ?? '尚未匹配,请后台核对' }}{{ evidence.actual ? '真实测量依据' : '仅供参考' }} {{ evidence.sourceBusinessNo || '—' }}:{{ evidence.value ?? '—' }}
-
-
diff --git a/src/stores/fulfillment.js b/src/stores/fulfillment.js
index 43208c9..2a4939e 100644
--- a/src/stores/fulfillment.js
+++ b/src/stores/fulfillment.js
@@ -1,10 +1,10 @@
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: {} },
+ dispatch: { currentNo: '', palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1, pendingCommand: null },
outbound: { currentTaskNo: '', scanCode: '', packages: 1, pendingKeys: {} }
})
@@ -18,7 +18,7 @@ function safeDomain(value, fallback) {
return {
...fallback,
...(value || {}),
- pendingKeys: { ...(value?.pendingKeys || {}) }
+ ...(Object.hasOwn(fallback, 'pendingKeys') ? { pendingKeys: { ...(value?.pendingKeys || {}) } } : {})
}
}
@@ -57,6 +57,7 @@ function randomKey(domain, action) {
export function fulfillmentIdempotencyKey(domain, action, payload = {}) {
if (!fulfillment[domain]) throw new Error('未知海外 PDA 履约类型')
+ if (domain === 'dispatch') throw new Error('排车必须保存完整原命令,不能只生成请求键')
const signature = `${action}:${JSON.stringify(payload)}`
const pendingKeys = fulfillment[domain].pendingKeys
if (!pendingKeys[signature]) pendingKeys[signature] = randomKey(domain, action)
@@ -66,10 +67,39 @@ export function fulfillmentIdempotencyKey(domain, action, payload = {}) {
export function releaseFulfillmentKey(domain, signature) {
if (!fulfillment[domain]) return
+ if (domain === 'dispatch') throw new Error('排车原命令只能在服务端事实核对后清除')
delete fulfillment[domain].pendingKeys[signature]
persistFulfillmentDraft()
}
+export function beginDispatchCommand(dispatchNo, action, body) {
+ if (!fulfillment.scopeKey) throw new Error('当前作业身份未恢复')
+ if (fulfillment.dispatch.pendingCommand) throw new Error('请先核对原排车操作结果')
+ const idempotencyKey = randomKey('dispatch', action)
+ fulfillment.dispatch.pendingCommand = { dispatchNo, action, body: JSON.parse(JSON.stringify({ ...body, idempotencyKey })), attempted: false, rejected: false, result: null }
+ persistFulfillmentDraft()
+ return fulfillment.dispatch.pendingCommand
+}
+
+export function patchDispatchCommand(patch) {
+ if (!fulfillment.dispatch.pendingCommand) throw new Error('原排车请求不存在')
+ Object.assign(fulfillment.dispatch.pendingCommand, patch)
+ persistFulfillmentDraft()
+}
+
+export function finishDispatchCommand(key) {
+ if (fulfillment.dispatch.pendingCommand?.body.idempotencyKey !== key) throw new Error('核对结果与原请求键不一致')
+ fulfillment.dispatch.pendingCommand = null
+ persistFulfillmentDraft()
+}
+
+export function discardRejectedDispatchCommand() {
+ if (fulfillment.dispatch.pendingCommand?.rejected === true) {
+ fulfillment.dispatch.pendingCommand = null
+ persistFulfillmentDraft()
+ }
+}
+
export function resetFulfillmentDomain(domain) {
if (!fulfillment[domain]) return
fulfillment[domain] = blankState()[domain]
diff --git a/tests/dispatch-workflow.test.mjs b/tests/dispatch-workflow.test.mjs
new file mode 100644
index 0000000..355e08e
--- /dev/null
+++ b/tests/dispatch-workflow.test.mjs
@@ -0,0 +1,103 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import { fulfillment, beginDispatchCommand, patchDispatchCommand, finishDispatchCommand, discardRejectedDispatchCommand, restoreFulfillmentDraft, updateFulfillmentDraft, resetFulfillmentSession } from '../src/stores/fulfillment.js'
+import { requireDispatchVersion, dispatchVersionAtLeast, normalizeDispatchTask } from '../src/api/fulfillment.js'
+const source = (await readFile(new URL('../src/pages/dispatch/dispatch.vue', import.meta.url), 'utf8')).match(/