This commit is contained in:
2026-09-24 08:55:38 +08:00
parent 50dbbcaa6e
commit bc9fa0faa3
8 changed files with 107 additions and 15 deletions
+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' }
] },
+15 -1
View File
@@ -292,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') {
@@ -300,8 +312,10 @@
}
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)}` })
@@ -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