提交价格功能绑定

This commit is contained in:
wuweihua
2026-09-07 10:41:21 +08:00
parent 7204497a65
commit 10352fca6d
12 changed files with 578 additions and 105 deletions
@@ -57,6 +57,11 @@ const props = defineProps({
updateSupportLabel: {
type: String,
default: '是否更新已经存在的数据'
},
// 打开时是否默认勾选“更新已存在”
defaultUpdateSupport: {
type: Boolean,
default: false
}
})
@@ -77,7 +82,7 @@ const templateUrl = computed(() => !!props.templateAction)
// 打开对话框(供父组件通过 ref 调用)
function open() {
updateSupport.value = false
updateSupport.value = props.defaultUpdateSupport
isUploading.value = false
visible.value = true
nextTick(() => {
@@ -5,7 +5,7 @@
:closable="false"
show-icon
class="mb16"
title="Excel 导入:上传威狮B2C小包价表.xlsx。仅入库产品目录 + 各产品 sheet 的国家尺寸/尾程/时效;不导入运费、挂号费、退件重派、申报说明、赔偿标准。"
title="Excel 导入:上传威狮B2C小包价表.xlsx。入库产品目录 + 各产品 sheet 的国家×重量段(运费/挂号费/尺寸/尾程/时效);不导入退件重派、申报说明、赔偿标准。"
/>
<basic-table
@@ -47,7 +47,7 @@
</template>
<template #col-action="{ row }">
<el-button link type="primary" icon="View" @click="openCountries(row)" v-hasPermi="['price:product:query']">国家明细</el-button>
<el-button link type="primary" icon="View" @click="openCountries(row)" v-hasPermi="['price:product:query']">产品明细</el-button>
<el-button link type="primary" icon="Edit" @click="handleUpdate(row)" v-hasPermi="['price:product:edit']">编辑</el-button>
<el-button link type="primary" icon="Delete" @click="handleDelete(row.productId)" v-hasPermi="['price:product:remove']">删除</el-button>
</template>
@@ -122,7 +122,7 @@
</template>
</el-dialog>
<el-drawer v-model="countryOpen" size="720px" :title="countryTitle" append-to-body>
<el-drawer v-model="countryOpen" size="920px" :title="countryTitle" append-to-body>
<el-table v-loading="countryLoading" :data="countryList" border empty-text="暂无国家明细">
<el-table-column label="国家" prop="countryName" width="90" show-overflow-tooltip />
<el-table-column label="ISO2" prop="countryCode" width="60" align="center" />
@@ -132,6 +132,8 @@
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="运费(RMB/KG)" prop="freightPrice" width="110" align="right" />
<el-table-column label="挂号费(RMB/票)" prop="registrationFee" width="120" align="right" />
<el-table-column label="尺寸限制" prop="sizeLimit" min-width="140">
<template #default="{ row }">
<span class="pre-wrap">{{ row.sizeLimit }}</span>
@@ -150,7 +152,8 @@
ref="importRef"
title="产品 Excel 导入"
action="/price/product/importData"
update-support-label="是否更新已经存在的产品(含国家明细覆盖)"
:default-update-support="true"
update-support-label="是否更新已经存在的产品主数据(国家/运费明细每次导入都会覆盖)"
@success="() => tableRef.refresh()"
/>
</div>
+197 -45
View File
@@ -23,7 +23,7 @@
@click="form.setType = 'FIXED'"
>
<div class="t-title">FIXED 固定价</div>
<div class="t-desc">每个产品×国家×重量段独立配置运费单价 + 挂号费,覆盖默认价</div>
<div class="t-desc">可不填明细(走产品默认价);有明细则按产品×国家×重量段覆盖运费+挂号费</div>
</div>
<div
class="type-card"
@@ -77,7 +77,7 @@
:closable="false"
show-icon
class="mb12"
title="FIXED 模式:每个产品 × 国家 × 重量段独立配置运费单价(RMB/KG)+ 挂号费(RMB/票)。命中时直接取该固定价。"
title="FIXED 模式:可不填明细(计费取 bms_product_country 默认运费/挂号费);有明细时按产品×国家×重量段覆盖固定价。"
/>
<el-alert
v-else
@@ -88,24 +88,57 @@
title="DISCOUNT 模式:可不填明细(全部产品适用默认折扣),或按产品限定折扣范围。"
/>
<el-table :data="form.details" border empty-text="暂无明细,点击添加明细行">
<el-table-column label="产品" min-width="150">
<el-table-column label="产品" min-width="180">
<template #default="{ row }">
<el-select v-model="row.productCode" filterable allow-create placeholder="产品" @change="onProductChange(row)">
<el-select
v-model="row.productCode"
filterable
clearable
placeholder="选择产品"
style="width: 100%"
@change="onProductChange(row)"
>
<el-option v-for="p in productOptions" :key="p.value" :label="p.label" :value="p.value" />
</el-select>
</template>
</el-table-column>
<el-table-column v-if="form.setType === 'FIXED'" label="国家" min-width="130">
<el-table-column v-if="form.setType === 'FIXED'" label="国家" min-width="140">
<template #default="{ row }">
<el-select v-model="row.countryCode" filterable allow-create placeholder="国家" @change="onCountryChange(row)">
<el-option v-for="c in countryOptions" :key="c.value" :label="c.label" :value="c.value" />
<el-select
v-model="row._countryKey"
filterable
clearable
placeholder="选择国家"
style="width: 100%"
:disabled="!row.productCode"
@change="onCountryChange(row)"
>
<el-option
v-for="c in countryOptionsOf(row)"
:key="c.key"
:label="c.label"
:value="c.key"
/>
</el-select>
</template>
</el-table-column>
<el-table-column v-if="form.setType === 'FIXED'" label="重量段" min-width="150">
<template #default="{ row }">
<el-select v-model="row.weightLabel" filterable allow-create placeholder="重量段">
<el-option v-for="w in weightOptions" :key="w.value" :label="w.label" :value="w.value" />
<el-select
v-model="row.weightLabel"
filterable
clearable
placeholder="选择重量段"
style="width: 100%"
:disabled="!row._countryKey"
@change="onWeightChange(row)"
>
<el-option
v-for="w in weightOptionsOf(row)"
:key="w.value"
:label="w.label"
:value="w.value"
/>
</el-select>
</template>
</el-table-column>
@@ -212,6 +245,7 @@
<script setup name="PriceSetCreate">
import { getPriceSet, savePriceSet, validatePriceSet } from '@/api/price/set'
import { listProduct, listProductCountries } from '@/api/price/product'
const { proxy } = getCurrentInstance()
const route = useRoute()
@@ -219,25 +253,9 @@ const active = ref(0)
const saving = ref(false)
const formRef = ref()
const checkItems = ref([])
const productOptions = [
{ value: 'WSP037', label: 'WSP037 球衣' },
{ value: 'WSP038', label: 'WSP038 普货' },
{ value: 'WSP034', label: 'WSP034 带电' },
{ value: 'WSP035', label: 'WSP035 敏感' },
{ value: 'WSP066', label: 'WSP066 鞋盒' }
]
const countryOptions = [
{ value: 'DE', label: '德国 DE' },
{ value: 'FR', label: '法国 FR' },
{ value: 'IT', label: '意大利 IT' },
{ value: 'ES', label: '西班牙 ES' }
]
const weightOptions = [
{ value: '0.01≤W≤0.3', label: '0.01≤W≤0.3' },
{ value: '0.3<W≤1', label: '0.3<W≤1' },
{ value: '1<W≤2', label: '1<W≤2' }
]
const productOptions = ref([])
/** productCode -> bms_product_country rows */
const countryCache = reactive({})
const form = reactive({
priceSetId: undefined,
@@ -257,13 +275,93 @@ const rules = {
effectiveDate: [{ required: true, message: '生效日期不能为空', trigger: 'change' }]
}
function trimNum(v) {
if (v == null || v === '') return ''
const n = Number(v)
if (Number.isNaN(n)) return String(v)
return String(parseFloat(n.toFixed(6)))
}
function formatWeightLabel(min, max) {
return `${trimNum(min)}≤W≤${trimNum(max)}`
}
function countryKeyOf(row) {
if (!row) return ''
if (row.countryCode) return row.countryCode
if (row.countryName) return 'N:' + row.countryName
return ''
}
function matchCountry(detail, item) {
if (detail.countryCode && item.countryCode) {
return detail.countryCode === item.countryCode
}
return detail.countryName && item.countryName === detail.countryName
}
function loadProducts() {
return listProduct({ status: '0', pageNum: 1, pageSize: 1000 }).then(res => {
const rows = res.rows || []
productOptions.value = rows.map(p => ({
value: p.productCode,
label: `${p.productCode} ${p.productName || ''}`.trim(),
name: p.productName
}))
})
}
function ensureCountries(productCode) {
if (!productCode) return Promise.resolve([])
if (countryCache[productCode]) return Promise.resolve(countryCache[productCode])
return listProductCountries(productCode).then(res => {
const rows = res.data || []
countryCache[productCode] = rows
return rows
})
}
function countryOptionsOf(row) {
const rows = countryCache[row.productCode] || []
const map = new Map()
rows.forEach(item => {
const key = countryKeyOf(item)
if (!key || map.has(key)) return
map.set(key, {
key,
code: item.countryCode || undefined,
name: item.countryName,
label: item.countryCode ? `${item.countryName} ${item.countryCode}` : item.countryName
})
})
return [...map.values()]
}
function weightOptionsOf(row) {
const rows = (countryCache[row.productCode] || []).filter(item => matchCountry(row, item))
const map = new Map()
rows.forEach(item => {
if (item.weightMin == null && item.weightMax == null) return
const value = formatWeightLabel(item.weightMin, item.weightMax)
if (map.has(value)) return
map.set(value, {
value,
label: value,
freightPrice: item.freightPrice,
registrationFee: item.registrationFee
})
})
return [...map.values()]
}
function emptyDetail() {
return {
productCode: undefined,
productName: undefined,
countryCode: form.setType === 'FIXED' ? 'DE' : undefined,
countryName: form.setType === 'FIXED' ? '德国' : undefined,
weightLabel: form.setType === 'FIXED' ? '0.3<W≤1' : undefined,
countryCode: undefined,
countryName: undefined,
_countryKey: undefined,
weightLabel: undefined,
fvValue: undefined,
fvRegistrationFee: undefined,
discountValue: undefined
@@ -288,22 +386,74 @@ function addCustomerRow() {
form.customers.push(emptyCustomer())
}
function clearCascadeAfterProduct(row) {
row.countryCode = undefined
row.countryName = undefined
row._countryKey = undefined
row.weightLabel = undefined
row.fvValue = undefined
row.fvRegistrationFee = undefined
}
function onProductChange(row) {
const found = productOptions.find(p => p.value === row.productCode)
row.productName = found ? found.label.replace(row.productCode + ' ', '') : row.productCode
const found = productOptions.value.find(p => p.value === row.productCode)
row.productName = found ? found.name : row.productCode
clearCascadeAfterProduct(row)
if (!row.productCode || form.setType !== 'FIXED') return
ensureCountries(row.productCode)
}
function onCountryChange(row) {
const found = countryOptions.find(c => c.value === row.countryCode)
row.countryName = found ? found.label.replace(' ' + row.countryCode, '') : row.countryCode
const opt = countryOptionsOf(row).find(c => c.key === row._countryKey)
row.countryCode = opt ? opt.code : undefined
row.countryName = opt ? opt.name : undefined
row.weightLabel = undefined
row.fvValue = undefined
row.fvRegistrationFee = undefined
}
function onWeightChange(row) {
const opt = weightOptionsOf(row).find(w => w.value === row.weightLabel)
if (!opt) return
if (opt.freightPrice != null) row.fvValue = Number(opt.freightPrice)
if (opt.registrationFee != null) row.fvRegistrationFee = Number(opt.registrationFee)
}
function hydrateDetailRow(d) {
const row = {
...d,
_countryKey: countryKeyOf(d)
}
// normalize historical weight labels to min≤W≤max when possible
if (row.productCode && form.setType === 'FIXED') {
ensureCountries(row.productCode).then(rows => {
if (!row.weightLabel) return
const hit = rows.find(item => {
if (!matchCountry(row, item)) return false
return formatWeightLabel(item.weightMin, item.weightMax) === row.weightLabel
|| row.weightLabel.includes(String(item.weightMin)) && row.weightLabel.includes(String(item.weightMax))
})
if (hit) {
row.weightLabel = formatWeightLabel(hit.weightMin, hit.weightMax)
row._countryKey = countryKeyOf(hit)
if (!row.countryCode) row.countryCode = hit.countryCode
if (!row.countryName) row.countryName = hit.countryName
}
})
}
return row
}
function buildPayload(submitFlag) {
const details = (form.details || []).filter(d => d.productCode).map(d => ({
...d,
productCode: d.productCode,
productName: d.productName,
countryCode: d.countryCode || undefined,
countryName: d.countryName || undefined,
weightLabel: d.weightLabel || undefined,
fvValue: d.fvValue,
fvRegistrationFee: d.fvRegistrationFee
fvRegistrationFee: d.fvRegistrationFee,
discountValue: d.discountValue
}))
const customers = (form.customers || [])
.filter(c => c.customerId)
@@ -392,7 +542,7 @@ function checkTag(result) {
}
function checkLabel(result) {
const map = { PASS: '? 通过', WARN: '? 待确认', FAIL: '? 失败', INFO: '? 提示' }
const map = { PASS: '通过', WARN: '待确认', FAIL: '失败', INFO: '提示' }
return map[result] || result
}
@@ -406,18 +556,20 @@ function loadExisting(id) {
form.effectiveDate = data.effectiveDate
form.expireDate = data.expireDate
form.priceSetNote = data.priceSetNote
form.details = (data.details || []).map(d => ({ ...d }))
form.details = (data.details || []).map(d => hydrateDetailRow(d))
form.customers = (data.customers || []).map(c => ({ ...c }))
})
}
onMounted(() => {
const id = route.query.priceSetId
if (id) {
loadExisting(id)
} else {
addDetailRow()
}
loadProducts().finally(() => {
const id = route.query.priceSetId
if (id) {
loadExisting(id)
} else {
addDetailRow()
}
})
})
</script>
+159 -14
View File
@@ -126,21 +126,44 @@
<el-dialog :title="detailTitle" v-model="detailOpen" width="560px" append-to-body>
<el-form ref="detailFormRef" :model="detailForm" :rules="detailRules" label-width="120px">
<el-form-item label="产品代码" prop="productCode">
<el-input v-model="detailForm.productCode" maxlength="10" />
</el-form-item>
<el-form-item label="产品名称" prop="productName">
<el-input v-model="detailForm.productName" maxlength="50" />
<el-form-item label="产品" prop="productCode">
<el-select
v-model="detailForm.productCode"
filterable
clearable
placeholder="选择产品"
style="width: 100%"
@change="onDetailProductChange"
>
<el-option v-for="p in productOptions" :key="p.value" :label="p.label" :value="p.value" />
</el-select>
</el-form-item>
<template v-if="set.setType === 'FIXED'">
<el-form-item label="国家代码" prop="countryCode">
<el-input v-model="detailForm.countryCode" maxlength="2" />
</el-form-item>
<el-form-item label="国家名称" prop="countryName">
<el-input v-model="detailForm.countryName" maxlength="50" />
<el-form-item label="国家" prop="countryCode">
<el-select
v-model="detailForm._countryKey"
filterable
clearable
placeholder="选择国家"
style="width: 100%"
:disabled="!detailForm.productCode"
@change="onDetailCountryChange"
>
<el-option v-for="c in detailCountryOptions" :key="c.key" :label="c.label" :value="c.key" />
</el-select>
</el-form-item>
<el-form-item label="重量段" prop="weightLabel">
<el-input v-model="detailForm.weightLabel" maxlength="50" />
<el-select
v-model="detailForm.weightLabel"
filterable
clearable
placeholder="选择重量段"
style="width: 100%"
:disabled="!detailForm._countryKey"
@change="onDetailWeightChange"
>
<el-option v-for="w in detailWeightOptions" :key="w.value" :label="w.label" :value="w.value" />
</el-select>
</el-form-item>
<el-form-item label="运费单价" prop="fvValue">
<el-input-number v-model="detailForm.fvValue" :min="0" :precision="4" controls-position="right" style="width: 100%" />
@@ -194,19 +217,22 @@ import {
addSetDetail, updateSetDetail, delSetDetail,
addSetCustomer, updateSetCustomer, delSetCustomer
} from '@/api/price/set'
import { listProduct, listProductCountries } from '@/api/price/product'
import { parseTime } from '@/utils/ruoyi'
const { proxy } = getCurrentInstance()
const route = useRoute()
const loading = ref(false)
const set = ref({})
const productOptions = ref([])
const countryCache = reactive({})
const detailOpen = ref(false)
const detailTitle = ref('')
const detailFormRef = ref()
const detailForm = ref({})
const detailRules = {
productCode: [{ required: true, message: '产品代码不能为空', trigger: 'blur' }]
productCode: [{ required: true, message: '请选择产品', trigger: 'change' }]
}
const customerOpen = ref(false)
@@ -218,6 +244,113 @@ const customerRules = {
prBegindate: [{ required: true, message: '生效日期不能为空', trigger: 'change' }]
}
function trimNum(v) {
if (v == null || v === '') return ''
const n = Number(v)
if (Number.isNaN(n)) return String(v)
return String(parseFloat(n.toFixed(6)))
}
function formatWeightLabel(min, max) {
return `${trimNum(min)}≤W≤${trimNum(max)}`
}
function countryKeyOf(row) {
if (!row) return ''
if (row.countryCode) return row.countryCode
if (row.countryName) return 'N:' + row.countryName
return ''
}
function matchCountry(detail, item) {
if (detail.countryCode && item.countryCode) return detail.countryCode === item.countryCode
return detail.countryName && item.countryName === detail.countryName
}
function ensureCountries(productCode) {
if (!productCode) return Promise.resolve([])
if (countryCache[productCode]) return Promise.resolve(countryCache[productCode])
return listProductCountries(productCode).then(res => {
const rows = res.data || []
countryCache[productCode] = rows
return rows
})
}
const detailCountryOptions = computed(() => {
const rows = countryCache[detailForm.value.productCode] || []
const map = new Map()
rows.forEach(item => {
const key = countryKeyOf(item)
if (!key || map.has(key)) return
map.set(key, {
key,
code: item.countryCode || undefined,
name: item.countryName,
label: item.countryCode ? `${item.countryName} ${item.countryCode}` : item.countryName
})
})
return [...map.values()]
})
const detailWeightOptions = computed(() => {
const form = detailForm.value
const rows = (countryCache[form.productCode] || []).filter(item => matchCountry(form, item))
const map = new Map()
rows.forEach(item => {
if (item.weightMin == null && item.weightMax == null) return
const value = formatWeightLabel(item.weightMin, item.weightMax)
if (map.has(value)) return
map.set(value, {
value,
label: value,
freightPrice: item.freightPrice,
registrationFee: item.registrationFee
})
})
return [...map.values()]
})
function loadProducts() {
listProduct({ status: '0', pageNum: 1, pageSize: 1000 }).then(res => {
productOptions.value = (res.rows || []).map(p => ({
value: p.productCode,
label: `${p.productCode} ${p.productName || ''}`.trim(),
name: p.productName
}))
})
}
function onDetailProductChange() {
const found = productOptions.value.find(p => p.value === detailForm.value.productCode)
detailForm.value.productName = found ? found.name : detailForm.value.productCode
detailForm.value.countryCode = undefined
detailForm.value.countryName = undefined
detailForm.value._countryKey = undefined
detailForm.value.weightLabel = undefined
detailForm.value.fvValue = undefined
detailForm.value.fvRegistrationFee = undefined
if (detailForm.value.productCode && set.value.setType === 'FIXED') {
ensureCountries(detailForm.value.productCode)
}
}
function onDetailCountryChange() {
const opt = detailCountryOptions.value.find(c => c.key === detailForm.value._countryKey)
detailForm.value.countryCode = opt ? opt.code : undefined
detailForm.value.countryName = opt ? opt.name : undefined
detailForm.value.weightLabel = undefined
detailForm.value.fvValue = undefined
detailForm.value.fvRegistrationFee = undefined
}
function onDetailWeightChange() {
const opt = detailWeightOptions.value.find(w => w.value === detailForm.value.weightLabel)
if (!opt) return
if (opt.freightPrice != null) detailForm.value.fvValue = Number(opt.freightPrice)
if (opt.registrationFee != null) detailForm.value.fvRegistrationFee = Number(opt.registrationFee)
}
const childEditable = computed(() => ['D', 'R', 'E'].includes(set.value.status))
const statusLabel = computed(() => {
@@ -296,12 +429,16 @@ function onCopy() {
}
function openDetailDialog(row) {
detailForm.value = row ? { ...row } : {
detailForm.value = row ? {
...row,
_countryKey: countryKeyOf(row)
} : {
priceSetId: set.value.priceSetId,
productCode: undefined,
productName: undefined,
countryCode: undefined,
countryName: undefined,
_countryKey: undefined,
weightLabel: undefined,
fvValue: undefined,
fvRegistrationFee: undefined,
@@ -309,12 +446,19 @@ function openDetailDialog(row) {
}
detailTitle.value = row ? '编辑明细' : '添加明细'
detailOpen.value = true
if (detailForm.value.productCode && set.value.setType === 'FIXED') {
ensureCountries(detailForm.value.productCode)
}
}
function submitDetail() {
proxy.$refs['detailFormRef'].validate(valid => {
if (!valid) return
const payload = { ...detailForm.value, priceSetId: set.value.priceSetId }
const payload = {
...detailForm.value,
priceSetId: set.value.priceSetId
}
delete payload._countryKey
const req = payload.psdId ? updateSetDetail(payload) : addSetDetail(payload)
req.then(() => {
proxy.$modal.msgSuccess('保存成功')
@@ -369,6 +513,7 @@ function removeCustomer(row) {
}
onMounted(() => {
loadProducts()
load()
})
</script>