Release/test #1

Merged
admin merged 15 commits from release/test into dev 2026-09-17 11:53:38 +08:00
6 changed files with 62 additions and 5 deletions
Showing only changes of commit bcad98d5e3 - Show all commits
+1 -1
View File
@@ -40,7 +40,7 @@
| 登录、刷新与会话 | `src/pages/login/login.vue`、`src/api/auth.js`、`src/stores/session.js` | 国内 Auth 签发 Token;API 根见 `src/config/runtime.js` |
| 海外仓工作台与模块入口 | `src/pages/home/home.vue`、`src/pages/module/module.vue` | `src/data/overseas-workbench.js` 是模块与作业可用性入口;首页通过 `/api/pda/v1/last-leg/receiving-containers/warehouse-profile` 读取 Token 绑定仓的中文主档名称 |
| 中英西语言设置 | `src/i18n/index.js`、`src/i18n/locales/`、`src/pages/profile/profile.vue` | 个人中心可选择并持久化 `zh-CN`、`en`、`es`;当前业务翻译资源仍为空,页面业务文案继续使用中文,不得把语言选择入口误报为全量翻译完成 |
| 柜号签收、照片与入仓真实作业 | `src/pages/operation/operation.vue`、`src/components/ReceivingSessionSummary.vue`、`src/api/inbound.js`、`src/api/receiving-adapter.js`、`src/api/overseas.js`、`src/stores/receiving.js` | 三个固定照片角色各一张,支持拍照/相册、鉴权预览及更换失败保留原图;多计划同柜直接展示服务端 `assigned/mergeAllowed/urgent/mergeHint`,合托明确为现场可选而非完成门禁;扫码实收确认计划生成阶段已预留的真实托盘和库位,不在设备端重新分配。Backend `lastleg-service/inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,公网前缀 `/api/pda/v1/last-leg/receiving-containers/**` |
| 柜号签收、照片与入仓真实作业 | `src/pages/operation/operation.vue`、`src/components/ReceivingSessionSummary.vue`、`src/api/inbound.js`、`src/api/receiving-adapter.js`、`src/api/overseas.js`、`src/stores/receiving.js` | 签收前展开显示国内空仓、半仓、满仓、锁仓和带锁码五类整柜只读照片,收货中折叠查看,单图加载失败只允许重试且不阻断拆柜;海外三个固定照片角色各一张,支持现场拍照、鉴权预览及更换失败保留原图;多计划同柜直接展示服务端 `assigned/mergeAllowed/urgent/mergeHint`,合托明确为现场可选而非完成门禁;扫码实收确认计划生成阶段已预留的真实托盘和库位,不在设备端重新分配。Backend `lastleg-service/inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,公网前缀 `/api/pda/v1/last-leg/receiving-containers/**` |
| 退货入库签收与合箱 | `src/pages/return-receipt/return-receipt.vue`、`src/pages/return-packing/return-packing.vue`、`src/api/return-receipt.js`、`src/api/return-packing.js`、`src/stores/warehouse-work.js` | 签收优先扫描退货单号,也可扫描 SKU 后选择候选退货单,按本包实际 SKU 数量生成退货箱与计费批次;合箱只选择同客户已完成退货单的可用库存并录入新箱箱规;公网前缀 `/api/pda/v1/last-leg/return-receipts/**`、`/api/pda/v1/last-leg/return-inventory/**` |
| 托盘管理真实作业 | `src/pages/pallet/pallet.vue`、`src/api/pallet.js` | 扫码查询托盘内容,按服务端返回的托盘/库存版本执行组托、拆托、换托和解绑;公网前缀 `/api/pda/v1/last-leg/pallet-operations/**` |
| 库存盘点(入口隐藏) | `src/pages/stocktake/stocktake.vue`、`src/api/warehouse-operations.js`、`src/stores/warehouse-work.js` | 底层创建、恢复、计数与提交实现仍保留;当前 `src/data/overseas-workbench.js` 不注册库存盘点,不向现场开放入口 |
+1
View File
@@ -18,6 +18,7 @@ export const getReceivingContainer = async (containerNo) => normalizeReceivingSe
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)
export const downloadCurrentUnloadPhoto = (downloadPath) => overseasDownload(downloadPath)
export const downloadCurrentDomesticLoadingPhoto = (downloadPath) => overseasDownload(downloadPath)
export const signCurrentReceiving = async (request) => normalizeReceivingSession(await overseasRequest('POST', current('/sign'), request))
export const getCurrentReceiving = async () => normalizeReceivingSession(await overseasRequest('GET', current()))
export const resolveCurrentCarton = async (cartonMark) => normalizeReceivingInbound(await overseasRequest('POST', current('/cartons/resolve'), { cartonMark }))
+5
View File
@@ -87,11 +87,16 @@ export function normalizeReceivingSession(value) {
const photos = Array.isArray(value.photos)
? value.photos.map((photo) => ({ ...photo, fileId: String(photo.fileId) }))
: []
const domesticLoadingPhotos = Array.isArray(value.domesticLoadingPhotos)
? value.domesticLoadingPhotos.filter((photo) => photo?.evidenceType && photo?.downloadPath)
.map((photo) => ({ ...photo, status: 'idle', localPath: '' }))
: []
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),
domesticLoadingPhotos,
totalPackages: number(value.totalPackages),
receivedPackages: number(value.receivedPackages),
pendingPackages: number(value.pendingPackages),
+29 -4
View File
@@ -43,6 +43,16 @@
<template v-else-if="phase === 'sign'">
<ReceivingSessionSummary :session="current" />
<view class="photo-card domestic-photo-card">
<view class="card-head"><view><text class="card-title">国内装柜照片(只读)</text><text class="container-meta">现场对照国内装柜过程与封柜状态</text></view><text class="status-tag status-good">{{ domesticPhotoCount }} / 5</text></view>
<view class="domestic-photo-grid">
<view v-for="photo in domesticLoadingPhotos" :key="photo.evidenceType" class="domestic-photo-item" @tap="previewDomesticPhoto(photo)">
<image v-if="photo.localPath" class="domestic-photo-thumb" :src="photo.localPath" mode="aspectFill" />
<view v-else class="domestic-photo-placeholder"><uni-load-more v-if="photo.status === 'loading'" status="loading" :show-text="false" /><uni-icons v-else type="image" color="#8090a3" size="22" /><text>{{ photo.status === 'error' ? '加载失败,点按重试' : '正在准备照片' }}</text></view>
<text>{{ photo.label }}</text>
</view>
</view>
</view>
<view class="photo-card">
<view class="card-head"><view><text class="card-title">卸货签收照片</text><text class="container-meta">三个固定槽位各拍摄 1 张现场照片</text></view><text class="status-tag" :class="photosReady ? 'status-good' : 'status-wait'">{{ uploadedPhotoCount }} / 3</text></view>
<view class="photo-grid">
@@ -65,6 +75,16 @@
<template v-else-if="phase === 'receive'">
<ReceivingSessionSummary :session="current" />
<view class="photo-card domestic-photo-card domestic-photo-collapsible">
<view class="card-head" @tap="domesticPhotosExpanded = !domesticPhotosExpanded"><view><text class="card-title">国内装柜照片</text><text class="container-meta">已同步 {{ domesticPhotoCount }} / 5,点按{{ domesticPhotosExpanded ? '收起' : '展开' }}对照</text></view><text class="container-detail-link">{{ domesticPhotosExpanded ? '收起' : '查看' }} ›</text></view>
<view v-if="domesticPhotosExpanded" class="domestic-photo-grid">
<view v-for="photo in domesticLoadingPhotos" :key="photo.evidenceType" class="domestic-photo-item" @tap.stop="previewDomesticPhoto(photo)">
<image v-if="photo.localPath" class="domestic-photo-thumb" :src="photo.localPath" mode="aspectFill" />
<view v-else class="domestic-photo-placeholder"><uni-load-more v-if="photo.status === 'loading'" status="loading" :show-text="false" /><uni-icons v-else type="image" color="#8090a3" size="22" /><text>{{ photo.status === 'error' ? '加载失败,点按重试' : '正在准备照片' }}</text></view>
<text>{{ photo.label }}</text>
</view>
</view>
</view>
<view class="scan-card">
<text class="section-label">1. 扫描 DP、箱码或 FBA 任一箱</text>
<ScanTargetInput name="carton" v-model="cartonInput" label="货物码" placeholder="扫描 DP 或箱码" :active="activeTarget === 'carton'" @activate="activeTarget = 'carton'" @submit="resolveCarton" />
@@ -122,16 +142,17 @@
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, getCurrentWarehouseProfile, getReceivingContainer, listReceivingCandidates, prepareReceivingContainer, receiveCurrentBulk, receiveCurrentCarton, receiveCurrentWholePallet, resolveCurrentCarton, resolveCurrentPallet, signCurrentReceiving, uploadCurrentUnloadPhoto } from '@/api/inbound.js'
import { completeCurrentReceiving, downloadCurrentDomesticLoadingPhoto, downloadCurrentUnloadPhoto, getCurrentReceiving, getCurrentWarehouseProfile, getReceivingContainer, listReceivingCandidates, prepareReceivingContainer, receiveCurrentBulk, receiveCurrentCarton, receiveCurrentWholePallet, resolveCurrentCarton, resolveCurrentPallet, signCurrentReceiving, uploadCurrentUnloadPhoto } from '@/api/inbound.js'
const MAX_UNLOAD_PHOTO_BYTES = 10 * 1024 * 1024
export default {
components: { PortalHeader, ReceivingSessionSummary, ScanTargetInput },
data() { return { user: session.currentUser, moduleKey: 'query', operationName: '库存查询', loading: false, candidateLoading: false, busy: false, bootstrapped: false, detailOpen: false, cartonInput: '', palletInput: '', packageCount: '', candidateError: null, error: null, scanQueue: [], toast: { visible: false, message: '', type: '' } } },
data() { return { user: session.currentUser, moduleKey: 'query', operationName: '库存查询', loading: false, candidateLoading: false, busy: false, bootstrapped: false, detailOpen: false, domesticPhotosExpanded: false, cartonInput: '', palletInput: '', packageCount: '', candidateError: null, error: null, scanQueue: [], 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] })) },
candidates() { return receiving.candidates }, selected() { return receiving.selected }, current() { return receiving.current }, photoSlots() { return receiving.photoSlots },
domesticLoadingPhotos() { return this.current?.domesticLoadingPhotos || [] }, domesticPhotoCount() { return this.domesticLoadingPhotos.length },
resolvedCarton() { return receiving.resolvedCarton }, resolvedPallet() { return receiving.resolvedPallet }, lastReceipt() { return receiving.lastReceipt },
activeTarget: { get() { return receiving.activeScanTarget }, set(value) { receiving.activeScanTarget = value } },
phase() { return this.detailOpen && !this.current ? 'detail' : receivingPhase(this.current) }, progress() { return receivingProgress(this.current) }, activeInbound() { return this.resolvedCarton },
@@ -162,7 +183,10 @@
async run(action, work) { this.busy = true; this.error = null; try { const result = await work(); this.showToast(`${action}成功`, 'success'); return result } catch (error) { const mappedError = toReceivingError(error, `${action}失败`); this.error = mappedError; playErrorSound(); this.showToast(mappedError.message, 'error'); throw mappedError } finally { this.busy = false; setTimeout(() => this.drainScanQueue(), 0) } },
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) { this.validateReceivingWarehouse(value, '当前接柜会话'); setCurrentReceiving(value); await this.restorePhotoPreviews(); this.setScanTargetForCurrent(value) },
async restoreDomesticPhotoPreviews() { await Promise.all(this.domesticLoadingPhotos.filter((photo) => photo.downloadPath && !photo.localPath).map((photo) => this.loadDomesticPhoto(photo))) },
async loadDomesticPhoto(photo) { if (!photo?.downloadPath || photo.status === 'loading') return; photo.status = 'loading'; try { photo.localPath = await downloadCurrentDomesticLoadingPhoto(photo.downloadPath); photo.status = 'success' } catch (_) { photo.status = 'error' } },
async previewDomesticPhoto(photo) { if (!photo?.localPath) await this.loadDomesticPhoto(photo); if (photo?.localPath) { const urls = this.domesticLoadingPhotos.map((item) => item.localPath).filter(Boolean); uni.previewImage({ urls, current: photo.localPath }) } },
async applyCurrentReceiving(value) { this.validateReceivingWarehouse(value, '当前接柜会话'); setCurrentReceiving(value); await Promise.all([this.restorePhotoPreviews(), this.restoreDomesticPhotoPreviews()]); 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, 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, refreshWarehouse = true) { if (this.candidateLoading) return; this.candidateLoading = true; this.candidateError = null; this.error = null; try { if (refreshWarehouse) 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 } },
@@ -203,6 +227,7 @@
.photo-error{margin-top:3px;overflow:hidden;color:#c9362b;font-size:9px;line-height:13px;text-overflow:ellipsis;white-space:nowrap}
.photo-actions{display:grid;grid-template-columns:1fr}
.photo-actions button{box-sizing:border-box;min-height:44px;padding:0 5px;border:1px solid #8fb8f5;border-radius:8px;color:#1668dc;font-size:11px;font-weight:700;line-height:44px;background:#fff}
.domestic-photo-card{border-color:#b9d3f4;background:#f9fbff}.domestic-photo-collapsible .card-head{cursor:pointer}.domestic-photo-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:7px;margin-top:11px}.domestic-photo-item{display:flex;min-width:0;align-items:center;flex-direction:column;gap:5px;color:#315777;font-size:10px;font-weight:700}.domestic-photo-thumb,.domestic-photo-placeholder{box-sizing:border-box;width:100%;height:66px;border:1px solid #cddbeb;border-radius:8px;background:#edf4fb}.domestic-photo-placeholder{display:flex;align-items:center;flex-direction:column;justify-content:center;gap:2px;padding:3px;color:#75869a;font-size:8px;line-height:11px;text-align:center}.domestic-photo-thumb{display:block}
.matched-inbound-card{margin:14px 0;padding:12px;border:1px solid #cfe0f2;border-radius:10px;background:#f8fbff}
.matched-inbound-card .mode-banner{margin-bottom:10px}
.matched-inbound-code{display:block;color:#17324d;font-size:15px;font-weight:800}
@@ -210,7 +235,7 @@
.matched-inbound-progress{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;margin-top:8px;color:#5f7185;font-size:10px}
.matched-inbound-progress text{padding:6px 4px;border-radius:6px;text-align:center;background:#edf3f9}
.error-card text{overflow-wrap:anywhere;word-break:break-word}
@media(max-width:360px){.photo-role{grid-template-columns:68px minmax(0,1fr) 64px;gap:7px;padding:8px}.photo-preview{width:68px;height:64px}.photo-actions button{min-height:34px;padding:0 2px;line-height:34px}.photo-state{font-size:9px;line-height:13px}}
@media(max-width:360px){.photo-role{grid-template-columns:68px minmax(0,1fr) 64px;gap:7px;padding:8px}.photo-preview{width:68px;height:64px}.photo-actions button{min-height:34px;padding:0 2px;line-height:34px}.photo-state{font-size:9px;line-height:13px}.domestic-photo-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}
.receiving-page{padding-bottom:env(safe-area-inset-bottom,0px)}
.receiving-content{box-sizing:border-box;padding-bottom:calc(24px + env(safe-area-inset-bottom,0px))}
.receiving-content-with-action{padding-bottom:calc(152px + env(safe-area-inset-bottom,0px))}
+14
View File
@@ -71,6 +71,20 @@ test('receiving adapter preserves the three signed photo roles in workflow order
assert.equal(session.inbounds[0].plannedPalletCount, 2)
})
test('domestic loading photos remain cabinet-level read-only references without file IDs', () => {
const session = normalizeReceivingSession({ domesticLoadingPhotos: [
{ evidenceType: 'PHOTO_STEP_1', label: '空仓', downloadPath: '/current/domestic-loading-photos/PHOTO_STEP_1/content' },
{ evidenceType: 'SEAL', label: '带锁码', downloadPath: '/current/domestic-loading-photos/SEAL/content' },
{ evidenceType: '', label: 'invalid', downloadPath: '/invalid' }
] })
assert.equal(session.domesticLoadingPhotos.length, 2)
assert.deepEqual(session.domesticLoadingPhotos.map(({ evidenceType, status, localPath }) => ({ evidenceType, status, localPath })), [
{ evidenceType: 'PHOTO_STEP_1', status: 'idle', localPath: '' },
{ evidenceType: 'SEAL', status: 'idle', localPath: '' }
])
assert.equal('fileId' in session.domesticLoadingPhotos[0], false)
})
test('receiving adapter exposes calculable candidate facts and workflow errors', () => {
const candidate = normalizeReceivingCandidate({ containerNo: ' C10000000 ', status: 'WAIT_SIGN', planCount: 15, inboundCodeCount: 15, destinationCodes: ['ONT8', 'LAX'], plannedPalletCount: 4, currentAccount: true })
assert.equal(candidate.containerNo, 'C10000000')
@@ -78,6 +78,18 @@ test('local thumbnail, preview, and authenticated session restore are wired to t
assert.ok(photoUiBlockers().every((item) => item.deploymentBlocker === true && item.reason))
})
test('domestic cabinet photos are read-only, previewable and non-blocking in sign and receive phases', () => {
assert.match(page, /国内装柜照片(只读)/)
assert.match(page, /domesticPhotosExpanded/)
assert.match(page, /downloadCurrentDomesticLoadingPhoto\(photo\.downloadPath\)/)
assert.match(page, /async loadDomesticPhoto\(photo\)[\s\S]*catch \(_\) \{ photo\.status = 'error' \}/)
assert.match(page, /previewDomesticPhoto\(photo\)[\s\S]*uni\.previewImage/)
assert.match(inbound, /downloadCurrentDomesticLoadingPhoto/)
assert.match(controller, /current\/domestic-loading-photos\/\{evidenceType\}\/content/)
assert.match(openapi, /DomesticLoadingPhoto:/)
assert.doesNotMatch(page, /domesticLoadingPhotos[\s\S]{0,120}customer/)
})
test('fixed receiving action leaves enough scroll-safe space above the PDA bottom bar', () => {
assert.match(page, /v-if="canFinishOnsite" class="sticky-action one"/)
assert.match(page, /allInboundsReconciled \? '完成整柜入库' : '现场收货结束(标记差异)'/)