This commit is contained in:
2026-08-28 10:39:02 +08:00
parent fdde94488d
commit d096116cf7
12 changed files with 390 additions and 24 deletions
+6 -4
View File
@@ -15,7 +15,7 @@
## 服务与发布边界
- 国内 Auth 是唯一账号和 Token 签发方;海外业务请求统一经 `gateway-overseas`。
- 登录、刷新和本地会话必须同时校验国内/海外 Token 的平台主体、成员、冻结海外仓一致;海外 Token 还必须精确包含 `WAREHOUSE_PICKER` 与 `lastleg:pda:operate`。无效、过期、跨主体或撤权响应必须 fail-closed 清理会话,不得只依赖页面入口隐藏。
- 登录、刷新和本地会话必须同时校验国内/海外 Token 的平台主体、成员、冻结海外仓一致;海外 Token 还必须精确包含 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 与 `lastleg:pda:operate`。无效、过期、跨主体或撤权响应必须 fail-closed 清理会话,不得只依赖页面入口隐藏。
- 并发海外 401 只允许一次国内 refresh;双 Token 完整校验后原子替换,每个原请求或照片上传最多重放一次。刷新失败清理本地会话,业务错误和网络错误保留 code、requestId、traceId 或 network 诊断。
- 国内与海外 AppID、Android 包名、签名、版本号、构建产物和在线更新通道必须独立。
- `src/manifest.json` 中的 AppID 在正式打包前由海外版所属 DCloud 账号创建;不得伪造或复用国内 AppID。
@@ -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 逐箱收货并逐入仓码核平;收货页不预列柜内全部入仓码,也不要求作业员扫描 SKU;库内、换标与查询入口在海外 PDA 专用 Controller、权限和 Token 冻结仓库范围契约齐备前必须禁用,不得调用平台/客户/内部接口或模拟成功。
- 入仓作业已接柜号级真实海外 API:账号锁柜、三类照片签收、统一箱码定位入仓码、FBA 按箱数收货、OVERSEAS 逐箱收货并逐入仓码核平;托盘管理已接扫码查询、组托、拆托、换托和解绑专用 PDA API。其余库内、换标与查询入口在前端完成真实契约接入前必须禁用,不得调用平台/客户/内部接口或模拟成功。
## 页面与能力快速索引
@@ -41,7 +41,8 @@
| 海外仓工作台与模块入口 | `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/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/data/overseas-workbench.js` 中对应 `available: false` 入口 | Backend 已有 `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/` 与 `overseas-pda-operations-v1.yaml`;本应用新增专用 API/状态恢复和测试前不得启用 |
| 托盘管理真实作业 | `src/pages/pallet/pallet.vue`、`src/api/pallet.js` | 扫码查询托盘内容,按服务端返回的托盘/库存版本执行组托、拆托、换托和解绑;公网前缀 `/api/pda/v1/last-leg/pallet-operations/**` |
| 其余库内、换标与现场查询待接前端 | `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` | 会话隔离、退出清理和后续真实作业历史契约 |
| 本地契约测试 | `tests/project-contract.test.mjs`、`navigation.test.mjs`、`scan-broadcast.test.mjs`、`audio-feedback.test.mjs` | 运行 `npm test`;不代表真实海外 API 或真机验收 |
@@ -49,9 +50,10 @@
## 海外业务接口边界
- `src/api/inbound.js` 已接入待卸柜候选、柜详情/prepare、current 恢复、三类照片签收、统一箱码定位入仓码、实物托盘解析、FBA bulk/OVERSEAS carton 收货及整柜完成;FBA bulk 不提交 `skuCode`,库存分配由服务端按计划明细完成;响应与 404/409 判定集中在 `src/api/receiving-adapter.js`,双 Token 刷新与海外 401 单次重放由 `src/api/overseas.js` 统一处理。
- `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 冻结仓库范围、错误态和契约测试,不得把平台、客户或内部接口直接暴露给 PDA。
- 除已开放的托盘管理外,其余库内、换标与现场查询仍保持禁用;新增真实作业必须接入 `/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 页面入口,也不得加入工作台。
+5 -4
View File
@@ -2,7 +2,7 @@
独立的 uni-app Vue 3 App 工程,正式产品形态是安装在海外仓手持 PDA 上的 App。工程不依赖国内 PDA 源码或仓库外共享目录,可独立安装依赖、运行、编译和发布;H5 只用于浏览器调试和编译门禁,不是最终交付形态。
首版工作台按四期边界提供柜号级真实入仓作业,以及库内作业、换标作业和现场查询原型。登录使用国内 Auth 统一签发的双 Token;海外入仓请求统一经 `gateway-overseas`,不得请求国内 Firstleg/Warehouse 业务接口。
工作台按四期边界提供柜号级真实入仓作业和托盘管理,以及其余库内、换标和现场查询的禁用入口。登录使用国内 Auth 统一签发的双 Token;海外业务请求统一经 `gateway-overseas`,不得请求国内 Firstleg/Warehouse 业务接口。
## 本地运行与检查
@@ -14,7 +14,7 @@ npm test
npm run dev:h5
```
开发环境可在不提交的 `.env.local` 中配置 `VITE_PDA_DEV_USERNAME`、`VITE_PDA_DEV_PASSWORD`,登录页会自动预填;生产 App 构建始终忽略这两个值,账号必须具备 `WAREHOUSE_PICKER`、`lastleg:pda:operate` 和有效海外仓绑定。
开发环境可在不提交的 `.env.local` 中配置 `VITE_PDA_DEV_USERNAME`、`VITE_PDA_DEV_PASSWORD`,登录页会自动预填;生产 App 构建始终忽略这两个值,账号必须具备 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`、`lastleg:pda:operate` 和有效海外仓绑定。
生产编译:
@@ -40,9 +40,10 @@ npm run build:app
- 已复用并内置:登录壳、PDA 样式、扫码广播、声音反馈、导航、网络检测、图标及 `uni_modules`。
- 已隔离:国内集装箱拣货、装柜证据、国内 Firstleg API、国内任务状态和国内本地草稿。
- 入仓页面已接待卸柜候选、柜详情与账号锁柜、三类角色照片签收、current 恢复、统一箱码与既有物理托盘解析、FBA 按箱数和 OVERSEAS 逐箱收货、逐入仓码核平及整柜完成的真实 API;收货页不预列柜内入仓码,箱码解析成功后只显示本次命中的入仓码;409 会引导返回当前柜,错误会保留业务码、requestId 与 traceId。
- 托盘管理已接 `/api/pda/v1/last-leg/pallet-operations/**`:支持扫描查询当前仓实物托盘及库存内容,并按服务端版本执行组托、拆托、换托和解绑;不同目的仓不能合托,冻结或处理中的库存由服务端拒绝。
- 入仓请求不手填客户、计划、分配、仓库或库位标识;身份与当前柜范围由 Token、Gateway 和服务端会话确定。页面同时支持 PDA 广播扫码与 H5 人工输入。
- 登录、恢复和刷新会 fail-closed 校验国内/海外双 Token 的同一平台操作主体、`WAREHOUSE_PICKER`、`lastleg:pda:operate` 及同一冻结海外仓;并发海外 401 共享一次国内 refresh,校验完成后原子替换双 Token并将原请求或照片上传重放一次。
- 库存盘点、商品移库、托盘管理、换标收货/执行/复核以及库存/库位/托盘查询当前缺少海外 PDA 专用 Controller、权限和 Token 冻结仓库范围契约,入口已禁用;不会调用平台、客户或内部接口,也不会模拟成功。
- 登录、恢复和刷新会 fail-closed 校验国内/海外双 Token 的同一平台操作主体、`OVERSEAS_WAREHOUSE_PDA_OPERATOR`、`lastleg:pda:operate` 及同一冻结海外仓;并发海外 401 共享一次国内 refresh,校验完成后原子替换双 Token并将原请求或照片上传重放一次。
- 库存盘点、商品移库、换标收货/执行/复核以及库存/库位/托盘查询仍未接入海外 PDA 前端,入口保持禁用;不会调用平台、客户或内部接口,也不会模拟成功。
- 本地契约测试与 H5/App 资源编译不等于真实开发环境 HTTP 或 PDA 真机验收。
## App 在线更新
+1 -1
View File
@@ -5,7 +5,7 @@ export const MODULES = [
{ 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 },
{ key: 'pallet', name: '托盘管理', nameKey: 'modules.pallet.name', icon: '托', iconType: 'map', description: '组托、拆托及托盘状态核对', descriptionKey: 'modules.pallet.description', available: false }
{ 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: false },
+1
View File
@@ -4,6 +4,7 @@
{ "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/pallet/pallet", "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" } }
+1 -1
View File
@@ -73,7 +73,7 @@
openOperation(operation) {
if (!operation.available) return
const module = this.groups.find((group) => group.operations.some((item) => item.name === operation.name))
uni.navigateTo({ url: `/pages/operation/operation?module=${module?.key || 'query'}&op=${operation.key}` })
uni.navigateTo({ url: operation.route || `/pages/operation/operation?module=${module?.key || 'query'}&op=${operation.key}` })
}
}
}
+2 -2
View File
@@ -5,7 +5,7 @@
<view class="module-hero" :class="`module-hero-${currentModule.key}`"><view class="module-hero-icon">{{ currentModule.icon }}</view><view class="module-hero-copy"><text class="module-hero-title">{{ currentModule.name }}</text><text class="module-hero-desc">{{ currentModule.description }}</text></view><text class="module-hero-count">{{ availableCount }}/{{ currentModule.operations.length }} 可用</text></view>
<view class="section-heading"><text class="group-title">业务功能</text><text class="section-heading-hint">点击可用功能开始作业</text></view>
<view class="operation-list"><button v-for="operation in currentModule.operations" :key="operation.name" class="operation-item" :class="{ 'operation-item-disabled': !operation.available }" :disabled="!operation.available" @tap="openOperation(operation)"><view class="operation-icon">{{ operation.icon }}</view><view class="operation-info"><text class="operation-name">{{ operation.name }}</text><text class="operation-desc">{{ operation.description }}</text></view><text v-if="operation.available" class="operation-ready">可处理</text><text v-else class="operation-locked">未开放</text><text v-if="operation.available" class="operation-arrow">›</text></button></view>
<view class="module-empty-note">当前仅开放拆柜收货,其他作业暂未开放。</view>
<view v-if="availableCount < currentModule.operations.length" class="module-empty-note">灰色功能尚未开放,仅可进入已接入真实接口的作业。</view>
</view>
</view>
</template>
@@ -24,7 +24,7 @@
onLoad(options) { this.moduleKey = options.key || 'inbound' },
onShow() { if (!requireSession()) return; this.user = session.currentUser },
methods: {
openOperation(operation) { if (operation.available) uni.navigateTo({ url: `/pages/operation/operation?module=${this.moduleKey}&op=${operation.key}` }) }
openOperation(operation) { if (operation.available) uni.navigateTo({ url: operation.route || `/pages/operation/operation?module=${this.moduleKey}&op=${operation.key}` }) }
}
}
</script>
+316
View File
@@ -0,0 +1,316 @@
<template>
<view v-if="user" class="page-shell portal-page pallet-page">
<PortalHeader title="托盘管理" :user="user" />
<scroll-view class="pallet-scroll" scroll-y>
<view class="portal-content pallet-content">
<view class="pallet-heading">
<text class="pallet-title">托盘管理</text>
<text class="pallet-subtitle">扫描实物托盘,按 Token 绑定的当前海外仓执行作业</text>
</view>
<scroll-view class="pallet-mode-scroll" scroll-x :show-scrollbar="false">
<view class="pallet-modes">
<button v-for="item in modes" :key="item.key" class="pallet-mode" :class="{ active: mode === item.key }" @tap="selectMode(item.key)">{{ item.name }}</button>
</view>
</scroll-view>
<view class="pallet-card">
<view class="pallet-card-head"><text>{{ sourceTitle }}</text><text class="pallet-card-hint">{{ sourceHint }}</text></view>
<ScanTargetInput name="source" :model-value="sourceCode" label="来源托盘码" placeholder="扫描或人工输入托盘码" :active="activeTarget === 'source'" @activate="activateTarget" @update:modelValue="handleSourceInput" @submit="resolveSource" />
<button class="pallet-secondary" :disabled="!sourceCode || resolvingSource || submitting" @tap="resolveSource(sourceCode)">{{ resolvingSource ? '查询中…' : sourceButtonText }}</button>
<text class="pallet-scan-tip">PDA 广播扫码与 H5 人工输入均可使用</text>
</view>
<view v-if="sources.length" class="pallet-stack">
<view v-for="source in sources" :key="source.palletCode" class="pallet-card pallet-result-card">
<view class="pallet-summary-head">
<view><text class="pallet-code">{{ source.palletCode }}</text><text class="pallet-meta">{{ source.locationCode || '未绑定库位' }} · {{ statusLabel(source.status) }}</text></view>
<view class="pallet-summary-side"><text class="pallet-packages">{{ source.totalPackages }} 箱</text><button v-if="mode === 'assemble'" class="pallet-remove" @tap="removeSource(source.palletCode)">移除</button></view>
</view>
<text v-if="source.destinationWarehouseCode" class="pallet-destination">目的仓 {{ source.destinationWarehouseCode }}</text>
<view v-if="source.inventory?.length" class="inventory-list">
<view v-for="line in source.inventory" :key="String(line.positionId)" class="inventory-line">
<view class="inventory-copy"><text class="inventory-name">{{ line.productName || line.inboundCode || '库存明细' }}</text><text class="inventory-meta">{{ line.inboundCode || '无入仓码' }} · 可移动 {{ line.availablePackages }} 箱</text><text v-if="lockedPackages(line)" class="inventory-warning">另有 {{ lockedPackages(line) }} 箱处于冻结或处理中</text></view>
<view v-if="editableLines" class="inventory-count"><input type="number" :value="line.movePackages" @input="updateMovePackages(source.palletCode, line.positionId, $event.detail.value)" /><text>箱</text></view>
</view>
</view>
<view v-else class="pallet-empty-inline">当前托盘为空</view>
</view>
</view>
<view v-if="needsTarget" class="pallet-card">
<view class="pallet-card-head"><text>目标托盘</text><text class="pallet-card-hint">{{ targetHint }}</text></view>
<ScanTargetInput name="target" :model-value="targetCode" label="目标托盘码" placeholder="扫描或人工输入目标托盘码" :active="activeTarget === 'target'" @activate="activateTarget" @update:modelValue="handleTargetInput" @submit="resolveTarget" />
<button class="pallet-secondary" :disabled="!targetCode || resolvingTarget || submitting" @tap="resolveTarget(targetCode)">{{ resolvingTarget ? '校验中…' : '校验目标托盘' }}</button>
<view v-if="target" class="target-summary"><text class="pallet-code">{{ target.palletCode }}</text><text>{{ target.locationCode || '未绑定库位' }} · {{ statusLabel(target.status) }} · {{ target.totalPackages }} 箱</text></view>
</view>
<view v-if="mode !== 'query' && sources.length" class="pallet-card">
<text class="pallet-card-title">作业备注</text>
<textarea v-model="reason" class="pallet-reason" maxlength="500" placeholder="选填,异常或调整原因" />
<text class="pallet-confirm-tip">{{ confirmationTip }}</text>
<button class="pallet-primary" :disabled="!canSubmit || submitting" @tap="submitOperation">{{ submitting ? '提交中…' : actionText }}</button>
</view>
<view v-if="error" class="pallet-error" role="alert">
<text class="pallet-error-title">{{ error.message }}</text>
<text v-if="error.code">错误码:{{ error.code }}</text>
<text v-if="error.requestId">requestId:{{ error.requestId }}</text>
<text v-if="error.traceId">traceId:{{ error.traceId }}</text>
</view>
<view v-if="result" class="pallet-success" role="status">
<text class="pallet-success-title">托盘作业已完成</text>
<text>{{ operationLabel(result.operationType) }} · {{ result.replayed ? '幂等恢复结果' : '本次提交成功' }}</text>
<text v-for="item in result.pallets || []" :key="item.palletCode">{{ item.palletCode }} · {{ statusLabel(item.status) }} · {{ item.totalPackages }} 箱</text>
<button class="pallet-secondary" @tap="resetWork">继续作业</button>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { assemblePallet, disassemblePallet, resolvePallet, swapPallet, unbindPallet } from '@/api/pallet.js'
import { session, requireSession } from '@/stores/session.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
const MODES = [
{ key: 'query', name: '查询' },
{ key: 'assemble', name: '组托' },
{ key: 'disassemble', name: '拆托' },
{ key: 'swap', name: '换托' },
{ key: 'unbind', name: '解绑' }
]
function normalizedPallet(value) {
return {
...value,
palletCode: String(value?.palletCode || ''),
palletVersion: Number(value?.palletVersion || 0),
totalPackages: Number(value?.totalPackages || 0),
inventory: (value?.inventory || []).map((line) => ({
...line,
positionId: String(line.positionId),
positionVersion: String(line.positionVersion || 0),
availablePackages: Number(line.availablePackages || 0),
holdPackages: Number(line.holdPackages || 0),
allocatedPackages: Number(line.allocatedPackages || 0),
pickedPackages: Number(line.pickedPackages || 0),
receivingPackages: Number(line.receivingPackages || 0),
movePackages: Number(line.availablePackages || 0)
}))
}
}
function expectation(pallet) {
return { palletCode: pallet.palletCode, expectedVersion: pallet.palletVersion }
}
function idempotencyKey(mode) {
return `pda-pallet-${mode}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
function isConflict(error) {
return String(error?.code || '') === 'A0409' || Number(error?.code) === 409
}
export default {
components: { PortalHeader, ScanTargetInput },
data() {
return {
user: session.currentUser,
modes: MODES,
mode: 'query',
activeTarget: 'source',
sourceCode: '', targetCode: '', sources: [], target: null, reason: '',
resolvingSource: false, resolvingTarget: false, submitting: false,
error: null, result: null, pendingKey: ''
}
},
computed: {
sourceTitle() { return this.mode === 'query' ? '扫描托盘查询' : this.mode === 'assemble' ? '来源托盘(可连续扫描)' : '来源托盘' },
sourceHint() { return this.mode === 'assemble' ? `已添加 ${this.sources.length} 个` : '先扫描并读取当前状态' },
sourceButtonText() { return this.mode === 'assemble' ? '添加来源托盘' : '查询托盘' },
needsTarget() { return ['assemble', 'disassemble', 'swap'].includes(this.mode) },
editableLines() { return ['assemble', 'disassemble'].includes(this.mode) },
targetHint() { return this.mode === 'swap' ? '必须为空托盘' : '需与来源托盘属于同一海外仓' },
actionText() { return ({ assemble: '确认组托', disassemble: '确认拆托', swap: '确认换托', unbind: '确认解绑' })[this.mode] || '' },
confirmationTip() {
if (this.mode === 'assemble') return '把所选箱数移动到目标托盘;不同目的仓不能合托。'
if (this.mode === 'disassemble') return '把所选箱数从来源托盘拆到目标托盘。'
if (this.mode === 'swap') return '将来源托盘全部可用库存更换到空目标托盘。'
return '解除全部库存与当前实物托盘的绑定。'
},
moveLines() {
return this.sources.flatMap((source) => source.inventory.map((line) => ({
positionId: String(line.positionId),
packages: Number(line.movePackages || 0),
expectedPositionVersion: String(line.positionVersion || 0)
}))).filter((line) => line.packages > 0)
},
canSubmit() {
if (!this.sources.length || this.submitting) return false
if (this.needsTarget && !this.target) return false
if (this.editableLines && !this.moveLines.length) return false
return true
}
},
onShow() {
if (!requireSession()) return
this.user = session.currentUser
this.startScanner()
},
onHide() { this.stopScanner() },
beforeUnmount() { this.stopScanner() },
methods: {
startScanner() {
this.stopScanner()
this._stopScanner = startScanBroadcast({
onScan: (value) => this.handleHardwareScan(value),
onError: (error) => this.setError(error, '扫码广播注册失败')
})
},
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
activateTarget(name) { this.activeTarget = name },
handleSourceInput(value) {
this.sourceCode = value
if (this.mode !== 'assemble' && this.sources[0]?.palletCode !== String(value || '').trim()) this.sources = []
this.pendingKey = ''; this.result = null
},
handleTargetInput(value) {
this.targetCode = value
if (this.target?.palletCode !== String(value || '').trim()) this.target = null
this.pendingKey = ''; this.result = null
},
handleHardwareScan(value) {
console.info('[Overseas PDA pallet] scan', { target: this.activeTarget, value })
if (this.activeTarget === 'target' && this.needsTarget) { this.targetCode = value; this.resolveTarget(value); return }
this.sourceCode = value
this.resolveSource(value)
},
selectMode(mode) { if (this.mode === mode || this.submitting) return; this.mode = mode; this.resetWork() },
resetWork() {
this.sourceCode = ''; this.targetCode = ''; this.sources = []; this.target = null; this.reason = ''
this.activeTarget = 'source'; this.error = null; this.result = null; this.pendingKey = ''
},
async resolveSource(rawValue) {
const code = String(rawValue?.detail?.value ?? rawValue ?? this.sourceCode).trim()
if (!code || this.resolvingSource || this.submitting) return
this.sourceCode = code; this.resolvingSource = true; this.error = null; this.result = null
try {
const pallet = normalizedPallet(await resolvePallet(code))
if (this.mode === 'assemble') {
if (this.sources.some((item) => item.palletCode === pallet.palletCode)) throw new Error('该来源托盘已添加')
if (this.target?.palletCode === pallet.palletCode) throw new Error('来源托盘不能与目标托盘相同')
this.sources.push(pallet); this.sourceCode = ''
} else {
if (this.target?.palletCode === pallet.palletCode) this.target = null
this.sources = [pallet]
}
this.pendingKey = ''; playSuccessSound()
} catch (error) { this.setError(error, '托盘查询失败') } finally { this.resolvingSource = false }
},
async resolveTarget(rawValue) {
const code = String(rawValue?.detail?.value ?? rawValue ?? this.targetCode).trim()
if (!code || this.resolvingTarget || this.submitting) return
this.targetCode = code; this.resolvingTarget = true; this.error = null; this.result = null
try {
const pallet = normalizedPallet(await resolvePallet(code))
if (this.sources.some((item) => item.palletCode === pallet.palletCode)) throw new Error('目标托盘不能与来源托盘相同')
this.target = pallet; this.pendingKey = ''; playSuccessSound()
} catch (error) { this.setError(error, '目标托盘校验失败') } finally { this.resolvingTarget = false }
},
removeSource(code) { this.sources = this.sources.filter((item) => item.palletCode !== code); this.pendingKey = '' },
updateMovePackages(palletCode, positionId, rawValue) {
const source = this.sources.find((item) => item.palletCode === palletCode)
const line = source?.inventory.find((item) => String(item.positionId) === String(positionId))
if (!line) return
const count = Math.max(0, Math.min(Number(line.availablePackages || 0), Math.trunc(Number(rawValue || 0))))
line.movePackages = Number.isFinite(count) ? count : 0; this.pendingKey = ''
},
lockedPackages(line) { return Number(line.holdPackages || 0) + Number(line.allocatedPackages || 0) + Number(line.pickedPackages || 0) + Number(line.receivingPackages || 0) },
async submitOperation() {
if (!this.canSubmit) return
this.submitting = true; this.error = null; this.result = null
if (!this.pendingKey) this.pendingKey = idempotencyKey(this.mode)
try {
const source = expectation(this.sources[0])
const target = this.target ? expectation(this.target) : null
const common = { idempotencyKey: this.pendingKey, reason: this.reason.trim() || undefined }
let response
if (this.mode === 'assemble') response = await assemblePallet({ ...common, target, sources: this.sources.map(expectation), lines: this.moveLines })
else if (this.mode === 'disassemble') response = await disassemblePallet({ ...common, source, targets: [{ target, lines: this.moveLines }] })
else if (this.mode === 'swap') response = await swapPallet({ ...common, source, target })
else response = await unbindPallet({ ...common, source })
this.result = response; playSuccessSound(); this.sourceCode = ''; this.targetCode = ''; this.sources = []; this.target = null; this.reason = ''; this.pendingKey = ''
} catch (error) {
this.setError(error, '托盘作业失败')
if (isConflict(error)) {
this.error.message = `${this.error.message},请重新扫描相关托盘后再操作`
this.sources = []; this.target = null; this.pendingKey = ''; this.activeTarget = 'source'
}
} finally { this.submitting = false }
},
setError(error, fallback) {
this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }
console.error('[Overseas PDA pallet] failed', this.error); playErrorSound()
},
statusLabel(status) { return ({ IDLE: '空闲', OCCUPIED: '已占用', LOCKED: '已锁定', SCRAPPED: '已报废' })[status] || status || '状态未知' },
operationLabel(type) { return ({ PALLET_ASSEMBLE: '组托', PALLET_DISASSEMBLE: '拆托', PALLET_SWAP: '换托', PALLET_UNBIND: '解绑' })[type] || type || '托盘作业' }
}
}
</script>
<style scoped>
.pallet-page { height: 100vh; overflow: hidden; }
.pallet-scroll { height: calc(100vh - 48px - var(--status-bar-height, 0px)); }
.pallet-content { padding-bottom: 40px; }
.pallet-heading { margin: 4px 2px 12px; }
.pallet-title, .pallet-subtitle, .pallet-code, .pallet-meta, .pallet-destination, .inventory-name, .inventory-meta, .inventory-warning, .pallet-scan-tip, .pallet-confirm-tip, .pallet-error text, .pallet-success text { display: block; }
.pallet-title { color: #14263d; font-size: 21px; font-weight: 800; }
.pallet-subtitle { margin-top: 4px; color: #708197; font-size: 11px; line-height: 17px; }
.pallet-mode-scroll { width: 100%; margin-bottom: 10px; white-space: nowrap; }
.pallet-modes { display: inline-flex; gap: 6px; min-width: 100%; }
.pallet-mode { box-sizing: border-box; min-width: 68px; height: 36px; margin: 0; padding: 0 14px; border-radius: 18px; color: #5f7085; font-size: 12px; line-height: 36px; background: #e8edf4; }
.pallet-mode.active { color: #fff; font-weight: 700; background: #135ee8; }
.pallet-stack { display: flex; flex-direction: column; gap: 10px; }
.pallet-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); }
.pallet-card-head, .pallet-summary-head, .inventory-line, .target-summary { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.pallet-card-head { margin-bottom: 10px; color: #17314f; font-size: 13px; font-weight: 800; }
.pallet-card-hint { color: #7a899d; font-size: 10px; font-weight: 500; }
.pallet-card-title { display: block; margin-bottom: 9px; color: #17314f; font-size: 13px; font-weight: 800; }
.pallet-secondary, .pallet-primary { box-sizing: border-box; width: 100%; height: 42px; margin: 9px 0 0; border-radius: 8px; font-size: 13px; line-height: 42px; }
.pallet-secondary { border: 1px solid #9dc0f4; color: #135ee8; background: #f8fbff; }
.pallet-primary { color: #fff; font-weight: 800; background: #135ee8; }
.pallet-secondary[disabled], .pallet-primary[disabled] { color: #9aa7b5; background: #e8edf3; opacity: 1; }
.pallet-scan-tip, .pallet-confirm-tip { margin-top: 8px; color: #718095; font-size: 10px; line-height: 16px; }
.pallet-result-card { margin-bottom: 0; }
.pallet-summary-head { align-items: flex-start; }
.pallet-code { color: #142c48; font-size: 16px; font-weight: 800; }
.pallet-meta { margin-top: 3px; color: #718095; font-size: 10px; }
.pallet-summary-side { display: flex; align-items: center; gap: 8px; }
.pallet-packages { color: #135ee8; font-size: 13px; font-weight: 800; }
.pallet-remove { height: 28px; margin: 0; padding: 0 9px; border-radius: 6px; color: #c52b2b; font-size: 10px; line-height: 28px; background: #fff0f0; }
.pallet-destination { margin-top: 8px; padding: 6px 8px; border-radius: 6px; color: #7a4b00; font-size: 10px; background: #fff4dc; }
.inventory-list { margin-top: 10px; border-top: 1px solid #e5ebf2; }
.inventory-line { min-height: 58px; padding: 8px 0; border-bottom: 1px solid #edf1f5; }
.inventory-line:last-child { border-bottom: 0; }
.inventory-copy { min-width: 0; flex: 1; }
.inventory-name { overflow: hidden; color: #243951; font-size: 12px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
.inventory-meta { margin-top: 3px; color: #718095; font-size: 10px; }
.inventory-warning { margin-top: 3px; color: #ad6700; font-size: 9px; }
.inventory-count { display: flex; align-items: center; gap: 4px; color: #5f7085; font-size: 10px; }
.inventory-count input { box-sizing: border-box; width: 62px; height: 36px; padding: 0 8px; border: 1px solid #9dc0f4; border-radius: 7px; color: #17314f; font-size: 14px; text-align: center; background: #f8fbff; }
.pallet-empty-inline { margin-top: 10px; padding: 12px; border-radius: 7px; color: #7a899d; font-size: 11px; text-align: center; background: #f5f7fa; }
.target-summary { margin-top: 10px; padding: 9px 10px; border-radius: 8px; color: #65768b; font-size: 10px; background: #edf5ff; }
.pallet-reason { box-sizing: border-box; width: 100%; height: 70px; padding: 9px 10px; border: 1px solid #d3dde9; border-radius: 8px; color: #27384d; font-size: 12px; background: #fbfcfe; }
.pallet-error, .pallet-success { margin-bottom: 10px; padding: 12px; border-radius: 10px; font-size: 10px; line-height: 17px; }
.pallet-error { color: #9d2323; background: #fff0f0; }
.pallet-error-title, .pallet-success-title { font-size: 12px; font-weight: 800; }
.pallet-success { color: #176b42; background: #eaf8f1; }
.pallet-success .pallet-secondary { background: #fff; }
</style>
+2 -2
View File
@@ -3,7 +3,7 @@ import { OVERSEAS_API_ROOT } from '../config/runtime.js'
import { resetReceivingSession } from './receiving.js'
const SESSION_KEY = 'overseas-pda-auth-session'
export const PDA_ROLE = 'WAREHOUSE_PICKER'
export const PDA_ROLE = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR'
export const PDA_AUTHORITY = 'lastleg:pda:operate'
export const session = reactive({ currentUser: null, token: '', domesticToken: '', refreshToken: '', tenantId: '', apiBase: OVERSEAS_API_ROOT })
@@ -61,7 +61,7 @@ export function validatePdaTokenPair(accessToken, overseasAccessToken) {
}
const warehouseId = claimId(overseas, 'overseas_warehouse_id')
if (!/^\d+$/.test(warehouseId) || warehouseId === '0') throw new Error('海外PDA账号未绑定有效作业仓库')
if (!includesClaim(overseas.roles, PDA_ROLE)) throw new Error('当前账号不是海外仓库拣货员')
if (!includesClaim(overseas.roles, PDA_ROLE)) throw new Error('当前账号不是海外仓 PDA 作业员')
if (!includesClaim(overseas.authorities, PDA_AUTHORITY)) throw new Error(`当前账号缺少${PDA_AUTHORITY}权限`)
return { domestic, overseas, warehouseId }
}
+1 -4
View File
@@ -763,7 +763,7 @@
.home-operation-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.home-operation { position: relative; display: flex; box-sizing: border-box; width: 100%; min-width: 0; height: 108px; padding: 9px 4px 8px; align-items: center; flex-direction: column; justify-content: center; border: 1px solid #dce3eb; border-radius: 12px; color: #334155; text-align: center; background: #ffffff; box-shadow: 0 3px 10px rgba(35, 62, 98, 0.06); }
.home-operation:active { border-color: #9bbcf2; background: #edf4ff; }
.home-operation-primary { height: 88px; padding: 12px 16px; grid-column: 1 / -1; flex-direction: row; justify-content: flex-start; gap: 12px; border-color: #8cb5f5; border-radius: 14px; color: #135ee8; text-align: left; background: linear-gradient(145deg, #f7faff, #eaf2ff); box-shadow: 0 4px 12px rgba(19, 94, 232, 0.1); }
.home-operation-primary { border-color: #8cb5f5; color: #135ee8; background: linear-gradient(145deg, #f7faff, #eaf2ff); box-shadow: 0 4px 12px rgba(19, 94, 232, 0.1); }
.home-operation-disabled, .home-operation-disabled[disabled] { border-color: #e1e6ec; color: #8b97a5; opacity: 1; background: #f1f3f6; }
.home-operation-disabled:active { border-color: #e1e6ec; background: #f1f3f6; }
.home-operation-icon { display: flex; width: 40px; height: 40px; flex-shrink: 0; align-items: center; justify-content: center; border-radius: 10px; font-size: 20px; font-weight: 700; background: rgba(255, 255, 255, 0.75); }
@@ -771,9 +771,6 @@
.home-operation-pending, .home-operation-unavailable { width: fit-content; margin-top: 3px; padding: 1px 6px; border-radius: 99px; font-size: 9px; line-height: 14px; }
.home-operation-pending { color: #135ee8; background: #dceaff; }
.home-operation-unavailable { color: #7d8997; background: #e5e9ee; }
.home-operation-primary .home-operation-icon { width: 46px; height: 46px; }
.home-operation-primary .home-operation-name { width: auto; min-width: 0; min-height: 0; margin-top: 0; flex: 1; font-size: 15px; line-height: 21px; text-align: left; }
.home-operation-primary .home-operation-pending { flex-shrink: 0; margin-top: 0; padding: 3px 8px; font-size: 10px; line-height: 16px; }
.header-menu-sheet { box-sizing: border-box; width: 100vw; max-width: 480px; }
.header-menu-content { padding-top: 1px; }
+40
View File
@@ -0,0 +1,40 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
test('pallet management is enabled through its dedicated real PDA route', async () => {
const [workbench, pages, home, module] = await Promise.all([
read('../src/data/overseas-workbench.js'), read('../src/pages.json'),
read('../src/pages/home/home.vue'), read('../src/pages/module/module.vue')
])
assert.match(workbench, /key: 'pallet'[\s\S]*available: true[\s\S]*route: '\/pages\/pallet\/pallet'/)
assert.match(pages, /pages\/pallet\/pallet/)
assert.match(home, /operation\.route \|\|/)
assert.match(module, /operation\.route \|\|/)
})
test('pallet API maps only the frozen overseas PDA operation endpoints', async () => {
const api = await read('../src/api/pallet.js')
for (const path of ['pallets/resolve', 'assemble', 'disassemble', 'swap', 'unbind']) {
assert.match(api, new RegExp(path.replace('/', '\\/')))
}
assert.match(api, /overseasRequest/)
for (const forbidden of ['customerId', 'warehouseId', 'locationId']) assert.doesNotMatch(api, new RegExp(forbidden))
})
test('pallet page preserves versions, idempotency and scan diagnostics for real writes', async () => {
const page = await read('../src/pages/pallet/pallet.vue')
for (const action of ['assemblePallet', 'disassemblePallet', 'swapPallet', 'unbindPallet', 'resolvePallet']) assert.match(page, new RegExp(action))
assert.match(page, /startScanBroadcast/)
assert.match(page, /activeTarget === 'source'/)
assert.match(page, /activeTarget === 'target'/)
assert.match(page, /expectedVersion: pallet\.palletVersion/)
assert.match(page, /expectedPositionVersion: String\(line\.positionVersion/)
assert.match(page, /idempotencyKey: this\.pendingKey/)
assert.match(page, /requestId[\s\S]*traceId/)
assert.match(page, /isConflict\(error\)[\s\S]*请重新扫描相关托盘[\s\S]*this\.sources = \[\]; this\.target = null/)
assert.match(page, /不同目的仓不能合托/)
assert.doesNotMatch(page, /模拟成功|customerId:|warehouseId:|locationId:/)
})
+9 -4
View File
@@ -21,6 +21,7 @@ test('overseas PDA is an independent Vue 3 app with separate packaging identity'
assert.match(vite, /removeHeader\('referer'\)/)
assert.match(styles, /home-operation-grid[^}]*grid-template-columns:\s*repeat\(3/)
assert.match(styles, /home-operation[^}]*width:\s*100%/)
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 () => {
@@ -79,14 +80,18 @@ test('login has no committed demo credentials and inbound operation implements t
assert.match(overseas, /requestId/)
})
test('unimplemented warehouse operations cannot submit prototype scans', async () => {
const [operation, home, module] = await Promise.all([
read('../src/pages/operation/operation.vue'), read('../src/pages/home/home.vue'), read('../src/pages/module/module.vue')
test('remaining unimplemented warehouse operations cannot submit prototype scans', async () => {
const [operation, home, module, workbench] = await Promise.all([
read('../src/pages/operation/operation.vue'), read('../src/pages/home/home.vue'),
read('../src/pages/module/module.vue'), read('../src/data/overseas-workbench.js')
])
assert.doesNotMatch(operation, /prototypeScan|prototypeInput/)
assert.match(operation, /功能暂未开放|扫码不会提交/)
assert.match(home, /暂未开放/)
assert.match(module, /当前仅开放拆柜收货,其他作业暂未开放/)
assert.match(module, /灰色功能尚未开放/)
for (const key of ['stocktake', 'move', 'relabelReceive', 'inventoryQuery', 'locationQuery', 'palletQuery']) {
assert.match(workbench, new RegExp(`key: '${key}'[^\\n]*available: false`))
}
})
test('receiving phases use a compiled container summary with cabinet and aggregate facts', async () => {
+6 -2
View File
@@ -5,9 +5,13 @@ import { MODULES } from '../src/data/overseas-workbench.js'
test('only operations backed by a dedicated overseas PDA contract are enabled', () => {
const operations = MODULES.flatMap((module) => module.operations.map((operation) => ({ module: module.key, ...operation })))
const enabled = operations.filter((operation) => operation.available)
assert.deepEqual(enabled.map(({ module, name }) => ({ module, name })), [{ module: 'inbound', name: '拆柜收货' }])
assert.deepEqual(enabled.map(({ module, name }) => ({ module, name })), [
{ module: 'inbound', name: '拆柜收货' },
{ module: 'inventory', name: '托盘管理' }
])
assert.equal(operations.find((item) => item.name === '托盘管理')?.route, '/pages/pallet/pallet')
const pendingNames = ['库存盘点', '商品移库', '托盘管理', '换标收货', '换标执行', '换标复核', '库存查询', '库位查询', '托盘查询']
const pendingNames = ['库存盘点', '商品移库', '换标收货', '换标执行', '换标复核', '库存查询', '库位查询', '托盘查询']
for (const name of pendingNames) {
const operation = operations.find((item) => item.name === name)
assert.equal(operation?.available, false, `${name} must fail closed until its PDA API exists`)