Просмотр исходного кода

feat(crm): sync contract number date segment with sign date

Align standard contract nos on create/update (keep seq, bump on collision),
preview the change in contract forms, and sync name parentheses.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 месяцев назад
Родитель
Сommit
594e4ce38e

+ 90 - 0
backend/app/crud/crm/contract.py

@@ -625,6 +625,71 @@ def generate_contract_no(
     raise ValueError("生成合同编号失败,请稍后重试")
 
 
+def contract_no_exists(
+    db: Session,
+    tenant_no: str,
+    contract_no: str,
+    *,
+    exclude_id: str = None,
+) -> bool:
+    """租户内合同编号是否已占用(含软删,保证全局唯一)。"""
+    q = db.query(CrmContract.id).filter(
+        CrmContract.tenant_no == tenant_no,
+        CrmContract.contract_no == contract_no,
+    )
+    if exclude_id:
+        q = q.filter(CrmContract.id != exclude_id)
+    return q.first() is not None
+
+
+_CONTRACT_NO_STD_RE = re.compile(r"^([A-Z]+)/X-JT(\d{8})(\d{3})$")
+
+
+def replace_contract_no_date(contract_no: str, sign_date: date) -> Optional[str]:
+    """标准编号替换日期段,保留序号;非标准返回 None。"""
+    if not contract_no or not sign_date:
+        return None
+    m = _CONTRACT_NO_STD_RE.match(contract_no.strip())
+    if not m:
+        return None
+    supplier_code, _old_date, seq = m.group(1), m.group(2), m.group(3)
+    date_str = sign_date.strftime("%Y%m%d")
+    return f"{supplier_code}/X-JT{date_str}{seq}"
+
+
+def align_contract_no_to_sign_date(
+    db: Session,
+    tenant_no: str,
+    contract_no: str,
+    sign_date: date,
+    *,
+    exclude_id: str = None,
+) -> str:
+    """将标准合同编号日期段对齐签订日;撞号则序号顺延(含软删查重)。
+
+    非标准编号原样返回。无签订日则原样返回。
+    """
+    if not (contract_no or "").strip():
+        return contract_no or ""
+    if not sign_date:
+        return contract_no.strip()
+
+    replaced = replace_contract_no_date(contract_no, sign_date)
+    if replaced is None:
+        return contract_no.strip()
+
+    m = _CONTRACT_NO_STD_RE.match(replaced)
+    if not m:
+        return replaced
+    supplier_code, date_str, seq = m.group(1), m.group(2), m.group(3)
+    start_seq = int(seq)
+    for retry in range(50):
+        candidate = f"{supplier_code}/X-JT{date_str}{start_seq + retry:03d}"
+        if not contract_no_exists(db, tenant_no, candidate, exclude_id=exclude_id):
+            return candidate
+    raise ValueError("生成合同编号失败,请稍后重试")
+
+
 def generate_contract_name(
     contract_no: str,
     contract_type: str,
@@ -929,6 +994,10 @@ def create_contract(
     contract_no = (_get_field(data, "contract_no", "contractNo") or "").strip()
     if not contract_no:
         contract_no = generate_contract_no(db, tenant_no, supplier_id=supplier_id, sign_date=sign_date)
+    else:
+        contract_no = align_contract_no_to_sign_date(db, tenant_no, contract_no, sign_date)
+        if contract_no_exists(db, tenant_no, contract_no):
+            raise ValueError(f"合同编号已存在:{contract_no}")
 
     customer = get_customer(db, customer_id)
     customer_short_name = (customer.short_name or "") if customer else ""
@@ -1036,6 +1105,27 @@ def update_contract(
     if product_rows is not None:
         _resolve_contract_product_ids(db, tenant_no=tenant_no, products=product_rows, user_id=user_id)
 
+    sign_date = old.sign_date or date.today()
+    submitted_no = (_get_field(data, "contract_no", "contractNo") or "").strip() or (
+        old.contract_no or ""
+    ).strip()
+    if submitted_no:
+        aligned_no = align_contract_no_to_sign_date(
+            db,
+            tenant_no,
+            submitted_no,
+            sign_date,
+            exclude_id=old.id,
+        )
+        if aligned_no != (old.contract_no or ""):
+            if contract_no_exists(db, tenant_no, aligned_no, exclude_id=old.id):
+                raise ValueError(f"合同编号已存在:{aligned_no}")
+            old.contract_no = aligned_no
+        elif submitted_no != (old.contract_no or ""):
+            if contract_no_exists(db, tenant_no, submitted_no, exclude_id=old.id):
+                raise ValueError(f"合同编号已存在:{submitted_no}")
+            old.contract_no = submitted_no
+
     supplier_short_name = _get_supplier_short_name(db, old.supplier_id or "", tenant_no)
     customer = get_customer(db, old.customer_id) if old.customer_id else None
     customer_short_name = (customer.short_name or "") if customer else (old.customer_name or "")

+ 63 - 0
backend/tests/test_crm_contract_no_align.py

@@ -0,0 +1,63 @@
+from datetime import date
+from unittest.mock import MagicMock, patch
+
+from app.crud.crm.contract import align_contract_no_to_sign_date, replace_contract_no_date
+
+
+def test_replace_keeps_seq_and_swaps_date():
+    assert (
+        replace_contract_no_date("SX/X-JT20250803001", date(2025, 8, 10))
+        == "SX/X-JT20250810001"
+    )
+
+
+def test_replace_noop_when_same_date():
+    assert replace_contract_no_date("SX/X-JT20250803001", date(2025, 8, 3)) == "SX/X-JT20250803001"
+
+
+def test_replace_returns_none_for_nonstandard():
+    assert replace_contract_no_date("CUSTOM-001", date(2025, 8, 10)) is None
+    assert replace_contract_no_date("", date(2025, 8, 10)) is None
+    assert replace_contract_no_date(None, date(2025, 8, 10)) is None
+
+
+def test_align_returns_original_when_nonstandard():
+    db = MagicMock()
+    assert (
+        align_contract_no_to_sign_date(
+            db, "t1", "CUSTOM-001", date(2025, 8, 10)
+        )
+        == "CUSTOM-001"
+    )
+
+
+def test_align_bumps_seq_when_occupied():
+    db = MagicMock()
+
+    def _exists(db_, tenant, no, exclude_id=None):
+        return no == "SX/X-JT20250810001"
+
+    with patch("app.crud.crm.contract.contract_no_exists", side_effect=_exists):
+        out = align_contract_no_to_sign_date(
+            db, "t1", "SX/X-JT20250803001", date(2025, 8, 10)
+        )
+        assert out == "SX/X-JT20250810002"
+
+
+def test_align_exclude_id_skips_self():
+    db = MagicMock()
+
+    def _exists(db_, tenant, no, exclude_id=None):
+        if exclude_id == "self-id":
+            return False
+        return no == "SX/X-JT20250810001"
+
+    with patch("app.crud.crm.contract.contract_no_exists", side_effect=_exists):
+        out = align_contract_no_to_sign_date(
+            db,
+            "t1",
+            "SX/X-JT20250803001",
+            date(2025, 8, 10),
+            exclude_id="self-id",
+        )
+        assert out == "SX/X-JT20250810001"

+ 1 - 1
docs/superpowers/specs/2026-08-03-crm-contract-no-follow-sign-date-design.md

@@ -2,7 +2,7 @@
 
 **日期**:2026-08-03  
 **范围**:`web-admin` 合同管理新增/编辑;`backend` 合同 create/update(草稿)  
-**状态**:已评审,待实现
+**状态**:已实现
 
 ## 背景
 

+ 43 - 23
web-admin/src/views/crm/contract/components/ContractFormDialog.vue

@@ -19,7 +19,12 @@
             <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-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">
@@ -242,7 +247,7 @@
               </div>
             </div>
             <div class="list-body list-wrap">
-              <el-table :data="form.products" class="list-table" stripe @selection-change="handleProductSelectionChange">
+              <ListTable table-key="crm-contract-components-contract-form-dialog" :data="form.products" class="list-table" stripe @selection-change="handleProductSelectionChange">
             <el-table-column type="selection" min-width="55" align="center" header-align="center" />
             <el-table-column label="序号" type="index" min-width="60" align="center" header-align="center" />
             <el-table-column label="产品名称" min-width="160" :show-overflow-tooltip="true">
@@ -410,7 +415,7 @@
                 </el-button>
               </template>
             </el-table-column>
-              </el-table>
+              </ListTable>
             </div>
           </div>
 
@@ -427,7 +432,7 @@
               </el-button>
               <div class="panel dialog-list-panel mb-3">
                 <div class="list-body list-wrap dialog-list-body-only">
-                  <el-table :data="prod.priceTiers || []" class="list-table" stripe empty-text="暂无调价区间">
+                  <ListTable table-key="crm-contract-components-contract-form-dialog-2" :data="prod.priceTiers || []" class="list-table" stripe empty-text="暂无调价区间">
                 <el-table-column type="index" label="#" min-width="50" align="center" header-align="center" />
                 <el-table-column label="开始日期" min-width="150">
                   <template #default="scope">
@@ -468,7 +473,7 @@
                     </el-button>
                   </template>
                 </el-table-column>
-                  </el-table>
+                  </ListTable>
                 </div>
               </div>
             </div>
@@ -601,6 +606,7 @@ import {
 } from '@/utils/crm-product-brand'
 import { fetchProductDetail } from '@/api/crm/product'
 import ApprovalDialog from './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'
@@ -1348,6 +1354,31 @@ const updateContractNoBySupplier = (newSupplierCode: string | undefined) => {
   }
 }
 
+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) {
+    const replaced = form.value.contractName.replace(/[((][^))]*[))]$/, `(${nextNo})`)
+    if (replaced !== form.value.contractName) {
+      form.value.contractName = replaced
+    } else {
+      rebuildContractName()
+    }
+  }
+}
+
 const COMMON_SUPPLIER_SHORT_NAMES = ['山峡', 'SX']
 
 const extractContractNameParts = (contractName: string) => {
@@ -1366,7 +1397,7 @@ const isSupplierShortNamePart = (part: string, supplierShort: string) => {
   return part === supplierShort || COMMON_SUPPLIER_SHORT_NAMES.some((name) => part.includes(name))
 }
 
-/** 从现有合同名称推断客户简称(切换合同类型时不能依赖新类型解析旧格式) */
+/** 从现有合同名称推断客户简称(统一格式:供方简称-需方简称) */
 const inferCustomerShortNameFromContractName = (): string => {
   if (customerShortName.value) return customerShortName.value
   if (!form.value.contractName) return ''
@@ -1383,14 +1414,13 @@ const inferCustomerShortNameFromContractName = (): string => {
     return isSupplierShortNamePart(parts[0], supplierShort) ? '' : parts[0]
   }
 
+  // 统一格式:供方(交易主体)-需方(客户);供方在前则取第二段为客户简称
   if (isSupplierShortNamePart(parts[0], supplierShort)) return parts[1]
   if (isSupplierShortNamePart(parts[1], supplierShort)) return parts[0]
-
-  if (form.value.contractType === 'purchase') return parts[0]
   return parts[1]
 }
 
-/** 按合同类型重建合同名称:销售/框架=供方简称-客户简称,采购=客户简称-供方简称 */
+/** 重建合同名称:统一为 {供方简称}-{需方简称}({合同编号}),不因采购反序 */
 const rebuildContractName = () => {
   if (!form.value.contractName) return
 
@@ -1400,10 +1430,7 @@ const rebuildContractName = () => {
 
   const { contractNoInName } = extractContractNameParts(form.value.contractName)
   const contractNo = form.value.contractNo || contractNoInName
-  const isPurchase = form.value.contractType === 'purchase'
-  const nameBody = isPurchase
-    ? [customerShort, supplierShort].filter(Boolean).join('-')
-    : [supplierShort, customerShort].filter(Boolean).join('-')
+  const nameBody = [supplierShort, customerShort].filter(Boolean).join('-')
 
   if (!nameBody) return
 
@@ -1534,6 +1561,7 @@ const handleSignDateChange = (val: string | null) => {
     form.value.startDate = ''
     form.value.endDate = ''
   }
+  syncContractNoDateFromSignDate(val)
   nextTick(() => {
     formRef.value?.validateField('startDate')
   })
@@ -1919,11 +1947,7 @@ const handleSaveDraft = async () => {
       data.attachmentFiles = attachmentJson
     }
 
-    Object.keys(data).forEach((key) => {
-      if (data[key] === undefined || data[key] === '') {
-        delete data[key]
-      }
-    })
+    omitEmptyExceptPartyFields(data)
 
     if (data.contactsName) {
       data.contactName = data.contactsName
@@ -2086,11 +2110,7 @@ const handleSubmit = async () => {
       data.attachmentFiles = attachmentJson
     }
 
-    Object.keys(data).forEach((key) => {
-      if (data[key] === undefined || data[key] === '') {
-        delete data[key]
-      }
-    })
+    omitEmptyExceptPartyFields(data)
 
     if (data.contactsName) {
       data.contactName = data.contactsName

+ 63 - 178
web-admin/src/views/crm/contract/form.vue

@@ -19,7 +19,12 @@
             </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-input
+                  v-model="form.contractName"
+                  placeholder="可不填,自动生成:供方简称-需方简称(合同编号)"
+                  maxlength="100"
+                  show-word-limit
+                />
               </el-form-item>
             </el-col>
             <el-col :span="6">
@@ -600,6 +605,7 @@ 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';
@@ -1324,11 +1330,29 @@ const updateContractNoBySupplier = (newSupplierCode: string | undefined) => {
   }
 };
 
+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) {
@@ -1336,7 +1360,6 @@ const updateContractNameBySupplier = (newSupplierShortName: string | undefined)
   }
 
   const contractName = form.value.contractName;
-  const contractType = form.value.contractType;
 
   // 提取合同编号(在括号中,支持中文和英文括号)
   const contractNoMatch = contractName.match(/[((](.+?)[))]$/);
@@ -1348,78 +1371,21 @@ const updateContractNameBySupplier = (newSupplierShortName: string | undefined)
   // 常见的供方简称列表(用于识别和替换)
   const commonSupplierNames = ['山峡', 'SX'];
 
-  // 根据合同类型解析和替换
-  if (contractType === 'sale' || contractType === 'framework') {
-    // 销售/框架合同格式:供方简称-客户简称(合同编号)
-    // 例如:山峡-客户A(SX/X-JT20250926002)
-    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]}`;
-      }
-    }
-  } else if (contractType === 'purchase') {
-    // 采购合同格式:客户简称-供方简称(合同编号)
-    // 例如:客户A-山峡(SX/X-JT20250926002)
-    const parts = nameBody.split('-').map((p) => p.trim());
-    if (parts.length >= 2) {
-      // 替换最后一个部分(供方简称)
-      parts[parts.length - 1] = 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 ? `${parts[0]}-${newSupplierShortName}(${contractNo})` : `${parts[0]}-${newSupplierShortName}`;
-      }
-    }
-  } else {
-    // 其他类型,尝试替换所有可能的供方简称
-    let updatedName = contractName;
-    let replaced = false;
-
-    for (const oldName of commonSupplierNames) {
-      if (updatedName.includes(oldName)) {
-        // 使用正则替换,保持格式
-        updatedName = updatedName.replace(new RegExp(oldName, 'g'), newSupplierShortName);
-        replaced = true;
-        break;
-      }
-    }
-
-    // 如果没有找到已知的供方简称,尝试在名称开头或结尾查找
-    if (!replaced && nameBody) {
-      // 检查是否以常见供方简称开头或结尾
-      for (const oldName of commonSupplierNames) {
-        if (nameBody.startsWith(oldName)) {
-          updatedName = contractName.replace(oldName, newSupplierShortName);
-          replaced = true;
-          break;
-        } else if (nameBody.endsWith(oldName)) {
-          updatedName = contractName.replace(oldName, newSupplierShortName);
-          replaced = true;
-          break;
-        }
-      }
+  // 统一格式:供方简称-需方简称(合同编号)
+  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]}`;
     }
-
-    form.value.contractName = updatedName;
   }
 
   // 如果合同编号已更新,同步更新合同名称中的合同编号
