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

155 lines
14 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>
<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>
<ScanTargetInput v-model="palletCode" name="palletCode" label="托盘码" placeholder="扫描或输入已生成的 DP 托盘码" :active="activeScan === 'palletCode'" @activate="activeScan = 'palletCode'" @submit="selectPallet" />
<ScanTargetInput v-model="boxCode" name="boxCode" label="箱码" placeholder="扫描或输入箱唛箱码" :active="activeScan === 'boxCode'" @activate="activeScan = 'boxCode'" @submit="resolveCarton" />
<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>
<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' ? '作业中' : '待领取') },
quantityDiffers() { return this.carton && Number(this.actualQuantity) !== Number(this.carton.expectedSkuQuantity) },
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) { 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) {
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) {
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; }
.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; }
.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>