Files
tms-erp-web/src/views/settlement/components/pre-settlement-editor.vue
T
gxwebsoft a4c2987c5f style(table): 全站文件名列添加省略号与溢出隐藏
- 统一给文件名相关的 el-table-column 添加 class-name="is-ellipsis-link"
- 解决了 el-link 为 flex 项导致列宽撑破的问题
- 在样式中新增 is-ellipsis-link 规则,实现文本溢出省略号效果
- 保证超长文件名不会破坏表格布局且能完整展示链接提示
- 适用于业务多个组件中涉及附件、文件名等长文本列的处理
2026-10-12 12:12:24 +08:00

3002 lines
106 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<component
:is="editorContainer"
v-bind="editorContainerProps"
@update:model-value="visible = $event"
@closed="handleClosed"
>
<div v-loading="loading" class="pre-settlement-editor__content">
<section-card title="结算基本信息">
<el-form
ref="formRef"
:model="form"
:rules="formRules"
label-position="right"
label-width="auto"
class="pre-settlement-editor__form"
>
<el-row :gutter="24">
<el-col v-for="field in formFields" :key="field.prop" :span="field.span || 6">
<el-form-item :label="field.label" :prop="field.prop">
<el-input
v-if="field.type === 'contract'"
:model-value="formatContractOptionLabel(form)"
readonly
:disabled="!editable || details.length > 0"
placeholder="请选择合同"
>
<template #append>
<el-button
:disabled="!editable || details.length > 0"
@click="openContractDialog"
>
选择
</el-button>
</template>
</el-input>
<el-date-picker
v-else-if="field.type === 'date'"
v-model="form[field.prop]"
type="date"
value-format="YYYY-MM-DD"
format="YYYY-MM-DD"
:disabled="!editable || form.currency === 'RMB'"
placeholder="请选择"
@change="recalculateLocalAmount"
/>
<el-input-number
v-else-if="field.type === 'number'"
v-model="form[field.prop]"
:min="0"
:precision="2"
:controls="false"
:disabled="!editable || form.currency === 'RMB'"
@change="recalculateLocalAmount"
/>
<el-input
v-else-if="field.type === 'textarea'"
v-model="form[field.prop]"
type="textarea"
:rows="2"
:maxlength="field.maxlength"
show-word-limit
:disabled="!editable"
placeholder="请输入"
/>
<span v-else-if="field.prop === 'settlementType'" class="form-readonly">
{{ settlementTypeName }}
</span>
<span v-else-if="field.prop === 'preSettlementNo'" class="form-readonly">
{{ form.preSettlementNo || (!form.id ? '系统自动生成' : '-') }}
</span>
<span v-else-if="field.prop === 'createTime'" class="form-readonly">
{{ formatCreateDate(form.createTime) }}
</span>
<span v-else-if="field.money" class="form-readonly">
{{ formatMoney(form[field.prop], moneyCurrency(field.prop)) }}
</span>
<span v-else class="form-readonly">{{ displayValue(form[field.prop]) }}</span>
</el-form-item>
</el-col>
</el-row>
</el-form>
</section-card>
<section-card title="结算合计">
<template #extra>
<el-button v-if="editable" type="primary" plain @click="addSummaryFee">
添加费用
</el-button>
</template>
<el-table :data="summaryFees" border show-summary :summary-method="getSummarySums">
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column
v-for="column in summaryColumns"
:key="column.prop"
:label="column.label"
:prop="column.prop"
:min-width="column.minWidth"
align="center"
>
<template #default="{ row }">
<el-select
v-if="editable && column.prop === 'feeType' && row.manualFlag === 1"
v-model="row.feeType"
filterable
placeholder="请选择"
@change="handleManualFeeTypeChange(row)"
>
<el-option
v-for="item in feeCategoryOptions"
:key="item.id || item.dictKey"
:label="item.dictValue || item.dictKey"
:value="item.dictKey"
/>
</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' && Number(row.manualFlag) === 1
"
v-model="row.adjustAmount"
:class="{
'pre-settlement-editor__negative-amount': Number(row.adjustAmount || 0) < 0,
}"
:precision="2"
:controls="false"
@change="recalculateSummaryRow(row)"
/>
<el-input
v-else-if="editable && column.prop === 'remark'"
v-model="row.remark"
maxlength="50"
show-word-limit
/>
<span
v-else-if="isSummaryMoney(column.prop)"
:class="{
'pre-settlement-editor__negative-amount':
column.prop === 'adjustAmount' && Number(row.adjustAmount || 0) < 0,
}"
>
{{ formatMoney(row[column.prop], form.currency) }}
</span>
<span v-else-if="column.prop === 'feeType'">
{{ feeCategoryName(row.feeType) }}
</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
</el-table-column>
<el-table-column v-if="editable" label="操作" width="100" align="center">
<template #default="{ row, $index }">
<el-link v-if="row.manualFlag === 1" type="danger" @click="removeSummaryFee($index)">
删除
</el-link>
</template>
</el-table-column>
</el-table>
</section-card>
<section-card title="结算明细">
<template #extra>
<div class="pre-settlement-editor__section-actions">
<el-button
v-if="hasPermission('pre_settlement_export')"
type="primary"
plain
@click="exportDetails"
>
导出
</el-button>
<el-button v-if="editable" type="primary" plain @click="openCandidateDialog">
选择结算明细
</el-button>
<el-button type="primary" text @click="detailCollapsed = !detailCollapsed">
{{ detailCollapsed ? '展开明细' : '收起明细' }}
</el-button>
</div>
</template>
<el-form
:model="detailQuery"
label-position="right"
label-width="auto"
class="pre-settlement-editor__detail-filter"
@submit.prevent
>
<el-form-item label="单据号">
<el-input
v-model="detailQuery.documentNo"
clearable
placeholder="请输入"
@keyup.enter="handleDetailQuery"
/>
</el-form-item>
<el-form-item label="运单号">
<el-input
v-model="detailQuery.waybillNo"
clearable
placeholder="请输入"
@keyup.enter="handleDetailQuery"
/>
</el-form-item>
<el-form-item label="批次号">
<el-input
v-model="detailQuery.batchNo"
clearable
placeholder="请输入"
@keyup.enter="handleDetailQuery"
/>
</el-form-item>
<el-form-item label="货物名称">
<el-input
v-model="detailQuery.cargoName"
clearable
placeholder="请输入"
@keyup.enter="handleDetailQuery"
/>
</el-form-item>
<el-form-item class="pre-settlement-editor__detail-filter-actions">
<el-button type="primary" @click="handleDetailQuery">查询</el-button>
<el-button @click="resetDetailQuery">重置</el-button>
</el-form-item>
</el-form>
<div v-show="!detailCollapsed">
<el-table :data="pagedDetails" border class="pre-settlement-editor__detail-table">
<el-table-column
type="index"
label="序号"
width="64"
fixed="left"
align="center"
:index="detailIndexMethod"
/>
<el-table-column
v-for="column in visibleDetailColumns"
:key="column.prop"
:label="column.label"
:prop="column.prop"
:min-width="column.minWidth"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-link
v-if="column.link && row[column.prop]"
type="primary"
@click="openAdjustDialog(row, true)"
>
{{ row[column.prop] }}
</el-link>
<span v-else-if="column.money && column.prop === 'unitPrice'">
{{ formatUnitPrice(row) }}
</span>
<span v-else-if="column.money">
{{ formatMoney(row[column.prop], row.currency || form.currency) }}
</span>
<span v-else-if="column.prop === 'transportQuantityText'">
{{ formatQuantity(row) }}
</span>
<span v-else-if="pageMode && column.prop === 'mileage'">
{{ formatDetailMileage(row[column.prop]) }}
</span>
<span v-else-if="column.prop === 'transportType'">
{{ transportTypeName(row[column.prop]) }}
</span>
<span v-else-if="column.prop === 'cargoName'">
{{ formatCargoNames(row) }}
</span>
<span v-else-if="column.prop === 'cargoType'">
{{ formatCargoTypes(row) }}
</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
</el-table-column>
<el-table-column
v-for="feeItem in detailFeeItemNames"
:key="`fee-${feeItem}`"
:label="feeItem"
min-width="130"
align="center"
>
<template #default="{ row }">
{{
formatMoney(
parseFeeItems(row.feeItemsJson)[feeItem],
row.currency || form.currency
)
}}
</template>
</el-table-column>
<el-table-column label="操作" :width="editable ? 150 : 90" fixed="right" align="center">
<template #default="{ row }">
<div class="pre-settlement-editor__links">
<el-link type="primary" @click="openAdjustDialog(row, !canAdjustDetail)">
{{ canAdjustDetail ? '调整' : '查看' }}
</el-link>
<el-link v-if="editable" type="danger" @click="removeDetailRow(row)">
踢出
</el-link>
</div>
</template>
</el-table-column>
</el-table>
<div class="pre-settlement-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>
</div>
</section-card>
<section-card title="附件">
<template #extra>
<el-button type="primary" :disabled="!attachments.length" @click="downloadAttachments">
批量下载
</el-button>
</template>
<div class="pre-settlement-editor__attachment">
<el-table :data="attachments" border @selection-change="handleAttachmentSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="附件类型" min-width="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="260"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220">
<template #default="{ row }">
<span v-if="!editable">{{ row.description || '-' }}</span>
<el-input
v-else
v-model="row.description"
maxlength="200"
placeholder="请输入"
/>
</template>
</el-table-column>
<el-table-column label="文件大小" width="120" align="center">
<template #default="{ row }">{{ formatFileSize(row.size) }}</template>
</el-table-column>
<el-table-column prop="uploadUserName" label="上传人" width="140" align="center" />
<el-table-column
prop="uploadTime"
label="上传时间"
width="170"
align="center"
sortable
/>
<el-table-column label="操作" :width="editable ? 150 : 80" fixed="right" align="center">
<template #default="{ row, $index }">
<div class="pre-settlement-editor__links">
<el-link type="primary" @click="downloadAttachment(row)">下载</el-link>
<el-link v-if="editable" type="danger" @click="removeAttachment($index)">
删除
</el-link>
</div>
</template>
</el-table-column>
</el-table>
<div class="pre-settlement-editor__attachment-upload">
<vehicle-attachment-upload
ref="attachmentUploadRef"
v-model="attachments"
:readonly="!editable"
:multiple="true"
:limit="20"
:max-size="500"
:file-types="attachmentFileTypes"
:show-file-list="false"
button-text="上传附件"
@change="handleAttachmentChange"
/>
</div>
</div>
</section-card>
<section-card v-if="form.id && readonly" title="预付信息">
<el-table :data="advances" border>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column
v-for="column in advanceTableColumns"
:key="column.prop"
:label="column.label"
:prop="column.prop"
:min-width="column.minWidth"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<span v-if="['appliedAmount', 'paidAmount'].includes(column.prop)">
{{ formatMoney(row[column.prop], form.currency) }}
</span>
<span v-else-if="column.prop === 'billStatus'">
{{ advanceStatusName(row.billStatus) }}
</span>
<span v-else-if="column.prop === 'createUserName'">
{{ advanceCreateUserName(row) }}
</span>
<span v-else-if="column.prop === 'createTime'">
{{ advanceCreateTime(row) }}
</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
</el-table-column>
</el-table>
</section-card>
<section-card v-if="form.id && readonly" title="变更记录">
<el-table :data="adjustChangeRecords" border>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column
v-for="column in changeTableColumns"
:key="column.prop"
:label="column.label"
:prop="column.prop"
:min-width="column.minWidth"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<span v-if="column.prop === 'lineNo'">{{ changeRecordLineNo(row) }}</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
</el-table-column>
</el-table>
</section-card>
</div>
<template v-if="!pageMode" #footer>
<el-button @click="visible = false">取消</el-button>
<el-button v-if="editable" type="primary" plain :loading="saving" @click="saveDraft(false)"
>保存</el-button
>
<el-button
v-if="editable && hasPermission('pre_settlement_submit')"
type="primary"
:loading="submitting"
@click="saveDraft(true)"
>
提交
</el-button>
</template>
<div v-if="pageMode && !publicMode" class="pre-settlement-editor__page-actions">
<el-button @click="visible = false">取消</el-button>
<el-button v-if="editable" type="primary" plain :loading="saving" @click="saveDraft(false)"
>保存</el-button
>
<el-button
v-if="editable && hasPermission('pre_settlement_submit')"
type="primary"
:loading="submitting"
@click="saveDraft(true)"
>
提交
</el-button>
</div>
</component>
<el-dialog
v-model="candidateDialog.visible"
title="选择结算明细"
width="92%"
append-to-body
destroy-on-close
class="pre-settlement-candidate-dialog"
>
<section-card title="选择结算明细">
<el-form :model="candidateQuery" inline label-position="right" label-width="auto">
<el-form-item label="批次号">
<el-input v-model="candidateQuery.batchNo" clearable placeholder="请输入" />
</el-form-item>
<el-form-item label="费用日期">
<el-date-picker
v-model="candidateQuery.feeDateRange"
type="daterange"
value-format="YYYY-MM-DD"
format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadCandidates">查询</el-button>
<el-button @click="resetCandidateQuery">重置</el-button>
</el-form-item>
</el-form>
</section-card>
<el-table
ref="candidateTableRef"
v-loading="candidateDialog.loading"
:data="candidateRows"
border
@selection-change="handleCandidateSelection"
>
<el-table-column type="selection" width="52" fixed="left" align="center" />
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column
v-for="column in candidateColumns"
:key="column.prop"
:label="column.label"
:prop="column.prop"
:min-width="column.minWidth"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<span v-if="column.money">{{ formatMoney(row[column.prop], row.currency) }}</span>
<span v-else-if="column.prop === 'transportType'">
{{ transportTypeName(row[column.prop]) }}
</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
</el-table-column>
</el-table>
<div class="pre-settlement-editor__pagination">
<el-pagination
v-model:current-page="candidatePage.current"
v-model:page-size="candidatePage.size"
:total="candidatePage.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadCandidates"
@size-change="handleCandidateSizeChange"
/>
</div>
<template #footer>
<el-button @click="candidateDialog.visible = false">取消</el-button>
<el-button type="primary" :loading="candidateDialog.confirming" @click="confirmCandidates">
确认
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="contractDialog.visible"
title="选择合同"
width="92%"
append-to-body
destroy-on-close
class="pre-settlement-contract-dialog"
>
<el-form
:model="contractDialog.query"
inline
label-position="right"
label-width="auto"
class="pre-settlement-editor__contract-filter"
@submit.prevent
>
<el-form-item label="合同编号">
<el-input
v-model="contractDialog.query.contractNo"
clearable
placeholder="请输入"
@keyup.enter="loadContractRows"
/>
</el-form-item>
<el-form-item label="合同名称">
<el-input
v-model="contractDialog.query.contractName"
clearable
placeholder="请输入"
@keyup.enter="loadContractRows"
/>
</el-form-item>
<el-form-item label="所属项目">
<el-input
v-model="contractDialog.query.projectName"
clearable
placeholder="请输入"
@keyup.enter="loadContractRows"
/>
</el-form-item>
<el-form-item label="所属组织">
<el-input
v-model="contractDialog.query.organizationName"
clearable
placeholder="请输入"
@keyup.enter="loadContractRows"
/>
</el-form-item>
<el-form-item label="合同类别">
<el-select :model-value="contractDialogCategory" disabled placeholder="请选择">
<el-option
v-for="item in contractCategoryOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="签约类型">
<el-select v-model="contractDialog.query.signType" clearable placeholder="请选择">
<el-option
v-for="item in signTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="生效类型">
<el-select v-model="contractDialog.query.effectiveType" clearable placeholder="请选择">
<el-option
v-for="item in effectiveTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="合同阶段">
<el-select v-model="contractDialog.query.contractStage" clearable placeholder="请选择">
<el-option
v-for="item in contractStageOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item v-if="contractDialog.expanded" label="审核状态">
<el-select v-model="contractDialog.query.approvalStatus" clearable placeholder="请选择">
<el-option
v-for="item in contractApprovalStatusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item class="pre-settlement-editor__contract-filter-actions">
<el-button type="primary" @click="searchContractRows">查询</el-button>
<el-button @click="resetContractQuery">重置</el-button>
<el-button text @click="contractDialog.expanded = !contractDialog.expanded">
{{ contractDialog.expanded ? '收起' : '展开' }}
</el-button>
</el-form-item>
</el-form>
<el-table
v-loading="contractDialog.loading"
:data="contractDialog.rows"
border
highlight-current-row
@current-change="contractDialog.current = $event"
@row-dblclick="selectContract"
>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column
v-for="column in contractSelectionColumns"
:key="column.prop"
:prop="column.prop"
:label="column.label"
:min-width="column.minWidth"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<span v-if="column.prop === 'organizationName'">{{
row.organizationName || row.deptName || '-'
}}</span>
<span v-else-if="column.prop === 'contractCategory'">{{
contractCategoryName(row.contractCategory)
}}</span>
<span v-else-if="column.prop === 'signType'">{{ signTypeName(row.signType) }}</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right" align="center">
<template #default="{ row }">
<el-link type="primary" @click.stop="selectContract(row)">选择</el-link>
</template>
</el-table-column>
</el-table>
<div class="pre-settlement-editor__pagination">
<el-pagination
v-model:current-page="contractDialog.page.current"
v-model:page-size="contractDialog.page.size"
:total="contractDialog.page.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadContractRows"
@size-change="handleContractSizeChange"
/>
</div>
<template #footer>
<el-button @click="contractDialog.visible = false">取消</el-button>
<el-button type="primary" @click="selectContract(contractDialog.current)">确定</el-button>
</template>
</el-dialog>
<el-dialog
v-model="adjustDialog.visible"
title="结算明细调整"
width="96%"
append-to-body
destroy-on-close
>
<el-table v-loading="adjustDialog.loading" :data="adjustRows" border>
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column prop="cargoName" label="货物名称" min-width="140" align="center" />
<el-table-column prop="cargoType" label="货物类型" min-width="130" align="center" />
<el-table-column label="计费规则" min-width="190" align="center">
<template #default="{ row }">
<div class="pre-settlement-editor__billing-rule-cell">
<span>{{ displayValue(billingRuleName(row)) }}</span>
<el-icon
class="pre-settlement-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 }">
<span v-if="adjustDialog.readonly">{{ formatQuantity(row) }}</span>
<el-input-number
v-else
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"
:disabled="adjustDialog.readonly"
@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"
:disabled="adjustDialog.readonly"
@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"
:disabled="adjustDialog.readonly"
@change="recalculateAdjustRow(row, feeItem)"
/>
</template>
</el-table-column>
<el-table-column label="结算金额(含税)" min-width="170" align="center">
<template #default="{ row }">
<el-input-number
v-model="row.settlementAmountTax"
:precision="2"
:controls="false"
:disabled="adjustDialog.readonly"
/>
</template>
</el-table-column>
<el-table-column label="备注" min-width="200" align="center">
<template #default="{ row }">
<el-input v-model="row.remark" maxlength="200" :disabled="adjustDialog.readonly" />
</template>
</el-table-column>
</el-table>
<el-form
v-if="!adjustDialog.readonly"
:model="adjustDialog"
label-position="right"
label-width="auto"
class="pre-settlement-editor__adjust-reason"
>
<el-form-item label="调整原因">
<el-input
v-model="adjustDialog.reason"
type="textarea"
:rows="2"
maxlength="200"
show-word-limit
placeholder="请输入"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="adjustDialog.visible = false">取消</el-button>
<el-button
v-if="!adjustDialog.readonly"
type="primary"
:loading="adjustDialog.saving"
@click="saveAdjustment"
>
保存
</el-button>
</template>
</el-dialog>
<change-record-detail-dialog
v-model="adjustChangeRecordVisible"
:rows="adjustChangeRecordDetailRows"
/>
<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="pre-settlement-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 pre-settlement-editor__billing-rule-match-title">
匹配条件
</div>
<el-descriptions :column="2" border class="pre-settlement-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>
</template>
<script>
import { h } from 'vue';
import { mapGetters } from 'vuex';
import { InfoFilled } from '@element-plus/icons-vue';
import ChangeRecordDetailDialog from '@/components/change-record-detail-dialog/main.vue';
import { getMkPublicDetail } from '@/api/mk-process';
import {
adjustDetail,
getCandidateDetails,
getContractList,
getDetail,
getDetailFees,
getFeeOptions,
removeDetail,
save,
submit,
} from '@/api/settlement/preSettlement';
import { calculateAdjustedFee, getFeeDetail } from '@/api/settlement/receivable-payable-detail';
import { getDictionary } from '@/api/system/dictbiz';
import {
emptyPreSettlementForm,
preSettlementFormFields,
preSettlementFormRules,
} from '@/option/settlement/preSettlementForm';
import {
contractApprovalStatusOptions,
contractCategoryOptions,
contractStageOptions,
effectiveTypeOptions,
signTypeOptions,
} from '@/option/business/common';
import {
advanceColumns,
candidateDetailColumns,
changeRecordColumns,
settlementDetailColumns,
settlementSummaryColumns,
} from '@/option/settlement/preSettlementTable';
import { formatContractOptionLabel } from '@/utils/contract';
import { downloadFileByUrl } from '@/utils/util';
import { submitMkApprovalFlow } from '@/utils/mk-approval';
import * as XLSX from 'xlsx';
import { isAdministrator } from '@/utils/auth';
export default {
name: 'PreSettlementEditor',
components: { InfoFilled, ChangeRecordDetailDialog },
props: {
modelValue: {
type: Boolean,
default: false,
},
recordId: {
type: [String, Number],
default: '',
},
readonly: {
type: Boolean,
default: false,
},
pageMode: {
type: Boolean,
default: false,
},
initialData: {
type: Object,
default: null,
},
publicMode: {
type: Boolean,
default: false,
},
},
emits: ['update:modelValue', 'success'],
data() {
return {
loading: false,
saving: false,
submitting: false,
form: emptyPreSettlementForm(),
formFields: preSettlementFormFields,
formRules: preSettlementFormRules,
summaryColumns: settlementSummaryColumns,
detailColumns: settlementDetailColumns,
advanceTableColumns: advanceColumns,
changeTableColumns: changeRecordColumns,
candidateColumns: candidateDetailColumns,
contractOptions: [],
contractCategoryOptions,
signTypeOptions,
effectiveTypeOptions,
contractStageOptions,
contractApprovalStatusOptions,
contractSelectionColumns: [
{ prop: 'contractNo', label: '合同编号', minWidth: 180 },
{ prop: 'contractName', label: '合同名称', minWidth: 220 },
{ prop: 'projectName', label: '所属项目', minWidth: 170 },
{ prop: 'organizationName', label: '所属组织', minWidth: 170 },
{ prop: 'contractCategory', label: '合同类别', minWidth: 130 },
{ prop: 'signType', label: '签约类型', minWidth: 120 },
{ prop: 'partyA', label: '甲方', minWidth: 170 },
{ prop: 'partyB', label: '乙方', minWidth: 170 },
{ prop: 'startDate', label: '开始日期', minWidth: 130 },
{ prop: 'endDate', label: '结束日期', minWidth: 130 },
{ prop: 'temporaryStartDate', label: '临时效力起', minWidth: 130 },
{ prop: 'temporaryEndDate', label: '临时效力止', minWidth: 130 },
],
contractDialog: {
visible: false,
loading: false,
expanded: false,
rows: [],
current: null,
query: {
contractNo: '',
contractName: '',
projectName: '',
organizationName: '',
contractCategory: '承运商合同',
signType: '',
effectiveType: '',
contractStage: '',
approvalStatus: '',
},
page: { current: 1, size: 10, total: 0 },
},
feeOptions: [],
feeCategoryOptions: [],
transportTypeOptions: [],
summaryFees: [],
details: [],
detailCollapsed: false,
detailQuery: {
documentNo: '',
waybillNo: '',
batchNo: '',
cargoName: '',
},
appliedDetailQuery: {
documentNo: '',
waybillNo: '',
batchNo: '',
cargoName: '',
},
detailPage: {
current: 1,
size: 10,
},
advances: [],
changeRecords: [],
attachments: [],
attachmentTypeOptions: [],
selectedAttachmentRows: [],
candidateDialog: {
visible: false,
loading: false,
confirming: false,
},
candidateQuery: {
batchNo: '',
feeDateRange: [],
},
candidateRows: [],
candidateSelection: [],
candidatePage: {
current: 1,
size: 10,
total: 0,
},
adjustDialog: {
visible: false,
loading: false,
saving: false,
readonly: false,
detailId: '',
sourceDetailId: '',
detailLineNo: '',
reason: '',
},
adjustRows: [],
pendingAdjustments: {},
sourceFeeSnapshots: {},
detailFeeCache: {}, // 缓存每个明细的费用明细数据(用于拼接货物名称和类型)
adjustChangeRecordVisible: false,
adjustChangeRecord: null,
adjustChangeRecordDetailRows: [],
billingRuleDialog: {
visible: false,
rule: null,
},
attachmentFileTypes: [
'pdf',
'bmp',
'jpeg',
'png',
'jpg',
'doc',
'docx',
'ppt',
'pptx',
'xlsx',
'xls',
'eml',
'msg',
'zip',
],
};
},
computed: {
...mapGetters(['permission', 'userInfo']),
visible: {
get() {
return this.modelValue;
},
set(value) {
this.$emit('update:modelValue', value);
},
},
editorContainer() {
return this.pageMode ? 'div' : 'el-dialog';
},
editorContainerProps() {
if (this.pageMode) {
return {
class: ['pre-settlement-editor', 'pre-settlement-editor--page'],
};
}
return {
modelValue: this.visible,
title: this.dialogTitle,
width: '96%',
top: '2vh',
appendToBody: true,
destroyOnClose: true,
class: 'pre-settlement-editor',
};
},
editable() {
return !this.readonly && ['draft', 'returned'].includes(this.form.approvalStatus);
},
isAdmin() {
return isAdministrator(this.userInfo);
},
canAdjustDetail() {
return this.editable && this.hasPermission('pre_settlement_adjust');
},
dialogTitle() {
if (this.readonly) return '查看预结算单';
return this.form.id ? '编辑预结算单' : '新增预结算单';
},
settlementTypeName() {
return this.form.settlementType === 'receivable' ? '应收' : '应付';
},
contractDialogCategory() {
return '承运商合同';
},
summaryTotal() {
// 与结算合计表格的合计行保持一致:包含手工添加的费用与明细调整后的金额。
if (this.summaryFees.length) {
return this.summaryFees.reduce((total, row) => total + Number(row.settlementAmount || 0), 0);
}
return this.details.reduce((total, row) => total + this.detailSettlementAmount(row), 0);
},
visibleDetailColumns() {
if (!this.readonly && !this.pageMode) return this.detailColumns;
return this.detailColumns.filter(column => column.prop !== 'settlementAmountNoTax');
},
detailFeeItemNames() {
const names = new Set();
this.details.forEach(row => {
Object.keys(this.parseFeeItems(row.feeItemsJson)).forEach(name => names.add(name));
});
return Array.from(names);
},
// 按 preSettlementDetailId 分组的明细数据(用于拼接货物名称和类型)
groupedDetails() {
const groups = {};
this.details.forEach(detail => {
// 优先使用 sourceDetailId,如果没有则使用 preSettlementDetailId
const groupKey = detail.sourceDetailId || detail.preSettlementDetailId || detail.id;
if (!groups[groupKey]) {
groups[groupKey] = [];
}
groups[groupKey].push(detail);
});
return groups;
},
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);
},
adjustFeeItemNames() {
const names = new Set();
this.adjustRows.forEach(row => {
Object.keys(row.feeItems || {}).forEach(name => names.add(name));
});
return Array.from(names);
},
adjustChangeRecords() {
return this.changeRecords.filter(
row =>
row.changeType === '结算明细项' &&
String(row.lineNo ?? '') === String(this.adjustDialog.detailLineNo ?? '')
);
},
},
watch: {
modelValue: {
immediate: true,
handler(value) {
if (value) this.initialize();
},
},
summaryTotal(value) {
this.form.settlementAmount = Number(value || 0).toFixed(2);
this.recalculateLocalAmount();
},
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,
},
},
methods: {
formatContractOptionLabel,
hasPermission(code) {
return this.isAdmin || this.validData(this.permission?.[code], false);
},
async initialize() {
this.resetEditor();
if (!this.publicMode) {
await this.loadFeeOptions();
await this.loadFeeCategoryOptions();
await this.loadTransportTypeOptions();
await this.loadAttachmentTypeOptions();
}
if (this.recordId) await this.loadDetail();
else if (this.initialData) await this.applyInitialData();
},
async applyInitialData() {
const rows = Array.isArray(this.initialData?.rows) ? this.initialData.rows : [];
if (!rows.length) return;
const first = rows[0];
const settlementType = this.initialData.settlementType || first.settlementType || 'payable';
const contractId = first.contractId || '';
if (!this.contractOptions.some(item => String(item.id) === String(contractId))) {
this.contractOptions.push({
id: contractId,
contractNo: first.contractNo,
contractName: first.contractName,
projectId: first.projectId,
projectName: first.projectName,
deptId: first.deptId,
deptName: first.deptName,
settlementType,
partyA: first.payerName,
partyB: first.payeeName,
});
}
this.handleContractChange(contractId);
Object.assign(this.form, {
contractId,
contractNo: first.contractNo || this.form.contractNo,
contractName: first.contractName || this.form.contractName,
projectId: first.projectId || this.form.projectId,
projectName: first.projectName || this.form.projectName,
deptId: first.deptId || this.form.deptId,
deptName: first.deptName || this.form.deptName,
payerName: first.payerName || this.form.payerName,
payeeName: first.payeeName || this.form.payeeName,
settlementType,
currency: first.currency || 'RMB',
localCurrency: first.localCurrency || 'RMB',
});
this.details = rows.map(row => this.normalizeSourceDetail(row));
this.detailPage.current = 1;
this.loading = true;
try {
await this.ensureSourceFeeSnapshots();
} finally {
this.loading = false;
}
this.buildSummaryFeesFromDetails();
},
normalizeSourceDetail(row) {
const settlementAmount = this.detailSettlementAmount(row);
const originalValue =
row.originalAmount ?? row.originalSettlementAmount ?? row.totalAmount ?? settlementAmount;
const originalAmount = Number(originalValue || 0);
const adjustValue = row.adjustAmount ?? row.adjustmentAmount;
const adjustAmount =
adjustValue === undefined || adjustValue === null
? settlementAmount - originalAmount
: Number(adjustValue || 0);
return {
...row,
sourceDetailId: row.sourceDetailId || row.id,
originalAmount: Number(originalAmount.toFixed(2)),
adjustAmount: Number(adjustAmount.toFixed(2)),
settlementAmountTax: Number(settlementAmount.toFixed(2)),
feeItemsJson: row.feeItemsJson || JSON.stringify(row.feeItems || {}),
};
},
detailOriginalAmount(row) {
return Number(row?.originalAmount ?? 0);
},
detailAdjustAmount(row) {
const value = row?.adjustAmount ?? row?.adjustmentAmount;
return Number(
Number(
value === undefined || value === null
? this.detailSettlementAmount(row) - this.detailOriginalAmount(row)
: value
).toFixed(2)
);
},
detailSettlementAmount(row) {
return Number(
row?.settlementAmountTax ??
row?.totalAmount ??
row?.afterAmount ??
row?.settlementAmount ??
0
);
},
resetEditor() {
this.form = emptyPreSettlementForm();
this.summaryFees = [];
this.details = [];
this.detailCollapsed = false;
this.detailQuery = {
documentNo: '',
waybillNo: '',
batchNo: '',
cargoName: '',
};
this.appliedDetailQuery = { ...this.detailQuery };
this.detailPage = { current: 1, size: 10 };
this.advances = [];
this.changeRecords = [];
this.attachments = [];
this.selectedAttachmentRows = [];
this.adjustRows = [];
this.pendingAdjustments = {};
this.sourceFeeSnapshots = {};
this.detailFeeCache = {};
this.adjustDialog.detailId = '';
this.adjustDialog.sourceDetailId = '';
this.adjustDialog.detailLineNo = '';
this.adjustChangeRecordVisible = false;
this.adjustChangeRecord = null;
this.adjustChangeRecordDetailRows = [];
this.billingRuleDialog.visible = false;
this.billingRuleDialog.rule = null;
this.contractOptions = [];
this.contractDialog.visible = false;
this.contractDialog.expanded = false;
this.contractDialog.rows = [];
this.contractDialog.current = null;
this.contractDialog.page = { current: 1, size: 10, total: 0 };
this.$nextTick(() => this.$refs.formRef?.clearValidate());
},
async loadDetail() {
this.loading = true;
try {
const response = this.publicMode
? await getMkPublicDetail('pre-settlement', this.form.id || this.recordId)
: await getDetail(this.form.id || this.recordId);
const detail = response.data?.data || {};
this.form = { ...emptyPreSettlementForm(), ...detail };
this.summaryFees = (detail.summaryFees || []).map(row => ({ ...row }));
this.details = (detail.details || []).map(row => this.normalizeSourceDetail(row));
this.detailPage.current = 1;
this.advances = (detail.advances || []).map(row => ({
...row,
createUserName: row.createUserName || detail.createUserName,
createTime: row.createTime || detail.createTime,
}));
this.changeRecords = detail.changeRecords || [];
this.attachments = this.parseAttachments(detail.attachmentsJson);
this.sortAttachments();
this.selectedAttachmentRows = [];
if (
detail.contractId &&
!this.contractOptions.some(item => item.id === detail.contractId)
) {
this.contractOptions.push({
id: detail.contractId,
contractNo: detail.contractNo,
contractName: detail.contractName,
projectId: detail.projectId,
projectName: detail.projectName,
deptId: detail.deptId,
deptName: detail.deptName,
partyA: detail.payerName,
partyB: detail.payeeName,
});
}
if (this.publicMode && detail.detailFees) {
Object.entries(detail.detailFees).forEach(([detailId, feeRows]) => {
if (Array.isArray(feeRows) && feeRows.length) {
this.detailFeeCache[detailId] = feeRows;
}
});
} else {
await this.loadAllDetailFees();
}
} finally {
this.loading = false;
}
},
openContractDialog() {
if (!this.editable || this.details.length > 0) return;
this.contractDialog.visible = true;
this.contractDialog.expanded = false;
this.contractDialog.current = null;
this.contractDialog.page.current = 1;
this.loadContractRows();
},
async loadContractRows() {
this.contractDialog.loading = true;
try {
const { data } = await getContractList(
this.contractDialog.page.current,
this.contractDialog.page.size,
{
...this.contractDialog.query,
contractCategory: this.contractDialogCategory,
}
);
const page = data?.data || {};
this.contractDialog.rows = page.records || [];
this.contractDialog.page.total = Number(page.total || 0);
} finally {
this.contractDialog.loading = false;
}
},
searchContractRows() {
this.contractDialog.page.current = 1;
this.loadContractRows();
},
resetContractQuery() {
this.contractDialog.query = {
contractNo: '',
contractName: '',
projectName: '',
organizationName: '',
contractCategory: this.contractDialogCategory,
signType: '',
effectiveType: '',
contractStage: '',
approvalStatus: '',
};
this.searchContractRows();
},
handleContractSizeChange() {
this.contractDialog.page.current = 1;
this.loadContractRows();
},
selectContract(row) {
if (!row) {
this.$message.warning('请选择合同');
return;
}
const contract = {
...row,
deptId: row.deptId || row.organizationId,
deptName: row.deptName || row.organizationName,
partyA: row.partyA || row.payerName,
partyB: row.partyB || row.payeeName,
settlementType:
row.settlementType || (row.contractCategory === '客户合同' ? 'receivable' : 'payable'),
};
const index = this.contractOptions.findIndex(item => String(item.id) === String(contract.id));
if (index >= 0) this.contractOptions.splice(index, 1, contract);
else this.contractOptions.push(contract);
this.handleContractChange(contract.id);
this.contractDialog.visible = false;
this.contractDialog.current = null;
this.$refs.formRef?.clearValidate('contractId');
},
contractCategoryName(value) {
return (
this.contractCategoryOptions.find(item => String(item.value) === String(value))?.label ||
this.displayValue(value)
);
},
signTypeName(value) {
return (
this.signTypeOptions.find(item => String(item.value) === String(value))?.label ||
this.displayValue(value)
);
},
async loadFeeOptions() {
const { data } = await getFeeOptions();
this.feeOptions = data?.data || [];
},
async loadFeeCategoryOptions() {
const { data } = await getDictionary({ code: 'fee_category' });
this.feeCategoryOptions = data?.data || [];
},
async loadTransportTypeOptions() {
const { data } = await getDictionary({ code: 'transport_type' });
const records = Array.isArray(data?.data) ? data.data : data?.data?.records || [];
this.transportTypeOptions = records.map(item => ({
dictKey: item.dictKey ?? item.value,
dictValue: item.dictValue ?? item.label ?? item.name,
}));
},
handleContractChange(id) {
const contract = this.contractOptions.find(item => String(item.id) === String(id));
if (!contract) {
this.form.contractId = '';
this.form.contractNo = '';
this.form.contractName = '';
this.form.projectId = '';
this.form.projectName = '';
this.form.deptId = '';
this.form.deptName = '';
this.form.payerName = '';
this.form.payeeName = '';
this.summaryFees = [];
return;
}
this.form.contractId = contract.id;
this.form.contractNo = contract.contractNo;
this.form.contractName = contract.contractName;
this.form.projectId = contract.projectId;
this.form.projectName = contract.projectName;
this.form.deptId = contract.deptId;
this.form.deptName = contract.deptName;
this.form.settlementType = contract.settlementType || 'payable';
this.form.payerName = contract.partyA;
this.form.payeeName = contract.partyB;
this.summaryFees = [];
},
async saveDraft(shouldSubmit) {
await this.$refs.formRef?.validate();
if (shouldSubmit && !this.details.length) {
this.$message.warning('请至少选择一条结算明细');
return;
}
const invalidManualFeeIndex = this.summaryFees.findIndex(
row =>
Number(row.manualFlag) === 1 &&
(!String(row.feeType || '').trim() || !String(row.feeItem || '').trim())
);
if (invalidManualFeeIndex >= 0) {
this.$message.warning(`请完善结算合计第${invalidManualFeeIndex + 1}行的费用类型和费用项`);
return;
}
const stateKey = shouldSubmit ? 'submitting' : 'saving';
this[stateKey] = true;
try {
const { data } = await save(this.buildSavePayload());
this.form.id = data?.data || this.form.id;
await this.loadDetail();
await this.persistPendingAdjustments();
if (shouldSubmit) {
const previousApprovalStatus = this.form.approvalStatus || '';
await submit({ id: this.form.id });
await submitMkApprovalFlow({
bizType: 'pre-settlement',
bizId: this.form.id,
subjectName: this.form.preSettlementNo || this.form.contractName || '',
approvalStatus: previousApprovalStatus,
});
this.$message.success('审批流程已发起');
this.visible = false;
this.$emit('success', this.form.id);
return;
}
this.$message.success('保存成功');
this.visible = false;
this.$emit('success', this.form.id);
} finally {
this[stateKey] = false;
}
},
buildSavePayload() {
return {
id: this.form.id || undefined,
contractId: this.form.contractId,
exchangeRateDate: this.form.exchangeRateDate || undefined,
exchangeRate: this.form.exchangeRate,
attachmentsJson: JSON.stringify(this.attachments || []),
remark: this.form.remark,
sourceDetailIds: this.details.map(row => row.sourceDetailId || row.id),
summaryFees: this.summaryFees.map(row => ({
id: row.id || undefined,
feeType: this.feeCategoryValue(row.feeType),
feeItem: row.feeItem,
originalAmount: Number(row.originalAmount || 0),
adjustAmount: Number(row.adjustAmount || 0),
remark: row.remark,
manualFlag: row.manualFlag || 0,
})),
};
},
addSummaryFee() {
this.summaryFees.push({
id: '',
feeType: '',
feeItem: '',
originalAmount: 0,
adjustAmount: 0,
settlementAmount: 0,
remark: '',
manualFlag: 1,
});
},
handleManualFeeTypeChange(row) {
if (!this.manualFeeItems(row.feeType).includes(row.feeItem)) row.feeItem = '';
},
manualFeeItems(feeType) {
return (
this.feeOptions.find(
item => this.feeCategoryValue(item.feeType) === this.feeCategoryValue(feeType)
)?.feeItems || []
);
},
findFeeCategory(value) {
return this.feeCategoryOptions.find(
item => String(item.dictKey) === String(value) || String(item.dictValue) === String(value)
);
},
// fee_category 业务字典:dictKey 为字典键值(落库值),dictValue 为字典名称(展示值)。
feeCategoryValue(value) {
if (value === undefined || value === null || value === '') return '';
return this.findFeeCategory(value)?.dictKey ?? value;
},
feeCategoryName(value) {
if (value === undefined || value === null || value === '') return '';
const option = this.findFeeCategory(value);
if (option) return option.dictValue ?? value;
const feeOption = this.feeOptions.find(item => String(item.feeType) === String(value));
return feeOption?.feeTypeName || value;
},
transportTypeName(value) {
if (value === undefined || value === null || value === '') return '';
const normalizedValue = String(value).trim().toLocaleLowerCase();
const option = this.transportTypeOptions.find(
item =>
String(item.dictKey ?? '')
.trim()
.toLocaleLowerCase() === normalizedValue ||
String(item.dictValue ?? '')
.trim()
.toLocaleLowerCase() === normalizedValue
);
return option?.dictValue || value;
},
formatDetailMileage(value) {
if (value === undefined || value === null || value === '' || Number(value) === -1) return '';
return value;
},
removeSummaryFee(index) {
this.summaryFees.splice(index, 1);
},
recalculateSummaryRow(row) {
if (Number(row.manualFlag) !== 1) return;
row.settlementAmount = Number(row.originalAmount || 0) + Number(row.adjustAmount || 0);
},
getSummarySums({ columns, data }) {
const sumProps = ['originalAmount', 'adjustAmount', 'settlementAmount'];
return columns.map((column, index) => {
if (index === 0) return '合计';
if (!sumProps.includes(column.property)) return '';
const total = data.reduce((sum, row) => sum + Number(row[column.property] || 0), 0);
const totalText = this.formatMoney(total, this.form.currency);
if (column.property === 'adjustAmount' && total < 0) {
return h('span', { style: { color: 'var(--el-color-danger)' } }, totalText);
}
return totalText;
});
},
// 结算合计按费用项归集:原金额取来源费用明细的初始金额,结算金额取调整后的金额,调整金额为两者之差。
buildSummaryFeesFromDetails() {
const summaryMap = new Map();
const generatedFees = new Map(
this.summaryFees
.filter(row => Number(row.manualFlag) !== 1)
.map(row => [`${row.feeType}\u0000${row.feeItem}`, row])
);
const appendSummary = (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 feeOption = this.feeOptions.find(item =>
(item.feeItems || []).some(name => String(name) === String(feeItem))
);
const feeType = this.feeCategoryValue(feeOption?.feeType || fallbackFeeType);
const key = `${feeType}\u0000${feeItem}`;
const generated = generatedFees.get(key);
const current = summaryMap.get(key) || {
id: generated?.id || '',
feeType,
feeItem,
originalAmount: 0,
adjustAmount: 0,
settlementAmount: 0,
remark: generated?.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)
);
summaryMap.set(key, current);
};
this.details.forEach(detail => {
this.resolveDetailFeePairs(detail).forEach(([baseRow, currentRow]) =>
this.appendFeeRowSummary(baseRow, currentRow, detail, appendSummary)
);
});
const manualFees = this.summaryFees.filter(row => Number(row.manualFlag) === 1);
this.summaryFees = [...summaryMap.values(), ...manualFees];
this.recalculateLocalAmount();
},
// 每条明细对应的费用行:[调整前基准行, 调整后当前行],缺少来源费用快照时退化为明细自身的汇总行。
resolveDetailFeePairs(detail) {
const sourceDetailId = String(detail.sourceDetailId || detail.id || '');
const baseRows = this.sourceFeeSnapshots[sourceDetailId] || [];
if (!baseRows.length) return [this.buildDetailFeePair(detail)];
const pendingRows = this.pendingAdjustments[sourceDetailId]?.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: this.detailOriginalAmount(detail),
};
return [baseRow, { ...baseRow, settlementAmountTax: this.detailSettlementAmount(detail) }];
},
appendFeeRowSummary(baseRow, currentRow, detail, appendSummary) {
const fallbackFeeType = currentRow.feeType || detail.feeType || '';
const feeItemNames = Array.from(
new Set([...Object.keys(baseRow.feeItems || {}), ...Object.keys(currentRow.feeItems || {})])
);
feeItemNames.forEach(name =>
appendSummary(name, baseRow.feeItems?.[name], currentRow.feeItems?.[name], fallbackFeeType)
);
const hasFreightItem = feeItemNames.some(name => this.isFreightFeeItem(name));
if (!hasFreightItem) {
appendSummary(
this.defaultFreightFeeItem(),
baseRow.freightAmount,
currentRow.freightAmount,
fallbackFeeType
);
}
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;
// 结算金额被手工改写且无法拆分到具体费用项时,计入金额占比最大的费用项,否则归入其他费用。
appendSummary(
this.residualFeeItem(baseRow, currentRow),
baseResidual,
currentResidual,
fallbackFeeType
);
},
defaultFreightFeeItem() {
return (
this.feeOptions
.flatMap(item => item.feeItems || [])
.find(name => this.isFreightFeeItem(name)) || '运输费'
);
},
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)
);
},
recalculateLocalAmount() {
const rate = this.form.currency === 'RMB' ? 1 : Number(this.form.exchangeRate || 0);
this.form.localSettlementAmount = (Number(this.form.settlementAmount || 0) * rate).toFixed(2);
},
openCandidateDialog() {
if (!this.form.contractId) {
this.$message.warning('请先选择合同名称');
return;
}
this.candidateDialog.visible = true;
this.candidatePage.current = 1;
this.candidateSelection = [];
this.loadCandidates();
},
async loadCandidates() {
this.candidateDialog.loading = true;
try {
const dateRange = this.candidateQuery.feeDateRange || [];
const { data } = await getCandidateDetails(
this.candidatePage.current,
this.candidatePage.size,
{
contractId: this.form.contractId,
settlementType: this.form.settlementType,
batchNo: this.candidateQuery.batchNo,
feeStartDate: dateRange[0],
feeEndDate: dateRange[1],
}
);
const page = data?.data || {};
this.candidateRows = page.records || [];
this.candidatePage.total = Number(page.total || 0);
} finally {
this.candidateDialog.loading = false;
}
},
resetCandidateQuery() {
this.candidateQuery = { batchNo: '', feeDateRange: [] };
this.candidatePage.current = 1;
this.loadCandidates();
},
handleCandidateSelection(rows) {
this.candidateSelection = rows;
},
handleCandidateSizeChange() {
this.candidatePage.current = 1;
this.loadCandidates();
},
async confirmCandidates() {
if (!this.candidateSelection.length) {
this.$message.warning('请选择结算明细');
return;
}
const existingIds = new Set(this.details.map(row => String(row.sourceDetailId || row.id)));
this.candidateSelection.forEach(row => {
if (!existingIds.has(String(row.id))) {
this.details.push(this.normalizeSourceDetail(row));
}
});
this.candidateDialog.confirming = true;
await this.ensureSourceFeeSnapshots();
this.buildSummaryFeesFromDetails();
// 页面模式选择明细先更新本地数据,保存/提交时再统一落库。
if (this.pageMode) {
this.candidateDialog.visible = false;
this.candidateDialog.confirming = false;
this.$message.success('结算明细添加成功,请点击保存提交');
return;
}
try {
const { data } = await save(this.buildSavePayload());
this.form.id = data?.data || this.form.id;
await this.loadDetail();
this.candidateDialog.visible = false;
this.$message.success('结算明细添加成功');
this.$emit('success');
} finally {
this.candidateDialog.confirming = false;
}
},
async removeDetailRow(row) {
await this.$confirm('确认将该明细踢出预结算单?', '提示', { type: 'warning' });
if (!this.form.id || !row.id || row.id === row.sourceDetailId) {
this.details = this.details.filter(item => item !== row);
this.buildSummaryFeesFromDetails();
return;
}
await removeDetail(this.form.id, row.id);
this.$message.success('已移除');
await this.loadDetail();
this.$emit('success', this.form.id);
},
handleDetailQuery() {
this.appliedDetailQuery = { ...this.detailQuery };
this.detailPage.current = 1;
},
resetDetailQuery() {
this.detailQuery = {
documentNo: '',
waybillNo: '',
batchNo: '',
cargoName: '',
};
this.appliedDetailQuery = { ...this.detailQuery };
this.detailPage.current = 1;
},
handleDetailPageSizeChange() {
this.detailPage.current = 1;
},
detailIndexMethod(index) {
return (this.detailPage.current - 1) * this.detailPage.size + index + 1;
},
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);
const adjusted = {
...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),
originalAmount: Number(item.originalAmount || 0),
feeItems: Object.fromEntries(names.map(name => [name, Number(rawFeeItems[name] || 0)])),
settlementAmountTax: Number(item.settlementAmountTax ?? item.afterAmount ?? 0),
settlementAmountNoTax: item.settlementAmountNoTax,
billingRule: this.normalizeBillingRule(item),
calculating: false,
calculateError: '',
adjustCalculateTimer: null,
adjustCalculateVersion: 0,
};
if (item.settlementAmountTax === undefined && item.afterAmount === undefined) {
this.recalculateAdjustRow(adjusted);
}
return adjusted;
},
cloneAdjustRows(rows) {
return (rows || []).map(row => ({
...row,
feeItems: { ...row.feeItems },
calculating: false,
calculateError: '',
adjustCalculateTimer: null,
adjustCalculateVersion: 0,
}));
},
async loadSourceFeeRows(sourceDetailId) {
const response = await getFeeDetail(sourceDetailId);
const data = response.data?.data || response.data || {};
return (data.records || []).map(item =>
this.normalizeAdjustRow(item, data.feeItemNames || [])
);
},
// 批量加载所有明细的费用明细数据,用于拼接货物名称和类型
async loadAllDetailFees() {
if (!this.details.length) return;
// 并发加载所有明细的费用数据
await Promise.all(
this.details.map(async detail => {
if (!detail.id) return;
try {
const response = await getDetailFees(detail.id);
const feeRows = response.data?.data || response.data || [];
// 缓存费用明细数据
if (Array.isArray(feeRows) && feeRows.length > 0) {
this.detailFeeCache[detail.id] = feeRows;
}
} catch (error) {
// 加载失败时使用明细本身的数据
console.warn(`加载明细 ${detail.id} 的费用数据失败:`, error);
}
})
);
},
// 明细列表只有汇总金额,费用项级别的原始金额需要按来源明细单独拉取并缓存为调整基准。
async ensureSourceFeeSnapshots() {
const targets = this.details.filter(row => {
const sourceDetailId = String(row.sourceDetailId || row.id || '');
return sourceDetailId && !this.sourceFeeSnapshots[sourceDetailId];
});
if (!targets.length) return;
await Promise.all(
targets.map(async row => {
const sourceDetailId = String(row.sourceDetailId || row.id || '');
try {
const feeRows = await this.loadSourceFeeRows(sourceDetailId);
if (!feeRows.length) return;
this.sourceFeeSnapshots[sourceDetailId] = feeRows;
this.applySnapshotToDetail(row, feeRows);
} catch (error) {
// 取不到费用明细时退回按明细汇总金额归集,后续操作会再次尝试拉取。
delete this.sourceFeeSnapshots[sourceDetailId];
}
})
);
},
applySnapshotToDetail(detail, feeRows) {
if (!feeRows.length) 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)),
});
},
async openAdjustDialog(row, readonly) {
const detailRow = row;
this.adjustDialog.visible = true;
this.adjustDialog.loading = true;
this.adjustDialog.readonly = readonly;
this.adjustDialog.detailId = detailRow.id;
this.adjustDialog.detailLineNo = detailRow.lineNo;
this.adjustDialog.sourceDetailId =
detailRow.id && String(detailRow.id) === String(detailRow.sourceDetailId)
? detailRow.sourceDetailId
: '';
const sourceDetailId = String(this.adjustDialog.sourceDetailId || '');
const pending = sourceDetailId ? this.pendingAdjustments[sourceDetailId] : null;
this.adjustDialog.reason = pending?.reason || '';
try {
// 已暂存的调整结果优先回显,避免重新打开时又回到调整前的数据。
if (pending?.rows?.length) {
this.adjustRows = this.cloneAdjustRows(pending.rows);
return;
}
if (this.publicMode) {
const cachedFees = this.detailFeeCache[detailRow.id] || [];
this.adjustRows = cachedFees.map(item => this.normalizeAdjustRow(item));
return;
}
if (sourceDetailId) {
if (!this.sourceFeeSnapshots[sourceDetailId]) {
this.sourceFeeSnapshots[sourceDetailId] = await this.loadSourceFeeRows(sourceDetailId);
}
this.adjustRows = this.cloneAdjustRows(this.sourceFeeSnapshots[sourceDetailId]);
return;
}
const cachedFees = this.detailFeeCache[detailRow.id];
if (this.publicMode && Array.isArray(cachedFees)) {
this.adjustRows = cachedFees.map(item => this.normalizeAdjustRow(item));
return;
}
const response = await getDetailFees(detailRow.id);
const rows = response.data?.data || response.data || [];
this.adjustRows = rows.map(item => this.normalizeAdjustRow(item));
} finally {
this.adjustDialog.loading = false;
}
},
parseChangeRecordData(value) {
if (!value) return {};
if (typeof value === 'object') return value;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch (error) {
return {};
}
},
formatAdjustChangeValue(value) {
if (value === undefined || value === null || value === '') return '空';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
},
buildAdjustChangeRecordRows(row = {}) {
const before = this.parseChangeRecordData(row.beforeData);
const after = this.parseChangeRecordData(row.afterData);
const fields = [...new Set([...Object.keys(before), ...Object.keys(after)])];
const rows = fields
.filter(field => JSON.stringify(before[field]) !== JSON.stringify(after[field]))
.map(field => ({
field,
before: this.formatAdjustChangeValue(before[field]),
after: this.formatAdjustChangeValue(after[field]),
}));
if (row.changeContent)
rows.unshift({ field: '变更内容', before: '空', after: row.changeContent });
if (row.changeReason) rows.push({ field: '变更原因', before: '空', after: row.changeReason });
return rows;
},
openAdjustChangeRecord(row) {
this.adjustChangeRecord = row;
this.adjustChangeRecordDetailRows = this.buildAdjustChangeRecordRows(row);
this.adjustChangeRecordVisible = true;
},
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')
: [];
let rule = this.parseObject(matchedRules[0] || parsedNested || nested);
if (!Object.keys(rule).length && typeof nested === 'string') rule = { name: 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);
const 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 ||
'';
return {
...rule,
name,
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 && changedField !== 'freight' && this.isFreightFeeItem(changedField)) {
row.freightAmount = Number(row.feeItems[changedField] || 0);
}
if (changedField === 'freight') {
const freightItem = Object.keys(row.feeItems || {}).find(this.isFreightFeeItem);
if (freightItem) row.feeItems[freightItem] = Number(row.freightAmount || 0);
}
const feeItemTotal = Object.values(row.feeItems || {}).reduce(
(total, value) => total + Number(value || 0),
0
);
const hasFreightItem = Object.keys(row.feeItems || {}).some(this.isFreightFeeItem);
row.settlementAmountTax = Number(
(hasFreightItem ? feeItemTotal : Number(row.freightAmount || 0) + feeItemTotal).toFixed(2)
);
if (changedField && this.adjustDialog.sourceDetailId && row.id) {
this.scheduleSourceAdjustCalculation(row);
}
},
scheduleSourceAdjustCalculation(row) {
if (row.adjustCalculateTimer) clearTimeout(row.adjustCalculateTimer);
row.adjustCalculateVersion += 1;
row.calculating = true;
const version = row.adjustCalculateVersion;
row.adjustCalculateTimer = setTimeout(async () => {
row.adjustCalculateTimer = null;
try {
const response = await calculateAdjustedFee({
detailId: this.adjustDialog.sourceDetailId,
feeId: 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.adjustDialog.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);
},
isFreightFeeItem(name) {
return String(name || '').includes('运费') || String(name || '').includes('运输费');
},
async saveAdjustment() {
if (this.adjustRows.some(row => row.calculating)) {
this.$message.warning('费用正在重新计算,请稍候');
return;
}
this.adjustDialog.saving = true;
try {
if (this.adjustDialog.sourceDetailId) {
this.pendingAdjustments[this.adjustDialog.sourceDetailId] = {
reason: this.adjustDialog.reason,
rows: this.adjustRows.map(row => ({ ...row, feeItems: { ...row.feeItems } })),
};
this.applyLocalAdjustment(this.adjustDialog.sourceDetailId, this.adjustRows);
this.adjustDialog.visible = false;
this.$message.success('结算明细调整已暂存,保存预结算单后生效');
return;
}
await adjustDetail({
detailId: this.adjustDialog.detailId,
changeReason: this.adjustDialog.reason,
rows: this.adjustRows.map(row => ({
id: row.id,
transportQuantity: row.transportQuantity,
mileage: row.mileage,
unitPrice: row.unitPrice,
freightAmount: row.freightAmount,
feeItems: row.feeItems,
settlementAmountTax: row.settlementAmountTax,
settlementAmountNoTax: row.settlementAmountNoTax,
remark: row.remark,
})),
});
this.adjustDialog.visible = false;
this.$message.success('结算明细调整成功');
await this.loadDetail();
this.$emit('success', this.form.id);
} finally {
this.adjustDialog.saving = false;
}
},
applyLocalAdjustment(sourceDetailId, rows) {
const detail = this.details.find(
item => String(item.sourceDetailId || item.id) === String(sourceDetailId)
);
if (!detail) return;
// 原金额以进入预结算单时的费用快照为基准,调整金额 = 结算金额 - 原金额。
const baseRows = this.sourceFeeSnapshots[String(sourceDetailId)] || [];
const originalAmount = baseRows.length
? baseRows.reduce((sum, row) => sum + Number(row.settlementAmountTax || 0), 0)
: rows.reduce((sum, row) => sum + Number(row.originalAmount || 0), 0);
const settlementAmountTax = rows.reduce(
(sum, row) => sum + Number(row.settlementAmountTax || 0),
0
);
const freightAmount = rows.reduce((sum, row) => sum + Number(row.freightAmount || 0), 0);
const feeItems = {};
rows.forEach(row =>
Object.entries(row.feeItems || {}).forEach(([name, amount]) => {
feeItems[name] = Number((Number(feeItems[name] || 0) + Number(amount || 0)).toFixed(2));
})
);
Object.assign(detail, {
originalAmount: Number(originalAmount.toFixed(2)),
adjustAmount: Number((settlementAmountTax - originalAmount).toFixed(2)),
settlementAmountTax: Number(settlementAmountTax.toFixed(2)),
freightAmount: Number(freightAmount.toFixed(2)),
feeItemsJson: JSON.stringify(feeItems),
});
this.buildSummaryFeesFromDetails();
},
async persistPendingAdjustments() {
const pendingEntries = Object.entries(this.pendingAdjustments);
if (!pendingEntries.length || !this.form.id) return;
for (const [sourceDetailId, pending] of pendingEntries) {
const detail = this.details.find(
item => String(item.sourceDetailId || '') === String(sourceDetailId)
);
if (!detail) continue;
const persistedFees = await getDetailFees(detail.id);
const feeRows = persistedFees.data?.data || persistedFees.data || [];
const pendingRows = pending.rows || [];
const rows = feeRows.map(fee => {
const pendingRow = this.findPendingAdjustmentRow(fee, pendingRows);
if (!pendingRow) {
throw new Error(`费用行“${fee.cargoName || fee.lineNo || fee.id}”保存匹配失败`);
}
return {
id: fee.id,
transportQuantity: pendingRow.transportQuantity,
mileage: pendingRow.mileage,
unitPrice: pendingRow.unitPrice,
freightAmount: pendingRow.freightAmount,
feeItems: pendingRow.feeItems,
settlementAmountTax: pendingRow.settlementAmountTax,
settlementAmountNoTax: pendingRow.settlementAmountNoTax,
remark: pendingRow.remark,
};
});
if (rows.length) {
await adjustDetail({ detailId: detail.id, changeReason: pending.reason, rows });
}
}
this.pendingAdjustments = {};
await this.loadDetail();
},
findPendingAdjustmentRow(fee, pendingRows) {
const exactIdMatch = pendingRows.find(
row => row.id && fee.sourceFeeId && String(row.id) === String(fee.sourceFeeId)
);
if (exactIdMatch) return exactIdMatch;
const exactLineMatch = pendingRows.find(
row =>
row.lineNo &&
fee.lineNo &&
String(row.lineNo) === String(fee.lineNo) &&
String(row.cargoName || '') === String(fee.cargoName || '') &&
String(row.cargoType || '') === String(fee.cargoType || '')
);
if (exactLineMatch) return exactLineMatch;
const cargoMatches = pendingRows.filter(
row =>
String(row.cargoName || '') === String(fee.cargoName || '') &&
String(row.cargoType || '') === String(fee.cargoType || '')
);
return cargoMatches.length === 1 ? cargoMatches[0] : null;
},
exportDetails() {
if (!this.filteredDetails.length) {
this.$message.warning('暂无可导出的结算明细');
return;
}
const dynamicFeeItems = this.detailFeeItemNames;
const rows = this.filteredDetails.map((row, index) => {
const feeItems = this.parseFeeItems(row.feeItemsJson);
const result = {
序号: index + 1,
单据号: row.documentNo,
运单号: row.waybillNo,
车号: row.vehicleNo,
发货地址: row.departureAddress,
到货地址: row.arrivalAddress,
实际发货时间: row.actualDepartureTime,
实际完成时间: row.actualCompletionTime,
运输类型: row.transportType,
货物名称: row.cargoName,
货物类型: row.cargoType,
运输总量: this.formatQuantity(row),
'里程(KM)': row.mileage,
批次号: row.batchNo,
运输单价: this.formatMoney(row.unitPrice, row.currency || this.form.currency),
运费: this.formatMoney(row.freightAmount, row.currency || this.form.currency),
};
dynamicFeeItems.forEach(name => {
result[name] = this.formatMoney(feeItems[name], row.currency || this.form.currency);
});
result['结算金额(含税)'] = this.formatMoney(
row.settlementAmountTax,
row.currency || this.form.currency
);
result['结算金额(不含税)'] = this.formatMoney(
row.settlementAmountNoTax,
row.currency || this.form.currency
);
result.备注 = row.remark;
return result;
});
const sheet = XLSX.utils.json_to_sheet(rows);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, sheet, '结算明细');
XLSX.writeFile(
workbook,
`${this.form.preSettlementNo || '预结算单'}结算明细${this.$dayjs().format(
'YYYY-MM-DD HH-mm-ss'
)}.xlsx`
);
},
handleAttachmentChange(list) {
const uploadUserName = this.userInfo?.realName || this.userInfo?.userName || '';
const uploadTime = this.$dayjs().format('YYYY-MM-DD HH:mm:ss');
const existingRows = this.attachments || [];
this.attachments = (list || []).map(file => {
const fileUrl = this.attachmentUrl(file);
const existing = existingRows.find(item => {
const sameUid = file.uid && item.uid && String(file.uid) === String(item.uid);
const sameUrl = fileUrl && this.attachmentUrl(item) === fileUrl;
return sameUid || sameUrl;
});
const originalName = this.attachmentName(file);
return {
...file,
type: existing?.type || file.type || this.resolveAttachmentType(originalName),
description: existing?.description || file.description || '',
uploadUserName: existing?.uploadUserName || file.uploadUserName || uploadUserName,
uploadTime: existing?.uploadTime || file.uploadTime || uploadTime,
};
});
this.sortAttachments();
this.selectedAttachmentRows = [];
},
async loadAttachmentTypeOptions() {
try {
const response = await getDictionary({ code: 'pre_settlement_attachment_type' });
const data = response?.data?.data || [];
this.attachmentTypeOptions = data
.map(item => ({
label: item.dictValue,
value: item.dictValue,
}))
.filter(item => item.label && item.value);
} catch (error) {
this.attachmentTypeOptions = [];
}
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;
return '';
},
isOtherAttachmentType(item) {
const typeText = String(item?.label || item?.value || '').trim();
return (
typeText === '其他' ||
typeText === '其它' ||
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);
},
attachmentTypeName(type) {
const option = this.attachmentTypeOptions.find(
item => String(item.value) === String(type) || String(item.label) === String(type)
);
return option?.label || type || '-';
},
handleAttachmentSelectionChange(rows) {
this.selectedAttachmentRows = rows || [];
},
attachmentUrl(file = {}) {
return file.url || file.link || file.fileUrl || file.domain || '';
},
attachmentName(file = {}) {
return file.originalName || file.name || file.fileName || '附件';
},
formatFileSize(size) {
const bytes = Number(size);
if (!bytes) return '-';
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
},
previewAttachment(file) {
this.$refs.attachmentUploadRef?.handlePreview(file);
},
downloadAttachment(file) {
const url = this.attachmentUrl(file);
if (!url) {
this.$message.warning('附件地址为空');
return;
}
downloadFileByUrl(url, this.attachmentName(file));
},
downloadAttachments() {
const files = this.selectedAttachmentRows.length
? this.selectedAttachmentRows
: this.attachments;
files.forEach((file, index) => {
window.setTimeout(() => this.downloadAttachment(file), index * 200);
});
},
removeAttachment(index) {
this.attachments.splice(index, 1);
this.selectedAttachmentRows = this.selectedAttachmentRows.filter(file =>
this.attachments.includes(file)
);
},
parseAttachments(value) {
if (!value) return [];
let list = [];
if (Array.isArray(value)) {
list = value;
} else {
try {
const parsed = JSON.parse(value);
list = Array.isArray(parsed) ? parsed : [];
} catch (error) {
return [];
}
}
return list.map(file => {
const originalName = this.attachmentName(file);
return {
...file,
type: file?.type || this.resolveAttachmentType(originalName),
description: file?.description || '',
};
});
},
parseFeeItems(value) {
if (!value) return {};
if (typeof value === 'object') return { ...value };
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (error) {
return {};
}
},
parseObject(value) {
if (!value) return {};
if (typeof value === 'object' && !Array.isArray(value)) return { ...value };
if (typeof value !== 'string') return {};
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch (error) {
return {};
}
},
parseJsonValue(value) {
if (typeof value !== 'string') return value;
try {
return JSON.parse(value);
} catch (error) {
return value;
}
},
formatMoney(value, currency = 'RMB') {
if (value === undefined || value === null || value === '') return '-';
const amount = Number(value);
if (!Number.isFinite(amount)) return '-';
return `${amount.toFixed(2)} ${currency || 'RMB'}`;
},
formatCreateDate(value) {
if (value === undefined || value === null || value === '') return '-';
const parsed = this.$dayjs(value);
return parsed.isValid() ? parsed.format('YYYY-MM-DD') : this.displayValue(value);
},
formatQuantity(row) {
const value = row.transportQuantity;
if (value === undefined || value === null || value === '') return '-';
const quantity = Number(value);
if (!Number.isFinite(quantity)) return '-';
return `${quantity.toFixed(2)}${row.quantityUnit ? ` ${row.quantityUnit}` : ''}`;
},
// 拼接同一运单的货物名称
formatCargoNames(row) {
const detailId = row.id;
const feeRows = this.detailFeeCache[detailId] || [];
// 如果没有缓存数据或只有一条,返回原始值
if (feeRows.length <= 1) {
return this.displayValue(row.cargoName);
}
// 收集所有货物名称并去重
const names = feeRows
.map(item => item.cargoName)
.filter(name => name && String(name).trim())
.filter((name, index, arr) => arr.indexOf(name) === index);
return names.length > 0 ? names.join('、') : this.displayValue(row.cargoName);
},
// 拼接同一运单的货物类型
formatCargoTypes(row) {
const detailId = row.id;
const feeRows = this.detailFeeCache[detailId] || [];
// 如果没有缓存数据或只有一条,返回原始值
if (feeRows.length <= 1) {
return this.displayValue(row.cargoType);
}
// 收集所有货物类型并去重
const types = feeRows
.map(item => item.cargoType)
.filter(type => type && String(type).trim())
.filter((type, index, arr) => arr.indexOf(type) === index);
return types.length > 0 ? types.join('、') : this.displayValue(row.cargoType);
},
// 格式化运输单价,如果多个货物的单价不一样则显示'-'
formatUnitPrice(row) {
const detailId = row.id;
const feeRows = this.detailFeeCache[detailId] || [];
// 如果没有缓存数据或只有一条,返回原始值
if (feeRows.length <= 1) {
return this.formatMoney(row.unitPrice, row.currency || this.form.currency);
}
// 检查所有货物的运输单价是否一致
const prices = feeRows
.map(item => Number(item.unitPrice || 0))
.filter((price, index, arr) => arr.indexOf(price) === index);
// 如果单价不一致,返回'-'
if (prices.length > 1) {
return '-';
}
// 单价一致,返回该单价
return this.formatMoney(prices[0], row.currency || this.form.currency);
},
displayValue(value) {
return value === undefined || value === null || value === '' ? '-' : value;
},
advanceStatusName(status) {
return (
{
reviewing: '审批中',
approved: '审批通过',
paid: '已付款',
returned: '已驳回',
voided: '已作废',
}[status] || this.displayValue(status)
);
},
advanceCreateUserName(row) {
return this.displayValue(
row?.createUserName || row?.advanceCreateUserName || row?.applyUserName
);
},
advanceCreateTime(row) {
return this.displayValue(row?.createTime || row?.advanceCreateTime || row?.applyTime);
},
changeRecordLineNo(row) {
if (row.changeType !== '结算明细项') return '';
if (row.lineNo !== undefined && row.lineNo !== null && row.lineNo !== '') return row.lineNo;
return '';
},
isSummaryMoney(prop) {
return ['originalAmount', 'adjustAmount', 'settlementAmount'].includes(prop);
},
moneyCurrency(prop) {
return prop === 'localSettlementAmount' ? this.form.localCurrency : this.form.currency;
},
handleClosed() {
this.resetEditor();
},
},
};
</script>
<style lang="scss" scoped>
.pre-settlement-editor {
&__content {
max-height: 82vh;
overflow-y: auto;
}
&--page &__content {
max-height: none;
padding-bottom: 72px;
overflow: visible;
}
&__page-actions {
display: flex;
justify-content: flex-end;
padding: 12px 24px;
border-top: 1px solid #eff1f7;
position: fixed;
right: 0;
left: 230px;
bottom: 0;
margin: 0;
z-index: 10;
background: #fff;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
}
&__form {
:deep(.el-select),
:deep(.el-date-editor),
:deep(.el-input-number) {
width: 100%;
}
}
&__section-actions,
&__links {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
&__detail-filter {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)) auto;
align-items: center;
gap: 8px 16px;
margin-bottom: 12px;
padding: 12px 12px 4px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
:deep(.el-form-item) {
display: flex;
min-width: 0;
margin-right: 0;
margin-bottom: 0;
}
:deep(.el-form-item__content) {
flex: 1;
min-width: 0;
}
:deep(.el-input) {
width: 100%;
}
}
&__detail-filter-actions {
width: auto;
margin-left: 0;
white-space: nowrap;
justify-self: end;
:deep(.el-form-item__content) {
flex: none;
justify-content: flex-end;
flex-wrap: nowrap;
}
}
&__contract-filter {
display: grid !important;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px 24px;
margin-bottom: 16px;
:deep(.el-form-item) {
display: flex;
min-width: 0;
margin-bottom: 0;
}
:deep(.el-form-item__content) {
flex: 1;
min-width: 0;
}
:deep(.el-input),
:deep(.el-select) {
width: 100%;
}
}
&__contract-filter-actions {
grid-column: 1 / -1;
width: 100%;
justify-content: flex-end;
:deep(.el-form-item__content) {
justify-content: flex-end;
}
}
&__attachment-upload {
display: flex;
justify-content: flex-start;
margin-top: 12px;
:deep(.vehicle-attachment-upload) {
width: auto;
}
:deep(.el-upload) {
display: inline-flex;
}
}
&__negative-amount {
color: var(--el-color-danger);
}
:deep(.pre-settlement-editor__negative-amount .el-input__inner) {
color: var(--el-color-danger);
}
&__pagination {
display: flex;
justify-content: flex-end;
padding-top: 12px;
}
&__adjust-reason {
margin-top: 16px;
}
&__billing-rule-cell {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
max-width: 100%;
}
&__billing-rule-icon {
flex: 0 0 auto;
color: var(--el-color-primary);
cursor: pointer;
}
&__billing-rule-detail {
margin-top: 12px;
}
&__billing-rule-match-title {
margin-top: 20px;
}
.form-readonly {
min-height: 32px;
line-height: 32px;
color: #303133;
}
}
.dialog-section-title {
display: flex;
align-items: center;
min-height: 22px;
font-size: 16px;
font-weight: 600;
&::before {
width: 4px;
height: 18px;
margin-right: 8px;
background: #409eff;
content: '';
}
}
:deep(.pre-settlement-editor .el-dialog__body) {
padding: 12px 16px;
}
:deep(.pre-settlement-editor .el-form-item) {
margin-bottom: 24px;
}
:deep(.pre-settlement-editor .el-table),
:deep(.pre-settlement-candidate-dialog .el-table) {
--el-table-border-color: #eff1f7;
}
:deep(.pre-settlement-editor .el-table__body tr:nth-child(even) > td.el-table__cell),
:deep(.pre-settlement-candidate-dialog .el-table__body tr:nth-child(even) > td.el-table__cell) {
background: #fafafa;
}
:global(.avue--collapse .pre-settlement-editor__page-actions) {
left: 60px;
}
:global(.avue-layout--horizontal .pre-settlement-editor__page-actions) {
left: 0;
}
</style>