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

1252 lines
54 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-change-page">
<div class="archive-page-form__title">{{ pageTitle }}</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
:disabled="pageReadonly"
label-position="right"
label-width="auto"
class="contract-change-form"
>
<section class="change-section contract-basic-section">
<div class="dialog-section-title">基本信息</div>
<div class="contract-basic-section__grid">
<el-form-item label="变更类型" class="contract-basic-section__change-type">
<el-radio-group v-model="form.changeType" :disabled="pageReadonly">
<el-radio label="合同信息变更" />
<el-radio label="结算规则变更" />
<el-radio label="终止合同" />
</el-radio-group>
</el-form-item>
<el-form-item label="签约类型" prop="signType">
<el-select
v-model="form.signType"
placeholder="请选择签约类型"
clearable
:disabled="isSettlementRuleChange"
>
<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="请输入合同编号"
:disabled="isSettlementRuleChange"
/>
</el-form-item>
<el-form-item label="合同名称" prop="contractName">
<el-input
v-model="form.contractName"
maxlength="100"
show-word-limit
placeholder="请输入合同名称"
:disabled="isSettlementRuleChange"
/>
</el-form-item>
<el-form-item label="合同类型">
<el-select v-model="form.contractCategory" disabled>
<el-option
v-for="item in contractCategoryOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="项目">
<el-input v-model="form.projectName" disabled />
</el-form-item>
<el-form-item label="合同负责部门">
<el-input v-model="form.organizationName" disabled />
</el-form-item>
<el-form-item label="甲方">
<el-input v-model="form.partyA" disabled />
</el-form-item>
<el-form-item label="乙方">
<el-input v-model="form.partyB" disabled />
</el-form-item>
<el-form-item label="签订日期">
<el-date-picker
v-model="form.signDate"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
disabled
/>
</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"
disabled
/>
</el-form-item>
<el-form-item label="合同期限">
<div class="contract-basic-section__date-range">
<el-date-picker
v-model="period[0]"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="请输入"
:disabled="isSettlementRuleChange"
/>
<span>至</span>
<el-date-picker
v-model="period[1]"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="请输入"
:disabled="isSettlementRuleChange"
/>
</div>
</el-form-item>
<el-form-item label="回款账期">
<el-input
v-model="form.paymentDays"
placeholder="请输入"
:disabled="isSettlementRuleChange"
@input="value => positiveIntegerInput('paymentDays', value)"
>
<template #suffix>天</template>
</el-input>
</el-form-item>
<el-form-item label="合同金额" prop="contractAmount">
<el-input
:model-value="contractAmountDisplay"
placeholder="请输入"
clearable
:disabled="isSettlementRuleChange"
@input="handleContractAmountInput"
@blur="formatContractAmountField"
@clear="form.contractAmount = null"
/>
</el-form-item>
<el-form-item label="是否范本">
<el-select
v-model="form.templateFlag"
placeholder="请选择"
:disabled="isSettlementRuleChange"
>
<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="请输入"
:disabled="isSettlementRuleChange"
/>
</el-form-item>
<el-form-item label="是否电子章">
<el-select
v-model="form.electronicSealFlag"
placeholder="请选择"
:disabled="isSettlementRuleChange"
>
<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="请输入"
:disabled="isSettlementRuleChange"
@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
:disabled="isSettlementRuleChange"
>
<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="合同格式">
<el-select
v-model="form.contractFormat"
placeholder="请选择合同格式"
clearable
:disabled="isSettlementRuleChange"
>
<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="请输入"
:disabled="isSettlementRuleChange"
@input="value => positiveIntegerInput('copyCount', value)"
>
<template #suffix>份</template>
</el-input>
</el-form-item>
<el-form-item label="是否需要加盖法人章">
<el-select
v-model="form.legalSealFlag"
placeholder="请选择"
clearable
:disabled="isSettlementRuleChange"
>
<el-option label="否" :value="0" />
<el-option label="是" :value="1" />
</el-select>
</el-form-item>
<el-form-item label="结算方式">
<el-select
v-model="form.settlementMode"
placeholder="请选择结算方式"
clearable
:disabled="isSettlementRuleChange"
>
<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="合同主要内容" class="contract-basic-section__remark">
<el-input
v-model="form.mainContent"
type="textarea"
:rows="4"
maxlength="1000"
show-word-limit
placeholder="请输入合同主要内容"
:disabled="isSettlementRuleChange"
/>
</el-form-item>
<el-form-item label="其他事项说明" class="contract-basic-section__remark">
<el-input
v-model="form.remark"
type="textarea"
:rows="2"
maxlength="200"
show-word-limit
placeholder="请输入其他事项说明"
:disabled="isSettlementRuleChange"
/>
</el-form-item>
<!-- 风险客商提示:合同已推送合规并留痕高风险/黑名单时展示,变更提交同样要求特批附件 -->
<div v-if="riskTipText" class="contract-manage-form__risk-tip">
<div class="contract-manage-form__risk-tip-title">风险客商提示:</div>
<div class="contract-manage-form__risk-tip-content">{{ riskTipText }}</div>
</div>
</section>
<contract-attachment-section
title="用印材料"
description
attachment-type
:readonly="isSettlementRuleChange"
:attachment-location-options="changeAttachmentLocationOptions"
:rows="contractFileRows"
:preview="previewAttachment"
@update:rows="contractFileRows = $event"
@upload-to-location="handleAttachmentUploadToLocation"
/>
<section class="change-section">
<div class="section-head">
<div class="dialog-section-title">计费信息</div>
<el-button
v-if="!pageReadonly && feeGenerationMode !== 'manual'"
type="primary"
plain
@click="addPlan"
>添加</el-button
>
</div>
<el-radio-group v-model="feeGenerationMode"
><el-radio label="system">系统生成</el-radio
><el-radio label="manual">账单导入生成</el-radio></el-radio-group
>
<el-table :data="plans" border class="change-table"
><el-table-column type="index" label="序号" width="70" /><el-table-column
prop="planName"
label="方案名称"
min-width="220"
/><el-table-column label="默认方案" min-width="120"
><template #default="{ row }">{{
row.defaultPlan ? '是' : '否'
}}</template></el-table-column
><el-table-column prop="remark" label="备注" min-width="260" /><el-table-column
v-if="!pageReadonly"
label="操作"
width="180"
><template #default="{ row, $index }"
><el-link type="primary" @click="editPlan(row, $index)">编辑</el-link
><el-link type="danger" @click="plans.splice($index, 1)">删除</el-link></template
></el-table-column
></el-table
>
</section>
<section class="change-section">
<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="settlement-switch">
<span>自动生成结算单</span>
<el-radio-group v-model="settlementRule.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>
<el-form
v-if="settlementRule.autoGenerate === 1"
:model="settlementRule"
label-position="right"
label-width="auto"
class="settlement-form"
>
<el-form-item label="账单起始日" required
><el-date-picker
v-model="settlementRule.billStartDate"
type="date"
placeholder="请选择账单起始日"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
/></el-form-item>
<el-form-item label="结算类型" required
><el-select
v-model="settlementRule.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="showSettlementBillCycleType" label="结算周期" required
><el-select
v-model="settlementRule.billCycleType"
placeholder="请选择结算周期"
@change="handleCycleTypeChange"
><el-option
v-for="item in billCycleTypeOptions"
:key="item"
:label="item"
:value="item"
/></el-select></el-form-item>
<el-form-item v-if="showSettlementBillCutoffDay" label="账单截单日" required
><el-select v-model="settlementRule.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="showSettlementCycleDays" label="周期天数" required
><el-select v-model="settlementRule.cycleDays" placeholder="请选择周期天数"
><el-option
v-for="item in cycleDayOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/></el-select></el-form-item>
</el-form>
<el-table
v-if="settlementRule.autoGenerate === 1 && showSettlementCustomPeriods"
:data="settlementRule.customPeriods || []"
border
class="change-table custom-periods-table"
>
<el-table-column type="index" label="序号" width="70" />
<el-table-column label="运单区间-开始日" min-width="180"
><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"
><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 v-if="!pageReadonly" label="操作" width="100"
><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 v-if="!pageReadonly" type="primary" @click="addCustomPeriodRow">添加</el-link></template
>
</el-table>
</section>
<section class="change-section">
<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="paymentRatioRows" border class="change-table">
<el-table-column type="index" label="序号" width="70" />
<el-table-column prop="paymentTerm" label="付款笔数" min-width="180" />
<el-table-column label="付款比例上限(%)" min-width="220"
><template #default="{ row }"
><el-input-number
v-model="row.ratioLimit"
:min="0"
:max="100"
:precision="2"
controls-position="right"
:disabled="isSettlementRuleChange"
/></template
></el-table-column>
<el-table-column label="备注" min-width="220"
><template #default="{ row }"
><el-input
v-model="row.remark"
maxlength="200"
:disabled="isSettlementRuleChange"
/></template
></el-table-column>
<el-table-column v-if="!pageReadonly && !isSettlementRuleChange" label="操作" width="100"
><template #default="{ $index }"
><el-link type="danger" @click="paymentRatioRows.splice($index, 1)"
>删除</el-link
></template
></el-table-column
>
</el-table>
<el-button
v-if="!pageReadonly && !isSettlementRuleChange"
type="primary"
plain
@click="addPaymentRatioRow"
>添加</el-button
>
</section>
<billing-plan-editor
v-model="planDialogVisible"
:value="planEditor"
:index="planEditorIndex"
:settlement-currency="form.settlementCurrency"
@save="savePlan"
/>
<contract-attachment-section
title="附件(佐证材料等)"
description
attachment-type
attachment-location="附件(佐证材料等)"
attachment-type-dict-code="contract_supporting_attachment_type"
:readonly="isSettlementRuleChange"
:attachment-location-options="changeAttachmentLocationOptions"
:rows="attachments"
:preview="previewAttachment"
@update:rows="attachments = $event"
@upload-to-location="handleAttachmentUploadToLocation"
/>
<section class="change-section change-reason-section">
<div class="dialog-section-title">变更原因</div>
<el-form-item prop="changeReason" class="change-reason-section__input">
<el-input
v-model="form.changeReason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请输入变更原因"
/>
</el-form-item>
</section>
<contract-attachment-section
title="变更材料"
description
attachment-type
attachment-location="变更材料"
:attachment-location-options="changeAttachmentLocationOptions"
:rows="changeMaterials"
:preview="previewAttachment"
@update:rows="changeMaterials = $event"
@upload-to-location="handleAttachmentUploadToLocation"
/>
<div v-if="!pageReadonly" class="page-footer">
<el-button @click="goBack">取消</el-button>
<el-button v-if="isContractChangeModule" @click="saveDraft">保存草稿</el-button>
<el-button type="primary" @click="submit">提交</el-button>
</div>
</el-form>
<el-dialog v-model="documentPreviewVisible" :title="previewFile.name || '附件预览'" append-to-body destroy-on-close width="90%" top="4vh">
<open-file-viewer v-if="documentPreviewVisible && previewFile.url" :file="previewFile.url" :file-name="previewFile.name" :mime-type="previewFile.mimeType" width="100%" height="72vh" fit="contain" theme="auto" locale="zh-CN" :toolbar="viewerToolbar" :plugins="viewerPlugins" @unsupported="handlePreviewUnsupported" @error="handlePreviewError" />
</el-dialog>
<el-image-viewer v-if="imagePreviewVisible" :url-list="imagePreviewUrls" :initial-index="imagePreviewIndex" @close="imagePreviewVisible = false" />
</basic-container>
</template>
<script>
import * as contractApi from '@/api/business/contract-manage';
import * as changeApi from '@/api/business/contract-change';
import { getMkPublicDetail } from '@/api/mk-process';
import { loadMkEnabled, submitMkApprovalFlow } from '@/utils/mk-approval';
import BillingPlanEditor from './components/billing-plan-editor.vue';
import ContractAttachmentSection, {
isContractSealMaterialLocation,
isContractSupportingAttachmentLocation,
normalizeContractFileRows,
} from './components/contract-attachment-section.vue';
import { getDictionary as getSystemDictionary } from '@/api/system/dict';
import { getDictionary as getBizDictionary } from '@/api/system/dictbiz';
import {
contractCategoryOptions as defaultContractCategoryOptions,
signTypeOptions as defaultSignTypeOptions,
} from '@/option/business/common';
import { ElImageViewer } from 'element-plus';
import { OpenFileViewer } from '@open-file-viewer/vue';
import { fallbackPlugin, imagePlugin, officePlugin, pdfPlugin, textPlugin } from '@open-file-viewer/core';
import '@open-file-viewer/core/style.css';
import pdfWorkerSrc from 'pdfjs-dist/build/pdf.worker.mjs?url';
const viewerPlugins = [
imagePlugin(),
pdfPlugin({ workerSrc: pdfWorkerSrc, useFetchData: true }),
officePlugin({ pdf: { workerSrc: pdfWorkerSrc, useFetchData: true } }),
textPlugin(),
fallbackPlugin(),
];
const defaultSettlementModeOptions = [
{ label: '现结', value: '现结' },
{ label: '日结', value: '日结' },
{ label: '周结', value: '周结' },
{ label: '月结', value: '月结' },
{ label: '按固定天数周期', value: '按固定天数周期' },
];
const defaultContractFormatOptions = [
{ label: '电子合同', value: '电子合同' },
{ label: '纸质合同', value: '纸质合同' },
];
const contractChangeListRoute = '/business/contract-change';
const contractChangeListRefreshKey = 'contract-change-list-refresh';
const normalizeOptionalPositiveInteger = value => {
if (value === undefined || value === null || value === '') return null;
const number = Number(value);
return Number.isInteger(number) && number > 0 ? number : null;
};
const normalizeOptionalAmount = value => {
if (
value === undefined ||
value === null ||
value === '' ||
Number(value) === -1 ||
String(value) === '-1'
) {
return null;
}
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? Number(number.toFixed(2)) : null;
};
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 normalizeCustomPeriods = (periods = []) =>
(periods || []).map(item => ({
startDay: Number(item?.startDay) > 0 ? Number(item.startDay) : '',
endDay: Number(item?.endDay) > 0 ? Number(item.endDay) : '',
}));
export default {
components: { BillingPlanEditor, ContractAttachmentSection, ElImageViewer, OpenFileViewer },
data() {
return {
form: {},
period: [],
plans: [],
attachments: [],
contractFiles: [],
contractFileRows: [],
paymentRatioRows: [],
settlementConfigTab: 'pre',
changeMaterials: [],
imagePreviewVisible: false,
imagePreviewUrls: [],
imagePreviewIndex: 0,
documentPreviewVisible: false,
previewFile: {},
viewerPlugins,
viewerToolbar: { zoom: true, rotate: true, download: true, fullscreen: true },
feeGenerationMode: 'system',
settlementRule: {
autoGenerate: '',
billStartDate: '',
settlementType: '',
billCycleType: '',
billCutoffDay: '',
cycleDays: '',
customPeriods: [],
},
preSettlementConfig: {},
formalSettlementConfig: {},
settlementTypeOptions: ['月结', '日结', '周结', '固定天数周期结算'],
billCycleTypeOptions: ['固定截单日', '自然月', '自定义多周期'],
billCutoffDayOptions: Array.from({ length: 31 }, (_, index) => ({
label: `${index + 1}日`,
value: index + 1,
})),
cycleDayOptions: [7, 15, 30, 60].map(value => ({ label: `${value}天`, value })),
signTypeOptions: defaultSignTypeOptions,
contractCategoryDictOptions: [],
settlementCurrencyOptions: [],
settlementModeDictOptions: [],
contractFormatDictOptions: [],
planDialogVisible: false,
planEditor: { planName: '', defaultPlan: false, remark: '', rules: [] },
planEditorIndex: -1,
billingElements: [
'按重量',
'按体积',
'按车辆',
'按里程',
'按吨·公里',
'固定金额(整单一口价)',
'按数量',
],
rules: {
contractNo: [
{
validator: (rule, value, callback) => {
if (this.isSettlementRuleChange) {
return callback();
}
const contractNo = String(value || '').trim();
if (!contractNo) {
return callback(new Error('请输入合同编号'));
}
if (contractNo.length > 100) {
return callback(new Error('合同编号不能超过100个字符'));
}
callback();
},
trigger: 'blur',
},
],
contractName: [{ required: true, message: '请输入合同名称', trigger: 'blur' }],
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',
},
],
changeReason: [{ max: 500, message: '变更原因不能超过500个字符', trigger: 'blur' }],
},
};
},
computed: {
pageTitle() {
if (this.pageReadonly) return this.$route.query.name || '查看合同变更';
return this.$route.query.name || '合同变更';
},
isContractChangeModule() {
return (
this.$route.path === '/business/contract-change/form' ||
this.$route.path === '/business/contract-change/public-view'
);
},
isPublicViewPage() {
return this.$route.path === '/business/contract-change/public-view';
},
pageReadonly() {
return this.isPublicViewPage || this.$route.query.mode === 'view';
},
// 高风险/黑名单客商提示:按合同推送合规留痕 hcs_risk_level/hcs_black_status 判定(与合同详情口径一致)
riskTipText() {
const riskLevel = String(this.form.hcsRiskLevel || '').trim();
const blacklist = String(this.form.kingdeeBlackStatus || '').toLowerCase() === 'yes';
if (riskLevel !== '2' && !blacklist) return '';
const parties = [this.form.partyA, this.form.partyB]
.map(name => String(name || '').trim())
.filter(Boolean)
.join('、');
return `【${parties || '相对方'}】:被列入外部黑名单;涉贪污受贿裁判相关提及方(3年以上及其他);被执行人信息;买卖合同纠纷;劳动纠纷;重大纠纷。提交变更前须上传「特批附件」类型附件`;
},
isSettlementRuleChange() {
return this.form.changeType === '结算规则变更';
},
changeAttachmentLocationOptions() {
if (this.isSettlementRuleChange) {
return ['变更材料'];
}
return ['用印材料', '附件(佐证材料等)', '变更材料'];
},
contractAmountDisplay() {
const value = this.form.contractAmount;
if (
value === undefined ||
value === null ||
value === '' ||
Number(value) === -1 ||
String(value) === '-1'
) {
return '';
}
return value;
},
contractCategoryOptions() {
return this.contractCategoryDictOptions.length
? this.contractCategoryDictOptions
: defaultContractCategoryOptions;
},
settlementModeOptions() {
return this.settlementModeDictOptions.length
? this.settlementModeDictOptions
: defaultSettlementModeOptions;
},
contractFormatOptions() {
return this.contractFormatDictOptions.length
? this.contractFormatDictOptions
: defaultContractFormatOptions;
},
showSettlementBillCycleType() {
return this.settlementRule.settlementType === '月结';
},
showSettlementBillCutoffDay() {
return this.showSettlementBillCycleType && this.settlementRule.billCycleType === '固定截单日';
},
showSettlementCustomPeriods() {
return this.showSettlementBillCycleType && this.settlementRule.billCycleType === '自定义多周期';
},
showSettlementCycleDays() {
return this.settlementRule.settlementType === '固定天数周期结算';
},
},
mounted() {
this.loadDictionaries();
this.load();
},
watch: {
settlementConfigTab(tab, oldTab) {
if (tab === oldTab) return;
if (oldTab === 'pre') this.preSettlementConfig = { ...this.settlementRule };
else this.formalSettlementConfig = { ...this.settlementRule };
this.settlementRule = {
...(tab === 'pre' ? this.preSettlementConfig : this.formalSettlementConfig),
};
},
},
methods: {
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);
},
loadDictionaries() {
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,
}));
});
},
async load() {
const id = this.$route.query.id;
if (!id) return;
let data = {};
if (this.isPublicViewPage) {
const res = await getMkPublicDetail('contract-change', id);
data = res.data?.data || res.data || {};
} else if (this.isContractChangeModule) {
const res = await changeApi.getDetail(id);
data = res.data?.data || res.data || {};
} else {
const res = await contractApi.getDetail(id);
data = res.data?.data || res.data || {};
}
const isChangeRecord = this.isContractChangeModule || this.isPublicViewPage;
this.form = {
...data,
// 独立变更单保留 contractId;旧路由以合同 id 作为主键
contractId: data.contractId || (isChangeRecord ? data.contractId : data.id),
changeContent: isChangeRecord ? data.changeContent || '' : '',
changeReason: isChangeRecord ? data.changeReason || '' : '',
changeAttachmentsJson: data.changeAttachmentsJson || '',
copyCount: normalizeOptionalPositiveInteger(data.copyCount),
invoiceCycle: normalizeOptionalPositiveInteger(data.invoiceCycle),
paymentDays: normalizeOptionalPositiveInteger(data.paymentDays),
contractAmount: formatContractAmountInput(data.contractAmount),
templateFlag: data.templateFlag ?? 0,
electronicSealFlag: data.electronicSealFlag ?? 0,
archiveStatus: data.archiveStatus || '未归档',
changeType: data.changeType || '合同信息变更',
};
this.changeMaterials = this.parse(data.changeAttachmentsJson);
this.period = data.startDate && data.endDate ? [data.startDate, data.endDate] : [];
this.plans = this.parse(data.billingPlanJson);
this.attachments = this.mergeChangeAttachments(
this.parse(data.attachmentsJson),
isChangeRecord ? [] : this.parse(data.changeAttachmentsJson)
);
this.contractFileRows = normalizeContractFileRows(this.parse(data.contractFileJson));
const rules = this.parseObject(data.settlementRuleJson);
const pre = this.parseObject(data.preSettlementConfigJson);
const formal = this.parseObject(data.formalSettlementConfigJson);
const legacy = Object.keys(rules).some(
key => !['preSettlementConfig', 'formalSettlementConfig'].includes(key)
)
? rules
: {};
const normalizeRule = rule => {
const next = {
autoGenerate: '',
billStartDate: '',
settlementType: '',
billCycleType: '',
billCutoffDay: '',
cycleDays: '',
customPeriods: [],
...(rule || {}),
};
if (next.settlementType === '月结' && next.billCycleType === '自定义多周期') {
next.customPeriods = normalizeCustomPeriods(
Array.isArray(next.customPeriods) ? next.customPeriods : []
);
} else {
next.customPeriods = [];
}
return next;
};
this.preSettlementConfig = normalizeRule(
rules.preSettlementConfig || (Object.keys(pre).length ? pre : legacy)
);
this.formalSettlementConfig = normalizeRule(
rules.formalSettlementConfig || (Object.keys(formal).length ? formal : legacy)
);
this.settlementRule = { ...this.preSettlementConfig };
this.feeGenerationMode =
data.feeGenerationMode || (Number(data.billingEnabled) === 0 ? 'manual' : 'system');
this.paymentRatioRows = this.parse(data.paymentRatioJson);
},
goBack() {
if (this.isContractChangeModule) {
this.$router.push(contractChangeListRoute);
return;
}
this.$router.back();
},
buildChangePayload() {
if (this.settlementConfigTab === 'pre') this.preSettlementConfig = { ...this.settlementRule };
else this.formalSettlementConfig = { ...this.settlementRule };
const settlementRule = {
preSettlementConfig: this.preSettlementConfig,
formalSettlementConfig: this.formalSettlementConfig,
};
if (this.isSettlementRuleChange) {
return {
id: this.form.id,
contractId: this.form.contractId,
changeType: this.form.changeType,
changeContent: this.form.changeContent,
changeReason: this.form.changeReason,
feeGenerationMode: this.feeGenerationMode,
billingEnabled: this.feeGenerationMode === 'system' ? 1 : 0,
billingPlanJson: JSON.stringify(this.plans),
settlementRuleJson: JSON.stringify(settlementRule),
preSettlementConfigJson: JSON.stringify(this.preSettlementConfig),
formalSettlementConfigJson: JSON.stringify(this.formalSettlementConfig),
changeAttachmentsJson: JSON.stringify(this.changeMaterials),
};
}
return {
...this.form,
contractId: this.form.contractId,
changeType: this.form.changeType || '合同信息变更',
settlementCurrency: String(this.form.settlementCurrency || '').trim() || 'RMB',
copyCount: normalizeOptionalPositiveInteger(this.form.copyCount),
invoiceCycle: normalizeOptionalPositiveInteger(this.form.invoiceCycle),
paymentDays: normalizeOptionalPositiveInteger(this.form.paymentDays),
contractAmount: normalizeOptionalAmount(this.form.contractAmount),
startDate: this.period[0],
endDate: this.period[1],
feeGenerationMode: this.feeGenerationMode,
billingEnabled: this.feeGenerationMode === 'system' ? 1 : 0,
billingPlanJson: JSON.stringify(this.plans),
settlementRuleJson: JSON.stringify(settlementRule),
preSettlementConfigJson: JSON.stringify(this.preSettlementConfig),
formalSettlementConfigJson: JSON.stringify(this.formalSettlementConfig),
paymentRatioJson: JSON.stringify(this.paymentRatioRows),
contractFileJson: JSON.stringify(this.contractFileRows),
attachmentsJson: JSON.stringify(this.attachments),
changeContent: this.form.changeContent,
changeReason: this.form.changeReason,
changeAttachmentsJson: JSON.stringify(this.changeMaterials),
};
},
async saveDraft() {
if (this.form.changeType === '终止合同') {
this.$message.warning('终止合同无需保存草稿,请直接提交');
return;
}
const payload = this.buildChangePayload();
await changeApi.saveDraft(payload);
this.$message.success('草稿已保存');
},
parse(value) {
try {
const result = JSON.parse(value || '[]');
return Array.isArray(result) ? result : [];
} catch {
return [];
}
},
// 审核通过后上一次的变更材料归集到「附件(佐证材料等)」,按文件地址/文件名去重,避免重复展示
mergeChangeAttachments(attachments = [], changeAttachments = []) {
const key = item => this.attachmentUrl(item) || this.attachmentName(item);
const exists = new Set(attachments.map(key));
const merged = changeAttachments
.filter(item => !exists.has(key(item)))
.map(item => ({
...item,
size: /^\d+$/.test(String(item.size ?? '')) ? this.formatFileSize(item.size) : item.size,
}));
return [...attachments, ...merged];
},
parseObject(value) {
try {
return {
autoGenerate: '',
billStartDate: '',
settlementType: '',
billCycleType: '',
billCutoffDay: '',
cycleDays: '',
customPeriods: [],
...(JSON.parse(value || '{}') || {}),
};
} catch {
return {
autoGenerate: '',
billStartDate: '',
settlementType: '',
billCycleType: '',
billCutoffDay: '',
cycleDays: '',
customPeriods: [],
};
}
},
positiveIntegerInput(prop, value) {
this.form[prop] = String(value ?? '')
.replace(/\D/g, '')
.replace(/^0+/, '');
},
addPlan() { this.planEditorIndex = -1; this.planEditor = { planName: `计费方案${this.plans.length + 1}`, defaultPlan: !this.plans.length, remark: '', rules: [{}] }; this.planDialogVisible = true; },
editPlan(row, index) { this.planEditorIndex = index; this.planEditor = JSON.parse(JSON.stringify(row)); this.planDialogVisible = true; },
toggleDefaultPlan(value) { if (value) this.plans.forEach(item => { item.defaultPlan = false; }); },
savePlan(value, index) { if (index < 0) this.plans.push(value); else this.plans.splice(index, 1, value); if (value.defaultPlan) this.plans.forEach((item, current) => { if (current !== (index < 0 ? this.plans.length - 1 : index)) item.defaultPlan = false; }); },
handleSettlementTypeChange(value) { if (value !== '月结') { this.settlementRule.billCycleType = ''; this.settlementRule.billCutoffDay = ''; this.settlementRule.customPeriods = []; } else if (this.settlementRule.billCycleType === '自定义多周期') this.settlementRule.customPeriods = normalizeCustomPeriods(this.settlementRule.customPeriods || []); else this.settlementRule.customPeriods = []; if (value !== '固定天数周期结算') this.settlementRule.cycleDays = ''; },
handleCycleTypeChange(value) { if (value !== '固定截单日') this.settlementRule.billCutoffDay = ''; if (value === '自定义多周期') this.settlementRule.customPeriods = normalizeCustomPeriods(this.settlementRule.customPeriods || []); else this.settlementRule.customPeriods = []; },
attachmentUrl(row = {}) { return row.url || row.link || row.fileUrl || row.downloadUrl || row.domain || ''; },
attachmentName(row = {}) { return row.originalName || row.name || row.fileName || '附件'; },
attachmentExtension(row = {}) { const source = String(this.attachmentName(row) || this.attachmentUrl(row)).split('?')[0]; const index = source.lastIndexOf('.'); return index > -1 ? source.slice(index + 1).toLowerCase() : ''; },
isAttachmentImage(row) { return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(this.attachmentExtension(row)); },
previewAttachment(row, rows = this.attachments) { const url = this.attachmentUrl(row); if (!url) { this.$message.warning('附件地址为空,无法预览'); return; } if (this.isAttachmentImage(row)) { this.imagePreviewUrls = (rows || []).filter(item => this.isAttachmentImage(item) && this.attachmentUrl(item)).map(item => this.attachmentUrl(item)); this.imagePreviewIndex = Math.max(this.imagePreviewUrls.indexOf(url), 0); this.imagePreviewVisible = true; return; } this.previewFile = { name: this.attachmentName(row), url, mimeType: row.mimeType || row.contentType || '' }; this.documentPreviewVisible = true; },
handleAttachmentUploadToLocation({ location, rows = [] }) {
if (!rows.length) return;
if (this.isSettlementRuleChange) {
if (location === '变更材料') {
this.changeMaterials = [...(this.changeMaterials || []), ...rows];
}
return;
}
if (isContractSealMaterialLocation(location)) {
this.contractFileRows = [...(this.contractFileRows || []), ...rows];
return;
}
if (isContractSupportingAttachmentLocation(location)) {
this.attachments = [...(this.attachments || []), ...rows];
return;
}
if (location === '变更材料') {
this.changeMaterials = [...(this.changeMaterials || []), ...rows];
}
},
handlePreviewUnsupported() { this.$message.warning('当前文件暂不支持在线预览'); },
handlePreviewError() { this.$message.error('附件预览失败'); },
formatFileSize(value) { const size = Number(value || 0); return size > 1024 * 1024 ? `${(size / 1024 / 1024).toFixed(2)}MB` : `${Math.max(1, Math.ceil(size / 1024))}KB`; },
addPaymentRatioRow() { this.paymentRatioRows.push({ paymentTerm: `第${this.paymentRatioRows.length + 1}笔`, ratioLimit: '', remark: '' }); },
customPeriodEndDayOptions(row) { const startDay = Number(row?.startDay); if (!Number.isFinite(startDay) || startDay < 1) return this.billCutoffDayOptions; return Array.from({ length: 31 - startDay + 1 }, (_, index) => ({ label: `${startDay + index}日`, value: startDay + index })); },
handleCustomPeriodStartDayChange(index, value) { const periods = normalizeCustomPeriods(this.settlementRule.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.settlementRule.customPeriods = periods; },
handleCustomPeriodEndDayChange(index, value) { const periods = normalizeCustomPeriods(this.settlementRule.customPeriods || []); const row = periods[index]; if (!row) return; row.endDay = value || ''; this.settlementRule.customPeriods = periods; },
addCustomPeriodRow() { const periods = normalizeCustomPeriods(this.settlementRule.customPeriods || []); periods.push({ startDay: '', endDay: '' }); this.settlementRule.customPeriods = periods; },
removeCustomPeriodRow(index) { if (index <= 0) return; const periods = [...(this.settlementRule.customPeriods || [])]; periods.splice(index, 1); this.settlementRule.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 && startDay !== Number(rows[index - 1].endDay) + 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; },
async submit() {
if (this.form.changeType === '终止合同') {
const contractId = this.form.contractId || this.form.id;
await contractApi.terminate(contractId, this.form.changeReason);
this.$message.success('合同已终止');
this.goBack();
return;
}
await this.$refs.formRef.validate();
if (!this.isSettlementRuleChange) {
const contractNo = String(this.form.contractNo || '').trim();
if (!contractNo) {
this.$message.warning('请输入合同编号');
return;
}
if (contractNo.length > 100) {
this.$message.warning('合同编号不能超过100个字符');
return;
}
this.form.contractNo = contractNo;
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;
}
}
if (this.settlementConfigTab === 'pre') this.preSettlementConfig = { ...this.settlementRule };
else this.formalSettlementConfig = { ...this.settlementRule };
if (
!this.validateSettlementRule(this.preSettlementConfig, '预结算配置') ||
!this.validateSettlementRule(this.formalSettlementConfig, '正式结算配置')
) {
return;
}
const payload = this.buildChangePayload();
if (this.isContractChangeModule) {
await changeApi.submit(payload);
await submitMkApprovalFlow({
bizType: 'contract-change',
bizId: this.form.id,
subjectName: this.form.contractName || '',
approvalStatus: this.form.approvalStatus || '',
mkEnabled: await loadMkEnabled().catch(() => false),
});
sessionStorage.setItem(contractChangeListRefreshKey, '1');
this.$message.success('变更已提交');
this.$router.push(contractChangeListRoute);
return;
}
await contractApi.submitChange(payload);
await submitMkApprovalFlow({
bizType: 'contract-manage',
bizId: this.form.id,
subjectName: this.form.contractName || '',
approvalStatus: this.form.approvalStatus || 'change_rejected',
mkEnabled: await loadMkEnabled().catch(() => false),
});
this.$message.success('变更已提交');
this.$router.back();
},
},
};
</script>
<style scoped lang="scss">
.contract-change-page { min-height: 100%; padding-bottom: 72px; background: #f5f6fa; }
.contract-change-page :deep(.basic-container__card) { border: 0; background: transparent; box-shadow: none; }
.contract-change-form { background: #f5f6fa; }
.change-section { padding: 20px 24px; background: #fff; border-bottom: 1px solid #eff1f7; margin-top: 12px; }
.contract-basic-section { margin-bottom: 12px; padding: 14px 16px 16px; overflow: hidden; background: #fff; border-bottom: 0; border-radius: 6px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); }
.contract-basic-section > .dialog-section-title { margin-bottom: 20px; color: #303133; }
.contract-basic-section__grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); column-gap: 48px; }
.contract-basic-section__change-type { grid-column: 1 / -1; }
.contract-basic-section__remark {
width: 100%;
margin-bottom: 23px !important;
&:last-child {
margin-bottom: 0 !important;
}
}
.contract-basic-section__date-range { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 8px; width: 360px; max-width: 100%; }
.contract-basic-section__date-range span { color: #606266; }
// 风险客商提示条(基本信息备注后):橙黄背景 + 红色文字,与合同新增页一致
.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-basic-section__date-range :deep(.el-date-editor) { width: auto; min-width: 0; }
.contract-basic-section :deep(.el-form-item) { align-items: center; }
.contract-basic-section :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; }
.contract-basic-section :deep(.el-form-item__content) { min-width: 0; }
.contract-basic-section :deep(.el-input),
.contract-basic-section :deep(.el-select),
.contract-basic-section :deep(.el-date-editor) { width: 360px; max-width: 100%; }
.contract-basic-section :deep(.el-input-number) {
width: 360px;
max-width: 100%;
.el-input__inner {
text-align: left;
}
}
.dialog-section-title { margin-bottom: 18px; font-size: 16px; font-weight: 600; }
.dialog-section-title::before { display: inline-block; width: 4px; height: 16px; margin-right: 8px; vertical-align: -2px; background: #409eff; content: ''; }
.section-head { display: flex; align-items: center; justify-content: space-between; }
.change-table { margin: 16px 0; }
.ratio-tip { margin-bottom: 8px; color: #f56c6c; }
.unit { margin-left: 8px; }
.attachment-upload { display: flex; justify-content: flex-start; margin-top: 12px; }
.attachment-upload .vehicle-attachment-upload { width: auto; }
.attachment-upload .el-upload { display: inline-flex; }
.settlement-switch { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
// 问号提示图标紧靠「开启」一侧:清掉开启 radio 自带的右间距,图标与「关闭」之间留大间距
.settlement-switch :deep(.el-radio:first-child) { margin-right: 0; }
.settlement-switch-tip { margin: 0 32px 0 8px; color: #a8abb2; cursor: help; font-size: 14px; }
.settlement-form { display: grid; grid-template-columns: repeat(3, minmax(240px, 1fr)); gap: 8px 28px; }
.change-reason-section { margin: 20px 0; }
.change-reason-section__input {
width: 100%;
:deep(.el-textarea__inner) {
border-color: #f56c6c;
box-shadow: 0 0 0 1px #f56c6c inset;
}
:deep(.el-textarea__inner:hover),
:deep(.el-textarea__inner:focus) {
border-color: #f56c6c;
box-shadow: 0 0 0 1px #f56c6c inset;
}
}
.page-footer {
position: fixed;
right: 0;
bottom: 0;
left: 230px;
z-index: 20;
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;
}
}
:global(.avue--collapse .page-footer) {
left: 60px;
}
:global(.avue-layout--horizontal .page-footer) {
left: 0;
}
@media (max-width: 1200px) { .contract-basic-section__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 32px; } }
@media (max-width: 768px) { .contract-basic-section__grid { grid-template-columns: 1fr; } }
</style>