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

177 lines
17 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 palletization-page">
<PortalHeader title="一件代发入库" :user="user" :fallback-url="'/pages/dropship-tasks/dropship-tasks'" />
<view class="content">
<view v-if="error" class="message error" role="alert">{{ error }}</view>
<view v-if="notice" class="message success" role="status">{{ notice }}</view>
<view v-if="loading && !task" class="state">正在加载入库任务…</view>
<template v-if="task">
<view class="task-summary-card">
<view class="summary-head"><view><text class="eyebrow">入仓码</text><text class="inbound-code">{{ task.inboundCode || '—' }}</text></view><text class="status">{{ statusLabel }}</text></view>
<text class="customer">{{ task.customerName || '客户未关联' }} · {{ task.warehouseName || '仓库未关联' }}</text>
<view class="metrics">
<view><strong>{{ task.totalCartons || 0 }}</strong><text>总箱</text></view>
<view><strong>{{ task.countedCartons || 0 }}</strong><text>已清点</text></view>
<view><strong>{{ task.boundCartons || 0 }}</strong><text>已归托</text></view>
<view><strong>{{ task.unboundCartons || 0 }}</strong><text>待归托</text></view>
</view>
<view class="pallet-readiness" :class="{ invalid: !palletSetupReady }">
<text>预计 {{ task.expectedPalletCount || 0 }} 托</text>
<text>已生成 {{ task.generatedPalletCount || 0 }} 托</text>
</view>
<view v-if="!palletSetupReady" class="message error pallet-setup-error" role="alert">{{ palletSetupMessage }}</view>
<button v-if="task.taskStatus === 'PENDING'" class="primary-button" :disabled="submitting" @tap="claimTask">领取并开始入库</button>
</view>
<view v-if="task.taskStatus === 'CLAIMED'" class="section-card">
<text class="section-title">1. 箱码清点与归托</text>
<text class="section-desc">先扫箱:核对实际件数并确认清点;已选择托盘时本箱直接归托。也可先扫托盘,再连续扫箱清点归托。</text>
<template v-if="palletSetupReady">
<ScanTargetInput v-model="boxCode" name="boxCode" label="箱码" placeholder="扫描或输入箱唛箱码" :active="activeScan === 'boxCode'" @activate="activeScan = 'boxCode'" @submit="resolveCarton" />
<view v-if="!carton" class="count-entry">
<text>扫描箱码后核对计划件数与实际件数,再确认完成本箱清点。</text>
<button class="primary-button count-button" :disabled="!canResolveCarton" @tap="resolveCarton()">开始清点</button>
</view>
<view v-if="carton" class="carton-card">
<view class="carton-head"><text>{{ carton.boxCode }}</text><text>{{ carton.countStatus === 'UNCOUNTED' ? '待清点' : '已处理' }}</text></view>
<text class="carton-line">{{ carton.skuCode || 'SKU —' }} · {{ carton.cargoName || '品名 —' }}</text>
<text class="carton-line">计划 {{ carton.expectedSkuQuantity || 0 }} 件</text>
<view class="field"><text>实际件数</text><input v-model="actualQuantity" type="number" aria-label="本箱实际件数" /></view>
<view v-if="quantityDiffers" class="field stacked"><text>差异说明</text><textarea v-model="discrepancyRemark" maxlength="500" placeholder="数量不一致时必填" /></view>
<button class="primary-button" :disabled="!canCount || submitting" @tap="countCarton">{{ currentPallet ? `确认清点并归入 ${currentPallet.palletNo}` : '确认清点并进入待归托区' }}</button>
</view>
<view class="pallet-scan-block">
<text class="field-label">托盘归托(可先扫或后扫)</text>
<ScanTargetInput v-model="palletCode" name="palletCode" label="托盘码" placeholder="扫描或输入已生成的 DP 托盘码" :active="activeScan === 'palletCode'" @activate="activeScan = 'palletCode'" @submit="selectPallet" />
</view>
</template>
<view v-else class="count-unavailable">托盘码未准备完成,当前不能开始箱码清点。</view>
</view>
<view class="section-card">
<view class="section-heading"><view><text class="section-title">2. 预生成托盘码</text><text class="section-desc">测量审核后已按预计托数生成,可先打印粘贴;装完一个托盘即可封托。</text></view><button class="text-button" :disabled="loading" @tap="loadTask">刷新</button></view>
<view v-if="!task.pallets || !task.pallets.length" class="empty">暂无托盘</view>
<view v-for="pallet in task.pallets" :key="pallet.palletId" class="pallet-card">
<view class="pallet-head"><text class="pallet-code">{{ pallet.palletNo }}</text><text class="pallet-status" :class="pallet.status === 'SEALED' ? 'sealed' : ''">{{ pallet.status === 'SEALED' ? '已封托' : '待封托' }}</text></view>
<text class="pallet-meta">{{ pallet.cartonCount || 0 }} 箱 · {{ pallet.skuCount || 0 }} SKU · {{ pallet.weightKg || 0 }} kg · {{ pallet.volumeCbm || 0 }} m³</text>
<button v-if="pallet.status === 'OPEN'" class="secondary-button" :disabled="submitting" @tap="selectPallet(pallet.palletNo)">{{ currentPallet?.palletId === pallet.palletId ? '当前托盘' : '设为当前托盘' }}</button>
<button class="secondary-button" :disabled="printing" @tap="printPallet(pallet)">{{ printing ? '正在生成打印文件…' : '打印托盘码' }}</button>
<button v-if="pallet.status === 'OPEN'" class="primary-button" :disabled="!pallet.cartonCount || submitting" @tap="sealPallet(pallet)">封托</button>
</view>
</view>
</template>
</view>
</view>
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { bindDropshipPendingCartons, claimDropshipPalletizationTask, countDropshipCarton, downloadDropshipPalletLabel, getDropshipPalletizationTask, resolveDropshipCarton, sealDropshipPallet } from '@/api/pda-dropship.js'
import { session, requireSession } from '@/stores/session.js'
import { formatApiError } from '@/utils/user-facing.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
export default {
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, taskId: '', warehouseId: '', task: null, palletCode: '', selectedPalletId: '', boxCode: '', carton: null, actualQuantity: '', discrepancyRemark: '', activeScan: 'boxCode', loading: false, submitting: false, printing: false, error: '', notice: '' } },
computed: {
statusLabel() { return this.task?.taskStatus === 'COMPLETED' ? '已完成' : (this.task?.taskStatus === 'CLAIMED' ? '作业中' : '待领取') },
palletSetupReady() { return this.task?.palletSetupReady === true },
palletSetupMessage() { return this.task?.palletSetupMessage || `预计 ${this.task?.expectedPalletCount || 0} 托,当前仅生成 ${this.task?.generatedPalletCount || 0} 个托盘码,请联系后台处理后刷新` },
quantityDiffers() { return this.carton && Number(this.actualQuantity) !== Number(this.carton.expectedSkuQuantity) },
canResolveCarton() { return Boolean(this.palletSetupReady && this.task?.taskStatus === 'CLAIMED' && String(this.boxCode || '').trim() && !this.loading && !this.submitting) },
canCount() { return Boolean(this.carton?.canCount && Number(this.actualQuantity) > 0 && (!this.quantityDiffers || this.discrepancyRemark.trim())) },
currentPallet() { return (this.task?.pallets || []).find((item) => String(item.palletId) === String(this.selectedPalletId) && item.status === 'OPEN') || null }
},
onLoad(options) { this.taskId = String(options.taskId || ''); this.warehouseId = String(options.warehouseId || '') },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.startScanner(); this.loadTask() },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: {
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleScan(value), onError: () => { this.error = '扫码头连接失败,可使用输入框继续作业' } }) },
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
handleScan(value) { if (!this.palletSetupReady) { this.error = this.palletSetupMessage; return } const code = String(value).trim().toUpperCase(); if (code.startsWith('DP-')) { this.palletCode = code; this.selectPallet(code) } else { this.boxCode = code; this.resolveCarton() } },
resetMessages() { this.error = ''; this.notice = '' },
async loadTask() {
if (!this.taskId || !this.warehouseId) { this.error = '缺少合托任务或仓库信息,请返回任务列表'; return }
this.loading = true; this.resetMessages()
try { this.task = await getDropshipPalletizationTask(this.taskId, this.warehouseId) }
catch (error) { this.error = formatApiError(error, '一件代发入库任务加载失败') }
finally { this.loading = false }
},
async claimTask() { await this.runMutation(() => claimDropshipPalletizationTask(this.taskId, this.warehouseId), '任务领取成功') },
async resolveCarton(value) {
if (!this.palletSetupReady) { this.error = this.palletSetupMessage; return }
const code = String(value || this.boxCode || '').trim().toUpperCase(); if (!code || this.loading) return
this.boxCode = code; this.loading = true; this.resetMessages(); this.carton = null
try { this.carton = await resolveDropshipCarton(this.taskId, this.warehouseId, code); this.actualQuantity = String(this.carton.expectedSkuQuantity || '') }
catch (error) { this.error = formatApiError(error, '箱码解析失败') }
finally { this.loading = false }
},
async countCarton() {
const result = await this.runMutation(() => countDropshipCarton(this.taskId, { warehouseId: this.warehouseId, boxCode: this.carton.boxCode, palletNo: this.currentPallet?.palletNo || null, actualSkuQuantity: Number(this.actualQuantity), discrepancyAction: this.quantityDiffers ? 'ACCEPT_VARIANCE' : 'MATCH', discrepancyRemark: this.quantityDiffers ? this.discrepancyRemark.trim() : null }), this.currentPallet ? '本箱已清点并归托' : '本箱已完成入库清点')
if (result) { this.boxCode = ''; this.carton = null; this.actualQuantity = ''; this.discrepancyRemark = '' }
},
async selectPallet(value) {
if (!this.palletSetupReady) { this.error = this.palletSetupMessage; return }
const code = String(value || this.palletCode || '').trim().toUpperCase(); const pallet = (this.task?.pallets || []).find((item) => item.palletNo === code)
if (!pallet) { this.error = '托盘码不属于当前一件代发任务'; return }
if (pallet.status !== 'OPEN') { this.error = '托盘已封托,不能继续归托'; return }
this.palletCode = code; this.selectedPalletId = String(pallet.palletId); this.activeScan = 'boxCode'
if (Number(this.task?.unboundCartons || 0) > 0) await this.runMutation(() => bindDropshipPendingCartons(this.taskId, this.warehouseId, code), `待归托箱已绑定至 ${code}`)
else this.notice = `当前托盘:${code}`
},
async sealPallet(pallet) { const result = await this.runMutation(() => sealDropshipPallet(this.taskId, this.warehouseId, pallet), `${pallet.palletNo} 已封托`); if (result && String(this.selectedPalletId) === String(pallet.palletId)) { this.selectedPalletId = ''; this.palletCode = '' } },
async printPallet(pallet) {
if (this.printing) return
this.printing = true; this.resetMessages()
try {
const filePath = await downloadDropshipPalletLabel(this.taskId, this.warehouseId, pallet.palletId)
// #ifdef H5
const link = document.createElement('a'); link.href = filePath; link.download = `${pallet.palletNo}-托盘码.pdf`; link.click()
// #endif
// #ifndef H5
await new Promise((resolve, reject) => uni.openDocument({ filePath, fileType: 'pdf', showMenu: true, success: resolve, fail: reject }))
// #endif
this.notice = `${pallet.palletNo} 打印文件已打开`
} catch (error) { this.error = formatApiError(error, '托盘码打印失败') }
finally { this.printing = false }
},
async runMutation(action, message) {
if (this.submitting) return null
this.submitting = true; this.resetMessages()
try {
const result = await action()
this.task = result?.taskId && Array.isArray(result?.pallets)
? result
: await getDropshipPalletizationTask(this.taskId, this.warehouseId)
this.notice = message
return result
}
catch (error) { this.error = formatApiError(error, '操作失败'); return null }
finally { this.submitting = false }
}
}
}
</script>
<style scoped>
.content { padding: 12px 12px 42px; }
.state, .message { margin-bottom: 10px; padding: 12px; border-radius: 9px; color: #667085; font-size: 12px; line-height: 18px; text-align: center; background: #fff; }
.message.error { color: #b42318; background: #fff1f0; }.message.success { color: #067647; background: #ecfdf3; }
.task-summary-card, .section-card { margin-bottom: 12px; padding: 13px; border: 1px solid #dce6f1; border-radius: 12px; background: #fff; box-shadow: 0 2px 8px rgba(23,55,93,.05); }
.task-summary-card { display: block; }
.summary-head, .section-heading, .pallet-head, .carton-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.summary-head > view { display: flex; min-width: 0; flex-direction: column; }.eyebrow { color: #667085; font-size: 10px; }.inbound-code { margin-top: 2px; color: #12233f; font-size: 19px; font-weight: 700; overflow-wrap: anywhere; }
.status, .pallet-status { flex-shrink: 0; padding: 4px 8px; border-radius: 99px; color: #155eef; font-size: 10px; background: #eff4ff; }.pallet-status.sealed { color: #067647; background: #ecfdf3; }
.customer { display: block; margin-top: 6px; color: #667085; font-size: 11px; }
.metrics { display: grid; grid-template-columns: repeat(4,1fr); margin: 12px 0; padding: 10px 0; border-top: 1px solid #edf1f5; border-bottom: 1px solid #edf1f5; }.metrics view { display: flex; flex-direction: column; align-items: center; border-right: 1px solid #edf1f5; }.metrics view:last-child { border: 0; }.metrics strong { font-size: 17px; }.metrics text { margin-top: 2px; color: #667085; font-size: 10px; }
.pallet-readiness { display: flex; justify-content: center; gap: 18px; margin-top: -2px; color: #067647; font-size: 11px; font-weight: 700; }.pallet-readiness.invalid { color: #b42318; }.pallet-setup-error { margin-top: 10px; margin-bottom: 0; text-align: left; }
.section-title { display: block; color: #12233f; font-size: 16px; font-weight: 700; }.section-desc { display: block; margin: 3px 0 11px; color: #667085; font-size: 11px; line-height: 17px; }.section-heading > view { min-width: 0; }
.primary-button, .secondary-button { box-sizing: border-box; width: 100%; height: 46px; margin-top: 10px; border-radius: 9px; font-size: 14px; font-weight: 700; line-height: 44px; }.primary-button { border: 0; color: #fff; background: #1a73e8; }.secondary-button { border: 1px solid #8ab8ef; color: #155eef; background: #f6f9ff; }.primary-button[disabled],.secondary-button[disabled] { opacity: .45; }
.count-entry { margin-top: 9px; padding: 10px; border: 1px solid #dbe8f8; border-radius: 9px; color: #52657a; font-size: 11px; line-height: 17px; background: #f6f9ff; }.count-button { margin-top: 8px; }.count-unavailable { padding: 18px 10px; border-radius: 9px; color: #b42318; font-size: 12px; line-height: 18px; text-align: center; background: #fff1f0; }.pallet-scan-block { margin-top: 13px; padding-top: 12px; border-top: 1px solid #e6edf5; }.field-label { display: block; margin-bottom: 7px; color: #344054; font-size: 12px; font-weight: 700; }
.carton-card, .pallet-card { margin-top: 11px; padding: 11px; border: 1px solid #e3eaf2; border-radius: 9px; background: #f8fbff; }.carton-head { color: #12233f; font-size: 13px; font-weight: 700; }.carton-line, .pallet-meta { display: block; margin-top: 5px; color: #667085; font-size: 11px; line-height: 17px; }
.field { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 10px; color: #344054; font-size: 12px; }.field input { box-sizing: border-box; width: 120px; height: 42px; padding: 0 10px; border: 1px solid #cfd8e3; border-radius: 8px; background: #fff; }.field.stacked { align-items: stretch; flex-direction: column; gap: 6px; }.field textarea { box-sizing: border-box; width: 100%; height: 70px; padding: 9px; border: 1px solid #cfd8e3; border-radius: 8px; background: #fff; }
.pallet-card { background: #fff; }.pallet-code { color: #12233f; font-size: 15px; font-weight: 700; overflow-wrap: anywhere; }.text-button { min-width: 58px; height: 40px; border: 0; color: #155eef; font-size: 12px; line-height: 40px; background: transparent; }.empty { padding: 18px 0 8px; color: #98a2b3; font-size: 12px; text-align: center; }
</style>