Files
shanxia2.0/web-admin/src/views/crm/contract/form.vue
T
zlwuandCursor 594e4ce38e feat(crm): sync contract number date segment with sign date
Align standard contract nos on create/update (keep seq, bump on collision),
preview the change in contract forms, and sync name parentheses.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 11:46:11 +08:00

2739 lines
96 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>
<div class="page-container contract-form-page">
<section class="panel">
<div class="panel-top panel-top-with-actions">
<h2 class="title-bar-heading">{{ formTitle }}</h2>
<el-button class="btn-ghost" @click="handleBack">
<el-icon><Back /></el-icon>返回
</el-button>
</div>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<!-- 合同信息(融合基本信息+合同条款+产品明细) -->
<div>
<el-row :gutter="20">
<el-col :span="6">
<el-form-item label="合同编号" prop="contractNo">
<el-input v-model="form.contractNo" placeholder="系统自动生成,格式:SX/X-JT20250926002" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="合同名称" prop="contractName">
<el-input
v-model="form.contractName"
placeholder="可不填,自动生成:供方简称-需方简称(合同编号)"
maxlength="100"
show-word-limit
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="合同类型" prop="contractType" required>
<el-select v-model="form.contractType" placeholder="请选择合同类型" style="width: 100%" @change="handleContractTypeChange">
<el-option v-for="dict in dictStore.getOptions('crm_contract_type')" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="合同模版" prop="contractTemplate" required>
<el-select v-model="form.contractTemplate" placeholder="请选择合同模版" style="width: 100%" @change="handleContractTemplateChange">
<el-option v-for="dict in dictStore.getOptions('crm_contract_template')" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<!-- 第一行:客户名称、关联报价单、联系人 -->
<el-row :gutter="20">
<el-col :span="6">
<el-form-item label="客户名称" prop="customerId" required>
<el-input v-model="form.customerName" placeholder="请选择客户" readonly>
<template #append>
<el-button text icon="Search" @click="handleSelectCustomer" />
</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="关联报价单" prop="quoteId">
<el-input v-model="form.quoteNo" placeholder="请选择报价单(可选)" readonly clearable>
<template #append>
<el-button text icon="Search" @click="handleSelectQuote" />
</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="联系人" prop="contactId" required>
<el-input v-model="form.contactsName" placeholder="请选择联系人" maxlength="50">
<template #append>
<el-button text icon="Search" @click="handleSelectContact" :disabled="!form.customerId" />
</template>
</el-input>
</el-form-item>
</el-col>
</el-row>
<!-- 第二行:联系电话、签订日期、负责人、合同金额 -->
<el-row :gutter="20">
<el-col :span="6">
<el-form-item label="联系电话" prop="contactsPhone">
<el-input v-model="form.contactsPhone" placeholder="从客户/联系人带出" maxlength="20" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="签订日期" prop="signDate">
<el-date-picker
v-model="form.signDate"
type="date"
placeholder="选择签订日期"
value-format="YYYY-MM-DD"
style="width: 100%"
@change="handleSignDateChange"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="负责人" prop="ownerUserId" required>
<el-input v-model="form.ownerUserName" placeholder="请选择负责人" readonly>
<template #append>
<el-button text icon="Search" @click="handleSelectUser" />
</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="合同金额" prop="amount">
<el-input-number v-model="form.amount" :precision="2" :min="0" placeholder="可选填,留空则从产品明细自动计算" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<!-- 第三行:合同期限(单独一行) -->
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="合同期限" prop="startDate" required>
<el-date-picker
v-model="contractDateRange"
type="daterange"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
@change="handleDateRangeChange"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="供方主体" prop="supplierId" required>
<el-select v-model="form.supplierId" placeholder="请选择供方主体" clearable style="width: 100%" @change="handleSupplierChange">
<el-option v-for="supplier in supplierList" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="结算方式" prop="settlementMethod">
<el-select v-model="form.settlementMethod" placeholder="请选择结算方式" clearable style="width: 100%">
<el-option v-for="dict in dictStore.getOptions('crm_settlement_method')" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<!-- 合同条款部分 -->
<el-divider content-position="left">
供方信息
<el-button type="primary" size="small" @click="handleSwapParties" style="margin-left: 10px; min-width: 120px">
<el-icon><Sort /></el-icon>
供需方调换
</el-button>
</el-divider>
<el-row :gutter="20">
<el-col :span="6">
<el-form-item label="单位名称(章)" prop="partyA">
<el-input v-model="form.partyA" placeholder="请输入单位名称" maxlength="100" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="单位地址" prop="partyAAddress">
<el-input v-model="form.partyAAddress" placeholder="请输入单位地址" maxlength="200" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="法定代表人" prop="partyALegalPerson">
<el-input v-model="form.partyALegalPerson" placeholder="请输入法定代表人" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="代理人" prop="partyAAgent">
<el-input v-model="form.partyAAgent" placeholder="请输入代理人(可选)" maxlength="50" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="6">
<el-form-item label="电话/传真" prop="partyAPhoneFax">
<el-input v-model="form.partyAPhoneFax" placeholder="请输入电话/传真" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="开户银行" prop="partyABank">
<el-input v-model="form.partyABank" placeholder="请输入开户银行" maxlength="100" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="账号" prop="partyAAccount">
<el-input v-model="form.partyAAccount" placeholder="请输入银行账号" maxlength="50" />
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left">需方信息</el-divider>
<el-row :gutter="20">
<el-col :span="6">
<el-form-item label="单位名称(章)" prop="partyB">
<el-input v-model="form.partyB" placeholder="从客户开票信息带出" maxlength="200" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="单位地址" prop="partyBAddress">
<el-input v-model="form.partyBAddress" placeholder="从客户开票信息带出" maxlength="500" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="法定代表人" prop="partyBLegalPerson">
<el-input v-model="form.partyBLegalPerson" placeholder="从客户开票信息带出" maxlength="100" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="代理人" prop="partyBAgent">
<el-input v-model="form.partyBAgent" placeholder="从客户开票信息带出(可选)" maxlength="100" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="6">
<el-form-item label="电话/传真" prop="partyBPhoneFax">
<el-input v-model="form.partyBPhoneFax" placeholder="从客户开票信息带出" maxlength="100" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="开户银行" prop="partyBBank">
<el-input v-model="form.partyBBank" placeholder="从客户开票信息带出" maxlength="200" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="账号" prop="partyBAccount">
<el-input v-model="form.partyBAccount" placeholder="从客户开票信息带出" maxlength="100" />
</el-form-item>
</el-col>
<!-- 联系人字段已隐藏 -->
<!-- <el-col :span="6">
<el-form-item label="联系人" prop="partyBContact">
<el-input v-model="form.partyBContact" placeholder="从客户资料带出" maxlength="50" />
</el-form-item>
</el-col> -->
</el-row>
<!-- 联系电话字段已隐藏 -->
<!-- <el-row :gutter="20">
<el-col :span="12">
<el-form-item label="联系电话" prop="partyBMobile">
<el-input v-model="form.partyBMobile" placeholder="从客户资料带出" maxlength="20" />
</el-form-item>
</el-col>
</el-row> -->
<!-- 条款内容部分已隐藏 -->
<!-- <el-divider content-position="left">条款内容</el-divider>
<el-form-item label="付款条款" prop="paymentTerms">
<el-input v-model="form.paymentTerms" type="textarea" :rows="4" placeholder="请输入付款条款" maxlength="1000" show-word-limit />
</el-form-item>
<el-form-item label="交付条款" prop="deliveryTerms">
<el-input v-model="form.deliveryTerms" type="textarea" :rows="4" placeholder="请输入交付条款" maxlength="1000" show-word-limit />
</el-form-item>
<el-form-item label="合同条款" prop="contractTerms">
<el-input v-model="form.contractTerms" type="textarea" :rows="6" placeholder="请输入合同条款" maxlength="2000" show-word-limit />
</el-form-item> -->
<!-- 产品明细部分 -->
<el-divider content-position="left">产品明细</el-divider>
<el-button type="primary" icon="Plus" @click="handleAddProduct" class="mb-3">添加产品</el-button>
<el-button type="danger" icon="Delete" @click="handleRemoveSelectedProducts" class="mb-3 ml-2" :disabled="selectedProducts.length === 0"
>删除选中</el-button
>
<el-table :data="form.products" border @selection-change="handleProductSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="序号" type="index" width="60" align="center" />
<el-table-column label="产品名称" prop="productName" min-width="160" :show-overflow-tooltip="true">
<template #default="scope">
<el-input v-model="scope.row.productName" placeholder="请输入产品名称" clearable size="small" readonly />
</template>
</el-table-column>
<el-table-column label="型号/规格" prop="productModel" min-width="130" :show-overflow-tooltip="true">
<template #default="scope">
<el-input v-model="scope.row.productModel" placeholder="请输入型号/规格" clearable size="small" readonly />
</template>
</el-table-column>
<el-table-column label="产品分类" prop="categoryName" min-width="110" :show-overflow-tooltip="true" />
<el-table-column label="税率(%)" prop="taxRate" width="110">
<template #default="scope">
<el-input v-model="scope.row.taxRate" placeholder="税率" @input="handleTaxRateChange(scope.row)" clearable size="small" />
</template>
</el-table-column>
<el-table-column label="单位" prop="unit" width="100" align="center">
<template #default="scope">
<el-select
v-model="scope.row.unit"
placeholder="请选择单位"
clearable
size="small"
style="width: 100%"
@change="handleUnitChange(scope.row)"
>
<el-option v-for="dict in dictStore.getOptions('crm_product_unit')" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</template>
</el-table-column>
<el-table-column prop="quantity" width="110">
<template #header> <span style="color: #f56c6c">*</span> 数量</template>
<template #default="scope">
<el-input v-model="scope.row.quantity" placeholder="数量" @input="handleQuantityChange(scope.row)" clearable size="small" />
</template>
</el-table-column>
<el-table-column prop="price" width="130">
<template #header>
<span v-if="isFrameworkContract">含税单价/元</span>
<span v-else><span style="color: #f56c6c">*</span> 含税单价/元</span>
</template>
<template #default="scope">
<span v-if="isFrameworkContract" class="text-muted">随行就市</span>
<el-input v-else v-model="scope.row.price" placeholder="含税单价" @input="handlePriceChange(scope.row)" clearable size="small" />
</template>
</el-table-column>
<el-table-column prop="subtotal" width="130" align="right">
<template #header>
<span v-if="isFrameworkContract">含税总价/元</span>
<span v-else><span style="color: #f56c6c">*</span> 含税总价/元</span>
</template>
<template #default="scope">
<span v-if="isFrameworkContract" class="text-muted">随行就市</span>
<span v-else class="text-success font-weight-bold">¥{{ parseFloat(scope.row.subtotal || 0).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column label="税费/元" prop="taxAmount" width="120" align="right">
<template #default="scope">
<span v-if="isFrameworkContract" class="text-muted">随行就市</span>
<span v-else>¥{{ parseFloat(scope.row.taxAmount || 0).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column prop="packageSpec" min-width="150">
<template #header> <span style="color: #f56c6c">*</span> 包装规格</template>
<template #default="scope">
<el-select
v-model="scope.row.packageSpec"
placeholder="请选择包装规格"
filterable
allow-create
default-first-option
clearable
size="small"
style="width: 100%"
@change="(val) => handlePackageSpecChange(scope.row, val)"
>
<el-option v-for="dict in dictStore.getOptions('crm_product_gg')" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</template>
</el-table-column>
<el-table-column prop="deliveryMethod" min-width="150">
<template #header> <span style="color: #f56c6c">*</span> 送货方式</template>
<template #default="scope">
<el-select v-model="scope.row.deliveryMethod" placeholder="请选择送货方式" clearable size="small" style="width: 100%">
<el-option v-for="dict in dictStore.getOptions('crm_shfs')" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="160">
<template #default="scope">
<el-input
v-model="scope.row.remark"
type="textarea"
:rows="2"
placeholder="请输入备注"
maxlength="500"
show-word-limit
clearable
size="small"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="scope">
<el-button link type="danger" icon="Delete" @click="handleRemoveProduct(scope.$index)" />
</template>
</el-table-column>
</el-table>
<template v-if="form.contractType === 'framework'">
<el-divider content-position="left">框架合同调价区间</el-divider>
<p class="text-muted mb-3" style="font-size: 13px">
按产品维护生效日期区间与含税单价(可选);若填写区间则须完整(日期、单价、调价函)。合同总金额不与此联动,仍为 0。
</p>
<div v-for="(prod, pIdx) in form.products" :key="'fw-tier-' + pIdx" class="framework-tier-block mb-4">
<div class="fw-tier-title mb-2">{{ prod.productName || '产品' + (pIdx + 1) }}</div>
<el-button type="primary" link class="mb-2 fw-tier-letter-add-btn" icon="Plus" @click="addPriceTierRow(pIdx)">调价函</el-button>
<el-table :data="prod.priceTiers || []" border size="small">
<el-table-column type="index" label="#" width="50" align="center" />
<el-table-column label="开始日期" min-width="150">
<template #default="scope">
<el-date-picker v-model="scope.row.startDate" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" style="width: 100%" />
</template>
</el-table-column>
<el-table-column label="结束日期" min-width="150">
<template #default="scope">
<el-date-picker v-model="scope.row.endDate" type="date" value-format="YYYY-MM-DD" placeholder="结束日期" style="width: 100%" />
</template>
</el-table-column>
<el-table-column label="含税单价/元" width="140">
<template #default="scope">
<el-input v-model="scope.row.price" placeholder="含税单价" clearable size="small" />
</template>
</el-table-column>
<el-table-column label="调价函" min-width="300">
<template #default="scope">
<el-upload
:action="uploadAction"
:headers="uploadHeaders"
:data="uploadDataTierLetter"
list-type="picture-card"
:limit="20"
multiple
accept="image/*"
:file-list="scope.row._letterFiles || []"
:on-success="(res, file) => handleTierLetterUploadSuccess(res, file, pIdx, scope.$index)"
:on-remove="(file) => handleTierLetterRemove(file, pIdx, scope.$index)"
class="tier-letter-upload"
>
<el-icon><Plus /></el-icon>
</el-upload>
<div class="text-muted" style="font-size: 11px; margin-top: 4px">支持多张图片</div>
</template>
</el-table-column>
<el-table-column label="操作" width="70" align="center" fixed="right">
<template #default="scope">
<el-button link type="danger" icon="Delete" @click="removePriceTierRow(pIdx, scope.$index)" />
</template>
</el-table-column>
</el-table>
</div>
</template>
<el-divider />
<div class="total-section">
<el-row :gutter="20">
<el-col :span="16"></el-col>
<el-col :span="8">
<div class="total-item">
<span class="total-label">合计金额:</span>
<span class="total-value primary"
>¥{{ parseFloat(form.amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}</span
>
</div>
</el-col>
</el-row>
</div>
<!-- 附件上传部分 -->
<el-divider content-position="left">附件上传</el-divider>
<el-form-item label="附件" prop="attachmentFiles" :required="form.contractTemplate === 'customer_template'">
<div class="attachment-upload-container">
<el-upload
ref="uploadRef"
:file-list="attachmentList"
class="upload-demo"
drag
list-type="picture-card"
:on-preview="handleFilePreview"
:action="uploadAction"
:headers="uploadHeaders"
:data="uploadData"
:auto-upload="true"
:before-upload="handleBeforeUpload"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
:on-remove="handleRemoveAttachmentConfirm"
:on-change="handleFileChange"
multiple
:limit="10"
:show-file-list="true"
accept="image/*,.pdf,.doc,.docx"
>
<template #default>
<div class="el-upload-dragger1">
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
</div>
</template>
<template #file="{ file }">
<div class="upload-file-card">
<!-- 缩略图区域 -->
<div class="upload-file-thumb">
<ImagePreview v-if="isImageFile(file)" :src="file.ossId || file.url" width="100%" height="100%" :isOssId="!!file.ossId" />
<div v-else class="upload-file-placeholder">
<el-icon><Document /></el-icon>
<div class="file-ext">{{ getFileExtension(file.name) }}</div>
</div>
</div>
<!-- 文件名称 -->
<div class="upload-file-name" :title="file.name">
{{ getShortFileName(file.name) }}
</div>
<!-- 分类选择 -->
<div class="upload-file-category" v-if="file.status === 'success'">
<el-select
:model-value="file.category || 'other'"
@update:model-value="(val) => (file.category = val)"
placeholder="请选择分类"
size="small"
style="width: 100%"
>
<el-option label="单章附件" value="single_seal" />
<el-option label="双章附件" value="double_seal" />
<el-option label="其他附件" value="other" />
</el-select>
</div>
<!-- 操作按钮 -->
<div class="upload-file-card-actions">
<el-button-group size="small">
<el-button type="primary" @click.stop="handleFilePreview(file)" title="预览">
<el-icon><zoom-in /></el-icon>
</el-button>
<el-button type="danger" @click.stop="handleRemoveAttachmentConfirm(file)" title="删除">
<el-icon><Delete /></el-icon>
</el-button>
</el-button-group>
</div>
</div>
</template>
<template #tip>
<div class="el-upload__tip">支持上传图片、PDF、Word文档等,最多10个文件</div>
</template>
</el-upload>
</div>
</el-form-item>
</div>
<!-- 底部操作按钮 -->
<el-form-item class="footer-buttons">
<el-button
type="info"
@click="handleSaveDraft"
:loading="saveDraftLoading"
>保存草稿</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">提交审批</el-button>
<el-button @click="handleBack">取消</el-button>
</el-form-item>
</el-form>
</section>
<!-- 产品选择器 -->
<ProductSelector
:visible="productSelectorVisible"
:supplier-id="form.supplierId"
@update:visible="productSelectorVisible = $event"
@confirm="handleProductSelected"
/>
<!-- 客户选择器 -->
<CustomerSelector :visible="customerSelectorVisible" @update:visible="customerSelectorVisible = $event" @confirm="handleCustomerConfirm" />
<!-- 联系人选择器 -->
<ContactSelector
:visible="contactSelectorVisible"
@update:visible="contactSelectorVisible = $event"
:customerId="form.customerId"
@confirm="handleContactConfirm"
/>
<!-- 报价单选择器 -->
<QuoteSelector
:visible="quoteSelectorVisible"
@update:visible="quoteSelectorVisible = $event"
:customerId="form.customerId"
@confirm="handleQuoteConfirm"
/>
<!-- 用户选择器 -->
<UserSelector :visible="userSelectorVisible" @update:visible="userSelectorVisible = $event" @confirm="handleUserConfirm" />
<!-- 审批弹窗 -->
<ApprovalDialog ref="approvalDialogRef" @success="handleApprovalSuccess" />
</div>
</template>
<script setup lang="ts" name="ContractForm">
import { ref, reactive, computed, onMounted, nextTick } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import type { FormInstance } from 'element-plus';
import { ElMessage } from 'element-plus';
import {
getContract,
addContract,
updateContract,
type ContractForm,
type ContractProductForm,
type ContractProductPriceTierForm
} from '@/api/crm/contract/index';
import { useDictStore } from '@/store/modules/dict';
import { useUserStore } from '@/store/modules/user';
import { $modal } from '@/utils/modal';
import { UploadFilled, Document, Loading, Download, Delete, ZoomIn, Sort, Plus, Back } from '@element-plus/icons-vue';
import ImagePreview from '@/components/ImagePreview/index.vue';
import {
buildUploadFileListFromField,
downloadCrmAttachment,
fillAttachmentInfoCache
} from '@/utils/crm-attachment';
import { getOpportunity } from '@/api/crm/opportunity';
import { getQuote } from '@/api/crm/quote';
import ProductSelector from './components/ProductSelector.vue';
import CustomerSelector from '@/components/CustomerSelector.vue';
import ContactSelector from '@/components/ContactSelector.vue';
import QuoteSelector from '@/components/QuoteSelector.vue';
import UserSelector from '@/components/UserSelector.vue';
import ApprovalDialog from './components/ApprovalDialog.vue';
import { omitEmptyExceptPartyFields } from './partyFields';
import { getContactByCustomerId } from '@/api/crm/customer';
import type { CustomerVO, ContactVO } from '@/api/crm/customer/types';
import { getDefaultInvoiceInfoByCustomerId } from '@/api/crm/invoiceInfo';
import { listAllSupplierInfo, type SupplierInfoVO } from '@/api/crm/supplierInfo';
import { removeProductsNotAllowedByHazard } from '@/utils/crm-supplier-hazard';
interface UserVO {
userId: string | number
nickName: string
userName?: string
}
// 扩展表单类型,添加显示用字段
type ExtendedContractForm = ContractForm & {
customerName?: string;
opportunityName?: string;
quoteNo?: string;
contactsName?: string;
contactsPhone?: string;
ownerUserName?: string;
supplierId?: number;
settlementMethod?: string;
templateContent?: string; // 富文本模板内容(用于记录原始值)
};
// 扩展产品类型,添加显示字段
type PriceTierRow = ContractProductPriceTierForm & { _letterFiles?: any[] };
type ExtendedContractProductForm = ContractProductForm & {
productName?: string;
productModel?: string;
unit?: string;
unitPrice?: number;
subtotal?: number;
priceTiers?: PriceTierRow[];
};
const router = useRouter();
const route = useRoute();
const dictStore = useDictStore();
const userStore = useUserStore();
// 表单标题
const formTitle = computed(() => {
return form.value.id ? '编辑合同' : '新增合同';
});
// 合同期限日期范围
const contractDateRange = ref<[string, string]>([]);
// 表单数据
const form = ref<ExtendedContractForm>({
id: undefined,
contractNo: undefined,
contractName: '',
contractType: 'sale',
contractTemplate: 'shanxia', // 默认山峡模版
customerId: 0,
customerName: '',
contactId: undefined,
opportunityId: undefined,
opportunityName: '',
quoteId: undefined,
quoteNo: '',
contactsName: '',
contactsPhone: '',
signDate: '',
startDate: '',
endDate: '',
ownerUserId: undefined,
ownerUserName: '',
amount: 0,
supplierId: undefined,
settlementMethod: undefined,
partyA: '',
partyAAddress: '',
partyAContact: '',
partyAMobile: '',
partyALegalPerson: '',
partyAAgent: '',
partyAPhoneFax: '',
partyABank: '',
partyAAccount: '',
partyB: '',
partyBAddress: '',
partyBContact: '',
partyBMobile: '',
partyBLegalPerson: '',
partyBAgent: '',
partyBPhoneFax: '',
partyBBank: '',
partyBAccount: '',
paymentTerms: '',
deliveryTerms: '',
contractTerms: '',
products: [] as ExtendedContractProductForm[],
remark: ''
});
// 供需方是否已调换的标志
const isPartiesSwapped = ref(false);
// 记录原始的模板内容(用于检测是否被意外删除)
const originalTemplateContent = ref<string>('');
// 供方信息列表
const supplierList = ref<SupplierInfoVO[]>([]);
// 表单校验规则
const rules = ref({
contractType: [{ required: true, message: '合同类型不能为空', trigger: 'change' }],
customerId: [{ required: true, message: '客户不能为空', trigger: 'change' }],
contactId: [{ required: true, message: '联系人不能为空', trigger: 'change' }],
signDate: [{ required: true, message: '签订日期不能为空', trigger: 'change' }],
ownerUserId: [{ required: true, message: '负责人不能为空', trigger: 'change' }],
supplierId: [{ required: true, message: '供方主体不能为空', trigger: 'change' }],
startDate: [
{
validator: (rule: any, value: any, callback: any) => {
if (!contractDateRange.value || contractDateRange.value.length !== 2 || !contractDateRange.value[0] || !contractDateRange.value[1]) {
callback(new Error('合同期限不能为空'));
} else {
callback();
}
},
trigger: ['change', 'blur']
}
],
// 附件字段不需要表单验证,在保存和提交时单独检查
attachmentFiles: []
});
// 附件列表
const attachmentList = ref<any[]>([]);
// 上传配置
const uploadAction = computed(() => '/web/admin/attachment/upload');
const uploadHeaders = computed(() => ({
Authorization: `Bearer ${userStore.accessToken || ''}`
}));
const uploadData = computed(() => {
return {
bizType: 'crm_contract_attachment'
};
});
const uploadDataTierLetter = computed(() => ({
bizType: 'crm_contract_price_letter'
}));
const isFrameworkContract = computed(() => form.value.contractType === 'framework');
// 加载状态
const saveDraftLoading = ref(false);
const submitLoading = ref(false);
// 组件引用
const formRef = ref<FormInstance>();
const approvalDialogRef = ref();
// 选择器状态
const customerSelectorVisible = ref(false);
const contactSelectorVisible = ref(false);
const opportunitySelectorVisible = ref(false);
const quoteSelectorVisible = ref(false);
const userSelectorVisible = ref(false);
const productSelectorVisible = ref(false);
// 选中的产品列表
const selectedProducts = ref<ExtendedContractProductForm[]>([]);
// 临时接口定义
interface OpportunityVO {
id: number;
opportunityName: string;
customerId?: number;
customerName?: string;
}
interface QuoteVO {
id: number;
quoteNo: string;
quoteTitle: string;
customerId?: number;
customerName?: string;
contactId?: number;
contactName?: string;
productList?: any[];
quoteDate?: string;
}
/** 加载合同详情 */
const loadContractDetail = async (id: number | string) => {
const res = await getContract(id);
const contractData = res.data;
// 基础字段赋值
Object.assign(form.value, contractData);
// 记录原始的模板内容(用于检测是否被意外删除)
originalTemplateContent.value = contractData.templateContent || '';
// 字段映射和处理
// 1. 联系人字段映射(后端用contactName/contactMobile,前端用contactsName/contactsPhone)
if (contractData.contactName) {
form.value.contactsName = contractData.contactName;
}
if (contractData.contactMobile) {
form.value.contactsPhone = contractData.contactMobile;
}
// 2. 负责人字段回显(确保ownerUserName被正确设置)
if (contractData.ownerUserName) {
form.value.ownerUserName = contractData.ownerUserName;
}
if (contractData.ownerUserId) {
form.value.ownerUserId = contractData.ownerUserId;
}
// 3. 加载关联商机名称
if (contractData.opportunityId) {
try {
const oppRes = await getOpportunity(contractData.opportunityId);
if (oppRes.data) {
form.value.opportunityName = oppRes.data.opportunityName;
}
} catch (error) {
console.warn('加载商机信息失败:', error);
}
}
// 4. 加载关联报价单编号
if (contractData.quoteId) {
try {
const quoteRes = await getQuote(contractData.quoteId);
if (quoteRes.data) {
form.value.quoteNo = quoteRes.data.quoteNo;
}
} catch (error) {
console.warn('加载报价单信息失败:', error);
}
}
// 5. 处理产品列表字段映射
if (contractData.products && Array.isArray(contractData.products)) {
form.value.products = contractData.products.map((product: any) => ({
...product,
productModel: product.model || '',
isToxic: Number(product.isToxic || 0),
isExplosive: Number(product.isExplosive || 0),
priceTiers: (product.priceTiers || []).map((t: any) => {
let ids: string[] = [];
if (Array.isArray(t.letterOssIds) && t.letterOssIds.length) {
ids = t.letterOssIds.map((id: any) => String(id));
} else if (t.letterOssId != null && t.letterOssId !== '') {
ids = [String(t.letterOssId)];
}
return {
...t,
letterOssIds: ids,
_letterFiles: [] as any[]
};
}),
taxRate: product.taxRate || 6,
taxAmount: 0,
packageSpec: product.packageSpec || '',
deliveryMethod: product.deliveryMethod || ''
}));
if (contractData.contractType === 'framework') {
form.value.products.forEach((product: any) => {
product.price = 0;
product.subtotal = 0;
product.taxAmount = 0;
});
form.value.amount = 0;
await enrichFrameworkTierLetterFiles();
} else {
form.value.products.forEach((product: any) => {
calculateProductAmount(product);
});
}
}
// 6. 设置合同期限日期范围
if (form.value.startDate && form.value.endDate) {
contractDateRange.value = [form.value.startDate, form.value.endDate];
}
// 7. 加载附件列表
if (form.value.attachmentFiles) {
await handleAttachmentData();
}
};
/** 选择客户 */
const handleSelectCustomer = () => {
customerSelectorVisible.value = true;
};
/** 客户选择确认 */
const handleCustomerConfirm = async (customer: CustomerVO) => {
if (customer) {
form.value.customerId = customer.id;
form.value.customerName = customer.customerName;
// 拼接完整地址(省市区+详细地址)
const addressParts: string[] = [];
if (customer.province) addressParts.push(customer.province);
if (customer.city) addressParts.push(customer.city);
if (customer.district) addressParts.push(customer.district);
if (customer.address) addressParts.push(customer.address);
const fullAddress = addressParts.join('');
// 清空关联的联系人、商机和报价单
form.value.contactId = undefined;
form.value.opportunityId = undefined;
form.value.opportunityName = '';
form.value.quoteId = undefined;
form.value.quoteNo = '';
// 自动获取客户的默认开票信息并填充到需方信息(乙方)
try {
// 注意:不要使用 Number() 转换,避免大整数精度丢失
const invoiceRes = await getDefaultInvoiceInfoByCustomerId(customer.id);
console.log('========== 开票信息调试 ==========');
console.log('客户ID(原始):', customer.id, '类型:', typeof customer.id);
console.log('1. API返回完整数据:', JSON.stringify(invoiceRes, null, 2));
// 判断是否有开票信息数据(data可能为null或空对象)
const invoiceData = invoiceRes?.data;
console.log('2. 开票信息data:', invoiceData);
if (invoiceData && invoiceData.companyName) {
console.log('3. 开票信息字段详情:');
console.log(' - companyName:', invoiceData.companyName);
console.log(' - companyAddress:', invoiceData.companyAddress);
console.log(' - legalRepresentative:', invoiceData.legalRepresentative);
console.log(' - agent:', invoiceData.agent);
console.log(' - phoneFax:', invoiceData.phoneFax);
console.log(' - bankName:', invoiceData.bankName);
console.log(' - bankAccount:', invoiceData.bankAccount);
// 根据调换状态决定填充到哪个区域
if (isPartiesSwapped.value) {
// 已调换:客户名称对应供方信息区域(partyA)
form.value.partyA = invoiceData.companyName || '';
form.value.partyAAddress = invoiceData.companyAddress || fullAddress || '';
form.value.partyALegalPerson = invoiceData.legalRepresentative || '';
form.value.partyAAgent = invoiceData.agent || '';
form.value.partyAPhoneFax = invoiceData.phoneFax || '';
form.value.partyABank = invoiceData.bankName || '';
form.value.partyAAccount = invoiceData.bankAccount || '';
console.log('4. 填充后的供方信息(已调换):');
console.log(' - partyA:', form.value.partyA);
console.log(' - partyAAddress:', form.value.partyAAddress);
console.log(' - partyALegalPerson:', form.value.partyALegalPerson);
console.log(' - partyAAgent:', form.value.partyAAgent);
console.log(' - partyAPhoneFax:', form.value.partyAPhoneFax);
console.log(' - partyABank:', form.value.partyABank);
console.log(' - partyAAccount:', form.value.partyAAccount);
console.log('✅ 已自动填充客户开票信息到供方信息(已调换)');
} else {
// 未调换:客户名称对应需方信息区域(partyB)
form.value.partyB = invoiceData.companyName || '';
form.value.partyBAddress = invoiceData.companyAddress || fullAddress || '';
form.value.partyBLegalPerson = invoiceData.legalRepresentative || '';
form.value.partyBAgent = invoiceData.agent || '';
form.value.partyBPhoneFax = invoiceData.phoneFax || '';
form.value.partyBBank = invoiceData.bankName || '';
form.value.partyBAccount = invoiceData.bankAccount || '';
console.log('4. 填充后的需方信息:');
console.log(' - partyB:', form.value.partyB);
console.log(' - partyBAddress:', form.value.partyBAddress);
console.log(' - partyBLegalPerson:', form.value.partyBLegalPerson);
console.log(' - partyBAgent:', form.value.partyBAgent);
console.log(' - partyBPhoneFax:', form.value.partyBPhoneFax);
console.log(' - partyBBank:', form.value.partyBBank);
console.log(' - partyBAccount:', form.value.partyBAccount);
console.log('✅ 已自动填充客户开票信息到需方信息');
}
} else {
// 如果没有开票信息,使用客户基本信息
console.log('⚠️ 该客户暂无开票信息,使用客户基本信息');
if (isPartiesSwapped.value) {
// 已调换:客户名称对应供方信息区域(partyA)
form.value.partyA = customer.customerName;
form.value.partyAAddress = fullAddress;
form.value.partyALegalPerson = '';
form.value.partyAAgent = '';
form.value.partyAPhoneFax = customer.telephone || '';
form.value.partyABank = '';
form.value.partyAAccount = '';
} else {
// 未调换:客户名称对应需方信息区域(partyB)
form.value.partyB = customer.customerName;
form.value.partyBAddress = fullAddress;
form.value.partyBLegalPerson = '';
form.value.partyBAgent = '';
form.value.partyBPhoneFax = customer.telephone || '';
form.value.partyBBank = '';
form.value.partyBAccount = '';
}
}
console.log('====================================');
} catch (error) {
console.error('❌ 获取客户开票信息失败:', error);
// 如果获取开票信息失败,使用客户基本信息
if (isPartiesSwapped.value) {
// 已调换:客户名称对应供方信息区域(partyA)
form.value.partyA = customer.customerName;
form.value.partyAAddress = fullAddress;
form.value.partyALegalPerson = '';
form.value.partyAAgent = '';
form.value.partyAPhoneFax = customer.telephone || '';
form.value.partyABank = '';
form.value.partyAAccount = '';
} else {
// 未调换:客户名称对应需方信息区域(partyB)
form.value.partyB = customer.customerName;
form.value.partyBAddress = fullAddress;
form.value.partyBLegalPerson = '';
form.value.partyBAgent = '';
form.value.partyBPhoneFax = customer.telephone || '';
form.value.partyBBank = '';
form.value.partyBAccount = '';
}
}
// 初始化联系人字段(后面会自动填充主联系人)
if (isPartiesSwapped.value) {
// 已调换:客户名称对应供方信息区域(partyA)
form.value.partyAContact = '';
form.value.partyAMobile = customer.mobile || customer.telephone || '';
} else {
// 未调换:客户名称对应需方信息区域(partyB)
form.value.partyBContact = '';
form.value.partyBMobile = customer.mobile || customer.telephone || '';
}
form.value.contactsName = '';
form.value.contactsPhone = customer.mobile || customer.telephone || '';
// 自动获取客户的主联系人信息
try {
// 注意:不要使用 Number() 转换,避免大整数精度丢失
const res = await getContactByCustomerId(customer.id);
if (res.data && res.data.length > 0) {
// 优先选择主联系人,如果没有就选第一个
const primaryContact = res.data.find((contact: any) => contact.isPrimary === 1);
const contact = primaryContact || res.data[0];
// 填充联系人信息到基本信息
form.value.contactId = contact.id;
form.value.contactsName = contact.contactName;
form.value.contactsPhone = contact.mobile || contact.telephone || '';
// 根据调换状态同步到对应的联系人字段
if (isPartiesSwapped.value) {
// 已调换:客户名称对应供方信息区域(partyA)
form.value.partyAContact = contact.contactName;
form.value.partyAMobile = contact.mobile || contact.telephone || '';
} else {
// 未调换:客户名称对应需方信息区域(partyB)
form.value.partyBContact = contact.contactName;
form.value.partyBMobile = contact.mobile || contact.telephone || '';
}
}
} catch (error) {
console.error('获取客户联系人失败:', error);
// 即使获取联系人失败,也不影响客户选择流程
}
// 更新合同名称中的客户简称
updateContractNameByCustomer(customer.shortName);
}
};
/** 选择联系人 */
const handleSelectContact = () => {
if (!form.value.customerId) {
$modal.msgWarning('请先选择客户');
return;
}
contactSelectorVisible.value = true;
};
/** 联系人选择确认 */
const handleContactConfirm = (contact: ContactVO) => {
if (contact) {
form.value.contactId = contact.id;
form.value.contactsName = contact.contactName;
form.value.contactsPhone = contact.mobile || contact.telephone || '';
// 根据调换状态同步到对应的联系人字段
if (isPartiesSwapped.value) {
// 已调换:客户名称对应供方信息区域(partyA)
form.value.partyAContact = contact.contactName;
form.value.partyAMobile = contact.mobile || contact.telephone || '';
} else {
// 未调换:客户名称对应需方信息区域(partyB)
form.value.partyBContact = contact.contactName;
form.value.partyBMobile = contact.mobile || contact.telephone || '';
}
// 触发联系人字段的验证
formRef.value?.validateField('contactId');
}
};
/** 选择商机 */
const handleSelectOpportunity = () => {
opportunitySelectorVisible.value = true;
};
/** 商机选择确认 */
const handleOpportunityConfirm = (opportunity: OpportunityVO) => {
if (opportunity) {
form.value.opportunityId = opportunity.id;
form.value.opportunityName = opportunity.opportunityName;
// 如果商机有客户信息,自动填充客户
if (opportunity.customerId && !form.value.customerId) {
form.value.customerId = opportunity.customerId;
form.value.customerName = opportunity.customerName || '';
}
}
};
/** 选择报价单 */
const handleSelectQuote = () => {
quoteSelectorVisible.value = true;
};
/** 报价单选择确认 */
const handleQuoteConfirm = (quote: QuoteVO) => {
if (quote) {
form.value.quoteId = quote.id;
form.value.quoteNo = quote.quoteNo;
// 如果报价单有报价日期,自动带出到签订日期
if (quote.quoteDate) {
form.value.signDate = quote.quoteDate;
// 同步更新合同期限
handleSignDateChange(quote.quoteDate);
}
// 自动带出客户信息
if (quote.customerId && !form.value.customerId) {
form.value.customerId = quote.customerId;
form.value.customerName = quote.customerName || '';
}
// 自动带出联系人信息
if (quote.contactId) {
form.value.contactId = quote.contactId;
form.value.contactsName = quote.contactName || '';
// 根据调换状态同步到对应的联系人字段
if (isPartiesSwapped.value) {
// 已调换:客户名称对应供方信息区域(partyA)
form.value.partyAContact = quote.contactName || '';
} else {
// 未调换:客户名称对应需方信息区域(partyB)
form.value.partyBContact = quote.contactName || '';
}
}
// 自动带出产品明细
if (quote.productList && quote.productList.length > 0) {
$modal
.confirm('是否要从报价单中带入产品明细?')
.then(() => {
const isFw = form.value.contractType === 'framework';
form.value.products = quote.productList.map((item: any) => ({
productId: item.productId,
productName: item.productName,
productModel: item.model,
categoryId: item.categoryId,
categoryName: item.categoryName || '',
unit: item.unit || '个',
quantity: item.quantity || 1,
price: isFw ? 0 : item.price || 0,
unitPrice: isFw ? 0 : item.price || 0,
taxRate: item.taxRate || 13, // 税率(%)
taxAmount: isFw ? 0 : item.taxAmount || 0, // 税费/元
discount: item.discount || 0,
subtotal: isFw ? 0 : item.subtotal || (item.quantity || 1) * (item.price || 0) * (1 - (item.discount || 0) / 100),
packageSpec: item.packageSpec || '', // 包装规格
deliveryMethod: item.deliveryMethod || '', // 送货方式
deliveryDate: '',
description: item.description || '',
remark: item.remark || '',
sort: item.sort, // 排序号,保持与报价单一致
priceTiers: isFw ? [] : undefined
}));
calculateTotalAmount();
})
.catch(() => {
// 用户取消
});
}
}
};
/** 选择负责人 */
const handleSelectUser = () => {
userSelectorVisible.value = true;
};
/** 用户选择确认 */
const handleUserConfirm = (user: UserVO) => {
if (user) {
form.value.ownerUserId = user.userId;
form.value.ownerUserName = user.nickName;
}
};
/** 供方主体变化处理 */
const handleSupplierChange = async (supplierId: number | undefined) => {
if (!supplierId) {
// 清空供方主体时,根据调换状态清空对应的信息区域
if (isPartiesSwapped.value) {
// 已调换:供方主体对应需方信息区域(partyB)
form.value.partyB = '';
form.value.partyBAddress = '';
form.value.partyBLegalPerson = '';
form.value.partyBAgent = '';
form.value.partyBPhoneFax = '';
form.value.partyBBank = '';
form.value.partyBAccount = '';
} else {
// 未调换:供方主体对应供方信息区域(partyA)
form.value.partyA = '';
form.value.partyAAddress = '';
form.value.partyALegalPerson = '';
form.value.partyAAgent = '';
form.value.partyAPhoneFax = '';
form.value.partyABank = '';
form.value.partyAAccount = '';
}
return;
}
const supplier = supplierList.value.find((s) => s.id === supplierId);
if (supplier) {
// 根据调换状态决定填充到哪个区域
if (isPartiesSwapped.value) {
// 已调换:供方主体对应需方信息区域(partyB)
form.value.partyB = supplier.supplierName || '';
form.value.partyBAddress = supplier.supplierAddress || '';
form.value.partyBLegalPerson = supplier.legalPerson || '';
form.value.partyBAgent = supplier.agent || '';
form.value.partyBPhoneFax = supplier.phoneFax || '';
form.value.partyBBank = supplier.bankName || '';
form.value.partyBAccount = supplier.bankAccount || '';
} else {
// 未调换:供方主体对应供方信息区域(partyA)
form.value.partyA = supplier.supplierName || '';
form.value.partyAAddress = supplier.supplierAddress || '';
form.value.partyALegalPerson = supplier.legalPerson || '';
form.value.partyAAgent = supplier.agent || '';
form.value.partyAPhoneFax = supplier.phoneFax || '';
form.value.partyABank = supplier.bankName || '';
form.value.partyAAccount = supplier.bankAccount || '';
}
// 更新合同编号中的供方编码
updateContractNoBySupplier(supplier.supplierCode);
// 更新合同名称中的供方简称
updateContractNameBySupplier(supplier.shortName);
const allowToxic = Number((supplier as any).allowToxic || 0);
const allowExplosive = Number((supplier as any).allowExplosive || 0);
const { kept, removedNames } = removeProductsNotAllowedByHazard(
form.value.products || [],
allowToxic,
allowExplosive
);
form.value.products = kept;
if (removedNames.length) {
$modal.msgWarning(`当前主体不支持以下产品,已自动移除:${removedNames.join('、')}`);
form.value.products.forEach((p) => calculateProductAmount(p));
calculateTotalAmount();
}
}
};
/**
* 更新合同编号中的供方编码
* 合同编号格式:{供方编码}/X-JT + yyyyMMdd + 3位流水号
* 例如:SX/X-JT20250926002
*/
const updateContractNoBySupplier = (newSupplierCode: string | undefined) => {
if (!form.value.contractNo || !newSupplierCode) {
return;
}
// 匹配格式:{供方编码}/X-JT + 日期 + 流水号
// 例如:SX/X-JT20250926002
const contractNoPattern = /^([A-Z]+)\/X-JT(\d{8})(\d{3})$/;
const match = form.value.contractNo.match(contractNoPattern);
if (match) {
// 替换供方编码部分
const oldSupplierCode = match[1];
const date = match[2];
const serialNum = match[3];
form.value.contractNo = `${newSupplierCode}/X-JT${date}${serialNum}`;
} else {
// 如果格式不匹配,尝试简单替换(兼容其他格式)
// 查找第一个 "/X-JT" 之前的部分作为供方编码
const index = form.value.contractNo.indexOf('/X-JT');
if (index > 0) {
const oldSupplierCode = form.value.contractNo.substring(0, index);
form.value.contractNo = form.value.contractNo.replace(oldSupplierCode, newSupplierCode);
}
}
};
const CONTRACT_NO_STD_RE = /^([A-Z]+)\/X-JT(\d{8})(\d{3})$/;
/** 签订日变更时:标准编号只换日期段,保留序号;并同步名称括号内编号 */
const syncContractNoDateFromSignDate = (signDateVal: string | null) => {
if (!signDateVal || !form.value.contractNo) return;
const m = String(form.value.contractNo).match(CONTRACT_NO_STD_RE);
if (!m) return;
const dateStr = signDateVal.replace(/-/g, '');
if (dateStr.length !== 8) return;
if (m[2] === dateStr) return;
const nextNo = `${m[1]}/X-JT${dateStr}${m[3]}`;
form.value.contractNo = nextNo;
if (form.value.contractName) {
form.value.contractName = form.value.contractName.replace(/[((][^))]*[))]$/, `(${nextNo})`);
}
};
/**
* 更新合同名称中的供方简称
* 统一格式:{供方简称}-{需方简称}({合同编号})
*/
const updateContractNameBySupplier = (newSupplierShortName: string | undefined) => {
if (!form.value.contractName || !newSupplierShortName) {
return;
}
const contractName = form.value.contractName;
// 提取合同编号(在括号中,支持中文和英文括号)
const contractNoMatch = contractName.match(/[((](.+?)[))]$/);
const contractNo = contractNoMatch ? contractNoMatch[1] : '';
// 提取合同名称主体部分(去掉括号和合同编号)
const nameBody = contractNoMatch ? contractName.substring(0, contractNoMatch.index).trim() : contractName.trim();
// 常见的供方简称列表(用于识别和替换)
const commonSupplierNames = ['山峡', 'SX'];
// 统一格式:供方简称-需方简称(合同编号)
const parts = nameBody.split('-').map((p) => p.trim());
if (parts.length >= 2) {
parts[0] = newSupplierShortName;
const newNameBody = parts.join('-');
form.value.contractName = contractNo ? `${newNameBody}(${contractNo})` : newNameBody;
} else if (parts.length === 1) {
const isSupplierName = commonSupplierNames.some((name) => parts[0].includes(name));
if (isSupplierName) {
form.value.contractName = contractNo ? `${newSupplierShortName}(${contractNo})` : newSupplierShortName;
} else {
form.value.contractName = contractNo
? `${newSupplierShortName}-${parts[0]}(${contractNo})`
: `${newSupplierShortName}-${parts[0]}`;
}
}
// 如果合同编号已更新,同步更新合同名称中的合同编号
if (contractNo && form.value.contractNo && contractNo !== form.value.contractNo) {
// 替换合同名称中的旧合同编号
form.value.contractName = form.value.contractName.replace(/[((].+?[))]$/, `(${form.value.contractNo})`);
}
};
/**
* 更新合同名称中的客户简称(需方)
* 统一格式:{供方简称}-{需方简称}({合同编号})
*/
const updateContractNameByCustomer = (newCustomerShortName: string | undefined) => {
if (!form.value.contractName || !newCustomerShortName) {
return;
}
const contractName = form.value.contractName;
// 提取合同编号(在括号中,支持中文和英文括号)
const contractNoMatch = contractName.match(/[((](.+?)[))]$/);
const contractNo = contractNoMatch ? contractNoMatch[1] : '';
// 提取合同名称主体部分(去掉括号和合同编号)
const nameBody = contractNoMatch ? contractName.substring(0, contractNoMatch.index).trim() : contractName.trim();
// 常见的供方简称列表(用于识别,避免误替换)
const commonSupplierNames = ['山峡', 'SX'];
// 统一格式:供方简称-需方简称(合同编号)
const parts = nameBody.split('-').map((p) => p.trim());
if (parts.length >= 2) {
parts[1] = newCustomerShortName;
const newNameBody = parts.join('-');
form.value.contractName = contractNo ? `${newNameBody}(${contractNo})` : newNameBody;
} else if (parts.length === 1) {
const isSupplierName = commonSupplierNames.some((name) => parts[0].includes(name));
if (isSupplierName) {
form.value.contractName = contractNo
? `${parts[0]}-${newCustomerShortName}(${contractNo})`
: `${parts[0]}-${newCustomerShortName}`;
} else {
form.value.contractName = contractNo ? `${newCustomerShortName}(${contractNo})` : newCustomerShortName;
}
}
// 如果合同编号已更新,同步更新合同名称中的合同编号
if (contractNo && form.value.contractNo && contractNo !== form.value.contractNo) {
// 替换合同名称中的旧合同编号
form.value.contractName = form.value.contractName.replace(/[((].+?[))]$/, `(${form.value.contractNo})`);
}
};
/** 供需方调换 */
const handleSwapParties = async () => {
// 保存当前的供方主体ID(调换时保持不变)
const currentSupplierId = form.value.supplierId;
// 交换供方和需方的所有信息(不包括supplierId)
const temp = {
partyA: form.value.partyA,
partyAAddress: form.value.partyAAddress,
partyAContact: form.value.partyAContact,
partyAMobile: form.value.partyAMobile,
partyALegalPerson: form.value.partyALegalPerson,
partyAAgent: form.value.partyAAgent,
partyAPhoneFax: form.value.partyAPhoneFax,
partyABank: form.value.partyABank,
partyAAccount: form.value.partyAAccount
};
form.value.partyA = form.value.partyB;
form.value.partyAAddress = form.value.partyBAddress;
form.value.partyAContact = form.value.partyBContact;
form.value.partyAMobile = form.value.partyBMobile;
form.value.partyALegalPerson = form.value.partyBLegalPerson;
form.value.partyAAgent = form.value.partyBAgent;
form.value.partyAPhoneFax = form.value.partyBPhoneFax;
form.value.partyABank = form.value.partyBBank;
form.value.partyAAccount = form.value.partyBAccount;
form.value.partyB = temp.partyA;
form.value.partyBAddress = temp.partyAAddress;
form.value.partyBContact = temp.partyAContact;
form.value.partyBMobile = temp.partyAMobile;
form.value.partyBLegalPerson = temp.partyALegalPerson;
form.value.partyBAgent = temp.partyAAgent;
form.value.partyBPhoneFax = temp.partyAPhoneFax;
form.value.partyBBank = temp.partyABank;
form.value.partyBAccount = temp.partyAAccount;
// 切换调换状态
isPartiesSwapped.value = !isPartiesSwapped.value;
// 如果存在供方主体,根据新的调换状态重新填充对应的信息区域
if (currentSupplierId) {
await handleSupplierChange(currentSupplierId);
}
$modal.msgSuccess('供需方信息已调换');
};
/** 合同类型变化处理 */
const handleContractTypeChange = (value: string) => {
if (value === 'framework') {
form.value.amount = 0;
form.value.products?.forEach((p) => {
p.price = 0;
p.subtotal = 0;
p.taxAmount = 0;
if (!p.priceTiers) {
p.priceTiers = [];
}
});
}
// 当选择采购合同时,自动调换供需方
if (value === 'purchase' && !isPartiesSwapped.value) {
$modal
.confirm('检测到您选择了采购合同,系统将自动调换供需方信息,是否继续?')
.then(() => {
handleSwapParties();
})
.catch(() => {
// 用户取消,不调换
});
}
};
/** 合同模版变化处理 */
const handleContractTemplateChange = (value: string) => {
// 当选择客户模版时,提醒用户必须上传合同附件
if (value === 'customer_template') {
ElMessage({
message: '使用客户模版时,必须上传合同附件!',
type: 'warning',
duration: 3000 // 3秒后自动消失
});
}
};
/** 合同期限日期范围变化 */
const handleDateRangeChange = (val: [string, string] | null) => {
if (val && val.length === 2) {
form.value.startDate = val[0];
form.value.endDate = val[1];
} else {
form.value.startDate = '';
form.value.endDate = '';
contractDateRange.value = [] as any;
}
// 触发合同期限字段的验证
nextTick(() => {
formRef.value?.validateField('startDate');
});
};
/** 签订日期变化处理 - 自动设置合同期限为签订日期加30天 */
const handleSignDateChange = (val: string | null) => {
if (val) {
// 将签订日期转换为Date对象
const signDate = new Date(val);
// 计算结束日期:签订日期 + 30天
const endDate = new Date(signDate);
endDate.setDate(endDate.getDate() + 30);
// 格式化日期为 YYYY-MM-DD
const formatDate = (date: Date): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
// 更新合同期限日期范围
const startDateStr = formatDate(signDate);
const endDateStr = formatDate(endDate);
contractDateRange.value = [startDateStr, endDateStr];
form.value.startDate = startDateStr;
form.value.endDate = endDateStr;
} else {
// 清空合同期限
contractDateRange.value = [] as any;
form.value.startDate = '';
form.value.endDate = '';
}
syncContractNoDateFromSignDate(val);
// 触发合同期限字段的验证
nextTick(() => {
formRef.value?.validateField('startDate');
});
};
/** 添加产品 */
const handleAddProduct = () => {
if (!form.value.supplierId) {
$modal.msgWarning('请先选择交易主体');
return;
}
productSelectorVisible.value = true;
};
/** 产品选择确认 */
const handleProductSelected = (products: any[]) => {
const isFw = form.value.contractType === 'framework';
products.forEach((product) => {
// 允许重复添加相同产品:直接追加到 products 列表
form.value.products.push({
productId: product.id,
productName: product.productName,
productModel: product.model || '', // 型号/规格
categoryId: product.categoryId,
categoryName: product.categoryName || '',
unit: product.unit || '个',
quantity: 1,
price: isFw ? 0 : product.price || 0,
taxRate: normalizeTaxRateDisplay(product.taxRate, 13), // 税率:整数不显示小数位
taxAmount: 0,
subtotal: isFw ? 0 : product.price || 0,
packageSpec: '',
deliveryMethod: '',
description: product.description || '',
remark: '',
isToxic: Number(product.isToxic || 0),
isExplosive: Number(product.isExplosive || 0),
priceTiers: isFw ? [] : undefined
});
});
// 计算所有产品金额
form.value.products.forEach((p) => {
calculateProductAmount(p);
});
calculateTotalAmount();
};
/** 规范化税率展示:整数不带小数,有小数才保留 */
const normalizeTaxRateDisplay = (value: any, defaultValue = 0): number => {
if (value === null || value === undefined || value === '') {
return defaultValue;
}
const num = Number(value);
if (Number.isNaN(num)) {
return defaultValue;
}
return Number.isInteger(num) ? num : parseFloat(num.toString());
};
/** 产品选择变化 */
const handleProductSelectionChange = (selection: ExtendedContractProductForm[]) => {
selectedProducts.value = selection;
};
/** 删除选中的产品 */
const handleRemoveSelectedProducts = () => {
if (selectedProducts.value.length === 0) {
return;
}
$modal
.confirm(`确认删除选中的 ${selectedProducts.value.length} 个产品吗?`)
.then(() => {
// 从products数组中删除选中的产品
selectedProducts.value.forEach((selectedProduct) => {
const index = form.value.products.findIndex((p) => p === selectedProduct);
if (index > -1) {
form.value.products.splice(index, 1);
}
});
// 清空选中状态
selectedProducts.value = [];
// 重新计算总金额
calculateTotalAmount();
$modal.msgSuccess('删除成功');
})
.catch(() => {
// 用户取消删除
});
};
/** 删除产品 */
const handleRemoveProduct = (index: number) => {
form.value.products.splice(index, 1);
calculateTotalAmount();
};
/** 单价变化 */
const handlePriceChange = (row: ExtendedContractProductForm) => {
calculateProductAmount(row);
calculateTotalAmount();
};
/** 数量变化 */
const handleQuantityChange = (row: ExtendedContractProductForm) => {
calculateProductAmount(row);
calculateTotalAmount();
};
/** 税率变化 */
const handleTaxRateChange = (row: ExtendedContractProductForm) => {
calculateProductAmount(row);
calculateTotalAmount();
};
/** 单位变化 */
const handleUnitChange = (row: ExtendedContractProductForm) => {
// 单位变化通常不需要重新计算金额,但可以在这里添加其他逻辑
// 例如:记录变更日志、验证单位有效性等
};
/** 包装规格变更处理(支持自由输入并写入字典) */
const handlePackageSpecChange = (product: ExtendedContractProductForm, value: string) => {
product.packageSpec = value;
};
/** 计算产品金额(参考报价单逻辑) */
const calculateProductAmount = (product: ExtendedContractProductForm) => {
if (form.value.contractType === 'framework') {
product.price = 0;
product.subtotal = 0;
product.taxAmount = 0;
return;
}
// 获取输入值并转换为数字
const price = parseFloat(product.price?.toString() || '0');
const quantity = parseFloat(product.quantity?.toString() || '0');
const taxRate = parseFloat(product.taxRate?.toString() || '0');
// 1. 含税总价 = 数量 × 含税单价(四舍五入保留2位小数)
const subtotal = Math.round(price * quantity * 100) / 100;
product.subtotal = subtotal;
// 2. 税费 = (含税总价 / (1+税率)) × 税率(四舍五入保留2位小数)
// 注意:税率是百分比,需要除以100转换为小数
const taxRateDecimal = taxRate / 100;
let taxAmount = 0;
if (taxRate > 0 && subtotal > 0) {
const taxBase = subtotal / (1 + taxRateDecimal); // 不含税金额
taxAmount = Math.round(taxBase * taxRateDecimal * 100) / 100;
}
product.taxAmount = taxAmount;
};
/** 计算合计金额 */
const calculateTotalAmount = () => {
if (form.value.contractType === 'framework') {
form.value.amount = 0;
return;
}
let total = 0;
form.value.products.forEach((item) => {
const quantity = item.quantity || 0;
const price = item.price || 0;
const discount = item.discount || 0;
const subtotal = quantity * price * (1 - discount / 100);
(item as ExtendedContractProductForm).subtotal = subtotal;
total += subtotal;
});
form.value.amount = total;
};
/** 框架合同:批量解析调价函 URL,用于上传组件缩略图回显 */
const enrichFrameworkTierLetterFiles = async () => {
const products = form.value.products || [];
const idSet = new Set<string>();
products.forEach((p: any) => {
(p.priceTiers || []).forEach((t: any) => {
(t.letterOssIds || []).forEach((id: string) => idSet.add(String(id)));
});
});
if (idSet.size === 0) return;
const cache = new Map<string, { name: string; url: string }>();
fillAttachmentInfoCache([...idSet], cache);
products.forEach((p: any) => {
(p.priceTiers || []).forEach((t: any) => {
const ids = t.letterOssIds || [];
t._letterFiles = ids.map((ossId: string, i: number) => {
const info = cache.get(String(ossId));
return {
name: info?.name || `调价函${i + 1}`,
ossId: String(ossId),
url: info?.url || ossId,
uid: `tier-prev-${t.id ?? 'n'}-${i}-${ossId}`,
status: 'success'
};
});
});
});
};
/** 组装提交/保存的产品明细(含框架合同调价区间) */
const buildProductsPayload = () => {
const list = form.value.products || [];
return list.map((product: any, idx: number) => {
const base: any = {
contractId: product.contractId || form.value.id,
productId: product.productId,
productName: product.productName,
productNo: product.productNo,
categoryId: product.categoryId,
categoryName: product.categoryName,
model: product.productModel || product.model,
unit: product.unit,
quantity: product.quantity,
price: product.price,
discount: product.discount,
taxRate: product.taxRate,
taxAmount: product.taxAmount,
subtotal: product.subtotal,
deliveryDate: product.deliveryDate,
description: product.description,
packageSpec: product.packageSpec,
deliveryMethod: product.deliveryMethod,
sort: product.sort ?? idx + 1,
remark: product.remark
};
if (product.id) {
base.id = product.id;
}
if (form.value.contractType === 'framework') {
base.price = 0;
base.subtotal = 0;
base.taxAmount = 0;
base.priceTiers = (product.priceTiers || [])
.filter(
(t: any) => t.startDate && t.endDate && t.price != null && t.price !== '' && Array.isArray(t.letterOssIds) && t.letterOssIds.length > 0
)
.map((t: any, i: number) => ({
id: t.id,
startDate: t.startDate,
endDate: t.endDate,
price: Number(t.price),
letterOssIds: (t.letterOssIds || []).map((id: string) => String(id).trim()).filter(Boolean),
sort: t.sort ?? i + 1
}));
}
return base;
});
};
const addPriceTierRow = (pIdx: number) => {
const p = form.value.products[pIdx];
if (!p) return;
if (!p.priceTiers) {
p.priceTiers = [];
}
p.priceTiers.push({ startDate: '', endDate: '', price: undefined, letterOssIds: [], _letterFiles: [] });
};
const removePriceTierRow = (pIdx: number, tIdx: number) => {
const p = form.value.products[pIdx];
if (!p?.priceTiers) return;
p.priceTiers.splice(tIdx, 1);
};
const handleTierLetterUploadSuccess = (res: any, file: any, pIdx: number, tIdx: number) => {
const payload = res?.data ?? res;
const url = payload?.url || payload?.fileUrl || payload;
if (!url) {
ElMessage.error(res?.msg || '上传失败');
return;
}
const p = form.value.products[pIdx];
const tiers = p?.priceTiers;
if (!tiers || !tiers[tIdx]) return;
const row = tiers[tIdx];
const oid = String(url);
row.letterOssIds = row.letterOssIds || [];
row.letterOssIds.push(oid);
row._letterFiles = row._letterFiles || [];
row._letterFiles.push({
name: file.name || `调价函${row._letterFiles.length + 1}`,
ossId: oid,
url: oid,
uid: file.uid,
status: 'success'
});
};
const handleTierLetterRemove = (file: any, pIdx: number, tIdx: number) => {
const p = form.value.products[pIdx];
const tiers = p?.priceTiers;
if (!tiers || !tiers[tIdx]) return;
const row = tiers[tIdx];
const uid = file.uid;
const idx = (row._letterFiles || []).findIndex((f: any) => f.uid === uid);
if (idx >= 0) {
const removed = row._letterFiles[idx];
row._letterFiles.splice(idx, 1);
if (removed?.ossId) {
row.letterOssIds = (row.letterOssIds || []).filter((id: string) => String(id) !== String(removed.ossId));
}
}
};
/** 处理附件数据(支持分类) */
const handleAttachmentData = async () => {
attachmentList.value = buildUploadFileListFromField(form.value.attachmentFiles);
};
/** 文件类型判断 */
const isImageFile = (file: any) => {
const imageTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/webp'];
// 1. 检查 raw.type(新上传的文件)
if (file.raw?.type && imageTypes.includes(file.raw.type)) {
return true;
}
// 2. 检查文件名扩展名
if (file.name && /\.(jpg|jpeg|png|gif|bmp|webp)$/i.test(file.name)) {
return true;
}
// 3. 检查 URL 扩展名(回显的文件)
if (file.url && /\.(jpg|jpeg|png|gif|bmp|webp)(\?|$)/i.test(file.url)) {
return true;
}
return false;
};
/** 获取文件OSS ID */
const getFileOssId = (file: any) => {
return file.ossId || '';
};
/** 获取文件扩展名 */
const getFileExt = (fileName: string) => {
const ext = fileName.split('.').pop()?.toUpperCase();
return ext || 'FILE';
};
/** 获取缩写的文件名 */
const getShortFileName = (fileName: string) => {
if (!fileName) return '';
const lastDotIndex = fileName.lastIndexOf('.');
if (lastDotIndex === -1) {
return fileName.length > 12 ? fileName.substring(0, 12) + '...' : fileName;
}
const name = fileName.substring(0, lastDotIndex);
const ext = fileName.substring(lastDotIndex);
if (name.length <= 8) {
return fileName;
}
return name.substring(0, 8) + '...' + ext;
};
/** 获取文件扩展名 */
const getFileExtension = (fileName: string) => {
if (!fileName) return '';
const lastDotIndex = fileName.lastIndexOf('.');
if (lastDotIndex === -1) return '';
return fileName.substring(lastDotIndex + 1).toUpperCase();
};
/** 上传前检查 */
const handleBeforeUpload = (file: any) => {
const isValidType = /\.(jpg|jpeg|png|gif|bmp|webp|pdf|doc|docx|txt|mp4|avi|mov)$/i.test(file.name);
if (!isValidType) {
$modal.msgError('上传文件格式不正确!');
return false;
}
return true;
};
/** 文件上传成功 */
const handleUploadSuccess = (response: any, file: any) => {
const payload = response?.data ?? response;
const url = payload?.url || payload?.fileUrl || payload;
if (url) {
file.url = url;
file.ossId = url;
file.status = 'success';
file.category = 'other';
$modal.msgSuccess('文件上传成功');
} else {
file.status = 'error';
$modal.msgError(response?.msg || '文件上传失败');
}
};
/** 文件上传失败 */
const handleUploadError = (error: any) => {
console.error('文件上传失败:', error);
$modal.msgError('文件上传失败');
};
/** 文件预览 */
const handleFilePreview = (file: any) => {
if (isImageFile(file)) {
window.open(file.url || file.ossId, '_blank');
} else {
handleDownloadAttachment(file);
}
};
/** 文件变更 */
const handleFileChange = (file: any, fileList: any) => {
attachmentList.value = fileList;
};
/** 删除附件确认 */
const handleRemoveAttachmentConfirm = (file: any) => {
$modal
.confirm('确定要删除这个文件吗?')
.then(() => {
handleRemoveAttachment(file);
})
.catch(() => {
// 取消删除
});
};
/** 删除附件 */
const handleRemoveAttachment = (file: any) => {
const index = attachmentList.value.findIndex((item: any) => item.uid === file.uid);
if (index > -1) {
attachmentList.value.splice(index, 1);
$modal.msgSuccess('文件删除成功');
}
};
/** 下载附件 */
const handleDownloadAttachment = async (file: any) => {
const fileName = file.name || 'attachment';
if (file.raw) {
const link = document.createElement('a');
link.href = URL.createObjectURL(file.raw);
link.download = fileName;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
return;
}
if (!file.ossId && !file.url) {
$modal.msgError('无法获取文件下载地址');
return;
}
try {
await downloadCrmAttachment(file);
} catch (error) {
console.error('下载文件失败:', error);
$modal.msgError('下载文件失败,请稍后重试');
}
};
/** 保存草稿 */
const handleSaveDraft = async () => {
// 保存草稿时只校验客户名称和供方主体
if (!form.value.customerId || form.value.customerId === 0) {
$modal.msgWarning('客户名称不能为空');
return;
}
if (!form.value.supplierId) {
$modal.msgWarning('供方主体不能为空');
return;
}
saveDraftLoading.value = true;
try {
// 处理附件数据 - 收集所有成功上传的文件的ossId和分类
const attachmentsWithCategory = attachmentList.value
.filter((file) => file.status === 'success' && file.ossId)
.map((file) => ({
ossId: file.ossId,
category: file.category || 'other'
}));
const data: any = { ...form.value };
data.status = 'draft';
data.approvalStatus = null; // 草稿状态下,审批状态应该为空
// 只有有附件时才设置 attachmentFiles(使用JSON格式保存分类信息)
if (attachmentsWithCategory.length > 0) {
data.attachmentFiles = JSON.stringify(attachmentsWithCategory);
}
// 清理未填写的字段;供/需方空字符串保留,以便清空落库
omitEmptyExceptPartyFields(data);
// 联系人字段映射(前端contactsName/contactsPhone映射回后端contactName/contactMobile)
if (data.contactsName) {
data.contactName = data.contactsName;
}
if (data.contactsPhone) {
data.contactMobile = data.contactsPhone;
}
// 清理扩展字段(这些字段不应该发送到后端)
delete data.customerName;
delete data.opportunityName;
delete data.quoteNo;
delete data.contactsName;
delete data.contactsPhone;
delete data.ownerUserName;
delete data.templateContent; // 不修改模板内容
// 清理产品列表中的扩展字段并映射回后端字段
if (data.products && Array.isArray(data.products) && data.products.length > 0) {
for (let i = 0; i < data.products.length; i++) {
const product = data.products[i];
const rowNum = i + 1;
if (!product.productName || product.productName.trim() === '') {
$modal.msgWarning(`第${rowNum}行产品的产品名称不能为空`);
return;
}
}
data.products = buildProductsPayload();
} else {
// 如果产品列表为空,删除该字段,避免后端校验
delete data.products;
}
if (form.value.id) {
await updateContract(data);
$modal.msgSuccess('保存成功');
} else {
await addContract(data);
$modal.msgSuccess('保存成功');
}
handleBack();
} finally {
saveDraftLoading.value = false;
}
};
/** 提交审批 */
const handleSubmit = async () => {
try {
await formRef.value?.validate();
} catch (error) {
ElMessage.warning({
message: '请填写完整的必填信息',
duration: 3000
});
return;
}
// 检查合同模版为"客户模版"时,必须上传附件
if (form.value.contractTemplate === 'customer') {
console.log('🔍 检查附件列表:', attachmentList.value);
const hasAttachments = attachmentList.value.some((file) => file.status === 'success' && file.ossId);
console.log('✅ 是否有有效附件:', hasAttachments);
if (!hasAttachments) {
ElMessage.warning({
message: '使用客户模版时,必须上传合同附件',
duration: 3000
});
return;
}
}
const requiresProducts = form.value.contractType === 'sale' || form.value.contractType === 'purchase' || form.value.contractType === 'framework';
const validateProductRowCommon = (product: any, rowNum: number): boolean => {
if (!product.productName || String(product.productName).trim() === '') {
$modal.msgWarning(`第${rowNum}行产品的产品名称不能为空`);
return false;
}
if (!product.quantity || Number(product.quantity) <= 0) {
$modal.msgWarning(`第${rowNum}行产品的数量不能为空且必须大于0`);
return false;
}
if (!product.packageSpec || String(product.packageSpec).trim() === '') {
$modal.msgWarning(`第${rowNum}行产品的包装规格不能为空`);
return false;
}
if (!product.deliveryMethod || String(product.deliveryMethod).trim() === '') {
$modal.msgWarning(`第${rowNum}行产品的送货方式不能为空`);
return false;
}
return true;
};
if (requiresProducts) {
if (!form.value.products || form.value.products.length === 0) {
$modal.msgWarning('销售合同、采购合同或框架合同必须至少添加一个产品');
return;
}
for (let i = 0; i < form.value.products.length; i++) {
const product = form.value.products[i];
const rowNum = i + 1;
if (!validateProductRowCommon(product, rowNum)) {
return;
}
const isFw = form.value.contractType === 'framework';
if (!isFw) {
if (!product.price || Number(product.price) <= 0) {
$modal.msgWarning(`第${rowNum}行产品的含税单价不能为空且必须大于0`);
return;
}
if (!product.subtotal || Number(product.subtotal) <= 0) {
$modal.msgWarning(`第${rowNum}行产品的含税总价不能为空且必须大于0`);
return;
}
} else {
const tiers = product.priceTiers || [];
for (let j = 0; j < tiers.length; j++) {
const t = tiers[j];
const tRow = j + 1;
if (!t.startDate || !t.endDate) {
$modal.msgWarning(`产品「${product.productName}」调价区间第${tRow}行:请选择开始与结束日期`);
return;
}
if (String(t.startDate) > String(t.endDate)) {
$modal.msgWarning(`产品「${product.productName}」调价区间第${tRow}行:开始日期不能晚于结束日期`);
return;
}
const pVal = t.price === null || t.price === '' ? NaN : Number(t.price);
if (Number.isNaN(pVal) || pVal <= 0) {
$modal.msgWarning(`产品「${product.productName}」调价区间第${tRow}行:含税单价必须大于0`);
return;
}
if (!(t.letterOssIds?.length > 0)) {
$modal.msgWarning(`产品「${product.productName}」调价区间第${tRow}行:请上传调价函`);
return;
}
}
}
}
} else if (form.value.products && form.value.products.length > 0) {
for (let i = 0; i < form.value.products.length; i++) {
const product = form.value.products[i];
const rowNum = i + 1;
if (!validateProductRowCommon(product, rowNum)) {
return;
}
if (!product.price || Number(product.price) <= 0) {
$modal.msgWarning(`第${rowNum}行产品的含税单价不能为空且必须大于0`);
return;
}
if (!product.subtotal || Number(product.subtotal) <= 0) {
$modal.msgWarning(`第${rowNum}行产品的含税总价不能为空且必须大于0`);
return;
}
}
}
submitLoading.value = true;
try {
// 处理附件数据 - 收集所有成功上传的文件的ossId和分类
const attachmentsWithCategory = attachmentList.value
.filter((file) => file.status === 'success' && file.ossId)
.map((file) => ({
ossId: file.ossId,
category: file.category || 'other'
}));
const data: any = { ...form.value };
// 只有有附件时才设置 attachmentFiles(使用JSON格式保存分类信息)
if (attachmentsWithCategory.length > 0) {
data.attachmentFiles = JSON.stringify(attachmentsWithCategory);
}
// 清理未填写的字段;供/需方空字符串保留,以便清空落库
omitEmptyExceptPartyFields(data);
// 联系人字段映射(前端contactsName/contactsPhone映射回后端contactName/contactMobile)
if (data.contactsName) {
data.contactName = data.contactsName;
}
if (data.contactsPhone) {
data.contactMobile = data.contactsPhone;
}
// 清理扩展字段(这些字段不应该发送到后端)
delete data.customerName;
delete data.opportunityName;
delete data.quoteNo;
delete data.contactsName;
delete data.contactsPhone;
delete data.ownerUserName;
delete data.templateContent; // 不修改模板内容
// 清理产品列表中的扩展字段并映射回后端字段
if (data.products && Array.isArray(data.products) && data.products.length > 0) {
data.products = buildProductsPayload();
} else {
// 如果产品列表为空,删除该字段,避免后端校验
delete data.products;
}
let contractId: string | number;
if (form.value.id) {
// 编辑时更新合同
await updateContract(data);
contractId = form.value.id;
$modal.msgSuccess('保存成功');
} else {
// 新增时创建合同(addContract 返回 { data: id })
const res = await addContract(data);
contractId = res.data;
if (contractId == null || contractId === '') {
$modal.msgError('保存成功但未返回合同 ID,请从列表重新提交审批');
return;
}
form.value.id = contractId as any;
$modal.msgSuccess('保存成功');
}
// 打开审批弹窗
approvalDialogRef.value?.open(contractId, 'submit');
} finally {
submitLoading.value = false;
}
};
/** 审批成功回调 */
const handleApprovalSuccess = () => {
// 提交审批成功后返回列表页
handleBack();
};
/** 返回 */
const handleBack = () => {
router.back();
};
/** 检查模板内容是否被意外删除 */
const checkTemplateContentDeleted = () => {
// 如果原始有模板内容,但现在表单中没有(或为空),说明模板内容被意外删除了
const hasOriginalContent = originalTemplateContent.value && originalTemplateContent.value.trim().length > 0;
const hasCurrentContent = form.value.templateContent && form.value.templateContent.trim().length > 0;
if (hasOriginalContent && !hasCurrentContent) {
// 原始有模板内容,现在没有了,提示用户
$modal.alert(
'<div style="color: #e6a23c; font-weight: bold;">⚠️ 提醒:合同模板内容已被清除</div>' +
'<div style="margin-top: 10px; line-height: 1.8;">' +
'您在编辑合同表单时,之前的富文本模板内容已被意外清除。<br/>' +
'下次打印合同时,需要重新编辑模板内容。' +
'</div>',
'温馨提示',
{
confirmButtonText: '知道了',
dangerouslyUseHTMLString: true,
type: 'warning'
}
);
}
};
/** 加载供方信息列表 */
const loadSupplierList = async () => {
try {
const res = await listAllSupplierInfo({ status: '0' }); // 只加载正常状态的供方
supplierList.value = res.data || [];
return res.data || [];
} catch (error) {
console.error('加载供方信息列表失败:', error);
return [];
}
};
onMounted(async () => {
// 加载供方信息列表
await loadSupplierList();
const id = route.query.id as string;
if (id) {
loadContractDetail(id); // 保持字符串类型,避免精度丢失
} else {
// 设置默认值
form.value.signDate = new Date().toISOString().split('T')[0];
// 自动设置合同期限:签订日期开始,结束日期为签订日期加30天
handleSignDateChange(form.value.signDate);
// TODO: 设置当前用户为负责人
// 设置默认供方主体为"SX"(根据供方编码匹配)
const defaultSupplier = supplierList.value.find((supplier) => supplier.supplierCode === 'SX');
if (defaultSupplier) {
form.value.supplierId = defaultSupplier.id;
// 触发供方主体变化处理,自动填充供方信息
await handleSupplierChange(defaultSupplier.id);
}
}
});
</script>
<style scoped lang="scss">
.box-card {
margin-top: 10px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
font-size: 18px;
font-weight: bold;
}
.total-section {
margin-top: 20px;
padding: 15px;
background-color: var(--mint-row-hover, #f1f5f9);
border-radius: 8px;
border: 1px solid var(--mint-border, #d9e8e2);
}
.total-item {
display: flex;
justify-content: flex-end;
align-items: center;
margin-bottom: 10px;
font-size: 16px;
&:last-child {
margin-bottom: 0;
}
}
.total-label {
font-weight: bold;
margin-right: 10px;
}
.total-value {
font-size: 18px;
font-weight: bold;
&.primary {
color: #409eff;
}
}
.text-primary {
color: #409eff;
}
.font-weight-bold {
font-weight: bold;
}
/* 附件上传容器样式 */
.attachment-upload-container {
width: 100%;
max-height: calc(100vh - 650px);
min-height: 200px;
overflow-y: auto;
padding-right: 8px;
border: 1px solid #e4e7ed;
border-radius: 6px;
background: #fafafa;
}
/* 附件上传区域内容样式 */
.attachment-upload-container .upload-demo {
padding: 12px;
min-height: 180px;
}
.attachment-upload-container .upload-demo .el-upload {
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: all 0.3s;
}
.attachment-upload-container .upload-demo .el-upload:hover {
border-color: #409eff;
}
.attachment-upload-container .upload-demo .el-upload__tip {
font-size: 12px;
color: #000000;
margin-top: 7px;
}
/* 调整拖拽上传图标与文字在同一行、居中对齐 */
.attachment-upload-container .upload-demo .el-upload-dragger1 {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 15px;
padding: 0px !important;
}
.attachment-upload-container .upload-demo .el-upload-dragger1 .el-icon--upload {
margin: 0;
padding: 0px !important;
}
/* 文件卡片占位封面样式 */
.attachment-upload-container .upload-demo .upload-file-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #1e293b;
font-size: 14px;
}
.attachment-upload-container .upload-demo .upload-file-placeholder .el-icon {
font-size: 28px;
margin-bottom: 4px;
}
.attachment-upload-container .upload-demo .upload-file-placeholder .file-ext {
font-size: 12px;
text-transform: uppercase;
text-align: center;
}
/* 让附件卡片一行展示 5 个 */
.attachment-upload-container :deep(.el-upload-list--picture-card) {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 9px;
margin-top: 12px;
padding-bottom: 16px;
}
/* 确保卡片宽度自适应,增加高度以容纳分类选择框 */
.attachment-upload-container :deep(.el-upload-list--picture-card) .el-upload-list__item {
margin: 0;
height: 220px !important;
width: 100% !important;
}
:deep(.el-upload-dragger) {
border: 0px !important;
padding: 0px !important;
}
:deep(.el-upload-dragger) .el-upload-dragger1 {
border: 0px !important;
padding: 0px !important;
}
/* 文件卡片样式 */
.upload-file-card {
position: relative;
width: 100%;
height: 100%;
border-radius: 6px;
overflow: hidden;
background: #fff;
transition: all 0.3s;
}
.upload-file-card:hover {
}
.upload-file-thumb {
position: relative;
width: 100%;
height: 110px;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.upload-thumb-image {
width: 100%;
height: 100%;
:deep(.el-image) {
width: 100%;
height: 100%;
}
}
.upload-file-loading {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 10;
}
.loading-text {
margin-top: 8px;
font-size: 12px;
color: #606266;
}
.upload-file-name {
padding: 6px 8px;
font-size: 11px;
color: #303133;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: #fafafa;
border-top: 1px solid #f0f0f0;
line-height: 1.2;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
word-break: break-all;
max-width: 100%;
}
.upload-file-category {
padding: 4px 8px;
background: #fafafa;
border-top: 1px solid #f0f0f0;
}
.upload-file-card-actions {
display: flex;
justify-content: center;
align-items: center;
padding: 6px 8px;
background: #fafafa;
border-top: 1px solid #f0f0f0;
transition: all 0.3s;
height: 38px;
}
.upload-file-card-actions .el-button-group {
background: transparent;
border-radius: 4px;
padding: 0;
}
.upload-file-card-actions .el-button {
padding: 4px 8px;
border: 1px solid #e0e0e0;
background: #fff;
font-size: 12px;
margin: 0 2px;
border-radius: 3px;
transition: all 0.3s;
}
.upload-file-card-actions .el-button:hover {
background: #f5f5f5;
border-color: #409eff;
}
.upload-file-card-actions .el-button.is-disabled {
opacity: 0.5;
cursor: not-allowed;
background: #f5f5f5;
}
/* 底部按钮居中样式 */
.footer-buttons {
margin-top: 30px;
text-align: center;
:deep(.el-form-item__content) {
justify-content: center;
}
}
/* 统一表格内输入框和下拉框高度 */
:deep(.el-table .el-input__wrapper) {
height: 32px !important;
min-height: 32px !important;
}
:deep(.el-table .el-select__wrapper) {
height: 32px !important;
min-height: 32px !important;
}
:deep(.el-table .el-input--small .el-input__wrapper) {
height: 32px !important;
min-height: 32px !important;
}
:deep(.el-table .el-select--small .el-select__wrapper) {
height: 32px !important;
min-height: 32px !important;
}
/* 去除输入框后置按钮的边框和背景 */
:deep(.el-input-group__append) {
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
:deep(.el-input-group__append .el-button) {
border: none !important;
background: transparent !important;
box-shadow: none !important;
padding: 0 15px !important;
}
:deep(.el-input-group__append .el-button:hover) {
background: transparent !important;
border: none !important;
}
:deep(.el-input-group__append .el-button:focus) {
background: transparent !important;
border: none !important;
}
/* 框架合同调价函:表格内多图卡片上传 */
.tier-letter-upload :deep(.el-upload-list--picture-card) {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tier-letter-upload :deep(.el-upload--picture-card) {
width: 72px;
height: 72px;
line-height: 72px;
}
.tier-letter-upload :deep(.el-upload-list--picture-card .el-upload-list__item) {
width: 72px;
height: 72px;
margin: 0;
}
/* 框架合同:增加「调价函」行内按钮可点区域宽度 */
.fw-tier-letter-add-btn.el-button--primary.is-link {
min-width: 108px;
padding-left: 12px;
padding-right: 12px;
justify-content: center;
}
</style>