merge overseas PDA frontend changes

This commit is contained in:
2026-10-08 15:45:25 +08:00
26 changed files with 890 additions and 67 deletions
+3
View File
@@ -7,3 +7,6 @@
!dist/build/
!dist/build/h5/
!dist/build/h5/**
!deploy/Dockerfile.h5.production
!deploy/nginx.production.conf.template
+6 -3
View File
@@ -6,7 +6,7 @@
2. 登录、Token 或统一入口请求先读 `src/api/auth.js`、`src/stores/session.js`、`src/config/runtime.js` 和 `../../docs/统一认证与服务通信契约.md`。
3. 沿实际 API 定位:入仓/库存契约读 `../../baiyue-firstleg/lastleg-service/AGENTS.md`;资源契约读 `../../baiyue-firstleg/warehouse-overseas-service/AGENTS.md`;网关路径或鉴权问题才读 `../../baiyue-firstleg/gateway/AGENTS.md`。跨模块流程或入口不明才读 `../../docs/海外仓业务/README.md`;纯页面改动不自动读取后端指引。
最小读取集按任务保持在本文件、目标页面/API 和一个直接服务指引;只有跨服务状态或鉴权无法判断时才增加第二个服务指引。累计超过约 250 行时优先读取命中段,不全文加载海外仓 README。
最小读取集按任务保持在适用指引和目标页面/API;纯展示任务不增加服务指引,服务端判断才读直接服务指引,跨服务状态或鉴权无法判断时再增加相关方。读取预算遵循根指引的行数与字符数标准,先读适用规则,再检索业务命中段,不全文加载海外仓 README。
## 项目定位
@@ -50,7 +50,8 @@
| 商品移库真实作业 | `src/pages/move/move.vue`、`src/api/warehouse-operations.js`、`src/stores/warehouse-work.js` | 扫描来源托盘与目标库位执行同仓移库;冲突后重新读取服务端事实 |
| 换标真实作业 | `src/pages/relabel/relabel.vue`、`src/api/relabel.js` | 同一页面按操作参数承载换标收货、执行与复核;执行页按当前单明细切换 SKU/箱唛 Tab,组合明细同时显示两类需求。SKU 逐条扫码旧新SKU并携带审批件数,箱唛扫码仓库唛头/新FBA箱唛;组合单按同箱保留两个Tab的核对输入,全部核对后只提交一次箱事实。认领同步处理中;支持拍照、异常/返工和幂等恢复 |
| 现场查询真实作业 | `src/pages/query/query.vue`、`src/api/warehouse-operations.js` | 同一页面按操作参数承载库存、库位与托盘查询,只展示稳定业务标识 |
| 排车拣货与发车 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 普通进入默认展示任务列表和“上次作业”标记,不因本地草稿自动打开详情;只有出库扫码明确跳转 `?resume=1` 时恢复对应排车。详情提供独立返回列表入口;托盘业务码扫码拣货、幂等完成及发车仍严格消费服务端 `canDepart/departBlockReason`。公网前缀 `/api/pda/v1/last-leg/dispatches/**` |
| 排车拣货与发车 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 普通进入默认展示任务列表和“上次作业”标记,不因本地草稿自动打开详情;只有出库扫码明确跳转 `?resume=1` 时恢复对应排车。详情提供独立返回列表入口;托盘业务码扫码拣货、幂等完成前消费服务端 `canPick/pickBlockReason` 调度审核结果,整车发车消费 `canDepart/departBlockReason`(含理货审核)。公网前缀 `/api/pda/v1/last-leg/dispatches/**` |
| 整车发车与证照提醒 | 同上 `dispatch.vue`、`fulfillment.js` 的 `tripDepartureRequest` | 显示 `tripNo/tripDispatchCount/driverWarning`;发车提交 `tripRecordVersion` 对应的 `recordVersion`,幂等键按车次和版本保留。响应必须为该车次 `IN_TRANSIT` 且包含当前排车,随后读取服务端任务状态再释放幂等键;不能把整车状态当子单状态,也不能缺省伪造 `DEPARTED`。证照即将到期提醒不独立禁用按钮,门禁仍以服务端结果为准 |
| 普通/一件代发出库 | `src/pages/outbound/outbound.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 首次进入清空扫描框并展示任务列表和“上次作业”标记,不自动恢复旧详情;详情提供独立返回列表入口。逐箱扫码、DP 托盘整托拣取及幂等完成保持不变;扫描排车业务号显式跳转排车 `?resume=1`。公网前缀 `/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` | 会话隔离、退出清理和后续真实作业历史契约 |
@@ -85,10 +86,12 @@
- 库内、换标与现场查询只调用 `/api/pda/**` 业务 API/DTO,并统一受 `lastleg:pda:operate`、Token 冻结仓库范围和服务端资源归属约束;写操作必须保留稳定幂等键,409 后重新读取服务端状态,不得把平台、客户或内部接口直接暴露给 PDA。
- 柜号签收入仓调用 Lastleg `/api/pda/v1/last-leg/receiving-containers/**`;退货签收与合箱分别调用 `/api/pda/v1/last-leg/return-receipts/**`、`/api/pda/v1/last-leg/return-inventory/**`,具体入口见上表。浏览器或设备请求必须经统一 `gateway` 的显式 PDA 路由,不得直连 Lastleg。
- 海外业务 Token、PDA 权限和可信身份最终由 Gateway 与服务端校验;前端不得用工作台 `available`、本地客户 ID 或仓库 ID 代替授权和资源归属校验。
- 排车和出库入口只调用专用 PDA 接口;客户与冻结海外仓范围由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不把内部数据库 ID 展示成业务编号。网络失败保留草稿和幂等键,恢复后以服务端任务状态为准;排车详情返回的 `canDepart`、`departBlockReason` 是发车按钮唯一门禁,平台未转派送生成 `WAIT_OUTBOUND` 出库单时不得发车;完成或发车成功后不得重复提交。派送始终由 Admin/Client 承载。
- 排车和出库入口只调用专用 PDA 接口;客户与冻结海外仓范围由 Token、Gateway 和服务端解析,前端不提交 `customerId`、`warehouseId`,也不把内部数据库 ID 展示成业务编号。网络失败保留草稿和幂等键,恢复后以服务端任务状态为准;排车详情返回的 `canDepart`、`departBlockReason` 是发车资格依据,拣货及完成按钮同时核对当前单`canPick/pickBlockReason`;平台未转派送生成 `WAIT_OUTBOUND` 出库单时不得发车;完成或发车成功后不得重复提交。派送始终由 Admin/Client 承载。
## 质量门禁
- 已发车排车详情按需挂载`src/components/DispatchHandoverStatus.vue`,通过`src/api/trip-handover.js`读取当前派送资源和最近交接;每次进入/刷新一次GET,列表不逐行请求。仅只读、不提交客户或仓ID;后台确认交接,已完成派送保留完成时资源。回归`tests/trip-handover.test.mjs`。
- 安装:`npm install`;检查:`npm test`。
- 仓储转运测量、打托、照片或标签入口变更运行 `node --test tests/warehouse-transfer-api.test.mjs tests/project-contract.test.mjs tests/workbench-availability.test.mjs`;静态合同不替代真实扫码、绑定及打印验收。
- 退货签收、合箱或工作台入口变更先运行 `node --test tests/return-receipt-api.test.mjs tests/return-packing-api.test.mjs tests/project-contract.test.mjs tests/workbench-availability.test.mjs`;离线检查不替代真实 API 联调与真机扫码验收。
+1 -1
View File
@@ -46,7 +46,7 @@ npm run build:app
- 出库作业已接 `/api/pda/v1/last-leg/outbound-tasks/**`:支持普通出库和一件代发任务列表/详情、托盘码/箱码/SKU/入仓码扫码拣货与核平完成;不会展示或进入派送流程。
- 排车与出库写操作使用可恢复的稳定幂等键。网络失败保留当前任务、扫码草稿和幂等键;成功后释放键并按服务端状态恢复,避免重复完成或发车。
- 入仓请求不手填客户、计划、分配、仓库或库位标识;身份与当前柜范围由 Token、Gateway 和服务端会话确定。页面同时支持 PDA 广播扫码与 H5 人工输入。
- 登录、恢复和刷新会 fail-closed 校验统一 Token 的平台操作主体和冻结海外仓,并从 Auth 登录/刷新响应的顶层权限快照校验 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`、`lastleg:pda:operate`,不从 JWT 解析权限;并发 401 共享一次 refresh,主体与绑定仓保持一致后原子替换统一 Token 和权限快照,并将原请求或照片上传重放一次。
- 登录、恢复和刷新会 fail-closed 校验统一 Token 的平台操作主体和冻结海外仓,并从 Auth 登录/刷新响应的顶层权限快照校验 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`、`lastleg:pda:operate`,不从 JWT 解析权限;并发 401 共享一次 refresh,主体与绑定仓保持一致后原子替换统一 Token 和权限快照,并将原请求或照片上传重放一次。刷新失败或重放后仍未授权时清空会话和作业草稿,并返回登录页;退出或重新登录后,旧请求不能重放到新会话,迟到的刷新响应也不能恢复旧会话或清除新会话。
- 库存盘点、商品移库、换标收货/执行/复核以及库存/库位/托盘查询均已接入 `/api/pda/**` 专用接口;支持 PDA 广播扫码与 H5 人工输入,并按服务端版本、幂等键和冲突状态恢复,不调用平台、客户或内部接口。
- 本地契约测试与 H5/App 资源编译不等于真实测试环境 HTTP 或 PDA 真机验收。
+5
View File
@@ -0,0 +1,5 @@
ARG NGINX_BASE_IMAGE=nginx:1.27-alpine
FROM ${NGINX_BASE_IMAGE}
COPY deploy/nginx.production.conf.template /etc/nginx/templates/default.conf.template
COPY dist/build/h5/ /srv/pda-h5/
EXPOSE 80
+29
View File
@@ -0,0 +1,29 @@
name: firstleg-production-pda-overseas-h5
services:
pda-overseas-h5:
build:
context: ..
dockerfile: deploy/Dockerfile.h5.production
args:
NGINX_BASE_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
image: pda-overseas-h5-prod:${IMAGE_TAG:?IMAGE_TAG is required}
container_name: pda-overseas-h5-prod
restart: unless-stopped
environment:
API_UPSTREAM: "http://gateway:9000"
ports:
- "127.0.0.1:${HOST_PORT:-29006}:80"
healthcheck:
test: ["CMD-SHELL", "wget -qO /dev/null http://127.0.0.1/healthz && wget -qO /dev/null http://127.0.0.1/"]
interval: 5s
timeout: 3s
retries: 12
start_period: 5s
networks:
backend:
aliases:
- pda-overseas-h5-prod
networks:
backend:
external: true
name: firstleg-production-network
+25
View File
@@ -0,0 +1,25 @@
server {
listen 80;
server_name _;
root /srv/pda-h5;
location = /healthz {
default_type text/plain;
add_header Cache-Control "no-store" always;
return 200 "ok\n";
}
location /gateway-api/ {
client_max_body_size 55m;
proxy_pass ${API_UPSTREAM}/api/;
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
index index.html;
try_files $uri $uri/ =404;
add_header Cache-Control "no-store" always;
}
}
+19 -3
View File
@@ -1,6 +1,8 @@
import { API_ROOT } from '../config/runtime.js'
function authRequest(path, data) {
const pendingRefreshes = new Map()
function authRequest(path, data, accessToken = '') {
return new Promise((resolve, reject) => {
const url = `${API_ROOT}${path}`
console.info('[Overseas PDA auth] request', { method: 'POST', url })
@@ -8,6 +10,8 @@ function authRequest(path, data) {
url,
method: 'POST',
data,
header: { Accept: 'application/json', 'Content-Type': 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}) },
success(response) {
const body = response.data || {}
console.info('[Overseas PDA auth] response', { url, statusCode: response.statusCode, code: body.code,
@@ -15,9 +19,10 @@ function authRequest(path, data) {
contentType: response.header?.['Content-Type'] || response.header?.['content-type'],
requestId: body.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id'],
traceId: body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id'] })
if (String(body.code ?? response.statusCode) !== '200') {
if (response.statusCode !== 200 || String(body.code) !== '200') {
const error = new Error(body.message || '认证服务请求失败')
error.code = body.code || response.statusCode
error.statusCode = response.statusCode
error.requestId = body.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id']
error.traceId = body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id']
reject(error)
@@ -40,5 +45,16 @@ export function login(loginId, password) {
}
export function refresh(refreshToken) {
return authRequest('/auth/refresh', { refreshToken })
if (pendingRefreshes.has(refreshToken)) return pendingRefreshes.get(refreshToken)
const pending = authRequest('/auth/refresh', { refreshToken }).finally(() => {
if (pendingRefreshes.get(refreshToken) === pending) pendingRefreshes.delete(refreshToken)
})
pendingRefreshes.set(refreshToken, pending)
return pending
}
export function getPendingRefresh(refreshToken) { return pendingRefreshes.get(refreshToken) || null }
export function logout(accessToken, refreshToken) {
return authRequest('/auth/logout', { refreshToken }, accessToken)
}
+14 -1
View File
@@ -24,8 +24,14 @@ export function normalizeDispatchTask(value) {
taskNo: String(value?.taskNo || ''),
dispatchNo: String(value?.dispatchNo || ''),
vehiclePlate: String(value?.vehiclePlate || ''),
tripNo: String(value?.tripNo || ''),
tripRecordVersion: value?.tripRecordVersion == null ? null : String(value.tripRecordVersion),
tripDispatchCount: count(value?.tripDispatchCount),
driverWarning: String(value?.driverWarning || ''),
status: String(value?.status || ''),
canDepart: value?.canDepart === true,
canPick: value?.canPick === true,
pickBlockReason: String(value?.pickBlockReason || ''),
departBlockReason: String(value?.departBlockReason || ''),
items: asList(value?.items).map((item) => ({
...item,
@@ -98,6 +104,13 @@ export function departDispatch(dispatchNo, request) {
return overseasRequest('POST', `${DISPATCH_BASE}/${encoded(dispatchNo)}/depart`, request)
}
export function tripDepartureRequest(task, idempotencyKey) {
if (!task?.tripNo || task.tripRecordVersion == null || !/^\d+$/.test(String(task.tripRecordVersion))) {
throw new Error('车次信息不完整,请刷新任务后再发车')
}
return { recordVersion: String(task.tripRecordVersion), idempotencyKey, remark: '海外 PDA 整车核平后发车' }
}
export async function listOutboundTasks(status = '') {
const suffix = status ? `?status=${encoded(status)}` : ''
return asList(await overseasRequest('GET', `${OUTBOUND_BASE}${suffix}`)).map(normalizeOutboundTask)
@@ -123,7 +136,7 @@ export function outboundPickPayload(item, position, packages, scannedCode = '')
const skuScan = item.skuCode && scannedCode === item.skuCode
return {
outboundPalletItemId: item.outboundPalletItemId,
inventoryPositionId: position.inventoryPositionId,
inventoryPositionId: position.inventoryPositionId || null,
packages: Number(packages),
cartonMarks: skuScan ? [] : position.cartonMark ? [position.cartonMark] : [],
...(skuScan ? { skuCode: item.skuCode } : {})
+53
View File
@@ -0,0 +1,53 @@
import { logout, refresh, getPendingRefresh } from './auth.js'
import { session, signOut, getSessionRevision, decodeAccessToken } from '../stores/session.js'
function replacementForCapturedSession(captured, tokens) {
if (!tokens?.accessToken || typeof tokens.refreshToken !== 'string' || !tokens.refreshToken.trim()) {
throw new Error('退出刷新响应缺少会话凭据')
}
const previous = decodeAccessToken(captured.accessToken)
const replacement = decodeAccessToken(tokens.accessToken)
if (!Number.isFinite(Number(replacement?.exp)) || Number(replacement.exp) * 1000 <= Date.now()) {
throw new Error('退出刷新凭据无效或已过期')
}
for (const name of ['user_id', 'membership_id', 'tenant_id', 'app_code', 'overseas_warehouse_id']) {
if (previous[name] == null || String(previous[name]) !== String(replacement[name])) {
throw new Error('退出刷新返回了不同作业主体,无法确认原会话撤销')
}
}
return { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken }
}
// Active user logout only. Ordinary authentication failures continue to use local signOut.
export function logoutCurrentSession() {
const captured = {
accessToken: session.token, refreshToken: session.refreshToken,
pendingRefresh: getPendingRefresh(session.refreshToken)
}
signOut()
const clearedRevision = getSessionRevision()
const revoke = async () => {
if (!captured.accessToken || !captured.refreshToken) return { revoked: false, reason: 'NO_CAPTURED_SESSION' }
let credentials = captured
let refreshed = false
if (captured.pendingRefresh) {
// Auth rotates access/refresh credentials. Revoke the captured refresh's result,
// while the business refresh wrapper's revision guard prevents local restoration.
refreshed = true
credentials = replacementForCapturedSession(captured, await captured.pendingRefresh)
}
try {
await logout(credentials.accessToken, credentials.refreshToken)
} catch (error) {
if (error.network || error.statusCode !== 401 || refreshed) throw error
// Only an explicit expired-access HTTP 401 permits one refresh and one revoke.
credentials = replacementForCapturedSession(captured, await refresh(captured.refreshToken))
await logout(credentials.accessToken, credentials.refreshToken)
}
return { revoked: true }
}
return revoke().then(result => ({ ...result, clearedRevision }), error => {
error.logoutRevision = clearedRevision
throw error
})
}
+40 -13
View File
@@ -1,7 +1,18 @@
import { refresh } from './auth.js'
import { session, replaceTokens, signOut } from '../stores/session.js'
import { session, replaceTokens, signOut, getSessionRevision } from '../stores/session.js'
let refreshPromise = null
let refreshRevision = -1
function requireCurrentSession(revision) {
if (revision !== getSessionRevision()) throw new Error('登录会话已变更,请重新操作')
}
function expireSession(revision) {
if (revision !== getSessionRevision()) return
signOut()
uni.reLaunch({ url: '/pages/login/login' })
}
function errorFrom(response, fallback) {
const body = response?.data || {}
@@ -21,23 +32,27 @@ function successful(response, body) {
return response?.statusCode === 200 && ['00000', '200'].includes(String(body?.code))
}
async function refreshOverseasToken() {
async function refreshOverseasToken(revision) {
requireCurrentSession(revision)
if (!session.refreshToken) {
signOut()
expireSession(revision)
throw new Error('登录状态已过期,请重新登录')
}
if (!refreshPromise) {
refreshPromise = refresh(session.refreshToken)
.then((tokens) => { replaceTokens(tokens); return session.token })
.catch((error) => { signOut(); throw error })
.finally(() => { refreshPromise = null })
if (!refreshPromise || refreshRevision !== revision) {
refreshRevision = revision
const pending = refresh(session.refreshToken)
.then((tokens) => { requireCurrentSession(revision); replaceTokens(tokens); return session.token })
.catch((error) => { expireSession(revision); throw error })
.finally(() => { if (refreshPromise === pending) refreshPromise = null })
refreshPromise = pending
}
return refreshPromise
}
async function tokenAfterUnauthorized(failedToken) {
async function tokenAfterUnauthorized(failedToken, revision) {
requireCurrentSession(revision)
if (session.token && session.token !== failedToken) return session.token
return refreshOverseasToken()
return refreshOverseasToken(revision)
}
function overseasUrl(path) {
@@ -70,13 +85,17 @@ function rawRequest(method, path, data, token) {
}
export async function overseasRequest(method, path, data, retry = true) {
const revision = getSessionRevision()
console.info('[Overseas PDA API] request', { method, path })
const attemptedToken = session.token
let response = await rawRequest(method, path, data, attemptedToken)
requireCurrentSession(revision)
if (retry && unauthorized(response)) {
const replacementToken = await tokenAfterUnauthorized(attemptedToken)
const replacementToken = await tokenAfterUnauthorized(attemptedToken, revision)
response = await rawRequest(method, path, data, replacementToken)
}
requireCurrentSession(revision)
if (unauthorized(response)) expireSession(revision)
const body = response?.data || {}
console.info('[Overseas PDA API] response', { method, path, statusCode: response?.statusCode, code: body?.code,
message: body?.message, requestId: body?.requestId || response?.header?.['x-request-id'] || response?.header?.['X-Request-Id'],
@@ -149,27 +168,35 @@ function rawDownload(path, token) {
}
export async function overseasUpload(path, filePath, name = 'file', retry = true) {
const revision = getSessionRevision()
const attemptedToken = session.token
let response = await rawUpload(path, filePath, name, attemptedToken)
requireCurrentSession(revision)
let body = null
try { body = JSON.parse(response.data || '{}') } catch (_) { /* handled below */ }
if (retry && unauthorized({ ...response, data: body })) {
const replacementToken = await tokenAfterUnauthorized(attemptedToken)
const replacementToken = await tokenAfterUnauthorized(attemptedToken, revision)
response = await rawUpload(path, filePath, name, replacementToken)
try { body = JSON.parse(response.data || '{}') } catch (_) { body = null }
}
requireCurrentSession(revision)
if (unauthorized({ ...response, data: body })) expireSession(revision)
if (!successful(response, body)) throw errorFrom({ ...response, data: body }, '海外作业文件上传失败')
return body.data
}
export async function overseasDownload(path, options = {}) {
const revision = getSessionRevision()
const { retry = true, expectedContentTypePrefix = '' } = options
const attemptedToken = session.token
let response = await rawDownload(path, attemptedToken)
requireCurrentSession(revision)
if (retry && unauthorized(response)) {
const replacementToken = await tokenAfterUnauthorized(attemptedToken)
const replacementToken = await tokenAfterUnauthorized(attemptedToken, revision)
response = await rawDownload(path, replacementToken)
}
requireCurrentSession(revision)
if (unauthorized(response)) expireSession(revision)
if (response?.statusCode !== 200 || !response?.tempFilePath) {
throw errorFrom({ ...response, data: {} }, '签收照片下载失败')
}
+28
View File
@@ -0,0 +1,28 @@
import { overseasRequest } from './overseas.js'
const text = value => typeof value === 'string' ? value : ''
const transport = value => ({ vehiclePlate: text(value?.vehiclePlate), driverName: text(value?.driverName), driverPhone: text(value?.driverPhone) })
export function normalizeHandoverStatus(value) {
if (!value?.dispatchNo || !value?.tripNo || !Array.isArray(value.deliveries)) throw new Error('交接状态缺少排车或派送依据')
return {
dispatchNo: text(value.dispatchNo), tripNo: text(value.tripNo), tripStatus: text(value.tripStatus),
tripRecordVersion: value.tripRecordVersion == null ? '' : String(value.tripRecordVersion),
originalDeparture: transport(value.originalDeparture),
deliveries: value.deliveries.map(row => {
const packages = Number(row.packages)
if (!row.deliveryNo || row.packages == null || String(row.packages).trim() === '' || !Number.isSafeInteger(packages) || packages <= 0) throw new Error('交接状态中的派送箱数无效')
const remainingPackages = Number(row.remainingPackages)
if (row.remainingPackages == null || String(row.remainingPackages).trim() === '' || !Number.isSafeInteger(remainingPackages) || remainingPackages < 0 || remainingPackages > packages) throw new Error('交接状态中的剩余箱数无效')
return { deliveryNo: text(row.deliveryNo), status: text(row.status), packages, remainingPackages, terminalAt: text(row.terminalAt), transport: transport(row.transport) }
}),
latestHandover: value.latestHandover ? { handoverNo: text(value.latestHandover.handoverNo), handedOverAt: text(value.latestHandover.handedOverAt),
location: text(value.latestHandover.location), previous: transport(value.latestHandover.previous), replacement: transport(value.latestHandover.replacement) } : null
}
}
export async function getDispatchHandoverStatus(dispatchNo) {
const number = String(dispatchNo || '').trim()
if (!number) throw new Error('请先打开排车任务')
const result = normalizeHandoverStatus(await overseasRequest('GET', `/pda/v1/last-leg/dispatches/${encodeURIComponent(number)}/handover-status`))
if (result.dispatchNo !== number) throw new Error('返回的交接记录不属于当前排车任务')
return result
}
+61
View File
@@ -0,0 +1,61 @@
<template>
<view class="handover-card">
<view class="handover-heading"><text class="handover-title">发车与交接记录</text><button :disabled="loading" @tap="load">刷新</button></view>
<text v-if="loading">正在读取当前运输资源…</text>
<view v-else-if="error" class="handover-error" role="alert">
<text>{{ error.message }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text>
<button @tap="load">重新读取</button>
</view>
<template v-else-if="status">
<text class="handover-line">车次 {{ status.tripNo }}</text>
<text class="handover-line">原始发车:{{ status.originalDeparture.vehiclePlate || '—' }} · {{ status.originalDeparture.driverName || '—' }}</text>
<view v-if="status.latestHandover" class="handover-record">
<text class="handover-line">最近交接 {{ status.latestHandover.handoverNo || '—' }}</text>
<text class="handover-line">{{ time(status.latestHandover.handedOverAt) }} · {{ status.latestHandover.location || '—' }}</text>
<text class="handover-line">{{ status.latestHandover.previous.vehiclePlate || '—' }} → {{ status.latestHandover.replacement.vehiclePlate || '—' }}</text>
<text class="handover-line">{{ status.latestHandover.previous.driverName || '—' }} → {{ status.latestHandover.replacement.driverName || '—' }}</text>
</view>
<text v-else class="handover-line">本排车暂无在途交接记录。</text>
<view v-for="delivery in status.deliveries" :key="delivery.deliveryNo" class="handover-delivery">
<text class="handover-line">{{ delivery.deliveryNo }} · 原实发 {{ delivery.packages }} 箱 · 剩余 {{ delivery.remainingPackages }} 箱 · {{ label(delivery) }}</text>
<text class="handover-line">{{ delivery.terminalAt ? '完成时承运' : '当前承运' }}:{{ delivery.transport.vehiclePlate || '—' }} · {{ delivery.transport.driverName || '—' }}</text>
<text v-if="delivery.transport.driverPhone" class="handover-line">联系电话 {{ delivery.transport.driverPhone }}</text>
</view>
<text class="handover-note">交接由后台确认。本页只读,不重复发车。</text>
</template>
</view>
</template>
<script>
import { getDispatchHandoverStatus } from '@/api/trip-handover.js'
import { toDispatchError } from '@/utils/dispatch-error.js'
export default {
props: { dispatchNo: { type: String, required: true }, recordVersion: { type: [String, Number], default: null } },
data() { return { status: null, error: null, loading: false, generation: 0 } },
computed: { scopeKey() { return `${this.dispatchNo}:${this.recordVersion ?? ''}` } },
watch: { scopeKey: { immediate: true, handler() { this.status = null; this.load() } } },
beforeUnmount() { this.generation++ },
methods: {
async load() {
const generation = ++this.generation
this.error = null; this.status = null
if (!this.dispatchNo) { this.loading = false; return }
this.loading = true
try { const result = await getDispatchHandoverStatus(this.dispatchNo); if (generation === this.generation) this.status = result }
catch (cause) { if (generation === this.generation) this.error = toDispatchError(cause, '交接记录读取失败') }
finally { if (generation === this.generation) this.loading = false }
},
time(value) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' },
label(delivery) {
if (delivery.terminalAt) return delivery.status === 'RETURNED' ? '运输已完成' : '已签收'
return { WAIT_DELIVERY: '派送中', RETURN_PENDING: '待退回' }[delivery.status] || '待核对'
}
}
}
</script>
<style scoped>
.handover-card{padding:28rpx;background:#fff;border-radius:20rpx;margin-top:20rpx;color:#334155;font-size:26rpx}
.handover-heading{display:flex;align-items:center;justify-content:space-between;gap:16rpx}.handover-title{font-size:30rpx;font-weight:600}
.handover-heading button{margin:0;font-size:26rpx}.handover-line{display:block;margin-top:12rpx;overflow-wrap:anywhere}
.handover-record,.handover-delivery{padding-top:16rpx;margin-top:16rpx;border-top:1px solid #e2e8f0}.handover-note{display:block;margin-top:20rpx;color:#64748b}
.handover-error{color:#b91c1c}.handover-error text{display:block;margin-top:12rpx}
</style>
+22 -18
View File
@@ -47,7 +47,7 @@
<view class="card-head"><view><text class="card-title">国内装柜照片(只读)</text><text class="container-meta">现场对照国内装柜过程与封柜状态</text></view><text class="status-tag status-good">{{ domesticPhotoCount }} / 5</text></view>
<view class="domestic-photo-grid">
<view v-for="photo in domesticLoadingPhotos" :key="photo.evidenceType" class="domestic-photo-item" @tap="previewDomesticPhoto(photo)">
<image v-if="photo.localPath" class="domestic-photo-thumb" :src="photo.localPath" mode="aspectFill" />
<image v-if="photo.localPath" class="domestic-photo-thumb" :src="photo.localPath" mode="aspectFill" @error="invalidatePhotoPreview(photo)" />
<view v-else class="domestic-photo-placeholder"><uni-load-more v-if="photo.status === 'loading'" status="loading" :show-text="false" /><uni-icons v-else type="image" color="#8090a3" size="22" /><text>{{ photo.status === 'error' ? '加载失败,点按重试' : '正在准备照片' }}</text></view>
<text>{{ photo.label }}</text>
</view>
@@ -58,7 +58,7 @@
<view class="photo-grid">
<view v-for="(step, index) in photoSteps" :key="step.role" class="photo-role" :class="{ complete: photoSlots[index]?.fileId }">
<view class="photo-preview" :class="{ failed: photoSlots[index]?.status === 'error' }" @tap="previewPhoto(index)">
<image v-if="photoSlots[index]?.localPath" class="photo-thumb" :src="photoSlots[index].localPath" mode="aspectFill" />
<image v-if="photoSlots[index]?.localPath" class="photo-thumb" :src="photoSlots[index].localPath" mode="aspectFill" @error="invalidatePhotoPreview(photoSlots[index])" />
<view v-else class="photo-placeholder"><uni-icons type="camera" color="#8090a3" size="23" /><text>未添加</text></view>
</view>
<view class="photo-role-info">
@@ -76,10 +76,10 @@
<template v-else-if="phase === 'receive'">
<ReceivingSessionSummary :session="current" />
<view class="photo-card domestic-photo-card domestic-photo-collapsible">
<view class="card-head" @tap="domesticPhotosExpanded = !domesticPhotosExpanded"><view><text class="card-title">国内装柜照片</text><text class="container-meta">已同步 {{ domesticPhotoCount }} / 5,点按{{ domesticPhotosExpanded ? '收起' : '展开' }}对照</text></view><text class="container-detail-link">{{ domesticPhotosExpanded ? '收起' : '查看' }} ›</text></view>
<view class="card-head" @tap="toggleDomesticPhotos"><view><text class="card-title">国内装柜照片</text><text class="container-meta">已同步 {{ domesticPhotoCount }} / 5,点按{{ domesticPhotosExpanded ? '收起' : '展开' }}对照</text></view><text class="container-detail-link">{{ domesticPhotosExpanded ? '收起' : '查看' }} ›</text></view>
<view v-if="domesticPhotosExpanded" class="domestic-photo-grid">
<view v-for="photo in domesticLoadingPhotos" :key="photo.evidenceType" class="domestic-photo-item" @tap.stop="previewDomesticPhoto(photo)">
<image v-if="photo.localPath" class="domestic-photo-thumb" :src="photo.localPath" mode="aspectFill" />
<image v-if="photo.localPath" class="domestic-photo-thumb" :src="photo.localPath" mode="aspectFill" @error="invalidatePhotoPreview(photo)" />
<view v-else class="domestic-photo-placeholder"><uni-load-more v-if="photo.status === 'loading'" status="loading" :show-text="false" /><uni-icons v-else type="image" color="#8090a3" size="22" /><text>{{ photo.status === 'error' ? '加载失败,点按重试' : '正在准备照片' }}</text></view>
<text>{{ photo.label }}</text>
</view>
@@ -139,7 +139,7 @@
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { assertReceivingWarehouse, isNoCurrentReceiving, isReceivingConflict, RECEIVING_PHOTO_ROLES, toReceivingError } from '@/api/receiving-adapter.js'
import { applyWarehouseProfile, session, requireSession } from '@/stores/session.js'
import { idempotencyKey, receiving, receivingPhase, receivingProgress, releaseIdempotencyKey, resetReceivingDraft, setCurrentReceiving } from '@/stores/receiving.js'
import { idempotencyKey, receiving, receivingPhase, receivingProgress, releaseIdempotencyKey, resetReceivingDraft, restoreReceivingPhoto, setCurrentReceiving } from '@/stores/receiving.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
import { completeCurrentReceiving, downloadCurrentDomesticLoadingPhoto, downloadCurrentUnloadPhoto, getCurrentReceiving, getCurrentWarehouseProfile, getReceivingContainer, listReceivingCandidates, prepareReceivingContainer, receiveCurrentBulk, receiveCurrentCarton, receiveCurrentWholePallet, resolveCurrentCarton, resolveCurrentPallet, signCurrentReceiving, uploadCurrentUnloadPhoto } from '@/api/inbound.js'
@@ -167,8 +167,8 @@
pageSubtitle() { return { candidates: '选择集装箱后拍摄 3 张照片完成签收', detail: '核对柜号与整柜计划箱数', sign: '完成现场取证后进入收货', receive: '扫描箱码定位入仓码后开始收货', completed: '库存与柜级进度已由服务端核平' }[this.phase] }
},
onLoad(options) { this.moduleKey = options.module || 'query'; const key = options.op || (this.moduleKey === 'inbound' ? 'receiving' : 'inventoryQuery'); this.operationName = ({ receiving: '拆柜收货', stocktake: '库存盘点', move: '商品移库', pallet: '托盘管理', relabelReceive: '换标列表', relabelExecute: '换标执行', relabelReview: '换标复核', inventoryQuery: '库存查询', locationQuery: '库位查询', palletQuery: '托盘查询' })[key] || '库存查询' },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.startScanner(); if (this.inboundMode && (!this.bootstrapped || this.phase === 'candidates')) this.bootstrap() },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { clearTimeout(this._toastTimer); this.stopScanner() },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this._pageActive = true; this.startScanner(); if (this.inboundMode && (!this.bootstrapped || this.phase === 'candidates' || this._resumeReceiving)) { if (!this.busy) { this._resumeReceiving = false; this.bootstrap() } } },
onHide() { this.suspendReceiving() }, onUnload() { this.suspendReceiving() }, beforeUnmount() { this.suspendReceiving() },
methods: {
candidateKey(item) { return `${item?.sourceContainerBookingId || ''}:${item?.containerNo || ''}` },
statusLabel(status) { return ({ WAIT_SIGN: '待签收', SIGNED: '已签收', RECEIVING: '收货中', COMPLETED: '已完成', COMPLETED_WITH_DIFFERENCE: '差异待核平' })[status] || status },
@@ -180,16 +180,19 @@
async refreshBoundWarehouse() { this.user = applyWarehouseProfile(await getCurrentWarehouseProfile()); return this.user },
photoStateLabel(photo) { if (photo?.status === 'uploading') return '正在上传…'; if (photo?.status === 'error') return photo.errorMessage || (photo.fileId ? '重拍失败,已保留原图' : '上传失败,请重试'); return photo?.fileId ? '点击左侧照片可查看大图' : '请拍摄现场照片' },
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleHardwareScan(value), onError: (error) => { this.error = error; this.showToast('硬件扫码接收器异常,请检查扫描服务', 'warning') } }) }, stopScanner() { if (typeof this._stopScanner === 'function') this._stopScanner(); this._stopScanner = null },
handleHardwareScan(value) { const scan = { value: String(value || '').trim(), target: this.activeTarget }; if (!scan.value) return; if (!this.inboundMode) return this.showToast('该功能缺少海外 PDA 专用接口,扫码未提交', 'warning'); if (this.phase === 'candidates') return this.showToast('请直接点击待卸货集装箱进入详情', 'warning'); if (this.phase !== 'receive') return; if (this.busy || this.activeTarget === 'quantity') { if (this.scanQueue.length >= 100) return this.showToast('扫码队列已满,请先处理当前托盘', 'warning'); this.scanQueue.push(scan); return this.showToast(`已排队 ${this.scanQueue.length} 个扫码`, 'warning') } return this.dispatchHardwareScan(scan) },
dispatchHardwareScan(scan) { const palletWorkflowActive = Boolean(this.activeInbound || this.resolvedPallet); const target = palletWorkflowActive ? 'pallet' : scan.target; console.info('[Overseas PDA scan] dispatch', { value: scan.value, target, queuedTarget: scan.target }); if (target === 'pallet') { this.palletInput = scan.value; return this.resolvePallet(scan.value) } this.cartonInput = scan.value; return this.resolveCarton(scan.value) },
drainScanQueue() { if (this.busy || this.activeTarget === 'quantity' || !this.scanQueue.length || this.phase !== 'receive') return; const next = this.scanQueue.shift(); this.dispatchHardwareScan(next) },
async run(action, work) { this.busy = true; this.error = null; try { const result = await work(); this.showToast(`${action}成功`, 'success'); return result } catch (error) { const mappedError = toReceivingError(error, `${action}失败`); this.error = mappedError; playErrorSound(); this.showToast(mappedError.message, 'error'); throw mappedError } finally { this.busy = false; setTimeout(() => this.drainScanQueue(), 0) } },
handleHardwareScan(value) { if (!this._pageActive || this.loading) return; const scan = { value: String(value || '').trim(), target: this.activeTarget }; if (!scan.value) return; if (!this.inboundMode) return this.showToast('该功能缺少海外 PDA 专用接口,扫码未提交', 'warning'); if (this.phase === 'candidates') return this.showToast('请直接点击待卸货集装箱进入详情', 'warning'); if (this.phase !== 'receive') return; if (this.busy || this.activeTarget === 'quantity') { if (this.scanQueue.length >= 100) return this.showToast('扫码队列已满,请先处理当前托盘', 'warning'); this.scanQueue.push(scan); return this.showToast(`已排队 ${this.scanQueue.length} 个扫码`, 'warning') } return this.dispatchHardwareScan(scan) },
dispatchHardwareScan(scan) { if (!this._pageActive || this.loading) return; const palletWorkflowActive = Boolean(this.activeInbound || this.resolvedPallet); const target = palletWorkflowActive ? 'pallet' : scan.target; console.info('[Overseas PDA scan] dispatch', { value: scan.value, target, queuedTarget: scan.target }); if (target === 'pallet') { this.palletInput = scan.value; return this.resolvePallet(scan.value) } this.cartonInput = scan.value; return this.resolveCarton(scan.value) },
drainScanQueue() { if (!this._pageActive || this.loading || this.busy || this.activeTarget === 'quantity' || !this.scanQueue.length || this.phase !== 'receive') return; const next = this.scanQueue.shift(); this.dispatchHardwareScan(next) },
suspendReceiving() { this._pageActive = false; this._scanEpoch = (this._scanEpoch || 0) + 1; this._resumeReceiving = true; clearTimeout(this._drainTimer); clearTimeout(this._toastTimer); this.scanQueue = []; this.stopScanner() },
scheduleScanDrain() { clearTimeout(this._drainTimer); if (!this._pageActive) return; const epoch = this._scanEpoch || 0; this._drainTimer = setTimeout(() => { if (this._pageActive && epoch === (this._scanEpoch || 0)) this.drainScanQueue() }, 0) },
async run(action, work) { if (this.busy || !this._pageActive) throw new Error('当前页面未就绪,请返回作业页重试'); this.busy = true; this.error = null; try { const result = await work(); this.showToast(`${action}成功`, 'success'); return result } catch (error) { const mappedError = toReceivingError(error, `${action}失败`); this.error = mappedError; playErrorSound(); this.showToast(mappedError.message, 'error'); throw mappedError } finally { this.busy = false; if (this._pageActive && this._resumeReceiving) { this._resumeReceiving = false; this.bootstrap() } else this.scheduleScanDrain() } },
setScanTargetForCurrent(value) { receiving.activeScanTarget = value?.status === 'WAIT_SIGN' ? 'container' : 'carton' },
async restorePhotoPreviews() { await Promise.all(receiving.photoSlots.filter((photo) => photo?.downloadPath && !photo.localPath).map(async (photo) => { photo.status = 'restoring'; try { photo.localPath = await downloadCurrentUnloadPhoto(photo.downloadPath); photo.status = 'success' } catch (error) { photo.status = 'error'; photo.errorMessage = error.message || '照片加载失败'; this.error = error } })) },
async restoreDomesticPhotoPreviews() { await Promise.all(this.domesticLoadingPhotos.filter((photo) => photo.downloadPath && !photo.localPath).map((photo) => this.loadDomesticPhoto(photo))) },
async loadDomesticPhoto(photo) { if (!photo?.downloadPath || photo.status === 'loading') return; photo.status = 'loading'; try { photo.localPath = await downloadCurrentDomesticLoadingPhoto(photo.downloadPath); photo.status = 'success' } catch (_) { photo.status = 'error' } },
async restorePhotoPreviews() { await Promise.all(receiving.photoSlots.filter((photo) => photo?.downloadPath && !photo.localPath).map(async (photo) => { try { await restoreReceivingPhoto(photo, downloadCurrentUnloadPhoto) } catch (error) { this.error = error } })) },
async restoreDomesticPhotoPreviews() { if (this.phase !== 'sign' && !this.domesticPhotosExpanded) return; await Promise.all(this.domesticLoadingPhotos.filter((photo) => photo.downloadPath && !photo.localPath).map((photo) => this.loadDomesticPhoto(photo))) },
async toggleDomesticPhotos() { this.domesticPhotosExpanded = !this.domesticPhotosExpanded; if (this.domesticPhotosExpanded) await this.restoreDomesticPhotoPreviews() },
async loadDomesticPhoto(photo) { if (!photo?.downloadPath) return; try { await restoreReceivingPhoto(photo, downloadCurrentDomesticLoadingPhoto) } catch (_) { photo.status = 'error' } },
async previewDomesticPhoto(photo) { if (!photo?.localPath) await this.loadDomesticPhoto(photo); if (photo?.localPath) { const urls = this.domesticLoadingPhotos.map((item) => item.localPath).filter(Boolean); uni.previewImage({ urls, current: photo.localPath }) } },
async applyCurrentReceiving(value) { this.validateReceivingWarehouse(value, '当前接柜会话'); setCurrentReceiving(value); await Promise.all([this.restorePhotoPreviews(), this.restoreDomesticPhotoPreviews()]); this.setScanTargetForCurrent(value) },
async applyCurrentReceiving(value) { this.validateReceivingWarehouse(value, '当前接柜会话'); setCurrentReceiving(value, JSON.stringify([session.tenantId, this.user?.id, this.user?.warehouseId])); if (!this._pageActive) return; await Promise.all([this.restorePhotoPreviews(), this.restoreDomesticPhotoPreviews()]); this.setScanTargetForCurrent(value) },
async bootstrap() { if (this.loading) return; this.loading = true; this.error = null; this.detailOpen = false; try { await this.refreshBoundWarehouse(); try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.candidateError = null } catch (error) { if (!isNoCurrentReceiving(error)) throw error; resetReceivingDraft(); await this.loadCandidates(false, false) } this.bootstrapped = true } catch (error) { this.candidateError = error } finally { this.loading = false } },
async recoverCurrentAfterConflict(error, preserveEvidence = false) { if (!isReceivingConflict(error)) return false; try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.detailOpen = false; this.error = preserveEvidence ? error : null; this.showToast(`账号已有未完成柜 ${current.containerNo},已返回当前柜`, 'warning'); return true } catch (recoveryError) { if (isNoCurrentReceiving(recoveryError)) { this.detailOpen = false; receiving.selected = null; await this.loadCandidates(false); this.candidateError = preserveEvidence ? error : null; this.error = preserveEvidence ? error : null; this.showToast('柜状态已变化,列表已刷新', 'warning'); return true } this.error = error; return false } },
async loadCandidates(feedback = true, refreshWarehouse = true) { if (this.candidateLoading) return; this.candidateLoading = true; this.candidateError = null; this.error = null; try { if (refreshWarehouse) await this.refreshBoundWarehouse(); const values = await listReceivingCandidates(); receiving.candidates = values.map((item) => this.validateReceivingWarehouse(item, `柜号 ${item.containerNo || '—'}`)); if (feedback) this.showToast('刷新待卸柜成功', 'success') } catch (error) { receiving.candidates = []; this.candidateError = error; if (feedback) { playErrorSound(); this.showToast(error.message || '刷新待卸柜失败', 'error') } } finally { this.candidateLoading = false } },
@@ -198,10 +201,11 @@
async prepareContainer() { if (!this.selected?.containerNo || !this.selected?.sourceContainerBookingId || !this.selected?.allPlansReady || !this.selected?.operable || this.busy) return; const key = idempotencyKey('prepare', { sourceContainerBookingId: this.selected.sourceContainerBookingId, containerNo: this.selected.containerNo }); try { this.validateReceivingWarehouse(this.selected, `柜号 ${this.selected.containerNo}`); const value = await this.run('选择当前柜', async () => this.validateReceivingWarehouse(await prepareReceivingContainer(this.selected.containerNo, this.selected.sourceContainerBookingId, { idempotencyKey: key.value }), '新建接柜会话')); releaseIdempotencyKey(key.signature); await this.applyCurrentReceiving(value); this.detailOpen = false } catch (error) { if (!await this.recoverCurrentAfterConflict(error, true)) this.error = error } },
choosePhoto(index) { if (this.busy) return; uni.chooseImage({ count: 1, sourceType: ['camera'], success: ({ tempFilePaths, tempFiles }) => { const path = tempFilePaths?.[0]; const size = Number(tempFiles?.[0]?.size || 0); if (size > MAX_UNLOAD_PHOTO_BYTES) { const message = '照片不能超过10MB'; this.error = Object.assign(new Error(message), { code: 'A0400' }); this.showToast(message, 'error'); playErrorSound(); return } if (path) this.replacePhoto(index, path) } }) },
async replacePhoto(index, path) { const previous = receiving.photoSlots[index]; receiving.photoSlots.splice(index, 1, { ...(previous || {}), role: this.photoSteps[index].role, status: 'uploading', errorMessage: '' }); this.busy = true; this.error = null; try { const file = await uploadCurrentUnloadPhoto(path); receiving.photoSlots.splice(index, 1, { ...file, fileId: String(file.fileId), role: this.photoSteps[index].role, localPath: path, uploaded: true, status: 'success' }); this.showToast(`${this.photoSteps[index].label}上传成功`, 'success') } catch (error) { receiving.photoSlots.splice(index, 1, previous?.fileId ? { ...previous, status: 'error', errorMessage: error.message || '重拍失败,已保留原图' } : { role: this.photoSteps[index].role, localPath: path, uploaded: false, status: 'error', errorMessage: error.message || '上传失败,请重试' }); this.error = error; this.showToast(error.message || '照片上传失败', 'error'); playErrorSound() } finally { this.busy = false } },
previewPhoto(index) { const path = this.photoSlots[index]?.localPath; if (!path) return; uni.previewImage({ urls: [path], current: path }) },
invalidatePhotoPreview(photo) { if (!photo) return; photo.localPath = ''; photo.status = 'error'; photo.errorMessage = '照片加载失败,点按重试' },
async previewPhoto(index) { const photo = this.photoSlots[index]; if (!photo) return; try { const path = await restoreReceivingPhoto(photo, downloadCurrentUnloadPhoto); if (path) uni.previewImage({ urls: [path], current: path }) } catch (error) { this.error = error } },
async signContainer() { if (!this.photosReady) return; const photos = this.photoSlots.map((item, index) => ({ role: this.photoSteps[index].role, fileId: String(item.fileId) })); const key = idempotencyKey('sign', { containerNo: this.current.containerNo, photos }); try { const value = await this.run('签收当前柜', async () => this.validateReceivingWarehouse(await signCurrentReceiving({ photos, idempotencyKey: key.value }), '当前接柜会话')); releaseIdempotencyKey(key.signature); await this.applyCurrentReceiving(value); this.clearReceiptContext() } catch (_) {} },
clearReceiptContext() { receiving.resolvedCarton = null; receiving.resolvedPallet = null; this.cartonInput = ''; this.palletInput = ''; this.packageCount = ''; receiving.activeScanTarget = 'carton'; setTimeout(() => this.drainScanQueue(), 0) },
async resolveCarton(raw) { const cartonMark = String(raw?.detail?.value ?? raw ?? this.cartonInput).trim(); if (!cartonMark) return; if (this.busy) return this.handleHardwareScan(cartonMark); try { const outcome = await this.run('处理货物码', async () => { const value = await resolveCurrentCarton(cartonMark); if (value.operationMode !== 'DROPSHIP_PALLET') return { value }; const payload = { sourcePalletNo: value.sourcePalletNo || cartonMark }; const key = idempotencyKey('whole-pallet-receipt', { containerNo: this.current?.containerNo, ...payload }); const result = await receiveCurrentWholePallet({ ...payload, idempotencyKey: key.value }); const current = this.validateReceivingWarehouse(await getCurrentReceiving(), '当前接柜会话'); releaseIdempotencyKey(key.signature); return { value, result, current } }); if (outcome.result) { receiving.lastReceipt = outcome.result; await this.applyCurrentReceiving(outcome.current); this.clearReceiptContext(); playSuccessSound(); return } receiving.resolvedCarton = outcome.value; receiving.resolvedPallet = null; this.cartonInput = outcome.value.cartonMark; this.palletInput = ''; this.packageCount = ''; receiving.activeScanTarget = 'pallet'; playSuccessSound() } catch (error) { console.error('[Overseas PDA cargo] failed', { cartonMark, message: error.message, code: error.code, requestId: error.requestId, traceId: error.traceId }) } },
clearReceiptContext() { receiving.resolvedCarton = null; receiving.resolvedPallet = null; this.cartonInput = ''; this.palletInput = ''; this.packageCount = ''; receiving.activeScanTarget = 'carton'; this.scheduleScanDrain() },
async resolveCarton(raw) { const cartonMark = String(raw?.detail?.value ?? raw ?? this.cartonInput).trim(); if (!cartonMark || !this._pageActive) return; const scanEpoch = this._scanEpoch || 0; if (this.busy) return this.handleHardwareScan(cartonMark); try { const outcome = await this.run('处理货物码', async () => { const value = await resolveCurrentCarton(cartonMark); if (value.operationMode !== 'DROPSHIP_PALLET') return { value }; if (!this._pageActive || scanEpoch !== (this._scanEpoch || 0)) throw new Error('页面已离开,请返回后重新扫码确认收货'); const payload = { sourcePalletNo: value.sourcePalletNo || cartonMark }; const key = idempotencyKey('whole-pallet-receipt', { containerNo: this.current?.containerNo, ...payload }); const result = await receiveCurrentWholePallet({ ...payload, idempotencyKey: key.value }); const current = this.validateReceivingWarehouse(await getCurrentReceiving(), '当前接柜会话'); releaseIdempotencyKey(key.signature); return { value, result, current } }); if (outcome.result) { receiving.lastReceipt = outcome.result; await this.applyCurrentReceiving(outcome.current); this.clearReceiptContext(); playSuccessSound(); return } receiving.resolvedCarton = outcome.value; receiving.resolvedPallet = null; this.cartonInput = outcome.value.cartonMark; this.palletInput = ''; this.packageCount = ''; receiving.activeScanTarget = 'pallet'; playSuccessSound() } catch (error) { console.error('[Overseas PDA cargo] failed', { cartonMark, message: error.message, code: error.code, requestId: error.requestId, traceId: error.traceId }) } },
async resolvePallet(raw) { const palletCode = String(raw?.detail?.value ?? raw ?? this.palletInput).trim(); if (!palletCode || !this.activeInbound || this.busy) return; try { const value = await this.run('校验托盘', () => resolveCurrentPallet(palletCode, this.activeInbound.inboundCode)); receiving.resolvedPallet = value; this.palletInput = value.palletCode; this.packageCount = Number(value.currentReceivedPackages || 0) || ''; if (!value.compatible) throw Object.assign(new Error('托盘不匹配'), { code: 'PALLET_INCOMPATIBLE' }); receiving.activeScanTarget = this.activeInbound.destinationType === 'FBA' ? 'quantity' : 'carton'; playSuccessSound() } catch (error) { if (!this.error) this.error = error } },
async submitReceipt() { const isFba = this.activeInbound?.destinationType === 'FBA'; if (isFba && Number(this.packageCount) > MAX_PACKAGES_PER_PALLET) { this.error = Object.assign(new Error(PALLET_CAPACITY_MESSAGE), { code: 'A0409' }); playErrorSound(); this.showToast(PALLET_CAPACITY_MESSAGE, 'error'); return } if (!this.canSubmitReceipt) return; const payload = isFba ? { inboundCode: this.activeInbound.inboundCode, palletCode: this.resolvedPallet.palletCode, packageCount: Number(this.packageCount), expectedPalletVersion: this.resolvedPallet.version } : { cartonMark: this.activeInbound.cartonMark, palletCode: this.resolvedPallet.palletCode }; const key = idempotencyKey(isFba ? 'bulk-receipt' : 'carton-receipt', { containerNo: this.current?.containerNo, groupKey: this.activeInbound.groupKey, ...payload }); try { const result = await this.run('提交实收', async () => { const receipt = isFba ? await receiveCurrentBulk({ ...payload, idempotencyKey: key.value }) : await receiveCurrentCarton({ ...payload, idempotencyKey: key.value }); const current = this.validateReceivingWarehouse(await getCurrentReceiving(), '当前接柜会话'); return { receipt, current } }); releaseIdempotencyKey(key.signature); receiving.lastReceipt = result.receipt; await this.applyCurrentReceiving(result.current); this.clearReceiptContext(); playSuccessSound() } catch (_) {} },
async completeContainer() { if (!this.canFinishOnsite || this.activeTarget === 'quantity' || this.scanQueue.length) return; const payload = { allowDifference: !this.allInboundsReconciled }; const key = idempotencyKey('complete', { containerNo: this.current.containerNo, ...payload }); try { const value = await this.run(this.allInboundsReconciled ? '完成入库' : '结束现场收货', async () => this.validateReceivingWarehouse(await completeCurrentReceiving({ ...payload, idempotencyKey: key.value }), '当前接柜会话')); releaseIdempotencyKey(key.signature); await this.applyCurrentReceiving(value) } catch (_) {} },
+29 -14
View File
@@ -7,14 +7,14 @@
<view v-if="current" class="detail-toolbar"><view><text class="detail-kicker">当前任务</text><text class="detail-title">出库作业详情</text></view><button :disabled="busy" @tap="closeTask">‹ 返回列表</button></view>
<view class="fulfillment-card">
<ScanTargetInput name="outbound" :model-value="scanCode" :label="discardCurrent ? '废弃订单托盘码' : '任务号、出库单号、排车单号、托盘码或箱码'" :placeholder="discardCurrent ? '扫描废弃订单对应托盘码' : '扫描任务号 / 出库单号 / 排车单号 / 托盘码 / 箱码'" :active="true" @update:modelValue="updateScanCode" @submit="handleScan" />
<button class="secondary-button" :disabled="!scanCode || loading || busy" @tap="handleScan(scanCode)">{{ discardCurrent ? '打开废弃托盘' : current ? '匹配出库货物' : '打开出库任务' }}</button>
<button class="secondary-button" :disabled="!scanCode || loading || busy || discardLoading || discardBusy" @tap="handleScan(scanCode)">{{ discardCurrent ? '打开废弃托盘' : current ? '匹配出库货物' : '打开出库任务' }}</button>
<text class="scan-help">任务号直接恢复;退货整托出库扫托盘码,散箱出库扫箱唛。</text>
</view>
<view v-if="loading" class="fulfillment-state"><text>正在加载出库任务…</text></view>
<view v-else-if="error" class="fulfillment-error" role="alert"><text class="state-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><text v-if="error.network" class="offline-note">网络连接失败,当前任务、扫码草稿和幂等键均已保留。</text><button class="secondary-button" :disabled="loading || busy" @tap="retry">重试</button></view>
<view v-else-if="error" class="fulfillment-error" role="alert"><text class="state-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><text v-if="error.network" class="offline-note">网络连接失败,当前任务、扫码草稿和幂等键均已保留。</text><button class="secondary-button" :disabled="loading || busy || discardLoading || discardBusy" @tap="retry">重试</button></view>
<template v-if="!loading && !current">
<template v-if="!loading && !current && !discardCurrent">
<view class="list-toolbar"><view><text class="list-title">待处理出库任务</text><text class="list-count">{{ tasks.length }} 个任务</text></view><button :disabled="loading" @tap="loadTasks">刷新</button></view>
<view v-if="tasks.length" class="task-list">
<button v-for="task in tasks" :key="task.taskNo" class="task-card" @tap="openTask(task.taskNo)"><view class="task-head"><view class="task-code-row"><text class="business-code">{{ task.taskNo || '—' }}</text><text v-if="task.taskNo === recentTaskNo" class="recent-badge">上次作业</text></view><text class="status-badge">{{ statusLabel(task.status) }}</text></view><text class="task-meta">出库单 {{ task.outboundNo || '—' }} · {{ taskType(task) }}</text><text class="task-enter">查看并开始出库 ›</text></button>
@@ -74,11 +74,12 @@
pickedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.pickedPackages, 0) },
remainingPackages() { return Math.max(0, this.plannedPackages - this.pickedPackages) },
selectedRemaining() { return Math.max(0, Math.min(Number(this.selectedItem?.plannedPackages || 0) - Number(this.selectedItem?.pickedPackages || 0), Number(this.selectedPosition?.availablePackages || 0))) },
canPick() { return this.current?.status !== 'COMPLETED' && this.selectedItem && this.selectedPosition && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining },
canComplete() { return this.plannedPackages > 0 && this.remainingPackages === 0 && this.current?.status !== 'COMPLETED' },
taskOperable() { return ['PENDING', 'IN_PROGRESS'].includes(this.current?.status) },
canPick() { return this.taskOperable && this.selectedItem && this.selectedPosition && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining },
canComplete() { return this.plannedPackages > 0 && this.remainingPackages === 0 && this.taskOperable },
discardOrders() { const groups = new Map(); for (const task of this.discardTasks) { const current = groups.get(task.discardNo) || { discardNo: task.discardNo, palletCount: 0, pendingPalletCount: 0 }; current.palletCount += 1; if (task.status !== 'COMPLETED') current.pendingPalletCount += 1; groups.set(task.discardNo, current) } return [...groups.values()] },
discardOrderCount() { return this.discardOrders.length },
canConfirmDiscard() { return Boolean(this.discardTask && this.discardTask.status !== 'COMPLETED' && this.discardTask.skus.length && this.discardTask.skus.every((sku) => Number.isInteger(Number(this.discardActuals[sku.itemId])) && Number(this.discardActuals[sku.itemId]) >= 0 && Number(this.discardActuals[sku.itemId]) <= sku.plannedPackages)) }
canConfirmDiscard() { return Boolean(!this.discardLoading && !this.discardBusy && this.discardTask && this.discardTask.status !== 'COMPLETED' && this.discardTask.skus.length && this.discardTask.skus.every((sku) => Number.isInteger(Number(this.discardActuals[sku.itemId])) && Number(this.discardActuals[sku.itemId]) >= 0 && Number(this.discardActuals[sku.itemId]) <= sku.plannedPackages)) }
},
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (!this.bootstrapped) { this.bootstrapped = true; this.scanCode = ''; this.loadTasks(); return } if (this.current?.taskNo) this.openTask(this.current.taskNo); else this.loadTasks() },
onHide() { this.stopScanner() },
@@ -106,31 +107,45 @@
}, true)
},
closeTask() { this.current = null; this.selectedItem = null; this.selectedPosition = null; this.error = null; this.successMessage = ''; updateFulfillmentDraft('outbound', { currentTaskNo: '', scanCode: '', packages: 1 }); this.loadTasks() },
openDiscardOrder(discardNo) { const code = String(discardNo || '').trim(); if (!code) return; this.discardCurrent = { discardNo: code }; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.error = null },
closeDiscard() { this.discardCurrent = null; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.loadTasks() },
openDiscardOrder(discardNo) { const code = String(discardNo || '').trim(); if (!code || this.discardBusy || this.busy || this.loading) return; this.current = null; this.discardLoading = false; this.discardCurrent = { discardNo: code }; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.error = null },
closeDiscard() { if (this.discardBusy) return; this.discardLoading = false; this.discardCurrent = null; this.discardTask = null; this.discardPalletCode = ''; this.discardActuals = {}; this.loadTasks() },
updateDiscardPalletCode(value) { this.discardPalletCode = value },
async resolveDiscardPallet(raw) { const code = String(raw?.detail?.value ?? raw ?? this.discardPalletCode).trim(); if (!code || !this.discardCurrent || this.discardBusy) return; this.discardPalletCode = code; this.discardLoading = true; try { const task = await resolveCargoDiscardPallet(code); if (task.discardNo !== this.discardCurrent.discardNo) throw new Error('扫描的托盘不属于当前废弃订单'); this.discardTask = normalizeCargoDiscardTask(task); this.discardActuals = Object.fromEntries(this.discardTask.skus.map((sku) => [sku.itemId, sku.actualPackages == null ? sku.plannedPackages : sku.actualPackages])) } catch (error) { this.setError(error, '废弃托盘读取失败') } finally { this.discardLoading = false } },
async resolveDiscardPallet(raw) {
const code = String(raw?.detail?.value ?? raw ?? this.discardPalletCode).trim()
if (!code || !this.discardCurrent || this.discardBusy || this.discardLoading) return
const order = this.discardCurrent
this.discardPalletCode = code; this.discardLoading = true; this.discardTask = null; this.discardActuals = {}; this.error = null
try {
const task = await resolveCargoDiscardPallet(code)
if (this.discardCurrent !== order) return
if (task.discardNo !== order.discardNo) throw new Error('扫描的托盘不属于当前废弃订单')
this.discardTask = normalizeCargoDiscardTask(task)
this.discardActuals = Object.fromEntries(this.discardTask.skus.map((sku) => [sku.itemId, sku.actualPackages == null ? sku.plannedPackages : sku.actualPackages]))
} catch (error) { if (this.discardCurrent === order) this.setError(error, '废弃托盘读取失败') }
finally { if (this.discardCurrent === order) this.discardLoading = false }
},
updateDiscardActual(itemId, event) { const value = Math.max(0, Math.trunc(Number(event.detail.value || 0))); this.discardActuals = { ...this.discardActuals, [itemId]: value } },
async confirmDiscard() { if (!this.canConfirmDiscard || !this.discardTask || !this.discardCurrent) return; this.discardBusy = true; try { const lines = this.discardTask.skus.map((sku) => ({ itemId: sku.itemId, actualPackages: Number(this.discardActuals[sku.itemId]) })); await confirmCargoDiscard(this.discardCurrent.discardNo, this.discardTask.palletNo, lines); this.successMessage = '废弃出库作业已完成,等待仓库确认'; this.discardPalletCode = ''; this.discardTask = null; this.discardTasks = await listCargoDiscardTasks(); playSuccessSound() } catch (error) { this.setError(error, '废弃出库失败') } finally { this.discardBusy = false } },
async confirmDiscard() { if (this.discardBusy || this.discardLoading || !this.canConfirmDiscard || !this.discardTask || !this.discardCurrent) return; this.discardBusy = true; try { const lines = this.discardTask.skus.map((sku) => ({ itemId: sku.itemId, actualPackages: Number(this.discardActuals[sku.itemId]) })); await confirmCargoDiscard(this.discardCurrent.discardNo, this.discardTask.palletNo, lines); this.successMessage = '废弃出库作业已完成,等待仓库确认'; this.discardPalletCode = ''; this.discardTask = null; this.discardTasks = await listCargoDiscardTasks(); playSuccessSound() } catch (error) { this.setError(error, '废弃出库失败') } finally { this.discardBusy = false } },
handleScan(raw) {
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim(); if (!code || this.busy || this.loading) return
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim(); if (!code || this.busy || this.loading || this.discardBusy || this.discardLoading) return
console.info('[Overseas PDA outbound] scan', { code, task: this.current?.taskNo || '', discard: this.discardCurrent?.discardNo || '' }); this.scanCode = code; updateFulfillmentDraft('outbound', { scanCode: code })
if (this.discardCurrent) return this.resolveDiscardPallet(code)
if (!this.current) return this.openScannedTask(code)
if (code === this.current.taskNo || code === this.current.outboundNo) return this.openTask(this.current.taskNo)
if (!this.taskOperable) return
if ((this.current.items || []).some((item) => item.palletNo === code)) return this.submitPalletPick(code)
if (this.findAndSelectPosition(code)) return
this.setError(new Error('扫码未匹配当前出库任务的托盘码、箱码、SKU 或入仓码'), '扫码未匹配')
},
findAndSelectPosition(code, feedback = true) { for (const item of this.current?.items || []) { if (this.current?.outboundType === 'RETURN_OUTBOUND' && item.palletNo) continue; const position = item.positions.find((value) => value.availablePackages > 0 && [value.palletNo, value.cartonMark, value.skuCode, value.inboundCode].filter(Boolean).includes(code)); if (position) { this.selectPosition(item, position, code); if (feedback) playSuccessSound(); return true } } return false },
selectPosition(item, position, scannedCode = '') { if (!position || position.availablePackages <= 0 || (this.current?.outboundType === 'RETURN_OUTBOUND' && item.palletNo)) return; this.selectedItem = item; this.selectedPosition = position; this.selectedScanCode = scannedCode || this.lineBusinessCode(position); updateFulfillmentDraft('outbound', { scanCode: this.selectedScanCode, packages: Math.max(1, Math.min(item.plannedPackages - item.pickedPackages, position.availablePackages)) }) },
selectPosition(item, position, scannedCode = '') { if (!this.taskOperable || !position || position.availablePackages <= 0 || (this.current?.outboundType === 'RETURN_OUTBOUND' && item.palletNo)) return; this.selectedItem = item; this.selectedPosition = position; this.selectedScanCode = scannedCode || this.lineBusinessCode(position); updateFulfillmentDraft('outbound', { scanCode: this.selectedScanCode, packages: Math.max(1, Math.min(item.plannedPackages - item.pickedPackages, position.availablePackages)) }) },
async submitPick() {
if (!this.canPick) return
const payload = outboundPickPayload(this.selectedItem, this.selectedPosition, this.packages, this.selectedScanCode); const key = fulfillmentIdempotencyKey('outbound', 'pick', { taskNo: this.current.taskNo, ...payload })
await this.run('提交出库拣货', async () => { await pickOutboundTask(this.current.taskNo, { ...payload, idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); releaseFulfillmentKey('outbound', key.signature); this.selectedItem = null; this.selectedPosition = null; this.selectedScanCode = ''; updateFulfillmentDraft('outbound', { scanCode: '', packages: 1 }); this.successMessage = '本次出库拣货已记录'; playSuccessSound() })
},
async submitPalletPick(palletNo) {
const code = String(palletNo || '').trim(); if (!code || !this.current) return
const code = String(palletNo || '').trim(); if (!code || !this.taskOperable) return
const key = fulfillmentIdempotencyKey('outbound', 'pallet-pick', { taskNo: this.current.taskNo, palletNo: code })
await this.run('整托拣货', async () => { await pickOutboundPallet(this.current.taskNo, code, { idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); releaseFulfillmentKey('outbound', key.signature); this.selectedItem = null; this.selectedPosition = null; updateFulfillmentDraft('outbound', { scanCode: '', packages: 1 }); this.successMessage = `${code} 整托拣货已完成`; playSuccessSound() })
},
@@ -139,7 +154,7 @@
const key = fulfillmentIdempotencyKey('outbound', 'complete', { taskNo: this.current.taskNo })
await this.run('完成出库任务', async () => { await completeOutboundTask(this.current.taskNo, { idempotencyKey: key.value }); this.current = await getOutboundTask(this.current.taskNo); releaseFulfillmentKey('outbound', key.signature); this.successMessage = '出库任务已完成'; playSuccessSound() })
},
async retry() { if (this.current?.taskNo) await this.openTask(this.current.taskNo); else if (fulfillment.outbound.currentTaskNo) await this.openTask(fulfillment.outbound.currentTaskNo); else if (this.scanCode) await this.openScannedTask(this.scanCode); else await this.loadTasks() },
async retry() { if (this.discardCurrent) { if (this.discardPalletCode) await this.resolveDiscardPallet(this.discardPalletCode); return } if (this.current?.taskNo) await this.openTask(this.current.taskNo); else if (fulfillment.outbound.currentTaskNo) await this.openTask(fulfillment.outbound.currentTaskNo); else if (this.scanCode) await this.openScannedTask(this.scanCode); else await this.loadTasks() },
async run(action, work, loading = false) { if (this.busy || this.loading) return; this.error = null; this.successMessage = ''; if (loading) this.loading = true; else { this.busy = true; this.busyAction = action.includes('完成') ? 'complete' : 'pick' } try { await work() } catch (error) { this.setError(error, `${action}失败`) } finally { this.loading = false; this.busy = false; this.busyAction = '' } },
setError(error, fallback) { const forbidden = ['403', 'A0403'].includes(String(error?.code || '')); this.error = { message: forbidden ? '当前账号没有海外仓 PDA 作业权限,请联系管理员授权' : error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId, network: Boolean(error?.network) }; console.error('[Overseas PDA outbound] failed', this.error); playErrorSound() },
lineBusinessCode(position) { return position?.cartonMark || position?.palletNo || position?.skuCode || position?.inboundCode || '业务码缺失' },
+16 -3
View File
@@ -13,7 +13,7 @@
</view>
<button class="logout-action" @tap="openLogout">退出登录</button>
</view>
<uni-popup ref="logoutPopup" type="center" :is-mask-click="true" @change="handleLogoutChange"><view class="logout-dialog-card" role="dialog" aria-modal="true" aria-label="确认退出登录"><PopupHeader title="确认退出登录?" tone="danger" @close="closeLogout" /><text class="logout-dialog-content">退出后会清空海外 PDA 的本地登录会话;未提交作业数据不得跨账号保留。</text><view class="logout-dialog-actions"><button class="logout-dialog-cancel" @tap="closeLogout">取消</button><button class="logout-dialog-confirm" @tap="logout">确认退出</button></view></view></uni-popup>
<uni-popup ref="logoutPopup" type="center" :is-mask-click="true" @change="handleLogoutChange"><view class="logout-dialog-card" role="dialog" aria-modal="true" aria-label="确认退出登录"><PopupHeader title="确认退出登录?" tone="danger" @close="closeLogout" /><text class="logout-dialog-content">退出会撤销当前登录会话并清空本地作业草稿;未提交作业数据不得跨账号保留。</text><view class="logout-dialog-actions"><button class="logout-dialog-cancel" @tap="closeLogout">取消</button><button class="logout-dialog-confirm" @tap="logout">确认退出</button></view></view></uni-popup>
<view v-if="toast" class="app-toast warning" role="status">设置功能将在后续版本开放</view>
</view>
</template>
@@ -22,7 +22,8 @@
import PopupHeader from '@/components/PopupHeader.vue'
import { getCurrentWarehouseProfile } from '@/api/inbound.js'
import { currentLocale, LOCALE_OPTIONS, setLocale } from '@/i18n/index.js'
import { applyWarehouseProfile, session, requireSession, signOut } from '@/stores/session.js'
import { applyWarehouseProfile, session, requireSession, getSessionRevision } from '@/stores/session.js'
import { logoutCurrentSession } from '@/api/logout-session.js'
import { checkForAppUpdate } from '@/utils/app-update.js'
export default {
components: { PopupHeader, PortalHeader },
@@ -66,7 +67,19 @@
async loadWarehouseProfile() { try { this.user = applyWarehouseProfile(await getCurrentWarehouseProfile()) } catch (_) { this.user = session.currentUser } },
openLogout() { this.logoutOpen = true; this.$refs.logoutPopup.open() }, closeLogout() { this.logoutOpen = false; this.$refs.logoutPopup.close() }, handleLogoutChange(event) { this.logoutOpen = Boolean(event.show) },
showSettings() { clearTimeout(this._toastTimer); this.toast = true; this._toastTimer = setTimeout(() => { this.toast = false }, 2200) },
logout() { signOut(); uni.reLaunch({ url: '/pages/login/login' }) }
logout() {
if (this._logoutPromise) return this._logoutPromise
this.closeLogout()
const operation = logoutCurrentSession()
this.user = null
uni.reLaunch({ url: '/pages/login/login' })
this._logoutPromise = operation.catch(error => {
if (getSessionRevision() === error.logoutRevision) {
uni.showToast({ title: '已清除本地登录,服务端退出未确认', icon: 'none' })
}
})
return this._logoutPromise
}
}
}
</script>
+31 -2
View File
@@ -8,6 +8,28 @@ export const receiving = reactive({
})
const idempotencyKeys = new Map()
let previewOwner = ''
const previewRequests = new WeakMap()
function photoOwner(value, owner) {
if (!owner || !value?.containerNo || !value?.preparedAt) return ''
return JSON.stringify([owner, value.containerNo, value.preparedAt, value.overseasWarehouseId,
(value.inbounds || []).map(row => [row.customerId || '', row.inboundCode || '']).sort()])
}
export async function restoreReceivingPhoto(photo, download) {
if (!photo?.downloadPath || photo.localPath) return photo?.localPath || ''
if (previewRequests.has(photo)) return previewRequests.get(photo)
photo.status = 'loading'
const request = Promise.resolve().then(() => download(photo.downloadPath)).then(path => {
if (!path) throw new Error('照片加载失败')
photo.localPath = path; photo.status = 'success'; photo.errorMessage = ''
return path
}).catch(error => { photo.status = 'error'; photo.errorMessage = error.message || '照片加载失败'; throw error })
.finally(() => previewRequests.delete(photo))
previewRequests.set(photo, request)
return request
}
export function receivingPhase(session = receiving.current) {
if (!session) return 'candidates'
@@ -23,16 +45,23 @@ export function receivingProgress(session = receiving.current) {
return { total, received, pending: Math.max(0, Number(session?.pendingPackages ?? total - received)), percent: total ? Math.min(100, Math.round(received / total * 100)) : 0 }
}
export function setCurrentReceiving(value) {
export function setCurrentReceiving(value, owner = '') {
const nextOwner = photoOwner(value, owner)
const previous = previewOwner && previewOwner === nextOwner ? receiving.photoSlots : []
previewOwner = nextOwner
receiving.current = value || null
receiving.selected = value || null
receiving.photoSlots = RECEIVING_PHOTO_ROLES.map((role) => {
const photo = (value?.photos || []).find((item) => item.role === role)
return photo ? { ...photo, fileId: String(photo.fileId), role, uploaded: true, status: 'success' } : null
if (!photo) return null
const old = previous.find(item => item?.role === role && String(item.fileId) === String(photo.fileId)
&& item.downloadPath === photo.downloadPath)
return old || { ...photo, fileId: String(photo.fileId), role, localPath: '', uploaded: true, status: 'idle' }
})
}
export function resetReceivingDraft({ keepCurrent = false } = {}) {
previewOwner = ''
receiving.selected = keepCurrent ? receiving.current : null
if (!keepCurrent) receiving.current = null
receiving.photoSlots = [null, null, null]
+5
View File
@@ -5,6 +5,8 @@ import { resetFulfillmentSession } from './fulfillment.js'
import { resetWarehouseWork } from './warehouse-work.js'
const SESSION_KEY = 'overseas-pda-auth-session'
let sessionRevision = 0
export function getSessionRevision() { return sessionRevision }
export const PDA_ROLE = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR'
export const PDA_AUTHORITY = 'lastleg:pda:operate'
@@ -108,6 +110,7 @@ function persistSession() {
}
export function signIn(account, warehouse, options = {}) {
sessionRevision += 1
session.currentUser = { id: String(account.id), name: account.name, role: account.role, roles: account.roles || [], warehouse,
warehouseId: account.warehouseId || null, warehouseCode: account.warehouseCode || '', warehouseName: account.warehouseName || '' }
session.token = options.token || account.token || ''
@@ -184,6 +187,7 @@ export function restoreSession() {
currentUser: userFromClaims(pair, saved.currentUser), tenantId: claimId(pair.claims, 'tenant_id'),
apiBase: API_ROOT, authorization: pair.authorization
})
sessionRevision += 1
return true
} catch (_) {
signOut()
@@ -192,6 +196,7 @@ export function restoreSession() {
}
export function signOut() {
sessionRevision += 1
session.currentUser = null
session.token = ''
session.refreshToken = ''
+45 -4
View File
@@ -6,13 +6,30 @@ import { fulfillment, fulfillmentIdempotencyKey, releaseFulfillmentKey, resetFul
import {
completeDispatchPicking, completeOutboundTask, departDispatch, getDispatchTask, getOutboundTask,
listDispatchTasks, listOutboundTasks, normalizeDispatchTask, normalizeOutboundTask,
pickDispatchItem, pickOutboundPallet, pickOutboundTask, resolveOutboundTask, outboundPickPayload
pickDispatchItem, pickOutboundPallet, pickOutboundTask, resolveOutboundTask, outboundPickPayload, tripDepartureRequest
} from '../src/api/fulfillment.js'
import { session } from '../src/stores/session.js'
import { toDispatchError } from '../src/utils/dispatch-error.js'
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
test('trip departure preserves version zero and rejects incomplete trip identity', () => {
const task = normalizeDispatchTask({ tripNo: 'TR-1', tripRecordVersion: 0, tripDispatchCount: 2, items: [] })
assert.equal(task.tripRecordVersion, '0')
assert.equal(task.tripDispatchCount, 2)
assert.deepEqual(tripDepartureRequest(task, 'departure-key'), { recordVersion: '0', idempotencyKey: 'departure-key', remark: '海外 PDA 整车核平后发车' })
for (const invalid of [{}, { tripNo: 'TR-1' }, { tripNo: 'TR-1', tripRecordVersion: '-1' }]) assert.throws(() => tripDepartureRequest(invalid, 'key'), /车次信息不完整/)
})
test('whole trip departure UI identifies all members and refreshes server state before releasing retry key', async () => {
const page = await read('../src/pages/dispatch/dispatch.vue')
assert.match(page, /current\.tripNo[\s\S]*current\.tripDispatchCount/)
assert.match(page, /确认整车发车/)
assert.match(page, /tripDepartureRequest\(this.current, key.value\)/)
assert.doesNotMatch(page, /status: result\?\.status \|\| 'DEPARTED'/)
assert.match(page, /result\.dispatches\?\.some[\s\S]*await getDispatchTask[\s\S]*releaseFulfillmentKey/)
})
test('outbound partial pick submits the selected carton and distinguishes shared-pallet items', async () => {
const first = outboundPickPayload({ outboundPalletItemId: '11' }, { inventoryPositionId: '21', cartonMark: 'BOX-A' }, '1')
assert.deepEqual(first, { outboundPalletItemId: '11', inventoryPositionId: '21', packages: 1, cartonMarks: ['BOX-A'] })
@@ -36,13 +53,27 @@ test('return outbound preserves its task type and only permits pallet scans for
const item = { outboundPalletItemId: '12', skuCode: 'SKU-A' }
const position = { inventoryPositionId: '', cartonMark: 'RC-1' }
assert.deepEqual(outboundPickPayload(item, position, 1, 'SKU-A'), {
outboundPalletItemId: '12', inventoryPositionId: '', packages: 1, cartonMarks: [], skuCode: 'SKU-A'
outboundPalletItemId: '12', inventoryPositionId: null, packages: 1, cartonMarks: [], skuCode: 'SKU-A'
})
assert.deepEqual(outboundPickPayload(item, position, 1, 'RC-1'), {
outboundPalletItemId: '12', inventoryPositionId: '', packages: 1, cartonMarks: ['RC-1']
outboundPalletItemId: '12', inventoryPositionId: null, packages: 1, cartonMarks: ['RC-1']
})
})
test('real return-carton DTO remains scannable without inventing a warehouse inventory position or pallet', () => {
const task = normalizeOutboundTask({ task: { taskNo: 'PDA-RETURN', outboundType: 'RETURN_OUTBOUND', status: 'PENDING' },
items: [{ outboundPalletItemId: '12', palletNo: null, packageCount: 1, pickedPackageCount: 0,
positions: [{ inventoryPositionId: null, palletNo: null, cartonMark: 'RC-REAL', inboundCode: 'RT-REAL', availablePackages: 1, pickedPackages: 0 }] }] })
const item = task.items[0], position = item.positions[0]
assert.equal(item.palletNo, '')
assert.equal(position.cartonMark, 'RC-REAL')
assert.equal(position.availablePackages, 1)
assert.deepEqual(outboundPickPayload(item, position, 1, 'RC-REAL'), {
outboundPalletItemId: '12', inventoryPositionId: null, packages: 1, cartonMarks: ['RC-REAL']
})
assert.equal(outboundPickPayload(item, { ...position, inventoryPositionId: '2099999999999999999' }, 1).inventoryPositionId, '2099999999999999999')
})
function storageMock() {
const values = new Map()
globalThis.uni = {
@@ -89,7 +120,7 @@ test('fulfillment API calls the frozen list detail pick complete and depart rout
await listDispatchTasks('PICKING'); await getDispatchTask('DSP/1')
await pickDispatchItem('DSP/1', '11', { positionId: '22', packages: 1, productQuantity: 0, idempotencyKey: 'pick-key' })
await completeDispatchPicking('DSP/1', { idempotencyKey: 'complete-key' })
await departDispatch('DSP/1', { idempotencyKey: 'depart-key' })
await departDispatch('DSP/1', { recordVersion: '4', idempotencyKey: 'depart-key' })
await listOutboundTasks('PENDING'); await getOutboundTask('OUT/TASK/1')
await resolveOutboundTask('OUT/1')
await pickOutboundTask('OUT/TASK/1', { outboundPalletItemId: '33', inventoryPositionId: '44', packages: 1, idempotencyKey: 'out-pick-key' })
@@ -97,6 +128,7 @@ test('fulfillment API calls the frozen list detail pick complete and depart rout
await completeOutboundTask('OUT/TASK/1', { idempotencyKey: 'out-complete-key' })
assert.equal(calls.length, 11)
assert.deepEqual(calls.find(call => call.url.endsWith('/depart')).data, { recordVersion: '4', idempotencyKey: 'depart-key' })
assert.ok(calls.some((call) => call.url.endsWith('/dispatches?status=PICKING')))
assert.ok(calls.some((call) => call.url.includes('/dispatches/DSP%2F1/items/11/pick')))
assert.ok(calls.some((call) => call.url.endsWith('/dispatches/DSP%2F1/depart')))
@@ -227,3 +259,12 @@ test('receiving conflict recovery keeps request and trace evidence visible on na
assert.match(operation, /openContainer\(candidate\)[\s\S]*recoverCurrentAfterConflict\(error, true\)/)
assert.match(operation, /\.error-card text\{overflow-wrap:anywhere;word-break:break-word\}/)
})
test('dispatch review gate preserves the server decision without treating a string or missing value as approval', async () => {
assert.equal(normalizeDispatchTask({ canPick: true }).canPick, true)
for (const canPick of [false, undefined, 'true']) assert.equal(normalizeDispatchTask({ canPick }).canPick, false)
assert.equal(normalizeDispatchTask({ canPick: false, pickBlockReason: '调度审核已打回:预约待核对' }).pickBlockReason, '调度审核已打回:预约待核对')
const source = await read('../src/pages/dispatch/dispatch.vue')
assert.match(source, /canPick\(\) \{ return this.current\?\.canPick === true/)
assert.match(source, /canComplete\(\) \{ return this.current\?\.canPick === true/)
})
+12
View File
@@ -72,3 +72,15 @@ test('H5 proxy uses the shared gateway prefix and preserves API suffixes', async
assert.equal(proxy['/gateway-api'].rewrite(`/gateway-api${suffix}`), `/api${suffix}`)
}
})
test('gray H5 runs on an isolated network with same-origin Gateway API', async () => {
const compose = await read('../deploy/docker-compose.h5.production.yml')
const nginx = await read('../deploy/nginx.production.conf.template')
const docker = await read('../deploy/Dockerfile.h5.production')
assert.match(compose, /name: firstleg-production-network/)
assert.match(compose, /127\.0\.0\.1:/)
assert.doesNotMatch(compose, /test-network|gateway-overseas/)
assert.match(nginx, /location \/gateway-api\//)
assert.ok(nginx.includes('proxy_pass ${API_UPSTREAM}/api/;'))
assert.match(docker, /COPY deploy\/nginx.production.conf.template \/etc\/nginx\/templates\/default.conf.template/)
})
+83
View File
@@ -0,0 +1,83 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
const source = readFileSync(new URL('../src/pages/outbound/outbound.vue', import.meta.url), 'utf8').match(/<script>([\s\S]*?)<\/script>/)[1]
const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done }); return { promise, resolve } }
function page(overrides = {}) {
const calls = []
const deps = { PortalHeader: {}, ScanTargetInput: {}, session: { currentUser: {} }, fulfillment: { outbound: { packages: 1, currentTaskNo: 'OLD-TASK' } },
updateFulfillmentDraft() {}, fulfillmentIdempotencyKey: () => ({ value: 'key', signature: 'signature' }), releaseFulfillmentKey() {},
playErrorSound() {}, playSuccessSound() {}, pickOutboundPallet: async () => calls.push('pick'), completeOutboundTask: async () => calls.push('complete'),
getOutboundTask: async () => ({ taskNo: 'TASK', status: 'COMPLETED', items: [] }), normalizeCargoDiscardTask: value => value,
...overrides }
const definition = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
const vm = definition.data()
for (const [name, fn] of Object.entries(definition.methods)) vm[name] = fn.bind(vm)
for (const [name, fn] of Object.entries(definition.computed)) Object.defineProperty(vm, name, { get: () => fn.call(vm) })
return { vm, calls }
}
test('completed and cancelled tasks cannot submit pallet picking or task completion', async () => {
for (const status of ['COMPLETED', 'CANCELLED']) {
const { vm, calls } = page()
vm.current = { taskNo: 'TASK', status, items: [{ palletNo: 'PALLET', plannedPackages: 1, pickedPackages: 1, positions: [] }] }
await vm.handleScan('PALLET'); await vm.submitPalletPick('PALLET'); await vm.completeTask()
assert.deepEqual(calls, [])
}
})
test('pending and in-progress tasks still submit exactly once while busy', async () => {
for (const status of ['PENDING', 'IN_PROGRESS']) {
const pending = deferred(); let submits = 0
const { vm } = page({ pickOutboundPallet: async () => { submits++; await pending.promise } })
vm.current = { taskNo: 'TASK', status, items: [{ palletNo: 'PALLET', plannedPackages: 1, pickedPackages: 0, positions: [] }] }
const first = vm.handleScan('PALLET'); const second = vm.handleScan('PALLET')
pending.resolve(); await Promise.all([first, second]); assert.equal(submits, 1)
}
})
test('failed new pallet lookup clears the previous selectable pallet and preserves retry code', async () => {
const { vm } = page({ resolveCargoDiscardPallet: async () => { throw new Error('offline') } })
vm.discardCurrent = { discardNo: 'DISCARD' }; vm.discardTask = { palletNo: 'OLD', status: 'PENDING', skus: [] }
await vm.resolveDiscardPallet('NEW')
assert.equal(vm.discardTask, null); assert.equal(vm.canConfirmDiscard, false)
assert.equal(vm.discardPalletCode, 'NEW'); assert.equal(vm.error.message, 'offline'); assert.equal(vm.discardLoading, false)
})
test('discard pallet scans do not overlap and cannot leave the previous pallet ready to submit', async () => {
const pending = deferred(); let resolves = 0
const { vm } = page({ resolveCargoDiscardPallet: async () => { resolves++; return pending.promise } })
vm.discardCurrent = { discardNo: 'DISCARD' }; vm.discardTask = { palletNo: 'OLD', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }; vm.discardActuals = { sku: 1 }
const first = vm.resolveDiscardPallet('NEW')
const second = vm.resolveDiscardPallet('SECOND')
const canConfirmDuringLoad = vm.canConfirmDiscard
pending.resolve({ discardNo: 'DISCARD', palletNo: 'NEW', status: 'PENDING', skus: [] }); await Promise.all([first, second])
assert.equal(resolves, 1); assert.equal(canConfirmDuringLoad, false)
assert.equal(vm.discardTask.palletNo, 'NEW')
})
test('discard confirmation is guarded against double submission', async () => {
const pending = deferred(); let submits = 0
const { vm } = page({ confirmCargoDiscard: async () => { submits++; await pending.promise }, listCargoDiscardTasks: async () => [] })
vm.discardCurrent = { discardNo: 'DISCARD' }; vm.discardTask = { palletNo: 'PALLET', status: 'PENDING', skus: [{ itemId: 'sku', plannedPackages: 1 }] }; vm.discardActuals = { sku: 1 }
const first = vm.confirmDiscard(); const second = vm.confirmDiscard()
pending.resolve(); await Promise.all([first, second]); assert.equal(submits, 1)
})
test('discard retry stays in the discard workflow instead of restoring an old outbound task', async () => {
const { vm } = page(); const calls = []
vm.discardCurrent = { discardNo: 'DISCARD' }; vm.discardPalletCode = 'PALLET'
vm.resolveDiscardPallet = async code => calls.push(['discard', code]); vm.openTask = async code => calls.push(['outbound', code])
await vm.retry(); assert.deepEqual(calls, [['discard', 'PALLET']])
})
test('closing or switching discard order ignores the previous resolve response', async () => {
const pending = deferred()
const { vm } = page({ resolveCargoDiscardPallet: () => pending.promise })
vm.discardCurrent = { discardNo: 'FIRST' }
const first = vm.resolveDiscardPallet('PALLET')
vm.openDiscardOrder('SECOND')
pending.resolve({ discardNo: 'FIRST', palletNo: 'PALLET', skus: [] }); await first
assert.equal(vm.discardTask, null); assert.equal(vm.error, null)
})
+85 -1
View File
@@ -22,7 +22,8 @@ function tokens(version = 'old') {
function prepareSession() {
globalThis.uni = {
setStorageSync() {}, getStorageSync() {}, removeStorageSync() {}
setStorageSync() {}, getStorageSync() {}, removeStorageSync() {},
launches: [], reLaunch(options) { this.launches.push(options.url) }
}
resetOverseasRequestStateForTest(); signOut()
const value = tokens('old')
@@ -114,6 +115,8 @@ test('request retries only once and preserves the final request and trace identi
})
assert.equal(refreshCount, 1)
assert.equal(businessCalls, 2)
assert.equal(session.token, '')
assert.deepEqual(uni.launches, ['/pages/login/login'])
})
test('refresh rejection clears every local token and returns the auth trace', async () => {
@@ -133,8 +136,89 @@ test('refresh rejection clears every local token and returns the auth trace', as
assert.equal(session.currentUser, null)
assert.equal(session.token, '')
assert.equal(session.refreshToken, '')
assert.deepEqual(uni.launches, ['/pages/login/login'])
})
test('concurrent refresh rejection clears the session and navigates to login only once', async () => {
prepareSession()
let refreshCount = 0
uni.request = (options) => {
if (options.url.endsWith('/auth/refresh')) {
refreshCount += 1
return setTimeout(() => options.success(denied()), 5)
}
options.success(denied())
}
const results = await Promise.allSettled([overseasRequest('GET', '/one'), overseasRequest('GET', '/two')])
assert.ok(results.every(result => result.status === 'rejected'))
assert.equal(refreshCount, 1)
assert.equal(session.currentUser, null)
assert.deepEqual(uni.launches, ['/pages/login/login'])
})
for (const refreshSucceeds of [true, false]) {
test(`late refresh ${refreshSucceeds ? 'success' : 'failure'} cannot restore logout or clear a new login`, async () => {
for (const relogin of [false, true]) {
prepareSession()
let completeRefresh
let started
const refreshStarted = new Promise(resolve => { started = resolve })
let businessCalls = 0
uni.request = (options) => {
if (options.url.endsWith('/auth/refresh')) {
completeRefresh = () => options.success(refreshSucceeds ? ok(tokens('late')) : denied())
started()
return
}
businessCalls += 1
options.success(denied())
}
const pending = overseasRequest('POST', '/write')
await refreshStarted
signOut()
const replacement = tokens('new-login')
if (relogin) signInWithTokens(replacement)
completeRefresh()
await assert.rejects(pending)
assert.equal(session.token, relogin ? replacement.accessToken : '')
assert.equal(businessCalls, 1)
assert.deepEqual(uni.launches, [])
}
})
}
test('an old business response cannot replay with a newly logged-in session', async () => {
prepareSession()
let complete
let calls = 0
uni.request = options => { calls += 1; complete = () => options.success(denied()) }
const pending = overseasRequest('POST', '/write')
signOut()
const replacement = tokens('another-login')
signInWithTokens(replacement)
complete()
await assert.rejects(pending, /会话已变更/)
assert.equal(calls, 1)
assert.equal(session.token, replacement.accessToken)
assert.deepEqual(uni.launches, [])
})
for (const kind of ['upload', 'download']) {
test(`${kind} final unauthorized response navigates once and stops after one replay`, async () => {
prepareSession()
let refreshCount = 0
let calls = 0
uni.request = options => { refreshCount += 1; options.success(ok(tokens(kind))) }
uni.uploadFile = options => { calls += 1; options.success({ ...denied(), data: JSON.stringify(denied().data) }) }
uni.downloadFile = options => { calls += 1; options.success(denied()) }
await assert.rejects(kind === 'upload' ? overseasUpload('/photo', '/tmp/photo.jpg') : overseasDownload('/photo'))
assert.equal(refreshCount, 1)
assert.equal(calls, 2)
assert.equal(session.currentUser, null)
assert.deepEqual(uni.launches, ['/pages/login/login'])
})
}
test('photo upload refreshes and replays once while retaining the file payload', async () => {
const old = prepareSession(); const next = tokens('upload')
let refreshCount = 0; const uploads = []
+1 -1
View File
@@ -67,7 +67,7 @@ test('login has no committed demo credentials and inbound operation implements t
assert.match(operation, /resolveCurrentCarton\(cartonMark\)[\s\S]*value\.operationMode !== 'DROPSHIP_PALLET'/)
assert.match(operation, /name="carton"[\s\S]*@submit="resolveCarton"/)
assert.match(operation, /sourcePalletNo: value\.sourcePalletNo \|\| cartonMark[\s\S]*receiveCurrentWholePallet/)
assert.match(operation, /clearReceiptContext\(\)[\s\S]*activeScanTarget = 'carton'[\s\S]*drainScanQueue/)
assert.match(operation, /clearReceiptContext\(\)[\s\S]*activeScanTarget = 'carton'[\s\S]*scheduleScanDrain/)
assert.match(operation, /name="pallet"[\s\S]*@activate="activeTarget = 'pallet'"/)
assert.match(operation, /receiving\.activeScanTarget = 'pallet'/, '普通FBA或中转解析后直接进入托盘扫码以减少操作')
assert.match(operation, /\[Overseas PDA scan\] dispatch/)
+108
View File
@@ -0,0 +1,108 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import * as store from '../src/stores/receiving.js'
import { normalizeReceivingSession } from '../src/api/receiving-adapter.js'
const source = readFileSync(new URL('../src/pages/operation/operation.vue', import.meta.url), 'utf8').match(/<script>([\s\S]*?)<\/script>/)[1]
const defer = () => { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b }); return { promise, resolve, reject } }
const sessionValue = (patch = {}) => normalizeReceivingSession({ containerNo: 'CAB-A', preparedAt: '2026-10-02T10:00:00',
overseasWarehouseId: '900', status: 'SIGNED', inbounds: [{ inboundCode: 'IN-A', customerId: '100' }],
photos: ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN'].map((role, i) => ({ role, fileId: String(i + 1), downloadPath: `/signed/${i + 1}` })),
domesticLoadingPhotos: Array.from({ length: 5 }, (_, i) => ({ evidenceType: `PHOTO_${i}`, downloadPath: `/domestic/${i}` })), ...patch })
function page(overrides = {}) {
const deps = { ...store, session: { tenantId: '100', currentUser: { id: 'operator', warehouseId: '900' } },
PortalHeader: {}, ReceivingSessionSummary: {}, ScanTargetInput: {}, requireSession: () => true,
startScanBroadcast: () => () => {}, playErrorSound() {}, playSuccessSound() {},
receivingPhase: store.receivingPhase, toReceivingError: error => error, ...overrides }
const definition = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
const vm = { ...definition.data(), _pageActive: true, _scanEpoch: 0 }
for (const [name, method] of Object.entries(definition.methods)) vm[name] = method.bind(vm)
for (const [name, descriptor] of Object.entries(definition.computed)) Object.defineProperty(vm, name,
typeof descriptor === 'function' ? { get: () => descriptor.call(vm) } : { get: () => descriptor.get.call(vm), set: value => descriptor.set.call(vm, value) })
vm.moduleKey = 'inbound'; vm.validateReceivingWarehouse = value => value; vm.showToast = () => {}
return { vm, definition, deps }
}
test('same current readback retains three signed previews; collapsed unversioned domestic images issue no downloads', async () => {
store.resetReceivingSession(); const calls = []
const download = async path => { calls.push(path); return `local:${path}` }
const { vm } = page({ downloadCurrentUnloadPhoto: download, downloadCurrentDomesticLoadingPhoto: download })
await vm.applyCurrentReceiving(sessionValue({ status: 'WAIT_SIGN' })); assert.equal(calls.length, 8)
await vm.applyCurrentReceiving(sessionValue()); await vm.applyCurrentReceiving(sessionValue()); assert.equal(calls.length, 8)
await vm.toggleDomesticPhotos(); assert.equal(calls.length, 13)
await vm.restoreDomesticPhotoPreviews(); assert.equal(calls.length, 13)
// Domestic DTO has no file identity/version: a new response must not reuse an old image.
await vm.applyCurrentReceiving(sessionValue()); assert.equal(calls.length, 18)
})
test('signed photo changes, account/customer/cabinet changes and image failure cannot inherit stale previews', async () => {
store.resetReceivingSession(); let calls = 0
const { vm, deps } = page({ downloadCurrentUnloadPhoto: async () => `local:${++calls}`, downloadCurrentDomesticLoadingPhoto: async () => '' })
await vm.applyCurrentReceiving(sessionValue()); assert.equal(calls, 3)
const changed = sessionValue(); changed.photos[0].fileId = 'NEW'; await vm.applyCurrentReceiving(changed); assert.equal(calls, 4)
deps.session.tenantId = '200'; await vm.applyCurrentReceiving(changed); assert.equal(calls, 7)
await vm.applyCurrentReceiving(sessionValue({ containerNo: 'CAB-B' })); assert.equal(calls, 10)
await vm.applyCurrentReceiving(sessionValue({ containerNo: 'CAB-B', inbounds: [{ inboundCode: 'IN-OTHER', customerId: '201' }] })); assert.equal(calls, 13)
vm.invalidatePhotoPreview(store.receiving.photoSlots[0]); await vm.restorePhotoPreviews(); assert.equal(calls, 14)
})
test('overlapping current refresh joins inflight photo download and failed fetch remains retryable', async () => {
store.resetReceivingSession(); const pending = defer(); let calls = 0
const { vm } = page({ downloadCurrentUnloadPhoto: async path => { calls++; await pending.promise; return `local:${path}` } })
const first = vm.applyCurrentReceiving(sessionValue()); await Promise.resolve()
const second = vm.applyCurrentReceiving(sessionValue()); await Promise.resolve(); assert.equal(calls, 3)
pending.resolve(); await Promise.all([first, second]); assert.equal(calls, 3)
const photo = { downloadPath: '/retry' }; let attempts = 0
const download = async () => { if (++attempts === 1) throw new Error('offline'); return 'local:retry' }
await assert.rejects(store.restoreReceivingPhoto(photo, download), /offline/)
assert.equal(photo.status, 'error'); assert.equal(await store.restoreReceivingPhoto(photo, download), 'local:retry'); assert.equal(attempts, 2)
})
test('hide and unload discard queued scans and cancel drain scheduled by an in-flight request; return refreshes facts', async () => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
const pending = defer(); const { vm, definition } = page(); let submissions = 0, refreshes = 0
vm.dispatchHardwareScan = () => { submissions++ }; vm.bootstrap = async () => { refreshes++ }
vm.scanQueue = [{ value: 'SECOND', target: 'carton' }]
const running = vm.run('already sent', async () => { submissions++; await pending.promise })
definition.onHide.call(vm); pending.resolve(); await running
await new Promise(resolve => setTimeout(resolve, 5)); assert.equal(submissions, 1); assert.equal(vm.scanQueue.length, 0)
definition.onShow.call(vm); assert.equal(refreshes, 1); assert.equal(submissions, 1)
vm.scanQueue.push({ value: 'THIRD' }); vm.scheduleScanDrain(); definition.onUnload.call(vm)
await new Promise(resolve => setTimeout(resolve, 5)); assert.equal(submissions, 1)
})
test('hide during cargo resolution prevents the subsequent automatic dropship write, including quick return', async () => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
const pending = defer(); let writes = 0
const { vm, definition } = page({ resolveCurrentCarton: () => pending.promise, receiveCurrentWholePallet: async () => { writes++ } })
vm.bootstrap = async () => {}
const running = vm.resolveCarton('DP-1'); definition.onHide.call(vm); definition.onShow.call(vm)
pending.resolve({ operationMode: 'DROPSHIP_PALLET', sourcePalletNo: 'DP-1' }); await running
assert.equal(writes, 0); assert.equal(vm.scanQueue.length, 0)
})
test('already sent receipt retains successful server fact after hide without draining another scan', async () => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
const pending = defer(); let writes = 0, reads = 0
const { vm, definition } = page({ resolveCurrentCarton: async () => ({ operationMode: 'DROPSHIP_PALLET', sourcePalletNo: 'DP-1' }),
receiveCurrentWholePallet: async () => { writes++; return pending.promise },
getCurrentReceiving: async () => { reads++; return sessionValue({ receivedPackages: 1 }) } })
const running = vm.resolveCarton('DP-1'); await Promise.resolve(); assert.equal(writes, 1)
vm.scanQueue.push({ value: 'DP-2', target: 'carton' }); definition.onHide.call(vm)
pending.resolve({ receivedPackages: 1 }); await running
assert.equal(writes, 1); assert.equal(reads, 1); assert.equal(store.receiving.current.receivedPackages, 1)
assert.equal(store.receiving.lastReceipt.receivedPackages, 1); assert.equal(vm.scanQueue.length, 0)
})
test('late old photo download never overwrites replacement or cross-account preview', async () => {
store.resetReceivingSession(); const first = defer(), calls = []
const { vm, deps } = page({ downloadCurrentUnloadPhoto: async path => { calls.push(path); if (calls.length <= 3) await first.promise; return `local:${path}` } })
const old = vm.applyCurrentReceiving(sessionValue()); await Promise.resolve()
deps.session.tenantId = 'new-customer'
const changed = sessionValue(); changed.photos[0] = { ...changed.photos[0], fileId: 'new', downloadPath: '/signed/new' }
await vm.applyCurrentReceiving(changed); assert.equal(calls.length, 6)
first.resolve(); await old
assert.equal(store.receiving.photoSlots[0].fileId, 'new'); assert.equal(store.receiving.photoSlots[0].localPath, 'local:/signed/new')
})
+3 -3
View File
@@ -68,12 +68,12 @@ test('camera replaces only one fixed slot and failed retake preserves its previo
assert.match(page, /\.photo-grid\{display:grid;grid-template-columns:1fr/)
assert.match(page, /grid-template-columns:82px minmax\(0,1fr\) 78px/)
assert.match(page, /class="photo-role-info"/)
assert.match(page, /onShow\(\)[\s\S]*this\.inboundMode && \(!this\.bootstrapped \|\| this\.phase === 'candidates'\)[\s\S]*this\.bootstrap\(\)/)
assert.match(page, /onShow\(\)[\s\S]*this\.inboundMode && \(!this\.bootstrapped \|\| this\.phase === 'candidates' \|\| this\._resumeReceiving\)[\s\S]*this\.bootstrap\(\)/)
assert.match(page, /await this\.applyCurrentReceiving\(current\);[\s\S]*this\.bootstrapped = true/)
})
test('local thumbnail, preview, and authenticated session restore are wired to the frozen contract', () => {
assert.match(page, /restorePhotoPreviews\(\)[\s\S]*photo\?\.downloadPath[\s\S]*downloadCurrentUnloadPhoto\(photo\.downloadPath\)/)
assert.match(page, /restorePhotoPreviews\(\)[\s\S]*photo\?\.downloadPath[\s\S]*restoreReceivingPhoto\(photo, downloadCurrentUnloadPhoto\)/)
assert.deepEqual(photoUiBlockers().map((item) => item.code), [])
assert.ok(photoUiBlockers().every((item) => item.deploymentBlocker === true && item.reason))
})
@@ -81,7 +81,7 @@ test('local thumbnail, preview, and authenticated session restore are wired to t
test('domestic cabinet photos are read-only, previewable and non-blocking in sign and receive phases', () => {
assert.match(page, /国内装柜照片(只读)/)
assert.match(page, /domesticPhotosExpanded/)
assert.match(page, /downloadCurrentDomesticLoadingPhoto\(photo\.downloadPath\)/)
assert.match(page, /restoreReceivingPhoto\(photo, downloadCurrentDomesticLoadingPhoto\)/)
assert.match(page, /async loadDomesticPhoto\(photo\)[\s\S]*catch \(_\) \{ photo\.status = 'error' \}/)
assert.match(page, /previewDomesticPhoto\(photo\)[\s\S]*uni\.previewImage/)
assert.match(inbound, /downloadCurrentDomesticLoadingPhoto/)
+50
View File
@@ -0,0 +1,50 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import vm from 'node:vm'
import { getDispatchHandoverStatus, normalizeHandoverStatus } from '../src/api/trip-handover.js'
import { session } from '../src/stores/session.js'
const wire = () => ({ dispatchNo: 'PC-ONE', tripNo: 'TR-ONE', tripStatus: 'IN_TRANSIT', tripRecordVersion: '90071992547409931',
originalDeparture: { vehiclePlate: 'ORIGINAL', driverName: '原司机' },
deliveries: [{ deliveryNo: 'DL-DONE', status: 'DELIVERED', packages: '10', remainingPackages: 0, terminalAt: '2026-10-03T01:00:00Z', transport: { vehiclePlate: 'ORIGINAL', driverName: '原司机' } },
{ deliveryNo: 'DL-PENDING', status: 'WAIT_DELIVERY', packages: 20, remainingPackages: 16, terminalAt: null, transport: { vehiclePlate: 'NEW', driverName: '接替司机' } }], latestHandover: null })
test('PDA query stays a GET in the dedicated namespace and never supplies customer or warehouse', async () => {
const calls = []; session.apiBase = '/gateway-api'; session.token = 'test-token'
globalThis.uni = { request(options) { calls.push(options); options.success({ statusCode: 200, data: { code: '200', data: wire() }, header: {} }) } }
const result = await getDispatchHandoverStatus('PC-ONE')
assert.equal(calls.length, 1); assert.equal(calls[0].method, 'GET'); assert.match(calls[0].url, /\/pda\/v1\/last-leg\/dispatches\/PC-ONE\/handover-status$/)
assert.ok(!calls[0].data?.customerId); assert.ok(!calls[0].data?.warehouseId)
assert.equal(result.tripRecordVersion, '90071992547409931'); assert.equal(result.deliveries[0].transport.vehiclePlate, 'ORIGINAL'); assert.equal(result.deliveries[1].transport.vehiclePlate, 'NEW')
})
test('normalization exposes only business references and does not replace missing fields with internal IDs', () => {
const data = wire(); data.customerId = 'secret-customer'; data.originalDeparture = { vehicleId: 'secret-vehicle' }; data.deliveries[0].id = 'secret-id'
const value = normalizeHandoverStatus(data)
assert.doesNotMatch(JSON.stringify(value), /secret-/); assert.equal(value.originalDeparture.vehiclePlate, '')
assert.equal(value.deliveries[0].packages, 10)
assert.equal(value.deliveries[1].remainingPackages, 16)
for (const remainingPackages of [null, '', -1, 21, '1.5']) assert.throws(() => normalizeHandoverStatus({ ...data, deliveries: [{ ...data.deliveries[1], remainingPackages }] }), /剩余箱数无效/)
for (const packages of [null, '', -1, 0, '1.5']) assert.throws(() => normalizeHandoverStatus({ ...data, deliveries: [{ ...data.deliveries[0], packages }] }), /箱数无效/)
})
test('a response for another dispatch is rejected', async () => {
session.apiBase = '/gateway-api'; session.token = 'test-token'
globalThis.uni = { request(options) { options.success({ statusCode: 200, data: { code: '200', data: wire() }, header: {} }) } }
await assert.rejects(getDispatchHandoverStatus('PC-OTHER'), /不属于当前排车/)
})
test('the component ignores stale responses after switching task and has no write commands', async () => {
const file = await readFile(new URL('../src/components/DispatchHandoverStatus.vue', import.meta.url), 'utf8')
const pending = []
const context = { getDispatchHandoverStatus: () => new Promise(resolve => pending.push(resolve)), toDispatchError: error => ({ message: error.message }) }
const source = file.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^import .*$/gm, '').replace('export default', 'component =')
vm.runInNewContext(source, context)
const component = context.component, state = { ...component.data(), dispatchNo: 'PC-OLD' }
const first = component.methods.load.call(state)
state.dispatchNo = 'PC-NEW'; const second = component.methods.load.call(state)
pending[1]({ dispatchNo: 'PC-NEW' }); await second
pending[0]({ dispatchNo: 'PC-OLD' }); await first
assert.equal(state.status.dispatchNo, 'PC-NEW')
assert.doesNotMatch(file, /confirmTripHandover|departDispatch|pickDispatchItem|POST/)
const page = await readFile(new URL('../src/pages/dispatch/dispatch.vue', import.meta.url), 'utf8')
assert.match(page, /DispatchHandoverStatus v-if="departed" :dispatch-no="current.dispatchNo"/)
})
+116
View File
@@ -0,0 +1,116 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import { PDA_ROLE, PDA_AUTHORITY, session, signInWithTokens, signOut, getSessionRevision } from '../src/stores/session.js'
import { receiving } from '../src/stores/receiving.js'
import { refresh, getPendingRefresh } from '../src/api/auth.js'
import { overseasRequest } from '../src/api/overseas.js'
const helperUrl = new URL('../src/api/logout-session.js', import.meta.url)
const logoutHelper = fs.existsSync(helperUrl) ? await import(helperUrl) : {}
const jwt = claims => [Buffer.from('{"alg":"none"}').toString('base64url'), Buffer.from(JSON.stringify(claims)).toString('base64url'), 'signature'].join('.')
function tokens(version='old', identity={}) { return {accessToken:jwt({user_id:42,membership_id:84,tenant_id:0,app_code:'PLATFORM',overseas_warehouse_id:900,exp:Math.floor(Date.now()/1000)+3600,version,...identity}),refreshToken:`refresh-${version}`,roles:[PDA_ROLE],authorities:[PDA_AUTHORITY]} }
const ok = data => ({statusCode:200,data:{code:'200',data},header:{}})
function prepare(){const values=new Map(),calls=[],launches=[],toasts=[];globalThis.uni={setStorageSync:(k,v)=>values.set(k,JSON.parse(JSON.stringify(v))),getStorageSync:k=>values.get(k),removeStorageSync:k=>values.delete(k),reLaunch:o=>launches.push(o.url),showToast:o=>toasts.push(o.title)};signOut();const original=tokens();signInWithTokens(original);receiving.current={containerNo:'ORIGINAL'};return {values,calls,launches,toasts,original}}
function page(){const source=fs.readFileSync(new URL('../src/pages/profile/profile.vue',import.meta.url),'utf8').match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^\s*import[^\n]*$/gm,'').replace('export default','return');return new Function('PortalHeader','PopupHeader','signOut','logoutCurrentSession','getSessionRevision','session',source)({}, {},signOut,logoutHelper.logoutCurrentSession,getSessionRevision,session)}
test('actual profile confirmation clears drafts and revokes captured session before reporting success',async()=>{
const state=prepare();uni.request=o=>{state.calls.push(o);assert.equal(session.token,'');assert.equal(receiving.current,null);o.success(ok(null))}
const context={user:session.currentUser,closeLogout(){this.closed=true}}
await page().methods.logout.call(context)
assert.equal(state.calls.length,1)
assert.match(state.calls[0].url,/\/auth\/logout$/)
assert.deepEqual(state.calls[0].data,{refreshToken:state.original.refreshToken})
assert.equal(state.calls[0].header.Authorization,`Bearer ${state.original.accessToken}`)
assert.equal(state.values.has('overseas-pda-auth-session'),false)
})
const denied = (statusCode=401,code='A0401') => ({statusCode,data:{code,message:'denied',requestId:'logout-request',traceId:'logout-trace'},header:{}})
test('explicit expired-access 401 refreshes captured credentials once and revokes without restoring local tokens',async()=>{
const state=prepare(),replacement=tokens('rotated')
session.token=tokens('expired',{exp:1}).accessToken
uni.request=o=>{state.calls.push(o);assert.equal(session.token,'');if(state.calls.length===1)o.success(denied());else if(o.url.endsWith('/auth/refresh')){assert.deepEqual(o.data,{refreshToken:state.original.refreshToken});assert.equal(o.header.Authorization,undefined);o.success(ok(replacement))}else{o.success(ok(null));assert.equal(o.header.Authorization,`Bearer ${replacement.accessToken}`);assert.deepEqual(o.data,{refreshToken:replacement.refreshToken})}}
const result=await logoutHelper.logoutCurrentSession()
assert.equal(result.revoked,true);assert.equal(state.calls.length,3);assert.equal(session.currentUser,null);assert.equal(state.values.size,0)
})
test('unknown network and 5xx results never trigger refresh or blind logout replay',async()=>{
for(const failure of ['network','server','business']){
const state=prepare();uni.request=o=>{state.calls.push(o);if(failure==='network')o.fail({errMsg:'connection lost'});else o.success(denied(failure==='server'?503:200,failure==='server'?'200':'A0401'))}
await assert.rejects(logoutHelper.logoutCurrentSession(),error=>failure==='network'?error.network===true:error.statusCode===(failure==='server'?503:200))
assert.equal(state.calls.length,1);assert.equal(session.token,'');assert.equal(receiving.current,null)
}
})
test('refresh failure preserves diagnostic evidence and does not send a second revoke',async()=>{
const state=prepare();uni.request=o=>{state.calls.push(o);o.success(state.calls.length===1?denied():denied(503,'S0503'))}
await assert.rejects(logoutHelper.logoutCurrentSession(),error=>error.code==='S0503'&&error.requestId==='logout-request'&&error.traceId==='logout-trace')
assert.equal(state.calls.length,2);assert.equal(getPendingRefresh(state.original.refreshToken),null);assert.equal(session.token,'')
})
test('different user membership or warehouse from logout refresh cannot revoke another session',async()=>{
for(const identity of [{user_id:43},{membership_id:85},{overseas_warehouse_id:901}]){
const state=prepare();uni.request=o=>{state.calls.push(o);o.success(state.calls.length===1?denied():ok(tokens('wrong',identity)))}
await assert.rejects(logoutHelper.logoutCurrentSession(),/不同作业主体/);assert.equal(state.calls.length,2);assert.equal(state.values.size,0)
}
})
test('missing refreshed credentials fail closed without local restoration',async()=>{
for(const field of ['refreshToken','accessToken']){
const state=prepare(),replacement=tokens('invalid');delete replacement[field]
uni.request=o=>{state.calls.push(o);o.success(state.calls.length===1?denied():ok(replacement))}
await assert.rejects(logoutHelper.logoutCurrentSession());assert.equal(state.calls.length,2);assert.equal(session.token,'');assert.equal(state.values.size,0)
}
})
test('revocation-only refresh does not load permissions or recreate a login when permission snapshots are absent',async()=>{
const state=prepare(),replacement=tokens('revocation-only');delete replacement.roles;delete replacement.authorities
uni.request=o=>{state.calls.push(o);o.success(state.calls.length===1?denied():o.url.endsWith('/auth/refresh')?ok(replacement):ok(null))}
assert.equal((await logoutHelper.logoutCurrentSession()).revoked,true)
assert.equal(state.calls.length,3);assert.equal(session.token,'');assert.deepEqual(session.authorization,{roles:[],authorities:[]});assert.equal(state.values.size,0)
})
test('second revoke 401 is terminal and cannot start another refresh loop',async()=>{
const state=prepare();uni.request=o=>{state.calls.push(o);o.success(o.url.endsWith('/auth/refresh')?ok(tokens('rotated')):denied())}
await assert.rejects(logoutHelper.logoutCurrentSession(),error=>error.statusCode===401)
assert.equal(state.calls.length,3);assert.equal(state.calls.filter(o=>o.url.endsWith('/auth/refresh')).length,1)
})
test('profile duplicate confirmation shares its pending operation',async()=>{
const state=prepare();uni.request=o=>state.calls.push(o)
const context={user:session.currentUser,closeLogout(){this.closed=true}},methods=page().methods
const first=methods.logout.call(context),second=methods.logout.call(context)
assert.equal(first,second);assert.equal(state.calls.length,1);assert.equal(state.launches.length,1)
state.calls[0].success(ok(null));await first
})
test('old logout result cannot clear redirect or warn over a subsequent new login',async()=>{
for(const failed of [false,true]){
const state=prepare();uni.request=o=>state.calls.push(o)
const context={user:session.currentUser,closeLogout(){}},pending=page().methods.logout.call(context)
const replacement=tokens('new-login',{user_id:43,membership_id:85,overseas_warehouse_id:901})
signInWithTokens(replacement);receiving.current={containerNo:'NEW-USER'}
if(failed)state.calls[0].fail({errMsg:'old connection lost'});else state.calls[0].success(ok(null))
await pending
assert.equal(session.token,replacement.accessToken);assert.equal(receiving.current.containerNo,'NEW-USER');assert.equal(state.launches.length,1);assert.equal(state.toasts.length,0)
}
})
test('logout joins actual in-flight business refresh and revokes its rotation while preserving a new login',async()=>{
const state=prepare();let refreshRequest
uni.request=o=>{state.calls.push(o);if(o.url.endsWith('/auth/refresh'))refreshRequest=o;else if(o.url.endsWith('/auth/logout'))o.success(ok(null));else o.success(denied())}
const business=overseasRequest('GET','/pda/v1/last-leg/receiving-containers/current').catch(error=>error)
await new Promise(resolve=>setImmediate(resolve));assert.ok(refreshRequest)
const logout=logoutHelper.logoutCurrentSession()
const newer=tokens('new-login',{user_id:43,membership_id:85,overseas_warehouse_id:901});signInWithTokens(newer)
const rotated=tokens('rotated-old');refreshRequest.success(ok(rotated))
assert.equal((await logout).revoked,true);assert.match((await business).message,/会话已变更/)
assert.equal(state.calls.filter(o=>o.url.endsWith('/auth/refresh')).length,1)
const revoked=state.calls.find(o=>o.url.endsWith('/auth/logout'));assert.deepEqual(revoked.data,{refreshToken:rotated.refreshToken});assert.equal(revoked.header.Authorization,`Bearer ${rotated.accessToken}`)
assert.equal(session.token,newer.accessToken);assert.deepEqual(state.launches,[])
})
test('unknown in-flight refresh never rotates captured credentials again or reports remote success',async()=>{
const state=prepare();uni.request=o=>state.calls.push(o)
const inFlight=refresh(state.original.refreshToken).catch(error=>error)
const logout=logoutHelper.logoutCurrentSession();const rejected=assert.rejects(logout,error=>error.network===true)
state.calls[0].fail({errMsg:'refresh result unknown'});await rejected;await inFlight
assert.equal(state.calls.length,1);assert.equal(session.token,'');assert.equal(getPendingRefresh(state.original.refreshToken),null)
})
test('ordinary local signOut and cancelled profile dialog never contact Auth',()=>{
const state=prepare();uni.request=o=>state.calls.push(o)
page().methods.closeLogout.call({logoutOpen:true,$refs:{logoutPopup:{close(){}}}})
assert.equal(session.token,state.original.accessToken);signOut();assert.equal(state.calls.length,0);assert.equal(session.token,'')
})
test('no captured credentials require no remote request',async()=>{
const state=prepare();signOut();uni.request=o=>state.calls.push(o)
assert.equal((await logoutHelper.logoutCurrentSession()).revoked,false);assert.equal(state.calls.length,0)
})