182 lines
20 KiB
Vue
182 lines
20 KiB
Vue
<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>
|