Files
tms-erp-web/src/views/settlement/components/formal-settlement-editor.vue
T
gxwebsoft c9f673342a style(ui): 统一底部操作栏按钮间距,取消所有gap样式
- 移除各底部操作栏(footer/__actions/page-actions)内所有gap属性
- 按钮间距统一采用 Element Plus 全局默认的相邻按钮12px margin-left规则
- 修改 .el-dialog__footer 内.el-button间距由8px调整为12px,保持全站一致
- 确认 Avue 表单底部按钮无自带间距,去gap后间距由 Element Plus 提供
- 涉及模块覆盖 vehicle、payment、business、settlement,移除了相关组件样式中gap属性
- MEMORY.md规范更新,补充底栏按钮间距统一为无gap纯文字规则
2026-08-27 11:39:13 +08:00

1623 lines
58 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"
>
<div v-loading="loading" class="formal-editor">
<section-card title="结算基本信息">
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="right"
label-width="auto"
>
<el-row :gutter="24">
<el-col v-for="field in fields" :key="field.prop" :span="6">
<el-form-item :label="field.label" :prop="field.prop">
<el-date-picker
v-if="field.type === 'date' && editable"
v-model="form[field.prop]"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择"
/>
<el-select
v-else-if="field.type === 'project' && editable"
v-model="form.projectId"
filterable
clearable
placeholder="请选择项目"
:disabled="details.length > 0 || sources.length > 0"
@change="handleProjectChange"
>
<el-option
v-for="item in projects"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
<el-select
v-else-if="field.type === 'contract' && editable"
v-model="form.contractId"
filterable
remote
:remote-method="loadContracts"
:disabled="!form.projectId || details.length > 0 || sources.length > 0"
placeholder="请选择合同"
@change="handleContractChange"
>
<el-option
v-for="item in contracts"
:key="item.id"
:label="`${item.contractNo} / ${item.contractName}`"
:value="item.id"
/>
</el-select>
<el-input-number
v-else-if="field.type === 'number' && editable"
v-model="form[field.prop]"
:min="0.000001"
:precision="6"
:controls="false"
/>
<span v-else-if="field.money">{{ formatMoney(form[field.prop]) }}</span>
<span v-else-if="field.type === 'contract'">{{
displayValue(form.contractName)
}}</span>
<span v-else-if="field.type === 'project'">{{
displayValue(form.projectName)
}}</span>
<span v-else>{{ displayValue(form[field.prop]) }}</span>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注" prop="remark">
<el-input
v-if="editable"
v-model="form.remark"
type="textarea"
maxlength="200"
show-word-limit
/>
<span v-else>{{ displayValue(form.remark) }}</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 show-summary :summary-method="getSummarySums">
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column
v-for="column in summaryTableColumns"
: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"
:class="{
'formal-editor__negative-amount': Number(row.adjustAmount || 0) < 0,
}"
:precision="2"
:step="0.01"
: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)"
:class="{
'formal-editor__negative-amount':
column.prop === 'adjustAmount' && Number(row[column.prop] || 0) < 0,
}"
>
{{ formatMoney(row[column.prop]) }}
</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>
</section-card>
<section-card title="结算明细">
<template #extra>
<div class="formal-editor__actions">
<el-button type="primary" text @click="detailCollapsed = !detailCollapsed">
{{ detailCollapsed ? '展开明细' : '收起明细' }}
</el-button>
</div>
</template>
<el-form
:model="detailQuery"
inline
label-position="right"
label-width="88px"
class="formal-editor__detail-filter"
@submit.prevent
>
<el-form-item label="单据号">
<el-input v-model="detailQuery.documentNo" clearable @keyup.enter="handleDetailQuery" />
</el-form-item>
<el-form-item label="运单号">
<el-input v-model="detailQuery.waybillNo" clearable @keyup.enter="handleDetailQuery" />
</el-form-item>
<el-form-item label="批次号">
<el-input v-model="detailQuery.batchNo" clearable @keyup.enter="handleDetailQuery" />
</el-form-item>
<el-form-item label="货物名称">
<el-input v-model="detailQuery.cargoName" clearable @keyup.enter="handleDetailQuery" />
</el-form-item>
<el-form-item class="formal-editor__detail-filter-actions">
<el-button type="primary" @click="handleDetailQuery">查询</el-button>
<el-button @click="resetDetailQuery">重置</el-button>
<el-button type="primary" :disabled="!details.length" @click="exportDetails">
导出
</el-button>
<el-button v-if="editable" type="primary" @click="openDetailDialog">
选择结算明细
</el-button>
</el-form-item>
</el-form>
<el-table v-show="!detailCollapsed" :data="filteredDetails" border>
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column
v-for="column in detailTableColumns"
:key="column.prop"
v-bind="column"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<span v-if="column.money">{{ formatMoney(row[column.prop]) }}</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-column v-if="editable" label="操作" width="150" fixed="right" align="center">
<template #default="{ row }">
<div class="formal-editor__links">
<el-link v-if="row.formalSettlementId" type="primary" @click="openAdjustDialog(row)"
>调整</el-link
>
<el-link v-if="!row.sourcePreSettlementId" type="danger" @click="removeDetail(row)"
>删除</el-link
>
</div>
</template>
</el-table-column>
</el-table>
</section-card>
<section-card>
<template #title>
<span>附件</span>
<span v-if="missingAttachmentTypeText" class="formal-editor__attachment-missing">
{{ missingAttachmentTypeText }}
</span>
</template>
<el-table :data="attachments" border>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column label="类型" min-width="180" align="center">
<template #default="{ row }">
<el-select
v-if="editable"
v-model="row.type"
filterable
placeholder="请选择类型"
@change="sortAttachments"
>
<el-option
v-for="item in attachmentTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<span v-else>{{ attachmentTypeName(row.type) }}</span>
</template>
</el-table-column>
<el-table-column label="文件名" min-width="220" align="left" show-overflow-tooltip>
<template #default="{ row }">
<el-link
:disabled="!getAttachmentUrl(row)"
type="primary"
@click="previewAttachment(row)"
>
{{ row.originalName || row.name || '-' }}
</el-link>
</template>
</el-table-column>
<el-table-column prop="uploadUserName" label="上传人" min-width="120" align="center">
<template #default="{ row }">
{{ displayValue(row.uploadUserName) }}
</template>
</el-table-column>
<el-table-column prop="uploadTime" label="上传时间" min-width="170" align="center">
<template #default="{ row }">
{{ displayValue(row.uploadTime) }}
</template>
</el-table-column>
<el-table-column prop="size" label="文件大小" min-width="120" align="center">
<template #default="{ row }">
{{ displayValue(row.size) }}
</template>
</el-table-column>
<el-table-column v-if="editable" label="操作" width="90" align="center">
<template #default="{ $index }">
<el-link type="danger" @click="removeAttachment($index)">删除</el-link>
</template>
</el-table-column>
</el-table>
<div v-if="editable" class="formal-editor__attachment-upload">
<vehicle-attachment-upload
v-model="attachmentUploadFiles"
:multiple="true"
:limit="20"
:max-size="500"
:file-types="attachmentFileTypes"
button-text="上传附件"
:show-file-list="false"
:show-uploading="true"
@success="handleAttachmentUploadSuccess"
/>
</div>
</section-card>
<section-card v-if="readonly && payments.length" title="付款信息">
<el-table :data="payments" border>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column
v-for="column in paymentTableColumns"
:key="column.prop"
v-bind="column"
align="center"
>
<template #default="{ row }">
<span v-if="column.money">{{ formatMoney(row[column.prop]) }}</span>
<span v-else-if="column.prop === 'paymentTypeName'">{{
row.paymentType === 'final' ? '尾款' : displayValue(row.paymentType)
}}</span>
<span v-else-if="column.prop === 'billStatusName'">{{
row.billStatus === 'reviewing' ? '审批中' : displayValue(row.billStatus)
}}</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" :loading="saving" @click="handleSave"
>提交</el-button
>
</template>
<div v-if="pageMode" class="formal-editor__page-actions">
<el-button @click="visible = false">取消</el-button>
<el-button v-if="editable" type="primary" :loading="saving" @click="handleSave">
提交
</el-button>
</div>
<el-dialog v-model="candidate.visible" title="选择预结算单" width="88%" append-to-body>
<el-form
:model="candidate.query"
inline
label-position="right"
label-width="88px"
class="formal-editor__candidate-search"
>
<el-form-item label="预结算单号"
><el-input v-model="candidate.query.preSettlementNo" clearable
/></el-form-item>
<el-form-item label="合同编号"
><el-input v-model="candidate.query.contractNo" clearable
/></el-form-item>
<el-form-item
><el-button @click="resetCandidates">重置</el-button
><el-button type="primary" @click="loadCandidates">查询</el-button></el-form-item
>
</el-form>
<el-table
ref="candidateTable"
v-loading="candidate.loading"
:data="candidate.rows"
border
@selection-change="candidate.selected = $event"
>
<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 candidateTableColumns"
:key="column.prop"
v-bind="column"
align="center"
>
<template #default="{ row }">
<span v-if="column.money">{{ formatMoney(row[column.prop]) }}</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="formal-editor__pagination">
<el-pagination
v-model:current-page="candidate.page.current"
v-model:page-size="candidate.page.size"
:total="candidate.page.total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadCandidates"
@size-change="loadCandidates"
/>
</div>
<template #footer
><el-button @click="candidate.visible = false">取消</el-button
><el-button type="primary" @click="confirmCandidates">确定</el-button></template
>
</el-dialog>
<el-dialog v-model="detailCandidate.visible" title="选择结算明细" width="92%" append-to-body>
<el-form
:model="detailCandidate.query"
inline
label-position="right"
label-width="88px"
class="formal-editor__detail-search"
>
<el-form-item label="批次号"
><el-input v-model="detailCandidate.query.batchNo" clearable
/></el-form-item>
<el-form-item label="创建时间"
><el-date-picker
v-model="detailCandidate.query.createTimeRange"
type="daterange"
value-format="YYYY-MM-DD"
format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
/></el-form-item>
<el-form-item
><el-button @click="resetDetailCandidates">重置</el-button
><el-button type="primary" @click="loadDetailCandidates">查询</el-button></el-form-item
>
</el-form>
<el-table
v-loading="detailCandidate.loading"
:data="detailCandidate.rows"
border
@selection-change="detailCandidate.selected = $event"
>
<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 candidateDetailTableColumns"
:key="column.prop"
v-bind="column"
align="center"
>
<template #default="{ row }"
><span v-if="column.money">{{ formatMoney(row[column.prop]) }}</span
><span v-else>{{ displayValue(row[column.prop]) }}</span></template
>
</el-table-column>
</el-table>
<div class="formal-editor__pagination">
<el-pagination
v-model:current-page="detailCandidate.page.current"
v-model:page-size="detailCandidate.page.size"
:total="detailCandidate.page.total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadDetailCandidates"
@size-change="loadDetailCandidates"
/>
</div>
<template #footer
><el-button @click="detailCandidate.visible = false">取消</el-button
><el-button type="primary" @click="confirmDetailCandidates">确定</el-button></template
>
</el-dialog>
<el-dialog v-model="adjust.visible" title="结算明细调整" width="92%" append-to-body>
<el-table v-loading="adjust.loading" :data="adjust.rows" border>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column prop="cargoName" label="货物名称" min-width="130" align="center" />
<el-table-column prop="cargoType" label="货物类型" min-width="130" align="center" />
<el-table-column label="运输总量" min-width="130" align="center"
><template #default="{ row }"
><el-input-number
v-model="row.transportQuantity"
:min="0"
:precision="6"
:controls="false" /></template
></el-table-column>
<el-table-column label="里程(KM" min-width="130" align="center"
><template #default="{ row }"
><el-input-number
v-model="row.mileage"
:min="0"
:precision="2"
:controls="false" /></template
></el-table-column>
<el-table-column label="运输单价" min-width="130" align="center"
><template #default="{ row }"
><el-input-number
v-model="row.unitPrice"
:min="0"
:precision="2"
:controls="false" /></template
></el-table-column>
<el-table-column label="运费" min-width="130" align="center"
><template #default="{ row }"
><el-input-number
v-model="row.freightAmount"
:min="0"
:precision="2"
:controls="false" /></template
></el-table-column>
<el-table-column label="结算金额(含税)" min-width="165" align="center"
><template #default="{ row }"
><el-input-number
v-model="row.settlementAmountTax"
:min="0"
:precision="2"
:controls="false" /></template
></el-table-column>
<el-table-column label="结算金额(不含税)" min-width="180" align="center"
><template #default="{ row }"
><el-input-number
v-model="row.settlementAmountNoTax"
:min="0"
:precision="2"
:controls="false" /></template
></el-table-column>
<el-table-column label="备注" min-width="180" align="center"
><template #default="{ row }"><el-input v-model="row.remark" maxlength="200" /></template
></el-table-column>
</el-table>
<el-form label-position="right" label-width="auto" class="formal-editor__adjust-reason">
<el-form-item label="调整原因" required
><el-input v-model="adjust.reason" type="textarea" maxlength="200" show-word-limit
/></el-form-item>
</el-form>
<template #footer
><el-button @click="adjust.visible = false">取消</el-button
><el-button type="primary" :loading="adjust.saving" @click="saveAdjustment"
>保存</el-button
></template
>
</el-dialog>
</component>
</template>
<script>
import {
adjustDetail,
getCandidateDetails,
getCandidates,
getContractOptions,
getDetail,
getDetailFees,
getFeeOptions,
getNextNo,
save,
} from '@/api/settlement/formalSettlement';
import { getDetail as getPreSettlementDetail } from '@/api/settlement/preSettlement';
import {
createFormalSettlementForm,
formalSettlementFormFields,
} from '@/option/settlement/formalSettlementForm';
import {
candidateColumns,
candidateDetailColumns,
detailColumns,
paymentColumns,
sourceColumns,
summaryColumns,
} from '@/option/settlement/formalSettlementTable';
import { getDictionary } from '@/api/system/dictbiz';
import { mapGetters } from 'vuex';
import * as XLSX from 'xlsx';
export default {
name: 'FormalSettlementEditor',
props: {
modelValue: Boolean,
recordId: [String, Number],
readonly: Boolean,
pageMode: Boolean,
initialData: {
type: Object,
default: null,
},
},
emits: ['update:modelValue', 'success'],
data() {
return {
loading: false,
saving: false,
form: createFormalSettlementForm(),
sources: [],
details: [],
summaryFees: [],
payments: [],
attachments: [],
attachmentUploadFiles: [],
attachmentTypeOptions: [],
attachmentFileTypes: [
'pdf',
'bmp',
'jpeg',
'png',
'jpg',
'doc',
'docx',
'ppt',
'pptx',
'xlsx',
'xls',
'eml',
'msg',
'zip',
'rar',
],
contracts: [],
allContracts: [],
projects: [],
feeOptions: [],
transportTypeOptions: [],
fields: formalSettlementFormFields,
sourceTableColumns: sourceColumns,
summaryTableColumns: summaryColumns,
detailTableColumns: detailColumns,
paymentTableColumns: paymentColumns,
candidateTableColumns: candidateColumns,
candidateDetailTableColumns: candidateDetailColumns,
rules: {
projectId: [{ required: true, message: '请选择项目', trigger: 'change' }],
contractId: [{ required: true, message: '请选择合同', trigger: 'change' }],
exchangeRateDate: [{ required: true, message: '请选择汇率日期', trigger: 'change' }],
exchangeRate: [{ required: true, message: '请输入结算汇率', trigger: 'blur' }],
},
candidate: {
visible: false,
loading: false,
query: {},
rows: [],
selected: [],
page: { current: 1, size: 10, total: 0 },
},
detailCandidate: {
visible: false,
loading: false,
query: { batchNo: '', createTimeRange: [] },
rows: [],
selected: [],
page: { current: 1, size: 10, total: 0 },
},
adjust: {
visible: false,
loading: false,
saving: false,
detailId: null,
reason: '',
rows: [],
},
detailCollapsed: false,
detailQuery: {
documentNo: '',
waybillNo: '',
batchNo: '',
cargoName: '',
},
appliedDetailQuery: {
documentNo: '',
waybillNo: '',
batchNo: '',
cargoName: '',
},
};
},
computed: {
...mapGetters(['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: 'formal-editor-shell formal-editor-shell--page' };
}
return {
modelValue: this.visible,
title: this.title,
width: '96%',
top: '2vh',
appendToBody: true,
destroyOnClose: true,
};
},
editable() {
return !this.readonly;
},
title() {
return this.readonly ? '查看正式结算单' : this.recordId ? '编辑正式结算单' : '新增正式结算单';
},
summaryTotal() {
return this.summaryFees.reduce((total, row) => total + Number(row.settlementAmount || 0), 0);
},
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());
})
);
},
missingAttachmentTypeText() {
const missing = this.getMissingAttachmentTypes();
return missing.length ? `未上传:${missing.join('、')}` : '';
},
},
watch: {
modelValue: {
immediate: true,
handler(value) {
if (value) this.initialize();
},
},
summaryTotal(value) {
this.form.settlementAmount = Number(value || 0).toFixed(2);
this.form.localSettlementAmount = (
Number(value || 0) * Number(this.form.exchangeRate || 1)
).toFixed(2);
},
'form.exchangeRate'(value) {
this.form.localSettlementAmount = (
Number(this.summaryTotal || 0) * Number(value || 1)
).toFixed(2);
},
},
methods: {
async initialize() {
this.form = createFormalSettlementForm();
this.sources = [];
this.details = [];
this.summaryFees = [];
this.payments = [];
this.attachments = [];
this.attachmentUploadFiles = [];
this.detailCollapsed = false;
this.resetDetailQuery(false);
await Promise.all([
this.loadAllContracts(),
this.loadFeeOptions(),
this.loadAttachmentTypeOptions(),
this.loadTransportTypeOptions(),
]);
if (!this.recordId) {
this.form.exchangeRateDate = this.$dayjs().format('YYYY-MM-DD');
if (this.initialData) await this.applyInitialData();
await this.refreshFormalSettlementNo();
return;
}
this.loading = true;
try {
const response = await getDetail(this.recordId);
const data = this.unwrapData(response);
this.form = {
...createFormalSettlementForm(),
...data,
sourcePreSettlementIds: (data.sources || []).map(item => item.preSettlementId),
sourceDetailIds: (data.details || [])
.filter(item => !item.sourcePreSettlementId)
.map(item => item.sourceDetailId),
};
this.sources = data.sources || [];
this.details = data.details || [];
this.summaryFees = data.summaryFees || [];
this.payments = data.payments || [];
this.attachments = this.parseAttachments(data.attachmentsJson);
this.sortAttachments();
this.contracts = this.allContracts.filter(
item => String(item.projectId) === String(this.form.projectId)
);
} finally {
this.loading = false;
}
},
async refreshFormalSettlementNo() {
if (this.form.id || !this.form.settlementType) {
if (!this.form.id) this.form.formalSettlementNo = '';
return;
}
const response = await getNextNo(this.form.settlementType);
this.form.formalSettlementNo = this.unwrapData(response) || '';
},
async applyInitialData() {
const sourcePreSettlements = Array.isArray(this.initialData?.sourcePreSettlements)
? this.initialData.sourcePreSettlements
: [];
if (sourcePreSettlements.length) {
await this.applyInitialSources(sourcePreSettlements);
return;
}
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 matchedContract = this.allContracts.find(
item =>
(first.contractId && String(item.id) === String(first.contractId)) ||
(first.contractNo && String(item.contractNo) === String(first.contractNo))
);
const contractId = first.contractId || matchedContract?.id || null;
const projectId = first.projectId || matchedContract?.projectId || null;
const projectName = first.projectName || matchedContract?.projectName || '';
this.contracts = this.allContracts.filter(
item => projectId && String(item.projectId) === String(projectId)
);
if (projectId && !this.projects.some(item => String(item.id) === String(projectId))) {
this.projects.push({ id: projectId, name: projectName });
}
if (!this.contracts.some(item => String(item.id) === String(contractId))) {
this.contracts.push({
id: contractId,
contractNo: first.contractNo,
contractName: first.contractName,
projectId,
projectName,
deptId: first.deptId || matchedContract?.deptId,
deptName: first.deptName || matchedContract?.deptName,
payerName: first.payerName,
payeeName: first.payeeName,
partyA: matchedContract?.partyA,
partyB: matchedContract?.partyB,
settlementType,
});
}
this.handleContractChange(contractId, false);
Object.assign(this.form, {
contractId,
contractNo: first.contractNo || this.form.contractNo,
contractName: first.contractName || this.form.contractName,
projectId: projectId || this.form.projectId,
projectName: 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,
settlementTypeName: settlementType === 'receivable' ? '应收' : '应付',
currency: first.currency || 'RMB',
});
this.details = rows.map(row => ({
...row,
sourceDetailId: row.sourceDetailId || row.id,
settlementAmountTax:
row.settlementAmountTax ?? row.totalAmount ?? row.afterAmount ?? row.settlementAmount,
}));
this.form.sourceDetailIds = this.details.map(item => item.sourceDetailId);
this.buildSummaryFeesFromDetails();
},
async applyInitialSources(rows) {
const first = rows[0];
const contractId = first.contractId || null;
const settlementType = first.settlementType || 'payable';
if (!this.contracts.some(item => String(item.id) === String(contractId))) {
this.contracts.push({
id: contractId,
contractNo: first.contractNo,
contractName: first.contractName,
projectId: first.projectId,
projectName: first.projectName,
deptId: first.deptId,
deptName: first.deptName,
payerName: first.payerName,
payeeName: first.payeeName,
settlementType,
});
}
this.handleContractChange(contractId, false);
this.sources = rows.map(row => ({
...row,
preSettlementId: row.preSettlementId || row.id,
}));
this.form.sourcePreSettlementIds = this.sources.map(item => item.preSettlementId);
Object.assign(this.form, first, {
id: null,
formalSettlementNo: this.form.formalSettlementNo,
contractId,
settlementType,
settlementTypeName: settlementType === 'receivable' ? '应收' : '应付',
sourcePreSettlementIds: this.form.sourcePreSettlementIds,
});
const responses = await Promise.all(
this.sources.map(item => getPreSettlementDetail(item.preSettlementId))
);
const settlements = responses.map(response => this.unwrapData(response) || {});
this.details = settlements.flatMap(settlement =>
(settlement.details || []).map(detail => ({
...detail,
sourcePreSettlementId: settlement.id,
}))
);
const summaryMap = new Map();
settlements
.flatMap(settlement => settlement.summaryFees || [])
.forEach(row => {
const key = `${row.feeType}\u0000${row.feeItem}`;
const current = summaryMap.get(key) || {
feeType: row.feeType,
feeItem: row.feeItem,
originalAmount: 0,
adjustAmount: 0,
settlementAmount: 0,
remark: '',
manualFlag: 0,
};
current.originalAmount += Number(row.settlementAmount || 0);
current.settlementAmount = current.originalAmount;
summaryMap.set(key, current);
});
this.summaryFees = [...summaryMap.values()];
if (!this.summaryFees.length) this.buildSummaryFeesFromDetails();
},
async loadAllContracts() {
const response = await getContractOptions('');
this.allContracts = this.unwrapData(response) || [];
const projectMap = new Map();
this.allContracts.forEach(item => {
if (item.projectId)
projectMap.set(String(item.projectId), { id: item.projectId, name: item.projectName });
});
this.projects = [...projectMap.values()];
this.contracts = [];
},
async loadContracts(keyword = '') {
const projectId = this.form.projectId;
if (!projectId) {
this.contracts = [];
return;
}
const response = await getContractOptions(keyword, projectId);
if (String(this.form.projectId) === String(projectId)) {
this.contracts = this.unwrapData(response) || [];
}
},
async loadFeeOptions() {
const response = await getFeeOptions();
this.feeOptions = this.unwrapData(response) || [];
},
handleProjectChange(id) {
const project = this.projects.find(item => String(item.id) === String(id));
this.form.projectName = project?.name || '';
this.form.contractId = null;
this.form.contractNo = '';
this.form.contractName = '';
this.form.deptId = null;
this.form.deptName = '';
this.form.payerName = '';
this.form.payeeName = '';
this.sources = [];
this.details = [];
this.summaryFees = [];
this.form.sourcePreSettlementIds = [];
this.form.sourceDetailIds = [];
this.loadContracts();
},
handleContractChange(id, refreshSettlementNo = true) {
const contract = this.contracts.find(item => String(item.id) === String(id));
if (!contract) return;
const formalSettlementId = this.form.id;
Object.assign(this.form, contract, {
id: formalSettlementId,
contractId: contract.id,
payerName:
contract.payerName ||
(contract.settlementType === 'receivable' ? contract.partyB : contract.partyA),
payeeName:
contract.payeeName ||
(contract.settlementType === 'receivable' ? contract.partyA : contract.partyB),
settlementTypeName: contract.settlementType === 'receivable' ? '应收' : '应付',
});
this.sources = [];
this.details = [];
this.summaryFees = [];
this.form.sourcePreSettlementIds = [];
this.form.sourceDetailIds = [];
if (refreshSettlementNo) this.refreshFormalSettlementNo();
},
openCandidateDialog() {
this.candidate.visible = true;
this.candidate.page.current = 1;
this.loadCandidates();
},
async loadCandidates() {
this.candidate.loading = true;
try {
const response = await getCandidates(
this.candidate.page.current,
this.candidate.page.size,
{
...this.candidate.query,
contractId: this.form.contractId || undefined,
}
);
const data = this.unwrapData(response);
this.candidate.rows = data.records || [];
this.candidate.page.total = data.total || 0;
} finally {
this.candidate.loading = false;
}
},
resetCandidates() {
this.candidate.query = {};
this.candidate.page.current = 1;
this.loadCandidates();
},
confirmCandidates() {
const merged = [...this.sources, ...this.candidate.selected];
const map = new Map(
merged.map(item => [
String(item.preSettlementId || item.id),
{ ...item, preSettlementId: item.preSettlementId || item.id },
])
);
this.sources = [...map.values()];
this.form.sourcePreSettlementIds = this.sources.map(item => item.preSettlementId);
if (this.sources.length) {
Object.assign(this.form, this.sources[0], {
contractId: this.sources[0].contractId,
formalSettlementNo: this.form.formalSettlementNo,
});
this.refreshFormalSettlementNo();
}
this.form.settlementAmount = this.sources.reduce(
(sum, item) => sum + Number(item.settlementAmount || 0),
0
);
this.form.localSettlementAmount =
this.form.settlementAmount * Number(this.form.exchangeRate || 1);
this.candidate.visible = false;
},
removeSource(index) {
this.sources.splice(index, 1);
this.form.sourcePreSettlementIds = this.sources.map(item => item.preSettlementId || item.id);
},
openDetailDialog() {
if (!this.form.contractId) return this.$message.warning('请先选择合同');
this.detailCandidate.visible = true;
this.detailCandidate.page.current = 1;
this.detailCandidate.selected = [];
this.loadDetailCandidates();
},
async loadDetailCandidates() {
this.detailCandidate.loading = true;
try {
const range = this.detailCandidate.query.createTimeRange || [];
const params = {
contractId: this.form.contractId,
settlementType: this.form.settlementType,
batchNo: this.detailCandidate.query.batchNo,
createStartDate: range[0],
createEndDate: range[1],
};
const response = await getCandidateDetails(
this.detailCandidate.page.current,
this.detailCandidate.page.size,
params
);
const data = this.unwrapData(response);
this.detailCandidate.rows = data.records || [];
this.detailCandidate.page.total = data.total || 0;
} finally {
this.detailCandidate.loading = false;
}
},
resetDetailCandidates() {
this.detailCandidate.query = { batchNo: '', createTimeRange: [] };
this.detailCandidate.page.current = 1;
this.loadDetailCandidates();
},
confirmDetailCandidates() {
if (!this.detailCandidate.selected.length) {
return this.$message.warning('请选择结算明细');
}
const existing = new Map(
this.details
.filter(item => !item.formalSettlementId)
.map(item => [String(item.sourceDetailId || item.id), item])
);
this.detailCandidate.selected.forEach(item =>
existing.set(String(item.id), {
...item,
sourceDetailId: item.id,
settlementAmountTax: item.totalAmount,
})
);
this.details = [
...this.details.filter(item => item.formalSettlementId),
...existing.values(),
];
this.form.sourceDetailIds = [...existing.values()].map(item => item.sourceDetailId);
this.buildSummaryFeesFromDetails();
this.detailCandidate.visible = false;
},
removeDetail(row) {
const index = this.details.indexOf(row);
if (index < 0) return;
this.details.splice(index, 1);
this.form.sourceDetailIds = this.details
.filter(item => !item.sourcePreSettlementId)
.map(item => item.sourceDetailId);
this.buildSummaryFeesFromDetails();
},
addSummaryFee() {
this.summaryFees.push({
feeType: '',
feeItem: '',
originalAmount: 0,
adjustAmount: 0,
settlementAmount: 0,
remark: '',
manualFlag: 1,
});
},
removeSummaryFee(index) {
this.summaryFees.splice(index, 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) {
return (
this.feeOptions.find(item => String(item.feeType) === String(value))?.feeTypeName || value
);
},
isSummaryMoney(prop) {
return ['originalAmount', 'adjustAmount', 'settlementAmount'].includes(prop);
},
recalculateSummaryRow(row) {
row.settlementAmount = Number(row.originalAmount || 0) + Number(row.adjustAmount || 0);
},
getSummarySums({ columns, data }) {
const sumProps = ['originalAmount', 'adjustAmount', 'settlementAmount'];
return columns.map((column, index) => {
if (index === 0) return '合计';
if (!sumProps.includes(column.property)) return '';
const total = data.reduce((sum, row) => sum + Number(row[column.property] || 0), 0);
return this.formatMoney(total);
});
},
buildSummaryFeesFromDetails() {
const generatedMap = new Map();
const existingGenerated = new Map(
this.summaryFees
.filter(row => 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 option = this.feeOptions.find(item =>
(item.feeItems || []).some(name => String(name) === String(feeItem))
);
const feeType =
option?.feeType ||
fallbackFeeType ||
(this.isFreightFeeItem(feeItem) ? '物流配送' : '其他费用');
const key = `${feeType}\u0000${feeItem}`;
const old = existingGenerated.get(key);
const current = generatedMap.get(key) || {
id: old?.id,
feeType,
feeItem,
originalAmount: 0,
adjustAmount: Number(old?.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)
);
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
);
const residualAmount = Number((totalAmount - knownAmount).toFixed(2));
if (Math.abs(residualAmount) >= 0.005) append('其他费用', residualAmount, '其他费用');
});
const manualRows = this.summaryFees.filter(row => row.manualFlag === 1);
this.summaryFees = [...generatedMap.values(), ...manualRows];
},
isFreightFeeItem(name) {
return name && (name.includes('运费') || name.includes('运输费'));
},
handleDetailQuery() {
this.appliedDetailQuery = { ...this.detailQuery };
},
resetDetailQuery(apply = true) {
this.detailQuery = { documentNo: '', waybillNo: '', batchNo: '', cargoName: '' };
if (apply) this.handleDetailQuery();
else this.appliedDetailQuery = { ...this.detailQuery };
},
exportDetails() {
if (!this.details.length) return this.$message.warning('暂无可导出的结算明细');
const rows = this.details.map((row, index) => {
const result = { 序号: index + 1 };
this.detailTableColumns.forEach(column => {
result[column.label] = column.money
? Number(row[column.prop] || 0).toFixed(2)
: row[column.prop] ?? '';
});
return result;
});
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(rows), '结算明细');
XLSX.writeFile(
workbook,
`${this.form.formalSettlementNo || '正式结算单'}结算明细${this.$dayjs().format(
'YYYY-MM-DD HH-mm-ss'
)}.xlsx`
);
},
async openAdjustDialog(row) {
this.adjust = {
visible: true,
loading: true,
saving: false,
detailId: row.id,
reason: '',
rows: [],
};
try {
const response = await getDetailFees(row.id);
const data = this.unwrapData(response);
this.adjust.rows = (data || []).map(item => ({
...item,
feeItems: this.parseFeeItems(item.feeItemsJson),
}));
} finally {
this.adjust.loading = false;
}
},
async saveAdjustment() {
if (!this.adjust.reason.trim()) return this.$message.warning('请输入调整原因');
this.adjust.saving = true;
try {
await adjustDetail({
detailId: this.adjust.detailId,
changeReason: this.adjust.reason,
rows: this.adjust.rows,
});
this.$message.success('调整保存成功');
this.adjust.visible = false;
await this.initialize();
} finally {
this.adjust.saving = false;
}
},
async handleSave() {
await this.$refs.formRef.validate();
if (!this.form.sourcePreSettlementIds.length && !this.form.sourceDetailIds.length) {
return this.$message.warning('请选择结算明细');
}
const invalidManualFee = this.summaryFees.find(
row =>
row.manualFlag === 1 &&
(!String(row.feeType || '').trim() || !String(row.feeItem || '').trim())
);
if (invalidManualFee) {
return this.$message.warning('请完善手工费用的费用类型和费用项');
}
if (!this.validateAttachments()) return;
this.saving = true;
try {
await save({
id: this.form.id,
contractId: this.form.contractId,
settlementType: this.form.settlementType,
sourcePreSettlementIds: this.form.sourcePreSettlementIds,
sourceDetailIds: this.form.sourceDetailIds,
exchangeRateDate: this.form.exchangeRateDate,
exchangeRate: this.form.exchangeRate,
summaryFees: this.summaryFees
.filter(
row =>
row.manualFlag === 1 ||
row.id ||
Number(row.adjustAmount || 0) !== 0 ||
String(row.remark || '').trim()
)
.map(row => ({
id: row.id || undefined,
feeType: row.feeType,
feeItem: row.feeItem,
adjustAmount: Number(row.adjustAmount || 0),
remark: row.remark,
manualFlag: row.manualFlag || 0,
})),
attachmentsJson: this.stringifyAttachments(this.attachments),
remark: this.form.remark,
});
this.$message.success('保存成功');
this.visible = false;
this.$emit('success');
} finally {
this.saving = false;
}
},
displayValue(value) {
return value === null || value === undefined || value === '' ? '-' : value;
},
unwrapData(response) {
const body = response?.data || response || {};
return body?.data || body;
},
formatMoney(value) {
return `${Number(value || 0).toFixed(2)} RMB`;
},
async loadTransportTypeOptions() {
const { data } = await getDictionary({ code: 'transport_type' });
this.transportTypeOptions = data?.data || [];
},
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;
},
async loadAttachmentTypeOptions() {
const response = await getDictionary({ code: 'settle_attachment_types' });
const data = response?.data?.data || [];
this.attachmentTypeOptions = data.map(item => ({
label: item.dictValue,
value: item.dictValue,
}));
this.sortAttachments();
},
resolveAttachmentType(fileName) {
const normalizedName = String(fileName || '').toLocaleLowerCase();
const matchedType = [...this.attachmentTypeOptions]
.filter(item => item?.label || item?.value)
.sort(
(a, b) =>
String(b.label || b.value || '').length - String(a.label || a.value || '').length
)
.find(item => {
const typeText = String(item.label || item.value || '').toLocaleLowerCase();
return typeText && normalizedName.includes(typeText);
});
if (matchedType?.value) return matchedType.value;
const otherType = this.attachmentTypeOptions.find(item =>
String(item.label || item.value || '').includes('其他')
);
if (otherType?.value) return otherType.value;
this.attachmentTypeOptions.push({ label: '其他附件', value: '其他附件' });
return '其他附件';
},
getAttachmentTypeOrder(type) {
const normalizedType = String(type || '').trim();
const index = this.attachmentTypeOptions.findIndex(
item =>
String(item.value || '').trim() === normalizedType ||
String(item.label || '').trim() === normalizedType
);
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
},
sortAttachments() {
this.attachments = (this.attachments || [])
.map((file, index) => ({ file, index }))
.sort((a, b) => {
const orderDifference =
this.getAttachmentTypeOrder(a.file.type) - this.getAttachmentTypeOrder(b.file.type);
return orderDifference || a.index - b.index;
})
.map(item => item.file);
},
getMissingAttachmentTypes(files = this.attachments) {
const uploadedTypes = new Set(
(files || [])
.filter(item => this.getAttachmentUrl(item))
.map(item => String(item.type || '').trim())
.filter(Boolean)
);
return this.attachmentTypeOptions
.filter(item => String(item.label || item.value || '').trim() !== '其他附件')
.filter(item => !uploadedTypes.has(String(item.value || item.label || '').trim()))
.map(item => item.label || item.value);
},
validateAttachments(files = this.attachments) {
const missing = this.getMissingAttachmentTypes(files);
if (!missing.length) return true;
this.$message.warning(`请先上传附件:${missing.join('、')}`);
return false;
},
handleAttachmentUploadSuccess(file) {
const originalName = file.originalName || file.name || '附件';
this.attachments.push({
type: this.resolveAttachmentType(originalName),
originalName,
name: originalName,
uploadUserName: this.userInfo.realName || this.userInfo.userName || '',
uploadTime: this.$dayjs().format('YYYY-MM-DD HH:mm:ss'),
size: this.formatAttachmentSize(file.size),
url: file.url || file.link || '',
});
this.sortAttachments();
},
removeAttachment(index) {
this.attachments.splice(index, 1);
},
getAttachmentUrl(file = {}) {
return file.url || file.link || file.src || file.domain || '';
},
previewAttachment(file) {
const url = this.getAttachmentUrl(file);
if (!url) return this.$message.warning('附件地址为空,无法预览');
window.open(url, '_blank');
},
attachmentTypeName(type) {
const option = this.attachmentTypeOptions.find(
item => String(item.value) === String(type) || String(item.label) === String(type)
);
return this.displayValue(option?.label || type);
},
formatAttachmentSize(size) {
if (!size) return '';
const byteSize = Number(size);
if (!Number.isFinite(byteSize)) return String(size);
const mb = byteSize / 1024 / 1024;
if (mb >= 1) return `${mb.toFixed(2)} MB`;
return `${(byteSize / 1024).toFixed(2)} KB`;
},
stringifyAttachments(list) {
const files = (list || [])
.filter(item => this.getAttachmentUrl(item))
.map(item => ({
type: String(item.type || '').trim(),
originalName: String(item.originalName || item.name || '').trim(),
name: String(item.originalName || item.name || '').trim(),
uploadUserName: String(item.uploadUserName || '').trim(),
uploadTime: String(item.uploadTime || '').trim(),
size: String(item.size || '').trim(),
url: String(this.getAttachmentUrl(item)).trim(),
}));
return files.length ? JSON.stringify(files) : '';
},
parseFeeItems(value) {
if (!value) return {};
if (typeof value === 'object') return value;
try {
return JSON.parse(value);
} catch {
return {};
}
},
parseAttachments(value) {
if (!value) return [];
let attachments = value;
if (!Array.isArray(value)) {
try {
attachments = JSON.parse(value);
} catch {
return [];
}
}
if (!Array.isArray(attachments)) return [];
return attachments.map(item => {
const url = this.getAttachmentUrl(item);
const originalName =
item.originalName || item.name || this.getAttachmentFileName(url) || '附件';
return {
...item,
type: item.type || this.resolveAttachmentType(originalName),
originalName,
name: originalName,
uploadUserName: item.uploadUserName || item.createUserName || item.uploadUser || '',
uploadTime: item.uploadTime || item.createTime || '',
size: this.formatAttachmentSize(item.size || item.attachSize),
url,
};
});
},
getAttachmentFileName(url) {
if (!url) return '';
const path = String(url).split('?')[0];
try {
return decodeURIComponent(path.substring(path.lastIndexOf('/') + 1));
} catch {
return path.substring(path.lastIndexOf('/') + 1);
}
},
},
};
</script>
<style scoped lang="scss">
.formal-editor__actions {
display: flex;
align-items: center;
gap: 8px;
}
.formal-editor__detail-filter {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px 16px;
margin-bottom: 8px;
padding: 12px 12px 4px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.formal-editor__detail-filter-actions {
grid-column: 1 / -1;
justify-self: start;
}
.formal-editor__page-actions {
display: flex;
justify-content: flex-end;
padding: 12px 24px;
border-top: 1px solid #eff1f7;
position: fixed;
right: 0;
left: 230px;
bottom: 0;
z-index: 10;
margin: 0;
background: #fff;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
}
:global(.avue--collapse) .formal-editor__page-actions {
left: 60px;
}
:global(.avue-layout--horizontal) .formal-editor__page-actions {
left: 0;
}
.formal-editor__links {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
.formal-editor :deep(.el-form-item) {
margin-bottom: 14px;
}
.formal-editor :deep(.el-form-item__content) {
line-height: normal;
}
.formal-editor :deep(.el-table) {
--el-table-border-color: #eff1f7;
}
.formal-editor :deep(.el-table__body tr:nth-child(even) > td.el-table__cell) {
background: #fafafa;
}
.formal-editor__pagination {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.formal-editor__adjust-reason {
margin-top: 16px;
}
.formal-editor__attachment-missing {
margin-left: 12px;
color: var(--el-color-danger);
font-size: 13px;
font-weight: 400;
}
.formal-editor__attachment-upload {
margin-top: 12px;
}
.formal-editor__negative-amount {
color: var(--el-color-danger);
}
.formal-editor :deep(.formal-editor__negative-amount .el-input__inner) {
color: var(--el-color-danger);
}
/* 选择预结算单 / 选择结算明细 弹窗搜索区白底 */
.formal-editor__candidate-search,
.formal-editor__detail-search {
background: #fff;
border-radius: 6px;
padding: 12px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
@media (max-width: 1200px) {
.formal-editor__detail-filter {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>