This commit is contained in:
2026-10-12 13:43:37 +08:00
parent 9c3855cec2
commit aebe4f9528
21 changed files with 482 additions and 141 deletions
+25 -10
View File
@@ -1,12 +1,12 @@
<template>
<view>
<view class="work-card">
<text class="card-title">箱与箱合并</text>
<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 class="secondary" @tap="choose('EXISTING')">{{ mode === 'EXISTING' ? '✓ ' : '' }}使用已有箱子</button></view>
<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 === 'EXISTING' || (mode === 'NEW' && !plan)">
<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)">{{ mode === 'EXISTING' && sources.length ? '确认更换来源箱' : '确认来源箱' }}</button>
<view v-if="mode === 'EXISTING'" class="confirmed-sources">
@@ -36,7 +36,8 @@
</view>
<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>
<view v-for="item in box.items" :key="item.skuCode" class="source-quantity"><text class="meta-line">{{ item.skuCode }} · 可扣 {{ item.quantity }} 件</text><input class="small-input" type="number" :value="deduction(box.cartonMark,item.skuCode)" placeholder="扣除数量" @input="setDeduction(box.cartonMark,item.skuCode,$event.detail.value)" /></view>
<template v-if="!plan"><button class="secondary" @tap="printSource(box.cartonMark)">补打包裹/箱标签</button><ReturnLabelConfirmation ref="sourceLabels" :carton-mark="box.cartonMark" /></template>
<view v-for="item in box.items" :key="item.skuCode" class="source-quantity"><text class="meta-line">{{ item.skuCode }} · 可扣 {{ item.quantity }} 件</text><input class="small-input" type="number" :disabled="plan?.mode === 'SKU'" :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>
@@ -45,16 +46,18 @@
<script>
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { scanReturnCarton, completePackingPlan, mergeReturnBoxes } from '@/api/return-packing.js'
import ReturnLabelConfirmation from '@/components/ReturnLabelConfirmation.vue'
import { scanReturnCarton, completePackingPlan, mergeReturnBoxes, getReturnCartonLabel } from '@/api/return-packing.js'
import { updateWarehouseWork, warehouseWork, warehouseIdempotencyKey, releaseWarehouseKey } from '@/stores/warehouse-work.js'
export default {
components: { ScanTargetInput },
components: { ScanTargetInput, ReturnLabelConfirmation },
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: { 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.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.sources[0]?.cartonMark === this.sourceCode.trim()) } },
computed: { 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.mode !== 'SKU' || this.plan.sourceCartonMarks.every(mark => this.sources.some(box => box.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.sources[0]?.cartonMark === this.sourceCode.trim()) } },
async mounted() {
if (!this.plan && this.initialSource) { this.choose('EXISTING'); this.sources = [this.initialSource]; this.sourceCode = this.initialSource.cartonMark; this.active = 'target'; return }
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) {
@@ -65,7 +68,7 @@ export default {
} 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=mode === 'NEW' && this.plan ? (this.plan.sources || []).map(box => ({...box,customerId:this.plan.customerId})) : current ? [current] : []; this.target=null; this.targetCode=''; this.sourceCode=mode === 'EXISTING' && current ? current.cartonMark : ''; this.deductions={}; this.active=mode === 'NEW' && this.plan ? '' : mode === 'EXISTING' && current ? 'target' : 'source' },
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) {
@@ -85,6 +88,7 @@ export default {
if (this.mode === 'EXISTING') {
this.sources = [box]; this.sourceCode = mark; this.deductions = {}; this.target = null; this.targetCode = ''
} else { this.sources.push(box); this.sourceCode = '' }
if (this.plan?.mode === 'SKU') 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'
})
},
@@ -111,7 +115,18 @@ export default {
this.target = box; this.save()
})
},
acceptScan(value) { if (!this.active) return; return this.active === 'source' ? this.scanSource(value) : this.scanTarget(value) },
acceptScan(value) { if (!this.plan && this.sources.some(box => box.cartonMark === String(value).trim())) { for (const label of [].concat(this.$refs?.sourceLabels || [])) label.acceptScan(value); return } 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) {
const box = this.sources.find(item => item.cartonMark === mark)
@@ -0,0 +1,31 @@
<template>
<view class="label-confirmation">
<text v-if="confirmed">标签已扫码确认,可按该包裹/箱号出库</text>
<template v-else>
<ScanTargetInput v-model="code" label="确认已贴标签" placeholder="贴好后扫描此包裹/箱标签" @submit="confirm" />
<button class="secondary" :disabled="busy || !code.trim()" @tap="confirm(code)">确认贴标</button>
<text v-if="error">{{ error }}</text>
</template>
</view>
</template>
<script>
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { confirmReturnLabel } from '@/api/return-packing.js'
export default {
components: { ScanTargetInput },
props: { cartonMark: { type: String, required: true } },
data() { return { code: '', confirmed: false, busy: false, error: '' } },
methods: {
acceptScan(value) { if (String(value).trim() === this.cartonMark) return this.confirm(value) },
async confirm(raw) {
if (this.busy || this.confirmed) return
const mark = String(raw?.detail?.value ?? raw ?? this.code).trim()
if (mark !== this.cartonMark) { this.error = '请扫描当前包裹/箱的标签'; return }
this.busy = true; this.error = ''
try { await confirmReturnLabel(mark); this.confirmed = true }
catch (error) { this.error = error?.message || '贴标确认失败,请重试' }
finally { this.busy = false }
}
}
}
</script>
+41
View File
@@ -0,0 +1,41 @@
<template>
<view class="section-gap">
<text class="card-title">换标商品独立合箱</text><text class="hint">部分换标须装入独立新箱;来源余量留在原包裹/箱。所有来源登记成功后才可生成新箱。</text>
<view v-for="row in deductions" :key="`${row.cartonMark}:${row.skuCode}`" class="meta-line">{{ row.cartonMark }} · {{ row.skuCode }} · {{ row.quantity }} 件</view>
<template v-if="!facts.length"><view class="dimensions"><label v-for="field in fields" :key="field.key"><text>{{ field.label }}</text><input v-model="dimensions[field.key]" type="digit" placeholder="请输入" /></label></view><button class="primary" :disabled="!ready" @tap="pack">{{ busy ? '生成中…' : '生成独立新箱及标签' }}</button></template>
<view v-for="fact in facts" :key="fact.cartonMark"><text class="business-code">新箱:{{ fact.cartonMark }} · {{ fact.quantity }} 件</text><button class="secondary" @tap="print(fact.label)">查看 / 打印新箱识别标签</button><text class="hint">此标签用于识别仓库包裹/箱;商品目标 SKU 标签请按上方换标明细核对。</text></view>
</view>
</template>
<script>
import { packReturnRelabelCartons } from '@/api/relabel.js'
import { requiresReturnPack, returnDeductions, returnPackFacts, returnSelections } from '@/utils/return-relabel.js'
import { warehouseIdempotencyKey, releaseWarehouseKey } from '@/stores/warehouse-work.js'
export default {
props: { task: { type: Object, required: true }, locked: { type: Boolean, default: false } }, emits: ['saved', 'error', 'busy', 'refresh'],
data() { return { busy: false, dimensions: { length: '', width: '', height: '', weight: '' }, fields: [{ key: 'length', label: '长 cm' }, { key: 'width', label: '宽 cm' }, { key: 'height', label: '高 cm' }, { key: 'weight', label: '重量 kg' }] } },
computed: {
deductions() { return returnDeductions(this.task) }, facts() { return returnPackFacts(this.task) },
ready() { const items = returnSelections(this.task); return !this.locked && !this.busy && requiresReturnPack(this.task) && !this.facts.length && items.length === this.task.items.length && items.length > 0 && items.every(item => Number(item.relabelledPackages) === Number(item.expectedPackages) && !Number(item.skippedPackages) && !Number(item.exceptionPackages)) && this.deductions.every(row => row.cartonMark && row.skuCode && Number.isSafeInteger(row.quantity) && row.quantity > 0) && this.fields.every(field => Number.isFinite(Number(this.dimensions[field.key])) && Number(this.dimensions[field.key]) > 0) }
},
methods: {
async pack() {
if (!this.ready) return
const payload = { sourceCartonMarks: [...new Set(this.deductions.map(row => row.cartonMark))], targetCartonMark: null, sourceDeductions: this.deductions, ...Object.fromEntries(this.fields.map(field => [field.key, Number(this.dimensions[field.key])])) }
const key = warehouseIdempotencyKey('relabel', 'return-pack', { relabelNo: this.task.relabelNo, round: this.task.reviewedAt || 'INITIAL', ...payload })
this.busy = true; this.$emit('busy', true)
try { const result = await packReturnRelabelCartons(this.task.relabelNo, { ...payload, idempotencyKey: key.value }); releaseWarehouseKey('relabel', key.signature); this.$emit('saved', result) } catch (error) { if (['409', 'A0409', 'B0409'].includes(String(error?.code || ''))) { releaseWarehouseKey('relabel', key.signature); this.$emit('refresh') } this.$emit('error', error) } finally { this.busy = false; this.$emit('busy', false) }
},
print(label) {
// #ifdef H5
if (!label?.printableHtml) return this.$emit('error', new Error('服务端未返回可打印的新箱标签'))
const tab = window.open('', '_blank'); if (!tab) return this.$emit('error', new Error('请允许打开标签打印窗口'))
tab.document.write(label.printableHtml); tab.document.close(); tab.focus(); tab.print()
// #endif
// #ifndef H5
this.$emit('error', new Error('当前设备未配置打印服务,生成标签不代表打印完成'))
// #endif
}
}
}
</script>
<style scoped>.dimensions{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;margin:12px 0}.dimensions input{border:1px solid #cbd8e8;padding:8px}.hint,.meta-line{display:block;margin:8px 0}</style>