Files
tms-erp-web/src/views/business/contract-manage.vue
T

3178 lines
115 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>
</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 #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="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('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('remark')
}}</el-descriptions-item>
</el-descriptions>
</section>
<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="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
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 @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="合同编号">
<el-input
v-model="form.contractNo"
:disabled="formMode === 'edit'"
placeholder="不填写,则系统自动生成"
/>
</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()"
>
<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()"
>
<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="合同期限" 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="YYYY-MM-DD"
/>
<span>至</span>
<el-date-picker
v-model="form.endDate"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="YYYY-MM-DD"
/>
</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="合同金额">
<el-input-number
v-model="form.contractAmount"
:min="0"
:precision="2"
:controls="false"
placeholder="请输入"
/>
</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="请选择">
<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="remark" class="contract-manage-form__remark">
<el-input
v-model="form.remark"
type="textarea"
:rows="2"
maxlength="200"
show-word-limit
placeholder="请输入备注"
/>
</el-form-item>
</section>
<contract-attachment-section
title="合同文件"
description
attachment-type
use-upload-dialog
:lock-approved="isAttachmentUploadMode || hasApprovedContractFiles"
:mark-approved-on-upload="isAttachmentUploadMode"
:rows="contractFileRows"
:preview="previewAttachment"
@update:rows="contractFileRows = $event"
/>
<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 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="settlement-switch-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
use-upload-dialog
attachment-location="其它附件"
:readonly="isAttachmentUploadMode"
:rows="attachmentRows"
:preview="previewAttachment"
@update:rows="attachmentRows = $event"
/>
<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"
@save="saveBillingPlan"
/>
<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"
/>
<el-dialog
v-model="detailChangeRecordVisible"
title="变更记录详情"
append-to-body
destroy-on-close
width="1100px"
top="10px"
class="contract-change-record-detail-dialog"
>
<div v-if="detailChangeRecord" class="contract-change-record-detail-meta">
<span>经办人:{{ detailChangeRecord.handlerUserName || '-' }}</span>
<span>变更类型:{{ detailChangeRecord.changeType || '-' }}</span>
<span>状态:{{ detailChangeRecord.statusName || detailChangeRecord.status || '-' }}</span>
</div>
<el-table :data="detailChangeRecordDetailRows" border :show-overflow-tooltip="false">
<el-table-column prop="field" label="变更字段" min-width="180" />
<el-table-column
prop="before"
label="变更前"
min-width="360"
class-name="contract-change-record-detail-value"
/>
<el-table-column
prop="after"
label="变更后"
min-width="500"
class-name="contract-change-record-detail-value"
/>
</el-table>
<el-empty
v-if="!detailChangeRecordDetailRows.length"
description="暂无变更内容"
:image-size="60"
/>
<template #footer>
<el-button type="primary" @click="detailChangeRecordVisible = false">关闭</el-button>
</template>
</el-dialog>
<billing-plan-editor
v-model="detailBillingPlanBox"
:value="detailBillingPlanForm"
:index="detailBillingPlanIndex"
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 { 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 } from '@/utils/auth';
import { downloadFileByUrl, downloadXls } from '@/utils/util';
import BillingPlanEditor from './components/billing-plan-editor.vue';
import ContractAttachmentSection, {
attachmentName as sharedAttachmentName,
attachmentUrl as sharedAttachmentUrl,
normalizeContractFileRows,
} from './components/contract-attachment-section.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 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 === '') return emptyValue;
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? Number(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: '',
contractFormat: '',
legalSealFlag: 0,
copyCount: '',
settlementCurrency: '',
invoiceCycle: '',
paymentDays: '',
contractAmount: '',
templateFlag: 0,
originalContractNo: '',
electronicSealFlag: 0,
startDate: '',
endDate: '',
temporaryStartDate: '',
temporaryEndDate: '',
effectiveType: '',
feeGenerationMode: 'system',
archiveStatus: '未归档',
remark: '',
});
// 复制新增:仅保留可编辑字段与明细,剔除主键、编号、审批与流程信息
const createCopiedDetail = (detail = {}) => {
const form = defaultForm();
Object.keys(form).forEach(prop => {
if (Object.prototype.hasOwnProperty.call(detail, prop)) form[prop] = detail[prop];
});
return {
...form,
contractNo: '',
archiveStatus: '未归档',
feeGenerationMode: detail.feeGenerationMode || '',
billingEnabled: detail.billingEnabled,
contractFileJson: detail.contractFileJson || '',
attachmentsJson: 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, ElImageViewer, OpenFileViewer, PdfPreview },
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(),
formRules: {
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' }],
legalSealFlag: [{ 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' }],
},
submitAction: '',
projectOptions: [],
projectLoading: false,
selectedProjectId: '',
settlementCurrencyOptions: [],
settlementModeDictOptions: [],
contractFormatDictOptions: [],
contractCategoryDictOptions: [],
contractPartyAOptions: [],
contractPartyBOptions: [],
contractPartyLoading: false,
contractPartyRequestId: 0,
organizationOptions: [],
organizationFlatOptions: [],
selectedOrganizationId: '',
organizationLoading: false,
contractFileRows: [],
attachmentRows: [],
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: {},
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: '',
};
},
computed: {
...mapGetters(['permission', 'userInfo']),
permissionList() {
return { addBtn: this.canCreate };
},
isAdmin() {
const authority = this.userInfo?.authority;
return Array.isArray(authority)
? authority.includes('admin')
: String(authority || '').includes('admin');
},
canCreate() {
return this.hasPermission(`${this.config.permission}_add`);
},
isListPage() {
return !this.isFormPage && !this.isDetailPage;
},
isFormPage() {
return this.$route.path === '/business/contract-manage/form';
},
isDetailPage() {
return this.$route.path === '/business/contract-manage/detail';
},
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';
},
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.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}`;
},
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.fullPath'() {
if (this.isFormPage) this.initFormPage();
if (this.isDetailPage) 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.loadProjectOptions();
this.loadSettlementDictionaries();
this.loadOrganizationOptions();
if (this.isFormPage) this.initFormPage();
if (this.isDetailPage) this.initDetailPage();
},
methods: {
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';
},
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 };
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: '/business/contract-manage/form',
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.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: normalizeOptionalAmount(detail.contractAmount, ''),
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.$nextTick(() => this.$refs.contractForm?.clearValidate());
},
closeForm() {
this.$router.$avueRouter?.closeTag?.();
this.$router.push('/business/contract-manage');
},
validateBase(action) {
if (action === 'temporary') {
const required = [
['signType', '签约类型'],
['contractName', '合同名称'],
['contractCategory', '合同类型'],
['projectName', '所属项目'],
['organizationName', '所属组织'],
['partyA', '甲方'],
['partyB', '乙方'],
];
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] === 'contractName' ? `请输入${empty[1]}` : `请选择${empty[1]}`
);
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', '合同格式'],
];
const empty = required.find(([prop]) => !this.form[prop]);
if (empty) {
this.$message.warning(`请选择或输入${empty[1]}`);
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,
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 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);
}
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(() => {
this.$message.success(isChangeRejected ? '已重新提交变更审批' : '修改成功');
this.closeForm();
})
.finally(() => {
this.submitAction = '';
});
},
submitAttachmentUpload() {
if (this.submitAction) return;
if (!this.form.id) {
this.$message.warning('合同不存在,无法上传附件');
return;
}
this.submitAction = 'attachment';
this.api
.updateAttachments({
id: this.form.id,
contractFileJson: JSON.stringify(this.contractFileRows),
attachmentsJson: JSON.stringify(this.attachmentRows),
})
.then(() => {
this.$message.success('附件保存成功');
this.closeForm();
})
.finally(() => {
this.submitAction = '';
});
},
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 names = responses
.flatMap(extractRecords)
.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;
})
.finally(() => {
if (requestId === this.contractPartyRequestId) this.contractPartyLoading = false;
});
},
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();
})
.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: '' }
);
},
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, '');
},
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;
},
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;
},
normalizeSettlementRule(rule = {}) {
const next = { ...defaultSettlementRule(), ...rule };
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 (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 (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 (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({});
this.api
.getDetail(id)
.then(res => {
this.applyDetailState(res.data?.data || {});
})
.finally(() => {
this.detailLoading = false;
});
},
closeDetail() {
this.$router.$avueRouter?.closeTag?.();
this.$router.push('/business/contract-manage');
},
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 = {
contractName: '合同名称',
partyB: '乙方',
startDate: '开始日期',
endDate: '结束日期',
contractFormat: '合同格式',
settlementMode: '结算方式',
legalSealFlag: '是否需要加盖法人章',
copyCount: '一式(份)',
settlementCurrency: '结算币种',
invoiceCycle: '开票周期',
paymentDays: '回款账期',
contractAmount: '合同金额',
templateFlag: '是否范本',
originalContractNo: '原件合同编号',
electronicSealFlag: '是否电子章',
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 === '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;
},
handleOperation(operation, row) {
if (operation.action === 'startChange') {
this.$router.push({
path: '/business/contract-manage/change',
query: { id: row.id, name: '合同变更' },
});
return;
}
if (operation.action === 'flow') {
this.openFlow(row);
return;
}
if (operation.action === 'attachmentUpload') {
this.$router.push({
path: '/business/contract-manage/form',
query: {
mode: 'edit',
id: row.id,
name: '附件上传',
attachmentUpload: '1',
},
});
return;
}
const run = value => {
const args = operation.prompt ? [row.id, value] : [row.id];
this.api[operation.action](...args).then(() => {
this.$message.success(operation.successMessage || `${operation.label}成功`);
this.onLoad(this.page, this.query);
});
};
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: '/business/contract-manage/form',
query: { mode: 'add', copyId: row.id, name: '新增合同管理' },
});
},
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-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;
}
.contract-change-record-detail-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 32px;
margin-bottom: 16px;
color: #606266;
}
:deep(.contract-change-record-detail-dialog .el-dialog__body) {
max-height: 65vh;
overflow: auto;
padding-top: 12px;
}
:deep(.contract-change-record-detail-dialog .contract-change-record-detail-value .cell) {
white-space: pre-wrap;
word-break: break-all;
}
: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);
}
> .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: 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-switch-tip {
margin: 0 4px;
color: #a8abb2;
cursor: help;
font-size: 14px;
}
}
&__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: 14px;
}
: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),
:deep(.el-select),
:deep(.el-cascader),
:deep(.el-tree-select),
:deep(.el-input-number),
: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>