调整结算模块
This commit is contained in:
@@ -116,7 +116,7 @@
|
||||
<el-option
|
||||
v-for="item in feeOptions"
|
||||
:key="item.feeType"
|
||||
:label="item.feeTypeName || item.feeType"
|
||||
:label="feeCategoryName(item.feeType)"
|
||||
:value="item.feeType"
|
||||
/>
|
||||
</el-select>
|
||||
@@ -773,17 +773,30 @@
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
@change="recalculateAdjustRow(row, 'transportQuantity')"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="里程(KM)" min-width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.mileage" :min="0" :precision="2" :controls="false" />
|
||||
<el-input-number
|
||||
v-model="row.mileage"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
@change="recalculateAdjustRow(row, 'mileage')"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="运输单价" min-width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.unitPrice" :min="0" :precision="2" :controls="false" />
|
||||
<el-input-number
|
||||
v-model="row.unitPrice"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
@change="recalculateAdjustRow(row, 'unitPrice')"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
@@ -915,6 +928,7 @@ import {
|
||||
getDetail as getPreSettlementDetail,
|
||||
getDetailFees as getPreSettlementDetailFees,
|
||||
} from '@/api/settlement/preSettlement';
|
||||
import { calculateAdjustedFee, getFeeDetail } from '@/api/settlement/receivable-payable-detail';
|
||||
import {
|
||||
createFormalSettlementForm,
|
||||
formalSettlementFormFields,
|
||||
@@ -995,6 +1009,7 @@ export default {
|
||||
allContracts: [],
|
||||
projects: [],
|
||||
feeOptions: [],
|
||||
feeCategoryOptions: [],
|
||||
transportTypeOptions: [],
|
||||
fields: formalSettlementFormFields,
|
||||
sourceTableColumns: sourceColumns,
|
||||
@@ -1029,10 +1044,14 @@ export default {
|
||||
loading: false,
|
||||
saving: false,
|
||||
detailId: null,
|
||||
sourceDetailId: null,
|
||||
feeKey: '',
|
||||
reason: '',
|
||||
rows: [],
|
||||
targetRow: null,
|
||||
},
|
||||
detailFeeSnapshots: {},
|
||||
pendingAdjustments: {},
|
||||
billingRuleDialog: {
|
||||
visible: false,
|
||||
rule: null,
|
||||
@@ -1148,6 +1167,9 @@ export default {
|
||||
this.sources = data.sources || [];
|
||||
this.details = data.details || [];
|
||||
this.summaryFees = data.summaryFees || [];
|
||||
// 服务端数据已刷新,费用快照与暂存调整全部作废。
|
||||
this.detailFeeSnapshots = {};
|
||||
this.pendingAdjustments = {};
|
||||
this.paymentApplications = data.paymentApplications || [];
|
||||
if (this.readonly && this.form.settlementType === 'receivable') {
|
||||
this.receiptClaims = this.unwrapData(await getReceiptClaims(id)) || [];
|
||||
@@ -1174,6 +1196,8 @@ export default {
|
||||
this.sources = [];
|
||||
this.details = [];
|
||||
this.summaryFees = [];
|
||||
this.detailFeeSnapshots = {};
|
||||
this.pendingAdjustments = {};
|
||||
this.paymentApplications = [];
|
||||
this.receiptClaims = [];
|
||||
this.adjustments = [];
|
||||
@@ -1195,6 +1219,7 @@ export default {
|
||||
await Promise.all([
|
||||
this.loadAllContracts(),
|
||||
this.loadFeeOptions(),
|
||||
this.loadFeeCategoryOptions(),
|
||||
this.loadAttachmentTypeOptions(),
|
||||
this.loadTransportTypeOptions(),
|
||||
]);
|
||||
@@ -1284,7 +1309,7 @@ export default {
|
||||
row.settlementAmountTax ?? row.totalAmount ?? row.afterAmount ?? row.settlementAmount,
|
||||
}));
|
||||
this.form.sourceDetailIds = this.details.map(item => item.sourceDetailId);
|
||||
this.buildSummaryFeesFromDetails();
|
||||
await this.refreshSummaryFees();
|
||||
},
|
||||
async applyInitialSources(rows) {
|
||||
const first = rows[0];
|
||||
@@ -1347,7 +1372,7 @@ export default {
|
||||
summaryMap.set(key, current);
|
||||
});
|
||||
this.summaryFees = [...summaryMap.values()];
|
||||
if (!this.summaryFees.length) this.buildSummaryFeesFromDetails();
|
||||
if (!this.summaryFees.length) await this.refreshSummaryFees();
|
||||
},
|
||||
async loadAllContracts() {
|
||||
const response = await getContractOptions('');
|
||||
@@ -1375,6 +1400,10 @@ export default {
|
||||
const response = await getFeeOptions();
|
||||
this.feeOptions = this.unwrapData(response) || [];
|
||||
},
|
||||
async loadFeeCategoryOptions() {
|
||||
const { data } = await getDictionary({ code: 'fee_category' });
|
||||
this.feeCategoryOptions = data?.data || [];
|
||||
},
|
||||
handleProjectChange(id) {
|
||||
const project = this.projects.find(item => String(item.id) === String(id));
|
||||
this.form.projectName = project?.name || '';
|
||||
@@ -1505,7 +1534,7 @@ export default {
|
||||
this.detailCandidate.page.current = 1;
|
||||
this.loadDetailCandidates();
|
||||
},
|
||||
confirmDetailCandidates() {
|
||||
async confirmDetailCandidates() {
|
||||
if (!this.detailCandidate.selected.length) {
|
||||
return this.$message.warning('请选择结算明细');
|
||||
}
|
||||
@@ -1526,7 +1555,12 @@ export default {
|
||||
...existing.values(),
|
||||
];
|
||||
this.form.sourceDetailIds = [...existing.values()].map(item => item.sourceDetailId);
|
||||
this.buildSummaryFeesFromDetails();
|
||||
this.detailCandidate.loading = true;
|
||||
try {
|
||||
await this.refreshSummaryFees();
|
||||
} finally {
|
||||
this.detailCandidate.loading = false;
|
||||
}
|
||||
this.detailCandidate.visible = false;
|
||||
},
|
||||
async removeDetail(row) {
|
||||
@@ -1537,7 +1571,7 @@ export default {
|
||||
this.form.sourceDetailIds = this.details
|
||||
.filter(item => !item.sourcePreSettlementId)
|
||||
.map(item => item.sourceDetailId);
|
||||
this.buildSummaryFeesFromDetails();
|
||||
await this.refreshSummaryFees();
|
||||
},
|
||||
addSummaryFee() {
|
||||
this.summaryFees.push({
|
||||
@@ -1559,7 +1593,13 @@ export default {
|
||||
manualFeeItems(feeType) {
|
||||
return this.feeOptions.find(item => item.feeType === feeType)?.feeItems || [];
|
||||
},
|
||||
// fee_category 业务字典:dictKey 为字典键值(落库值),dictValue 为字典名称(展示值)。
|
||||
feeCategoryName(value) {
|
||||
if (value === undefined || value === null || value === '') return '';
|
||||
const option = this.feeCategoryOptions.find(
|
||||
item => String(item.dictKey) === String(value) || String(item.dictValue) === String(value)
|
||||
);
|
||||
if (option) return option.dictValue ?? value;
|
||||
return (
|
||||
this.feeOptions.find(item => String(item.feeType) === String(value))?.feeTypeName || value
|
||||
);
|
||||
@@ -1589,16 +1629,25 @@ export default {
|
||||
return totalText;
|
||||
});
|
||||
},
|
||||
async refreshSummaryFees() {
|
||||
await this.ensureDetailFeeSnapshots();
|
||||
this.buildSummaryFeesFromDetails();
|
||||
},
|
||||
// 结算合计按费用项归集:原金额取明细进入结算单时的费用金额,结算金额取调整后的金额,调整金额为两者之差。
|
||||
buildSummaryFeesFromDetails() {
|
||||
const generatedMap = new Map();
|
||||
const existingGenerated = new Map(
|
||||
this.summaryFees
|
||||
.filter(row => row.manualFlag !== 1)
|
||||
.filter(row => Number(row.manualFlag) !== 1)
|
||||
.map(row => [`${row.feeType}\u0000${row.feeItem}`, row])
|
||||
);
|
||||
const append = (feeItem, value, fallbackFeeType = '') => {
|
||||
const amount = Number(value || 0);
|
||||
if (!feeItem || !Number.isFinite(amount) || Math.abs(amount) < 0.005) return;
|
||||
const append = (feeItem, originalValue, settlementValue, fallbackFeeType = '') => {
|
||||
const originalAmount = Number(originalValue || 0);
|
||||
const settlementAmount = Number(settlementValue || 0);
|
||||
if (!feeItem || !Number.isFinite(originalAmount) || !Number.isFinite(settlementAmount)) {
|
||||
return;
|
||||
}
|
||||
if (Math.abs(originalAmount) < 0.005 && Math.abs(settlementAmount) < 0.005) return;
|
||||
const option = this.feeOptions.find(item =>
|
||||
(item.feeItems || []).some(name => String(name) === String(feeItem))
|
||||
);
|
||||
@@ -1613,35 +1662,212 @@ export default {
|
||||
feeType,
|
||||
feeItem,
|
||||
originalAmount: 0,
|
||||
adjustAmount: Number(old?.adjustAmount || 0),
|
||||
adjustAmount: 0,
|
||||
settlementAmount: 0,
|
||||
remark: old?.remark || '',
|
||||
manualFlag: 0,
|
||||
};
|
||||
current.originalAmount = Number((current.originalAmount + amount).toFixed(2));
|
||||
current.settlementAmount = Number(
|
||||
(current.originalAmount + Number(current.adjustAmount || 0)).toFixed(2)
|
||||
current.originalAmount = Number((current.originalAmount + originalAmount).toFixed(2));
|
||||
current.settlementAmount = Number((current.settlementAmount + settlementAmount).toFixed(2));
|
||||
current.adjustAmount = Number(
|
||||
(current.settlementAmount - current.originalAmount).toFixed(2)
|
||||
);
|
||||
generatedMap.set(key, current);
|
||||
};
|
||||
this.details.forEach(row => {
|
||||
const feeItems = this.parseFeeItems(row.feeItemsJson || row.feeItems);
|
||||
const entries = Object.entries(feeItems);
|
||||
entries.forEach(([feeItem, amount]) => append(feeItem, amount, row.feeType));
|
||||
let knownAmount = entries.reduce((total, [, amount]) => total + Number(amount || 0), 0);
|
||||
if (!entries.some(([feeItem]) => this.isFreightFeeItem(feeItem))) {
|
||||
append('运输费', row.freightAmount, '物流配送');
|
||||
knownAmount += Number(row.freightAmount || 0);
|
||||
}
|
||||
const totalAmount = Number(
|
||||
row.settlementAmountTax ?? row.totalAmount ?? row.afterAmount ?? row.settlementAmount ?? 0
|
||||
this.details.forEach(detail => {
|
||||
this.resolveDetailFeePairs(detail).forEach(([baseRow, currentRow]) =>
|
||||
this.appendFeeRowSummary(baseRow, currentRow, detail, append)
|
||||
);
|
||||
const residualAmount = Number((totalAmount - knownAmount).toFixed(2));
|
||||
if (Math.abs(residualAmount) >= 0.005) append('其他费用', residualAmount, '其他费用');
|
||||
});
|
||||
const manualRows = this.summaryFees.filter(row => row.manualFlag === 1);
|
||||
const manualRows = this.summaryFees.filter(row => Number(row.manualFlag) === 1);
|
||||
this.summaryFees = [...generatedMap.values(), ...manualRows];
|
||||
},
|
||||
// 每条明细对应的费用行:[调整前基准行, 调整后当前行],缺少费用快照时退化为明细自身的汇总行。
|
||||
resolveDetailFeePairs(detail) {
|
||||
const feeKey = this.detailFeeKey(detail);
|
||||
const baseRows = this.detailFeeSnapshots[feeKey] || [];
|
||||
if (!baseRows.length) return [this.buildDetailFeePair(detail)];
|
||||
const pendingRows = this.pendingAdjustments[feeKey]?.rows || [];
|
||||
return baseRows.map((baseRow, index) => {
|
||||
const matched =
|
||||
pendingRows.find(row => baseRow.id && String(row.id) === String(baseRow.id)) ||
|
||||
pendingRows[index];
|
||||
return [baseRow, matched || baseRow];
|
||||
});
|
||||
},
|
||||
buildDetailFeePair(detail) {
|
||||
const baseRow = {
|
||||
feeItems: this.parseFeeItems(detail.feeItemsJson || detail.feeItems),
|
||||
feeType: detail.feeType,
|
||||
freightAmount: Number(detail.freightAmount || 0),
|
||||
settlementAmountTax: Number(
|
||||
detail.originalAmount ?? this.detailSettlementAmount(detail)
|
||||
),
|
||||
};
|
||||
return [baseRow, { ...baseRow, settlementAmountTax: this.detailSettlementAmount(detail) }];
|
||||
},
|
||||
detailSettlementAmount(detail) {
|
||||
return Number(
|
||||
detail?.settlementAmountTax ??
|
||||
detail?.totalAmount ??
|
||||
detail?.afterAmount ??
|
||||
detail?.settlementAmount ??
|
||||
0
|
||||
);
|
||||
},
|
||||
appendFeeRowSummary(baseRow, currentRow, detail, append) {
|
||||
const fallbackFeeType = currentRow.feeType || detail.feeType || '';
|
||||
const feeItemNames = Array.from(
|
||||
new Set([...Object.keys(baseRow.feeItems || {}), ...Object.keys(currentRow.feeItems || {})])
|
||||
);
|
||||
feeItemNames.forEach(name =>
|
||||
append(name, baseRow.feeItems?.[name], currentRow.feeItems?.[name], fallbackFeeType)
|
||||
);
|
||||
const hasFreightItem = feeItemNames.some(name => this.isFreightFeeItem(name));
|
||||
if (!hasFreightItem) {
|
||||
append('运输费', baseRow.freightAmount, currentRow.freightAmount, '物流配送');
|
||||
}
|
||||
const knownAmount = row =>
|
||||
this.sumFeeItems(row.feeItems) + (hasFreightItem ? 0 : Number(row.freightAmount || 0));
|
||||
const baseResidual = Number(
|
||||
(Number(baseRow.settlementAmountTax || 0) - knownAmount(baseRow)).toFixed(2)
|
||||
);
|
||||
const currentResidual = Number(
|
||||
(Number(currentRow.settlementAmountTax || 0) - knownAmount(currentRow)).toFixed(2)
|
||||
);
|
||||
if (Math.abs(baseResidual) < 0.005 && Math.abs(currentResidual) < 0.005) return;
|
||||
// 结算金额被手工改写且无法拆分到具体费用项时,计入金额占比最大的费用项,否则归入其他费用。
|
||||
append(
|
||||
this.residualFeeItem(baseRow, currentRow),
|
||||
baseResidual,
|
||||
currentResidual,
|
||||
fallbackFeeType
|
||||
);
|
||||
},
|
||||
residualFeeItem(baseRow, currentRow) {
|
||||
const feeItems = { ...(baseRow.feeItems || {}), ...(currentRow.feeItems || {}) };
|
||||
const dominant = Object.entries(feeItems)
|
||||
.filter(([, amount]) => Math.abs(Number(amount || 0)) >= 0.005)
|
||||
.sort((a, b) => Math.abs(Number(b[1] || 0)) - Math.abs(Number(a[1] || 0)))[0];
|
||||
if (dominant) return dominant[0];
|
||||
return (
|
||||
currentRow.billingRule?.feeItem ||
|
||||
currentRow.feeItem ||
|
||||
baseRow.billingRule?.feeItem ||
|
||||
baseRow.feeItem ||
|
||||
'其他费用'
|
||||
);
|
||||
},
|
||||
sumFeeItems(feeItems) {
|
||||
return Number(
|
||||
Object.values(feeItems || {})
|
||||
.reduce((total, value) => total + Number(value || 0), 0)
|
||||
.toFixed(2)
|
||||
);
|
||||
},
|
||||
detailFeeKey(detail) {
|
||||
if (detail.formalSettlementId && detail.id) return `formal:${detail.id}`;
|
||||
if (detail.sourcePreSettlementId && detail.id) return `pre:${detail.id}`;
|
||||
const sourceDetailId = detail.sourceDetailId || detail.id;
|
||||
return sourceDetailId ? `src:${sourceDetailId}` : '';
|
||||
},
|
||||
async loadDetailFeeRows(detail) {
|
||||
if (detail.formalSettlementId && detail.id) {
|
||||
const rows = this.unwrapData(await getDetailFees(detail.id)) || [];
|
||||
return rows.map(item => this.normalizeAdjustRow(item));
|
||||
}
|
||||
if (detail.sourcePreSettlementId && detail.id) {
|
||||
const rows = this.unwrapData(await getPreSettlementDetailFees(detail.id)) || [];
|
||||
return rows.map(item => this.normalizeAdjustRow(item));
|
||||
}
|
||||
const sourceDetailId = detail.sourceDetailId || detail.id;
|
||||
if (!sourceDetailId) return [];
|
||||
const data = this.unwrapData(await getFeeDetail(sourceDetailId)) || {};
|
||||
return (data.records || []).map(item =>
|
||||
this.normalizeAdjustRow(item, data.feeItemNames || [])
|
||||
);
|
||||
},
|
||||
// 明细列表只有汇总金额,费用项级别的原始金额需要按明细单独拉取并缓存为调整基准。
|
||||
async ensureDetailFeeSnapshots() {
|
||||
const targets = this.details.filter(detail => {
|
||||
const feeKey = this.detailFeeKey(detail);
|
||||
return feeKey && !this.detailFeeSnapshots[feeKey];
|
||||
});
|
||||
if (!targets.length) return;
|
||||
await Promise.all(
|
||||
targets.map(async detail => {
|
||||
const feeKey = this.detailFeeKey(detail);
|
||||
try {
|
||||
const feeRows = await this.loadDetailFeeRows(detail);
|
||||
if (!feeRows.length) return;
|
||||
this.detailFeeSnapshots[feeKey] = feeRows;
|
||||
this.applySnapshotToDetail(detail, feeRows);
|
||||
} catch (error) {
|
||||
// 取不到费用明细时退回按明细汇总金额归集,后续操作会再次尝试拉取。
|
||||
delete this.detailFeeSnapshots[feeKey];
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
applySnapshotToDetail(detail, feeRows) {
|
||||
if (!feeRows.length || detail.pendingDetailAdjustment) return;
|
||||
const feeItems = {};
|
||||
feeRows.forEach(row =>
|
||||
Object.entries(row.feeItems || {}).forEach(([name, amount]) => {
|
||||
feeItems[name] = Number((Number(feeItems[name] || 0) + Number(amount || 0)).toFixed(2));
|
||||
})
|
||||
);
|
||||
const settlementAmount = feeRows.reduce(
|
||||
(total, row) => total + Number(row.settlementAmountTax || 0),
|
||||
0
|
||||
);
|
||||
const freightAmount = feeRows.reduce(
|
||||
(total, row) => total + Number(row.freightAmount || 0),
|
||||
0
|
||||
);
|
||||
Object.assign(detail, {
|
||||
feeItemsJson: JSON.stringify(feeItems),
|
||||
freightAmount: Number(freightAmount.toFixed(2)),
|
||||
originalAmount: Number(settlementAmount.toFixed(2)),
|
||||
adjustAmount: 0,
|
||||
settlementAmountTax: Number(settlementAmount.toFixed(2)),
|
||||
});
|
||||
},
|
||||
normalizeAdjustRow(item, feeItemNames = []) {
|
||||
const rawFeeItems =
|
||||
item.feeItems && typeof item.feeItems === 'object'
|
||||
? item.feeItems
|
||||
: this.parseFeeItems(item.feeItemsJson);
|
||||
const names = feeItemNames.length ? feeItemNames : Object.keys(rawFeeItems);
|
||||
return {
|
||||
...item,
|
||||
transportQuantity: Number(item.transportQuantity || 0),
|
||||
mileage:
|
||||
item.mileage === null || item.mileage === undefined || Number(item.mileage) === -1
|
||||
? null
|
||||
: Number(item.mileage),
|
||||
freightAmount: Number(item.freightAmount || 0),
|
||||
feeItems: Object.fromEntries(names.map(name => [name, Number(rawFeeItems[name] || 0)])),
|
||||
settlementAmountTax: Number(
|
||||
item.settlementAmountTax ?? item.totalAmount ?? item.afterAmount ?? item.settlementAmount ?? 0
|
||||
),
|
||||
billingRule: this.normalizeBillingRule(item),
|
||||
calculating: false,
|
||||
calculateError: '',
|
||||
adjustCalculateTimer: null,
|
||||
adjustCalculateVersion: 0,
|
||||
};
|
||||
},
|
||||
cloneAdjustRows(rows) {
|
||||
return (rows || []).map(row => ({
|
||||
...row,
|
||||
feeItems: { ...row.feeItems },
|
||||
calculating: false,
|
||||
calculateError: '',
|
||||
adjustCalculateTimer: null,
|
||||
adjustCalculateVersion: 0,
|
||||
}));
|
||||
},
|
||||
isFreightFeeItem(name) {
|
||||
return name && (name.includes('运费') || name.includes('运输费'));
|
||||
},
|
||||
@@ -1768,76 +1994,100 @@ 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);
|
||||
}
|
||||
},
|
||||
scheduleSourceAdjustCalculation(row) {
|
||||
if (row.adjustCalculateTimer) clearTimeout(row.adjustCalculateTimer);
|
||||
row.adjustCalculateVersion = Number(row.adjustCalculateVersion || 0) + 1;
|
||||
row.calculating = true;
|
||||
const version = row.adjustCalculateVersion;
|
||||
row.adjustCalculateTimer = setTimeout(async () => {
|
||||
row.adjustCalculateTimer = null;
|
||||
try {
|
||||
const response = await calculateAdjustedFee({
|
||||
detailId: this.adjust.sourceDetailId,
|
||||
feeId: row.sourceFeeId || row.id,
|
||||
transportQuantity: Number(row.transportQuantity || 0),
|
||||
mileage: Number(row.mileage || 0),
|
||||
freightAmount: Number(row.freightAmount || 0),
|
||||
feeItems: Object.fromEntries(
|
||||
Object.entries(row.feeItems || {}).map(([name, amount]) => [name, Number(amount || 0)])
|
||||
),
|
||||
});
|
||||
if (version !== row.adjustCalculateVersion || !this.adjust.visible) return;
|
||||
const data = response.data?.data || response.data || {};
|
||||
row.freightAmount = Number(data.freightAmount || 0);
|
||||
row.feeItems = Object.fromEntries(
|
||||
Object.entries(data.feeItems || {}).map(([name, amount]) => [name, Number(amount || 0)])
|
||||
);
|
||||
row.adjustAmount = Number(data.adjustAmount || 0);
|
||||
row.settlementAmountTax = Number(data.afterAmount || 0);
|
||||
} catch (error) {
|
||||
row.calculateError = error.message || '费用试算失败';
|
||||
} finally {
|
||||
if (version === row.adjustCalculateVersion) row.calculating = false;
|
||||
}
|
||||
}, 300);
|
||||
},
|
||||
async openAdjustDialog(row) {
|
||||
if (!row.formalSettlementId) {
|
||||
this.adjust = {
|
||||
visible: true,
|
||||
loading: true,
|
||||
saving: false,
|
||||
detailId: null,
|
||||
reason: '',
|
||||
rows: [],
|
||||
targetRow: row,
|
||||
};
|
||||
try {
|
||||
let feeRows = [];
|
||||
if (row.sourcePreSettlementId && row.id) {
|
||||
const response = await getPreSettlementDetailFees(row.id);
|
||||
feeRows = this.unwrapData(response) || [];
|
||||
}
|
||||
this.adjust.rows = (feeRows.length ? feeRows : [row]).map(item => ({
|
||||
...item,
|
||||
feeItems: this.parseFeeItems(item.feeItemsJson || item.feeItems),
|
||||
billingRule: this.normalizeBillingRule(item),
|
||||
settlementAmountTax:
|
||||
item.settlementAmountTax ??
|
||||
item.totalAmount ??
|
||||
item.afterAmount ??
|
||||
item.settlementAmount ??
|
||||
0,
|
||||
}));
|
||||
} finally {
|
||||
this.adjust.loading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const detailRow = row;
|
||||
const feeKey = this.detailFeeKey(row);
|
||||
const pending = feeKey ? this.pendingAdjustments[feeKey] : null;
|
||||
this.adjust = {
|
||||
visible: true,
|
||||
loading: true,
|
||||
saving: false,
|
||||
detailId: detailRow.id,
|
||||
reason: '',
|
||||
detailId: row.formalSettlementId ? row.id : null,
|
||||
sourceDetailId: row.sourceDetailId || null,
|
||||
feeKey,
|
||||
reason: pending?.reason || '',
|
||||
rows: [],
|
||||
targetRow: null,
|
||||
targetRow: row.formalSettlementId ? null : row,
|
||||
};
|
||||
try {
|
||||
const response = await getDetailFees(detailRow.id);
|
||||
const data = this.unwrapData(response);
|
||||
this.adjust.rows = (data || []).map(item => ({
|
||||
...item,
|
||||
feeItems: this.parseFeeItems(item.feeItemsJson),
|
||||
billingRule: this.normalizeBillingRule(item),
|
||||
}));
|
||||
// 已暂存的调整结果优先回显,避免重新打开时又回到调整前的数据。
|
||||
if (pending?.rows?.length) {
|
||||
this.adjust.rows = this.cloneAdjustRows(pending.rows);
|
||||
return;
|
||||
}
|
||||
if (feeKey && !this.detailFeeSnapshots[feeKey]) {
|
||||
const feeRows = await this.loadDetailFeeRows(row);
|
||||
if (feeRows.length) this.detailFeeSnapshots[feeKey] = feeRows;
|
||||
}
|
||||
const snapshot = feeKey ? this.detailFeeSnapshots[feeKey] : null;
|
||||
this.adjust.rows = snapshot?.length
|
||||
? this.cloneAdjustRows(snapshot)
|
||||
: [this.normalizeAdjustRow(row)];
|
||||
} finally {
|
||||
this.adjust.loading = false;
|
||||
}
|
||||
},
|
||||
async saveAdjustment() {
|
||||
if (!this.adjust.reason.trim()) return this.$message.warning('请输入调整原因');
|
||||
if (this.adjust.rows.some(row => row.calculating)) {
|
||||
return this.$message.warning('费用正在重新计算,请稍候');
|
||||
}
|
||||
if (!this.adjust.detailId) {
|
||||
const target = this.adjust.targetRow;
|
||||
const edited = this.adjust.rows[0];
|
||||
if (!target || !edited) return;
|
||||
const originalAmount = Number(
|
||||
target.originalAmount ??
|
||||
target.totalAmount ??
|
||||
target.afterAmount ??
|
||||
target.settlementAmountTax ??
|
||||
target.settlementAmount ??
|
||||
0
|
||||
);
|
||||
// 原金额以明细进入结算单时的费用快照为基准,调整金额 = 结算金额 - 原金额。
|
||||
const baseRows = this.detailFeeSnapshots[this.adjust.feeKey] || [];
|
||||
const originalAmount = baseRows.length
|
||||
? Number(
|
||||
baseRows
|
||||
.reduce((total, row) => total + Number(row.settlementAmountTax || 0), 0)
|
||||
.toFixed(2)
|
||||
)
|
||||
: Number(
|
||||
target.originalAmount ??
|
||||
target.totalAmount ??
|
||||
target.afterAmount ??
|
||||
target.settlementAmountTax ??
|
||||
target.settlementAmount ??
|
||||
0
|
||||
);
|
||||
const settlementAmount = Number(
|
||||
this.adjust.rows
|
||||
.reduce((total, item) => total + Number(item.settlementAmountTax || 0), 0)
|
||||
@@ -1870,9 +2120,15 @@ export default {
|
||||
remark: edited.remark,
|
||||
pendingDetailAdjustment: true,
|
||||
});
|
||||
this.buildSummaryFeesFromDetails();
|
||||
if (this.adjust.feeKey) {
|
||||
this.pendingAdjustments[this.adjust.feeKey] = {
|
||||
reason: this.adjust.reason,
|
||||
rows: this.cloneAdjustRows(this.adjust.rows),
|
||||
};
|
||||
}
|
||||
await this.refreshSummaryFees();
|
||||
this.adjust.visible = false;
|
||||
this.$message.success('调整已暂存,请保存正式结算单提交');
|
||||
this.$message.success('调整已暂存,保存正式结算单后生效');
|
||||
return;
|
||||
}
|
||||
this.adjust.saving = true;
|
||||
|
||||
@@ -102,9 +102,9 @@
|
||||
>
|
||||
<el-option
|
||||
v-for="item in feeCategoryOptions"
|
||||
:key="item.id || item.dictValue"
|
||||
:label="item.dictKey || item.dictValue"
|
||||
:value="item.dictValue"
|
||||
:key="item.id || item.dictKey"
|
||||
:label="item.dictValue || item.dictKey"
|
||||
:value="item.dictKey"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
@@ -701,6 +701,7 @@
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
@change="recalculateAdjustRow(row, 'transportQuantity')"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -712,6 +713,7 @@
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
:disabled="adjustDialog.readonly"
|
||||
@change="recalculateAdjustRow(row, 'mileage')"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -723,6 +725,7 @@
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
:disabled="adjustDialog.readonly"
|
||||
@change="recalculateAdjustRow(row, 'unitPrice')"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -921,6 +924,7 @@ import {
|
||||
save,
|
||||
submit,
|
||||
} from '@/api/settlement/preSettlement';
|
||||
import { calculateAdjustedFee, getFeeDetail } from '@/api/settlement/receivable-payable-detail';
|
||||
import { getDictionary } from '@/api/system/dictbiz';
|
||||
import {
|
||||
emptyPreSettlementForm,
|
||||
@@ -968,10 +972,6 @@ export default {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
deferSave: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue', 'success'],
|
||||
data() {
|
||||
@@ -1071,10 +1071,13 @@ export default {
|
||||
readonly: false,
|
||||
activeTab: 'adjust',
|
||||
detailId: '',
|
||||
sourceDetailId: '',
|
||||
detailLineNo: '',
|
||||
reason: '',
|
||||
},
|
||||
adjustRows: [],
|
||||
pendingAdjustments: {},
|
||||
sourceFeeSnapshots: {},
|
||||
adjustChangeRecordVisible: false,
|
||||
adjustChangeRecord: null,
|
||||
adjustChangeRecordDetailRows: [],
|
||||
@@ -1149,24 +1152,11 @@ export default {
|
||||
return this.form.settlementType === 'receivable' ? '应收' : '应付';
|
||||
},
|
||||
summaryTotal() {
|
||||
// 与结算合计表格的合计行保持一致:包含手工添加的费用与明细调整后的金额。
|
||||
if (this.summaryFees.length) {
|
||||
return this.summaryFees.reduce(
|
||||
(total, row) => total + Number(row.settlementAmount || 0),
|
||||
0
|
||||
);
|
||||
return this.summaryFees.reduce((total, row) => total + Number(row.settlementAmount || 0), 0);
|
||||
}
|
||||
return this.details.reduce(
|
||||
(total, row) =>
|
||||
total +
|
||||
Number(
|
||||
row.settlementAmountTax ??
|
||||
row.totalAmount ??
|
||||
row.afterAmount ??
|
||||
row.settlementAmount ??
|
||||
0
|
||||
),
|
||||
0
|
||||
);
|
||||
return this.details.reduce((total, row) => total + this.detailSettlementAmount(row), 0);
|
||||
},
|
||||
visibleDetailColumns() {
|
||||
if (!this.readonly && !this.pageMode) return this.detailColumns;
|
||||
@@ -1226,9 +1216,9 @@ export default {
|
||||
await this.loadFeeCategoryOptions();
|
||||
await this.loadTransportTypeOptions();
|
||||
if (this.recordId) await this.loadDetail();
|
||||
else if (this.initialData) this.applyInitialData();
|
||||
else if (this.initialData) await this.applyInitialData();
|
||||
},
|
||||
applyInitialData() {
|
||||
async applyInitialData() {
|
||||
const rows = Array.isArray(this.initialData?.rows) ? this.initialData.rows : [];
|
||||
if (!rows.length) return;
|
||||
const first = rows[0];
|
||||
@@ -1264,25 +1254,55 @@ export default {
|
||||
localCurrency: first.localCurrency || 'RMB',
|
||||
});
|
||||
this.details = rows.map(row => this.normalizeSourceDetail(row));
|
||||
this.loading = true;
|
||||
try {
|
||||
await this.ensureSourceFeeSnapshots();
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
this.buildSummaryFeesFromDetails();
|
||||
},
|
||||
normalizeSourceDetail(row) {
|
||||
const originalAmount =
|
||||
row.originalAmount ?? row.totalAmount ?? row.afterAmount ?? row.settlementAmount ?? 0;
|
||||
const settlementAmount = this.detailSettlementAmount(row);
|
||||
const originalValue =
|
||||
row.originalAmount ?? row.originalSettlementAmount ?? row.totalAmount ?? settlementAmount;
|
||||
const originalAmount = Number(originalValue || 0);
|
||||
const adjustValue = row.adjustAmount ?? row.adjustmentAmount;
|
||||
const adjustAmount =
|
||||
adjustValue === undefined || adjustValue === null
|
||||
? settlementAmount - originalAmount
|
||||
: Number(adjustValue || 0);
|
||||
return {
|
||||
...row,
|
||||
sourceDetailId: row.sourceDetailId || row.id,
|
||||
originalAmount,
|
||||
adjustAmount: row.adjustAmount ?? 0,
|
||||
settlementAmountTax:
|
||||
row.settlementAmountTax ??
|
||||
row.totalAmount ??
|
||||
row.afterAmount ??
|
||||
row.settlementAmount ??
|
||||
originalAmount,
|
||||
originalAmount: Number(originalAmount.toFixed(2)),
|
||||
adjustAmount: Number(adjustAmount.toFixed(2)),
|
||||
settlementAmountTax: Number(settlementAmount.toFixed(2)),
|
||||
feeItemsJson: row.feeItemsJson || JSON.stringify(row.feeItems || {}),
|
||||
};
|
||||
},
|
||||
detailOriginalAmount(row) {
|
||||
return Number(row?.originalAmount ?? 0);
|
||||
},
|
||||
detailAdjustAmount(row) {
|
||||
const value = row?.adjustAmount ?? row?.adjustmentAmount;
|
||||
return Number(
|
||||
Number(
|
||||
value === undefined || value === null
|
||||
? this.detailSettlementAmount(row) - this.detailOriginalAmount(row)
|
||||
: value
|
||||
).toFixed(2)
|
||||
);
|
||||
},
|
||||
detailSettlementAmount(row) {
|
||||
return Number(
|
||||
row?.settlementAmountTax ??
|
||||
row?.totalAmount ??
|
||||
row?.afterAmount ??
|
||||
row?.settlementAmount ??
|
||||
0
|
||||
);
|
||||
},
|
||||
resetEditor() {
|
||||
this.form = emptyPreSettlementForm();
|
||||
this.summaryFees = [];
|
||||
@@ -1300,8 +1320,11 @@ export default {
|
||||
this.attachments = [];
|
||||
this.selectedAttachmentRows = [];
|
||||
this.adjustRows = [];
|
||||
this.pendingAdjustments = {};
|
||||
this.sourceFeeSnapshots = {};
|
||||
this.adjustDialog.activeTab = 'adjust';
|
||||
this.adjustDialog.detailId = '';
|
||||
this.adjustDialog.sourceDetailId = '';
|
||||
this.adjustDialog.detailLineNo = '';
|
||||
this.adjustChangeRecordVisible = false;
|
||||
this.adjustChangeRecord = null;
|
||||
@@ -1323,7 +1346,7 @@ export default {
|
||||
const detail = data?.data || {};
|
||||
this.form = { ...emptyPreSettlementForm(), ...detail };
|
||||
this.summaryFees = (detail.summaryFees || []).map(row => ({ ...row }));
|
||||
this.details = (detail.details || []).map(row => ({ ...row }));
|
||||
this.details = (detail.details || []).map(row => this.normalizeSourceDetail(row));
|
||||
this.advances = (detail.advances || []).map(row => ({
|
||||
...row,
|
||||
createUserName: row.createUserName || detail.createUserName,
|
||||
@@ -1499,6 +1522,8 @@ export default {
|
||||
try {
|
||||
const { data } = await save(this.buildSavePayload());
|
||||
this.form.id = data?.data || this.form.id;
|
||||
await this.loadDetail();
|
||||
await this.persistPendingAdjustments();
|
||||
if (shouldSubmit) {
|
||||
await submit({ id: this.form.id });
|
||||
this.$message.success('审批流程已发起');
|
||||
@@ -1507,7 +1532,6 @@ export default {
|
||||
return;
|
||||
}
|
||||
this.$message.success('保存成功');
|
||||
await this.loadDetail();
|
||||
this.$emit('success', this.form.id);
|
||||
} finally {
|
||||
this[stateKey] = false;
|
||||
@@ -1555,19 +1579,22 @@ export default {
|
||||
)?.feeItems || []
|
||||
);
|
||||
},
|
||||
feeCategoryValue(value) {
|
||||
if (value === undefined || value === null || value === '') return '';
|
||||
const option = this.feeCategoryOptions.find(
|
||||
findFeeCategory(value) {
|
||||
return this.feeCategoryOptions.find(
|
||||
item => String(item.dictKey) === String(value) || String(item.dictValue) === String(value)
|
||||
);
|
||||
return option?.dictValue || value;
|
||||
},
|
||||
// fee_category 业务字典:dictKey 为字典键值(落库值),dictValue 为字典名称(展示值)。
|
||||
feeCategoryValue(value) {
|
||||
if (value === undefined || value === null || value === '') return '';
|
||||
return this.findFeeCategory(value)?.dictKey ?? value;
|
||||
},
|
||||
feeCategoryName(value) {
|
||||
if (value === undefined || value === null || value === '') return '';
|
||||
const option = this.feeCategoryOptions.find(
|
||||
item => String(item.dictKey) === String(value) || String(item.dictValue) === String(value)
|
||||
);
|
||||
return option?.dictKey || value;
|
||||
const option = this.findFeeCategory(value);
|
||||
if (option) return option.dictValue ?? value;
|
||||
const feeOption = this.feeOptions.find(item => String(item.feeType) === String(value));
|
||||
return feeOption?.feeTypeName || value;
|
||||
},
|
||||
transportTypeName(value) {
|
||||
if (value === undefined || value === null || value === '') return '';
|
||||
@@ -1607,71 +1634,139 @@ export default {
|
||||
return totalText;
|
||||
});
|
||||
},
|
||||
// 结算合计按费用项归集:原金额取来源费用明细的初始金额,结算金额取调整后的金额,调整金额为两者之差。
|
||||
buildSummaryFeesFromDetails() {
|
||||
const summaryMap = new Map();
|
||||
const defaultFreightItem =
|
||||
this.feeOptions
|
||||
.flatMap(item => item.feeItems || [])
|
||||
.find(name => this.isFreightFeeItem(name)) || '运输费';
|
||||
const appendSummary = (feeItem, value, fallbackFeeType = '') => {
|
||||
const amount = Number(value || 0);
|
||||
if (!feeItem || !Number.isFinite(amount) || Math.abs(amount) < 0.005) return;
|
||||
const generatedFees = new Map(
|
||||
this.summaryFees
|
||||
.filter(row => Number(row.manualFlag) !== 1)
|
||||
.map(row => [`${row.feeType}\u0000${row.feeItem}`, row])
|
||||
);
|
||||
const appendSummary = (feeItem, originalValue, settlementValue, fallbackFeeType = '') => {
|
||||
const originalAmount = Number(originalValue || 0);
|
||||
const settlementAmount = Number(settlementValue || 0);
|
||||
if (!feeItem || !Number.isFinite(originalAmount) || !Number.isFinite(settlementAmount)) {
|
||||
return;
|
||||
}
|
||||
if (Math.abs(originalAmount) < 0.005 && Math.abs(settlementAmount) < 0.005) return;
|
||||
const feeOption = this.feeOptions.find(item =>
|
||||
(item.feeItems || []).some(name => String(name) === String(feeItem))
|
||||
);
|
||||
const feeType = this.feeCategoryValue(feeOption?.feeType || fallbackFeeType);
|
||||
const key = `${feeType}\u0000${feeItem}`;
|
||||
const generated = generatedFees.get(key);
|
||||
const current = summaryMap.get(key) || {
|
||||
id: '',
|
||||
id: generated?.id || '',
|
||||
feeType,
|
||||
feeItem,
|
||||
originalAmount: 0,
|
||||
adjustAmount: 0,
|
||||
settlementAmount: 0,
|
||||
remark: '',
|
||||
remark: generated?.remark || '',
|
||||
manualFlag: 0,
|
||||
};
|
||||
current.originalAmount = Number((current.originalAmount + amount).toFixed(2));
|
||||
current.settlementAmount = current.originalAmount;
|
||||
current.originalAmount = Number((current.originalAmount + originalAmount).toFixed(2));
|
||||
current.settlementAmount = Number((current.settlementAmount + settlementAmount).toFixed(2));
|
||||
current.adjustAmount = Number(
|
||||
(current.settlementAmount - current.originalAmount).toFixed(2)
|
||||
);
|
||||
summaryMap.set(key, current);
|
||||
};
|
||||
|
||||
this.details.forEach(row => {
|
||||
const feeItems = this.parseFeeItems(row.feeItemsJson || row.feeItems);
|
||||
const feeItemEntries = Object.entries(feeItems);
|
||||
feeItemEntries.forEach(([feeItem, amount]) => appendSummary(feeItem, amount, row.feeType));
|
||||
|
||||
let knownAmount = feeItemEntries.reduce(
|
||||
(total, [, amount]) => total + Number(amount || 0),
|
||||
0
|
||||
this.details.forEach(detail => {
|
||||
this.resolveDetailFeePairs(detail).forEach(([baseRow, currentRow]) =>
|
||||
this.appendFeeRowSummary(baseRow, currentRow, detail, appendSummary)
|
||||
);
|
||||
if (!feeItemEntries.some(([feeItem]) => this.isFreightFeeItem(feeItem))) {
|
||||
const freightAmount = Number(row.freightAmount || 0);
|
||||
appendSummary(defaultFreightItem, freightAmount, row.feeType);
|
||||
knownAmount += freightAmount;
|
||||
}
|
||||
if (!feeItemEntries.some(([feeItem]) => !this.isFreightFeeItem(feeItem))) {
|
||||
const otherFeeAmount = Number(row.otherFeeAmount || 0);
|
||||
appendSummary('其他费用', otherFeeAmount, row.feeType);
|
||||
knownAmount += otherFeeAmount;
|
||||
}
|
||||
|
||||
const totalAmount = Number(
|
||||
row.settlementAmountTax ?? row.totalAmount ?? row.afterAmount ?? row.settlementAmount ?? 0
|
||||
);
|
||||
const residualAmount = Number((totalAmount - knownAmount).toFixed(2));
|
||||
if (Math.abs(residualAmount) >= 0.005) {
|
||||
appendSummary('其他费用', residualAmount, row.feeType);
|
||||
}
|
||||
});
|
||||
|
||||
const manualFees = this.summaryFees.filter(row => row.manualFlag === 1);
|
||||
const manualFees = this.summaryFees.filter(row => Number(row.manualFlag) === 1);
|
||||
this.summaryFees = [...summaryMap.values(), ...manualFees];
|
||||
this.form.settlementAmount = this.summaryFees
|
||||
.reduce((total, row) => total + Number(row.settlementAmount || 0), 0)
|
||||
.toFixed(2);
|
||||
this.recalculateLocalAmount();
|
||||
},
|
||||
// 每条明细对应的费用行:[调整前基准行, 调整后当前行],缺少来源费用快照时退化为明细自身的汇总行。
|
||||
resolveDetailFeePairs(detail) {
|
||||
const sourceDetailId = String(detail.sourceDetailId || detail.id || '');
|
||||
const baseRows = this.sourceFeeSnapshots[sourceDetailId] || [];
|
||||
if (!baseRows.length) return [this.buildDetailFeePair(detail)];
|
||||
const pendingRows = this.pendingAdjustments[sourceDetailId]?.rows || [];
|
||||
return baseRows.map((baseRow, index) => {
|
||||
const matched =
|
||||
pendingRows.find(row => baseRow.id && String(row.id) === String(baseRow.id)) ||
|
||||
pendingRows[index];
|
||||
return [baseRow, matched || baseRow];
|
||||
});
|
||||
},
|
||||
buildDetailFeePair(detail) {
|
||||
const baseRow = {
|
||||
feeItems: this.parseFeeItems(detail.feeItemsJson || detail.feeItems),
|
||||
feeType: detail.feeType,
|
||||
freightAmount: Number(detail.freightAmount || 0),
|
||||
settlementAmountTax: this.detailOriginalAmount(detail),
|
||||
};
|
||||
return [baseRow, { ...baseRow, settlementAmountTax: this.detailSettlementAmount(detail) }];
|
||||
},
|
||||
appendFeeRowSummary(baseRow, currentRow, detail, appendSummary) {
|
||||
const fallbackFeeType = currentRow.feeType || detail.feeType || '';
|
||||
const feeItemNames = Array.from(
|
||||
new Set([...Object.keys(baseRow.feeItems || {}), ...Object.keys(currentRow.feeItems || {})])
|
||||
);
|
||||
feeItemNames.forEach(name =>
|
||||
appendSummary(name, baseRow.feeItems?.[name], currentRow.feeItems?.[name], fallbackFeeType)
|
||||
);
|
||||
const hasFreightItem = feeItemNames.some(name => this.isFreightFeeItem(name));
|
||||
if (!hasFreightItem) {
|
||||
appendSummary(
|
||||
this.defaultFreightFeeItem(),
|
||||
baseRow.freightAmount,
|
||||
currentRow.freightAmount,
|
||||
fallbackFeeType
|
||||
);
|
||||
}
|
||||
const knownAmount = row =>
|
||||
this.sumFeeItems(row.feeItems) + (hasFreightItem ? 0 : Number(row.freightAmount || 0));
|
||||
const baseResidual = Number(
|
||||
(Number(baseRow.settlementAmountTax || 0) - knownAmount(baseRow)).toFixed(2)
|
||||
);
|
||||
const currentResidual = Number(
|
||||
(Number(currentRow.settlementAmountTax || 0) - knownAmount(currentRow)).toFixed(2)
|
||||
);
|
||||
if (Math.abs(baseResidual) < 0.005 && Math.abs(currentResidual) < 0.005) return;
|
||||
// 结算金额被手工改写且无法拆分到具体费用项时,计入金额占比最大的费用项,否则归入其他费用。
|
||||
appendSummary(
|
||||
this.residualFeeItem(baseRow, currentRow),
|
||||
baseResidual,
|
||||
currentResidual,
|
||||
fallbackFeeType
|
||||
);
|
||||
},
|
||||
defaultFreightFeeItem() {
|
||||
return (
|
||||
this.feeOptions
|
||||
.flatMap(item => item.feeItems || [])
|
||||
.find(name => this.isFreightFeeItem(name)) || '运输费'
|
||||
);
|
||||
},
|
||||
residualFeeItem(baseRow, currentRow) {
|
||||
const feeItems = { ...(baseRow.feeItems || {}), ...(currentRow.feeItems || {}) };
|
||||
const dominant = Object.entries(feeItems)
|
||||
.filter(([, amount]) => Math.abs(Number(amount || 0)) >= 0.005)
|
||||
.sort((a, b) => Math.abs(Number(b[1] || 0)) - Math.abs(Number(a[1] || 0)))[0];
|
||||
if (dominant) return dominant[0];
|
||||
return (
|
||||
currentRow.billingRule?.feeItem ||
|
||||
currentRow.feeItem ||
|
||||
baseRow.billingRule?.feeItem ||
|
||||
baseRow.feeItem ||
|
||||
'其他费用'
|
||||
);
|
||||
},
|
||||
sumFeeItems(feeItems) {
|
||||
return Number(
|
||||
Object.values(feeItems || {})
|
||||
.reduce((total, value) => total + Number(value || 0), 0)
|
||||
.toFixed(2)
|
||||
);
|
||||
},
|
||||
recalculateLocalAmount() {
|
||||
const rate = this.form.currency === 'RMB' ? 1 : Number(this.form.exchangeRate || 0);
|
||||
this.form.localSettlementAmount = (Number(this.form.settlementAmount || 0) * rate).toFixed(2);
|
||||
@@ -1731,9 +1826,11 @@ export default {
|
||||
this.details.push(this.normalizeSourceDetail(row));
|
||||
}
|
||||
});
|
||||
this.buildSummaryFeesFromDetails();
|
||||
this.candidateDialog.confirming = true;
|
||||
if (this.deferSave || (this.pageMode && !this.form.id)) {
|
||||
await this.ensureSourceFeeSnapshots();
|
||||
this.buildSummaryFeesFromDetails();
|
||||
// 页面模式选择明细先更新本地数据,保存/提交时再统一落库。
|
||||
if (this.pageMode) {
|
||||
this.candidateDialog.visible = false;
|
||||
this.candidateDialog.confirming = false;
|
||||
this.$message.success('结算明细添加成功,请点击保存提交');
|
||||
@@ -1774,48 +1871,129 @@ export default {
|
||||
};
|
||||
this.appliedDetailQuery = { ...this.detailQuery };
|
||||
},
|
||||
async persistDetailForAdjustment(row) {
|
||||
const sourceDetailId = row.sourceDetailId || row.id;
|
||||
const isPersistedDetail =
|
||||
row.id && sourceDetailId && String(row.id) !== String(sourceDetailId);
|
||||
if (isPersistedDetail) return row;
|
||||
|
||||
await this.$refs.formRef?.validate();
|
||||
this.loading = true;
|
||||
try {
|
||||
const { data } = await save(this.buildSavePayload());
|
||||
this.form.id = data?.data || this.form.id;
|
||||
await this.loadDetail();
|
||||
const savedRow = this.details.find(
|
||||
item => String(item.sourceDetailId || '') === String(sourceDetailId || '')
|
||||
);
|
||||
if (!savedRow) {
|
||||
this.$message.warning('结算明细保存失败,请重试');
|
||||
return null;
|
||||
}
|
||||
this.$emit('success', this.form.id);
|
||||
return savedRow;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
normalizeAdjustRow(item, feeItemNames = []) {
|
||||
const rawFeeItems =
|
||||
item.feeItems && typeof item.feeItems === 'object'
|
||||
? item.feeItems
|
||||
: this.parseFeeItems(item.feeItemsJson);
|
||||
const names = feeItemNames.length ? feeItemNames : Object.keys(rawFeeItems);
|
||||
const adjusted = {
|
||||
...item,
|
||||
transportQuantity: Number(item.transportQuantity || 0),
|
||||
mileage:
|
||||
item.mileage === null || item.mileage === undefined || Number(item.mileage) === -1
|
||||
? null
|
||||
: Number(item.mileage),
|
||||
freightAmount: Number(item.freightAmount || 0),
|
||||
originalAmount: Number(item.originalAmount || 0),
|
||||
feeItems: Object.fromEntries(names.map(name => [name, Number(rawFeeItems[name] || 0)])),
|
||||
settlementAmountTax: Number(item.settlementAmountTax ?? item.afterAmount ?? 0),
|
||||
settlementAmountNoTax: item.settlementAmountNoTax,
|
||||
billingRule: this.normalizeBillingRule(item),
|
||||
calculating: false,
|
||||
calculateError: '',
|
||||
adjustCalculateTimer: null,
|
||||
adjustCalculateVersion: 0,
|
||||
};
|
||||
if (item.settlementAmountTax === undefined && item.afterAmount === undefined) {
|
||||
this.recalculateAdjustRow(adjusted);
|
||||
}
|
||||
return adjusted;
|
||||
},
|
||||
cloneAdjustRows(rows) {
|
||||
return (rows || []).map(row => ({
|
||||
...row,
|
||||
feeItems: { ...row.feeItems },
|
||||
calculating: false,
|
||||
calculateError: '',
|
||||
adjustCalculateTimer: null,
|
||||
adjustCalculateVersion: 0,
|
||||
}));
|
||||
},
|
||||
async loadSourceFeeRows(sourceDetailId) {
|
||||
const response = await getFeeDetail(sourceDetailId);
|
||||
const data = response.data?.data || response.data || {};
|
||||
return (data.records || []).map(item =>
|
||||
this.normalizeAdjustRow(item, data.feeItemNames || [])
|
||||
);
|
||||
},
|
||||
// 明细列表只有汇总金额,费用项级别的原始金额需要按来源明细单独拉取并缓存为调整基准。
|
||||
async ensureSourceFeeSnapshots() {
|
||||
const targets = this.details.filter(row => {
|
||||
const sourceDetailId = String(row.sourceDetailId || row.id || '');
|
||||
return sourceDetailId && !this.sourceFeeSnapshots[sourceDetailId];
|
||||
});
|
||||
if (!targets.length) return;
|
||||
await Promise.all(
|
||||
targets.map(async row => {
|
||||
const sourceDetailId = String(row.sourceDetailId || row.id || '');
|
||||
try {
|
||||
const feeRows = await this.loadSourceFeeRows(sourceDetailId);
|
||||
if (!feeRows.length) return;
|
||||
this.sourceFeeSnapshots[sourceDetailId] = feeRows;
|
||||
this.applySnapshotToDetail(row, feeRows);
|
||||
} catch (error) {
|
||||
// 取不到费用明细时退回按明细汇总金额归集,后续操作会再次尝试拉取。
|
||||
delete this.sourceFeeSnapshots[sourceDetailId];
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
applySnapshotToDetail(detail, feeRows) {
|
||||
if (!feeRows.length) return;
|
||||
const feeItems = {};
|
||||
feeRows.forEach(row =>
|
||||
Object.entries(row.feeItems || {}).forEach(([name, amount]) => {
|
||||
feeItems[name] = Number((Number(feeItems[name] || 0) + Number(amount || 0)).toFixed(2));
|
||||
})
|
||||
);
|
||||
const settlementAmount = feeRows.reduce(
|
||||
(total, row) => total + Number(row.settlementAmountTax || 0),
|
||||
0
|
||||
);
|
||||
const freightAmount = feeRows.reduce(
|
||||
(total, row) => total + Number(row.freightAmount || 0),
|
||||
0
|
||||
);
|
||||
Object.assign(detail, {
|
||||
feeItemsJson: JSON.stringify(feeItems),
|
||||
freightAmount: Number(freightAmount.toFixed(2)),
|
||||
originalAmount: Number(settlementAmount.toFixed(2)),
|
||||
adjustAmount: 0,
|
||||
settlementAmountTax: Number(settlementAmount.toFixed(2)),
|
||||
});
|
||||
},
|
||||
async openAdjustDialog(row, readonly) {
|
||||
const detailRow = await this.persistDetailForAdjustment(row);
|
||||
if (!detailRow) return;
|
||||
const detailRow = row;
|
||||
this.adjustDialog.visible = true;
|
||||
this.adjustDialog.loading = true;
|
||||
this.adjustDialog.readonly = readonly;
|
||||
this.adjustDialog.activeTab = 'adjust';
|
||||
this.adjustDialog.detailId = detailRow.id;
|
||||
this.adjustDialog.detailLineNo = detailRow.lineNo;
|
||||
this.adjustDialog.reason = '';
|
||||
this.adjustDialog.sourceDetailId =
|
||||
detailRow.id && String(detailRow.id) === String(detailRow.sourceDetailId)
|
||||
? detailRow.sourceDetailId
|
||||
: '';
|
||||
const sourceDetailId = String(this.adjustDialog.sourceDetailId || '');
|
||||
const pending = sourceDetailId ? this.pendingAdjustments[sourceDetailId] : null;
|
||||
this.adjustDialog.reason = pending?.reason || '';
|
||||
try {
|
||||
const { data } = await getDetailFees(detailRow.id);
|
||||
this.adjustRows = (data?.data || []).map(item => ({
|
||||
...item,
|
||||
feeItems: this.parseFeeItems(item.feeItemsJson),
|
||||
billingRule: this.normalizeBillingRule(item),
|
||||
}));
|
||||
// 已暂存的调整结果优先回显,避免重新打开时又回到调整前的数据。
|
||||
if (pending?.rows?.length) {
|
||||
this.adjustRows = this.cloneAdjustRows(pending.rows);
|
||||
return;
|
||||
}
|
||||
if (sourceDetailId) {
|
||||
if (!this.sourceFeeSnapshots[sourceDetailId]) {
|
||||
this.sourceFeeSnapshots[sourceDetailId] = await this.loadSourceFeeRows(sourceDetailId);
|
||||
}
|
||||
this.adjustRows = this.cloneAdjustRows(this.sourceFeeSnapshots[sourceDetailId]);
|
||||
return;
|
||||
}
|
||||
const response = await getDetailFees(detailRow.id);
|
||||
const rows = response.data?.data || response.data || [];
|
||||
this.adjustRows = rows.map(item => this.normalizeAdjustRow(item));
|
||||
} finally {
|
||||
this.adjustDialog.loading = false;
|
||||
}
|
||||
@@ -1957,6 +2135,45 @@ export default {
|
||||
row.settlementAmountTax = Number(
|
||||
(hasFreightItem ? feeItemTotal : Number(row.freightAmount || 0) + feeItemTotal).toFixed(2)
|
||||
);
|
||||
if (changedField && this.adjustDialog.sourceDetailId && row.id) {
|
||||
this.scheduleSourceAdjustCalculation(row);
|
||||
}
|
||||
},
|
||||
scheduleSourceAdjustCalculation(row) {
|
||||
if (row.adjustCalculateTimer) clearTimeout(row.adjustCalculateTimer);
|
||||
row.adjustCalculateVersion += 1;
|
||||
row.calculating = true;
|
||||
const version = row.adjustCalculateVersion;
|
||||
row.adjustCalculateTimer = setTimeout(async () => {
|
||||
row.adjustCalculateTimer = null;
|
||||
try {
|
||||
const response = await calculateAdjustedFee({
|
||||
detailId: this.adjustDialog.sourceDetailId,
|
||||
feeId: row.id,
|
||||
transportQuantity: Number(row.transportQuantity || 0),
|
||||
mileage: Number(row.mileage || 0),
|
||||
freightAmount: Number(row.freightAmount || 0),
|
||||
feeItems: Object.fromEntries(
|
||||
Object.entries(row.feeItems || {}).map(([name, amount]) => [
|
||||
name,
|
||||
Number(amount || 0),
|
||||
])
|
||||
),
|
||||
});
|
||||
if (version !== row.adjustCalculateVersion || !this.adjustDialog.visible) return;
|
||||
const data = response.data?.data || response.data || {};
|
||||
row.freightAmount = Number(data.freightAmount || 0);
|
||||
row.feeItems = Object.fromEntries(
|
||||
Object.entries(data.feeItems || {}).map(([name, amount]) => [name, Number(amount || 0)])
|
||||
);
|
||||
row.adjustAmount = Number(data.adjustAmount || 0);
|
||||
row.settlementAmountTax = Number(data.afterAmount || 0);
|
||||
} catch (error) {
|
||||
row.calculateError = error.message || '费用试算失败';
|
||||
} finally {
|
||||
if (version === row.adjustCalculateVersion) row.calculating = false;
|
||||
}
|
||||
}, 300);
|
||||
},
|
||||
isFreightFeeItem(name) {
|
||||
return String(name || '').includes('运费') || String(name || '').includes('运输费');
|
||||
@@ -1966,8 +2183,22 @@ export default {
|
||||
this.$message.warning('请输入调整原因');
|
||||
return;
|
||||
}
|
||||
if (this.adjustRows.some(row => row.calculating)) {
|
||||
this.$message.warning('费用正在重新计算,请稍候');
|
||||
return;
|
||||
}
|
||||
this.adjustDialog.saving = true;
|
||||
try {
|
||||
if (this.adjustDialog.sourceDetailId) {
|
||||
this.pendingAdjustments[this.adjustDialog.sourceDetailId] = {
|
||||
reason: this.adjustDialog.reason,
|
||||
rows: this.adjustRows.map(row => ({ ...row, feeItems: { ...row.feeItems } })),
|
||||
};
|
||||
this.applyLocalAdjustment(this.adjustDialog.sourceDetailId, this.adjustRows);
|
||||
this.adjustDialog.visible = false;
|
||||
this.$message.success('结算明细调整已暂存,保存预结算单后生效');
|
||||
return;
|
||||
}
|
||||
await adjustDetail({
|
||||
detailId: this.adjustDialog.detailId,
|
||||
changeReason: this.adjustDialog.reason,
|
||||
@@ -1991,6 +2222,92 @@ export default {
|
||||
this.adjustDialog.saving = false;
|
||||
}
|
||||
},
|
||||
applyLocalAdjustment(sourceDetailId, rows) {
|
||||
const detail = this.details.find(
|
||||
item => String(item.sourceDetailId || item.id) === String(sourceDetailId)
|
||||
);
|
||||
if (!detail) return;
|
||||
// 原金额以进入预结算单时的费用快照为基准,调整金额 = 结算金额 - 原金额。
|
||||
const baseRows = this.sourceFeeSnapshots[String(sourceDetailId)] || [];
|
||||
const originalAmount = baseRows.length
|
||||
? baseRows.reduce((sum, row) => sum + Number(row.settlementAmountTax || 0), 0)
|
||||
: rows.reduce((sum, row) => sum + Number(row.originalAmount || 0), 0);
|
||||
const settlementAmountTax = rows.reduce(
|
||||
(sum, row) => sum + Number(row.settlementAmountTax || 0),
|
||||
0
|
||||
);
|
||||
const freightAmount = rows.reduce((sum, row) => sum + Number(row.freightAmount || 0), 0);
|
||||
const feeItems = {};
|
||||
rows.forEach(row =>
|
||||
Object.entries(row.feeItems || {}).forEach(([name, amount]) => {
|
||||
feeItems[name] = Number((Number(feeItems[name] || 0) + Number(amount || 0)).toFixed(2));
|
||||
})
|
||||
);
|
||||
Object.assign(detail, {
|
||||
originalAmount: Number(originalAmount.toFixed(2)),
|
||||
adjustAmount: Number((settlementAmountTax - originalAmount).toFixed(2)),
|
||||
settlementAmountTax: Number(settlementAmountTax.toFixed(2)),
|
||||
freightAmount: Number(freightAmount.toFixed(2)),
|
||||
feeItemsJson: JSON.stringify(feeItems),
|
||||
});
|
||||
this.buildSummaryFeesFromDetails();
|
||||
},
|
||||
async persistPendingAdjustments() {
|
||||
const pendingEntries = Object.entries(this.pendingAdjustments);
|
||||
if (!pendingEntries.length || !this.form.id) return;
|
||||
for (const [sourceDetailId, pending] of pendingEntries) {
|
||||
const detail = this.details.find(
|
||||
item => String(item.sourceDetailId || '') === String(sourceDetailId)
|
||||
);
|
||||
if (!detail) continue;
|
||||
const persistedFees = await getDetailFees(detail.id);
|
||||
const feeRows = persistedFees.data?.data || persistedFees.data || [];
|
||||
const pendingRows = pending.rows || [];
|
||||
const rows = feeRows.map(fee => {
|
||||
const pendingRow = this.findPendingAdjustmentRow(fee, pendingRows);
|
||||
if (!pendingRow) {
|
||||
throw new Error(`费用行“${fee.cargoName || fee.lineNo || fee.id}”保存匹配失败`);
|
||||
}
|
||||
return {
|
||||
id: fee.id,
|
||||
transportQuantity: pendingRow.transportQuantity,
|
||||
mileage: pendingRow.mileage,
|
||||
unitPrice: pendingRow.unitPrice,
|
||||
freightAmount: pendingRow.freightAmount,
|
||||
feeItems: pendingRow.feeItems,
|
||||
settlementAmountTax: pendingRow.settlementAmountTax,
|
||||
settlementAmountNoTax: pendingRow.settlementAmountNoTax,
|
||||
remark: pendingRow.remark,
|
||||
};
|
||||
});
|
||||
if (rows.length) {
|
||||
await adjustDetail({ detailId: detail.id, changeReason: pending.reason, rows });
|
||||
}
|
||||
}
|
||||
this.pendingAdjustments = {};
|
||||
await this.loadDetail();
|
||||
},
|
||||
findPendingAdjustmentRow(fee, pendingRows) {
|
||||
const exactIdMatch = pendingRows.find(
|
||||
row => row.id && fee.sourceFeeId && String(row.id) === String(fee.sourceFeeId)
|
||||
);
|
||||
if (exactIdMatch) return exactIdMatch;
|
||||
const exactLineMatch = pendingRows.find(
|
||||
row =>
|
||||
row.lineNo &&
|
||||
fee.lineNo &&
|
||||
String(row.lineNo) === String(fee.lineNo) &&
|
||||
String(row.cargoName || '') === String(fee.cargoName || '') &&
|
||||
String(row.cargoType || '') === String(fee.cargoType || '')
|
||||
);
|
||||
if (exactLineMatch) return exactLineMatch;
|
||||
const cargoMatches = pendingRows.filter(
|
||||
row =>
|
||||
String(row.cargoName || '') === String(fee.cargoName || '') &&
|
||||
String(row.cargoType || '') === String(fee.cargoType || '')
|
||||
);
|
||||
return cargoMatches.length === 1 ? cargoMatches[0] : null;
|
||||
},
|
||||
exportDetails() {
|
||||
if (!this.filteredDetails.length) {
|
||||
this.$message.warning('暂无可导出的结算明细');
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" :title="title" width="98%" top="2vh" append-to-body destroy-on-close>
|
||||
<div v-loading="loading" class="reconciliation-editor">
|
||||
<component
|
||||
:is="editorContainer"
|
||||
v-bind="editorContainerProps"
|
||||
@update:model-value="visible = $event"
|
||||
>
|
||||
<div
|
||||
v-loading="loading"
|
||||
class="reconciliation-editor"
|
||||
:class="{ 'reconciliation-editor--page': pageMode }"
|
||||
>
|
||||
<section-card title="导入外部账单,与内部账单核对">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
@@ -98,17 +106,34 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="reconciliation-editor__filter">
|
||||
<el-input v-model="internalQuery.documentNo" placeholder="单据号" clearable />
|
||||
<el-input v-model="internalQuery.vehicleNo" placeholder="车号" clearable />
|
||||
<el-input v-model="internalQuery.batchNo" placeholder="批次号" clearable />
|
||||
<el-input v-model="internalQuery.cargoName" placeholder="货物名称" clearable />
|
||||
<div class="reconciliation-editor__filter-item">
|
||||
<span class="reconciliation-editor__filter-label">单据号</span>
|
||||
<el-input v-model="internalQuery.documentNo" placeholder="请输入" clearable />
|
||||
</div>
|
||||
<div class="reconciliation-editor__filter-item">
|
||||
<span class="reconciliation-editor__filter-label">车号</span>
|
||||
<el-input v-model="internalQuery.vehicleNo" placeholder="请输入" clearable />
|
||||
</div>
|
||||
<div class="reconciliation-editor__filter-item">
|
||||
<span class="reconciliation-editor__filter-label">批次号</span>
|
||||
<el-input v-model="internalQuery.batchNo" placeholder="请输入" clearable />
|
||||
</div>
|
||||
<div class="reconciliation-editor__filter-item">
|
||||
<span class="reconciliation-editor__filter-label">货物名称</span>
|
||||
<el-input v-model="internalQuery.cargoName" placeholder="请输入" clearable />
|
||||
</div>
|
||||
<el-button @click="resetInternalQuery">重置</el-button>
|
||||
<el-button type="primary" @click="internalFilterTick++">查询</el-button>
|
||||
</div>
|
||||
<el-table :data="filteredInternalRows" border max-height="420">
|
||||
<el-table
|
||||
:data="filteredInternalRows"
|
||||
border
|
||||
max-height="420"
|
||||
:row-class-name="internalRowClassName"
|
||||
>
|
||||
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
|
||||
<el-table-column
|
||||
v-for="column in internalColumns"
|
||||
v-for="column in internalTableColumns"
|
||||
:key="column.prop"
|
||||
v-bind="column"
|
||||
align="center"
|
||||
@@ -133,6 +158,9 @@
|
||||
<span v-else-if="column.prop === 'transportQuantity'">
|
||||
{{ formatTransportQuantity(row.transportQuantity) }}
|
||||
</span>
|
||||
<span v-else-if="column.feeItemName">
|
||||
{{ formatMoney(getFeeItemAmount(row, column.feeItemName)) }}
|
||||
</span>
|
||||
<span v-else-if="column.prop === 'mileage'">{{ formatMileage(row.mileage) }}</span>
|
||||
<span
|
||||
v-else-if="
|
||||
@@ -149,17 +177,11 @@
|
||||
<div class="reconciliation-editor__links">
|
||||
<el-link v-if="editable" type="primary" @click="openAdjust(row)">调整</el-link>
|
||||
<el-link
|
||||
v-if="editable && row.matchResult === 'matched'"
|
||||
v-if="editable && ['matched', 'partial'].includes(row.matchResult)"
|
||||
type="primary"
|
||||
@click="handleUnmatch(row)"
|
||||
>取消匹配</el-link
|
||||
>
|
||||
<el-link
|
||||
v-if="editable && row.matchResult !== 'matched'"
|
||||
type="primary"
|
||||
@click="openManualMatch(row)"
|
||||
>人工匹配</el-link
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -192,7 +214,12 @@
|
||||
<el-tab-pane :label="`导入明细(${externalDetails.length})`" name="all" />
|
||||
<el-tab-pane :label="`疑似重复(${duplicateRows.length})`" name="duplicate" />
|
||||
</el-tabs>
|
||||
<el-table :data="visibleExternalRows" border max-height="420">
|
||||
<el-table
|
||||
:data="visibleExternalRows"
|
||||
border
|
||||
max-height="420"
|
||||
:row-class-name="externalRowClassName"
|
||||
>
|
||||
<el-table-column
|
||||
v-for="column in externalColumns"
|
||||
:key="column.prop"
|
||||
@@ -204,12 +231,57 @@
|
||||
<el-tag v-if="column.prop === 'matchStatus'" :type="matchTagType(row.matchStatus)" class="status-text">{{
|
||||
matchName(row.matchStatus)
|
||||
}}</el-tag>
|
||||
<template v-else-if="isExternalRowEditing(row) && isExternalEditableColumn(column.prop)">
|
||||
<el-input-number
|
||||
v-if="column.feeItemName"
|
||||
v-model="row.feeItems[column.feeItemName]"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
size="small"
|
||||
/>
|
||||
<el-date-picker
|
||||
v-else-if="isExternalDateColumn(column.prop)"
|
||||
v-model="row[column.prop]"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
format="YYYY-MM-DD"
|
||||
size="small"
|
||||
placeholder="请选择"
|
||||
/>
|
||||
<el-input-number
|
||||
v-else-if="isExternalNumberColumn(column.prop)"
|
||||
v-model="row[column.prop]"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
size="small"
|
||||
/>
|
||||
<el-select
|
||||
v-else-if="column.prop === 'transportType'"
|
||||
v-model="row[column.prop]"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="请选择"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in transportTypeOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input v-else v-model="row[column.prop]" size="small" />
|
||||
</template>
|
||||
<span v-else-if="column.prop === 'transportType'">
|
||||
{{ transportTypeLabel(row.transportType) }}
|
||||
</span>
|
||||
<span v-else-if="column.prop === 'transportQuantity'">
|
||||
{{ formatTransportQuantity(row.transportQuantity) }}
|
||||
</span>
|
||||
<span v-else-if="column.feeItemName">
|
||||
{{ formatMoney(getFeeItemAmount(row, column.feeItemName)) }}
|
||||
</span>
|
||||
<span v-else-if="column.prop === 'mileage'">{{ formatMileage(row.mileage) }}</span>
|
||||
<span
|
||||
v-else-if="
|
||||
@@ -221,21 +293,24 @@
|
||||
<span v-else>{{ displayValue(row[column.prop]) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right" align="center">
|
||||
<template #default="{ row }"
|
||||
><el-link
|
||||
v-if="editable && row.matchStatus !== 'matched'"
|
||||
type="primary"
|
||||
@click="openManualMatchByExternal(row)"
|
||||
>匹配</el-link
|
||||
></template
|
||||
>
|
||||
<el-table-column label="操作" width="150" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="reconciliation-editor__links">
|
||||
<template v-if="editable && isExternalRowEditing(row)">
|
||||
<el-link type="primary" @click="finishExternalAdjust(row)">完成</el-link>
|
||||
<el-link type="primary" @click="cancelExternalAdjust(row)">取消</el-link>
|
||||
</template>
|
||||
<el-link v-else-if="editable" type="primary" @click="openExternalAdjust(row)">
|
||||
调整
|
||||
</el-link>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section-card>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<template v-if="!pageMode" #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<template v-if="editable">
|
||||
<el-button type="primary" plain :loading="saving" @click="handleSave">保存草稿</el-button>
|
||||
@@ -248,6 +323,18 @@
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div v-if="pageMode" class="reconciliation-editor__page-actions">
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<template v-if="editable">
|
||||
<el-button type="primary" plain :loading="actionLoading" @click="handleUpdate">
|
||||
按匹配结果更新账单
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="actionLoading" @click="handleComplete">
|
||||
完成对账
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="formalDialog.visible" title="选择正式结算单" width="84%" append-to-body>
|
||||
<el-form :model="formalDialog.query" inline label-position="right" label-width="88px">
|
||||
<el-form-item label="正式结算单号"
|
||||
@@ -335,34 +422,7 @@
|
||||
>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="manualDialog.visible" title="选择外部账单明细" width="86%" append-to-body>
|
||||
<el-table :data="unmatchedExternalRows" border @row-click="manualDialog.selected = $event">
|
||||
<el-table-column type="index" label="序号" width="64" />
|
||||
<el-table-column prop="externalLineNo" label="外部行号" width="90" />
|
||||
<el-table-column prop="vehicleNo" label="车牌号" width="110" />
|
||||
<el-table-column prop="cargoName" label="货物名称" min-width="140" />
|
||||
<el-table-column prop="transportQuantity" label="运输量" width="110"
|
||||
><template #default="{ row }">{{
|
||||
formatTransportQuantity(row.transportQuantity)
|
||||
}}</template></el-table-column
|
||||
>
|
||||
<el-table-column prop="settlementAmount" label="结算金额" width="130"
|
||||
><template #default="{ row }">{{
|
||||
formatMoney(row.settlementAmount)
|
||||
}}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="选择" width="80"
|
||||
><template #default="{ row }"
|
||||
><el-radio v-model="manualDialog.selected" :label="row"> </el-radio></template
|
||||
></el-table-column
|
||||
>
|
||||
</el-table>
|
||||
<template #footer
|
||||
><el-button @click="manualDialog.visible = false">取消</el-button
|
||||
><el-button type="primary" @click="confirmManualMatch">确定</el-button></template
|
||||
>
|
||||
</el-dialog>
|
||||
</el-dialog>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -385,6 +445,7 @@ export default {
|
||||
recordId: [String, Number],
|
||||
settlementType: { type: String, default: 'payable' },
|
||||
readonly: Boolean,
|
||||
pageMode: Boolean,
|
||||
},
|
||||
emits: ['update:modelValue', 'success'],
|
||||
data() {
|
||||
@@ -411,7 +472,9 @@ export default {
|
||||
page: { current: 1, size: 10, total: 0 },
|
||||
},
|
||||
adjustDialog: { visible: false, saving: false, rows: [], reason: '' },
|
||||
manualDialog: { visible: false, internal: null, selected: null },
|
||||
externalEditing: {},
|
||||
externalEditSnapshots: {},
|
||||
matchingStarted: false,
|
||||
rules: {
|
||||
formalSettlementNo: [{ required: true, message: '请选择正式结算单', trigger: 'change' }],
|
||||
reconciliationMode: [{ required: true, message: '请选择对账模式', trigger: 'change' }],
|
||||
@@ -429,6 +492,22 @@ export default {
|
||||
this.$emit('update:modelValue', value);
|
||||
},
|
||||
},
|
||||
editorContainer() {
|
||||
return this.pageMode ? 'div' : 'el-dialog';
|
||||
},
|
||||
editorContainerProps() {
|
||||
if (this.pageMode) {
|
||||
return { class: 'reconciliation-editor-shell reconciliation-editor-shell--page' };
|
||||
}
|
||||
return {
|
||||
modelValue: this.visible,
|
||||
title: this.title,
|
||||
width: '98%',
|
||||
top: '2vh',
|
||||
appendToBody: true,
|
||||
destroyOnClose: true,
|
||||
};
|
||||
},
|
||||
editable() {
|
||||
return !this.readonly;
|
||||
},
|
||||
@@ -442,7 +521,7 @@ export default {
|
||||
filteredInternalRows() {
|
||||
void this.internalFilterTick;
|
||||
const query = this.internalQuery;
|
||||
return this.internalDetails.filter(
|
||||
return this.groupedInternalRows.filter(
|
||||
row =>
|
||||
(!query.documentNo || String(row.documentNo || '').includes(query.documentNo)) &&
|
||||
(!query.vehicleNo || String(row.vehicleNo || '').includes(query.vehicleNo)) &&
|
||||
@@ -450,10 +529,62 @@ export default {
|
||||
(!query.cargoName || String(row.cargoName || '').includes(query.cargoName))
|
||||
);
|
||||
},
|
||||
groupedInternalRows() {
|
||||
const groups = new Map();
|
||||
this.internalDetails.forEach((row, index) => {
|
||||
const key = row.documentNo ? String(row.documentNo) : `__row_${index}`;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(row);
|
||||
});
|
||||
return [...groups.values()].map(rows => this.mergeInternalRows(rows));
|
||||
},
|
||||
internalFeeItemNames() {
|
||||
const names = new Set();
|
||||
this.internalDetails.forEach(row => {
|
||||
Object.keys(row.feeItems || this.parseFeeItems(row.feeItemsJson)).forEach(name => {
|
||||
if (name) names.add(name);
|
||||
});
|
||||
});
|
||||
return [...names];
|
||||
},
|
||||
internalTableColumns() {
|
||||
const columns = internalColumns.filter(column => column.prop !== 'freightAmount');
|
||||
const settlementIndex = columns.findIndex(column => column.prop === 'settlementAmount');
|
||||
const feeColumns = this.internalFeeItemNames.map((name, index) => ({
|
||||
prop: `feeItem_${index}`,
|
||||
label: name,
|
||||
minWidth: 120,
|
||||
money: true,
|
||||
feeItemName: name,
|
||||
}));
|
||||
columns.splice(settlementIndex < 0 ? columns.length : settlementIndex, 0, ...feeColumns);
|
||||
return columns;
|
||||
},
|
||||
externalColumns() {
|
||||
return this.form.reconciliationMode === 'cargo'
|
||||
? externalCargoColumns
|
||||
: externalVehicleColumns;
|
||||
const columns = [
|
||||
...(this.form.reconciliationMode === 'cargo'
|
||||
? externalCargoColumns
|
||||
: externalVehicleColumns),
|
||||
];
|
||||
const settlementIndex = columns.findIndex(column => column.prop === 'settlementAmount');
|
||||
const feeColumns = this.externalFeeItemNames.map((name, index) => ({
|
||||
prop: `externalFeeItem_${index}`,
|
||||
label: name,
|
||||
minWidth: 120,
|
||||
money: true,
|
||||
feeItemName: name,
|
||||
}));
|
||||
columns.splice(settlementIndex < 0 ? columns.length : settlementIndex, 0, ...feeColumns);
|
||||
return columns;
|
||||
},
|
||||
externalFeeItemNames() {
|
||||
const names = new Set();
|
||||
this.externalDetails.forEach(row => {
|
||||
Object.keys(row.feeItems || this.parseFeeItems(row.feeItemsJson)).forEach(name => {
|
||||
if (name) names.add(name);
|
||||
});
|
||||
});
|
||||
return [...names];
|
||||
},
|
||||
duplicateRows() {
|
||||
return this.externalDetails.filter(
|
||||
@@ -466,15 +597,13 @@ export default {
|
||||
visibleExternalRows() {
|
||||
return this.externalTab === 'duplicate' ? this.duplicateRows : this.externalDetails;
|
||||
},
|
||||
unmatchedExternalRows() {
|
||||
return this.externalDetails.filter(
|
||||
row => row.matchStatus !== 'matched' && !row.suspectedDuplicate
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
modelValue(value) {
|
||||
if (value) this.initialize();
|
||||
modelValue: {
|
||||
immediate: true,
|
||||
handler(value) {
|
||||
if (value) this.initialize();
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
@@ -484,6 +613,7 @@ export default {
|
||||
reconciliationMode: 'vehicle',
|
||||
formalSettlementId: null,
|
||||
formalSettlementNo: '',
|
||||
customerName: '',
|
||||
payerName: '',
|
||||
payeeName: '',
|
||||
projectName: '',
|
||||
@@ -512,6 +642,9 @@ export default {
|
||||
this.form = this.emptyForm();
|
||||
this.internalDetails = [];
|
||||
this.externalDetails = [];
|
||||
this.externalEditing = {};
|
||||
this.externalEditSnapshots = {};
|
||||
this.matchingStarted = false;
|
||||
this.externalTab = 'all';
|
||||
this.internalQuery = { documentNo: '', vehicleNo: '', batchNo: '', cargoName: '' };
|
||||
if (!this.currentId) {
|
||||
@@ -530,11 +663,27 @@ export default {
|
||||
},
|
||||
async loadDetail() {
|
||||
const data = this.unwrapData(await api.getDetail(this.currentId)) || {};
|
||||
this.form = { ...this.emptyForm(), ...data };
|
||||
this.internalDetails =
|
||||
data.internalDetails || data.internalBillDetails || data.internals || [];
|
||||
this.externalDetails =
|
||||
data.externalDetails || data.externalBillDetails || data.externals || [];
|
||||
this.form = {
|
||||
...this.emptyForm(),
|
||||
...data,
|
||||
customerName:
|
||||
data.customerName ||
|
||||
((data.settlementType || this.settlementType) === 'receivable'
|
||||
? data.payerName
|
||||
: data.payeeName) ||
|
||||
'',
|
||||
};
|
||||
const internalRows = data.internalDetails || data.internalBillDetails || data.internals || [];
|
||||
this.internalDetails = internalRows.map(row => this.normalizeInternalRow(row));
|
||||
const externalRows = data.externalDetails || data.externalBillDetails || data.externals || [];
|
||||
this.externalDetails = externalRows.map((row, index) =>
|
||||
this.normalizeExternalRow(row, index)
|
||||
);
|
||||
this.matchingStarted =
|
||||
this.matchingStarted ||
|
||||
data.matchStatus === 'partial' ||
|
||||
this.internalDetails.some(row => row.matchResult === 'matched') ||
|
||||
this.externalDetails.some(row => row.matchStatus === 'matched');
|
||||
},
|
||||
openFormalDialog() {
|
||||
if (!this.editable) return;
|
||||
@@ -542,6 +691,80 @@ export default {
|
||||
this.formalDialog.page.current = 1;
|
||||
this.loadFormalOptions();
|
||||
},
|
||||
parseFeeItems(value) {
|
||||
if (!value) return {};
|
||||
if (typeof value === 'object' && !Array.isArray(value)) return value;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
normalizeInternalRow(row) {
|
||||
const feeItems =
|
||||
row.feeItems && typeof row.feeItems === 'object' && Object.keys(row.feeItems).length
|
||||
? row.feeItems
|
||||
: this.parseFeeItems(row.feeItemsJson);
|
||||
return { ...row, feeItems };
|
||||
},
|
||||
normalizeExternalRow(row, index) {
|
||||
const feeItems =
|
||||
row.feeItems && typeof row.feeItems === 'object' && Object.keys(row.feeItems).length
|
||||
? row.feeItems
|
||||
: this.parseFeeItems(row.feeItemsJson);
|
||||
return {
|
||||
...row,
|
||||
feeItems,
|
||||
_externalKey: row.id || row.externalLineNo || `external-${index}`,
|
||||
};
|
||||
},
|
||||
mergeInternalRows(rows) {
|
||||
const firstRow = rows[0] || {};
|
||||
const feeItems = {};
|
||||
rows.forEach(row => {
|
||||
Object.entries(row.feeItems || this.parseFeeItems(row.feeItemsJson)).forEach(
|
||||
([name, amount]) => {
|
||||
feeItems[name] = Number(feeItems[name] || 0) + Number(amount || 0);
|
||||
}
|
||||
);
|
||||
});
|
||||
const matchResults = rows.map(row => row.matchResult);
|
||||
const updateResults = rows.map(row => row.updateResult);
|
||||
return {
|
||||
...firstRow,
|
||||
cargoName: this.joinInternalValues(rows, 'cargoName'),
|
||||
cargoType: this.joinInternalValues(rows, 'cargoType'),
|
||||
transportQuantity: this.sumInternalValues(rows, 'transportQuantity'),
|
||||
settlementAmount: this.sumInternalValues(rows, 'settlementAmount'),
|
||||
feeItems,
|
||||
matchedExternalLineNo: this.joinInternalValues(rows, 'matchedExternalLineNo'),
|
||||
matchResult: matchResults.every(value => value === 'matched')
|
||||
? 'matched'
|
||||
: matchResults.some(value => value === 'matched')
|
||||
? 'partial'
|
||||
: 'unmatched',
|
||||
updateResult: updateResults.every(value => value === updateResults[0])
|
||||
? updateResults[0]
|
||||
: 'partial_updated',
|
||||
_sourceRows: rows,
|
||||
};
|
||||
},
|
||||
joinInternalValues(rows, prop) {
|
||||
return [
|
||||
...new Set(
|
||||
rows
|
||||
.map(row => row[prop])
|
||||
.filter(value => value !== null && value !== undefined && value !== '')
|
||||
),
|
||||
].join('、');
|
||||
},
|
||||
sumInternalValues(rows, prop) {
|
||||
return rows.reduce((total, row) => total + Number(row[prop] || 0), 0);
|
||||
},
|
||||
getFeeItemAmount(row, name) {
|
||||
return (row.feeItems || this.parseFeeItems(row.feeItemsJson))[name] ?? 0;
|
||||
},
|
||||
async loadFormalOptions() {
|
||||
this.formalDialog.loading = true;
|
||||
try {
|
||||
@@ -586,25 +809,35 @@ export default {
|
||||
...selected,
|
||||
formalSettlementId: selected.id,
|
||||
formalSettlementNo: selected.formalSettlementNo,
|
||||
customerName:
|
||||
selected.customerName ||
|
||||
((selected.settlementType || this.settlementType) === 'receivable'
|
||||
? selected.payerName
|
||||
: selected.payeeName) ||
|
||||
'',
|
||||
reconciliationNo: this.form.reconciliationNo,
|
||||
};
|
||||
if (formalSettlementChanged) this.externalDetails = [];
|
||||
if (formalSettlementChanged) {
|
||||
this.externalDetails = [];
|
||||
this.matchingStarted = false;
|
||||
}
|
||||
await this.loadFormalInternalPreview(selected.id);
|
||||
this.formalDialog.visible = false;
|
||||
},
|
||||
async loadFormalInternalPreview(formalSettlementId) {
|
||||
const formal = this.unwrapData(await formalSettlementApi.getDetail(formalSettlementId));
|
||||
const details = formal.details || [];
|
||||
const buildInternalRow = (detail, overrides = {}, index = 0) => ({
|
||||
...detail,
|
||||
...overrides,
|
||||
id: null,
|
||||
lineNo: index + 1,
|
||||
formalSettlementDetailId: detail.id,
|
||||
settlementAmount: overrides.settlementAmount ?? detail.settlementAmountTax ?? 0,
|
||||
matchResult: 'unmatched',
|
||||
updateResult: 'not_updated',
|
||||
});
|
||||
const buildInternalRow = (detail, overrides = {}, index = 0) =>
|
||||
this.normalizeInternalRow({
|
||||
...detail,
|
||||
...overrides,
|
||||
id: null,
|
||||
lineNo: index + 1,
|
||||
formalSettlementDetailId: detail.id,
|
||||
settlementAmount: overrides.settlementAmount ?? detail.settlementAmountTax ?? 0,
|
||||
matchResult: 'unmatched',
|
||||
updateResult: 'not_updated',
|
||||
});
|
||||
if (this.form.reconciliationMode === 'cargo') {
|
||||
const feeGroups = await Promise.all(
|
||||
details.map(async detail => ({
|
||||
@@ -688,7 +921,7 @@ export default {
|
||||
await this.loadDetail();
|
||||
if (!silent) {
|
||||
this.$message.success('草稿保存成功');
|
||||
this.$emit('success');
|
||||
this.$emit('success', this.currentId);
|
||||
}
|
||||
return data;
|
||||
} finally {
|
||||
@@ -701,6 +934,7 @@ export default {
|
||||
try {
|
||||
await api.match(this.currentId);
|
||||
await this.loadDetail();
|
||||
this.matchingStarted = true;
|
||||
this.$message.success('匹配完成');
|
||||
} finally {
|
||||
this.actionLoading = false;
|
||||
@@ -740,7 +974,7 @@ export default {
|
||||
await this.loadDetail();
|
||||
this.$message.success('对账完成');
|
||||
this.visible = false;
|
||||
this.$emit('success');
|
||||
this.$emit('success', this.currentId);
|
||||
} finally {
|
||||
this.actionLoading = false;
|
||||
}
|
||||
@@ -773,7 +1007,9 @@ export default {
|
||||
if (result.code !== 200) throw new Error(result.msg || '导入失败');
|
||||
this.$message.success('外部账单导入成功');
|
||||
}
|
||||
this.matchingStarted = false;
|
||||
await this.loadDetail();
|
||||
this.matchingStarted = false;
|
||||
} catch (error) {
|
||||
this.$message.error(error.message || '外部账单导入失败');
|
||||
}
|
||||
@@ -794,9 +1030,9 @@ export default {
|
||||
visible: true,
|
||||
saving: false,
|
||||
reason: '',
|
||||
rows: this.internalDetails
|
||||
.filter(item => item.formalSettlementDetailId === row.formalSettlementDetailId)
|
||||
.map(item => ({ ...item })),
|
||||
rows: (row._sourceRows || this.internalDetails.filter(
|
||||
item => item.formalSettlementDetailId === row.formalSettlementDetailId
|
||||
)).map(item => ({ ...item })),
|
||||
};
|
||||
},
|
||||
async saveAdjust() {
|
||||
@@ -817,26 +1053,85 @@ export default {
|
||||
}
|
||||
},
|
||||
async handleUnmatch(row) {
|
||||
await api.unmatch(row.id);
|
||||
const matchedRows = (row._sourceRows || [row]).filter(item => item.matchResult === 'matched');
|
||||
for (const item of matchedRows) await api.unmatch(item.id);
|
||||
await this.loadDetail();
|
||||
},
|
||||
openManualMatch(row) {
|
||||
this.manualDialog = { visible: true, internal: row, selected: null };
|
||||
internalRowClassName({ row }) {
|
||||
return this.matchingStarted && row.matchResult !== 'matched'
|
||||
? 'reconciliation-editor__row--unmatched'
|
||||
: '';
|
||||
},
|
||||
openManualMatchByExternal(row) {
|
||||
this.manualDialog = { visible: true, internal: null, selected: row };
|
||||
externalRowClassName({ row }) {
|
||||
return this.matchingStarted && row.matchStatus !== 'matched'
|
||||
? 'reconciliation-editor__row--unmatched'
|
||||
: '';
|
||||
},
|
||||
async confirmManualMatch() {
|
||||
if (!this.manualDialog.selected) return this.$message.warning('请选择外部账单明细');
|
||||
if (!this.manualDialog.internal) return this.$message.warning('请从内部账单行发起人工匹配');
|
||||
await api.manualMatch({
|
||||
reconciliationId: this.currentId,
|
||||
internalId: this.manualDialog.internal.id,
|
||||
externalId: this.manualDialog.selected.id,
|
||||
});
|
||||
this.manualDialog.visible = false;
|
||||
await this.loadDetail();
|
||||
this.$message.success('人工匹配成功');
|
||||
externalRowKey(row) {
|
||||
return String(row.id || row.externalLineNo || row._externalKey || '');
|
||||
},
|
||||
isExternalRowEditing(row) {
|
||||
return Boolean(this.externalEditing[this.externalRowKey(row)]);
|
||||
},
|
||||
isExternalEditableColumn(prop) {
|
||||
return !['externalLineNo', 'matchStatus'].includes(prop);
|
||||
},
|
||||
isExternalDateColumn(prop) {
|
||||
return ['actualDepartureTime', 'actualCompletionTime'].includes(prop);
|
||||
},
|
||||
isExternalNumberColumn(prop) {
|
||||
return ['transportQuantity', 'mileage', 'unitPrice', 'freightAmount', 'settlementAmount'].includes(prop);
|
||||
},
|
||||
openExternalAdjust(row) {
|
||||
const key = this.externalRowKey(row);
|
||||
this.externalEditSnapshots = {
|
||||
...this.externalEditSnapshots,
|
||||
[key]: { ...row },
|
||||
};
|
||||
this.externalEditing = { ...this.externalEditing, [key]: true };
|
||||
},
|
||||
cancelExternalAdjust(row) {
|
||||
const key = this.externalRowKey(row);
|
||||
const snapshot = this.externalEditSnapshots[key];
|
||||
if (snapshot) Object.assign(row, snapshot);
|
||||
const editing = { ...this.externalEditing };
|
||||
const snapshots = { ...this.externalEditSnapshots };
|
||||
delete editing[key];
|
||||
delete snapshots[key];
|
||||
this.externalEditing = editing;
|
||||
this.externalEditSnapshots = snapshots;
|
||||
},
|
||||
finishExternalAdjust(row) {
|
||||
row.feeItemsJson = JSON.stringify(row.feeItems || {});
|
||||
const key = this.externalRowKey(row);
|
||||
const editing = { ...this.externalEditing };
|
||||
const snapshots = { ...this.externalEditSnapshots };
|
||||
delete editing[key];
|
||||
delete snapshots[key];
|
||||
this.externalEditing = editing;
|
||||
this.externalEditSnapshots = snapshots;
|
||||
this.refreshExternalStats();
|
||||
},
|
||||
refreshExternalStats() {
|
||||
const externalQuantity = this.externalDetails.reduce(
|
||||
(total, item) => total + Number(item.transportQuantity || 0),
|
||||
0
|
||||
);
|
||||
const externalAmount = this.externalDetails.reduce(
|
||||
(total, item) => total + Number(item.settlementAmount || 0),
|
||||
0
|
||||
);
|
||||
const matchedCount = this.externalDetails.filter(item => item.matchStatus === 'matched').length;
|
||||
this.form = {
|
||||
...this.form,
|
||||
externalBillCount: this.externalDetails.length,
|
||||
externalQuantity,
|
||||
externalAmount,
|
||||
differenceQuantity: Math.abs(Number(this.form.internalQuantity || 0) - externalQuantity),
|
||||
differenceAmount: Math.abs(Number(this.form.internalAmount || 0) - externalAmount),
|
||||
matchedCount,
|
||||
unmatchedCount: Math.max(Number(this.form.internalBillCount || 0), this.externalDetails.length) - matchedCount,
|
||||
};
|
||||
},
|
||||
matchName(value) {
|
||||
return (
|
||||
@@ -863,6 +1158,7 @@ export default {
|
||||
updated: '已更新',
|
||||
skipped_multi_cargo: '跳过更新',
|
||||
manually_adjusted: '手工调整',
|
||||
partial_updated: '部分更新',
|
||||
not_updated: '未更新',
|
||||
}[value] ||
|
||||
value ||
|
||||
@@ -872,7 +1168,7 @@ export default {
|
||||
updateTagType(value) {
|
||||
return value === 'updated' || value === 'manually_adjusted'
|
||||
? 'success'
|
||||
: value === 'skipped_multi_cargo'
|
||||
: value === 'skipped_multi_cargo' || value === 'partial_updated'
|
||||
? 'warning'
|
||||
: 'info';
|
||||
},
|
||||
@@ -956,11 +1252,50 @@ export default {
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.reconciliation-editor__filter-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.reconciliation-editor__filter-label {
|
||||
flex: 0 0 auto;
|
||||
margin-right: 8px;
|
||||
color: #606266;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.reconciliation-editor__filter-item :deep(.el-input) {
|
||||
min-width: 0;
|
||||
}
|
||||
.reconciliation-editor__pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.reconciliation-editor-shell--page {
|
||||
display: block;
|
||||
}
|
||||
.reconciliation-editor--page {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.reconciliation-editor__page-actions {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 230px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-height: 64px;
|
||||
box-sizing: border-box;
|
||||
padding: 12px 24px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #eff1f7;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.reconciliation-editor__page-actions .el-button + .el-button {
|
||||
margin-left: 12px;
|
||||
}
|
||||
.reconciliation-editor__help {
|
||||
color: #606266;
|
||||
line-height: 1.8;
|
||||
@@ -980,6 +1315,20 @@ export default {
|
||||
.reconciliation-editor :deep(.el-table__body tr:nth-child(even) > td.el-table__cell) {
|
||||
background: #fafafa;
|
||||
}
|
||||
.reconciliation-editor :deep(.el-table__body tr.reconciliation-editor__row--unmatched > td.el-table__cell) {
|
||||
color: #f56c6c;
|
||||
background: #fff1f0 !important;
|
||||
}
|
||||
.reconciliation-editor
|
||||
:deep(.el-table__body tr.reconciliation-editor__row--unmatched > td.el-table__cell .cell) {
|
||||
color: #f56c6c;
|
||||
}
|
||||
.reconciliation-editor
|
||||
:deep(.el-table__body tr.reconciliation-editor__row--unmatched .status-text) {
|
||||
color: #f56c6c;
|
||||
background: #fde2e2;
|
||||
border-color: #fbc4c4;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.reconciliation-editor__stats {
|
||||
grid-template-columns: repeat(3, minmax(150px, 1fr));
|
||||
|
||||
@@ -104,8 +104,6 @@ export default {
|
||||
},
|
||||
rowActions(row) {
|
||||
const actions = [{ type: 'view', label: '查看' }];
|
||||
if (this.hasPermission('transport_reconciliation_edit') && this.isEditable(row))
|
||||
actions.push({ type: 'edit', label: '编辑' });
|
||||
if (this.hasPermission('transport_reconciliation_delete') && this.isEditable(row))
|
||||
actions.push({ type: 'delete', label: '删除', danger: true });
|
||||
if (this.hasPermission('transport_reconciliation_complete') && this.isEditable(row))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<basic-container class="formal-settlement-form-page">
|
||||
<div class="formal-settlement-form-page__title">{{ pageTitle }}</div>
|
||||
<!-- 新增来源明细可直接调整:计费试算走独立接口,调整结果先暂存,保存正式结算单时再落库。 -->
|
||||
<formal-settlement-editor
|
||||
v-model="editorVisible"
|
||||
page-mode
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<basic-container class="pre-settlement-form-page">
|
||||
<div class="pre-settlement-form-page__title">{{ pageTitle }}</div>
|
||||
<!-- 新增来源明细可直接调整:计费试算走独立接口,调整结果先暂存,保存预结算单时再落库。 -->
|
||||
<pre-settlement-editor
|
||||
v-model="editorVisible"
|
||||
page-mode
|
||||
:record-id="recordId"
|
||||
:initial-data="transferPayload"
|
||||
:defer-save="Boolean(transferPayload)"
|
||||
@success="handleSuccess"
|
||||
/>
|
||||
</basic-container>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<basic-container class="transport-reconciliation-form-page">
|
||||
<div class="transport-reconciliation-form-page__title">{{ pageTitle }}</div>
|
||||
<transport-reconciliation-editor
|
||||
v-model="editorVisible"
|
||||
page-mode
|
||||
:record-id="recordId"
|
||||
:settlement-type="settlementType"
|
||||
@success="handleSuccess"
|
||||
/>
|
||||
</basic-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TransportReconciliationEditor from './components/transport-reconciliation-editor.vue';
|
||||
|
||||
export default {
|
||||
name: 'TransportReconciliationForm',
|
||||
components: { TransportReconciliationEditor },
|
||||
data() {
|
||||
return {
|
||||
editorVisible: true,
|
||||
savedRecordId: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
recordId() {
|
||||
return this.$route.query.id || '';
|
||||
},
|
||||
settlementType() {
|
||||
return this.$route.query.settlementType || 'payable';
|
||||
},
|
||||
pageTitle() {
|
||||
return this.recordId || this.savedRecordId ? '编辑运输对账单' : '新增运输对账单';
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
editorVisible(value) {
|
||||
if (!value) this.goBack();
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.syncTagTitle();
|
||||
},
|
||||
methods: {
|
||||
handleSuccess(recordId) {
|
||||
this.savedRecordId = recordId || this.savedRecordId;
|
||||
this.syncTagTitle();
|
||||
},
|
||||
goBack() {
|
||||
this.$router.push('/settlement/transport-reconciliation');
|
||||
},
|
||||
syncTagTitle() {
|
||||
this.$nextTick(() => {
|
||||
this.$store.commit('SET_TAG', {
|
||||
fullPath: this.$route.fullPath,
|
||||
name: this.pageTitle,
|
||||
});
|
||||
this.$router.$avueRouter.setTitle(this.pageTitle);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.transport-reconciliation-form-page {
|
||||
min-height: 100%;
|
||||
padding-bottom: 72px;
|
||||
background: #f5f6fa;
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
|
||||
&::before {
|
||||
width: 4px;
|
||||
height: 20px;
|
||||
margin-right: 8px;
|
||||
background: #409eff;
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.transport-reconciliation-form-page.basic-container .basic-container__card > .el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.transport-reconciliation-form-page.basic-container .basic-container__card) {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:global(.avue--collapse) .reconciliation-editor__page-actions {
|
||||
left: 60px;
|
||||
}
|
||||
|
||||
:global(.avue-layout--horizontal) .reconciliation-editor__page-actions {
|
||||
left: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -109,8 +109,8 @@ const emptyQuery = () => ({
|
||||
contractNo: '',
|
||||
payerName: '',
|
||||
payeeName: '',
|
||||
matchStatus: '',
|
||||
reconciliationStatus: '',
|
||||
matchStatus: 'all',
|
||||
reconciliationStatus: 'all',
|
||||
});
|
||||
|
||||
export default {
|
||||
@@ -153,7 +153,7 @@ export default {
|
||||
try {
|
||||
const response = await api.getList(this.page.current, this.page.size, {
|
||||
// 所属组织级联返回 id 路径,转成组织名称传给后端(用副本,避免污染搜索框回显)
|
||||
...this.normalizeOrganizationSearch({ ...this.query }, 'deptName'),
|
||||
...this.buildSearchParams(),
|
||||
settlementType: this.settlementType,
|
||||
});
|
||||
const data = response?.data?.data || response?.data || response || {};
|
||||
@@ -183,10 +183,15 @@ export default {
|
||||
this.page.size = size;
|
||||
this.loadTable();
|
||||
},
|
||||
buildSearchParams() {
|
||||
const params = this.normalizeOrganizationSearch({ ...this.query }, 'deptName');
|
||||
if (params.matchStatus === 'all') params.matchStatus = '';
|
||||
if (params.reconciliationStatus === 'all') params.reconciliationStatus = '';
|
||||
return params;
|
||||
},
|
||||
handleAction({ type, row }) {
|
||||
const map = {
|
||||
view: this.openView,
|
||||
edit: this.openEdit,
|
||||
delete: this.handleDelete,
|
||||
complete: this.handleComplete,
|
||||
};
|
||||
@@ -194,10 +199,10 @@ export default {
|
||||
if (fn) fn(row);
|
||||
},
|
||||
openCreate() {
|
||||
this.editor = { visible: true, id: null, readonly: false };
|
||||
},
|
||||
openEdit(row) {
|
||||
this.editor = { visible: true, id: row.id, readonly: false };
|
||||
this.$router.push({
|
||||
path: '/settlement/transport-reconciliation/form',
|
||||
query: { mode: 'add', settlementType: this.settlementType, name: '新增运输对账单' },
|
||||
});
|
||||
},
|
||||
openView(row) {
|
||||
this.editor = { visible: true, id: row.id, readonly: true };
|
||||
@@ -218,7 +223,7 @@ export default {
|
||||
},
|
||||
async handleExport() {
|
||||
const params = {
|
||||
...this.normalizeOrganizationSearch({ ...this.query }, 'deptName'),
|
||||
...this.buildSearchParams(),
|
||||
settlementType: this.settlementType,
|
||||
};
|
||||
if (this.selection.length) params.ids = this.selection.map(item => item.id).join(',');
|
||||
|
||||
Reference in New Issue
Block a user