zlwu před 5 měsíci
rodič
revize
ef2024b456
18 změnil soubory, kde provedl 1178 přidání a 278 odebrání
  1. 53 0
      crm/backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/domain/CrmContractProductPriceTier.java
  2. 1 1
      crm/backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/domain/bo/CrmContractBo.java
  3. 8 2
      crm/backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/domain/bo/CrmContractProductBo.java
  4. 36 0
      crm/backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/domain/bo/CrmContractProductPriceTierBo.java
  5. 1 1
      crm/backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/domain/bo/CrmPaymentPlanBo.java
  6. 38 0
      crm/backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/domain/vo/CrmContractProductPriceTierVo.java
  7. 6 0
      crm/backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/domain/vo/CrmContractProductVo.java
  8. 11 0
      crm/backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/mapper/CrmContractProductPriceTierMapper.java
  9. 5 1
      crm/backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/service/impl/CrmContractProductServiceImpl.java
  10. 249 18
      crm/backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/service/impl/CrmContractServiceImpl.java
  11. 11 0
      crm/sql/add_contract_price_tier_letter_oss_ids.sql
  12. 21 0
      crm/sql/add_contract_product_price_tier.sql
  13. 15 0
      crm/web/src/api/crm/contract/index.ts
  14. 11 2
      crm/web/src/components/ImagePreview/index.vue
  15. 22 9
      crm/web/src/components/QuoteSelector.vue
  16. 120 15
      crm/web/src/views/crm/contract/detail.vue
  17. 408 163
      crm/web/src/views/crm/contract/form.vue
  18. 162 66
      crm/web/src/views/crm/contract/index.vue

+ 53 - 0
crm/backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/domain/CrmContractProductPriceTier.java

@@ -0,0 +1,53 @@
+package org.dromara.crm.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.dromara.common.tenant.core.TenantEntity;
+
+import java.io.Serial;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+
+/**
+ * 框架合同产品调价区间 crm_contract_product_price_tier
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("crm_contract_product_price_tier")
+public class CrmContractProductPriceTier extends TenantEntity {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+
+    private Long contractId;
+
+    private Long contractProductId;
+
+    private LocalDate startDate;
+
+    private LocalDate endDate;
+
+    private BigDecimal price;
+
+    /**
+     * 调价函 OSS ID 列表(JSON 字符串数组)
+     */
+    private String letterOssIds;
+
+    /**
+     * @deprecated 使用 letterOssIds
+     */
+    private Long letterOssId;
+
+    private Integer sort;
+
+    @TableLogic
+    private Integer delFlag;
+}

+ 1 - 1
crm/backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/domain/bo/CrmContractBo.java

@@ -98,7 +98,7 @@ public class CrmContractBo extends BaseEntity {
      * 合同金额
      */
     @NotNull(message = "合同金额不能为空", groups = { AddGroup.class, EditGroup.class })
-    @DecimalMin(value = "0.01", message = "合同金额必须大于0")
+    @DecimalMin(value = "0", message = "合同金额不能为负数")
     private BigDecimal amount;
 
     /**

+ 8 - 2
crm/backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/domain/bo/CrmContractProductBo.java

@@ -11,6 +11,7 @@ import org.dromara.crm.domain.CrmContractProduct;
 
 import java.math.BigDecimal;
 import java.time.LocalDate;
+import java.util.List;
 
 /**
  * CRM合同产品明细业务对象 crm_contract_product
@@ -47,10 +48,10 @@ public class CrmContractProductBo extends BaseEntity {
     private BigDecimal quantity;
 
     /**
-     * 单价
+     * 单价(框架合同固定为0,由业务层按合同类型校验)
      */
     @NotNull(message = "单价不能为空", groups = { AddGroup.class, EditGroup.class })
-    @DecimalMin(value = "0.01", message = "单价必须大于0")
+    @DecimalMin(value = "0", message = "单价不能为负数")
     private BigDecimal price;
 
     /**
@@ -120,4 +121,9 @@ public class CrmContractProductBo extends BaseEntity {
     private String model; // 产品型号
     private String unit; // 计量单位
     private BigDecimal subtotal; // 小计金额
+
+    /**
+     * 框架合同:调价区间(不落库到产品表,单独表保存)
+     */
+    private List<CrmContractProductPriceTierBo> priceTiers;
 }

+ 36 - 0
crm/backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/domain/bo/CrmContractProductPriceTierBo.java

@@ -0,0 +1,36 @@
+package org.dromara.crm.domain.bo;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.dromara.common.mybatis.core.domain.BaseEntity;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+
+/**
+ * 框架合同产品调价区间 BO
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class CrmContractProductPriceTierBo extends BaseEntity {
+
+    private Long id;
+
+    private Long contractId;
+
+    private Long contractProductId;
+
+    private LocalDate startDate;
+
+    private LocalDate endDate;
+
+    private BigDecimal price;
+
+    /**
+     * 调价函附件 OSS ID 列表(多张图片)
+     */
+    private List<String> letterOssIds;
+
+    private Integer sort;
+}

+ 1 - 1
crm/backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/domain/bo/CrmPaymentPlanBo.java

@@ -60,7 +60,7 @@ public class CrmPaymentPlanBo extends BaseEntity {
      * 计划回款金额
      */
     @NotNull(message = "计划回款金额不能为空", groups = { AddGroup.class, EditGroup.class })
-    @DecimalMin(value = "0.01", message = "计划回款金额必须大于0")
+    @DecimalMin(value = "0", message = "计划回款金额不能为负数")
     private BigDecimal planAmount;
 
     /**

+ 38 - 0
crm/backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/domain/vo/CrmContractProductPriceTierVo.java

@@ -0,0 +1,38 @@
+package org.dromara.crm.domain.vo;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+
+/**
+ * 框架合同产品调价区间 VO
+ */
+@Data
+public class CrmContractProductPriceTierVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    private Long id;
+
+    private Long contractId;
+
+    private Long contractProductId;
+
+    private LocalDate startDate;
+
+    private LocalDate endDate;
+
+    private BigDecimal price;
+
+    /**
+     * 调价函 OSS ID 列表(解析自 letter_oss_ids)
+     */
+    private List<String> letterOssIds;
+
+    private Integer sort;
+}

+ 6 - 0
crm/backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/domain/vo/CrmContractProductVo.java

@@ -15,6 +15,7 @@ import java.math.BigDecimal;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.util.Date;
+import java.util.List;
 
 /**
  * CRM合同产品明细视图对象 crm_contract_product
@@ -169,4 +170,9 @@ public class CrmContractProductVo implements Serializable {
      */
     @ExcelProperty(value = "备注")
     private String remark;
+
+    /**
+     * 框架合同调价区间(查询时组装)
+     */
+    private List<CrmContractProductPriceTierVo> priceTiers;
 }

+ 11 - 0
crm/backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/mapper/CrmContractProductPriceTierMapper.java

@@ -0,0 +1,11 @@
+package org.dromara.crm.mapper;
+
+import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
+import org.dromara.crm.domain.CrmContractProductPriceTier;
+import org.dromara.crm.domain.vo.CrmContractProductPriceTierVo;
+
+/**
+ * 框架合同产品调价区间 Mapper
+ */
+public interface CrmContractProductPriceTierMapper extends BaseMapperPlus<CrmContractProductPriceTier, CrmContractProductPriceTierVo> {
+}

+ 5 - 1
crm/backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/service/impl/CrmContractProductServiceImpl.java

@@ -95,7 +95,11 @@ public class CrmContractProductServiceImpl implements ICrmContractProductService
             entity.setDeliveredQuantity(BigDecimal.ZERO);
         }
 
-        return baseMapper.insert(entity) > 0;
+        boolean ok = baseMapper.insert(entity) > 0;
+        if (ok) {
+            bo.setId(entity.getId());
+        }
+        return ok;
     }
 
     @Override

+ 249 - 18
crm/backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/service/impl/CrmContractServiceImpl.java

@@ -19,6 +19,7 @@ import org.dromara.common.satoken.utils.LoginHelper;
 import org.dromara.crm.domain.*;
 import org.dromara.crm.domain.bo.CrmContractBo;
 import org.dromara.crm.domain.bo.CrmContractProductBo;
+import org.dromara.crm.domain.bo.CrmContractProductPriceTierBo;
 import org.dromara.crm.domain.bo.CrmProductBo;
 import org.dromara.crm.domain.vo.*;
 import org.dromara.crm.mapper.*;
@@ -37,6 +38,7 @@ import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.format.DateTimeFormatter;
 import java.util.*;
