Files
tms-erp-web/src/views/business/contract-manage.vue
T
2026-11-02 09:54:00 +08:00

3843 lines
141 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>
<basic-container
:class="['contract-manage-page', { 'contract-manage-page--form': isFormPage || isDetailPage }]"
>
<template v-if="isListPage">
<avue-crud
ref="crud"
v-model:page="page"
:data="data"
:option="tableOption"
:permission="permissionList"
:table-loading="loading"
@search-change="searchChange"
@search-reset="searchReset"
@selection-change="selectionChange"
@current-change="currentChange"
@size-change="sizeChange"
@refresh-change="refreshChange"
@search-icon-change="searchExpanded = $event"
@on-load="onLoad"
>
<template #menu-left>
<el-button v-if="canCreate" type="primary" icon="el-icon-plus" @click="openForm('add')">
新增
</el-button>
<el-button
v-if="config.exportUrl && hasPermission(`${config.permission}_export`)"
type="primary"
icon="el-icon-download"
plain
@click="handleExport"
>
批量导出
</el-button>
<el-button
v-if="hcsImmediateEnabled"
type="warning"
plain
:loading="hcsRepushingId === 'batch'"
@click="handleHcsBatchRepush"
>
<el-icon class="el-icon--left"><Position /></el-icon>批量推送合规
</el-button>
</template>
<template #header>
<div class="contract-manage-page__expiry-search">
<el-check-tag
v-for="item in expiryTagOptions"
:key="item.value"
:checked="contractExpiryScope === item.value"
@change="changeExpiryScope(item.value)"
>
{{ item.label }}({{ contractExpiryStats[item.statKey] || 0 }})
</el-check-tag>
</div>
</template>
<template #contractName="{ row }">
<el-link v-if="row.contractName" type="primary" class="contract-name-cell" @click.stop="openDetail(row)">
{{ row.contractName }}
</el-link>
<span v-else>-</span>
</template>
<template #contractStage="{ row }">
<el-tag :type="stageTagType(row.contractStage)" class="status-text">
{{ displayStatus(row, 'contractStage') }}
</el-tag>
</template>
<template #approvalStatus="{ row }">
<el-tag :type="statusTagType(row.approvalStatus)" class="status-text">
{{ displayStatus(row, 'approvalStatus') }}
</el-tag>
</template>
<template #archiveStatus="{ row }">
<el-tag :type="archiveStatusTagType(row.archiveStatus)" class="status-text">
{{ row.archiveStatus || '未归档' }}
</el-tag>
</template>
<template #hcsSyncStatus="{ row }">
<el-tooltip v-if="row.hcsSyncMsg" placement="top" :content="row.hcsSyncMsg">
<el-tag :type="hcsSyncTagType(row.hcsSyncStatus)" class="status-text">
{{ hcsSyncLabel(row.hcsSyncStatus) }}
</el-tag>
</el-tooltip>
<el-tag v-else-if="row.hcsSyncStatus" :type="hcsSyncTagType(row.hcsSyncStatus)" class="status-text">
{{ hcsSyncLabel(row.hcsSyncStatus) }}
</el-tag>
<span v-else>-</span>
</template>
<!-- 客商风险:等级与金蝶黑名单组合展示(推送链路按金蝶命中口径落库),复用公共展示方法 -->
<template #customerRisk="{ row }">
<el-tag
v-if="contractRiskTagName(row)"
:type="isContractRisky(row) ? 'danger' : row.hcsRiskLevel === '1' ? 'warning' : 'success'"
class="status-text"
>
{{ contractRiskTagName(row) }}
</el-tag>
<span v-else>-</span>
</template>
<template #menu="{ row }">
<el-link
v-if="hasPermission(`${config.permission}_view`)"
type="primary"
@click="openDetail(row)"
>
查看
</el-link>
<el-link v-if="canEdit(row)" type="primary" @click="openForm('edit', row)">
{{ editLabel(row) }}
</el-link>
<el-link v-if="canCopy(row)" type="primary" @click="handleCopy(row)">复制</el-link>
<el-link
v-for="operation in customOperations(row)"
:key="`${operation.action}-${operation.label}`"
:type="operation.type || 'primary'"
@click="handleOperation(operation, row)"
>
{{ operation.label }}
</el-link>
<el-link
v-if="hcsImmediateEnabled && canHcsRepush(row)"
type="warning"
@click="handleHcsRepush(row)"
>
{{ hcsRepushingId === row.id ? '推送中…' : '推送合规' }}
</el-link>
<el-link v-if="canDelete(row)" type="danger" @click="removeRow(row)">删除</el-link>
</template>
</avue-crud>
<empty-pagination
:page="page"
@size-change="sizeChange"
@current-change="currentChange"
@load="onLoad(page, query)"
/>
</template>
<template v-else-if="isDetailPage">
<div class="archive-page-form__title">{{ detailPageTitle }}</div>
<div v-loading="detailLoading" class="contract-manage-form contract-manage-detail">
<section class="contract-manage-form__section contract-manage-form__section--panel">
<div class="dialog-section-title">基本信息</div>
<el-descriptions :column="3" class="contract-manage-detail__descriptions">
<el-descriptions-item label="签约类型">{{
detailValue('signType')
}}</el-descriptions-item>
<el-descriptions-item label="合同编号">{{
detailValue('contractNo')
}}</el-descriptions-item>
<el-descriptions-item label="合同名称">{{
detailValue('contractName')
}}</el-descriptions-item>
<el-descriptions-item label="合同类型">{{
detailValue('contractCategory')
}}</el-descriptions-item>
<el-descriptions-item label="项目">{{
detailValue('projectName')
}}</el-descriptions-item>
<el-descriptions-item label="甲方">{{ detailValue('partyA') }}</el-descriptions-item>
<el-descriptions-item label="乙方">{{ detailValue('partyB') }}</el-descriptions-item>
<el-descriptions-item label="合同期限">{{ detailContractPeriod }}</el-descriptions-item>
<el-descriptions-item label="合同负责部门">{{
detailValue('organizationName')
}}</el-descriptions-item>
<el-descriptions-item label="经办人">{{
detailValue('handlerUserName')
}}</el-descriptions-item>
<el-descriptions-item label="归档状态">{{
detailRow.archiveStatus || '未归档'
}}</el-descriptions-item>
<el-descriptions-item label="签订日期">{{
detailValue('signDate')
}}</el-descriptions-item>
<el-descriptions-item label="申请日期">{{
detailValue('applyDate')
}}</el-descriptions-item>
<el-descriptions-item label="合同格式">{{
detailValue('contractFormat')
}}</el-descriptions-item>
<el-descriptions-item label="是否需要加盖法人章">{{
detailLegalSealText
}}</el-descriptions-item>
<el-descriptions-item label="一式">{{
detailUnitValue('copyCount', '份')
}}</el-descriptions-item>
<el-descriptions-item label="结算币种">{{
detailDictionaryValue('settlementCurrency', settlementCurrencyOptions)
}}</el-descriptions-item>
<el-descriptions-item label="结算方式">{{
detailDictionaryValue('settlementMode', settlementModeOptions)
}}</el-descriptions-item>
<el-descriptions-item label="开票周期">{{
detailUnitValue('invoiceCycle', '天')
}}</el-descriptions-item>
<el-descriptions-item label="回款账期">{{
detailUnitValue('paymentDays', '天')
}}</el-descriptions-item>
<el-descriptions-item label="合同金额">{{
detailValue('contractAmount')
}}</el-descriptions-item>
<el-descriptions-item label="是否范本">{{ detailTemplateText }}</el-descriptions-item>
<el-descriptions-item label="原件合同编号">{{
detailValue('originalContractNo')
}}</el-descriptions-item>
<el-descriptions-item label="是否电子章">
{{ detailElectronicSealText }}
</el-descriptions-item>
<el-descriptions-item label="合同主要内容" :span="3">{{
detailValue('mainContent')
}}</el-descriptions-item>
<el-descriptions-item label="其他事项说明" :span="3">{{
detailValue('remark')
}}</el-descriptions-item>
<el-descriptions-item v-if="detailRiskTipText" label="风险客商提示" :span="3">
<span style="color: #f56c6c">{{ detailRiskTipText }}</span>
</el-descriptions-item>
</el-descriptions>
</section>
<hcs-contract-panel
v-if="!isPublicViewPage"
:contract="detailRow"
@refresh="initDetailPage"
/>
<contract-attachment-section
title="用印材料"
description
attachment-type
readonly
:rows="detailContractFileRows"
:preview="previewAttachment"
/>
<section class="contract-manage-form__section contract-manage-form__section--panel">
<div class="dialog-section-title">计费信息</div>
<el-descriptions :column="3" class="contract-manage-detail__descriptions">
<el-descriptions-item label="费用生成模式">{{
detailFeeGenerationMode
}}</el-descriptions-item>
</el-descriptions>
<el-table :data="detailBillingPlanRows" border>
<el-table-column type="index" label="序号" width="80" align="center" />
<el-table-column prop="planName" label="方案名称" min-width="220" align="center" />
<el-table-column label="运输方式" min-width="180" align="center">
<template #default="{ row }">{{
row.transportModeLabel || row.transportMode || '-'
}}</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center" fixed="right">
<template #default="{ row, $index }">
<el-link type="primary" @click="openDetailBillingPlan(row, $index)"
>查看详情</el-link
>
</template>
</el-table-column>
</el-table>
</section>
<section class="contract-manage-form__section contract-manage-form__section--panel">
<div class="dialog-section-title">结算单规则</div>
<el-tabs v-model="detailSettlementConfigTab">
<el-tab-pane label="预结算配置" name="pre" />
<el-tab-pane label="正式结算配置" name="formal" />
</el-tabs>
<el-descriptions :column="3" class="contract-manage-detail__descriptions">
<el-descriptions-item label="自动生成结算单">{{
Number(detailSettlementRule.autoGenerate) === 1 ? '开启' : '关闭'
}}</el-descriptions-item>
<template v-if="Number(detailSettlementRule.autoGenerate) === 1">
<el-descriptions-item label="账单起始日">{{
displayValue(detailSettlementRule.billStartDate)
}}</el-descriptions-item>
<el-descriptions-item label="结算类型">{{
displayValue(detailSettlementRule.settlementType)
}}</el-descriptions-item>
<el-descriptions-item
v-if="detailSettlementRule.settlementType === '月结'"
label="结算周期"
>{{ displayValue(detailSettlementRule.billCycleType) }}</el-descriptions-item
>
<el-descriptions-item
v-if="
detailSettlementRule.settlementType === '月结' &&
detailSettlementRule.billCycleType === '固定截单日'
"
label="账单截单日"
>{{
detailObjectUnitValue(detailSettlementRule, 'billCutoffDay', '日')
}}</el-descriptions-item
>
<el-descriptions-item
v-if="isFixedCycleSettlementType(detailSettlementRule.settlementType)"
label="周期天数"
>{{
detailObjectUnitValue(detailSettlementRule, 'cycleDays', '天')
}}</el-descriptions-item
>
</template>
</el-descriptions>
<el-table
v-if="
Number(detailSettlementRule.autoGenerate) === 1 &&
detailSettlementRule.settlementType === '月结' &&
detailSettlementRule.billCycleType === '自定义多周期'
"
:data="detailSettlementRule.customPeriods || []"
border
class="contract-manage-form__custom-periods"
>
<el-table-column type="index" label="序号" width="80" align="center" />
<el-table-column label="运单区间-开始日" min-width="180" align="center">
<template #default="{ row }">{{ row.startDay || '-' }}</template>
</el-table-column>
<el-table-column label="运单区间-结束日" min-width="180" align="center">
<template #default="{ row }">{{ row.endDay || '-' }}</template>
</el-table-column>
</el-table>
</section>
<section class="contract-manage-form__section contract-manage-form__section--panel">
<div class="dialog-section-title">
付款比例设置
<el-tooltip content="非必填;配置付款比例时,合计必须等于100%。" placement="top">
<el-icon style="margin-left: 4px; color: #909399; cursor: pointer"
><el-icon-question-filled
/></el-icon>
</el-tooltip>
</div>
<el-table :data="detailPaymentRatioRows" border>
<el-table-column type="index" label="序号" width="80" align="center" />
<el-table-column prop="paymentTerm" label="付款笔数" min-width="180" align="center" />
<el-table-column label="付款比例上限(%)" min-width="220" align="center">
<template #default="{ row }">{{
detailObjectUnitValue(row, 'ratioLimit', '%')
}}</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="220" />
</el-table>
</section>
<contract-attachment-section
title="附件(佐证材料等)"
description
attachment-type
readonly
:rows="detailAttachmentRows"
:preview="previewAttachment"
/>
<section class="contract-manage-form__section contract-manage-form__section--panel">
<div class="dialog-section-title">变更记录</div>
<el-table :data="detailChangeRecordRows" border>
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column
prop="changeDate"
label="变更日期"
min-width="150"
align="center"
sortable
/>
<el-table-column prop="handlerUserName" label="经办人" min-width="140" align="center" />
<el-table-column prop="changeType" label="变更类型" min-width="160" align="center" />
<el-table-column label="变更内容" min-width="420">
<template #default="{ row }">
<el-tooltip placement="top" :show-after="200">
<template #content>
<div class="contract-change-record-content-tooltip">
{{ formatContractChangeContent(row) }}
</div>
</template>
<span class="contract-change-record-content-cell">{{
formatContractChangeContent(row)
}}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column
prop="changeReason"
label="变更原因"
min-width="240"
align="center"
show-overflow-tooltip
/>
<el-table-column prop="statusName" label="状态" min-width="140" align="center" />
<el-table-column label="操作" width="120" align="center" fixed="right">
<template #default="{ row }"
><el-link type="primary" @click="openDetailChangeRecord(row)">详情</el-link></template
>
</el-table-column>
</el-table>
</section>
</div>
<div class="contract-manage-page__footer">
<el-button v-if="!isPublicViewPage" @click="closeDetail">关闭</el-button>
</div>
</template>
<template v-else>
<div class="archive-page-form__title">{{ formPageTitle }}</div>
<el-form
ref="contractForm"
:model="form"
:rules="formRules"
label-position="right"
label-width="auto"
class="contract-manage-form"
:class="{ 'contract-manage-form--attachment-upload': isAttachmentUploadMode }"
>
<section class="contract-manage-form__section contract-manage-form__section--panel">
<div class="dialog-section-title">基本信息</div>
<div class="contract-manage-form__grid">
<el-form-item label="签约类型" prop="signType">
<el-select v-model="form.signType" 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="合同编号" prop="contractNo">
<el-input
v-model="form.contractNo"
maxlength="100"
clearable
:placeholder="
formMode === 'add' ? '不填写则按部门规则自动生成' : '请输入合同编号'
"
@input="handleContractNoInput"
/>
</el-form-item>
<el-form-item label="合同名称" prop="contractName">
<el-input
v-model="form.contractName"
maxlength="100"
show-word-limit
placeholder="请输入合同名称"
/>
</el-form-item>
<el-form-item label="合同类型" prop="contractCategory">
<el-select v-model="form.contractCategory" 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="项目" prop="projectName">
<el-select
v-model="selectedProjectId"
placeholder="请选择"
filterable
clearable
:loading="projectLoading"
@visible-change="visible => visible && loadProjectOptions()"
@change="handleProjectChange"
>
<el-option
v-for="item in projectOptions"
:key="item.id"
:label="item.projectName"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="合同负责部门" prop="organizationName">
<el-cascader
v-model="organizationCascaderValue"
:options="organizationOptions"
:props="organizationCascaderProps"
placeholder="请选择"
clearable
filterable
@visible-change="visible => visible && loadOrganizationOptions()"
/>
</el-form-item>
<el-form-item label="甲方" prop="partyA">
<el-select
v-model="form.partyA"
placeholder="请选择甲方"
filterable
clearable
:loading="contractPartyLoading"
@visible-change="visible => visible && loadContractPartyOptions()"
@change="queryPartyRisk('partyA')"
>
<el-option
v-for="item in contractPartyAOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="乙方" prop="partyB">
<el-select
v-model="form.partyB"
placeholder="请选择乙方"
filterable
clearable
:loading="contractPartyLoading"
@visible-change="visible => visible && loadContractPartyOptions()"
@change="queryPartyRisk('partyB')"
>
<el-option
v-for="item in contractPartyBOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="签订日期" prop="signDate">
<el-date-picker
v-model="form.signDate"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="请选择签订日期"
/>
</el-form-item>
<el-form-item label="申请日期">
<el-date-picker
v-model="form.applyDate"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="提交正式合同时自动填充"
disabled
/>
</el-form-item>
<el-form-item label="合同期限" prop="startDate">
<div class="contract-manage-form__date-range">
<el-date-picker
v-model="form.startDate"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="请输入"
/>
<span>至</span>
<el-date-picker
v-model="form.endDate"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="请输入"
/>
</div>
</el-form-item>
<el-form-item label="回款账期">
<el-input
v-model="form.paymentDays"
placeholder="请输入"
@input="value => integerInput('paymentDays', value)"
><template #suffix>天</template></el-input>
</el-form-item>
<el-form-item label="合同金额" prop="contractAmount">
<el-input
:model-value="contractAmountDisplay"
placeholder="请输入"
clearable
@input="handleContractAmountInput"
@blur="formatContractAmountField"
@clear="form.contractAmount = null"
/>
</el-form-item>
<el-form-item label="是否范本">
<el-select v-model="form.templateFlag" placeholder="请选择">
<el-option label="否" :value="0" />
<el-option label="是" :value="1" />
</el-select>
</el-form-item>
<el-form-item label="原件合同编号">
<el-input v-model="form.originalContractNo" maxlength="100" placeholder="请输入" />
</el-form-item>
<el-form-item label="是否电子章">
<el-select v-model="form.electronicSealFlag" placeholder="请选择">
<el-option label="否" :value="0" />
<el-option label="是" :value="1" />
</el-select>
</el-form-item>
<el-form-item label="开票周期">
<el-input
v-model="form.invoiceCycle"
placeholder="请输入"
@input="value => positiveIntegerInput('invoiceCycle', value)"
><template #suffix>天</template></el-input>
</el-form-item>
<el-form-item label="结算币种" prop="settlementCurrency">
<el-select v-model="form.settlementCurrency" placeholder="请选择结算币种" clearable>
<el-option
v-for="item in settlementCurrencyOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="合同格式" prop="contractFormat">
<el-select v-model="form.contractFormat" placeholder="请选择合同格式">
<el-option
v-for="item in contractFormatOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="一式">
<el-input
v-model="form.copyCount"
placeholder="请输入"
@input="value => integerInput('copyCount', value)"
><template #suffix>份</template></el-input
>
</el-form-item>
<el-form-item label="是否需要加盖法人章" prop="legalSealFlag">
<el-select v-model="form.legalSealFlag" placeholder="请选择" clearable>
<el-option label="否" :value="0" />
<el-option label="是" :value="1" />
</el-select>
</el-form-item>
<el-form-item label="结算方式" prop="settlementMode">
<el-select v-model="form.settlementMode" placeholder="请选择结算方式" clearable>
<el-option
v-for="item in settlementModeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="经办人">
<el-input v-model="form.handlerUserName" disabled />
</el-form-item>
</div>
<el-form-item label="合同主要内容" prop="mainContent" class="contract-manage-form__remark">
<el-input
v-model="form.mainContent"
type="textarea"
:rows="4"
maxlength="1000"
show-word-limit
placeholder="请输入合同主要内容"
/>
</el-form-item>
<el-form-item label="其他事项说明" prop="remark" class="contract-manage-form__remark">
<el-input
v-model="form.remark"
type="textarea"
:rows="4"
maxlength="1000"
show-word-limit
placeholder="请输入其他事项说明"
/>
</el-form-item>
<!-- 风险客商提示(基本信息备注后):外部客商高风险/黑名单时展示,文案由后端 riskTip 拼装 -->
<div v-if="partyRiskText" class="contract-manage-form__risk-tip">
<div class="contract-manage-form__risk-tip-title">风险客商提示:</div>
<div class="contract-manage-form__risk-tip-content">{{ partyRiskText }}</div>
</div>
</section>
<contract-attachment-section
title="用印材料"
description
attachment-type
:attachment-location-options="contractAttachmentLocationOptions"
:lock-approved="isAttachmentUploadMode || hasApprovedContractFiles"
:mark-approved-on-upload="isAttachmentUploadMode"
:rows="contractFileRows"
:preview="previewAttachment"
@update:rows="contractFileRows = $event"
@upload-to-location="handleAttachmentUploadToLocation"
/>
<section class="contract-manage-form__section contract-manage-form__section--panel">
<div class="dialog-section-title">计费信息</div>
<div class="contract-manage-form__billing-head">
<div>
<span>费用生成模式</span>
<el-radio-group v-model="form.feeGenerationMode">
<el-radio label="system">系统生成</el-radio>
<el-tooltip content="运单完成后,系统根据计费规则自动生成应收应付" placement="top">
<el-icon class="contract-manage-form__fee-mode-tip"><QuestionFilled /></el-icon>
</el-tooltip>
<el-radio label="manual">账单导入生成</el-radio>
</el-radio-group>
</div>
<el-button
v-if="form.feeGenerationMode !== 'manual'"
type="primary"
@click="openBillingPlan()"
>添加</el-button
>
</div>
<el-table :data="billingPlanRows" border>
<el-table-column type="index" label="序号" width="80" align="center" />
<el-table-column prop="planName" label="方案名称" min-width="220" align="center" />
<el-table-column label="运输方式" min-width="180" align="center">
<template #default="{ row }">{{
row.transportModeLabel || row.transportMode || '-'
}}</template>
</el-table-column>
<el-table-column label="操作" width="180" align="center" fixed="right">
<template #default="{ row, $index }">
<el-link type="primary" @click="openBillingPlan(row, $index)">编辑</el-link>
<el-link type="danger" @click="removeBillingPlan($index)">删除</el-link>
</template>
</el-table-column>
</el-table>
</section>
<section class="contract-manage-form__section contract-manage-form__section--panel">
<div class="dialog-section-title">结算单规则</div>
<el-tabs v-model="settlementConfigTab">
<el-tab-pane label="预结算配置" name="pre" />
<el-tab-pane label="正式结算配置" name="formal" />
</el-tabs>
<div class="contract-manage-form__settlement-switch">
<span>自动生成结算单</span>
<el-radio-group v-model="settlementRuleForm.autoGenerate">
<el-radio :label="1">开启</el-radio>
<el-tooltip
content="开启时,系统根据配置规则归集运单,定时生成结算单"
placement="top"
>
<el-icon class="contract-manage-form__fee-mode-tip"><QuestionFilled /></el-icon>
</el-tooltip>
<el-radio :label="0">关闭</el-radio>
</el-radio-group>
</div>
<div
v-if="settlementRuleEnabled"
class="contract-manage-form__grid contract-manage-form__settlement-grid"
>
<el-form-item label="账单起始日" required>
<el-date-picker
v-model="settlementRuleForm.billStartDate"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="请选择账单起始日"
/>
</el-form-item>
<el-form-item label="结算类型" required>
<el-select
v-model="settlementRuleForm.settlementType"
placeholder="请选择结算类型"
@change="handleSettlementTypeChange"
>
<el-option
v-for="item in settlementTypeOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
<el-form-item v-if="showBillCycleType" label="结算周期" required>
<el-select
v-model="settlementRuleForm.billCycleType"
placeholder="请选择结算周期"
@change="handleBillCycleTypeChange"
>
<el-option
v-for="item in billCycleTypeOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
<el-form-item v-if="showBillCutoffDay" label="账单截单日" required>
<el-select v-model="settlementRuleForm.billCutoffDay" placeholder="请选择账单截单日">
<el-option
v-for="item in billCutoffDayOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item v-if="showCycleDays" label="周期天数" required>
<el-select v-model="settlementRuleForm.cycleDays" placeholder="请选择周期天数">
<el-option
v-for="item in cycleDayOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</div>
<div
v-if="settlementRuleEnabled && showCustomPeriods"
class="contract-manage-form__custom-periods"
>
<el-table :data="settlementRuleForm.customPeriods" border>
<el-table-column type="index" label="序号" width="80" align="center" />
<el-table-column label="运单区间-开始日" min-width="180" align="center">
<template #default="{ row, $index }">
<el-select
:model-value="row.startDay"
placeholder="请选择"
clearable
@update:model-value="value => handleCustomPeriodStartDayChange($index, value)"
>
<el-option
v-for="item in billCutoffDayOptions"
:key="`start-${item.value}`"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="运单区间-结束日" min-width="200" align="center">
<template #default="{ row, $index }">
<el-select
:model-value="row.endDay"
placeholder="请选择"
clearable
@update:model-value="value => handleCustomPeriodEndDayChange($index, value)"
>
<el-option
v-for="item in customPeriodEndDayOptions(row)"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template #default="{ $index }">
<el-link v-if="$index === 0" type="primary" @click="addCustomPeriodRow"
>添加</el-link
>
<el-link v-else type="danger" @click="removeCustomPeriodRow($index)"
>删除</el-link
>
</template>
</el-table-column>
<template #empty>
<el-link type="primary" @click="addCustomPeriodRow">添加</el-link>
</template>
</el-table>
</div>
</section>
<section class="contract-manage-form__section contract-manage-form__section--panel">
<div class="contract-manage-form__section-head">
<div class="dialog-section-title">
付款比例设置
<el-tooltip content="非必填;配置付款比例时,合计必须等于100%。" placement="top">
<el-icon style="margin-left: 4px; color: #909399; cursor: pointer"><el-icon-question-filled /></el-icon>
</el-tooltip>
</div>
<el-button type="primary" @click="addPaymentRatio">添加</el-button>
</div>
<el-table :data="paymentRatioRows" border>
<el-table-column type="index" label="序号" width="80" align="center" />
<el-table-column prop="paymentTerm" label="付款笔数" min-width="180" align="center" />
<el-table-column label="付款比例上限(%)" min-width="220" align="center">
<template #default="{ row }">
<el-input
v-model="row.ratioLimit"
placeholder="请输入"
@input="value => ratioInput(row, value)"
>
<template #append>%</template>
</el-input>
</template>
</el-table-column>
<el-table-column label="备注" min-width="220">
<template #default="{ row }">
<el-input v-model="row.remark" maxlength="200" />
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template #default="{ $index }">
<el-link type="danger" @click="paymentRatioRows.splice($index, 1)">删除</el-link>
</template>
</el-table-column>
</el-table>
</section>
<contract-attachment-section
title="附件(佐证材料等)"
description
attachment-type
attachment-location="附件(佐证材料等)"
attachment-type-dict-code="contract_supporting_attachment_type"
:attachment-location-options="contractAttachmentLocationOptions"
:readonly="isAttachmentUploadMode"
:rows="attachmentRows"
:preview="previewAttachment"
@update:rows="attachmentRows = $event"
@upload-to-location="handleAttachmentUploadToLocation"
/>
<section
v-if="formMode === 'edit'"
class="contract-manage-form__section contract-manage-form__section--panel"
>
<div class="dialog-section-title">变更记录</div>
<el-table :data="changeRecordRows" border>
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column
prop="changeDate"
label="变更日期"
min-width="150"
align="center"
sortable
/>
<el-table-column prop="handlerUserName" label="经办人" min-width="140" align="center" />
<el-table-column prop="changeType" label="变更类型" min-width="160" align="center" />
<el-table-column label="变更内容" min-width="420">
<template #default="{ row }">
<el-tooltip placement="top" :show-after="200">
<template #content>
<div class="contract-change-record-content-tooltip">
{{ formatContractChangeContent(row) }}
</div>
</template>
<span class="contract-change-record-content-cell">{{
formatContractChangeContent(row)
}}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column
prop="changeReason"
label="变更原因"
min-width="240"
align="center"
show-overflow-tooltip
/>
<el-table-column prop="statusName" label="状态" min-width="140" align="center" />
<el-table-column label="操作" width="150" align="center" fixed="right">
<template #default="{ row }">
<el-link type="primary" @click="openDetailChangeRecord(row)">详情</el-link>
<el-link type="primary" @click="openFlow(row)">流程</el-link>
</template>
</el-table-column>
</el-table>
</section>
</el-form>
<div class="contract-manage-page__footer">
<el-button @click="closeForm">关闭</el-button>
<template v-if="isAttachmentUploadMode">
<el-button
type="primary"
:loading="submitAction === 'attachment'"
:disabled="Boolean(submitAction)"
@click="submitAttachmentUpload"
>
保存
</el-button>
</template>
<template v-else-if="formMode === 'add' || isDraftEdit">
<el-button
type="primary"
plain
:loading="submitAction === 'draft'"
:disabled="Boolean(submitAction)"
@click="submitCreate('draft')"
>
保存
</el-button>
<el-button
type="primary"
plain
:loading="submitAction === 'temporary'"
:disabled="Boolean(submitAction)"
@click="submitCreate('temporary')"
>
提交临时合同
</el-button>
<el-button
type="primary"
:loading="submitAction === 'formal'"
:disabled="Boolean(submitAction)"
@click="submitCreate('formal')"
>
提交正式合同
</el-button>
</template>
<el-button v-else type="primary" :loading="submitAction === 'edit'" @click="submitEdit">
提交
</el-button>
</div>
</template>
<billing-plan-editor
ref="billingPlanEditor"
v-model="billingPlanBox"
:value="billingPlanForm"
:index="billingPlanIndex"
:settlement-currency="form.settlementCurrency"
@save="saveBillingPlan"
/>
<contract-attachment-section
ref="listAttachmentUploader"
dialog-only
title="用印材料"
description
attachment-type
use-upload-dialog
mark-approved-on-upload
:attachment-location-options="['用印材料', '附件(佐证材料等)']"
:rows="listUploadContractFileRows"
@update:rows="listUploadContractFileRows = $event"
@upload-to-location="handleListAttachmentUploadToLocation"
@upload-confirmed="handleListAttachmentUploadConfirmed"
/>
<el-dialog
v-model="attachmentDocumentPreviewVisible"
:title="attachmentPreviewFile.name || '附件预览'"
append-to-body
destroy-on-close
width="90%"
top="4vh"
>
<pdf-preview
v-if="
attachmentDocumentPreviewVisible &&
attachmentPreviewFile.url &&
attachmentPreviewFile.isPdf
"
:source="attachmentPreviewFile.url"
@error="handleAttachmentPreviewError"
/>
<open-file-viewer
v-else-if="attachmentDocumentPreviewVisible && attachmentPreviewFile.url"
:file="attachmentPreviewFile.url"
:file-name="attachmentPreviewFile.name"
:mime-type="attachmentPreviewFile.mimeType"
width="100%"
height="72vh"
fit="contain"
theme="auto"
locale="zh-CN"
:toolbar="attachmentViewerToolbar"
:plugins="attachmentViewerPlugins"
@unsupported="handleAttachmentPreviewUnsupported"
@error="handleAttachmentPreviewError"
/>
</el-dialog>
<el-image-viewer
v-if="attachmentImagePreviewVisible"
:url-list="attachmentImagePreviewUrls"
:initial-index="attachmentImagePreviewIndex"
@close="attachmentImagePreviewVisible = false"
/>
<change-record-detail-dialog
v-model="detailChangeRecordVisible"
:rows="detailChangeRecordDetailRows"
/>
<billing-plan-editor
v-model="detailBillingPlanBox"
:value="detailBillingPlanForm"
:index="detailBillingPlanIndex"
:settlement-currency="detailRow.settlementCurrency || form.settlementCurrency"
readonly
/>
<flow-design
v-if="website.design.designMode"
is-dialog
v-model:is-display="flowBox"
:process-instance-id="processInstanceId"
/>
<el-dialog v-else v-model="flowBox" title="流程图" append-to-body fullscreen>
<iframe class="contract-manage-page__flow" :src="flowUrl" />
<template #footer><el-button @click="flowBox = false">关闭</el-button></template>
</el-dialog>
</basic-container>
</template>
<script>
import { mapGetters } from 'vuex';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
import { exportBlob } from '@/api/common';
import * as api from '@/api/business/contract-manage';
import { createFromContract } from '@/api/business/contract-change';
import {
getRisk,
loadHcsImmediateEnabled,
repush as hcsRepush,
repushBatch as hcsRepushBatch,
} from '@/api/business/hcs';
import { isContractBlack, isRiskyTag, resolveRiskTagName } from '@/utils/risk-display';
import { Position } from '@element-plus/icons-vue';
import { getMkPublicDetail } from '@/api/mk-process';
import { getList as getProjectList } from '@/api/business/project-apply';
import { getList as getCustomerArchiveList } from '@/api/vehicle/customer-archive';
import { getDeptTree } from '@/api/system/dept';
import { getDictionary as getSystemDictionary } from '@/api/system/dict';
import { getDictionary as getBizDictionary } from '@/api/system/dictbiz';
import { config, option } from '@/option/business/contract-manage';
import { getToken, isAdministrator } from '@/utils/auth';
import { downloadFileByUrl, downloadXls } from '@/utils/util';
import { getMKApprovalUrl } from '@/api/system/business-process';
import { loadMkEnabled, submitMkApprovalFlow } from '@/utils/mk-approval';
import BillingPlanEditor from './components/billing-plan-editor.vue';
import ChangeRecordDetailDialog from '@/components/change-record-detail-dialog/main.vue';
import ContractAttachmentSection, {
attachmentName as sharedAttachmentName,
attachmentUrl as sharedAttachmentUrl,
isContractSealMaterialLocation,
isContractSupportingAttachmentLocation,
normalizeContractFileRows,
} from './components/contract-attachment-section.vue';
import HcsContractPanel from './components/hcs-contract-panel.vue';
import PdfPreview from '@/components/pdf-preview/main.vue';
import { ElImageViewer, ElCascader } from 'element-plus';
import { OpenFileViewer } from '@open-file-viewer/vue';
import { fallbackPlugin, imagePlugin, officePlugin, textPlugin } from '@open-file-viewer/core';
import '@open-file-viewer/core/style.css';
import pdfWorkerSrc from 'pdfjs-dist/build/pdf.worker.mjs?url';
const contractManageListRoute = '/business/contract-manage';
const contractManageFormRoute = '/business/contract-manage/form';
const contractManageDetailRoute = '/business/contract-manage/detail';
const contractManageListRefreshKey = 'contract-manage-list-refresh';
const clone = value => JSON.parse(JSON.stringify(value));
const normalizeOptionalInteger = (value, emptyValue = null) => {
if (value === undefined || value === null || value === '' || Number(value) < 0) {
return emptyValue;
}
const number = Number(value);
return Number.isFinite(number) ? Math.trunc(number) : emptyValue;
};
const normalizeOptionalAmount = (value, emptyValue = null) => {
if (
value === undefined ||
value === null ||
value === '' ||
Number(value) === -1 ||
String(value) === '-1'
) {
return emptyValue;
}
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? Number(number.toFixed(2)) : emptyValue;
};
const formatContractAmountInput = (value, emptyValue = null) => {
if (
value === undefined ||
value === null ||
value === '' ||
Number(value) === -1 ||
String(value) === '-1'
) {
return emptyValue;
}
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? number.toFixed(2) : emptyValue;
};
const normalizeProjectValue = value => {
if (value === undefined || value === null || value === '' || String(value) === '-1') return '';
return value;
};
const defaultSettlementRule = () => ({
autoGenerate: '',
billStartDate: '',
settlementType: '',
billCycleType: '',
billCutoffDay: '',
cycleDays: '',
customPeriods: [],
});
const normalizeCustomPeriods = (periods = []) =>
(periods || []).map(item => ({
startDay: Number(item?.startDay) > 0 ? Number(item.startDay) : '',
endDay: Number(item?.endDay) > 0 ? Number(item.endDay) : '',
}));
const defaultForm = () => ({
contractCategory: '',
contractNo: '',
contractName: '',
projectId: '',
projectName: '',
organizationId: '',
organizationName: '',
signType: '',
settlementMode: '',
partyA: '',
partyB: '',
handlerUserName: '',
signDate: '',
applyDate: '',
contractFormat: '',
legalSealFlag: null,
copyCount: '',
settlementCurrency: '',
invoiceCycle: '',
paymentDays: '',
contractAmount: null,
templateFlag: 0,
originalContractNo: '',
electronicSealFlag: 0,
startDate: '',
endDate: '',
temporaryStartDate: '',
temporaryEndDate: '',
effectiveType: '',
feeGenerationMode: 'system',
archiveStatus: '未归档',
mainContent: '',
remark: '',
});
// 复制新增:仅保留可编辑字段与明细,剔除主键、编号、审批与流程信息
const createCopiedDetail = (detail = {}) => {
const form = defaultForm();
Object.keys(form).forEach(prop => {
if (Object.prototype.hasOwnProperty.call(detail, prop)) form[prop] = detail[prop];
});
// 特批附件属于原合同的特批佐证,复制后须对新合同重新上传,否则会绕过高风险/黑名单校验
const stripSpecAttachments = value => {
const rows = parseArray(value).filter(item => item?.fileType !== '特批附件');
return rows.length ? JSON.stringify(rows) : '';
};
return {
...form,
contractNo: '',
applyDate: '',
archiveStatus: '未归档',
feeGenerationMode: detail.feeGenerationMode || '',
billingEnabled: detail.billingEnabled,
contractFileJson: stripSpecAttachments(detail.contractFileJson),
attachmentsJson: stripSpecAttachments(detail.attachmentsJson),
billingPlanJson: detail.billingPlanJson || '',
paymentRatioJson: detail.paymentRatioJson || '',
settlementRuleJson: detail.settlementRuleJson || '',
preSettlementConfigJson: detail.preSettlementConfigJson || '',
formalSettlementConfigJson: detail.formalSettlementConfigJson || '',
changeRecordJson: '',
};
};
const parseArray = value => {
if (Array.isArray(value)) return value;
if (!value) return [];
try {
const data = JSON.parse(value);
return Array.isArray(data) ? data : [];
} catch (error) {
return [];
}
};
const parseObject = value => {
if (value && typeof value === 'object' && !Array.isArray(value)) return value;
if (!value) return {};
try {
const data = JSON.parse(value);
return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
} catch (error) {
return {};
}
};
const extractRecords = res => {
const data = res?.data?.data || res?.data || {};
return Array.isArray(data) ? data : data.records || [];
};
const attachmentName = sharedAttachmentName;
const attachmentUrl = sharedAttachmentUrl;
const attachmentExtension = row => {
const source = String(attachmentName(row) || attachmentUrl(row)).split('?')[0];
const index = source.lastIndexOf('.');
return index > -1 ? source.slice(index + 1).toLowerCase() : '';
};
const isAttachmentImage = row =>
['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(attachmentExtension(row));
const isAttachmentPdf = row => {
const mimeType = String(row.mimeType || row.contentType || '').toLowerCase();
return mimeType.includes('application/pdf') || attachmentExtension(row) === 'pdf';
};
const attachmentViewerPlugins = [
imagePlugin(),
officePlugin({ pdf: { workerSrc: pdfWorkerSrc, useFetchData: true } }),
textPlugin(),
fallbackPlugin(),
];
export default {
name: 'ContractManage',
components: {
ContractAttachmentSection,
BillingPlanEditor,
ChangeRecordDetailDialog,
HcsContractPanel,
ElImageViewer,
OpenFileViewer,
PdfPreview,
Position,
},
data() {
return {
api,
config,
tableOption: this.buildTableOption(),
loading: true,
data: [],
query: {},
page: { currentPage: 1, pageSize: 10, pageSizes: [10, 20, 50, 100], total: 0 },
selectionList: [],
searchExpanded: false,
contractExpiryScope: '',
contractExpiryStats: { all: 0, within30: 0, within90: 0, over90: 0, expired: 0 },
expiryTagOptions: [
{ label: '全部', value: '', statKey: 'all' },
{ label: '30天内到期', value: 'within30', statKey: 'within30' },
{ label: '90天内到期', value: 'within90', statKey: 'within90' },
{ label: '90天以上到期', value: 'over90', statKey: 'over90' },
{ label: '已到期', value: 'expired', statKey: 'expired' },
],
form: defaultForm(),
contractNoManual: false,
formRules: {
contractNo: [
{
validator: (rule, value, callback) => {
const contractNo = String(value || '').trim();
if (this.formMode === 'edit' && !contractNo) {
return callback(new Error('请输入合同编号'));
}
if (contractNo.length > 100) {
return callback(new Error('合同编号不能超过100个字符'));
}
callback();
},
trigger: 'blur',
},
],
contractName: [{ required: true, message: '请输入合同名称', trigger: 'blur' }],
contractCategory: [{ required: true, message: '请选择合同类别', trigger: 'change' }],
signType: [{ required: true, message: '请选择签约类型', trigger: 'change' }],
settlementCurrency: [{ required: true, message: '请选择结算币种', trigger: 'change' }],
projectName: [{ required: true, message: '请选择所属项目', trigger: 'change' }],
organizationName: [{ required: true, message: '请选择合同负责部门', trigger: 'change' }],
partyA: [{ required: true, message: '请选择甲方', trigger: 'change' }],
partyB: [{ required: true, message: '请选择乙方', trigger: 'change' }],
signDate: [{ required: true, message: '请选择签订日期', trigger: 'change' }],
settlementMode: [{ required: true, message: '请选择结算方式', trigger: 'change' }],
contractFormat: [{ required: true, message: '请选择合同格式', trigger: 'change' }],
invoiceCycle: [
{
validator: (rule, value, callback) => {
if (value === '' || value === undefined || value === null) return callback();
if (!/^\d+$/.test(String(value)) || Number(value) < 1) {
return callback(new Error('开票周期只能输入正整数'));
}
callback();
},
trigger: 'blur',
},
],
startDate: [{ required: true, message: '请选择开始日期', trigger: 'change' }],
endDate: [{ required: true, message: '请选择结束日期', trigger: 'change' }],
contractAmount: [
{ required: true, message: '请输入合同金额', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (value === undefined || value === null || value === '') {
return callback(new Error('请输入合同金额'));
}
if (Number(value) < 0) {
return callback(new Error('合同金额不能小于0'));
}
callback();
},
trigger: 'blur',
},
],
mainContent: [{ max: 1000, message: '合同主要内容不能超过1000个字符', trigger: 'blur' }],
},
submitAction: '',
projectOptions: [],
projectLoading: false,
selectedProjectId: '',
settlementCurrencyOptions: [],
settlementModeDictOptions: [],
contractFormatDictOptions: [],
contractCategoryDictOptions: [],
contractPartyAOptions: [],
contractPartyBOptions: [],
// 合规客商风险(C101-1 展示用):甲方/乙方选择后即时查询
contractPartyCodeMap: {},
partyRisk: {},
contractPartyLoading: false,
contractPartyRequestId: 0,
organizationOptions: [],
organizationFlatOptions: [],
selectedOrganizationId: '',
organizationLoading: false,
contractFileRows: [],
attachmentRows: [],
listUploadContractFileRows: [],
listUploadAttachmentRows: [],
attachmentUploadFromList: false,
attachmentImagePreviewVisible: false,
attachmentImagePreviewUrls: [],
attachmentImagePreviewIndex: 0,
attachmentDocumentPreviewVisible: false,
attachmentPreviewFile: {},
attachmentViewerPlugins,
attachmentViewerToolbar: {
download: true,
fullscreen: true,
print: true,
rotate: true,
zoom: true,
},
billingPlanRows: [],
billingPlanBox: false,
billingPlanIndex: -1,
billingPlanForm: {},
settlementConfigTab: 'pre',
settlementRuleForm: defaultSettlementRule(),
preSettlementRuleForm: defaultSettlementRule(),
formalSettlementRuleForm: defaultSettlementRule(),
paymentRatioRows: [],
changeRecordRows: [],
settlementTypeOptions: ['现结', '日结', '周结', '月结', '按固定天数周期'],
billCycleTypeOptions: ['固定截单日', '自然月', '自定义多周期'],
detailLoading: false,
detailRow: {},
// 合规(HCS)推送/对账状态;受 thirdParty.hcs.push.immediate-enabled 控制显隐
hcsRepushingId: null,
hcsImmediateEnabled: false,
detailContractFileRows: [],
detailAttachmentRows: [],
detailBillingPlanRows: [],
detailBillingPlanBox: false,
detailBillingPlanIndex: -1,
detailBillingPlanForm: {},
detailSettlementConfigTab: 'pre',
detailPreSettlementRuleForm: defaultSettlementRule(),
detailFormalSettlementRuleForm: defaultSettlementRule(),
detailPaymentRatioRows: [],
detailChangeRecordRows: [],
detailChangeRecordVisible: false,
detailChangeRecord: null,
detailChangeRecordDetailRows: [],
flowBox: false,
flowUrl: '',
processInstanceId: '',
mkEnabled: false,
// 页面形态在实例创建时锁定,避免 keep-alive 缓存后随全局 $route 翻转
formPageLocked: false,
detailPageLocked: false,
};
},
computed: {
...mapGetters(['permission', 'userInfo']),
permissionList() {
return { addBtn: this.canCreate };
},
isAdmin() {
return isAdministrator(this.userInfo);
},
canCreate() {
return this.hasPermission(`${this.config.permission}_add`);
},
isListPage() {
return !this.formPageLocked && !this.detailPageLocked && !this.isPublicViewPage;
},
isFormPage() {
return this.formPageLocked;
},
isDetailPage() {
return this.detailPageLocked || this.isPublicViewPage;
},
isPublicViewPage() {
return this.$route.path === '/business/contract-manage/public-view';
},
formMode() {
return this.$route.query.mode === 'edit' ? 'edit' : 'add';
},
// 草稿/已撤回/已驳回编辑页:按钮与新增页一致(保存/提交临时/提交正式)
isDraftEdit() {
return (
this.formMode === 'edit' &&
['draft', 'withdrawn', 'rejected'].includes(this.form.approvalStatus)
);
},
isAttachmentUploadMode() {
return this.$route.query.attachmentUpload === '1';
},
contractAttachmentLocationOptions() {
if (this.isAttachmentUploadMode) return ['用印材料'];
return ['用印材料', '附件(佐证材料等)'];
},
contractAmountDisplay() {
const value = this.form.contractAmount;
if (
value === undefined ||
value === null ||
value === '' ||
Number(value) === -1 ||
String(value) === '-1'
) {
return '';
}
return value;
},
hasApprovedContractFiles() {
return (this.contractFileRows || []).some(
row =>
row?.approved === true ||
row?.approved === 1 ||
row?.approved === '1' ||
row?.approvalStatus === 'approved'
);
},
formPageTitle() {
if (this.isAttachmentUploadMode) {
return this.$route.query.name || '附件上传';
}
return this.$route.query.name || `${this.formMode === 'edit' ? '编辑' : '新增'}合同管理`;
},
detailPageTitle() {
return this.$route.query.name || '合同详情';
},
ids() {
return this.selectionList.map(item => item.id).join(',');
},
contractCategoryOptions() {
return this.contractCategoryDictOptions.length
? this.contractCategoryDictOptions
: this.columnOptions('contractCategory');
},
signTypeOptions() {
return this.columnOptions('signType');
},
effectiveTypeOptions() {
return this.columnOptions('effectiveType');
},
settlementModeOptions() {
return this.settlementModeDictOptions.length
? this.settlementModeDictOptions
: this.columnOptions('settlementMode');
},
contractFormatOptions() {
return this.contractFormatDictOptions.length
? this.contractFormatDictOptions
: this.columnOptions('contractFormat');
},
organizationCascaderProps() {
return {
label: 'label',
value: 'id',
children: 'children',
emitPath: true,
checkStrictly: true,
expandTrigger: 'click',
};
},
// 合同负责部门级联:selectedOrganizationId 存部门 id,级联控件用根到节点的 id 路径,
// 两者在此双向转换;依赖 organizationOptions,树加载完成后回显自动刷新
organizationCascaderValue: {
get() {
const id = this.selectedOrganizationId || this.form.organizationId;
if (!id) return [];
return this.findOrganizationPath(id) || [String(id)];
},
set(path) {
const value = Array.isArray(path)
? path.filter(item => item !== undefined && item !== null && item !== '')
: [];
this.selectedOrganizationId = value.length ? String(value.at(-1)) : '';
this.handleOrganizationChange(value);
},
},
settlementRuleEnabled() {
return Number(this.settlementRuleForm.autoGenerate) === 1;
},
showBillCycleType() {
return this.settlementRuleForm.settlementType === '月结';
},
showBillCutoffDay() {
return this.showBillCycleType && this.settlementRuleForm.billCycleType === '固定截单日';
},
showCustomPeriods() {
return this.showBillCycleType && this.settlementRuleForm.billCycleType === '自定义多周期';
},
showCycleDays() {
return this.isFixedCycleSettlementType(this.settlementRuleForm.settlementType);
},
billCutoffDayOptions() {
return Array.from({ length: 31 }, (_, index) => ({
label: `${index + 1}日`,
value: index + 1,
}));
},
cycleDayOptions() {
return Array.from({ length: 365 }, (_, index) => ({
label: `${index + 1}天`,
value: index + 1,
}));
},
detailContractPeriod() {
const startDate = this.detailRow.startDate || '-';
const endDate = this.detailRow.endDate || '-';
return `${startDate} 至 ${endDate}`;
},
// 高风险/黑名单客商签约提示(C101-1,签约前提示;强制拦截在后端提交校验与推送链路)
partyRiskText() {
const parts = [];
['partyA', 'partyB'].forEach(prop => {
const risk = this.partyRisk[prop];
if (!risk?.risky) return;
// riskTip 由后端按风险项拼装(【公司名称】:黑名单;涉诉;被执行人等),缺失时兜底通用提示
parts.push(
risk.riskTip ||
`${prop === 'partyA' ? '甲方' : '乙方'}[${this.form[prop] || ''}]为高风险/黑名单客商`
);
});
if (!parts.length) return '';
return `${parts.join(';')},按集团规定不可直接签约,提交前须上传「特批附件」类型附件,审批通过后将自动发起合同特批`;
},
// 详情风险客商提示(基本信息备注后展示):按推送留痕 hcs_risk_level/hcs_black_status 判定(甲乙取高/任一),文案与后端 riskTip 一致
detailRiskTipText() {
const riskLevel = String(this.detailRow.hcsRiskLevel || '').trim();
const blacklist = String(this.detailRow.kingdeeBlackStatus || '').toLowerCase() === 'yes';
if (riskLevel !== '2' && !blacklist) return '';
const parties = [this.detailRow.partyA, this.detailRow.partyB]
.map(name => String(name || '').trim())
.filter(Boolean)
.join('、');
return `【${parties || '相对方'}】:被列入外部黑名单;涉贪污受贿裁判相关提及方(3年以上及其他);被执行人信息;买卖合同纠纷;劳动纠纷;重大纠纷`;
},
detailLegalSealText() {
const value = this.detailRow.legalSealFlag;
if (value === undefined || value === null || value === '') return '-';
return Number(value) === 1 ? '是' : '否';
},
detailTemplateText() {
const value = this.detailRow.templateFlag;
if (value === undefined || value === null || value === '') return '-';
return Number(value) === 1 ? '是' : '否';
},
detailElectronicSealText() {
const value = this.detailRow.electronicSealFlag;
if (value === undefined || value === null || value === '') return '-';
return Number(value) === 1 ? '是' : '否';
},
detailFeeGenerationMode() {
const value =
this.detailRow.feeGenerationMode ||
(Number(this.detailRow.billingEnabled) === 0 ? 'manual' : 'system');
return value === 'manual' ? '账单导入生成' : '系统生成';
},
detailSettlementRule() {
return this.detailSettlementConfigTab === 'formal'
? this.detailFormalSettlementRuleForm
: this.detailPreSettlementRuleForm;
},
},
watch: {
$route(to, from) {
if (this.isListPage) {
if (
to.path === contractManageListRoute &&
(from?.path?.includes('/contract-manage/form') ||
from?.path?.includes('/contract-manage/detail') ||
from?.path?.includes('/contract-manage/change'))
) {
sessionStorage.removeItem(contractManageListRefreshKey);
this.onLoad(this.page, this.query);
}
return;
}
if (this.formPageLocked && to.path === contractManageFormRoute) this.initFormPage();
if (this.detailPageLocked && to.path === contractManageDetailRoute) this.initDetailPage();
},
settlementConfigTab(tab, oldTab) {
if (tab === oldTab) return;
if (oldTab === 'formal') this.formalSettlementRuleForm = { ...this.settlementRuleForm };
else this.preSettlementRuleForm = { ...this.settlementRuleForm };
this.settlementRuleForm = {
...(tab === 'formal' ? this.formalSettlementRuleForm : this.preSettlementRuleForm),
};
},
},
created() {
this.formPageLocked = this.$route.path === contractManageFormRoute;
this.detailPageLocked =
this.$route.path === contractManageDetailRoute || this.isPublicViewPage;
if (!this.isPublicViewPage) {
this.loadProjectOptions();
this.loadSettlementDictionaries();
this.loadOrganizationOptions();
loadMkEnabled()
.then(enabled => {
this.mkEnabled = !!enabled;
})
.catch(() => {
this.mkEnabled = false;
});
loadHcsImmediateEnabled()
.then(enabled => {
this.hcsImmediateEnabled = !!enabled;
})
.catch(() => {
this.hcsImmediateEnabled = false;
});
}
if (this.isFormPage) this.initFormPage();
if (this.isDetailPage) this.initDetailPage();
},
activated() {
if (!this.isListPage) return;
if (sessionStorage.getItem(contractManageListRefreshKey)) {
sessionStorage.removeItem(contractManageListRefreshKey);
this.onLoad(this.page, this.query);
}
},
methods: {
markContractManageListNeedRefresh() {
sessionStorage.setItem(contractManageListRefreshKey, '1');
},
buildTableOption() {
return {
...option,
addBtn: false,
viewBtn: false,
editBtn: false,
delBtn: false,
menuWidth: 320,
column: (option.column || []).map(column => ({ ...column, display: false })),
};
},
columnOptions(prop) {
return this.findColumn(this.tableOption.column, prop)?.dicData || [];
},
hasPermission(code) {
return this.isAdmin || this.validData(this.permission?.[code], false);
},
displayStatus(row, prop) {
const textProp = prop === this.config.statusProp ? this.config.statusTextProp : '';
if (textProp && row[textProp]) return row[textProp];
const item = this.columnOptions(prop).find(dic => String(dic.value) === String(row[prop]));
return item?.label || row[prop] || '未知';
},
statusTagType(status) {
if (['approved', 'change_approved'].includes(status)) return 'success';
if (['draft', 'withdrawn'].includes(status)) return 'info';
if (['rejected', 'change_rejected'].includes(status)) return 'danger';
return 'warning';
},
archiveStatusTagType(status) {
return status === '已归档' ? 'success' : 'info';
},
stageTagType(stage) {
if (stage === 'formal') return 'success';
if (stage === 'temporary') return 'warning';
if (stage === 'terminated') return 'danger';
return 'info';
},
// ==================== 合规(HCS)推送/对账(照“同步金蝶”按钮模式) ====================
hcsSyncLabel(value) {
return { unsynced: '待推送', synced: '已推送', failed: '推送失败' }[value] || value;
},
// ==================== 客商风险(推送链路按金蝶黑名单命中口径落库,展示复用公共方法) ====================
contractRiskTagName(row) {
return resolveRiskTagName(row.hcsRiskLevel, isContractBlack(row.kingdeeBlackStatus));
},
isContractRisky(row) {
return isRiskyTag(row.hcsRiskLevel, isContractBlack(row.kingdeeBlackStatus));
},
hcsSyncTagType(value) {
return { unsynced: 'info', synced: 'success', failed: 'danger' }[value] || 'info';
},
canHcsRepush(row) {
return (
this.hcsImmediateEnabled &&
['approved', 'change_approved'].includes(row.approvalStatus) &&
row.hcsSyncStatus !== 'synced'
);
},
handleHcsRepush(row) {
if (this.hcsRepushingId) return;
this.$confirm('确定推送该数据到合同合规系统?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
// 同步推送合规耗时较长,加遮罩防重复操作(照审批通过遮罩)
const loading = this.$loading({
lock: true,
text: '正在推送合规,请稍候…',
background: 'rgba(255, 255, 255, 0.6)',
});
this.hcsRepushingId = row.id;
hcsRepush('contract', row.id)
.then(res => {
if (res.data?.success === false) {
this.$message.error(res.data?.msg || '推送合规失败');
return;
}
this.$message.success(res.data?.data || '推送合规成功');
this.onLoad(this.page, this.query);
})
.catch(() => {
// 失败原因由 axios 拦截器统一弹出后端 msg,此处不再重复提示
})
.finally(() => {
this.hcsRepushingId = null;
loading.close();
});
});
},
handleHcsBatchRepush() {
if (this.hcsRepushingId) return;
if (!this.selectionList.length) {
this.$message.warning('请选择至少一条数据');
return;
}
const invalidCount = this.selectionList.filter(item => !this.canHcsRepush(item)).length;
if (invalidCount) {
this.$message.warning(`选中数据中有${invalidCount}条不符合推送条件(仅审批通过且未同步的可推送),请重新选择`);
return;
}
this.$confirm(`确定将选中的${this.selectionList.length}条数据推送到合同合规系统?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
// 同步推送合规耗时较长,加遮罩防重复操作(照审批通过遮罩)
const loading = this.$loading({
lock: true,
text: '正在批量推送合规,请稍候…',
background: 'rgba(255, 255, 255, 0.6)',
});
this.hcsRepushingId = 'batch';
hcsRepushBatch('contract', this.selectionList.map(item => item.id))
.then(res => {
if (res.data?.success === false) {
this.$message.error(res.data?.msg || '批量推送合规失败');
return;
}
this.$message.success(res.data?.data || '批量推送合规完成');
this.onLoad(this.page, this.query);
})
.catch(() => {
// 失败原因由 axios 拦截器统一弹出后端 msg,此处不再重复提示
})
.finally(() => {
this.hcsRepushingId = null;
loading.close();
});
});
},
canEdit(row) {
return (
this.hasPermission(`${this.config.permission}_edit`) &&
!row.readonly &&
(this.config.editStatus || []).includes(row.approvalStatus)
);
},
canDelete(row) {
return (
this.hasPermission(`${this.config.permission}_delete`) &&
!row.readonly &&
(this.config.deleteStatus || []).includes(row.approvalStatus) &&
(this.config.deleteStage || []).includes(row.contractStage)
);
},
canCopy(row) {
return this.hasPermission(`${this.config.permission}_copy`) && !row.readonly;
},
editLabel(row) {
return this.config.editLabelStatusMap?.[row.approvalStatus] || '编辑';
},
customOperations(row) {
return (this.config.operations || []).filter(operation => {
const permission = operation.permission || `${this.config.permission}_${operation.action}`;
const statusProp = operation.statusProp || this.config.statusProp || 'status';
return (
this.hasPermission(permission) &&
!row.readonly &&
(!operation.status?.length || operation.status.includes(row[statusProp])) &&
(!operation.stage?.length || operation.stage.includes(row.contractStage))
);
});
},
normalizeSearch(params = {}) {
const query = { ...params, allDept: this.isAdmin ? 1 : 0 };
if (query.organizationName) {
const id = Array.isArray(query.organizationName)
? query.organizationName.at(-1)
: query.organizationName;
const organization = this.findOrganization(id);
query.organizationId = id;
if (organization) query.organizationName = organization.rawLabel || organization.label;
}
return query;
},
onLoad(page, params = {}) {
this.loading = true;
const query = this.normalizeSearch({
...params,
...this.query,
...(this.contractExpiryScope ? { expireScope: this.contractExpiryScope } : {}),
});
this.api
.getList(page.currentPage, page.pageSize, query)
.then(res => {
const result = res.data?.data || {};
this.page.total = result.total || 0;
this.data = result.records || [];
this.selectionList = [];
this.refreshExpiryStats(query);
})
.finally(() => {
this.loading = false;
});
},
searchChange(params, done) {
this.query = { ...params };
this.page.currentPage = 1;
this.onLoad(this.page);
done();
},
searchReset() {
this.query = {};
this.contractExpiryScope = '';
this.page.currentPage = 1;
this.onLoad(this.page);
},
selectionChange(list) {
this.selectionList = list;
},
currentChange(currentPage) {
this.page.currentPage = currentPage;
},
sizeChange(pageSize) {
this.page.pageSize = pageSize;
},
refreshChange() {
this.onLoad(this.page, this.query);
},
changeExpiryScope(value) {
this.contractExpiryScope = value;
this.page.currentPage = 1;
this.onLoad(this.page, this.query);
},
refreshExpiryStats(query) {
const params = { ...query };
delete params.expireScope;
this.api.expireStats(params).then(res => {
this.contractExpiryStats = { ...this.contractExpiryStats, ...(res.data?.data || {}) };
});
},
openForm(mode, row = {}) {
this.$router.push({
path: contractManageFormRoute,
query:
mode === 'edit'
? { mode, id: row.id, name: '编辑合同管理' }
: { mode, name: '新增合同管理' },
});
},
initFormPage() {
this.resetFormState();
if (this.formMode === 'add') {
const copyId = this.$route.query.copyId;
if (copyId) {
this.api.getDetail(copyId).then(res => this.applyCopyDetail(res.data?.data || {}));
return;
}
this.loadContractPartyOptions();
return;
}
const id = this.$route.query.id;
if (!id) return;
this.api.getDetail(id).then(res => this.applyDetail(res.data?.data || {}));
},
resetFormState() {
this.contractNoManual = false;
this.form = {
...defaultForm(),
handlerUserName:
this.userInfo?.realName || this.userInfo?.userName || this.userInfo?.account || '',
};
this.selectedProjectId = '';
this.selectedOrganizationId = '';
this.contractPartyAOptions = [];
this.contractPartyBOptions = [];
this.contractFileRows = [];
this.attachmentRows = [];
this.billingPlanRows = [];
this.preSettlementRuleForm = defaultSettlementRule();
this.formalSettlementRuleForm = defaultSettlementRule();
this.settlementConfigTab = 'pre';
this.settlementRuleForm = this.preSettlementRuleForm;
this.paymentRatioRows = [];
this.changeRecordRows = [];
this.$nextTick(() => this.$refs.contractForm?.clearValidate());
},
applyDetail(detail) {
const projectId = normalizeProjectValue(detail.projectId);
const projectName = projectId ? normalizeProjectValue(detail.projectName) : '';
this.form = {
...defaultForm(),
...detail,
projectId,
projectName,
copyCount: normalizeOptionalInteger(detail.copyCount, ''),
invoiceCycle: normalizeOptionalInteger(detail.invoiceCycle, ''),
paymentDays: normalizeOptionalInteger(detail.paymentDays, ''),
contractAmount: formatContractAmountInput(detail.contractAmount, null),
archiveStatus: detail.archiveStatus || '未归档',
feeGenerationMode:
detail.feeGenerationMode || (Number(detail.billingEnabled) === 0 ? 'manual' : 'system'),
};
this.selectedProjectId = projectId;
this.selectedOrganizationId = detail.organizationId || '';
this.contractFileRows = normalizeContractFileRows(parseArray(detail.contractFileJson));
this.attachmentRows = parseArray(detail.attachmentsJson);
this.billingPlanRows = parseArray(detail.billingPlanJson);
this.paymentRatioRows = parseArray(detail.paymentRatioJson);
this.changeRecordRows = parseArray(detail.changeRecordJson);
const payload = parseObject(detail.settlementRuleJson);
const legacy = Object.keys(payload).some(
key => !['preSettlementConfig', 'formalSettlementConfig'].includes(key)
)
? payload
: {};
const preConfig = parseObject(detail.preSettlementConfigJson);
const formalConfig = parseObject(detail.formalSettlementConfigJson);
this.preSettlementRuleForm = this.normalizeSettlementRule(
payload.preSettlementConfig || (Object.keys(preConfig).length ? preConfig : legacy)
);
this.formalSettlementRuleForm = this.normalizeSettlementRule(
payload.formalSettlementConfig || (Object.keys(formalConfig).length ? formalConfig : legacy)
);
this.settlementConfigTab = 'pre';
this.settlementRuleForm = { ...this.preSettlementRuleForm };
this.syncCurrentProjectOption();
this.syncCurrentOrganizationOption();
this.loadContractPartyOptions();
},
applyCopyDetail(detail) {
this.applyDetail(createCopiedDetail(detail));
this.form.handlerUserName =
this.userInfo?.realName || this.userInfo?.userName || this.userInfo?.account || '';
this.refreshPreviewContractNo();
this.$nextTick(() => this.$refs.contractForm?.clearValidate());
},
closeForm() {
this.markContractManageListNeedRefresh();
this.$router.$avueRouter?.closeTag?.();
this.$router.push(contractManageListRoute);
},
validateContractNo() {
const contractNo = String(this.form.contractNo || '').trim();
if (this.formMode === 'edit' && !contractNo) {
this.$message.warning('请输入合同编号');
return false;
}
if (contractNo.length > 100) {
this.$message.warning('合同编号不能超过100个字符');
return false;
}
return true;
},
validateBase(action) {
if (!this.validateContractNo()) return false;
if (action === 'temporary') {
const required = [
['signType', '签约类型'],
['contractName', '合同名称'],
['contractCategory', '合同类型'],
['projectName', '所属项目'],
['organizationName', '合同负责部门'],
['partyA', '甲方'],
['partyB', '乙方'],
['contractAmount', '合同金额'],
];
const empty = required.find(([prop]) => {
const value = this.form[prop];
return value === undefined || value === null || String(value).trim() === '';
});
if (empty) {
this.$message.warning(
['contractName', 'contractAmount'].includes(empty[0])
? `请输入${empty[1]}`
: `请选择${empty[1]}`
);
return false;
}
if (Number(this.form.contractAmount) < 0) {
this.$message.warning('合同金额不能小于0');
return false;
}
return true;
}
if (!this.form.contractName?.trim()) {
this.$message.warning('请输入合同名称');
return false;
}
if (!this.form.contractCategory) {
this.$message.warning('请选择合同类别');
return false;
}
if (!this.form.settlementCurrency) {
this.$message.warning('请选择结算币种');
return false;
}
if (action === 'draft') return true;
if (['formal', 'edit'].includes(action) && !this.form.signType) {
this.$message.warning('请选择签约类型');
return false;
}
if (action !== 'formal' && action !== 'edit') return true;
const required = [
['projectName', '所属项目'],
['organizationName', '合同负责部门'],
['partyA', '甲方'],
['partyB', '乙方'],
['startDate', '合同开始日期'],
['endDate', '合同结束日期'],
['signDate', '签订日期'],
['settlementMode', '结算方式'],
['contractFormat', '合同格式'],
['contractAmount', '合同金额'],
];
const empty = required.find(([prop]) => {
const value = this.form[prop];
return value === undefined || value === null || String(value).trim() === '';
});
if (empty) {
this.$message.warning(
empty[0] === 'contractAmount' ? `请输入${empty[1]}` : `请选择或输入${empty[1]}`
);
return false;
}
if (Number(this.form.contractAmount) < 0) {
this.$message.warning('合同金额不能小于0');
return false;
}
return (
this.validateBillingPlanDefault() &&
this.validateSettlementRules() &&
this.validatePaymentRatios()
);
},
normalizeSubmit() {
this.syncSettlementConfig();
const projectId = normalizeProjectValue(this.form.projectId);
const projectName = projectId ? normalizeProjectValue(this.form.projectName) : '';
return {
...this.form,
contractNo: String(this.form.contractNo || '').trim(),
projectId,
projectName,
copyCount: normalizeOptionalInteger(this.form.copyCount),
invoiceCycle: normalizeOptionalInteger(this.form.invoiceCycle),
paymentDays: normalizeOptionalInteger(this.form.paymentDays),
contractAmount: normalizeOptionalAmount(this.form.contractAmount),
billingEnabled: this.form.feeGenerationMode === 'manual' ? 0 : 1,
contractFileJson: JSON.stringify(this.contractFileRows),
attachmentsJson: JSON.stringify(this.attachmentRows),
billingPlanJson: JSON.stringify(this.billingPlanRows),
settlementRuleJson: JSON.stringify({
preSettlementConfig: this.normalizeSettlementRule(this.preSettlementRuleForm),
formalSettlementConfig: this.normalizeSettlementRule(this.formalSettlementRuleForm),
}),
preSettlementConfigJson: JSON.stringify(
this.normalizeSettlementRule(this.preSettlementRuleForm)
),
formalSettlementConfigJson: JSON.stringify(
this.normalizeSettlementRule(this.formalSettlementRuleForm)
),
paymentRatioJson: JSON.stringify(this.paymentRatioRows),
changeRecordJson: JSON.stringify(this.changeRecordRows),
};
},
submitCreate(action) {
if (this.submitAction || !this.validateBase(action)) return;
this.submitAction = action;
const previousApprovalStatus = this.form.approvalStatus || '';
const submit = {
...this.normalizeSubmit(),
contractStage: action === 'formal' ? 'temporary' : 'draft',
approvalStatus: 'draft',
};
this.api
.saveDraft(submit)
.then(res => {
const saved = res.data?.data || {};
const id = saved.id || this.form.id;
if (action === 'temporary' || action === 'formal') {
if (!id) throw new Error('合同保存成功但未返回主键,无法提交合同');
return (action === 'temporary'
? this.api.toTemporary(id)
: this.api.submitFormal(id)
).then(() =>
submitMkApprovalFlow({
bizType: 'contract-manage',
bizId: id,
subjectName: saved.contractName || submit.contractName || '',
approvalStatus:
previousApprovalStatus || saved.approvalStatus || submit.approvalStatus || '',
mkEnabled: this.mkEnabled,
})
);
}
return null;
})
.then(() => {
const message = {
draft: '保存成功',
temporary: '临时合同提交成功',
formal: '正式合同提交成功',
}[action];
this.$message.success(message);
this.closeForm();
})
.finally(() => {
this.submitAction = '';
});
},
submitEdit() {
if (this.submitAction || !this.validateBase('edit')) return;
this.submitAction = 'edit';
const isChangeRejected = this.form.approvalStatus === 'change_rejected';
const payload = this.normalizeSubmit();
const request = isChangeRejected
? this.api.submitChange(payload)
: this.api.submit(payload);
request
.then(() => {
if (!isChangeRejected) {
this.$message.success('修改成功');
this.closeForm();
return;
}
return submitMkApprovalFlow({
bizType: 'contract-manage',
bizId: payload.id || this.form.id,
subjectName: payload.contractName || this.form.contractName || '',
approvalStatus: this.form.approvalStatus || 'change_rejected',
mkEnabled: this.mkEnabled,
}).then(() => {
this.$message.success('已重新提交变更审批');
this.closeForm();
});
})
.finally(() => {
this.submitAction = '';
});
},
submitAttachmentUpload() {
if (this.submitAction) return;
if (!this.form.id) {
this.$message.warning('合同不存在,无法上传附件');
return;
}
this.submitAction = 'attachment';
const contractFileRows = this.attachmentUploadFromList
? this.listUploadContractFileRows
: this.contractFileRows;
const attachmentRows = this.attachmentUploadFromList
? this.listUploadAttachmentRows
: this.attachmentRows;
return this.api
.updateAttachments({
id: this.form.id,
contractFileJson: JSON.stringify(contractFileRows),
attachmentsJson: JSON.stringify(attachmentRows),
})
.then(() => {
this.$message.success('附件保存成功');
if (this.attachmentUploadFromList) {
this.attachmentUploadFromList = false;
this.listUploadContractFileRows = [];
this.listUploadAttachmentRows = [];
this.onLoad(this.page, this.query);
return;
}
this.closeForm();
})
.finally(() => {
this.submitAction = '';
});
},
openAttachmentUploadDialog(row = {}) {
if (!row?.id) {
this.$message.warning('合同不存在,无法上传附件');
return;
}
const loading = this.$loading({
lock: true,
text: '加载中',
background: 'rgba(255, 255, 255, 0.6)',
});
this.attachmentUploadFromList = true;
this.listUploadContractFileRows = [];
this.listUploadAttachmentRows = [];
this.form = { ...defaultForm(), id: row.id };
this.api
.getDetail(row.id)
.then(res => {
const detail = res.data?.data || {};
this.form = {
...defaultForm(),
...detail,
id: detail.id || row.id,
};
this.listUploadContractFileRows = normalizeContractFileRows(
parseArray(detail.contractFileJson)
);
this.listUploadAttachmentRows = parseArray(detail.attachmentsJson);
this.$nextTick(() => {
this.$refs.listAttachmentUploader?.openUploadDialog?.();
});
})
.catch(() => {
this.attachmentUploadFromList = false;
this.$message.error('合同详情加载失败');
})
.finally(() => {
loading.close();
});
},
handleListAttachmentUploadConfirmed() {
if (!this.attachmentUploadFromList) return;
this.submitAttachmentUpload();
},
handleListAttachmentUploadToLocation({ location, rows = [] }) {
if (!rows.length) return;
if (isContractSealMaterialLocation(location)) {
this.listUploadContractFileRows = [...(this.listUploadContractFileRows || []), ...rows];
return;
}
if (isContractSupportingAttachmentLocation(location)) {
this.listUploadAttachmentRows = [...(this.listUploadAttachmentRows || []), ...rows];
}
},
loadProjectOptions() {
if (this.projectLoading) return;
this.projectLoading = true;
getProjectList(1, 9999, this.config.projectQueryParams || {})
.then(res => {
this.projectOptions = extractRecords(res);
this.syncCurrentProjectOption();
})
.finally(() => {
this.projectLoading = false;
});
},
loadSettlementDictionaries() {
Promise.all([
getSystemDictionary({ code: 'currency_type' }),
getBizDictionary({ code: 'settle_method' }),
getBizDictionary({ code: 'contractFormat' }),
getBizDictionary({ code: 'contractCategory' }),
]).then(([currencyRes, methodRes, formatRes, categoryRes]) => {
const records = response => {
const data = response?.data?.data || response?.data || [];
return Array.isArray(data) ? data : data.records || [];
};
this.settlementCurrencyOptions = records(currencyRes).map(item => ({
label: item.dictValue,
value: item.dictKey,
}));
this.settlementModeDictOptions = records(methodRes).map(item => ({
label: item.dictValue,
value: item.dictKey,
}));
this.contractFormatDictOptions = records(formatRes).map(item => ({
label: item.dictValue,
value: item.dictKey,
}));
this.contractCategoryDictOptions = records(categoryRes).map(item => ({
label: item.dictValue,
value: item.dictKey,
}));
['settlementMode'].forEach(prop => {
const column = this.findColumn(this.tableOption.column, prop);
if (column) column.dicData = this.settlementModeDictOptions;
});
const formatColumn = this.findColumn(this.tableOption.column, 'contractFormat');
if (formatColumn && this.contractFormatDictOptions.length) {
formatColumn.dicData = this.contractFormatDictOptions;
}
const categoryColumn = this.findColumn(this.tableOption.column, 'contractCategory');
if (categoryColumn && this.contractCategoryDictOptions.length) {
categoryColumn.dicData = this.contractCategoryDictOptions;
}
});
},
syncCurrentProjectOption() {
if (
!this.form.projectId ||
!this.form.projectName ||
String(this.form.projectId) === '-1' ||
String(this.form.projectName) === '-1'
) {
return;
}
if (this.projectOptions.some(item => String(item.id) === String(this.form.projectId))) return;
this.projectOptions.unshift({ id: this.form.projectId, projectName: this.form.projectName });
},
handleProjectChange(projectId) {
const project = this.projectOptions.find(item => String(item.id) === String(projectId));
if (!project) {
Object.assign(this.form, { projectId: '', projectName: '', settlementMode: '' });
return;
}
Object.assign(this.form, {
projectId: project.id,
projectName: project.projectName || '',
settlementMode: project.settlementMode || this.form.settlementMode || '',
});
},
loadContractPartyOptions() {
if (this.contractPartyLoading) return;
const requestId = ++this.contractPartyRequestId;
this.contractPartyLoading = true;
const queryParams = { status: 1, approvalStatus: 'approved' };
Promise.all([
getCustomerArchiveList(1, 9999, queryParams),
getCustomerArchiveList(1, 9999, { ...queryParams, accessType: 'temporary' }),
])
.then(responses => {
if (requestId !== this.contractPartyRequestId) return;
const records = responses.flatMap(extractRecords);
// 名称→客商编码映射,供 C101-1 风险查询使用(§9-U6 编码体系联调确认)
const codeMap = {};
records.forEach(item => {
const code = item.customerCode;
if (!code) return;
[item.fullName, item.shortName, item.customerName]
.map(name => String(name || '').trim())
.filter(Boolean)
.forEach(name => {
codeMap[name] = code;
});
});
this.contractPartyCodeMap = codeMap;
const names = records
.map(item => item.fullName || item.shortName || item.customerName || item.customerCode)
.map(name => String(name || '').trim())
.filter(Boolean);
[this.form.partyA, this.form.partyB].filter(Boolean).forEach(name => names.unshift(name));
const options = [...new Set(names)].map(name => ({ label: name, value: name }));
this.contractPartyAOptions = options;
this.contractPartyBOptions = options;
// 复制/编辑回填的甲乙方不会触发下拉 change,加载选项后主动查一次 C101-1 风险
['partyA', 'partyB'].forEach(prop => {
if (this.form[prop]) this.queryPartyRisk(prop);
});
})
.finally(() => {
if (requestId === this.contractPartyRequestId) this.contractPartyLoading = false;
});
},
// C101-1 客商风险查询(展示用,强校验在后端提交/推送链路):失败静默不阻塞填写
queryPartyRisk(prop) {
const name = this.form[prop];
const code = name ? this.contractPartyCodeMap[name] : null;
if (!code) {
this.partyRisk = { ...this.partyRisk, [prop]: null };
return;
}
getRisk(code)
.then(res => {
const risk = res?.data?.data ?? res?.data ?? {};
this.partyRisk = {
...this.partyRisk,
[prop]: {
customerKind: risk.customerKind || '',
// risky 由后端统一判定:外部客商高风险(等级=2)或黑名单;内部组织客商默认无风险恒为 false
risky: risk.risky === true,
riskTip: risk.riskTip || '',
},
};
})
.catch(() => {
this.partyRisk = { ...this.partyRisk, [prop]: null };
});
},
loadOrganizationOptions() {
if (this.organizationLoading) return;
this.organizationLoading = true;
getDeptTree(this.userInfo?.tenantId)
.then(res => {
const organizationTree = this.excludeExternalOrganization(res.data?.data || []);
this.organizationOptions = this.normalizeOrganizationTree(organizationTree);
this.organizationFlatOptions = this.flattenOrganizationTree(this.organizationOptions);
const column = this.findColumn(this.tableOption.column, 'organizationName');
if (column) {
column.dicData = this.organizationOptions;
column.renderSearch = scope => this.renderOrganizationSearch(scope);
}
this.syncCurrentOrganizationOption();
if (this.isFormPage && this.formMode === 'add' && this.form.organizationId) {
this.refreshPreviewContractNo();
}
})
.finally(() => {
this.organizationLoading = false;
});
},
excludeExternalOrganization(tree = []) {
return tree.reduce((result, item) => {
const organizationName = item.title || item.deptName || item.name || item.label || '';
if (String(organizationName).trim() === '外部组织') return result;
result.push({
...item,
children: this.excludeExternalOrganization(item.children || []),
});
return result;
}, []);
},
normalizeOrganizationTree(tree) {
return (tree || []).map(item => {
const label = item.title || item.deptName || item.name || item.label || '';
const children = this.normalizeOrganizationTree(item.children || []);
return {
...item,
id: String(item.id),
label,
rawLabel: label,
children: children.length ? children : undefined,
};
});
},
flattenOrganizationTree(tree) {
return (tree || []).flatMap(item => [
{ id: String(item.id), label: item.label, rawLabel: item.rawLabel },
...this.flattenOrganizationTree(item.children || []),
]);
},
findOrganization(id) {
const value = Array.isArray(id) ? id.at(-1) : id;
return this.organizationFlatOptions.find(item => String(item.id) === String(value));
},
// 由部门 id 反查「根 → 该节点」的 id 路径,供级联回显(找不到返回 null)
findOrganizationPath(id, tree = this.organizationOptions, parents = []) {
const target = Array.isArray(id) ? id.at(-1) : id;
if (target === undefined || target === null || target === '') return null;
for (const node of tree || []) {
const path = [...parents, String(node.id)];
if (String(node.id) === String(target)) return path;
const matched = this.findOrganizationPath(target, node.children, path);
if (matched) return matched;
}
return null;
},
handleOrganizationChange(id) {
const organization = this.findOrganization(id);
Object.assign(
this.form,
organization
? {
organizationId: organization.id,
organizationName: organization.rawLabel || organization.label,
}
: { organizationId: '', organizationName: '' }
);
if (this.formMode === 'add') {
this.refreshPreviewContractNo();
}
},
handleContractNoInput(value) {
this.contractNoManual = String(value || '').trim() !== '';
},
refreshPreviewContractNo() {
if (this.formMode !== 'add') return;
const organizationId = this.form.organizationId;
if (!organizationId) {
if (!this.contractNoManual) this.form.contractNo = '';
return;
}
const requestId = (this.contractNoPreviewRequestId =
(this.contractNoPreviewRequestId || 0) + 1);
api
.nextContractNo(organizationId)
.then(res => {
if (requestId !== this.contractNoPreviewRequestId || this.contractNoManual) return;
this.form.contractNo = res.data?.data || '';
})
.catch(() => {
if (requestId !== this.contractNoPreviewRequestId || this.contractNoManual) return;
this.form.contractNo = '';
});
},
syncCurrentOrganizationOption() {
if (!this.form.organizationId || !this.form.organizationName) return;
if (this.findOrganization(this.form.organizationId)) return;
const item = {
id: String(this.form.organizationId),
label: this.form.organizationName,
rawLabel: this.form.organizationName,
};
this.organizationOptions.unshift(item);
this.organizationFlatOptions.unshift(item);
},
renderOrganizationSearch(scope) {
// 搜索区合同负责部门:单选级联(与新增/编辑表单一致)
// 级联返回 id 路径数组,提交时由 normalizeSearch 取末级 id 转组织名称
return h(ElCascader, {
modelValue: Array.isArray(scope.row?.organizationName) ? scope.row.organizationName : [],
'onUpdate:modelValue': value => {
// 级联单选:modelValue 必须是 id 路径数组,否则控件内部状态会错乱导致无法再次选中
if (scope.row) scope.row.organizationName = Array.isArray(value) ? value : [];
},
options: this.organizationOptions,
props: {
label: 'label',
value: 'id',
children: 'children',
checkStrictly: true,
expandTrigger: 'click',
},
filterable: true,
clearable: true,
style: 'width: 100%',
placeholder: '请选择 合同负责部门',
});
},
integerInput(prop, value) {
this.form[prop] = String(value || '').replace(/\D/g, '');
},
handleContractAmountInput(value) {
const text = String(value || '').replace(/[^\d.]/g, '');
const parts = text.split('.');
const normalized =
parts.length > 1 ? `${parts[0]}.${parts.slice(1).join('').slice(0, 2)}` : parts[0];
this.form.contractAmount = normalized === '' ? null : normalized;
},
formatContractAmountField() {
this.form.contractAmount = formatContractAmountInput(this.form.contractAmount, null);
},
positiveIntegerInput(prop, value) {
const normalized = String(value || '').replace(/\D/g, '').replace(/^0+/, '');
this.form[prop] = normalized;
},
previewAttachment(row, rows = []) {
const url = attachmentUrl(row);
if (!url) {
this.$message.warning('附件地址为空,无法预览');
return;
}
if (isAttachmentImage(row)) {
this.attachmentImagePreviewUrls = (rows || [])
.filter(item => isAttachmentImage(item) && attachmentUrl(item))
.map(item => attachmentUrl(item));
this.attachmentImagePreviewIndex = Math.max(
this.attachmentImagePreviewUrls.indexOf(url),
0
);
this.attachmentImagePreviewVisible = true;
return;
}
const isPdf = isAttachmentPdf(row);
this.attachmentPreviewFile = {
name: attachmentName(row),
url,
mimeType: isPdf ? 'application/pdf' : row.mimeType || row.contentType || '',
isPdf,
};
this.attachmentDocumentPreviewVisible = true;
},
handleAttachmentUploadToLocation({ location, rows = [] }) {
if (!rows.length) return;
if (isContractSealMaterialLocation(location)) {
this.contractFileRows = [...(this.contractFileRows || []), ...rows];
return;
}
if (isContractSupportingAttachmentLocation(location)) {
this.attachmentRows = [...(this.attachmentRows || []), ...rows];
}
},
handleAttachmentPreviewUnsupported() {
this.$message.warning('当前文件暂不支持在线预览');
},
handleAttachmentPreviewError() {
this.$message.error('附件预览失败');
},
openBillingPlan(row = {}, index = -1) {
this.billingPlanIndex = index;
this.billingPlanForm =
index < 0
? { planName: '', transportMode: '', defaultPlan: false, remark: '', rules: [] }
: clone(row);
this.billingPlanBox = true;
this.$nextTick(() => this.$refs.billingPlanEditor?.ensureTransportModes?.());
},
saveBillingPlan(plan, index) {
const nextPlan = clone(plan);
if (this.isDefaultBillingPlan(nextPlan)) {
this.billingPlanRows.forEach((item, itemIndex) => {
if (itemIndex !== index && item.transportMode === nextPlan.transportMode) {
item.defaultPlan = false;
}
});
}
if (index < 0) this.billingPlanRows.push(nextPlan);
else this.billingPlanRows.splice(index, 1, nextPlan);
this.billingPlanBox = false;
},
removeBillingPlan(index) {
this.$confirm('确定删除该计费方案?', '提示', { type: 'warning' }).then(() => {
this.billingPlanRows.splice(index, 1);
});
},
isDefaultBillingPlan(plan) {
return (
plan?.defaultPlan === true ||
plan?.defaultPlan === 1 ||
['true', '1'].includes(String(plan?.defaultPlan).toLowerCase())
);
},
validateBillingPlanDefault() {
if (!this.billingPlanRows.length) return true;
const defaultCountByTransportMode = this.billingPlanRows.reduce((result, plan) => {
const transportMode = plan.transportMode || '';
result[transportMode] =
(result[transportMode] || 0) + Number(this.isDefaultBillingPlan(plan));
return result;
}, {});
const invalidTransportMode = Object.entries(defaultCountByTransportMode).find(
([, count]) => count > 1
);
if (invalidTransportMode) {
const [transportMode, count] = invalidTransportMode;
this.$message.warning(`运输方式“${transportMode}”仅支持配置一个默认计费方案`);
return false;
}
return true;
},
isFixedCycleSettlementType(value) {
return value === '按固定天数周期' || value === '固定天数周期结算';
},
normalizeSettlementRule(rule = {}) {
const next = { ...defaultSettlementRule(), ...rule };
if (next.settlementType === '固定天数周期结算') next.settlementType = '按固定天数周期';
if (next.settlementType !== '月结') {
next.billCycleType = '';
next.billCutoffDay = '';
next.customPeriods = [];
} else if (next.billCycleType !== '固定截单日') {
next.billCutoffDay = '';
}
if (next.settlementType === '月结' && next.billCycleType === '自定义多周期') {
next.customPeriods = normalizeCustomPeriods(
Array.isArray(next.customPeriods) ? next.customPeriods : []
);
} else {
next.customPeriods = [];
}
if (!this.isFixedCycleSettlementType(next.settlementType)) next.cycleDays = '';
return next;
},
syncSettlementConfig() {
if (this.settlementConfigTab === 'formal')
this.formalSettlementRuleForm = { ...this.settlementRuleForm };
else this.preSettlementRuleForm = { ...this.settlementRuleForm };
},
handleSettlementTypeChange(value) {
if (value === '月结') {
if (this.settlementRuleForm.billCycleType === '自定义多周期') {
this.settlementRuleForm.customPeriods = normalizeCustomPeriods(
this.settlementRuleForm.customPeriods || []
);
} else {
this.settlementRuleForm.customPeriods = [];
}
this.settlementRuleForm.cycleDays = '';
return;
}
this.settlementRuleForm.billCycleType = '';
this.settlementRuleForm.billCutoffDay = '';
this.settlementRuleForm.customPeriods = [];
if (!this.isFixedCycleSettlementType(value)) this.settlementRuleForm.cycleDays = '';
},
handleBillCycleTypeChange(value) {
if (value !== '固定截单日') this.settlementRuleForm.billCutoffDay = '';
if (value === '自定义多周期') {
this.settlementRuleForm.customPeriods = normalizeCustomPeriods(
this.settlementRuleForm.customPeriods || []
);
return;
}
this.settlementRuleForm.customPeriods = [];
},
customPeriodEndDayOptions(row) {
const startDay = Number(row?.startDay);
if (!Number.isFinite(startDay) || startDay < 1) return this.billCutoffDayOptions;
return Array.from({ length: 31 - startDay + 1 }, (_, index) => {
const value = startDay + index;
return { label: `${value}日`, value };
});
},
handleCustomPeriodStartDayChange(index, value) {
const periods = normalizeCustomPeriods(this.settlementRuleForm.customPeriods || []);
const row = periods[index];
if (!row) return;
row.startDay = value || '';
const startDay = Number(row.startDay);
const endDay = Number(row.endDay);
if (
Number.isFinite(startDay) &&
Number.isFinite(endDay) &&
(endDay < startDay || endDay > 31)
) {
row.endDay = '';
}
this.settlementRuleForm.customPeriods = periods;
},
handleCustomPeriodEndDayChange(index, value) {
const periods = normalizeCustomPeriods(this.settlementRuleForm.customPeriods || []);
const row = periods[index];
if (!row) return;
row.endDay = value || '';
this.settlementRuleForm.customPeriods = periods;
},
addCustomPeriodRow() {
const periods = normalizeCustomPeriods(this.settlementRuleForm.customPeriods || []);
periods.push({ startDay: '', endDay: '' });
this.settlementRuleForm.customPeriods = periods;
},
removeCustomPeriodRow(index) {
if (index <= 0) return;
const periods = [...(this.settlementRuleForm.customPeriods || [])];
periods.splice(index, 1);
this.settlementRuleForm.customPeriods = normalizeCustomPeriods(periods);
},
validateCustomPeriods(periods = [], label) {
const rows = normalizeCustomPeriods(periods);
if (!rows.length) {
this.$message.warning(`${label}:请至少配置一段自定义周期`);
return false;
}
for (let index = 0; index < rows.length; index += 1) {
const row = rows[index];
const startDay = Number(row.startDay);
const endDay = Number(row.endDay);
if (!Number.isFinite(startDay) || startDay < 1 || startDay > 31) {
this.$message.warning(`${label}:请选择第${index + 1}行运单区间开始日`);
return false;
}
if (!Number.isFinite(endDay) || endDay < 1 || endDay > 31) {
this.$message.warning(`${label}:请选择第${index + 1}行运单区间结束日`);
return false;
}
if (endDay < startDay) {
this.$message.warning(`${label}:第${index + 1}行结束日不能早于开始日`);
return false;
}
if (index > 0) {
const previousEnd = Number(rows[index - 1].endDay);
if (startDay !== previousEnd + 1) {
this.$message.warning(
`${label}:自定义多周期区间必须连续,不允许重叠或存在日期缺口`
);
return false;
}
}
}
return true;
},
validateSettlementRule(rule, label) {
if (Number(rule.autoGenerate) !== 1) return true;
if (!rule.billStartDate || !rule.settlementType) {
this.$message.warning(`${label}:请完整填写账单起始日和结算类型`);
return false;
}
if (rule.settlementType === '月结' && !rule.billCycleType) {
this.$message.warning(`${label}:请选择结算周期`);
return false;
}
if (
rule.settlementType === '月结' &&
rule.billCycleType === '固定截单日' &&
!rule.billCutoffDay
) {
this.$message.warning(`${label}:请选择账单截单日`);
return false;
}
if (rule.settlementType === '月结' && rule.billCycleType === '自定义多周期') {
return this.validateCustomPeriods(rule.customPeriods, label);
}
if (this.isFixedCycleSettlementType(rule.settlementType) && !rule.cycleDays) {
this.$message.warning(`${label}:请选择周期天数`);
return false;
}
return true;
},
validateSettlementRules() {
this.syncSettlementConfig();
return (
this.validateSettlementRule(this.preSettlementRuleForm, '预结算配置') &&
this.validateSettlementRule(this.formalSettlementRuleForm, '正式结算配置')
);
},
addPaymentRatio() {
this.paymentRatioRows.push({
paymentTerm: `第${this.paymentRatioRows.length + 1}笔`,
ratioLimit: '',
remark: '',
});
},
validatePaymentRatios() {
const total = this.paymentRatioRows.reduce(
(sum, row) => sum + Number(row.ratioLimit || 0),
0
);
if (this.paymentRatioRows.length && Math.abs(total - 100) > 0.0001) {
this.$message.warning('付款比例上限合计必须等于100%');
return false;
}
return true;
},
ratioInput(row, value) {
let nextValue = String(value ?? '').replace(/[^\d.]/g, '');
const parts = nextValue.split('.');
if (parts.length > 2) nextValue = `${parts.shift()}.${parts.join('')}`;
const [integerPart, decimalPart] = nextValue.split('.');
nextValue =
decimalPart === undefined ? integerPart : `${integerPart}.${decimalPart.slice(0, 2)}`;
if (Number(nextValue) > 100) nextValue = '100';
row.ratioLimit = nextValue;
},
openDetail(row) {
this.$router.push({
path: '/business/contract-manage/detail',
query: { id: row.id, name: '合同详情' },
});
},
initDetailPage() {
const id = this.$route.query.id;
if (!id) return;
this.detailLoading = true;
this.applyDetailState({});
const request = this.isPublicViewPage
? getMkPublicDetail('contract-manage', id)
: this.api.getDetail(id);
request
.then(res => {
this.applyDetailState(res.data?.data || {});
})
.finally(() => {
this.detailLoading = false;
});
},
closeDetail() {
this.markContractManageListNeedRefresh();
this.$router.$avueRouter?.closeTag?.();
this.$router.push(contractManageListRoute);
},
applyDetailState(detail = {}) {
this.detailRow = {
...detail,
copyCount: normalizeOptionalInteger(detail.copyCount),
invoiceCycle: normalizeOptionalInteger(detail.invoiceCycle),
paymentDays: normalizeOptionalInteger(detail.paymentDays),
};
this.detailContractFileRows = normalizeContractFileRows(parseArray(detail.contractFileJson));
this.detailAttachmentRows = parseArray(detail.attachmentsJson);
this.detailBillingPlanRows = parseArray(detail.billingPlanJson);
this.detailPaymentRatioRows = parseArray(detail.paymentRatioJson);
this.detailChangeRecordRows = parseArray(detail.changeRecordJson);
const payload = parseObject(detail.settlementRuleJson);
const legacy = Object.keys(payload).some(
key => !['preSettlementConfig', 'formalSettlementConfig'].includes(key)
)
? payload
: {};
const preConfig = parseObject(detail.preSettlementConfigJson);
const formalConfig = parseObject(detail.formalSettlementConfigJson);
this.detailPreSettlementRuleForm = this.normalizeSettlementRule(
payload.preSettlementConfig || (Object.keys(preConfig).length ? preConfig : legacy)
);
this.detailFormalSettlementRuleForm = this.normalizeSettlementRule(
payload.formalSettlementConfig || (Object.keys(formalConfig).length ? formalConfig : legacy)
);
this.detailSettlementConfigTab = 'pre';
},
openDetailBillingPlan(row, index) {
this.detailBillingPlanForm = clone(row);
this.detailBillingPlanIndex = index;
this.detailBillingPlanBox = true;
},
parseChangeRecordData(value) {
if (!value) return {};
if (typeof value === 'object') return value;
try {
const data = JSON.parse(value);
return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
} catch (error) {
return {};
}
},
normalizeChangeRecordValue(value) {
if (typeof value !== 'string') return value;
const text = value.trim();
if (!text || (!text.startsWith('[') && !text.startsWith('{') && text !== 'null')) {
return value;
}
try {
return JSON.parse(text);
} catch (error) {
return value;
}
},
isEmptyChangeRecordValue(value) {
const normalized = this.normalizeChangeRecordValue(value);
if (normalized === undefined || normalized === null || normalized === '') return true;
if (Array.isArray(normalized)) return normalized.length === 0;
if (typeof normalized === 'object') return Object.keys(normalized).length === 0;
return false;
},
getContractChangeFieldLabel(field) {
const labels = {
contractNo: '合同编号',
contractName: '合同名称',
partyB: '乙方',
startDate: '开始日期',
endDate: '结束日期',
contractFormat: '合同格式',
settlementMode: '结算方式',
legalSealFlag: '是否需要加盖法人章',
copyCount: '一式(份)',
settlementCurrency: '结算币种',
invoiceCycle: '开票周期',
paymentDays: '回款账期',
contractAmount: '合同金额',
templateFlag: '是否范本',
originalContractNo: '原件合同编号',
electronicSealFlag: '是否电子章',
mainContent: '合同主要内容',
remark: '其他事项说明',
feeGenerationMode: '费用生成模式',
billingPlanJson: '计费方案',
settlementRuleJson: '结算单规则',
preSettlementConfigJson: '预结算配置',
formalSettlementConfigJson: '正式结算配置',
paymentRatioJson: '付款比例设置',
contractFileJson: '用印材料',
attachmentsJson: '附件(佐证材料等)',
changeAttachmentsJson: '变更材料',
};
return labels[field] || field;
},
formatChangeRecordAttachments(value) {
const attachments = Array.isArray(value)
? value
: parseArray(typeof value === 'string' ? value : JSON.stringify(value || []));
const names = attachments
.map(item =>
typeof item === 'string'
? item
: item?.originalName || item?.name || item?.fileName || item?.url || item?.link || ''
)
.filter(Boolean);
return names.length ? names.join('、') : '空';
},
formatChangeRecordBillingPlans(value) {
const list = Array.isArray(value) ? value : [];
if (!list.length) return '空';
return list
.map(item => {
if (!item || typeof item !== 'object') return String(item ?? '');
const name = item.planName || item.name || '未命名方案';
return item.defaultPlan ? `${name}(默认)` : name;
})
.filter(Boolean)
.join('、');
},
formatChangeRecordPaymentRatios(value) {
const list = Array.isArray(value) ? value : [];
if (!list.length) return '空';
return list
.map(item => {
if (!item || typeof item !== 'object') return String(item ?? '');
const term = item.paymentTerm || '';
const ratio = item.ratioLimit === undefined || item.ratioLimit === null || item.ratioLimit === '' ? '' : `${item.ratioLimit}%`;
return [term, ratio].filter(Boolean).join(' ');
})
.filter(Boolean)
.join(';');
},
formatChangeRecordSettlementRule(value) {
const normalized = this.normalizeChangeRecordValue(value);
if (!normalized || typeof normalized !== 'object' || Array.isArray(normalized)) {
return this.isEmptyChangeRecordValue(normalized) ? '空' : String(normalized);
}
const summary = config => {
if (!config || typeof config !== 'object') return '';
const parts = [];
const autoGenerate = config.autoGenerate;
if (autoGenerate !== undefined && autoGenerate !== null && autoGenerate !== '') {
parts.push(`自动生成结算单:${Number(autoGenerate) === 1 ? '开启' : '关闭'}`);
}
if (config.settlementType) parts.push(`结算类型:${config.settlementType}`);
if (config.billCycleType) parts.push(`结算周期:${config.billCycleType}`);
if (config.billCutoffDay) parts.push(`账单截单日:${config.billCutoffDay}日`);
if (config.cycleDays) parts.push(`周期天数:${config.cycleDays}天`);
if (
config.billCycleType === '自定义多周期' &&
Array.isArray(config.customPeriods) &&
config.customPeriods.length
) {
parts.push(
`自定义周期:${config.customPeriods
.map(item => `${item.startDay || '-'}-${item.endDay || '-'}日`)
.join(',')}`
);
}
if (config.billStartDate) parts.push(`账单起始日:${config.billStartDate}`);
return parts.join(',');
};
if (normalized.preSettlementConfig || normalized.formalSettlementConfig) {
return [
`预结算:${summary(normalized.preSettlementConfig) || '空'}`,
`正式结算:${summary(normalized.formalSettlementConfig) || '空'}`,
].join(';');
}
return summary(normalized) || '空';
},
formatContractChangeValue(field, value) {
const normalized = this.normalizeChangeRecordValue(value);
if (this.isEmptyChangeRecordValue(normalized)) return '空';
if (['contractFileJson', 'attachmentsJson', 'changeAttachmentsJson'].includes(field)) {
return this.formatChangeRecordAttachments(normalized);
}
if (field === 'billingPlanJson') return this.formatChangeRecordBillingPlans(normalized);
if (field === 'paymentRatioJson') return this.formatChangeRecordPaymentRatios(normalized);
if (
['settlementRuleJson', 'preSettlementConfigJson', 'formalSettlementConfigJson'].includes(
field
)
) {
return this.formatChangeRecordSettlementRule(normalized);
}
if (['legalSealFlag', 'templateFlag', 'electronicSealFlag'].includes(field)) {
return Number(normalized) === 1 ? '是' : '否';
}
if (field === 'feeGenerationMode') return normalized === 'manual' ? '账单导入生成' : '系统生成';
if (field === 'invoiceCycle' || field === 'paymentDays') return `${normalized}天`;
if (Array.isArray(normalized) || typeof normalized === 'object') {
return JSON.stringify(normalized);
}
return String(normalized);
},
// 列表「变更内容」列:把变更前后内容拼成一段摘要(参照客商变更记录)
formatContractChangeContent(row = {}) {
const rows = this.buildDetailChangeRecordRows(row).filter(
item => item.field !== '变更原因'
);
return rows
.map(item => `${item.field}:变更前:${item.before} → 变更后:${item.after}`)
.join(';');
},
buildDetailChangeRecordRows(row = {}) {
const beforeData = this.parseChangeRecordData(row.beforeData);
const afterData = this.parseChangeRecordData(row.afterData);
const fields = [...new Set([...Object.keys(beforeData), ...Object.keys(afterData)])];
const rows = fields
.filter(field => {
// 前后都为空的字段不展示('' 与 null 序列化不同,单靠 JSON 比对会漏掉)
if (
this.isEmptyChangeRecordValue(beforeData[field]) &&
this.isEmptyChangeRecordValue(afterData[field])
) {
return false;
}
return (
JSON.stringify(this.normalizeChangeRecordValue(beforeData[field])) !==
JSON.stringify(this.normalizeChangeRecordValue(afterData[field]))
);
})
.map(field => ({
field: this.getContractChangeFieldLabel(field),
before: this.formatContractChangeValue(field, beforeData[field]),
after: this.formatContractChangeValue(field, afterData[field]),
}));
if (row.changeContent) {
rows.unshift({ field: '变更内容', before: '空', after: row.changeContent });
}
if (row.changeReason) {
rows.push({ field: '变更原因', before: '空', after: row.changeReason });
}
return rows;
},
openDetailChangeRecord(row) {
this.detailChangeRecord = row;
this.detailChangeRecordDetailRows = this.buildDetailChangeRecordRows(row);
this.detailChangeRecordVisible = true;
},
displayValue(value) {
return value === undefined || value === null || value === '' ? '-' : value;
},
detailUnitValue(prop, unit) {
const value = this.detailRow[prop];
return value === undefined || value === null || value === '' || Number(value) < 0
? ''
: `${value}${unit}`;
},
detailObjectUnitValue(row, prop, unit) {
const value = row?.[prop];
return value === undefined || value === null || value === '' ? '-' : `${value}${unit}`;
},
detailValue(prop) {
const value = this.detailRow[prop];
if (prop === 'contractAmount') {
const amountText = formatContractAmountInput(value, '');
return amountText === '' ? '-' : amountText;
}
if (
prop === 'contractStage' ||
prop === 'approvalStatus' ||
prop === 'contractCategory' ||
prop === 'signType' ||
prop === 'effectiveType' ||
prop === 'contractFormat'
) {
return this.displayStatus(this.detailRow, prop);
}
if (prop === 'billingPlanJson') return `${parseArray(value).length}个计费方案`;
if (prop === 'settlementRuleJson') return value ? '已配置' : '-';
if (prop === 'paymentRatioJson') return `${parseArray(value).length}项付款比例`;
if (prop === 'feeGenerationMode') return value === 'manual' ? '账单导入生成' : '系统生成';
return this.displayValue(value);
},
detailDictionaryValue(prop, options = []) {
const value = this.detailRow[prop];
if (value === undefined || value === null || value === '') return '-';
return options.find(item => String(item.value) === String(value))?.label || value;
},
handleStartContractChange(row) {
if (!row?.id) {
this.$message.warning('合同ID为空,无法发起变更');
return;
}
const loading = this.$loading({
lock: true,
text: '正在创建变更单…',
background: 'rgba(255, 255, 255, 0.6)',
});
createFromContract(row.id)
.then(res => {
const data = res?.data?.data || res?.data || {};
if (!data.id) {
this.$message.error(res?.data?.msg || '创建合同变更草稿失败');
return;
}
this.$router.push({
path: '/business/contract-change/form',
query: { mode: 'edit', id: data.id, name: '合同变更' },
});
})
.finally(() => loading.close());
},
handleOperation(operation, row) {
if (operation.action === 'startChange') {
this.handleStartContractChange(row);
return;
}
if (operation.action === 'flow') {
if (this.mkEnabled) {
this.handleMkAudit(row);
return;
}
this.openFlow(row);
return;
}
if (operation.action === 'attachmentUpload') {
this.openAttachmentUploadDialog(row);
return;
}
const run = value => {
const args = operation.prompt ? [row.id, value] : [row.id];
const request = () => this.api[operation.action](...args);
const afterMk =
operation.action === 'submitFormal'
? request().then(() =>
submitMkApprovalFlow({
bizType: 'contract-manage',
bizId: row.id,
subjectName: row.contractName || '',
approvalStatus: row.approvalStatus || '',
mkEnabled: this.mkEnabled,
})
)
: request();
// 合同通过会同步等待附件转传 + 合规推送,加遮罩防重复点击
const loading =
operation.action === 'approve'
? this.$loading({
lock: true,
text: '正在推送合规,请稍候…',
background: 'rgba(255, 255, 255, 0.6)',
})
: null;
afterMk
.then(res => {
// data=false 表示审批已通过但合规推送失败,msg 带具体原因(照正式结算单审批)
if (operation.action === 'approve' && res?.data?.data === false) {
this.$message.warning(res.data.msg);
} else {
this.$message.success(operation.successMessage || `${operation.label}成功`);
}
this.onLoad(this.page, this.query);
})
.finally(() => loading && loading.close());
};
if (operation.prompt) {
this.$prompt(operation.prompt, operation.label, {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPattern: /\S+/,
inputErrorMessage: '请输入内容',
}).then(({ value }) => run(value));
} else {
this.$confirm(operation.confirmMessage || `确定${operation.label}该合同?`, '提示', {
type: 'warning',
}).then(() => run());
}
},
removeRow(row) {
this.$confirm('确定删除该合同?', '提示', { type: 'warning' })
.then(() => this.api.remove(row.id))
.then(() => {
this.$message.success('删除成功');
this.onLoad(this.page, this.query);
});
},
handleCopy(row) {
this.$router.push({
path: contractManageFormRoute,
query: { mode: 'add', copyId: row.id, name: '新增合同管理' },
});
},
handleMkAudit(row) {
if (!row?.id) {
this.$message.warning('合同ID为空,无法打开审核页');
return;
}
getMKApprovalUrl({ bizId: String(row.id) })
.then(res => {
const url = res?.data?.data;
if (!url) {
this.$message.error('获取审核跳转地址失败');
return;
}
window.open(url, '_blank');
})
.catch(() => {
this.$message.error('获取审核跳转地址失败');
});
},
openFlow(row, loaded = false) {
const processInstanceId = row.processInstanceId || row.processInstanceID || row.procInstId;
if (!processInstanceId && !loaded && row.id) {
this.api.getDetail(row.id).then(res => this.openFlow(res.data?.data || {}, true));
return;
}
if (!processInstanceId) {
this.$message.warning('流程实例为空,无法查看流程');
return;
}
if (this.website.design.designMode) this.processInstanceId = processInstanceId;
else
this.flowUrl = `/api/blade-flow/process/diagram-view?processInstanceId=${processInstanceId}`;
this.flowBox = true;
},
handleExport() {
this.$confirm(`是否导出${this.config.exportName}?`, '提示', { type: 'warning' }).then(() => {
NProgress.start();
exportBlob(
this.config.exportUrl,
{
...this.normalizeSearch(this.query),
ids: this.ids,
[this.website.tokenHeader]: getToken(),
},
{ feedback: true }
)
.then(res =>
downloadXls(
res.data,
`${this.config.exportName}${this.$dayjs().format('YYYY-MM-DD HH:mm:ss')}.xlsx`
)
)
.finally(() => NProgress.done());
});
},
},
};
</script>
<style lang="scss" scoped>
.contract-name-cell {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
.contract-manage-form__risk-alert {
width: 100%;
}
// 风险客商提示条(基本信息备注后):橙黄背景 + 红色文字(设计稿 2026-10)
.contract-manage-form__risk-tip {
margin: 0 0 18px;
padding: 10px 16px;
background: #faecd8;
border-radius: 4px;
.contract-manage-form__risk-tip-title {
color: #f56c6c;
font-weight: 600;
line-height: 22px;
}
.contract-manage-form__risk-tip-content {
color: #f56c6c;
line-height: 22px;
}
}
.contract-manage-page {
&__expiry-search {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
margin-bottom: 16px;
}
&__footer {
position: fixed;
z-index: 20;
right: 0;
bottom: 0;
left: 230px;
display: flex;
align-items: center;
justify-content: flex-end;
min-height: 64px;
box-sizing: border-box;
padding: 12px 24px;
background: #fff;
border-top: 1px solid #eff1f7;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
// 统一 12px 间距,与全站底部操作栏(去 gap,由相邻按钮 margin 提供)一致
.el-button + .el-button {
margin-left: 12px;
}
}
&__flow {
width: 100%;
height: calc(100vh - 150px);
border: 0;
}
:deep(.avue-crud__header) {
margin-top: 12px;
}
:deep(.avue-crud__menu) {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
// 搜索区域白底卡片包裹
:deep(.avue-crud__search) {
background-color: #fff;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
:deep(.avue-crud__search:not(.el-card)) {
padding: 12px 12px 4px;
}
&--form {
min-height: 100%;
padding-bottom: 72px;
background: #f5f6fa;
:deep(.basic-container__card) {
border: 0;
background: transparent;
box-shadow: none;
}
}
}
.contract-change-record-content-cell {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contract-change-record-content-tooltip {
max-width: 900px;
max-height: 320px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
:global(.avue--collapse .contract-manage-page__footer) {
left: 60px;
}
:global(.avue-layout--horizontal .contract-manage-page__footer) {
left: 0;
}
.contract-manage-form {
//padding: 12px 16px 24px;
background: #f5f6fa;
&--attachment-upload {
:deep(.contract-manage-form__section--panel:not(.contract-manage-form__section--attachment)) {
pointer-events: none;
opacity: 0.92;
}
:deep(.contract-manage-form__section--attachment) {
pointer-events: auto;
opacity: 1;
}
}
&__section {
margin-bottom: 12px;
&--panel {
padding: 14px 16px 16px;
overflow: hidden;
background: #fff;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
&--attachment {
overflow: visible;
}
> .dialog-section-title,
:deep(.dialog-section-title) {
margin-bottom: 20px;
color: #303133;
font-size: 16px;
font-weight: 600;
}
}
&__section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
.dialog-section-title {
color: #303133;
font-size: 16px;
font-weight: 600;
}
}
&__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
column-gap: 48px;
}
&__remark {
width: 100%;
margin-bottom: 24px !important;
&:last-child {
margin-bottom: 0 !important;
}
}
&__date-range {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 8px;
width: 360px;
max-width: 100%;
span {
color: #606266;
}
:deep(.el-date-editor) {
width: auto;
min-width: 0;
}
}
&__billing-head,
&__attachment-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
&__settlement-switch {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 16px;
}
&__settlement-grid {
:deep(.el-form-item__label) {
flex-basis: 120px !important;
width: 120px !important;
white-space: nowrap;
word-break: normal;
}
}
&__custom-periods {
margin-top: 16px;
}
&__billing-head > div {
display: flex;
align-items: center;
gap: 20px;
}
&__fee-mode-tip {
margin-left: -22px;
margin-right: 20px;
color: #a8abb2;
cursor: help;
font-size: 14px;
}
&__tip {
margin: 0 0 8px;
color: #303133;
}
&__ratio-unit {
margin-left: 8px;
}
:deep(.el-form-item) {
align-items: center;
//margin-bottom: 24px;
}
:deep(.el-form-item__label) {
flex: 0 0 96px !important;
width: 96px !important;
height: auto;
padding-right: 12px;
white-space: normal;
word-break: break-all;
line-height: 18px;
}
:deep(.el-form-item__content) {
min-width: 0;
}
:deep(.el-input-number) {
width: 360px;
max-width: 100%;
.el-input__inner {
text-align: left;
}
}
:deep(.el-input),
:deep(.el-select),
:deep(.el-cascader),
:deep(.el-tree-select),
:deep(.el-date-editor) {
width: 360px;
max-width: 100%;
}
:deep(.el-tabs__header) {
margin-bottom: 16px;
}
:deep(.el-table) {
--el-table-border-color: #eff1f7;
}
:deep(.el-table th.el-table__cell) {
height: 42px;
padding: 8px 0;
background: #fafafa;
}
:deep(.el-table td.el-table__cell) {
height: 52px;
padding: 8px 0;
}
:deep(.el-link + .el-link) {
margin-left: 8px;
}
:deep(.vehicle-attachment-upload) {
width: auto;
}
}
:deep(.contract-manage-form__attachment-head) {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
// 标题与「批量下载」按钮同排、垂直居中,消除标题自身下边距
.dialog-section-title {
margin-bottom: 0;
}
}
:deep(.contract-manage-form__attachment-upload) {
display: flex;
justify-content: flex-start;
margin-top: 12px;
:deep(.vehicle-attachment-upload) {
display: flex;
align-items: center;
width: auto;
}
:deep(.el-upload) {
display: inline-flex;
}
:deep(.el-upload__tip) {
display: inline-block;
margin: 0 0 0 30px;
color: #909399;
font-size: 13px;
line-height: 1.4;
text-align: left;
}
}
.contract-manage-detail {
&__descriptions {
:deep(.el-descriptions__label) {
display: inline-block;
width: 96px;
min-width: 96px;
max-width: 96px;
box-sizing: border-box;
padding-right: 12px;
color: #606266;
line-height: 18px;
text-align: right;
white-space: normal;
word-break: break-all;
}
:deep(.el-descriptions__content) {
color: #303133;
word-break: break-word;
}
:deep(.el-descriptions__cell) {
padding-bottom: 16px;
}
}
}
@media (max-width: 1200px) {
.contract-manage-form__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 32px;
}
}
@media (max-width: 768px) {
.contract-manage-form {
padding: 12px;
&__grid {
grid-template-columns: 1fr;
}
}
}
</style>
<style>
.el-dialog .el-form-item__label {
white-space: normal;
line-height: 1.2;
}
</style>