Files
BMS/bms-front/src/views/price/billing/quote.vue
T
2026-09-10 14:23:58 +08:00

479 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>