feat: complete overseas PDA warehouse operations
This commit is contained in:
@@ -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 承载。
|
||||
|
||||
## 质量门禁
|
||||
|
||||
|
||||
@@ -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 在线更新
|
||||
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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() })
|
||||
}
|
||||
@@ -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' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<view v-if="user" class="page-shell portal-page work-page">
|
||||
<PortalHeader title="商品移库" :user="user" fallback-url="/pages/module/module?key=inventory" />
|
||||
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
|
||||
<view class="work-heading"><text class="work-title">整托移库</text><text class="work-subtitle">扫描来源托盘和同仓目标库位,按服务端资源版本一次移动整托库存</text></view>
|
||||
<view v-if="restoring" class="empty-box"><uni-load-more status="loading" /><text class="empty-copy">正在恢复未完成的移库草稿…</text></view>
|
||||
<view v-else-if="!source && !sourceCode" class="empty-box"><text class="empty-copy">暂无已读取托盘,请先扫描或输入来源托盘码</text></view>
|
||||
<view class="work-card">
|
||||
<text class="card-title">1. 来源托盘</text>
|
||||
<ScanTargetInput name="source" v-model="sourceCode" label="托盘码" placeholder="扫描或输入来源托盘码" :active="activeTarget === 'source'" @activate="activeTarget = 'source'" @submit="resolveSource" />
|
||||
<button class="secondary" :disabled="!sourceCode.trim() || busy" @tap="resolveSource(sourceCode)">{{ resolving === 'source' ? '查询中…' : '读取托盘状态' }}</button>
|
||||
<view v-if="source" class="task-card section-gap"><view class="row-head"><view><text class="business-code">{{ source.palletCode }}</text><text class="meta-line">当前位置:{{ source.locationCode || '未绑定库位' }}</text></view><text class="status-pill">{{ statusLabel(source.status) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ source.occupiedPackages }}</strong><text>在托箱数</text></view><view class="metric"><strong>{{ source.inventoryPositionCount }}</strong><text>库存位置</text></view></view></view>
|
||||
</view>
|
||||
<view class="work-card">
|
||||
<text class="card-title">2. 目标库位</text>
|
||||
<ScanTargetInput name="target" v-model="targetCode" label="库位码" placeholder="扫描或输入目标库位码" :active="activeTarget === 'target'" @activate="activeTarget = 'target'" @submit="resolveTarget" />
|
||||
<button class="secondary" :disabled="!targetCode.trim() || busy" @tap="resolveTarget(targetCode)">{{ resolving === 'target' ? '校验中…' : '校验目标库位' }}</button>
|
||||
<view v-if="target" class="task-card section-gap"><view class="row-head"><view><text class="business-code">{{ target.locationCode }}</text><text class="meta-line">{{ locationTypeLabel(target.locationType) }}</text></view><text class="status-pill">{{ statusLabel(target.status) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ target.occupiedPackages }}</strong><text>已占箱数</text></view><view class="metric"><strong>{{ target.inventoryPositionCount }}</strong><text>库存位置</text></view></view></view>
|
||||
</view>
|
||||
<view class="work-card"><text class="card-title">3. 移库原因</text><textarea v-model="reason" class="reason-input" maxlength="500" placeholder="必填,例如:库位容量调整" /><button class="primary" :disabled="!canMove || busy" @tap="submitMove">{{ busyAction === 'move' ? '移库中…' : '确认整托移库' }}</button><text class="hint">来源和目标必须属于 Token 绑定的同一海外仓;冲突后必须重新扫描。</text></view>
|
||||
<view v-if="result" class="success-box" role="status"><text class="success-title">移库完成</text><text>{{ result.moveNo || '移库单号待生成' }}</text><text>{{ result.palletCode }}:{{ result.sourceLocationCode || '未绑定库位' }} → {{ result.targetLocationCode || '—' }}</text><text>{{ result.packageCount }} 箱 · {{ result.replayed ? '幂等恢复结果' : '本次提交成功' }}</text><button class="secondary" @tap="resetPage">继续移库</button></view>
|
||||
<view v-if="error" class="error-box" role="alert"><text class="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><button class="secondary" @tap="refreshFacts">重新读取托盘和库位</button></view>
|
||||
</view></scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { moveInventory, resolveWarehouseLocation, resolveWarehousePallet } from '@/api/warehouse-operations.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { releaseWarehouseKey, resetWarehouseDomain, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
|
||||
const isConflict = (error) => ['409', 'A0409', 'B0409'].includes(String(error?.code || ''))
|
||||
export default {
|
||||
components: { PortalHeader, ScanTargetInput },
|
||||
data() { return { user: session.currentUser, source: null, target: null, activeTarget: 'source', resolving: '', restoring: false, busy: false, busyAction: '', error: null, result: null } },
|
||||
computed: {
|
||||
sourceCode: { get() { return warehouseWork.move.sourcePalletCode }, set(value) { updateWarehouseWork('move', { sourcePalletCode: value }); if (this.source?.palletCode !== String(value || '').trim()) this.source = null } },
|
||||
targetCode: { get() { return warehouseWork.move.targetLocationCode }, set(value) { updateWarehouseWork('move', { targetLocationCode: value }); if (this.target?.locationCode !== String(value || '').trim()) this.target = null } },
|
||||
reason: { get() { return warehouseWork.move.reason }, set(value) { updateWarehouseWork('move', { reason: value }) } },
|
||||
canMove() { return Boolean(this.source && this.target && this.reason.trim() && this.source.locationCode !== this.target.locationCode) }
|
||||
},
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.startScanner(); this.restoreDraftFacts() },
|
||||
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
|
||||
methods: {
|
||||
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleScan(value), onError: (error) => this.setError(error, '扫码广播注册失败') }) },
|
||||
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
|
||||
handleScan(value) { if (this.activeTarget === 'target') { this.targetCode = value; this.resolveTarget(value) } else { this.sourceCode = value; this.resolveSource(value) } },
|
||||
async restoreDraftFacts() { this.restoring = true; try { if (this.sourceCode) await this.resolveSource(this.sourceCode, false); if (this.targetCode) await this.resolveTarget(this.targetCode, false) } finally { this.restoring = false } },
|
||||
async resolveSource(raw, sound = true) { const code = String(raw?.detail?.value ?? raw ?? this.sourceCode).trim(); if (!code || this.busy) return; this.sourceCode = code; this.resolving = 'source'; this.error = null; try { this.source = await resolveWarehousePallet(code); this.sourceCode = this.source.palletCode; if (sound) playSuccessSound(); this.activeTarget = 'target' } catch (error) { this.setError(error, '来源托盘查询失败') } finally { this.resolving = '' } },
|
||||
async resolveTarget(raw, sound = true) { const code = String(raw?.detail?.value ?? raw ?? this.targetCode).trim(); if (!code || this.busy) return; this.targetCode = code; this.resolving = 'target'; this.error = null; try { this.target = await resolveWarehouseLocation(code); this.targetCode = this.target.locationCode; if (sound) playSuccessSound() } catch (error) { this.setError(error, '目标库位校验失败') } finally { this.resolving = '' } },
|
||||
async submitMove() { if (!this.canMove || this.busy) return; const payload = { sourcePalletCode: this.source.palletCode, targetLocationCode: this.target.locationCode, expectedPalletVersion: Number(this.source.version || 0), reason: this.reason.trim() }; const key = warehouseIdempotencyKey('move', 'move', payload); this.busy = true; this.busyAction = 'move'; this.error = null; this.result = null; try { this.result = await moveInventory({ ...payload, idempotencyKey: key.value }); releaseWarehouseKey('move', key.signature); playSuccessSound(); updateWarehouseWork('move', { sourcePalletCode: '', targetLocationCode: '', reason: '' }); this.source = null; this.target = null } catch (error) { this.setError(error, '整托移库失败'); if (isConflict(error)) { releaseWarehouseKey('move', key.signature); await this.refreshFacts(false); if (this.error) this.error.message += ',已刷新最新资源版本,请重新确认' } } finally { this.busy = false; this.busyAction = '' } },
|
||||
async refreshFacts(clearError = true) { const retainedError = clearError ? null : this.error; const sourceCode = this.sourceCode; const targetCode = this.targetCode; this.source = null; this.target = null; try { if (sourceCode) this.source = await resolveWarehousePallet(sourceCode); if (targetCode) this.target = await resolveWarehouseLocation(targetCode); this.error = retainedError } catch (error) { this.setError(error, '最新托盘或库位状态读取失败') } },
|
||||
resetPage() { resetWarehouseDomain('move'); this.source = null; this.target = null; this.result = null; this.error = null; this.activeTarget = 'source' },
|
||||
setError(error, fallback) { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
|
||||
statusLabel(value) { return ({ ACTIVE: '可用', IDLE: '空闲', OCCUPIED: '已占用', LOCKED: '已锁定' })[value] || value || '状态未知' },
|
||||
locationTypeLabel(value) { return ({ STORAGE: '存储库位', STAGING: '暂存库位', RELABEL: '换标库位', RECEIVING: '收货库位' })[value] || value || '库位类型未标注' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">@import '../warehouse/operations.scss';</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<view v-if="user" class="page-shell portal-page work-page">
|
||||
<PortalHeader :title="title" :user="user" fallback-url="/pages/module/module?key=query" />
|
||||
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
|
||||
<view class="work-heading"><text class="work-title">{{ title }}</text><text class="work-subtitle">{{ subtitle }};查询范围固定为 Token 绑定的海外仓</text></view>
|
||||
<view class="work-card"><ScanTargetInput name="query" v-model="code" :label="label" :placeholder="placeholder" :active="true" @submit="search" /><button class="primary" :disabled="!code.trim() || loading" @tap="search(code)">{{ loading ? '查询中…' : '查询' }}</button></view>
|
||||
<view v-if="loading" class="empty-box"><uni-load-more status="loading" /><text class="empty-copy">正在读取最新库存事实…</text></view>
|
||||
<template v-else-if="searched && mode === 'inventory'">
|
||||
<view v-if="!inventoryItems.length" class="empty-box"><uni-icons type="info" color="#8090a3" size="32" /><text class="empty-copy">未找到与 {{ result?.queryCode || code }} 匹配的库存</text></view>
|
||||
<view v-else class="item-list"><view v-for="(item,index) in inventoryItems" :key="`${item.palletCode}:${item.locationCode}:${item.skuCode}:${index}`" class="item-card"><view class="row-head"><view><text class="business-code">{{ item.skuCode || 'SKU 未关联' }}</text><text class="meta-line">{{ item.productName || '商品名称未关联' }}</text></view><text class="status-pill">{{ statusLabel(item.status) }}</text></view><text class="meta-line">入仓码:{{ item.inboundCode || '—' }}</text><text class="meta-line">箱码:{{ item.cartonMark || '—' }}</text><text class="meta-line">托盘:{{ item.palletCode || '未绑定' }} · 库位:{{ item.locationCode || '未绑定' }}</text><view class="metric-row"><view class="metric"><strong>{{ item.availablePackages }}</strong><text>可用</text></view><view class="metric"><strong>{{ item.holdPackages }}</strong><text>冻结</text></view><view class="metric"><strong>{{ item.receivingPackages }}</strong><text>收货中</text></view></view></view></view>
|
||||
</template>
|
||||
<view v-else-if="searched && mode === 'location' && result" class="work-card"><view class="row-head"><view><text class="business-code">{{ result.locationCode }}</text><text class="meta-line">{{ locationTypeLabel(result.locationType) }}</text></view><text class="status-pill">{{ statusLabel(result.status) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ result.occupiedPackages }}</strong><text>在库箱数</text></view><view class="metric"><strong>{{ result.inventoryPositionCount }}</strong><text>库存位置</text></view><view class="metric"><strong>{{ result.maxLoadKg || '—' }}</strong><text>最大载重 kg</text></view></view></view>
|
||||
<template v-else-if="searched && mode === 'pallet' && result"><view class="work-card"><view class="row-head"><view><text class="business-code">{{ result.palletCode }}</text><text class="meta-line">库位:{{ result.locationCode || '未绑定' }}</text></view><text class="status-pill">{{ statusLabel(result.status) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ result.occupiedPackages }}</strong><text>在托箱数</text></view><view class="metric"><strong>{{ result.inventoryPositionCount }}</strong><text>库存位置</text></view></view></view><view v-if="!palletItems.length" class="empty-box"><text class="empty-copy">当前托盘未绑定库存</text></view><view v-else class="item-list"><view v-for="(item,index) in palletItems" :key="`${item.skuCode}:${item.inboundCode}:${index}`" class="item-card"><text class="business-code">{{ item.skuCode || 'SKU 未关联' }}</text><text class="meta-line">{{ item.productName || '商品名称未关联' }}</text><text class="meta-line">{{ item.inboundCode || '无入仓码' }} · {{ item.cartonMark || '无箱码' }}</text><view class="metric-row"><view class="metric"><strong>{{ item.availablePackages }}</strong><text>可用</text></view><view class="metric"><strong>{{ item.holdPackages }}</strong><text>冻结</text></view><view class="metric"><strong>{{ item.receivingPackages }}</strong><text>收货中</text></view></view></view></view></template>
|
||||
<view v-if="error" class="error-box" role="alert"><text class="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><button class="secondary" @tap="search(code)">重新查询</button></view>
|
||||
</view></scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { resolveInventory, resolveWarehouseLocation, resolveWarehousePallet } from '@/api/warehouse-operations.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { restoreWarehouseWork, updateWarehouseWork, warehouseWork } from '@/stores/warehouse-work.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
|
||||
const MODES = {
|
||||
inventoryQuery: { mode: 'inventory', title: '库存查询', label: '商品码或箱码', placeholder: '扫描或输入 SKU / 箱码', subtitle: '查看可用、冻结和收货中库存' },
|
||||
locationQuery: { mode: 'location', title: '库位查询', label: '库位码', placeholder: '扫描或输入库位码', subtitle: '查看库位状态、容量和占用' },
|
||||
palletQuery: { mode: 'pallet', title: '托盘查询', label: '托盘码', placeholder: '扫描或输入托盘码', subtitle: '查看托盘状态和绑定库存' }
|
||||
}
|
||||
export default {
|
||||
components: { PortalHeader, ScanTargetInput },
|
||||
data() { return { user: session.currentUser, operationKey: 'inventoryQuery', mode: 'inventory', title: '库存查询', label: '商品码或箱码', placeholder: '扫描或输入 SKU / 箱码', subtitle: '', loading: false, searched: false, result: null, error: null } },
|
||||
computed: {
|
||||
storeField() { return `${this.mode}Code` },
|
||||
code: { get() { return warehouseWork.query[this.storeField] || '' }, set(value) { updateWarehouseWork('query', { [this.storeField]: value }) } },
|
||||
inventoryItems() { return Array.isArray(this.result?.items) ? this.result.items : [] }, palletItems() { return Array.isArray(this.result?.inventory) ? this.result.inventory : [] }
|
||||
},
|
||||
onLoad(options) { this.operationKey = options.op || 'inventoryQuery'; Object.assign(this, MODES[this.operationKey] || MODES.inventoryQuery) },
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.startScanner() },
|
||||
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
|
||||
methods: {
|
||||
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => { this.code = value; this.search(value) }, onError: (error) => this.setError(error, '扫码广播注册失败') }) },
|
||||
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
|
||||
async search(raw) { const code = String(raw?.detail?.value ?? raw ?? this.code).trim(); if (!code || this.loading) return; this.code = code; this.loading = true; this.error = null; this.result = null; this.searched = false; try { if (this.mode === 'location') this.result = await resolveWarehouseLocation(code); else if (this.mode === 'pallet') this.result = await resolveWarehousePallet(code); else this.result = await resolveInventory(code); this.searched = true; playSuccessSound() } catch (error) { this.searched = true; this.setError(error, `${this.title}失败`) } finally { this.loading = false } },
|
||||
setError(error, fallback) { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
|
||||
statusLabel(value) { return ({ AVAILABLE: '可用', ACTIVE: '可用', IDLE: '空闲', OCCUPIED: '已占用', HOLD: '冻结', LOCKED: '已锁定' })[value] || value || '状态未知' },
|
||||
locationTypeLabel(value) { return ({ STORAGE: '存储库位', STAGING: '暂存库位', RELABEL: '换标库位', RECEIVING: '收货库位' })[value] || value || '库位类型未标注' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">@import '../warehouse/operations.scss';</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<view v-if="user" class="page-shell portal-page work-page">
|
||||
<PortalHeader :title="title" :user="user" fallback-url="/pages/module/module?key=relabel" />
|
||||
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
|
||||
<view class="work-heading"><text class="work-title">{{ title }}</text><text class="work-subtitle">任务和仓库范围由服务端按当前 PDA Token 校验;扫描与人工输入使用同一正式接口</text></view>
|
||||
<view class="work-card"><ScanTargetInput name="relabelTask" v-model="taskCode" label="换标单号" placeholder="扫描或输入换标单号" :active="activeTarget === 'task'" @activate="activeTarget = 'task'" @submit="openTask" /><button class="primary" :disabled="!taskCode.trim() || busy" @tap="openTask(taskCode)">{{ busyAction === 'detail' ? '查询中…' : '打开换标任务' }}</button></view>
|
||||
|
||||
<view class="row-head"><text class="card-title">{{ candidateHeading }}</text><button class="link-button" :disabled="loading" @tap="loadCandidates">{{ loading ? '刷新中…' : '刷新' }}</button></view>
|
||||
<view v-if="loading && !visibleCandidates.length" class="empty-box"><uni-load-more status="loading" /><text class="empty-copy">正在加载换标任务…</text></view>
|
||||
<view v-else-if="!visibleCandidates.length" class="empty-box"><uni-icons type="info" color="#8090a3" size="32" /><text class="empty-copy">{{ emptyText }}</text></view>
|
||||
<view v-else class="task-list"><view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card" :class="{ current: current?.relabelNo === item.relabelNo }" @tap="openTask(item.relabelNo)"><view class="row-head"><view><text class="business-code">{{ item.relabelNo }}</text><text class="meta-line">客户订单:{{ item.customerOrderNo || '—' }}</text></view><text class="status-pill">{{ statusLabel(item.taskStatus || item.applicationStatus) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ item.expectedPackages }}</strong><text>应处理</text></view><view class="metric"><strong>{{ item.relabelledPackages }}</strong><text>已换标</text></view><view class="metric"><strong>{{ item.skippedPackages + item.exceptionPackages }}</strong><text>跳过/异常</text></view><text class="link-button">详情 ›</text></view></view></view>
|
||||
|
||||
<view v-if="current" class="work-card">
|
||||
<view class="row-head"><view><text class="business-code">{{ current.relabelNo }}</text><text class="meta-line">客户订单:{{ current.customerOrderNo || '—' }}</text><text class="meta-line">标签截止:{{ formatTime(current.labelDeadline) }}</text></view><text class="status-pill">{{ statusLabel(current.taskStatus || current.applicationStatus) }}</text></view>
|
||||
<view class="metric-row"><view class="metric"><strong>{{ current.expectedPackages }}</strong><text>应处理</text></view><view class="metric"><strong>{{ current.relabelledPackages }}</strong><text>已换标</text></view><view class="metric"><strong>{{ current.skippedPackages }}</strong><text>跳过</text></view><view class="metric"><strong>{{ current.exceptionPackages }}</strong><text>异常</text></view></view>
|
||||
<template v-if="stage === 'receive'"><text class="hint">认领后任务进入处理中,并锁定当前操作人;其他账号不可并发作业。</text><button class="primary" :disabled="busy || current.taskStatus === 'WAIT_REVIEW' || current.taskStatus === 'COMPLETED'" @tap="claimTask">{{ busyAction === 'claim' ? '认领中…' : current.claimedByCurrentOperator ? '恢复当前换标任务' : '确认收货并认领任务' }}</button></template>
|
||||
|
||||
<template v-else-if="stage === 'execute'">
|
||||
<view v-if="current.taskStatus !== 'PROCESSING'" class="empty-box section-gap"><text class="empty-copy">当前任务不是处理中状态,请先在“换标收货”认领,或等待复核退回。</text></view>
|
||||
<template v-else>
|
||||
<text class="card-title section-gap">选择商品明细</text>
|
||||
<view class="item-list"><view v-for="item in current.items" :key="item.itemId" class="item-card" :class="{ done: remaining(item) === 0, current: selectedItem?.itemId === item.itemId }" @tap="selectItem(item)"><view class="row-head"><view><text class="business-code">{{ item.skuCode || 'SKU 未关联' }}</text><text class="meta-line">{{ item.productNameZh || '商品名称未关联' }}</text><text class="meta-line">新 SKU:{{ item.newSkuCode || '—' }}</text></view><text class="status-pill" :class="{ good: remaining(item) === 0 }">待处理 {{ remaining(item) }}</text></view><text class="meta-line">{{ item.labelRequirement || '无额外标签要求' }}</text></view></view>
|
||||
<template v-if="selectedItem && remaining(selectedItem) > 0">
|
||||
<view class="mode-row section-gap"><button v-for="mode in actionModes" :key="mode.key" class="mode-button" :class="{ active: action === mode.key }" @tap="changeAction(mode.key)">{{ mode.name }}</button></view>
|
||||
<ScanTargetInput name="oldLabel" v-model="oldLabel" label="旧标签" placeholder="扫描或输入旧标签" :active="activeTarget === 'old'" @activate="activeTarget = 'old'" @submit="handleOldLabel" />
|
||||
<ScanTargetInput v-if="action === 'RELABEL'" name="newLabel" v-model="newLabel" label="新标签" :placeholder="`应匹配 ${selectedItem.newSkuCode || '审批新 SKU'}`" :active="activeTarget === 'new'" @activate="activeTarget = 'new'" @submit="handleNewLabel" />
|
||||
<template v-if="action !== 'RELABEL'"><textarea v-model="reason" class="reason-input section-gap" maxlength="1000" placeholder="必填:跳过或异常原因" /><view v-if="action === 'EXCEPTION'" class="mode-row section-gap"><button v-for="item in exceptionCodes" :key="item.key" class="mode-button" :class="{ active: exceptionCode === item.key }" @tap="exceptionCode = item.key">{{ item.name }}</button></view></template>
|
||||
<button class="primary" :disabled="busy || !canSubmitUnit" @tap="submitUnit">{{ busyAction === 'unit' ? '提交中…' : unitActionText }}</button>
|
||||
</template>
|
||||
<view class="work-card section-gap"><view class="row-head"><text class="card-title">完成照片</text><text class="status-pill">{{ photoFileIds.length }} 张</text></view><button class="secondary" :disabled="busy" @tap="choosePhoto">拍照或从相册选择</button><text v-for="(name,index) in photoNames" :key="`${name}:${index}`" class="meta-line">{{ index + 1 }}. {{ name || '换标现场照片' }}</text><textarea v-model="remark" class="reason-input section-gap" maxlength="1000" placeholder="完成备注(选填)" /><button class="primary" :disabled="busy || !allProcessed || !photoFileIds.length" @tap="completeTask">{{ busyAction === 'complete' ? '提交中…' : '完成现场换标并等待复核' }}</button><text class="hint">每件货物必须换标、跳过或登记异常,且至少上传一张现场照片。</text></view>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else><view v-if="current.taskStatus !== 'WAIT_REVIEW'" class="empty-box section-gap"><text class="empty-copy">当前任务尚未进入待复核状态。</text></view><template v-else><textarea v-model="remark" class="reason-input section-gap" maxlength="1000" placeholder="复核备注;退回重做时建议填写原因" /><view class="action-row"><button class="danger" :disabled="busy" @tap="review('REWORK')">退回重做</button><button class="primary" :disabled="busy" @tap="review('APPROVE')">复核通过</button></view></template></template>
|
||||
</view>
|
||||
|
||||
<view v-if="error" class="error-box" role="alert"><text class="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><button class="secondary" @tap="retry">重新读取任务</button></view>
|
||||
<view v-if="successMessage" class="success-box" role="status"><text class="success-title">{{ successMessage }}</text></view>
|
||||
</view></scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { claimRelabelTask, completeRelabelTask, getRelabelTask, listRelabelCandidates, relabelUnit, reportRelabelException, reviewRelabelTask, skipRelabelUnit, uploadRelabelPhoto } from '@/api/relabel.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { releaseWarehouseKey, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
|
||||
const STAGES = { relabelReceive: 'receive', relabelExecute: 'execute', relabelReview: 'review' }
|
||||
const isConflict = (error) => ['409', 'A0409', 'B0409'].includes(String(error?.code || ''))
|
||||
export default {
|
||||
components: { PortalHeader, ScanTargetInput },
|
||||
data() { return { user: session.currentUser, stage: 'receive', candidates: [], current: null, selectedItem: null, activeTarget: 'task', loading: false, busy: false, busyAction: '', error: null, successMessage: '', exceptionCode: 'OTHER', actionModes: [{ key: 'RELABEL', name: '正常换标' }, { key: 'SKIP', name: '跳过' }, { key: 'EXCEPTION', name: '异常' }], exceptionCodes: [{ key: 'DAMAGED', name: '破损' }, { key: 'MISSING_LABEL', name: '缺标签' }, { key: 'LABEL_MISMATCH', name: '标签不符' }, { key: 'UNREADABLE', name: '无法识别' }, { key: 'OTHER', name: '其他' }] } },
|
||||
computed: {
|
||||
title() { return ({ receive: '换标收货', execute: '换标执行', review: '换标复核' })[this.stage] },
|
||||
candidateHeading() { return this.stage === 'review' ? '待复核任务' : this.stage === 'execute' ? '处理中任务' : '可认领任务' },
|
||||
emptyText() { return `暂无${this.candidateHeading}` },
|
||||
visibleCandidates() { if (this.stage === 'review') return this.candidates.filter((item) => item.taskStatus === 'WAIT_REVIEW'); if (this.stage === 'execute') return this.candidates.filter((item) => item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator); return this.candidates.filter((item) => !item.taskStatus || (item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator)) },
|
||||
taskCode: { get() { return warehouseWork.relabel.currentNo }, set(value) { updateWarehouseWork('relabel', { currentNo: value }) } },
|
||||
oldLabel: { get() { return warehouseWork.relabel.oldLabel }, set(value) { updateWarehouseWork('relabel', { oldLabel: value }) } },
|
||||
newLabel: { get() { return warehouseWork.relabel.newLabel }, set(value) { updateWarehouseWork('relabel', { newLabel: value }) } },
|
||||
action: { get() { return warehouseWork.relabel.action }, set(value) { updateWarehouseWork('relabel', { action: value }) } },
|
||||
reason: { get() { return warehouseWork.relabel.reason }, set(value) { updateWarehouseWork('relabel', { reason: value }) } },
|
||||
remark: { get() { return warehouseWork.relabel.remark }, set(value) { updateWarehouseWork('relabel', { remark: value }) } },
|
||||
photoFileIds() { return warehouseWork.relabel.photoFileIds || [] }, photoNames() { return warehouseWork.relabel.photoNames || [] },
|
||||
allProcessed() { return Boolean(this.current?.expectedPackages) && this.current.relabelledPackages + this.current.skippedPackages + this.current.exceptionPackages === this.current.expectedPackages },
|
||||
canSubmitUnit() { if (!this.selectedItem || !this.oldLabel.trim()) return false; if (this.action === 'RELABEL') return Boolean(this.newLabel.trim()); return Boolean(this.reason.trim()) },
|
||||
unitActionText() { return ({ RELABEL: '确认本件换标', SKIP: '确认跳过本件', EXCEPTION: '登记本件异常' })[this.action] }
|
||||
},
|
||||
onLoad(options) { this.stage = STAGES[options.op] || 'receive' },
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.startScanner(); this.bootstrap() },
|
||||
onHide() { this.stopScanner() }, onUnload() { 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 },
|
||||
handleHardwareScan(value) { if (this.activeTarget === 'old') { this.oldLabel = value; this.activeTarget = this.action === 'RELABEL' ? 'new' : 'old'; return } if (this.activeTarget === 'new') { this.newLabel = value; return } this.taskCode = value; this.openTask(value) },
|
||||
handleOldLabel(value) { this.oldLabel = String(value?.detail?.value ?? value ?? '').trim(); if (this.action === 'RELABEL') this.activeTarget = 'new' },
|
||||
handleNewLabel(value) { this.newLabel = String(value?.detail?.value ?? value ?? '').trim() },
|
||||
async bootstrap() { await this.loadCandidates(); if (this.taskCode) await this.openTask(this.taskCode, false) },
|
||||
async loadCandidates() { if (this.loading) return; this.loading = true; this.error = null; try { this.candidates = await listRelabelCandidates() } catch (error) { this.setError(error, '换标任务加载失败') } finally { this.loading = false } },
|
||||
async openTask(raw, sound = true) { const no = String(raw?.detail?.value ?? raw ?? this.taskCode).trim(); if (!no || this.busy) return; this.taskCode = no; await this.run('detail', async () => { this.applyCurrent(await getRelabelTask(no)); if (sound) playSuccessSound() }, false) },
|
||||
applyCurrent(value) { this.current = value; this.taskCode = value.relabelNo; if (this.selectedItem) this.selectedItem = value.items.find((item) => item.itemId === this.selectedItem.itemId) || null },
|
||||
selectItem(item) { if (this.remaining(item) <= 0) return; this.selectedItem = item; this.oldLabel = ''; this.newLabel = ''; this.reason = ''; this.activeTarget = 'old' },
|
||||
remaining(item) { return Math.max(0, item.expectedPackages - item.relabelledPackages - item.skippedPackages - item.exceptionPackages) },
|
||||
changeAction(value) { this.action = value; this.newLabel = ''; this.reason = ''; this.activeTarget = 'old' },
|
||||
async claimTask() { if (!this.current) return; const payload = { relabelNo: this.current.relabelNo }; const key = warehouseIdempotencyKey('relabel', 'claim', payload); await this.run('claim', async () => { this.applyCurrent(await claimRelabelTask(this.current.relabelNo, { idempotencyKey: key.value })); releaseWarehouseKey('relabel', key.signature); this.successMessage = '换标任务已认领,可进入换标执行'; await this.loadCandidates() }, true, key) },
|
||||
async submitUnit() { if (!this.canSubmitUnit || this.busy) return; const base = { itemId: this.selectedItem.itemId, oldLabel: this.oldLabel.trim(), expectedVersion: this.current.version }; const payload = { relabelNo: this.current.relabelNo, action: this.action, ...base, newLabel: this.newLabel.trim(), reason: this.reason.trim(), exceptionCode: this.exceptionCode }; const key = warehouseIdempotencyKey('relabel', 'unit', payload); await this.run('unit', async () => { let value; if (this.action === 'SKIP') value = await skipRelabelUnit(this.current.relabelNo, { ...base, reason: this.reason.trim(), idempotencyKey: key.value }); else if (this.action === 'EXCEPTION') value = await reportRelabelException(this.current.relabelNo, { ...base, exceptionCode: this.exceptionCode, reason: this.reason.trim(), idempotencyKey: key.value }); else value = await relabelUnit(this.current.relabelNo, { ...base, newLabel: this.newLabel.trim(), idempotencyKey: key.value }); releaseWarehouseKey('relabel', key.signature); this.applyCurrent(value); this.oldLabel = ''; this.newLabel = ''; this.reason = ''; this.activeTarget = 'old'; this.successMessage = '本件处理结果已保存' }, true, key) },
|
||||
choosePhoto() { if (this.busy || !this.current) return; uni.chooseImage({ count: 1, sourceType: ['camera', 'album'], success: ({ tempFilePaths, tempFiles }) => { const path = tempFilePaths?.[0]; if (Number(tempFiles?.[0]?.size || 0) > 10 * 1024 * 1024) return this.setError(new Error('照片不能超过 10MB')); if (path) this.uploadPhoto(path) } }) },
|
||||
async uploadPhoto(path) { await this.run('photo', async () => { const file = await uploadRelabelPhoto(this.current.relabelNo, path); updateWarehouseWork('relabel', { photoFileIds: [...this.photoFileIds, String(file.fileId)], photoNames: [...this.photoNames, file.fileName || '换标现场照片'] }); this.successMessage = '现场照片已上传' }, false) },
|
||||
async completeTask() { if (!this.allProcessed || !this.photoFileIds.length) return; const payload = { relabelNo: this.current.relabelNo, photoFileIds: this.photoFileIds, expectedVersion: this.current.version, remark: this.remark.trim() }; const key = warehouseIdempotencyKey('relabel', 'complete', payload); await this.run('complete', async () => { this.applyCurrent(await completeRelabelTask(this.current.relabelNo, { photoFileIds: this.photoFileIds, expectedVersion: this.current.version, idempotencyKey: key.value, remark: this.remark.trim() || undefined })); releaseWarehouseKey('relabel', key.signature); updateWarehouseWork('relabel', { photoFileIds: [], photoNames: [], remark: '' }); this.successMessage = '现场换标已提交复核'; await this.loadCandidates() }, true, key) },
|
||||
async review(decision) { if (!this.current || this.current.taskStatus !== 'WAIT_REVIEW') return; const payload = { relabelNo: this.current.relabelNo, decision, expectedVersion: this.current.version, remark: this.remark.trim() }; const key = warehouseIdempotencyKey('relabel', 'review', payload); await this.run('review', async () => { this.applyCurrent(await reviewRelabelTask(this.current.relabelNo, { decision, expectedVersion: this.current.version, idempotencyKey: key.value, remark: this.remark.trim() || undefined })); releaseWarehouseKey('relabel', key.signature); this.remark = ''; this.successMessage = decision === 'APPROVE' ? '换标复核已通过' : '换标任务已退回重做'; await this.loadCandidates() }, true, key) },
|
||||
async retry() { if (this.current?.relabelNo) await this.openTask(this.current.relabelNo, false); else await this.loadCandidates() },
|
||||
async reloadCurrent() { if (this.current?.relabelNo) this.applyCurrent(await getRelabelTask(this.current.relabelNo)) },
|
||||
async run(action, work, refreshOnConflict = false, key = null) { if (this.busy) return; this.busy = true; this.busyAction = action; this.error = null; this.successMessage = ''; try { await work(); if (!['detail'].includes(action)) playSuccessSound() } catch (error) { this.setError(error, `${this.title}失败`); if (refreshOnConflict && isConflict(error)) { if (key) releaseWarehouseKey('relabel', key.signature); try { await this.reloadCurrent(); this.error.message += ',已刷新任务版本,请重新确认' } catch (_) {} } } finally { this.busy = false; this.busyAction = '' } },
|
||||
setError(error, fallback = '换标作业失败') { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
|
||||
statusLabel(value) { return ({ PENDING_PROCESSING: '待认领', PROCESSING: '处理中', WAIT_REVIEW: '待复核', COMPLETED: '已完成' })[value] || value || '待认领' },
|
||||
formatTime(value) { return value ? String(value).replace('T', ' ').slice(0, 16) : '—' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">@import '../warehouse/operations.scss';.action-row>.danger,.action-row>.primary{width:50%}.item-card.current{border-color:#6ea3ee;background:#f3f8ff}</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<view v-if="user" class="page-shell portal-page work-page">
|
||||
<PortalHeader title="库存盘点" :user="user" fallback-url="/pages/module/module?key=inventory" />
|
||||
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
|
||||
<view class="work-heading"><text class="work-title">库存盘点</text><text class="work-subtitle">按 Token 绑定的海外仓冻结库位或托盘库存,逐位置实盘后提交差异</text></view>
|
||||
<view class="work-card">
|
||||
<text class="card-title">新建盘点任务</text>
|
||||
<view class="mode-row"><button class="mode-button" :class="{ active: scopeType === 'LOCATION' }" @tap="changeScope('LOCATION')">按库位</button><button class="mode-button" :class="{ active: scopeType === 'PALLET' }" @tap="changeScope('PALLET')">按托盘</button></view>
|
||||
<ScanTargetInput name="scope" v-model="scopeCode" :label="scopeType === 'LOCATION' ? '库位码' : '托盘码'" :placeholder="scopeType === 'LOCATION' ? '扫描或输入库位码' : '扫描或输入托盘码'" :active="true" @submit="createTask" />
|
||||
<button class="primary" :disabled="!scopeCode.trim() || busy" @tap="createTask(scopeCode)">{{ busyAction === 'create' ? '创建中…' : '创建并冻结盘点范围' }}</button>
|
||||
</view>
|
||||
|
||||
<view class="row-head"><text class="card-title">进行中的任务</text><button class="link-button" :disabled="loading" @tap="loadTasks">{{ loading ? '刷新中…' : '刷新' }}</button></view>
|
||||
<view v-if="loading && !tasks.length" class="empty-box"><uni-load-more status="loading" /><text class="empty-copy">正在加载盘点任务…</text></view>
|
||||
<view v-else-if="!tasks.length" class="empty-box"><uni-icons type="info" color="#8090a3" size="32" /><text class="empty-copy">暂无进行中的盘点任务</text></view>
|
||||
<view v-else class="task-list"><view v-for="task in tasks" :key="task.taskNo" class="task-card" :class="{ current: current?.task?.taskNo === task.taskNo }" @tap="openTask(task.taskNo)"><view class="row-head"><view><text class="business-code">{{ task.taskNo }}</text><text class="meta-line">{{ scopeLabel(task.scopeType) }} · {{ task.scopeCode || '—' }}</text></view><text class="status-pill">{{ statusLabel(task.status) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ task.positionCount }}</strong><text>位置</text></view><view class="metric"><strong>{{ task.countedCount }}</strong><text>已盘</text></view><view class="metric"><strong>{{ task.differenceCount }}</strong><text>差异</text></view><text class="link-button">继续 ›</text></view></view></view>
|
||||
|
||||
<view v-if="current" class="work-card">
|
||||
<view class="row-head"><view><text class="card-title">当前任务</text><text class="business-code">{{ current.task.taskNo }}</text><text class="meta-line">{{ scopeLabel(current.task.scopeType) }} · {{ current.task.scopeCode || '—' }}</text></view><text class="status-pill" :class="{ good: current.task.status === 'SUBMITTED' }">{{ statusLabel(current.task.status) }}</text></view>
|
||||
<view v-if="!current.items.length" class="empty-box"><text class="empty-copy">该任务暂无库存位置</text></view>
|
||||
<view v-else class="item-list section-gap"><view v-for="item in current.items" :key="item.itemId" class="item-card" :class="{ done: item.actualPackages != null }">
|
||||
<view class="row-head"><view><text class="business-code">{{ item.skuCode || 'SKU 未关联' }}</text><text class="meta-line">{{ item.productName || '商品名称未关联' }}</text><text class="meta-line">{{ item.inboundCode || '无入仓码' }} · {{ item.palletCode || '未绑定托盘' }} · {{ item.locationCode || '未绑定库位' }}</text></view><text class="status-pill" :class="{ good: item.actualPackages != null }">{{ item.actualPackages == null ? '待盘' : '已盘' }}</text></view>
|
||||
<view class="item-grid"><view><strong>{{ item.bookPackages }}</strong><text>账面箱数</text></view><view><strong>{{ item.actualPackages == null ? '—' : item.actualPackages }}</strong><text>实盘箱数</text></view><view><strong>{{ item.differencePackages == null ? '—' : item.differencePackages }}</strong><text>差异</text></view></view>
|
||||
<input v-model.number="item.inputPackages" class="number-input section-gap" type="number" inputmode="numeric" placeholder="输入实盘箱数(可为 0)" />
|
||||
<button class="secondary" :disabled="busy || !validCount(item) || current.task.status !== 'IN_PROGRESS'" @tap="saveCount(item)">{{ isSaving(item) ? '保存中…' : '保存本位置实盘数' }}</button>
|
||||
</view></view>
|
||||
<template v-if="current.task.status === 'IN_PROGRESS'"><text class="card-title section-gap">提交原因</text><textarea v-model="reason" class="reason-input" maxlength="500" placeholder="必填,例如:月度循环盘点" /><button class="primary" :disabled="busy || !allCounted || !reason.trim()" @tap="submitTask">{{ busyAction === 'submit' ? '提交中…' : '提交盘点并调整差异' }}</button><text class="hint">所有位置完成实盘后才能提交;版本变化时页面会自动刷新。</text></template>
|
||||
</view>
|
||||
|
||||
<view v-if="error" class="error-box" role="alert"><text class="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><button class="secondary" @tap="retry">重新读取</button></view>
|
||||
<view v-if="successMessage" class="success-box" role="status"><text class="success-title">{{ successMessage }}</text></view>
|
||||
</view></scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { countStocktake, createStocktake, getStocktake, listStocktakes, submitStocktake } from '@/api/warehouse-operations.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { releaseWarehouseKey, restoreWarehouseWork, updateWarehouseWork, warehouseIdempotencyKey, warehouseWork } from '@/stores/warehouse-work.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||||
|
||||
const isConflict = (error) => ['409', 'A0409', 'B0409'].includes(String(error?.code || ''))
|
||||
export default {
|
||||
components: { PortalHeader, ScanTargetInput },
|
||||
data() { return { user: session.currentUser, tasks: [], current: null, loading: false, busy: false, busyAction: '', busyItem: '', error: null, successMessage: '' } },
|
||||
computed: {
|
||||
scopeType: { get() { return warehouseWork.stocktake.scopeType }, set(value) { updateWarehouseWork('stocktake', { scopeType: value }) } },
|
||||
scopeCode: { get() { return warehouseWork.stocktake.scopeCode }, set(value) { updateWarehouseWork('stocktake', { scopeCode: value }) } },
|
||||
reason: { get() { return warehouseWork.stocktake.reason }, set(value) { updateWarehouseWork('stocktake', { reason: value }) } },
|
||||
allCounted() { return Boolean(this.current?.items?.length) && this.current.items.every((item) => item.actualPackages != null) }
|
||||
},
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.startScanner(); this.bootstrap() },
|
||||
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
|
||||
methods: {
|
||||
isSaving(item) { return this.busyItem === item.positionId },
|
||||
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => { this.scopeCode = value; this.createTask(value) }, onError: (error) => this.setError(error, '扫码广播注册失败') }) },
|
||||
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
|
||||
async bootstrap() { await this.loadTasks(); const saved = warehouseWork.stocktake.taskNo; if (saved) await this.openTask(saved, false) },
|
||||
async loadTasks() { if (this.loading) return; this.loading = true; this.error = null; try { this.tasks = await listStocktakes('IN_PROGRESS') } catch (error) { this.setError(error, '盘点任务加载失败') } finally { this.loading = false } },
|
||||
changeScope(value) { this.scopeType = value; this.scopeCode = '' },
|
||||
async createTask(raw) { const code = String(raw?.detail?.value ?? raw ?? this.scopeCode).trim(); if (!code || this.busy) return; this.scopeCode = code; const payload = { scopeType: this.scopeType, scopeCode: code }; const key = warehouseIdempotencyKey('stocktake', 'create', payload); await this.run('create', async () => { const value = await createStocktake({ ...payload, idempotencyKey: key.value }); releaseWarehouseKey('stocktake', key.signature); this.applyCurrent(value); this.scopeCode = ''; await this.loadTasks(); this.successMessage = '盘点任务已创建' }) },
|
||||
async openTask(taskNo, feedback = true) { if (!taskNo || this.busy) return; await this.run('detail', async () => { const value = await getStocktake(taskNo); this.applyCurrent(value); if (feedback) playSuccessSound() }, false) },
|
||||
applyCurrent(value) { this.current = value; this.current.items = this.current.items.map((item) => ({ ...item, inputPackages: item.actualPackages })); updateWarehouseWork('stocktake', { taskNo: value.task.taskNo }) },
|
||||
validCount(item) { const value = Number(item.inputPackages); return Number.isInteger(value) && value >= 0 },
|
||||
async saveCount(item) { if (!this.validCount(item) || this.busy) return; const payload = { taskNo: this.current.task.taskNo, positionId: item.positionId, actualPackages: Number(item.inputPackages), expectedVersion: item.version }; const key = warehouseIdempotencyKey('stocktake', 'count', payload); this.busyItem = item.positionId; await this.run('count', async () => { await countStocktake(this.current.task.taskNo, { positionId: item.positionId, actualPackages: payload.actualPackages, expectedVersion: item.version, idempotencyKey: key.value }); releaseWarehouseKey('stocktake', key.signature); await this.reloadCurrent(); this.successMessage = '实盘数量已保存' }, true, key) },
|
||||
async submitTask() { if (!this.allCounted || !this.reason.trim() || this.busy) return; const payload = { taskNo: this.current.task.taskNo, expectedVersion: this.current.task.version, reason: this.reason.trim() }; const key = warehouseIdempotencyKey('stocktake', 'submit', payload); await this.run('submit', async () => { const value = await submitStocktake(this.current.task.taskNo, { expectedVersion: payload.expectedVersion, reason: payload.reason, idempotencyKey: key.value }); releaseWarehouseKey('stocktake', key.signature); this.applyCurrent(value); this.reason = ''; this.successMessage = '盘点已提交'; await this.loadTasks() }, true, key) },
|
||||
async reloadCurrent() { if (this.current?.task?.taskNo) this.applyCurrent(await getStocktake(this.current.task.taskNo)) },
|
||||
async retry() { this.error = null; if (this.current?.task?.taskNo) await this.openTask(this.current.task.taskNo, false); else await this.loadTasks() },
|
||||
async run(action, work, refreshOnConflict = false, key = null) { if (this.busy) return; this.busy = true; this.busyAction = action; this.error = null; this.successMessage = ''; try { await work(); playSuccessSound() } catch (error) { this.setError(error, '盘点作业失败'); if (refreshOnConflict && isConflict(error)) { if (key) releaseWarehouseKey('stocktake', key.signature); try { await this.reloadCurrent(); this.error.message += ',已刷新最新版本,请重新确认' } catch (_) {} } } finally { this.busy = false; this.busyAction = ''; this.busyItem = '' } },
|
||||
setError(error, fallback) { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
|
||||
scopeLabel(value) { return value === 'PALLET' ? '托盘盘点' : '库位盘点' },
|
||||
statusLabel(value) { return ({ IN_PROGRESS: '进行中', SUBMITTED: '已提交' })[value] || value || '状态未知' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">@import '../warehouse/operations.scss';</style>
|
||||
@@ -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}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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\//)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user