6 Commits
9 changed files with 140 additions and 31 deletions
+1 -1
View File
@@ -42,7 +42,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`,合托明确为现场可选而非完成门禁;扫码实收确认计划生成阶段已预留的真实托盘和库位,校验卡展示预留库位编码及 `A0` 共享溢出位提示,不在设备端重新分配。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/warehouse-transfer/warehouse-transfer.vue`、`src/api/warehouse-transfers.js` | 工作台键 `warehouseTransfer`;候选→详情→逐托测量草稿→生成新托盘号→扫码绑定→照片与完成打托。API 模块路径 `/pda/v1/last-leg/warehouse-transfers` 经统一海外请求层处理;不是 Admin 转运审核或排车转派送。回归 `tests/warehouse-transfer-api.test.mjs` |
| 托盘管理真实作业 | `src/pages/pallet/pallet.vue`、`src/api/pallet.js` | 扫码查询托盘内容,按服务端返回的托盘/库存版本执行组托、拆托、换托和解绑;公网前缀 `/api/pda/v1/last-leg/pallet-operations/**` |
+2 -1
View File
@@ -1,7 +1,8 @@
export const MODULES = [
{ key: 'inbound', name: '入仓作业', nameKey: 'modules.inbound.name', icon: '入', description: '处理拆柜收货与客户退货实收入仓', descriptionKey: 'modules.inbound.description', operations: [
{ key: 'receiving', name: '拆柜收货', nameKey: 'modules.receiving.name', icon: '卸', iconType: 'download', description: '三类照片签收,箱码定位入仓码后按目的类型收货', descriptionKey: 'modules.receiving.description', available: true },
{ key: 'warehouseTransfer', name: '仓储转运测量', nameKey: 'modules.warehouseTransfer.name', icon: '转', iconType: 'compose', description: '回填箱规、箱数并完成托盘打托', descriptionKey: 'modules.warehouseTransfer.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer' },
{ key: 'warehouseTransferList', name: '仓储转运测量列表', nameKey: 'modules.warehouseTransferList.name', icon: '列', iconType: 'list', description: '查看待测量的仓储转运单', descriptionKey: 'modules.warehouseTransferList.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer?mode=list' },
{ key: 'warehouseTransferWork', name: '转运测量作业', nameKey: 'modules.warehouseTransferWork.name', icon: '转', iconType: 'compose', description: '进入转运单详情完成测量、打托和拍照', descriptionKey: 'modules.warehouseTransferWork.description', available: true, route: '/pages/warehouse-transfer/warehouse-transfer?mode=work' },
{ key: 'returnReceipt', name: '退货入库', nameKey: 'modules.returnReceipt.name', icon: '退', iconType: 'undo', description: '扫描退货单或 SKU,按本包实收数量生成退货库存', descriptionKey: 'modules.returnReceipt.description', available: true, route: '/pages/return-receipt/return-receipt' },
{ key: 'returnPacking', name: '退货合箱', nameKey: 'modules.returnPacking.name', icon: '合', iconType: 'folder-add', description: '选择同客户已签收退货单,按 SKU 数量生成新箱', descriptionKey: 'modules.returnPacking.description', available: true, route: '/pages/return-packing/return-packing' }
] },
+28 -10
View File
@@ -3,14 +3,14 @@
<PortalHeader :title="title" :user="user" fallback-url="/pages/module/module?key=relabel" />
<scroll-view class="work-scroll" scroll-y><view class="portal-content work-content">
<view class="work-heading"><text class="work-title">{{ title }}</text><text class="work-subtitle">{{ stageSubtitle }}</text></view>
<view v-if="stage === 'execute' && showCurrent" class="detail-toolbar"><view><text class="detail-kicker">当前任务</text><text class="detail-title">换标执行详情</text></view><button :disabled="busy" @tap="closeCurrent">‹ 返回列表</button></view>
<view v-if="showCurrent" class="detail-toolbar"><view><text class="detail-kicker">当前任务</text><text class="detail-title">{{ stage === 'review' ? '换标复核详情' : '换标执行详情' }}</text></view><button :disabled="busy" @tap="closeCurrent">‹ 返回列表</button></view>
<template v-if="stage !== 'execute' || !showCurrent">
<template v-if="!showCurrent">
<view class="row-head"><text class="card-title">{{ candidateHeading }}</text><button v-if="stage !== 'review'" class="link-button" :disabled="loading" @tap="bootstrap">{{ loading ? '刷新中…' : '刷新' }}</button></view>
<view v-if="loading && !visibleCandidates.length" class="empty-box"><uni-load-more status="loading" /><text class="empty-copy">正在加载换标任务…</text></view>
<view v-else-if="!visibleCandidates.length" class="empty-box"><uni-icons type="info" color="#8090a3" size="32" /><text class="empty-copy">{{ emptyText }}</text></view>
<view v-else-if="stage !== 'review'" class="task-list"><view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card" :class="{ current: current?.relabelNo === item.relabelNo }" @tap="openExecution(item)"><view class="row-head"><view><text class="business-code">{{ item.relabelNo }}</text><text class="meta-line">客户订单:{{ item.customerOrderNo || '—' }}</text><text class="meta-line">换标类型:{{ relabelTypeName(item.operationType) }}</text></view><text class="status-pill">{{ statusLabel(item.taskStatus || item.applicationStatus) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ item.expectedPackages }}</strong><text>应处理</text></view><view class="metric"><strong>{{ item.relabelledPackages }}</strong><text>已换标</text></view><view class="metric"><strong>{{ item.skippedPackages + item.exceptionPackages }}</strong><text>跳过/异常</text></view><text class="link-button">查看并执行 ›</text></view></view></view>
<view v-else class="task-list"><view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card"><view class="row-head"><view><text class="business-code">{{ item.relabelNo }}</text><text class="meta-line">客户订单:{{ item.customerOrderNo || '—' }}</text><text class="meta-line">换标类型:{{ relabelTypeName(item.operationType) }}</text></view><text class="status-pill">{{ statusLabel(item.taskStatus || item.applicationStatus) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ item.expectedPackages }}</strong><text>应处理</text></view><view class="metric"><strong>{{ item.relabelledPackages }}</strong><text>已换标</text></view><view class="metric"><strong>{{ item.skippedPackages + item.exceptionPackages }}</strong><text>跳过/异常</text></view></view></view></view>
<view v-else-if="stage !== 'review'" class="task-list"><view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card" :class="{ current: current?.relabelNo === item.relabelNo, 'rework-card': item.reviewDecision === 'REWORK' }" @tap="openExecution(item)"><view v-if="item.reviewDecision === 'REWORK'" class="rework-banner"><text class="rework-tag">退回重做</text><text class="rework-priority">优先处理</text></view><view v-if="item.reviewDecision === 'REWORK' && item.reviewRemark" class="rework-remark">退回备注:{{ item.reviewRemark }}</view><view class="row-head"><view><text class="business-code">{{ item.relabelNo }}</text><text class="meta-line">客户订单:{{ item.customerOrderNo || '—' }}</text><text class="meta-line">换标类型:{{ relabelTypeName(item.operationType) }}</text></view><text class="status-pill">{{ statusLabel(item.taskStatus || item.applicationStatus) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ item.expectedPackages }}</strong><text>应处理</text></view><view class="metric"><strong>{{ item.relabelledPackages }}</strong><text>已换标</text></view><view class="metric"><strong>{{ item.skippedPackages + item.exceptionPackages }}</strong><text>跳过/异常</text></view><text class="link-button">{{ item.reviewDecision === 'REWORK' ? '优先重做' : '查看并执行' }} ›</text></view></view></view>
<view v-else class="task-list"><view v-for="item in visibleCandidates" :key="item.relabelNo" class="task-card" @tap="openReview(item)"><view class="row-head"><view><text class="business-code">{{ item.relabelNo }}</text><text class="meta-line">客户订单:{{ item.customerOrderNo || '—' }}</text><text class="meta-line">换标类型:{{ relabelTypeName(item.operationType) }}</text></view><text class="status-pill">{{ statusLabel(item.taskStatus || item.applicationStatus) }}</text></view><view class="metric-row"><view class="metric"><strong>{{ item.expectedPackages }}</strong><text>应处理</text></view><view class="metric"><strong>{{ item.relabelledPackages }}</strong><text>已换标</text></view><view class="metric"><strong>{{ item.skippedPackages + item.exceptionPackages }}</strong><text>跳过/异常</text></view><text class="link-button">查看并复核 ›</text></view></view></view>
</template>
<view v-if="showCurrent" class="work-card">
@@ -67,6 +67,9 @@
</view>
</template>
</template>
<template v-else-if="stage === 'review'">
<view class="section-gap"><text class="card-title">待客户确认</text><text class="meta-line">现场换标已经完成。请客户在客户工作台核对完工结果并确认;需要退回重做时由平台处理。</text></view>
</template>
</view>
@@ -110,8 +113,8 @@
}
return [...groups.values()].map(group => ({ ...group, remaining: Math.max(0, group.expectedPackages - group.relabelledPackages - group.skippedPackages - group.exceptionPackages) }))
},
showCurrent() { if (!this.current || this.stage === 'review') return false; const owned = this.candidates.some((item) => item.relabelNo === this.current.relabelNo && item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator === true); if (this.stage === 'execute') return this.current.taskStatus === 'PROCESSING' && owned; return !this.current.taskStatus || this.current.taskStatus === 'AVAILABLE' || (this.current.taskStatus === 'PROCESSING' && owned) },
stageSubtitle() { return this.stage === 'review' ? '仅展示当前仓库已完成现场换标并等待复核的单据' : this.stage === 'execute' ? '仅展示当前账号已认领且正在处理的换标任务' : '点击任意换标单直接进入执行;同一账号必须先完成当前任务才能处理其他换标单' },
showCurrent() { if (!this.current) return false; if (this.stage === 'review') return this.current.taskStatus === 'WAIT_REVIEW'; const owned = this.candidates.some((item) => item.relabelNo === this.current.relabelNo && item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator === true); if (this.stage === 'execute') return this.current.taskStatus === 'PROCESSING' && owned; return !this.current.taskStatus || this.current.taskStatus === 'AVAILABLE' || (this.current.taskStatus === 'PROCESSING' && owned) },
stageSubtitle() { return this.stage === 'review' ? '仅展示当前仓库已完成现场换标并等待客户或平台处理的单据' : this.stage === 'execute' ? '仅展示当前账号已认领且正在处理的换标任务' : '退回重做单置顶优先处理;同一账号一次只能执行一票' },
availableTabs() { const items = this.current?.items || []; return [{ key: 'sku', name: 'SKU 换标', types: ['SKU_LABEL', 'CARTON_AND_SKU'] }, { key: 'carton', name: '箱唛换标', types: ['CARTON_LABEL', 'CARTON_AND_SKU'] }].filter((tab) => items.some((item) => tab.types.includes(item.operationType))) },
unitDraft() { return this.unitDrafts?.[this.selectedItem?.itemId] || { cartonMark: '', fbaCartonMark: '', skuVerifications: [] } },
skuVerified() { if (!this.selectedItem || this.selectedItem.operationType === 'CARTON_LABEL') return true; const expected = this.skuDetails(this.selectedItem); return expected.length > 0 && expected.every((sku, index) => { const actual = this.unitDraft.skuVerifications[index]; return actual && actual.skuCode.trim() === sku.skuCode && actual.newSkuCode.trim() === sku.newSkuCode && Number(actual.quantity) > 0 && Number(actual.quantity) === Number(sku.quantity) }) },
@@ -119,7 +122,7 @@
title() { return ({ receive: '换标列表', execute: '换标执行', review: '换标复核' })[this.stage] },
candidateHeading() { return this.stage === 'review' ? '待复核任务' : this.stage === 'execute' ? '处理中任务' : '换标任务' },
emptyText() { return `暂无${this.candidateHeading}` },
visibleCandidates() { const candidates = this.candidates; if (this.stage === 'review') return candidates.filter((item) => item.taskStatus === 'WAIT_REVIEW'); if (this.stage === 'execute') return candidates.filter((item) => item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator); return candidates.filter((item) => !item.taskStatus || item.taskStatus === 'AVAILABLE' || (item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator)) },
visibleCandidates() { const candidates = this.candidates; if (this.stage === 'review') return candidates.filter((item) => item.taskStatus === 'WAIT_REVIEW'); if (this.stage === 'execute') return candidates.filter((item) => item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator); return candidates.filter((item) => !item.taskStatus || item.taskStatus === 'AVAILABLE' || (item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator)).sort((left, right) => Number(right.reviewDecision === 'REWORK') - Number(left.reviewDecision === 'REWORK') || String(right.reviewedAt || '').localeCompare(String(left.reviewedAt || ''))) },
visibleItems() { return (this.current?.items || []).filter((item) => (this.taskTab === 'sku' ? ['SKU_LABEL', 'CARTON_AND_SKU'].includes(item.operationType) : ['CARTON_LABEL', 'CARTON_AND_SKU'].includes(item.operationType)) && this.remaining(item) > 0) },
pendingSkuRows() { return this.visibleItems.flatMap(item => this.skuDetails(item).map((sku, index) => ({ item, sku, index })).filter(row => !this.skuDone(row.item.itemId, row.index))) },
requiresNewCartonLabel() { return this.action === 'RELABEL' && this.selectedItem && this.selectedItem.operationType !== 'SKU_LABEL' },
@@ -289,6 +292,18 @@
uni.redirectTo({ url: `/pages/relabel/relabel?op=relabelExecute&task=${encodeURIComponent(active.relabelNo)}` })
} finally { this.busy = false }
},
async confirmStartTask(item) {
if (this.busy) return false
this.busy = true
try {
return await new Promise((resolve) => uni.showModal({
title: '开始贴标作业',
content: `是否开始贴标作业?\n贴标申请:${item.relabelNo}`,
confirmText: '是', cancelText: '否',
success: (result) => resolve(result.confirm), fail: () => resolve(false)
}))
} finally { this.busy = false }
},
async openExecution(item) {
if (this.busy || this.stage === 'review') return
if (this.stage === 'execute') {
@@ -297,12 +312,15 @@
}
const active = this.candidates.find((candidate) => candidate.taskStatus === 'PROCESSING' && candidate.claimedByCurrentOperator === true)
if (active && active.relabelNo !== item.relabelNo) return this.promptActiveTask(active)
this.taskCode = item.relabelNo
if (item.taskStatus === 'PROCESSING' && item.claimedByCurrentOperator) return uni.redirectTo({ url: `/pages/relabel/relabel?op=relabelExecute&task=${encodeURIComponent(item.relabelNo)}` })
const confirmed = await this.confirmStartTask(item)
if (!confirmed) return
this.taskCode = item.relabelNo
const payload = { relabelNo: item.relabelNo }; const key = warehouseIdempotencyKey('relabel', 'claim', payload); let claimed = false
await this.run('claim', async () => { this.applyCurrent(await claimRelabelTask(item.relabelNo, { idempotencyKey: key.value })); releaseWarehouseKey('relabel', key.signature); claimed = true }, true, key)
if (claimed) uni.redirectTo({ url: `/pages/relabel/relabel?op=relabelExecute&task=${encodeURIComponent(item.relabelNo)}` })
},
async openReview(item) { if (this.busy || this.stage !== 'review' || item.taskStatus !== 'WAIT_REVIEW') return; await this.openTask(item.relabelNo) },
async submitUnit() { if (!this.canSubmitUnit || this.busy) return; const base = { itemId: this.selectedItem.itemId, oldLabel: this.oldLabel.trim(), expectedVersion: this.current.version }; const payload = { relabelNo: this.current.relabelNo, action: this.action, ...base, newLabel: this.newLabel.trim(), skuVerifications: this.unitDraft.skuVerifications.map((sku) => ({ ...sku, skuCode: sku.skuCode.trim(), newSkuCode: sku.newSkuCode.trim() })), reason: this.reason.trim(), exceptionCode: this.exceptionCode }; const key = warehouseIdempotencyKey('relabel', 'unit', payload); await this.run('unit', async () => { let value; if (this.action === 'SKIP') value = await skipRelabelUnit(this.current.relabelNo, { ...base, reason: this.reason.trim(), idempotencyKey: key.value }); else if (this.action === 'EXCEPTION') value = await reportRelabelException(this.current.relabelNo, { ...base, exceptionCode: this.exceptionCode, reason: this.reason.trim(), idempotencyKey: key.value }); else value = await relabelUnit(this.current.relabelNo, { ...base, newLabel: this.newLabel.trim(), skuVerifications: payload.skuVerifications, idempotencyKey: key.value }); releaseWarehouseKey('relabel', key.signature); this.applyCurrent(value); this.oldLabel = ''; this.newLabel = ''; this.reason = ''; this.activeTarget = 'old'; this.successMessage = '本箱处理结果已保存' }, true, key) },
choosePhoto() { if (this.busy || !this.current || this.photoFileIds.length >= 20) return; const no = this.current.relabelNo; uni.chooseImage({ count: Math.min(9, 20 - this.photoFileIds.length), sourceType: ['camera', 'album'], success: async ({ tempFilePaths, tempFiles }) => { if (this.current?.relabelNo !== no) return; await this.run('photo', async () => { for (const [index, path] of (tempFilePaths || []).entries()) { const selected = tempFiles?.[index]?.file || tempFiles?.[index]; if (Number(tempFiles?.[index]?.size || 0) > 10 * 1024 * 1024) throw new Error('照片不能超过 10MB'); const uploadSource = typeof Blob !== 'undefined' && selected instanceof Blob ? selected : path; const file = await uploadRelabelPhoto(no, uploadSource); this.photoUrls[String(file.fileId)] = path; updateWarehouseWork('relabel', { photoTaskNo: no, photoFileIds: [...this.photoFileIds, String(file.fileId)], photoNames: [...this.photoNames, file.fileName || '换标现场照片'] }) } this.successMessage = '现场照片已上传' }, false) } }) },
async uploadPhoto(path) { await this.run('photo', async () => { const file = await uploadRelabelPhoto(this.current.relabelNo, path); updateWarehouseWork('relabel', { photoFileIds: [...this.photoFileIds, String(file.fileId)], photoNames: [...this.photoNames, file.fileName || '换标现场照片'] }); this.successMessage = '现场照片已上传' }, false) },
@@ -314,12 +332,12 @@
async reloadCurrent() { if (this.current?.relabelNo) this.applyCurrent(await getRelabelTask(this.current.relabelNo)) },
async run(action, work, refreshOnConflict = false, key = null) { if (this.busy) return; this.busy = true; this.busyAction = action; this.error = null; this.successMessage = ''; try { await work(); if (!['detail'].includes(action)) playSuccessSound() } catch (error) { this.setError(error, `${this.title}失败`); if (refreshOnConflict && isConflict(error)) { if (key) releaseWarehouseKey('relabel', key.signature); try { await this.reloadCurrent(); this.error.message += ',已刷新任务版本,请重新确认' } catch (_) {} } } finally { this.busy = false; this.busyAction = '' } },
setError(error, fallback = '换标作业失败') { this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }; playErrorSound() },
statusLabel(value) { return ({ PENDING_PROCESSING: '待认领', PROCESSING: '处理中', WAIT_REVIEW: '待复核', COMPLETED: '已完成' })[value] || value || '待认领' },
statusLabel(value) { return ({ AVAILABLE: '待认领', PENDING_PROCESSING: '待认领', PROCESSING: '处理中', WAIT_REVIEW: '待客户/平台处理', COMPLETED: '已完成' })[value] || value || '待认领' },
formatTime(value) { return value ? String(value).replace('T', ' ').slice(0, 16) : '—' }
}
}
</script>
<style scoped lang="scss">@import '../warehouse/operations.scss';.action-row>.danger,.action-row>.primary{width:50%}.item-card.current{border-color:#6ea3ee;background:#f3f8ff}.detail-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:4px 0 10px;padding:10px 12px;border:1px solid #bed4f6;border-radius:11px;background:#eef5ff}.detail-kicker,.detail-title{display:block}.detail-kicker{color:#6580a4;font-size:9px}.detail-title{margin-top:2px;color:#153d74;font-size:14px;font-weight:800}.detail-toolbar button{flex-shrink:0;min-height:34px;margin:0;padding:0 11px;border-radius:8px;color:#135ee8;font-size:10px;line-height:34px;background:#fff}.tab-row{display:flex;gap:10px;margin:14px 0 10px}.tab-button{flex:1;border:1px solid #d7e1ef;border-radius:10px;background:#fff;color:#52647c;font-size:15px;padding:10px 8px}.tab-button.active{border-color:#2167e8;background:#2167e8;color:#fff;font-weight:600}
<style scoped lang="scss">@import '../warehouse/operations.scss';.action-row>.danger,.action-row>.primary{width:50%}.item-card.current{border-color:#6ea3ee;background:#f3f8ff}.task-card.rework-card{border:2px solid #f29b38;background:#fffaf2;box-shadow:0 5px 16px rgba(221,116,20,.13)}.rework-banner{display:flex;align-items:center;gap:8px;margin-bottom:9px}.rework-tag,.rework-priority{display:inline-flex;padding:4px 9px;border-radius:999px;font-size:11px;font-weight:800}.rework-tag{background:#d94343;color:#fff}.rework-priority{background:#fff0d7;color:#b55a0b}.rework-remark{margin-bottom:11px;padding:9px 10px;border-left:3px solid #e2761b;border-radius:6px;background:#fff3e4;color:#8e4308;font-size:12px;line-height:1.55}.detail-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:4px 0 10px;padding:10px 12px;border:1px solid #bed4f6;border-radius:11px;background:#eef5ff}.detail-kicker,.detail-title{display:block}.detail-kicker{color:#6580a4;font-size:9px}.detail-title{margin-top:2px;color:#153d74;font-size:14px;font-weight:800}.detail-toolbar button{flex-shrink:0;min-height:34px;margin:0;padding:0 11px;border-radius:8px;color:#135ee8;font-size:10px;line-height:34px;background:#fff}.tab-row{display:flex;gap:10px;margin:14px 0 10px}.tab-button{flex:1;border:1px solid #d7e1ef;border-radius:10px;background:#fff;color:#52647c;font-size:15px;padding:10px 8px}.tab-button.active{border-color:#2167e8;background:#2167e8;color:#fff;font-weight:600}
.photo-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:12px}.photo-tile{min-width:0;overflow:hidden;border:1px solid #d7e1ef;border-radius:8px}.photo-thumbnail{display:block;width:100%;height:140px;background:#f1f4f8}.photo-placeholder{margin:0;border:0;border-radius:0;font-size:13px;white-space:normal;line-height:1.5;display:flex;align-items:center;justify-content:center}.photo-name{display:block;padding:6px 8px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:12px;color:#52647c}.photo-actions{display:flex;justify-content:space-around;padding:4px}.photo-actions .link-button{margin:0}.bulk-total-button{margin-top:14px}.bulk-quantity{box-sizing:border-box;width:100%;padding:12px;border:1px solid #d7e1ef;border-radius:8px;background:#fff}.source-allocation{margin-top:14px;padding:12px;border-radius:10px;background:#f6f8fb}.source-title{display:block;font-weight:700;color:#24364b;margin-bottom:8px}.source-row,.source-total{display:flex;justify-content:space-between;gap:12px;padding:8px 0;color:#52647c}.source-row+ .source-row{border-top:1px solid #e4eaf2}.source-row text{min-width:0;overflow-wrap:anywhere}.source-row strong,.source-total strong{white-space:nowrap;color:#1f5fca}.source-total{border-top:1px solid #d7e1ef;font-weight:700}
</style>
@@ -1,24 +1,28 @@
<template>
<view v-if="user" class="page-shell portal-page transfer-page">
<PortalHeader title="仓储转运测量" :user="user" fallback-url="/pages/module/module?key=inbound" />
<PortalHeader :title="entryMode === 'list' ? '仓储转运测量列表' : '转运测量作业'" :user="user" fallback-url="/pages/module/module?key=inbound" />
<scroll-view scroll-y class="transfer-scroll"><view class="portal-content transfer-content">
<view class="transfer-heading">
<view><text class="transfer-title">托盘测量</text><text class="transfer-subtitle">按实际托盘分别记录箱规、重量和箱数</text></view>
<view><text class="transfer-title">{{ entryMode === 'list' ? '仓储转运测量列表' : '转运测量作业' }}</text><text class="transfer-subtitle">{{ entryMode === 'list' ? '选择待测转运单后进入作业详情' : '按实际托盘分别记录箱规、重量和箱数' }}</text></view>
<button class="refresh-button" :disabled="loading" aria-label="刷新待测转运单" @tap="loadCandidates"><uni-icons type="refresh" color="#135ee8" size="18" /><text>{{ loading ? '加载中' : '刷新' }}</text></button>
</view>
<template v-if="!selected && entryMode === 'list'">
<view class="section-heading"><text>待测转运单</text><text>{{ candidates.length }} 单</text></view>
<view v-if="!candidates.length && !loading" class="transfer-empty"><text class="empty-title">暂无待测量任务</text><text>新任务进入后可点击右上角刷新</text></view>
<view class="candidate-list">
<button v-for="item in candidates" :key="item.transferNo" class="transfer-card" :class="{ selected: selected?.transferNo === item.transferNo }" @tap="select(item)">
<view class="transfer-card-head"><text class="transfer-no">{{ item.transferNo }}</text><text class="status-pill">{{ statusLabel(item.status) }}</text></view>
<view class="transfer-card-head"><text class="transfer-no">{{ item.transferNo }}</text><text class="status-pill">{{ statusLabel(item.status, item.transferNo) }}</text></view>
<view class="task-metrics"><view><text>计划箱数</text><strong>{{ item.packageCount || 0 }}</strong></view><view><text>{{ item.destinationType === 'PRIVATE' ? '目的仓' : 'FBA 目的仓' }}</text><strong>{{ destinationLabel(item) }}</strong></view><view><text>换标</text><strong>{{ item.needRelabel ? '需要' : '不需要' }}</strong></view></view>
<text class="transfer-meta">入仓码 {{ item.inboundCode || '—' }}</text>
</button>
</view>
</template>
<view v-if="!selected && entryMode === 'work' && !detailLoading" class="transfer-empty"><text class="empty-title">暂无进行中的转运测量作业</text><text>请先从“仓储转运测量列表”选择待测转运单。</text></view>
<view v-if="detailLoading" class="transfer-empty"><text class="empty-title">正在读取转运明细</text><text>请稍候,不要重复选择</text></view>
<template v-if="selected && !detailLoading">
<view class="operation-detail-heading"><button class="back-to-list" @tap="backToCandidates">‹ 返回待测列表</button><view><text class="operation-detail-title">作业详情</text><text class="operation-detail-subtitle">进入后完成当前转运单的测量、打托和拍照</text></view></view>
<view v-if="isInventoryTransfer" class="transfer-success inventory-transfer-panel"><text class="feedback-title">库存转运 · 无需测量</text><text>该单来源于仓库现有库存,不需要重复测量或上传现场照片。请进入托盘作业,按来源托盘执行整托转运、拆托重打,或与相同目的仓合托。</text><button class="transfer-primary" @tap="openPalletOperations">进入托盘作业</button></view>
<template v-if="!isInventoryTransfer">
<view class="order-summary">
@@ -81,6 +85,7 @@
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { session, requireSession } from '@/stores/session.js'
import { restoreWarehouseWork, resetWarehouseDomain, updateWarehouseWork, warehouseWork } from '@/stores/warehouse-work.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { bindWarehouseTransferPallet, deleteWarehouseTransferPallet, downloadWarehouseTransferMeasurementPhoto, downloadWarehouseTransferPalletLabel, generateWarehouseTransferPallet, getWarehouseTransferDetail, listWarehouseTransferCandidates, listWarehouseTransferMeasurementPhotos, palletizeWarehouseTransfer, saveWarehouseTransferPalletDraft, uploadWarehouseTransferMeasurementPhoto } from '@/api/warehouse-transfers.js'
@@ -96,7 +101,7 @@ const restoredPallet = (value, index) => ({
export default {
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, candidates: [], selected: null, pallets: [newPallet()], activePalletKey: '', photoFileIds: [], photoUrls: {}, photoNames: [], completedPallets: [], completedTransferNo: '', completedDestination: '', completedDestinationType: '', loading: false, detailLoading: false, submitting: false, uploadingPhoto: false, openingLabel: false, error: null, success: false, pendingKey: '' } },
data() { return { user: session.currentUser, entryMode: 'work', pendingTaskNo: '', candidates: [], selected: null, pallets: [newPallet()], activePalletKey: '', photoFileIds: [], photoNames: [], photoUrls: {}, completedPallets: [], completedTransferNo: '', completedDestination: '', completedDestinationType: '', loading: false, detailLoading: false, submitting: false, uploadingPhoto: false, openingLabel: false, error: null, success: false, pendingKey: '' } },
computed: {
completedDestinationLabel() { return this.destinationLabel({ fbaWarehouseCode: this.completedDestination, destinationType: this.completedDestinationType }) },
totalCartons() { return this.pallets.reduce((sum, pallet) => sum + Math.max(0, Number(pallet.cartonCount) || 0), 0) },
@@ -121,7 +126,8 @@ export default {
watch: {
pallets: { deep: true, handler() { if (!this.submitting) this.pendingKey = '' } }
},
onShow() { if (requireSession()) { this.user = session.currentUser; this.startScanner(); this.loadCandidates() } },
onShow() { if (requireSession()) { this.user = session.currentUser; restoreWarehouseWork(`${this.user.id}:${this.user.warehouseId}`); this.startScanner(); this.loadCandidates().then(() => { const activeNo = String(warehouseWork.warehouseTransfer.currentNo || ''); const taskNo = this.pendingTaskNo || activeNo; if (this.entryMode === 'work' && taskNo) { const active = this.candidates.find((item) => String(item.transferNo) === taskNo); if (active) { this.pendingTaskNo = ''; void this.select(active) } } }) } },
onLoad(options) { this.entryMode = options?.mode === 'list' ? 'list' : 'work'; this.pendingTaskNo = String(options?.task || '') },
onHide() { this.stopScanner() },
beforeUnmount() { this.stopScanner() },
methods: {
@@ -130,7 +136,7 @@ export default {
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handlePalletScan(value), onError: (error) => { this.error = error } }) },
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
handlePalletScan(value) { const pallet = this.pallets.find((item) => item.key === this.activePalletKey && item.generated && !item.bound); if (!pallet) return; pallet.bindCode = String(value || '').trim(); this.bindPallet(pallet) },
statusLabel(status) { return ({ WAIT_PDA_RECEIVING: '待测量', APPROVED: '待测量', PARTIALLY_RECEIVED: '测量中' })[status] || '待测量' },
statusLabel(status, transferNo = '') { if (String(status) === 'WAIT_PDA_RECEIVING' && String(warehouseWork.warehouseTransfer.currentNo) === String(transferNo)) return '测量中'; return ({ WAIT_PDA_RECEIVING: '待测量', APPROVED: '待测量', PARTIALLY_RECEIVED: '测量中' })[status] || '待测量' },
rawPalletVolume(pallet) { const values = [pallet.lengthCm, pallet.widthCm, pallet.heightCm].map(Number); return values.every((value) => value > 0) ? values[0] * values[1] * values[2] / 1000000 : 0 },
palletVolume(pallet) { return this.rawPalletVolume(pallet).toFixed(3) },
palletReady(pallet) { return Number.isInteger(Number(pallet.cartonCount)) && [pallet.cartonCount, pallet.lengthCm, pallet.widthCm, pallet.heightCm, pallet.grossWeightKg].every((value) => Number(value) > 0) },
@@ -157,14 +163,29 @@ export default {
catch (error) { this.error = error; pallet.busy = false }
},
async printDraftPallet(pallet) { if (!pallet.labelDownloadPath) { this.error = new Error('服务端未返回托盘标签下载地址'); return }; await this.openLabelPdf(() => downloadWarehouseTransferPalletLabel(pallet.labelDownloadPath)) },
async loadCandidates() { this.loading = true; this.error = null; try { this.candidates = await listWarehouseTransferCandidates() } catch (e) { this.error = e } finally { this.loading = false } },
async loadCandidates() { this.loading = true; this.error = null; try { const activeNo = String(warehouseWork.warehouseTransfer.currentNo || ''); this.candidates = (await listWarehouseTransferCandidates()).map((item) => String(item.transferNo) === activeNo && item.status === 'WAIT_PDA_RECEIVING' ? { ...item, status: 'PARTIALLY_RECEIVED' } : item) } catch (e) { this.error = e } finally { this.loading = false } },
async select(item) {
if (this.detailLoading || this.submitting) return
const requestedNo = String(item?.transferNo || '')
const activeNo = String(warehouseWork.warehouseTransfer.currentNo || '')
if (!activeNo) {
const result = await new Promise((resolve) => uni.showModal({ title: '开始测量', content: `确认开始测量转运单 ${requestedNo} 吗?开始后本设备需先完成该单,才能选择下一单。`, confirmText: '确认开始', cancelText: '取消', success: resolve, fail: () => resolve({ confirm: false }) }))
if (!result.confirm) return
}
if (activeNo && activeNo !== requestedNo) {
const result = await new Promise((resolve) => uni.showModal({ title: '存在未完结的测量单', content: `转运单 ${activeNo} 尚未测量完成,是否继续进入?`, confirmText: '去继续测量', cancelText: '取消', success: resolve, fail: () => resolve({ confirm: false }) }))
if (!result.confirm) return
item = this.candidates.find((candidate) => String(candidate.transferNo) === activeNo) || item
}
updateWarehouseWork('warehouseTransfer', { currentNo: String(item.transferNo) })
this.candidates = this.candidates.map((candidate) => String(candidate.transferNo) === String(item.transferNo) ? { ...candidate, status: 'PARTIALLY_RECEIVED' } : candidate)
if (this.entryMode === 'list') { uni.navigateTo({ url: `/pages/warehouse-transfer/warehouse-transfer?mode=work&task=${encodeURIComponent(String(item.transferNo))}` }); return }
this.detailLoading = true; this.error = null; this.success = false; this.completedPallets = []; this.completedTransferNo = ''; this.completedDestination = ''; this.completedDestinationType = ''
try { this.selected = await getWarehouseTransferDetail(item.transferNo); this.pendingKey = ''; this.pallets = this.selected.pallets?.length ? this.selected.pallets.map(restoredPallet) : [newPallet()]; await this.restorePhotos(item.transferNo, this.selected.measurementPhotos) }
catch (e) { this.selected = null; this.error = e }
finally { this.detailLoading = false }
},
backToCandidates() { if (this.submitting || this.detailLoading) return; this.entryMode = 'list'; this.selected = null; this.error = null; this.pallets = [newPallet()]; this.photoFileIds = []; this.photoUrls = {}; this.photoNames = [] },
addPallet() { this.pendingKey = ''; this.pallets.push(newPallet()) },
async removePallet(index) { const pallet = this.pallets[index]; if (!pallet || pallet.generated || pallet.busy) return; pallet.busy = true; try { if (pallet.draftId) await deleteWarehouseTransferPallet(this.selected.transferNo, pallet.draftId); this.pallets.splice(index, 1); this.pendingKey = '' } catch (error) { pallet.busy = false; this.error = error } },
async restorePhotos(transferNo, knownPhotos) {
@@ -203,7 +224,7 @@ export default {
try {
if (!this.pendingKey) this.pendingKey = `pda-transfer-${this.selected.transferNo}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
const payload = { idempotencyKey: this.pendingKey, photoFileIds: [...this.photoFileIds] }
const transferNo = this.selected.transferNo; const result = await palletizeWarehouseTransfer(transferNo, payload); this.completedTransferNo = transferNo; this.completedDestination = result?.fbaWarehouseCode || this.selected.fbaWarehouseCode || ''; this.completedDestinationType = result?.destinationType || this.selected.destinationType || ''; this.completedPallets = (result?.pallets || []).filter((pallet) => pallet?.palletNo); this.success = true; this.selected = null; this.pendingKey = ''; this.pallets = [newPallet()]; this.photoFileIds = []; this.photoUrls = {}; this.photoNames = []; await this.loadCandidates()
const transferNo = this.selected.transferNo; const result = await palletizeWarehouseTransfer(transferNo, payload); this.completedTransferNo = transferNo; this.completedDestination = result?.fbaWarehouseCode || this.selected.fbaWarehouseCode || ''; this.completedDestinationType = result?.destinationType || this.selected.destinationType || ''; this.completedPallets = (result?.pallets || []).filter((pallet) => pallet?.palletNo); this.success = true; resetWarehouseDomain('warehouseTransfer'); this.selected = null; this.pendingKey = ''; this.pallets = [newPallet()]; this.photoFileIds = []; this.photoUrls = {}; this.photoNames = []; await this.loadCandidates()
} catch (e) { this.error = e } finally { this.submitting = false }
},
async printPallet(pallet) { if (!pallet?.labelDownloadPath) { this.error = new Error('服务端未返回托盘标签下载地址,请刷新后重试'); return }; await this.openLabelPdf(() => downloadWarehouseTransferPalletLabel(pallet.labelDownloadPath)) },
@@ -241,6 +262,7 @@ export default {
.take-photo-button[disabled]{color:#9aa7b5;background:#e7ecf2;opacity:1}
.print-card{box-sizing:border-box;margin-bottom:11px;padding:14px;border:1px solid #b9d3f4;border-radius:13px;background:#f8fbff}.print-row button{width:auto;margin:0;padding:0 10px;color:#135ee8;border:1px solid #9fc0ef;border-radius:8px;background:#fff}.print-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-top:1px solid #dce8f5;color:#728196;font-size:9px}.print-code{display:block;color:#17314f;font-size:13px;font-weight:850}
.generate-button{width:100%;margin-top:12px;color:#fff;background:#135ee8}.generated-card{display:flex;align-items:center;justify-content:space-between;margin-top:12px;padding:10px;border-radius:9px;background:#edf5ff}.generated-card button,.bind-panel>button{width:auto;margin:0;color:#135ee8;background:#fff}.bind-panel{margin-top:10px}.bound-state{margin-top:10px;padding:9px;border-radius:8px;color:#08705f;font-weight:800;background:#dff5ed}
.operation-detail-heading{display:flex;align-items:flex-start;gap:10px;margin:0 2px 12px;padding:2px 0}.back-to-list{width:auto;height:30px;margin:0;padding:0 9px;border:1px solid #c8d8ec;border-radius:8px;color:#135ee8;font-size:10px;line-height:30px;background:#f8fbff}.operation-detail-heading>view{min-width:0}.operation-detail-title{display:block;color:#243951;font-size:15px;font-weight:850}.operation-detail-subtitle{display:block;margin-top:3px;color:#8794a4;font-size:9px;line-height:14px}
.collapse-button{width:30px;height:28px;margin:0;padding:0;color:#135ee8;font-size:16px;line-height:28px;background:#edf5ff}
.receipt-comparison{display:grid;grid-template-columns:repeat(3,1fr);gap:7px;margin-bottom:9px}.receipt-comparison view{padding:9px;border-radius:9px;text-align:center;background:#fff}.receipt-comparison strong,.receipt-comparison text{display:block}.receipt-comparison strong{color:#17314f;font-size:15px}.receipt-comparison text{margin-top:2px;color:#8390a1;font-size:8px}.receipt-comparison .difference{border:1px solid #e3a044;background:#fff4df}.receipt-comparison .difference strong,.receipt-comparison .difference text{color:#a35b0c;font-weight:800}
</style>
+2
View File
@@ -8,6 +8,7 @@ const blankState = () => ({
relabel: { currentNo: '', oldLabel: '', newLabel: '', action: 'RELABEL', reason: '', remark: '', photoFileIds: [], photoNames: [], pendingKeys: {} },
returnReceipt: { entryMode: 'SKU', lookupCode: '', currentReturnNo: '', startedBy: 'SKU', trackingNo: '', palletCode: '', quantities: {}, pendingKeys: {} },
returnPacking: { searchCode: '', selectedOrders: [], quantities: {}, length: '', width: '', height: '', weight: '', pendingKeys: {} },
warehouseTransfer: { currentNo: '', pendingKeys: {} },
query: { inventoryCode: '', locationCode: '', palletCode: '' }
})
@@ -39,6 +40,7 @@ export function restoreWarehouseWork(scopeKey) {
stocktake: domain(saved.stocktake, fresh.stocktake), move: domain(saved.move, fresh.move),
relabel: domain(saved.relabel, fresh.relabel), returnReceipt: domain(saved.returnReceipt, fresh.returnReceipt),
returnPacking: domain(saved.returnPacking, fresh.returnPacking),
warehouseTransfer: domain(saved.warehouseTransfer, fresh.warehouseTransfer),
query: { ...fresh.query, ...(saved.query || {}) }
})
return true
+27
View File
@@ -30,3 +30,30 @@ test('cancel stays on list and preserves existing task selection', async () => {
assert.equal(context.taskCode, 'PREVIOUS')
assert.equal(context.busy, false)
})
test('new relabel task asks whether to start before it can be claimed', async () => {
const calls = []
const uni = { showModal(options) { calls.push(options); options.success({ confirm: false }) } }
const component = new Function('PortalHeader', 'RelabelPalletWork', 'ScanTargetInput', 'uni', body)({}, {}, {}, uni)
const context = { busy: false }
const confirmed = await component.methods.confirmStartTask.call(context, { relabelNo: 'RL-NEW' })
assert.equal(confirmed, false)
assert.equal(calls[0].title, '开始贴标作业')
assert.match(calls[0].content, /是否开始贴标作业/)
assert.match(calls[0].content, /RL-NEW/)
assert.equal(calls[0].confirmText, '是')
assert.equal(calls[0].cancelText, '否')
assert.equal(context.busy, false)
})
test('start confirmation gates task selection and claim', () => {
const start = source.indexOf('const confirmed = await this.confirmStartTask(item)')
const selection = source.indexOf('this.taskCode = item.relabelNo', start)
const claim = source.indexOf("warehouseIdempotencyKey('relabel', 'claim', payload)", start)
assert.ok(start >= 0)
assert.ok(selection > start)
assert.ok(claim > selection)
assert.match(source.slice(start, selection), /if \(!confirmed\) return/)
})
+26 -2
View File
@@ -19,17 +19,41 @@ test('warehouse transfer PDA API exposes candidates and palletize operations', a
test('warehouse transfer workbench entry is registered', async () => {
const { MODULES } = await import('../src/data/overseas-workbench.js')
const operation = MODULES.flatMap((m) => m.operations).find((item) => item.key === 'warehouseTransfer')
const operation = MODULES.flatMap((m) => m.operations).find((item) => item.key === 'warehouseTransferList')
assert.equal(operation?.available, true)
assert.equal(operation?.route, '/pages/warehouse-transfer/warehouse-transfer')
assert.equal(operation?.route, '/pages/warehouse-transfer/warehouse-transfer?mode=list')
const work = MODULES.flatMap((m) => m.operations).find((item) => item.key === 'warehouseTransferWork')
assert.equal(work?.route, '/pages/warehouse-transfer/warehouse-transfer?mode=work')
})
test('warehouse transfer page loads detail without exposing internal identifiers', async () => {
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
assert.match(page, /getWarehouseTransferDetail\(item\.transferNo\)/)
assert.match(page, /待测转运单/)
assert.match(page, /仓储转运测量列表/)
assert.match(page, /转运测量作业/)
assert.match(page, /!selected && entryMode === 'list'/)
assert.match(page, /entryMode === 'work' && taskNo/)
assert.match(page, /mode=work&task=\$\{encodeURIComponent\(String\(item\.transferNo\)\)\}/)
assert.match(page, /v-if="!selected && entryMode === 'list'"/)
assert.match(page, /作业详情/)
assert.match(page, /backToCandidates\(\)/)
assert.match(page, /backToCandidates\(\) \{ if \(this\.submitting.*this\.entryMode = 'list'/)
assert.match(page, /存在未完结的测量单/)
assert.match(page, /title: '开始测量'/)
assert.match(page, /确认开始测量转运单/)
assert.match(page, /warehouseWork\.warehouseTransfer\.currentNo/)
assert.match(page, /resetWarehouseDomain\('warehouseTransfer'\)/)
assert.match(page, /statusLabel\(status, transferNo = ''\) \{ if \(String\(status\) === 'WAIT_PDA_RECEIVING'/)
assert.match(page, /listWarehouseTransferCandidates\(\)\)\.map\(\(item\) => String\(item\.transferNo\) === activeNo.*PARTIALLY_RECEIVED/s)
assert.doesNotMatch(page, /转运明细 ID|manualItems/)
})
test('warehouse transfer removes completed work from the pending list', async () => {
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
assert.match(page, /this\.selected = null; this\.pendingKey = '';[\s\S]*await this\.loadCandidates\(\)/)
})
test('warehouse transfer shows forecast and actual variance without blocking short or over receipt', async () => {
const page = await readFile(new URL('../src/pages/warehouse-transfer/warehouse-transfer.vue', import.meta.url), 'utf8')
for (const label of ['客户预报', '当前实收', '短收差异', '超收差异']) assert.match(page, new RegExp(label))
+21 -7
View File
@@ -102,32 +102,36 @@ test('mixed relabel keeps SKU work open and return bulk also mounts pallet work'
assert.match(page, /<RelabelPalletWork v-if="taskTab === 'carton' \|\| isReturnBulk"/)
})
test('relabel list opens a row directly in execution and completion returns to the list', async () => {
test('relabel list confirms before starting a new task and completion returns to the list', async () => {
const page = await read('../src/pages/relabel/relabel.vue')
assert.doesNotMatch(page, /name="relabelTask"/)
assert.match(page, /@tap="openExecution\(item\)"/)
assert.match(page, /async openExecution\(item\)/)
assert.match(page, /const confirmed = await this\.confirmStartTask\(item\)[\s\S]*if \(!confirmed\) return[\s\S]*claimRelabelTask/)
assert.match(page, /relabelExecute&task=\$\{encodeURIComponent\(item\.relabelNo\)\}/)
assert.match(page, /当前账号正在执行 \$\{active\.relabelNo\}/)
assert.match(page, /v-if="stage === 'execute' && showCurrent" class="detail-toolbar"[\s\S]*@tap="closeCurrent">\u2039 返回列表<\/button>/)
assert.match(page, /v-if="showCurrent" class="detail-toolbar"[\s\S]*@tap="closeCurrent">\u2039 返回列表<\/button>/)
assert.match(page, /closeCurrent\(\) \{ this\.current = null;[\s\S]*this\.taskCode = '' \}/)
assert.match(page, /updateWarehouseWork\('relabel', \{ currentNo: '', photoFileIds: \[\], photoNames: \[\], remark: '' \}\)/)
assert.match(page, /if \(completed\) this\.goToClaimList\(\)/)
assert.match(page, /goToClaimList\(\) \{ this\.taskCode = ''; uni\.redirectTo\(\{ url: '\/pages\/relabel\/relabel\?op=relabelReceive' \}\) \}/)
})
test('relabel review is a read-only list and completed execution details stay hidden', async () => {
test('relabel review opens a pending task as customer-confirmation read-only detail', async () => {
const page = await read('../src/pages/relabel/relabel.vue')
const body = page.match(/showCurrent\(\) \{([^\n]+)\},/)[1]
const visible = new Function(body)
const context = { stage: 'execute', entryTaskNo: '', current: { relabelNo: 'RL-1', taskStatus: 'PROCESSING' }, candidates: [{ relabelNo: 'RL-1', taskStatus: 'PROCESSING', claimedByCurrentOperator: true }] }
assert.equal(visible.call(context), true, 'detail without ownership field must use server candidate ownership')
assert.equal(visible.call({ ...context, candidates: [] }), false)
assert.equal(visible.call({ ...context, stage: 'review' }), false)
assert.equal(visible.call({ ...context, stage: 'review', current: { ...context.current, taskStatus: 'WAIT_REVIEW' } }), true)
assert.equal(visible.call({ ...context, current: { ...context.current, taskStatus: 'WAIT_REVIEW' } }), false)
assert.match(page, /stageSubtitle\(\) \{ return this\.stage === 'review' \? '仅展示当前仓库已完成现场换标并等待复核的单据'/)
const reviewList = page.slice(page.indexOf('<view v-else class="task-list">'), page.indexOf('<view v-if="showCurrent"'))
assert.doesNotMatch(reviewList, /@tap|详情/)
assert.match(page, /stageSubtitle\(\) \{ return this\.stage === 'review' \? '仅展示当前仓库已完成现场换标并等待客户或平台处理的单据'/)
assert.match(page, /<view v-else class="task-list">[\s\S]*@tap="openReview\(item\)"/)
assert.match(page, /查看并复核/)
assert.match(page, /async openReview\(item\)/)
assert.match(page, /现场换标已经完成。请客户在客户工作台核对完工结果并确认/)
assert.doesNotMatch(page, /@tap="review\('APPROVE'\)"/)
assert.match(page, /<button v-if="stage !== 'review'" class="link-button"[^>]*@tap="bootstrap"/)
assert.match(page, /if \(this\.stage === 'execute'\) this\.startScanner\(\)/)
const bootstrapBody = page.match(/async bootstrap\(\) \{([^\n]+)\},/)[1]
@@ -139,6 +143,16 @@ test('relabel review is a read-only list and completed execution details stay hi
assert.deepEqual(opened, ['RL-1'], 'workbench execution entry must stay on the processing-task list')
})
test('returned relabel tasks are highlighted, sorted first, and still obey the single active task lock', async () => {
const page = await read('../src/pages/relabel/relabel.vue')
assert.match(page, /item\.reviewDecision === 'REWORK'[\s\S]*退回重做[\s\S]*优先处理/)
assert.match(page, /退回备注:\{\{ item\.reviewRemark \}\}/)
assert.match(page, /\.sort\(\(left, right\) => Number\(right\.reviewDecision === 'REWORK'\) - Number\(left\.reviewDecision === 'REWORK'\)/)
assert.match(page, /const active = this\.candidates\.find\(\(candidate\) => candidate\.taskStatus === 'PROCESSING' && candidate\.claimedByCurrentOperator === true\)/)
assert.match(page, /if \(active && active\.relabelNo !== item\.relabelNo\) return this\.promptActiveTask\(active\)/)
assert.match(page, /AVAILABLE: '待认领'/)
})
test('warehouse drafts are isolated by operator and warehouse and retain retry keys until success', () => {
const values = new Map()
globalThis.uni = {
+3 -2
View File
@@ -7,7 +7,7 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
const enabled = operations.filter((operation) => operation.available)
assert.deepEqual(enabled.map(({ module, name }) => ({ module, name })), [
{ module: 'inbound', name: '拆柜收货' },
{ module: 'inbound', name: '仓储转运测量' },
{ module: 'inbound', name: '仓储转运测量列表' }, { module: 'inbound', name: '转运测量作业' },
{ module: 'inbound', name: '退货入库' },
{ module: 'inbound', name: '退货合箱' },
{ module: 'outbound', name: '排车拣货' },
@@ -26,7 +26,8 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
assert.equal(operations.find((item) => item.name === '出库作业')?.route, '/pages/outbound/outbound')
assert.equal(operations.find((item) => item.name === '退货入库')?.route, '/pages/return-receipt/return-receipt')
assert.equal(operations.find((item) => item.name === '退货合箱')?.route, '/pages/return-packing/return-packing')
assert.equal(operations.find((item) => item.name === '仓储转运测量')?.route, '/pages/warehouse-transfer/warehouse-transfer')
assert.equal(operations.find((item) => item.name === '仓储转运测量列表')?.route, '/pages/warehouse-transfer/warehouse-transfer?mode=list')
assert.equal(operations.find((item) => item.name === '转运测量作业')?.route, '/pages/warehouse-transfer/warehouse-transfer?mode=work')
assert.equal(operations.some((item) => item.name === '库存盘点'), false)