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 @@ + + + diff --git a/src/components/DispatchLoadingDocuments.vue b/src/components/DispatchLoadingDocuments.vue new file mode 100644 index 0000000..3db4448 --- /dev/null +++ b/src/components/DispatchLoadingDocuments.vue @@ -0,0 +1,113 @@ + + + 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 }} + + {{ message }}{{ error }} diff --git a/src/data/overseas-workbench.js b/src/data/overseas-workbench.js index ec649be..66e5483 100644 --- a/src/data/overseas-workbench.js +++ b/src/data/overseas-workbench.js @@ -8,7 +8,7 @@ export const MODULES = [ { 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: '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: [ 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..708c407 100644 --- a/src/pages/dispatch/dispatch.vue +++ b/src/pages/dispatch/dispatch.vue @@ -1,209 +1,268 @@