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

182 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 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"><text class="inbound-code">{{ task.inboundCode || '—' }}</text><text class="status">{{ statusLabel }}</text></view>
<text class="warehouse-summary">{{ task.warehouseName || '仓库未关联' }}</text>
<view class="compact-metrics">
<text><strong>{{ task.totalCartons || 0 }}</strong>总箱</text>
<text><strong>{{ task.countedCartons || 0 }}</strong>清点</text>
<text><strong>{{ task.boundCartons || 0 }}</strong>归托</text>
<text><strong>{{ task.unboundCartons || 0 }}</strong>待归</text>
<text class="pallet-readiness" :class="{ invalid: !palletSetupReady }"><strong>{{ task.generatedPalletCount || 0 }}/{{ task.expectedPalletCount || 0 }}</strong>托盘</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>
<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="compact-action" :disabled="!canResolveCarton" @tap="resolveCarton()">读取 SKU</button>
</view>
<view v-if="carton" class="carton-card">
<view v-for="(line, index) in skuCounts" :key="line.inboundDetailId" class="sku-count-row">
<view class="sku-copy"><text class="sku-label">SKU {{ index + 1 }}</text><text class="sku-code">{{ line.skuCode || '—' }}</text></view>
<view class="quantity-stepper">
<button :aria-label="`减少 ${line.skuCode} 清点数量`" @tap="adjustQuantity(index, -1)">−</button>
<input v-model="line.actualQuantity" type="number" :aria-label="`${line.skuCode} 清点数量`" />
<button :aria-label="`增加 ${line.skuCode} 清点数量`" @tap="adjustQuantity(index, 1)">+</button>
</view>
<view v-if="lineDiffers(line)" class="field stacked difference-field"><text>差异说明</text><textarea v-model="line.discrepancyRemark" maxlength="500" placeholder="数量不一致时必填" /></view>
</view>
<text v-if="currentPallet" class="pallet-hint">归入 {{ currentPallet.palletNo }}</text>
<button class="primary-button" :disabled="!canCount || submitting" @tap="countCarton">确认清点</button>
</view>
<view class="pallet-scan-block">
<text class="field-label">扫托盘 <text>可选</text></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 v-if="task.taskStatus === 'CLAIMED' && task.totalCartons > 0 && task.boundCartons === task.totalCartons" class="continue-strip">
<text>本票已全部归托。托盘未装满时先继续装其他票,实物装满后再封托。</text>
<button class="secondary-button" @tap="continueOtherTask">继续装其他入仓单</button>
</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="currentPallet" class="pallet-card">
<view class="pallet-head"><text class="pallet-code">{{ currentPallet.palletNo }}</text><text class="pallet-status" :class="currentPallet.status === 'SEALED' ? 'sealed' : ''">{{ currentPallet.status === 'SEALED' ? '已封托' : '当前托盘' }}</text></view>
<text class="pallet-meta">{{ currentPallet.cartonCount || 0 }} 箱 · {{ currentPallet.skuCount || 0 }} SKU · {{ currentPallet.weightKg || 0 }} kg · {{ currentPallet.volumeCbm || 0 }} m³</text>
<view v-for="item in (currentPalletDetails?.cartons || [])" :key="item.cartonId" class="carton-row"><text>{{ item.boxCode }}</text><button v-if="currentPallet.status === 'OPEN'" class="text-button" :disabled="submitting" @tap="moveCarton(item, null)">移回待绑托</button></view>
<button v-if="currentPallet.palletId && currentPallet.status === 'OPEN'" class="primary-button" :disabled="!currentPallet.cartonCount || submitting" @tap="sealPallet(currentPallet)">封托</button>
</view>
</view>
</template>
</view>
</view>
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { bindDropshipPendingCartons, claimDropshipPalletizationTask, countDropshipCarton, getDropshipPalletizationTask, moveDropshipCarton, resolveDropshipCarton, sealDropshipPallet } from '@/api/pda-dropship.js'
import { session, requireSession } from '@/stores/session.js'
import { formatApiError } from '@/utils/user-facing.js'
import { stableOperationId } from '@/utils/idempotency.js'
import { isOnline } from '@/utils/network.js'
import { clearDropshipPalletizingDraft, restoreDropshipPalletizingDraft, saveDropshipPalletizingDraft } from '@/stores/dropship-palletizing.js'
import { createDropshipScanState, planDropshipScan, DROPSHIP_SCAN_INTENT } from '@/utils/dropship-palletizing-state.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
export default {
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, taskId: '', warehouseId: '', task: null, palletCode: '', selectedPalletId: '', selectedPalletNo: '', boxCode: '', carton: null, skuCounts: [], activeScan: 'boxCode', loading: false, submitting: 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} 个托盘码,请联系后台处理后刷新` },
canResolveCarton() { return Boolean(this.palletSetupReady && this.task?.taskStatus === 'CLAIMED' && String(this.boxCode || '').trim() && !this.loading && !this.submitting) },
canCount() { return Boolean(this.carton?.canCount && this.skuCounts.length && this.skuCounts.every((line) => Number.isInteger(Number(line.actualQuantity)) && Number(line.actualQuantity) >= 0 && (!this.lineDiffers(line) || String(line.discrepancyRemark || '').trim()))) },
currentPalletDetails() { return (this.task?.pallets || []).find((item) => item.palletNo === this.selectedPalletNo) || null },
currentPallet() { return this.currentPalletDetails?.status === 'OPEN' ? this.currentPalletDetails : (this.selectedPalletNo ? { palletNo: this.selectedPalletNo, status: 'OPEN' } : null) }
},
onLoad(options) { this.taskId = String(options.taskId || ''); this.warehouseId = String(options.warehouseId || '') },
onShow() { if (!requireSession()) return; this.user = session.currentUser; const draft = restoreDropshipPalletizingDraft(this.draftIdentity()); this.selectedPalletNo = draft?.activePalletNo || ''; 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 plan = planDropshipScan(createDropshipScanState({ online: isOnline() }), value, { unboundCartons: this.task?.unboundCartons }); if ([DROPSHIP_SCAN_INTENT.BLOCKED, DROPSHIP_SCAN_INTENT.INVALID].includes(plan.intent)) { this.error = plan.message; return } const code = plan.palletNo || plan.boxCode; if (plan.palletNo) { 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); if (this.task.taskStatus === 'COMPLETED') clearDropshipPalletizingDraft(this.draftIdentity()) }
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.skuCounts = (this.carton.skuLines || []).map((line) => ({ ...line, actualQuantity: String(line.expectedQuantity || ''), discrepancyRemark: '' })) }
catch (error) { this.error = formatApiError(error, '箱码解析失败') }
finally { this.loading = false }
},
async countCarton() {
const skuLines = this.skuCounts.map((line) => ({ inboundDetailId: line.inboundDetailId, skuCode: line.skuCode, actualQuantity: Number(line.actualQuantity), discrepancyAction: this.lineDiffers(line) ? 'ACCEPT_VARIANCE' : 'MATCH', discrepancyRemark: this.lineDiffers(line) ? String(line.discrepancyRemark || '').trim() : null }))
const result = await this.runMutation(() => countDropshipCarton(this.taskId, { warehouseId: this.warehouseId, boxCode: this.carton.boxCode, palletNo: this.currentPallet?.palletNo || null, skuLines }), this.currentPallet ? '本箱已清点并归托' : '本箱已完成入库清点')
if (result) { this.boxCode = ''; this.carton = null; this.skuCounts = [] }
},
lineDiffers(line) { return Number(line.actualQuantity) !== Number(line.expectedQuantity) },
adjustQuantity(index, step) { const line = this.skuCounts[index]; if (line) line.actualQuantity = String(Math.max(0, Number(line.actualQuantity || 0) + step)) },
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 && pallet.status !== 'OPEN') { this.error = '托盘已封托,不能继续归托'; return }
this.palletCode = code; this.selectedPalletId = pallet ? String(pallet.palletId) : ''; this.selectedPalletNo = code; this.activeScan = 'boxCode'; saveDropshipPalletizingDraft(this.draftIdentity(), { activePalletNo: code })
if (Number(this.task?.unboundCartons || 0) > 0) await this.runMutation(() => bindDropshipPendingCartons(this.taskId, this.warehouseId, code, this.task.version), `待归托箱已绑定至 ${code}`)
else this.notice = pallet ? `当前托盘:${code}` : `已选择仓库开放托盘 ${code},清点箱码时将校验并归托`
},
draftIdentity() { return { operatorId: this.user?.id, taskId: this.taskId, warehouseId: this.warehouseId } },
async moveCarton(carton, targetPalletNo) {
if (this.submitting || !isOnline() || !this.palletSetupReady) return
const payload = { warehouseId: this.warehouseId, targetPalletNo: targetPalletNo || null }
payload.operationId = stableOperationId('dropship-move', String(this.user?.id) + '-' + this.taskId, { cartonId: carton.cartonId, taskVersion: this.task.version, ...payload })
await this.runMutation(() => moveDropshipCarton(this.taskId, carton.cartonId, payload), targetPalletNo ? '箱码已换托' : '箱码已移回待绑托区')
},
continueOtherTask() { uni.redirectTo({ url: `/pages/dropship-tasks/dropship-tasks?warehouseId=${encodeURIComponent(this.warehouseId)}` }) },
async sealPallet(pallet) { const result = await this.runMutation(() => sealDropshipPallet(this.taskId, this.warehouseId, pallet), `${pallet.palletNo} 已封托`); if (result && this.selectedPalletNo === pallet.palletNo) { this.selectedPalletId = ''; this.selectedPalletNo = ''; this.palletCode = '' } },
async runMutation(action, message) {
if (this.submitting) return null
if (!isOnline()) { this.error = '当前网络不可用,请联网后刷新'; 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: 0 0 32px; background: #fff; }
.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: 0; padding: 11px 12px; border: 0; border-bottom: 6px solid #eef2f7; border-radius: 0; background: #fff; box-shadow: none; }
.continue-strip { padding: 10px 12px 12px; border-bottom: 6px solid #eef2f7; color: #475467; font-size: 13px; line-height: 19px; background: #f8fbff; }.continue-strip .secondary-button { margin-top: 7px; }
.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; }
.inbound-code { min-width: 0; color: #12233f; font-size: 17px; font-weight: 700; overflow-wrap: anywhere; }
.status, .pallet-status { flex-shrink: 0; padding: 4px 8px; border-radius: 99px; color: #155eef; font-size: 12px; background: #eff4ff; }.pallet-status.sealed { color: #067647; background: #ecfdf3; }
.warehouse-summary { display: block; margin-top: 3px; color: #667085; font-size: 12px; }
.compact-metrics { display: grid; grid-template-columns: repeat(5,1fr); margin-top: 8px; padding-top: 7px; border-top: 1px solid #edf1f5; }.compact-metrics > text { display: flex; align-items: center; justify-content: center; gap: 3px; color: #667085; font-size: 11px; white-space: nowrap; }.compact-metrics strong { color: #12233f; font-size: 15px; }.compact-metrics .pallet-readiness { color: #067647; }.compact-metrics .pallet-readiness.invalid,.compact-metrics .pallet-readiness.invalid strong { color: #b42318; }.pallet-setup-error { margin-top: 8px; margin-bottom: 0; text-align: left; }
.section-title { display: block; color: #12233f; font-size: 18px; font-weight: 700; }.section-desc { display: block; margin: 2px 0 8px; color: #667085; font-size: 13px; line-height: 18px; }.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 { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 6px; padding: 5px 7px; border: 0; border-radius: 0; color: #667085; font-size: 13px; background: #f6f9ff; }.compact-action { width: 86px; height: 34px; margin: 0; border: 0; border-radius: 6px; color: #fff; font-size: 13px; line-height: 34px; background: #1a73e8; }.compact-action[disabled] { opacity: .35; }.count-unavailable { padding: 12px 8px; color: #b42318; font-size: 13px; line-height: 18px; text-align: center; background: #fff1f0; }.pallet-scan-block { margin-top: 8px; padding-top: 8px; border-top: 1px solid #e6edf5; }.field-label { display: block; margin-bottom: 5px; color: #344054; font-size: 15px; font-weight: 700; }.field-label text { margin-left: 3px; color: #98a2b3; font-size: 12px; font-weight: 400; }
.carton-card, .pallet-card { margin-top: 8px; padding: 8px 0 0; border: 0; border-top: 1px solid #e3eaf2; border-radius: 0; background: transparent; }.sku-count-row { display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid #edf1f5; }.sku-copy { min-width: 0; }.sku-label { display: block; color: #667085; font-size: 11px; }.sku-code { display: block; margin-top: 2px; color: #12233f; font-size: 16px; font-weight: 700; overflow-wrap: anywhere; }.pallet-meta { display: block; margin-top: 5px; color: #667085; font-size: 14px; line-height: 20px; }
.quantity-stepper { display: grid; grid-template-columns: 38px 56px 38px; overflow: hidden; border: 1px solid #b8c6d8; border-radius: 8px; background: #fff; }.quantity-stepper button,.quantity-stepper input { box-sizing: border-box; height: 40px; margin: 0; border: 0; border-radius: 0; text-align: center; background: #fff; }.quantity-stepper button { color: #155eef; font-size: 20px; line-height: 40px; }.quantity-stepper input { width: 56px; border-right: 1px solid #dce4ed; border-left: 1px solid #dce4ed; color: #12233f; font-size: 15px; }.difference-field { grid-column: 1 / -1; margin-top: 0; }.pallet-hint { display: block; margin-top: 10px; color: #155eef; font-size: 11px; text-align: center; }
.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-code { color: #12233f; font-size: 19px; font-weight: 700; overflow-wrap: anywhere; }.text-button { min-width: 58px; height: 40px; border: 0; color: #155eef; font-size: 14px; line-height: 40px; background: transparent; }.empty { padding: 18px 0 8px; color: #98a2b3; font-size: 14px; text-align: center; }
.palletization-page :deep(.scan-target-value) { font-size: 17px; }
</style>