修改套餐价格
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mask" :class="{ open: modelValue }" @click="close"></div>
|
||||
<div class="drawer" :class="{ open: modelValue }">
|
||||
<div class="drawer-hd">
|
||||
<span>{{ title }}</span>
|
||||
<div class="hd-actions">
|
||||
<el-button size="small" @click="close">关闭</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="drawer-bd">
|
||||
<el-alert type="info" :closable="false" show-icon class="mb12" :title="hint" />
|
||||
<div class="filters">
|
||||
<el-input v-model="keyword" clearable :placeholder="phSearch" style="width: 240px" />
|
||||
</div>
|
||||
<el-table
|
||||
:data="displayRows"
|
||||
border
|
||||
size="small"
|
||||
:span-method="spanMethod"
|
||||
:row-class-name="rowClass"
|
||||
>
|
||||
<el-table-column :label="colCountry" min-width="100">
|
||||
<template #default="{ row }">{{ countryLabel(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="colZone" min-width="90">
|
||||
<template #default="{ row }">{{ row.zoneName || row.zoneCode || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="colWeight" prop="weightLabel" min-width="120" />
|
||||
<el-table-column :label="colSetFv" width="160" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="price-cell">
|
||||
<template v-if="editingKey !== cellKey(row, 'fv')">
|
||||
<span class="price-text">{{ formatNum(row.fvValue, 4) }}</span>
|
||||
<el-button link type="primary" icon="Edit" class="edit-ico" @click="startEdit(row, 'fv')" />
|
||||
</template>
|
||||
<el-input-number
|
||||
v-else
|
||||
:ref="el => setEditRef(el, row, 'fv')"
|
||||
v-model="row.fvValue"
|
||||
:min="0"
|
||||
:precision="4"
|
||||
:controls="false"
|
||||
size="small"
|
||||
style="width: 110px"
|
||||
@blur="() => commitEdit(row, 'fv')"
|
||||
@keyup.enter="() => commitEdit(row, 'fv')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="colSetReg" width="150" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="price-cell">
|
||||
<template v-if="editingKey !== cellKey(row, 'reg')">
|
||||
<span class="price-text">{{ formatNum(row.fvRegistrationFee, 2) }}</span>
|
||||
<el-button link type="primary" icon="Edit" class="edit-ico" @click="startEdit(row, 'reg')" />
|
||||
</template>
|
||||
<el-input-number
|
||||
v-else
|
||||
:ref="el => setEditRef(el, row, 'reg')"
|
||||
v-model="row.fvRegistrationFee"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
size="small"
|
||||
style="width: 100px"
|
||||
@blur="() => commitEdit(row, 'reg')"
|
||||
@keyup.enter="() => commitEdit(row, 'reg')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="colSurcharge" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<surcharge-count-cell :list="row.surcharges || []" @click="pickSurcharge(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="colAction" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="pickSurcharge(row)">附加费</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="PriceEditDrawer">
|
||||
import SurchargeCountCell from './SurchargeCountCell.vue'
|
||||
import { isDetailPriceChanged } from '../../utils/priceSet'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
productCode: { type: String, default: '' },
|
||||
productName: { type: String, default: '' },
|
||||
rows: { type: Array, default: () => [] }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'pick-surcharge', 'row-change'])
|
||||
|
||||
const keyword = ref('')
|
||||
const editingKey = ref('')
|
||||
const committing = ref(false)
|
||||
const editInputRefs = {}
|
||||
|
||||
const title = computed(() => {
|
||||
const code = props.productCode || ''
|
||||
const name = props.productName || ''
|
||||
return code + ' �� ' + name + ' �� 国家服务价格'
|
||||
})
|
||||
const hint = '黄色行=已改价;点编辑图标后修改,失焦即刻写入数据库。单位:运费 RMB/KG,挂号费 RMB/票。'
|
||||
const phSearch = '搜索国家/分区'
|
||||
const colCountry = '国家'
|
||||
const colZone = '分区'
|
||||
const colWeight = '重量段'
|
||||
const colSetFv = '套餐运费'
|
||||
const colSetReg = '套餐挂号费'
|
||||
const colSurcharge = '附加费'
|
||||
const colAction = '操作'
|
||||
|
||||
function cellKey(row, field) {
|
||||
return String(row && row.psdId != null ? row.psdId : row && row.productCountryId) + ':' + field
|
||||
}
|
||||
|
||||
function formatNum(v, precision) {
|
||||
if (v == null || v === '') return '-'
|
||||
const n = Number(v)
|
||||
if (Number.isNaN(n)) return '-'
|
||||
return n.toFixed(precision)
|
||||
}
|
||||
|
||||
function setEditRef(el, row, field) {
|
||||
if (el) editInputRefs[cellKey(row, field)] = el
|
||||
}
|
||||
|
||||
function startEdit(row, field) {
|
||||
if (!row) return
|
||||
editingKey.value = cellKey(row, field)
|
||||
nextTick(() => {
|
||||
const el = editInputRefs[editingKey.value]
|
||||
if (el && typeof el.focus === 'function') el.focus()
|
||||
})
|
||||
}
|
||||
|
||||
async function commitEdit(row, field) {
|
||||
if (committing.value) return
|
||||
if (editingKey.value !== cellKey(row, field)) return
|
||||
editingKey.value = ''
|
||||
if (!row || !row.psdId) return
|
||||
committing.value = true
|
||||
try {
|
||||
emit('row-change', row)
|
||||
} finally {
|
||||
committing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function pickSurcharge(row) {
|
||||
emit('pick-surcharge', row || null)
|
||||
}
|
||||
|
||||
function countryKey(row) {
|
||||
return String(row.countryCode || row.countryName || '').toUpperCase()
|
||||
}
|
||||
|
||||
function countryLabel(row) {
|
||||
return row.countryName || row.countryCode || '-'
|
||||
}
|
||||
|
||||
const displayRows = computed(() => {
|
||||
const q = (keyword.value || '').trim().toLowerCase()
|
||||
let list = [...(props.rows || [])]
|
||||
if (q) {
|
||||
list = list.filter(r => {
|
||||
const blob = [r.countryName, r.countryCode, r.zoneName, r.zoneCode, r.weightLabel]
|
||||
.filter(Boolean).join(' ').toLowerCase()
|
||||
return blob.includes(q)
|
||||
})
|
||||
}
|
||||
list.sort((a, b) => {
|
||||
const ca = countryKey(a)
|
||||
const cb = countryKey(b)
|
||||
if (ca !== cb) return ca.localeCompare(cb)
|
||||
const za = String(a.zoneCode || a.zoneName || '')
|
||||
const zb = String(b.zoneCode || b.zoneName || '')
|
||||
if (za !== zb) return za.localeCompare(zb)
|
||||
return String(a.weightLabel || '').localeCompare(String(b.weightLabel || ''))
|
||||
})
|
||||
return list
|
||||
})
|
||||
|
||||
function buildSpans(rows, keyOf) {
|
||||
const spans = new Array(rows.length).fill(1)
|
||||
let i = 0
|
||||
while (i < rows.length) {
|
||||
let j = i + 1
|
||||
const key = keyOf(rows[i])
|
||||
while (j < rows.length && keyOf(rows[j]) === key) j++
|
||||
spans[i] = j - i
|
||||
for (let k = i + 1; k < j; k++) spans[k] = 0
|
||||
i = j
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
function zoneKey(row) {
|
||||
return String(row.zoneCode || row.zoneName || '')
|
||||
}
|
||||
|
||||
const countrySpans = computed(() => buildSpans(displayRows.value, countryKey))
|
||||
const zoneSpans = computed(() => buildSpans(displayRows.value, r => countryKey(r) + '|' + zoneKey(r)))
|
||||
|
||||
function spanMethod({ columnIndex, rowIndex }) {
|
||||
if (columnIndex === 0) return spanOf(countrySpans.value, rowIndex)
|
||||
if (columnIndex === 1) return spanOf(zoneSpans.value, rowIndex)
|
||||
return { rowspan: 1, colspan: 1 }
|
||||
}
|
||||
|
||||
function spanOf(spans, rowIndex) {
|
||||
const rowspan = spans[rowIndex] || 0
|
||||
return rowspan > 0 ? { rowspan, colspan: 1 } : { rowspan: 0, colspan: 0 }
|
||||
}
|
||||
|
||||
function rowClass({ row }) {
|
||||
return isDetailPriceChanged(row) ? 'changed-row' : ''
|
||||
}
|
||||
|
||||
function close() {
|
||||
editingKey.value = ''
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, v => {
|
||||
if (v) {
|
||||
keyword.value = ''
|
||||
editingKey.value = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 1999;
|
||||
display: none;
|
||||
}
|
||||
.mask.open { display: block; }
|
||||
.drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: min(960px, 96vw);
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
|
||||
transform: translateX(100%);
|
||||
transition: 0.25s;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.drawer.open { transform: translateX(0); }
|
||||
.drawer-hd {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.hd-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.drawer-bd {
|
||||
padding: 16px 20px;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.mb12 { margin-bottom: 12px; }
|
||||
.filters { margin-bottom: 12px; }
|
||||
.price-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
min-height: 28px;
|
||||
}
|
||||
.price-text {
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 64px;
|
||||
text-align: right;
|
||||
}
|
||||
.edit-ico {
|
||||
padding: 0 2px !important;
|
||||
}
|
||||
:deep(.changed-row > td) {
|
||||
background: #fffbe6 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -41,15 +41,6 @@
|
||||
<div class="form-hint">范围 1~99,88 表示默认价 × 0.88;FIXED 模式此值忽略</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="生效日期" prop="effectiveDate">
|
||||
<el-date-picker v-model="basic.effectiveDate" type="date" value-format="YYYY-MM-DD" placeholder="生效日期" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="失效日期" prop="expireDate">
|
||||
<el-date-picker v-model="basic.expireDate" type="date" value-format="YYYY-MM-DD" placeholder="空表示长期有效" />
|
||||
<div class="form-hint">空表示长期有效</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="说明备注" prop="priceSetNote">
|
||||
<el-input v-model="basic.priceSetNote" type="textarea" :rows="3" maxlength="200" placeholder="套餐业务说明,显示给客户与运营" />
|
||||
</el-form-item>
|
||||
@@ -69,7 +60,6 @@ const formRef = ref()
|
||||
const rules = {
|
||||
priceSetName: [{ required: true, message: '套餐名称不能为空', trigger: 'blur' }],
|
||||
setType: [{ required: true, message: '请选择计价类型', trigger: 'change' }],
|
||||
effectiveDate: [{ required: true, message: '生效日期不能为空', trigger: 'change' }]
|
||||
}
|
||||
|
||||
/** 供父级在下一步 / 保存前调用 */
|
||||
|
||||
@@ -2,95 +2,23 @@
|
||||
<el-card shadow="never" class="step-card">
|
||||
<template #header>
|
||||
<div class="card-head">
|
||||
<span>第 3 步 · 绑定客户<span class="sub">可选,可后续补充</span></span>
|
||||
<el-button type="primary" size="small" icon="Plus" @click="addRow">添加客户</el-button>
|
||||
<span>已绑定客户<span class="sub">仅查看,请到套餐详情页维护绑定</span></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb12"
|
||||
title="套餐创建后处于草稿状态,可暂不绑定客户。一个客户只能绑定一个套餐。"
|
||||
/>
|
||||
|
||||
<el-table :data="customers" border empty-text="暂未绑定客户">
|
||||
<el-table-column label="客户" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<customer-select
|
||||
v-model="row.customerCode"
|
||||
value-key="customerCode"
|
||||
placeholder="请选择客户"
|
||||
@change="(code, item) => onCustomerChange(row, code, item)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="客户代码" prop="customerCode" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="客户名称" prop="customerName" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="优先级" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.bindPriority" :min="1" controls-position="right" style="width: 100%" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="生效日期" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-date-picker v-model="row.prBegindate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="失效日期" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-date-picker v-model="row.prEnddate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" @click="customers.splice($index, 1)">移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="客户代码" prop="customerCode" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="客户名称" prop="customerName" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="优先级" prop="bindPriority" width="110" align="center" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup name="StepCustomers">
|
||||
/**
|
||||
* 第 3 步 · 绑定客户
|
||||
* customers 为父级 form.customers 的同一数组引用;新行默认继承套餐生效/失效日期。
|
||||
*/
|
||||
import { createEmptyCustomerRow } from '../../utils/priceSet'
|
||||
|
||||
const props = defineProps({
|
||||
/** 客户绑定行数组(父级 form.customers) */
|
||||
customers: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
/** 套餐生效日期,用于新行默认值 */
|
||||
effectiveDate: {
|
||||
type: String,
|
||||
default: undefined
|
||||
},
|
||||
/** 套餐失效日期,用于新行默认值 */
|
||||
expireDate: {
|
||||
type: String,
|
||||
default: undefined
|
||||
}
|
||||
defineProps({
|
||||
customers: { type: Array, required: true },
|
||||
readOnly: { type: Boolean, default: true }
|
||||
})
|
||||
|
||||
function addRow() {
|
||||
props.customers.push(createEmptyCustomerRow({
|
||||
effectiveDate: props.effectiveDate,
|
||||
expireDate: props.expireDate,
|
||||
seq: props.customers.length + 1
|
||||
}))
|
||||
}
|
||||
|
||||
/** 下拉选中客户后回填代码 / 名称 / ID */
|
||||
function onCustomerChange(row, code, item) {
|
||||
row.customerCode = code || undefined
|
||||
row.customerName = item ? item.customerName : ''
|
||||
row.customerId = item ? item.customerId : undefined
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -105,7 +33,4 @@ function onCustomerChange(row, code, item) {
|
||||
font-weight: normal;
|
||||
font-size: 13px;
|
||||
}
|
||||
.mb12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,191 +1,473 @@
|
||||
<template>
|
||||
<el-card shadow="never" class="step-card">
|
||||
<el-card shadow="never" class="step-card" v-loading="pulling" :element-loading-text="loadingText">
|
||||
<template #header>
|
||||
<div class="card-head">
|
||||
<span>
|
||||
第 2 步 · 套餐明细
|
||||
<span class="sub">当前:{{ setTypeLabel(setType) }}模式</span>
|
||||
{{ headerTitle }}
|
||||
<span class="sub">{{ typeHint }}</span>
|
||||
</span>
|
||||
<el-button type="primary" size="small" icon="Plus" @click="addRow">添加明细行</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert
|
||||
v-if="isFixedType(setType)"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb12"
|
||||
title="FIXED 模式:可不填明细(计费取 bms_product_country 默认运费/挂号费);有明细时按产品×国家×分区×重量段覆盖固定价。附加费来自产品关联,只读展示。"
|
||||
/>
|
||||
<el-alert
|
||||
v-else
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb12"
|
||||
title="DISCOUNT 模式:可不填明细(全部产品适用默认折扣),或按产品限定折扣范围。"
|
||||
/>
|
||||
<el-alert type="info" :closable="false" show-icon class="mb12" :title="metaText" />
|
||||
|
||||
<el-table :data="details" border empty-text="暂无明细,点击添加明细行">
|
||||
<el-table-column label="产品" min-width="180">
|
||||
<div class="filters">
|
||||
<el-input v-model="query.keyword" clearable :placeholder="phKeyword" style="width: 180px" />
|
||||
<el-select v-model="query.taxMode" clearable :placeholder="phTax" style="width: 140px">
|
||||
<el-option v-for="d in bms_tax_mode" :key="d.value" :label="d.label" :value="d.value" />
|
||||
</el-select>
|
||||
<el-button size="small" @click="noop">查询</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="filteredProducts" border empty-text="暂无产品价格明细">
|
||||
<el-table-column :label="colCode" min-width="80">
|
||||
<template #default="{ row }"><b>{{ row.productCode }}</b></template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="colName" prop="productName" min-width="80" show-overflow-tooltip />
|
||||
<el-table-column :label="colLevel" prop="serviceLevel" width="90" align="center" />
|
||||
<el-table-column :label="colCargo" width="200" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-model="row.productCode"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="选择产品"
|
||||
style="width: 100%"
|
||||
@change="onProductChange(row, setType)"
|
||||
>
|
||||
<el-option v-for="p in refData.productOptions.value" :key="p.value" :label="p.label" :value="p.value" />
|
||||
</el-select>
|
||||
<el-tag v-if="row.cargoAttr" type="primary" size="small" effect="plain">{{ row.cargoAttr }}</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="isFixedType(setType)" label="国家" min-width="140">
|
||||
<el-table-column :label="colRegion" prop="destRegion" width="90" align="center" />
|
||||
<el-table-column :label="colTax" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-model="row._countryKey"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="选择国家"
|
||||
style="width: 100%"
|
||||
:disabled="!row.productCode"
|
||||
@change="onCountryChange(row)"
|
||||
>
|
||||
<el-option
|
||||
v-for="c in refData.countryOptionsOf(row)"
|
||||
:key="c.key"
|
||||
:label="c.label"
|
||||
:value="c.key"
|
||||
/>
|
||||
</el-select>
|
||||
<dict-tag :options="bms_tax_mode" :value="row.taxMode" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="isFixedType(setType)" label="分区" min-width="140">
|
||||
<el-table-column :label="colSurcharge" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-if="refData.hasZonesOf(row)"
|
||||
v-model="row.zoneCode"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="选择分区"
|
||||
style="width: 100%"
|
||||
:disabled="!row._countryKey"
|
||||
@change="onZoneChange(row)"
|
||||
>
|
||||
<el-option
|
||||
v-for="z in refData.zoneOptionsOf(row)"
|
||||
:key="z.key"
|
||||
:label="z.label"
|
||||
:value="z.zoneCode"
|
||||
/>
|
||||
</el-select>
|
||||
<span v-else class="muted">-</span>
|
||||
<surcharge-count-cell :list="productSurcharges(row)" @click="openSurcharge(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="isFixedType(setType)" label="重量段" min-width="150">
|
||||
<el-table-column :label="colAction" width="220" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-model="row.weightLabel"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="选择重量段"
|
||||
style="width: 100%"
|
||||
:disabled="!row._countryKey || (refData.hasZonesOf(row) && !row.zoneCode)"
|
||||
@change="onWeightChange(row)"
|
||||
>
|
||||
<el-option
|
||||
v-for="w in refData.weightOptionsOf(row)"
|
||||
:key="w.value"
|
||||
:label="w.label"
|
||||
:value="w.value"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="isFixedType(setType)" label="运费单价(RMB/KG)" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.fvValue" :min="0" :precision="4" :step="1" controls-position="right" style="width: 100%" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="isFixedType(setType)" label="挂号费(RMB/票)" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.fvRegistrationFee" :min="0" :precision="2" :step="1" controls-position="right" style="width: 100%" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="isDiscountType(setType)" label="产品折扣%" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.discountValue" :min="1" :max="99" :precision="2" controls-position="right" style="width: 100%" placeholder="空=默认折扣" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="附加费" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div v-if="!row.productCode" class="muted">-</div>
|
||||
<surcharge-tags v-else :list="surchargesOf(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="取值方式" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.roundMode" placeholder="取值方式" style="width: 100%">
|
||||
<el-option v-for="d in bms_round_mode" :key="d.value" :label="d.label" :value="d.value" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" @click="details.splice($index, 1)">删除</el-button>
|
||||
<el-button link type="primary" @click="openPrice(row)">编辑价格</el-button>
|
||||
<el-button link type="primary" @click="openSurcharge(row)">附加费</el-button>
|
||||
<el-button link type="danger" @click="removeProduct(row.productCode)">移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<surcharge-picker-dialog
|
||||
v-model="scVisible"
|
||||
:title="scTitle"
|
||||
:list="scList"
|
||||
:selected-ids="scPickedIds"
|
||||
@confirm="onSurchargeConfirm"
|
||||
/>
|
||||
|
||||
<el-dialog v-model="discVisible" :title="discTitle" width="420px" append-to-body>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item :label="colDiscount">
|
||||
<el-input-number v-model="discValue" :min="1" :max="99" :precision="2" controls-position="right" />
|
||||
<span class="unit">%</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="discVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveDiscount">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<price-edit-drawer
|
||||
v-model="priceVisible"
|
||||
:product-code="editCode"
|
||||
:product-name="editName"
|
||||
:rows="editRows"
|
||||
@row-change="onRowPriceChange"
|
||||
@pick-surcharge="openSurchargeFromDrawer"
|
||||
/>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup name="StepDetails">
|
||||
/**
|
||||
* 第 2 步 · 套餐明细
|
||||
* details 为父级 form.details 的同一数组引用,增删行直接原地操作。
|
||||
*/
|
||||
import SurchargeTags from '../SurchargeTags.vue'
|
||||
import PriceEditDrawer from './PriceEditDrawer.vue'
|
||||
import SurchargeCountCell from './SurchargeCountCell.vue'
|
||||
import SurchargePickerDialog from './SurchargePickerDialog.vue'
|
||||
import { useDetailCascade } from '../../composables/usePriceSetRefData'
|
||||
import { createEmptyDetailRow, isDiscountType, isFixedType, setTypeLabel } from '../../utils/priceSet'
|
||||
import {
|
||||
createEmptyDetailRow,
|
||||
formatWeightLabel,
|
||||
isDiscountType,
|
||||
isFixedType,
|
||||
setTypeLabel,
|
||||
surchargeAppliesToRow,
|
||||
toDetailPayload
|
||||
} from '../../utils/priceSet'
|
||||
import { updateSetDetail, delSetDetail } from '@/api/price/set'
|
||||
|
||||
const props = defineProps({
|
||||
/** 明细行数组(父级 form.details) */
|
||||
details: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
/** 计价类型 FIXED / DISCOUNT */
|
||||
setType: {
|
||||
type: String,
|
||||
default: 'FIXED'
|
||||
},
|
||||
/** 页面共享的参考数据(usePriceSetRefData 返回值) */
|
||||
refData: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
details: { type: Array, required: true },
|
||||
priceSetId: { type: [Number, String], default: undefined },
|
||||
setType: { type: String, default: 'FIXED' },
|
||||
effectiveTime: { type: String, default: undefined },
|
||||
versionNo: { type: String, default: '' },
|
||||
refData: { type: Object, required: true }
|
||||
})
|
||||
|
||||
const { bms_round_mode } = useDict('bms_round_mode')
|
||||
const { proxy } = getCurrentInstance()
|
||||
const { bms_tax_mode } = useDict('bms_tax_mode')
|
||||
const { hydrateRow } = useDetailCascade(props.refData)
|
||||
|
||||
/** 参考数据来自父页面,缓存与请求去重全页面共享 */
|
||||
const { productOptions, surchargesOf, countryOptionsOf, weightOptionsOf } = props.refData
|
||||
const { onProductChange, onCountryChange, onZoneChange, onWeightChange } = useDetailCascade(props.refData)
|
||||
const loadingText = '正在全量拉取产品价格…'
|
||||
const headerTitle = '第 2 步 · 编辑产品价格'
|
||||
const phKeyword = '产品代码/名称'
|
||||
const phTax = '全部税模式'
|
||||
const colCode = '产品代码'
|
||||
const colName = '产品名称'
|
||||
const colLevel = '时效等级'
|
||||
const colCargo = '走货属性'
|
||||
const colRegion = '目的区域'
|
||||
const colTax = '税模式'
|
||||
const colSurcharge = '附加费'
|
||||
const colAction = '操作'
|
||||
const colDiscount = '产品折扣'
|
||||
|
||||
function addRow() {
|
||||
props.details.push(createEmptyDetailRow())
|
||||
const query = reactive({ keyword: '', taxMode: undefined })
|
||||
const priceVisible = ref(false)
|
||||
const editCode = ref('')
|
||||
const editName = ref('')
|
||||
const editRows = ref([])
|
||||
/** 抽屉当前对应的产品分组(抽屉内点附加费时用它打开勾选弹窗) */
|
||||
const editGroup = ref(null)
|
||||
const scVisible = ref(false)
|
||||
const scTitle = ref('附加费')
|
||||
const scList = ref([])
|
||||
const scRows = ref([])
|
||||
const scPickedIds = ref([])
|
||||
/** 作用范围:null=产品级(覆盖全部明细行),否则=行级键(只写回国家+重量段那一行) */
|
||||
const scScopeKey = ref(null)
|
||||
const scScopeName = ref('')
|
||||
const discVisible = ref(false)
|
||||
const discTitle = ref('编辑价格')
|
||||
const discValue = ref(undefined)
|
||||
const discCode = ref('')
|
||||
|
||||
const typeHint = computed(() => '· 当前:' + setTypeLabel(props.setType))
|
||||
|
||||
const metaText = computed(() => {
|
||||
const ver = props.versionNo || 'V?'
|
||||
return ver + ' · 数据已落库,编辑价格/附加费会立即写入数据库。'
|
||||
})
|
||||
|
||||
const productGroups = computed(() => {
|
||||
const map = new Map()
|
||||
for (const d of props.details) {
|
||||
if (!d.productCode) continue
|
||||
if (!map.has(d.productCode)) {
|
||||
const meta = props.refData.productMetaOf(d.productCode) || {}
|
||||
map.set(d.productCode, {
|
||||
productCode: d.productCode,
|
||||
productName: d.productName || meta.name || d.productCode,
|
||||
serviceLevel: meta.serviceLevel,
|
||||
cargoAttr: meta.cargoAttr,
|
||||
destRegion: meta.destRegion,
|
||||
taxMode: meta.taxMode,
|
||||
rows: []
|
||||
})
|
||||
}
|
||||
map.get(d.productCode).rows.push(d)
|
||||
}
|
||||
return [...map.values()]
|
||||
})
|
||||
|
||||
const filteredProducts = computed(() => {
|
||||
const kw = (query.keyword || '').trim().toLowerCase()
|
||||
return productGroups.value.filter(p => {
|
||||
if (query.taxMode && p.taxMode !== query.taxMode) return false
|
||||
if (!kw) return true
|
||||
const blob = [p.productCode, p.productName].filter(Boolean).join(' ').toLowerCase()
|
||||
return blob.includes(kw)
|
||||
})
|
||||
})
|
||||
|
||||
const pulling = ref(false)
|
||||
|
||||
function noop() {}
|
||||
|
||||
/** 该产品所有明细行附加费的并集(按 scId 去重),列表列展示用 */
|
||||
function productSurcharges(row) {
|
||||
const seen = new Set()
|
||||
const list = []
|
||||
;((row && row.rows) || []).forEach(r => {
|
||||
(r.surcharges || []).forEach(s => {
|
||||
const key = s && s.scId != null ? String(s.scId) : (s && s.scName)
|
||||
if (!key || seen.has(key)) return
|
||||
seen.add(key)
|
||||
list.push(s)
|
||||
})
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
/** enter step 3: pull all library products + country prices (skip already added) */
|
||||
async function pullAllFromLibrary(opts = {}) {
|
||||
const silent = !!opts.silent
|
||||
const replace = !!opts.replace
|
||||
if (!(props.refData.productOptions.value || []).length) {
|
||||
await props.refData.loadProducts()
|
||||
}
|
||||
const products = props.refData.productOptions.value || []
|
||||
if (!products.length) {
|
||||
if (!silent) proxy.$modal.msgWarning('产品库无可用产品')
|
||||
return { added: 0 }
|
||||
}
|
||||
if (replace) {
|
||||
props.details.splice(0, props.details.length)
|
||||
}
|
||||
const used = new Set((props.details || []).map(d => d.productCode).filter(Boolean))
|
||||
const toAdd = products.filter(p => !used.has(p.value))
|
||||
if (!toAdd.length) return { added: 0 }
|
||||
|
||||
pulling.value = true
|
||||
let added = 0
|
||||
try {
|
||||
// parallel fetch countries/surcharges, then push rows
|
||||
await Promise.all(toAdd.map(async (p) => {
|
||||
const code = p.value
|
||||
const name = p.name || props.refData.productNameOf(code)
|
||||
await props.refData.ensureSurcharges(code)
|
||||
if (isDiscountType(props.setType)) {
|
||||
const row = createEmptyDetailRow()
|
||||
row.productCode = code
|
||||
row.productName = name
|
||||
row.surcharges = props.refData.surchargesOf(row)
|
||||
props.details.push(row)
|
||||
added++
|
||||
return
|
||||
}
|
||||
const open = await props.refData.ensureCountries(code, props.effectiveTime)
|
||||
if (!open.length) return
|
||||
open.forEach(item => {
|
||||
const row = countryToDetail(item, code, name)
|
||||
row.surcharges = props.refData.surchargesOf(row)
|
||||
props.details.push(row)
|
||||
})
|
||||
added++
|
||||
}))
|
||||
if (!silent) {
|
||||
if (added > 0) proxy.$modal.msgSuccess('已从产品库全量拉取 ' + added + ' 个产品')
|
||||
else proxy.$modal.msgWarning('未拉取到可展示的产品价格明细,请检查产品库国家配置')
|
||||
}
|
||||
} finally {
|
||||
pulling.value = false
|
||||
}
|
||||
return { added }
|
||||
}
|
||||
|
||||
function countryToDetail(item, productCode, productName) {
|
||||
const row = createEmptyDetailRow()
|
||||
row.productCode = productCode
|
||||
row.productName = productName
|
||||
row.countryCode = item.countryCode
|
||||
row.countryName = item.countryName
|
||||
row.zoneId = item.zoneId
|
||||
row.zoneCode = item.zoneCode
|
||||
row.zoneName = item.zoneName
|
||||
row.weightMin = item.weightMin
|
||||
row.weightMax = item.weightMax
|
||||
row.weightLabel = formatWeightLabel(item.weightMin, item.weightMax)
|
||||
row.productCountryId = item.detailId
|
||||
const baseFv = item.freightPrice != null ? Number(item.freightPrice) : undefined
|
||||
const baseReg = item.registrationFee != null ? Number(item.registrationFee) : undefined
|
||||
row._baseFreight = baseFv
|
||||
row._baseReg = baseReg
|
||||
row.fvValue = baseFv
|
||||
row.fvRegistrationFee = baseReg
|
||||
row.effectiveDate = item.effectiveDate
|
||||
row.expireDate = item.expireDate
|
||||
row._countryKey = item.countryCode || (item.countryName ? 'N:' + item.countryName : undefined)
|
||||
return hydrateRow(row, props.setType)
|
||||
}
|
||||
|
||||
function openPrice(row) {
|
||||
if (isDiscountType(props.setType)) {
|
||||
discCode.value = row.productCode
|
||||
discTitle.value = row.productCode + ' · ' + (row.productName || '') + ' · 产品折扣'
|
||||
const hit = (props.details || []).find(d => d.productCode === row.productCode)
|
||||
discValue.value = hit && hit.discountValue != null ? Number(hit.discountValue) : undefined
|
||||
discVisible.value = true
|
||||
return
|
||||
}
|
||||
editCode.value = row.productCode
|
||||
editName.value = row.productName || ''
|
||||
editRows.value = (row.rows || []).map(r => {
|
||||
if (r._baseFreight == null && r.fvValue != null) r._baseFreight = Number(r.fvValue)
|
||||
if (r._baseReg == null && r.fvRegistrationFee != null) r._baseReg = Number(r.fvRegistrationFee)
|
||||
return r
|
||||
})
|
||||
editGroup.value = row
|
||||
priceVisible.value = true
|
||||
}
|
||||
|
||||
/** 抽屉里点附加费:带行 = 行级(只改该行,国家+重量段),不带 = 产品级(覆盖全部明细行) */
|
||||
function openSurchargeFromDrawer(row) {
|
||||
const group = editGroup.value || { productCode: editCode.value, rows: editRows.value }
|
||||
if (row && (row.countryCode || row.countryName)) {
|
||||
openSurcharge(group, rowScopeKey(row), rowScopeLabel(row))
|
||||
} else {
|
||||
openSurcharge(group)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** persist one detail row to DB immediately */
|
||||
async function persistDetail(row, withSurcharge) {
|
||||
if (!row || !row.psdId || !props.priceSetId) return
|
||||
const payload = toDetailPayload(row)
|
||||
payload.priceSetId = props.priceSetId
|
||||
if (!withSurcharge) delete payload.surcharges
|
||||
await updateSetDetail(payload)
|
||||
}
|
||||
|
||||
/** drawer field change -> immediate DB update */
|
||||
async function onRowPriceChange(row) {
|
||||
if (!row || !row.psdId) return
|
||||
try {
|
||||
await persistDetail(row, false)
|
||||
proxy.$modal.msgSuccess('价格已更新')
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function saveDiscount() {
|
||||
const hit = (props.details || []).find(d => d.productCode === discCode.value)
|
||||
if (hit) {
|
||||
hit.discountValue = discValue.value
|
||||
try {
|
||||
await persistDetail(hit, false)
|
||||
proxy.$modal.msgSuccess('折扣已写入数据库')
|
||||
} catch (e) {}
|
||||
}
|
||||
discVisible.value = false
|
||||
}
|
||||
|
||||
/** 明细行的国家 key(与 countryToDetail 的 _countryKey 同口径,大写便于比较) */
|
||||
function rowCountryKey(r) {
|
||||
if (!r) return ''
|
||||
return String(r.countryCode || (r.countryName ? 'N:' + r.countryName : '')).toUpperCase()
|
||||
}
|
||||
|
||||
/** 明细行的行级唯一键(国家 + 分区 + 重量段),用于精确定位「只改这一行」 */
|
||||
function rowScopeKey(r) {
|
||||
if (!r) return ''
|
||||
const c = rowCountryKey(r)
|
||||
const z = String(r.zoneCode || r.zoneName || '')
|
||||
const w = String(r.weightLabel || r.weightMin || r.weightMax || '')
|
||||
return `${c}|${z}|${w}`
|
||||
}
|
||||
|
||||
/** 行级作用域展示文案,如「荷兰 · 0.3-2kg」 */
|
||||
function rowScopeLabel(r) {
|
||||
if (!r) return ''
|
||||
const c = r.countryName || r.countryCode || ''
|
||||
const z = r.zoneName || r.zoneCode || ''
|
||||
const w = r.weightLabel || ''
|
||||
return [c, z, w].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开附加费勾选弹窗
|
||||
* 列表 = 价格基础 → 附加费配置 主表全量(不要求产品已做关联);
|
||||
* 勾选回显 = 作用范围内明细行当前附加费的并集;
|
||||
* scopeKey 传行级键时只作用于该行(国家+重量段),不传则产品级全覆盖。
|
||||
*/
|
||||
async function openSurcharge(row, scopeKey, scopeName) {
|
||||
await props.refData.ensureAllSurcharges()
|
||||
scRows.value = row.rows || []
|
||||
scScopeKey.value = scopeKey || null
|
||||
const targets = scScopeKey.value
|
||||
? scRows.value.filter(r => rowScopeKey(r) === scScopeKey.value)
|
||||
: scRows.value
|
||||
scScopeName.value = scopeName || (scScopeKey.value
|
||||
? ((targets[0] && rowScopeLabel(targets[0])) || '')
|
||||
: '')
|
||||
scList.value = props.refData.allSurchargeConfigs()
|
||||
const picked = new Set()
|
||||
targets.forEach(r => {
|
||||
(r.surcharges || []).forEach(s => {
|
||||
if (s && s.scId != null) picked.add(String(s.scId))
|
||||
})
|
||||
})
|
||||
scPickedIds.value = [...picked]
|
||||
scTitle.value = row.productCode
|
||||
+ (scScopeName.value ? ' · ' + scScopeName.value : '')
|
||||
+ ' · 附加费'
|
||||
scVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 勾选结果写回:行级只改该行(国家+重量段),产品级覆盖该产品全部明细行;同一附加费只留一条。
|
||||
*
|
||||
* 校验口径:附加费的 country_code 是它自身的计费适用国家(如「燃油附加费 RO」仅对罗马
|
||||
* 尼亚计费),勾选挂载时【按目标明细行的国家做适用性校验】——国家码不匹配的附加费
|
||||
* 不落到该行(该行计费时也不会命中它,见 TmsPriceQuoteServiceImpl.surchargeApplies),
|
||||
* 并统计不匹配数提示用户。country_code 为空 = 全部国家,直接适用。
|
||||
*/
|
||||
function onSurchargeConfirm(picked) {
|
||||
const ids = new Set((picked || []).map(s => String(s.scId)))
|
||||
const targets = scScopeKey.value
|
||||
? scRows.value.filter(r => rowScopeKey(r) === scScopeKey.value)
|
||||
: scRows.value
|
||||
let applied = 0
|
||||
let skipped = 0
|
||||
targets.forEach(r => {
|
||||
const seen = new Set()
|
||||
const list = []
|
||||
scList.value.forEach(rel => {
|
||||
const key = String(rel.scId)
|
||||
if (!ids.has(key) || seen.has(key)) return
|
||||
if (!surchargeAppliesToRow(rel, r)) {
|
||||
skipped++
|
||||
return
|
||||
}
|
||||
seen.add(key)
|
||||
list.push(rel)
|
||||
})
|
||||
r.surcharges = list
|
||||
applied += list.length
|
||||
})
|
||||
const scopeText = scScopeName.value ? `(仅 ${scScopeName.value})` : ''
|
||||
if (!scList.value.length) {
|
||||
proxy.$modal.msgWarning('附加费配置列表为空,请先到「价格基础 → 附加费配置」维护')
|
||||
return
|
||||
}
|
||||
if (ids.size && !applied) {
|
||||
proxy.$modal.msgWarning('已勾选但未生效:这些附加费配置了适用国家/重量段,与当前明细行不匹配')
|
||||
return
|
||||
}
|
||||
if (skipped > 0) {
|
||||
Promise.all(targets.filter(r => r.psdId).map(r => persistDetail(r, true))).finally(() => {
|
||||
proxy.$modal.msgWarning(`已纳入 ${applied} 项附加费${scopeText};${skipped} 项因适用国家不匹配被跳过(已写库)`)
|
||||
})
|
||||
return
|
||||
}
|
||||
const okText = ids.size ? `已纳入 ${ids.size} 项附加费${scopeText}` : `已清空附加费${scopeText}`
|
||||
Promise.all(targets.filter(r => r.psdId).map(r => persistDetail(r, true))).then(() => {
|
||||
proxy.$modal.msgSuccess(okText + ',已写入数据库')
|
||||
}).catch(() => {
|
||||
proxy.$modal.msgSuccess(okText)
|
||||
})
|
||||
}
|
||||
|
||||
async function removeProduct(code) {
|
||||
const ids = props.details.filter(d => d.productCode === code && d.psdId).map(d => d.psdId)
|
||||
if (ids.length) {
|
||||
try {
|
||||
await delSetDetail(ids.join(','))
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
}
|
||||
for (let i = props.details.length - 1; i >= 0; i--) {
|
||||
if (props.details[i].productCode === code) props.details.splice(i, 1)
|
||||
}
|
||||
proxy.$modal.msgSuccess('已移除产品明细')
|
||||
}
|
||||
|
||||
defineExpose({ pullAllFromLibrary })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -193,6 +475,7 @@ function addRow() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.sub {
|
||||
margin-left: 8px;
|
||||
@@ -200,10 +483,16 @@ function addRow() {
|
||||
font-weight: normal;
|
||||
font-size: 13px;
|
||||
}
|
||||
.mb12 {
|
||||
.mb12 { margin-bottom: 12px; }
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.muted {
|
||||
color: #c0c4cc;
|
||||
.unit {
|
||||
margin-left: 8px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="step-version">
|
||||
<el-card shadow="never" class="step-card summary-card">
|
||||
<template #header>
|
||||
<div class="card-head">
|
||||
<span>{{ summaryTitle }}</span>
|
||||
<el-tag size="small" type="info" effect="plain">{{ summaryHint }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-descriptions :column="2" border class="summary">
|
||||
<el-descriptions-item :label="labelName">{{ basic.priceSetName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="labelType">{{ setTypeLabel(basic.setType) }}</el-descriptions-item>
|
||||
<el-descriptions-item v-if="isDiscountType(basic.setType)" :label="labelDiscount">
|
||||
{{ basic.defaultDiscount }}%
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="labelNote" :span="2">{{ basic.priceSetNote || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="customer-block">
|
||||
<div class="customer-head">
|
||||
<span class="customer-title">{{ customerTitle }}</span>
|
||||
<span class="customer-count">{{ boundCustomers.length }}</span>
|
||||
</div>
|
||||
<el-table :data="boundCustomers" border size="small" empty-text="暂未绑定客户">
|
||||
<el-table-column :label="colCode" prop="customerCode" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column :label="colName" prop="customerName" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column :label="colPriority" prop="bindPriority" width="110" align="center" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="step-card version-card">
|
||||
<template #header><span>{{ title }}</span></template>
|
||||
<el-form ref="formRef" :model="version" :rules="rules" label-width="120px" class="max-form">
|
||||
<el-form-item :label="labelVersionNo">
|
||||
<el-input :model-value="version.versionNo" disabled style="width: 280px" />
|
||||
<div class="form-hint">{{ hintVersionNo }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="labelEffTime" prop="effectiveTime">
|
||||
<el-date-picker
|
||||
v-model="version.effectiveTime"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
:placeholder="phEffTime"
|
||||
style="width: 280px"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="StepVersion">
|
||||
import { isDiscountType, setTypeLabel } from '../../utils/priceSet'
|
||||
|
||||
const version = defineModel({ type: Object, required: true })
|
||||
const props = defineProps({
|
||||
basic: { type: Object, required: true },
|
||||
customers: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const formRef = ref()
|
||||
const title = '第 2 步 · 版本与生效时间'
|
||||
const labelVersionNo = '版本号'
|
||||
const hintVersionNo = '自动生成,下一个版本 +1'
|
||||
const labelEffTime = '生效时间'
|
||||
const phEffTime = '请选择版本生效时间'
|
||||
const summaryTitle = '第 1 步信息回显'
|
||||
const summaryHint = '仅回显,不可编辑'
|
||||
const labelName = '套餐名称'
|
||||
const labelType = '计价类型'
|
||||
const labelDiscount = '默认折扣'
|
||||
const labelNote = '说明'
|
||||
const customerTitle = '已绑定客户'
|
||||
const colCode = '客户代码'
|
||||
const colName = '客户名称'
|
||||
const colPriority = '优先级'
|
||||
|
||||
const rules = {
|
||||
effectiveTime: [{ required: true, message: '请填写版本生效时间', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const boundCustomers = computed(() => (props.customers || []).filter(c => c.customerCode))
|
||||
|
||||
function validate() {
|
||||
return new Promise(resolve => {
|
||||
if (!formRef.value) return resolve(false)
|
||||
formRef.value.validate(valid => resolve(!!valid))
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({ validate })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.step-version {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.max-form {
|
||||
max-width: 760px;
|
||||
}
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.4;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.summary-card :deep(.el-card__body) {
|
||||
padding-top: 12px;
|
||||
}
|
||||
.customer-block {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.customer-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.customer-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
.customer-count {
|
||||
margin-left: auto;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
line-height: 22px;
|
||||
text-align: center;
|
||||
border-radius: 11px;
|
||||
background: #f0f2f5;
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="sc-count">
|
||||
<el-popover
|
||||
v-if="list.length"
|
||||
placement="top"
|
||||
:width="380"
|
||||
trigger="hover"
|
||||
>
|
||||
<template #default>
|
||||
<div class="sc-count__pop-title">已纳入 {{ list.length }} 项附加费</div>
|
||||
<surcharge-tags :list="list" :popover="false" empty-text="-" />
|
||||
</template>
|
||||
<template #reference>
|
||||
<el-link type="primary" :underline="false" @click="emit('click')">
|
||||
{{ list.length }} 项
|
||||
</el-link>
|
||||
</template>
|
||||
</el-popover>
|
||||
<el-link v-else type="info" :underline="false" @click="emit('click')">0</el-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="SurchargeCountCell">
|
||||
/**
|
||||
* 套餐 · 附加费数量单元格(新建第 3 步产品列表 / 价格编辑抽屉共用)
|
||||
*
|
||||
* 显示当前数量(默认 0);hover 气泡看具体是哪几项,点击进入勾选弹窗调整。
|
||||
*/
|
||||
import SurchargeTags from '../SurchargeTags.vue'
|
||||
|
||||
defineProps({
|
||||
/** 该行 / 该产品当前纳入的附加费列表 */
|
||||
list: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sc-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.sc-count__pop-title {
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #606266;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
width="1000px"
|
||||
append-to-body
|
||||
class="ps-dialog"
|
||||
>
|
||||
<div class="ps-picker-bar">
|
||||
<span>共 {{ rows.length }} 项附加费配置,勾选后写入所选范围的明细行</span>
|
||||
<span class="ps-picker-bar__picked">已选 {{ picked.length }} 项</span>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
:data="rows"
|
||||
row-key="scId"
|
||||
border
|
||||
size="small"
|
||||
max-height="400"
|
||||
empty-text="暂无附加费配置"
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="46" />
|
||||
<el-table-column label="附加费名称" prop="scName" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="计费方式" width="200" align="center">
|
||||
<template #default="{ row }">{{ chargeTypeLabel(row.chargeType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="计费值" width="130" align="right">
|
||||
<template #default="{ row }">{{ formatChargeValue(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="作用范围" min-width="170" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.scopeText }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="ps-hint ps-hint--block">取消勾选即不纳入本套餐;「作用范围」表示该附加费自身计费适用的国家(如「燃油附加费 RO」仅对罗马尼亚计费),勾选后按所选范围写回明细行,计费时再按国家匹配。</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取 消</el-button>
|
||||
<el-button type="primary" @click="submit">确 定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup name="SurchargePickerDialog">
|
||||
/**
|
||||
* 套餐 · 附加费勾选弹窗(新建流程第 3 步)
|
||||
*
|
||||
* 数据来源:产品已关联的附加费配置(prc_surcharge_rel ∩ prc_surcharge),
|
||||
* 与明细表格里「附加费」列展示的口径一致;勾选结果由父级写回该产品的明细行。
|
||||
*/
|
||||
import { formatChargeValue, surchargeScopeLabel } from '../../utils/priceSet'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
title: { type: String, default: '附加费' },
|
||||
/** 附加费配置主表全量(refData.allSurchargeConfigs()) */
|
||||
list: { type: Array, default: () => [] },
|
||||
/** 当前已勾选的 scId 集合,用于打开时回显 */
|
||||
selectedIds: { type: Array, default: () => [] }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||
|
||||
const { bms_charge_type } = useDict('bms_charge_type')
|
||||
|
||||
const tableRef = ref()
|
||||
const picked = ref([])
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: v => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
/** 同一附加费可能同时挂在产品级与国家/重量段上,按 scId 合并成一行,作用范围拼接展示 */
|
||||
const rows = computed(() => {
|
||||
const map = new Map()
|
||||
;(props.list || []).forEach(rel => {
|
||||
if (!rel || rel.scId == null) return
|
||||
const key = String(rel.scId)
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { ...rel, scopeTexts: [] })
|
||||
}
|
||||
const item = map.get(key)
|
||||
const text = surchargeScopeLabel(rel)
|
||||
if (!item.scopeTexts.includes(text)) item.scopeTexts.push(text)
|
||||
})
|
||||
return [...map.values()].map(item => ({
|
||||
...item,
|
||||
scopeText: item.scopeTexts.join(' / ')
|
||||
}))
|
||||
})
|
||||
|
||||
function chargeTypeLabel(val) {
|
||||
const found = (bms_charge_type.value || []).find(c => c.value === val)
|
||||
return found ? found.label : (val || '-')
|
||||
}
|
||||
|
||||
function onSelectionChange(selection) {
|
||||
picked.value = selection
|
||||
}
|
||||
|
||||
/** 确定:以表格当前选中为准(回显/重渲染可能清掉 selection,不能只信 selection-change 的快照) */
|
||||
function submit() {
|
||||
const table = tableRef.value
|
||||
let list = picked.value
|
||||
if (table && typeof table.getSelection === 'function') {
|
||||
const cur = table.getSelection() || []
|
||||
if (cur.length || !list.length) list = cur
|
||||
}
|
||||
emit('confirm', list)
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
/** 回显勾选:首次打开时 el-dialog 内容才挂载,取不到表格就再等一帧(最多 5 帧) */
|
||||
function applySelection(ids, tries) {
|
||||
const table = tableRef.value
|
||||
if (!table) {
|
||||
if ((tries || 0) < 5) nextTick(() => applySelection(ids, (tries || 0) + 1))
|
||||
return
|
||||
}
|
||||
table.clearSelection()
|
||||
rows.value.forEach(row => {
|
||||
table.toggleRowSelection(row, ids.includes(String(row.scId)))
|
||||
})
|
||||
picked.value = rows.value.filter(row => ids.includes(String(row.scId)))
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, v => {
|
||||
if (!v) return
|
||||
const ids = (props.selectedIds || []).map(id => String(id))
|
||||
nextTick(() => applySelection(ids, 0))
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- 非 scoped:弹窗被 append-to-body 传送到 body,样式需全局生效;.ps-picker-bar 工具类见 styles/dialog.scss -->
|
||||
<style lang="scss" src="../../styles/dialog.scss"></style>
|
||||
@@ -22,10 +22,7 @@
|
||||
*/
|
||||
import SetSectionCard from './SetSectionCard.vue'
|
||||
import {
|
||||
formatDateRange,
|
||||
isDiscountType,
|
||||
setStatusLabel,
|
||||
setStatusTagType,
|
||||
setTypeLabel
|
||||
} from '../../utils/priceSet'
|
||||
|
||||
@@ -50,8 +47,6 @@ const items = computed(() => {
|
||||
value: discount ? `${s.defaultDiscount}%` : '不适用(FIXED)',
|
||||
muted: !discount
|
||||
},
|
||||
{ label: '状态', value: setStatusLabel(s), tag: setStatusTagType(s) },
|
||||
{ label: '有效期', value: formatDateRange(s.effectiveDate, s.expireDate) },
|
||||
{ label: '创建人', value: s.createBy || '-' },
|
||||
{ label: '创建时间', value: s.createTime || '-' },
|
||||
{ label: '最后更新', value: (s.updateTime || '-') + ' ' + updater }
|
||||
|
||||
@@ -5,17 +5,6 @@
|
||||
:count="customers.length"
|
||||
sub="一个客户只能绑定一个套餐"
|
||||
>
|
||||
<template #action>
|
||||
<el-button
|
||||
v-if="editable"
|
||||
type="primary"
|
||||
size="small"
|
||||
icon="Plus"
|
||||
@click="dialogRef.open()"
|
||||
v-hasPermi="['price:set:edit']"
|
||||
>添加客户</el-button>
|
||||
</template>
|
||||
|
||||
<el-table :data="customers" border class="customer-table" empty-text="暂未绑定客户">
|
||||
<el-table-column label="客户代码" prop="customerCode" min-width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
@@ -29,62 +18,18 @@
|
||||
<span v-else class="priority">{{ row.bindPriority }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="有效期" min-width="220">
|
||||
<template #default="{ row }">{{ formatDateRange(row.prBegindate, row.prEnddate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="editable" label="操作" width="120" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="dialogRef.open(row)" v-hasPermi="['price:set:edit']">编辑</el-button>
|
||||
<el-button link type="danger" @click="remove(row)" v-hasPermi="['price:set:edit']">解绑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="table-hint">解绑后该客户可绑定其他套餐;生效中的套餐修改绑定会即时影响计费命中。</div>
|
||||
|
||||
<set-customer-dialog
|
||||
ref="dialogRef"
|
||||
:price-set-id="set.priceSetId"
|
||||
:default-begindate="set.effectiveDate"
|
||||
:default-enddate="set.expireDate"
|
||||
@success="emit('refresh')"
|
||||
/>
|
||||
</set-section-card>
|
||||
</template>
|
||||
|
||||
<script setup name="SetCustomerTable">
|
||||
/**
|
||||
* 套餐详情 · 绑定客户表格
|
||||
* 自带新增 / 编辑弹窗与解绑,数据变更后 emit('refresh') 由页面重新拉取详情。
|
||||
*/
|
||||
import SetSectionCard from './SetSectionCard.vue'
|
||||
import SetCustomerDialog from './SetCustomerDialog.vue'
|
||||
import { formatDateRange, isChildEditable } from '../../utils/priceSet'
|
||||
import { delSetCustomer } from '@/api/price/set'
|
||||
|
||||
const props = defineProps({
|
||||
/** 套餐详情对象 */
|
||||
set: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
set: { type: Object, required: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['refresh'])
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
|
||||
const dialogRef = ref()
|
||||
|
||||
const customers = computed(() => props.set.customers || [])
|
||||
const editable = computed(() => isChildEditable(props.set.status))
|
||||
|
||||
function remove(row) {
|
||||
proxy.$modal.confirm('确认解绑该客户?').then(() => delSetCustomer(row.customerPricesetId)).then(() => {
|
||||
proxy.$modal.msgSuccess('已解绑')
|
||||
emit('refresh')
|
||||
}).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -104,10 +49,4 @@ function remove(row) {
|
||||
.priority {
|
||||
color: #303133;
|
||||
}
|
||||
.table-hint {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
/>
|
||||
<span class="ps-field__unit">RMB/KG</span>
|
||||
</div>
|
||||
<div class="ps-hint">可留空:原基础表无值时表示不覆盖运费,按产品基础价计费</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
|
||||
@@ -6,60 +6,40 @@
|
||||
<span v-if="set.priceSetCode" class="hd-code">{{ set.priceSetCode }}</span>
|
||||
</div>
|
||||
<div class="hd-line hd-meta">
|
||||
<el-tag :type="statusTagType" size="small" effect="dark">{{ statusText }}</el-tag>
|
||||
<el-tag :type="isFixedType(set.setType) ? 'warning' : 'primary'" size="small" effect="plain">
|
||||
{{ setTypeLabel(set.setType) }}
|
||||
</el-tag>
|
||||
<span class="hd-range">
|
||||
<el-icon class="hd-icon"><Calendar /></el-icon>
|
||||
{{ dateRange }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hd-actions">
|
||||
<el-button @click="emit('back')">返回列表</el-button>
|
||||
<template v-if="editableStatus">
|
||||
<el-button type="primary" @click="emit('edit')" v-hasPermi="['price:set:edit']">继续配置</el-button>
|
||||
<el-button type="primary" plain @click="emit('submit')" v-hasPermi="['price:set:audit']">提交审核</el-button>
|
||||
</template>
|
||||
<template v-else-if="set.status === 'A'">
|
||||
<el-button type="success" @click="emit('approve')" v-hasPermi="['price:set:audit']">审批生效</el-button>
|
||||
<el-button type="warning" plain @click="emit('reject')" v-hasPermi="['price:set:audit']">驳回</el-button>
|
||||
</template>
|
||||
<el-button
|
||||
v-else-if="set.status === 'E' && !set.expired"
|
||||
v-if="set.status === 'E' && !set.expired"
|
||||
type="danger"
|
||||
plain
|
||||
@click="emit('disable')"
|
||||
v-hasPermi="['price:set:audit']"
|
||||
>停用</el-button>
|
||||
<el-button v-else-if="set.status === 'S'" type="primary" plain @click="emit('copy')" v-hasPermi="['price:set:add']">复用新建</el-button>
|
||||
<el-button
|
||||
v-else-if="set.status === 'S'"
|
||||
type="primary"
|
||||
plain
|
||||
@click="emit('copy')"
|
||||
v-hasPermi="['price:set:add']"
|
||||
>复用新建</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="SetDetailHeader">
|
||||
/**
|
||||
* 套餐详情 · 页头
|
||||
* 只负责展示套餐标识 / 状态与派发操作事件,接口调用与确认框由页面处理。
|
||||
*/
|
||||
import { SET_STATUS, formatDateRange, isFixedType, setStatusLabel, setStatusTagType, setTypeLabel } from '../../utils/priceSet'
|
||||
import { isFixedType, setTypeLabel } from '../../utils/priceSet'
|
||||
|
||||
const props = defineProps({
|
||||
/** 套餐详情对象 */
|
||||
set: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
defineProps({
|
||||
set: { type: Object, required: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['back', 'edit', 'submit', 'approve', 'reject', 'disable', 'copy'])
|
||||
|
||||
const statusText = computed(() => setStatusLabel(props.set))
|
||||
const statusTagType = computed(() => setStatusTagType(props.set))
|
||||
const dateRange = computed(() => formatDateRange(props.set.effectiveDate, props.set.expireDate))
|
||||
const editableStatus = computed(() => [SET_STATUS.DRAFT, SET_STATUS.REJECT].includes(props.set.status))
|
||||
const emit = defineEmits(['back', 'disable', 'copy'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -76,9 +56,7 @@ const editableStatus = computed(() => [SET_STATUS.DRAFT, SET_STATUS.REJECT].incl
|
||||
border-left: 3px solid #1890ff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.hd-main {
|
||||
min-width: 0;
|
||||
}
|
||||
.hd-main { min-width: 0; }
|
||||
.hd-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -101,19 +79,7 @@ const editableStatus = computed(() => [SET_STATUS.DRAFT, SET_STATUS.REJECT].incl
|
||||
border-radius: 4px;
|
||||
padding: 1px 8px;
|
||||
}
|
||||
.hd-meta {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.hd-range {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
.hd-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
.hd-meta { margin-top: 10px; }
|
||||
.hd-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -36,8 +36,13 @@
|
||||
<el-table-column label="重量段" prop="weightLabel" min-width="130" />
|
||||
<el-table-column label="运费单价" min-width="130" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="hl">{{ row.fvValue }}</span>
|
||||
<span class="unit"> RMB/KG</span>
|
||||
<template v-if="row.fvValue == null">
|
||||
<span class="unit">未覆盖</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="hl">{{ row.fvValue }}</span>
|
||||
<span class="unit"> RMB/KG</span>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="挂号费" min-width="120" align="right">
|
||||
@@ -57,7 +62,7 @@
|
||||
|
||||
<el-table-column label="附加费" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<surcharge-tags :list="surchargesOf(row)" />
|
||||
<surcharge-tags :list="surchargesOfRow(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -116,6 +121,11 @@ const { bms_round_mode } = useDict('bms_round_mode')
|
||||
|
||||
const { surchargesOf } = props.refData
|
||||
|
||||
function surchargesOfRow(row) {
|
||||
if (row && row.surcharges && row.surcharges.length) return row.surcharges
|
||||
return surchargesOf(row)
|
||||
}
|
||||
|
||||
const dialogRef = ref()
|
||||
|
||||
const details = computed(() => props.set.details || [])
|
||||
@@ -126,7 +136,7 @@ const subTitle = computed(() => isFixedType(props.set.setType)
|
||||
: '按产品限定折扣,留空走套餐默认折扣')
|
||||
|
||||
const hint = computed(() => isFixedType(props.set.setType)
|
||||
? '运费单价单位 RMB/KG,挂号费单位 RMB/票;附加费来自产品关联配置,此处只读展示。'
|
||||
? '运费单价单位 RMB/KG(留空表示不覆盖,按产品基础价计费),挂号费单位 RMB/票;附加费来自产品关联配置,此处只读展示。'
|
||||
: '未配置明细时全部在售产品均适用套餐默认折扣。')
|
||||
|
||||
function roundModeLabel(mode) {
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
:model-value="modelValue"
|
||||
:title="title"
|
||||
size="72%"
|
||||
destroy-on-close
|
||||
@close="close"
|
||||
>
|
||||
<div class="drawer-body" v-loading="loading">
|
||||
<div class="toolbar">
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
clearable
|
||||
:placeholder="phSearch"
|
||||
style="width: 280px"
|
||||
@keyup.enter="search"
|
||||
/>
|
||||
<el-button type="primary" @click="search">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="rows" border height="calc(100vh - 220px)" empty-text="暂无明细">
|
||||
<el-table-column :label="colProduct" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-product">
|
||||
<span class="p-code">{{ row.productCode }}</span>
|
||||
<span class="p-name">{{ row.productName || '' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<template v-if="isFixed">
|
||||
<el-table-column :label="colCountry" min-width="120">
|
||||
<template #default="{ row }">{{ row.countryName || row.countryCode || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="colZone" min-width="110">
|
||||
<template #default="{ row }">{{ row.zoneName || row.zoneCode || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="colWeight" prop="weightLabel" min-width="120" />
|
||||
<el-table-column :label="colFv" min-width="130" align="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.fvValue == null">
|
||||
<span class="unit">未覆盖</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="hl">{{ row.fvValue }}</span>
|
||||
<span class="unit"> RMB/KG</span>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="colReg" min-width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="reg">{{ row.fvRegistrationFee }}</span>
|
||||
<span class="unit"> RMB/票</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
|
||||
<el-table-column v-else :label="colDisc" min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.discountValue == null" size="small" type="info" effect="plain">默认折扣</el-tag>
|
||||
<span v-else class="pct">{{ row.discountValue }}%</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column :label="colSc" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<surcharge-tags :list="row.surcharges || []" :empty-text="noneSc" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column :label="colRound" min-width="100" align="center">
|
||||
<template #default="{ row }">{{ roundModeLabel(row.roundMode) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pager">
|
||||
<pagination
|
||||
v-show="total > 0"
|
||||
:total="total"
|
||||
v-model:page="query.pageNum"
|
||||
v-model:limit="query.pageSize"
|
||||
@pagination="load"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="close">关闭</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup name="SetVersionDetailDrawer">
|
||||
import SurchargeTags from '../SurchargeTags.vue'
|
||||
import { isFixedType } from '../../utils/priceSet'
|
||||
import { listSetDetail } from '@/api/price/set'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
version: { type: Object, default: null },
|
||||
setType: { type: String, default: 'FIXED' }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const { bms_round_mode } = useDict('bms_round_mode')
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref([])
|
||||
const total = ref(0)
|
||||
const query = reactive({
|
||||
pageNum: 1,
|
||||
pageSize: 20,
|
||||
keyword: ''
|
||||
})
|
||||
|
||||
const phSearch = '产品/国家/分区/重量段'
|
||||
const colProduct = '产品'
|
||||
const colCountry = '国家'
|
||||
const colZone = '分区'
|
||||
const colWeight = '重量段'
|
||||
const colFv = '运费单价'
|
||||
const colReg = '挂号费'
|
||||
const colDisc = '产品折扣'
|
||||
const colSc = '附加费'
|
||||
const colRound = '取值方式'
|
||||
const noneSc = '无'
|
||||
|
||||
const isFixed = computed(() => isFixedType(props.setType))
|
||||
const title = computed(() => {
|
||||
const v = props.version || {}
|
||||
return (v.versionNo || '-') + ' · 版本明细'
|
||||
})
|
||||
|
||||
function roundModeLabel(mode) {
|
||||
const hit = (bms_round_mode.value || []).find(d => d.value === mode)
|
||||
return hit ? hit.label : (mode || '四舍五入')
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function search() {
|
||||
query.pageNum = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
query.keyword = ''
|
||||
query.pageNum = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function load() {
|
||||
const psvId = props.version && props.version.psvId
|
||||
if (!psvId) {
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
listSetDetail({
|
||||
psvId,
|
||||
keyword: query.keyword || undefined,
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize
|
||||
}).then(res => {
|
||||
rows.value = res.rows || []
|
||||
total.value = res.total || 0
|
||||
}).finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.modelValue, props.version && props.version.psvId],
|
||||
([open]) => {
|
||||
if (open) {
|
||||
query.pageNum = 1
|
||||
query.keyword = ''
|
||||
load()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.drawer-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pager {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.cell-product {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
.p-code {
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
background: #f4f4f5;
|
||||
border-radius: 3px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
.p-name { color: #303133; }
|
||||
.hl { color: #1890ff; font-weight: 600; }
|
||||
.reg { color: #d48806; font-weight: 600; }
|
||||
.pct { color: #1890ff; font-weight: 600; }
|
||||
.unit { font-size: 12px; color: #909399; }
|
||||
</style>
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<set-section-card :title="title" tone="green" :count="filteredVersions.length">
|
||||
<el-tabs v-model="statusTab" class="ver-tabs">
|
||||
<el-tab-pane :label="tabLabel('all')" name="all" />
|
||||
<el-tab-pane :label="tabLabel('0')" name="0" />
|
||||
<el-tab-pane :label="tabLabel('1')" name="1" />
|
||||
<el-tab-pane :label="tabLabel('2')" name="2" />
|
||||
</el-tabs>
|
||||
|
||||
<el-table :data="filteredVersions" border empty-text="暂无版本">
|
||||
<el-table-column :label="colNo" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetails(row)">{{ row.versionNo || '-' }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="colEff" min-width="170">
|
||||
<template #default="{ row }">{{ row.effectiveTime || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="colExp" min-width="170">
|
||||
<template #default="{ row }">{{ row.expireTime || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="colStatus" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="versionStatusTag(row.status)" size="small">{{ versionStatusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="colRows" width="100" align="center">
|
||||
<template #default="{ row }">{{ detailCount(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="colAction" width="320" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetails(row)">查看详情</el-button>
|
||||
<el-button
|
||||
v-if="String(row.status) === '0'"
|
||||
link
|
||||
type="primary"
|
||||
@click="emit('edit', row)"
|
||||
v-hasPermi="['price:set:edit']"
|
||||
>继续配置</el-button>
|
||||
<el-button
|
||||
v-if="String(row.status) === '0'"
|
||||
link
|
||||
type="success"
|
||||
@click="emit('activate', row)"
|
||||
v-hasPermi="['price:set:edit']"
|
||||
>启动</el-button>
|
||||
<el-button
|
||||
v-if="String(row.status) === '0'"
|
||||
link
|
||||
type="danger"
|
||||
@click="emit('remove', row)"
|
||||
v-hasPermi="['price:set:edit']"
|
||||
>删除</el-button>
|
||||
<el-button
|
||||
v-if="String(row.status) === '1'"
|
||||
link
|
||||
type="primary"
|
||||
@click="emit('copy-version', row)"
|
||||
v-hasPermi="['price:set:add']"
|
||||
>复制为新版本</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</set-section-card>
|
||||
</template>
|
||||
|
||||
<script setup name="SetVersionTable">
|
||||
import SetSectionCard from './SetSectionCard.vue'
|
||||
import { versionStatusLabel, versionStatusTag } from '../../utils/priceSet'
|
||||
|
||||
const props = defineProps({
|
||||
versions: { type: Array, default: () => [] },
|
||||
modelValue: { type: [Number, String], default: undefined }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'copy-version', 'open-details', 'activate', 'edit', 'remove'])
|
||||
|
||||
const title = '套餐版本'
|
||||
const colNo = '版本号'
|
||||
const colEff = '生效时间'
|
||||
const colExp = '失效时间'
|
||||
const colStatus = '状态'
|
||||
const colRows = '明细行数'
|
||||
const colAction = '操作'
|
||||
const statusTab = ref('all')
|
||||
|
||||
const counts = computed(() => {
|
||||
const map = { all: 0, '0': 0, '1': 0, '2': 0 }
|
||||
;(props.versions || []).forEach(v => {
|
||||
map.all++
|
||||
const s = String(v.status == null ? '' : v.status)
|
||||
if (map[s] != null) map[s]++
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
function tabLabel(key) {
|
||||
const labels = {
|
||||
all: '全部',
|
||||
'0': '草稿',
|
||||
'1': '生效',
|
||||
'2': '失效'
|
||||
}
|
||||
return labels[key] + ' (' + (counts.value[key] || 0) + ')'
|
||||
}
|
||||
|
||||
const filteredVersions = computed(() => {
|
||||
const list = props.versions || []
|
||||
if (statusTab.value === 'all') return list
|
||||
return list.filter(v => String(v.status) === statusTab.value)
|
||||
})
|
||||
|
||||
function detailCount(row) {
|
||||
if (row && row.detailCount != null) return row.detailCount
|
||||
return (row && row.details || []).length
|
||||
}
|
||||
|
||||
function openDetails(row) {
|
||||
if (!row || row.psvId == null) return
|
||||
emit('update:modelValue', row.psvId)
|
||||
emit('open-details', row)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ver-tabs {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ver-tabs :deep(.el-tabs__header) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -5,7 +5,7 @@
|
||||
* 避免产品 / 产品国家 / 附加费 被重复请求。
|
||||
*/
|
||||
import { listProduct, listProductCountries } from '@/api/price/product'
|
||||
import { listSurchargeByProduct } from '@/api/price/surcharge'
|
||||
import { listSurchargeByProduct, listSurchargeOptions } from '@/api/price/surcharge'
|
||||
import {
|
||||
buildCountryOptions,
|
||||
buildZoneOptions,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
countryKeyOf,
|
||||
formatWeightLabel,
|
||||
matchCountryZone,
|
||||
surchargeAppliesToRow,
|
||||
toEditableDetailRow
|
||||
} from '../utils/priceSet'
|
||||
|
||||
@@ -25,20 +26,30 @@ export function usePriceSetRefData() {
|
||||
const productOptions = ref([])
|
||||
/** productCode -> bms_product_country 行 */
|
||||
const countryCache = reactive({})
|
||||
/** productCode -> 进行中的国家明细请求(去重,避免 hydrate 按行打爆接口) */
|
||||
const countryPending = {}
|
||||
/** productCode -> 有效附加费关联 */
|
||||
const surchargeCache = reactive({})
|
||||
/** productCode -> 进行中的请求 */
|
||||
const surchargePending = {}
|
||||
/** 价格基础 → 附加费配置 主表全量(勾选弹窗数据源,页面内共享一份) */
|
||||
const allSurchargeList = ref(null)
|
||||
let allSurchargePending = null
|
||||
|
||||
const productMap = computed(() => new Map(productOptions.value.map(p => [p.value, p])))
|
||||
|
||||
/** 加载可选产品(已生效) */
|
||||
/** 加载可选产品(已生效),保留列表展示字段 */
|
||||
function loadProducts() {
|
||||
return listProduct({ status: '1', pageNum: 1, pageSize: 1000 }).then(res => {
|
||||
productOptions.value = (res.rows || []).map(p => ({
|
||||
value: p.productCode,
|
||||
label: `${p.productCode} ${p.productName || ''}`.trim(),
|
||||
name: p.productName
|
||||
name: p.productName,
|
||||
serviceLevel: p.serviceLevel,
|
||||
cargoAttr: p.cargoAttr,
|
||||
destRegion: p.destRegion,
|
||||
taxMode: p.taxMode,
|
||||
countryCount: p.countryCount
|
||||
}))
|
||||
return productOptions.value
|
||||
})
|
||||
@@ -50,15 +61,72 @@ export function usePriceSetRefData() {
|
||||
return hit ? hit.name : productCode
|
||||
}
|
||||
|
||||
/** 确保产品的国家×重量段配置已加载(过滤已失效配置) */
|
||||
function ensureCountries(productCode) {
|
||||
/** product meta for step-3 product list */
|
||||
function productMetaOf(productCode) {
|
||||
return productMap.value.get(productCode) || null
|
||||
}
|
||||
|
||||
/** YYYY-MM-DD from datetime / date */
|
||||
function dateOnly(v) {
|
||||
if (!v) return null
|
||||
return String(v).slice(0, 10)
|
||||
}
|
||||
|
||||
/**
|
||||
* Country row active at asOf (version effective time).
|
||||
* Window: effective_date <= asOf AND (expire_date is null OR expire_date > asOf)
|
||||
*/
|
||||
function isCountryActiveAt(item, asOf) {
|
||||
const t = dateOnly(asOf)
|
||||
if (!t) return true
|
||||
const eff = dateOnly(item.effectiveDate)
|
||||
const exp = dateOnly(item.expireDate)
|
||||
if (eff && eff > t) return false
|
||||
if (exp && exp <= t) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** currently open country rows (not expired) */
|
||||
function isCountryOpen(item) {
|
||||
if (String(item.status) === '2') return false
|
||||
const exp = dateOnly(item.expireDate)
|
||||
if (!exp) return true
|
||||
const today = dateOnly(new Date().toISOString())
|
||||
return exp > today
|
||||
}
|
||||
|
||||
/**
|
||||
* Load product country×weight rows.
|
||||
* Cache keeps full list; pass asOf (version effective_time) to filter the active window.
|
||||
* If asOf match is empty, fall back to currently open rows so step-3 can still show full product prices.
|
||||
* In-flight requests are deduped per productCode (hydrateRow is per-detail-row).
|
||||
*/
|
||||
function ensureCountries(productCode, asOf) {
|
||||
if (!productCode) return Promise.resolve([])
|
||||
if (countryCache[productCode]) return Promise.resolve(countryCache[productCode])
|
||||
return listProductCountries(productCode).then(res => {
|
||||
const rows = (res.data || []).filter(item => !item.expireDate)
|
||||
const filterRows = (rows) => {
|
||||
const all = rows || []
|
||||
if (!asOf) return all.filter(isCountryOpen)
|
||||
const hit = all.filter(item => isCountryActiveAt(item, asOf))
|
||||
if (hit.length) return hit
|
||||
return all.filter(isCountryOpen)
|
||||
}
|
||||
if (countryCache[productCode]) {
|
||||
return Promise.resolve(filterRows(countryCache[productCode]))
|
||||
}
|
||||
if (countryPending[productCode]) {
|
||||
return countryPending[productCode].then(filterRows)
|
||||
}
|
||||
countryPending[productCode] = listProductCountries(productCode).then(res => {
|
||||
const rows = res.data || []
|
||||
countryCache[productCode] = rows
|
||||
return rows
|
||||
}).catch(() => {
|
||||
// do not cache failure; allow later retry without stampeding while in-flight
|
||||
return []
|
||||
}).finally(() => {
|
||||
delete countryPending[productCode]
|
||||
})
|
||||
return countryPending[productCode].then(filterRows)
|
||||
}
|
||||
|
||||
/** 确保产品的有效附加费已加载(剔除 OF 燃油,结果缓存 + 请求去重) */
|
||||
@@ -71,7 +139,7 @@ export function usePriceSetRefData() {
|
||||
surchargeCache[productCode] = rows
|
||||
return rows
|
||||
}).catch(() => {
|
||||
surchargeCache[productCode] = []
|
||||
// 失败不写缓存,后续可重试(避免一次抖动就让该产品永久显示无附加费)
|
||||
return []
|
||||
}).finally(() => {
|
||||
delete surchargePending[productCode]
|
||||
@@ -79,18 +147,41 @@ export function usePriceSetRefData() {
|
||||
return surchargePending[productCode]
|
||||
}
|
||||
|
||||
/** 产品已关联的附加费全量(不按国家/重量段过滤),供套餐勾选使用 */
|
||||
function allSurchargesOf(productCode) {
|
||||
if (!productCode) return []
|
||||
return surchargeCache[productCode] || []
|
||||
}
|
||||
|
||||
/** 确保附加费配置主表已加载(剔除 OF 燃油,页面内共享一份缓存) */
|
||||
function ensureAllSurcharges() {
|
||||
if (allSurchargeList.value) return Promise.resolve(allSurchargeList.value)
|
||||
if (!allSurchargePending) {
|
||||
allSurchargePending = listSurchargeOptions().then(res => {
|
||||
// /options 返回 AjaxResult(data),兼容分页接口的 rows 写法
|
||||
const raw = res.data || res.rows || []
|
||||
const rows = raw.filter(item => String(item.ssCode || '').toLowerCase() !== 'of')
|
||||
allSurchargeList.value = rows
|
||||
return rows
|
||||
}).catch(() => {
|
||||
// 失败不缓存,允许下次重试;调用方拿空数组也不至于报错中断
|
||||
return []
|
||||
}).finally(() => {
|
||||
allSurchargePending = null
|
||||
})
|
||||
}
|
||||
return allSurchargePending
|
||||
}
|
||||
|
||||
/** 附加费配置主表全量(需先 ensureAllSurcharges) */
|
||||
function allSurchargeConfigs() {
|
||||
return allSurchargeList.value || []
|
||||
}
|
||||
|
||||
/** 明细行命中的附加费(按产品 + 国家 + 重量段过滤) */
|
||||
function surchargesOf(row) {
|
||||
if (!row || !row.productCode) return []
|
||||
const all = surchargeCache[row.productCode] || []
|
||||
return all.filter(s => {
|
||||
if (s.countryCode && row.countryCode && String(s.countryCode).toUpperCase() !== String(row.countryCode).toUpperCase()) {
|
||||
return false
|
||||
}
|
||||
if (s.detailId == null) return true
|
||||
if (row.productCountryId != null && Number(s.detailId) === Number(row.productCountryId)) return true
|
||||
return false
|
||||
})
|
||||
return (surchargeCache[row.productCode] || []).filter(s => surchargeAppliesToRow(s, row))
|
||||
}
|
||||
|
||||
/** 明细行可选国家 */
|
||||
@@ -115,8 +206,12 @@ export function usePriceSetRefData() {
|
||||
productOptions,
|
||||
loadProducts,
|
||||
productNameOf,
|
||||
productMetaOf,
|
||||
ensureCountries,
|
||||
ensureSurcharges,
|
||||
allSurchargesOf,
|
||||
ensureAllSurcharges,
|
||||
allSurchargeConfigs,
|
||||
surchargesOf,
|
||||
countryOptionsOf,
|
||||
zoneOptionsOf,
|
||||
@@ -140,9 +235,13 @@ export function useDetailCascade(refData) {
|
||||
row.zoneCode = undefined
|
||||
row.zoneName = undefined
|
||||
row.weightLabel = undefined
|
||||
row.weightMin = undefined
|
||||
row.weightMax = undefined
|
||||
row.productCountryId = undefined
|
||||
row.fvValue = undefined
|
||||
row.fvRegistrationFee = undefined
|
||||
row.effectiveDate = undefined
|
||||
row.expireDate = undefined
|
||||
}
|
||||
|
||||
/** 选择产品:回填名称、清空级联、预取附加费与国家配置 */
|
||||
@@ -163,9 +262,13 @@ export function useDetailCascade(refData) {
|
||||
row.zoneCode = undefined
|
||||
row.zoneName = undefined
|
||||
row.weightLabel = undefined
|
||||
row.weightMin = undefined
|
||||
row.weightMax = undefined
|
||||
row.productCountryId = undefined
|
||||
row.fvValue = undefined
|
||||
row.fvRegistrationFee = undefined
|
||||
row.effectiveDate = undefined
|
||||
row.expireDate = undefined
|
||||
}
|
||||
|
||||
function onZoneChange(row) {
|
||||
@@ -174,9 +277,13 @@ export function useDetailCascade(refData) {
|
||||
row.zoneCode = opt ? opt.zoneCode : undefined
|
||||
row.zoneName = opt ? opt.zoneName : undefined
|
||||
row.weightLabel = undefined
|
||||
row.weightMin = undefined
|
||||
row.weightMax = undefined
|
||||
row.productCountryId = undefined
|
||||
row.fvValue = undefined
|
||||
row.fvRegistrationFee = undefined
|
||||
row.effectiveDate = undefined
|
||||
row.expireDate = undefined
|
||||
}
|
||||
|
||||
/** 选择重量段:绑定产品国家明细,并带出默认运费 / 挂号费 */
|
||||
@@ -184,6 +291,10 @@ export function useDetailCascade(refData) {
|
||||
const opt = refData.weightOptionsOf(row).find(w => w.value === row.weightLabel)
|
||||
if (!opt) {
|
||||
row.productCountryId = undefined
|
||||
row.weightMin = undefined
|
||||
row.weightMax = undefined
|
||||
row.effectiveDate = undefined
|
||||
row.expireDate = undefined
|
||||
return
|
||||
}
|
||||
row.productCountryId = opt.detailId
|
||||
@@ -192,14 +303,32 @@ export function useDetailCascade(refData) {
|
||||
if (opt.zoneName) row.zoneName = opt.zoneName
|
||||
if (opt.freightPrice != null) row.fvValue = Number(opt.freightPrice)
|
||||
if (opt.registrationFee != null) row.fvRegistrationFee = Number(opt.registrationFee)
|
||||
row.weightMin = opt.weightMin
|
||||
row.weightMax = opt.weightMax
|
||||
row.effectiveDate = opt.effectiveDate
|
||||
row.expireDate = opt.expireDate
|
||||
}
|
||||
|
||||
/** hydrate detail row; normalize weight label to min<W\u2264max */
|
||||
function hydrateRow(d, setType) {
|
||||
const row = toEditableDetailRow(d)
|
||||
if (!row.productCode) return row
|
||||
refData.ensureSurcharges(row.productCode)
|
||||
// Do NOT prefetch /surcharge/byProduct here: applySetData hydrates every detail row
|
||||
// on step-1 load; use version snapshot row.surcharges instead. Fetch on demand in picker.
|
||||
if (setType !== 'FIXED') return row
|
||||
// Bulk load from DB already has productCountryId + weight bounds: normalize locally,
|
||||
// do not fire /countries per detail row (was stampeding thousands of identical requests).
|
||||
if (row.productCountryId != null && (row.weightMin != null || row.weightMax != null || row.weightLabel)) {
|
||||
if (row.weightMin != null || row.weightMax != null) {
|
||||
row.weightLabel = formatWeightLabel(row.weightMin, row.weightMax)
|
||||
}
|
||||
row._countryKey = countryKeyOf(row)
|
||||
// Snapshot from DB: treat current package price as baseline so drawer cells stay editable
|
||||
// and yellow "changed" highlight still works after first edit.
|
||||
if (row._baseFreight == null && row.fvValue != null) row._baseFreight = Number(row.fvValue)
|
||||
if (row._baseReg == null && row.fvRegistrationFee != null) row._baseReg = Number(row.fvRegistrationFee)
|
||||
return row
|
||||
}
|
||||
refData.ensureCountries(row.productCode).then(rows => {
|
||||
if (!row.weightLabel) return
|
||||
const hit = rows.find(item => {
|
||||
@@ -217,6 +346,12 @@ export function useDetailCascade(refData) {
|
||||
if (hit.zoneId != null) row.zoneId = hit.zoneId
|
||||
if (hit.zoneCode) row.zoneCode = hit.zoneCode
|
||||
if (hit.zoneName) row.zoneName = hit.zoneName
|
||||
row.weightMin = hit.weightMin
|
||||
row.weightMax = hit.weightMax
|
||||
row.effectiveDate = hit.effectiveDate
|
||||
row.expireDate = hit.expireDate
|
||||
if (row._baseFreight == null && hit.freightPrice != null) row._baseFreight = Number(hit.freightPrice)
|
||||
if (row._baseReg == null && hit.registrationFee != null) row._baseReg = Number(hit.registrationFee)
|
||||
})
|
||||
return row
|
||||
}
|
||||
|
||||
@@ -1,59 +1,61 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-steps :active="active" finish-status="success" align-center class="wizard-steps">
|
||||
<el-step title="基本信息" />
|
||||
<el-step title="套餐明细" />
|
||||
<el-step title="绑定客户" />
|
||||
<el-step :title="step1Title" />
|
||||
<el-step :title="step2Title" />
|
||||
</el-steps>
|
||||
|
||||
<!-- 第 1 步 -->
|
||||
<step-basic-info v-show="active === 0" ref="basicRef" v-model="form.basic" />
|
||||
<div v-show="active === 0">
|
||||
<step-basic-info ref="basicRef" v-model="form.basic" />
|
||||
</div>
|
||||
|
||||
<!-- 第 2 步 -->
|
||||
<step-details
|
||||
v-show="active === 1"
|
||||
ref="detailsRef"
|
||||
:details="form.details"
|
||||
:price-set-id="form.priceSetId"
|
||||
:set-type="form.basic.setType"
|
||||
:version-no="form.version.versionNo"
|
||||
:ref-data="refData"
|
||||
/>
|
||||
|
||||
<!-- 第 3 步 -->
|
||||
<step-customers
|
||||
v-show="active === 2"
|
||||
:customers="form.customers"
|
||||
:effective-date="form.basic.effectiveDate"
|
||||
:expire-date="form.basic.expireDate"
|
||||
/>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<el-button @click="goBack">取消</el-button>
|
||||
<el-button v-if="active > 0" @click="active--">上一步</el-button>
|
||||
<el-button v-if="active < 2" type="primary" @click="nextStep">下一步</el-button>
|
||||
<el-button type="info" :loading="saving" @click="saveDraft">保存草稿</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="saveAndSubmit">保存并提交审核</el-button>
|
||||
<el-button v-if="active > 0 && !directStep2" @click="active--">上一步</el-button>
|
||||
<el-button v-if="active === 0" type="primary" :loading="creating" @click="goStep2">创建套餐价格</el-button>
|
||||
<el-button v-if="active === 0" type="info" :loading="saving" @click="saveDraft">保存草稿</el-button>
|
||||
<el-button v-if="active === 1" type="primary" :loading="saving" @click="saveDraft">保存</el-button>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="progressVisible"
|
||||
:title="'正在保存套餐'"
|
||||
width="420px"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="false"
|
||||
:show-close="false"
|
||||
append-to-body
|
||||
>
|
||||
<div class="save-progress">
|
||||
<el-progress :percentage="progressPercent" :status="progressStatus" />
|
||||
<p class="save-progress__msg">{{ progressMessage }}</p>
|
||||
<p class="save-progress__hint">请勿关闭页面,大量明细保存可能需要一些时间</p>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="PriceSetCreate">
|
||||
/**
|
||||
* 新建 / 编辑套餐 · 流程编排
|
||||
*
|
||||
* 三步表单已拆分到 components/,页面只负责:
|
||||
* 步骤切换、保存前校验、payload 组装、路由返回。
|
||||
* 参考数据(产品 / 国家 / 附加费)由 composables 提供,整页共享一份缓存。
|
||||
*/
|
||||
import StepBasicInfo from './components/create/StepBasicInfo.vue'
|
||||
import StepDetails from './components/create/StepDetails.vue'
|
||||
import StepCustomers from './components/create/StepCustomers.vue'
|
||||
import { usePriceSetRefData, useDetailCascade } from './composables/usePriceSetRefData'
|
||||
import {
|
||||
createEmptyBasicInfo,
|
||||
createEmptyDetailRow,
|
||||
nextVersionNo,
|
||||
toCustomerPayload,
|
||||
toDetailPayload
|
||||
} from './utils/priceSet'
|
||||
import { getPriceSet, savePriceSet } from '@/api/price/set'
|
||||
import { getPriceSet, savePriceSetAsync } from '@/api/price/set'
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
const route = useRoute()
|
||||
@@ -63,71 +65,144 @@ const cascade = useDetailCascade(refData)
|
||||
|
||||
const active = ref(0)
|
||||
const saving = ref(false)
|
||||
const creating = ref(false)
|
||||
const progressVisible = ref(false)
|
||||
const progressPercent = ref(0)
|
||||
const progressMessage = ref('')
|
||||
const progressStatus = ref(undefined)
|
||||
const basicRef = ref()
|
||||
const detailsRef = ref()
|
||||
const copyMode = computed(() => String(route.query.copyVersion || '') === '1')
|
||||
/** Entered from detail: continue-config / copy-version jump to step2 — hide 上一步 */
|
||||
const directStep2 = computed(() => copyMode.value || String(route.query.step || '') === '2')
|
||||
|
||||
const step1Title = '基本信息'
|
||||
const step2Title = '编辑产品价格'
|
||||
|
||||
/** 整个套餐表单:基本信息 / 明细 / 绑定客户 */
|
||||
const form = reactive({
|
||||
priceSetId: undefined,
|
||||
basic: createEmptyBasicInfo(),
|
||||
version: { psvId: undefined, versionNo: 'V1', effectiveTime: undefined, expireTime: undefined },
|
||||
details: [],
|
||||
customers: []
|
||||
})
|
||||
|
||||
/** 下一步:仅第 1 步需要校验 */
|
||||
async function nextStep() {
|
||||
if (active.value === 0) {
|
||||
const valid = await basicRef.value.validate()
|
||||
if (!valid) return
|
||||
function mapSaveProgressMessage(message) {
|
||||
const m = message || ''
|
||||
if (!m) return ''
|
||||
if (m === 'queued') return '排队中'
|
||||
if (m === 'pulling library') return '拉取产品库'
|
||||
if (m === 'details exist, skip pull') return '明细已存在,跳过拉取'
|
||||
if (m === 'saving basic') return '保存基本信息'
|
||||
if (m === 'saving version') return '保存版本'
|
||||
if (m.startsWith('saving details ')) return '保存明细 ' + m.slice('saving details '.length)
|
||||
if (m === 'saving surcharges') return '保存附加费'
|
||||
if (m === 'saving customers') return '保存客户'
|
||||
if (m === 'finishing') return '收尾中'
|
||||
if (m === 'done') return '完成'
|
||||
if (m === 'failed') return '失败'
|
||||
return m
|
||||
}
|
||||
|
||||
async function runSaveWithProgress(payload) {
|
||||
progressVisible.value = true
|
||||
progressPercent.value = 0
|
||||
progressMessage.value = ''
|
||||
progressStatus.value = undefined
|
||||
try {
|
||||
const result = await savePriceSetAsync(payload, (task) => {
|
||||
progressPercent.value = Math.min(100, Number(task.percent) || 0)
|
||||
progressMessage.value = mapSaveProgressMessage(task.message)
|
||||
if (task.status === 'FAIL') {
|
||||
progressStatus.value = 'exception'
|
||||
} else if (task.status === 'SUCCESS') {
|
||||
progressStatus.value = 'success'
|
||||
} else {
|
||||
progressStatus.value = undefined
|
||||
}
|
||||
})
|
||||
progressPercent.value = 100
|
||||
progressStatus.value = 'success'
|
||||
return result.priceSetId
|
||||
} catch (e) {
|
||||
progressStatus.value = 'exception'
|
||||
const msg = (e && e.task && e.task.error) || (e && e.message) || '保存失败'
|
||||
progressMessage.value = msg
|
||||
if (proxy && proxy.$modal) {
|
||||
proxy.$modal.msgError(msg)
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 800))
|
||||
throw e
|
||||
} finally {
|
||||
progressVisible.value = false
|
||||
}
|
||||
active.value++
|
||||
}
|
||||
|
||||
function saveDraft() {
|
||||
save(false)
|
||||
/** Step1 -> Step2: create package prices from library when needed */
|
||||
async function goStep2() {
|
||||
const validBasic = await basicRef.value.validate()
|
||||
if (!validBasic) return
|
||||
|
||||
const hasPersistedDetails = (form.details || []).some(d => d && d.psdId)
|
||||
if (form.priceSetId && form.version.psvId && hasPersistedDetails) {
|
||||
active.value = 1
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
form.details.splice(0, form.details.length)
|
||||
const priceSetId = await runSaveWithProgress({
|
||||
...buildPayload(),
|
||||
details: [],
|
||||
pullFromLibrary: 'Y'
|
||||
})
|
||||
form.priceSetId = priceSetId
|
||||
const fresh = await getPriceSet(priceSetId)
|
||||
applySetData(fresh.data || {}, false)
|
||||
active.value = 1
|
||||
proxy.$modal.msgSuccess('已创建套餐价格,可在第 2 步编辑')
|
||||
} catch (e) {
|
||||
// surfaced
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function saveAndSubmit() {
|
||||
save(true)
|
||||
}
|
||||
|
||||
/** 校验 + 保存;校验失败回到第 1 步 */
|
||||
async function save(submit) {
|
||||
const valid = await basicRef.value.validate()
|
||||
if (!valid) {
|
||||
async function saveDraft() {
|
||||
const validBasic = await basicRef.value.validate()
|
||||
if (!validBasic) {
|
||||
active.value = 0
|
||||
return
|
||||
}
|
||||
const ids = form.customers.filter(c => c.customerId).map(c => String(c.customerId))
|
||||
if (new Set(ids).size !== ids.length) {
|
||||
proxy.$modal.msgError('该客户已绑定套餐,一个客户只能绑定一个套餐')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
savePriceSet(buildPayload(submit)).then(res => {
|
||||
form.priceSetId = res.data
|
||||
proxy.$modal.msgSuccess(submit ? '已保存并提交审核' : '草稿已保存,套餐ID=' + res.data)
|
||||
try {
|
||||
const priceSetId = await runSaveWithProgress(buildPayload())
|
||||
form.priceSetId = priceSetId
|
||||
proxy.$modal.msgSuccess('草稿已保存,套餐ID=' + priceSetId)
|
||||
goBack()
|
||||
}).catch(() => {
|
||||
// 错误提示由请求拦截器统一处理
|
||||
}).finally(() => {
|
||||
} catch (e) {
|
||||
// surfaced
|
||||
} finally {
|
||||
saving.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** 表单 -> 后端 payload */
|
||||
function buildPayload(submitFlag) {
|
||||
const { basic } = form
|
||||
function buildPayload() {
|
||||
const { basic, version } = form
|
||||
return {
|
||||
priceSetId: form.priceSetId,
|
||||
priceSetName: basic.priceSetName,
|
||||
setType: basic.setType,
|
||||
defaultDiscount: basic.setType === 'DISCOUNT' ? basic.defaultDiscount : undefined,
|
||||
effectiveDate: basic.effectiveDate,
|
||||
expireDate: basic.expireDate || undefined,
|
||||
priceSetNote: basic.priceSetNote,
|
||||
version: {
|
||||
psvId: copyMode.value ? undefined : version.psvId,
|
||||
versionNo: version.versionNo
|
||||
// no effectiveTime: set on activate
|
||||
},
|
||||
details: form.details.filter(d => d.productCode).map(toDetailPayload),
|
||||
customers: form.customers.filter(c => c.customerCode).map(toCustomerPayload),
|
||||
submitFlag: submitFlag ? 'Y' : undefined
|
||||
customers: form.customers.filter(c => c.customerCode).map(toCustomerPayload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,21 +215,44 @@ function goBack() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 编辑 / 复用新建:回显已有套餐 */
|
||||
function loadExisting(id) {
|
||||
function applySetData(data, asCopy) {
|
||||
form.priceSetId = data.priceSetId
|
||||
Object.assign(form.basic, {
|
||||
priceSetName: data.priceSetName,
|
||||
setType: data.setType || 'FIXED',
|
||||
defaultDiscount: data.defaultDiscount != null ? Number(data.defaultDiscount) : 88,
|
||||
priceSetNote: data.priceSetNote
|
||||
})
|
||||
const versions = data.versions || []
|
||||
const qPsv = route.query.psvId
|
||||
const byQuery = qPsv != null ? versions.find(v => String(v.psvId) === String(qPsv)) : null
|
||||
const current = byQuery || data.version || versions[0] || {}
|
||||
if (asCopy) {
|
||||
form.version.psvId = undefined
|
||||
form.version.versionNo = nextVersionNo(versions)
|
||||
form.version.effectiveTime = undefined
|
||||
form.version.expireTime = undefined
|
||||
} else {
|
||||
form.version.psvId = current.psvId
|
||||
form.version.versionNo = current.versionNo || nextVersionNo(versions)
|
||||
form.version.effectiveTime = current.effectiveTime
|
||||
form.version.expireTime = current.expireTime
|
||||
}
|
||||
const srcDetails = (current.details && current.details.length) ? current.details : (data.details || [])
|
||||
form.details = srcDetails.map(d => {
|
||||
const row = cascade.hydrateRow(d, form.basic.setType)
|
||||
if (asCopy) row.psdId = undefined
|
||||
return row
|
||||
})
|
||||
form.customers = (data.customers || []).map(c => ({ ...c }))
|
||||
}
|
||||
|
||||
function loadExisting(id, asCopy) {
|
||||
getPriceSet(id).then(res => {
|
||||
const data = res.data || {}
|
||||
form.priceSetId = data.priceSetId
|
||||
Object.assign(form.basic, {
|
||||
priceSetName: data.priceSetName,
|
||||
setType: data.setType || 'FIXED',
|
||||
defaultDiscount: data.defaultDiscount != null ? Number(data.defaultDiscount) : 88,
|
||||
effectiveDate: data.effectiveDate,
|
||||
expireDate: data.expireDate,
|
||||
priceSetNote: data.priceSetNote
|
||||
})
|
||||
form.details = (data.details || []).map(d => cascade.hydrateRow(d, form.basic.setType))
|
||||
form.customers = (data.customers || []).map(c => ({ ...c }))
|
||||
applySetData(res.data || {}, asCopy)
|
||||
if (asCopy || String(route.query.step || '') === '2') {
|
||||
active.value = 1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -162,9 +260,7 @@ onMounted(() => {
|
||||
refData.loadProducts().finally(() => {
|
||||
const id = route.query.priceSetId
|
||||
if (id) {
|
||||
loadExisting(id)
|
||||
} else {
|
||||
form.details.push(createEmptyDetailRow())
|
||||
loadExisting(id, copyMode.value)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -177,11 +273,19 @@ onMounted(() => {
|
||||
padding: 20px 12px 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.step-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.wizard-actions {
|
||||
text-align: center;
|
||||
padding: 8px 0 24px;
|
||||
}
|
||||
.save-progress__msg {
|
||||
margin: 12px 0 4px;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.save-progress__hint {
|
||||
margin: 0;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,62 +3,72 @@
|
||||
<set-detail-header
|
||||
:set="set"
|
||||
@back="goList"
|
||||
@edit="goEdit"
|
||||
@submit="onSubmit"
|
||||
@approve="onApprove"
|
||||
@reject="onReject"
|
||||
@disable="onDisable"
|
||||
@copy="onCopy"
|
||||
/>
|
||||
|
||||
<set-basic-info-card :set="set" />
|
||||
|
||||
<set-detail-table :set="set" :ref-data="refData" @refresh="load" />
|
||||
<set-customer-table :set="set" />
|
||||
|
||||
<set-customer-table :set="set" @refresh="load" />
|
||||
<set-version-table
|
||||
v-model="currentPsvId"
|
||||
:versions="set.versions || []"
|
||||
@copy-version="onCopyVersion"
|
||||
@open-details="onOpenVersionDetails"
|
||||
@activate="onActivateVersion"
|
||||
@edit="onEditVersion"
|
||||
@remove="onRemoveVersion"
|
||||
/>
|
||||
|
||||
<set-version-detail-drawer
|
||||
v-model="detailDrawerVisible"
|
||||
:version="detailVersion"
|
||||
:set-type="set.setType"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="PriceSetDetail">
|
||||
/**
|
||||
* 套餐详情 · 页面编排
|
||||
*
|
||||
* 展示区拆到 components/:页头、基本信息、明细表(含明细弹窗)、客户表(含客户弹窗);
|
||||
* 本页只负责:加载详情、状态流转操作、向子组件下发共享参考数据。
|
||||
*/
|
||||
import SetDetailHeader from './components/detail/SetDetailHeader.vue'
|
||||
import SetBasicInfoCard from './components/detail/SetBasicInfoCard.vue'
|
||||
import SetDetailTable from './components/detail/SetDetailTable.vue'
|
||||
import SetCustomerTable from './components/detail/SetCustomerTable.vue'
|
||||
import SetVersionTable from './components/detail/SetVersionTable.vue'
|
||||
import SetVersionDetailDrawer from './components/detail/SetVersionDetailDrawer.vue'
|
||||
import { usePriceSetRefData } from './composables/usePriceSetRefData'
|
||||
import {
|
||||
getPriceSet, submitPriceSet, approvePriceSet, rejectPriceSet, disablePriceSet, copyPriceSet
|
||||
getPriceSet, disablePriceSet, copyPriceSet, activatePriceSetVersion, copyPriceSetVersion, deletePriceSetVersion
|
||||
} from '@/api/price/set'
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
const route = useRoute()
|
||||
|
||||
/** 产品 / 国家 / 附加费 参考数据与明细弹窗级联共用,与新建页同一套实现 */
|
||||
const refData = usePriceSetRefData()
|
||||
|
||||
const loading = ref(false)
|
||||
const set = ref({})
|
||||
const currentPsvId = ref()
|
||||
const detailDrawerVisible = ref(false)
|
||||
const detailVersion = ref(null)
|
||||
|
||||
function onOpenVersionDetails(row) {
|
||||
detailVersion.value = row || null
|
||||
detailDrawerVisible.value = true
|
||||
}
|
||||
|
||||
/** 拉取套餐详情,并预取明细涉及产品的附加费 */
|
||||
function load() {
|
||||
const id = route.query.priceSetId
|
||||
if (!id) return
|
||||
loading.value = true
|
||||
getPriceSet(id).then(res => {
|
||||
set.value = res.data || {}
|
||||
const codes = [...new Set((set.value.details || []).map(d => d.productCode).filter(Boolean))]
|
||||
codes.forEach(code => refData.ensureSurcharges(code))
|
||||
const versions = set.value.versions || []
|
||||
const cur = set.value.version || versions[0]
|
||||
currentPsvId.value = cur ? cur.psvId : undefined
|
||||
}).finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
/** 二次确认 -> 调接口 -> 提示 -> 刷新(或跳转) */
|
||||
function confirmAction(text, request, successText, done) {
|
||||
proxy.$modal.confirm(text).then(() => request()).then(() => {
|
||||
proxy.$modal.msgSuccess(successText)
|
||||
@@ -66,30 +76,53 @@ function confirmAction(text, request, successText, done) {
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
confirmAction('确认提交审核?', () => submitPriceSet(set.value.priceSetId), '已提交审核')
|
||||
}
|
||||
|
||||
function onApprove() {
|
||||
confirmAction('确认审批生效?', () => approvePriceSet(set.value.priceSetId), '套餐已生效')
|
||||
}
|
||||
|
||||
function onReject() {
|
||||
confirmAction('确认驳回该套餐?', () => rejectPriceSet(set.value.priceSetId), '已驳回')
|
||||
}
|
||||
|
||||
function onDisable() {
|
||||
confirmAction('停用后不再参与计费命中,确认停用?', () => disablePriceSet(set.value.priceSetId), '已停用')
|
||||
}
|
||||
|
||||
/** 复用新建:复制为草稿后跳转到新建页 */
|
||||
function onActivateVersion(row) {
|
||||
if (!row || row.psvId == null) return
|
||||
confirmAction(
|
||||
'确认启动版本 ' + (row.versionNo || '') + '?启动后将以当前时间生效,上一版本自动失效。',
|
||||
() => activatePriceSetVersion(row.psvId),
|
||||
'版本已启动'
|
||||
)
|
||||
}
|
||||
function onCopy() {
|
||||
copyPriceSet(set.value.priceSetId).then(res => {
|
||||
proxy.$modal.msgSuccess('已复制为草稿')
|
||||
proxy.$router.push({ path: '/customer/setCreate', query: { priceSetId: res.data } })
|
||||
})
|
||||
}
|
||||
|
||||
function onCopyVersion(row) {
|
||||
if (!row || row.psvId == null) return
|
||||
proxy.$modal.confirm(
|
||||
'确认复制版本 ' + (row.versionNo || '') + '为新草稿?将新增版本并拷贝全部明细。'
|
||||
).then(() => copyPriceSetVersion(row.psvId)).then(res => {
|
||||
proxy.$modal.msgSuccess('已复制为新版本')
|
||||
const newPsvId = res.data
|
||||
load()
|
||||
if (newPsvId != null) {
|
||||
proxy.$router.push({
|
||||
path: '/customer/setCreate',
|
||||
query: { priceSetId: set.value.priceSetId, psvId: newPsvId, step: '2' }
|
||||
})
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
function onRemoveVersion(row) {
|
||||
if (!row || row.psvId == null) return
|
||||
proxy.$modal.confirm(
|
||||
'确认删除草稿版本 ' + (row.versionNo || '') + '?其明细与附加费将一并删除。'
|
||||
).then(() => deletePriceSetVersion(row.psvId)).then(() => {
|
||||
proxy.$modal.msgSuccess('版本已删除')
|
||||
load()
|
||||
}).catch(() => {})
|
||||
}
|
||||
function onEditVersion(row) {
|
||||
const q = { priceSetId: set.value.priceSetId, step: '2' }
|
||||
if (row && row.psvId != null) q.psvId = row.psvId
|
||||
proxy.$router.push({ path: '/customer/setCreate', query: q })
|
||||
}
|
||||
function goList() {
|
||||
const obj = { path: '/customer/set' }
|
||||
if (proxy.$tab) {
|
||||
@@ -99,10 +132,6 @@ function goList() {
|
||||
}
|
||||
}
|
||||
|
||||
function goEdit() {
|
||||
proxy.$router.push({ path: '/customer/setCreate', query: { priceSetId: set.value.priceSetId } })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refData.loadProducts()
|
||||
load()
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
:query="queryList"
|
||||
row-key="priceSetId"
|
||||
>
|
||||
<template #toolbar="{ selection, ids }">
|
||||
<template #toolbar>
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
type="primary"
|
||||
@@ -17,16 +17,6 @@
|
||||
v-hasPermi="['price:set:add']"
|
||||
>新建套餐</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
icon="Delete"
|
||||
:disabled="!selection.length"
|
||||
@click="handleDelete(ids)"
|
||||
v-hasPermi="['price:set:remove']"
|
||||
>删除</el-button>
|
||||
</el-col>
|
||||
</template>
|
||||
|
||||
<template #col-setType="{ row }">
|
||||
@@ -46,47 +36,11 @@
|
||||
<template #col-action="{ row }">
|
||||
<el-button link type="primary" @click="handleDetail(row)" v-hasPermi="['price:set:query']">详情</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 'D' || row.status === 'R'"
|
||||
link
|
||||
type="primary"
|
||||
@click="handleEdit(row)"
|
||||
v-hasPermi="['price:set:edit']"
|
||||
>{{ row.status === 'D' ? '继续配置' : '编辑' }}</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 'D' || row.status === 'R'"
|
||||
link
|
||||
type="primary"
|
||||
@click="handleSubmit(row)"
|
||||
v-hasPermi="['price:set:audit']"
|
||||
>提交审核</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 'A'"
|
||||
link
|
||||
type="success"
|
||||
@click="handleApprove(row)"
|
||||
v-hasPermi="['price:set:audit']"
|
||||
>审批</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 'E' && !row.expired"
|
||||
link
|
||||
type="warning"
|
||||
@click="handleDisable(row)"
|
||||
v-hasPermi="['price:set:audit']"
|
||||
>停用</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 'S'"
|
||||
link
|
||||
type="primary"
|
||||
@click="handleCopy(row)"
|
||||
v-hasPermi="['price:set:add']"
|
||||
>复用</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 'D' || row.status === 'R'"
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(row.priceSetId)"
|
||||
v-hasPermi="['price:set:remove']"
|
||||
>删除</el-button>
|
||||
</template>
|
||||
</basic-table>
|
||||
|
||||
@@ -95,7 +49,7 @@
|
||||
|
||||
<script setup name="PriceSet">
|
||||
import {
|
||||
listPriceSet, delPriceSet, submitPriceSet, approvePriceSet, disablePriceSet, copyPriceSet
|
||||
listPriceSet, disablePriceSet, copyPriceSet
|
||||
} from '@/api/price/set'
|
||||
import { parseTime } from '@/utils/ruoyi'
|
||||
|
||||
@@ -120,10 +74,7 @@ const searchColumns = [
|
||||
prop: 'status',
|
||||
component: 'select',
|
||||
options: [
|
||||
{ label: '草稿', value: 'D' },
|
||||
{ label: '审核中', value: 'A' },
|
||||
{ label: '生效', value: 'E' },
|
||||
{ label: '驳回', value: 'R' },
|
||||
{ label: '已停用', value: 'S' }
|
||||
],
|
||||
placeholder: '全部状态',
|
||||
@@ -137,11 +88,7 @@ const columns = [
|
||||
{ label: '套餐名称', prop: 'priceSetName', minWidth: 160 },
|
||||
{ label: '类型', prop: 'setType', width: 140 },
|
||||
{ label: '说明', prop: 'priceSetNote', minWidth: 160 },
|
||||
{ label: '产品数', prop: 'productCount', width: 80 },
|
||||
{ label: '客户数', prop: 'customerCount', width: 80 },
|
||||
{ label: '生效日期', prop: 'dateRange', minWidth: 200 },
|
||||
{ label: '状态', prop: 'status', width: 90 },
|
||||
{ label: '操作', prop: 'action', width: 280, showInToolbar: false }
|
||||
{ label: '操作', prop: 'action', width: 160, showInToolbar: false }
|
||||
]
|
||||
|
||||
function queryList(params) {
|
||||
@@ -172,9 +119,6 @@ function handleDetail(row) {
|
||||
proxy.$router.push({ path: '/customer/setDetail', query: { priceSetId: row.priceSetId } })
|
||||
}
|
||||
|
||||
function handleEdit(row) {
|
||||
proxy.$router.push({ path: '/customer/setCreate', query: { priceSetId: row.priceSetId } })
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
tableRef.value?.refresh()
|
||||
@@ -188,32 +132,6 @@ onActivated(() => {
|
||||
tableRef.value?.reload()
|
||||
})
|
||||
|
||||
function handleDelete(ids) {
|
||||
proxy.$modal.confirm('是否确认删除套餐编号为 "' + ids + '" 的数据项?').then(() => {
|
||||
return delPriceSet(ids)
|
||||
}).then(() => {
|
||||
proxy.$modal.msgSuccess('删除成功')
|
||||
refresh()
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
function handleSubmit(row) {
|
||||
proxy.$modal.confirm('确认提交套餐【' + row.priceSetName + '】审核?').then(() => {
|
||||
return submitPriceSet(row.priceSetId)
|
||||
}).then(() => {
|
||||
proxy.$modal.msgSuccess('已提交审核')
|
||||
refresh()
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
function handleApprove(row) {
|
||||
proxy.$modal.confirm('确认将套餐【' + row.priceSetName + '】审批生效?').then(() => {
|
||||
return approvePriceSet(row.priceSetId)
|
||||
}).then(() => {
|
||||
proxy.$modal.msgSuccess('套餐已生效')
|
||||
refresh()
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
function handleDisable(row) {
|
||||
proxy.$modal.confirm('停用后该套餐不再参与计费命中,确认停用【' + row.priceSetName + '】?').then(() => {
|
||||
|
||||
@@ -212,3 +212,24 @@
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 弹窗顶部说明条:左侧口径说明 + 右侧已选计数(附加费勾选弹窗) */
|
||||
.ps-picker-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
|
||||
&__picked {
|
||||
flex: none;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: #1890ff;
|
||||
background: #ecf5ff;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,29 @@ const STATUS_TAG = {
|
||||
/** 默认取值方式 */
|
||||
export const DEFAULT_ROUND_MODE = 'HALF_UP'
|
||||
|
||||
/** version status: 0 draft / 1 active / 2 expired */
|
||||
export const VER_STATUS = {
|
||||
DRAFT: '0',
|
||||
EFFECT: '1',
|
||||
EXPIRED: '2'
|
||||
}
|
||||
|
||||
export function versionStatusLabel(status) {
|
||||
const map = { '0': '草稿', '1': '生效', '2': '失效' }
|
||||
return map[status] || status || '-'
|
||||
}
|
||||
|
||||
export function versionStatusTag(status) {
|
||||
const map = { '0': 'warning', '1': 'success', '2': 'info' }
|
||||
return map[status] || 'info'
|
||||
}
|
||||
|
||||
export function nextVersionNo(versions) {
|
||||
const seqs = (versions || []).map(v => Number(v.versionSeq) || 0)
|
||||
const max = seqs.length ? Math.max.apply(null, seqs) : 0
|
||||
return 'V' + (max + 1)
|
||||
}
|
||||
|
||||
/** 状态文案(已过期优先) */
|
||||
export function setStatusLabel(set) {
|
||||
if (set && set.expired) return '已过期'
|
||||
@@ -201,7 +224,11 @@ export function buildWeightOptions(rows, row) {
|
||||
zoneCode: item.zoneCode,
|
||||
zoneName: item.zoneName,
|
||||
freightPrice: item.freightPrice,
|
||||
registrationFee: item.registrationFee
|
||||
registrationFee: item.registrationFee,
|
||||
weightMin: item.weightMin,
|
||||
weightMax: item.weightMax,
|
||||
effectiveDate: item.effectiveDate,
|
||||
expireDate: item.expireDate
|
||||
})
|
||||
})
|
||||
return [...map.values()]
|
||||
@@ -217,11 +244,29 @@ export function formatChargeValue(row) {
|
||||
|
||||
/** 附加费作用范围文案 */
|
||||
export function surchargeScopeLabel(s) {
|
||||
if (s.detailId == null) return '产品级'
|
||||
// 主表(prc_surcharge)数据:country_code 有值 = 该国专属(计费归属),空 = 全部国家
|
||||
if (s.detailId == null && s.psdId == null) {
|
||||
return s.countryCode ? `国家 ${s.countryCode}` : '全部国家'
|
||||
}
|
||||
const w = s.weightMin != null && s.weightMax != null ? `${s.weightMin}-${s.weightMax}kg` : '-'
|
||||
return `${s.countryName || '-'} · ${w}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 产品关联附加费(prc_surcharge_rel)是否作用于该明细行
|
||||
* 与后端 snapshotVersionSurcharges 的过滤口径保持一致:
|
||||
* 国家三字码一致 + 产品级(detailId 空)直接命中,明细级需 productCountryId 相同
|
||||
*/
|
||||
export function surchargeAppliesToRow(rel, row) {
|
||||
if (!rel || !row) return false
|
||||
if (rel.countryCode && row.countryCode
|
||||
&& String(rel.countryCode).toUpperCase() !== String(row.countryCode).toUpperCase()) {
|
||||
return false
|
||||
}
|
||||
if (rel.detailId == null) return true
|
||||
return row.productCountryId != null && Number(rel.detailId) === Number(row.productCountryId)
|
||||
}
|
||||
|
||||
/** 附加费标签文案 */
|
||||
export function surchargeTagLabel(s) {
|
||||
return `${s.scName} ${formatChargeValue(s)}`
|
||||
@@ -239,11 +284,19 @@ export function createEmptyDetailRow() {
|
||||
zoneCode: undefined,
|
||||
zoneName: undefined,
|
||||
weightLabel: undefined,
|
||||
weightMin: undefined,
|
||||
weightMax: undefined,
|
||||
productCountryId: undefined,
|
||||
fvValue: undefined,
|
||||
fvRegistrationFee: undefined,
|
||||
/** product default freight when copied from bms_product_country */
|
||||
_baseFreight: undefined,
|
||||
/** product default registration fee */
|
||||
_baseReg: undefined,
|
||||
discountValue: undefined,
|
||||
roundMode: DEFAULT_ROUND_MODE
|
||||
roundMode: DEFAULT_ROUND_MODE,
|
||||
effectiveDate: undefined,
|
||||
expireDate: undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,10 +305,20 @@ export function toEditableDetailRow(row) {
|
||||
return {
|
||||
...row,
|
||||
roundMode: row.roundMode || DEFAULT_ROUND_MODE,
|
||||
_countryKey: countryKeyOf(row)
|
||||
_countryKey: countryKeyOf(row),
|
||||
_baseFreight: row._baseFreight != null ? row._baseFreight : undefined,
|
||||
_baseReg: row._baseReg != null ? row._baseReg : undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** FIXED row changed vs product default */
|
||||
export function isDetailPriceChanged(row) {
|
||||
if (!row) return false
|
||||
if (row._baseFreight != null && Number(row.fvValue) !== Number(row._baseFreight)) return true
|
||||
if (row._baseReg != null && Number(row.fvRegistrationFee) !== Number(row._baseReg)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** 新增一条空客户绑定行 */
|
||||
export function createEmptyCustomerRow({ effectiveDate, expireDate, seq = 1 } = {}) {
|
||||
return {
|
||||
@@ -283,6 +346,9 @@ export function createEmptyBasicInfo() {
|
||||
/** 明细行 -> 提交后端的 payload */
|
||||
export function toDetailPayload(d) {
|
||||
return {
|
||||
psdId: d.psdId || undefined,
|
||||
psvId: d.psvId || undefined,
|
||||
priceSetId: d.priceSetId || undefined,
|
||||
productCode: d.productCode,
|
||||
productName: d.productName,
|
||||
countryCode: d.countryCode || undefined,
|
||||
@@ -291,11 +357,17 @@ export function toDetailPayload(d) {
|
||||
zoneCode: d.zoneCode || undefined,
|
||||
zoneName: d.zoneName || undefined,
|
||||
weightLabel: d.weightLabel || undefined,
|
||||
weightMin: d.weightMin,
|
||||
weightMax: d.weightMax,
|
||||
productCountryId: d.productCountryId || undefined,
|
||||
fvValue: d.fvValue,
|
||||
fvRegistrationFee: d.fvRegistrationFee,
|
||||
discountValue: d.discountValue,
|
||||
roundMode: d.roundMode || DEFAULT_ROUND_MODE
|
||||
roundMode: d.roundMode || DEFAULT_ROUND_MODE,
|
||||
effectiveDate: d.effectiveDate || undefined,
|
||||
expireDate: d.expireDate || undefined,
|
||||
/** 勾选保存的附加费:带数组即按勾选落快照,空数组=一条都不选 */
|
||||
surcharges: Array.isArray(d.surcharges) ? d.surcharges.map(s => ({ scId: s.scId })) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user