Files
shanxia2.0/web-admin/src/views/crm/contract/components/ContractFormDialog.vue
T
2026-08-23 09:47:38 +08:00

3362 lines
111 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>
<el-dialog
v-model="dialogVisible"
:title="formTitle"
width="100%"
top="20px"
class="dialog-form-layout contract-form-dialog"
destroy-on-close
append-to-body
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
@opened="handleOpened"
>
<div
class="contract-form-body"
v-loading="formLoading"
element-loading-text="正在加载合同数据..."
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="right"
label-width="120px"
class="crm-dialog-form"
:disabled="formLoading"
>
<!-- 合同基础信息(一行 4 列) -->
<div class="contract-basic-info">
<el-form-item label="合同编号" prop="contractNo">
<el-input v-model="form.contractNo" placeholder="系统自动生成,格式:SX/X-JT20250926002" maxlength="50" />
</el-form-item>
<el-form-item label="合同名称" prop="contractName">
<el-input
v-model="form.contractName"
placeholder="可不填,自动生成:供方简称-需方简称(合同编号)"
maxlength="100"
show-word-limit
/>
</el-form-item>
<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-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-form-item label="客户名称" prop="customerId" required>
<div class="customer-select-row">
<el-select
v-model="form.customerId"
class="customer-select-row__select"
filterable
remote
clearable
:remote-method="searchCustomerOptions"
:loading="customerOptionsLoading"
placeholder="请输入客户名称/拼音首字母搜索"
@focus="handleCustomerSelectFocus"
@change="handleCustomerSelectChange"
>
<el-option
v-for="item in customerOptions"
:key="item.id"
:label="item.customerName"
:value="item.id"
>
<div class="customer-option-row">
<span>{{ item.customerName }}</span>
<span v-if="item.customerNo" class="customer-option-row__meta">{{ item.customerNo }}</span>
</div>
</el-option>
</el-select>
<el-button text class="customer-select-row__search" @click="handleSelectCustomer">
<el-icon><Search /></el-icon>
</el-button>
</div>
</el-form-item>
<el-form-item label="关联报价单" prop="quoteId">
<el-input v-model="form.quoteNo" placeholder="请选择报价单(可选)" readonly clearable>
<template #append>
<el-button text @click="handleSelectQuote">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item label="联系人" prop="contactId" required>
<div class="customer-select-row">
<el-select
v-model="form.contactId"
class="customer-select-row__select"
filterable
clearable
:disabled="!form.customerId"
:loading="contactOptionsLoading"
:placeholder="form.customerId ? '请选择联系人' : '请先选择客户'"
@visible-change="handleContactSelectVisible"
@change="handleContactSelectChange"
>
<el-option
v-for="item in contactOptions"
:key="item.id"
:label="item.contactName"
:value="item.id"
>
<div class="customer-option-row">
<span>
{{ item.contactName }}
<el-tag v-if="item.isPrimary === 1" type="success" size="small" style="margin-left: 6px">主要</el-tag>
</span>
<span v-if="item.mobile" class="customer-option-row__meta">{{ item.mobile }}</span>
</div>
</el-option>
</el-select>
<el-button
text
class="customer-select-row__search"
:disabled="!form.customerId"
@click="handleSelectContact"
>
<el-icon><Search /></el-icon>
</el-button>
</div>
</el-form-item>
<el-form-item label="联系电话" prop="contactsPhone">
<el-input v-model="form.contactsPhone" placeholder="从客户/联系人带出" maxlength="20" />
</el-form-item>
<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-form-item label="负责人" prop="ownerUserId" required>
<el-select
v-model="form.ownerUserId"
placeholder="请选择负责人"
filterable
clearable
style="width: 100%"
@change="handleOwnerChange"
>
<el-option
v-for="user in ownerUserList"
:key="user.userId"
:label="user.nickName"
:value="user.userId"
/>
</el-select>
</el-form-item>
<el-form-item label="合同金额(元)" prop="amount">
<el-input-number
v-model="form.amount"
:precision="2"
:min="0"
placeholder="可选填,留空则从产品明细自动计算"
style="width: 100%"
class="money-field-input"
/>
</el-form-item>
<el-form-item label="运费(元)" prop="freight">
<el-input-number
v-model="form.freight"
:precision="2"
:min="0"
placeholder="可选,选择报价单后可自动带入"
style="width: 100%"
class="money-field-input"
/>
</el-form-item>
<el-form-item label="桶押金(元)" prop="barrelDeposit">
<el-input-number
v-model="form.barrelDeposit"
:precision="2"
:min="0"
placeholder="可选,选择报价单后可自动带入"
style="width: 100%"
class="money-field-input"
/>
</el-form-item>
<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-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-form-item label="结算方式" prop="settlementMethod" required>
<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>
</div>
<!-- 供方 / 需方信息(左右布局,默认缩略、各自独立展开) -->
<div class="parties-section">
<div class="parties-section-toolbar">
<el-button text type="primary" size="small" @click="partiesExpanded = !partiesExpanded">
<el-icon>
<ArrowUp v-if="partiesExpanded" />
<ArrowDown v-else />
</el-icon>
{{ partiesExpanded ? '收起供需方' : '展开供需方' }}
</el-button>
<el-button type="primary" size="small" @click="handleSwapParties">
<el-icon><Sort /></el-icon>
供需方调换
</el-button>
</div>
<div class="parties-row">
<div class="party-panel" :class="{ 'is-collapsed': !partiesExpanded }">
<div class="party-panel__header">
<div class="party-panel__title">供方信息</div>
</div>
<div v-if="!partiesExpanded" class="party-panel__summary">
<span class="party-panel__summary-label">单位名称</span>
<span class="party-panel__summary-value" :class="{ 'is-empty': !form.partyA }">
{{ form.partyA || '未填写' }}
</span>
</div>
<div v-show="partiesExpanded" class="form-grid-2">
<el-form-item label="单位名称(章)" prop="partyA">
<el-input v-model="form.partyA" placeholder="请输入单位名称" maxlength="100" />
</el-form-item>
<el-form-item label="单位地址" prop="partyAAddress">
<el-input v-model="form.partyAAddress" placeholder="请输入单位地址" maxlength="200" />
</el-form-item>
<el-form-item label="法定代表人" prop="partyALegalPerson">
<el-input v-model="form.partyALegalPerson" placeholder="请输入法定代表人" maxlength="100" />
</el-form-item>
<el-form-item label="代理人" prop="partyAAgent">
<el-input v-model="form.partyAAgent" placeholder="请输入代理人(可选)" maxlength="100" />
</el-form-item>
<el-form-item label="电话/传真" prop="partyAPhoneFax">
<el-input v-model="form.partyAPhoneFax" placeholder="请输入电话/传真" maxlength="100" />
</el-form-item>
<el-form-item label="开户银行" prop="partyABank">
<el-input v-model="form.partyABank" placeholder="请输入开户银行" maxlength="200" />
</el-form-item>
<el-form-item label="账号" prop="partyAAccount">
<el-input v-model="form.partyAAccount" placeholder="请输入账号" maxlength="100" />
</el-form-item>
</div>
</div>
<div class="party-flow-bridge" aria-hidden="true">
<span class="party-flow-bridge__label">供货</span>
<span class="party-flow-bridge__arrow">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path
d="M5 12h11.5"
stroke="currentColor"
stroke-width="2.75"
stroke-linecap="round"
/>
<path
d="M13.5 8.5 18.5 12l-5 3.5"
stroke="currentColor"
stroke-width="2.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
</div>
<div class="party-panel" :class="{ 'is-collapsed': !partiesExpanded }">
<div class="party-panel__header">
<div class="party-panel__title">需方信息</div>
</div>
<div v-if="!partiesExpanded" class="party-panel__summary">
<span class="party-panel__summary-label">单位名称</span>
<span class="party-panel__summary-value" :class="{ 'is-empty': !form.partyB }">
{{ form.partyB || '未填写' }}
</span>
</div>
<div v-show="partiesExpanded" class="form-grid-2">
<el-form-item label="单位名称(章)" prop="partyB">
<el-input v-model="form.partyB" placeholder="从客户开票信息带出" maxlength="200" />
</el-form-item>
<el-form-item label="单位地址" prop="partyBAddress">
<el-input v-model="form.partyBAddress" placeholder="从客户开票信息带出" maxlength="500" />
</el-form-item>
<el-form-item label="法定代表人" prop="partyBLegalPerson">
<el-input v-model="form.partyBLegalPerson" placeholder="从客户开票信息带出" maxlength="100" />
</el-form-item>
<el-form-item label="代理人" prop="partyBAgent">
<el-input v-model="form.partyBAgent" placeholder="从客户开票信息带出(可选)" maxlength="100" />
</el-form-item>
<el-form-item label="电话/传真" prop="partyBPhoneFax">
<el-input v-model="form.partyBPhoneFax" placeholder="从客户开票信息带出" maxlength="100" />
</el-form-item>
<el-form-item label="开户银行" prop="partyBBank">
<el-input v-model="form.partyBBank" placeholder="从客户开票信息带出" maxlength="200" />
</el-form-item>
<el-form-item label="账号" prop="partyBAccount">
<el-input v-model="form.partyBAccount" placeholder="从客户开票信息带出" maxlength="100" />
</el-form-item>
</div>
</div>
</div>
</div>
<!-- 产品明细部分 -->
<el-divider content-position="left">产品明细</el-divider>
<div class="panel dialog-list-panel mb-4">
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
<el-button class="btn-main" type="primary" @click="handleAddProduct">
<el-icon><Plus /></el-icon>添加产品
</el-button>
<el-button class="btn-ghost" @click="handleRemoveSelectedProducts" :disabled="selectedProducts.length === 0">
<el-icon><Delete /></el-icon>删除选中
</el-button>
</div>
</div>
</div>
<div class="list-body list-wrap">
<ListTable table-key="crm-contract-components-contract-form-dialog" :data="form.products" class="list-table" stripe @selection-change="handleProductSelectionChange">
<el-table-column type="selection" width="55" align="center" header-align="center" />
<el-table-column label="序号" type="index" width="60" align="center" header-align="center" />
<el-table-column label="产品名称" min-width="160" :show-overflow-tooltip="true">
<template #default="scope">
<el-input
:model-value="scope.row.standardProductName || scope.row.productName"
placeholder="产品名称"
clearable
size="small"
readonly
/>
</template>
</el-table-column>
<el-table-column label="别名" min-width="140">
<template #default="scope">
<div class="line-meta-cell">
<el-select
v-if="(scope.row.aliasOptions || []).length"
v-model="scope.row.aliasName"
placeholder="不选则用产品名称"
clearable
filterable
size="small"
class="line-meta-cell__control"
>
<el-option
v-for="name in scope.row.aliasOptions"
:key="name"
:label="name"
:value="name"
/>
</el-select>
<span v-else class="text-muted line-meta-cell__empty">—</span>
<el-button
v-if="scope.row.productId"
link
type="primary"
class="line-meta-cell__btn"
@click="openAliasBrandManage(scope.row, 'alias')"
>
<el-icon><Plus /></el-icon>
</el-button>
</div>
</template>
</el-table-column>
<el-table-column label="品牌" min-width="140">
<template #default="scope">
<div class="line-meta-cell">
<el-select
v-if="(scope.row.brandOptions || []).length"
v-model="scope.row.brandId"
placeholder="不限品牌"
clearable
filterable
size="small"
class="line-meta-cell__control"
@change="(val) => handleBrandIdChange(scope.row, val)"
>
<el-option
v-for="b in scope.row.brandOptions"
:key="b.id"
:label="b.brandName"
:value="b.id"
/>
</el-select>
<span v-else class="text-muted line-meta-cell__empty">—</span>
<el-button
v-if="scope.row.productId"
link
type="primary"
class="line-meta-cell__btn"
@click="openAliasBrandManage(scope.row, 'brand')"
>
<el-icon><Plus /></el-icon>
</el-button>
</div>
</template>
</el-table-column>
<el-table-column label="型号/规格" prop="productModel" min-width="100" :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="taxRate" min-width="80">
<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">
<DictSelect
v-model="scope.row.unit"
dict-type="crm_product_unit"
placeholder="请选择单位"
size="small"
style="width: 100%"
/>
</template>
</el-table-column>
<el-table-column prop="quantity" width="130">
<template #header> <span style="color: #f56c6c">*</span> 数量</template>
<template #default="scope">
<div v-if="isFrameworkContract" class="framework-pricing-cell">
<template v-if="isFrameworkQuantityCustomerDemand(scope.row)">
<span class="text-muted framework-pricing-cell__label">{{ FRAMEWORK_QUANTITY_LABEL }}</span>
<el-button
link
type="primary"
class="framework-pricing-cell__action"
@click="handleEnableFrameworkQuantityFixed(scope.row)"
>
填数字
</el-button>
</template>
<template v-else>
<el-input
v-model="scope.row.quantity"
placeholder="数量"
@input="handleQuantityChange(scope.row)"
clearable
size="small"
/>
<el-button
link
type="primary"
class="framework-pricing-cell__action"
@click="handleResetFrameworkQuantity(scope.row)"
>
按客户需求
</el-button>
</template>
</div>
<el-input
v-else
v-model="scope.row.quantity"
placeholder="数量"
@input="handleQuantityChange(scope.row)"
clearable
size="small"
/>
</template>
</el-table-column>
<el-table-column prop="price" width="150">
<template #header>
<span v-if="isFrameworkContract">含税单价(元)</span>
<span v-else><span style="color: #f56c6c">*</span> 含税单价(元)</span>
</template>
<template #default="scope">
<div v-if="isFrameworkContract" class="framework-pricing-cell">
<template v-if="isFrameworkPriceMarket(scope.row)">
<span class="text-muted framework-pricing-cell__label">{{ FRAMEWORK_PRICE_LABEL }}</span>
<el-button
link
type="primary"
class="framework-pricing-cell__action"
@click="handleEnableFrameworkPriceFixed(scope.row)"
>
填数字
</el-button>
</template>
<template v-else>
<el-input
v-model="scope.row.price"
placeholder="含税单价"
@input="handlePriceChange(scope.row)"
clearable
size="small"
class="money-field-input"
/>
<el-button
link
type="primary"
class="framework-pricing-cell__action"
@click="handleResetFrameworkPrice(scope.row)"
>
随行就市
</el-button>
</template>
</div>
<el-input
v-else
v-model="scope.row.price"
placeholder="含税单价"
@input="handlePriceChange(scope.row)"
clearable
size="small"
class="money-field-input"
/>
</template>
</el-table-column>
<el-table-column prop="subtotal" width="150" align="right">
<template #header>
<span v-if="isFrameworkContract">含税总价(元)</span>
<span v-else><span style="color: #f56c6c">*</span> 含税总价(元)</span>
</template>
<template #default="scope">
<div v-if="isFrameworkContract" class="framework-pricing-cell framework-pricing-cell--money">
<template v-if="isFrameworkPriceMarket(scope.row)">
<span class="text-muted framework-pricing-cell__label">{{ FRAMEWORK_PRICE_LABEL }}</span>
<el-button
link
type="primary"
class="framework-pricing-cell__action"
@click="handleEnableFrameworkPriceFixed(scope.row)"
>
填数字
</el-button>
</template>
<span v-else class="money-display">{{ formatFrameworkMoneyDisplay(scope.row, 'subtotal') }}</span>
</div>
<span v-else class="money-display">¥{{ parseFloat(scope.row.subtotal || 0).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column label="税费(元)" prop="taxAmount" min-width="120" align="right">
<template #default="scope">
<div v-if="isFrameworkContract" class="framework-pricing-cell framework-pricing-cell--money">
<template v-if="isFrameworkPriceMarket(scope.row)">
<span class="text-muted framework-pricing-cell__label">{{ FRAMEWORK_PRICE_LABEL }}</span>
<el-button
link
type="primary"
class="framework-pricing-cell__action"
@click="handleEnableFrameworkPriceFixed(scope.row)"
>
填数字
</el-button>
</template>
<span v-else class="money-display">{{ formatFrameworkMoneyDisplay(scope.row, 'taxAmount') }}</span>
</div>
<span v-else class="money-display">¥{{ 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">
<DictSelect
v-model="scope.row.packageSpec"
dict-type="crm_product_gg"
placeholder="请选择包装规格"
size="small"
style="width: 100%"
/>
</template>
</el-table-column>
<el-table-column prop="deliveryMethod" min-width="150">
<template #header> <span style="color: #f56c6c">*</span> 送货方式</template>
<template #default="scope">
<DictSelect
v-model="scope.row.deliveryMethod"
dict-type="crm_shfs"
placeholder="请选择送货方式"
size="small"
style="width: 100%"
/>
</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="操作" min-width="80" align="center" header-align="center" fixed="right" class-name="col-actions">
<template #default="scope">
<el-button link type="danger" @click="handleRemoveProduct(scope.$index)">
<el-icon><Delete /></el-icon>
</el-button>
</template>
</el-table-column>
</ListTable>
</div>
</div>
<!-- 框架合同调价区间 -->
<template v-if="isFrameworkContractType(form.contractType)">
<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" @click="addPriceTierRow(pIdx)">
<el-icon><Plus /></el-icon>调价函
</el-button>
<div class="panel dialog-list-panel mb-3">
<div class="list-body list-wrap dialog-list-body-only">
<ListTable table-key="crm-contract-components-contract-form-dialog-2" :data="prod.priceTiers || []" class="list-table fw-price-tier-table" stripe empty-text="暂无调价区间">
<el-table-column type="index" label="#" width="50" align="center" header-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" class="money-field-input" />
</template>
</el-table-column>
<el-table-column label="调价函" min-width="220">
<template #default="scope">
<AttachmentUploadField
:model-value="tierLetterCsv(scope.row)"
accept="image/*"
:limit="20"
compact
:biz-type="CRM_UPLOAD_BIZ_TYPE.CONTRACT_PRICE_LETTER"
@update:model-value="syncTierLetterCsv(scope.row, $event)"
/>
</template>
</el-table-column>
<el-table-column label="操作" min-width="70" align="center" header-align="center" fixed="right" class-name="col-actions">
<template #default="scope">
<el-button link type="danger" @click="removePriceTierRow(pIdx, scope.$index)">
<el-icon><Delete /></el-icon>
</el-button>
</template>
</el-table-column>
</ListTable>
</div>
</div>
</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 money-display">¥{{ parseFloat(form.amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}</span>
</div>
</el-col>
</el-row>
</div>
<!-- 附件上传部分 - OpenSpec 标准卡片化布局 -->
<el-divider content-position="left">附件上传</el-divider>
<el-form-item label="附件" prop="attachmentFiles" :required="form.contractTemplate === 'customer_template'">
<ContractAttachmentPanel
v-model="attachmentList"
:biz-type="CRM_UPLOAD_BIZ_TYPE.CONTRACT_ATTACHMENT"
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<div class="dialog-footer">
<el-button class="btn-ghost" @click="handleClose">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button
class="btn-ghost"
:loading="saveDraftLoading"
:disabled="formLoading"
@click="handleSaveDraft"
>
<el-icon><FolderChecked /></el-icon>保存草稿
</el-button>
<el-button
class="btn-main"
type="primary"
:loading="submitLoading"
:disabled="formLoading"
@click="handleSubmit"
>
<el-icon><Promotion /></el-icon>提交审批
</el-button>
</div>
</template>
<!-- 产品选择器 -->
<ProductSelector
:visible="productSelectorVisible"
:supplier-id="form.supplierId"
@update:visible="productSelectorVisible = $event"
@confirm="handleProductSelected"
/>
<ProductAliasBrandDialog
v-model="aliasBrandManageVisible"
:product-id="aliasBrandManageProductId"
:product-name="aliasBrandManageProductName"
:mode="aliasBrandManageMode"
@saved="handleAliasBrandManaged"
/>
<!-- 客户选择器 -->
<CustomerSelector
:visible="customerSelectorVisible"
@update:visible="customerSelectorVisible = $event"
@confirm="handleCustomerSelectorConfirm"
/>
<!-- 下拉选客户时补填简称 -->
<el-dialog
v-model="shortNameDialogVisible"
title="填写客户简称"
width="480px"
align-center
class="dialog-form-layout"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
append-to-body
@closed="handleShortNameDialogClosed"
>
<el-form
ref="shortNameFormRef"
:model="shortNameForm"
:rules="shortNameRules"
label-position="top"
class="crm-dialog-form"
>
<el-form-item label="客户名称">
<el-input :model-value="shortNameForm.customerName" disabled />
</el-form-item>
<el-form-item label="客户简称" prop="shortName">
<el-input
v-model="shortNameForm.shortName"
placeholder="请输入客户简称"
maxlength="100"
show-word-limit
clearable
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button class="btn-ghost" @click="shortNameDialogVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" :loading="shortNameSaving" @click="handleSaveShortName">
<el-icon><Check /></el-icon>确定
</el-button>
</div>
</template>
</el-dialog>
<!-- 联系人选择器 -->
<ContactSelector
:visible="contactSelectorVisible"
@update:visible="contactSelectorVisible = $event"
:customerId="form.customerId"
@confirm="handleContactConfirm"
/>
<!-- 下拉无联系人时补充 -->
<ContactFormDialog ref="contactFormRef" @success="handleContactFormSuccess" />
<!-- 报价单选择器 -->
<QuoteSelector
:visible="quoteSelectorVisible"
@update:visible="quoteSelectorVisible = $event"
:customerId="form.customerId"
@confirm="handleQuoteConfirm"
/>
<!-- 用户选择器 -->
<!-- 审批弹窗 -->
<ApprovalDialog ref="approvalDialogRef" @success="handleApprovalSuccess" />
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, nextTick, watch } from 'vue'
import type { FormInstance, FormItemRule } from 'element-plus'
import { ElMessage } from 'element-plus'
import {
getContract,
addContract,
updateContract,
type ContractForm,
type ContractProductForm,
type ContractProductPriceTierForm
} from '@/api/crm/contract/index'
import { isFrameworkContractType, isPurchaseLikeContractType } from '../contractType'
import {
FRAMEWORK_QUANTITY_LABEL,
FRAMEWORK_PRICE_LABEL,
applyFrameworkDefaultsToProduct,
createFrameworkProductPricingDefaults,
enableFrameworkPriceFixed,
enableFrameworkQuantityFixed,
formatFrameworkMoneyDisplay,
isBlankNumeric,
isFrameworkPriceMarket,
isFrameworkQuantityCustomerDemand,
isNegativeNumeric,
confirmZeroPricingIfNeeded,
normalizeFrameworkProductPricing,
prepareFrameworkProductForSubmit,
resetFrameworkPriceToMarket,
resetFrameworkQuantityToCustomerDemand
} from '../frameworkProductPricing'
import { useDictStore } from '@/store/modules/dict'
import { useUserStore } from '@/store/modules/user'
import { $modal } from '@/utils/modal'
import {
Document,
Delete,
Sort,
Plus,
Search,
Close,
Check,
FolderChecked,
Promotion,
Loading,
ArrowDown,
ArrowUp
} from '@element-plus/icons-vue'
import {
buildUploadFileListFromField,
serializeContractAttachments,
type ContractAttachmentItem
} from '@/utils/crm-attachment'
import { CRM_UPLOAD_BIZ_TYPE } from '@/utils/crm-upload'
import ContractAttachmentPanel from './ContractAttachmentPanel.vue'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import { getOpportunity } from '@/api/crm/opportunity'
import { getQuote } from '@/api/crm/quote'
import ProductSelector from '@/components/business/product-selector/index.vue'
import ProductAliasBrandDialog from '@/components/business/product-alias-brand-dialog/index.vue'
import DictSelect from '@/components/DictSelect/index.vue'
import CustomerSelector from '@/components/CustomerSelector.vue'
import ContactSelector from '@/components/ContactSelector.vue'
import ContactFormDialog from '@/views/crm/customer/components/ContactFormDialog.vue'
import QuoteSelector from '@/components/QuoteSelector.vue'
import { fetchContactsByCustomerId } from '@/api/crm/contact'
import { removeProductsNotAllowedByHazard } from '@/utils/crm-supplier-hazard'
import {
applyBrandSelectChange,
applyManagedProductMetaToLines,
initLineBrandMetaFromProduct,
prepareLineAliasAndBrandForSubmit,
resolveAliasFromSavedName
} from '@/utils/crm-product-brand'
import { fetchProductDetail } from '@/api/crm/product'
import ApprovalDialog from './ApprovalDialog.vue'
import { omitEmptyExceptPartyFields } from '../partyFields'
import {
fetchCustomerList,
fetchCustomerDetail,
fetchUpdateCustomer
} 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 { fetchUserOptions } from '@/api/admin'
import { filterOwnerCandidates, findDefaultOwnerUser } from '@/utils/crm-owner-candidates'
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
type ExtendedContractProductForm = ContractProductForm & {
productName?: string
productModel?: string
unit?: string
unitPrice?: number
subtotal?: number
priceTiers?: PriceTierRow[]
}
const props = defineProps<{
visible: boolean
editId: string | null
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: []
}>()
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val)
})
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: undefined,
customerName: '',
contactId: undefined,
opportunityId: undefined,
opportunityName: '',
quoteId: undefined,
quoteNo: '',
contactsName: '',
contactsPhone: '',
signDate: '',
startDate: '',
endDate: '',
ownerUserId: undefined,
ownerUserName: '',
amount: 0,
freight: 0,
barrelDeposit: 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 partiesExpanded = ref(false)
const resetPartyExpand = () => {
partiesExpanded.value = false
}
/** 校验失败时,自动展开供需方面板 */
const expandPartiesForInvalidFields = (invalidFields?: Record<string, unknown>) => {
if (!invalidFields) return
const keys = Object.keys(invalidFields)
if (keys.some((k) => k.startsWith('partyA') || k.startsWith('partyB'))) {
partiesExpanded.value = true
}
}
// 记录原始的模板内容(用于检测是否被意外删除)
const originalTemplateContent = ref<string>('')
// 供方信息列表
const supplierList = ref<SupplierInfoVO[]>([])
// 客户简称 / 开票公司名(用于按供需方面板匹配简称)
const customerShortName = ref('')
const customerInvoiceName = ref('')
// 表单校验规则
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' }],
settlementMethod: [{ 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<ContractAttachmentItem[]>([])
const isFrameworkContract = computed(() => isFrameworkContractType(form.value.contractType))
// 加载状态
const formLoading = ref(false)
const saveDraftLoading = ref(false)
const submitLoading = ref(false)
/** 编辑打开时立即进入加载态,避免空白表单可被误改 */
watch(
() => props.visible,
(visible) => {
if (visible && props.editId) {
formLoading.value = true
} else if (!visible) {
formLoading.value = 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 customerOptions = ref<CustomerVO[]>([])
const customerOptionsLoading = ref(false)
/** 已确认选定的客户(用于简称弹窗取消时回滚下拉值) */
const confirmedCustomerId = ref<string | number | undefined>(undefined)
const shortNameDialogVisible = ref(false)
const shortNameSaving = ref(false)
const shortNameFormRef = ref<FormInstance>()
const shortNameForm = ref({
id: '' as string | number,
customerName: '',
shortName: ''
})
const shortNameRules: Record<string, FormItemRule[]> = {
shortName: [{ required: true, message: '客户简称不能为空', trigger: 'blur' }]
}
const pendingCustomerForShortName = ref<CustomerVO | null>(null)
const shortNameSavedSuccessfully = ref(false)
// 联系人下拉
const contactOptions = ref<ContactVO[]>([])
const contactOptionsLoading = ref(false)
const contactFormRef = ref<InstanceType<typeof ContactFormDialog>>()
/** 避免 visible-change 重复弹出补充联系人弹窗 */
const contactEmptyPrompted = ref(false)
const hasCustomerShortName = (row?: { shortName?: string } | null) =>
Boolean(String(row?.shortName || '').trim())
const ensureCustomerOption = (customer?: Partial<CustomerVO> | null) => {
if (!customer?.id) return
const exists = customerOptions.value.some((item) => String(item.id) === String(customer.id))
if (exists) {
customerOptions.value = customerOptions.value.map((item) =>
String(item.id) === String(customer.id)
? {
...item,
customerName: customer.customerName || item.customerName,
customerNo: customer.customerNo ?? item.customerNo,
shortName: customer.shortName ?? item.shortName
}
: item
)
return
}
customerOptions.value = [
{
id: customer.id,
customerName: customer.customerName || '',
customerNo: customer.customerNo,
shortName: customer.shortName
} as CustomerVO,
...customerOptions.value
]
}
const extractCustomerList = (res: unknown): CustomerVO[] => {
const data = (res as { data?: unknown })?.data ?? res ?? {}
const list = (data as { list?: CustomerVO[]; rows?: CustomerVO[] }).list
|| (data as { rows?: CustomerVO[] }).rows
|| []
return Array.isArray(list) ? list : []
}
const loadDefaultCustomerOptions = async () => {
customerOptionsLoading.value = true
try {
const res = await fetchCustomerList({ page: 1, pageSize: 20 })
customerOptions.value = extractCustomerList(res)
if (form.value.customerId) {
ensureCustomerOption({
id: form.value.customerId,
customerName: form.value.customerName,
shortName: customerShortName.value
})
}
} catch (error) {
console.error('加载客户列表失败:', error)
customerOptions.value = []
} finally {
customerOptionsLoading.value = false
}
}
const searchCustomerOptions = async (query: string) => {
const keyword = query?.trim()
if (!keyword) {
await loadDefaultCustomerOptions()
return
}
customerOptionsLoading.value = true
try {
const res = await fetchCustomerList({
customerName: keyword,
page: 1,
pageSize: 50
})
customerOptions.value = extractCustomerList(res)
if (form.value.customerId) {
ensureCustomerOption({
id: form.value.customerId,
customerName: form.value.customerName,
shortName: customerShortName.value
})
}
} catch (error) {
console.error('搜索客户失败:', error)
customerOptions.value = []
} finally {
customerOptionsLoading.value = false
}
}
const handleCustomerSelectFocus = () => {
if (customerOptions.value.length === 0) {
loadDefaultCustomerOptions()
}
}
const extractContactList = (res: unknown): ContactVO[] => {
const unwrapped = (res as { data?: unknown })?.data ?? res ?? []
if (Array.isArray(unwrapped)) return unwrapped as ContactVO[]
const payload = (unwrapped as { list?: ContactVO[]; rows?: ContactVO[] }) || {}
return payload.list || payload.rows || []
}
const ensureContactOption = (contact?: Partial<ContactVO> | null) => {
if (!contact?.id) return
const exists = contactOptions.value.some((item) => String(item.id) === String(contact.id))
if (exists) {
contactOptions.value = contactOptions.value.map((item) =>
String(item.id) === String(contact.id)
? {
...item,
contactName: contact.contactName || item.contactName,
mobile: contact.mobile ?? item.mobile,
isPrimary: contact.isPrimary ?? item.isPrimary
}
: item
)
return
}
contactOptions.value = [
{
id: contact.id,
contactName: contact.contactName || '',
mobile: contact.mobile,
isPrimary: contact.isPrimary
} as ContactVO,
...contactOptions.value
]
}
const loadContactOptions = async (customerId?: string | number) => {
if (!customerId) {
contactOptions.value = []
return []
}
contactOptionsLoading.value = true
try {
const res = await fetchContactsByCustomerId({ customerId })
contactOptions.value = extractContactList(res)
if (form.value.contactId) {
ensureContactOption({
id: form.value.contactId,
contactName: form.value.contactsName
})
}
return contactOptions.value
} catch (error) {
console.error('加载联系人列表失败:', error)
contactOptions.value = []
return []
} finally {
contactOptionsLoading.value = false
}
}
const openContactFormDialog = () => {
if (!form.value.customerId) {
$modal.msgWarning('请先选择客户')
return
}
contactFormRef.value?.open(undefined, form.value.customerId)
}
const openShortNameDialog = (customer: CustomerVO) => {
pendingCustomerForShortName.value = customer
shortNameSavedSuccessfully.value = false
shortNameForm.value = {
id: customer.id,
customerName: customer.customerName || '',
shortName: customer.shortName || ''
}
shortNameDialogVisible.value = true
nextTick(() => shortNameFormRef.value?.clearValidate())
}
const clearCustomerSelection = () => {
form.value.customerId = undefined
form.value.customerName = ''
customerShortName.value = ''
customerInvoiceName.value = ''
confirmedCustomerId.value = undefined
form.value.contactId = undefined
form.value.contactsName = ''
form.value.contactsPhone = ''
contactOptions.value = []
contactEmptyPrompted.value = false
form.value.opportunityId = undefined
form.value.opportunityName = ''
form.value.quoteId = undefined
form.value.quoteNo = ''
form.value.freight = 0
form.value.barrelDeposit = 0
rebuildContractName()
}
// 用户列表(负责人下拉仅方金强 / 吴九华)
const userList = ref<any[]>([])
const ownerUserList = computed(() => filterOwnerCandidates(userList.value))
// 选中的产品列表
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 handleOpened = async () => {
resetPartyExpand()
const isEdit = !!props.editId
if (isEdit) {
formLoading.value = true
}
try {
await Promise.all([loadSupplierList(), loadUserList()])
if (props.editId) {
await loadContractDetail(props.editId)
} else {
resetForm()
form.value.signDate = new Date().toISOString().split('T')[0]
handleSignDateChange(form.value.signDate)
applyDefaultOwnerUser()
// 设置默认供方主体为"SX"
const defaultSupplier = supplierList.value.find((supplier) => supplier.supplierCode === 'SX')
if (defaultSupplier) {
form.value.supplierId = defaultSupplier.id
await handleSupplierChange(defaultSupplier.id)
}
}
} catch (error: any) {
console.error('合同表单初始化失败:', error)
$modal.msgError(error?.message || '加载合同数据失败')
if (isEdit) {
dialogVisible.value = false
}
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
form.value = {
id: undefined,
contractNo: undefined,
contractName: '',
contractType: 'sale',
contractTemplate: 'shanxia',
customerId: undefined,
customerName: '',
contactId: undefined,
opportunityId: undefined,
opportunityName: '',
quoteId: undefined,
quoteNo: '',
contactsName: '',
contactsPhone: '',
signDate: '',
startDate: '',
endDate: '',
ownerUserId: undefined,
ownerUserName: '',
amount: 0,
freight: 0,
barrelDeposit: 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: ''
}
isPartiesSwapped.value = false
resetPartyExpand()
originalTemplateContent.value = ''
customerShortName.value = ''
customerInvoiceName.value = ''
confirmedCustomerId.value = undefined
customerOptions.value = []
contactOptions.value = []
contactEmptyPrompted.value = false
pendingCustomerForShortName.value = null
shortNameDialogVisible.value = false
shortNameSavedSuccessfully.value = false
contractDateRange.value = [] as any
attachmentList.value = []
selectedProducts.value = []
}
/** 加载合同详情 */
const loadContractDetail = async (id: number | string) => {
const res = await getContract(id)
const contractData = res.data
Object.assign(form.value, contractData)
originalTemplateContent.value = contractData.templateContent || ''
if (contractData.contactName) {
form.value.contactsName = contractData.contactName
}
if (contractData.contactMobile) {
form.value.contactsPhone = contractData.contactMobile
}
if (contractData.ownerUserName) {
form.value.ownerUserName = contractData.ownerUserName
}
if (contractData.ownerUserId) {
form.value.ownerUserId = contractData.ownerUserId
}
if (contractData.opportunityId) {
try {
const oppRes = await getOpportunity(contractData.opportunityId)
if (oppRes.data) {
form.value.opportunityName = oppRes.data.opportunityName
}
} catch (error) {
console.warn('加载商机信息失败:', error)
}
}
if (contractData.quoteId) {
try {
const quoteRes = await getQuote(contractData.quoteId)
if (quoteRes.data) {
form.value.quoteNo = quoteRes.data.quoteNo
}
} catch (error) {
console.warn('加载报价单信息失败:', error)
}
}
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),
brandMode: product.brandMode || undefined,
brandId: product.brandId || undefined,
brandName: product.brandName || undefined,
brandOptions: [],
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
}
}),
taxRate: product.taxRate || 6,
taxAmount: product.taxAmount ?? 0,
packageSpec: product.packageSpec || '',
deliveryMethod: product.deliveryMethod || ''
}))
await enrichContractProductBrandMeta(form.value.products)
const isFw = isFrameworkContractType(contractData.contractType)
form.value.products.forEach((product: any) => {
normalizeFrameworkProductPricing(product, isFw)
if (!isFw || !isFrameworkPriceMarket(product)) {
calculateProductAmount(product)
}
})
if (isFw) {
form.value.amount = 0
} else {
calculateTotalAmount()
}
}
if (form.value.startDate && form.value.endDate) {
contractDateRange.value = [form.value.startDate, form.value.endDate]
}
if (form.value.attachmentFiles) {
await handleAttachmentData()
}
customerShortName.value = ''
customerInvoiceName.value = ''
confirmedCustomerId.value = form.value.customerId || undefined
contactEmptyPrompted.value = false
if (form.value.customerId) {
try {
const [invoiceRes, customerRes] = await Promise.all([
getDefaultInvoiceInfoByCustomerId(form.value.customerId),
fetchCustomerDetail({ id: String(form.value.customerId) })
])
customerInvoiceName.value = invoiceRes?.data?.companyName || ''
customerShortName.value = customerRes?.data?.shortName || ''
if (!form.value.customerName && customerRes?.data?.customerName) {
form.value.customerName = customerRes.data.customerName
}
ensureCustomerOption({
id: form.value.customerId,
customerName: form.value.customerName || customerRes?.data?.customerName,
shortName: customerShortName.value,
customerNo: customerRes?.data?.customerNo
})
} catch {
customerInvoiceName.value = ''
ensureCustomerOption({
id: form.value.customerId,
customerName: form.value.customerName,
shortName: customerShortName.value
})
}
await loadContactOptions(form.value.customerId)
if (form.value.contactId) {
ensureContactOption({
id: form.value.contactId,
contactName: form.value.contactsName,
mobile: form.value.contactsPhone
})
}
} else {
customerOptions.value = []
contactOptions.value = []
}
syncPartiesSwappedFlagFromPanels()
rebuildContractName()
}
/** 选择客户 */
const handleSelectCustomer = () => {
customerSelectorVisible.value = true
}
/** 下拉选择客户 */
const handleCustomerSelectChange = async (customerId?: string | number) => {
if (!customerId) {
clearCustomerSelection()
return
}
if (
confirmedCustomerId.value !== undefined
&& confirmedCustomerId.value !== null
&& String(customerId) === String(confirmedCustomerId.value)
) {
return
}
const customer = customerOptions.value.find((item) => String(item.id) === String(customerId))
if (!customer) return
if (!hasCustomerShortName(customer)) {
ElMessage.warning('该客户尚未配置简称,请先填写客户简称后再继续')
form.value.customerId = confirmedCustomerId.value as any
openShortNameDialog(customer)
return
}
await handleCustomerConfirm(customer)
}
/** 客户选择器确认(无简称时先补填) */
const handleCustomerSelectorConfirm = async (customer: CustomerVO) => {
let enriched: CustomerVO = { ...customer }
if (!hasCustomerShortName(enriched)) {
try {
const customerRes = await fetchCustomerDetail({ id: String(customer.id) })
enriched = {
...enriched,
customerName: customerRes?.data?.customerName || enriched.customerName,
shortName: customerRes?.data?.shortName || enriched.shortName
}
} catch {
// ignore,后续走简称弹窗
}
}
if (!hasCustomerShortName(enriched)) {
ElMessage.warning('该客户尚未配置简称,请先填写客户简称后再继续')
openShortNameDialog(enriched)
return
}
await handleCustomerConfirm(enriched)
}
/** 客户选择确认 */
const handleCustomerConfirm = async (customer: CustomerVO) => {
if (customer) {
ensureCustomerOption(customer)
form.value.customerId = customer.id
form.value.customerName = customer.customerName
confirmedCustomerId.value = customer.id
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.contactsName = ''
contactOptions.value = []
contactEmptyPrompted.value = false
form.value.opportunityId = undefined
form.value.opportunityName = ''
form.value.quoteId = undefined
form.value.quoteNo = ''
form.value.freight = 0
form.value.barrelDeposit = 0
try {
const [invoiceRes, customerRes] = await Promise.all([
getDefaultInvoiceInfoByCustomerId(customer.id),
fetchCustomerDetail({ id: String(customer.id) })
])
const invoiceData = invoiceRes?.data
// 简称以客户主数据为准(列表/选择器可能缺字段)
customerShortName.value =
(customerRes?.data?.shortName || customer.shortName || '').trim()
if (customerRes?.data?.customerName) {
form.value.customerName = customerRes.data.customerName
}
ensureCustomerOption({
id: customer.id,
customerName: form.value.customerName,
shortName: customerShortName.value,
customerNo: customer.customerNo
})
customerInvoiceName.value = invoiceData?.companyName || ''
if (invoiceData && invoiceData.companyName) {
if (isPartiesSwapped.value) {
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 || ''
} else {
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 || ''
}
} else {
if (isPartiesSwapped.value) {
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 {
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 = ''
}
}
} catch (error) {
console.error('获取客户开票信息失败:', error)
customerInvoiceName.value = ''
customerShortName.value = (customer.shortName || '').trim()
if (isPartiesSwapped.value) {
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 {
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) {
form.value.partyAContact = ''
form.value.partyAMobile = customer.mobile || customer.telephone || ''
} else {
form.value.partyBContact = ''
form.value.partyBMobile = customer.mobile || customer.telephone || ''
}
form.value.contactsName = ''
form.value.contactsPhone = customer.mobile || customer.telephone || ''
// 自动加载该客户的联系人并设置默认联系人;无联系人时立即引导补充
const contacts = await loadContactOptions(customer.id)
if (contacts.length > 0) {
const primaryContact = contacts.find((c) => c.isPrimary === 1)
const contact = primaryContact || contacts[0]
if (contact) {
handleContactConfirm(contact)
}
} else {
contactEmptyPrompted.value = true
ElMessage.warning('该客户暂无联系人,请先补充联系人信息')
nextTick(() => openContactFormDialog())
}
if (!customerShortName.value) {
customerShortName.value = (customer.shortName || '').trim()
}
rebuildContractName()
}
}
const handleSaveShortName = async () => {
const valid = await shortNameFormRef.value?.validate().catch(() => false)
if (!valid) return
shortNameSaving.value = true
try {
const trimmedShortName = shortNameForm.value.shortName.trim()
await fetchUpdateCustomer({
id: shortNameForm.value.id,
shortName: trimmedShortName
})
ElMessage.success('客户简称保存成功')
const pending = pendingCustomerForShortName.value
const customer: CustomerVO = {
id: shortNameForm.value.id,
customerName: shortNameForm.value.customerName || pending?.customerName || '',
customerNo: pending?.customerNo,
shortName: trimmedShortName
} as CustomerVO
ensureCustomerOption(customer)
shortNameSavedSuccessfully.value = true
shortNameDialogVisible.value = false
await handleCustomerConfirm(customer)
} catch (error) {
console.error('保存客户简称失败:', error)
ElMessage.error('保存客户简称失败')
} finally {
shortNameSaving.value = false
}
}
const handleShortNameDialogClosed = () => {
pendingCustomerForShortName.value = null
if (!shortNameSavedSuccessfully.value) {
form.value.customerId = confirmedCustomerId.value as any
}
shortNameSavedSuccessfully.value = false
}
/** 选择联系人 */
const handleSelectContact = () => {
if (!form.value.customerId) {
$modal.msgWarning('请先选择客户')
return
}
contactSelectorVisible.value = true
}
const handleContactSelectVisible = async (visible: boolean) => {
if (!visible) {
contactEmptyPrompted.value = false
return
}
if (!form.value.customerId) return
const list = await loadContactOptions(form.value.customerId)
if (list.length === 0 && !contactEmptyPrompted.value) {
contactEmptyPrompted.value = true
ElMessage.warning('该客户暂无联系人,请先补充联系人信息')
nextTick(() => openContactFormDialog())
}
}
const handleContactSelectChange = (contactId?: string | number) => {
if (!contactId) {
form.value.contactId = undefined
form.value.contactsName = ''
form.value.contactsPhone = ''
return
}
const contact = contactOptions.value.find((item) => String(item.id) === String(contactId))
if (contact) {
handleContactConfirm(contact)
}
}
/** 联系人选择确认 */
const handleContactConfirm = (contact: ContactVO) => {
if (contact) {
ensureContactOption(contact)
form.value.contactId = contact.id
form.value.contactsName = contact.contactName
form.value.contactsPhone = contact.mobile || contact.telephone || ''
if (isPartiesSwapped.value) {
form.value.partyAContact = contact.contactName
form.value.partyAMobile = contact.mobile || contact.telephone || ''
} else {
form.value.partyBContact = contact.contactName
form.value.partyBMobile = contact.mobile || contact.telephone || ''
}
formRef.value?.validateField('contactId')
}
}
const handleContactFormSuccess = async (payload?: { id?: string | number }) => {
contactEmptyPrompted.value = false
const list = await loadContactOptions(form.value.customerId)
const createdId = payload?.id
if (createdId) {
const created = list.find((item) => String(item.id) === String(createdId))
if (created) {
handleContactConfirm(created)
return
}
}
if (list.length === 1) {
handleContactConfirm(list[0])
}
}
/** 选择商机 */
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
form.value.freight = Number(quote.freight) || 0
form.value.barrelDeposit = Number(quote.barrelDeposit) || 0
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) {
form.value.partyAContact = quote.contactName || ''
} else {
form.value.partyBContact = quote.contactName || ''
}
}
if (quote.productList && quote.productList.length > 0) {
$modal
.confirm('是否要从报价单中带入产品明细?')
.then(async () => {
const isFw = isFrameworkContractType(form.value.contractType)
form.value.products = quote.productList!.map((item: any) => {
const hasQuotePrice = Number(item.price) > 0 || Number(item.subtotal) > 0
const hasQuoteQty = Number(item.quantity) > 0
const fwDefaults = createFrameworkProductPricingDefaults()
return {
productId: item.productId,
productName: item.productName,
productModel: item.model,
categoryId: item.categoryId,
categoryName: item.categoryName || '',
unit: item.unit || '个',
...(isFw
? {
...fwDefaults,
quantityMode: hasQuoteQty ? 'fixed' : fwDefaults.quantityMode,
quantity: hasQuoteQty ? item.quantity || 1 : 0,
priceMode: hasQuotePrice ? 'fixed' : fwDefaults.priceMode,
price: hasQuotePrice ? item.price || 0 : 0,
taxAmount: hasQuotePrice ? item.taxAmount || 0 : 0,
subtotal: hasQuotePrice
? item.subtotal || (item.quantity || 1) * (item.price || 0) * (1 - (item.discount || 0) / 100)
: 0
}
: {
quantity: item.quantity || 1,
price: item.price || 0,
unitPrice: item.price || 0,
taxAmount: item.taxAmount || 0,
subtotal:
item.subtotal || (item.quantity || 1) * (item.price || 0) * (1 - (item.discount || 0) / 100)
}),
taxRate: item.taxRate || 13,
discount: item.discount || 0,
packageSpec: item.packageSpec || '',
deliveryMethod: item.deliveryMethod || '',
deliveryDate: '',
description: item.description || '',
remark: item.remark || '',
sort: item.sort,
brandMode: item.brandMode || undefined,
brandId: item.brandId || undefined,
brandName: item.brandName || undefined,
brandOptions: [],
priceTiers: isFw ? [] : undefined
}
})
await enrichContractProductBrandMeta(form.value.products)
form.value.products.forEach((p) => calculateProductAmount(p))
calculateTotalAmount()
})
.catch(() => {})
}
}
}
/** 加载用户列表(负责人下拉) */
const loadUserList = async () => {
try {
const res = await fetchUserOptions()
const options = (res as any)?.data ?? res ?? []
userList.value = (Array.isArray(options) ? options : []).map((item: any) => ({
userId: item.value ?? item.userId ?? item.id,
nickName: item.label ?? item.nickName ?? item.userName ?? ''
}))
} catch (error) {
console.error('加载用户列表失败:', error)
userList.value = []
}
}
/** 新增时默认负责人为方金强 */
const applyDefaultOwnerUser = () => {
const matched = findDefaultOwnerUser(ownerUserList.value)
if (!matched) return
form.value.ownerUserId = matched.userId as ContractForm['ownerUserId']
form.value.ownerUserName = matched.nickName || ''
}
/** 负责人变化 */
const handleOwnerChange = (userId: string | number) => {
const user = ownerUserList.value.find((item: any) => String(item.userId) === String(userId))
form.value.ownerUserName = user?.nickName || ''
}
/** 选择负责人 */
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) {
form.value.partyB = ''
form.value.partyBAddress = ''
form.value.partyBLegalPerson = ''
form.value.partyBAgent = ''
form.value.partyBPhoneFax = ''
form.value.partyBBank = ''
form.value.partyBAccount = ''
} else {
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) {
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 {
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)
rebuildContractName()
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()
}
}
}
/** 更新合同编号中的供方编码 */
const updateContractNoBySupplier = (newSupplierCode: string | undefined) => {
if (!form.value.contractNo || !newSupplierCode) return
const contractNoPattern = /^([A-Z]+)\/X-JT(\d{8})(\d{3})$/
const match = form.value.contractNo.match(contractNoPattern)
if (match) {
const date = match[2]
const serialNum = match[3]
form.value.contractNo = `${newSupplierCode}/X-JT${date}${serialNum}`
} else {
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
rebuildContractName()
}
/** 面板单位全称 → 匹配交易主体/客户主数据取简称;对不上用全称 */
const resolvePartyShortName = (partyName?: string): string => {
const party = (partyName || '').trim()
if (!party) return ''
const supplier = form.value.supplierId ? supplierList.value.find((s) => s.id === form.value.supplierId) : undefined
const supplierName = (supplier?.supplierName || '').trim()
const supplierShort = (supplier?.shortName || supplierName || '').trim()
if (supplierName && party === supplierName) return supplierShort || party
if (supplierShort && party === supplierShort) return supplierShort
const customerName = (form.value.customerName || '').trim()
const customerShort = (customerShortName.value || '').trim()
const invoiceName = (customerInvoiceName.value || '').trim()
if (customerName && party === customerName) return customerShort || customerName
if (customerShort && party === customerShort) return customerShort
if (invoiceName && party === invoiceName) return customerShort || invoiceName
return party
}
/** 重建合同名称:始终 {供方简称}-{需方简称}({合同编号}),随面板调换 */
const rebuildContractName = () => {
const partyAShort = resolvePartyShortName(form.value.partyA)
const partyBShort = resolvePartyShortName(form.value.partyB)
const nameBody = [partyAShort, partyBShort].filter(Boolean).join('-')
if (!nameBody) return
const contractNo = (form.value.contractNo || '').trim()
form.value.contractName = contractNo ? `${nameBody}(${contractNo})` : nameBody
}
/** 根据面板与交易主体/客户推断是否已调换(编辑回显用) */
const syncPartiesSwappedFlagFromPanels = () => {
const partyA = (form.value.partyA || '').trim()
const partyB = (form.value.partyB || '').trim()
if (!partyA && !partyB) {
isPartiesSwapped.value = false
return
}
const supplier = form.value.supplierId
? supplierList.value.find((s) => s.id === form.value.supplierId)
: undefined
const supplierName = (supplier?.supplierName || '').trim()
if (supplierName) {
if (partyB === supplierName && partyA !== supplierName) {
isPartiesSwapped.value = true
return
}
if (partyA === supplierName) {
isPartiesSwapped.value = false
return
}
}
const customerName = (form.value.customerName || '').trim()
const invoiceName = (customerInvoiceName.value || '').trim()
if ((customerName && partyA === customerName) || (invoiceName && partyA === invoiceName)) {
isPartiesSwapped.value = true
return
}
if ((customerName && partyB === customerName) || (invoiceName && partyB === invoiceName)) {
isPartiesSwapped.value = false
}
}
/** 供需方调换:仅交换面板字段,勿再调 handleSupplierChange(会按交易主体覆盖一侧,导致两边都变成交易主体) */
const handleSwapParties = async () => {
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
rebuildContractName()
$modal.msgSuccess('供需方信息已调换')
}
/** 合同类型变化处理 */
const handleContractTypeChange = (value: string) => {
if (isFrameworkContractType(value)) {
form.value.amount = 0
form.value.products?.forEach((p) => {
applyFrameworkDefaultsToProduct(p)
if (!p.priceTiers) {
p.priceTiers = []
}
})
} else {
form.value.products?.forEach((p) => {
normalizeFrameworkProductPricing(p, false)
calculateProductAmount(p)
})
calculateTotalAmount()
}
if (isPurchaseLikeContractType(value) && !isPartiesSwapped.value) {
$modal
.confirm('检测到您选择了采购类合同,系统将自动调换供需方信息,是否继续?')
.then(async () => {
await handleSwapParties()
})
.catch(() => {
rebuildContractName()
})
} else {
rebuildContractName()
}
}
/** 合同模版变化处理 */
const handleContractTemplateChange = (value: string) => {
if (value === 'customer_template') {
ElMessage({
message: '使用客户模版时,必须上传合同附件!',
type: 'warning',
duration: 3000
})
}
}
/** 合同期限日期范围变化 */
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')
})
}
/** 签订日期变化处理 */
const handleSignDateChange = (val: string | null) => {
if (val) {
const signDate = new Date(val)
const endDate = new Date(signDate)
endDate.setDate(endDate.getDate() + 30)
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 = isFrameworkContractType(form.value.contractType)
products.forEach((product) => {
const brandMeta = initLineBrandMetaFromProduct(product)
form.value.products.push({
productId: product.id,
productName: product.productName,
productModel: product.model || '',
categoryId: product.categoryId,
categoryName: product.categoryName || '',
unit: product.unit || '个',
...(isFw ? createFrameworkProductPricingDefaults() : { quantity: 1, price: product.price || 0, taxAmount: 0, subtotal: product.price || 0 }),
taxRate: normalizeTaxRateDisplay(product.taxRate, 13),
packageSpec: '',
deliveryMethod: '',
description: product.description || '',
remark: '',
isToxic: Number(product.isToxic || 0),
isExplosive: Number(product.isExplosive || 0),
...brandMeta,
priceTiers: isFw ? [] : undefined
})
})
form.value.products.forEach((p) => {
calculateProductAmount(p)
})
calculateTotalAmount()
}
const handleBrandIdChange = (row: ExtendedContractProductForm, val: string | undefined | null) => {
applyBrandSelectChange(row, val, row.brandOptions || [])
}
const aliasBrandManageVisible = ref(false)
const aliasBrandManageProductId = ref('')
const aliasBrandManageProductName = ref('')
const aliasBrandManageMode = ref<'alias' | 'brand'>('alias')
function openAliasBrandManage(row: ExtendedContractProductForm, mode: 'alias' | 'brand') {
if (!row.productId) return
aliasBrandManageProductId.value = String(row.productId)
aliasBrandManageProductName.value = String(
row.standardProductName || row.productName || ''
)
aliasBrandManageMode.value = mode
aliasBrandManageVisible.value = true
}
function handleAliasBrandManaged(product: Record<string, any>) {
applyManagedProductMetaToLines(
form.value.products || [],
aliasBrandManageProductId.value,
product
)
}
async function enrichContractProductBrandMeta(lines: ExtendedContractProductForm[]) {
await Promise.all(
(lines || []).map(async (line) => {
if (!line.productId) return
try {
const res: any = await fetchProductDetail({ id: String(line.productId) })
const data = res?.data ?? res
if (!data) return
const meta = initLineBrandMetaFromProduct(data)
line.standardProductName = meta.standardProductName
line.aliasOptions = meta.aliasOptions
line.aliasName = resolveAliasFromSavedName(
line.productName,
meta.standardProductName,
meta.aliasOptions
)
line.brandOptions = meta.brandOptions
if (meta.brandOptions.length) {
if (line.brandMode === 'required' && line.brandId) {
applyBrandSelectChange(line, line.brandId, meta.brandOptions)
} else {
line.brandMode = 'any'
line.brandId = undefined
line.brandName = undefined
}
} else {
line.brandMode = undefined
line.brandId = undefined
line.brandName = undefined
}
} catch (e) {
console.warn('enrich contract brand meta failed', line.productId, e)
}
})
)
}
/** 规范化税率展示 */
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(() => {
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 handleEnableFrameworkQuantityFixed = (row: ExtendedContractProductForm) => {
enableFrameworkQuantityFixed(row)
calculateProductAmount(row)
calculateTotalAmount()
}
const handleResetFrameworkQuantity = (row: ExtendedContractProductForm) => {
resetFrameworkQuantityToCustomerDemand(row)
calculateProductAmount(row)
calculateTotalAmount()
}
const handleEnableFrameworkPriceFixed = (row: ExtendedContractProductForm) => {
enableFrameworkPriceFixed(row)
calculateProductAmount(row)
calculateTotalAmount()
}
const handleResetFrameworkPrice = (row: ExtendedContractProductForm) => {
resetFrameworkPriceToMarket(row)
calculateTotalAmount()
}
/** 计算产品金额 */
const calculateProductAmount = (product: ExtendedContractProductForm) => {
if (isFrameworkContractType(form.value.contractType) && isFrameworkPriceMarket(product)) {
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')
const subtotal = Math.round(price * quantity * 100) / 100
product.subtotal = subtotal
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 (isFrameworkContractType(form.value.contractType)) {
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 与 letterOssIds 互转 */
const tierLetterCsv = (row: PriceTierRow) => (row.letterOssIds || []).join(',')
const syncTierLetterCsv = (row: PriceTierRow, val: string) => {
row.letterOssIds = String(val || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean)
}
/** 组装提交/保存的产品明细 */
const buildProductsPayload = () => {
const list = form.value.products || []
list.forEach((product) => prepareLineAliasAndBrandForSubmit(product))
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,
brandMode: product.brandMode || null,
brandId: product.brandMode === 'required' ? product.brandId || null : null,
brandName: product.brandMode === 'required' ? product.brandName || null : null
}
if (product.id) {
base.id = product.id
}
if (isFrameworkContractType(form.value.contractType)) {
const fwPricing = prepareFrameworkProductForSubmit(product)
Object.assign(base, fwPricing)
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: [] })
}
const removePriceTierRow = (pIdx: number, tIdx: number) => {
const p = form.value.products[pIdx]
if (!p?.priceTiers) return
p.priceTiers.splice(tIdx, 1)
}
/** 处理附件数据 */
const handleAttachmentData = async () => {
attachmentList.value = buildUploadFileListFromField(
form.value.attachmentFiles
) as ContractAttachmentItem[]
}
/** 保存草稿 */
const handleSaveDraft = async () => {
if (!form.value.customerId) {
$modal.msgWarning('客户名称不能为空')
return
}
if (!form.value.supplierId) {
$modal.msgWarning('交易主体不能为空')
return
}
const confirmedZero = await confirmZeroPricingIfNeeded(
form.value.products,
isFrameworkContractType(form.value.contractType)
)
if (!confirmedZero) return
rebuildContractName()
saveDraftLoading.value = true
try {
const attachmentJson = serializeContractAttachments(attachmentList.value)
const data: any = { ...form.value }
data.status = 'draft'
data.approvalStatus = null
if (attachmentJson) {
data.attachmentFiles = attachmentJson
}
omitEmptyExceptPartyFields(data)
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) {
;(form.value.products || []).forEach((p) => prepareLineAliasAndBrandForSubmit(p))
for (let i = 0; i < form.value.products.length; i++) {
const product = form.value.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('保存成功')
}
handleClose()
emit('success')
} finally {
saveDraftLoading.value = false
}
}
/** 提交审批 */
const handleSubmit = async () => {
try {
await formRef.value?.validate()
} catch (error) {
expandPartiesForInvalidFields(error as Record<string, unknown>)
ElMessage.warning({
message: '请填写完整的必填信息',
duration: 3000
})
return
}
if (form.value.contractTemplate === 'customer') {
const hasAttachments = attachmentList.value.some((file) => file.status === 'success' && file.ossId)
if (!hasAttachments) {
ElMessage.warning({
message: '使用客户模版时,必须上传合同附件',
duration: 3000
})
return
}
}
const requiresProducts = form.value.contractType === 'sale' || form.value.contractType === 'purchase' || isFrameworkContractType(form.value.contractType)
const validateProductRowCommon = (product: any, rowNum: number): boolean => {
if (!product.productName || String(product.productName).trim() === '') {
$modal.msgWarning(`第${rowNum}行产品的产品名称不能为空`)
return false
}
const isFwRow = isFrameworkContractType(form.value.contractType)
if (isFwRow) {
if (product.quantityMode === 'fixed') {
if (isBlankNumeric(product.quantity)) {
$modal.msgWarning(`第${rowNum}行产品的数量不能为空`)
return false
}
if (isNegativeNumeric(product.quantity)) {
$modal.msgWarning(`第${rowNum}行产品的数量不能为负数`)
return false
}
}
} else if (isBlankNumeric(product.quantity)) {
$modal.msgWarning(`第${rowNum}行产品的数量不能为空`)
return false
} else if (isNegativeNumeric(product.quantity)) {
$modal.msgWarning(`第${rowNum}行产品的数量不能为负数`)
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
}
;(form.value.products || []).forEach((p) => prepareLineAliasAndBrandForSubmit(p))
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 = isFrameworkContractType(form.value.contractType)
if (!isFw) {
if (isBlankNumeric(product.price)) {
$modal.msgWarning(`第${rowNum}行产品的含税单价不能为空`)
return
}
if (isNegativeNumeric(product.price) || isNegativeNumeric(product.subtotal) || isNegativeNumeric(product.taxAmount)) {
$modal.msgWarning(`第${rowNum}行产品的含税单价、含税总价、税费不能为负数`)
return
}
} else {
if (product.priceMode === 'fixed') {
if (isBlankNumeric(product.price)) {
$modal.msgWarning(`第${rowNum}行产品的含税单价不能为空`)
return
}
if (isNegativeNumeric(product.price) || isNegativeNumeric(product.subtotal) || isNegativeNumeric(product.taxAmount)) {
$modal.msgWarning(`第${rowNum}行产品的含税单价、含税总价、税费不能为负数`)
return
}
}
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) {
;(form.value.products || []).forEach((p) => prepareLineAliasAndBrandForSubmit(p))
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 (isBlankNumeric(product.price)) {
$modal.msgWarning(`第${rowNum}行产品的含税单价不能为空`)
return
}
if (isNegativeNumeric(product.price) || isNegativeNumeric(product.subtotal) || isNegativeNumeric(product.taxAmount)) {
$modal.msgWarning(`第${rowNum}行产品的含税单价、含税总价、税费不能为负数`)
return
}
}
}
const confirmedZero = await confirmZeroPricingIfNeeded(
form.value.products,
isFrameworkContractType(form.value.contractType)
)
if (!confirmedZero) return
submitLoading.value = true
try {
rebuildContractName()
const attachmentJson = serializeContractAttachments(attachmentList.value)
const data: any = { ...form.value }
if (attachmentJson) {
data.attachmentFiles = attachmentJson
}
omitEmptyExceptPartyFields(data)
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 {
const res = await addContract(data)
contractId = res.data
if (contractId == null || contractId === '') {
$modal.msgError('保存成功但未返回合同 ID,请从列表重新提交审批')
emit('success')
return
}
form.value.id = contractId as any
$modal.msgSuccess('保存成功')
}
approvalDialogRef.value?.open(contractId, 'submit')
} finally {
submitLoading.value = false
}
}
/** 审批成功回调 */
const handleApprovalSuccess = () => {
handleClose()
emit('success')
}
/** 关闭对话框 */
const handleClose = () => {
dialogVisible.value = false
}
/** 加载供方信息列表 */
const loadSupplierList = async () => {
try {
const res = await listAllSupplierInfo({ status: '0' })
supplierList.value = res.data || []
return res.data || []
} catch (error) {
console.error('加载供方信息列表失败:', error)
return []
}
}
</script>
<style scoped lang="scss">
.contract-form-dialog {
:deep(.el-dialog__wrapper) {
padding: 0 !important;
top: 20px !important;
height: calc(100vh - 40px) !important;
}
:deep(.el-dialog) {
backdrop-filter: none;
height: 100% !important;
max-height: calc(100vh - 40px);
display: flex;
flex-direction: column;
}
:deep(.dialog-footer) {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
width: 100%;
}
:deep(.el-dialog__header),
:deep(.el-dialog__footer) {
flex: 0 0 auto;
}
:deep(.el-dialog__body) {
padding-top: 8px;
flex: 1 1 auto;
min-height: 0;
max-height: none;
overflow-y: auto;
overflow-x: hidden;
}
}
.contract-form-body {
position: relative;
min-height: 280px;
padding: 0 4px;
}
.contract-basic-info {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px 14px;
margin-bottom: 20px;
:deep(.el-form-item) {
margin-bottom: 0;
}
:deep(.el-input-number) {
width: 100%;
}
}
@media (max-width: 1100px) {
.contract-basic-info {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.contract-basic-info {
grid-template-columns: 1fr;
}
}
.parties-section {
margin: 8px 0 20px;
}
.parties-section-toolbar {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.parties-row {
display: flex;
align-items: stretch;
gap: 0;
min-height: 0;
}
.party-panel {
flex: 1 1 0;
min-width: 0;
padding: 16px;
border: 1px solid var(--border, #d9e8e2);
border-radius: 10px;
background: #fff;
&.is-collapsed {
padding: 12px 16px;
}
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.party-panel__header {
display: flex;
align-items: center;
margin-bottom: 12px;
.party-panel.is-collapsed & {
margin-bottom: 8px;
}
}
.party-panel__title {
margin-bottom: 0;
padding-left: 10px;
font-size: 14px;
font-weight: 600;
color: var(--section-bar-title-color, var(--primary-dark, #059669));
line-height: 1.5;
border-left: 3px solid var(--section-bar-border-color, var(--primary, #10b981));
}
.party-panel__summary {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
padding: 2px 0 2px 10px;
}
.party-panel__summary-label {
flex-shrink: 0;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
line-height: 1.5;
}
.party-panel__summary-value {
min-width: 0;
font-size: 14px;
font-weight: 500;
color: var(--mint-text, #0f172a);
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.is-empty {
font-weight: 400;
color: var(--mint-text-muted, #94a3b8);
}
}
.party-flow-bridge {
flex: 0 0 88px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 0 8px;
}
.party-flow-bridge__label {
font-size: 12px;
font-weight: 600;
color: var(--primary-dark, #168f45);
letter-spacing: 0.06em;
padding: 4px 10px;
border-radius: 999px;
background: #e6f8ed;
border: 1px solid #a8e0b8;
line-height: 1.2;
}
.party-flow-bridge__arrow {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 50%;
color: var(--primary-dark, #168f45);
background: linear-gradient(145deg, #e6f8ed 0%, #d4f0de 100%);
border: 1px solid #a8e0b8;
box-shadow: 0 2px 8px rgba(28, 168, 81, 0.14);
svg {
width: 22px;
height: 22px;
display: block;
}
}
@media (max-width: 1100px) {
.parties-row {
flex-direction: column;
min-height: 0;
}
.party-flow-bridge {
flex: 0 0 auto;
flex-direction: row;
gap: 12px;
padding: 12px 0;
}
.party-flow-bridge__arrow {
transform: rotate(90deg);
}
}
.total-section {
margin-top: 20px;
padding: 15px;
background-color: #f5f7fa;
border-radius: 4px;
}
.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;
}
.text-muted {
color: #1e293b;
}
:deep(.money-field-input.el-input-number .el-input__inner),
:deep(.money-field-input.el-input-number .el-input__wrapper input) {
color: #dc2626 !important;
-webkit-text-fill-color: #dc2626 !important;
font-weight: 600;
}
/* 统一表格内输入框和下拉框高度 */
: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;
}
/* 价格输入框红色文字 */
.price-input :deep(.el-input__inner) {
color: #e34d59;
}
/* 去除输入框后置按钮的边框和背景 */
: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;
}
/* 框架合同产品明细:默认文案 + 可切换填数字 */
.framework-pricing-cell {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
}
.framework-pricing-cell--money {
align-items: flex-end;
}
.framework-pricing-cell__label {
font-size: 13px;
line-height: 1.4;
white-space: nowrap;
}
.framework-pricing-cell__action {
padding: 0 !important;
height: auto !important;
font-size: 12px !important;
}
/* 框架合同调价函:表格内紧凑附件上传 */
.fw-price-tier-table :deep(.attachment-upload--compact:not(.attachment-upload--readonly)) {
min-width: 0;
.attachment-upload {
width: auto;
}
.attachment-list {
flex-wrap: wrap;
align-items: flex-start;
}
}
/* 框架合同:增加「调价函」行内按钮可点区域宽度 */
.fw-tier-letter-add-btn.el-button--primary.is-link {
min-width: 108px;
padding-left: 12px;
padding-right: 12px;
justify-content: center;
}
.mb-3 {
margin-bottom: 12px;
}
.mb-4 {
margin-bottom: 16px;
}
.ml-2 {
margin-left: 8px;
}
.line-meta-cell {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.line-meta-cell__control {
flex: 1;
min-width: 0;
}
.line-meta-cell__empty {
flex: 1;
min-width: 0;
}
.line-meta-cell__btn {
flex-shrink: 0;
padding: 4px;
}
.customer-select-row {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
}
.customer-select-row__select {
flex: 1;
min-width: 0;
}
.customer-select-row__search {
flex-shrink: 0;
padding: 0 8px;
}
.customer-option-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
min-width: 0;
}
.customer-option-row__meta {
flex-shrink: 0;
font-size: 12px;
color: var(--mint-text-muted, #8492a6);
}
</style>