Merge branch 'master' into feature/20261019-bfi-all-web

# Conflicts:
#	src/views/payment/invoice-application.vue
#	src/views/payment/invoice-receipt.vue
#	src/views/payment/payment-application.vue
#	src/views/settlement/formal-settlement.vue
#	src/views/settlement/pre-settlement.vue
This commit is contained in:
刘泉佳
2026-10-27 15:03:17 +08:00
76 changed files with 4402 additions and 17679 deletions
@@ -64,6 +64,20 @@
:precision="2"
:controls="false"
/>
<el-select
v-else-if="field.type === 'select' && editable"
v-model="form[field.prop]"
filterable
clearable
placeholder="请选择"
>
<el-option
v-for="item in formSelectOptions(field)"
:key="item.dictKey"
:label="item.dictValue"
:value="item.dictKey"
/>
</el-select>
<span
v-else-if="field.prop === 'formalSettlementNo'"
:class="{ 'form-readonly--code': form.formalSettlementNo }"
@@ -71,6 +85,9 @@
{{ form.formalSettlementNo || (form.id ? '-' : '系统自动生成') }}
</span>
<span v-else-if="field.money">{{ formatMoney(form[field.prop]) }}</span>
<span v-else-if="field.type === 'select'">{{
formSelectLabel(field, form[field.prop])
}}</span>
<span v-else-if="field.type === 'contract'">{{
displayValue(form.contractName)
}}</span>
@@ -424,26 +441,7 @@
<section-card title="发票信息">
<div class="formal-editor__invoice-claim">
<el-form inline label-position="right" label-width="auto" @submit.prevent>
<el-form-item label="发票号码">
<el-input
v-model="invoiceClaimNo"
clearable
maxlength="32"
placeholder="请输入发票号码"
@keyup.enter="openInvoiceClaimDialog"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="invoiceClaimLoading"
@click="openInvoiceClaimDialog"
>
认领查询
</el-button>
</el-form-item>
</el-form>
<el-button type="primary" @click="openInvoiceClaimDialog">认领查询</el-button>
</div>
<el-table :data="invoices" border>
<el-table-column type="index" label="序号" width="64" align="center" />
@@ -544,7 +542,6 @@
title="查询可认领发票"
confirm-text="确认认领"
multiple
:initial-invoice-no="String(invoiceClaimNo || '').trim()"
:query-api="invoicePoolQueryApi"
@confirm="handleInvoiceClaimConfirm"
/>
@@ -697,9 +694,19 @@
<el-table-column prop="changeTime" label="变更时间" min-width="170" align="center">
<template #default="{ row }">{{ displayValue(row.changeTime) }}</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center" fixed="right">
<template #default="{ row }">
<el-link type="primary" @click="openChangeRecordDetail(row)">详情</el-link>
</template>
</el-table-column>
</el-table>
</section-card>
</div>
<change-record-detail-dialog
v-model="changeRecordDetailVisible"
:rows="changeRecordDetailRows"
/>
<template v-if="!pageMode" #footer>
<el-button v-if="editable" @click="handleSyncPlaceholder">同步</el-button>
<el-button @click="visible = false">取消</el-button>
@@ -1071,6 +1078,7 @@ import { getDictionary } from '@/api/system/dictbiz';
import { h } from 'vue';
import { mapGetters } from 'vuex';
import { isTierUnitPrice, isUnitPriceAdjustable } from '@/utils/settlement-adjust-rule';
import { isApprovalSettlementChange } from '@/utils/settlement-change-record';
import { normalizeCurrencyCode } from '@/utils/currency';
import {
settlementRecordTypeName,
@@ -1083,6 +1091,7 @@ import {
viewKingdeeInvoiceFile,
} from '@/utils/kingdee-attachment';
import KingdeeInvoicePoolDialog from '@/components/kingdee-invoice-pool-dialog/main.vue';
import ChangeRecordDetailDialog from '@/components/change-record-detail-dialog/main.vue';
import ProjectAdvanceWriteOff from './project-advance-write-off.vue';
import * as XLSX from 'xlsx';
import { isAdministrator } from '@/utils/auth';
@@ -1093,7 +1102,12 @@ const LOCAL_CURRENCY_HIDDEN_FIELDS = ['exchangeRateDate', 'exchangeRate', 'local
export default {
name: 'FormalSettlementEditor',
components: { InfoFilled, KingdeeInvoicePoolDialog, ProjectAdvanceWriteOff },
components: {
InfoFilled,
KingdeeInvoicePoolDialog,
ChangeRecordDetailDialog,
ProjectAdvanceWriteOff,
},
props: {
modelValue: Boolean,
recordId: [String, Number],
@@ -1123,14 +1137,14 @@ export default {
receiptClaims: [],
adjustments: [],
changeRecords: [],
changeRecordDetailVisible: false,
changeRecordDetailRows: [],
projectAdvanceWriteOffs: [],
attachments: [],
selectedAttachmentRows: [],
invoices: [],
// 开票申请经金蝶回写的发票结果(只读展示,含板式文件/数电PDF下载地址)
issuedInvoices: [],
invoiceClaimNo: '',
invoiceClaimLoading: false,
// 金蝶侧发票附件查看/下载进行中(行组级 loading 守卫)
kingdeeAttachmentBusy: false,
invoiceClaimDialog: {
@@ -1162,6 +1176,7 @@ export default {
projects: [],
feeOptions: [],
feeCategoryOptions: [],
businessModelOptions: [],
transportTypeOptions: [],
fields: formalSettlementFormFields,
sourceTableColumns: sourceColumns,
@@ -1373,16 +1388,12 @@ export default {
},
immediate: true,
},
summaryTotal(value) {
this.form.settlementAmount = Number(value || 0).toFixed(2);
this.form.localSettlementAmount = (
Number(value || 0) * Number(this.form.exchangeRate || 1)
).toFixed(2);
summaryTotal() {
this.syncSettlementAmountsFromSummary();
},
'form.exchangeRate'(value) {
this.form.localSettlementAmount = (
Number(this.summaryTotal || 0) * Number(value || 1)
).toFixed(2);
'form.exchangeRate'() {
if (this._skipExchangeRateRebuild) return;
this.buildSummaryFeesFromDetails();
},
},
methods: {
@@ -1438,7 +1449,9 @@ export default {
this.receiptClaims = this.unwrapData(await getReceiptClaims(id)) || [];
}
this.adjustments = data.adjustments || [];
this.changeRecords = data.changeRecords || [];
this.changeRecords = (data.changeRecords || []).filter(
row => !isApprovalSettlementChange(row.changeContent)
);
this.projectAdvanceWriteOffs = (data.projectAdvanceWriteOffs || []).map(row => ({
...row,
writeOffAmount: Number(row.writeOffAmount || 0),
@@ -1476,13 +1489,14 @@ export default {
this.receiptClaims = [];
this.adjustments = [];
this.changeRecords = [];
this.changeRecordDetailVisible = false;
this.changeRecordDetailRows = [];
this.projectAdvanceWriteOffs = [];
this.advancePayments = [];
this.attachments = [];
this.selectedAttachmentRows = [];
this.invoices = [];
this.issuedInvoices = [];
this.invoiceClaimNo = '';
this.invoiceClaimDialog.visible = false;
this.invoiceClaimDialog.selected = [];
this.attachmentUploadFiles = [];
@@ -1499,6 +1513,7 @@ export default {
this.loadFeeOptions(),
this.loadFeeCategoryOptions(),
this.loadAttachmentTypeOptions(),
this.loadBusinessModelOptions(),
this.loadTransportTypeOptions(),
]);
}
@@ -1664,8 +1679,9 @@ export default {
current.settlementAmount = current.originalAmount;
summaryMap.set(key, current);
});
// 先保留预结算手工费用行,再按明细本位币 + 结算汇率重算系统归集行
this.summaryFees = [...summaryMap.values()];
if (!this.summaryFees.length) await this.refreshSummaryFees();
await this.refreshSummaryFees();
},
async loadAllContracts() {
const response = await getContractOptions('');
@@ -1767,13 +1783,19 @@ export default {
},
async fetchBfiExchangeRate() {
const currency = this.form.currency;
if (!currency || currency === 'RMB') return;
if (!currency || currency === 'RMB') {
this.buildSummaryFeesFromDetails();
return;
}
if (!this.form.exchangeRateDate) return;
try {
const { data } = await getBfiExchangeRate(currency, this.form.exchangeRateDate);
const rateData = data?.data;
if (rateData?.excval != null) {
this._skipExchangeRateRebuild = true;
this.form.exchangeRate = Number(rateData.excval);
this._skipExchangeRateRebuild = false;
this.buildSummaryFeesFromDetails();
} else {
this.$message.warning(`未查询到 ${currency} 的BFI汇率数据`);
}
@@ -1781,6 +1803,37 @@ export default {
this.$message.warning('查询BFI汇率失败,请手动输入结算汇率');
}
},
/**
* 明细金额按本位币理解;外币结算时结算合计原金额/结算金额 = 本位币金额 / 结算汇率。
*/
convertLocalToSettlementAmount(localAmount) {
const amount = Number(localAmount || 0);
if (!Number.isFinite(amount)) return 0;
if (this.isLocalCurrencySettlement) {
return Number(amount.toFixed(2));
}
const rate = Number(this.form.exchangeRate || 0);
if (rate <= 0) return 0;
return Number((amount / rate).toFixed(2));
},
/**
* 结算金额取结算合计原币汇总值;本位币合计 = 结算金额 × 结算汇率。
*/
syncSettlementAmountsFromSummary() {
const settlementTotal = this.summaryFees.reduce(
(total, row) => total + Number(row.settlementAmount || 0),
0
);
this.form.settlementAmount = Number(settlementTotal || 0).toFixed(2);
if (this.isLocalCurrencySettlement) {
this.form.localSettlementAmount = this.form.settlementAmount;
this.form.exchangeRate = 1;
return;
}
const rate = Number(this.form.exchangeRate || 0);
this.form.localSettlementAmount =
rate > 0 ? (Number(this.form.settlementAmount || 0) * rate).toFixed(2) : '0.00';
},
openCandidateDialog() {
this.candidate.visible = true;
this.candidate.page.current = 1;
@@ -2002,8 +2055,9 @@ export default {
.map(row => [`${row.feeType}\u0000${row.feeItem}`, row])
);
const append = (feeItem, originalValue, settlementValue, fallbackFeeType = '') => {
const originalAmount = Number(originalValue || 0);
const settlementAmount = Number(settlementValue || 0);
// 明细费用按本位币归集,外币结算时再按汇率换算成结算原币金额
const originalAmount = this.convertLocalToSettlementAmount(originalValue);
const settlementAmount = this.convertLocalToSettlementAmount(settlementValue);
if (!feeItem || !Number.isFinite(originalAmount) || !Number.isFinite(settlementAmount)) {
return;
}
@@ -2041,6 +2095,7 @@ export default {
});
const manualRows = this.summaryFees.filter(row => Number(row.manualFlag) === 1);
this.summaryFees = [...generatedMap.values(), ...manualRows];
this.syncSettlementAmountsFromSummary();
},
// 每条明细对应的费用行:[调整前基准行, 调整后当前行],缺少费用快照时退化为明细自身的汇总行。
resolveDetailFeePairs(detail) {
@@ -2424,6 +2479,7 @@ export default {
row.settlementAmountTax = Number(data.afterAmount || 0);
} catch (error) {
row.calculateError = error.message || '费用试算失败';
this.$message.warning(row.calculateError);
} finally {
if (version === row.adjustCalculateVersion) row.calculating = false;
}
@@ -2796,6 +2852,8 @@ export default {
sourceDetailId: row.sourcePreSettlementId ? undefined : row.sourceDetailId || row.id,
adjustAmount: Number(row.adjustAmount || 0),
})),
businessModel: this.form.businessModel || undefined,
transportType: this.form.transportType || undefined,
exchangeRateDate: this.form.exchangeRateDate,
exchangeRate: this.form.exchangeRate,
summaryFees: this.summaryFees
@@ -2886,6 +2944,55 @@ export default {
const lineNo = row?.lineNo;
return lineNo === -1 || String(lineNo) === '-1' ? '' : this.displayValue(lineNo);
},
parseChangeRecordData(value) {
if (!value) return {};
if (typeof value === 'object') return value;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch (error) {
return {};
}
},
formatChangeRecordValue(value) {
if (value === undefined || value === null || value === '') return '空';
if (Array.isArray(value)) {
if (!value.length) return '空';
return value
.map(item => {
if (!item || typeof item !== 'object') return String(item);
return Object.entries(item)
.map(([key, itemValue]) => `${key}:${this.formatChangeRecordValue(itemValue)}`)
.join(',');
})
.join('\n');
}
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
},
buildChangeRecordDetailRows(row = {}) {
const before = this.parseChangeRecordData(row.beforeData);
const after = this.parseChangeRecordData(row.afterData);
const fields = [...new Set([...Object.keys(before), ...Object.keys(after)])];
const rows = fields
.filter(field => JSON.stringify(before[field]) !== JSON.stringify(after[field]))
.map(field => ({
field,
before: this.formatChangeRecordValue(before[field]),
after: this.formatChangeRecordValue(after[field]),
}));
if (row.changeContent) {
rows.unshift({ field: '变更内容', before: '空', after: row.changeContent });
}
if (row.changeReason) {
rows.push({ field: '变更原因', before: '空', after: row.changeReason });
}
return rows;
},
openChangeRecordDetail(row) {
this.changeRecordDetailRows = this.buildChangeRecordDetailRows(row);
this.changeRecordDetailVisible = true;
},
paymentMethodName(value) {
const labels = {
bank_transfer: '银行转账',
@@ -2915,9 +3022,36 @@ export default {
formatDetailMileage(value) {
return this.readonly && Number(value) === -1 ? '-' : this.displayValue(value);
},
async loadBusinessModelOptions() {
const { data } = await getDictionary({ code: 'business_model' });
const records = Array.isArray(data?.data) ? data.data : data?.data?.records || [];
this.businessModelOptions = records.map(item => ({
dictKey: item.dictKey ?? item.value,
dictValue: item.dictValue ?? item.label ?? item.name,
}));
},
async loadTransportTypeOptions() {
const { data } = await getDictionary({ code: 'transport_type' });
this.transportTypeOptions = data?.data || [];
const records = Array.isArray(data?.data) ? data.data : data?.data?.records || [];
this.transportTypeOptions = records.map(item => ({
dictKey: item.dictKey ?? item.value,
dictValue: item.dictValue ?? item.label ?? item.name,
}));
},
formSelectOptions(field) {
const key = field?.optionsKey;
const options = key ? this[key] : [];
return Array.isArray(options) ? options : [];
},
formSelectLabel(field, value) {
if (value === undefined || value === null || value === '') return '-';
const normalized = String(value).trim();
const option = this.formSelectOptions(field).find(
item =>
String(item.dictKey ?? '').trim() === normalized ||
String(item.dictValue ?? '').trim() === normalized
);
return option?.dictValue || normalized;
},
transportTypeName(value) {
if (value === undefined || value === null || value === '') return '';
@@ -3253,21 +3387,9 @@ export default {
}
.formal-editor__invoice-claim {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
justify-content: flex-end;
margin-bottom: 12px;
}
.formal-editor__invoice-claim-hint {
color: var(--el-color-danger);
font-size: 13px;
}
.formal-editor__invoice-claim :deep(.el-form-item) {
margin-bottom: 0;
}
.formal-editor__invoice-claim :deep(.el-input) {
width: 360px;
}
.formal-editor__issued-invoices {
margin-top: 16px;
}
@@ -20,7 +20,7 @@
<el-form-item :label="field.label" :prop="field.prop">
<el-input
v-if="field.type === 'contract'"
:model-value="formatContractOptionLabel(form)"
:model-value="form.contractName"
readonly
:disabled="!editable || details.length > 0"
placeholder="请选择合同"
@@ -53,6 +53,23 @@
:disabled="!editable"
@change="recalculateLocalAmount"
/>
<el-select
v-else-if="field.type === 'select' && editable"
v-model="form[field.prop]"
filterable
clearable
placeholder="请选择"
>
<el-option
v-for="item in formSelectOptions(field)"
:key="item.dictKey"
:label="item.dictValue"
:value="item.dictKey"
/>
</el-select>
<span v-else-if="field.type === 'select'" class="form-readonly">
{{ formSelectLabel(field, form[field.prop]) }}
</span>
<el-input
v-else-if="field.type === 'textarea'"
v-model="form[field.prop]"
@@ -493,7 +510,7 @@
</el-table-column>
<el-table-column label="操作" width="100" align="center" fixed="right">
<template #default="{ row }">
<el-link type="primary" @click="openAdjustChangeRecord(row)">查看</el-link>
<el-link type="primary" @click="openAdjustChangeRecord(row)">详情</el-link>
</template>
</el-table-column>
</el-table>
@@ -999,8 +1016,8 @@ import {
settlementDetailColumns,
settlementSummaryColumns,
} from '@/option/settlement/preSettlementTable';
import { formatContractOptionLabel } from '@/utils/contract';
import { isTierUnitPrice, isUnitPriceAdjustable } from '@/utils/settlement-adjust-rule';
import { isApprovalSettlementChange } from '@/utils/settlement-change-record';
import { normalizeCurrencyCode } from '@/utils/currency';
import { settlementStatusName } from '@/utils/settlementStatus';
import { downloadFileByUrl } from '@/utils/util';
@@ -1095,6 +1112,7 @@ export default {
},
feeOptions: [],
feeCategoryOptions: [],
businessModelOptions: [],
transportTypeOptions: [],
summaryFees: [],
details: [],
@@ -1144,6 +1162,8 @@ export default {
readonly: false,
detailId: '',
sourceDetailId: '',
// 未保存明细(id 仍等于来源应收应付明细)时先暂存,保存预结算单后再生效
draftMode: false,
detailLineNo: '',
reason: '',
},
@@ -1312,9 +1332,12 @@ export default {
if (value) this.initialize();
},
},
summaryTotal(value) {
this.form.settlementAmount = Number(value || 0).toFixed(2);
this.recalculateLocalAmount();
summaryTotal() {
this.syncSettlementAmountsFromSummary();
},
'form.exchangeRate'() {
if (this._skipExchangeRateRebuild) return;
this.buildSummaryFeesFromDetails();
},
filteredDetails: {
handler(list) {
@@ -1330,7 +1353,6 @@ export default {
methods: {
isTierUnitPrice,
isUnitPriceAdjustable,
formatContractOptionLabel,
hasPermission(code) {
return this.isAdmin || this.validData(this.permission?.[code], false);
},
@@ -1344,6 +1366,7 @@ export default {
if (!this.publicMode) {
await this.loadFeeOptions();
await this.loadFeeCategoryOptions();
await this.loadBusinessModelOptions();
await this.loadTransportTypeOptions();
await this.loadAttachmentTypeOptions();
}
@@ -1485,6 +1508,7 @@ export default {
this.detailFeeCache = {};
this.adjustDialog.detailId = '';
this.adjustDialog.sourceDetailId = '';
this.adjustDialog.draftMode = false;
this.adjustDialog.detailLineNo = '';
this.adjustChangeRecordVisible = false;
this.adjustChangeRecord = null;
@@ -1523,7 +1547,7 @@ export default {
createTime: row.paymentCreateTime || row.createTime,
}));
this.changeRecords = (detail.changeRecords || []).filter(
row => row.changeContent !== '预结算审批通过'
row => !isApprovalSettlementChange(row.changeContent)
);
this.attachments = this.parseAttachments(detail.attachmentsJson);
this.sortAttachments();
@@ -1647,6 +1671,14 @@ export default {
const { data } = await getDictionary({ code: 'fee_category' });
this.feeCategoryOptions = data?.data || [];
},
async loadBusinessModelOptions() {
const { data } = await getDictionary({ code: 'business_model' });
const records = Array.isArray(data?.data) ? data.data : data?.data?.records || [];
this.businessModelOptions = records.map(item => ({
dictKey: item.dictKey ?? item.value,
dictValue: item.dictValue ?? item.label ?? item.name,
}));
},
async loadTransportTypeOptions() {
const { data } = await getDictionary({ code: 'transport_type' });
const records = Array.isArray(data?.data) ? data.data : data?.data?.records || [];
@@ -1655,6 +1687,21 @@ export default {
dictValue: item.dictValue ?? item.label ?? item.name,
}));
},
formSelectOptions(field) {
const key = field?.optionsKey;
const options = key ? this[key] : [];
return Array.isArray(options) ? options : [];
},
formSelectLabel(field, value) {
if (value === undefined || value === null || value === '') return '-';
const normalized = String(value).trim();
const option = this.formSelectOptions(field).find(
item =>
String(item.dictKey ?? '').trim() === normalized ||
String(item.dictValue ?? '').trim() === normalized
);
return option?.dictValue || normalized;
},
handleContractChange(id) {
const contract = this.contractOptions.find(item => String(item.id) === String(id));
if (!contract) {
@@ -1741,6 +1788,8 @@ export default {
return {
id: this.form.id || undefined,
contractId: this.form.contractId,
businessModel: this.form.businessModel || undefined,
transportType: this.form.transportType || undefined,
exchangeRateDate: this.form.exchangeRateDate || undefined,
exchangeRate: this.form.exchangeRate,
attachmentsJson: JSON.stringify(this.attachments || []),
@@ -1844,8 +1893,9 @@ export default {
.map(row => [`${row.feeType}\u0000${row.feeItem}`, row])
);
const appendSummary = (feeItem, originalValue, settlementValue, fallbackFeeType = '') => {
const originalAmount = Number(originalValue || 0);
const settlementAmount = Number(settlementValue || 0);
// 明细费用按本位币归集,外币结算时再按汇率换算成结算原币金额
const originalAmount = this.convertLocalToSettlementAmount(originalValue);
const settlementAmount = this.convertLocalToSettlementAmount(settlementValue);
if (!feeItem || !Number.isFinite(originalAmount) || !Number.isFinite(settlementAmount)) {
return;
}
@@ -1900,7 +1950,7 @@ export default {
});
nextFeeMap.forEach(row => orderedFees.push(row));
this.summaryFees = orderedFees;
this.recalculateLocalAmount();
this.syncSettlementAmountsFromSummary();
},
// 每条明细对应的费用行:[调整前基准行, 调整后当前行],缺少来源费用快照时退化为明细自身的汇总行。
resolveDetailFeePairs(detail) {
@@ -1988,17 +2038,21 @@ export default {
},
handleExchangeRateDateChange() {
this.fetchBfiExchangeRate();
this.recalculateLocalAmount();
},
async fetchBfiExchangeRate() {
if (!this.form.currency || this.form.currency === 'RMB') return;
if (!this.form.currency || this.form.currency === 'RMB') {
this.buildSummaryFeesFromDetails();
return;
}
if (!this.form.exchangeRateDate) return;
try {
const { data } = await getBfiExchangeRate(this.form.currency, this.form.exchangeRateDate);
const rateData = data?.data;
if (rateData?.excval != null) {
this._skipExchangeRateRebuild = true;
this.form.exchangeRate = Number(rateData.excval);
this.recalculateLocalAmount();
this._skipExchangeRateRebuild = false;
this.buildSummaryFeesFromDetails();
} else {
this.$message.warning(`未查询到 ${this.form.currency} 的BFI汇率数据`);
}
@@ -2006,9 +2060,39 @@ export default {
this.$message.warning('查询BFI汇率失败,请手动输入结算汇率');
}
},
/**
* 明细金额按本位币理解;外币结算时结算合计原金额/结算金额 = 本位币金额 / 结算汇率。
*/
convertLocalToSettlementAmount(localAmount) {
const amount = Number(localAmount || 0);
if (!Number.isFinite(amount)) return 0;
if (this.isLocalCurrencySettlement) {
return Number(amount.toFixed(2));
}
const rate = Number(this.form.exchangeRate || 0);
if (rate <= 0) return 0;
return Number((amount / rate).toFixed(2));
},
/**
* 结算金额取结算合计原币汇总值;本位币合计 = 结算金额 × 结算汇率。
*/
syncSettlementAmountsFromSummary() {
const settlementTotal = this.summaryFees.reduce(
(total, row) => total + Number(row.settlementAmount || 0),
0
);
this.form.settlementAmount = Number(settlementTotal || 0).toFixed(2);
if (this.isLocalCurrencySettlement) {
this.form.localSettlementAmount = this.form.settlementAmount;
this.form.exchangeRate = 1;
return;
}
const rate = Number(this.form.exchangeRate || 0);
this.form.localSettlementAmount =
rate > 0 ? (Number(this.form.settlementAmount || 0) * rate).toFixed(2) : '0.00';
},
recalculateLocalAmount() {
const rate = this.form.currency === 'RMB' ? 1 : Number(this.form.exchangeRate || 0);
this.form.localSettlementAmount = (Number(this.form.settlementAmount || 0) * rate).toFixed(2);
this.buildSummaryFeesFromDetails();
},
openCandidateDialog() {
if (!this.form.contractId) {
@@ -2251,17 +2335,21 @@ export default {
},
async openAdjustDialog(row, readonly) {
const detailRow = row;
const sourceDetailId = detailRow.sourceDetailId || '';
// 未保存明细 id 仍等于来源应收应付明细 id,调整结果先暂存
const draftMode =
!this.form.id ||
!detailRow.id ||
String(detailRow.id) === String(detailRow.sourceDetailId);
this.adjustDialog.visible = true;
this.adjustDialog.loading = true;
this.adjustDialog.readonly = readonly;
this.adjustDialog.draftMode = draftMode;
this.adjustDialog.detailId = detailRow.id;
this.adjustDialog.detailLineNo = detailRow.lineNo;
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.sourceDetailId = sourceDetailId;
const pendingKey = String(sourceDetailId || '');
const pending = pendingKey ? this.pendingAdjustments[pendingKey] : null;
// 底部备注回显结算明细备注;暂存过的调整优先取暂存备注
this.adjustDialog.reason =
pending?.reason !== undefined && pending?.reason !== null
@@ -2278,11 +2366,12 @@ export default {
this.adjustRows = cachedFees.map(item => this.normalizeAdjustRow(item));
return;
}
if (sourceDetailId) {
if (!this.sourceFeeSnapshots[sourceDetailId]) {
this.sourceFeeSnapshots[sourceDetailId] = await this.loadSourceFeeRows(sourceDetailId);
if (draftMode && sourceDetailId) {
if (!this.sourceFeeSnapshots[String(sourceDetailId)]) {
this.sourceFeeSnapshots[String(sourceDetailId)] =
await this.loadSourceFeeRows(sourceDetailId);
}
this.adjustRows = this.cloneAdjustRows(this.sourceFeeSnapshots[sourceDetailId]);
this.adjustRows = this.cloneAdjustRows(this.sourceFeeSnapshots[String(sourceDetailId)]);
return;
}
const cachedFees = this.detailFeeCache[detailRow.id];
@@ -2554,7 +2643,7 @@ export default {
if (
['transportQuantity', 'mileage', 'unitPrice'].includes(changedField) &&
this.adjustDialog.sourceDetailId &&
row.id &&
(row.sourceFeeId || row.id) &&
(changedField !== 'unitPrice' || isUnitPriceAdjustable(row))
) {
this.scheduleSourceAdjustCalculation(row, changedField);
@@ -2570,7 +2659,8 @@ export default {
try {
const response = await calculateAdjustedFee({
detailId: this.adjustDialog.sourceDetailId,
feeId: row.id,
// 已保存预结算费用行需用源应收应付费用ID试算
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),
@@ -2596,6 +2686,7 @@ export default {
row.settlementAmountTax = Number(data.afterAmount || 0);
} catch (error) {
row.calculateError = error.message || '费用试算失败';
this.$message.warning(row.calculateError);
} finally {
if (version === row.adjustCalculateVersion) row.calculating = false;
}
@@ -2611,7 +2702,7 @@ export default {
}
this.adjustDialog.saving = true;
try {
if (this.adjustDialog.sourceDetailId) {
if (this.adjustDialog.draftMode && this.adjustDialog.sourceDetailId) {
this.pendingAdjustments[this.adjustDialog.sourceDetailId] = {
reason: this.adjustDialog.reason,
rows: this.adjustRows.map(row => ({ ...row, feeItems: { ...row.feeItems } })),
@@ -380,7 +380,13 @@ export default {
},
async loadFormalSettlements(keyword = '') {
const { data } = await api.getFormalSettlements(keyword);
this.candidates = data?.data || data || [];
const payload = data?.data ?? data;
// 兼容误返回分页对象的情况,只取正式结算单列表
this.candidates = Array.isArray(payload)
? payload
: Array.isArray(payload?.records)
? payload.records
: [];
},
async loadFeeOptions() {
const { data } = await api.getFeeOptions();
@@ -926,14 +926,34 @@ const stripInternalRowFields = row => {
};
};
/** 合并费用列名与各行 feeItems 键,避免保存/回显时丢动态费用列 */
const collectFeeItemNames = (preferred = [], detailRows = []) => {
const names = [];
const seen = new Set();
const pushName = value => {
const name = String(value || '').trim();
if (!name || seen.has(name)) return;
seen.add(name);
names.push(name);
};
(preferred || []).forEach(pushName);
(detailRows || []).forEach(row => {
const feeItems = row?.feeItems;
if (!feeItems || typeof feeItems !== 'object' || Array.isArray(feeItems)) return;
Object.keys(feeItems).forEach(pushName);
});
return names;
};
const buildPayload = () => {
const { file, ...batch } = form;
const payloadRows = rows.value.map(stripInternalRowFields);
return {
...batch,
settlementBillType: form.settlementType === 'payable' ? form.settlementBillType || 'formal' : 'formal',
waybillMode: form.waybillMode === 'history' ? 'history' : 'new',
feeItemNames: [...feeItemNames.value],
rows: rows.value.map(stripInternalRowFields),
feeItemNames: collectFeeItemNames(feeItemNames.value, payloadRows),
rows: payloadRows,
};
};
@@ -1436,9 +1456,13 @@ const loadDetail = async id => {
if (detail.projectId) {
await loadContractsByProject(detail.projectId, detail.projectName || '');
}
feeItemNames.value = Array.isArray(detail.feeItemNames) ? detail.feeItemNames : [];
parsedHeaders.value = [];
const detailRows = Array.isArray(detail.rows) ? detail.rows : [];
// 先按库中列名与明细 feeItems 合并,再规范化行数据,保证草稿再打开仍展示全部费用列
feeItemNames.value = collectFeeItemNames(
Array.isArray(detail.feeItemNames) ? detail.feeItemNames : [],
detailRows
);
rows.value = detailRows.map((item, index) => normalizeParsedRow(item, index));
refreshDuplicateFlags();
// 查看模式始终只读;编辑时仅草稿可改,已完成/已作废强制只读
@@ -125,15 +125,17 @@
<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>
<el-button
type="primary"
plain
:disabled="!filteredInternalRows.length"
@click="exportInternalDetails"
>导出</el-button
>
<div class="reconciliation-editor__filter-actions">
<el-button @click="resetInternalQuery">重置</el-button>
<el-button type="primary" @click="internalFilterTick++">查询</el-button>
<el-button
type="primary"
plain
:disabled="!filteredInternalRows.length"
@click="exportInternalDetails"
>导出</el-button
>
</div>
</div>
<el-table
:data="filteredInternalRows"
@@ -189,6 +191,9 @@
"
>{{ formatDate(row[column.prop]) }}</span
>
<span v-else-if="column.prop === 'unitPrice'">{{
formatOptionalMoney(row.unitPrice)
}}</span>
<span v-else-if="column.money">{{ formatMoney(row[column.prop]) }}</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
@@ -263,7 +268,7 @@
<template v-else-if="isExternalRowEditing(row) && isExternalEditableColumn(column.prop)">
<el-input-number
v-if="column.feeItemName"
v-model="row.feeItems[column.feeItemName]"
v-model="externalEditTarget(row).feeItems[column.feeItemName]"
:min="0"
:precision="2"
:controls="false"
@@ -271,7 +276,7 @@
/>
<el-date-picker
v-else-if="isExternalDateColumn(column.prop)"
v-model="row[column.prop]"
v-model="externalEditTarget(row)[column.prop]"
type="date"
value-format="YYYY-MM-DD"
format="YYYY-MM-DD"
@@ -280,7 +285,7 @@
/>
<el-input-number
v-else-if="isExternalNumberColumn(column.prop)"
v-model="row[column.prop]"
v-model="externalEditTarget(row)[column.prop]"
:min="0"
:precision="2"
:controls="false"
@@ -288,7 +293,7 @@
/>
<el-select
v-else-if="column.prop === 'transportType'"
v-model="row[column.prop]"
v-model="externalEditTarget(row)[column.prop]"
size="small"
clearable
placeholder="请选择"
@@ -300,7 +305,7 @@
:value="item.value"
/>
</el-select>
<el-input v-else v-model="row[column.prop]" size="small" />
<el-input v-else v-model="externalEditTarget(row)[column.prop]" size="small" />
</template>
<span v-else-if="column.prop === 'transportType'">
{{ transportTypeLabel(row.transportType) }}
@@ -318,6 +323,9 @@
"
>{{ formatDate(row[column.prop]) }}</span
>
<span v-else-if="column.prop === 'unitPrice'">{{
formatOptionalMoney(row.unitPrice)
}}</span>
<span v-else-if="column.money">{{ formatMoney(row[column.prop]) }}</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
@@ -476,6 +484,7 @@
<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="column.prop === 'actualDepartureTime' || column.prop === 'actualCompletionTime'">{{ formatDate(row[column.prop]) }}</span>
<span v-else-if="column.prop === 'unitPrice'">{{ formatOptionalMoney(row.unitPrice) }}</span>
<span v-else-if="column.money">{{ formatMoney(row[column.prop]) }}</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
@@ -630,6 +639,10 @@ import { getDictionary } from '@/api/system/dictbiz';
import { Search } from '@element-plus/icons-vue';
import { h } from 'vue';
import { downloadXls } from '@/utils/util';
import {
consumeImportResponse,
resolveImportErrorMessage,
} from '@/utils/import-excel';
import { transportReconciliationFormFields } from '@/option/settlement/transportReconciliationForm';
import {
internalColumns,
@@ -676,6 +689,8 @@ export default {
completionDialog: { visible: false, loading: false },
completionSummaryRowsData: null,
externalEditing: {},
// 调整中的草稿,输入只改草稿,点「完成」才写回列表
externalEditDrafts: {},
externalEditSnapshots: {},
matchingStarted: false,
rules: {
@@ -932,6 +947,7 @@ export default {
this.internalDetails = [];
this.externalDetails = [];
this.externalEditing = {};
this.externalEditDrafts = {};
this.externalEditSnapshots = {};
this.completionSummaryRowsData = null;
this.matchingStarted = false;
@@ -1038,12 +1054,24 @@ export default {
.map(normalized)
.join('|');
},
blankOptionalNumber(value) {
if (value === null || value === undefined || value === '' || Number(value) === -1) {
return null;
}
return value;
},
normalizeInternalRow(row) {
const feeItems =
row.feeItems && typeof row.feeItems === 'object' && Object.keys(row.feeItems).length
? row.feeItems
: this.parseFeeItems(row.feeItemsJson);
return { ...row, feeItems };
return {
...row,
feeItems,
unitPrice: this.blankOptionalNumber(row.unitPrice),
mileage: this.blankOptionalNumber(row.mileage),
freightAmount: this.blankOptionalNumber(row.freightAmount),
};
},
normalizeExternalRow(row, index) {
const feeItems =
@@ -1053,6 +1081,9 @@ export default {
return {
...row,
feeItems,
unitPrice: this.blankOptionalNumber(row.unitPrice),
mileage: this.blankOptionalNumber(row.mileage),
freightAmount: this.blankOptionalNumber(row.freightAmount),
_externalKey: row.id || row.externalLineNo || `external-${index}`,
};
},
@@ -1280,6 +1311,9 @@ export default {
snapshot.feeItemsJson = row.feeItemsJson || JSON.stringify(row.feeItems || {});
snapshot.actualDepartureTime = this.normalizeExternalDateTime(row.actualDepartureTime);
snapshot.actualCompletionTime = this.normalizeExternalDateTime(row.actualCompletionTime);
snapshot.unitPrice = this.blankOptionalNumber(row.unitPrice);
snapshot.mileage = this.blankOptionalNumber(row.mileage);
snapshot.freightAmount = this.blankOptionalNumber(row.freightAmount);
return snapshot;
});
},
@@ -1293,6 +1327,9 @@ export default {
snapshot.feeItemsJson = row.feeItemsJson || JSON.stringify(row.feeItems || {});
snapshot.actualDepartureTime = this.normalizeExternalDateTime(row.actualDepartureTime);
snapshot.actualCompletionTime = this.normalizeExternalDateTime(row.actualCompletionTime);
snapshot.unitPrice = this.blankOptionalNumber(row.unitPrice);
snapshot.mileage = this.blankOptionalNumber(row.mileage);
snapshot.freightAmount = this.blankOptionalNumber(row.freightAmount);
snapshot.suspectedDuplicate = duplicateIds.has(String(row.id));
return snapshot;
});
@@ -1322,7 +1359,7 @@ export default {
}
},
async handleUpdate() {
await this.$confirm('将以外部账单数据更新运单及内部结算明细,是否继续?', '更新账单', {
await this.$confirm('将以外部账单数据更新内部结算明细,是否继续?', '更新账单', {
type: 'warning',
});
this.actionLoading = true;
@@ -1503,23 +1540,24 @@ export default {
this.form.reconciliationMode === 'cargo'
? await api.importCargo(this.currentId, file)
: await api.importVehicle(this.currentId, file);
const contentType = response.headers?.['content-type'] || response.data?.type || '';
if (contentType.includes('spreadsheetml') || contentType.includes('ms-excel')) {
downloadXls(
response.data,
`运输对账导入失败明细${this.$dayjs().format('YYYY-MM-DD HH-mm-ss')}.xlsx`
);
// 成功时 axios 拦截器可能已把 JSON Blob 解析成对象,不能再调 .text()
const consumed = await consumeImportResponse(response, {
businessName: '运输对账',
dayjsFormat: this.$dayjs().format('YYYY-MM-DD HH-mm-ss'),
});
if (consumed.kind === 'excel') {
downloadXls(consumed.blob, consumed.fileName);
this.$message.warning('部分数据导入失败,已下载失败明细');
} else {
const result = JSON.parse(await response.data.text());
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 || '外部账单导入失败');
if (!error?._notified) {
this.$message.error(await resolveImportErrorMessage(error));
}
}
},
async downloadTemplate(mode) {
@@ -1661,6 +1699,28 @@ export default {
isExternalRowEditing(row) {
return Boolean(this.externalEditing[this.externalRowKey(row)]);
},
cloneExternalEditRow(row) {
return {
...row,
feeItems: { ...(row.feeItems || this.parseFeeItems(row.feeItemsJson)) },
};
},
/** 编辑态绑定草稿,避免输入时实时改写列表导致疑似重复 Tab 刷新 */
externalEditTarget(row) {
const key = this.externalRowKey(row);
return this.externalEditDrafts[key] || row;
},
clearExternalEditState(key) {
const editing = { ...this.externalEditing };
const drafts = { ...this.externalEditDrafts };
const snapshots = { ...this.externalEditSnapshots };
delete editing[key];
delete drafts[key];
delete snapshots[key];
this.externalEditing = editing;
this.externalEditDrafts = drafts;
this.externalEditSnapshots = snapshots;
},
isExternalEditableColumn(prop) {
return !['externalLineNo', 'matchStatus'].includes(prop);
},
@@ -1672,34 +1732,30 @@ export default {
},
openExternalAdjust(row) {
const key = this.externalRowKey(row);
const draft = this.cloneExternalEditRow(row);
this.externalEditSnapshots = {
...this.externalEditSnapshots,
[key]: { ...row },
[key]: this.cloneExternalEditRow(row),
};
this.externalEditDrafts = { ...this.externalEditDrafts, [key]: draft };
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;
this.clearExternalEditState(this.externalRowKey(row));
},
async 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;
const draft = this.externalEditDrafts[key];
if (draft) {
const feeItems = { ...(draft.feeItems || {}) };
Object.assign(row, draft, { feeItems });
row.feeItemsJson = JSON.stringify(feeItems);
} else {
row.feeItemsJson = JSON.stringify(row.feeItems || {});
}
this.clearExternalEditState(key);
this.refreshLocalStats();
this.$message.success('调整已暂存');
this.$message.success('调整已暂存,匹配或完成对账后生效');
},
normalizeExternalDateTime(value) {
if (!value) return null;
@@ -1743,7 +1799,8 @@ export default {
differenceQuantity: Math.abs(internalQuantity - externalQuantity),
differenceAmount: Math.abs(internalAmount - externalAmount),
matchedCount,
unmatchedCount: internalUnmatched + externalUnmatched,
// 无法匹配按内部账单条数统计,不叠加外部未匹配数
unmatchedCount: internalUnmatched,
matchStatus:
this.internalDetails.length > 0 &&
this.internalDetails.length === this.externalDetails.length &&
@@ -1808,7 +1865,10 @@ export default {
: Number(value || 0).toFixed(2);
},
formatMileage(value) {
return Number(value) === -1 ? '' : this.displayValue(value);
return this.blankOptionalNumber(value) === null ? '' : this.displayValue(value);
},
formatOptionalMoney(value, currency = 'RMB') {
return this.blankOptionalNumber(value) === null ? '-' : this.formatMoney(value, currency);
},
formatDate(value) {
if (value === null || value === undefined || value === '') return '-';
@@ -1929,15 +1989,26 @@ export default {
}
.reconciliation-editor__filter {
display: grid;
grid-template-columns: repeat(4, minmax(160px, 1fr)) auto auto;
grid-template-columns: repeat(4, minmax(160px, 1fr)) auto;
gap: 8px;
margin-bottom: 12px;
align-items: center;
}
.reconciliation-editor__filter-item {
display: flex;
align-items: center;
min-width: 0;
}
.reconciliation-editor__filter-actions {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.reconciliation-editor__filter-actions .el-button {
min-width: 64px;
margin-left: 0;
}
.reconciliation-editor__filter-label {
flex: 0 0 auto;
margin-right: 8px;
+18 -11
View File
@@ -140,6 +140,7 @@ export default {
rows: [],
selection: [],
loading: false,
approveLoading: false,
syncLoading: false,
page: { currentPage: 1, pageSize: 10, pageSizes: [10, 20, 50, 100], total: 0 },
editor: { visible: false, id: null, readonly: false },
@@ -371,15 +372,21 @@ export default {
});
},
async handleApprove(row) {
await this.$confirm(
`审核通过后${this.billLabel}生效,金额只能通过结算调整单处理,确认通过?`,
'提示',
{ type: 'warning' }
);
// 防止重复点击:请求期间表格遮罩,后续点击直接忽略
if (this.loading) return;
this.loading = true;
// 防止重复点击:请求进行中忽略后续点击,避免重复审批和重复提示
if (this.approveLoading) return;
this.approveLoading = true;
let loading = null;
try {
await this.$confirm(
`审核通过后${this.billLabel}生效,金额只能通过结算调整单处理,确认通过?`,
'提示',
{ type: 'warning' }
);
loading = this.$loading({
lock: true,
text: '审批处理中,请稍候…',
background: 'rgba(255, 255, 255, 0.6)',
});
const res = await api.approve({ id: row.id });
// data=false 表示审批已通过但自动同步金蝶失败,msg 带具体原因
if (res.data.data === false) {
@@ -387,11 +394,11 @@ export default {
} else {
this.$message.success(res.data.msg || '审批通过');
}
await this.loadTable();
this.onLoad(this.page);
} finally {
this.loading = false;
loading?.close();
this.approveLoading = false;
}
this.onLoad(this.page);
},
async handleReturn(row) {
const { value } = await this.$prompt('请输入驳回原因', '审批驳回', {
+14 -6
View File
@@ -334,6 +334,7 @@ export default {
ArrowDown,
ArrowUp,
loading: false,
approveLoading: false,
query: emptyQuery(),
searchFields: preSettlementSearchFields,
searchExpanded: false,
@@ -691,16 +692,23 @@ export default {
});
},
async handleApprove(row) {
await this.$confirm('确认审批通过该预付结算单?', '提示', { type: 'warning' });
// 防止重复点击:请求期间表格遮罩,后续点击直接忽略
if (this.loading) return;
this.loading = true;
// 防止重复点击:请求进行中忽略后续点击,避免重复审批和重复提示
if (this.approveLoading) return;
this.approveLoading = true;
let loading = null;
try {
await this.$confirm('确认审批通过该预付结算单?', '提示', { type: 'warning' });
loading = this.$loading({
lock: true,
text: '审批处理中,请稍候…',
background: 'rgba(255, 255, 255, 0.6)',
});
await approve({ id: row.id });
this.$message.success('审批通过');
await this.loadTable();
this.loadTable();
} finally {
this.loading = false;
loading?.close();
this.approveLoading = false;
}
},
async handleReturn(row) {
@@ -6,15 +6,33 @@
</template>
<script setup>
import { computed } from 'vue';
import { computed, getCurrentInstance, nextTick, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import SettlementImportPage from './components/settlement-import-page.vue';
const route = useRoute();
const store = useStore();
const instance = getCurrentInstance();
const pageTitle = computed(() => {
if (route.query.mode === 'view') return '查看导入结算单';
return route.query.id ? '编辑导入结算单' : '新增导入结算单';
});
const syncTagTitle = () => {
nextTick(() => {
store.commit('SET_TAG', {
fullPath: route.fullPath,
name: pageTitle.value,
});
instance?.proxy?.$router?.$avueRouter?.setTitle?.(pageTitle.value);
});
};
onMounted(() => {
syncTagTitle();
});
</script>
<style scoped lang="scss">
+6 -3
View File
@@ -239,19 +239,22 @@ export default {
this.onLoad(this.page);
},
openCreate() {
this.$router.push({ path: '/settlement/settlement-import/form' });
this.$router.push({
path: '/settlement/settlement-import/form',
query: { name: '新增导入结算单' },
});
},
openDetail(row) {
if (!row?.id) return;
this.$router.push({
path: '/settlement/settlement-import/form',
query: { id: row.id, mode: 'view' },
query: { id: row.id, mode: 'view', name: '查看导入结算单' },
});
},
editDraft(row) {
this.$router.push({
path: '/settlement/settlement-import/form',
query: { id: row.id },
query: { id: row.id, name: '编辑导入结算单' },
});
},
async handleDelete(row) {