diff --git a/AGENTS.md b/AGENTS.md index a4dcfa6..1b76e99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ - 本项目是“柏悦物流海外版”独立 uni-app Vue 3 工程,面向海外仓现场人员。 - 工程必须独立安装、构建和发布,不得依赖 `apps/pda-uniapp/` 或仓库外 `pda-shared` 目录。 -- 当前业务范围按四期收敛为入仓、库内、换标、库存/库位/托盘查询;不包含海外仓出库、派送和国内集装箱拣货/装柜。 +- 当前设备端业务范围包括四期入仓、库内、换标、库存/库位/托盘查询,以及五期排车拣货、普通/一件代发出库任务、任务完成和发车;不包含派送、客户回访,也不包含国内集装箱拣货/装柜。 ## 服务与发布边界 @@ -42,6 +42,8 @@ | 中英西语言设置 | `src/i18n/index.js`、`src/i18n/locales/`、`src/pages/profile/profile.vue` | 个人中心可选择并持久化 `zh-CN`、`en`、`es`;当前业务翻译资源仍为空,页面业务文案继续使用中文,不得把语言选择入口误报为全量翻译完成 | | 柜号签收、照片与入仓真实作业 | `src/pages/operation/operation.vue`、`src/api/inbound.js`、`src/api/receiving-adapter.js`、`src/api/overseas.js`、`src/stores/receiving.js` | 三个固定照片角色各一张,支持拍照/相册、鉴权预览及更换失败保留原图;Backend `lastleg-service/inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,公网前缀 `/api/pda/v1/last-leg/receiving-containers/**` | | 托盘管理真实作业 | `src/pages/pallet/pallet.vue`、`src/api/pallet.js` | 扫码查询托盘内容,按服务端返回的托盘/库存版本执行组托、拆托、换托和解绑;公网前缀 `/api/pda/v1/last-leg/pallet-operations/**` | +| 五期排车拣货与发车 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 任务列表/详情、托盘业务码扫码拣货、幂等完成和核平后发车;公网前缀 `/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/**`;派送不进入 PDA | | 其余库内、换标与现场查询待接前端 | `src/data/overseas-workbench.js` 中对应 `available: false` 入口 | Backend 已有 `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`relabel/pda/` 与 `overseas-pda-operations-v1.yaml`;本应用新增专用 API/状态恢复和测试前不得启用 | | 扫码设备与诊断 | `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` | 会话隔离、退出清理和后续真实作业历史契约 | @@ -57,7 +59,7 @@ - 除已开放的托盘管理外,其余库内、换标与现场查询仍保持禁用;新增真实作业必须接入 `/api/pda/**` 业务 API/DTO、`lastleg:pda:operate` 权限、Token 冻结仓库范围、错误态和契约测试,不得把平台、客户或内部接口直接暴露给 PDA。 - 已有海外入仓 PDA Controller 统一位于 Lastleg `/api/pda/v1/last-leg/inbound-plans/{planNo}/**`;浏览器或设备请求仍必须经 `gateway-overseas` 的显式 PDA 路由,不得直连 Lastleg,也不得改走国内 Gateway。 - 海外业务 Token、PDA 权限和可信身份最终由 Gateway 与服务端校验;前端不得用工作台 `available`、本地客户 ID 或仓库 ID 代替授权和资源归属校验。 -- 四期仍止于入仓、库存与库内操作。后端历史 `dispatch/`、出库和派送代码不构成海外 PDA 页面入口,也不得加入工作台。 +- 五期排车和出库入口只调用专用 PDA 接口;客户与冻结海外仓范围由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不把内部数据库 ID 展示成业务编号。网络失败保留草稿和幂等键,恢复后以服务端任务状态为准;完成或发车成功后不得重复提交。派送始终不进入 PDA。 ## 质量门禁 @@ -65,5 +67,6 @@ - 正式产品形态是海外 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,也不能下载国内更新包。 - 不提交 `node_modules/`、`dist/`、`unpackage/`、环境文件、签名证书或打包产物。 diff --git a/README.md b/README.md index 5fa07f4..ffa70da 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 独立的 uni-app Vue 3 App 工程,正式产品形态是安装在海外仓手持 PDA 上的 App。工程不依赖国内 PDA 源码或仓库外共享目录,可独立安装依赖、运行、编译和发布;H5 只用于浏览器调试和编译门禁,不是最终交付形态。 -工作台按四期边界提供柜号级真实入仓作业和托盘管理,以及其余库内、换标和现场查询的禁用入口。登录使用国内 Auth 统一签发的双 Token;海外业务请求统一经 `gateway-overseas`,不得请求国内 Firstleg/Warehouse 业务接口。 +工作台提供柜号级真实入仓、托盘管理,以及五期排车拣货、出库任务、一件代发出库、任务完成和发车入口;派送单、派送状态与客户回访不进入 PDA。登录使用国内 Auth 统一签发的双 Token;海外业务请求统一经 `gateway-overseas`,不得请求国内 Firstleg/Warehouse 业务接口。 ## 本地运行与检查 @@ -42,6 +42,9 @@ npm run build:app - 入仓页面已接待卸柜候选、柜详情与账号锁柜、三类角色照片签收、current 恢复、统一箱码与既有物理托盘解析、FBA 按箱数和 OVERSEAS 逐箱收货、逐入仓码核平及整柜完成的真实 API;收货页不预列柜内入仓码,箱码解析成功后只显示本次命中的入仓码;409 会引导返回当前柜,错误会保留业务码、requestId 与 traceId。 - 候选列表同时显示已齐全与未齐全柜,展示计划到仓时间、总订单、入仓码、已生成计划、未操作和缺入仓码数量;未齐全柜置灰但可查看服务端阻断原因,只有服务端 `allPlansReady=true && operable=true` 才能 prepare。 - 托盘管理已接 `/api/pda/v1/last-leg/pallet-operations/**`:支持扫描查询当前仓实物托盘及库存内容,并按服务端版本执行组托、拆托、换托和解绑;不同目的仓不能合托,冻结或处理中的库存由服务端拒绝。 +- 排车拣货已接 `/api/pda/v1/last-leg/dispatches/**`:支持任务列表、业务单号恢复、托盘码扫码拣货、任务完成及全部箱数核平后的发车;服务端仍会校验关联出库单和托盘门禁。 +- 出库作业已接 `/api/pda/v1/last-leg/outbound-tasks/**`:支持普通出库和一件代发任务列表/详情、托盘码/箱码/SKU/入仓码扫码拣货与核平完成;不会展示或进入派送流程。 +- 五期写操作使用可恢复的稳定幂等键。网络失败保留当前任务、扫码草稿和幂等键;成功后释放键并按服务端状态恢复,避免重复完成或发车。 - 入仓请求不手填客户、计划、分配、仓库或库位标识;身份与当前柜范围由 Token、Gateway 和服务端会话确定。页面同时支持 PDA 广播扫码与 H5 人工输入。 - 登录、恢复和刷新会 fail-closed 校验国内/海外双 Token 的同一平台操作主体、`OVERSEAS_WAREHOUSE_PDA_OPERATOR`、`lastleg:pda:operate` 及同一冻结海外仓;并发海外 401 共享一次国内 refresh,校验完成后原子替换双 Token并将原请求或照片上传重放一次。 - 库存盘点、商品移库、换标收货/执行/复核以及库存/库位/托盘查询仍未接入海外 PDA 前端,入口保持禁用;不会调用平台、客户或内部接口,也不会模拟成功。 diff --git a/src/api/fulfillment.js b/src/api/fulfillment.js new file mode 100644 index 0000000..4e979a5 --- /dev/null +++ b/src/api/fulfillment.js @@ -0,0 +1,111 @@ +import { overseasRequest } from './overseas.js' + +const DISPATCH_BASE = '/pda/v1/last-leg/dispatches' +const OUTBOUND_BASE = '/pda/v1/last-leg/outbound-tasks' + +const encoded = (value) => encodeURIComponent(String(value || '').trim()) + +function asList(value) { + if (Array.isArray(value)) return value + if (Array.isArray(value?.records)) return value.records + if (Array.isArray(value?.items)) return value.items + return [] +} + +function count(value) { + const number = Number(value || 0) + return Number.isFinite(number) ? Math.max(0, number) : 0 +} + +export function normalizeDispatchTask(value) { + return { + ...value, + taskNo: String(value?.taskNo || ''), + dispatchNo: String(value?.dispatchNo || ''), + vehiclePlate: String(value?.vehiclePlate || ''), + status: String(value?.status || ''), + 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), + 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) + })) + })) + } +} + +export function normalizeOutboundTask(value) { + const header = value?.task || value || {} + return { + ...header, + taskNo: String(header?.taskNo || ''), + outboundNo: String(header?.outboundNo || ''), + status: String(header?.status || ''), + items: asList(value?.items).map((item) => ({ + ...item, + outboundPalletItemId: String(item?.outboundPalletItemId || ''), + palletNo: String(item?.palletNo || ''), + plannedPackages: count(item?.packageCount), + pickedPackages: count(item?.pickedPackageCount), + positions: asList(item?.positions).map((position) => ({ + ...position, + inventoryPositionId: String(position?.inventoryPositionId || ''), + palletNo: String(position?.palletNo || item?.palletNo || ''), + cartonMark: String(position?.cartonMark || ''), + skuCode: String(position?.skuCode || position?.sku || ''), + inboundCode: String(position?.inboundCode || ''), + availablePackages: count(position?.availablePackages), + pickedPackages: count(position?.pickedPackages) + })) + })) + } +} + +export async function listDispatchTasks(status = '') { + const suffix = status ? `?status=${encoded(status)}` : '' + return asList(await overseasRequest('GET', `${DISPATCH_BASE}${suffix}`)).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 completeDispatchPicking(dispatchNo, request) { + return overseasRequest('POST', `${DISPATCH_BASE}/${encoded(dispatchNo)}/complete`, request) +} + +export function departDispatch(dispatchNo, request) { + return overseasRequest('POST', `${DISPATCH_BASE}/${encoded(dispatchNo)}/depart`, request) +} + +export async function listOutboundTasks(status = '') { + const suffix = status ? `?status=${encoded(status)}` : '' + return asList(await overseasRequest('GET', `${OUTBOUND_BASE}${suffix}`)).map(normalizeOutboundTask) +} + +export async function getOutboundTask(taskNo) { + return normalizeOutboundTask(await overseasRequest('GET', `${OUTBOUND_BASE}/${encoded(taskNo)}`)) +} + +export function pickOutboundTask(taskNo, request) { + return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/pick`, request) +} + +export function completeOutboundTask(taskNo, request) { + return overseasRequest('POST', `${OUTBOUND_BASE}/${encoded(taskNo)}/complete`, request) +} diff --git a/src/data/overseas-workbench.js b/src/data/overseas-workbench.js index f4b274d..da27998 100644 --- a/src/data/overseas-workbench.js +++ b/src/data/overseas-workbench.js @@ -2,6 +2,10 @@ 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: '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: false }, { key: 'move', name: '商品移库', nameKey: 'modules.move.name', icon: '移', iconType: 'loop', description: '扫描来源与目标库位完成移库', descriptionKey: 'modules.move.description', available: false }, diff --git a/src/pages.json b/src/pages.json index 250e454..e3d882c 100644 --- a/src/pages.json +++ b/src/pages.json @@ -5,6 +5,8 @@ { "path": "pages/module/module", "style": { "navigationStyle": "custom", "navigationBarTitleText": "海外仓业务", "backgroundColor": "#F5F7FA" } }, { "path": "pages/operation/operation", "style": { "navigationStyle": "custom", "navigationBarTitleText": "柜号入库与现场作业", "backgroundColor": "#F5F7FA" } }, { "path": "pages/pallet/pallet", "style": { "navigationStyle": "custom", "navigationBarTitleText": "托盘管理", "backgroundColor": "#F5F7FA" } }, + { "path": "pages/dispatch/dispatch", "style": { "navigationStyle": "custom", "navigationBarTitleText": "排车拣货与发车", "backgroundColor": "#F5F7FA" } }, + { "path": "pages/outbound/outbound", "style": { "navigationStyle": "custom", "navigationBarTitleText": "出库作业", "backgroundColor": "#F5F7FA" } }, { "path": "pages/history/history", "style": { "navigationStyle": "custom", "navigationBarTitleText": "作业记录", "backgroundColor": "#F5F7FA" } }, { "path": "pages/profile/profile", "style": { "navigationStyle": "custom", "navigationBarTitleText": "个人中心", "backgroundColor": "#F5F7FA" } }, { "path": "pages/scanner-test/scanner-test", "style": { "navigationStyle": "custom", "navigationBarTitleText": "广播扫码测试", "backgroundColor": "#F5F7FA" } } diff --git a/src/pages/dispatch/dispatch.vue b/src/pages/dispatch/dispatch.vue new file mode 100644 index 0000000..d1d17ff --- /dev/null +++ b/src/pages/dispatch/dispatch.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/src/pages/dispatch/fulfillment.scss b/src/pages/dispatch/fulfillment.scss new file mode 100644 index 0000000..d8b5522 --- /dev/null +++ b/src/pages/dispatch/fulfillment.scss @@ -0,0 +1,47 @@ + .fulfillment-page { height: 100vh; overflow: hidden; } + .fulfillment-scroll { height: calc(100vh - 48px - var(--status-bar-height, 0px)); } + .fulfillment-content { padding-bottom: 44px; } + .fulfillment-heading { margin: 4px 2px 12px; } + .fulfillment-title, .fulfillment-subtitle, .scan-help, .state-title, .fulfillment-error text, .fulfillment-success text, .task-meta, .card-title, .gate-note, .gate-warning, .gate-success, .offline-note { display: block; } + .fulfillment-title { color: #14263d; font-size: 21px; font-weight: 800; } + .fulfillment-subtitle { margin-top: 4px; color: #708197; font-size: 11px; line-height: 17px; } + .fulfillment-card { box-sizing: border-box; margin-bottom: 10px; padding: 14px; border: 1px solid #dce5f0; border-radius: 13px; background: #fff; box-shadow: 0 4px 15px rgba(32,67,111,.06); } + .secondary-button, .primary-button, .depart-button, .link-button { box-sizing: border-box; width: 100%; min-height: 44px; margin: 10px 0 0; border-radius: 8px; font-size: 13px; line-height: 44px; } + .secondary-button { border: 1px solid #9dc0f4; color: #135ee8; background: #f8fbff; } + .primary-button { color: #fff; font-weight: 800; background: #135ee8; } + .depart-button { color: #fff; font-weight: 800; background: #0a8754; } + .link-button { color: #5d6f84; background: #f1f4f8; } + .secondary-button[disabled], .primary-button[disabled], .depart-button[disabled], .link-button[disabled] { color: #9aa7b5; background: #e8edf3; opacity: 1; } + .scan-help, .gate-note { margin-top: 8px; color: #718095; font-size: 10px; line-height: 16px; } + .fulfillment-state { margin-bottom: 10px; padding: 28px 16px; border-radius: 12px; color: #748399; font-size: 11px; line-height: 18px; text-align: center; background: #fff; } + .state-title { margin-bottom: 4px; color: #263b55; font-size: 14px; font-weight: 800; } + .fulfillment-error, .fulfillment-success { margin-bottom: 10px; padding: 12px; border-radius: 10px; font-size: 10px; line-height: 17px; } + .fulfillment-error { color: #a62323; background: #fff0f0; } + .fulfillment-success { color: #087443; background: #e8f8ef; } + .offline-note { margin-top: 7px; color: #8a5a00; } + .list-toolbar, .task-head, .count-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; } + .list-toolbar { margin: 16px 2px 10px; color: #233951; font-size: 13px; font-weight: 800; } + .list-toolbar button { min-height: 32px; margin: 0; padding: 0 12px; border-radius: 7px; color: #135ee8; font-size: 11px; line-height: 32px; background: #e9f2ff; } + .task-list, .item-list { display: flex; flex-direction: column; gap: 9px; } + .task-card { box-sizing: border-box; width: 100%; min-height: 76px; margin: 0; padding: 13px; border: 1px solid #dce5f0; border-radius: 11px; text-align: left; background: #fff; } + .task-card::after { border: 0; } + .business-code { color: #16304d; font-size: 15px; font-weight: 800; overflow-wrap: anywhere; } + .status-badge { flex-shrink: 0; padding: 4px 7px; border-radius: 99px; color: #165fbf; font-size: 10px; background: #e6f0ff; } + .task-meta { margin-top: 6px; color: #718095; font-size: 10px; line-height: 16px; } + .summary-card { display: block; } + .metrics { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); margin-top: 12px; padding: 10px 0; border-radius: 9px; background: #f5f8fc; } + .metrics view { display: flex; align-items: center; flex-direction: column; gap: 3px; color: #718095; font-size: 9px; } + .metrics view + view { border-left: 1px solid #dce5f0; } + .metric-value { color: #135ee8; font-size: 20px; font-weight: 800; } + .metric-value.success { color: #087443; } + .metric-value.warning { color: #a56800; } + .item-card { margin: 0; } + .item-card.selected { border: 2px solid #135ee8; background: #f8fbff; } + .progress { height: 6px; margin-top: 10px; overflow: hidden; border-radius: 99px; background: #e4eaf1; } + .progress view { height: 100%; border-radius: 99px; background: #20a464; } + .card-title { color: #17314f; font-size: 13px; font-weight: 800; } + .count-row { margin-top: 12px; color: #65768b; font-size: 10px; } + .count-row input { box-sizing: border-box; width: 78px; height: 42px; padding: 0 8px; border: 2px solid #135ee8; border-radius: 8px; color: #17314f; font-size: 17px; text-align: center; background: #fff; } + .gate-card textarea { box-sizing: border-box; width: 100%; height: 76px; margin-top: 10px; padding: 9px; border: 1px solid #d3dde9; border-radius: 8px; color: #27384d; font-size: 12px; background: #fbfcfe; } + .gate-warning { margin-top: 8px; color: #a56800; font-size: 11px; line-height: 17px; } + .gate-success { margin-top: 8px; color: #087443; font-size: 11px; line-height: 17px; } diff --git a/src/pages/outbound/outbound.vue b/src/pages/outbound/outbound.vue new file mode 100644 index 0000000..841be48 --- /dev/null +++ b/src/pages/outbound/outbound.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/src/stores/fulfillment.js b/src/stores/fulfillment.js new file mode 100644 index 0000000..6ff9b3e --- /dev/null +++ b/src/stores/fulfillment.js @@ -0,0 +1,82 @@ +import { reactive } from 'vue' + +const STORAGE_KEY = 'overseas-pda-fulfillment-draft-v1' + +const blankState = () => ({ + scopeKey: '', + dispatch: { currentNo: '', palletCode: '', packages: 1, pendingKeys: {} }, + outbound: { currentTaskNo: '', scanCode: '', packages: 1, pendingKeys: {} } +}) + +export const fulfillment = reactive(blankState()) + +function storageAvailable() { + return typeof uni !== 'undefined' && typeof uni.setStorageSync === 'function' && typeof uni.getStorageSync === 'function' +} + +function safeDomain(value, fallback) { + return { + ...fallback, + ...(value || {}), + pendingKeys: { ...(value?.pendingKeys || {}) } + } +} + +export function persistFulfillmentDraft() { + if (!storageAvailable() || !fulfillment.scopeKey) return + uni.setStorageSync(STORAGE_KEY, JSON.parse(JSON.stringify(fulfillment))) +} + +export function restoreFulfillmentDraft(scopeKey) { + const expected = String(scopeKey || '') + if (!expected) return false + const saved = storageAvailable() ? uni.getStorageSync(STORAGE_KEY) : null + if (!saved || saved.scopeKey !== expected) { + Object.assign(fulfillment, blankState(), { scopeKey: expected }) + if (storageAvailable()) uni.removeStorageSync(STORAGE_KEY) + return false + } + const fresh = blankState() + Object.assign(fulfillment, fresh, { + scopeKey: expected, + dispatch: safeDomain(saved.dispatch, fresh.dispatch), + outbound: safeDomain(saved.outbound, fresh.outbound) + }) + return true +} + +export function updateFulfillmentDraft(domain, patch) { + if (!fulfillment[domain]) throw new Error('未知海外 PDA 履约草稿') + Object.assign(fulfillment[domain], patch) + persistFulfillmentDraft() +} + +function randomKey(domain, action) { + return `pda-${domain}-${action}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` +} + +export function fulfillmentIdempotencyKey(domain, action, payload = {}) { + if (!fulfillment[domain]) throw new Error('未知海外 PDA 履约类型') + const signature = `${action}:${JSON.stringify(payload)}` + const pendingKeys = fulfillment[domain].pendingKeys + if (!pendingKeys[signature]) pendingKeys[signature] = randomKey(domain, action) + persistFulfillmentDraft() + return { signature, value: pendingKeys[signature] } +} + +export function releaseFulfillmentKey(domain, signature) { + if (!fulfillment[domain]) return + delete fulfillment[domain].pendingKeys[signature] + persistFulfillmentDraft() +} + +export function resetFulfillmentDomain(domain) { + if (!fulfillment[domain]) return + fulfillment[domain] = blankState()[domain] + persistFulfillmentDraft() +} + +export function resetFulfillmentSession() { + Object.assign(fulfillment, blankState()) + if (typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(STORAGE_KEY) +} diff --git a/src/stores/session.js b/src/stores/session.js index 6f82a3f..9b26ede 100644 --- a/src/stores/session.js +++ b/src/stores/session.js @@ -1,6 +1,7 @@ import { reactive } from 'vue' import { OVERSEAS_API_ROOT } from '../config/runtime.js' import { resetReceivingSession } from './receiving.js' +import { resetFulfillmentSession } from './fulfillment.js' const SESSION_KEY = 'overseas-pda-auth-session' export const PDA_ROLE = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' @@ -179,6 +180,7 @@ export function signOut() { session.apiBase = OVERSEAS_API_ROOT if (typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(SESSION_KEY) resetReceivingSession() + resetFulfillmentSession() } export function requireSession() { diff --git a/tests/business-identifiers-ui.test.mjs b/tests/business-identifiers-ui.test.mjs index 08a5205..966da59 100644 --- a/tests/business-identifiers-ui.test.mjs +++ b/tests/business-identifiers-ui.test.mjs @@ -10,6 +10,7 @@ test('all registered user-facing pages avoid rendering internal identifiers', as const paths = [ '../src/pages/login/login.vue', '../src/pages/home/home.vue', '../src/pages/module/module.vue', '../src/pages/operation/operation.vue', '../src/pages/pallet/pallet.vue', '../src/pages/profile/profile.vue', + '../src/pages/dispatch/dispatch.vue', '../src/pages/outbound/outbound.vue', '../src/pages/history/history.vue', '../src/pages/scanner-test/scanner-test.vue', '../src/components/PortalHeader.vue', '../src/components/PopupHeader.vue', '../src/components/ReceivingSessionSummary.vue', '../src/components/ScanTargetInput.vue' diff --git a/tests/fulfillment-workflows.test.mjs b/tests/fulfillment-workflows.test.mjs new file mode 100644 index 0000000..1accbb0 --- /dev/null +++ b/tests/fulfillment-workflows.test.mjs @@ -0,0 +1,144 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' + +import { fulfillment, fulfillmentIdempotencyKey, releaseFulfillmentKey, resetFulfillmentSession, restoreFulfillmentDraft, updateFulfillmentDraft } from '../src/stores/fulfillment.js' +import { + completeDispatchPicking, completeOutboundTask, departDispatch, getDispatchTask, getOutboundTask, + listDispatchTasks, listOutboundTasks, normalizeDispatchTask, normalizeOutboundTask, + pickDispatchItem, pickOutboundTask +} from '../src/api/fulfillment.js' +import { session } from '../src/stores/session.js' + +const read = (path) => readFile(new URL(path, import.meta.url), 'utf8') + +function storageMock() { + const values = new Map() + globalThis.uni = { + setStorageSync(key, value) { values.set(key, structuredClone(value)) }, + getStorageSync(key) { return structuredClone(values.get(key)) }, + removeStorageSync(key) { values.delete(key) } + } + return values +} + +test('phase-five workbench registers only dispatch and outbound PDA delivery boundaries', async () => { + const [workbench, pages] = await Promise.all([read('../src/data/overseas-workbench.js'), read('../src/pages.json')]) + assert.match(workbench, /name: '出库履约'/) + assert.match(workbench, /key: 'dispatchPicking'[\s\S]*available: true[\s\S]*route: '\/pages\/dispatch\/dispatch'/) + assert.match(workbench, /key: 'outboundPicking'[\s\S]*available: true[\s\S]*route: '\/pages\/outbound\/outbound'/) + assert.match(pages, /pages\/dispatch\/dispatch/) + assert.match(pages, /pages\/outbound\/outbound/) + for (const excluded of ['派送单', '派送状态', '客户回访']) assert.doesNotMatch(workbench, new RegExp(excluded)) +}) + +test('fulfillment API uses dedicated PDA routes and never sends customer or warehouse scope', async () => { + const api = await read('../src/api/fulfillment.js') + for (const path of ['dispatches', 'outbound-tasks', '/pick', '/complete', '/depart']) assert.match(api, new RegExp(path.replace('/', '\\/'))) + for (const forbidden of ['customerId', 'warehouseId', 'locationId']) assert.doesNotMatch(api, new RegExp(forbidden)) + assert.match(api, /encodeURIComponent/) +}) + +test('fulfillment API calls the frozen list detail pick complete and depart routes with exact payloads', async () => { + const calls = [] + session.apiBase = '/overseas-api'; session.token = 'overseas-token' + globalThis.uni = { + request(options) { + calls.push({ method: options.method, url: options.url, data: options.data, authorization: options.header.Authorization }) + let data = {} + if (options.method === 'GET' && /\/dispatches(?:\?|$)/.test(options.url)) data = [] + else if (options.method === 'GET' && /\/outbound-tasks(?:\?|$)/.test(options.url)) data = [] + else if (options.method === 'GET' && options.url.includes('/dispatches/')) data = { dispatchNo: 'DSP-1', taskNo: 'TASK-1', items: [] } + else if (options.method === 'GET' && options.url.includes('/outbound-tasks/')) data = { task: { taskNo: 'OUT-TASK-1', outboundNo: 'OUT-1' }, items: [] } + options.success({ statusCode: 200, data: { code: '200', data }, header: {} }) + } + } + + await listDispatchTasks('PICKING'); await getDispatchTask('DSP/1') + await pickDispatchItem('DSP/1', '11', { positionId: '22', packages: 1, productQuantity: 0, idempotencyKey: 'pick-key' }) + await completeDispatchPicking('DSP/1', { idempotencyKey: 'complete-key' }) + await departDispatch('DSP/1', { idempotencyKey: 'depart-key' }) + await listOutboundTasks('PENDING'); await getOutboundTask('OUT/TASK/1') + await pickOutboundTask('OUT/TASK/1', { outboundPalletItemId: '33', inventoryPositionId: '44', packages: 1, idempotencyKey: 'out-pick-key' }) + await completeOutboundTask('OUT/TASK/1', { idempotencyKey: 'out-complete-key' }) + + assert.equal(calls.length, 9) + assert.ok(calls.some((call) => call.url.endsWith('/dispatches?status=PICKING'))) + assert.ok(calls.some((call) => call.url.includes('/dispatches/DSP%2F1/items/11/pick'))) + assert.ok(calls.some((call) => call.url.endsWith('/dispatches/DSP%2F1/depart'))) + assert.ok(calls.some((call) => call.url.includes('/outbound-tasks/OUT%2FTASK%2F1/pick'))) + assert.ok(calls.every((call) => call.authorization === 'Bearer overseas-token')) + assert.ok(calls.every((call) => !/[?&](?:customerId|warehouseId)=/.test(call.url))) + for (const call of calls) { + assert.equal(Object.hasOwn(call.data || {}, 'customerId'), false) + assert.equal(Object.hasOwn(call.data || {}, 'warehouseId'), false) + } + const dispatchComplete = calls.find((call) => call.url.endsWith('/dispatches/DSP%2F1/complete')) + assert.deepEqual(dispatchComplete.data, { idempotencyKey: 'complete-key' }) +}) + +test('dispatch and outbound adapters retain stable business identifiers and count progress', () => { + const dispatch = normalizeDispatchTask({ taskNo: 'DPT-1', dispatchNo: 'DSP-1', items: [{ dispatchItemId: 8, palletNo: 'PLT-1', scheduledPackages: 5, pickedPackages: 2, positions: [{ id: 9, availablePackages: 5, pickedPackages: 2 }] }] }) + assert.equal(dispatch.dispatchNo, 'DSP-1') + assert.equal(dispatch.items[0].palletNo, 'PLT-1') + assert.equal(dispatch.items[0].positions[0].availablePackages, 5) + + const outbound = normalizeOutboundTask({ task: { taskNo: 'OUT-TASK-1', outboundNo: 'OUT-1', status: 'PENDING' }, items: [{ outboundPalletItemId: 3, palletNo: 'PLT-2', skuCode: 'SKU-1', inboundCode: 'INB-1', packageCount: 4, pickedPackageCount: 1, status: 'PARTIAL_PICKED', positions: [{ inventoryPositionId: 7, palletNo: 'PLT-2', cartonMark: 'BOX-1', skuCode: 'SKU-1', inboundCode: 'INB-1', availablePackages: 3, pickedPackages: 1 }] }] }) + assert.equal(outbound.taskNo, 'OUT-TASK-1') + assert.equal(outbound.items[0].palletNo, 'PLT-2') + assert.equal(outbound.items[0].outboundPalletItemId, '3') + assert.equal(outbound.items[0].plannedPackages, 4) + assert.equal(outbound.items[0].pickedPackages, 1) + assert.equal(outbound.items[0].positions[0].cartonMark, 'BOX-1') + assert.equal(outbound.items[0].positions[0].availablePackages, 3) +}) + +test('draft restoration scopes work to the signed-in operator warehouse and retains retry idempotency', () => { + storageMock(); resetFulfillmentSession() + assert.equal(restoreFulfillmentDraft('operator-a:warehouse-a'), false) + updateFulfillmentDraft('dispatch', { currentNo: 'DSP-001', palletCode: 'PLT-001', packages: 3 }) + const first = fulfillmentIdempotencyKey('dispatch', 'pick', { dispatchNo: 'DSP-001', palletNo: 'PLT-001', packages: 3 }) + const retry = fulfillmentIdempotencyKey('dispatch', 'pick', { dispatchNo: 'DSP-001', palletNo: 'PLT-001', packages: 3 }) + assert.equal(retry.value, first.value) + + fulfillment.dispatch.currentNo = '' + assert.equal(restoreFulfillmentDraft('operator-a:warehouse-a'), true) + assert.equal(fulfillment.dispatch.currentNo, 'DSP-001') + assert.equal(fulfillment.dispatch.pendingKeys[first.signature], first.value) + + releaseFulfillmentKey('dispatch', first.signature) + const next = fulfillmentIdempotencyKey('dispatch', 'pick', { dispatchNo: 'DSP-001', palletNo: 'PLT-001', packages: 3 }) + assert.notEqual(next.value, first.value) + assert.equal(restoreFulfillmentDraft('operator-b:warehouse-a'), false) + assert.equal(fulfillment.dispatch.currentNo, '') +}) + +test('pages expose loading empty error offline and permission diagnostics with guarded writes', async () => { + const [dispatch, outbound] = await Promise.all([read('../src/pages/dispatch/dispatch.vue'), read('../src/pages/outbound/outbound.vue')]) + for (const page of [dispatch, outbound]) { + assert.match(page, /startScanBroadcast/) + assert.match(page, /正在加载/) + assert.match(page, /暂无/) + assert.match(page, /requestId[\s\S]*traceId/) + assert.match(page, /error\.network[\s\S]*幂等键/) + assert.match(page, /A0403[\s\S]*没有海外仓 PDA 作业权限/) + assert.match(page, /fulfillmentIdempotencyKey/) + assert.match(page, /releaseFulfillmentKey/) + assert.match(page, /if \(!this\.can/) + assert.doesNotMatch(page, /模拟成功|静默成功|customerId:|warehouseId:|locationId:/) + } + assert.match(dispatch, /completeDispatchPicking/) + assert.match(dispatch, /this\.remainingPackages === 0/) + assert.doesNotMatch(dispatch, /partialReason|reason \}/) + assert.match(dispatch, /this\.pickingCompleted && this\.allPicked && !this\.departed/) + assert.match(dispatch, /this\.selectedPosition\?\.availablePackages \|\| 0/) + assert.doesNotMatch(dispatch, /availablePackages - (?:this\.)?selectedPosition.*pickedPackages/) + assert.match(dispatch, /departDispatch/) + assert.doesNotMatch(dispatch, /departedNo/) + assert.match(outbound, /completeOutboundTask/) + assert.match(outbound, /this\.remainingPackages === 0/) + assert.match(outbound, /position\.availablePackages <= 0/) + assert.doesNotMatch(outbound, /position\.availablePackages - position\.pickedPackages/) + for (const page of [dispatch, outbound]) assert.match(page, /await get(?:Dispatch|Outbound)Task[\s\S]*releaseFulfillmentKey/) + assert.doesNotMatch(dispatch + outbound, /DeliveryOrder|deliveryStatus|customerVisit|\/deliveries/) +}) diff --git a/tests/project-contract.test.mjs b/tests/project-contract.test.mjs index 962b33a..9d914e9 100644 --- a/tests/project-contract.test.mjs +++ b/tests/project-contract.test.mjs @@ -24,12 +24,12 @@ test('overseas PDA is an independent Vue 3 app with separate packaging identity' assert.doesNotMatch(styles, /home-operation-primary[^}]*grid-column:\s*1\s*\/\s*-1/) }) -test('overseas workbench contains only confirmed inbound and in-warehouse domains', async () => { +test('overseas workbench contains confirmed warehouse and phase-five outbound domains without delivery', async () => { const [data, session, runtime] = await Promise.all([ read('../src/data/overseas-workbench.js'), read('../src/stores/session.js'), read('../src/config/runtime.js') ]) - for (const label of ['入仓作业', '库内作业', '换标作业', '现场查询']) assert.match(data, new RegExp(label)) - for (const excluded of ['集装箱拣货', '装柜取证', '海外派送', '出库履约']) assert.doesNotMatch(data, new RegExp(excluded)) + for (const label of ['入仓作业', '出库履约', '排车拣货', '出库作业', '库内作业', '换标作业', '现场查询']) assert.match(data, new RegExp(label)) + for (const excluded of ['集装箱拣货', '装柜取证', '海外派送', '派送单', '客户回访']) assert.doesNotMatch(data, new RegExp(excluded)) assert.match(session, /overseas-pda-auth-session/) assert.match(runtime, /VITE_OVERSEAS_API_BASE_URL/) }) diff --git a/tests/session-auth.test.mjs b/tests/session-auth.test.mjs index 7595c04..257a35d 100644 --- a/tests/session-auth.test.mjs +++ b/tests/session-auth.test.mjs @@ -6,6 +6,7 @@ import { decodeAccessToken, signInWithTokens, signOut, validatePdaTokenPair } from '../src/stores/session.js' import { receiving } from '../src/stores/receiving.js' +import { fulfillment, restoreFulfillmentDraft, updateFulfillmentDraft } from '../src/stores/fulfillment.js' const future = () => Math.floor(Date.now() / 1000) + 3600 const jwt = (claims) => [ @@ -124,7 +125,7 @@ test('restore rebuilds identity from claims and removes expired or tampered stor assert.equal(values.has('overseas-pda-auth-session'), false) }) -test('sign out clears tokens, frozen warehouse identity, and every receiving draft', () => { +test('sign out clears tokens, frozen warehouse identity, and every receiving or fulfillment draft', () => { const values = storage(); signOut() signInWithTokens(tokenPair()) receiving.candidates = [{ containerNo: 'CONT-1' }] @@ -134,6 +135,9 @@ test('sign out clears tokens, frozen warehouse identity, and every receiving dra receiving.resolvedCarton = { cartonMark: 'BOX-1' } receiving.resolvedPallet = { palletCode: 'PALLET-1' } receiving.lastReceipt = { acceptedPackages: 1 } + restoreFulfillmentDraft('42:900') + updateFulfillmentDraft('dispatch', { currentNo: 'DSP-1', palletCode: 'PLT-1' }) + updateFulfillmentDraft('outbound', { currentTaskNo: 'PDA-1', scanCode: 'BOX-1' }) signOut() assert.equal(session.currentUser, null) @@ -148,4 +152,8 @@ test('sign out clears tokens, frozen warehouse identity, and every receiving dra assert.equal(receiving.resolvedCarton, null) assert.equal(receiving.resolvedPallet, null) assert.equal(receiving.lastReceipt, null) + assert.equal(fulfillment.scopeKey, '') + assert.equal(fulfillment.dispatch.currentNo, '') + assert.equal(fulfillment.outbound.currentTaskNo, '') + assert.equal(values.has('overseas-pda-fulfillment-draft-v1'), false) }) diff --git a/tests/workbench-availability.test.mjs b/tests/workbench-availability.test.mjs index 8bd70a7..a1ddb43 100644 --- a/tests/workbench-availability.test.mjs +++ b/tests/workbench-availability.test.mjs @@ -7,9 +7,13 @@ test('only operations backed by a dedicated overseas PDA contract are enabled', const enabled = operations.filter((operation) => operation.available) assert.deepEqual(enabled.map(({ module, name }) => ({ module, name })), [ { module: 'inbound', name: '拆柜收货' }, + { module: 'outbound', name: '排车拣货' }, + { module: 'outbound', name: '出库作业' }, { module: 'inventory', name: '托盘管理' } ]) assert.equal(operations.find((item) => item.name === '托盘管理')?.route, '/pages/pallet/pallet') + assert.equal(operations.find((item) => item.name === '排车拣货')?.route, '/pages/dispatch/dispatch') + assert.equal(operations.find((item) => item.name === '出库作业')?.route, '/pages/outbound/outbound') const pendingNames = ['库存盘点', '商品移库', '换标收货', '换标执行', '换标复核', '库存查询', '库位查询', '托盘查询'] for (const name of pendingNames) {