This commit is contained in:
2026-08-28 13:54:38 +08:00
parent a617386f9d
commit 4810b44452
6 changed files with 81 additions and 17 deletions
+1
View File
@@ -50,6 +50,7 @@
## 海外业务接口边界
- `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` 统一处理。
- 待卸柜候选保留服务端返回的未齐全柜并严格消费 `allPlansReady`、`operable`、`blockReason`、`plannedArrivalAt` 与柜级计划统计;`operable=false` 只能查看只读摘要,不得调用 prepare。候选顺序沿用服务端计划到仓时间排序,前端不得用入仓码/计划数组长度自行推导就绪或把预计到港冒充计划到仓。
- `src/api/pallet.js` 只调用 Lastleg 托盘专用 PDA Controller;前端提交服务端扫码返回的 `palletVersion`、`positionVersion` 和随机幂等键,不提交客户、仓库或库位标识。409 后必须重新扫码获取最新版本,禁止用旧库存快照继续作业。
- 请求身份和资源范围只取 Token 与 current 会话;前端不得提交 `customerId`、`planNo`、`allocationId`、`warehouseId` 或 `locationId`。409 必须引导账号恢复当前柜,完成前不得切柜。
- 签收照片固定为三个角色且每个角色只有一个当前文件;上传成功后使用服务端 `downloadPath` 在当前会话内鉴权读取。更换失败时保留原 `fileId` 和可提交状态,无原图的失败槽不得计入 `photosReady`;本地临时路径仅用于本次设备预览,不作为跨会话文件事实。
+1
View File
@@ -40,6 +40,7 @@ npm run build:app
- 已复用并内置:登录壳、PDA 样式、扫码广播、声音反馈、导航、网络检测、图标及 `uni_modules`。
- 已隔离:国内集装箱拣货、装柜证据、国内 Firstleg API、国内任务状态和国内本地草稿。
- 入仓页面已接待卸柜候选、柜详情与账号锁柜、三类角色照片签收、current 恢复、统一箱码与既有物理托盘解析、FBA 按箱数和 OVERSEAS 逐箱收货、逐入仓码核平及整柜完成的真实 API;收货页不预列柜内入仓码,箱码解析成功后只显示本次命中的入仓码;409 会引导返回当前柜,错误会保留业务码、requestId 与 traceId。
- 候选列表同时显示已齐全与未齐全柜,展示计划到仓时间、总订单、入仓码、已生成计划、未操作和缺入仓码数量;未齐全柜置灰但可查看服务端阻断原因,只有服务端 `allPlansReady=true && operable=true` 才能 prepare。
- 托盘管理已接 `/api/pda/v1/last-leg/pallet-operations/**`:支持扫描查询当前仓实物托盘及库存内容,并按服务端版本执行组托、拆托、换托和解绑;不同目的仓不能合托,冻结或处理中的库存由服务端拒绝。
- 入仓请求不手填客户、计划、分配、仓库或库位标识;身份与当前柜范围由 Token、Gateway 和服务端会话确定。页面同时支持 PDA 广播扫码与 H5 人工输入。
- 登录、恢复和刷新会 fail-closed 校验国内/海外双 Token 的同一平台操作主体、`OVERSEAS_WAREHOUSE_PDA_OPERATOR`、`lastleg:pda:operate` 及同一冻结海外仓;并发海外 401 共享一次国内 refresh,校验完成后原子替换双 Token并将原请求或照片上传重放一次。
+10 -1
View File
@@ -15,14 +15,23 @@ export function normalizeReceivingInbound(value = {}) {
export function normalizeReceivingCandidate(value = {}) {
return {
...value,
sourceContainerBookingId: typeof value.sourceContainerBookingId === 'string' ? value.sourceContainerBookingId.trim() : '',
containerNo: String(value.containerNo || '').trim(),
plannedArrivalAt: typeof value.plannedArrivalAt === 'string' && value.plannedArrivalAt.trim() ? value.plannedArrivalAt.trim() : null,
totalOrderCount: number(value.totalOrderCount),
planCount: number(value.planCount),
inboundCodeCount: number(value.inboundCodeCount),
missingInboundCodeCount: number(value.missingInboundCodeCount),
plannedInboundCodeCount: number(value.plannedInboundCodeCount),
pendingPlanCount: number(value.pendingPlanCount),
destinationCodes: Array.isArray(value.destinationCodes) ? value.destinationCodes.filter(Boolean) : [],
plannedPalletCount: number(value.plannedPalletCount),
totalPackages: number(value.totalPackages),
receivedPackages: number(value.receivedPackages),
currentAccount: value.currentAccount === true
currentAccount: value.currentAccount === true,
allPlansReady: value.allPlansReady === true,
operable: value.operable === true,
blockReason: typeof value.blockReason === 'string' ? value.blockReason.trim() : ''
}
}
+22 -7
View File
@@ -12,17 +12,30 @@
<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>
<view v-else-if="!candidateError && !candidates.length" class="empty-card"><uni-icons type="info" color="#8090a3" size="34" /><text>暂无待卸货集装箱</text><button class="secondary-button" :disabled="candidateLoading" @tap="loadCandidates">重新加载</button></view>
<view v-for="item in candidates" :key="item.containerNo" class="container-card container-card-compact" role="button" :aria-label="`${item.containerNo},${item.currentAccount ? '继续当前柜' : '查看详情'}`" hover-class="container-card-pressed" @tap="openContainer(item.containerNo)">
<view class="card-head"><text class="container-no">{{ item.containerNo }}</text><view class="container-card-actions"><text class="status-tag" :class="statusTone(item.status)">{{ statusLabel(item.status) }}</text><text class="container-detail-link">{{ item.currentAccount ? '继续' : '详情' }} ›</text></view></view>
<view class="candidate-facts"><view><strong>{{ item.inboundCodeCount }}</strong><text>入仓码</text></view><view><strong>{{ item.totalPackages }}</strong><text>箱</text></view><view><strong>{{ item.plannedPalletCount }}</strong><text>计划托盘</text></view><view><strong>{{ item.destinationCodes?.length || 0 }}</strong><text>目的仓</text></view></view>
<view v-for="item in candidates" :key="item.containerNo" class="container-card container-card-compact" :class="{ 'container-card-disabled': !item.operable }" role="button" :aria-label="`${item.containerNo},${item.operable ? '查看可操作柜详情' : '查看未齐全柜摘要'}`" hover-class="container-card-pressed" @tap="openContainer(item)">
<view class="card-head"><view><text class="container-no">{{ item.containerNo }}</text><text class="candidate-arrival">计划到仓:{{ formatPlannedArrival(item.plannedArrivalAt) }}</text></view><view class="container-card-actions"><text class="status-tag" :class="item.operable ? statusTone(item.status) : 'status-disabled'">{{ item.operable ? statusLabel(item.status) : '待齐全' }}</text><text class="container-detail-link">{{ item.operable ? (item.currentAccount ? '继续' : '详情') : '只读' }} ›</text></view></view>
<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>
</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" @tap="prepareContainer">选择此柜并开始拍照</button></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">
<view class="card-head"><view><text class="container-no">{{ selected?.containerNo }}</text><text class="candidate-arrival">计划到仓:{{ formatPlannedArrival(selected?.plannedArrivalAt) }}</text></view><text class="status-tag status-disabled">暂不可操作</text></view>
<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>
</view>
<view class="sticky-action one"><button class="ghost-button" :disabled="busy" @tap="cancelDetail">返回列表</button></view>
</template>
</template>
<template v-else-if="phase === 'sign'">
@@ -128,6 +141,7 @@
methods: {
statusLabel(status) { return ({ WAIT_SIGN: '待签收', SIGNED: '已签收', RECEIVING: '收货中', COMPLETED: '已完成' })[status] || status },
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 || '海外仓') },
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 },
@@ -137,11 +151,11 @@
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 recoverCurrentAfterConflict(error) { if (!isReceivingConflict(error)) return false; try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.detailOpen = false; this.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.error = null; this.showToast('该柜已被其他账号选择,列表已刷新', 'warning'); return true } this.error = error; return 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(raw) { const containerNo = String(raw?.detail?.value ?? raw ?? '').trim(); if (!containerNo || this.busy) return; try { receiving.selected = await this.run('查询柜号', () => getReceivingContainer(containerNo)); this.detailOpen = true; playSuccessSound() } catch (error) { await this.recoverCurrentAfterConflict(error) } },
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) } },
cancelDetail() { this.detailOpen = false; receiving.selected = null },
async prepareContainer() { if (!this.selected?.containerNo || 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) } },
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) } },
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 }) },
@@ -187,4 +201,5 @@
.primary-button,.secondary-button,.ghost-button{display:flex;box-sizing:border-box;align-items:center;justify-content:center;padding-top:0;padding-bottom:0;line-height:normal;text-align:center}
.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}
</style>
+39 -4
View File
@@ -20,16 +20,42 @@ const inbounds = Array.from({ length: 15 }, (_, index) => ({
test('one container candidate aggregates fifteen independent one-inbound plans', () => {
const candidate = normalizeReceivingCandidate({
containerNo: 'C10000000', status: 'WAIT_SIGN', planCount: 15, inboundCodeCount: 15,
sourceContainerBookingId: '2093166483653849089', containerNo: 'C10000000', status: 'WAIT_SIGN',
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,
totalPackages: 60, receivedPackages: 0, currentAccount: false
})
assert.deepEqual(candidate, {
containerNo: 'C10000000', status: 'WAIT_SIGN', planCount: 15, inboundCodeCount: 15,
sourceContainerBookingId: '2093166483653849089', containerNo: 'C10000000', status: 'WAIT_SIGN',
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,
totalPackages: 60, receivedPackages: 0, currentAccount: false
})
assert.equal(candidate.planCount, candidate.inboundCodeCount)
assert.equal(candidate.sourceContainerBookingId, '2093166483653849089')
})
test('incomplete containers remain visible but fail closed from server readiness facts', () => {
const candidate = normalizeReceivingCandidate({
sourceContainerBookingId: '2093166483653849099', containerNo: 'C10000001',
plannedArrivalAt: null, totalOrderCount: 15, inboundCodeCount: 12, planCount: 10,
missingInboundCodeCount: 3, plannedInboundCodeCount: 10, pendingPlanCount: 5, allPlansReady: false, operable: false,
blockReason: '存在3票缺少入仓码'
})
assert.equal(candidate.totalOrderCount, 15)
assert.equal(candidate.planCount, 10)
assert.equal(candidate.plannedInboundCodeCount, 10)
assert.equal(candidate.pendingPlanCount, 5)
assert.equal(candidate.missingInboundCodeCount, 3)
assert.equal(candidate.allPlansReady, false)
assert.equal(candidate.operable, false)
assert.equal(candidate.blockReason, '存在3票缺少入仓码')
const absentGate = normalizeReceivingCandidate({ containerNo: 'OLD-CONTRACT' })
assert.equal(absentGate.allPlansReady, false)
assert.equal(absentGate.operable, false)
})
test('current recovery retains all independent-plan inbound facts while progress remains container-level', () => {
@@ -53,7 +79,7 @@ test('container UI locks and signs once across independent plans, then renders o
read('../src/api/inbound.js')
])
assert.match(operation, /item\.inboundCodeCount[\s\S]*item\.totalPackages[\s\S]*item\.plannedPalletCount[\s\S]*item\.destinationCodes/)
assert.match(operation, /item\.totalOrderCount[\s\S]*item\.inboundCodeCount[\s\S]*item\.planCount[\s\S]*item\.pendingPlanCount/)
assert.match(summary, /inboundCodeCount[\s\S]*plannedPalletCount[\s\S]*totalPackages/)
assert.doesNotMatch(operation, /v-for="[^"]*inbound[^"]*"/)
assert.match(operation, /v-if="activeInbound"[\s\S]*activeInbound\.inboundCode[\s\S]*activeInbound\.pendingPackages/)
@@ -70,10 +96,19 @@ test('container UI locks and signs once across independent plans, then renders o
})
test('PDA does not infer container eligibility from partial plan workflow states', async () => {
const operation = await read('../src/pages/operation/operation.vue')
const [operation, adapter, inbound] = await Promise.all([
read('../src/pages/operation/operation.vue'), read('../src/api/receiving-adapter.js'), read('../src/api/inbound.js')
])
for (const internalStatus of ['PENDING_ASSIGNMENT', 'INBOUND_PLANNED', 'PDA_PENDING', 'ASSIGNED', 'PICKING']) {
assert.doesNotMatch(operation, new RegExp(internalStatus))
}
assert.match(operation, /listReceivingCandidates\(\)/)
assert.match(operation, /statusLabel\(status\)[\s\S]*WAIT_SIGN[\s\S]*SIGNED[\s\S]*RECEIVING[\s\S]*COMPLETED/)
assert.match(adapter, /allPlansReady: value\.allPlansReady === true/)
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, /preserveEvidence \? error : null[\s\S]*this\.candidateError = preserveEvidence \? error : null/)
assert.doesNotMatch(inbound, /sort\(/)
})
+6 -3
View File
@@ -66,7 +66,7 @@ test('login has no committed demo credentials and inbound operation implements t
for (const role of ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN']) assert.match(adapter, new RegExp(role))
for (const forbidden of ['customerId', 'planNo', 'allocationId', 'warehouseId', 'locationId']) assert.doesNotMatch(inbound, new RegExp(forbidden))
assert.match(operation, /isReceivingConflict|recoverCurrentAfterConflict/)
assert.match(operation, /isNoCurrentReceiving\(recoveryError\)[\s\S]*该柜已被其他账号选择,列表已刷新/)
assert.match(operation, /isNoCurrentReceiving\(recoveryError\)[\s\S]*柜状态已变化,列表已刷新/)
assert.match(operation, /onShow\(\)[\s\S]*this\.phase === 'candidates'[\s\S]*this\.bootstrap\(\)/)
assert.match(operation, /candidateLoading[\s\S]*candidateError/)
assert.match(operation, /candidateError\.requestId[\s\S]*candidateError\.traceId/)
@@ -112,13 +112,16 @@ test('receiving phases use a compiled container summary with cabinet and aggrega
test('candidate containers use compact card navigation and a small refresh action', async () => {
const operation = await read('../src/pages/operation/operation.vue')
assert.match(operation, /class="container-card container-card-compact"[\s\S]*@tap="openContainer\(item\.containerNo\)"/)
assert.match(operation, /class="container-card container-card-compact"[\s\S]*container-card-disabled[\s\S]*@tap="openContainer\(item\)"/)
assert.match(operation, /class="container-detail-link"/)
assert.doesNotMatch(operation, /<button class="primary-button"[^>]*openContainer\(item\.containerNo\)/)
assert.match(operation, /class="list-toolbar"><text>共 \{\{ candidates\.length \}\} 柜<\/text>/)
assert.match(operation, /\.list-toolbar button\{[^}]*height:28px[^}]*min-height:28px/)
assert.match(operation, /class="candidate-facts"[\s\S]*入仓码[\s\S]*计划托盘[\s\S]*目的仓/)
assert.match(operation, /class="candidate-facts"[\s\S]*总订单[\s\S]*入仓码[\s\S]*已生成计划[\s\S]*未操作/)
assert.match(operation, /class="candidate-destinations"/)
assert.match(operation, /计划到仓:\{\{ formatPlannedArrival\(item\.plannedArrivalAt\) \}\}/)
assert.match(operation, /item\.blockReason \|\| '当前柜入库计划尚未齐全'/)
assert.doesNotMatch(operation, /预计到港/)
assert.doesNotMatch(operation, /item\.receivedPackages[\s\S]*item\.totalPackages[\s\S]*progress-fill/)
})