@@ -1430,10 +1396,8 @@ const updateContractNameBySupplier = (newSupplierShortName: string | undefined)
 };
 
 /**
- * 更新合同名称中的客户简称
- * 合同名称格式:
- * - 销售合同、框架合同:供方简称-客户简称(合同编号)
- * - 采购合同:客户简称-供方简称(合同编号)
+ * 更新合同名称中的客户简称(需方)
+ * 统一格式:{供方简称}-{需方简称}({合同编号})
  */
 const updateContractNameByCustomer = (newCustomerShortName: string | undefined) => {
   if (!form.value.contractName || !newCustomerShortName) {
@@ -1441,7 +1405,6 @@ const updateContractNameByCustomer = (newCustomerShortName: string | undefined)
   }
 
   const contractName = form.value.contractName;
-  const contractType = form.value.contractType;
 
   // 提取合同编号(在括号中,支持中文和英文括号)
   const contractNoMatch = contractName.match(/[((](.+?)[))]$/);
@@ -1453,92 +1416,21 @@ const updateContractNameByCustomer = (newCustomerShortName: string | undefined)
   // 常见的供方简称列表(用于识别,避免误替换)
   const commonSupplierNames = ['山峡', 'SX'];
 
-  // 根据合同类型解析和替换
-  if (contractType === 'sale' || contractType === 'framework') {
-    // 销售/框架合同格式:供方简称-客户简称(合同编号)
-    // 例如:山峡-客户A(SX/X-JT20250926002)
-    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;
-      }
-    }
-  } else if (contractType === 'purchase') {
-    // 采购合同格式:客户简称-供方简称(合同编号)
-    // 例如:客户A-山峡(SX/X-JT20250926002)
-    const parts = nameBody.split('-').map((p) => p.trim());
-    if (parts.length >= 2) {
-      // 替换第一个部分(客户简称)
-      parts[0] = 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 ? `${newCustomerShortName}-${parts[0]}(${contractNo})` : `${newCustomerShortName}-${parts[0]}`;
-      } else {
-        // 可能是客户简称,替换它
-        form.value.contractName = contractNo ? `${newCustomerShortName}(${contractNo})` : newCustomerShortName;
-      }
-    }
-  } else {
-    // 其他类型,尝试智能替换客户简称
-    // 策略:如果名称中包含已知的供方简称,则替换另一个部分;否则尝试替换非供方简称的部分
-    let updatedName = contractName;
-    let replaced = false;
-
-    // 先检查是否包含供方简称
-    let supplierNameInBody = '';
-    for (const supplierName of commonSupplierNames) {
-      if (nameBody.includes(supplierName)) {
-        supplierNameInBody = supplierName;
-        break;
-      }
-    }
-
-    if (supplierNameInBody) {
-      // 如果包含供方简称,替换另一个部分
-      const parts = nameBody.split('-').map((p) => p.trim());
-      if (parts.length >= 2) {
-        // 找到供方简称的位置并替换另一个部分
-        const supplierIndex = parts.findIndex((p) => p.includes(supplierNameInBody));
-        const customerIndex = supplierIndex === 0 ? 1 : 0;
-        parts[customerIndex] = newCustomerShortName;
-        const newNameBody = parts.join('-');
-        updatedName = contractNo ? `${newNameBody}(${contractNo})` : newNameBody;
-        replaced = true;
-      }
-    }
-
-    // 如果没有替换成功,尝试直接替换非供方简称的部分
-    if (!replaced && nameBody) {
-      const parts = nameBody.split('-').map((p) => p.trim());
-      if (parts.length >= 2) {
-        // 替换第一个部分(假设是客户简称)
-        parts[0] = newCustomerShortName;
-        const newNameBody = parts.join('-');
-        updatedName = contractNo ? `${newNameBody}(${contractNo})` : newNameBody;
-      } else if (parts.length === 1 && !commonSupplierNames.some((name) => parts[0].includes(name))) {
-        // 如果只有一个部分且不是供方简称,则替换它
-        updatedName = contractNo ? `${newCustomerShortName}(${contractNo})` : newCustomerShortName;
-      }
+  // 统一格式:供方简称-需方简称(合同编号)
+  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;
     }
-
-    form.value.contractName = updatedName;
   }
 
   // 如果合同编号已更新,同步更新合同名称中的合同编号
