diff --git a/AGENTS.md b/AGENTS.md index 275c464..6771a53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ - 本项目是“柏悦物流海外版”独立 uni-app Vue 3 工程,面向海外仓现场人员。 - 工程必须独立安装、构建和发布,不得依赖 `apps/pda-uniapp/` 或仓库外 `pda-shared` 目录。 -- 当前设备端业务范围包括海外仓入仓、库内、换标、库存/库位/托盘查询,以及排车拣货、普通/一件代发出库任务、任务完成和发车;派送与客户回访由 Admin/Client 承载,国内集装箱拣货/装柜由国内 PDA 承载。 +- 当前设备端业务范围包括海外仓入仓、退货签收与合箱、库内、换标、库存/库位/托盘查询,以及排车拣货、普通/一件代发出库任务、任务完成和发车;派送与客户回访由 Admin/Client 承载,国内集装箱拣货/装柜由国内 PDA 承载。 ## 服务与发布边界 @@ -28,7 +28,7 @@ - 工作台定义:`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 逐箱收货、一件代发按国内封托来源托盘整托收货,并逐入仓码核平;库内盘点、移库、托盘管理、换标收货/执行/复核以及库存/库位/托盘查询均已接入专用 PDA API。 +- 入仓作业已接柜号级真实海外 API:账号锁柜、三类照片签收、统一箱码定位入仓码、FBA 按箱数收货、OVERSEAS 逐箱收货、一件代发按国内封托来源托盘整托收货并逐入仓码核平;退货入库通过退货单号或 SKU 候选进入并按本包实收生成退货箱与计费批次;移库、托盘管理、换标收货/执行/复核以及库存/库位/托盘查询均已接入专用 PDA API。库存盘点底层页面与接口仍保留,但当前工作台不开放入口。 ## 页面与能力快速索引 @@ -40,14 +40,15 @@ | 登录、刷新与会话 | `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`,合托明确为现场可选而非完成门禁;扫码实收确认计划生成阶段已预留的真实托盘和库位,不在设备端重新分配。Backend `lastleg-service/inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,公网前缀 `/api/pda/v1/last-leg/receiving-containers/**` | +| 柜号签收、照片与入仓真实作业 | `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`,合托明确为现场可选而非完成门禁;扫码实收确认计划生成阶段已预留的真实托盘和库位,不在设备端重新分配。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/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/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` | 同一页面按操作参数承载换标收货、执行与复核;支持扫码、拍照、异常/返工和幂等恢复 | +| 换标真实作业 | `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` | 任务列表/详情、托盘业务码扫码拣货、幂等完成;发车必须同时完成拣货核平、平台转派送并严格消费服务端 `canDepart`、`departBlockReason`,不得按本地数量自行推断;公网前缀 `/api/pda/v1/last-leg/dispatches/**` | -| 普通/一件代发出库 | `src/pages/outbound/outbound.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 任务列表/详情、稳定业务码扫码拣货和幂等完成;公网前缀 `/api/pda/v1/last-leg/outbound-tasks/**`;派送由 Admin/Client 承载 | +| 普通/一件代发出库 | `src/pages/outbound/outbound.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 任务列表/详情、逐箱扫码、扫描 DP 托盘码原子拣取整托剩余箱件及幂等完成;公网前缀 `/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 | @@ -55,6 +56,13 @@ ## 海外业务接口边界 +- 换标任务标明类型,仅显示实际明细需要的 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` 或 `/overseas-api` 结尾时,再移除业务路径重复的 `/api` 前缀。各业务 API 不得自行拼接 `session.apiBase`,也不得依赖双斜杠或重复 `/api` 被网关容错。 @@ -64,13 +72,14 @@ - 请求身份和资源范围只取 Token 与 current 会话;前端不得提交 `customerId`、`planNo`、`allocationId`、`warehouseId` 或 `locationId`。409 必须引导账号恢复当前柜,完成前不得切柜。 - 签收照片固定为三个角色且每个角色只有一个当前文件;上传成功后使用服务端 `downloadPath` 在当前会话内鉴权读取。更换失败时保留原 `fileId` 和可提交状态,无原图的失败槽不得计入 `photosReady`;本地临时路径仅用于本次设备预览,不作为跨会话文件事实。 - 库内、换标与现场查询只调用 `/api/pda/**` 业务 API/DTO,并统一受 `lastleg:pda:operate`、Token 冻结仓库范围和服务端资源归属约束;写操作必须保留稳定幂等键,409 后重新读取服务端状态,不得把平台、客户或内部接口直接暴露给 PDA。 -- 已有海外入仓 PDA Controller 统一位于 Lastleg `/api/pda/v1/last-leg/inbound-plans/{planNo}/**`;浏览器或设备请求仍必须经 `gateway-overseas` 的显式 PDA 路由,不得直连 Lastleg,也不得改走国内 Gateway。 +- 柜号签收入仓调用 Lastleg `/api/pda/v1/last-leg/receiving-containers/**`;退货签收与合箱分别调用 `/api/pda/v1/last-leg/return-receipts/**`、`/api/pda/v1/last-leg/return-inventory/**`,具体入口见上表。浏览器或设备请求必须经 `gateway-overseas` 的显式 PDA 路由,不得直连 Lastleg,也不得改走国内 Gateway。 - 海外业务 Token、PDA 权限和可信身份最终由 Gateway 与服务端校验;前端不得用工作台 `available`、本地客户 ID 或仓库 ID 代替授权和资源归属校验。 - 排车和出库入口只调用专用 PDA 接口;客户与冻结海外仓范围由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不把内部数据库 ID 展示成业务编号。网络失败保留草稿和幂等键,恢复后以服务端任务状态为准;排车详情返回的 `canDepart`、`departBlockReason` 是发车按钮唯一门禁,平台未转派送生成 `WAIT_OUTBOUND` 出库单时不得发车;完成或发车成功后不得重复提交。派送始终由 Admin/Client 承载。 ## 质量门禁 - 安装:`npm install`;检查:`npm test`。 +- 退货签收、合箱或工作台入口变更先运行 `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 真机验收完成。 diff --git a/src/api/fulfillment.js b/src/api/fulfillment.js index 85c1f89..0a82511 100644 --- a/src/api/fulfillment.js +++ b/src/api/fulfillment.js @@ -112,6 +112,19 @@ export function pickOutboundTask(taskNo, request) { return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/pick`, request) } +export function pickOutboundPallet(taskNo, palletNo, request) { + return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/pallets/${encoded(palletNo)}/pick`, request) +} + +export function outboundPickPayload(item, position, packages) { + return { + outboundPalletItemId: item.outboundPalletItemId, + inventoryPositionId: position.inventoryPositionId, + packages: Number(packages), + cartonMarks: position.cartonMark ? [position.cartonMark] : [] + } +} + export function completeOutboundTask(taskNo, request) { return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/complete`, request) } diff --git a/src/api/inbound.js b/src/api/inbound.js index 9a45d8b..1d22e16 100644 --- a/src/api/inbound.js +++ b/src/api/inbound.js @@ -17,7 +17,8 @@ export const getCurrentWarehouseProfile = () => { export const getReceivingContainer = async (containerNo) => normalizeReceivingSession(await overseasRequest('GET', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}`)) export const prepareReceivingContainer = async (containerNo, request) => normalizeReceivingSession(await overseasRequest('POST', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}/prepare`, request)) export const uploadCurrentUnloadPhoto = (filePath) => overseasUpload(current('/unload-photos/files'), filePath) -export const downloadCurrentUnloadPhoto = (downloadPath) => overseasDownload(downloadPath) +export const downloadCurrentUnloadPhoto = (downloadPath) => overseasDownload(downloadPath, { expectedContentTypePrefix: 'image/' }) +export const downloadCurrentDomesticLoadingPhoto = (downloadPath) => overseasDownload(downloadPath, { expectedContentTypePrefix: 'image/' }) export const signCurrentReceiving = async (request) => normalizeReceivingSession(await overseasRequest('POST', current('/sign'), request)) export const getCurrentReceiving = async () => normalizeReceivingSession(await overseasRequest('GET', current())) export const resolveCurrentCarton = async (cartonMark) => normalizeReceivingInbound(await overseasRequest('POST', current('/cartons/resolve'), { cartonMark })) diff --git a/src/api/overseas.js b/src/api/overseas.js index c792931..44d70df 100644 --- a/src/api/overseas.js +++ b/src/api/overseas.js @@ -116,7 +116,8 @@ export async function overseasUpload(path, filePath, name = 'file', retry = true return body.data } -export async function overseasDownload(path, retry = true) { +export async function overseasDownload(path, options = {}) { + const { retry = true, expectedContentTypePrefix = '' } = options const attemptedToken = session.token let response = await rawDownload(path, attemptedToken) if (retry && unauthorized(response)) { @@ -126,6 +127,10 @@ export async function overseasDownload(path, retry = true) { if (response?.statusCode !== 200 || !response?.tempFilePath) { throw errorFrom({ ...response, data: {} }, '签收照片下载失败') } + const contentType = String(response?.header?.['content-type'] || response?.header?.['Content-Type'] || '').toLowerCase() + if (expectedContentTypePrefix && !contentType.startsWith(expectedContentTypePrefix.toLowerCase())) { + throw errorFrom({ ...response, data: {} }, '照片服务返回了无效内容,请稍后重试') + } return response.tempFilePath } diff --git a/src/api/receiving-adapter.js b/src/api/receiving-adapter.js index 2b91063..629e429 100644 --- a/src/api/receiving-adapter.js +++ b/src/api/receiving-adapter.js @@ -87,11 +87,16 @@ export function normalizeReceivingSession(value) { const photos = Array.isArray(value.photos) ? value.photos.map((photo) => ({ ...photo, fileId: String(photo.fileId) })) : [] + const domesticLoadingPhotos = Array.isArray(value.domesticLoadingPhotos) + ? value.domesticLoadingPhotos.filter((photo) => photo?.evidenceType && photo?.downloadPath) + .map((photo) => ({ ...photo, status: 'idle', localPath: '' })) + : [] return { ...value, overseasWarehouseCode: text(value.overseasWarehouseCode), overseasWarehouseName: text(value.overseasWarehouseName), photos: RECEIVING_PHOTO_ROLES.map((role) => photos.find((photo) => photo?.role === role && photo?.fileId)).filter(Boolean), + domesticLoadingPhotos, totalPackages: number(value.totalPackages), receivedPackages: number(value.receivedPackages), pendingPackages: number(value.pendingPackages), diff --git a/src/api/relabel.js b/src/api/relabel.js index 1460d8d..d0d24f0 100644 --- a/src/api/relabel.js +++ b/src/api/relabel.js @@ -1,4 +1,4 @@ -import { overseasRequest, overseasUpload } from './overseas.js' +import { overseasRequest, overseasUpload, overseasDownload } from './overseas.js' const BASE = '/pda/v1/last-leg/relabel-tasks' const encoded = (value) => encodeURIComponent(String(value || '').trim()) @@ -6,18 +6,22 @@ const encoded = (value) => encodeURIComponent(String(value || '').trim()) 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 || ''), + 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) + })) + const inferredOperationType = items.some((item) => item.operationType === 'CARTON_AND_SKU') ? 'CARTON_AND_SKU' : items.some((item) => item.operationType === 'SKU_LABEL') + ? (items.some((item) => item.operationType !== 'SKU_LABEL') ? 'CARTON_AND_SKU' : 'SKU_LABEL') : 'CARTON_LABEL' return { ...value, relabelNo: String(value.relabelNo || ''), customerOrderNo: String(value.customerOrderNo || ''), version: Number(value.version || 0), expectedPackages: Number(value.expectedPackages || 0), + operationType: String(value.operationType || inferredOperationType), relabelledPackages: Number(value.relabelledPackages || 0), skippedPackages: Number(value.skippedPackages || 0), exceptionPackages: Number(value.exceptionPackages || 0), - items: asList(value.items).map((item) => ({ - ...item, itemId: String(item.itemId || ''), skuCode: String(item.skuCode || ''), - newSkuCode: String(item.newSkuCode || ''), expectedPackages: Number(item.expectedPackages || 0), - relabelledPackages: Number(item.relabelledPackages || 0), skippedPackages: Number(item.skippedPackages || 0), - exceptionPackages: Number(item.exceptionPackages || 0) - })), + items, unitFacts: asList(value.unitFacts), history: asList(value.history), completionPhotoFileIds: asList(value.completionPhotoFileIds).map(String) } @@ -36,9 +40,13 @@ async function detailAction(relabelNo, suffix, request) { } export const claimRelabelTask = (relabelNo, request) => detailAction(relabelNo, '/claim', request) +export const scanRelabelCarton = (relabelNo, request) => detailAction(relabelNo, '/cartons/scan', request) +export const listRelabelDocuments = (relabelNo) => overseasRequest('GET', `${BASE}/${encoded(relabelNo)}/documents`) +export const downloadRelabelDocument = (relabelNo, file) => overseasDownload(`${BASE}/${encoded(relabelNo)}/documents/${encoded(file.fileId)}/download?scope=${encoded(file.scope)}${file.itemId ? `&itemId=${encoded(file.itemId)}` : ''}`) export const relabelUnit = (relabelNo, request) => detailAction(relabelNo, '/units', request) export const skipRelabelUnit = (relabelNo, request) => detailAction(relabelNo, '/units/skip', request) export const reportRelabelException = (relabelNo, request) => detailAction(relabelNo, '/exceptions', request) export const completeRelabelTask = (relabelNo, request) => detailAction(relabelNo, '/complete', request) export const reviewRelabelTask = (relabelNo, request) => detailAction(relabelNo, '/review', request) export const uploadRelabelPhoto = (relabelNo, filePath) => overseasUpload(`${BASE}/${encoded(relabelNo)}/photos/files`, filePath) +export const downloadRelabelPhoto = (relabelNo, fileId) => overseasDownload(`${BASE}/${encoded(relabelNo)}/photos/files/${encoded(fileId)}/download`, { expectedContentTypePrefix: 'image/' }) diff --git a/src/api/return-packing.js b/src/api/return-packing.js new file mode 100644 index 0000000..9b43e69 --- /dev/null +++ b/src/api/return-packing.js @@ -0,0 +1,57 @@ +import { overseasRequest } from './overseas.js' + +const BASE = '/pda/v1/last-leg/return-inventory' +const encoded = (value) => encodeURIComponent(String(value || '').trim()) +const count = (value) => Number.isFinite(Number(value)) ? Math.max(0, Number(value)) : 0 + +function isNotFound(error) { + const code = String(error?.code || '').toUpperCase() + return code === 'A0404' || code === '404' +} + +function normalizeItems(items) { + return Array.isArray(items) ? items.map((item) => ({ + skuCode: String(item.skuCode || ''), productNameZh: String(item.productNameZh || ''), + quantity: count(item.quantity ?? item.availableQuantity) + })) : [] +} + +export function normalizePackingPreview(value = {}) { + return { + customerKey: value.customerId == null ? '' : String(value.customerId), + orders: Array.isArray(value.orders) ? value.orders.map((item) => ({ + returnNo: String(item.returnNo || ''), customerReferenceNo: String(item.customerReferenceNo || ''), status: String(item.status || '') + })) : [], + skus: Array.isArray(value.skus) ? value.skus.map((item) => ({ + skuCode: String(item.skuCode || ''), productNameZh: String(item.productNameZh || ''), availableQuantity: count(item.availableQuantity) + })) : [] + } +} + +export function normalizeReturnLabel(value = {}) { + return { + cartonMark: String(value.cartonMark || ''), sourceBatchNo: String(value.sourceBatchNo || ''), + sourceReturnNo: String(value.sourceReturnNo || ''), totalQuantity: count(value.totalQuantity), + length: count(value.length), width: count(value.width), height: count(value.height), weight: count(value.weight), + items: normalizeItems(value.items), generatedAt: value.generatedAt || null, printableHtml: String(value.printableHtml || '') + } +} + +export async function findPackingOrders(returnNo = '') { + const query = String(returnNo || '').trim() + try { + return normalizePackingPreview(await overseasRequest('GET', `${BASE}/packing/orders${query ? `?returnNo=${encoded(query)}` : ''}`)) + } catch (error) { + if (isNotFound(error)) return normalizePackingPreview() + throw error + } +} + +export async function mergeReturnCartons(request) { + const value = await overseasRequest('POST', `${BASE}/cartons/merge`, request) + return { cartonMark: String(value?.cartonMark || ''), skuCode: String(value?.skuCode || ''), totalQuantity: count(value?.totalQuantity), label: normalizeReturnLabel(value?.label) } +} + +export async function getReturnCartonLabel(cartonMark) { + return normalizeReturnLabel(await overseasRequest('GET', `${BASE}/cartons/${encoded(cartonMark)}/label`)) +} diff --git a/src/api/return-receipt.js b/src/api/return-receipt.js new file mode 100644 index 0000000..f18fcb1 --- /dev/null +++ b/src/api/return-receipt.js @@ -0,0 +1,82 @@ +import { overseasRequest } from './overseas.js' + +const BASE = '/pda/v1/last-leg/return-receipts' + +function count(value) { + const result = Number(value || 0) + return Number.isFinite(result) ? Math.max(0, result) : 0 +} + +function normalizeItem(item = {}) { + return { + skuCode: String(item.skuCode || ''), + productNameZh: String(item.productNameZh || ''), + expectedQuantity: count(item.expectedQuantity), + receivedQuantity: count(item.receivedQuantity) + } +} + +function isNotFound(error) { + const code = String(error?.code || '').toUpperCase() + return code === 'A0404' || code === '404' +} + +export function normalizeReturnReceiptStart(value = {}) { + return { + returnNo: String(value.returnNo || ''), + status: String(value.status || ''), + expectedItems: Array.isArray(value.expectedItems) ? value.expectedItems.map(normalizeItem) : [] + } +} + +export function normalizeReturnSkuCandidates(value = {}) { + return { + skuCode: String(value.skuCode || ''), + candidates: Array.isArray(value.candidates) ? value.candidates.map((item) => ({ + returnNo: String(item.returnNo || ''), + customerReferenceNo: String(item.customerReferenceNo || ''), + status: String(item.status || ''), + skuExpectedQuantity: count(item.skuExpectedQuantity), + skuReceivedQuantity: count(item.skuReceivedQuantity), + orderExpectedQuantity: count(item.orderExpectedQuantity), + orderReceivedQuantity: count(item.orderReceivedQuantity) + })) : [] + } +} + +export function normalizeReturnReceiptResult(value = {}) { + return { + returnNo: String(value.returnNo || ''), + receiptNo: String(value.receiptNo || ''), + receiptBatchNo: String(value.receiptBatchNo || ''), + cartonMark: String(value.cartonMark || ''), + orderStatus: String(value.orderStatus || ''), + fullyMatched: Boolean(value.fullyMatched), + expectedQuantity: count(value.expectedQuantity), + receivedQuantity: count(value.receivedQuantity), + signedAt: value.signedAt || null + } +} + +export async function startReturnReceipt(returnNo) { + try { + return normalizeReturnReceiptStart(await overseasRequest('POST', `${BASE}/start`, { returnNo: String(returnNo || '').trim() })) + } catch (error) { + if (isNotFound(error)) return null + throw error + } +} + +export async function findReturnSkuCandidates(skuCode) { + const normalizedSkuCode = String(skuCode || '').trim() + try { + return normalizeReturnSkuCandidates(await overseasRequest('POST', `${BASE}/sku-candidates`, { skuCode: normalizedSkuCode })) + } catch (error) { + if (isNotFound(error)) return normalizeReturnSkuCandidates({ skuCode: normalizedSkuCode, candidates: [] }) + throw error + } +} + +export async function confirmReturnReceipt(request) { + return normalizeReturnReceiptResult(await overseasRequest('POST', `${BASE}/confirm`, request)) +} diff --git a/src/data/overseas-workbench.js b/src/data/overseas-workbench.js index 0e26b0f..6ea3219 100644 --- a/src/data/overseas-workbench.js +++ b/src/data/overseas-workbench.js @@ -1,18 +1,19 @@ export const MODULES = [ - { key: 'inbound', name: '入仓作业', nameKey: 'modules.inbound.name', icon: '入', description: '单账号锁定一柜,完成柜号级入库闭环', descriptionKey: 'modules.inbound.description', operations: [ - { key: 'receiving', name: '拆柜收货', nameKey: 'modules.receiving.name', icon: '卸', iconType: 'download', description: '三类照片签收,箱码定位入仓码后按目的类型收货', descriptionKey: 'modules.receiving.description', available: true } + { key: 'inbound', name: '入仓作业', nameKey: 'modules.inbound.name', icon: '入', description: '处理拆柜收货与客户退货实收入仓', descriptionKey: 'modules.inbound.description', operations: [ + { key: 'receiving', name: '拆柜收货', nameKey: 'modules.receiving.name', icon: '卸', iconType: 'download', description: '三类照片签收,箱码定位入仓码后按目的类型收货', descriptionKey: 'modules.receiving.description', available: true }, + { key: 'returnReceipt', name: '退货入库', nameKey: 'modules.returnReceipt.name', icon: '退', iconType: 'undo', description: '扫描退货单或 SKU,按本包实收数量生成退货库存', descriptionKey: 'modules.returnReceipt.description', available: true, route: '/pages/return-receipt/return-receipt' }, + { key: 'returnPacking', name: '退货合箱', nameKey: 'modules.returnPacking.name', icon: '合', iconType: 'folder-add', description: '选择同客户已签收退货单,按 SKU 数量生成新箱', descriptionKey: 'modules.returnPacking.description', available: true, route: '/pages/return-packing/return-packing' } ] }, { key: 'outbound', name: '出库履约', nameKey: 'modules.outbound.name', icon: '出', description: '排车拣货、出库任务与发车闭环', descriptionKey: 'modules.outbound.description', operations: [ { key: 'dispatchPicking', name: '排车拣货', nameKey: 'modules.dispatchPicking.name', icon: '车', iconType: 'paperplane', description: '按排车任务扫描托盘拣货,完成门禁后执行发车', descriptionKey: 'modules.dispatchPicking.description', available: true, route: '/pages/dispatch/dispatch' }, { key: 'outboundPicking', name: '出库作业', nameKey: 'modules.outboundPicking.name', icon: '拣', iconType: 'checkbox', description: '处理出库与一件代发任务,扫码核平后完成', descriptionKey: 'modules.outboundPicking.description', available: true, route: '/pages/outbound/outbound' } ] }, - { key: 'inventory', name: '库内作业', nameKey: 'modules.inventory.name', icon: '库', description: '盘点、移库与托盘管理', descriptionKey: 'modules.inventory.description', operations: [ - { key: 'stocktake', name: '库存盘点', nameKey: 'modules.stocktake.name', icon: '盘', iconType: 'list', description: '按任务扫描库位和库存', descriptionKey: 'modules.stocktake.description', available: true, route: '/pages/stocktake/stocktake' }, + { key: 'inventory', name: '库内作业', nameKey: 'modules.inventory.name', icon: '库', description: '商品移库与托盘管理', descriptionKey: 'modules.inventory.description', operations: [ { key: 'move', name: '商品移库', nameKey: 'modules.move.name', icon: '移', iconType: 'loop', description: '扫描来源与目标库位完成移库', descriptionKey: 'modules.move.description', available: true, route: '/pages/move/move' }, { key: 'pallet', name: '托盘管理', nameKey: 'modules.pallet.name', icon: '托', iconType: 'map', description: '扫码查询、组托、拆托、换托及解绑', descriptionKey: 'modules.pallet.description', available: true, route: '/pages/pallet/pallet' } ] }, - { key: 'relabel', name: '换标作业', nameKey: 'modules.relabel.name', icon: '换', description: '换标收货、执行与复核', descriptionKey: 'modules.relabel.description', operations: [ - { key: 'relabelReceive', name: '换标收货', nameKey: 'modules.relabelReceive.name', icon: '收', iconType: 'download', description: '扫描换标单并确认待处理库存', descriptionKey: 'modules.relabelReceive.description', available: true, route: '/pages/relabel/relabel?op=relabelReceive' }, + { key: 'relabel', name: '换标作业', nameKey: 'modules.relabel.name', icon: '换', description: '换标列表、执行与复核', descriptionKey: 'modules.relabel.description', operations: [ + { key: 'relabelReceive', name: '换标列表', nameKey: 'modules.relabelReceive.name', icon: '列', iconType: 'list', description: '选择换标单并直接进入执行', descriptionKey: 'modules.relabelReceive.description', available: true, route: '/pages/relabel/relabel?op=relabelReceive' }, { key: 'relabelExecute', name: '换标执行', nameKey: 'modules.relabelExecute.name', icon: '换', iconType: 'compose', description: '扫描旧标和新标记录换标结果', descriptionKey: 'modules.relabelExecute.description', available: true, route: '/pages/relabel/relabel?op=relabelExecute' }, { key: 'relabelReview', name: '换标复核', nameKey: 'modules.relabelReview.name', icon: '核', iconType: 'checkbox', description: '复核标签和商品关联关系', descriptionKey: 'modules.relabelReview.description', available: true, route: '/pages/relabel/relabel?op=relabelReview' } ] }, diff --git a/src/pages.json b/src/pages.json index 1f7abe7..cd5d8bf 100644 --- a/src/pages.json +++ b/src/pages.json @@ -4,6 +4,8 @@ { "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/return-receipt/return-receipt", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货入库", "backgroundColor": "#F5F7FA" } }, + { "path": "pages/return-packing/return-packing", "style": { "navigationStyle": "custom", "navigationBarTitleText": "退货合箱", "backgroundColor": "#F5F7FA" } }, { "path": "pages/pallet/pallet", "style": { "navigationStyle": "custom", "navigationBarTitleText": "托盘管理", "backgroundColor": "#F5F7FA" } }, { "path": "pages/stocktake/stocktake", "style": { "navigationStyle": "custom", "navigationBarTitleText": "库存盘点", "backgroundColor": "#F5F7FA" } }, { "path": "pages/move/move", "style": { "navigationStyle": "custom", "navigationBarTitleText": "商品移库", "backgroundColor": "#F5F7FA" } }, diff --git a/src/pages/operation/operation.vue b/src/pages/operation/operation.vue index ef133b8..7d76efc 100644 --- a/src/pages/operation/operation.vue +++ b/src/pages/operation/operation.vue @@ -1,7 +1,7 @@