This commit is contained in:
2026-09-07 15:07:54 +08:00
parent 65962eb940
commit ee99854a39
9 changed files with 94 additions and 24 deletions
+2 -1
View File
@@ -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`;本地临时路径仅用于本次设备预览,不作为跨会话文件事实。
+1 -1
View File
@@ -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/入仓码扫码拣货与核平完成;不会展示或进入派送流程。
+2 -2
View File
@@ -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)
+38
View File
@@ -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),
+2 -1
View File
@@ -5,7 +5,7 @@
<text class="receiving-summary-status">{{ statusLabel }}</text>
</view>
<view class="receiving-summary-context">
<text class="receiving-summary-warehouse">收货仓:{{ session?.overseasWarehouseName || '—' }}</text>
<text class="receiving-summary-warehouse">海外接柜仓:{{ receivingWarehouseLabel }}</text>
<view class="receiving-summary-destinations"><text class="destination-label">目的仓:</text><view class="destination-tags"><text v-for="value in destinationValues" :key="value">{{ value }}</text></view></view>
</view>
<view class="receiving-summary-metrics">
@@ -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 : ['—'] },
+22 -14
View File
@@ -8,6 +8,7 @@
<view v-if="loading" class="state-card"><uni-load-more status="loading" /><text>正在恢复当前柜作业…</text></view>
<template v-else-if="phase === 'candidates'">
<view class="warehouse-scope-card"><text>当前 PDA 绑定仓</text><strong>{{ boundWarehouseLabel }}</strong><text>仅显示已分配到当前仓的集装箱</text></view>
<view class="list-toolbar"><text>共 {{ candidates.length }} 柜</text><button :disabled="busy || candidateLoading" @tap="loadCandidates">{{ candidateLoading ? '刷新中…' : '刷新' }}</button></view>
<view v-if="candidateError" class="error-card candidate-error-card"><text class="error-message">{{ candidateError.message || '待卸货集装箱加载失败' }}</text><text v-if="candidateError.code">code={{ candidateError.code }}</text><text v-if="candidateError.requestId">requestId={{ candidateError.requestId }}</text><text v-if="candidateError.traceId">traceId={{ candidateError.traceId }}</text><button class="secondary-button" :disabled="candidateLoading" @tap="loadCandidates">重新加载</button></view>
<view v-if="candidateLoading && !candidates.length" class="state-card"><uni-load-more status="loading" /><text>正在刷新待卸货集装箱…</text></view>
@@ -17,15 +18,16 @@
<view class="candidate-facts"><view><strong>{{ item.totalOrderCount }}</strong><text>总订单</text></view><view><strong>{{ item.inboundCodeCount }}</strong><text>入仓码</text></view><view><strong>{{ item.planCount }}</strong><text>已生成计划</text></view><view><strong>{{ item.pendingPlanCount }}</strong><text>未操作</text></view></view>
<text class="candidate-progress-copy">已生成 {{ item.planCount }} 个入库计划,{{ item.pendingPlanCount }} 个未操作<template v-if="item.missingInboundCodeCount">,其中 {{ item.missingInboundCodeCount }} 个缺入仓码</template></text>
<text v-if="!item.operable" class="candidate-block-reason">{{ item.blockReason || '当前柜入库计划尚未齐全' }}</text>
<text v-if="item.destinationCodes?.length" class="candidate-destinations">仓码:{{ item.destinationCodes.join(' / ') }}</text>
<text class="candidate-receiving-warehouse">海外接柜仓:{{ receivingWarehouseLabel(item) }}</text>
<text v-if="item.destinationCodes?.length" class="candidate-destinations">业务目的仓:{{ item.destinationCodes.join(' / ') }}</text>
</view>
</template>
<template v-else-if="phase === 'detail'">
<template v-if="selected?.operable">
<ReceivingSessionSummary :session="selected" />
<view class="lock-notice">选择后账号将锁定当前柜,完成前不能切换其他柜号。</view>
<view class="sticky-action"><button class="ghost-button" :disabled="busy" @tap="cancelDetail">返回列表</button><button class="primary-button" :disabled="busy || !selected.allPlansReady || !selected.operable" @tap="prepareContainer">选择此柜并开始拍照</button></view>
<view class="lock-notice">接柜仓由平台预先分配;确认后只建立当前账号的接柜会话,完成前不能切柜。</view>
<view class="sticky-action"><button class="ghost-button" :disabled="busy" @tap="cancelDetail">返回列表</button><button class="primary-button" :disabled="busy || !selected.allPlansReady || !selected.operable" @tap="prepareContainer">确认接柜仓并开始拍照</button></view>
</template>
<template v-else>
<view class="readonly-candidate-card">
@@ -33,6 +35,7 @@
<view class="candidate-facts"><view><strong>{{ selected?.totalOrderCount }}</strong><text>总订单</text></view><view><strong>{{ selected?.inboundCodeCount }}</strong><text>入仓码</text></view><view><strong>{{ selected?.planCount }}</strong><text>已生成计划</text></view><view><strong>{{ selected?.pendingPlanCount }}</strong><text>未操作</text></view></view>
<text class="candidate-progress-copy">已生成 {{ selected?.planCount }} 个入库计划,{{ selected?.pendingPlanCount }} 个未操作<template v-if="selected?.missingInboundCodeCount">,其中 {{ selected.missingInboundCodeCount }} 个缺入仓码</template></text>
<text class="candidate-block-reason">{{ selected?.blockReason || '当前柜入库计划尚未齐全' }}</text>
<text class="candidate-receiving-warehouse">海外接柜仓:{{ receivingWarehouseLabel(selected) }}</text>
</view>
<view class="sticky-action one"><button class="ghost-button" :disabled="busy" @tap="cancelDetail">返回列表</button></view>
</template>
@@ -111,12 +114,12 @@
import PortalHeader from '@/components/PortalHeader.vue'
import ReceivingSessionSummary from '@/components/ReceivingSessionSummary.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { isNoCurrentReceiving, isReceivingConflict, RECEIVING_PHOTO_ROLES } from '@/api/receiving-adapter.js'
import { session, requireSession } from '@/stores/session.js'
import { assertReceivingWarehouse, isNoCurrentReceiving, isReceivingConflict, RECEIVING_PHOTO_ROLES } 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 { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
import { completeCurrentReceiving, downloadCurrentUnloadPhoto, getCurrentReceiving, getReceivingContainer, listReceivingCandidates, prepareReceivingContainer, receiveCurrentBulk, receiveCurrentCarton, resolveCurrentCarton, resolveCurrentPallet, signCurrentReceiving, uploadCurrentUnloadPhoto } from '@/api/inbound.js'
import { completeCurrentReceiving, downloadCurrentUnloadPhoto, getCurrentReceiving, getCurrentWarehouseProfile, getReceivingContainer, listReceivingCandidates, prepareReceivingContainer, receiveCurrentBulk, receiveCurrentCarton, resolveCurrentCarton, resolveCurrentPallet, signCurrentReceiving, uploadCurrentUnloadPhoto } from '@/api/inbound.js'
const MAX_UNLOAD_PHOTO_BYTES = 10 * 1024 * 1024
export default {
@@ -132,6 +135,7 @@
uploadedPhotoCount() { return this.photoSlots.filter((item) => item?.fileId).length }, photosReady() { return this.uploadedPhotoCount === 3 && !this.photoSlots.some((item) => item?.status === 'uploading') },
canSubmitReceipt() { if (!this.activeInbound || !this.resolvedPallet?.compatible) return false; if (this.activeInbound.destinationType === 'FBA') { const count = Number(this.packageCount); return Number.isInteger(count) && count > 0 && count <= Number(this.activeInbound.pendingPackages) } return Boolean(this.activeInbound.cartonMark) },
allInboundsReconciled() { const values = this.current?.inbounds || []; return values.length > 0 && values.every((item) => Number(item.pendingPackages) === 0) },
boundWarehouseLabel() { const code = String(this.user?.warehouseCode || '').trim(); const name = String(this.user?.warehouseName || '').trim(); return code && name ? `${code} · ${name}` : '海外仓未关联' },
pageTitle() { return { candidates: '待卸货集装箱', detail: '柜号详情', sign: '拆柜拍照签收', receive: '当前柜收货', completed: '入库完成' }[this.phase] },
pageSubtitle() { return { candidates: '选择集装箱后拍摄 3 张照片完成签收', detail: '核对柜号与整柜计划箱数', sign: '完成现场取证后进入收货', receive: '扫描箱码定位入仓码后开始收货', completed: '库存与柜级进度已由服务端核平' }[this.phase] }
},
@@ -143,28 +147,31 @@
statusTone(status) { return status === 'RECEIVING' ? 'status-current' : status === 'COMPLETED' ? 'status-good' : 'status-wait' },
formatPlannedArrival(value) { const text = String(value || '').trim(); return text ? text.replace('T', ' ').slice(0, 16) : '待确认' },
destinationLabel(item) { return item?.destinationType === 'FBA' ? (item.fbaWarehouseCode || 'FBA') : (item?.destinationWarehouseName || item?.destinationWarehouseCode || '海外仓') },
receivingWarehouseLabel(item) { const code = String(item?.overseasWarehouseCode || '').trim(); const name = String(item?.overseasWarehouseName || '').trim(); return code && name ? `${code} · ${name}` : '—' },
validateReceivingWarehouse(value, label) { return assertReceivingWarehouse(value, this.user, label) },
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) { console.info('[Overseas PDA scan] dispatch', { value, phase: this.phase, target: this.activeTarget, matchedInbound: this.activeInbound?.inboundCode }); if (!this.inboundMode) return this.showToast('该功能缺少海外 PDA 专用接口,扫码未提交', 'warning'); if (this.phase === 'candidates') return this.showToast('请直接点击待卸货集装箱进入详情', 'warning'); if (this.phase !== 'receive') return; if (this.activeTarget === 'pallet' && this.activeInbound) { this.palletInput = value; return this.resolvePallet(value) } this.cartonInput = value; return this.resolveCarton(value) },
async run(action, work) { this.busy = true; this.error = null; try { const result = await work(); this.showToast(`${action}成功`, 'success'); return result } catch (error) { this.error = error; playErrorSound(); this.showToast(error.message || `${action}失败`, 'error'); throw error } finally { this.busy = false } },
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 applyCurrentReceiving(value) { setCurrentReceiving(value); await this.restorePhotoPreviews(); this.setScanTargetForCurrent(value) },
async bootstrap() { if (this.loading) return; this.loading = true; this.error = null; this.detailOpen = false; try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.candidateError = null; this.bootstrapped = true } catch (error) { if (isNoCurrentReceiving(error)) { resetReceivingDraft(); await this.loadCandidates(false); this.bootstrapped = true } else this.candidateError = error } finally { this.loading = false } },
async applyCurrentReceiving(value) { this.validateReceivingWarehouse(value, '当前接柜会话'); setCurrentReceiving(value); await this.restorePhotoPreviews(); 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) } 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) { if (this.candidateLoading) return; this.candidateLoading = true; this.candidateError = null; this.error = null; try { receiving.candidates = await listReceivingCandidates(); if (feedback) this.showToast('刷新待卸柜成功', 'success') } catch (error) { this.candidateError = error; if (feedback) { playErrorSound(); this.showToast(error.message || '刷新待卸柜失败', 'error') } } finally { this.candidateLoading = false } },
async openContainer(candidate) { const containerNo = String(candidate?.containerNo || '').trim(); if (!containerNo || this.busy) return; this.error = null; if (!candidate.operable) { receiving.selected = candidate; this.detailOpen = true; return } try { const detail = await this.run('查询柜号', () => getReceivingContainer(containerNo)); receiving.selected = { ...candidate, ...detail, allPlansReady: candidate.allPlansReady, operable: candidate.operable, blockReason: candidate.blockReason }; this.detailOpen = true; playSuccessSound() } catch (error) { await this.recoverCurrentAfterConflict(error) } },
async loadCandidates(feedback = true) { if (this.candidateLoading) return; this.candidateLoading = true; this.candidateError = null; this.error = null; try { 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 } },
async openContainer(candidate) { const containerNo = String(candidate?.containerNo || '').trim(); if (!containerNo || this.busy) return; this.error = null; try { this.validateReceivingWarehouse(candidate, `柜号 ${containerNo}`); if (!candidate.operable) { receiving.selected = candidate; this.detailOpen = true; return } const detail = await this.run('查询柜号', async () => this.validateReceivingWarehouse(await getReceivingContainer(containerNo), `柜号 ${containerNo}`)); receiving.selected = { ...candidate, ...detail, allPlansReady: candidate.allPlansReady, operable: candidate.operable, blockReason: candidate.blockReason }; this.detailOpen = true; playSuccessSound() } catch (error) { if (!await this.recoverCurrentAfterConflict(error)) { this.error = error; this.showToast(error.message || '查询柜号失败', 'error') } } },
cancelDetail() { this.detailOpen = false; receiving.selected = null },
async prepareContainer() { if (!this.selected?.containerNo || !this.selected?.allPlansReady || !this.selected?.operable || this.busy) return; const key = idempotencyKey('prepare', { containerNo: this.selected.containerNo }); try { const value = await this.run('选择当前柜', () => prepareReceivingContainer(this.selected.containerNo, { idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value); this.setScanTargetForCurrent(value); this.detailOpen = false } catch (error) { await this.recoverCurrentAfterConflict(error, true) } },
async prepareContainer() { if (!this.selected?.containerNo || !this.selected?.allPlansReady || !this.selected?.operable || this.busy) return; const key = idempotencyKey('prepare', { 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, { 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 }) },
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('签收当前柜', () => signCurrentReceiving({ photos, idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value); this.clearReceiptContext() } catch (_) {} },
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 = 1; receiving.activeScanTarget = 'carton' },
async resolveCarton(raw) { const cartonMark = String(raw?.detail?.value ?? raw ?? this.cartonInput).trim(); if (!cartonMark) return; if (this.busy) { console.warn('[Overseas PDA carton] ignored while busy', { cartonMark }); return this.showToast('正在处理上一笔扫描,请稍后重试', 'warning') } console.info('[Overseas PDA carton] resolving', { cartonMark, containerNo: this.current?.containerNo }); try { const value = await this.run('解析箱码', () => resolveCurrentCarton(cartonMark)); console.info('[Overseas PDA carton] resolved', { cartonMark: value.cartonMark, inboundCode: value.inboundCode, destinationType: value.destinationType, pendingPackages: value.pendingPackages }); receiving.resolvedCarton = value; receiving.resolvedPallet = null; this.cartonInput = value.cartonMark; this.palletInput = ''; this.packageCount = 1; playSuccessSound() } catch (error) { console.error('[Overseas PDA carton] 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; if (!value.compatible) throw Object.assign(new Error('托盘不匹配'), { code: 'PALLET_INCOMPATIBLE' }); playSuccessSound() } catch (error) { if (!this.error) this.error = error } },
async submitReceipt() { if (!this.canSubmitReceipt) return; const isFba = this.activeInbound.destinationType === 'FBA'; const payload = isFba ? { inboundCode: this.activeInbound.inboundCode, palletCode: this.resolvedPallet.palletCode, packageCount: Number(this.packageCount) } : { cartonMark: this.activeInbound.cartonMark, palletCode: this.resolvedPallet.palletCode }; const key = idempotencyKey(isFba ? 'bulk-receipt' : 'carton-receipt', payload); try { const result = await this.run('提交实收', () => isFba ? receiveCurrentBulk({ ...payload, idempotencyKey: key.value }) : receiveCurrentCarton({ ...payload, idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); receiving.lastReceipt = result; setCurrentReceiving(await getCurrentReceiving()); this.clearReceiptContext(); playSuccessSound() } catch (_) {} },
async completeContainer() { if (!this.allInboundsReconciled) return; const key = idempotencyKey('complete', { containerNo: this.current.containerNo }); try { const value = await this.run('完成入库', () => completeCurrentReceiving({ idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value) } catch (_) {} },
async submitReceipt() { if (!this.canSubmitReceipt) return; const isFba = this.activeInbound.destinationType === 'FBA'; const payload = isFba ? { inboundCode: this.activeInbound.inboundCode, palletCode: this.resolvedPallet.palletCode, packageCount: Number(this.packageCount) } : { cartonMark: this.activeInbound.cartonMark, palletCode: this.resolvedPallet.palletCode }; const key = idempotencyKey(isFba ? 'bulk-receipt' : 'carton-receipt', payload); try { const result = await this.run('提交实收', () => isFba ? receiveCurrentBulk({ ...payload, idempotencyKey: key.value }) : receiveCurrentCarton({ ...payload, idempotencyKey: key.value })); const current = this.validateReceivingWarehouse(await getCurrentReceiving(), '当前接柜会话'); releaseIdempotencyKey(key.signature); receiving.lastReceipt = result; await this.applyCurrentReceiving(current); this.clearReceiptContext(); playSuccessSound() } catch (_) {} },
async completeContainer() { if (!this.allInboundsReconciled) return; const key = idempotencyKey('complete', { containerNo: this.current.containerNo }); try { const value = await this.run('完成入库', async () => this.validateReceivingWarehouse(await completeCurrentReceiving({ idempotencyKey: key.value }), '当前接柜会话')); releaseIdempotencyKey(key.signature); await this.applyCurrentReceiving(value) } catch (_) {} },
async finishAndReturn() { resetReceivingDraft(); await this.loadCandidates(false) },
showToast(message, type) { clearTimeout(this._toastTimer); this.toast = { visible: true, message, type }; this._toastTimer = setTimeout(() => { this.toast.visible = false }, 2600) }
}
@@ -202,4 +209,5 @@
.sticky-action button{display:flex;align-items:center;justify-content:center;line-height:normal;text-align:center}
.list-toolbar{height:28px;margin:0 2px 7px;align-items:center}.list-toolbar button{box-sizing:border-box;width:auto;min-width:48px;height:28px;min-height:28px;margin:0;padding:0 8px;border:1px solid #bdd1eb;border-radius:7px;color:#1668dc;font-size:11px;line-height:26px;background:#f7faff}.container-card-compact{padding:11px 13px;cursor:pointer;transition:background-color .12s ease,border-color .12s ease}.container-card-pressed{border-color:#93baf0;background:#f4f8ff}.container-card-actions{display:flex;flex-shrink:0;align-items:flex-end;flex-direction:column;gap:5px}.container-detail-link{box-sizing:border-box;min-height:24px;padding:0 4px;color:#1668dc;font-size:11px;font-weight:700;line-height:24px}.candidate-facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin-top:9px;padding:8px 0;border-top:1px solid #edf1f5;border-bottom:1px solid #edf1f5}.candidate-facts view{display:flex;align-items:center;flex-direction:column;border-right:1px solid #edf1f5}.candidate-facts view:last-child{border-right:0}.candidate-facts strong{color:#17324d;font-size:17px;line-height:21px}.candidate-facts text{margin-top:2px;color:#6f8194;font-size:10px}.candidate-destinations{display:block;margin-top:7px;overflow:hidden;color:#6f8194;font-size:11px;line-height:17px;text-overflow:ellipsis;white-space:nowrap}
.candidate-arrival{display:block;margin-top:3px;color:#718095;font-size:10px;line-height:15px}.candidate-progress-copy{display:block;margin-top:7px;color:#435a72;font-size:10px;line-height:16px}.candidate-block-reason{display:block;margin-top:6px;padding:6px 8px;border-radius:7px;color:#73551b;font-size:10px;line-height:16px;background:#f0e9dc}.container-card-disabled{border-color:#d9dee5;background:#f1f3f5;box-shadow:none}.container-card-disabled .container-no,.container-card-disabled .candidate-facts strong{color:#778391}.container-card-disabled .container-detail-link{color:#788797}.container-card-disabled .candidate-progress-copy,.container-card-disabled .candidate-arrival,.container-card-disabled .candidate-destinations{color:#87919d}.status-disabled{color:#788491;background:#e1e5e9}.readonly-candidate-card{padding:13px;border:1px solid #d9dee5;border-radius:13px;background:#f1f3f5}.readonly-candidate-card .candidate-block-reason{margin-top:9px}
.warehouse-scope-card{display:flex;flex-direction:column;gap:3px;margin-bottom:10px;padding:10px 12px;border:1px solid #b8d2f3;border-radius:10px;color:#617389;font-size:10px;line-height:15px;background:#f2f7ff}.warehouse-scope-card strong{color:#17324d;font-size:13px}.candidate-receiving-warehouse{display:block;margin-top:7px;color:#315777;font-size:11px;font-weight:700;line-height:17px}
</style>
+1 -1
View File
@@ -80,7 +80,7 @@ test('missing user-visible relationships use explicit empty labels instead of ID
read('../src/components/ReceivingSessionSummary.vue'),
read('../src/pages/pallet/pallet.vue')
])
assert.match(summary, /overseasWarehouseName \|\| '—'/)
assert.match(summary, /overseasWarehouseCode[\s\S]*overseasWarehouseName/)
assert.match(summary, /unique\.length \? unique : \['—'\]/)
assert.match(pallet, /inboundCode \|\| '无入仓码'/)
assert.match(pallet, /locationCode \|\| '未绑定库位'/)
+24 -3
View File
@@ -2,7 +2,7 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { normalizeReceivingCandidate, normalizeReceivingSession } from '../src/api/receiving-adapter.js'
import { assertReceivingWarehouse, normalizeReceivingCandidate, normalizeReceivingSession, normalizeReceivingWarehouseProfile } from '../src/api/receiving-adapter.js'
import { receiving, receivingPhase, receivingProgress, resetReceivingSession, setCurrentReceiving } from '../src/stores/receiving.js'
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
@@ -21,6 +21,7 @@ const inbounds = Array.from({ length: 15 }, (_, index) => ({
test('one container candidate aggregates fifteen independent one-inbound plans', () => {
const candidate = normalizeReceivingCandidate({
sourceContainerBookingId: '2093166483653849089', containerNo: 'C10000000', status: 'WAIT_SIGN',
overseasWarehouseCode: 'US-LAX-RCV', overseasWarehouseName: '洛杉矶接柜仓',
plannedArrivalAt: '2026-08-29T09:30:00', totalOrderCount: 15, planCount: 15, inboundCodeCount: 15,
missingInboundCodeCount: 0, plannedInboundCodeCount: 15, pendingPlanCount: 0, allPlansReady: true, operable: true, blockReason: '',
destinationCodes: ['ABE3', 'US-LAX-01'], plannedPalletCount: 15,
@@ -28,6 +29,7 @@ test('one container candidate aggregates fifteen independent one-inbound plans',
})
assert.deepEqual(candidate, {
sourceContainerBookingId: '2093166483653849089', containerNo: 'C10000000', status: 'WAIT_SIGN',
overseasWarehouseCode: 'US-LAX-RCV', overseasWarehouseName: '洛杉矶接柜仓',
plannedArrivalAt: '2026-08-29T09:30:00', totalOrderCount: 15, planCount: 15, inboundCodeCount: 15,
missingInboundCodeCount: 0, plannedInboundCodeCount: 15, pendingPlanCount: 0, allPlansReady: true, operable: true, blockReason: '',
destinationCodes: ['ABE3', 'US-LAX-01'], plannedPalletCount: 15,
@@ -37,6 +39,21 @@ test('one container candidate aggregates fifteen independent one-inbound plans',
assert.equal(candidate.sourceContainerBookingId, '2093166483653849089')
})
test('receiving warehouse facts are explicit and fail closed when absent or assigned elsewhere', () => {
const profile = normalizeReceivingWarehouseProfile({ warehouseId: '900', warehouseCode: 'US-LAX-RCV', warehouseName: '洛杉矶接柜仓' })
const assigned = normalizeReceivingCandidate({ containerNo: 'C10000002', overseasWarehouseCode: 'US-LAX-RCV', overseasWarehouseName: '洛杉矶接柜仓' })
assert.equal(assertReceivingWarehouse(assigned, profile, '柜号 C10000002'), assigned)
assert.throws(() => assertReceivingWarehouse(normalizeReceivingCandidate({ containerNo: 'C10000003' }), profile), (error) => {
assert.equal(error.code, 'RECEIVING_WAREHOUSE_CONTRACT_INVALID')
return /缺少已分配海外接柜仓/.test(error.message)
})
assert.throws(() => assertReceivingWarehouse(normalizeReceivingCandidate({ containerNo: 'C10000004', overseasWarehouseCode: 'US-NYC-RCV', overseasWarehouseName: '纽约接柜仓' }), profile), (error) => {
assert.equal(error.code, 'RECEIVING_WAREHOUSE_MISMATCH')
return /已分配至其他海外接柜仓/.test(error.message)
})
assert.throws(() => normalizeReceivingWarehouseProfile({ warehouseId: '900', warehouseName: '洛杉矶接柜仓' }), /绑定仓信息不完整/)
})
test('incomplete containers remain visible but fail closed from server readiness facts', () => {
const candidate = normalizeReceivingCandidate({
sourceContainerBookingId: '2093166483653849099', containerNo: 'C10000001',
@@ -60,7 +77,7 @@ test('incomplete containers remain visible but fail closed from server readiness
test('current recovery retains all independent-plan inbound facts while progress remains container-level', () => {
const session = normalizeReceivingSession({
containerNo: 'C10000000', status: 'RECEIVING', totalPackages: 60,
containerNo: 'C10000000', overseasWarehouseCode: 'US-LAX-RCV', overseasWarehouseName: '洛杉矶接柜仓', status: 'RECEIVING', totalPackages: 60,
receivedPackages: 4, pendingPackages: 56, photos: [], inbounds
})
setCurrentReceiving(session)
@@ -90,6 +107,10 @@ test('container UI locks and signs once across independent plans, then renders o
assert.match(inboundApi, /current\('\/sign'\)/)
assert.match(operation, /prepareReceivingContainer\(this\.selected\.containerNo, \{ idempotencyKey: key\.value \}\)/)
assert.match(operation, /getCurrentReceiving\(\)[\s\S]*applyCurrentReceiving\(current\)/)
assert.match(operation, /applyWarehouseProfile\(await getCurrentWarehouseProfile\(\)\)/)
assert.match(operation, /values\.map\(\(item\) => this\.validateReceivingWarehouse/)
assert.match(operation, /prepareReceivingContainer[\s\S]*validateReceivingWarehouse/)
assert.match(summary, /overseasWarehouseCode[\s\S]*overseasWarehouseName/)
for (const forbidden of ['customerId', 'planNo', 'allocationId', 'warehouseId', 'locationId']) {
assert.doesNotMatch(inboundApi, new RegExp(forbidden))
}
@@ -108,7 +129,7 @@ test('PDA does not infer container eligibility from partial plan workflow states
assert.match(adapter, /operable: value\.operable === true/)
assert.match(operation, /if \(!candidate\.operable\) \{ receiving\.selected = candidate; this\.detailOpen = true; return \}/)
assert.match(operation, /if \(!this\.selected\?\.containerNo \|\| !this\.selected\?\.allPlansReady \|\| !this\.selected\?\.operable/)
assert.match(operation, /catch \(error\) \{ await this\.recoverCurrentAfterConflict\(error, true\) \}/)
assert.match(operation, /recoverCurrentAfterConflict\(error, true\)/)
assert.match(operation, /preserveEvidence \? error : null[\s\S]*this\.candidateError = preserveEvidence \? error : null/)
assert.doesNotMatch(inbound, /sort\(/)
})
+2 -1
View File
@@ -178,10 +178,11 @@ test('business and network errors retain diagnostics without refreshing or clear
prepareSession(); let refreshCount = 0
globalThis.uni.request = (options) => {
if (options.url.endsWith('/auth/refresh')) refreshCount += 1
options.success({ statusCode: 200, data: { code: 'A0409', message: 'locked', requestId: 'business-request', traceId: 'business-trace' }, header: {} })
options.success({ statusCode: 200, data: { code: 'A0409', message: '当前柜已分配至其他海外接柜仓,无法在本仓操作', requestId: 'business-request', traceId: 'business-trace' }, header: {} })
}
await assert.rejects(overseasRequest('POST', '/pda/locked'), (error) => {
assert.equal(error.code, 'A0409')
assert.equal(error.message, '当前柜已分配至其他海外接柜仓,无法在本仓操作')
assert.equal(error.requestId, 'business-request')
assert.equal(error.traceId, 'business-trace')
return true