+import java.util.stream.Collectors;
 
 /**
  * 合同管理Service实现
@@ -70,6 +72,7 @@ public class CrmContractServiceImpl implements ICrmContractService {
     private final ICrmSupplierInfoService supplierInfoService;
     private final ICrmProductService productService;
     private final CrmProductMapper productMapper;
+    private final CrmContractProductPriceTierMapper contractProductPriceTierMapper;
 
     /**
      * 构建查询条件
@@ -208,6 +211,24 @@ public class CrmContractServiceImpl implements ICrmContractService {
                 if (vo.getProducts() == null || vo.getProducts().isEmpty()) {
                     vo.setProducts(products);
                 }
+                if ("framework".equals(vo.getContractType()) && products != null && !products.isEmpty()) {
+                    List<CrmContractProductPriceTier> tierEntities = contractProductPriceTierMapper.selectList(
+                        Wrappers.lambdaQuery(CrmContractProductPriceTier.class)
+                            .eq(CrmContractProductPriceTier::getContractId, id)
+                            .orderByAsc(CrmContractProductPriceTier::getContractProductId)
+                            .orderByAsc(CrmContractProductPriceTier::getSort));
+                    List<CrmContractProductPriceTierVo> allTiers = new ArrayList<>();
+                    for (CrmContractProductPriceTier en : tierEntities) {
+                        allTiers.add(convertTierEntityToVo(en));
+                    }
+                    Map<Long, List<CrmContractProductPriceTierVo>> tierMap = new HashMap<>();
+                    for (CrmContractProductPriceTierVo t : allTiers) {
+                        tierMap.computeIfAbsent(t.getContractProductId(), k -> new ArrayList<>()).add(t);
+                    }
+                    for (CrmContractProductVo p : products) {
+                        p.setPriceTiers(tierMap.getOrDefault(p.getId(), new ArrayList<>()));
+                    }
+                }
                 log.info("查询合同详情成功,合同ID: {}, 产品数量: {}", id, products != null ? products.size() : 0);
             } catch (Exception e) {
                 log.error("查询合同产品明细失败,合同ID: {}", id, e);
@@ -219,6 +240,11 @@ public class CrmContractServiceImpl implements ICrmContractService {
     @Override
     @Transactional(rollbackFor = Exception.class)
     public Boolean insertByBo(CrmContractBo bo) {
+        validateContractTypeAndProductsPresence(bo);
+        normalizeFrameworkContractData(bo);
+        validateProductPricesByContractType(bo);
+        validateSalePurchaseContractAmount(bo);
+
         CrmContract entity = MapstructUtils.convert(bo, CrmContract.class);
 
         // 查询供方信息(用于生成合同编号和合同名称)
@@ -248,14 +274,7 @@ public class CrmContractServiceImpl implements ICrmContractService {
             }
         }
 
-        // 根据合同类型校验产品列表
         String contractType = entity.getContractType();
-        if ("sale".equals(contractType) || "purchase".equals(contractType)) {
-            // 销售合同和采购合同必须有产品
-            if (bo.getProducts() == null || bo.getProducts().isEmpty()) {
-                throw new ServiceException("销售合同和采购合同必须至少添加一个产品");
-            }
-        }
 
         // 自动生成合同名称
         String contractNo = entity.getContractNo();
@@ -327,6 +346,9 @@ public class CrmContractServiceImpl implements ICrmContractService {
                     }
                     sort++;
                     contractProductService.insertByBo(product);
+                    if ("framework".equals(entity.getContractType())) {
+                        savePriceTiersForProduct(product.getId(), entity.getId(), product.getPriceTiers());
+                    }
                 }
             }
 
@@ -356,14 +378,10 @@ public class CrmContractServiceImpl implements ICrmContractService {
             throw new ServiceException("只有草稿状态的合同才能修改");
         }
 
-        // 根据合同类型校验产品列表
-        String contractType = bo.getContractType();
-        if ("sale".equals(contractType) || "purchase".equals(contractType)) {
-            // 销售合同和采购合同必须有产品
-            if (bo.getProducts() == null || bo.getProducts().isEmpty()) {
-                throw new ServiceException("销售合同和采购合同必须至少添加一个产品");
-            }
-        }
+        validateContractTypeAndProductsPresence(bo);
+        normalizeFrameworkContractData(bo);
+        validateProductPricesByContractType(bo);
+        validateSalePurchaseContractAmount(bo);
 
         CrmContract entity = MapstructUtils.convert(bo, CrmContract.class);
 
@@ -452,6 +470,8 @@ public class CrmContractServiceImpl implements ICrmContractService {
         boolean flag = baseMapper.updateById(entity) > 0;
         if (flag && bo.getProducts() != null) {
             resolveContractProductIds(bo.getProducts());
+            contractProductPriceTierMapper.delete(Wrappers.lambdaQuery(CrmContractProductPriceTier.class)
+                .eq(CrmContractProductPriceTier::getContractId, bo.getId()));
             // 更新产品明细(简化处理:先删除再新增)
             contractProductService.deleteByIds(
                     contractProductService.queryListByContractId(bo.getId())
@@ -467,6 +487,9 @@ public class CrmContractServiceImpl implements ICrmContractService {
                 }
                 sort++;
                 contractProductService.insertByBo(product);
+                if ("framework".equals(bo.getContractType())) {
+                    savePriceTiersForProduct(product.getId(), bo.getId(), product.getPriceTiers());
+                }
             }
         }
         return flag;
@@ -498,6 +521,8 @@ public class CrmContractServiceImpl implements ICrmContractService {
             throw new ServiceException("只有草稿状态的合同才能提交审批");
         }
 
+        validateFrameworkContractForSubmitApproval(id, contract);
+
         // 更新合同状态
         CrmContract update = new CrmContract();
         update.setId(id);
@@ -1145,7 +1170,8 @@ public class CrmContractServiceImpl implements ICrmContractService {
 
     /**
      * 生成合同名称
-     * 
+     * <p>销售合同、框架合同:供方简称-客户简称(合同编号);采购合同:客户简称-供方简称(合同编号);其余类型未填名称时为「合同(编号)」。</p>
+     *
      * @param contractNo 合同编号
      * @param contractType 合同类型
      * @param supplierShortName 供方简称
@@ -1158,8 +1184,8 @@ public class CrmContractServiceImpl implements ICrmContractService {
                                         String userContractName) {
         if (StringUtils.isBlank(userContractName)) {
             // 用户未填写合同名称,根据类型自动生成
-            if ("sale".equals(contractType)) {
-                // 销售合同:供方简称-客户简称(合同编号)
+            if ("sale".equals(contractType) || "framework".equals(contractType)) {
+                // 销售合同、框架合同:供方简称-客户简称(合同编号)
                 return supplierShortName + "-" + customerShortName + "(" + contractNo + ")";
             } else if ("purchase".equals(contractType)) {
                 // 采购合同:客户简称-供方简称(合同编号)
@@ -1373,6 +1399,9 @@ public class CrmContractServiceImpl implements ICrmContractService {
                 // 清空ID,让MyBatis-Plus自动生成新ID,避免主键冲突
                 product.setId(null);
                 contractProductService.insertByBo(product);
+                if ("framework".equals(parentContract.getContractType())) {
+                    savePriceTiersForProduct(product.getId(), newContract.getId(), product.getPriceTiers());
+                }
             }
         } else {
             // 复制原合同的产品
@@ -1397,6 +1426,9 @@ public class CrmContractServiceImpl implements ICrmContractService {
                 newProduct.setDescription(parentProduct.getDescription());
                 newProduct.setSort(parentProduct.getSort());
                 contractProductMapper.insert(newProduct);
+                if ("framework".equals(parentContract.getContractType())) {
+                    copyPriceTiersToNewProduct(parentProduct.getId(), newContract.getId(), newProduct.getId());
+                }
             }
         }
 
@@ -1575,6 +1607,205 @@ public class CrmContractServiceImpl implements ICrmContractService {
         throw new ServiceException("自动生成产品编号失败,请稍后重试");
     }
 
+    private void validateContractTypeAndProductsPresence(CrmContractBo bo) {
+        if (bo == null || StringUtils.isBlank(bo.getContractType())) {
+            return;
+        }
+        String contractType = bo.getContractType();
+        if ("sale".equals(contractType) || "purchase".equals(contractType) || "framework".equals(contractType)) {
+            if (bo.getProducts() == null || bo.getProducts().isEmpty()) {
+                throw new ServiceException("销售合同、采购合同或框架合同必须至少添加一个产品");
+            }
+        }
+    }
+
+    private void normalizeFrameworkContractData(CrmContractBo bo) {
+        if (bo == null || !"framework".equals(bo.getContractType())) {
+            return;
+        }
+        bo.setAmount(BigDecimal.ZERO);
+        bo.setAmountWithTax(BigDecimal.ZERO);
+        if (bo.getProducts() == null) {
+            return;
+        }
+        for (CrmContractProductBo p : bo.getProducts()) {
+            p.setPrice(BigDecimal.ZERO);
+            p.setSubtotal(BigDecimal.ZERO);
+            p.setTaxAmount(BigDecimal.ZERO);
+        }
+    }
+
+    private void validateProductPricesByContractType(CrmContractBo bo) {
+        if (bo == null || StringUtils.isBlank(bo.getContractType())) {
+            return;
+        }
+        String contractType = bo.getContractType();
+        if (!"sale".equals(contractType) && !"purchase".equals(contractType)) {
+            return;
+        }
+        List<CrmContractProductBo> products = bo.getProducts();
+        if (products == null) {
+            return;
+        }
+        for (CrmContractProductBo p : products) {
+            if (p.getPrice() == null || p.getPrice().compareTo(new BigDecimal("0.01")) < 0) {
+                throw new ServiceException("销售/采购合同产品的含税单价必须大于0");
+            }
+            if (p.getSubtotal() == null || p.getSubtotal().compareTo(new BigDecimal("0.01")) < 0) {
+                throw new ServiceException("销售/采购合同产品的含税总价必须大于0");
+            }
+        }
+    }
+
+    private void validateSalePurchaseContractAmount(CrmContractBo bo) {
+        if (bo == null || StringUtils.isBlank(bo.getContractType())) {
+            return;
+        }
+        String contractType = bo.getContractType();
+        if (!"sale".equals(contractType) && !"purchase".equals(contractType)) {
+            return;
+        }
+        if (bo.getAmount() == null || bo.getAmount().compareTo(new BigDecimal("0.01")) < 0) {
+            throw new ServiceException("销售合同或采购合同的合同金额必须大于0");
+        }
+    }
+
+    private List<String> parseLetterOssIdsJson(String json) {
+        if (StringUtils.isBlank(json)) {
+            return Collections.emptyList();
+        }
+        try {
+            List<String> list = JSONUtil.toList(json, String.class);
+            return list.stream().filter(StringUtils::isNotBlank).map(String::trim).toList();
+        } catch (Exception e) {
+            log.warn("解析 letter_oss_ids JSON 失败: {}", json);
+            return Collections.emptyList();
+        }
+    }
+
+    private List<String> parseLetterOssIdsFromEntity(CrmContractProductPriceTier en) {
+        if (StringUtils.isNotBlank(en.getLetterOssIds())) {
+            List<String> parsed = parseLetterOssIdsJson(en.getLetterOssIds());
+            if (!parsed.isEmpty()) {
+                return new ArrayList<>(parsed);
+            }
+        }
+        if (en.getLetterOssId() != null) {
+            return new ArrayList<>(List.of(en.getLetterOssId().toString()));
+        }
+        return new ArrayList<>();
+    }
+
+    private CrmContractProductPriceTierVo convertTierEntityToVo(CrmContractProductPriceTier en) {
+        CrmContractProductPriceTierVo vo = new CrmContractProductPriceTierVo();
+        vo.setId(en.getId());
+        vo.setContractId(en.getContractId());
+        vo.setContractProductId(en.getContractProductId());
+        vo.setStartDate(en.getStartDate());
+        vo.setEndDate(en.getEndDate());
+        vo.setPrice(en.getPrice());
+        vo.setSort(en.getSort());
+        vo.setLetterOssIds(parseLetterOssIdsFromEntity(en));
+        return vo;
+    }
+
+    private void savePriceTiersForProduct(Long contractProductId, Long contractId, List<CrmContractProductPriceTierBo> tiers) {
+        if (tiers == null || tiers.isEmpty()) {
+            return;
+        }
+        int sort = 1;
+        for (CrmContractProductPriceTierBo t : tiers) {
+            if (t.getStartDate() == null || t.getEndDate() == null || t.getPrice() == null) {
+                continue;
+            }
+            List<String> ossIds = t.getLetterOssIds();
+            if (ossIds == null || ossIds.isEmpty()) {
+                continue;
+            }
+            List<String> normalized = ossIds.stream()
+                .filter(StringUtils::isNotBlank)
+                .map(String::trim)
+                .toList();
+            if (normalized.isEmpty()) {
+                continue;
+            }
+            CrmContractProductPriceTier e = new CrmContractProductPriceTier();
+            e.setContractId(contractId);
+            e.setContractProductId(contractProductId);
+            e.setStartDate(t.getStartDate());
+            e.setEndDate(t.getEndDate());
+            e.setPrice(t.getPrice());
+            e.setLetterOssIds(JSONUtil.toJsonStr(normalized));
+            e.setLetterOssId(null);
+            if (t.getSort() == null) {
+                e.setSort(sort);
+            } else {
+                e.setSort(t.getSort());
+            }
+            sort++;
+            contractProductPriceTierMapper.insert(e);
+        }
+    }
+
+    private void copyPriceTiersToNewProduct(Long oldContractProductId, Long newContractId, Long newContractProductId) {
+        List<CrmContractProductPriceTier> rows = contractProductPriceTierMapper.selectList(
+            Wrappers.lambdaQuery(CrmContractProductPriceTier.class)
+                .eq(CrmContractProductPriceTier::getContractProductId, oldContractProductId));
+        if (rows == null || rows.isEmpty()) {
+            return;
+        }
+        int s = 1;
+        for (CrmContractProductPriceTier row : rows) {
+            CrmContractProductPriceTier n = new CrmContractProductPriceTier();
+            n.setContractId(newContractId);
+            n.setContractProductId(newContractProductId);
+            n.setStartDate(row.getStartDate());
+            n.setEndDate(row.getEndDate());
+            n.setPrice(row.getPrice());
+            n.setLetterOssIds(row.getLetterOssIds());
+            n.setLetterOssId(row.getLetterOssId());
+            n.setSort(row.getSort() != null ? row.getSort() : s);
+            contractProductPriceTierMapper.insert(n);
+            s++;
+        }
+    }
+
+    private void validateFrameworkContractForSubmitApproval(Long contractId, CrmContract contract) {
+        if (!"framework".equals(contract.getContractType())) {
+            return;
+        }
+        List<CrmContractProductVo> products = contractProductService.queryListByContractId(contractId);
+        if (products == null || products.isEmpty()) {
+            throw new ServiceException("框架合同必须维护产品明细");
+        }
+        List<CrmContractProductPriceTier> tiers = contractProductPriceTierMapper.selectList(
+            Wrappers.lambdaQuery(CrmContractProductPriceTier.class)
+                .eq(CrmContractProductPriceTier::getContractId, contractId));
+        Map<Long, List<CrmContractProductPriceTier>> byProduct = tiers.stream()
+            .collect(Collectors.groupingBy(CrmContractProductPriceTier::getContractProductId));
+        for (CrmContractProductVo prod : products) {
+            List<CrmContractProductPriceTier> list = byProduct.get(prod.getId());
+            if (list == null || list.isEmpty()) {
+                throw new ServiceException("框架合同产品「" + prod.getProductName() + "」至少维护一条调价区间(含调价函)");
+            }
+            for (CrmContractProductPriceTier t : list) {
+                if (t.getStartDate() == null || t.getEndDate() == null) {
+                    throw new ServiceException("框架合同产品「" + prod.getProductName() + "」调价区间日期不能为空");
+                }
+                if (t.getStartDate().isAfter(t.getEndDate())) {
+                    throw new ServiceException("框架合同产品「" + prod.getProductName() + "」调价开始日期不能晚于结束日期");
+                }
+                if (t.getPrice() == null || t.getPrice().compareTo(BigDecimal.ZERO) <= 0) {
+                    throw new ServiceException("框架合同产品「" + prod.getProductName() + "」调价含税单价必须大于0");
+                }
+                List<String> letterIds = parseLetterOssIdsFromEntity(t);
+                if (letterIds.isEmpty()) {
+                    throw new ServiceException("框架合同产品「" + prod.getProductName() + "」每条调价区间须上传调价函附件");
+                }
+            }
+        }
+    }
+
     @Override
     @Transactional(rollbackFor = Exception.class)
     public Boolean createFromQuote(Long quoteId, CrmContractBo bo) {

+ 11 - 0
crm/sql/add_contract_price_tier_letter_oss_ids.sql

@@ -0,0 +1,11 @@
+-- 框架合同调价函支持多张图片:OSS ID 列表 JSON 存储
+ALTER TABLE `crm_contract_product_price_tier`
+  ADD COLUMN `letter_oss_ids` VARCHAR(4000) NULL COMMENT '调价函OSS ID列表(JSON字符串数组,如["id1","id2"])' AFTER `price`;
+
+-- 历史单附件迁移为单元素 JSON 数组
+UPDATE `crm_contract_product_price_tier`
+SET `letter_oss_ids` = CONCAT('["', CAST(`letter_oss_id` AS CHAR), '"]')
+WHERE `letter_oss_id` IS NOT NULL AND (`letter_oss_ids` IS NULL OR `letter_oss_ids` = '');
+
+ALTER TABLE `crm_contract_product_price_tier`
+  MODIFY COLUMN `letter_oss_id` BIGINT(20) NULL COMMENT '调价函单附件OSS ID(废弃,请使用 letter_oss_ids)';

+ 21 - 0
crm/sql/add_contract_product_price_tier.sql

@@ -0,0 +1,21 @@
+-- 框架合同:产品调价区间(含税单价 + 调价函附件)
+CREATE TABLE IF NOT EXISTS `crm_contract_product_price_tier` (
+  `id` bigint(20) NOT NULL COMMENT '主键ID',
+  `contract_id` bigint(20) NOT NULL COMMENT '合同ID',
+  `contract_product_id` bigint(20) NOT NULL COMMENT '合同产品明细ID',
+  `start_date` date NOT NULL COMMENT '区间开始日期',
+  `end_date` date NOT NULL COMMENT '区间结束日期',
+  `price` decimal(15,2) NOT NULL COMMENT '含税单价',
+  `letter_oss_id` bigint(20) NOT NULL COMMENT '调价函附件OSS ID',
+  `sort` int(11) DEFAULT '0' COMMENT '排序号',
+  `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
+  `create_dept` bigint(20) DEFAULT NULL COMMENT '创建部门',
+  `create_by` bigint(20) DEFAULT NULL COMMENT '创建者',
+  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+  `update_by` bigint(20) DEFAULT NULL COMMENT '更新者',
+  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+  `del_flag` tinyint(1) DEFAULT '0' COMMENT '删除标志(0存在 1删除)',
+  PRIMARY KEY (`id`),
+  KEY `idx_contract_id` (`contract_id`),
+  KEY `idx_contract_product_id` (`contract_product_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CRM-框架合同产品调价区间';

+ 15 - 0
crm/web/src/api/crm/contract/index.ts

@@ -109,6 +109,21 @@ export interface ContractProductForm {
   description?: string;
   sort?: number;
   remark?: string;
+  /** 框架合同:调价区间 */
+  priceTiers?: ContractProductPriceTierForm[];
+}
+
+/** 框架合同产品调价区间 */
+export interface ContractProductPriceTierForm {
+  id?: number;
+  contractId?: number;
+  contractProductId?: number;
+  startDate?: string;
+  endDate?: string;
+  price?: number;
+  /** 调价函图片 OSS ID 列表(多张) */
+  letterOssIds?: string[];
+  sort?: number;
 }
 
 /**

+ 11 - 2
crm/web/src/components/ImagePreview/index.vue

@@ -295,9 +295,18 @@ watch(
   { immediate: true }
 );
 
-const realWidth = computed(() => (typeof props.width == 'string' ? props.width : `${props.width}px`));
+/** 纯数字字符串需补 px,否则 style 无效,图片会按原始尺寸伸缩 */
+const normalizeCssSize = (v: string | number | undefined) => {
+  if (v === '' || v === undefined || v === null) return '';
+  if (typeof v === 'number' && !Number.isNaN(v)) return `${v}px`;
+  const s = String(v).trim();
+  if (/^\d+(\.\d+)?$/.test(s)) return `${s}px`;
+  return s;
+};
+
+const realWidth = computed(() => normalizeCssSize(props.width as string | number));
 
