From ee99854a39bc55328f6097a054a603c5dda80029 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 7 Sep 2026 15:07:54 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 3 +- README.md | 2 +- src/api/inbound.js | 4 +-- src/api/receiving-adapter.js | 38 ++++++++++++++++++++++ src/components/ReceivingSessionSummary.vue | 3 +- src/pages/operation/operation.vue | 36 ++++++++++++-------- tests/business-identifiers-ui.test.mjs | 2 +- tests/multi-plan-receiving.test.mjs | 27 +++++++++++++-- tests/overseas-request.test.mjs | 3 +- 9 files changed, 94 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b4dbc53..163d232 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,7 +57,8 @@ - `src/api/inbound.js` 已接入待卸柜候选、柜详情/prepare、current 恢复、三类照片签收、统一箱码定位入仓码、实物托盘解析、FBA bulk/OVERSEAS carton 收货及整柜完成;FBA bulk 不提交 `skuCode`,库存分配由服务端按计划明细完成;响应与 404/409 判定集中在 `src/api/receiving-adapter.js`,双 Token 刷新与海外 401 单次重放由 `src/api/overseas.js` 统一处理。 - `src/api/overseas.js` 的 `overseasUrl` 统一拼接普通请求、上传和下载地址:先移除运行时 `apiBase` 末尾斜杠;当基址已以 `/api` 或 `/overseas-api` 结尾时,再移除业务路径重复的 `/api` 前缀。各业务 API 不得自行拼接 `session.apiBase`,也不得依赖双斜杠或重复 `/api` 被网关容错。 -- 待卸柜候选保留服务端返回的未齐全柜并严格消费 `allPlansReady`、`operable`、`blockReason`、`plannedArrivalAt` 与柜级计划统计;`operable=false` 只能查看只读摘要,不得调用 prepare。候选顺序沿用服务端“可操作优先、同组计划到仓时间升序、时间为空最后”的排序,前端不得用入仓码/计划数组长度自行推导就绪或把预计到港冒充计划到仓。 +- 待卸柜候选只消费服务端已按 Token 绑定仓过滤的结果,并严格校验每柜正式 `overseasWarehouseCode`、`overseasWarehouseName` 与 `warehouse-profile` 一致;字段缺失或仓不一致一律 fail-closed,不得从 FBA/业务目的仓或内部 ID 推导。候选保留服务端返回的未齐全柜并严格消费 `allPlansReady`、`operable`、`blockReason`、`plannedArrivalAt` 与柜级计划统计;`operable=false` 只能查看只读摘要,不得调用 prepare。候选顺序沿用服务端“可操作优先、同组计划到仓时间升序、时间为空最后”的排序,前端不得用入仓码/计划数组长度自行推导就绪或把预计到港冒充计划到仓。 +- 候选详情、prepare 响应和 current 恢复都必须重复校验同一接柜仓编码;prepare 只建立已分配柜的作业会话,不得当作首次认领。服务端仓冲突的 `409/A0409` 中文错误保留 `requestId`/`traceId`;会话建立后仍按既有 current 恢复与完成前禁止切柜逻辑处理。 - `src/api/pallet.js` 只调用 Lastleg 托盘专用 PDA Controller;前端提交服务端扫码返回的 `palletVersion`、`positionVersion` 和随机幂等键,不提交客户、仓库或库位标识。409 后必须重新扫码获取最新版本,禁止用旧库存快照继续作业。 - 请求身份和资源范围只取 Token 与 current 会话;前端不得提交 `customerId`、`planNo`、`allocationId`、`warehouseId` 或 `locationId`。409 必须引导账号恢复当前柜,完成前不得切柜。 - 签收照片固定为三个角色且每个角色只有一个当前文件;上传成功后使用服务端 `downloadPath` 在当前会话内鉴权读取。更换失败时保留原 `fileId` 和可提交状态,无原图的失败槽不得计入 `photosReady`;本地临时路径仅用于本次设备预览,不作为跨会话文件事实。 diff --git a/README.md b/README.md index af2ccbd..b10f9b2 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ npm run build:app - 已复用并内置:登录壳、PDA 样式、扫码广播、声音反馈、导航、网络检测、图标及 `uni_modules`。 - 已隔离:国内集装箱拣货、装柜证据、国内 Firstleg API、国内任务状态和国内本地草稿。 - 入仓页面已接待卸柜候选、柜详情与账号锁柜、三类角色照片签收、current 恢复、统一箱码与既有物理托盘解析、FBA 按箱数和 OVERSEAS 逐箱收货、逐入仓码核平及整柜完成的真实 API;收货页不预列柜内入仓码,箱码解析成功后只显示本次命中的入仓码;409 会引导返回当前柜,错误会保留业务码、requestId 与 traceId。 -- 候选列表同时显示已齐全与未齐全柜,展示计划到仓时间、总订单、入仓码、已生成计划、未操作和缺入仓码数量;未齐全柜置灰但可查看服务端阻断原因,只有服务端 `allPlansReady=true && operable=true` 才能 prepare。 +- 候选列表明确显示当前 PDA 绑定仓及每柜已分配的海外接柜仓编码/名称,仅接受与绑定仓一致的服务端候选、详情、prepare 与 current 会话;字段缺失或仓不匹配时直接阻断,不从 FBA/业务目的仓或内部 ID 推导。列表同时显示已齐全与未齐全柜,展示计划到仓时间、总订单、入仓码、已生成计划、未操作和缺入仓码数量;未齐全柜置灰但可查看服务端阻断原因,只有服务端 `allPlansReady=true && operable=true` 才能建立已分配柜的作业会话。 - 托盘管理已接 `/api/pda/v1/last-leg/pallet-operations/**`:支持扫描查询当前仓实物托盘及库存内容,并按服务端版本执行组托、拆托、换托和解绑;不同目的仓不能合托,冻结或处理中的库存由服务端拒绝。 - 排车拣货已接 `/api/pda/v1/last-leg/dispatches/**`:支持任务列表、业务单号恢复、托盘码扫码拣货、任务完成及全部箱数核平后的发车;服务端仍会校验关联出库单和托盘门禁。 - 出库作业已接 `/api/pda/v1/last-leg/outbound-tasks/**`:支持普通出库和一件代发任务列表/详情、托盘码/箱码/SKU/入仓码扫码拣货与核平完成;不会展示或进入派送流程。 diff --git a/src/api/inbound.js b/src/api/inbound.js index 3db847f..44bde46 100644 --- a/src/api/inbound.js +++ b/src/api/inbound.js @@ -1,11 +1,11 @@ import { overseasDownload, overseasRequest, overseasUpload } from '@/api/overseas.js' -import { normalizeReceivingCandidate, normalizeReceivingSession } from '@/api/receiving-adapter.js' +import { normalizeReceivingCandidate, normalizeReceivingSession, normalizeReceivingWarehouseProfile } from '@/api/receiving-adapter.js' const RECEIVING_BASE = '/pda/v1/last-leg/receiving-containers' const current = (suffix = '') => `${RECEIVING_BASE}/current${suffix}` export const listReceivingCandidates = async () => (await overseasRequest('GET', `${RECEIVING_BASE}/candidates`)).map(normalizeReceivingCandidate) -export const getCurrentWarehouseProfile = () => overseasRequest('GET', `${RECEIVING_BASE}/warehouse-profile`) +export const getCurrentWarehouseProfile = async () => normalizeReceivingWarehouseProfile(await overseasRequest('GET', `${RECEIVING_BASE}/warehouse-profile`)) export const getReceivingContainer = async (containerNo) => normalizeReceivingSession(await overseasRequest('GET', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}`)) export const prepareReceivingContainer = async (containerNo, request) => normalizeReceivingSession(await overseasRequest('POST', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}/prepare`, request)) export const uploadCurrentUnloadPhoto = (filePath) => overseasUpload(current('/unload-photos/files'), filePath) diff --git a/src/api/receiving-adapter.js b/src/api/receiving-adapter.js index 7b1cf6d..daf8d13 100644 --- a/src/api/receiving-adapter.js +++ b/src/api/receiving-adapter.js @@ -1,6 +1,40 @@ export const RECEIVING_PHOTO_ROLES = ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN'] const number = (value) => Math.max(0, Number(value || 0)) +const text = (value) => typeof value === 'string' ? value.trim() : '' + +function warehouseContractError(message) { + return Object.assign(new Error(message), { code: 'RECEIVING_WAREHOUSE_CONTRACT_INVALID' }) +} + +export function normalizeReceivingWarehouseProfile(value = {}) { + const profile = { + warehouseId: value.warehouseId === null || value.warehouseId === undefined ? '' : String(value.warehouseId), + warehouseCode: text(value.warehouseCode), + warehouseName: text(value.warehouseName) + } + if (!profile.warehouseId || !profile.warehouseCode || !profile.warehouseName) { + throw warehouseContractError('当前 PDA 绑定仓信息不完整,请联系管理员检查账号绑定') + } + return profile +} + +export function assertReceivingWarehouse(value, profile, label = '当前柜') { + const expectedCode = text(profile?.warehouseCode) + const expectedName = text(profile?.warehouseName) + const actualCode = text(value?.overseasWarehouseCode) + const actualName = text(value?.overseasWarehouseName) + if (!expectedCode || !expectedName) { + throw warehouseContractError('当前 PDA 绑定仓信息不完整,暂停接柜作业') + } + if (!actualCode || !actualName) { + throw warehouseContractError(`${label}缺少已分配海外接柜仓信息,暂停操作并请联系管理员`) + } + if (actualCode !== expectedCode) { + throw Object.assign(new Error(`${label}已分配至其他海外接柜仓,当前仓不可操作`), { code: 'RECEIVING_WAREHOUSE_MISMATCH' }) + } + return value +} export function normalizeReceivingInbound(value = {}) { return { @@ -17,6 +51,8 @@ export function normalizeReceivingCandidate(value = {}) { ...value, sourceContainerBookingId: typeof value.sourceContainerBookingId === 'string' ? value.sourceContainerBookingId.trim() : '', containerNo: String(value.containerNo || '').trim(), + overseasWarehouseCode: text(value.overseasWarehouseCode), + overseasWarehouseName: text(value.overseasWarehouseName), plannedArrivalAt: typeof value.plannedArrivalAt === 'string' && value.plannedArrivalAt.trim() ? value.plannedArrivalAt.trim() : null, totalOrderCount: number(value.totalOrderCount), planCount: number(value.planCount), @@ -42,6 +78,8 @@ export function normalizeReceivingSession(value) { : [] return { ...value, + overseasWarehouseCode: text(value.overseasWarehouseCode), + overseasWarehouseName: text(value.overseasWarehouseName), photos: RECEIVING_PHOTO_ROLES.map((role) => photos.find((photo) => photo?.role === role && photo?.fileId)).filter(Boolean), totalPackages: number(value.totalPackages), receivedPackages: number(value.receivedPackages), diff --git a/src/components/ReceivingSessionSummary.vue b/src/components/ReceivingSessionSummary.vue index 7c0cc30..0a96e73 100644 --- a/src/components/ReceivingSessionSummary.vue +++ b/src/components/ReceivingSessionSummary.vue @@ -5,7 +5,7 @@ {{ statusLabel }} - 收货仓:{{ session?.overseasWarehouseName || '—' }} + 海外接柜仓:{{ receivingWarehouseLabel }} 目的仓:{{ value }} @@ -22,6 +22,7 @@ export default { props: { session: { type: Object, default: null } }, computed: { + receivingWarehouseLabel() { const code = String(this.session?.overseasWarehouseCode || '').trim(); const name = String(this.session?.overseasWarehouseName || '').trim(); return code && name ? `${code} · ${name}` : '—' }, inboundCodeCount() { return Array.isArray(this.session?.inbounds) ? this.session.inbounds.length : 0 }, plannedPalletCount() { return (this.session?.inbounds || []).reduce((sum, item) => sum + Math.max(0, Number(item?.plannedPalletCount || 0)), 0) }, destinationValues() { const values = (this.session?.inbounds || []).map((item) => item?.fbaWarehouseCode || item?.destinationWarehouseName || item?.destinationWarehouseCode).filter(Boolean); const unique = [...new Set(values)]; return unique.length ? unique : ['—'] }, diff --git a/src/pages/operation/operation.vue b/src/pages/operation/operation.vue index 5ce6c22..8d5aa7a 100644 --- a/src/pages/operation/operation.vue +++ b/src/pages/operation/operation.vue @@ -8,6 +8,7 @@ 正在恢复当前柜作业…