Files
pda-overseas-uniapp/src/components/ReturnBoxMergeWork.vue
T

164 lines
15 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>
<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>