Merge remote-tracking branch 'origin/master'

This commit is contained in:
luojiaquan
2026-09-09 17:41:00 +08:00
51 changed files with 2700 additions and 671 deletions
+17
View File
@@ -8,3 +8,20 @@ export function quoteBilling(data) {
data
})
}
/** Historical quote list */
export function listQuote(query) {
return request({
url: '/price/billing/quote/list',
method: 'get',
params: query
})
}
/** Historical quote detail */
export function getQuote(quoteId) {
return request({
url: '/price/billing/quote/' + quoteId,
method: 'get'
})
}
+7 -3
View File
@@ -17,11 +17,15 @@ export function getProduct(productId) {
})
}
/** 查询产品国家明细 */
export function listProductCountries(productCode) {
/** 查询产品国家明细(优先 productId,绑定到具体产品版本行) */
export function listProductCountries(productCode, effectiveDate, productId) {
const params = {}
if (effectiveDate) params.effectiveDate = effectiveDate
if (productId) params.productId = productId
return request({
url: '/price/product/' + productCode + '/countries',
method: 'get'
method: 'get',
params
})
}
@@ -50,7 +50,10 @@
<!-- 选项与提示 -->
<div class="import-options">
<el-checkbox v-if="showUpdateSupport" v-model="updateSupport" class="update-checkbox">{{ updateSupportLabel }}</el-checkbox>
<el-checkbox v-if="showUpdateSupport" v-model="updateSupport" class="update-checkbox">
<span class="update-label">{{ updateSupportLabel }}</span>
</el-checkbox>
<div v-if="showUpdateSupport && updateSupportHint" class="update-hint">{{ updateSupportHint }}</div>
<div class="import-tips">
<el-icon class="tip-icon"><Info-Filled /></el-icon>
<div class="tip-text">
@@ -105,6 +108,11 @@ const props = defineProps({
type: String,
default: '是否更新已经存在的数据'
},
// 勾选框下方补充说明(长文案放这里,避免与复选框挤叠)
updateSupportHint: {
type: String,
default: ''
},
// 打开时是否默认勾选“更新已存在”
defaultUpdateSupport: {
type: Boolean,
@@ -350,8 +358,38 @@ defineExpose({ open })
}
.update-checkbox {
display: flex !important;
align-items: flex-start;
height: auto !important;
white-space: normal;
font-weight: 500;
color: var(--el-text-color-primary);
width: 100%;
}
.update-checkbox :deep(.el-checkbox__input) {
margin-top: 3px;
flex-shrink: 0;
}
.update-checkbox :deep(.el-checkbox__label) {
white-space: normal;
line-height: 1.5;
word-break: break-word;
padding-left: 8px;
}
.update-label {
display: inline-block;
line-height: 1.5;
}
.update-hint {
margin: 6px 0 0 22px;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.6;
word-break: break-word;
}
.import-tips {
@@ -0,0 +1,121 @@
<template>
<el-table
ref="tableRef"
class="simple-table"
v-loading="loading"
:data="data"
:row-key="rowKey"
:border="border"
:stripe="stripe"
:size="size"
:height="height"
:max-height="maxHeight"
:span-method="spanMethod"
:empty-text="emptyText"
:highlight-current-row="highlightCurrentRow"
@selection-change="(val) => $emit('selection-change', val)"
@row-click="(row, col, e) => $emit('row-click', row, col, e)"
>
<el-table-column v-if="selection" type="selection" width="55" align="center" />
<el-table-column v-if="showIndex" type="index" :label="indexLabel" width="55" align="center" />
<el-table-column
v-for="col in columns"
:key="col.key || col.prop || col.label"
:prop="col.prop"
:label="col.label"
:width="col.width"
:min-width="col.minWidth"
:align="col.align"
:fixed="col.fixed"
:sortable="col.sortable"
:show-overflow-tooltip="col.showOverflowTooltip"
>
<template #header="scope">
<slot :name="`header-${col.prop}`" v-bind="scope">{{ col.label }}</slot>
</template>
<template #default="scope">
<slot :name="`col-${col.slot || col.prop}`" v-bind="scope" :row="scope.row">
<dict-tag
v-if="col.dict"
:options="getDictOptions(col.dict)"
:value="scope.row[col.prop]"
/>
<span v-else-if="col.formatter" :class="col.className">{{ col.formatter(scope.row, col, scope.row[col.prop], scope.$index) }}</span>
<span v-else :class="col.className">{{ scope.row[col.prop] }}</span>
</slot>
</template>
</el-table-column>
</el-table>
</template>
<script setup>
/**
* SimpleTable ¡ª local-data table (no search / toolbar / pagination).
* Column config aligns with BasicTable: prop, label, width, minWidth, align, fixed,
* sortable, showOverflowTooltip, dict, formatter, slot, className.
*
* <simple-table :data="list" :columns="columns" :loading="loading" border>
* <template #col-action="{ row }">...</template>
* </simple-table>
*/
defineOptions({ name: 'SimpleTable' })
const props = defineProps({
data: { type: Array, default: () => [] },
columns: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
rowKey: { type: [String, Function], default: undefined },
border: { type: Boolean, default: false },
stripe: { type: Boolean, default: false },
size: { type: String, default: undefined },
height: { type: [String, Number], default: undefined },
maxHeight: { type: [String, Number], default: undefined },
spanMethod: { type: Function, default: null },
emptyText: { type: String, default: undefined },
selection: { type: Boolean, default: false },
showIndex: { type: Boolean, default: false },
indexLabel: { type: String, default: '#' },
highlightCurrentRow: { type: Boolean, default: false }
})
defineEmits(['selection-change', 'row-click'])
const { proxy } = getCurrentInstance()
const tableRef = ref()
const dictMap = reactive({})
const dictTypes = computed(() => {
const set = new Set()
props.columns.forEach((c) => c.dict && set.add(c.dict))
return [...set]
})
watch(
dictTypes,
(types) => {
if (!types.length) return
const dictRefs = proxy.useDict(...types)
types.forEach((t) => {
const d = dictRefs[t]
dictMap[t] = isRef(d) ? d : ref(d)
})
},
{ immediate: true }
)
function getDictOptions(type) {
const d = dictMap[type]
return isRef(d) ? d.value : d || []
}
defineExpose({
getTableRef: () => tableRef.value
})
</script>
<style scoped>
.simple-table {
width: 100%;
}
</style>
+2
View File
@@ -47,6 +47,7 @@ import DictTag from '@/components/DictTag'
import BasicForm from '@/components/BasicForm'
// 公共表格组件
import BasicTable from '@/components/BasicTable'
import SimpleTable from '@/components/SimpleTable'
const app = createApp(App)
@@ -71,6 +72,7 @@ app.component('RightToolbar', RightToolbar)
app.component('Editor', Editor)
app.component('BasicForm', BasicForm)
app.component('BasicTable', BasicTable)
app.component('SimpleTable', SimpleTable)
app.use(router)
app.use(store)
@@ -0,0 +1,107 @@
<template>
<el-drawer v-model="visible" size="920px" :title="title" append-to-body>
<div class="mb8" style="display: flex; align-items: center; gap: 12px">
<el-input v-model="keyword" placeholder="搜索国家(中文名 / ISO2)" clearable style="width: 260px" />
</div>
<simple-table
:data="filteredList"
:columns="columns"
:loading="loading"
border
max-height="calc(100vh - 120px)"
empty-text="暂无国家明细"
:span-method="spanMethod"
>
<template #col-sizeLimit="{ row }">
<span class="pre-wrap">{{ row.sizeLimit }}</span>
</template>
<template #col-lastMile="{ row }">
<span class="pre-wrap">{{ row.lastMile }}</span>
</template>
</simple-table>
</el-drawer>
</template>
<script setup>
import SimpleTable from '@/components/SimpleTable'
import { listProductCountries } from '@/api/price/product'
const columns = [
{ prop: 'countryName', label: '国家', width: 90, showOverflowTooltip: true },
{ prop: 'countryCode', label: 'ISO2', width: 60, align: 'center' },
{
prop: 'weightMin',
label: '重量段(KG)',
width: 120,
align: 'center',
formatter: (row) => (row.weightMin != null || row.weightMax != null ? (row.weightMin + ' ~ ' + row.weightMax) : '-')
},
{ prop: 'freightPrice', label: '运费(RMB/KG)', width: 110, align: 'right' },
{ prop: 'registrationFee', label: '挂号费(RMB/票)', width: 120, align: 'right' },
{ prop: 'sizeLimit', label: '尺寸限制', minWidth: 140 },
{ prop: 'lastMile', label: '尾程', minWidth: 110 },
{ prop: 'leadTime', label: '时效', width: 100, showOverflowTooltip: true },
{ prop: 'effectiveDate', label: '生效日期', width: 110, align: 'center' },
{ prop: 'expireDate', label: '失效日期', width: 110, align: 'center', formatter: (row) => row.expireDate || '长期' },
{ prop: 'status', label: '状态', width: 90, align: 'center', dict: 'bms_product_effect_status' }
]
const visible = ref(false)
const title = ref('')
const loading = ref(false)
const list = ref([])
const keyword = ref('')
const status = ref('')
const filteredList = computed(() => {
const kw = keyword.value.trim().toLowerCase()
const st = status.value
return list.value.filter(r => {
if (kw && !(String(r.countryName || '').toLowerCase().includes(kw) ||
String(r.countryCode || '').toLowerCase().includes(kw))) return false
if (st && String(r.status) !== String(st)) return false
return true
})
})
function formatEffDate(v) {
if (!v) return undefined
return String(v).slice(0, 10)
}
function open(row) {
title.value = row.productCode + ' · ' + row.productName + ' · 国家服务明细'
visible.value = true
loading.value = true
list.value = []
keyword.value = ''
status.value = ''
listProductCountries(row.productCode, formatEffDate(row.effectiveDate), row.productId).then(res => {
list.value = (res.data || []).slice().sort((a, b) => {
return String(a.countryCode || '').localeCompare(String(b.countryCode || ''))
})
}).finally(() => {
loading.value = false
})
}
function spanMethod({ column, rowIndex }) {
if (column.property !== 'countryName' && column.property !== 'countryCode') return
const rows = filteredList.value
if (!rows.length) return
const cur = rows[rowIndex]?.countryCode
if (rowIndex > 0 && rows[rowIndex - 1]?.countryCode === cur) {
return { rowspan: 0, colspan: 0 }
}
let count = 1
while (rowIndex + count < rows.length && rows[rowIndex + count]?.countryCode === cur) count++
return { rowspan: count, colspan: 1 }
}
defineExpose({ open })
</script>
<style scoped>
.mb8 { margin-bottom: 8px; }
.pre-wrap { white-space: pre-wrap; font-size: 12px; line-height: 1.4; }
</style>
@@ -0,0 +1,167 @@
<template>
<el-dialog :title="title" v-model="visible" width="640px" append-to-body>
<basic-form ref="formRef" v-model="form" :schemas="schemas" label-width="100px" />
<template #footer>
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="visible = false">取 消</el-button>
</template>
</el-dialog>
</template>
<script setup>
import BasicForm from '@/components/BasicForm'
import { getProduct, addProduct, updateProduct } from '@/api/price/product'
const emit = defineEmits(['success'])
const { proxy } = getCurrentInstance()
const formRef = ref()
const visible = ref(false)
const title = ref('')
const form = ref(emptyForm())
const schemas = computed(() => [
{
label: '产品代码',
prop: 'productCode',
component: 'input',
placeholder: '如 WSP037',
span: 12,
width: '100%',
disabled: !!form.value.productId,
props: { maxlength: 20 },
rules: [{ required: true, message: '产品代码不能为空', trigger: 'blur' }]
},
{
label: '产品名称',
prop: 'productName',
component: 'input',
placeholder: '产品名称',
span: 12,
width: '100%',
props: { maxlength: 100 },
rules: [{ required: true, message: '产品名称不能为空', trigger: 'blur' }]
},
{
label: '时效等级',
prop: 'serviceLevel',
component: 'input',
placeholder: '如 标准',
span: 12,
width: '100%',
props: { maxlength: 20 }
},
{
label: '走货属性',
prop: 'cargoAttr',
component: 'input',
placeholder: '普货/带电等',
span: 12,
width: '100%',
props: { maxlength: 100 }
},
{
label: '目的区域',
prop: 'destRegion',
component: 'input',
placeholder: '如 欧盟',
span: 12,
width: '100%',
props: { maxlength: 50 }
},
{
label: '税模式',
prop: 'taxMode',
component: 'select',
placeholder: '请选择',
dict: 'bms_tax_mode',
span: 12,
width: '100%',
rules: [{ required: true, message: '请选择税模式', trigger: 'change' }]
},
{
label: '生效日期',
prop: 'effectiveDate',
component: 'date',
placeholder: '选择日期',
span: 12,
width: '100%',
rules: [{ required: true, message: '生效日期不能为空', trigger: 'change' }]
},
{
label: '失效日期',
prop: 'expireDate',
component: 'date',
placeholder: '选择日期',
span: 12,
width: '100%'
},
{
label: '更新内容',
prop: 'updateNote',
component: 'textarea',
span: 24,
width: '100%',
props: { rows: 2, maxlength: 500 }
},
{
label: '备注',
prop: 'remark',
component: 'textarea',
span: 24,
width: '100%',
props: { rows: 2, maxlength: 500 }
}
])
function emptyForm() {
return {
productId: undefined,
productCode: undefined,
productName: undefined,
serviceLevel: undefined,
cargoAttr: undefined,
destRegion: undefined,
updateNote: undefined,
taxMode: 'TAX_INCLUDED',
effectiveDate: undefined,
expireDate: undefined,
status: '0',
remark: undefined
}
}
function reset() {
form.value = emptyForm()
nextTick(() => formRef.value?.clearValidate())
}
function open(row) {
reset()
if (!row) {
title.value = '新建产品'
visible.value = true
return
}
getProduct(row.productId).then(res => {
form.value = { ...emptyForm(), ...(res.data || {}) }
title.value = '修改产品'
visible.value = true
nextTick(() => formRef.value?.clearValidate())
})
}
function submitForm() {
formRef.value.validate(valid => {
if (!valid) return
const req = form.value.productId ? updateProduct(form.value) : addProduct(form.value)
req.then(() => {
proxy.$modal.msgSuccess('操作成功')
visible.value = false
emit('success')
})
})
}
defineExpose({ open })
</script>
@@ -0,0 +1,136 @@
<template>
<el-drawer v-model="visible" size="920px" :title="title" append-to-body>
<div class="mb8" style="display: flex; justify-content: space-between">
<el-button type="primary" plain icon="Plus" @click="handleRelAdd" v-hasPermi="['price:surcharge:add']">新增关联</el-button>
</div>
<el-table v-loading="loading" :data="list" border max-height="calc(100vh - 160px)" empty-text="该产品暂无关联附加费">
<el-table-column label="附加费名称" prop="scName" min-width="140" show-overflow-tooltip />
<el-table-column label="计费方式" width="110" align="center">
<template #default="{ row }">{{ chargeTypeLabel(row.chargeType) }}</template>
</el-table-column>
<el-table-column label="计费数值" width="140" align="right">
<template #default="{ row }">
<span class="charge-value">{{ formatChargeValue(row) }}</span>
</template>
</el-table-column>
<el-table-column label="取值方式" width="100" align="center">
<template #default="{ row }">{{ roundModeLabel(row.roundMode) }}</template>
</el-table-column>
<el-table-column label="适用国家" width="110" align="center">
<template #default="{ row }">{{ countryLabel(row.countryCode) }}</template>
</el-table-column>
<el-table-column label="作用范围" width="170" align="center">
<template #default="{ row }">
<el-tag v-if="row.detailId == null" type="primary" size="small" effect="plain">产品级 · 全部明细</el-tag>
<el-tag v-else type="warning" size="small" effect="plain">{{ row.countryName || '-' }} · {{ row.weightMin }}-{{ row.weightMax }}kg</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="String(row.ssCode).toLowerCase() === 'on' ? 'success' : 'info'" size="small">
{{ String(row.ssCode).toLowerCase() === 'on' ? '有效' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" prop="scNote" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="90" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="danger" icon="Delete" @click="handleRelDelete(row.relId)" v-hasPermi="['price:surcharge:remove']">解绑</el-button>
</template>
</el-table-column>
</el-table>
<surcharge-rel-dialog ref="relRef" @success="handleRelSuccess" />
</el-drawer>
</template>
<script setup>
import { onMounted } from 'vue'
import { listSurchargeByProduct, delSurchargeRel } from '@/api/price/surcharge'
import { listRegionOptions } from '@/api/data/region'
import SurchargeRelDialog from './SurchargeRelDialog.vue'
const emit = defineEmits(['success'])
const { proxy } = getCurrentInstance()
const { bms_round_mode, bms_charge_type } = useDict('bms_round_mode', 'bms_charge_type')
const relRef = ref()
const visible = ref(false)
const title = ref('')
const loading = ref(false)
const list = ref([])
const product = ref(null)
const regionOptions = ref([])
function open(row) {
product.value = row
title.value = row.productCode + ' · ' + row.productName + ' · 附加费关联'
visible.value = true
loadList()
}
function loadList() {
if (!product.value) return
loading.value = true
listSurchargeByProduct(product.value.productCode).then(res => {
list.value = res.data || []
}).finally(() => {
loading.value = false
})
}
function handleRelAdd() {
relRef.value.open(product.value)
}
function handleRelSuccess() {
loadList()
emit('success')
}
function handleRelDelete(relId) {
proxy.$modal.confirm('是否确认解绑该附加费?').then(() => {
return delSurchargeRel(relId)
}).then(() => {
proxy.$modal.msgSuccess('解绑成功')
loadList()
emit('success')
}).catch(() => {})
}
function chargeTypeLabel(val) {
const found = bms_charge_type.value.find(c => c.value === val)
return found ? found.label : (val || '-')
}
function roundModeLabel(val) {
const found = bms_round_mode.value.find(d => d.value === val)
return found ? found.label : (val || '-')
}
function formatChargeValue(row) {
if (row.chargeValue == null) return '-'
const v = Number(row.chargeValue)
if (row.chargeType === 'RATE') return (v * 100).toFixed(2) + '%'
return v + ' ' + (row.currency || 'CNY')
}
function countryLabel(code) {
if (!code) return '全部'
const found = regionOptions.value.find(r => r.countryCode2 === code)
return found ? (found.countryNameCn || found.countryNameEn || code) : code
}
onMounted(() => {
listRegionOptions().then(res => {
regionOptions.value = res.data || []
})
})
defineExpose({ open })
</script>
<style scoped>
.mb8 { margin-bottom: 8px; }
.charge-value { font-weight: 600; color: #d63031; }
</style>
@@ -0,0 +1,98 @@
<template>
<el-dialog :title="title" v-model="visible" width="560px" append-to-body>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="附加费" prop="scId">
<el-select v-model="form.scId" placeholder="请选择附加费" filterable style="width: 100%">
<el-option v-for="s in surchargePool" :key="s.scId" :label="s.scName" :value="s.scId" />
</el-select>
</el-form-item>
<el-form-item label="作用范围" prop="scope">
<el-radio-group v-model="form.scope">
<el-radio value="PRODUCT">产品级(全部明细)</el-radio>
<el-radio value="DETAIL">明细级(指定国家×重量段)</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.scope === 'DETAIL'" label="明细" prop="detailId">
<el-select v-model="form.detailId" placeholder="请选择明细" filterable style="width: 100%">
<el-option v-for="d in countryList" :key="d.detailId" :label="detailLabel(d)" :value="d.detailId" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submit">确 定</el-button>
<el-button @click="visible = false">取 消</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { listProductCountries } from '@/api/price/product'
import { listSurcharge, addSurchargeRel } from '@/api/price/surcharge'
const emit = defineEmits(['success'])
const { proxy } = getCurrentInstance()
const formRef = ref()
const visible = ref(false)
const title = ref('新增附加费关联')
const product = ref(null)
const surchargePool = ref([])
const countryList = ref([])
const form = ref({})
const rules = {
scId: [{ required: true, message: '请选择附加费', trigger: 'change' }]
}
function detailLabel(d) {
const w = d.weightMin != null && d.weightMax != null ? (d.weightMin + '-' + d.weightMax + 'kg') : '-'
return (d.countryName || d.countryCode || '-') + ' · ' + w
}
function reset() {
form.value = {
scId: undefined,
scope: 'PRODUCT',
detailId: undefined
}
formRef.value?.resetFields()
}
function open(row) {
product.value = row
reset()
visible.value = true
listSurcharge({ ssCode: 'on', pageNum: 1, pageSize: 1000 }).then(res => {
surchargePool.value = res.rows || []
})
if (row && row.productCode) {
listProductCountries(row.productCode, undefined, row.productId).then(res => {
countryList.value = res.data || []
})
}
}
function submit() {
formRef.value.validate(valid => {
if (!valid) return
if (form.value.scope === 'DETAIL' && !form.value.detailId) {
proxy.$modal.msgError('请选择明细')
return
}
const payload = {
scId: form.value.scId,
productId: product.value ? product.value.productId : undefined,
productCode: product.value ? product.value.productCode : undefined,
detailId: form.value.scope === 'DETAIL' ? form.value.detailId : null
}
addSurchargeRel(payload).then(() => {
proxy.$modal.msgSuccess('关联成功')
visible.value = false
emit('success')
})
})
}
defineExpose({ open })
</script>
+66 -428
View File
@@ -5,14 +5,24 @@
:closable="false"
show-icon
class="mb16"
title="Excel 导入:上传威狮B2C小包价表.xlsx。入库产品目录 + 各产品 sheet 的国家×重量段(运费/挂号费/尺寸/尾程/时效);不导入退件重派、申报说明、赔偿标准。"
title="Excel 导入:相同产品代码+生效日期不覆盖;不同生效日期新增一条产品。同时入库国家×重量段(运费/挂号费/尺寸/尾程/时效)。"
/>
<el-tabs v-model="effectTab" class="mb8" @tab-change="onTabChange">
<el-tab-pane
v-for="t in statusTabs"
:key="t.name"
:label="t.label"
:name="t.name"
/>
</el-tabs>
<basic-table
ref="tableRef"
:columns="columns"
:search-columns="searchColumns"
:query="queryList"
:extra-params="extraParams"
row-key="productId"
>
<template #toolbar="{ selection, ids }">
@@ -35,196 +45,26 @@
</template>
<template #col-status="{ row }">
<dict-tag :options="bms_product_effect_status" :value="resolveEffectStatus(row)" />
<dict-tag :options="bms_product_effect_status" :value="row.status" />
</template>
<template #col-action="{ row }">
<el-button link type="primary" icon="View" @click="openCountries(row)" v-hasPermi="['price:product:query']">产品明细</el-button>
<el-button link type="primary" icon="Money" @click="openSurcharges(row)" v-hasPermi="['price:surcharge:query']">附加费</el-button>
<el-button link type="primary" icon="View" @click="countryRef.open(row)" v-hasPermi="['price:product:query']">产品明细</el-button>
<el-button link type="primary" icon="Money" @click="surchargeRef.open(row)" v-hasPermi="['price:surcharge:query']">附加费</el-button>
<el-button :disabled="isProductActive(row)" :title="isProductActive(row) ? '已生效不可编辑' : ''" link type="primary" icon="Edit" @click="handleUpdate(row)" v-hasPermi="['price:product:edit']">编辑</el-button>
<el-button :disabled="isProductActive(row)" :title="isProductActive(row) ? '已生效不可删除' : ''" link type="primary" icon="Delete" @click="handleDelete(row.productId)" v-hasPermi="['price:product:remove']">删除</el-button>
</template>
</basic-table>
<el-dialog :title="title" v-model="open" width="640px" append-to-body>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-row>
<el-col :span="12">
<el-form-item label="产品代码" prop="productCode">
<el-input v-model="form.productCode" placeholder="如 WSP037" maxlength="20" :disabled="!!form.productId" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="产品名称" prop="productName">
<el-input v-model="form.productName" placeholder="产品名称" maxlength="100" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="时效等级" prop="serviceLevel">
<el-input v-model="form.serviceLevel" placeholder="如 标准" maxlength="20" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="走货属性" prop="cargoAttr">
<el-input v-model="form.cargoAttr" placeholder="普货/带电等" maxlength="100" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="目的区域" prop="destRegion">
<el-input v-model="form.destRegion" placeholder="如 欧盟" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="税模式" prop="taxMode">
<el-select v-model="form.taxMode" placeholder="请选择" style="width: 100%">
<el-option
v-for="dict in bms_tax_mode"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="生效日期" prop="effectiveDate">
<el-date-picker v-model="form.effectiveDate" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio value="0">启用(按生效日期自动:待生效/已生效)</el-radio>
<el-radio value="1">已失效</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="更新内容" prop="updateNote">
<el-input v-model="form.updateNote" type="textarea" :rows="2" maxlength="500" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="500" />
</el-form-item>
</el-form>
<template #footer>
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="open = false">取 消</el-button>
</template>
</el-dialog>
<el-drawer v-model="countryOpen" size="920px" :title="countryTitle" append-to-body>
<el-input v-model="countryKeyword" placeholder="搜索国家(中文名 / ISO2)" clearable class="mb8" style="width: 260px" />
<el-table v-loading="countryLoading" :data="filteredCountryList" border :span-method="countrySpanMethod" max-height="calc(100vh - 120px)" empty-text="暂无国家明细">
<el-table-column label="国家" prop="countryName" width="90" show-overflow-tooltip />
<el-table-column label="ISO2" prop="countryCode" width="60" align="center" />
<el-table-column label="重量段(KG)" width="120" align="center">
<template #default="{ row }">
<span v-if="row.weightMin != null || row.weightMax != null">{{ row.weightMin }} ~ {{ row.weightMax }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="运费(RMB/KG)" prop="freightPrice" width="110" align="right" />
<el-table-column label="挂号费(RMB/票)" prop="registrationFee" width="120" align="right" />
<el-table-column label="尺寸限制" prop="sizeLimit" min-width="140">
<template #default="{ row }">
<span class="pre-wrap">{{ row.sizeLimit }}</span>
</template>
</el-table-column>
<el-table-column label="尾程" prop="lastMile" min-width="110">
<template #default="{ row }">
<span class="pre-wrap">{{ row.lastMile }}</span>
</template>
</el-table-column>
<el-table-column label="时效" prop="leadTime" width="100" show-overflow-tooltip />
<el-table-column label="生效日期" prop="effectiveDate" width="110" align="center" />
<el-table-column label="失效日期" width="110" align="center">
<template #default="{ row }">{{ row.expireDate || '长期' }}</template>
</el-table-column>
</el-table>
</el-drawer>
<el-drawer v-model="surchargeOpen" size="920px" :title="surchargeTitle" append-to-body>
<div class="mb8" style="display: flex; justify-content: space-between">
<el-button type="primary" plain icon="Plus" @click="handleRelAdd" v-hasPermi="['price:surcharge:add']">新增关联</el-button>
</div>
<el-table v-loading="surchargeLoading" :data="surchargeList" border max-height="calc(100vh - 160px)" empty-text="该产品暂无关联附加费">
<el-table-column label="附加费名称" prop="scName" min-width="140" show-overflow-tooltip />
<el-table-column label="计费方式" width="110" align="center">
<template #default="{ row }">{{ chargeTypeLabel(row.chargeType) }}</template>
</el-table-column>
<el-table-column label="计费数值" width="140" align="right">
<template #default="{ row }">
<span class="charge-value">{{ formatChargeValue(row) }}</span>
</template>
</el-table-column>
<el-table-column label="取值方式" width="100" align="center">
<template #default="{ row }">{{ roundModeLabel(row.roundMode) }}</template>
</el-table-column>
<el-table-column label="适用国家" width="110" align="center">
<template #default="{ row }">{{ countryLabel(row.countryCode) }}</template>
</el-table-column>
<el-table-column label="作用范围" width="170" align="center">
<template #default="{ row }">
<el-tag v-if="row.detailId == null" type="primary" size="small" effect="plain">产品级 · 全部明细</el-tag>
<el-tag v-else type="warning" size="small" effect="plain">{{ row.countryName || '-' }} · {{ row.weightMin }}-{{ row.weightMax }}kg</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="String(row.ssCode).toLowerCase() === 'on' ? 'success' : 'info'" size="small">
{{ String(row.ssCode).toLowerCase() === 'on' ? '有效' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" prop="scNote" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="90" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="danger" icon="Delete" @click="handleRelDelete(row.relId)" v-hasPermi="['price:surcharge:remove']">解绑</el-button>
</template>
</el-table-column>
</el-table>
</el-drawer>
<el-dialog :title="relTitle" v-model="relOpen" width="560px" append-to-body>
<el-form ref="relFormRef" :model="relForm" :rules="relRules" label-width="100px">
<el-form-item label="附加费" prop="scId">
<el-select v-model="relForm.scId" placeholder="请选择附加费" filterable style="width: 100%">
<el-option v-for="s in surchargePool" :key="s.scId" :label="s.scName" :value="s.scId" />
</el-select>
</el-form-item>
<el-form-item label="作用范围" prop="scope">
<el-radio-group v-model="relForm.scope">
<el-radio value="PRODUCT">产品级(全部明细)</el-radio>
<el-radio value="DETAIL">明细级(指定国家×重量段)</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="relForm.scope === 'DETAIL'" label="明细" prop="detailId">
<el-select v-model="relForm.detailId" placeholder="请选择明细" filterable style="width: 100%">
<el-option v-for="d in countryList" :key="d.detailId" :label="detailLabel(d)" :value="d.detailId" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitRel">确 定</el-button>
<el-button @click="relOpen = false">取 消</el-button>
</div>
</template>
</el-dialog>
<product-form-dialog ref="formRef" @success="() => tableRef.refresh()" />
<product-country-drawer ref="countryRef" />
<product-surcharge-drawer ref="surchargeRef" @success="() => tableRef.refresh()" />
<excel-import-dialog
ref="importRef"
title="产品 Excel 导入"
action="/price/product/importData"
:default-update-support="true"
update-support-label="是否更新已存在的产品主数据(国家明细:新生效日晚于上次则保留历史,上次失效日=本次生效日)"
width="560px"
:show-update-support="false"
@success="() => tableRef.refresh()"
/>
</div>
@@ -232,43 +72,54 @@
<script setup name="PriceProduct">
import ExcelImportDialog from '@/components/ExcelImportDialog'
import { listProduct, getProduct, addProduct, updateProduct, delProduct, listProductCountries } from '@/api/price/product'
import { listSurchargeByProduct, listSurcharge, addSurchargeRel, delSurchargeRel } from '@/api/price/surcharge'
import { listRegionOptions } from '@/api/data/region'
import { onMounted } from 'vue'
import ProductFormDialog from './components/ProductFormDialog.vue'
import ProductCountryDrawer from './components/ProductCountryDrawer.vue'
import ProductSurchargeDrawer from './components/ProductSurchargeDrawer.vue'
import { listProduct, delProduct } from '@/api/price/product'
const { proxy } = getCurrentInstance()
const { bms_product_effect_status, bms_tax_mode, bms_round_mode, bms_charge_type } = useDict('bms_product_effect_status', 'bms_tax_mode', 'bms_round_mode', 'bms_charge_type')
const { bms_product_effect_status } = useDict('bms_product_effect_status')
const tableRef = ref()
const importRef = ref()
const formRef = ref()
const open = ref(false)
const title = ref('')
const countryOpen = ref(false)
const countryTitle = ref('')
const countryLoading = ref(false)
const countryList = ref([])
const countryKeyword = ref('')
const countryRef = ref()
const surchargeRef = ref()
const importRef = ref()
const surchargeOpen = ref(false)
const surchargeTitle = ref('')
const surchargeLoading = ref(false)
const surchargeList = ref([])
const currentProduct = ref(null)
const regionOptions = ref([])
const TAB_ACTIVE = 'active'
const TAB_PENDING = 'pending'
const TAB_EXPIRED = 'expired'
const surchargePool = ref([])
const relOpen = ref(false)
const relTitle = ref('')
const relFormRef = ref()
const filteredCountryList = computed(() => {
const kw = countryKeyword.value.trim().toLowerCase()
if (!kw) return countryList.value
return countryList.value.filter(r =>
String(r.countryName || '').toLowerCase().includes(kw) ||
String(r.countryCode || '').toLowerCase().includes(kw)
)
})
const effectTab = ref(TAB_ACTIVE)
const statusTabs = computed(() => [
{ name: TAB_ACTIVE, code: '1', label: dictLabel('1', 'ACTIVE') },
{ name: TAB_PENDING, code: '0', label: dictLabel('0', 'PENDING') },
{ name: TAB_EXPIRED, code: '2', label: dictLabel('2', 'EXPIRED') }
])
const extraParams = computed(() => ({ status: tabToStatus(effectTab.value) }))
function dictLabel(code, legacy) {
const list = bms_product_effect_status.value || []
const hit = list.find(d => String(d.value) === code || String(d.value) === legacy)
return hit?.label || code
}
function tabToStatus(name) {
if (name === TAB_ACTIVE || name === 'ACTIVE' || name === '1') return '1'
if (name === TAB_PENDING || name === 'PENDING' || name === '0') return '0'
if (name === TAB_EXPIRED || name === 'EXPIRED' || name === '2') return '2'
return '1'
}
function onTabChange(name) {
effectTab.value = String(name || TAB_ACTIVE)
nextTick(() => {
tableRef.value?.clearSelection()
tableRef.value?.reload()
})
}
const columns = [
{ prop: 'productCode', label: '产品代码', minWidth: 110 },
@@ -278,6 +129,7 @@ const columns = [
{ prop: 'destRegion', label: '目的区域', width: 90, align: 'center' },
{ prop: 'taxMode', label: '税模式', width: 90, align: 'center', dict: 'bms_tax_mode' },
{ prop: 'effectiveDate', label: '生效日期', width: 120, align: 'center' },
{ prop: 'expireDate', label: '失效日期', width: 120, align: 'center' },
{ prop: 'countryCount', label: '国家数', width: 80, align: 'center' },
{ prop: 'surchargeCount', label: '附加费数', width: 90, align: 'center' },
{ prop: 'status', label: '状态', width: 90, align: 'center', slot: 'status' },
@@ -295,69 +147,19 @@ const searchColumns = [
dict: 'bms_tax_mode',
placeholder: '税模式',
clearable: true
},
{
prop: 'effectStatus',
label: '状态',
component: 'select',
dict: 'bms_product_effect_status',
placeholder: '请选择状态',
clearable: true
}
]
const data = reactive({
form: {},
rules: {
productCode: [{ required: true, message: '产品代码不能为空', trigger: 'blur' }],
productName: [{ required: true, message: '产品名称不能为空', trigger: 'blur' }],
taxMode: [{ required: true, message: '请选择税模式', trigger: 'change' }]
}
})
const { form, rules } = toRefs(data)
function queryList(params) {
return listProduct(params)
return listProduct({ ...params, status: tabToStatus(effectTab.value) })
}
function todayStr() {
return new Date().toISOString().slice(0, 10)
}
/** ACTIVE已生效 / PENDING待生效 / EXPIRED已失效 */
function resolveEffectStatus(row) {
if (!row) return 'PENDING'
if (row.status === '1') return 'EXPIRED'
if (!row.effectiveDate) return 'PENDING'
return String(row.effectiveDate).slice(0, 10) <= todayStr() ? 'ACTIVE' : 'PENDING'
}
// 已生效不可编辑/删除
function isProductActive(row) {
return resolveEffectStatus(row) === 'ACTIVE'
}
function reset() {
form.value = {
productId: undefined,
productCode: undefined,
productName: undefined,
serviceLevel: undefined,
cargoAttr: undefined,
destRegion: undefined,
updateNote: undefined,
taxMode: 'TAX_INCLUDED',
effectiveDate: undefined,
status: '0',
remark: undefined
}
proxy.resetForm('formRef')
return row && String(row.status) === '1'
}
function handleAdd() {
reset()
open.value = true
title.value = '新建产品'
formRef.value.open()
}
function handleUpdate(row) {
@@ -365,24 +167,7 @@ function handleUpdate(row) {
proxy.$modal.msgError('已生效的产品不可编辑')
return
}
reset()
getProduct(row.productId).then(res => {
form.value = res.data
open.value = true
title.value = '修改产品'
})
}
function submitForm() {
proxy.$refs['formRef'].validate(valid => {
if (!valid) return
const req = form.value.productId ? updateProduct(form.value) : addProduct(form.value)
req.then(() => {
proxy.$modal.msgSuccess('操作成功')
open.value = false
tableRef.value.refresh()
})
})
formRef.value.open(row)
}
function handleDelete(ids) {
@@ -398,155 +183,8 @@ function handleDelete(ids) {
function handleImport() {
importRef.value.open()
}
function openCountries(row) {
countryTitle.value = `${row.productCode} · ${row.productName} · 国家服务明细`
countryOpen.value = true
countryLoading.value = true
countryList.value = []
countryKeyword.value = ''
listProductCountries(row.productCode).then(res => {
// 按 ISO2 排序,保证相同国家相邻,便于合并单元格
const list = (res.data || []).slice().sort((a, b) => {
return String(a.countryCode || '').localeCompare(String(b.countryCode || ''))
})
countryList.value = list
}).finally(() => {
countryLoading.value = false
})
}
// 弹窗表格合并:相同 countryCode 的连续行合并「国家」「ISO2」列
function countrySpanMethod({ column, rowIndex }) {
if (column.property !== 'countryName' && column.property !== 'countryCode') return
const list = filteredCountryList.value
if (!list.length) return
const cur = list[rowIndex]?.countryCode
if (rowIndex > 0 && list[rowIndex - 1]?.countryCode === cur) {
return { rowspan: 0, colspan: 0 }
}
let count = 1
while (rowIndex + count < list.length && list[rowIndex + count]?.countryCode === cur) count++
return { rowspan: count, colspan: 1 }
}
// ===== 产品附加费关联 =====
const relData = reactive({
relForm: {},
relRules: {
scId: [{ required: true, message: '请选择附加费', trigger: 'change' }]
}
})
const { relForm, relRules } = toRefs(relData)
function openSurcharges(row) {
currentProduct.value = row
surchargeTitle.value = `${row.productCode} · ${row.productName} · 附加费关联`
surchargeOpen.value = true
loadSurcharges()
}
function loadSurcharges() {
if (!currentProduct.value) return
surchargeLoading.value = true
listSurchargeByProduct(currentProduct.value.productCode).then(res => {
surchargeList.value = res.data || []
}).finally(() => {
surchargeLoading.value = false
})
}
function loadSurchargePool() {
return listSurcharge({ ssCode: 'on', pageNum: 1, pageSize: 1000 }).then(res => {
surchargePool.value = res.rows || []
})
}
function resetRelForm() {
relForm.value = {
scId: undefined,
scope: 'PRODUCT',
detailId: undefined
}
relFormRef.value && relFormRef.value.resetFields()
}
function handleRelAdd() {
resetRelForm()
relTitle.value = '新增附加费关联'
relOpen.value = true
loadSurchargePool()
}
function submitRel() {
relFormRef.value.validate(valid => {
if (!valid) return
if (relForm.value.scope === 'DETAIL' && !relForm.value.detailId) {
proxy.$modal.msgError('请选择明细')
return
}
const payload = {
scId: relForm.value.scId,
productId: currentProduct.value ? currentProduct.value.productId : undefined,
productCode: currentProduct.value ? currentProduct.value.productCode : undefined,
detailId: relForm.value.scope === 'DETAIL' ? relForm.value.detailId : null
}
addSurchargeRel(payload).then(() => {
proxy.$modal.msgSuccess('关联成功')
relOpen.value = false
loadSurcharges()
tableRef.value.refresh()
})
})
}
function handleRelDelete(relId) {
proxy.$modal.confirm('是否确认解绑该附加费?').then(() => {
return delSurchargeRel(relId)
}).then(() => {
proxy.$modal.msgSuccess('解绑成功')
loadSurcharges()
tableRef.value.refresh()
}).catch(() => {})
}
function detailLabel(d) {
const w = d.weightMin != null && d.weightMax != null ? `${d.weightMin}-${d.weightMax}kg` : '-'
return `${d.countryName || d.countryCode || '-'} · ${w}`
}
function chargeTypeLabel(val) {
const found = bms_charge_type.value.find(c => c.value === val)
return found ? found.label : (val || '-')
}
function roundModeLabel(val) {
const found = bms_round_mode.value.find(d => d.value === val)
return found ? found.label : (val || '-')
}
function formatChargeValue(row) {
if (row.chargeValue == null) return '-'
const v = Number(row.chargeValue)
if (row.chargeType === 'RATE') return (v * 100).toFixed(2) + '%'
return v + ' ' + (row.currency || 'CNY')
}
function countryLabel(code) {
if (!code) return '全部'
const found = regionOptions.value.find(r => r.countryCode2 === code)
return found ? (found.countryNameCn || found.countryNameEn || code) : code
}
onMounted(() => {
listRegionOptions().then(res => {
regionOptions.value = res.data || []
})
})
</script>
<style scoped>
.mb16 { margin-bottom: 16px; }
.pre-wrap { white-space: pre-wrap; font-size: 12px; line-height: 1.4; }
.charge-value { font-weight: 600; color: #d63031; }
</style>
+200 -3
View File
@@ -95,37 +95,194 @@
<el-descriptions-item label="套餐编号">{{ result.priceSetCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="套餐名称">{{ result.priceSetName || '-' }}</el-descriptions-item>
<el-descriptions-item label="套餐类型">{{ result.setType || '-' }}</el-descriptions-item>
<el-descriptions-item label="币种">{{ result.currency || 'RMB' }}</el-descriptions-item>
<el-descriptions-item label="币种">
<dict-tag :options="bms_currency" :value="displayCurrency(result.currency)" />
</el-descriptions-item>
<el-descriptions-item label="运费单价">{{ formatMoney(result.freightRate) }}</el-descriptions-item>
<el-descriptions-item label="折扣前单价">{{ formatMoney(result.baseFreightRate) }}</el-descriptions-item>
<el-descriptions-item label="折扣%">{{ result.discountPercent != null ? result.discountPercent : '-' }}</el-descriptions-item>
<el-descriptions-item label="取值方式">{{ roundModeLabel(result.roundMode) }}</el-descriptions-item>
<el-descriptions-item label="运费金额">{{ formatMoney(result.freightAmount) }}</el-descriptions-item>
<el-descriptions-item label="挂号费">{{ formatMoney(result.registrationFee) }}</el-descriptions-item>
<el-descriptions-item label="附加费">{{ formatMoney(result.surchargeAmount) }}</el-descriptions-item>
<el-descriptions-item label="应付总额">
<span class="payable">{{ formatMoney(result.payableTotal) }}</span>
</el-descriptions-item>
<el-descriptions-item label="试算来源">
<el-tag v-if="result.fromCache" size="small" type="success">命中历史试算</el-tag>
<el-tag v-else size="small" type="primary">实时计算并落库</el-tag>
</el-descriptions-item>
<el-descriptions-item label="命中说明" :span="2">{{ result.matchMessage || '-' }}</el-descriptions-item>
</el-descriptions>
<div class="formula-tip">计算公式:运费金额 = 运费单价 × 计费重量;应付总额 = 运费金额 + 挂号费</div>
<el-table
v-if="result.feeDetails && result.feeDetails.length"
:data="result.feeDetails"
border
size="small"
class="fee-table"
>
<el-table-column label="费用名称" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ displayFeeName(row) }}</template>
</el-table-column>
<el-table-column label="费用类型" width="110" align="center">
<template #default="{ row }">{{ feeTypeLabel(row.feeType) || row.feeType }}</template>
</el-table-column>
<el-table-column label="金额" min-width="120" align="right">
<template #default="{ row }">
<span>{{ formatMoney(row.amount) }}</span>
</template>
</el-table-column>
<el-table-column label="币种" width="90" align="center">
<template #default="{ row }">
<dict-tag :options="bms_currency" :value="displayCurrency(row.currency)" />
</template>
</el-table-column>
<el-table-column label="取值方式" width="110" align="center">
<template #default="{ row }">{{ roundModeLabel(row.roundMode) }}</template>
</el-table-column>
<el-table-column label="说明" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark || '-' }}</template>
</el-table-column>
</el-table>
<div class="formula-tip">计算公式:运费金额 = 运费单价 × 计费重量;应付总额 = 运费金额 + 挂号费 + 附加费。相同试算参数直接返回已落库费用明细。</div>
</div>
</el-card>
</el-col>
</el-row>
<el-card shadow="never" class="history-card">
<template #header><span>历史试算</span></template>
<basic-table
ref="historyTableRef"
:columns="historyColumns"
:search-columns="historySearchColumns"
:query="queryHistory"
row-key="quoteId"
>
<template #col-product="{ row }">
{{ row.productName || '-' }} ({{ row.productCode }})
</template>
<template #col-country="{ row }">
{{ row.countryName || '-' }} ({{ row.countryCode || '-' }})
</template>
<template #col-matchSource="{ row }">
<el-tag size="small" :type="sourceTagType(row.matchSource)">{{ sourceLabel(row.matchSource) }}</el-tag>
</template>
<template #col-payableTotal="{ row }">
<span class="payable">{{ formatMoney(row.payableTotal) }}</span>
</template>
<template #col-action="{ row }">
<el-button link type="primary" icon="View" @click="handleViewHistory(row)">查看</el-button>
</template>
</basic-table>
</el-card>
<el-drawer v-model="historyOpen" size="720px" :title="historyTitle" append-to-body>
<div v-loading="historyLoading">
<el-empty v-if="!viewResult" description="暂无详情" />
<div v-else class="quote-result">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="客户ID">{{ viewResult.customerId }}</el-descriptions-item>
<el-descriptions-item label="产品">{{ viewResult.productName || '-' }} ({{ viewResult.productCode }})</el-descriptions-item>
<el-descriptions-item label="国家">{{ viewResult.countryName || '-' }} ({{ viewResult.countryCode }})</el-descriptions-item>
<el-descriptions-item label="计费重量(kg)">{{ viewResult.chargeWeight ?? viewResult.weight }}</el-descriptions-item>
<el-descriptions-item label="重量段">{{ viewResult.weightLabel || '-' }}</el-descriptions-item>
<el-descriptions-item label="计价来源">
<el-tag size="small" :type="sourceTagType(viewResult.matchSource)">{{ sourceLabel(viewResult.matchSource) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="套餐">{{ viewResult.priceSetName || '-' }} {{ viewResult.priceSetCode ? '(' + viewResult.priceSetCode + ')' : '' }}</el-descriptions-item>
<el-descriptions-item label="运费金额">{{ formatMoney(viewResult.freightAmount) }}</el-descriptions-item>
<el-descriptions-item label="挂号费">{{ formatMoney(viewResult.registrationFee) }}</el-descriptions-item>
<el-descriptions-item label="附加费">{{ formatMoney(viewResult.surchargeAmount) }}</el-descriptions-item>
<el-descriptions-item label="应付总额">
<span class="payable">{{ formatMoney(viewResult.payableTotal) }}</span>
</el-descriptions-item>
<el-descriptions-item label="命中说明" :span="2">{{ viewResult.matchMessage || '-' }}</el-descriptions-item>
</el-descriptions>
<el-table
v-if="viewResult.feeDetails && viewResult.feeDetails.length"
:data="viewResult.feeDetails"
border
size="small"
class="fee-table"
>
<el-table-column label="费用名称" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ displayFeeName(row) }}</template>
</el-table-column>
<el-table-column label="费用类型" width="110" align="center">
<template #default="{ row }">{{ feeTypeLabel(row.feeType) || row.feeType }}</template>
</el-table-column>
<el-table-column label="金额" min-width="120" align="right">
<template #default="{ row }">{{ formatMoney(row.amount) }}</template>
</el-table-column>
<el-table-column label="币种" width="90" align="center">
<template #default="{ row }">
<dict-tag :options="bms_currency" :value="displayCurrency(row.currency)" />
</template>
</el-table-column>
<el-table-column label="说明" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark || '-' }}</template>
</el-table-column>
</el-table>
</div>
</div>
</el-drawer>
</div>
</template>
<script setup name="BillingQuote">
import { quoteBilling } from '@/api/price/billing'
import { quoteBilling, listQuote, getQuote } from '@/api/price/billing'
import { listProduct, listProductCountries } from '@/api/price/product'
const { proxy } = getCurrentInstance()
const { bms_fee_type, bms_currency } = useDict('bms_fee_type', 'bms_currency')
const formRef = ref()
const historyTableRef = ref()
const loading = ref(false)
const productOptions = ref([])
const countryOptions = ref([])
const result = ref(null)
const errorMsg = ref('')
const historyOpen = ref(false)
const historyTitle = ref('试算详情')
const historyLoading = ref(false)
const viewResult = ref(null)
const historyColumns = [
{ label: '客户ID', prop: 'customerId', align: 'center', width: 90 },
{ label: '产品', prop: 'product', minWidth: 160, slot: 'product' },
{ label: '国家', prop: 'country', minWidth: 130, slot: 'country' },
{ label: '计费重量', prop: 'weight', align: 'center', width: 100 },
{ label: '计费日期', prop: 'bizDate', align: 'center', width: 120 },
{ label: '计价来源', prop: 'matchSource', align: 'center', width: 140, slot: 'matchSource' },
{ label: '套餐', prop: 'priceSetName', minWidth: 120, showOverflowTooltip: true },
{ label: '应付总额', prop: 'payableTotal', align: 'right', width: 120, slot: 'payableTotal' },
{ label: '创建时间', prop: 'createTime', align: 'center', width: 170, parseTime: true },
{ label: '操作', prop: 'action', align: 'center', width: 90, fixed: 'right', slot: 'action', showInToolbar: false }
]
const historySearchColumns = [
{ label: '客户ID', prop: 'customerId', component: 'input', placeholder: '客户ID', width: '140px' },
{ label: '产品代码', prop: 'productCode', component: 'input', placeholder: '产品代码', width: '150px' },
{ label: '国家代码', prop: 'countryCode', component: 'input', placeholder: '如 DE', width: '120px' },
{ label: '套餐名称', prop: 'priceSetName', component: 'input', placeholder: '套餐名称', width: '150px' }
]
function queryHistory(params) {
return listQuote(params)
}
function handleViewHistory(row) {
historyTitle.value = '试算详情 #' + row.quoteId
historyOpen.value = true
historyLoading.value = true
viewResult.value = null
getQuote(row.quoteId).then(res => {
viewResult.value = res.data || null
}).finally(() => {
historyLoading.value = false
})
}
const form = reactive({
customerId: undefined,
@@ -195,6 +352,8 @@ function handleQuote() {
result.value = res.data || null
if (!result.value) {
errorMsg.value = res.msg || '计算失败'
} else if (historyTableRef.value && historyTableRef.value.refresh) {
historyTableRef.value.refresh()
}
}).catch(err => {
errorMsg.value = (err && (err.msg || err.message)) || '计算失败'
@@ -257,6 +416,38 @@ function roundModeLabel(mode) {
return map[mode] || mode || '四舍五入'
}
function feeTypeLabel(type) {
const found = (bms_fee_type.value || []).find(d => d.value === type)
return found ? found.label : ''
}
function displayCurrency(v) {
if (!v || v === 'RMB' || v === 'rmb') return 'CNY'
return v
}
function displayFeeName(row) {
const typeLabel = feeTypeLabel(row.feeType)
const name = String(row.feeName || row.fee_name || '').trim()
const isSurcharge = row.feeType === 'SURCHARGE' || row.feeType === '附加费'
if (isSurcharge) {
if (name && name !== typeLabel && name !== 'SURCHARGE' && name !== '附加费') {
return name
}
const fromRemark = specificNameFromRemark(row.remark)
if (fromRemark) {
return fromRemark
}
}
return name || typeLabel || row.feeType || '-'
}
function specificNameFromRemark(remark) {
if (!remark) return ''
const first = String(remark).split(';')[0].trim()
return first.replace(/\s+-?\d+(\.\d+)?$/, '').trim()
}
function sourceTagType(source) {
if (source === 'PACKAGE_FIXED' || source === 'FIXED') return 'success'
if (source === 'PACKAGE_DISCOUNT' || source === 'DISCOUNT') return 'warning'
@@ -284,4 +475,10 @@ loadProducts()
color: #909399;
font-size: 12px;
}
.billing-quote .fee-table {
margin-top: 12px;
}
.billing-quote .history-card {
margin-top: 16px;
}
</style>
+10 -45
View File
@@ -4,7 +4,6 @@
<el-step title="基本信息" />
<el-step title="套餐明细" />
<el-step title="绑定客户" />
<el-step title="校验确认" />
</el-steps>
<!-- 第 1 步 -->
@@ -220,7 +219,7 @@
:closable="false"
show-icon
class="mb12"
title="套餐创建后处于草稿状态,可暂不绑定客户。优先级数字越小越优先(1 = 最高)。同一客户可绑定多个套餐。"
title="套餐创建后处于草稿状态,可暂不绑定客户。一个客户只能绑定一个套餐。"
/>
<el-table :data="form.customers" border empty-text="暂未绑定客户">
<el-table-column label="客户ID" min-width="120">
@@ -256,29 +255,10 @@
</el-table>
</el-card>
<!-- 第 4 步 -->
<el-card v-show="active === 3" shadow="never" class="step-card">
<template #header>
<div class="card-head">
<span>第 4 步 · 校验确认</span>
<el-button type="primary" size="small" @click="runValidate">重新校验</el-button>
</div>
</template>
<el-table :data="checkItems" border empty-text="点击「下一步」或「重新校验」执行校验">
<el-table-column label="校验项" prop="item" min-width="200" />
<el-table-column label="结果" width="120">
<template #default="{ row }">
<el-tag :type="checkTag(row.result)" size="small">{{ checkLabel(row.result) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="说明" prop="message" min-width="280" />
</el-table>
</el-card>
<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 < 3" type="primary" @click="nextStep">下一步</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>
</div>
@@ -286,7 +266,7 @@
</template>
<script setup name="PriceSetCreate">
import { getPriceSet, savePriceSet, validatePriceSet } from '@/api/price/set'
import { getPriceSet, savePriceSet } from '@/api/price/set'
import { listProduct, listProductCountries } from '@/api/price/product'
import { listSurchargeByProduct } from '@/api/price/surcharge'
@@ -296,7 +276,6 @@ const route = useRoute()
const active = ref(0)
const saving = ref(false)
const formRef = ref()
const checkItems = ref([])
const productOptions = ref([])
/** productCode -> bms_product_country rows */
const countryCache = reactive({})
@@ -571,6 +550,8 @@ function buildPayload(submitFlag) {
...c,
customerId: Number(c.customerId)
}))
console.log('details', details)
return {
priceSetId: form.priceSetId,
priceSetName: form.priceSetName,
@@ -592,20 +573,9 @@ function nextStep() {
})
return
}
if (active.value === 2) {
active.value++
runValidate()
return
}
active.value++
}
function runValidate() {
validatePriceSet(buildPayload(false)).then(res => {
checkItems.value = res.data || []
})
}
function saveDraft() {
formRef.value.validate(valid => {
if (!valid) {
@@ -627,6 +597,11 @@ function saveAndSubmit() {
}
function doSave(submit) {
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
@@ -646,16 +621,6 @@ function goBack() {
}
}
function checkTag(result) {
const map = { PASS: 'success', WARN: 'warning', FAIL: 'danger', INFO: 'primary' }
return map[result] || 'info'
}
function checkLabel(result) {
const map = { PASS: '通过', WARN: '待确认', FAIL: '失败', INFO: '提示' }
return map[result] || result
}
function loadExisting(id) {
getPriceSet(id).then(res => {
const data = res.data || {}
+1 -1
View File
@@ -158,7 +158,7 @@
</template>
</el-table-column>
</el-table>
<div class="rel-hint">优先级数字越小越优先;同一客户绑定多个套餐时,按优先级 → 生效日期依次命中。</div>
<div class="rel-hint">一个客户只能绑定一个套餐;解绑后可绑定其他套餐。</div>
</el-card>
<el-dialog :title="detailTitle" v-model="detailOpen" width="560px" append-to-body>