164 lines
15 KiB
Vue
164 lines
15 KiB
Vue
<template>
|
||
<view>
|
||
<view class="work-card">
|
||
<text class="card-title">包裹/箱合箱</text>
|
||
<text v-if="plan" class="business-code">{{ plan.planNo }}</text>
|
||
<text v-if="plan && plan.remark" class="hint plan-remark">合箱备注:{{ plan.remark }}</text>
|
||
<view class="choice-row"><button class="secondary" @tap="choose('NEW')">{{ mode === 'NEW' ? '✓ ' : '' }}新增箱子</button><button v-if="!plan || plan.mode !== 'SKU'" class="secondary" @tap="choose('EXISTING')">{{ mode === 'EXISTING' ? '✓ ' : '' }}使用已有箱子</button></view>
|
||
<text class="hint">{{ mode === 'NEW' ? '从下方来源箱选择 SKU 和扣除数量,完成后生成新箱唛。' : '扫描来源箱,再扫描另一只箱作为目标;目标箱原有库存不扣除。' }}</text>
|
||
<template v-if="mode">
|
||
<ScanTargetInput name="source" v-model="sourceCode" label="来源箱唛" placeholder="扫描来源箱唛" :active="active === 'source'" @activate="active = 'source'" @submit="scanSource" />
|
||
<button class="secondary" :disabled="busy || !mode || !sourceCode.trim()" @tap="scanSource(sourceCode)">{{ !plan && mode === 'EXISTING' && sources.length ? '确认更换来源箱' : '确认来源箱' }}</button>
|
||
<view v-if="plan" class="plan-boxes section-gap">
|
||
<text class="card-title">计划需扫描箱子</text>
|
||
<view v-for="box in plannedSourceRows" :key="box.cartonMark" class="plan-box">
|
||
<text class="business-code">{{ box.cartonMark }} · {{ box.participation === 'FULL' ? '整箱' : box.participation === 'PARTIAL' ? '部分' : '计划来源' }}</text>
|
||
<text class="meta-line">来源退货单:{{ box.sourceReturnNo || '—' }}</text>
|
||
<text v-for="item in box.items || []" :key="item.skuCode" class="meta-line">{{ item.skuCode }} · {{ item.quantity }} 件</text>
|
||
<text v-for="item in box.originalItems || []" :key="'original-' + item.skuCode" class="meta-line">{{ item.skuCode }} · 原有 {{ item.quantity }} 件 · 计划后剩余 {{ target?.cartonMark === box.cartonMark ? item.quantity : Math.max(0, item.quantity - (box.items || []).filter(row => row.skuCode === item.skuCode).reduce((sum, row) => sum + Number(row.quantity), 0)) }} 件</text>
|
||
<text class="hint">{{ target?.cartonMark === box.cartonMark ? '目标箱 · 保留原有库存' : sources.some(source => source.cartonMark === box.cartonMark) ? '已扫描确认' : '待扫描' }}</text>
|
||
</view>
|
||
</view>
|
||
<view v-if="mode === 'EXISTING'" class="confirmed-sources">
|
||
<view v-for="box in sources" :key="box.cartonMark" class="item-card section-gap">
|
||
<view class="row-head"><text class="business-code">{{ box.cartonMark }}</text><button class="link-button" :disabled="busy" @tap="active = 'source'">更换来源箱</button></view>
|
||
<text v-for="item in box.items || []" :key="item.skuCode" class="meta-line">{{ item.skuCode }} · {{ item.quantity }} 件</text>
|
||
<text class="hint">来源箱已确认</text>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
</view>
|
||
<view v-if="mode" class="work-card">
|
||
<text class="card-title">{{ mode === 'NEW' ? '新增目标箱' : '已有目标箱' }}</text>
|
||
<template v-if="mode === 'EXISTING'">
|
||
<ScanTargetInput name="target" v-model="targetCode" label="目标箱唛" placeholder="扫描另一只箱作为目标箱" :active="active === 'target'" @activate="active = 'target'" @submit="scanTarget" />
|
||
<button class="secondary" :disabled="busy || !sources.length || !targetCode.trim()" @tap="scanTarget(targetCode)">确认目标箱</button>
|
||
<view v-if="target" class="item-card section-gap"><text class="business-code">{{ target.cartonMark }}</text><text v-for="item in target.items" :key="item.skuCode" class="meta-line">{{ item.skuCode }} · 原有 {{ item.quantity }} 件(不扣除)</text></view>
|
||
</template>
|
||
<template v-else><view class="dimensions"><label v-for="field in fields" :key="field.key"><text>{{ field.label }}</text><input class="small-input" type="digit" v-model="dimensions[field.key]" placeholder="请输入" /></label></view><text class="hint">体积:{{ volume }} m³</text></template>
|
||
<view v-for="box in sources" :key="box.cartonMark" class="item-card section-gap">
|
||
<view class="row-head"><text class="business-code">{{ box.cartonMark }}</text><button v-if="!plan" class="link-button" @tap="remove(box.cartonMark)">移除</button></view>
|
||
<button v-if="!plan" class="secondary" @tap="printSource(box.cartonMark)">补打包裹/箱标签</button>
|
||
<view v-for="item in box.items" :key="item.skuCode" class="source-quantity"><text class="meta-line">{{ item.skuCode }} · {{ plan ? '计划取出' : '可扣' }} {{ item.quantity }} 件</text><input class="small-input" type="number" :disabled="!!plan" :value="deduction(box.cartonMark,item.skuCode)" placeholder="扣除数量" @input="setDeduction(box.cartonMark,item.skuCode,$event.detail.value)" /></view>
|
||
</view>
|
||
<button class="primary" :disabled="!canComplete" @tap="complete">{{ busy ? '处理中…' : '确认合箱' }}</button>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||
import { scanReturnCarton, completePackingPlan, mergeReturnBoxes, getReturnCartonLabel } from '@/api/return-packing.js'
|
||
import { updateWarehouseWork, warehouseWork, warehouseIdempotencyKey } from '@/stores/warehouse-work.js'
|
||
export default {
|
||
components: { ScanTargetInput },
|
||
props: { plan: { type: Object, default: null }, initialSource: { type: Object, default: null } },
|
||
emits: ['completed', 'error', 'busy-change'],
|
||
data() { return { mode:'', dimensions:{length:'',width:'',height:'',weight:''}, fields:[{key:'length',label:'长 cm'},{key:'width',label:'宽 cm'},{key:'height',label:'高 cm'},{key:'weight',label:'重量 kg'}], sourceCode: '', targetCode: '', sources: [], target: null, deductions: {}, active: 'source', busy: false } },
|
||
computed: { plannedSourceRows() { const listed = Array.isArray(this.plan?.sources) ? this.plan.sources : []; const marks = this.plan?.sourceCartonMarks?.length ? this.plan.sourceCartonMarks : listed.map(box => box.cartonMark).filter(Boolean); return marks.map(mark => listed.find(box => box.cartonMark === mark) || { cartonMark: mark, items: [] }) }, volume() { const value = Number(this.dimensions.length) * Number(this.dimensions.width) * Number(this.dimensions.height) / 1000000; return Number.isFinite(value) && value > 0 ? value.toFixed(6) : '0.000000' }, canComplete() { return !this.busy && (!this.plan || (this.plan.sourceCartonMarks || this.plan.sources.map(box => box.cartonMark)).every(mark => this.sources.some(box => box.cartonMark === mark) || this.target?.cartonMark === mark)) && this.plannedItems().length > 0 && (this.mode === 'NEW' ? this.fields.every(f => Number(this.dimensions[f.key]) > 0) : !!this.target && this.target.cartonMark === this.targetCode.trim() && (!!this.plan || this.sources[0]?.cartonMark === this.sourceCode.trim())) } },
|
||
async mounted() {
|
||
if (!this.plan && this.initialSource) { this.choose('NEW'); this.sources = [this.initialSource]; this.sourceCode = ''; this.active = 'source'; return }
|
||
this.choose('NEW')
|
||
this.targetCode = warehouseWork.returnPacking.targetCode || ''
|
||
const marks = this.plan?.sourceCartonMarks || warehouseWork.returnPacking.sourceCartonMarks || []
|
||
if (this.plan) {
|
||
// Reserved plan sources are read from the reviewed plan, never from the free-stock scan endpoint.
|
||
this.sources = []
|
||
this.sourceCode = ''
|
||
this.active = 'source'
|
||
} else for (const mark of marks) await this.scanSource(mark)
|
||
},
|
||
methods: {
|
||
choose(mode) { if(this.busy || this.mode === mode) return; const current = !this.plan ? (this.sources[0] || this.initialSource) : null; this.mode=mode; this.sources=current ? [current] : []; this.target=null; this.targetCode=''; this.sourceCode=mode === 'EXISTING' && current ? current.cartonMark : ''; this.deductions={}; this.active=mode === 'EXISTING' && current ? 'target' : 'source' },
|
||
save() { updateWarehouseWork('returnPacking', { sourceCartonMarks: this.sources.map(box => box.cartonMark), targetCode: this.targetCode }) },
|
||
remove(mark) { this.sources = this.sources.filter(box => box.cartonMark !== mark); this.save() },
|
||
async scanSource(raw) {
|
||
if (!this.mode || this.busy) return
|
||
const mark = String(raw || this.sourceCode).trim(); if (!mark) return
|
||
await this.run(async () => {
|
||
if (this.sources.some(box => box.cartonMark === mark)) {
|
||
if (this.mode === 'EXISTING') { this.sourceCode = mark; this.active = 'target'; return }
|
||
throw new Error('该来源箱已添加')
|
||
}
|
||
if ((this.plan || this.mode !== 'EXISTING') && this.target?.cartonMark === mark) throw new Error('来源箱不能与目标箱相同')
|
||
const planned = this.plan?.sources?.find(box => box.cartonMark === mark)
|
||
if(this.plan && !planned) throw new Error('该来源箱不在合箱计划中')
|
||
const box = planned ? {...planned,customerId:this.plan.customerId} : await scanReturnCarton(mark)
|
||
const customer = this.sources[0]?.customerId || this.target?.customerId
|
||
if (customer && customer !== box.customerId) throw new Error('不同客户的箱不能合并')
|
||
if (this.mode === 'EXISTING' && !this.plan) {
|
||
this.sources = [box]; this.sourceCode = mark; this.deductions = {}; this.target = null; this.targetCode = ''
|
||
} else { this.sources.push(box); this.sourceCode = '' }
|
||
if (this.plan) this.deductions = { ...this.deductions, [mark]: Object.fromEntries((box.items || []).map(item => [item.skuCode, Number(item.quantity)])) }
|
||
this.save(); this.active = this.mode === 'NEW' ? 'source' : 'target'
|
||
})
|
||
},
|
||
async scanTarget(raw) {
|
||
if (this.busy || !this.sources.length || this.mode !== 'EXISTING') return
|
||
const mark = String(raw || this.targetCode).trim(); if (!mark) return
|
||
this.targetCode = mark; this.target = null
|
||
await this.run(async () => {
|
||
if(this.sources.some(box => box.cartonMark === mark)) throw new Error('来源箱和目标箱不能相同,请扫描另一只箱')
|
||
const plannedSource = this.plan?.sources?.find(box => box.cartonMark === mark)
|
||
if (plannedSource) {
|
||
// A reviewed BOX plan may nominate one of its original boxes as the
|
||
// target. The remaining planned boxes are the source boxes.
|
||
this.target = {...plannedSource,customerId:this.plan.customerId}
|
||
this.targetCode = mark
|
||
this.deductions = Object.fromEntries(this.sources.map(box => [box.cartonMark, Object.fromEntries((box.items || []).map(item => [item.skuCode, Number(item.quantity)]))]))
|
||
this.save()
|
||
return
|
||
}
|
||
if (this.sources.some(box => box.cartonMark === mark)) throw new Error('目标箱不能与来源箱相同')
|
||
const box = await scanReturnCarton(mark)
|
||
const customer = this.plan?.customerId || this.sources[0]?.customerId
|
||
if (customer && customer !== box.customerId) throw new Error('目标箱与来源箱必须属于同一客户')
|
||
this.target = box; this.save()
|
||
})
|
||
},
|
||
acceptScan(value) { if (!this.active) return; return this.active === 'source' ? this.scanSource(value) : this.scanTarget(value) },
|
||
async printSource(mark) {
|
||
// #ifdef H5
|
||
const tab = window.open('', '_blank')
|
||
if (!tab) { this.$emit('error', new Error('请允许打开标签打印窗口')); return }
|
||
try { const label = await getReturnCartonLabel(mark); tab.document.write(label.printableHtml); tab.document.close(); tab.focus(); tab.print() }
|
||
catch (error) { tab.close(); this.$emit('error', error) }
|
||
// #endif
|
||
// #ifndef H5
|
||
this.$emit('error', new Error('当前设备未配置打印服务'))
|
||
// #endif
|
||
},
|
||
deduction(mark, sku) { return this.deductions?.[mark]?.[sku] || '' },
|
||
setDeduction(mark, sku, raw) {
|
||
if (this.plan) return
|
||
const box = this.sources.find(item => item.cartonMark === mark)
|
||
const max = Number(box?.items?.find(item => item.skuCode === sku)?.quantity || 0)
|
||
const value = Math.min(max, Math.max(0, Math.floor(Number(raw) || 0)))
|
||
this.deductions = { ...this.deductions, [mark]: { ...(this.deductions[mark] || {}), [sku]: value } }
|
||
},
|
||
plannedItems() { return this.sources.flatMap(box => (box.items || []).map(item => ({ skuCode: item.skuCode, quantity: Number(this.deduction(box.cartonMark, item.skuCode)) || 0 }))).filter(item => item.quantity > 0) },
|
||
async complete() {
|
||
if (!this.canComplete) return
|
||
const sourceDeductions=this.sources.flatMap(box => (box.items || []).map(item => ({cartonMark:box.cartonMark,skuCode:item.skuCode,quantity:Number(this.deduction(box.cartonMark,item.skuCode))||0}))).filter(item=>item.quantity>0)
|
||
const totals={}; for(const item of sourceDeductions) totals[item.skuCode]=(totals[item.skuCode]||0)+item.quantity
|
||
const items=Object.entries(totals).map(([skuCode,quantity])=>({skuCode,quantity}))
|
||
const box={items,...(this.mode==='NEW'?Object.fromEntries(this.fields.map(f=>[f.key,Number(this.dimensions[f.key])])):{targetCartonMark:this.target.cartonMark})}
|
||
const expectedSourceQuantities = this.sources.flatMap(source => (source.items || []).map(item => ({ cartonMark: source.cartonMark, skuCode: item.skuCode, quantity: Number(item.quantity) })))
|
||
const payload = { sourceCartonMarks: this.sources.map(box => box.cartonMark), targetCartonMark: this.target?.cartonMark, sourceDeductions, expectedSourceQuantities, ...box }
|
||
const key = warehouseIdempotencyKey('returnPacking', 'box-merge', { ...payload, planNo: this.plan?.planNo })
|
||
await this.run(async () => {
|
||
const result = this.plan ? await completePackingPlan(this.plan.planNo, { boxes: [box], sourceDeductions, idempotencyKey: key.value }) : await mergeReturnBoxes({ ...payload, idempotencyKey: key.value })
|
||
this.$emit('completed', { ...result, targetMode: this.mode })
|
||
})
|
||
},
|
||
async run(work) { this.busy = true; this.$emit('busy-change', true); try { await work() } catch (error) { this.$emit('error', error) } finally { this.busy = false; this.$emit('busy-change', false) } }
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
@import '../pages/warehouse/operations.scss';
|
||
.business-code{font-size:13px}.item-card{padding:8px}.secondary,.primary{min-height:36px}.source-quantity{display:flex;align-items:center;justify-content:space-between;gap:8px}.small-input{width:90px;height:30px;padding:0 6px;border:1px solid #cbd8e8;border-radius:6px;text-align:center;font-size:11px}.plan-remark{display:block;color:#315985;font-weight:600}.choice-row{display:flex;gap:8px}.choice-row button{flex:1}.dimensions{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}
|
||
.dimensions{gap:8px 12px;padding:8px 0}.dimensions label{display:flex;align-items:center;gap:6px;min-width:0}.dimensions label text{flex-shrink:0;width:44px;font-size:11px;color:#617287}.dimensions .small-input{box-sizing:border-box;width:100%;min-width:0;height:30px;background:#f8fbff;font-size:12px}.choice-row button{height:34px;min-height:34px;line-height:32px;margin:4px 0;padding:0 8px;font-size:11px}.plan-box{padding:7px 0;border-bottom:1px solid #e6edf5}.source-quantity .meta-line{flex:1;min-width:0;overflow-wrap:anywhere}.source-quantity .small-input{flex-shrink:0;width:72px}.primary{height:36px;line-height:36px;padding:0 10px}.business-code{overflow-wrap:anywhere}
|
||
</style>
|