✨ 完善付款申请多结算行、结算导入编辑与计费规则展示

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-19 12:06:51 +08:00
parent 1ac741f785
commit aee661772b
8 changed files with 884 additions and 217 deletions
@@ -860,11 +860,14 @@
</el-table-column>
<el-table-column label="运输单价" min-width="140" align="center">
<template #default="{ row }">
<span v-if="isTierUnitPrice(row)">-</span>
<el-input-number
v-else
v-model="row.unitPrice"
:min="0"
:precision="2"
:controls="false"
:disabled="!isUnitPriceAdjustable(row)"
@change="recalculateAdjustRow(row, 'unitPrice')"
/>
</template>
@@ -1037,6 +1040,7 @@ import { getDictionary } from '@/api/system/dictbiz';
import { h } from 'vue';
import { mapGetters } from 'vuex';
import { formatContractOptionLabel } from '@/utils/contract';
import { isTierUnitPrice, isUnitPriceAdjustable } from '@/utils/settlement-adjust-rule';
import { normalizeCurrencyCode } from '@/utils/currency';
import { downloadFileByUrl } from '@/utils/util';
import {
@@ -1318,6 +1322,8 @@ export default {
},
},
methods: {
isTierUnitPrice,
isUnitPriceAdjustable,
formatContractOptionLabel,
hasPermission(code) {
if (this.isAdmin) return true;
@@ -2128,15 +2134,21 @@ export default {
const settlementType = row.settlementType || this.form.settlementType || 'payable';
const detailId = await resolveReceivablePayableDetailId(row, settlementType);
if (!detailId) return this.$message.info('当前记录未找到对应的应收/应付明细');
this.$router.push({ path: settlementDetailPath(settlementType), query: { detailId } });
await this.$router.push({ path: settlementDetailPath(settlementType), query: { detailId } });
this.closeDetailDialog();
return;
}
if (column.prop === 'waybillNo') {
const waybillId = await resolveWaybillId(row);
if (!waybillId) return this.$message.info('当前记录未找到对应的运单详情');
this.$router.push({ path: '/business/waybill-manage/detail', query: { id: waybillId } });
await this.$router.push({ path: '/business/waybill-manage/detail', query: { id: waybillId } });
this.closeDetailDialog();
}
},
closeDetailDialog() {
if (this.pageMode) return;
this.visible = false;
},
resetDetailQuery(apply = true) {
this.detailQuery = { documentNo: '', waybillNo: '', batchNo: '', cargoName: '' };
if (apply) this.handleDetailQuery();
@@ -2260,11 +2272,16 @@ export default {
row.settlementAmountTax = Number(
(hasFreightItem ? feeItemTotal : Number(row.freightAmount || 0) + feeItemTotal).toFixed(2)
);
if (changedField && this.adjust.sourceDetailId && (row.sourceFeeId || row.id)) {
this.scheduleSourceAdjustCalculation(row);
if (
['transportQuantity', 'mileage', 'unitPrice'].includes(changedField) &&
this.adjust.sourceDetailId &&
(row.sourceFeeId || row.id) &&
(changedField !== 'unitPrice' || isUnitPriceAdjustable(row))
) {
this.scheduleSourceAdjustCalculation(row, changedField);
}
},
scheduleSourceAdjustCalculation(row) {
scheduleSourceAdjustCalculation(row, changedField) {
if (row.adjustCalculateTimer) clearTimeout(row.adjustCalculateTimer);
row.adjustCalculateVersion = Number(row.adjustCalculateVersion || 0) + 1;
row.calculating = true;
@@ -2277,6 +2294,8 @@ export default {
feeId: row.sourceFeeId || row.id,
transportQuantity: Number(row.transportQuantity || 0),
mileage: Number(row.mileage || 0),
unitPrice: row.unitPrice === null || row.unitPrice === '' ? null : Number(row.unitPrice),
changedField,
freightAmount: Number(row.freightAmount || 0),
feeItems: Object.fromEntries(
Object.entries(row.feeItems || {}).map(([name, amount]) => [name, Number(amount || 0)])
@@ -2288,6 +2307,9 @@ export default {
row.feeItems = Object.fromEntries(
Object.entries(data.feeItems || {}).map(([name, amount]) => [name, Number(amount || 0)])
);
if (!isTierUnitPrice(row) && data.unitPrice !== undefined && data.unitPrice !== null) {
row.unitPrice = Number(data.unitPrice);
}
row.adjustAmount = Number(data.adjustAmount || 0);
row.settlementAmountTax = Number(data.afterAmount || 0);
} catch (error) {
@@ -256,13 +256,23 @@
show-overflow-tooltip
>
<template #default="{ row }">
<span v-if="item.kind === 'fee'">
{{
formatMoney(
parseFeeItems(row.feeItemsJson)[item.name],
row.currency || form.currency
)
}}
<span v-if="item.kind === 'fee'" class="pre-settlement-editor__billing-rule-cell">
<span>
{{
formatMoney(
parseFeeItems(row.feeItemsJson)[item.name],
row.currency || form.currency
)
}}
</span>
<el-icon
v-if="hasDetailBillingRule(row)"
class="pre-settlement-editor__billing-rule-icon"
title="查看计费规则详情"
@click.stop="openDetailBillingRule(row, item.name)"
>
<InfoFilled />
</el-icon>
</span>
<el-link
v-else-if="item.column.link && row[item.column.prop] && !publicMode"
@@ -274,6 +284,20 @@
<span v-else-if="item.column.money && item.column.prop === 'unitPrice'">
{{ formatUnitPrice(row) }}
</span>
<span
v-else-if="item.column.prop === 'freightAmount'"
class="pre-settlement-editor__billing-rule-cell"
>
<span>{{ formatMoney(row[item.column.prop], row.currency || form.currency) }}</span>
<el-icon
v-if="hasDetailBillingRule(row)"
class="pre-settlement-editor__billing-rule-icon"
title="查看计费规则详情"
@click.stop="openDetailBillingRule(row, '运输费')"
>
<InfoFilled />
</el-icon>
</span>
<span v-else-if="item.column.money">
{{ formatMoney(row[item.column.prop], row.currency || form.currency) }}
</span>
@@ -781,12 +805,14 @@
</el-table-column>
<el-table-column label="运输单价" min-width="140" align="center">
<template #default="{ row }">
<span v-if="isTierUnitPrice(row)">-</span>
<el-input-number
v-else
v-model="row.unitPrice"
:min="0"
:precision="2"
:controls="false"
:disabled="adjustDialog.readonly"
:disabled="adjustDialog.readonly || !isUnitPriceAdjustable(row)"
@change="recalculateAdjustRow(row, 'unitPrice')"
/>
</template>
@@ -799,10 +825,7 @@
align="center"
>
<template #default="{ row }">
<div
v-if="isFreightFeeItem(feeItem)"
class="pre-settlement-editor__billing-rule-cell"
>
<div class="pre-settlement-editor__billing-rule-cell">
<el-input-number
v-model="row.feeItems[feeItem]"
:min="0"
@@ -815,20 +838,11 @@
v-if="hasBillingRule(row)"
class="pre-settlement-editor__billing-rule-icon"
title="查看计费规则详情"
@click="openBillingRuleInfo(row)"
@click="openBillingRuleInfo(row, feeItem)"
>
<InfoFilled />
</el-icon>
</div>
<el-input-number
v-else
v-model="row.feeItems[feeItem]"
:min="0"
:precision="2"
:controls="false"
:disabled="adjustDialog.readonly"
@change="recalculateAdjustRow(row, feeItem)"
/>
</template>
</el-table-column>
<el-table-column label="结算金额(含税)" min-width="170" align="center">
@@ -986,6 +1000,7 @@ import {
settlementSummaryColumns,
} from '@/option/settlement/preSettlementTable';
import { formatContractOptionLabel } from '@/utils/contract';
import { isTierUnitPrice, isUnitPriceAdjustable } from '@/utils/settlement-adjust-rule';
import { normalizeCurrencyCode } from '@/utils/currency';
import { downloadFileByUrl } from '@/utils/util';
import { loadMkEnabled, submitMkApprovalFlow } from '@/utils/mk-approval';
@@ -1234,8 +1249,14 @@ export default {
});
return Array.from(names);
},
// 费用项里已有运输费/运费时,不再单独展示运费列,避免和应收应付费用项重复。
showStandaloneFreightColumn() {
return !this.detailFeeItemNames.some(name => this.isFreightFeeItem(name));
},
detailDisplayColumns() {
const columns = this.visibleDetailColumns;
const columns = this.visibleDetailColumns.filter(
column => this.showStandaloneFreightColumn || column.prop !== 'freightAmount'
);
const unitPriceIndex = columns.findIndex(column => column.prop === 'unitPrice');
const beforeColumns = unitPriceIndex < 0 ? columns : columns.slice(0, unitPriceIndex + 1);
const afterColumns = unitPriceIndex < 0 ? [] : columns.slice(unitPriceIndex + 1);
@@ -1306,6 +1327,8 @@ export default {
},
},
methods: {
isTierUnitPrice,
isUnitPriceAdjustable,
formatContractOptionLabel,
hasPermission(code) {
return this.isAdmin || this.validData(this.permission?.[code], false);
@@ -2321,11 +2344,38 @@ export default {
rule.matchCondition?.cargoType
);
},
openBillingRuleInfo(row) {
this.billingRuleDialog.rule = this.normalizeBillingRule(row);
detailFeeRows(row = {}) {
return this.detailFeeCache[row.id] || this.detailFeeCache[row.preSettlementDetailId] || [];
},
hasDetailBillingRule(row) {
if (this.hasBillingRule(row)) return true;
return this.detailFeeRows(row).some(fee => this.hasBillingRule(fee));
},
openDetailBillingRule(row, feeName = '') {
const fees = this.detailFeeRows(row);
const matched = fees.find(fee =>
this.collectBillingRules(fee).some(rule =>
this.feeNamesMatch(this.ruleFeeName(rule), feeName)
)
);
const source = matched || fees.find(fee => this.hasBillingRule(fee)) || row;
this.openBillingRuleInfo(source, feeName);
},
openBillingRuleInfo(row, feeName = '') {
this.billingRuleDialog.rule = this.normalizeBillingRule(row, feeName);
this.billingRuleDialog.visible = true;
},
normalizeBillingRule(row) {
ruleFeeName(rule = {}) {
return String(rule.feeItem || rule.feeItemName || rule.name || '').trim();
},
feeNamesMatch(left, right) {
const source = String(left || '').trim();
const target = String(right || '').trim();
if (!source || !target) return false;
if (source === target) return true;
return this.isFreightFeeItem(source) && this.isFreightFeeItem(target);
},
collectBillingRules(row = {}) {
const source = row || {};
const nested = [
source.billingRule,
@@ -2338,28 +2388,63 @@ export default {
source.rule,
].find(value => value !== undefined && value !== null && value !== '');
const parsedNested = this.parseJsonValue(nested);
const matchedRules = Array.isArray(parsedNested)
return Array.isArray(parsedNested)
? parsedNested.filter(item => item && typeof item === 'object')
: [];
let rule = this.parseObject(matchedRules[0] || parsedNested || nested);
if (!Object.keys(rule).length && typeof nested === 'string') rule = { name: nested };
},
normalizeBillingRule(row, feeName = '') {
const source = row || {};
const nested = [
source.billingRule,
source.billingRuleInfo,
source.billingRuleJson,
source.billingRulesJson,
source.billingRules,
source.billingPlanRule,
source.feeRule,
source.rule,
].find(value => value !== undefined && value !== null && value !== '');
const parsedNested = this.parseJsonValue(nested);
const parsedRules = this.collectBillingRules(source);
const requestedFeeName = String(feeName || '').trim();
const matchedRules = requestedFeeName
? parsedRules.filter(item => this.feeNamesMatch(this.ruleFeeName(item), requestedFeeName))
: parsedRules;
const sharedRule =
parsedRules.length === 1 && !this.ruleFeeName(parsedRules[0]) ? parsedRules[0] : null;
const ruleSource =
matchedRules[0] || sharedRule || (!requestedFeeName ? parsedNested || nested : null);
let rule = this.parseObject(ruleSource);
if (!Object.keys(rule).length && typeof nested === 'string' && !requestedFeeName) {
rule = { name: nested };
}
const matchCondition = this.parseObject(
source.matchCondition ?? source.billingMatchCondition ?? rule.matchCondition
requestedFeeName
? (rule.matchCondition ?? source.matchCondition ?? source.billingMatchCondition)
: (source.matchCondition ?? source.billingMatchCondition ?? rule.matchCondition)
);
return {
...rule,
feeItem: source.billingFeeItem || source.feeItem || rule.feeItem,
billingElement:
source.billingElement ||
source.billingFactor ||
rule.billingElement ||
rule.billingFactor,
billingType: source.billingType || rule.billingType,
billingUnit: source.billingUnit || source.priceUnit || rule.billingUnit || rule.priceUnit,
unitPrice: source.billingUnitPrice ?? source.unitPrice ?? rule.unitPrice,
minimumBillingWeight:
source.minimumBillingWeight ?? rule.minimumBillingWeight ?? rule.lowerLimit,
remark: source.billingRuleRemark || source.ruleRemark || rule.remark,
feeItem: requestedFeeName || source.billingFeeItem || source.feeItem || rule.feeItem,
billingElement: requestedFeeName
? rule.billingElement || rule.billingFactor || ''
: source.billingElement ||
source.billingFactor ||
rule.billingElement ||
rule.billingFactor,
billingType: requestedFeeName ? rule.billingType || '' : source.billingType || rule.billingType,
billingUnit: requestedFeeName
? rule.billingUnit || rule.priceUnit || ''
: source.billingUnit || source.priceUnit || rule.billingUnit || rule.priceUnit,
unitPrice: requestedFeeName
? (rule.unitPrice ?? '')
: (source.billingUnitPrice ?? source.unitPrice ?? rule.unitPrice),
minimumBillingWeight: requestedFeeName
? (rule.minimumBillingWeight ?? rule.lowerLimit ?? '')
: (source.minimumBillingWeight ?? rule.minimumBillingWeight ?? rule.lowerLimit),
remark: requestedFeeName
? rule.remark || ''
: source.billingRuleRemark || source.ruleRemark || rule.remark,
rules: matchedRules,
matchCondition: {
...matchCondition,
@@ -2404,11 +2489,16 @@ export default {
row.settlementAmountTax = Number(
(hasFreightItem ? feeItemTotal : Number(row.freightAmount || 0) + feeItemTotal).toFixed(2)
);
if (changedField && this.adjustDialog.sourceDetailId && row.id) {
this.scheduleSourceAdjustCalculation(row);
if (
['transportQuantity', 'mileage', 'unitPrice'].includes(changedField) &&
this.adjustDialog.sourceDetailId &&
row.id &&
(changedField !== 'unitPrice' || isUnitPriceAdjustable(row))
) {
this.scheduleSourceAdjustCalculation(row, changedField);
}
},
scheduleSourceAdjustCalculation(row) {
scheduleSourceAdjustCalculation(row, changedField) {
if (row.adjustCalculateTimer) clearTimeout(row.adjustCalculateTimer);
row.adjustCalculateVersion += 1;
row.calculating = true;
@@ -2421,6 +2511,8 @@ export default {
feeId: row.id,
transportQuantity: Number(row.transportQuantity || 0),
mileage: Number(row.mileage || 0),
unitPrice: row.unitPrice === null || row.unitPrice === '' ? null : Number(row.unitPrice),
changedField,
freightAmount: Number(row.freightAmount || 0),
feeItems: Object.fromEntries(
Object.entries(row.feeItems || {}).map(([name, amount]) => [
@@ -2435,6 +2527,9 @@ export default {
row.feeItems = Object.fromEntries(
Object.entries(data.feeItems || {}).map(([name, amount]) => [name, Number(amount || 0)])
);
if (!isTierUnitPrice(row) && data.unitPrice !== undefined && data.unitPrice !== null) {
row.unitPrice = Number(data.unitPrice);
}
row.adjustAmount = Number(data.adjustAmount || 0);
row.settlementAmountTax = Number(data.afterAmount || 0);
} catch (error) {
@@ -2630,9 +2725,12 @@ export default {
运输总量: this.formatQuantity(row),
'里程(KM)': row.mileage,
批次号: row.batchNo,
计费方式: row.billingType,
运输单价: this.formatMoney(row.unitPrice, row.currency || this.form.currency),
运费: this.formatMoney(row.freightAmount, row.currency || this.form.currency),
};
if (this.showStandaloneFreightColumn) {
result['运费'] = this.formatMoney(row.freightAmount, row.currency || this.form.currency);
}
dynamicFeeItems.forEach(name => {
result[name] = this.formatMoney(feeItems[name], row.currency || this.form.currency);
});
@@ -208,12 +208,13 @@
:prop="column.prop"
:label="column.label"
:min-width="column.minWidth || 120"
show-overflow-tooltip
:show-overflow-tooltip="readonly || !column.editor || column.readonly"
>
<template #default="{ row }">
<template v-if="row._editing && column.editor && !column.readonly">
<template v-if="!readonly && column.editor && !column.readonly">
<el-date-picker
v-if="column.editor === 'date'"
:key="`${row._key}-${column.prop}`"
v-model="row[column.prop]"
type="date"
format="YYYY-MM-DD"
@@ -222,6 +223,7 @@
/>
<el-select
v-else-if="column.editor === 'select' && column.prop === 'transportType'"
:key="`${row._key}-${column.prop}`"
v-model="row[column.prop]"
clearable
filterable
@@ -244,8 +246,21 @@
选择
</el-link>
</div>
<el-cascader
v-else-if="column.editor === 'cargoType'"
:key="`${row._key}-${column.prop}`"
:model-value="resolveCargoTypePath(row)"
:options="cargoTypeSelectOptions(row)"
:props="cargoTypeCascaderProps"
clearable
filterable
placeholder="请选择货物类型"
style="width: 100%"
@change="value => handleCargoTypeChange(row, value)"
/>
<el-input
v-else-if="column.editor === 'number' || column.editor === 'optionalNumber'"
:key="`${row._key}-${column.prop}`"
:model-value="resolveCellValue(row, column)"
inputmode="decimal"
:placeholder="`请输入${column.label}`"
@@ -253,8 +268,10 @@
/>
<el-input
v-else
:key="`${row._key}-${column.prop}`"
v-model="row[column.prop]"
:placeholder="`请输入${column.label}`"
@update:model-value="value => syncEditedText(row, column, value)"
/>
</template>
<template v-else-if="column.editor === 'waybillSelect' && !readonly">
@@ -268,20 +285,13 @@
<span v-else>{{ formatCell(row, column) }}</span>
</template>
</el-table-column>
<el-table-column v-if="!readonly" label="操作" width="180" fixed="right">
<el-table-column v-if="!readonly" label="操作" width="120" fixed="right">
<template #default="{ row }">
<div class="settlement-import-create__row-actions">
<template v-if="row._editing">
<el-link type="primary" @click="saveRow(row)">保存</el-link>
<el-link type="primary" @click="cancelRow(row)">取消</el-link>
</template>
<template v-else>
<el-link v-if="row._duplicate" type="primary" @click="confirmDuplicateRow(row)">
确认
</el-link>
<el-link type="primary" @click="editRow(row)">编辑</el-link>
<el-link type="danger" @click="deleteRow(row)">删除</el-link>
</template>
<el-link v-if="row._duplicate" type="primary" @click="confirmDuplicateRow(row)">
确认
</el-link>
<el-link type="danger" @click="deleteRow(row)">删除</el-link>
</div>
</template>
</el-table-column>
@@ -345,6 +355,7 @@ import { ElMessage, ElMessageBox } from 'element-plus';
import { QuestionFilled } from '@element-plus/icons-vue';
import dayjs from 'dayjs';
import * as api from '@/api/settlement/settlementImport';
import { getList as getCargoTypeList } from '@/api/base/cargo-type';
import { getList as getContractList } from '@/api/business/contract-manage';
import { getDictionary } from '@/api/system/dictbiz';
import { formatContractOptionLabel } from '@/utils/contract';
@@ -363,6 +374,7 @@ const parseLoading = ref(false);
const rowSelection = ref([]);
const previewTab = ref('all');
const feeItemNames = ref([]);
const parsedHeaders = ref([]);
const projects = ref([]);
const customerContracts = ref([]);
const carrierContracts = ref([]);
@@ -370,6 +382,14 @@ const draftLoading = ref(false);
const confirmLoading = ref(false);
const readonly = ref(false);
const transportTypeOptions = ref([]);
const cargoTypeOptions = ref([]);
const cargoTypeFlatOptions = ref([]);
const cargoTypeCascaderProps = {
label: 'cargoName',
value: 'id',
children: 'children',
emitPath: true,
};
const historyWaybillVisible = ref(false);
const historyWaybillLoading = ref(false);
const historyWaybillRows = ref([]);
@@ -478,18 +498,18 @@ const loadContractsByProject = async (projectId, projectName = '') => {
.filter(Boolean);
};
const fixedDetailColumns = [
{ prop: 'loadingIdentifier', label: '配载标识号', editor: 'input', minWidth: 140 },
const columnsBeforeFee = [
{ prop: 'originalNo', label: '原始单号', editor: 'input', minWidth: 130 },
{ prop: 'loadingIdentifier', label: '配载标识号', editor: 'input', minWidth: 140 },
{
prop: 'vehicleNo',
label: '车牌号/航班号/船号/班列号',
editor: 'input',
minWidth: 190,
},
{ prop: 'transportType', label: '运输方式', editor: 'select', minWidth: 120 },
{ prop: 'driverName', label: '司机/船长姓名', editor: 'input', minWidth: 120 },
{ prop: 'driverPhone', label: '司机/船长手机号', editor: 'input', minWidth: 140 },
{ prop: 'transportType', label: '运输方式', editor: 'select', minWidth: 140 },
{ prop: 'driverName', label: '司机/船长姓名', editor: 'input', minWidth: 130 },
{ prop: 'driverPhone', label: '司机/船长手机号', editor: 'input', minWidth: 150 },
{ prop: 'departureAddress', label: '发货地址', editor: 'input', minWidth: 160 },
{ prop: 'departureContact', label: '发货联系人', editor: 'input', minWidth: 120 },
{ prop: 'departurePhone', label: '发货联系人电话', editor: 'input', minWidth: 140 },
@@ -497,30 +517,97 @@ const fixedDetailColumns = [
{ prop: 'arrivalContact', label: '收货联系人', editor: 'input', minWidth: 120 },
{ prop: 'arrivalPhone', label: '收货联系人电话', editor: 'input', minWidth: 140 },
{ prop: 'cargoName', label: '货物名称', editor: 'input', minWidth: 140 },
{ prop: 'cargoType', label: '货物类型', editor: 'input', minWidth: 120 },
{ prop: 'cargoType', label: '货物类型', editor: 'cargoType', minWidth: 200 },
{ prop: 'packageType', label: '包装', editor: 'input', minWidth: 100 },
{ prop: 'quantity', label: '数量', editor: 'number', minWidth: 100 },
{ prop: 'quantityUnit', label: '数量单位', editor: 'input', minWidth: 100 },
{ prop: 'quantityUnit', label: '数量单位', editor: 'input', minWidth: 110 },
{ prop: 'specification', label: '规格', editor: 'input', minWidth: 110 },
{ prop: 'model', label: '型号', editor: 'input', minWidth: 110 },
{ prop: 'mileage', label: '里程(km)', editor: 'optionalNumber', minWidth: 110 },
{ prop: 'unitPrice', label: '运输单价', editor: 'optionalNumber', minWidth: 100 },
{ prop: 'transportFee', label: '运输费', editor: 'number', minWidth: 100 },
{ prop: 'actualStartDate', label: '实际发货时间', editor: 'date', minWidth: 140 },
{ prop: 'actualEndDate', label: '实际完成时间', editor: 'date', minWidth: 140 },
{ prop: 'planStartDate', label: '预计发货时间', editor: 'date', minWidth: 140 },
{ prop: 'planEndDate', label: '预计完成时间', editor: 'date', minWidth: 140 },
{ prop: 'waybillIdentifier', label: '同一运单标识号', editor: 'input', minWidth: 140 },
{ prop: 'unitPrice', label: '运输单价', editor: 'optionalNumber', minWidth: 110 },
{ prop: 'transportFee', label: '运输费', editor: 'number', minWidth: 110 },
];
const columnsAfterFee = [
{ prop: 'actualStartDate', label: '实际发货时间', editor: 'date', minWidth: 150 },
{ prop: 'actualEndDate', label: '实际完成时间', editor: 'date', minWidth: 150 },
{ prop: 'planStartDate', label: '预计发货时间', editor: 'date', minWidth: 150 },
{ prop: 'planEndDate', label: '预计完成时间', editor: 'date', minWidth: 150 },
{ prop: 'remark', label: '备注', editor: 'input', minWidth: 160 },
{ prop: 'waybillIdentifier', label: '同一运单标识号', editor: 'input', minWidth: 150 },
];
const detailColumns = computed(() => {
const feeColumns = feeItemNames.value.map(name => ({
const headerColumnMap = {};
[...columnsBeforeFee, ...columnsAfterFee].forEach(column => {
headerColumnMap[column.label] = column;
headerColumnMap[`*${column.label}`] = column;
});
headerColumnMap['结算金额'] = {
prop: 'settlementAmount',
label: '结算金额',
editor: 'number',
minWidth: 120,
};
headerColumnMap['里程'] = headerColumnMap['里程(km)'];
headerColumnMap['单价'] = headerColumnMap['运输单价'];
headerColumnMap['运输类型'] = headerColumnMap['运输方式'];
headerColumnMap['*运输类型'] = headerColumnMap['运输方式'];
const VEHICLE_ALIAS_KEYS = [
'*车牌号/航班号/船号/班列号',
'车牌号/航班号/船号/班列号',
'*车牌号/船班号/船次号/班次号',
'车牌号/船班号/船次号/班次号',
'车号',
];
const readFilledText = value => {
const text = String(value ?? '').trim();
return text;
};
const applyVehicleNo = (row, value) => {
const vehicleNo = readFilledText(value);
row.vehicleNo = vehicleNo;
VEHICLE_ALIAS_KEYS.forEach(key => {
row[key] = vehicleNo;
});
};
const syncEditedText = (row, column, value) => {
if (column.prop !== 'vehicleNo') return;
applyVehicleNo(row, value);
};
prop: name,
label: name,
editor: 'number',
minWidth: 110,
isFeeItem: true,
});
const columnFromHeader = header => {
const name = String(header || '').trim();
if (!name || name === '序号') return null;
if (feeItemNames.value.includes(name)) return feeColumn(name);
const mapped = headerColumnMap[name];
if (mapped) return { ...mapped, label: mapped.label || name.replace(/^\*/, '') };
return {
prop: name,
label: name,
editor: 'number',
minWidth: 110,
isFeeItem: true,
}));
label: name.replace(/^\*/, ''),
editor: 'input',
minWidth: 140,
};
};
const dedupeColumns = columns => {
const seen = new Set();
return columns.filter(column => {
if (!column?.prop || seen.has(column.prop)) return false;
seen.add(column.prop);
return true;
});
};
const detailColumns = computed(() => {
const historyColumns =
form.waybillMode === 'history'
? [
@@ -532,19 +619,20 @@ const detailColumns = computed(() => {
},
]
: [];
return [
...historyColumns,
...fixedDetailColumns,
...feeColumns,
const fallback = [
...columnsBeforeFee,
...feeItemNames.value.map(name => feeColumn(name)),
{
prop: 'settlementAmount',
label: '结算金额',
editor: 'number',
readonly: true,
minWidth: 120,
},
{ prop: 'remark', label: '备注', editor: 'input', minWidth: 140 },
...columnsAfterFee,
];
const uploaded = parsedHeaders.value.map(columnFromHeader).filter(Boolean);
const columns = dedupeColumns(uploaded.length ? uploaded : fallback);
return [...historyColumns, ...columns];
});
const duplicateRows = computed(() => rows.value.filter(row => row._duplicate));
@@ -664,6 +752,11 @@ const updateNumberCell = (row, column, value) => {
if (column.prop === 'transportFee') row.freight = '';
if (column.prop === 'unitPrice') row.transportUnitPrice = '';
}
if (column.prop === 'settlementAmount') {
row.settlementAmount = '';
row.freightTotal = '';
return;
}
recalcSettlementAmount(row);
return;
}
@@ -682,6 +775,11 @@ const updateNumberCell = (row, column, value) => {
if (column.prop === 'unitPrice') {
row.transportUnitPrice = normalized;
}
if (column.prop === 'settlementAmount') {
row.freightTotal = normalized;
row['运费合计'] = normalized;
return;
}
}
recalcSettlementAmount(row);
};
@@ -748,11 +846,22 @@ const normalizeParsedRow = (item, index) => {
model: item.model ?? item['型号'] ?? '',
specification: item.specification ?? item['规格'] ?? '',
cargoType: item.cargoType ?? item['*货物类型'] ?? item['货物类型'] ?? '',
settlementAmount:
item.settlementAmount ?? item['结算金额'] ?? '',
feeItems,
};
const vehicleNo =
item.vehicleNo !== undefined && item.vehicleNo !== null
? readFilledText(item.vehicleNo)
: readFilledText(VEHICLE_ALIAS_KEYS.map(key => item[key]).find(value => readFilledText(value)));
applyVehicleNo(row, vehicleNo);
row.transportUnitPrice = row.unitPrice;
row.freight = row.transportFee;
recalcSettlementAmount(row);
if (row.settlementAmount === '' || row.settlementAmount === null || row.settlementAmount === undefined) {
recalcSettlementAmount(row);
} else {
row.freightTotal = row.settlementAmount;
}
return row;
};
@@ -863,6 +972,7 @@ const resetUploadState = () => {
form.file = null;
rows.value = [];
feeItemNames.value = [];
parsedHeaders.value = [];
rowSelection.value = [];
};
@@ -918,6 +1028,7 @@ const parseSelectedFile = async (raw, displayName = '') => {
const response = await api.parseFile(raw);
const data = unwrapData(response) || {};
feeItemNames.value = Array.isArray(data.feeItemNames) ? data.feeItemNames : [];
parsedHeaders.value = Array.isArray(data.headers) ? data.headers : [];
const parsedRows = Array.isArray(data.rows) ? data.rows : [];
rows.value = parsedRows.map((item, index) => normalizeParsedRow(item, index));
refreshDuplicateFlags();
@@ -938,22 +1049,115 @@ const parseSelectedFile = async (raw, displayName = '') => {
}
};
const editRow = row => {
row._editSnapshot = JSON.parse(JSON.stringify(row));
row._editing = true;
const formatCargoTypeLabel = item =>
item?.cargoName || item?.name || item?.typeName || item?.label || '';
const buildCargoTypeTree = cargoTypes => {
const flatCargoTypes = [];
const collectCargoTypes = list => {
(list || []).forEach(item => {
flatCargoTypes.push({ ...item, children: undefined });
if (item.children?.length) collectCargoTypes(item.children);
});
};
collectCargoTypes(cargoTypes);
const nodeMap = new Map();
const codeMap = new Map();
flatCargoTypes.forEach(item => {
const id = item.id ?? item.cargoCode ?? item.code;
if (id === undefined || id === null) return;
const node = {
...item,
id: String(id),
cargoName: formatCargoTypeLabel(item),
cargoCode: item.cargoCode || item.code || '',
parentId: item.parentId === undefined || item.parentId === null ? '' : String(item.parentId),
parentCargoCode: item.parentCargoCode || item.parentCode || '',
children: [],
};
nodeMap.set(node.id, node);
if (node.cargoCode) codeMap.set(String(node.cargoCode), node);
});
const rootNodes = [];
nodeMap.forEach(node => {
const parent =
nodeMap.get(node.parentId) ||
codeMap.get(String(node.parentCargoCode || '')) ||
codeMap.get(String(node.parentId || ''));
if (parent && parent !== node && Number(node.typeLevel) !== 1) parent.children.push(node);
else rootNodes.push(node);
});
const normalize = (cargoType, level = 1, parentPath = []) => {
if (level > 2) return null;
const id = String(cargoType.id);
const path = [...parentPath, id];
const children =
level === 1
? (cargoType.children || []).map(item => normalize(item, level + 1, path)).filter(Boolean)
: [];
return {
...cargoType,
id,
cargoName: formatCargoTypeLabel(cargoType),
path,
leaf: level === 2,
children: children.length ? children : undefined,
};
};
return rootNodes.map(item => normalize(item, 1)).filter(Boolean);
};
const saveRow = row => {
recalcSettlementAmount(row);
row._editing = false;
delete row._editSnapshot;
refreshDuplicateFlags();
const flattenCargoTypeOptions = options => {
const result = [];
const collect = list =>
(list || []).forEach(item => {
result.push(item);
if (item.children?.length) collect(item.children);
});
collect(options);
return result;
};
const cancelRow = row => {
if (row._editSnapshot) Object.assign(row, row._editSnapshot);
row._editing = false;
delete row._editSnapshot;
const resolveCargoTypePath = row => {
if (Array.isArray(row.cargoTypePath) && row.cargoTypePath.length) return row.cargoTypePath;
const current = String(row.cargoType || row.cargoTypeCode || '').trim();
if (!current) return [];
const matched = cargoTypeFlatOptions.value.find(item =>
[item.cargoName, item.name, item.typeName, item.cargoCode, item.code].some(
value => String(value || '').trim() === current
)
);
if (matched?.path?.length) return matched.path;
return [`custom:${current}`];
};
const cargoTypeSelectOptions = row => {
const path = resolveCargoTypePath(row);
const customId = path.find(value => String(value).startsWith('custom:'));
if (!customId) return cargoTypeOptions.value;
const name = String(customId).slice('custom:'.length);
return [{ id: customId, cargoName: name, leaf: true }, ...cargoTypeOptions.value];
};
const handleCargoTypeChange = (row, value) => {
const path = Array.isArray(value) ? value : [];
const leafId = path.length ? String(path[path.length - 1]) : '';
if (!leafId) {
row.cargoTypePath = [];
row.cargoType = '';
row.cargoTypeCode = '';
return;
}
if (leafId.startsWith('custom:')) {
row.cargoTypePath = path;
row.cargoType = leafId.slice('custom:'.length);
row.cargoTypeCode = '';
return;
}
const cargoType = cargoTypeFlatOptions.value.find(item => String(item.id) === leafId);
row.cargoTypePath = cargoType?.path || path;
row.cargoType = cargoType?.cargoName || '';
row.cargoTypeCode = cargoType?.cargoCode || cargoType?.code || '';
};
const deleteRow = row => {
@@ -1159,11 +1363,24 @@ const loadTransportTypeOptions = async () => {
}
};
const loadCargoTypeOptions = async () => {
try {
const response = await getCargoTypeList(1, 9999);
const body = unwrapData(response) || {};
const records = Array.isArray(body) ? body : body.records || body.data || [];
cargoTypeOptions.value = buildCargoTypeTree(records);
cargoTypeFlatOptions.value = flattenCargoTypeOptions(cargoTypeOptions.value);
} catch (error) {
cargoTypeOptions.value = [];
cargoTypeFlatOptions.value = [];
}
};
const loadOptions = async () => {
const response = await api.getOptions();
const options = unwrapData(response) || {};
projects.value = (options.projects || []).map(normalizeProject).filter(Boolean);
await loadTransportTypeOptions();
await Promise.all([loadTransportTypeOptions(), loadCargoTypeOptions()]);
};
const loadDetail = async id => {
@@ -1190,6 +1407,7 @@ const loadDetail = async id => {
await loadContractsByProject(detail.projectId, detail.projectName || '');
}
feeItemNames.value = Array.isArray(detail.feeItemNames) ? detail.feeItemNames : [];
parsedHeaders.value = [];
const detailRows = Array.isArray(detail.rows) ? detail.rows : [];
rows.value = detailRows.map((item, index) => normalizeParsedRow(item, index));
refreshDuplicateFlags();