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>
2739 lines
96 KiB
Vue
2739 lines
96 KiB
Vue
<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>
|