479 lines
20 KiB
Vue
479 lines
20 KiB
Vue
<template>
|
||
<div class="app-container billing-quote">
|
||
<el-row :gutter="16">
|
||
<el-col :xs="24" :lg="10">
|
||
<el-card shadow="never">
|
||
<template #header><span>试算参数</span></template>
|
||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||
<el-form-item label="客户" prop="customerCode">
|
||
<customer-select v-model="form.customerCode" value-key="customerCode" placeholder="请选择客户" />
|
||
<div class="form-hint">与套餐绑定客户一致;无套餐时走产品默认价</div>
|
||
</el-form-item>
|
||
<el-form-item label="产品" prop="productCode">
|
||
<el-select
|
||
v-model="form.productCode"
|
||
filterable
|
||
clearable
|
||
placeholder="请选择产品"
|
||
style="width: 100%"
|
||
@change="handleProductChange"
|
||
>
|
||
<el-option
|
||
v-for="item in productOptions"
|
||
:key="item.productCode"
|
||
:label="item.productName + ' (' + item.productCode + ')'"
|
||
:value="item.productCode"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="目的国/地区" prop="countryCode">
|
||
<el-select
|
||
v-model="form.countryCode"
|
||
filterable
|
||
clearable
|
||
placeholder="请先选择产品"
|
||
style="width: 100%"
|
||
:disabled="!form.productCode"
|
||
>
|
||
<el-option
|
||
v-for="item in countryOptions"
|
||
:key="item.countryCode + '-' + (item.detailId || item.weightMin)"
|
||
:label="countryOptionLabel(item)"
|
||
:value="item.countryCode"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="计费重量(kg)" prop="weight">
|
||
<el-input-number
|
||
v-model="form.weight"
|
||
:min="0.001"
|
||
:precision="3"
|
||
:step="0.1"
|
||
controls-position="right"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="计费时间" prop="bizDate">
|
||
<el-date-picker
|
||
v-model="form.bizDate"
|
||
type="date"
|
||
value-format="YYYY-MM-DD"
|
||
placeholder="默认今天,用于筛选已生效的产品明细"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" :loading="loading" @click="handleQuote">计算价格</el-button>
|
||
<el-button @click="resetForm">重置</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
</el-card>
|
||
</el-col>
|
||
|
||
<el-col :xs="24" :lg="14">
|
||
<el-card shadow="never">
|
||
<template #header><span>试算结果</span></template>
|
||
<el-empty v-if="!result && !errorMsg" description="请输入参数后点击计算价格" />
|
||
<el-alert v-else-if="errorMsg" :title="errorMsg" type="error" show-icon :closable="false" />
|
||
<div v-else class="quote-result">
|
||
<el-descriptions :column="2" border size="small">
|
||
<el-descriptions-item label="客户ID">{{ result.customerId }}</el-descriptions-item>
|
||
<el-descriptions-item label="产品">{{ result.productName || '-' }} ({{ result.productCode }})</el-descriptions-item>
|
||
<el-descriptions-item label="国家">{{ result.countryName || '-' }} ({{ result.countryCode }})</el-descriptions-item>
|
||
<el-descriptions-item label="计费重量(kg)">{{ result.chargeWeight ?? result.weight }}</el-descriptions-item>
|
||
<el-descriptions-item label="重量段">{{ result.weightLabel || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="明细生效时间">{{ formatDate(result.effectiveDate) }}</el-descriptions-item>
|
||
<el-descriptions-item label="计价来源">
|
||
<el-tag size="small" :type="sourceTagType(result.matchSource)">{{ sourceLabel(result.matchSource) }}</el-tag>
|
||
</el-descriptions-item>
|
||
<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="币种">
|
||
<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>
|
||
<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, 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({
|
||
customerCode: undefined,
|
||
productCode: undefined,
|
||
countryCode: undefined,
|
||
weight: undefined,
|
||
bizDate: undefined
|
||
})
|
||
|
||
const rules = {
|
||
customerCode: [{ required: true, message: '请选择客户', trigger: 'change' }],
|
||
productCode: [{ required: true, message: '请选择产品', trigger: 'change' }],
|
||
countryCode: [{ required: true, message: '请选择目的国/地区', trigger: 'change' }],
|
||
weight: [{ required: true, message: '请输入计费重量', trigger: 'blur' }]
|
||
}
|
||
|
||
function loadProducts() {
|
||
listProduct({ pageNum: 1, pageSize: 500, status: '0' }).then(res => {
|
||
productOptions.value = res.rows || []
|
||
})
|
||
}
|
||
|
||
function uniqueCountries(list) {
|
||
const map = new Map()
|
||
;(list || []).forEach(item => {
|
||
if (item && item.countryCode && !map.has(item.countryCode)) {
|
||
map.set(item.countryCode, item)
|
||
}
|
||
})
|
||
return Array.from(map.values())
|
||
}
|
||
|
||
function countryOptionLabel(item) {
|
||
const name = item.countryName || item.countryCode
|
||
return name + ' (' + item.countryCode + ')'
|
||
}
|
||
|
||
function handleProductChange(productCode) {
|
||
form.countryCode = undefined
|
||
countryOptions.value = []
|
||
if (!productCode) {
|
||
return
|
||
}
|
||
listProductCountries(productCode).then(res => {
|
||
countryOptions.value = uniqueCountries((res.data || []).filter(item => !item.expireDate))
|
||
})
|
||
}
|
||
|
||
function handleQuote() {
|
||
formRef.value.validate(valid => {
|
||
if (!valid) {
|
||
return
|
||
}
|
||
loading.value = true
|
||
result.value = null
|
||
errorMsg.value = ''
|
||
const payload = {
|
||
customerCode: form.customerCode,
|
||
productCode: form.productCode,
|
||
countryCode: form.countryCode,
|
||
weight: form.weight
|
||
}
|
||
if (form.bizDate) {
|
||
payload.bizDate = form.bizDate
|
||
}
|
||
quoteBilling(payload).then(res => {
|
||
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)) || '计算失败'
|
||
}).finally(() => {
|
||
loading.value = false
|
||
})
|
||
})
|
||
}
|
||
|
||
function resetForm() {
|
||
form.customerCode = undefined
|
||
form.productCode = undefined
|
||
form.countryCode = undefined
|
||
form.weight = undefined
|
||
form.bizDate = undefined
|
||
countryOptions.value = []
|
||
result.value = null
|
||
errorMsg.value = ''
|
||
proxy.$nextTick(() => {
|
||
formRef.value && formRef.value.clearValidate()
|
||
})
|
||
}
|
||
|
||
function formatMoney(val) {
|
||
if (val === null || val === undefined || val === '') {
|
||
return '-'
|
||
}
|
||
const num = Number(val)
|
||
if (Number.isNaN(num)) {
|
||
return val
|
||
}
|
||
return num.toFixed(4)
|
||
}
|
||
|
||
function formatDate(val) {
|
||
if (!val) return '-'
|
||
// 兼容后端 java.util.Date 转 ISO 字符串
|
||
const d = new Date(val)
|
||
if (Number.isNaN(d.getTime())) return val
|
||
return d.toISOString().slice(0, 10)
|
||
}
|
||
|
||
function sourceLabel(source) {
|
||
const map = {
|
||
PACKAGE_FIXED: '客户固定价套餐',
|
||
PACKAGE_DISCOUNT: '客户折扣套餐',
|
||
PRODUCT_DEFAULT: '产品默认价',
|
||
FIXED: '客户固定价套餐',
|
||
DISCOUNT: '客户折扣套餐'
|
||
}
|
||
return map[source] || source || '-'
|
||
}
|
||
|
||
function roundModeLabel(mode) {
|
||
const map = {
|
||
UP: '向上取值',
|
||
DOWN: '向下取整',
|
||
HALF_UP: '四舍五入'
|
||
}
|
||
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'
|
||
if (source === 'PRODUCT_DEFAULT') return 'info'
|
||
return ''
|
||
}
|
||
|
||
loadProducts()
|
||
</script>
|
||
|
||
<style scoped>
|
||
.billing-quote .form-hint {
|
||
margin-top: 4px;
|
||
color: #909399;
|
||
font-size: 12px;
|
||
line-height: 1.4;
|
||
}
|
||
.billing-quote .payable {
|
||
color: #f56c6c;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
}
|
||
.billing-quote .formula-tip {
|
||
margin-top: 12px;
|
||
color: #909399;
|
||
font-size: 12px;
|
||
}
|
||
.billing-quote .fee-table {
|
||
margin-top: 12px;
|
||
}
|
||
.billing-quote .history-card {
|
||
margin-top: 16px;
|
||
}
|
||
</style>
|