Files
tms-erp-web/src/views/settlement/components/formal-settlement-editor.vue
T
2026-09-02 03:22:51 +08:00

2159 lines
81 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" :class="{ 'formal-editor--page': pageMode }">
<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="2"
: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': isNegativeAdjustedAmount(row, column.prop),
}"
>
{{ 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 === 'transportQuantity'">
{{ formatDetailTransportQuantity(row[column.prop]) }}
</span>
<span v-else-if="column.prop === 'mileage'">
{{ formatDetailMileage(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 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>
<div class="formal-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="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 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="claimInvoices"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="invoiceClaimLoading" @click="claimInvoices">
认领查询
</el-button>
</el-form-item>
</el-form>
</div>
<el-table :data="invoices" border>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column prop="invoiceNo" label="发票号" min-width="150" align="center" />
<el-table-column prop="invoiceDate" label="开票日期" min-width="130" align="center">
<template #default="{ row }">{{ displayValue(row.invoiceDate) }}</template>
</el-table-column>
<el-table-column prop="invoiceType" label="发票类型" min-width="150" align="center">
<template #default="{ row }">{{ displayValue(row.invoiceType) }}</template>
</el-table-column>
<el-table-column prop="taxRate" label="税率" min-width="100" align="center">
<template #default="{ row }">
{{ row.taxRate === null || row.taxRate === undefined ? '-' : `${row.taxRate}%` }}
</template>
</el-table-column>
<el-table-column
prop="invoiceAmount"
label="发票金额(含税)"
min-width="150"
align="center"
>
<template #default="{ row }">{{ formatMoney(row.invoiceAmount) }}</template>
</el-table-column>
<el-table-column
prop="availableInvoiceAmount"
label="可匹配发票金额(含税)"
min-width="180"
align="center"
>
<template #default="{ row }">{{ formatMoney(row.availableInvoiceAmount) }}</template>
</el-table-column>
<el-table-column
prop="matchedAmount"
label="匹配结算单金额(含税)"
min-width="190"
align="center"
>
<template #default="{ row }">
<el-input-number
v-if="editable"
v-model="row.matchedAmount"
:min="0"
:max="Number(row.availableInvoiceAmount || 0)"
:precision="2"
:controls="false"
/>
<span v-else>{{ formatMoney(row.matchedAmount) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="220" align="center">
<template #default="{ row, $index }">
<div class="formal-editor__links">
<el-link type="primary" @click="viewInvoiceAttachment(row)">查看</el-link>
<el-link type="primary" @click="downloadInvoiceAttachment(row)">下载</el-link>
<el-link v-if="editable" type="danger" @click="removeInvoice($index)">删除</el-link>
</div>
</template>
</el-table-column>
</el-table>
</section-card>
<section-card
v-if="readonly"
:title="form.settlementType === 'receivable' ? '收款信息' : '付款信息'"
>
<el-table
v-if="form.settlementType === 'receivable'"
:data="receiptClaims"
border
empty-text="暂无收款认领数据"
>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column
prop="receiptNoticeNo"
label="认领通知单"
min-width="160"
align="center"
/>
<el-table-column prop="payerName" label="付款人" min-width="150" align="center">
<template #default="{ row }">{{ displayValue(row.payerName) }}</template>
</el-table-column>
<el-table-column prop="receiptAmount" label="收款金额" min-width="130" align="center">
<template #default="{ row }">{{ formatMoney(row.receiptAmount) }}</template>
</el-table-column>
<el-table-column
prop="allocatedReceiptAmount"
label="本次认领金额"
min-width="145"
align="center"
>
<template #default="{ row }">{{ formatMoney(row.allocatedReceiptAmount) }}</template>
</el-table-column>
<el-table-column prop="transactionTime" label="交易时间" min-width="170" align="center">
<template #default="{ row }">{{ displayValue(row.transactionTime) }}</template>
</el-table-column>
<el-table-column prop="claimerName" label="认领人" min-width="120" align="center">
<template #default="{ row }">{{ displayValue(row.claimerName) }}</template>
</el-table-column>
<el-table-column prop="claimDate" label="认领日期" min-width="130" align="center">
<template #default="{ row }">{{ displayValue(row.claimDate) }}</template>
</el-table-column>
<el-table-column prop="claimStatusName" label="认领状态" min-width="120" align="center">
<template #default="{ row }">{{ displayValue(row.claimStatusName) }}</template>
</el-table-column>
</el-table>
<el-table v-else :data="paymentApplications" border>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column prop="paymentTypeName" label="付款类型" min-width="120" align="center">
<template #default="{ row }">{{ displayValue(row.paymentTypeName) }}</template>
</el-table-column>
<el-table-column label="付款方式" min-width="140" align="center">
<template #default="{ row }">{{ paymentMethodName(row.paymentMethod) }}</template>
</el-table-column>
<el-table-column prop="paymentNo" label="单据号" min-width="160" align="center" />
<el-table-column prop="appliedAmount" label="申请付款金额" min-width="145" align="center">
<template #default="{ row }">{{ formatMoney(row.appliedAmount) }}</template>
</el-table-column>
<el-table-column prop="paidAmount" label="已付款金额" min-width="130" align="center">
<template #default="{ row }">{{ formatMoney(row.paidAmount) }}</template>
</el-table-column>
<el-table-column
prop="approvalStatusName"
label="单据状态"
min-width="120"
align="center"
>
<template #default="{ row }">{{ displayValue(row.approvalStatusName) }}</template>
</el-table-column>
<el-table-column prop="kingdeeBillNo" label="金蝶单据号" min-width="150" align="center">
<template #default="{ row }">{{ displayValue(row.kingdeeBillNo) }}</template>
</el-table-column>
<el-table-column prop="createUserName" label="创建人" min-width="120" align="center">
<template #default="{ row }">{{ displayValue(row.createUserName) }}</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="170" align="center">
<template #default="{ row }">{{ displayValue(row.createTime) }}</template>
</el-table-column>
</el-table>
</section-card>
<section-card v-if="readonly" title="结算调整单">
<el-table :data="adjustments" border>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column prop="adjustmentNo" label="调整单号" min-width="170" align="center" />
<el-table-column prop="adjustmentAmount" label="调整金额" min-width="130" align="center">
<template #default="{ row }">{{ formatMoney(row.adjustmentAmount) }}</template>
</el-table-column>
<el-table-column
prop="approvalStatusName"
label="单据状态"
min-width="120"
align="center"
>
<template #default="{ row }">{{ displayValue(row.approvalStatusName) }}</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="220" align="center">
<template #default="{ row }">{{ displayValue(row.remark) }}</template>
</el-table-column>
<el-table-column prop="kingdeeBillNo" label="金蝶单据号" min-width="150" align="center">
<template #default="{ row }">{{ displayValue(row.kingdeeBillNo) }}</template>
</el-table-column>
<el-table-column prop="createUserName" label="创建人" min-width="120" align="center">
<template #default="{ row }">{{ displayValue(row.createUserName) }}</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="170" align="center">
<template #default="{ row }">{{ displayValue(row.createTime) }}</template>
</el-table-column>
</el-table>
</section-card>
<section-card v-if="readonly" title="变更记录">
<el-table :data="changeRecords" border>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column prop="changeType" label="变更类型" min-width="150" align="center">
<template #default="{ row }">{{ displayValue(row.changeType) }}</template>
</el-table-column>
<el-table-column prop="lineNo" label="行号" min-width="90" align="center">
<template #default="{ row }">{{ displayValue(row.lineNo) }}</template>
</el-table-column>
<el-table-column prop="operationType" label="类型" min-width="110" align="center">
<template #default="{ row }">{{ displayValue(row.operationType) }}</template>
</el-table-column>
<el-table-column prop="changeContent" label="变更内容" min-width="360" align="center">
<template #default="{ row }">{{ displayValue(row.changeContent) }}</template>
</el-table-column>
<el-table-column prop="changeReason" label="变更原因" min-width="220" align="center">
<template #default="{ row }">{{ displayValue(row.changeReason) }}</template>
</el-table-column>
<el-table-column prop="operatorName" label="操作人" min-width="120" align="center">
<template #default="{ row }">{{ displayValue(row.operatorName) }}</template>
</el-table-column>
<el-table-column prop="changeTime" label="变更时间" min-width="170" align="center">
<template #default="{ row }">{{ displayValue(row.changeTime) }}</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="detailAmountAdjust.visible"
title="结算明细调整"
width="520px"
append-to-body
>
<el-form
:model="detailAmountAdjust"
label-position="right"
label-width="auto"
class="formal-editor__amount-adjust"
>
<el-form-item label="原金额">
<span>{{ formatMoney(detailAmountAdjust.originalAmount) }}</span>
</el-form-item>
<el-form-item label="调整金额" required>
<el-input-number
v-model="detailAmountAdjust.adjustAmount"
:precision="2"
:step="0.01"
:controls="false"
/>
</el-form-item>
<el-form-item label="结算金额">
<span>{{ formatMoney(detailAmountAdjustSettlementAmount) }}</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="detailAmountAdjust.visible = false">取消</el-button>
<el-button type="primary" @click="confirmDetailAmountAdjustment">提交</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="2"
: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 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,
getReceiptClaims,
save,
} from '@/api/settlement/formalSettlement';
import { getDetail as getPreSettlementDetail } from '@/api/settlement/preSettlement';
import {
createFormalSettlementForm,
formalSettlementFormFields,
} from '@/option/settlement/formalSettlementForm';
import {
candidateColumns,
candidateDetailColumns,
detailColumns,
sourceColumns,
summaryColumns,
} from '@/option/settlement/formalSettlementTable';
import { getDictionary } from '@/api/system/dictbiz';
import { h } from 'vue';
import { mapGetters } from 'vuex';
import { downloadFileByUrl } from '@/utils/util';
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: [],
paymentApplications: [],
receiptClaims: [],
adjustments: [],
changeRecords: [],
attachments: [],
selectedAttachmentRows: [],
invoices: [],
invoiceClaimNo: '',
invoiceClaimLoading: false,
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,
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: [],
},
detailAmountAdjust: {
visible: false,
row: null,
originalAmount: 0,
adjustAmount: 0,
},
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);
},
detailAmountAdjustSettlementAmount() {
return Number(
(
Number(this.detailAmountAdjust.originalAmount || 0) +
Number(this.detailAmountAdjust.adjustAmount || 0)
).toFixed(2)
);
},
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() {
const newRecordAudit = this.recordId
? null
: {
createUserName: this.userInfo?.realName || this.userInfo?.userName || '',
createTime: this.$dayjs().format('YYYY-MM-DD HH:mm:ss'),
};
this.form = createFormalSettlementForm();
if (newRecordAudit) Object.assign(this.form, newRecordAudit);
this.sources = [];
this.details = [];
this.summaryFees = [];
this.paymentApplications = [];
this.receiptClaims = [];
this.adjustments = [];
this.changeRecords = [];
this.attachments = [];
this.selectedAttachmentRows = [];
this.invoices = [];
this.invoiceClaimNo = '';
this.attachmentUploadFiles = [];
this.detailAmountAdjust = {
visible: false,
row: null,
originalAmount: 0,
adjustAmount: 0,
};
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();
Object.assign(this.form, newRecordAudit);
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.paymentApplications = data.paymentApplications || [];
if (this.readonly && this.form.settlementType === 'receivable') {
this.receiptClaims = this.unwrapData(await getReceiptClaims(this.recordId)) || [];
}
this.adjustments = data.adjustments || [];
this.changeRecords = data.changeRecords || [];
this.attachments = this.parseAttachments(data.attachmentsJson);
this.selectedAttachmentRows = [];
this.invoices = data.invoices || [];
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;
},
async removeDetail(row) {
await this.$confirm('确认将该明细踢出正式结算单?', '提示', { type: 'warning' });
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);
},
isNegativeAdjustedAmount(row, prop) {
return (
Number(row.adjustAmount || 0) < 0 && ['adjustAmount', 'settlementAmount'].includes(prop)
);
},
recalculateSummaryRow(row) {
row.settlementAmount = Number(row.originalAmount || 0) + Number(row.adjustAmount || 0);
},
getSummarySums({ columns, data }) {
const sumProps = ['originalAmount', 'adjustAmount', 'settlementAmount'];
const hasNegativeAdjustment = data.some(row => Number(row.adjustAmount || 0) < 0);
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);
const totalText = this.formatMoney(total);
if (column.property === 'settlementAmount' && hasNegativeAdjustment) {
return h('span', { style: { color: 'var(--el-color-danger)' } }, totalText);
}
return totalText;
});
},
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) {
if (!row.formalSettlementId) {
const originalAmount = Number(
row.originalAmount ??
row.totalAmount ??
Number(row.settlementAmountTax || 0) - Number(row.adjustAmount || 0)
);
this.detailAmountAdjust = {
visible: true,
row,
originalAmount: Number(originalAmount.toFixed(2)),
adjustAmount: Number(row.adjustAmount || 0),
};
return;
}
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;
}
},
confirmDetailAmountAdjustment() {
const settlementAmount = this.detailAmountAdjustSettlementAmount;
if (!Number.isFinite(settlementAmount) || settlementAmount < 0) {
return this.$message.warning('调整后的结算金额不能小于0');
}
const row = this.detailAmountAdjust.row;
if (!row) return;
row.originalAmount = this.detailAmountAdjust.originalAmount;
row.adjustAmount = Number(this.detailAmountAdjust.adjustAmount || 0);
row.settlementAmountTax = settlementAmount;
row.pendingDetailAdjustment = true;
this.buildSummaryFeesFromDetails();
this.detailAmountAdjust.visible = 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;
}
},
claimInvoices() {
this.invoiceClaimLoading = true;
try {
const keyword = String(this.invoiceClaimNo || '').trim();
const defaultNo = `MOCK${this.$dayjs().format('YYYYMMDD')}`;
const baseNo = (keyword || defaultNo).slice(0, 29);
const numbers = [baseNo, `${baseNo}-02`, `${baseNo}-03`];
const invoiceAmounts = [1000, 2000, 3000];
const availableAmounts = [1000, 1000, 3000];
const preferredMatchedAmounts = [1000, 1000, 2000];
let remainingAmount = Math.max(
Number(this.form.settlementAmount || this.summaryTotal || 0) -
this.invoices.reduce((total, item) => total + Number(item.matchedAmount || 0), 0),
0
);
const existingNumbers = new Set(this.invoices.map(item => String(item.invoiceNo)));
const mockRows = numbers
.map((invoiceNo, index) => {
const matchedAmount = Math.min(
preferredMatchedAmounts[index],
availableAmounts[index],
remainingAmount
);
remainingAmount = Number((remainingAmount - matchedAmount).toFixed(2));
return {
invoiceNo,
invoiceDate: this.$dayjs().subtract(index, 'day').format('YYYY-MM-DD'),
invoiceType: index === 1 ? '增值税普通发票' : '增值税专用发票',
taxRate: [3, 6, 9][index],
invoiceAmount: invoiceAmounts[index],
availableInvoiceAmount: availableAmounts[index],
matchedAmount,
attachmentJson: '',
mockData: true,
};
})
.filter(item => !existingNumbers.has(item.invoiceNo));
if (!mockRows.length) {
this.$message.warning('当前发票号码的Mock数据已认领');
return;
}
this.invoices = [...this.invoices, ...mockRows];
this.$message.success(`已生成${mockRows.length}条Mock发票数据`);
} finally {
this.invoiceClaimLoading = false;
}
},
validateInvoices() {
const invoiceNumbers = new Set();
let matchedTotal = 0;
for (const row of this.invoices) {
const invoiceNo = String(row.invoiceNo || '').trim();
const invoiceAmount = Number(row.invoiceAmount || 0);
const availableAmount = Number(row.availableInvoiceAmount || 0);
const matchedAmount = Number(row.matchedAmount || 0);
const taxRate = Number(row.taxRate || 0);
if (!invoiceNo) {
this.$message.warning('发票号不能为空');
return false;
}
if (invoiceNumbers.has(invoiceNo)) {
this.$message.warning(`发票号${invoiceNo}重复`);
return false;
}
invoiceNumbers.add(invoiceNo);
if ([invoiceAmount, availableAmount, matchedAmount].some(value => value < 0)) {
this.$message.warning(`发票${invoiceNo}的金额不能小于0`);
return false;
}
if (availableAmount > invoiceAmount) {
this.$message.warning(`发票${invoiceNo}的可匹配金额不能超过发票金额`);
return false;
}
if (matchedAmount > availableAmount) {
this.$message.warning(`发票${invoiceNo}的匹配金额不能超过可匹配金额`);
return false;
}
if (taxRate < 0 || taxRate > 100) {
this.$message.warning(`发票${invoiceNo}的税率必须在0-100之间`);
return false;
}
matchedTotal += matchedAmount;
}
if (matchedTotal > Number(this.form.settlementAmount || 0)) {
this.$message.warning('发票匹配结算单金额合计不能超过结算金额');
return false;
}
return true;
},
removeInvoice(index) {
this.invoices.splice(index, 1);
},
invoiceAttachment(row = {}) {
if (!row.attachmentJson) return {};
if (typeof row.attachmentJson === 'object') {
return Array.isArray(row.attachmentJson) ? row.attachmentJson[0] || {} : row.attachmentJson;
}
try {
const parsed = JSON.parse(row.attachmentJson);
return Array.isArray(parsed) ? parsed[0] || {} : parsed || {};
} catch {
return {};
}
},
invoiceAttachmentUrl(row) {
const attachment = this.invoiceAttachment(row);
return attachment.url || attachment.link || attachment.fileUrl || '';
},
invoiceAttachmentName(row) {
const attachment = this.invoiceAttachment(row);
return attachment.originalName || attachment.name || `${row.invoiceNo || '发票'}.pdf`;
},
viewInvoiceAttachment(row) {
const url = this.invoiceAttachmentUrl(row);
if (!url) return this.$message.warning('当前Mock发票暂无可查看附件');
window.open(url, '_blank');
},
downloadInvoiceAttachment(row) {
const url = this.invoiceAttachmentUrl(row);
if (!url) return this.$message.warning('当前Mock发票暂无可下载附件');
downloadFileByUrl(url, this.invoiceAttachmentName(row));
},
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.validateInvoices()) return;
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,
detailAdjustments: this.details
.filter(row => row.pendingDetailAdjustment)
.map(row => ({
sourcePreSettlementDetailId:
row.sourcePreSettlementDetailId || (row.sourcePreSettlementId ? row.id : undefined),
sourceDetailId: row.sourcePreSettlementId ? undefined : row.sourceDetailId || row.id,
adjustAmount: Number(row.adjustAmount || 0),
})),
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),
invoices: this.invoices.map(row => ({
invoiceNo: String(row.invoiceNo || '').trim(),
invoiceDate: row.invoiceDate || null,
invoiceType: row.invoiceType || '',
taxRate: Number(row.taxRate || 0),
invoiceAmount: Number(row.invoiceAmount || 0),
availableInvoiceAmount: Number(row.availableInvoiceAmount || 0),
matchedAmount: Number(row.matchedAmount || 0),
attachmentJson: row.attachmentJson || '',
})),
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;
},
paymentMethodName(value) {
const labels = {
bank_transfer: '银行转账',
bank_draft: '银行承兑汇票',
commercial_draft: '商业承兑汇票',
};
return this.displayValue(labels[value] || value);
},
unwrapData(response) {
const body = response?.data || response || {};
return body?.data || body;
},
formatMoney(value) {
return `${Number(value || 0).toFixed(2)} RMB`;
},
formatQuantity(value) {
if (value === undefined || value === null || value === '') return '-';
const quantity = Number(value);
return Number.isFinite(quantity) ? quantity.toFixed(2) : '-';
},
formatDetailTransportQuantity(value) {
return this.readonly && Number(value) === -1 ? '-' : this.formatQuantity(value);
},
formatDetailMileage(value) {
return this.readonly && Number(value) === -1 ? '-' : this.displayValue(value);
},
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();
},
handleAttachmentSelectionChange(rows) {
this.selectedAttachmentRows = rows || [];
},
removeAttachment(index) {
this.attachments.splice(index, 1);
this.selectedAttachmentRows = this.selectedAttachmentRows.filter(file =>
this.attachments.includes(file)
);
},
getAttachmentUrl(file = {}) {
return file.url || file.link || file.src || file.domain || '';
},
getAttachmentName(file = {}) {
return (
file.originalName || file.name || this.getAttachmentFileName(this.getAttachmentUrl(file))
);
},
downloadAttachment(file) {
const url = this.getAttachmentUrl(file);
if (!url) {
this.$message.warning('附件地址为空,无法下载');
return;
}
downloadFileByUrl(url, this.getAttachmentName(file) || '附件');
},
downloadAttachments() {
const files = this.selectedAttachmentRows.length
? this.selectedAttachmentRows
: this.attachments;
const downloadableFiles = files.filter(file => this.getAttachmentUrl(file));
if (!downloadableFiles.length) {
this.$message.warning(
this.selectedAttachmentRows.length ? '所选附件无法下载' : '暂无可下载附件'
);
return;
}
downloadableFiles.forEach((file, index) => {
window.setTimeout(() => this.downloadAttachment(file), index * 200);
});
},
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--page {
padding-bottom: 72px;
}
.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: end;
}
.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__amount-adjust :deep(.el-input-number) {
width: 100%;
}
.formal-editor__attachment-missing {
margin-left: 12px;
color: var(--el-color-danger);
font-size: 13px;
font-weight: 400;
}
.formal-editor__attachment-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
}
.formal-editor__attachment-upload {
margin-top: 12px;
}
.formal-editor__invoice-claim {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
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__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>