-const realHeight = computed(() => (typeof props.height == 'string' ? props.height : `${props.height}px`));
+const realHeight = computed(() => normalizeCssSize(props.height as string | number));
 </script>
 
 <style lang="scss" scoped>

+ 22 - 9
crm/web/src/components/QuoteSelector.vue

@@ -13,7 +13,7 @@
       </el-form-item>
       <el-form-item label="状态" prop="status">
         <el-select v-model="queryParams.status" placeholder="请选择状态" clearable style="width: 150px">
-          <el-option v-for="dict in crm_quote_status" :key="dict.value" :label="dict.label" :value="dict.value" />
+          <el-option v-for="dict in quoteStatusFilterOptions" :key="dict.value" :label="dict.label" :value="dict.value" />
         </el-select>
       </el-form-item>
       <el-form-item class="form-buttons-right">
@@ -26,6 +26,11 @@
     <el-table v-loading="loading" :data="quoteList" @current-change="handleCurrentChange" :max-height="400" border highlight-current-row>
       <el-table-column type="index" label="序号" width="60" align="center" />
       <el-table-column label="报价单编号" prop="quoteNo" width="160" align="center" :show-overflow-tooltip="true" />
+      <el-table-column label="状态" prop="status" width="100" align="center">
+        <template #default="scope">
+          <dict-tag :options="crm_quote_status" :value="scope.row.status" />
+        </template>
+      </el-table-column>
       <el-table-column label="报价主题" prop="quoteTitle" min-width="200" :show-overflow-tooltip="true" />
       <el-table-column label="客户名称" prop="customerName" width="180" :show-overflow-tooltip="true" />
       <el-table-column label="合同关联情况" prop="contractLinkedCount" width="180" align="center">
@@ -42,11 +47,6 @@
       <el-table-column label="有效期" prop="validityDays" width="100" align="center">
         <template #default="scope">{{ scope.row.validityDays }}天</template>
       </el-table-column>
-      <el-table-column label="状态" prop="status" width="100" align="center">
-        <template #default="scope">
-          <dict-tag :options="crm_quote_status" :value="scope.row.status" />
-        </template>
-      </el-table-column>
       <el-table-column label="负责人" prop="ownerUserName" width="100" :show-overflow-tooltip="true" />
     </el-table>
 
@@ -68,6 +68,9 @@
           <el-descriptions-item label="报价单编号">{{ currentQuote.quoteNo }}</el-descriptions-item>
           <el-descriptions-item label="报价主题">{{ currentQuote.quoteTitle }}</el-descriptions-item>
           <el-descriptions-item label="客户名称">{{ currentQuote.customerName }}</el-descriptions-item>
+          <el-descriptions-item label="状态">
+            <dict-tag :options="crm_quote_status" :value="currentQuote.status" />
+          </el-descriptions-item>
           <el-descriptions-item label="联系人">{{ currentQuote.contactName || '-' }}</el-descriptions-item>
           <el-descriptions-item label="报价金额">
             <span class="text-primary">¥{{ parseFloat(currentQuote.totalAmount || 0).toLocaleString() }}</span>
@@ -87,7 +90,7 @@
 </template>
 
 <script setup lang="ts" name="QuoteSelector">
-import { ref, watch, getCurrentInstance, toRefs, type ComponentInternalInstance } from 'vue';
+import { ref, watch, computed, getCurrentInstance, toRefs, type ComponentInternalInstance } from 'vue';
 import type { FormInstance } from 'element-plus';
 import { listQuote, getQuote } from '@/api/crm/quote';
 import type { QuoteQuery, QuoteVO } from '@/api/crm/quote';
