diff --git a/src/api/receiving-adapter.js b/src/api/receiving-adapter.js index c6874e9..0249dc8 100644 --- a/src/api/receiving-adapter.js +++ b/src/api/receiving-adapter.js @@ -15,6 +15,9 @@ export function normalizeReceivingInbound(value = {}) { export function normalizeReceivingCandidate(value = {}) { return { ...value, + containerNo: String(value.containerNo || '').trim(), + planCount: number(value.planCount), + inboundCodeCount: number(value.inboundCodeCount), destinationCodes: Array.isArray(value.destinationCodes) ? value.destinationCodes.filter(Boolean) : [], plannedPalletCount: number(value.plannedPalletCount), totalPackages: number(value.totalPackages), diff --git a/src/pages/operation/operation.vue b/src/pages/operation/operation.vue index 04360b8..8054548 100644 --- a/src/pages/operation/operation.vue +++ b/src/pages/operation/operation.vue @@ -13,8 +13,10 @@ - {{ candidates.length }} 个待卸货集装箱 - 暂无待卸货集装箱 + {{ candidates.length }} 个待卸货集装箱 + {{ candidateError.message || '待卸货集装箱加载失败' }}code={{ candidateError.code }}requestId={{ candidateError.requestId }}traceId={{ candidateError.traceId }} + 正在刷新待卸货集装箱… + 暂无待卸货集装箱 {{ item.containerNo }}{{ item.inboundCodeCount }} 个入仓码 · {{ item.planCount }} 个计划目的仓:{{ item.destinationCodes.join(' / ') }}{{ statusLabel(item.status) }} 收货进度{{ item.receivedPackages }} / {{ item.totalPackages }} 箱 @@ -111,7 +113,7 @@ export default { components: { PortalHeader, ReceivingSessionSummary, ScanTargetInput }, - data() { return { user: session.currentUser, moduleKey: 'query', operationName: '库存查询', loading: false, busy: false, bootstrapped: false, detailOpen: false, containerInput: '', cartonInput: '', palletInput: '', packageCount: 1, error: null, toast: { visible: false, message: '', type: '' } } }, + data() { return { user: session.currentUser, moduleKey: 'query', operationName: '库存查询', loading: false, candidateLoading: false, busy: false, bootstrapped: false, detailOpen: false, containerInput: '', cartonInput: '', palletInput: '', packageCount: 1, candidateError: null, error: null, toast: { visible: false, message: '', type: '' } } }, computed: { inboundMode() { return this.moduleKey === 'inbound' }, photoSteps() { return RECEIVING_PHOTO_ROLES.map((role) => ({ role, label: ({ LICENSE_PLATE: '车牌照片', CONTAINER_SEAL: '集装箱封签', DOOR_OPEN: '开柜现场' })[role] })) }, @@ -126,7 +128,7 @@ pageSubtitle() { return { candidates: '选择集装箱后拍摄 3 张照片完成签收', detail: '核对柜号与整柜计划箱数', sign: '完成现场取证后进入收货', receive: '扫描箱码定位入仓码后开始收货', completed: '库存与柜级进度已由服务端核平' }[this.phase] } }, onLoad(options) { this.moduleKey = options.module || 'query'; const key = options.op || (this.moduleKey === 'inbound' ? 'receiving' : 'inventoryQuery'); this.operationName = ({ receiving: '拆柜收货', stocktake: '库存盘点', move: '商品移库', pallet: '托盘管理', relabelReceive: '换标收货', relabelExecute: '换标执行', relabelReview: '换标复核', inventoryQuery: '库存查询', locationQuery: '库位查询', palletQuery: '托盘查询' })[key] || '库存查询' }, - onShow() { if (!requireSession()) return; this.user = session.currentUser; this.startScanner(); if (this.inboundMode && !this.bootstrapped) this.bootstrap() }, + onShow() { if (!requireSession()) return; this.user = session.currentUser; this.startScanner(); if (this.inboundMode && (!this.bootstrapped || this.phase === 'candidates')) this.bootstrap() }, onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { clearTimeout(this._toastTimer); this.stopScanner() }, methods: { statusLabel(status) { return ({ WAIT_SIGN: '待签收', SIGNED: '已签收', RECEIVING: '收货中', COMPLETED: '已完成' })[status] || status }, @@ -139,12 +141,12 @@ 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() { this.loading = true; this.error = null; this.detailOpen = false; try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.bootstrapped = true } catch (error) { if (isNoCurrentReceiving(error)) { resetReceivingDraft(); await this.loadCandidates(false); this.bootstrapped = true } else this.error = error } finally { this.loading = false } }, + 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) { this.error = recoveryError; return false } }, - async loadCandidates(feedback = true) { try { const work = () => listReceivingCandidates(); receiving.candidates = feedback ? await this.run('刷新待卸柜', work) : await work() } catch (_) {} }, + 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 ?? this.containerInput).trim(); if (!containerNo || this.busy) return; try { receiving.selected = await this.run('查询柜号', () => getReceivingContainer(containerNo)); this.containerInput = containerNo; this.detailOpen = true; playSuccessSound() } catch (error) { await this.recoverCurrentAfterConflict(error) } }, cancelDetail() { this.detailOpen = false; receiving.selected = null }, - async prepareContainer() { if (!this.selected?.containerNo) 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.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) } }, choosePhoto(index) { if (this.busy) return; uni.chooseImage({ count: 1, sourceType: ['camera'], success: ({ tempFilePaths }) => { const path = tempFilePaths?.[0]; 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; playErrorSound() } finally { this.busy = false } }, previewPhoto(index) { const path = this.photoSlots[index]?.localPath; if (!path) return; uni.previewImage({ urls: [path], current: path }) }, diff --git a/tests/project-contract.test.mjs b/tests/project-contract.test.mjs index d07c5f1..371e1b9 100644 --- a/tests/project-contract.test.mjs +++ b/tests/project-contract.test.mjs @@ -65,6 +65,13 @@ 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, /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/) + assert.match(operation, /@tap="loadCandidates"[^>]*>重新加载/) + assert.match(operation, /async loadCandidates\(feedback = true\)[\s\S]*if \(this\.candidateLoading\) return[\s\S]*this\.candidateError = error/) + assert.match(operation, /async prepareContainer\(\)[\s\S]*this\.busy\) return/) + assert.match(operation, /prepareReceivingContainer[\s\S]*releaseIdempotencyKey\(key\.signature\)/) assert.match(overseas, /replaceTokens|refreshOverseasToken/) assert.match(overseas, /requestId/) }) diff --git a/tests/receiving-adapter.test.mjs b/tests/receiving-adapter.test.mjs index 402ed42..54d4f9d 100644 --- a/tests/receiving-adapter.test.mjs +++ b/tests/receiving-adapter.test.mjs @@ -16,7 +16,11 @@ test('receiving adapter preserves the three signed photo roles in workflow order }) test('receiving adapter exposes calculable candidate facts and workflow errors', () => { - const candidate = normalizeReceivingCandidate({ destinationCodes: ['ONT8', 'LAX'], plannedPalletCount: 4, currentAccount: true }) + const candidate = normalizeReceivingCandidate({ containerNo: ' C10000000 ', status: 'WAIT_SIGN', planCount: 15, inboundCodeCount: 15, destinationCodes: ['ONT8', 'LAX'], plannedPalletCount: 4, currentAccount: true }) + assert.equal(candidate.containerNo, 'C10000000') + assert.equal(candidate.status, 'WAIT_SIGN') + assert.equal(candidate.planCount, 15) + assert.equal(candidate.inboundCodeCount, 15) assert.deepEqual(candidate.destinationCodes, ['ONT8', 'LAX']) assert.equal(candidate.plannedPalletCount, 4) assert.equal(candidate.currentAccount, true) diff --git a/tests/receiving-photo-ui-contract.test.mjs b/tests/receiving-photo-ui-contract.test.mjs index c87c25c..ad36e6e 100644 --- a/tests/receiving-photo-ui-contract.test.mjs +++ b/tests/receiving-photo-ui-contract.test.mjs @@ -65,8 +65,8 @@ test('camera replaces only one fixed slot and failed retake preserves its previo assert.match(page, /\.photo-grid\{display:grid;grid-template-columns:1fr/) assert.match(page, /grid-template-columns:82px minmax\(0,1fr\) 78px/) assert.match(page, /class="photo-role-info"/) - assert.match(page, /onShow\(\)[\s\S]*this\.inboundMode && !this\.bootstrapped[\s\S]*this\.bootstrap\(\)/) - assert.match(page, /await this\.applyCurrentReceiving\(current\); this\.bootstrapped = true/) + assert.match(page, /onShow\(\)[\s\S]*this\.inboundMode && \(!this\.bootstrapped \|\| this\.phase === 'candidates'\)[\s\S]*this\.bootstrap\(\)/) + assert.match(page, /await this\.applyCurrentReceiving\(current\);[\s\S]*this\.bootstrapped = true/) }) test('local thumbnail, preview, and authenticated session restore are wired to the frozen contract', () => {