diff --git a/.dockerignore b/.dockerignore index 54ab655..de0e642 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,3 +7,6 @@ !dist/build/ !dist/build/h5/ !dist/build/h5/** + +!deploy/Dockerfile.h5.production +!deploy/nginx.production.conf.template diff --git a/AGENTS.md b/AGENTS.md index f178d19..31693af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 联调与真机扫码验收。 diff --git a/README.md b/README.md index b9a03e9..5d9b525 100644 --- a/README.md +++ b/README.md @@ -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 真机验收。 diff --git a/deploy/Dockerfile.h5.production b/deploy/Dockerfile.h5.production new file mode 100644 index 0000000..5ecc940 --- /dev/null +++ b/deploy/Dockerfile.h5.production @@ -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 diff --git a/deploy/docker-compose.h5.production.yml b/deploy/docker-compose.h5.production.yml new file mode 100644 index 0000000..3dc79ee --- /dev/null +++ b/deploy/docker-compose.h5.production.yml @@ -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 diff --git a/deploy/nginx.production.conf.template b/deploy/nginx.production.conf.template new file mode 100644 index 0000000..4ce203e --- /dev/null +++ b/deploy/nginx.production.conf.template @@ -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; + } +} diff --git a/src/api/auth.js b/src/api/auth.js index 966d4e7..da78f63 100644 --- a/src/api/auth.js +++ b/src/api/auth.js @@ -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) } diff --git a/src/api/fulfillment.js b/src/api/fulfillment.js index 6f04379..eb2eeba 100644 --- a/src/api/fulfillment.js +++ b/src/api/fulfillment.js @@ -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 } : {}) diff --git a/src/api/logout-session.js b/src/api/logout-session.js new file mode 100644 index 0000000..35500f2 --- /dev/null +++ b/src/api/logout-session.js @@ -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 + }) +} diff --git a/src/api/overseas.js b/src/api/overseas.js index 19b049e..3966a6f 100644 --- a/src/api/overseas.js +++ b/src/api/overseas.js @@ -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: {} }, '签收照片下载失败') } diff --git a/src/api/trip-handover.js b/src/api/trip-handover.js new file mode 100644 index 0000000..558286f --- /dev/null +++ b/src/api/trip-handover.js @@ -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 +} diff --git a/src/components/DispatchHandoverStatus.vue b/src/components/DispatchHandoverStatus.vue new file mode 100644 index 0000000..39f2589 --- /dev/null +++ b/src/components/DispatchHandoverStatus.vue @@ -0,0 +1,61 @@ + + + diff --git a/src/pages/operation/operation.vue b/src/pages/operation/operation.vue index 2aa5177..eb3bf22 100644 --- a/src/pages/operation/operation.vue +++ b/src/pages/operation/operation.vue @@ -47,7 +47,7 @@ 国内装柜照片(只读)现场对照国内装柜过程与封柜状态{{ domesticPhotoCount }} / 5 - + {{ photo.status === 'error' ? '加载失败,点按重试' : '正在准备照片' }} {{ photo.label }} @@ -58,7 +58,7 @@ - + 未添加 @@ -76,10 +76,10 @@