@@ -1679,6 +1571,7 @@ const handleSignDateChange = (val: string | null) => {
     form.value.startDate = '';
     form.value.endDate = '';
   }
+  syncContractNoDateFromSignDate(val);
   // 触发合同期限字段的验证
   nextTick(() => {
     formRef.value?.validateField('startDate');
@@ -2184,12 +2077,8 @@ const handleSaveDraft = async () => {
       data.attachmentFiles = JSON.stringify(attachmentsWithCategory);
     }
 
-    // 清理未填写的字段,避免发送 undefined 到后端
-    Object.keys(data).forEach((key) => {
-      if (data[key] === undefined || data[key] === '') {
-        delete data[key];
-      }
-    });
+    // 清理未填写的字段;供/需方空字符串保留,以便清空落库
+    omitEmptyExceptPartyFields(data);
 
     // 联系人字段映射(前端contactsName/contactsPhone映射回后端contactName/contactMobile)
     if (data.contactsName) {
@@ -2366,12 +2255,8 @@ const handleSubmit = async () => {
       data.attachmentFiles = JSON.stringify(attachmentsWithCategory);
     }
 
-    // 清理未填写的字段,避免发送 undefined 到后端
-    Object.keys(data).forEach((key) => {
-      if (data[key] === undefined || data[key] === '') {
-        delete data[key];
-      }
-    });
+    // 清理未填写的字段;供/需方空字符串保留,以便清空落库
+    omitEmptyExceptPartyFields(data);
 
     // 联系人字段映射(前端contactsName/contactsPhone映射回后端contactName/contactMobile)
     if (data.contactsName) {