Files
tms-erp-web/src/views/business/components/billing-plan-editor.vue
T
2026-09-14 15:01:13 +08:00

1232 lines
44 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>
<el-dialog
v-model="visible"
:title="readonly ? '计费方案详情' : index < 0 ? '添加计费方案' : '编辑计费方案'"
width="92%"
append-to-body
destroy-on-close
>
<section-card>
<el-descriptions v-if="readonly" :column="3" class="billing-plan-detail">
<el-descriptions-item label="方案名称">{{
displayValue(draft.planName)
}}</el-descriptions-item>
<el-descriptions-item label="运输方式">{{
transportModeLabel(draft.transportMode)
}}</el-descriptions-item>
<el-descriptions-item label="默认方案">{{
isDefaultPlan(draft) ? '是' : '否'
}}</el-descriptions-item>
<el-descriptions-item label="备注">{{ displayValue(draft.remark) }}</el-descriptions-item>
</el-descriptions>
<el-form
v-else
ref="formRef"
:model="draft"
:rules="formRules"
label-position="right"
label-width="auto"
>
<el-row :gutter="20">
<el-col :span="8"
><el-form-item label="方案名称" prop="planName"
><el-input
v-model="draft.planName"
maxlength="100"
:disabled="readonly" /></el-form-item
></el-col>
<el-col :span="8"
><el-form-item label="运输方式" prop="transportMode"
><el-select
v-model="draft.transportMode"
clearable
filterable
:loading="transportModeLoading"
placeholder="请选择运输方式"
@visible-change="visible => visible && ensureTransportModes()"
><el-option
v-for="item in transportModeOptions"
:key="item.value"
:label="item.label"
:value="item.value" /></el-select></el-form-item
></el-col>
<el-col :span="8"
><el-form-item
><el-checkbox v-model="draft.defaultPlan" :disabled="readonly">默认方案</el-checkbox
><el-tooltip content="同一运输方式仅支持配置一个默认计费方案" placement="top">
<el-icon class="billing-plan-editor__default-tip"
><InfoFilled
/></el-icon> </el-tooltip
></el-form-item
></el-col
>
<el-col :span="24"
><el-form-item label="备注"
><el-input
v-model="draft.remark"
maxlength="100"
show-word-limit
:disabled="readonly" /></el-form-item
></el-col>
</el-row>
</el-form>
</section-card>
<div class="rule-head">
<el-link v-if="!readonly" type="primary" @click="addRule">+添加规则</el-link>
</div>
<el-table :data="draft.rules" border>
<el-table-column type="index" label="序号" width="65" />
<el-table-column label="费用类型" width="180"
><template #default="{ row }"
><span v-if="readonly">{{ feeTypeLabel(row) }}</span
><el-select
v-else
v-model="row.feeType"
clearable
filterable
:loading="feeCategoryLoading"
@change="value => handleFeeTypeChange(row, value)"
><el-option
v-for="item in feeCategories"
:key="item.dictKey"
:label="item.dictValue"
:value="item.dictKey" /></el-select></template
></el-table-column>
<el-table-column label="费用项" width="210"
><template #default="{ row }"
><span v-if="readonly">{{ displayValue(row.feeItem) }}</span
><el-select
v-else
v-model="row.feeItem"
clearable
filterable
:disabled="!row.feeType"
:loading="feeItemLoadingMap[feeTypeKey(row)]"
@change="value => handleFeeItemChange(row, value)"
><el-option
v-for="item in feeItemOptions(row)"
:key="item.id || item.name || item.englishName"
:label="item.name || item.englishName"
:value="item.name || item.englishName" /></el-select></template
></el-table-column>
<el-table-column label="税率" width="180"
><template #default="{ row }"
><span v-if="readonly">{{ formatTaxRate(row.taxRate) }}</span
><el-input
v-else
v-model="row.taxRate"
inputmode="numeric"
maxlength="3"
clearable
placeholder="请输入"
@input="value => taxRateInput(row, value)"
><template #append>%</template></el-input
></template
></el-table-column
>
<el-table-column label="计费要素" width="170"
><template #default="{ row }"
><span v-if="readonly">{{ displayValue(row.billingElement) }}</span
><el-select
v-else
v-model="row.billingElement"
placeholder="请选择"
@change="handleElementChange(row)"
><el-option
v-for="item in billingElements"
:key="item"
:label="item"
:value="item" /></el-select></template
></el-table-column>
<el-table-column label="计费类型" width="180"
><template #default="{ row }"
><span v-if="readonly">{{ displayValue(row.billingType) }}</span
><el-select
v-else
v-model="row.billingType"
:disabled="!row.billingElement"
placeholder="请选择"
@change="handleTypeChange(row)"
><el-option
v-for="item in billingTypes(row)"
:key="item"
:label="item"
:value="item" /></el-select></template
></el-table-column>
<el-table-column label="计费单位" width="150"
><template #default="{ row }"
><span v-if="readonly">{{ displayValue(row.billingUnit) }}</span
><el-select v-else v-model="row.billingUnit" clearable filterable :loading="unitLoading"
><el-option
v-for="item in unitOptions"
:key="item.id || item.dictKey || item.dictValue"
:label="item.dictValue"
:value="item.dictValue" /></el-select></template
></el-table-column>
<el-table-column label="单价" width="180"
><template #default="{ row }"
><div v-if="readonly && usesRangeUnitPrice(row)" class="limit-list">
<span v-for="(item, rangeIndex) in getRanges(row)" :key="rangeIndex">{{
displayValue(item.unitPrice)
}}</span>
</div>
<span v-else-if="readonly">{{ displayValue(row.unitPrice) }}</span>
<div v-else-if="usesRangeUnitPrice(row)" class="limit-list">
<el-input
v-for="(item, rangeIndex) in getRanges(row)"
:key="rangeIndex"
v-model="item.unitPrice"
placeholder="请输入单价"
@input="value => rangeUnitPriceInput(row, rangeIndex, value)"
/>
</div>
<el-input
v-else
v-model="row.unitPrice"
@input="value => decimalInput(row, 'unitPrice', value)" /></template
></el-table-column>
<el-table-column label="计费要素下限" width="250"
><template #default="{ row }"
><div v-if="readonly" class="limit-list">
<span v-for="(item, rangeIndex) in getRanges(row)" :key="rangeIndex">{{
displayValue(item.lowerLimit)
}}</span>
</div>
<div v-else class="limit-list">
<el-input
v-for="(item, rangeIndex) in getRanges(row)"
:key="rangeIndex"
v-model="item.lowerLimit"
:disabled="!canEditLimit(row)"
:placeholder="canEditLimit(row) ? '请输入下限' : '无需配置'"
@input="value => limitInput(row, rangeIndex, 'lowerLimit', value)"
/></div></template
></el-table-column>
<el-table-column label="计费要素上限" width="270"
><template #default="{ row }"
><div v-if="readonly" class="limit-list">
<span v-for="(item, rangeIndex) in getRanges(row)" :key="rangeIndex">{{
displayValue(item.upperLimit)
}}</span>
</div>
<div v-else class="limit-list">
<div v-for="(item, rangeIndex) in getRanges(row)" :key="rangeIndex" class="limit-row">
<el-input
v-model="item.upperLimit"
:disabled="!canEditLimit(row)"
:placeholder="canEditLimit(row) ? '请输入上限' : '无需配置'"
@input="value => limitInput(row, rangeIndex, 'upperLimit', value)"
/><el-link v-if="canEditLimit(row)" type="primary" @click="addRange(row, rangeIndex)"
>添加</el-link
><el-link
v-if="canEditLimit(row) && getRanges(row).length > 1"
type="danger"
@click="removeRange(row, rangeIndex)"
>删除</el-link
>
</div>
</div></template
></el-table-column
>
<el-table-column width="240"
><template #header
><span class="billing-plan-editor__column-title"
>保底计费重量<el-tooltip content="实际计量值小于保底数值时按保底计费" placement="top"
><el-icon class="billing-plan-editor__default-tip"><InfoFilled /></el-icon></el-tooltip></span></template
><template #default="{ row }"
><div v-if="readonly && usesRangeMinimum(row)" class="limit-list">
<span v-for="(item, rangeIndex) in getRanges(row)" :key="rangeIndex">{{
rangeIndex === 0
? displayValue(item.minimumBillingWeight || row.minimumBillingWeight)
: ''
}}</span>
</div>
<span v-else-if="readonly">{{ displayValue(row.minimumBillingWeight) }}</span>
<div v-else-if="usesRangeMinimum(row)" class="limit-list">
<template v-for="(item, rangeIndex) in getRanges(row)" :key="rangeIndex">
<el-input
v-if="rangeIndex === 0"
v-model="item.minimumBillingWeight"
:disabled="!canEditMinimum(row)"
:placeholder="canEditMinimum(row) ? '请输入' : '无需配置'"
@input="value => rangeMinimumInput(row, rangeIndex, value)"
/>
<div v-else class="limit-placeholder" />
</template>
</div>
<el-input
v-else
v-model="row.minimumBillingWeight"
:disabled="!canEditMinimum(row)"
:placeholder="
canEditMinimum(row) ? '请输入' : '仅按重量、按吨·公里可填写'
"
@input="value => decimalInput(row, 'minimumBillingWeight', value)" /></template
></el-table-column>
<el-table-column label="备注" width="180"
><template #default="{ row }"
><span v-if="readonly">{{ displayValue(row.remark) }}</span
><el-input v-else v-model="row.remark" maxlength="100" show-word-limit /></template
></el-table-column>
<el-table-column label="操作" width="250" fixed="right"
><template #default="{ row, $index }"
><el-link type="primary" @click="openMatch(row, $index)">{{
readonly ? '查看匹配条件' : '设置匹配条件'
}}</el-link
><el-link v-if="!readonly" type="primary" @click="copyRule(row)">复制</el-link
><el-link v-if="!readonly" type="danger" @click="removeRule($index)"
>删除</el-link
></template
></el-table-column
>
</el-table>
<template #footer
><el-button v-if="readonly" type="primary" @click="visible = false">关闭</el-button
><template v-else
><el-button @click="visible = false">取消</el-button
><el-button type="primary" @click="save">提交</el-button></template
></template
>
</el-dialog>
<el-dialog
v-model="matchVisible"
:title="readonly ? '匹配条件详情' : '设置匹配条件'"
append-to-body
width="720px"
>
<el-descriptions v-if="readonly" :column="2" class="billing-plan-detail">
<el-descriptions-item label="起运地">{{
displayValue(matchForm.origin)
}}</el-descriptions-item>
<el-descriptions-item label="目的地">{{
displayValue(matchForm.destination)
}}</el-descriptions-item>
<el-descriptions-item label="货物类型">{{
displayValue(matchForm.cargoType)
}}</el-descriptions-item>
</el-descriptions>
<el-form v-else :model="matchForm" label-position="right" label-width="90px">
<el-row :gutter="48">
<el-col :span="12"
><el-form-item label="起运地"
><el-cascader
v-model="matchForm.originPath"
:options="regionOptions"
:props="regionProps"
:placeholder="matchForm.origin || '请选择起运地'"
:loading="regionLoading"
:disabled="readonly"
clearable
filterable
@visible-change="v => v && ensureRegions()"
@change="v => matchRegionChange('origin', v)" /></el-form-item
></el-col>
<el-col :span="12"
><el-form-item label="目的地"
><el-cascader
v-model="matchForm.destinationPath"
:options="regionOptions"
:props="regionProps"
:placeholder="matchForm.destination || '请选择目的地'"
:loading="regionLoading"
:disabled="readonly"
clearable
filterable
@visible-change="v => v && ensureRegions()"
@change="v => matchRegionChange('destination', v)" /></el-form-item
></el-col>
<el-col :span="12"
><el-form-item label="货物类型"
><el-cascader
v-model="matchForm.cargoTypePath"
:options="cargoOptions"
:props="cargoProps"
:placeholder="matchForm.cargoType || '请选择货物类型'"
:loading="cargoLoading"
:disabled="readonly"
clearable
filterable
:filter-method="filterCargo"
@visible-change="v => v && ensureCargo()"
@change="matchCargoChange" /></el-form-item
></el-col>
</el-row>
</el-form>
<template #footer
><el-button v-if="readonly" type="primary" @click="matchVisible = false">关闭</el-button
><template v-else
><el-button @click="matchVisible = false">取消</el-button
><el-button type="primary" @click="saveMatch">保存</el-button></template
></template
>
</el-dialog>
</template>
<script>
import { getList as getCargoTypeList } from '@/api/base/cargo-type';
import { getList as getFeeItemList } from '@/api/base/fee-item';
import { getLazyTree as getRegionLazyTree } from '@/api/base/region';
import { InfoFilled } from '@element-plus/icons-vue';
import { getDictionary } from '@/api/system/dictbiz';
import SectionCard from '@/components/section-card/main.vue';
const clone = value => JSON.parse(JSON.stringify(value));
const defaultRule = () => ({
feeType: '',
feeItem: '',
taxRate: '',
billingElement: '',
billingType: '',
billingUnit: '',
unitPrice: '',
lowerLimit: '',
upperLimit: '',
limitRanges: [{ lowerLimit: '', upperLimit: '', unitPrice: '', minimumBillingWeight: '' }],
minimumBillingWeight: '',
remark: '',
matchCondition: {
origin: '',
originCode: '',
originPath: [],
destination: '',
destinationCode: '',
destinationPath: [],
transportMode: '',
cargoType: '',
cargoTypeCode: '',
cargoTypePath: [],
},
});
export default {
components: { SectionCard, InfoFilled },
props: {
modelValue: Boolean,
value: { type: Object, default: () => ({}) },
index: { type: Number, default: -1 },
readonly: Boolean,
},
emits: ['update:modelValue', 'save'],
data() {
return {
draft: this.normalizePlan(this.value),
feeCategories: [],
feeCategoryLoading: false,
feeItems: {},
feeItemLoadingMap: {},
unitOptions: [],
unitLoading: false,
billingElements: [
'按重量',
'按体积',
'按车辆',
'按里程',
'按吨·公里',
'固定金额(整单一口价)',
'按数量',
],
typeMap: {
按重量: ['固定单价', '区间单价', '阶梯单价', '区间阶梯一口价'],
按体积: ['固定单价', '区间单价', '阶梯单价', '区间阶梯一口价'],
按车辆: ['固定单价'],
按里程: ['固定单价', '区间单价', '阶梯单价', '区间阶梯一口价'],
'按吨·公里': ['固定单价', '区间单价', '阶梯单价', '区间阶梯一口价'],
'固定金额(整单一口价)': ['固定一口价'],
按数量: ['固定单价', '区间单价', '阶梯单价', '区间阶梯一口价'],
},
matchVisible: false,
matchIndex: -1,
matchForm: defaultRule().matchCondition,
transportModeOptions: [],
transportModeLoading: false,
transportModeRequest: null,
regionOptions: [],
regionLoading: false,
regionRequest: null,
cargoOptions: [],
cargoFlatOptions: [],
cargoLoading: false,
cargoRequest: null,
formRules: {
planName: [{ required: true, message: '请输入方案名称', trigger: 'blur' }],
transportMode: [{ required: true, message: '请选择运输方式', trigger: 'change' }],
},
};
},
computed: {
visible: {
get() {
return this.modelValue;
},
set(value) {
this.$emit('update:modelValue', value);
},
},
regionProps() {
return { label: 'title', value: 'id', children: 'children', leaf: 'leaf', emitPath: true };
},
cargoProps() {
return {
label: 'cargoName',
value: 'id',
children: 'children',
leaf: 'leaf',
checkStrictly: true,
emitPath: true,
};
},
},
mounted() {
this.loadDictionaries();
this.ensureTransportModes();
},
watch: {
value: {
deep: true,
handler(value) {
if (this.modelValue) {
this.draft = this.normalizePlan(value);
this.loadRuleItems();
}
},
},
},
methods: {
displayValue(value) {
return value === undefined || value === null || value === '' ? '-' : value;
},
feeTypeLabel(row) {
const option = this.feeCategories.find(
item =>
String(item.dictKey) === String(row.feeType) ||
String(item.dictValue) === String(row.feeType)
);
return this.displayValue(option?.dictValue || row.feeType);
},
transportModeLabel(value) {
const option = this.transportModeOptions.find(
item => String(item.value) === String(value) || String(item.label) === String(value)
);
return this.displayValue(option?.label || this.draft.transportModeLabel || value);
},
isDefaultPlan(plan) {
return (
plan?.defaultPlan === true ||
plan?.defaultPlan === 1 ||
['true', '1'].includes(String(plan?.defaultPlan).toLowerCase())
);
},
normalizePlan(value = {}) {
const plan = {
planName: '',
transportMode: '',
transportModeLabel: '',
defaultPlan: false,
remark: '',
...clone(value || {}),
};
plan.rules = (value?.rules?.length ? value.rules : [defaultRule()]).map(rule =>
this.normalizeRule(rule)
);
return plan;
},
normalizeRule(rule = {}) {
const next = { ...defaultRule(), ...clone(rule) };
if (next.billingType === '阶梯单价' && next.minimumBillingWeight === '') {
const rangeMinimum = (Array.isArray(next.limitRanges) ? next.limitRanges : []).find(
item => item?.minimumBillingWeight !== ''
)?.minimumBillingWeight;
if (rangeMinimum !== undefined) next.minimumBillingWeight = rangeMinimum;
}
next.limitRanges = this.normalizeRanges(next);
if (!this.usesRangeMinimum(next))
next.limitRanges = next.limitRanges.map(item => ({ ...item, minimumBillingWeight: '' }));
this.syncLegacyLimit(next);
next.matchCondition = { ...defaultRule().matchCondition, ...(rule.matchCondition || {}) };
return next;
},
normalizeRanges(row) {
const rawRanges = Array.isArray(row.limitRanges) ? row.limitRanges : [];
const ranges = rawRanges
.map((item, index) => ({
lowerLimit: item?.lowerLimit ?? '',
upperLimit: item?.upperLimit ?? '',
unitPrice:
item?.unitPrice === undefined || item?.unitPrice === ''
? row.unitPrice ?? ''
: item.unitPrice,
minimumBillingWeight: this.resolveRangeMinimum(row, item, index),
}))
.filter(
item =>
item.lowerLimit !== '' ||
item.upperLimit !== '' ||
item.unitPrice !== '' ||
item.minimumBillingWeight !== ''
);
if (
!ranges.length &&
(row.lowerLimit !== '' ||
row.upperLimit !== '' ||
row.unitPrice !== '' ||
row.minimumBillingWeight !== '')
)
ranges.push({
lowerLimit: row.lowerLimit ?? '',
upperLimit: row.upperLimit ?? '',
unitPrice: row.unitPrice ?? '',
minimumBillingWeight: this.usesRangeMinimum(row) ? row.minimumBillingWeight ?? '' : '',
});
return ranges.length
? ranges.map((item, index) => ({
...item,
minimumBillingWeight: this.usesRangeMinimum(row)
? index === 0
? item.minimumBillingWeight
: ''
: '',
}))
: [{ lowerLimit: '', upperLimit: '', unitPrice: '', minimumBillingWeight: '' }];
},
resolveRangeMinimum(row, item, index) {
if (!this.usesRangeMinimum(row) || index !== 0) return '';
if (item?.minimumBillingWeight !== undefined && item?.minimumBillingWeight !== '') {
return item.minimumBillingWeight;
}
return row.minimumBillingWeight ?? '';
},
syncLegacyLimit(row) {
const first = row.limitRanges?.[0] || {};
row.lowerLimit = first.lowerLimit ?? '';
row.upperLimit = first.upperLimit ?? '';
if (this.usesRangeUnitPrice(row)) row.unitPrice = first.unitPrice ?? '';
if (this.usesRangeMinimum(row)) row.minimumBillingWeight = first.minimumBillingWeight ?? '';
},
loadDictionaries() {
this.feeCategoryLoading = true;
getDictionary({ code: 'fee_category' })
.then(res => {
this.feeCategories = res.data?.data || [];
this.loadRuleItems();
})
.finally(() => {
this.feeCategoryLoading = false;
});
this.unitLoading = true;
getDictionary({ code: 'unit_fee' })
.then(res => {
this.unitOptions = res.data?.data || [];
})
.finally(() => {
this.unitLoading = false;
});
},
feeTypeKey(row) {
const option = this.feeCategories.find(
item =>
String(item.dictKey) === String(row.feeType) ||
String(item.dictValue) === String(row.feeType)
);
return option?.dictKey || row.feeType || '';
},
feeItemOptions(row) {
return this.feeItems[this.feeTypeKey(row)] || [];
},
formatTaxRate(value) {
return value === undefined || value === null || value === '' ? '-' : `${value}%`;
},
loadRuleItems() {
(this.draft.rules || []).forEach(row => this.loadFeeItems(row.feeType));
},
loadFeeItems(value) {
const key = this.feeTypeKey({ feeType: value });
if (!key) return;
if (this.feeItems[key]) {
this.fillLoadedRuleTaxRates(key);
return;
}
this.feeItemLoadingMap[key] = true;
getFeeItemList(1, 9999, { feeCategory: key })
.then(res => {
const data = res?.data?.data || res?.data || {};
this.feeItems[key] = Array.isArray(data) ? data : data.records || [];
this.fillLoadedRuleTaxRates(key);
})
.finally(() => {
this.feeItemLoadingMap[key] = false;
});
},
fillLoadedRuleTaxRates(key) {
(this.draft.rules || [])
.filter(row => this.feeTypeKey(row) === key)
.forEach(row => this.fillRuleTaxRate(row));
},
handleFeeTypeChange(row, value) {
row.feeType = this.feeTypeKey({ feeType: value });
row.feeItem = '';
row.taxRate = '';
this.loadFeeItems(row.feeType);
},
handleFeeItemChange(row, value) {
row.feeItem = value;
const feeItem = this.feeItemOptions(row).find(
item => String(item.name || item.englishName || '') === String(value || '')
);
row.taxRate = this.hasTaxRate(feeItem?.taxRate) ? this.normalizeTaxRate(feeItem.taxRate) : '';
},
fillRuleTaxRate(row) {
if (this.hasTaxRate(row.taxRate) || !row.feeItem) return;
const feeItem = this.feeItemOptions(row).find(
item => String(item.name || item.englishName || '') === String(row.feeItem)
);
if (this.hasTaxRate(feeItem?.taxRate)) row.taxRate = this.normalizeTaxRate(feeItem.taxRate);
},
hasTaxRate(value) {
return value !== undefined && value !== null && String(value).trim() !== '';
},
normalizeTaxRate(value) {
const rate = Number(String(value ?? '').trim());
return Number.isFinite(rate) ? String(Math.trunc(Math.abs(rate))) : '';
},
taxRateInput(row, value) {
let text = String(value ?? '').replace(/\D/g, '');
if (text.length > 1) text = text.replace(/^0+/, '') || '0';
row.taxRate = text;
},
billingTypes(row) {
return this.typeMap[row.billingElement] || [];
},
handleElementChange(row) {
if (!this.billingTypes(row).includes(row.billingType)) row.billingType = '';
if (!this.canEditMinimum(row)) {
row.minimumBillingWeight = '';
row.limitRanges = (row.limitRanges || []).map(item => ({
...item,
minimumBillingWeight: '',
}));
}
this.handleTypeChange(row);
},
handleTypeChange(row) {
if (!this.canEditMinimum(row)) row.minimumBillingWeight = '';
if (!this.usesRangeMinimum(row))
row.limitRanges = (row.limitRanges || []).map(item => ({
...item,
minimumBillingWeight: '',
}));
if (!this.canEditLimit(row)) {
row.limitRanges = [
{ lowerLimit: '', upperLimit: '', unitPrice: '', minimumBillingWeight: '' },
];
row.lowerLimit = '';
row.upperLimit = '';
} else {
row.limitRanges = this.normalizeRanges(row);
this.syncLegacyLimit(row);
}
},
canEditLimit(row) {
return (
!this.readonly &&
Boolean(row.billingElement) &&
Boolean(row.billingType) &&
(row.billingType.includes('区间') || row.billingType.includes('阶梯'))
);
},
usesRangeUnitPrice(row) {
return ['区间单价', '阶梯单价', '区间阶梯一口价'].includes(row.billingType);
},
usesRangeMinimum(row) {
return this.supportsMinimum(row) && row.billingType === '区间单价';
},
supportsMinimum(row) {
return (
['按重量', '按吨·公里'].includes(row.billingElement) &&
Boolean(row.billingType) &&
!row.billingType.includes('一口价')
);
},
canEditMinimum(row) {
return !this.readonly && this.supportsMinimum(row);
},
getRanges(row) {
return Array.isArray(row.limitRanges) && row.limitRanges.length
? row.limitRanges
: [{ lowerLimit: '', upperLimit: '', unitPrice: '', minimumBillingWeight: '' }];
},
addRange(row, index) {
row.limitRanges = this.getRanges(row);
row.limitRanges.splice(index + 1, 0, {
lowerLimit: '',
upperLimit: '',
unitPrice: '',
minimumBillingWeight: '',
});
},
removeRange(row, index) {
row.limitRanges = this.getRanges(row);
row.limitRanges.splice(index, 1);
if (!row.limitRanges.length)
row.limitRanges.push({
lowerLimit: '',
upperLimit: '',
unitPrice: '',
minimumBillingWeight: '',
});
this.syncLegacyLimit(row);
},
limitInput(row, index, prop, value) {
row.limitRanges = this.getRanges(row);
const text = String(value || '').replace(/[^\d.]/g, '');
const parts = text.split('.');
row.limitRanges[index][prop] =
parts.length > 1 ? `${parts[0]}.${parts.slice(1).join('').slice(0, 2)}` : parts[0];
this.syncLegacyLimit(row);
},
decimalInput(row, prop, value) {
const text = String(value || '').replace(/[^\d.]/g, '');
const parts = text.split('.');
row[prop] =
parts.length > 1 ? `${parts[0]}.${parts.slice(1).join('').slice(0, 2)}` : parts[0];
},
rangeUnitPriceInput(row, index, value) {
row.limitRanges = this.getRanges(row);
const target = { value };
this.decimalInput(target, 'value', value);
row.limitRanges[index].unitPrice = target.value;
if (index === 0) row.unitPrice = target.value;
},
rangeMinimumInput(row, index, value) {
if (index !== 0) return;
row.limitRanges = this.getRanges(row);
const target = { value };
this.decimalInput(target, 'value', value);
row.limitRanges[0].minimumBillingWeight = target.value;
row.minimumBillingWeight = target.value;
row.limitRanges = row.limitRanges.map((item, rangeIndex) => ({
...item,
minimumBillingWeight: rangeIndex === 0 ? target.value : '',
}));
},
addRule() {
this.draft.rules.push(defaultRule());
},
copyRule(row) {
const next = clone(row);
this.draft.rules.push(this.normalizeRule(next));
this.loadFeeItems(next.feeType);
},
removeRule(index) {
this.draft.rules.splice(index, 1);
if (!this.draft.rules.length) this.addRule();
},
validateRules() {
const groups = {};
const feeItemSet = new Set();
const required = [
['feeType', '费用类型'],
['feeItem', '费用项'],
['taxRate', '税率'],
['billingElement', '计费要素'],
['billingType', '计费类型'],
['billingUnit', '计费单位'],
];
for (const [i, row] of this.draft.rules.entries()) {
const empty = required.find(
([key]) => row[key] === undefined || row[key] === null || String(row[key]).trim() === ''
);
if (empty) {
this.$message.warning(`第${i + 1}${empty[1]}不能为空`);
return false;
}
const feeItem = String(row.feeItem).trim();
if (feeItemSet.has(feeItem)) {
this.$message.warning(`费用项“${feeItem}”不能重复`);
return false;
}
feeItemSet.add(feeItem);
if (this.hasTaxRate(row.taxRate)) {
const taxRate = Number(row.taxRate);
if (!/^\d+$/.test(String(row.taxRate).trim()) || taxRate < 0 || taxRate > 100) {
this.$message.warning(`第${i + 1}行税率必须是0到100之间的整数`);
return false;
}
}
if (
!this.usesRangeUnitPrice(row) &&
(row.unitPrice === undefined ||
row.unitPrice === null ||
String(row.unitPrice).trim() === '')
) {
this.$message.warning(`第${i + 1}行单价不能为空`);
return false;
}
if (!this.billingTypes(row).includes(row.billingType)) {
this.$message.warning('请选择计费要素对应的计费类型');
return false;
}
if (!this.canEditLimit(row)) continue;
const billingElement = String(row.billingElement).trim();
const key = JSON.stringify([feeItem, billingElement]);
groups[key] = groups[key] || { feeItem, billingElement, ranges: [] };
for (const range of this.getRanges(row)) {
const lower = Number(range.lowerLimit);
const upper = Number(range.upperLimit);
if (
range.lowerLimit === '' ||
range.upperLimit === '' ||
Number.isNaN(lower) ||
Number.isNaN(upper)
) {
this.$message.warning('请完整填写计费要素上下限');
return false;
}
if (
this.usesRangeUnitPrice(row) &&
(range.unitPrice === undefined ||
range.unitPrice === null ||
String(range.unitPrice).trim() === '')
) {
this.$message.warning('请为每组计费要素区间填写单价');
return false;
}
if (lower > upper) {
this.$message.warning('计费要素下限不能大于上限');
return false;
}
groups[key].ranges.push({ lower, upper });
}
}
return this.validateRangeGroups(groups);
},
validateRangeGroups(groups) {
const precision = 0.000001;
for (const { feeItem, billingElement, ranges } of Object.values(groups)) {
const sorted = [...ranges].sort((a, b) => a.lower - b.lower || a.upper - b.upper);
for (let i = 1; i < sorted.length; i += 1) {
if (sorted[i].lower < sorted[i - 1].upper - precision) {
this.$message.warning(`费用项“${feeItem}${billingElement}的计费要素区间不能重叠`);
return false;
}
if (sorted[i].lower > sorted[i - 1].upper + precision) {
this.$message.warning(
`费用项“${feeItem}${billingElement}的计费要素区间必须连续,不能存在间隙`
);
return false;
}
}
}
return true;
},
save() {
this.$refs.formRef.validate(valid => {
if (!valid || !this.validateRules()) return;
const plan = clone(this.draft);
const transportMode = this.transportModeOptions.find(
item => String(item.value) === String(plan.transportMode)
);
plan.transportModeLabel = transportMode?.label || plan.transportMode;
plan.rules = plan.rules.map(rule => {
const ranges = this.canEditLimit(rule) ? this.getRanges(rule) : [];
const normalizedRanges = ranges.map((item, rangeIndex) => ({
...item,
minimumBillingWeight:
this.usesRangeMinimum(rule) && rangeIndex === 0 ? item.minimumBillingWeight || '' : '',
}));
return {
...rule,
unitPrice: this.usesRangeUnitPrice(rule)
? normalizedRanges[0]?.unitPrice || ''
: rule.unitPrice,
limitRanges: normalizedRanges,
lowerLimit: normalizedRanges[0]?.lowerLimit || '',
upperLimit: normalizedRanges[0]?.upperLimit || '',
minimumBillingWeight: this.canEditMinimum(rule)
? this.usesRangeMinimum(rule)
? normalizedRanges[0]?.minimumBillingWeight || ''
: rule.minimumBillingWeight
: '',
};
});
this.$emit('save', plan, this.index);
this.visible = false;
});
},
openMatch(row, index) {
this.matchIndex = index;
this.matchForm = { ...defaultRule().matchCondition, ...(row.matchCondition || {}) };
this.matchVisible = true;
this.ensureRegions();
this.ensureCargo().then(() => {
const path = this.resolveCargoPath(this.matchForm);
if (path.length) this.matchCargoChange(path);
});
},
ensureTransportModes() {
if (this.transportModeOptions.length) return Promise.resolve(this.transportModeOptions);
if (this.transportModeRequest) return this.transportModeRequest;
this.transportModeLoading = true;
this.transportModeRequest = getDictionary({ code: 'transport_type' })
.then(res => {
this.transportModeOptions = (res.data?.data || []).map(item => ({
label: item.dictValue || item.label || item.value || '',
value: item.dictKey || item.value || item.dictValue || '',
}));
return this.transportModeOptions;
})
.finally(() => {
this.transportModeLoading = false;
this.transportModeRequest = null;
});
return this.transportModeRequest;
},
ensureRegions() {
if (this.regionOptions.length) return Promise.resolve(this.regionOptions);
if (this.regionRequest) return this.regionRequest;
this.regionLoading = true;
this.regionRequest = getRegionLazyTree()
.then(res => {
const raw = res.data?.data || [];
this.regionOptions = this.normalizeRegionTree(raw);
return this.regionOptions;
})
.finally(() => {
this.regionLoading = false;
this.regionRequest = null;
});
return this.regionRequest;
},
normalizeRegionTree(list) {
const map = new Map();
const flat = [];
const walk = items =>
(items || []).forEach(item => {
flat.push({ ...item, children: undefined });
if (item.children?.length) walk(item.children);
});
walk(list);
flat.forEach(item =>
map.set(String(item.id ?? item.code ?? item.value), {
...item,
id: String(item.id ?? item.code ?? item.value),
parentId: String(item.parentId ?? item.parentCode ?? ''),
children: [],
})
);
const roots = [];
map.forEach(node => {
const parent = map.get(node.parentId);
if (parent && parent !== node) parent.children.push(node);
else roots.push(node);
});
const china = roots.find(item =>
['中国', '中华人民共和国'].includes(item.title || item.name)
);
const source = china?.children?.length ? china.children : roots;
const normalize = (node, level) => ({
...node,
title: node.title || node.name || '',
leaf: level === 2,
children: level === 1 ? (node.children || []).map(child => normalize(child, 2)) : undefined,
});
return source.map(node => normalize(node, 1));
},
regionLabels(path) {
let options = this.regionOptions;
const labels = [];
(path || []).forEach(value => {
const option = options.find(item => String(item.id) === String(value));
if (option) {
labels.push(option.title || option.name || '');
options = option.children || [];
}
});
return labels;
},
matchRegionChange(prop, value) {
const path = Array.isArray(value) && value.length >= 2 ? value.slice(0, 2) : [];
this.matchForm[`${prop}Path`] = path;
const labels = this.regionLabels(path);
this.matchForm[prop] = labels.join('');
this.matchForm[`${prop}Code`] = path.length ? String(path[path.length - 1]) : '';
},
ensureCargo() {
if (this.cargoOptions.length) return Promise.resolve(this.cargoOptions);
if (this.cargoRequest) return this.cargoRequest;
this.cargoLoading = true;
this.cargoRequest = getCargoTypeList(1, 9999)
.then(res => {
const data = res?.data?.data || res?.data || {};
const records = Array.isArray(data) ? data : data.records || [];
this.cargoOptions = this.buildCargoTree(records);
this.cargoFlatOptions = this.flatten(this.cargoOptions);
return this.cargoOptions;
})
.finally(() => {
this.cargoLoading = false;
this.cargoRequest = null;
});
return this.cargoRequest;
},
buildCargoTree(records) {
const flat = [];
const walk = items =>
(items || []).forEach(item => {
flat.push(item);
if (item.children?.length) walk(item.children);
});
walk(records);
const map = new Map();
flat.forEach(item => {
const id = String(item.id ?? item.cargoCode ?? item.code);
map.set(id, {
...item,
id,
cargoName: item.cargoName || item.name || item.typeName || '',
parentId: String(item.parentId ?? item.parentCode ?? ''),
children: [],
});
});
const roots = [];
map.forEach(node => {
const parent = map.get(node.parentId);
if (parent && parent !== node && Number(node.typeLevel) !== 1) parent.children.push(node);
else roots.push(node);
});
const normalize = (node, level, parent = []) => {
const path = [...parent, node.id];
const children = level === 1 ? node.children.map(item => normalize(item, 2, path)) : [];
return {
...node,
path,
leaf: level === 2,
children: children.length ? children : undefined,
};
};
return roots.map(node => normalize(node, 1));
},
flatten(list) {
const result = [];
const walk = items =>
(items || []).forEach(item => {
result.push(item);
if (item.children?.length) walk(item.children);
});
walk(list);
return result;
},
filterCargo(node, keyword) {
const text = String(keyword || '').trim();
return !text || String(node.label || node.text || node.cargoName || '').includes(text);
},
cargoLabels(path) {
let options = this.cargoOptions;
const labels = [];
(path || []).forEach(value => {
const option = options.find(item => String(item.id) === String(value));
if (option) {
labels.push(option.cargoName || '');
options = option.children || [];
}
});
return labels;
},
matchCargoChange(value) {
const path =
Array.isArray(value) && value.length
? value.slice(0, 2).map(item => String(item))
: [];
const item = this.cargoFlatOptions.find(
option =>
option.path?.length === path.length &&
option.path.every((itemValue, index) => String(itemValue) === path[index])
);
this.matchForm.cargoTypePath = path;
this.matchForm.cargoType = item ? this.cargoLabels(path).join('/') : '';
this.matchForm.cargoTypeCode = item ? item.cargoCode || item.code || item.id : '';
},
resolveCargoPath(condition) {
if (Array.isArray(condition.cargoTypePath) && condition.cargoTypePath.length)
return condition.cargoTypePath.slice(0, 2).map(item => String(item));
const item = this.cargoFlatOptions.find(
option =>
(String(option.cargoCode || option.code || option.id) ===
String(condition.cargoTypeCode) ||
option.cargoName === condition.cargoType ||
this.cargoLabels(option.path).join('/') === condition.cargoType)
);
return item?.path || [];
},
saveMatch() {
const row = this.draft.rules[this.matchIndex];
if (row) {
this.matchRegionChange('origin', this.matchForm.originPath);
this.matchRegionChange('destination', this.matchForm.destinationPath);
this.matchCargoChange(this.matchForm.cargoTypePath);
row.matchCondition = clone(this.matchForm);
}
this.matchVisible = false;
},
},
};
</script>
<style scoped>
.billing-plan-detail {
margin-bottom: 16px;
}
.billing-plan-detail :deep(.el-descriptions__label) {
display: inline-block;
min-width: 80px;
padding-right: 12px;
color: #606266;
text-align: right;
}
.billing-plan-detail :deep(.el-descriptions__content) {
color: #303133;
word-break: break-word;
}
.billing-plan-detail :deep(.el-descriptions__cell) {
padding-bottom: 16px;
}
.rule-head {
margin: 12px 0;
}
.billing-plan-editor__default-tip {
margin-left: 6px;
color: #909399;
cursor: pointer;
}
.billing-plan-editor__column-title {
display: inline-flex;
align-items: center;
}
.limit-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.limit-row {
display: flex;
align-items: center;
gap: 8px;
}
.limit-row .el-input {
flex: 1;
}
.limit-placeholder {
height: 32px;
}
</style>