From f960b9fe5f607dc91c13f4314b2818a7982d6895 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Fri, 4 Sep 2026 12:42:17 +0800
Subject: [PATCH] feat: complete overseas PDA warehouse operations
---
AGENTS.md | 19 ++--
README.md | 6 +-
src/api/overseas.js | 4 +-
src/api/relabel.js | 44 +++++++++
src/api/warehouse-operations.js | 70 ++++++++++++++
src/data/overseas-workbench.js | 47 ++-------
src/pages.json | 4 +
src/pages/move/move.vue | 65 +++++++++++++
src/pages/query/query.vue | 55 +++++++++++
src/pages/relabel/relabel.vue | 105 ++++++++++++++++++++
src/pages/stocktake/stocktake.vue | 80 ++++++++++++++++
src/pages/warehouse/operations.scss | 1 +
src/stores/session.js | 2 +
src/stores/warehouse-work.js | 74 ++++++++++++++
tests/business-identifiers-ui.test.mjs | 10 +-
tests/project-contract.test.mjs | 14 +--
tests/warehouse-workflows.test.mjs | 127 +++++++++++++++++++++++++
tests/workbench-availability.test.mjs | 17 +++-
18 files changed, 678 insertions(+), 66 deletions(-)
create mode 100644 src/api/relabel.js
create mode 100644 src/api/warehouse-operations.js
create mode 100644 src/pages/move/move.vue
create mode 100644 src/pages/query/query.vue
create mode 100644 src/pages/relabel/relabel.vue
create mode 100644 src/pages/stocktake/stocktake.vue
create mode 100644 src/pages/warehouse/operations.scss
create mode 100644 src/stores/warehouse-work.js
create mode 100644 tests/warehouse-workflows.test.mjs
diff --git a/AGENTS.md b/AGENTS.md
index d0a0095..e499646 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,13 +4,13 @@
1. 先用“页面与能力快速索引”定位 `src/pages.json`、工作台或最小页面;页面业务配置从 `src/data/overseas-workbench.js` 进入,不扫描 `src/uni_modules/`。
2. 登录、Token 或双区域请求先读 `src/api/auth.js`、`src/stores/session.js`、`src/config/runtime.js` 和 `../../docs/跨区域认证与服务通信契约.md`。
-3. 海外入仓、库存或仓储资源联调先读 `../../docs/四期/README.md`、`../../baiyue-firstleg/lastleg-service/AGENTS.md`、`warehouse-overseas-service/AGENTS.md` 与 `gateway-overseas/AGENTS.md`。
+3. 海外入仓、库存或仓储资源联调先读 `../../docs/海外仓业务/README.md`、`../../baiyue-firstleg/lastleg-service/AGENTS.md`、`warehouse-overseas-service/AGENTS.md` 与 `gateway-overseas/AGENTS.md`。
## 项目定位
- 本项目是“柏悦物流海外版”独立 uni-app Vue 3 工程,面向海外仓现场人员。
- 工程必须独立安装、构建和发布,不得依赖 `apps/pda-uniapp/` 或仓库外 `pda-shared` 目录。
-- 当前设备端业务范围包括四期入仓、库内、换标、库存/库位/托盘查询,以及五期排车拣货、普通/一件代发出库任务、任务完成和发车;不包含派送、客户回访,也不包含国内集装箱拣货/装柜。
+- 当前设备端业务范围包括海外仓入仓、库内、换标、库存/库位/托盘查询,以及排车拣货、普通/一件代发出库任务、任务完成和发车;派送与客户回访由 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 逐箱收货并逐入仓码核平;库内盘点、移库、托盘管理、换标收货/执行/复核以及库存/库位/托盘查询均已接入专用 PDA API。
## 页面与能力快速索引
@@ -42,9 +42,12 @@
| 中英西语言设置 | `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` | 任务列表/详情、托盘业务码扫码拣货、幂等完成;发车必须同时完成拣货核平、平台转派送并严格消费服务端 `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/**`;派送不进入 PDA |
-| 其余库内、换标与现场查询待接前端 | `src/data/overseas-workbench.js` 中对应 `available: false` 入口 | Backend 已有 `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`relabel/pda/` 与 `overseas-pda-operations-v1.yaml`;本应用新增专用 API/状态恢复和测试前不得启用 |
+| 库存盘点真实作业 | `src/pages/stocktake/stocktake.vue`、`src/api/warehouse-operations.js`、`src/stores/warehouse-work.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/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/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 |
@@ -57,10 +60,10 @@
- `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 冻结仓库范围和服务端资源归属约束;写操作必须保留稳定幂等键,409 后重新读取服务端状态,不得把平台、客户或内部接口直接暴露给 PDA。
- 已有海外入仓 PDA Controller 统一位于 Lastleg `/api/pda/v1/last-leg/inbound-plans/{planNo}/**`;浏览器或设备请求仍必须经 `gateway-overseas` 的显式 PDA 路由,不得直连 Lastleg,也不得改走国内 Gateway。
- 海外业务 Token、PDA 权限和可信身份最终由 Gateway 与服务端校验;前端不得用工作台 `available`、本地客户 ID 或仓库 ID 代替授权和资源归属校验。
-- 五期排车和出库入口只调用专用 PDA 接口;客户与冻结海外仓范围由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不把内部数据库 ID 展示成业务编号。网络失败保留草稿和幂等键,恢复后以服务端任务状态为准;排车详情返回的 `canDepart`、`departBlockReason` 是发车按钮唯一门禁,平台未转派送生成 `WAIT_OUTBOUND` 出库单时不得发车;完成或发车成功后不得重复提交。派送始终不进入 PDA。
+- 排车和出库入口只调用专用 PDA 接口;客户与冻结海外仓范围由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不把内部数据库 ID 展示成业务编号。网络失败保留草稿和幂等键,恢复后以服务端任务状态为准;排车详情返回的 `canDepart`、`departBlockReason` 是发车按钮唯一门禁,平台未转派送生成 `WAIT_OUTBOUND` 出库单时不得发车;完成或发车成功后不得重复提交。派送始终由 Admin/Client 承载。
## 质量门禁
diff --git a/README.md b/README.md
index ffa70da..6ee0a84 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
独立的 uni-app Vue 3 App 工程,正式产品形态是安装在海外仓手持 PDA 上的 App。工程不依赖国内 PDA 源码或仓库外共享目录,可独立安装依赖、运行、编译和发布;H5 只用于浏览器调试和编译门禁,不是最终交付形态。
-工作台提供柜号级真实入仓、托盘管理,以及五期排车拣货、出库任务、一件代发出库、任务完成和发车入口;派送单、派送状态与客户回访不进入 PDA。登录使用国内 Auth 统一签发的双 Token;海外业务请求统一经 `gateway-overseas`,不得请求国内 Firstleg/Warehouse 业务接口。
+工作台提供柜号级真实入仓、托盘管理、排车拣货、出库任务、一件代发出库、任务完成和发车入口;派送单、派送状态与客户回访由 Admin/Client 承载。登录使用国内 Auth 统一签发的双 Token;海外业务请求统一经 `gateway-overseas`,不得请求国内 Firstleg/Warehouse 业务接口。
## 本地运行与检查
@@ -44,10 +44,10 @@ npm run build:app
- 托盘管理已接 `/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 前端,入口保持禁用;不会调用平台、客户或内部接口,也不会模拟成功。
+- 库存盘点、商品移库、换标收货/执行/复核以及库存/库位/托盘查询均已接入 `/api/pda/**` 专用接口;支持 PDA 广播扫码与 H5 人工输入,并按服务端版本、幂等键和冲突状态恢复,不调用平台、客户或内部接口。
- 本地契约测试与 H5/App 资源编译不等于真实开发环境 HTTP 或 PDA 真机验收。
## App 在线更新
diff --git a/src/api/overseas.js b/src/api/overseas.js
index 8a6eb31..0171768 100644
--- a/src/api/overseas.js
+++ b/src/api/overseas.js
@@ -77,7 +77,7 @@ function rawUpload(path, filePath, name, token) {
name,
header: { Authorization: `Bearer ${token}` },
success: resolve,
- fail: (cause) => reject(Object.assign(new Error(cause.errMsg || '卸柜照片上传失败'), { network: true }))
+ fail: (cause) => reject(Object.assign(new Error(cause.errMsg || '海外作业文件上传失败'), { network: true }))
})
})
}
@@ -111,7 +111,7 @@ export async function overseasUpload(path, filePath, name = 'file', retry = true
response = await rawUpload(path, filePath, name, replacementToken)
try { body = JSON.parse(response.data || '{}') } catch (_) { body = null }
}
- if (!successful(response, body)) throw errorFrom({ ...response, data: body }, '卸柜照片上传失败')
+ if (!successful(response, body)) throw errorFrom({ ...response, data: body }, '海外作业文件上传失败')
return body.data
}
diff --git a/src/api/relabel.js b/src/api/relabel.js
new file mode 100644
index 0000000..1460d8d
--- /dev/null
+++ b/src/api/relabel.js
@@ -0,0 +1,44 @@
+import { overseasRequest, overseasUpload } from './overseas.js'
+
+const BASE = '/pda/v1/last-leg/relabel-tasks'
+const encoded = (value) => encodeURIComponent(String(value || '').trim())
+
+function asList(value) { return Array.isArray(value) ? value : [] }
+
+export function normalizeRelabel(value = {}) {
+ return {
+ ...value,
+ relabelNo: String(value.relabelNo || ''), customerOrderNo: String(value.customerOrderNo || ''),
+ version: Number(value.version || 0), expectedPackages: Number(value.expectedPackages || 0),
+ 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)
+ })),
+ unitFacts: asList(value.unitFacts), history: asList(value.history),
+ completionPhotoFileIds: asList(value.completionPhotoFileIds).map(String)
+ }
+}
+
+export async function listRelabelCandidates() {
+ return asList(await overseasRequest('GET', `${BASE}/candidates`)).map(normalizeRelabel)
+}
+
+export async function getRelabelTask(relabelNo) {
+ return normalizeRelabel(await overseasRequest('GET', `${BASE}/${encoded(relabelNo)}`))
+}
+
+async function detailAction(relabelNo, suffix, request) {
+ return normalizeRelabel(await overseasRequest('POST', `${BASE}/${encoded(relabelNo)}${suffix}`, request))
+}
+
+export const claimRelabelTask = (relabelNo, request) => detailAction(relabelNo, '/claim', request)
+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)
diff --git a/src/api/warehouse-operations.js b/src/api/warehouse-operations.js
new file mode 100644
index 0000000..9b01388
--- /dev/null
+++ b/src/api/warehouse-operations.js
@@ -0,0 +1,70 @@
+import { overseasRequest } from './overseas.js'
+
+const BASE = '/pda/v1/last-leg'
+const encoded = (value) => encodeURIComponent(String(value || '').trim())
+
+function asList(value) {
+ if (Array.isArray(value)) return value
+ if (Array.isArray(value?.records)) return value.records
+ return []
+}
+
+function count(value) {
+ const result = Number(value || 0)
+ return Number.isFinite(result) ? Math.max(0, result) : 0
+}
+
+export function normalizeStocktake(value) {
+ const task = value?.task || value || {}
+ return {
+ ...value,
+ task: { ...task, taskNo: String(task.taskNo || ''), scopeCode: String(task.scopeCode || ''), version: count(task.version) },
+ items: asList(value?.items).map((item) => ({
+ ...item,
+ itemId: String(item.itemId || ''), balanceId: String(item.balanceId || ''), positionId: String(item.positionId || ''),
+ palletCode: String(item.palletCode || ''), locationCode: String(item.locationCode || ''),
+ bookPackages: count(item.bookPackages), actualPackages: item.actualPackages == null ? null : count(item.actualPackages),
+ differencePackages: item.differencePackages == null ? null : Number(item.differencePackages),
+ positionVersion: count(item.positionVersion), version: count(item.version)
+ }))
+ }
+}
+
+export async function listStocktakes(status = 'IN_PROGRESS') {
+ const suffix = `?page=1&size=100${status ? `&status=${encoded(status)}` : ''}`
+ return asList(await overseasRequest('GET', `${BASE}/stocktakes${suffix}`)).map((item) => ({
+ ...item, taskNo: String(item.taskNo || ''), scopeCode: String(item.scopeCode || ''), version: count(item.version)
+ }))
+}
+
+export async function createStocktake(request) {
+ return normalizeStocktake(await overseasRequest('POST', `${BASE}/stocktakes`, request))
+}
+
+export async function getStocktake(taskNo) {
+ return normalizeStocktake(await overseasRequest('GET', `${BASE}/stocktakes/${encoded(taskNo)}`))
+}
+
+export function countStocktake(taskNo, request) {
+ return overseasRequest('POST', `${BASE}/stocktakes/${encoded(taskNo)}/counts`, request)
+}
+
+export async function submitStocktake(taskNo, request) {
+ return normalizeStocktake(await overseasRequest('POST', `${BASE}/stocktakes/${encoded(taskNo)}/submit`, request))
+}
+
+export function moveInventory(request) {
+ return overseasRequest('POST', `${BASE}/inventory-moves`, request)
+}
+
+export function resolveInventory(code) {
+ return overseasRequest('POST', `${BASE}/inventory/resolve`, { code: String(code || '').trim() })
+}
+
+export function resolveWarehouseLocation(code) {
+ return overseasRequest('POST', `${BASE}/locations/resolve`, { code: String(code || '').trim() })
+}
+
+export function resolveWarehousePallet(code) {
+ return overseasRequest('POST', `${BASE}/pallets/resolve`, { code: String(code || '').trim() })
+}
diff --git a/src/data/overseas-workbench.js b/src/data/overseas-workbench.js
index da27998..0e26b0f 100644
--- a/src/data/overseas-workbench.js
+++ b/src/data/overseas-workbench.js
@@ -7,49 +7,18 @@ export const MODULES = [
{ 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 },
+ { key: 'stocktake', name: '库存盘点', nameKey: 'modules.stocktake.name', icon: '盘', iconType: 'list', description: '按任务扫描库位和库存', descriptionKey: 'modules.stocktake.description', available: true, route: '/pages/stocktake/stocktake' },
+ { 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: false },
- { key: 'relabelExecute', name: '换标执行', nameKey: 'modules.relabelExecute.name', icon: '换', iconType: 'compose', description: '扫描旧标和新标记录换标结果', descriptionKey: 'modules.relabelExecute.description', available: false },
- { key: 'relabelReview', name: '换标复核', nameKey: 'modules.relabelReview.name', icon: '核', iconType: 'checkbox', description: '复核标签和商品关联关系', descriptionKey: 'modules.relabelReview.description', available: false }
+ { key: 'relabelReceive', name: '换标收货', nameKey: 'modules.relabelReceive.name', icon: '收', iconType: 'download', 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' }
] },
{ key: 'query', name: '现场查询', nameKey: 'modules.query.name', icon: '查', description: '库存、库位与托盘查询', descriptionKey: 'modules.query.description', operations: [
- { key: 'inventoryQuery', name: '库存查询', nameKey: 'modules.inventoryQuery.name', icon: '存', iconType: 'search', description: '扫描商品或箱码查询可用库存', descriptionKey: 'modules.inventoryQuery.description', available: false },
- { key: 'locationQuery', name: '库位查询', nameKey: 'modules.locationQuery.name', icon: '位', iconType: 'location', description: '扫描库位查看容量和在库明细', descriptionKey: 'modules.locationQuery.description', available: false },
- { key: 'palletQuery', name: '托盘查询', nameKey: 'modules.palletQuery.name', icon: '托', iconType: 'flag', description: '扫描托盘码查看绑定库存', descriptionKey: 'modules.palletQuery.description', available: false }
+ { key: 'inventoryQuery', name: '库存查询', nameKey: 'modules.inventoryQuery.name', icon: '存', iconType: 'search', description: '扫描商品或箱码查询可用库存', descriptionKey: 'modules.inventoryQuery.description', available: true, route: '/pages/query/query?op=inventoryQuery' },
+ { key: 'locationQuery', name: '库位查询', nameKey: 'modules.locationQuery.name', icon: '位', iconType: 'location', description: '扫描库位查看容量和在库明细', descriptionKey: 'modules.locationQuery.description', available: true, route: '/pages/query/query?op=locationQuery' },
+ { key: 'palletQuery', name: '托盘查询', nameKey: 'modules.palletQuery.name', icon: '托', iconType: 'flag', description: '扫描托盘码查看绑定库存', descriptionKey: 'modules.palletQuery.description', available: true, route: '/pages/query/query?op=palletQuery' }
] }
]
-
-const PROTOTYPES = {
- '到仓登记': { subtitle: '核对入仓计划与车辆到仓信息', scanLabel: '入仓计划 / 柜号', scanPlaceholder: '扫描入仓计划号或柜号', prefix: 'INB', titles: ['海外仓入仓计划', '合作仓到仓任务'], meta: '等待到仓', location: '收货月台 R-01', quantity: '12 托 · 86 箱', action: '开始登记' },
- '卸柜收货': { subtitle: '按箱或托盘确认卸柜实收', scanLabel: '箱码 / 托盘码', scanPlaceholder: '扫描箱码或托盘码', prefix: 'RCV', titles: ['海运柜卸柜收货', '转运车辆卸货'], meta: '卸柜中', location: '卸货区 U-02', quantity: '待收 68 箱', action: '继续收货' },
- '入仓上架': { subtitle: '将已收货库存上架到指定库位', scanLabel: '容器码 / 库位码', scanPlaceholder: '扫描容器或目标库位', prefix: 'PUT', titles: ['到仓库存上架', '待上架托盘任务'], meta: '待上架', location: 'S-01 → A-03-12', quantity: '8 托 · 42 箱', action: '开始上架' },
- '库存盘点': { subtitle: '按盘点任务扫描库位和库存', scanLabel: '盘点单 / 库位码', scanPlaceholder: '扫描盘点单或库位码', prefix: 'CC', titles: ['A 区循环盘点', '异常库位复盘'], meta: '动态盘点', location: '库区 A-03', quantity: '24 库位', action: '开始盘点' },
- '商品移库': { subtitle: '从来源库位移动到目标库位', scanLabel: '移库单 / 库位码', scanPlaceholder: '扫描移库单或来源库位', prefix: 'MV', titles: ['库位容量优化', '换标区库存移库'], meta: '待执行', location: 'A-05 → B-01', quantity: '6 托 · 28 箱', action: '开始移库' },
- '托盘管理': { subtitle: '核对托盘状态并执行组托或拆托', scanLabel: '托盘码', scanPlaceholder: '扫描托盘码', prefix: 'PLT', titles: ['到仓库存组托', '异常托盘拆托'], meta: '托盘作业', location: '托盘区 P-02', quantity: '6 个托盘', action: '开始处理' },
- '换标收货': { subtitle: '确认换标单与待处理库存', scanLabel: '换标单 / 箱码', scanPlaceholder: '扫描换标单号或箱码', prefix: 'RL-R', titles: ['FBA 换标收货', '退货库存换标收货'], meta: '待收货', location: '换标区 L-01', quantity: '36 箱', action: '开始收货' },
- '换标执行': { subtitle: '扫描旧标与新标记录换标结果', scanLabel: '旧标 / 新标', scanPlaceholder: '扫描商品标签', prefix: 'RL-W', titles: ['FBA 标签更换', '商品条码更正'], meta: '换标中', location: '换标台 L-03', quantity: '待换 120 件', action: '继续换标' },
- '换标复核': { subtitle: '复核标签与商品关联关系', scanLabel: '换标单 / 新标', scanPlaceholder: '扫描换标单或新标签', prefix: 'RL-C', titles: ['换标结果复核', '异常标签复核'], meta: '待复核', location: '复核台 L-04', quantity: '待核 42 件', action: '开始复核' },
- '库存查询': { subtitle: '查询商品、箱和托盘的库存状态', scanLabel: '商品码 / 箱码', scanPlaceholder: '扫描商品或箱码', prefix: 'SKU', titles: ['商品库存查询', '箱库存查询'], meta: '可用库存', location: 'A-03-12', quantity: '可用 1,280 件', action: '查看详情' },
- '库位查询': { subtitle: '查看库位容量、状态与在库明细', scanLabel: '库位码', scanPlaceholder: '扫描库位编码', prefix: 'LOC', titles: ['标准货架位', '托盘库位'], meta: '库位正常', location: 'A 区 03 通道', quantity: '占用 72%', action: '查看详情' },
- '托盘查询': { subtitle: '查看托盘状态与绑定库存', scanLabel: '托盘码', scanPlaceholder: '扫描托盘码', prefix: 'PLT', titles: ['在库托盘', '待处理托盘'], meta: '托盘正常', location: 'B-02-08', quantity: '绑定 12 箱', action: '查看详情' }
-}
-
-export function getOperationPrototype(name) {
- const prototype = PROTOTYPES[name] || PROTOTYPES['库存查询']
- return {
- ...prototype,
- metrics: [
- { label: '待处理', value: 6, tone: 'metric-primary' },
- { label: '进行中', value: 2, tone: 'metric-warning' },
- { label: '今日完成', value: 18, tone: 'metric-success' }
- ],
- tasks: [
- { code: `${prototype.prefix}-260820-001`, title: prototype.titles[0], meta: prototype.meta, location: prototype.location, quantity: prototype.quantity, action: prototype.action, status: '待处理', tone: 'status-pending' },
- { code: `${prototype.prefix}-260820-002`, title: prototype.titles[1], meta: prototype.meta, location: prototype.location, quantity: prototype.quantity, action: '继续处理', status: '进行中', tone: 'status-progress' }
- ]
- }
-}
diff --git a/src/pages.json b/src/pages.json
index e3d882c..1f7abe7 100644
--- a/src/pages.json
+++ b/src/pages.json
@@ -5,6 +5,10 @@
{ "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/stocktake/stocktake", "style": { "navigationStyle": "custom", "navigationBarTitleText": "库存盘点", "backgroundColor": "#F5F7FA" } },
+ { "path": "pages/move/move", "style": { "navigationStyle": "custom", "navigationBarTitleText": "商品移库", "backgroundColor": "#F5F7FA" } },
+ { "path": "pages/relabel/relabel", "style": { "navigationStyle": "custom", "navigationBarTitleText": "换标作业", "backgroundColor": "#F5F7FA" } },
+ { "path": "pages/query/query", "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" } },
diff --git a/src/pages/move/move.vue b/src/pages/move/move.vue
new file mode 100644
index 0000000..b85adb0
--- /dev/null
+++ b/src/pages/move/move.vue
@@ -0,0 +1,65 @@
+
+
+
+
+ 整托移库扫描来源托盘和同仓目标库位,按服务端资源版本一次移动整托库存
+ 正在恢复未完成的移库草稿…
+ 暂无已读取托盘,请先扫描或输入来源托盘码
+
+ 1. 来源托盘
+
+
+ {{ source.palletCode }}当前位置:{{ source.locationCode || '未绑定库位' }}{{ statusLabel(source.status) }}{{ source.occupiedPackages }}在托箱数{{ source.inventoryPositionCount }}库存位置
+
+
+ 2. 目标库位
+
+
+ {{ target.locationCode }}{{ locationTypeLabel(target.locationType) }}{{ statusLabel(target.status) }}{{ target.occupiedPackages }}已占箱数{{ target.inventoryPositionCount }}库存位置
+
+ 3. 移库原因来源和目标必须属于 Token 绑定的同一海外仓;冲突后必须重新扫描。
+ 移库完成{{ result.moveNo || '移库单号待生成' }}{{ result.palletCode }}:{{ result.sourceLocationCode || '未绑定库位' }} → {{ result.targetLocationCode || '—' }}{{ result.packageCount }} 箱 · {{ result.replayed ? '幂等恢复结果' : '本次提交成功' }}
+ {{ error.message }}错误码:{{ error.code }}requestId:{{ error.requestId }}traceId:{{ error.traceId }}
+
+
+
+
+
+
+
diff --git a/src/pages/query/query.vue b/src/pages/query/query.vue
new file mode 100644
index 0000000..459db89
--- /dev/null
+++ b/src/pages/query/query.vue
@@ -0,0 +1,55 @@
+
+
+
+
+ {{ title }}{{ subtitle }};查询范围固定为 Token 绑定的海外仓
+
+ 正在读取最新库存事实…
+
+ 未找到与 {{ result?.queryCode || code }} 匹配的库存
+ {{ item.skuCode || 'SKU 未关联' }}{{ item.productName || '商品名称未关联' }}{{ statusLabel(item.status) }}入仓码:{{ item.inboundCode || '—' }}箱码:{{ item.cartonMark || '—' }}托盘:{{ item.palletCode || '未绑定' }} · 库位:{{ item.locationCode || '未绑定' }}{{ item.availablePackages }}可用{{ item.holdPackages }}冻结{{ item.receivingPackages }}收货中
+
+ {{ result.locationCode }}{{ locationTypeLabel(result.locationType) }}{{ statusLabel(result.status) }}{{ result.occupiedPackages }}在库箱数{{ result.inventoryPositionCount }}库存位置{{ result.maxLoadKg || '—' }}最大载重 kg
+ {{ result.palletCode }}库位:{{ result.locationCode || '未绑定' }}{{ statusLabel(result.status) }}{{ result.occupiedPackages }}在托箱数{{ result.inventoryPositionCount }}库存位置当前托盘未绑定库存{{ item.skuCode || 'SKU 未关联' }}{{ item.productName || '商品名称未关联' }}{{ item.inboundCode || '无入仓码' }} · {{ item.cartonMark || '无箱码' }}{{ item.availablePackages }}可用{{ item.holdPackages }}冻结{{ item.receivingPackages }}收货中
+ {{ error.message }}错误码:{{ error.code }}requestId:{{ error.requestId }}traceId:{{ error.traceId }}
+
+
+
+
+
+
+
diff --git a/src/pages/relabel/relabel.vue b/src/pages/relabel/relabel.vue
new file mode 100644
index 0000000..90230b1
--- /dev/null
+++ b/src/pages/relabel/relabel.vue
@@ -0,0 +1,105 @@
+
+
+
+
+ {{ title }}任务和仓库范围由服务端按当前 PDA Token 校验;扫描与人工输入使用同一正式接口
+
+
+ {{ candidateHeading }}
+ 正在加载换标任务…
+ {{ emptyText }}
+ {{ item.relabelNo }}客户订单:{{ item.customerOrderNo || '—' }}{{ statusLabel(item.taskStatus || item.applicationStatus) }}{{ item.expectedPackages }}应处理{{ item.relabelledPackages }}已换标{{ item.skippedPackages + item.exceptionPackages }}跳过/异常详情 ›
+
+
+ {{ current.relabelNo }}客户订单:{{ current.customerOrderNo || '—' }}标签截止:{{ formatTime(current.labelDeadline) }}{{ statusLabel(current.taskStatus || current.applicationStatus) }}
+ {{ current.expectedPackages }}应处理{{ current.relabelledPackages }}已换标{{ current.skippedPackages }}跳过{{ current.exceptionPackages }}异常
+ 认领后任务进入处理中,并锁定当前操作人;其他账号不可并发作业。
+
+
+ 当前任务不是处理中状态,请先在“换标收货”认领,或等待复核退回。
+
+ 选择商品明细
+ {{ item.skuCode || 'SKU 未关联' }}{{ item.productNameZh || '商品名称未关联' }}新 SKU:{{ item.newSkuCode || '—' }}待处理 {{ remaining(item) }}{{ item.labelRequirement || '无额外标签要求' }}
+
+
+
+
+
+
+
+ 完成照片{{ photoFileIds.length }} 张{{ index + 1 }}. {{ name || '换标现场照片' }}每件货物必须换标、跳过或登记异常,且至少上传一张现场照片。
+
+
+
+ 当前任务尚未进入待复核状态。
+
+
+ {{ error.message }}错误码:{{ error.code }}requestId:{{ error.requestId }}traceId:{{ error.traceId }}
+ {{ successMessage }}
+
+
+
+
+
+
+
diff --git a/src/pages/stocktake/stocktake.vue b/src/pages/stocktake/stocktake.vue
new file mode 100644
index 0000000..02146a0
--- /dev/null
+++ b/src/pages/stocktake/stocktake.vue
@@ -0,0 +1,80 @@
+
+
+
+
+ 库存盘点按 Token 绑定的海外仓冻结库位或托盘库存,逐位置实盘后提交差异
+
+ 新建盘点任务
+
+
+
+
+
+ 进行中的任务
+ 正在加载盘点任务…
+ 暂无进行中的盘点任务
+ {{ task.taskNo }}{{ scopeLabel(task.scopeType) }} · {{ task.scopeCode || '—' }}{{ statusLabel(task.status) }}{{ task.positionCount }}位置{{ task.countedCount }}已盘{{ task.differenceCount }}差异继续 ›
+
+
+ 当前任务{{ current.task.taskNo }}{{ scopeLabel(current.task.scopeType) }} · {{ current.task.scopeCode || '—' }}{{ statusLabel(current.task.status) }}
+ 该任务暂无库存位置
+
+ {{ item.skuCode || 'SKU 未关联' }}{{ item.productName || '商品名称未关联' }}{{ item.inboundCode || '无入仓码' }} · {{ item.palletCode || '未绑定托盘' }} · {{ item.locationCode || '未绑定库位' }}{{ item.actualPackages == null ? '待盘' : '已盘' }}
+ {{ item.bookPackages }}账面箱数{{ item.actualPackages == null ? '—' : item.actualPackages }}实盘箱数{{ item.differencePackages == null ? '—' : item.differencePackages }}差异
+
+
+
+ 提交原因所有位置完成实盘后才能提交;版本变化时页面会自动刷新。
+
+
+ {{ error.message }}错误码:{{ error.code }}requestId:{{ error.requestId }}traceId:{{ error.traceId }}
+ {{ successMessage }}
+
+
+
+
+
+
+
diff --git a/src/pages/warehouse/operations.scss b/src/pages/warehouse/operations.scss
new file mode 100644
index 0000000..0432ca7
--- /dev/null
+++ b/src/pages/warehouse/operations.scss
@@ -0,0 +1 @@
+.work-page{height:100vh;overflow:hidden}.work-scroll{height:calc(100vh - 48px - var(--status-bar-height,0px))}.work-content{padding-bottom:42px}.work-heading{margin:4px 2px 12px}.work-title,.work-subtitle,.card-title,.business-code,.meta-line,.empty-copy,.error-box text,.success-box text{display:block}.work-title{color:#14263d;font-size:21px;font-weight:800}.work-subtitle{margin-top:4px;color:#708197;font-size:11px;line-height:17px}.work-card,.task-card,.empty-box,.error-box,.success-box{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)}.card-title{margin-bottom:9px;color:#17314f;font-size:13px;font-weight:800}.business-code{color:#142c48;font-size:16px;font-weight:800}.meta-line{margin-top:4px;color:#718095;font-size:10px;line-height:16px}.row-head,.action-row,.metric-row,.mode-row{display:flex;align-items:center;justify-content:space-between;gap:9px}.mode-row{justify-content:flex-start;margin-bottom:10px}.mode-button{height:34px;margin:0;padding:0 14px;border-radius:17px;color:#617287;font-size:11px;line-height:34px;background:#e9eef4}.mode-button.active{color:#fff;font-weight:700;background:#135ee8}.primary,.secondary,.danger{box-sizing:border-box;min-height:42px;margin-top:9px;border-radius:8px;font-size:12px;font-weight:750}.primary{width:100%;color:#fff;background:#135ee8}.secondary{width:100%;color:#135ee8;border:1px solid #9dc0f4;background:#f8fbff}.danger{width:100%;color:#a43030;border:1px solid #e2a5a5;background:#fff7f7}.primary[disabled],.secondary[disabled],.danger[disabled]{color:#9aa7b5;background:#e8edf3;opacity:1}.compact-button{width:auto;min-width:82px;margin-top:0;padding:0 12px}.text-input,.number-input,.reason-input{box-sizing:border-box;width:100%;border:1px solid #cad6e3;border-radius:8px;color:#243951;background:#fbfcfe}.text-input,.number-input{height:44px;padding:0 11px}.number-input{font-size:17px;font-weight:750}.reason-input{height:74px;padding:9px 10px}.task-list{display:flex;flex-direction:column;gap:9px;margin-bottom:12px}.task-card{margin-bottom:0}.task-card.current{border-color:#8eb8f5;background:#f7faff}.metric-row{margin-top:10px;padding-top:9px;border-top:1px solid #edf1f5}.metric{display:flex;align-items:center;flex-direction:column;min-width:0;color:#708197;font-size:9px}.metric strong{color:#135ee8;font-size:15px}.item-list{display:flex;flex-direction:column;gap:9px}.item-card{padding:11px;border:1px solid #e0e7ef;border-radius:9px;background:#fbfcfe}.item-card.done{border-color:#9ed5bd;background:#f2fbf7}.item-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:9px;color:#708197;font-size:9px;text-align:center}.item-grid strong{display:block;color:#203750;font-size:14px}.empty-box{text-align:center;color:#738399}.empty-copy{line-height:20px}.error-box{color:#9d2323;background:#fff0f0}.error-title,.success-title{font-size:12px;font-weight:800}.success-box{color:#176b42;background:#eaf8f1}.status-pill{flex-shrink:0;padding:4px 7px;border-radius:6px;color:#8a570c;font-size:9px;background:#fff1dd}.status-pill.good{color:#08705f;background:#e2f6f0}.scan-gap{margin-top:10px}.section-gap{margin-top:13px}.hint{display:block;margin-top:7px;color:#718095;font-size:10px;line-height:16px}.link-button{height:30px;margin:0;padding:0 8px;color:#135ee8;font-size:10px;line-height:30px;background:transparent}
diff --git a/src/stores/session.js b/src/stores/session.js
index 9b26ede..c23a909 100644
--- a/src/stores/session.js
+++ b/src/stores/session.js
@@ -2,6 +2,7 @@ import { reactive } from 'vue'
import { OVERSEAS_API_ROOT } from '../config/runtime.js'
import { resetReceivingSession } from './receiving.js'
import { resetFulfillmentSession } from './fulfillment.js'
+import { resetWarehouseWork } from './warehouse-work.js'
const SESSION_KEY = 'overseas-pda-auth-session'
export const PDA_ROLE = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR'
@@ -181,6 +182,7 @@ export function signOut() {
if (typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(SESSION_KEY)
resetReceivingSession()
resetFulfillmentSession()
+ resetWarehouseWork()
}
export function requireSession() {
diff --git a/src/stores/warehouse-work.js b/src/stores/warehouse-work.js
new file mode 100644
index 0000000..744b7b8
--- /dev/null
+++ b/src/stores/warehouse-work.js
@@ -0,0 +1,74 @@
+import { reactive } from 'vue'
+
+const STORAGE_KEY = 'overseas-pda-warehouse-work-v1'
+const blankState = () => ({
+ scopeKey: '',
+ stocktake: { taskNo: '', scopeType: 'LOCATION', scopeCode: '', reason: '', pendingKeys: {} },
+ move: { sourcePalletCode: '', targetLocationCode: '', reason: '', pendingKeys: {} },
+ relabel: { currentNo: '', oldLabel: '', newLabel: '', action: 'RELABEL', reason: '', remark: '', photoFileIds: [], photoNames: [], pendingKeys: {} },
+ query: { inventoryCode: '', locationCode: '', palletCode: '' }
+})
+
+export const warehouseWork = reactive(blankState())
+
+function storageAvailable() {
+ return typeof uni !== 'undefined' && typeof uni.setStorageSync === 'function' && typeof uni.getStorageSync === 'function'
+}
+
+function domain(value, fallback) { return { ...fallback, ...(value || {}), pendingKeys: { ...(value?.pendingKeys || {}) } } }
+
+export function persistWarehouseWork() {
+ if (!storageAvailable() || !warehouseWork.scopeKey) return
+ uni.setStorageSync(STORAGE_KEY, JSON.parse(JSON.stringify(warehouseWork)))
+}
+
+export function restoreWarehouseWork(scopeKey) {
+ const expected = String(scopeKey || '')
+ if (!expected) return false
+ const saved = storageAvailable() ? uni.getStorageSync(STORAGE_KEY) : null
+ const fresh = blankState()
+ if (!saved || saved.scopeKey !== expected) {
+ Object.assign(warehouseWork, fresh, { scopeKey: expected })
+ if (storageAvailable()) uni.removeStorageSync(STORAGE_KEY)
+ return false
+ }
+ Object.assign(warehouseWork, fresh, {
+ scopeKey: expected,
+ stocktake: domain(saved.stocktake, fresh.stocktake), move: domain(saved.move, fresh.move),
+ relabel: domain(saved.relabel, fresh.relabel), query: { ...fresh.query, ...(saved.query || {}) }
+ })
+ return true
+}
+
+export function updateWarehouseWork(name, patch) {
+ if (!warehouseWork[name]) throw new Error('未知海外仓现场作业')
+ Object.assign(warehouseWork[name], patch)
+ persistWarehouseWork()
+}
+
+export function warehouseIdempotencyKey(name, action, payload = {}) {
+ const target = warehouseWork[name]
+ if (!target?.pendingKeys) throw new Error('当前作业不支持写操作')
+ const signature = `${action}:${JSON.stringify(payload)}`
+ if (!target.pendingKeys[signature]) target.pendingKeys[signature] = `pda-${name}-${action}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
+ persistWarehouseWork()
+ return { signature, value: target.pendingKeys[signature] }
+}
+
+export function releaseWarehouseKey(name, signature) {
+ if (!warehouseWork[name]?.pendingKeys) return
+ delete warehouseWork[name].pendingKeys[signature]
+ persistWarehouseWork()
+}
+
+export function resetWarehouseDomain(name) {
+ const fresh = blankState()
+ if (!fresh[name]) return
+ warehouseWork[name] = fresh[name]
+ persistWarehouseWork()
+}
+
+export function resetWarehouseWork() {
+ Object.assign(warehouseWork, blankState())
+ if (typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(STORAGE_KEY)
+}
diff --git a/tests/business-identifiers-ui.test.mjs b/tests/business-identifiers-ui.test.mjs
index 966da59..bbfe797 100644
--- a/tests/business-identifiers-ui.test.mjs
+++ b/tests/business-identifiers-ui.test.mjs
@@ -11,6 +11,7 @@ test('all registered user-facing pages avoid rendering internal identifiers', as
'../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/stocktake/stocktake.vue', '../src/pages/move/move.vue', '../src/pages/relabel/relabel.vue', '../src/pages/query/query.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'
@@ -39,13 +40,16 @@ test('operator and warehouse cards never fall back to internal IDs', async () =>
})
test('operation templates expose business references only and keep diagnostic evidence', async () => {
- const [operation, pallet, summary, apiFiles] = await Promise.all([
+ const [operation, pallet, summary, stocktake, move, relabel, query, apiFiles] = await Promise.all([
read('../src/pages/operation/operation.vue'),
read('../src/pages/pallet/pallet.vue'),
read('../src/components/ReceivingSessionSummary.vue'),
+ read('../src/pages/stocktake/stocktake.vue'), read('../src/pages/move/move.vue'),
+ read('../src/pages/relabel/relabel.vue'), read('../src/pages/query/query.vue'),
Promise.all([
read('../src/api/inbound.js'), read('../src/api/pallet.js'),
- read('../src/api/overseas.js'), read('../src/api/receiving-adapter.js')
+ read('../src/api/overseas.js'), read('../src/api/receiving-adapter.js'),
+ read('../src/api/warehouse-operations.js'), read('../src/api/relabel.js')
]).then((values) => values.join('\n'))
])
@@ -59,6 +63,7 @@ test('operation templates expose business references only and keep diagnostic ev
}
for (const field of ['orderId', 'inboundId', 'planId', 'containerBookingId', 'sourceContainerBookingId', 'warehouseId', 'palletId', 'locationId']) {
assert.doesNotMatch(template(pallet), new RegExp(`\\b${field}\\b`))
+ for (const page of [stocktake, move, relabel, query]) assert.doesNotMatch(template(page), new RegExp(`\\b${field}\\b`))
}
for (const businessField of ['containerNo', 'inboundCode', 'cartonMark', 'palletCode']) {
@@ -66,6 +71,7 @@ test('operation templates expose business references only and keep diagnostic ev
}
assert.match(operation, /requestId[\s\S]*traceId/)
assert.match(pallet, /requestId[\s\S]*traceId/)
+ for (const page of [stocktake, move, relabel, query]) assert.match(page, /requestId[\s\S]*traceId/)
assert.doesNotMatch(apiFiles, /\/api\/platform\/v1\/|inbound-code-relationships/)
})
diff --git a/tests/project-contract.test.mjs b/tests/project-contract.test.mjs
index 195dc8d..c253e14 100644
--- a/tests/project-contract.test.mjs
+++ b/tests/project-contract.test.mjs
@@ -86,18 +86,18 @@ test('login has no committed demo credentials and inbound operation implements t
assert.match(overseas, /requestId/)
})
-test('remaining unimplemented warehouse operations cannot submit prototype scans', async () => {
- const [operation, home, module, workbench] = await Promise.all([
+test('warehouse operations use dedicated real pages and contain no prototype task data', async () => {
+ const [operation, home, module, workbench, pages] = 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')
+ read('../src/pages/module/module.vue'), read('../src/data/overseas-workbench.js'), read('../src/pages.json')
])
assert.doesNotMatch(operation, /prototypeScan|prototypeInput/)
- assert.match(operation, /功能暂未开放|扫码不会提交/)
- assert.match(home, /暂未开放/)
- assert.match(module, /灰色功能尚未开放/)
+ assert.doesNotMatch(workbench, /PROTOTYPES|getOperationPrototype|模拟成功/)
for (const key of ['stocktake', 'move', 'relabelReceive', 'inventoryQuery', 'locationQuery', 'palletQuery']) {
- assert.match(workbench, new RegExp(`key: '${key}'[^\\n]*available: false`))
+ assert.match(workbench, new RegExp(`key: '${key}'[^\\n]*available: true[^\\n]*route:`))
}
+ for (const page of ['pages/stocktake/stocktake', 'pages/move/move', 'pages/relabel/relabel', 'pages/query/query']) assert.match(pages, new RegExp(page))
+ assert.match(home + module, /operation\.route \|\|/)
})
test('receiving phases use a compiled container summary with cabinet and aggregate facts', async () => {
diff --git a/tests/warehouse-workflows.test.mjs b/tests/warehouse-workflows.test.mjs
new file mode 100644
index 0000000..c988fa0
--- /dev/null
+++ b/tests/warehouse-workflows.test.mjs
@@ -0,0 +1,127 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+import { session } from '../src/stores/session.js'
+import {
+ countStocktake, createStocktake, getStocktake, listStocktakes, moveInventory,
+ resolveInventory, resolveWarehouseLocation, resolveWarehousePallet, submitStocktake
+} from '../src/api/warehouse-operations.js'
+import {
+ claimRelabelTask, completeRelabelTask, getRelabelTask, listRelabelCandidates, relabelUnit,
+ reportRelabelException, reviewRelabelTask, skipRelabelUnit
+} from '../src/api/relabel.js'
+import {
+ releaseWarehouseKey, resetWarehouseWork, restoreWarehouseWork, updateWarehouseWork,
+ warehouseIdempotencyKey, warehouseWork
+} from '../src/stores/warehouse-work.js'
+
+const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
+
+function requestMock() {
+ const calls = []
+ session.apiBase = '/overseas-api'; session.token = 'warehouse-token'
+ globalThis.uni = {
+ request(options) {
+ calls.push({ method: options.method, url: options.url, data: options.data, authorization: options.header.Authorization })
+ let data = {}
+ if (options.url.includes('/stocktakes?')) data = { records: [], page: 1, size: 100, total: 0, pages: 0 }
+ else if (options.url.endsWith('/stocktakes')) data = { task: { taskNo: 'CC-1', version: 0 }, items: [] }
+ else if (/\/stocktakes\/[^/]+$/.test(options.url)) data = { task: { taskNo: 'CC/1', version: 0 }, items: [] }
+ else if (options.url.includes('/stocktakes/') && options.url.endsWith('/submit')) data = { task: { taskNo: 'CC/1', status: 'SUBMITTED', version: 1 }, items: [] }
+ else if (options.url.includes('/stocktakes/') && options.url.endsWith('/counts')) data = { positionId: '9', actualPackages: 2 }
+ else if (options.url.endsWith('/inventory-moves')) data = { moveNo: 'MV-1', palletCode: 'PLT-1', status: 'COMPLETED' }
+ else if (options.url.endsWith('/inventory/resolve')) data = { queryCode: 'SKU-1', items: [] }
+ else if (options.url.endsWith('/locations/resolve')) data = { locationCode: 'A-01' }
+ else if (options.url.endsWith('/pallets/resolve')) data = { palletCode: 'PLT-1', version: 2, inventory: [] }
+ else if (options.url.endsWith('/relabel-tasks/candidates')) data = []
+ else data = { relabelNo: 'RL/1', taskStatus: 'PROCESSING', version: 1, items: [] }
+ options.success({ statusCode: 200, data: { code: '200', data }, header: {} })
+ }
+ }
+ return calls
+}
+
+test('warehouse API calls exact PDA paths and never supplies client warehouse scope', async () => {
+ const calls = requestMock()
+ await listStocktakes(); await createStocktake({ scopeType: 'LOCATION', scopeCode: 'A-01', idempotencyKey: 'create-1' })
+ await getStocktake('CC/1'); await countStocktake('CC/1', { positionId: '9', actualPackages: 2, expectedVersion: 0, idempotencyKey: 'count-1' })
+ await submitStocktake('CC/1', { expectedVersion: 1, reason: 'cycle', idempotencyKey: 'submit-1' })
+ await moveInventory({ sourcePalletCode: 'PLT-1', targetLocationCode: 'B-01', expectedPalletVersion: 2, reason: 'capacity', idempotencyKey: 'move-1' })
+ await resolveInventory('SKU-1'); await resolveWarehouseLocation('A-01'); await resolveWarehousePallet('PLT-1')
+
+ assert.equal(calls.length, 9)
+ for (const path of ['/stocktakes?page=1&size=100&status=IN_PROGRESS', '/stocktakes/CC%2F1/counts', '/stocktakes/CC%2F1/submit', '/inventory-moves', '/inventory/resolve', '/locations/resolve', '/pallets/resolve']) {
+ assert.ok(calls.some((call) => call.url.endsWith(path)), path)
+ }
+ assert.ok(calls.every((call) => call.authorization === 'Bearer warehouse-token'))
+ for (const call of calls) {
+ assert.equal(Object.hasOwn(call.data || {}, 'customerId'), false)
+ assert.equal(Object.hasOwn(call.data || {}, 'warehouseId'), false)
+ }
+})
+
+test('relabel API covers claim, unit outcomes, completion and review with encoded business numbers', async () => {
+ const calls = requestMock()
+ await listRelabelCandidates(); await getRelabelTask('RL/1')
+ await claimRelabelTask('RL/1', { idempotencyKey: 'claim-1' })
+ await relabelUnit('RL/1', { itemId: '7', oldLabel: 'OLD', newLabel: 'NEW', expectedVersion: 1, idempotencyKey: 'unit-1' })
+ await skipRelabelUnit('RL/1', { itemId: '7', oldLabel: 'OLD-2', reason: 'skip', expectedVersion: 2, idempotencyKey: 'skip-1' })
+ await reportRelabelException('RL/1', { itemId: '7', oldLabel: 'OLD-3', exceptionCode: 'DAMAGED', reason: 'damage', expectedVersion: 3, idempotencyKey: 'exception-1' })
+ await completeRelabelTask('RL/1', { photoFileIds: ['8'], expectedVersion: 4, idempotencyKey: 'complete-1' })
+ await reviewRelabelTask('RL/1', { decision: 'APPROVE', expectedVersion: 5, idempotencyKey: 'review-1' })
+
+ for (const path of ['/relabel-tasks/candidates', '/relabel-tasks/RL%2F1', '/claim', '/units', '/units/skip', '/exceptions', '/complete', '/review']) {
+ assert.ok(calls.some((call) => call.url.endsWith(path)), path)
+ }
+ assert.ok(calls.every((call) => !/[?&](?:customerId|warehouseId)=/.test(call.url)))
+})
+
+test('warehouse drafts are isolated by operator and warehouse and retain retry keys until success', () => {
+ 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) }
+ }
+ resetWarehouseWork()
+ assert.equal(restoreWarehouseWork('operator-a:warehouse-a'), false)
+ updateWarehouseWork('move', { sourcePalletCode: 'PLT-1', targetLocationCode: 'A-01', reason: 'capacity' })
+ const first = warehouseIdempotencyKey('move', 'move', { palletCode: 'PLT-1', locationCode: 'A-01' })
+ const retry = warehouseIdempotencyKey('move', 'move', { palletCode: 'PLT-1', locationCode: 'A-01' })
+ assert.equal(retry.value, first.value)
+ warehouseWork.move.sourcePalletCode = ''
+ assert.equal(restoreWarehouseWork('operator-a:warehouse-a'), true)
+ assert.equal(warehouseWork.move.sourcePalletCode, 'PLT-1')
+ releaseWarehouseKey('move', first.signature)
+ assert.equal(restoreWarehouseWork('operator-b:warehouse-a'), false)
+ assert.equal(warehouseWork.move.sourcePalletCode, '')
+})
+
+test('new operation pages wire scans, manual inputs, conflict refresh, loading and diagnostics', async () => {
+ const [stocktake, move, relabel, query, workbench, pages, api, relabelApi] = await Promise.all([
+ read('../src/pages/stocktake/stocktake.vue'), read('../src/pages/move/move.vue'),
+ read('../src/pages/relabel/relabel.vue'), read('../src/pages/query/query.vue'),
+ read('../src/data/overseas-workbench.js'), read('../src/pages.json'),
+ read('../src/api/warehouse-operations.js'), read('../src/api/relabel.js')
+ ])
+ for (const page of [stocktake, move, relabel, query]) {
+ assert.match(page, /startScanBroadcast/)
+ assert.match(page, /ScanTargetInput/)
+ assert.match(page, /requestId[\s\S]*traceId/)
+ assert.match(page, /正在|加载|查询中/)
+ assert.match(page, /暂无|未找到|无库存|不是处理中/)
+ assert.doesNotMatch(page, /模拟成功|customerId:|warehouseId:/)
+ }
+ for (const page of [stocktake, move, relabel]) {
+ assert.match(page, /warehouseIdempotencyKey/)
+ assert.match(page, /releaseWarehouseKey/)
+ assert.match(page, /isConflict[\s\S]*刷新/)
+ }
+ assert.match(stocktake, /expectedVersion: item\.version/)
+ assert.match(move, /expectedPalletVersion: Number\(this\.source\.version/)
+ assert.match(relabel, /expectedVersion: this\.current\.version/)
+ assert.doesNotMatch(workbench, /PROTOTYPES|getOperationPrototype/)
+ for (const route of ['stocktake/stocktake', 'move/move', 'relabel/relabel', 'query/query']) assert.match(pages, new RegExp(route))
+ assert.doesNotMatch(api + relabelApi, /\/api\/platform\/|customerId|warehouseId/)
+})
diff --git a/tests/workbench-availability.test.mjs b/tests/workbench-availability.test.mjs
index a1ddb43..7ae0c7e 100644
--- a/tests/workbench-availability.test.mjs
+++ b/tests/workbench-availability.test.mjs
@@ -9,16 +9,23 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
{ module: 'inbound', name: '拆柜收货' },
{ module: 'outbound', name: '排车拣货' },
{ module: 'outbound', name: '出库作业' },
- { module: 'inventory', name: '托盘管理' }
+ { module: 'inventory', name: '库存盘点' },
+ { module: 'inventory', name: '商品移库' },
+ { module: 'inventory', name: '托盘管理' },
+ { module: 'relabel', name: '换标收货' },
+ { module: 'relabel', name: '换标执行' },
+ { module: 'relabel', name: '换标复核' },
+ { module: 'query', name: '库存查询' },
+ { module: 'query', name: '库位查询' },
+ { module: 'query', 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) {
+ for (const name of ['库存盘点', '商品移库', '换标收货', '换标执行', '换标复核', '库存查询', '库位查询', '托盘查询']) {
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, undefined)
+ assert.equal(operation?.available, true)
+ assert.match(operation?.route || '', /^\/pages\//)
}
})