Files
tms-erp-web/src/views/settlement/components/formal-settlement-editor.vue
T
b2894lxlx a1da644c49 ✨ 优化港口地图回填、合同展示与运单导入空值
地图选点回填国家省市区;合同选择显示编号/名称;常用数据组织筛选修正;运单详情将-1费用字段显示为空。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 17:07:56 +08:00

2966 lines
113 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="formatContractOptionLabel(item)"
: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="feeCategoryName(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="pagedDetails" border>
<el-table-column
type="index"
label="序号"
width="64"
fixed="left"
align="center"
:index="detailIndexMethod"
/>
<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>
<div v-show="!detailCollapsed" class="formal-editor__pagination">
<el-pagination
v-model:current-page="detailPage.current"
v-model:page-size="detailPage.size"
:total="filteredDetails.length"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleDetailPageSizeChange"
/>
</div>
</section-card>
<section-card>
<template #title>
<span>附件</span>
<span v-if="missingAttachmentTypeText" class="formal-editor__attachment-missing">
{{ missingAttachmentTypeText }}
</span>
</template>
<template #extra>
<el-button type="primary" :disabled="!attachments.length" @click="downloadAttachments">
批量下载
</el-button>
</template>
<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="openInvoiceClaimDialog"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="invoiceClaimLoading"
@click="openInvoiceClaimDialog"
>
认领查询
</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>
<el-dialog
v-model="invoiceClaimDialog.visible"
title="查询可认领发票"
width="92%"
append-to-body
destroy-on-close
>
<el-table
ref="invoiceClaimTable"
v-loading="invoiceClaimDialog.loading"
:data="invoiceClaimPagedRows"
row-key="invoiceNo"
border
height="520"
@selection-change="invoiceClaimDialog.selected = $event"
>
<el-table-column
type="selection"
width="52"
fixed="left"
align="center"
reserve-selection
/>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column prop="invoiceNo" label="发票号" min-width="180" align="center" />
<el-table-column prop="invoiceDate" label="开票日期" min-width="130" align="center" />
<el-table-column prop="invoiceType" label="发票类型" min-width="150" align="center" />
<el-table-column prop="issuerName" label="开票单位" min-width="180" align="center" />
<el-table-column prop="receiverName" label="受票单位" min-width="180" align="center" />
<el-table-column
prop="invoiceAmount"
label="发票金额(含税)"
min-width="160"
align="right"
>
<template #default="{ row }">{{ formatMoney(row.invoiceAmount) }}</template>
</el-table-column>
<el-table-column prop="matchedAmount" label="认领金额" min-width="140" align="right">
<template #default="{ row }">{{ formatMoney(row.matchedAmount) }}</template>
</el-table-column>
</el-table>
<div class="formal-editor__pagination">
<el-pagination
v-model:current-page="invoiceClaimDialog.page.current"
v-model:page-size="invoiceClaimDialog.page.size"
:total="invoiceClaimDialog.page.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
/>
</div>
<template #footer>
<el-button @click="invoiceClaimDialog.visible = false">取消</el-button>
<el-button
type="primary"
:loading="invoiceClaimDialog.confirming"
@click="confirmInvoiceClaims"
>确认认领</el-button
>
</template>
</el-dialog>
<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 }">{{ changeRecordLineNo(row) }}</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 && !recordId"
type="primary"
plain
:loading="draftSaving"
@click="handleDraftSave"
>
保存
</el-button>
<el-button v-if="editable" type="primary" :loading="saving" @click="handleSave"
>提交</el-button
>
</template>
<div v-if="pageMode && !publicMode" class="formal-editor__page-actions">
<el-button @click="visible = false">取消</el-button>
<el-button
v-if="editable && !recordId"
type="primary"
plain
:loading="draftSaving"
@click="handleDraftSave"
>
保存
</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="96%"
append-to-body
destroy-on-close
>
<el-table v-loading="adjust.loading" :data="adjust.rows" border>
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column prop="cargoName" label="货物名称" min-width="140" align="center" />
<el-table-column prop="cargoType" label="货物类型" min-width="130" align="center" />
<el-table-column label="计费规则" min-width="190" align="center">
<template #default="{ row }">
<div class="formal-editor__billing-rule-cell">
<span>{{ displayValue(billingRuleName(row)) }}</span>
<el-icon
class="formal-editor__billing-rule-icon"
title="查看计费规则详情"
@click="openBillingRuleInfo(row)"
>
<InfoFilled />
</el-icon>
</div>
</template>
</el-table-column>
<el-table-column label="运输总量" min-width="150" align="center">
<template #default="{ row }">
<el-input-number
v-model="row.transportQuantity"
:min="0"
:precision="2"
:controls="false"
@change="recalculateAdjustRow(row, 'transportQuantity')"
/>
</template>
</el-table-column>
<el-table-column label="里程(KM)" min-width="140" align="center">
<template #default="{ row }">
<el-input-number
v-model="row.mileage"
:min="0"
:precision="2"
:controls="false"
@change="recalculateAdjustRow(row, 'mileage')"
/>
</template>
</el-table-column>
<el-table-column label="运输单价" min-width="140" align="center">
<template #default="{ row }">
<el-input-number
v-model="row.unitPrice"
:min="0"
:precision="2"
:controls="false"
@change="recalculateAdjustRow(row, 'unitPrice')"
/>
</template>
</el-table-column>
<el-table-column
v-for="feeItem in adjustFeeItemNames"
:key="feeItem"
:label="feeItem"
min-width="140"
align="center"
>
<template #default="{ row }">
<el-input-number
v-model="row.feeItems[feeItem]"
:min="0"
:precision="2"
:controls="false"
@change="recalculateAdjustRow(row, feeItem)"
/>
</template>
</el-table-column>
<el-table-column label="结算金额(含税)" min-width="170" align="center">
<template #default="{ row }">
<el-input-number v-model="row.settlementAmountTax" :precision="2" :controls="false" />
</template>
</el-table-column>
<el-table-column label="备注" min-width="200" align="center">
<template #default="{ row }">
<el-input v-model="row.remark" maxlength="200" />
</template>
</el-table-column>
</el-table>
<el-form
:model="adjust"
label-position="right"
label-width="auto"
class="formal-editor__adjust-reason"
>
<el-form-item label="调整原因" required>
<el-input v-model="adjust.reason" 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>
<el-dialog
v-model="billingRuleDialog.visible"
title="计费规则详情"
width="720px"
append-to-body
destroy-on-close
>
<template v-if="billingRuleDialog.rule">
<div class="dialog-section-title">计费规则</div>
<el-descriptions :column="2" border class="formal-editor__billing-rule-detail">
<el-descriptions-item label="规则名称">{{
displayValue(billingRuleDialog.rule.name)
}}</el-descriptions-item>
<el-descriptions-item label="费用类型">{{
displayValue(billingRuleDialog.rule.feeType)
}}</el-descriptions-item>
<el-descriptions-item label="费用项">{{
displayValue(billingRuleDialog.rule.feeItem)
}}</el-descriptions-item>
<el-descriptions-item label="计费要素">{{
displayValue(billingRuleDialog.rule.billingElement)
}}</el-descriptions-item>
<el-descriptions-item label="计费类型">{{
displayValue(billingRuleDialog.rule.billingType)
}}</el-descriptions-item>
<el-descriptions-item label="计费单位">{{
displayValue(billingRuleDialog.rule.billingUnit)
}}</el-descriptions-item>
<el-descriptions-item label="单价">{{
displayValue(billingRuleDialog.rule.unitPrice)
}}</el-descriptions-item>
<el-descriptions-item label="最低计费量">{{
displayValue(billingRuleDialog.rule.minimumBillingWeight)
}}</el-descriptions-item>
<el-descriptions-item label="计费区间">{{
billingRuleRanges(billingRuleDialog.rule)
}}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{
displayValue(billingRuleDialog.rule.remark)
}}</el-descriptions-item>
</el-descriptions>
<div class="dialog-section-title formal-editor__billing-rule-match-title">匹配条件</div>
<el-descriptions :column="2" border class="formal-editor__billing-rule-detail">
<el-descriptions-item label="起运地">{{
displayValue(billingRuleDialog.rule.matchCondition.origin)
}}</el-descriptions-item>
<el-descriptions-item label="目的地">{{
displayValue(billingRuleDialog.rule.matchCondition.destination)
}}</el-descriptions-item>
<el-descriptions-item label="运输方式">{{
displayValue(billingRuleDialog.rule.matchCondition.transportMode)
}}</el-descriptions-item>
<el-descriptions-item label="货物类型">{{
displayValue(billingRuleDialog.rule.matchCondition.cargoType)
}}</el-descriptions-item>
</el-descriptions>
</template>
<el-empty v-else description="暂无计费规则详情" />
<template #footer>
<el-button type="primary" @click="billingRuleDialog.visible = false">关闭</el-button>
</template>
</el-dialog>
</component>
</template>
<script>
import { getMkPublicDetail } from '@/api/mk-process';
import {
adjustDetail,
getCandidateDetails,
getCandidates,
getContractOptions,
getDetail,
getDetailFees,
getFeeOptions,
getNextNo,
getReceiptClaims,
claimInvoices as claimInvoicesApi,
save,
} from '@/api/settlement/formalSettlement';
import {
getDetail as getPreSettlementDetail,
getDetailFees as getPreSettlementDetailFees,
} from '@/api/settlement/preSettlement';
import { calculateAdjustedFee, getFeeDetail } from '@/api/settlement/receivable-payable-detail';
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 { InfoFilled } from '@element-plus/icons-vue';
import { mapGetters } from 'vuex';
import { formatContractOptionLabel } from '@/utils/contract';
import { downloadFileByUrl } from '@/utils/util';
import * as XLSX from 'xlsx';
export default {
name: 'FormalSettlementEditor',
components: { InfoFilled },
props: {
modelValue: Boolean,
recordId: [String, Number],
readonly: Boolean,
pageMode: Boolean,
initialData: {
type: Object,
default: null,
},
publicMode: {
type: Boolean,
default: false,
},
},
emits: ['update:modelValue', 'success'],
data() {
return {
loading: false,
draftSaving: false,
saving: false,
form: createFormalSettlementForm(),
sources: [],
details: [],
summaryFees: [],
paymentApplications: [],
receiptClaims: [],
adjustments: [],
changeRecords: [],
attachments: [],
selectedAttachmentRows: [],
invoices: [],
invoiceClaimNo: '',
invoiceClaimLoading: false,
invoiceClaimDialog: {
visible: false,
loading: false,
confirming: false,
rows: [],
selected: [],
page: { current: 1, size: 10, total: 0 },
},
attachmentUploadFiles: [],
attachmentTypeOptions: [],
attachmentFileTypes: [
'pdf',
'bmp',
'jpeg',
'png',
'jpg',
'doc',
'docx',
'ppt',
'pptx',
'xlsx',
'xls',
'eml',
'msg',
'zip',
'rar',
],
contracts: [],
allContracts: [],
projects: [],
feeOptions: [],
feeCategoryOptions: [],
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,
sourceDetailId: null,
feeKey: '',
reason: '',
rows: [],
targetRow: null,
},
detailFeeSnapshots: {},
pendingAdjustments: {},
billingRuleDialog: {
visible: false,
rule: null,
},
detailCollapsed: false,
detailQuery: {
documentNo: '',
waybillNo: '',
batchNo: '',
cargoName: '',
},
appliedDetailQuery: {
documentNo: '',
waybillNo: '',
batchNo: '',
cargoName: '',
},
detailPage: {
current: 1,
size: 10,
},
};
},
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());
})
);
},
pagedDetails() {
const list = this.filteredDetails;
const size = Number(this.detailPage.size) || 10;
const current = Math.max(Number(this.detailPage.current) || 1, 1);
const start = (current - 1) * size;
return list.slice(start, start + size);
},
missingAttachmentTypeText() {
const missing = this.getMissingAttachmentTypes();
return missing.length ? `未上传:${missing.join('、')}` : '';
},
adjustFeeItemNames() {
const names = new Set();
this.adjust.rows.forEach(row => {
Object.keys(row.feeItems || {}).forEach(name => names.add(name));
});
return Array.from(names);
},
invoiceClaimPagedRows() {
const { current, size } = this.invoiceClaimDialog.page;
const start = (current - 1) * size;
return this.invoiceClaimDialog.rows.slice(start, start + size);
},
},
watch: {
modelValue: {
immediate: true,
handler(value) {
if (value) this.initialize();
},
},
filteredDetails: {
handler(list) {
const size = Number(this.detailPage.size) || 10;
const maxPage = Math.max(1, Math.ceil((list?.length || 0) / size) || 1);
if (this.detailPage.current > maxPage) {
this.detailPage.current = maxPage;
}
},
immediate: true,
},
summaryTotal(value) {
this.form.settlementAmount = Number(value || 0).toFixed(2);
this.form.localSettlementAmount = (
Number(value || 0) * Number(this.form.exchangeRate || 1)
).toFixed(2);
},
'form.exchangeRate'(value) {
this.form.localSettlementAmount = (
Number(this.summaryTotal || 0) * Number(value || 1)
).toFixed(2);
},
},
methods: {
formatContractOptionLabel,
async loadFormalDetail(id) {
const response = this.publicMode
? await getMkPublicDetail('formal-settlement', id)
: await getDetail(id);
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.detailPage.current = 1;
this.summaryFees = data.summaryFees || [];
// 服务端数据已刷新,费用快照与暂存调整全部作废。
this.detailFeeSnapshots = {};
this.pendingAdjustments = {};
this.paymentApplications = data.paymentApplications || [];
if (this.readonly && this.form.settlementType === 'receivable' && !this.publicMode) {
this.receiptClaims = this.unwrapData(await getReceiptClaims(id)) || [];
}
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.filterContracts(this.allContracts, this.form.projectId);
},
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 (['receivable', 'payable'].includes(this.initialData?.settlementType)) {
this.form.settlementType = this.initialData.settlementType;
this.form.settlementTypeName =
this.initialData.settlementType === 'receivable' ? '应收' : '应付';
}
if (newRecordAudit) Object.assign(this.form, newRecordAudit);
this.sources = [];
this.details = [];
this.summaryFees = [];
this.detailFeeSnapshots = {};
this.pendingAdjustments = {};
this.paymentApplications = [];
this.receiptClaims = [];
this.adjustments = [];
this.changeRecords = [];
this.attachments = [];
this.selectedAttachmentRows = [];
this.invoices = [];
this.invoiceClaimNo = '';
this.invoiceClaimDialog.visible = false;
this.invoiceClaimDialog.rows = [];
this.invoiceClaimDialog.selected = [];
this.attachmentUploadFiles = [];
this.billingRuleDialog = {
visible: false,
rule: null,
};
this.detailCollapsed = false;
this.detailPage = { current: 1, size: 10 };
this.resetDetailQuery(false);
if (!this.publicMode) {
await Promise.all([
this.loadAllContracts(),
this.loadFeeOptions(),
this.loadFeeCategoryOptions(),
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 {
await this.loadFormalDetail(this.recordId);
} 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.filterContracts(this.allContracts, null, settlementType).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.filterContracts(this.allContracts, projectId, settlementType);
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.detailPage.current = 1;
this.form.sourceDetailIds = this.details.map(item => item.sourceDetailId);
await this.refreshSummaryFees();
},
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,
}))
);
this.detailPage.current = 1;
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) await this.refreshSummaryFees();
},
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 = [];
},
contractCategory(settlementType = this.form.settlementType) {
if (settlementType === 'receivable') return '客户合同';
if (settlementType === 'payable') return '承运商合同';
return '';
},
filterContracts(items, projectId, settlementType = this.form.settlementType) {
const category = this.contractCategory(settlementType);
return (items || []).filter(item => {
if (projectId && String(item.projectId) !== String(projectId)) return false;
if (!category) return true;
const itemCategory = String(item.contractCategory || '').trim();
if (itemCategory) return itemCategory === category;
const itemSettlementType = String(item.settlementType || '').trim();
return (
(category === '客户合同' && itemSettlementType === 'receivable') ||
(category === '承运商合同' && itemSettlementType === 'payable')
);
});
},
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.filterContracts(this.unwrapData(response), projectId);
}
},
async loadFeeOptions() {
const response = await getFeeOptions();
this.feeOptions = this.unwrapData(response) || [];
},
async loadFeeCategoryOptions() {
const { data } = await getDictionary({ code: 'fee_category' });
this.feeCategoryOptions = data?.data || [];
},
handleProjectChange(id) {
const project = this.projects.find(item => String(item.id) === String(id));
this.form.projectName = project?.name || '';
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;
const settlementType =
contract.settlementType ||
(contract.contractCategory === '客户合同'
? 'receivable'
: contract.contractCategory === '承运商合同'
? 'payable'
: this.form.settlementType);
Object.assign(this.form, contract, {
id: formalSettlementId,
contractId: contract.id,
payerName:
contract.payerName ||
(settlementType === 'receivable' ? contract.partyB : contract.partyA),
payeeName:
contract.payeeName ||
(settlementType === 'receivable' ? contract.partyA : contract.partyB),
settlementType,
settlementTypeName: 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,
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();
},
async 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.detailPage.current = 1;
this.form.sourceDetailIds = [...existing.values()].map(item => item.sourceDetailId);
this.detailCandidate.loading = true;
try {
await this.refreshSummaryFees();
} finally {
this.detailCandidate.loading = false;
}
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);
await this.refreshSummaryFees();
},
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 || [];
},
// fee_category 业务字典:dictKey 为字典键值(落库值),dictValue 为字典名称(展示值)。
feeCategoryName(value) {
if (value === undefined || value === null || value === '') return '';
const option = this.feeCategoryOptions.find(
item => String(item.dictKey) === String(value) || String(item.dictValue) === String(value)
);
if (option) return option.dictValue ?? value;
return (
this.feeOptions.find(item => String(item.feeType) === String(value))?.feeTypeName || value
);
},
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;
});
},
async refreshSummaryFees() {
await this.ensureDetailFeeSnapshots();
this.buildSummaryFeesFromDetails();
},
// 结算合计按费用项归集:原金额取明细进入结算单时的费用金额,结算金额取调整后的金额,调整金额为两者之差。
buildSummaryFeesFromDetails() {
const generatedMap = new Map();
const existingGenerated = new Map(
this.summaryFees
.filter(row => Number(row.manualFlag) !== 1)
.map(row => [`${row.feeType}\u0000${row.feeItem}`, row])
);
const append = (feeItem, originalValue, settlementValue, fallbackFeeType = '') => {
const originalAmount = Number(originalValue || 0);
const settlementAmount = Number(settlementValue || 0);
if (!feeItem || !Number.isFinite(originalAmount) || !Number.isFinite(settlementAmount)) {
return;
}
if (Math.abs(originalAmount) < 0.005 && Math.abs(settlementAmount) < 0.005) return;
const option = this.feeOptions.find(item =>
(item.feeItems || []).some(name => String(name) === String(feeItem))
);
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: 0,
settlementAmount: 0,
remark: old?.remark || '',
manualFlag: 0,
};
current.originalAmount = Number((current.originalAmount + originalAmount).toFixed(2));
current.settlementAmount = Number((current.settlementAmount + settlementAmount).toFixed(2));
current.adjustAmount = Number(
(current.settlementAmount - current.originalAmount).toFixed(2)
);
generatedMap.set(key, current);
};
this.details.forEach(detail => {
this.resolveDetailFeePairs(detail).forEach(([baseRow, currentRow]) =>
this.appendFeeRowSummary(baseRow, currentRow, detail, append)
);
});
const manualRows = this.summaryFees.filter(row => Number(row.manualFlag) === 1);
this.summaryFees = [...generatedMap.values(), ...manualRows];
},
// 每条明细对应的费用行:[调整前基准行, 调整后当前行],缺少费用快照时退化为明细自身的汇总行。
resolveDetailFeePairs(detail) {
const feeKey = this.detailFeeKey(detail);
const baseRows = this.detailFeeSnapshots[feeKey] || [];
if (!baseRows.length) return [this.buildDetailFeePair(detail)];
const pendingRows = this.pendingAdjustments[feeKey]?.rows || [];
return baseRows.map((baseRow, index) => {
const matched =
pendingRows.find(row => baseRow.id && String(row.id) === String(baseRow.id)) ||
pendingRows[index];
return [baseRow, matched || baseRow];
});
},
buildDetailFeePair(detail) {
const baseRow = {
feeItems: this.parseFeeItems(detail.feeItemsJson || detail.feeItems),
feeType: detail.feeType,
freightAmount: Number(detail.freightAmount || 0),
settlementAmountTax: Number(
detail.originalAmount ?? this.detailSettlementAmount(detail)
),
};
return [baseRow, { ...baseRow, settlementAmountTax: this.detailSettlementAmount(detail) }];
},
detailSettlementAmount(detail) {
return Number(
detail?.settlementAmountTax ??
detail?.totalAmount ??
detail?.afterAmount ??
detail?.settlementAmount ??
0
);
},
appendFeeRowSummary(baseRow, currentRow, detail, append) {
const fallbackFeeType = currentRow.feeType || detail.feeType || '';
const feeItemNames = Array.from(
new Set([...Object.keys(baseRow.feeItems || {}), ...Object.keys(currentRow.feeItems || {})])
);
feeItemNames.forEach(name =>
append(name, baseRow.feeItems?.[name], currentRow.feeItems?.[name], fallbackFeeType)
);
const hasFreightItem = feeItemNames.some(name => this.isFreightFeeItem(name));
if (!hasFreightItem) {
append('运输费', baseRow.freightAmount, currentRow.freightAmount, '物流配送');
}
const knownAmount = row =>
this.sumFeeItems(row.feeItems) + (hasFreightItem ? 0 : Number(row.freightAmount || 0));
const baseResidual = Number(
(Number(baseRow.settlementAmountTax || 0) - knownAmount(baseRow)).toFixed(2)
);
const currentResidual = Number(
(Number(currentRow.settlementAmountTax || 0) - knownAmount(currentRow)).toFixed(2)
);
if (Math.abs(baseResidual) < 0.005 && Math.abs(currentResidual) < 0.005) return;
// 结算金额被手工改写且无法拆分到具体费用项时,计入金额占比最大的费用项,否则归入其他费用。
append(
this.residualFeeItem(baseRow, currentRow),
baseResidual,
currentResidual,
fallbackFeeType
);
},
residualFeeItem(baseRow, currentRow) {
const feeItems = { ...(baseRow.feeItems || {}), ...(currentRow.feeItems || {}) };
const dominant = Object.entries(feeItems)
.filter(([, amount]) => Math.abs(Number(amount || 0)) >= 0.005)
.sort((a, b) => Math.abs(Number(b[1] || 0)) - Math.abs(Number(a[1] || 0)))[0];
if (dominant) return dominant[0];
return (
currentRow.billingRule?.feeItem ||
currentRow.feeItem ||
baseRow.billingRule?.feeItem ||
baseRow.feeItem ||
'其他费用'
);
},
sumFeeItems(feeItems) {
return Number(
Object.values(feeItems || {})
.reduce((total, value) => total + Number(value || 0), 0)
.toFixed(2)
);
},
detailFeeKey(detail) {
if (detail.formalSettlementId && detail.id) return `formal:${detail.id}`;
if (detail.sourcePreSettlementId && detail.id) return `pre:${detail.id}`;
const sourceDetailId = detail.sourceDetailId || detail.id;
return sourceDetailId ? `src:${sourceDetailId}` : '';
},
async loadDetailFeeRows(detail) {
if (this.publicMode) {
return [this.normalizeAdjustRow(detail)];
}
if (detail.formalSettlementId && detail.id) {
const rows = this.unwrapData(await getDetailFees(detail.id)) || [];
return rows.map(item => this.normalizeAdjustRow(item));
}
if (detail.sourcePreSettlementId && detail.id) {
const rows = this.unwrapData(await getPreSettlementDetailFees(detail.id)) || [];
return rows.map(item => this.normalizeAdjustRow(item));
}
const sourceDetailId = detail.sourceDetailId || detail.id;
if (!sourceDetailId) return [];
const data = this.unwrapData(await getFeeDetail(sourceDetailId)) || {};
return (data.records || []).map(item =>
this.normalizeAdjustRow(item, data.feeItemNames || [])
);
},
// 明细列表只有汇总金额,费用项级别的原始金额需要按明细单独拉取并缓存为调整基准。
async ensureDetailFeeSnapshots() {
const targets = this.details.filter(detail => {
const feeKey = this.detailFeeKey(detail);
return feeKey && !this.detailFeeSnapshots[feeKey];
});
if (!targets.length) return;
await Promise.all(
targets.map(async detail => {
const feeKey = this.detailFeeKey(detail);
try {
const feeRows = await this.loadDetailFeeRows(detail);
if (!feeRows.length) return;
this.detailFeeSnapshots[feeKey] = feeRows;
this.applySnapshotToDetail(detail, feeRows);
} catch (error) {
// 取不到费用明细时退回按明细汇总金额归集,后续操作会再次尝试拉取。
delete this.detailFeeSnapshots[feeKey];
}
})
);
},
applySnapshotToDetail(detail, feeRows) {
if (!feeRows.length || detail.pendingDetailAdjustment) return;
const feeItems = {};
feeRows.forEach(row =>
Object.entries(row.feeItems || {}).forEach(([name, amount]) => {
feeItems[name] = Number((Number(feeItems[name] || 0) + Number(amount || 0)).toFixed(2));
})
);
const settlementAmount = feeRows.reduce(
(total, row) => total + Number(row.settlementAmountTax || 0),
0
);
const freightAmount = feeRows.reduce(
(total, row) => total + Number(row.freightAmount || 0),
0
);
Object.assign(detail, {
feeItemsJson: JSON.stringify(feeItems),
freightAmount: Number(freightAmount.toFixed(2)),
originalAmount: Number(settlementAmount.toFixed(2)),
adjustAmount: 0,
settlementAmountTax: Number(settlementAmount.toFixed(2)),
});
},
normalizeAdjustRow(item, feeItemNames = []) {
const rawFeeItems =
item.feeItems && typeof item.feeItems === 'object'
? item.feeItems
: this.parseFeeItems(item.feeItemsJson);
const names = feeItemNames.length ? feeItemNames : Object.keys(rawFeeItems);
return {
...item,
transportQuantity: Number(item.transportQuantity || 0),
mileage:
item.mileage === null || item.mileage === undefined || Number(item.mileage) === -1
? null
: Number(item.mileage),
freightAmount: Number(item.freightAmount || 0),
feeItems: Object.fromEntries(names.map(name => [name, Number(rawFeeItems[name] || 0)])),
settlementAmountTax: Number(
item.settlementAmountTax ?? item.totalAmount ?? item.afterAmount ?? item.settlementAmount ?? 0
),
billingRule: this.normalizeBillingRule(item),
calculating: false,
calculateError: '',
adjustCalculateTimer: null,
adjustCalculateVersion: 0,
};
},
cloneAdjustRows(rows) {
return (rows || []).map(row => ({
...row,
feeItems: { ...row.feeItems },
calculating: false,
calculateError: '',
adjustCalculateTimer: null,
adjustCalculateVersion: 0,
}));
},
isFreightFeeItem(name) {
return name && (name.includes('运费') || name.includes('运输费'));
},
handleDetailQuery() {
this.appliedDetailQuery = { ...this.detailQuery };
this.detailPage.current = 1;
},
resetDetailQuery(apply = true) {
this.detailQuery = { documentNo: '', waybillNo: '', batchNo: '', cargoName: '' };
if (apply) this.handleDetailQuery();
else this.appliedDetailQuery = { ...this.detailQuery };
},
handleDetailPageSizeChange() {
this.detailPage.current = 1;
},
detailIndexMethod(index) {
return (this.detailPage.current - 1) * this.detailPage.size + index + 1;
},
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`
);
},
billingRuleName(row) {
return this.normalizeBillingRule(row).name;
},
openBillingRuleInfo(row) {
this.billingRuleDialog.rule = this.normalizeBillingRule(row);
this.billingRuleDialog.visible = true;
},
normalizeBillingRule(row) {
const source = row || {};
const nested = [
source.billingRule,
source.billingRuleInfo,
source.billingRuleJson,
source.billingRulesJson,
source.billingRules,
source.billingPlanRule,
source.feeRule,
source.rule,
].find(value => value !== undefined && value !== null && value !== '');
const parsedNested = this.parseJsonValue(nested);
const matchedRules = Array.isArray(parsedNested)
? parsedNested.filter(item => item && typeof item === 'object')
: [];
const rule = this.parseObject(matchedRules[0] || parsedNested || nested);
const matchCondition = this.parseObject(
source.matchCondition ?? source.billingMatchCondition ?? rule.matchCondition
);
const ruleNames = matchedRules
.map(item => item.ruleName || item.name || item.billingRuleName || item.feeItem)
.filter(Boolean);
return {
...rule,
name:
source.billingRuleName ||
source.feeRuleName ||
source.ruleName ||
source.billingPlanRuleName ||
source.billingPlanName ||
rule.name ||
rule.ruleName ||
rule.billingRuleName ||
rule.planName ||
rule.billingPlanName ||
ruleNames.join('、') ||
rule.feeItem ||
'',
feeType: source.billingFeeType || source.feeType || rule.feeType,
feeItem: source.billingFeeItem || source.feeItem || rule.feeItem,
billingElement:
source.billingElement ||
source.billingFactor ||
rule.billingElement ||
rule.billingFactor,
billingType: source.billingType || rule.billingType,
billingUnit: source.billingUnit || source.priceUnit || rule.billingUnit || rule.priceUnit,
unitPrice: source.billingUnitPrice ?? source.unitPrice ?? rule.unitPrice,
minimumBillingWeight:
source.minimumBillingWeight ?? rule.minimumBillingWeight ?? rule.lowerLimit,
remark: source.billingRuleRemark || source.ruleRemark || rule.remark,
rules: matchedRules,
matchCondition: {
...matchCondition,
origin: matchCondition.origin || matchCondition.originName,
destination: matchCondition.destination || matchCondition.destinationName,
transportMode: matchCondition.transportMode || matchCondition.transportType,
cargoType: matchCondition.cargoType || matchCondition.cargoTypeName,
},
};
},
billingRuleRanges(rule) {
const ranges = Array.isArray(rule?.limitRanges) ? rule.limitRanges : [];
if (ranges.length) {
return ranges
.map(
item => `${item.lowerLimit ?? '-'}~${item.upperLimit ?? '-'}:${item.unitPrice ?? '-'}`
)
.join(';');
}
if (rule?.lowerLimit !== undefined || rule?.upperLimit !== undefined) {
return `${rule.lowerLimit ?? '-'}~${rule.upperLimit ?? '-'}`;
}
return '-';
},
recalculateAdjustRow(row, changedField) {
if (changedField && this.isFreightFeeItem(changedField)) {
row.freightAmount = Number(row.feeItems[changedField] || 0);
}
const feeItemTotal = Object.values(row.feeItems || {}).reduce(
(total, value) => total + Number(value || 0),
0
);
const hasFreightItem = Object.keys(row.feeItems || {}).some(this.isFreightFeeItem);
row.settlementAmountTax = Number(
(hasFreightItem ? feeItemTotal : Number(row.freightAmount || 0) + feeItemTotal).toFixed(2)
);
if (changedField && this.adjust.sourceDetailId && (row.sourceFeeId || row.id)) {
this.scheduleSourceAdjustCalculation(row);
}
},
scheduleSourceAdjustCalculation(row) {
if (row.adjustCalculateTimer) clearTimeout(row.adjustCalculateTimer);
row.adjustCalculateVersion = Number(row.adjustCalculateVersion || 0) + 1;
row.calculating = true;
const version = row.adjustCalculateVersion;
row.adjustCalculateTimer = setTimeout(async () => {
row.adjustCalculateTimer = null;
try {
const response = await calculateAdjustedFee({
detailId: this.adjust.sourceDetailId,
feeId: row.sourceFeeId || row.id,
transportQuantity: Number(row.transportQuantity || 0),
mileage: Number(row.mileage || 0),
freightAmount: Number(row.freightAmount || 0),
feeItems: Object.fromEntries(
Object.entries(row.feeItems || {}).map(([name, amount]) => [name, Number(amount || 0)])
),
});
if (version !== row.adjustCalculateVersion || !this.adjust.visible) return;
const data = response.data?.data || response.data || {};
row.freightAmount = Number(data.freightAmount || 0);
row.feeItems = Object.fromEntries(
Object.entries(data.feeItems || {}).map(([name, amount]) => [name, Number(amount || 0)])
);
row.adjustAmount = Number(data.adjustAmount || 0);
row.settlementAmountTax = Number(data.afterAmount || 0);
} catch (error) {
row.calculateError = error.message || '费用试算失败';
} finally {
if (version === row.adjustCalculateVersion) row.calculating = false;
}
}, 300);
},
async openAdjustDialog(row) {
const feeKey = this.detailFeeKey(row);
const pending = feeKey ? this.pendingAdjustments[feeKey] : null;
this.adjust = {
visible: true,
loading: true,
saving: false,
detailId: row.formalSettlementId ? row.id : null,
sourceDetailId: row.sourceDetailId || null,
feeKey,
reason: pending?.reason || '',
rows: [],
targetRow: row.formalSettlementId ? null : row,
};
try {
// 已暂存的调整结果优先回显,避免重新打开时又回到调整前的数据。
if (pending?.rows?.length) {
this.adjust.rows = this.cloneAdjustRows(pending.rows);
return;
}
if (feeKey && !this.detailFeeSnapshots[feeKey]) {
const feeRows = await this.loadDetailFeeRows(row);
if (feeRows.length) this.detailFeeSnapshots[feeKey] = feeRows;
}
const snapshot = feeKey ? this.detailFeeSnapshots[feeKey] : null;
this.adjust.rows = snapshot?.length
? this.cloneAdjustRows(snapshot)
: [this.normalizeAdjustRow(row)];
} finally {
this.adjust.loading = false;
}
},
async saveAdjustment() {
if (!this.adjust.reason.trim()) return this.$message.warning('请输入调整原因');
if (this.adjust.rows.some(row => row.calculating)) {
return this.$message.warning('费用正在重新计算,请稍候');
}
if (!this.adjust.detailId) {
const target = this.adjust.targetRow;
const edited = this.adjust.rows[0];
if (!target || !edited) return;
// 原金额以明细进入结算单时的费用快照为基准,调整金额 = 结算金额 - 原金额。
const baseRows = this.detailFeeSnapshots[this.adjust.feeKey] || [];
const originalAmount = baseRows.length
? Number(
baseRows
.reduce((total, row) => total + Number(row.settlementAmountTax || 0), 0)
.toFixed(2)
)
: Number(
target.originalAmount ??
target.totalAmount ??
target.afterAmount ??
target.settlementAmountTax ??
target.settlementAmount ??
0
);
const settlementAmount = Number(
this.adjust.rows
.reduce((total, item) => total + Number(item.settlementAmountTax || 0), 0)
.toFixed(2)
);
const feeItems = this.adjust.rows.reduce((result, item) => {
Object.entries(item.feeItems || {}).forEach(([name, amount]) => {
result[name] = Number((Number(result[name] || 0) + Number(amount || 0)).toFixed(2));
});
return result;
}, {});
if (!Number.isFinite(settlementAmount) || settlementAmount < 0) {
return this.$message.warning('调整后的结算金额不能小于0');
}
Object.assign(target, {
originalAmount,
adjustAmount: Number((settlementAmount - originalAmount).toFixed(2)),
settlementAmountTax: settlementAmount,
feeItemsJson: JSON.stringify(feeItems),
freightAmount: this.adjust.rows.reduce(
(total, item) => total + Number(item.freightAmount || 0),
0
),
transportQuantity: this.adjust.rows.reduce(
(total, item) => total + Number(item.transportQuantity || 0),
0
),
mileage: this.adjust.rows.reduce((total, item) => total + Number(item.mileage || 0), 0),
unitPrice: edited.unitPrice,
remark: edited.remark,
pendingDetailAdjustment: true,
});
if (this.adjust.feeKey) {
this.pendingAdjustments[this.adjust.feeKey] = {
reason: this.adjust.reason,
rows: this.cloneAdjustRows(this.adjust.rows),
};
}
await this.refreshSummaryFees();
this.adjust.visible = false;
this.$message.success('调整已暂存,保存正式结算单后生效');
return;
}
this.adjust.saving = true;
try {
await adjustDetail({
detailId: this.adjust.detailId,
changeReason: this.adjust.reason,
rows: this.adjust.rows.map(row => ({
id: row.id,
transportQuantity: row.transportQuantity,
mileage: row.mileage,
unitPrice: row.unitPrice,
freightAmount: row.freightAmount,
feeItems: row.feeItems,
settlementAmountTax: row.settlementAmountTax,
settlementAmountNoTax: row.settlementAmountNoTax,
remark: row.remark,
})),
});
this.$message.success('调整保存成功');
this.adjust.visible = false;
await this.loadFormalDetail(this.form.id);
} 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;
}
},
openInvoiceClaimDialog() {
const stamp = Date.now();
this.invoiceClaimDialog.loading = true;
this.invoiceClaimDialog.rows = Array.from({ length: 80 }, (_, index) => {
const amount = Math.floor(1000 + Math.random() * 49000);
return {
invoiceNo: `MOCK${stamp}${String(index + 1).padStart(3, '0')}`.slice(0, 32),
invoiceDate: this.$dayjs()
.subtract(Math.floor(Math.random() * 90), 'day')
.format('YYYY-MM-DD'),
invoiceType: index % 2 ? '增值税普通发票' : '增值税专用发票',
issuerName: this.form.payerName || 'Mock开票单位',
receiverName: this.form.payeeName || 'Mock受票单位',
invoiceAmount: amount,
availableInvoiceAmount: amount,
matchedAmount: amount,
taxRate: [3, 6, 9, 13][index % 4],
attachmentJson: '',
mockData: true,
};
});
this.invoiceClaimDialog.selected = [];
this.invoiceClaimDialog.page.current = 1;
this.invoiceClaimDialog.page.total = 80;
this.invoiceClaimDialog.visible = true;
this.invoiceClaimDialog.loading = false;
},
async confirmInvoiceClaims() {
if (!this.invoiceClaimDialog.selected.length) {
this.$message.warning('请至少选择一条发票');
return;
}
this.invoiceClaimDialog.confirming = true;
try {
const existingNumbers = new Set(this.invoices.map(item => String(item.invoiceNo)));
let remaining = Math.max(
Number(this.form.settlementAmount || 0) -
this.invoices.reduce((total, item) => total + Number(item.matchedAmount || 0), 0),
0
);
const invoices = this.invoiceClaimDialog.selected
.filter(row => !existingNumbers.has(String(row.invoiceNo)))
.map(row => {
const sourceAmount = Number(row.matchedAmount || 0);
const matchedAmount =
Number(this.form.settlementAmount || 0) > 0
? Math.min(sourceAmount, remaining)
: sourceAmount;
if (Number(this.form.settlementAmount || 0) > 0) {
remaining = Math.max(0, remaining - matchedAmount);
}
return { ...row, matchedAmount };
})
.filter(row => row.matchedAmount > 0);
if (!invoices.length) {
this.$message.warning('当前正式结算单没有可认领的剩余金额');
return;
}
if (!this.form.id) {
this.invoices = [...this.invoices, ...invoices];
this.invoiceClaimDialog.visible = false;
this.$message.success(`已选择${invoices.length}条发票,保存正式结算单后生效`);
return;
}
await claimInvoicesApi({ formalSettlementId: this.form.id, invoices });
await this.loadFormalDetail(this.form.id);
this.invoiceClaimDialog.visible = false;
this.$message.success('发票认领成功,已同步正式结算单及关联付款单据');
} finally {
this.invoiceClaimDialog.confirming = 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));
},
buildSavePayload() {
return {
id: this.form.id,
formalSettlementNo: this.form.formalSettlementNo,
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,
};
},
async handleDraftSave() {
this.draftSaving = true;
try {
await save({
...this.buildSavePayload(),
approvalStatus: 'draft',
});
this.$message.success('保存成功');
this.visible = false;
this.$emit('success');
} finally {
this.draftSaving = 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.validateInvoices()) return;
// 附件类型仅做提示,不阻断正式结算单新增/保存提交。
// this.validateAttachments();
this.saving = true;
try {
await save(this.buildSavePayload());
this.$message.success('保存成功');
this.visible = false;
this.$emit('success');
} finally {
this.saving = false;
}
},
displayValue(value) {
return value === null || value === undefined || value === '' ? '-' : value;
},
changeRecordLineNo(row) {
const lineNo = row?.lineNo;
return lineNo === -1 || String(lineNo) === '-1' ? '' : this.displayValue(lineNo);
},
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 => this.isOtherAttachmentType(item));
if (otherType?.value) return otherType.value;
this.attachmentTypeOptions.push({ label: '其他附件', value: '其他附件' });
return '其他附件';
},
isOtherAttachmentType(item) {
const typeText = String(item?.label || item?.value || '').trim();
return typeText === '其他' || typeText === '其他附件';
},
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 => !this.isOtherAttachmentType(item))
.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 {};
}
},
parseJsonValue(value) {
if (value === undefined || value === null || value === '') return null;
if (typeof value === 'object') return value;
try {
return JSON.parse(value);
} catch {
return value;
}
},
parseObject(value) {
const parsed = this.parseJsonValue(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
},
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__billing-rule-cell {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
max-width: 100%;
}
.formal-editor__billing-rule-icon {
flex: 0 0 auto;
color: var(--el-color-primary);
cursor: pointer;
}
.formal-editor__billing-rule-detail {
margin-top: 12px;
}
.formal-editor__billing-rule-match-title {
margin-top: 20px;
}
.dialog-section-title {
display: flex;
align-items: center;
min-height: 22px;
font-size: 16px;
font-weight: 600;
}
.dialog-section-title::before {
width: 4px;
height: 18px;
margin-right: 8px;
background: #409eff;
content: '';
}
.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__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>