This commit is contained in:
2026-08-25 10:39:50 +08:00
parent f2b9d48268
commit 3d91aa1667
12 changed files with 162 additions and 73 deletions
+3 -3
View File
@@ -27,7 +27,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 按 SKU 与箱数收货、OVERSEAS 逐箱收货并逐入仓码核平;其他库内、换标与查询模块仍是静态原型,不得将原型提示改成模拟远程成功。
- 当前通用作业页中的入仓作业已接柜号级真实海外 API:账号锁柜、三类照片签收、FBA 按 SKU 与箱数收货、OVERSEAS 逐箱收货并逐入仓码核平;库内、换标与查询入口在海外 PDA 专用 Controller、权限和 Token 冻结仓库范围契约齐备前必须禁用,不得调用平台/客户/内部接口或模拟成功。
## 页面与能力快速索引
@@ -45,7 +45,7 @@
- `src/api/inbound.js` 已接入待卸柜候选、柜详情/prepare、current 恢复、三类照片签收、入仓码下 SKU/箱唛解析、实物托盘解析、FBA bulk/OVERSEAS carton 收货及整柜完成;响应与 404/409 判定集中在 `src/api/receiving-adapter.js`,双 Token 刷新与海外 401 单次重放由 `src/api/overseas.js` 统一处理。
- 请求身份和资源范围只取 Token 与 current 会话;前端不得提交 `customerId`、`planNo`、`allocationId`、`warehouseId` 或 `locationId`。409 必须引导账号恢复当前柜,完成前不得切柜。
- 库内、换标与现场查询仍是静态原型;新增真实作业必须建立明确的业务 API/DTO、错误态和契约测试,不得把原型任务改成模拟成功。
- 库内、换标与现场查询只有视觉原型依据,当前工作台入口保持禁用;新增真实作业必须建立 `/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 页面入口,也不得加入工作台。
@@ -53,6 +53,6 @@
## 质量门禁
- 安装:`npm install`;检查:`npm test`。
- H5 生产编译:`npm run build:h5`;App 资源编译:`npm run build:app`。
- 正式产品形态是海外 PDA App;`npm run build:h5` 仅用于浏览器调试和模板编译门禁,App 资源编译使用 `npm run build:app`。
- APK/IPA 必须在 HBuilderX 或正式原生打包环境中使用独立 AppID 与签名验证;仅通过 App 资源编译不得声称 APK 真机验收完成。
- 不提交 `node_modules/`、`dist/`、`unpackage/`、环境文件、签名证书或打包产物。
+4 -4
View File
@@ -1,6 +1,6 @@
# 柏悦物流海外版 PDA
独立的 uni-app Vue 3 工程,面向海外仓现场人员。工程不依赖国内 PDA 源码或仓库外共享目录,可独立安装依赖、运行、编译和发布。
独立的 uni-app Vue 3 App 工程,正式产品形态是安装在海外仓手持 PDA 上的 App。工程不依赖国内 PDA 源码或仓库外共享目录,可独立安装依赖、运行、编译和发布;H5 只用于浏览器调试和编译门禁,不是最终交付形态。
首版工作台按四期边界提供柜号级真实入仓作业,以及库内作业、换标作业和现场查询原型。登录使用国内 Auth 统一签发的双 Token;海外入仓请求统一经 `gateway-overseas`,不得请求国内 Firstleg/Warehouse 业务接口。
@@ -21,8 +21,8 @@ npm run build:h5
npm run build:app
```
- `build:h5` 生成 H5 发布文件;
- `build:app` 生成 App 离线打包资源;
- `build:h5` 生成浏览器调试资源,用于页面和模板编译检查;
- `build:app` 生成正式 App 的离线打包资源;
- 生成可安装 APK/IPA 需使用 HBuilderX App 开发版执行云打包或配置原生离线打包环境。
## 正式 App 打包前必填
@@ -40,5 +40,5 @@ npm run build:app
- 入仓页面已接待卸柜候选、柜详情与账号锁柜、三类角色照片签收、current 恢复、SKU/箱唛与既有物理托盘解析、FBA 按 SKU 和箱数收货、OVERSEAS 逐箱收货、逐入仓码核平及整柜完成的真实 API;409 会引导返回当前柜,错误会保留业务码、requestId 与 traceId。
- 入仓请求不手填客户、计划、分配、仓库或库位标识;身份与当前柜范围由 Token、Gateway 和服务端会话确定。页面同时支持 PDA 广播扫码与 H5 人工输入。
- 登录、恢复和刷新会 fail-closed 校验国内/海外双 Token 的同一平台操作主体、`WAREHOUSE_PICKER`、`lastleg:pda:operate` 及同一冻结海外仓;并发海外 401 共享一次国内 refresh,校验完成后原子替换双 Token并将原请求或照片上传重放一次。
- 库内、换标和现场查询仍是可运行的静态原型,不会模拟海外服务提交成功。
- 库存盘点、商品移库、托盘管理、换标收货/执行/复核以及库存/库位/托盘查询当前缺少海外 PDA 专用 Controller、权限和 Token 冻结仓库范围契约,入口已禁用;不会调用平台、客户或内部接口,也不会模拟成功。
- 本地契约测试与 H5/App 资源编译不等于真实开发环境 HTTP 或 PDA 真机验收。
+9 -9
View File
@@ -3,19 +3,19 @@ export const MODULES = [
{ name: '拆柜收货', icon: '卸', iconType: 'download', description: '三类照片签收,FBA 按 SKU 箱数、海外仓逐箱收货', available: true, badge: '真实接口' }
] },
{ key: 'inventory', name: '库内作业', icon: '库', description: '盘点、移库与托盘管理', operations: [
{ name: '库存盘点', icon: '盘', iconType: 'list', description: '按任务扫描库位和库存', available: true, badge: '原型' },
{ name: '商品移库', icon: '移', iconType: 'loop', description: '扫描来源与目标库位完成移库', available: true, badge: '原型' },
{ name: '托盘管理', icon: '托', iconType: 'grid', description: '组托、拆托及托盘状态核对', available: true, badge: '原型' }
{ name: '库存盘点', icon: '盘', iconType: 'list', description: '按任务扫描库位和库存', available: false, badge: '待 PDA 接口' },
{ name: '商品移库', icon: '移', iconType: 'loop', description: '扫描来源与目标库位完成移库', available: false, badge: '待 PDA 接口' },
{ name: '托盘管理', icon: '托', iconType: 'grid', description: '组托、拆托及托盘状态核对', available: false, badge: '待 PDA 接口' }
] },
{ key: 'relabel', name: '换标作业', icon: '换', description: '换标收货、执行与复核', operations: [
{ name: '换标收货', icon: '收', iconType: 'download', description: '扫描换标单并确认待处理库存', available: true, badge: '原型' },
{ name: '换标执行', icon: '换', iconType: 'compose', description: '扫描旧标和新标记录换标结果', available: true, badge: '原型' },
{ name: '换标复核', icon: '核', iconType: 'checkbox', description: '复核标签和商品关联关系', available: true, badge: '原型' }
{ name: '换标收货', icon: '收', iconType: 'download', description: '扫描换标单并确认待处理库存', available: false, badge: '待 PDA 接口' },
{ name: '换标执行', icon: '换', iconType: 'compose', description: '扫描旧标和新标记录换标结果', available: false, badge: '待 PDA 接口' },
{ name: '换标复核', icon: '核', iconType: 'checkbox', description: '复核标签和商品关联关系', available: false, badge: '待 PDA 接口' }
] },
{ key: 'query', name: '现场查询', icon: '查', description: '库存、库位与托盘查询', operations: [
{ name: '库存查询', icon: '存', iconType: 'search', description: '扫描商品或箱码查询可用库存', available: true, badge: '原型' },
{ name: '库位查询', icon: '位', iconType: 'location', description: '扫描库位查看容量和在库明细', available: true, badge: '原型' },
{ name: '托盘查询', icon: '托', iconType: 'flag', description: '扫描托盘码查看绑定库存', available: true, badge: '原型' }
{ name: '库存查询', icon: '存', iconType: 'search', description: '扫描商品或箱码查询可用库存', available: false, badge: '待 PDA 接口' },
{ name: '库位查询', icon: '位', iconType: 'location', description: '扫描库位查看容量和在库明细', available: false, badge: '待 PDA 接口' },
{ name: '托盘查询', icon: '托', iconType: 'flag', description: '扫描托盘码查看绑定库存', available: false, badge: '待 PDA 接口' }
] }
]
+70 -45
View File
@@ -1,49 +1,74 @@
{
"name": "柏悦物流海外版",
"appid": "",
"description": "柏悦物流海外仓 PDA 作业端",
"versionName": "0.1.0",
"versionCode": "10",
"transformPx": false,
"app-plus": {
"usingComponents": true,
"nvueCompiler": "uni-app",
"nvueStyleCompiler": "uni-app",
"compilerVersion": 3,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
},
"modules": {},
"distribute": {
"android": {
"packagename": "com.baiyuelogistics.pda.overseas",
"permissions": [],
"abiFilters": ["armeabi-v7a", "arm64-v8a"]
},
"ios": {},
"sdkConfigs": {},
"icons": {
"android": {
"hdpi": "static/app-icons/72x72.png",
"xhdpi": "static/app-icons/96x96.png",
"xxhdpi": "static/app-icons/144x144.png",
"xxxhdpi": "static/app-icons/192x192.png"
"name" : "柏悦物流 PDA 海外版",
"appid" : "__UNI__936D079",
"description" : "柏悦物流海外仓 PDA 作业端",
"versionName" : "0.0.1",
"versionCode" : 1,
"transformPx" : false,
"app-plus" : {
"usingComponents" : true,
"nvueCompiler" : "uni-app",
"nvueStyleCompiler" : "uni-app",
"compilerVersion" : 3,
"splashscreen" : {
"alwaysShowBeforeRender" : true,
"waiting" : true,
"autoclose" : true,
"delay" : 0
},
"ios": {
"appstore": "static/app-icons/1024x1024.png"
"modules" : {},
"distribute" : {
"android" : {
"packagename" : "com.baiyuelogistics.pda.overseas",
"permissions" : [],
"abiFilters" : [ "armeabi-v7a", "arm64-v8a" ]
},
"ios" : {},
"sdkConfigs" : {},
"icons" : {
"android" : {
"hdpi" : "static/app-icons/72x72.png",
"xhdpi" : "static/app-icons/96x96.png",
"xxhdpi" : "static/app-icons/144x144.png",
"xxxhdpi" : "static/app-icons/192x192.png"
},
"ios" : {
"appstore" : "static/app-icons/1024x1024.png",
"ipad" : {
"app" : "static/app-icons/76x76.png",
"app@2x" : "static/app-icons/152x152.png",
"notification" : "static/app-icons/20x20.png",
"notification@2x" : "static/app-icons/40x40.png",
"proapp@2x" : "static/app-icons/167x167.png",
"settings" : "static/app-icons/29x29.png",
"settings@2x" : "static/app-icons/58x58.png",
"spotlight" : "static/app-icons/40x40.png",
"spotlight@2x" : "static/app-icons/80x80.png"
},
"iphone" : {
"app@2x" : "static/app-icons/120x120.png",
"app@3x" : "static/app-icons/180x180.png",
"notification@2x" : "static/app-icons/40x40.png",
"notification@3x" : "static/app-icons/60x60.png",
"settings@2x" : "static/app-icons/58x58.png",
"settings@3x" : "static/app-icons/87x87.png",
"spotlight@2x" : "static/app-icons/80x80.png",
"spotlight@3x" : "static/app-icons/120x120.png"
}
}
}
}
}
}
},
"quickapp": {},
"mp-weixin": {
"appid": "",
"setting": { "urlCheck": false },
"usingComponents": true
},
"uniStatistics": { "enable": false },
"vueVersion": "3"
},
"quickapp" : {},
"mp-weixin" : {
"appid" : "",
"setting" : {
"urlCheck" : false
},
"usingComponents" : true
},
"uniStatistics" : {
"enable" : false
},
"vueVersion" : "3"
}
+1 -1
View File
@@ -9,7 +9,7 @@
<view class="home-operation-icon" aria-hidden="true"><uni-icons :type="operation.iconType" :color="operation.available ? '#135ee8' : '#9aa6b2'" size="28" /></view>
<text class="home-operation-name">{{ operation.name }}</text>
<text v-if="operation.available" class="home-operation-pending">{{ operation.badge }}</text>
<text v-else class="home-operation-unavailable">未开放</text>
<text v-else class="home-operation-unavailable">{{ operation.badge || '未开放' }}</text>
</button>
</view>
</view>
+2 -2
View File
@@ -4,8 +4,8 @@
<view class="portal-content">
<view class="module-hero" :class="`module-hero-${currentModule.key}`"><view class="module-hero-icon">{{ currentModule.icon }}</view><view><text class="module-hero-title">{{ currentModule.name }}</text><text class="module-hero-desc">{{ currentModule.description }}</text></view></view>
<text class="group-title">业务功能</text>
<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-prototype">{{ operation.badge }}</text><text v-else class="operation-prototype">未开放</text><text v-if="operation.available" class="operation-arrow">›</text></button></view>
<view class="module-empty-note">拆柜收货已经海外网关接入真实接口;标记“原型”的库内与换标功能不会模拟提交成功。</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 class="operation-prototype">{{ operation.badge || '未开放' }}</text><text v-if="operation.available" class="operation-arrow">›</text></button></view>
<view class="module-empty-note">只有拆柜收货已接海外 PDA 真实接口;其余入口在专用接口、权限与仓库范围契约完成前保持禁用。</view>
</view>
</view>
</template>
File diff suppressed because one or more lines are too long
+6 -1
View File
@@ -1,5 +1,5 @@
import { reactive } from 'vue'
import { RECEIVING_PHOTO_ROLES } from '@/api/receiving-adapter.js'
import { RECEIVING_PHOTO_ROLES } from '../api/receiving-adapter.js'
export const receiving = reactive({
candidates: [], selected: null, current: null, photoSlots: [null, null, null],
@@ -53,6 +53,11 @@ export function resetReceivingDraft({ keepCurrent = false } = {}) {
idempotencyKeys.clear()
}
export function resetReceivingSession() {
receiving.candidates = []
resetReceivingDraft()
}
export function idempotencyKey(action, payload = {}) {
const signature = `${action}:${JSON.stringify(payload)}`
if (!idempotencyKeys.has(signature)) idempotencyKeys.set(signature, `pda-${Date.now()}-${Math.random().toString(16).slice(2)}`)
+2
View File
@@ -1,5 +1,6 @@
import { reactive } from 'vue'
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'
@@ -158,6 +159,7 @@ export function signOut() {
session.tenantId = ''
session.apiBase = OVERSEAS_API_ROOT
if (typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(SESSION_KEY)
resetReceivingSession()
}
export function requireSession() {
+11 -1
View File
@@ -8,7 +8,7 @@ test('overseas PDA is an independent Vue 3 app with separate packaging identity'
const [manifest, pages, pkg] = await Promise.all([
read('../src/manifest.json'), read('../src/pages.json'), read('../package.json')
])
assert.match(manifest, /"name"\s*:\s*"柏悦物流海外版"/)
assert.match(manifest, /"name"\s*:\s*"柏悦物流\s*PDA\s*海外版"/)
assert.match(manifest, /"packagename"\s*:\s*"com\.baiyuelogistics\.pda\.overseas"/)
assert.match(manifest, /"vueVersion"\s*:\s*"3"/)
assert.doesNotMatch(pages, /pages\/picking|pages\/tasks/)
@@ -41,3 +41,13 @@ test('login has no committed demo credentials and inbound operation implements t
assert.match(overseas, /replaceTokens|refreshOverseasToken/)
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')
])
assert.doesNotMatch(operation, /prototypeScan|prototypeInput/)
assert.match(operation, /功能暂未开放|扫码不会提交/)
assert.match(home, /operation\.badge/)
assert.match(module, /专用接口、权限与仓库范围契约完成前保持禁用/)
})
+31
View File
@@ -5,6 +5,7 @@ import {
PDA_AUTHORITY, PDA_ROLE, replaceTokens, restoreSession, session,
decodeAccessToken, signInWithTokens, signOut, validatePdaTokenPair
} from '../src/stores/session.js'
import { receiving } from '../src/stores/receiving.js'
const future = () => Math.floor(Date.now() / 1000) + 3600
const jwt = (claims) => [
@@ -114,3 +115,33 @@ test('restore rebuilds identity from claims and removes expired or tampered stor
assert.equal(restoreSession(), false)
assert.equal(values.has('overseas-pda-auth-session'), false)
})
test('sign out clears tokens, frozen warehouse identity, and every receiving draft', () => {
const values = storage(); signOut()
signInWithTokens(tokenPair())
receiving.candidates = [{ containerNo: 'CONT-1' }]
receiving.current = { containerNo: 'CONT-1', status: 'RECEIVING' }
receiving.selected = receiving.current
receiving.photoSlots = [{ fileId: '1' }, null, null]
receiving.selectedInboundCode = 'IN-1'
receiving.resolvedCarton = { cartonMark: 'BOX-1' }
receiving.resolvedSku = { skuCode: 'SKU-1' }
receiving.resolvedPallet = { palletCode: 'PALLET-1' }
receiving.lastReceipt = { acceptedPackages: 1 }
signOut()
assert.equal(session.currentUser, null)
assert.equal(session.token, '')
assert.equal(session.domesticToken, '')
assert.equal(session.refreshToken, '')
assert.equal(values.has('overseas-pda-auth-session'), false)
assert.deepEqual(receiving.candidates, [])
assert.equal(receiving.current, null)
assert.equal(receiving.selected, null)
assert.deepEqual(receiving.photoSlots, [null, null, null])
assert.equal(receiving.selectedInboundCode, '')
assert.equal(receiving.resolvedCarton, null)
assert.equal(receiving.resolvedSku, null)
assert.equal(receiving.resolvedPallet, null)
assert.equal(receiving.lastReceipt, null)
})
+17
View File
@@ -0,0 +1,17 @@
import test from 'node:test'
import assert from 'node:assert/strict'
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: '拆柜收货' }])
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`)
assert.equal(operation?.badge, '待 PDA 接口')
}
})