Files
tms-erp-web/src/views/settlement/components/pre-settlement-editor.vue
T
2026-08-28 02:10:54 +08:00

1663 lines
55 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<component
:is="editorContainer"
v-bind="editorContainerProps"
@update:model-value="visible = $event"
@closed="handleClosed"
>
<div v-loading="loading" class="pre-settlement-editor__content">
<section-card title="结算基本信息">
<el-form
ref="formRef"
:model="form"
:rules="formRules"
label-position="right"
label-width="auto"
class="pre-settlement-editor__form"
>
<el-row :gutter="24">
<el-col v-for="field in formFields" :key="field.prop" :span="field.span || 6">
<el-form-item :label="field.label" :prop="field.prop">
<el-select
v-if="field.type === 'contract'"
v-model="form.contractId"
filterable
remote
clearable
:remote-method="loadContractOptions"
:loading="contractLoading"
:disabled="!editable || details.length > 0"
placeholder="请选择合同"
@change="handleContractChange"
>
<el-option
v-for="item in contractOptions"
:key="item.id"
:label="`${item.contractName}${item.contractNo || '-'}`"
:value="item.id"
/>
</el-select>
<el-date-picker
v-else-if="field.type === 'date'"
v-model="form[field.prop]"
type="date"
value-format="YYYY-MM-DD"
format="YYYY-MM-DD"
:disabled="!editable || form.currency === 'RMB'"
placeholder="请选择"
@change="recalculateLocalAmount"
/>
<el-input-number
v-else-if="field.type === 'number'"
v-model="form[field.prop]"
:min="0"
:precision="6"
:controls="false"
:disabled="!editable || form.currency === 'RMB'"
@change="recalculateLocalAmount"
/>
<el-input
v-else-if="field.type === 'textarea'"
v-model="form[field.prop]"
type="textarea"
:rows="2"
:maxlength="field.maxlength"
show-word-limit
:disabled="!editable"
placeholder="请输入"
/>
<span v-else-if="field.prop === 'settlementType'" class="form-readonly">
{{ settlementTypeName }}
</span>
<span v-else-if="field.money" class="form-readonly">
{{ formatMoney(form[field.prop], moneyCurrency(field.prop)) }}
</span>
<span v-else class="form-readonly">{{ displayValue(form[field.prop]) }}</span>
</el-form-item>
</el-col>
</el-row>
</el-form>
</section-card>
<section-card title="结算合计">
<template #extra>
<el-button v-if="editable" type="primary" plain @click="addSummaryFee">
添加费用
</el-button>
</template>
<el-table :data="summaryFees" border>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column
v-for="column in summaryColumns"
:key="column.prop"
:label="column.label"
:prop="column.prop"
:min-width="column.minWidth"
align="center"
>
<template #default="{ row }">
<el-select
v-if="editable && column.prop === 'feeType' && row.manualFlag === 1"
v-model="row.feeType"
filterable
placeholder="请选择"
@change="handleManualFeeTypeChange(row)"
>
<el-option
v-for="item in feeOptions"
:key="item.feeType"
:label="item.feeTypeName || item.feeType"
:value="item.feeType"
/>
</el-select>
<el-select
v-else-if="editable && column.prop === 'feeItem' && row.manualFlag === 1"
v-model="row.feeItem"
filterable
placeholder="请选择"
>
<el-option
v-for="name in manualFeeItems(row.feeType)"
:key="name"
:label="name"
:value="name"
/>
</el-select>
<el-input-number
v-else-if="editable && column.prop === 'adjustAmount'"
v-model="row.adjustAmount"
:precision="2"
:controls="false"
@change="recalculateSummaryRow(row)"
/>
<el-input
v-else-if="editable && column.prop === 'remark'"
v-model="row.remark"
maxlength="50"
show-word-limit
/>
<span v-else-if="isSummaryMoney(column.prop)">
{{ formatMoney(row[column.prop], form.currency) }}
</span>
<span v-else-if="column.prop === 'feeType'">
{{ feeCategoryName(row.feeType) }}
</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
</el-table-column>
<el-table-column v-if="editable" label="操作" width="100" align="center">
<template #default="{ row, $index }">
<el-link v-if="row.manualFlag === 1" type="danger" @click="removeSummaryFee($index)">
删除
</el-link>
</template>
</el-table-column>
</el-table>
<div class="pre-settlement-editor__summary-total">
合计{{ formatMoney(summaryTotal, form.currency) }}
</div>
</section-card>
<section-card title="结算明细">
<template #extra>
<div class="pre-settlement-editor__section-actions">
<el-button
v-if="hasPermission('pre_settlement_export')"
type="primary"
plain
@click="exportDetails"
>
导出
</el-button>
<el-button v-if="editable" type="primary" plain @click="openCandidateDialog">
选择结算明细
</el-button>
<el-button type="primary" text @click="detailCollapsed = !detailCollapsed">
{{ detailCollapsed ? '展开明细' : '收起明细' }}
</el-button>
</div>
</template>
<el-form
:model="detailQuery"
inline
label-position="right"
label-width="auto"
class="pre-settlement-editor__detail-filter"
@submit.prevent
>
<el-form-item label="单据号">
<el-input
v-model="detailQuery.documentNo"
clearable
placeholder="请输入"
@keyup.enter="handleDetailQuery"
/>
</el-form-item>
<el-form-item label="运单号">
<el-input
v-model="detailQuery.waybillNo"
clearable
placeholder="请输入"
@keyup.enter="handleDetailQuery"
/>
</el-form-item>
<el-form-item label="批次号">
<el-input
v-model="detailQuery.batchNo"
clearable
placeholder="请输入"
@keyup.enter="handleDetailQuery"
/>
</el-form-item>
<el-form-item label="货物名称">
<el-input
v-model="detailQuery.cargoName"
clearable
placeholder="请输入"
@keyup.enter="handleDetailQuery"
/>
</el-form-item>
<el-form-item class="pre-settlement-editor__detail-filter-actions">
<el-button type="primary" @click="handleDetailQuery">查询</el-button>
<el-button @click="resetDetailQuery">重置</el-button>
</el-form-item>
</el-form>
<div v-show="!detailCollapsed">
<el-table :data="filteredDetails" border class="pre-settlement-editor__detail-table">
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column
v-for="column in visibleDetailColumns"
:key="column.prop"
:label="column.label"
:prop="column.prop"
:min-width="column.minWidth"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-link
v-if="column.link && row[column.prop]"
type="primary"
@click="openAdjustDialog(row, true)"
>
{{ row[column.prop] }}
</el-link>
<span v-else-if="column.money">
{{ formatMoney(row[column.prop], row.currency || form.currency) }}
</span>
<span v-else-if="column.prop === 'transportQuantityText'">
{{ formatQuantity(row) }}
</span>
<span v-else-if="pageMode && column.prop === 'mileage'">
{{ formatDetailMileage(row[column.prop]) }}
</span>
<span v-else-if="pageMode && column.prop === 'transportType'">
{{ transportTypeName(row[column.prop]) }}
</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
</el-table-column>
<el-table-column
v-for="feeItem in detailFeeItemNames"
:key="`fee-${feeItem}`"
:label="feeItem"
min-width="130"
align="center"
>
<template #default="{ row }">
{{
formatMoney(
parseFeeItems(row.feeItemsJson)[feeItem],
row.currency || form.currency
)
}}
</template>
</el-table-column>
<el-table-column label="操作" :width="editable ? 150 : 90" fixed="right" align="center">
<template #default="{ row }">
<div class="pre-settlement-editor__links">
<el-link type="primary" @click="openAdjustDialog(row, !canAdjustDetail)">
{{ canAdjustDetail ? '调整' : '查看' }}
</el-link>
<el-link v-if="editable" type="danger" @click="removeDetailRow(row)">
踢出
</el-link>
</div>
</template>
</el-table-column>
</el-table>
</div>
</section-card>
<section-card title="附件">
<div class="pre-settlement-editor__attachment">
<div class="pre-settlement-editor__attachment-actions">
<el-button type="primary" :disabled="!attachments.length" @click="downloadAttachments">
批量下载
</el-button>
</div>
<el-table :data="attachments" border @selection-change="handleAttachmentSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名" min-width="260" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
</template>
</el-table-column>
<el-table-column label="文件大小" width="120" align="center">
<template #default="{ row }">{{ formatFileSize(row.size) }}</template>
</el-table-column>
<el-table-column prop="uploadUserName" label="上传人" width="140" align="center" />
<el-table-column
prop="uploadTime"
label="上传时间"
width="170"
align="center"
sortable
/>
<el-table-column label="操作" :width="editable ? 150 : 80" fixed="right" align="center">
<template #default="{ row, $index }">
<div class="pre-settlement-editor__links">
<el-link type="primary" @click="downloadAttachment(row)">下载</el-link>
<el-link v-if="editable" type="danger" @click="removeAttachment($index)">
删除
</el-link>
</div>
</template>
</el-table-column>
</el-table>
<div class="pre-settlement-editor__attachment-upload">
<vehicle-attachment-upload
ref="attachmentUploadRef"
v-model="attachments"
:readonly="!editable"
:multiple="true"
:limit="20"
:max-size="500"
:file-types="attachmentFileTypes"
:show-file-list="false"
button-text="上传附件"
@change="handleAttachmentChange"
/>
</div>
</div>
</section-card>
<section-card v-if="form.id && readonly" title="预付信息">
<el-table :data="advances" border>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column
v-for="column in advanceTableColumns"
:key="column.prop"
:label="column.label"
:prop="column.prop"
:min-width="column.minWidth"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<span v-if="['appliedAmount', 'paidAmount'].includes(column.prop)">
{{ formatMoney(row[column.prop], form.currency) }}
</span>
<span v-else-if="column.prop === 'billStatus'">
{{ advanceStatusName(row.billStatus) }}
</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
</el-table-column>
</el-table>
</section-card>
<section-card v-if="form.id && readonly" title="变更记录">
<el-table :data="changeRecords" border>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column
v-for="column in changeTableColumns"
:key="column.prop"
:label="column.label"
:prop="column.prop"
:min-width="column.minWidth"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<span v-if="column.prop === 'lineNo'">{{ changeRecordLineNo(row) }}</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
</el-table-column>
</el-table>
</section-card>
</div>
<template v-if="!pageMode" #footer>
<el-button @click="visible = false">取消</el-button>
<el-button v-if="editable" type="primary" plain :loading="saving" @click="saveDraft(false)">保存</el-button>
<el-button
v-if="editable && hasPermission('pre_settlement_submit')"
type="primary"
:loading="submitting"
@click="saveDraft(true)"
>
提交
</el-button>
</template>
<div v-if="pageMode" class="pre-settlement-editor__page-actions">
<el-button @click="visible = false">取消</el-button>
<el-button v-if="editable" type="primary" plain :loading="saving" @click="saveDraft(false)">保存</el-button>
<el-button
v-if="editable && hasPermission('pre_settlement_submit')"
type="primary"
:loading="submitting"
@click="saveDraft(true)"
>
提交
</el-button>
</div>
</component>
<el-dialog
v-model="candidateDialog.visible"
title="选择结算明细"
width="92%"
append-to-body
destroy-on-close
class="pre-settlement-candidate-dialog"
>
<section-card title="选择结算明细">
<el-form :model="candidateQuery" inline label-position="right" label-width="auto">
<el-form-item label="批次号">
<el-input v-model="candidateQuery.batchNo" clearable placeholder="请输入" />
</el-form-item>
<el-form-item label="费用日期">
<el-date-picker
v-model="candidateQuery.feeDateRange"
type="daterange"
value-format="YYYY-MM-DD"
format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadCandidates">查询</el-button>
<el-button @click="resetCandidateQuery">重置</el-button>
</el-form-item>
</el-form>
</section-card>
<el-table
ref="candidateTableRef"
v-loading="candidateDialog.loading"
:data="candidateRows"
border
@selection-change="handleCandidateSelection"
>
<el-table-column type="selection" width="52" fixed="left" align="center" />
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column
v-for="column in candidateColumns"
:key="column.prop"
:label="column.label"
:prop="column.prop"
:min-width="column.minWidth"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<span v-if="column.money">{{ formatMoney(row[column.prop], row.currency) }}</span>
<span v-else-if="column.prop === 'transportType'">
{{ transportTypeName(row[column.prop]) }}
</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
</el-table-column>
</el-table>
<div class="pre-settlement-editor__pagination">
<el-pagination
v-model:current-page="candidatePage.current"
v-model:page-size="candidatePage.size"
:total="candidatePage.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadCandidates"
@size-change="handleCandidateSizeChange"
/>
</div>
<template #footer>
<el-button @click="candidateDialog.visible = false">取消</el-button>
<el-button type="primary" :loading="candidateDialog.confirming" @click="confirmCandidates">
确认
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="adjustDialog.visible"
title="结算明细调整"
width="96%"
append-to-body
destroy-on-close
>
<el-table v-loading="adjustDialog.loading" :data="adjustRows" border>
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column prop="cargoName" label="货物名称" min-width="140" align="center" />
<el-table-column prop="cargoType" label="货物类型" min-width="130" align="center" />
<el-table-column label="运输总量" min-width="150" align="center">
<template #default="{ row }">
<span v-if="adjustDialog.readonly">{{ formatQuantity(row) }}</span>
<el-input-number
v-else
v-model="row.transportQuantity"
:min="0"
:precision="2"
:controls="false"
/>
</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"
:disabled="adjustDialog.readonly"
/>
</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"
:disabled="adjustDialog.readonly"
/>
</template>
</el-table-column>
<el-table-column label="运费" min-width="140" align="center">
<template #default="{ row }">
<el-input-number
v-model="row.freightAmount"
:min="0"
:precision="2"
:controls="false"
:disabled="adjustDialog.readonly"
@change="recalculateAdjustRow(row, 'freight')"
/>
</template>
</el-table-column>
<el-table-column
v-for="feeItem in adjustFeeItemNames"
:key="feeItem"
:label="feeItem"
min-width="140"
align="center"
>
<template #default="{ row }">
<el-input-number
v-model="row.feeItems[feeItem]"
:min="0"
:precision="2"
:controls="false"
:disabled="adjustDialog.readonly"
@change="recalculateAdjustRow(row, feeItem)"
/>
</template>
</el-table-column>
<el-table-column label="结算金额(含税)" min-width="170" align="center">
<template #default="{ row }">
<el-input-number
v-model="row.settlementAmountTax"
:precision="2"
:controls="false"
:disabled="adjustDialog.readonly"
/>
</template>
</el-table-column>
<el-table-column label="备注" min-width="200" align="center">
<template #default="{ row }">
<el-input v-model="row.remark" maxlength="200" :disabled="adjustDialog.readonly" />
</template>
</el-table-column>
</el-table>
<el-form
v-if="!adjustDialog.readonly"
:model="adjustDialog"
label-position="right"
label-width="auto"
class="pre-settlement-editor__adjust-reason"
>
<el-form-item label="调整原因">
<el-input v-model="adjustDialog.reason" maxlength="200" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="adjustDialog.visible = false">取消</el-button>
<el-button
v-if="!adjustDialog.readonly"
type="primary"
:loading="adjustDialog.saving"
@click="saveAdjustment"
>
保存
</el-button>
</template>
</el-dialog>
</template>
<script>
import { mapGetters } from 'vuex';
import {
adjustDetail,
getCandidateDetails,
getContractOptions,
getDetail,
getDetailFees,
getFeeOptions,
removeDetail,
save,
submit,
} from '@/api/settlement/preSettlement';
import { getDictionary } from '@/api/system/dictbiz';
import {
emptyPreSettlementForm,
preSettlementFormFields,
preSettlementFormRules,
} from '@/option/settlement/preSettlementForm';
import {
advanceColumns,
candidateDetailColumns,
changeRecordColumns,
settlementDetailColumns,
settlementSummaryColumns,
} from '@/option/settlement/preSettlementTable';
import { downloadFileByUrl } from '@/utils/util';
import * as XLSX from 'xlsx';
export default {
name: 'PreSettlementEditor',
props: {
modelValue: {
type: Boolean,
default: false,
},
recordId: {
type: [String, Number],
default: '',
},
readonly: {
type: Boolean,
default: false,
},
pageMode: {
type: Boolean,
default: false,
},
initialData: {
type: Object,
default: null,
},
},
emits: ['update:modelValue', 'success'],
data() {
return {
loading: false,
saving: false,
submitting: false,
form: emptyPreSettlementForm(),
formFields: preSettlementFormFields,
formRules: preSettlementFormRules,
summaryColumns: settlementSummaryColumns,
detailColumns: settlementDetailColumns,
advanceTableColumns: advanceColumns,
changeTableColumns: changeRecordColumns,
candidateColumns: candidateDetailColumns,
contractLoading: false,
contractOptions: [],
feeOptions: [],
feeCategoryOptions: [],
transportTypeOptions: [],
summaryFees: [],
details: [],
detailCollapsed: false,
detailQuery: {
documentNo: '',
waybillNo: '',
batchNo: '',
cargoName: '',
},
appliedDetailQuery: {
documentNo: '',
waybillNo: '',
batchNo: '',
cargoName: '',
},
advances: [],
changeRecords: [],
attachments: [],
selectedAttachmentRows: [],
candidateDialog: {
visible: false,
loading: false,
confirming: false,
},
candidateQuery: {
batchNo: '',
feeDateRange: [],
},
candidateRows: [],
candidateSelection: [],
candidatePage: {
current: 1,
size: 10,
total: 0,
},
adjustDialog: {
visible: false,
loading: false,
saving: false,
readonly: false,
detailId: '',
reason: '',
},
adjustRows: [],
attachmentFileTypes: [
'pdf',
'bmp',
'jpeg',
'png',
'jpg',
'doc',
'docx',
'ppt',
'pptx',
'xlsx',
'xls',
'eml',
'msg',
'zip',
],
};
},
computed: {
...mapGetters(['permission', 'userInfo']),
visible: {
get() {
return this.modelValue;
},
set(value) {
this.$emit('update:modelValue', value);
},
},
editorContainer() {
return this.pageMode ? 'div' : 'el-dialog';
},
editorContainerProps() {
if (this.pageMode) {
return {
class: ['pre-settlement-editor', 'pre-settlement-editor--page'],
};
}
return {
modelValue: this.visible,
title: this.dialogTitle,
width: '96%',
top: '2vh',
appendToBody: true,
destroyOnClose: true,
class: 'pre-settlement-editor',
};
},
editable() {
return !this.readonly && ['draft', 'returned'].includes(this.form.approvalStatus);
},
isAdmin() {
const authority = this.userInfo?.authority;
return Array.isArray(authority)
? authority.includes('admin')
: String(authority || '').includes('admin');
},
canAdjustDetail() {
return this.editable && this.hasPermission('pre_settlement_adjust');
},
dialogTitle() {
if (this.readonly) return '查看预结算单';
return this.form.id ? '编辑预结算单' : '新增预结算单';
},
settlementTypeName() {
return this.form.settlementType === 'receivable' ? '应收' : '应付';
},
summaryTotal() {
if (this.summaryFees.length) {
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
);
},
visibleDetailColumns() {
if (!this.pageMode) return this.detailColumns;
return this.detailColumns.filter(column => column.prop !== 'settlementAmountNoTax');
},
detailFeeItemNames() {
const names = new Set();
this.details.forEach(row => {
Object.keys(this.parseFeeItems(row.feeItemsJson)).forEach(name => names.add(name));
});
return Array.from(names);
},
filteredDetails() {
return this.details.filter(row =>
Object.entries(this.appliedDetailQuery).every(([field, keyword]) => {
if (!keyword?.trim()) return true;
return String(row[field] || '')
.toLocaleLowerCase()
.includes(keyword.trim().toLocaleLowerCase());
})
);
},
adjustFeeItemNames() {
const names = new Set();
this.adjustRows.forEach(row => {
Object.keys(row.feeItems || {}).forEach(name => names.add(name));
});
return Array.from(names);
},
},
watch: {
modelValue: {
immediate: true,
handler(value) {
if (value) this.initialize();
},
},
summaryTotal(value) {
this.form.settlementAmount = Number(value || 0).toFixed(2);
this.recalculateLocalAmount();
},
},
methods: {
hasPermission(code) {
return this.isAdmin || this.validData(this.permission?.[code], false);
},
async initialize() {
this.resetEditor();
await this.loadContractOptions('');
await this.loadFeeOptions();
await this.loadFeeCategoryOptions();
if (this.pageMode) await this.loadTransportTypeOptions();
if (this.recordId) await this.loadDetail();
else if (this.initialData) this.applyInitialData();
},
applyInitialData() {
const rows = Array.isArray(this.initialData?.rows) ? this.initialData.rows : [];
if (!rows.length) return;
const first = rows[0];
const settlementType = this.initialData.settlementType || first.settlementType || 'payable';
const contractId = first.contractId || '';
if (!this.contractOptions.some(item => String(item.id) === String(contractId))) {
this.contractOptions.push({
id: contractId,
contractNo: first.contractNo,
contractName: first.contractName,
projectId: first.projectId,
projectName: first.projectName,
deptId: first.deptId,
deptName: first.deptName,
settlementType,
partyA: settlementType === 'receivable' ? first.payeeName : first.payerName,
partyB: settlementType === 'receivable' ? first.payerName : first.payeeName,
});
}
this.handleContractChange(contractId);
Object.assign(this.form, {
contractId,
contractNo: first.contractNo || this.form.contractNo,
contractName: first.contractName || this.form.contractName,
projectId: first.projectId || this.form.projectId,
projectName: first.projectName || this.form.projectName,
deptId: first.deptId || this.form.deptId,
deptName: first.deptName || this.form.deptName,
payerName: first.payerName || this.form.payerName,
payeeName: first.payeeName || this.form.payeeName,
settlementType,
currency: first.currency || 'RMB',
localCurrency: first.localCurrency || 'RMB',
});
this.details = rows.map(row => ({
...row,
sourceDetailId: row.sourceDetailId || row.id,
settlementAmountTax:
row.settlementAmountTax ?? row.totalAmount ?? row.afterAmount ?? row.settlementAmount,
feeItemsJson: row.feeItemsJson || JSON.stringify(row.feeItems || {}),
}));
this.buildSummaryFeesFromDetails();
},
resetEditor() {
this.form = emptyPreSettlementForm();
this.summaryFees = [];
this.details = [];
this.detailCollapsed = false;
this.detailQuery = {
documentNo: '',
waybillNo: '',
batchNo: '',
cargoName: '',
};
this.appliedDetailQuery = { ...this.detailQuery };
this.advances = [];
this.changeRecords = [];
this.attachments = [];
this.selectedAttachmentRows = [];
this.$nextTick(() => this.$refs.formRef?.clearValidate());
},
async loadDetail() {
this.loading = true;
try {
const { data } = await getDetail(this.form.id || this.recordId);
const detail = data?.data || {};
this.form = { ...emptyPreSettlementForm(), ...detail };
this.summaryFees = (detail.summaryFees || []).map(row => ({ ...row }));
this.details = (detail.details || []).map(row => ({ ...row }));
this.advances = detail.advances || [];
this.changeRecords = detail.changeRecords || [];
this.attachments = this.parseAttachments(detail.attachmentsJson);
this.selectedAttachmentRows = [];
if (
detail.contractId &&
!this.contractOptions.some(item => item.id === detail.contractId)
) {
this.contractOptions.push({
id: detail.contractId,
contractNo: detail.contractNo,
contractName: detail.contractName,
projectId: detail.projectId,
projectName: detail.projectName,
deptId: detail.deptId,
deptName: detail.deptName,
partyA: detail.payerName,
partyB: detail.payeeName,
});
}
} finally {
this.loading = false;
}
},
async loadContractOptions(keyword = '') {
this.contractLoading = true;
try {
const { data } = await getContractOptions(keyword);
this.contractOptions = data?.data || [];
} finally {
this.contractLoading = false;
}
},
async loadFeeOptions() {
const { data } = await getFeeOptions();
this.feeOptions = data?.data || [];
},
async loadFeeCategoryOptions() {
const { data } = await getDictionary({ code: 'fee_category' });
this.feeCategoryOptions = data?.data || [];
},
async loadTransportTypeOptions() {
const { data } = await getDictionary({ code: 'transport_type' });
this.transportTypeOptions = data?.data || [];
},
handleContractChange(id) {
const contract = this.contractOptions.find(item => String(item.id) === String(id));
if (!contract) {
this.form.contractNo = '';
this.form.contractName = '';
this.form.projectId = '';
this.form.projectName = '';
this.form.deptId = '';
this.form.deptName = '';
this.form.payerName = '';
this.form.payeeName = '';
this.summaryFees = [];
return;
}
this.form.contractNo = contract.contractNo;
this.form.contractName = contract.contractName;
this.form.projectId = contract.projectId;
this.form.projectName = contract.projectName;
this.form.deptId = contract.deptId;
this.form.deptName = contract.deptName;
this.form.settlementType = contract.settlementType || 'payable';
if (this.form.settlementType === 'receivable') {
this.form.payerName = contract.partyB;
this.form.payeeName = contract.partyA;
} else {
this.form.payerName = contract.partyA;
this.form.payeeName = contract.partyB;
}
this.summaryFees = [];
},
async saveDraft(shouldSubmit) {
await this.$refs.formRef?.validate();
if (shouldSubmit && !this.details.length) {
this.$message.warning('请至少选择一条结算明细');
return;
}
const invalidManualFeeIndex = this.summaryFees.findIndex(
row =>
Number(row.manualFlag) === 1 &&
(!String(row.feeType || '').trim() || !String(row.feeItem || '').trim())
);
if (invalidManualFeeIndex >= 0) {
this.$message.warning(`请完善结算合计第${invalidManualFeeIndex + 1}行的费用类型和费用项`);
return;
}
const stateKey = shouldSubmit ? 'submitting' : 'saving';
this[stateKey] = true;
try {
const { data } = await save(this.buildSavePayload());
this.form.id = data?.data || this.form.id;
if (shouldSubmit) {
await submit({ id: this.form.id });
this.$message.success('审批流程已发起');
this.visible = false;
this.$emit('success', this.form.id);
return;
}
this.$message.success('保存成功');
await this.loadDetail();
this.$emit('success', this.form.id);
} finally {
this[stateKey] = false;
}
},
buildSavePayload() {
return {
id: this.form.id || undefined,
contractId: this.form.contractId,
exchangeRateDate: this.form.exchangeRateDate || undefined,
exchangeRate: this.form.exchangeRate,
attachmentsJson: JSON.stringify(this.attachments || []),
remark: this.form.remark,
sourceDetailIds: this.details.map(row => row.sourceDetailId || row.id),
summaryFees: this.summaryFees.map(row => ({
id: row.id || undefined,
feeType: row.feeType || '',
feeItem: row.feeItem,
originalAmount: Number(row.originalAmount || 0),
adjustAmount: Number(row.adjustAmount || 0),
remark: row.remark,
manualFlag: row.manualFlag || 0,
})),
};
},
addSummaryFee() {
this.summaryFees.push({
id: '',
feeType: '',
feeItem: '',
originalAmount: 0,
adjustAmount: 0,
settlementAmount: 0,
remark: '',
manualFlag: 1,
});
},
handleManualFeeTypeChange(row) {
if (!this.manualFeeItems(row.feeType).includes(row.feeItem)) row.feeItem = '';
},
manualFeeItems(feeType) {
return this.feeOptions.find(item => item.feeType === feeType)?.feeItems || [];
},
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?.dictValue || value;
},
transportTypeName(value) {
if (value === undefined || value === null || value === '') return '';
const normalizedValue = String(value).trim().toLocaleLowerCase();
const option = this.transportTypeOptions.find(
item =>
String(item.dictKey ?? '')
.trim()
.toLocaleLowerCase() === normalizedValue ||
String(item.dictValue ?? '')
.trim()
.toLocaleLowerCase() === normalizedValue
);
return option?.dictValue || value;
},
formatDetailMileage(value) {
if (value === undefined || value === null || value === '' || Number(value) === -1) return '';
return value;
},
removeSummaryFee(index) {
this.summaryFees.splice(index, 1);
},
recalculateSummaryRow(row) {
row.settlementAmount = Number(row.originalAmount || 0) + Number(row.adjustAmount || 0);
},
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 feeOption = this.feeOptions.find(item =>
(item.feeItems || []).some(name => String(name) === String(feeItem))
);
const feeType = feeOption?.feeType || fallbackFeeType || '';
const key = `${feeType}\u0000${feeItem}`;
const current = summaryMap.get(key) || {
id: '',
feeType,
feeItem,
originalAmount: 0,
adjustAmount: 0,
settlementAmount: 0,
remark: '',
manualFlag: 0,
};
current.originalAmount = Number((current.originalAmount + amount).toFixed(2));
current.settlementAmount = current.originalAmount;
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
);
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);
this.summaryFees = [...summaryMap.values(), ...manualFees];
this.form.settlementAmount = this.summaryFees
.reduce((total, row) => total + Number(row.settlementAmount || 0), 0)
.toFixed(2);
this.recalculateLocalAmount();
},
recalculateLocalAmount() {
const rate = this.form.currency === 'RMB' ? 1 : Number(this.form.exchangeRate || 0);
this.form.localSettlementAmount = (Number(this.form.settlementAmount || 0) * rate).toFixed(2);
},
openCandidateDialog() {
if (!this.form.contractId) {
this.$message.warning('请先选择合同名称');
return;
}
this.candidateDialog.visible = true;
this.candidatePage.current = 1;
this.candidateSelection = [];
this.loadCandidates();
},
async loadCandidates() {
this.candidateDialog.loading = true;
try {
const dateRange = this.candidateQuery.feeDateRange || [];
const { data } = await getCandidateDetails(
this.candidatePage.current,
this.candidatePage.size,
{
contractId: this.form.contractId,
settlementType: this.form.settlementType,
batchNo: this.candidateQuery.batchNo,
feeStartDate: dateRange[0],
feeEndDate: dateRange[1],
}
);
const page = data?.data || {};
this.candidateRows = page.records || [];
this.candidatePage.total = Number(page.total || 0);
} finally {
this.candidateDialog.loading = false;
}
},
resetCandidateQuery() {
this.candidateQuery = { batchNo: '', feeDateRange: [] };
this.candidatePage.current = 1;
this.loadCandidates();
},
handleCandidateSelection(rows) {
this.candidateSelection = rows;
},
handleCandidateSizeChange() {
this.candidatePage.current = 1;
this.loadCandidates();
},
async confirmCandidates() {
if (!this.candidateSelection.length) {
this.$message.warning('请选择结算明细');
return;
}
const existingIds = new Set(this.details.map(row => String(row.sourceDetailId || row.id)));
this.candidateSelection.forEach(row => {
if (!existingIds.has(String(row.id))) {
this.details.push({ ...row, sourceDetailId: row.id });
}
});
this.candidateDialog.confirming = true;
try {
const { data } = await save(this.buildSavePayload());
this.form.id = data?.data || this.form.id;
await this.loadDetail();
this.candidateDialog.visible = false;
this.$message.success('结算明细添加成功');
this.$emit('success');
} finally {
this.candidateDialog.confirming = false;
}
},
async removeDetailRow(row) {
await this.$confirm('确认将该明细踢出预结算单?', '提示', { type: 'warning' });
if (!this.form.id || !row.id || row.id === row.sourceDetailId) {
this.details = this.details.filter(item => item !== row);
this.buildSummaryFeesFromDetails();
return;
}
await removeDetail(this.form.id, row.id);
this.$message.success('已移除');
await this.loadDetail();
this.$emit('success', this.form.id);
},
handleDetailQuery() {
this.appliedDetailQuery = { ...this.detailQuery };
},
resetDetailQuery() {
this.detailQuery = {
documentNo: '',
waybillNo: '',
batchNo: '',
cargoName: '',
};
this.appliedDetailQuery = { ...this.detailQuery };
},
async openAdjustDialog(row, readonly) {
if (!row.id || row.id === row.sourceDetailId) {
this.$message.warning('请先保存预结算单');
return;
}
this.adjustDialog.visible = true;
this.adjustDialog.loading = true;
this.adjustDialog.readonly = readonly;
this.adjustDialog.detailId = row.id;
this.adjustDialog.reason = '';
try {
const { data } = await getDetailFees(row.id);
this.adjustRows = (data?.data || []).map(item => ({
...item,
feeItems: this.parseFeeItems(item.feeItemsJson),
}));
} finally {
this.adjustDialog.loading = false;
}
},
recalculateAdjustRow(row, changedField) {
if (changedField && changedField !== 'freight' && this.isFreightFeeItem(changedField)) {
row.freightAmount = Number(row.feeItems[changedField] || 0);
}
if (changedField === 'freight') {
const freightItem = Object.keys(row.feeItems || {}).find(this.isFreightFeeItem);
if (freightItem) row.feeItems[freightItem] = Number(row.freightAmount || 0);
}
const feeItemTotal = Object.values(row.feeItems || {}).reduce(
(total, value) => total + Number(value || 0),
0
);
const hasFreightItem = Object.keys(row.feeItems || {}).some(this.isFreightFeeItem);
row.settlementAmountTax = Number(
(hasFreightItem ? feeItemTotal : Number(row.freightAmount || 0) + feeItemTotal).toFixed(2)
);
},
isFreightFeeItem(name) {
return String(name || '').includes('运费') || String(name || '').includes('运输费');
},
async saveAdjustment() {
if (!this.adjustDialog.reason.trim()) {
this.$message.warning('请输入调整原因');
return;
}
this.adjustDialog.saving = true;
try {
await adjustDetail({
detailId: this.adjustDialog.detailId,
changeReason: this.adjustDialog.reason,
rows: this.adjustRows.map(row => ({
id: row.id,
transportQuantity: row.transportQuantity,
mileage: row.mileage,
unitPrice: row.unitPrice,
freightAmount: row.freightAmount,
feeItems: row.feeItems,
settlementAmountTax: row.settlementAmountTax,
settlementAmountNoTax: row.settlementAmountNoTax,
remark: row.remark,
})),
});
this.adjustDialog.visible = false;
this.$message.success('结算明细调整成功');
await this.loadDetail();
this.$emit('success', this.form.id);
} finally {
this.adjustDialog.saving = false;
}
},
exportDetails() {
if (!this.filteredDetails.length) {
this.$message.warning('暂无可导出的结算明细');
return;
}
const dynamicFeeItems = this.detailFeeItemNames;
const rows = this.filteredDetails.map((row, index) => {
const feeItems = this.parseFeeItems(row.feeItemsJson);
const result = {
序号: index + 1,
单据号: row.documentNo,
运单号: row.waybillNo,
车号: row.vehicleNo,
发货地址: row.departureAddress,
到货地址: row.arrivalAddress,
实际发货时间: row.actualDepartureTime,
实际完成时间: row.actualCompletionTime,
运输类型: row.transportType,
货物名称: row.cargoName,
货物类型: row.cargoType,
运输总量: this.formatQuantity(row),
'里程(KM': row.mileage,
批次号: row.batchNo,
运输单价: this.formatMoney(row.unitPrice, row.currency || this.form.currency),
运费: this.formatMoney(row.freightAmount, row.currency || this.form.currency),
};
dynamicFeeItems.forEach(name => {
result[name] = this.formatMoney(feeItems[name], row.currency || this.form.currency);
});
result['结算金额(含税)'] = this.formatMoney(
row.settlementAmountTax,
row.currency || this.form.currency
);
result['结算金额(不含税)'] = this.formatMoney(
row.settlementAmountNoTax,
row.currency || this.form.currency
);
result.备注 = row.remark;
return result;
});
const sheet = XLSX.utils.json_to_sheet(rows);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, sheet, '结算明细');
XLSX.writeFile(
workbook,
`${this.form.preSettlementNo || '预结算单'}结算明细${this.$dayjs().format(
'YYYY-MM-DD HH-mm-ss'
)}.xlsx`
);
},
handleAttachmentChange(list) {
const uploadUserName = this.userInfo?.realName || this.userInfo?.userName || '';
const uploadTime = this.$dayjs().format('YYYY-MM-DD HH:mm:ss');
this.attachments = (list || []).map(file => ({
...file,
uploadUserName: file.uploadUserName || uploadUserName,
uploadTime: file.uploadTime || uploadTime,
}));
this.selectedAttachmentRows = [];
},
handleAttachmentSelectionChange(rows) {
this.selectedAttachmentRows = rows || [];
},
attachmentUrl(file = {}) {
return file.url || file.link || file.fileUrl || file.domain || '';
},
attachmentName(file = {}) {
return file.originalName || file.name || file.fileName || '附件';
},
formatFileSize(size) {
const bytes = Number(size);
if (!bytes) return '-';
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
},
previewAttachment(file) {
this.$refs.attachmentUploadRef?.handlePreview(file);
},
downloadAttachment(file) {
const url = this.attachmentUrl(file);
if (!url) {
this.$message.warning('附件地址为空');
return;
}
downloadFileByUrl(url, this.attachmentName(file));
},
downloadAttachments() {
const files = this.selectedAttachmentRows.length
? this.selectedAttachmentRows
: this.attachments;
files.forEach((file, index) => {
window.setTimeout(() => this.downloadAttachment(file), index * 200);
});
},
removeAttachment(index) {
this.attachments.splice(index, 1);
this.selectedAttachmentRows = this.selectedAttachmentRows.filter(file =>
this.attachments.includes(file)
);
},
parseAttachments(value) {
if (!value) return [];
if (Array.isArray(value)) return value;
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
return [];
}
},
parseFeeItems(value) {
if (!value) return {};
if (typeof value === 'object') return { ...value };
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (error) {
return {};
}
},
formatMoney(value, currency = 'RMB') {
if (value === undefined || value === null || value === '') return '-';
const amount = Number(value);
if (!Number.isFinite(amount)) return '-';
return `${amount.toFixed(2)} ${currency || 'RMB'}`;
},
formatQuantity(row) {
const value = row.transportQuantity;
if (value === undefined || value === null || value === '') return '-';
const quantity = Number(value);
if (!Number.isFinite(quantity)) return '-';
return `${quantity.toFixed(2)}${row.quantityUnit ? ` ${row.quantityUnit}` : ''}`;
},
displayValue(value) {
return value === undefined || value === null || value === '' ? '-' : value;
},
advanceStatusName(status) {
return (
{
reviewing: '审批中',
approved: '审批通过',
paid: '已付款',
returned: '已驳回',
voided: '已作废',
}[status] || this.displayValue(status)
);
},
changeRecordLineNo(row) {
if (row.changeType !== '结算明细项') return '';
if (row.lineNo !== undefined && row.lineNo !== null && row.lineNo !== '') return row.lineNo;
return '';
},
isSummaryMoney(prop) {
return ['originalAmount', 'adjustAmount', 'settlementAmount'].includes(prop);
},
moneyCurrency(prop) {
return prop === 'localSettlementAmount' ? this.form.localCurrency : this.form.currency;
},
handleClosed() {
this.resetEditor();
},
},
};
</script>
<style lang="scss" scoped>
.pre-settlement-editor {
&__content {
max-height: 82vh;
overflow-y: auto;
}
&--page &__content {
max-height: none;
overflow: visible;
}
&__page-actions {
display: flex;
justify-content: flex-end;
padding: 12px 24px;
border-top: 1px solid #eff1f7;
position: fixed;
right: 0;
left: 230px;
bottom: 0;
margin: 0;
z-index: 10;
background: #fff;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
}
&__form {
:deep(.el-select),
:deep(.el-date-editor),
:deep(.el-input-number) {
width: 100%;
}
}
&__section-actions,
&__links {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
&__detail-filter {
display: flex;
flex-wrap: wrap;
gap: 0 24px;
margin-bottom: 12px;
:deep(.el-form-item) {
margin-bottom: 0;
}
:deep(.el-input) {
width: 220px;
}
}
&__detail-filter-actions {
margin-left: auto;
}
&__attachment-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-bottom: 12px;
}
&__attachment-upload {
display: flex;
justify-content: flex-start;
margin-top: 12px;
:deep(.vehicle-attachment-upload) {
width: auto;
}
:deep(.el-upload) {
display: inline-flex;
}
}
&__summary-total {
padding: 12px 16px 0;
text-align: right;
font-weight: 600;
}
&__pagination {
display: flex;
justify-content: flex-end;
padding-top: 12px;
}
&__adjust-reason {
margin-top: 16px;
}
.form-readonly {
min-height: 32px;
line-height: 32px;
color: #303133;
}
}
.dialog-section-title {
display: flex;
align-items: center;
min-height: 22px;
font-size: 16px;
font-weight: 600;
&::before {
width: 4px;
height: 18px;
margin-right: 8px;
background: #409eff;
content: '';
}
}
:deep(.pre-settlement-editor .el-dialog__body) {
padding: 12px 16px;
}
:deep(.pre-settlement-editor .el-form-item) {
margin-bottom: 14px;
}
:deep(.pre-settlement-editor .el-table),
:deep(.pre-settlement-candidate-dialog .el-table) {
--el-table-border-color: #eff1f7;
}
:deep(.pre-settlement-editor .el-table__body tr:nth-child(even) > td.el-table__cell),
:deep(.pre-settlement-candidate-dialog .el-table__body tr:nth-child(even) > td.el-table__cell) {
background: #fafafa;
}
:global(.avue--collapse .pre-settlement-editor__page-actions) {
left: 60px;
}
:global(.avue-layout--horizontal .pre-settlement-editor__page-actions) {
left: 0;
}
</style>