Files
pda-uniapp/pages/dropship-palletizing/dropship-palletizing.vue
T

95 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view v-if="user" class="page-shell dropship-page">
<PortalHeader title="一件代发清点组托" :user="user" fallback-url="/pages/dropship-palletizing/tasks" />
<view v-if="!online" class="network-warning" role="status">当前网络不可用,不能扫码、确认、换托或封托;联网后请刷新。</view>
<scroll-view scroll-y class="work-scroll">
<view v-if="error" class="message-card error" role="alert">{{ error }}</view>
<view v-if="notice" class="message-card" role="status">{{ notice }}</view>
<view v-if="loading && !task" class="loading-card">正在加载真实任务...</view>
<template v-if="task">
<view class="task-card"><view><text class="minor">入仓码</text><text class="task-code">{{ task.inboundCode || '—' }}</text><text class="minor">{{ task.customerName || '客户 —' }} · {{ task.warehouseName || '仓库 —' }}</text></view><text class="badge">{{ statusLabel(task.taskStatus) }}</text></view>
<view class="summary-grid"><view><text>{{ task.countedCartons || 0 }}/{{ task.totalCartons || 0 }}</text><text>已清点</text></view><view><text>{{ task.unboundCartons || 0 }}</text><text>待绑托</text></view><view><text>{{ sealedPalletCount }}</text><text>已封托</text></view></view>
<view class="panel">
<text class="panel-title">扫描箱码或系统托盘码</text><text class="panel-copy">先扫托盘:后续箱确认后直接绑定;先扫多箱:后扫托盘一次批量绑定。</text>
<ScanTargetInput v-model="scanValue" name="dropshipCode" label="箱码或托盘码" placeholder="扫描箱码 / DP 托盘码" :active="true" @submit="handleManualScan" />
<view class="active-pallet"><text>当前托盘</text><text>{{ activePalletNo || '未选择(箱将进入待绑托区)' }}</text></view>
</view>
<view v-if="resolvedCarton" class="panel">
<view class="heading"><view><text class="panel-title">{{ resolvedCarton.boxCode }}</text><text class="minor">入仓码 {{ resolvedCarton.inboundCode || '—' }}</text></view><text class="badge">待确认</text></view>
<view class="sku-card"><view><text class="minor">SKU</text><text class="sku-code">{{ resolvedCarton.skuCode || '—' }}</text></view><view class="expected"><text class="minor">应收</text><text>{{ resolvedCarton.expectedSkuQuantity }}</text></view></view>
<text class="quantity-label">SKU 实收数量</text><view class="quantity-control"><button @tap="changeQuantity(-1)">−</button><input v-model="actualSkuQuantity" type="number" aria-label="SKU 实收数量" /><button @tap="changeQuantity(1)">+</button></view>
<view v-if="hasDifference" class="difference-box"><text>实收与应收不一致,请确认接受差异并填写处理结果。</text><label><checkbox :checked="acceptVariance" @tap="acceptVariance = !acceptVariance" />接受本次数量差异</label><textarea v-model="discrepancyRemark" maxlength="500" placeholder="填写差异原因及处理结果" /></view>
<button class="primary-button" :disabled="busy || !online || !canConfirmCarton" @tap="confirmCarton">确认本箱实收</button>
</view>
<view class="panel"><button class="primary-button" :disabled="busy || !online || task.taskStatus !== 'CLAIMED' || !unboundCartons.length" @tap="createPallet">新建托盘并绑定</button><view class="heading"><text class="panel-title">待绑托箱</text><text class="badge">{{ task.unboundCartons || 0 }}</text></view><view v-if="!unboundCartons.length" class="empty">暂无待绑托箱</view><view v-for="carton in unboundCartons" :key="carton.cartonId" class="carton-row"><view><text>{{ carton.boxCode }}</text><text>{{ carton.skuCode }} · 实收 {{ carton.actualSkuQuantity }}</text></view></view></view>
<view class="panel"><view class="heading"><text class="panel-title">托盘</text><text class="minor">{{ pallets.length }} 托</text></view><view v-if="!pallets.length" class="empty">清点箱码后新建托盘,或扫描已有 DP 托盘码</view>
<view v-for="pallet in pallets" :key="pallet.palletId" class="pallet-card" :class="{ active: pallet.palletNo === activePalletNo }">
<view class="heading"><view><text class="pallet-code">{{ pallet.palletNo }}</text><text class="minor">{{ pallet.cartonCount || 0 }} 箱 · {{ pallet.skuCount || 0 }} SKU</text></view><text class="badge" :class="{ sealed: pallet.status === 'SEALED' }">{{ pallet.status === 'SEALED' ? '已封托' : '组托中' }}</text></view>
<view v-if="pallet.weightKg != null || pallet.volumeCbm != null" class="measure-row"><text>重量 {{ pallet.weightKg ?? '—' }} kg</text><text>体积 {{ pallet.volumeCbm ?? '—' }} m³</text></view>
<view v-for="carton in palletCartons(pallet)" :key="carton.cartonId || carton.boxCode" class="carton-row"><view><text>{{ carton.boxCode }}</text><text>{{ carton.skuCode }} · 实收 {{ carton.actualSkuQuantity }}</text></view><button v-if="pallet.status !== 'SEALED'" :disabled="busy || !online" @tap="moveCarton(carton, activePalletNo && activePalletNo !== pallet.palletNo ? activePalletNo : null)">{{ activePalletNo && activePalletNo !== pallet.palletNo ? '换至当前托' : '移回待绑托' }}</button></view>
<button v-if="pallet.status !== 'SEALED'" class="secondary-button" :disabled="busy || !online || !canSeal" @tap="sealPallet(pallet)">封托</button><text v-if="pallet.status !== 'SEALED' && !canSeal" class="guard">{{ sealBlockReason }}</text>
</view>
</view>
</template><view class="bottom-space"></view>
</scroll-view>
</view>
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { createAndBindDropshipPallet, bindPendingDropshipCartons, countDropshipCarton, getDropshipPalletizingTask, moveDropshipCarton, resolveDropshipCarton, sealDropshipPallet } from '@/api/pda-dropship.js'
import { session, requireSession } from '@/stores/session.js'
import { clearDropshipPalletizingDraft, restoreDropshipPalletizingDraft, saveDropshipPalletizingDraft } from '@/stores/dropship-palletizing.js'
import { stableOperationId } from '@/utils/idempotency.js'
import { createDropshipScanState, DROPSHIP_SCAN_INTENT, planDropshipScan, releaseDropshipScan } from '@/utils/dropship-palletizing-state.js'
import { watchNetwork } from '@/utils/network.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
import { formatApiError } from '@/utils/user-facing.js'
export default {
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, taskId: '', warehouseId: '', task: null, loading: false, busy: false, online: true, error: '', notice: '', scanValue: '', scanState: createDropshipScanState(), activePalletNo: '', resolvedCarton: null, actualSkuQuantity: '', acceptVariance: false, discrepancyRemark: '', _stopNetwork: null, _stopScanner: null } },
computed: {
pallets() { return this.task?.pallets || [] }, unboundCartons() { return this.task?.unbound || [] }, sealedPalletCount() { return this.pallets.filter((pallet) => pallet.status === 'SEALED').length },
hasDifference() { return this.resolvedCarton ? Number(this.actualSkuQuantity) !== Number(this.resolvedCarton.expectedSkuQuantity) : false },
canConfirmCarton() { return Boolean(this.resolvedCarton && Number.isInteger(Number(this.actualSkuQuantity)) && Number(this.actualSkuQuantity) > 0 && (!this.hasDifference || (this.acceptVariance && this.discrepancyRemark.trim()))) },
canSeal() { return Boolean(this.task && Number(this.task.countedCartons) === Number(this.task.totalCartons) && Number(this.task.unboundCartons) === 0) },
sealBlockReason() { if (Number(this.task?.countedCartons) !== Number(this.task?.totalCartons)) return '仍有箱码未完成清点'; if (Number(this.task?.unboundCartons) > 0) return '仍有已清点箱未绑定托盘'; return '' },
draftIdentity() { return { operatorId: this.user?.id, taskId: this.taskId, warehouseId: this.warehouseId } }
},
onLoad(options) { if (!requireSession()) return; this.user = session.currentUser; this.taskId = String(options.taskId || ''); this.warehouseId = String(options.warehouseId || ''); const draft = restoreDropshipPalletizingDraft(this.draftIdentity); if (draft?.activePalletNo) this.activePalletNo = draft.activePalletNo; this.scanState = createDropshipScanState({ activePalletNo: this.activePalletNo }); this._stopNetwork = watchNetwork((value) => { this.online = value; this.scanState.online = value }) },
onShow() { if (!this.taskId || !this.warehouseId) { this.error = '任务参数缺失,请返回任务列表刷新'; return }; this.startScanner(); this.loadTask() }, onHide() { this.stopScanner() }, onUnload() { this.stopScanner(); if (this._stopNetwork) this._stopNetwork() },
methods: {
async createPallet() {
if (this.busy || !this.online || this.task?.taskStatus !== 'CLAIMED' || !this.unboundCartons.length) return
const cartonIds = this.unboundCartons.map((carton) => String(carton.cartonId)).sort()
const payload = { warehouseId: this.warehouseId, operationId: stableOperationId('dropship-create-bind', `${this.user?.id}-${this.taskId}`, { warehouseId: this.warehouseId, taskVersion: this.task.version, cartonIds }) }
this.busy = true; this.error = ''
try {
this.task = await createAndBindDropshipPallet(this.taskId, payload)
const pallet = this.pallets.find((item) => (item.cartons || []).some((carton) => cartonIds.includes(String(carton.cartonId))))
if (pallet) { this.activePalletNo = pallet.palletNo; this.scanState.activePalletNo = pallet.palletNo; this.persistDraft() }
this.notice = `已新建托盘 ${pallet?.palletNo || ''} 并绑定待绑托箱`; playSuccessSound()
} catch (error) { this.error = formatApiError(error, '新建托盘失败,请重试'); playErrorSound() }
finally { this.busy = false }
},
statusLabel(status) { return status === 'COMPLETED' ? '已完成' : status === 'CLAIMED' ? '清点组托中' : '待领取' }, palletCartons(pallet) { return pallet.cartons || [] },
startScanner() { if (!this._stopScanner) this._stopScanner = startScanBroadcast({ onScan: (value) => this.processScan(value), onError: () => playErrorSound() }) }, stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
async loadTask() { if (!this.online) { this.error = '当前网络不可用,请联网后刷新'; return }; this.loading = true; this.error = ''; try { this.task = await getDropshipPalletizingTask(this.taskId, this.warehouseId); if (this.task?.taskStatus === 'COMPLETED') { this.activePalletNo = ''; this.scanState = createDropshipScanState(); clearDropshipPalletizingDraft(this.draftIdentity) } } catch (error) { this.error = formatApiError(error, '清点组托任务加载失败') } finally { this.loading = false } },
handleManualScan(value) { this.processScan(value) },
async processScan(value) { if (this.busy) return; const plan = planDropshipScan(this.scanState, value, { unboundCartons: this.task?.unboundCartons }); this.scanState = plan.state; this.scanValue = String(value || '').trim().toUpperCase(); this.error = ''; this.notice = ''; if ([DROPSHIP_SCAN_INTENT.BLOCKED, DROPSHIP_SCAN_INTENT.INVALID, DROPSHIP_SCAN_INTENT.DUPLICATE].includes(plan.intent)) { this.error = plan.message; playErrorSound(); return }; if ([DROPSHIP_SCAN_INTENT.SELECT_PALLET, DROPSHIP_SCAN_INTENT.BIND_PENDING].includes(plan.intent)) { await this.selectPallet(plan.palletNo); this.scanState = releaseDropshipScan(this.scanState); return }; this.busy = true; try { const carton = await resolveDropshipCarton(this.taskId, this.warehouseId, plan.boxCode); if (carton.countStatus === 'BOUND') { this.notice = `箱码 ${plan.boxCode} 已绑定托盘 ${carton.palletNo || '—'},请勿重复扫描`; playErrorSound(); return this.loadTask() }; if (carton.countStatus === 'COUNTED_UNBOUND') { this.notice = `箱码 ${plan.boxCode} 已清点,当前位于待绑托区`; playErrorSound(); return this.loadTask() }; if (carton.canCount === false) { this.error = carton.blockReason || '当前箱码不可清点,请刷新任务'; playErrorSound(); return }; this.resolvedCarton = carton; this.actualSkuQuantity = String(carton.expectedSkuQuantity); this.acceptVariance = false; this.discrepancyRemark = ''; playSuccessSound() } catch (error) { this.error = formatApiError(error, '箱码识别失败'); playErrorSound() } finally { this.busy = false; this.scanState = releaseDropshipScan(this.scanState) } },
async selectPallet(palletNo) { this.activePalletNo = palletNo; this.scanState.activePalletNo = palletNo; this.persistDraft(); if (!Number(this.task?.unboundCartons || 0)) { this.notice = `当前托盘已切换为 ${palletNo}`; playSuccessSound(); return }; this.busy = true; const operationId = stableOperationId('dropship-bind', `${this.user?.id}-${this.taskId}`, { palletNo, cartonIds: this.unboundCartons.map((carton) => carton.cartonId) }); try { this.task = await bindPendingDropshipCartons(this.taskId, { warehouseId: this.warehouseId, palletNo, operationId }); this.notice = `待绑托箱已批量绑定到 ${palletNo}`; playSuccessSound() } catch (error) { const message = formatApiError(error, '批量绑定失败,请刷新后重试'); playErrorSound(); await this.loadTask(); this.error = message } finally { this.busy = false } },
changeQuantity(delta) { this.actualSkuQuantity = String(Math.max(1, Number(this.actualSkuQuantity || 0) + delta)) },
async confirmCarton() { if (!this.canConfirmCarton || this.busy) return; const payload = { warehouseId: this.warehouseId, boxCode: this.resolvedCarton.boxCode, actualSkuQuantity: Number(this.actualSkuQuantity), discrepancyAction: this.hasDifference ? 'ACCEPT_VARIANCE' : 'MATCH', discrepancyRemark: this.hasDifference ? this.discrepancyRemark.trim() : null, palletNo: this.activePalletNo || null }; payload.operationId = stableOperationId('dropship-count', `${this.user?.id}-${this.taskId}`, payload); this.busy = true; this.error = ''; try { await countDropshipCarton(this.taskId, payload); this.notice = this.activePalletNo ? `本箱已清点并绑定到 ${this.activePalletNo}` : '本箱已清点,等待绑定托盘'; this.resolvedCarton = null; this.actualSkuQuantity = ''; this.scanValue = ''; await this.loadTask(); playSuccessSound() } catch (error) { const message = formatApiError(error, '实收确认失败,请刷新后重试'); playErrorSound(); await this.loadTask(); this.error = message } finally { this.busy = false } },
async moveCarton(carton, targetPalletNo) { if (targetPalletNo === carton.palletNo) return; const payload = { warehouseId: this.warehouseId, targetPalletNo: targetPalletNo || null }; payload.operationId = stableOperationId('dropship-move', `${this.user?.id}-${this.taskId}-${carton.cartonId}`, payload); this.busy = true; this.error = ''; try { this.task = await moveDropshipCarton(this.taskId, carton.cartonId, payload); this.notice = targetPalletNo ? `箱码 ${carton.boxCode} 已换至 ${targetPalletNo}` : `箱码 ${carton.boxCode} 已移回待绑托区`; playSuccessSound() } catch (error) { const message = formatApiError(error, '换托操作失败,请刷新后重试'); playErrorSound(); await this.loadTask(); this.error = message } finally { this.busy = false } },
async sealPallet(pallet) { if (!this.canSeal || this.busy) return; const payload = { warehouseId: this.warehouseId, expectedVersion: Number(pallet.version) }; payload.operationId = stableOperationId('dropship-seal', `${this.user?.id}-${this.taskId}-${pallet.palletId}`, payload); this.busy = true; this.error = ''; try { this.task = await sealDropshipPallet(this.taskId, pallet.palletId, payload); if (this.activePalletNo === pallet.palletNo) this.activePalletNo = ''; this.persistDraft(); if (this.task?.taskStatus === 'COMPLETED') clearDropshipPalletizingDraft(this.draftIdentity); this.notice = `托盘 ${pallet.palletNo} 已封托,后续按整托不可拆分`; playSuccessSound() } catch (error) { const message = formatApiError(error, '封托失败,请刷新后重试'); playErrorSound(); await this.loadTask(); this.error = message } finally { this.busy = false } },
persistDraft() { saveDropshipPalletizingDraft(this.draftIdentity, { activePalletNo: this.activePalletNo }) }
}
}
</script>
<style scoped>
.dropship-page{min-width:0;overflow:hidden;background:#f5f7fa}.work-scroll{box-sizing:border-box;height:calc(100vh - 56px);padding:10px 10px 32px}.network-warning{padding:8px 10px;color:#8a2c0d;font-size:12px;line-height:18px;background:#fff1e8}.message-card,.loading-card{margin-bottom:9px;padding:10px 12px;border-radius:8px;color:#155724;font-size:12px;line-height:18px;background:#edf8f0}.message-card.error{color:#9b2c2c;background:#fff0f0}.loading-card{color:#64748b;text-align:center;background:#fff}.panel,.task-card{box-sizing:border-box;width:100%;margin-bottom:9px;padding:12px;border:1px solid #e1e7ef;border-radius:11px;background:#fff}.task-card,.heading{display:flex;min-width:0;align-items:flex-start;justify-content:space-between;gap:8px}.minor,.panel-copy,.guard{display:block;color:#667085;font-size:11px;line-height:17px}.task-code,.panel-title,.pallet-code,.sku-code{display:block;min-width:0;color:#22344a;font-size:16px;font-weight:800;overflow-wrap:anywhere}.badge{flex-shrink:0;padding:4px 7px;border-radius:999px;color:#155eef;font-size:10px;font-weight:700;background:#eff4ff}.badge.sealed{color:#137333;background:#e8f5ec}.summary-grid{display:grid;margin-bottom:9px;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px}.summary-grid view{padding:9px 4px;border-radius:9px;text-align:center;background:#fff}.summary-grid text{display:block;color:#22344a;font-size:17px;font-weight:800}.summary-grid text+text{color:#718096;font-size:10px;font-weight:500}.panel-copy{margin:3px 0 8px}.active-pallet{display:flex;margin-top:8px;padding:8px;border-radius:7px;justify-content:space-between;gap:8px;color:#64748b;font-size:11px;background:#f6f8fb}.active-pallet text:last-child{min-width:0;color:#155eef;font-weight:700;text-align:right;overflow-wrap:anywhere}.sku-card{display:flex;margin:10px 0;padding:10px;border-radius:8px;align-items:center;justify-content:space-between;gap:8px;background:#f6f8fb}.expected{text-align:right}.expected text:last-child{display:block;color:#172b45;font-size:20px;font-weight:800}.quantity-label{display:block;margin-bottom:5px;color:#475467;font-size:12px;font-weight:700}.quantity-control{display:grid;width:100%;grid-template-columns:52px minmax(0,1fr) 52px}.quantity-control button{height:48px;margin:0;padding:0;color:#155eef;font-size:22px;line-height:48px;background:#edf3ff}.quantity-control input{box-sizing:border-box;width:100%;height:48px;border:1px solid #d8e0ea;text-align:center;font-size:17px;background:#fff}.difference-box{margin-top:10px;padding:10px;border-radius:8px;color:#8a2c0d;font-size:12px;line-height:18px;background:#fff6ed}.difference-box label{display:flex;min-height:42px;align-items:center;gap:6px;font-weight:700}.difference-box textarea{box-sizing:border-box;width:100%;height:68px;padding:8px;border:1px solid #e4c7ad;border-radius:7px;font-size:12px;background:#fff}.primary-button,.secondary-button{box-sizing:border-box;width:100%;min-height:48px;margin:10px 0 0;border-radius:8px;font-size:14px;font-weight:700;line-height:48px}.primary-button{color:#fff;background:#155eef}.secondary-button{color:#155eef;background:#edf3ff}.empty{padding:13px 0 3px;color:#8793a3;font-size:12px;text-align:center}.carton-row{display:flex;min-width:0;min-height:46px;border-bottom:1px solid #edf0f4;align-items:center;justify-content:space-between;gap:7px}.carton-row view{min-width:0}.carton-row text{display:block;color:#334155;font-size:12px;font-weight:700;overflow-wrap:anywhere}.carton-row text+text{color:#667085;font-size:10px;font-weight:500}.carton-row button{flex-shrink:0;min-width:82px;height:38px;margin:0;padding:0 7px;color:#155eef;font-size:10px;line-height:38px;background:#edf3ff}.pallet-card{margin-top:9px;padding:10px;border:1px solid #dfe5ed;border-radius:9px}.pallet-card.active{border:2px solid #155eef;background:#f8fbff}.measure-row{display:flex;margin-top:7px;padding:7px;border-radius:6px;justify-content:space-between;color:#475467;font-size:10px;background:#f6f8fb}.guard{margin-top:5px;text-align:center}.bottom-space{height:20px}button::after{border:0}
</style>