Files
tms-erp-web/src/views/settlement/components/pre-settlement-editor.vue
T
b2894lxlx e1d87f5788 ✨ 结算汇率反算、开票合计填报、配载承运商交集与车辆组织悬停
外币结算按本位币/汇率反算;开票改填合计金额并反算不含税单价;配载展示承运商合同并按乙方交集限选;评分加减分封顶15分;车辆所有组织/使用部门悬停显示完整路径。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-26 10:50:58 +08:00

3422 lines
125 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 visibleFormFields" :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="form.contractName"
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"
placeholder="请选择"
@change="handleExchangeRateDateChange"
/>
<el-input-number
v-else-if="field.type === 'number'"
v-model="form[field.prop]"
:min="0"
:precision="2"
:controls="false"
:disabled="!editable"
@change="recalculateLocalAmount"
/>
<el-select
v-else-if="field.type === 'select' && editable"
v-model="form[field.prop]"
filterable
clearable
placeholder="请选择"
>
<el-option
v-for="item in formSelectOptions(field)"
:key="item.dictKey"
:label="item.dictValue"
:value="item.dictKey"
/>
</el-select>
<span v-else-if="field.type === 'select'" class="form-readonly">
{{ formSelectLabel(field, form[field.prop]) }}
</span>
<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"
:class="{ 'form-readonly--code': form.preSettlementNo }"
>
{{ 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="item in detailDisplayColumns"
:key="item.key"
:label="item.kind === 'fee' ? item.name : item.column.label"
:prop="item.kind === 'fee' ? undefined : item.column.prop"
:min-width="item.kind === 'fee' ? 150 : item.column.minWidth"
align="center"
show-overflow-tooltip
>
<template #header>
<span class="pre-settlement-editor__billing-rule-cell">
<span>{{ item.kind === 'fee' ? item.name : item.column.label }}</span>
<el-icon
v-if="showDetailColumnRuleIcon(item)"
class="pre-settlement-editor__billing-rule-icon"
title="查看计费规则详情"
@click.stop="openDetailColumnRule(item)"
>
<InfoFilled />
</el-icon>
</span>
</template>
<template #default="{ row }">
<span v-if="item.kind === 'fee'">
{{
formatMoney(
parseFeeItems(row.feeItemsJson)[item.name],
row.currency || form.currency
)
}}
</span>
<el-link
v-else-if="item.column.link && row[item.column.prop] && !publicMode"
type="primary"
@click.stop="handleDetailLink(row, item.column)"
>
{{ row[item.column.prop] }}
</el-link>
<span v-else-if="item.column.money && item.column.prop === 'unitPrice'">
{{ formatUnitPrice(row) }}
</span>
<span v-else-if="item.column.prop === 'freightAmount'">
{{ formatMoney(row[item.column.prop], row.currency || form.currency) }}
</span>
<span v-else-if="item.column.money">
{{ formatMoney(row[item.column.prop], row.currency || form.currency) }}
</span>
<span v-else-if="item.column.prop === 'transportQuantityText'">
{{ formatQuantity(row) }}
</span>
<span v-else-if="item.column.prop === 'mileage'">
{{ formatDetailMileage(row[item.column.prop]) }}
</span>
<span v-else-if="item.column.prop === 'transportType'">
{{ transportTypeName(row[item.column.prop]) }}
</span>
<span v-else-if="item.column.prop === 'cargoName'">
{{ formatCargoNames(row) }}
</span>
<span v-else-if="item.column.prop === 'cargoType'">
{{ formatCargoTypes(row) }}
</span>
<span v-else>{{ displayValue(row[item.column.prop]) }}</span>
</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 }">
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span>
</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>
<project-advance-write-off
ref="projectAdvanceWriteOffRef"
v-model="projectAdvanceWriteOffs"
:editable="editable"
:contract-id="form.contractId"
:settlement-amount="form.settlementAmount"
settlement-type="pre"
:settlement-id="form.id"
/>
<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="changeRecords" border>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column
v-for="column in changeTableColumns"
:key="column.prop"
:label="column.label"
:prop="column.prop"
:min-width="column.minWidth"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<span v-if="column.prop === 'lineNo'">{{ changeRecordLineNo(row) }}</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center" fixed="right">
<template #default="{ row }">
<el-link type="primary" @click="openAdjustChangeRecord(row)">详情</el-link>
</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="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 }">
<span v-if="adjustDialog.readonly">{{ formatDetailMileage(row.mileage) }}</span>
<el-input-number
v-else
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 }">
<span v-if="isTierUnitPrice(row)">-</span>
<el-input-number
v-else
v-model="row.unitPrice"
:min="0"
:precision="2"
:controls="false"
:disabled="adjustDialog.readonly || !isUnitPriceAdjustable(row)"
@change="recalculateAdjustRow(row, 'unitPrice')"
/>
</template>
</el-table-column>
<el-table-column
v-for="feeItem in adjustFeeItemNames"
:key="feeItem"
:label="feeItem"
min-width="160"
align="center"
>
<template #header>
<span class="pre-settlement-editor__billing-rule-cell">
<span>{{ feeItem }}</span>
<el-icon
v-if="showAdjustFeeRuleIcon(feeItem)"
class="pre-settlement-editor__billing-rule-icon"
title="查看计费规则详情"
@click.stop="openAdjustFeeRule(feeItem)"
>
<InfoFilled />
</el-icon>
</span>
</template>
<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>
<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.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.cargoName) }}
</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 ProjectAdvanceWriteOff from './project-advance-write-off.vue';
import { getMkPublicDetail } from '@/api/mk-process';
import {
adjustDetail,
getCandidateDetails,
getContractList,
getContractOptions,
getDetail,
getDetailFees,
getFeeOptions,
getNextNo,
removeDetail,
save,
submit,
} from '@/api/settlement/preSettlement';
import { calculateAdjustedFee, getFeeDetail } from '@/api/settlement/receivable-payable-detail';
import {
resolveReceivablePayableDetailId,
resolveWaybillId,
settlementDetailPath,
} from '@/utils/settlement-detail-link';
import { getBfiExchangeRate } from '@/api/base/currency';
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 { isTierUnitPrice, isUnitPriceAdjustable } from '@/utils/settlement-adjust-rule';
import { isApprovalSettlementChange } from '@/utils/settlement-change-record';
import { normalizeCurrencyCode } from '@/utils/currency';
import { settlementStatusName } from '@/utils/settlementStatus';
import { downloadFileByUrl } from '@/utils/util';
import { loadMkEnabled, submitMkApprovalFlow } from '@/utils/mk-approval';
import * as XLSX from 'xlsx';
import { isAdministrator } from '@/utils/auth';
// 结算币种为本位币(人民币)时汇率恒为1,汇率日期、结算汇率与按汇率换算的本位币合计都不展示
const LOCAL_CURRENCY_HIDDEN_FIELDS = ['exchangeRateDate', 'exchangeRate', 'localSettlementAmount'];
export default {
name: 'PreSettlementEditor',
components: { InfoFilled, ChangeRecordDetailDialog, ProjectAdvanceWriteOff },
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: [],
businessModelOptions: [],
transportTypeOptions: [],
summaryFees: [],
details: [],
detailCollapsed: false,
detailQuery: {
documentNo: '',
waybillNo: '',
batchNo: '',
cargoName: '',
},
appliedDetailQuery: {
documentNo: '',
waybillNo: '',
batchNo: '',
cargoName: '',
},
detailPage: {
current: 1,
size: 10,
},
advances: [],
projectAdvanceWriteOffs: [],
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: '',
// 未保存明细(id 仍等于来源应收应付明细)时先暂存,保存预结算单后再生效
draftMode: false,
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' ? '应收' : '应付';
},
// 币种为空按人民币处理,与后端 normalizeRate 的取默认值一致
isLocalCurrencySettlement() {
return normalizeCurrencyCode(this.form.currency) === 'RMB';
},
visibleFormFields() {
if (!this.isLocalCurrencySettlement) return this.formFields;
return this.formFields.filter(field => !LOCAL_CURRENCY_HIDDEN_FIELDS.includes(field.prop));
},
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);
},
// 费用项里已有运输费/运费时,不再单独展示运费列,避免和应收应付费用项重复。
showStandaloneFreightColumn() {
return !this.detailFeeItemNames.some(name => this.isFreightFeeItem(name));
},
detailDisplayColumns() {
const columns = this.visibleDetailColumns.filter(
column => this.showStandaloneFreightColumn || column.prop !== 'freightAmount'
);
const unitPriceIndex = columns.findIndex(column => column.prop === 'unitPrice');
const beforeColumns = unitPriceIndex < 0 ? columns : columns.slice(0, unitPriceIndex + 1);
const afterColumns = unitPriceIndex < 0 ? [] : columns.slice(unitPriceIndex + 1);
return [
...beforeColumns.map(column => ({ kind: 'field', key: column.prop, column })),
...this.detailFeeItemNames.map(name => ({ kind: 'fee', key: `fee-${name}`, name })),
...afterColumns.map(column => ({ kind: 'field', key: column.prop, column })),
];
},
// 按 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);
},
},
watch: {
modelValue: {
immediate: true,
handler(value) {
if (value) this.initialize();
},
},
summaryTotal() {
this.syncSettlementAmountsFromSummary();
},
'form.exchangeRate'() {
if (this._skipExchangeRateRebuild) return;
this.buildSummaryFeesFromDetails();
},
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: {
isTierUnitPrice,
isUnitPriceAdjustable,
hasPermission(code) {
return this.isAdmin || this.validData(this.permission?.[code], false);
},
async initialize() {
this.resetEditor();
// 新增页在保存前没有创建人/创建日期,先按当前登录用户与当前时间展示将要落库的值
if (!this.recordId) {
this.form.createUserName = this.userInfo?.realName || this.userInfo?.userName || '';
this.form.createTime = this.$dayjs().format('YYYY-MM-DD HH:mm:ss');
}
if (!this.publicMode) {
await this.loadFeeOptions();
await this.loadFeeCategoryOptions();
await this.loadBusinessModelOptions();
await this.loadTransportTypeOptions();
await this.loadAttachmentTypeOptions();
}
if (this.recordId) await this.loadDetail();
else if (this.initialData) {
await this.applyInitialData();
this.form.exchangeRateDate = this.$dayjs().format('YYYY-MM-DD');
await this.refreshPreSettlementNo();
await this.fetchBfiExchangeRate();
} else if (!this.publicMode) {
await this.refreshPreSettlementNo();
}
},
async refreshPreSettlementNo() {
if (this.form.id) return;
const response = await getNextNo();
this.form.preSettlementNo = response.data?.data || '';
},
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 || '';
let contractPartyA = first.payerName || '';
let contractPartyB = first.payeeName || '';
if (contractId && (!contractPartyA || !contractPartyB)) {
try {
const { data } = await getContractOptions(first.contractNo || '');
const matched = (data?.data || []).find(item => String(item.id) === String(contractId));
contractPartyA = contractPartyA || matched?.partyA || '';
contractPartyB = contractPartyB || matched?.partyB || '';
} catch (error) {
contractPartyA = '';
contractPartyB = '';
}
}
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: contractPartyA,
partyB: contractPartyB,
});
}
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: this.form.payerName || contractPartyA,
payeeName: this.form.payeeName || contractPartyB,
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.projectAdvanceWriteOffs = [];
this.changeRecords = [];
this.attachments = [];
this.selectedAttachmentRows = [];
this.adjustRows = [];
this.pendingAdjustments = {};
this.sourceFeeSnapshots = {};
this.detailFeeCache = {};
this.adjustDialog.detailId = '';
this.adjustDialog.sourceDetailId = '';
this.adjustDialog.draftMode = false;
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.projectAdvanceWriteOffs = (detail.projectAdvanceWriteOffs || []).map(row => ({
...row,
writeOffAmount: Number(row.writeOffAmount || 0),
projectAdvanceAmount: Number(row.projectAdvanceAmount || 0),
availableWriteOffAmount: Number(row.availableWriteOffAmount || 0),
createTime: row.paymentCreateTime || row.createTime,
}));
this.changeRecords = (detail.changeRecords || []).filter(
row => !isApprovalSettlementChange(row.changeContent)
);
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 loadBusinessModelOptions() {
const { data } = await getDictionary({ code: 'business_model' });
const records = Array.isArray(data?.data) ? data.data : data?.data?.records || [];
this.businessModelOptions = records.map(item => ({
dictKey: item.dictKey ?? item.value,
dictValue: item.dictValue ?? item.label ?? item.name,
}));
},
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,
}));
},
formSelectOptions(field) {
const key = field?.optionsKey;
const options = key ? this[key] : [];
return Array.isArray(options) ? options : [];
},
formSelectLabel(field, value) {
if (value === undefined || value === null || value === '') return '-';
const normalized = String(value).trim();
const option = this.formSelectOptions(field).find(
item =>
String(item.dictKey ?? '').trim() === normalized ||
String(item.dictValue ?? '').trim() === normalized
);
return option?.dictValue || normalized;
},
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 = [];
this.projectAdvanceWriteOffs = [];
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';
// 部分候选合同只带 id/名称,缺甲方乙方;此时保留已带出的收付方,不要清成 undefined
if (contract.partyA) this.form.payerName = contract.partyA;
if (contract.partyB) this.form.payeeName = contract.partyB;
this.form.currency = contract.settlementCurrency || 'RMB';
this.summaryFees = [];
this.projectAdvanceWriteOffs = [];
if (this.form.currency !== 'RMB') {
this.form.exchangeRateDate = this.$dayjs().format('YYYY-MM-DD');
this.fetchBfiExchangeRate();
}
},
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;
}
if (this.$refs.projectAdvanceWriteOffRef?.validateBeforeSave?.() === false) {
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 });
const mkStarted = await submitMkApprovalFlow({
bizType: 'pre-settlement',
bizId: this.form.id,
subjectName: this.form.preSettlementNo || this.form.contractName || '',
approvalStatus: previousApprovalStatus,
mkEnabled: await loadMkEnabled().catch(() => false),
});
this.$message.success(mkStarted ? '审批流程已发起' : '提交成功');
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,
businessModel: this.form.businessModel || undefined,
transportType: this.form.transportType || undefined,
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,
})),
projectAdvanceWriteOffs: this.$refs.projectAdvanceWriteOffRef?.toSavePayload?.() || [],
};
},
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 = this.convertLocalToSettlementAmount(originalValue);
const settlementAmount = this.convertLocalToSettlementAmount(settlementValue);
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);
const nextFeeMap = new Map();
summaryMap.forEach((row, key) => nextFeeMap.set(`0\u0000${key}`, row));
manualFees.forEach(row => {
const key = `1\u0000${row.feeType}\u0000${row.feeItem}`;
nextFeeMap.set(key, row);
});
// 按编辑前顺序回填,新增费用项追加到末尾,避免手工费用被统一挤到最后。
const orderedFees = [];
const previousKeys = this.summaryFees.map(
row => `${Number(row.manualFlag) === 1 ? 1 : 0}\u0000${row.feeType}\u0000${row.feeItem}`
);
previousKeys.forEach(key => {
const row = nextFeeMap.get(key);
if (!row) return;
orderedFees.push(row);
nextFeeMap.delete(key);
});
nextFeeMap.forEach(row => orderedFees.push(row));
this.summaryFees = orderedFees;
this.syncSettlementAmountsFromSummary();
},
// 每条明细对应的费用行:[调整前基准行, 调整后当前行],缺少来源费用快照时退化为明细自身的汇总行。
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)
);
},
handleExchangeRateDateChange() {
this.fetchBfiExchangeRate();
},
async fetchBfiExchangeRate() {
if (!this.form.currency || this.form.currency === 'RMB') {
this.buildSummaryFeesFromDetails();
return;
}
if (!this.form.exchangeRateDate) return;
try {
const { data } = await getBfiExchangeRate(this.form.currency, this.form.exchangeRateDate);
const rateData = data?.data;
if (rateData?.excval != null) {
this._skipExchangeRateRebuild = true;
this.form.exchangeRate = Number(rateData.excval);
this._skipExchangeRateRebuild = false;
this.buildSummaryFeesFromDetails();
} else {
this.$message.warning(`未查询到 ${this.form.currency} 的BFI汇率数据`);
}
} catch (e) {
this.$message.warning('查询BFI汇率失败,请手动输入结算汇率');
}
},
/**
* 明细金额按本位币理解;外币结算时结算合计原金额/结算金额 = 本位币金额 / 结算汇率。
*/
convertLocalToSettlementAmount(localAmount) {
const amount = Number(localAmount || 0);
if (!Number.isFinite(amount)) return 0;
if (this.isLocalCurrencySettlement) {
return Number(amount.toFixed(2));
}
const rate = Number(this.form.exchangeRate || 0);
if (rate <= 0) return 0;
return Number((amount / rate).toFixed(2));
},
/**
* 结算金额取结算合计原币汇总值;本位币合计 = 结算金额 × 结算汇率。
*/
syncSettlementAmountsFromSummary() {
const settlementTotal = this.summaryFees.reduce(
(total, row) => total + Number(row.settlementAmount || 0),
0
);
this.form.settlementAmount = Number(settlementTotal || 0).toFixed(2);
if (this.isLocalCurrencySettlement) {
this.form.localSettlementAmount = this.form.settlementAmount;
this.form.exchangeRate = 1;
return;
}
const rate = Number(this.form.exchangeRate || 0);
this.form.localSettlementAmount =
rate > 0 ? (Number(this.form.settlementAmount || 0) * rate).toFixed(2) : '0.00';
},
recalculateLocalAmount() {
this.buildSummaryFeesFromDetails();
},
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;
},
// 结算明细的「单据号」跳到该应收应付单据在应收明细/应付明细页的详情,「运单号」跳到运单详情
async handleDetailLink(row, column) {
if (column.prop === 'documentNo') {
const settlementType = row.settlementType || this.form.settlementType || 'payable';
const detailId = await resolveReceivablePayableDetailId(row, settlementType);
if (!detailId) return this.$message.info('当前记录未找到对应的应收/应付明细');
this.$router.push({ path: settlementDetailPath(settlementType), query: { detailId } });
return;
}
if (column.prop === 'waybillNo') {
const waybillId = await resolveWaybillId(row);
if (!waybillId) return this.$message.info('当前记录未找到对应的运单详情');
this.$router.push({ path: '/business/waybill-manage/detail', query: { id: waybillId } });
}
},
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;
const sourceDetailId = detailRow.sourceDetailId || '';
// 未保存明细 id 仍等于来源应收应付明细 id,调整结果先暂存
const draftMode =
!this.form.id ||
!detailRow.id ||
String(detailRow.id) === String(detailRow.sourceDetailId);
this.adjustDialog.visible = true;
this.adjustDialog.loading = true;
this.adjustDialog.readonly = readonly;
this.adjustDialog.draftMode = draftMode;
this.adjustDialog.detailId = detailRow.id;
this.adjustDialog.detailLineNo = detailRow.lineNo;
this.adjustDialog.sourceDetailId = sourceDetailId;
const pendingKey = String(sourceDetailId || '');
const pending = pendingKey ? this.pendingAdjustments[pendingKey] : null;
// 底部备注回显结算明细备注;暂存过的调整优先取暂存备注
this.adjustDialog.reason =
pending?.reason !== undefined && pending?.reason !== null
? pending.reason
: detailRow.remark || '';
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 (draftMode && sourceDetailId) {
if (!this.sourceFeeSnapshots[String(sourceDetailId)]) {
this.sourceFeeSnapshots[String(sourceDetailId)] =
await this.loadSourceFeeRows(sourceDetailId);
}
this.adjustRows = this.cloneAdjustRows(this.sourceFeeSnapshots[String(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 (Array.isArray(value)) {
if (!value.length) return '空';
return value
.map(item => {
if (!item || typeof item !== 'object') return String(item);
return Object.entries(item)
.map(([key, itemValue]) => `${key}:${this.formatAdjustChangeValue(itemValue)}`)
.join(',');
})
.join('\n');
}
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;
},
hasBillingRule(row) {
const rule = this.normalizeBillingRule(row);
return Boolean(
rule.feeItem ||
rule.billingElement ||
rule.billingType ||
rule.unitPrice ||
rule.rules?.length ||
rule.matchCondition?.origin ||
rule.matchCondition?.destination ||
rule.matchCondition?.cargoName ||
rule.matchCondition?.cargoType
);
},
detailFeeRows(row = {}) {
return this.detailFeeCache[row.id] || this.detailFeeCache[row.preSettlementDetailId] || [];
},
hasDetailBillingRule(row) {
if (this.hasBillingRule(row)) return true;
return this.detailFeeRows(row).some(fee => this.hasBillingRule(fee));
},
openDetailBillingRule(row, feeName = '') {
const fees = this.detailFeeRows(row);
const matched = fees.find(fee =>
this.collectBillingRules(fee).some(rule =>
this.feeNamesMatch(this.ruleFeeName(rule), feeName)
)
);
const source = matched || fees.find(fee => this.hasBillingRule(fee)) || row;
this.openBillingRuleInfo(source, feeName);
},
openBillingRuleInfo(row, feeName = '') {
this.billingRuleDialog.rule = this.normalizeBillingRule(row, feeName);
this.billingRuleDialog.visible = true;
},
ruleFeeName(rule = {}) {
return String(rule.feeItem || rule.feeItemName || rule.name || '').trim();
},
isHandlingFeeItem(name) {
return String(name || '').includes('装卸');
},
feeNamesMatch(left, right) {
const source = String(left || '').trim();
const target = String(right || '').trim();
if (!source || !target) return false;
if (source === target) return true;
if (this.isFreightFeeItem(source) && this.isFreightFeeItem(target)) return true;
return this.isHandlingFeeItem(source) && this.isHandlingFeeItem(target);
},
detailColumnFeeName(item = {}) {
if (item.kind === 'fee') return item.name || '';
if (item.column?.prop === 'freightAmount') return '运输费';
return '';
},
showDetailColumnRuleIcon(item) {
const feeName = this.detailColumnFeeName(item);
if (!feeName) return false;
return this.details.some(row => this.rowHasNamedBillingRule(row, feeName));
},
openDetailColumnRule(item) {
const feeName = this.detailColumnFeeName(item);
const row =
this.details.find(detail => this.rowHasNamedBillingRule(detail, feeName)) ||
this.details[0];
if (row) this.openDetailBillingRule(row, feeName);
},
showAdjustFeeRuleIcon(feeName) {
return (this.adjustRows || []).some(row => this.sourceHasNamedBillingRule(row, feeName));
},
openAdjustFeeRule(feeName) {
const row =
(this.adjustRows || []).find(item => this.sourceHasNamedBillingRule(item, feeName)) ||
this.adjustRows[0];
if (row) this.openBillingRuleInfo(row, feeName);
},
rowHasNamedBillingRule(row, feeName) {
return [row, ...this.detailFeeRows(row)].some(source =>
this.sourceHasNamedBillingRule(source, feeName)
);
},
sourceHasNamedBillingRule(source = {}, feeName) {
const rules = [
...this.collectBillingRules(source),
...(Array.isArray(source.billingRule?.rules) ? source.billingRule.rules : []),
];
if (rules.some(rule => this.feeNamesMatch(this.ruleFeeName(rule), feeName))) return true;
if (!this.hasBillingRule(source)) return false;
const named = String(
source.billingFeeItem || source.feeItem || this.ruleFeeName(source.billingRule) || ''
).trim();
if (!named) return true;
return this.feeNamesMatch(named, feeName);
},
collectBillingRules(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);
return Array.isArray(parsedNested)
? parsedNested.filter(item => item && typeof item === 'object')
: [];
},
normalizeBillingRule(row, feeName = '') {
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 parsedRules = this.collectBillingRules(source);
const requestedFeeName = String(feeName || '').trim();
const matchedRules = requestedFeeName
? parsedRules.filter(item => this.feeNamesMatch(this.ruleFeeName(item), requestedFeeName))
: parsedRules;
const sharedRule =
parsedRules.length === 1 && !this.ruleFeeName(parsedRules[0]) ? parsedRules[0] : null;
const ruleSource =
matchedRules[0] || sharedRule || (!requestedFeeName ? parsedNested || nested : null);
let rule = this.parseObject(ruleSource);
if (!Object.keys(rule).length && typeof nested === 'string' && !requestedFeeName) {
rule = { name: nested };
}
const matchCondition = this.parseObject(
requestedFeeName
? (rule.matchCondition ?? source.matchCondition ?? source.billingMatchCondition)
: (source.matchCondition ?? source.billingMatchCondition ?? rule.matchCondition)
);
return {
...rule,
feeItem: requestedFeeName || source.billingFeeItem || source.feeItem || rule.feeItem,
billingElement: requestedFeeName
? rule.billingElement || rule.billingFactor || ''
: source.billingElement ||
source.billingFactor ||
rule.billingElement ||
rule.billingFactor,
billingType: requestedFeeName ? rule.billingType || '' : source.billingType || rule.billingType,
billingUnit: requestedFeeName
? rule.billingUnit || rule.priceUnit || ''
: source.billingUnit || source.priceUnit || rule.billingUnit || rule.priceUnit,
unitPrice: requestedFeeName
? (rule.unitPrice ?? '')
: (source.billingUnitPrice ?? source.unitPrice ?? rule.unitPrice),
minimumBillingWeight: requestedFeeName
? (rule.minimumBillingWeight ?? rule.lowerLimit ?? '')
: (source.minimumBillingWeight ?? rule.minimumBillingWeight ?? rule.lowerLimit),
remark: requestedFeeName
? rule.remark || ''
: source.billingRuleRemark || source.ruleRemark || rule.remark,
rules: matchedRules,
matchCondition: {
...matchCondition,
origin: matchCondition.origin || matchCondition.originName,
destination: matchCondition.destination || matchCondition.destinationName,
cargoName:
matchCondition.cargoName ||
matchCondition.cargoNameLabel ||
source.cargoName ||
rule.cargoName,
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 (
['transportQuantity', 'mileage', 'unitPrice'].includes(changedField) &&
this.adjustDialog.sourceDetailId &&
(row.sourceFeeId || row.id) &&
(changedField !== 'unitPrice' || isUnitPriceAdjustable(row))
) {
this.scheduleSourceAdjustCalculation(row, changedField);
}
},
scheduleSourceAdjustCalculation(row, changedField) {
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,
// 已保存预结算费用行需用源应收应付费用ID试算
feeId: row.sourceFeeId || row.id,
transportQuantity: Number(row.transportQuantity || 0),
mileage: Number(row.mileage || 0),
unitPrice: row.unitPrice === null || row.unitPrice === '' ? null : Number(row.unitPrice),
changedField,
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)])
);
if (!isTierUnitPrice(row) && data.unitPrice !== undefined && data.unitPrice !== null) {
row.unitPrice = Number(data.unitPrice);
}
row.adjustAmount = Number(data.adjustAmount || 0);
row.settlementAmountTax = Number(data.afterAmount || 0);
} catch (error) {
row.calculateError = error.message || '费用试算失败';
this.$message.warning(row.calculateError);
} 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.draftMode && 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.reason
);
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,
})),
});
this.adjustDialog.visible = false;
this.$message.success('结算明细调整成功');
await this.loadDetail();
this.$emit('success', this.form.id);
} finally {
this.adjustDialog.saving = false;
}
},
applyLocalAdjustment(sourceDetailId, rows, detailRemark) {
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 settlementAmountNoTax = rows.reduce(
(sum, row) => sum + Number(row.settlementAmountNoTax || 0),
0
);
const hasSettlementAmountNoTax = rows.some(
row => row.settlementAmountNoTax !== undefined && row.settlementAmountNoTax !== null
);
const freightAmount = rows.reduce((sum, row) => sum + Number(row.freightAmount || 0), 0);
const transportQuantity = rows.reduce(
(sum, row) => sum + Number(row.transportQuantity || 0),
0
);
const mileage = rows.reduce((sum, row) => sum + Number(row.mileage || 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));
})
);
const firstRow = rows[0] || {};
Object.assign(detail, {
transportQuantity: Number(transportQuantity.toFixed(2)),
mileage: Number(mileage.toFixed(2)),
unitPrice: Number(firstRow.unitPrice || 0),
originalAmount: Number(originalAmount.toFixed(2)),
adjustAmount: Number((settlementAmountTax - originalAmount).toFixed(2)),
settlementAmountTax: Number(settlementAmountTax.toFixed(2)),
settlementAmountNoTax: hasSettlementAmountNoTax
? Number(settlementAmountNoTax.toFixed(2))
: detail.settlementAmountNoTax,
freightAmount: Number(freightAmount.toFixed(2)),
feeItemsJson: JSON.stringify(feeItems),
// 底部备注写入结算明细备注
remark: detailRemark ?? '',
});
// 暂存调整后同步费用缓存,保证货物名称/类型拼接与列表展示一致。
if (detail.id) {
this.detailFeeCache[detail.id] = rows.map(row => ({
...row,
feeItems: { ...row.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,
};
});
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,
计费方式: row.billingType,
运输单价: this.formatMoney(row.unitPrice, row.currency || this.form.currency),
};
if (this.showStandaloneFreightColumn) {
result['运费'] = 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 settlementStatusName(status, this.displayValue);
},
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;
}
.form-readonly--code {
color: var(--el-color-primary);
}
}
.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>