@@ -115,6 +118,7 @@ watch(
     dialogVisible.value = val;
     if (val) {
       // 打开对话框时加载数据
+      queryParams.value.excludeStatus = 'invalid';
       if (props.customerId) {
         queryParams.value.customerId = props.customerId;
       }
@@ -131,7 +135,14 @@ watch(dialogVisible, (val) => {
 // 字典数据
 const { crm_quote_status } = toRefs<any>(proxy!.useDict('crm_quote_status'));
 
-// 查询参数
+/** 筛选下拉里不提供「已作废」,列表接口始终 excludeStatus=invalid */
+const quoteStatusFilterOptions = computed(() => {
+  const list = crm_quote_status?.value;
+  if (!Array.isArray(list)) return [];
+  return list.filter((d: { value: string }) => d.value !== 'invalid');
+});
+
+// 查询参数(始终排除已作废报价单,与报价单管理列表默认行为一致)
 const queryParams = ref<QuoteQuery>({
   pageNum: 1,
   pageSize: 10,
@@ -139,7 +150,8 @@ const queryParams = ref<QuoteQuery>({
   quoteTitle: undefined,
   customerName: undefined,
   status: undefined,
-  customerId: undefined
+  customerId: undefined,
+  excludeStatus: 'invalid'
 });
 
 // 报价单列表
@@ -181,6 +193,7 @@ const handleQuery = () => {
 /** 重置按钮操作 */
 const resetQuery = () => {
   queryFormRef.value?.resetFields();
+  queryParams.value.excludeStatus = 'invalid';
   if (props.customerId) {
     queryParams.value.customerId = props.customerId;
   }

+ 120 - 15
crm/web/src/views/crm/contract/detail.vue

@@ -276,16 +276,21 @@
               </el-table-column>
               <el-table-column label="数量" prop="quantity" width="110" align="right" />
               <el-table-column label="含税单价/元" prop="price" width="130" align="right">
-                <template #default="scope"> ¥{{ parseFloat(scope.row.price || 0).toFixed(2) }} </template>
+                <template #default="scope">
+                  <span v-if="contract.contractType === 'framework'" class="text-muted">随行就市</span>
+                  <template v-else>¥{{ parseFloat(scope.row.price || 0).toFixed(2) }}</template>
+                </template>
               </el-table-column>
               <el-table-column label="含税总价/元" prop="subtotal" width="130" align="right">
                 <template #default="scope">
-                  <span class="text-success font-weight-bold">¥{{ parseFloat(scope.row.subtotal || 0).toFixed(2) }}</span>
+                  <span v-if="contract.contractType === 'framework'" class="text-muted">随行就市</span>
+                  <span v-else class="text-success font-weight-bold">¥{{ parseFloat(scope.row.subtotal || 0).toFixed(2) }}</span>
                 </template>
               </el-table-column>
               <el-table-column label="税费/元" prop="taxAmount" width="120" align="right">
                 <template #default="scope">
-                  <span>¥{{ parseFloat(scope.row.taxAmount || 0).toFixed(2) }}</span>
+                  <span v-if="contract.contractType === 'framework'" class="text-muted">随行就市</span>
+                  <span v-else>¥{{ parseFloat(scope.row.taxAmount || 0).toFixed(2) }}</span>
                 </template>
               </el-table-column>
               <el-table-column label="包装规格" prop="packageSpec" min-width="150" :show-overflow-tooltip="true">
@@ -300,6 +305,44 @@
               </el-table-column>
             </el-table>
 
+            <!-- 框架合同:各产品调价明细(仅详情页展示,不参与打印模板 contractContent) -->
+            <template v-if="contract.contractType === 'framework'">
+              <el-divider content-position="left">框架合同调价明细</el-divider>
+              <p class="text-muted" style="font-size: 13px; margin-bottom: 12px">按生效区间记录含税单价及调价函;后续结算可据此核对。</p>
+              <div
+                v-for="(prod, pIdx) in contract.products || []"
+                :key="'fw-tier-detail-' + (prod.id ?? pIdx)"
+                class="framework-tier-detail-block mb-4"
+              >
+                <div class="fw-tier-detail-title">{{ prod.productName || '产品' + (pIdx + 1) }}</div>
+                <el-table :data="prod.priceTiers || []" border size="small" empty-text="暂无调价记录">
+                  <el-table-column type="index" label="#" width="52" align="center" />
+                  <el-table-column label="开始日期" prop="startDate" min-width="120" align="center" />
+                  <el-table-column label="结束日期" prop="endDate" min-width="120" align="center" />
+                  <el-table-column label="含税单价/元" width="130" align="right">
+                    <template #default="scope"> ¥{{ parseFloat(String(scope.row.price ?? 0)).toFixed(2) }} </template>
+                  </el-table-column>
+                  <el-table-column label="调价函" min-width="260" align="center">
+                    <template #default="scope">
+                      <div v-if="tierLetterOssIds(scope.row).length" class="tier-letter-thumbs">
+                        <div
+                          v-for="(oid, li) in tierLetterOssIds(scope.row)"
+                          :key="'fw-tier-letter-' + li + '-' + oid"
+                          class="tier-letter-thumb-wrap"
+                        >
+                          <ImagePreview :src="String(oid)" class="tier-letter-thumb-img" :isOssId="true" :width="72" :height="72" />
+                          <el-link type="primary" :underline="false" class="tier-letter-download-link" @click="handleDownloadTierLetter(oid)">
+                            下载
+                          </el-link>
+                        </div>
+                      </div>
+                      <span v-else class="text-muted">-</span>
+                    </template>
+                  </el-table-column>
+                </el-table>
+              </div>
+            </template>
+
             <!-- 合计金额 -->
             <el-divider />
             <div class="total-section">
@@ -1519,9 +1562,7 @@ const replacePlaceholders = (htmlContent: string, contractData: ContractVO | und
   // 获取产品包装规格(去重)
   const getPackageSpecs = (): string => {
     if (!contractData.products || contractData.products.length === 0) return '';
-    const specs = contractData.products
-      .map((p) => p.packageSpec)
-      .filter((spec) => spec && spec.trim() !== '');
+    const specs = contractData.products.map((p) => p.packageSpec).filter((spec) => spec && spec.trim() !== '');
     const uniqueSpecs = [...new Set(specs)];
     return uniqueSpecs.join('、');
   };
@@ -1598,7 +1639,7 @@ const replacePlaceholders = (htmlContent: string, contractData: ContractVO | und
     '{{packageSpecs}}': packageSpecs || '散水包装',
 
     // 第五条结算方式内容(优先使用用户按结算方式保存的草稿)
-    '{{SETTLEMENT_METHOD_CONTENT}}': getSettlementClauseContent(contractData),
+    '{{SETTLEMENT_METHOD_CONTENT}}': getSettlementClauseContent(contractData)
   };
 
   // 替换普通占位符
@@ -1687,7 +1728,7 @@ const replaceDynamicFields = (template: string, contractData: ContractVO): strin
     '{{partyBAgent}}': contractData.partyBAgent || '',
     '{{partyBPhoneFax}}': contractData.partyBPhoneFax || '',
     '{{partyBBank}}': contractData.partyBBank || '',
-    '{{partyBAccount}}': contractData.partyBAccount || '',
+    '{{partyBAccount}}': contractData.partyBAccount || ''
   };
 
   // 执行替换:将动态字段的当前值替换为占位符
@@ -1700,9 +1741,7 @@ const replaceDynamicFields = (template: string, contractData: ContractVO): strin
   }
 
   // 处理包装规格
-  const packageSpecs = contractData.products
-    ?.map((p) => p.packageSpec)
-    .filter((spec) => spec && spec.trim() !== '');
+  const packageSpecs = contractData.products?.map((p) => p.packageSpec).filter((spec) => spec && spec.trim() !== '');
   const uniqueSpecs = packageSpecs ? [...new Set(packageSpecs)].join('、') : '';
   const packageSpecsValue = uniqueSpecs || '散水包装';
   if (packageSpecsValue) {
@@ -2029,18 +2068,19 @@ const handleSaveTemplate = async () => {
     // 1. 替换第五条结算方式为占位符(使用通用方法)
     const marker = '第五条、结算方式及期限';
     const titleIndex = finalTemplate.indexOf(marker);
-    
+
     if (titleIndex !== -1) {
       // 找到标题后的内容 div
       const contentMarker = '<div style="line-height: 1.8; margin: 0;">';
       const contentStart = finalTemplate.indexOf(contentMarker, titleIndex);
-      
+
       if (contentStart !== -1) {
         const contentEnd = finalTemplate.indexOf('</div>', contentStart + contentMarker.length);
-        
+
         if (contentEnd !== -1) {
           // 替换内容部分
-          finalTemplate = finalTemplate.slice(0, contentStart + contentMarker.length) + '{{SETTLEMENT_METHOD_CONTENT}}' + finalTemplate.slice(contentEnd);
+          finalTemplate =
+            finalTemplate.slice(0, contentStart + contentMarker.length) + '{{SETTLEMENT_METHOD_CONTENT}}' + finalTemplate.slice(contentEnd);
         }
       }
     }
@@ -2337,6 +2377,27 @@ const getShortFileName = (fileName: string) => {
   return name.substring(0, 12) + '...' + ext;
 };
 
+/** 调价函 OSS ID 列表(兼容旧数据仅 letterOssId) */
+const tierLetterOssIds = (row: any): string[] => {
+  if (!row) return [];
+  if (Array.isArray(row.letterOssIds) && row.letterOssIds.length) {
+    return row.letterOssIds.map((id: any) => String(id));
+  }
+  if (row.letterOssId != null && row.letterOssId !== '') {
+    return [String(row.letterOssId)];
+  }
+  return [];
+};
+
+/** 下载框架合同调价函(OSS ID) */
+const handleDownloadTierLetter = (ossId: string | number | undefined) => {
+  if (ossId == null || ossId === '') {
+    proxy?.$modal.msgError('无法获取调价函文件');
+    return;
+  }
+  handleDownloadFile({ ossId: String(ossId), name: '调价函' });
+};
+
 /** 下载文件 */
 const handleDownloadFile = async (file: any) => {
   if (!file.ossId) {
@@ -2697,6 +2758,15 @@ const handleGeneratePdfFile = async () => {
   margin-top: 10px;
 }
 
+.framework-tier-detail-block {
+  .fw-tier-detail-title {
+    font-weight: 600;
+    font-size: 14px;
+    margin-bottom: 8px;
+    color: var(--el-text-color-primary);
+  }
+}
+
 .total-section {
   padding: 20px 0;
 
@@ -2971,4 +3041,39 @@ const handleGeneratePdfFile = async () => {
 .mt-3 {
   margin-top: 12px;
 }
+
+.tier-letter-thumbs {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+  justify-content: center;
+  align-items: flex-start;
+}
+.tier-letter-thumb-wrap {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 4px;
+}
+/* 统一缩略图占位,避免原图比例差异导致视觉大小不一 */
+.tier-letter-thumb-wrap :deep(.el-image) {
+  width: 72px !important;
+  height: 72px !important;
+  flex-shrink: 0;
+  border-radius: 6px;
+  overflow: hidden;
+  border: 1px solid var(--el-border-color-lighter);
+  background-color: var(--el-fill-color-light);
+}
+.tier-letter-thumb-wrap :deep(.el-image__inner) {
+  width: 100% !important;
+  height: 100% !important;
+  object-fit: cover;
+}
+.tier-letter-thumb-img {
+  display: block;
+}
+.tier-letter-download-link {
+  font-size: 12px;
+}
 </style>

+ 408 - 163
crm/web/src/views/crm/contract/form.vue

@@ -89,7 +89,14 @@
             </el-col>
             <el-col :span="6">
               <el-form-item label="签订日期" prop="signDate">
-                <el-date-picker v-model="form.signDate" type="date" placeholder="选择签订日期" value-format="YYYY-MM-DD" style="width: 100%" @change="handleSignDateChange" />
+                <el-date-picker
+                  v-model="form.signDate"
+                  type="date"
+                  placeholder="选择签订日期"
+                  value-format="YYYY-MM-DD"
+                  style="width: 100%"
+                  @change="handleSignDateChange"
+                />
               </el-form-item>
             </el-col>
             <el-col :span="6">
@@ -290,12 +297,7 @@
                   style="width: 100%"
                   @change="handleUnitChange(scope.row)"
                 >
-                  <el-option
-                    v-for="dict in crm_product_unit"
-                    :key="dict.value"
-                    :label="dict.label"
-                    :value="dict.value"
-                  />
+                  <el-option v-for="dict in crm_product_unit" :key="dict.value" :label="dict.label" :value="dict.value" />
                 </el-select>
               </template>
             </el-table-column>
@@ -306,20 +308,29 @@
               </template>
             </el-table-column>
             <el-table-column prop="price" width="130">
-              <template #header> <span style="color: #f56c6c">*</span> 含税单价/元</template>
+              <template #header>
+                <span v-if="isFrameworkContract">含税单价/元</span>
+                <span v-else><span style="color: #f56c6c">*</span> 含税单价/元</span>
+              </template>
               <template #default="scope">
-                <el-input v-model="scope.row.price" placeholder="含税单价" @input="handlePriceChange(scope.row)" clearable size="small" />
+                <span v-if="isFrameworkContract" class="text-muted">随行就市</span>
+                <el-input v-else v-model="scope.row.price" placeholder="含税单价" @input="handlePriceChange(scope.row)" clearable size="small" />
               </template>
             </el-table-column>
             <el-table-column prop="subtotal" width="130" align="right">
-              <template #header> <span style="color: #f56c6c">*</span> 含税总价/元</template>
+              <template #header>
+                <span v-if="isFrameworkContract">含税总价/元</span>
+                <span v-else><span style="color: #f56c6c">*</span> 含税总价/元</span>
+              </template>
               <template #default="scope">
-                <span class="text-success font-weight-bold">¥{{ parseFloat(scope.row.subtotal || 0).toFixed(2) }}</span>
+                <span v-if="isFrameworkContract" class="text-muted">随行就市</span>
+                <span v-else class="text-success font-weight-bold">¥{{ parseFloat(scope.row.subtotal || 0).toFixed(2) }}</span>
               </template>
             </el-table-column>
             <el-table-column label="税费/元" prop="taxAmount" width="120" align="right">
               <template #default="scope">
-                <span>¥{{ parseFloat(scope.row.taxAmount || 0).toFixed(2) }}</span>
+                <span v-if="isFrameworkContract" class="text-muted">随行就市</span>
+                <span v-else>¥{{ parseFloat(scope.row.taxAmount || 0).toFixed(2) }}</span>
               </template>
             </el-table-column>
             <el-table-column prop="packageSpec" min-width="150">
@@ -350,7 +361,16 @@
             </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" />
+                <el-input
+                  v-model="scope.row.remark"
+                  type="textarea"
+                  :rows="2"
+                  placeholder="请输入备注"
+                  maxlength="500"
+                  show-word-limit
+                  clearable
+                  size="small"
+                />
               </template>
             </el-table-column>
             <el-table-column label="操作" width="80" align="center" fixed="right">
@@ -360,6 +380,60 @@
             </el-table-column>
           </el-table>
 
+          <template v-if="form.contractType === 'framework'">
+            <el-divider content-position="left">框架合同调价区间</el-divider>
+            <p class="text-muted mb-3" style="font-size: 13px">
+              按产品维护生效日期区间与含税单价;每条区间须上传调价函。合同总金额不与此联动,仍为 0。提交审批前将校验每条产品至少一条完整区间。
+            </p>
+            <div v-for="(prod, pIdx) in form.products" :key="'fw-tier-' + pIdx" class="framework-tier-block mb-4">
+              <div class="fw-tier-title mb-2">{{ prod.productName || '产品' + (pIdx + 1) }}</div>
+              <el-button type="primary" link class="mb-2 fw-tier-letter-add-btn" icon="Plus" @click="addPriceTierRow(pIdx)">调价函</el-button>
+              <el-table :data="prod.priceTiers || []" border size="small">
+                <el-table-column type="index" label="#" width="50" align="center" />
+                <el-table-column label="开始日期" min-width="150">
+                  <template #default="scope">
+                    <el-date-picker v-model="scope.row.startDate" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" style="width: 100%" />
+                  </template>
+                </el-table-column>
+                <el-table-column label="结束日期" min-width="150">
+                  <template #default="scope">
+                    <el-date-picker v-model="scope.row.endDate" type="date" value-format="YYYY-MM-DD" placeholder="结束日期" style="width: 100%" />
+                  </template>
+                </el-table-column>
+                <el-table-column label="含税单价/元" width="140">
+                  <template #default="scope">
+                    <el-input v-model="scope.row.price" placeholder="含税单价" clearable size="small" />
+                  </template>
+                </el-table-column>
+                <el-table-column label="调价函" min-width="300">
+                  <template #default="scope">
+                    <el-upload
+                      :action="uploadAction"
+                      :headers="uploadHeaders"
+                      :data="uploadDataTierLetter"
+                      list-type="picture-card"
+                      :limit="20"
+                      multiple
+                      accept="image/*"
+                      :file-list="scope.row._letterFiles || []"
+                      :on-success="(res, file) => handleTierLetterUploadSuccess(res, file, pIdx, scope.$index)"
+                      :on-remove="(file) => handleTierLetterRemove(file, pIdx, scope.$index)"
+                      class="tier-letter-upload"
+                    >
+                      <el-icon><Plus /></el-icon>
+                    </el-upload>
+                    <div class="text-muted" style="font-size: 11px; margin-top: 4px">支持多张图片,单图建议不超过 5MB</div>
+                  </template>
+                </el-table-column>
+                <el-table-column label="操作" width="70" align="center" fixed="right">
+                  <template #default="scope">
+                    <el-button link type="danger" icon="Delete" @click="removePriceTierRow(pIdx, scope.$index)" />
+                  </template>
+                </el-table-column>
+              </el-table>
+            </div>
+          </template>
+
           <el-divider />
           <div class="total-section">
             <el-row :gutter="20">
@@ -510,10 +584,17 @@ import { ref, reactive, toRefs, computed, onMounted, nextTick, getCurrentInstanc
 import { useRouter, useRoute } from 'vue-router';
 import type { FormInstance } from 'element-plus';
 import { ElMessage } from 'element-plus';
-import { getContract, addContract, updateContract, type ContractForm, type ContractProductForm } from '@/api/crm/contract/index';
+import {
+  getContract,
+  addContract,
+  updateContract,
+  type ContractForm,
+  type ContractProductForm,
+  type ContractProductPriceTierForm
+} from '@/api/crm/contract/index';
 import { getToken } from '@/utils/auth';
 import { useDict } from '@/utils/dict';
-import { UploadFilled, Document, Loading, Download, Delete, ZoomIn, Sort } from '@element-plus/icons-vue';
+import { UploadFilled, Document, Loading, Download, Delete, ZoomIn, Sort, Plus } from '@element-plus/icons-vue';
 import ImagePreview from '@/components/ImagePreview/index.vue';
 import request from '@/utils/request';
 import ProductSelector from './components/ProductSelector.vue';
@@ -543,12 +624,15 @@ type ExtendedContractForm = ContractForm & {
 };
 
 // 扩展产品类型,添加显示字段
+type PriceTierRow = ContractProductPriceTierForm & { _letterFiles?: any[] };
+
 type ExtendedContractProductForm = ContractProductForm & {
   productName?: string;
   productModel?: string;
   unit?: string;
   unitPrice?: number;
   subtotal?: number;
+  priceTiers?: PriceTierRow[];
 };
 
 const router = useRouter();
@@ -671,6 +755,12 @@ const uploadData = computed(() => {
   };
 });
 
+const uploadDataTierLetter = computed(() => ({
+  bizType: 'crm_contract_price_letter'
+}));
+
+const isFrameworkContract = computed(() => form.value.contractType === 'framework');
+
 // 加载状态
 const saveDraftLoading = ref(false);
 const submitLoading = ref(false);
@@ -772,19 +862,39 @@ const loadContractDetail = async (id: number | string) => {
   if (contractData.products && Array.isArray(contractData.products)) {
     form.value.products = contractData.products.map((product: any) => ({
       ...product,
-      // 字段映射:后端model -> 前端productModel
       productModel: product.model || '',
-      // 初始化前端需要的计算字段
-      taxRate: product.taxRate || 6, // 默认税率6%
+      priceTiers: (product.priceTiers || []).map((t: any) => {
+        let ids: string[] = [];
+        if (Array.isArray(t.letterOssIds) && t.letterOssIds.length) {
+          ids = t.letterOssIds.map((id: any) => String(id));
+        } else if (t.letterOssId != null && t.letterOssId !== '') {
+          ids = [String(t.letterOssId)];
+        }
+        return {
+          ...t,
+          letterOssIds: ids,
+          _letterFiles: [] as any[]
+        };
+      }),
+      taxRate: product.taxRate || 6,
       taxAmount: 0,
       packageSpec: product.packageSpec || '',
       deliveryMethod: product.deliveryMethod || ''
     }));
 
-    // 重新计算每个产品的税费
-    form.value.products.forEach((product: any) => {
-      calculateProductAmount(product);
-    });
+    if (contractData.contractType === 'framework') {
+      form.value.products.forEach((product: any) => {
+        product.price = 0;
+        product.subtotal = 0;
+        product.taxAmount = 0;
+      });
+      form.value.amount = 0;
+      await enrichFrameworkTierLetterFiles();
+    } else {
+      form.value.products.forEach((product: any) => {
+        calculateProductAmount(product);
+      });
+    }
   }
 
   // 6. 设置合同期限日期范围
@@ -1077,6 +1187,7 @@ const handleQuoteConfirm = (quote: QuoteVO) => {
       proxy?.$modal
         .confirm('是否要从报价单中带入产品明细?')
         .then(() => {
+          const isFw = form.value.contractType === 'framework';
           form.value.products = quote.productList.map((item: any) => ({
             productId: item.productId,
             productName: item.productName,
@@ -1085,18 +1196,19 @@ const handleQuoteConfirm = (quote: QuoteVO) => {
             categoryName: item.categoryName || '',
             unit: item.unit || '个',
             quantity: item.quantity || 1,
-            price: item.price || 0,
-            unitPrice: item.price || 0,
+            price: isFw ? 0 : item.price || 0,
+            unitPrice: isFw ? 0 : item.price || 0,
             taxRate: item.taxRate || 13, // 税率(%)
-            taxAmount: item.taxAmount || 0, // 税费/元
+            taxAmount: isFw ? 0 : item.taxAmount || 0, // 税费/元
             discount: item.discount || 0,
-            subtotal: item.subtotal || (item.quantity || 1) * (item.price || 0) * (1 - (item.discount || 0) / 100),
+            subtotal: isFw ? 0 : item.subtotal || (item.quantity || 1) * (item.price || 0) * (1 - (item.discount || 0) / 100),
             packageSpec: item.packageSpec || '', // 包装规格
             deliveryMethod: item.deliveryMethod || '', // 送货方式
             deliveryDate: '',
             description: item.description || '',
             remark: item.remark || '',
-            sort: item.sort // 排序号,保持与报价单一致
+            sort: item.sort, // 排序号,保持与报价单一致
+            priceTiers: isFw ? [] : undefined
           }));
           calculateTotalAmount();
         })
@@ -1212,7 +1324,7 @@ const updateContractNoBySupplier = (newSupplierCode: string | undefined) => {
 /**
  * 更新合同名称中的供方简称
  * 合同名称格式:
- * - 销售合同:供方简称-客户简称(合同编号)
+ * - 销售合同、框架合同:供方简称-客户简称(合同编号)
  * - 采购合同:客户简称-供方简称(合同编号)
  */
 const updateContractNameBySupplier = (newSupplierShortName: string | undefined) => {
@@ -1234,8 +1346,8 @@ const updateContractNameBySupplier = (newSupplierShortName: string | undefined)
   const commonSupplierNames = ['山峡', 'SX'];
 
   // 根据合同类型解析和替换
-  if (contractType === 'sale') {
-    // 销售合同格式:供方简称-客户简称(合同编号)
+  if (contractType === 'sale' || contractType === 'framework') {
+    // 销售/框架合同格式:供方简称-客户简称(合同编号)
     // 例如:山峡-客户A(SX/X-JT20250926002)
     const parts = nameBody.split('-').map((p) => p.trim());
     if (parts.length >= 2) {
@@ -1317,7 +1429,7 @@ const updateContractNameBySupplier = (newSupplierShortName: string | undefined)
 /**
  * 更新合同名称中的客户简称
  * 合同名称格式:
- * - 销售合同:供方简称-客户简称(合同编号)
+ * - 销售合同、框架合同:供方简称-客户简称(合同编号)
  * - 采购合同:客户简称-供方简称(合同编号)
  */
 const updateContractNameByCustomer = (newCustomerShortName: string | undefined) => {
@@ -1339,8 +1451,8 @@ const updateContractNameByCustomer = (newCustomerShortName: string | undefined)
   const commonSupplierNames = ['山峡', 'SX'];
 
   // 根据合同类型解析和替换
-  if (contractType === 'sale') {
-    // 销售合同格式:供方简称-客户简称(合同编号)
+  if (contractType === 'sale' || contractType === 'framework') {
+    // 销售/框架合同格式:供方简称-客户简称(合同编号)
     // 例如:山峡-客户A(SX/X-JT20250926002)
     const parts = nameBody.split('-').map((p) => p.trim());
     if (parts.length >= 2) {
@@ -1483,6 +1595,17 @@ const handleSwapParties = async () => {
 
 /** 合同类型变化处理 */
 const handleContractTypeChange = (value: string) => {
+  if (value === 'framework') {
+    form.value.amount = 0;
+    form.value.products?.forEach((p) => {
+      p.price = 0;
+      p.subtotal = 0;
+      p.taxAmount = 0;
+      if (!p.priceTiers) {
+        p.priceTiers = [];
+      }
+    });
+  }
   // 当选择采购合同时,自动调换供需方
   if (value === 'purchase' && !isPartiesSwapped.value) {
     proxy?.$modal
@@ -1566,6 +1689,7 @@ const handleAddProduct = () => {
 
 /** 产品选择确认 */
 const handleProductSelected = (products: any[]) => {
+  const isFw = form.value.contractType === 'framework';
   products.forEach((product) => {
     // 允许重复添加相同产品:直接追加到 products 列表
     form.value.products.push({
@@ -1576,14 +1700,15 @@ const handleProductSelected = (products: any[]) => {
       categoryName: product.categoryName || '',
       unit: product.unit || '个',
       quantity: 1,
-      price: product.price || 0,
+      price: isFw ? 0 : product.price || 0,
       taxRate: normalizeTaxRateDisplay(product.taxRate, 13), // 税率:整数不显示小数位
       taxAmount: 0,
-      subtotal: product.price || 0,
+      subtotal: isFw ? 0 : product.price || 0,
       packageSpec: '',
       deliveryMethod: '',
       description: product.description || '',
-      remark: ''
+      remark: '',
+      priceTiers: isFw ? [] : undefined
     });
   });
   // 计算所有产品金额
@@ -1677,6 +1802,12 @@ const handlePackageSpecChange = (product: ExtendedContractProductForm, value: st
 
 /** 计算产品金额(参考报价单逻辑) */
 const calculateProductAmount = (product: ExtendedContractProductForm) => {
+  if (form.value.contractType === 'framework') {
+    product.price = 0;
+    product.subtotal = 0;
+    product.taxAmount = 0;
+    return;
+  }
   // 获取输入值并转换为数字
   const price = parseFloat(product.price?.toString() || '0');
   const quantity = parseFloat(product.quantity?.toString() || '0');
@@ -1699,6 +1830,10 @@ const calculateProductAmount = (product: ExtendedContractProductForm) => {
 
 /** 计算合计金额 */
 const calculateTotalAmount = () => {
+  if (form.value.contractType === 'framework') {
+    form.value.amount = 0;
+    return;
+  }
   let total = 0;
   form.value.products.forEach((item) => {
     const quantity = item.quantity || 0;
@@ -1711,6 +1846,146 @@ const calculateTotalAmount = () => {
   form.value.amount = total;
 };
 
+/** 框架合同:批量拉取调价函 OSS URL,用于上传组件缩略图回显 */
+const enrichFrameworkTierLetterFiles = async () => {
+  const products = form.value.products || [];
+  const idSet = new Set<string>();
+  products.forEach((p: any) => {
+    (p.priceTiers || []).forEach((t: any) => {
+      (t.letterOssIds || []).forEach((id: string) => idSet.add(String(id)));
+    });
+  });
+  if (idSet.size === 0) return;
+  try {
+    const response = await request({
+      url: `/resource/oss/listByIds/${[...idSet].join(',')}`,
+      method: 'get'
+    });
+    if (response.code !== 200 || !response.data?.length) return;
+    const urlMap = new Map<string, string>();
+    response.data.forEach((item: any) => {
+      urlMap.set(String(item.ossId), item.url);
+    });
+    products.forEach((p: any) => {
+      (p.priceTiers || []).forEach((t: any) => {
+        const ids = t.letterOssIds || [];
+        t._letterFiles = ids.map((ossId: string, i: number) => ({
+          name: `调价函${i + 1}`,
+          ossId: String(ossId),
+          url: urlMap.get(String(ossId)),
+          uid: `tier-prev-${t.id ?? 'n'}-${i}-${ossId}`,
+          status: 'success'
+        }));
+      });
+    });
+  } catch (e) {
+    console.warn('调价函缩略图加载失败', e);
+  }
+};
+
+/** 组装提交/保存的产品明细(含框架合同调价区间) */
+const buildProductsPayload = () => {
+  const list = form.value.products || [];
+  return list.map((product: any, idx: number) => {
+    const base: any = {
+      contractId: product.contractId || form.value.id,
+      productId: product.productId,
+      productName: product.productName,
+      productNo: product.productNo,
+      categoryId: product.categoryId,
+      categoryName: product.categoryName,
+      model: product.productModel || product.model,
+      unit: product.unit,
+      quantity: product.quantity,
+      price: product.price,
+      discount: product.discount,
+      taxRate: product.taxRate,
+      taxAmount: product.taxAmount,
+      subtotal: product.subtotal,
+      deliveryDate: product.deliveryDate,
+      description: product.description,
+      packageSpec: product.packageSpec,
+      deliveryMethod: product.deliveryMethod,
+      sort: product.sort ?? idx + 1,
+      remark: product.remark
+    };
+    if (product.id) {
+      base.id = product.id;
+    }
+    if (form.value.contractType === 'framework') {
+      base.price = 0;
+      base.subtotal = 0;
+      base.taxAmount = 0;
+      base.priceTiers = (product.priceTiers || [])
+        .filter(
+          (t: any) => t.startDate && t.endDate && t.price != null && t.price !== '' && Array.isArray(t.letterOssIds) && t.letterOssIds.length > 0
+        )
+        .map((t: any, i: number) => ({
+          id: t.id,
+          startDate: t.startDate,
+          endDate: t.endDate,
+          price: Number(t.price),
+          letterOssIds: (t.letterOssIds || []).map((id: string) => String(id).trim()).filter(Boolean),
+          sort: t.sort ?? i + 1
+        }));
+    }
+    return base;
+  });
+};
+
+const addPriceTierRow = (pIdx: number) => {
+  const p = form.value.products[pIdx];
+  if (!p) return;
+  if (!p.priceTiers) {
+    p.priceTiers = [];
+  }
+  p.priceTiers.push({ startDate: '', endDate: '', price: undefined, letterOssIds: [], _letterFiles: [] });
+};
+
+const removePriceTierRow = (pIdx: number, tIdx: number) => {
+  const p = form.value.products[pIdx];
+  if (!p?.priceTiers) return;
+  p.priceTiers.splice(tIdx, 1);
+};
+
+const handleTierLetterUploadSuccess = (res: any, file: any, pIdx: number, tIdx: number) => {
+  if (res.code !== 200 || res.data?.ossId == null) {
+    ElMessage.error(res.msg || '上传失败');
+    return;
+  }
+  const p = form.value.products[pIdx];
+  const tiers = p?.priceTiers;
+  if (!tiers || !tiers[tIdx]) return;
+  const row = tiers[tIdx];
+  const oid = String(res.data.ossId);
+  row.letterOssIds = row.letterOssIds || [];
+  row.letterOssIds.push(oid);
+  row._letterFiles = row._letterFiles || [];
+  row._letterFiles.push({
+    name: file.name || `调价函${row._letterFiles.length + 1}`,
+    ossId: oid,
+    url: res.data.url,
+    uid: file.uid,
+    status: 'success'
+  });
+};
+
+const handleTierLetterRemove = (file: any, pIdx: number, tIdx: number) => {
+  const p = form.value.products[pIdx];
+  const tiers = p?.priceTiers;
+  if (!tiers || !tiers[tIdx]) return;
+  const row = tiers[tIdx];
+  const uid = file.uid;
+  const idx = (row._letterFiles || []).findIndex((f: any) => f.uid === uid);
+  if (idx >= 0) {
+    const removed = row._letterFiles[idx];
+    row._letterFiles.splice(idx, 1);
+    if (removed?.ossId) {
+      row.letterOssIds = (row.letterOssIds || []).filter((id: string) => String(id) !== String(removed.ossId));
+    }
+  }
+};
+
 /** 处理附件数据(支持分类) */
 const handleAttachmentData = async () => {
   console.log('[附件回显] 开始处理附件数据');
@@ -2056,35 +2331,7 @@ const handleSaveDraft = async () => {
           return;
         }
       }
-      data.products = data.products.map((product: any) => {
-        const productData: any = {
-          contractId: product.contractId || form.value.id, // 新增产品时,使用合同ID填充contractId
-          productId: product.productId,
-          productName: product.productName,
-          productNo: product.productNo,
-          categoryId: product.categoryId,
-          categoryName: product.categoryName,
-          model: product.productModel || product.model, // 前端productModel映射回后端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,
-          remark: product.remark
-        };
-        // 只有已存在的产品才包含id字段(新增产品没有id)
-        if (product.id) {
-          productData.id = product.id;
-        }
-        return productData;
-      });
+      data.products = buildProductsPayload();
     } else {
       // 如果产品列表为空,删除该字段,避免后端校验
       delete data.products;
@@ -2129,93 +2376,94 @@ const handleSubmit = async () => {
     }
   }
 
-  // 根据合同类型决定是否需要校验产品信息
-  console.log('当前合同类型:', form.value.contractType);
-  console.log('合同类型判断:', form.value.contractType === 'sale', form.value.contractType === 'purchase');
+  const requiresProducts = form.value.contractType === 'sale' || form.value.contractType === 'purchase' || form.value.contractType === 'framework';
 
-  const requiresProducts = form.value.contractType === 'sale' || form.value.contractType === 'purchase';
-  console.log('是否需要强制产品:', requiresProducts);
+  const validateProductRowCommon = (product: any, rowNum: number): boolean => {
+    if (!product.productName || String(product.productName).trim() === '') {
+      proxy?.$modal.msgWarning(`第${rowNum}行产品的产品名称不能为空`);
+      return false;
+    }
+    if (!product.quantity || Number(product.quantity) <= 0) {
+      proxy?.$modal.msgWarning(`第${rowNum}行产品的数量不能为空且必须大于0`);
+      return false;
+    }
+    if (!product.packageSpec || String(product.packageSpec).trim() === '') {
+      proxy?.$modal.msgWarning(`第${rowNum}行产品的包装规格不能为空`);
+      return false;
+    }
+    if (!product.deliveryMethod || String(product.deliveryMethod).trim() === '') {
+      proxy?.$modal.msgWarning(`第${rowNum}行产品的送货方式不能为空`);
+      return false;
+    }
+    return true;
+  };
 
   if (requiresProducts) {
-    // 销售合同和采购合同必须至少添加一个产品
     if (!form.value.products || form.value.products.length === 0) {
-      proxy?.$modal.msgWarning('销售合同和采购合同必须至少添加一个产品');
+      proxy?.$modal.msgWarning('销售合同、采购合同或框架合同必须至少添加一个产品');
       return;
     }
-
-    // 验证产品必填字段
     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() === '') {
-        proxy?.$modal.msgWarning(`第${rowNum}行产品的产品名称不能为空`);
-        return;
-      }
-
-      if (!product.quantity || product.quantity <= 0) {
-        proxy?.$modal.msgWarning(`第${rowNum}行产品的数量不能为空且必须大于0`);
-        return;
-      }
-
-      if (!product.price || product.price <= 0) {
-        proxy?.$modal.msgWarning(`第${rowNum}行产品的含税单价不能为空且必须大于0`);
+      if (!validateProductRowCommon(product, rowNum)) {
         return;
       }
-
-      if (!product.subtotal || product.subtotal <= 0) {
-        proxy?.$modal.msgWarning(`第${rowNum}行产品的含税总价不能为空且必须大于0`);
-        return;
-      }
-
-      if (!product.packageSpec || product.packageSpec.trim() === '') {
-        proxy?.$modal.msgWarning(`第${rowNum}行产品的包装规格不能为空`);
-        return;
-      }
-
-      if (!product.deliveryMethod || product.deliveryMethod.trim() === '') {
-        proxy?.$modal.msgWarning(`第${rowNum}行产品的送货方式不能为空`);
-        return;
-      }
-    }
-  } else {
-    // 其他合同类型不强制要求产品,但如果已填写产品,则验证产品字段的完整性
-    if (form.value.products && form.value.products.length > 0) {
-      for (let i = 0; i < form.value.products.length; i++) {
-        const product = form.value.products[i];
-        const rowNum = i + 1;
-
-        if (!product.productName || product.productName.trim() === '') {
-          proxy?.$modal.msgWarning(`第${rowNum}行产品的产品名称不能为空`);
-          return;
-        }
-
-        if (!product.quantity || product.quantity <= 0) {
-          proxy?.$modal.msgWarning(`第${rowNum}行产品的数量不能为空且必须大于0`);
-          return;
-        }
-
-        if (!product.price || product.price <= 0) {
+      const isFw = form.value.contractType === 'framework';
+      if (!isFw) {
+        if (!product.price || Number(product.price) <= 0) {
           proxy?.$modal.msgWarning(`第${rowNum}行产品的含税单价不能为空且必须大于0`);
           return;
         }
-
-        if (!product.subtotal || product.subtotal <= 0) {
+        if (!product.subtotal || Number(product.subtotal) <= 0) {
           proxy?.$modal.msgWarning(`第${rowNum}行产品的含税总价不能为空且必须大于0`);
           return;
         }
-
-        if (!product.packageSpec || product.packageSpec.trim() === '') {
-          proxy?.$modal.msgWarning(`第${rowNum}行产品的包装规格不能为空`);
+      } else {
+        const tiers = product.priceTiers || [];
+        if (tiers.length === 0) {
+          proxy?.$modal.msgWarning(`框架合同产品「${product.productName || rowNum}」至少添加一条调价区间`);
           return;
         }
-
-        if (!product.deliveryMethod || product.deliveryMethod.trim() === '') {
-          proxy?.$modal.msgWarning(`第${rowNum}行产品的送货方式不能为空`);
-          return;
+        for (let j = 0; j < tiers.length; j++) {
+          const t = tiers[j];
+          const tRow = j + 1;
+          if (!t.startDate || !t.endDate) {
+            proxy?.$modal.msgWarning(`产品「${product.productName}」调价区间第${tRow}行:请选择开始与结束日期`);
+            return;
+          }
+          if (String(t.startDate) > String(t.endDate)) {
+            proxy?.$modal.msgWarning(`产品「${product.productName}」调价区间第${tRow}行:开始日期不能晚于结束日期`);
+            return;
+          }
+          const pVal = t.price === null || t.price === '' ? NaN : Number(t.price);
+          if (Number.isNaN(pVal) || pVal <= 0) {
+            proxy?.$modal.msgWarning(`产品「${product.productName}」调价区间第${tRow}行:含税单价必须大于0`);
+            return;
+          }
+          if (!(t.letterOssIds?.length > 0)) {
+            proxy?.$modal.msgWarning(`产品「${product.productName}」调价区间第${tRow}行:请上传调价函`);
+            return;
+          }
         }
       }
     }
+  } else if (form.value.products && form.value.products.length > 0) {
+    for (let i = 0; i < form.value.products.length; i++) {
+      const product = form.value.products[i];
+      const rowNum = i + 1;
+      if (!validateProductRowCommon(product, rowNum)) {
+        return;
+      }
+      if (!product.price || Number(product.price) <= 0) {
+        proxy?.$modal.msgWarning(`第${rowNum}行产品的含税单价不能为空且必须大于0`);
+        return;
+      }
+      if (!product.subtotal || Number(product.subtotal) <= 0) {
+        proxy?.$modal.msgWarning(`第${rowNum}行产品的含税总价不能为空且必须大于0`);
+        return;
+      }
+    }
   }
 
   submitLoading.value = true;
@@ -2261,35 +2509,7 @@ const handleSubmit = async () => {
 
     // 清理产品列表中的扩展字段并映射回后端字段
     if (data.products && Array.isArray(data.products) && data.products.length > 0) {
-      data.products = data.products.map((product: any) => {
-        const productData: any = {
-          contractId: product.contractId || form.value.id, // 新增产品时,使用合同ID填充contractId
-          productId: product.productId,
-          productName: product.productName,
-          productNo: product.productNo,
-          categoryId: product.categoryId,
-          categoryName: product.categoryName,
-          model: product.productModel || product.model, // 前端productModel映射回后端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,
-          remark: product.remark
-        };
-        // 只有已存在的产品才包含id字段(新增产品没有id)
-        if (product.id) {
-          productData.id = product.id;
-        }
-        return productData;
-      });
+      data.products = buildProductsPayload();
     } else {
       // 如果产品列表为空,删除该字段,避免后端校验
       delete data.products;
@@ -2338,10 +2558,10 @@ const checkTemplateContentDeleted = () => {
     // 原始有模板内容,现在没有了,提示用户
     proxy?.$modal.alert(
       '<div style="color: #e6a23c; font-weight: bold;">⚠️ 提醒:合同模板内容已被清除</div>' +
-      '<div style="margin-top: 10px; line-height: 1.8;">' +
-      '您在编辑合同表单时,之前的富文本模板内容已被意外清除。<br/>' +
-      '下次打印合同时,需要重新编辑模板内容。' +
-      '</div>',
+        '<div style="margin-top: 10px; line-height: 1.8;">' +
+        '您在编辑合同表单时,之前的富文本模板内容已被意外清除。<br/>' +
+        '下次打印合同时,需要重新编辑模板内容。' +
+        '</div>',
       '温馨提示',
       {
         confirmButtonText: '知道了',
@@ -2718,4 +2938,29 @@ onMounted(async () => {
   background: transparent !important;
   border: none !important;
 }
+
+/* 框架合同调价函:表格内多图卡片上传 */
+.tier-letter-upload :deep(.el-upload-list--picture-card) {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+.tier-letter-upload :deep(.el-upload--picture-card) {
+  width: 72px;
+  height: 72px;
+  line-height: 72px;
+}
+.tier-letter-upload :deep(.el-upload-list--picture-card .el-upload-list__item) {
+  width: 72px;
+  height: 72px;
+  margin: 0;
+}
+
+/* 框架合同:增加「调价函」行内按钮可点区域宽度 */
+.fw-tier-letter-add-btn.el-button--primary.is-link {
+  min-width: 108px;
+  padding-left: 12px;
+  padding-right: 12px;
+  justify-content: center;
+}
 </style>

+ 162 - 66
crm/web/src/views/crm/contract/index.vue

@@ -228,36 +228,13 @@
           </el-col>
           <el-col :span="6">
             <el-button-group class="date-shortcut-group">
-              <el-button
-                :type="activeDateShortcut === 'all' ? 'primary' : 'default'"
-                @click="handleDateShortcut('all')"
-              >
-                全部
-              </el-button>
-              <el-button
-                :type="activeDateShortcut === 'yesterday' ? 'primary' : 'default'"
-                @click="handleDateShortcut('yesterday')"
-              >
+              <el-button :type="activeDateShortcut === 'all' ? 'primary' : 'default'" @click="handleDateShortcut('all')"> 全部 </el-button>
+              <el-button :type="activeDateShortcut === 'yesterday' ? 'primary' : 'default'" @click="handleDateShortcut('yesterday')">
                 昨日
               </el-button>
-              <el-button
-                :type="activeDateShortcut === 'today' ? 'primary' : 'default'"
-                @click="handleDateShortcut('today')"
-              >
-                今日
-              </el-button>
-              <el-button
-                :type="activeDateShortcut === 'tomorrow' ? 'primary' : 'default'"
-                @click="handleDateShortcut('tomorrow')"
-              >
-                明日
-              </el-button>
-              <el-button
-                :type="activeDateShortcut === 'week' ? 'primary' : 'default'"
-                @click="handleDateShortcut('week')"
-              >
-                本周
-              </el-button>
+              <el-button :type="activeDateShortcut === 'today' ? 'primary' : 'default'" @click="handleDateShortcut('today')"> 今日 </el-button>
+              <el-button :type="activeDateShortcut === 'tomorrow' ? 'primary' : 'default'" @click="handleDateShortcut('tomorrow')"> 明日 </el-button>
+              <el-button :type="activeDateShortcut === 'week' ? 'primary' : 'default'" @click="handleDateShortcut('week')"> 本周 </el-button>
             </el-button-group>
           </el-col>
           <el-col :span="2.5">
@@ -340,19 +317,20 @@
         <div class="toolbar-buttons">
           <el-button type="primary" icon="Plus" @click="handleAdd">新增</el-button>
           <!-- 提交审批按钮 - 仅在"我的合同"tab中显示 -->
+          <el-button v-if="activeView === 'my'" type="default" icon="Document" :disabled="!canSubmit" @click="handleSubmitBatch">提交审批</el-button>
+          <!-- 审批按钮 - 仅在待审批视图显示 -->
           <el-button
-            v-if="activeView === 'my'"
+            v-if="activeView === 'submitted'"
             type="default"
-            icon="Document"
-            :disabled="!canSubmit"
-            @click="handleSubmitBatch"
-            >提交审批</el-button
-          >
-          <!-- 审批按钮 - 仅在待审批视图显示 -->
-          <el-button v-if="activeView === 'submitted'" type="default" icon="Select" @click="handleApprove(contractList[contractList.findIndex((item) => item.id === ids[0])])"
+            icon="Select"
+            @click="handleApprove(contractList[contractList.findIndex((item) => item.id === ids[0])])"
             >审批通过</el-button
           >
-          <el-button v-if="activeView === 'submitted'" type="default" icon="Close" @click="handleReject(contractList[contractList.findIndex((item) => item.id === ids[0])])"
+          <el-button
+            v-if="activeView === 'submitted'"
+            type="default"
+            icon="Close"
+            @click="handleReject(contractList[contractList.findIndex((item) => item.id === ids[0])])"
             >审批驳回</el-button
           >
           <el-button type="default" icon="Upload" @click="handleUpload(contractList[contractList.findIndex((item) => item.id === ids[0])])"
@@ -892,16 +870,21 @@
                 </el-table-column>
                 <el-table-column label="数量" prop="quantity" width="110" align="right" />
                 <el-table-column label="含税单价/元" prop="price" width="130" align="right">
-                  <template #default="scope"> ¥{{ parseFloat(scope.row.price || 0).toFixed(2) }} </template>
+                  <template #default="scope">
+                    <span v-if="detailContract.contractType === 'framework'" class="text-muted">随行就市</span>
+                    <template v-else>¥{{ parseFloat(scope.row.price || 0).toFixed(2) }}</template>
+                  </template>
                 </el-table-column>
                 <el-table-column label="含税总价/元" prop="subtotal" width="130" align="right">
                   <template #default="scope">
-                    <span class="text-success font-weight-bold">¥{{ parseFloat(scope.row.subtotal || 0).toFixed(2) }}</span>
+                    <span v-if="detailContract.contractType === 'framework'" class="text-muted">随行就市</span>
+                    <span v-else class="text-success font-weight-bold">¥{{ parseFloat(scope.row.subtotal || 0).toFixed(2) }}</span>
                   </template>
                 </el-table-column>
                 <el-table-column label="税费/元" prop="taxAmount" width="120" align="right">
                   <template #default="scope">
-                    <span>¥{{ parseFloat(scope.row.taxAmount || 0).toFixed(2) }}</span>
+                    <span v-if="detailContract.contractType === 'framework'" class="text-muted">随行就市</span>
+                    <span v-else>¥{{ parseFloat(scope.row.taxAmount || 0).toFixed(2) }}</span>
                   </template>
                 </el-table-column>
                 <el-table-column label="包装规格" prop="packageSpec" min-width="150" :show-overflow-tooltip="true">
@@ -921,6 +904,43 @@
                 </el-table-column>
               </el-table>
 
+              <template v-if="detailContract.contractType === 'framework'">
+                <el-divider content-position="left">框架合同调价明细</el-divider>
+                <p class="text-muted" style="font-size: 13px; margin-bottom: 12px">按生效区间记录含税单价及调价函;后续结算可据此核对。</p>
+                <div
+                  v-for="(prod, pIdx) in detailContract.products || []"
+                  :key="'fw-tier-drawer-' + (prod.id ?? pIdx)"
+                  class="framework-tier-detail-block mb-4"
+                >
+                  <div class="fw-tier-detail-title">{{ prod.productName || '产品' + (pIdx + 1) }}</div>
+                  <el-table :data="prod.priceTiers || []" border size="small" empty-text="暂无调价记录">
+                    <el-table-column type="index" label="#" width="52" align="center" />
+                    <el-table-column label="开始日期" prop="startDate" min-width="120" align="center" />
+                    <el-table-column label="结束日期" prop="endDate" min-width="120" align="center" />
+                    <el-table-column label="含税单价/元" width="130" align="right">
+                      <template #default="scope">¥{{ parseFloat(String(scope.row.price ?? 0)).toFixed(2) }}</template>
+                    </el-table-column>
+                    <el-table-column label="调价函" min-width="260" align="center">
+                      <template #default="scope">
+                        <div v-if="detailTierLetterOssIds(scope.row).length" class="tier-letter-thumbs">
+                          <div
+                            v-for="(oid, li) in detailTierLetterOssIds(scope.row)"
+                            :key="'drawer-tier-letter-' + li + '-' + oid"
+                            class="tier-letter-thumb-wrap"
+                          >
+                            <ImagePreview :src="String(oid)" class="tier-letter-thumb-img" :isOssId="true" :width="72" :height="72" />
+                            <el-link type="primary" :underline="false" class="tier-letter-download-link" @click="handleDetailDownloadTierLetter(oid)">
+                              下载
+                            </el-link>
+                          </div>
+                        </div>
+                        <span v-else class="text-muted">-</span>
+                      </template>
+                    </el-table-column>
+                  </el-table>
+                </div>
+              </template>
+
               <!-- 合计金额 -->
               <el-divider />
               <div class="total-section">
@@ -1551,8 +1571,24 @@ const { proxy } = getCurrentInstance() as ComponentInternalInstance;
 const userStore = useUserStore();
 
 // 字典数据
-const { crm_contract_type, crm_contract_status, crm_approval_status, crm_product_unit, crm_invalid_reason, crm_contract_template, crm_settlement_method } = toRefs<any>(
-  proxy?.useDict('crm_contract_type', 'crm_contract_status', 'crm_approval_status', 'crm_product_unit', 'crm_invalid_reason', 'crm_contract_template', 'crm_settlement_method')
+const {
+  crm_contract_type,
+  crm_contract_status,
+  crm_approval_status,
+  crm_product_unit,
+  crm_invalid_reason,
+  crm_contract_template,
+  crm_settlement_method
+} = toRefs<any>(
+  proxy?.useDict(
+    'crm_contract_type',
+    'crm_contract_status',
+    'crm_approval_status',
+    'crm_product_unit',
+    'crm_invalid_reason',
+    'crm_contract_template',
+    'crm_settlement_method'
+  )
 );
 
 // 列配置相关
@@ -2909,6 +2945,27 @@ const getDetailShortFileName = (fileName: string) => {
   return name.substring(0, 12) + '...' + ext;
 };
 
+/** 详情抽屉 - 调价函 OSS ID 列表(兼容旧数据仅 letterOssId) */
+const detailTierLetterOssIds = (row: any): string[] => {
+  if (!row) return [];
+  if (Array.isArray(row.letterOssIds) && row.letterOssIds.length) {
+    return row.letterOssIds.map((id: any) => String(id));
+  }
+  if (row.letterOssId != null && row.letterOssId !== '') {
+    return [String(row.letterOssId)];
+  }
+  return [];
+};
+
+/** 详情抽屉 - 下载框架合同调价函 */
+const handleDetailDownloadTierLetter = (ossId: string | number | undefined) => {
+  if (ossId == null || ossId === '') {
+    proxy?.$modal.msgError('无法获取调价函文件');
+    return;
+  }
+  handleDetailDownloadFile({ ossId: String(ossId), name: '调价函' });
+};
+
 /** 详情抽屉 - 下载文件 */
 const handleDetailDownloadFile = async (file: any) => {
   if (!file.ossId) {
@@ -3079,9 +3136,7 @@ const replacePlaceholders = (htmlContent: string, contractData: ContractVO | und
   // 获取产品包装规格(去重)
   const getPackageSpecs = (): string => {
     if (!contractData.products || contractData.products.length === 0) return '';
-    const specs = contractData.products
-      .map((p) => p.packageSpec)
-      .filter((spec) => spec && spec.trim() !== '');
+    const specs = contractData.products.map((p) => p.packageSpec).filter((spec) => spec && spec.trim() !== '');
     const uniqueSpecs = [...new Set(specs)];
     return uniqueSpecs.join('、');
   };
@@ -3191,7 +3246,7 @@ const replacePlaceholders = (htmlContent: string, contractData: ContractVO | und
     '{{packageSpecs}}': packageSpecs || '散水包装',
 
     // 第五条结算方式内容(根据最新结算方式动态生成)
-    '{{SETTLEMENT_METHOD_CONTENT}}': getSettlementClauseContent(),
+    '{{SETTLEMENT_METHOD_CONTENT}}': getSettlementClauseContent()
   };
 
   // 替换普通占位符
@@ -3261,29 +3316,29 @@ const replaceFifthClauseContent = (htmlContent: string, replacement: string): st
   // 查找第五条标题
   const marker = '第五条、结算方式及期限';
   const titleIndex = htmlContent.indexOf(marker);
-  
+
   if (titleIndex === -1) {
     console.warn('未找到第五条标题');
     return htmlContent;
   }
-  
+
   // 找到标题后的内容 div(style 包含 line-height: 1.8; margin: 0;)
   const contentMarker = '<div style="line-height: 1.8; margin: 0;">';
   const contentStart = htmlContent.indexOf(contentMarker, titleIndex);
-  
+
   if (contentStart === -1) {
     console.warn('未找到第五条内容开始标记');
     return htmlContent;
   }
-  
+
   // 找到内容结束标记
   const contentEnd = htmlContent.indexOf('</div>', contentStart + contentMarker.length);
-  
+
   if (contentEnd === -1) {
     console.warn('未找到第五条内容结束标记');
     return htmlContent;
   }
-  
+
   // 替换内容部分
   return htmlContent.slice(0, contentStart + contentMarker.length) + replacement + htmlContent.slice(contentEnd);
 };
@@ -3341,7 +3396,7 @@ const generateContractTemplate = (contractData: ContractVO | undefined): string
   // 根据结算方式生成第五条内容
   // 使用占位符,方便后续动态替换
   const settlementMethod = contractData.settlementMethod || 'arrive_payment'; // 默认货到付款
-  let 第五条Content = '{{SETTLEMENT_METHOD_CONTENT}}'; // 占位符
+  const 第五条Content = '{{SETTLEMENT_METHOD_CONTENT}}'; // 占位符
   // 注意:实际内容在打印/生成PDF时通过 replacePlaceholders 动态替换
 
   // 数字转中文大写
@@ -3448,9 +3503,7 @@ const generateContractTemplate = (contractData: ContractVO | undefined): string
   // 获取产品包装规格(去重)
   const getPackageSpecs = (): string => {
     if (!contractData.products || contractData.products.length === 0) return '';
-    const specs = contractData.products
-      .map((p) => p.packageSpec)
-      .filter((spec) => spec && spec.trim() !== '');
+    const specs = contractData.products.map((p) => p.packageSpec).filter((spec) => spec && spec.trim() !== '');
     const uniqueSpecs = [...new Set(specs)];
     return uniqueSpecs.join('、');
   };
@@ -3677,10 +3730,7 @@ const handleSaveTemplate = async () => {
 
     // 重新获取最新合同数据
     const contractRes = await getContractDetail(currentContractId.value);
-    const latestContractData =
-      contractRes?.data ||
-      detailContract.value ||
-      contractList.value.find((item) => item.id === currentContractId.value);
+    const latestContractData = contractRes?.data || detailContract.value || contractList.value.find((item) => item.id === currentContractId.value);
     if (!latestContractData) {
       throw new Error('未获取到合同数据,无法保存模板');
     }
@@ -3706,7 +3756,9 @@ const handleSaveTemplate = async () => {
     finalTemplate = replaceFifthClauseContent(finalTemplate, '{{SETTLEMENT_METHOD_CONTENT}}');
 
     // 2. 替换产品表格为占位符
-    const hasOldProductTable = currentContent.includes('<tr>\n<td style="padding: 4px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">');
+    const hasOldProductTable = currentContent.includes(
+      '<tr>\n<td style="padding: 4px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">'
+    );
 
     if (hasOldProductTable || templateWithPlaceholders.includes('{{productTable}}')) {
       // 从新模板中提取产品表格相关部分
@@ -3725,7 +3777,10 @@ const handleSaveTemplate = async () => {
       // 替换产品表格部分
       if (productTableHtml) {
         // 找到 tbody 并替换其中的内容
-        finalTemplate = finalTemplate.replace(/<tbody>[\s\S]*?<\/tbody>/, `<tbody>${productTableHtml.replace('<tbody>', '').replace('</tbody>', '')}</tbody>`);
+        finalTemplate = finalTemplate.replace(
+          /<tbody>[\s\S]*?<\/tbody>/,
+          `<tbody>${productTableHtml.replace('<tbody>', '').replace('</tbody>', '')}</tbody>`
+        );
       }
 
       // 替换合计行(如果存在)
@@ -3830,7 +3885,7 @@ const replaceDynamicFields = (template: string, contractData: ContractVO): strin
     '{{partyBAgent}}': contractData.partyBAgent || '',
     '{{partyBPhoneFax}}': contractData.partyBPhoneFax || '',
     '{{partyBBank}}': contractData.partyBBank || '',
-    '{{partyBAccount}}': contractData.partyBAccount || '',
+    '{{partyBAccount}}': contractData.partyBAccount || ''
   };
 
   // 执行替换:将动态字段的当前值替换为占位符
@@ -3845,9 +3900,7 @@ const replaceDynamicFields = (template: string, contractData: ContractVO): strin
   }
 
   // 处理包装规格
-  const packageSpecs = contractData.products
-    ?.map((p) => p.packageSpec)
-    .filter((spec) => spec && spec.trim() !== '');
+  const packageSpecs = contractData.products?.map((p) => p.packageSpec).filter((spec) => spec && spec.trim() !== '');
   const uniqueSpecs = packageSpecs ? [...new Set(packageSpecs)].join('、') : '';
   const packageSpecsValue = uniqueSpecs || '散水包装';
   if (packageSpecsValue) {
@@ -4236,6 +4289,15 @@ onMounted(() => {
   color: #999;
 }
 
+.framework-tier-detail-block {
+  .fw-tier-detail-title {
+    font-weight: 600;
+    font-size: 14px;
+    margin-bottom: 8px;
+    color: var(--el-text-color-primary);
+  }
+}
+
 .font-weight-bold {
   font-weight: bold;
 }
@@ -4659,4 +4721,38 @@ onMounted(() => {
   padding: 12px 16px;
   background-color: #fafafa;
 }
+
+.tier-letter-thumbs {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+  justify-content: center;
+  align-items: flex-start;
+}
+.tier-letter-thumb-wrap {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 4px;
+}
+.tier-letter-thumb-wrap :deep(.el-image) {
+  width: 72px !important;
+  height: 72px !important;
+  flex-shrink: 0;
+  border-radius: 6px;
+  overflow: hidden;
+  border: 1px solid var(--el-border-color-lighter);
+  background-color: var(--el-fill-color-light);
+}
+.tier-letter-thumb-wrap :deep(.el-image__inner) {
+  width: 100% !important;
+  height: 100% !important;
+  object-fit: cover;
+}
+.tier-letter-thumb-img {
+  display: block;
+}
+.tier-letter-download-link {
+  font-size: 12px;
+}
 </style>